add sentence service

This commit is contained in:
2026-08-14 19:06:04 +08:00
parent f8a6bf24e4
commit 69d2ba986f
19 changed files with 2379 additions and 109 deletions

View File

@@ -38,6 +38,8 @@ from pathlib import Path
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from sentence_analysis import detect_sentence_boundaries
try:
from PIL import Image as PILImage, ImageTk
except ImportError:
@@ -71,11 +73,6 @@ 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):
"""把毫秒格式化为 mm:ss 或 h:mm:ss。"""
if ms is None or ms < 0:
@@ -88,96 +85,6 @@ 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:
"""播放内核统一接口。"""