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:
tocmo0nlord
2026-07-04 16:35:34 +00:00
parent 80e0bbe899
commit 1e7b7844a4
8 changed files with 292 additions and 0 deletions

86
scripts/make_ambience.py Normal file
View 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()