fixed a bug,spliting sentence

This commit is contained in:
2026-08-13 22:33:27 +08:00
parent b3c264e910
commit 3b0fd719d9
2 changed files with 213 additions and 6 deletions

View File

@@ -10,6 +10,8 @@ pip 安装依赖后即可直接播放视频。
- 播放 / 暂停 / 停止、上一首 / 下一首 - 播放 / 暂停 / 停止、上一首 / 下一首
- 前进 / 后退(步长可自定义)与进度条点击拖动定位 - 前进 / 后退(步长可自定义)与进度条点击拖动定位
- 快进步长可在界面底部直接填写1-600 秒),自动保存、下次启动自动加载 - 快进步长可在界面底部直接填写1-600 秒),自动保存、下次启动自动加载
- 按句跳转:勾选后 `←` / `→` 自动播放上一句/下一句(基于音频静音自动识别
句边界,无需联网,分析结果自动缓存)
- 音量调节、静音0.5x - 2.0x 变速播放 - 音量调节、静音0.5x - 2.0x 变速播放
- 三种循环模式:关 / 单曲循环 / 列表循环 - 三种循环模式:关 / 单曲循环 / 列表循环
- 播放列表管理(打开文件、打开文件夹、删除条目、双击播放) - 播放列表管理(打开文件、打开文件夹、删除条目、双击播放)
@@ -66,7 +68,7 @@ pip install -r requirements.txt
| 快捷键 | 功能 | | 快捷键 | 功能 |
| --- | --- | | --- | --- |
| `Space` | 播放 / 暂停 | | `Space` | 播放 / 暂停 |
| `←` / `→` | 后退 / 前进 10 秒 | | `←` / `→` | 后退 / 前进(步长可自定义;勾选“按句跳转”后按句子跳转) |
| `Shift+←` / `Shift+→` | 后退 / 前进 60 秒 | | `Shift+←` / `Shift+→` | 后退 / 前进 60 秒 |
| `↑` / `↓` | 音量 +5 / -5 | | `↑` / `↓` | 音量 +5 / -5 |
| `Ctrl+↑` / `Ctrl+↓` | 快进步长 +1 / -1 秒macOS 亦支持 `Command+↑ / ↓` | | `Ctrl+↑` / `Ctrl+↓` | 快进步长 +1 / -1 秒macOS 亦支持 `Command+↑ / ↓` |
@@ -91,6 +93,13 @@ pip install -r requirements.txt
60 秒),输入后按回车或点击其他位置即生效并自动保存(配置文件:用户主目录下的 60 秒),输入后按回车或点击其他位置即生效并自动保存(配置文件:用户主目录下的
`.mediaplayer_settings.json`)。 `.mediaplayer_settings.json`)。
勾选“按句跳转”后,`←` / `→` 不再按固定秒数跳转,而是自动跳到上一句 / 下一句
的开头:程序会分析音频中的静音停顿来自动切分句子(适合听写、跟读等场景)。
静音阈值会根据每个文件的背景噪声自动调整,带环境噪音的录音也能正常分句。
分析在后台进行,首次播放某文件时状态栏会提示“正在分析句边界…”,完成后
自动缓存;无法识别(如纯音乐、无音频轨)时会提示并回退为普通步进跳转。
“按句跳转”的开关状态同样会自动保存。
## 常见问题 ## 常见问题
- **状态栏显示“纯 Python 解码内核”**:说明未检测到 VLC程序正在使用内置的 - **状态栏显示“纯 Python 解码内核”**:说明未检测到 VLC程序正在使用内置的

View File

