fixed a bug,spliting sentence
This commit is contained in:
11
README.md
11
README.md
@@ -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,程序正在使用内置的
|
||||||
|
|||||||
208
media_player.py
208
media_player.py
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user