diff --git a/pyproject.toml b/pyproject.toml index 11cd0ae..626ab74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/src/mcspeak/audio.py b/src/mcspeak/audio.py index 12ada71..18f3ef1 100644 --- a/src/mcspeak/audio.py +++ b/src/mcspeak/audio.py @@ -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) diff --git a/src/mcspeak/settings.py b/src/mcspeak/settings.py index 4a546ea..4708056 100644 --- a/src/mcspeak/settings.py +++ b/src/mcspeak/settings.py @@ -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 "." 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 diff --git a/src/mcspeak/tones.py b/src/mcspeak/tones.py index de93dce..45f9d98 100644 --- a/src/mcspeak/tones.py +++ b/src/mcspeak/tones.py @@ -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 + 700→1100 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, } diff --git a/uv.lock b/uv.lock index 618aecc..5e52fd5 100644 --- a/uv.lock +++ b/uv.lock @@ -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"