Skip to content

faster_whisper

faster_whisper

Faster-Whisper speech-to-text backend (local, CTranslate2-based).

Classes

FasterWhisperBackend

FasterWhisperBackend(
    model_size: str = "base",
    device: str = "auto",
    compute_type: str = "float16",
    use_dictionary_hints: bool = True,
    language: str = "",
    realtime: bool = False,
)

Bases: SpeechBackend

Local speech-to-text using Faster-Whisper (CTranslate2).

Source code in src/diapason/speech/faster_whisper.py
def __init__(
    self,
    model_size: str = "base",
    device: str = "auto",
    compute_type: str = "float16",
    use_dictionary_hints: bool = True,
    language: str = "",
    realtime: bool = False,
) -> None:
    self._model_size = model_size
    self._device = device
    self._compute_type = compute_type
    self._model: Optional[Any] = None
    self._last_error: Optional[str] = None
    # Configured language, or "auto" / "" to detect.
    self._language = (language or "").strip()
    # Whisper runs a separate detection pass whenever no language is
    # given, and on this machine that pass costs roughly as much as the
    # decode itself — measured 1.90 s -> 1.13 s per 4-second French clip
    # once the language is known. Detection is also least reliable
    # exactly where dictation lives: a two-second utterance.
    #
    # So detect ONCE, then reuse. The first utterance of a session pays
    # for it, every later one is fast, and nobody has to guess a locale
    # or edit a config file to get the speedup.
    self._detected: Optional[str] = None
    # (value,) so a cached "no hotwords" is distinguishable from "unread".
    self._hotwords_cache: Optional[tuple] = None
    self._hotwords_stamp: int = -1
    # Bias recognition toward the user's own vocabulary. The dictionary
    # already fixed mistakes AFTER the fact (apply_dictionary in
    # dictate_polish); ``transcription_hints`` was written to fix them
    # BEFORE, and had no caller at all. Feeding it to faster-whisper's
    # ``hotwords`` is what stops a name like "Carlito" coming back as
    # "Karli 2-1" in the first place — a post-hoc replacement cannot
    # recover a name the recogniser never proposed.
    self._use_dictionary_hints = use_dictionary_hints
    # Realtime turns are short and already segmented by the microphone
    # gate. Greedy decoding is much faster here, while Silero VAD rejects
    # the low-level noise that Whisper otherwise turns into stock phrases
    # or hotwords (the observed silent turn became "Google Chrome").
    self._realtime = bool(realtime)
Methods:
preload
preload() -> bool

Build the model now rather than on the user's first keypress.

Under the LaunchAgent the service starts at login and then sits idle, so without this the first dictation of the day pays several seconds of model construction while the user is already talking.

Source code in src/diapason/speech/faster_whisper.py
def preload(self) -> bool:
    """Build the model now rather than on the user's first keypress.

    Under the LaunchAgent the service starts at login and then sits idle,
    so without this the first dictation of the day pays several seconds
    of model construction while the user is already talking.
    """
    try:
        self._ensure_model()
        return True
    except Exception:  # noqa: BLE001 - stay usable; the first call retries
        logger.debug("model preload failed", exc_info=True)
        return False
transcribe
transcribe(
    audio: bytes,
    *,
    format: str = "wav",
    language: Optional[str] = None,
) -> TranscriptionResult

Transcribe audio bytes using Faster-Whisper.

