murmurvault 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ """Local-first recording, transcription and semantic search for meetings and voice notes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from murmurvault.version import __version__
6
+
7
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ """Main entry point for ``python -m murmurvault``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ if __name__ == "__main__":
6
+ from murmurvault.cli.main import app
7
+
8
+ app()
murmurvault/audio.py ADDED
@@ -0,0 +1,546 @@
1
+ """Audio capture and file I/O.
2
+
3
+ Every recording keeps the microphone and the computer's own audio on separate 16 kHz mono tracks. Keeping
4
+ them apart gives a free "me vs. others" speaker split and gives diarization a cleaner signal.
5
+ """
6
+
7
+ # Optional and platform-specific dependencies (sounddevice needs PortAudio, catap is macOS-only, PyAV
8
+ # comes with the av or whisper extra) are imported where they are used, so the rest of the package works
9
+ # without them.
10
+ # ruff: noqa: PLC0415
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import queue
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import threading
20
+ import time
21
+ from collections.abc import Callable
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+ import soundfile as sf
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Sample rate of every stored track, in Hz.
31
+ SR = 16000
32
+
33
+ Listener = Callable[[str, np.ndarray], None]
34
+ Feed = Callable[[np.ndarray, float], None]
35
+
36
+ _INT16_SCALE = 32768.0
37
+ _INT16_BYTES = 2
38
+ _FLOAT32_BYTES = 4
39
+ _MAX_CHANNELS = 2
40
+ _FRAMES_CHANNELS_NDIM = 2
41
+ _MIN_INTERP_SAMPLES = 2
42
+ _RATE_TOLERANCE = 1e-6
43
+
44
+
45
+ def to_mono(x: np.ndarray) -> np.ndarray:
46
+ """Average the channels of a ``(frames, channels)`` block.
47
+
48
+ Args:
49
+ x: Mono or multi-channel samples.
50
+
51
+ Returns:
52
+ Mono float32 samples.
53
+ """
54
+ x = np.asarray(x, dtype=np.float32)
55
+ return x.mean(axis=1) if x.ndim == _FRAMES_CHANNELS_NDIM else x
56
+
57
+
58
+ class Resampler:
59
+ """Streaming linear-interpolation resampler that keeps phase across blocks."""
60
+
61
+ def __init__(self, sr_in: float, sr_out: float = SR):
62
+ """Create a resampler.
63
+
64
+ Args:
65
+ sr_in: Input sample rate in Hz.
66
+ sr_out: Output sample rate in Hz.
67
+ """
68
+ self.step = sr_in / sr_out
69
+ self.pos = 0.0
70
+ self.buf = np.zeros(0, dtype=np.float32)
71
+ self.identity = abs(sr_in - sr_out) < _RATE_TOLERANCE
72
+
73
+ def __call__(self, x: np.ndarray) -> np.ndarray:
74
+ """Resample the next block.
75
+
76
+ Args:
77
+ x: Mono input samples continuing the previous block.
78
+
79
+ Returns:
80
+ The output samples that the input so far determines.
81
+ """
82
+ if self.identity:
83
+ return x
84
+ self.buf = np.concatenate([self.buf, x])
85
+ n = len(self.buf)
86
+ if n < _MIN_INTERP_SAMPLES:
87
+ return np.zeros(0, dtype=np.float32)
88
+ idx = np.arange(self.pos, n - 1, self.step)
89
+ out = np.interp(idx, np.arange(n), self.buf).astype(np.float32)
90
+ nxt = self.pos + len(idx) * self.step
91
+ # The next position can lie past the buffer; keep it relative to the samples still to come.
92
+ drop = min(int(nxt), n)
93
+ self.buf = self.buf[drop:]
94
+ self.pos = nxt - drop
95
+ return out
96
+
97
+
98
+ def resample(x: np.ndarray, sr_in: float, sr_out: float = SR) -> np.ndarray:
99
+ """Resample a whole signal to mono at ``sr_out``.
100
+
101
+ Args:
102
+ x: Mono or multi-channel samples.
103
+ sr_in: Input sample rate in Hz.
104
+ sr_out: Output sample rate in Hz.
105
+
106
+ Returns:
107
+ Mono float32 samples.
108
+ """
109
+ return Resampler(sr_in, sr_out)(to_mono(x))
110
+
111
+
112
+ def load_audio(path: str | Path) -> np.ndarray:
113
+ """Decode any audio or video file to 16 kHz mono float32.
114
+
115
+ libsndfile handles WAV/FLAC/OGG/MP3; anything else (m4a, webm, mp4, ...) goes through PyAV, which the
116
+ ``av`` extra installs (``whisper`` brings it too).
117
+
118
+ Args:
119
+ path: File to decode.
120
+
121
+ Returns:
122
+ The samples.
123
+
124
+ Raises:
125
+ RuntimeError: If the file cannot be decoded.
126
+ """
127
+ try:
128
+ data, sr = sf.read(str(path), dtype="float32", always_2d=False)
129
+ return resample(data, sr)
130
+ except RuntimeError: # soundfile.LibsndfileError: format not supported by libsndfile
131
+ pass
132
+ try:
133
+ import av
134
+ except ImportError as exc:
135
+ raise RuntimeError(f"cannot decode {path}: install `murmurvault[av]` for PyAV support") from exc
136
+ chunks = []
137
+ with av.open(str(path)) as container:
138
+ if not container.streams.audio:
139
+ raise RuntimeError(f"{path} has no audio stream")
140
+ resampler = av.AudioResampler(format="flt", layout="mono", rate=SR)
141
+ for frame in container.decode(container.streams.audio[0]):
142
+ chunks.extend(f.to_ndarray().reshape(-1) for f in resampler.resample(frame))
143
+ chunks.extend(f.to_ndarray().reshape(-1) for f in resampler.resample(None))
144
+ return np.concatenate(chunks).astype(np.float32) if chunks else np.zeros(0, np.float32)
145
+
146
+
147
+ def split_at_pauses(audio: np.ndarray, max_s: float, min_s: float | None = None) -> list[tuple[int, int]]:
148
+ """Cut a recording into chunks of at most ``max_s``, each ending at the quietest 100 ms frame.
149
+
150
+ Args:
151
+ audio: 16 kHz mono samples.
152
+ max_s: Maximum chunk length in seconds; at least one 100 ms frame.
153
+ min_s: Earliest point in a chunk to look for a pause; defaults to half of ``max_s``.
154
+
155
+ Returns:
156
+ ``(start, end)`` sample ranges covering ``audio`` without gaps.
157
+
158
+ Raises:
159
+ ValueError: If ``max_s`` is shorter than a frame or ``min_s`` is not in ``[0, max_s]``.
160
+ """
161
+ frame = SR // 10
162
+ min_s = max_s / 2 if min_s is None else min_s
163
+ if max_s * SR < frame or not 0 <= min_s <= max_s:
164
+ raise ValueError(f"need 0 <= min_s <= max_s and max_s >= 0.1, got min_s={min_s}, max_s={max_s}")
165
+ max_n = int(max_s * SR)
166
+ min_n = min(int(min_s * SR), max_n - frame)
167
+ ranges: list[tuple[int, int]] = []
168
+ start = 0
169
+ while len(audio) - start > max_n:
170
+ lo = start + (min_n // frame) * frame
171
+ hi = start + (max_n // frame) * frame
172
+ frames = audio[lo:hi].reshape(-1, frame)
173
+ cut = lo + (int(np.argmin(np.mean(np.square(frames), axis=1))) + 1) * frame
174
+ ranges.append((start, cut))
175
+ start = cut
176
+ if start < len(audio):
177
+ ranges.append((start, len(audio)))
178
+ return ranges
179
+
180
+
181
+ def save_flac(path: Path, audio: np.ndarray) -> None:
182
+ """Write 16 kHz mono samples as 16-bit FLAC.
183
+
184
+ Args:
185
+ path: Destination file.
186
+ audio: Samples at :data:`SR`.
187
+ """
188
+ sf.write(str(path), audio, SR, format="FLAC", subtype="PCM_16")
189
+
190
+
191
+ # -- tracks -------------------------------------------------------------------------------------
192
+
193
+
194
+ class Track:
195
+ """Receives raw blocks from a source thread, resamples to 16 kHz and writes FLAC off-thread."""
196
+
197
+ def __init__(self, name: str, path: Path, listeners: list[Listener]):
198
+ """Open the track file and start its writer thread.
199
+
200
+ Args:
201
+ name: Track name, e.g. ``mic`` or ``system``.
202
+ path: FLAC file to write.
203
+ listeners: Callbacks receiving each resampled block, e.g. the live transcriber.
204
+ """
205
+ self.name = name
206
+ self.path = path
207
+ self.listeners = listeners
208
+ self.file = sf.SoundFile(str(path), "w", SR, 1, format="FLAC", subtype="PCM_16")
209
+ self.queue: queue.Queue[tuple[np.ndarray, float] | None] = queue.Queue()
210
+ self.frames = 0
211
+ self.first_block_time: float | None = None
212
+ self.resampler: Resampler | None = None
213
+ self.thread = threading.Thread(target=self._run, name=f"track-{name}", daemon=True)
214
+ self.thread.start()
215
+
216
+ def feed(self, block: np.ndarray, sr: float) -> None:
217
+ """Queue a block of samples. Safe to call from a real-time audio callback.
218
+
219
+ Args:
220
+ block: Mono or multi-channel samples.
221
+ sr: Sample rate of the block in Hz.
222
+ """
223
+ if self.first_block_time is None:
224
+ # Time at which the first sample of this block was captured.
225
+ self.first_block_time = time.monotonic() - len(block) / sr
226
+ self.queue.put((to_mono(block).copy(), sr))
227
+
228
+ def _run(self) -> None:
229
+ while (item := self.queue.get()) is not None:
230
+ block, sr = item
231
+ if self.resampler is None:
232
+ self.resampler = Resampler(sr)
233
+ out = self.resampler(block)
234
+ if not len(out):
235
+ continue
236
+ self.file.write(out)
237
+ self.frames += len(out)
238
+ for listener in self.listeners:
239
+ try:
240
+ listener(self.name, out)
241
+ except Exception as exc: # noqa: BLE001 -- a broken live consumer must never lose audio
242
+ logger.warning("live listener failed: %s", exc)
243
+
244
+ def close(self) -> None:
245
+ """Flush queued blocks and close the file."""
246
+ self.queue.put(None)
247
+ self.thread.join()
248
+ self.file.close()
249
+
250
+
251
+ # -- sources ------------------------------------------------------------------------------------
252
+
253
+
254
+ class Source:
255
+ """An audio input that pushes blocks to a feed callback until stopped."""
256
+
257
+ def start(self, feed: Feed) -> None:
258
+ """Start capturing.
259
+
260
+ Args:
261
+ feed: Called with ``(samples, sample_rate)`` for every captured block.
262
+ """
263
+ raise NotImplementedError
264
+
265
+ def stop(self) -> None:
266
+ """Stop capturing and release the device."""
267
+ raise NotImplementedError
268
+
269
+
270
+ def _device(value: str | int | None) -> str | int | None:
271
+ if value in {None, ""}:
272
+ return None
273
+ if isinstance(value, str) and value.isdigit():
274
+ return int(value)
275
+ return value
276
+
277
+
278
+ class SoundDeviceSource(Source):
279
+ """Any PortAudio input: the microphone, or a loopback device such as BlackHole."""
280
+
281
+ def __init__(self, device: str | int | None = None):
282
+ """Select the input device.
283
+
284
+ Args:
285
+ device: Device name or index; None or ``""`` for the default input.
286
+ """
287
+ self.device = _device(device)
288
+ self.stream: Any = None
289
+
290
+ def start(self, feed: Feed) -> None:
291
+ """Open the device at its native rate and start streaming.
292
+
293
+ Args:
294
+ feed: Called with every captured block.
295
+ """
296
+ import sounddevice as sd
297
+
298
+ info = sd.query_devices(self.device, "input")
299
+ sr = float(info["default_samplerate"])
300
+ channels = max(1, min(_MAX_CHANNELS, int(info["max_input_channels"])))
301
+
302
+ def callback(indata, _frames, _time_info, _status):
303
+ feed(indata, sr)
304
+
305
+ self.stream = sd.InputStream(
306
+ device=self.device, channels=channels, samplerate=sr, dtype="float32", callback=callback
307
+ )
308
+ self.stream.start()
309
+
310
+ def stop(self) -> None:
311
+ """Close the stream."""
312
+ if self.stream is not None:
313
+ self.stream.stop()
314
+ self.stream.close()
315
+
316
+
317
+ class PulseMonitorSource(Source):
318
+ """Linux: capture what the speakers play via the PulseAudio/PipeWire monitor of the default sink."""
319
+
320
+ def __init__(self):
321
+ """Pick ``parec`` or ``pw-record``.
322
+
323
+ Raises:
324
+ RuntimeError: If neither tool is installed.
325
+ """
326
+ if parec := shutil.which("parec"):
327
+ self.cmd = [parec, "--device=@DEFAULT_MONITOR@", "--format=s16le", f"--rate={SR}", "--channels=1"]
328
+ elif pw_record := shutil.which("pw-record"):
329
+ self.cmd = [
330
+ pw_record, "-P", "{ stream.capture.sink = true }",
331
+ "--rate", str(SR), "--channels", "1", "--format", "s16", "-",
332
+ ] # fmt: skip
333
+ else:
334
+ raise RuntimeError(
335
+ "system audio capture needs `parec` (pulseaudio-utils) or `pw-record` (pipewire); "
336
+ "install one, set audio.system_device to a loopback input, or pass --no-system"
337
+ )
338
+ self.proc: subprocess.Popen | None = None
339
+ self.thread: threading.Thread | None = None
340
+
341
+ def start(self, feed: Feed) -> None:
342
+ """Start the capture process and a thread reading its raw PCM output.
343
+
344
+ Args:
345
+ feed: Called with every captured block.
346
+ """
347
+ # The command is a fixed argument list built in __init__; nothing user-supplied reaches it.
348
+ proc = subprocess.Popen(self.cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) # noqa: S603
349
+ self.proc = proc
350
+ stdout = proc.stdout
351
+ if stdout is None:
352
+ raise RuntimeError("capture process has no stdout")
353
+
354
+ def reader():
355
+ block = SR // 10 * _INT16_BYTES # 100 ms of int16
356
+ while chunk := stdout.read(block):
357
+ usable = len(chunk) - len(chunk) % _INT16_BYTES
358
+ feed(np.frombuffer(chunk[:usable], dtype=np.int16).astype(np.float32) / _INT16_SCALE, SR)
359
+
360
+ self.thread = threading.Thread(target=reader, name="system-audio", daemon=True)
361
+ self.thread.start()
362
+
363
+ def stop(self) -> None:
364
+ """Terminate the capture process."""
365
+ if self.proc:
366
+ self.proc.terminate()
367
+ try:
368
+ self.proc.wait(timeout=3)
369
+ except subprocess.TimeoutExpired:
370
+ self.proc.kill()
371
+ if self.thread:
372
+ self.thread.join(timeout=3)
373
+
374
+
375
+ class CoreAudioTapSource(Source):
376
+ """macOS 14.2+: capture the system output mix through a Core Audio process tap (via ``catap``)."""
377
+
378
+ def __init__(self, scratch: Path):
379
+ """Check that ``catap`` is installed.
380
+
381
+ Args:
382
+ scratch: Path for the WAV file catap writes alongside the stream; removed on stop.
383
+
384
+ Raises:
385
+ RuntimeError: If ``catap`` is missing.
386
+ """
387
+ try:
388
+ import catap # noqa: F401
389
+ except ImportError as exc:
390
+ raise RuntimeError(
391
+ "system audio capture on macOS needs `pip install 'murmurvault[macos]'` (macOS 14.2+), "
392
+ "or set audio.system_device to a loopback input such as BlackHole"
393
+ ) from exc
394
+ self.scratch = scratch
395
+ self.session: Any = None
396
+
397
+ def start(self, feed: Feed) -> None:
398
+ """Create the tap and start streaming buffers.
399
+
400
+ Args:
401
+ feed: Called with every captured block.
402
+ """
403
+ from catap import record_system_audio
404
+
405
+ def on_buffer(buf):
406
+ raw = bytes(buf.bytes)
407
+ channels = max(1, int(getattr(buf.format, "channels", 1)))
408
+ width = len(raw) // max(1, buf.frame_count * channels)
409
+ if width == _FLOAT32_BYTES:
410
+ data = np.frombuffer(raw, dtype=np.float32)
411
+ elif width == _INT16_BYTES:
412
+ data = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / _INT16_SCALE
413
+ else:
414
+ return
415
+ data = data[: (len(data) // channels) * channels].reshape(-1, channels)
416
+ feed(data, float(buf.format.sample_rate))
417
+
418
+ self.session = record_system_audio(output_path=str(self.scratch), mono=True, on_buffer=on_buffer)
419
+ self.session.start()
420
+
421
+ def stop(self) -> None:
422
+ """Stop the tap and remove catap's own WAV file."""
423
+ if self.session is not None:
424
+ self.session.stop()
425
+ self.scratch.unlink(missing_ok=True)
426
+
427
+
428
+ def system_source(audio_cfg: dict, rec_dir: Path) -> Source:
429
+ """Choose how to capture the computer's audio output on this platform.
430
+
431
+ Args:
432
+ audio_cfg: The ``[audio]`` config section.
433
+ rec_dir: Recording directory, for scratch files.
434
+
435
+ Returns:
436
+ The source.
437
+
438
+ Raises:
439
+ RuntimeError: If the platform is not supported.
440
+ """
441
+ if audio_cfg.get("system_device"):
442
+ return SoundDeviceSource(audio_cfg["system_device"])
443
+ if sys.platform.startswith("linux"):
444
+ return PulseMonitorSource()
445
+ if sys.platform == "darwin":
446
+ return CoreAudioTapSource(rec_dir / ".catap-system.wav")
447
+ raise RuntimeError(f"system audio capture is not supported on {sys.platform} yet; pass --no-system")
448
+
449
+
450
+ class Recorder:
451
+ """Captures one or more sources, each to its own track file."""
452
+
453
+ def __init__(self, rec_dir: Path, audio_cfg: dict, mic: bool = True, system: bool = True):
454
+ """Set up the sources.
455
+
456
+ Args:
457
+ rec_dir: Recording directory to write the tracks into.
458
+ audio_cfg: The ``[audio]`` config section.
459
+ mic: Record the microphone.
460
+ system: Record the computer's audio output.
461
+
462
+ Raises:
463
+ ValueError: If both sources are disabled.
464
+ """
465
+ if not (mic or system):
466
+ raise ValueError("nothing to record: enable the microphone or system audio")
467
+ self.rec_dir = rec_dir
468
+ self.listeners: list[Listener] = []
469
+ self.sources: dict[str, Source] = {}
470
+ if mic:
471
+ self.sources["mic"] = SoundDeviceSource(audio_cfg.get("mic_device"))
472
+ if system:
473
+ self.sources["system"] = system_source(audio_cfg, rec_dir)
474
+ self.tracks: dict[str, Track] = {}
475
+ self.t0 = 0.0
476
+
477
+ def add_listener(self, fn: Listener) -> None:
478
+ """Register a callback for every resampled block of every track.
479
+
480
+ Args:
481
+ fn: Called with ``(track_name, samples)``.
482
+ """
483
+ self.listeners.append(fn)
484
+
485
+ def start(self) -> None:
486
+ """Open the track files and start every source; on failure, stop what was started."""
487
+ self.t0 = time.monotonic()
488
+ try:
489
+ for name, source in self.sources.items():
490
+ track = Track(name, self.rec_dir / f"{name}.flac", self.listeners)
491
+ self.tracks[name] = track
492
+ source.start(track.feed)
493
+ except Exception:
494
+ self.stop()
495
+ raise
496
+
497
+ def elapsed(self) -> float:
498
+ """Seconds since :meth:`start`.
499
+
500
+ Returns:
501
+ Elapsed time.
502
+ """
503
+ return time.monotonic() - self.t0
504
+
505
+ def stop(self) -> tuple[dict[str, str], dict[str, float], float]:
506
+ """Stop all sources and close the tracks; empty tracks are deleted.
507
+
508
+ Returns:
509
+ Track name to file name, track name to start offset in seconds, and the total length.
510
+ """
511
+ for source in self.sources.values():
512
+ try:
513
+ source.stop()
514
+ except Exception as exc: # noqa: BLE001 -- one failing device must not keep the others open
515
+ logger.warning("stopping a source failed: %s", exc)
516
+ tracks, offsets, longest = {}, {}, 0.0
517
+ for name, track in self.tracks.items():
518
+ track.close()
519
+ if track.frames == 0:
520
+ track.path.unlink(missing_ok=True)
521
+ continue
522
+ tracks[name] = track.path.name
523
+ first = track.first_block_time if track.first_block_time is not None else self.t0
524
+ offsets[name] = round(max(0.0, first - self.t0), 3)
525
+ longest = max(longest, offsets[name] + track.frames / SR)
526
+ return tracks, offsets, longest
527
+
528
+
529
+ def list_devices() -> list[dict[str, Any]]:
530
+ """List PortAudio devices.
531
+
532
+ Returns:
533
+ One dict per device with its index, name, channel counts and default rate.
534
+ """
535
+ import sounddevice as sd
536
+
537
+ return [
538
+ {
539
+ "index": idx,
540
+ "name": dev["name"],
541
+ "inputs": dev["max_input_channels"],
542
+ "outputs": dev["max_output_channels"],
543
+ "default_samplerate": dev["default_samplerate"],
544
+ }
545
+ for idx, dev in enumerate(sd.query_devices())
546
+ ]
@@ -0,0 +1 @@
1
+ """Command line interface package for murmurvault."""