diff --git a/resources/sounds/chirp_start.wav b/resources/sounds/chirp_start.wav new file mode 100644 index 0000000..9f15de4 Binary files /dev/null and b/resources/sounds/chirp_start.wav differ diff --git a/resources/sounds/chirp_stop.wav b/resources/sounds/chirp_stop.wav new file mode 100644 index 0000000..ccf0b70 Binary files /dev/null and b/resources/sounds/chirp_stop.wav differ diff --git a/resources/sounds/drop_start.wav b/resources/sounds/drop_start.wav new file mode 100644 index 0000000..f4397ad Binary files /dev/null and b/resources/sounds/drop_start.wav differ diff --git a/resources/sounds/drop_stop.wav b/resources/sounds/drop_stop.wav new file mode 100644 index 0000000..42eeda0 Binary files /dev/null and b/resources/sounds/drop_stop.wav differ diff --git a/resources/sounds/ember_start.wav b/resources/sounds/ember_start.wav new file mode 100644 index 0000000..8a01b4a Binary files /dev/null and b/resources/sounds/ember_start.wav differ diff --git a/resources/sounds/ember_stop.wav b/resources/sounds/ember_stop.wav new file mode 100644 index 0000000..a67f42c Binary files /dev/null and b/resources/sounds/ember_stop.wav differ diff --git a/resources/sounds/flick_start.wav b/resources/sounds/flick_start.wav new file mode 100644 index 0000000..40f4efe Binary files /dev/null and b/resources/sounds/flick_start.wav differ diff --git a/resources/sounds/flick_stop.wav b/resources/sounds/flick_stop.wav new file mode 100644 index 0000000..49b9193 Binary files /dev/null and b/resources/sounds/flick_stop.wav differ diff --git a/resources/sounds/glass_start.wav b/resources/sounds/glass_start.wav new file mode 100644 index 0000000..3f7fa81 Binary files /dev/null and b/resources/sounds/glass_start.wav differ diff --git a/resources/sounds/glass_stop.wav b/resources/sounds/glass_stop.wav new file mode 100644 index 0000000..1fc7222 Binary files /dev/null and b/resources/sounds/glass_stop.wav differ diff --git a/resources/sounds/lift_start.wav b/resources/sounds/lift_start.wav new file mode 100644 index 0000000..72865f2 Binary files /dev/null and b/resources/sounds/lift_start.wav differ diff --git a/resources/sounds/lift_stop.wav b/resources/sounds/lift_stop.wav new file mode 100644 index 0000000..ac16a46 Binary files /dev/null and b/resources/sounds/lift_stop.wav differ diff --git a/resources/sounds/scale_start.wav b/resources/sounds/scale_start.wav new file mode 100644 index 0000000..bf3619e Binary files /dev/null and b/resources/sounds/scale_start.wav differ diff --git a/resources/sounds/scale_stop.wav b/resources/sounds/scale_stop.wav new file mode 100644 index 0000000..3e43a18 Binary files /dev/null and b/resources/sounds/scale_stop.wav differ diff --git a/resources/sounds/soft_start.wav b/resources/sounds/soft_start.wav new file mode 100644 index 0000000..4390096 Binary files /dev/null and b/resources/sounds/soft_start.wav differ diff --git a/resources/sounds/soft_stop.wav b/resources/sounds/soft_stop.wav new file mode 100644 index 0000000..2ac6fa2 Binary files /dev/null and b/resources/sounds/soft_stop.wav differ diff --git a/resources/sounds/step_start.wav b/resources/sounds/step_start.wav new file mode 100644 index 0000000..2cbce59 Binary files /dev/null and b/resources/sounds/step_start.wav differ diff --git a/resources/sounds/step_stop.wav b/resources/sounds/step_stop.wav new file mode 100644 index 0000000..aba0abc Binary files /dev/null and b/resources/sounds/step_stop.wav differ diff --git a/resources/sounds/voca_start.wav b/resources/sounds/voca_start.wav new file mode 100644 index 0000000..1b6d540 Binary files /dev/null and b/resources/sounds/voca_start.wav differ diff --git a/resources/sounds/voca_stop.wav b/resources/sounds/voca_stop.wav new file mode 100644 index 0000000..b184832 Binary files /dev/null and b/resources/sounds/voca_stop.wav differ diff --git a/scripts/generate_sounds.py b/scripts/generate_sounds.py index 596925a..deae7b0 100644 --- a/scripts/generate_sounds.py +++ b/scripts/generate_sounds.py @@ -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__": diff --git a/src/vocalinux/resources/sounds/chirp_start.wav b/src/vocalinux/resources/sounds/chirp_start.wav new file mode 100644 index 0000000..9f15de4 Binary files /dev/null and b/src/vocalinux/resources/sounds/chirp_start.wav differ diff --git a/src/vocalinux/resources/sounds/chirp_stop.wav b/src/vocalinux/resources/sounds/chirp_stop.wav new file mode 100644 index 0000000..ccf0b70 Binary files /dev/null and b/src/vocalinux/resources/sounds/chirp_stop.wav differ diff --git a/src/vocalinux/resources/sounds/drop_start.wav b/src/vocalinux/resources/sounds/drop_start.wav new file mode 100644 index 0000000..f4397ad Binary files /dev/null and b/src/vocalinux/resources/sounds/drop_start.wav differ diff --git a/src/vocalinux/resources/sounds/drop_stop.wav b/src/vocalinux/resources/sounds/drop_stop.wav new file mode 100644 index 0000000..42eeda0 Binary files /dev/null and b/src/vocalinux/resources/sounds/drop_stop.wav differ diff --git a/src/vocalinux/resources/sounds/ember_start.wav b/src/vocalinux/resources/sounds/ember_start.wav new file mode 100644 index 0000000..8a01b4a Binary files /dev/null and b/src/vocalinux/resources/sounds/ember_start.wav differ diff --git a/src/vocalinux/resources/sounds/ember_stop.wav b/src/vocalinux/resources/sounds/ember_stop.wav new file mode 100644 index 0000000..a67f42c Binary files /dev/null and b/src/vocalinux/resources/sounds/ember_stop.wav differ diff --git a/src/vocalinux/resources/sounds/flick_start.wav b/src/vocalinux/resources/sounds/flick_start.wav new file mode 100644 index 0000000..40f4efe Binary files /dev/null and b/src/vocalinux/resources/sounds/flick_start.wav differ diff --git a/src/vocalinux/resources/sounds/flick_stop.wav b/src/vocalinux/resources/sounds/flick_stop.wav new file mode 100644 index 0000000..49b9193 Binary files /dev/null and b/src/vocalinux/resources/sounds/flick_stop.wav differ diff --git a/src/vocalinux/resources/sounds/glass_start.wav b/src/vocalinux/resources/sounds/glass_start.wav new file mode 100644 index 0000000..3f7fa81 Binary files /dev/null and b/src/vocalinux/resources/sounds/glass_start.wav differ diff --git a/src/vocalinux/resources/sounds/glass_stop.wav b/src/vocalinux/resources/sounds/glass_stop.wav new file mode 100644 index 0000000..1fc7222 Binary files /dev/null and b/src/vocalinux/resources/sounds/glass_stop.wav differ diff --git a/src/vocalinux/resources/sounds/lift_start.wav b/src/vocalinux/resources/sounds/lift_start.wav new file mode 100644 index 0000000..72865f2 Binary files /dev/null and b/src/vocalinux/resources/sounds/lift_start.wav differ diff --git a/src/vocalinux/resources/sounds/lift_stop.wav b/src/vocalinux/resources/sounds/lift_stop.wav new file mode 100644 index 0000000..ac16a46 Binary files /dev/null and b/src/vocalinux/resources/sounds/lift_stop.wav differ diff --git a/src/vocalinux/resources/sounds/scale_start.wav b/src/vocalinux/resources/sounds/scale_start.wav new file mode 100644 index 0000000..bf3619e Binary files /dev/null and b/src/vocalinux/resources/sounds/scale_start.wav differ diff --git a/src/vocalinux/resources/sounds/scale_stop.wav b/src/vocalinux/resources/sounds/scale_stop.wav new file mode 100644 index 0000000..3e43a18 Binary files /dev/null and b/src/vocalinux/resources/sounds/scale_stop.wav differ diff --git a/src/vocalinux/resources/sounds/soft_start.wav b/src/vocalinux/resources/sounds/soft_start.wav new file mode 100644 index 0000000..4390096 Binary files /dev/null and b/src/vocalinux/resources/sounds/soft_start.wav differ diff --git a/src/vocalinux/resources/sounds/soft_stop.wav b/src/vocalinux/resources/sounds/soft_stop.wav new file mode 100644 index 0000000..2ac6fa2 Binary files /dev/null and b/src/vocalinux/resources/sounds/soft_stop.wav differ diff --git a/src/vocalinux/resources/sounds/step_start.wav b/src/vocalinux/resources/sounds/step_start.wav new file mode 100644 index 0000000..2cbce59 Binary files /dev/null and b/src/vocalinux/resources/sounds/step_start.wav differ diff --git a/src/vocalinux/resources/sounds/step_stop.wav b/src/vocalinux/resources/sounds/step_stop.wav new file mode 100644 index 0000000..aba0abc Binary files /dev/null and b/src/vocalinux/resources/sounds/step_stop.wav differ diff --git a/src/vocalinux/resources/sounds/voca_start.wav b/src/vocalinux/resources/sounds/voca_start.wav new file mode 100644 index 0000000..1b6d540 Binary files /dev/null and b/src/vocalinux/resources/sounds/voca_start.wav differ diff --git a/src/vocalinux/resources/sounds/voca_stop.wav b/src/vocalinux/resources/sounds/voca_stop.wav new file mode 100644 index 0000000..b184832 Binary files /dev/null and b/src/vocalinux/resources/sounds/voca_stop.wav differ diff --git a/src/vocalinux/ui/audio_feedback.py b/src/vocalinux/ui/audio_feedback.py index 42e4eea..d78d5d0 100644 --- a/src/vocalinux/ui/audio_feedback.py +++ b/src/vocalinux/ui/audio_feedback.py @@ -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 diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index e8b01bd..add4728 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -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. diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 9ded10d..c8ba4aa 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -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)) diff --git a/tests/test_audio_feedback.py b/tests/test_audio_feedback.py index 3b1359a..43417c3 100644 --- a/tests/test_audio_feedback.py +++ b/tests/test_audio_feedback.py @@ -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")) diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 142c9c3..b9f639a 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -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() diff --git a/tests/test_config_manager_ext.py b/tests/test_config_manager_ext.py index 3594e91..0d7078a 100644 --- a/tests/test_config_manager_ext.py +++ b/tests/test_config_manager_ext.py @@ -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.""" diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 49b69eb..7355d28 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -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."""