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:
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.
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user