Record at the input device's native sample rate

- Recorder queries default_samplerate and streams/WAVs at that rate
  instead of forcing 16 kHz (helps devices that don't support it
  natively and keeps recordings at full fidelity)
- new resample_16k() linear resampler: the live rolling window is
  converted to Whisper's 16 kHz before being handed to the engines
- snapshot() now returns (16 kHz audio, absolute start time in s)
- final pass unchanged: faster-whisper (PyAV) and whisper.cpp
  (miniaudio) resample native-rate WAVs themselves
- GUI status shows the active rate; README updated

Validated with real 44.1 kHz and 48 kHz inputs: WAV headers match the
device rate, live-window resampling is length- and spectrum-correct.
This commit is contained in:
2026-09-07 11:04:53 +02:00
parent 404f3db200
commit e14a6bfe9f
3 changed files with 40 additions and 13 deletions
+3 -1
View File
@@ -16,12 +16,14 @@ With output stem `-o meeting`:
| File | Contents | | File | Contents |
| ------------------ | ----------------------------------------------- | | ------------------ | ----------------------------------------------- |
| `meeting.wav` | raw recording (16 kHz mono WAV) | | `meeting.wav` | raw recording (mono WAV at the input's native rate) |
| `meeting.live.srt` | live rolling transcript (only with `--live > 0`) | | `meeting.live.srt` | live rolling transcript (only with `--live > 0`) |
| `meeting.txt` | final transcript | | `meeting.txt` | final transcript |
| `meeting.srt` | final transcript with timestamps | | `meeting.srt` | final transcript with timestamps |
| `meeting.json` | final transcript, structured (language, segments) | | `meeting.json` | final transcript, structured (language, segments) |
Recording always uses the input device's native sample rate; audio is resampled to Whisper's 16 kHz automatically (both for the live window and the final pass).
## Requirements ## Requirements
- Linux with an audio backend: **PipeWire** (or PulseAudio/ALSA). On Arch: `sudo pacman -S pipewire pipewire-pulse` - Linux with an audio backend: **PipeWire** (or PulseAudio/ALSA). On Arch: `sudo pacman -S pipewire pipewire-pulse`
+34 -11
View File
@@ -8,12 +8,15 @@ printing a live rolling transcript. On Ctrl+C it stops cleanly and runs a
final, higher-quality full transcription. final, higher-quality full transcription.
Outputs (with -o meeting): Outputs (with -o meeting):
meeting.wav raw recording, 16 kHz mono WAV meeting.wav raw recording, mono WAV at the input's native rate
meeting.live.srt live rolling transcript (only when --live > 0) meeting.live.srt live rolling transcript (only when --live > 0)
meeting.txt final transcript meeting.txt final transcript
meeting.srt final transcript with timestamps meeting.srt final transcript with timestamps
meeting.json final transcript, structured meeting.json final transcript, structured
Audio is recorded at the input device's native sample rate; both the live
window and the engines get it resampled to Whisper's 16 kHz internally.
Setup on Arch: Setup on Arch:
sudo pacman -S python pipewire pipewire-pulse sudo pacman -S python pipewire pipewire-pulse
python -m venv .venv && source .venv/bin/activate python -m venv .venv && source .venv/bin/activate
@@ -75,6 +78,22 @@ def write_srt(path: str, items) -> None:
f.write(f"{i}\n{fmt_srt(a)} --> {fmt_srt(b)}\n{text}\n\n") f.write(f"{i}\n{fmt_srt(a)} --> {fmt_srt(b)}\n{text}\n\n")
def resample_16k(audio: np.ndarray, rate: int) -> np.ndarray:
"""Linearly resample mono float32 audio to 16 kHz (Whisper's rate).
Only needed for the live rolling buffer, which is fed to the engines as
a raw array (they assume 16 kHz there). WAV files are resampled by the
engines themselves.
"""
audio = np.asarray(audio, dtype=np.float32)
if rate == SR or audio.size == 0:
return audio
n_out = max(1, int(round(audio.size * SR / rate)))
t_out = np.arange(n_out, dtype=np.float64) * (rate / SR)
return np.interp(t_out, np.arange(audio.size, dtype=np.float64),
audio).astype(np.float32)
def list_sources() -> None: def list_sources() -> None:
print("PortAudio (PipeWire/Pulse/ALSA) input devices:\n") print("PortAudio (PipeWire/Pulse/ALSA) input devices:\n")
for i, d in enumerate(sd.query_devices()): for i, d in enumerate(sd.query_devices()):
@@ -121,11 +140,15 @@ def resolve_source(name: str):
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
class Recorder: class Recorder:
"""Records mono 16 kHz float32 to a WAV file and keeps a rolling buffer.""" """Records mono float32 at the input's native rate to a WAV file and
keeps a rolling buffer for live transcription."""
def __init__(self, device, wav_path: str): def __init__(self, device, wav_path: str):
self.device = device self.device = device
self.wav_path = wav_path self.wav_path = wav_path
dev = sd.query_devices(kind="input") if device is None \
else sd.query_devices(device)
self.rate = int(dev["default_samplerate"]) # input's native rate
self.q: "queue.Queue" = queue.Queue() self.q: "queue.Queue" = queue.Queue()
self.lock = threading.Lock() self.lock = threading.Lock()
self.buf = np.zeros(0, dtype=np.float32) # rolling window self.buf = np.zeros(0, dtype=np.float32) # rolling window
@@ -146,7 +169,7 @@ class Recorder:
block = indata[:, 0].astype(np.float32, copy=False) block = indata[:, 0].astype(np.float32, copy=False)
self.buf = np.append(self.buf, block) self.buf = np.append(self.buf, block)
self.total += len(block) self.total += len(block)
limit = LIVE_WINDOW * SR limit = LIVE_WINDOW * self.rate
if len(self.buf) > limit: if len(self.buf) > limit:
drop = len(self.buf) - limit drop = len(self.buf) - limit
self.buf = self.buf[drop:] self.buf = self.buf[drop:]
@@ -154,13 +177,13 @@ class Recorder:
def duration(self) -> float: def duration(self) -> float:
with self.lock: with self.lock:
return self.total / SR return self.total / self.rate
def _write(self): def _write(self):
with wave.open(self.wav_path, "wb") as w: with wave.open(self.wav_path, "wb") as w:
w.setnchannels(1) w.setnchannels(1)
w.setsampwidth(2) w.setsampwidth(2)
w.setframerate(SR) w.setframerate(self.rate)
while True: while True:
block = self.q.get() block = self.q.get()
if block is None: if block is None:
@@ -169,13 +192,13 @@ class Recorder:
w.writeframes(pcm.tobytes()) w.writeframes(pcm.tobytes())
def snapshot(self): def snapshot(self):
"""Return (rolling audio, sample offset of its start).""" """Return (rolling window resampled to 16 kHz, its start time in s)."""
with self.lock: with self.lock:
return self.buf.copy(), self.base return resample_16k(self.buf, self.rate), self.base / self.rate
def start(self): def start(self):
self.stream = sd.InputStream( self.stream = sd.InputStream(
samplerate=SR, channels=1, dtype="float32", samplerate=self.rate, channels=1, dtype="float32",
device=self.device, callback=self._callback) device=self.device, callback=self._callback)
self.stream.start() self.stream.start()
@@ -224,7 +247,7 @@ def live_worker(model, rec: Recorder, args, live_srt_path: str,
stop.wait(args.live) stop.wait(args.live)
if stop.is_set(): if stop.is_set():
break break
buf, base = rec.snapshot() buf, base_s = rec.snapshot() # 16 kHz audio, absolute start time
if len(buf) < 2 * SR: # need at least 2 s of audio if len(buf) < 2 * SR: # need at least 2 s of audio
continue continue
try: try:
@@ -234,7 +257,6 @@ def live_worker(model, rec: Recorder, args, live_srt_path: str,
print(f"[live] transcription error: {e}", file=sys.stderr) print(f"[live] transcription error: {e}", file=sys.stderr)
continue continue
base_s = base / SR
added = False added = False
for s in segs: for s in segs:
a, b = base_s + s.start, base_s + s.end a, b = base_s + s.start, base_s + s.end
@@ -538,7 +560,8 @@ def main():
rec = Recorder(device, args.output + ".wav") rec = Recorder(device, args.output + ".wav")
rec.start() rec.start()
print(f"recording to {args.output}.wav (Ctrl+C to stop)", flush=True) print(f"recording to {args.output}.wav at {rec.rate} Hz "
f"(Ctrl+C to stop)", flush=True)
stop = threading.Event() stop = threading.Event()
worker = None worker = None
+3 -1
View File
@@ -400,7 +400,9 @@ class MeetRecWindow(QWidget):
name = sd.query_devices(idx)["name"] name = sd.query_devices(idx)["name"]
except Exception: except Exception:
name = self._args.source name = self._args.source
self.status.setText(f"Recording from: {name}{self._args.output}.wav") self.status.setText(
f"Recording from: {name} @ {self.rec.rate} Hz → "
f"{self._args.output}.wav")
if self._args.live: if self._args.live:
self.live_thread = threading.Thread( self.live_thread = threading.Thread(