feat(audio): add a family dictation tone picker (#707)

Settings → Audio can pick start/stop cues from the shared catalog
(lift, flick, ember, step, voca, soft, chirp, scale, drop, glass, off).
New installs and any missing or unknown sound_effects.tone value use
voca. A saved id, including off, is left alone.

The old start_recording/stop_recording files stay on disk. They are
no longer the implicit default. Error cues still follow the existing
enable switch.
This commit is contained in:
Jatin K Malik
2026-08-18 17:46:55 -07:00
committed by GitHub
parent 994dda10a6
commit fd23ef0d9d
48 changed files with 565 additions and 100 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+55 -2
View File
@@ -9,7 +9,12 @@ import os
import shutil
import subprocess
import sys
import threading
import wave
from pathlib import Path # noqa: F401
from typing import Optional
from .config_manager import DEFAULT_SOUND_EFFECT_TONE, normalize_sound_effect_tone
logger = logging.getLogger(__name__)
@@ -61,6 +66,30 @@ def _is_sound_effects_enabled() -> bool:
return True
def _resolved_tone() -> str:
try:
from .config_manager import ConfigManager
return ConfigManager().get_sound_effects_tone()
except Exception:
return DEFAULT_SOUND_EFFECT_TONE
def tone_sound_path(tone_id: str, kind: str) -> str:
"""Return the WAV path for a catalog start/stop cue."""
return _resource_manager.get_sound_path(f"{tone_id}_{kind}")
def _wav_duration_seconds(sound_path: str) -> float:
try:
with wave.open(sound_path, "rb") as wav_file:
frames = wav_file.getnframes()
rate = wav_file.getframerate() or 1
return frames / float(rate)
except Exception:
return 0.35
def _get_audio_player():
"""
Determine the best available audio player on the system.
@@ -166,12 +195,36 @@ def _play_sound_file(sound_path):
def play_start_sound():
return _play_sound_file(START_SOUND) if _is_sound_effects_enabled() else False
if not _is_sound_effects_enabled():
return False
tone = _resolved_tone()
if tone == "off":
return False
return _play_sound_file(tone_sound_path(tone, "start"))
def play_stop_sound():
return _play_sound_file(STOP_SOUND) if _is_sound_effects_enabled() else False
if not _is_sound_effects_enabled():
return False
tone = _resolved_tone()
if tone == "off":
return False
return _play_sound_file(tone_sound_path(tone, "stop"))
def play_error_sound():
return _play_sound_file(ERROR_SOUND) if _is_sound_effects_enabled() else False
def preview_tone(tone_id: Optional[str] = None) -> bool:
"""Play start then stop for a catalog tone. Off plays nothing."""
tone = normalize_sound_effect_tone(tone_id if tone_id is not None else _resolved_tone())
if tone == "off":
return False
start_path = tone_sound_path(tone, "start")
stop_path = tone_sound_path(tone, "stop")
if not _play_sound_file(start_path):
return False
delay = _wav_duration_seconds(start_path) + 0.08
threading.Timer(delay, _play_sound_file, args=(stop_path,)).start()
return True
+38 -1
View File
@@ -18,6 +18,32 @@ logger = logging.getLogger(__name__)
CONFIG_DIR = config_dir()
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
# Family dictation-tone catalog. Ids and display names are shared with VocaWin.
# The former "fifth" pair is voca / Voca. Off is a real choice (no start/stop).
SOUND_EFFECT_TONES: tuple[tuple[str, str], ...] = (
("lift", "Lift"),
("flick", "Flick"),
("ember", "Ember"),
("step", "Step"),
("voca", "Voca"),
("soft", "Soft"),
("chirp", "Chirp"),
("scale", "Scale"),
("drop", "Drop"),
("glass", "Glass"),
("off", "Off"),
)
SOUND_EFFECT_TONE_IDS = frozenset(tone_id for tone_id, _label in SOUND_EFFECT_TONES)
DEFAULT_SOUND_EFFECT_TONE = "voca"
def normalize_sound_effect_tone(tone: Any) -> str:
"""Return a catalog id. Missing, blank, or unknown values become voca."""
if isinstance(tone, str) and tone in SOUND_EFFECT_TONE_IDS:
return tone
return DEFAULT_SOUND_EFFECT_TONE
# Default configuration
DEFAULT_CONFIG = {
"speech_recognition": { # Changed section name
@@ -41,7 +67,8 @@ DEFAULT_CONFIG = {
"device_name": None, # Saved device name for display/reference
},
"sound_effects": {
"enabled": True, # Play sounds for recording start/stop/error
"enabled": True, # Master mute for start/stop/error cues
"tone": "voca", # Family catalog id; missing/unknown also resolve to voca
},
"shortcuts": {
"toggle_recognition": "right_alt+right_alt",
@@ -423,6 +450,16 @@ class ConfigManager:
self.config["sound_effects"] = {}
self.config["sound_effects"]["enabled"] = enabled
def get_sound_effects_tone(self) -> str:
"""Return the selected dictation tone id (voca when unset or unknown)."""
return normalize_sound_effect_tone(self.config.get("sound_effects", {}).get("tone"))
def set_sound_effects_tone(self, tone: str):
"""Save a catalog tone id. Unknown ids are stored as voca."""
if "sound_effects" not in self.config:
self.config["sound_effects"] = {}
self.config["sound_effects"]["tone"] = normalize_sound_effect_tone(tone)
def _update_dict_recursive(self, target: dict, source: dict):
"""
Update a dictionary recursively.
+67 -2
View File
@@ -69,7 +69,11 @@ from ..utils.whispercpp_model_info import (
list_downloaded_models as list_downloaded_whispercpp_models,
)
from ..version import __copyright__, __description__, __url__, __version__ # noqa: E402
from .config_manager import DEFAULT_CONFIG # noqa: E402
from .config_manager import ( # noqa: E402
DEFAULT_CONFIG,
DEFAULT_SOUND_EFFECT_TONE,
SOUND_EFFECT_TONES,
)
from .keyboard_backends import ( # noqa: E402
DEFAULT_SHORTCUT,
DEFAULT_SHORTCUT_MODE,
@@ -1755,7 +1759,12 @@ class SettingsDialog(Gtk.Dialog):
self.audio_tab.pack_start(group, False, False, 0)
# Sound Effects section
sound_group = PreferencesGroup(title="Sound Effects")
sound_group = PreferencesGroup(
title="Sound Effects",
description=(
"The switch mutes every cue, including errors. Off only skips start and stop."
),
)
self.sound_effects_switch = Gtk.Switch()
self.sound_effects_switch.set_tooltip_text(
"Play sounds when recording starts, stops, or encounters errors"
@@ -1764,10 +1773,38 @@ class SettingsDialog(Gtk.Dialog):
title="Enable Sound Effects",
subtitle="Play audio feedback for recording events",
widget=self.sound_effects_switch,
keywords=("tone", "chime", "cue", "feedback"),
)
sound_group.add_row(sound_row)
self.sound_tone_combo = Gtk.ComboBoxText()
self.sound_tone_combo.set_size_request(_CONTROL_WIDTH, -1)
self.sound_tone_combo.set_tooltip_text("Start and stop sound used while dictating")
_prevent_scroll_on_hover(self.sound_tone_combo)
for tone_id, label in SOUND_EFFECT_TONES:
self.sound_tone_combo.append(tone_id, label)
tone_row = PreferenceRow(
title="Dictation Tone",
subtitle="Start and stop cues. Off is silent for those two.",
widget=self.sound_tone_combo,
keywords=("tone", "chime", "voca", "lift", "preview"),
)
sound_group.add_row(tone_row)
self.preview_tone_btn = Gtk.Button(label="Preview")
self.preview_tone_btn.set_tooltip_text("Play the start cue, then the stop cue")
self.preview_tone_row = PreferenceRow(
title="Preview Tone",
subtitle="Play the start cue, then the stop cue",
widget=self.preview_tone_btn,
keywords=("tone", "preview", "listen"),
)
sound_group.add_row(self.preview_tone_row)
self.audio_tab.pack_start(sound_group, False, False, 0)
self.sound_effects_switch.connect("state-set", self._on_sound_effects_toggled)
self.sound_tone_combo.connect("changed", self._on_sound_tone_changed)
self.preview_tone_btn.connect("clicked", self._on_preview_tone_clicked)
# Populate devices
self._populate_audio_devices()
@@ -2242,6 +2279,30 @@ class SettingsDialog(Gtk.Dialog):
logger.info(f"Sound effects {'enabled' if enabled else 'disabled'}")
return False
def _update_tone_preview_subtitle(self, tone_id: str) -> None:
if tone_id == "off":
self.preview_tone_row.set_subtitle("Off has no start or stop cue")
self.preview_tone_btn.set_tooltip_text("Off has no start or stop cue")
else:
self.preview_tone_row.set_subtitle("Play the start cue, then the stop cue")
self.preview_tone_btn.set_tooltip_text("Play the start cue, then the stop cue")
def _on_sound_tone_changed(self, combo):
tone_id = combo.get_active_id() or DEFAULT_SOUND_EFFECT_TONE
self._update_tone_preview_subtitle(tone_id)
if self._initializing or self._applying_settings:
return
logger.info("Dictation tone selected: %s", tone_id)
self.config_manager.set_sound_effects_tone(tone_id)
self.config_manager.save_settings()
def _on_preview_tone_clicked(self, _widget):
from .audio_feedback import preview_tone
tone_id = self.sound_tone_combo.get_active_id() or DEFAULT_SOUND_EFFECT_TONE
self._update_tone_preview_subtitle(tone_id)
preview_tone(tone_id)
def _build_engine_section(self):
"""Build the Speech Engine section."""
group = PreferencesGroup(title="Speech Engine")
@@ -3864,6 +3925,10 @@ class SettingsDialog(Gtk.Dialog):
self.auto_capitalize_switch.set_active(auto_capitalize)
self.append_trailing_space_switch.set_active(append_trailing_space)
self.sound_effects_switch.set_active(self.config_manager.is_sound_effects_enabled())
tone_id = self.config_manager.get_sound_effects_tone()
if not self.sound_tone_combo.set_active_id(tone_id):
self.sound_tone_combo.set_active_id(DEFAULT_SOUND_EFFECT_TONE)
self._update_tone_preview_subtitle(self.sound_tone_combo.get_active_id() or tone_id)
auto_pause_settings = self.config_manager.get_settings().get("auto_pause", {})
auto_pause_enabled = bool(auto_pause_settings.get("enabled", False))