diff options
Diffstat (limited to 'docs/superpowers/specs/2026-10-23-companion-music-design.md')
| -rw-r--r-- | docs/superpowers/specs/2026-10-23-companion-music-design.md | 125 |
1 files changed, 125 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-10-23-companion-music-design.md b/docs/superpowers/specs/2026-10-23-companion-music-design.md new file mode 100644 index 0000000..dc30257 --- /dev/null +++ b/docs/superpowers/specs/2026-10-23-companion-music-design.md @@ -0,0 +1,125 @@ +# Companion music: design + +Date: 2026-10-23. Status: designed, not implemented. + +## Goal + +The companion reacts to what is playing, not only to headphones +connecting. When a track starts or changes she DJs behind a console for a +few seconds, and says a line if she knows the artist. While music plays +and she is bored, she grooves instead of looking sleepy: whistling, +headbanging or playing air guitar, with an occasional line. Her artist +lines are edited in Settings > Companion artists, like the app lines. + +## Verified before designing + +- **`modules/sound/Player.qml` already picks the real player.** It is a + singleton the island reads, filters out the playerctld and + plasma-browser-integration duplicates, and exposes `playing`, `artist` + and `title`. The companion reads it; it opens no MPRIS of its own. +- **The app lines page is one parameter away from serving artists.** + `PhrasesPage.qml` edits a list of `{cls, lines}` owned by + `PhrasesModule.qml`, which saves through `Prefs.setCompanionApps` and + falls back to `Lines.apps`. Only the key's label is app specific. + +## Behaviour + +### DJ burst + +A track key is `artist + "\n" + title`. While `Player.playing` is true, a +key different from the last one seen sets `djAt` to now and becomes the +last one seen. Resuming the same track after a pause is not a change, so +it does not DJ. A shell start with music already playing does DJ once: +unlike Bluetooth, MPRIS has no empty startup window to mistake for a +connection, and a DJ on launch is harmless. + +`Mood.T.djHold` is 6000. In `Mood.pick` the DJ ranks directly below +`sing`, so headphones connecting together with music starting sings +first. Its frames are `dj` and `dj-scratch`, cycled by Companion.qml the +way the wave frames are. + +On the burst she says an artist line if one matches, under the 30 s line +cooldown. No match says nothing. + +### Artist matching + +`Lines.artistLine(map, artist)`: the first key of the map that is a +case-insensitive substring of the reported artist wins, and one of its +lines is picked at random. "daft punk" matches "Daft Punk, Pharrell +Williams". Short keys can over-match ("air" in "Airbourne"); accepted. + +### Groove + +`Mood.pick` gains `s.music` (`Player.playing`). At `idle === 1` with +music, the state is `groove` instead of `bored`; yawning and lying down +are unchanged, so music does not stop her dozing off and the screensaver +handoff stays the same. + +Reaching idle stage 1, or music starting while already there, picks one +groove at random: `whistle`, `headbang` or `airguitar`; an unset pick +reads as `whistle`. The pick is passed to `pick` as `s.groove`, so +`Mood.js` stays pure. Headbang cycles `headbang-up` and `headbang-down` +fast; the other two are single frames. + +The bored line becomes a `groove` line when music is playing, same +chance, same cooldown. The opening monologue keeps its bored trigger +only when there is no music. + +## Lines + +New reaction group in `Lines.lines`: + + groove: ["This one's good.", "...Don't tell 9S.", "Again. Play it again."], + +Default artists, `Lines.artists`: + + "linkin park": ["In the end, it doesn't even matter.", "Crawling in my skin... I know this one."], + "evanescence": ["Wake me up inside. ...Operator, don't laugh."], + "gigi d'agostino": ["L'amour toujours. I'll fly with you.", "...My feet are moving on their own."], + "eminem": ["Lose yourself. Understood.", "He talks faster than Pod."], + +`appList` and `toMap` take the default set as an argument, so the same +functions serve apps and artists; `appMap` becomes `pickMap(saved, +defaults)`. + +## Settings + +- `Prefs.companionArtists`, `setCompanionArtists`, `resetCompanionArtists`, + parsed and defaulted like `companionApps`. +- `PhrasesPage.qml` gains `keyLabel` (default "Class") used for the key + field's placeholder and label. +- `modules/companion/ArtistsModule.qml`: "Companion artists", group + Companion, a copy of `PhrasesModule` bound to the artist prefs and + defaults, `keyLabel: "Artist"`. Registered in `shell.qml`, which means a + restart (see AGENTS.md, Reloading). + +## Frames + +In `frames.txt`, each with its leotard line (`${LEOTARD}`), 12 in all: + + DECK = A small black DJ console with two turntables and a mixer stands in front of her at waist height, hiding her legs. + dress/dj <- dress/base : ${DECK} Both gloved hands rest on the turntables, head nodding, a small smile; she wears black over-ear headphones. ${EYES} ${KEEP} + dress/dj-scratch <- dress/dj : Change only her arms: the hand on the left side of the image scratches a record, the other holds one headphone cup to her ear. Everything else exactly the same. ${KEEP} + dress/whistle <- dress/base : Change only her expression: lips puckered, whistling, a couple of small black musical notes floating beside her mouth. ${POSE} ${EYES} ${KEEP} + dress/headbang-up <- dress/base : She headbangs: head tipped back, hair flying up, eyes shut, fists clenched at her sides. ${BARE} ${KEEP_BARE} + dress/headbang-down <- dress/headbang-up : Change only her head: tipped sharply forward and down, hair thrown forward over her face. Everything else exactly the same. ${KEEP_BARE} + dress/airguitar <- dress/base : She plays an invisible air guitar: one hand strumming at her hip, the other fretting in the air to the side, knees bent, eyes shut, mouth open in a grin. Nothing in her hands. ${BARE} ${KEEP_BARE} + +Prompts are a first draft: klein results get rerolled and reworded as +before, and what failed goes into `recipe.txt`. The user runs +`gen-frames`; a missing frame falls back to `base`, so the code can land +before the art. + +## Tests + +- `test_mood.js`: dj wins over clicks and loses to sing; groove replaces + bored only with music; yawn and lying still win with music; groove + frame follows `s.groove`. +- `test_lines.js`: substring match is case-insensitive and picks the + first matching key; no match returns nothing; `appList`/`toMap` round + trip with the artist defaults. + +## Not doing + +- Window title or URL reactions (separate idea, not this spec). +- Per-artist frames, genre detection, beat sync. |