Source code in src/diapason/speech/faster_whisper.py
def transcribe(
    self,
    audio: bytes,
    *,
    format: str = "wav",
    language: Optional[str] = None,
) -> TranscriptionResult:
    """Transcribe audio bytes using Faster-Whisper."""
    try:
        model = self._ensure_model()

        kwargs = {}
        effective = self._effective_language(language)
        if effective:
            kwargs["language"] = effective
        hotwords = self._hotwords()
        if hotwords:
            kwargs["hotwords"] = hotwords
        if self._realtime:
            kwargs.update(
                {
                    "beam_size": 1,
                    "best_of": 1,
                    "condition_on_previous_text": False,
                    "vad_filter": True,
                    "vad_parameters": {
                        "threshold": 0.5,
                        "min_speech_duration_ms": 250,
                        "min_silence_duration_ms": 160,
                        "speech_pad_ms": 80,
                    },
                }
            )
        else:
            # La DICTÉE aussi filtre le silence. Elle envoyait la phrase
            # avec sa queue de souffle, et Whisper — nourri de sous-titres
            # de vidéos — y hallucinait le générique des sous-titreurs :
            # « Sous-titres par la communauté d'Amara.org » à chaque fin
            # de phrase (rapporté le 23 août 2026). Couper le silence
            # avant le modèle tue l'hallucination à la source, et
            # transcrit moins d'audio par-dessus le marché. Le rembourrage
            # est plus généreux qu'en temps réel : ici la précision prime,
            # aucun bord de mot ne doit tomber avec le silence.
            kwargs.update(
                {
                    "vad_filter": True,
                    "vad_parameters": {
                        "threshold": 0.5,
                        "min_speech_duration_ms": 200,
                        "min_silence_duration_ms": 300,
                        "speech_pad_ms": 250,
                    },
                }
            )

        samples = _decode_pcm_wav(audio) if format.lstrip(".") == "wav" else None
        if samples is not None:
            # Straight from memory. The dictation path already holds a
            # float32 array; encoding it to WAV, writing it to disk and
            # having PyAV decode it back was a round trip to nowhere that
            # measured ~27% of the total transcription time.
            segments_iter, info = model.transcribe(samples, **kwargs)
            segments_list = list(segments_iter)
        else:
            # Anything else (mp3, m4a…) still needs a demuxer, and
            # faster-whisper takes a path for that. delete=False + manual
            # unlink: on Windows an open NamedTemporaryFile holds an
            # exclusive handle, so PyAV's reopen fails with EACCES.
            suffix = f".{format}" if not format.startswith(".") else format
            tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
            try:
                with tmp:
                    tmp.write(audio)
                segments_iter, info = model.transcribe(tmp.name, **kwargs)
                segments_list = list(segments_iter)
            finally:
                try:
                    os.unlink(tmp.name)
                except OSError as unlink_exc:
                    logger.debug(
                        "Could not remove temp audio file %s: %s",
                        tmp.name,
                        unlink_exc,
                    )
    except Exception as exc:
        self._last_error = str(exc)
        raise

    # Remember a detected language so the next utterance can skip the
    # detection pass — but only on evidence strong enough to bet a whole
    # session on.
    #
    # The two guards below exist because of a real failure: one uncertain
    # detection on a first short clip latched the session to English, and
    # every French sentence afterwards came back translated. Forcing the
    # wrong language is not a small error — Whisper does not refuse, it
    # renders the speech *as* that language. Re-detecting merely costs a
    # few hundred milliseconds, so the asymmetry decides the design.
    if not self._language and self._detected is None:
        probability = getattr(info, "language_probability", 0.0) or 0.0
        seconds = getattr(info, "duration", 0.0) or 0.0
        confident = probability >= LANGUAGE_LATCH_CONFIDENCE
        if confident and seconds >= LANGUAGE_LATCH_SECONDS:
            self._detected = getattr(info, "language", None)

    # Build result — sans les segments qui ont toutes les marques d'une
    # hallucination (voir est_hallucination pour le pourquoi).
    segments_list = [
        seg
        for seg in segments_list
        if not est_hallucination(
            getattr(seg, "no_speech_prob", 0.0) or 0.0,
            getattr(seg, "avg_logprob", 0.0) or 0.0,
        )
    ]
    text = "".join(seg.text for seg in segments_list).strip()
    segments = [
        Segment(
            text=seg.text.strip(),
            start=seg.start,
            end=seg.end,
            confidence=None,
        )
        for seg in segments_list
    ]

    self._last_error = None
    return TranscriptionResult(
        text=text,
        language=getattr(info, "language", None),
        confidence=getattr(info, "language_probability", None),
        duration_seconds=getattr(info, "duration", 0.0),
        segments=segments,
    )
health
health() -> bool

Check if model is loaded or loadable.

Source code in src/diapason/speech/faster_whisper.py
def health(self) -> bool:
    """Check if model is loaded or loadable."""
    try:
        self._ensure_model()
        return True
    except Exception as exc:
        self._last_error = str(exc)
        logger.debug("Faster-Whisper health check failed: %s", exc)
        return False
last_error
last_error() -> Optional[str]

Return the last model load or transcription error, if any.

Source code in src/diapason/speech/faster_whisper.py
def last_error(self) -> Optional[str]:
    """Return the last model load or transcription error, if any."""
    return self._last_error
supported_formats
supported_formats() -> List[str]

Supported audio formats (same as ffmpeg/Whisper).

Source code in src/diapason/speech/faster_whisper.py
def supported_formats(self) -> List[str]:
    """Supported audio formats (same as ffmpeg/Whisper)."""
    return ["wav", "mp3", "m4a", "ogg", "flac", "webm"]

Functions:

est_hallucination

est_hallucination(
    no_speech_prob: object, avg_logprob: object
) -> bool

Vrai quand un segment a toutes les marques d'une hallucination.

Les valeurs passent par float() : un attribut absent ou exotique vaut « pas d'alarme » — on ne raye jamais de la parole sur un doute technique.

Source code in src/diapason/speech/faster_whisper.py
def est_hallucination(no_speech_prob: object, avg_logprob: object) -> bool:
    """Vrai quand un segment a toutes les marques d'une hallucination.

    Les valeurs passent par float() : un attribut absent ou exotique vaut
    « pas d'alarme » — on ne raye jamais de la parole sur un doute technique.
    """

    def _nombre(valeur: object, defaut: float) -> float:
        try:
            return float(valeur)  # type: ignore[arg-type]
        except (TypeError, ValueError):
            return defaut

    return (
        _nombre(no_speech_prob, 0.0) > HALLUCINATION_NO_SPEECH_MIN
        and _nombre(avg_logprob, 0.0) < HALLUCINATION_LOGPROB_MAX
    )