c64bridge 1.0.5 → 1.0.7

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 (58) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +3 -3
  3. package/data/sound/sidwave.md +113 -36
  4. package/data/sound/sidwave.schema.json +138 -28
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/c64Client.js +83 -93
  7. package/dist/chargen.js +0 -4
  8. package/dist/config.js +26 -6
  9. package/dist/device.js +130 -24
  10. package/dist/diagnostics.js +14 -5
  11. package/dist/logger.js +3 -2
  12. package/dist/loggingHttpClient.js +12 -1
  13. package/dist/mcp-server.js +19 -9
  14. package/dist/petscii.js +0 -5
  15. package/dist/redact.js +166 -0
  16. package/dist/sidwave.js +453 -61
  17. package/dist/sidwaveCompiler.js +1424 -269
  18. package/dist/tools/audio.js +70 -12
  19. package/dist/tools/developer.js +17 -9
  20. package/dist/tools/errors.js +7 -0
  21. package/dist/tools/graphics.js +4 -4
  22. package/dist/tools/input.js +10 -13
  23. package/dist/tools/machineControl.js +7 -7
  24. package/dist/tools/memory.js +6 -6
  25. package/dist/tools/meta/audio.js +8 -2
  26. package/dist/tools/meta/config.js +5 -4
  27. package/dist/tools/meta/configInventory.js +25 -2
  28. package/dist/tools/meta/diagnostics.js +2 -1
  29. package/dist/tools/meta/graphics.js +1 -1
  30. package/dist/tools/meta/memory.js +4 -4
  31. package/dist/tools/meta/program.js +12 -8
  32. package/dist/tools/printer.js +4 -4
  33. package/dist/tools/programRunners.js +8 -8
  34. package/dist/tools/registry/batch.js +1 -7
  35. package/dist/tools/registry/disk.js +7 -5
  36. package/dist/tools/storage.js +21 -17
  37. package/dist/tools/streaming.js +2 -2
  38. package/dist/tools/translation/assembler.js +44 -12
  39. package/dist/tools/translation/basicTokenizer.js +5 -1
  40. package/dist/tools/translation/opcodeInventory.js +1 -1
  41. package/dist/tools/vice.js +1 -1
  42. package/dist/vice/mockServer.js +92 -5
  43. package/dist/vice/readiness.js +4 -9
  44. package/dist/vice/viceClient.js +18 -0
  45. package/doc/audit/secret-handling.md +61 -0
  46. package/mcp/schemas/c64_config.schema.json +19 -2
  47. package/mcp/schemas/c64_disk.schema.json +2 -2
  48. package/mcp/schemas/c64_input.schema.json +3 -3
  49. package/mcp/schemas/c64_program.schema.json +2 -2
  50. package/mcp/schemas/c64_vice.schema.json +3 -3
  51. package/mcp/server.json +1 -1
  52. package/mcp/tools.json +33 -12
  53. package/mcp.json +1 -1
  54. package/package.json +5 -2
  55. package/scripts/mockC64Server.mjs +15 -1
  56. package/scripts/run-coverage.mjs +31 -1
  57. package/scripts/update-readme.ts +3 -2
  58. package/server.json +20 -3
package/dist/sidwave.js CHANGED
@@ -7,79 +7,359 @@ See <https://www.gnu.org/licenses/> for details.
7
7
  */
8
8
  import pkg from 'yaml';
9
9
  const { parse: yamlParse } = pkg;
