From 06bc8375d5bff40a5b3ad8168421fa91218f9fe3 Mon Sep 17 00:00:00 2001 From: aladin <6892108+aladin7@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:28:57 +0300 Subject: [PATCH] Improve recording startup and language feedback --- database.py | 18 +++ hotkey.py | 67 ++++++++-- locales.py | 12 ++ main.py | 219 ++++++++++++++++++++++++++++----- recognition_languages.py | 30 +++++ recorder.py | 224 +++++++++++++++++++++++----------- settings_window.py | 10 +- test_hotkey.py | 78 ++++++++++++ test_language_prompt.py | 81 ++++++++++++ test_recognition_languages.py | 23 ++++ test_recorder.py | 133 ++++++++++++++++++++ test_transcriber.py | 66 ++++++++++ test_widget.py | 75 ++++++++++++ transcriber.py | 19 ++- widget.py | 150 +++++++++++++++++++++-- 15 files changed, 1076 insertions(+), 129 deletions(-) create mode 100644 recognition_languages.py create mode 100644 test_language_prompt.py create mode 100644 test_recognition_languages.py create mode 100644 test_recorder.py create mode 100644 test_transcriber.py create mode 100644 test_widget.py diff --git a/database.py b/database.py index 64597f0..338431f 100644 --- a/database.py +++ b/database.py @@ -414,6 +414,24 @@ def save_setting(key: str, value: str): c.close() +def save_settings(settings: dict[str, str]): + """Save multiple settings atomically.""" + with _lock: + c = _conn() + try: + c.executemany( + "INSERT INTO settings(key,value) VALUES(?,?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + settings.items(), + ) + c.commit() + except Exception: + c.rollback() + raise + finally: + c.close() + + # ── Custom vocabulary (Layer A) ─────────────────────────────────────────── def list_vocabulary() -> list[tuple[str, str]]: diff --git a/hotkey.py b/hotkey.py index 1b2993a..e610787 100644 --- a/hotkey.py +++ b/hotkey.py @@ -11,6 +11,8 @@ (frozenset_of_modifier_strings, trigger_key) — see hotkey_util.py. """ +import queue +import threading import time from pynput import keyboard import config @@ -18,6 +20,7 @@ from hotkey_util import canonical_modifier, keys_match _DEBOUNCE_SEC = 0.3 # minimum time between toggle actions +_STOP_CALLBACKS = object() # ── Hotkey matching helpers ─────────────────────────────────────────────── @@ -63,6 +66,16 @@ def __init__(self, on_press_cb, on_release_cb, # Currently held modifier keys (canonical names: "ctrl", "shift", etc.) self._held_modifiers: set = set() self._listener = None + # pynput invokes handlers on its event thread. Microphone startup can + # block there, which prevents a physical key release from being seen. + # Preserve callback order on a separate worker while keeping pynput's + # event thread responsive. + self._callback_queue = queue.Queue() + self._callback_stopped = threading.Event() + self._lifecycle_lock = threading.Lock() + self._callback_worker = threading.Thread( + target=self._run_callbacks, daemon=True) + self._callback_worker.start() def _is_hold_mode(self) -> bool: return getattr(config, "HOLD_TO_RECORD", True) @@ -154,23 +167,51 @@ def force_stop_assistant(self): if self._on_assist_release: self._safe_call(self._on_assist_release, "Assistant timeout-stop") + def cancel_dictation_start(self): + """Reset dictation key state after microphone startup fails.""" + self._dict_recording = False + self._dict_pressed = False + + def cancel_assistant_start(self): + """Reset assistant key state after microphone startup fails.""" + self._assist_recording = False + self._assist_pressed = False + # ── helpers ─────────────────────────────────────────────────────────── - @staticmethod - def _safe_call(fn, label: str): - try: - fn() - except Exception as exc: - log.error("%s error: %s", label, exc) + def _safe_call(self, fn, label: str): + if not self._callback_stopped.is_set(): + self._callback_queue.put((fn, label)) + + def _run_callbacks(self): + while True: + item = self._callback_queue.get() + if item is _STOP_CALLBACKS: + return + if self._callback_stopped.is_set(): + continue + fn, label = item + try: + fn() + except Exception as exc: + log.error("%s error: %s", label, exc) def start(self): - self._listener = keyboard.Listener( - on_press=self._handle_press, - on_release=self._handle_release, - ) - self._listener.start() + with self._lifecycle_lock: + if self._callback_stopped.is_set(): + return + self._listener = keyboard.Listener( + on_press=self._handle_press, + on_release=self._handle_release, + ) + self._listener.start() self._listener.wait() def stop(self): - if self._listener is not None: - self._listener.stop() + self._callback_stopped.set() + with self._lifecycle_lock: + if self._listener is not None: + self._listener.stop() + self._callback_queue.put(_STOP_CALLBACKS) + if threading.current_thread() is not self._callback_worker: + self._callback_worker.join() diff --git a/locales.py b/locales.py index 430e2e6..b498f66 100644 --- a/locales.py +++ b/locales.py @@ -73,6 +73,10 @@ "lang_name": "English", # main.py — widget messages + "microphone_starting": "Starting microphone...", + "language_prompt_question": "Set recognition language to {language}?", + "language_prompt_accept": "Use {language}", + "language_prompt_decline": "Keep Auto", "show_notes": "📝 Here are your notes", "show_appointments": "📅 Here is your agenda", "show_reminders": "⏰ Here are your reminders", @@ -221,6 +225,10 @@ "lang_name": "Italian", "show_notes": "📝 Ecco le note", + "microphone_starting": "Avvio microfono...", + "language_prompt_question": "Impostare la lingua di riconoscimento su {language}?", + "language_prompt_accept": "Usa {language}", + "language_prompt_decline": "Mantieni Auto", "show_appointments": "📅 Ecco l'agenda", "show_reminders": "⏰ Ecco i reminder", "assistant_error": "Errore assistente", @@ -362,6 +370,10 @@ "lang_name": "German", "show_notes": "📝 Hier sind Ihre Notizen", + "microphone_starting": "Mikrofon wird gestartet...", + "language_prompt_question": "Erkennungssprache auf {language} setzen?", + "language_prompt_accept": "{language} verwenden", + "language_prompt_decline": "Auto behalten", "show_appointments": "📅 Hier ist Ihre Agenda", "show_reminders": "⏰ Hier sind Ihre Erinnerungen", "assistant_error": "Assistentenfehler", diff --git a/main.py b/main.py index 29fdb46..20338d9 100644 --- a/main.py +++ b/main.py @@ -41,9 +41,15 @@ from notes_window import NotesWindow from settings_window import SettingsWindow from replacements import apply_replacements +from recognition_languages import ( + SUPPORTED_LANGUAGE_CODES, + language_display_name, +) _pipeline_queue = queue.Queue() _assistant_queue = queue.Queue() +_recording_started_queue = queue.SimpleQueue() +_shutdown_event = threading.Event() recorder = Recorder() transcriber = None @@ -60,6 +66,9 @@ _DELETE_CONFIRM_SECONDS = 15.0 _DELETE_CONFIRM_TOKEN = re.compile(r"^__confirm_delete__:(note|appointment|reminder):(\d+)$") _pending_delete = None +_language_prompt_pending = False +_last_detected_language = None +_recording_session = 0 # Toggle-mode timeout timers _dict_timeout_timer = None @@ -161,18 +170,37 @@ def _timeout_assistant(): # ── Dictation callbacks (AltGr) ────────────────────────────────────────── def _on_hotkey_press(): - global _rec_start + global _rec_start, _recording_session + if _shutdown_event.is_set(): + return _rec_start = time.monotonic() - recorder.start() + _recording_session += 1 + session = _recording_session + if widget: + widget.show_status(locales.get("microphone_starting"), "loading") + if config.WHISPER_LANGUAGE: + language = config.WHISPER_LANGUAGE + elif _last_detected_language: + language = f"auto · {_last_detected_language}" + else: + language = "auto" + recorder.on_started = lambda: _recording_started_queue.put( + (session, "dictation", language)) + if not recorder.start(): + if widget: + widget.hide(immediate=True) + if hotkey_listener: + hotkey_listener.cancel_dictation_start() + return if tray: tray.set_recording(True) - if widget: - widget.show_recording() _start_timeout("dictation") - log.info("Recording started (dictation).") + log.info("Microphone starting (dictation).") def _on_hotkey_release(): + global _recording_session + _recording_session += 1 _cancel_timeout("dictation") audio = recorder.stop() duration = time.monotonic() - _rec_start @@ -186,7 +214,7 @@ def _on_hotkey_release(): _pipeline_queue.put(audio) else: if widget: - widget.hide() + widget.hide(immediate=True) if duration < _MIN_DURATION: log.info("Too short (%.2fs), skipping.", duration) else: @@ -196,19 +224,37 @@ def _on_hotkey_release(): # ── Assistant callbacks (Ctrl+R) ────────────────────────────────────────── def _on_assist_press(): - global _rec_start + global _rec_start, _recording_session + if _shutdown_event.is_set(): + return _rec_start = time.monotonic() - recorder.start() + _recording_session += 1 + session = _recording_session + if widget: + widget.show_status(locales.get("microphone_starting"), "loading") + if config.WHISPER_LANGUAGE: + language = config.WHISPER_LANGUAGE + elif _last_detected_language: + language = f"auto · {_last_detected_language}" + else: + language = "auto" + recorder.on_started = lambda: _recording_started_queue.put( + (session, "assistant", language)) + if not recorder.start(): + if widget: + widget.hide(immediate=True) + if hotkey_listener: + hotkey_listener.cancel_assistant_start() + return if tray: tray.set_recording(True) - if widget: - widget.show_assistant() - widget.set_expression("listening") _start_timeout("assistant") - log.info("Recording started (assistant).") + log.info("Microphone starting (assistant).") def _on_assist_release(): + global _recording_session + _recording_session += 1 _cancel_timeout("assistant") audio = recorder.stop() duration = time.monotonic() - _rec_start @@ -223,7 +269,7 @@ def _on_assist_release(): _assistant_queue.put(audio) else: if widget: - widget.hide() + widget.hide(immediate=True) # ── Pipeline workers ────────────────────────────────────────────────────── @@ -236,12 +282,13 @@ def _dictation_worker(): break try: log.info("Transcribing (dictation)...") - text = transcriber.transcribe(item) + text, detected_language, _ = transcriber.transcribe_with_info(item) if text: log.debug("Raw: %r", text) text = apply_replacements(text) log.info("Transcribed: %r", text) inject(text) + _maybe_offer_detected_language(detected_language) else: log.info("No speech detected.") except Exception as exc: @@ -251,6 +298,61 @@ def _dictation_worker(): widget.hide() +def _maybe_offer_detected_language(detected_language: str | None): + global _language_prompt_pending, _last_detected_language + if config.WHISPER_LANGUAGE is not None: + return + detected_language = (detected_language or "").strip().lower() + if detected_language not in SUPPORTED_LANGUAGE_CODES: + if detected_language: + log.warning("Ignoring unsupported detected language: %s", + detected_language) + return + _last_detected_language = detected_language + if db.get_setting("auto_language_prompt_answered", "0") == "1": + return + if _language_prompt_pending: + return + if not widget: + return + + _language_prompt_pending = True + language_label = language_display_name(detected_language) + + def accept(): + global _language_prompt_pending + try: + db.save_settings({ + "whisper_language": detected_language, + "auto_language_prompt_answered": "1", + }) + config.WHISPER_LANGUAGE = detected_language + log.info("Recognition language saved from first detection: %s", + detected_language) + except Exception as exc: + log.error("Could not save recognition language: %s", exc) + finally: + _language_prompt_pending = False + + def decline(): + global _language_prompt_pending + try: + db.save_setting("auto_language_prompt_answered", "1") + log.info("Recognition language remains on auto detection.") + except Exception as exc: + log.error("Could not save Auto-language preference: %s", exc) + finally: + _language_prompt_pending = False + + widget.show_language_prompt( + locales.get("language_prompt_question", language=language_label), + locales.get("language_prompt_accept", language=language_label), + locales.get("language_prompt_decline"), + accept, + decline, + ) + + def _parse_delete_token(result: str): m = _DELETE_CONFIRM_TOKEN.match(result or "") if not m: @@ -420,7 +522,7 @@ def _assistant_worker(): break try: log.info("Transcribing (assistant)...") - text = transcriber.transcribe(item) + text, detected_language, _ = transcriber.transcribe_with_info(item) if not text: log.info("No speech detected.") if widget: @@ -428,6 +530,7 @@ def _assistant_worker(): continue log.info("Assistant heard: %r", text) + _maybe_offer_detected_language(detected_language) result = _handle_pending_delete_confirmation(text) if result is None: if not assistant.ping_ollama(): @@ -523,7 +626,45 @@ def _show_settings(): root.after(0, lambda: settings_win.show()) +def _poll_recording_started(): + """Apply first-audio UI updates from the Tk main thread.""" + if not root: + return + while True: + try: + session, mode, language = _recording_started_queue.get_nowait() + except queue.Empty: + break + if session != _recording_session or not recorder.recording or not widget: + continue + def should_show(session=session): + return session == _recording_session and recorder.recording + if mode == "assistant": + widget.show_assistant(language, should_show) + else: + widget.show_recording(language, should_show) + root.after(20, _poll_recording_started) + + +def _poll_shutdown(): + """Destroy Tk from its owning thread after a tray-requested shutdown.""" + if not root: + return + if not _shutdown_event.is_set(): + root.after(20, _poll_shutdown) + return + try: + if notes_win and notes_win._win and notes_win._win.winfo_exists(): + notes_win._win.withdraw() + if settings_win and settings_win._win and settings_win._win.winfo_exists(): + settings_win._win.withdraw() + except Exception: + pass + root.after(50, _destroy_root) + + def _quit(): + _shutdown_event.set() log.info("Quitting...") _cancel_timeout("dictation") _cancel_timeout("assistant") @@ -542,22 +683,9 @@ def _quit(): except Exception: pass try: - recorder.stop() + recorder.close() except Exception: pass - # Hide child windows immediately, then destroy root after event queue drains - if root: - try: - if notes_win and notes_win._win and notes_win._win.winfo_exists(): - notes_win._win.withdraw() - if settings_win and settings_win._win and settings_win._win.winfo_exists(): - settings_win._win.withdraw() - except Exception: - pass - try: - root.after(50, _destroy_root) - except Exception: - pass log.info("Shutdown complete.") @@ -633,21 +761,52 @@ def _finish_startup(): ) return # tray stays alive so the user can read the toast and quit + if _shutdown_event.is_set(): + return + + # Opening the stream at startup avoids device enumeration on first use. + # It remains stopped while idle so Windows shows no microphone indicator. + recorder.prepare() + if _shutdown_event.is_set(): + recorder.close() + return + scheduler = ReminderScheduler() scheduler.start() + if _shutdown_event.is_set(): + scheduler.stop() + recorder.close() + return t1 = threading.Thread(target=_dictation_worker, daemon=True) t1.start() t2 = threading.Thread(target=_assistant_worker, daemon=True) t2.start() + if _shutdown_event.is_set(): + _pipeline_queue.put(_STOP) + _assistant_queue.put(_STOP) + scheduler.stop() + recorder.close() + return + hotkey_listener = HotkeyListener( on_press_cb=_on_hotkey_press, on_release_cb=_on_hotkey_release, on_assist_press_cb=_on_assist_press, on_assist_release_cb=_on_assist_release, ) + if _shutdown_event.is_set(): + hotkey_listener.stop() + scheduler.stop() + recorder.close() + return hotkey_listener.start() + if _shutdown_event.is_set(): + hotkey_listener.stop() + scheduler.stop() + recorder.close() + return dict_key = key_display_name(config.HOTKEY) # Wording must match the configured recording mode: "hold" is wrong @@ -689,6 +848,8 @@ def main(): recorder.on_level = lambda rms: widget.update_level(min(1.0, rms * 8)) recorder.on_mic_error = lambda msg: widget.show_message(msg, 4000) + root.after(20, _poll_recording_started) + root.after(20, _poll_shutdown) tray = TrayIcon(on_quit=_quit, on_show_notes=_show_notes, on_show_settings=_show_settings) diff --git a/recognition_languages.py b/recognition_languages.py new file mode 100644 index 0000000..a3efbc0 --- /dev/null +++ b/recognition_languages.py @@ -0,0 +1,30 @@ +"""Whisper recognition-language codes and display names.""" + +import ctypes + +from faster_whisper.tokenizer import _LANGUAGE_CODES + + +SUPPORTED_LANGUAGE_CODES = tuple(_LANGUAGE_CODES) +_LOCALE_SENGLISHLANGUAGENAME = 0x1001 + + +def language_display_name(code: str) -> str: + """Return a readable Windows language name with the Whisper code.""" + normalized = (code or "").strip().lower() + if not normalized: + return "" + try: + buffer = ctypes.create_unicode_buffer(128) + length = ctypes.windll.kernel32.GetLocaleInfoEx( + normalized, + _LOCALE_SENGLISHLANGUAGENAME, + buffer, + len(buffer), + ) + name = buffer.value.strip() if length else "" + if name and not name.startswith("Unknown "): + return f"{name} ({normalized.upper()})" + except Exception: + pass + return normalized.upper() diff --git a/recorder.py b/recorder.py index 8939c5b..4ff476d 100644 --- a/recorder.py +++ b/recorder.py @@ -1,5 +1,7 @@ import numpy as np import sounddevice as sd +import threading +from time import perf_counter import config from logger import log @@ -7,14 +9,14 @@ def _resolve_device(name: str | None) -> int | None: """Resolve a device name to a WASAPI device index at call time. - Returns None (system default) if name is None or not found. - Prefers WASAPI host API for reliable Windows audio. + Returns None (PortAudio's system default) if name is None or not found. + Prefers WASAPI host API for explicitly selected microphones. Re-initializes PortAudio to get fresh device indices. """ if not name: return None try: - # Re-init PortAudio to get current indices + # Re-init PortAudio to get current indices after device hot-plug. sd._terminate() sd._initialize() @@ -63,92 +65,178 @@ class Recorder: def __init__(self): self._frames = [] self._stream = None + self._stream_device_name = None + self._sample_rate = config.SAMPLE_RATE + self._lock = threading.RLock() + self._start_requested_at = None + self._first_frame_pending = False self.recording = False + self.on_started = None # optional callback() after first audio block self.on_level = None # optional callback(rms: float) set by main self.on_mic_error = None # optional callback(msg: str) set by main def _callback(self, indata, frames, time, status): if self.recording: self._frames.append(indata.copy()) + if self._first_frame_pending: + self._first_frame_pending = False + if self._start_requested_at is not None: + log.info("First microphone audio received after %.0fms.", + (perf_counter() - self._start_requested_at) * 1000) + if self.on_started is not None: + try: + self.on_started() + except Exception as exc: + log.warning("Microphone-start notification failed: %s", + exc) if self.on_level is not None: rms = float(np.sqrt(np.mean(indata ** 2))) self.on_level(rms) - def start(self): - if self.recording: + def _close_stream(self): + stream, self._stream = self._stream, None + self._stream_device_name = None + if stream is None: return - self._frames = [] - self._sample_rate = config.SAMPLE_RATE - self.recording = True try: - device_name = getattr(config, "MIC_DEVICE_NAME", None) - device_idx = _resolve_device(device_name) - log.info("Opening mic: name=%s resolved_idx=%s", device_name, device_idx) - - # Always try 16000 Hz first (what Whisper expects). - # Only fall back to device native rate if 16kHz is not supported. - sample_rate = config.SAMPLE_RATE + if stream.active: + stream.stop() + except Exception: + pass + try: + stream.close() + except Exception: + pass + + def _open_stream(self, device_name): + device_idx = _resolve_device(device_name) + log.info("Opening mic: name=%s resolved_idx=%s", device_name, device_idx) + + sample_rates = [config.SAMPLE_RATE] + if device_idx is not None: + dev_info = sd.query_devices(device_idx) + native_rate = int(dev_info.get("default_samplerate", 48000)) + if native_rate not in sample_rates: + sample_rates.append(native_rate) + elif 48000 not in sample_rates: + sample_rates.append(48000) + + last_error = None + for sample_rate in sample_rates: try: - self._stream = sd.InputStream( + stream = sd.InputStream( samplerate=sample_rate, channels=1, dtype="float32", device=device_idx, callback=self._callback, + latency="low", ) - self._stream.start() + self._stream = stream + self._stream_device_name = device_name self._sample_rate = sample_rate + if sample_rate != config.SAMPLE_RATE: + log.info( + "Using device native sample rate: %d Hz " + "(will resample to %d)", + sample_rate, config.SAMPLE_RATE) return - except (sd.PortAudioError, OSError): - # 16kHz not supported by this device, try native rate - log.info("Device does not support %d Hz, trying native rate", sample_rate) - - # Fall back to device default sample rate + resample later - if device_idx is not None: - dev_info = sd.query_devices(device_idx) - sample_rate = int(dev_info.get("default_samplerate", 48000)) - else: - sample_rate = 48000 - log.info("Using device native sample rate: %d Hz (will resample to %d)", - sample_rate, config.SAMPLE_RATE) - - self._stream = sd.InputStream( - samplerate=sample_rate, - channels=1, - dtype="float32", - device=device_idx, - callback=self._callback, - ) - self._stream.start() - self._sample_rate = sample_rate - except (sd.PortAudioError, OSError, Exception) as exc: - log.error("Failed to open microphone: %s", exc) - self.recording = False - self._stream = None - if self.on_mic_error: - self.on_mic_error("🎤 No microphone detected") + except (sd.PortAudioError, OSError) as exc: + last_error = exc + log.info("Could not open device=%s at %d Hz: %s", + device_idx, sample_rate, exc) + + raise last_error or OSError("No microphone input device available") + + def prepare(self) -> bool: + """Open the configured microphone once, leaving its stream stopped.""" + with self._lock: + if self.recording: + return True + device_name = getattr(config, "MIC_DEVICE_NAME", None) + if self._stream is not None and self._stream_device_name == device_name: + return True + self._close_stream() + started = perf_counter() + try: + self._open_stream(device_name) + log.info("Microphone prepared in %.0fms.", + (perf_counter() - started) * 1000) + return True + except Exception as exc: + log.error("Failed to prepare microphone: %s", exc) + self._close_stream() + if self.on_mic_error: + self.on_mic_error("🎤 No microphone detected") + return False + + def start(self): + with self._lock: + if self.recording: + return True + self._frames = [] + if not self.prepare(): + return False + self._start_requested_at = perf_counter() + self._first_frame_pending = True + self.recording = True + try: + self._stream.start() + log.info("Microphone start returned after %.0fms.", + (perf_counter() - self._start_requested_at) * 1000) + return True + except Exception as exc: + log.warning("Prepared microphone could not start, reopening: %s", + exc) + self.recording = False + self._close_stream() + if not self.prepare(): + return False + try: + self._start_requested_at = perf_counter() + self._first_frame_pending = True + self.recording = True + self._stream.start() + return True + except Exception as retry_exc: + log.error("Failed to start microphone: %s", retry_exc) + self.recording = False + self._close_stream() + if self.on_mic_error: + self.on_mic_error("🎤 No microphone detected") + return False def stop(self): - if not self.recording: - return None - self.recording = False - if self._stream is not None: - self._stream.stop() - self._stream.close() - self._stream = None - if not self._frames: - return None - audio = np.concatenate(self._frames, axis=0).flatten() - - # Resample to 16kHz if recorded at a different rate - target_rate = config.SAMPLE_RATE - if self._sample_rate != target_rate: - # Simple linear interpolation resampling - duration = len(audio) / self._sample_rate - target_len = int(duration * target_rate) - indices = np.linspace(0, len(audio) - 1, target_len) - audio = np.interp(indices, np.arange(len(audio)), audio).astype(np.float32) - log.info("Resampled audio from %d Hz to %d Hz (%d samples)", - self._sample_rate, target_rate, target_len) - - return audio + with self._lock: + if not self.recording: + return None + self.recording = False + if self._stream is not None: + self._stream.stop() + if not self._frames: + return None + audio = np.concatenate(self._frames, axis=0).flatten() + + # Resample to 16kHz if recorded at a different rate + target_rate = config.SAMPLE_RATE + if self._sample_rate != target_rate: + # Simple linear interpolation resampling + duration = len(audio) / self._sample_rate + target_len = int(duration * target_rate) + indices = np.linspace(0, len(audio) - 1, target_len) + audio = np.interp(indices, np.arange(len(audio)), audio).astype(np.float32) + log.info("Resampled audio from %d Hz to %d Hz (%d samples)", + self._sample_rate, target_rate, target_len) + + return audio + + def close(self): + """Stop recording and release the prepared microphone stream.""" + with self._lock: + if self.recording: + self.recording = False + try: + self._stream.stop() + except Exception: + pass + self._close_stream() diff --git a/settings_window.py b/settings_window.py index 21c72fb..93bc685 100644 --- a/settings_window.py +++ b/settings_window.py @@ -22,7 +22,7 @@ import database as db import locales from brand import make_title_bar_image -from hotkey_util import (key_to_str, str_to_key, key_display_name, is_blocked, +from hotkey_util import (key_to_str, key_display_name, is_blocked, canonical_modifier, hotkeys_equal) import theme as T @@ -36,7 +36,7 @@ _LANGUAGES = [("en", "English"), ("it", "Italiano"), ("de", "Deutsch")] # Recognition (Whisper) language options — value None means auto-detect. -_RECOGNITION_LANGS = [(None, "Auto"), ("en", "en"), ("it", "it"), ("de", "de")] +_RECOGNITION_LANGS = [(None, "Auto"), ("en", "EN"), ("it", "IT"), ("de", "DE")] def _fetch_ollama_models() -> list[str]: @@ -625,12 +625,18 @@ def _sync_ui(self): # Recognition language if self._recog_dropdown: current = config.WHISPER_LANGUAGE + matched = False for code, label in _RECOGNITION_LANGS: if code == current: display = (locales.get("setting_recognition_auto") if code is None else label) self._recog_dropdown.set(display) + matched = True break + if not matched and current: + # A language accepted from Auto detection may intentionally + # fall outside the compact Auto/EN/IT/DE dropdown list. + self._recog_dropdown.set(current.upper()) # Language if self._lang_dropdown: diff --git a/test_hotkey.py b/test_hotkey.py index 7d98117..3d3e4ad 100644 --- a/test_hotkey.py +++ b/test_hotkey.py @@ -4,10 +4,13 @@ detection" for the fork's combo-hotkey feature. """ +import threading import unittest +from unittest.mock import patch from pynput.keyboard import Key, KeyCode +from hotkey import HotkeyListener from hotkey_util import (key_to_str, str_to_key, hotkeys_equal, keys_match, canonical_modifier, is_blocked) @@ -97,5 +100,80 @@ def test_combo_never_blocked(self): )) +class TestFailedStartReset(unittest.TestCase): + def test_cancel_dictation_start_resets_toggle_and_hold_state(self): + listener = HotkeyListener(lambda: None, lambda: None) + listener._dict_recording = True + listener._dict_pressed = True + + listener.cancel_dictation_start() + + self.assertFalse(listener._dict_recording) + self.assertFalse(listener._dict_pressed) + + def test_cancel_assistant_start_resets_toggle_and_hold_state(self): + listener = HotkeyListener(lambda: None, lambda: None) + listener._assist_recording = True + listener._assist_pressed = True + + listener.cancel_assistant_start() + + self.assertFalse(listener._assist_recording) + self.assertFalse(listener._assist_pressed) + + +class TestCallbackDispatch(unittest.TestCase): + def test_slow_start_does_not_prevent_release_from_being_queued(self): + start_entered = threading.Event() + allow_start_to_finish = threading.Event() + release_called = threading.Event() + + def slow_start(): + start_entered.set() + allow_start_to_finish.wait(timeout=1) + + listener = HotkeyListener(slow_start, release_called.set) + listener._safe_call(slow_start, "start") + self.assertTrue(start_entered.wait(timeout=0.2)) + + listener._safe_call(release_called.set, "release") + self.assertFalse(release_called.is_set()) + allow_start_to_finish.set() + + self.assertTrue(release_called.wait(timeout=0.2)) + listener.stop() + + def test_stop_waits_for_running_callback(self): + callback_entered = threading.Event() + allow_callback_to_finish = threading.Event() + + def blocked_callback(): + callback_entered.set() + allow_callback_to_finish.wait(timeout=1) + + listener = HotkeyListener(blocked_callback, lambda: None) + listener._safe_call(blocked_callback, "blocked") + self.assertTrue(callback_entered.wait(timeout=0.2)) + + stopper = threading.Thread(target=listener.stop) + stopper.start() + self.assertTrue(stopper.is_alive()) + allow_callback_to_finish.set() + stopper.join(timeout=0.2) + + self.assertFalse(stopper.is_alive()) + self.assertFalse(listener._callback_worker.is_alive()) + + @patch("hotkey.keyboard.Listener") + def test_start_after_stop_does_not_create_keyboard_listener( + self, keyboard_listener): + listener = HotkeyListener(lambda: None, lambda: None) + listener.stop() + + listener.start() + + keyboard_listener.assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/test_language_prompt.py b/test_language_prompt.py new file mode 100644 index 0000000..05d2c06 --- /dev/null +++ b/test_language_prompt.py @@ -0,0 +1,81 @@ +import unittest +from unittest.mock import MagicMock, patch + +import config +import main + + +class LanguagePromptTests(unittest.TestCase): + def setUp(self): + self.original_language = config.WHISPER_LANGUAGE + self.original_widget = main.widget + self.original_pending = main._language_prompt_pending + self.original_detected = main._last_detected_language + config.WHISPER_LANGUAGE = None + main.widget = MagicMock() + main._language_prompt_pending = False + main._last_detected_language = None + + def tearDown(self): + config.WHISPER_LANGUAGE = self.original_language + main.widget = self.original_widget + main._language_prompt_pending = self.original_pending + main._last_detected_language = self.original_detected + + @patch.object(main.db, "get_setting", return_value="0") + def test_any_detected_language_can_be_offered(self, _get_setting): + main._maybe_offer_detected_language("lt") + + self.assertEqual(main._last_detected_language, "lt") + args = main.widget.show_language_prompt.call_args.args + self.assertIn("Lithuanian (LT)", args[0]) + self.assertIn("Lithuanian (LT)", args[1]) + + @patch.object(main.db, "get_setting", return_value="0") + def test_unsupported_detected_language_is_ignored(self, _get_setting): + main._maybe_offer_detected_language("not-a-language") + + main.widget.show_language_prompt.assert_not_called() + self.assertIsNone(main._last_detected_language) + + @patch.object(main.db, "save_settings") + @patch.object(main.db, "get_setting", return_value="0") + def test_accept_saves_detected_language(self, _get_setting, save_settings): + main._maybe_offer_detected_language("ja") + accept = main.widget.show_language_prompt.call_args.args[3] + + accept() + + self.assertEqual(config.WHISPER_LANGUAGE, "ja") + save_settings.assert_called_once_with({ + "whisper_language": "ja", + "auto_language_prompt_answered": "1", + }) + + @patch.object(main.db, "save_setting") + @patch.object(main.db, "get_setting", return_value="0") + def test_decline_keeps_auto_and_suppresses_future_prompt( + self, _get_setting, save_setting): + main._maybe_offer_detected_language("de") + decline = main.widget.show_language_prompt.call_args.args[4] + + decline() + + self.assertIsNone(config.WHISPER_LANGUAGE) + save_setting.assert_called_with("auto_language_prompt_answered", "1") + + @patch.object(main.db, "save_settings", side_effect=OSError("disk full")) + @patch.object(main.db, "get_setting", return_value="0") + def test_save_failure_clears_pending_without_fixing_language( + self, _get_setting, _save_settings): + main._maybe_offer_detected_language("lt") + accept = main.widget.show_language_prompt.call_args.args[3] + + accept() + + self.assertIsNone(config.WHISPER_LANGUAGE) + self.assertFalse(main._language_prompt_pending) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_recognition_languages.py b/test_recognition_languages.py new file mode 100644 index 0000000..497daef --- /dev/null +++ b/test_recognition_languages.py @@ -0,0 +1,23 @@ +import unittest + +from recognition_languages import ( + SUPPORTED_LANGUAGE_CODES, + language_display_name, +) + + +class RecognitionLanguageTests(unittest.TestCase): + def test_whisper_language_list_is_not_artificially_limited(self): + self.assertGreaterEqual(len(SUPPORTED_LANGUAGE_CODES), 90) + self.assertIn("lt", SUPPORTED_LANGUAGE_CODES) + self.assertIn("ja", SUPPORTED_LANGUAGE_CODES) + + def test_windows_resolves_common_language_name(self): + self.assertEqual(language_display_name("lt"), "Lithuanian (LT)") + + def test_unknown_language_falls_back_to_code(self): + self.assertEqual(language_display_name("not-a-language"), "NOT-A-LANGUAGE") + + +if __name__ == "__main__": + unittest.main() diff --git a/test_recorder.py b/test_recorder.py new file mode 100644 index 0000000..9ba42fe --- /dev/null +++ b/test_recorder.py @@ -0,0 +1,133 @@ +import unittest +from unittest.mock import MagicMock, patch + +import numpy as np + +import config +from recorder import Recorder, _resolve_device + + +class RecorderTests(unittest.TestCase): + def setUp(self): + self.original_device = config.MIC_DEVICE_NAME + config.MIC_DEVICE_NAME = None + + def tearDown(self): + config.MIC_DEVICE_NAME = self.original_device + + @patch("recorder._resolve_device", return_value=None) + @patch("recorder.sd.InputStream") + def test_reuses_stopped_stream_between_recordings( + self, input_stream, _resolve): + stream = MagicMock() + stream.active = False + input_stream.return_value = stream + recorder = Recorder() + + self.assertTrue(recorder.prepare()) + recorder.start() + recorder.stop() + recorder.start() + recorder.stop() + + input_stream.assert_called_once() + self.assertEqual(stream.start.call_count, 2) + self.assertEqual(stream.stop.call_count, 2) + stream.close.assert_not_called() + + recorder.close() + stream.close.assert_called_once() + + @patch("recorder._resolve_device", return_value=None) + @patch("recorder.sd.InputStream") + def test_reopens_stream_after_microphone_setting_changes( + self, input_stream, _resolve): + first, second = MagicMock(), MagicMock() + first.active = False + second.active = False + input_stream.side_effect = [first, second] + recorder = Recorder() + + self.assertTrue(recorder.prepare()) + config.MIC_DEVICE_NAME = "New microphone" + recorder.start() + + self.assertEqual(input_stream.call_count, 2) + first.close.assert_called_once() + second.start.assert_called_once() + + @patch("recorder._resolve_device", return_value=None) + @patch("recorder.sd.InputStream") + def test_prepare_leaves_stream_stopped(self, input_stream, _resolve): + stream = MagicMock() + stream.active = False + input_stream.return_value = stream + recorder = Recorder() + + self.assertTrue(recorder.prepare()) + + stream.start.assert_not_called() + + @patch("recorder._resolve_device", return_value=None) + @patch("recorder.sd.InputStream") + def test_stop_returns_audio_without_closing_stream( + self, input_stream, _resolve): + stream = MagicMock() + stream.active = False + input_stream.return_value = stream + recorder = Recorder() + recorder.start() + recorder._frames = [ + np.array([[0.1], [0.2]], dtype=np.float32), + np.array([[0.3]], dtype=np.float32), + ] + + audio = recorder.stop() + + np.testing.assert_allclose(audio, [0.1, 0.2, 0.3]) + stream.stop.assert_called_once() + stream.close.assert_not_called() + + @patch("recorder._resolve_device", return_value=None) + @patch("recorder.sd.InputStream") + def test_first_audio_notifies_started_callback( + self, input_stream, _resolve): + stream = MagicMock() + stream.active = False + input_stream.return_value = stream + recorder = Recorder() + recorder.on_started = MagicMock() + recorder.start() + + recorder._callback( + np.array([[0.1]], dtype=np.float32), 1, None, None) + recorder._callback( + np.array([[0.2]], dtype=np.float32), 1, None, None) + + recorder.on_started.assert_called_once() + + @patch("recorder._resolve_device", return_value=None) + @patch("recorder.sd.InputStream") + def test_first_audio_is_saved_before_started_callback( + self, input_stream, _resolve): + stream = MagicMock() + stream.active = False + input_stream.return_value = stream + recorder = Recorder() + frame_counts = [] + recorder.on_started = lambda: frame_counts.append(len(recorder._frames)) + recorder.start() + + recorder._callback( + np.array([[0.1]], dtype=np.float32), 1, None, None) + + self.assertEqual(frame_counts, [1]) + + +class ResolveDeviceTests(unittest.TestCase): + def test_system_default_uses_portaudio_default(self): + self.assertIsNone(_resolve_device(None)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_transcriber.py b/test_transcriber.py new file mode 100644 index 0000000..852313c --- /dev/null +++ b/test_transcriber.py @@ -0,0 +1,66 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import numpy as np + +from transcriber import Transcriber + + +class TranscriberInfoTests(unittest.TestCase): + @patch("transcriber.log") + @patch("transcriber.config.WHISPER_LANGUAGE", None) + @patch("transcriber.get_initial_prompt", return_value="") + def test_transcribe_with_info_returns_detected_language( + self, _prompt, mocked_log): + transcriber = Transcriber.__new__(Transcriber) + segment = SimpleNamespace(text=" hello ") + info = SimpleNamespace(language="en", language_probability=0.92) + transcriber._model = MagicMock() + transcriber._model.transcribe.return_value = ([segment], info) + + text, language, probability = transcriber.transcribe_with_info( + np.zeros(100, dtype=np.float32)) + + self.assertEqual(text, "hello") + self.assertEqual(language, "en") + self.assertEqual(probability, 0.92) + mocked_log.info.assert_any_call( + "Language detection: enabled (Auto).") + + @patch("transcriber.log") + @patch("transcriber.config.WHISPER_LANGUAGE", "lt") + @patch("transcriber.get_initial_prompt", return_value="") + def test_fixed_language_is_logged_and_detection_is_skipped( + self, _prompt, mocked_log): + transcriber = Transcriber.__new__(Transcriber) + transcriber._model = MagicMock() + transcriber._model.transcribe.return_value = ( + [SimpleNamespace(text=" labas ")], + SimpleNamespace(language="lt", language_probability=1.0), + ) + + text, language, _ = transcriber.transcribe_with_info( + np.zeros(100, dtype=np.float32)) + + self.assertEqual((text, language), ("labas", "lt")) + self.assertEqual( + transcriber._model.transcribe.call_args.kwargs["language"], "lt") + mocked_log.info.assert_any_call( + "Language detection: disabled; using fixed language: %s.", "lt") + self.assertFalse(any( + call.args and call.args[0].startswith("Whisper detected language") + for call in mocked_log.info.call_args_list + )) + + @patch.object(Transcriber, "transcribe_with_info") + def test_transcribe_keeps_string_api(self, transcribe_with_info): + transcribe_with_info.return_value = ("hello", "en", 0.92) + transcriber = Transcriber.__new__(Transcriber) + + self.assertEqual( + transcriber.transcribe(np.zeros(10, dtype=np.float32)), "hello") + + +if __name__ == "__main__": + unittest.main() diff --git a/test_widget.py b/test_widget.py new file mode 100644 index 0000000..3d737ff --- /dev/null +++ b/test_widget.py @@ -0,0 +1,75 @@ +import threading +import unittest +from unittest.mock import patch + +from widget import RecordingWidget + + +class _FakeRoot: + def __init__(self): + self.after_calls = [] + + def after(self, delay, callback): + self.after_calls.append((delay, callback)) + + +class WidgetThreadingTests(unittest.TestCase): + def test_ui_request_from_worker_does_not_call_tk_or_block(self): + root = _FakeRoot() + widget = RecordingWidget(root) + shown = [] + widget._show = shown.append + root.after_calls.clear() + + worker = threading.Thread(target=widget.show_processing) + worker.start() + worker.join(timeout=0.2) + + self.assertFalse(worker.is_alive()) + self.assertEqual(root.after_calls, []) + self.assertEqual(shown, []) + + widget._drain_ui_queue() + + self.assertEqual(shown, [widget.PROCESSING]) + self.assertEqual(root.after_calls[0][0], 10) + + @patch("widget.log") + def test_bad_ui_operation_does_not_stop_dispatcher(self, mocked_log): + root = _FakeRoot() + widget = RecordingWidget(root) + completed = [] + root.after_calls.clear() + + def fail(): + raise ValueError("bad update") + + widget._dispatch(fail) + widget._dispatch(lambda: completed.append(True)) + widget._drain_ui_queue() + + self.assertEqual(completed, [True]) + self.assertEqual(root.after_calls[0][0], 10) + mocked_log.error.assert_called_once() + + def test_stale_recording_status_is_discarded(self): + root = _FakeRoot() + widget = RecordingWidget(root) + shown = [] + widget._show = shown.append + + widget.show_recording("en", should_show=lambda: False) + widget._drain_ui_queue() + + self.assertEqual(shown, []) + + def test_immediate_hide_bypasses_fade(self): + root = _FakeRoot() + widget = RecordingWidget(root) + hidden = [] + widget._hide_immediately = lambda: hidden.append(True) + + widget.hide(immediate=True) + widget._drain_ui_queue() + + self.assertEqual(hidden, [True]) diff --git a/transcriber.py b/transcriber.py index 04361db..b751a0a 100644 --- a/transcriber.py +++ b/transcriber.py @@ -15,21 +15,32 @@ def __init__(self): ) log.info("Model loaded.") - def transcribe(self, audio_np: np.ndarray) -> str: + def transcribe_with_info( + self, audio_np: np.ndarray) -> tuple[str, str | None, float | None]: + language = config.WHISPER_LANGUAGE + if language is None: + log.info("Language detection: enabled (Auto).") + else: + log.info("Language detection: disabled; using fixed language: %s.", + language) segments, info = self._model.transcribe( audio_np, - language=config.WHISPER_LANGUAGE, + language=language, beam_size=5, vad_filter=True, initial_prompt=get_initial_prompt(), ) detected = getattr(info, "language", None) probability = getattr(info, "language_probability", None) - if detected: + if detected and language is None: if probability is not None: log.info("Whisper detected language: %s (p=%.2f)", detected, probability) else: log.info("Whisper detected language: %s", detected) text = " ".join(seg.text.strip() for seg in segments) - return text.strip() + return text.strip(), detected, probability + + def transcribe(self, audio_np: np.ndarray) -> str: + text, _, _ = self.transcribe_with_info(audio_np) + return text diff --git a/widget.py b/widget.py index 04bc15d..b95df14 100644 --- a/widget.py +++ b/widget.py @@ -15,6 +15,7 @@ import ctypes import math +import queue import threading import tkinter as tk import tkinter.font as tkfont @@ -31,6 +32,7 @@ # Widget dimensions — pill shape _W, _H = 220, 44 +_RECORDING_W = 280 _RADIUS = _H // 2 # full pill (borderRadius: height/2 in JSX) # ── avatar / eye area ─────────────────────────────────────────────────── @@ -196,6 +198,7 @@ def __init__(self, root: tk.Tk): self._bar_ids = [] self._text_id = None self._label_id = None # status label (JSX-style) + self._language_id = None # contrasting recognition-language badge self._sep_ids = [] # separator lines self._after_anim = None self._after_fade = None @@ -217,35 +220,80 @@ def __init__(self, root: tk.Tk): # Current pill width — grows to fit long messages self._width = _W self._msg_font = None + self._language_label = None + self._language_prompt_win = None + # Tk calls must only be made by the thread running mainloop(). + # Cross-thread ``after`` calls can block the hotkey listener and delay + # the recording-to-transcription handoff until another key event. + self._ui_queue = queue.SimpleQueue() + self._root.after(10, self._drain_ui_queue) + + def _dispatch(self, func): + """Queue a UI operation without blocking the calling thread.""" + self._ui_queue.put(func) + + def _drain_ui_queue(self): + """Run queued UI operations on the Tk main thread.""" + try: + while True: + try: + func = self._ui_queue.get_nowait() + except queue.Empty: + break + try: + func() + except Exception as exc: + log.error("Widget UI operation failed: %s", exc) + finally: + try: + self._root.after(10, self._drain_ui_queue) + except tk.TclError: + pass # ── public API ──────────────────────────────────────────────────────── - def show_recording(self): - self._source_mode = "dictation" - self._root.after(0, lambda: self._show(self.RECORDING)) + def show_recording(self, language: str | None = None, should_show=None): + def show(): + if should_show is not None and not should_show(): + return + self._source_mode = "dictation" + self._language_label = (language or "AUTO").upper() + self._show(self.RECORDING) + self._dispatch(show) def show_processing(self): # _source_mode intentionally not reset — carries through from the # preceding show_recording() or show_assistant() call so the # processing phase keeps the same colour theme. - self._root.after(0, lambda: self._show(self.PROCESSING)) + self._dispatch(lambda: self._show(self.PROCESSING)) + + def show_assistant(self, language: str | None = None, should_show=None): + def show(): + if should_show is not None and not should_show(): + return + self._source_mode = "assistant" + self._language_label = (language or "AUTO").upper() + self._show(self.ASSISTANT) + self._dispatch(show) - def show_assistant(self): - self._source_mode = "assistant" - self._root.after(0, lambda: self._show(self.ASSISTANT)) + def show_language_prompt(self, question: str, accept_text: str, + decline_text: str, on_accept, on_decline): + self._dispatch(lambda: self._show_language_prompt( + question, accept_text, decline_text, on_accept, on_decline)) def show_message(self, text: str, duration_ms: int = 3000): - self._root.after(0, lambda: self._show_msg(text, duration_ms)) + self._dispatch(lambda: self._show_msg(text, duration_ms)) def show_status(self, text: str, expression: str = "loading"): """Persistent status message (no auto-hide) with animated eyes. Used for long-running startup states such as the Whisper model download. Call hide() to dismiss.""" - self._root.after(0, lambda: self._show_status(text, expression)) + self._dispatch(lambda: self._show_status(text, expression)) - def hide(self): - self._root.after(0, self._start_fade_out) + def hide(self, immediate: bool = False): + self._dispatch( + self._hide_immediately if immediate else self._start_fade_out) def update_level(self, level: float): with self._level_lock: @@ -255,7 +303,7 @@ def set_expression(self, expr: str): """Set bot eye expression: idle, listening, thinking, coding, happy, error, alert, surprised, wink, sleep, sad, love, loading""" if expr in _STATE_STYLE: - self._expression = expr + self._dispatch(lambda: setattr(self, "_expression", expr)) # ── dynamic width (long messages) ───────────────────────────────────── @@ -269,6 +317,8 @@ def _set_width(self, width: int): self._win.geometry(f"{width}x{_H}+{(sw - width) // 2}+{sh - _H - 80}") self._canvas.config(width=width) self._canvas.coords(self._text_id, (_SEP_X + width - 10) // 2, _H // 2) + if self._language_id is not None: + self._canvas.coords(self._language_id, width - 16, _H // 2) self._update_pill_bg() def _fit_width_to_text(self, text: str): @@ -322,6 +372,13 @@ def _start_fade_out(self): self._cancel_fade() self._fade_step() + def _hide_immediately(self): + """Cancel in-flight fades and fully withdraw the overlay.""" + self._cancel_fade() + self._fading = None + self._set_alpha(_ALPHA_MIN) + self._do_hide() + def _fade_step(self): step = _ALPHA_MAX / _FADE_STEPS if self._fading == "in": @@ -369,7 +426,8 @@ def _show(self, mode: str): if self._win: self._win.deiconify() - self._set_width(_W) # recording modes use the default compact pill + self._set_width( + _RECORDING_W if mode in (self.RECORDING, self.ASSISTANT) else _W) self._mode = mode self._tick = 0 @@ -388,6 +446,12 @@ def _show(self, mode: str): # Update label self._update_label() + if self._language_id: + self._canvas.itemconfig( + self._language_id, + text=self._language_label or "AUTO", + state="normal" if show_bars else "hidden", + ) if self._text_id: self._canvas.itemconfig(self._text_id, state="hidden") @@ -425,6 +489,55 @@ def _do_hide(self): except Exception: pass + def _show_language_prompt(self, question: str, accept_text: str, + decline_text: str, on_accept, on_decline): + if self._language_prompt_win is not None: + try: + if self._language_prompt_win.winfo_exists(): + return + except Exception: + pass + + win = tk.Toplevel(self._root) + self._language_prompt_win = win + win.overrideredirect(True) + win.attributes("-topmost", True) + win.configure(bg="#38bdf8") + width, height = 430, 112 + sw, sh = win.winfo_screenwidth(), win.winfo_screenheight() + win.geometry( + f"{width}x{height}+{(sw - width) // 2}+{sh - height - 126}") + + panel = tk.Frame(win, bg=_BG, padx=14, pady=12) + panel.pack(fill="both", expand=True, padx=1, pady=1) + tk.Label( + panel, text=question, bg=_BG, fg="#e8e8f0", + font=("Segoe UI", 10), anchor="center", + ).pack(fill="x", pady=(0, 10)) + + buttons = tk.Frame(panel, bg=_BG) + buttons.pack() + + def finish(callback): + try: + win.destroy() + finally: + self._language_prompt_win = None + callback() + + tk.Button( + buttons, text=accept_text, command=lambda: finish(on_accept), + bg="#38bdf8", fg="#001018", activebackground="#7dd3fc", + activeforeground="#001018", relief="flat", + font=("Segoe UI", 9, "bold"), padx=14, pady=5, + ).pack(side="left", padx=5) + tk.Button( + buttons, text=decline_text, command=lambda: finish(on_decline), + bg="#1c1c26", fg="#d0d0dc", activebackground="#2a2a38", + activeforeground="#ffffff", relief="flat", + font=("Segoe UI", 9), padx=14, pady=5, + ).pack(side="left", padx=5) + def _show_msg(self, text: str, duration_ms: int): needs_build = (self._win is None) if not needs_build: @@ -446,6 +559,8 @@ def _show_msg(self, text: str, duration_ms: int): self._canvas.itemconfig(bid, state="hidden") if self._label_id: self._canvas.itemconfig(self._label_id, state="hidden") + if self._language_id: + self._canvas.itemconfig(self._language_id, state="hidden") if self._text_id: self._canvas.itemconfig(self._text_id, text=text, state="normal") @@ -485,6 +600,8 @@ def _show_status(self, text: str, expression: str): self._canvas.itemconfig(bid, state="hidden") if self._label_id: self._canvas.itemconfig(self._label_id, state="hidden") + if self._language_id: + self._canvas.itemconfig(self._language_id, state="hidden") if self._text_id: self._canvas.itemconfig(self._text_id, text=text, state="normal") @@ -609,6 +726,13 @@ def _build(self): anchor="w", state="hidden", ) + self._language_id = c.create_text( + _RECORDING_W - 16, _H // 2, + text="", fill="#ffaa00", + font=("Segoe UI", 9, "bold"), + anchor="e", state="hidden", + ) + # ── Waveform bars (JSX-style: 5 bars, only during listening) wave_start_x = _TEXT_X + 90 # after status text mid_y = _H // 2