feat: publish JPW7 web editor
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const run = (command, args) => execFileSync(command, args, { encoding: "utf8", stdio: "pipe" });
|
||||
|
||||
test("M0 baseline check passes", () => {
|
||||
const output = run(process.execPath, ["scripts/m0-check.mjs"]);
|
||||
const result = JSON.parse(output);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.samplePairs, 33);
|
||||
});
|
||||
|
||||
test("M0 build emits a browser entry point", async () => {
|
||||
const source = await readFile("dist/main.js", "utf8");
|
||||
const page = await readFile("dist/index.html", "utf8");
|
||||
assert.match(source, /mountEditor/);
|
||||
assert.match(page, /main\.js/);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { encodeSource, hasExactRoundTrip, parseJpwabc, serializeBytes, serializeText } from "../dist/domain/jpwabc.js";
|
||||
|
||||
const readRaw = (path) => readFile(path);
|
||||
|
||||
test("M1 decodes UTF-16LE/BOM and preserves the 康定情歌 source", async () => {
|
||||
const result = parseJpwabc(await readRaw("research/jpw7/demo/JP-Word练习曲-歌曲-康定情歌.jpwabc"));
|
||||
assert.equal(result.source.encoding, "utf-16le");
|
||||
assert.equal(result.source.bom, "utf-16le");
|
||||
assert.equal(result.header.formatVersion, "1.0");
|
||||
assert.equal(result.header.productVersion, "7.30");
|
||||
assert.deepEqual(result.sections.map((section) => section.name), ["<preamble>", ".Options", ".Fonts", ".Title", ".Voice", ".Words", ".Attachments", ".Page"]);
|
||||
assert.ok(result.options.document.length >= 0);
|
||||
assert.ok(result.fonts.length > 0);
|
||||
assert.ok(result.titles.some((entry) => entry.field === "Title" && entry.value === "康定情歌"));
|
||||
assert.ok(result.voices.some((line) => line.tokens.some((token) => token.kind === "note")));
|
||||
assert.ok(result.voices.some((line) => line.tokens.some((token) => token.kind === "barline")));
|
||||
assert.ok(result.words.length >= 4);
|
||||
assert.ok(result.attachments.length > 0);
|
||||
assert.equal(hasExactRoundTrip(result), true);
|
||||
assert.equal(serializeText(result), result.source.text);
|
||||
assert.deepEqual([...serializeBytes(result)], [...result.source.bytes]);
|
||||
const reparsed = parseJpwabc(serializeBytes(result));
|
||||
assert.equal(serializeText(reparsed), serializeText(result));
|
||||
assert.deepEqual([...serializeBytes(reparsed)], [...result.source.bytes]);
|
||||
});
|
||||
|
||||
test("M1 detects UTF-16BE and UTF-8 BOM variants", () => {
|
||||
const source = ".Title\r\nTitle = 编码探针\r\n";
|
||||
const bigEndian = parseJpwabc(encodeSource(source, "utf-16be", "utf-16be"));
|
||||
const utf8 = parseJpwabc(encodeSource(source, "utf-8", "utf-8"));
|
||||
assert.equal(bigEndian.source.encoding, "utf-16be");
|
||||
assert.equal(bigEndian.source.bom, "utf-16be");
|
||||
assert.equal(utf8.source.encoding, "utf-8");
|
||||
assert.equal(utf8.source.bom, "utf-8");
|
||||
assert.equal(hasExactRoundTrip(bigEndian), true);
|
||||
assert.equal(hasExactRoundTrip(utf8), true);
|
||||
});
|
||||
|
||||
test("M1 retains unknown sections, unknown options and source ranges", () => {
|
||||
const source = [
|
||||
"// ******** JPW-ABC File Ver 9.9 ********",
|
||||
"",
|
||||
".Options",
|
||||
"KnownLater = {opaque:value}",
|
||||
"",
|
||||
".MysteryBlock",
|
||||
"Payload {vendor:private} ",
|
||||
"",
|
||||
".Voice",
|
||||
"1_ {vendor:private} | 0__",
|
||||
""
|
||||
].join("\r\n");
|
||||
const result = parseJpwabc(encodeSource(source, "utf-16le", "utf-16le"));
|
||||
assert.equal(result.source.encoding, "utf-16le");
|
||||
assert.equal(result.sections.find((section) => section.name === ".MysteryBlock")?.known, false);
|
||||
assert.ok(result.options.unknownDocument.some((entry) => entry.key === "KnownLater"));
|
||||
assert.ok(result.unknownBlocks.some((block) => block.reason === "unknown-section"));
|
||||
assert.ok(result.unknownTokens.some((token) => token.head === "vendor"));
|
||||
assert.ok(result.diagnostics.some((item) => item.code === "UNKNOWN_SECTION"));
|
||||
assert.equal(result.compatibility.originalExport, "blocked");
|
||||
assert.equal(hasExactRoundTrip(result), true);
|
||||
assert.ok(result.sections.every((section) => section.range.byteEnd >= section.range.byteStart));
|
||||
});
|
||||
|
||||
test("M1 reports malformed structures without dropping their bytes", () => {
|
||||
const source = ".Voice\n1_ {unclosed\n";
|
||||
const result = parseJpwabc(encodeSource(source, "utf-8", "none"));
|
||||
assert.ok(result.diagnostics.some((item) => item.code === "UNCLOSED_BRACE"));
|
||||
assert.equal(result.compatibility.projectSave, "blocked");
|
||||
assert.equal(result.compatibility.originalExport, "blocked");
|
||||
assert.equal(hasExactRoundTrip(result), true);
|
||||
});
|
||||
|
||||
test("M1 classification report covers all 33 fixtures", async () => {
|
||||
const report = JSON.parse(await readFile("acceptance/m1/sample-classification.json", "utf8"));
|
||||
assert.equal(report.sampleCountExpected, 33);
|
||||
assert.equal(report.sampleCountActual, 33);
|
||||
assert.equal(report.allSourceBytesPreserved, true);
|
||||
assert.equal(report.failures.length, 0);
|
||||
assert.equal(report.samples.length, 33);
|
||||
assert.ok(report.samples.every((sample) => sample.status && sample.sourcePreserved === true));
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import {
|
||||
createEmptyDocument,
|
||||
createNoteEvent,
|
||||
createRestEvent,
|
||||
rational,
|
||||
modelEquivalent,
|
||||
modelFromParse,
|
||||
validateScore
|
||||
} from "../dist/domain/score-model.js";
|
||||
import { CommandRejectedError, CommandStore } from "../dist/domain/command-store.js";
|
||||
import { decodeProject, encodeProject, ProjectCodecError, projectEquivalentAfterReopen } from "../dist/domain/project-codec.js";
|
||||
import { parseJpwabc } from "../dist/domain/jpwabc.js";
|
||||
|
||||
function makeDocument() {
|
||||
const note1 = createNoteEvent("event-note-1", 1, rational(1));
|
||||
const note2 = createNoteEvent("event-note-2", 2, rational(1));
|
||||
const note3 = createNoteEvent("event-note-3", 3, rational(1));
|
||||
const rest = createRestEvent("event-rest-1", rational(1));
|
||||
const text = { id: "event-text-1", kind: "text", pitch: null, duration: null, text: "表情", source: null };
|
||||
const measure = { id: "measure-1", ordinal: 0, beats: rational(4), events: [note1, note2, note3, rest, text] };
|
||||
const voice = { id: "voice-1", name: "主旋律", sectionId: null, visible: true, measures: [measure] };
|
||||
const document = createEmptyDocument("document-test");
|
||||
document.title.title = "测试曲";
|
||||
document.voices.push(voice);
|
||||
document.attachments.push({
|
||||
id: "attachment-1",
|
||||
kind: "Text",
|
||||
raw: "Text@event-text-1 = 表情",
|
||||
anchor: { raw: "event-text-1", startId: "event-text-1", endId: null },
|
||||
status: "attached",
|
||||
sourceRange: null
|
||||
});
|
||||
return { document, note1, text, voice, measure };
|
||||
}
|
||||
|
||||
test("M2 validates durations and rejects invalid edits atomically", () => {
|
||||
const { document, voice, measure } = makeDocument();
|
||||
const store = new CommandStore(document);
|
||||
const before = store.snapshot();
|
||||
assert.equal(store.dirty, false);
|
||||
assert.throws(() => store.execute({
|
||||
type: "insert-event",
|
||||
voiceId: voice.id,
|
||||
measureId: measure.id,
|
||||
event: createNoteEvent("event-invalid", 5, rational(2))
|
||||
}), (error) => error instanceof CommandRejectedError && error.diagnostics.some((item) => item.code === "MEASURE_DURATION_MISMATCH"));
|
||||
assert.equal(modelEquivalent(store.snapshot(), before), true);
|
||||
assert.equal(store.dirty, false);
|
||||
});
|
||||
|
||||
test("M2 insertion, modification, deletion, undo and redo preserve stable IDs", () => {
|
||||
const { document, note1, voice, measure } = makeDocument();
|
||||
const store = new CommandStore(document);
|
||||
store.execute({
|
||||
type: "insert-event",
|
||||
voiceId: voice.id,
|
||||
measureId: measure.id,
|
||||
index: 0,
|
||||
event: { id: "event-text-2", kind: "text", pitch: null, duration: null, text: "新文字", source: null }
|
||||
});
|
||||
store.execute({ type: "update-event", eventId: note1.id, patch: { pitch: { degree: 7, accidental: 1, octave: 1 } } });
|
||||
store.execute({ type: "delete-event", eventId: "event-text-2" });
|
||||
assert.equal(store.snapshot().voices[0].measures[0].events.some((event) => event.id === "event-text-2"), false);
|
||||
assert.equal(store.snapshot().voices[0].measures[0].events.find((event) => event.id === note1.id)?.pitch?.degree, 7);
|
||||
store.undo();
|
||||
assert.equal(store.snapshot().voices[0].measures[0].events.some((event) => event.id === "event-text-2"), true);
|
||||
assert.equal(store.snapshot().voices[0].measures[0].events.find((event) => event.id === note1.id)?.id, note1.id);
|
||||
store.redo();
|
||||
assert.equal(store.snapshot().voices[0].measures[0].events.some((event) => event.id === "event-text-2"), false);
|
||||
assert.equal(store.canUndo, true);
|
||||
assert.equal(store.canRedo, false);
|
||||
});
|
||||
|
||||
test("M2 deleting an anchored non-timed object retains an orphan diagnostic", () => {
|
||||
const { document, text } = makeDocument();
|
||||
const store = new CommandStore(document);
|
||||
store.execute({ type: "delete-event", eventId: text.id });
|
||||
const attachment = store.snapshot().attachments[0];
|
||||
assert.equal(attachment.id, "attachment-1");
|
||||
assert.equal(attachment.anchor.startId, null);
|
||||
assert.equal(attachment.status, "orphaned");
|
||||
assert.ok(store.model.validate().diagnostics.some((item) => item.code === "ORPHANED_ANCHOR"));
|
||||
});
|
||||
|
||||
test("M2 ProjectCodec saves and reopens the current model without undo history", () => {
|
||||
const { document, note1, voice, measure } = makeDocument();
|
||||
const store = new CommandStore(document);
|
||||
store.execute({ type: "set-title", field: "title", value: "编辑后曲名" });
|
||||
store.execute({ type: "update-event", eventId: note1.id, patch: { text: "高音" } });
|
||||
store.markSaved();
|
||||
assert.equal(store.dirty, false);
|
||||
const text = encodeProject(store.snapshot());
|
||||
const reopened = decodeProject(text);
|
||||
assert.equal(projectEquivalentAfterReopen(store.snapshot(), reopened), true);
|
||||
assert.equal(reopened.documentId, "document-test");
|
||||
assert.equal(reopened.title.title, "编辑后曲名");
|
||||
assert.equal(reopened.voices[0].measures[0].events.find((event) => event.id === note1.id)?.text, "高音");
|
||||
assert.doesNotMatch(text, /undoStack|redoStack|LayoutSnapshot|diagnostics/);
|
||||
assert.equal(encodeProject(reopened), text);
|
||||
assert.equal(store.canUndo, true);
|
||||
assert.equal(store.canRedo, false);
|
||||
assert.equal(voice.id, "voice-1");
|
||||
assert.equal(measure.id, "measure-1");
|
||||
});
|
||||
|
||||
test("M2 ProjectCodec rejects unknown schema fields and invalid nested types", () => {
|
||||
const { document } = makeDocument();
|
||||
const project = JSON.parse(encodeProject(document));
|
||||
project.futureField = true;
|
||||
assert.throws(() => decodeProject(JSON.stringify(project)), (error) => error instanceof ProjectCodecError && error.issues.some((issue) => issue.code === "UNKNOWN_FIELD"));
|
||||
delete project.futureField;
|
||||
project.schemaVersion = 99;
|
||||
assert.throws(() => decodeProject(JSON.stringify(project)), (error) => error instanceof ProjectCodecError && error.issues.some((issue) => issue.code === "UNSUPPORTED_SCHEMA"));
|
||||
const invalidDuration = JSON.parse(encodeProject(document));
|
||||
invalidDuration.score.voices[0].measures[0].events[0].duration = "not-a-rational";
|
||||
assert.throws(() => decodeProject(JSON.stringify(invalidDuration)), (error) => error instanceof ProjectCodecError && error.issues.some((issue) => issue.code === "INVALID_TYPE"));
|
||||
});
|
||||
|
||||
test("M2 converts the parsed JPW-ABC AST into stable editable model IDs", async () => {
|
||||
const bytes = await readFile("research/jpw7/demo/JP-Word练习曲-歌曲-康定情歌.jpwabc");
|
||||
const parsed = parseJpwabc(bytes);
|
||||
const first = modelFromParse(parsed, { fileName: "康定情歌.jpwabc" });
|
||||
const second = modelFromParse(parseJpwabc(bytes), { fileName: "康定情歌.jpwabc" });
|
||||
const validation = validateScore(first);
|
||||
assert.equal(validation.valid, true);
|
||||
assert.equal(first.documentId, second.documentId);
|
||||
assert.deepEqual(first.voices.map((voice) => voice.id), second.voices.map((voice) => voice.id));
|
||||
assert.ok(first.unknownBlocks.length > 0 || first.attachments.some((attachment) => attachment.status !== "attached"));
|
||||
assert.ok(first.source?.rawBase64);
|
||||
assert.equal(projectEquivalentAfterReopen(first, decodeProject(encodeProject(first))), true);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { parseJpwabc } from "../dist/domain/jpwabc.js";
|
||||
import { modelFromParse, createEmptyDocument, rational, createNoteEvent } from "../dist/domain/score-model.js";
|
||||
import { DeterministicFontMeasurementProvider } from "../dist/layout/font-measure.js";
|
||||
import { DEFAULT_PAPER_CONFIG, layoutDocument, layoutSummary } from "../dist/layout/layout-engine.js";
|
||||
import { probeSvg, renderSnapshotSvg } from "../dist/layout/svg-renderer.js";
|
||||
|
||||
async function kangdingModel() {
|
||||
const bytes = await readFile("research/jpw7/demo/JP-Word练习曲-歌曲-康定情歌.jpwabc");
|
||||
return modelFromParse(parseJpwabc(bytes), { fileName: "康定情歌.jpwabc" });
|
||||
}
|
||||
|
||||
test("M3 waits for fonts, produces fixed A4 geometry and the 16-measure/6-line baseline", async () => {
|
||||
const provider = new DeterministicFontMeasurementProvider();
|
||||
const snapshot = await layoutDocument(await kangdingModel(), DEFAULT_PAPER_CONFIG, provider);
|
||||
const summary = layoutSummary(snapshot);
|
||||
assert.deepEqual(snapshot.pageSizeMm, { width: 210, height: 297 });
|
||||
assert.deepEqual(snapshot.contentRectMm, { x: 20, y: 20, width: 170, height: 257 });
|
||||
assert.equal(summary.pageCount, 1);
|
||||
assert.equal(summary.lineCount, 6);
|
||||
assert.equal(summary.measureCount, 16);
|
||||
assert.ok(snapshot.fontMeasurements.length > 0);
|
||||
assert.ok(snapshot.fontMeasurements.every((measurement) => measurement.loaded === true));
|
||||
assert.equal(snapshot.pages[0].widthMm, 210);
|
||||
assert.equal(snapshot.pages[0].heightMm, 297);
|
||||
});
|
||||
|
||||
test("M3 layout is deterministic for identical model, config and measurement provider", async () => {
|
||||
const model = await kangdingModel();
|
||||
const first = await layoutDocument(model, DEFAULT_PAPER_CONFIG, new DeterministicFontMeasurementProvider());
|
||||
const second = await layoutDocument(model, DEFAULT_PAPER_CONFIG, new DeterministicFontMeasurementProvider());
|
||||
assert.equal(first.deterministicKey, second.deterministicKey);
|
||||
assert.deepEqual(first.pages, second.pages);
|
||||
assert.deepEqual(first.warnings, second.warnings);
|
||||
});
|
||||
|
||||
test("M3 reports an unbreakable over-wide measure instead of clipping it", async () => {
|
||||
const document = createEmptyDocument("overflow-document");
|
||||
const event = createNoteEvent("wide-event", 1, rational(1));
|
||||
event.source = { raw: "X".repeat(800), range: null };
|
||||
document.voices.push({ id: "wide-voice", name: "宽小节", sectionId: null, visible: true, measures: [{ id: "wide-measure", ordinal: 0, beats: rational(1), events: [event] }] });
|
||||
const snapshot = await layoutDocument(document, DEFAULT_PAPER_CONFIG, new DeterministicFontMeasurementProvider());
|
||||
const warning = snapshot.warnings.find((item) => item.code === "MEASURE_OVERFLOW");
|
||||
assert.ok(warning);
|
||||
assert.equal(warning.severity, "error");
|
||||
assert.equal(snapshot.measures["wide-measure"].overflow, true);
|
||||
});
|
||||
|
||||
test("M3 SVG renderer preserves A4 physical units, semantic IDs and layer separation", async () => {
|
||||
const snapshot = await layoutDocument(await kangdingModel(), DEFAULT_PAPER_CONFIG, new DeterministicFontMeasurementProvider());
|
||||
const [svg] = renderSnapshotSvg(snapshot);
|
||||
const probe = probeSvg(svg);
|
||||
assert.equal(probe.width, "210mm");
|
||||
assert.equal(probe.height, "297mm");
|
||||
assert.equal(probe.viewBox, "0 0 21000 29700");
|
||||
assert.equal(probe.pageNumber, 1);
|
||||
assert.equal(probe.hasScoreLayer, true);
|
||||
assert.equal(probe.hasAssistiveLayer, false);
|
||||
assert.ok(probe.dataIds.length >= Object.keys(snapshot.symbols).length);
|
||||
const accessibleProbe = probeSvg(renderSnapshotSvg(snapshot, true)[0]);
|
||||
assert.equal(accessibleProbe.hasAssistiveLayer, true);
|
||||
});
|
||||
|
||||
test("M3 overflow and orphan diagnostics are visible in the snapshot, not hidden by renderer", async () => {
|
||||
const snapshot = await layoutDocument(await kangdingModel(), DEFAULT_PAPER_CONFIG, new DeterministicFontMeasurementProvider());
|
||||
assert.ok(snapshot.warnings.some((warning) => warning.code === "ORPHANED_ATTACHMENT"));
|
||||
assert.ok(snapshot.pages.every((page) => page.widthMm === 210 && page.heightMm === 297));
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
createEmptyDocument,
|
||||
createNoteEvent,
|
||||
createRestEvent,
|
||||
modelFromParse,
|
||||
rational
|
||||
} from "../dist/domain/score-model.js";
|
||||
import { parseJpwabc } from "../dist/domain/jpwabc.js";
|
||||
import { CompatibilityExportBlockedError, serializeJpwabc } from "../dist/domain/jpwabc-writer.js";
|
||||
import {
|
||||
compilePlaybackTimeline,
|
||||
PlaybackController,
|
||||
RecordingPlaybackSink
|
||||
} from "../dist/playback/playback.js";
|
||||
import { buildPrintableHtml, pixelsForMillimeters } from "../dist/output/output.js";
|
||||
import { DEFAULT_PAPER_CONFIG } from "../dist/layout/layout-engine.js";
|
||||
|
||||
function eightVoiceDocument() {
|
||||
const document = createEmptyDocument("m5-eight-voice");
|
||||
document.title.title = "M5 fixture";
|
||||
document.title.key = "C";
|
||||
document.title.meter = "4/4";
|
||||
document.title.tempo = 120;
|
||||
for (let voiceIndex = 0; voiceIndex < 8; voiceIndex += 1) {
|
||||
const events = [
|
||||
createNoteEvent(`v${voiceIndex}-1`, 1, rational(1)),
|
||||
createRestEvent(`v${voiceIndex}-r`, rational(1, 2)),
|
||||
createNoteEvent(`v${voiceIndex}-2`, 3, rational(1, 2)),
|
||||
createNoteEvent(`v${voiceIndex}-3`, 5, rational(2))
|
||||
];
|
||||
document.voices.push({
|
||||
id: `voice-${voiceIndex + 1}`,
|
||||
name: `Voice ${voiceIndex + 1}`,
|
||||
sectionId: null,
|
||||
visible: true,
|
||||
measures: [{ id: `measure-${voiceIndex + 1}`, ordinal: 0, beats: rational(4), events }]
|
||||
});
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
test("M5 PlaybackTimeline compiles exact durations and eight simultaneous voices", () => {
|
||||
const document = eightVoiceDocument();
|
||||
const timeline = compilePlaybackTimeline(document);
|
||||
assert.equal(timeline.voiceIds.length, 8);
|
||||
assert.equal(timeline.tempoBpm, 120);
|
||||
assert.equal(timeline.meter.numerator, 4);
|
||||
assert.equal(timeline.events.filter((event) => event.kind === "note" && event.startTick === 0).length, 8);
|
||||
assert.equal(timeline.events.find((event) => event.eventId === "v0-r").startTick, 960);
|
||||
assert.equal(timeline.events.find((event) => event.eventId === "v0-2").durationTicks, 480);
|
||||
assert.equal(timeline.durationTicks, 3840);
|
||||
assert.ok(Math.abs(timeline.secondsAtTick(3840) - 2) < 1e-12);
|
||||
});
|
||||
|
||||
test("M5 PlaybackController schedules, pauses, seeks and applies voice controls", async () => {
|
||||
const timeline = compilePlaybackTimeline(eightVoiceDocument());
|
||||
const sink = new RecordingPlaybackSink();
|
||||
const controller = new PlaybackController(timeline, { sink });
|
||||
await controller.play();
|
||||
assert.equal(controller.state, "playing");
|
||||
assert.equal(sink.scheduled.length, 24);
|
||||
controller.setVoiceControl("voice-1", { muted: true });
|
||||
assert.equal(controller.state, "playing");
|
||||
assert.ok(sink.scheduled.every((entry) => entry.event.voiceId !== "voice-1"));
|
||||
controller.pause();
|
||||
assert.equal(controller.state, "paused");
|
||||
controller.seek(0);
|
||||
assert.equal(controller.currentTick, 0);
|
||||
controller.stop();
|
||||
assert.equal(controller.state, "stopped");
|
||||
assert.equal(controller.currentTick, 0);
|
||||
controller.destroy();
|
||||
});
|
||||
|
||||
test("M5 core JPW-ABC writer emits UTF-16LE and blocks unsafe content", () => {
|
||||
const document = createEmptyDocument("writer-safe");
|
||||
document.title.title = "核心写出";
|
||||
document.title.key = "C";
|
||||
document.title.meter = "4/4";
|
||||
const event = createNoteEvent("writer-note", 1, rational(2));
|
||||
event.pitch.octave = 1;
|
||||
document.voices.push({ id: "writer-voice", name: "主旋律", sectionId: null, visible: true, measures: [{ id: "writer-measure", ordinal: 0, beats: rational(2), events: [event] }] });
|
||||
const result = serializeJpwabc(document);
|
||||
assert.deepEqual([...result.bytes.slice(0, 2)], [0xff, 0xfe]);
|
||||
assert.match(result.text, /\.Voice/);
|
||||
assert.match(result.text, /1g-/);
|
||||
const reopened = parseJpwabc(result.bytes);
|
||||
assert.equal(reopened.compatibility.originalExport, "safe");
|
||||
assert.equal(reopened.voices.length, 1);
|
||||
const reopenedModel = modelFromParse(reopened);
|
||||
assert.equal(reopenedModel.voices[0].measures[0].events[0].duration.numerator / reopenedModel.voices[0].measures[0].events[0].duration.denominator, 2);
|
||||
assert.equal(reopenedModel.voices[0].measures[0].events[0].pitch.octave, 1);
|
||||
|
||||
document.unknownBlocks.push({ id: "unknown-1", reason: "unknown-section", rawText: ".Private", context: ".Private", sourceRange: null });
|
||||
assert.throws(() => serializeJpwabc(document), CompatibilityExportBlockedError);
|
||||
});
|
||||
|
||||
test("M5 output geometry is fixed A4 and printable HTML omits assistive layers", () => {
|
||||
const dimensions = pixelsForMillimeters(210, 297, 300);
|
||||
assert.deepEqual(dimensions, { width: 2480, height: 3508, dpi: 300 });
|
||||
const snapshot = {
|
||||
version: 1,
|
||||
sourceDocumentId: "doc",
|
||||
documentTitle: "test",
|
||||
pageSizeMm: { width: 210, height: 297 },
|
||||
contentRectMm: { x: 20, y: 20, width: 170, height: 257 },
|
||||
config: DEFAULT_PAPER_CONFIG,
|
||||
pages: [{ pageNumber: 1, widthMm: 210, heightMm: 297, contentRectMm: { x: 20, y: 20, width: 170, height: 257 }, titleRectMm: { x: 20, y: 20, width: 170, height: 15 }, lines: [], measures: [], symbols: [], attachments: [], pageObjects: [] }],
|
||||
measures: {}, symbols: {}, attachments: {}, warnings: [], fontMeasurements: [], deterministicKey: "m5"
|
||||
};
|
||||
const html = buildPrintableHtml(snapshot);
|
||||
assert.match(html, /size:A4 portrait/);
|
||||
assert.doesNotMatch(html, /data-layer="assistive"/);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseJpwabc } from "../dist/domain/jpwabc.js";
|
||||
import { modelFromParse, createEmptyDocument, createNoteEvent, rational } from "../dist/domain/score-model.js";
|
||||
import { encodeProject, decodeProject } from "../dist/domain/project-codec.js";
|
||||
import { applyEditCommand, CommandStore } from "../dist/domain/command-store.js";
|
||||
import { DEFAULT_PAPER_CONFIG, layoutDocument } from "../dist/layout/layout-engine.js";
|
||||
import { createDefaultFontMeasurementProvider } from "../dist/layout/font-measure.js";
|
||||
import { renderSnapshotSvg } from "../dist/layout/svg-renderer.js";
|
||||
|
||||
function parseFixture() {
|
||||
return modelFromParse(parseJpwabc(new TextEncoder().encode(`JPW-ABC File Ver 7.30\n.Voice\n1 {(3} [13] {DunYin} 2 3 |: 4 Hide 5 :|\n.Words\nW1@1,1(True,Lyric,1.00,$FF000000,False):\n{(女)[跑]}马溜/溜的山/上\nW2@1,1(True,Lyric,1.00,$FF000000,False):\n第二段歌词\n.Attachments\nText@2,1 = hello\nArc@1,1 = curve\n.Page\nMemo@1,20,30 = hello\n`)));
|
||||
}
|
||||
|
||||
test("M6 parses tuplets, chords, decorations, hidden events and complex lyrics", () => {
|
||||
const document = parseFixture();
|
||||
const events = document.voices[0].measures.flatMap((measure) => measure.events);
|
||||
assert.equal(events.filter((event) => event.tuplet?.ratio === 3).length, 3);
|
||||
assert.deepEqual(events[1].chord?.map((pitch) => pitch.degree), [1, 3]);
|
||||
assert.equal(events[1].decorations?.[0].kind, "DunYin");
|
||||
assert.equal(events.find((event) => event.hidden)?.text, "Hide");
|
||||
const lyrics = document.attachments.filter((attachment) => attachment.kind === "Lyric");
|
||||
assert.equal(lyrics.length, 2);
|
||||
assert.equal(lyrics[0].status, "attached");
|
||||
assert.equal(lyrics[0].lyric.prefix, "{(女)[跑]}");
|
||||
assert.deepEqual(lyrics[0].lyric.syllables, ["马", "溜", "溜", "的", "山", "上"]);
|
||||
assert.equal(lyrics[0].lyric.syllableEventIds?.length, 6);
|
||||
assert.equal(new Set(lyrics[0].lyric.syllableEventIds?.filter(Boolean)).size, 6);
|
||||
assert.equal(document.attachments.find((attachment) => attachment.kind === "Text").status, "attached");
|
||||
assert.deepEqual(document.pageObjects[0].position, { x: 20, y: 30 });
|
||||
assert.equal(document.voices[0].measures[0].barlineAfter, "|:");
|
||||
assert.equal(document.voices[0].measures[1].barlineAfter, ":|");
|
||||
});
|
||||
|
||||
test("M6 editor commands update chords, decorations, tuplets and barline/repeat fields", () => {
|
||||
const document = parseFixture();
|
||||
const eventId = document.voices[0].measures[0].events[1].id;
|
||||
const withEventFields = applyEditCommand(document, {
|
||||
type: "update-event",
|
||||
eventId,
|
||||
patch: {
|
||||
chord: [{ degree: 1, accidental: 0, octave: null }, { degree: 5, accidental: 0, octave: null }],
|
||||
decorations: [{ id: "m6-decoration", kind: "staccato", raw: "staccato", playback: "visual", sourceRange: null }],
|
||||
tuplet: { id: "m6-tuplet", ratio: 3, ordinal: 1, raw: "3:1" }
|
||||
}
|
||||
});
|
||||
const withMeasureFields = applyEditCommand(withEventFields, {
|
||||
type: "update-measure",
|
||||
measureId: withEventFields.voices[0].measures[0].id,
|
||||
patch: { barlineAfter: ":|", repeatStart: true, repeatEnd: true, ending: 1 }
|
||||
});
|
||||
const event = withMeasureFields.voices[0].measures[0].events[1];
|
||||
assert.deepEqual(event.chord?.map((pitch) => pitch.degree), [1, 5]);
|
||||
assert.equal(event.decorations?.[0].kind, "staccato");
|
||||
assert.equal(event.tuplet?.ratio, 3);
|
||||
assert.equal(withMeasureFields.voices[0].measures[0].barlineAfter, ":|");
|
||||
assert.equal(withMeasureFields.voices[0].measures[0].repeatStart, true);
|
||||
assert.equal(withMeasureFields.voices[0].measures[0].ending, 1);
|
||||
});
|
||||
|
||||
test("M6 project codec preserves complex semantic fields and source attachments", () => {
|
||||
const document = parseFixture();
|
||||
const reopened = decodeProject(encodeProject(document));
|
||||
const event = reopened.voices[0].measures[0].events[1];
|
||||
assert.equal(event.chord?.length, 2);
|
||||
assert.equal(event.decorations?.[0].raw, "{DunYin}");
|
||||
assert.equal(event.tuplet?.ratio, 3);
|
||||
assert.equal(reopened.attachments.filter((attachment) => attachment.lyric).length, 2);
|
||||
assert.deepEqual(reopened.pageObjects[0].position, { x: 20, y: 30 });
|
||||
});
|
||||
|
||||
test("M6 lyric editing preserves one-to-many syllables, punctuation, undo/redo and anchors", () => {
|
||||
const store = new CommandStore(parseFixture());
|
||||
const lyric = store.snapshot().attachments.find((attachment) => attachment.kind === "Lyric");
|
||||
assert.ok(lyric);
|
||||
const raw = `${lyric.raw.split("\n", 1)[0]}\n一音多字-A,B。C\n`;
|
||||
store.execute({ type: "set-attachment-raw", attachmentId: lyric.id, raw });
|
||||
const edited = store.snapshot().attachments.find((attachment) => attachment.id === lyric.id);
|
||||
assert.deepEqual(edited?.lyric?.syllables, ["一", "音", "多", "字", "A", "B", "C"]);
|
||||
assert.equal(edited?.lyric?.syllableEventIds?.filter(Boolean).length, 7);
|
||||
store.undo();
|
||||
assert.notEqual(store.snapshot().attachments.find((attachment) => attachment.id === lyric.id)?.raw, raw);
|
||||
store.redo();
|
||||
const reopened = decodeProject(encodeProject(store.snapshot()));
|
||||
assert.equal(reopened.attachments.find((attachment) => attachment.id === lyric.id)?.raw, raw);
|
||||
assert.deepEqual(reopened.attachments.find((attachment) => attachment.id === lyric.id)?.lyric?.syllables, edited?.lyric?.syllables);
|
||||
});
|
||||
|
||||
test("M6 visual versus real meter/key edits remain distinct in the saved timeline", () => {
|
||||
const document = parseFixture();
|
||||
const withMusicControls = applyEditCommand(document, { type: "set-title", field: "meter", value: "3/4" });
|
||||
const withVisualText = applyEditCommand(withMusicControls, { type: "insert-event", voiceId: withMusicControls.voices[0].id, measureId: withMusicControls.voices[0].measures[0].id, event: { id: "visual-meter", kind: "text", pitch: null, duration: null, text: "拍号文字 4/4", source: null } });
|
||||
const reopened = decodeProject(encodeProject(withVisualText));
|
||||
assert.equal(reopened.title.meter, "3/4");
|
||||
assert.equal(reopened.voices[0].measures[0].events.find((event) => event.id === "visual-meter")?.duration, null);
|
||||
});
|
||||
|
||||
test("M6 layout renders decoration, chord, complex lyrics, page object and complex barlines", async () => {
|
||||
const document = parseFixture();
|
||||
const snapshot = await layoutDocument(document, DEFAULT_PAPER_CONFIG, createDefaultFontMeasurementProvider());
|
||||
const svg = renderSnapshotSvg(snapshot)[0];
|
||||
assert.match(svg, /data-layer="decoration"/);
|
||||
assert.match(svg, /data-layer="chord"/);
|
||||
assert.match(svg, /data-kind="attachment"/);
|
||||
assert.match(svg, /data-layer="lyric-syllable"/);
|
||||
assert.match(svg, /data-layer="attachment-curve"/);
|
||||
assert.match(svg, /data-layer="barline"/);
|
||||
assert.match(svg, /data-layer="page-objects"/);
|
||||
assert.ok(Object.values(snapshot.attachments).some((attachment) => attachment.visible));
|
||||
});
|
||||
|
||||
test("M6 layout segments an anchored attachment across pages without dropping stable ID", async () => {
|
||||
const document = createEmptyDocument("m6-cross-page");
|
||||
const events = Array.from({ length: 80 }, (_, index) => createNoteEvent(`m6-event-${index}`, (index % 7) + 1, rational(1)));
|
||||
document.voices.push({ id: "m6-voice", name: "主旋律", sectionId: null, visible: true, measures: events.map((event, index) => ({ id: `m6-measure-${index}`, ordinal: index, beats: rational(1), events: [event] })) });
|
||||
const start = events[0];
|
||||
const end = events.at(-1);
|
||||
document.attachments.push({ id: "m6-range", kind: "Arc", raw: "Arc@range", anchor: { raw: "Arc@range", startId: start.id, endId: end.id }, status: "attached", sourceRange: null, visible: true });
|
||||
const snapshot = await layoutDocument(document, { ...DEFAULT_PAPER_CONFIG, lineHeightMm: 30 }, createDefaultFontMeasurementProvider());
|
||||
const segments = snapshot.pages.flatMap((page) => page.attachments).filter((attachment) => attachment.id === "m6-range");
|
||||
assert.ok(snapshot.pages.length > 1);
|
||||
assert.ok(segments.length > 1);
|
||||
assert.ok(segments.every((segment) => segment.continued));
|
||||
assert.equal(snapshot.attachments["m6-range"].id, "m6-range");
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { CommandStore } from "../dist/domain/command-store.js";
|
||||
import { decodeProject, encodeProject } from "../dist/domain/project-codec.js";
|
||||
import { createEmptyDocument, createNoteEvent, rational, modelEquivalent, validateScore } from "../dist/domain/score-model.js";
|
||||
import { DEFAULT_PAPER_CONFIG, layoutDocument } from "../dist/layout/layout-engine.js";
|
||||
import { createDefaultFontMeasurementProvider } from "../dist/layout/font-measure.js";
|
||||
import { preflightJpwabcExport } from "../dist/domain/jpwabc-writer.js";
|
||||
|
||||
function measure(id, ordinal, degree) {
|
||||
return { id, ordinal, beats: rational(1), events: [createNoteEvent(`${id}-event`, degree, rational(1))] };
|
||||
}
|
||||
|
||||
function baseDocument() {
|
||||
const document = createEmptyDocument("m7-document");
|
||||
document.voices.push({ id: "voice-a", name: "主旋律", sectionId: null, visible: true, alignment: "aligned", measures: [measure("measure-a-1", 0, 1), measure("measure-a-2", 1, 2), measure("measure-a-3", 2, 3)] });
|
||||
document.voices.push({ id: "voice-b", name: "和声", sectionId: "section-1", visible: true, alignment: "overlay", measures: [measure("measure-b-1", 0, 3), measure("measure-b-2", 1, 5)] });
|
||||
return document;
|
||||
}
|
||||
|
||||
test("M7 manual layout and voice commands are editable and survive project reopen", () => {
|
||||
const store = new CommandStore(baseDocument());
|
||||
store.execute({ type: "set-manual-layout", patch: {
|
||||
lockedMeasureIds: ["measure-a-1"],
|
||||
forcedBreakAfterMeasureIds: ["measure-a-1"],
|
||||
measureSpacing: { "measure-a-1": 8 },
|
||||
measurePositions: { "measure-a-1": { x: 3, y: 2 } },
|
||||
objectPositions: { "measure-a-1-event": { x: 1.5, y: -0.5 } },
|
||||
voiceOrder: ["voice-b", "voice-a"],
|
||||
voiceSpacing: { "voice-b": 5 },
|
||||
voiceAlignment: { "voice-b": "overlay" },
|
||||
lyricOffsets: { "lyric-a": { x: 1, y: -2 } },
|
||||
fontOverrides: { "voice-b": { role: "special", scaleX: 1.1, scaleY: 0.9 } }
|
||||
}});
|
||||
store.execute({ type: "set-voice-layout", voiceId: "voice-b", patch: { temporary: true, fontRole: "special", alignment: "overlay" } });
|
||||
const temporary = { id: "voice-temp", name: "临时声部", sectionId: null, visible: true, temporary: true, alignment: "independent", measures: [measure("measure-t-1", 0, 6)] };
|
||||
store.execute({ type: "add-temporary-voice", voice: temporary });
|
||||
const reopened = decodeProject(encodeProject(store.snapshot()));
|
||||
assert.equal(modelEquivalent(store.snapshot(), reopened), true);
|
||||
assert.deepEqual(reopened.manualLayout.measurePositions["measure-a-1"], { x: 3, y: 2 });
|
||||
assert.deepEqual(reopened.manualLayout.objectPositions["measure-a-1-event"], { x: 1.5, y: -0.5 });
|
||||
assert.equal(reopened.voices.find((voice) => voice.id === "voice-b").temporary, true);
|
||||
assert.equal(reopened.voices.find((voice) => voice.id === "voice-temp").temporary, true);
|
||||
const report = preflightJpwabcExport(reopened);
|
||||
assert.equal(report.safe, false);
|
||||
assert.ok(report.reasons.some((reason) => reason.includes("手动排版") || reason.includes("临时")));
|
||||
});
|
||||
|
||||
test("M7 layout consumes lock, forced break, measure movement, voice order and voice spacing", async () => {
|
||||
const document = baseDocument();
|
||||
document.manualLayout.lockedMeasureIds = ["measure-a-1"];
|
||||
document.manualLayout.forcedBreakAfterMeasureIds = ["measure-a-1"];
|
||||
document.manualLayout.measureSpacing["measure-a-1"] = 12;
|
||||
document.manualLayout.measurePositions = { "measure-a-1": { x: 4, y: 2 } };
|
||||
document.manualLayout.objectPositions = { "measure-a-1-event": { x: 1.5, y: -0.5 } };
|
||||
document.manualLayout.voiceOrder = ["voice-b", "voice-a"];
|
||||
document.manualLayout.voiceSpacing = { "voice-b": 6 };
|
||||
document.voices[1].alignment = "overlay";
|
||||
const snapshot = await layoutDocument(document, { ...DEFAULT_PAPER_CONFIG, lineHeightMm: 25 }, createDefaultFontMeasurementProvider());
|
||||
const locked = snapshot.measures["measure-a-1"];
|
||||
assert.equal(locked.locked, true);
|
||||
assert.equal(locked.voiceId, "voice-a");
|
||||
assert.equal(locked.x, 128);
|
||||
assert.equal(locked.y, 37);
|
||||
assert.ok(snapshot.pages.some((page) => page.lines.some((line) => line.measureIds.includes("measure-a-1"))));
|
||||
assert.ok(snapshot.pages.flatMap((page) => page.lines).some((line) => line.measureIds.includes("measure-a-2")));
|
||||
assert.equal(snapshot.symbols["measure-a-1-event"].voiceId, "voice-a");
|
||||
assert.equal(snapshot.symbols["measure-b-1-event"].voiceId, "voice-b");
|
||||
});
|
||||
|
||||
test("M7 measure insert/delete preserves ordinals and diagnoses removed anchors", () => {
|
||||
const store = new CommandStore(baseDocument());
|
||||
const voice = store.snapshot().voices[0];
|
||||
store.execute({ type: "insert-measure", voiceId: voice.id, index: 1, measure: { id: "measure-inserted", ordinal: 1, beats: rational(1), events: [measure("measure-inserted", 1, 4).events[0]] } });
|
||||
assert.equal(store.snapshot().voices[0].measures.length, 4);
|
||||
assert.deepEqual(store.snapshot().voices[0].measures.map((item) => item.ordinal), [0, 1, 2, 3]);
|
||||
store.execute({ type: "delete-measure", voiceId: voice.id, measureId: "measure-inserted" });
|
||||
assert.equal(store.snapshot().voices[0].measures.length, 3);
|
||||
assert.deepEqual(store.snapshot().voices[0].measures.map((item) => item.ordinal), [0, 1, 2]);
|
||||
});
|
||||
|
||||
test("M7 page text boxes and voice names are editable and persist", () => {
|
||||
const store = new CommandStore(baseDocument());
|
||||
store.execute({ type: "set-voice-layout", voiceId: "voice-a", patch: { name: "主旋律改名", alignment: "independent" } });
|
||||
store.execute({ type: "insert-page-object", object: { id: "page-note", kind: "TextBox", raw: "页面备注", sourceRange: null, position: { x: 20, y: 270 }, visible: true } });
|
||||
store.execute({ type: "update-page-object", objectId: "page-note", patch: { raw: "页面备注2", position: { x: 22, y: 268 } } });
|
||||
const reopened = decodeProject(encodeProject(store.snapshot()));
|
||||
assert.equal(reopened.voices[0].name, "主旋律改名");
|
||||
assert.deepEqual(reopened.pageObjects.find((object) => object.id === "page-note")?.position, { x: 22, y: 268 });
|
||||
store.execute({ type: "delete-page-object", objectId: "page-note" });
|
||||
assert.equal(store.snapshot().pageObjects.some((object) => object.id === "page-note"), false);
|
||||
});
|
||||
|
||||
test("M7 voice groups support split, merge, undo and explicit mismatch diagnostics", () => {
|
||||
const store = new CommandStore(baseDocument());
|
||||
store.execute({ type: "split-voice", voiceId: "voice-a", atMeasure: 1, newVoiceId: "voice-a-split", newName: "主旋律 B" });
|
||||
assert.equal(store.snapshot().voices.find((voice) => voice.id === "voice-a")?.measures.length, 1);
|
||||
assert.equal(store.snapshot().voices.find((voice) => voice.id === "voice-a-split")?.measures.length, 2);
|
||||
store.undo();
|
||||
assert.equal(store.snapshot().voices.some((voice) => voice.id === "voice-a-split"), false);
|
||||
store.redo();
|
||||
store.execute({ type: "merge-voices", targetVoiceId: "voice-a", sourceVoiceId: "voice-a-split", strategy: "append" });
|
||||
assert.equal(store.snapshot().voices.find((voice) => voice.id === "voice-a")?.measures.length, 3);
|
||||
const mismatch = baseDocument();
|
||||
mismatch.voices[1].alignment = "aligned";
|
||||
mismatch.voices[1].measures = mismatch.voices[1].measures.slice(0, 1);
|
||||
const diagnostics = validateScore(mismatch).diagnostics;
|
||||
assert.ok(diagnostics.some((diagnostic) => diagnostic.code === "VOICE_MEASURE_COUNT_MISMATCH"));
|
||||
});
|
||||
|
||||
test("M7 temporary voice can be removed without deleting primary voices", () => {
|
||||
const store = new CommandStore(baseDocument());
|
||||
const temporary = { id: "voice-temp", name: "临时", sectionId: null, visible: true, temporary: true, measures: [measure("measure-t-1", 0, 4)] };
|
||||
store.execute({ type: "add-temporary-voice", voice: temporary });
|
||||
store.execute({ type: "delete-voice", voiceId: "voice-temp" });
|
||||
assert.equal(store.snapshot().voices.some((voice) => voice.id === "voice-temp"), false);
|
||||
assert.throws(() => store.execute({ type: "delete-voice", voiceId: "voice-a" }), /主声部不可/);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user