moontide-mcp 2.0.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +94 -6
  2. package/dist/ask.js +76 -15
  3. package/dist/mcp.js +198 -26
  4. package/dist/protocol.js +130 -2
  5. package/dist/reference.js +22 -0
  6. package/dist/request-status.js +221 -0
  7. package/dist/session.js +256 -7
  8. package/package.json +1 -1
  9. package/page/ask-view.js +64 -57
  10. package/page/ask.js +363 -188
  11. package/page/bar-model.js +43 -2
  12. package/page/change-timing.js +60 -4
  13. package/page/harmony.js +13 -0
  14. package/page/index.html +7 -4
  15. package/page/jam-analysis.js +68 -23
  16. package/page/jam-model.js +196 -31
  17. package/page/jam-resolve.js +271 -0
  18. package/page/jam-view.js +182 -10
  19. package/page/lights.js +300 -0
  20. package/page/main.js +2171 -188
  21. package/page/midi-clock.js +23 -4
  22. package/page/midi-out.js +17 -2
  23. package/page/mixer-groups.js +114 -0
  24. package/page/mixer-model.js +172 -29
  25. package/page/mixer-rewrite.js +30 -1
  26. package/page/mixer-view.js +289 -14
  27. package/page/note-edit.js +267 -0
  28. package/page/note-locate.js +346 -0
  29. package/page/note-panel.js +402 -0
  30. package/page/note-tag.js +141 -0
  31. package/page/page-rewrite.js +184 -0
  32. package/page/query-check.js +21 -2
  33. package/page/request-status-view.js +419 -0
  34. package/page/request-status.js +686 -0
  35. package/page/song-edit.js +230 -0
  36. package/page/song-fit.js +250 -0
  37. package/page/song-grid-model.js +506 -0
  38. package/page/song-grid-view.js +710 -0
  39. package/page/song-prefs.js +100 -0
  40. package/page/structure-check.js +216 -0
  41. package/page/structure-view.js +38 -0
  42. package/page/structure.js +761 -0
  43. package/page/style.css +1218 -65
  44. package/page/takeover.js +20 -5
  45. package/page/tempo-glide.js +330 -0
  46. package/page/tempo-guard.js +62 -10
  47. package/page/timeline-edit-view.js +1956 -0
  48. package/page/timeline-gesture.js +279 -0
  49. package/page/timeline.js +197 -3
  50. package/page/top-bar-view.js +18 -13
  51. package/page/tracks.js +37 -8
  52. package/page/ui-state.js +10 -5
  53. package/page/views.js +39 -9
  54. package/reference/functions.md +1 -0
  55. package/reference/midi.md +2 -0
  56. package/reference/structure.md +131 -0
  57. package/reference/transitions.md +13 -1
