diff --git a/docs/images/microphone-indicator.gif b/docs/images/microphone-indicator.gif new file mode 100644 index 0000000..22b0bdb Binary files /dev/null and b/docs/images/microphone-indicator.gif differ diff --git a/src/vocalinux/common_types.py b/src/vocalinux/common_types.py index 0c792f9..b9a21dc 100644 --- a/src/vocalinux/common_types.py +++ b/src/vocalinux/common_types.py @@ -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.""" diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index d9d44ad..56a1fde 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -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 diff --git a/src/vocalinux/ui/dictation_overlay.py b/src/vocalinux/ui/dictation_overlay.py index 0bc38f1..67b7686 100644 --- a/src/vocalinux/ui/dictation_overlay.py +++ b/src/vocalinux/ui/dictation_overlay.py @@ -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() diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index aa6ca37..56148aa 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -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 diff --git a/tests/test_dictation_overlay.py b/tests/test_dictation_overlay.py index ff45d2b..8f899ea 100644 --- a/tests/test_dictation_overlay.py +++ b/tests/test_dictation_overlay.py @@ -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)