moontide-mcp 2.0.2 → 2.1.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 (46) hide show
  1. package/README.md +41 -5
  2. package/dist/ask.js +30 -8
  3. package/dist/mcp.js +60 -14
  4. package/dist/protocol.js +89 -2
  5. package/dist/reference.js +22 -0
  6. package/dist/session.js +53 -0
  7. package/package.json +1 -1
  8. package/page/ask-view.js +21 -7
  9. package/page/ask.js +292 -34
  10. package/page/change-timing.js +25 -4
  11. package/page/harmony.js +13 -0
  12. package/page/index.html +4 -3
  13. package/page/jam-model.js +11 -2
  14. package/page/jam-view.js +17 -0
  15. package/page/lights.js +278 -0
  16. package/page/main.js +1723 -140
  17. package/page/midi-clock.js +1 -1
  18. package/page/midi-out.js +12 -0
  19. package/page/mixer-groups.js +114 -0
  20. package/page/mixer-model.js +172 -29
  21. package/page/mixer-rewrite.js +30 -1
  22. package/page/mixer-view.js +282 -13
  23. package/page/note-edit.js +267 -0
  24. package/page/note-locate.js +346 -0
  25. package/page/note-panel.js +381 -0
  26. package/page/note-tag.js +126 -0
  27. package/page/page-rewrite.js +184 -0
  28. package/page/song-edit.js +230 -0
  29. package/page/song-fit.js +250 -0
  30. package/page/song-grid-model.js +491 -0
  31. package/page/song-grid-view.js +710 -0
  32. package/page/song-prefs.js +100 -0
  33. package/page/structure-check.js +216 -0
  34. package/page/structure-view.js +38 -0
  35. package/page/structure.js +760 -0
  36. package/page/style.css +879 -84
  37. package/page/takeover.js +20 -5
  38. package/page/timeline-edit-view.js +1956 -0
  39. package/page/timeline-gesture.js +279 -0
  40. package/page/timeline.js +196 -3
  41. package/page/tracks.js +37 -8
  42. package/page/ui-state.js +10 -5
  43. package/page/views.js +25 -8
  44. package/reference/functions.md +1 -0
  45. package/reference/midi.md +2 -0
  46. package/reference/structure.md +131 -0
@@ -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;
package/page/midi-out.js CHANGED
@@ -721,6 +721,16 @@ export function createMidiOut({
721
721
  });
722
722
  }
723
723
 
