Skip to content

Engines

Transcription engines. Every engine takes 16 kHz mono float32 audio and returns timed segments.

Engine

Bases: Protocol

A speech-to-text engine.

Source code in src/murmurvault/engines.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Engine(Protocol):
    """A speech-to-text engine."""

    name: str
    model: str

    def transcribe(self, audio: np.ndarray, language: str | None = None, fast: bool = False) -> list[Segment]:
        """Transcribe audio.

        Args:
            audio: 16 kHz mono float32 samples.
            language: Language code, or None to auto-detect.
            fast: Trade accuracy for speed, for the live draft.

        Returns:
            Segments with times relative to the start of ``audio``.
        """

transcribe(audio, language=None, fast=False)

Transcribe audio.

Parameters:

Name Type Description Default
audio ndarray

16 kHz mono float32 samples.

required
language str | None

Language code, or None to auto-detect.

None
fast bool

Trade accuracy for speed, for the live draft.

False

Returns:

Type Description
list[Segment]

Segments with times relative to the start of audio.

Source code in src/murmurvault/engines.py
27
28
29
30
31
32
33
34
35
36
37
def transcribe(self, audio: np.ndarray, language: str | None = None, fast: bool = False) -> list[Segment]:
    """Transcribe audio.

    Args:
        audio: 16 kHz mono float32 samples.
        language: Language code, or None to auto-detect.
        fast: Trade accuracy for speed, for the live draft.

    Returns:
        Segments with times relative to the start of ``audio``.
    """

FasterWhisperEngine

The built-in engine: faster-whisper (CTranslate2) running locally.

Source code in src/murmurvault/engines.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
class FasterWhisperEngine:
    """The built-in engine: faster-whisper (CTranslate2) running locally."""

    def __init__(self, name: str, model: str = "small", device: str = "auto", compute_type: str = "default"):
        """Configure the engine; the model loads on first use and is cached per process.

        Args:
            name: Engine name from the config.
            model: Whisper model name or path, e.g. ``small`` or ``large-v3``.
            device: ``auto``, ``cpu`` or ``cuda``.
            compute_type: CTranslate2 compute type.
        """
        self.name, self.model, self.device, self.compute_type = name, model, device, compute_type

    def _load(self):
        key = (self.model, self.device, self.compute_type)
        if key not in _WHISPER_CACHE:
            try:
                from faster_whisper import WhisperModel
            except ImportError as exc:
                raise RuntimeError("the built-in engine needs `pip install 'murmurvault[whisper]'`") from exc
            _WHISPER_CACHE[key] = WhisperModel(self.model, device=self.device, compute_type=self.compute_type)
        return _WHISPER_CACHE[key]

    def transcribe(self, audio: np.ndarray, language: str | None = None, fast: bool = False) -> list[Segment]:
        """Transcribe with voice-activity filtering.

        Args:
            audio: 16 kHz mono float32 samples.
            language: Language code, or None to auto-detect.
            fast: Greedy decoding without conditioning on previous text.

        Returns:
            Non-empty segments.
        """
        model = self._load()
        segments, _info = model.transcribe(
            audio,
            language=language or None,
            beam_size=1 if fast else 5,
            vad_filter=True,
            condition_on_previous_text=not fast,
        )
        return [Segment(start=s.start, end=s.end, text=s.text.strip()) for s in segments if s.text.strip()]

__init__(name, model='small', device='auto', compute_type='default')

Configure the engine; the model loads on first use and is cached per process.

Parameters:

Name Type Description Default
name str

Engine name from the config.

required
model str

Whisper model name or path, e.g. small or large-v3.

'small'
device str

auto, cpu or cuda.

'auto'
compute_type str

CTranslate2 compute type.

'default'
Source code in src/murmurvault/engines.py
46
47
48
49
50
51
52
53
54
55
def __init__(self, name: str, model: str = "small", device: str = "auto", compute_type: str = "default"):
    """Configure the engine; the model loads on first use and is cached per process.

    Args:
        name: Engine name from the config.
        model: Whisper model name or path, e.g. ``small`` or ``large-v3``.
        device: ``auto``, ``cpu`` or ``cuda``.
        compute_type: CTranslate2 compute type.
    """
    self.name, self.model, self.device, self.compute_type = name, model, device, compute_type

transcribe(audio, language=None, fast=False)

Transcribe with voice-activity filtering.

Parameters:

Name Type Description Default
audio ndarray

16 kHz mono float32 samples.

required
language str | None

Language code, or None to auto-detect.

None
fast bool

Greedy decoding without conditioning on previous text.

