2026-10-22 19:25:56 +00:00
// gandora MCP spike: one stateless Streamable-HTTP MCP endpoint (POST /mcp, JSON responses) exposing MCP Apps.
// ui://gandora/probe the audio probe (synth / remote / blob) — answered Oct 22 2026: all three play in claude.ai
// ui://gandora/radio Claude as the DJ: dj_play(sheet) shows a card that renders the track and the DJ's spoken line
// through app-only tools, plays voice then song, and (auto-DJ) asks Claude for the next one.
// Rendering goes through the shared inference worker by service binding (env.INFERENCE -> time-inference), as app
// "gandora" from the dev origin, so it shares gandora's quota. Throwaway: see mcp/README.md.
2026-10-22 19:13:41 +00:00
const MEDIA = "https://dev.gandora.lol" ;
2026-10-22 19:25:56 +00:00
const ORIGIN = "https://dev.gandora.lol" ;
const TRACKS = { sky : "/asmr/audio/kokoro-sky.mp3" , rain : "/asmr/audio/bed-rain.mp3" };
const PROBE_URI = "ui://gandora/probe" ;
2026-10-22 19:51:51 +00:00
const RADIO_URI = "ui://gandora/station" ;
2026-10-22 19:13:41 +00:00
const UI_MIME = "text/html;profile=mcp-app" ;
const UI_META = { ui : { csp : { resourceDomains : [ MEDIA ], connectDomains : [ MEDIA ] }, prefersBorder : true } };
2026-10-22 19:25:56 +00:00
const APP_ONLY = { ui : { visibility : [ "app" ] } };
const SHEET = {
type : "object" ,
properties : {
title : { type : "string" , description : "Song title" },
tags : { type : "string" , description : "ACE-Step style prompt: genre, instruments, mood, vocal type, production. Comma-separated." },
lyrics : { type : "string" , description : "Optional lyrics with [verse]/[chorus] section tags. Empty for instrumental." },
bpm : { type : "number" , description : "30-300" },
key : { type : "string" , description : "e.g. 'A minor', 'F# major'" },
meter : { type : "string" , description : "2/4, 3/4, 4/4 or 6/8" , default : "4/4" },
seconds : { type : "number" , description : "Length, 30-120. Default 60." , default : 60 },
dj_line : { type : "string" , description : "What the DJ says before the track (1-3 sentences, spoken by TTS). Optional." },
voice : { type : "string" , description : "Kokoro voice for the DJ line, e.g. af_sky, af_nicole, am_adam, bf_emma. Default af_sky." },
2026-10-22 19:45:05 +00:00
station : { type : "string" , description : "The station's theme in a few words (e.g. 'city girl: late-night city pop'). The card keeps playing tracks on this theme by itself." },
2026-10-22 19:25:56 +00:00
},
required : [ "title" , "tags" ],
};
2026-10-22 19:13:41 +00:00
const TOOLS = [
2026-10-22 19:25:56 +00:00
{ name : "open_probe" , title : "Open gandora audio probe" ,
description : "Shows the gandora audio probe player in chat (an MCP App)." ,
2026-10-22 19:13:41 +00:00
inputSchema : { type : "object" , properties : {} },
2026-10-22 19:25:56 +00:00
_meta : { ui : { resourceUri : PROBE_URI }, "ui/resourceUri" : PROBE_URI } },
2026-10-22 19:51:51 +00:00
{ name : "dj_station" , title : "Open a gandora station" ,
description : "Preferred over dj_play. You are the gandora DJ: write the opening track sheet plus a station theme. A self-running station card appears in chat: it renders and plays your track, then keeps the station going by itself (a small DJ model writes each following sheet, steered by the listener's Like / Not this / Next), with no messages to the chat. Its play log reaches you as model context. Call again only for a new station or a change of direction." ,
inputSchema : SHEET ,
_meta : { ui : { resourceUri : RADIO_URI }, "ui/resourceUri" : RADIO_URI } },
2026-10-22 19:25:56 +00:00
{ name : "dj_play" , title : "Play a track on gandora" ,
2026-10-22 19:45:05 +00:00
description : "You are the gandora DJ. Write the opening track sheet (and a station theme) and put it on air: a player card appears in chat, renders the song (ACE-Step, ~15-60 s), speaks your dj_line, then plays. The card then keeps the station going on its own (a small DJ model writes the following sheets in your style, steered by the listener's Like / Not this / Next), so you do not need to call dj_play per track. Its play log and reactions reach you as model context. Call dj_play again only when the listener asks you for a change of direction or a new station." ,
2026-10-22 19:25:56 +00:00
inputSchema : SHEET ,
_meta : { ui : { resourceUri : RADIO_URI }, "ui/resourceUri" : RADIO_URI } },
{ name : "get_track" , title : "Get a gandora test clip" , description : "Probe UI only." ,
inputSchema : { type : "object" , properties : { id : { type : "string" , enum : Object . keys ( TRACKS ) } } }, _meta : APP_ONLY },
{ name : "render_track" , title : "Render a gandora track" , description : "Radio UI only: renders a sheet to mp3 (base64)." ,
inputSchema : SHEET , _meta : APP_ONLY },
2026-10-22 19:45:05 +00:00
{ name : "dj_next" , title : "Write the next sheet" , description : "Radio UI only: the in-card DJ writes the next track sheet from the station and play history." ,
inputSchema : { type : "object" , properties : { station : { type : "string" }, history : { type : "array" } } }, _meta : APP_ONLY },
2026-10-22 19:25:56 +00:00
{ name : "dj_speak" , title : "Speak a DJ line" , description : "Radio UI only: TTS for the DJ line (base64 mp3)." ,
inputSchema : { type : "object" , properties : { text : { type : "string" }, voice : { type : "string" } }, required : [ "text" ] }, _meta : APP_ONLY },
2026-10-22 19:13:41 +00:00
];
function b64 ( buf ) {
const u = new Uint8Array ( buf );
let s = "" ;
for ( let i = 0 ; i < u . length ; i += 0x8000 ) s += String . fromCharCode . apply ( null , u . subarray ( i , i + 0x8000 ));
return btoa ( s );
}
2026-10-22 19:25:56 +00:00
const fail = ( text ) => ({ isError : true , content : [{ type : "text" , text }] });
const clamp = ( n , lo , hi , d ) => { n = + n ; return Number . isFinite ( n ) ? Math . max ( lo , Math . min ( hi , n )) : d ; };
function hash32 ( s ) { let h = 2166136261 ; for ( const c of String ( s )) { h ^= c . charCodeAt ( 0 ); h = Math . imul ( h , 16777619 ); } return h >>> 1 ; }
2026-10-22 19:13:41 +00:00
2026-10-22 19:25:56 +00:00
async function inference ( env , path , body ) {
if ( ! env . INFERENCE ) throw new Error ( "no INFERENCE service binding on this worker" );
const r = await env . INFERENCE . fetch ( "https://inference.yolopreneur.lol" + path , {
method : "POST" , body : JSON . stringify ( body ),
headers : { origin : ORIGIN , "x-app" : "gandora" , "content-type" : "application/json" , accept : "*/*" } });
if ( ! r . ok ) { let t = "" ; try { t = ( await r . json ()). error || "" ; } catch {} throw new Error ( path + " -> " + r . status + ( t ? ": " + t : "" )); }
return r ;
}
async function callTool ( name , a , env ) {
a = a || {};
if ( name === "open_probe" )
return { content : [{ type : "text" , text : "gandora probe opened." }], structuredContent : { media : MEDIA , tracks : Object . keys ( TRACKS ) } };
2026-10-22 19:13:41 +00:00
if ( name === "get_track" ) {
2026-10-22 19:25:56 +00:00
const path = TRACKS [ a . id || "sky" ]; if ( ! path ) return fail ( "unknown track" );
const r = await fetch ( MEDIA + path ); if ( ! r . ok ) return fail ( "fetch " + r . status );
return { content : [{ type : "text" , text : "clip" }], structuredContent : { id : a . id || "sky" , mimeType : "audio/mpeg" , data : b64 ( await r . arrayBuffer ()) } };
}
2026-10-22 19:51:51 +00:00
if ( name === "dj_play" || name === "dj_station" ) {
2026-10-22 19:25:56 +00:00
const sheet = { ... a , seconds : clamp ( a . seconds , 30 , 120 , 60 ), seed : hash32 ( a . title + "|" + a . tags ) };
return { content : [{ type : "text" , text : `On air: " ${ a . title } " ( ${ sheet . seconds } s). The card is rendering it now; wait for a [gandora] message before playing the next one.` }],
structuredContent : sheet };
}
if ( name === "render_track" ) {
try {
const r = await inference ( env , "/v1/render" , { prompt : a . tags , lyrics : a . lyrics || "" , duration : clamp ( a . seconds , 30 , 120 , 60 ),
bpm : a . bpm , key : a . key , timeSignature : a . meter || "4/4" , seed : a . seed ?? hash32 ( a . title + "|" + a . tags ), quality : "turbo" });
const data = b64 ( await r . arrayBuffer ());
return { content : [{ type : "text" , text : "rendered" }],
structuredContent : { mimeType : r . headers . get ( "content-type" ) || "audio/mpeg" , data , renderer : r . headers . get ( "x-renderer" ) || "" , cost : r . headers . get ( "x-cost" ) || "" } };
} catch ( e ) { return fail ( String ( e . message || e )); }
}
2026-10-22 19:45:05 +00:00
if ( name === "dj_next" ) {
try {
const hist = ( Array . isArray ( a . history ) ? a . history : []). slice ( - 8 );
const sys = "You are the late-night DJ of gandora, a generative radio station. Write the NEXT track sheet as one JSON object and nothing else: " +
"{\"title\",\"tags\",\"lyrics\",\"bpm\",\"key\",\"seconds\",\"dj_line\"}. tags: an ACE-Step style prompt (genre, instruments, mood, vocal type, production), comma-separated, under 300 chars. " +
"lyrics: original, short ([verse] 4 lines, [chorus] 4 lines), never quote real songs; or empty for an instrumental now and then. key like 'A minor'. seconds 60-90. " +
"dj_line: 1-2 warm, short sentences spoken before the track, can reference the last track. Stay on the station theme but vary tempo, key, texture and title; " +
"lean toward what was liked, away from what was disliked or skipped." ;
const user = JSON . stringify ({ station : a . station || "" , recent : hist . map (( h ) => ({ title : h . title , tags : h . tags , bpm : h . bpm , key : h . key , reaction : h . reaction || "played" })) });
const r = await inference ( env , "/v1/chat/completions" , { model : "claude-haiku-5-5" , max_tokens : 900 , temperature : 1 ,
messages : [{ role : "system" , content : sys }, { role : "user" , content : user }] });
const d = await r . json ();
const text = String ( d . choices ? .[ 0 ] ? . message ? . content || "" );
const m = text . match ( /\{[\s\S]*\}/ ); if ( ! m ) return fail ( "DJ wrote no sheet" );
const s = JSON . parse ( m [ 0 ]);
if ( ! s . title || ! s . tags ) return fail ( "DJ sheet missing title/tags" );
const sheet = { ... s , station : a . station || "" , seconds : clamp ( s . seconds , 30 , 120 , 75 ), seed : hash32 ( s . title + "|" + s . tags ) };
return { content : [{ type : "text" , text : "next: " + sheet . title }], structuredContent : sheet };
} catch ( e ) { return fail ( String ( e . message || e )); }
}
2026-10-22 19:25:56 +00:00
if ( name === "dj_speak" ) {
try {
const r = await inference ( env , "/v1/speech" , { input : String ( a . text || "" ). slice ( 0 , 600 ), model : "tts-kokoro" , voice : a . voice || "af_sky" , response_format : "mp3" });
return { content : [{ type : "text" , text : "spoken" }], structuredContent : { mimeType : "audio/mpeg" , data : b64 ( await r . arrayBuffer ()) } };
} catch ( e ) { return fail ( String ( e . message || e )); }
2026-10-22 19:13:41 +00:00
}
return null ;
}
2026-10-22 19:25:56 +00:00
async function handle ( msg , env ) {
2026-10-22 19:13:41 +00:00
const { id , method , params } = msg ;
const ok = ( result ) => ({ jsonrpc : "2.0" , id , result });
const err = ( code , message ) => ({ jsonrpc : "2.0" , id , error : { code , message } });
switch ( method ) {
case "initialize" :
2026-10-22 19:25:56 +00:00
return ok ({ protocolVersion : ( params && params . protocolVersion ) || "2025-06-18" , capabilities : { tools : {}, resources : {} },
serverInfo : { name : "gandora" , title : "gandora" , version : "0.0.2-spike" },
instructions : "gandora: generative radio, with you as the DJ. dj_play puts a track on air in chat. Messages starting with [gandora] come from the player (track ended, next, reactions): answer them with the next dj_play." });
case "ping" : return ok ({});
case "tools/list" : return ok ({ tools : TOOLS });
2026-10-22 19:13:41 +00:00
case "tools/call" : {
2026-10-22 19:25:56 +00:00
const res = await callTool ( params && params . name , params && params . arguments , env );
2026-10-22 19:13:41 +00:00
return res ? ok ( res ) : err ( - 32602 , `unknown tool ${ params && params . name } ` );
}
case "resources/list" :
2026-10-22 19:25:56 +00:00
return ok ({ resources : [
{ uri : PROBE_URI , name : "gandora probe" , mimeType : UI_MIME , _meta : UI_META },
{ uri : RADIO_URI , name : "gandora radio" , mimeType : UI_MIME , _meta : UI_META } ] });
case "resources/templates/list" : return ok ({ resourceTemplates : [] });
2026-10-22 19:13:41 +00:00
case "resources/read" :
2026-10-22 19:25:56 +00:00
if ( params && params . uri === PROBE_URI ) return ok ({ contents : [{ uri : PROBE_URI , mimeType : UI_MIME , text : PROBE_HTML , _meta : UI_META }] });
2026-10-22 19:51:51 +00:00
if ( params && ( params . uri === RADIO_URI || params . uri === "ui://gandora/radio" || params . uri === "ui://gandora/radio-v3" )) return ok ({ contents : [{ uri : RADIO_URI , mimeType : UI_MIME , text : RADIO_HTML , _meta : UI_META }] });
2026-10-22 19:13:41 +00:00
return err ( - 32002 , "resource not found" );
default :
2026-10-22 19:25:56 +00:00
if ( id === undefined ) return null ;
2026-10-22 19:13:41 +00:00
return err ( - 32601 , `method not found: ${ method } ` );
}
}
const CORS = {
"access-control-allow-origin" : "*" ,
"access-control-allow-headers" : "content-type, mcp-session-id, mcp-protocol-version, authorization" ,
"access-control-allow-methods" : "GET, POST, DELETE, OPTIONS" ,
};
export default {
2026-10-22 19:25:56 +00:00
async fetch ( req , env ) {
2026-10-22 19:13:41 +00:00
const url = new URL ( req . url );
if ( req . method === "OPTIONS" ) return new Response ( null , { headers : CORS });
2026-10-22 19:25:56 +00:00
if ( url . pathname === "/health" ) return new Response ( "ok gandora-mcp 0.0.2 inference=" + ( env . INFERENCE ? "bound" : "missing" ), { headers : CORS });
2026-10-22 19:26:11 +00:00
const want = env . TOKEN ? "/mcp/" + env . TOKEN : "/mcp" ;
2026-10-22 19:53:48 +00:00
if ( url . pathname !== want && ! url . pathname . startsWith ( want + "/" )) return new Response ( "gandora mcp spike" , { status : 404 , headers : CORS });
2026-10-22 19:13:41 +00:00
if ( req . method !== "POST" ) return new Response ( null , { status : 405 , headers : CORS });
let body ;
try { body = await req . json (); } catch { return Response . json ({ jsonrpc : "2.0" , id : null , error : { code : - 32700 , message : "parse error" } }, { status : 400 , headers : CORS }); }
const batch = Array . isArray ( body );
2026-10-22 19:25:56 +00:00
const out = ( await Promise . all (( batch ? body : [ body ]). map (( m ) => handle ( m , env )))). filter ( Boolean );
2026-10-22 19:13:41 +00:00
if ( ! out . length ) return new Response ( null , { status : 202 , headers : CORS });
return Response . json ( batch ? out : out [ 0 ], { headers : CORS });
},
};
2026-10-22 19:25:56 +00:00
/* the host bridge both UIs share: JSON-RPC over postMessage (matched the claude.ai host in the Oct 22 probe) */
const BRIDGE = String . raw `
let nid=1;const pend={},listeners={};
function rpc(method,params,ms){const id=nid++;return new Promise((res,rej)=>{pend[id]={res,rej};parent.postMessage({jsonrpc:'2.0',id,method,params},'*');setTimeout(()=>{if(pend[id]){delete pend[id];rej(new Error('timeout '+method))}},ms||20000)})}
function note(method,params){parent.postMessage({jsonrpc:'2.0',method,params},'*')}
function on(method,fn){listeners[method]=fn}
addEventListener('message',e=>{const m=e.data;if(!m||m.jsonrpc!=='2.0')return;if(m.id!=null&&pend[m.id]&&!m.method){const p=pend[m.id];delete pend[m.id];m.error?p.rej(new Error(m.error.message||JSON.stringify(m.error))):p.res(m.result)}else if(m.method){if(listeners[m.method])listeners[m.method](m.params||{});if(m.id!=null)parent.postMessage({jsonrpc:'2.0',id:m.id,result:{}},'*')}});
function say(text){return rpc('ui/message',{role:'user',content:[{type:'text',text}]})}
function bytes(sc){const bin=atob(sc.data),u=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)u[i]=bin.charCodeAt(i);return URL.createObjectURL(new Blob([u],{type:sc.mimeType||'audio/mpeg'}))}
async function tool(name,args,ms){const r=await rpc('tools/call',{name,arguments:args},ms);if(r&&r.isError)throw new Error((r.content&&r.content[0]&&r.content[0].text)||'tool error');return r&&r.structuredContent}
` ;
2026-10-22 19:13:41 +00:00
const PROBE_HTML = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
2026-10-22 19:25:56 +00:00
<style>:root{color-scheme:light dark;font-family:system-ui,sans-serif}body{margin:0;padding:14px;background:transparent;color:CanvasText}
.row{display:flex;justify-content:space-between;align-items:center;gap:8px;font-size:14px;margin:8px 0}.s{font-size:12px;opacity:.75}
button{font:inherit;font-size:13px;padding:5px 10px;border-radius:7px;border:1px solid #8886;background:transparent;color:inherit;cursor:pointer}</style></head><body>
<b>gandora · probe</b><div id="rows"></div>
<script> ${ BRIDGE }
const ready=rpc('ui/initialize',{protocolVersion:'2026-01-26',appInfo:{name:'gandora-probe',version:'0.0.2'},appCapabilities:{}}).then(()=>{note('ui/notifications/initialized',{});return true}).catch(()=>false);
2026-10-22 19:13:41 +00:00
const rows=document.getElementById('rows');
2026-10-22 19:25:56 +00:00
[['remote',' ${ MEDIA } /asmr/audio/bed-rain.mp3'],['blob','']].forEach(([id,src])=>{const d=document.createElement('div');d.className='row';d.innerHTML='<span>'+id+'</span><span class="s" id="s-'+id+'">not run</span><button>Play</button>';
d.querySelector('button').onclick=async()=>{const s=document.getElementById('s-'+id);try{const u=src||bytes(await tool('get_track',{id:'sky'}));const a=new Audio(u);await a.play();s.textContent='playing';setTimeout(()=>a.pause(),4000)}catch(e){s.textContent='error: '+e.message}};rows.appendChild(d)});
</script></body></html>` ;
2026-10-22 19:45:47 +00:00
const RADIO_HTML_V2 = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
2026-10-22 19:25:56 +00:00
<style>
:root{color-scheme:light dark;font-family:system-ui,sans-serif;--mut:color-mix(in srgb,CanvasText 55%,transparent);--line:color-mix(in srgb,CanvasText 18%,transparent)}
2026-10-22 19:40:39 +00:00
body{margin:0;padding:16px 16px 18px;background:transparent;color:CanvasText}
.card{display:flex;gap:16px;align-items:center}
canvas{width:150px;height:150px;border-radius:14px;flex:none}
@media (max-width:480px){canvas{width:110px;height:110px}}
2026-10-22 19:25:56 +00:00
.t{font-size:16px;font-weight:600;margin:0}.g{font-size:12px;color:var(--mut);margin:2px 0 0;line-height:1.4}
.st{font-size:12px;color:var(--mut);margin-top:6px;min-height:16px}
.line{font-size:13px;font-style:italic;margin:10px 0 0;color:var(--mut)}
.bar{height:3px;background:var(--line);border-radius:2px;margin:12px 0 10px;overflow:hidden}.bar i{display:block;height:100%;width:0;background:CanvasText;transition:width .25s linear}
.ctl{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
button{font:inherit;font-size:13px;padding:5px 10px;border-radius:7px;border:1px solid var(--line);background:transparent;color:inherit;cursor:pointer}
button:disabled{opacity:.4;cursor:default}label{font-size:12px;color:var(--mut);display:flex;gap:4px;align-items:center;margin-left:auto}
details{margin-top:10px;font-size:12px;color:var(--mut)}pre{white-space:pre-wrap;font:11px ui-monospace,monospace}
</style></head><body>
2026-10-22 19:40:39 +00:00
<div class="card"><canvas id="cv" width="300" height="300"></canvas>
2026-10-22 19:25:56 +00:00
<div style="min-width:0"><p class="t" id="title">tuning in…</p><p class="g" id="tags"></p><div class="st" id="st">waiting for the DJ</div></div></div>
<p class="line" id="line" hidden></p>
<div class="bar"><i id="pos"></i></div>
<div class="ctl">
2026-10-22 19:40:39 +00:00
<button id="pp" disabled>Play</button><button id="up" disabled>Like</button><button id="down" disabled>Not this</button><button id="next" disabled>Next ↗</button>
2026-10-22 19:25:56 +00:00
<label><input type="checkbox" id="auto" checked> auto-DJ</label></div>
<details><summary>sheet</summary><pre id="sheet"></pre><pre id="log"></pre></details>
<script> ${ BRIDGE }
const $=id=>document.getElementById(id), log=t=>{$('log').textContent+=t+'\\n'};
let S=null,started=false,song=null,voice=null,sent=false,t0=0,tick=null;
const bc=('BroadcastChannel' in window)?new BroadcastChannel('gandora-radio'):null;
const me=Math.random().toString(36).slice(2);
if(bc)bc.onmessage=e=>{if(e.data&&e.data.on&&e.data.on!==me){[song,voice].forEach(a=>a&&a.pause());sent=true;$('pp').textContent='Play'}};
function hue(k){const n=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];const m=String(k||'').match(/^([A-G])(#|b)?/i);if(!m)return 260;let i=n.indexOf(m[1].toUpperCase());if(m[2]==='#')i++;if(m[2]==='b')i--;return((i+12)%12)*30}
function cover(s){const c=$('cv'),x=c.getContext('2d'),h=hue(s.key),minor=/minor/i.test(s.key||''),bpm=+s.bpm||100;let r=s.seed||1;const rnd=()=>{r=(r*1103515245+12345)&0x7fffffff;return r/0x7fffffff};
2026-10-22 19:40:39 +00:00
x.fillStyle='hsl('+h+' 45% '+(minor?18:30)+'%)';x.fillRect(0,0,300,300);const n=3+Math.round(bpm/30);
for(let i=n;i>0;i--){x.beginPath();x.arc(150+(rnd()-.5)*46,150+(rnd()-.5)*46,i*(132/n),0,7);x.strokeStyle='hsl('+((h+i*(minor?8:20))%360)+' 70% '+(55+i*3)+'%)';x.lineWidth=1.5+rnd()*3;x.stroke()}}
2026-10-22 19:25:56 +00:00
function show(s){S=s;$('title').textContent=s.title||'untitled';$('tags').textContent=[s.tags,s.bpm&&(s.bpm+' bpm'),s.key].filter(Boolean).join(' · ');
if(s.dj_line){$('line').textContent='“'+s.dj_line+'”';$('line').hidden=false}$('sheet').textContent=JSON.stringify(s,null,1);cover(s)}
function status(t){$('st').textContent=t}
async function begin(s){if(started)return;started=true;show(s);t0=Date.now();tick=setInterval(()=>status('cooking… '+Math.round((Date.now()-t0)/1000)+'s'),1000);
const vp=s.dj_line?tool('dj_speak',{text:s.dj_line,voice:s.voice},60000).then(bytes).catch(e=>{log('voice: '+e.message);return null}):Promise.resolve(null);
try{const r=await tool('render_track',s,180000);clearInterval(tick);song=new Audio(bytes(r));log('renderer '+r.renderer+' cost '+r.cost);
const vu=await vp;if(vu)voice=new Audio(vu);wire();status('ready · '+Math.round((Date.now()-t0)/1000)+'s to cook');['pp','up','down','next'].forEach(i=>$(i).disabled=false);go()}
catch(e){clearInterval(tick);status('render failed: '+e.message);$('next').disabled=false}}
function wire(){song.ontimeupdate=()=>{$('pos').style.width=(100*song.currentTime/(song.duration||1))+'%'};
2026-10-22 19:40:39 +00:00
song.onended=()=>{$('pp').textContent='Play';status('ended');tell('finished "'+S.title+'" all the way through');if($('auto').checked)ask('track ended: "'+S.title+'". Next track please.')};
2026-10-22 19:25:56 +00:00
if(voice)voice.onended=()=>song.play().catch(()=>{status('tap Play');$('pp').textContent='Play'})}
async function go(){if(bc)bc.postMessage({on:me});sent=false;try{if(voice&&voice.currentTime===0&&song.currentTime===0)await voice.play();else await song.play();$('pp').textContent='Pause';status('on air')}catch(e){status('tap Play to start');$('pp').textContent='Play'}}
$('pp').onclick=()=>{const live=(voice&&!voice.paused)?voice:song;if(!live.paused){live.pause();$('pp').textContent='Play'}else{live.play();$('pp').textContent='Pause';if(bc)bc.postMessage({on:me})}};
async function ask(text){if(sent)return;sent=true;try{await say('[gandora] '+text)}catch(e){log('send: '+e.message);sent=false}}
2026-10-22 19:40:39 +00:00
const events=[];
async function tell(text){events.push(text);status(text);try{await rpc('ui/update-model-context',{content:[{type:'text',text:'[gandora] listener feedback on this card, oldest first:\n- '+events.join('\n- ')}],structuredContent:{title:S&&S.title,events}});log('context: '+text)}catch(e){log('context failed: '+e.message)}}
$('up').onclick=()=>{tell('liked "'+S.title+'" ('+S.tags+')');$('up').textContent='Liked'};
$('down').onclick=()=>{[song,voice].forEach(a=>a&&a.pause());$('pp').textContent='Play';tell('disliked "'+S.title+'" ('+S.tags+'), stopped it')};
const fit=()=>note('ui/notifications/size-changed',{width:document.documentElement.scrollWidth,height:document.documentElement.scrollHeight});
if('ResizeObserver' in window)new ResizeObserver(fit).observe(document.body);addEventListener('load',fit);
2026-10-22 19:25:56 +00:00
$('next').onclick=()=>{[song,voice].forEach(a=>a&&a.pause());ask('skip "'+(S?S.title:'')+'". Next track please.')};
on('ui/notifications/tool-input',p=>{if(p.arguments&&p.arguments.title&&!S)show(p.arguments)});
on('ui/notifications/tool-result',p=>{const sc=p.structuredContent||(p.result&&p.result.structuredContent);if(sc&&sc.tags)begin(sc)});
rpc('ui/initialize',{protocolVersion:'2026-01-26',appInfo:{name:'gandora-radio',version:'0.0.2'},appCapabilities:{}}).then(r=>{log('init ok');note('ui/notifications/initialized',{})}).catch(e=>log('init: '+e.message));
2026-10-22 19:13:41 +00:00
</script></body></html>` ;
2026-10-22 19:45:47 +00:00
/* v3: one card is a whole station. Claude's dj_play sheet opens it; dj_next (a small DJ model) writes each following
sheet, pre-cooked while the current track plays. Reactions and the play log go to Claude as model context only. */
const RADIO_HTML = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<style>
:root{color-scheme:light dark;font-family:system-ui,sans-serif;--mut:color-mix(in srgb,CanvasText 55%,transparent);--line:color-mix(in srgb,CanvasText 18%,transparent)}
html,body{margin:0;background:transparent;color:CanvasText}body{padding:14px 16px}
.card{display:flex;gap:16px;align-items:stretch}
canvas{width:132px;height:132px;border-radius:14px;flex:none}
.side{min-width:0;flex:1;display:flex;flex-direction:column;gap:6px}
.t{font-size:16px;font-weight:600;margin:0;line-height:1.25}.g{font-size:12px;color:var(--mut);margin:0;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.st{font-size:12px;color:var(--mut)}
.bar{height:3px;background:var(--line);border-radius:2px;overflow:hidden}.bar i{display:block;height:100%;width:0;background:CanvasText;transition:width .25s linear}
.ctl{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:auto}
button{font:inherit;font-size:13px;padding:4px 10px;border-radius:7px;border:1px solid var(--line);background:transparent;color:inherit;cursor:pointer}
button:disabled{opacity:.4;cursor:default}button.on{border-color:CanvasText}
label{font-size:12px;color:var(--mut);display:flex;gap:4px;align-items:center}
.line{font-size:13px;font-style:italic;margin:10px 0 0;color:var(--mut)}
.q{font-size:12px;color:var(--mut);margin-top:6px}
details{margin-top:8px;font-size:12px;color:var(--mut)}pre{white-space:pre-wrap;font:11px ui-monospace,monospace;max-height:160px;overflow:auto}
@media (max-width:480px){canvas{width:96px;height:96px}}
</style></head><body>
<div class="card"><canvas id="cv" width="264" height="264"></canvas>
<div class="side"><p class="t" id="title">tuning in…</p><p class="g" id="tags"></p><div class="st" id="st">waiting for the DJ</div>
<div class="bar"><i id="pos"></i></div>
<div class="ctl"><button id="pp" disabled>Play</button><button id="up" disabled>Like</button><button id="down" disabled>Not this</button><button id="next" disabled>Next</button>
<label><input type="checkbox" id="auto" checked> keep playing</label></div></div></div>
<p class="line" id="line" hidden></p>
<div class="q" id="q"></div>
<details><summary>log</summary><pre id="log"></pre></details>
<script> ${ BRIDGE }
const $=id=>document.getElementById(id), log=t=>{$('log').textContent+=t+'\\n';fit()};
const MAX=12;let station='',hist=[],cur=null,nextP=null,started=false,tick=null,count=0;
const bc=('BroadcastChannel' in window)?new BroadcastChannel('gandora-radio'):null,me=Math.random().toString(36).slice(2);
if(bc)bc.onmessage=e=>{if(e.data&&e.data.on&&e.data.on!==me&&cur){pauseAll();$('auto').checked=false;status('paused: another card took the air')}};
function fit(){const h=Math.ceil(document.body.getBoundingClientRect().height)+2;note('ui/notifications/size-changed',{width:Math.ceil(document.body.scrollWidth),height:h})}
if('ResizeObserver' in window)new ResizeObserver(fit).observe(document.body);addEventListener('load',fit);
function status(t){$('st').textContent=t}
function hue(k){const n=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];const m=String(k||'').match(/^([A-G])(#|b)?/i);if(!m)return 260;let i=n.indexOf(m[1].toUpperCase());if(m[2]==='#')i++;if(m[2]==='b')i--;return((i+12)%12)*30}
function cover(s){const x=$('cv').getContext('2d'),h=hue(s.key),minor=/minor/i.test(s.key||''),bpm=+s.bpm||100;let r=s.seed||1;const rnd=()=>{r=(r*1103515245+12345)&0x7fffffff;return r/0x7fffffff};
x.fillStyle='hsl('+h+' 45% '+(minor?18:30)+'%)';x.fillRect(0,0,264,264);const n=3+Math.round(bpm/30);
for(let i=n;i>0;i--){x.beginPath();x.arc(132+(rnd()-.5)*40,132+(rnd()-.5)*40,i*(116/n),0,7);x.strokeStyle='hsl('+((h+i*(minor?8:20))%360)+' 70% '+(55+i*3)+'%)';x.lineWidth=1.5+rnd()*3;x.stroke()}}
function show(s){$('title').textContent=s.title||'untitled';$('tags').textContent=[s.tags,s.bpm&&(s.bpm+' bpm'),s.key].filter(Boolean).join(' · ');
if(s.dj_line){$('line').textContent='“'+s.dj_line+'”';$('line').hidden=false}else $('line').hidden=true;cover(s);$('up').textContent='Like';$('up').classList.remove('on');fit()}
async function cook(s){const vp=s.dj_line?tool('dj_speak',{text:s.dj_line,voice:s.voice||'af_nicole'},60000).then(bytes).catch(e=>{log('voice: '+e.message);return null}):Promise.resolve(null);
const r=await tool('render_track',s,180000);log('cooked "'+s.title+'" via '+r.renderer);const vu=await vp;return{s,song:new Audio(bytes(r)),voice:vu?new Audio(vu):null}}
async function cookNext(){const s=await tool('dj_next',{station,history:hist},60000);s.voice=s.voice||(cur&&cur.s.voice);$('q').textContent='up next: '+s.title;fit();return cook(s)}
function pauseAll(){if(cur){cur.song.pause();cur.voice&&cur.voice.pause()}$('pp').textContent='Play'}
async function context(){const lines=hist.map((h,i)=>(i+1)+'. "'+h.title+'" ('+h.bpm+' bpm, '+h.key+'): '+(h.reaction||'played'));
try{await rpc('ui/update-model-context',{content:[{type:'text',text:'[gandora] station "'+station+'" play log, oldest first:\\n'+lines.join('\\n')}],structuredContent:{station,history:hist}})}catch(e){log('context: '+e.message)}}
function mark(r){const h=hist[hist.length-1];if(h){h.reaction=r;context()}}
async function air(t){pauseAll();cur=t;show(t.s);hist.push({title:t.s.title,tags:t.s.tags,bpm:t.s.bpm,key:t.s.key});count++;context();
t.song.ontimeupdate=()=>{$('pos').style.width=(100*t.song.currentTime/(t.song.duration||1))+'%'};
t.song.onended=()=>{if(cur!==t)return;if(!hist[hist.length-1].reaction)mark('played through');if($('auto').checked)advance();else{status('ended');$('pp').textContent='Play'}};
if(t.voice)t.voice.onended=()=>{if(cur===t)t.song.play().catch(()=>status('tap Play'))};
['pp','up','down','next'].forEach(i=>$(i).disabled=false);if(bc)bc.postMessage({on:me});
try{if(t.voice)await t.voice.play();else await t.song.play();$('pp').textContent='Pause';status('on air · track '+count)}catch(e){status('tap Play to start');$('pp').textContent='Play'}
$('q').textContent='';if(count<MAX){nextP=cookNext();nextP.catch(e=>{log('next: '+e.message);nextP=null})}else $('q').textContent='end of this set: ask the DJ for more'}
async function advance(){if(count>=MAX){status('set over');return}const p=nextP||cookNext();nextP=null;status('cooking the next one…');try{await air(await p)}catch(e){status('next failed: '+e.message);$('next').disabled=false}}
$('pp').onclick=()=>{if(!cur)return;const live=(cur.voice&&!cur.voice.paused)?cur.voice:(cur.voice&&cur.voice.currentTime>0&&!cur.voice.ended&&cur.song.currentTime===0?cur.voice:cur.song);
if(!live.paused){live.pause();$('pp').textContent='Play'}else{live.play();$('pp').textContent='Pause';if(bc)bc.postMessage({on:me})}};
$('up').onclick=()=>{mark('liked');$('up').textContent='Liked';$('up').classList.add('on')};
$('down').onclick=()=>{mark('disliked, skipped');pauseAll();advance()};
$('next').onclick=()=>{mark('skipped');pauseAll();advance()};
async function begin(s){if(started)return;started=true;station=s.station||s.tags;show(s);const t0=Date.now();tick=setInterval(()=>status('cooking… '+Math.round((Date.now()-t0)/1000)+'s'),1000);
try{const t=await cook(s);clearInterval(tick);await air(t)}catch(e){clearInterval(tick);status('render failed: '+e.message);$('next').disabled=false}}
on('ui/notifications/tool-input',p=>{if(p.arguments&&p.arguments.title&&!started)show(p.arguments)});
on('ui/notifications/tool-result',p=>{const sc=p.structuredContent||(p.result&&p.result.structuredContent);if(sc&&sc.tags)begin(sc)});
rpc('ui/initialize',{protocolVersion:'2026-01-26',appInfo:{name:'gandora-radio',version:'0.0.3'},appCapabilities:{}}).then(()=>{log('init ok');note('ui/notifications/initialized',{});fit()}).catch(e=>log('init: '+e.message));
</script></body></html>` ;