Files
voculinux/scripts/generate_sounds.py
T
Jatin K MalikandGitHub fd23ef0d9d 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.
2026-08-18 17:46:55 -07:00

267 lines
8.6 KiB
Python

#!/usr/bin/env python3
"""Generate Vocalinux notification WAVs.
Writes 16-bit PCM mono files at 44.1 kHz into resources/sounds/:
- 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
"""
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,
freq_start: float,
freq_end: float,
duration: float = 0.6,
amplitude: float = 0.16,
sample_rate: int = SAMPLE_RATE,
) -> None:
"""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
envelope = _sine_envelope(progress)
glide = _smoothstep(progress)
freq_current = freq_start + (freq_end - freq_start) * glide
phase = 2 * math.pi * freq_current * t
samples.append(amplitude * envelope * math.sin(phase))
_write_wav(filename, samples, sample_rate)
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)
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 _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_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),
)
# 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)
# 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)
# 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__":
main()