False

Returns:

Type Description
list[Segment]

Non-empty segments.

Source code in src/murmurvault/engines.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
def transcribe(self, audio: np.ndarray, language: str | None = None, fast: bool = False) -> list[Segment]:
    """Transcribe with voice-activity filtering.

    Args:
        audio: 16 kHz mono float32 samples.
        language: Language code, or None to auto-detect.
        fast: Greedy decoding without conditioning on previous text.

    Returns:
        Non-empty segments.
    """
    model = self._load()
    segments, _info = model.transcribe(
        audio,
        language=language or None,
        beam_size=1 if fast else 5,
        vad_filter=True,
        condition_on_previous_text=not fast,
    )
    return [Segment(start=s.start, end=s.end, text=s.text.strip()) for s in segments if s.text.strip()]

OpenAICompatibleEngine

Any server implementing POST /v1/audio/transcriptions: OpenAI, Groq, speaches, whisper.cpp, ...

Source code in src/murmurvault/engines.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
class OpenAICompatibleEngine:
    """Any server implementing ``POST /v1/audio/transcriptions``: OpenAI, Groq, speaches, whisper.cpp, ..."""

    # Default upload length in seconds. Some servers (e.g. Qwen3-ASR on oMLX) return one segment per
    # upload, so the upload length bounds the timestamp resolution of the transcript and its search hits.
    CHUNK_S = 30.0

    def __init__(self, name: str, base_url: str, model: str, api_key: str | None = None, chunk_s: float = CHUNK_S):
        """Configure the endpoint.

        Args:
            name: Engine name from the config.
            base_url: API base URL, e.g. ``https://api.openai.com/v1``.
            model: Model name as the server knows it.
            api_key: Bearer token, if the server needs one.
            chunk_s: Maximum upload length in seconds, at least 0.1; uploads end at the quietest moment in
                their second half, and the last may run up to 100 ms over. Keep it under 10 minutes for hosted
                APIs' ~25 MB upload limit.
        """
        self.name, self.model = name, model
        self.base_url = base_url.rstrip("/")
        self.api_key = api_key
        self.chunk_s = chunk_s

    def _post(self, audio: np.ndarray, language: str | None, response_format: str) -> dict:
        import httpx

        buf = io.BytesIO()
        sf.write(buf, audio, SR, format="FLAC", subtype="PCM_16")
        data = {"model": self.model, "response_format": response_format}
        if language:
            data["language"] = language
        headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
        try:
            resp = httpx.post(
                f"{self.base_url}/audio/transcriptions",
                headers=headers,
                data=data,
                files={"file": ("audio.flac", buf.getvalue(), "audio/flac")},
                timeout=900,
            )
        except httpx.HTTPError as exc:
            raise RuntimeError(f"cannot reach {self.base_url}: {exc}") from exc
        if resp.status_code >= HTTPStatus.BAD_REQUEST:
            raise RuntimeError(f"{resp.status_code} from {self.base_url}: {resp.text[:300]}")
        return resp.json()

    def _one(self, audio: np.ndarray, language: str | None, offset: float) -> list[Segment]:
        try:
            body = self._post(audio, language, "verbose_json")
        except RuntimeError as exc:
            # Some models (e.g. gpt-4o-transcribe) only support plain json, which has no timestamps.
            if "response_format" not in str(exc) and "verbose_json" not in str(exc):
                raise
            body = self._post(audio, language, "json")
        segs = body.get("segments") or []
        if segs:
            return [
                Segment(start=offset + float(s["start"]), end=offset + float(s["end"]), text=s["text"].strip())
                for s in segs
                if s.get("text", "").strip()
            ]
        text = (body.get("text") or "").strip()
        return [Segment(start=offset, end=offset + len(audio) / SR, text=text)] if text else []

    def transcribe(self, audio: np.ndarray, language: str | None = None, fast: bool = False) -> list[Segment]:
        """Upload the audio in chunks cut at pauses and collect the segments.

        Args:
            audio: 16 kHz mono float32 samples.
            language: Language code, or None to auto-detect.
            fast: Ignored; the server decides how to decode.

        Returns:
            Segments with times relative to the start of ``audio``.
        """
        ranges = split_at_pauses(audio, self.chunk_s)
        # Clips of 100 ms or less are not uploaded on their own; send such a tail with the previous upload.
        if len(ranges) > 1 and ranges[-1][1] - ranges[-1][0] <= SR // 10:
            ranges[-2:] = [(ranges[-2][0], ranges[-1][1])]
        out: list[Segment] = []
        for start, end in ranges:
            if end - start > SR // 10:
                out.extend(self._one(audio[start:end], language, start / SR))
        return out

