Plays music from YouTube when asked in plain words, audio only through a hidden VLC, with a small control pane

Music on demand inside a Claude Code session, asked in plain words, played without a browser.
A prompt that names music (put on some ambient, coupe la musique, next) goes to Haiku first, which answers one intent: play, toggle, next, similar, volume, stop, or none. Anything else, including requests aimed at play7, keys7, Renoise or a melody, reaches the session untouched; a prompt without a music word skips the model call altogether.
yt-dlp searches YouTube, keeps songs of two to twenty minutes (a mood asked in words prefers five and more), and streams the audio into Windows' VLC with its dummy interface: no window, no focus taken. The track asked for plays whatever its length; next walks the songs alone, and past the last one the list ends.setsid); pause is VLC's own pl_forcepause over its HTTP interface, at once, stop is CONT then TERM, and the vlc.exe it started is terminated by its --meta-title=jukebox7 tag, so a VLC opened by hand is never touched. A pause VLC does not answer changes nothing and says so. One change of song runs at a time (a search takes up to 40 s): a press meanwhile is refused. A session that never played leaves the VLC alone when it ends.The pane (/music, or opened on the first song) shows the title, its state and volume, p pause, n next, r similar, s stop, d and u the volume by 10 %, and the genre buttons 1 to 9 and 0: ambient, alt 90s, metal, synth & electro, idm, indie rock, video games, film & modern classical, hip-hop & abstract, post-rock. A genre is a radio: a random artist from its list, a random song of theirs; next and the end of a song roll again.
A pick searches YouTube Music's songs tab, which holds tracks only: no interview, gameplay or full OST. Its listing gives no length: yt-dlp is asked for it once the song plays, the bar shows the elapsed time until then; plain YouTube, kept to two to twenty minutes, is the fallback when it finds nothing.
Discoveries. Each genre also holds a few artists beyond the listener's own, drawn from the lists' neighbors (Grouper and Tim Hecker by Loscil and Brian Eno, Deathspell Omega and Panopticon by Blut Aus Nord and Agalloch, Sewerslvt and Machine Girl by Venetian Snares, Car Seat Headrest and Alex G by Modest Mouse and Elliott Smith). One pick in three comes from there and its title wears a ✦; strike a name from discover to drop it.
similar (r, /music similar, or in words, plus comme ça) plays the YouTube Music radio of the song on: other artists first (the radio lingers on the same one), covers, tributes and hour-long loops dropped; next walks that list.
On the terminal the pane is black down to its last row, framed in the avatar's color, and the room left under the player fills with a green code rain in the manner of Ghost in the Shell: half-width katakana and digits, falling five frames a second while a song plays, frozen on pause.
With avatar7 loaded, a plays the pick of the avatar on duty: the station of its persona.json, which avatar7 publishes in its station state. Each song that starts, asked, from a genre or chained when the last one ends, is written to this mod's say state: avatar7, if loaded, introduces it in the persona's manner, like a radio host over the intro. While the avatar speaks (its isVoicing state) the music drops to 70 % of its level, then returns.
/music <search> plays one named track; /music pause|next|stop; /music vol 60, /music vol +20. In words, monte le son works too.
Only a short prompt (eight words at most) with a music word or phrase goes to Haiku; a longer one is talk to the assistant and passes untouched, even when it mentions music.
The volume goes through VLC's HTTP interface on Windows' loopback (port 18797), reached with Windows' own curl.exe. The level is kept apart from the player, survives a stop, and is set again on each new song: a fresh VLC starts at whatever level Windows kept for it.
WSL with Windows interop, yt-dlp on the path, VLC at C:\Program Files\VideoLAN\VLC. VLC rather than ffplay on WSLg's PulseAudio: that server stalls after a sleep (it takes audio at a tenth of real time and nothing comes out) until wsl --shutdown.
GENRES in hooks/register.tsx. A station belongs to its persona, in avatar7's personas/<id>/persona.json.claude plugin test ~/flux7-mods/jukebox7
hooks/register.tsx 1028 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Found, ModelUse, Player, Say, Track } from '../types'
5import { drainArgv, heldArgv, parseJukebox, pauseScript, REMOTE, statusArgv } from './remote'
6
7// Cheap sieve before any model call: a prompt that fails it reaches the
8// session untouched, with no added latency. A command to the jukebox is
9// short; a longer message is talk to the assistant, even about music (one
10// about ducking the music was once taken for a volume change). Common words
11// (son, fort, mets, lance) only count inside a music phrase; a prompt that
12// opens on joue or play names what to play.
13const MAX_WORDS = 8
14const MUSIC =
15 /\b(musique|music|morceau|chanson|playlist|youtube|ambient|lofi|radio|volume|pause|stop|coupe|arr[eê]te|reprends|resume|suivant|next|skip|louder|quieter|similar|similaire|pareil)\b|\b(monte|baisse|coupe)[sz]?\s+(le\s+)?son\b|\b(plus|moins)\s+fort\b|\b(joue|jouer|mets|lance|play)\b.*\b(du|des|de\s+la|un\s+peu\s+de|some)\b|^\s*(joue|play)\b/i
16export const isCandidate = (text: string): boolean => text.trim().split(/\s+/).length <= MAX_WORDS && MUSIC.test(text)
17
18const INTENT = [
19 'You read one message typed to a coding assistant and decide whether it asks to control music playback on the computer.',
20 'Answer with one line of JSON and nothing else, one of:',
21 '{"action":"play","query":"...","long":true}',
22 '{"action":"toggle"}',
23 '{"action":"next"}',
24 '{"action":"similar"}',
25 '{"action":"stop"}',
26 '{"action":"volume","delta":10}',
27 '{"action":"none"}',
28 'play: the user wants music played now. query: YouTube search words, in English for a genre or mood, as given for a named artist or title.',
29 'long: true for background or mood music (ambient, lofi, focus, a genre), false for one named track.',
30 'toggle: pause or resume the music. next: skip to another one. similar: more songs like the one playing. stop: stop, cut or turn off the music.',
31 'volume: louder (delta 10, or 20 for much louder) or quieter (delta -10, or -20).',
32 'Only a direct, short command to the music player counts. A question, a remark, a request to change software or code, or a description of how music should behave is none, even when it names volume or music.',
33 'none: anything else, including talk about music, code that plays audio, and requests aimed at Renoise, play7, keys7, a piano, a pattern or a melody.',
34].join('\n')
35
36type Intent =
37 | { action: 'play'; query: string; long: boolean }
38 | { action: 'toggle' }
39 | { action: 'next' }
40 | { action: 'similar' }
41 | { action: 'stop' }
42 | { action: 'volume'; delta: number }
43 | { action: 'none' }
44
45// A song lasts two to twenty minutes; above is an album or a mix. A mood
46// asked in words prefers five minutes and more.
47const MIN_SECONDS = 120
48const LONG_SECONDS = 300
49const MAX_SECONDS = 1200
50const SEARCH_SIZE = 8
51const PANE = 'jukebox7'
52const POLL_MS = 5_000
53// The phone's presses are read this often.
54const REMOTE_MS = 1_000
55
56// One click, a radio: a random artist of the genre, a random song of theirs;
57// next and the end of the song roll again. Seeded from the author's own listening.
58export const GENRES: { key: string; label: string; artists: string[]; discover?: string[] }[] = [
59 {
60 key: '1',
61 label: 'ambient',
62 artists: ['Boards of Canada', 'Loscil', 'Helios', 'Múm', 'Kenji Kawai Ghost in the Shell', 'Rafael Anton Irisarri', 'Ulver Perdition City', 'Brian Eno', 'bxnwxghxrn', 'Deru 1979'], discover: ['Grouper', 'Tim Hecker', 'William Basinski', 'Stars of the Lid', 'Hiroshi Yoshimura', 'Chihei Hatakeyama', 'Biosphere', 'Huerco S.', 'Kali Malone', 'Sarah Davachi', '36 ambient', 'Rafael Anton Irisarri Agitas'],
63 },
64 { key: '2', label: 'alt 90s', artists: ['Radiohead', 'The Clash', 'Pulp', 'Smashing Pumpkins', 'Pixies Doolittle', 'Pavement', 'Supergrass', 'Tool', 'Nine Inch Nails', 'Rage Against the Machine', 'My Bloody Valentine', 'Queens of the Stone Age', 'Slint', 'Beck'], discover: ['Unwound', 'Polvo', 'Archers of Loaf', 'Sebadoh', 'Hum', 'Failure', 'Swirlies', 'Dinosaur Jr', 'MJ Lenderman', 'Wednesday band', 'Horse Jumper of Love'] },
65 {
66 key: '3',
67 label: 'metal',
68 artists: ['Dissection', 'Cradle of Filth Dusk and Her Embrace', 'Emperor', 'Dimmu Borgir Enthrone Darkness Triumphant', 'Agalloch', 'Ulver Bergtatt', 'Arcturus', 'Blut Aus Nord', 'Wolves in the Throne Room', 'Opeth', 'Gojira', 'Children of Bodom', 'In Flames', 'Therion', 'Diabolical Masquerade', 'Anorexia Nervosa', 'Summoning', 'CLANN', 'Drabikowski Batushka', 'Limbonic Art', 'Obtained Enslavement', 'Peste Noire'], discover: ['Deathspell Omega', 'Mgła', 'Panopticon', 'Alcest', 'Oranssi Pazuzu', 'Wiegedood', 'Cult of Luna', 'Russian Circles', 'Igorrr', 'Sumac', 'Misþyrming', 'Svartidauði', 'Imperial Triumphant', 'Erang dungeon synth', 'Old Tower'],
69 },
70 { key: '4', label: 'synth & electro', artists: ['Fixions', 'Mega Drive', 'Danger 11h30', 'Perturbator', 'Carpenter Brut', 'Dan Terminus', 'Gost', 'Dance With the Dead', 'Master Boot Record', 'Crystal Castles', 'The Knife Silent Shout', 'Grimes Visions', 'Justice', 'Röyksopp', 'Atari Teenage Riot', 'Waveshaper', 'RWD NRTHNDR', 'Lorn Ask the Dust', 'SurgeryHead', 'Baldocaster', 'The Toxic Avenger', 'Scattle'], discover: ['Volkor X', 'Daniel Deluxe', 'Magic Sword', 'Irving Force', 'Hollywood Burns', 'Lueur Verte', 'Gunship', 'Lazerhawk', 'Boy Harsher', 'Drab Majesty', 'Kavinsky Reborn', 'Perturbator Lustful Sacraments'] },
71 { key: '5', label: 'idm', artists: ['Aphex Twin', 'Plaid', 'Boards of Canada', 'Squarepusher', 'Venetian Snares', 'Autechre', 'Clark', 'Wisp The Shimmering Hour', 'Arovane', 'Kettel', 'Jega', 'Bogdan Raczynski', 'Ceephax Acid Crew', 'The Future Sound of London', 'Tujiko Noriko', 'Plone', 'Ochre', 'The Tuss', 'Daed', 'EOD Questionmarks', 'Acrnym', 'Koolmorf Widesen', 'exandroid', 'James Shinra', 'Stazma', 'µ-Ziq', 'ID:null', 'Gareth Clarke', 'Hitori Tori'], discover: ['Richard Devine', 'Proem', 'Lusine', 'Funckarma', 'Sewerslvt', 'Machine Girl', 'Rival Consoles', 'Max Cooper', 'Jlin', 'Lanark Artefax', 'Loraine James', 'Bogdan Raczynski Rave Till You Cry'] },
72 {
73 key: '6',
74 label: 'indie rock',
75 artists: ['Pixies', 'Modest Mouse', 'Blonde Redhead', 'Eels', 'Sparklehorse', 'Elliott Smith', 'Grandaddy', 'Built to Spill', 'The Unicorns', 'Sufjan Stevens', 'MGMT', 'Ratatat', 'Perfume Genius', 'The Notwist', 'Beach House', 'Weezer', 'Les Savy Fav', 'Wolf Parade', 'The Shins', 'Animal Collective', 'Arcade Fire', 'Joanna Newsom', 'Broadcast', 'Syd Matters', 'The Walkmen', 'The Strokes', 'Metric', 'The Flaming Lips', 'Clues Alden Penner', 'Jason Lytle', 'Ugly Casanova', 'CocoRosie', 'Soap&Skin', 'Xiu Xiu', 'Lykke Li', 'Candy Claws', 'Surfer Blood'], discover: ['Car Seat Headrest', 'Alex G', 'Duster Stratosphere', 'Pinegrove', 'Big Thief', 'Low Sparhawk slowcore', 'Sun Kil Moon', 'Unknown Mortal Orchestra', 'Black Country New Road', 'Weyes Blood', 'Alvvays', 'Caroline Polachek', 'Zola Jesus', 'Chelsea Wolfe', 'Ethel Cain', 'Fever Ray'],
76 },
77 {
78 key: '7',
79 label: 'video games',
80 artists: [
81 'NieR Automata soundtrack Keiichi Okabe', 'Jeremy Soule Skyrim', 'Jeremy Soule Morrowind', 'Jeremy Soule Oblivion',
82 'Akira Yamaoka Silent Hill', 'Deus Ex soundtrack', 'Christopher Larkin Hollow Knight', 'C418 Minecraft', 'Cyberpunk 2077 soundtrack',
83 'Darren Korb Bastion', 'Darren Korb Transistor', 'Lena Raine Celeste', 'Mark Morgan Fallout', 'Mark Morgan Planescape Torment',
84 'FlybyNo Endless Space', 'FlybyNo Endless Legend', 'Arnaud Roy Humankind', 'Andrew Prahlow Outer Wilds', 'Furi soundtrack', 'Waveshaper Masters of Light',
85 ], discover: ['Disasterpeace Fez', 'Darren Korb Hades', 'Austin Wintory Journey', 'Ben Prunty FTL', 'Toby Fox Undertale', 'Mick Gordon Doom', 'Hideki Naganuma', 'Christopher Larkin Silksong', 'Darren Korb Hades II', 'Disasterpeace Hyper Light Drifter', 'Lena Raine Chicory', 'Toby Fox Deltarune', 'Mick Gordon Atomic Heart'],
86 },
87 {
88 key: '8',
89 label: 'film & modern classical',
90 artists: ['Joe Hisaishi', 'Philip Glass', 'Max Richter', 'Kenji Kawai Ghost in the Shell', 'Ryuichi Sakamoto', 'Jóhann Jóhannsson', 'Hans Zimmer The Thin Red Line', 'Clint Mansell The Fountain', 'Trevor Jones The Last of the Mohicans', 'Elliot Goldenthal Heat', 'Thomas Newman American Beauty', 'John Williams', 'Antonín Dvořák New World Symphony'],
91 discover: ['Ólafur Arnalds', 'Nils Frahm', 'Arvo Pärt', 'Hildur Guðnadóttir', 'Steve Reich', 'Michael Nyman', 'Hania Rani', 'A Winged Victory for the Sullen', 'Hildur Guðnadóttir Chernobyl', 'Kali Malone Living Torch', 'Caterina Barbieri'],
92 },
93 {
94 key: '9',
95 label: 'hip-hop & abstract',
96 artists: ['Wu-Tang Clan', 'Jedi Mind Tricks', 'Kanye West', 'cLOUDDEAD', 'Sage Francis', 'Danger Mouse', 'Why?', 'Nujabes', 'Prefuse 73', 'DJ Shadow', 'Bonobo', 'Themselves', 'Odd Nosdam', 'Alias Anticon', 'Sole Anticon', 'Son Lux', 'Beastie Boys', 'Cypress Hill', 'Stupeflip', 'Wax Tailor', 'Outkast', 'Eminem', 'Dr. Dre', 'Passage Anticon', 'Death Grips'],
97 discover: ['MF DOOM', 'Cannibal Ox', 'Aesop Rock', 'Company Flow', 'Armand Hammer', 'billy woods', 'clipping.', 'Deltron 3030', 'Madlib', 'J Dilla', 'Flying Lotus', 'Knxwledge', 'JPEGMAFIA', 'Danny Brown', 'ShrapKnel', 'Backxwash'],
98 },
99 {
100 key: '0',
101 label: 'post-rock',
102 artists: ['Godspeed You! Black Emperor', 'Explosions in the Sky', 'Mono', 'Isis', 'Red Sparowes', 'Mogwai', 'Anathema', 'Sigur Rós', 'This Will Destroy You', 'The Mars Volta'],
103 discover: ['Caspian', 'If These Trees Could Talk', 'Pelican', 'Do Make Say Think', 'Tortoise', 'Hammock', 'Lost in Kiev', 'God Is an Astronaut', 'Sumac', 'Year of No Light', 'Bell Witch'],
104 },
105]
106
107// A genre's artists, or else the station of the avatar on duty, which
108// avatar7 publishes from the persona's own persona.json.
109async function poolOf($: Engine, label: string): Promise<{ name: string; artists: string[] }> {
110 const genre = GENRES.find(g => g.label === label)
111 if (genre !== undefined) return { name: label, artists: genre.artists }
112 const { value } = await $.state.get(onStation)
113 return { name: value?.name || label, artists: value?.artists ?? [] }
114}
115// Artists picked for a genre beyond the listener's own: one pick in three
116// comes from there, and its title wears a ✦.
117const discoveries = (label: string): string[] => GENRES.find(g => g.label === label)?.discover ?? []
118const DISCOVER_ODDS = 1 / 3
119export const DISCOVERED = '✦ '
120const onDuty = { plugin: 'avatar7', key: 'avatar' } as const
121const tint = { plugin: 'avatar7', key: 'color' } as const
122const onStation = { plugin: 'avatar7', key: 'station' } as const
123// Without an avatar on duty the pane keeps the terminal's phosphor green.
124const PHOSPHOR = '#00ff9c'
125const BLACK = '#000000'
126const BAR = 12
127
128const anyOf = <T,>(xs: T[]): T | undefined => xs[Math.floor(Math.random() * xs.length)]
129export const isSong = (t: Track): boolean => t.seconds !== null && t.seconds >= MIN_SECONDS && t.seconds <= MAX_SECONDS
130
131const IDLE: Player = { tracks: [], index: 0, pgid: null, isPlaying: false, genre: null, startedAt: null, pausedAt: null }
132const player = atom({ plugin: 'jukebox7', key: 'player' } as const, IDLE)
133// The last search asked for a pick (/music find, or the phone): what was
134// asked and what YouTube found, in its order. Nothing plays until a pick.
135const found = atom({ plugin: 'jukebox7', key: 'found' } as const, { query: '', tracks: [] } as Found)
136let isFinding = false
137// The pane's search: its field and the results, shown after `f` until `f`
138// again. Each result answers a letter the pane leaves free (digits are the
139// stations', the rest its controls').
140const searching = atom({ plugin: 'jukebox7', key: 'searching' } as const, false)
141export const PICK_KEYS = ['q', 'w', 'e', 't', 'y', 'i', 'o', 'l']
142const PANE_ROWS = 6
143// The pane grows by the search's rows (the field, a result each, or one
144// line while it searches), so the rain is not all that gives way.
145const paneRows = (isSearching: boolean, results: number): number => PANE_ROWS + (isSearching ? 1 + Math.max(1, results) : 0)
146// Percent of VLC's 100 %, kept apart from the player so a stop keeps it.
147const volume = atom({ plugin: 'jukebox7', key: 'volume' } as const, 70)
148const VOLUME_STEP = 10
149const VOLUME_MAX = 125
150export const clampVolume = (v: number): number => Math.min(VOLUME_MAX, Math.max(0, Math.round(v)))
151
152export const parseIntent = (text: string): Intent => {
153 const json = /\{[^}]*\}/.exec(text)?.[0]
154 if (json === undefined) return { action: 'none' }
155 try {
156 const v = JSON.parse(json) as Record<string, unknown>
157 if (v.action === 'play' && typeof v.query === 'string' && v.query.trim() !== '') {
158 return { action: 'play', query: v.query.trim(), long: v.long === true }
159 }
160 if (v.action === 'toggle' || v.action === 'next' || v.action === 'similar' || v.action === 'stop') return { action: v.action }
161 if (v.action === 'volume' && typeof v.delta === 'number' && v.delta !== 0) return { action: 'volume', delta: v.delta }
162 } catch {
163 // Not JSON after all: the prompt goes on to the session.
164 }
165 return { action: 'none' }
166}
167
168// yt-dlp prints one `id<TAB>title<TAB>duration` line per result; a live
169// stream has no duration (NA), which suits a backdrop.
170export const parseTracks = (stdout: string): Track[] =>
171 stdout
172 .split('\n')
173 .map(l => l.split('\t'))
174 .filter(([id]) => id !== undefined && /^[\w-]{11}$/.test(id))
175 .map(([id, title, d]) => ({
176 id: id as string,
177 title: (title ?? '').trim(),
178 seconds: d === undefined || !/^\d+(\.\d+)?$/.test(d.trim()) ? null : Math.round(Number(d)),
179 }))
180
181// The playlist starts on the first fitting result; next walks on from there.
182export const pickTrack = (stdout: string, long: boolean): Track | undefined => {
183 const tracks = parseTracks(stdout)
184 if (!long) return tracks[0]
185 const songs = tracks.filter(isSong)
186 return songs.find(t => (t.seconds ?? 0) >= LONG_SECONDS) ?? songs[0] ?? tracks[0]
187}
188
189const search = (query: string): string[] => [
190 'yt-dlp',
191 '--flat-playlist',
192 '--no-warnings',
193 '--print',
194 '%(id)s\t%(title)s\t%(duration)s',
195 `ytsearch${SEARCH_SIZE}:${query}`,
196]
197
198// A station pick searches YouTube Music's songs tab: only tracks, never an
199// interview, a gameplay video or a full OST, at the cost of the durations
200// (the flat listing gives none). Plain YouTube, length-filtered, is the
201// fallback when it finds nothing.
202export const musicSearch = (query: string): string[] => [
203 'yt-dlp',
204 '--flat-playlist',
205 '--no-warnings',
206 '--playlist-end',
207 String(SEARCH_SIZE),
208 '--print',
209 '%(id)s\t%(title)s\t%(duration)s',
210 `https://music.youtube.com/search?q=${encodeURIComponent(query)}#songs`,
211]
212
213// YouTube Music's search lists no duration (NA): the one playing is asked
214// for its own, so the progress bar has something to fill.
215export const durationArgv = (id: string): string[] => [
216 'yt-dlp',
217 '--no-warnings',
218 '--skip-download',
219 '--print',
220 '%(duration)s',
221 `https://www.youtube.com/watch?v=${id}`,
222]
223
224// YouTube Music's radio from a song: its first entry is the song itself.
225// The radio lingers on the same artist, so other artists come first; covers,
226// tributes and hour-long loops (the drift of a niche radio) are dropped.
227export const radioArgv = (id: string): string[] => [
228 'yt-dlp',
229 '--flat-playlist',
230 '--no-warnings',
231 '--playlist-end',
232 '25',
233 '--print',
234 '%(id)s\t%(title)s\t%(duration)s\t%(channel)s',
235 `https://music.youtube.com/watch?v=${id}&list=RDAMVM${id}`,
236]
237const COVER = /\b(cover|tribute|relaxing|reimagined|orchestral version|piano version|8.?bit|lofi version|1 hour|hours?\b|extended|epic music|fantasy music|sleep|study)\b/i
238export const parseRadio = (stdout: string): Track[] => {
239 const rows = stdout
240 .split('\n')
241 .map(l => l.split('\t'))
242 .filter(([id]) => id !== undefined && /^[\w-]{11}$/.test(id))
243 .map(([id, title, d, channel]) => ({
244 id: id as string,
245 title: (title ?? '').trim(),
246 seconds: d === undefined || !/^\d+(\.\d+)?$/.test(d.trim()) ? null : Math.round(Number(d)),
247 artist: (channel ?? '').replace(/ - Topic$/, '').trim(),
248 }))
249 const [self, ...rest] = rows
250 const kept = rest.filter(r => !COVER.test(r.title) && !COVER.test(r.artist) && (r.seconds === null || isSong(r)))
251 const others = kept.filter(r => r.artist !== self?.artist)
252 const same = kept.filter(r => r.artist === self?.artist)
253 return [...others, ...same].map(r => ({ id: r.id, title: r.artist === '' || r.artist === 'NA' ? r.title : `${r.artist} - ${r.title}`, seconds: r.seconds }))
254}
255
256// Audio only, no window: yt-dlp streams the best audio into Windows' VLC
257// with its dummy interface (WSLg's PulseAudio stalls after a sleep, VLC
258// plays on the Windows side and takes no focus). setsid puts the WSL side in
259// a process group of its own, which stop ends (pause asks VLC itself); vlc://quit
260// ends it with the stream; the id arrives as $1, checked against
261// /^[\w-]{11}$/ before.
262const VLC = '/mnt/c/Program Files/VideoLAN/VLC/vlc.exe'
263const TAG = '--meta-title=jukebox7'
264// The volume goes through VLC's HTTP interface, bound to Windows' loopback:
265// curl.exe runs there, where WSL's own localhost does not reach. The
266// password only satisfies VLC, which refuses the interface without one.
267const HTTP_PORT = 18797
268const HTTP_PASSWORD = 'jukebox7'
269const HTTP = `--extraintf http --http-host 127.0.0.1 --http-port ${HTTP_PORT} --http-password ${HTTP_PASSWORD}`
270const PIPE = `${REMOTE}yt-dlp -q --no-warnings -f bestaudio -o - "https://www.youtube.com/watch?v=$1" | "${VLC}" --intf dummy --dummy-quiet --play-and-exit --no-video -q ${TAG} ${HTTP} - vlc://quit`
271export const startArgv = (id: string): string[] => [
272 'bash',
273 '-c',
274 `setsid bash -c '${PIPE}' _ "$1" </dev/null >/dev/null 2>&1 & echo $!`,
275 '_',
276 id,
277]
278// Killing the WSL side leaves vlc.exe running: it goes by its tag, so a VLC
279// the user opened themselves is never touched. A VLC whose WSL side died
280// with its window shows no command line any more, and still holds the port
281// beside the next one (Windows lets both listen, the controls reach one of
282// them). Windows refuses to end that one even to its own user, but it still
283// obeys its interface: pl_stop ends it (--play-and-exit), once per listener,
284// each request reaching one. The port's owner goes last, where allowed.
285const POWERSHELL = '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'
286const BASIC = btoa(`:${HTTP_PASSWORD}`)
287export const killVlcArgv = [
288 POWERSHELL,
289 '-NoProfile',
290 '-Command',
291 `1..3 | ForEach-Object { try { Invoke-WebRequest -UseBasicParsing -TimeoutSec 1 -Headers @{ Authorization = 'Basic ${BASIC}' } 'http://127.0.0.1:${HTTP_PORT}/requests/status.xml?command=pl_stop' | Out-Null } catch {} }; ` +
292 `Get-CimInstance Win32_Process -Filter "Name='vlc.exe'" | Where-Object { $_.CommandLine -like '*${TAG}*' } | Invoke-CimMethod -MethodName Terminate | Out-Null; ` +
293 `Get-NetTCPConnection -LocalPort ${HTTP_PORT} -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue } | Where-Object Name -eq 'vlc' | Stop-Process -Force -ErrorAction SilentlyContinue`,
294]
295// VLC counts 256 for 100 %. A fresh VLC starts at whatever Windows kept for
296// it (--mmdevice-volume is ignored), so each start sets the level again,
297// retrying while the interface is not up yet.
298const CURL = '/mnt/c/Windows/System32/curl.exe'
299export const volumeArgv = (percent: number, retry = false): string[] => [
300 CURL,
301 '-s',
302 '-o',
303 'NUL',
304 '-u',
305 `:${HTTP_PASSWORD}`,
306 ...(retry ? ['--retry', '10', '--retry-connrefused', '--retry-delay', '1'] : ['--max-time', '2']),
307 `http://127.0.0.1:${HTTP_PORT}/requests/status.xml?command=volume&val=${Math.round((clampVolume(percent) * 256) / 100)}`,
308]
309// Pause and resume through the same interface: instant, where stopping the
310// WSL pipeline only starved VLC after its buffer ran out, seconds later or
311// not at all. force* are idempotent, so a missed click cannot invert them.
312// On the phone (music.json holds a song), pause flips its flag; here, VLC's.
313export const pauseArgv = (isPaused: boolean): string[] => [
314 'sh',
315 '-c',
316 pauseScript(isPaused),
317 '_',
318 CURL,
319 '-sf',
320 '-o',
321 'NUL',
322 '-u',
323 `:${HTTP_PASSWORD}`,
324 '--max-time',
325 '2',
326 `http://127.0.0.1:${HTTP_PORT}/requests/status.xml?command=${isPaused ? 'pl_forcepause' : 'pl_forceresume'}`,
327]
328
329// How many rows a wrapped line of plain Buttons takes: each draws as
330// `k: label`, three columns past its label, with `gap` between them.
331export const buttonRows = (labels: string[], columns: number, gap = 2): number => {
332 let rows = 1
333 let used = 0
334 for (const label of labels) {
335 const w = label.length + 3
336 if (used > 0 && used + gap + w > columns) {
337 rows += 1
338 used = w
339 } else {
340 used += (used > 0 ? gap : 0) + w
341 }
342 }
343 return rows
344}
345
346// While the avatar speaks, the music drops to this share of its level.
347export const DUCK = 0.7
348export const duckArgv = (level: number, isVoicing: boolean): string[] => volumeArgv(isVoicing ? level * DUCK : level)
349const detached = (argv: string[]): string[] => ['bash', '-c', 'setsid "$0" "$@" </dev/null >/dev/null 2>&1 &', ...argv]
350export const detachedKillVlcArgv = ['bash', '-c', 'setsid "$0" "$@" </dev/null >/dev/null 2>&1 &', ...killVlcArgv]
351const signal = (sig: 'STOP' | 'CONT' | 'TERM', pgid: number): string[] => ['kill', `-${sig}`, '--', `-${pgid}`]
352
353const show = (p: Player) => {
354 const t = p.tracks[p.index]
355 return t === undefined ? undefined : `music${p.isPlaying ? '' : ' (paused)'}: ${t.title}`
356}
357
358async function halt($: Engine, pgid: number | null) {
359 // A stopped group ignores TERM until it runs again.
360 if (pgid !== null) {
361 await $.process.run(signal('CONT', pgid))
362 await $.process.run(signal('TERM', pgid))
363 }
364 await $.process.run(killVlcArgv)
365}
366
367// Fills in the duration of the track playing, if it still plays when the
368// answer comes; the play never waits on it.
369async function fillDuration($: Engine, id: string): Promise<void> {
370 try {
371 const r = await $.process.run(durationArgv(id), { timeoutMs: 20_000 })
372 const [found] = parseTracks(`${id}\t\t${r.stdout.trim()}`)
373 if (r.exitCode !== 0 || found?.seconds == null) return
374 const seconds = found.seconds
375 await update($, player, p =>
376 p.tracks[p.index]?.id === id ? { ...p, tracks: p.tracks.map((t, i) => (i === p.index ? { ...t, seconds } : t)) } : p,
377 )
378 } catch {
379 // No duration (yt-dlp failed, the session ended): the bar stays a clock.
380 }
381}
382
383// What the persona on duty hears when a song starts: the title, plain, and
384// the cue to introduce it in its own manner.
385export const introEvent = (title: string): string =>
386 `the jukebox starts "${title.replace(DISCOVERED, '')}"; introduce it in your own manner, the way a radio host would`
387
388async function playAt($: Engine, tracks: Track[], index: number, genre: string | null = null): Promise<Track | undefined> {
389 // Halted even when nothing plays from here: an orphan VLC on the port
390 // would take the controls of the new one. One jukebox per machine.
391 await halt($, (await read($, player)).pgid)
392 const track = tracks[index]
393 if (track === undefined) {
394 await update($, player, () => IDLE)
395 $.ui.status(undefined)
396 return undefined
397 }
398 const started = await $.process.run(startArgv(track.id))
399 const pgid = Number(started.stdout.trim())
400 const p: Player = {
401 tracks,
402 index,
403 pgid: Number.isInteger(pgid) && pgid > 1 ? pgid : null,
404 isPlaying: true,
405 genre,
406 startedAt: await $.clock.now(),
407 pausedAt: null,
408 }
409 await update($, player, () => p)
410 // Every start goes through here: asked, from a genre, or the next one when
411 // a song ends; avatar7, if loaded, speaks over the intro.
412 await $.state.set({ plugin: 'jukebox7', key: 'say' }, { mood: 'watch', event: introEvent(track.title), at: Date.now() } satisfies Say)
413 if (p.pgid !== null) await $.process.run(detached(volumeArgv(await read($, volume), true)))
414 if (track.seconds === null) void fillDuration($, track.id)
415 $.ui.status(show(p))
416 void $.ui.open({ id: PANE, title: 'jukebox7', rows: 6 })
417 return track
418}
419
420// One change of song at a time: a search takes up to 40 s, and the poll, a
421// button and a command would each start their own song meanwhile.
422let isMoving = false
423async function once($: Engine, change: () => Promise<Track | undefined>): Promise<Track | undefined> {
424 if (isMoving) {
425 $.ui.toast('music: a song is already on its way')
426 return undefined
427 }
428 isMoving = true
429 try {
430 return await change()
431 } finally {
432 isMoving = false
433 }
434}
435
436// Past the last result the list ends (playAt goes idle): no wrapping back to
437// the first, which would loop for ever over tracks that cannot play.
438async function skip($: Engine, by: number): Promise<Track | undefined> {
439 const p = await read($, player)
440 if (typeof p.genre === 'string') return playGenre($, p.genre)
441 return playAt($, p.tracks, p.index + by)
442}
443
444async function toggle($: Engine): Promise<Player | undefined> {
445 const p = await read($, player)
446 if (p.pgid === null) return p
447 // VLC not listening (still starting, gone): the state stays as it is, so
448 // the poll keeps watching a song that still plays.
449 const sent = await $.process.run(pauseArgv(p.isPlaying))
450 if (sent.exitCode !== 0) {
451 $.ui.toast('music: VLC did not answer, try again')
452 return undefined
453 }
454 const now = await $.clock.now()
455 const q: Player = p.isPlaying
456 ? { ...p, isPlaying: false, pausedAt: now }
457 : { ...p, isPlaying: true, pausedAt: null, startedAt: (p.startedAt ?? now) + (now - (p.pausedAt ?? now)) }
458 await update($, player, () => q)
459 // A voice that started or ended while paused: the level follows it again.
460 if (q.isPlaying) await $.process.run(detached(duckArgv(await read($, volume), await isAvatarVoicing($))))
461 $.ui.status(show(q))
462 return q
463}
464
465async function isAvatarVoicing($: Engine): Promise<boolean> {
466 return (await $.state.get({ plugin: 'avatar7', key: 'isVoicing' })).value === true
467}
468
469async function louder($: Engine, delta: number): Promise<number> {
470 const v = clampVolume((await read($, volume)) + delta)
471 await update($, volume, () => v)
472 if ((await read($, player)).pgid !== null) await $.process.run(duckArgv(v, await isAvatarVoicing($)))
473 return v
474}
475
476async function stop($: Engine) {
477 const p = await read($, player)
478 await halt($, p.pgid)
479 await update($, player, () => IDLE)
480 $.ui.status(undefined)
481 await $.ui.close({ id: PANE })
482}
483
484async function play($: Engine, query: string, long: boolean): Promise<Track | undefined> {
485 const found = await $.process.run(search(query), { timeoutMs: 20_000 })
486 if (found.exitCode !== 0) return undefined
487 const first = pickTrack(found.stdout, long)
488 if (first === undefined) return undefined
489 // The track asked for plays whatever its length; next walks songs only
490 // (no hour-long mix, no live stream that never ends).
491 const rest = parseTracks(found.stdout).filter(t => t.id !== first.id && isSong(t))
492 return playAt($, [first, ...rest], 0)
493}
494
495// Lists what YouTube finds for a search, and keeps it for a pick; undefined
496// when yt-dlp failed. One search at a time.
497async function find($: Engine, query: string): Promise<Track[] | undefined> {
498 isFinding = true
499 try {
500 const r = await $.process.run(search(query), { timeoutMs: 20_000 })
501 if (r.exitCode !== 0) return undefined
502 const tracks = parseTracks(r.stdout)
503 await update($, found, () => ({ query, tracks }))
504 return tracks
505 } finally {
506 isFinding = false
507 }
508}
509
510// The result at `index` (from 0) plays whatever its length; next walks on
511// through the rest of the search, as after /music <search>.
512async function pick($: Engine, index: number): Promise<Track | undefined> {
513 const f = await read($, found)
514 if (f.tracks[index] === undefined) return undefined
515 return playAt($, f.tracks, index)
516}
517
518// The results as /music find prints them: numbered from 1, with a length
519// when YouTube gave one.
520export const listing = (tracks: Track[]): string =>
521 tracks.map((t, i) => `${i + 1}. ${t.title}${t.seconds === null ? '' : ` (${clock(t.seconds)})`}`).join('\n')
522
523async function playGenre($: Engine, label: string): Promise<Track | undefined> {
524 const fresh = discoveries(label)
525 const isNew = fresh.length > 0 && Math.random() < DISCOVER_ODDS
526 const pool = await poolOf($, label)
527 const artist = anyOf(isNew ? fresh : pool.artists)
528 if (artist === undefined) return undefined
529 $.ui.status(`music: looking for ${pool.name}, ${artist}…`)
530 const was = (await read($, player)).tracks
531 const music = await $.process.run(musicSearch(artist), { timeoutMs: 20_000 })
532 let songs = music.exitCode === 0 ? parseTracks(music.stdout).filter(t => t.seconds === null || isSong(t)) : []
533 // Its titles carry no artist; the query names it.
534 songs = songs.map(t => ({ ...t, title: `${isNew ? DISCOVERED : ''}${artist} - ${t.title}` }))
535 if (songs.length === 0) {
536 const found = await $.process.run(search(artist), { timeoutMs: 20_000 })
537 songs = found.exitCode === 0 ? parseTracks(found.stdout).filter(isSong) : []
538 if (isNew) songs = songs.map(t => ({ ...t, title: `${DISCOVERED}${t.title}` }))
539 }
540 const unheard = songs.filter(t => !was.some(w => w.id === t.id))
541 const track = anyOf(unheard.length > 0 ? unheard : songs)
542 if (track === undefined) {
543 $.ui.status(undefined)
544 $.ui.toast(`music: nothing found for ${artist}`)
545 await $.state.set({ plugin: 'jukebox7', key: 'say' }, { mood: 'error', event: `the jukebox found nothing to play for ${artist}`, at: Date.now() } satisfies Say)
546 return undefined
547 }
548 $.ui.toast(`music now playing: ${track.title}`)
549 return playAt($, [track], 0, label)
550}
551
552// More like the song playing: its YouTube Music radio, as a list next walks.
553async function playSimilar($: Engine): Promise<Track | undefined> {
554 const p = await read($, player)
555 const now = p.tracks[p.index]
556 if (now === undefined) return undefined
557 $.ui.status(`music: looking for songs like ${now.title}…`)
558 const found = await $.process.run(radioArgv(now.id), { timeoutMs: 20_000 })
559 const tracks = found.exitCode === 0 ? parseRadio(found.stdout) : []
560 if (tracks.length === 0) {
561 $.ui.status(show(p))
562 $.ui.toast('music: no radio for this one')
563 return undefined
564 }
565 $.ui.toast(`music, like ${now.title}: ${tracks[0]?.title}`)
566 return playAt($, tracks, 0, null)
567}
568
569const clock = (s: number): string => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`
570
571// `▰▰▰▱▱▱ 03:12 / 07:00`, or the elapsed time alone when YouTube gave no length.
572export const progress = (p: Player, now: number, seconds: number | null): { done: string; left: string; time: string } => {
573 const elapsed = p.startedAt === null ? 0 : Math.max(0, ((p.pausedAt ?? now) - p.startedAt) / 1000)
574 if (seconds === null || seconds <= 0) return { done: '', left: '', time: clock(elapsed) }
575 const filled = Math.min(BAR, Math.round((elapsed / seconds) * BAR))
576 return { done: '▰'.repeat(filled), left: '▱'.repeat(BAR - filled), time: `${clock(Math.min(elapsed, seconds))} / ${clock(seconds)}` }
577}
578
579// Ghost in the Shell's green code rain under the player: half-width katakana
580// and digits falling per column, moving only while a song plays.
581const GLYPHS = 'ヲァィゥェォャュョッアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789'
582const TRAIL = 6
583const RAIN_MS = 200
584let frame = 0
585const hash = (n: number): number => {
586 let h = Math.imul(n ^ 0x9e3779b9, 0x85ebca6b)
587 h ^= h >>> 13
588 h = Math.imul(h, 0xc2b2ae35)
589 return (h ^ (h >>> 16)) >>> 0
590}
591
592// One row is a list of runs: 0 blank, 1 trail (dim), 2 head (bright).
593export const rain = (columns: number, rows: number, tick: number): { level: 0 | 1 | 2; text: string }[][] =>
594 Array.from({ length: rows }, (_, r) => {
595 const runs: { level: 0 | 1 | 2; text: string }[] = []
596 for (let c = 0; c < columns; c++) {
597 const span = rows + TRAIL + (hash(c * 7 + 1) % rows || 1)
598 const speed = 1 + (hash(c * 13 + 5) % 2)
599 const head = (Math.floor((tick * speed) / 2) + hash(c * 29 + 3)) % span
600 const behind = head - r
601 const level: 0 | 1 | 2 = c % 2 === 1 || behind < 0 || behind > TRAIL ? 0 : behind === 0 ? 2 : 1
602 const ch = level === 0 ? ' ' : (GLYPHS[hash(c * 131 + r * 17 + Math.floor(tick / 3)) % GLYPHS.length] ?? ' ')
603 const last = runs[runs.length - 1]
604 if (last !== undefined && last.level === level) last.text += ch
605 else runs.push({ level, text: ch })
606 }
607 return runs
608 })
609
610export const register: Register = on => {
611 // A daemon's background session (a spare kept warm, a `claude --bg`)
612 // inherits the plugin dirs but nobody listens to it: no /music, no poll,
613 // no prompt turned into a song.
614 let isBackground = false
615
616 // avatar7 speaking: the music steps back, then returns to its level. The
617 // curl is detached, so the voice never waits on VLC.
618 on('state.set', { plugin: 'avatar7', key: 'isVoicing' }, async ($, e, next) => {
619 const done = await next(e)
620 const p = await read($, player)
621 if (p.pgid !== null && p.isPlaying) await $.process.run(detached(duckArgv(await read($, volume), e.value === true)))
622 return done
623 })
624
625 on('session.start', async ($, e, next) => {
626 const kind = await $.process.run(['sh', '-c', 'printf %s "$CLAUDE_CODE_SESSION_KIND"'])
627 isBackground = kind.stdout === 'bg'
628 if (isBackground) return next(e)
629
630 await $.command.register({
631 name: 'music',
632 description: 'Play music from YouTube: /music <search>, /music find <search> then /music pick <n>, /music pause, /music next, /music similar, /music stop, /music vol [+|-]<n>, /music alone for what plays',
633 })
634
635 // A pipeline that ended by itself (the track is over) moves on to the
636 // next result; one that cannot be found any more is forgotten.
637 $.clock.every(POLL_MS, () => {
638 void (async () => {
639 const p = await read($, player)
640 if (p.pgid === null || !p.isPlaying || isMoving) return
641 const alive = await $.process.run(['kill', '-0', '--', `-${p.pgid}`])
642 if (alive.exitCode === 0) {
643 $.ui.invalidate('ui.render')
644 return
645 }
646 // Nothing to follow (the list ended, no network, no station): the
647 // jukebox goes idle rather than retry every tick.
648 if ((await once($, () => skip($, 1))) === undefined) {
649 await update($, player, () => IDLE)
650 $.ui.status(undefined)
651 }
652 })()
653 })
654
655 // The phone, through avatar7's relay: its presses for the jukebox, and
656 // what the jukebox shows it, written again only when it changed.
657 let shown = ''
658 // Where the voice was at the last tick: undefined until first seen.
659 let wasHeld: boolean | undefined
660 $.clock.every(REMOTE_MS, () => {
661 void (async () => {
662 // The relay just taken: a song playing here starts again from its
663 // beginning, and its start takes the remote branch (no seek through
664 // the relay yet). Given back, the phone's song ends by itself and the
665 // next one plays here.
666 const isHeld = (await $.process.run(heldArgv())).stdout.trim() === 'up'
667 const was = wasHeld
668 wasHeld = isHeld
669 if (isHeld && was === false && !isMoving) {
670 const p = await read($, player)
671 if (p.pgid !== null && p.isPlaying) await once($, () => playAt($, p.tracks, p.index, p.genre))
672 }
673 const out = (await $.process.run(drainArgv())).stdout
674 for (const line of out.split('\n')) {
675 const press = parseJukebox(line, GENRES.map(g => g.label))
676 if (press === undefined) continue
677 if (press.do === 'pause') await toggle($)
678 else if (press.do === 'stop') await stop($)
679 else if (press.do === 'next') await once($, () => skip($, 1))
680 else if (press.do === 'similar') await once($, () => playSimilar($))
681 else if (press.do === 'find') {
682 if (!isFinding) await find($, press.query)
683 } else if (press.do === 'pick') await once($, () => pick($, press.index))
684 else await once($, () => playGenre($, press.genre))
685 $.ui.invalidate('ui.render')
686 }
687 const p = await read($, player)
688 const now = p.pgid === null ? undefined : p.tracks[p.index]
689 const f = await read($, found)
690 const status = JSON.stringify({
691 title: now?.title ?? '', isPlaying: now !== undefined && p.isPlaying, genre: p.genre ?? '',
692 genres: GENRES.map(g => g.label), isMoving,
693 // The last search, for the phone to list and pick from.
694 query: f.query, found: f.tracks.map(t => ({ title: t.title, seconds: t.seconds })), isFinding,
695 })
696 if (status !== shown) {
697 await $.process.run(statusArgv(), { stdin: status })
698 shown = status
699 }
700 })()
701 })
702
703 $.clock.every(RAIN_MS, () => {
704 void (async () => {
705 if (!(await read($, player)).isPlaying) return
706 frame += 1
707 $.ui.invalidate('ui.render')
708 })()
709 })
710
711 // Unasked, the pane seats from 144 columns, under the avatar's; /music
712 // opens it at any width.
713 void $.ui.open({ id: PANE, title: 'jukebox7', rows: 6 })
714
715 return next(e)
716 })
717
718 // Quitting (or /clear, which loses $.state's pgid) must not orphan the
719 // detached pipeline. The end chain runs under one short bound and
720 // PowerShell starts slower than that: the group goes at once, the VLC
721 // kill is detached so it outlives the exit.
722 // A session that never played (another window, a background spare) leaves
723 // the VLC alone: it may be another session's song.
724 on('session.end', async ($, e, next) => {
725 const p = await read($, player)
726 if (p.pgid !== null) {
727 await $.process.run(signal('CONT', p.pgid))
728 await $.process.run(signal('TERM', p.pgid))
729 await $.process.run(detachedKillVlcArgv)
730 }
731 return next(e)
732 })
733
734 on('prompt.submit', async ($, e, next) => {
735 if (isBackground || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') || !isCandidate(e.text)) return next(e)
736
737 const r = await $.model.complete({
738 model: 'haiku',
739 system: INTENT,
740 prompt: e.text.slice(0, 500),
741 maxTokens: 60,
742 effort: 'low',
743 timeoutMs: 8_000,
744 })
745 if (r.usage !== undefined) {
746 await $.state.set({ plugin: 'jukebox7', key: 'modelUse' }, {
747 model: 'haiku', input: r.usage.input_tokens, output: r.usage.output_tokens, cacheRead: r.usage.cache_read_input_tokens ?? 0, at: Date.now(),
748 } satisfies ModelUse)
749 }
750 const intent = r.isAnswered ? parseIntent(r.text) : ({ action: 'none' } as const)
751
752 if (intent.action === 'none') return next(e)
753 // A song already on its way: no second search, and no false "nothing found".
754 const changes = intent.action === 'play' || intent.action === 'next' || intent.action === 'similar'
755 if (changes && isMoving) return { drop: 'jukebox7: a song is already on its way' }
756
757 if (intent.action !== 'play') {
758 // Nothing plays: a bare stop, pause or next was meant for the assistant.
759 if ((await read($, player)).pgid === null) return next(e)
760 if (intent.action === 'stop') {
761 await stop($)
762 $.ui.toast('music stopped')
763 return { drop: 'jukebox7: stopped' }
764 }
765 if (intent.action === 'volume') {
766 const v = await louder($, intent.delta)
767 $.ui.toast(`music volume ${v}%`)
768 return { drop: `jukebox7: volume ${v}%` }
769 }
770 if (intent.action === 'similar') {
771 const t = await once($, () => playSimilar($))
772 return { drop: `jukebox7: ${t === undefined ? 'no radio for this one' : `like it, ${t.title}`}` }
773 }
774 if (intent.action === 'next') {
775 const t = await once($, () => skip($, 1))
776 $.ui.toast(t === undefined ? 'music: end of the list' : `music now playing: ${t.title}`)
777 return { drop: `jukebox7: ${t === undefined ? 'end of the list' : `next, ${t.title}`}` }
778 }
779 const q = await toggle($)
780 if (q === undefined) return { drop: 'jukebox7: VLC did not answer' }
781 const said = q.isPlaying ? `resumed: ${q.tracks[q.index]?.title ?? ''}` : 'paused'
782 $.ui.toast(`music ${said}`)
783 return { drop: `jukebox7: ${said}` }
784 }
785
786 const track = await once($, () => play($, intent.query, intent.long))
787 if (track === undefined) return { drop: `jukebox7: nothing found on YouTube for "${intent.query}".` }
788 $.ui.toast(`music now playing: ${track.title}`)
789 return { drop: `jukebox7: playing "${track.title}" (https://youtu.be/${track.id})` }
790 })
791
792 on('command.run', { command: 'music' }, async ($, e) => {
793 const args = e.args.trim()
794 const isControl = args === '' || args === 'pause' || args === 'stop' || /^vol(?:ume)?\b/.test(args) || /^find\b/.test(args)
795 if (!isControl && isMoving) return { text: 'A song is already on its way.' }
796 const p = await read($, player)
797 const now = p.tracks[p.index]
798 if (args === '') {
799 void $.ui.open({ id: PANE, title: 'jukebox7', rows: 6 })
800 if (p.pgid === null || now === undefined) return { text: 'Nothing played from here yet: pick a genre in the pane.' }
801 return { text: `${p.isPlaying ? 'Playing' : 'Paused'}: ${now.title}` }
802 }
803
804 const vol = /^vol(?:ume)?(?:\s+([+-]?)(\d+))?$/.exec(args)
805 if (vol !== null) {
806 const [, sign, n] = vol
807 if (n === undefined) return { text: `Volume ${await read($, volume)}%.` }
808 const v = await louder($, sign === '' ? Number(n) - (await read($, volume)) : Number(`${sign}${n}`))
809 return { text: `Volume ${v}%.` }
810 }
811
812 if (args === 'similar' || args === 'like') {
813 const t = await once($, () => playSimilar($))
814 return { text: t === undefined ? 'No radio for this one.' : `Like it: ${t.title}` }
815 }
816
817 if (args === 'pause' || args === 'next' || args === 'stop') {
818 if (p.pgid === null) return { text: 'Nothing is playing from here.' }
819 if (args === 'stop') {
820 await stop($)
821 return { text: 'Stopped.' }
822 }
823 if (args === 'next') {
824 const t = await once($, () => skip($, 1))
825 return { text: t === undefined ? 'End of the list.' : `Next: ${t.title}` }
826 }
827 const q = await toggle($)
828 if (q === undefined) return { text: 'VLC did not answer; nothing changed.' }
829 return { text: q.isPlaying ? 'Resumed.' : 'Paused.' }
830 }
831
832 // /music find <search>: the results, numbered, nothing played; /music
833 // pick <n> plays one of them.
834 const asked = /^find\s+(.+)$/.exec(args)
835 if (asked !== null) {
836 if (isFinding) return { text: 'A search is already running.' }
837 const query = (asked[1] ?? '').trim()
838 const tracks = await find($, query)
839 if (tracks === undefined) return { text: `The search for "${query}" failed.` }
840 if (tracks.length === 0) return { text: `Nothing found on YouTube for "${query}".` }
841 return { text: `${listing(tracks)}\n\n/music pick <n> plays one.` }
842 }
843 const picked = /^pick\s+(\d+)$/.exec(args)
844 if (picked !== null) {
845 const f = await read($, found)
846 const n = Number(picked[1]) - 1
847 if (f.tracks.length === 0) return { text: 'Nothing found yet: /music find <search> first.' }
848 if (f.tracks[n] === undefined) return { text: `Pick 1 to ${f.tracks.length}.` }
849 const t = await once($, () => pick($, n))
850 return { text: t === undefined ? 'That one did not start.' : `Playing "${t.title}" (https://youtu.be/${t.id})` }
851 }
852
853 const track = await once($, () => play($, args, false))
854 if (track === undefined) return { text: `Nothing found on YouTube for "${args}".` }
855 return { text: `Playing "${track.title}" (https://youtu.be/${track.id})` }
856 })
857
858 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
859 const { Box, Text, Button, Input } = $.ui.resolve(e)
860 const p = await read($, player)
861 const level = await read($, volume)
862 const now = p.tracks[p.index]
863 const { value: avatar } = await $.state.get(onDuty)
864 const { value: onAir } = await $.state.get(onStation)
865 const station = onAir !== undefined && onAir.artists.length > 0 ? onAir : undefined
866 const isTerminal = e.surface === 'terminal'
867 // The border and the play glyph take three columns on the terminal.
868 const width = Math.max(10, (e.props.bodyColumns ?? 40) - (isTerminal ? 5 : 2))
869 const title = now === undefined ? 'Nothing plays.' : now.title.length > width ? `${now.title.slice(0, width - 1)}…` : now.title
870 const where = p.genre === null || p.genre === undefined ? `${p.index + 1}/${p.tracks.length}` : (GENRES.some(g => g.label === p.genre) ? p.genre : (onAir?.name || p.genre))
871
872 // A list, not a fragment: the terminal lays a fragment out as a column of
873 // its own, which stacked the controls one per row and cut off `u: vol+`.
874 const controls = [
875 <Button key="toggle" label={p.isPlaying ? 'pause' : 'play'} hotkey="p" plain onPress={() => void toggle($)} />,
876 <Button key="next" label="next" hotkey="n" plain onPress={() => void once($, () => skip($, 1))} />,
877 <Button key="similar" label="similar" hotkey="r" plain onPress={() => void once($, () => playSimilar($))} />,
878 <Button key="stop" label="stop" hotkey="s" plain dimColor onPress={() => void stop($)} />,
879 <Button key="quieter" label="vol−" hotkey="d" plain dimColor onPress={() => void louder($, -VOLUME_STEP)} />,
880 <Button key="louder" label="vol+" hotkey="u" plain dimColor onPress={() => void louder($, VOLUME_STEP)} />,
881 ]
882 const f = await read($, found)
883 const isSearching = await read($, searching)
884 const fit = (t: string, room: number): string => (t.length > room ? `${t.slice(0, room - 1)}…` : t)
885 const reopen = (open: boolean, results: number) => void $.ui.open({ id: PANE, title: 'jukebox7', rows: paneRows(open, results) })
886 const finder = (
887 <Button
888 key="find"
889 label={isSearching ? 'close search' : 'find'}
890 hotkey="f"
891 plain
892 dimColor={!isSearching}
893 onPress={() => {
894 void (async () => {
895 await update($, searching, () => !isSearching)
896 reopen(!isSearching, f.tracks.length)
897 })()
898 }}
899 />
900 )
901 const search = isSearching ? (
902 <Box flexDirection="column" backgroundColor={isTerminal ? BLACK : undefined}>
903 <Input
904 key="query"
905 label="find: "
906 placeholder={f.query === '' ? 'ctrl+x tab, a search, Enter' : f.query}
907 submitLabel="find"
908 autoFocus
909 onSubmit={value => {
910 const query = value.replace(/\s+/g, ' ').trim()
911 if (query === '' || isFinding) return
912 void (async () => {
913 const pending = find($, query)
914 $.ui.invalidate('ui.render')
915 const tracks = await pending
916 reopen(true, tracks?.length ?? 1)
917 $.ui.invalidate('ui.render')
918 })()
919 }}
920 />
921 {isFinding ? (
922 <Text dimColor>searching…</Text>
923 ) : f.query !== '' && f.tracks.length === 0 ? (
924 <Text dimColor>{`nothing found for "${f.query}"`}</Text>
925 ) : (
926 f.tracks.slice(0, PICK_KEYS.length).map((t, i) => (
927 <Button
928 key={`pick-${t.id}`}
929 label={fit(`${t.title}${t.seconds === null ? '' : ` (${clock(t.seconds)})`}`, width - 3)}
930 hotkey={PICK_KEYS[i]}
931 plain
932 dimColor={now?.id !== t.id}
933 onPress={() => void once($, () => pick($, i))}
934 />
935 ))
936 )}
937 </Box>
938 ) : null
939
940 const stations = (
941 <Box flexDirection="row" flexWrap="wrap" columnGap={2} backgroundColor={isTerminal ? BLACK : undefined}>
942 {GENRES.map(g => (
943 <Button key={g.label} label={g.label} hotkey={g.key} plain dimColor onPress={() => void once($, () => playGenre($, g.label))} />
944 ))}
945 {station !== undefined && avatar !== undefined && (
946 <Button key="avatar" label={`${station.name}'s pick`} hotkey="a" plain onPress={() => void once($, () => playGenre($, avatar))} />
947 )}
948 {isTerminal && finder}
949 </Box>
950 )
951
952 if (!isTerminal) {
953 return (
954 <Box flexDirection="column">
955 <Text dimColor={now === undefined || !p.isPlaying}>{title}</Text>
956 {now !== undefined && (
957 <Box flexDirection="row" gap={2}>
958 <Text dimColor>
959 {p.isPlaying ? 'playing' : 'paused'} {where} · vol {level}%
960 </Text>
961 {controls}
962 </Box>
963 )}
964 {stations}
965 {finder}
966 {search}
967 </Box>
968 )
969 }
970
971 // The terminal: black, framed and lit in the color of the avatar on duty.
972 const { value: color } = await $.state.get(tint)
973 const accent = typeof color === 'string' && color !== '' ? color : PHOSPHOR
974 const bar = progress(p, await $.clock.now(), now?.seconds ?? null)
975 const rows = e.props.scroll?.bodyRows ?? 0
976 // The frame, the title, the bar, the controls and the stations; the rain
977 // takes what is left of the pane's height.
978 // The controls and the stations wrap inside the frame (border and padding:
979 // four columns); each row they take comes out of the rain.
980 const inner = Math.max(10, (e.props.bodyColumns ?? 40) - 4)
981 const controlRows = now === undefined ? 0 : buttonRows([p.isPlaying ? 'pause' : 'play', 'next', 'similar', 'stop', 'vol−', 'vol+'], inner)
982 const stationRows = buttonRows(
983 [...GENRES.map(g => g.label), ...(station !== undefined ? [`${station.name}'s pick`] : []), isSearching ? 'close search' : 'find'],
984 inner,
985 )
986 // The field, then a result a row (or the one line saying why there are none).
987 const searchRows = isSearching ? 1 + (isFinding || f.tracks.length === 0 ? (f.query === '' && !isFinding ? 0 : 1) : Math.min(f.tracks.length, PICK_KEYS.length)) : 0
988 // The frame's two borders, the title, the bar when something plays.
989 const rainRows = Math.max(0, rows - 3 - (now === undefined ? 0 : 1) - controlRows - stationRows - searchRows)
990 const rainColumns = Math.max(0, (e.props.bodyColumns ?? 40) - 2)
991 return (
992 <Box flexDirection="column" width="100%" height={rows > 0 ? rows : undefined} backgroundColor={BLACK}>
993 <Box flexDirection="column" borderStyle="round" borderColor={accent} backgroundColor={BLACK} paddingX={1}>
994 <Text color={accent} backgroundColor={BLACK} dimColor={now === undefined || !p.isPlaying}>
995 {now === undefined ? '■ ' : p.isPlaying ? '▶ ' : '❚❚ '}
996 {title}
997 </Text>
998 {now !== undefined && (
999 <Text backgroundColor={BLACK}>
1000 <Text color={accent}>{bar.done}</Text>
1001 <Text dimColor>{bar.left}</Text>
1002 <Text dimColor>{` ${bar.time} ${where} vol ${level}%`}</Text>
1003 </Text>
1004 )}
1005 {now !== undefined && (
1006 <Box flexDirection="row" flexWrap="wrap" columnGap={2} backgroundColor={BLACK}>
1007 {controls}
1008 </Box>
1009 )}
1010 {stations}
1011 {search}
1012 </Box>
1013 <Box flexDirection="column" flexGrow={1} paddingX={1} backgroundColor={BLACK}>
1014 {rain(rainColumns, rainRows, frame).map((runs, r) => (
1015 <Text key={String(r)} backgroundColor={BLACK}>
1016 {runs.map((run, i) => (
1017 <Text key={String(i)} color={run.level === 2 ? '#e8fff4' : accent} dimColor={run.level === 1}>
1018 {run.text}
1019 </Text>
1020 ))}
1021 </Text>
1022 ))}
1023 </Box>
1024 </Box>
1025 )
1026 })
1027}
1028hooks/remote.ts 71 lines1// jukebox7's remote branch: pure strings, run for real by
2// tools/test_remote_shell.ts (the plugin tests mock every process).
3//
4// While avatar7's relay is held (the voice is on the phone), the music goes
5// there too: the song is written for the relay (music.json), the phone streams
6// it through the relay, and this process waits for the phone to say it ended
7// (or skipped it): then it ends, and the poll moves on as when VLC ends a song.
8// Given back meanwhile, it ends too, and the next song plays here. Its exit
9// clears music.json unless a newer song took it already. No single quote: it
10// runs inside one.
11export const RELAY = '"\${XDG_CACHE_HOME:-$HOME/.cache}/avatar7/relay"'
12export const REMOTE =
13 `R=${RELAY}; if [ -f "$R/owner" ]; then rm -f "$R/ended-$1"; ` +
14 `printf "{\\"id\\": \\"%s\\", \\"paused\\": false}" "$1" > "$R/music.part" && mv "$R/music.part" "$R/music.json"; ` +
15 `trap "grep -q $1 $R/music.json 2>/dev/null && printf {} > $R/music.part && mv $R/music.part $R/music.json" EXIT; trap exit TERM; ` +
16 `while [ ! -f "$R/ended-$1" ] && [ -f "$R/owner" ]; do sleep 1; done; rm -f "$R/ended-$1"; exit; fi; `
17
18// Prints `up` while a session holds the relay: the poll watches it change,
19// so a song already playing here moves to the phone when the voice does.
20export const heldArgv = (): string[] => ['sh', '-c', `R=${RELAY}; [ -f "$R/owner" ] && echo up; true`, 'jukebox7-held']
21
22// Pause on the phone flips the flag of the song music.json holds; otherwise
23// the arguments after the script (VLC's curl) run as they are.
24export const pauseScript = (isPaused: boolean): string =>
25 `R=${RELAY}; if [ -f "$R/owner" ] && grep -q '"id"' "$R/music.json" 2>/dev/null; then ` +
26 `sed 's/"paused": [a-z]*/"paused": ${isPaused}/' "$R/music.json" > "$R/music.part" && mv "$R/music.part" "$R/music.json"; else exec "$@"; fi`
27
28// The phone's presses for the jukebox, queued by the relay one JSON file each
29// under jukebox/: read and removed in one go, a line each.
30export const drainArgv = (): string[] => [
31 'sh',
32 '-c',
33 `R=${RELAY}; for f in "$R"/jukebox/*.json; do [ -f "$f" ] && cat "$f" && echo && rm -f "$f"; done; true`,
34]
35
36// What the jukebox shows the phone (title, playing, genre, the genres), from
37// stdin, while a session holds the relay.
38export const statusArgv = (): string[] => [
39 'sh',
40 '-c',
41 `R=${RELAY}; [ -f "$R/owner" ] && cat > "$R/jukebox.part" && mv "$R/jukebox.part" "$R/jukebox.json"; true`,
42]
43
44export type JukeboxPress =
45 | { do: 'pause' | 'next' | 'similar' | 'stop' }
46 | { do: 'genre'; genre: string }
47 | { do: 'find'; query: string }
48 | { do: 'pick'; index: number }
49
50// A search asked from the phone, cut as the relay cuts it.
51export const QUERY_MAX = 120
52
53// A press as the relay queued it, or undefined for anything else: a genre
54// must be one of the given labels.
55export const parseJukebox = (line: string, genres: string[]): JukeboxPress | undefined => {
56 let r: unknown
57 try {
58 r = JSON.parse(line)
59 } catch {
60 return undefined
61 }
62 if (typeof r !== 'object' || r === null) return undefined
63 const o = r as Record<string, unknown>
64 if (o.do === 'pause' || o.do === 'next' || o.do === 'similar' || o.do === 'stop') return { do: o.do }
65 if (o.do === 'genre' && typeof o.genre === 'string' && genres.includes(o.genre)) return { do: 'genre', genre: o.genre }
66 if (o.do === 'find' && typeof o.query === 'string' && o.query.trim() !== '') return { do: 'find', query: o.query.trim().slice(0, QUERY_MAX) }
67 // Which result of the last search, from 0; checked against the list when it plays.
68 if (o.do === 'pick' && typeof o.index === 'number' && Number.isInteger(o.index) && o.index >= 0) return { do: 'pick', index: o.index }
69 return undefined
70}
71types/index.d.ts 37 lines1export type Track = { id: string; title: string; seconds: number | null }
2
3// What plays: the search results, the one playing, and the process group of
4// its yt-dlp | VLC pipeline. Kept in $.state so a reload still owns it.
5export type Player = {
6 tracks: Track[]
7 index: number
8 pgid: number | null
9 isPlaying: boolean
10 // The genre button behind it: next and the end of a song roll again there.
11 genre: string | null
12 // When the track started, pushed forward by each pause, and when the
13 // current pause began (ms, $.clock.now): what the progress bar reads.
14 startedAt: number | null
15 pausedAt: number | null
16}
17
18// What this mod asks avatar7 to say, if avatar7 is loaded: each song as it
19// starts, for the persona to introduce like a radio host; a search that found
20// nothing. avatar7 hears the write; nothing here depends on it.
21export type Say = { mood: 'watch' | 'error'; event: string; at: number }
22
23// The last search asked for a pick (/music find, the pane's f, the phone):
24// what was asked and the results, in YouTube's order.
25export type Found = { query: string; tracks: Track[] }
26
27// One model call of this mod (the intent of a prompt), for usage-bell's tally.
28export type ModelUse = { model: string; input: number; output: number; cacheRead: number; at: number }
29
30declare module 'claude-code' {
31 interface PluginState {
32 jukebox7: { player: Player; volume: number; say: Say; found: Found; searching: boolean; modelUse: ModelUse }
33 // Read only: avatar7 owns it and says which face is on duty.
34 avatar7: { avatar: string; color: string; isVoicing: boolean; station: { name: string; artists: string[] } }
35 }
36}
37