From 3b0fd719d96801790d85de1ebb1d1fa2fab93873 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Thu, 13 Aug 2026 22:33:27 +0800 Subject: [PATCH] fixed a bug,spliting sentence --- README.md | 11 ++- media_player.py | 208 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 213 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c836dd2..7db7401 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ pip 安装依赖后即可直接播放视频。 - 播放 / 暂停 / 停止、上一首 / 下一首 - 前进 / 后退(步长可自定义)与进度条点击拖动定位 - 快进步长可在界面底部直接填写(1-600 秒),自动保存、下次启动自动加载 +- 按句跳转:勾选后 `←` / `→` 自动播放上一句/下一句(基于音频静音自动识别 + 句边界,无需联网,分析结果自动缓存) - 音量调节、静音,0.5x - 2.0x 变速播放 - 三种循环模式:关 / 单曲循环 / 列表循环 - 播放列表管理(打开文件、打开文件夹、删除条目、双击播放) @@ -66,7 +68,7 @@ pip install -r requirements.txt | 快捷键 | 功能 | | --- | --- | | `Space` | 播放 / 暂停 | -| `←` / `→` | 后退 / 前进 10 秒 | +| `←` / `→` | 后退 / 前进(步长可自定义;勾选“按句跳转”后按句子跳转) | | `Shift+←` / `Shift+→` | 后退 / 前进 60 秒 | | `↑` / `↓` | 音量 +5 / -5 | | `Ctrl+↑` / `Ctrl+↓` | 快进步长 +1 / -1 秒(macOS 亦支持 `Command+↑ / ↓`) | @@ -91,6 +93,13 @@ pip install -r requirements.txt 60 秒),输入后按回车或点击其他位置即生效并自动保存(配置文件:用户主目录下的 `.mediaplayer_settings.json`)。 +勾选“按句跳转”后,`←` / `→` 不再按固定秒数跳转,而是自动跳到上一句 / 下一句 +的开头:程序会分析音频中的静音停顿来自动切分句子(适合听写、跟读等场景)。 +静音阈值会根据每个文件的背景噪声自动调整,带环境噪音的录音也能正常分句。 +分析在后台进行,首次播放某文件时状态栏会提示“正在分析句边界…”,完成后 +自动缓存;无法识别(如纯音乐、无音频轨)时会提示并回退为普通步进跳转。 +“按句跳转”的开关状态同样会自动保存。 + ## 常见问题 - **状态栏显示“纯 Python 解码内核”**:说明未检测到 VLC,程序正在使用内置的 diff --git a/media_player.py b/media_player.py index 4e1b914..e72ec7a 100644 --- a/media_player.py +++ b/media_player.py @@ -13,6 +13,7 @@ Shift+← / → 后退 / 前进 60 秒 ↑ / ↓ 音量 +5 / -5 Ctrl+↑ / ↓ 快进步长 +1 / -1 秒(macOS 亦支持 Command+↑ / ↓) + 勾选“按句跳转”后,←/→ 自动播放上一句/下一句(基于音频静音识别句边界) M 静音 / 恢复 Ctrl+← / → 上一首 / 下一首(macOS 也支持 Command+← / →) N / P 下一首 / 上一首 @@ -70,6 +71,9 @@ LOOP_LABELS = {"off": "循环: 关", "one": "循环: 单曲", "list": "循环: SPEED_PRESETS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] SEEK_STEP = 10 SEEK_STEP_BIG = 60 +SILENCE_FLOOR_PERCENTILE = 10 # 用能量低分位估计背景噪声底 +SILENCE_THRESHOLD_FACTOR = 1.5 # 静音阈值 = 噪声底 × 系数 +SILENCE_BRIDGE_GAP = 0.06 # 停顿中 60ms 内的短促残响/噪声也按静音处理 def fmt_time(ms): @@ -84,6 +88,96 @@ def fmt_time(ms): return f"{minutes:02d}:{seconds:02d}" +def detect_sentence_boundaries(path, min_silence=0.30, min_sentence=0.35): + """基于音频静音自动切分句子,返回每句话开始的时间(秒)列表。 + + 语音之间通常有短暂停顿,把停顿前后的连续声音视为一句话。 + 没有音频轨、无法解码或缺少依赖时返回 None。 + """ + try: + import av + import numpy as np + except ImportError: + return None + try: + container = av.open(str(path)) + audio = next((s for s in container.streams if s.type == "audio"), None) + if audio is None: + container.close() + return None + sample_rate = 16000 + resampler = av.AudioResampler(format="fltp", layout="mono", rate=sample_rate) + window = 480 # 30ms @ 16kHz + frame_seconds = window / sample_rate + energies = [] + buf = [] + + def push(data): + buf.append(data) + total = sum(len(x) for x in buf) + if total < window: + return + arr = np.concatenate(buf) + buf.clear() + n = len(arr) // window * window + win = arr[:n].reshape(-1, window) + energies.extend((np.mean(win * win, axis=1) ** 0.5).tolist()) + if len(arr) > n: + buf.append(arr[n:]) + + for packet in container.demux(audio): + for frame in packet.decode(): + for out in resampler.resample(frame): + push(out.to_ndarray()[0]) + for out in resampler.resample(None): + push(out.to_ndarray()[0]) + if buf: + push(np.zeros(window, dtype=np.float32)) + duration = container.duration + container.close() + if len(energies) < 3: + return None + energies = np.asarray(energies, dtype=np.float64) + floor = float(np.percentile(energies, 95)) + if floor <= 0.0: + return None + noise_floor = float(np.percentile(energies, SILENCE_FLOOR_PERCENTILE)) + threshold = max(0.008, SILENCE_THRESHOLD_FACTOR * noise_floor) + silence = energies < threshold + bridge_frames = int(round(SILENCE_BRIDGE_GAP / frame_seconds)) + if bridge_frames > 0: + bridged = silence.copy() + run_start = None + for idx, is_silent in enumerate(silence): + if not is_silent and run_start is None: + run_start = idx + elif is_silent and run_start is not None: + if run_start > 0 and idx - run_start <= bridge_frames: + bridged[run_start:idx] = True + run_start = None + silence = bridged + boundaries = [0.0] + run_start = None + for idx, is_silent in enumerate(silence): + if is_silent and run_start is None: + run_start = idx + elif not is_silent and run_start is not None: + if (idx - run_start) * frame_seconds >= min_silence: + end_sec = idx * frame_seconds + if end_sec - boundaries[-1] >= min_sentence: + boundaries.append(end_sec) + run_start = None + if run_start is not None and (len(silence) - run_start) * frame_seconds >= min_silence: + end_sec = len(silence) * frame_seconds + if end_sec - boundaries[-1] >= min_sentence: + boundaries.append(end_sec) + if duration and duration > 0: + boundaries = [b for b in boundaries if b < duration / 1e6 - 0.1] + return boundaries + except Exception: + return None + + class _BaseEngine: """播放内核统一接口。""" @@ -831,13 +925,18 @@ class MediaPlayerApp(tk.Tk): self.current_index = -1 self.volume = 80 self.rate = 1.0 - self.seek_step = self._load_settings() + settings = self._load_settings() + self.seek_step = settings["seek_step"] + self.sentence_mode = tk.BooleanVar(value=settings["sentence_mode"]) self.loop_mode = "list" self._seeking = False self._switching_until = 0.0 self._fullscreen = False self._muted_volume = None self._photo = None + self._boundaries = {} + self._analysis_running = set() + self._analysis_done = queue.Queue() self.engine = self._create_engine() if self.engine is None: @@ -983,6 +1082,13 @@ class MediaPlayerApp(tk.Tk): ) self.seek_step_spin.pack(side="left") ttk.Label(controls, text="秒").pack(side="left", padx=(4, 0)) + self.sentence_mode_btn = ttk.Checkbutton( + controls, + text="按句跳转", + variable=self.sentence_mode, + command=self._on_sentence_mode, + ) + self.sentence_mode_btn.pack(side="left", padx=(16, 0)) self.seek_step_spin.bind("", self._on_seek_step_return) self.seek_step_spin.bind("", lambda _e: self._commit_seek_step()) ttk.Label(controls, textvariable=self.status_var, anchor="e").pack(side="right", fill="x", expand=True) @@ -1047,10 +1153,14 @@ class MediaPlayerApp(tk.Tk): return "break" def _on_left(self, _event): + if self.sentence_mode.get() and self._seek_sentence(-1): + return "break" self.seek_relative(-self.seek_step) return "break" def _on_right(self, _event): + if self.sentence_mode.get() and self._seek_sentence(1): + return "break" self.seek_relative(self.seek_step) return "break" @@ -1071,6 +1181,19 @@ class MediaPlayerApp(tk.Tk): def _tick(self): if self.engine is not None: + while True: + try: + done_path = self._analysis_done.get_nowait() + except queue.Empty: + break + if done_path == self._current_path() and self.sentence_mode.get(): + boundaries = self._boundaries.get(done_path) + if boundaries: + self.status_var.set( + f"句边界分析完成:识别到 {len(boundaries)} 句,←/→ 将按句跳转" + ) + else: + self.status_var.set("该文件无法识别句子边界,按句跳转不可用") for event in self.engine.poll_events(): if event == "end": self._on_media_end() @@ -1156,15 +1279,27 @@ class MediaPlayerApp(tk.Tk): def _load_settings(self): try: data = json.loads(self._settings_path().read_text(encoding="utf-8")) - value = int(data.get("seek_step", SEEK_STEP)) except Exception: - value = SEEK_STEP - return value if 1 <= value <= 600 else SEEK_STEP + data = {} + try: + step = int(data.get("seek_step", SEEK_STEP)) + except (TypeError, ValueError): + step = SEEK_STEP + if not 1 <= step <= 600: + step = SEEK_STEP + return {"seek_step": step, "sentence_mode": bool(data.get("sentence_mode", False))} def _save_settings(self): try: self._settings_path().write_text( - json.dumps({"seek_step": self.seek_step}, ensure_ascii=False, indent=2), + json.dumps( + { + "seek_step": self.seek_step, + "sentence_mode": bool(self.sentence_mode.get()), + }, + ensure_ascii=False, + indent=2, + ), encoding="utf-8", ) except Exception as exc: @@ -1238,6 +1373,8 @@ class MediaPlayerApp(tk.Tk): self.title(f"{Path(path).name} — 媒体播放器") self._update_status() self._save_playlist() + if self.sentence_mode.get(): + self._ensure_sentence_boundaries(path) def toggle_play(self): if not self.playlist: @@ -1310,6 +1447,67 @@ class MediaPlayerApp(tk.Tk): self.focus_set() return "break" + def _current_path(self): + if 0 <= self.current_index < len(self.playlist): + return self.playlist[self.current_index] + return None + + def _on_sentence_mode(self): + self._save_settings() + if not self.sentence_mode.get(): + self.status_var.set("按句跳转已关闭") + return + path = self._current_path() + if path is None: + self.status_var.set("按句跳转已开启(请先添加并播放文件)") + return + if path not in self._boundaries: + self._ensure_sentence_boundaries(path) + self.status_var.set("正在分析句边界…") + elif self._boundaries[path]: + self.status_var.set(f"按句跳转已开启(识别到 {len(self._boundaries[path])} 句)") + else: + self.status_var.set("该文件无法识别句子边界,按句跳转不可用") + + def _ensure_sentence_boundaries(self, path): + if path in self._boundaries or path in self._analysis_running: + return + self._analysis_running.add(path) + threading.Thread(target=self._analyze_sentences, args=(path,), daemon=True).start() + + def _analyze_sentences(self, path): + boundaries = detect_sentence_boundaries(path) + self._boundaries[path] = boundaries + self._analysis_running.discard(path) + self._analysis_done.put(path) + + def _seek_sentence(self, direction): + path = self._current_path() + if path is None: + return False + boundaries = self._boundaries.get(path) + if not boundaries: + return False + pos = self.engine.get_time() + if pos < 0: + return False + pos_sec = pos / 1000.0 + if direction > 0: + for boundary in boundaries: + if boundary > pos_sec + 0.15: + self.seek_to(int(boundary * 1000)) + return True + return False + target = 0.0 + for boundary in reversed(boundaries): + if boundary < pos_sec - 0.25: + target = boundary + break + if target > 0.0 or pos_sec > 0.25: + self.seek_to(int(target * 1000)) + return True + return False + def seek_to(self, ms): ms = max(0, int(ms)) if not self.engine.seek(ms):