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:
parent
5db7876dac
commit
3c4e06aa64
@ -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",
|
||||
]
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
41
uv.lock
generated
41
uv.lock
generated
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user