from __future__ import annotations
import ctypes
import json
import logging
import os
import platform
import sys
import threading
import time
import webbrowser
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
APP_NAME = "微信语音发送器"
APP_NAME_EN = "WeChat Voice Sender"
APP_VERSION = "0.4.1"
SUPPORTED_SUFFIXES = {".wav", ".mp3", ".ogg", ".flac"}
MAX_AUDIO_SECONDS = 55.0
VK_F8 = 0x77
VK_F9 = 0x78
HOTKEY_POLL_MS = 30
PLAYBACK_CHUNK_SECONDS = 0.02
VB_CABLE_URL = "https://vb-audio.com/Cable/"
def app_data_dir() -> Path:
base = Path(os.environ.get("LOCALAPPDATA", Path.home()))
path = base / "WeChatVoiceSender"
path.mkdir(parents=True, exist_ok=True)
return path
APP_DATA_DIR = app_data_dir()
LOG_PATH = APP_DATA_DIR / "voice_sender.log"
SETTINGS_PATH = APP_DATA_DIR / "settings.json"
def resource_path(relative: str) -> Path:
bundle_dir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
return bundle_dir / relative
def setup_windows_dpi() -> None:
if platform.system() != "Windows":
return
try:
ctypes.windll.shcore.SetProcessDpiAwareness(2)
except Exception:
try:
ctypes.windll.user32.SetProcessDPIAware()
except Exception:
pass
def setup_logging() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
handlers=[logging.FileHandler(LOG_PATH, encoding="utf-8")],
force=True,
)
@dataclass(frozen=True)
class AudioEnvironment:
cable_playback_index: int
cable_playback_name: str
cable_recording_index: int
cable_recording_name: str
@dataclass(frozen=True)
class AudioSelection:
path: Path
duration: float
def load_dependencies():
if platform.system() != "Windows":
raise RuntimeError("此程序只能在 Windows 10/11 上运行。")
try:
import sounddevice as sd
import soundfile as sf
except ImportError as exc:
if getattr(sys, "frozen", False):
raise RuntimeError("程序组件不完整,请重新下载独立版。") from exc
raise RuntimeError("依赖未安装,请运行“启动源码版.bat”。") from exc
return sd, sf
def check_environment() -> tuple[AudioEnvironment, object, object]:
sd, sf = load_dependencies()
playback: list[tuple[int, str]] = []
recording: list[tuple[int, str]] = []
for index, device in enumerate(sd.query_devices()):
name = str(device.get("name", ""))
lowered = name.lower()
if device.get("max_output_channels", 0) > 0 and "cable input" in lowered:
playback.append((index, name))
if device.get("max_input_channels", 0) > 0 and "cable output" in lowered:
recording.append((index, name))
if not playback or not recording:
raise RuntimeError("未检测到 VB-CABLE,请安装驱动并重启电脑。")
environment = AudioEnvironment(
cable_playback_index=playback[0][0],
cable_playback_name=playback[0][1],
cable_recording_index=recording[0][0],
cable_recording_name=recording[0][1],
)
return environment, sd, sf
def validate_audio(audio_path: str, sf) -> AudioSelection:
if not audio_path.strip():
raise ValueError("请先选择本地音频文件。")
path = Path(os.path.expandvars(os.path.expanduser(audio_path))).resolve()
if not path.is_file():
raise ValueError("请选择存在的本地音频文件。")
if path.suffix.lower() not in SUPPORTED_SUFFIXES:
raise ValueError("当前支持 WAV、MP3、OGG、FLAC 音频。")
try:
info = sf.info(str(path))
duration = float(info.frames) / float(info.samplerate)
except Exception as exc:
raise ValueError(f"无法读取音频文件:{exc}") from exc
if duration <= 0:
raise ValueError("音频文件为空。")
if duration > MAX_AUDIO_SECONDS:
raise ValueError(
f"音频时长为 {duration:.1f} 秒,当前上限为 {MAX_AUDIO_SECONDS:.0f} 秒。"
"请先裁剪,避免触发微信录音时长限制。"
)
return AudioSelection(path=path, duration=duration)
def load_audio(audio_path: Path, sf):
try:
audio, samplerate = sf.read(str(audio_path), dtype="float32", always_2d=True)
except Exception as exc:
raise RuntimeError(f"无法解码音频文件:{exc}") from exc
if samplerate <= 0 or len(audio) == 0:
raise RuntimeError("音频文件为空或采样率无效。")
if audio.shape[1] > 2:
audio = audio[:, :2]
return audio, samplerate
def play_to_cable(
selection: AudioSelection,
status: Callable[[str], None],
progress: Callable[[float], None],
stop_event: threading.Event,
) -> bool:
environment, sd, sf = check_environment()
audio, samplerate = load_audio(selection.path, sf)
interrupted = False
total_frames = len(audio)
elapsed_tick = 1
try:
status("正在向 VB-CABLE 播放;需要提前结束时按 F9。")
status("播放计时:0 秒")
chunk_frames = max(256, int(samplerate * PLAYBACK_CHUNK_SECONDS))
with sd.OutputStream(
samplerate=samplerate,
device=environment.cable_playback_index,
channels=audio.shape[1],
dtype="float32",
) as stream:
for start in range(0, total_frames, chunk_frames):
if stop_event.is_set():
interrupted = True
break
end = min(start + chunk_frames, total_frames)
stream.write(audio[start:end])
elapsed_seconds = end / samplerate
while elapsed_seconds >= elapsed_tick:
status(f"播放计时:{elapsed_tick} 秒")
elapsed_tick += 1
progress(end / total_frames)
time.sleep(0.12)
if interrupted:
status("已提前结束。请返回微信确认试听,并手动点击发送。")
else:
progress(1.0)
status(f"播放计时结束:{selection.duration:.1f} 秒")
status("播放完成。请返回微信确认试听,并手动点击发送。")
try:
import winsound
winsound.MessageBeep(winsound.MB_OK)
except Exception:
pass
return interrupted
except Exception:
try:
sd.stop()
except Exception:
pass
raise
class VoicePlayerApp:
COLORS = {
"blue": "#0F6CBD",
"blue_hover": "#115EA3",
"green": "#107C10",
"red": "#C42B1C",
}
def __init__(self) -> None:
import customtkinter as ctk
import tkinter as tk
from tkinter import filedialog, messagebox
self.ctk = ctk
self.tk = tk
self.filedialog = filedialog
self.messagebox = messagebox
self.worker: threading.Thread | None = None
self.stop_event = threading.Event()
self.waiting_for_f8 = False
self.pending_audio: AudioSelection | None = None
self.playback_duration = 0.0
self.f8_was_down = False
self.f9_was_down = False
self.last_folder = ""
settings = self.load_settings()
theme = str(settings.get("theme", "System"))
if theme not in {"System", "Light", "Dark"}:
theme = "System"
self.last_folder = str(settings.get("last_folder", ""))
ctk.set_appearance_mode(theme)
ctk.set_default_color_theme("blue")
self.root = ctk.CTk()
self.root.title(f"{APP_NAME} · v{APP_VERSION}")
self.root.geometry("960x680")
self.root.minsize(900, 640)
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
self.root.grid_columnconfigure(0, weight=1)
self.root.grid_rowconfigure(1, weight=1)
icon_path = resource_path("assets/app_icon.ico")
if icon_path.exists():
try:
self.root.iconbitmap(str(icon_path))
except Exception:
pass
self.audio_var = tk.StringVar()
self.theme_var = tk.StringVar(value=theme)
self.file_name_var = tk.StringVar(value="尚未选择音频")
self.file_meta_var = tk.StringVar(value="支持 WAV / MP3 / OGG / FLAC,最长 55 秒")
self.device_var = tk.StringVar(value="尚未检测 VB-CABLE")
self.progress_text_var = tk.StringVar(value="等待选择音频")
self.build_header()
self.build_content()
self.root.after(120, self.apply_windows_style)
self.root.after(HOTKEY_POLL_MS, self.poll_hotkeys)
self.append_status("程序已启动。选择音频后先检测 VB-CABLE。")
def build_header(self) -> None:
ctk = self.ctk
header = ctk.CTkFrame(self.root, fg_color="transparent", corner_radius=0)
header.grid(row=0, column=0, sticky="ew", padx=28, pady=(22, 12))
header.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(
header,
text="W",
width=44,
height=44,
corner_radius=12,
fg_color=self.COLORS["blue"],
text_color="white",
font=ctk.CTkFont(family="Segoe UI Variable Display", size=21, weight="bold"),
).grid(row=0, column=0, rowspan=2, padx=(0, 13))
ctk.CTkLabel(
header,
text=APP_NAME,
anchor="w",
font=ctk.CTkFont(family="Segoe UI Variable Display", size=24, weight="bold"),
).grid(row=0, column=1, sticky="sw")
ctk.CTkLabel(
header,
text="把本地音频送入微信语音输入 · 手动确认发送",
anchor="w",
text_color=("#5F5F5F", "#B8B8B8"),
font=ctk.CTkFont(family="Microsoft YaHei UI", size=12),
).grid(row=1, column=1, sticky="nw")
ctk.CTkLabel(
header,
text=f"v{APP_VERSION} 独立版",
height=28,
corner_radius=14,
fg_color=("#E5F1FB", "#14344A"),
text_color=("#0F6CBD", "#62B8F6"),
font=ctk.CTkFont(family="Segoe UI", size=11, weight="bold"),
).grid(row=0, column=2, rowspan=2, padx=(8, 12))
self.theme_menu = ctk.CTkOptionMenu(
header,
values=["System", "Light", "Dark"],
variable=self.theme_var,
command=self.change_theme,
width=104,
height=32,
corner_radius=8,
fg_color=("#F0F0F0", "#303030"),
button_color=("#E4E4E4", "#3A3A3A"),
button_hover_color=("#D8D8D8", "#464646"),
text_color=("#202020", "#F4F4F4"),
)
self.theme_menu.grid(row=0, column=3, rowspan=2)
def build_content(self) -> None:
ctk = self.ctk
body = ctk.CTkFrame(self.root, fg_color="transparent")
body.grid(row=1, column=0, sticky="nsew", padx=28, pady=(0, 24))
body.grid_columnconfigure(0, weight=3, uniform="body")
body.grid_columnconfigure(1, weight=2, uniform="body")
body.grid_rowconfigure(0, weight=1)
left = ctk.CTkFrame(
body, corner_radius=16, border_width=1,
border_color=("#E3E3E3", "#363636"), fg_color=("#FFFFFF", "#242424")
)
left.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
left.grid_columnconfigure(0, weight=1)
left.grid_rowconfigure(7, weight=1)
ctk.CTkLabel(
left, text="音频与控制", anchor="w",
font=ctk.CTkFont(family="Microsoft YaHei UI", size=17, weight="bold")
).grid(row=0, column=0, sticky="ew", padx=22, pady=(20, 4))
ctk.CTkLabel(
left, text="选择一段音频,程序会将它播放到 CABLE Input。", anchor="w",
text_color=("#666666", "#B7B7B7"),
font=ctk.CTkFont(family="Microsoft YaHei UI", size=12)
).grid(row=1, column=0, sticky="ew", padx=22, pady=(0, 16))
picker = ctk.CTkFrame(left, fg_color=("#F7F7F7", "#2D2D2D"), corner_radius=12)
picker.grid(row=2, column=0, sticky="ew", padx=22)
picker.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(
picker, textvariable=self.file_name_var, anchor="w",
font=ctk.CTkFont(family="Microsoft YaHei UI", size=14, weight="bold")
).grid(row=0, column=0, sticky="ew", padx=(16, 8), pady=(13, 2))
ctk.CTkLabel(
picker, textvariable=self.file_meta_var, anchor="w",
text_color=("#6A6A6A", "#B5B5B5"),
font=ctk.CTkFont(family="Microsoft YaHei UI", size=11)
).grid(row=1, column=0, sticky="ew", padx=(16, 8), pady=(0, 13))
ctk.CTkButton(
picker, text="选择音频…", command=self.choose_audio, width=105, height=34,
corner_radius=8, fg_color=self.COLORS["blue"], hover_color=self.COLORS["blue_hover"],
font=ctk.CTkFont(family="Microsoft YaHei UI", size=12, weight="bold")
).grid(row=0, column=1, rowspan=2, padx=(6, 14), pady=14)
self.path_entry = ctk.CTkEntry(
left, textvariable=self.audio_var, height=36, corner_radius=8,
placeholder_text="也可以粘贴完整音频路径",
border_color=("#D2D2D2", "#484848"),
font=ctk.CTkFont(family="Segoe UI", size=11)
)
self.path_entry.grid(row=3, column=0, sticky="ew", padx=22, pady=(10, 16))
self.path_entry.bind("<FocusOut>", lambda _event: self.inspect_selected_audio(False))
device_row = ctk.CTkFrame(left, fg_color="transparent")
device_row.grid(row=4, column=0, sticky="ew", padx=22)
device_row.grid_columnconfigure(0, weight=1)
self.device_label = ctk.CTkLabel(
device_row, textvariable=self.device_var, anchor="w",
text_color=("#666666", "#B7B7B7"),
font=ctk.CTkFont(family="Microsoft YaHei UI", size=11)
)
self.device_label.grid(row=0, column=0, sticky="ew")
ctk.CTkButton(
device_row, text="检测设备", command=self.detect_environment,
width=92, height=30, corner_radius=8,
fg_color=("#EAEAEA", "#343434"), hover_color=("#DDDDDD", "#414141"),
text_color=("#202020", "#F4F4F4")
).grid(row=0, column=1, padx=(8, 0))
self.progress_bar = ctk.CTkProgressBar(
left, height=8, corner_radius=4, progress_color=self.COLORS["blue"],
fg_color=("#EAEAEA", "#383838")
)
self.progress_bar.grid(row=5, column=0, sticky="ew", padx=22, pady=(20, 5))
self.progress_bar.set(0)
ctk.CTkLabel(
left, textvariable=self.progress_text_var, anchor="w",
text_color=("#5F5F5F", "#BCBCBC"),
font=ctk.CTkFont(family="Microsoft YaHei UI", size=11)
).grid(row=6, column=0, sticky="ew", padx=22)
self.status_text = ctk.CTkTextbox(
left, height=126, corner_radius=10, border_width=1,
border_color=("#E4E4E4", "#3A3A3A"), fg_color=("#FAFAFA", "#1E1E1E"),
font=ctk.CTkFont(family="Microsoft YaHei UI", size=11), wrap="word"
)
self.status_text.grid(row=7, column=0, sticky="nsew", padx=22, pady=(13, 14))
self.status_text.configure(state="disabled")
controls = ctk.CTkFrame(left, fg_color="transparent")
controls.grid(row=8, column=0, sticky="ew", padx=22, pady=(0, 20))
controls.grid_columnconfigure(0, weight=1)
self.play_button = ctk.CTkButton(
controls, text="进入等待状态 · F8 播放", command=self.arm_f8_playback,
height=42, corner_radius=10, fg_color=self.COLORS["blue"],
hover_color=self.COLORS["blue_hover"],
font=ctk.CTkFont(family="Microsoft YaHei UI", size=13, weight="bold")
)
self.play_button.grid(row=0, column=0, sticky="ew")
self.cancel_button = ctk.CTkButton(
controls, text="取消等待", command=self.cancel_waiting, width=98, height=42,
corner_radius=10, state="disabled", fg_color=("#E8E8E8", "#353535"),
hover_color=("#DADADA", "#424242"), text_color=("#202020", "#F4F4F4")
)
self.cancel_button.grid(row=0, column=1, padx=(9, 0))
self.stop_button = ctk.CTkButton(
controls, text="停止 F9", command=self.request_stop_playback, width=92, height=42,
corner_radius=10, state="disabled", fg_color=self.COLORS["red"], hover_color="#A4262C"
)
self.stop_button.grid(row=0, column=2, padx=(9, 0))
right = ctk.CTkFrame(
body, corner_radius=16, border_width=1,
border_color=("#E3E3E3", "#363636"), fg_color=("#FFFFFF", "#242424")
)
right.grid(row=0, column=1, sticky="nsew", padx=(10, 0))
right.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(
right, text="发送流程", anchor="w",
font=ctk.CTkFont(family="Microsoft YaHei UI", size=17, weight="bold")
).grid(row=0, column=0, sticky="ew", padx=22, pady=(20, 4))
ctk.CTkLabel(
right, text="全程由你确认,不读取微信数据。", anchor="w",
text_color=("#666666", "#B7B7B7"),
font=ctk.CTkFont(family="Microsoft YaHei UI", size=12)
).grid(row=1, column=0, sticky="ew", padx=22, pady=(0, 14))
steps = [
("1", "准备微信", "打开正确好友或群聊,点击微信的语音输入。"),
("2", "开始播放", "本软件进入等待后,在微信录音界面按 F8。"),
("3", "确认发送", "听到提示音后,返回微信手动点击绿色发送箭头。"),
]
for row, (number, title, detail) in enumerate(steps, start=2):
self.add_step_card(right, row, number, title, detail)
tip = ctk.CTkFrame(right, fg_color=("#F5F9FD", "#172B3A"), corner_radius=12)
tip.grid(row=5, column=0, sticky="ew", padx=22, pady=(16, 10))
ctk.CTkLabel(
tip, text="快捷键", anchor="w", text_color=("#0F6CBD", "#62B8F6"),
font=ctk.CTkFont(family="Microsoft YaHei UI", size=12, weight="bold")
).grid(row=0, column=0, sticky="ew", padx=14, pady=(12, 4))
ctk.CTkLabel(
tip, text="F8 开始播放 F9 提前结束", anchor="w",
font=ctk.CTkFont(family="Segoe UI", size=12)
).grid(row=1, column=0, sticky="ew", padx=14, pady=(0, 12))
links = ctk.CTkFrame(right, fg_color="transparent")
links.grid(row=6, column=0, sticky="ew", padx=22, pady=(4, 18))
links.grid_columnconfigure(0, weight=1)
links.grid_columnconfigure(1, weight=1)
ctk.CTkButton(
links, text="VB-CABLE 官网", command=lambda: webbrowser.open(VB_CABLE_URL),
height=34, corner_radius=8, fg_color="transparent",
hover_color=("#EEEEEE", "#333333"), text_color=("#0F6CBD", "#62B8F6"),
border_width=1, border_color=("#D6D6D6", "#484848")
).grid(row=0, column=0, sticky="ew", padx=(0, 5))
ctk.CTkButton(
links, text="打开日志目录", command=self.open_log_folder,
height=34, corner_radius=8, fg_color="transparent",
hover_color=("#EEEEEE", "#333333"), text_color=("#202020", "#F4F4F4"),
border_width=1, border_color=("#D6D6D6", "#484848")
).grid(row=0, column=1, sticky="ew", padx=(5, 0))
def add_step_card(self, parent, row: int, number: str, title: str, detail: str) -> None:
ctk = self.ctk
card = ctk.CTkFrame(parent, fg_color=("#F7F7F7", "#2D2D2D"), corner_radius=12)
card.grid(row=row, column=0, sticky="ew", padx=22, pady=5)
card.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(
card, text=number, width=30, height=30, corner_radius=15,
fg_color=("#E5F1FB", "#14344A"), text_color=("#0F6CBD", "#62B8F6"),
font=ctk.CTkFont(family="Segoe UI", size=12, weight="bold")
).grid(row=0, column=0, rowspan=2, padx=(13, 11), pady=13)
ctk.CTkLabel(
card, text=title, anchor="w",
font=ctk.CTkFont(family="Microsoft YaHei UI", size=12, weight="bold")
).grid(row=0, column=1, sticky="sew", padx=(0, 12), pady=(11, 1))
ctk.CTkLabel(
card, text=detail, anchor="w", justify="left", wraplength=260,
text_color=("#666666", "#B7B7B7"),
font=ctk.CTkFont(family="Microsoft YaHei UI", size=10)
).grid(row=1, column=1, sticky="new", padx=(0, 12), pady=(1, 11))
def append_status(self, text: str) -> None:
logging.info(text)
def update() -> None:
stamp = time.strftime("%H:%M:%S")
self.status_text.configure(state="normal")
self.status_text.insert("end", f"{stamp} {text}\n")
self.status_text.see("end")
self.status_text.configure(state="disabled")
self.root.after(0, update)
def update_progress(self, value: float) -> None:
value = min(1.0, max(0.0, float(value)))
duration = self.playback_duration
elapsed = min(duration, value * duration)
def update() -> None:
self.progress_bar.set(value)
self.progress_text_var.set(
f"正在播放 {elapsed:.1f} / {duration:.1f} 秒 · {round(value * 100):d}%"
)
self.root.after(0, update)
def choose_audio(self) -> None:
initial_dir = self.last_folder if Path(self.last_folder).is_dir() else None
path = self.filedialog.askopenfilename(
title="选择本地音频", initialdir=initial_dir,
filetypes=[("支持的音频", "*.wav *.mp3 *.ogg *.flac"), ("所有文件", "*.*")]
)
if not path:
return
self.audio_var.set(path)
self.last_folder = str(Path(path).parent)
self.save_settings()
self.inspect_selected_audio(show_error=True)
def inspect_selected_audio(self, show_error: bool = True) -> AudioSelection | None:
if not self.audio_var.get().strip():
return None
try:
_sd, sf = load_dependencies()
selection = validate_audio(self.audio_var.get(), sf)
self.file_name_var.set(selection.path.name)
size_mb = selection.path.stat().st_size / (1024 * 1024)
self.file_meta_var.set(
f"{selection.duration:.1f} 秒 · {size_mb:.2f} MB · {selection.path.suffix[1:].upper()}"
)
self.progress_text_var.set("音频已就绪")
return selection
except Exception as exc:
self.file_name_var.set("音频不可用")
self.file_meta_var.set(str(exc))
if show_error:
self.messagebox.showerror(APP_NAME, str(exc))
return None
def detect_environment(self) -> bool:
try:
environment, _sd, _sf = check_environment()
self.device_var.set(f"已连接 · {environment.cable_playback_name}")
self.device_label.configure(text_color=(self.COLORS["green"], "#6CCB5F"))
self.append_status(f"VB-CABLE 检测通过:{environment.cable_playback_name}")
return True
except Exception as exc:
logging.exception("环境检测失败")
self.device_var.set("VB-CABLE 未就绪")
self.device_label.configure(text_color=(self.COLORS["red"], "#FF8177"))
self.append_status(f"环境检测失败:{exc}")
self.messagebox.showerror(
APP_NAME,
f"{exc}\n\n请安装 VB-CABLE、重启电脑,并将 CABLE Output 设为默认录音设备。"
)
return False
def arm_f8_playback(self) -> None:
if self.worker and self.worker.is_alive():
self.messagebox.showwarning(APP_NAME, "音频正在播放。")
return
if self.waiting_for_f8:
self.messagebox.showwarning(APP_NAME, "程序已经在等待 F8。")
return
selection = self.inspect_selected_audio(show_error=True)
if selection is None or not self.detect_environment():
return
confirmed = self.messagebox.askyesno(
"准备发送语音",
f"音频:{selection.path.name}\n时长:{selection.duration:.1f} 秒\n\n"
"确认后窗口保持打开并等待 F8。\n"
"请在微信点击“语音输入”,再按 F8 开始播放。"
)
if not confirmed:
return
self.pending_audio = selection
self.waiting_for_f8 = True
self.f8_was_down = self.is_key_down(VK_F8)
self.play_button.configure(state="disabled")
self.cancel_button.configure(state="normal")
self.progress_bar.set(0)
self.progress_text_var.set("等待 F8 · 窗口保持打开")
self.append_status(f"已进入等待状态:{selection.path.name};窗口保持打开。")
@staticmethod
def is_key_down(virtual_key: int) -> bool:
if platform.system() != "Windows":
return False
return bool(ctypes.windll.user32.GetAsyncKeyState(virtual_key) & 0x8000)
def poll_hotkeys(self) -> None:
try:
if self.waiting_for_f8:
is_down = self.is_key_down(VK_F8)
if is_down and not self.f8_was_down:
self.start_playback_from_f8()
self.f8_was_down = is_down
else:
self.f8_was_down = False
if self.worker and self.worker.is_alive():
is_down = self.is_key_down(VK_F9)
if is_down and not self.f9_was_down:
self.request_stop_playback()
self.f9_was_down = is_down
else:
self.f9_was_down = False
except Exception as exc:
logging.exception("快捷键检测失败")
self.cancel_waiting()
self.messagebox.showerror(APP_NAME, f"无法检测 F8/F9:{exc}")
finally:
self.root.after(HOTKEY_POLL_MS, self.poll_hotkeys)
def cancel_waiting(self) -> None:
if not self.waiting_for_f8:
return
self.waiting_for_f8 = False
self.pending_audio = None
self.play_button.configure(state="normal")
self.cancel_button.configure(state="disabled")
self.progress_text_var.set("已取消等待")
self.append_status("已取消等待 F8,没有播放音频。")
def start_playback_from_f8(self) -> None:
selection = self.pending_audio
if not self.waiting_for_f8 or selection is None:
return
self.waiting_for_f8 = False
self.pending_audio = None
self.cancel_button.configure(state="disabled")
self.stop_event.clear()
self.playback_duration = selection.duration
self.f9_was_down = self.is_key_down(VK_F9)
self.stop_button.configure(state="normal")
self.progress_text_var.set(
f"正在播放 0.0 / {selection.duration:.1f} 秒 · 0%"
)
self.append_status("检测到 F8,开始播放。")
def task() -> None:
try:
interrupted = play_to_cable(
selection, self.append_status, self.update_progress, self.stop_event
)
text = "已提前结束 · 返回微信确认" if interrupted else "播放完成 · 返回微信确认"
self.root.after(0, lambda value=text: self.progress_text_var.set(value))
except Exception as exc:
logging.exception("音频播放失败")
self.append_status(f"播放失败:{exc}")
message = str(exc)
self.root.after(
0,
lambda error_message=message: self.messagebox.showerror(
APP_NAME, f"{error_message}\n\n诊断日志:\n{LOG_PATH}"
)
)
finally:
self.root.after(0, self.reset_controls_after_playback)
self.worker = threading.Thread(target=task, daemon=True)
self.worker.start()
def reset_controls_after_playback(self) -> None:
self.play_button.configure(state="normal")
self.stop_button.configure(state="disabled")
def request_stop_playback(self) -> None:
if not self.worker or not self.worker.is_alive() or self.stop_event.is_set():
return
self.stop_event.set()
self.stop_button.configure(state="disabled")
self.append_status("检测到 F9,正在提前结束……")
def change_theme(self, value: str) -> None:
self.ctk.set_appearance_mode(value)
self.save_settings()
self.root.after(80, self.apply_windows_style)
def apply_windows_style(self) -> None:
if platform.system() != "Windows":
return
try:
self.root.update_idletasks()
hwnd = self.root.winfo_id()
dark = ctypes.c_int(1 if self.ctk.get_appearance_mode() == "Dark" else 0)
corner = ctypes.c_int(2)
ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, 20, ctypes.byref(dark), 4)
ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, 33, ctypes.byref(corner), 4)
except Exception:
pass
def open_log_folder(self) -> None:
try:
os.startfile(APP_DATA_DIR) # type: ignore[attr-defined]
except Exception as exc:
self.messagebox.showerror(APP_NAME, f"无法打开日志目录:{exc}")
def load_settings(self) -> dict:
try:
if SETTINGS_PATH.is_file():
return json.loads(SETTINGS_PATH.read_text(encoding="utf-8"))
except Exception:
logging.exception("读取设置失败")
return {}
def save_settings(self) -> None:
try:
SETTINGS_PATH.write_text(
json.dumps(
{"theme": self.theme_var.get(), "last_folder": self.last_folder},
ensure_ascii=False, indent=2
),
encoding="utf-8"
)
except Exception:
logging.exception("保存设置失败")
def on_close(self) -> None:
if self.worker and self.worker.is_alive():
if not self.messagebox.askyesno(APP_NAME, "音频仍在播放,确定退出吗?"):
return
self.stop_event.set()
self.save_settings()
self.root.destroy()
def run(self) -> None:
self.root.mainloop()
def main() -> int:
setup_windows_dpi()
setup_logging()
try:
VoicePlayerApp().run()
except Exception as exc:
logging.exception("程序启动失败")
try:
import tkinter.messagebox as messagebox
messagebox.showerror(APP_NAME_EN, f"程序启动失败:{exc}\n\n日志:{LOG_PATH}")
except Exception:
pass
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())