This commit is contained in:
2026-08-18 09:08:21 +08:00
parent 7c81976b2b
commit a36e776343
6 changed files with 368 additions and 42 deletions

View File

@@ -57,12 +57,38 @@ def analyze_audio(path: Path) -> AudioMetrics:
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.")
recording_duration_ms = max(1, int(round(samples.size / sample_rate * 1000)))
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))
@@ -79,24 +105,7 @@ def analyze_samples(samples: np.ndarray, sample_rate: int = SAMPLE_RATE) -> Audi
# 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)
speech_indexes = np.flatnonzero(speech)
if speech_indexes.size == 0:
raise AudioAnalysisError("No usable speech was detected in the recording.")
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),
)
return speech
def _bridge_false_runs(values: np.ndarray, max_frames: int) -> None: