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.
+215
-83
@@ -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.
@@ -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))
|
||||
|
||||
+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