10
+ /** Raised for SIDWAVE documents that cannot be played as written. The message names the offending path. */
11
+ export class SidwaveValidationError extends Error {
12
+ constructor(path, detail) {
13
+ super(`Invalid SIDWAVE at ${path}: ${detail}`);
14
+ this.path = path;
15
+ this.name = "SidwaveValidationError";
16
+ }
17
+ }
18
+ export const SIDWAVE_TEMPO_RANGE = { min: 30, max: 220 };
19
+ /** MIDI numbers of C0 and B7, the note range of the player's frequency table. */
20
+ export const SIDWAVE_NOTE_RANGE = { min: 12, max: 107 };
21
+ const TOP_LEVEL_KEYS = new Set(["sidwave", "title", "author", "tempo", "mode", "length_bars", "global_fx", "voices", "timeline"]);
22
+ const LEGACY_KEYS = new Set(["song", "voices", "timeline"]);
23
+ const LEGACY_SONG_KEYS = new Set(["title", "author", "tempo", "mode", "length_bars", "global_fx"]);
24
+ const VOICE_KEYS = new Set(["id", "name", "waveform", "pulse_width", "adsr", "patterns"]);
25
+ const ARP_KEYS = new Set(["type", "notes", "frame_rate", "length", "transpositions", "fx"]);
26
+ const GROOVE_KEYS = new Set(["groove", "length", "transpositions", "fx"]);
27
+ const MOTIF_KEYS = new Set(["motif", "root", "length", "transpositions", "fx"]);
28
+ const SECTION_KEYS = new Set(["section", "bars", "layers"]);
29
+ const PATTERN_FX_KEYS = new Set(["vibrato", "pwm_sweep", "slide", "filter_mod"]);
30
+ const GLOBAL_FX_KEYS = new Set(["reverb_sim", "filter_env"]);
31
+ const WAVEFORMS = new Set(["pulse", "sawtooth", "saw", "triangle", "tri", "noise"]);
10
32
  export function parseSidwave(input) {
11
- const obj = typeof input === "string" ? yamlParse(input) : input;
12
- if (!obj || typeof obj !== "object") {
13
- throw new Error("Invalid SIDWAVE: not an object");
14
- }
15
- // Detect legacy CPG shape vs new SIDWAVE top-level
16
- const legacy = !!obj.song;
17
- const header = legacy
18
- ? { title: obj.song?.title, tempo: obj.song?.tempo, mode: obj.song?.mode, length_bars: obj.song?.length_bars, global_fx: obj.song?.global_fx }
19
- : { title: obj.title, author: obj.author, tempo: obj.tempo, mode: obj.mode, length_bars: obj.length_bars, global_fx: obj.global_fx };
20
- const title = header.title ?? "Untitled";
21
- const tempo = normalizeTempo(header.tempo);
22
- const mode = header.mode ?? "PAL";
23
- const length_bars = Math.max(1, Math.floor(header.length_bars ?? inferBarsFromTimeline(obj.timeline) ?? 16));
24
- const global_fx = header.global_fx ?? {};
33
+ let obj;
34
+ if (typeof input === "string") {
35
+ try {
36
+ obj = yamlParse(input);
37
+ }
38
+ catch (error) {
39
+ const message = error instanceof Error ? error.message : String(error);
40
+ const hint = /BLOCK_IN_FLOW|Block collections are not allowed within flow/.test(message)
41
+ ? " A bare '-' inside a flow map such as '{ v2: - }' is YAML list syntax; quote it as '\"-\"'."
42
+ : "";
43
+ throw new SidwaveValidationError("$", `YAML/JSON could not be parsed: ${message.split("\n")[0]}.${hint}`);
44
+ }
45
+ }
46
+ else {
47
+ obj = input;
48
+ }
49
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
50
+ throw new SidwaveValidationError("$", "expected a mapping with voices and timeline");
51
+ }
52
+ // Detect legacy CPG shape (song: {...}) vs SIDWAVE top-level fields.
53
+ const legacy = obj.song !== undefined;
54
+ if (legacy) {
55
+ checkKeys(obj, LEGACY_KEYS, "$", "legacy documents with a 'song' block may only contain song, voices and timeline");
56
+ if (!isPlainObject(obj.song))
57
+ throw new SidwaveValidationError("$.song", "expected a mapping");
58
+ checkKeys(obj.song, LEGACY_SONG_KEYS, "$.song");
59
+ }
60
+ else {
61
+ checkKeys(obj, TOP_LEVEL_KEYS, "$");
62
+ if (obj.sidwave !== undefined && !["1", "1.0"].includes(String(obj.sidwave))) {
63
+ throw new SidwaveValidationError("$.sidwave", `unsupported version ${JSON.stringify(obj.sidwave)}; use 1.0`);
64
+ }
65
+ }
66
+ const header = (legacy ? obj.song : obj);
67
+ const headerPath = legacy ? "$.song" : "$";
68
+ const title = header.title === undefined ? "Untitled" : String(header.title);
69
+ const author = header.author === undefined ? undefined : String(header.author);
70
+ const tempo = parseTempo(header.tempo, `${headerPath}.tempo`);
71
+ const mode = parseMode(header.mode, `${headerPath}.mode`);
72
+ const global_fx = parseGlobalFx(header.global_fx, `${headerPath}.global_fx`);
73
+ if (!Array.isArray(obj.voices) || obj.voices.length === 0) {
74
+ throw new SidwaveValidationError("$.voices", "missing voices (expected a list of 1 to 3 voices)");
75
+ }
76
+ if (obj.voices.length > 3) {
77
+ throw new SidwaveValidationError("$.voices", `the SID has 3 voices; ${obj.voices.length} were given`);
78
+ }
79
+ const voices = obj.voices.map((v, i) => normalizeVoice(v, `$.voices[${i}]`));
80
+ const seen = new Set();
81
+ voices.forEach((v, i) => {
82
+ if (seen.has(v.id))
83
+ throw new SidwaveValidationError(`$.voices[${i}].id`, `voice id ${v.id} is declared more than once`);
84
+ seen.add(v.id);
85
+ });
86
+ if (!Array.isArray(obj.timeline) || obj.timeline.length === 0) {
87
+ throw new SidwaveValidationError("$.timeline", "missing timeline (expected a list of sections)");
88
+ }
89
+ const timeline = obj.timeline.map((t, i) => normalizeTimelineSection(t, `$.timeline[${i}]`, voices));
90
+ const timelineBars = timeline.reduce((sum, s) => sum + s.bars, 0);
91
+ let length_bars = timelineBars;
92
+ if (header.length_bars !== undefined) {
93
+ length_bars = requireNumber(header.length_bars, `${headerPath}.length_bars`, { min: 1, integer: true });
94
+ }
25
95
  const song = { title, tempo, mode, length_bars, global_fx };