724
+ // The timestamp the port got for each note (the hap), so a light can blink when the note is heard. The page clock is
725
+ // not asked a second time: it keeps state.
726
+ const noteStamps = new WeakMap();
727
+
728
+ /** The timestamp the port got for this note, or null when it went out without one. */
729
+ function noteStampOf(hap) {
730
+ if (hap === null || typeof hap !== 'object') return null;
731
+ return noteStamps.get(hap) ?? null;
732
+ }
733
+
724
734
  /**
725
735
  * Wraps one trigger function of Strudel's `.midi()`. With a timestamp from the page clock for the hap's target
726
736
  * time (its 4th argument), the trigger runs with its target moved so that its sends fire NOTE_AHEAD_MS before
@@ -752,6 +762,7 @@ export function createMidiOut({
752
762
  }
753
763
  // A note whose time has passed goes out at once and keeps its length.
754
764
  const ts = Math.max(stamp, now);
765
+ if (args[0] !== null && typeof args[0] === 'object') noteStamps.set(args[0], ts);
755
766
  const shifted = getAudioContext().currentTime + Math.max(0, ts - now - NOTE_AHEAD_MS) / 1000;
756
767
  const proxies = webMidi.outputs.map((output) => outputProxy(output, ts, now));
757
768
  // The real webmidi keeps `outputs` as a getter on its prototype; a fake may keep an own property: the own
@@ -2608,5 +2619,6 @@ export function createMidiOut({
2608
2619
  resync,
2609
2620
  setActive,
2610
2621
  installNoteStamps,
2622
+ noteStampOf,
2611
2623
  };
2612
2624
  }
@@ -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 ---
@@ -721,6 +721,10 @@ function inspectTrack(code, entry, scope) {
721
721
  const sharedRoot =
722
722
  (root.type === 'Identifier' && declared.has(root.name)) ||
723
723
  (root.type === 'CallExpression' && root.callee.type === 'Identifier' && declared.has(root.callee.name));
724
+ // A track that starts with play(...) may be built by the song's own helper, whose lines are not shared code the user needs
725
+ // to hear about. The model drops the "built from shared code" line only when the song reading says that helper is the
726
+ // canonical one; this read just says the track starts with a call named play.
727
+ const playRoot = root.type === 'CallExpression' && root.callee.type === 'Identifier' && root.callee.name === 'play';
724
728
  for (const call of spine.calls) {
725
729
  if (!MIXER_CALLS.has(call.name) && effectOfMethod(call.name) === null) continue;
726
730
  const args = call.node.arguments;
@@ -731,6 +735,7 @@ function inspectTrack(code, entry, scope) {
731
735
  return {
732
736
  spine,
733
737
  sharedRoot,
738
+ playRoot,
734
739
  ...readReach(spine, entry.expression, scope),
735
740
  gain: readControl(spine.calls, 'gain'),
736
741
  postgain: readControl(spine.calls, 'postgain'),
@@ -774,7 +779,7 @@ function publicBus({ orbit, sends, busParams }) {
774
779
  /**
775
780
  * Parses `code` once and reads every track: `{ tracks, reads }`, `tracks` from extractTracks and
776
781
  * `reads` a Map from track identity to `{ ok: true, gain: { kind, value }, postgain: { kind,
777
- * value }, effects, bus, label, sharedRoot, playsMidi, innerPostgain }` (`sharedRoot` is true when the track's root is a declared name or a call of one; `playsMidi` is true when a `.midi(` call sits anywhere the track's code reaches, `innerPostgain` is true when a `.postgain(` call sits in that code off the spine; `busHidden` is true when the track writes an orbit, a send or a bus value anywhere but on its own chain (inside another call, in shared code it names, through `.set({ ... })` or a bare call), so its bus facts are not trusted; see readReach; kind 'none', 'fixed' or 'pattern'; `bus` is `{ orbit, sends, busParams }`
782
+ * value }, effects, bus, label, sharedRoot, playRoot, playsMidi, innerPostgain }` (`sharedRoot` is true when the track's root is a declared name or a call of one; `playRoot` is true when the track's own statement starts with a `play(...)` call; `playsMidi` is true when a `.midi(` call sits anywhere the track's code reaches, `innerPostgain` is true when a `.postgain(` call sits in that code off the spine; `busHidden` is true when the track writes an orbit, a send or a bus value anywhere but on its own chain (inside another call, in shared code it names, through `.set({ ... })` or a bare call), so its bus facts are not trusted; see readReach; kind 'none', 'fixed' or 'pattern'; `bus` is `{ orbit, sends, busParams }`
778
783
  * of readBusFacts without call positions (kind 'unwritten', 'fixed' or 'pattern'); `effects` is a Map from effect id
779
784
  * to `{ calls, params, written, bypassable, bypassWhy }`, see readEffects; `label` is null for a track without one,
780
785
  * else `{ raw, ...parseLabel(raw) }`) or `{ ok: false, reason }`. One bad track never throws; an
@@ -798,6 +803,7 @@ export function readProgram(code, { parse }) {
798
803
  bus: publicBus(read.bus),
799
804
  label: raw === null ? null : { raw, ...parseLabel(raw) },
800
805
  sharedRoot: read.sharedRoot,
806
+ playRoot: read.playRoot,
801
807
  playsMidi: read.playsMidi,
802
808
  innerPostgain: read.innerPostgain,
803
809
  busHidden: read.busHidden,
@@ -1561,3 +1567,26 @@ export function writeTrack(code, identity, change, { parse }) {
1561
1567
  }
1562
1568
  return { code: applyEdits(code, edits), edits };
1563
1569
  }
1570
+
1571
+ /**
1572
+ * Writes one mute or solo change on many tracks at once and returns `{ code, edits }`, like writeTrack. `change` is
1573
+ * `{ type: 'mute' | 'solo', on }` and `identities` the tracks to write, each read and written against the SAME base
1574
+ * text. All their edits are joined into one list and applied once, by position, so the result is the same whatever
1575
+ * order the tracks are listed in, no track is written twice (a repeated identity counts once) and two edits that
1576
+ * overlap throw. The first track that cannot be written throws its MixerRewriteError and nothing is returned, so a
1577
+ * refused track never leaves the others half written. A track already in the asked state adds no edit.
1578
+ */
1579
+ export function writeTracks(code, identities, change, { parse }) {
1580
+ if (!Array.isArray(identities) || identities.length === 0 || identities.some((identity) => typeof identity !== 'string')) {
1581
+ throw new MixerRewriteError('unsupported-change', 'a group change needs at least one track.');
1582
+ }
1583
+ const type = change ? change.type : undefined;
1584
+ if (type !== 'mute' && type !== 'solo') {
1585
+ throw new MixerRewriteError('unsupported-change', `a group can only be muted or soloed, not '${String(type)}'.`);
1586
+ }
1587
+ const edits = [];
1588
+ for (const identity of new Set(identities)) {
1589
+ edits.push(...writeTrack(code, identity, { identity, type, on: change.on }, { parse }).edits);
1590
+ }
1591
+ return { code: applyEdits(code, edits), edits };
1592
+ }