__init__(name, base_url, model, api_key=None, chunk_s=CHUNK_S)

Configure the endpoint.

Parameters:

Name Type Description Default
name str

Engine name from the config.

required
base_url str

API base URL, e.g. https://api.openai.com/v1.

required
model str

Model name as the server knows it.

required
api_key str | None

Bearer token, if the server needs one.

None
chunk_s float

Maximum upload length in seconds, at least 0.1; uploads end at the quietest moment in their second half, and the last may run up to 100 ms over. Keep it under 10 minutes for hosted APIs' ~25 MB upload limit.

CHUNK_S
Source code in src/murmurvault/engines.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def __init__(self, name: str, base_url: str, model: str, api_key: str | None = None, chunk_s: float = CHUNK_S):
    """Configure the endpoint.

    Args:
        name: Engine name from the config.
        base_url: API base URL, e.g. ``https://api.openai.com/v1``.
        model: Model name as the server knows it.
        api_key: Bearer token, if the server needs one.
        chunk_s: Maximum upload length in seconds, at least 0.1; uploads end at the quietest moment in
            their second half, and the last may run up to 100 ms over. Keep it under 10 minutes for hosted
            APIs' ~25 MB upload limit.
    """
    self.name, self.model = name, model
    self.base_url = base_url.rstrip("/")
    self.api_key = api_key
    self.chunk_s = chunk_s

transcribe(audio, language=None, fast=False)

Upload the audio in chunks cut at pauses and collect the segments.

Parameters:

Name Type Description Default
audio ndarray

16 kHz mono float32 samples.

required
language str | None

Language code, or None to auto-detect.

None
fast bool

Ignored; the server decides how to decode.

False

Returns:

Type Description
list[Segment]

Segments with times relative to the start of audio.

Source code in src/murmurvault/engines.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def transcribe(self, audio: np.ndarray, language: str | None = None, fast: bool = False) -> list[Segment]:
    """Upload the audio in chunks cut at pauses and collect the segments.

    Args:
        audio: 16 kHz mono float32 samples.
        language: Language code, or None to auto-detect.
        fast: Ignored; the server decides how to decode.

    Returns:
        Segments with times relative to the start of ``audio``.
    """
    ranges = split_at_pauses(audio, self.chunk_s)
    # Clips of 100 ms or less are not uploaded on their own; send such a tail with the previous upload.
    if len(ranges) > 1 and ranges[-1][1] - ranges[-1][0] <= SR // 10:
        ranges[-2:] = [(ranges[-2][0], ranges[-1][1])]
    out: list[Segment] = []
    for start, end in ranges:
        if end - start > SR // 10:
            out.extend(self._one(audio[start:end], language, start / SR))
    return out

get_engine(cfg, name=None, model=None)

Build an engine from the [engines] config.

Parameters:

Name Type Description Default
cfg dict

The configuration.

required
name str | None

Engine name; defaults to transcription.engine.

None
model str | None

Override the engine's configured model.

None

Returns:

Type Description
Engine

The engine.

Raises:

Type Description
KeyError

If no engine has that name.

ValueError

If the engine's type is unknown.

Source code in src/murmurvault/engines.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
def get_engine(cfg: dict, name: str | None = None, model: str | None = None) -> Engine:
    """Build an engine from the ``[engines]`` config.

    Args:
        cfg: The configuration.
        name: Engine name; defaults to ``transcription.engine``.
        model: Override the engine's configured model.

    Returns:
        The engine.

    Raises:
        KeyError: If no engine has that name.
        ValueError: If the engine's ``type`` is unknown.
    """
    name = name or cfg["transcription"]["engine"]
    try:
        section = cfg["engines"][name]
    except KeyError as exc:
        raise KeyError(f"unknown engine {name!r}; configured: {', '.join(cfg['engines'])}") from exc
    kind = section.get("type", "faster-whisper")
    if kind == "faster-whisper":
        return FasterWhisperEngine(
            name,
            model=model or section.get("model", "small"),
            device=section.get("device", "auto"),
            compute_type=section.get("compute_type", "default"),
        )
    if kind == "openai":
        return OpenAICompatibleEngine(
            name,
            base_url=section["base_url"],
            model=model or section["model"],
            api_key=config_mod.api_key(section),
            chunk_s=float(section.get("chunk_s", OpenAICompatibleEngine.CHUNK_S)),
        )
    raise ValueError(f"engine {name!r} has unknown type {kind!r} (expected faster-whisper or openai)")