Add show improvement features: crossfade, emotions, returning callers, transcripts, screening

- Music crossfade: smooth 3-second blend between tracks instead of hard stop/start
- Emotional detection: analyze host mood from recent messages so callers adapt tone
- AI caller summaries: generate call summaries with timestamps for show history
- Returning callers: persist regular callers across sessions with call history
- Session export: generate transcripts with speaker labels and chapter markers
- Caller screening: AI pre-screens phone callers to get name and topic while queued

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 02:43:01 -07:00
parent de5577e582
commit 356bf145b8
13 changed files with 3736 additions and 40 deletions

View File

@@ -53,6 +53,14 @@ class AudioService:
self._music_volume: float = 0.3
self._music_loop: bool = True
# Music crossfade state
self._crossfade_active: bool = False
self._crossfade_old_data: Optional[np.ndarray] = None
self._crossfade_old_position: int = 0
self._crossfade_progress: float = 0.0
self._crossfade_samples: int = 0
self._crossfade_step: float = 0.0
# Caller playback state
self._caller_stop_event = threading.Event()
self._caller_thread: Optional[threading.Thread] = None
@@ -578,6 +586,55 @@ class AudioService:
print(f"Failed to load music: {e}")
return False
def crossfade_to(self, file_path: str, duration: float = 3.0):
"""Crossfade from current music track to a new one"""
import librosa
if not self._music_playing or self._music_resampled is None:
if self.load_music(file_path):
self.play_music()
return
# Load the new track
path = Path(file_path)
if not path.exists():
print(f"Music file not found: {file_path}")
return
try:
audio, sr = librosa.load(str(path), sr=self.output_sample_rate, mono=True)
new_data = audio.astype(np.float32)
except Exception as e:
print(f"Failed to load music for crossfade: {e}")
return
# Get device sample rate for resampling
if self.output_device is not None:
device_info = sd.query_devices(self.output_device)
device_sr = int(device_info['default_samplerate'])
else:
device_sr = self.output_sample_rate
if self.output_sample_rate != device_sr:
new_resampled = librosa.resample(new_data, orig_sr=self.output_sample_rate, target_sr=device_sr)
else:
new_resampled = new_data.copy()
# Swap: current becomes old, new becomes current
self._crossfade_old_data = self._music_resampled
self._crossfade_old_position = self._music_position
self._music_resampled = new_resampled
self._music_data = new_data
self._music_position = 0
# Configure crossfade timing
self._crossfade_samples = int(device_sr * duration)
self._crossfade_progress = 0.0
self._crossfade_step = 1.0 / self._crossfade_samples if self._crossfade_samples > 0 else 1.0
self._crossfade_active = True
print(f"Crossfading to {path.name} over {duration}s")
def play_music(self):
"""Start music playback to specific channel"""
import librosa
@@ -625,24 +682,54 @@ class AudioService:
if not self._music_playing or self._music_resampled is None:
return
# Read new track samples
end_pos = self._music_position + frames
if end_pos <= len(self._music_resampled):
outdata[:, channel_idx] = self._music_resampled[self._music_position:end_pos] * self._music_volume
new_samples = self._music_resampled[self._music_position:end_pos].copy()
self._music_position = end_pos
else:
remaining = len(self._music_resampled) - self._music_position
new_samples = np.zeros(frames, dtype=np.float32)
if remaining > 0:
outdata[:remaining, channel_idx] = self._music_resampled[self._music_position:] * self._music_volume
new_samples[:remaining] = self._music_resampled[self._music_position:]
if self._music_loop:
self._music_position = 0
wrap_frames = frames - remaining
if wrap_frames > 0:
outdata[remaining:, channel_idx] = self._music_resampled[:wrap_frames] * self._music_volume
new_samples[remaining:] = self._music_resampled[:wrap_frames]
self._music_position = wrap_frames
else:
self._music_playing = False
self._music_position = len(self._music_resampled)
if remaining <= 0:
self._music_playing = False
if self._crossfade_active and self._crossfade_old_data is not None:
# Read old track samples
old_end = self._crossfade_old_position + frames
if old_end <= len(self._crossfade_old_data):
old_samples = self._crossfade_old_data[self._crossfade_old_position:old_end]
self._crossfade_old_position = old_end
else:
old_remaining = len(self._crossfade_old_data) - self._crossfade_old_position
old_samples = np.zeros(frames, dtype=np.float32)
if old_remaining > 0:
old_samples[:old_remaining] = self._crossfade_old_data[self._crossfade_old_position:]
self._crossfade_old_position = len(self._crossfade_old_data)
# Compute fade curves for this chunk
start_progress = self._crossfade_progress
end_progress = min(1.0, start_progress + self._crossfade_step * frames)
fade_in = np.linspace(start_progress, end_progress, frames, dtype=np.float32)
fade_out = 1.0 - fade_in
outdata[:, channel_idx] = (old_samples * fade_out + new_samples * fade_in) * self._music_volume
self._crossfade_progress = end_progress
if self._crossfade_progress >= 1.0:
self._crossfade_active = False
self._crossfade_old_data = None
print("Crossfade complete")
else:
outdata[:, channel_idx] = new_samples * self._music_volume
try:
self._music_stream = sd.OutputStream(
@@ -659,15 +746,48 @@ class AudioService:
print(f"Music playback error: {e}")
self._music_playing = False
def stop_music(self):
"""Stop music playback"""
self._music_playing = False
if self._music_stream:
def stop_music(self, fade_duration: float = 2.0):
"""Stop music playback with fade out"""
if not self._music_playing or not self._music_stream:
self._music_playing = False
if self._music_stream:
self._music_stream.stop()
self._music_stream.close()
self._music_stream = None
self._music_position = 0
return
if fade_duration <= 0:
self._music_playing = False
self._music_stream.stop()
self._music_stream.close()
self._music_stream = None
self._music_position = 0
print("Music stopped")
self._music_position = 0
print("Music stopped")
return
import threading
original_volume = self._music_volume
steps = 20
step_time = fade_duration / steps
def _fade():
for i in range(steps):
if not self._music_playing:
break
self._music_volume = original_volume * (1 - (i + 1) / steps)
import time
time.sleep(step_time)
self._music_playing = False
if self._music_stream:
self._music_stream.stop()
self._music_stream.close()
self._music_stream = None
self._music_position = 0
self._music_volume = original_volume
print("Music faded out and stopped")
threading.Thread(target=_fade, daemon=True).start()
def play_ad(self, file_path: str):
"""Load and play an ad file once (no loop) on the ad channel"""

View File

@@ -25,6 +25,7 @@ class CallerService:
self._stream_sids: dict[str, str] = {} # caller_id -> SignalWire streamSid
self._send_locks: dict[str, asyncio.Lock] = {} # per-caller send lock
self._streaming_tts: set[str] = set() # caller_ids currently receiving TTS
self._screening_state: dict[str, dict] = {} # caller_id -> screening conversation
def _get_send_lock(self, caller_id: str) -> asyncio.Lock:
if caller_id not in self._send_locks:
@@ -51,18 +52,6 @@ class CallerService:
self._queue = [c for c in self._queue if c["caller_id"] != caller_id]
print(f"[Caller] {caller_id} removed from queue")
def get_queue(self) -> list[dict]:
now = time.time()
with self._lock:
return [
{
"caller_id": c["caller_id"],
"phone": c["phone"],
"wait_time": int(now - c["queued_at"]),
}
for c in self._queue
]
def allocate_channel(self) -> int:
with self._lock:
ch = self.FIRST_REAL_CHANNEL
@@ -111,6 +100,7 @@ class CallerService:
self._call_sids.pop(caller_id, None)
self._stream_sids.pop(caller_id, None)
self._send_locks.pop(caller_id, None)
self._screening_state.pop(caller_id, None)
def reset(self):
with self._lock:
@@ -125,8 +115,72 @@ class CallerService:
self._stream_sids.clear()
self._send_locks.clear()
self._streaming_tts.clear()
self._screening_state.clear()
print("[Caller] Service reset")
# --- Screening ---
def start_screening(self, caller_id: str):
"""Initialize screening state for a queued caller"""
self._screening_state[caller_id] = {
"conversation": [],
"caller_name": None,
"topic": None,
"status": "screening", # screening, complete
"response_count": 0,
}
print(f"[Screening] Started for {caller_id}")
def get_screening_state(self, caller_id: str) -> Optional[dict]:
return self._screening_state.get(caller_id)
def update_screening(self, caller_id: str, caller_text: str = None,
screener_text: str = None, caller_name: str = None,
topic: str = None):
"""Update screening conversation and extracted info"""
state = self._screening_state.get(caller_id)
if not state:
return
if caller_text:
state["conversation"].append({"role": "caller", "content": caller_text})
state["response_count"] += 1
if screener_text:
state["conversation"].append({"role": "screener", "content": screener_text})
if caller_name:
state["caller_name"] = caller_name
if topic:
state["topic"] = topic
def end_screening(self, caller_id: str):
"""Mark screening as complete"""
state = self._screening_state.get(caller_id)
if state:
state["status"] = "complete"
print(f"[Screening] Complete for {caller_id}: name={state.get('caller_name')}, topic={state.get('topic')}")
def get_queue(self) -> list[dict]:
"""Get queue with screening info enrichment"""
now = time.time()
with self._lock:
result = []
for c in self._queue:
entry = {
"caller_id": c["caller_id"],
"phone": c["phone"],
"wait_time": int(now - c["queued_at"]),
}
screening = self._screening_state.get(c["caller_id"])
if screening:
entry["screening_status"] = screening["status"]
entry["caller_name"] = screening.get("caller_name")
entry["screening_summary"] = screening.get("topic")
else:
entry["screening_status"] = None
entry["caller_name"] = None
entry["screening_summary"] = None
result.append(entry)
return result
def register_websocket(self, caller_id: str, websocket):
"""Register a WebSocket for a caller"""
self._websockets[caller_id] = websocket

View File

@@ -0,0 +1,95 @@
"""Returning caller persistence service"""
import json
import time
import uuid
from pathlib import Path
from typing import Optional
DATA_FILE = Path(__file__).parent.parent.parent / "data" / "regulars.json"
MAX_REGULARS = 12
class RegularCallerService:
"""Manages persistent 'regular' callers who return across sessions"""
def __init__(self):
self._regulars: list[dict] = []
self._load()
def _load(self):
if DATA_FILE.exists():
try:
with open(DATA_FILE) as f:
data = json.load(f)
self._regulars = data.get("regulars", [])
print(f"[Regulars] Loaded {len(self._regulars)} regular callers")
except Exception as e:
print(f"[Regulars] Failed to load: {e}")
self._regulars = []
def _save(self):
try:
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(DATA_FILE, "w") as f:
json.dump({"regulars": self._regulars}, f, indent=2)
except Exception as e:
print(f"[Regulars] Failed to save: {e}")
def get_regulars(self) -> list[dict]:
return list(self._regulars)
def get_returning_callers(self, count: int = 2) -> list[dict]:
"""Get up to `count` regulars for returning caller slots"""
import random
if not self._regulars:
return []
available = [r for r in self._regulars if len(r.get("call_history", [])) > 0]
if not available:
return []
return random.sample(available, min(count, len(available)))
def add_regular(self, name: str, gender: str, age: int, job: str,
location: str, personality_traits: list[str],
first_call_summary: str) -> dict:
"""Promote a first-time caller to regular"""
# Retire oldest if at cap
if len(self._regulars) >= MAX_REGULARS:
self._regulars.sort(key=lambda r: r.get("last_call", 0))
retired = self._regulars.pop(0)
print(f"[Regulars] Retired {retired['name']} to make room")
regular = {
"id": str(uuid.uuid4())[:8],
"name": name,
"gender": gender,
"age": age,
"job": job,
"location": location,
"personality_traits": personality_traits,
"call_history": [
{"summary": first_call_summary, "timestamp": time.time()}
],
"last_call": time.time(),
"created_at": time.time(),
}
self._regulars.append(regular)
self._save()
print(f"[Regulars] Promoted {name} to regular (total: {len(self._regulars)})")
return regular
def update_after_call(self, regular_id: str, call_summary: str):
"""Update a regular's history after a returning call"""
for regular in self._regulars:
if regular["id"] == regular_id:
regular.setdefault("call_history", []).append(
{"summary": call_summary, "timestamp": time.time()}
)
regular["last_call"] = time.time()
self._save()
print(f"[Regulars] Updated {regular['name']} call history ({len(regular['call_history'])} calls)")
return
print(f"[Regulars] Regular {regular_id} not found for update")
regular_caller_service = RegularCallerService()