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.
- package/README.md +41 -5
- package/dist/ask.js +30 -8
- package/dist/mcp.js +60 -14
- package/dist/protocol.js +89 -2
- package/dist/reference.js +22 -0
- package/dist/session.js +53 -0
- package/package.json +1 -1
- package/page/ask-view.js +21 -7
- package/page/ask.js +292 -34
- package/page/change-timing.js +25 -4
- package/page/harmony.js +13 -0
- package/page/index.html +4 -3
- package/page/jam-model.js +11 -2
- package/page/jam-view.js +17 -0
- package/page/lights.js +278 -0
- package/page/main.js +1723 -140
- package/page/midi-clock.js +1 -1
- package/page/midi-out.js +12 -0
- package/page/mixer-groups.js +114 -0
- package/page/mixer-model.js +172 -29
- package/page/mixer-rewrite.js +30 -1
- package/page/mixer-view.js +282 -13
- package/page/note-edit.js +267 -0
- package/page/note-locate.js +346 -0
- package/page/note-panel.js +381 -0
- package/page/note-tag.js +126 -0
- package/page/page-rewrite.js +184 -0
- package/page/song-edit.js +230 -0
- package/page/song-fit.js +250 -0
- package/page/song-grid-model.js +491 -0
- package/page/song-grid-view.js +710 -0
- package/page/song-prefs.js +100 -0
- package/page/structure-check.js +216 -0
- package/page/structure-view.js +38 -0
- package/page/structure.js +760 -0
- package/page/style.css +879 -84
- package/page/takeover.js +20 -5
- package/page/timeline-edit-view.js +1956 -0
- package/page/timeline-gesture.js +279 -0
- package/page/timeline.js +196 -3
- package/page/tracks.js +37 -8
- package/page/ui-state.js +10 -5
- package/page/views.js +25 -8
- package/reference/functions.md +1 -0
- package/reference/midi.md +2 -0
- package/reference/structure.md +131 -0
package/page/midi-clock.js
CHANGED
|
@@ -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
|
+
}
|
package/page/mixer-model.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
785
|
+
* channel or port cannot be read has no channel mates (its channel is unknown), so it is alone.
|
|
751
786
|
*/
|
|
752
|
-
function
|
|
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
|
|
762
|
-
for (const members of byKey.values()) for (const member of members)
|
|
763
|
-
return
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
779
|
-
|
|
780
|
-
|
|
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
|
|
799
|
-
const
|
|
800
|
-
const ownReason = midiTrack.reason ??
|
|
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
|
|
808
|
-
const written =
|
|
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
|
-
|
|
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
|
|
867
|
-
const
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 ---
|
package/page/mixer-rewrite.js
CHANGED
|
@@ -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
|
+
}
|