@@ -13,6 +13,7 @@
Shift+← / → 后退 / 前进 60 秒 Shift+← / → 后退 / 前进 60 秒
↑ / ↓ 音量 +5 / -5 ↑ / ↓ 音量 +5 / -5
Ctrl+↑ / ↓ 快进步长 +1 / -1 秒macOS 亦支持 Command+↑ / ↓) Ctrl+↑ / ↓ 快进步长 +1 / -1 秒macOS 亦支持 Command+↑ / ↓)
勾选“按句跳转”后,←/→ 自动播放上一句/下一句(基于音频静音识别句边界)
M 静音 / 恢复 M 静音 / 恢复
Ctrl+← / → 上一首 / 下一首macOS 也支持 Command+← / →) Ctrl+← / → 上一首 / 下一首macOS 也支持 Command+← / →)
N / P 下一首 / 上一首 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] SPEED_PRESETS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]
SEEK_STEP = 10 SEEK_STEP = 10
SEEK_STEP_BIG = 60 SEEK_STEP_BIG = 60
SILENCE_FLOOR_PERCENTILE = 10 # 用能量低分位估计背景噪声底
SILENCE_THRESHOLD_FACTOR = 1.5 # 静音阈值 = 噪声底 × 系数
SILENCE_BRIDGE_GAP = 0.06 # 停顿中 60ms 内的短促残响/噪声也按静音处理
def fmt_time(ms): def fmt_time(ms):
@@ -84,6 +88,96 @@ def fmt_time(ms):
return f"{minutes:02d}:{seconds:02d}" 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: class _BaseEngine:
"""播放内核统一接口。""" """播放内核统一接口。"""
@@ -831,13 +925,18 @@ class MediaPlayerApp(tk.Tk):
self.current_index = -1 self.current_index = -1
self.volume = 80 self.volume = 80
self.rate = 1.0 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.loop_mode = "list"
self._seeking = False self._seeking = False
self._switching_until = 0.0 self._switching_until = 0.0
self._fullscreen = False self._fullscreen = False
self._muted_volume = None self._muted_volume = None
self._photo = None self._photo = None
self._boundaries = {}
self._analysis_running = set()
self._analysis_done = queue.Queue()
self.engine = self._create_engine() self.engine = self._create_engine()
if self.engine is None: if self.engine is None:
@@ -983,6 +1082,13 @@ class MediaPlayerApp(tk.Tk):
) )
self.seek_step_spin.pack(side="left") self.seek_step_spin.pack(side="left")
ttk.Label(controls, text="").pack(side="left", padx=(4, 0)) 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("<Return>", self._on_seek_step_return) self.seek_step_spin.bind("<Return>", self._on_seek_step_return)
self.seek_step_spin.bind("<FocusOut>", lambda _e: self._commit_seek_step()) self.seek_step_spin.bind("<FocusOut>", lambda _e: self._commit_seek_step())
ttk.Label(controls, textvariable=self.status_var, anchor="e").pack(side="right", fill="x", expand=True) 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" return "break"
def _on_left(self, _event): def _on_left(self, _event):
if self.sentence_mode.get() and self._seek_sentence(-1):
return "break"
self.seek_relative(-self.seek_step) self.seek_relative(-self.seek_step)
return "break" return "break"
def _on_right(self, _event): def _on_right(self, _event):
if self.sentence_mode.get() and self._seek_sentence(1):
return "break"
self.seek_relative(self.seek_step) self.seek_relative(self.seek_step)
return "break" return "break"
@@ -1071,6 +1181,19 @@ class MediaPlayerApp(tk.Tk):
def _tick(self): def _tick(self):
if self.engine is not None: 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(): for event in self.engine.poll_events():
if event == "end": if event == "end":
self._on_media_end() self._on_media_end()
@@ -1156,15 +1279,27 @@ class MediaPlayerApp(tk.Tk):
def _load_settings(self): def _load_settings(self):
try: try:
data = json.loads(self._settings_path().read_text(encoding="utf-8")) data = json.loads(self._settings_path().read_text(encoding="utf-8"))
value = int(data.get("seek_step", SEEK_STEP))
except Exception: except Exception:
value = SEEK_STEP data = {}
return value if 1 <= value <= 600 else SEEK_STEP 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): def _save_settings(self):
try: try:
self._settings_path().write_text( 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", encoding="utf-8",
) )
except Exception as exc: except Exception as exc:
@@ -1238,6 +1373,8 @@ class MediaPlayerApp(tk.Tk):
self.title(f"{Path(path).name} — 媒体播放器") self.title(f"{Path(path).name} — 媒体播放器")
self._update_status() self._update_status()
self._save_playlist() self._save_playlist()
if self.sentence_mode.get():
self._ensure_sentence_boundaries(path)
def toggle_play(self): def toggle_play(self):
if not self.playlist: if not self.playlist:
@@ -1310,6 +1447,67 @@ class MediaPlayerApp(tk.Tk):
self.focus_set() self.focus_set()
return "break" 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): def seek_to(self, ms):
ms = max(0, int(ms)) ms = max(0, int(ms))
if not self.engine.seek(ms): if not self.engine.seek(ms):