ratandeep commited on
Commit
b852032
·
verified ·
1 Parent(s): eddd90b

SP2: AI showrunner — mood-matched songs, smart pacing, spoken through-line

Browse files
Files changed (4) hide show
  1. .pytest_cache/v/cache/nodeids +2 -0
  2. proxy.py +3 -0
  3. radio.html +74 -6
  4. tests/test_proxy.py +29 -0
.pytest_cache/v/cache/nodeids CHANGED
@@ -48,8 +48,10 @@
48
  "tests/test_proxy.py::test_segment_dedication_memory_aware",
49
  "tests/test_proxy.py::test_segment_dedication_no_memory_is_templated",
50
  "tests/test_proxy.py::test_segment_fallback_is_never_failing_and_well_shaped",
 
51
  "tests/test_proxy.py::test_song_intro_template_includes_handle",
52
  "tests/test_proxy.py::test_song_intro_template_omits_handle_when_absent",
 
53
  "tests/test_proxy.py::test_speakable_normalizes_nightwave_for_tts",
54
  "tests/test_proxy.py::test_speakable_titlecases_nightwave",
55
  "tests/test_proxy.py::test_templated_caller_intro_nonempty",
 
48
  "tests/test_proxy.py::test_segment_dedication_memory_aware",
49
  "tests/test_proxy.py::test_segment_dedication_no_memory_is_templated",
50
  "tests/test_proxy.py::test_segment_fallback_is_never_failing_and_well_shaped",
51
+ "tests/test_proxy.py::test_song_intro_no_segue_when_absent",
52
  "tests/test_proxy.py::test_song_intro_template_includes_handle",
53
  "tests/test_proxy.py::test_song_intro_template_omits_handle_when_absent",
54
+ "tests/test_proxy.py::test_song_intro_weaves_segue",
55
  "tests/test_proxy.py::test_speakable_normalizes_nightwave_for_tts",
56
  "tests/test_proxy.py::test_speakable_titlecases_nightwave",
57
  "tests/test_proxy.py::test_templated_caller_intro_nonempty",
proxy.py CHANGED
@@ -534,6 +534,9 @@ def segment_turn(kind: str, ctx: Optional[Dict[str, Any]] = None) -> Dict[str, A
534
  ctx.get("artist", "a friend of the show"),
535
  ctx.get("vibe", "late-night"))
536
  )
 
 
 
537
  rb = ctx.get("recommended_by")
538
  if rb:
539
  user += (
 
534
  ctx.get("artist", "a friend of the show"),
535
  ctx.get("vibe", "late-night"))
536
  )
537
+ if ctx.get("segue"):
538
+ user = ("Open with a brief, warm half-sentence through-line -- %s -- and then "
539
+ % ctx["segue"]) + user[0].lower() + user[1:]
540
  rb = ctx.get("recommended_by")
541
  if rb:
