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.
- murmurvault/__init__.py +7 -0
- murmurvault/__main__.py +8 -0
- murmurvault/audio.py +546 -0
- murmurvault/cli/__init__.py +1 -0
- murmurvault/cli/main.py +475 -0
- murmurvault/config.py +171 -0
- murmurvault/diarize.py +76 -0
- murmurvault/engines.py +212 -0
- murmurvault/live.py +158 -0
- murmurvault/pipeline.py +167 -0
- murmurvault/search.py +470 -0
- murmurvault/session.py +120 -0
- murmurvault/tui.py +444 -0
- murmurvault/vault.py +439 -0
- murmurvault/version.py +11 -0
- murmurvault-0.1.0.dist-info/METADATA +194 -0
- murmurvault-0.1.0.dist-info/RECORD +20 -0
- murmurvault-0.1.0.dist-info/WHEEL +4 -0
- murmurvault-0.1.0.dist-info/entry_points.txt +2 -0
- murmurvault-0.1.0.dist-info/licenses/LICENSE +26 -0
murmurvault/__init__.py
ADDED
murmurvault/__main__.py
ADDED
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."""
|