15 Commits
Author SHA1 Message Date
teahubxyzandClaude Opus 5.5 a88930ec4a mcp station: keep listener requests in the model context alongside the play log
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 20:00:44 +00:00
teahubxyzandClaude Opus 5.5 187bc16163 mcp station: DJ box (listener requests steer the next sheet, re-cooks up-next) + emoji reactions; URI station2 to bust the host cache
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 20:00:36 +00:00
teahubxyzandClaude Opus 5.5 7fad367d16 mcp: accept /mcp/<token>/<anything> so a fresh connector URL can bust claude.ai's cached tools and UI
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 19:53:48 +00:00
teahubxyzandClaude Opus 5.5 2b1993ba9f mcp radio: add dj_station (new tool, new ui://gandora/station URI) to escape claude.ai's cached v1 card
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 19:51:51 +00:00
teahubxyzandClaude Opus 5.5 1599709315 mcp radio: new resource URI so the host stops serving the cached v2 card (which still messaged for each track)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 19:49:11 +00:00
teahubxyzandClaude Opus 5.5 e339901e5c mcp radio: v3 card — self-running station in one card, compact layout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 19:45:47 +00:00
teahubxyzandClaude Opus 5.5 9cff04e694 mcp radio: the card keeps the station going itself (dj_next via inference chat, pre-cooked), no prompt-box messages; compact layout + explicit size reports
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 19:45:05 +00:00
teahubxyzandClaude Opus 5.5 c42a6f5c87 mcp radio: bigger card + report height to host; reactions go to model context (no new prompt), Next stays a message
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 19:40:39 +00:00
teahubxyzandClaude Opus 5.5 ff6e9146cf mcp: gate the endpoint behind a secret path (/mcp/<TOKEN>) now that it can spend render quota
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 19:26:11 +00:00
teahubxyzandClaude Opus 5.5 c9f3823cc2 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
2026-10-22 19:25:56 +00:00
teahubxyz 6da217f4c4 mcp: spike — gandora MCP App audio probe (ui://gandora/probe + get_track) 2026-10-22 19:13:41 +00:00
teahubxyzandClaude Opus 5.5 f173b9dffd dev: bump the SW REV so phones drop the pre-split cache
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-22 06:21:38 +00:00
teahubxyz 62f6f3951c Merge pull request 'split → dev: index.html as ES modules (no build step), for a phone pass' (#4) from split into dev 2026-10-22 06:20:49 +00:00
teahubxyzandClaude Opus 5.5 7cf03eb1f5 dev: webamp.html as on main (tapes, museum skins), so the split merges cleanly
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-22 06:20:25 +00:00
teahubxyzandClaude Fable 5.1 fb265d0c9f dev: /webamp.html — the library in Winamp, with Milkdrop (Butterchurn) as the visualizer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 06:18:00 +00:00
3 changed files with 366 additions and 1 deletions

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
# gandora MCP spike
A throwaway experiment, not part of the page. Question: can a gandora player run as an MCP App inside claude.ai and
play audio, and by which path?
The chat Visualizer sandbox (Oct 22 2026) played Web Audio but refused `<audio>` from dev.gandora.lol and teahub.xyz
(MediaError code 4: CSP). An MCP App's UI resource can declare its own domains, and can call its server's tools
through the host, so this probe tests three paths:
1. **synth**: Web Audio, no network (control)
2. **remote**: `<audio>` from dev.gandora.lol, declared in the resource's `_meta.ui.csp.resourceDomains`
3. **blob**: the UI calls `get_track` through the host bridge, gets base64 mp3, plays it from a `blob:` URL
`worker.js` is a single stateless Cloudflare Worker speaking MCP over Streamable HTTP (POST `/mcp`, JSON replies).
Tools: `open_probe` (shows the UI) and `get_track` (app-only). Deployed as `gandora-mcp` at
`https://gandora-mcp.yolopreneur.lol/mcp` by teahubxyz/time › Actions › gandora-mcp (reads this file from `dev`).
Try it: add the URL as a custom connector in claude.ai, then ask Claude to open the gandora probe.
No auth, no keys: it only serves public test clips. Don't grow it in place; the real player server gets designed
from what this answers.
+344
View File
@@ -0,0 +1,344 @@
// 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 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/station2";
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." },
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." },
},
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).",
inputSchema: { type: "object", properties: {} },
_meta: { ui: { resourceUri: PROBE_URI }, "ui/resourceUri": PROBE_URI } },
{ 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 } },
{ name: "dj_play", title: "Play a track on gandora",
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.",
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_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" }, requests: { type: "array" } } }, _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) {
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);
}
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 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 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" || name === "dj_station") {
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)); }
}
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 or got positive emoji, away from what was disliked or skipped. " +
"If there are listener requests, the newest one comes first: honor it in this track, and acknowledge it warmly in the dj_line.";
const reqs = (Array.isArray(a.requests) ? a.requests : []).slice(-4).reverse();
const user = JSON.stringify({ station: a.station || "", listener_requests: reqs, 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)); }
}
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, 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.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, env);
return res ? ok(res) : err(-32602, `unknown tool ${params && params.name}`);
}
case "resources/list":
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 === PROBE_URI) return ok({ contents: [{ uri: PROBE_URI, mimeType: UI_MIME, text: PROBE_HTML, _meta: UI_META }] });
if (params && (params.uri === RADIO_URI || params.uri === "ui://gandora/radio" || params.uri === "ui://gandora/radio-v3" || params.uri === "ui://gandora/station")) 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;
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 {
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 0.0.2 inference=" + (env.INFERENCE ? "bound" : "missing"), { headers: CORS });
const want = env.TOKEN ? "/mcp/" + env.TOKEN : "/mcp";
if (url.pathname !== want && !url.pathname.startsWith(want + "/")) return new Response("gandora mcp spike", { 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((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}
.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');
[['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_V2 = `<!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: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}}
.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="300" height="300"></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>Like</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,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()}}
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');tell('finished "'+S.title+'" all the way through');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}}
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);
$('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>`;
/* 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}
.emo{display:flex;gap:4px;flex-wrap:wrap;margin-top:10px}.emo button{font-size:16px;padding:2px 8px}
.dj{display:flex;gap:6px;margin-top:10px}.dj input{flex:1;min-width:0;font:inherit;font-size:13px;padding:6px 10px;border-radius:7px;border:1px solid var(--line);background:transparent;color:inherit}
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="emo" id="emo"></div>
<form class="dj" id="djf"><input id="dji" placeholder="Talk to the DJ: slower, more sax, a song about trains…" autocomplete="off"><button>Send</button></form>
<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){document.querySelectorAll('.emo button').forEach(b=>b.classList.remove('on'));$('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}}
const notes=[];
async function cookNext(){const s=await tool('dj_next',{station,history:hist,requests:notes},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')+(notes.length?'\\nlistener requests to the DJ: '+notes.join(' | '):'')}],structuredContent:{station,history:hist,requests:notes}})}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})}};
['🔥','💜','🕺','🌙','😴','🥱'].forEach(e=>{const b=document.createElement('button');b.textContent=e;b.title='react';b.onclick=()=>{const h=hist[hist.length-1];if(!h)return;h.reaction=((h.reaction&&h.reaction!=='played through')?h.reaction+' ':'')+e;context();b.classList.add('on');status('reacted '+e)};$('emo').appendChild(b)});
$('djf').onsubmit=ev=>{ev.preventDefault();const v=$('dji').value.trim();if(!v)return;notes.push(v);$('dji').value='';status('DJ heard: '+v);
if(count<MAX){nextP=cookNext();nextP.catch(e=>{log('next: '+e.message);nextP=null})}
context()};
$('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>`;
+1 -1
View File
@@ -1,7 +1,7 @@
// gandora's service worker: the shell is cached so the app opens and the library plays with no signal.
// Tracks live in IndexedDB already; the DJ and new renders need the network and the page says so.
// REV is stamped by the shots workflow on every push, so an installed app sees each update as a new worker.
const REV = "be3bce8aac3e";
const REV = "split-62f6f39";
const SHELL = "gandora-shell-" + REV;
const FILES = ["/", "/index.html", "/app.js", "/core.js", "/covers.js", "/dj.js", "/mashup.js", "/player.js", "/render.js", "/store.js", "/ui/library.js", "/ui/player.js", "/ui/sheet.js", "/ui/taste.js", "/stats.html", "/favicon.svg", "/manifest.json", "/icons/icon.svg"];
self.addEventListener("install", (e) => { e.waitUntil(caches.open(SHELL).then((c) => c.addAll(FILES)).then(() => self.skipWaiting())); });