overlay: silence auto-stop countdown ring around the mic; Release 1.5.1

A full circle wraps the mic glyph and drains clockwise as the trailing-
silence timer runs out, recolouring cyan→amber→red and emptying exactly
as auto-stop fires. The daemon emits the countdown from the same VAD loop
that decides auto-stop, so the ring stays in sync; the overlay drains it
against its own clock for smooth motion and fades it in/out so word gaps
don't flicker it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-07 13:49:20 +02:00
parent d55cdafcc1
commit 6a9c1fc95a
6 changed files with 111 additions and 9 deletions

View File

@ -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.
<p align="center">

View File

@ -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

View File

@ -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"

View File

@ -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()

View File

@ -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

View File

@ -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)