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:
parent
d55cdafcc1
commit
6a9c1fc95a
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user