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
This commit is contained in:
1 parent
7fad367d16
commit
187bc16163
1 file changed
+18
-7
+18
-7
@@ -9,7 +9,7 @@ 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/station";
|
||||
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"] } };
|
||||
@@ -49,7 +49,7 @@ const TOOLS = [
|
||||
{ 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" } } }, _meta: APP_ONLY },
|
||||
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 },
|
||||
];
|
||||
@@ -103,8 +103,10 @@ async function callTool(name, a, env) {
|
||||
"{\"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" })) });
|
||||
"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();
|
||||
@@ -147,7 +149,7 @@ async function handle(msg, env) {
|
||||
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")) return ok({ contents: [{ uri: RADIO_URI, mimeType: UI_MIME, text: RADIO_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;
|
||||
@@ -280,6 +282,8 @@ 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>
|
||||
@@ -289,6 +293,8 @@ details{margin-top:8px;font-size:12px;color:var(--mut)}pre{white-space:pre-wrap;
|
||||
<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}
|
||||
@@ -303,11 +309,12 @@ function hue(k){const n=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];c
|
||||
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(' · ');
|
||||
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}}
|
||||
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)}
|
||||
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')}],structuredContent:{station,history:hist}})}catch(e){log('context: '+e.message)}}
|
||||
@@ -322,6 +329,10 @@ $('q').textContent='';if(count<MAX){nextP=cookNext();nextP.catch(e=>{log('next:
|
||||
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})}
|
||||
try{rpc('ui/update-model-context',{content:[{type:'text',text:'[gandora] station "'+station+'" listener requests to the DJ, oldest first:\\n- '+notes.join('\\n- ')}]})}catch(e){}};
|
||||
$('up').onclick=()=>{mark('liked');$('up').textContent='Liked';$('up').classList.add('on')};
|
||||
$('down').onclick=()=>{mark('disliked, skipped');pauseAll();advance()};
|
||||
$('next').onclick=()=>{mark('skipped');pauseAll();advance()};
|
||||
|
||||
Reference in new issue
Block a user