mcp: radio spike — Claude DJs gandora in chat (dj_play + ui://gandora/radio; render/speech via service binding to time-inference)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
This commit is contained in:
1 parent
6da217f4c4
commit
c9f3823cc2
1 file changed
+170
-88
+170
-88
@@ -1,34 +1,50 @@
|
||||
// gandora MCP spike: one stateless Streamable-HTTP MCP endpoint (POST /mcp, JSON responses) exposing an MCP App.
|
||||
// Question it answers: can a gandora player inside claude.ai play audio, and by which path?
|
||||
// 1. synth - Web Audio, no network
|
||||
// 2. remote - <audio src> from dev.gandora.lol, allowed through the resource's declared CSP domains
|
||||
// 3. blob - bytes returned by the get_track tool (called by the UI through the host bridge) -> blob: URL
|
||||
// Throwaway: not wired to the page, the player or the inference worker. See mcp/README.md.
|
||||
// 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.
|
||||
|
||||
const MEDIA = "https://dev.gandora.lol";
|
||||
const TRACKS = {
|
||||
sky: "/asmr/audio/kokoro-sky.mp3",
|
||||
rain: "/asmr/audio/bed-rain.mp3",
|
||||
};
|
||||
const UI_URI = "ui://gandora/probe";
|
||||
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";
|
||||
const RADIO_URI = "ui://gandora/radio";
|
||||
const UI_MIME = "text/html;profile=mcp-app";
|
||||
const UI_META = { ui: { csp: { resourceDomains: [MEDIA], connectDomains: [MEDIA] }, prefersBorder: true } };
|
||||
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." },
|
||||
},
|
||||
required: ["title", "tags"],
|
||||
};
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
name: "open_probe",
|
||||
title: "Open gandora audio probe",
|
||||
description: "Shows the gandora audio probe player in chat (an MCP App). Use when the user asks to run the gandora probe or open the gandora player.",
|
||||
{ name: "open_probe", title: "Open gandora audio probe",
|
||||
description: "Shows the gandora audio probe player in chat (an MCP App).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
_meta: { ui: { resourceUri: UI_URI }, "ui/resourceUri": UI_URI },
|
||||
},
|
||||
{
|
||||
name: "get_track",
|
||||
title: "Get a gandora track",
|
||||
description: "Returns one short test track's audio bytes (base64 mp3). Called by the probe UI.",
|
||||
inputSchema: { type: "object", properties: { id: { type: "string", enum: Object.keys(TRACKS) } } },
|
||||
_meta: { ui: { visibility: ["app"] } },
|
||||
},
|
||||
_meta: { ui: { resourceUri: PROBE_URI }, "ui/resourceUri": PROBE_URI } },
|
||||
{ name: "dj_play", title: "Play a track on gandora",
|
||||
description: "You are the gandora DJ. Write one track sheet 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. When a track ends with auto-DJ on, or the listener taps next or reacts, a message arrives starting with '[gandora]'; answer it by calling dj_play again with the next sheet, steering by their reactions. Keep chat text short between tracks.",
|
||||
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 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
function b64(buf) {
|
||||
@@ -37,55 +53,77 @@ function b64(buf) {
|
||||
for (let i = 0; i < u.length; i += 0x8000) s += String.fromCharCode.apply(null, u.subarray(i, i + 0x8000));
|
||||
return btoa(s);
|
||||
}
|
||||
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; }
|
||||
|
||||
async function callTool(name, args) {
|
||||
if (name === "open_probe") {
|
||||
return { content: [{ type: "text", text: "gandora probe opened. Tap each row, then Send results." }],
|
||||
structuredContent: { media: MEDIA, tracks: Object.keys(TRACKS) } };
|
||||
}
|
||||
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) } };
|
||||
if (name === "get_track") {
|
||||
const id = (args && args.id) || "sky";
|
||||
const path = TRACKS[id];
|
||||
if (!path) return { isError: true, content: [{ type: "text", text: `unknown track ${id}` }] };
|
||||
const r = await fetch(MEDIA + path);
|
||||
if (!r.ok) return { isError: true, content: [{ type: "text", text: `fetch ${path} -> ${r.status}` }] };
|
||||
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()) } };
|
||||
}
|
||||
if (name === "dj_play") {
|
||||
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: `track ${id}: ${data.length} base64 chars` }],
|
||||
structuredContent: { id, mimeType: "audio/mpeg", data } };
|
||||
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)); }
|
||||
}
|
||||
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)); }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handle(msg) {
|
||||
async function handle(msg, env) {
|
||||
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":
|
||||
return ok({
|
||||
protocolVersion: (params && params.protocolVersion) || "2025-06-18",
|
||||
capabilities: { tools: {}, resources: {} },
|
||||
serverInfo: { name: "gandora", title: "gandora", version: "0.0.1-spike" },
|
||||
instructions: "gandora: generative radio. open_probe shows the audio probe player in chat.",
|
||||
});
|
||||
case "ping":
|
||||
return ok({});
|
||||
case "tools/list":
|
||||
return ok({ tools: TOOLS });
|
||||
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 });
|
||||
case "tools/call": {
|
||||
const res = await callTool(params && params.name, params && params.arguments);
|
||||
const res = await callTool(params && params.name, params && params.arguments, env);
|
||||
return res ? ok(res) : err(-32602, `unknown tool ${params && params.name}`);
|
||||
}
|
||||
case "resources/list":
|
||||
return ok({ resources: [{ uri: UI_URI, name: "gandora probe", mimeType: UI_MIME, _meta: UI_META }] });
|
||||
case "resources/templates/list":
|
||||
return ok({ resourceTemplates: [] });
|
||||
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: [] });
|
||||
case "resources/read":
|
||||
if (params && params.uri === UI_URI)
|
||||
return ok({ contents: [{ uri: UI_URI, mimeType: UI_MIME, text: PROBE_HTML, _meta: UI_META }] });
|
||||
if (params && params.uri === PROBE_URI) return ok({ contents: [{ uri: PROBE_URI, mimeType: UI_MIME, text: PROBE_HTML, _meta: UI_META }] });
|
||||
if (params && params.uri === RADIO_URI) return ok({ contents: [{ uri: RADIO_URI, mimeType: UI_MIME, text: RADIO_HTML, _meta: UI_META }] });
|
||||
return err(-32002, "resource not found");
|
||||
default:
|
||||
if (id === undefined) return null; // notification
|
||||
if (id === undefined) return null;
|
||||
return err(-32601, `method not found: ${method}`);
|
||||
}
|
||||
}
|
||||
@@ -97,52 +135,96 @@ const CORS = {
|
||||
};
|
||||
|
||||
export default {
|
||||
async fetch(req) {
|
||||
async fetch(req, env) {
|
||||
const url = new URL(req.url);
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: CORS });
|
||||
if (url.pathname === "/health") return new Response("ok gandora-mcp", { headers: CORS });
|
||||
if (url.pathname === "/health") return new Response("ok gandora-mcp 0.0.2 inference=" + (env.INFERENCE ? "bound" : "missing"), { headers: CORS });
|
||||
if (url.pathname !== "/mcp") return new Response("gandora mcp spike: POST /mcp", { status: 404, headers: CORS });
|
||||
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);
|
||||
const out = (await Promise.all((batch ? body : [body]).map(handle))).filter(Boolean);
|
||||
const out = (await Promise.all((batch ? body : [body]).map((m) => handle(m, env)))).filter(Boolean);
|
||||
if (!out.length) return new Response(null, { status: 202, headers: CORS });
|
||||
return Response.json(batch ? out : out[0], { headers: CORS });
|
||||
},
|
||||
};
|
||||
|
||||
/* 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}
|
||||
`;
|
||||
|
||||
const PROBE_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}
|
||||
body{margin:0;padding:14px;background:transparent;color:CanvasText}
|
||||
h1{font-size:15px;font-weight:600;margin:0 0 10px}
|
||||
.row{display:flex;justify-content:space-between;align-items:center;gap:8px;font-size:14px;margin:8px 0}
|
||||
.s{font-size:12px;opacity:.75}.ok{color:#2a8a3e;opacity:1}.bad{color:#c0392b;opacity:1}
|
||||
button{font:inherit;font-size:13px;padding:5px 10px;border-radius:7px;border:1px solid #8886;background:transparent;color:inherit;cursor:pointer}
|
||||
#log{font:11px ui-monospace,monospace;white-space:pre-wrap;opacity:.7;margin-top:10px;max-height:120px;overflow:auto}
|
||||
</style></head><body>
|
||||
<h1>gandora · MCP App probe</h1>
|
||||
<div id="rows"></div>
|
||||
<div style="display:flex;gap:8px;margin-top:12px"><button id="all">Run all</button><button id="send">Send results ↗</button></div>
|
||||
<div id="log"></div>
|
||||
<script>
|
||||
const R={},log=t=>{document.getElementById('log').textContent+=t+'\\n'};
|
||||
let nid=1;const pend={};
|
||||
function rpc(method,params){const id=nid++;return new Promise((res,rej)=>{pend[id]={res,rej};window.parent.postMessage({jsonrpc:'2.0',id,method,params},'*');setTimeout(()=>{if(pend[id]){delete pend[id];rej(new Error('timeout '+method))}},20000)})}
|
||||
function note(method,params){window.parent.postMessage({jsonrpc:'2.0',method,params},'*')}
|
||||
window.addEventListener('message',e=>{const m=e.data;if(!m||m.jsonrpc!=='2.0')return;if(m.id!=null&&pend[m.id]){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){log('host: '+m.method)}});
|
||||
let ready=rpc('ui/initialize',{protocolVersion:'2026-01-26',appInfo:{name:'gandora-probe',version:'0.0.1'},appCapabilities:{}}).then(r=>{log('init ok '+(r&&r.protocolVersion||''));note('ui/notifications/initialized',{});return true}).catch(e=>{log('init: '+e.message);return false});
|
||||
const tests=[{id:'synth',name:'Web Audio synth'},{id:'remote',name:'<audio> from dev.gandora.lol (CSP domain)'},{id:'blob',name:'bytes via get_track tool → blob:'}];
|
||||
<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);
|
||||
const rows=document.getElementById('rows');
|
||||
for(const t of tests){const d=document.createElement('div');d.className='row';d.innerHTML='<span>'+t.name.replace(/</g,'<')+'</span><span style="display:flex;gap:8px;align-items:center"><span class="s" id="s-'+t.id+'">not run</span><button>Play</button></span>';d.querySelector('button').onclick=()=>run(t);rows.appendChild(d)}
|
||||
function set(id,txt,ok){R[id]=txt;const e=document.getElementById('s-'+id);e.textContent=txt;e.className='s'+(ok===true?' ok':ok===false?' bad':'')}
|
||||
let cur;
|
||||
async function playUrl(id,src){if(cur)cur.pause();const a=new Audio();cur=a;a.onerror=()=>set(id,'failed (code '+(a.error&&a.error.code)+')',false);a.src=src;try{await a.play();set(id,'playing',true);setTimeout(()=>a.pause(),4000)}catch(e){if(R[id]==='loading…')set(id,'refused: '+e.name,false)}}
|
||||
async function run(t){set(t.id,'loading…');
|
||||
if(t.id==='synth'){try{const C=new(window.AudioContext||window.webkitAudioContext)();await C.resume();const o=C.createOscillator(),g=C.createGain();o.frequency.value=440;g.gain.value=.08;o.connect(g).connect(C.destination);o.start();o.stop(C.currentTime+.8);set('synth','played ('+C.state+')',true)}catch(e){set('synth','error: '+e.name,false)}return}
|
||||
if(t.id==='remote')return playUrl('remote','${MEDIA}/asmr/audio/bed-rain.mp3');
|
||||
if(t.id==='blob'){try{if(!await ready)throw new Error('no host bridge');const r=await rpc('tools/call',{name:'get_track',arguments:{id:'sky'}});const sc=r&&r.structuredContent;if(!sc||!sc.data)throw new Error('no bytes in result');const bin=atob(sc.data),u=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)u[i]=bin.charCodeAt(i);log('got '+u.length+' bytes');return playUrl('blob',URL.createObjectURL(new Blob([u],{type:sc.mimeType})))}catch(e){set('blob','error: '+e.message,false)}}}
|
||||
document.getElementById('all').onclick=async()=>{for(const t of tests){await run(t);await new Promise(r=>setTimeout(r,1500))}};
|
||||
document.getElementById('send').onclick=async()=>{const text='gandora MCP App probe: '+tests.map(t=>t.id+'='+(R[t.id]||'not run')).join('; ');try{await ready;await rpc('ui/message',{role:'user',content:[{type:'text',text}]});log('sent')}catch(e){log('send failed: '+e.message+' — copy: '+text)}};
|
||||
[['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>`;
|
||||
|
||||
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)}
|
||||
body{margin:0;padding:14px;background:transparent;color:CanvasText}
|
||||
.card{display:flex;gap:14px;align-items:center}
|
||||
canvas{width:96px;height:96px;border-radius:12px;flex:none}
|
||||
.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>
|
||||
<div class="card"><canvas id="cv" width="192" height="192"></canvas>
|
||||
<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">
|
||||
<button id="pp" disabled>Play</button><button id="up" disabled>More like this ↗</button><button id="down" disabled>Not this ↗</button><button id="next" disabled>Next ↗</button>
|
||||
<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};
|
||||
x.fillStyle='hsl('+h+' 45% '+(minor?18:30)+'%)';x.fillRect(0,0,192,192);const n=3+Math.round(bpm/30);
|
||||
for(let i=n;i>0;i--){x.beginPath();x.arc(96+(rnd()-.5)*30,96+(rnd()-.5)*30,i*(84/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){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))+'%'};
|
||||
song.onended=()=>{$('pp').textContent='Play';status('ended');if($('auto').checked)ask('track ended: "'+S.title+'". Next track please.')};
|
||||
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}}
|
||||
$('up').onclick=()=>ask('loved "'+S.title+'" ('+S.tags+'). More in this direction next.');
|
||||
$('down').onclick=()=>{[song,voice].forEach(a=>a&&a.pause());ask('not feeling "'+S.title+'" ('+S.tags+'). Skip it and steer away from that.')};
|
||||
$('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));
|
||||
</script></body></html>`;
|
||||
Reference in new issue
Block a user