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 |
| ------------------ | ----------------------------------------------- |
| `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.txt` | final transcript |
| `meeting.srt` | final transcript with timestamps |
| `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
- 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.
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.txt final transcript
meeting.srt final transcript with timestamps
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:
sudo pacman -S python pipewire pipewire-pulse
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")
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:
print("PortAudio (PipeWire/Pulse/ALSA) input devices:\n")
for i, d in enumerate(sd.query_devices()):
@@ -121,11 +140,15 @@ def resolve_source(name: str):
# --------------------------------------------------------------------------
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):
self.device = device
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.lock = threading.Lock()
self.buf = np.zeros(0, dtype=np.float32) # rolling window
@@ -146,7 +169,7 @@ class Recorder:
block = indata[:, 0].astype(np.float32, copy=False)
self.buf = np.append(self.buf, block)
self.total += len(block)
limit = LIVE_WINDOW * SR
limit = LIVE_WINDOW * self.rate
if len(self.buf) > limit:
drop = len(self.buf) - limit
self.buf = self.buf[drop:]
@@ -154,13 +177,13 @@ class Recorder:
def duration(self) -> float:
with self.lock:
return self.total / SR
return self.total / self.rate
def _write(self):
with wave.open(self.wav_path, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(SR)
w.setframerate(self.rate)
while True:
block = self.q.get()
if block is None:
@@ -169,13 +192,13 @@ class Recorder:
w.writeframes(pcm.tobytes())
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:
return self.buf.copy(), self.base
return resample_16k(self.buf, self.rate), self.base / self.rate
def start(self):
self.stream = sd.InputStream(
samplerate=SR, channels=1, dtype="float32",
samplerate=self.rate, channels=1, dtype="float32",
device=self.device, callback=self._callback)
self.stream.start()
@@ -224,7 +247,7 @@ def live_worker(model, rec: Recorder, args, live_srt_path: str,
stop.wait(args.live)
if stop.is_set():
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
continue
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)
continue
base_s = base / SR
added = False
for s in segs:
a, b = base_s + s.start, base_s + s.end
@@ -538,7 +560,8 @@ def main():
rec = Recorder(device, args.output + ".wav")
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()
worker = None
+3 -1
View File
@@ -400,7 +400,9 @@ class MeetRecWindow(QWidget):
name = sd.query_devices(idx)["name"]
except Exception:
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:
self.live_thread = threading.Thread(