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
@@ -6,318 +6,1473 @@ Licensed under the GNU General Public License v2.0 or later.
6
6
  See <https://www.gnu.org/licenses/> for details.
7
7
  */
8
8
  import { Buffer } from "node:buffer";
9
- import { assemblyToPrg } from "./tools/translation/assembler.js";
10
- import { midiToHz, noteNameToMidi } from "./sidwave.js";
9
+ import { assemblyToPrgDetailed } from "./tools/translation/assembler.js";
10
+ import { SidwaveValidationError, isRestToken, isTieToken, midiToHz, midiToNoteName, noteNameToMidi, parseFilterMod, parseLayerRef, parseMotif, } from "./sidwave.js";
11
+ /** Standalone PRG start address (`10 SYS2061`). */
12
+ export const SIDWAVE_PRG_ENTRY = 0x080d;
13
+ /** PSID init routine (A = song number, ignored). */
14
+ export const SIDWAVE_INIT_ADDRESS = 0x0810;
15
+ /** PSID play routine, called once per video frame. */
16
+ export const SIDWAVE_PLAY_ADDRESS = 0x0813;
17
+ export const SIDWAVE_LOAD_ADDRESS = 0x0801;
18
+ /** The player and its data must end below the I/O area at $D000. */
19
+ export const SIDWAVE_MEMORY_END = 0xd000;
20
+ /** Frames of gate-off with zeroed ADSR before every new note (hard restart). */
21
+ export const SIDWAVE_HARD_RESTART_FRAMES = 2;
22
+ /** Shortest note: one frame with the gate on plus the hard restart before the next note. */
23
+ export const SIDWAVE_MIN_NOTE_FRAMES = SIDWAVE_HARD_RESTART_FRAMES + 1;
24
+ /** BASIC's memory ends where the BASIC ROM starts. */
25
+ export const SIDWAVE_BASIC_END = 0xa000;
11
26
  /**
12
- * Small SIDWAVE to PRG compiler: generates a 50/60Hz player that writes SID registers from a precomputed frame table.
13
- * It currently supports three voices with per-frame frequency, waveform, pulse width, and ADSR gate on/off.
14
- * Effects are not fully implemented; basic PWM sweep from pulse_width and simple transpositions are handled.
27
+ * Compiles SIDWAVE to a C64 program with a frame-based player.
28
+ *
29
+ * The compiler arranges the timeline into per-voice note events with frame-exact onsets
30
+ * (no tempo drift), then encodes them as compact command streams. The 6502 player reads the
31
+ * streams once per frame and runs the per-note modulation itself: hard restart, fast arpeggio,
32
+ * pitch slide, vibrato, pulse-width sweep, and a global filter with sweep and per-pattern ramp.
15
33
  */
