feat(overlay): live mic orb with audio-reactive pulse and ripple (#940)
Looks great, the live level feedback makes the overlay feel a lot more responsive.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 339 KiB |
@@ -80,6 +80,14 @@ class SpeechRecognitionManagerProtocol(Protocol):
|
||||
"""Register a callback for recognized text with its audio-capture start time."""
|
||||
...
|
||||
|
||||
def register_audio_level_callback(self, callback: Callable[[float], None]) -> None:
|
||||
"""Register a callback for microphone loudness updates (0-100)."""
|
||||
...
|
||||
|
||||
def unregister_audio_level_callback(self, callback: Callable[[float], None]) -> None:
|
||||
"""Remove a previously registered audio level callback."""
|
||||
...
|
||||
|
||||
@property
|
||||
def model_ready(self) -> bool:
|
||||
"""Whether the engine has a model loaded and can recognize speech."""
|
||||
|
||||
@@ -3186,6 +3186,7 @@ class SpeechRecognitionManager:
|
||||
# sees the dictation as ended. The volume goes back only once the
|
||||
# microphone thread has left the device, and before the stop cue.
|
||||
self.should_record = False
|
||||
self._update_state(RecognitionState.PROCESSING)
|
||||
self._cancel_pending_playback_duck()
|
||||
|
||||
# Wait for audio thread to finish recording and enqueue any pending audio
|
||||
|
||||
@@ -37,6 +37,7 @@ _ORB_SIZE = 96
|
||||
_PULSE_PERIOD_MS = 1400
|
||||
_ANIMATION_TICK_MS = 33 # ~30 fps
|
||||
_BOTTOM_MARGIN = 48
|
||||
_RIPPLE_STROKE_WIDTH = 2.0
|
||||
|
||||
|
||||
class DictationOverlayController:
|
||||
@@ -125,6 +126,8 @@ class DictationOverlay:
|
||||
self._drawing_area: Optional[Gtk.DrawingArea] = None
|
||||
self._anim_id: Optional[int] = None
|
||||
self._phase = 0.0
|
||||
self._audio_level = self._audio_level_target = 0.0
|
||||
self._ripple_phase = 1.0
|
||||
self._gtk_ready = False
|
||||
self._use_layer_shell = False
|
||||
|
||||
@@ -282,8 +285,17 @@ class DictationOverlay:
|
||||
def on_recognition_state(self, state: RecognitionState) -> None:
|
||||
"""Handle a recognition state change (same path as the tray icon)."""
|
||||
self.controller.set_state(state)
|
||||
if state != RecognitionState.LISTENING:
|
||||
self._audio_level_target = 0.0
|
||||
self._sync_window()
|
||||
|
||||
def set_audio_level(self, level: float) -> None:
|
||||
"""Accept microphone loudness (0–100) without calling GTK from the audio thread."""
|
||||
target = max(0.0, min(1.0, level / 100.0))
|
||||
if target > 0.025 and target > self._audio_level_target * 1.45:
|
||||
self._ripple_phase = 0.0
|
||||
self._audio_level_target = target
|
||||
|
||||
def destroy(self) -> None:
|
||||
"""Tear down animation and window."""
|
||||
self._stop_animation()
|
||||
@@ -401,6 +413,11 @@ class DictationOverlay:
|
||||
|
||||
def _on_anim_tick(self) -> bool:
|
||||
self._phase = (self._phase + _ANIMATION_TICK_MS / _PULSE_PERIOD_MS) % 1.0
|
||||
target = self._audio_level_target if self.controller.mode == MODE_LISTENING else 0.0
|
||||
self._audio_level += (target - self._audio_level) * (
|
||||
0.42 if target > self._audio_level else 0.16
|
||||
)
|
||||
self._ripple_phase = min(1.0, self._ripple_phase + _ANIMATION_TICK_MS / 650.0)
|
||||
if self._drawing_area is not None:
|
||||
self._drawing_area.queue_draw()
|
||||
# Keep ticking while visible.
|
||||
@@ -438,25 +455,35 @@ class DictationOverlay:
|
||||
pulse = 0.55 + 0.25 * math.sin(self._phase * 2.0 * math.pi)
|
||||
alpha_scale = 0.75
|
||||
core_a = 0.8
|
||||
loudness = 0.0
|
||||
else:
|
||||
pulse = 0.5 + 0.5 * math.sin(self._phase * 2.0 * math.pi)
|
||||
alpha_scale = 1.0
|
||||
core_a = 0.95
|
||||
loudness = max(
|
||||
0.0, min(1.0, (20 * math.log10(max(self._audio_level, 0.001)) + 45) / 45)
|
||||
)
|
||||
|
||||
# Outer glow rings.
|
||||
for radius_scale, ring_a in ((0.95, 0.12), (0.72, 0.22), (0.52, 0.35)):
|
||||
radius = (orb * 0.42) * radius_scale * (0.92 + 0.08 * pulse)
|
||||
alpha = ring_a * (0.55 + 0.45 * pulse) * alpha_scale
|
||||
outer = mode == MODE_LISTENING and radius_scale == 0.95
|
||||
alpha *= 0.25 + 0.75 * loudness if outer else 1.0
|
||||
cr.set_source_rgba(base_r, base_g, base_b, alpha)
|
||||
cr.arc(cx, cy, radius, 0, 2 * math.pi)
|
||||
cr.fill()
|
||||
cr.set_line_width(2 + 10 * loudness if outer else 2)
|
||||
cr.stroke() if outer else cr.fill()
|
||||
|
||||
# Expanding ripple (listening only).
|
||||
if mode == MODE_LISTENING:
|
||||
ripple_r = (orb * 0.28) + self._phase * (orb * 0.28)
|
||||
ripple_a = max(0.0, 0.45 * (1.0 - self._phase))
|
||||
# Expanding ripple (listening only). The cap keeps the circle and
|
||||
# its stroke inside the window — on backends without layer shell
|
||||
# the window is exactly the orb size, so an uncapped ripple clips.
|
||||
if mode == MODE_LISTENING and self._ripple_phase < 1.0:
|
||||
max_r = max(0.0, min(width, height) / 2 - _RIPPLE_STROKE_WIDTH / 2 - 1)
|
||||
ripple_r = min(orb * (0.24 + 0.42 * self._ripple_phase), max_r)
|
||||
ripple_a = 0.65 * (1.0 - self._ripple_phase)
|
||||
cr.set_source_rgba(base_r, base_g, base_b, ripple_a)
|
||||
cr.set_line_width(2.0)
|
||||
cr.set_line_width(_RIPPLE_STROKE_WIDTH)
|
||||
cr.arc(cx, cy, ripple_r, 0, 2 * math.pi)
|
||||
cr.stroke()
|
||||
|
||||
|
||||
@@ -249,6 +249,7 @@ class TrayIndicator:
|
||||
from .dictation_overlay import DictationOverlay
|
||||
|
||||
self.overlay = DictationOverlay(enabled=self.config_manager.is_overlay_enabled())
|
||||
self.speech_engine.register_audio_level_callback(self.overlay.set_audio_level)
|
||||
|
||||
# Initialize the icon files and validate resources
|
||||
self._init_icons()
|
||||
@@ -1738,6 +1739,7 @@ class TrayIndicator:
|
||||
self._stop_language_shortcut_managers()
|
||||
|
||||
if getattr(self, "overlay", None) is not None:
|
||||
self.speech_engine.unregister_audio_level_callback(self.overlay.set_audio_level)
|
||||
self.overlay.destroy()
|
||||
self.overlay = None
|
||||
|
||||
|
||||
@@ -490,7 +490,62 @@ class TestOverlayWithMockedGtkWindow(unittest.TestCase):
|
||||
self.assertFalse(overlay._on_draw(widget, cr))
|
||||
self.assertTrue(cr.arc.called)
|
||||
self.assertTrue(cr.fill.called)
|
||||
self.assertTrue(cr.stroke.called) # ripple ring
|
||||
self.assertEqual(cr.stroke.call_count, 1) # loudness ring only, no ripple yet
|
||||
|
||||
def test_ripple_follows_audio_peaks_and_expires(self) -> None:
|
||||
"""set_audio_level over the spike threshold draws a ripple that ends."""
|
||||
overlay = _overlay_without_gtk(enabled=True)
|
||||
overlay.controller.set_state(RecognitionState.LISTENING)
|
||||
overlay._phase = 0.25
|
||||
widget = MagicMock()
|
||||
widget.get_allocated_width.return_value = 96
|
||||
widget.get_allocated_height.return_value = 96
|
||||
|
||||
# Quiet and slowly rising levels never start a ripple.
|
||||
overlay.set_audio_level(1.0)
|
||||
overlay.set_audio_level(2.4)
|
||||
self.assertEqual(overlay._ripple_phase, 1.0)
|
||||
|
||||
# A real peak starts one; a step inside the spike ratio does not retrigger.
|
||||
overlay.set_audio_level(50.0)
|
||||
self.assertEqual(overlay._ripple_phase, 0.0)
|
||||
overlay._ripple_phase = 0.5
|
||||
overlay.set_audio_level(60.0)
|
||||
self.assertEqual(overlay._ripple_phase, 0.5)
|
||||
|
||||
cr = MagicMock()
|
||||
self.assertFalse(overlay._on_draw(widget, cr))
|
||||
self.assertEqual(cr.stroke.call_count, 2) # loudness ring + ripple
|
||||
|
||||
# The ripple expands over its ~650ms of ticks and then stops drawing.
|
||||
while overlay._ripple_phase < 1.0:
|
||||
overlay._on_anim_tick()
|
||||
cr_done = MagicMock()
|
||||
self.assertFalse(overlay._on_draw(widget, cr_done))
|
||||
self.assertEqual(cr_done.stroke.call_count, 1)
|
||||
|
||||
def test_ripple_radius_stays_inside_window(self) -> None:
|
||||
"""The ripple circle plus its stroke never exceeds the window."""
|
||||
from vocalinux.ui.dictation_overlay import _RIPPLE_STROKE_WIDTH
|
||||
|
||||
overlay = _overlay_without_gtk(enabled=True)
|
||||
overlay.controller.set_state(RecognitionState.LISTENING)
|
||||
overlay.set_audio_level(80.0)
|
||||
overlay._ripple_phase = 0.0
|
||||
widget = MagicMock()
|
||||
widget.get_allocated_width.return_value = 96
|
||||
widget.get_allocated_height.return_value = 96
|
||||
|
||||
max_r = 0.0
|
||||
for _ in range(60): # span the whole ripple expansion
|
||||
cr = MagicMock()
|
||||
self.assertFalse(overlay._on_draw(widget, cr))
|
||||
for arc_call in cr.arc.call_args_list:
|
||||
max_r = max(max_r, arc_call.args[2])
|
||||
overlay._on_anim_tick()
|
||||
|
||||
limit = 96 / 2 - _RIPPLE_STROKE_WIDTH / 2
|
||||
self.assertLessEqual(max_r, limit)
|
||||
|
||||
def test_on_draw_processing_uses_muted_pulse(self):
|
||||
overlay = _overlay_without_gtk(enabled=True)
|
||||
|
||||
Reference in New Issue
Block a user