A second review found three numbers left behind when the report was regenerated with pinned decoding: - the transcriber docstring claimed 1.7 s for 88.9 s of speech; the report says 1.39 s, and 1.7 came from the unpinned run. The docstring now names no number, so it cannot go stale, and it stops calling container seconds speech with voice activity detection off. - the checklist still quoted 3.55 s and 61.6x / 73.0x / 97.7x; the frozen report says 3.26 s and 63.9x / 82.4x / 112.2x. - the checklist still said 31 speech segments for one mp4; with greedy decoding that video yields 38, which is where the store's 80 atoms come from (38 + 39 + 3). The report also credited the store with timing invariants that were measured on the model's answer. The harness now recomputes them from the words as stored (out_of_order_in_store, before_their_segment_in_store) and says so. Verification: 212 passed, ruff clean, mypy clean on both touched files with a cold cache, pyright 0 errors.
151 lines
5.1 KiB
Python
151 lines
5.1 KiB
Python
"""Speech to text with word-level timings, so a spoken memory can be found and pointed at."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
|
|
from .config import Settings, get_settings
|
|
|
|
DOWNLOAD_HINT = (
|
|
"fetch a faster-whisper CTranslate2 snapshot and set ELY_EYE_ASR_MODEL_DIR "
|
|
"to the directory holding it"
|
|
)
|
|
DEFERRED = "deferred: no speech model snapshot"
|
|
MODEL_GRAPH = "model.bin"
|
|
SAMPLE_RATE = 16_000
|
|
CPU_THREADS = 4
|
|
TRANSCRIBE_KNOBS: dict[str, Any] = {
|
|
"word_timestamps": True,
|
|
"beam_size": 1,
|
|
"temperature": (0.0,),
|
|
"condition_on_previous_text": False,
|
|
"vad_filter": False,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SpokenWord:
|
|
word: str
|
|
start: float
|
|
end: float
|
|
confidence: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SpeechSegment:
|
|
text: str
|
|
start: float
|
|
end: float
|
|
words: tuple[SpokenWord, ...]
|
|
confidence: float
|
|
|
|
|
|
def decode_audio(path: Path) -> np.ndarray:
|
|
"""Decode any container PyAV reads into mono 16 kHz floats, the shape the model wants.
|
|
|
|
The decode lives here rather than inside faster-whisper because its own decoder calls
|
|
av.open(metadata_errors=...), a keyword PyAV 19 no longer accepts. PyAV 19 raises its
|
|
failures as OSError and ValueError subclasses, so FFmpegError is the only thing caught
|
|
here and a caller's own error keeps its own message.
|
|
"""
|
|
import av
|
|
from av.error import FFmpegError
|
|
|
|
frames: list[np.ndarray] = []
|
|
try:
|
|
with av.open(str(path)) as container:
|
|
audio_streams = container.streams.audio
|
|
if not audio_streams:
|
|
raise ValueError(f"{path}: the file holds no audio track")
|
|
resampler = av.AudioResampler(format="flt", layout="mono", rate=SAMPLE_RATE)
|
|
for chunk in container.decode(audio_streams[0]):
|
|
if not isinstance(chunk, av.AudioFrame):
|
|
continue
|
|
for resampled in resampler.resample(chunk):
|
|
frames.append(np.asarray(resampled.to_ndarray()).reshape(-1))
|
|
except FFmpegError as error:
|
|
raise ValueError(f"{path}: the audio cannot be decoded ({error})") from error
|
|
if not frames:
|
|
raise ValueError(f"{path}: the audio track is empty")
|
|
return np.concatenate(frames).astype(np.float32)
|
|
|
|
|
|
def audio_duration(path: Path) -> float:
|
|
"""Seconds of media the container claims; 0.0 when it does not say."""
|
|
import av
|
|
from av.error import FFmpegError
|
|
|
|
try:
|
|
with av.open(str(path)) as container:
|
|
if not container.duration:
|
|
return 0.0
|
|
return container.duration / av.time_base
|
|
except FFmpegError as error:
|
|
raise ValueError(f"{path}: the container cannot be read ({error})") from error
|
|
|
|
|
|
def _confidence(words: list[Any]) -> float:
|
|
scores = [float(word.probability) for word in words if getattr(word, "probability", None) is not None]
|
|
if not scores:
|
|
return 0.0
|
|
return min(sum(scores) / len(scores), 1.0)
|
|
|
|
|
|
def _segment(segment: Any) -> SpeechSegment:
|
|
words = list(getattr(segment, "words", None) or [])
|
|
return SpeechSegment(
|
|
text=segment.text.strip(),
|
|
start=float(segment.start),
|
|
end=float(segment.end),
|
|
words=tuple(
|
|
SpokenWord(
|
|
word=spoken.word.strip(),
|
|
start=float(spoken.start),
|
|
end=float(spoken.end),
|
|
confidence=float(getattr(spoken, "probability", 0.0) or 0.0),
|
|
)
|
|
for spoken in words
|
|
),
|
|
confidence=_confidence(words),
|
|
)
|
|
|
|
|
|
class SpeechTranscriber:
|
|
"""Whisper on CTranslate2, greedy on CPU int8; bench/reports/ASR.md carries the measured speed."""
|
|
|
|
def __init__(self, settings: Settings | None = None) -> None:
|
|
self.settings = settings or get_settings()
|
|
self._model: Any = None
|
|
|
|
def model_dir(self) -> Path | None:
|
|
return self.settings.asr_model_dir
|
|
|
|
def available(self) -> bool:
|
|
directory = self.model_dir()
|
|
return directory is not None and (directory / MODEL_GRAPH).exists()
|
|
|
|
def transcribe(self, path: Path) -> list[SpeechSegment]:
|
|
"""Words with the instant each was spoken; the timings are the point of the feature."""
|
|
directory = self.model_dir()
|
|
if directory is None:
|
|
raise SystemExit(f"no speech model snapshot is configured; {DOWNLOAD_HINT}")
|
|
if not (directory / MODEL_GRAPH).exists():
|
|
raise SystemExit(f"{directory} holds no {MODEL_GRAPH}; {DOWNLOAD_HINT}")
|
|
model = self._load(directory)
|
|
# Greedy, no retry ladder, no carry-over: the same file must answer the same way twice.
|
|
segments, _info = model.transcribe(decode_audio(path), **TRANSCRIBE_KNOBS)
|
|
return [_segment(segment) for segment in segments]
|
|
|
|
def _load(self, directory: Path) -> Any:
|
|
if self._model is None:
|
|
from faster_whisper import WhisperModel
|
|
|
|
self._model = WhisperModel(
|
|
str(directory), device="cpu", compute_type="int8", cpu_threads=CPU_THREADS
|
|
)
|
|
return self._model
|