Add Pink Floyd tone kit and conversation-first listen

A telephone-themed bookend set, tuned by ear:

- speak: heartbeat entry ("Speak to Me" woven with warm MF tones), soft-chord
  exit (pure sines, easy on the ears on repetition)
- listen: mf-dial start (the genuine Young Lust R1 operator routing sequence,
  KP 0-4-4-1-8-3-1 ST) and machine end (Welcome to the Machine throb)
- mf-listen/mf-done gentler alternatives, and a call-waiting blip

listen() is now conversation-first: wait_for_silence defaults on with a 30s
cap, and the "go" tone plays only after the mic is live (warmup) so the first
word isn't clipped. VAD defaults tuned (aggressiveness 3, 2200ms silence,
400ms min-speech) after live testing showed the old values cut replies off on
brief background transients. Adds webrtcvad-wheels for the VAD path.
This commit is contained in:
Ryan Malloy 2026-07-03 20:08:14 -06:00
parent 5db7876dac
commit 3c4e06aa64
5 changed files with 675 additions and 5 deletions

View File

@ -19,6 +19,10 @@ dependencies = [
"snac>=1.2.1",
"soundfile",
"torch",
# webrtcvad-wheels is a drop-in fork of webrtcvad that ships pre-built
# wheels for Python 3.12/3.13 — the original requires gcc to build from
# source, which the slim Docker image doesn't have.
"webrtcvad-wheels",
"wyoming>=1.8.0",
]

View File

