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
+127 -12
View File
@@ -305,28 +305,30 @@ class TestAudioFeedback(unittest.TestCase):
self.assertFalse(result)
def test_play_start_sound(self):
"""Test playing start sound."""
"""Test playing start sound (default tone is voca)."""
# Import the module first
import vocalinux.ui.audio_feedback as audio_feedback
with patch.object(audio_feedback, "_play_sound_file") as mock_play:
# Call the function
with (
patch.object(audio_feedback, "_is_sound_effects_enabled", return_value=True),
patch.object(audio_feedback, "_resolved_tone", return_value="voca"),
patch.object(audio_feedback, "_play_sound_file") as mock_play,
):
audio_feedback.play_start_sound()
# Verify _play_sound_file was called with correct path
mock_play.assert_called_once_with(audio_feedback.START_SOUND)
mock_play.assert_called_once_with(audio_feedback.tone_sound_path("voca", "start"))
def test_play_stop_sound(self):
"""Test playing stop sound."""
"""Test playing stop sound (default tone is voca)."""
# Import the module first
import vocalinux.ui.audio_feedback as audio_feedback
with patch.object(audio_feedback, "_play_sound_file") as mock_play:
# Call the function
with (
patch.object(audio_feedback, "_is_sound_effects_enabled", return_value=True),
patch.object(audio_feedback, "_resolved_tone", return_value="voca"),
patch.object(audio_feedback, "_play_sound_file") as mock_play,
):
audio_feedback.play_stop_sound()
# Verify _play_sound_file was called with correct path
mock_play.assert_called_once_with(audio_feedback.STOP_SOUND)
mock_play.assert_called_once_with(audio_feedback.tone_sound_path("voca", "stop"))
def test_play_error_sound(self):
"""Test playing error sound."""
@@ -422,3 +424,116 @@ class TestAudioFeedback(unittest.TestCase):
):
result = audio_feedback._is_sound_effects_enabled()
self.assertTrue(result)
def test_missing_or_unknown_tone_uses_voca_not_legacy_pair(self):
"""Unset or unknown tone ids play voca, not start_recording/stop_recording."""
import vocalinux.ui.audio_feedback as audio_feedback
from vocalinux.ui.config_manager import (
DEFAULT_SOUND_EFFECT_TONE,
normalize_sound_effect_tone,
)
self.assertEqual(DEFAULT_SOUND_EFFECT_TONE, "voca")
for raw in (None, "", "fifth", "01-linux-glide", "not-a-tone"):
self.assertEqual(normalize_sound_effect_tone(raw), "voca")
with (
patch.object(audio_feedback, "_is_sound_effects_enabled", return_value=True),
patch.object(audio_feedback, "_resolved_tone", return_value="voca"),
patch.object(audio_feedback, "_play_sound_file") as mock_play,
):
audio_feedback.play_start_sound()
audio_feedback.play_stop_sound()
played = [call.args[0] for call in mock_play.call_args_list]
self.assertTrue(played[0].endswith("voca_start.wav"))
self.assertTrue(played[1].endswith("voca_stop.wav"))
self.assertFalse(any("start_recording.wav" in path for path in played))
def test_off_plays_nothing_for_start_stop(self):
import vocalinux.ui.audio_feedback as audio_feedback
with (
patch.object(audio_feedback, "_is_sound_effects_enabled", return_value=True),
patch.object(audio_feedback, "_resolved_tone", return_value="off"),
patch.object(audio_feedback, "_play_sound_file") as mock_play,
):
self.assertFalse(audio_feedback.play_start_sound())
self.assertFalse(audio_feedback.play_stop_sound())
mock_play.assert_not_called()
with (
patch.object(audio_feedback, "_is_sound_effects_enabled", return_value=True),
patch.object(audio_feedback, "_play_sound_file") as mock_play,
):
self.assertTrue(audio_feedback.play_error_sound())
mock_play.assert_called_once_with(audio_feedback.ERROR_SOUND)
def test_each_catalog_id_maps_to_its_pair(self):
import vocalinux.ui.audio_feedback as audio_feedback
from vocalinux.ui.config_manager import SOUND_EFFECT_TONE_IDS, normalize_sound_effect_tone
catalog_ids = (
"lift",
"flick",
"ember",
"step",
"voca",
"soft",
"chirp",
"scale",
"drop",
"glass",
"off",
)
self.assertEqual(set(catalog_ids), set(SOUND_EFFECT_TONE_IDS))
self.assertNotIn("fifth", SOUND_EFFECT_TONE_IDS)
for tone_id in catalog_ids:
self.assertEqual(normalize_sound_effect_tone(tone_id), tone_id)
if tone_id == "off":
continue
start = audio_feedback.tone_sound_path(tone_id, "start")
stop = audio_feedback.tone_sound_path(tone_id, "stop")
self.assertTrue(os.path.isfile(start), start)
self.assertTrue(os.path.isfile(stop), stop)
self.assertTrue(start.endswith(f"{tone_id}_start.wav"))
self.assertTrue(stop.endswith(f"{tone_id}_stop.wav"))
with (
patch.object(audio_feedback, "_is_sound_effects_enabled", return_value=True),
patch.object(audio_feedback, "_resolved_tone", return_value=tone_id),
patch.object(audio_feedback, "_play_sound_file") as mock_play,
):
audio_feedback.play_start_sound()
audio_feedback.play_stop_sound()
self.assertEqual(mock_play.call_args_list[0].args[0], start)
self.assertEqual(mock_play.call_args_list[1].args[0], stop)
def test_preview_tone_does_not_crash(self):
import vocalinux.ui.audio_feedback as audio_feedback
class ImmediateTimer:
def __init__(self, delay, function, args=None, kwargs=None):
self.function = function
self.args = args or ()
self.kwargs = kwargs or {}
def start(self):
self.function(*self.args, **self.kwargs)
with (
patch.object(audio_feedback, "_play_sound_file", return_value=True) as mock_play,
patch.object(audio_feedback.threading, "Timer", ImmediateTimer),
):
self.assertTrue(audio_feedback.preview_tone("voca"))
self.assertEqual(len(mock_play.call_args_list), 2)
self.assertTrue(mock_play.call_args_list[0].args[0].endswith("voca_start.wav"))
self.assertTrue(mock_play.call_args_list[1].args[0].endswith("voca_stop.wav"))
mock_play.reset_mock()
self.assertFalse(audio_feedback.preview_tone("off"))
mock_play.assert_not_called()
mock_play.reset_mock()
self.assertTrue(audio_feedback.preview_tone("not-a-tone"))
self.assertTrue(mock_play.call_args_list[0].args[0].endswith("voca_start.wav"))
+9
View File
@@ -485,6 +485,15 @@ class TestConfigManager(unittest.TestCase):
config_manager = ConfigManager()
self.assertTrue(config_manager.is_sound_effects_enabled())
def test_sound_effects_tone_defaults_to_voca(self):
"""New installs and missing tone keys resolve to voca."""
from vocalinux.ui.config_manager import DEFAULT_SOUND_EFFECT_TONE
config_manager = ConfigManager()
self.assertEqual(DEFAULT_SOUND_EFFECT_TONE, "voca")
self.assertEqual(config_manager.get_sound_effects_tone(), "voca")
self.assertEqual(config_manager.config["sound_effects"]["tone"], "voca")
def test_missing_tray_warning_enabled_by_default(self):
"""Test that the missing tray support warning is enabled by default."""
config_manager = ConfigManager()
+33
View File
@@ -233,6 +233,39 @@ class TestConfigManagerSoundEffects:
assert "sound_effects" in manager.config
assert manager.config["sound_effects"]["enabled"] is False
def test_missing_tone_key_lands_on_voca(self):
from vocalinux.ui.config_manager import ConfigManager
manager = ConfigManager.__new__(ConfigManager)
manager.config = {"sound_effects": {"enabled": True}}
assert manager.get_sound_effects_tone() == "voca"
def test_unknown_tone_lands_on_voca(self):
from vocalinux.ui.config_manager import ConfigManager
manager = ConfigManager.__new__(ConfigManager)
manager.config = {"sound_effects": {"enabled": True, "tone": "fifth"}}
assert manager.get_sound_effects_tone() == "voca"
assert manager.config["sound_effects"]["tone"] == "fifth"
def test_explicit_saved_tone_is_left_alone(self):
from vocalinux.ui.config_manager import SOUND_EFFECT_TONE_IDS, ConfigManager
manager = ConfigManager.__new__(ConfigManager)
for tone_id in SOUND_EFFECT_TONE_IDS:
manager.config = {"sound_effects": {"enabled": True, "tone": tone_id}}
assert manager.get_sound_effects_tone() == tone_id
def test_set_sound_effects_tone_persists(self):
from vocalinux.ui.config_manager import ConfigManager
manager = ConfigManager.__new__(ConfigManager)
manager.config = {}
manager.set_sound_effects_tone("off")
assert manager.get_sound_effects_tone() == "off"
manager.set_sound_effects_tone("lift")
assert manager.get_sound_effects_tone() == "lift"
class TestConfigManagerUpdateDictRecursive:
"""Tests for _update_dict_recursive method."""
+21
View File
@@ -797,6 +797,27 @@ class TestLanguageComboSearch(unittest.TestCase):
self.assertIn("def _commit_or_restore_language_entry", source_code)
self.assertIn("Type to search, or pick from the list", source_code)
def test_dictation_tone_picker_in_source(self):
import os
source_path = os.path.join(
os.path.dirname(__file__),
"..",
"src",
"vocalinux",
"ui",
"settings_dialog.py",
)
with open(source_path, "r") as f:
source_code = f.read()
self.assertIn("SOUND_EFFECT_TONES", source_code)
self.assertIn('title="Dictation Tone"', source_code)
self.assertIn("def _on_preview_tone_clicked", source_code)
self.assertIn("preview_tone(tone_id)", source_code)
self.assertNotIn("fifth", source_code)
self.assertNotIn("01-linux-glide", source_code)
class TestSettingsSearch(unittest.TestCase):
"""Test cases for the settings search feature."""