92 lines
4.9 KiB
JavaScript
92 lines
4.9 KiB
JavaScript
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();
|
|
});
|