diff --git a/README.md b/README.md index ec65ecb..ebf8439 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Blitztext is a native Linux dictation tool that captures your voice, transcribes it locally with [faster-whisper](https://github.com/SYSTRAN/faster-whisper), optionally rewrites the text through an LLM, and types the result directly into whatever application has focus. Think macOS Dictation, but open-source, extensible, and designed for power users who want full control over their speech-to-text pipeline. -> **Status:** Experimental open-source Linux/X11 desktop app (v1.5.0). +> **Status:** Experimental open-source Linux/X11 desktop app (v1.5.1). > No hosted backend — bring your own models and endpoints.

diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 0682eb1..b87131d 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -9,6 +9,17 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py). ## [Unreleased] +## [1.5.1] - 2026-06-07 + +### Added +- **Silence auto-stop countdown ring** on the dictation overlay: when you stop + speaking, a full circle wrapping the microphone glyph drains clockwise as the + trailing-silence timer runs out, recolouring from calm cyan to an urgent red + and emptying exactly as the recording auto-stops. It spans the configured + "Silence to stop (s)" window (`[wakeword] silence_seconds`), fades back in/out + as you pause and resume, and so finally makes the hands-free auto-stop visible + instead of a silent surprise. + ## [1.5.0] - 2026-06-07 ### Added @@ -202,7 +213,8 @@ into that field. AppIndicator typelibs and GNOME `ubuntu-appindicators` extension are already present on the target host). -[Unreleased]: https://github.com/mARTin-B78/blitztext-app-linux/compare/v1.5.0...HEAD +[Unreleased]: https://github.com/mARTin-B78/blitztext-app-linux/compare/v1.5.1...HEAD +[1.5.1]: https://github.com/mARTin-B78/blitztext-app-linux/compare/v1.5.0...v1.5.1 [1.5.0]: https://github.com/mARTin-B78/blitztext-app-linux/compare/v1.4.0...v1.5.0 [1.1.0]: https://github.com/mARTin-B78/blitztext-app-linux/compare/v1.0.1...v1.1.0 [1.0.1]: https://github.com/mARTin-B78/blitztext-app-linux/compare/v1.0.0...v1.0.1 diff --git a/linux/blitztext/__init__.py b/linux/blitztext/__init__.py index 1f04722..452842a 100644 --- a/linux/blitztext/__init__.py +++ b/linux/blitztext/__init__.py @@ -6,4 +6,4 @@ counterpart to the macOS Blitztext menu bar app: it runs natively on the host (not in a container) so it can type into any application via xdotool. """ -__version__ = "1.5.0" +__version__ = "1.5.1" diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py index 51878fd..de85784 100644 --- a/linux/blitztext/daemon.py +++ b/linux/blitztext/daemon.py @@ -26,18 +26,28 @@ from .transcribe import Transcriber # state in {"loading", "idle", "recording", "streaming", "busy", "done", "error"} StatusCallback = Callable[[str, str | None, str], None] +# A trailing pause shorter than this never arms the auto-stop countdown, so the +# overlay ring doesn't flicker in the gaps between words. The visible countdown +# therefore spans (silence_seconds - this) and begins full once you genuinely +# stop speaking. +_VAD_COUNTDOWN_GRACE = 0.35 + class Daemon: def __init__(self, cfg: Config, status_cb: StatusCallback | None = None, level_cb: Callable[[float], None] | None = None, - text_cb: Callable[[str], None] | None = None): + text_cb: Callable[[str], None] | None = None, + countdown_cb: Callable[[float | None, float], None] | None = None): self.cfg = cfg self.status_cb = status_cb # Optional UI feedback hooks for the on-screen overlay. The daemon stays # UI-agnostic: these are no-ops in headless mode. level_cb gets the live - # mic level (0..1); text_cb gets the running transcript while streaming. + # mic level (0..1); text_cb gets the running transcript while streaming; + # countdown_cb(seconds_left, window) drives the silence auto-stop ring + # (seconds_left=None while you're speaking, so the ring clears). self.level_cb = level_cb self.text_cb = text_cb + self.countdown_cb = countdown_cb self._ov_meter = None self._ov_text_final = "" self._lock = threading.Lock() @@ -156,10 +166,21 @@ class Daemon: now = time.time() if level > 0.05: self._vad_last_speech = now - elif now - self._vad_started_at > 2.0 and now - self._vad_last_speech > silence: - if getattr(self, "is_recording", False): + if self.countdown_cb: + self.countdown_cb(None, silence) # speaking — no countdown + return + quiet = now - self._vad_last_speech + armed = now - self._vad_started_at > 2.0 + if armed and quiet > _VAD_COUNTDOWN_GRACE: + # Mirror the auto-stop window into the overlay ring: full when + # you fall quiet, empty exactly as it fires. + if self.countdown_cb: + self.countdown_cb(silence - quiet, silence - _VAD_COUNTDOWN_GRACE) + if quiet > silence and getattr(self, "is_recording", False): GLib.idle_add(lambda: self.finish_dictation(send_enter=False)) self._vad_stop() + elif self.countdown_cb: + self.countdown_cb(None, silence) self._vad_meter = audio.LevelMeter(self.cfg.mic, on_level=on_level) self._vad_meter.start() diff --git a/linux/blitztext/gtkui.py b/linux/blitztext/gtkui.py index 2521ecc..3bba42b 100644 --- a/linux/blitztext/gtkui.py +++ b/linux/blitztext/gtkui.py @@ -107,6 +107,7 @@ class App: self.daemon = Daemon( cfg, status_cb=self._status_cb, level_cb=self._on_level, text_cb=self._on_text, + countdown_cb=self._on_countdown, ) _install_css() @@ -271,6 +272,10 @@ class App: if self.overlay is not None: self.overlay.set_text(text) + def _on_countdown(self, remaining: float | None, total: float) -> None: + if self.overlay is not None: + self.overlay.set_countdown(remaining, total) + def _overlay_status(self, state: str, message: str) -> None: """Translate engine phases into overlay show/update/hide (GTK thread).""" ov = self.overlay diff --git a/linux/blitztext/overlay.py b/linux/blitztext/overlay.py index e37c92b..9a2bc34 100644 --- a/linux/blitztext/overlay.py +++ b/linux/blitztext/overlay.py @@ -69,6 +69,13 @@ class Overlay: self._levels: deque[float] = deque([0.0] * _BARS, maxlen=_BARS) self._disp = [0.0] * _BARS # eased bar heights for smooth motion self._pulse = 0.0 + # Silence auto-stop countdown ring (wraps the mic): a deadline the ring + # drains toward, the window it spans, and an eased opacity so it fades in + # when you fall quiet and out the moment you speak again. + self._cd_deadline: float | None = None + self._cd_total = 1.0 + self._cd_frac = 0.0 + self._cd_alpha = 0.0 self._tick_id: int | None = None self._hide_id: int | None = None self._t0 = time.time() @@ -111,6 +118,11 @@ class Overlay: def set_text(self, text: str) -> None: GLib.idle_add(self._set_text, text or "") + def set_countdown(self, remaining: float | None, total: float) -> None: + """Silence auto-stop progress: ``remaining`` seconds until it fires over + a ``total``-second window, or ``None`` while you're still speaking.""" + GLib.idle_add(self._set_countdown, remaining, total) + def set_state(self, state: str, message: str = "") -> None: GLib.idle_add(self._set_state, state, message) @@ -128,6 +140,9 @@ class Overlay: self._text = "" self._levels = deque([0.0] * _BARS, maxlen=_BARS) self._disp = [0.0] * _BARS + self._cd_deadline = None + self._cd_frac = 0.0 + self._cd_alpha = 0.0 self._anchor = caret.resolve(self.anchor_mode, window_id) self._relayout() self._visible = True @@ -148,9 +163,21 @@ class Overlay: self._area.queue_draw() return False + def _set_countdown(self, remaining: float | None, total: float) -> bool: + if remaining is None: + self._cd_deadline = None + else: + self._cd_total = max(0.1, total) + self._cd_deadline = time.time() + max(0.0, remaining) + return False + def _set_state(self, state: str, message: str) -> bool: self._state = state self._phase_label = _PHASES.get(state, ((1, 1, 1), message[:40]))[1] or message[:40] + if state not in ("recording", "streaming"): + # The countdown only makes sense while listening; drop it as soon as + # we move on to transcribing / done / idle so the ring doesn't linger. + self._cd_deadline = None if state in ("recording", "streaming", "busy"): self._cancel_hide() elif state in ("done", "idle", "error"): @@ -195,6 +222,14 @@ class Overlay: for i, t in enumerate(targets): self._disp[i] += (t - self._disp[i]) * 0.35 self._pulse = (math.sin((time.time() - self._t0) * 5.0) + 1.0) * 0.5 + # Drain the silence ring against its own clock so it stays smooth between + # the ~10 Hz level samples. Fade in gently (so word gaps don't flash it) + # and out a touch faster. + if self._cd_deadline is not None: + self._cd_frac = max(0.0, min(1.0, (self._cd_deadline - time.time()) / self._cd_total)) + self._cd_alpha += (1.0 - self._cd_alpha) * 0.15 + else: + self._cd_alpha += (0.0 - self._cd_alpha) * 0.30 self._area.queue_draw() return True @@ -272,6 +307,7 @@ class Overlay: cx = _PAD + 14 cy = body_top + _PAD + 12 self._draw_mic(cr, cx, cy) + self._draw_countdown(cr, cx, cy) # Waveform fills the space right of the mic across the header row. wf_x = cx + 26 @@ -310,10 +346,11 @@ class Overlay: def _draw_mic(self, cr, cx, cy) -> None: colour, _ = _PHASES.get(self._state, ((1, 1, 1), "")) pulsing = self._state in ("recording", "streaming") - # Soft pulsing halo while listening. + # Soft pulsing halo while listening — but yield to the countdown ring as + # it takes over (you've gone quiet, so the pulse fades out under it). if pulsing: rad = 12 + self._pulse * 6 - cr.set_source_rgba(*colour, 0.18 * (1 - self._pulse * 0.6)) + cr.set_source_rgba(*colour, 0.18 * (1 - self._pulse * 0.6) * (1 - self._cd_alpha)) cr.arc(cx, cy, rad, 0, 2 * math.pi) cr.fill() cr.set_source_rgba(*colour, 1.0) @@ -335,6 +372,33 @@ class Overlay: cr.line_to(cx + 5, cy + 12) cr.stroke() + def _draw_countdown(self, cr, cx, cy) -> None: + """Silence auto-stop ring wrapping the mic: a full circle that drains + clockwise as the trailing-silence timer runs out, recolouring from calm + cyan to an urgent red just before it fires.""" + a = self._cd_alpha + if a <= 0.01: + return + r, lw = 16.0, 2.6 + cr.set_line_width(lw) + # Faint full track so the drained part of the ring stays legible. + cr.set_source_rgba(1, 1, 1, 0.10 * a) + cr.arc(cx, cy, r, 0, 2 * math.pi) + cr.stroke() + frac = self._cd_frac + if frac <= 0.0: + return + spent = 1.0 - frac + red = 0.30 + spent * 0.70 + grn = 0.80 - spent * 0.45 + blu = 0.90 - spent * 0.60 + cr.set_source_rgba(red, grn, blu, 0.95 * a) + cr.set_line_cap(cairo.LINE_CAP_ROUND) + start = -math.pi / 2 # 12 o'clock + cr.arc(cx, cy, r, start, start + frac * 2 * math.pi) + cr.stroke() + cr.set_line_cap(cairo.LINE_CAP_BUTT) + def _draw_wave(self, cr, x, y, w, h) -> None: gap = 2.0 bw = max(1.5, (w - gap * (_BARS - 1)) / _BARS)