chime.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""The wake-acknowledgment chime: a short cue played the instant the wake word
fires, before the request is captured, to tell the user the mic is now listening
(issue #41).
Pure DSP -- numpy in, int16 PCM out -- with no audio backend, on purpose. The two
live loops play the cue through different outputs (sounddevice on the desktop loop,
ALSA `aplay` on the Pi, which has no sounddevice), so the generator must not import
either; it just produces the samples and a WAV both loops can play. That is also why
this is its own module rather than living in audio.py, which imports sounddevice at
module top and so cannot be imported on the Pi driver.
The cue is two ascending tones with raised-cosine edge fades. Rising pitch is the
near-universal "ready/listening" signal; the fades ramp each tone through zero at its
edges, so there is no step discontinuity and no click. The design is a set of
constants, so retuning the sound is a one-line change (a two-way door) -- swap the
notes, durations, or amplitude below.
"""
from __future__ import annotations
import os
import tempfile
import numpy as np
import soundfile as sf
SR = 16000 # 16 kHz mono int16, matching the pipeline's audio format.
# Cue design. Each note is (frequency Hz, duration ms, amplitude 0..1). The notes
# rise in pitch. Amplitude is modest so the cue is a soft prompt, not a jolt.
_NOTES: tuple[tuple[float, float, float], ...] = (
(880.0, 90.0, 0.25), # A5
(1318.0, 110.0, 0.25), # E6 -- a rising perfect fifth, the classic "ready" cue
)
_FADE_MS = 10.0 # raised-cosine fade in/out per tone; long enough to kill clicks
# A stable cache path in the system temp dir, so both live loops (and repeated turns)
# reuse one generated file instead of re-rendering it every wake.
_CACHE_WAV = os.path.join(tempfile.gettempdir(), "computah_wake_cue.wav")
def _tone(freq: float, ms: float, amp: float, fade_ms: float = _FADE_MS) -> np.ndarray:
"""One sine tone with raised-cosine fade in/out, as float in [-1, 1]."""
n = int(SR * ms / 1000.0)
t = np.arange(n) / SR
wave = amp * np.sin(2 * np.pi * freq * t)
f = int(SR * fade_ms / 1000.0)
if f > 0 and 2 * f <= n:
ramp = 0.5 * (1.0 - np.cos(np.linspace(0.0, np.pi, f))) # 0 -> 1
wave[:f] *= ramp
wave[-f:] *= ramp[::-1]
return wave
def wake_cue_pcm() -> np.ndarray:
"""The cue as 16 kHz mono int16 PCM: the rising tones, concatenated."""
sig = np.concatenate([_tone(freq, ms, amp) for freq, ms, amp in _NOTES])
return (np.clip(sig, -1.0, 1.0) * 32767.0).astype(np.int16)
def wake_cue_wav(path: str | None = None) -> str:
"""Path to a WAV of the cue, rendering it once if absent. With no path, uses a
cached file in the temp dir so both live loops and repeated turns reuse it.
Returns the path. Idempotent: an existing file is left untouched (so its mtime is
stable), since the cue is deterministic and never needs rewriting."""
path = path or _CACHE_WAV
if not os.path.exists(path):
sf.write(path, wake_cue_pcm(), SR, subtype="PCM_16")
return path
if __name__ == "__main__":
out = wake_cue_wav()
pcm = wake_cue_pcm()
print(f"wake cue: {pcm.size} samples ({pcm.size / SR * 1000:.0f} ms) -> {out}")