diff --git a/README.md b/README.md index ccf5acd..2a76fb8 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,19 @@ Set from the tray menu, or via files / env vars: Devices that refuse 16 kHz (raw ALSA and PipeWire's JACK endpoints do) are captured at their native rate and resampled. -Every clip is conditioned before upload — the first 150 ms are dropped (mics -click when the stream opens), DC bias is filtered out, and the level is -normalised towards -20 dBFS. Near-silent clips are refused instead of being -sent, since transcription models answer silence with confident hallucinations. +Every clip is conditioned before upload — the stream-open click is trimmed off +the front (only as much of it as is actually there, up to 150 ms), DC bias is +filtered out, and the level is normalised towards -20 dBFS. Near-silent clips +are refused instead of being sent, since transcription models answer silence +with confident hallucinations. + +Nothing else is cut: the whole recording is uploaded. If a transcript comes +back missing words, check `blurt.log` — each clip logs its length, speech level, +noise floor, and `dead=`, the longest run of digital silence inside it. A +non-zero `dead=` means the input dropped out mid-clip, which is what following +the system default source invites; pin a device instead. Set +`BLURT_SAVE_CAPTURES=1` (or to a directory) to keep the raw and uploaded WAVs +for every clip and hear exactly what the API got. ## Checking the microphone diff --git a/blurt.py b/blurt.py index 73712c7..7830e83 100644 --- a/blurt.py +++ b/blurt.py @@ -10,6 +10,7 @@ import subprocess import sys import tempfile import threading +import time from dataclasses import dataclass from logging.handlers import RotatingFileHandler from math import gcd @@ -77,7 +78,9 @@ TRANSCRIPTION_LANGUAGES = [ SAMPLE_RATE = 16000 CHANNELS = 1 DTYPE = "float32" # capture with headroom; the WAV sent to the API is int16 -WARMUP_SECONDS = 0.15 # drop the click many mics emit when the stream opens +WARMUP_SECONDS = 0.15 # most a stream-open click may cost us off the front +DEAD_AIR_SECONDS = 1.0 # digital silence this long is a dropped input, not a pause +DEAD_AIR_DBFS = -75.0 # below any real mic's self-noise: nothing arrived at all HIGHPASS_HZ = 70.0 # strip DC bias and rumble (raw digital mics carry a lot) TARGET_RMS_DBFS = -20.0 PEAK_CEILING_DBFS = -1.0 @@ -307,30 +310,75 @@ def resample_to_target(samples: np.ndarray, rate: int) -> np.ndarray: return resampled.astype(np.float32) -def without_warmup(samples: np.ndarray) -> np.ndarray: - """The clip minus the stream-open click, which otherwise skews every stat.""" - warmup = int(WARMUP_SECONDS * SAMPLE_RATE) - return samples[warmup:] if len(samples) > warmup * 2 else samples +def leading_click_samples(samples: np.ndarray) -> int: + """Length of the stream-open click, capped at WARMUP_SECONDS. Cutting a fixed + slice off the front of every clip costs a syllable on mics that do not click, + so cut only what is genuinely louder than the rest of the recording.""" + cap = int(WARMUP_SECONDS * SAMPLE_RATE) + frame = SAMPLE_RATE // 200 + if len(samples) < cap * 2: + return 0 + centered = samples - float(samples.mean()) + body = float(np.percentile(np.abs(centered[cap:]), 99.9)) + head = np.abs(centered[:cap]).reshape(-1, frame).max(axis=1) + loud = np.flatnonzero(head > max(body * 2.0, 0.02)) + return int(loud[-1] + 1) * frame if len(loud) else 0 + + +def frame_levels(samples: np.ndarray) -> np.ndarray: + """Per-20 ms RMS level in dBFS: the resolution speech energy lives at. + Each frame is centred on itself, so a steady DC bias — or a stream frozen + on one value — reads as the silence it actually is.""" + frame = SAMPLE_RATE // 50 + if len(samples) < frame * 2: + return np.full(1, -120.0) + usable = samples[: len(samples) // frame * frame].reshape(-1, frame) + centered = usable - usable.mean(axis=1, keepdims=True) + energy = np.sqrt(np.mean(np.square(centered), axis=1)) + return 20 * np.log10(np.maximum(energy, 1e-6)) def speech_level_dbfs(samples: np.ndarray) -> float: """Level of the loudest tenth of the clip: the part that should be speech. - Robust to the click at stream start and to long pauses.""" - frame = SAMPLE_RATE // 50 - if len(samples) < frame * 2: - return -120.0 - centered = samples - samples.mean() - usable = centered[: len(centered) // frame * frame].reshape(-1, frame) - energy = np.sqrt(np.mean(np.square(usable), axis=1)) - return 20 * np.log10(max(float(np.percentile(energy, 90)), 1e-6)) + Robust to a click at stream start and to long pauses.""" + return float(np.percentile(frame_levels(samples), 90)) + + +def level_profile(samples: np.ndarray) -> dict: + """Speech level, room-noise floor, and the longest run of nothing at all. + Even a silent room registers well above DEAD_AIR_DBFS on a live mic, so a + stretch below it is not the speaker pausing — it is audio that never came.""" + levels = frame_levels(samples) + longest = run = 0 + for level in levels: + run = run + 1 if level < DEAD_AIR_DBFS else 0 + longest = max(longest, run) + return { + "speech_dbfs": float(np.percentile(levels, 90)), + "floor_dbfs": float(np.percentile(levels, 10)), + "dead_seconds": longest / 50.0, + } + + +def dump_capture(tag: str, samples: np.ndarray): + """Keep a copy of the clip when BLURT_SAVE_CAPTURES is set, so a transcript + that came back short can be checked against what was actually recorded.""" + target = os.environ.get("BLURT_SAVE_CAPTURES", "") + if not target: + return + directory = APP_DIR / "captures" if target == "1" else Path(target) + try: + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{time.strftime('%Y%m%d-%H%M%S')}-{tag}.wav" + wavfile.write(path, SAMPLE_RATE, samples) + logging.info("Saved %s capture to %s", tag, path) + except Exception: + logging.exception("Could not save %s capture to %s", tag, directory) def condition_audio(samples: np.ndarray) -> np.ndarray: """Make raw capture look like what a Windows mic driver would have handed us: - no start-up click, no DC bias, speech at a predictable level.""" - trimmed = len(samples) - samples = without_warmup(samples) - trimmed -= len(samples) + no DC bias, speech at a predictable level.""" sos = dsp.butter(2, HIGHPASS_HZ, btype="highpass", fs=SAMPLE_RATE, output="sos") # Seed the filter with the first sample, or the DC step at t=0 rings for # ~100 ms and lands right on top of the first word. @@ -349,13 +397,15 @@ def condition_audio(samples: np.ndarray) -> np.ndarray: ) else: gain = 1.0 + amplified = filtered * gain + limited = float(np.mean(np.abs(amplified) > 1.0) * 100) if len(amplified) else 0.0 logging.info( - "Conditioned audio: trimmed=%d samples highpass=%gHz gain=%+.1f dB", - trimmed, + "Conditioned audio: highpass=%gHz gain=%+.1f dB limited=%.3f%%", HIGHPASS_HZ, 20 * np.log10(gain), + limited, ) - return np.clip(filtered * gain, -1.0, 1.0) + return np.clip(amplified, -1.0, 1.0) def get_model_label(model: str) -> str: @@ -566,6 +616,12 @@ class BlurtApp: self.input_devices = self._get_input_devices() self.device_name = load_input_device_name() self.device_index = self._resolve_device(self.device_name) + if not self.device_name: + logging.warning( + "No input device pinned: recording follows the system default, which " + "the sound server is free to move to another microphone between (or " + "during) clips. Pick one under Input device in the tray menu." + ) self.api_keys = { "openai": load_api_key(), "openrouter": load_openrouter_key(), @@ -745,18 +801,26 @@ class BlurtApp: self.audio_frames = [] audio_data = frames.mean(axis=1) if frames.shape[1] > 1 else frames[:, 0] audio_data = resample_to_target(audio_data, self.capture_rate) - raw = audio_stats(without_warmup(audio_data)) - speech_level = speech_level_dbfs(without_warmup(audio_data)) + dump_capture("raw", audio_data) + click = leading_click_samples(audio_data) + audio_data = audio_data[click:] + raw = audio_stats(audio_data) + profile = level_profile(audio_data) + speech_level = profile["speech_dbfs"] logging.info( - "Stopped recording: samples=%d (%.1fs) speech=%.1f dBFS rms=%.1f dBFS " - "peak=%.1f dBFS dc=%+.4f clipped=%.3f%% device=%s", + "Stopped recording: samples=%d (%.1fs) click=%.0fms speech=%.1f dBFS " + "floor=%.1f dBFS rms=%.1f dBFS peak=%.1f dBFS dc=%+.4f clipped=%.3f%% " + "dead=%.1fs device=%s", len(audio_data), len(audio_data) / SAMPLE_RATE, + click * 1000 / SAMPLE_RATE, speech_level, + profile["floor_dbfs"], raw["rms_dbfs"], raw["peak_dbfs"], raw["dc"], raw["clipped_pct"], + profile["dead_seconds"], self.device_name or "", ) if speech_level < SILENT_RMS_DBFS: @@ -767,6 +831,14 @@ class BlurtApp: self.device_name or "", ) return None + if profile["dead_seconds"] >= DEAD_AIR_SECONDS: + logging.warning( + "Capture holds %.1fs of digital silence, below any live mic's own " + "noise: the input dropped out mid-clip rather than the speaker " + "pausing. Pin an input device if this repeats. device=%s", + profile["dead_seconds"], + self.device_name or "", + ) if raw["clipped_pct"] > 1.0: logging.warning( "Capture clips on %.2f%% of samples; lower the input gain for %s", @@ -782,7 +854,9 @@ class BlurtApp: clean["peak_dbfs"], clean["dc"], ) - return (conditioned * 32767).astype(np.int16) + sent = (conditioned * 32767).astype(np.int16) + dump_capture("sent", sent) + return sent def _transcribe(self, audio_data: np.ndarray): self.platform.notify( diff --git a/mic_check.py b/mic_check.py index 44a362c..1490e2d 100644 --- a/mic_check.py +++ b/mic_check.py @@ -63,8 +63,10 @@ def main() -> None: sd.wait() audio = frames.mean(axis=1) if channels > 1 else frames[:, 0] audio = blurt.resample_to_target(audio, rate) + click = blurt.leading_click_samples(audio) + audio = audio[click:] - print("\nCapture:") + print(f"\nCapture (trimmed {click * 1000 / blurt.SAMPLE_RATE:.0f} ms of stream-open click):") report("raw", audio) conditioned = blurt.condition_audio(audio) report("after Blurt conditioning", conditioned)