Skip to content

Audio

Audio capture and file I/O.

Every recording keeps the microphone and the computer's own audio on separate 16 kHz mono tracks. Keeping them apart gives a free "me vs. others" speaker split and gives diarization a cleaner signal.

CoreAudioTapSource

Bases: Source

macOS 14.2+: capture the system output mix through a Core Audio process tap (via catap).

Source code in src/murmurvault/audio.py
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
class CoreAudioTapSource(Source):
    """macOS 14.2+: capture the system output mix through a Core Audio process tap (via ``catap``)."""

    def __init__(self, scratch: Path):
        """Check that ``catap`` is installed.

        Args:
            scratch: Path for the WAV file catap writes alongside the stream; removed on stop.

        Raises:
            RuntimeError: If ``catap`` is missing.
        """
        try:
            import catap  # noqa: F401
        except ImportError as exc:
            raise RuntimeError(
                "system audio capture on macOS needs `pip install 'murmurvault[macos]'` (macOS 14.2+), "
                "or set audio.system_device to a loopback input such as BlackHole"
            ) from exc
        self.scratch = scratch
        self.session: Any = None

    def start(self, feed: Feed) -> None:
        """Create the tap and start streaming buffers.

        Args:
            feed: Called with every captured block.
        """
        from catap import record_system_audio

        def on_buffer(buf):
            raw = bytes(buf.bytes)
            channels = max(1, int(getattr(buf.format, "channels", 1)))
            width = len(raw) // max(1, buf.frame_count * channels)
            if width == _FLOAT32_BYTES:
                data = np.frombuffer(raw, dtype=np.float32)
            elif width == _INT16_BYTES:
                data = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / _INT16_SCALE
            else:
                return
            data = data[: (len(data) // channels) * channels].reshape(-1, channels)
            feed(data, float(buf.format.sample_rate))

        self.session = record_system_audio(output_path=str(self.scratch), mono=True, on_buffer=on_buffer)
        self.session.start()

    def stop(self) -> None:
        """Stop the tap and remove catap's own WAV file."""
        if self.session is not None:
            self.session.stop()
        self.scratch.unlink(missing_ok=True)

__init__(scratch)

Check that catap is installed.

Parameters:

Name Type Description Default
scratch Path

Path for the WAV file catap writes alongside the stream; removed on stop.

required

Raises:

Type Description
RuntimeError

If catap is missing.

Source code in src/murmurvault/audio.py
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
def __init__(self, scratch: Path):
    """Check that ``catap`` is installed.

    Args:
        scratch: Path for the WAV file catap writes alongside the stream; removed on stop.

    Raises:
        RuntimeError: If ``catap`` is missing.
    """
    try:
        import catap  # noqa: F401
    except ImportError as exc:
        raise RuntimeError(
            "system audio capture on macOS needs `pip install 'murmurvault[macos]'` (macOS 14.2+), "
            "or set audio.system_device to a loopback input such as BlackHole"
        ) from exc
    self.scratch = scratch
    self.session: Any = None

start(feed)

Create the tap and start streaming buffers.

Parameters:

Name Type Description Default
feed Feed

Called with every captured block.

required
Source code in src/murmurvault/audio.py
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
def start(self, feed: Feed) -> None:
    """Create the tap and start streaming buffers.

    Args:
        feed: Called with every captured block.
    """
    from catap import record_system_audio

    def on_buffer(buf):
        raw = bytes(buf.bytes)
        channels = max(1, int(getattr(buf.format, "channels", 1)))
        width = len(raw) // max(1, buf.frame_count * channels)
        if width == _FLOAT32_BYTES:
            data = np.frombuffer(raw, dtype=np.float32)
        elif width == _INT16_BYTES:
            data = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / _INT16_SCALE
        else:
            return
        data = data[: (len(data) // channels) * channels].reshape(-1, channels)
        feed(data, float(buf.format.sample_rate))

    self.session = record_system_audio(output_path=str(self.scratch), mono=True, on_buffer=on_buffer)
    self.session.start()

stop()

Stop the tap and remove catap's own WAV file.

Source code in src/murmurvault/audio.py
421
422
423
424
425
def stop(self) -> None:
    """Stop the tap and remove catap's own WAV file."""
    if self.session is not None:
        self.session.stop()
    self.scratch.unlink(missing_ok=True)

PulseMonitorSource

Bases: Source

Linux: capture what the speakers play via the PulseAudio/PipeWire monitor of the default sink.

Source code in src/murmurvault/audio.py
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
class PulseMonitorSource(Source):
    """Linux: capture what the speakers play via the PulseAudio/PipeWire monitor of the default sink."""

    def __init__(self):
        """Pick ``parec`` or ``pw-record``.

        Raises:
            RuntimeError: If neither tool is installed.
        """
        if parec := shutil.which("parec"):
            self.cmd = [parec, "--device=@DEFAULT_MONITOR@", "--format=s16le", f"--rate={SR}", "--channels=1"]
        elif pw_record := shutil.which("pw-record"):
            self.cmd = [
                pw_record, "-P", "{ stream.capture.sink = true }",
                "--rate", str(SR), "--channels", "1", "--format", "s16", "-",
            ]  # fmt: skip
        else:
            raise RuntimeError(
                "system audio capture needs `parec` (pulseaudio-utils) or `pw-record` (pipewire); "
                "install one, set audio.system_device to a loopback input, or pass --no-system"
            )
        self.proc: subprocess.Popen | None = None
        self.thread: threading.Thread | None = None

    def start(self, feed: Feed) -> None:
        """Start the capture process and a thread reading its raw PCM output.

        Args:
            feed: Called with every captured block.
        """
        # The command is a fixed argument list built in __init__; nothing user-supplied reaches it.
        proc = subprocess.Popen(self.cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)  # noqa: S603
        self.proc = proc
        stdout = proc.stdout
        if stdout is None:
            raise RuntimeError("capture process has no stdout")

        def reader():
            block = SR // 10 * _INT16_BYTES  # 100 ms of int16
            while chunk := stdout.read(block):
                usable = len(chunk) - len(chunk) % _INT16_BYTES
                feed(np.frombuffer(chunk[:usable], dtype=np.int16).astype(np.float32) / _INT16_SCALE, SR)

        self.thread = threading.Thread(target=reader, name="system-audio", daemon=True)
        self.thread.start()

    def stop(self) -> None:
        """Terminate the capture process."""
        if self.proc:
            self.proc.terminate()
            try:
                self.proc.wait(timeout=3)
            except subprocess.TimeoutExpired:
                self.proc.kill()
        if self.thread:
            self.thread.join(timeout=3)

__init__()

Pick parec or pw-record.

Raises:

Type Description
RuntimeError

If neither tool is installed.

Source code in src/murmurvault/audio.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
def __init__(self):
    """Pick ``parec`` or ``pw-record``.

    Raises:
        RuntimeError: If neither tool is installed.
    """
    if parec := shutil.which("parec"):
        self.cmd = [parec, "--device=@DEFAULT_MONITOR@", "--format=s16le", f"--rate={SR}", "--channels=1"]
    elif pw_record := shutil.which("pw-record"):
        self.cmd = [
            pw_record, "-P", "{ stream.capture.sink = true }",
            "--rate", str(SR), "--channels", "1", "--format", "s16", "-",
        ]  # fmt: skip
    else:
        raise RuntimeError(
            "system audio capture needs `parec` (pulseaudio-utils) or `pw-record` (pipewire); "
            "install one, set audio.system_device to a loopback input, or pass --no-system"
        )
    self.proc: subprocess.Popen | None = None
    self.thread: threading.Thread | None = None

start(feed)

Start the capture process and a thread reading its raw PCM output.

Parameters:

Name Type Description Default
feed Feed

Called with every captured block.

required
Source code in src/murmurvault/audio.py
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
def start(self, feed: Feed) -> None:
    """Start the capture process and a thread reading its raw PCM output.

    Args:
        feed: Called with every captured block.
    """
    # The command is a fixed argument list built in __init__; nothing user-supplied reaches it.
    proc = subprocess.Popen(self.cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)  # noqa: S603
    self.proc = proc
    stdout = proc.stdout
    if stdout is None:
        raise RuntimeError("capture process has no stdout")

    def reader():
        block = SR // 10 * _INT16_BYTES  # 100 ms of int16
        while chunk := stdout.read(block):
            usable = len(chunk) - len(chunk) % _INT16_BYTES
            feed(np.frombuffer(chunk[:usable], dtype=np.int16).astype(np.float32) / _INT16_SCALE, SR)

    self.thread = threading.Thread(target=reader, name="system-audio", daemon=True)
    self.thread.start()

stop()

Terminate the capture process.

Source code in src/murmurvault/audio.py
363
364
365
366
367
368
369
370
371
372
def stop(self) -> None:
    """Terminate the capture process."""
    if self.proc:
        self.proc.terminate()
        try:
            self.proc.wait(timeout=3)
        except subprocess.TimeoutExpired:
            self.proc.kill()
    if self.thread:
        self.thread.join(timeout=3)

Recorder

Captures one or more sources, each to its own track file.

Source code in src/murmurvault/audio.py
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
class Recorder:
    """Captures one or more sources, each to its own track file."""

    def __init__(self, rec_dir: Path, audio_cfg: dict, mic: bool = True, system: bool = True):
        """Set up the sources.

        Args:
            rec_dir: Recording directory to write the tracks into.
            audio_cfg: The ``[audio]`` config section.
            mic: Record the microphone.
            system: Record the computer's audio output.

        Raises:
            ValueError: If both sources are disabled.
        """
        if not (mic or system):
            raise ValueError("nothing to record: enable the microphone or system audio")
        self.rec_dir = rec_dir
        self.listeners: list[Listener] = []
        self.sources: dict[str, Source] = {}
        if mic:
            self.sources["mic"] = SoundDeviceSource(audio_cfg.get("mic_device"))
        if system:
            self.sources["system"] = system_source(audio_cfg, rec_dir)
        self.tracks: dict[str, Track] = {}
        self.t0 = 0.0

    def add_listener(self, fn: Listener) -> None:
        """Register a callback for every resampled block of every track.

        Args:
            fn: Called with ``(track_name, samples)``.
        """
        self.listeners.append(fn)

    def start(self) -> None:
        """Open the track files and start every source; on failure, stop what was started."""
        self.t0 = time.monotonic()
        try:
            for name, source in self.sources.items():
                track = Track(name, self.rec_dir / f"{name}.flac", self.listeners)
                self.tracks[name] = track
                source.start(track.feed)
        except Exception:
            self.stop()
            raise

    def elapsed(self) -> float:
        """Seconds since :meth:`start`.

        Returns:
            Elapsed time.
        """
        return time.monotonic() - self.t0

    def stop(self) -> tuple[dict[str, str], dict[str, float], float]:
        """Stop all sources and close the tracks; empty tracks are deleted.

        Returns:
            Track name to file name, track name to start offset in seconds, and the total length.
        """
        for source in self.sources.values():
            try:
                source.stop()
            except Exception as exc:  # noqa: BLE001 -- one failing device must not keep the others open
                logger.warning("stopping a source failed: %s", exc)
        tracks, offsets, longest = {}, {}, 0.0
        for name, track in self.tracks.items():
            track.close()
            if track.frames == 0:
                track.path.unlink(missing_ok=True)
                continue
            tracks[name] = track.path.name
            first = track.first_block_time if track.first_block_time is not None else self.t0
            offsets[name] = round(max(0.0, first - self.t0), 3)
            longest = max(longest, offsets[name] + track.frames / SR)
        return tracks, offsets, longest

__init__(rec_dir, audio_cfg, mic=True, system=True)

Set up the sources.

Parameters:

Name Type Description Default
rec_dir Path

Recording directory to write the tracks into.

required
audio_cfg dict

The [audio] config section.

required
mic bool

Record the microphone.

True
system bool

Record the computer's audio output.

True

Raises:

Type Description
ValueError

If both sources are disabled.

Source code in src/murmurvault/audio.py
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
def __init__(self, rec_dir: Path, audio_cfg: dict, mic: bool = True, system: bool = True):
    """Set up the sources.

    Args:
        rec_dir: Recording directory to write the tracks into.
        audio_cfg: The ``[audio]`` config section.
        mic: Record the microphone.
        system: Record the computer's audio output.

    Raises:
        ValueError: If both sources are disabled.
    """
    if not (mic or system):
        raise ValueError("nothing to record: enable the microphone or system audio")
    self.rec_dir = rec_dir
    self.listeners: list[Listener] = []
    self.sources: dict[str, Source] = {}
    if mic:
        self.sources["mic"] = SoundDeviceSource(audio_cfg.get("mic_device"))
    if system:
        self.sources["system"] = system_source(audio_cfg, rec_dir)
    self.tracks: dict[str, Track] = {}
    self.t0 = 0.0

add_listener(fn)

Register a callback for every resampled block of every track.

Parameters:

Name Type Description Default
fn Listener

Called with (track_name, samples).

required
Source code in src/murmurvault/audio.py
477
478
479
480
481
482
483
def add_listener(self, fn: Listener) -> None:
    """Register a callback for every resampled block of every track.

    Args:
        fn: Called with ``(track_name, samples)``.
    """
    self.listeners.append(fn)

elapsed()

Seconds since :meth:start.

Returns:

Type Description
float

Elapsed time.

Source code in src/murmurvault/audio.py
497
498
499
500
501
502
503
def elapsed(self) -> float:
    """Seconds since :meth:`start`.

    Returns:
        Elapsed time.
    """
    return time.monotonic() - self.t0

start()

Open the track files and start every source; on failure, stop what was started.

Source code in src/murmurvault/audio.py
485
486
487
488
489
490
491
492
493
494
495
def start(self) -> None:
    """Open the track files and start every source; on failure, stop what was started."""
    self.t0 = time.monotonic()
    try:
        for name, source in self.sources.items():
            track = Track(name, self.rec_dir / f"{name}.flac", self.listeners)
            self.tracks[name] = track
            source.start(track.feed)
    except Exception:
        self.stop()
        raise

stop()

Stop all sources and close the tracks; empty tracks are deleted.

Returns:

Type Description
tuple[dict[str, str], dict[str, float], float]

Track name to file name, track name to start offset in seconds, and the total length.

Source code in src/murmurvault/audio.py
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
def stop(self) -> tuple[dict[str, str], dict[str, float], float]:
    """Stop all sources and close the tracks; empty tracks are deleted.

    Returns:
        Track name to file name, track name to start offset in seconds, and the total length.
    """
    for source in self.sources.values():
        try:
            source.stop()
        except Exception as exc:  # noqa: BLE001 -- one failing device must not keep the others open
            logger.warning("stopping a source failed: %s", exc)
    tracks, offsets, longest = {}, {}, 0.0
    for name, track in self.tracks.items():
        track.close()
        if track.frames == 0:
            track.path.unlink(missing_ok=True)
            continue
        tracks[name] = track.path.name
        first = track.first_block_time if track.first_block_time is not None else self.t0
        offsets[name] = round(max(0.0, first - self.t0), 3)
        longest = max(longest, offsets[name] + track.frames / SR)
    return tracks, offsets, longest

Resampler

Streaming linear-interpolation resampler that keeps phase across blocks.

Source code in src/murmurvault/audio.py
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
87
88
89
90
91
92
93
94
95
class Resampler:
    """Streaming linear-interpolation resampler that keeps phase across blocks."""

    def __init__(self, sr_in: float, sr_out: float = SR):
        """Create a resampler.

        Args:
            sr_in: Input sample rate in Hz.
            sr_out: Output sample rate in Hz.
        """
        self.step = sr_in / sr_out
        self.pos = 0.0
        self.buf = np.zeros(0, dtype=np.float32)
        self.identity = abs(sr_in - sr_out) < _RATE_TOLERANCE

    def __call__(self, x: np.ndarray) -> np.ndarray:
        """Resample the next block.

        Args:
            x: Mono input samples continuing the previous block.

        Returns:
            The output samples that the input so far determines.
        """
        if self.identity:
            return x
        self.buf = np.concatenate([self.buf, x])
        n = len(self.buf)
        if n < _MIN_INTERP_SAMPLES:
            return np.zeros(0, dtype=np.float32)
        idx = np.arange(self.pos, n - 1, self.step)
        out = np.interp(idx, np.arange(n), self.buf).astype(np.float32)
        nxt = self.pos + len(idx) * self.step
        # The next position can lie past the buffer; keep it relative to the samples still to come.
        drop = min(int(nxt), n)
        self.buf = self.buf[drop:]
        self.pos = nxt - drop
        return out

__call__(x)

Resample the next block.

Parameters:

Name Type Description Default
x ndarray

Mono input samples continuing the previous block.

required

Returns:

Type Description
ndarray

The output samples that the input so far determines.

Source code in src/murmurvault/audio.py
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def __call__(self, x: np.ndarray) -> np.ndarray:
    """Resample the next block.

    Args:
        x: Mono input samples continuing the previous block.

    Returns:
        The output samples that the input so far determines.
    """
    if self.identity:
        return x
    self.buf = np.concatenate([self.buf, x])
    n = len(self.buf)
    if n < _MIN_INTERP_SAMPLES:
        return np.zeros(0, dtype=np.float32)
    idx = np.arange(self.pos, n - 1, self.step)
    out = np.interp(idx, np.arange(n), self.buf).astype(np.float32)
    nxt = self.pos + len(idx) * self.step
    # The next position can lie past the buffer; keep it relative to the samples still to come.
    drop = min(int(nxt), n)
    self.buf = self.buf[drop:]
    self.pos = nxt - drop
    return out

__init__(sr_in, sr_out=SR)

Create a resampler.

Parameters:

Name Type Description Default
sr_in float

Input sample rate in Hz.

required
sr_out float

Output sample rate in Hz.

SR
Source code in src/murmurvault/audio.py
61
62
63
64
65
66
67
68
69
70
71
def __init__(self, sr_in: float, sr_out: float = SR):
    """Create a resampler.

    Args:
        sr_in: Input sample rate in Hz.
        sr_out: Output sample rate in Hz.
    """
    self.step = sr_in / sr_out
    self.pos = 0.0
    self.buf = np.zeros(0, dtype=np.float32)
    self.identity = abs(sr_in - sr_out) < _RATE_TOLERANCE

SoundDeviceSource

Bases: Source

Any PortAudio input: the microphone, or a loopback device such as BlackHole.

Source code in src/murmurvault/audio.py
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
class SoundDeviceSource(Source):
    """Any PortAudio input: the microphone, or a loopback device such as BlackHole."""

    def __init__(self, device: str | int | None = None):
        """Select the input device.

        Args:
            device: Device name or index; None or ``""`` for the default input.
        """
        self.device = _device(device)
        self.stream: Any = None

    def start(self, feed: Feed) -> None:
        """Open the device at its native rate and start streaming.

        Args:
            feed: Called with every captured block.
        """
        import sounddevice as sd

        info = sd.query_devices(self.device, "input")
        sr = float(info["default_samplerate"])
        channels = max(1, min(_MAX_CHANNELS, int(info["max_input_channels"])))

        def callback(indata, _frames, _time_info, _status):
            feed(indata, sr)

        self.stream = sd.InputStream(
            device=self.device, channels=channels, samplerate=sr, dtype="float32", callback=callback
        )
        self.stream.start()

    def stop(self) -> None:
        """Close the stream."""
        if self.stream is not None:
            self.stream.stop()
            self.stream.close()

__init__(device=None)

Select the input device.

Parameters:

Name Type Description Default
device str | int | None

Device name or index; None or "" for the default input.

None
Source code in src/murmurvault/audio.py
281
282
283
284
285
286
287
288
def __init__(self, device: str | int | None = None):
    """Select the input device.

    Args:
        device: Device name or index; None or ``""`` for the default input.
    """
    self.device = _device(device)
    self.stream: Any = None

start(feed)

Open the device at its native rate and start streaming.

Parameters:

Name Type Description Default
feed Feed

Called with every captured block.

required
Source code in src/murmurvault/audio.py
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
def start(self, feed: Feed) -> None:
    """Open the device at its native rate and start streaming.

    Args:
        feed: Called with every captured block.
    """
    import sounddevice as sd

    info = sd.query_devices(self.device, "input")
    sr = float(info["default_samplerate"])
    channels = max(1, min(_MAX_CHANNELS, int(info["max_input_channels"])))

    def callback(indata, _frames, _time_info, _status):
        feed(indata, sr)

    self.stream = sd.InputStream(
        device=self.device, channels=channels, samplerate=sr, dtype="float32", callback=callback
    )
    self.stream.start()

stop()

Close the stream.

Source code in src/murmurvault/audio.py
310
311
312
313
314
def stop(self) -> None:
    """Close the stream."""
    if self.stream is not None:
        self.stream.stop()
        self.stream.close()

Source

An audio input that pushes blocks to a feed callback until stopped.

Source code in src/murmurvault/audio.py
254
255
256
257
258
259
260
261
262
263
264
265
266
267
class Source:
    """An audio input that pushes blocks to a feed callback until stopped."""

    def start(self, feed: Feed) -> None:
        """Start capturing.

        Args:
            feed: Called with ``(samples, sample_rate)`` for every captured block.
        """
        raise NotImplementedError

    def stop(self) -> None:
        """Stop capturing and release the device."""
        raise NotImplementedError

start(feed)

Start capturing.

Parameters:

Name Type Description Default
feed Feed

Called with (samples, sample_rate) for every captured block.

required
Source code in src/murmurvault/audio.py
257
258
259
260
261
262
263
def start(self, feed: Feed) -> None:
    """Start capturing.

    Args:
        feed: Called with ``(samples, sample_rate)`` for every captured block.
    """
    raise NotImplementedError

stop()

Stop capturing and release the device.

Source code in src/murmurvault/audio.py
265
266
267
def stop(self) -> None:
    """Stop capturing and release the device."""
    raise NotImplementedError

Track

Receives raw blocks from a source thread, resamples to 16 kHz and writes FLAC off-thread.

Source code in src/murmurvault/audio.py
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
class Track:
    """Receives raw blocks from a source thread, resamples to 16 kHz and writes FLAC off-thread."""

    def __init__(self, name: str, path: Path, listeners: list[Listener]):
        """Open the track file and start its writer thread.

        Args:
            name: Track name, e.g. ``mic`` or ``system``.
            path: FLAC file to write.
            listeners: Callbacks receiving each resampled block, e.g. the live transcriber.
        """
        self.name = name
        self.path = path
        self.listeners = listeners
        self.file = sf.SoundFile(str(path), "w", SR, 1, format="FLAC", subtype="PCM_16")
        self.queue: queue.Queue[tuple[np.ndarray, float] | None] = queue.Queue()
        self.frames = 0
        self.first_block_time: float | None = None
        self.resampler: Resampler | None = None
        self.thread = threading.Thread(target=self._run, name=f"track-{name}", daemon=True)
        self.thread.start()

    def feed(self, block: np.ndarray, sr: float) -> None:
        """Queue a block of samples. Safe to call from a real-time audio callback.

        Args:
            block: Mono or multi-channel samples.
            sr: Sample rate of the block in Hz.
        """
        if self.first_block_time is None:
            # Time at which the first sample of this block was captured.
            self.first_block_time = time.monotonic() - len(block) / sr
        self.queue.put((to_mono(block).copy(), sr))

    def _run(self) -> None:
        while (item := self.queue.get()) is not None:
            block, sr = item
            if self.resampler is None:
                self.resampler = Resampler(sr)
            out = self.resampler(block)
            if not len(out):
                continue
            self.file.write(out)
            self.frames += len(out)
            for listener in self.listeners:
                try:
                    listener(self.name, out)
                except Exception as exc:  # noqa: BLE001 -- a broken live consumer must never lose audio
                    logger.warning("live listener failed: %s", exc)

    def close(self) -> None:
        """Flush queued blocks and close the file."""
        self.queue.put(None)
        self.thread.join()
        self.file.close()

__init__(name, path, listeners)

Open the track file and start its writer thread.

Parameters:

Name Type Description Default
name str

Track name, e.g. mic or system.

required
path Path

FLAC file to write.

required
listeners list[Listener]

Callbacks receiving each resampled block, e.g. the live transcriber.

required
Source code in src/murmurvault/audio.py
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
def __init__(self, name: str, path: Path, listeners: list[Listener]):
    """Open the track file and start its writer thread.

    Args:
        name: Track name, e.g. ``mic`` or ``system``.
        path: FLAC file to write.
        listeners: Callbacks receiving each resampled block, e.g. the live transcriber.
    """
    self.name = name
    self.path = path
    self.listeners = listeners
    self.file = sf.SoundFile(str(path), "w", SR, 1, format="FLAC", subtype="PCM_16")
    self.queue: queue.Queue[tuple[np.ndarray, float] | None] = queue.Queue()
    self.frames = 0
    self.first_block_time: float | None = None
    self.resampler: Resampler | None = None
    self.thread = threading.Thread(target=self._run, name=f"track-{name}", daemon=True)
    self.thread.start()

close()

Flush queued blocks and close the file.

Source code in src/murmurvault/audio.py
244
245
246
247
248
def close(self) -> None:
    """Flush queued blocks and close the file."""
    self.queue.put(None)
    self.thread.join()
    self.file.close()

feed(block, sr)

Queue a block of samples. Safe to call from a real-time audio callback.

Parameters:

Name Type Description Default
block ndarray

Mono or multi-channel samples.

required
sr float

Sample rate of the block in Hz.

required
Source code in src/murmurvault/audio.py
216
217
218
219
220
221
222
223
224
225
226
def feed(self, block: np.ndarray, sr: float) -> None:
    """Queue a block of samples. Safe to call from a real-time audio callback.

    Args:
        block: Mono or multi-channel samples.
        sr: Sample rate of the block in Hz.
    """
    if self.first_block_time is None:
        # Time at which the first sample of this block was captured.
        self.first_block_time = time.monotonic() - len(block) / sr
    self.queue.put((to_mono(block).copy(), sr))

list_devices()

List PortAudio devices.

Returns:

Type Description
list[dict[str, Any]]

One dict per device with its index, name, channel counts and default rate.

Source code in src/murmurvault/audio.py
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
def list_devices() -> list[dict[str, Any]]:
    """List PortAudio devices.

    Returns:
        One dict per device with its index, name, channel counts and default rate.
    """
    import sounddevice as sd

    return [
        {
            "index": idx,
            "name": dev["name"],
            "inputs": dev["max_input_channels"],
            "outputs": dev["max_output_channels"],
            "default_samplerate": dev["default_samplerate"],
        }
        for idx, dev in enumerate(sd.query_devices())
    ]

load_audio(path)

Decode any audio or video file to 16 kHz mono float32.

libsndfile handles WAV/FLAC/OGG/MP3; anything else (m4a, webm, mp4, ...) goes through PyAV, which the av extra installs (whisper brings it too).

Parameters:

Name Type Description Default
path str | Path

File to decode.

required

Returns:

Type Description
ndarray

The samples.

Raises:

Type Description
RuntimeError

If the file cannot be decoded.

Source code in src/murmurvault/audio.py
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
def load_audio(path: str | Path) -> np.ndarray:
    """Decode any audio or video file to 16 kHz mono float32.

    libsndfile handles WAV/FLAC/OGG/MP3; anything else (m4a, webm, mp4, ...) goes through PyAV, which the
    ``av`` extra installs (``whisper`` brings it too).

    Args:
        path: File to decode.

    Returns:
        The samples.

    Raises:
        RuntimeError: If the file cannot be decoded.
    """
    try:
        data, sr = sf.read(str(path), dtype="float32", always_2d=False)
        return resample(data, sr)
    except RuntimeError:  # soundfile.LibsndfileError: format not supported by libsndfile
        pass
    try:
        import av
    except ImportError as exc:
        raise RuntimeError(f"cannot decode {path}: install `murmurvault[av]` for PyAV support") from exc
    chunks = []
    with av.open(str(path)) as container:
        if not container.streams.audio:
            raise RuntimeError(f"{path} has no audio stream")
        resampler = av.AudioResampler(format="flt", layout="mono", rate=SR)
        for frame in container.decode(container.streams.audio[0]):
            chunks.extend(f.to_ndarray().reshape(-1) for f in resampler.resample(frame))
        chunks.extend(f.to_ndarray().reshape(-1) for f in resampler.resample(None))
    return np.concatenate(chunks).astype(np.float32) if chunks else np.zeros(0, np.float32)

resample(x, sr_in, sr_out=SR)

Resample a whole signal to mono at sr_out.

Parameters:

Name Type Description Default
x ndarray

Mono or multi-channel samples.

required
sr_in float

Input sample rate in Hz.

required
sr_out float

Output sample rate in Hz.

SR

Returns:

Type Description
ndarray

Mono float32 samples.

Source code in src/murmurvault/audio.py
 98
 99
100
101
102
103
104
105
106
107
108
109
def resample(x: np.ndarray, sr_in: float, sr_out: float = SR) -> np.ndarray:
    """Resample a whole signal to mono at ``sr_out``.

    Args:
        x: Mono or multi-channel samples.
        sr_in: Input sample rate in Hz.
        sr_out: Output sample rate in Hz.

    Returns:
        Mono float32 samples.
    """
    return Resampler(sr_in, sr_out)(to_mono(x))

save_flac(path, audio)

Write 16 kHz mono samples as 16-bit FLAC.

Parameters:

Name Type Description Default
path Path

Destination file.

required
audio ndarray

Samples at :data:SR.

required
Source code in src/murmurvault/audio.py
181
182
183
184
185
186
187
188
def save_flac(path: Path, audio: np.ndarray) -> None:
    """Write 16 kHz mono samples as 16-bit FLAC.

    Args:
        path: Destination file.
        audio: Samples at :data:`SR`.
    """
    sf.write(str(path), audio, SR, format="FLAC", subtype="PCM_16")

split_at_pauses(audio, max_s, min_s=None)

Cut a recording into chunks of at most max_s, each ending at the quietest 100 ms frame.

Parameters:

Name Type Description Default
audio ndarray

16 kHz mono samples.

required
max_s float

Maximum chunk length in seconds; at least one 100 ms frame.

required
min_s float | None

Earliest point in a chunk to look for a pause; defaults to half of max_s.

None

Returns:

Type Description
list[tuple[int, int]]

(start, end) sample ranges covering audio without gaps.

Raises:

Type Description
ValueError

If max_s is shorter than a frame or min_s is not in [0, max_s].

Source code in src/murmurvault/audio.py
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
174
175
176
177
178
def split_at_pauses(audio: np.ndarray, max_s: float, min_s: float | None = None) -> list[tuple[int, int]]:
    """Cut a recording into chunks of at most ``max_s``, each ending at the quietest 100 ms frame.

    Args:
        audio: 16 kHz mono samples.
        max_s: Maximum chunk length in seconds; at least one 100 ms frame.
        min_s: Earliest point in a chunk to look for a pause; defaults to half of ``max_s``.

    Returns:
        ``(start, end)`` sample ranges covering ``audio`` without gaps.

    Raises:
        ValueError: If ``max_s`` is shorter than a frame or ``min_s`` is not in ``[0, max_s]``.
    """
    frame = SR // 10
    min_s = max_s / 2 if min_s is None else min_s
    if max_s * SR < frame or not 0 <= min_s <= max_s:
        raise ValueError(f"need 0 <= min_s <= max_s and max_s >= 0.1, got min_s={min_s}, max_s={max_s}")
    max_n = int(max_s * SR)
    min_n = min(int(min_s * SR), max_n - frame)
    ranges: list[tuple[int, int]] = []
    start = 0
    while len(audio) - start > max_n:
        lo = start + (min_n // frame) * frame
        hi = start + (max_n // frame) * frame
        frames = audio[lo:hi].reshape(-1, frame)
        cut = lo + (int(np.argmin(np.mean(np.square(frames), axis=1))) + 1) * frame
        ranges.append((start, cut))
        start = cut
    if start < len(audio):
        ranges.append((start, len(audio)))
    return ranges

system_source(audio_cfg, rec_dir)

Choose how to capture the computer's audio output on this platform.

Parameters:

Name Type Description Default
audio_cfg dict

The [audio] config section.

required
rec_dir Path

Recording directory, for scratch files.

required

Returns:

Type Description
Source

The source.

Raises:

Type Description
RuntimeError

If the platform is not supported.

Source code in src/murmurvault/audio.py
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
def system_source(audio_cfg: dict, rec_dir: Path) -> Source:
    """Choose how to capture the computer's audio output on this platform.

    Args:
        audio_cfg: The ``[audio]`` config section.
        rec_dir: Recording directory, for scratch files.

    Returns:
        The source.

    Raises:
        RuntimeError: If the platform is not supported.
    """
    if audio_cfg.get("system_device"):
        return SoundDeviceSource(audio_cfg["system_device"])
    if sys.platform.startswith("linux"):
        return PulseMonitorSource()
    if sys.platform == "darwin":
        return CoreAudioTapSource(rec_dir / ".catap-system.wav")
    raise RuntimeError(f"system audio capture is not supported on {sys.platform} yet; pass --no-system")

to_mono(x)

Average the channels of a (frames, channels) block.

Parameters:

Name Type Description Default
x ndarray

Mono or multi-channel samples.

required

Returns:

Type Description
ndarray

Mono float32 samples.

Source code in src/murmurvault/audio.py
45
46
47
48
49
50
51
52
53
54
55
def to_mono(x: np.ndarray) -> np.ndarray:
    """Average the channels of a ``(frames, channels)`` block.

    Args:
        x: Mono or multi-channel samples.

    Returns:
        Mono float32 samples.
    """
    x = np.asarray(x, dtype=np.float32)
    return x.mean(axis=1) if x.ndim == _FRAMES_CHANNELS_NDIM else x