228 lines
8.6 KiB
Python
228 lines
8.6 KiB
Python
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Optional, Tuple
|
|
|
|
import numpy as np
|
|
|
|
|
|
SAMPLE_RATE = 16_000
|
|
FRAME_SAMPLES = 480
|
|
# A silence run at least this long (in 30 ms frames) separates two sentences.
|
|
# speech_frame_mask bridges gaps of up to 5 frames, so use 6 frames (~180 ms).
|
|
MIN_PAUSE_FRAMES = 6
|
|
ONSET_MARGIN_MS = 50
|
|
START_LOOKBACK_MS = 300
|
|
START_LOOKAHEAD_MS = 400
|
|
|
|
|
|
class AudioAnalysisError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AudioMetrics:
|
|
recording_duration_ms: int
|
|
speech_duration_ms: int
|
|
internal_silence_ms: int
|
|
internal_pause_ratio: float
|
|
|
|
|
|
def decode_audio_mono(path: Path) -> Tuple[np.ndarray, int]:
|
|
try:
|
|
import av
|
|
except ImportError as exc:
|
|
raise AudioAnalysisError("PyAV is required for audio analysis.") from exc
|
|
|
|
container = av.open(str(path))
|
|
chunks = []
|
|
try:
|
|
audio_stream = next(
|
|
(stream for stream in container.streams if stream.type == "audio"),
|
|
None,
|
|
)
|
|
if audio_stream is None:
|
|
raise AudioAnalysisError("The uploaded file does not contain an audio stream.")
|
|
resampler = av.AudioResampler(format="fltp", layout="mono", rate=SAMPLE_RATE)
|
|
for packet in container.demux(audio_stream):
|
|
for frame in packet.decode():
|
|
for output in resampler.resample(frame):
|
|
chunks.append(output.to_ndarray()[0].astype(np.float32, copy=False))
|
|
for output in resampler.resample(None):
|
|
chunks.append(output.to_ndarray()[0].astype(np.float32, copy=False))
|
|
finally:
|
|
container.close()
|
|
|
|
if not chunks:
|
|
raise AudioAnalysisError("The uploaded audio is empty.")
|
|
return np.concatenate(chunks), SAMPLE_RATE
|
|
|
|
|
|
def analyze_audio(path: Path) -> AudioMetrics:
|
|
samples, sample_rate = decode_audio_mono(path)
|
|
return analyze_samples(samples, sample_rate)
|
|
|
|
|
|
def analyze_samples(samples: np.ndarray, sample_rate: int = SAMPLE_RATE) -> AudioMetrics:
|
|
if samples.ndim != 1:
|
|
samples = samples.reshape(-1)
|
|
speech = speech_frame_mask(samples, sample_rate)
|
|
speech_indexes = np.flatnonzero(speech)
|
|
if speech_indexes.size == 0:
|
|
raise AudioAnalysisError("No usable speech was detected in the recording.")
|
|
|
|
recording_duration_ms = max(1, int(round(samples.size / sample_rate * 1000)))
|
|
window = max(1, int(round(sample_rate * 0.03)))
|
|
frame_ms = window / sample_rate * 1000
|
|
speech_duration_ms = max(1, int(round(speech.sum() * frame_ms)))
|
|
first = int(speech_indexes[0])
|
|
last = int(speech_indexes[-1])
|
|
internal_frames = max(1, last - first + 1)
|
|
internal_silence_frames = int((~speech[first : last + 1]).sum())
|
|
internal_silence_ms = int(round(internal_silence_frames * frame_ms))
|
|
internal_pause_ratio = internal_silence_frames / internal_frames
|
|
return AudioMetrics(
|
|
recording_duration_ms=recording_duration_ms,
|
|
speech_duration_ms=speech_duration_ms,
|
|
internal_silence_ms=internal_silence_ms,
|
|
internal_pause_ratio=round(internal_pause_ratio, 4),
|
|
)
|
|
|
|
|
|
def speech_frame_mask(samples: np.ndarray, sample_rate: int = SAMPLE_RATE) -> np.ndarray:
|
|
"""Per-frame speech mask (30 ms frames) using the same RMS threshold as analyze_samples."""
|
|
if samples.ndim != 1:
|
|
samples = samples.reshape(-1)
|
|
if samples.size == 0 or sample_rate <= 0:
|
|
raise AudioAnalysisError("The uploaded audio is empty.")
|
|
|
|
window = max(1, int(round(sample_rate * 0.03)))
|
|
complete_frames = int(np.ceil(samples.size / window))
|
|
padded = np.pad(samples, (0, complete_frames * window - samples.size))
|
|
frames = padded.reshape(complete_frames, window).astype(np.float64, copy=False)
|
|
rms = np.sqrt(np.mean(frames * frames, axis=1))
|
|
|
|
signal_level = float(np.percentile(rms, 95))
|
|
if signal_level < 0.002:
|
|
raise AudioAnalysisError("No usable speech was detected in the recording.")
|
|
noise_floor = float(np.percentile(rms, 10))
|
|
threshold = min(signal_level * 0.45, max(0.006, noise_floor * 2.2))
|
|
speech = rms >= threshold
|
|
|
|
# Treat very short gaps inside a word as speech, then reject short clicks.
|
|
_bridge_false_runs(speech, max_frames=5)
|
|
_remove_true_runs(speech, max_frames=2)
|
|
return speech
|
|
|
|
|
|
def refine_sentence_end_ms(
|
|
samples: np.ndarray,
|
|
sample_rate: int = SAMPLE_RATE,
|
|
*,
|
|
raw_end_ms: int,
|
|
padded_end_ms: int,
|
|
) -> int:
|
|
"""Pull a padded sentence end back to just before the next sentence's speech.
|
|
|
|
Whisper's timestamps are not always aligned with the real audio: the next
|
|
segment's start can be later than the actual speech onset, so a fixed
|
|
end-padding may occasionally run into the next sentence's beginning. This
|
|
finds the first silence run of at least MIN_PAUSE_FRAMES inside the padded
|
|
region and stops the sentence just before the speech that follows it.
|
|
"""
|
|
if padded_end_ms <= raw_end_ms:
|
|
return padded_end_ms
|
|
frame_ms = 1000 * max(1, int(round(sample_rate * 0.03))) / sample_rate
|
|
start_sample = max(0, int(raw_end_ms / 1000 * sample_rate))
|
|
end_sample = min(samples.size, int(padded_end_ms / 1000 * sample_rate))
|
|
if end_sample <= start_sample:
|
|
return padded_end_ms
|
|
try:
|
|
speech = speech_frame_mask(samples[start_sample:end_sample], sample_rate)
|
|
except AudioAnalysisError:
|
|
return padded_end_ms
|
|
silence_frames = 0
|
|
for index, is_speech in enumerate(speech):
|
|
if not is_speech:
|
|
silence_frames += 1
|
|
continue
|
|
if silence_frames >= MIN_PAUSE_FRAMES:
|
|
onset_ms = int(raw_end_ms + index * frame_ms)
|
|
return max(raw_end_ms, min(padded_end_ms, onset_ms - ONSET_MARGIN_MS))
|
|
silence_frames = 0
|
|
return padded_end_ms
|
|
|
|
|
|
def refine_sentence_start_ms(
|
|
samples: np.ndarray,
|
|
sample_rate: int = SAMPLE_RATE,
|
|
*,
|
|
raw_start_ms: int,
|
|
previous_end_ms: int,
|
|
) -> int:
|
|
"""Pull a sentence start forward to the actual speech onset.
|
|
|
|
Whisper's segment start can be earlier than the real speech onset (the tail
|
|
of the previous sentence is still audible at the beginning of the next
|
|
sentence). When the raw start lands inside continuous speech, this looks for
|
|
the last pause of at least MIN_PAUSE_FRAMES before the next speech run and
|
|
moves the start to that onset, so adjacent sentences share no audio.
|
|
"""
|
|
if raw_start_ms <= previous_end_ms:
|
|
return max(previous_end_ms, raw_start_ms)
|
|
frame_ms = 1000 * max(1, int(round(sample_rate * 0.03))) / sample_rate
|
|
total_ms = samples.size / sample_rate * 1000
|
|
window_start_ms = max(0, raw_start_ms - START_LOOKBACK_MS)
|
|
window_end_ms = min(int(total_ms), raw_start_ms + START_LOOKAHEAD_MS)
|
|
start_sample = int(window_start_ms / 1000 * sample_rate)
|
|
end_sample = min(samples.size, int(window_end_ms / 1000 * sample_rate))
|
|
if end_sample - start_sample < max(1, int(round(sample_rate * 0.03))):
|
|
return raw_start_ms
|
|
try:
|
|
speech = speech_frame_mask(samples[start_sample:end_sample], sample_rate)
|
|
except AudioAnalysisError:
|
|
return raw_start_ms
|
|
onset_frame = int(round((raw_start_ms - window_start_ms) / frame_ms))
|
|
if (
|
|
0 <= onset_frame < len(speech)
|
|
and speech[onset_frame]
|
|
and (onset_frame == 0 or not speech[onset_frame - 1])
|
|
):
|
|
return raw_start_ms # already at a speech onset
|
|
boundary_ms: Optional[float] = None
|
|
silence_frames = 0
|
|
for index, is_speech in enumerate(speech):
|
|
if not is_speech:
|
|
silence_frames += 1
|
|
continue
|
|
if silence_frames >= MIN_PAUSE_FRAMES:
|
|
boundary_ms = window_start_ms + index * frame_ms
|
|
silence_frames = 0
|
|
if boundary_ms is None:
|
|
return raw_start_ms
|
|
boundary_ms = min(boundary_ms, raw_start_ms + START_LOOKAHEAD_MS)
|
|
return max(previous_end_ms, int(boundary_ms))
|
|
|
|
|
|
def _bridge_false_runs(values: np.ndarray, max_frames: int) -> None:
|
|
start = None
|
|
for index, value in enumerate(values):
|
|
if not value and start is None:
|
|
start = index
|
|
elif value and start is not None:
|
|
if start > 0 and index - start <= max_frames:
|
|
values[start:index] = True
|
|
start = None
|
|
|
|
|
|
def _remove_true_runs(values: np.ndarray, max_frames: int) -> None:
|
|
start = None
|
|
for index, value in enumerate(values):
|
|
if value and start is None:
|
|
start = index
|
|
elif not value and start is not None:
|
|
if index - start <= max_frames:
|
|
values[start:index] = False
|
|
start = None
|
|
if start is not None and len(values) - start <= max_frames:
|
|
values[start:] = False
|