26
- const voices = (legacy ? obj.voices : obj.voices) ?? [];
27
- if (!Array.isArray(voices) || voices.length === 0)
28
- throw new Error("Invalid SIDWAVE: missing voices");
29
- const normalizedVoices = voices.map((v) => normalizeVoice(v));
30
- const timeline = ((legacy ? obj.timeline : obj.timeline) ?? []).map((t) => normalizeTimelineSection(t));
31
- if (!Array.isArray(timeline) || timeline.length === 0)
32
- throw new Error("Invalid SIDWAVE: missing timeline");
33
- return { song, voices: normalizedVoices, timeline };
34
- }
35
- function inferBarsFromTimeline(t) {
36
- if (!Array.isArray(t) || t.length === 0)
37
- return undefined;
38
- return t.reduce((sum, s) => sum + Math.max(1, Math.floor(s.bars ?? 0)), 0);
96
+ if (author !== undefined)
97
+ song.author = author;
98
+ return { song, voices, timeline };
39
99
  }
40
- function normalizeTempo(tempo) {
41
- if (!Number.isFinite(tempo))
100
+ function parseTempo(value, path) {
101
+ if (value === undefined)
42
102
  return 100;
43
- const t = Math.max(30, Math.min(220, Math.floor(tempo)));
44
- return t;
45
- }
46
- function normalizeVoice(v) {
47
- const waveform = normalizeWaveform(v.waveform ?? "pulse");
48
- let pulseWidth = v.pulse_width;
49
- if (typeof pulseWidth === "number") {
50
- if (pulseWidth > 0 && pulseWidth <= 1) {
51
- pulseWidth = Math.round(pulseWidth * 4095);
52
- }
53
- pulseWidth = Math.max(0, Math.min(4095, Math.floor(pulseWidth)));
54
- }
55
- const adsr = (v.adsr ?? [2, 2, 10, 3]).map((n) => Math.max(0, Math.min(15, Math.floor(n))));
56
- return {
57
- id: v.id,
58
- name: v.name ?? `Voice${v.id}`,
59
- waveform,
60
- pulse_width: pulseWidth,
61
- adsr,
62
- patterns: v.patterns ?? {},
63
- };
103
+ return requireNumber(value, path, { min: SIDWAVE_TEMPO_RANGE.min, max: SIDWAVE_TEMPO_RANGE.max });
104
+ }
105
+ function parseMode(value, path) {
106
+ if (value === undefined)
107
+ return "PAL";
108
+ const m = String(value).toUpperCase();
109
+ if (m !== "PAL" && m !== "NTSC")
110
+ throw new SidwaveValidationError(path, `expected PAL or NTSC, got ${JSON.stringify(value)}`);
111
+ return m;
112
+ }
113
+ function normalizeVoice(raw, path) {
114
+ if (!isPlainObject(raw))
115
+ throw new SidwaveValidationError(path, "expected a voice mapping");
116
+ const v = raw;
117
+ checkKeys(v, VOICE_KEYS, path);
118
+ const id = requireNumber(v.id, `${path}.id`, { min: 1, max: 3, integer: true });
119
+ const wfRaw = v.waveform === undefined ? "pulse" : String(v.waveform).toLowerCase();
120
+ if (!WAVEFORMS.has(wfRaw)) {
121
+ throw new SidwaveValidationError(`${path}.waveform`, `unknown waveform ${JSON.stringify(v.waveform)}; use pulse, sawtooth, triangle or noise`);
122
+ }
123
+ const waveform = normalizeWaveform(wfRaw);
124
+ let pulseWidth;
125
+ if (v.pulse_width !== undefined) {
126
+ const pw = requireNumber(v.pulse_width, `${path}.pulse_width`, { min: 0, max: 4095 });
127
+ pulseWidth = pw > 0 && pw <= 1 ? Math.round(pw * 4095) : Math.floor(pw);
128
+ if (pulseWidth <= 0 || pulseWidth >= 4095) {
129
+ throw new SidwaveValidationError(`${path}.pulse_width`, "a pulse width of 0% or 100% is silent; use a fraction between 0 and 1 or 1..4094");
130
+ }
131
+ }
132
+ let adsr = [2, 2, 10, 3];
133
+ if (v.adsr !== undefined) {
134
+ if (!Array.isArray(v.adsr) || v.adsr.length !== 4) {
135
+ throw new SidwaveValidationError(`${path}.adsr`, "expected [attack, decay, sustain, release] with values 0..15");
136
+ }
137
+ adsr = v.adsr.map((n, i) => requireNumber(n, `${path}.adsr[${i}]`, { min: 0, max: 15, integer: true }));
138
+ }
139
+ const patterns = {};
140
+ if (v.patterns !== undefined) {
141
+ if (!isPlainObject(v.patterns))
142
+ throw new SidwaveValidationError(`${path}.patterns`, "expected a mapping of pattern names to patterns");
143
+ for (const [name, p] of Object.entries(v.patterns)) {
144
+ patterns[name] = validatePattern(p, `${path}.patterns.${name}`);
145
+ }
146
+ }
147
+ return { id, name: v.name === undefined ? `Voice${id}` : String(v.name), waveform, pulse_width: pulseWidth, adsr, patterns };
148
+ }
149
+ function validatePattern(raw, path) {
150
+ if (!isPlainObject(raw))
151
+ throw new SidwaveValidationError(path, "expected a pattern mapping (groove, motif or type: arpeggio)");
152
+ const p = raw;
153
+ if (p.length !== undefined)
154
+ requireNumber(p.length, `${path}.length`, { min: 1 / 64, max: 64 });
155
+ if (p.transpositions !== undefined) {
156
+ if (!Array.isArray(p.transpositions) || p.transpositions.length === 0) {
157
+ throw new SidwaveValidationError(`${path}.transpositions`, "expected a non-empty list of semitone offsets");
158
+ }
159
+ p.transpositions.forEach((t, i) => requireNumber(t, `${path}.transpositions[${i}]`, { min: -48, max: 48, integer: true }));
160
+ }
161
+ if (p.fx !== undefined)
162
+ validatePatternFx(p.fx, `${path}.fx`);
163
+ if (p.type !== undefined || p.notes !== undefined) {
164
+ if (p.type !== undefined && p.type !== "arpeggio") {
165
+ throw new SidwaveValidationError(`${path}.type`, `unknown pattern type ${JSON.stringify(p.type)}; only 'arpeggio' is supported (a pattern with 'notes' is an arpeggio)`);
166
+ }
167
+ checkKeys(p, ARP_KEYS, path);
168
+ if (!Array.isArray(p.notes) || p.notes.length === 0)
169
+ throw new SidwaveValidationError(`${path}.notes`, "expected a non-empty list of note names");
170
+ p.notes.forEach((n, i) => requireNote(n, `${path}.notes[${i}]`));
171
+ if (p.frame_rate !== undefined)
172
+ requireNumber(p.frame_rate, `${path}.frame_rate`, { min: 1, max: 50, integer: true });
173
+ return p;
174
+ }
175
+ if (p.groove !== undefined) {
176
+ checkKeys(p, GROOVE_KEYS, path);
177
+ if (!Array.isArray(p.groove) || p.groove.length === 0)
178
+ throw new SidwaveValidationError(`${path}.groove`, "expected a non-empty list of notes, '-' rests or '~' ties");
179
+ p.groove.forEach((n, i) => {
180
+ const tok = String(n).trim();
181
+ if (!isRestToken(tok) && !isTieToken(tok))
182
+ requireNote(n, `${path}.groove[${i}]`);
183
+ });
184
+ return p;
185
+ }
186
+ if (p.motif !== undefined) {
187
+ checkKeys(p, MOTIF_KEYS, path);
188
+ if (p.root !== undefined)
189
+ requireNote(p.root, `${path}.root`);
190
+ parseMotif(String(p.motif), p.root === undefined ? "C3" : String(p.root), `${path}.motif`);
191
+ return p;
192
+ }
193
+ throw new SidwaveValidationError(path, "a pattern needs 'groove', 'motif' or 'type: arpeggio' with 'notes'");
194
+ }
195
+ function validatePatternFx(raw, path) {
196
+ if (!isPlainObject(raw))
197
+ throw new SidwaveValidationError(path, "expected a mapping");
198
+ const fx = raw;
199
+ checkKeys(fx, PATTERN_FX_KEYS, path);
200
+ if (fx.vibrato !== undefined) {
201
+ const v = requireObject(fx.vibrato, `${path}.vibrato`, ["depth", "rate", "delay"]);
202
+ requireNumber(v.depth, `${path}.vibrato.depth`, { min: 0.03, max: 6 });
203
+ // At about 50 player calls per second, faster rates sample too few points per cycle (25 Hz lands on zero crossings).
204
+ requireNumber(v.rate, `${path}.vibrato.rate`, { min: 0.2, max: 12 });
205
+ if (v.delay !== undefined)
206
+ requireNumber(v.delay, `${path}.vibrato.delay`, { min: 0, max: 255, integer: true });
207
+ }
208
+ if (fx.pwm_sweep !== undefined) {
209
+ const v = requireObject(fx.pwm_sweep, `${path}.pwm_sweep`, ["depth", "speed"]);
210
+ requireNumber(v.depth, `${path}.pwm_sweep.depth`, { min: 0, max: 2047, exclusiveMin: true });
211
+ requireNumber(v.speed, `${path}.pwm_sweep.speed`, { min: 0, max: 25, exclusiveMin: true });
212
+ }
213
+ if (fx.slide !== undefined) {
214
+ const v = requireObject(fx.slide, `${path}.slide`, ["depth", "speed"]);
215
+ const d = requireNumber(v.depth, `${path}.slide.depth`, { min: -24, max: 24, integer: true });
216
+ if (d === 0)
217
+ throw new SidwaveValidationError(`${path}.slide.depth`, "must not be 0");
218
+ requireNumber(v.speed, `${path}.slide.speed`, { min: 1, max: 255, integer: true });
219
+ }
220
+ if (fx.filter_mod !== undefined)
221
+ parseFilterMod(fx.filter_mod, `${path}.filter_mod`);
222
+ }
223
+ /** Parses `{ sweep: "+500 over 8 steps" }` or `{ sweep: 500, steps: 8 }`. */
224
+ export function parseFilterMod(raw, path) {
225
+ const v = requireObject(raw, path, ["sweep", "steps", "beats", "bars", "frames"]);
226
+ if (typeof v.sweep === "string") {
227
+ const m = /^\s*([+-]?\d+(?:\.\d+)?)\s*(?:over\s+(\d+(?:\.\d+)?)\s*(steps?|beats?|bars?|frames?))?\s*$/i.exec(v.sweep);
228
+ if (!m)
229
+ throw new SidwaveValidationError(`${path}.sweep`, `expected e.g. "+500 over 8 steps", got ${JSON.stringify(v.sweep)}`);
230
+ const sweep = Number(m[1]);
231
+ const amount = m[2] === undefined ? 1 : Number(m[2]);
232
+ const unit = (m[3] ? m[3].toLowerCase().replace(/s$/, "") + "s" : "steps");
233
+ return checkFilterMod({ sweep, amount, unit }, path);
234
+ }
235
+ const sweep = requireNumber(v.sweep, `${path}.sweep`, { min: -2047, max: 2047 });
236
+ for (const unit of ["steps", "beats", "bars", "frames"]) {
237
+ if (v[unit] !== undefined)
238
+ return checkFilterMod({ sweep, amount: requireNumber(v[unit], `${path}.${unit}`, { min: 0, exclusiveMin: true }), unit }, path);
239
+ }
240
+ return checkFilterMod({ sweep, amount: 1, unit: "steps" }, path);
241
+ }
242
+ function checkFilterMod(spec, path) {
243
+ if (Math.abs(spec.sweep) > 2047)
244
+ throw new SidwaveValidationError(`${path}.sweep`, "must be within -2047..2047 cutoff units");
245
+ if (!(spec.amount > 0))
246
+ throw new SidwaveValidationError(path, "sweep duration must be positive");
247
+ return spec;
248
+ }
249
+ function parseGlobalFx(raw, path) {
250
+ if (raw === undefined || raw === null)
251
+ return {};
252
+ if (!isPlainObject(raw))
253
+ throw new SidwaveValidationError(path, "expected a mapping");
254
+ const fx = raw;
255
+ checkKeys(fx, GLOBAL_FX_KEYS, path);
256
+ if (fx.reverb_sim !== undefined) {
257
+ const v = requireObject(fx.reverb_sim, `${path}.reverb_sim`, ["depth", "delay_frames"]);
258
+ requireNumber(v.depth, `${path}.reverb_sim.depth`, { min: 0, max: 1, exclusiveMin: true });
259
+ requireNumber(v.delay_frames, `${path}.reverb_sim.delay_frames`, { min: 1, max: 50, integer: true });
260
+ }
261
+ if (fx.filter_env !== undefined) {
262
+ const v = requireObject(fx.filter_env, `${path}.filter_env`, ["cutoff_base", "sweep", "speed", "resonance", "type", "voices"]);
263
+ requireNumber(v.cutoff_base, `${path}.filter_env.cutoff_base`, { min: 0, max: 2047 });
264
+ if (v.sweep !== undefined)
265
+ requireNumber(v.sweep, `${path}.filter_env.sweep`, { min: -2047, max: 2047 });
266
+ if (v.speed !== undefined)
267
+ requireNumber(v.speed, `${path}.filter_env.speed`, { min: 0, max: 1024, exclusiveMin: true });
268
+ if (v.resonance !== undefined)
269
+ requireNumber(v.resonance, `${path}.filter_env.resonance`, { min: 0, max: 15, integer: true });
270
+ if (v.type !== undefined && !["lowpass", "bandpass", "highpass"].includes(String(v.type).toLowerCase())) {
271
+ throw new SidwaveValidationError(`${path}.filter_env.type`, "expected lowpass, bandpass or highpass");
272
+ }
273
+ if (v.voices !== undefined) {
274
+ if (!Array.isArray(v.voices))
275
+ throw new SidwaveValidationError(`${path}.filter_env.voices`, "expected a list of voice ids");
276
+ v.voices.forEach((id, i) => requireNumber(id, `${path}.filter_env.voices[${i}]`, { min: 1, max: 3, integer: true }));
277
+ }
278
+ }
279
+ return fx;
64
280
  }
65
281
  function normalizeWaveform(w) {
66
- const w0 = (w || "pulse").toLowerCase();
67
- if (w0 === "saw")
282
+ if (w === "saw")
68
283
  return "sawtooth";
69
- if (w0 === "tri")
284
+ if (w === "tri")
70
285
  return "triangle";
71
- if (w0 === "pulse" || w0 === "sawtooth" || w0 === "triangle" || w0 === "noise")
72
- return w0;
73
- return "pulse";
286
+ return w;
74
287
  }
75
- function normalizeTimelineSection(t) {
76
- const bars = Math.max(1, Math.floor(t?.bars ?? 1));
288
+ const LAYER_KEY = /^(?:v|voice)?([1-3])$/i;
289
+ function normalizeTimelineSection(raw, path, voices) {
290
+ if (!isPlainObject(raw))
291
+ throw new SidwaveValidationError(path, "expected a section mapping with bars and layers");
292
+ const t = raw;
293
+ checkKeys(t, SECTION_KEYS, path);
294
+ const bars = requireNumber(t.bars, `${path}.bars`, { min: 1, integer: true });
77
295
  const layers = {};
78
- for (const [k, v] of Object.entries(t?.layers ?? {})) {
79
- layers[k] = String(v ?? "-");
296
+ if (t.layers !== undefined && !isPlainObject(t.layers))
297
+ throw new SidwaveValidationError(`${path}.layers`, "expected a mapping such as { v1: A, v2: \"-\" }");
298
+ for (const [k, v] of Object.entries((t.layers ?? {}))) {
299
+ const km = LAYER_KEY.exec(k);
300
+ if (!km)
301
+ throw new SidwaveValidationError(`${path}.layers.${k}`, "layer keys must be v1, v2 or v3");
302
+ const id = Number(km[1]);
303
+ const key = `v${id}`;
304
+ if (layers[key] !== undefined)
305
+ throw new SidwaveValidationError(`${path}.layers.${k}`, `voice ${id} is assigned twice`);
306
+ const ref = v === null || v === undefined ? "-" : String(v).trim();
307
+ const voice = voices.find((vv) => vv.id === id);
308
+ const parsed = parseLayerRef(ref, Object.keys(voice?.patterns ?? {}));
309
+ if (parsed.pattern !== null) {
310
+ if (!voice)
311
+ throw new SidwaveValidationError(`${path}.layers.${k}`, `voice ${id} is not declared in voices`);
312
+ if (!(parsed.pattern in (voice.patterns ?? {}))) {
313
+ const known = Object.keys(voice.patterns ?? {});
314
+ throw new SidwaveValidationError(`${path}.layers.${k}`, `pattern ${JSON.stringify(ref)} is not defined for voice ${id}${known.length ? ` (known: ${known.join(", ")})` : ""}`);
315
+ }
316
+ }
317
+ layers[key] = ref;
80
318
  }
81
- return { section: t.section ?? "", bars, layers };
319
+ return { section: t.section === undefined ? "" : String(t.section), bars, layers };
82
320
  }
321
+ /**
322
+ * Parses a timeline layer reference: a pattern name followed by optional modifiers.
323
+ * `↑`/`^` transpose up an octave, `↓` down an octave, `+N`/`-N` by N semitones, and a trailing
324
+ * `–`, `—` or `-` fades the voice out over the section. `-`, `.`, `rest` or an empty value is silence.
325
+ */
326
+ export function parseLayerRef(ref, patternNames) {
327
+ const s = ref.trim();
328
+ if (s === "" || s === "-" || s === "–" || s === "—" || s === "." || s.toLowerCase() === "rest")
329
+ return { pattern: null, transpose: 0, fade: false };
330
+ if (patternNames.includes(s))
331
+ return { pattern: s, transpose: 0, fade: false };
332
+ let rest = s;
333
+ let transpose = 0;
334
+ let fade = false;
335
+ for (;;) {
336
+ let m;
337
+ if ((m = /(↑|\^)$/.exec(rest))) {
338
+ transpose += 12;
339
+ rest = rest.slice(0, m.index);
340
+ }
341
+ else if ((m = /↓$/.exec(rest))) {
342
+ transpose -= 12;
343
+ rest = rest.slice(0, m.index);
344
+ }
345
+ else if ((m = /([+-]\d+)$/.exec(rest)) && !patternNames.includes(rest)) {
346
+ transpose += Number(m[1]);
347
+ rest = rest.slice(0, m.index);
348
+ }
349
+ else if ((m = /(–|—|-)$/.exec(rest)) && !patternNames.includes(rest)) {
350
+ fade = true;
351
+ rest = rest.slice(0, m.index);
352
+ }
353
+ else
354
+ break;
355
+ rest = rest.trimEnd();
356
+ if (patternNames.includes(rest))
357
+ break;
358
+ }
359
+ return { pattern: rest, transpose, fade };
360
+ }
361
+ // ---------------------------------------------------------------------------
362
+ // Musical tokens
83
363
  export function noteNameToMidi(note) {
84
364
  const m = /^([A-Ga-g])([#b]?)(-?\d+)$/.exec((note || "").trim());
85
365
  if (!m)
@@ -99,3 +379,115 @@ export function noteNameToMidi(note) {
99
379
  export function midiToHz(midi) {
100
380
  return 440 * Math.pow(2, (midi - 69) / 12);
101
381
  }
382
+ const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
383
+ export function midiToNoteName(midi) {
384
+ return `${NOTE_NAMES[((midi % 12) + 12) % 12]}${Math.floor(midi / 12) - 1}`;
385
+ }
386
+ export function isRestToken(token) {
387
+ return token === "-" || token === "." || token.toLowerCase() === "r" || token.toLowerCase() === "rest";
388
+ }
389
+ export function isTieToken(token) {
390
+ return token === "~" || token === "_";
391
+ }
392
+ function requireNote(raw, path) {
393
+ const midi = typeof raw === "string" ? noteNameToMidi(raw) : undefined;
394
+ if (midi === undefined)
395
+ throw new SidwaveValidationError(path, `invalid note ${JSON.stringify(raw)}; use names such as C4, Eb3 or F#2`);
396
+ if (midi < SIDWAVE_NOTE_RANGE.min || midi > SIDWAVE_NOTE_RANGE.max) {
397
+ throw new SidwaveValidationError(path, `note ${raw} is outside the supported range C0..B7`);
398
+ }
399
+ return midi;
400
+ }
401
+ const CHORD_QUALITIES = {
402
+ "": [0, 4, 7], maj: [0, 4, 7], M: [0, 4, 7],
403
+ m: [0, 3, 7], min: [0, 3, 7],
404
+ dim: [0, 3, 6], aug: [0, 4, 8], "+": [0, 4, 8],
405
+ sus2: [0, 2, 7], sus4: [0, 5, 7], sus: [0, 5, 7],
406
+ "6": [0, 4, 7, 9], m6: [0, 3, 7, 9], min6: [0, 3, 7, 9],
407
+ "7": [0, 4, 7, 10], maj7: [0, 4, 7, 11], M7: [0, 4, 7, 11], m7: [0, 3, 7, 10], min7: [0, 3, 7, 10],
408
+ dim7: [0, 3, 6, 9], m7b5: [0, 3, 6, 10],
409
+ "9": [0, 4, 7, 10, 14], maj9: [0, 4, 7, 11, 14], M9: [0, 4, 7, 11, 14], m9: [0, 3, 7, 10, 14], min9: [0, 3, 7, 10, 14],
410
+ add9: [0, 4, 7, 14],
411
+ };
412
+ /**
413
+ * Expands a motif string into entries. Tokens: note names (C4, Eb3), integers (semitones above `root`),
414
+ * chord symbols (Cmin9, Am7, F, Gsus4; expanded to their chord tones starting in the root's octave),
415
+ * `-` rests and `~` ties. Purely alphabetic words that are not chord symbols (e.g. "pad", "motif") are ignored.
416
+ */
417
+ export function parseMotif(motif, root, path) {
418
+ const rootMidi = requireNote(root, path.replace(/\.motif$/, ".root"));
419
+ const rootOctave = Math.floor(rootMidi / 12) - 1;
420
+ const entries = [];
421
+ for (const token of motif.split(/[\s,]+/).filter((t) => t.length > 0)) {
422
+ if (isRestToken(token)) {
423
+ entries.push(null);
424
+ continue;
425
+ }
426
+ if (isTieToken(token)) {
427
+ entries.push("~");
428
+ continue;
429
+ }
430
+ if (/^[+-]?\d+$/.test(token)) {
431
+ const midi = rootMidi + Number(token);
432
+ if (midi < SIDWAVE_NOTE_RANGE.min || midi > SIDWAVE_NOTE_RANGE.max) {
433
+ throw new SidwaveValidationError(path, `motif offset ${token} from ${root} leaves the range C0..B7`);
434
+ }
435
+ entries.push(midi);
436
+ continue;
437
+ }
438
+ const note = noteNameToMidi(token);
439
+ if (note !== undefined) {
440
+ entries.push(requireNote(token, path));
441
+ continue;
442
+ }
443
+ const chord = /^([A-G])([#b]?)(.*)$/.exec(token);
444
+ if (chord && chord[3] in CHORD_QUALITIES) {
445
+ const base = noteNameToMidi(`${chord[1]}${chord[2]}${rootOctave}`);
446
+ for (const iv of CHORD_QUALITIES[chord[3]]) {
447
+ const midi = base + iv;
448
+ if (midi > SIDWAVE_NOTE_RANGE.max)
449
+ throw new SidwaveValidationError(path, `chord ${token} leaves the range C0..B7`);
450
+ entries.push(midi);
451
+ }
452
+ continue;
453
+ }
454
+ if (/^[A-Za-z]+$/.test(token))
455
+ continue; // descriptive word
456
+ throw new SidwaveValidationError(path, `unrecognised motif token ${JSON.stringify(token)}; use notes (C4), semitone offsets (0 4 7), chord symbols (Cmin9), '-' or '~'`);
457
+ }
458
+ if (!entries.some((e) => typeof e === "number")) {
459
+ throw new SidwaveValidationError(path, `motif ${JSON.stringify(motif)} contains no notes; use notes (C4 Eb4 G4), semitone offsets (0 3 7) or a chord symbol (Cmin9)`);
460
+ }
461
+ return entries;
462
+ }
463
+ // ---------------------------------------------------------------------------
464
+ // Generic helpers
465
+ function isPlainObject(v) {
466
+ return !!v && typeof v === "object" && !Array.isArray(v);
467
+ }
468
+ function checkKeys(obj, allowed, path, hint) {
469
+ for (const key of Object.keys(obj)) {
470
+ if (!allowed.has(key)) {
471
+ throw new SidwaveValidationError(`${path}.${key}`, hint ?? `unknown field; expected one of ${[...allowed].join(", ")}`);
472
+ }
473
+ }
474
+ }
475
+ function requireObject(raw, path, keys) {
476
+ if (!isPlainObject(raw))
477
+ throw new SidwaveValidationError(path, `expected a mapping with ${keys.join(", ")}`);
478
+ checkKeys(raw, new Set(keys), path);
479
+ return raw;
480
+ }
481
+ function requireNumber(raw, path, opts) {
482
+ const n = typeof raw === "string" && raw.trim() !== "" ? Number(raw) : raw;
483
+ if (typeof n !== "number" || !Number.isFinite(n))
484
+ throw new SidwaveValidationError(path, `expected a number, got ${JSON.stringify(raw)}`);
485
+ if (opts.integer && !Number.isInteger(n))
486
+ throw new SidwaveValidationError(path, `expected a whole number, got ${n}`);
487
+ if (opts.min !== undefined && (opts.exclusiveMin ? n <= opts.min : n < opts.min)) {
488
+ throw new SidwaveValidationError(path, `${n} is below the minimum ${opts.exclusiveMin ? "(exclusive) " : ""}${opts.min}`);
489
+ }
490
+ if (opts.max !== undefined && n > opts.max)
491
+ throw new SidwaveValidationError(path, `${n} is above the maximum ${opts.max}`);
492
+ return n;
493
+ }