Add multilingual STT support and config UI/runtime updates

This commit is contained in:
Thales Maciel 2026-02-27 12:38:13 -03:00
parent ed950cb7c4
commit 4a69c3d333
26 changed files with 2207 additions and 465 deletions

728
src/config_ui.py Normal file
View file

@ -0,0 +1,728 @@
from __future__ import annotations
import copy
import logging
import time
from dataclasses import dataclass
from pathlib import Path
import gi
from config import (
Config,
DEFAULT_EXTERNAL_API_BASE_URL,
DEFAULT_EXTERNAL_API_KEY_ENV_VAR,
DEFAULT_EXTERNAL_API_MAX_RETRIES,
DEFAULT_EXTERNAL_API_MODEL,
DEFAULT_EXTERNAL_API_PROVIDER,
DEFAULT_EXTERNAL_API_TIMEOUT_MS,
DEFAULT_LLM_PROVIDER,
DEFAULT_STT_PROVIDER,
)
from constants import DEFAULT_CONFIG_PATH
from languages import COMMON_STT_LANGUAGE_OPTIONS, stt_language_label
from recorder import list_input_devices, resolve_input_device, start_recording, stop_recording
gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, Gtk # type: ignore[import-not-found]
RUNTIME_MODE_MANAGED = "aman_managed"
RUNTIME_MODE_EXPERT = "expert_custom"
@dataclass
class ConfigUiResult:
saved: bool
config: Config | None
closed_reason: str | None = None
def infer_runtime_mode(cfg: Config) -> str:
is_canonical = (
cfg.stt.provider.strip().lower() == DEFAULT_STT_PROVIDER
and cfg.llm.provider.strip().lower() == DEFAULT_LLM_PROVIDER
and not bool(cfg.external_api.enabled)
and not bool(cfg.models.allow_custom_models)
and not cfg.models.whisper_model_path.strip()
and not cfg.models.llm_model_path.strip()
)
return RUNTIME_MODE_MANAGED if is_canonical else RUNTIME_MODE_EXPERT
def apply_canonical_runtime_defaults(cfg: Config) -> None:
cfg.stt.provider = DEFAULT_STT_PROVIDER
cfg.llm.provider = DEFAULT_LLM_PROVIDER
cfg.external_api.enabled = False
cfg.external_api.provider = DEFAULT_EXTERNAL_API_PROVIDER
cfg.external_api.base_url = DEFAULT_EXTERNAL_API_BASE_URL
cfg.external_api.model = DEFAULT_EXTERNAL_API_MODEL
cfg.external_api.timeout_ms = DEFAULT_EXTERNAL_API_TIMEOUT_MS
cfg.external_api.max_retries = DEFAULT_EXTERNAL_API_MAX_RETRIES
cfg.external_api.api_key_env_var = DEFAULT_EXTERNAL_API_KEY_ENV_VAR
cfg.models.allow_custom_models = False
cfg.models.whisper_model_path = ""
cfg.models.llm_model_path = ""
class ConfigWindow:
def __init__(
self,
initial_cfg: Config,
desktop,
*,
required: bool,
config_path: str | Path | None,
) -> None:
self._desktop = desktop
self._config = copy.deepcopy(initial_cfg)
self._required = required
self._config_path = Path(config_path) if config_path else DEFAULT_CONFIG_PATH
self._devices = list_input_devices()
self._device_by_id = {str(device["index"]): device for device in self._devices}
self._row_to_section: dict[Gtk.ListBoxRow, str] = {}
self._runtime_mode = infer_runtime_mode(self._config)
title = "Aman Settings (Required)" if required else "Aman Settings"
self._dialog = Gtk.Dialog(title=title, flags=Gtk.DialogFlags.MODAL)
self._dialog.set_default_size(880, 560)
self._dialog.set_modal(True)
self._dialog.set_keep_above(True)
self._dialog.set_position(Gtk.WindowPosition.CENTER_ALWAYS)
self._dialog.set_type_hint(Gdk.WindowTypeHint.DIALOG)
self._dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
self._apply_button = self._dialog.add_button("Apply", Gtk.ResponseType.APPLY)
self._dialog.set_default_response(Gtk.ResponseType.APPLY)
content = self._dialog.get_content_area()
content.set_border_width(12)
content.set_spacing(10)
if self._required:
banner = Gtk.InfoBar()
banner.set_show_close_button(False)
banner.set_message_type(Gtk.MessageType.WARNING)
banner_label = Gtk.Label(
label="Aman needs saved settings before it can start recording."
)
banner_label.set_xalign(0.0)
banner_label.set_line_wrap(True)
banner.get_content_area().pack_start(banner_label, True, True, 0)
content.pack_start(banner, False, False, 0)
body = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
content.pack_start(body, True, True, 0)
self._navigation = Gtk.ListBox()
self._navigation.set_selection_mode(Gtk.SelectionMode.SINGLE)
self._navigation.set_activate_on_single_click(True)
self._navigation.connect("row-selected", self._on_nav_selected)
nav_scroll = Gtk.ScrolledWindow()
nav_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
nav_scroll.set_min_content_width(210)
nav_scroll.add(self._navigation)
body.pack_start(nav_scroll, False, False, 0)
self._stack = Gtk.Stack()
self._stack.set_hexpand(True)
self._stack.set_vexpand(True)
self._stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
self._stack.set_transition_duration(120)
body.pack_start(self._stack, True, True, 0)
self._general_page = self._build_general_page()
self._audio_page = self._build_audio_page()
self._advanced_page = self._build_advanced_page()
self._help_page = self._build_help_page()
self._about_page = self._build_about_page()
self._add_section("general", "General", self._general_page)
self._add_section("audio", "Audio", self._audio_page)
self._add_section("advanced", "Runtime & Models", self._advanced_page)
self._add_section("help", "Help", self._help_page)
self._add_section("about", "About", self._about_page)
self._initialize_widget_values()
self._validate_hotkey()
first_row = self._navigation.get_row_at_index(0)
if first_row is not None:
self._navigation.select_row(first_row)
def run(self) -> ConfigUiResult:
self._dialog.show_all()
while True:
response = self._dialog.run()
if response == Gtk.ResponseType.APPLY:
if not self._validate_hotkey():
continue
if not self._validate_runtime_settings():
continue
cfg = self._build_result_config()
self._dialog.destroy()
return ConfigUiResult(saved=True, config=cfg, closed_reason="saved")
reason = "cancelled" if response == Gtk.ResponseType.CANCEL else "closed"
self._dialog.destroy()
return ConfigUiResult(saved=False, config=None, closed_reason=reason)
def _add_section(self, name: str, title: str, widget: Gtk.Widget) -> None:
row = Gtk.ListBoxRow()
row_label = Gtk.Label(label=title)
row_label.set_xalign(0.0)
row_label.set_margin_start(10)
row_label.set_margin_end(10)
row_label.set_margin_top(8)
row_label.set_margin_bottom(8)
row.add(row_label)
self._navigation.add(row)
self._row_to_section[row] = name
self._stack.add_titled(widget, name, title)
def _on_nav_selected(self, _listbox, row: Gtk.ListBoxRow | None) -> None:
if row is None:
return
section = self._row_to_section.get(row)
if section:
self._stack.set_visible_child_name(section)
def _build_general_page(self) -> Gtk.Widget:
grid = Gtk.Grid(column_spacing=12, row_spacing=10)
grid.set_margin_start(14)
grid.set_margin_end(14)
grid.set_margin_top(14)
grid.set_margin_bottom(14)
hotkey_label = Gtk.Label(label="Trigger hotkey")
hotkey_label.set_xalign(0.0)
self._hotkey_entry = Gtk.Entry()
self._hotkey_entry.set_placeholder_text("Super+m")
self._hotkey_entry.connect("changed", lambda *_: self._validate_hotkey())
grid.attach(hotkey_label, 0, 0, 1, 1)
grid.attach(self._hotkey_entry, 1, 0, 1, 1)
self._hotkey_error = Gtk.Label(label="")
self._hotkey_error.set_xalign(0.0)
self._hotkey_error.set_line_wrap(True)
grid.attach(self._hotkey_error, 1, 1, 1, 1)
backend_label = Gtk.Label(label="Text injection")
backend_label.set_xalign(0.0)
self._backend_combo = Gtk.ComboBoxText()
self._backend_combo.append("clipboard", "Clipboard paste (recommended)")
self._backend_combo.append("injection", "Simulated typing")
grid.attach(backend_label, 0, 2, 1, 1)
grid.attach(self._backend_combo, 1, 2, 1, 1)
self._remove_clipboard_check = Gtk.CheckButton(
label="Remove transcription from clipboard after paste"
)
self._remove_clipboard_check.set_hexpand(True)
grid.attach(self._remove_clipboard_check, 1, 3, 1, 1)
language_label = Gtk.Label(label="Transcription language")
language_label.set_xalign(0.0)
self._language_combo = Gtk.ComboBoxText()
for code, label in COMMON_STT_LANGUAGE_OPTIONS:
self._language_combo.append(code, label)
grid.attach(language_label, 0, 4, 1, 1)
grid.attach(self._language_combo, 1, 4, 1, 1)
profile_label = Gtk.Label(label="Profile")
profile_label.set_xalign(0.0)
self._profile_combo = Gtk.ComboBoxText()
self._profile_combo.append("default", "Default")
self._profile_combo.append("fast", "Fast (lower latency)")
self._profile_combo.append("polished", "Polished")
grid.attach(profile_label, 0, 5, 1, 1)
grid.attach(self._profile_combo, 1, 5, 1, 1)
self._show_notifications_check = Gtk.CheckButton(label="Enable tray notifications")
self._show_notifications_check.set_hexpand(True)
grid.attach(self._show_notifications_check, 1, 6, 1, 1)
return grid
def _build_audio_page(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
box.set_margin_start(14)
box.set_margin_end(14)
box.set_margin_top(14)
box.set_margin_bottom(14)
input_label = Gtk.Label(label="Input device")
input_label.set_xalign(0.0)
box.pack_start(input_label, False, False, 0)
self._mic_combo = Gtk.ComboBoxText()
self._mic_combo.append("", "System default")
for device in self._devices:
self._mic_combo.append(str(device["index"]), f"{device['index']}: {device['name']}")
box.pack_start(self._mic_combo, False, False, 0)
test_button = Gtk.Button(label="Test microphone")
test_button.connect("clicked", lambda *_: self._on_test_microphone())
box.pack_start(test_button, False, False, 0)
self._mic_status = Gtk.Label(label="")
self._mic_status.set_xalign(0.0)
self._mic_status.set_line_wrap(True)
box.pack_start(self._mic_status, False, False, 0)
return box
def _build_advanced_page(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
box.set_margin_start(14)
box.set_margin_end(14)
box.set_margin_top(14)
box.set_margin_bottom(14)
self._strict_startup_check = Gtk.CheckButton(label="Fail fast on startup validation errors")
box.pack_start(self._strict_startup_check, False, False, 0)
runtime_title = Gtk.Label()
runtime_title.set_markup("<span weight='bold'>Runtime management</span>")
runtime_title.set_xalign(0.0)
box.pack_start(runtime_title, False, False, 0)
runtime_copy = Gtk.Label(
label=(
"Aman-managed mode handles model downloads, updates, and safe defaults for you. "
"Expert mode keeps Aman open-source friendly by exposing custom providers and models."
)
)
runtime_copy.set_xalign(0.0)
runtime_copy.set_line_wrap(True)
box.pack_start(runtime_copy, False, False, 0)
mode_label = Gtk.Label(label="Runtime mode")
mode_label.set_xalign(0.0)
box.pack_start(mode_label, False, False, 0)
self._runtime_mode_combo = Gtk.ComboBoxText()
self._runtime_mode_combo.append(RUNTIME_MODE_MANAGED, "Aman-managed (recommended)")
self._runtime_mode_combo.append(RUNTIME_MODE_EXPERT, "Expert mode (custom models/providers)")
self._runtime_mode_combo.connect("changed", lambda *_: self._on_runtime_mode_changed(user_initiated=True))
box.pack_start(self._runtime_mode_combo, False, False, 0)
self._runtime_status_label = Gtk.Label(label="")
self._runtime_status_label.set_xalign(0.0)
self._runtime_status_label.set_line_wrap(True)
box.pack_start(self._runtime_status_label, False, False, 0)
self._expert_expander = Gtk.Expander(label="Expert options")
self._expert_expander.set_expanded(False)
box.pack_start(self._expert_expander, False, False, 0)
expert_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
expert_box.set_margin_start(10)
expert_box.set_margin_end(10)
expert_box.set_margin_top(8)
expert_box.set_margin_bottom(8)
self._expert_expander.add(expert_box)
expert_warning = Gtk.InfoBar()
expert_warning.set_show_close_button(False)
expert_warning.set_message_type(Gtk.MessageType.WARNING)
warning_label = Gtk.Label(
label=(
"Expert mode is best-effort and may require manual troubleshooting. "
"Aman-managed mode is the canonical supported path."
)
)
warning_label.set_xalign(0.0)
warning_label.set_line_wrap(True)
expert_warning.get_content_area().pack_start(warning_label, True, True, 0)
expert_box.pack_start(expert_warning, False, False, 0)
llm_provider_label = Gtk.Label(label="LLM provider")
llm_provider_label.set_xalign(0.0)
expert_box.pack_start(llm_provider_label, False, False, 0)
self._llm_provider_combo = Gtk.ComboBoxText()
self._llm_provider_combo.append("local_llama", "Local llama.cpp")
self._llm_provider_combo.append("external_api", "External API")
self._llm_provider_combo.connect("changed", lambda *_: self._on_runtime_widgets_changed())
expert_box.pack_start(self._llm_provider_combo, False, False, 0)
self._external_api_enabled_check = Gtk.CheckButton(label="Enable external API provider")
self._external_api_enabled_check.connect("toggled", lambda *_: self._on_runtime_widgets_changed())
expert_box.pack_start(self._external_api_enabled_check, False, False, 0)
external_model_label = Gtk.Label(label="External API model")
external_model_label.set_xalign(0.0)
expert_box.pack_start(external_model_label, False, False, 0)
self._external_model_entry = Gtk.Entry()
self._external_model_entry.connect("changed", lambda *_: self._on_runtime_widgets_changed())
expert_box.pack_start(self._external_model_entry, False, False, 0)
external_base_url_label = Gtk.Label(label="External API base URL")
external_base_url_label.set_xalign(0.0)
expert_box.pack_start(external_base_url_label, False, False, 0)
self._external_base_url_entry = Gtk.Entry()
self._external_base_url_entry.connect("changed", lambda *_: self._on_runtime_widgets_changed())
expert_box.pack_start(self._external_base_url_entry, False, False, 0)
external_key_env_label = Gtk.Label(label="External API key env var")
external_key_env_label.set_xalign(0.0)
expert_box.pack_start(external_key_env_label, False, False, 0)
self._external_key_env_entry = Gtk.Entry()
self._external_key_env_entry.connect("changed", lambda *_: self._on_runtime_widgets_changed())
expert_box.pack_start(self._external_key_env_entry, False, False, 0)
self._allow_custom_models_check = Gtk.CheckButton(
label="Allow custom local model paths"
)
self._allow_custom_models_check.connect("toggled", lambda *_: self._on_runtime_widgets_changed())
expert_box.pack_start(self._allow_custom_models_check, False, False, 0)
whisper_model_path_label = Gtk.Label(label="Custom Whisper model path")
whisper_model_path_label.set_xalign(0.0)
expert_box.pack_start(whisper_model_path_label, False, False, 0)
self._whisper_model_path_entry = Gtk.Entry()
self._whisper_model_path_entry.connect("changed", lambda *_: self._on_runtime_widgets_changed())
expert_box.pack_start(self._whisper_model_path_entry, False, False, 0)
llm_model_path_label = Gtk.Label(label="Custom LLM model path")
llm_model_path_label.set_xalign(0.0)
expert_box.pack_start(llm_model_path_label, False, False, 0)
self._llm_model_path_entry = Gtk.Entry()
self._llm_model_path_entry.connect("changed", lambda *_: self._on_runtime_widgets_changed())
expert_box.pack_start(self._llm_model_path_entry, False, False, 0)
self._runtime_error = Gtk.Label(label="")
self._runtime_error.set_xalign(0.0)
self._runtime_error.set_line_wrap(True)
expert_box.pack_start(self._runtime_error, False, False, 0)
path_label = Gtk.Label(label="Config path")
path_label.set_xalign(0.0)
box.pack_start(path_label, False, False, 0)
path_entry = Gtk.Entry()
path_entry.set_editable(False)
path_entry.set_text(str(self._config_path))
box.pack_start(path_entry, False, False, 0)
note = Gtk.Label(
label=(
"Tip: after editing the file directly, use Reload Config from the tray to apply changes."
)
)
note.set_xalign(0.0)
note.set_line_wrap(True)
box.pack_start(note, False, False, 0)
return box
def _build_help_page(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
box.set_margin_start(14)
box.set_margin_end(14)
box.set_margin_top(14)
box.set_margin_bottom(14)
help_text = Gtk.Label(
label=(
"Usage:\n"
"- Press your hotkey to start recording.\n"
"- Press the hotkey again to stop and process.\n"
"- Press Esc while recording to cancel.\n\n"
"Model/runtime tips:\n"
"- Aman-managed mode (recommended) handles model lifecycle for you.\n"
"- Expert mode lets you bring your own models/providers.\n\n"
"Use the tray menu for pause/resume, config reload, and diagnostics."
)
)
help_text.set_xalign(0.0)
help_text.set_line_wrap(True)
box.pack_start(help_text, False, False, 0)
about_button = Gtk.Button(label="Open About Dialog")
about_button.connect("clicked", lambda *_: _present_about_dialog(self._dialog))
box.pack_start(about_button, False, False, 0)
return box
def _build_about_page(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
box.set_margin_start(14)
box.set_margin_end(14)
box.set_margin_top(14)
box.set_margin_bottom(14)
title = Gtk.Label()
title.set_markup("<span size='x-large' weight='bold'>Aman</span>")
title.set_xalign(0.0)
box.pack_start(title, False, False, 0)
subtitle = Gtk.Label(label="Local amanuensis for desktop dictation and rewriting.")
subtitle.set_xalign(0.0)
subtitle.set_line_wrap(True)
box.pack_start(subtitle, False, False, 0)
about_button = Gtk.Button(label="About Aman")
about_button.connect("clicked", lambda *_: _present_about_dialog(self._dialog))
box.pack_start(about_button, False, False, 0)
return box
def _initialize_widget_values(self) -> None:
hotkey = self._config.daemon.hotkey.strip() or "Super+m"
self._hotkey_entry.set_text(hotkey)
backend = (self._config.injection.backend or "clipboard").strip().lower()
if backend not in {"clipboard", "injection"}:
backend = "clipboard"
self._backend_combo.set_active_id(backend)
self._remove_clipboard_check.set_active(
bool(self._config.injection.remove_transcription_from_clipboard)
)
language = (self._config.stt.language or "auto").strip().lower()
if self._language_combo.get_active_id() is None:
self._language_combo.set_active_id("auto")
self._language_combo.set_active_id(language)
if self._language_combo.get_active_id() != language:
self._language_combo.append(language, stt_language_label(language))
self._language_combo.set_active_id(language)
profile = (self._config.ux.profile or "default").strip().lower()
if profile not in {"default", "fast", "polished"}:
profile = "default"
self._profile_combo.set_active_id(profile)
self._show_notifications_check.set_active(bool(self._config.ux.show_notifications))
self._strict_startup_check.set_active(bool(self._config.advanced.strict_startup))
llm_provider = self._config.llm.provider.strip().lower()
if llm_provider not in {"local_llama", "external_api"}:
llm_provider = "local_llama"
self._llm_provider_combo.set_active_id(llm_provider)
self._external_api_enabled_check.set_active(bool(self._config.external_api.enabled))
self._external_model_entry.set_text(self._config.external_api.model)
self._external_base_url_entry.set_text(self._config.external_api.base_url)
self._external_key_env_entry.set_text(self._config.external_api.api_key_env_var)
self._allow_custom_models_check.set_active(bool(self._config.models.allow_custom_models))
self._whisper_model_path_entry.set_text(self._config.models.whisper_model_path)
self._llm_model_path_entry.set_text(self._config.models.llm_model_path)
self._runtime_mode_combo.set_active_id(self._runtime_mode)
self._sync_runtime_mode_ui(user_initiated=False)
self._validate_runtime_settings()
resolved = resolve_input_device(self._config.recording.input)
if resolved is None:
self._mic_combo.set_active_id("")
return
resolved_id = str(resolved)
self._mic_combo.set_active_id(resolved_id if resolved_id in self._device_by_id else "")
def _current_runtime_mode(self) -> str:
mode = (self._runtime_mode_combo.get_active_id() or "").strip().lower()
if mode in {RUNTIME_MODE_MANAGED, RUNTIME_MODE_EXPERT}:
return mode
return RUNTIME_MODE_MANAGED
def _on_runtime_mode_changed(self, *, user_initiated: bool) -> None:
self._sync_runtime_mode_ui(user_initiated=user_initiated)
self._validate_runtime_settings()
def _on_runtime_widgets_changed(self) -> None:
self._sync_runtime_mode_ui(user_initiated=False)
self._validate_runtime_settings()
def _sync_runtime_mode_ui(self, *, user_initiated: bool) -> None:
mode = self._current_runtime_mode()
self._runtime_mode = mode
if mode == RUNTIME_MODE_MANAGED:
if user_initiated:
self._apply_canonical_runtime_defaults_to_widgets()
self._runtime_status_label.set_text(
"Aman-managed mode is active. Aman handles model lifecycle and keeps supported defaults."
)
self._expert_expander.set_expanded(False)
self._expert_expander.set_visible(False)
self._set_expert_controls_sensitive(False)
self._runtime_error.set_text("")
return
self._runtime_status_label.set_text(
"Expert mode is active. You are responsible for provider, model, and environment compatibility."
)
self._expert_expander.set_visible(True)
self._expert_expander.set_expanded(True)
self._set_expert_controls_sensitive(True)
def _set_expert_controls_sensitive(self, enabled: bool) -> None:
provider = (self._llm_provider_combo.get_active_id() or "local_llama").strip().lower()
allow_custom = self._allow_custom_models_check.get_active()
external_fields_enabled = enabled and provider == "external_api"
custom_path_enabled = enabled and allow_custom
self._llm_provider_combo.set_sensitive(enabled)
self._external_api_enabled_check.set_sensitive(enabled)
self._external_model_entry.set_sensitive(external_fields_enabled)
self._external_base_url_entry.set_sensitive(external_fields_enabled)
self._external_key_env_entry.set_sensitive(external_fields_enabled)
self._allow_custom_models_check.set_sensitive(enabled)
self._whisper_model_path_entry.set_sensitive(custom_path_enabled)
self._llm_model_path_entry.set_sensitive(custom_path_enabled)
def _apply_canonical_runtime_defaults_to_widgets(self) -> None:
self._llm_provider_combo.set_active_id(DEFAULT_LLM_PROVIDER)
self._external_api_enabled_check.set_active(False)
self._external_model_entry.set_text(DEFAULT_EXTERNAL_API_MODEL)
self._external_base_url_entry.set_text(DEFAULT_EXTERNAL_API_BASE_URL)
self._external_key_env_entry.set_text(DEFAULT_EXTERNAL_API_KEY_ENV_VAR)
self._allow_custom_models_check.set_active(False)
self._whisper_model_path_entry.set_text("")
self._llm_model_path_entry.set_text("")
def _validate_runtime_settings(self) -> bool:
mode = self._current_runtime_mode()
if mode == RUNTIME_MODE_MANAGED:
self._runtime_error.set_text("")
return True
provider = (self._llm_provider_combo.get_active_id() or "local_llama").strip().lower()
if provider == "external_api" and not self._external_api_enabled_check.get_active():
self._runtime_error.set_text(
"Expert mode: enable External API provider when LLM provider is set to External API."
)
return False
if provider == "external_api" and not self._external_model_entry.get_text().strip():
self._runtime_error.set_text("Expert mode: External API model is required.")
return False
if provider == "external_api" and not self._external_base_url_entry.get_text().strip():
self._runtime_error.set_text("Expert mode: External API base URL is required.")
return False
if provider == "external_api" and not self._external_key_env_entry.get_text().strip():
self._runtime_error.set_text("Expert mode: External API key env var is required.")
return False
self._runtime_error.set_text("")
return True
def _selected_input_spec(self) -> str | int | None:
selected = self._mic_combo.get_active_id()
if not selected:
return ""
if selected.isdigit():
return int(selected)
return selected
def _on_test_microphone(self) -> None:
input_spec = self._selected_input_spec()
self._mic_status.set_text("Testing microphone...")
while Gtk.events_pending():
Gtk.main_iteration()
try:
stream, record = start_recording(input_spec)
time.sleep(0.35)
audio = stop_recording(stream, record)
if getattr(audio, "size", 0) > 0:
self._mic_status.set_text("Microphone test successful.")
return
self._mic_status.set_text("No audio captured. Try another device.")
except Exception as exc:
self._mic_status.set_text(f"Microphone test failed: {exc}")
def _validate_hotkey(self) -> bool:
hotkey = self._hotkey_entry.get_text().strip()
if not hotkey:
self._hotkey_error.set_text("Hotkey is required.")
self._apply_button.set_sensitive(False)
return False
try:
self._desktop.validate_hotkey(hotkey)
except Exception as exc:
self._hotkey_error.set_text(f"Hotkey is not available: {exc}")
self._apply_button.set_sensitive(False)
return False
self._hotkey_error.set_text("")
self._apply_button.set_sensitive(True)
return True
def _build_result_config(self) -> Config:
cfg = copy.deepcopy(self._config)
cfg.daemon.hotkey = self._hotkey_entry.get_text().strip()
cfg.recording.input = self._selected_input_spec()
cfg.injection.backend = self._backend_combo.get_active_id() or "clipboard"
cfg.injection.remove_transcription_from_clipboard = self._remove_clipboard_check.get_active()
cfg.stt.language = self._language_combo.get_active_id() or "auto"
cfg.ux.profile = self._profile_combo.get_active_id() or "default"
cfg.ux.show_notifications = self._show_notifications_check.get_active()
cfg.advanced.strict_startup = self._strict_startup_check.get_active()
if self._current_runtime_mode() == RUNTIME_MODE_MANAGED:
apply_canonical_runtime_defaults(cfg)
return cfg
cfg.stt.provider = DEFAULT_STT_PROVIDER
cfg.llm.provider = self._llm_provider_combo.get_active_id() or DEFAULT_LLM_PROVIDER
cfg.external_api.enabled = self._external_api_enabled_check.get_active()
cfg.external_api.model = self._external_model_entry.get_text().strip()
cfg.external_api.base_url = self._external_base_url_entry.get_text().strip()
cfg.external_api.api_key_env_var = self._external_key_env_entry.get_text().strip()
cfg.models.allow_custom_models = self._allow_custom_models_check.get_active()
if cfg.models.allow_custom_models:
cfg.models.whisper_model_path = self._whisper_model_path_entry.get_text().strip()
cfg.models.llm_model_path = self._llm_model_path_entry.get_text().strip()
else:
cfg.models.whisper_model_path = ""
cfg.models.llm_model_path = ""
return cfg
def run_config_ui(
initial_cfg: Config,
desktop,
*,
required: bool,
config_path: str | Path | None = None,
) -> ConfigUiResult:
try:
Gtk.init([])
except Exception:
pass
logging.info("opening settings ui")
window = ConfigWindow(
initial_cfg,
desktop,
required=required,
config_path=config_path,
)
return window.run()
def show_help_dialog() -> None:
try:
Gtk.init([])
except Exception:
pass
dialog = Gtk.MessageDialog(
None,
Gtk.DialogFlags.MODAL,
Gtk.MessageType.INFO,
Gtk.ButtonsType.OK,
"Aman Help",
)
dialog.set_title("Aman Help")
dialog.format_secondary_text(
"Press your hotkey to record, press it again to process, and press Esc while recording to "
"cancel. Aman-managed mode is the canonical supported path; expert mode exposes custom "
"providers/models for advanced users."
)
dialog.run()
dialog.destroy()
def show_about_dialog() -> None:
try:
Gtk.init([])
except Exception:
pass
_present_about_dialog(None)
def _present_about_dialog(parent) -> None:
about = Gtk.AboutDialog(transient_for=parent, modal=True)
about.set_program_name("Aman")
about.set_version("pre-release")
about.set_comments("Local amanuensis for desktop dictation and rewriting.")
about.set_license("MIT")
about.set_wrap_license(True)
about.run()
about.destroy()