Trim only the real click, and surface mid-clip dropouts
Transcripts came back short, but the log showed the whole clip going up: captured samples matched wall clock and upload duration matched the recording minus exactly the fixed 150 ms head trim. So nothing was being cut in transit — the losses had to be at the edges or inside the audio, and neither was visible. Trim the stream-open click by measurement instead of by the clock: cut only the leading frames that are genuinely louder than the rest of the clip, up to the old cap. Mics that do not click now keep their first syllable. Measure each frame against its own mean so a steady DC bias — or a stream frozen on one value — reads as the silence it is, and report the longest run of digital silence as dead=. A live mic never goes below DEAD_AIR_DBFS, so anything there is input that never arrived, which is what following the system default source invites. Warn on startup when no device is pinned, log the noise floor next to the speech level, and log how much of the clip the output limiter had to clamp. BLURT_SAVE_CAPTURES keeps the raw and uploaded WAVs per clip, so the next short transcript can be checked against what the API actually got. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
Devices that refuse 16 kHz (raw ALSA and PipeWire's JACK endpoints do) are
|
||||||
captured at their native rate and resampled.
|
captured at their native rate and resampled.
|
||||||
|
|
||||||
Every clip is conditioned before upload — the first 150 ms are dropped (mics
|
Every clip is conditioned before upload — the stream-open click is trimmed off
|
||||||
click when the stream opens), DC bias is filtered out, and the level is
|
the front (only as much of it as is actually there, up to 150 ms), DC bias is
|
||||||
normalised towards -20 dBFS. Near-silent clips are refused instead of being
|
filtered out, and the level is normalised towards -20 dBFS. Near-silent clips
|
||||||
sent, since transcription models answer silence with confident hallucinations.
|
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
|
## Checking the microphone
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from math import gcd
|
from math import gcd
|
||||||
@@ -77,7 +78,9 @@ TRANSCRIPTION_LANGUAGES = [
|
|||||||
SAMPLE_RATE = 16000
|
SAMPLE_RATE = 16000
|
||||||
CHANNELS = 1
|
CHANNELS = 1
|
||||||
DTYPE = "float32" # capture with headroom; the WAV sent to the API is int16
|
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)
|
HIGHPASS_HZ = 70.0 # strip DC bias and rumble (raw digital mics carry a lot)
|
||||||
TARGET_RMS_DBFS = -20.0
|
TARGET_RMS_DBFS = -20.0
|
||||||
PEAK_CEILING_DBFS = -1.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)
|
return resampled.astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
def without_warmup(samples: np.ndarray) -> np.ndarray:
|
def leading_click_samples(samples: np.ndarray) -> int:
|
||||||
"""The clip minus the stream-open click, which otherwise skews every stat."""
|
"""Length of the stream-open click, capped at WARMUP_SECONDS. Cutting a fixed
|
||||||
warmup = int(WARMUP_SECONDS * SAMPLE_RATE)
|
slice off the front of every clip costs a syllable on mics that do not click,
|
||||||
return samples[warmup:] if len(samples) > warmup * 2 else samples
|
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:
|
def speech_level_dbfs(samples: np.ndarray) -> float:
|
||||||
"""Level of the loudest tenth of the clip: the part that should be speech.
|
"""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."""
|
Robust to a click at stream start and to long pauses."""
|
||||||
frame = SAMPLE_RATE // 50
|
return float(np.percentile(frame_levels(samples), 90))
|
||||||
if len(samples) < frame * 2:
|
|
||||||
return -120.0
|
|
||||||
centered = samples - samples.mean()
|
def level_profile(samples: np.ndarray) -> dict:
|
||||||
usable = centered[: len(centered) // frame * frame].reshape(-1, frame)
|
"""Speech level, room-noise floor, and the longest run of nothing at all.
|
||||||
energy = np.sqrt(np.mean(np.square(usable), axis=1))
|
Even a silent room registers well above DEAD_AIR_DBFS on a live mic, so a
|
||||||
return 20 * np.log10(max(float(np.percentile(energy, 90)), 1e-6))
|
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:
|
def condition_audio(samples: np.ndarray) -> np.ndarray:
|
||||||
"""Make raw capture look like what a Windows mic driver would have handed us:
|
"""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."""
|
no DC bias, speech at a predictable level."""
|
||||||
trimmed = len(samples)
|
|
||||||
samples = without_warmup(samples)
|
|
||||||
trimmed -= len(samples)
|
|
||||||
sos = dsp.butter(2, HIGHPASS_HZ, btype="highpass", fs=SAMPLE_RATE, output="sos")
|
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
|
# 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.
|
# ~100 ms and lands right on top of the first word.
|
||||||
@@ -349,13 +397,15 @@ def condition_audio(samples: np.ndarray) -> np.ndarray:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
gain = 1.0
|
gain = 1.0
|
||||||
|
amplified = filtered * gain
|
||||||
|
limited = float(np.mean(np.abs(amplified) > 1.0) * 100) if len(amplified) else 0.0
|
||||||
logging.info(
|
logging.info(
|
||||||
"Conditioned audio: trimmed=%d samples highpass=%gHz gain=%+.1f dB",
|
"Conditioned audio: highpass=%gHz gain=%+.1f dB limited=%.3f%%",
|
||||||
trimmed,
|
|
||||||
HIGHPASS_HZ,
|
HIGHPASS_HZ,
|
||||||
20 * np.log10(gain),
|
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:
|
def get_model_label(model: str) -> str:
|
||||||
@@ -566,6 +616,12 @@ class BlurtApp:
|
|||||||
self.input_devices = self._get_input_devices()
|
self.input_devices = self._get_input_devices()
|
||||||
self.device_name = load_input_device_name()
|
self.device_name = load_input_device_name()
|
||||||
self.device_index = self._resolve_device(self.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 = {
|
self.api_keys = {
|
||||||
"openai": load_api_key(),
|
"openai": load_api_key(),
|
||||||
"openrouter": load_openrouter_key(),
|
"openrouter": load_openrouter_key(),
|
||||||
@@ -745,18 +801,26 @@ class BlurtApp:
|
|||||||
self.audio_frames = []
|
self.audio_frames = []
|
||||||
audio_data = frames.mean(axis=1) if frames.shape[1] > 1 else frames[:, 0]
|
audio_data = frames.mean(axis=1) if frames.shape[1] > 1 else frames[:, 0]
|
||||||
audio_data = resample_to_target(audio_data, self.capture_rate)
|
audio_data = resample_to_target(audio_data, self.capture_rate)
|
||||||
raw = audio_stats(without_warmup(audio_data))
|
dump_capture("raw", audio_data)
|
||||||
speech_level = speech_level_dbfs(without_warmup(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(
|
logging.info(
|
||||||
"Stopped recording: samples=%d (%.1fs) speech=%.1f dBFS rms=%.1f dBFS "
|
"Stopped recording: samples=%d (%.1fs) click=%.0fms speech=%.1f dBFS "
|
||||||
"peak=%.1f dBFS dc=%+.4f clipped=%.3f%% device=%s",
|
"floor=%.1f dBFS rms=%.1f dBFS peak=%.1f dBFS dc=%+.4f clipped=%.3f%% "
|
||||||
|
"dead=%.1fs device=%s",
|
||||||
len(audio_data),
|
len(audio_data),
|
||||||
len(audio_data) / SAMPLE_RATE,
|
len(audio_data) / SAMPLE_RATE,
|
||||||
|
click * 1000 / SAMPLE_RATE,
|
||||||
speech_level,
|
speech_level,
|
||||||
|
profile["floor_dbfs"],
|
||||||
raw["rms_dbfs"],
|
raw["rms_dbfs"],
|
||||||
raw["peak_dbfs"],
|
raw["peak_dbfs"],
|
||||||
raw["dc"],
|
raw["dc"],
|
||||||
raw["clipped_pct"],
|
raw["clipped_pct"],
|
||||||
|
profile["dead_seconds"],
|
||||||
self.device_name or "<system default>",
|
self.device_name or "<system default>",
|
||||||
)
|
)
|
||||||
if speech_level < SILENT_RMS_DBFS:
|
if speech_level < SILENT_RMS_DBFS:
|
||||||
@@ -767,6 +831,14 @@ class BlurtApp:
|
|||||||
self.device_name or "<system default>",
|
self.device_name or "<system default>",
|
||||||
)
|
)
|
||||||
return None
|
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 "<system default>",
|
||||||
|
)
|
||||||
if raw["clipped_pct"] > 1.0:
|
if raw["clipped_pct"] > 1.0:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
"Capture clips on %.2f%% of samples; lower the input gain for %s",
|
"Capture clips on %.2f%% of samples; lower the input gain for %s",
|
||||||
@@ -782,7 +854,9 @@ class BlurtApp:
|
|||||||
clean["peak_dbfs"],
|
clean["peak_dbfs"],
|
||||||
clean["dc"],
|
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):
|
def _transcribe(self, audio_data: np.ndarray):
|
||||||
self.platform.notify(
|
self.platform.notify(
|
||||||
|
|||||||
+3
-1
@@ -63,8 +63,10 @@ def main() -> None:
|
|||||||
sd.wait()
|
sd.wait()
|
||||||
audio = frames.mean(axis=1) if channels > 1 else frames[:, 0]
|
audio = frames.mean(axis=1) if channels > 1 else frames[:, 0]
|
||||||
audio = blurt.resample_to_target(audio, rate)
|
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)
|
report("raw", audio)
|
||||||
conditioned = blurt.condition_audio(audio)
|
conditioned = blurt.condition_audio(audio)
|
||||||
report("after Blurt conditioning", conditioned)
|
report("after Blurt conditioning", conditioned)
|
||||||
|
|||||||
Reference in New Issue
Block a user