16
34
  export function compileSidwaveToPrg(doc) {
17
- const system = (doc.song.mode ?? "PAL");
18
- const fps = system === "PAL" ? 50 : 60;
19
- // Estimate total frames by bars at 4/4 with quarter note = 1 beat = 1/ (tempo/60) seconds.
20
- const beatsPerBar = 4;
21
- const secondsPerBeat = 60 / (doc.song.tempo ?? 100);
22
- const framesPerBeat = Math.max(1, Math.round(secondsPerBeat * fps));
23
- const totalBeats = (doc.song.length_bars ?? 16) * beatsPerBar;
24
- const totalFrames = Math.max(framesPerBeat * totalBeats, fps * 15); // ensure at least ~15s of content
25
- const cappedFrames = Math.min(totalFrames, 255); // v1 player index Y wraps at 255
26
- // Build per-voice frame data arrays.
27
- const voices = [1, 2, 3].map((id) => doc.voices.find((v) => v.id === id) ?? { id, name: `Voice${id}`, waveform: "triangle", adsr: [2, 2, 10, 3], pulse_width: 2048, patterns: {} });
28
- const freqFrames = [new Uint16Array(cappedFrames), new Uint16Array(cappedFrames), new Uint16Array(cappedFrames)];
29
- const ctrlFrames = [new Uint8Array(cappedFrames), new Uint8Array(cappedFrames), new Uint8Array(cappedFrames)];
30
- const pwFrames = [new Uint16Array(cappedFrames), new Uint16Array(cappedFrames), new Uint16Array(cappedFrames)];
31
- const adFrames = [new Uint8Array(cappedFrames), new Uint8Array(cappedFrames), new Uint8Array(cappedFrames)];
32
- const srFrames = [new Uint8Array(cappedFrames), new Uint8Array(cappedFrames), new Uint8Array(cappedFrames)];
33
- // Build frames from timeline/patterns where possible
34
- const framesForVoice = [[], [], []];
35
- // Precompute frames-per-bar
36
- const framesPerBar = framesPerBeat * beatsPerBar;
37
- for (let vi = 0; vi < 3; vi += 1) {
38
- const v = voices[vi];
39
- const adsr = v.adsr ?? [2, 2, 10, 3];
40
- const ad = ((adsr[0] & 0xf) << 4) | (adsr[1] & 0xf);
41
- const sr = ((adsr[2] & 0xf) << 4) | (adsr[3] & 0xf);
42
- adFrames[vi].fill(ad);
43
- srFrames[vi].fill(sr);
44
- const waveformCtrl = waveformToCtrl(v.waveform ?? "pulse");
45
- const basePw = Math.max(0, Math.min(0x0fff, Math.floor(v.pulse_width ?? 0x0800)));
46
- // Collect a note per frame using timeline mapping (fallback to simple arp)
47
- const notesForSong = [];
48
- const patterns = (v.patterns ?? {});
49
- const voiceKeyCandidates = [
50
- `v${v.id}`,
51
- `voice${v.id}`,
52
- `${v.id}`,
53
- ];
54
- for (const section of doc.timeline ?? []) {
55
- // Pick the first matching layer key that maps to this voice
56
- let patternName;
57
- for (const key of voiceKeyCandidates) {
58
- if (section.layers && section.layers[key]) {
59
- patternName = String(section.layers[key]);
60
- break;
61
- }
62
- }
63
- const bars = Math.max(1, Math.floor(section.bars ?? 1));
64
- const totalFramesInSection = bars * framesPerBar;
65
- const pat = (patternName ? patterns[patternName] : undefined);
66
- if (!pat) {
67
- // Fill section with sustained default note
68
- for (let i = 0; i < totalFramesInSection; i += 1)
69
- notesForSong.push("C3");
70
- continue;
71
- }
72
- if (pat.type === "arpeggio" && Array.isArray(pat.notes) && pat.notes.length) {
73
- const stepFrames = Math.max(1, Math.round((pat.frame_rate ? fps / pat.frame_rate : framesPerBeat)));
74
- for (let f = 0; f < totalFramesInSection; f += 1) {
75
- const idx = Math.floor(f / stepFrames) % pat.notes.length;
76
- notesForSong.push(String(pat.notes[idx]));
77
- }
78
- }
79
- else if (Array.isArray(pat.groove) && pat.groove.length) {
80
- const stepFrames = framesPerBeat; // one note per beat
81
- for (let f = 0; f < totalFramesInSection; f += 1) {
82
- const gi = Math.floor(f / stepFrames) % pat.groove.length;
83
- notesForSong.push(String(pat.groove[gi]));
84
- }
85
- }
86
- else if (typeof pat.motif === "string" && pat.motif.trim().length > 0) {
87
- const intervals = pat.motif
88
- .split(/[\s,]+/)
89
- .map((x) => Number.parseInt(x, 10))
90
- .filter((n) => Number.isFinite(n));
91
- const lengthBeats = Math.max(1, Math.floor(pat.length ?? 1));
92
- const stepFrames = framesPerBeat * lengthBeats;
93
- const baseNote = "C3";
94
- for (let f = 0; f < totalFramesInSection; f += 1) {
95
- const mi = Math.floor(f / stepFrames) % Math.max(1, intervals.length);
96
- const iv = intervals.length ? intervals[mi] : 0;
97
- notesForSong.push(transposeNote(baseNote, iv));
98
- }
99
- }
100
- else {
101
- // Unknown pattern shape; fill with default sustained
102
- for (let i = 0; i < totalFramesInSection; i += 1)
103
- notesForSong.push("C3");
104
- }
105
- }
106
- // Ensure we have at least cappedFrames entries
107
- if (notesForSong.length < cappedFrames) {
108
- const fallback = inferNotesForVoice(v) ?? ["C3", "E3", "G3", "B2"];
109
- let i = 0;
110
- while (notesForSong.length < cappedFrames) {
111
- notesForSong.push(fallback[i % fallback.length]);
112
- i += 1;
113
- }
114
- }
115
- const fx = inferVoiceFx(v);
116
- const vibDepthSemis = fx.vibrato?.depth ?? 0;
117
- const vibRateHz = fx.vibrato?.rate ?? 0;
118
- const pwmDepth = fx.pwm_sweep?.depth ?? 0;
119
- const pwmSpeed = fx.pwm_sweep?.speed ?? 0;
120
- for (let f = 0; f < cappedFrames; f += 1) {
121
- const note = notesForSong[f] ?? notesForSong[notesForSong.length - 1] ?? "C3";
122
- const midi = noteNameToMidi(note) ?? 48;
123
- const vib = vibDepthSemis > 0 && vibRateHz > 0 ? vibDepthSemis * Math.sin((2 * Math.PI * vibRateHz * f) / fps) : 0;
124
- const midiWithVib = midi + vib;
125
- const hz = midiToHz(midiWithVib);
126
- const sidFreq = hzToSidFrequency(hz, system);
127
- freqFrames[vi][f] = sidFreq;
128
- ctrlFrames[vi][f] = waveformCtrl | 0x01; // GATE on
129
- const baseSweep = pwmDepth > 0 && pwmSpeed > 0 ? pwmDepth * Math.sin((2 * Math.PI * pwmSpeed * f) / fps) : 0;
130
- const lfo = (String(v.waveform).toLowerCase().startsWith("pulse")) ? baseSweep : 0;
131
- const pw = (String(v.waveform).toLowerCase().startsWith("pulse") ? Math.max(0, Math.min(0x0fff, Math.floor(basePw + lfo))) : basePw) & 0x0fff;
132
- pwFrames[vi][f] = pw;
133
- }
35
+ const arrangement = arrangeSidwave(doc);
36
+ const streams = arrangement.voices.map((v) => encodeVoiceStream(v.events, arrangement.totalFrames));
37
+ // Check the size before assembling the streams: the player and its tables are assembled once with
38
+ // empty streams, which gives the exact start of the stream data. Songs larger than 64 KB would
39
+ // otherwise wrap the address counter inside the assembler.
40
+ const layout = assemblyToPrgDetailed(buildPlayerAsm(arrangement, [[OP.LOOP], [OP.LOOP], [OP.LOOP]]), {
41
+ fileName: "sidwave_player.asm",
42
+ loadAddress: SIDWAVE_LOAD_ADDRESS,
43
+ });
44
+ const streamStart = layout.symbols.get("stream0");
45
+ const projectedEnd = streamStart + streams.reduce((sum, st) => sum + st.length, 0);
46
+ // The PRG installer also writes three bytes after the program (an empty BASIC program).
47
+ if (projectedEnd + 3 > SIDWAVE_MEMORY_END) {
48
+ const kb = ((projectedEnd - SIDWAVE_LOAD_ADDRESS) / 1024).toFixed(1);
49
+ throw new SidwaveValidationError("$.timeline", `the compiled song needs ${kb} KB and would end at $${projectedEnd.toString(16).toUpperCase()}, past the $D000 limit (about 50 KB); shorten the timeline or use longer note lengths`);
134
50
  }
135
- const entryAddress = 0x0810;
136
- const asm = buildPlayerAsm({ system, totalFrames: cappedFrames, freqFrames, ctrlFrames, pwFrames, adFrames, srFrames, entryAddress });
137
- const prg = assemblyToPrg(asm, { fileName: "sidwave_player.asm", loadAddress: 0x0801 });
138
- return { prg, entryAddress };
51
+ const asm = buildPlayerAsm(arrangement, streams);
52
+ const assembled = assemblyToPrgDetailed(asm, { fileName: "sidwave_player.asm", loadAddress: SIDWAVE_LOAD_ADDRESS });
53
+ const prg = assembled.prg;
54
+ const endAddress = SIDWAVE_LOAD_ADDRESS + prg.length - 2;
55
+ for (const [label, expected] of [["install", SIDWAVE_PRG_ENTRY], ["init_vec", SIDWAVE_INIT_ADDRESS], ["play_vec", SIDWAVE_PLAY_ADDRESS], ["prg_end", endAddress]]) {
56
+ if (assembled.symbols.get(label) !== expected)
57
+ throw new Error(`SIDWAVE player layout error: ${label} is not at $${expected.toString(16)}`);
58
+ }
59
+ // After the PRG's SYS returns, BASIC's program and variables start right behind the player.
60
+ const basicFreeBytes = Math.max(0, SIDWAVE_BASIC_END - (endAddress + 3));
61
+ const warnings = [];
62
+ if (basicFreeBytes < 1024) {
63
+ warnings.push(basicFreeBytes === 0
64
+ ? `The PRG ends at $${endAddress.toString(16).toUpperCase()}, past BASIC's memory ($A000): after it returns to BASIC, any BASIC command that needs memory fails with ?OUT OF MEMORY. The music keeps playing.`
65
+ : `The PRG leaves only ${basicFreeBytes} bytes of BASIC memory after it returns to BASIC.`);
66
+ }
67
+ return {
68
+ prg,
69
+ entryAddress: SIDWAVE_PRG_ENTRY,
70
+ initAddress: SIDWAVE_INIT_ADDRESS,
71
+ playAddress: SIDWAVE_PLAY_ADDRESS,
72
+ loadAddress: SIDWAVE_LOAD_ADDRESS,
73
+ endAddress,
74
+ totalFrames: arrangement.totalFrames,
75
+ framesPerSecond: arrangement.fps,
76
+ instrumentCount: arrangement.instruments.length - 1,
77
+ streamBytes: streams.map((s) => s.length),
78
+ basicFreeBytes,
79
+ warnings,
80
+ };
139
81
  }
140
82
  /**
141
- * Build a minimal PSID v2 header and embed the PRG body, so clients can POST via sidplay attachment.
142
- * The PRG must contain a 2-byte load address followed by code. We keep loadAddress=0 (derive from data), init/play=$0810.
83
+ * Wraps the compiled PRG in a PSID v2 file. Init and play are the player's fixed entry points;
84
+ * the clock flag follows the document's mode so the host calls play at 50 Hz (PAL) or 60 Hz (NTSC).
85
+ * `entryAddress` is accepted for compatibility and ignored.
143
86
  */
144
87
  export function compileSidwaveToSid(doc, prg, options) {
145
- const headerSize = 124; // PSID v2 header size
88
+ const headerSize = 0x7c;
146
89
  const header = Buffer.alloc(headerSize, 0);
147
90
  header.write("PSID", 0, "ascii");
148
91
  header.writeUInt16BE(2, 4); // version
149
92
  header.writeUInt16BE(headerSize, 6); // data offset
150
- header.writeUInt16BE(0, 8); // load address (0 => take from data)
151
- const entry = options?.entryAddress ?? 0x0810;
152
- header.writeUInt16BE(entry, 10); // init
153
- header.writeUInt16BE(entry, 12); // play (single entry; routine self-dispatches)
93
+ header.writeUInt16BE(0, 8); // load address taken from the first two data bytes
94
+ header.writeUInt16BE(options?.initAddress ?? SIDWAVE_INIT_ADDRESS, 10);
95
+ header.writeUInt16BE(options?.playAddress ?? SIDWAVE_PLAY_ADDRESS, 12);
154
96
  header.writeUInt16BE(1, 14); // songs
155
97
  header.writeUInt16BE(1, 16); // start song
156
- header.writeUInt32BE(0, 18); // speed
157
- // strings (32 bytes each)
158
- const title = (doc.song.title ?? "Untitled").slice(0, 31);
159
- header.write(title, 22, "ascii");
160
- header.write("SIDWAVE", 54, "ascii");
161
- header.write("MCP", 86, "ascii");
162
- // data is PRG body; if it already has a 2-byte load address, keep it so loaders can use it
98
+ header.writeUInt32BE(0, 18); // speed: vertical blank timing for song 1
99
+ writeLatin1(header, doc.song.title ?? "Untitled", 0x16);
100
+ writeLatin1(header, doc.song.author ?? "SIDWAVE", 0x36);
101
+ writeLatin1(header, "c64bridge SIDWAVE", 0x56);
102
+ const clockBits = doc.song.mode === "NTSC" ? 0b10 : 0b01;
103
+ header.writeUInt16BE(clockBits << 2, 0x76); // flags: built-in player, C64 compatible, clock, SID model unspecified
163
104
  const body = Buffer.isBuffer(prg) ? prg : Buffer.from(prg);
164
105
  return { sid: Buffer.concat([header, body]) };
165
106
  }
107
+ function writeLatin1(buf, text, offset) {
108
+ const safe = [...text].map((ch) => (ch.codePointAt(0) <= 0xff ? ch : "?")).join("").slice(0, 32);
109
+ buf.write(safe, offset, 32, "latin1");
110
+ }
166
111
  export function hzToSidFrequency(hz, system) {
167
112
  const phi2 = system === "PAL" ? 985248 : 1022727;
168
113
  const value = Math.round((hz * 16777216) / phi2);
169
114
  return Math.max(0, Math.min(0xffff, value));
170
115
  }
116
+ const CLOCK_HZ = { PAL: 985248, NTSC: 1022727 };
117
+ /** CPU cycles per video frame: 312 lines x 63 cycles (PAL), 263 lines x 65 cycles (NTSC). */
118
+ const CYCLES_PER_FRAME = { PAL: 312 * 63, NTSC: 263 * 65 };
119
+ /** Video frame rate of a real C64: 50.1245 Hz on PAL, 59.8262 Hz on NTSC. The player runs once per frame. */
120
+ export function sidwaveFrameRate(system) {
121
+ return CLOCK_HZ[system] / CYCLES_PER_FRAME[system];
122
+ }
123
+ /** Highest playable MIDI note for a system: B7 exceeds the 16-bit frequency register at the PAL clock. */
124
+ export function maxSidwaveMidi(system) {
125
+ let midi = 107;
126
+ while (Math.round((midiToHz(midi) * 16777216) / CLOCK_HZ[system]) > 0xffff)
127
+ midi -= 1;
128
+ return midi;
129
+ }
171
130
  function waveformToCtrl(w) {
172
131
  const wf = (w || "pulse").toLowerCase();
173
132
  if (wf.startsWith("tri"))
174
- return 1 << 4;
133
+ return 0x10;
175
134
  if (wf.startsWith("saw"))
176
- return 1 << 5;
177
- if (wf.startsWith("pulse"))
178
- return 1 << 6;
135
+ return 0x20;
179
136
  if (wf.startsWith("noise"))
180
- return 1 << 7;
181
- return 1 << 6;
137
+ return 0x80;
138
+ return 0x40;
182
139
  }
183
- function inferNotesForVoice(v) {
184
- const pnames = Object.keys(v.patterns ?? {});
185
- for (const name of pnames) {
186
- const p = v.patterns[name];
187
- if (!p)
188
- continue;
189
- if (Array.isArray(p.notes))
190
- return p.notes;
191
- if (Array.isArray(p.groove))
192
- return p.groove;
140
+ const BEAT_EPS = 1e-9;
141
+ export function arrangeSidwave(doc) {
142
+ const system = doc.song.mode ?? "PAL";
143
+ const fps = sidwaveFrameRate(system);
144
+ const framesPerBeat = (fps * 60) / doc.song.tempo;
145
+ const frameAt = (beat) => Math.round(beat * framesPerBeat + BEAT_EPS);
146
+ const maxMidi = maxSidwaveMidi(system);
147
+ const totalBars = doc.song.length_bars;
148
+ const totalFrames = frameAt(totalBars * 4);
149
+ // Instrument 0 is a neutral placeholder: voices that have not played a note yet use it, so they are not routed to the filter.
150
+ const instruments = [{ ad: 0, sr: 0, wave: 0, pw: 0, pwStep: 0, pwMin: 0, pwMax: 0, vibUp: [0, 0], vibDown: [0, 0], vibRate: 0, vibDelay: 0, arp: [], arpSpeed: 0, slideSemis: 0, slideSpeed: 0, slideRecip: 0, filter: 0 }];
151
+ const instKeys = new Map();
152
+ const instIndex = (inst) => {
153
+ const key = JSON.stringify(inst);
154
+ let idx = instKeys.get(key);
155
+ if (idx === undefined) {
156
+ idx = instruments.length;
157
+ if (idx >= 64)
158
+ throw new SidwaveValidationError("$.voices", "more than 63 distinct voice/pattern/effect combinations; reuse patterns or effects");
159
+ instruments.push(inst);
160
+ instKeys.set(key, idx);
161
+ }
162
+ return idx;
163
+ };
164
+ const gfx = doc.song.global_fx;
165
+ const fenv = gfx.filter_env;
166
+ const filterVoices = new Set(fenv ? (fenv.voices ?? [1, 2, 3]).map(Number) : []);
167
+ let filterUsed = !!fenv;
168
+ const voicesById = new Map(doc.voices.map((v) => [v.id, v]));
169
+ const notes = [[], [], []];
170
+ const globals = [];
171
+ const fadeWindows = [[], [], []];
172
+ const reverb = gfx.reverb_sim;
173
+ // Expand the timeline, cycling it when length_bars exceeds it and truncating when shorter.
174
+ const sections = [];
175
+ for (let bar = 0, i = 0; bar < totalBars; i += 1) {
176
+ const s = doc.timeline[i % doc.timeline.length];
177
+ const bars = Math.min(s.bars, totalBars - bar);
178
+ sections.push({ index: i % doc.timeline.length, startBeat: bar * 4, endBeat: (bar + bars) * 4 });
179
+ bar += bars;
193
180
  }
194
- return undefined;
181
+ for (const sec of sections) {
182
+ const section = doc.timeline[sec.index];
183
+ const secPath = `$.timeline[${sec.index}]`;
184
+ const sf = frameAt(sec.startBeat);
185
+ const ef = frameAt(sec.endBeat);
186
+ const active = [];
187
+ const fading = [];
188
+ for (const id of [1, 2, 3]) {
189
+ const ref = section.layers[`v${id}`];
190
+ if (ref === undefined)
191
+ continue;
192
+ const voice = voicesById.get(id);
193
+ const parsed = parseLayerRef(ref, Object.keys(voice?.patterns ?? {}));
194
+ if (parsed.pattern === null || !voice)
195
+ continue;
196
+ const pattern = voice.patterns[parsed.pattern];
197
+ const patPath = `${secPath}.layers.v${id} (${parsed.pattern})`;
198
+ active.push(id);
199
+ if (parsed.fade) {
200
+ fading.push(id);
201
+ fadeWindows[id - 1].push({ start: sf, end: ef });
202
+ }
203
+ const fxRaw = (pattern.fx ?? {});
204
+ const filterMod = fxRaw.filter_mod ? parseFilterMod(fxRaw.filter_mod, `${patPath}.fx.filter_mod`) : undefined;
205
+ if (filterMod)
206
+ filterUsed = true;
207
+ const routed = filterVoices.has(id) || !!filterMod ? 1 : 0;
208
+ const entries = patternEntries(pattern, patPath);
209
+ const lengthBeats = pattern.length ?? 1;
210
+ const transpositions = pattern.transpositions ?? [0];
211
+ const cycleBeats = entries.length * lengthBeats;
212
+ const out = notes[id - 1];
213
+ for (let c = 0;; c += 1) {
214
+ const cycleStart = sec.startBeat + c * cycleBeats;
215
+ if (cycleStart >= sec.endBeat - BEAT_EPS)
216
+ break;
217
+ const tr = transpositions[c % transpositions.length] + parsed.transpose;
218
+ if (filterMod) {
219
+ const unitFrames = { steps: lengthBeats * framesPerBeat, beats: framesPerBeat, bars: 4 * framesPerBeat, frames: 1 }[filterMod.unit];
220
+ const count = Math.max(1, Math.min(0xffff, Math.round(filterMod.amount * unitFrames)));
221
+ // Offset and step are in cutoff x 32 units and signed 24-bit in the player, so the full ±2047 range ramps smoothly.
222
+ const step = Math.round((filterMod.sweep * 32) / count);
223
+ globals.push({ frame: frameAt(cycleStart), kind: "filtermod", step, count });
224
+ }
225
+ for (let k = 0; k < entries.length; k += 1) {
226
+ const b = cycleStart + k * lengthBeats;
227
+ if (b >= sec.endBeat - BEAT_EPS)
228
+ break;
229
+ const truncated = b + lengthBeats > sec.endBeat + BEAT_EPS;
230
+ const f0 = frameAt(b);
231
+ const f1 = frameAt(Math.min(b + lengthBeats, sec.endBeat));
232
+ const entry = entries[k];
233
+ if (entry.kind === "rest")
234
+ continue;
235
+ if (entry.kind === "tie") {
236
+ // A tie holds the note sounding right before it, also across a section boundary; after a rest it is a rest.
237
+ const last = out[out.length - 1];
238
+ if (last && last.end === f0)
239
+ last.end = f1;
240
+ continue;
241
+ }
242
+ if (f1 - f0 < SIDWAVE_MIN_NOTE_FRAMES) {
243
+ if (truncated)
244
+ continue;
245
+ throw new SidwaveValidationError(patPath, `each entry lasts ${(lengthBeats * framesPerBeat).toFixed(2)} frames at ${doc.song.tempo} BPM; the player needs at least ${SIDWAVE_MIN_NOTE_FRAMES} frames per note (one sounding frame plus the ${SIDWAVE_HARD_RESTART_FRAMES}-frame hard restart); raise 'length' or lower the tempo`);
246
+ }
247
+ const base = entry.notes[0] + tr;
248
+ const offsets = entry.notes.map((n) => n - entry.notes[0]);
249
+ for (const off of offsets) {
250
+ const midi = base + off;
251
+ if (midi < 12 || midi > maxMidi) {
252
+ throw new SidwaveValidationError(patPath, `note ${midiToNoteName(midi)} (after transposition ${tr >= 0 ? "+" : ""}${tr}) is outside the playable range C0..${midiToNoteName(maxMidi)} on ${system}`);
253
+ }
254
+ }
255
+ const slideDepth = fxRaw.slide ? Number(fxRaw.slide.depth) : 0;
256
+ if (slideDepth !== 0 && (base - slideDepth < 12 || base - slideDepth > maxMidi)) {
257
+ throw new SidwaveValidationError(`${patPath}.fx.slide.depth`, `the slide into ${midiToNoteName(base)} would start at ${midiToNoteName(base - slideDepth)}, outside the playable range C0..${midiToNoteName(maxMidi)} on ${system}`);
258
+ }
259
+ if (fxRaw.vibrato) {
260
+ // The upward swing must fit the 16-bit frequency register, or it would clip and the vibrato become lopsided.
261
+ const depth = Number(fxRaw.vibrato.depth);
262
+ const top = base + Math.max(...offsets);
263
+ const peak = (midiToHz(top) * Math.pow(2, depth / 12) * 16777216) / CLOCK_HZ[system];
264
+ if (Math.round(peak) > 0xffff) {
265
+ const maxDepth = 12 * Math.log2((0xffff * CLOCK_HZ[system]) / 16777216 / midiToHz(top));
266
+ throw new SidwaveValidationError(`${patPath}.fx.vibrato.depth`, `a vibrato of ${depth} semitones on ${midiToNoteName(top)} would rise above the SID's highest frequency on ${system}; use a depth of at most ${Math.floor(maxDepth * 100) / 100} or a lower note`);
267
+ }
268
+ }
269
+ const inst = buildInstrument(voice, fxRaw, entry.arp ? offsets : [], entry.arp ? entry.arpSpeed : 0, routed, fps);
270
+ out.push({ start: f0, end: f1, midi: base, inst: instIndex(inst) });
271
+ }
272
+ }
273
+ if (filterMod)
274
+ globals.push({ frame: ef, kind: "filtermod", step: 0, count: 0 });
275
+ }
276
+ // Master volume fade when every sounding voice of the section fades.
277
+ if (fading.length > 0 && fading.length === active.length) {
278
+ for (let beat = sec.startBeat; beat < sec.endBeat - BEAT_EPS; beat += 1) {
279
+ const f = frameAt(beat);
280
+ globals.push({ frame: f, kind: "volume", value: Math.round(15 * (1 - (f - sf) / (ef - sf))) });
281
+ }
282
+ globals.push({ frame: ef, kind: "volume", value: 15 });
283
+ }
284
+ // Reverb simulation: a free voice echoes the lowest active voice, delayed and quieter.
285
+ if (reverb && active.length > 0 && active.length < 3) {
286
+ const src = Math.min(...active);
287
+ const tgt = [1, 2, 3].find((id) => !active.includes(id));
288
+ const delay = reverb.delay_frames;
289
+ for (const n of notes[src - 1]) {
290
+ if (n.start < sf || n.start >= ef)
291
+ continue;
292
+ const start = n.start + delay;
293
+ const end = Math.min(n.end + delay, ef);
294
+ if (end - start < SIDWAVE_MIN_NOTE_FRAMES)
295
+ continue;
296
+ const srcInst = instruments[n.inst];
297
+ const sustain = Math.round((srcInst.sr >> 4) * reverb.depth);
298
+ const echo = { ...srcInst, sr: (sustain << 4) | (srcInst.sr & 0x0f) };
299
+ notes[tgt - 1].push({ start, end, midi: n.midi, inst: instIndex(echo) });
300
+ }
301
+ if (fading.includes(src))
302
+ fadeWindows[tgt - 1].push({ start: sf, end: ef });
303
+ }
304
+ }
305
+ for (let v = 0; v < 3; v += 1) {
306
+ notes[v].sort((a, b) => a.start - b.start);
307
+ for (const n of notes[v]) {
308
+ const w = fadeWindows[v].find((fw) => n.start >= fw.start && n.start < fw.end);
309
+ if (w)
310
+ n.fade = w;
311
+ }
312
+ }
313
+ const voices = notes.map((list, v) => ({
314
+ notes: list,
315
+ events: buildVoiceEvents(list, instruments, totalFrames, framesPerBeat, v === 0 ? globals : []),
316
+ }));
317
+ const base = fenv ? Math.round(Number(fenv.cutoff_base)) : 1024;
318
+ const sweep = fenv?.sweep !== undefined ? Math.round(Number(fenv.sweep)) : 0;
319
+ const sweepFrames = fenv?.speed !== undefined ? Number(fenv.speed) * framesPerBeat : 0;
320
+ const stepFixed = sweep !== 0 && sweepFrames > 0 ? Math.max(1, Math.round((Math.abs(sweep) * 32) / sweepFrames)) : 0;
321
+ const type = String(fenv?.type ?? "lowpass").toLowerCase();
322
+ const mode = filterUsed ? (type === "bandpass" ? 0x20 : type === "highpass" ? 0x40 : 0x10) : 0;
323
+ const resonance = fenv?.resonance !== undefined ? Number(fenv.resonance) : 8;
324
+ return {
325
+ fps,
326
+ framesPerBeat,
327
+ totalFrames,
328
+ system,
329
+ instruments,
330
+ voices,
331
+ filter: { base, sweep, stepFixed, resonance, mode, used: filterUsed },
332
+ };
333
+ }
334
+ function patternEntries(pattern, path) {
335
+ const p = pattern;
336
+ if (p.type === "arpeggio" || Array.isArray(p.notes)) {
337
+ const midis = p.notes.map((n) => noteNameToMidi(n));
338
+ if (p.frame_rate !== undefined)
339
+ return [{ kind: "note", notes: midis, arp: true, arpSpeed: Number(p.frame_rate) }];
340
+ return midis.map((m) => ({ kind: "note", notes: [m], arp: false, arpSpeed: 0 }));
341
+ }
342
+ if (Array.isArray(p.groove)) {
343
+ return p.groove.map((raw) => {
344
+ const tok = String(raw).trim();
345
+ if (isRestToken(tok))
346
+ return { kind: "rest" };
347
+ if (isTieToken(tok))
348
+ return { kind: "tie" };
349
+ return { kind: "note", notes: [noteNameToMidi(tok)], arp: false, arpSpeed: 0 };
350
+ });
351
+ }
352
+ const entries = parseMotif(String(p.motif), p.root === undefined ? "C3" : String(p.root), `${path}.motif`);
353
+ return entries.map((e) => (e === null ? { kind: "rest" } : e === "~" ? { kind: "tie" } : { kind: "note", notes: [e], arp: false, arpSpeed: 0 }));
195
354
  }
196
- function inferVoiceFx(v) {
197
- const patterns = v.patterns ?? {};
198
- const first = Object.values(patterns)[0];
199
- const fx = (first && typeof first === "object" && first.fx) ? first.fx : {};
355
+ function buildInstrument(voice, fx, arp, arpSpeed, filter, fps) {
356
+ const adsr = voice.adsr ?? [2, 2, 10, 3];
357
+ const wave = waveformToCtrl(voice.waveform ?? "pulse");
358
+ const pw = voice.pulse_width ?? 0x800;
359
+ let pwStep = 0;
360
+ let pwMin = pw;
361
+ let pwMax = pw;
362
+ if (fx.pwm_sweep && wave === 0x40) {
363
+ const rawDepth = Number(fx.pwm_sweep.depth);
364
+ const depth = rawDepth <= 1 ? rawDepth * 4095 : rawDepth;
365
+ pwMin = Math.max(16, Math.round(pw - depth));
366
+ pwMax = Math.min(4079, Math.round(pw + depth));
367
+ pwStep = Math.max(1, Math.round((2 * (pwMax - pwMin) * Number(fx.pwm_sweep.speed)) / fps));
368
+ }
369
+ const vib = fx.vibrato;
370
+ const slide = fx.slide;
371
+ const depth = vib ? Number(vib.depth) : 0;
372
+ const slideSpeed = slide ? Number(slide.speed) : 0;
373
+ const slideRecip = slideSpeed > 1 ? Math.round(65536 / slideSpeed) : 0;
200
374
  return {
201
- vibrato: fx?.vibrato,
202
- slide: fx?.slide,
203
- pwm_sweep: fx?.pwm_sweep,
375
+ ad: ((adsr[0] & 0xf) << 4) | (adsr[1] & 0xf),
376
+ sr: ((adsr[2] & 0xf) << 4) | (adsr[3] & 0xf),
377
+ wave,
378
+ pw,
379
+ pwStep,
380
+ pwMin,
381
+ pwMax,
382
+ // The player's triangle peaks at 126/128, so the factors are raised by 128/126 to reach ±depth exactly.
383
+ vibUp: vib ? mantissaShift((128 / 126) * (Math.pow(2, depth / 12) - 1)) : [0, 0],
384
+ vibDown: vib ? mantissaShift((128 / 126) * (1 - Math.pow(2, -depth / 12))) : [0, 0],
385
+ vibRate: vib ? Math.max(1, Math.min(255, Math.round((256 * Number(vib.rate)) / fps))) : 0,
386
+ vibDelay: vib ? Number(vib.delay ?? 0) : 0,
387
+ arp,
388
+ arpSpeed,
389
+ slideSemis: slide ? Number(slide.depth) : 0,
390
+ slideSpeed,
391
+ slideRecip,
392
+ filter,
204
393
  };
205
394
  }
206
- function buildPlayerAsm(args) {
207
- const L = [];
208
- const entryHex = hex16(args.entryAddress);
209
- L.push(`* = $${entryHex}`);
210
- L.push("; --- PSID single-entry init/play routine ---");
211
- L.push("entry:");
212
- L.push(" LDA inited");
213
- L.push(" BNE do_play");
214
- L.push(" JSR init");
215
- L.push(" RTS");
216
- L.push("do_play:");
217
- L.push(" JSR play_frame");
218
- L.push(" RTS");
219
- L.push("");
220
- L.push("init:");
221
- L.push(" LDA #$0F");
222
- L.push(" STA $D418");
223
- L.push(" LDA #$00");
224
- L.push(" STA frame_idx");
225
- L.push(" LDA #$01");
226
- L.push(" STA inited");
227
- L.push(" RTS");
228
- L.push("");
229
- L.push("play_frame:");
230
- L.push(" LDY frame_idx");
231
- for (let v = 0; v < 3; v += 1) {
232
- const base = 0xd400 + v * 7;
233
- L.push(` ; voice ${v + 1}`);
234
- L.push(` LDA v${v + 1}_freq_lo,Y`);
235
- L.push(` STA $${hex16(base)}`);
236
- L.push(` LDA v${v + 1}_freq_hi,Y`);
237
- L.push(` STA $${hex16(base + 1)}`);
238
- L.push(` LDA v${v + 1}_pw_lo,Y`);
239
- L.push(` STA $${hex16(base + 2)}`);
240
- L.push(` LDA v${v + 1}_pw_hi,Y`);
241
- L.push(` STA $${hex16(base + 3)}`);
242
- L.push(` LDA v${v + 1}_ctrl,Y`);
243
- L.push(` STA $${hex16(base + 4)}`);
244
- L.push(` LDA v${v + 1}_ad,Y`);
245
- L.push(` STA $${hex16(base + 5)}`);
246
- L.push(` LDA v${v + 1}_sr,Y`);
247
- L.push(` STA $${hex16(base + 6)}`);
395
+ /** Represents 0 < k < 1 as mant / 2^(8 + shift) with the largest shift that keeps mant <= 255 (at least 7 bits of precision). */
396
+ function mantissaShift(k) {
397
+ let shift = 0;
398
+ while (shift < 15 && Math.round(k * 2 ** (9 + shift)) <= 255)
399
+ shift += 1;
400
+ return [Math.max(1, Math.round(k * 2 ** (8 + shift))), shift];
401
+ }
402
+ // ---------------------------------------------------------------------------
403
+ // Command stream
404
+ export const OP = {
405
+ NOTE: 0x00, // + note index 0..95 (C0..B7)
406
+ INST: 0x60, // + instrument 0..63
407
+ WAIT: 0xa0, // + frames-1, 1..64 frames
408
+ GATEOFF: 0xe0,
409
+ HARDRESTART: 0xe1,
410
+ SETSR: 0xe2, // nn
411
+ SETVOL: 0xe3, // nn
412
+ WAITLONG: 0xe4, // nn frames, 0 = 256
413
+ FILTERMOD: 0xe5, // step (signed 24-bit, cutoff x 32), count lo, count hi
414
+ LOOP: 0xe6,
415
+ };
416
+ const ORDER = { gate: 0, filterReset: 1, inst: 2, note: 3, sustain: 4, filterStart: 5, volume: 6 };
417
+ function buildVoiceEvents(notes, instruments, total, framesPerBeat, globals) {
418
+ const events = [];
419
+ const wrap = (f) => (f >= total ? f - total : f);
420
+ let currentInst = -1;
421
+ for (let i = 0; i < notes.length; i += 1) {
422
+ const n = notes[i];
423
+ const next = i + 1 < notes.length ? notes[i + 1].start : notes[0].start + total;
424
+ const hr = Math.max(n.start + 1, next - SIDWAVE_HARD_RESTART_FRAMES);
425
+ const gateOff = Math.min(n.end, hr);
426
+ if (gateOff < hr)
427
+ events.push({ frame: wrap(gateOff), order: ORDER.gate, bytes: [OP.GATEOFF] });
428
+ events.push({ frame: wrap(hr), order: ORDER.gate, bytes: [OP.HARDRESTART] });
429
+ if (n.inst !== currentInst) {
430
+ events.push({ frame: n.start, order: ORDER.inst, bytes: [OP.INST + n.inst] });
431
+ currentInst = n.inst;
432
+ }
433
+ events.push({ frame: n.start, order: ORDER.note, bytes: [OP.NOTE + (n.midi - 12)] });
434
+ if (n.fade) {
435
+ const inst = instruments[n.inst];
436
+ const sustain = inst.sr >> 4;
437
+ const level = (f) => (Math.round(sustain * Math.max(0, 1 - (f - n.fade.start) / (n.fade.end - n.fade.start))) << 4) | (inst.sr & 0x0f);
438
+ events.push({ frame: n.start, order: ORDER.sustain, bytes: [OP.SETSR, level(n.start)] });
439
+ const firstBeat = Math.ceil((n.start + 1) / framesPerBeat - BEAT_EPS);
440
+ for (let beat = firstBeat;; beat += 1) {
441
+ const f = Math.round(beat * framesPerBeat + BEAT_EPS);
442
+ if (f >= gateOff)
443
+ break;
444
+ if (f > n.start)
445
+ events.push({ frame: f, order: ORDER.sustain, bytes: [OP.SETSR, level(f)] });
446
+ }
447
+ }
248
448
  }
249
- L.push(" INY");
250
- L.push(` CPY #$${hex2(args.totalFrames & 0xff)}`);
251
- L.push(" BCC store_idx");
252
- L.push(" LDY #$00");
253
- L.push("store_idx:");
254
- L.push(" STY frame_idx");
255
- L.push(" RTS");
256
- L.push("");
257
- L.push("; --- Zero page state ---");
258
- L.push("frame_idx = $FB");
259
- L.push("inited = $FC");
260
- L.push("");
261
- // Data tables
262
- for (let v = 0; v < 3; v += 1) {
263
- const fLo = [];
264
- const fHi = [];
265
- const pwLo = [];
266
- const pwHi = [];
267
- const ctrl = [];
268
- const ad = [];
269
- const sr = [];
270
- for (let i = 0; i < args.totalFrames; i += 1) {
271
- const f = args.freqFrames[v][i];
272
- fLo.push(`$${hex2(f & 0xff)}`);
273
- fHi.push(`$${hex2((f >> 8) & 0xff)}`);
274
- const pw = args.pwFrames[v][i];
275
- pwLo.push(`$${hex2(pw & 0xff)}`);
276
- pwHi.push(`$${hex2((pw >> 8) & 0x0f)}`);
277
- ctrl.push(`$${hex2(args.ctrlFrames[v][i])}`);
278
- ad.push(`$${hex2(args.adFrames[v][i])}`);
279
- sr.push(`$${hex2(args.srFrames[v][i])}`);
449
+ for (const g of globals) {
450
+ if (g.kind === "volume") {
451
+ events.push({ frame: wrap(g.frame), order: ORDER.volume, bytes: [OP.SETVOL, g.value] });
452
+ }
453
+ else {
454
+ const step = g.step & 0xffffff;
455
+ events.push({
456
+ frame: wrap(g.frame),
457
+ order: g.count === 0 ? ORDER.filterReset : ORDER.filterStart,
458
+ bytes: [OP.FILTERMOD, step & 0xff, (step >> 8) & 0xff, step >> 16, g.count & 0xff, g.count >> 8],
459
+ });
460
+ }
461
+ }
462
+ return events;
463
+ }
464
+ export function encodeVoiceStream(events, total) {
465
+ const sorted = [...events].sort((a, b) => a.frame - b.frame || a.order - b.order);
466
+ const out = [];
467
+ const pushWait = (frames) => {
468
+ let left = frames;
469
+ while (left > 256) {
470
+ out.push(OP.WAITLONG, 0);
471
+ left -= 256;
472
+ }
473
+ if (left <= 0)
474
+ return;
475
+ if (left <= 64)
476
+ out.push(OP.WAIT + left - 1);
477
+ else
478
+ out.push(OP.WAITLONG, left & 0xff);
479
+ };
480
+ let cursor = 0;
481
+ for (const e of sorted) {
482
+ if (e.frame > cursor) {
483
+ pushWait(e.frame - cursor);
484
+ cursor = e.frame;
280
485
  }
281
- pushByteRows(L, `v${v + 1}_freq_lo`, fLo);
282
- pushByteRows(L, `v${v + 1}_freq_hi`, fHi);
283
- pushByteRows(L, `v${v + 1}_pw_lo`, pwLo);
284
- pushByteRows(L, `v${v + 1}_pw_hi`, pwHi);
285
- pushByteRows(L, `v${v + 1}_ctrl`, ctrl);
286
- pushByteRows(L, `v${v + 1}_ad`, ad);
287
- pushByteRows(L, `v${v + 1}_sr`, sr);
486
+ out.push(...e.bytes);
288
487
  }
488
+ pushWait(total - cursor);
489
+ out.push(OP.LOOP);
490
+ return out;
491
+ }
492
+ // ---------------------------------------------------------------------------
493
+ // 6502 player
494
+ function buildPlayerAsm(arr, streams) {
495
+ const L = [];
496
+ const insts = arr.instruments;
497
+ const clockIndex = arr.system === "NTSC" ? 1 : 0;
498
+ // The PRG's CIA timer reproduces the song's video frame rate on whichever clock the machine has.
499
+ const ciaLatch = (clock) => Math.round(clock / arr.fps) - 1;
500
+ const f = arr.filter;
501
+ const fBase = Math.max(0, Math.min(2047, f.base)) * 32;
502
+ const fEnd = Math.max(0, Math.min(2047, f.base + f.sweep)) * 32;
503
+ const fMin = Math.min(fBase, fEnd);
504
+ const fMax = Math.max(fBase, fEnd);
505
+ const fDir = f.sweep < 0 ? 1 : 0;
506
+ L.push(`ptr = $FB`);
507
+ L.push(`* = $0801`);
508
+ L.push(` .byte $0B,$08,$0A,$00,$9E,$32,$30,$36,$31,$00,$00,$00 ; 10 SYS2061`);
509
+ L.push(`install:`);
510
+ L.push(` jmp do_install`);
511
+ L.push(`init_vec:`);
512
+ L.push(` jmp init`);
513
+ L.push(`play_vec:`);
514
+ L.push(` jmp play`);
515
+ L.push(PLAYER_CODE);
516
+ // Constants
517
+ L.push(`doc_clock: .byte ${clockIndex}`);
518
+ L.push(`cia_lo: .byte <${ciaLatch(CLOCK_HZ.PAL)}, <${ciaLatch(CLOCK_HZ.NTSC)}`);
519
+ L.push(`cia_hi: .byte >${ciaLatch(CLOCK_HZ.PAL)}, >${ciaLatch(CLOCK_HZ.NTSC)}`);
520
+ L.push(`fenv_init: .byte <${fBase}, >${fBase}, ${fDir}`);
521
+ L.push(`fenv_step: .byte <${f.stepFixed}, >${f.stepFixed}`);
522
+ L.push(`fenv_min: .byte <${fMin}, >${fMin}`);
523
+ L.push(`fenv_max: .byte <${fMax}, >${fMax}`);
524
+ L.push(`res_route: .byte ${(f.resonance & 0x0f) << 4}`);
525
+ L.push(`fmode: .byte ${f.mode}`);
526
+ L.push(`voff: .byte 0, 7, 14`);
527
+ L.push(`vbit: .byte 1, 2, 4`);
528
+ L.push(`start_lo: .byte <stream0, <stream1, <stream2`);
529
+ L.push(`start_hi: .byte >stream0, >stream1, >stream2`);
530
+ L.push(`cmd_tab:`);
531
+ for (const name of ["c_gateoff", "c_hr", "c_sr", "c_vol", "c_waitl", "c_fmod", "c_loop"])
532
+ L.push(` .byte <(${name}-1), >(${name}-1)`);
533
+ // Instrument tables
534
+ const arpTable = [];
535
+ const arpStart = insts.map((inst) => {
536
+ if (inst.arp.length === 0)
537
+ return 0;
538
+ for (let s = 0; s + inst.arp.length <= arpTable.length; s += 1) {
539
+ if (inst.arp.every((v, i) => arpTable[s + i] === v))
540
+ return s;
541
+ }
542
+ const s = arpTable.length;
543
+ arpTable.push(...inst.arp);
544
+ return s;
545
+ });
546
+ if (arpTable.length > 256)
547
+ throw new SidwaveValidationError("$.voices", "arpeggio tables exceed 256 entries");
548
+ const col = (name, values) => pushByteRows(L, name, values.map((v) => `$${hex2(v)}`));
549
+ col("i_ad", insts.map((i) => i.ad));
550
+ col("i_sr", insts.map((i) => i.sr));
551
+ col("i_wave", insts.map((i) => i.wave));
552
+ col("i_pwlo", insts.map((i) => i.pw & 0xff));
553
+ col("i_pwhi", insts.map((i) => i.pw >> 8));
554
+ col("i_pwslo", insts.map((i) => i.pwStep & 0xff));
555
+ col("i_pwshi", insts.map((i) => i.pwStep >> 8));
556
+ col("i_pwminlo", insts.map((i) => i.pwMin & 0xff));
557
+ col("i_pwminhi", insts.map((i) => i.pwMin >> 8));
558
+ col("i_pwmaxlo", insts.map((i) => i.pwMax & 0xff));
559
+ col("i_pwmaxhi", insts.map((i) => i.pwMax >> 8));
560
+ col("i_vupm", insts.map((i) => i.vibUp[0]));
561
+ col("i_vupsh", insts.map((i) => i.vibUp[1]));
562
+ col("i_vdnm", insts.map((i) => i.vibDown[0]));
563
+ col("i_vdnsh", insts.map((i) => i.vibDown[1]));
564
+ col("i_vibrate", insts.map((i) => i.vibRate));
565
+ col("i_vibdel", insts.map((i) => i.vibDelay));
566
+ col("i_arpst", arpStart);
567
+ col("i_arplen", insts.map((i) => i.arp.length));
568
+ col("i_arpspd", insts.map((i) => Math.max(1, i.arpSpeed)));
569
+ col("i_slsemi", insts.map((i) => i.slideSemis & 0xff));
570
+ col("i_slspd", insts.map((i) => i.slideSpeed));
571
+ col("i_slreclo", insts.map((i) => i.slideRecip & 0xff));
572
+ col("i_slrechi", insts.map((i) => i.slideRecip >> 8));
573
+ col("i_filt", insts.map((i) => i.filter));
574
+ col("arptab", arpTable.length ? arpTable.map((v) => v & 0xff) : [0]);
575
+ // Quarter-square table for the 8x8 multiply: sq(x) = floor(x * x / 4), x = 0..511.
576
+ const squares = Array.from({ length: 512 }, (_, x) => Math.floor((x * x) / 4));
577
+ col("sq_lo", squares.map((v) => v & 0xff));
578
+ col("sq_hi", squares.map((v) => (v >> 8) & 0xff));
579
+ // Frequency tables C0..B7 for both clocks; init copies the right one into ftlo/fthi.
580
+ for (const sys of ["PAL", "NTSC"]) {
581
+ const values = Array.from({ length: 96 }, (_, n) => hzToSidFrequency(midiToHz(n + 12), sys));
582
+ col(`${sys.toLowerCase()}_lo`, values.map((v) => v & 0xff));
583
+ col(`${sys.toLowerCase()}_hi`, values.map((v) => v >> 8));
584
+ }
585
+ // RAM
586
+ L.push(`ftlo: .res 96`);
587
+ L.push(`fthi: .res 96`);
588
+ L.push(`vars:`);
589
+ for (const name of VOICE_VARS)
590
+ L.push(`v_${name}: .res 3`);
591
+ for (const name of GLOBAL_VARS)
592
+ L.push(`${name}: .res 1`);
593
+ L.push(`vars_end:`);
594
+ streams.forEach((s, i) => pushByteRows(L, `stream${i}`, s.map((b) => `$${hex2(b)}`)));
595
+ L.push(`prg_end:`);
289
596
  return L.join("\n");
290
597
  }
598
+ const VOICE_VARS = [
599
+ "ptrlo", "ptrhi", "wait", "note", "inst", "ctrl", "ad", "sr", "pwlo", "pwhi", "pwdir", "arppos", "arpcnt",
600
+ "vibph", "vibdel", "vuplo", "vuphi", "vdnlo", "vdnhi", "sofflo", "soffmd", "soffhi", "sstlo", "sstmd", "ssthi", "sdir", "scnt",
601
+ ];
602
+ const GLOBAL_VARS = [
603
+ "g_vol", "g_cutlo", "g_cuthi", "g_edir", "g_mofflo", "g_moffmd", "g_moffhi", "g_mstlo", "g_mstmd", "g_msthi", "g_mcntlo", "g_mcnthi",
604
+ "f_lo", "f_hi", "p0", "p1", "p2", "m_lo", "m_hi", "m_fac", "n_lo", "n_hi", "q0", "q1", "q2", "q3",
605
+ "ma", "mb", "r_lo", "r_hi", "ysave",
606
+ "tmp", "t_lo", "t_hi", "t_xx",
607
+ ];
608
+ // The player. X holds the voice index (0..2) inside the voice routine; Y is scratch.
609
+ const PLAYER_CODE = String.raw `
610
+ do_install:
611
+ sei
612
+ det1:
613
+ lda $D012
614
+ det2:
615
+ cmp $D012
616
+ beq det2
617
+ bmi det1
618
+ ldx #0
619
+ cmp #$20
620
+ bcs det_pal
621
+ ldx #1
622
+ det_pal:
623
+ lda cia_lo,x
624
+ sta $DC04
625
+ lda cia_hi,x
626
+ sta $DC05
627
+ lda $DC0E
628
+ and #$80
629
+ ora #$11
630
+ sta $DC0E
631
+ txa
632
+ jsr init_core
633
+ ; Move BASIC's program and variables behind the player (like NEW with a raised start of BASIC),
634
+ ; so BASIC stays usable after the SYS returns and new program lines cannot overwrite the player.
635
+ lda #0
636
+ sta prg_end
637
+ sta prg_end+1
638
+ sta prg_end+2
639
+ lda #<(prg_end+1)
640
+ sta $2B
641
+ lda #>(prg_end+1)
642
+ sta $2C
643
+ lda #<(prg_end+3)
644
+ sta $2D
645
+ sta $2F
646
+ sta $31
647
+ lda #>(prg_end+3)
648
+ sta $2E
649
+ sta $30
650
+ sta $32
651
+ lda #<irq
652
+ sta $0314
653
+ lda #>irq
654
+ sta $0315
655
+ cli
656
+ rts
657
+
658
+ irq:
659
+ jsr play
660
+ jmp $EA31
661
+
662
+ init:
663
+ lda doc_clock
664
+ init_core:
665
+ tax
666
+ lda $01
667
+ pha
668
+ lda #$36
669
+ sta $01
670
+ ldy #95
671
+ cpx #0
672
+ bne ini_ntsc
673
+ ini_pal:
674
+ lda pal_lo,y
675
+ sta ftlo,y
676
+ lda pal_hi,y
677
+ sta fthi,y
678
+ dey
679
+ bpl ini_pal
680
+ jmp ini_state
681
+ ini_ntsc:
682
+ lda ntsc_lo,y
683
+ sta ftlo,y
684
+ lda ntsc_hi,y
685
+ sta fthi,y
686
+ dey
687
+ bpl ini_ntsc
688
+ ini_state:
689
+ lda #0
690
+ ldx #$18
691
+ ini_sid:
692
+ sta $D400,x
693
+ dex
694
+ bpl ini_sid
695
+ ldx #vars_end-vars-1
696
+ ini_vars:
697
+ sta vars,x
698
+ dex
699
+ bpl ini_vars
700
+ ldx #2
701
+ ini_voice:
702
+ lda #1
703
+ sta v_wait,x
704
+ lda start_lo,x
705
+ sta v_ptrlo,x
706
+ lda start_hi,x
707
+ sta v_ptrhi,x
708
+ dex
709
+ bpl ini_voice
710
+ lda #15
711
+ sta g_vol
712
+ jsr env_reset
713
+ pla
714
+ sta $01
715
+ rts
716
+
717
+ play:
718
+ lda $01
719
+ pha
720
+ lda #$36
721
+ sta $01
722
+ lda ptr
723
+ pha
724
+ lda ptr+1
725
+ pha
726
+ ldx #0
727
+ jsr voice
728
+ ldx #1
729
+ jsr voice
730
+ ldx #2
731
+ jsr voice
732
+ jsr globals
733
+ pla
734
+ sta ptr+1
735
+ pla
736
+ sta ptr
737
+ pla
738
+ sta $01
739
+ rts
740
+
741
+ fetch:
742
+ ldy #0
743
+ lda (ptr),y
744
+ inc ptr
745
+ bne fetch_x
746
+ inc ptr+1
747
+ fetch_x:
748
+ rts
749
+
750
+ voice:
751
+ dec v_wait,x
752
+ beq v_seq
753
+ jmp v_regs
754
+ v_seq:
755
+ lda v_ptrlo,x
756
+ sta ptr
757
+ lda v_ptrhi,x
758
+ sta ptr+1
759
+ v_read:
760
+ jsr fetch
761
+ cmp #$60
762
+ bcs v_not_note
763
+ jmp note_on
764
+ v_not_note:
765
+ cmp #$A0
766
+ bcs v_not_inst
767
+ sbc #$5F ; carry is clear here, so this subtracts $60
768
+ sta v_inst,x
769
+ jmp v_read
770
+ v_not_inst:
771
+ cmp #$E0
772
+ bcs v_cmd
773
+ sbc #$9E ; carry is clear here, so this subtracts $9F (WAIT $A0 = 1 frame)
774
+ sta v_wait,x
775
+ jmp v_save
776
+ v_cmd:
777
+ sec
778
+ sbc #$E0
779
+ asl
780
+ tay
781
+ lda cmd_tab+1,y
782
+ pha
783
+ lda cmd_tab,y
784
+ pha
785
+ rts
786
+
787
+ c_gateoff:
788
+ lda v_ctrl,x
789
+ and #$FE
790
+ sta v_ctrl,x
791
+ jmp v_read
792
+ c_hr:
793
+ lda v_ctrl,x
794
+ and #$FE
795
+ sta v_ctrl,x
796
+ lda #0
797
+ sta v_ad,x
798
+ sta v_sr,x
799
+ jmp v_read
800
+ c_sr:
801
+ jsr fetch
802
+ sta v_sr,x
803
+ jmp v_read
804
+ c_vol:
805
+ jsr fetch
806
+ sta g_vol
807
+ jmp v_read
808
+ c_waitl:
809
+ jsr fetch
810
+ sta v_wait,x
811
+ jmp v_save
812
+ c_fmod:
813
+ jsr fetch
814
+ sta g_mstlo
815
+ jsr fetch
816
+ sta g_mstmd
817
+ jsr fetch
818
+ sta g_msthi
819
+ jsr fetch
820
+ sta g_mcntlo
821
+ jsr fetch
822
+ sta g_mcnthi
823
+ lda #0
824
+ sta g_mofflo
825
+ sta g_moffmd
826
+ sta g_moffhi
827
+ jmp v_read
828
+ c_loop:
829
+ lda start_lo,x
830
+ sta ptr
831
+ lda start_hi,x
832
+ sta ptr+1
833
+ cpx #0
834
+ bne c_loop_x
835
+ jsr env_reset
836
+ c_loop_x:
837
+ jmp v_read
838
+
839
+ ; the filter sweep restarts with the song so every pass sounds the same
840
+ env_reset:
841
+ lda fenv_init
842
+ sta g_cutlo
843
+ lda fenv_init+1
844
+ sta g_cuthi
845
+ lda fenv_init+2
846
+ sta g_edir
847
+ rts
848
+
849
+ v_save:
850
+ lda ptr
851
+ sta v_ptrlo,x
852
+ lda ptr+1
853
+ sta v_ptrhi,x
854
+ jmp v_regs
855
+
856
+ ; ---- note on: A = note index ----
857
+ note_on:
858
+ sta v_note,x
859
+ ldy v_inst,x
860
+ lda i_ad,y
861
+ sta v_ad,x
862
+ lda i_sr,y
863
+ sta v_sr,x
864
+ lda i_wave,y
865
+ ora #1
866
+ sta v_ctrl,x
867
+ lda i_pwlo,y
868
+ sta v_pwlo,x
869
+ lda i_pwhi,y
870
+ sta v_pwhi,x
871
+ lda i_arpspd,y
872
+ sta v_arpcnt,x
873
+ lda i_vibdel,y
874
+ sta v_vibdel,x
875
+ lda i_slspd,y
876
+ sta v_scnt,x
877
+ lda #0
878
+ sta v_pwdir,x
879
+ sta v_arppos,x
880
+ sta v_vibph,x
881
+ sta v_vuplo,x
882
+ sta v_vuphi,x
883
+ sta v_vdnlo,x
884
+ sta v_vdnhi,x
885
+ ; vibrato amplitudes above and below the note: ft[n] * mant >> (8 + shift)
886
+ lda i_vupm,y
887
+ beq vn_slide
888
+ ldy v_note,x
889
+ lda ftlo,y
890
+ sta m_lo
891
+ lda fthi,y
892
+ sta m_hi
893
+ ldy v_inst,x
894
+ lda i_vupm,y
895
+ sta m_fac
896
+ jsr mul
897
+ lda i_vupsh,y
898
+ jsr shr_p
899
+ lda p1
900
+ sta v_vuplo,x
901
+ lda p2
902
+ sta v_vuphi,x
903
+ lda i_vdnm,y
904
+ sta m_fac
905
+ jsr mul
906
+ lda i_vdnsh,y
907
+ jsr shr_p
908
+ lda p1
909
+ sta v_vdnlo,x
910
+ lda p2
911
+ sta v_vdnhi,x
912
+ vn_slide:
913
+ ldy v_inst,x
914
+ lda i_slsemi,y
915
+ bne vn_sl_on
916
+ jmp v_read
917
+ vn_sl_on:
918
+ ; the slide starts at note - semis (the compiler checks that it is in range)
919
+ sta tmp
920
+ lda v_note,x
921
+ sec
922
+ sbc tmp
923
+ tay
924
+ lda tmp
925
+ bmi vn_sl_above
926
+ ; start below: magnitude = ft[note] - ft[start]
927
+ lda #0
928
+ sta v_sdir,x
929
+ sty tmp
930
+ ldy v_note,x
931
+ sec
932
+ lda ftlo,y
933
+ ldy tmp
934
+ sbc ftlo,y
935
+ sta m_lo
936
+ ldy v_note,x
937
+ lda fthi,y
938
+ ldy tmp
939
+ sbc fthi,y
940
+ sta m_hi
941
+ jmp vn_sl_step
942
+ vn_sl_above:
943
+ ; start above: magnitude = ft[start] - ft[note]
944
+ lda #1
945
+ sta v_sdir,x
946
+ sty tmp
947
+ sec
948
+ lda ftlo,y
949
+ ldy v_note,x
950
+ sbc ftlo,y
951
+ sta m_lo
952
+ ldy tmp
953
+ lda fthi,y
954
+ ldy v_note,x
955
+ sbc fthi,y
956
+ sta m_hi
957
+ vn_sl_step:
958
+ ; offset = magnitude (16.8 fixed point); step per frame = magnitude * round(65536 / speed) / 256
959
+ lda #0
960
+ sta v_sofflo,x
961
+ lda m_lo
962
+ sta v_soffmd,x
963
+ lda m_hi
964
+ sta v_soffhi,x
965
+ ldy v_inst,x
966
+ lda i_slreclo,y
967
+ sta n_lo
968
+ lda i_slrechi,y
969
+ sta n_hi
970
+ jsr mul16
971
+ lda q1
972
+ sta v_sstlo,x
973
+ lda q2
974
+ sta v_sstmd,x
975
+ lda q3
976
+ sta v_ssthi,x
977
+ jmp v_read
978
+
979
+ ; ---- per-frame register output ----
980
+ v_regs:
981
+ ldy v_inst,x
982
+ lda i_arplen,y
983
+ beq vr_noarp
984
+ lda i_arpst,y
985
+ clc
986
+ adc v_arppos,x
987
+ tay
988
+ lda arptab,y
989
+ clc
990
+ adc v_note,x
991
+ jmp vr_note
992
+ vr_noarp:
993
+ lda v_note,x
994
+ vr_note:
995
+ tay
996
+ lda ftlo,y
997
+ sta f_lo
998
+ lda fthi,y
999
+ sta f_hi
1000
+ ; slide: the remaining offset, applied below or above the note
1001
+ lda v_scnt,x
1002
+ bne vr_sl_on
1003
+ jmp vr_vib
1004
+ vr_sl_on:
1005
+ lda v_soffmd,x
1006
+ sta t_lo
1007
+ lda v_soffhi,x
1008
+ sta t_hi
1009
+ vr_sl_apply:
1010
+ lda v_sdir,x
1011
+ bne vr_sl_up
1012
+ sec
1013
+ lda f_lo
1014
+ sbc t_lo
1015
+ sta f_lo
1016
+ lda f_hi
1017
+ sbc t_hi
1018
+ sta f_hi
1019
+ bcs vr_vib
1020
+ lda #0
1021
+ sta f_lo
1022
+ sta f_hi
1023
+ jmp vr_vib
1024
+ vr_sl_up:
1025
+ clc
1026
+ lda f_lo
1027
+ adc t_lo
1028
+ sta f_lo
1029
+ lda f_hi
1030
+ adc t_hi
1031
+ sta f_hi
1032
+ bcc vr_vib
1033
+ lda #$FF
1034
+ sta f_lo
1035
+ sta f_hi
1036
+ vr_vib:
1037
+ lda v_vuplo,x
1038
+ ora v_vuphi,x
1039
+ beq vr_novib
1040
+ lda v_vibdel,x
1041
+ beq vr_vib_on
1042
+ vr_novib:
1043
+ jmp vr_write
1044
+ vr_vib_on:
1045
+ lda v_vibph,x
1046
+ and #$7F
1047
+ cmp #$40
1048
+ bcc vr_tri
1049
+ eor #$7F
1050
+ vr_tri:
1051
+ asl
1052
+ sta m_fac
1053
+ lda v_vibph,x
1054
+ bmi vr_vib_dn
1055
+ lda v_vuplo,x
1056
+ sta m_lo
1057
+ lda v_vuphi,x
1058
+ sta m_hi
1059
+ jsr mul
1060
+ asl p0
1061
+ rol p1
1062
+ rol p2
1063
+ ; add round(amplitude * tri / 128): the carry holds the rounding bit
1064
+ lda p0
1065
+ cmp #$80
1066
+ lda f_lo
1067
+ adc p1
1068
+ sta f_lo
1069
+ lda f_hi
1070
+ adc p2
1071
+ sta f_hi
1072
+ bcc vr_write
1073
+ lda #$FF
1074
+ sta f_lo
1075
+ sta f_hi
1076
+ jmp vr_write
1077
+ vr_vib_dn:
1078
+ lda v_vdnlo,x
1079
+ sta m_lo
1080
+ lda v_vdnhi,x
1081
+ sta m_hi
1082
+ jsr mul
1083
+ asl p0
1084
+ rol p1
1085
+ rol p2
1086
+ ; subtract round(amplitude * tri / 128): a clear carry borrows the rounding bit
1087
+ lda #$7F
1088
+ cmp p0
1089
+ lda f_lo
1090
+ sbc p1
1091
+ sta f_lo
1092
+ lda f_hi
1093
+ sbc p2
1094
+ sta f_hi
1095
+ bcs vr_write
1096
+ lda #0
1097
+ sta f_lo
1098
+ sta f_hi
1099
+ vr_write:
1100
+ ldy voff,x
1101
+ lda f_lo
1102
+ sta $D400,y
1103
+ lda f_hi
1104
+ sta $D401,y
1105
+ lda v_pwlo,x
1106
+ sta $D402,y
1107
+ lda v_pwhi,x
1108
+ sta $D403,y
1109
+ lda v_ad,x
1110
+ sta $D405,y
1111
+ lda v_sr,x
1112
+ sta $D406,y
1113
+ lda v_ctrl,x
1114
+ sta $D404,y
1115
+
1116
+ ; ---- advance modulators for the next frame ----
1117
+ ldy v_inst,x
1118
+ lda i_arplen,y
1119
+ beq va_slide
1120
+ dec v_arpcnt,x
1121
+ bne va_slide
1122
+ lda i_arpspd,y
1123
+ sta v_arpcnt,x
1124
+ lda v_arppos,x
1125
+ clc
1126
+ adc #1
1127
+ cmp i_arplen,y
1128
+ bcc va_arp_ok
1129
+ lda #0
1130
+ va_arp_ok:
1131
+ sta v_arppos,x
1132
+ va_slide:
1133
+ lda v_scnt,x
1134
+ beq va_vib
1135
+ dec v_scnt,x
1136
+ sec
1137
+ lda v_sofflo,x
1138
+ sbc v_sstlo,x
1139
+ sta v_sofflo,x
1140
+ lda v_soffmd,x
1141
+ sbc v_sstmd,x
1142
+ sta v_soffmd,x
1143
+ lda v_soffhi,x
1144
+ sbc v_ssthi,x
1145
+ sta v_soffhi,x
1146
+ bcs va_vib
1147
+ lda #0
1148
+ sta v_soffmd,x
1149
+ sta v_soffhi,x
1150
+ va_vib:
1151
+ lda v_vibdel,x
1152
+ beq va_vib_run
1153
+ dec v_vibdel,x
1154
+ jmp va_pw
1155
+ va_vib_run:
1156
+ lda v_vibph,x
1157
+ clc
1158
+ adc i_vibrate,y
1159
+ sta v_vibph,x
1160
+ va_pw:
1161
+ lda i_pwslo,y
1162
+ ora i_pwshi,y
1163
+ beq va_done
1164
+ lda v_pwdir,x
1165
+ bne va_pw_down
1166
+ clc
1167
+ lda v_pwlo,x
1168
+ adc i_pwslo,y
1169
+ sta v_pwlo,x
1170
+ lda v_pwhi,x
1171
+ adc i_pwshi,y
1172
+ sta v_pwhi,x
1173
+ cmp i_pwmaxhi,y
1174
+ bcc va_done
1175
+ bne va_pw_max
1176
+ lda v_pwlo,x
1177
+ cmp i_pwmaxlo,y
1178
+ bcc va_done
1179
+ va_pw_max:
1180
+ lda i_pwmaxlo,y
1181
+ sta v_pwlo,x
1182
+ lda i_pwmaxhi,y
1183
+ sta v_pwhi,x
1184
+ lda #1
1185
+ sta v_pwdir,x
1186
+ rts
1187
+ va_pw_down:
1188
+ sec
1189
+ lda v_pwlo,x
1190
+ sbc i_pwslo,y
1191
+ sta v_pwlo,x
1192
+ lda v_pwhi,x
1193
+ sbc i_pwshi,y
1194
+ sta v_pwhi,x
1195
+ bcc va_pw_min
1196
+ cmp i_pwminhi,y
1197
+ bcc va_pw_min
1198
+ bne va_done
1199
+ lda v_pwlo,x
1200
+ cmp i_pwminlo,y
1201
+ beq va_pw_min
1202
+ bcs va_done
1203
+ va_pw_min:
1204
+ lda i_pwminlo,y
1205
+ sta v_pwlo,x
1206
+ lda i_pwminhi,y
1207
+ sta v_pwhi,x
1208
+ lda #0
1209
+ sta v_pwdir,x
1210
+ va_done:
1211
+ rts
1212
+
1213
+ ; ---- p2:p1 = round(p2:p1:p0 / 2^(8 + A)) (A = 0..15) ----
1214
+ shr_p:
1215
+ sta tmp
1216
+ shr_l:
1217
+ lda tmp
1218
+ beq shr_x
1219
+ lsr p2
1220
+ ror p1
1221
+ ror p0
1222
+ dec tmp
1223
+ jmp shr_l
1224
+ shr_x:
1225
+ lda p0
1226
+ cmp #$80
1227
+ lda p1
1228
+ adc #0
1229
+ sta p1
1230
+ lda p2
1231
+ adc #0
1232
+ sta p2
1233
+ rts
1234
+
1235
+ ; ---- 8x8 multiply: ma * mb -> r_hi:r_lo, using a*b = sq(a+b) - sq(|a-b|) with sq(x) = floor(x*x/4) ----
1236
+ ; Clobbers A and Y; preserves X, ma and mb.
1237
+ mul8:
1238
+ lda ma
1239
+ clc
1240
+ adc mb
1241
+ tay
1242
+ bcc mul8_low
1243
+ lda sq_lo+256,y
1244
+ sta r_lo
1245
+ lda sq_hi+256,y
1246
+ sta r_hi
1247
+ jmp mul8_diff
1248
+ mul8_low:
1249
+ lda sq_lo,y
1250
+ sta r_lo
1251
+ lda sq_hi,y
1252
+ sta r_hi
1253
+ mul8_diff:
1254
+ lda ma
1255
+ sec
1256
+ sbc mb
1257
+ bcs mul8_pos
1258
+ eor #$FF
1259
+ adc #1
1260
+ mul8_pos:
1261
+ tay
1262
+ sec
1263
+ lda r_lo
1264
+ sbc sq_lo,y
1265
+ sta r_lo
1266
+ lda r_hi
1267
+ sbc sq_hi,y
1268
+ sta r_hi
1269
+ rts
1270
+
1271
+ ; ---- 16x8 multiply: m_hi:m_lo * m_fac -> p2:p1:p0 (preserves X, Y and m) ----
1272
+ mul:
1273
+ sty ysave
1274
+ lda m_fac
1275
+ sta mb
1276
+ lda m_lo
1277
+ sta ma
1278
+ jsr mul8
1279
+ lda r_lo
1280
+ sta p0
1281
+ lda r_hi
1282
+ sta p1
1283
+ lda m_hi
1284
+ sta ma
1285
+ jsr mul8
1286
+ clc
1287
+ lda p1
1288
+ adc r_lo
1289
+ sta p1
1290
+ lda r_hi
1291
+ adc #0
1292
+ sta p2
1293
+ ldy ysave
1294
+ rts
1295
+
1296
+ ; ---- 16x16 multiply: m_hi:m_lo * n_hi:n_lo -> q3:q2:q1:q0 (preserves X, Y, m and n) ----
1297
+ mul16:
1298
+ sty ysave
1299
+ lda m_lo
1300
+ sta ma
1301
+ lda n_lo
1302
+ sta mb
1303
+ jsr mul8
1304
+ lda r_lo
1305
+ sta q0
1306
+ lda r_hi
1307
+ sta q1
1308
+ lda m_hi
1309
+ sta ma
1310
+ jsr mul8
1311
+ clc
1312
+ lda q1
1313
+ adc r_lo
1314
+ sta q1
1315
+ lda r_hi
1316
+ adc #0
1317
+ sta q2
1318
+ lda n_hi
1319
+ sta mb
1320
+ jsr mul8
1321
+ clc
1322
+ lda q2
1323
+ adc r_lo
1324
+ sta q2
1325
+ lda r_hi
1326
+ adc #0
1327
+ sta q3
1328
+ lda m_lo
1329
+ sta ma
1330
+ jsr mul8
1331
+ clc
1332
+ lda q1
1333
+ adc r_lo
1334
+ sta q1
1335
+ lda q2
1336
+ adc r_hi
1337
+ sta q2
1338
+ lda q3
1339
+ adc #0
1340
+ sta q3
1341
+ ldy ysave
1342
+ rts
1343
+
1344
+ ; ---- filter, routing and volume ----
1345
+ globals:
1346
+ ; cutoff = sweep position (unsigned 16-bit) + filter_mod offset (signed 24-bit), clamped to 0..2047 x 32
1347
+ clc
1348
+ lda g_cutlo
1349
+ adc g_mofflo
1350
+ sta t_lo
1351
+ lda g_cuthi
1352
+ adc g_moffmd
1353
+ sta t_hi
1354
+ lda #0
1355
+ adc g_moffhi
1356
+ sta t_xx
1357
+ bmi gl_low
1358
+ beq gl_out
1359
+ lda #$E0
1360
+ sta t_lo
1361
+ lda #$FF
1362
+ sta t_hi
1363
+ jmp gl_out
1364
+ gl_low:
1365
+ lda #0
1366
+ sta t_lo
1367
+ sta t_hi
1368
+ gl_out:
1369
+ lda t_lo
1370
+ lsr
1371
+ lsr
1372
+ lsr
1373
+ lsr
1374
+ lsr
1375
+ sta $D415
1376
+ lda t_hi
1377
+ sta $D416
1378
+ lda #0
1379
+ sta tmp
1380
+ ldx #2
1381
+ gl_route:
1382
+ ldy v_inst,x
1383
+ lda i_filt,y
1384
+ beq gl_route_n
1385
+ lda vbit,x
1386
+ ora tmp
1387
+ sta tmp
1388
+ gl_route_n:
1389
+ dex
1390
+ bpl gl_route
1391
+ lda tmp
1392
+ ora res_route
1393
+ sta $D417
1394
+ lda g_vol
1395
+ ora fmode
1396
+ sta $D418
1397
+ ; filter ramp from filter_mod
1398
+ lda g_mcntlo
1399
+ ora g_mcnthi
1400
+ beq gl_env
1401
+ clc
1402
+ lda g_mofflo
1403
+ adc g_mstlo
1404
+ sta g_mofflo
1405
+ lda g_moffmd
1406
+ adc g_mstmd
1407
+ sta g_moffmd
1408
+ lda g_moffhi
1409
+ adc g_msthi
1410
+ sta g_moffhi
1411
+ lda g_mcntlo
1412
+ bne gl_dec
1413
+ dec g_mcnthi
1414
+ gl_dec:
1415
+ dec g_mcntlo
1416
+ gl_env:
1417
+ lda fenv_step
1418
+ ora fenv_step+1
1419
+ beq gl_done
1420
+ lda g_edir
1421
+ bne gl_env_down
1422
+ clc
1423
+ lda g_cutlo
1424
+ adc fenv_step
1425
+ sta g_cutlo
1426
+ lda g_cuthi
1427
+ adc fenv_step+1
1428
+ sta g_cuthi
1429
+ bcs gl_env_top
1430
+ cmp fenv_max+1
1431
+ bcc gl_done
1432
+ bne gl_env_top
1433
+ lda g_cutlo
1434
+ cmp fenv_max
1435
+ bcc gl_done
1436
+ gl_env_top:
1437
+ lda fenv_max
1438
+ sta g_cutlo
1439
+ lda fenv_max+1
1440
+ sta g_cuthi
1441
+ lda #1
1442
+ sta g_edir
1443
+ rts
1444
+ gl_env_down:
1445
+ sec
1446
+ lda g_cutlo
1447
+ sbc fenv_step
1448
+ sta g_cutlo
1449
+ lda g_cuthi
1450
+ sbc fenv_step+1
1451
+ sta g_cuthi
1452
+ bcc gl_env_bot
1453
+ cmp fenv_min+1
1454
+ bcc gl_env_bot
1455
+ bne gl_done
1456
+ lda g_cutlo
1457
+ cmp fenv_min
1458
+ beq gl_env_bot
1459
+ bcs gl_done
1460
+ gl_env_bot:
1461
+ lda fenv_min
1462
+ sta g_cutlo
1463
+ lda fenv_min+1
1464
+ sta g_cuthi
1465
+ lda #0
1466
+ sta g_edir
1467
+ gl_done:
1468
+ rts
1469
+ `;
291
1470
  function pushByteRows(out, label, items, perLine = 16) {
292
- out.push("");
293
1471
  out.push(`${label}:`);
294
1472
  for (let i = 0; i < items.length; i += perLine) {
295
- const row = items.slice(i, i + perLine).join(",");
296
- out.push(` .byte ${row}`);
1473
+ out.push(` .byte ${items.slice(i, i + perLine).join(",")}`);
297
1474
  }
298
1475
  }
299
1476
  function hex2(n) {
300
1477
  return (n & 0xff).toString(16).toUpperCase().padStart(2, "0");
301
1478
  }
302
- function hex16(n) {
303
- return (n & 0xffff).toString(16).toUpperCase().padStart(4, "0");
304
- }
305
- function transposeNote(note, semitones) {
306
- const m = /^([A-Ga-g])([#b]?)(-?\d+)$/.exec((note || "").trim());
307
- if (!m)
308
- return note;
309
- const letter = m[1].toUpperCase();
310
- const accidental = m[2];
311
- const octave = Number(m[3]);
312
- const map = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
313
- let semi = map[letter] ?? 0;
314
- if (accidental === "#")
315
- semi += 1;
316
- if (accidental === "b")
317
- semi -= 1;
318
- let midi = (octave + 1) * 12 + semi + semitones;
319
- const letters = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
320
- const newOct = Math.floor(midi / 12) - 1;
321
- const name = letters[((midi % 12) + 12) % 12];
322
- return `${name}${newOct}`;
323
- }