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

@@ -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("<Return>", self._on_seek_step_return)
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)
@@ -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):