542
  user += (
radio.html CHANGED
@@ -681,6 +681,11 @@ const State = {
681
  connecting:false, // caller-intro is playing before the mic opens
682
  sessionMemory:null, // SP1: most recent caller {caller_name,place,topic,mood}
683
  dedicationQueue:[], // SP1: pending caller dedications (cap 2)
 
 
 
 
 
684
  warming:false // power-on warm-up ritual is in progress (visual only)
685
  };
686
 
@@ -1168,6 +1173,60 @@ function pickSong(){
1168
  let s; do { s = bank[Math.floor(Math.random() * bank.length)]; } while (bank.length > 1 && s === State.song);
1169
  return s;
1170
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1171
  function showNowPlaying(track){
1172
  if (!els.nowPlaying || !track) return;
1173
  els.nowPlaying.classList.add("playing");
@@ -1209,11 +1268,13 @@ function chooseBreakKinds(){
1209
  return picks; // song_intro is appended in prepareBreak (always last)
1210
  }
1211
  // Buffer a whole break: DJ segments + the upcoming track's intro. -> {segs, track}
1212
- async function prepareBreak(track){
 
 
1213
  const segs = [];
1214
  // A queued caller dedication takes precedence over a generic one this break.
1215
  const callerDed = State.dedicationQueue.length ? State.dedicationQueue.shift() : null;
1216
- let kinds = chooseBreakKinds();
1217
  if (callerDed) kinds = kinds.filter(k => k !== "dedication");
1218
  for (const k of kinds){
1219
  const ctx = (k === "local_weather") ? State.locale : null;
@@ -1224,11 +1285,16 @@ async function prepareBreak(track){
1224
  if (d) segs.push(d);
1225
  }
1226
  }
1227
- const intro = await fetchSegment("song_intro",
1228
- { title:track.title, artist:track.artist, vibe:track.vibe, recommended_by:track.recommended_by });
 
1229
  segs.push(intro || { kind:"song_intro", mood:"warm", arc_cue:"none",
1230
  text:"Coming up next, " + track.title + " by " + track.artist + ".",
1231
  audio_b64: SILENT_WAV, words:[], wtimes:[] });
 
 
 
 
1232
  return { segs, track };
1233
  }
1234
  async function playDJ(s){
@@ -1247,7 +1313,7 @@ async function runShow(){
1247
  // ensure a buffered break (only the opening one blocks, under the bed)
1248
  if (!State.pendingBreak){
1249
  showCaption("… NIGHTWAVE is coming on the air …", "status");
1250
- State.pendingBreak = await prepareBreak(pickSong());
1251
  }
1252
  const brk = State.pendingBreak; State.pendingBreak = null;
1253
  if (!State.powered || State.recording) continue;
@@ -1266,7 +1332,7 @@ async function runShow(){
1266
  const player = renderSong(track);
1267
  State.songPlayer = player;
1268
  // 3) dead-air gating: buffer the NEXT break while this song plays
1269
- prepareBreak(pickSong()).then(b => { State.pendingBreak = b; }).catch(()=>{});
1270
  if (player.setLevel) player.setLevel(0.28, 0.6); // duck the record under the DJ intro
1271
  await playDJ(intro); // rides over the song's fade-in
1272
  if (player.setLevel) player.setLevel(0.5, 1.2); // bring the record back up
@@ -1753,6 +1819,7 @@ async function onCallStop(){
1753
  if (data && typeof data.meter_delta === "number") addMeter(data.meter_delta);
1754
  if (data && data.memory_patch) State.sessionMemory = data.memory_patch;
1755
  if (data && data.queue_dedication) queueDedication(data.memory_patch);
 
1756
  await playSegment(data, data && data.caller_text);
1757
  }
1758
 
@@ -2034,6 +2101,7 @@ function wire(){
2034
  // Hidden judge demo trigger: ?demo=1 + Shift+D queues a caller dedication for the
2035
  // next break, so the callback is reliably demoable. Not visible product UI.
2036
  if (/[?&]demo=1\b/.test(location.search)){
 
2037
  window.addEventListener("keydown", e=>{
2038
  if (e.shiftKey && (e.key === "D" || e.key === "d")){
2039
  e.preventDefault();
 
681
  connecting:false, // caller-intro is playing before the mic opens
682
  sessionMemory:null, // SP1: most recent caller {caller_name,place,topic,mood}
683
  dedicationQueue:[], // SP1: pending caller dedications (cap 2)
684
+ recentKinds:[], // SP2: rolling log of content kinds (cap 8) for pacing
685
+ breaksSinceCaller:99, // SP2: breaks since last call-in (high = none yet)
686
+ lastSegueBreak:-99, // SP2: break index of last voiced through-line
687
+ breakIndex:0, // SP2: monotonic break counter
688
+ demo:false, // SP2: ?demo=1 logs showrunner picks
689
  warming:false // power-on warm-up ritual is in progress (visual only)
690
  };
691
 
 
1173
  let s; do { s = bank[Math.floor(Math.random() * bank.length)]; } while (bank.length > 1 && s === State.song);
1174
  return s;
1175
  }
1176
+ // ---- SP2: AI showrunner (context-aware policy, deterministic, never blocks) ----
1177
+ const MOOD_VIBES = {
1178
+ warm: ["warm","cozy","hopeful","nostalgic","breezy","gentle"],
1179
+ nostalgic: ["nostalgic","wistful","bittersweet","melancholy","jazzy","romantic"],
1180
+ tender: ["tender","gentle","lonely","romantic","wistful","melancholy"],
1181
+ searching: ["pensive","mysterious","hypnotic","dreamy","eerie","ambient"],
1182
+ hollow: ["lonely","melancholy","eerie","ambient","bittersweet","pensive"],
1183
+ uneasy: ["eerie","mysterious","hypnotic","pensive","ambient","lonely"]
1184
+ };
1185
+ const MOOD_SEGUE = {
1186
+ warm:"the night's warm and easy", nostalgic:"we're feeling a little nostalgic",
1187
+ tender:"the hour's gone tender", searching:"a thoughtful stretch, this",
1188
+ hollow:"for the quiet, lonely hours", uneasy:"there's something restless in the air"
1189
+ };
1190
+ function pickSongForMood(mood, avoid){
1191
+ const bank = (State.songs && State.songs.length) ? State.songs : CLIENT_SONGS;
1192
+ const vibes = MOOD_VIBES[mood] || MOOD_VIBES.warm;
1193
+ const avoidVibe = avoid && avoid.vibe;
1194
+ const cand = bank.filter(s => vibes.indexOf(s.vibe) >= 0 && s !== avoid && s.vibe !== avoidVibe);
1195
+ if (!cand.length) return pickSong();
1196
+ const weighted = [];
1197
+ cand.forEach(s => { const w = vibes.length - vibes.indexOf(s.vibe); for (let i=0;i<w;i++) weighted.push(s); });
1198
+ return weighted[Math.floor(Math.random()*weighted.length)];
1199
+ }
1200
+ function runShowrunner(){
1201
+ try{
1202
+ const mood = (State.sessionMemory && State.breaksSinceCaller <= 3)
1203
+ ? (State.sessionMemory.mood || "warm") : "warm";
1204
+ const song = pickSongForMood(mood, State.song);
1205
+ const weatherKind = (State.locale && State.locale.resolved) ? "local_weather" : "weather";
1206
+ const recent2 = State.recentKinds.slice(-2);
1207
+ let pool = ["thought", weatherKind, "dedication"].filter(k => recent2.indexOf(k) < 0);
1208
+ if (!pool.length) pool = ["thought", weatherKind, "dedication"];
1209
+ const kinds = ["rejoin"];
1210
+ const extra = (Math.random() < 0.5) ? 1 : 2;
1211
+ for (let i=0;i<extra;i++){ // distinct content kinds this break
1212
+ const avail = pool.filter(k => kinds.indexOf(k) < 0);
1213
+ const src = avail.length ? avail : pool;
1214
+ kinds.push(src[Math.floor(Math.random()*src.length)]);
1215
+ }
1216
+ if (State.breakIndex % 3 === 0) kinds.push("station_id");
1217
+ let segueHint = null;
1218
+ const sinceSegue = State.breakIndex - State.lastSegueBreak;
1219
+ if ((State.breaksSinceCaller === 1 || sinceSegue >= 5) && sinceSegue >= 2){
1220
+ segueHint = (State.breaksSinceCaller === 1) ? "right after that caller"
1221
+ : (MOOD_SEGUE[mood] || MOOD_SEGUE.warm);
1222
+ State.lastSegueBreak = State.breakIndex;
1223
+ }
1224
+ if (State.demo) try{ console.log("showrunner:", mood, "->", song.title, "("+song.vibe+")", segueHint||""); }catch(_){}
1225
+ return { kinds, song, tone:mood, segueHint };
1226
+ }catch(_){
1227
+ return { kinds: chooseBreakKinds(), song: pickSong(), tone:"warm", segueHint:null };
1228
+ }
1229
+ }
1230
  function showNowPlaying(track){
1231
  if (!els.nowPlaying || !track) return;
1232
  els.nowPlaying.classList.add("playing");
 
1268
  return picks; // song_intro is appended in prepareBreak (always last)
1269
  }
1270
  // Buffer a whole break: DJ segments + the upcoming track's intro. -> {segs, track}
1271
+ async function prepareBreak(){
1272
+ const plan = runShowrunner();
1273
+ const track = plan.song;
1274
  const segs = [];
1275
  // A queued caller dedication takes precedence over a generic one this break.
1276
  const callerDed = State.dedicationQueue.length ? State.dedicationQueue.shift() : null;
1277
+ let kinds = plan.kinds.slice();
1278
  if (callerDed) kinds = kinds.filter(k => k !== "dedication");
1279
  for (const k of kinds){
1280
  const ctx = (k === "local_weather") ? State.locale : null;
 
1285
  if (d) segs.push(d);
1286
  }
1287
  }
1288
+ const introCtx = { title:track.title, artist:track.artist, vibe:track.vibe, recommended_by:track.recommended_by };
1289
+ if (plan.segueHint) introCtx.segue = plan.segueHint;
1290
+ const intro = await fetchSegment("song_intro", introCtx);
1291
  segs.push(intro || { kind:"song_intro", mood:"warm", arc_cue:"none",
1292
  text:"Coming up next, " + track.title + " by " + track.artist + ".",
1293
  audio_b64: SILENT_WAV, words:[], wtimes:[] });
1294
+ State.breakIndex++;
1295
+ State.breaksSinceCaller++;
1296
+ for (const k of kinds){ if (k !== "rejoin" && k !== "station_id") State.recentKinds.push(k); }
1297
+ while (State.recentKinds.length > 8) State.recentKinds.shift();
1298
  return { segs, track };
1299
  }
1300
  async function playDJ(s){
 
1313
  // ensure a buffered break (only the opening one blocks, under the bed)
1314
  if (!State.pendingBreak){
1315
  showCaption("… NIGHTWAVE is coming on the air …", "status");
1316
+ State.pendingBreak = await prepareBreak();
1317
  }
1318
  const brk = State.pendingBreak; State.pendingBreak = null;
1319
  if (!State.powered || State.recording) continue;
 
1332
  const player = renderSong(track);
1333
  State.songPlayer = player;
1334
  // 3) dead-air gating: buffer the NEXT break while this song plays
1335
+ prepareBreak().then(b => { State.pendingBreak = b; }).catch(()=>{});
1336
  if (player.setLevel) player.setLevel(0.28, 0.6); // duck the record under the DJ intro
1337
  await playDJ(intro); // rides over the song's fade-in
1338
  if (player.setLevel) player.setLevel(0.5, 1.2); // bring the record back up
 
1819
  if (data && typeof data.meter_delta === "number") addMeter(data.meter_delta);
1820
  if (data && data.memory_patch) State.sessionMemory = data.memory_patch;
1821
  if (data && data.queue_dedication) queueDedication(data.memory_patch);
1822
+ State.breaksSinceCaller = 0; // SP2: next break reflects the caller's mood
1823
  await playSegment(data, data && data.caller_text);
1824
  }
1825
 
 
2101
  // Hidden judge demo trigger: ?demo=1 + Shift+D queues a caller dedication for the
2102
  // next break, so the callback is reliably demoable. Not visible product UI.
2103
  if (/[?&]demo=1\b/.test(location.search)){
2104
+ State.demo = true;
2105
  window.addEventListener("keydown", e=>{
2106
  if (e.shiftKey && (e.key === "D" || e.key === "d")){
2107
  e.preventDefault();
tests/test_proxy.py CHANGED
@@ -284,3 +284,32 @@ def test_segment_dedication_junk_falls_back_to_name(monkeypatch):
284
  lambda t, voice=arc.VOICE: {"audio_b64": "", "words": [], "wtimes": [], "wdurations": []})
285
  out = proxy.segment_turn("dedication", {"topic": "waiting up", "caller_name": "Sam"})
286
  assert "Sam" in out["text"] # _dedication_fallback weaves the name
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  lambda t, voice=arc.VOICE: {"audio_b64": "", "words": [], "wtimes": [], "wdurations": []})
285
  out = proxy.segment_turn("dedication", {"topic": "waiting up", "caller_name": "Sam"})
286
  assert "Sam" in out["text"] # _dedication_fallback weaves the name
287
+
288
+
289
+ def test_song_intro_weaves_segue(monkeypatch):
290
+ captured = {}
291
+
292
+ def _brain(system, messages):
293
+ captured["user"] = messages[-1]["content"]
294
+ return {"text": "After that caller, a soft one -- 'Neon Rain'.", "mood": "tender", "arc_cue": "none"}
295
+
296
+ monkeypatch.setattr(proxy, "call_brain", _brain)
297
+ monkeypatch.setattr(proxy, "call_speak",
298
+ lambda t, voice=arc.VOICE: {"audio_b64": "", "words": [], "wtimes": [], "wdurations": []})
299
+ proxy.segment_turn("song_intro", {"title": "Neon Rain", "artist": "The Tuesday Ghosts",
300
+ "vibe": "melancholy", "segue": "right after that caller"})
301
+ assert "right after that caller" in captured["user"]
302
+
303
+
304
+ def test_song_intro_no_segue_when_absent(monkeypatch):
305
+ captured = {}
306
+
307
+ def _brain(system, messages):
308
+ captured["user"] = messages[-1]["content"]
309
+ return {"text": "Coming up, 'Neon Rain'.", "mood": "warm", "arc_cue": "none"}
310
+
311
+ monkeypatch.setattr(proxy, "call_brain", _brain)
312
+ monkeypatch.setattr(proxy, "call_speak",
313
+ lambda t, voice=arc.VOICE: {"audio_b64": "", "words": [], "wtimes": [], "wdurations": []})
314
+ proxy.segment_turn("song_intro", {"title": "Neon Rain", "artist": "The Tuesday Ghosts", "vibe": "melancholy"})
315
+ assert "through-line" not in captured["user"]