add sentence service
This commit is contained in:
113
sentence_analysis.py
Normal file
113
sentence_analysis.py
Normal 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"]
|
||||
Reference in New Issue
Block a user