Spaces:
Running
Running
SP2: AI showrunner — mood-matched songs, smart pacing, spoken through-line
Browse files- .pytest_cache/v/cache/nodeids +2 -0
- proxy.py +3 -0
- radio.html +74 -6
- 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(
|
|
|
|
|
|
|
| 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 =
|
| 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
|
| 1228 |
-
|
|
|
|
| 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(
|
| 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(
|
| 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"]
|