@ -186,11 +186,153 @@ async def _force_pwplay_volume_100() -> None:
return
async def record_audio_until_silence(
out_path: Path,
silence_threshold_ms: int = 1500,
max_seconds: float = 30.0,
aggressiveness: int = 3,
source: str | None = None,
min_speech_ms: int = 400,
ready_tone: Path | None = None,
warmup_ms: int = 150,
) -> dict:
"""Record from mic, terminate when N ms of consecutive silence detected.
Streams pw-record's raw s16 PCM stdout in 30 ms frames (480 samples =
960 bytes at 16 kHz mono), feeds each frame to webrtcvad.is_speech().
Tracks consecutive non-speech frames; when their cumulative duration
exceeds silence_threshold_ms (AND at least min_speech_ms of speech has
been observed first), terminates pw-record and writes the WAV.
The min_speech_ms guard prevents premature termination from the initial
"user hasn't started yet" silence we only count silence as end-of-speech
AFTER speech has been seen.
Args:
out_path: where to write the final WAV.
silence_threshold_ms: pause length that counts as "user stopped".
max_seconds: absolute cap to prevent runaway recordings.
aggressiveness: webrtcvad mode 0-3 (0 lax, 3 strict). 2 is the
balance between rejecting noise and accepting soft speech.
source: PipeWire source name, None = system default.
min_speech_ms: minimum speech observed before silence can end recording.
Returns dict with: bytes_written, speech_ms, silence_ms, terminated_by
("silence" | "max_duration" | "no_speech").
"""
import webrtcvad
SAMPLE_RATE = 16000 # webrtcvad requires 8000, 16000, 32000, or 48000
FRAME_MS = 30 # webrtcvad accepts 10/20/30 ms; 30 = lowest CPU
FRAME_BYTES = SAMPLE_RATE * 2 * FRAME_MS // 1000 # 960 bytes at 16k mono s16
vad = webrtcvad.Vad(aggressiveness)
cmd = [
"pw-record",
"--rate", str(SAMPLE_RATE),
"--channels", "1",
"--format", "s16",
]
if source:
cmd.extend(["--target", source])
cmd.append("-") # stdout
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
# Cue the person only AFTER the mic is live (see warmup rationale above), so
# the first word isn't clipped by pw-record's stream startup. The beep is
# captured as leading audio but VAD skips it (pure tone != speech) and the
# min_speech_ms guard below keeps it from ending the recording.
if ready_tone is not None:
await asyncio.sleep(warmup_ms / 1000)
try:
await play_audio(ready_tone, expected_seconds=0.3)
except Exception:
pass # tone failure is non-fatal — keep recording
pcm_buf = bytearray()
leftover = b""
speech_frames = 0
silence_frames = 0
silence_run_frames = 0 # consecutive silence frames since last speech
terminated_by = "max_duration"
silence_frame_threshold = silence_threshold_ms // FRAME_MS
min_speech_frames = min_speech_ms // FRAME_MS
max_frames = int(max_seconds * 1000) // FRAME_MS
try:
for _ in range(max_frames):
# Read enough bytes for one VAD frame. pw-record may give us
# smaller chunks; accumulate leftover across reads.
while len(leftover) < FRAME_BYTES:
chunk = await proc.stdout.read(FRAME_BYTES - len(leftover))
if not chunk:
terminated_by = "no_speech"
break
leftover += chunk
else:
# Got a complete frame
frame = bytes(leftover[:FRAME_BYTES])
leftover = leftover[FRAME_BYTES:]
pcm_buf.extend(frame)
if vad.is_speech(frame, SAMPLE_RATE):
speech_frames += 1
silence_run_frames = 0
else:
silence_frames += 1
silence_run_frames += 1
# End-of-speech: enough silence run AND we've heard speech
if (silence_run_frames >= silence_frame_threshold
and speech_frames >= min_speech_frames):
terminated_by = "silence"
break
continue
# The inner read returned no data — break outer loop
break
finally:
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), timeout=2.0)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
if not pcm_buf:
raise PlaybackError(
f"VAD recording captured no audio. Check source '{source or 'default'}'."
)
# Write the accumulated PCM as a WAV file.
write_wav_from_pcm(
bytes(pcm_buf),
sample_rate=SAMPLE_RATE,
sample_width=2,
channels=1,
path=out_path,
)
return {
"bytes_written": len(pcm_buf),
"speech_ms": speech_frames * FRAME_MS,
"silence_ms": silence_frames * FRAME_MS,
"terminated_by": terminated_by,
"duration_ms": (speech_frames + silence_frames) * FRAME_MS,
}
async def record_audio(
out_path: Path,
duration_seconds: float,
source: str | None = None,
sample_rate: int = 16000,
ready_tone: Path | None = None,
warmup_ms: int = 150,
) -> Path:
"""Capture audio from the host mic via pw-record (PipeWire socket bind mount).
@ -222,6 +364,15 @@ async def record_audio(
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
# Cue the person only AFTER the mic is live so the first word isn't clipped
# by pw-record's stream startup (the beep bleeds harmlessly into the head of
# the WAV; Parakeet ignores it). The duration timeout starts after the cue.
if ready_tone is not None:
await asyncio.sleep(warmup_ms / 1000)
try:
await play_audio(ready_tone, expected_seconds=0.3)
except Exception:
pass # tone failure is non-fatal — keep recording
try:
# pw-record never exits on its own — wait the desired duration then term.
await asyncio.wait_for(proc.wait(), timeout=duration_seconds)

View File

@ -42,7 +42,29 @@ class Settings(BaseSettings):
# Voice identity (project-aware voice assignment)
voice_identity: bool = True
# Legacy flag — when True, forces announce_mode="always" (kept for back-compat).
announce_project: bool = False
# Secretary-style project announcements before an utterance plays:
# "secretary" — announce only when the speaker changes from the last
# project that spoke, or when the same project returns after
# a lull (see reintroduce_after_seconds). The default.
# "always" — announce every utterance with its project name.
# "off" — never announce.
# The announcement is a short "<project>." preamble synthesized in the
# project's own assigned voice and played by the queue consumer right
# before the utterance, so the listener learns which voice maps to which
# project. The decision is made at play time (after urgent reordering).
announce_mode: str = "secretary"
# Same project speaking again after this many seconds of overall silence
# gets re-introduced under "secretary" mode. Speaker *changes* always
# announce regardless of this value.
reintroduce_after_seconds: float = 120.0
# Dedicated voice for the secretary's project announcements. It is reserved:
# excluded from the project auto-assignment pool so no project ever sounds
# like the secretary. Announcements always synthesize through Kokoro (fast,
# and identical every time) regardless of the speaking engine, so this must
# be a Kokoro voice name. An explicit voice= request can still use it.
secretary_voice: str = "bf_emma"
# Comma-separated prefixes for auto-assignment pool (English voices)
voice_identity_prefixes: str = "af_,am_,bf_,bm_,ef_,em_"
# Voices available explicitly but excluded from auto-assignment (e.g. whispery)
@ -50,16 +72,38 @@ class Settings(BaseSettings):
# JSON file to persist project->voice assignments across restarts
voice_identity_file: str = ""
# Entry tone before speech: "chirp", "apollo", "none", or path to custom WAV
entry_tone: str = "chirp"
# Entry tone before speech: "heartbeat" (Speak to Me heartbeat woven with
# warm MF telephone tones), or "chirp"/"apollo"/"none"/path to custom WAV
entry_tone: str = "heartbeat"
# Exit tone after speech: "roger", "quindar-out", "none", or path to custom WAV
# Exit tone after speech: "soft-chord" (mellow resolving dyad, ear-friendly),
# or "roger"/"quindar-out"/"none"/path to custom WAV.
# When more items are queued, plays "standby" (ascending blip) instead
exit_tone: str = "roger"
exit_tone: str = "soft-chord"
# Cancel tone: "scratch", "reverse-roger", "none", or path to custom WAV
cancel_tone: str = "scratch"
# Call-waiting tone: a brief blip mixed OVER the currently-playing message
# when a DIFFERENT project's message joins the queue, so the listener knows
# someone else is waiting. Fires at most once per playing turn (not once per
# arriving message). "none" disables; any tone name or path to a custom WAV.
call_waiting_tone: str = "call-waiting"
# Bookend tones for listen() — a Pink Floyd telephone arc: mf-dial (the
# genuine Young Lust R1 operator routing sequence) once the mic is live, as
# if placing a call to the user; machine (the Welcome to the Machine throb)
# after recording stops, a warm "connected / got it". "none" disables; any
# tone name from tones.py or a path to a custom WAV. (mf-listen / mf-done
# remain available as the gentler alternative.)
listen_start_tone: str = "mf-dial"
listen_end_tone: str = "machine"
# Mic warm-up before the "go" tone sounds. The recorder is spawned, given
# this long for its PipeWire stream to go live, THEN the person is cued — so
# the first word isn't clipped by capture-stream startup. The beep is leading
# non-speech that VAD and Parakeet both ignore.
listen_warmup_ms: int = 150
# Media ducking
duck_media: bool = True
duck_fade_out_ms: int = 500

View File

@ -110,6 +110,412 @@ def _build_reverse_roger() -> np.ndarray:
return np.concatenate(segments)
# -----------------------------------------------------------------------------
# Natural-feeling tones — softer alternatives to the telephony beeps above.
# Tuned for in-ear / headset use where the radio-style tones feel aggressive.
# -----------------------------------------------------------------------------
def _build_bell_soft() -> np.ndarray:
"""Struck bell — 880 Hz fundamental + slightly-inharmonic partials,
exponential decay, ~400ms. Like a small finger cymbal or triangle.
"""
duration_ms = 400
n = int(SAMPLE_RATE * duration_ms / 1000)
t = np.arange(n, dtype=np.float32) / SAMPLE_RATE
f0 = 880.0
# Slight inharmonicity (the 2.01x, 3.02x) gives "real bell" warmth vs
# pure integer harmonics which sound synthesized.
samples = (
1.00 * np.sin(2 * np.pi * f0 * t)
+ 0.50 * np.sin(2 * np.pi * f0 * 2.01 * t)
+ 0.30 * np.sin(2 * np.pi * f0 * 3.02 * t)
+ 0.15 * np.sin(2 * np.pi * f0 * 4.10 * t)
)
envelope = np.exp(-3.5 * t / (duration_ms / 1000))
# 1ms attack ramp to avoid the initial click
ramp_n = int(SAMPLE_RATE * 0.001)
envelope[:ramp_n] *= np.linspace(0, 1, ramp_n)
return samples * envelope
def _build_bell_deep() -> np.ndarray:
"""Deeper bell — 440 Hz fundamental, slower decay, ~500ms.
Resolving "done" feel; pairs well with bell-soft as start/end.
"""
duration_ms = 500
n = int(SAMPLE_RATE * duration_ms / 1000)
t = np.arange(n, dtype=np.float32) / SAMPLE_RATE
f0 = 440.0
samples = (
1.00 * np.sin(2 * np.pi * f0 * t)
+ 0.50 * np.sin(2 * np.pi * f0 * 2.01 * t)
+ 0.30 * np.sin(2 * np.pi * f0 * 3.02 * t)
)
envelope = np.exp(-3.0 * t / (duration_ms / 1000))
ramp_n = int(SAMPLE_RATE * 0.001)
envelope[:ramp_n] *= np.linspace(0, 1, ramp_n)
return samples * envelope
def _build_tap_wood() -> np.ndarray:
"""Wood block tap — 20ms bandpassed noise burst with sharp attack/decay.
Brief, percussive, almost zero sustain. Good "got it" close.
"""
duration_ms = 20
n = int(SAMPLE_RATE * duration_ms / 1000)
rng = np.random.default_rng(123)
noise = rng.standard_normal(n).astype(np.float32)
# Crude bandpass: high-pass via 1-sample diff, then short MA low-pass.
# Centers energy around 1.5-3 kHz, which reads as "wood" not "snare".
hp = np.diff(noise, prepend=0)
kernel = np.ones(4, dtype=np.float32) / 4
bandpass = np.convolve(hp, kernel, mode="same")
# Sharp exponential decay
envelope = np.exp(-80 * np.arange(n, dtype=np.float32) / SAMPLE_RATE)
return bandpass * envelope
def _build_water_drop() -> np.ndarray:
"""Single water drop — physically-informed "plink".
Real water drops produce two acoustic events:
1. Splash impact: 2-4ms broadband click (mid-high frequencies).
2. Air-bubble Helmholtz oscillation: a pitched tone whose frequency
RISES as the entrained bubble shrinks/escapes typically
7001100 Hz for a kitchen-sink-sized drop. NOT a descending
sweep (a common synth-tone mistake).
Total ~100ms with sharp decay. The combination of "splash click +
rising pitched ring" is what makes the brain hear it as water vs.
just a beep with reverb.
"""
SR = SAMPLE_RATE
rng = np.random.default_rng(789)
# ---- 1. splash impact: 3ms broadband click, mostly mid-high band ----
splash_n = int(SR * 0.003)
noise = rng.standard_normal(splash_n).astype(np.float32)
# Simple high-pass via 1-sample diff to push energy upward
splash = np.diff(noise, prepend=0.0)
splash_env = np.exp(-300 * np.arange(splash_n, dtype=np.float32) / SR)
splash = splash * splash_env * 0.6 # half-volume vs the ring
# ---- 2. bubble ring: 700→1100 Hz rising over 90ms ----
ring_ms = 90
ring_n = int(SR * ring_ms / 1000)
t = np.arange(ring_n, dtype=np.float32) / SR
f0, f1 = 700.0, 1100.0
# Quadratic chirp: phase = 2π·(f0·t + (f1-f0)/(2·T)·t²)
phase = 2 * np.pi * (f0 * t + (f1 - f0) / (2 * (ring_ms / 1000)) * t**2)
ring = np.sin(phase)
# Slight 2nd harmonic for body
ring += 0.25 * np.sin(2 * phase)
# Exponential decay
ring_env = np.exp(-25 * t)
# 1ms attack so the splice with splash doesn't click
ramp_n = int(SR * 0.001)
ring_env[:ramp_n] *= np.linspace(0, 1, ramp_n)
ring = ring * ring_env
return np.concatenate([splash, ring])
def _build_soft_pulse() -> np.ndarray:
"""Breath-like soft pulse — 440 Hz sine with raised-cosine envelope, ~80ms.
No attack click, no decay tail. Feels like a "heartbeat blip" or a quiet
ambient cue. Gentlest tone in the catalog, ideal for repeated triggers.
"""
duration_ms = 80
n = int(SAMPLE_RATE * duration_ms / 1000)
t = np.arange(n, dtype=np.float32) / SAMPLE_RATE
tone = np.sin(2 * np.pi * 440.0 * t)
# Raised-cosine envelope: 0→1→0 smoothly, no clicks at either end.
envelope = 0.5 * (1.0 - np.cos(2.0 * np.pi * np.arange(n, dtype=np.float32) / n))
return tone * envelope
def _build_hmm_up() -> np.ndarray:
"""Vocal-feeling "hm?" — two sine waves at vowel formants with rising
pitch. Reads as a soft questioning acknowledgement, like the system
just opened its ears. ~180ms.
"""
SR = SAMPLE_RATE
duration_ms = 180
n = int(SR * duration_ms / 1000)
t = np.arange(n, dtype=np.float32) / SR
# Pitch contour: starts at 220 Hz, rises to 280 Hz (questioning intonation)
pitch = 220.0 + (280.0 - 220.0) * t / (duration_ms / 1000)
# Cumulative phase for time-varying frequency
phase = 2 * np.pi * np.cumsum(pitch) / SR
# Vowel /ʌ/ approximated: fundamental + F1≈700Hz + F2≈1300Hz formants
# Approximated by adding harmonics at those frequencies.
fundamental = np.sin(phase)
# F1 and F2 as modulated by the pitch contour (rough formant approximation)
f1 = 0.4 * np.sin(2 * np.pi * 700.0 * t)
f2 = 0.2 * np.sin(2 * np.pi * 1300.0 * t)
samples = fundamental + f1 + f2
# Soft attack + slow decay (vocal envelope)
envelope = np.ones(n, dtype=np.float32)
attack_n = int(SR * 0.020) # 20ms attack
envelope[:attack_n] = np.linspace(0, 1, attack_n)
decay_n = int(SR * 0.080) # 80ms decay tail
envelope[-decay_n:] *= np.linspace(1, 0, decay_n)
return samples * envelope
def _build_shutter_click() -> np.ndarray:
"""Camera shutter — two brief noise bursts 35ms apart (mirror up, mirror
down). Mechanical, definite, satisfying. ~60ms total.
"""
SR = SAMPLE_RATE
rng = np.random.default_rng(456)
# Two 8ms noise bursts. Second is slightly lower-energy.
def burst(samples_n: int, energy: float) -> np.ndarray:
noise = rng.standard_normal(samples_n).astype(np.float32)
# High-pass via diff to emphasize mechanical click character
clicky = np.diff(noise, prepend=0.0)
env = np.exp(-150 * np.arange(samples_n, dtype=np.float32) / SR)
return clicky * env * energy
burst_n = int(SR * 0.008)
gap_n = int(SR * 0.027)
return np.concatenate([
burst(burst_n, 1.0),
np.zeros(gap_n, dtype=np.float32),
burst(burst_n, 0.75),
])
def _build_chime_tube() -> np.ndarray:
"""Wind chime tube — single struck metal tube, long decay (~600ms).
More resonant + longer-lived than bell-soft. Multiple inharmonic partials.
"""
SR = SAMPLE_RATE
duration_ms = 600
n = int(SR * duration_ms / 1000)
t = np.arange(n, dtype=np.float32) / SR
# Tube modes: not harmonic, ratios roughly 1, 2.76, 5.40, 8.93 for
# transverse vibrations of a free-free metal bar (Chladni's formula).
f0 = 520.0
samples = (
1.00 * np.sin(2 * np.pi * f0 * 1.00 * t) * np.exp(-2.0 * t)
+ 0.55 * np.sin(2 * np.pi * f0 * 2.76 * t) * np.exp(-3.5 * t)
+ 0.25 * np.sin(2 * np.pi * f0 * 5.40 * t) * np.exp(-5.0 * t)
+ 0.10 * np.sin(2 * np.pi * f0 * 8.93 * t) * np.exp(-7.0 * t)
)
# 1ms attack ramp
ramp_n = int(SR * 0.001)
samples[:ramp_n] *= np.linspace(0, 1, ramp_n)
return samples
def _build_drop_deep() -> np.ndarray:
"""Deeper "plop" into a basin or bowl — same physics as water-drop but
larger air cavity, so lower starting pitch and longer decay (~200ms).
"""
SR = SAMPLE_RATE
rng = np.random.default_rng(790)
# Bigger splash for the bigger drop
splash_n = int(SR * 0.005)
noise = rng.standard_normal(splash_n).astype(np.float32)
splash = np.diff(noise, prepend=0.0)
splash_env = np.exp(-200 * np.arange(splash_n, dtype=np.float32) / SR)
splash = splash * splash_env * 0.5
# Lower bubble: 320→520 Hz rising over 180ms, slower decay
ring_ms = 180
ring_n = int(SR * ring_ms / 1000)
t = np.arange(ring_n, dtype=np.float32) / SR
f0, f1 = 320.0, 520.0
phase = 2 * np.pi * (f0 * t + (f1 - f0) / (2 * (ring_ms / 1000)) * t**2)
ring = np.sin(phase) + 0.30 * np.sin(2 * phase) + 0.10 * np.sin(3 * phase)
ring_env = np.exp(-12 * t)
ramp_n = int(SR * 0.001)
ring_env[:ramp_n] *= np.linspace(0, 1, ramp_n)
ring = ring * ring_env
return np.concatenate([splash, ring])
def _build_mf_listen() -> np.ndarray:
"""Warm analog "I'm listening" swell — a stacked-fifths chord (D-A-E) that
blooms in with detuned chorus and a slow vibrato, like a Pink Floyd VCS3 pad
opening up. Stacked fifths are the open, unresolved "waiting for you" voicing
Floyd leans on. A short portamento glides into pitch for the analog bloom.
Signals "the mic is live, go ahead." ~700ms.
"""
SR = SAMPLE_RATE
dur_ms = 700
n = int(SR * dur_ms / 1000)
t = np.arange(n, dtype=np.float32) / SR
notes = [146.83, 220.00, 329.63] # D3, A3, E4 — stacked fifths
amps = [1.0, 0.75, 0.55]
# Shared subtle vibrato (~5 Hz) + a portamento swell into pitch over the
# first ~45ms tau (starts 4% flat, glides up) for the analog bloom.
vib = 1.0 + 0.003 * np.sin(2 * np.pi * 5.0 * t)
glide = 1.0 - 0.04 * np.exp(-t / 0.045)
samples = np.zeros(n, dtype=np.float32)
for f, a in zip(notes, amps):
for detune in (0.997, 1.003): # dual-osc chorus per note
inst = f * detune * vib * glide
phase = 2 * np.pi * np.cumsum(inst) / SR
samples += a * np.sin(phase)
# Faint octave shimmer up top — air without harshness.
samples += 0.12 * np.sin(2 * np.pi * 587.33 * t) # D5
# Bloom envelope: 110ms raised-cosine attack, 300ms raised-cosine release.
env = np.ones(n, dtype=np.float32)
a_n = int(SR * 0.110)
env[:a_n] = 0.5 * (1 - np.cos(np.pi * np.arange(a_n, dtype=np.float32) / a_n))
r_n = int(SR * 0.300)
env[-r_n:] *= 0.5 * (1 + np.cos(np.pi * np.arange(r_n, dtype=np.float32) / r_n))
return samples * env
def _build_mf_done() -> np.ndarray:
"""Warm resolving "got it" chord — a low D-major triad (D-F#-A) that settles
with a slight downward drift, the consonant answer to mf-listen's open
fifths. Same tonal center (D), so the two read as a matched pair of
bookends. "Captured, closing." ~520ms.
"""
SR = SAMPLE_RATE
dur_ms = 520
n = int(SR * dur_ms / 1000)
t = np.arange(n, dtype=np.float32) / SR
notes = [146.83, 185.00, 220.00] # D3, F#3, A3 — low, warm resolution
amps = [1.0, 0.7, 0.7]
vib = 1.0 + 0.002 * np.sin(2 * np.pi * 4.5 * t)
settle = 1.0 - 0.015 * (t / (dur_ms / 1000)) # gentle 1.5% downward drift
samples = np.zeros(n, dtype=np.float32)
for f, a in zip(notes, amps):
for detune in (0.998, 1.002):
inst = f * detune * vib * settle
phase = 2 * np.pi * np.cumsum(inst) / SR
samples += a * np.sin(phase)
env = np.ones(n, dtype=np.float32)
a_n = int(SR * 0.050)
env[:a_n] = 0.5 * (1 - np.cos(np.pi * np.arange(a_n, dtype=np.float32) / a_n))
r_n = int(SR * 0.280)
env[-r_n:] *= 0.5 * (1 + np.cos(np.pi * np.arange(r_n, dtype=np.float32) / r_n))
return samples * env
def _build_mf_dial() -> np.ndarray:
"""Young Lust telephone dial — the genuine Bell-System R1 multi-frequency
operator routing sequence from the end of the track: KP, 0-4-4-1-8-3-1, ST
(the 44 is the UK country code). Real MF pairs drawn from
{700,900,1100,1300,1500,1700}, ~67ms digits with ~50ms gaps, KP/ST longer.
Used as the listen "I'm dialing you, go ahead" cue. ~1.1s.
"""
pairs = {
"0": (1300, 1500), "1": (700, 900), "3": (900, 1100), "4": (700, 1300),
"8": (900, 1500), "KP": (1100, 1700), "ST": (1500, 1700),
}
def mf(pair: tuple[int, int], ms: float) -> np.ndarray:
# Sum the two MF frequencies (equal length → direct add), soft edges.
return _sine_segment(pair[0], ms, fade_ms=4.0) + _sine_segment(pair[1], ms, fade_ms=4.0)
seq = ["KP", "0", "4", "4", "1", "8", "3", "1", "ST"]
out: list[np.ndarray] = []
for d in seq:
out.append(mf(pairs[d], 100 if d in ("KP", "ST") else 67))
out.append(_silence(50))
return np.concatenate(out)
def _build_machine() -> np.ndarray:
"""Welcome to the Machine throb — a low pulsing VCS3-style drone (~55 Hz
fundamental + harmonics), amplitude-pulsed by a ~3 Hz LFO with a bright
upper-harmonic sheen. Warm, mechanical "connected / got it" close. ~900ms.
"""
n = int(SAMPLE_RATE * 0.9)
t = np.arange(n, dtype=np.float32) / SAMPLE_RATE
b = 55.0
tone = (
np.sin(2 * np.pi * b * t)
+ 0.5 * np.sin(2 * np.pi * 2 * b * t)
+ 0.3 * np.sin(2 * np.pi * 3 * b * t)
)
lfo = 0.5 * (1 + np.sin(2 * np.pi * 3.0 * t - np.pi / 2)) # ~3 Hz pulse
tone = tone * (0.3 + 0.7 * lfo)
tone += 0.15 * np.sin(2 * np.pi * 8 * b * t) * lfo # sheen on the pulses
a = int(SAMPLE_RATE * 0.02)
tone[:a] *= np.linspace(0, 1, a)
r = int(SAMPLE_RATE * 0.15)
tone[-r:] *= np.linspace(1, 0, r)
return tone
def _build_heartbeat() -> np.ndarray:
"""speak() entry — a "Speak to Me" heartbeat woven with warm MF telephone
tones, tying the speak bookends back to the listen phone theme. A mellow MF
pair leads in, then a lub-dub beat, a small rising MF figure, and a softer
second beat. Low and warm, not harsh. ~1.5s.
"""
def thump(f0: float, dur_ms: float, amp: float) -> np.ndarray:
n = int(SAMPLE_RATE * dur_ms / 1000)
t = np.arange(n, dtype=np.float32) / SAMPLE_RATE
freq = f0 * (0.6 + 0.4 * np.exp(-t / 0.03)) # quick downward pitch drop
body = np.sin(2 * np.pi * np.cumsum(freq) / SAMPLE_RATE)
env = np.exp(-t / 0.055)
a = int(SAMPLE_RATE * 0.003)
env[:a] *= np.linspace(0, 1, a)
return body * env * amp
def beat(amp: float) -> np.ndarray:
return np.concatenate([thump(55, 140, amp), _silence(110), thump(66, 120, 0.7 * amp)])
def mf(pair: tuple[int, int], ms: float, amp: float = 1.0) -> np.ndarray:
return (
_sine_segment(pair[0], ms, fade_ms=4.0)
+ _sine_segment(pair[1], ms, fade_ms=4.0)
) * amp
d1, d4, d0 = (700, 900), (700, 1300), (1300, 1500) # warm MF pairs
return np.concatenate([
mf(d1, 90, 0.8), _silence(80),
beat(0.9), _silence(150),
mf(d4, 55, 0.6), _silence(40), mf(d0, 60, 0.6), _silence(150),
beat(0.7),
])
def _build_soft_chord() -> np.ndarray:
"""speak() exit — a mellow low resolving dyad (A3 + E4 + soft octave), pure
sines under a raised-cosine envelope so there are no clicks and no hiss.
Gentle "over and out" that's easy on the ears on every message. ~700ms.
"""
n = int(SAMPLE_RATE * 0.7)
t = np.arange(n, dtype=np.float32) / SAMPLE_RATE
s = (
np.sin(2 * np.pi * 220.0 * t)
+ 0.7 * np.sin(2 * np.pi * 329.63 * t)
+ 0.2 * np.sin(2 * np.pi * 440.0 * t)
)
env = 0.5 * (1 - np.cos(2 * np.pi * np.arange(n, dtype=np.float32) / n))
return s * env
def _build_call_waiting() -> np.ndarray:
"""Call-waiting alert — two very short soft blips at 1047 Hz (C6), ~125ms
total. Deliberately brief and high so it rides cleanly over ongoing speech
(it's mixed OVER the currently-playing message, phone-style) to signal that
another project's message just joined the queue.
"""
seg = _sine_segment(1047.0, 45, fade_ms=4.0) # soft edges, no click
return np.concatenate([seg, _silence(35), seg])
def _write_tone(samples: np.ndarray, path: Path) -> Path:
"""Write float32 samples to 16-bit PCM WAV."""
peak = max(abs(samples.max()), abs(samples.min()), 1e-8)
@ -132,6 +538,32 @@ _TONE_BUILDERS = {
"standby": _build_standby,
"scratch": _build_scratch,
"reverse-roger": _build_reverse_roger,
# Natural-feeling alternatives — designed for in-ear/headset listen() use.
"bell-soft": _build_bell_soft,
"bell-deep": _build_bell_deep,
"tap-wood": _build_tap_wood,
"water-drop": _build_water_drop,
"drop-deep": _build_drop_deep,
"soft-pulse": _build_soft_pulse,
"hmm-up": _build_hmm_up,
"shutter-click": _build_shutter_click,
"chime-tube": _build_chime_tube,
# Pink Floyd-esque multi-frequency pads for listen() bookends — warm,
# chorused, musical. mf-listen ("go, I'm listening") / mf-done ("got it").
"mf-listen": _build_mf_listen,
"mf-done": _build_mf_done,
# Pink Floyd telephone set for listen() — mf-dial is the genuine Young Lust
# R1 operator routing sequence ("dialing you, go ahead"); machine is the
# Welcome to the Machine throb ("connected / got it").
"mf-dial": _build_mf_dial,
"machine": _build_machine,
# speak() bookends — heartbeat+MF entry ("Speak to Me" woven with telephone
# tones) and a soft resolving chord exit. Warm, ear-friendly on repetition.
"heartbeat": _build_heartbeat,
"soft-chord": _build_soft_chord,
# Brief blip mixed over ongoing speech when another project's message
# joins the queue — phone-style call-waiting.
"call-waiting": _build_call_waiting,
}

41
uv.lock generated
View File

@ -893,7 +893,7 @@ wheels = [
[[package]]
name = "mcspeak"
version = "2026.3.4"
version = "2026.3.4.1"
source = { editable = "." }
dependencies = [
{ name = "fastmcp" },
@ -905,6 +905,7 @@ dependencies = [
{ name = "snac" },
{ name = "soundfile" },
{ name = "torch" },
{ name = "webrtcvad-wheels" },
{ name = "wyoming" },
]
@ -919,6 +920,7 @@ requires-dist = [
{ name = "snac", specifier = ">=1.2.1" },
{ name = "soundfile" },
{ name = "torch" },
{ name = "webrtcvad-wheels" },
{ name = "wyoming", specifier = ">=1.8.0" },
]
@ -1962,6 +1964,11 @@ dependencies = [
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/54/a2ba279afcca44bbd320d4e73675b282fcee3d81400ea1b53934efca6462/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:13ec4add8c3faaed8d13e0574f5cd4a323c11655546f91fbe6afa77b57423574", size = 79498202, upload-time = "2026-02-10T21:44:52.603Z" },
{ url = "https://files.pythonhosted.org/packages/ec/23/2c9fe0c9c27f7f6cb865abcea8a4568f29f00acaeadfc6a37f6801f84cb4/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:e521c9f030a3774ed770a9c011751fb47c4d12029a3d6522116e48431f2ff89e", size = 79498254, upload-time = "2026-02-10T21:44:44.095Z" },
{ url = "https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:98c01b8bb5e3240426dcde1446eed6f40c778091c8544767ef1168fc663a05a6", size = 915622781, upload-time = "2026-03-11T14:17:11.354Z" },
{ url = "https://files.pythonhosted.org/packages/ab/c6/4dfe238342ffdcec5aef1c96c457548762d33c40b45a1ab7033bb26d2ff2/torch-2.10.0-3-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:80b1b5bfe38eb0e9f5ff09f206dcac0a87aadd084230d4a36eea5ec5232c115b", size = 915627275, upload-time = "2026-03-11T14:16:11.325Z" },
{ url = "https://files.pythonhosted.org/packages/d8/f0/72bf18847f58f877a6a8acf60614b14935e2f156d942483af1ffc081aea0/torch-2.10.0-3-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:46b3574d93a2a8134b3f5475cfb98e2eb46771794c57015f6ad1fb795ec25e49", size = 915523474, upload-time = "2026-03-11T14:17:44.422Z" },
{ url = "https://files.pythonhosted.org/packages/f4/39/590742415c3030551944edc2ddc273ea1fdfe8ffb2780992e824f1ebee98/torch-2.10.0-3-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b1d5e2aba4eb7f8e87fbe04f86442887f9167a35f092afe4c237dfcaaef6e328", size = 915632474, upload-time = "2026-03-11T14:15:13.666Z" },
{ url = "https://files.pythonhosted.org/packages/b6/8e/34949484f764dde5b222b7fe3fede43e4a6f0da9d7f8c370bb617d629ee2/torch-2.10.0-3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0228d20b06701c05a8f978357f657817a4a63984b0c90745def81c18aedfa591", size = 915523882, upload-time = "2026-03-11T14:14:46.311Z" },
{ url = "https://files.pythonhosted.org/packages/cc/af/758e242e9102e9988969b5e621d41f36b8f258bb4a099109b7a4b4b50ea4/torch-2.10.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5fd4117d89ffd47e3dcc71e71a22efac24828ad781c7e46aaaf56bf7f2796acf", size = 145996088, upload-time = "2026-01-21T16:24:44.171Z" },
{ url = "https://files.pythonhosted.org/packages/23/8e/3c74db5e53bff7ed9e34c8123e6a8bfef718b2450c35eefab85bb4a7e270/torch-2.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:787124e7db3b379d4f1ed54dd12ae7c741c16a4d29b49c0226a89bea50923ffb", size = 915711952, upload-time = "2026-01-21T16:23:53.503Z" },
{ url = "https://files.pythonhosted.org/packages/6e/01/624c4324ca01f66ae4c7cd1b74eb16fb52596dce66dbe51eff95ef9e7a4c/torch-2.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2c66c61f44c5f903046cc696d088e21062644cbe541c7f1c4eaae88b2ad23547", size = 113757972, upload-time = "2026-01-21T16:24:39.516Z" },
@ -2157,6 +2164,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
[[package]]
name = "webrtcvad-wheels"
version = "2.0.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/28/ba/3a8ce2cff3eee72a39ed190e5f9dac792da1526909c97a11589590b21739/webrtcvad_wheels-2.0.14.tar.gz", hash = "sha256:5f59c8e291c6ef102d9f39532982fbf26a52ce2de6328382e2654b0960fea397", size = 70607, upload-time = "2024-09-05T10:17:02.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/1f/af4af5d30228d46bbc3ad85cd12aac670b930d94107d8c755c191e5454f3/webrtcvad_wheels-2.0.14-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fc5d5a5bb13e5f25e095859d7a0018101a6563434781f27965f38ee75da45f9e", size = 31016, upload-time = "2024-09-05T10:15:08.799Z" },
{ url = "https://files.pythonhosted.org/packages/df/7f/8ff8af528b0a8db20d28356dc5486ecf779a7d1bd9ff379d8b3d921a2ab3/webrtcvad_wheels-2.0.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:323aa89df721377f053923fe1ea5c14e748c85314a977e2fcc3c920c4f474d40", size = 29515, upload-time = "2024-09-05T10:15:09.829Z" },
{ url = "https://files.pythonhosted.org/packages/24/16/8e21cfabd0cd9b9d01231e3b65716ab0cf1a6bd0b9115e2ed038996fe2c1/webrtcvad_wheels-2.0.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42310d3bb6cdb46a79a219d14fe97ae272b8af629d84321e5cfa0764556109a6", size = 86036, upload-time = "2024-09-05T10:15:11.122Z" },
{ url = "https://files.pythonhosted.org/packages/8a/c3/51e1f87610b55823367f08253fba50659beda2807e2ec4cf08360aff6c25/webrtcvad_wheels-2.0.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fdc138ce3519e2f4ba2f74640bc6f72aff162fa0c9ae2941da8068f3d22ba6", size = 95471, upload-time = "2024-09-05T10:15:12.652Z" },
{ url = "https://files.pythonhosted.org/packages/84/59/a6cd0eeae17640e43215843ea6176645b65f58dd6d15a38c77c253116e87/webrtcvad_wheels-2.0.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63fdad42c0ca6248b9a5c8ca34ff17a5c43bb63ffd8997a5902ad7237a05ca68", size = 82895, upload-time = "2024-09-05T10:15:13.738Z" },
{ url = "https://files.pythonhosted.org/packages/7b/cd/fd784f552a32d1b44be9ffe8b8f243c8c9a9eb69a61ef92a8b6611d38349/webrtcvad_wheels-2.0.14-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7aa4cfbf0c816d2b9ee3100d3008f5acf8bd1a634d6058fa6c1604a1e2ba264", size = 80808, upload-time = "2024-09-05T10:15:15.166Z" },
{ url = "https://files.pythonhosted.org/packages/7b/0c/28846038f5de2872b91a5b91925792d056477cd42a6d639b6a3ba84bd0e8/webrtcvad_wheels-2.0.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6056a2559d83836bf4b6af4993448202bfd080efd5e799d2153118e4a91d3fd1", size = 86266, upload-time = "2024-09-05T10:15:16.382Z" },
{ url = "https://files.pythonhosted.org/packages/17/38/8b20fb52c14bc316089a7486b559412c785968b17f97b5486ad17e115b0e/webrtcvad_wheels-2.0.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:db9d5e6ae07cc82277eacac2b747afa7db53e9effab0259055116f5f77a28bf3", size = 82609, upload-time = "2024-09-05T10:15:17.707Z" },
{ url = "https://files.pythonhosted.org/packages/3c/7c/3a8abe5cc3d2b65072b24c4122ceae29de0ac5d4d5df45ebf7f3f101ef74/webrtcvad_wheels-2.0.14-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:407c29a1d577dff8c2ef746bbd78151688f23ca763d97f3334d6e310ffa68aaa", size = 93359, upload-time = "2024-09-05T10:15:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/7a/e6/14069e350e5cbd78f7c16add5613f03378d2ee36c43d5dc96d20ce9bc8e6/webrtcvad_wheels-2.0.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe2ced612732a41aefa14ea2ea52f84de5afd43665ebed092e1d6df93641e239", size = 86940, upload-time = "2024-09-05T10:15:20.301Z" },
{ url = "https://files.pythonhosted.org/packages/ec/7c/ac5a40ca80a09e978968b7065528ce340295115d1af2b1e0a98aa0ac0244/webrtcvad_wheels-2.0.14-cp312-cp312-win32.whl", hash = "sha256:68e2041d1a30dc619a0e7ff5adaa948a6b862fa6e6d4d85337f235240707eab5", size = 17364, upload-time = "2024-09-05T10:15:21.162Z" },
{ url = "https://files.pythonhosted.org/packages/24/1d/9a8f4c842ca880253821acf165080077b245577719e5e03b13e3c45853c5/webrtcvad_wheels-2.0.14-cp312-cp312-win_amd64.whl", hash = "sha256:4010b95b31b9b360fd1bf47a8344b06b946ec866cf3d14fb39fc692307ddf312", size = 19901, upload-time = "2024-09-05T10:15:21.96Z" },
{ url = "https://files.pythonhosted.org/packages/51/ab/08750672514f4d9d0304d8648ebdf7827c38e266263953629b22a370c68f/webrtcvad_wheels-2.0.14-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:984f412292bd6edb3487911a5b2e36d1690a6afb8fc3fab18286b52fdf20eea0", size = 32768, upload-time = "2024-09-05T10:15:22.797Z" },
{ url = "https://files.pythonhosted.org/packages/ba/e0/ab4624a30c59abeaa6824cda455fd7842b7feafa94c7e52f35933926be88/webrtcvad_wheels-2.0.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:578151f6d1d58a3735fc4c4b7d47db3d42503a49587f84d9c42c2cd8aee93867", size = 29519, upload-time = "2024-09-05T10:15:23.867Z" },
{ url = "https://files.pythonhosted.org/packages/0e/cb/b1f1890e863e6adf0f6d3d9aabe1951169b7f514eae2fd8bd266752e923b/webrtcvad_wheels-2.0.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:792ce66f32b6d3ffa7569ef467b4ec1a5a45eb95cc466d204c04c120f4169015", size = 85987, upload-time = "2024-09-05T10:15:26.021Z" },
{ url = "https://files.pythonhosted.org/packages/cc/3d/78c1f47214bf75682aba75435a9762451f1d1392d810fae1b3a5ece1aecb/webrtcvad_wheels-2.0.14-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de5d6d71cbe2b92ec8411abd9c40d86e32f7d65f92a41029c0387d59c642810b", size = 95426, upload-time = "2024-09-05T10:15:27.231Z" },
{ url = "https://files.pythonhosted.org/packages/07/07/4b9eff8e3eb64e7017e6b28d7d3881290f19ece015ad182824f52ae088d0/webrtcvad_wheels-2.0.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c67055236f1ffcf160825bb87dbe2c1d3846ad51b54602906b1c7ea37a0b4ac", size = 82864, upload-time = "2024-09-05T10:15:29.73Z" },
{ url = "https://files.pythonhosted.org/packages/9e/32/067431c5313722860f37602d6c37a53f5f9250a0c46c70e918a8afce0293/webrtcvad_wheels-2.0.14-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f2ae19589d89e1e67ad08f62fa11d5edce1a05a9a0e61d74ac3af6762da4caf", size = 80776, upload-time = "2024-09-05T10:15:31.092Z" },
{ url = "https://files.pythonhosted.org/packages/06/73/d297e6ea1493005e1b05c9ce56638f097bfd665a71045dad57000224a4fd/webrtcvad_wheels-2.0.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7de1df623d148052a61bd151545344dfafcf85dfb7075ea0e855009c080eb0ec", size = 86313, upload-time = "2024-09-05T10:15:32.014Z" },
{ url = "https://files.pythonhosted.org/packages/23/08/26f15a04aefeade474148ebf32ecc9aa68bb001fcea24dc8a3c299677e3d/webrtcvad_wheels-2.0.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bcdffafce093f72ccea028698163b33c85267481900f8389fc2ecc1d6d1b57ba", size = 82633, upload-time = "2024-09-05T10:15:32.876Z" },
{ url = "https://files.pythonhosted.org/packages/ac/f9/02a13c7dd6113ecc3f2e02d4a2ef54586e55a0bb4ea3772b54df9e08eb49/webrtcvad_wheels-2.0.14-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1a7a3cb39b4d33cf4895fceea9a12d4e30a44b98a9512e661ffb0a520b3f6bfb", size = 93403, upload-time = "2024-09-05T10:15:34.044Z" },
{ url = "https://files.pythonhosted.org/packages/a7/6e/0cd49b425ee9ccba2acd8451cbd96e6318081abcde6d9d6caa2083e53fab/webrtcvad_wheels-2.0.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:34b315326dd6c8529221492ebf28fc649d96b1a49ce49bf649eca60aabc70a2d", size = 86979, upload-time = "2024-09-05T10:15:35.045Z" },
{ url = "https://files.pythonhosted.org/packages/77/36/52e8998f8421c746defa8f8b509cab4aac984ef39a2cd5818f664901d245/webrtcvad_wheels-2.0.14-cp313-cp313-win32.whl", hash = "sha256:ff2a7eb4fe2189a7766726d6122b319df3e727c4cfed86409e2802fed1b459d3", size = 17368, upload-time = "2024-09-05T10:15:35.973Z" },
{ url = "https://files.pythonhosted.org/packages/f9/7c/73b9dd022c835e19180aad8aafd2f33863c02cbbafd5bc599dd54f6c1111/webrtcvad_wheels-2.0.14-cp313-cp313-win_amd64.whl", hash = "sha256:561f87975930833a5b77135fb6f15e6df430bfc000c327dc517a175aef15a707", size = 19901, upload-time = "2024-09-05T10:15:36.877Z" },
]
[[package]]
name = "websockets"
version = "16.0"