aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-10-23-companion-music-design.md
blob: dc302576d442cc7e17a70984fcb40e543b369972 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
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.