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:
+127
-12
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user