Keep long recordings: spool, retry, chunked upload, progress ring

A long clip used to die on a fixed 30 s timeout and the audio went with it.
Now every conditioned clip is written to spool/ before upload and only
removed once its text is on the clipboard; failures keep it, and a tray item
retries spooled clips (copied together, never dispatched). Requests the API
rejects outright (4xx other than 408/429) are not kept.

Timeouts scale with the audio (30 s + 30 s/min, capped at 5 min). Clips over
5 min are cut inside pauses into ~4 min chunks uploaded three at a time and
stitched in order; failed chunks are spooled individually and the partial
transcript is copied with a gap marker. The tray disc fills clockwise as
chunks return.

mic_check.py passes the new timeout tuple.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016rLFNPygDpRkRwPQakdmYh
This commit is contained in:
2026-09-05 19:03:50 -04:00
co-authored by Claude Fable 5.1
parent 6ad8526920
commit 82bc350521
4 changed files with 625 additions and 70 deletions
+3
View File
@@ -9,3 +9,6 @@ __pycache__/
model.txt model.txt
language.txt language.txt
device.txt device.txt
# Clips awaiting (or retrying) transcription
spool/
+20 -1
View File
@@ -56,7 +56,26 @@ filtered out, and the level is normalised towards -20 dBFS. Near-silent clips
are refused instead of being sent, since transcription models answer silence are refused instead of being sent, since transcription models answer silence
with confident hallucinations. with confident hallucinations.
Nothing else is cut: the whole recording is uploaded. If a transcript comes Every clip is written to `spool/` (gitignored) before it is uploaded, and
deleted again as soon as its text reaches the clipboard. If transcription fails —
timeout, connection error, server error, empty response — the WAV stays there
and the notification says so; **Retry failed transcriptions (N)** in the tray
menu re-uploads the spooled clips oldest first, so a network hiccup never costs
you the words. A rejected request (any 4xx other than 408 and 429) is the
exception: the same audio can only be rejected again, so it is not kept, and the
notification says that too. Retried transcripts are copied to the clipboard
together, oldest first, once the whole run is done — and never dispatched to an
agent, since a spooled clip may be one part of a longer recording. The upload
timeout scales with the clip: 30 s plus 30 s per minute of audio, capped at
5 minutes. Clips longer than 5 minutes are split into chunks of about
4 minutes — each cut lands in a pause, not mid-word — and the chunks are
uploaded in parallel and stitched back together in order. The tray icon fills
clockwise as each chunk comes back, so you can see how far along the clip is.
If only some chunks come back, the transcript is copied with ` […] ` where the
gaps are, and just the failed chunks are kept in the spool as `…-partNN.wav`
for the retry.
Nothing else is cut: every second of the recording is uploaded. If a transcript comes
back missing words, check `blurt.log` — each clip logs its length, speech level, 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 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 non-zero `dead=` means the input dropped out mid-clip, which is what following
+570 -38
View File
@@ -11,6 +11,7 @@ import sys
import tempfile import tempfile
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor
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
@@ -99,7 +100,18 @@ RAW_ALSA_DEVICE = re.compile(r"\(hw:\d+,\d+\)")
SOUND_SERVER_DEVICES = ("pipewire", "pulse", "default") SOUND_SERVER_DEVICES = ("pipewire", "pulse", "default")
APP_NAME = "Blurt" APP_NAME = "Blurt"
APP_DIR = Path(__file__).resolve().parent APP_DIR = Path(__file__).resolve().parent
SPOOL_DIR = APP_DIR / "spool" # clips wait here until their transcript is delivered
AGENT_PREFIX = "to my agent" AGENT_PREFIX = "to my agent"
TIMEOUT_CONNECT_SECONDS = 10.0 # reaching the API is fast or not happening
TIMEOUT_BASE_SECONDS = 30.0 # enough for a short clip, upload included
TIMEOUT_PER_AUDIO_MINUTE = 30.0 # LLM-based models take minutes per minute of audio
TIMEOUT_MAX_SECONDS = 300.0 # past this the request is hung, not working
CHUNK_TARGET_SECONDS = 240 # where we would like each cut to land
CHUNK_MAX_SECONDS = 300 # OpenAI refuses uploads past 25 MB, and LLM models crawl
CHUNK_SEARCH_SECONDS = 30 # how far either side of the target a pause may be
CHUNK_MIN_LAST_SECONDS = 60 # a tail shorter than this is not worth its own request
CHUNK_SMOOTH_FRAMES = 15 # 300 ms: cut inside a real pause, not between two words
CHUNK_WORKERS = 3 # parallel uploads; the clip is recorded, only latency is left
PID_FILE = Path(tempfile.gettempdir()) / "blurt.pid" PID_FILE = Path(tempfile.gettempdir()) / "blurt.pid"
LOG_FILE = Path(tempfile.gettempdir()) / "blurt.log" LOG_FILE = Path(tempfile.gettempdir()) / "blurt.log"
KEYRING_CREDENTIALS = [ KEYRING_CREDENTIALS = [
@@ -413,6 +425,183 @@ def condition_audio(samples: np.ndarray) -> np.ndarray:
return np.clip(amplified, -1.0, 1.0) return np.clip(amplified, -1.0, 1.0)
def transcription_timeout(seconds_of_audio: float) -> tuple[float, float]:
"""Connect and read timeouts for one upload. A fixed read timeout throws away
exactly the long recordings that took the most effort to make."""
read = TIMEOUT_BASE_SECONDS + TIMEOUT_PER_AUDIO_MINUTE * (seconds_of_audio / 60.0)
return TIMEOUT_CONNECT_SECONDS, min(read, TIMEOUT_MAX_SECONDS)
def split_at_silence(samples: np.ndarray) -> list[tuple[int, int]]:
"""Sample ranges to upload separately, in order and covering the whole clip.
Cutting is only worth its complexity because a long clip is slow and, past
25 MB, refused outright — so cut as rarely as the limit allows, and put each
cut in the middle of a pause where losing a few samples costs no word."""
total = len(samples)
if total <= CHUNK_MAX_SECONDS * SAMPLE_RATE:
return [(0, total)]
frame = SAMPLE_RATE // 50
# frame_levels reads dBFS off a float clip in [-1, 1]; the audio we cut is
# int16, so scale it or every level logged here is 90 dB too loud.
scaled = samples.astype(np.float32) / 32768.0 if samples.dtype == np.int16 else samples
levels = frame_levels(scaled)
smoothed = np.convolve(
levels, np.ones(CHUNK_SMOOTH_FRAMES) / CHUNK_SMOOTH_FRAMES, mode="same"
)
ranges: list[tuple[int, int]] = []
start = 0
while total - start > CHUNK_MAX_SECONDS * SAMPLE_RATE:
# Never exceed the upload limit, and never leave a tail too short to be
# worth a request of its own.
latest = min(
start + int((CHUNK_TARGET_SECONDS + CHUNK_SEARCH_SECONDS) * SAMPLE_RATE),
start + CHUNK_MAX_SECONDS * SAMPLE_RATE,
total - CHUNK_MIN_LAST_SECONDS * SAMPLE_RATE,
)
earliest = min(
start + int((CHUNK_TARGET_SECONDS - CHUNK_SEARCH_SECONDS) * SAMPLE_RATE),
latest,
)
first_frame = max(earliest // frame, 0)
last_frame = min(latest // frame, len(smoothed) - 1)
if last_frame <= first_frame:
cut, level = latest, float(smoothed[min(first_frame, len(smoothed) - 1)])
else:
window = smoothed[first_frame : last_frame + 1]
quietest = first_frame + int(np.argmin(window))
# Frames within 3 dB of the quietest are all the same pause; its
# centre is the furthest we can get from either neighbouring word.
floor = smoothed[quietest] + 3.0
left = right = quietest
while left > first_frame and smoothed[left - 1] <= floor:
left -= 1
while right < last_frame and smoothed[right + 1] <= floor:
right += 1
level = float(smoothed[quietest])
cut = min((left + right) // 2 * frame + frame // 2, latest)
cut = max(cut, start + 1)
logging.info(
"Chunk %d cut at %.1fs: quietest point in the window is %.1f dBFS, "
"%.1fs of clip left",
len(ranges) + 1,
cut / SAMPLE_RATE,
level,
(total - cut) / SAMPLE_RATE,
)
ranges.append((start, cut))
start = cut
ranges.append((start, total))
return ranges
def _spool_path(name: str) -> Path:
"""A free path for `name`. Two clips landing in the same millisecond is
unlikely, but overwriting one of them would lose it for good."""
path = SPOOL_DIR / f"{name}.wav"
counter = 1
while path.exists():
path = SPOOL_DIR / f"{name}-{counter}.wav"
counter += 1
return path
def spool_stem() -> str:
"""Timestamp down to the millisecond, zero-padded so names still sort oldest
first."""
now = time.time()
return f"{time.strftime('%Y%m%d-%H%M%S', time.localtime(now))}-{int(now * 1000) % 1000:03d}"
def _write_spool(samples: np.ndarray, name: str) -> Path | None:
"""Write one clip into the spool, or None if the spool is unwritable.
The WAV lands under a `.tmp` name and is renamed into place, so a retry
globbing the folder mid-write never picks up half a file."""
try:
SPOOL_DIR.mkdir(parents=True, exist_ok=True)
path = _spool_path(name)
partial = path.with_name(f"{path.name}.tmp")
wavfile.write(partial, SAMPLE_RATE, samples)
os.replace(partial, path)
return path
except Exception:
logging.exception("Could not write %s to the spool %s", name, SPOOL_DIR)
return None
def spool_capture(samples: np.ndarray) -> Path | None:
"""Park the clip on disk before uploading it, so a failed request costs a
retry instead of the words. Returns None if the spool is unwritable —
disk trouble must not stop us from transcribing what is already in memory."""
path = _write_spool(samples, spool_stem())
if path is None:
logging.error("Transcribing from memory without a safety net")
return None
logging.info("Spooled clip to %s: %d samples", path, len(samples))
return path
def spool_chunk(samples: np.ndarray, stem: str, index: int) -> Path | None:
"""Park one failed chunk under its own name, so the retry re-uploads only the
audio that is actually missing instead of the whole clip."""
path = _write_spool(samples, f"{stem}-part{index:02d}")
if path is None:
logging.error("Failed chunk %d is not in the spool; its audio is gone", index)
return None
logging.info("Spooled failed chunk %d to %s: %d samples", index, path, len(samples))
return path
def load_spooled_capture(path: Path) -> np.ndarray | None:
"""Read back a spooled clip, refusing anything we did not write ourselves:
a stray WAV in the folder would otherwise be uploaded at the wrong rate."""
try:
rate, samples = wavfile.read(path)
except Exception:
logging.exception("Could not read spooled clip %s; leaving it in place", path)
return None
if rate != SAMPLE_RATE or samples.dtype != np.int16 or samples.ndim != 1:
logging.warning(
"Skipping spooled clip %s: expected int16 mono at %d Hz, got %s %s at %d Hz",
path,
SAMPLE_RATE,
samples.dtype,
"mono" if samples.ndim == 1 else f"{samples.ndim}-D",
rate,
)
return None
return samples
class TranscriptionError(Exception):
"""The API answered but gave us no usable text, so the clip stays spooled."""
def error_text(error: BaseException) -> str:
return f"{type(error).__name__}: {error}"
def rejected_status(error: BaseException) -> int | None:
"""The HTTP status if the API refused the request itself, else None.
A 4xx that is not 408 or 429 says the upload was wrong, not unlucky: the
same audio can only be rejected again, so keeping it would leave a clip in
the spool that no retry can ever clear."""
if not isinstance(error, requests.HTTPError):
return None
status = getattr(getattr(error, "response", None), "status_code", None)
if isinstance(status, int) and 400 <= status < 500 and status not in (408, 429):
return status
return None
def response_snippet(error: BaseException) -> str:
return str(getattr(getattr(error, "response", None), "text", ""))[:200]
def get_model_label(model: str) -> str: def get_model_label(model: str) -> str:
for model_id, label, _ in TRANSCRIPTION_MODELS: for model_id, label, _ in TRANSCRIPTION_MODELS:
if model_id == model: if model_id == model:
@@ -617,6 +806,9 @@ class BlurtApp:
self.capture_rate = SAMPLE_RATE self.capture_rate = SAMPLE_RATE
self.capture_channels = CHANNELS self.capture_channels = CHANNELS
self.lock = threading.Lock() self.lock = threading.Lock()
# Redraws come from every chunk worker at once; some tray backends do not
# survive that. Never taken while self.lock is held, and never takes it.
self._icon_lock = threading.Lock()
self.icon: pystray.Icon | None = None self.icon: pystray.Icon | None = None
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()
@@ -640,6 +832,17 @@ class BlurtApp:
AwakeKeeper() if sys.platform == "win32" else None AwakeKeeper() if sys.platform == "win32" else None
) )
self.pending_transcriptions = 0 self.pending_transcriptions = 0
self.chunks_total = 0
self.chunks_done = 0
self._inflight_spool: set[Path] = set()
spooled = self._spooled_files()
if spooled:
logging.warning(
"%d clip(s) left in the spool %s by an earlier failure; "
"retry them from the tray menu",
len(spooled),
SPOOL_DIR,
)
logging.info( logging.info(
"BlurtApp initialized with %d input devices, model=%s, language=%s, awake_available=%s", "BlurtApp initialized with %d input devices, model=%s, language=%s, awake_available=%s",
len(self.input_devices), len(self.input_devices),
@@ -683,24 +886,45 @@ class BlurtApp:
logging.warning("Configured input device %r is not connected; using system default", name) logging.warning("Configured input device %r is not connected; using system default", name)
return None return None
def _create_icon_image(self, color: tuple[int, int, int]) -> Image.Image: def _create_icon_image(
self, color: tuple[int, int, int], progress: float | None = None
) -> Image.Image:
img = Image.new("RGBA", (64, 64), (0, 0, 0, 0)) img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
if progress is None:
draw.ellipse([8, 8, 56, 56], fill=color) draw.ellipse([8, 8, 56, 56], fill=color)
return img return img
# Dim disc for the whole clip, full colour for the finished fraction,
# filling clockwise from 12 o'clock (Pillow measures from 3 o'clock).
dim = tuple(round(c * 0.45) for c in color)
draw.ellipse([8, 8, 56, 56], fill=dim)
progress = min(1.0, max(0.0, progress))
if progress > 0:
draw.pieslice([8, 8, 56, 56], -90, -90 + 360 * progress, fill=color)
return img
def _update_icon(self): def _update_icon(self):
if self.icon: if not self.icon:
return
with self._icon_lock:
progress = None
# One read of each counter: another thread's finally clause can zero
# both between two reads, and a fraction over zero raises.
done, total = self.chunks_done, self.chunks_total
if self.recording: if self.recording:
color = (220, 40, 40) # red color = (220, 40, 40) # red
state = "Recording..." state = "Recording..."
elif self.pending_transcriptions > 0: elif self.pending_transcriptions > 0:
color = (40, 100, 220) # blue color = (40, 100, 220) # blue
if total > 1:
state = f"Transcribing {done}/{total}..."
progress = done / total
else:
state = "Transcribing..." state = "Transcribing..."
else: else:
color = (120, 120, 120) # gray color = (120, 120, 120) # gray
state = "Ready" state = "Ready"
self.icon.icon = self._create_icon_image(color) self.icon.icon = self._create_icon_image(color, progress)
self.icon.title = f"{APP_NAME} - {state}" self.icon.title = f"{APP_NAME} - {state}"
self.icon.update_menu() self.icon.update_menu()
@@ -749,18 +973,24 @@ class BlurtApp:
return f"API key: {provider_label} (missing)" return f"API key: {provider_label} (missing)"
return f"API key: {provider_label} (…{key[-4:]})" return f"API key: {provider_label} (…{key[-4:]})"
def _start_recording(self): def _require_api_key(self, action: str) -> bool:
if not self._current_api_key(): if self._current_api_key():
return True
provider = self._provider_for_model(self.transcription_model) provider = self._provider_for_model(self.transcription_model)
key_hint = PROVIDER_KEY_HINTS.get(provider, f"set the {provider} API key") key_hint = PROVIDER_KEY_HINTS.get(provider, f"set the {provider} API key")
logging.error( logging.error(
"Recording blocked: model=%s provider=%s has no API key; " "%s blocked: model=%s provider=%s has no API key; "
"%s or select a model with an available key", "%s or select a model with an available key",
action,
self.transcription_model, self.transcription_model,
provider, provider,
key_hint, key_hint,
) )
self.platform.notify("Error", f"No {provider} API key; {key_hint}.") self.platform.notify("Error", f"No {provider} API key; {key_hint}.")
return False
def _start_recording(self):
if not self._require_api_key("Recording"):
return return
self.audio_frames = [] self.audio_frames = []
try: try:
@@ -863,34 +1093,98 @@ class BlurtApp:
dump_capture("sent", sent) dump_capture("sent", sent)
return sent return sent
def _transcribe(self, audio_data: np.ndarray): def _transcribe(
self.platform.notify( self,
"Transcribing", audio_data: np.ndarray,
f"Using {get_model_label(self.transcription_model)} ({self.transcription_language})", spool_path: Path | None = None,
collect: list[str] | None = None,
):
"""Transcribe one clip. With `collect`, the text is appended there
instead of being handed to the user: a retry run copies everything it
recovered in one go rather than overwriting the clipboard per clip."""
ranges = split_at_silence(audio_data)
detail = (
f"Using {get_model_label(self.transcription_model)} "
f"({self.transcription_language})"
) )
logging.info("Starting transcription: samples=%d", len(audio_data)) if len(ranges) > 1:
detail = f"{detail} in {len(ranges)} chunks"
self.platform.notify("Transcribing", detail)
with self.lock:
self.chunks_total += len(ranges)
self._update_icon()
try:
if len(ranges) == 1:
buffer = io.BytesIO() buffer = io.BytesIO()
wavfile.write(buffer, SAMPLE_RATE, audio_data) wavfile.write(buffer, SAMPLE_RATE, audio_data)
wav_bytes = buffer.getvalue() wav_bytes = buffer.getvalue()
logging.info("Encoded WAV in memory: %d bytes", len(wav_bytes)) logging.info("Encoded WAV in memory: %d bytes", len(wav_bytes))
try: try:
if self._provider_for_model(self.transcription_model) == "openrouter": text = self._request_transcript(
result = self._transcribe_openrouter(wav_bytes) wav_bytes, len(audio_data) / SAMPLE_RATE
)
finally:
self._chunk_finished()
self._hand_over(text, collect)
self._discard_spool(spool_path)
else: else:
result = self._transcribe_openai(wav_bytes) texts, errors = self._transcribe_chunks(audio_data, ranges)
text = result.get("text", "").lstrip() if errors:
logging.info("Transcription text length: %d", len(text)) self._deliver_partial(
if "usage" in result: audio_data, ranges, texts, errors, spool_path, collect
logging.info("Transcription usage: %s", result["usage"]) )
else:
self._hand_over(" ".join(text.strip() for text in texts), collect)
self._discard_spool(spool_path)
if not text: except TranscriptionError as e:
error_msg = result.get("error", {}).get("message", "Unknown error") self._report_failure(str(e), spool_path)
logging.error("Transcription response did not include text: %s", result) except requests.RequestException as e:
self.platform.notify("Error", f"Transcription failed: {error_msg}") logging.exception("API request failed")
return status = rejected_status(e)
if status is None:
self._report_failure(f"API request failed: {e}", spool_path)
else:
logging.error(
"Request rejected with HTTP %d: %s", status, response_snippet(e)
)
self._discard_spool(spool_path, f"Request rejected with HTTP {status}")
self.platform.notify(
"Error",
f"API request failed: {e} — audio NOT kept, the request was "
f"rejected (HTTP {status}).",
)
except Exception as e:
logging.exception("Unexpected transcription failure")
self._report_failure(f"Unexpected transcription failure: {e}", spool_path)
finally:
with self.lock:
self.pending_transcriptions -= 1
if self.pending_transcriptions == 0:
self.chunks_total = self.chunks_done = 0
if spool_path is not None:
self._inflight_spool.discard(spool_path)
self._update_icon()
def _chunk_finished(self):
with self.lock:
self.chunks_done += 1
try:
self._update_icon()
except Exception:
# A tray backend that cannot draw must not turn a transcribed chunk
# into a failed one.
logging.exception("Could not update the tray icon after a finished chunk")
def _hand_over(self, text: str, collect: list[str] | None):
"""Give the transcript to the user, or to the retry run collecting them."""
if collect is None:
self._deliver(text)
else:
collect.append(text)
def _deliver(self, text: str):
if text.lower().startswith(AGENT_PREFIX): if text.lower().startswith(AGENT_PREFIX):
task = text[len(AGENT_PREFIX):].lstrip(" ,.:;-") task = text[len(AGENT_PREFIX):].lstrip(" ,.:;-")
if task: if task:
@@ -912,17 +1206,245 @@ class BlurtApp:
self.platform.notify("Copied", preview) self.platform.notify("Copied", preview)
logging.info("Copied transcription to clipboard") logging.info("Copied transcription to clipboard")
except requests.RequestException as e: def _transcribe_chunks(
logging.exception("API request failed") self, audio_data: np.ndarray, ranges: list[tuple[int, int]]
self.platform.notify("Error", f"API request failed: {e}") ) -> tuple[list[str | None], dict[int, Exception]]:
except Exception as e: """Upload every chunk in parallel; text per chunk, plus what went wrong
logging.exception("Unexpected transcription failure") for each missing one. The clip is already recorded, so latency is all
self.platform.notify("Error", f"Unexpected transcription failure: {e}") that is left to save, and one dead chunk must not cost the other nine.
finally: The exception itself is kept, not just its message: whether the audio is
self.pending_transcriptions -= 1 worth spooling depends on why the request failed."""
self._update_icon() texts: list[str | None] = [None] * len(ranges)
errors: dict[int, Exception] = {}
def _transcribe_openai(self, wav_bytes: bytes) -> dict: def upload(index: int) -> str:
start, end = ranges[index]
chunk = audio_data[start:end]
buffer = io.BytesIO()
wavfile.write(buffer, SAMPLE_RATE, chunk)
wav_bytes = buffer.getvalue()
logging.info(
"Encoded chunk %d of %d in memory: %d bytes, %.1fs",
index + 1,
len(ranges),
len(wav_bytes),
len(chunk) / SAMPLE_RATE,
)
try:
return self._request_transcript(wav_bytes, len(chunk) / SAMPLE_RATE)
finally:
self._chunk_finished()
with ThreadPoolExecutor(max_workers=CHUNK_WORKERS) as pool:
futures = [pool.submit(upload, index) for index in range(len(ranges))]
for index, future in enumerate(futures):
try:
texts[index] = future.result()
except Exception as e:
errors[index] = e
logging.exception(
"Chunk %d of %d failed (%.1fs at %.1fs into the clip)",
index + 1,
len(ranges),
(ranges[index][1] - ranges[index][0]) / SAMPLE_RATE,
ranges[index][0] / SAMPLE_RATE,
)
return texts, errors
def _deliver_partial(
self,
audio_data: np.ndarray,
ranges: list[tuple[int, int]],
texts: list[str | None],
errors: dict[int, Exception],
spool_path: Path | None,
collect: list[str] | None = None,
):
"""Hand over what came back and keep only the audio that did not. The
transcript has holes in it, so it goes to the clipboard for the user to
read — never to an agent, which cannot see what is missing."""
for index in sorted(errors):
logging.error(
"Chunk %d of %d has no transcript: %s",
index + 1,
len(ranges),
error_text(errors[index]),
)
if len(errors) == len(ranges) and spool_path is not None and spool_path.exists():
logging.error("Every chunk failed; keeping the whole clip at %s", spool_path)
kept_note = "audio kept, retry from the tray menu"
else:
stem = spool_path.stem if spool_path is not None else spool_stem()
kept = unwritten = 0
for index in sorted(errors):
status = rejected_status(errors[index])
if status is not None:
logging.error(
"Not keeping chunk %d: rejected with HTTP %d, so re-uploading "
"it can only be rejected again: %s",
index + 1,
status,
response_snippet(errors[index]),
)
continue
start, end = ranges[index]
if spool_chunk(audio_data[start:end], stem, index + 1) is None:
unwritten += 1
else:
kept += 1
if unwritten == 0:
# Every chunk worth keeping is in the spool under its own name;
# the whole clip would only make the retry re-upload good audio.
self._discard_spool(spool_path)
kept_note = (
f"{kept} of them kept, retry from the tray menu"
if kept
else "audio NOT kept, the request was rejected"
)
elif spool_path is not None and spool_path.exists():
logging.error(
"Keeping the whole clip at %s: %d failed chunk(s) could not be "
"spooled on their own",
spool_path,
unwritten,
)
kept_note = "whole clip kept, retry from the tray menu"
else:
logging.error(
"%d failed chunk(s) could not be spooled and there is no whole "
"clip to fall back on; their audio is gone",
unwritten,
)
kept_note = f"audio of {unwritten} of them could NOT be kept"
if len(errors) == len(ranges):
# Nothing came back, so there is no partial transcript to hand over.
self.platform.notify(
"Error",
f"All {len(ranges)} chunks failed: {error_text(errors[min(errors)])} — "
f"{kept_note}.",
)
return
pieces: list[str] = []
for text in texts:
if text:
pieces.append(text.strip())
elif not pieces or pieces[-1] != "[…]":
pieces.append("[…]")
partial = " ".join(pieces)
message = f"{len(errors)} of {len(ranges)} chunks failed — {kept_note}"
if collect is not None:
collect.append(partial)
logging.info(
"Collected partial transcription for the retry run: %d characters",
len(partial),
)
else:
pyperclip.copy(partial)
if partial.lower().startswith(AGENT_PREFIX):
message = (
f"{message}. Copied instead of dispatched: the transcript has holes"
)
logging.warning("Not dispatching a partial transcript; copied it instead")
logging.info(
"Copied partial transcription to clipboard: %d characters", len(partial)
)
self.platform.notify("Partial", message)
def _request_transcript(self, wav_bytes: bytes, seconds: float) -> str:
"""Upload one WAV and return its text, raising if the API gave us none."""
timeout = transcription_timeout(seconds)
logging.info(
"Starting transcription: %.1fs of audio, connect_timeout=%.0fs read_timeout=%.0fs",
seconds,
timeout[0],
timeout[1],
)
if self._provider_for_model(self.transcription_model) == "openrouter":
result = self._transcribe_openrouter(wav_bytes, timeout)
else:
result = self._transcribe_openai(wav_bytes, timeout)
text = result.get("text", "").lstrip()
logging.info("Transcription text length: %d", len(text))
if "usage" in result:
logging.info("Transcription usage: %s", result["usage"])
if not text:
error_msg = result.get("error", {}).get("message", "Unknown error")
logging.error("Transcription response did not include text: %s", result)
raise TranscriptionError(f"Transcription failed: {error_msg}")
return text
def _report_failure(self, message: str, spool_path: Path | None):
if spool_path is not None and spool_path.exists():
logging.error("Keeping audio at %s after failure: %s", spool_path, message)
message = f"{message} — audio kept, retry from the tray menu."
self.platform.notify("Error", message)
def _discard_spool(self, spool_path: Path | None, reason: str = "Transcript delivered"):
if spool_path is None:
return
try:
spool_path.unlink(missing_ok=True)
logging.info("%s; removed spooled clip %s", reason, spool_path)
except OSError:
logging.exception("Could not remove spooled clip %s", spool_path)
def _spooled_files(self) -> list[Path]:
"""Spooled clips, oldest first (their names are timestamps)."""
try:
return sorted(SPOOL_DIR.glob("*.wav"))
except OSError:
logging.exception("Could not list the spool %s", SPOOL_DIR)
return []
def _retryable_files(self) -> list[Path]:
"""Spooled clips a retry would actually take on: not the one that is
being uploaded right now, or the menu offers to retry a clip whose first
attempt has not even finished. A stale copy of the in-flight set is fine
here — the worker checks it again under the lock."""
inflight = set(self._inflight_spool)
return [path for path in self._spooled_files() if path not in inflight]
def _retry_spooled(self, icon=None, item=None):
if not self._require_api_key("Retry"):
return
threading.Thread(target=self._retry_spooled_worker, daemon=True, name="RetrySpool").start()
def _retry_spooled_worker(self):
"""Re-upload spooled clips one at a time: they failed once already, and a
burst of parallel requests is the least likely thing to fix that. Their
transcripts are collected and copied together at the end — one clipboard
holds one thing, and a per-clip copy would leave only the last clip."""
collected: list[str] = []
for path in self._spooled_files():
with self.lock:
if path in self._inflight_spool:
logging.info("Not retrying %s: it is being transcribed right now", path)
continue
self._inflight_spool.add(path)
audio_data = load_spooled_capture(path)
if audio_data is None:
with self.lock:
self._inflight_spool.discard(path)
continue
with self.lock:
self.pending_transcriptions += 1
self._update_icon()
logging.info("Retrying spooled clip %s", path)
self._transcribe(audio_data, path, collected)
if not collected:
return
pyperclip.copy("\n\n".join(collected))
# Never dispatched: a retried clip may be one part of a longer recording,
# and an agent prefix on it would send a truncated task.
logging.info(
"Copied %d retried transcript(s) to the clipboard; retries are copied, "
"never dispatched",
len(collected),
)
self.platform.notify("Copied", f"Copied {len(collected)} retried transcript(s)")
def _transcribe_openai(self, wav_bytes: bytes, timeout: tuple[float, float]) -> dict:
data = { data = {
"model": self.transcription_model, "model": self.transcription_model,
"language": self.transcription_language, "language": self.transcription_language,
@@ -935,13 +1457,13 @@ class BlurtApp:
headers={"Authorization": f"Bearer {self.api_keys['openai']}"}, headers={"Authorization": f"Bearer {self.api_keys['openai']}"},
files={"file": ("blurt.wav", wav_bytes, "audio/wav")}, files={"file": ("blurt.wav", wav_bytes, "audio/wav")},
data=data, data=data,
timeout=30, timeout=timeout,
) )
self._log_transcription_response(response) self._log_transcription_response(response)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
def _transcribe_openrouter(self, wav_bytes: bytes) -> dict: def _transcribe_openrouter(self, wav_bytes: bytes, timeout: tuple[float, float]) -> dict:
audio_b64 = base64.b64encode(wav_bytes).decode("ascii") audio_b64 = base64.b64encode(wav_bytes).decode("ascii")
response = self.session.post( response = self.session.post(
OPENROUTER_TRANSCRIPTIONS_URL, OPENROUTER_TRANSCRIPTIONS_URL,
@@ -955,7 +1477,7 @@ class BlurtApp:
"input_audio": {"data": audio_b64, "format": "wav"}, "input_audio": {"data": audio_b64, "format": "wav"},
"language": self.transcription_language, "language": self.transcription_language,
}, },
timeout=30, timeout=timeout,
) )
self._log_transcription_response(response) self._log_transcription_response(response)
response.raise_for_status() response.raise_for_status()
@@ -978,10 +1500,15 @@ class BlurtApp:
else: else:
audio_data = self._stop_recording() audio_data = self._stop_recording()
if audio_data is not None: if audio_data is not None:
spool_path = spool_capture(audio_data)
if spool_path is not None:
self._inflight_spool.add(spool_path)
self.pending_transcriptions += 1 self.pending_transcriptions += 1
self._update_icon() self._update_icon()
t = threading.Thread( t = threading.Thread(
target=self._transcribe, args=(audio_data,), daemon=True target=self._transcribe,
args=(audio_data, spool_path),
daemon=True,
) )
t.start() t.start()
else: else:
@@ -1131,6 +1658,11 @@ class BlurtApp:
pystray.MenuItem("Model", pystray.Menu(*model_items)), pystray.MenuItem("Model", pystray.Menu(*model_items)),
pystray.MenuItem(lambda item: self._api_key_label(), None, enabled=False), pystray.MenuItem(lambda item: self._api_key_label(), None, enabled=False),
pystray.MenuItem("Language", pystray.Menu(*language_items)), pystray.MenuItem("Language", pystray.Menu(*language_items)),
pystray.MenuItem(
lambda item: f"Retry failed transcriptions ({len(self._retryable_files())})",
self._retry_spooled,
enabled=lambda item: len(self._retryable_files()) > 0,
),
pystray.MenuItem("Show Logs", self._show_logs), pystray.MenuItem("Show Logs", self._show_logs),
*awake_items, *awake_items,
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
+3 -2
View File
@@ -87,10 +87,11 @@ def main() -> None:
print(f"\nNo {provider} key available; skipping transcription.") print(f"\nNo {provider} key available; skipping transcription.")
return return
print(f"\nTranscribing with {app.transcription_model} ({app.transcription_language})…") print(f"\nTranscribing with {app.transcription_model} ({app.transcription_language})…")
timeout = blurt.transcription_timeout(len(conditioned) / blurt.SAMPLE_RATE)
result = ( result = (
app._transcribe_openrouter(wav_bytes) app._transcribe_openrouter(wav_bytes, timeout)
if provider == "openrouter" if provider == "openrouter"
else app._transcribe_openai(wav_bytes) else app._transcribe_openai(wav_bytes, timeout)
) )
print("\n--- transcript ---") print("\n--- transcript ---")
print(result.get("text", result)) print(result.get("text", result))