From 82bc350521406c6c176555bf412cfa38a17dd053 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sat, 5 Sep 2026 19:03:50 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_016rLFNPygDpRkRwPQakdmYh --- .gitignore | 3 + README.md | 21 +- blurt.py | 666 +++++++++++++++++++++++++++++++++++++++++++++------ mic_check.py | 5 +- 4 files changed, 625 insertions(+), 70 deletions(-) diff --git a/.gitignore b/.gitignore index 2d74285..6eac0f5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ __pycache__/ model.txt language.txt device.txt + +# Clips awaiting (or retrying) transcription +spool/ diff --git a/README.md b/README.md index 2a76fb8..98f4ff9 100644 --- a/README.md +++ b/README.md @@ -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 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, 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 diff --git a/blurt.py b/blurt.py index 3f908af..8934d00 100644 --- a/blurt.py +++ b/blurt.py @@ -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, diff --git a/mic_check.py b/mic_check.py index 1490e2d..f3171b2 100644 --- a/mic_check.py +++ b/mic_check.py @@ -87,10 +87,11 @@ def main() -> None: print(f"\nNo {provider} key available; skipping transcription.") return print(f"\nTranscribing with {app.transcription_model} ({app.transcription_language})…") + timeout = blurt.transcription_timeout(len(conditioned) / blurt.SAMPLE_RATE) result = ( - app._transcribe_openrouter(wav_bytes) + app._transcribe_openrouter(wav_bytes, timeout) if provider == "openrouter" - else app._transcribe_openai(wav_bytes) + else app._transcribe_openai(wav_bytes, timeout) ) print("\n--- transcript ---") print(result.get("text", result))