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:
@@ -9,3 +9,6 @@ __pycache__/
|
|||||||
model.txt
|
model.txt
|
||||||
language.txt
|
language.txt
|
||||||
device.txt
|
device.txt
|
||||||
|
|
||||||
|
# Clips awaiting (or retrying) transcription
|
||||||
|
spool/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user