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.
+215 -83
View File
@@ -1,33 +1,51 @@
#!/usr/bin/env python3
"""
Sound generation utility for Vocalinux.
"""Generate Vocalinux notification WAVs.
This script generates the notification sounds used by Vocalinux for:
- Start recording (ascending pitch glide)
- Stop recording (descending pitch glide)
- Error notification (lower descending glide)
Writes 16-bit PCM mono files at 44.1 kHz into resources/sounds/:
The sounds use smooth pitch glides with pure sine waves and soft envelopes
to create pleasant, headphone-friendly audio feedback.
- start_recording.wav / stop_recording.wav / error.wav: the original Linux
pair. Kept on disk; not used as the implicit default anymore.
- {id}_start.wav / {id}_stop.wav for each family tone except Off.
Usage:
python scripts/generate_sounds.py
The generated sounds will be placed in resources/sounds/
Design Philosophy:
- Pure sine waves (no harsh harmonics)
- Smoothstep interpolation for pitch transitions
- Soft attack/decay envelopes
- Lower frequencies (F4-A4 range) for warmth
- Consistent glide aesthetic across all sounds
"""
from __future__ import annotations
import math
import os
import shutil
import struct
import wave
SAMPLE_RATE = 44100
# Named pitches used by the family catalog.
G2 = 98.00
C3 = 130.81
D3 = 146.83
F3 = 174.61
G3 = 196.00
C4 = 261.63
E4 = 329.63
F4 = 349.23
G4 = 392.00
A4 = 440.00
C5 = 523.25
A5 = 880.00
C6 = 1046.50
E7 = 2637.02
C7 = 2093.00
def _smoothstep(progress: float) -> float:
return progress * progress * (3.0 - 2.0 * progress)
def _sine_envelope(progress: float) -> float:
return (math.sin(math.pi * progress) ** 2) * math.exp(-0.5 * progress)
def generate_glide_tone(
filename: str,
@@ -35,99 +53,213 @@ def generate_glide_tone(
freq_end: float,
duration: float = 0.6,
amplitude: float = 0.16,
sample_rate: int = 44100,
sample_rate: int = SAMPLE_RATE,
) -> None:
"""
Generate a smooth pitch glide tone.
Args:
filename: Output WAV file path
freq_start: Starting frequency in Hz
freq_end: Ending frequency in Hz
duration: Duration in seconds
amplitude: Volume (0.0 to 1.0)
sample_rate: Audio sample rate
"""
"""Generate a smooth pitch glide tone (original Linux pair recipe)."""
num_samples = int(sample_rate * duration)
samples = []
for i in range(num_samples):
t = i / sample_rate
progress = i / num_samples
# Ultra-soft envelope: sine-squared with exponential decay
envelope = (math.sin(math.pi * progress) ** 2) * math.exp(-0.5 * progress)
# Smooth pitch glide using smoothstep interpolation
# This creates buttery smooth transitions with no abrupt changes
glide = progress ** 2 * (3 - 2 * progress)
envelope = _sine_envelope(progress)
glide = _smoothstep(progress)
freq_current = freq_start + (freq_end - freq_start) * glide
# Generate pure sine wave
phase = 2 * math.pi * freq_current * t
sample = amplitude * envelope * math.sin(phase)
samples.append(sample)
samples.append(amplitude * envelope * math.sin(phase))
# Convert to 16-bit PCM
wav_samples = [int(sample * 32767) for sample in samples]
_write_wav(filename, samples, sample_rate)
# Write WAV file
def _write_wav(filename: str, samples: list[float], sample_rate: int = SAMPLE_RATE) -> None:
wav_samples = [int(max(-1.0, min(1.0, sample)) * 32767) for sample in samples]
os.makedirs(os.path.dirname(filename) or ".", exist_ok=True)
with wave.open(filename, "w") as wav_file:
wav_file.setnchannels(1) # Mono
wav_file.setsampwidth(2) # 16-bit
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(sample_rate)
wav_file.writeframes(struct.pack("<" + "h" * len(wav_samples), *wav_samples))
print(f"Generated: {filename}")
def main():
"""Generate all Vocalinux notification sounds."""
# Determine output directory
def _tone_burst(
freq: float,
duration: float,
amplitude: float = 0.12,
decay: float = 8.0,
) -> list[float]:
n = int(SAMPLE_RATE * duration)
samples = []
for i in range(n):
t = i / SAMPLE_RATE
progress = i / max(n - 1, 1)
attack = min(1.0, i / max(int(0.004 * SAMPLE_RATE), 1))
env = attack * math.exp(-decay * t) * (1.0 - 0.15 * progress)
samples.append(amplitude * env * math.sin(2 * math.pi * freq * t))
return samples
def _silence(duration: float) -> list[float]:
return [0.0] * int(SAMPLE_RATE * duration)
def generate_ticks(
filename: str,
freqs: tuple[float, ...],
tick_duration: float = 0.07,
gap: float = 0.045,
amplitude: float = 0.11,
decay: float = 14.0,
) -> None:
samples: list[float] = []
for index, freq in enumerate(freqs):
if index:
samples.extend(_silence(gap))
samples.extend(_tone_burst(freq, tick_duration, amplitude, decay))
_write_wav(filename, samples)
def generate_fifth_swell(
filename: str,
low: float,
high: float,
duration: float = 0.42,
amplitude: float = 0.13,
opening: bool = True,
) -> None:
"""Open-fifth swell. Start opens C4→G4; stop closes G4→C4."""
n = int(SAMPLE_RATE * duration)
samples = []
phase_low = 0.0
phase_high = 0.0
dt = 1.0 / SAMPLE_RATE
for i in range(n):
progress = i / max(n - 1, 1)
env = math.sin(math.pi * progress) ** 2
blend = _smoothstep(progress)
if opening:
low_amt = 1.0
high_amt = blend
else:
low_amt = 1.0
high_amt = 1.0 - blend
sample = (
amplitude
* env
* (low_amt * math.sin(phase_low) + 0.85 * high_amt * math.sin(phase_high))
)
samples.append(sample)
phase_low += 2 * math.pi * low * dt
phase_high += 2 * math.pi * high * dt
_write_wav(filename, samples)
def generate_ping(
filename: str, freq: float, duration: float = 0.32, amplitude: float = 0.12
) -> None:
n = int(SAMPLE_RATE * duration)
samples = []
for i in range(n):
t = i / SAMPLE_RATE
env = math.exp(-14.0 * t)
samples.append(amplitude * env * math.sin(2 * math.pi * freq * t))
_write_wav(filename, samples)
def generate_scale(
filename: str,
freqs: tuple[float, ...],
note_duration: float = 0.085,
gap: float = 0.02,
amplitude: float = 0.12,
) -> None:
generate_ticks(filename, freqs, note_duration, gap, amplitude, decay=9.0)
def _maybe_write_original(path: str, writer) -> None:
if os.path.exists(path):
print(f"Keeping existing: {path}")
return
writer()
def main() -> None:
script_dir = os.path.dirname(os.path.abspath(__file__))
repo_root = os.path.dirname(script_dir)
sounds_dir = os.path.join(repo_root, "resources", "sounds")
os.makedirs(sounds_dir, exist_ok=True)
print(f"Generating sounds in: {sounds_dir}\n")
# START: Ascending F4→A4 (positive, uplifting)
print("Generating start_recording.wav (ascending F4→A4)...")
generate_glide_tone(
os.path.join(sounds_dir, "start_recording.wav"),
freq_start=349.23, # F4
freq_end=440.00, # A4
duration=0.6,
amplitude=0.16,
start_path = os.path.join(sounds_dir, "start_recording.wav")
stop_path = os.path.join(sounds_dir, "stop_recording.wav")
error_path = os.path.join(sounds_dir, "error.wav")
_maybe_write_original(
start_path,
lambda: generate_glide_tone(start_path, F4, A4, duration=0.6, amplitude=0.16),
)
_maybe_write_original(
stop_path,
lambda: generate_glide_tone(stop_path, A4, F4, duration=0.6, amplitude=0.16),
)
_maybe_write_original(
error_path,
lambda: generate_glide_tone(error_path, E4, C4, duration=0.7, amplitude=0.14),
)
# STOP: Descending A4→F4 (resolution, completion)
print("Generating stop_recording.wav (descending A4→F4)...")
generate_glide_tone(
os.path.join(sounds_dir, "stop_recording.wav"),
freq_start=440.00, # A4
freq_end=349.23, # F4
duration=0.6,
amplitude=0.16,
)
# lift: Linux-family F4→A4 sine glide (own files; does not replace the originals)
generate_glide_tone(os.path.join(sounds_dir, "lift_start.wav"), F4, A4, 0.48, 0.16)
generate_glide_tone(os.path.join(sounds_dir, "lift_stop.wav"), A4, F4, 0.48, 0.16)
# ERROR: Lower descending E4→C4 (sad, concerning but gentle)
print("Generating error.wav (descending E4→C4)...")
generate_glide_tone(
os.path.join(sounds_dir, "error.wav"),
freq_start=329.63, # E4
freq_end=261.63, # C4
duration=0.7,
amplitude=0.14, # Slightly quieter
)
# flick: same interval, shorter
generate_glide_tone(os.path.join(sounds_dir, "flick_start.wav"), F4, A4, 0.18, 0.15)
generate_glide_tone(os.path.join(sounds_dir, "flick_stop.wav"), A4, F4, 0.18, 0.15)
print("\n✓ All sounds generated successfully!")
print("\nSound characteristics:")
print("- Pure sine waves (headphone-friendly)")
print("- Smoothstep pitch interpolation (no jumps)")
print("- Soft envelopes (sine-squared with decay)")
print("- Lower frequencies (F4-A4 range, warm and pleasant)")
print("- Consistent glide aesthetic")
# ember: low warm G3→C4
generate_glide_tone(os.path.join(sounds_dir, "ember_start.wav"), G3, C4, 0.55, 0.15)
generate_glide_tone(os.path.join(sounds_dir, "ember_stop.wav"), C4, G3, 0.55, 0.15)
# step: two soft C/E ticks
generate_ticks(os.path.join(sounds_dir, "step_start.wav"), (C4, E4), 0.07, 0.05, 0.11)
generate_ticks(os.path.join(sounds_dir, "step_stop.wav"), (E4, C4), 0.07, 0.05, 0.11)
# voca: open fifth swell (id is voca, not fifth)
generate_fifth_swell(os.path.join(sounds_dir, "voca_start.wav"), C4, G4, 0.44, 0.14, True)
generate_fifth_swell(os.path.join(sounds_dir, "voca_stop.wav"), C4, G4, 0.40, 0.13, False)
# soft: very short muted ticks
generate_ticks(os.path.join(sounds_dir, "soft_start.wav"), (C5,), 0.05, 0.0, 0.07, decay=22.0)
generate_ticks(os.path.join(sounds_dir, "soft_stop.wav"), (A4,), 0.055, 0.0, 0.07, decay=22.0)
# chirp: light high chirp
generate_glide_tone(os.path.join(sounds_dir, "chirp_start.wav"), A5, C6, 0.09, 0.10)
generate_glide_tone(os.path.join(sounds_dir, "chirp_stop.wav"), C6, A5, 0.10, 0.10)
# scale: pentatonic A4→C5
generate_scale(os.path.join(sounds_dir, "scale_start.wav"), (A4, C5), 0.09, 0.025, 0.12)
generate_scale(os.path.join(sounds_dir, "scale_stop.wav"), (C5, A4), 0.09, 0.025, 0.12)
# drop: low start, deeper stop
generate_glide_tone(os.path.join(sounds_dir, "drop_start.wav"), D3, F3, 0.40, 0.16)
generate_glide_tone(os.path.join(sounds_dir, "drop_stop.wav"), C3, G2, 0.48, 0.16)
# glass: thin glass ping
generate_ping(os.path.join(sounds_dir, "glass_start.wav"), E7, 0.30, 0.10)
generate_ping(os.path.join(sounds_dir, "glass_stop.wav"), C7, 0.34, 0.09)
# Package data lives in src/vocalinux/resources and is what ResourceManager
# prefers when running from a checkout. Keep both trees in sync.
packaged_dir = os.path.join(repo_root, "src", "vocalinux", "resources", "sounds")
os.makedirs(packaged_dir, exist_ok=True)
for name in os.listdir(sounds_dir):
if not name.endswith(".wav"):
continue
if name in {"start_recording.wav", "stop_recording.wav", "error.wav"}:
continue
shutil.copy2(os.path.join(sounds_dir, name), os.path.join(packaged_dir, name))
print(f"Copied: {os.path.join(packaged_dir, name)}")
print("\nCatalog pairs written. Original start/stop/error files were left in place.")
if __name__ == "__main__":
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))
+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."""