@@ -67,7 +67,7 @@ export const CYCLIST_FIELDS = Object.freeze([
67
67
  // at: MIDI follows the clock, with no offset toward the browser's own sounds.
68
68
  const STRUDEL_LATENCY_MS = 34;
69
69
  // An output-timestamp pair is fresh while its performance time is this recent (measured in the spike).
70
- const FRESH_PAIR_MAX_AGE_MS = 100;
70
+ export const FRESH_PAIR_MAX_AGE_MS = 100;
71
71
  // A pair whose offset differs from the alignment by more than this is an outlier; a run of this many outliers
72
72
  // resets the alignment (measured in the spike).
73
73
  const OUTLIER_MS = 20;
@@ -148,6 +148,9 @@ function defaultOnError(text, err) {
148
148
  * - onState(port, state): called when a port's state changes.
149
149
  * - onError(text, err).
150
150
  * - alignment: { ms }, default the page-wide one.
151
+ * - rampSeconds(fromCycle, toCycle): while a tempo ramp runs, `{ seconds, lagSeconds }`: the seconds of music between two cycles
152
+ * through the ramp's steps and how far that can be off when the tempo trails the plan by a step; null when no ramp runs
153
+ * (default: always null). Only stampOfCycle uses it: a line far enough ahead to lie behind several tempo steps.
151
154
  * Returns { want, stop, stateOf, holdsNotes, stamp, stampOfCycle, stats }.
152
155
  */
153
156
  export function createPageClock({
@@ -162,6 +165,7 @@ export function createPageClock({
162
165
  onState = () => {},
163
166
  onError = defaultOnError,
164
167
  alignment = pageAlignment,
168
+ rampSeconds = () => null,
165
169
  }) {
166
170
  // port -> { entry: { port, id }, nextTick, startAt, startCycle, stopOwedFrom, holdSince, holdExpired }
167
171
  const wanted = new Map();
@@ -202,6 +206,20 @@ export function createPageClock({
202
206
  return (c - seg.c) / seg.cps + seg.s + s.latency;
203
207
  }
204
208
 
209
+ /**
210
+ * AudioContext time of the cycle line `c` a change waits for. Like audioTimeOf, except that while a tempo ramp runs the
211
+ * seconds from the start of the latest tempo segment to the line go through the ramp's own steps: one segment at the
212
+ * tempo of the moment puts a line early on a slowing ramp and late on a rising one. With `early` the time is the
213
+ * earliest the line can be heard, the ramp's lag taken off, for a setup that must land before the line.
214
+ */
215
+ function lineAudioTimeOf(s, c, early) {
216
+ let seg = segments[0];
217
+ for (const candidate of segments) if (candidate.c <= c) seg = candidate;
218
+ const through = c > seg.c ? rampSeconds(seg.c, c) : null;
219
+ const seconds = through === null ? (c - seg.c) / seg.cps : through.seconds - (early ? through.lagSeconds : 0);
220
+ return seconds + seg.s + s.latency;
221
+ }
222
+
205
223
  /**
206
224
  * performance.now() time of an AudioContext time, from getOutputTimestamp pairs lined up with Strudel's own
207
225
  * mapping by the page-wide alignment. Null when nothing can be scheduled yet (no alignment, no usable pair).
@@ -275,16 +293,17 @@ export function createPageClock({
275
293
  * a tick and a stamped note of that position use), or null when none can be given: the cycle is not a finite
276
294
  * number, the scheduler cannot be read or does not run, its numbers cannot be used, no tempo segment is known yet,
277
295
  * or stamp() has none. Reads the scheduler and notes its segment as a pass does, so it also serves while no
278
- * instrument wants the clock. Never throws.
296
+ * instrument wants the clock. While a tempo ramp runs the line is worked out through the ramp, and with `early` it is the
297
+ * earliest time the line can be heard (see lineAudioTimeOf). Never throws.
279
298
  */
280
- function stampOfCycle(cycle) {
299
+ function stampOfCycle(cycle, { early = false } = {}) {
281
300
  if (typeof cycle !== 'number' || !Number.isFinite(cycle)) return null;
282
301
  try {
283
302
  const s = readCyclist(getScheduler());
284
303
  if (!s.started || unusableReason(s) !== null) return null;
285
304
  if (s.num_ticks_since_cps_change !== 0) noteSegment(s);
286
305
  if (segments.length === 0) return null;
287
- return stamp(audioTimeOf(s, cycle));
306
+ return stamp(lineAudioTimeOf(s, cycle, early));
288
307
  } catch {
289
308
  return null;
290
309
  }
package/page/midi-out.js CHANGED
@@ -193,6 +193,8 @@ export function createMidiOut({
193
193
  getScheduler,
194
194
  getAudioContext,
195
195
  perfNow,
196
+ // (fromCycle, toCycle) => { seconds, lagSeconds } through a running tempo ramp, or null: lines far ahead are stamped through it.
197
+ rampSeconds = () => null,
196
198
  onClockState = () => {},
197
199
  resetSettleMs = RESET_SETTLE_MS,
198
200
  now = () => Date.now(),
@@ -721,6 +723,16 @@ export function createMidiOut({
721
723
  });
722
724
  }
723
725
 
726
+ // The timestamp the port got for each note (the hap), so a light can blink when the note is heard. The page clock is
727
+ // not asked a second time: it keeps state.
728
+ const noteStamps = new WeakMap();
729
+
730
+ /** The timestamp the port got for this note, or null when it went out without one. */
731
+ function noteStampOf(hap) {
732
+ if (hap === null || typeof hap !== 'object') return null;
733
+ return noteStamps.get(hap) ?? null;
734
+ }
735
+
724
736
  /**
725
737
  * Wraps one trigger function of Strudel's `.midi()`. With a timestamp from the page clock for the hap's target
726
738
  * time (its 4th argument), the trigger runs with its target moved so that its sends fire NOTE_AHEAD_MS before
@@ -752,6 +764,7 @@ export function createMidiOut({
752
764
  }
753
765
  // A note whose time has passed goes out at once and keeps its length.
754
766
  const ts = Math.max(stamp, now);
767
+ if (args[0] !== null && typeof args[0] === 'object') noteStamps.set(args[0], ts);
755
768
  const shifted = getAudioContext().currentTime + Math.max(0, ts - now - NOTE_AHEAD_MS) / 1000;
756
769
  const proxies = webMidi.outputs.map((output) => outputProxy(output, ts, now));
757
770
  // The real webmidi keeps `outputs` as a getter on its prototype; a fake may keep an own property: the own
@@ -1539,6 +1552,7 @@ export function createMidiOut({
1539
1552
  getScheduler,
1540
1553
  getAudioContext,
1541
1554
  perfNow,
1555
+ rampSeconds,
1542
1556
  setTimer,
1543
1557
  clearTimer,
1544
1558
  isPlaying,
@@ -1888,7 +1902,7 @@ export function createMidiOut({
1888
1902
 
1889
1903
  /**
1890
1904
  * The timestamp for the setup of a change that waits for cycle `atCycle`: the page clock's stamp of that line minus
1891
- * SETUP_BEFORE_LINE_MS. When no stamp can be given or it is more than NOTE_STAMP_MAX_AHEAD_MS ahead (a far timestamp
1905
+ * SETUP_BEFORE_LINE_MS (while a tempo ramp runs, the earliest the line can be heard: the setup must land before it). When no stamp can be given or it is more than NOTE_STAMP_MAX_AHEAD_MS ahead (a far timestamp
1892
1906
  * would park bytes in the OS queue, which cannot take them back), says so in one Moontide: line, waits until the
1893
1907
  * line through the injected timer and resolves undefined: the setup then goes out without a timestamp. `track` gets
1894
1908
  * { cancel } while that wait runs, so a dropped plan ends it. Never rejects.
@@ -1896,7 +1910,7 @@ export function createMidiOut({
1896
1910
  async function timedSetupAt(atCycle, track) {
1897
1911
  let line = null;
1898
1912
  try {
1899
- line = pageClock === null ? null : pageClock.stampOfCycle(atCycle);
1913
+ line = pageClock === null ? null : pageClock.stampOfCycle(atCycle, { early: true });
1900
1914
  } catch (err) {
1901
1915
  onError('Moontide: the page clock could not give the timestamp of the line:', err);
1902
1916
  }
@@ -2608,5 +2622,6 @@ export function createMidiOut({
2608
2622
  resync,
2609
2623
  setActive,
2610
2624
  installNoteStamps,
2625
+ noteStampOf,
2611
2626
  };
2612
2627
  }
@@ -0,0 +1,114 @@
1
+ // Pure module for the Mixer view: the groups of a song as the Mixer draws them. It joins the groups that
2
+ // page/structure.js read (names of tracks, nested to any depth) to the strips of the Mixer model, and returns the tree
3
+ // of frames: each group with its fold state, its nesting level and the strips below it, then the strips no group names.
4
+ // It has no DOM access at import time, no copy and no state, and it walks the tree with an explicit stack (never by
5
+ // recursion), so a very deep structure cannot overflow the stack. node:test and page/mixer-model.js import it without a
6
+ // build step; page/mixer-view.js draws what the model builds from it.
7
+
8
+ import { SONG_GRID_DEEP_FROM, groupKey } from './song-grid-model.js';
9
+
10
+ /**
11
+ * The layout of the Mixer for a read song, or null when the Mixer stays flat (the song is not read, or names no
12
+ * group). `reading` is the result of readStructure, `strips` the strips of the Mixer model (each with `identity` and
13
+ * `index`, the index readStructure gives its track) and `folds` a Set of the keys of folded groups. Returns
14
+ * `{ items, groups }`:
15
+ * - `items`: the top level in order, the groups in the song's order and then `{ kind: 'strip', identity }` for every
16
+ * strip no group names, in the order of `strips`;
17
+ * - a group item is `{ kind: 'group', key, path, name, level, deep, open, members, items }`: its key (see groupKey),
18
+ * the names from the root, its level (1 at the top), `deep` (from level 5 on), `open` (its own fold state alone: a
19
+ * group inside a folded group is open but hidden with its parent's body), `members` (the identity of every strip below
20
+ * at any depth, in display order) and its own `items` in the order written (group items and strip items);
21
+ * - `groups`: every group item in display order, the same objects as in the tree.
22
+ * A track a group names that has no strip adds nothing, and a group with no strip is still a group.
23
+ */
24
+ export function mixerLayout({ reading, strips, folds }) {
25
+ if (reading === null || typeof reading !== 'object' || reading.state !== 'read') return null;
26
+ if (!Array.isArray(reading.groups) || reading.groups.length === 0) return null;
27
+
28
+ // Keys of these maps are names and indexes from the code, so they stay in Map and Set, never in object properties.
29
+ const indexOfTrack = new Map();
30
+ for (const track of reading.tracks) indexOfTrack.set(track.name, track.index);
31
+ const stripOfIndex = new Map();
32
+ for (const strip of strips) stripOfIndex.set(strip.index, strip.identity);
33
+
34
+ const groups = [];
35
+ const named = new Set();
36
+ const items = [];
37
+ const stack = [{ source: reading.groups.map((group) => ({ group })), index: 0, path: [], container: items }];
38
+ // The group items open on the way down, root first: each strip found below them is one of their members.
39
+ const open = [];
40
+ while (stack.length > 0) {
41
+ const frame = stack[stack.length - 1];
42
+ if (frame.index >= frame.source.length) {
43
+ stack.pop();
44
+ if (stack.length > 0) open.pop();
45
+ continue;
46
+ }
47
+ const entry = frame.source[frame.index];
48
+ frame.index += 1;
49
+ if (entry.track !== undefined) {
50
+ const index = indexOfTrack.get(entry.track);
51
+ if (index === undefined) continue;
52
+ named.add(index);
53
+ const identity = stripOfIndex.get(index);
54
+ if (identity === undefined) continue;
55
+ frame.container.push({ kind: 'strip', identity });
56
+ for (const group of open) group.members.push(identity);
57
+ continue;
58
+ }
59
+ const path = [...frame.path, entry.group.name];
60
+ const key = groupKey(path);
61
+ const level = path.length;
62
+ const item = {
63
+ kind: 'group',
64
+ key,
65
+ path,
66
+ name: entry.group.name,
67
+ level,
68
+ deep: level >= SONG_GRID_DEEP_FROM,
69
+ open: !folds.has(key),
70
+ members: [],
71
+ items: [],
72
+ };
73
+ frame.container.push(item);
74
+ groups.push(item);
75
+ open.push(item);
76
+ stack.push({ source: entry.group.items, index: 0, path, container: item.items });
77
+ }
78
+ for (const strip of strips) {
79
+ if (!named.has(strip.index)) items.push({ kind: 'strip', identity: strip.identity });
80
+ }
81
+ return { items, groups };
82
+ }
83
+
84
+ /**
85
+ * What a click on a group's mute and solo buttons does, from the strips below the group. `group` is a group item of
86
+ * mixerLayout (its `members` are the identities of the strips below it at any depth, in display order) and
87
+ * `stripsByIdentity` a Map from identity to the strip of the Mixer model. Returns `{ mute, solo, skipped }`.
88
+ * For each kind, `mute` and `solo` hold:
89
+ * - `identities`: the strips that kind can reach, in display order: the strip is editable and its own button for that
90
+ * kind is not off. Only these are counted and written;
91
+ * - `n` and `k`: how many are reachable and how many of them are pressed (muted, or soloed);
92
+ * - `pressed`: false for none (or nothing reachable), true for all, 'mixed' for some;
93
+ * - `on`: what a click writes, true unless every reachable track is already pressed.
94
+ * `skipped` counts the members that mute or solo cannot reach, each once. The module holds no copy and no state.
95
+ */
96
+ export function groupActions(group, stripsByIdentity) {
97
+ const strips = group.members.map((identity) => stripsByIdentity.get(identity)).filter((strip) => strip !== undefined);
98
+ const action = (kind) => {
99
+ const reachable = strips.filter((strip) => strip.editable === true && strip[kind].disabled === false);
100
+ const n = reachable.length;
101
+ const k = reachable.filter((strip) => strip[kind].pressed === true).length;
102
+ return {
103
+ identities: reachable.map((strip) => strip.identity),
104
+ n,
105
+ k,
106
+ pressed: n === 0 || k === 0 ? false : k === n ? true : 'mixed',
107
+ on: !(k === n && n > 0),
108
+ };
109
+ };
110
+ const mute = action('mute');
111
+ const solo = action('solo');
112
+ const reachedByBoth = new Set(mute.identities.filter((identity) => solo.identities.includes(identity)));
113
+ return { mute, solo, skipped: strips.filter((strip) => !reachedByBoth.has(strip.identity)).length };
114
+ }
@@ -6,7 +6,9 @@
6
6
  import { readProgram } from './mixer-rewrite.js';
7
7
  import { controlsByCc, controlsText, defaultOf, faderControl, midiTag, readMidiTracks, sweepKey } from './midi-controls.js';
8
8
  import { fill } from './midi-plan.js';
9
- import { parseLabel } from './tracks.js';
9
+ import { groupActions, mixerLayout } from './mixer-groups.js';
10
+ import { SONG_COPY, countText, displayName, quoted } from './structure.js';
11
+ import { labelKey, parseLabel } from './tracks.js';
10
12
  import { BUSES, BUS_IDS, DEFAULT_POSTGAIN, EFFECTS, EFFECT_IDS, MOVE_SHAPES, PARAM_STEPS, SENDS, busParamById, effectById, fixedValueOf, formatDisplayNumber, formatNumber, gainToPosition, normalizeStoredCall, valueToPosition } from './mixer-registry.js';
11
13
 
12
14
  /** Page-visible copy for the Mixer view. `notSaved` is shown
@@ -32,7 +34,9 @@ export const MIXER_COPY = {
32
34
  mute: 'Mute',
33
35
  solo: 'Solo',
34
36
  soloByName: 'Starts with a capital S — Strudel plays this track alone.',
35
- soloNeedsLabel: 'Solo needs a label — ask Claude to name this track.',
37
+ soloNeedsLabel: 'Solo and the light need a label — ask Claude to name this track.',
38
+ lightTrackTitle: 'Blinks when {name} plays a note',
39
+ lightGroupTitle: 'Blinks when any track of {group} plays a note',
36
40
  addEffect: 'Add effect',
37
41
  off: 'off',
38
42
  moving: 'moving',
@@ -86,6 +90,26 @@ export const MIXER_COPY = {
86
90
  },
87
91
  // A strip built from shared code (a declared constant or function) shows this muted line; the mixer reads only the track's own chain.
88
92
  sharedRoot: 'Built from shared code',
93
+ // The accessible name of a group's frame; the group name is cut to 40 code points like every name in a sentence.
94
+ groupLabel: 'Group {group}',
95
+ // Mute and solo on a group: the accessible names, the hover texts, the count line and the notes. {tracks} is '1 track' or
96
+ // '{count} tracks'; {k} of {n} counts the reachable tracks that are muted (or soloed), {j} the soloed ones in the both-partial line.
97
+ groupMuteName: 'Mute {group}',
98
+ groupMuteNamePartial: 'Mute {group}, {k} of {n} tracks muted',
99
+ groupSoloName: 'Solo {group}',
100
+ groupSoloNamePartial: 'Solo {group}, {k} of {n} tracks soloed',
101
+ groupMuteTitle: 'Mute {tracks}',
102
+ groupUnmuteTitle: 'Unmute {tracks}',
103
+ groupMutePartialTitle: '{k} of {n} tracks muted. A click mutes all {n}.',
104
+ groupSoloTitle: 'Solo {tracks}',
105
+ groupEndSoloTitle: 'End the solo of {tracks}',
106
+ groupSoloPartialTitle: '{k} of {n} tracks soloed. A click solos all {n}.',
107
+ groupCountMuted: '{k} of {n} muted',
108
+ groupCountSolo: '{k} of {n} solo',
109
+ groupCountBoth: '{k} of {n} muted, {j} of {n} solo',
110
+ groupSkipOne: "Skips 1 track the mixer can't change.",
111
+ groupSkipMany: "Skips {n} tracks the mixer can't change.",
112
+ groupEmpty: 'No track of this group is in the code.',
89
113
  sharedRootTitle: 'The mixer shows and changes only what this track writes after the shared code.',
90
114
  // The fader of a MIDI track sets the instrument's own volume (CC7), written in the .midi() options.
91
115
  midiFaderLine: 'instrument volume',
@@ -171,7 +195,7 @@ export function clipMixerLabel(text) {
171
195
  * 'add-effect' | 'remove' | 'remove-effect' | 'bypass' | 'restore', name, effect }` (`effect` an effect id, written by
172
196
  * its plain name lower-cased), `{ kind: 'param', name, effect, param, value }` (`param` a parameter
173
197
  * id of the effect, written by its plain name lower-cased) or `{ kind: 'param-default', name, effect,
174
- * param }` or `{ kind: 'move', name, effect, from, to, over }` (a filter move) or `{ kind: 'send', name, bus, value }`
198
+ * param }` or `{ kind: 'group-mute' | 'group-solo', group, on }` (a group's mute or solo, named by the group alone) or `{ kind: 'move', name, effect, from, to, over }` (a filter move) or `{ kind: 'send', name, bus, value }`
175
199
  * (`bus` 'reverb' or 'delay') or `{ kind: 'bus-param', bus, param, value }` (a bus is one thing: no track name). An
176
200
  * unknown kind throws (a loud error), so a new kind cannot forget its label. A MIDI control is
177
201
  * `{ kind: 'midi-control', name, control, value }` or `{ kind: 'midi-control-default', name, control }` (`control` the
@@ -187,6 +211,12 @@ export function mixerHistoryLabel(action) {
187
211
  if (action && action.kind === 'solo') {
188
212
  return `mixer: ${action.name} solo ${action.on ? 'on' : 'off'}`;
189
213
  }
214
+ if (action && action.kind === 'group-mute') {
215
+ return `mixer: group ${displayName(action.group)} ${action.on ? 'muted' : 'unmuted'}`;
216
+ }
217
+ if (action && action.kind === 'group-solo') {
218
+ return `mixer: group ${displayName(action.group)} solo ${action.on ? 'on' : 'off'}`;
219
+ }
190
220
  if (action && action.kind === 'add-effect') {
191
221
  return `mixer: ${action.name} added ${effectById(action.effect).name.toLowerCase()}`;
192
222
  }
@@ -664,6 +694,11 @@ function sendRowsFor(strip, memory) {
664
694
  * all three are false or null for any other strip). A browser strip whose track plays MIDI without a MIDI strip (its
665
695
  * `.midi()` is in shared code or off its own chain) has `playsMidi` (true, else false) and its own reason line: no add list,
666
696
  * no sends, no place on a bus and no editing of its effects, because MIDI output never plays them; its gain, mute and solo still work.
697
+ * Every strip also has `lightKey`, the key the notes of its track are reported under (see lightKeysOf), or null for a track without
698
+ * a label, which has no light. Every strip also has `index`, its track's index in the code (the index the song reading gives the same track). `song` (the
699
+ * result of readStructure for this code; default `{ state: 'none' }`) and `folds` (a Set of the keys of folded groups; default
700
+ * empty) give a ready model its `layout`: null when the song is not read or names no group, else mixerLayout's tree with the
701
+ * words of each group (see groupLayout).
667
702
  */
668
703
  export function buildMixerModel(input) {
669
704
  try {
@@ -744,12 +779,12 @@ function midiChipFor(state, trackName) {
744
779
  }
745
780
 
746
781
  /**
747
- * The group of every MIDI track whose port and channel the page can read, `Map<identity, member[]>`: the tracks of
748
- * the same read that play the same port on the same channel (the port stands in for the instrument, so a file's
782
+ * The channel mates of every MIDI track whose port and channel the page can read, `Map<identity, mate[]>`: the tracks
783
+ * of the same read that play the same port on the same channel (the port stands in for the instrument, so a file's
749
784
  * instrument and its port are the same key), in code order, the track itself and muted ones included. A track whose
750
- * channel or port cannot be read is in no group (its channel is unknown), so it is alone.
785
+ * channel or port cannot be read has no channel mates (its channel is unknown), so it is alone.
751
786
  */
752
- function midiGroups(midiTracks) {
787
+ function channelMates(midiTracks) {
753
788
  const byKey = new Map();
754
789
  for (const track of midiTracks.values()) {
755
790
  const { port, channel } = track.spine;
@@ -758,16 +793,16 @@ function midiGroups(midiTracks) {
758
793
  if (!byKey.has(key)) byKey.set(key, []);
759
794
  byKey.get(key).push(track);
760
795
  }
761
- const groups = new Map();
762
- for (const members of byKey.values()) for (const member of members) groups.set(member.identity, members);
763
- return groups;
796
+ const mates = new Map();
797
+ for (const members of byKey.values()) for (const member of members) mates.set(member.identity, members);
798
+ return mates;
764
799
  }
765
800
 
766
801
  /**
767
- * The controls written on any member of a group, `Map<id, number>`: the first member in code order that writes a
802
+ * The controls written on any of the tracks on one port and channel, `Map<id, number>`: the first one in code order that writes a
768
803
  * control gives its value (the evaluation refuses two different values of one control on one channel, so they agree).
769
804
  */
770
- function groupControls(members) {
805
+ function channelControls(members) {
771
806
  const written = new Map();
772
807
  for (const member of members) {
773
808
  for (const [id, value] of member.spine.controls) if (!written.has(id)) written.set(id, value);
@@ -775,9 +810,15 @@ function groupControls(members) {
775
810
  return written;
776
811
  }
777
812
 
778
- /** The shared-code facts of a strip: `sharedRoot` from the read, and the muted line with its hover (null when the track is not built from shared code). */
779
- function sharedRootFacts(read) {
780
- const sharedRoot = read.ok && read.sharedRoot === true;
813
+ /**
814
+ * The shared-code facts of a strip: `sharedRoot` from the read, and the muted line with its hover (null when the track is not
815
+ * built from shared code). `canonicalPlay` is true when the piece's play() is the song helper the page knows (the song
816
+ * reading says its helper is the canonical text).
817
+ */
818
+ function sharedRootFacts(read, canonicalPlay) {
819
+ // A track written with play(...) starts from the song's own helper, so it shows no shared-code line. When the helper was
820
+ // changed, or play is some other function, that line is the warning the user needs and stays.
821
+ const sharedRoot = read.ok && read.sharedRoot === true && !(read.playRoot === true && canonicalPlay);
781
822
  return {
782
823
  sharedRoot,
783
824
  sharedRootText: sharedRoot ? MIXER_COPY.sharedRoot : null,
@@ -792,20 +833,20 @@ function sharedRootFacts(read) {
792
833
  * chips (every declared control except the volume, in model order; none while the controls are off) and
793
834
  * `midi.controls` the states they and the inspector rows are drawn from.
794
835
  */
795
- function midiStrip(track, read, midiTrack, colorOf, sweepsRead, members) {
836
+ function midiStrip(track, read, midiTrack, colorOf, sweepsRead, members, canonicalPlay) {
796
837
  const instrument = midiTrack.instrument;
797
838
  const control = instrument === null ? null : faderControl(instrument);
798
- // A member whose controls cannot be written (a rewrite writes every member or nothing) turns the whole group off with its reason.
799
- const groupReason = members.map((member) => member.reason).find((candidate) => candidate !== null) ?? null;
800
- const ownReason = midiTrack.reason ?? groupReason;
839
+ // A channel mate whose controls cannot be written (a rewrite writes every track on the channel or none) turns all of them off with its reason.
840
+ const channelReason = members.map((member) => member.reason).find((candidate) => candidate !== null) ?? null;
841
+ const ownReason = midiTrack.reason ?? channelReason;
801
842
  // A valid file whose model has no volume control: the strip says so, and the fader is off.
802
843
  const reason = ownReason === null && instrument !== null && control === null ? 'noVolume' : ownReason;
803
844
  // The other controls work for every reason but the ones that hide the instrument; a model with no volume still has them.
804
845
  const controlsOk = instrument !== null && (reason === null || reason === 'noVolume');
805
846
  const name = track.displayName ?? track.name;
806
847
  const sweeps = midiSweepsFor(midiTrack, sweepsRead);
807
- // A channel has one value per control: what the group writes, not only what this track writes.
808
- const written = groupControls(members);
848
+ // A channel has one value per control: what all its tracks write, not only what this track writes.
849
+ const written = channelControls(members);
809
850
  const others = members.filter((member) => member !== midiTrack).map((member) => member.name);
810
851
  const sharedText = others.length === 0 ? '' : fill(MIXER_COPY.midiShared, { names: others.join(', ') });
811
852
  const states = controlsOk ? midiControlStates(instrument, written, sweeps) : [];
@@ -832,10 +873,11 @@ function midiStrip(track, read, midiTrack, colorOf, sweepsRead, members) {
832
873
  sounding: !track.muted,
833
874
  ...muteSoloModel(track),
834
875
  color: colorOf(track.identity),
876
+ index: track.index,
835
877
  editable: read.ok,
836
878
  reason: read.ok ? null : read.reason,
837
879
  reasonText: read.ok ? null : reasonTextFor(read.reason),
838
- ...sharedRootFacts(read),
880
+ ...sharedRootFacts(read, canonicalPlay),
839
881
  midi,
840
882
  busHidden: false,
841
883
  playsMidi: false,
@@ -849,7 +891,26 @@ function midiStrip(track, read, midiTrack, colorOf, sweepsRead, members) {
849
891
  };
850
892
  }
851
893
 
852
- function buildModel({ code, parse, colorOf, bypassRecords = [], busMemory = new Map(), keepBus = null, rig = { setup: 'absent' } }) {
894
+ /**
895
+ * The key each track's light listens for, `Map<identity, key | null>`: for a plain label, the key the note tag reports (the
896
+ * label without its mute underscore and solo marker, the same for `hats` and a muted copy `hats_`, where the identity of the
897
+ * second one gets a number to stay unique), `$` and the number of the statement for a `$` label (the n-th one in code order that a
898
+ * leading or trailing underscore does not silence, counted from 0, as the tag counts them), null for a track without a label,
899
+ * which Strudel never reports. A `$` statement an underscore silences never plays, so it keeps its identity, a key no note names.
900
+ */
901
+ function lightKeysOf(tracks) {
902
+ const keys = new Map();
903
+ let dollars = 0;
904
+ for (const track of tracks) {
905
+ if (track.kind !== 'label') keys.set(track.identity, null);
906
+ else if (!track.label.includes('$')) keys.set(track.identity, labelKey(track.label));
907
+ else if (track.label.startsWith('_') || track.label.endsWith('_')) keys.set(track.identity, track.identity);
908
+ else keys.set(track.identity, `$${dollars++}`);
909
+ }
910
+ return keys;
911
+ }
912
+
913
+ function buildModel({ code, parse, colorOf, bypassRecords = [], busMemory = new Map(), keepBus = null, rig = { setup: 'absent' }, song = { state: 'none' }, folds = new Set() }) {
853
914
  if (typeof code !== 'string' || code.trim() === '') return { status: 'empty', strips: [], buses: [], busMemory };
854
915
  let program;
855
916
  try {
@@ -859,18 +920,20 @@ function buildModel({ code, parse, colorOf, bypassRecords = [], busMemory = new
859
920
  }
860
921
  if (program.tracks.length === 0) return { status: 'empty', strips: [], buses: [], busMemory };
861
922
  const staleRecords = [];
923
+ // The song reading knows whether play() is the canonical helper (an unreadable song still says so); with no structure it is not.
924
+ const canonicalPlay = (song.state === 'read' || song.state === 'unreadable') && song.helper === 'canonical';
862
925
  // One read of the MIDI tracks per render; code that does not parse reads as no MIDI strips (it is unreadable above anyway).
863
926
  const midiRead = readMidiTracks(code, { parse, rig });
864
927
  const midiTracks = midiRead.ok ? midiRead.tracks : new Map();
865
928
  const sweepsRead = midiRead.ok ? { sweeps: midiRead.sweeps, sweepsUnknown: midiRead.sweepsUnknown } : { sweeps: new Map(), sweepsUnknown: new Set() };
866
- // The groups come first: a folded track still belongs to its port and channel, so a kept strip names it.
867
- const groups = midiGroups(midiTracks);
929
+ // The channel mates come first: a folded track still belongs to its port and channel, so a kept strip names it.
930
+ const mates = channelMates(midiTracks);
868
931
  const buildStrip = (track, read, midiTrack) => {
869
932
  // A track that sets its own orbit or bus values off its own chain hides its real bus: it gets no bus facts, so no bus strip counts it and its sends are off.
870
933
  const busHidden = read.ok && read.busHidden === true;
871
934
  // A browser track that plays MIDI has no MIDI strip: MIDI output never plays its effects or its sends, so none of them is offered and it feeds no bus.
872
935
  const playsMidi = read.ok && read.playsMidi === true;
873
- if (midiTrack !== undefined) return midiStrip(track, read, midiTrack, colorOf, sweepsRead, groups.get(track.identity) ?? [midiTrack]);
936
+ if (midiTrack !== undefined) return midiStrip(track, read, midiTrack, colorOf, sweepsRead, mates.get(track.identity) ?? [midiTrack], canonicalPlay);
874
937
  const own = read.ok ? bypassRecords.filter((record) => record.identity === track.identity) : [];
875
938
  // The code wins: a record whose effect is back in the code is stale and the caller drops it.
876
939
  const kept = [];
@@ -884,10 +947,11 @@ function buildModel({ code, parse, colorOf, bypassRecords = [], busMemory = new
884
947
  sounding: !track.muted,
885
948
  ...muteSoloModel(track),
886
949
  color: colorOf(track.identity),
950
+ index: track.index,
887
951
  editable: read.ok,
888
952
  reason: read.ok ? null : read.reason,
889
953
  reasonText: read.ok ? (playsMidi ? MIXER_COPY.playsMidi : null) : reasonTextFor(read.reason),
890
- ...sharedRootFacts(read),
954
+ ...sharedRootFacts(read, canonicalPlay),
891
955
  midi: null,
892
956
  playsMidi,
893
957
  fader: faderModel(read),
@@ -903,6 +967,7 @@ function buildModel({ code, parse, colorOf, bypassRecords = [], busMemory = new
903
967
  busHidden,
904
968
  };
905
969
  };
970
+ const lightKeys = lightKeysOf(program.tracks);
906
971
  const noNotes = [];
907
972
  const strips = [];
908
973
  for (const track of program.tracks) {
@@ -912,7 +977,9 @@ function buildModel({ code, parse, colorOf, bypassRecords = [], busMemory = new
912
977
  noNotes.push({ identity: track.identity, name: track.displayName ?? track.name, tagText: midiTag(midiTrack)?.text ?? null, silent: track.muted });
913
978
  continue;
914
979
  }
915
- strips.push(buildStrip(track, read, midiTrack));
980
+ const strip = buildStrip(track, read, midiTrack);
981
+ strip.lightKey = lightKeys.get(track.identity) ?? null;
982
+ strips.push(strip);
916
983
  }
917
984
  const { buses, busMemory: nextMemory } = busEntries(strips, busMemory, keepBus);
918
985
  for (const strip of strips) strip.sends = sendRowsFor(strip, nextMemory);
@@ -923,7 +990,83 @@ function buildModel({ code, parse, colorOf, bypassRecords = [], busMemory = new
923
990
  })
924
991
  .join(', ');
925
992
  const noNotesText = noNotes.length === 0 ? null : fill(MIXER_COPY.noNotes, { list });
926
- return { status: 'ready', strips, staleRecords, buses, busMemory: nextMemory, noNotes, noNotesText };
993
+ const layout = groupLayout(mixerLayout({ reading: song, strips, folds }), strips);
994
+ return { status: 'ready', strips, staleRecords, buses, busMemory: nextMemory, noNotes, noNotesText, layout };
995
+ }
996
+
997
+ /**
998
+ * The layout of mixerLayout (null stays null) with the words each group frame shows: `text` and `title` (the name cut
999
+ * to 40 code points, and the whole name), `label` (the frame's accessible name), `foldTitle` (the grid's words, the
1000
+ * name in single quotes), `count` and `countText` (the strips below the group at any depth), then what the head's mute
1001
+ * and solo buttons need (see groupControls): `mute` and `solo`, `countText` and `countTitle` (the plain count, or the
1002
+ * partial forms) and `noteText`.
1003
+ */
1004
+ function groupLayout(layout, strips) {
1005
+ if (layout === null) return null;
1006
+ const stripsByIdentity = new Map(strips.map((strip) => [strip.identity, strip]));
1007
+ for (const group of layout.groups) {
1008
+ group.text = displayName(group.name);
1009
+ group.title = group.name;
1010
+ group.label = fill(MIXER_COPY.groupLabel, { group: displayName(group.name) });
1011
+ group.foldTitle = fill(group.open ? SONG_COPY.foldTitle : SONG_COPY.openTitle, { group: quoted(group.name) });
1012
+ group.count = group.members.length;
1013
+ group.countText = countText(group.count, SONG_COPY.trackOne, SONG_COPY.trackMany);
1014
+ Object.assign(group, groupControls(group, groupActions(group, stripsByIdentity)));
1015
+ }
1016
+ return layout;
1017
+ }
1018
+
1019
+ /** `n` tracks as a sentence part: '1 track' or '3 tracks'. */
1020
+ function tracksText(n) {
1021
+ return countText(n, SONG_COPY.trackOne, SONG_COPY.trackMany);
1022
+ }
1023
+
1024
+ /**
1025
+ * The mute and solo of one group head, from groupActions: for each, `{ pressed, on, identities, disabled, name, title }`
1026
+ * (`pressed` true, false or 'mixed'; `on` what a click writes; `identities` the tracks it writes; `disabled` when no
1027
+ * track can be reached; `name` the accessible name and `title` the hover text), the group's `countText` and `countTitle`
1028
+ * (the plain count, or what is partly muted or soloed) and `noteText` (the group has no strip, else how many tracks
1029
+ * the buttons skip, else null).
1030
+ */
1031
+ function groupControls(group, actions) {
1032
+ const who = displayName(group.name);
1033
+ const button = (kind, action) => {
1034
+ const mute = kind === 'mute';
1035
+ const mixed = action.pressed === 'mixed';
1036
+ const copy = mute
1037
+ ? { name: MIXER_COPY.groupMuteName, partial: MIXER_COPY.groupMuteNamePartial, off: MIXER_COPY.groupMuteTitle, all: MIXER_COPY.groupUnmuteTitle, some: MIXER_COPY.groupMutePartialTitle, none: MIXER_COPY.mute }
1038
+ : { name: MIXER_COPY.groupSoloName, partial: MIXER_COPY.groupSoloNamePartial, off: MIXER_COPY.groupSoloTitle, all: MIXER_COPY.groupEndSoloTitle, some: MIXER_COPY.groupSoloPartialTitle, none: MIXER_COPY.solo };
1039
+ const values = { group: who, k: action.k, n: action.n, tracks: tracksText(action.n) };
1040
+ let title = copy.none;
1041
+ if (mixed) title = fill(copy.some, values);
1042
+ else if (action.n > 0) title = fill(action.pressed === true ? copy.all : copy.off, values);
1043
+ return {
1044
+ pressed: action.pressed,
1045
+ on: action.on,
1046
+ identities: action.identities,
1047
+ disabled: action.n === 0,
1048
+ name: fill(mixed ? copy.partial : copy.name, values),
1049
+ title,
1050
+ };
1051
+ };
1052
+ const mute = button('mute', actions.mute);
1053
+ const solo = button('solo', actions.solo);
1054
+ const partMuted = mute.pressed === 'mixed';
1055
+ const partSolo = solo.pressed === 'mixed';
1056
+ let countLine = group.countText;
1057
+ if (partMuted && partSolo && actions.mute.n === actions.solo.n) {
1058
+ countLine = fill(MIXER_COPY.groupCountBoth, { k: actions.mute.k, n: actions.mute.n, j: actions.solo.k });
1059
+ } else if (partMuted || partSolo) {
1060
+ // The two buttons can reach different numbers of tracks (an unlabeled track mutes but cannot solo), so each part names its own.
1061
+ const parts = [];
1062
+ if (partMuted) parts.push(fill(MIXER_COPY.groupCountMuted, { k: actions.mute.k, n: actions.mute.n }));
1063
+ if (partSolo) parts.push(fill(MIXER_COPY.groupCountSolo, { k: actions.solo.k, n: actions.solo.n }));
1064
+ countLine = parts.join(', ');
1065
+ }
1066
+ let noteText = null;
1067
+ if (group.members.length === 0) noteText = MIXER_COPY.groupEmpty;
1068
+ else if (actions.skipped > 0) noteText = fill(actions.skipped === 1 ? MIXER_COPY.groupSkipOne : MIXER_COPY.groupSkipMany, { n: actions.skipped });
1069
+ return { mute, solo, countText: countLine, countTitle: countLine, noteText };
927
1070
  }
928
1071
 
929
1072
  // --- The inspector: one effect's parameters ---