import test from "node:test"; import assert from "node:assert/strict"; import { compilePlaybackTimeline, PlaybackController, RecordingPlaybackSink } from "../dist/playback/playback.js"; import { createEmptyDocument, createNoteEvent, rational } from "../dist/domain/score-model.js"; function note(id, degree, raw = id) { const event = createNoteEvent(id, degree, rational(1)); event.source = { range: null, raw }; return event; } function measure(id, ordinal, events, patch = {}) { return { id, ordinal, beats: rational(1), events, ...patch }; } test("M8 expands repeat start/end and first/second endings without an unbounded loop", () => { const document = createEmptyDocument("m8-repeat"); document.voices.push({ id: "voice-repeat", name: "反复", sectionId: null, visible: true, measures: [ measure("m1", 0, [note("n1", 1)], { repeatStart: true }), measure("m2", 1, [note("n2", 2)], { repeatEnd: true, ending: 1 }), measure("m3", 2, [note("n3", 3)], { ending: 2 }) ] }); const timeline = compilePlaybackTimeline(document); assert.deepEqual(timeline.events.filter((event) => event.kind === "note").map((event) => event.sourceEvent.id), ["n1", "n2", "n1", "n3"]); assert.equal(timeline.durationTicks, 3840); assert.equal(timeline.loopIterations, 2); assert.equal(timeline.warnings.some((warning) => warning.includes("安全迭代")), false); }); test("M8 handles DC/DS navigation with a bounded jump and exposes control state", () => { const document = createEmptyDocument("m8-navigation"); const dc = note("dc", 2, "{DC}"); dc.decorations = [{ id: "dc-mark", kind: "DC", raw: "{DC}", playback: "visual", sourceRange: null }]; const ds = note("ds", 3, "{DS}"); ds.decorations = [{ id: "ds-mark", kind: "DS", raw: "{DS}", playback: "visual", sourceRange: null }]; document.voices.push({ id: "voice-nav", name: "导航", sectionId: null, visible: true, measures: [measure("nav-1", 0, [note("nav-1-note", 1)], { repeatStart: true }), measure("nav-2", 1, [dc]), measure("nav-3", 2, [ds])] }); const timeline = compilePlaybackTimeline(document); assert.ok(timeline.events.length >= 6); assert.ok(timeline.controlEvents?.some((event) => event.type === "navigation" && event.value === "DC")); assert.ok(timeline.loopIterations >= 2); }); test("M8 restores tempo, meter and key state at control ticks", () => { const document = createEmptyDocument("m8-controls"); document.title.tempo = 60; document.title.meter = "4/4"; document.title.key = "C"; document.voices.push({ id: "voice-controls", name: "控制", sectionId: null, visible: true, measures: [ measure("control-1", 0, [note("control-1-note", 1)]), measure("control-2", 1, [note("control-2-note", 1)], { tempoBpm: 120, meter: { numerator: 3, denominator: 4 }, key: "G" }) ] }); const timeline = compilePlaybackTimeline(document); assert.equal(timeline.stateAtTick(0).tempoBpm, 60); assert.equal(timeline.stateAtTick(960).tempoBpm, 120); assert.deepEqual(timeline.stateAtTick(960).meter, { numerator: 3, denominator: 4 }); assert.equal(timeline.stateAtTick(960).key, "G"); assert.ok(Math.abs(timeline.secondsAtTick(1920) - 1.5) < 1e-12); assert.ok(Math.abs(timeline.ticksAtSeconds(1.5) - 1920) < 1e-9); }); test("M8 maps W1 dynamics, staccato, tuplets and chord tones to playback events", () => { const document = createEmptyDocument("m8-w1"); const event = note("w1-note", 1, "1"); event.chord = [{ degree: 1, accidental: 0, octave: 0 }, { degree: 3, accidental: 0, octave: 0 }]; event.tuplet = { id: "tuplet-1", ratio: 3, ordinal: 0, raw: "{(3}" }; event.decorations = [ { id: "dynamic", kind: "mf", raw: "{mf}", playback: "w1", sourceRange: null }, { id: "staccato", kind: "staccato", raw: "{staccato}", playback: "w1", sourceRange: null } ]; document.voices.push({ id: "voice-w1", name: "W1", sectionId: null, visible: true, measures: [measure("w1-measure", 0, [event])] }); const timeline = compilePlaybackTimeline(document); const notes = timeline.events.filter((candidate) => candidate.kind === "note"); assert.equal(notes.length, 2); assert.equal(notes[0].durationTicks, 640); assert.equal(notes[0].gateTicks, 320); assert.equal(notes[0].velocity, 82); assert.ok(timeline.controlEvents?.some((control) => control.type === "velocity")); }); test("M8 controller schedules gate duration from the tempo segment containing the note", async () => { const document = createEmptyDocument("m8-controller"); document.title.tempo = 60; document.voices.push({ id: "voice", name: "控制器", sectionId: null, visible: true, measures: [measure("m1", 0, [note("n1", 1)]), measure("m2", 1, [note("n2", 2)], { tempoBpm: 120 })] }); const timeline = compilePlaybackTimeline(document); const sink = new RecordingPlaybackSink(); const controller = new PlaybackController(timeline, { sink }); await controller.play(); assert.equal(sink.scheduled.length, 2); assert.ok(Math.abs(sink.scheduled[1].durationSeconds - 0.5) < 1e-12); controller.destroy(); });