Ops hardening: lead re-import, SMS health-watch, ambient office murmur
- scripts/reimport_leads.py drains appointment_requests.jsonl into Odoo (fallback leads were captured but invisible to staff forever); server.py now warns at startup when leads are pending. - scripts/healthwatch.sh (cron, every minute): after 3 consecutive /health failures sends a Twilio SMS to ALERT_SMS_TO (30-min cooldown) + a recovery SMS. systemd handles restarts; this makes a human aware when that isn't enough. Tested end-to-end (DOWN + RECOVERED SMS delivered). - Ambient office murmur (assets/office_ambience.wav, generated by scripts/make_ambience.py from low-passed unintelligible Kokoro babble + room tone) mixed continuously into outbound audio via pipecat SoundfileMixer, so callers hear a live office instead of dead silence while the agent thinks. AMBIENT_VOLUME/AMBIENT_ENABLED to tune/disable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
86
scripts/make_ambience.py
Normal file
86
scripts/make_ambience.py
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate assets/office_ambience.wav — low, unintelligible office murmur.
|
||||
|
||||
Mixed continuously into the outbound call audio (SoundfileMixer) so the caller hears a
|
||||
live-office room tone instead of dead digital silence while the agent thinks. Built from
|
||||
Kokoro voices speaking mundane phrases, overlapped and heavily low-pass filtered so no
|
||||
words are intelligible (nothing that could be mistaken for real conversation/PHI), plus
|
||||
a touch of room noise. Output: 60s mono 16 kHz PCM16 loop, quiet by design (the mixer
|
||||
volume scales it further).
|
||||
|
||||
Run inside the pipecat venv: python scripts/make_ambience.py
|
||||
"""
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from kokoro_onnx import Kokoro
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJ = os.path.dirname(HERE)
|
||||
MODEL_DIR = os.environ.get("KOKORO_MODEL_DIR", "/home/tocmo0nlord/pipecat-run/models")
|
||||
OUT = os.path.join(PROJ, "assets", "office_ambience.wav")
|
||||
|
||||
SR = 16000 # match PIPELINE_SAMPLE_RATE (transport output rate)
|
||||
DUR = 60.0 # loop length in seconds
|
||||
RNG = np.random.default_rng(42)
|
||||
|
||||
PHRASES = [
|
||||
"Okay, so I'll move that over to Thursday and send the reminder out this afternoon.",
|
||||
"Could you pull the file for the three o'clock? I think it's already up front.",
|
||||
"Yes, we got the shipment in this morning, it's in the back on the second shelf.",
|
||||
"Let me transfer you over, one moment please, thank you so much for holding.",
|
||||
"The afternoon looks pretty full but the morning still has a couple of openings.",
|
||||
"I'll leave a note for the doctor and we'll follow up first thing tomorrow.",
|
||||
"They said the delivery should arrive before noon so we should be all set.",
|
||||
"Can you double check the calendar for next Tuesday? I think there's a conflict.",
|
||||
]
|
||||
VOICES = ["af_bella", "am_michael", "bf_emma", "am_adam", "af_nicole", "bm_george"]
|
||||
|
||||
|
||||
def lowpass(x, cutoff_hz, sr):
|
||||
"""Simple FFT brick-wall low-pass — fine for ambience shaping."""
|
||||
X = np.fft.rfft(x)
|
||||
freqs = np.fft.rfftfreq(len(x), 1 / sr)
|
||||
X[freqs > cutoff_hz] = 0
|
||||
return np.fft.irfft(X, n=len(x))
|
||||
|
||||
|
||||
def main():
|
||||
k = Kokoro(os.path.join(MODEL_DIR, "kokoro-v1.0.onnx"),
|
||||
os.path.join(MODEL_DIR, "voices-v1.0.bin"))
|
||||
bed = np.zeros(int(SR * DUR), dtype=np.float64)
|
||||
|
||||
# ~18 murmured utterances scattered through the minute, different voices/speeds.
|
||||
for i in range(18):
|
||||
text = PHRASES[i % len(PHRASES)]
|
||||
voice = VOICES[i % len(VOICES)]
|
||||
samples, sr0 = k.create(text, voice=voice, speed=float(RNG.uniform(0.9, 1.1)))
|
||||
# Resample to SR by linear interpolation (quality is irrelevant post-filter).
|
||||
t_src = np.arange(len(samples)) / sr0
|
||||
t_dst = np.arange(int(len(samples) * SR / sr0)) / SR
|
||||
s = np.interp(t_dst, t_src, samples.astype(np.float64))
|
||||
s = lowpass(s, 900, SR) # muffle: through-the-wall murmur
|
||||
s *= RNG.uniform(0.25, 0.5) # each talker is quiet and uneven
|
||||
start = int(RNG.uniform(0, DUR - len(s) / SR) * SR)
|
||||
bed[start:start + len(s)] += s
|
||||
|
||||
# Gentle room tone under the voices (shaped noise), so pauses aren't pure silence.
|
||||
noise = lowpass(RNG.standard_normal(len(bed)), 400, SR) * 0.02
|
||||
bed += noise
|
||||
|
||||
# Loop seamlessly: crossfade the last second into the first.
|
||||
xf = SR
|
||||
ramp = np.linspace(0, 1, xf)
|
||||
bed[:xf] = bed[:xf] * ramp + bed[-xf:] * (1 - ramp)
|
||||
bed = bed[:-xf]
|
||||
|
||||
# Normalize conservatively — this is a QUIET bed; mixer volume scales it down further.
|
||||
bed = bed / (np.abs(bed).max() + 1e-9) * 0.35
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
sf.write(OUT, bed.astype(np.float32), SR, subtype="PCM_16")
|
||||
print(f"wrote {OUT}: {len(bed)/SR:.1f}s @ {SR}Hz, peak {np.abs(bed).max():.2f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user