diff --git a/mcp/worker.js b/mcp/worker.js index 28c40a8..6fb543f 100644 --- a/mcp/worker.js +++ b/mcp/worker.js @@ -26,6 +26,7 @@ const SHEET = { 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"], }; @@ -36,13 +37,15 @@ const TOOLS = [ inputSchema: { type: "object", properties: {} }, _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.", + 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" } } }, _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 }, ]; @@ -89,6 +92,26 @@ async function callTool(name, a, env) { 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, 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)); } + } 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" });