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

113
sentence_analysis.py Normal file
View File

@@ -0,0 +1,113 @@
"""Shared audio-silence sentence boundary detection.
This module intentionally has no GUI or web-service dependencies so the
desktop player, the pre-generation CLI, and the API worker use the same
algorithm.
"""
SILENCE_FLOOR_PERCENTILE = 10
SILENCE_THRESHOLD_FACTOR = 1.5
SILENCE_BRIDGE_GAP = 0.06
def detect_sentence_boundaries(path, min_silence=0.30, min_sentence=0.35):
"""Return sentence start times in seconds, or None when unavailable.
Audio is resampled to mono 16 kHz, divided into 30 ms windows, and
silence runs are converted into boundaries. This matches the original
desktop player's behavior.
"""
try:
import av
import numpy as np
except ImportError:
return None
container = None
try:
container = av.open(str(path))
audio = next((stream for stream in container.streams if stream.type == "audio"), None)
if audio is None:
return None
sample_rate = 16_000
resampler = av.AudioResampler(format="fltp", layout="mono", rate=sample_rate)
window = 480
frame_seconds = window / sample_rate
energies = []
buffer = []
def push(data):
buffer.append(data)
total = sum(len(chunk) for chunk in buffer)
if total < window:
return
array = np.concatenate(buffer)
buffer.clear()
complete = len(array) // window * window
windows = array[:complete].reshape(-1, window)
energies.extend((np.mean(windows * windows, axis=1) ** 0.5).tolist())
if len(array) > complete:
buffer.append(array[complete:])
for packet in container.demux(audio):
for frame in packet.decode():
for output in resampler.resample(frame):
push(output.to_ndarray()[0])
for output in resampler.resample(None):
push(output.to_ndarray()[0])
if buffer:
push(np.zeros(window, dtype=np.float32))
duration = container.duration
if len(energies) < 3:
return None
energies = np.asarray(energies, dtype=np.float64)
signal_floor = float(np.percentile(energies, 95))
if signal_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 index, is_silent in enumerate(silence):
if not is_silent and run_start is None:
run_start = index
elif is_silent and run_start is not None:
if run_start > 0 and index - run_start <= bridge_frames:
bridged[run_start:index] = True
run_start = None
silence = bridged
boundaries = [0.0]
run_start = None
for index, is_silent in enumerate(silence):
if is_silent and run_start is None:
run_start = index
elif not is_silent and run_start is not None:
if (index - run_start) * frame_seconds >= min_silence:
end_seconds = index * frame_seconds
if end_seconds - boundaries[-1] >= min_sentence:
boundaries.append(end_seconds)
run_start = None
if run_start is not None and (len(silence) - run_start) * frame_seconds >= min_silence:
end_seconds = len(silence) * frame_seconds
if end_seconds - boundaries[-1] >= min_sentence:
boundaries.append(end_seconds)
if duration and duration > 0:
boundaries = [boundary for boundary in boundaries if boundary < duration / 1e6 - 0.1]
return boundaries
except Exception:
return None
finally:
if container is not None:
container.close()
__all__ = ["detect_sentence_boundaries"]