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
+599 -67
View File
@@ -11,6 +11,7 @@ import sys
import tempfile
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from logging.handlers import RotatingFileHandler
from math import gcd
@@ -99,7 +100,18 @@ RAW_ALSA_DEVICE = re.compile(r"\(hw:\d+,\d+\)")
SOUND_SERVER_DEVICES = ("pipewire", "pulse", "default")
APP_NAME = "Blurt"
APP_DIR = Path(__file__).resolve().parent
SPOOL_DIR = APP_DIR / "spool" # clips wait here until their transcript is delivered
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"
LOG_FILE = Path(tempfile.gettempdir()) / "blurt.log"
KEYRING_CREDENTIALS = [
@@ -413,6 +425,183 @@ def condition_audio(samples: np.ndarray) -> np.ndarray:
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:
for model_id, label, _ in TRANSCRIPTION_MODELS:
if model_id == model:
@@ -617,6 +806,9 @@ class BlurtApp:
self.capture_rate = SAMPLE_RATE
self.capture_channels = CHANNELS
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.input_devices = self._get_input_devices()
self.device_name = load_input_device_name()
@@ -640,6 +832,17 @@ class BlurtApp:
AwakeKeeper() if sys.platform == "win32" else None
)
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(
"BlurtApp initialized with %d input devices, model=%s, language=%s, awake_available=%s",
len(self.input_devices),
@@ -683,24 +886,45 @@ class BlurtApp:
logging.warning("Configured input device %r is not connected; using system default", name)
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))
draw = ImageDraw.Draw(img)
draw.ellipse([8, 8, 56, 56], fill=color)
if progress is None:
draw.ellipse([8, 8, 56, 56], fill=color)
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):
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:
color = (220, 40, 40) # red
state = "Recording..."
elif self.pending_transcriptions > 0:
color = (40, 100, 220) # blue
state = "Transcribing..."
if total > 1:
state = f"Transcribing {done}/{total}..."
progress = done / total
else:
state = "Transcribing..."
else:
color = (120, 120, 120) # gray
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.update_menu()
@@ -749,18 +973,24 @@ class BlurtApp:
return f"API key: {provider_label} (missing)"
return f"API key: {provider_label} (…{key[-4:]})"
def _require_api_key(self, action: str) -> bool:
if self._current_api_key():
return True
provider = self._provider_for_model(self.transcription_model)
key_hint = PROVIDER_KEY_HINTS.get(provider, f"set the {provider} API key")
logging.error(
"%s blocked: model=%s provider=%s has no API key; "
"%s or select a model with an available key",
action,
self.transcription_model,
provider,
key_hint,
)
self.platform.notify("Error", f"No {provider} API key; {key_hint}.")
return False
def _start_recording(self):
if not self._current_api_key():
provider = self._provider_for_model(self.transcription_model)
key_hint = PROVIDER_KEY_HINTS.get(provider, f"set the {provider} API key")
logging.error(
"Recording blocked: model=%s provider=%s has no API key; "
"%s or select a model with an available key",
self.transcription_model,
provider,
key_hint,
)
self.platform.notify("Error", f"No {provider} API key; {key_hint}.")
if not self._require_api_key("Recording"):
return
self.audio_frames = []
try:
@@ -863,66 +1093,358 @@ class BlurtApp:
dump_capture("sent", sent)
return sent
def _transcribe(self, audio_data: np.ndarray):
self.platform.notify(
"Transcribing",
f"Using {get_model_label(self.transcription_model)} ({self.transcription_language})",
def _transcribe(
self,
audio_data: np.ndarray,
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))
buffer = io.BytesIO()
wavfile.write(buffer, SAMPLE_RATE, audio_data)
wav_bytes = buffer.getvalue()
logging.info("Encoded WAV in memory: %d bytes", len(wav_bytes))
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 self._provider_for_model(self.transcription_model) == "openrouter":
result = self._transcribe_openrouter(wav_bytes)
if len(ranges) == 1:
buffer = io.BytesIO()
wavfile.write(buffer, SAMPLE_RATE, audio_data)
wav_bytes = buffer.getvalue()
logging.info("Encoded WAV in memory: %d bytes", len(wav_bytes))
try:
text = self._request_transcript(
wav_bytes, len(audio_data) / SAMPLE_RATE
)
finally:
self._chunk_finished()
self._hand_over(text, collect)
self._discard_spool(spool_path)
else:
result = self._transcribe_openai(wav_bytes)
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)
self.platform.notify("Error", f"Transcription failed: {error_msg}")
return
if text.lower().startswith(AGENT_PREFIX):
task = text[len(AGENT_PREFIX):].lstrip(" ,.:;-")
if task:
pyperclip.copy(task)
try:
self.platform.dispatch(task)
except Exception as e:
self.platform.notify("Dispatch Error", str(e))
preview = task[:50] + ("..." if len(task) > 50 else "")
self.platform.notify("Dispatched", preview)
logging.info("Dispatched transcription task with %d characters", len(task))
texts, errors = self._transcribe_chunks(audio_data, ranges)
if errors:
self._deliver_partial(
audio_data, ranges, texts, errors, spool_path, collect
)
else:
pyperclip.copy(text)
self.platform.notify("Error", f"No task after '{AGENT_PREFIX}'")
logging.warning("Agent prefix detected with no task")
else:
pyperclip.copy(text)
preview = text[:50] + ("..." if len(text) > 50 else "")
self.platform.notify("Copied", preview)
logging.info("Copied transcription to clipboard")
self._hand_over(" ".join(text.strip() for text in texts), collect)
self._discard_spool(spool_path)
except TranscriptionError as e:
self._report_failure(str(e), spool_path)
except requests.RequestException as e:
logging.exception("API request failed")
self.platform.notify("Error", f"API request failed: {e}")
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.platform.notify("Error", f"Unexpected transcription failure: {e}")
self._report_failure(f"Unexpected transcription failure: {e}", spool_path)
finally:
self.pending_transcriptions -= 1
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 _transcribe_openai(self, wav_bytes: bytes) -> dict:
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):
task = text[len(AGENT_PREFIX):].lstrip(" ,.:;-")
if task:
pyperclip.copy(task)
try:
self.platform.dispatch(task)
except Exception as e:
self.platform.notify("Dispatch Error", str(e))
preview = task[:50] + ("..." if len(task) > 50 else "")
self.platform.notify("Dispatched", preview)
logging.info("Dispatched transcription task with %d characters", len(task))
else:
pyperclip.copy(text)
self.platform.notify("Error", f"No task after '{AGENT_PREFIX}'")
logging.warning("Agent prefix detected with no task")
else:
pyperclip.copy(text)
preview = text[:50] + ("..." if len(text) > 50 else "")
self.platform.notify("Copied", preview)
logging.info("Copied transcription to clipboard")
def _transcribe_chunks(
self, audio_data: np.ndarray, ranges: list[tuple[int, int]]
) -> tuple[list[str | None], dict[int, Exception]]:
"""Upload every chunk in parallel; text per chunk, plus what went wrong
for each missing one. The clip is already recorded, so latency is all
that is left to save, and one dead chunk must not cost the other nine.
The exception itself is kept, not just its message: whether the audio is
worth spooling depends on why the request failed."""
texts: list[str | None] = [None] * len(ranges)
errors: dict[int, Exception] = {}
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 = {
"model": self.transcription_model,
"language": self.transcription_language,
@@ -935,13 +1457,13 @@ class BlurtApp:
headers={"Authorization": f"Bearer {self.api_keys['openai']}"},
files={"file": ("blurt.wav", wav_bytes, "audio/wav")},
data=data,
timeout=30,
timeout=timeout,
)
self._log_transcription_response(response)
response.raise_for_status()
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")
response = self.session.post(
OPENROUTER_TRANSCRIPTIONS_URL,
@@ -955,7 +1477,7 @@ class BlurtApp:
"input_audio": {"data": audio_b64, "format": "wav"},
"language": self.transcription_language,
},
timeout=30,
timeout=timeout,
)
self._log_transcription_response(response)
response.raise_for_status()
@@ -978,10 +1500,15 @@ class BlurtApp:
else:
audio_data = self._stop_recording()
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._update_icon()
t = threading.Thread(
target=self._transcribe, args=(audio_data,), daemon=True
target=self._transcribe,
args=(audio_data, spool_path),
daemon=True,
)
t.start()
else:
@@ -1131,6 +1658,11 @@ class BlurtApp:
pystray.MenuItem("Model", pystray.Menu(*model_items)),
pystray.MenuItem(lambda item: self._api_key_label(), None, enabled=False),
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),
*awake_items,
pystray.Menu.SEPARATOR,