Score_To_MML / static /corrector.js
Coconuttttt's picture
Tutorial: revert to 8 steps, add free-glyphs (후보기호) step
f8506ee
/* ================================================================
OMR Corrector — corrector.js
Original image + MusicXML note marker overlay & pitch editor
================================================================ */
"use strict";
// ── i18n (Korean / English) ──────────────────────────────────
let currentLang = "ko";
const I18N = {
ko: {
images: "이미지", load: "불러오기", download: "XML 다운로드",
staff_lines: "오선 표시", adjust_staves: "오선 조정",
bpm: "BPM", vol: "음량", zoom: "확대",
x_off: "X 오프셋", y_off: "Y 오프셋",
staff_dist: "보표 간격", sys_dist: "단 간격 보정",
no_sel: "선택 없음", chord_select: "음표 선택:",
sc_select: "선택", sc_pitch: "음높이", sc_acc: "임시표",
sc_dur: "박자", sc_dot: "점", sc_del: "삭제",
sc_rest: "음표↔쉼표", sc_add: "추가 모드", sc_chord: "화음 추가",
sc_play: "재생", sc_seek: "탐색", sc_dblclick: "탐색+재생",
sc_page: "페이지", sc_undo: "실행취소/다시실행",
staff_adjust_mode: "오선 조정",
show_barlines: "마디선", barline_mode: "마디선 편집", free_glyphs: "후보 기호 표시",
barline_edit_mode: "마디선 편집", bl_autodetect: "이미지 감지",
bl_accept: "확정 (Enter)", bl_cancel: "취소 (Esc)",
sc_barline: "마디선 편집", sc_xanchor: "X위치 조정",
sc_timesig: "박자표", sc_keysig: "조표", sc_clef: "음자리표",
// titles
undo_title: "실행취소 (Ctrl+Z)", redo_title: "다시실행 (Ctrl+Y)",
pitch_up: "음높이 올림 (↑)", pitch_down: "음높이 내림 (↓)",
dblsharp: "겹올림표 (𝄪)", sharp: "올림표 (#)", flat: "내림표 (b)",
dblflat: "겹내림표 (𝄫)", natural: "제자리표 (n)", delete_note: "음표 삭제 (Del)",
whole: "온음표 (1)", half: "2분음표 (2)", quarter: "4분음표 (4)",
eighth: "8분음표 (5)", sixteenth: "16분음표 (6)", dot: "점 토글 (.)",
prev_note: "이전 음표 (Shift+Tab)", next_note: "다음 음표 (Tab)",
play_note: "음표 재생", play_all: "전체 재생 (Space)", stop: "정지",
prev_page: "이전 페이지", next_page: "다음 페이지",
// dynamic strings
loading_page: p => `${p}페이지 불러오는 중...`,
loading_pages: n => `${n}페이지 불러오는 중...`,
loading_piano: n => `피아노 로딩 중 (${n}개 음)...`,
select_prompt: "이미지와 XML/MXL 파일을 선택 후 불러오기를 누르세요.",
error_prefix: "오류: ",
// duration names for tooltip
dur: {4:"온음표",3:"점2분",2:"2분음표",1.5:"점4분",1:"4분음표",0.75:"점8분",0.5:"8분음표",0.375:"점16분",0.25:"16분음표",0.125:"32분음표"},
},
en: {
images: "Images", load: "Load", download: "Download XML",
staff_lines: "Staff Lines", adjust_staves: "Adjust Staves",
bpm: "BPM", vol: "Vol", zoom: "Zoom",
x_off: "X off", y_off: "Y off",
staff_dist: "Staff dist", sys_dist: "Sys dist adj",
no_sel: "No selection", chord_select: "Select note:",
sc_select: "Select", sc_pitch: "Pitch", sc_acc: "Accidental",
sc_dur: "Duration", sc_dot: "Dot", sc_del: "Delete",
sc_rest: "Note↔Rest", sc_add: "Add Mode", sc_chord: "Add Chord",
sc_play: "Play", sc_seek: "Seek", sc_dblclick: "Seek+Play",
sc_page: "Page", sc_undo: "Undo/Redo",
staff_adjust_mode: "STAFF ADJUST",
show_barlines: "Barlines", barline_mode: "Barline Edit", free_glyphs: "Candidates",
barline_edit_mode: "BARLINE EDIT", bl_autodetect: "Image Detect",
bl_accept: "Accept (Enter)", bl_cancel: "Cancel (Esc)",
sc_barline: "Barline Edit", sc_xanchor: "X Position",
sc_timesig: "Time Sig", sc_keysig: "Key Sig", sc_clef: "Clef",
undo_title: "Undo (Ctrl+Z)", redo_title: "Redo (Ctrl+Y)",
pitch_up: "Pitch Up (↑)", pitch_down: "Pitch Down (↓)",
dblsharp: "Double Sharp (𝄪)", sharp: "Sharp (#)", flat: "Flat (b)",
dblflat: "Double Flat (𝄫)", natural: "Natural (n)", delete_note: "Delete Note (Del)",
whole: "Whole Note (1)", half: "Half Note (2)", quarter: "Quarter Note (4)",
eighth: "Eighth Note (5)", sixteenth: "16th Note (6)", dot: "Dot Toggle (.)",
prev_note: "Previous Note (Shift+Tab)", next_note: "Next Note (Tab)",
play_note: "Play Note Sound", play_all: "Play All (Space)", stop: "Stop",
prev_page: "Previous Page", next_page: "Next Page",
loading_page: p => `Loading page ${p}...`,
loading_pages: n => `Loading ${n} pages...`,
loading_piano: n => `Loading piano (${n} notes)...`,
select_prompt: "Select image(s) and XML/MXL file(s), then click Load.",
error_prefix: "Error: ",
dur: {4:"whole",3:"half.",2:"half",1.5:"quarter.",1:"quarter",0.75:"8th.",0.5:"8th",0.375:"16th.",0.25:"16th",0.125:"32nd"},
}
};
function t(key) { return I18N[currentLang][key] || I18N.en[key] || key; }
function applyI18n() {
document.querySelectorAll("[data-i18n]").forEach(el => {
const key = el.dataset.i18n;
const val = I18N[currentLang][key];
if (val && typeof val === "string") el.textContent = val;
});
document.querySelectorAll("[data-i18n-title]").forEach(el => {
const key = el.dataset.i18nTitle;
const val = I18N[currentLang][key];
if (val && typeof val === "string") el.title = val;
});
// Update lang button label
const langBtn = document.getElementById("btn-lang");
if (langBtn) langBtn.textContent = currentLang === "ko" ? "EN" : "한";
}
function toggleLang() {
currentLang = currentLang === "ko" ? "en" : "ko";
applyI18n();
// Re-render status if selection exists
if (selectedIdx >= 0) selectNote(selectedIdx);
else statusSel.textContent = t("no_sel");
}
// ── Global State ──────────────────────────────────────────────
let xmlDoc = null; // parsed MusicXML DOM
let noteInfos = []; // array of NoteInfo objects
let selectedIdx = -1; // index into noteInfos (primary selection)
let scoreSelectedIndices = new Set(); // multi-select for score markers
let _scoreCtrlHandled = false; // flag to prevent double-toggle on ctrl+click
let layout = null; // ScoreLayout
let systemsData = []; // parsed systems
let pixelsPerTenth = 1;
let currentZoom = 0.5;
// ── Undo/Redo ────────────────────────────────────────────────
const MAX_UNDO = 50;
let undoStack = []; // array of xmlDoc clones
let redoStack = [];
// ── Multi-page ───────────────────────────────────────────────
let pages = []; // array of { imageFile, xmlFile, omrFile, imageUrl, xmlText, xmlDoc, noteInfos, systemsData, layout, detectedStaves, undoStack, redoStack, pixelsPerTenth, omrEdits }
let currentPageIdx = 0;
// ── OMR Edit Tracking ───────────────────────────────────────
// Each page accumulates omrEdits[]. "Apply to OMR" sends them to server.
let omrEdits = []; // current page's pending edits
let freeGlyphData = []; // [{glyphId, x, y, w, h, systemIdx}, ...]
let freeGlyphsVisible = false;
// ── Cross-page time signature carry-over ─────────────────────
// Persisted across page loads so page 2+ inherits the last time sig from previous pages
let carryBeats = 4, carryBeatType = 4;
// ── Add Mode (Phase 3A) ─────────────────────────────────────
let addMode = false;
let ghostMarker = null; // SVG circle for ghost preview
let ghostLabel = null; // SVG text for pitch label
let addDurationType = "quarter"; // pending insertion duration type
// ── Volume ──────────────────────────────────────────────────
let masterVolume = 0.5; // 0.0 ~ 1.0
const DUR_SYMBOLS = { "whole": "𝅝", "half": "𝅗𝅥", "quarter": "♩", "eighth": "♪", "16th": "𝅘𝅥𝅯", "32nd": "𝅘𝅥𝅰" };
const STEPS = ["C","D","E","F","G","A","B"];
const STEP_INDEX = { C:0, D:1, E:2, F:3, G:4, A:5, B:6 };
function alterStr(a) { return a===2?"x":a===1?"#":a===-1?"b":a===-2?"bb":""; }
// Key signature: fifths → map of step → alter
// Sharp order: F C G D A E B, Flat order: B E A D G C F
function keyAlterFromFifths(fifths) {
const map = {};
if (fifths > 0) {
const sharpOrder = ["F","C","G","D","A","E","B"];
for (let i = 0; i < Math.min(fifths, 7); i++) map[sharpOrder[i]] = 1;
} else if (fifths < 0) {
const flatOrder = ["B","E","A","D","G","C","F"];
for (let i = 0; i < Math.min(-fifths, 7); i++) map[flatOrder[i]] = -1;
}
return map;
}
// Apply key signature alter to a note's step (only if note has no explicit accidental override)
function keyAlterForStep(step, fifths) {
const map = keyAlterFromFifths(fifths);
return map[step] || 0;
}
const VOICE_COLORS = { 1:"voice1", 2:"voice2", 3:"voice3", 4:"voice4" };
// For multi-part: part 0 uses voice1-4, part 1 uses voice3-4 + voice1-2 (cycle)
function getMarkerClass(partIndex, voice) {
if (partIndex === 0) return VOICE_COLORS[voice] || "voice1";
// Part 1+: shift colors so parts are visually distinct
const shifted = ((voice - 1 + partIndex * 2) % 4) + 1;
return VOICE_COLORS[shifted] || "voice1";
}
// ── DOM refs ──────────────────────────────────────────────────
const imageInput = document.getElementById("image-input");
const xmlInput = document.getElementById("xml-input");
const omrInput = document.getElementById("omr-input");
const dpiInput = document.getElementById("dpi-input");
const loadBtn = document.getElementById("load-btn");
const loadStatus = document.getElementById("load-status");
const scoreImage = document.getElementById("score-image");
const markerSvg = document.getElementById("marker-svg");
const statusSel = document.getElementById("status-selection");
const statusTotal = document.getElementById("status-total");
const zoomSlider = document.getElementById("zoom-slider");
const zoomLabel = document.getElementById("zoom-label");
const offsetX = document.getElementById("offset-x");
const offsetY = document.getElementById("offset-y");
const chordPopup = document.getElementById("chord-popup");
const chordList = document.getElementById("chord-popup-list");
const volSlider = document.getElementById("vol-slider");
const volLabel = document.getElementById("vol-label");
if (volSlider) {
volSlider.addEventListener("input", () => {
masterVolume = parseInt(volSlider.value) / 100;
if (volLabel) volLabel.textContent = volSlider.value + "%";
});
}
// ================================================================
// Section 1: XML Parsing
// ================================================================
function parseScoreLayout(doc) {
const defaults = doc.querySelector("defaults");
const scaling = defaults?.querySelector("scaling");
const mm = parseFloat(scaling?.querySelector("millimeters")?.textContent || "7.112");
const tpu = parseFloat(scaling?.querySelector("tenths")?.textContent || "40");
const pl = defaults?.querySelector("page-layout");
const pageH = parseFloat(pl?.querySelector("page-height")?.textContent || "1800");
const pageW = parseFloat(pl?.querySelector("page-width")?.textContent || "1300");
const pm = pl?.querySelector("page-margins");
const marginL = parseFloat(pm?.querySelector("left-margin")?.textContent || "80");
const marginR = parseFloat(pm?.querySelector("right-margin")?.textContent || "80");
const marginT = parseFloat(pm?.querySelector("top-margin")?.textContent || "80");
// Parse default staff-distance from <defaults><staff-layout>
let defaultStaffDistance = 65; // MusicXML default
const defaultStaffLayouts = defaults?.querySelectorAll("staff-layout");
if (defaultStaffLayouts) {
defaultStaffLayouts.forEach(sl => {
const sd = sl.querySelector("staff-distance");
if (sd) defaultStaffDistance = parseFloat(sd.textContent);
});
}
return { mm, tpu, pageH, pageW, marginL, marginR, marginT, defaultStaffDistance };
}
/** Parse clef from <attributes> for a given staff number.
* MusicXML can have multiple <clef number="N"> for multi-staff. */
function parseClefs(attrEl) {
const clefs = {};
if (!attrEl) return clefs;
const clefEls = attrEl.querySelectorAll("clef");
clefEls.forEach(clefEl => {
const num = parseInt(clefEl.getAttribute("number") || "1");
const sign = clefEl.querySelector("sign")?.textContent || "G";
const line = parseInt(clefEl.querySelector("line")?.textContent || "2");
const oc = parseInt(clefEl.querySelector("clef-octave-change")?.textContent || "0");
clefs[num] = { sign, line, octaveChange: oc };
});
// If only one clef without number attribute
if (Object.keys(clefs).length === 0 && attrEl.querySelector("clef")) {
const clefEl = attrEl.querySelector("clef");
clefs[1] = {
sign: clefEl.querySelector("sign")?.textContent || "G",
line: parseInt(clefEl.querySelector("line")?.textContent || "2"),
octaveChange: parseInt(clefEl.querySelector("clef-octave-change")?.textContent || "0"),
};
}
return clefs;
}
/** Parse number of staves and staff-distance from <attributes> */
function parseStaffInfo(attrEl) {
const stavesEl = attrEl?.querySelector("staves");
const numStaves = stavesEl ? parseInt(stavesEl.textContent) : 1;
// staff-layout can appear in <attributes> or <print>
let staffDistance = 65; // default distance in tenths between staves
const staffLayoutEl = attrEl?.querySelector("staff-layout");
if (staffLayoutEl) {
const sd = staffLayoutEl.querySelector("staff-distance");
if (sd) staffDistance = parseFloat(sd.textContent);
}
return { numStaves, staffDistance };
}
/** Parse staff-distance from <print> element */
function parseStaffDistanceFromPrint(printEl) {
if (!printEl) return null;
const staffLayoutEl = printEl.querySelector("staff-layout");
if (!staffLayoutEl) return null;
const sd = staffLayoutEl.querySelector("staff-distance");
return sd ? parseFloat(sd.textContent) : null;
}
/** Parse all systems (system breaks) and their measures with widths */
// ── .omr-based system parser ─────────────────────────────────────
function parseSystemsFromOmr(omrData) {
if (!omrData || !omrData.systems || omrData.systems.length === 0) return null;
const systems = [];
let globalMeasureNum = 1;
for (let si = 0; si < omrData.systems.length; si++) {
const sys = omrData.systems[si];
const staves = sys.staves || [];
const numStaves = staves.length;
// topY: first staff's first line y1
let topY = 0, leftX = 0, rightX = 0;
if (staves.length > 0 && staves[0].lines && staves[0].lines.length > 0) {
topY = staves[0].lines[0].y1;
leftX = staves[0].left;
rightX = staves[0].right;
}
// staffDistance: gap between last line of staff 0 and first line of staff 1 (pixels)
let staffDistance = 65;
if (numStaves >= 2 && staves[0].lines.length >= 5 && staves[1].lines.length >= 1) {
staffDistance = staves[1].lines[0].y1 - staves[0].lines[4].y1;
}
// Measures from stacks
const measures = [];
for (const stack of (sys.stacks || [])) {
const left = stack.left;
const right = stack.right;
measures.push({
number: String(globalMeasureNum++),
left,
right,
width: right - left,
startX: left - leftX,
systemIdx: si,
duration: stack.duration || "1",
element: null, // no MusicXML element
});
}
systems.push({
index: si,
topY,
leftX,
rightX,
leftMargin: 0, // pixel 기반이라 margin 불필요
measures,
cumulativeWidth: rightX - leftX,
numStaves,
staffDistance,
staves, // raw staff data for coordinate mapping
_omrBased: true, // flag to distinguish from XML-based
});
}
return systems;
}
/**
* Convert .omr staff-relative diatonic pitch to MusicXML step+octave.
* pitch 0 = middle line of staff.
* Audiveris convention: positive = above middle line, negative = below.
* (stepOctaveToOmrPitch returns midDiatonic - noteDiatonic, so positive = below)
* Actually checking: stepOctaveToOmrPitch returns midDiatonic - diatonic → positive means note is BELOW mid.
* So noteDiatonic = midDiatonic - omrPitch.
*/
function omrPitchToStepOctave(pitch, clef) {
if (!clef) clef = { sign: "G", line: 2, octaveChange: 0 };
const ref = clefReferencePosition(clef);
// ref.diatonicIdx = diatonicIndex of the note on staff line ref.staffPosition
// staffPosition 0 = bottom line, 2 = line 2, etc.
// Middle line = staff position 4 (3rd line from bottom, 0-indexed: line positions 0,2,4,6,8)
// pitch 0 = middle line (staff position 4)
// pitch +1 = one diatonic step above middle → staffPosition 5
// So: noteDiatonic = ref.diatonicIdx + (pitch - ref.staffPosition + 4)
// Wait, let me derive from stepOctaveToOmrPitch:
// omrPitch = midDiatonic - noteDiatonic
// where midDiatonic is the note on middle line (staffPos 4)
// ref gives us: ref.diatonicIdx is on ref.staffPosition
// midDiatonic = ref.diatonicIdx + (4 - ref.staffPosition)
// So: noteDiatonic = midDiatonic - omrPitch = ref.diatonicIdx + (4 - ref.staffPosition) - pitch
// But Audiveris pitch sign: in the .omr file, head pitch is "staff-relative diatonic"
// From Audiveris source: pitch 0 = middle line, positive = above, negative = below
// But stepOctaveToOmrPitch returns midDiatonic - diatonic = positive when note is BELOW middle
// The .omr file stores what Audiveris computes, which uses the OPPOSITE convention
// Let me verify: secret_p01 head pitch=7.0 with onset=7/8 in measure 1
// This is the first actual note. In "말할 수 없는 비밀", first note is likely high.
// pitch=7 with treble clef: midLine = B4
// If Audiveris convention: pitch>0 = above → diatonic = B4_diatonic + 7 = 41 + 7 = 48
// 48 = octave*7+step → oct=6, step=6=B → B6? That's very high.
// If opposite: diatonic = 41 - 7 = 34 → oct=4, step=6=B → B4? That's middle line itself for treble.
// Hmm, let me re-check. Actually:
// stepOctaveToOmrPitch: return midDiatonic - diatonic
// For B4 on treble: midDiatonic=41, diatonic=41 → returns 0. So B4=pitch 0. ✓
// For C5: diatonic=42, returns 41-42=-1. So pitch=-1 means one above. ✓
// For A4: diatonic=40, returns 41-40=1. So pitch=1 means one below. ✓
// So positive pitch = below middle line. Negative pitch = above.
// .omr stores this same convention (head pitch="7" means 7 diatonic steps below middle line)
// Therefore: noteDiatonic = midDiatonic - omrPitch
const midDiatonic = ref.diatonicIdx + (4 - ref.staffPosition);
const noteDiatonic = midDiatonic - pitch;
const octave = Math.floor(noteDiatonic / 7);
const stepIdx = ((noteDiatonic % 7) + 7) % 7; // handle negative modulo
return { step: STEPS[stepIdx], octave };
}
/**
* Parse rational string "3/8" → float 0.375. Also handles "0" and integers.
*/
function parseRational(s) {
if (!s || s === "0") return 0;
const parts = String(s).split("/");
if (parts.length === 2) return parseInt(parts[0]) / parseInt(parts[1]);
return parseFloat(s) || 0;
}
/** Convert float duration to closest rational string (e.g. 0.375 → "3/8") */
function gcd(a, b) { while (b) { [a, b] = [b, a % b]; } return a; }
function durationFloatToRational(f) {
if (Math.abs(f) < 0.001) return "0";
const table = [
[1, "1/1"], [0.75, "3/4"], [0.5, "1/2"], [0.375, "3/8"],
[1/3, "1/3"], [0.25, "1/4"], [0.1875, "3/16"],
[1/6, "1/6"], [0.125, "1/8"], [0.09375, "3/32"],
[1/12, "1/12"], [0.0625, "1/16"], [1/24, "1/24"], [0.03125, "1/32"],
];
for (const [val, str] of table) {
if (Math.abs(f - val) < 0.001) return str;
}
// Fallback: n/96 — LCM(48,32), supports both triplets and 32nd notes exactly
const n = Math.round(f * 96);
if (n <= 0) return "0";
// Simplify fraction
const g = gcd(n, 96);
return `${n / g}/${96 / g}`;
}
/**
* Build noteInfos entirely from .omr data (no MusicXML dependency).
* Each noteInfo has the same shape as parseNotes() output for compatibility.
*/
function parseNotesFromOmr(omrData, systemsData) {
if (!omrData || !omrData.systems) return null;
const notes = [];
let globalMeasureBase = 1;
for (let si = 0; si < omrData.systems.length; si++) {
const sys = omrData.systems[si];
const sysInfo = systemsData[si];
if (!sysInfo) continue;
// Initialize clef/keySig state from this system's inters
const currentClefs = {}; // staffId → { sign, line, octaveChange }
let currentFifths = 0;
// Set default clefs from .omr clef inters (sorted by X to get first occurrence)
const sortedClefs = [...(sys.clefs || [])].sort((a, b) => {
const ax = a.bounds ? a.bounds.x : 0;
const bx = b.bounds ? b.bounds.x : 0;
return ax - bx;
});
for (const c of sortedClefs) {
if (!currentClefs[c.staff]) {
// Convert .omr kind (TREBLE/BASS/ALTO/TENOR) → MusicXML sign (G/F/C)
const kind = (c.kind || "").toUpperCase();
let sign, line;
if (kind === "TREBLE" || kind === "G") { sign = "G"; line = 2; }
else if (kind === "BASS" || kind === "F") { sign = "F"; line = 4; }
else if (kind === "ALTO" || kind === "C") { sign = "C"; line = 3; }
else if (kind === "TENOR") { sign = "C"; line = 4; }
else { sign = "G"; line = 2; } // fallback to treble
currentClefs[c.staff] = { sign, line, octaveChange: 0 };
}
}
// Key signature
if (sys.keySigs && sys.keySigs.length > 0) {
currentFifths = parseInt(sys.keySigs[0].fifths) || 0;
}
// sys.measures may contain multiple parts' measures (e.g., piano = 2 parts)
// Each part has N measures corresponding to N stacks. Measures from different
// parts at the same stack position share the same global measure number.
const numStacks = (sys.stacks || []).length || 1;
const measuresPerPart = numStacks; // each part has one measure per stack
const numParts = Math.max(1, Math.ceil(sys.measures.length / measuresPerPart));
for (let mi = 0; mi < sys.measures.length; mi++) {
const meas = sys.measures[mi];
const stackIdx = mi % measuresPerPart;
const partIdx = Math.floor(mi / measuresPerPart);
const measNum = String(globalMeasureBase + stackIdx);
const measInfo = sysInfo.measures[stackIdx];
// Process headChords
for (const hc of meas.headChords) {
const isMultiHead = hc.heads.length > 1;
for (let hi = 0; hi < hc.heads.length; hi++) {
const head = hc.heads[hi];
const clef = currentClefs[head.staff] || currentClefs[Object.keys(currentClefs)[0]] || { sign: "G", line: 2, octaveChange: 0 };
const { step, octave } = omrPitchToStepOctave(head.pitch, clef);
// Key signature: apply implied alter when no explicit accidental
const alter = head.hasAccidental ? (head.alter || 0) : keyAlterForStep(step, currentFifths);
const durFloat = parseRational(hc.duration);
const onsetFloat = parseRational(hc.timeOffset);
notes.push({
element: null, // no MusicXML DOM element
measureNum: measNum,
step, octave, alter,
fifths: currentFifths,
voice: hc.voice || 1,
staff: head.staff,
defaultX: head.bounds ? head.bounds.x : 0,
partIndex: partIdx,
systemIdx: si,
measureStartX: measInfo ? measInfo.startX : 0,
systemLeftMargin: sysInfo.leftX || 0,
systemTopY: sysInfo.topY || 0,
staffDistance: sysInfo.staffDistance || 0,
clef: { ...clef },
isChord: hi > 0, // 2nd+ head in same chord
isRest: false,
divisions: 1, // rational → use 1 as base
durationDiv: durFloat, // float (e.g. 0.125 for 1/8)
onsetDiv: onsetFloat,
measureIdx: measNum,
modified: false,
omrX: head.bounds ? head.bounds.x + head.bounds.w / 2 : null,
omrY: head.bounds ? head.bounds.y + head.bounds.h / 2 : null,
grade: head.grade,
omrChordId: hc.chordId,
omrHeadId: head.headId,
px: 0, py: 0,
// New .omr-specific fields
durationRational: hc.duration,
timeOffsetRational: hc.timeOffset,
headShape: head.shape,
tupletGroupId: hc.tupletGroupId || null,
_omrBased: true,
_omrMeasureIdx: mi,
_isHeadChord: true,
orphan: !!hc.orphan,
});
}
}
// Process restChords
for (const rc of meas.restChords) {
const staff = rc.staff || (sys.staves.length > 0 ? sys.staves[0].staffId : 1);
const clef = currentClefs[staff] || currentClefs[Object.keys(currentClefs)[0]] || { sign: "G", line: 2, octaveChange: 0 };
const durFloat = parseRational(rc.duration);
const onsetFloat = parseRational(rc.timeOffset);
notes.push({
element: null,
measureNum: measNum,
step: "R", octave: 0, alter: 0,
fifths: currentFifths,
voice: rc.voice || 1,
staff,
defaultX: rc.bounds ? rc.bounds.x : 0,
partIndex: partIdx,
systemIdx: si,
measureStartX: measInfo ? measInfo.startX : 0,
systemLeftMargin: sysInfo.leftX || 0,
systemTopY: sysInfo.topY || 0,
staffDistance: sysInfo.staffDistance || 0,
clef: { ...clef },
isChord: false,
isRest: true,
divisions: 1,
durationDiv: durFloat,
onsetDiv: onsetFloat,
measureIdx: measNum,
modified: false,
omrX: rc.bounds ? rc.bounds.x + rc.bounds.w / 2 : null,
omrY: rc.bounds ? rc.bounds.y + rc.bounds.h / 2 : null,
grade: rc.chordGrade,
omrChordId: rc.chordId,
omrHeadId: null,
px: 0, py: 0,
durationRational: rc.duration,
timeOffsetRational: rc.timeOffset,
restShape: rc.restShape,
_omrBased: true,
_omrMeasureIdx: mi,
_isHeadChord: false,
orphan: !!rc.orphan,
});
}
}
globalMeasureBase += numStacks;
}
// ── Voice merge: within each staff+measure, merge non-overlapping voices ──
const byStaffMeas = {};
for (let i = 0; i < notes.length; i++) {
const key = `${notes[i].staff}_${notes[i].measureNum}`;
if (!byStaffMeas[key]) byStaffMeas[key] = [];
byStaffMeas[key].push(i);
}
for (const indices of Object.values(byStaffMeas)) {
// Group by voice
const voiceGroups = {};
for (const i of indices) {
const v = notes[i].voice || 1;
if (!voiceGroups[v]) voiceGroups[v] = [];
voiceGroups[v].push(i);
}
const voiceKeys = Object.keys(voiceGroups).sort((a, b) => parseInt(a) - parseInt(b));
if (voiceKeys.length <= 1) continue;
// Try merging each voice into the first non-overlapping voice
const primaryVoice = parseInt(voiceKeys[0]);
for (let vi = 1; vi < voiceKeys.length; vi++) {
const srcVoice = parseInt(voiceKeys[vi]);
const srcIndices = voiceGroups[srcVoice];
// Check overlap between primaryVoice notes and srcVoice notes
const primaryIndices = voiceGroups[primaryVoice];
let overlaps = false;
for (const si of srcIndices) {
const sStart = notes[si].onsetDiv || 0;
const sEnd = sStart + (notes[si].durationDiv || 0);
for (const pi of primaryIndices) {
const pStart = notes[pi].onsetDiv || 0;
const pEnd = pStart + (notes[pi].durationDiv || 0);
if (sStart < pEnd && sEnd > pStart) { overlaps = true; break; }
}
if (overlaps) break;
}
if (!overlaps) {
// Merge: change voice in noteInfos AND omrData chords
for (const si of srcIndices) {
notes[si].voice = primaryVoice;
// Update omrData chord voice
const n = notes[si];
const sysIdx = n.systemIdx;
const omrMi = n._omrMeasureIdx;
if (omrData.systems[sysIdx] && omrData.systems[sysIdx].measures[omrMi]) {
const m = omrData.systems[sysIdx].measures[omrMi];
const hc = m.headChords.find(c => c.chordId === n.omrChordId);
if (hc) hc.voice = primaryVoice;
const rc = m.restChords.find(c => c.chordId === n.omrChordId);
if (rc) rc.voice = primaryVoice;
}
}
voiceGroups[primaryVoice].push(...srcIndices);
}
}
}
return notes;
}
function parseSystems(doc, layoutInfo) {
const parts = doc.querySelectorAll("part");
if (parts.length === 0) return [];
const part = parts[0]; // Use first part for layout (measures/widths are same across parts)
// Count total staves across ALL parts (e.g., Piano1=2 + Piano2=2 = 4)
let totalStavesPerSystem = 0;
parts.forEach(p => {
const firstAttr = p.querySelector("measure > attributes");
if (firstAttr) {
const si = parseStaffInfo(firstAttr);
totalStavesPerSystem += si.numStaves;
} else {
totalStavesPerSystem += 1;
}
});
const measures = part.querySelectorAll("measure");
const systems = [];
let currentSystem = null;
let numStaves = totalStavesPerSystem; // total across ALL parts
let staffDistance = layoutInfo.defaultStaffDistance; // from <defaults>
measures.forEach((mEl, mIdx) => {
const printEl = mEl.querySelector("print");
const attrEl = mEl.querySelector("attributes");
const isNewSystem = mIdx === 0
|| printEl?.getAttribute("new-system") === "yes"
|| printEl?.getAttribute("new-page") === "yes";
// Update staff distance from attributes (but NOT numStaves — that's the total across parts)
if (attrEl) {
if (attrEl.querySelector("staff-layout")) {
const si = parseStaffInfo(attrEl);
staffDistance = si.staffDistance;
}
}
// Check staff-distance in print element too
const printSD = parseStaffDistanceFromPrint(printEl);
if (printSD !== null) staffDistance = printSD;
if (isNewSystem) {
const sysLayout = printEl?.querySelector("system-layout");
const sysMar = sysLayout?.querySelector("system-margins");
const sysLeftM = parseFloat(sysMar?.querySelector("left-margin")?.textContent || "0");
const topSysDist = parseFloat(sysLayout?.querySelector("top-system-distance")?.textContent || "0");
const sysDist = parseFloat(sysLayout?.querySelector("system-distance")?.textContent || "0");
let topY;
if (systems.length === 0) {
// First system
topY = layoutInfo.marginT + topSysDist;
} else {
const prev = systems[systems.length - 1];
// Previous system total height = staff1(40) + staffDistance + staff2(40) + ... for each staff
const prevTotalHeight = 40 + (prev.numStaves - 1) * (prev.staffDistance + 40);
topY = prev.topY + prevTotalHeight + sysDist;
}
currentSystem = {
index: systems.length,
topY,
leftMargin: sysLeftM,
measures: [],
cumulativeWidth: 0,
numStaves,
staffDistance,
_origSysDist: sysDist,
};
systems.push(currentSystem);
}
// Update current system's staff info if changed mid-system
if (currentSystem) {
currentSystem.numStaves = numStaves;
currentSystem.staffDistance = staffDistance;
}
const width = parseFloat(mEl.getAttribute("width") || "200");
const measureStartX = currentSystem.cumulativeWidth;
currentSystem.measures.push({
element: mEl,
number: mEl.getAttribute("number"),
width,
startX: measureStartX,
systemIdx: currentSystem.index,
});
currentSystem.cumulativeWidth += width;
});
// Validate: if calculated system positions overflow page-height,
// the numStaves is likely inflated by OMR misdetection.
// Reduce numStaves until systems fit within page bounds.
if (systems.length >= 2 && layoutInfo.pageH > 0) {
const lastSys = systems[systems.length - 1];
const lastHeight = 40 + (lastSys.numStaves - 1) * (lastSys.staffDistance + 40);
const totalUsed = lastSys.topY + lastHeight;
if (totalUsed > layoutInfo.pageH) {
// Try reducing numStaves until it fits
for (let tryStaves = totalStavesPerSystem - 1; tryStaves >= 1; tryStaves--) {
// Recalculate all topY with reduced numStaves
let fits = true;
let testTopY = systems[0].topY; // first system stays
for (let i = 1; i < systems.length; i++) {
const prev = systems[i - 1];
const prevH = 40 + (tryStaves - 1) * (prev.staffDistance + 40);
testTopY = testTopY + prevH + systems[i]._origSysDist;
}
const testLastH = 40 + (tryStaves - 1) * (lastSys.staffDistance + 40);
if (testTopY + testLastH <= layoutInfo.pageH) {
// This numStaves fits — apply it
console.log(`Staff count adjusted: ${totalStavesPerSystem}${tryStaves} (page overflow fix)`);
totalStavesPerSystem = tryStaves;
// Recalculate all system topY and numStaves
for (let i = 0; i < systems.length; i++) {
systems[i].numStaves = tryStaves;
if (i > 0) {
const prev = systems[i - 1];
const prevH = 40 + (prev.numStaves - 1) * (prev.staffDistance + 40);
systems[i].topY = prev.topY + prevH + systems[i]._origSysDist;
}
}
break;
}
}
}
}
return systems;
}
/**
* Reassign measures to systems based on image-detected staff pixel widths.
* The XML <print new-system> breaks can be wrong (OMR errors), so we use the
* actual staff pixel widths to determine how many measures belong in each system.
*
* Algorithm: each system's share of total measures (by tenths width) should be
* proportional to its share of total image pixel width.
*/
function reassignMeasuresToSystems(systems, staves, numStavesPerSys) {
if (systems.length < 2 || staves.length < numStavesPerSys) return;
const staffSystems = mapStavesToSystems(staves, numStavesPerSys);
if (staffSystems.length !== systems.length) {
console.log(`reassignMeasures: system count mismatch XML=${systems.length} IMG=${staffSystems.length}, skipping`);
return;
}
// Get image pixel width per system (from first staff of each system)
const sysPixelWidths = staffSystems.map(ss => {
if (!ss || ss.length === 0 || !ss[0].leftX) return 0;
return ss[0].rightX - ss[0].leftX;
});
if (sysPixelWidths.some(w => w <= 0)) return; // missing X data
// Collect all measures in order
const allMeasures = [];
systems.forEach(sys => sys.measures.forEach(m => allMeasures.push(m)));
if (allMeasures.length === 0) return;
const totalTenths = allMeasures.reduce((s, m) => s + m.width, 0);
const totalPixels = sysPixelWidths.reduce((s, w) => s + w, 0);
if (totalTenths <= 0 || totalPixels <= 0) return;
// Check if the current assignment already matches image proportions
// Compare each system's tenths-ratio vs pixel-ratio
const currentRatios = systems.map(sys => sys.cumulativeWidth / totalTenths);
const imgRatios = sysPixelWidths.map(pw => pw / totalPixels);
const maxRatioError = Math.max(...currentRatios.map((cr, i) => Math.abs(cr - imgRatios[i])));
if (maxRatioError < 0.08) {
// Ratios are consistent (within 8%) — no reassignment needed
return;
}
console.log(`reassignMeasures: ratio mismatch detected (max error ${(maxRatioError*100).toFixed(1)}%), redistributing`);
console.log(` XML ratios: [${currentRatios.map(r => (r*100).toFixed(1)+'%').join(', ')}]`);
console.log(` IMG ratios: [${imgRatios.map(r => (r*100).toFixed(1)+'%').join(', ')}]`);
// Target tenths per system based on image proportions
const sysTargetTenths = sysPixelWidths.map(pw => (pw / totalPixels) * totalTenths);
let mIdx = 0;
for (let sysIdx = 0; sysIdx < systems.length; sysIdx++) {
const sys = systems[sysIdx];
sys.measures = [];
sys.cumulativeWidth = 0;
const target = sysTargetTenths[sysIdx];
while (mIdx < allMeasures.length) {
// Last system gets all remaining measures
if (sysIdx === systems.length - 1) {
const m = allMeasures[mIdx];
m.systemIdx = sysIdx;
m.startX = sys.cumulativeWidth;
sys.measures.push(m);
sys.cumulativeWidth += m.width;
mIdx++;
continue;
}
const m = allMeasures[mIdx];
const newCum = sys.cumulativeWidth + m.width;
// Each system must have at least 1 measure
if (sys.measures.length === 0) {
m.systemIdx = sysIdx;
m.startX = sys.cumulativeWidth;
sys.measures.push(m);
sys.cumulativeWidth = newCum;
mIdx++;
continue;
}
// Should this measure go in this system or the next?
// Include it if adding it brings cumulative closer to target
if (Math.abs(newCum - target) < Math.abs(sys.cumulativeWidth - target)) {
m.systemIdx = sysIdx;
m.startX = sys.cumulativeWidth;
sys.measures.push(m);
sys.cumulativeWidth = newCum;
mIdx++;
} else {
break;
}
}
}
// Log the result
systems.forEach((sys, i) => {
const nums = sys.measures.map(m => m.number).join(',');
console.log(` System ${i}: measures [${nums}] (${sys.measures.length}), width=${sys.cumulativeWidth.toFixed(0)} tenths`);
});
}
/** Parse all notes from ALL parts, with per-staff clef tracking and timing */
function parseNotes(doc, systems) {
const notes = [];
const allParts = doc.querySelectorAll("part");
// Compute staff offset per part: P1 staves 1,2 → global 1,2; P2 staves 1,2 → global 3,4
// If systems have adjusted numStaves (overflow fix), clamp offsets so all parts
// map into the actual stave range.
// If systems adjusted numStaves due to page overflow, later parts share the same
// physical staves as P1. Set their offset to 0 so globalStaff maps correctly.
const adjustedNumStaves = systems.length > 0 ? systems[0].numStaves : 99;
const partStaffOffsets = [];
let staffAccum = 0;
allParts.forEach(p => {
const offset = staffAccum >= adjustedNumStaves ? 0 : staffAccum;
partStaffOffsets.push(offset);
const firstAttr = p.querySelector("measure > attributes");
const si = firstAttr ? parseStaffInfo(firstAttr) : { numStaves: 1 };
staffAccum += si.numStaves;
});
allParts.forEach((partEl, partIdx) => {
const staffOffset = partStaffOffsets[partIdx];
let currentClefs = { 1: { sign: "G", line: 2, octaveChange: 0 } };
let divisions = 1;
let currentFifths = 0;
// Build measure lookup from systems (systems were parsed from parts[0] but measure numbers are shared)
const measuresByNum = {};
systems.forEach(sys => {
sys.measures.forEach(mInfo => { measuresByNum[mInfo.number] = mInfo; });
});
const measures = partEl.querySelectorAll("measure");
measures.forEach(mEl => {
const mNum = mEl.getAttribute("number");
const mInfo = measuresByNum[mNum];
if (!mInfo) return; // measure not in systems (shouldn't happen)
const sys = systems[mInfo.systemIdx];
// Check for clef/divisions changes in attributes
const attrEl = mEl.querySelector("attributes");
if (attrEl) {
const divEl = attrEl.querySelector("divisions");
if (divEl) divisions = parseInt(divEl.textContent) || 1;
const fifthsEl = attrEl.querySelector("key > fifths");
if (fifthsEl) currentFifths = parseInt(fifthsEl.textContent) || 0;
const newClefs = parseClefs(attrEl);
Object.assign(currentClefs, newClefs);
const si = parseStaffInfo(attrEl);
if (si.numStaves >= 2 && !currentClefs[2]) {
currentClefs[2] = { sign: "F", line: 4, octaveChange: 0 };
}
}
// Mid-measure clef changes
const midClefs = mEl.querySelectorAll("attributes clef");
midClefs.forEach(clefEl => {
const num = parseInt(clefEl.getAttribute("number") || "1");
currentClefs[num] = {
sign: clefEl.querySelector("sign")?.textContent || "G",
line: parseInt(clefEl.querySelector("line")?.textContent || "2"),
octaveChange: parseInt(clefEl.querySelector("clef-octave-change")?.textContent || "0"),
};
});
// Parse notes — single time cursor with forward/backup for multi-voice
const children = mEl.children;
let cursorTime = 0;
let lastOnset = 0;
for (let ci = 0; ci < children.length; ci++) {
const child = children[ci];
if (child.tagName === "forward") {
const dur = parseInt(child.querySelector("duration")?.textContent || "0");
cursorTime += dur;
} else if (child.tagName === "backup") {
const dur = parseInt(child.querySelector("duration")?.textContent || "0");
cursorTime -= dur;
if (cursorTime < 0) cursorTime = 0;
} else if (child.tagName === "note") {
const nEl = child;
const isRest = nEl.querySelector("rest") !== null;
const isGrace = nEl.querySelector("grace") !== null;
const voice = parseInt(nEl.querySelector("voice")?.textContent || "1");
const duration = parseInt(nEl.querySelector("duration")?.textContent || "0");
const isChord = nEl.querySelector("chord") !== null;
const onsetDiv = isChord ? lastOnset : cursorTime;
if (!isChord) {
lastOnset = cursorTime;
cursorTime += duration;
}
if (isGrace) continue;
const localStaff = parseInt(nEl.querySelector("staff")?.textContent || "1");
const globalStaff = localStaff + staffOffset;
const defaultX = parseFloat(nEl.getAttribute("default-x") || "0");
const _dataPx = nEl.hasAttribute("data-px") ? parseFloat(nEl.getAttribute("data-px")) : null;
// Restore .omr data from DOM attributes (persisted by matchOmrGrades on first load)
const _omrX = nEl.hasAttribute("data-omr-x") ? parseFloat(nEl.getAttribute("data-omr-x")) : _dataPx;
const _omrY = nEl.hasAttribute("data-omr-y") ? parseFloat(nEl.getAttribute("data-omr-y")) : null;
const _omrGrade = nEl.hasAttribute("data-omr-grade") ? parseFloat(nEl.getAttribute("data-omr-grade")) : undefined;
const _omrChordId = nEl.getAttribute("data-omr-chord-id") || null;
const _omrHeadId = nEl.getAttribute("data-omr-head-id") || null;
const clef = currentClefs[localStaff] || currentClefs[1] || { sign: "G", line: 2, octaveChange: 0 };
let step = "R", octave = 0, alter = 0;
if (!isRest) {
const pitchEl = nEl.querySelector("pitch");
if (!pitchEl) continue;
step = pitchEl.querySelector("step")?.textContent || "C";
octave = parseInt(pitchEl.querySelector("octave")?.textContent || "4");
const alterEl = pitchEl.querySelector("alter");
const hasExplicitAccidental = nEl.querySelector("accidental") !== null;
if (alterEl) {
alter = parseFloat(alterEl.textContent);
} else if (!hasExplicitAccidental) {
// No <alter> and no <accidental>: apply key signature
alter = keyAlterForStep(step, currentFifths);
}
}
notes.push({
element: nEl,
measureNum: mInfo.number,
step, octave, alter,
fifths: currentFifths,
voice, staff: globalStaff, defaultX,
partIndex: partIdx,
systemIdx: mInfo.systemIdx,
measureStartX: mInfo.startX,
systemLeftMargin: sys.leftMargin,
systemTopY: sys.topY,
staffDistance: sys.staffDistance,
clef: { ...clef },
isChord,
isRest,
divisions,
durationDiv: duration,
onsetDiv: onsetDiv,
measureIdx: mInfo.number,
modified: nEl.hasAttribute("data-modified"),
omrX: _omrX, // pixel X from .omr (or data-px for Add Mode)
omrY: _omrY, // pixel Y from .omr
grade: _omrGrade, // recognition confidence from .omr
omrChordId: _omrChordId,
omrHeadId: _omrHeadId,
px: 0, py: 0,
});
}
}
});
});
return notes;
}
/** Build a playback timeline: array of { timeSeconds, durationSeconds, noteIndices[] }
* Groups simultaneous notes (chords, multi-voice) into single events. */
function buildTimeline(notes, bpm) {
if (notes.length === 0) return [];
// Check if notes are .omr-based (rational durations) or XML-based (divisions)
const isOmrBased = notes.length > 0 && notes[0]._omrBased;
// Step 1: compute measure durations
const measureDurations = {}; // measureNum → duration (in whole notes for omr, divisions for xml)
if (isOmrBased) {
// .omr mode: get measure durations from systemsData stacks
for (const sys of systemsData) {
for (const m of sys.measures) {
measureDurations[m.number] = parseRational(m.duration || "1");
}
}
// Auto-correct: if any note's onset+duration exceeds stack duration, extend it
// This fixes Audiveris miscalculated stack durations without modifying omrData
notes.forEach(n => {
const mNum = String(n.measureNum);
const end = (n.onsetDiv || 0) + (n.durationDiv || 0);
if (measureDurations[mNum] !== undefined && end > measureDurations[mNum] + 0.001) {
measureDurations[mNum] = end;
}
});
} else {
// Legacy XML mode
let currentBeats = carryBeats, currentBeatType = carryBeatType, currentDivisions = 1;
if (xmlDoc) {
const parts = xmlDoc.querySelectorAll("part");
if (parts.length > 0) {
const measures = parts[0].querySelectorAll("measure");
measures.forEach(mEl => {
const attrEl = mEl.querySelector("attributes");
if (attrEl) {
const divEl = attrEl.querySelector("divisions");
if (divEl) currentDivisions = parseInt(divEl.textContent) || 1;
const timeEl = attrEl.querySelector("time");
if (timeEl) {
currentBeats = parseInt(timeEl.querySelector("beats")?.textContent || "4");
currentBeatType = parseInt(timeEl.querySelector("beat-type")?.textContent || "4");
}
}
const mNum = mEl.getAttribute("number");
measureDurations[mNum] = currentBeats * currentDivisions * (4 / currentBeatType);
});
}
}
carryBeats = currentBeats;
carryBeatType = currentBeatType;
// Reconcile with actual note content
if (xmlDoc) {
const parts = xmlDoc.querySelectorAll("part");
if (parts.length > 0) {
const measures = parts[0].querySelectorAll("measure");
measures.forEach(mEl => {
const mNum = mEl.getAttribute("number");
let cursor = 0, maxCursor = 0;
for (const el of mEl.children) {
if (el.tagName === "note") {
const durEl = el.querySelector("duration");
if (durEl && !el.querySelector("chord")) {
cursor += parseInt(durEl.textContent) || 0;
}
if (cursor > maxCursor) maxCursor = cursor;
} else if (el.tagName === "forward") {
const durEl = el.querySelector("duration");
if (durEl) cursor += parseInt(durEl.textContent) || 0;
if (cursor > maxCursor) maxCursor = cursor;
} else if (el.tagName === "backup") {
const durEl = el.querySelector("duration");
if (durEl) cursor -= parseInt(durEl.textContent) || 0;
}
}
if (maxCursor > 0) {
const existing = measureDurations[mNum];
measureDurations[mNum] = existing ? Math.max(existing, maxCursor) : maxCursor;
}
});
}
}
}
// Step 2: pre-compute measure start positions
const measureStartLookup = {};
{
let accum = 0;
const sortedMeasures = Object.keys(measureDurations).sort((a, b) => parseInt(a) - parseInt(b));
for (const mNum of sortedMeasures) {
measureStartLookup[mNum] = accum;
accum += measureDurations[mNum];
}
}
const noteEvents = [];
let misplacedFiltered = 0;
if (isOmrBased) {
// .omr mode: durations are in whole-note fractions, convert to seconds
// whole note = 4 beats, so 1 whole note at BPM = 4 * (60/bpm) seconds
const wholeNoteSec = 4 * (60 / bpm);
notes.forEach((n, idx) => {
if (n.isRest) return;
// orphan notes (no slot assigned by Audiveris) still included with onset=0 fallback
const measureStart = measureStartLookup[n.measureNum] || 0;
const absOnset = measureStart + (n.onsetDiv || 0); // in whole-note fractions
const timeSec = absOnset * wholeNoteSec;
const durSec = (n.durationDiv || 0) * wholeNoteSec;
noteEvents.push({
idx,
absOnsetDiv: absOnset,
onsetInMeasure: n.onsetDiv,
durationDiv: n.durationDiv,
timeSec,
durSec,
measureNum: n.measureNum,
_px: n.defaultX || 0,
_voice: n.voice,
_partIndex: n.partIndex || 0,
});
});
// Note: orphan notes (no slot from Audiveris) are included with onset=0 fallback.
} else {
// Legacy XML mode
notes.forEach((n, idx) => {
if (n.isRest) return;
const measureStartDiv = measureStartLookup[n.measureNum] || 0;
const divisions = n.divisions || 1;
const absOnsetDiv = measureStartDiv + n.onsetDiv;
const secPerDiv = 60 / (bpm * divisions);
const timeSec = absOnsetDiv * secPerDiv;
const durSec = n.durationDiv * secPerDiv;
noteEvents.push({
idx,
absOnsetDiv,
onsetInMeasure: n.onsetDiv,
durationDiv: n.durationDiv,
timeSec,
durSec,
measureNum: n.measureNum,
});
});
}
// Step 3: group by onset time (within 0.02s tolerance)
noteEvents.sort((a, b) => a.timeSec - b.timeSec || a.idx - b.idx);
const timeline = [];
let i = 0;
while (i < noteEvents.length) {
const t = noteEvents[i].timeSec;
const group = [];
let maxDur = 0;
while (i < noteEvents.length && Math.abs(noteEvents[i].timeSec - t) < 0.02) {
group.push(noteEvents[i].idx);
if (noteEvents[i].durSec > maxDur) maxDur = noteEvents[i].durSec;
i++;
}
timeline.push({ timeSec: t, durationSec: maxDur, noteIndices: group });
}
console.log(`Timeline: ${timeline.length} events from ${notes.length} notes, total ${timeline.length > 0 ? timeline[timeline.length-1].timeSec.toFixed(1) : 0}s`);
// DEBUG: build debug dump for playback diagnosis (downloadable via DBG button)
window._lastTimelineDebug = (() => {
const lines = [];
lines.push(`=== buildTimeline DEBUG ===`);
const orphanCount = notes.filter(n => n.orphan).length;
lines.push(`isOmrBased: ${isOmrBased}, BPM: ${bpm}, Total notes: ${notes.length}, Orphans: ${orphanCount}, Misplaced onset=0: ${misplacedFiltered}, NoteEvents: ${noteEvents.length}, Groups: ${timeline.length}`);
lines.push(``);
lines.push(`--- measureDurations ---`);
const sortedMD = Object.keys(measureDurations).sort((a, b) => parseInt(a) - parseInt(b));
for (const k of sortedMD) lines.push(` m${k}: dur=${measureDurations[k]}`);
lines.push(``);
lines.push(`--- measureStartLookup ---`);
const sortedMS = Object.keys(measureStartLookup).sort((a, b) => parseInt(a) - parseInt(b));
for (const k of sortedMS) lines.push(` m${k}: start=${measureStartLookup[k]}`);
lines.push(``);
lines.push(`--- noteEvents (sorted by timeSec, first 80) ---`);
for (let ei = 0; ei < Math.min(80, noteEvents.length); ei++) {
const e = noteEvents[ei];
const n = notes[e.idx];
const px = n.pxDefault?.toFixed(0) || n.defaultX?.toFixed(0) || '?';
lines.push(` [${ei}] m${e.measureNum} onset=${e.onsetInMeasure?.toFixed?.(4) ?? e.onsetInMeasure} abs=${e.absOnsetDiv?.toFixed?.(4) ?? e.absOnsetDiv} t=${e.timeSec.toFixed(3)}s dur=${e.durSec.toFixed(3)}s | ${n.step}${n.octave} staff=${n.staff} voice=${n.voice} part=${n.partIndex} px=${px} div=${n.divisions || '?'} grade=${n.grade || '?'} chordId=${n.omrChordId||'?'} tRat=${n.timeOffsetRational||'?'}`);
}
if (noteEvents.length > 80) lines.push(` ... (${noteEvents.length - 80} more)`);
lines.push(``);
lines.push(`--- timeline groups (first 50) ---`);
for (let ti = 0; ti < Math.min(50, timeline.length); ti++) {
const g = timeline[ti];
const noteDescs = g.noteIndices.map(idx => {
const n = notes[idx];
return `${n.step}${n.octave}(s${n.staff}v${n.voice}g=${n.grade||'?'})`;
}).join(", ");
lines.push(` [${ti}] t=${g.timeSec.toFixed(3)}s dur=${g.durationSec.toFixed(3)}s | ${noteDescs}`);
}
if (timeline.length > 50) lines.push(` ... (${timeline.length - 50} more)`);
return lines.join("\n");
})();
return timeline;
}
// ================================================================
// Section 2: Coordinate Mapping
// ================================================================
function computePixelsPerTenthFromImage(imageWidth, pageWidthTenths) {
// Most accurate: directly map XML page width to actual image width
return imageWidth / pageWidthTenths;
}
function computePixelsPerTenthFromDpi(dpi, mm, tpu) {
return (dpi * mm) / (tpu * 25.4);
}
function diatonicIndex(step, octave) {
return octave * 7 + STEP_INDEX[step];
}
function clefReferencePosition(clef) {
// staffPosition: 0 = bottom line (line 1), 2 = line 2, etc.
const linePos = (clef.line - 1) * 2;
let refStep, refBaseOctave;
if (clef.sign === "G") { refStep = "G"; refBaseOctave = 4; }
else if (clef.sign === "F") { refStep = "F"; refBaseOctave = 3; }
else if (clef.sign === "C") { refStep = "C"; refBaseOctave = 4; }
else { refStep = "G"; refBaseOctave = 4; }
const refOctave = refBaseOctave + clef.octaveChange;
const refDiatonic = diatonicIndex(refStep, refOctave);
return { diatonicIdx: refDiatonic, staffPosition: linePos };
}
// ── OMR data conversion helpers ──────────────────────────────
/**
* Convert .omr staves data to the same format as detectStaffLines() output.
* .omr staves have precise spline points; we use y1 (start) for each line.
*/
function omrStavesToDetected(omrData) {
const result = [];
for (const sys of omrData.systems) {
for (const staff of sys.staves) {
if (staff.lines.length < 5) continue;
const ys = staff.lines.map(l => l.y1);
ys.sort((a, b) => a - b);
const spacing = (ys[4] - ys[0]) / 4;
result.push({
lines: ys,
topLineY: ys[0],
bottomLineY: ys[4],
lineSpacing: spacing,
leftX: staff.left,
rightX: staff.right,
});
}
}
result.sort((a, b) => a.topLineY - b.topLineY);
return result;
}
/**
* Convert .omr barlines to detectedBarlines format.
* Maps each barline's staff ID to a system index using staff→system mapping.
*/
function omrBarlinesToDetected(omrData, staves, numStavesPerSys) {
const result = [];
let staffOffset = 0;
for (let sysIdx = 0; sysIdx < omrData.systems.length; sysIdx++) {
const sys = omrData.systems[sysIdx];
const sysStaffIds = sys.staves.map(s => s.staffId);
for (const bl of sys.barlines) {
if (!bl.bounds) continue;
// Skip left/right system-end barlines (they're structural, not measure barlines)
if (bl.staffEnd === "LEFT" || bl.staffEnd === "RIGHT") continue;
result.push({
x: bl.bounds.x + bl.bounds.w / 2,
systemIdx: sysIdx,
confidence: bl.grade,
source: "omr",
});
}
}
return result;
}
/**
* Match .omr head grades to noteInfos by measure + voice + order.
* Copies grade into each noteInfo as a one-time operation at load.
*/
function matchOmrGrades(notes, omrData) {
// Build a flat list of measure→chords from all systems
// measureId in .omr is 1-based per system; MusicXML measure number is global.
// We match by global order: .omr systems in order, measures in order within each system.
const omrMeasures = [];
for (const sys of omrData.systems) {
// Build global→local staff mapping for this system
const staffIds = sys.staves.map(s => s.staffId).sort((a, b) => a - b);
const staffGlobalToLocal = {};
staffIds.forEach((gid, idx) => { staffGlobalToLocal[gid] = idx + 1; });
for (const meas of sys.measures) {
omrMeasures.push({ ...meas, _staffMap: staffGlobalToLocal });
}
}
// Group noteInfos by MusicXML measure number (1-based string)
const notesByMeasure = {};
for (const n of notes) {
const mNum = n.measureNum || "0";
if (!notesByMeasure[mNum]) notesByMeasure[mNum] = [];
notesByMeasure[mNum].push(n);
}
// Match: for each MusicXML measure (in order), pair with .omr measure (in order)
const xmlMeasureNums = Object.keys(notesByMeasure).sort((a, b) => parseInt(a) - parseInt(b));
let matched = 0, unmatched = 0;
for (let mi = 0; mi < xmlMeasureNums.length && mi < omrMeasures.length; mi++) {
const mNum = xmlMeasureNums[mi];
const xmlNotes = notesByMeasure[mNum];
const omrMeas = omrMeasures[mi];
// Separate XML notes by staff, then match chord-by-chord
// .omr headChords are ordered per measure; XML notes are in document order
// Both come from Audiveris, so order should align.
const xmlNotesNonChord = [];
for (let ni = 0; ni < xmlNotes.length; ni++) {
const n = xmlNotes[ni];
// Skip chord continuation notes and rests
// (headChords in .omr only contain pitched notes, not rests)
if (n.isChord || n.isRest) continue;
xmlNotesNonChord.push(n);
}
// Match head-chords to non-chord XML notes
// headChords may span both staves; XML notes are interleaved by voice
// Group by staff for better matching
const omrByStaff = {};
const staffMap = omrMeas._staffMap || {};
for (const hc of omrMeas.headChords) {
if (hc.heads.length === 0) continue;
const globalStaff = hc.heads[0].staff;
const localStaff = staffMap[globalStaff] || globalStaff;
if (!omrByStaff[localStaff]) omrByStaff[localStaff] = [];
omrByStaff[localStaff].push(hc);
}
const xmlByStaff = {};
for (const n of xmlNotesNonChord) {
const staff = n.staff || 1;
if (!xmlByStaff[staff]) xmlByStaff[staff] = [];
xmlByStaff[staff].push(n);
}
for (const staffKey of Object.keys(xmlByStaff)) {
const xmlStaffNotes = xmlByStaff[staffKey];
const omrStaffChords = omrByStaff[staffKey] || [];
// Match by nearest X position instead of sequential index
// This prevents misalignment when notes are deleted (become rests)
const usedOmrIndices = new Set();
for (let xi = 0; xi < xmlStaffNotes.length; xi++) {
const n = xmlStaffNotes[xi];
// Find closest unmatched omr headChord by X (bounds center)
let bestCi = -1, bestDist = Infinity;
for (let oi = 0; oi < omrStaffChords.length; oi++) {
if (usedOmrIndices.has(oi)) continue;
const hc = omrStaffChords[oi];
if (hc.heads.length === 0 || !hc.heads[0].bounds) continue;
const omrCx = hc.heads[0].bounds.x + hc.heads[0].bounds.w / 2;
const dist = Math.abs(omrCx - n.defaultX);
if (dist < bestDist) { bestDist = dist; bestCi = oi; }
}
if (bestCi < 0) continue;
usedOmrIndices.add(bestCi);
const ci = bestCi;
const hc = omrStaffChords[ci];
// Use the minimum head grade (most uncertain note in chord)
const minGrade = Math.min(...hc.heads.map(h => h.grade));
n.grade = minGrade;
n.omrChordId = hc.chordId;
n.omrHeadId = hc.heads.length > 0 ? hc.heads[0].headId : null;
// Copy pixel-perfect position from Audiveris head bounds
if (hc.heads.length > 0 && hc.heads[0].bounds) {
const hb = hc.heads[0].bounds;
n.omrX = hb.x + hb.w / 2;
n.omrY = hb.y + hb.h / 2;
}
// Persist to DOM so re-parse doesn't need re-matching
if (n.element) {
if (n.omrX != null) n.element.setAttribute("data-omr-x", n.omrX);
if (n.omrY != null) n.element.setAttribute("data-omr-y", n.omrY);
if (n.grade != null) n.element.setAttribute("data-omr-grade", n.grade);
if (n.omrChordId != null) n.element.setAttribute("data-omr-chord-id", n.omrChordId);
if (n.omrHeadId != null) n.element.setAttribute("data-omr-head-id", n.omrHeadId);
}
matched++;
// Also assign grade + bounds to chord members (notes with isChord=true following this note)
const nIdx = xmlNotes.indexOf(n);
if (nIdx >= 0) {
let chordHeadIdx = 1; // first head already used for primary note
for (let ci2 = nIdx + 1; ci2 < xmlNotes.length; ci2++) {
if (!xmlNotes[ci2].isChord) break;
xmlNotes[ci2].grade = minGrade;
xmlNotes[ci2].omrChordId = hc.chordId;
xmlNotes[ci2].omrHeadId = chordHeadIdx < hc.heads.length ? hc.heads[chordHeadIdx].headId : null;
if (chordHeadIdx < hc.heads.length && hc.heads[chordHeadIdx].bounds) {
const hb2 = hc.heads[chordHeadIdx].bounds;
xmlNotes[ci2].omrX = hb2.x + hb2.w / 2;
xmlNotes[ci2].omrY = hb2.y + hb2.h / 2;
}
// Persist chord member to DOM too
if (xmlNotes[ci2].element) {
if (xmlNotes[ci2].omrX != null) xmlNotes[ci2].element.setAttribute("data-omr-x", xmlNotes[ci2].omrX);
if (xmlNotes[ci2].omrY != null) xmlNotes[ci2].element.setAttribute("data-omr-y", xmlNotes[ci2].omrY);
if (xmlNotes[ci2].grade != null) xmlNotes[ci2].element.setAttribute("data-omr-grade", xmlNotes[ci2].grade);
if (xmlNotes[ci2].omrChordId != null) xmlNotes[ci2].element.setAttribute("data-omr-chord-id", xmlNotes[ci2].omrChordId);
if (xmlNotes[ci2].omrHeadId != null) xmlNotes[ci2].element.setAttribute("data-omr-head-id", xmlNotes[ci2].omrHeadId);
}
chordHeadIdx++;
}
}
}
unmatched += omrStaffChords.length - usedOmrIndices.size + Math.max(0, xmlStaffNotes.length - usedOmrIndices.size);
}
// Also assign grades for rest-chords (no head grade, but mark as recognized)
// Rests don't need grade matching — they're typically high confidence
}
console.log(`OMR grade matching: ${matched} matched, ${unmatched} unmatched across ${xmlMeasureNums.length} measures`);
// Extract free glyphs for overlay display
freeGlyphData = [];
for (let si = 0; si < omrData.systems.length; si++) {
const sys = omrData.systems[si];
if (!sys.freeGlyphs) continue;
for (const fg of sys.freeGlyphs) {
freeGlyphData.push({ ...fg, systemIdx: si });
}
}
console.log(`Free glyphs: ${freeGlyphData.length} candidates`);
if (freeGlyphsVisible) renderFreeGlyphOverlays();
}
// ── Free glyph overlays ──────────────────────────────────────
const _GLYPH_SHAPES = [
{ cat: "Notes", cat_ko: "음표", items: [
{ shape: "NOTEHEAD_BLACK", label: "● Black (filled)", label_ko: "● 검은 음표머리" },
{ shape: "NOTEHEAD_VOID", label: "○ Void (half)", label_ko: "○ 빈 음표머리 (2분)" },
{ shape: "WHOLE_NOTE", label: "◎ Whole", label_ko: "◎ 온음표" },
]},
{ cat: "Rests", cat_ko: "쉼표", items: [
{ shape: "QUARTER_REST", label: "𝄾 Quarter", label_ko: "𝄾 4분쉼표" },
{ shape: "EIGHTH_REST", label: "𝄿 Eighth", label_ko: "𝄿 8분쉼표" },
{ shape: "HALF_REST", label: "▬ Half", label_ko: "▬ 2분쉼표" },
{ shape: "WHOLE_REST", label: "▄ Whole", label_ko: "▄ 온쉼표" },
]},
{ cat: "Accidentals", cat_ko: "임시표", items: [
{ shape: "SHARP", label: "♯ Sharp", label_ko: "♯ 올림표" },
{ shape: "FLAT", label: "♭ Flat", label_ko: "♭ 내림표" },
{ shape: "NATURAL", label: "♮ Natural", label_ko: "♮ 제자리표" },
]},
// { cat: "Clefs", cat_ko: "음자리표", items: [
// { shape: "G_CLEF", label: "𝄞 Treble", label_ko: "𝄞 높은음자리표" },
// { shape: "F_CLEF", label: "𝄢 Bass", label_ko: "𝄢 낮은음자리표" },
// { shape: "C_CLEF", label: "𝄡 Alto/Tenor", label_ko: "𝄡 가온음자리표" },
// ]},
// { cat: "Dynamics", cat_ko: "셈여림", items: [
// { shape: "DYNAMICS_F", label: "f Forte", label_ko: "f 포르테" },
// { shape: "DYNAMICS_P", label: "p Piano", label_ko: "p 피아노" },
// ]},
// { cat: "Other", cat_ko: "기타", items: [
// { shape: "FERMATA", label: "𝄐 Fermata", label_ko: "𝄐 늘임표" },
// { shape: "FLAG_8TH", label: "⚑ 8th flag", label_ko: "⚑ 8분 꼬리" },
// { shape: "FLAG_16TH", label: "⚑⚑ 16th flag", label_ko: "⚑⚑ 16분 꼬리" },
// { shape: "DOT", label: "• Dot", label_ko: "• 점" },
// ]},
];
function toggleFreeGlyphs() {
freeGlyphsVisible = !freeGlyphsVisible;
console.log(`toggleFreeGlyphs: visible=${freeGlyphsVisible}, data=${freeGlyphData.length} glyphs`);
if (freeGlyphsVisible && freeGlyphData.length === 0) {
const pg = pages[currentPageIdx];
const hasOmr = pg && pg.omrData;
const fgCount = hasOmr ? (pg.omrData.systems || []).reduce((s, sys) => s + (sys.freeGlyphs || []).length, 0) : 0;
console.warn(`No freeGlyphData. omrData=${!!hasOmr}, server freeGlyphs=${fgCount}`);
document.getElementById("status-selection").textContent = hasOmr
? (fgCount > 0 ? `Free glyphs: ${fgCount} (re-match needed)` : "No free glyphs from server — reload .omr")
: "No .omr loaded";
}
renderFreeGlyphOverlays();
const btn = document.getElementById("btn-show-free-glyphs");
if (btn) {
btn.style.background = freeGlyphsVisible ? "#a66200" : "";
btn.style.color = freeGlyphsVisible ? "#fff" : "";
}
}
function renderFreeGlyphOverlays() {
const svg = document.getElementById("marker-svg");
svg.querySelectorAll(".free-glyph-box").forEach(el => el.remove());
if (!freeGlyphsVisible || !freeGlyphData.length) return;
const ux = parseFloat(document.getElementById("offset-x").value || 0);
const uy = parseFloat(document.getElementById("offset-y").value || 0);
freeGlyphData.forEach((g, idx) => {
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("x", g.x + ux);
rect.setAttribute("y", g.y + uy);
rect.setAttribute("width", g.w);
rect.setAttribute("height", g.h);
rect.classList.add("free-glyph-box");
if (g._assigned) rect.classList.add("assigned");
rect.dataset.fgIdx = idx;
rect.addEventListener("click", (e) => { e.stopPropagation(); openGlyphAssignPopup(idx, e); });
svg.appendChild(rect);
});
}
function openGlyphAssignPopup(fgIdx, event) {
const popup = document.getElementById("glyph-popup");
const list = document.getElementById("glyph-popup-list");
list.innerHTML = "";
const g = freeGlyphData[fgIdx];
// Navigate timeline to the measure containing this glyph
const glyphCx = g.x + g.w / 2;
let closestIdx = -1, closestDist = Infinity;
noteInfos.forEach((n, i) => {
if (n.systemIdx !== g.systemIdx) return;
const d = Math.abs(n.px - glyphCx);
if (d < closestDist) { closestDist = d; closestIdx = i; }
});
if (closestIdx >= 0) {
const n = noteInfos[closestIdx];
renderTimelinePanel(n.measureNum, n.systemIdx);
}
const isKo = currentLang === "ko";
for (const cat of _GLYPH_SHAPES) {
const catLi = document.createElement("li");
catLi.className = "glyph-cat";
catLi.textContent = isKo ? cat.cat_ko : cat.cat;
list.appendChild(catLi);
for (const item of cat.items) {
const li = document.createElement("li");
li.textContent = isKo ? item.label_ko : item.label;
li.addEventListener("click", () => {
assignGlyphShape(fgIdx, item.shape);
popup.classList.add("hidden");
});
list.appendChild(li);
}
}
// Position popup at cursor, clamped to viewport
popup.classList.remove("hidden");
const popupRect = popup.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = event.clientX + 5;
let top = event.clientY + 5;
if (left + popupRect.width > vw) left = vw - popupRect.width - 5;
if (top + popupRect.height > vh) top = event.clientY - popupRect.height - 5;
if (top < 0) top = 5;
if (left < 0) left = 5;
popup.style.position = "fixed";
popup.style.left = left + "px";
popup.style.top = top + "px";
}
function glyphYToPitch(centerY, omrData, systemIdx) {
// Find the closest staff and compute staff-relative pitch
if (!omrData || !omrData.systems || !omrData.systems[systemIdx]) return { pitch: 0, staffId: "1" };
const sys = omrData.systems[systemIdx];
let bestStaff = null, bestDist = Infinity;
for (const staff of sys.staves) {
if (!staff.lines || staff.lines.length < 5) continue;
const topY = staff.lines[0].y1;
const botY = staff.lines[4].y1;
const midY = (topY + botY) / 2;
const dist = Math.abs(centerY - midY);
if (dist < bestDist) {
bestDist = dist;
bestStaff = staff;
}
}
if (!bestStaff || bestStaff.lines.length < 5) return { pitch: 0, staffId: "1" };
const topY = bestStaff.lines[0].y1;
const botY = bestStaff.lines[4].y1;
const interline = (botY - topY) / 4;
const midY = (topY + botY) / 2; // middle line = pitch 0
// pitch increases downward: positive = below middle line
const halfSteps = Math.round((centerY - midY) / (interline / 2));
return { pitch: halfSteps, staffId: String(bestStaff.staffId) };
}
function assignGlyphShape(fgIdx, shape) {
const g = freeGlyphData[fgIdx];
const pg = pages[currentPageIdx];
if (!pg || !pg.omrData) return;
const centerX = g.x + g.w / 2;
const centerY = g.y + g.h / 2;
const { pitch, staffId } = glyphYToPitch(centerY, pg.omrData, g.systemIdx);
pushUndo();
recordOmrEdit({
type: "assign_glyph",
glyphId: g.glyphId,
shape: shape,
staff: staffId,
pitch: pitch,
systemIdx: g.systemIdx,
});
// ── Also add to omrData for immediate visual feedback ──
const sys = pg.omrData.systems[g.systemIdx];
if (sys) {
// Find which measure this glyph falls in by X coordinate
const sysInfo = systemsData[g.systemIdx];
let targetMeas = null;
if (sysInfo) {
for (const m of sysInfo.measures) {
if (centerX >= (m.left || 0) && centerX <= (m.right || Infinity)) {
// Find corresponding omrData measure
let globalBase = 1;
for (let si = 0; si < g.systemIdx; si++) {
globalBase += (pg.omrData.systems[si].stacks || []).length || 1;
}
const numStacks = (sys.stacks || []).length || 1;
const stackIdx = parseInt(m.number) - globalBase;
if (stackIdx >= 0 && stackIdx < sys.measures.length) {
targetMeas = sys.measures[stackIdx];
}
break;
}
}
}
if (!targetMeas && sys.measures.length > 0) targetMeas = sys.measures[0];
if (targetMeas) {
// Generate unique ID
let maxId = 0;
for (const s of pg.omrData.systems) {
for (const m of s.measures) {
for (const hc of m.headChords) { const id = parseInt(hc.chordId) || 0; if (id > maxId) maxId = id; }
for (const rc of m.restChords) { const id = parseInt(rc.chordId) || 0; if (id > maxId) maxId = id; }
}
}
const newId = String(maxId + 1);
const NOTE_SHAPES = ["NOTEHEAD_BLACK", "NOTEHEAD_VOID", "WHOLE_NOTE"];
const REST_SHAPES = ["QUARTER_REST", "EIGHTH_REST", "HALF_REST", "WHOLE_REST", "16TH_REST", "32ND_REST"];
const SHAPE_TO_DUR = {
"NOTEHEAD_BLACK": "1/4", "NOTEHEAD_VOID": "1/2", "WHOLE_NOTE": "1/1",
"QUARTER_REST": "1/4", "EIGHTH_REST": "1/8", "HALF_REST": "1/2", "WHOLE_REST": "1/1",
"16TH_REST": "1/16", "32ND_REST": "1/32",
};
const dur = SHAPE_TO_DUR[shape] || "1/4";
// Estimate timeOffset from X position within measure
const measLeft = sysInfo ? (sysInfo.measures.find(m => {
let globalBase = 1;
for (let si = 0; si < g.systemIdx; si++) globalBase += (pg.omrData.systems[si].stacks || []).length || 1;
const numStacks = (sys.stacks || []).length || 1;
const stackIdx = parseInt(m.number) - globalBase;
return sys.measures[stackIdx] === targetMeas;
}) || {}).left || 0 : 0;
const measRight = sysInfo ? (sysInfo.measures.find(m => {
let globalBase = 1;
for (let si = 0; si < g.systemIdx; si++) globalBase += (pg.omrData.systems[si].stacks || []).length || 1;
const stackIdx = parseInt(m.number) - globalBase;
return sys.measures[stackIdx] === targetMeas;
}) || {}).right || (measLeft + 200) : measLeft + 200;
const measDur = parseRational(targetMeas.duration || "1");
const ratio = measRight > measLeft ? Math.max(0, Math.min(1, (centerX - measLeft) / (measRight - measLeft))) : 0;
const gridSize = 0.125; // 1/8 default snap
let onset = Math.round((ratio * measDur) / gridSize) * gridSize;
onset = Math.max(0, Math.min(onset, measDur - gridSize));
const timeOffset = durationFloatToRational(onset);
if (NOTE_SHAPES.includes(shape)) {
targetMeas.headChords.push({
chordId: newId,
duration: dur,
timeOffset,
voice: 1,
dotsNumber: 0,
heads: [{
headId: newId + "-h1",
pitch,
alter: 0,
staff: parseInt(staffId),
shape,
grade: 0.5,
bounds: { x: Math.round(g.x), y: Math.round(g.y), w: Math.round(g.w), h: Math.round(g.h) },
}],
});
} else if (REST_SHAPES.includes(shape)) {
targetMeas.restChords.push({
chordId: newId,
restShape: shape,
duration: dur,
timeOffset,
voice: 1,
staff: parseInt(staffId),
bounds: { x: Math.round(g.x), y: Math.round(g.y), w: Math.round(g.w), h: Math.round(g.h) },
});
}
// Accidentals: just record edit, server handles association
}
}
// Mark as assigned visually
g._assigned = true;
renderFreeGlyphOverlays();
// Rebuild to show the new note/rest
rebuildSystemsAndNotes();
const ux = parseInt(document.getElementById("offset-x").value) || 0;
const uy = parseInt(document.getElementById("offset-y").value) || 0;
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
}
// Close glyph popup on outside click
document.addEventListener("click", (e) => {
const popup = document.getElementById("glyph-popup");
if (popup && !popup.contains(e.target) && !e.target.classList.contains("free-glyph-box")) {
popup.classList.add("hidden");
}
});
// ── Image-based staff line detection ─────────────────────────
let detectedStaves = []; // array of { topLineY, bottomLineY, lineSpacing, lines: [y1..y5] }
let cachedImageData = null; // cached {data, width, height} from last detectStaffLines call
let detectedBarlines = []; // array of {x, systemIdx, confidence, source:'auto'|'manual'|'xml'}
let barlineOverlaysVisible = false;
function detectStaffLines(imgElement) {
const canvas = document.createElement("canvas");
const w = imgElement.naturalWidth;
const h = imgElement.naturalHeight;
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.drawImage(imgElement, 0, 0);
const imgData = ctx.getImageData(0, 0, w, h);
const pixels = imgData.data;
// Cache imageData for barline detection reuse
cachedImageData = { data: pixels, width: w, height: h };
// === Step 1: Run-length based horizontal line detection ===
// For each row, measure what fraction of the width is "dark" in long continuous runs.
// Staff lines span nearly the full page width; watermarks, text, beams don't.
const darkThreshold = 160; // pixel brightness below this = dark
const minRunLength = Math.floor(w * 0.15); // a "line" run must be >= 15% of width
const lineScore = new Float32Array(h); // fraction of width covered by long dark runs
// Also track the leftmost start and rightmost end of long runs per row
const lineLeftX = new Int32Array(h).fill(w); // leftmost start of a long run
const lineRightX = new Int32Array(h).fill(0); // rightmost end of a long run
for (let y = 0; y < h; y++) {
let runStart = -1;
let totalLinePx = 0;
for (let x = 0; x <= w; x++) {
let isDark = false;
if (x < w) {
const idx = (y * w + x) * 4;
const gray = (pixels[idx] + pixels[idx + 1] + pixels[idx + 2]) / 3;
isDark = gray < darkThreshold;
}
if (isDark) {
if (runStart < 0) runStart = x;
} else {
if (runStart >= 0) {
const runLen = x - runStart;
if (runLen >= minRunLength) {
totalLinePx += runLen;
if (runStart < lineLeftX[y]) lineLeftX[y] = runStart;
if (x > lineRightX[y]) lineRightX[y] = x;
}
runStart = -1;
}
}
}
lineScore[y] = totalLinePx / w;
}
// === Step 2: Find candidate line rows ===
// Staff line rows have high lineScore (>30% coverage by long dark runs)
const scoreSorted = Array.from(lineScore).filter(v => v > 0).sort((a, b) => b - a);
// Adaptive threshold: staff lines should be in top scores
// Use 50% of the score at the 5th percentile of non-zero values
let lineThreshold = 0.3; // default minimum
if (scoreSorted.length > 20) {
const p5 = scoreSorted[Math.floor(scoreSorted.length * 0.05)];
lineThreshold = Math.max(0.25, p5 * 0.5);
}
const candidates = [];
for (let y = 1; y < h - 1; y++) {
if (lineScore[y] >= lineThreshold) {
candidates.push(y);
}
}
// === Step 3: Merge adjacent rows into single line positions ===
const merged = [];
let i = 0;
while (i < candidates.length) {
let j = i;
let sumY = 0, sumScore = 0, count = 0;
while (j < candidates.length && candidates[j] - candidates[i] <= 3) {
sumY += candidates[j] * lineScore[candidates[j]]; // weighted by score
sumScore += lineScore[candidates[j]];
count++;
j++;
}
merged.push(Math.round(sumY / sumScore)); // score-weighted centroid
i = j;
}
// === Step 4: Group into staves of 5 with consistent spacing ===
const staves = groupIntoStaves(merged);
// === Step 5: Compute left/right X extent for each staff ===
// Average the leftX/rightX across the 5 staff lines of each staff
staves.forEach(staff => {
let sumL = 0, sumR = 0, count = 0;
staff.lines.forEach(ly => {
// Check a few rows around the detected line Y
for (let dy = -1; dy <= 1; dy++) {
const yy = ly + dy;
if (yy >= 0 && yy < h && lineLeftX[yy] < w && lineRightX[yy] > 0) {
sumL += lineLeftX[yy];
sumR += lineRightX[yy];
count++;
}
}
});
staff.leftX = count > 0 ? Math.round(sumL / count) : 0;
staff.rightX = count > 0 ? Math.round(sumR / count) : w;
});
console.log(`Staff detection: ${candidates.length} candidate rows → ${merged.length} line positions → ${staves.length} staves`);
console.log(` lineThreshold=${lineThreshold.toFixed(3)}, minRunLength=${minRunLength}`);
staves.forEach((s, si) => console.log(` Staff ${si}: lines=[${s.lines.join(",")}] spacing=${s.lineSpacing.toFixed(1)} X=[${s.leftX}..${s.rightX}]`));
return staves;
}
function groupIntoStaves(lines) {
if (lines.length < 5) return [];
const staves = [];
const usedLines = new Set(); // track used Y values
// Spacing-based search: for each pair (i, j), treat gap as candidate spacing,
// then look for 3 more lines at 2x, 3x, 4x that spacing.
// This handles noise lines between staff lines.
const tolerance = 3; // pixels
function findNearest(targetY) {
let best = -1, bestDist = Infinity;
for (let k = 0; k < lines.length; k++) {
const dist = Math.abs(lines[k] - targetY);
if (dist < bestDist) { bestDist = dist; best = k; }
}
return bestDist <= tolerance ? best : -1;
}
// Collect all valid 5-line groups, sorted by quality (lowest maxDev first)
const candidates = [];
for (let i = 0; i < lines.length; i++) {
for (let j = i + 1; j < lines.length; j++) {
const spacing = lines[j] - lines[i];
if (spacing < 5 || spacing > 35) continue;
// Look for lines at i + 2*spacing, 3*spacing, 4*spacing
const idx2 = findNearest(lines[i] + spacing * 2);
if (idx2 < 0) continue;
const idx3 = findNearest(lines[i] + spacing * 3);
if (idx3 < 0) continue;
const idx4 = findNearest(lines[i] + spacing * 4);
if (idx4 < 0) continue;
const group = [lines[i], lines[j], lines[idx2], lines[idx3], lines[idx4]];
group.sort((a, b) => a - b);
const totalSpan = group[4] - group[0];
if (totalSpan >= 150) continue;
const spacings = [];
for (let k = 1; k < 5; k++) spacings.push(group[k] - group[k - 1]);
const avgSpacing = spacings.reduce((a, b) => a + b, 0) / 4;
const maxDev = Math.max(...spacings.map(s => Math.abs(s - avgSpacing)));
if (maxDev < avgSpacing * 0.2) {
candidates.push({ group, avgSpacing, maxDev });
}
}
}
// Sort by quality: lowest deviation first
candidates.sort((a, b) => a.maxDev - b.maxDev);
// Greedily pick non-overlapping staves
for (const c of candidates) {
// Check none of the lines are already used
if (c.group.some(y => usedLines.has(y))) continue;
staves.push({
lines: c.group,
topLineY: c.group[0],
bottomLineY: c.group[4],
lineSpacing: c.avgSpacing,
});
c.group.forEach(y => usedLines.add(y));
}
// Sort staves top to bottom
staves.sort((a, b) => a.topLineY - b.topLineY);
// Validate: all staves should have similar spacing (reject outliers)
if (staves.length >= 2) {
const spacings = staves.map(s => s.lineSpacing);
const medianSpacing = spacings.slice().sort((a, b) => a - b)[Math.floor(spacings.length / 2)];
return staves.filter(s => Math.abs(s.lineSpacing - medianSpacing) < medianSpacing * 0.3);
}
return staves;
}
// ── Image-based barline detection (vertical run-length) ─────
/**
* Detect barlines in each staff system by scanning columns for vertical dark runs.
* Must be called AFTER detectStaffLines (uses cachedImageData and staves).
* @param {Array} staves - detected staff array
* @param {number} numStavesPerSys - staves per system (e.g. 2 for piano)
* @returns {Array} barlines - [{x, systemIdx, confidence, source}]
*/
function detectBarlines(staves, numStavesPerSys) {
if (!cachedImageData || staves.length === 0) return [];
const { data: pixels, width: imgW, height: imgH } = cachedImageData;
const darkThreshold = 160;
const staffSystems = mapStavesToSystems(staves, numStavesPerSys);
const allBarlines = [];
staffSystems.forEach((sysStaves, sysIdx) => {
if (!sysStaves || sysStaves.length === 0) return;
// Scan region: from first staff top to last staff bottom in this system
const scanTop = Math.max(0, sysStaves[0].topLineY - 5);
const scanBot = Math.min(imgH - 1, sysStaves[sysStaves.length - 1].bottomLineY + 5);
const staffHeight = scanBot - scanTop;
if (staffHeight < 10) return;
const scanLeft = Math.max(0, sysStaves[0].leftX - 10);
const scanRight = Math.min(imgW - 1, sysStaves[0].rightX + 10);
// For each column, compute vertical dark run coverage within the staff region
const columnScores = []; // {x, coverage, maxRun}
for (let x = scanLeft; x <= scanRight; x++) {
let darkCount = 0;
let maxRun = 0, currentRun = 0;
for (let y = scanTop; y <= scanBot; y++) {
const idx = (y * imgW + x) * 4;
const gray = (pixels[idx] + pixels[idx + 1] + pixels[idx + 2]) / 3;
if (gray < darkThreshold) {
darkCount++;
currentRun++;
if (currentRun > maxRun) maxRun = currentRun;
} else {
currentRun = 0;
}
}
const coverage = darkCount / staffHeight;
// Barline criterion: high coverage AND a long continuous vertical run
// Staff lines contribute ~5px each (5 lines) = ~25px, but barlines span 80%+
if (coverage >= 0.55 && maxRun >= staffHeight * 0.35) {
columnScores.push({ x, coverage, maxRun });
}
}
// Cluster adjacent candidate columns (barlines are 1-4px wide)
const clusters = [];
let ci = 0;
while (ci < columnScores.length) {
let cj = ci;
let sumX = 0, sumCov = 0, bestCov = 0, count = 0;
while (cj < columnScores.length && columnScores[cj].x - columnScores[ci].x <= 6) {
sumX += columnScores[cj].x * columnScores[cj].coverage;
sumCov += columnScores[cj].coverage;
if (columnScores[cj].coverage > bestCov) bestCov = columnScores[cj].coverage;
count++;
cj++;
}
const centerX = Math.round(sumX / sumCov);
const clusterWidth = count;
// Stem filter: stems are typically 1-2px wide with lower coverage
// Barlines: wider cluster OR very high coverage
const isLikelyBarline = (bestCov >= 0.70) || (clusterWidth >= 2 && bestCov >= 0.55);
if (isLikelyBarline) {
// Confidence scoring
let confidence;
if (bestCov >= 0.85) confidence = 1.0; // high
else if (bestCov >= 0.70) confidence = 0.7; // medium
else confidence = 0.4; // low
// Penalize very wide clusters (likely thick noteheads or brackets)
if (clusterWidth > 6) confidence *= 0.5;
clusters.push({ x: centerX, confidence, clusterWidth });
}
ci = cj;
}
// Remove duplicates too close together (keep higher confidence)
const minSeparation = 15;
const filtered = [];
for (const c of clusters) {
const existing = filtered.find(f => Math.abs(f.x - c.x) < minSeparation);
if (existing) {
if (c.confidence > existing.confidence) {
existing.x = c.x;
existing.confidence = c.confidence;
}
} else {
filtered.push({ ...c });
}
}
// Add to results
filtered.forEach(b => {
allBarlines.push({
x: b.x,
systemIdx: sysIdx,
confidence: Math.min(1.0, b.confidence),
source: "auto"
});
});
});
// Sort by system, then by X
allBarlines.sort((a, b) => a.systemIdx - b.systemIdx || a.x - b.x);
console.log(`Barline detection: ${allBarlines.length} barlines across ${staffSystems.length} systems`);
staffSystems.forEach((_, si) => {
const sysB = allBarlines.filter(b => b.systemIdx === si);
console.log(` System ${si}: ${sysB.length} barlines at X=[${sysB.map(b=>b.x).join(",")}]`);
});
return allBarlines;
}
/** Build a column-wise vertical feature map for snap-to-feature.
* Returns Float32Array of per-column "barline-ness" scores for the given system. */
function buildVerticalFeatureMap(sysIdx, staves, numStavesPerSys) {
if (!cachedImageData) return null;
const { data: pixels, width: imgW, height: imgH } = cachedImageData;
const darkThreshold = 160;
const staffSystems = mapStavesToSystems(staves, numStavesPerSys);
const sysStaves = staffSystems[sysIdx];
if (!sysStaves || sysStaves.length === 0) return null;
const scanTop = Math.max(0, sysStaves[0].topLineY - 5);
const scanBot = Math.min(imgH - 1, sysStaves[sysStaves.length - 1].bottomLineY + 5);
const staffHeight = scanBot - scanTop;
if (staffHeight < 10) return null;
const scores = new Float32Array(imgW);
for (let x = 0; x < imgW; x++) {
let darkCount = 0;
for (let y = scanTop; y <= scanBot; y++) {
const idx = (y * imgW + x) * 4;
const gray = (pixels[idx] + pixels[idx + 1] + pixels[idx + 2]) / 3;
if (gray < darkThreshold) darkCount++;
}
scores[x] = darkCount / staffHeight;
}
return scores;
}
/** Interpolate missing staves when detected count doesn't match expected.
* Inserts synthetic staves into the LARGEST gaps first, using within-system
* spacing from detected stave pairs to predict exact positions. */
function interpolateMissingStaves(staves, expectedCount, numStavesPerSys) {
if (staves.length === 0 || staves.length >= expectedCount || numStavesPerSys < 1) return staves;
if (staves.length < 2) return staves;
const avgLineSpacing = staves.reduce((s, st) => s + st.lineSpacing, 0) / staves.length;
const staffSpan = avgLineSpacing * 4; // height of one stave (5 lines)
const toInsertTotal = expectedCount - staves.length;
// Compute all gaps between consecutive staves
const gapInfos = [];
for (let i = 1; i < staves.length; i++) {
gapInfos.push({
idx: i, // index of the stave AFTER the gap
gap: staves[i].topLineY - staves[i - 1].bottomLineY
});
}
// Estimate "normal" gap sizes by looking at the smallest gaps
// For piano (numStavesPerSys=2): within-system gaps are the smallest
// Sort gaps ascending, take the bottom half as "normal" gaps
const sortedGapValues = gapInfos.map(g => g.gap).sort((a, b) => a - b);
// The smallest gap is most likely a within-system gap (treble→bass)
const withinGap = numStavesPerSys > 1 ? sortedGapValues[0] : 0;
// Normal between-system gap: second-smallest distinct gap cluster
const normalMaxGap = numStavesPerSys > 1
? sortedGapValues.filter(g => g < sortedGapValues[0] * 2.0).reduce((a, b) => Math.max(a, b), sortedGapValues[0])
: sortedGapValues[0];
// Sort gaps DESCENDING — insert into largest gaps first
const gapsBySize = gapInfos.slice().sort((a, b) => b.gap - a.gap);
// A gap needs a staff inserted if it's significantly larger than normalMaxGap
// Threshold: a normal gap + one staff height + one within-system gap
const missingThreshold = normalMaxGap + staffSpan * 0.5;
const avgLeftX = Math.round(staves.reduce((s, st) => s + st.leftX, 0) / staves.length);
const avgRightX = Math.round(staves.reduce((s, st) => s + st.rightX, 0) / staves.length);
function makeStaff(topY, refStaff) {
const sp = refStaff ? refStaff.lineSpacing : avgLineSpacing;
const lines = [];
for (let k = 0; k < 5; k++) lines.push(Math.round(topY + k * sp));
return {
lines,
topLineY: lines[0],
bottomLineY: lines[4],
lineSpacing: sp,
leftX: refStaff ? refStaff.leftX : avgLeftX,
rightX: refStaff ? refStaff.rightX : avgRightX,
interpolated: true
};
}
// Decide which gaps get insertions — insert into ALL gaps > threshold (don't limit by expectedCount)
const insertions = []; // {afterStaveIdx, count}
for (const gapInfo of gapsBySize) {
if (gapInfo.gap <= missingThreshold) break; // remaining gaps are too small
// How many staves fit in this gap?
const extraSpace = gapInfo.gap - normalMaxGap;
const possibleCount = Math.max(1, Math.round(extraSpace / (staffSpan + withinGap)));
insertions.push({ afterIdx: gapInfo.idx - 1, gap: gapInfo.gap, count: possibleCount });
}
// Build result array with insertions
const result = [];
const insertMap = new Map();
insertions.forEach(ins => insertMap.set(ins.afterIdx, ins));
for (let i = 0; i < staves.length; i++) {
result.push(staves[i]);
const ins = insertMap.get(i);
if (ins) {
// Insert synthetic staves after staves[i], before staves[i+1]
for (let m = 0; m < ins.count; m++) {
// Position using withinGap/betweenGap pattern instead of even distribution
// Alternate: within-gap after previous, then between-gap, then within-gap, etc.
let insertY;
if (ins.count === 1) {
// Single missing staff: place withinGap after previous
insertY = staves[i].bottomLineY + withinGap;
} else {
// Multiple missing: place using alternating gaps from the previous staff
const prevBottom = m === 0 ? staves[i].bottomLineY
: result[result.length - 1].bottomLineY;
const gapType = (result.length % numStavesPerSys === 0) ? normalMaxGap : withinGap;
insertY = prevBottom + gapType;
}
const ref = staves[i];
const synth = makeStaff(Math.round(insertY), ref);
result.push(synth);
console.log(` Interpolated staff at Y=${synth.topLineY} (gap=${ins.gap}px between Staff ${i} and ${i+1})`);
}
}
}
// If total staves is odd for multi-staff systems, append one more after the last
if (numStavesPerSys > 1 && result.length % numStavesPerSys !== 0) {
const lastStaff = result[result.length - 1];
const insertY = lastStaff.bottomLineY + withinGap;
const synth = makeStaff(Math.round(insertY), lastStaff);
result.push(synth);
console.log(` Interpolated staff at Y=${synth.topLineY} (appended to complete system pair)`);
}
result.sort((a, b) => a.topLineY - b.topLineY);
console.log(`Stave interpolation: ${staves.length} detected → ${result.length} total (expected ${expectedCount})`);
console.log(` withinGap=${withinGap.toFixed(0)}, normalMaxGap=${normalMaxGap.toFixed(0)}, threshold=${missingThreshold.toFixed(0)}`);
return result;
}
/** Map detected staves to systems/staff-numbers.
* For piano: staves come in pairs (treble, bass).
* For single-staff: each staff = one system. */
function mapStavesToSystems(staves, numStavesPerSystem) {
// Group staves into systems
const systems = [];
for (let i = 0; i < staves.length; i += numStavesPerSystem) {
const group = staves.slice(i, i + numStavesPerSystem);
systems.push(group);
}
return systems;
}
// ── Barline-based piecewise X mapping ────────────────────────
/**
* Build per-system piecewise maps from detected barlines + XML measure boundaries.
*
* Strategy: each barline is matched to the nearest XML measure boundary.
* This creates control points (pixelX ↔ tenthsX). Between control points,
* X is linearly interpolated. Moving a barline changes WHERE a measure
* boundary sits in pixel space, which warps the notes accordingly.
*
* Returns array indexed by systemIdx, each element is array of {xmlStart, xmlEnd, imgStart, imgEnd}.
*/
function buildBarlinePiecewiseMaps(layoutInfo, numStavesPerSys) {
const hasBarlines = detectedBarlines && detectedBarlines.length > 0;
const hasAnchors = xAnchors && xAnchors.length > 0;
if ((!hasBarlines && !hasAnchors) || !systemsData.length || !layoutInfo) return null;
const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys);
const result = [];
systemsData.forEach((sys, sysIdx) => {
if (!sys.measures || sys.measures.length === 0) { result.push(null); return; }
const sysLeftTenths = layoutInfo.marginL + sys.leftMargin;
const sysTotalTenths = sys.cumulativeWidth;
if (sysTotalTenths <= 0) { result.push(null); return; }
// Get image extent from staff data
const sysStaves = staffSystems[sysIdx];
const sysBarlines = hasBarlines
? detectedBarlines.filter(b => b.systemIdx === sysIdx).sort((a, b) => a.x - b.x)
: [];
const imgLeft = (sysStaves && sysStaves[0]) ? sysStaves[0].leftX
: (sysBarlines.length > 0 ? sysBarlines[0].x : null);
const imgRight = (sysStaves && sysStaves[0]) ? sysStaves[0].rightX
: (sysBarlines.length > 0 ? sysBarlines[sysBarlines.length - 1].x : null);
if (imgLeft == null || imgRight == null) { result.push(null); return; }
const imgWidth = imgRight - imgLeft;
if (imgWidth <= 0) { result.push(null); return; }
// Build all XML measure boundaries (in tenths from page left)
const boundaries = [sysLeftTenths];
sys.measures.forEach(m => {
boundaries.push(sysLeftTenths + m.startX + m.width);
});
// Build control points: staff edges or implicit barlines as first/last anchor.
const controlPoints = []; // {imgX, tenthsX, priority}
// Check if implicit barlines exist for this system (user may have dragged them)
const sysImplicits = sysBarlines.filter(b => b.source === "implicit").sort((a, b) => a.x - b.x);
const anchorLeft = sysImplicits.length >= 1 ? sysImplicits[0].x : imgLeft;
const anchorRight = sysImplicits.length >= 2 ? sysImplicits[sysImplicits.length - 1].x : imgRight;
// Anchor: left edge (implicit barline position or staff edge)
controlPoints.push({ imgX: anchorLeft, tenthsX: boundaries[0], priority: 0 });
// 1) Add xAnchors for this system (highest priority — user-placed)
const sysAnchors = hasAnchors ? xAnchors.filter(a => a.systemIdx === sysIdx) : [];
for (const anchor of sysAnchors) {
controlPoints.push({ imgX: anchor.pixelX, tenthsX: anchor.tenthsX, priority: 2 });
}
// 2) Add detected barlines matched to measure boundaries (lower priority)
const usedBoundaries = new Set([0, boundaries.length - 1]);
const anchorWidth = anchorRight - anchorLeft;
for (let bi = 0; bi < sysBarlines.length; bi++) {
if (sysBarlines[bi].source === "implicit") continue; // already used as edge anchors
const blRatio = (sysBarlines[bi].x - anchorLeft) / anchorWidth;
if (blRatio < 0.02 || blRatio > 0.98) continue;
let bestIdx = -1, bestDist = Infinity;
for (let mi = 1; mi < boundaries.length - 1; mi++) {
if (usedBoundaries.has(mi)) continue;
const mRatio = (boundaries[mi] - sysLeftTenths) / sysTotalTenths;
const dist = Math.abs(blRatio - mRatio);
if (dist < bestDist) { bestDist = dist; bestIdx = mi; }
}
if (bestIdx >= 0 && bestDist < 0.3) {
usedBoundaries.add(bestIdx);
controlPoints.push({ imgX: sysBarlines[bi].x, tenthsX: boundaries[bestIdx], priority: 1 });
}
}
// Anchor: right edge (implicit barline position or staff edge)
controlPoints.push({ imgX: anchorRight, tenthsX: boundaries[boundaries.length - 1], priority: 0 });
// Deduplicate: if two control points have close tenthsX, keep higher priority
controlPoints.sort((a, b) => a.tenthsX - b.tenthsX);
const deduped = [];
for (const cp of controlPoints) {
const last = deduped[deduped.length - 1];
if (last && Math.abs(cp.tenthsX - last.tenthsX) < 5) {
// Close in tenths space: keep higher priority (or replace)
if (cp.priority > last.priority) {
deduped[deduped.length - 1] = cp;
}
} else {
deduped.push(cp);
}
}
// Sort by tenthsX for segment building
deduped.sort((a, b) => a.tenthsX - b.tenthsX);
// Build segments between consecutive control points
const segments = [];
for (let i = 0; i < deduped.length - 1; i++) {
segments.push({
xmlStart: deduped[i].tenthsX,
xmlEnd: deduped[i + 1].tenthsX,
imgStart: deduped[i].imgX,
imgEnd: deduped[i + 1].imgX
});
}
result.push(segments.length > 0 ? segments : null);
});
return result;
}
/**
* Map an X coordinate (in tenths) to pixel using piecewise barline mapping.
* Returns null if no piecewise mapping available for this system.
*/
function mapXViaPiecewise(xTenths, systemIdx, piecewiseMaps) {
if (!piecewiseMaps || !piecewiseMaps[systemIdx]) return null;
const segments = piecewiseMaps[systemIdx];
// Find which segment this xTenths falls into
for (const seg of segments) {
if (xTenths >= seg.xmlStart && xTenths <= seg.xmlEnd) {
const xmlWidth = seg.xmlEnd - seg.xmlStart;
if (xmlWidth <= 0) return seg.imgStart;
const ratio = (xTenths - seg.xmlStart) / xmlWidth;
return seg.imgStart + ratio * (seg.imgEnd - seg.imgStart);
}
}
// Outside all segments: extrapolate from nearest
if (segments.length > 0) {
if (xTenths < segments[0].xmlStart) {
// Before first segment: extrapolate left
const seg = segments[0];
const xmlWidth = seg.xmlEnd - seg.xmlStart;
if (xmlWidth <= 0) return seg.imgStart;
const ratio = (xTenths - seg.xmlStart) / xmlWidth;
return seg.imgStart + ratio * (seg.imgEnd - seg.imgStart);
}
// After last segment: extrapolate right
const seg = segments[segments.length - 1];
const xmlWidth = seg.xmlEnd - seg.xmlStart;
if (xmlWidth <= 0) return seg.imgEnd;
const ratio = (xTenths - seg.xmlStart) / xmlWidth;
return seg.imgStart + ratio * (seg.imgEnd - seg.imgStart);
}
return null;
}
// ── Note positioning (image-based Y, XML-based X) ───────────
function computeNotePositions(notes, layoutInfo, ppt, userOffsetX, userOffsetY) {
const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1;
const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys);
// Build piecewise barline X mapping per system (if barlines available)
// Maps: for each system, an array of {xmlStartTenths, xmlEndTenths, imgStartX, imgEndX}
const barlinePiecewise = buildBarlinePiecewiseMaps(layoutInfo, numStavesPerSys);
// Precompute per-system per-staff X mapping from image-detected staff extents
const sysStaffXMaps = systemsData.map((sys, sysIdx) => {
const sysStaves = staffSystems[sysIdx];
if (!sysStaves || sysStaves.length === 0) return null;
const xmlLeftTenths = layoutInfo.marginL + sys.leftMargin;
const xmlWidthTenths = sys.cumulativeWidth;
// Build a map per staff within this system
return sysStaves.map(stave => {
if (!stave || stave.leftX == null) return null;
const imgLeftX = stave.leftX;
const imgRightX = stave.rightX;
const imgWidth = imgRightX - imgLeftX;
return imgWidth > 0 ? { imgLeftX, imgRightX, imgWidth, xmlLeftTenths, xmlWidthTenths } : null;
});
});
notes.forEach(n => {
// n.staff from .omr is page-global (1-based); convert to system-local (1-based)
const localStaffIdx = ((n.staff - 1) % numStavesPerSys) + 1;
// Compute default-x based position (for playback cursor — guaranteed monotonic)
const staffMaps = sysStaffXMaps[n.systemIdx];
const xMap = staffMaps ? (staffMaps[localStaffIdx - 1] || staffMaps[0]) : null;
const xTenths = layoutInfo.marginL + n.systemLeftMargin + n.measureStartX + n.defaultX;
if (n._omrBased) {
// .omr notes: defaultX is already in pixels, skip tenths mapping
n.pxDefault = n.defaultX + userOffsetX;
} else {
const piecewiseX = mapXViaPiecewise(xTenths, n.systemIdx, barlinePiecewise);
if (piecewiseX !== null) {
n.pxDefault = piecewiseX + userOffsetX;
} else if (xMap && xMap.imgWidth > 0) {
const xRatio = (xTenths - xMap.xmlLeftTenths) / xMap.xmlWidthTenths;
n.pxDefault = xMap.imgLeftX + xRatio * xMap.imgWidth + userOffsetX;
} else {
n.pxDefault = xTenths * ppt + userOffsetX;
}
}
// X for markers: prefer .omr pixel-perfect X if available
if (n.omrX != null) {
n.px = n.omrX + userOffsetX;
} else {
n.px = n.pxDefault;
}
// Y: use .omr pixel-perfect Y for unedited notes, staff-based for edited notes
if (n.omrY != null && !n.modified) {
n.py = n.omrY + userOffsetY;
} else {
// Staff-based Y calculation (for edited notes, rests, or no .omr data)
const sysStaves = staffSystems[n.systemIdx];
const staffData = sysStaves ? sysStaves[localStaffIdx - 1] : null;
// Helper: get OMR staff pixel data for this note
const _pg = pages[currentPageIdx];
const _omrSys = n._omrBased && _pg && _pg.omrData && _pg.omrData.systems[n.systemIdx];
const _omrStaff = _omrSys && _omrSys.staves[localStaffIdx - 1];
const _omrLines = (_omrStaff && _omrStaff.lines && _omrStaff.lines.length >= 5) ? _omrStaff.lines : null;
if (n.isRest) {
// Rest: position at middle of staff (line 3 = staffPos 4)
if (staffData) {
n.py = (staffData.topLineY + staffData.bottomLineY) / 2 + userOffsetY;
} else if (_omrLines) {
n.py = (_omrLines[0].y1 + _omrLines[4].y1) / 2 + userOffsetY;
} else {
let staffTopY = n.systemTopY;
if (localStaffIdx > 1) staffTopY += (localStaffIdx - 1) * (40 + n.staffDistance);
n.py = (staffTopY + 20) * ppt + userOffsetY;
}
} else if (staffData) {
const ref = clefReferencePosition(n.clef);
const noteDiatonic = diatonicIndex(n.step, n.octave);
const staffPos = ref.staffPosition + (noteDiatonic - ref.diatonicIdx);
const halfSpacing = staffData.lineSpacing / 2;
n.py = staffData.bottomLineY - (staffPos * halfSpacing) + userOffsetY;
} else if (_omrLines) {
// OMR mode: use .omr staves pixel coordinates directly
const ref = clefReferencePosition(n.clef);
const noteDiatonic = diatonicIndex(n.step, n.octave);
const staffPos = ref.staffPosition + (noteDiatonic - ref.diatonicIdx);
const botLineY = _omrLines[4].y1;
const halfInterline = (_omrLines[4].y1 - _omrLines[0].y1) / 8;
n.py = botLineY - (staffPos * halfInterline) + userOffsetY;
} else {
const ref = clefReferencePosition(n.clef);
const noteDiatonic = diatonicIndex(n.step, n.octave);
const staffPos = ref.staffPosition + (noteDiatonic - ref.diatonicIdx);
let staffTopY = n.systemTopY;
if (localStaffIdx > 1) staffTopY += (localStaffIdx - 1) * (40 + n.staffDistance);
const yTenths = staffTopY + 40 - (staffPos * 5);
n.py = yTenths * ppt + userOffsetY;
}
} // end of omrY else block
});
}
// ── Reverse mapping: pixel position → staff/pitch/measure ────
/**
* Given a pixel (clickX, clickY) on the score image, determine:
* { systemIdx, staffGlobal, clef, step, octave, measureNum, measureEl, snappedPy }
* Returns null if position can't be resolved.
*/
function pixelToStaffPitch(clickX, clickY) {
if (!systemsData.length || !detectedStaves.length) return null;
const numStavesPerSys = systemsData[0].numStaves;
const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys);
const uy = parseInt(document.getElementById("offset-y").value) || 0;
const ux = parseInt(document.getElementById("offset-x").value) || 0;
// 1) Find system by Y
let sysIdx = -1;
let bestDist = Infinity;
for (let si = 0; si < staffSystems.length; si++) {
const sysStaves = staffSystems[si];
if (!sysStaves || sysStaves.length === 0) continue;
const sysTop = sysStaves[0].topLineY - 40 + uy;
const sysBot = sysStaves[sysStaves.length - 1].bottomLineY + 40 + uy;
if (clickY >= sysTop && clickY <= sysBot) { sysIdx = si; break; }
const mid = (sysTop + sysBot) / 2;
const d = Math.abs(clickY - mid);
if (d < bestDist) { bestDist = d; sysIdx = si; }
}
if (sysIdx < 0) return null;
const sysStaves = staffSystems[sysIdx];
// 2) Find closest staff within system by Y
let staffLocal = 0; // 0-based index into sysStaves
bestDist = Infinity;
for (let si = 0; si < sysStaves.length; si++) {
const mid = (sysStaves[si].topLineY + sysStaves[si].bottomLineY) / 2 + uy;
const d = Math.abs(clickY - mid);
if (d < bestDist) { bestDist = d; staffLocal = si; }
}
const staffData = sysStaves[staffLocal];
const staffGlobal = staffLocal + 1; // 1-based
// 3) Y → diatonic index → step + octave
// Reverse of: py = bottomLineY - (staffPos * halfSpacing) + uy
const halfSpacing = staffData.lineSpacing / 2;
const staffPos = (staffData.bottomLineY + uy - clickY) / halfSpacing;
const roundedStaffPos = Math.round(staffPos);
// We need clef to convert staffPos → diatonic
// Find the clef for this staff from the nearest note, or default
let clef = { sign: staffLocal === 0 ? "G" : "F", line: staffLocal === 0 ? 2 : 4, octaveChange: 0 };
// Try to get clef from existing notes in this system+staff
for (const n of noteInfos) {
if (n.systemIdx === sysIdx && n.staff === staffGlobal) {
clef = n.clef;
break;
}
}
const ref = clefReferencePosition(clef);
const diatonic = ref.diatonicIdx + (roundedStaffPos - ref.staffPosition);
const octave = Math.floor(diatonic / 7);
const stepIdx = ((diatonic % 7) + 7) % 7; // handle negative
const step = STEPS[stepIdx];
// Snapped Y position (for ghost marker)
const snappedPy = staffData.bottomLineY + uy - roundedStaffPos * halfSpacing;
// 4) X → measure
// If sysIdx is beyond systemsData (e.g., Audiveris missed this system), flag it
if (sysIdx >= systemsData.length) {
return { systemIdx: sysIdx, staffGlobal: staffLocal + 1, staffLocal, clef,
step, octave, measureNum: null, measureEl: null, defaultX: 0,
snappedPy, snappedPx: clickX, snappedOnset: 0, beatLabel: "",
_missingSystem: true };
}
// ── OMR mode: use noteInfos px ranges to find measure, skip layout dependency ──
if (isOmrMode() && !layout) {
const sys = systemsData[sysIdx];
const cxNoOff = clickX - ux;
// Find measure by comparing click X against existing notes' px ranges per measure
let bestMeas = null;
for (const m of sys.measures) {
const measNotes = noteInfos.filter(n => n.systemIdx === sysIdx && String(n.measureNum) === String(m.number));
if (measNotes.length === 0) continue;
const minPx = Math.min(...measNotes.map(n => n.omrX || n.px)) - 20;
const maxPx = Math.max(...measNotes.map(n => n.omrX || n.px)) + 20;
if (cxNoOff >= minPx && cxNoOff <= maxPx) { bestMeas = m; break; }
if (!bestMeas) bestMeas = m; // fallback to first
}
if (!bestMeas && sys.measures.length > 0) bestMeas = sys.measures[0];
if (!bestMeas) return null;
const measureNum = bestMeas.number;
const measDuration = parseRational(bestMeas.duration || "1");
// Use measure left/right bounds from systemsData for precise X mapping
const measLeft = bestMeas.left || 0;
const measRight = bestMeas.right || (measLeft + 200);
const measPxWidth = measRight - measLeft;
// Grid size from timeline grid select, or default 1/8
const gridSel = document.getElementById("timeline-grid-select");
const gridSize = gridSel ? parseFloat(gridSel.value) || (measDuration / 8) : (measDuration / 8);
// Map click X → onset via measure pixel bounds
let snappedOnset = 0, snappedPx = clickX;
if (measPxWidth > 0) {
const ratio = Math.max(0, Math.min(1, (cxNoOff - measLeft) / measPxWidth));
const rawOnset = ratio * measDuration;
snappedOnset = Math.round(rawOnset / gridSize) * gridSize;
snappedOnset = Math.max(0, Math.min(snappedOnset, measDuration - gridSize));
// Compute snapped pixel position for ghost marker
snappedPx = measLeft + (snappedOnset / measDuration) * measPxWidth + ux;
}
const beatLabel = `${durationFloatToRational(snappedOnset) || '0'}`;
// For insertNoteOmr, snappedOnset needs to be in divisions*4 scale (divisions=1 → scale=4)
const snappedOnsetDivScale = snappedOnset * 4;
return { systemIdx: sysIdx, staffGlobal, staffLocal, clef, step, octave,
measureNum, measureEl: null, defaultX: 0,
snappedPy, snappedPx, snappedOnset: snappedOnsetDivScale,
beatLabel, _omrMode: true };
}
const sys = systemsData[sysIdx];
// Build per-staff X map (same as computeNotePositions)
const xmlLeftTenths = layout.marginL + sys.leftMargin;
const xmlWidthTenths = sys.cumulativeWidth;
// Use the clicked staff's extents (not always staff[0])
const clickedStave = sysStaves[staffLocal] || sysStaves[0];
const imgLeftX = clickedStave.leftX;
const imgRightX = clickedStave.rightX;
const imgWidth = imgRightX - imgLeftX;
// Convert clickX (pixel) → XML tenths using piecewise first, then staff linear
const barlinePiecewise = buildBarlinePiecewiseMaps(layout, numStavesPerSys);
let xTenths;
// Try reverse piecewise: find which segment contains clickX and reverse-map
const revPiecewise = barlinePiecewise[sysIdx];
let foundPiecewise = false;
if (revPiecewise && revPiecewise.length > 0) {
const cxNoOffset = clickX - ux;
for (const seg of revPiecewise) {
if (cxNoOffset >= seg.imgStartX && cxNoOffset <= seg.imgEndX) {
const ratio = (seg.imgEndX - seg.imgStartX) > 0
? (cxNoOffset - seg.imgStartX) / (seg.imgEndX - seg.imgStartX) : 0;
xTenths = seg.xmlStartTenths + ratio * (seg.xmlEndTenths - seg.xmlStartTenths);
foundPiecewise = true;
break;
}
}
}
if (!foundPiecewise) {
if (imgWidth > 0) {
const xRatio = (clickX - ux - imgLeftX) / imgWidth;
xTenths = xmlLeftTenths + xRatio * xmlWidthTenths;
} else {
xTenths = (clickX - ux) / pixelsPerTenth;
}
}
// Find which measure this X falls into
let measureNum = null;
let measureEl = null;
let defaultX = 0;
for (const m of sys.measures) {
const mStart = layout.marginL + sys.leftMargin + m.startX;
const mEnd = mStart + m.width;
if (xTenths >= mStart && xTenths < mEnd) {
measureNum = m.number;
measureEl = m.element;
defaultX = xTenths - mStart;
break;
}
}
// Fallback: last measure
if (!measureNum && sys.measures.length > 0) {
const last = sys.measures[sys.measures.length - 1];
measureNum = last.number;
measureEl = last.element;
defaultX = xTenths - (layout.marginL + sys.leftMargin + last.startX);
}
// System exists in XML but has 0 measures
if (!measureNum && sys.measures.length === 0) {
return { systemIdx: sysIdx, staffGlobal: staffLocal + 1, staffLocal, clef,
step, octave, measureNum: null, measureEl: null, defaultX: 0,
snappedPy, snappedPx: clickX, snappedOnset: 0, beatLabel: "",
_missingSystem: true };
}
// 5) Compute snapped onset + snapped pixel X
// Find measure width and total duration for grid snap
let measureWidth = 200;
let mStartTenths = 0;
for (const m of sys.measures) {
if (m.number === measureNum) { measureWidth = m.width; mStartTenths = layout.marginL + sys.leftMargin + m.startX; break; }
}
// Get divisions and measure total duration
let snapDivisions = 1;
const snapAttrEl = measureEl ? measureEl.querySelector("attributes") : null;
if (snapAttrEl) {
const divEl = snapAttrEl.querySelector("divisions");
if (divEl) snapDivisions = parseInt(divEl.textContent) || 1;
}
if (snapDivisions === 1) {
for (const n of noteInfos) {
if (n.measureNum === measureNum) { snapDivisions = n.divisions || 1; break; }
}
}
let snapMeasureDur = 0;
if (measureEl) {
let cur = 0;
for (const child of measureEl.children) {
if (child.tagName === "note") {
const dur = parseInt(child.querySelector("duration")?.textContent || "0");
if (!child.querySelector("chord")) cur += dur;
if (cur > snapMeasureDur) snapMeasureDur = cur;
} else if (child.tagName === "forward") {
cur += parseInt(child.querySelector("duration")?.textContent || "0");
if (cur > snapMeasureDur) snapMeasureDur = cur;
} else if (child.tagName === "backup") {
cur -= parseInt(child.querySelector("duration")?.textContent || "0");
}
}
}
// Clamp snapMeasureDur to time-signature duration (prevents multi-voice inflation)
// Walk backwards from current measure to find effective time signature
if (snapMeasureDur > 0 && measureEl && xmlDoc) {
let tsBeats = 4, tsBeatType = 4;
const partEl = measureEl.closest("part");
if (partEl) {
const allMeasures = partEl.querySelectorAll("measure");
const curNum = parseInt(measureEl.getAttribute("number") || "0");
for (const m of allMeasures) {
const mn = parseInt(m.getAttribute("number") || "0");
if (mn > curNum) break;
const timeEl = m.querySelector("attributes > time");
if (timeEl) {
tsBeats = parseInt(timeEl.querySelector("beats")?.textContent || "4") || 4;
tsBeatType = parseInt(timeEl.querySelector("beat-type")?.textContent || "4") || 4;
}
}
}
const timeSigDur = tsBeats * snapDivisions * (4 / tsBeatType);
if (timeSigDur > 0) snapMeasureDur = Math.min(snapMeasureDur, timeSigDur);
}
if (snapMeasureDur === 0) snapMeasureDur = snapDivisions * 4;
// Build onset↔px map from existing notes in this measure+system
const onsetPxPairs = [];
for (const n of noteInfos) {
if (n.measureNum === measureNum && n.systemIdx === sysIdx && !n.isRest) {
if (!onsetPxPairs.some(e => e.onset === n.onsetDiv)) {
onsetPxPairs.push({ onset: n.onsetDiv, px: n.px });
}
}
}
onsetPxPairs.sort((a, b) => a.onset - b.onset);
const durMultiplier = DURATION_TYPES[addDurationType] || 1;
const gridSize = snapDivisions * 0.25;
let snappedOnset, snappedPx;
if (onsetPxPairs.length >= 2) {
// Add measure end boundary (onset=snapMeasureDur, px=end barline) for extrapolation
// Estimate measure end px from last two notes' spacing
const lastPair = onsetPxPairs[onsetPxPairs.length - 1];
const prevPair = onsetPxPairs[onsetPxPairs.length - 2];
const pxPerOnset = (lastPair.onset - prevPair.onset) > 0
? (lastPair.px - prevPair.px) / (lastPair.onset - prevPair.onset) : 0;
if (lastPair.onset < snapMeasureDur && pxPerOnset > 0) {
onsetPxPairs.push({ onset: snapMeasureDur, px: lastPair.px + pxPerOnset * (snapMeasureDur - lastPair.onset) });
}
// Convert clickX → onset using existing notes' px→onset mapping
let rawOnset;
const cxNoOff = clickX;
if (cxNoOff <= onsetPxPairs[0].px) {
// Extrapolate before first note
rawOnset = onsetPxPairs[0].onset - (onsetPxPairs[0].px - cxNoOff) / (pxPerOnset || 1);
if (rawOnset < 0) rawOnset = 0;
} else if (cxNoOff >= onsetPxPairs[onsetPxPairs.length - 1].px) {
// Extrapolate after last note
rawOnset = onsetPxPairs[onsetPxPairs.length - 1].onset + (cxNoOff - onsetPxPairs[onsetPxPairs.length - 1].px) / (pxPerOnset || 1);
} else {
for (let i = 0; i < onsetPxPairs.length - 1; i++) {
if (cxNoOff >= onsetPxPairs[i].px && cxNoOff <= onsetPxPairs[i + 1].px) {
const pxRange = onsetPxPairs[i + 1].px - onsetPxPairs[i].px;
const ratio = pxRange > 0 ? (cxNoOff - onsetPxPairs[i].px) / pxRange : 0;
rawOnset = onsetPxPairs[i].onset + ratio * (onsetPxPairs[i + 1].onset - onsetPxPairs[i].onset);
break;
}
}
if (rawOnset == null) rawOnset = 0;
}
// Snap onset to grid, allow up to measure end
snappedOnset = Math.round(rawOnset / gridSize) * gridSize;
if (snappedOnset >= snapMeasureDur) snappedOnset = snapMeasureDur - gridSize;
if (snappedOnset < 0) snappedOnset = 0;
// Convert snappedOnset → pixel using same note mapping (reverse direction)
const exactMatch = onsetPxPairs.find(e => e.onset === snappedOnset);
if (exactMatch) {
snappedPx = exactMatch.px;
} else {
let lo = onsetPxPairs[0], hi = onsetPxPairs[onsetPxPairs.length - 1];
for (let i = 0; i < onsetPxPairs.length - 1; i++) {
if (onsetPxPairs[i].onset <= snappedOnset && onsetPxPairs[i + 1].onset >= snappedOnset) {
lo = onsetPxPairs[i]; hi = onsetPxPairs[i + 1]; break;
}
}
const range = hi.onset - lo.onset;
const ratio = range > 0 ? (snappedOnset - lo.onset) / range : 0;
snappedPx = lo.px + ratio * (hi.px - lo.px);
}
} else {
// Fallback: XML tenths mapping (no/few existing notes)
const xRatioInMeasure = Math.max(0, Math.min(1, defaultX / measureWidth));
const rawOnset = xRatioInMeasure * snapMeasureDur;
snappedOnset = Math.round(rawOnset / gridSize) * gridSize;
const maxOnset = snapMeasureDur - gridSize;
if (maxOnset > 0 && snappedOnset > maxOnset) snappedOnset = maxOnset;
if (snappedOnset < 0) snappedOnset = 0;
const snappedDefaultXFb = snapMeasureDur > 0 ? (snappedOnset / snapMeasureDur * measureWidth) : 0;
const snappedXTenths = mStartTenths + snappedDefaultXFb;
const piecewisePx = mapXViaPiecewise(snappedXTenths, sysIdx, barlinePiecewise);
if (piecewisePx !== null) {
snappedPx = piecewisePx + ux;
} else if (imgWidth > 0) {
const sRatio = (snappedXTenths - xmlLeftTenths) / xmlWidthTenths;
snappedPx = imgLeftX + sRatio * imgWidth + ux;
} else {
snappedPx = snappedXTenths * pixelsPerTenth + ux;
}
}
const snappedDefaultX = snapMeasureDur > 0 ? (snappedOnset / snapMeasureDur * measureWidth) : 0;
// Beat label: e.g. "beat 1", "beat 1.5"
const beatNum = snappedOnset / snapDivisions;
const beatLabel = Number.isInteger(beatNum) ? `beat ${beatNum + 1}` : `beat ${(beatNum + 1).toFixed(1)}`;
return { systemIdx: sysIdx, staffGlobal, staffLocal, clef, step, octave, measureNum, measureEl, defaultX: snappedDefaultX, snappedPy, snappedPx, snappedOnset, beatLabel };
}
// ── Add Mode: ghost marker + toggle ─────────────────────────
function toggleAddMode() {
addMode = !addMode;
const statusEl = document.getElementById("status-mode");
if (statusEl) statusEl.textContent = addMode ? `[ADD ${DUR_SYMBOLS[addDurationType] || addDurationType}]` : "";
if (!addMode) hideGhostMarker();
}
function showGhostMarker(px, py, step, octave, beatLabel) {
if (!ghostMarker) {
ghostMarker = document.createElementNS("http://www.w3.org/2000/svg", "circle");
ghostMarker.setAttribute("r", "8");
ghostMarker.classList.add("ghost-marker");
ghostMarker.style.pointerEvents = "none";
}
if (!ghostLabel) {
ghostLabel = document.createElementNS("http://www.w3.org/2000/svg", "text");
ghostLabel.classList.add("ghost-label");
ghostLabel.style.pointerEvents = "none";
}
ghostMarker.setAttribute("cx", px);
ghostMarker.setAttribute("cy", py);
ghostLabel.setAttribute("x", px + 12);
ghostLabel.setAttribute("y", py + 4);
ghostLabel.textContent = `${step}${octave} [${beatLabel || ""}]`;
if (!ghostMarker.parentNode) markerSvg.appendChild(ghostMarker);
if (!ghostLabel.parentNode) markerSvg.appendChild(ghostLabel);
}
function hideGhostMarker() {
if (ghostMarker && ghostMarker.parentNode) ghostMarker.remove();
if (ghostLabel && ghostLabel.parentNode) ghostLabel.remove();
}
// ================================================================
// Section 3: SVG Marker Rendering
// ================================================================
const SVG_NS = "http://www.w3.org/2000/svg";
// Playback cursor line (persistent SVG element, shown/hidden as needed)
let playbackCursorLine = null;
function ensurePlaybackCursor() {
if (!playbackCursorLine) {
playbackCursorLine = document.createElementNS(SVG_NS, "line");
playbackCursorLine.setAttribute("stroke", "#ff3333");
playbackCursorLine.setAttribute("stroke-width", "2.5");
playbackCursorLine.setAttribute("stroke-opacity", "0.85");
playbackCursorLine.style.display = "none";
playbackCursorLine.classList.add("playback-cursor");
}
// Always re-append so it's on top of all markers
if (playbackCursorLine.parentNode) playbackCursorLine.remove();
markerSvg.appendChild(playbackCursorLine);
}
function showPlaybackCursor(x, topY, bottomY) {
ensurePlaybackCursor();
playbackCursorLine.setAttribute("x1", x);
playbackCursorLine.setAttribute("x2", x);
playbackCursorLine.setAttribute("y1", topY);
playbackCursorLine.setAttribute("y2", bottomY);
playbackCursorLine.style.display = "";
}
function hidePlaybackCursor() {
if (playbackCursorLine) playbackCursorLine.style.display = "none";
}
/** Place cursor line at a note's X, spanning its system's full staff height */
function placeCursorAtNote(noteIdx) {
const n = noteInfos[noteIdx];
if (!n) return;
const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1;
const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys);
const sysStaves = staffSystems[n.systemIdx];
const uy = parseInt(document.getElementById("offset-y").value) || 0;
let topY, bottomY;
if (sysStaves && sysStaves.length > 0) {
topY = sysStaves[0].topLineY - 20 + uy;
bottomY = sysStaves[sysStaves.length - 1].bottomLineY + 20 + uy;
} else {
topY = n.py - 80;
bottomY = n.py + 80;
}
showPlaybackCursor(n.px, topY, bottomY);
}
/** Find the nearest note index to a given X pixel coordinate */
/** Find the nearest note to a click position, considering both X and Y.
* First determines which system the click is in (by Y), then finds nearest X within that system. */
function findNearestNote(clickX, clickY) {
if (noteInfos.length === 0) return -1;
// Determine which system the click is in using detected staves
const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1;
const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys);
const uy = parseInt(document.getElementById("offset-y").value) || 0;
let clickedSysIdx = -1;
for (let si = 0; si < staffSystems.length; si++) {
const sysStaves = staffSystems[si];
if (!sysStaves || sysStaves.length === 0) continue;
const sysTop = sysStaves[0].topLineY - 40 + uy;
const sysBot = sysStaves[sysStaves.length - 1].bottomLineY + 40 + uy;
if (clickY >= sysTop && clickY <= sysBot) {
clickedSysIdx = si;
break;
}
}
// If between systems, find closest system by Y distance
if (clickedSysIdx < 0) {
let bestDist = Infinity;
for (let si = 0; si < staffSystems.length; si++) {
const sysStaves = staffSystems[si];
if (!sysStaves || sysStaves.length === 0) continue;
const mid = (sysStaves[0].topLineY + sysStaves[sysStaves.length - 1].bottomLineY) / 2 + uy;
const d = Math.abs(clickY - mid);
if (d < bestDist) { bestDist = d; clickedSysIdx = si; }
}
}
// Find nearest note by X within that system
let closestIdx = -1;
let closestDist = Infinity;
noteInfos.forEach((n, i) => {
if (clickedSysIdx >= 0 && n.systemIdx !== clickedSysIdx) return;
const dist = Math.abs(n.px - clickX);
if (dist < closestDist) { closestDist = dist; closestIdx = i; }
});
return closestIdx;
}
/** Get the playback time (seconds) for a given note index from the current timeline */
function getTimeForNoteIdx(noteIdx) {
const bpm = parseInt(document.getElementById("bpm-input").value) || 120;
const tl = buildTimeline(noteInfos, bpm);
for (const evt of tl) {
if (evt.noteIndices.includes(noteIdx)) return evt.timeSec;
}
return 0;
}
function clearMarkers() {
markerSvg.innerHTML = "";
playbackCursorLine = null; // was inside innerHTML, so reference is stale
}
function durationLabel(n) {
const durNames = I18N[currentLang].dur;
if (n._omrBased && n.durationRational) {
// OMR mode: durationRational is whole=1 based (e.g., "1/8" for eighth)
// Convert to quarter=1 based for durNames lookup
const wholeVal = parseRational(n.durationRational);
const quarterVal = wholeVal * 4; // whole=1 → quarter=1
const name = durNames[quarterVal] || n.durationRational;
return name;
}
if (!n.divisions || n.divisions <= 0) return `${n.durationDiv}div`;
const beats = n.durationDiv / n.divisions;
const name = durNames[beats] || `${beats}`;
return `${name}(${beats})`;
}
// Hover tooltip element (reused)
let hoverTooltip = null;
function getHoverTooltip() {
if (hoverTooltip) return hoverTooltip;
const g = document.createElementNS(SVG_NS, "g");
g.setAttribute("id", "marker-tooltip");
g.style.pointerEvents = "none";
const rect = document.createElementNS(SVG_NS, "rect");
rect.setAttribute("rx", "4");
rect.setAttribute("ry", "4");
rect.setAttribute("fill", "rgba(0,0,0,0.85)");
rect.setAttribute("stroke", "#888");
rect.setAttribute("stroke-width", "1");
g.appendChild(rect);
const text = document.createElementNS(SVG_NS, "text");
text.setAttribute("fill", "#fff");
text.setAttribute("font-size", "12");
text.setAttribute("font-family", "monospace");
g.appendChild(text);
hoverTooltip = g;
return g;
}
function showMarkerTooltip(circle) {
const idx = parseInt(circle.dataset.idx);
if (isNaN(idx) || !noteInfos[idx]) return;
const n = noteInfos[idx];
const accStr = alterStr(n.alter);
const pitch = n.isRest ? "Rest" : `${n.step}${accStr}${n.octave}`;
const dur = durationLabel(n);
const gradeStr = n.grade !== undefined ? ` G:${(n.grade * 100).toFixed(0)}%` : "";
// Check chord members
let chordStr = "";
if (!n.isRest) {
const chordMates = noteInfos.filter((mn, mi) => mi !== idx && !mn.isRest &&
String(mn.measureNum) === String(n.measureNum) && mn.voice === n.voice &&
Math.abs((mn.onsetDiv || 0) - (n.onsetDiv || 0)) < 0.001);
if (chordMates.length > 0) {
chordStr = ` [${chordMates.map(m => `${m.step}${alterStr(m.alter)}${m.octave}`).join("+")}]`;
}
}
const label = `M${n.measureNum} ${pitch} ${dur}${gradeStr}${chordStr}`;
const g = getHoverTooltip();
const text = g.querySelector("text");
const rect = g.querySelector("rect");
// Scale inverse to zoom so tooltip stays constant screen size
const invZoom = 1 / currentZoom;
const fontSize = 36 * invZoom;
const charW = fontSize * 0.62;
const padX = 8 * invZoom, padY = 6 * invZoom;
const h = fontSize + padY;
text.setAttribute("font-size", fontSize);
text.textContent = label;
const cx = parseFloat(circle.getAttribute("cx"));
const cy = parseFloat(circle.getAttribute("cy"));
const textLen = label.length * charW;
text.setAttribute("x", cx - textLen / 2 + padX);
text.setAttribute("y", cy - (20 * invZoom));
rect.setAttribute("x", cx - textLen / 2);
rect.setAttribute("y", cy - (20 * invZoom) - fontSize);
rect.setAttribute("width", textLen + padX * 2);
rect.setAttribute("height", h + padY * 2);
rect.setAttribute("rx", 4 * invZoom);
rect.setAttribute("ry", 4 * invZoom);
if (!g.parentNode) markerSvg.appendChild(g);
g.style.display = "";
}
function hideMarkerTooltip() {
if (hoverTooltip) hoverTooltip.style.display = "none";
}
function renderMarkers(notes) {
clearMarkers();
hideMarkerTooltip();
const frag = document.createDocumentFragment();
notes.forEach((n, idx) => {
const circle = document.createElementNS(SVG_NS, "circle");
circle.setAttribute("cx", n.px);
circle.setAttribute("cy", n.py);
circle.setAttribute("r", "8");
circle.classList.add("marker", getMarkerClass(n.partIndex || 0, n.voice));
if (n.isRest) circle.classList.add("rest-marker");
if (n.modified) circle.classList.add("modified");
if (n.grade !== undefined) {
if (n.grade < 0.4) circle.classList.add("grade-low");
else if (n.grade < 0.6) circle.classList.add("grade-mid");
else if (n.grade < 0.75) circle.classList.add("grade-ok");
// >= 0.75: no extra class (default green/blue marker)
}
circle.dataset.idx = idx;
frag.appendChild(circle);
// Accidental label
if (n.alter !== 0) {
const txt = document.createElementNS(SVG_NS, "text");
txt.setAttribute("x", n.px + 8);
txt.setAttribute("y", n.py + 5);
txt.classList.add("acc-label");
txt.dataset.idx = idx;
txt.textContent = alterStr(n.alter);
frag.appendChild(txt);
}
});
markerSvg.appendChild(frag);
// Render rhythm validation warnings after markers
renderRhythmWarnings();
// Re-apply multi-select visuals after re-render
if (scoreSelectedIndices.size > 0) _scoreUpdateSelectionVisuals();
// Re-render free glyph overlays if visible
if (freeGlyphsVisible) renderFreeGlyphOverlays();
// Auto-refresh timeline panel if visible
if (typeof timelinePanelVisible !== "undefined" && timelinePanelVisible && timelinePanelMeasure) {
renderTimelinePanel(timelinePanelMeasure.measureNum, timelinePanelMeasure.systemIdx);
}
}
// ── Debug: draw staff lines to verify Y positions ──────────
let debugLinesVisible = false;
function toggleDebugLines() {
debugLinesVisible = !debugLinesVisible;
const existing = markerSvg.querySelectorAll(".debug-line");
if (!debugLinesVisible) {
existing.forEach(el => el.remove());
return;
}
const imgW = scoreImage.naturalWidth;
const imgH = scoreImage.naturalHeight;
const uy = parseFloat(offsetY.value || 0);
const ux = parseFloat(offsetX.value || 0);
// Draw detected staff lines (image-based)
if (detectedStaves.length > 0) {
detectedStaves.forEach((staff, sIdx) => {
const color = sIdx % 2 === 0 ? "rgba(0,255,0,0.5)" : "rgba(255,165,0,0.5)";
staff.lines.forEach(lineY => {
const line = document.createElementNS(SVG_NS, "line");
line.setAttribute("x1", 0);
line.setAttribute("y1", lineY + uy);
line.setAttribute("x2", imgW);
line.setAttribute("y2", lineY + uy);
line.setAttribute("stroke", color);
line.setAttribute("stroke-width", "1");
line.classList.add("debug-line");
line.dataset.staffIdx = sIdx;
line.style.pointerEvents = "none";
markerSvg.appendChild(line);
});
// Label
const midY = (staff.topLineY + staff.bottomLineY) / 2 + uy;
const txt = document.createElementNS(SVG_NS, "text");
txt.setAttribute("x", 5);
txt.setAttribute("y", midY);
txt.setAttribute("fill", color);
txt.setAttribute("font-size", "10");
txt.classList.add("debug-line");
txt.dataset.staffIdx = sIdx;
txt.style.pointerEvents = "none";
txt.textContent = `Staff ${sIdx + 1} (sp=${staff.lineSpacing.toFixed(1)})`;
markerSvg.appendChild(txt);
});
}
// Draw measure barlines (vertical debug lines) to diagnose X alignment
if (layout && systemsData.length > 0) {
const ppt = pixelsPerTenth;
const numStavesPerSys2 = systemsData.length > 0 ? systemsData[0].numStaves : 1;
const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys2);
systemsData.forEach((sys, sysIdx) => {
const sysStaves = staffSystems[sysIdx];
let yTop, yBot;
if (sysStaves && sysStaves.length > 0) {
yTop = sysStaves[0].topLineY - 10 + uy;
yBot = sysStaves[sysStaves.length - 1].bottomLineY + 10 + uy;
} else {
yTop = sys.topY * ppt + uy - 5;
yBot = yTop + 80;
}
// Use image-based X mapping if available (same logic as computeNotePositions)
let xMap = null;
if (sysStaves && sysStaves.length > 0 && sysStaves[0].leftX != null) {
const imgLeftX = sysStaves[0].leftX;
const imgRightX = sysStaves[0].rightX;
const imgWidth = imgRightX - imgLeftX;
const xmlLeftTenths = layout.marginL + sys.leftMargin;
const xmlWidthTenths = sys.cumulativeWidth;
if (imgWidth > 0 && xmlWidthTenths > 0) {
xMap = { imgLeftX, imgWidth, xmlLeftTenths, xmlWidthTenths };
}
}
function tenthsToX(tenthsFromPageLeft) {
if (xMap) {
const ratio = (tenthsFromPageLeft - xMap.xmlLeftTenths) / xMap.xmlWidthTenths;
return xMap.imgLeftX + ratio * xMap.imgWidth + ux;
}
return tenthsFromPageLeft * ppt + ux;
}
// Draw system start line (cyan)
const sysStartX = tenthsToX(layout.marginL + sys.leftMargin);
const sysLine = document.createElementNS(SVG_NS, "line");
sysLine.setAttribute("x1", sysStartX);
sysLine.setAttribute("y1", yTop);
sysLine.setAttribute("x2", sysStartX);
sysLine.setAttribute("y2", yBot);
sysLine.setAttribute("stroke", "rgba(0,255,255,0.7)");
sysLine.setAttribute("stroke-width", "2");
sysLine.classList.add("debug-line");
sysLine.dataset.sysIdx = sysIdx;
sysLine.style.pointerEvents = "none";
markerSvg.appendChild(sysLine);
// Draw XML measure barlines (magenta) — skip if detected barlines are shown
if (!barlineOverlaysVisible) {
sys.measures.forEach((m, mIdx) => {
const barX = tenthsToX(layout.marginL + sys.leftMargin + m.startX + m.width);
const barLine = document.createElementNS(SVG_NS, "line");
barLine.setAttribute("x1", barX);
barLine.setAttribute("y1", yTop);
barLine.setAttribute("x2", barX);
barLine.setAttribute("y2", yBot);
barLine.setAttribute("stroke", "rgba(255,0,255,0.5)");
barLine.setAttribute("stroke-width", "1");
barLine.classList.add("debug-line");
barLine.dataset.sysIdx = sysIdx;
barLine.style.pointerEvents = "none";
markerSvg.appendChild(barLine);
// Measure number label
if (mIdx === 0 || mIdx === sys.measures.length - 1) {
const mLabelX = tenthsToX(layout.marginL + sys.leftMargin + m.startX);
const lbl = document.createElementNS(SVG_NS, "text");
lbl.setAttribute("x", mLabelX + 2);
lbl.setAttribute("y", yTop - 2);
lbl.setAttribute("fill", "rgba(255,0,255,0.8)");
lbl.setAttribute("font-size", "9");
lbl.classList.add("debug-line");
lbl.dataset.sysIdx = sysIdx;
lbl.style.pointerEvents = "none";
lbl.textContent = `m${m.number}`;
markerSvg.appendChild(lbl);
}
});
}
});
}
}
// ── Barline overlay rendering ────────────────────────────────
function renderBarlineOverlays() {
// Remove existing barline overlays
markerSvg.querySelectorAll(".barline-overlay, .barline-label").forEach(el => el.remove());
if (!barlineOverlaysVisible || detectedBarlines.length === 0) return;
const uy = parseFloat(offsetY.value || 0);
const ux = parseFloat(offsetX.value || 0);
const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1;
const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys);
detectedBarlines.forEach((bl, blIdx) => {
const sysStaves = staffSystems[bl.systemIdx];
if (!sysStaves || sysStaves.length === 0) return;
const yTop = sysStaves[0].topLineY - 8 + uy;
const yBot = sysStaves[sysStaves.length - 1].bottomLineY + 8 + uy;
const x = bl.x + ux;
const line = document.createElementNS(SVG_NS, "line");
line.setAttribute("x1", x);
line.setAttribute("y1", yTop);
line.setAttribute("x2", x);
line.setAttribute("y2", yBot);
line.classList.add("barline-overlay");
line.dataset.blIdx = blIdx;
// Confidence class
if (bl.source === "manual") {
line.classList.add("manual");
} else if (bl.source === "implicit") {
line.classList.add("implicit");
} else if (bl.confidence >= 0.8) {
line.classList.add("high");
} else if (bl.confidence >= 0.55) {
line.classList.add("medium");
} else {
line.classList.add("low");
}
// In barline edit mode, make interactive
if (barlineMode) {
line.style.pointerEvents = "all";
line.style.cursor = "ew-resize";
}
markerSvg.appendChild(line);
// Store reference for quick access
bl.svgEl = line;
});
// Add measure number labels between barlines per system
staffSystems.forEach((sysStaves, sysIdx) => {
if (!sysStaves || sysStaves.length === 0) return;
const sysBarlines = detectedBarlines
.map((bl, i) => ({ ...bl, _idx: i }))
.filter(bl => bl.systemIdx === sysIdx)
.sort((a, b) => a.x - b.x);
const yTop = sysStaves[0].topLineY - 12 + uy;
sysBarlines.forEach((bl, i) => {
if (i === 0) return; // no label before first barline
const prevX = sysBarlines[i - 1].x + ux;
const curX = bl.x + ux;
const midX = (prevX + curX) / 2;
const lbl = document.createElementNS(SVG_NS, "text");
lbl.setAttribute("x", midX);
lbl.setAttribute("y", yTop);
lbl.setAttribute("text-anchor", "middle");
lbl.classList.add("barline-label");
lbl.textContent = `${i}`;
markerSvg.appendChild(lbl);
});
});
}
function toggleBarlineOverlays() {
barlineOverlaysVisible = !barlineOverlaysVisible;
renderBarlineOverlays();
const btn = document.getElementById("btn-show-barlines");
if (btn) {
btn.style.background = barlineOverlaysVisible ? "#2a6a2a" : "";
btn.style.color = barlineOverlaysVisible ? "#fff" : "";
}
}
function _setSvgTitle(el, text) {
let titleEl = el.querySelector("title");
if (!titleEl) {
titleEl = document.createElementNS(SVG_NS, "title");
el.appendChild(titleEl);
}
titleEl.textContent = text;
}
function updateSingleMarker(idx) {
const n = noteInfos[idx];
const circle = markerSvg.querySelector(`circle[data-idx="${idx}"]`);
console.log("[updateMarker] idx=", idx, "circle?", !!circle, "px=", n.px, "py=", n.py);
if (!circle) return;
circle.setAttribute("cx", n.px);
circle.setAttribute("cy", n.py);
if (n.isRest) {
circle.setAttribute("r", "6");
circle.setAttribute("class", "marker rest-marker" + (n.modified ? " modified" : ""));
_setSvgTitle(circle, `M${n.measureNum}: rest staff${n.staff} (voice ${n.voice})`);
// Remove accidental label if any
const accLabel = markerSvg.querySelector(`text[data-idx="${idx}"]`);
if (accLabel) accLabel.remove();
return;
}
if (n.modified) circle.classList.add("modified");
else circle.classList.remove("modified");
const accStr = alterStr(n.alter);
_setSvgTitle(circle, `M${n.measureNum}: ${n.step}${accStr}${n.octave} staff${n.staff} (voice ${n.voice})`);
let accLabel = markerSvg.querySelector(`text[data-idx="${idx}"]`);
if (n.alter !== 0) {
if (!accLabel) {
accLabel = document.createElementNS(SVG_NS, "text");
accLabel.classList.add("acc-label");
accLabel.dataset.idx = idx;
markerSvg.appendChild(accLabel);
}
accLabel.setAttribute("x", n.px + 8);
accLabel.setAttribute("y", n.py + 5);
accLabel.textContent = accStr;
} else if (accLabel) {
accLabel.remove();
}
}
// ================================================================
// Section 4: Note Selection & Interaction
// ================================================================
function selectNote(idx) {
if (selectedIdx >= 0) {
const prev = markerSvg.querySelector(`circle[data-idx="${selectedIdx}"]`);
if (prev) prev.classList.remove("selected");
}
selectedIdx = idx;
if (idx >= 0 && idx < noteInfos.length) {
const circle = markerSvg.querySelector(`circle[data-idx="${idx}"]`);
if (circle) circle.classList.add("selected");
const n = noteInfos[idx];
const accStr = alterStr(n.alter);
const partLabel = (n.partIndex !== undefined && n.partIndex > 0) ? ` P${n.partIndex + 1}` : "";
// Show duration info
const typeEl = n.element ? n.element.querySelector("type") : null;
const dotEl = n.element ? n.element.querySelector("dot") : null;
let durText;
if (typeEl) {
durText = typeEl.textContent;
} else if (n._omrBased && n.durationRational) {
// Convert rational duration to note type name: "1/1"→whole, "1/2"→half, "1/4"→quarter, "1/8"→eighth, "1/16"→16th
const RAT_TO_TYPE = {"1/1":"whole","1/2":"half","1/4":"quarter","1/8":"eighth","1/16":"16th","1/32":"32nd",
"3/8":"quarter.","3/16":"eighth.","3/4":"half.","3/2":"whole."};
durText = RAT_TO_TYPE[n.durationRational] || n.durationRational;
} else {
durText = "?";
}
const durLabel = durText + (dotEl || (n._omrBased && (findOmrChord(n) || {}).dotsNumber) ? "." : "");
const pitchLabel = n.isRest ? "rest" : `${n.step}${accStr}${n.octave}`;
statusSel.textContent = `M${n.measureNum}: ${pitchLabel} [${durLabel}] staff${n.staff}${partLabel} (voice ${n.voice})`;
} else {
statusSel.textContent = t("no_sel");
}
}
function findChordsAtPosition(idx) {
const target = noteInfos[idx];
const threshold = 8;
const matches = [];
noteInfos.forEach((n, i) => {
if (n.systemIdx === target.systemIdx
&& Math.abs(n.px - target.px) < threshold
&& Math.abs(n.py - target.py) < 100) {
matches.push(i);
}
});
return matches;
}
function showChordPopup(indices, clickX, clickY) {
chordList.innerHTML = "";
indices.forEach(idx => {
const n = noteInfos[idx];
const li = document.createElement("li");
const dot = document.createElement("span");
dot.classList.add("voice-dot");
const colors = { 1:"#4488ff", 2:"#ff4444", 3:"#44aa44", 4:"#ffaa00" };
dot.style.background = colors[n.voice] || "#888";
const accStr = alterStr(n.alter);
li.appendChild(dot);
li.appendChild(document.createTextNode(`${n.step}${accStr}${n.octave} staff${n.staff} (v${n.voice})`));
li.addEventListener("click", (e) => {
e.stopPropagation();
selectNote(idx);
hideChordPopup();
});
chordList.appendChild(li);
});
chordPopup.style.left = clickX + "px";
chordPopup.style.top = clickY + "px";
chordPopup.classList.remove("hidden");
}
function hideChordPopup() {
chordPopup.classList.add("hidden");
}
function onMarkerClick(e) {
const circle = e.target.closest("circle.marker");
if (!circle) return;
const idx = parseInt(circle.dataset.idx);
if (e.ctrlKey || e.metaKey) {
// Already handled in mousedown — skip to prevent double-toggle
if (_scoreCtrlHandled) { _scoreCtrlHandled = false; return; }
if (scoreSelectedIndices.has(idx)) {
scoreSelectedIndices.delete(idx);
if (selectedIdx === idx) selectedIdx = scoreSelectedIndices.size > 0 ? [...scoreSelectedIndices][0] : -1;
} else {
scoreSelectedIndices.add(idx);
selectedIdx = idx;
}
_scoreUpdateSelectionVisuals();
hideChordPopup();
return;
}
const chordNotes = findChordsAtPosition(idx);
if (chordNotes.length > 1) {
const rect = document.getElementById("canvas-wrapper").getBoundingClientRect();
showChordPopup(chordNotes, e.clientX - rect.left + 10, e.clientY - rect.top);
} else {
scoreSelectOnly(idx);
hideChordPopup();
}
}
function scoreSelectOnly(idx) {
scoreSelectedIndices.clear();
scoreSelectedIndices.add(idx);
selectNote(idx);
_scoreUpdateSelectionVisuals();
}
function _scoreUpdateSelectionVisuals() {
markerSvg.querySelectorAll("circle.marker").forEach(c => {
const i = parseInt(c.dataset.idx);
if (scoreSelectedIndices.has(i)) c.classList.add("multi-selected");
else c.classList.remove("multi-selected");
});
// Update status bar
if (scoreSelectedIndices.size > 1) {
document.getElementById("status-selection").textContent =
`${scoreSelectedIndices.size}개 선택`;
}
// Sync to TL selection
tlSelectedIndices = new Set(scoreSelectedIndices);
_tlUpdateSelectionVisuals();
}
// ── Score rubber-band drag selection ──
let _scoreRubberEl = null;
let _scoreRubberStartX = 0;
let _scoreRubberStartY = 0;
let _scoreRubberActive = false;
let _scoreRubberJustFinished = false;
function _scoreRubberStart(e) {
const wrapper = document.getElementById("canvas-wrapper");
const wrapRect = wrapper.getBoundingClientRect();
_scoreRubberStartX = e.clientX;
_scoreRubberStartY = e.clientY;
_scoreRubberActive = true;
_scoreRubberEl = document.createElement("div");
_scoreRubberEl.className = "score-rubber";
_scoreRubberEl.style.position = "absolute";
_scoreRubberEl.style.left = (e.clientX - wrapRect.left + wrapper.scrollLeft) + "px";
_scoreRubberEl.style.top = (e.clientY - wrapRect.top + wrapper.scrollTop) + "px";
_scoreRubberEl.style.width = "0px";
_scoreRubberEl.style.height = "0px";
wrapper.appendChild(_scoreRubberEl);
e.preventDefault();
}
document.addEventListener("mousemove", (e) => {
if (!_scoreRubberActive || !_scoreRubberEl) return;
const wrapper = document.getElementById("canvas-wrapper");
const wrapRect = wrapper.getBoundingClientRect();
const curX = e.clientX - wrapRect.left + wrapper.scrollLeft;
const curY = e.clientY - wrapRect.top + wrapper.scrollTop;
const startX = _scoreRubberStartX - wrapRect.left + wrapper.scrollLeft;
const startY = _scoreRubberStartY - wrapRect.top + wrapper.scrollTop;
const left = Math.min(startX, curX);
const top = Math.min(startY, curY);
const width = Math.abs(curX - startX);
const height = Math.abs(curY - startY);
_scoreRubberEl.style.left = left + "px";
_scoreRubberEl.style.top = top + "px";
_scoreRubberEl.style.width = width + "px";
_scoreRubberEl.style.height = height + "px";
// Live selection: find markers inside rubber rect
const rubberRect = { left, top, right: left + width, bottom: top + height };
const ctrlHeld = e.ctrlKey || e.metaKey;
if (!ctrlHeld) scoreSelectedIndices.clear();
markerSvg.querySelectorAll("circle.marker").forEach(c => {
const cx = parseFloat(c.getAttribute("cx")) * currentZoom;
const cy = parseFloat(c.getAttribute("cy")) * currentZoom;
if (cx >= rubberRect.left && cx <= rubberRect.right &&
cy >= rubberRect.top && cy <= rubberRect.bottom) {
scoreSelectedIndices.add(parseInt(c.dataset.idx));
}
});
_scoreUpdateSelectionVisuals();
});
document.addEventListener("mouseup", (e) => {
if (!_scoreRubberActive) return;
_scoreRubberActive = false;
const didDrag = Math.abs(e.clientX - _scoreRubberStartX) > 5 || Math.abs(e.clientY - _scoreRubberStartY) > 5;
_scoreRubberJustFinished = didDrag;
if (_scoreRubberEl) { _scoreRubberEl.remove(); _scoreRubberEl = null; }
if (didDrag && scoreSelectedIndices.size > 0) {
const first = [...scoreSelectedIndices].sort((a, b) => a - b)[0];
selectNote(first);
}
});
// ================================================================
// Section 5: Undo/Redo + Editing
// ================================================================
/** Save current state for undo */
function pushUndo() {
const entry = { omrEditsLen: omrEdits.length };
const pg = pages[currentPageIdx];
if (isOmrMode()) {
// OMR mode: snapshot omrData only, no xmlDoc dependency
entry.omrDataSnapshot = JSON.parse(JSON.stringify(pg.omrData));
} else {
// XML mode: snapshot xmlDoc
if (!xmlDoc) return;
entry.xml = xmlDoc.cloneNode(true);
}
undoStack.push(entry);
if (undoStack.length > MAX_UNDO) undoStack.shift();
redoStack = []; // new edit clears redo
}
/** Restore state from a snapshot and re-parse everything */
function restoreFromSnapshot(entry) {
if (entry.omrEditsLen != null) {
omrEdits.length = entry.omrEditsLen;
updateApplyBadge();
}
const prevSelected = selectedIdx;
const ux = parseInt(document.getElementById("offset-x").value) || 0;
const uy = parseInt(document.getElementById("offset-y").value) || 0;
if (entry.omrDataSnapshot) {
// OMR mode: restore omrData, rebuild from it
const pg = pages[currentPageIdx];
if (pg) pg.omrData = entry.omrDataSnapshot;
rebuildSystemsAndNotes();
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
} else {
// XML mode: restore xmlDoc
const snapshot = entry.xml || entry;
xmlDoc = snapshot;
layout = parseScoreLayout(xmlDoc);
rebuildSystemsAndNotes();
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
}
renderMarkers(noteInfos);
if (prevSelected >= 0 && prevSelected < noteInfos.length) {
selectNote(prevSelected);
} else {
selectNote(-1);
}
}
function _saveCurrentSnapshot() {
const entry = { omrEditsLen: omrEdits.length };
const pg = pages[currentPageIdx];
if (isOmrMode()) {
entry.omrDataSnapshot = JSON.parse(JSON.stringify(pg.omrData));
} else if (xmlDoc) {
entry.xml = xmlDoc.cloneNode(true);
}
return entry;
}
function undo() {
if (undoStack.length === 0) return;
redoStack.push(_saveCurrentSnapshot());
restoreFromSnapshot(undoStack.pop());
}
function redo() {
if (redoStack.length === 0) return;
undoStack.push(_saveCurrentSnapshot());
restoreFromSnapshot(redoStack.pop());
}
// ── OMR Mode Helpers ─────────────────────────────────────────
function isOmrMode() {
const pg = pages[currentPageIdx];
return !!(pg && pg.omrData && pg.omrData.systems && pg.omrData.systems.length > 0);
}
function findOmrChord(n) {
const pg = pages[currentPageIdx];
if (!pg || !pg.omrData) return null;
const sys = pg.omrData.systems[n.systemIdx];
if (!sys) return null;
const meas = sys.measures[n._omrMeasureIdx];
if (!meas) return null;
const arr = n._isHeadChord ? meas.headChords : meas.restChords;
return arr.find(c => c.chordId === n.omrChordId) || null;
}
function findOmrNoteInChord(chord, n) {
if (!chord || !chord.heads) return null;
return chord.heads.find(h => h.headId === n.omrHeadId) || null;
}
// ── OMR Edit Helpers ─────────────────────────────────────────
/**
* Convert MusicXML step+octave to Audiveris .omr staff-relative pitch.
* Pitch 0 = middle line of staff. Increases DOWNWARD.
* Treble (G clef): 0=B4, -1=C5, +1=A4
* Bass (F clef): 0=D3, -1=E3, +1=C3
*/
const _DIATONIC = { C: 0, D: 1, E: 2, F: 3, G: 4, A: 5, B: 6 };
function stepOctaveToOmrPitch(step, octave, clefSign) {
const diatonic = _DIATONIC[step] + octave * 7;
// Middle line reference: Treble=B4(41), Bass=D3(22), Alto=B3(34)
let midDiatonic;
if (clefSign === "F") midDiatonic = _DIATONIC["D"] + 3 * 7; // D3 = 22
else if (clefSign === "C") midDiatonic = _DIATONIC["B"] + 3 * 7; // B3 = 34
else midDiatonic = _DIATONIC["B"] + 4 * 7; // B4 = 41 (treble default)
return midDiatonic - diatonic; // positive = below middle line
}
function recordOmrEdit(edit) {
if (!edit) return;
omrEdits.push(edit);
updateApplyBadge();
}
function updateApplyBadge() {
const badge = document.getElementById("omr-apply-badge");
if (badge) {
badge.textContent = omrEdits.length > 0 ? omrEdits.length : "";
badge.style.display = omrEdits.length > 0 ? "inline-block" : "none";
}
}
// ── Multi-select editing dispatch ─────────────────────────────
// Returns the active multi-selection indices (TL or score, whichever is active)
function _getActiveSelection() {
if (timelinePanelVisible && tlSelectedIndices.size > 1) return tlSelectedIndices;
if (scoreSelectedIndices.size > 1) return scoreSelectedIndices;
return null;
}
// Wraps single-note edit functions to apply to all selected notes.
// Undo is pushed once for the batch.
function _multiEdit(singleFn) {
const sel = _getActiveSelection();
if (!sel) {
singleFn();
return;
}
pushUndo();
const indices = [...sel].sort((a, b) => a - b);
const _skipUndo = true;
for (const idx of indices) {
selectedIdx = idx;
singleFn(_skipUndo);
}
// Re-render all markers and restore primary selection
const ux = parseFloat(offsetX.value || 0);
const uy = parseFloat(offsetY.value || 0);
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
if (indices.length > 0) selectNote(indices[0]);
}
// Delete multiple notes safely: collect IDs first, delete by ID in reverse order
function _multiDelete() {
const sel = _getActiveSelection();
if (!sel) {
deleteSelectedNote();
return;
}
pushUndo();
// Collect stable identifiers for each selected note (reverse order: delete from end first)
const targets = [...sel]
.sort((a, b) => b - a)
.map(idx => noteInfos[idx])
.filter(n => n);
for (const target of targets) {
// Find current index by headId or chordId (stable after re-index)
const curIdx = noteInfos.findIndex(ni =>
target.omrHeadId ? (ni.omrHeadId === target.omrHeadId) :
target.omrChordId ? (ni.omrChordId === target.omrChordId && ni.isRest === target.isRest) :
false);
if (curIdx < 0) continue;
selectedIdx = curIdx;
deleteSelectedNote(true);
}
scoreSelectedIndices.clear();
tlSelectedIndices.clear();
_scoreUpdateSelectionVisuals();
_tlUpdateSelectionVisuals();
const ux = parseFloat(offsetX.value || 0);
const uy = parseFloat(offsetY.value || 0);
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
selectNote(-1);
}
// ── Pitch Editing ────────────────────────────────────────────
function raiseNote(skipUndo) {
if (selectedIdx < 0) return;
const n = noteInfos[selectedIdx];
if (n.isRest) return;
if (!skipUndo) pushUndo();
const si = STEP_INDEX[n.step];
if (si === 6) { n.step = "C"; n.octave += 1; }
else { n.step = STEPS[si + 1]; }
n.alter = keyAlterForStep(n.step, n.fifths);
if (isOmrMode() && n._omrBased) {
// Direct omrData modification (Audiveris-style)
const chord = findOmrChord(n);
const head = chord ? findOmrNoteInChord(chord, n) : null;
if (head) {
head.pitch = stepOctaveToOmrPitch(n.step, n.octave, n.clef.sign);
head.alter = n.alter;
head.hasAccidental = (n.alter !== keyAlterForStep(n.step, n.fifths));
} else {
console.warn("[raiseNote] findOmrChord/head failed:", n.omrChordId, n.omrHeadId, n._omrMeasureIdx);
}
markModified(n);
applyPitchToXmlByHeadId(n);
} else {
applyPitchToXml(n);
applyAlterOnly(n);
}
if (n.omrHeadId) {
recordOmrEdit({ type: "change_pitch", headId: n.omrHeadId,
newPitch: stepOctaveToOmrPitch(n.step, n.octave, n.clef.sign) });
}
recomputeAndUpdate(selectedIdx);
_previewNote(n);
}
function lowerNote(skipUndo) {
if (selectedIdx < 0) return;
const n = noteInfos[selectedIdx];
if (n.isRest) return;
if (!skipUndo) pushUndo();
const si = STEP_INDEX[n.step];
if (si === 0) { n.step = "B"; n.octave -= 1; }
else { n.step = STEPS[si - 1]; }
n.alter = keyAlterForStep(n.step, n.fifths);
if (isOmrMode() && n._omrBased) {
const chord = findOmrChord(n);
const head = chord ? findOmrNoteInChord(chord, n) : null;
if (head) {
head.pitch = stepOctaveToOmrPitch(n.step, n.octave, n.clef.sign);
head.alter = n.alter;
head.hasAccidental = (n.alter !== keyAlterForStep(n.step, n.fifths));
} else {
console.warn("[lowerNote] findOmrChord/head failed:", n.omrChordId, n.omrHeadId, n._omrMeasureIdx);
}
markModified(n);
applyPitchToXmlByHeadId(n);
} else {
applyPitchToXml(n);
applyAlterOnly(n);
}
if (n.omrHeadId) {
recordOmrEdit({ type: "change_pitch", headId: n.omrHeadId,
newPitch: stepOctaveToOmrPitch(n.step, n.octave, n.clef.sign) });
}
recomputeAndUpdate(selectedIdx);
_previewNote(n);
}
function setAccidental(alterValue, skipUndo) {
if (selectedIdx < 0) return;
const n = noteInfos[selectedIdx];
if (n.isRest) return;
if (!skipUndo) pushUndo();
if (n.alter === alterValue) { n.alter = 0; }
else { n.alter = alterValue; }
if (isOmrMode() && n._omrBased) {
// Direct omrData modification
const chord = findOmrChord(n);
const head = chord ? findOmrNoteInChord(chord, n) : null;
if (head) {
head.alter = n.alter;
head.hasAccidental = (n.alter !== 0);
} else {
console.warn("[setAccidental] findOmrChord/head failed:", n.omrChordId, n.omrHeadId, n._omrMeasureIdx);
}
markModified(n);
applyPitchToXmlByHeadId(n);
} else {
applyAccidentalToXml(n);
}
// Record OMR edit
if (n.omrHeadId) {
if (n.alter === 0) {
recordOmrEdit({ type: "remove_accidental", headId: n.omrHeadId });
} else {
const accName = n.alter===2?"double-sharp":n.alter===1?"sharp":n.alter===-1?"flat":n.alter===-2?"flat-flat":"natural";
recordOmrEdit({ type: "add_accidental", headId: n.omrHeadId, accidental: accName });
}
}
recomputeAndUpdate(selectedIdx);
_previewNote(n);
}
// ── Note Deletion ────────────────────────────────────────────
function deleteSelectedNote(skipUndo) {
if (selectedIdx < 0) return;
const n = noteInfos[selectedIdx];
if (!skipUndo) pushUndo();
// Rest → Delete = remove entirely
if (n.isRest) {
if (isOmrMode() && n._omrBased) {
const pg = pages[currentPageIdx];
const sys = pg.omrData.systems[n.systemIdx];
const meas = sys.measures[n._omrMeasureIdx];
if (meas && meas.restChords) {
meas.restChords = meas.restChords.filter(rc => rc.chordId !== n.omrChordId);
}
rebuildSystemsAndNotes();
const ux = parseInt(document.getElementById("offset-x").value) || 0;
const uy = parseInt(document.getElementById("offset-y").value) || 0;
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
} else {
// XML mode: remove element from DOM
if (n.element && n.element.parentNode) {
n.element.parentNode.removeChild(n.element);
}
noteInfos.splice(selectedIdx, 1);
renderMarkers(noteInfos);
}
selectNote(Math.min(selectedIdx, noteInfos.length - 1));
return;
}
// Record OMR edit before modifying data
if (n.omrHeadId && n.omrChordId) {
const durType = n.element ? (n.element.querySelector("type")?.textContent || "quarter") : (n.headShape || "quarter");
const restShapeMap = { whole:"WHOLE_REST", half:"HALF_REST", quarter:"QUARTER_REST",
eighth:"EIGHTH_REST", "16th":"16TH_REST", "32nd":"32ND_REST",
NOTEHEAD_VOID:"HALF_REST", NOTEHEAD_BLACK:"QUARTER_REST" };
recordOmrEdit({ type: "delete_note", headId: n.omrHeadId,
chordId: n.omrChordId, restShape: restShapeMap[durType] || "QUARTER_REST" });
}
if (isOmrMode() && n._omrBased) {
// ── OMR direct modification (Audiveris-style) ──
const chord = findOmrChord(n);
const isMultiHead = chord && chord.heads && chord.heads.length > 1;
if (isMultiHead) {
// Chord note: remove this head only, keep other heads
chord.heads = chord.heads.filter(h => h.headId !== n.omrHeadId);
// Rebuild from modified omrData
rebuildSystemsAndNotes();
const ux = parseInt(document.getElementById("offset-x").value) || 0;
const uy = parseInt(document.getElementById("offset-y").value) || 0;
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
selectNote(Math.min(selectedIdx, noteInfos.length - 1));
} else {
// Single note → convert headChord to restChord in omrData
if (chord) {
const pg = pages[currentPageIdx];
const sys = pg.omrData.systems[n.systemIdx];
const meas = sys.measures[n._omrMeasureIdx];
// Build restChord from headChord data
const restShape = n.headShape === "NOTEHEAD_VOID" ? "HALF_REST" :
n.headShape === "WHOLE_NOTE" ? "WHOLE_REST" : "QUARTER_REST";
const newRest = {
chordId: chord.chordId,
restShape: restShape,
duration: chord.duration,
timeOffset: chord.timeOffset,
voice: chord.voice,
staff: n.staff,
bounds: chord.heads[0] ? chord.heads[0].bounds : null,
chordGrade: chord.heads[0] ? chord.heads[0].grade : 0,
orphan: chord.orphan || false,
};
meas.restChords.push(newRest);
// Remove from headChords
meas.headChords = meas.headChords.filter(hc => hc.chordId !== chord.chordId);
}
// Update noteInfo to rest state
n.isRest = true;
n.step = "R";
n.octave = 0;
n.alter = 0;
n._isHeadChord = false;
markModified(n);
// Rebuild from omrData
rebuildSystemsAndNotes();
const ux = parseInt(document.getElementById("offset-x").value) || 0;
const uy = parseInt(document.getElementById("offset-y").value) || 0;
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
const nextIdx = selectedIdx < noteInfos.length - 1 ? selectedIdx + 1 : selectedIdx - 1;
if (nextIdx >= 0) selectNote(nextIdx);
}
} else {
// ── XML mode ──
if (n.isChord) {
if (n.element && n.element.parentNode) {
n.element.parentNode.removeChild(n.element);
}
const siblings = noteInfos.filter((other, i) =>
i !== selectedIdx &&
other.measureNum === n.measureNum &&
other.onsetDiv === n.onsetDiv &&
other.voice === n.voice
);
if (siblings.length === 1 && siblings[0].isChord) {
if (siblings[0].element) {
const chordTag = siblings[0].element.querySelector("chord");
if (chordTag) chordTag.remove();
}
siblings[0].isChord = false;
}
noteInfos.splice(selectedIdx, 1);
renderMarkers(noteInfos);
selectNote(Math.min(selectedIdx, noteInfos.length - 1));
} else {
if (n.element) {
const noteEl = n.element;
const pitch = noteEl.querySelector("pitch");
if (pitch) noteEl.removeChild(pitch);
const restEl = xmlDoc.createElement("rest");
const durEl = noteEl.querySelector("duration");
if (durEl) noteEl.insertBefore(restEl, durEl);
else noteEl.appendChild(restEl);
["stem", "beam", "lyric", "accidental", "notehead", "tie", "slur"].forEach(tag => {
noteEl.querySelectorAll(tag).forEach(el => el.remove());
});
}
n.isRest = true;
n.step = "R";
markModified(n);
n.octave = 0;
n.alter = 0;
updateSingleMarker(selectedIdx);
const nextIdx = selectedIdx < noteInfos.length - 1 ? selectedIdx + 1 : selectedIdx - 1;
if (nextIdx >= 0) selectNote(nextIdx);
}
}
}
const _modifiedChordIds = new Set();
function markModified(n) {
n.modified = true;
n.omrY = null; // Clear pixel-perfect Y so staff-based Y is used after pitch change
if (n.element) n.element.setAttribute("data-modified", "1");
if (n.omrChordId) _modifiedChordIds.add(n.omrChordId);
}
function applyPitchToXml(n) {
const pitchEl = n.element.querySelector("pitch");
if (!pitchEl) return;
pitchEl.querySelector("step").textContent = n.step;
pitchEl.querySelector("octave").textContent = n.octave.toString();
markModified(n);
}
// Apply pitch change to xmlDoc by headId (for OMR mode where n.element doesn't exist)
function applyPitchToXmlByHeadId(n) {
if (!xmlDoc || !n.omrHeadId) return;
const noteEl = xmlDoc.querySelector(`[data-omr-head-id="${n.omrHeadId}"]`);
if (!noteEl) return;
const pitchEl = noteEl.querySelector("pitch");
if (!pitchEl) return;
const stepEl = pitchEl.querySelector("step");
const octaveEl = pitchEl.querySelector("octave");
if (stepEl) stepEl.textContent = n.step;
if (octaveEl) octaveEl.textContent = n.octave.toString();
// Also update alter
let alterEl = pitchEl.querySelector("alter");
if (n.alter !== 0) {
if (!alterEl) {
alterEl = xmlDoc.createElement("alter");
if (stepEl) stepEl.after(alterEl);
else pitchEl.appendChild(alterEl);
}
alterEl.textContent = n.alter.toString();
} else if (alterEl) {
alterEl.remove();
}
noteEl.setAttribute("data-modified", "1");
}
// Apply only <alter> in pitch (no <accidental>) — for key signature auto-apply
function applyAlterOnly(n) {
const pitchEl = n.element.querySelector("pitch");
if (!pitchEl) return;
let alterEl = pitchEl.querySelector("alter");
if (n.alter !== 0) {
if (!alterEl) {
alterEl = xmlDoc.createElement("alter");
pitchEl.querySelector("step").after(alterEl);
}
alterEl.textContent = n.alter.toString();
} else if (alterEl) {
alterEl.remove();
}
// Remove <accidental> since key-sig accidentals are implicit
const accEl = n.element.querySelector("accidental");
if (accEl) accEl.remove();
markModified(n);
}
function applyAccidentalToXml(n) {
const pitchEl = n.element.querySelector("pitch");
if (!pitchEl) return;
let alterEl = pitchEl.querySelector("alter");
if (n.alter !== 0) {
if (!alterEl) {
alterEl = xmlDoc.createElement("alter");
const stepEl = pitchEl.querySelector("step");
stepEl.after(alterEl);
}
alterEl.textContent = n.alter.toString();
} else if (alterEl) {
alterEl.remove();
}
let accEl = n.element.querySelector("accidental");
if (n.alter !== 0) {
const accName = n.alter===2?"double-sharp":n.alter===1?"sharp":n.alter===-1?"flat":n.alter===-2?"flat-flat":"natural";
if (!accEl) {
accEl = xmlDoc.createElement("accidental");
pitchEl.after(accEl);
}
accEl.textContent = accName;
} else if (accEl) {
accEl.remove();
}
markModified(n);
}
// ── B3: Time Signature Editing ──────────────────────────────
function editTimeSignature() {
if (selectedIdx < 0) { alert(currentLang === "ko" ? "먼저 음표를 선택하세요." : "Select a note first."); return; }
const n = noteInfos[selectedIdx];
const measureEl = n.element.parentNode;
// Find current time sig for this measure
let curBeats = 4, curBeatType = 4;
// Walk back through XML to find last time sig
const allParts = xmlDoc.querySelectorAll("part");
if (allParts.length > 0) {
let found = false;
const measures = allParts[0].querySelectorAll("measure");
for (const m of measures) {
const timeEl = m.querySelector("attributes > time");
if (timeEl) {
curBeats = parseInt(timeEl.querySelector("beats")?.textContent || "4");
curBeatType = parseInt(timeEl.querySelector("beat-type")?.textContent || "4");
}
if (m.getAttribute("number") === n.measureNum) { found = true; break; }
}
}
const input = prompt(
currentLang === "ko"
? `박자표 변경 (마디 ${n.measureNum})\n현재: ${curBeats}/${curBeatType}\n새 박자표 (예: 3/4, 6/8):`
: `Change time signature (measure ${n.measureNum})\nCurrent: ${curBeats}/${curBeatType}\nNew (e.g. 3/4, 6/8):`,
`${curBeats}/${curBeatType}`
);
if (!input) return;
const parts = input.split("/");
if (parts.length !== 2) return;
const newBeats = parseInt(parts[0]);
const newBeatType = parseInt(parts[1]);
if (isNaN(newBeats) || isNaN(newBeatType) || newBeats < 1 || newBeatType < 1) return;
if (newBeats === curBeats && newBeatType === curBeatType) return;
// beatType must be power of 2
if ((newBeatType & (newBeatType - 1)) !== 0) {
alert(currentLang === "ko" ? "분모는 2의 거듭제곱이어야 합니다 (2, 4, 8, 16)." : "Denominator must be a power of 2.");
return;
}
pushUndo();
// Find or create <attributes><time> in the target measure (in all parts for consistency)
allParts.forEach(part => {
const measures = part.querySelectorAll("measure");
for (const m of measures) {
if (m.getAttribute("number") !== n.measureNum) continue;
let attrEl = m.querySelector("attributes");
if (!attrEl) {
attrEl = xmlDoc.createElement("attributes");
m.insertBefore(attrEl, m.firstChild);
}
let timeEl = attrEl.querySelector("time");
if (!timeEl) {
timeEl = xmlDoc.createElement("time");
attrEl.appendChild(timeEl);
}
let beatsEl = timeEl.querySelector("beats");
if (!beatsEl) { beatsEl = xmlDoc.createElement("beats"); timeEl.appendChild(beatsEl); }
let btEl = timeEl.querySelector("beat-type");
if (!btEl) { btEl = xmlDoc.createElement("beat-type"); timeEl.appendChild(btEl); }
beatsEl.textContent = newBeats.toString();
btEl.textContent = newBeatType.toString();
}
});
refreshAfterDurationChange();
}
// ── B4: Key Signature Editing ──────────────────────────────
const KEY_SIG_NAMES = {
"-7":"Cb","−6":"Gb","-6":"Gb","-5":"Db","-4":"Ab","-3":"Eb","-2":"Bb","-1":"F",
"0":"C","1":"G","2":"D","3":"A","4":"E","5":"B","6":"F#","7":"C#"
};
function editKeySignature() {
if (selectedIdx < 0) { alert(currentLang === "ko" ? "먼저 음표를 선택하세요." : "Select a note first."); return; }
const n = noteInfos[selectedIdx];
const curFifths = n.fifths || 0;
const curName = KEY_SIG_NAMES[curFifths.toString()] || `${curFifths}`;
const input = prompt(
currentLang === "ko"
? `조표 변경 (마디 ${n.measureNum})\n현재: ${curName} (fifths=${curFifths})\n새 fifths 값 (-7~+7):\n -3=Eb, -2=Bb, -1=F, 0=C, 1=G, 2=D, 3=A\n\n같은 값 입력 = 현재 조표를 누락된 음표에 재적용`
: `Change key signature (measure ${n.measureNum})\nCurrent: ${curName} (fifths=${curFifths})\nNew fifths value (-7 to +7):\n -3=Eb, -2=Bb, -1=F, 0=C, 1=G, 2=D, 3=A\n\nSame value = re-apply key to notes missing alter`,
curFifths.toString()
);
if (input === null) return;
const newFifths = parseInt(input);
if (isNaN(newFifths) || newFifths < -7 || newFifths > 7) return;
// Same value = re-apply key signature to notes missing alter (fix Audiveris gaps)
if (newFifths === curFifths) {
pushUndo();
const fixed = reapplyKeySignature();
if (fixed > 0) {
refreshAfterDurationChange();
alert(currentLang === "ko"
? `조표 재적용: ${fixed}개 음표에 alter 추가됨`
: `Key re-applied: ${fixed} notes fixed`);
} else {
alert(currentLang === "ko"
? "수정할 음표가 없습니다 (모든 음표에 이미 alter가 있음)"
: "No notes to fix (all notes already have alter)");
undoStack.pop(); // nothing changed, remove undo entry
}
return;
}
pushUndo();
const oldKeyAlters = keyAlterFromFifths(curFifths);
const newKeyAlters = keyAlterFromFifths(newFifths);
// ── OMR direct modification ──
if (isOmrMode()) {
const pg = pages[currentPageIdx];
const sys = pg.omrData.systems[n.systemIdx];
if (!sys) return;
// Update keySigs in omrData
if (!sys.keySigs || sys.keySigs.length === 0) {
sys.keySigs = [{ fifths: newFifths }];
} else {
sys.keySigs[0].fifths = newFifths;
}
// Find affected measure range: from selected measure's system onward until next keySig change
const targetMeasNum = parseInt(n.measureNum);
// Re-alter notes from this measure onward in all systems until a system with different keySig
for (let si = n.systemIdx; si < pg.omrData.systems.length; si++) {
const curSys = pg.omrData.systems[si];
// Stop at systems that have their own keySig (different from what we're setting)
if (si > n.systemIdx && curSys.keySigs && curSys.keySigs.length > 0) {
const existingFifths = parseInt(curSys.keySigs[0].fifths);
if (!isNaN(existingFifths) && existingFifths !== newFifths) break;
// Same fifths → update it too
curSys.keySigs[0].fifths = newFifths;
}
for (const meas of curSys.measures) {
for (const hc of meas.headChords) {
for (const head of hc.heads) {
const { step } = omrPitchToStepOctave(head.pitch,
noteInfos.find(ni => ni.systemIdx === si && ni.staff === head.staff)?.clef
|| { sign: "G", line: 2, octaveChange: 0 });
const curAlter = head.alter || 0;
const oldKeyAlter = oldKeyAlters[step] || 0;
// Skip notes with intentional explicit accidentals (differs from old key)
if (Math.abs(curAlter - oldKeyAlter) > 0.01) continue;
const newAlter = newKeyAlters[step] || 0;
if (Math.abs(curAlter - newAlter) < 0.01) continue;
head.alter = newAlter;
}
}
}
}
// Update noteInfos fifths for affected notes
for (let i = 0; i < noteInfos.length; i++) {
const ni = noteInfos[i];
if (ni.systemIdx >= n.systemIdx) {
ni.fifths = newFifths;
}
}
recordOmrEdit({ type: "change_key_signature", systemIdx: n.systemIdx, newFifths });
refreshAfterDurationChange();
return;
}
// Update XML fifths in all parts
const allParts = xmlDoc.querySelectorAll("part");
allParts.forEach(part => {
const measures = part.querySelectorAll("measure");
let pastTarget = false;
let nextKeyMeasure = null; // measure number where next key change occurs
// First pass: find next key change after target measure
let foundTarget = false;
for (const m of measures) {
const mNum = m.getAttribute("number");
if (mNum === n.measureNum) { foundTarget = true; continue; }
if (foundTarget && m.querySelector("attributes > key > fifths")) {
nextKeyMeasure = mNum;
break;
}
}
// Second pass: update fifths and re-alter notes in affected range
let inRange = false;
for (const m of measures) {
const mNum = m.getAttribute("number");
if (mNum === n.measureNum) {
inRange = true;
// Set the new key signature
let attrEl = m.querySelector("attributes");
if (!attrEl) {
attrEl = xmlDoc.createElement("attributes");
m.insertBefore(attrEl, m.firstChild);
}
let keyEl = attrEl.querySelector("key");
if (!keyEl) { keyEl = xmlDoc.createElement("key"); attrEl.appendChild(keyEl); }
let fifthsEl = keyEl.querySelector("fifths");
if (!fifthsEl) { fifthsEl = xmlDoc.createElement("fifths"); keyEl.appendChild(fifthsEl); }
fifthsEl.textContent = newFifths.toString();
}
if (mNum === nextKeyMeasure) inRange = false; // stop at next key change
if (!inRange) continue;
// Re-alter notes in this measure that don't have explicit accidentals
const noteEls = m.querySelectorAll("note");
for (const noteEl of noteEls) {
if (noteEl.querySelector("rest")) continue;
const pitchEl = noteEl.querySelector("pitch");
if (!pitchEl) continue;
const step = pitchEl.querySelector("step")?.textContent;
if (!step) continue;
const hasExplicitAccidental = noteEl.querySelector("accidental") !== null;
if (hasExplicitAccidental) continue; // user/OMR set this explicitly, don't touch
// Determine what alter this note currently has (explicit or from old key)
let alterEl = pitchEl.querySelector("alter");
const curAlter = alterEl ? parseFloat(alterEl.textContent) : (oldKeyAlters[step] || 0);
// What alter should it have under the new key?
const newAlter = newKeyAlters[step] || 0;
// If note had an explicit alter that matches old key, it was key-derived → update
// If note had an explicit alter that differs from old key, it was intentional → skip
if (alterEl && Math.abs(curAlter - (oldKeyAlters[step] || 0)) > 0.01) continue;
if (Math.abs(curAlter - newAlter) < 0.01) continue; // no change needed
// Update <alter> element
if (newAlter !== 0) {
if (!alterEl) {
alterEl = xmlDoc.createElement("alter");
pitchEl.querySelector("step").after(alterEl);
}
alterEl.textContent = newAlter.toString();
} else if (alterEl) {
alterEl.remove();
}
}
}
});
refreshAfterDurationChange();
}
/**
* Re-apply key signature: for notes missing <alter> where the key implies one,
* write the key-implied alter. Fixes Audiveris exports where key signature
* was not recognized on some staves but notes were still written without alter.
*/
function reapplyKeySignature() {
if (!xmlDoc) return;
const allParts = xmlDoc.querySelectorAll("part");
let fixCount = 0;
allParts.forEach(part => {
let currentFifths = 0;
const measures = part.querySelectorAll("measure");
measures.forEach(mEl => {
const attrEl = mEl.querySelector("attributes");
if (attrEl) {
const fifthsEl = attrEl.querySelector("key > fifths");
if (fifthsEl) currentFifths = parseInt(fifthsEl.textContent) || 0;
}
if (currentFifths === 0) return; // C major, nothing to apply
const keyAlters = keyAlterFromFifths(currentFifths);
const noteEls = mEl.querySelectorAll("note");
for (const noteEl of noteEls) {
if (noteEl.querySelector("rest")) continue;
const pitchEl = noteEl.querySelector("pitch");
if (!pitchEl) continue;
const step = pitchEl.querySelector("step")?.textContent;
if (!step) continue;
const expectedAlter = keyAlters[step];
if (!expectedAlter) continue; // this step is not affected by key sig
const alterEl = pitchEl.querySelector("alter");
const accEl = noteEl.querySelector("accidental");
// Only fix notes that have NO alter AND NO accidental
// (i.e., Audiveris just didn't know about the key signature)
if (!alterEl && !accEl) {
const newAlterEl = xmlDoc.createElement("alter");
newAlterEl.textContent = expectedAlter.toString();
pitchEl.querySelector("step").after(newAlterEl);
fixCount++;
}
}
});
});
return fixCount;
}
// ── B5: Rhythm Validation ──────────────────────────────────
/**
* Validate rhythm: check each measure's total duration per voice against time signature.
* Returns array of { measureNum, voice, expected, actual, partIdx } for mismatches.
*/
function _validateRhythmOmr() {
const warnings = [];
// Group noteInfos by measureNum+voice, sum durations
const measVoice = {}; // "measNum|voice" → { totalDur, count, measureNum, voice, systemIdx }
noteInfos.forEach(n => {
if (n.isChord) return; // skip chord sub-notes, only count first head
const key = `${n.measureNum}|${n.voice}`;
const dur = n.durationDiv || 0;
if (!measVoice[key]) {
measVoice[key] = { totalDur: 0, count: 0, measureNum: String(n.measureNum), voice: n.voice, systemIdx: n.systemIdx };
}
measVoice[key].totalDur += dur;
measVoice[key].count++;
});
// Find the primary (most notes) voice per measure to filter minor voices
const measPrimary = {};
for (const info of Object.values(measVoice)) {
const mKey = info.measureNum;
if (!measPrimary[mKey] || info.count > measPrimary[mKey]) {
measPrimary[mKey] = info.count;
}
}
for (const [key, info] of Object.entries(measVoice)) {
const expected = _tlGetMeasureDuration(info.measureNum, info.systemIdx);
if (expected <= 0) continue;
// Only validate the primary voice (most notes) per measure — secondary voices in OMR are unreliable
if (info.count < measPrimary[info.measureNum]) continue;
// Warn if voice total duration doesn't match measure duration
if (Math.abs(info.totalDur - expected) > 0.01) {
const expBeats = (expected * 4).toFixed(1);
const actBeats = (info.totalDur * 4).toFixed(1);
warnings.push({
measureNum: info.measureNum,
voice: info.voice,
expected: expBeats,
actual: actBeats,
});
}
}
return warnings;
}
function validateRhythm() {
if (isOmrMode()) return _validateRhythmOmr();
if (!xmlDoc) return [];
const warnings = [];
const allParts = xmlDoc.querySelectorAll("part");
allParts.forEach((part, partIdx) => {
let divisions = 1, beats = 4, beatType = 4;
const measures = part.querySelectorAll("measure");
measures.forEach(mEl => {
const attrEl = mEl.querySelector("attributes");
if (attrEl) {
const divEl = attrEl.querySelector("divisions");
if (divEl) divisions = parseInt(divEl.textContent) || 1;
const timeEl = attrEl.querySelector("time");
if (timeEl) {
beats = parseInt(timeEl.querySelector("beats")?.textContent || "4");
beatType = parseInt(timeEl.querySelector("beat-type")?.textContent || "4");
}
}
const expectedDur = divisions * beats * (4 / beatType);
const mNum = mEl.getAttribute("number");
// Compute actual duration per voice
const voiceDurations = {}; // voice → total duration
let cursor = 0;
for (const child of mEl.children) {
if (child.tagName === "note") {
const dur = parseInt(child.querySelector("duration")?.textContent || "0");
const isChord = !!child.querySelector("chord");
const voice = child.querySelector("voice")?.textContent || "1";
if (!isChord) {
if (!voiceDurations[voice]) voiceDurations[voice] = 0;
voiceDurations[voice] += dur;
}
} else if (child.tagName === "forward") {
}
}
for (const [voice, actual] of Object.entries(voiceDurations)) {
if (Math.abs(actual - expectedDur) > 0.5) {
warnings.push({ measureNum: mNum, voice, expected: expectedDur, actual, partIdx });
}
}
});
});
return warnings;
}
/**
* Render rhythm warnings on the marker SVG as colored rectangles behind measures.
*/
function renderRhythmWarnings() {
// Remove old warnings
markerSvg.querySelectorAll(".rhythm-warning").forEach(el => el.remove());
const warnings = validateRhythm();
if (warnings.length === 0) return;
// Build set of measure numbers with warnings
const warnMeasures = new Set(warnings.map(w => w.measureNum));
// For each warned measure, find its X range from noteInfos and draw a background rect
const uy = parseFloat(offsetY.value || 0);
for (const mNum of warnMeasures) {
const measNotes = noteInfos.filter(n => n.measureNum === mNum);
if (measNotes.length === 0) continue;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const n of measNotes) {
if (n.px < minX) minX = n.px;
if (n.px > maxX) maxX = n.px;
if (n.py < minY) minY = n.py;
if (n.py > maxY) maxY = n.py;
}
const pad = 15;
const rect = document.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", minX - pad);
rect.setAttribute("y", minY - pad);
rect.setAttribute("width", Math.max(maxX - minX + pad * 2, 30));
rect.setAttribute("height", Math.max(maxY - minY + pad * 2, 30));
rect.setAttribute("fill", "rgba(255, 40, 40, 0.15)");
rect.setAttribute("stroke", "rgba(255, 60, 60, 0.6)");
rect.setAttribute("stroke-width", "1.5");
rect.setAttribute("rx", "4");
rect.classList.add("rhythm-warning");
rect.style.pointerEvents = "none";
// Tooltip with details
const w = warnings.filter(w2 => w2.measureNum === mNum);
const detail = w.map(w2 =>
`V${w2.voice}: ${w2.actual}/${w2.expected}`
).join(", ");
const title = document.createElementNS(SVG_NS, "title");
title.textContent = currentLang === "ko"
? `마디 ${mNum} 박자 불일치: ${detail}`
: `Measure ${mNum} rhythm mismatch: ${detail}`;
rect.appendChild(title);
// Insert before markers so it appears behind them
if (markerSvg.firstChild) {
markerSvg.insertBefore(rect, markerSvg.firstChild);
} else {
markerSvg.appendChild(rect);
}
}
}
// ── B6: Clef Editing ───────────────────────────────────────
const CLEF_OPTIONS = [
{ label: "𝄞 Treble (G2)", sign: "G", line: 2 },
{ label: "𝄢 Bass (F4)", sign: "F", line: 4 },
{ label: "𝄡 Alto (C3)", sign: "C", line: 3 },
{ label: "𝄡 Tenor (C4)", sign: "C", line: 4 },
];
function editClef() {
if (selectedIdx < 0) { alert(currentLang === "ko" ? "먼저 음표를 선택하세요." : "Select a note first."); return; }
const n = noteInfos[selectedIdx];
const curClef = n.clef;
const curDesc = `${curClef.sign}${curClef.line}`;
const options = CLEF_OPTIONS.map((c, i) => `${i + 1}. ${c.label}`).join("\n");
const input = prompt(
currentLang === "ko"
? `음자리표 변경 (마디 ${n.measureNum}, 보표 ${n.staff})\n현재: ${curDesc}\n선택:\n${options}`
: `Change clef (measure ${n.measureNum}, staff ${n.staff})\nCurrent: ${curDesc}\nChoose:\n${options}`,
CLEF_OPTIONS.findIndex(c => c.sign === curClef.sign && c.line === curClef.line) + 1 || "1"
);
if (!input) return;
const choice = parseInt(input) - 1;
if (choice < 0 || choice >= CLEF_OPTIONS.length) return;
const newClef = CLEF_OPTIONS[choice];
if (newClef.sign === curClef.sign && newClef.line === curClef.line) return;
pushUndo();
const oldClefRef = clefReferencePosition(curClef);
const newClefRef = clefReferencePosition({ sign: newClef.sign, line: newClef.line, octaveChange: 0 });
// Helper: diatonic index → (step, octave)
function diatonicToStepOctave(di) {
const oct = Math.floor(di / 7);
const rem = ((di % 7) + 7) % 7; // handle negatives
return { step: STEPS[rem], octave: oct };
}
// Determine local staff number within the part
const allParts = xmlDoc.querySelectorAll("part");
let localStaff = n.staff;
let staffAccum = 0;
for (const p of allParts) {
const firstAttr = p.querySelector("measure > attributes");
const numStaves = firstAttr ? parseInt(firstAttr.querySelector("staves")?.textContent || "1") : 1;
if (n.staff <= staffAccum + numStaves) {
localStaff = n.staff - staffAccum;
const measures = p.querySelectorAll("measure");
let inRange = false;
let nextClefMeasure = null;
// Find next clef change for this staff after target measure
let foundTarget = false;
for (const m of measures) {
const mNum = m.getAttribute("number");
if (mNum === n.measureNum) { foundTarget = true; continue; }
if (foundTarget) {
const clefs = m.querySelectorAll("attributes clef");
for (const c of clefs) {
const num = parseInt(c.getAttribute("number") || "1");
if (num === localStaff) { nextClefMeasure = mNum; break; }
}
if (nextClefMeasure) break;
}
}
for (const m of measures) {
const mNum = m.getAttribute("number");
if (mNum === n.measureNum) {
inRange = true;
// Write new clef to XML
let attrEl = m.querySelector("attributes");
if (!attrEl) {
attrEl = xmlDoc.createElement("attributes");
m.insertBefore(attrEl, m.firstChild);
}
let clefEl = null;
const existingClefs = attrEl.querySelectorAll("clef");
for (const c of existingClefs) {
const num = parseInt(c.getAttribute("number") || "1");
if (num === localStaff) { clefEl = c; break; }
}
if (!clefEl) {
clefEl = xmlDoc.createElement("clef");
clefEl.setAttribute("number", localStaff.toString());
attrEl.appendChild(clefEl);
}
let signEl = clefEl.querySelector("sign");
if (!signEl) { signEl = xmlDoc.createElement("sign"); clefEl.appendChild(signEl); }
signEl.textContent = newClef.sign;
let lineEl = clefEl.querySelector("line");
if (!lineEl) { lineEl = xmlDoc.createElement("line"); clefEl.appendChild(lineEl); }
lineEl.textContent = newClef.line.toString();
const ocEl = clefEl.querySelector("clef-octave-change");
if (ocEl) ocEl.remove();
}
if (mNum === nextClefMeasure) inRange = false;
if (!inRange) continue;
// Re-interpret pitches on this staff
const noteEls = m.querySelectorAll("note");
for (const noteEl of noteEls) {
if (noteEl.querySelector("rest")) continue;
const noteStaff = parseInt(noteEl.querySelector("staff")?.textContent || "1");
if (noteStaff !== localStaff) continue;
const pitchEl = noteEl.querySelector("pitch");
if (!pitchEl) continue;
const step = pitchEl.querySelector("step")?.textContent;
const octave = parseInt(pitchEl.querySelector("octave")?.textContent || "4");
if (!step) continue;
// Compute staff position under OLD clef
const noteDiatonic = diatonicIndex(step, octave);
const staffPos = oldClefRef.staffPosition + (noteDiatonic - oldClefRef.diatonicIdx);
// Re-interpret under NEW clef → new diatonic index
const newDiatonic = newClefRef.diatonicIdx + (staffPos - newClefRef.staffPosition);
const newNote = diatonicToStepOctave(newDiatonic);
// Write back to XML
pitchEl.querySelector("step").textContent = newNote.step;
pitchEl.querySelector("octave").textContent = newNote.octave.toString();
// Clear alter (accidentals become invalid after clef change)
const alterEl = pitchEl.querySelector("alter");
if (alterEl) alterEl.remove();
const accEl = noteEl.querySelector("accidental");
if (accEl) accEl.remove();
}
}
break;
}
staffAccum += numStaves;
}
refreshAfterDurationChange();
}
// ── Phase 3A: Note Insertion ────────────────────────────────
/**
* Find the lowest available voice number for a given onset position and staff.
* Walks the measure XML to determine which voices are occupied at targetOnset.
* (Inspired by BeadSolver's BOS/EOS voice boundary concept)
*/
function findAvailableVoice(measureEl, targetOnset, localStaff) {
const usedVoices = new Set();
let cursor = 0;
for (const child of measureEl.children) {
if (child.tagName === "note") {
const dur = parseInt(child.querySelector("duration")?.textContent || "0");
const isChord = !!child.querySelector("chord");
const noteStaff = parseInt(child.querySelector("staff")?.textContent || "1");
const noteVoice = parseInt(child.querySelector("voice")?.textContent || "1");
if (!isChord) {
// This note spans [cursor, cursor+dur). If targetOnset falls in this range
// and it's on the same staff, that voice is occupied.
if (cursor <= targetOnset && cursor + dur > targetOnset && noteStaff === localStaff) {
usedVoices.add(noteVoice);
}
cursor += dur;
} else if (noteStaff === localStaff) {
// Chord members share the previous note's onset
if (cursor <= targetOnset && cursor + dur > targetOnset) {
usedVoices.add(noteVoice);
}
}
} else if (child.tagName === "backup") {
cursor -= parseInt(child.querySelector("duration")?.textContent || "0");
} else if (child.tagName === "forward") {
cursor += parseInt(child.querySelector("duration")?.textContent || "0");
}
}
// Return the lowest unused voice number (1-4)
for (let v = 1; v <= 4; v++) {
if (!usedVoices.has(v)) return v;
}
return 1; // fallback
}
/**
* Insert a new note directly into omrData (OMR mode).
* Adds a headChord to the target measure in omrData, then rebuilds.
*/
function insertNoteOmr(info) {
console.log("[insertNoteOmr] called, info=", JSON.stringify({measureNum:info.measureNum, systemIdx:info.systemIdx, step:info.step, octave:info.octave, staffGlobal:info.staffGlobal, snappedPx:info.snappedPx, snappedPy:info.snappedPy, snappedOnset:info.snappedOnset, _omrMode:info._omrMode}));
if (!info.measureNum) { console.warn("[insertNoteOmr] BAIL: no measureNum"); return; }
const pg = pages[currentPageIdx];
if (!pg || !pg.omrData) { console.warn("[insertNoteOmr] BAIL: no pg or omrData"); return; }
// Find the target measure in omrData
const sys = pg.omrData.systems[info.systemIdx];
if (!sys) { console.warn("[insertNoteOmr] BAIL: no system at idx", info.systemIdx, "total systems=", pg.omrData.systems.length); return; }
// Compute measure index: omrData measures don't have a .number field.
// Measure number is derived from system position during parsing.
let globalMeasureBase = 1;
for (let si = 0; si < info.systemIdx; si++) {
const s = pg.omrData.systems[si];
globalMeasureBase += (s.stacks || []).length || 1;
}
const numStacks = (sys.stacks || []).length || 1;
const stackIdx = parseInt(info.measureNum) - globalMeasureBase;
if (stackIdx < 0 || stackIdx >= numStacks) {
console.warn("[insertNoteOmr] BAIL: stackIdx", stackIdx, "out of range, measureNum=", info.measureNum, "globalMeasureBase=", globalMeasureBase, "numStacks=", numStacks);
return;
}
// Determine which part this staff belongs to (for multi-part systems like piano)
let partIdx = 0;
const existingNote = noteInfos.find(n => n.systemIdx === info.systemIdx && n.staff === info.staffGlobal);
if (existingNote) partIdx = existingNote.partIndex || 0;
const mi = partIdx * numStacks + stackIdx;
const meas = sys.measures[mi];
if (!meas) { console.warn("[insertNoteOmr] BAIL: no measure at mi=", mi, "total measures=", sys.measures.length, "partIdx=", partIdx, "stackIdx=", stackIdx); return; }
console.log("[insertNoteOmr] found measure mi=", mi, "headChords=", meas.headChords.length, "restChords=", meas.restChords.length);
pushUndo();
const measDuration = parseRational(meas.duration || "1");
// snappedOnset from pixelToStaffPitch is in divisions*4 scale (OMR: div=1 → scale 0..4)
// Convert to whole-note fraction: divide by 4
let onset = (info.snappedOnset || 0) / 4;
onset = Math.max(0, Math.min(onset, measDuration));
const timeOffsetRat = durationFloatToRational(onset);
console.log("[insertNoteOmr] onset=", onset, "measDuration=", measDuration, "timeOffsetRat=", timeOffsetRat);
// Duration from selected add type
const durRat = TYPE_TO_RATIONAL[addDurationType] || "1/4";
const headShape = TYPE_TO_HEAD_SHAPE[addDurationType] || "NOTEHEAD_BLACK";
// Generate unique IDs (simple incrementing from max existing)
let maxChordId = 0, maxHeadId = 0;
for (const s of pg.omrData.systems) {
for (const m of s.measures) {
for (const hc of m.headChords) {
const cid = parseInt(hc.chordId) || 0;
if (cid > maxChordId) maxChordId = cid;
for (const h of hc.heads) {
const hid = parseInt(h.headId) || 0;
if (hid > maxHeadId) maxHeadId = hid;
}
}
for (const rc of m.restChords) {
const cid = parseInt(rc.chordId) || 0;
if (cid > maxChordId) maxChordId = cid;
}
}
}
const newChordId = String(maxChordId + 1);
const newHeadId = String(maxHeadId + 1);
// Build pitch
const omrPitch = stepOctaveToOmrPitch(info.step, info.octave, info.clef.sign);
// Determine voice: find first voice where new note doesn't overlap, else create new
const newDur = parseRational(durRat);
const newEnd = onset + newDur;
const staffChords = [...meas.headChords, ...meas.restChords].filter(c =>
(c.heads ? c.heads.some(h => h.staff === info.staffGlobal) : (c.staff === info.staffGlobal))
);
let newVoice = 1;
if (staffChords.length > 0) {
// Group existing chords by voice
const voiceRanges = {};
for (const c of staffChords) {
const v = c.voice || 1;
if (!voiceRanges[v]) voiceRanges[v] = [];
const cOnset = parseRational(c.timeOffset || "0");
const cEnd = cOnset + parseRational(c.duration || "0");
voiceRanges[v].push([cOnset, cEnd]);
}
// Find first voice with no overlap
let found = false;
for (const v of Object.keys(voiceRanges).sort((a, b) => parseInt(a) - parseInt(b))) {
const ranges = voiceRanges[v];
const overlaps = ranges.some(([s, e]) => onset < e && newEnd > s);
if (!overlaps) { newVoice = parseInt(v); found = true; break; }
}
if (!found) {
// All voices overlap — create new voice
const maxV = Math.max(...Object.keys(voiceRanges).map(Number));
newVoice = maxV + 1;
}
}
// Build headChord object
const newChord = {
chordId: newChordId,
duration: durRat,
timeOffset: timeOffsetRat,
voice: newVoice,
dotsNumber: 0,
heads: [{
headId: newHeadId,
pitch: omrPitch,
alter: 0,
staff: info.staffGlobal,
shape: headShape,
grade: 1.0,
bounds: { x: Math.round(info.snappedPx || 0), y: Math.round(info.snappedPy || 0), w: 12, h: 12 },
}],
};
meas.headChords.push(newChord);
console.log("[insertNoteOmr] pushed newChord id=", newChordId, "headId=", newHeadId, "now headChords.length=", meas.headChords.length);
// Rebuild from omrData
rebuildSystemsAndNotes();
console.log("[insertNoteOmr] after rebuild, noteInfos.length=", noteInfos.length);
const ux = parseInt(document.getElementById("offset-x").value) || 0;
const uy = parseInt(document.getElementById("offset-y").value) || 0;
console.log("[insertNoteOmr] layout=", !!layout, "pixelsPerTenth=", pixelsPerTenth, "ux=", ux, "uy=", uy);
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
// Select the newly added note
const newIdx = noteInfos.findIndex(n =>
n.omrChordId === newChordId && n.omrHeadId === newHeadId
);
console.log("[insertNoteOmr] newIdx=", newIdx, "searching for chordId=", newChordId, "headId=", newHeadId);
if (newIdx >= 0) {
console.log("[insertNoteOmr] selecting note at idx=", newIdx, "px=", noteInfos[newIdx].px, "py=", noteInfos[newIdx].py);
selectNote(newIdx);
} else {
console.warn("[insertNoteOmr] note NOT FOUND in noteInfos after rebuild! Dumping omrChordIds:", noteInfos.map(n => n.omrChordId).filter((v,i,a) => a.indexOf(v) === i));
}
}
/**
* Insert a new note at the position determined by pixelToStaffPitch().
* Default duration: quarter note. Voice auto-determined by onset overlap.
*/
function insertNoteAtPosition(info) {
if (!xmlDoc || !info.measureEl) return;
pushUndo();
// Determine which part's measure to insert into based on staffGlobal
const allParts = xmlDoc.querySelectorAll("part");
let targetPart = allParts[0];
let localStaff = info.staffGlobal;
let staffAccum = 0;
for (let pi = 0; pi < allParts.length; pi++) {
const firstAttr = allParts[pi].querySelector("measure > attributes");
const numStaves = firstAttr ? parseInt(firstAttr.querySelector("staves")?.textContent || "1") : 1;
if (info.staffGlobal <= staffAccum + numStaves) {
targetPart = allParts[pi];
localStaff = info.staffGlobal - staffAccum;
break;
}
staffAccum += numStaves;
}
// Find the correct measure element in the target part
let measureEl = null;
const measures = targetPart.querySelectorAll("measure");
for (const m of measures) {
if (m.getAttribute("number") === info.measureNum) { measureEl = m; break; }
}
if (!measureEl) measureEl = info.measureEl;
// Find divisions for the actual target measure
let divisions = 1;
let divEl = measureEl.querySelector("attributes > divisions");
if (divEl) {
divisions = parseInt(divEl.textContent) || 1;
} else {
// Fallback: get from noteInfos or walk earlier measures in same part
for (const n of noteInfos) {
if (n.measureNum === info.measureNum) { divisions = n.divisions || 1; break; }
}
}
// Scale up divisions if current value can't represent the selected duration
// e.g. divisions=1 can't represent eighth (needs 0.5 duration) → scale to 2
const durMultiplier = DURATION_TYPES[addDurationType] || 1;
const requiredDivisions = Math.ceil(1 / durMultiplier); // eighth→2, 16th→4, quarter→1
if (divisions < requiredDivisions) {
const scaleFactor = requiredDivisions / divisions;
// Update <divisions> in XML (create <attributes> if needed)
let attrEl = measureEl.querySelector("attributes");
if (!attrEl) {
attrEl = xmlDoc.createElement("attributes");
measureEl.insertBefore(attrEl, measureEl.firstChild);
}
divEl = attrEl.querySelector("divisions");
if (!divEl) {
divEl = xmlDoc.createElement("divisions");
attrEl.insertBefore(divEl, attrEl.firstChild);
}
divEl.textContent = requiredDivisions.toString();
// Scale all existing <duration> values in this measure
for (const child of measureEl.children) {
if (child.tagName === "note" || child.tagName === "forward" || child.tagName === "backup") {
const durChild = child.querySelector("duration");
if (durChild) {
const oldDur = parseInt(durChild.textContent) || 0;
durChild.textContent = Math.round(oldDur * scaleFactor).toString();
}
}
}
divisions = requiredDivisions;
}
const durationDiv = Math.max(1, Math.round(divisions * durMultiplier));
// Use pre-computed snapped onset from pixelToStaffPitch, scaled to actual divisions
let targetOnset = info.snappedOnset != null ? info.snappedOnset : 0;
// snappedOnset was computed with original divisions; scale if divisions changed
const origDivisions = info.snappedOnset != null ? ((() => {
// Re-derive original divisions from noteInfos (before our scaling)
for (const n of noteInfos) {
if (n.measureNum === info.measureNum) return n.divisions || 1;
}
return 1;
})()) : 1;
if (origDivisions !== divisions) {
targetOnset = Math.round(targetOnset * (divisions / origDivisions));
}
targetOnset = Math.round(targetOnset); // ensure integer
// Find insertion point: walk XML children, track cursor, insert where cursor passes targetOnset
// Strategy: backup to 0, forward to targetOnset, then insert note
// We append: <backup duration=currentEndPos/> <forward duration=targetOnset/> <note .../>
// This positions the new note at the correct onset without disrupting existing voice structure
// Build the <note> element
const noteEl = xmlDoc.createElement("note");
noteEl.setAttribute("default-x", Math.round(info.defaultX).toString());
// Store pixel X so note stays at visual position after re-parse
const _ux = parseFloat(document.getElementById("offset-x").value || 0);
noteEl.setAttribute("data-px", (info.snappedPx - _ux).toString());
// Get fifths from nearby note in same measure
let fifths = 0;
for (const ni of noteInfos) {
if (ni.measureNum === info.measureNum) { fifths = ni.fifths || 0; break; }
}
const alter = keyAlterForStep(info.step, fifths);
const pitchEl = xmlDoc.createElement("pitch");
const stepEl = xmlDoc.createElement("step");
stepEl.textContent = info.step;
if (alter !== 0) {
const alterEl = xmlDoc.createElement("alter");
alterEl.textContent = alter.toString();
pitchEl.appendChild(stepEl);
pitchEl.appendChild(alterEl);
} else {
pitchEl.appendChild(stepEl);
}
const octaveEl = xmlDoc.createElement("octave");
octaveEl.textContent = info.octave.toString();
pitchEl.appendChild(octaveEl);
noteEl.appendChild(pitchEl);
const durElNew = xmlDoc.createElement("duration");
durElNew.textContent = durationDiv.toString();
noteEl.appendChild(durElNew);
const autoVoice = findAvailableVoice(measureEl, targetOnset, localStaff || 1);
const voiceEl = xmlDoc.createElement("voice");
voiceEl.textContent = autoVoice.toString();
noteEl.appendChild(voiceEl);
const typeEl = xmlDoc.createElement("type");
typeEl.textContent = addDurationType;
noteEl.appendChild(typeEl);
const stemEl = xmlDoc.createElement("stem");
stemEl.textContent = info.staffLocal === 0 ? "down" : "up";
noteEl.appendChild(stemEl);
if (localStaff > 0) {
const staffEl = xmlDoc.createElement("staff");
staffEl.textContent = localStaff.toString();
noteEl.appendChild(staffEl);
}
noteEl.setAttribute("data-modified", "1");
// Find correct insertion point by walking XML children and tracking per-voice cursors.
// Goal: insert the note into the voice's timeline at targetOnset without disrupting others.
const children = Array.from(measureEl.children);
let cursor = 0; // global cursor position
let insertBeforeEl = null;
let cursorAtInsert = 0;
// Find the last element in the measure where cursor <= targetOnset for this voice
// We track voice membership by looking at <voice> inside <note> elements
for (let i = 0; i < children.length; i++) {
const ch = children[i];
if (ch.tagName === "note") {
const dur = parseInt(ch.querySelector("duration")?.textContent || "0");
const isChord = !!ch.querySelector("chord");
const noteVoice = parseInt(ch.querySelector("voice")?.textContent || "1");
if (!isChord) {
if (noteVoice === autoVoice && cursor >= targetOnset && insertBeforeEl === null) {
insertBeforeEl = ch;
cursorAtInsert = cursor;
}
cursor += dur;
}
} else if (ch.tagName === "forward") {
cursor += parseInt(ch.querySelector("duration")?.textContent || "0");
} else if (ch.tagName === "backup") {
cursor -= parseInt(ch.querySelector("duration")?.textContent || "0");
}
}
// Compute the cursor position at end of measure (for backup calculation)
let endCursor = 0;
for (const ch of children) {
if (ch.tagName === "note") {
if (!ch.querySelector("chord")) endCursor += parseInt(ch.querySelector("duration")?.textContent || "0");
} else if (ch.tagName === "forward") {
endCursor += parseInt(ch.querySelector("duration")?.textContent || "0");
} else if (ch.tagName === "backup") {
endCursor -= parseInt(ch.querySelector("duration")?.textContent || "0");
}
}
// Strategy: always append backup→forward→note at end (safest for MusicXML voice model)
if (endCursor > 0) {
const backupEl = xmlDoc.createElement("backup");
const backupDur = xmlDoc.createElement("duration");
backupDur.textContent = endCursor.toString();
backupEl.appendChild(backupDur);
measureEl.appendChild(backupEl);
}
if (targetOnset > 0) {
const forwardEl = xmlDoc.createElement("forward");
const forwardDur = xmlDoc.createElement("duration");
forwardDur.textContent = targetOnset.toString();
forwardEl.appendChild(forwardDur);
measureEl.appendChild(forwardEl);
}
measureEl.appendChild(noteEl);
// Stop playback if active (timeline would be stale after XML change)
if (isPlaying) stopPlayback();
// Re-parse and render (uses refreshAfterDurationChange which re-matches omrX/omrY/grades)
const savedStep = info.step, savedOctave = info.octave, savedMeasure = info.measureNum;
const savedPx = info.snappedPx; // pixel X where user clicked
const savedPy = info.snappedPy; // pixel Y where user clicked
refreshAfterDurationChange();
// Select the newly added note (omrX is now set via data-px in parseNotes)
const newIdx = noteInfos.findIndex(n =>
n.measureNum === savedMeasure && n.step === savedStep && n.octave === savedOctave
&& n.element.getAttribute("data-modified") === "1"
);
if (newIdx >= 0) selectNote(newIdx);
else selectNote(noteInfos.length - 1);
}
/**
* Toggle selected note between note and rest.
* Note→rest: remove pitch, add <rest/>.
* Rest→note: add pitch (default C4 or last used pitch), remove <rest/>.
*/
function toggleNoteRest() {
if (selectedIdx < 0 || selectedIdx >= noteInfos.length) return;
pushUndo();
const n = noteInfos[selectedIdx];
if (isOmrMode() && n._omrBased) {
// ── OMR direct modification ──
const pg = pages[currentPageIdx];
const sys = pg.omrData.systems[n.systemIdx];
const meas = sys.measures[n._omrMeasureIdx];
if (n.isRest) {
// Rest → Note: convert restChord to headChord
const rc = meas.restChords.find(c => c.chordId === n.omrChordId);
if (rc) {
const newHead = {
chordId: rc.chordId,
heads: [{
headId: rc.chordId + "-h1",
pitch: 0, // middle line (will be C4 in treble)
alter: 0,
staff: rc.staff || n.staff,
shape: "NOTEHEAD_BLACK",
bounds: rc.bounds,
grade: rc.chordGrade || 0.5,
}],
duration: rc.duration,
timeOffset: rc.timeOffset,
voice: rc.voice,
orphan: rc.orphan || false,
};
meas.headChords.push(newHead);
meas.restChords = meas.restChords.filter(c => c.chordId !== rc.chordId);
}
n.isRest = false;
n.step = "C";
n.octave = 4;
n.alter = 0;
n._isHeadChord = true;
} else {
// Note → Rest: convert headChord to restChord
const chord = findOmrChord(n);
if (chord) {
const restShape = n.headShape === "NOTEHEAD_VOID" ? "HALF_REST" :
n.headShape === "WHOLE_NOTE" ? "WHOLE_REST" : "QUARTER_REST";
const newRest = {
chordId: chord.chordId,
restShape: restShape,
duration: chord.duration,
timeOffset: chord.timeOffset,
voice: chord.voice,
staff: n.staff,
bounds: chord.heads[0] ? chord.heads[0].bounds : null,
chordGrade: chord.heads[0] ? chord.heads[0].grade : 0,
orphan: chord.orphan || false,
};
meas.restChords.push(newRest);
meas.headChords = meas.headChords.filter(hc => hc.chordId !== chord.chordId);
}
n.isRest = true;
n.step = "R";
n.octave = 0;
n.alter = 0;
n._isHeadChord = false;
}
markModified(n);
// Rebuild from modified omrData
rebuildSystemsAndNotes();
const ux = parseInt(document.getElementById("offset-x").value) || 0;
const uy = parseInt(document.getElementById("offset-y").value) || 0;
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
if (selectedIdx < noteInfos.length) selectNote(selectedIdx);
updatePageStatus();
} else {
// ── XML mode ──
const nEl = n.element;
if (n.isRest) {
const restEl = nEl.querySelector("rest");
if (restEl) restEl.remove();
const pitchEl = xmlDoc.createElement("pitch");
const stepEl = xmlDoc.createElement("step");
stepEl.textContent = "C";
const octaveEl = xmlDoc.createElement("octave");
octaveEl.textContent = "4";
pitchEl.appendChild(stepEl);
pitchEl.appendChild(octaveEl);
const durEl = nEl.querySelector("duration");
if (durEl) nEl.insertBefore(pitchEl, durEl);
else nEl.appendChild(pitchEl);
const stemEl = xmlDoc.createElement("stem");
stemEl.textContent = "up";
nEl.appendChild(stemEl);
n.isRest = false;
n.step = "C";
n.octave = 4;
n.alter = 0;
markModified(n);
} else {
const pitch = nEl.querySelector("pitch");
if (pitch) pitch.remove();
const restEl = xmlDoc.createElement("rest");
const durEl = nEl.querySelector("duration");
if (durEl) nEl.insertBefore(restEl, durEl);
else nEl.appendChild(restEl);
["stem", "beam", "lyric", "accidental", "notehead", "tie", "slur"].forEach(tag => {
nEl.querySelectorAll(tag).forEach(el => el.remove());
});
n.isRest = true;
n.step = "R";
n.octave = 0;
n.alter = 0;
markModified(n);
}
layout = parseScoreLayout(xmlDoc);
rebuildSystemsAndNotes();
const ux = parseInt(document.getElementById("offset-x").value) || 0;
const uy = parseInt(document.getElementById("offset-y").value) || 0;
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
if (selectedIdx < noteInfos.length) selectNote(selectedIdx);
updatePageStatus();
}
}
// ── Phase 3B: Chord Addition ────────────────────────────────
function addChordNote() {
if (selectedIdx < 0 || selectedIdx >= noteInfos.length) return;
const n = noteInfos[selectedIdx];
if (n.isRest) return; // Can't add chord to a rest
pushUndo();
// Compute pitch a diatonic 3rd above (step + 2)
const si = STEP_INDEX[n.step];
const newSi = (si + 2) % 7;
const newStep = STEPS[newSi];
const newOctave = (si + 2 >= 7) ? n.octave + 1 : n.octave;
// Clone duration/voice/staff/type from the selected note
const srcEl = n.element;
const durText = srcEl.querySelector("duration")?.textContent || "1";
const voiceText = srcEl.querySelector("voice")?.textContent || "1";
const typeText = srcEl.querySelector("type")?.textContent || "quarter";
const staffText = srcEl.querySelector("staff")?.textContent || null;
const stemText = srcEl.querySelector("stem")?.textContent || "up";
// Build the new <note> element
const noteEl = xmlDoc.createElement("note");
// <chord/> must be first child
const chordEl = xmlDoc.createElement("chord");
noteEl.appendChild(chordEl);
// <pitch>
const pitchEl = xmlDoc.createElement("pitch");
const stepEl = xmlDoc.createElement("step");
stepEl.textContent = newStep;
const octaveEl = xmlDoc.createElement("octave");
octaveEl.textContent = newOctave.toString();
pitchEl.appendChild(stepEl);
pitchEl.appendChild(octaveEl);
noteEl.appendChild(pitchEl);
// <duration>
const durEl = xmlDoc.createElement("duration");
durEl.textContent = durText;
noteEl.appendChild(durEl);
// <voice>
const voiceEl = xmlDoc.createElement("voice");
voiceEl.textContent = voiceText;
noteEl.appendChild(voiceEl);
// <type>
const typeEl = xmlDoc.createElement("type");
typeEl.textContent = typeText;
noteEl.appendChild(typeEl);
// <stem>
const stemEl = xmlDoc.createElement("stem");
stemEl.textContent = stemText;
noteEl.appendChild(stemEl);
// <staff> (if multi-staff)
if (staffText) {
const staffEl = xmlDoc.createElement("staff");
staffEl.textContent = staffText;
noteEl.appendChild(staffEl);
}
// Copy default-x from source note
const dx = srcEl.getAttribute("default-x");
if (dx) noteEl.setAttribute("default-x", dx);
noteEl.setAttribute("data-modified", "1");
// Insert right after the selected note's element in the measure
const measureEl = srcEl.parentNode;
const nextSibling = srcEl.nextSibling;
if (nextSibling) measureEl.insertBefore(noteEl, nextSibling);
else measureEl.appendChild(noteEl);
// Re-parse and render
layout = parseScoreLayout(xmlDoc);
rebuildSystemsAndNotes();
const ux = parseInt(document.getElementById("offset-x").value) || 0;
const uy = parseInt(document.getElementById("offset-y").value) || 0;
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
// Select the newly added chord note
const newIdx = noteInfos.findIndex(ni => ni.element === noteEl);
if (newIdx >= 0) selectNote(newIdx);
updatePageStatus();
}
// ── Phase 2: Duration Change ────────────────────────────────
// MusicXML type string → multiplier relative to quarter note (divisions = quarter)
const DURATION_TYPES = {
"whole": 4,
"half": 2,
"quarter": 1,
"eighth": 0.5,
"16th": 0.25,
"32nd": 0.125,
};
// Key bindings: numpad/number → type name
const KEY_TO_TYPE = {
"1": "whole",
"2": "half",
"4": "quarter",
"5": "eighth",
"6": "16th",
"7": "32nd",
};
/**
* Change selected note's duration.
* @param {string} newType — "whole", "half", "quarter", "eighth", "16th"
*/
/** Find all noteInfos in the same chord group (same measure, onset, voice). */
function getChordGroup(idx) {
const n = noteInfos[idx];
return noteInfos.filter(other =>
!other.isRest &&
other.measureNum === n.measureNum &&
other.onsetDiv === n.onsetDiv &&
other.voice === n.voice &&
other.partIndex === n.partIndex
);
}
/** Apply duration+type change to a single XML note element. */
function applyDurationToElement(nEl, newDurDiv, newType) {
const durEl = nEl.querySelector("duration");
if (durEl) durEl.textContent = newDurDiv.toString();
let typeEl = nEl.querySelector("type");
if (!typeEl) {
typeEl = xmlDoc.createElement("type");
const durAfter = nEl.querySelector("duration");
if (durAfter) durAfter.after(typeEl);
else nEl.appendChild(typeEl);
}
typeEl.textContent = newType;
nEl.setAttribute("data-modified", "1");
}
// Map MusicXML type → omrData rational duration string
const TYPE_TO_RATIONAL = {
"whole": "1/1", "half": "1/2", "quarter": "1/4", "eighth": "1/8", "16th": "1/16", "32nd": "1/32",
};
// Map MusicXML type → omrData headShape
const TYPE_TO_HEAD_SHAPE = {
"whole": "WHOLE_NOTE", "half": "NOTEHEAD_VOID", "quarter": "NOTEHEAD_BLACK",
"eighth": "NOTEHEAD_BLACK", "16th": "NOTEHEAD_BLACK", "32nd": "NOTEHEAD_BLACK",
};
function changeDuration(newType) {
if (selectedIdx < 0) return;
const n = noteInfos[selectedIdx];
// Allow rest duration change in OMR mode, block in XML mode (complex backup recalc)
if (n.isRest && !(isOmrMode() && n._omrBased)) return;
const multiplier = DURATION_TYPES[newType];
if (multiplier === undefined) return;
if (isOmrMode() && n._omrBased) {
// ── OMR direct modification ──
const newRational = TYPE_TO_RATIONAL[newType];
if (!newRational || n.durationRational === newRational) return;
pushUndo();
const chord = findOmrChord(n);
if (chord) {
chord.duration = newRational;
if (chord.heads) {
const newShape = TYPE_TO_HEAD_SHAPE[newType] || "NOTEHEAD_BLACK";
for (const h of chord.heads) h.shape = newShape;
}
if (chord.restShape !== undefined) {
const REST_SHAPES = {"whole":"WHOLE_REST","half":"HALF_REST","quarter":"QUARTER_REST",
"eighth":"EIGHTH_REST","16th":"16TH_REST","32nd":"32ND_REST"};
chord.restShape = REST_SHAPES[newType] || chord.restShape;
}
} else {
console.warn("[changeDuration] findOmrChord failed:", n.omrChordId, n._omrMeasureIdx, n.systemIdx);
}
n.durationDiv = parseRational(newRational);
n.durationRational = newRational;
n.headShape = TYPE_TO_HEAD_SHAPE[newType] || n.headShape;
markModified(n);
if (n.omrHeadId && n.omrChordId) {
recordOmrEdit({ type: "change_duration", headId: n.omrHeadId,
chordId: n.omrChordId, newDuration: newType });
}
// Apply to all notes in same tuplet group
const tupGroup = getTupletGroup(selectedIdx);
if (tupGroup) {
for (const gi of tupGroup) {
if (gi === selectedIdx) continue;
const gn = noteInfos[gi];
if (!gn || !gn._omrBased) continue;
const gc = findOmrChord(gn);
if (gc) {
gc.duration = newRational;
if (gc.heads) {
const newShape = TYPE_TO_HEAD_SHAPE[newType] || "NOTEHEAD_BLACK";
for (const h of gc.heads) h.shape = newShape;
}
}
gn.durationDiv = parseRational(newRational);
gn.durationRational = newRational;
gn.headShape = TYPE_TO_HEAD_SHAPE[newType] || gn.headShape;
markModified(gn);
}
}
refreshAfterDurationChange();
// Check if note now overflows measure
const measDur = _tlGetMeasureDuration(n.measureNum, n.systemIdx);
if (measDur > 0) _checkMeasureOverflow(selectedIdx, measDur);
} else {
// ── XML mode ──
const divisions = n.divisions || 1;
const hasDot = n.element.querySelector("dot") !== null;
const dotMult = hasDot ? 1.5 : 1.0;
const newDurDiv = Math.round(divisions * multiplier * dotMult);
if (newDurDiv === n.durationDiv) return;
pushUndo();
applyDurationToElement(n.element, newDurDiv, newType);
n.durationDiv = newDurDiv;
refreshAfterDurationChange();
}
}
let _tupletGroupCounter = 0;
/** Get all noteInfo indices in the same tuplet group */
function getTupletGroup(idx) {
const n = noteInfos[idx];
if (!n || !n.tupletGroupId) return null;
const gid = n.tupletGroupId;
return noteInfos.map((ni, i) => ni.tupletGroupId === gid ? i : -1).filter(i => i >= 0);
}
/**
* Apply/remove triplet to selected notes (TL multi-select or single).
* Triplet: duration × 2/3, assigns tuplet group. Un-triplet: × 3/2, removes group.
*/
function applyTuplet(mode) {
const targets = tlSelectedIndices.size > 1 ? [...tlSelectedIndices]
: scoreSelectedIndices.size > 1 ? [...scoreSelectedIndices]
: (selectedIdx >= 0 ? [selectedIdx] : []);
if (targets.length === 0) return;
if (!isOmrMode()) return;
const multiplier = mode === "triplet" ? 2/3 : 3/2;
pushUndo();
const groupId = mode === "triplet" ? `tg_${++_tupletGroupCounter}` : null;
for (const idx of targets) {
const n = noteInfos[idx];
if (!n || !n._omrBased) continue;
const chord = findOmrChord(n);
if (!chord) continue;
const curDur = parseRational(chord.duration);
const newDur = curDur * multiplier;
const newRational = durationFloatToRational(newDur);
chord.duration = newRational;
chord.tupletGroupId = groupId;
n.durationDiv = newDur;
n.durationRational = newRational;
n.tupletGroupId = groupId;
markModified(n);
if (n.omrHeadId && n.omrChordId) {
recordOmrEdit({ type: "change_duration", headId: n.omrHeadId,
chordId: n.omrChordId, newDuration: newRational });
}
}
refreshAfterDurationChange();
}
/**
* Toggle dot on selected note.
* Dotted = duration * 1.5, undotted = restore base.
*/
function toggleDot() {
if (selectedIdx < 0) return;
const n = noteInfos[selectedIdx];
if (n.isRest) return;
if (isOmrMode() && n._omrBased) {
// ── OMR direct modification ──
// Determine current dot state from durationRational
const chord = findOmrChord(n);
if (!chord) return;
// Dotted = base * 1.5, so if current matches a dotted value, it has a dot
// Simple approach: toggle dots field
const hasDot = (chord.dotsNumber || 0) > 0;
pushUndo();
if (hasDot) {
chord.dotsNumber = 0;
const curDur = parseRational(chord.duration);
const newDur = curDur / 1.5;
chord.duration = durationFloatToRational(newDur);
} else {
chord.dotsNumber = 1;
const curDur = parseRational(chord.duration);
const newDur = curDur * 1.5;
chord.duration = durationFloatToRational(newDur);
}
n.durationDiv = parseRational(chord.duration);
n.durationRational = chord.duration;
markModified(n);
if (n.omrHeadId) {
recordOmrEdit({ type: "toggle_dot", headId: n.omrHeadId, addDot: !hasDot });
}
refreshAfterDurationChange();
} else {
// ── XML mode ──
const nEl = n.element;
const divisions = n.divisions || 1;
const hasDot = nEl.querySelector("dot") !== null;
const typeEl = nEl.querySelector("type");
const typeName = typeEl?.textContent || "quarter";
const baseMultiplier = DURATION_TYPES[typeName] || 1;
const newDurDiv = hasDot
? Math.round(divisions * baseMultiplier)
: Math.round(divisions * baseMultiplier * 1.5);
pushUndo();
const dot = nEl.querySelector("dot");
if (hasDot) {
if (dot) dot.remove();
} else {
if (!dot) {
const newDot = xmlDoc.createElement("dot");
const tEl = nEl.querySelector("type");
if (tEl) tEl.after(newDot);
else {
const dEl = nEl.querySelector("duration");
if (dEl) dEl.after(newDot);
}
}
}
const dEl = nEl.querySelector("duration");
if (dEl) dEl.textContent = newDurDiv.toString();
n.durationDiv = newDurDiv;
refreshAfterDurationChange();
}
}
/** Convert a duration in divisions to a MusicXML type name. */
function divToTypeName(dur, divisions) {
const ratio = dur / divisions; // relative to quarter note
if (Math.abs(ratio - 4) < 0.01) return "whole";
if (Math.abs(ratio - 3) < 0.01) return "half"; // dotted half
if (Math.abs(ratio - 2) < 0.01) return "half";
if (Math.abs(ratio - 1.5) < 0.01) return "quarter"; // dotted quarter
if (Math.abs(ratio - 1) < 0.01) return "quarter";
if (Math.abs(ratio - 0.75) < 0.01) return "eighth"; // dotted eighth
if (Math.abs(ratio - 0.5) < 0.01) return "eighth";
if (Math.abs(ratio - 0.25) < 0.01) return "16th";
if (Math.abs(ratio - 0.125) < 0.01) return "32nd";
return null;
}
/** After duration edit: re-parse notes and re-render. */
/**
* Recalculate <backup> durations in each measure.
*
* Strategy: walk the measure in two passes.
* Pass 1: compute each voice's total duration.
* Pass 2: backup = cursorTime (rewind to measure start, the common case).
*
* This handles the typical pattern where backup always rewinds to
* the beginning of the measure for the next voice.
*/
function recalcBackups() {
const measures = xmlDoc.querySelectorAll("measure");
for (const mEl of measures) {
const children = mEl.children;
let cursorTime = 0;
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child.tagName === "note") {
const isChord = child.querySelector("chord") !== null;
if (!isChord) {
const dur = parseInt(child.querySelector("duration")?.textContent || "0");
cursorTime += dur;
}
} else if (child.tagName === "forward") {
const dur = parseInt(child.querySelector("duration")?.textContent || "0");
cursorTime += dur;
} else if (child.tagName === "backup") {
// Rewind cursor to 0 (measure start), set backup duration accordingly
const durEl = child.querySelector("duration");
if (durEl && cursorTime > 0) {
durEl.textContent = cursorTime.toString();
}
cursorTime = 0;
}
}
}
}
/**
* Central function: rebuild systemsData + noteInfos from the best available source.
* OMR-primary mode (.omr data available) → parseSystemsFromOmr + parseNotesFromOmr
* Legacy XML mode (no .omr) → parseSystems + parseNotes from xmlDoc
*/
function rebuildSystemsAndNotes() {
const pg = pages[currentPageIdx];
const omrData = pg && pg.omrData;
const hasOmrSystems = omrData && omrData.systems && omrData.systems.length > 0;
if (hasOmrSystems) {
const omrSystems = parseSystemsFromOmr(omrData);
const omrNotes = omrSystems ? parseNotesFromOmr(omrData, omrSystems) : null;
if (omrSystems && omrNotes) {
systemsData = omrSystems;
noteInfos = omrNotes;
// Restore modified flags from prior edits
if (_modifiedChordIds.size > 0) {
let restored = 0;
for (const n of noteInfos) {
if (n.omrChordId && _modifiedChordIds.has(n.omrChordId)) {
n.modified = true;
n.omrY = null;
restored++;
}
}
console.log(`[rebuild] restored ${restored} modified flags from ${_modifiedChordIds.size} tracked edits`);
}
return;
}
}
// Fallback: XML mode
systemsData = parseSystems(xmlDoc, layout);
{ const ns = systemsData.length > 0 ? systemsData[0].numStaves : 1; reassignMeasuresToSystems(systemsData, detectedStaves, ns); }
noteInfos = parseNotes(xmlDoc, systemsData);
}
function refreshAfterDurationChange() {
if (!isOmrMode()) {
recalcBackups();
layout = parseScoreLayout(xmlDoc);
}
rebuildSystemsAndNotes();
const ux = parseFloat(offsetX.value || 0);
const uy = parseFloat(offsetY.value || 0);
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
// Try to re-select near same position
if (selectedIdx >= 0 && selectedIdx < noteInfos.length) {
selectNote(selectedIdx);
} else if (noteInfos.length > 0) {
selectNote(noteInfos.length - 1);
}
updatePageStatus();
}
function recomputeAndUpdate(idx) {
const n = noteInfos[idx];
const uy = parseFloat(offsetY.value || 0);
const ref = clefReferencePosition(n.clef);
const noteDiatonic = diatonicIndex(n.step, n.octave);
const staffPos = ref.staffPosition + (noteDiatonic - ref.diatonicIdx);
// Try detected staves first (image-based pixel coords)
const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1;
const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys);
const sysStaves = staffSystems[n.systemIdx];
// n.staff from .omr is page-global (1-based); convert to system-local (1-based)
const localStaffIdx = ((n.staff - 1) % numStavesPerSys) + 1;
const staffData = sysStaves ? sysStaves[localStaffIdx - 1] : null;
if (staffData) {
const halfSpacing = staffData.lineSpacing / 2;
const oldPy = n.py;
n.py = staffData.bottomLineY - (staffPos * halfSpacing) + uy;
console.log("[recompute] staffData branch: oldPy=", oldPy, "newPy=", n.py,
"botY=", staffData.bottomLineY, "halfSp=", halfSpacing, "staffPos=", staffPos,
"step=", n.step, "oct=", n.octave);
} else if (n._omrBased) {
// OMR mode: use .omr staves pixel coordinates directly
const pg = pages[currentPageIdx];
const omrSys = pg && pg.omrData && pg.omrData.systems[n.systemIdx];
const omrStaff = omrSys && omrSys.staves[localStaffIdx - 1];
console.log("[recompute-omr] staff=", n.staff, "sysIdx=", n.systemIdx,
"omrSys?", !!omrSys, "staves.len=", omrSys ? omrSys.staves.length : "N/A",
"omrStaff?", !!omrStaff, "lines?", omrStaff && omrStaff.lines ? omrStaff.lines.length : "N/A",
"staffPos=", staffPos);
if (omrStaff && omrStaff.lines && omrStaff.lines.length >= 5) {
const topLineY = omrStaff.lines[0].y1;
const botLineY = omrStaff.lines[4].y1;
const interline = (botLineY - topLineY) / 4;
const halfInterline = interline / 2;
n.py = botLineY - (staffPos * halfInterline) + uy;
console.log("[recompute-omr] topY=", topLineY, "botY=", botLineY, "halfIL=", halfInterline, "→ py=", n.py);
} else {
console.warn("[recompute-omr] FALLBACK — no OMR staff lines, py unchanged:", n.py);
}
} else {
let staffTopY = n.systemTopY;
if (localStaffIdx > 1) staffTopY += (localStaffIdx - 1) * (40 + n.staffDistance);
const yTenths = staffTopY + 40 - (staffPos * 5);
n.py = yTenths * pixelsPerTenth + uy;
}
updateSingleMarker(idx);
selectNote(idx);
// Refresh timeline if visible
if (typeof timelinePanelVisible !== "undefined" && timelinePanelVisible && timelinePanelMeasure) {
renderTimelinePanel(timelinePanelMeasure.measureNum, timelinePanelMeasure.systemIdx);
}
}
function navigateNote(direction) {
if (noteInfos.length === 0) return;
scoreSelectedIndices.clear();
_scoreUpdateSelectionVisuals();
if (selectedIdx < 0) { selectNote(0); return; }
let next = selectedIdx + direction;
if (next < 0) next = noteInfos.length - 1;
if (next >= noteInfos.length) next = 0;
selectNote(next);
const circle = markerSvg.querySelector(`circle[data-idx="${next}"]`);
if (circle) {
const wrapper = document.getElementById("canvas-wrapper");
const cx = parseFloat(circle.getAttribute("cx")) * currentZoom;
const cy = parseFloat(circle.getAttribute("cy")) * currentZoom;
const wRect = wrapper.getBoundingClientRect();
if (cx < wrapper.scrollLeft || cx > wrapper.scrollLeft + wRect.width)
wrapper.scrollLeft = cx - wRect.width / 2;
if (cy < wrapper.scrollTop || cy > wrapper.scrollTop + wRect.height)
wrapper.scrollTop = cy - wRect.height / 2;
}
}
// ================================================================
// Section 6: Download
// ================================================================
function downloadModifiedXml() {
// OMR-primary mode: generate MusicXML from noteInfos (full rewrite)
if (isOmrMode() && noteInfos.length > 0 && noteInfos[0]._omrBased) {
const xmlStr = generateMusicXmlFromNoteInfos();
if (xmlStr) {
const blob = new Blob([xmlStr], { type: "application/xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "corrected.musicxml";
a.click();
URL.revokeObjectURL(url);
return;
}
}
if (!xmlDoc) return;
// Legacy XML mode: clone and strip custom attributes
const exportDoc = xmlDoc.cloneNode(true);
exportDoc.querySelectorAll("[data-modified]").forEach(el => el.removeAttribute("data-modified"));
exportDoc.querySelectorAll("[data-px]").forEach(el => el.removeAttribute("data-px"));
exportDoc.querySelectorAll("[data-omr-x]").forEach(el => el.removeAttribute("data-omr-x"));
exportDoc.querySelectorAll("[data-omr-y]").forEach(el => el.removeAttribute("data-omr-y"));
exportDoc.querySelectorAll("[data-omr-grade]").forEach(el => el.removeAttribute("data-omr-grade"));
exportDoc.querySelectorAll("[data-omr-chord-id]").forEach(el => el.removeAttribute("data-omr-chord-id"));
exportDoc.querySelectorAll("[data-omr-head-id]").forEach(el => el.removeAttribute("data-omr-head-id"));
const serializer = new XMLSerializer();
const xmlStr = serializer.serializeToString(exportDoc);
const blob = new Blob([xmlStr], { type: "application/xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "corrected.musicxml";
a.click();
URL.revokeObjectURL(url);
}
async function downloadMml() {
// Get XML string (same logic as downloadModifiedXml)
let xmlStr;
if (isOmrMode() && noteInfos.length > 0 && noteInfos[0]._omrBased) {
xmlStr = generateMusicXmlFromNoteInfos();
} else if (xmlDoc) {
const exportDoc = xmlDoc.cloneNode(true);
exportDoc.querySelectorAll("[data-modified],[data-px],[data-omr-x],[data-omr-y],[data-omr-grade],[data-omr-chord-id],[data-omr-head-id]").forEach(el => {
el.removeAttribute("data-modified"); el.removeAttribute("data-px");
el.removeAttribute("data-omr-x"); el.removeAttribute("data-omr-y");
el.removeAttribute("data-omr-grade"); el.removeAttribute("data-omr-chord-id");
el.removeAttribute("data-omr-head-id");
});
xmlStr = new XMLSerializer().serializeToString(exportDoc);
}
if (!xmlStr) { alert("No XML data to convert."); return; }
// Send to server for MML conversion
const formData = new FormData();
formData.append("xml", new Blob([xmlStr], { type: "application/xml" }), "score.musicxml");
try {
const resp = await fetch("/api/xml-to-mml", { method: "POST", body: formData });
if (!resp.ok) { alert("MML conversion failed: " + resp.status); return; }
const data = await resp.json();
if (data.warnings && data.warnings.length) console.warn("MML warnings:", data.warnings);
const blob = new Blob([data.mml], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "corrected.mml"; a.click();
URL.revokeObjectURL(url);
} catch (e) { alert("MML conversion error: " + e.message); }
}
/**
* Generate MusicXML from current noteInfos (OMR-primary mode).
* Focuses on pitch and duration. Ignores tie/slur/dynamics.
*/
function generateMusicXmlFromNoteInfos() {
const pg = pages[currentPageIdx];
const omrData = pg && pg.omrData;
const DIVISIONS = 24; // quarter=24 divisions (triplets divide evenly)
const numStavesPerSys = systemsData.length > 0 ? systemsData[0].numStaves : 1;
// Group notes by partIndex, then measureNum
const partMap = {}; // partIndex → { measureNum → [noteInfo...] }
for (const n of noteInfos) {
const pi = n.partIndex || 0;
if (!partMap[pi]) partMap[pi] = {};
const mNum = n.measureNum || "1";
if (!partMap[pi][mNum]) partMap[pi][mNum] = [];
partMap[pi][mNum].push(n);
}
const partIndices = Object.keys(partMap).sort((a, b) => parseInt(a) - parseInt(b));
// Build barline map: measureNum → barline shape
const barlineMap = {};
if (omrData && omrData.systems) {
let globalMeasBase = 1;
for (const sys of omrData.systems) {
const stacks = sys.stacks || [];
const barlines = sys.barlines || [];
for (let si = 0; si < stacks.length; si++) {
const stack = stacks[si];
const measNum = String(globalMeasBase + si);
for (const bl of barlines) {
if (!bl.bounds) continue;
const blX = bl.bounds.x;
const shape = (bl.shape || "").toUpperCase();
if (Math.abs(blX - stack.left) < 20) {
if (shape.includes("REPEAT_START") || shape === "REPEAT_BOTH") {
if (!barlineMap[measNum]) barlineMap[measNum] = {};
barlineMap[measNum].left = shape;
}
}
if (Math.abs(blX - stack.right) < 20) {
if (!barlineMap[measNum]) barlineMap[measNum] = {};
barlineMap[measNum].right = shape;
}
}
}
globalMeasBase += stacks.length;
}
}
// Get time signature from omrData or default 4/4
let beats = 4, beatType = 4;
if (omrData && omrData.systems && omrData.systems.length > 0) {
const sys0 = omrData.systems[0];
if (sys0.timeSigs && sys0.timeSigs.length > 0) {
beats = sys0.timeSigs[0].numerator || 4;
beatType = sys0.timeSigs[0].denominator || 4;
}
}
// Helper: get duration as whole=1 based float (always use durationRational for consistency)
function getDurWhole(n) {
if (n.durationRational) return parseRational(n.durationRational);
if (n.durationDiv >= 1) return n.durationDiv / 4;
return n.durationDiv;
}
// Helper: whole-based duration → MusicXML duration integer
function durToDivisions(durWhole) {
return Math.round(durWhole * DIVISIONS * 4);
}
// Helper: whole-based duration → type name
function durToType(durWhole) {
const baseDurs = [
[1.0, "whole"], [0.5, "half"], [0.25, "quarter"],
[0.125, "eighth"], [0.0625, "16th"], [0.03125, "32nd"]
];
for (const [val, name] of baseDurs) {
if (Math.abs(durWhole - val) < 0.001) return name;
if (Math.abs(durWhole - val * 1.5) < 0.001) return name; // dotted
if (Math.abs(durWhole - val * 1.75) < 0.001) return name; // double-dotted
}
let closest = "quarter";
let minDiff = Infinity;
for (const [val, name] of baseDurs) {
const diff = Math.abs(durWhole - val);
if (diff < minDiff) { minDiff = diff; closest = name; }
}
return closest;
}
// Helper: check if duration is dotted
function isDotted(n) {
const chord = findOmrChord(n);
if (chord && (chord.dotsNumber || 0) > 0) return true;
const durW = getDurWhole(n);
const baseDurs = [1.0, 0.5, 0.25, 0.125, 0.0625, 0.03125];
for (const base of baseDurs) {
if (Math.abs(durW - base * 1.5) < 0.001) return true;
}
return false;
}
// Helper: convert page-global staff to part-local (1-based)
const numPartsTotal = partIndices.length || 1;
const numStavesPerPart = Math.max(1, Math.round(numStavesPerSys / numPartsTotal));
function localStaffOf(n) {
return ((n.staff || 1) - 1) % numStavesPerPart + 1;
}
// Build XML string
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 4.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">\n';
xml += '<score-partwise version="4.0">\n';
// Part list
xml += ' <part-list>\n';
for (const pi of partIndices) {
const id = `P${parseInt(pi) + 1}`;
xml += ` <score-part id="${id}"><part-name>Part ${parseInt(pi) + 1}</part-name></score-part>\n`;
}
xml += ' </part-list>\n';
// Parts
for (const pi of partIndices) {
const id = `P${parseInt(pi) + 1}`;
xml += ` <part id="${id}">\n`;
const measures = partMap[pi];
const measureNums = Object.keys(measures).sort((a, b) => parseInt(a) - parseInt(b));
// Determine number of staves for this part using system-local staff values
const localStaffSet = new Set();
for (const mNum of measureNums) {
for (const n of measures[mNum]) {
localStaffSet.add(localStaffOf(n));
}
}
const numStaves = localStaffSet.size || 1;
const minLocalStaff = Math.min(...localStaffSet);
const staffOffset = minLocalStaff - 1;
// Track running state for mid-piece changes
let prevFifths = null, prevBeats = null, prevBeatType = null;
const prevClefs = {}; // staffNum → {sign, line}
for (let mi = 0; mi < measureNums.length; mi++) {
const mNum = measureNums[mi];
const mNotes = measures[mNum];
xml += ` <measure number="${mNum}">\n`;
// Determine current measure's key/time/clef
const curFifths = mNotes[0] ? (mNotes[0].fifths || 0) : 0;
let curBeats = beats, curBeatType = beatType;
if (omrData && mNotes[0] && mNotes[0].systemIdx != null) {
const sys = omrData.systems[mNotes[0].systemIdx];
if (sys && sys.timeSigs && sys.timeSigs.length > 0) {
curBeats = sys.timeSigs[0].numerator || beats;
curBeatType = sys.timeSigs[0].denominator || beatType;
}
}
const keyChanged = prevFifths !== null && curFifths !== prevFifths;
const timeChanged = prevBeats !== null && (curBeats !== prevBeats || curBeatType !== prevBeatType);
let clefChanged = false;
const curClefs = {};
if (numStaves > 1) {
for (let si = 1; si <= numStaves; si++) {
const staffNote = mNotes.find(n => (localStaffOf(n) - staffOffset) === si);
const clef = staffNote ? staffNote.clef : { sign: "G", line: 2 };
curClefs[si] = clef;
if (prevClefs[si] && (prevClefs[si].sign !== clef.sign || prevClefs[si].line !== clef.line)) {
clefChanged = true;
}
}
} else {
const clef = mNotes[0] ? (mNotes[0].clef || { sign: "G", line: 2 }) : { sign: "G", line: 2 };
curClefs[1] = clef;
if (prevClefs[1] && (prevClefs[1].sign !== clef.sign || prevClefs[1].line !== clef.line)) {
clefChanged = true;
}
}
if (mi === 0 || keyChanged || timeChanged || clefChanged) {
xml += ' <attributes>\n';
if (mi === 0) {
xml += ` <divisions>${DIVISIONS}</divisions>\n`;
if (numStaves > 1) xml += ` <staves>${numStaves}</staves>\n`;
}
if (mi === 0 || keyChanged) {
xml += ` <key><fifths>${curFifths}</fifths></key>\n`;
}
if (mi === 0 || timeChanged) {
xml += ` <time><beats>${curBeats}</beats><beat-type>${curBeatType}</beat-type></time>\n`;
}
if (mi === 0 || clefChanged) {
if (numStaves > 1) {
for (let si = 1; si <= numStaves; si++) {
const clef = curClefs[si] || { sign: "G", line: 2 };
xml += ` <clef number="${si}"><sign>${clef.sign}</sign><line>${clef.line}</line></clef>\n`;
}
} else {
const clef = curClefs[1] || { sign: "G", line: 2 };
xml += ` <clef><sign>${clef.sign}</sign><line>${clef.line}</line></clef>\n`;
}
}
xml += ' </attributes>\n';
}
prevFifths = curFifths;
prevBeats = curBeats;
prevBeatType = curBeatType;
Object.assign(prevClefs, curClefs);
// Sort notes by onset, then voice, then staff
const sorted = [...mNotes].sort((a, b) => {
const oa = a.onsetDiv || 0, ob = b.onsetDiv || 0;
if (oa !== ob) return oa - ob;
const va = a.voice || 1, vb = b.voice || 1;
if (va !== vb) return va - vb;
return (a.staff || 1) - (b.staff || 1);
});
// Group by voice, write each voice, insert backup between voices
const voiceMap = {};
for (const n of sorted) {
const v = n.voice || 1;
if (!voiceMap[v]) voiceMap[v] = [];
voiceMap[v].push(n);
}
const voices = Object.keys(voiceMap).sort((a, b) => parseInt(a) - parseInt(b));
let prevVoiceTotal = 0;
let firstVoice = true;
for (const v of voices) {
const vNotes = voiceMap[v];
// Backup before second+ voice — use actual duration of previous voice
if (!firstVoice) {
xml += ` <backup><duration>${prevVoiceTotal}</duration></backup>\n`;
}
firstVoice = false;
// Forward to first note's onset if > 0
const firstOnsetWhole = vNotes[0].onsetDiv || 0;
const firstOnsetDiv = durToDivisions(firstOnsetWhole);
if (firstOnsetDiv > 0) {
xml += ` <forward><duration>${firstOnsetDiv}</duration></forward>\n`;
}
// Voice remapping: staff 2 voices 5-8 → 1-4
const exportVoice = parseInt(v) > 4 ? parseInt(v) - 4 : parseInt(v);
// Track onset to detect chords and accumulate voice total
let prevOnset = -1;
let voiceTotal = firstOnsetDiv;
for (const n of vNotes) {
const durW = getDurWhole(n);
const dur = durToDivisions(durW);
const type = durToType(durW);
const dot = isDotted(n);
const isChordNote = (n.onsetDiv === prevOnset && prevOnset >= 0);
const localStaff = localStaffOf(n) - staffOffset;
xml += ' <note>\n';
if (isChordNote) {
xml += ' <chord/>\n';
}
if (n.isRest) {
xml += ' <rest/>\n';
} else {
xml += ' <pitch>\n';
xml += ` <step>${n.step}</step>\n`;
if (n.alter && n.alter !== 0) {
xml += ` <alter>${n.alter}</alter>\n`;
}
xml += ` <octave>${n.octave}</octave>\n`;
xml += ' </pitch>\n';
}
xml += ` <duration>${dur}</duration>\n`;
xml += ` <voice>${exportVoice}</voice>\n`;
// Tuplet: adjust type to notated value and add time-modification
let finalType = type;
let isTuplet = !!n.tupletGroupId;
if (isTuplet) {
const notatedDur = durW * 1.5;
finalType = durToType(notatedDur);
}
xml += ` <type>${finalType}</type>\n`;
if (dot) {
xml += ' <dot/>\n';
}
if (isTuplet) {
xml += ' <time-modification>\n';
xml += ' <actual-notes>3</actual-notes>\n';
xml += ' <normal-notes>2</normal-notes>\n';
xml += ' </time-modification>\n';
}
if (numStaves > 1) {
xml += ` <staff>${localStaff}</staff>\n`;
}
xml += ' </note>\n';
if (!isChordNote) {
prevOnset = n.onsetDiv;
voiceTotal += dur;
}
}
prevVoiceTotal = voiceTotal;
}
// Barline at end of measure
const blInfo = barlineMap[mNum];
if (blInfo) {
const shape = blInfo.right || "";
let barStyle = "", repeatDir = "";
if (shape.includes("FINAL") || shape === "THIN_THICK") barStyle = "light-heavy";
else if (shape === "DOUBLE" || shape === "THIN_THIN") barStyle = "light-light";
else if (shape.includes("REPEAT_END") || shape === "REPEAT_BOTH") {
barStyle = "light-heavy"; repeatDir = "backward";
}
if (blInfo.left && (blInfo.left.includes("REPEAT_START") || blInfo.left === "REPEAT_BOTH")) {
xml += ' <barline location="left">\n';
xml += ' <bar-style>heavy-light</bar-style>\n';
xml += ' <repeat direction="forward"/>\n';
xml += ' </barline>\n';
}
if (barStyle) {
xml += ' <barline location="right">\n';
xml += ` <bar-style>${barStyle}</bar-style>\n`;
if (repeatDir) xml += ` <repeat direction="${repeatDir}"/>\n`;
xml += ' </barline>\n';
}
}
xml += ' </measure>\n';
}
xml += ' </part>\n';
}
xml += '</score-partwise>\n';
return xml;
}
// ── Apply OMR Edits ─────────────────────────────────────────
async function applyOmrEdits() {
if (omrEdits.length === 0) {
loadStatus.textContent = "No pending OMR edits";
return;
}
const pg = pages[currentPageIdx];
if (!pg || !pg.omrFile) {
loadStatus.textContent = "No .omr file loaded for this page";
return;
}
const btn = document.getElementById("omr-apply-btn");
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = "Applying...";
loadStatus.textContent = `Applying ${omrEdits.length} edits to OMR...`;
try {
const formData = new FormData();
formData.append("omr_file", pg.omrFile);
formData.append("edits", JSON.stringify(omrEdits));
formData.append("sheet_number", "1");
const resp = await fetch("/api/omr/apply-edits", { method: "POST", body: formData });
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || `Server error ${resp.status}`);
}
const result = await resp.json();
// Check edit results for errors
const errors = (result.editResults || []).filter(r => r.status !== "ok");
if (errors.length > 0) {
console.warn("Some edits failed:", errors);
loadStatus.textContent = `${errors.length} edit(s) failed — check console`;
}
// Keep current xmlDoc (edits already applied via applyPitchToXmlByHeadId).
// Audiveris CLI re-export ignores pitch attribute changes, so server XML is unmodified.
// Only use server XML as fallback if we don't have a local xmlDoc.
if (!xmlDoc && result.xml) {
const parser = new DOMParser();
const newXmlDoc = parser.parseFromString(result.xml, "text/xml");
if (!newXmlDoc.querySelector("parsererror")) {
xmlDoc = newXmlDoc;
pg.xmlDoc = xmlDoc;
}
}
// Update omrData
if (result.omrData) {
pg.omrData = result.omrData;
}
// Update omrFile so next Apply uses the modified .omr (not the original)
if (result.omrFileBase64) {
const bin = Uint8Array.from(atob(result.omrFileBase64), c => c.charCodeAt(0));
pg.omrFile = new Blob([bin], { type: "application/octet-stream" });
}
// Clear edits (they've been applied to .omr on server)
omrEdits = [];
pg.omrEdits = [];
undoStack = [];
redoStack = [];
pg.undoStack = [];
pg.redoStack = [];
// Keep existing noteInfos — they already reflect in-memory edits.
// Only clear modified flags since edits are now persisted in .omr.
noteInfos.forEach(n => { n.modified = false; });
// Also update in-memory omrData heads to match current noteInfos
// (so that future parseNotesFromOmr on page switch uses edited values)
if (pg.omrData) {
noteInfos.forEach(n => {
if (!n._omrBased || !n.omrHeadId) return;
const sys = pg.omrData.systems[n.systemIdx];
if (!sys) return;
for (const m of sys.measures) {
for (const ch of m.chords) {
for (const h of ch.heads) {
if (String(h.headId) === String(n.omrHeadId)) {
h.pitch = stepOctaveToOmrPitch(n.step, n.octave, n.clef.sign);
h.alter = n.alter;
}
}
}
}
});
}
renderMarkers(noteInfos);
if (selectedIdx >= 0 && selectedIdx < noteInfos.length) selectNote(selectedIdx);
updateApplyBadge();
loadStatus.textContent = `OMR edits applied — ${(result.editResults || []).filter(r => r.status === "ok").length} succeeded`;
} catch (e) {
console.error("Apply OMR edits failed:", e);
loadStatus.textContent = `Apply failed: ${e.message}`;
} finally {
btn.disabled = false;
btn.innerHTML = 'Apply to OMR <span id="omr-apply-badge" style="background:#e44;color:#fff;border-radius:8px;padding:0 5px;margin-left:4px;font-size:10px;display:none"></span>';
updateApplyBadge();
}
}
// Show/hide the Apply button based on whether .omr is loaded
function updateOmrApplyVisibility() {
const btn = document.getElementById("omr-apply-btn");
if (!btn) return;
const pg = pages[currentPageIdx];
btn.style.display = (pg && pg.omrFile) ? "inline-block" : "none";
}
// ================================================================
// Section 7: Load & Init
// ================================================================
function applyZoom(value) {
currentZoom = value / 100;
const container = document.getElementById("canvas-container");
container.style.transform = `scale(${currentZoom})`;
zoomLabel.textContent = value + "%";
}
function recomputeAll() {
if (!layout || !systemsData) return;
// Use image-based scaling as primary, DPI as fallback
const imgW = scoreImage.naturalWidth;
if (imgW > 0 && layout.pageW > 0) {
pixelsPerTenth = imgW / layout.pageW;
} else {
const dpi = parseInt(dpiInput.value) || 300;
pixelsPerTenth = computePixelsPerTenthFromDpi(dpi, layout.mm, layout.tpu);
}
// Apply user staff-distance override
const userStaffDist = parseFloat(document.getElementById("staff-dist-input").value);
const sysDistAdj = parseFloat(document.getElementById("sys-dist-adj").value) || 0;
if (!isNaN(userStaffDist)) {
systemsData.forEach(sys => { sys.staffDistance = userStaffDist; });
// Also update noteInfos
noteInfos.forEach(n => { n.staffDistance = userStaffDist; });
}
// Recalculate system topY with sys-dist adjustment
recalcSystemPositions(sysDistAdj);
const ux = parseFloat(offsetX.value || 0);
const uy = parseFloat(offsetY.value || 0);
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
if (selectedIdx >= 0) selectNote(selectedIdx);
// Refresh debug lines if visible
if (debugLinesVisible) {
markerSvg.querySelectorAll(".debug-line").forEach(el => el.remove());
toggleDebugLines();
debugLinesVisible = true; // toggleDebugLines flips it, so flip back
}
if (freeGlyphsVisible) renderFreeGlyphOverlays();
}
/** Recalculate system topY values, optionally adjusting system-distance */
function recalcSystemPositions(sysDistAdj) {
for (let i = 0; i < systemsData.length; i++) {
const sys = systemsData[i];
if (i === 0) {
// First system: topY stays as originally parsed
// (already set from top-system-distance)
} else {
const prev = systemsData[i - 1];
const prevTotalHeight = 40 + (prev.numStaves - 1) * (40 + prev.staffDistance);
// Use original system-distance + user adjustment
sys.topY = prev.topY + prevTotalHeight + sys._origSysDist + sysDistAdj;
}
}
// Update noteInfos to reference new systemTopY
noteInfos.forEach(n => {
n.systemTopY = systemsData[n.systemIdx].topY;
});
}
async function readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsText(file);
});
}
async function readFileAsArrayBuffer(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
async function extractXmlFromMxl(file) {
const buf = await readFileAsArrayBuffer(file);
if (typeof JSZip === "undefined") {
await loadScript("https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js");
}
const zip = await JSZip.loadAsync(buf);
const xmlFile = Object.keys(zip.files).find(name =>
name.endsWith(".xml") && !name.startsWith("META-INF")
);
if (!xmlFile) throw new Error("No XML file found in MXL archive");
return await zip.files[xmlFile].async("text");
}
function loadScript(src) {
return new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = src;
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
/** Save current page state back to pages[] before switching */
function saveCurrentPageState() {
if (pages.length === 0 || currentPageIdx >= pages.length) return;
if (!xmlDoc) return; // nothing loaded yet — don't overwrite with stale/null data
const pg = pages[currentPageIdx];
pg.xmlDoc = xmlDoc;
pg.noteInfos = noteInfos;
pg.systemsData = systemsData;
pg.layout = layout;
pg.detectedStaves = detectedStaves;
pg.detectedBarlines = detectedBarlines;
pg.pixelsPerTenth = pixelsPerTenth;
pg.undoStack = undoStack;
pg.redoStack = redoStack;
pg.selectedIdx = selectedIdx;
pg.carryBeats = carryBeats;
pg.carryBeatType = carryBeatType;
pg.omrEdits = omrEdits;
}
/** Load a single page by index — sets all globals and renders */
async function loadPage(pageIdx) {
if (pageIdx < 0 || pageIdx >= pages.length) return;
// Save current page state before switching
saveCurrentPageState();
currentPageIdx = pageIdx;
const pg = pages[pageIdx];
// Update page indicator
document.getElementById("page-indicator").textContent = `${pageIdx + 1} / ${pages.length}`;
// If page already parsed, restore state
if (pg.xmlDoc) {
xmlDoc = pg.xmlDoc;
noteInfos = pg.noteInfos;
systemsData = pg.systemsData;
layout = pg.layout;
detectedStaves = pg.detectedStaves;
detectedBarlines = pg.detectedBarlines || [];
pixelsPerTenth = pg.pixelsPerTenth;
undoStack = pg.undoStack || [];
redoStack = pg.redoStack || [];
selectedIdx = pg.selectedIdx >= 0 ? pg.selectedIdx : -1;
if (pg.carryBeats != null) { carryBeats = pg.carryBeats; carryBeatType = pg.carryBeatType; }
omrEdits = pg.omrEdits || [];
updateApplyBadge();
// Load image
scoreImage.src = pg.imageUrl;
await new Promise((resolve, reject) => {
scoreImage.onload = resolve;
scoreImage.onerror = reject;
});
markerSvg.setAttribute("width", scoreImage.naturalWidth);
markerSvg.setAttribute("height", scoreImage.naturalHeight);
// Auto-fit zoom to canvas width on first load
if (!pg._zoomApplied) {
const wrapper = document.getElementById("canvas-wrapper");
const fitZoom = Math.floor((wrapper.clientWidth / scoreImage.naturalWidth) * 100);
const clampedZoom = Math.max(25, Math.min(200, fitZoom));
zoomSlider.value = clampedZoom;
applyZoom(clampedZoom);
pg._zoomApplied = true;
}
renderMarkers(noteInfos);
if (selectedIdx >= 0 && selectedIdx < noteInfos.length) selectNote(selectedIdx);
else { selectedIdx = -1; statusSel.textContent = t("no_sel"); }
// ── Cached page debug ──
{
const hasOmr = !!(pg.omrData && pg.omrData.systems && pg.omrData.systems.length > 0);
const mode = hasOmr ? "OMR-primary(cached)" : "XML-legacy(cached)";
const xVals = noteInfos.filter(n => !n.isRest).map(n => n.px);
const yVals = noteInfos.filter(n => !n.isRest).map(n => n.py);
const xMin = xVals.length ? Math.min(...xVals).toFixed(0) : "N/A";
const xMax = xVals.length ? Math.max(...xVals).toFixed(0) : "N/A";
const omrXVals = noteInfos.filter(n => n.omrX != null).map(n => n.omrX);
const omrXMin = omrXVals.length ? Math.min(...omrXVals).toFixed(0) : "N/A";
const omrXMax = omrXVals.length ? Math.max(...omrXVals).toFixed(0) : "N/A";
console.log(`[PAGE ${pageIdx+1} cached] mode=${mode} img=${scoreImage.naturalWidth}x${scoreImage.naturalHeight} notes=${noteInfos.length} px=[${xMin}..${xMax}] omrX=[${omrXMin}..${omrXMax}]`);
if (hasOmr) {
const omrSys = pg.omrData.systems;
console.log(`[PAGE ${pageIdx+1} cached] omr systems=${omrSys.length}, staves/sys=[${omrSys.map(s=>(s.staves||[]).length).join(",")}]`);
}
// Barlines
const bls = pg.detectedBarlines || detectedBarlines || [];
if (bls.length > 0) {
const blBySys = {};
bls.forEach(bl => { if (!blBySys[bl.systemIdx]) blBySys[bl.systemIdx] = []; blBySys[bl.systemIdx].push(bl.x); });
Object.keys(blBySys).forEach(si => {
const xs = blBySys[si].sort((a,b) => a-b);
console.log(`[PAGE ${pageIdx+1} cached] barlines sys${si}(${xs.length}): x=[${xs[0]?.toFixed(0)}..${xs[xs.length-1]?.toFixed(0)}]`);
});
}
}
updatePageStatus();
return;
}
// First-time parse for this page
loadStatus.textContent = t("loading_page")(pageIdx + 1);
// Load image
if (!pg.imageUrl && pg.imageFile) pg.imageUrl = URL.createObjectURL(pg.imageFile);
scoreImage.src = pg.imageUrl;
await new Promise((resolve, reject) => {
scoreImage.onload = resolve;
scoreImage.onerror = reject;
});
markerSvg.setAttribute("width", scoreImage.naturalWidth);
markerSvg.setAttribute("height", scoreImage.naturalHeight);
// Auto-fit zoom to canvas width on first load
if (!pg._zoomApplied) {
const wrapper = document.getElementById("canvas-wrapper");
const fitZoom = Math.floor((wrapper.clientWidth / scoreImage.naturalWidth) * 100);
const clampedZoom = Math.max(25, Math.min(200, fitZoom));
zoomSlider.value = clampedZoom;
applyZoom(clampedZoom);
pg._zoomApplied = true;
}
// Parse XML — from pre-loaded text or from file
let xmlText;
if (pg.xmlText) {
xmlText = pg.xmlText;
} else if (pg.xmlFile) {
if (pg.xmlFile.name.toLowerCase().endsWith(".mxl")) {
xmlText = await extractXmlFromMxl(pg.xmlFile);
} else {
xmlText = await readFileAsText(pg.xmlFile);
}
} else {
throw new Error("No XML data available for page " + (pageIdx + 1));
}
const parser = new DOMParser();
xmlDoc = parser.parseFromString(xmlText, "application/xml");
if (xmlDoc.querySelector("parsererror")) {
throw new Error("XML parse error: " + xmlDoc.querySelector("parsererror").textContent);
}
layout = parseScoreLayout(xmlDoc);
const imgW = scoreImage.naturalWidth;
if (imgW > 0 && layout.pageW > 0) {
pixelsPerTenth = imgW / layout.pageW;
const effectiveDpi = (pixelsPerTenth * layout.tpu * 25.4) / layout.mm;
dpiInput.value = Math.round(effectiveDpi);
} else {
const dpi = parseInt(dpiInput.value) || 300;
pixelsPerTenth = computePixelsPerTenthFromDpi(dpi, layout.mm, layout.tpu);
}
// Use .omr data for staves/barlines if available, else fall back to image detection
const omrData = pg.omrData;
let useOmrPrimary = false;
if (omrData && omrData.systems && omrData.systems.length > 0) {
// Try .omr-based parsing (Phase 6)
const omrSystems = parseSystemsFromOmr(omrData);
const omrNotes = omrSystems ? parseNotesFromOmr(omrData, omrSystems) : null;
if (omrSystems && omrNotes) {
systemsData = omrSystems;
noteInfos = omrNotes;
useOmrPrimary = true;
console.log(`OMR-primary mode: ${systemsData.length} systems, ${noteInfos.length} notes from .omr`);
}
}
if (!useOmrPrimary) {
// Legacy XML-based parsing
systemsData = parseSystems(xmlDoc, layout);
}
if (omrData && omrData.systems && omrData.systems.length > 0) {
detectedStaves = omrStavesToDetected(omrData);
console.log(`Using OMR staff data: ${detectedStaves.length} staves from .omr`);
} else {
detectedStaves = detectStaffLines(scoreImage);
}
// Interpolate missing staves before reassignment
const numStavesPerSys = systemsData.length > 0 ? systemsData[0].numStaves : 1;
const expectedStaves = systemsData.length * numStavesPerSys;
if (detectedStaves.length < expectedStaves) {
detectedStaves = interpolateMissingStaves(detectedStaves, expectedStaves, numStavesPerSys);
}
if (!useOmrPrimary) {
// Reassign measures to systems using image-detected staff widths (legacy XML mode only)
reassignMeasuresToSystems(systemsData, detectedStaves, numStavesPerSys);
}
// Use .omr barlines if available, else init from XML
if (omrData && omrData.systems && omrData.systems.length > 0) {
detectedBarlines = omrBarlinesToDetected(omrData, detectedStaves, numStavesPerSys);
console.log(`Using OMR barline data: ${detectedBarlines.length} barlines from .omr`);
} else {
initBarlinesFromXML();
}
if (!useOmrPrimary) {
// Legacy: parse notes from MusicXML
noteInfos = parseNotes(xmlDoc, systemsData);
// Match .omr grades to noteInfos (1-time copy at load)
if (omrData && omrData.systems) {
matchOmrGrades(noteInfos, omrData);
}
} else {
// OMR-primary mode: still need to extract free glyphs from omrData
freeGlyphData = [];
for (let si = 0; si < omrData.systems.length; si++) {
const sys = omrData.systems[si];
if (!sys.freeGlyphs) continue;
for (const fg of sys.freeGlyphs) {
freeGlyphData.push({ ...fg, systemIdx: si });
}
}
console.log(`Free glyphs (OMR-primary): ${freeGlyphData.length} candidates`);
if (freeGlyphsVisible) renderFreeGlyphOverlays();
}
if (detectedStaves.length !== expectedStaves) {
console.warn(`Page ${pageIdx + 1}: found ${detectedStaves.length} staves, systems expect ${systemsData.length * numStavesPerSys}`);
}
const ux = parseFloat(offsetX.value || 0);
const uy = parseFloat(offsetY.value || 0);
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
// ── Page load debug ──
{
const imgW = scoreImage.naturalWidth, imgH = scoreImage.naturalHeight;
const hasOmr = !!(pg.omrData && pg.omrData.systems && pg.omrData.systems.length > 0);
const mode = useOmrPrimary ? "OMR-primary" : "XML-legacy";
const nNotes = noteInfos.length;
const staffInfo = detectedStaves.map((s, i) => `[${i}] topY=${s.topLineY?.toFixed(0)} botY=${s.bottomLineY?.toFixed(0)} L=${s.leftX?.toFixed(0)} R=${s.rightX?.toFixed(0)}`);
const xVals = noteInfos.filter(n => !n.isRest).map(n => n.px);
const yVals = noteInfos.filter(n => !n.isRest).map(n => n.py);
const xMin = xVals.length ? Math.min(...xVals).toFixed(0) : "N/A";
const xMax = xVals.length ? Math.max(...xVals).toFixed(0) : "N/A";
const yMin = yVals.length ? Math.min(...yVals).toFixed(0) : "N/A";
const yMax = yVals.length ? Math.max(...yVals).toFixed(0) : "N/A";
const omrXVals = noteInfos.filter(n => n.omrX != null).map(n => n.omrX);
const omrXMin = omrXVals.length ? Math.min(...omrXVals).toFixed(0) : "N/A";
const omrXMax = omrXVals.length ? Math.max(...omrXVals).toFixed(0) : "N/A";
const staffSample = noteInfos.slice(0, 5).map(n => `staff=${n.staff} sys=${n.systemIdx} omrBased=${n._omrBased}`);
console.log(`[PAGE ${pageIdx+1}] mode=${mode} hasOmr=${hasOmr} img=${imgW}x${imgH} ppt=${pixelsPerTenth.toFixed(3)}`);
console.log(`[PAGE ${pageIdx+1}] notes=${nNotes} px=[${xMin}..${xMax}] py=[${yMin}..${yMax}] omrX=[${omrXMin}..${omrXMax}]`);
console.log(`[PAGE ${pageIdx+1}] systems=${systemsData.length} numStavesPerSys=${numStavesPerSys} staves=${detectedStaves.length}`);
console.log(`[PAGE ${pageIdx+1}] staves:`, staffInfo);
console.log(`[PAGE ${pageIdx+1}] first 5 notes staff info:`, staffSample);
if (hasOmr) {
const omrSys = pg.omrData.systems;
console.log(`[PAGE ${pageIdx+1}] omrData: ${omrSys.length} systems, staves per sys: [${omrSys.map(s => (s.staves||[]).length).join(",")}]`);
// First system staves coordinate ranges
if (omrSys[0] && omrSys[0].staves) {
const s0 = omrSys[0].staves;
console.log(`[PAGE ${pageIdx+1}] omr sys0 staves:`, s0.map((st, i) => `[${i}] left=${st.left} right=${st.right} lines=${st.lines?.length}`));
}
// Barline X ranges per system
omrSys.forEach((sys, si) => {
const stacks = sys.stacks || [];
if (stacks.length > 0) {
const lefts = stacks.map(s => s.left);
const rights = stacks.map(s => s.right);
console.log(`[PAGE ${pageIdx+1}] omr sys${si} stacks(${stacks.length}): left=[${Math.min(...lefts).toFixed(0)}..${Math.max(...lefts).toFixed(0)}] right=[${Math.min(...rights).toFixed(0)}..${Math.max(...rights).toFixed(0)}]`);
}
});
}
// Detected barlines debug
if (detectedBarlines.length > 0) {
const blBySys = {};
detectedBarlines.forEach(bl => {
if (!blBySys[bl.systemIdx]) blBySys[bl.systemIdx] = [];
blBySys[bl.systemIdx].push(bl.x);
});
Object.keys(blBySys).forEach(si => {
const xs = blBySys[si].sort((a,b) => a-b);
console.log(`[PAGE ${pageIdx+1}] barlines sys${si}(${xs.length}): x=[${xs[0]?.toFixed(0)}..${xs[xs.length-1]?.toFixed(0)}]`);
});
}
// Layout info
console.log(`[PAGE ${pageIdx+1}] layout: pageW=${layout.pageW} marginL=${layout.marginL} marginR=${layout.marginR}`);
}
renderMarkers(noteInfos);
selectedIdx = -1;
undoStack = [];
redoStack = [];
// Save parsed state
pg.xmlDoc = xmlDoc;
pg.noteInfos = noteInfos;
pg.systemsData = systemsData;
pg.layout = layout;
pg.detectedStaves = detectedStaves;
pg.detectedBarlines = detectedBarlines;
pg.pixelsPerTenth = pixelsPerTenth;
pg.undoStack = undoStack;
pg.redoStack = redoStack;
pg.selectedIdx = -1;
updatePageStatus();
if (systemsData.length > 0) {
document.getElementById("staff-dist-input").value = systemsData[0].staffDistance;
}
const soundEl = xmlDoc.querySelector("sound[tempo]");
if (soundEl) {
const tempo = parseFloat(soundEl.getAttribute("tempo"));
if (tempo > 0) document.getElementById("bpm-input").value = Math.round(tempo);
}
}
function updatePageStatus() {
const pg = pages[currentPageIdx];
const hasOmr = pg && pg.omrData && pg.omrData.systems && pg.omrData.systems.length > 0;
const detectMethod = hasOmr ? `OMR(${detectedStaves.length} staves)` : detectedStaves.length > 0 ? `IMG(${detectedStaves.length} staves)` : "XML fallback";
const numParts = xmlDoc.querySelectorAll("part").length;
const editStr = omrEdits.length > 0 ? ` | ${omrEdits.length} pending` : "";
statusTotal.textContent = `P${currentPageIdx + 1}/${pages.length} | ${noteInfos.length} notes | ${numParts} parts | ${systemsData.length} sys | ${detectMethod}${editStr}`;
loadStatus.textContent = `Page ${currentPageIdx + 1}: ${noteInfos.length} notes, ${detectedStaves.length} staves`;
updateOmrApplyVisibility();
cursorSeekTime = 0;
const initBpm = parseInt(document.getElementById("bpm-input").value) || 120;
const initTl = buildTimeline(noteInfos, initBpm);
if (initTl.length > 0) {
const totalDur = initTl[initTl.length - 1].timeSec + initTl[initTl.length - 1].durationSec;
document.getElementById("progress-time").textContent = "0:00 / " + formatTime(totalDur);
}
if (noteInfos.length > 0) {
placeCursorAtNote(0);
}
}
async function loadFiles() {
let imageFiles = Array.from(imageInput.files).sort((a, b) => a.name.localeCompare(b.name));
let xmlFiles = Array.from(xmlInput.files).sort((a, b) => a.name.localeCompare(b.name));
let omrFiles = omrInput ? Array.from(omrInput.files).sort((a, b) => a.name.localeCompare(b.name)) : [];
// If no files selected, try loading default test set from server
if (imageFiles.length === 0 && xmlFiles.length === 0) {
try {
const resp = await fetch("/api/test-files");
if (resp.ok) {
const data = await resp.json();
if (data.sets && data.sets.length > 0) {
const testName = data.sets.find(s => s.startsWith("secret")) || data.sets[0];
loadStatus.textContent = `Loading test set: ${testName}...`;
// Fetch PNG
const pngResp = await fetch(`/api/test-file/${testName}.png`);
const pngBlob = await pngResp.blob();
imageFiles = [new File([pngBlob], `${testName}.png`, { type: "image/png" })];
// Fetch MXL (XML)
const mxlResp = await fetch(`/api/test-file/${testName}.mxl`);
const mxlBlob = await mxlResp.blob();
xmlFiles = [new File([mxlBlob], `${testName}.mxl`, { type: "application/octet-stream" })];
// Fetch OMR
const omrResp = await fetch(`/api/test-file/${testName}.omr`);
const omrBlob = await omrResp.blob();
omrFiles = [new File([omrBlob], `${testName}.omr`, { type: "application/octet-stream" })];
console.log(`Auto-loaded test set: ${testName}`);
}
}
} catch (e) {
console.warn("Test file auto-load failed:", e);
}
}
if (imageFiles.length === 0 || xmlFiles.length === 0) {
loadStatus.textContent = t("select_prompt");
return;
}
// Match pages: pair by sorted order. If counts differ, use min.
const numPages = Math.min(imageFiles.length, xmlFiles.length);
if (imageFiles.length !== xmlFiles.length) {
console.warn(`File count mismatch: ${imageFiles.length} images, ${xmlFiles.length} XMLs. Using ${numPages} pages.`);
}
// Parse .omr files via server API (optional)
const omrDataArray = [];
for (let i = 0; i < omrFiles.length && i < numPages; i++) {
try {
const formData = new FormData();
formData.append("omr_file", omrFiles[i]);
formData.append("sheet_number", "1");
const resp = await fetch("/api/parse-omr", { method: "POST", body: formData });
if (resp.ok) {
omrDataArray.push(await resp.json());
console.log(`OMR data loaded for page ${i + 1}: ${omrFiles[i].name}`);
} else {
console.warn(`OMR parse failed for ${omrFiles[i].name}: ${resp.status}`);
omrDataArray.push(null);
}
} catch (e) {
console.warn(`OMR fetch error for ${omrFiles[i].name}:`, e);
omrDataArray.push(null);
}
}
// Stop any active playback and release old object URLs
stopPlayback();
for (const oldPg of pages) {
if (oldPg.imageUrl) URL.revokeObjectURL(oldPg.imageUrl);
}
pages = [];
for (let i = 0; i < numPages; i++) {
pages.push({
imageFile: imageFiles[i],
xmlFile: xmlFiles[i],
omrData: omrDataArray[i] || null,
omrFile: i < omrFiles.length ? omrFiles[i] : null,
omrEdits: [],
imageUrl: null,
xmlDoc: null,
noteInfos: null,
systemsData: null,
layout: null,
detectedStaves: null,
pixelsPerTenth: 1,
undoStack: [],
redoStack: [],
selectedIdx: -1,
carryBeats: null, // time sig carry-over: set after first parse
carryBeatType: null,
});
}
// Reset globals so saveCurrentPageState() inside loadPage()
// doesn't write stale data into the fresh pages array
xmlDoc = null;
noteInfos = [];
systemsData = [];
layout = null;
detectedStaves = [];
undoStack = [];
redoStack = [];
omrEdits = [];
freeGlyphData = [];
selectedIdx = -1;
cursorSeekTime = 0;
carryBeats = 4;
carryBeatType = 4;
currentPageIdx = 0;
loadStatus.textContent = t("loading_pages")(numPages);
loadBtn.disabled = true;
try {
await loadPage(0);
// Preload piano samples so drag/click preview uses real piano
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
preloadPianoSamples(audioCtx);
} catch (err) {
loadStatus.textContent = t("error_prefix") + err.message;
console.error(err);
} finally {
loadBtn.disabled = false;
}
}
function prevPage() {
if (pages.length <= 1 || currentPageIdx <= 0) return;
stopPlayback();
loadPage(currentPageIdx - 1);
}
function nextPage() {
if (pages.length <= 1 || currentPageIdx >= pages.length - 1) return;
stopPlayback();
loadPage(currentPageIdx + 1);
}
// ── External data loading (for iframe embed / postMessage) ───
/**
* Load corrector data programmatically without file inputs.
* @param {string[]} imageUrls - array of image URLs (one per page)
* @param {string[]} xmlTexts - array of XML text strings (one per page)
*/
async function loadFromData(imageUrls, xmlTexts, omrDataArray, omrFileUrls) {
const numPages = Math.min(imageUrls.length, xmlTexts.length);
if (numPages === 0) return;
stopPlayback();
for (const oldPg of pages) {
if (oldPg.imageUrl) URL.revokeObjectURL(oldPg.imageUrl);
}
pages = [];
for (let i = 0; i < numPages; i++) {
// Fetch .omr file as blob if URL provided (for Apply to OMR)
let omrFile = null;
if (omrFileUrls && omrFileUrls[i]) {
try {
const resp = await fetch(omrFileUrls[i]);
if (resp.ok) {
const blob = await resp.blob();
const stem = imageUrls[i].split("/").pop().replace(/\.[^.]+$/, "") || `page_${i+1}`;
omrFile = new File([blob], stem + ".omr");
}
} catch(e) { console.warn("Failed to fetch .omr file:", e); }
}
pages.push({
imageFile: null,
xmlFile: null,
imageUrl: imageUrls[i], // pre-set URL
xmlText: xmlTexts[i], // pre-set XML text
xmlDoc: null,
omrData: (omrDataArray && omrDataArray[i]) || null,
omrFile: omrFile,
noteInfos: null,
systemsData: null,
layout: null,
detectedStaves: null,
pixelsPerTenth: 1,
undoStack: [],
redoStack: [],
selectedIdx: -1,
carryBeats: null,
carryBeatType: null,
});
}
xmlDoc = null;
noteInfos = [];
systemsData = [];
layout = null;
detectedStaves = [];
undoStack = [];
redoStack = [];
selectedIdx = -1;
cursorSeekTime = 0;
carryBeats = 4;
carryBeatType = 4;
currentPageIdx = 0;
loadStatus.textContent = t("loading_pages")(numPages);
// Hide file inputs when embedded, but keep page-nav visible
const hideIds = ["image-input", "xml-input", "dpi-input", "load-btn"];
hideIds.forEach(id => { const el = document.getElementById(id); if (el) el.closest("label")?.style.setProperty("display", "none") || (el.style.display = "none"); });
// Also hide the "Images:" and "MusicXML:" labels
document.querySelectorAll("#upload-bar label").forEach(lbl => {
const inp = lbl.querySelector("input[type='file'], #dpi-input");
if (inp) lbl.style.display = "none";
});
const loadBtnEl = document.getElementById("load-btn");
if (loadBtnEl) loadBtnEl.style.display = "none";
try {
await loadPage(0);
} catch (err) {
loadStatus.textContent = t("error_prefix") + err.message;
console.error(err);
}
}
// Listen for postMessage from parent (Gradio iframe embed)
window.addEventListener("message", async (event) => {
if (!event.data || event.data.type !== "corrector-load") return;
const { images, xmls, omrDataArray, omrFileUrls } = event.data;
if (!images || !xmls || images.length === 0 || xmls.length === 0) return;
try {
await loadFromData(images, xmls, omrDataArray, omrFileUrls);
} catch (err) {
console.error("loadFromData error:", err);
}
});
// ── Event Listeners ───────────────────────────────────────────
loadBtn.addEventListener("click", loadFiles);
document.getElementById("btn-prev-page").addEventListener("click", prevPage);
document.getElementById("btn-next-page").addEventListener("click", nextPage);
// Try loading from server session first (set by /api/convert), then fallback to loadFiles
async function loadFromSession() {
try {
const resp = await fetch("/api/session-data");
if (!resp.ok) return false;
const data = await resp.json();
if (!data.image_urls?.length || !data.xml_texts?.length) return false;
await loadFromData(data.image_urls, data.xml_texts,
data.omr_data_array || [], data.omr_file_urls || []);
return true;
} catch (e) {
console.warn("Session data load failed:", e);
return false;
}
}
(async () => {
const loaded = await loadFromSession();
if (!loaded) {
loadFiles().catch(err => {
console.error("Auto-load error:", err);
document.getElementById("load-status").textContent = "Auto-load error: " + err.message;
});
}
})();
markerSvg.addEventListener("mouseover", (e) => {
const circle = e.target.closest("circle.marker");
if (circle && !circle.classList.contains("ghost-marker")) {
showMarkerTooltip(circle);
const idx = parseInt(circle.dataset.idx);
const n = noteInfos[idx];
if (n && !n.isRest) {
// Highlight notes in same chord (same measure, onset, voice)
markerSvg.querySelectorAll("circle.marker").forEach(m => {
const mi = parseInt(m.dataset.idx);
if (mi === idx) return;
const mn = noteInfos[mi];
if (mn && !mn.isRest &&
String(mn.measureNum) === String(n.measureNum) &&
mn.voice === n.voice &&
Math.abs((mn.onsetDiv || 0) - (n.onsetDiv || 0)) < 0.001) {
m.classList.add("chord-hover");
}
});
}
}
});
markerSvg.addEventListener("mouseout", (e) => {
const circle = e.target.closest("circle.marker");
if (circle) {
hideMarkerTooltip();
markerSvg.querySelectorAll("circle.chord-hover").forEach(m => m.classList.remove("chord-hover"));
}
});
markerSvg.addEventListener("click", (e) => {
if (staffAdjustMode) return; // suppress note clicks in staff adjust mode
if (addMode) {
if (e.target.closest("circle.marker") && !e.target.classList.contains("ghost-marker")) {
onMarkerClick(e);
}
return;
}
if (!e.target.closest("circle.marker")) {
// After rubber-band drag, skip the clear
if (_scoreRubberJustFinished) { _scoreRubberJustFinished = false; return; }
// Clicked empty space: clear multi-select
if (!e.ctrlKey && !e.metaKey) {
scoreSelectedIndices.clear();
_scoreUpdateSelectionVisuals();
selectNote(-1);
}
return;
}
onMarkerClick(e);
});
// Ghost marker mousemove
let _ghostDebugThrottle = 0;
document.getElementById("canvas-wrapper").addEventListener("mousemove", (e) => {
if (staffAdjustMode) return;
if (!addMode || (!xmlDoc && !isOmrMode())) { hideGhostMarker(); return; }
const container = document.getElementById("canvas-container");
const rect = container.getBoundingClientRect();
const px = (e.clientX - rect.left) / currentZoom;
const py = (e.clientY - rect.top) / currentZoom;
let info;
try {
info = pixelToStaffPitch(px, py);
} catch (err) {
if (Date.now() - _ghostDebugThrottle > 2000) {
console.error("[ghostMove] pixelToStaffPitch crashed:", err);
_ghostDebugThrottle = Date.now();
}
hideGhostMarker();
return;
}
if (!info && Date.now() - _ghostDebugThrottle > 2000) {
console.log("[ghostMove] info=null, px=", Math.round(px), "py=", Math.round(py),
"systemsData.len=", systemsData.length, "detectedStaves.len=", detectedStaves.length);
_ghostDebugThrottle = Date.now();
}
if (info && !info._missingSystem) {
showGhostMarker(info.snappedPx, info.snappedPy, info.step, info.octave, info.beatLabel);
} else if (info && info._missingSystem) {
// Show ghost at click position with hint that measures will be auto-created
showGhostMarker(info.snappedPx, info.snappedPy, info.step, info.octave, "new");
} else {
hideGhostMarker();
}
});
// Add mode click: insert note at click position
document.getElementById("canvas-wrapper").addEventListener("click", (e) => {
if (!addMode) return;
console.log("[addClick] addMode=true, staffAdjustMode=", staffAdjustMode,
"xmlDoc=", !!xmlDoc, "isOmrMode=", isOmrMode(),
"target=", e.target.tagName, e.target.className);
if (staffAdjustMode) { console.log("[addClick] blocked: staffAdjustMode"); return; }
if (!addMode || (!xmlDoc && !isOmrMode())) { console.log("[addClick] blocked: no xmlDoc and not omrMode"); return; }
if (e.target.closest("circle.marker") && !e.target.classList.contains("ghost-marker")) { console.log("[addClick] blocked: clicked on existing marker"); return; }
if (e.shiftKey) { console.log("[addClick] blocked: shiftKey"); return; }
const container = document.getElementById("canvas-container");
const rect = container.getBoundingClientRect();
const px = (e.clientX - rect.left) / currentZoom;
const py = (e.clientY - rect.top) / currentZoom;
console.log("[addClick] px=", Math.round(px), "py=", Math.round(py));
let info;
try {
info = pixelToStaffPitch(px, py);
} catch (err) {
console.error("[addClick] pixelToStaffPitch crashed:", err);
return;
}
console.log("[addClick] info=", info);
// If clicked on a system with no XML measures, auto-create measures first
if (info && info._missingSystem) {
console.log(`[addClick] system ${info.systemIdx} has no measures — auto-creating`);
autoCreateMeasuresForSystem(info.systemIdx);
info = pixelToStaffPitch(px, py);
}
if (!info || (!info.measureEl && !isOmrMode())) { console.log("[addClick] blocked: no info or no measureEl"); return; }
if (!info.measureNum) { console.log("[addClick] blocked: no measureNum"); return; }
console.log("[addClick] inserting note, omrMode=", isOmrMode(), "measure=", info.measureNum, "step=", info.step, info.octave);
if (isOmrMode()) {
insertNoteOmr(info);
} else {
insertNoteAtPosition(info);
}
});
document.addEventListener("click", (e) => {
if (!chordPopup.contains(e.target)) hideChordPopup();
});
document.getElementById("btn-undo").addEventListener("click", undo);
document.getElementById("btn-redo").addEventListener("click", redo);
document.getElementById("btn-up").addEventListener("click", () => _multiEdit(raiseNote));
document.getElementById("btn-down").addEventListener("click", () => _multiEdit(lowerNote));
document.getElementById("btn-dblsharp").addEventListener("click", () => _multiEdit((sk) => setAccidental(2, sk)));
document.getElementById("btn-sharp").addEventListener("click", () => _multiEdit((sk) => setAccidental(1, sk)));
document.getElementById("btn-flat").addEventListener("click", () => _multiEdit((sk) => setAccidental(-1, sk)));
document.getElementById("btn-dblflat").addEventListener("click", () => _multiEdit((sk) => setAccidental(-2, sk)));
document.getElementById("btn-natural").addEventListener("click", () => _multiEdit((sk) => setAccidental(0, sk)));
document.getElementById("btn-delete").addEventListener("click", () => _multiDelete());
document.getElementById("btn-dur-whole").addEventListener("click", () => changeDuration("whole"));
document.getElementById("btn-dur-half").addEventListener("click", () => changeDuration("half"));
document.getElementById("btn-dur-quarter").addEventListener("click", () => changeDuration("quarter"));
document.getElementById("btn-dur-eighth").addEventListener("click", () => changeDuration("eighth"));
document.getElementById("btn-dur-16th").addEventListener("click", () => changeDuration("16th"));
document.getElementById("btn-dur-32nd").addEventListener("click", () => changeDuration("32nd"));
document.getElementById("btn-dur-dot").addEventListener("click", toggleDot);
document.getElementById("btn-triplet").addEventListener("click", () => applyTuplet("triplet"));
document.getElementById("btn-untriplet").addEventListener("click", () => applyTuplet("untriplet"));
document.getElementById("btn-auto-align").addEventListener("click", autoAlignMeasure);
document.getElementById("btn-tl-auto-align").addEventListener("click", autoAlignMeasure);
document.getElementById("btn-rest-toggle").addEventListener("click", toggleNoteRest);
document.getElementById("btn-prev").addEventListener("click", () => navigateNote(-1));
document.getElementById("btn-next").addEventListener("click", () => navigateNote(1));
document.getElementById("btn-download").addEventListener("click", downloadModifiedXml);
document.getElementById("btn-download-mml").addEventListener("click", downloadMml);
document.getElementById("btn-debug-lines").addEventListener("click", toggleDebugLines);
document.getElementById("btn-lang").addEventListener("click", toggleLang);
applyI18n(); // apply initial language
zoomSlider.addEventListener("input", (e) => applyZoom(parseInt(e.target.value)));
applyZoom(parseInt(zoomSlider.value)); // apply initial zoom
dpiInput.addEventListener("change", () => {
if (!layout) return;
const dpi = parseInt(dpiInput.value) || 300;
pixelsPerTenth = computePixelsPerTenthFromDpi(dpi, layout.mm, layout.tpu);
const ux = parseFloat(offsetX.value || 0);
const uy = parseFloat(offsetY.value || 0);
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
if (selectedIdx >= 0) selectNote(selectedIdx);
});
offsetX.addEventListener("change", recomputeAll);
offsetY.addEventListener("change", recomputeAll);
document.getElementById("staff-dist-input").addEventListener("change", recomputeAll);
document.getElementById("sys-dist-adj").addEventListener("change", recomputeAll);
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT") return;
// Barline mode intercepts all keys (handled by its own keydown listener)
if (barlineMode) return;
// Ctrl+Z / Ctrl+Y for undo/redo
if (e.ctrlKey || e.metaKey) {
if (e.key === "z" || e.key === "Z") { e.preventDefault(); undo(); return; }
if (e.key === "y" || e.key === "Y") { e.preventDefault(); redo(); return; }
}
switch (e.key) {
case "ArrowUp": e.preventDefault(); _multiEdit(raiseNote); break;
case "ArrowDown": e.preventDefault(); _multiEdit(lowerNote); break;
case "Tab": e.preventDefault(); navigateNote(e.shiftKey ? -1 : 1); break;
case "Escape": selectNote(-1); scoreSelectedIndices.clear(); _scoreUpdateSelectionVisuals(); hideChordPopup(); break;
case " ": e.preventDefault(); togglePlayback(); break;
case "#": _multiEdit((sk) => setAccidental(1, sk)); break;
case "b": _multiEdit((sk) => setAccidental(-1, sk)); break;
case "n": _multiEdit((sk) => setAccidental(0, sk)); break;
case "N": e.preventDefault(); toggleAddMode(); break;
case "A": e.preventDefault(); autoAlignMeasure(); break;
case "B":
// Enter barline mode (only if not in other modes)
if (!staffAdjustMode && !addMode && !barlineMode) {
e.preventDefault();
enterBarlineMode();
}
break;
case "r": e.preventDefault(); toggleNoteRest(); break;
case "t": e.preventDefault(); toggleTimelinePanel(); break;
case "T": e.preventDefault(); editTimeSignature(); break;
case "K": e.preventDefault(); editKeySignature(); break;
case "C": e.preventDefault(); editClef(); break;
case "A": e.preventDefault(); addChordNote(); break;
case "Delete": e.preventDefault(); _multiDelete(); break;
case "PageUp": e.preventDefault(); prevPage(); break;
case "PageDown": e.preventDefault(); nextPage(); break;
case ".": e.preventDefault(); toggleDot(); break;
case "1": case "2": case "4": case "5": case "6": case "7":
if (KEY_TO_TYPE[e.key]) {
e.preventDefault();
if (addMode) {
addDurationType = KEY_TO_TYPE[e.key];
const statusEl = document.getElementById("status-mode");
if (statusEl) statusEl.textContent = `[ADD ${DUR_SYMBOLS[addDurationType] || addDurationType}]`;
} else {
changeDuration(KEY_TO_TYPE[e.key]);
}
}
break;
}
});
// ================================================================
// Section 8: Playback Engine
// ================================================================
let audioCtx = null;
let playbackTimeline = [];
let playbackTimer = null;
let playbackStartTime = 0;
let playbackEventIdx = 0;
let isPlaying = false;
let playbackStartOffset = 0; // seconds offset for seek
let cursorSeekTime = 0; // time in seconds where the red cursor is parked
function _previewNote(n) {
if (!n || n.isRest) return;
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const freq = noteToFreq(n.step, n.octave, n.alter);
playNoteSound(freq, 0.3, 0, n.step, n.octave, n.alter);
}
let _dragPreviewGain = null;
let _dragPreviewSrc = null;
function _previewNoteDrag(n) {
if (!n || n.isRest) return;
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (_dragPreviewGain) { try { _dragPreviewGain.gain.setValueAtTime(0, audioCtx.currentTime); } catch(e){} }
if (_dragPreviewSrc) { try { _dragPreviewSrc.stop(); } catch(e){} }
_dragPreviewGain = null; _dragPreviewSrc = null;
const freq = noteToFreq(n.step, n.octave, n.alter);
const t = audioCtx.currentTime;
const dur = 0.25;
const name = sfName(n.step, n.octave, n.alter || 0);
const buf = name ? pianoCache[name] : null;
const gain = audioCtx.createGain();
gain.connect(audioCtx.destination);
gain.gain.setValueAtTime(0.001, t);
gain.gain.linearRampToValueAtTime(masterVolume * 0.7, t + 0.005);
gain.gain.setValueAtTime(masterVolume * 0.6, t + dur * 0.6);
gain.gain.exponentialRampToValueAtTime(0.001, t + dur);
_dragPreviewGain = gain;
if (buf) {
const src = audioCtx.createBufferSource();
src.buffer = buf;
src.connect(gain);
src.start(t);
src.stop(t + dur + 0.05);
_dragPreviewSrc = src;
} else {
const osc = audioCtx.createOscillator();
osc.type = "sine";
osc.frequency.value = freq;
osc.connect(gain);
osc.start(t);
osc.stop(t + dur + 0.05);
_dragPreviewSrc = osc;
}
}
function noteToFreq(step, octave, alter) {
const midi = (octave + 1) * 12 + [0,2,4,5,7,9,11][STEP_INDEX[step]] + alter;
return 440 * Math.pow(2, (midi - 69) / 12);
}
// ── Piano Sample System ─────────────────────────────────────
// Loads real piano samples from MIDI.js soundfonts (CDN).
// Falls back to FM synth if samples unavailable.
const PIANO_CDN = "https://gleitz.github.io/midi-js-soundfonts/FatBoy/acoustic_grand_piano-mp3";
const pianoCache = {}; // "C4" → AudioBuffer
const pianoLoading = {}; // "C4" → Promise
// Convert corrector (step, octave, alter) → soundfont note name
// Soundfont uses flats: Db, Eb, Gb, Ab, Bb
// Handles double sharp (alter=2) and double flat (alter=-2)
function sfName(step, octave, alter) {
const a = Math.round(alter);
if (a === 0) return step + octave;
// Normalize: convert step+alter to MIDI semitone, then back to nearest natural/flat name
const SEMI = { C:0, D:2, E:4, F:5, G:7, A:9, B:11 };
const semi = (SEMI[step] + a + 12) % 12;
const octShift = Math.floor((SEMI[step] + a) / 12);
const resultOct = octave + octShift;
// Map semitone back to soundfont name (prefer flats for black keys)
const SF_MAP = ["C","Db","D","Eb","E","F","Gb","G","Ab","A","Bb","B"];
return SF_MAP[semi] + resultOct;
}
async function loadPianoNote(ctx, name) {
if (pianoCache[name]) return pianoCache[name];
if (pianoLoading[name]) return pianoLoading[name];
pianoLoading[name] = (async () => {
try {
const resp = await fetch(`${PIANO_CDN}/${name}.mp3`);
if (!resp.ok) return null;
const buf = await resp.arrayBuffer();
const audioBuf = await ctx.decodeAudioData(buf);
pianoCache[name] = audioBuf;
return audioBuf;
} catch(e) { console.warn(`Piano load failed: ${name}`, e); return null; }
})();
return pianoLoading[name];
}
// Preload all unique notes in current noteInfos
async function preloadPianoSamples(ctx) {
const names = new Set();
for (const n of noteInfos) {
if (n.isRest) continue;
names.add(sfName(n.step, n.octave, n.alter));
}
const unloaded = [...names].filter(nm => !pianoCache[nm]);
if (unloaded.length === 0) return;
loadStatus.textContent = t("loading_piano")(unloaded.length);
await Promise.all(unloaded.map(nm => loadPianoNote(ctx, nm)));
const loaded = [...names].filter(nm => pianoCache[nm]);
const failed = [...names].filter(nm => !pianoCache[nm]);
console.log(`Piano preload: ${loaded.length} loaded, ${failed.length} failed`, failed.length > 0 ? failed : "");
loadStatus.textContent = currentLang === "ko" ? `피아노 로딩 완료 (${loaded.length}/${names.size})` : `Piano loaded (${loaded.length}/${names.size} notes)`;
}
function playNoteSound(freq, duration, startOffset, step, octave, alter) {
if (!audioCtx) return;
const t = audioCtx.currentTime + startOffset;
const vol = masterVolume;
// Try piano sample
const name = (step && octave != null) ? sfName(step, octave, alter || 0) : null;
const buf = name ? pianoCache[name] : null;
console.log(`playNote: ${step}${alter>0?'#':alter<0?'b':''}${octave} → sf="${name}" piano=${!!buf} freq=${freq.toFixed(1)}`);
if (buf) {
// Play real piano sample with smooth envelope
const src = audioCtx.createBufferSource();
src.buffer = buf;
const gain = audioCtx.createGain();
src.connect(gain);
gain.connect(audioCtx.destination);
// Soft attack (avoid click)
gain.gain.setValueAtTime(0.001, t);
gain.gain.linearRampToValueAtTime(vol, t + 0.008);
// Sustain then smooth release
const releaseDur = Math.min(0.08, duration * 0.3);
const sustainEnd = t + duration - releaseDur;
gain.gain.setValueAtTime(vol, sustainEnd);
gain.gain.exponentialRampToValueAtTime(0.001, t + duration);
src.start(t);
src.stop(t + duration + 0.1);
} else {
// Fallback: sine with smooth ADSR
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = "sine";
osc.frequency.value = freq;
osc.connect(gain);
gain.connect(audioCtx.destination);
// Attack
gain.gain.setValueAtTime(0.001, t);
gain.gain.linearRampToValueAtTime(vol * 0.24, t + 0.015);
// Release
const releaseDur = Math.min(0.06, duration * 0.3);
const sustainEnd = t + duration - releaseDur;
gain.gain.setValueAtTime(vol * 0.24, sustainEnd);
gain.gain.exponentialRampToValueAtTime(0.001, t + duration);
osc.start(t);
osc.stop(t + duration + 0.1);
}
}
// ═══════════════════════════════════════════════════════════════════
// TIMELINE PANEL (Piano Roll TimeOffset Editor) — right side panel
// ═══════════════════════════════════════════════════════════════════
let timelinePanelVisible = true;
let timelinePanelMeasure = null; // { measureNum, systemIdx }
let _tlDragCleanup = null; // cleanup function for drag listeners
/** Scroll the score so the marker for noteIdx is visible */
function scrollMarkerIntoView(idx) {
if (idx < 0 || idx >= noteInfos.length) return;
const circle = markerSvg.querySelector(`circle[data-idx="${idx}"]`);
if (!circle) return;
const wrapper = document.getElementById("canvas-wrapper");
const cx = parseFloat(circle.getAttribute("cx")) * currentZoom;
const cy = parseFloat(circle.getAttribute("cy")) * currentZoom;
const wRect = wrapper.getBoundingClientRect();
const margin = 80;
// Horizontal scroll
if (cx < wrapper.scrollLeft + margin || cx > wrapper.scrollLeft + wRect.width - margin) {
wrapper.scrollLeft = cx - wRect.width / 2;
}
// Vertical scroll
if (cy < wrapper.scrollTop + margin || cy > wrapper.scrollTop + wRect.height - margin) {
wrapper.scrollTop = cy - wRect.height / 2;
}
}
function toggleTimelinePanel() {
const panel = document.getElementById("timeline-panel");
timelinePanelVisible = !timelinePanelVisible;
panel.style.display = timelinePanelVisible ? "flex" : "none";
if (timelinePanelVisible && selectedIdx >= 0) {
renderTimelinePanel(noteInfos[selectedIdx].measureNum, noteInfos[selectedIdx].systemIdx);
} else if (!timelinePanelVisible) {
// Remove measure highlight when TL is hidden
document.querySelectorAll(".measure-highlight").forEach(el => el.remove());
}
}
/** Get sorted list of all measure numbers in current page */
function _tlGetMeasureList() {
const seen = new Set();
noteInfos.forEach(n => seen.add(String(n.measureNum)));
return [...seen].sort((a, b) => parseInt(a) - parseInt(b));
}
function _tlNavigateMeasure(delta) {
if (!timelinePanelMeasure) return;
const list = _tlGetMeasureList();
const curIdx = list.indexOf(String(timelinePanelMeasure.measureNum));
const newIdx = curIdx + delta;
if (newIdx < 0 || newIdx >= list.length) return;
const newMeasNum = list[newIdx];
// Find a note in this measure to get systemIdx
const sample = noteInfos.find(n => String(n.measureNum) === newMeasNum);
if (sample) {
const sampleIdx = noteInfos.indexOf(sample);
renderTimelinePanel(newMeasNum, sample.systemIdx);
selectNote(sampleIdx);
scrollMarkerIntoView(sampleIdx);
}
}
function _tlGetMeasureDuration(measureNum, systemIdx) {
// Always use systemsData — it has .number field and is kept in sync for both modes
for (const sys of systemsData) {
const m = sys.measures.find(m => String(m.number) === String(measureNum));
if (m && m.duration) return parseRational(m.duration);
}
return 1;
}
function _editMeasureDuration(measureNum, systemIdx) {
if (!isOmrMode()) return;
const pg = pages[currentPageIdx];
if (!pg || !pg.omrData) return;
const curDur = _tlGetMeasureDuration(measureNum, systemIdx);
const curBeats = curDur * 4;
const curRat = durationFloatToRational(curDur);
const input = prompt(
currentLang === "ko"
? `마디 ${measureNum} duration 수정\n현재: ${curRat} (${curBeats} beats)\n\n새 값 입력 (beats 또는 분수):\n 예: 4 = 4beats(4/4), 3 = 3beats(3/4), 3.5 = 7/8\n 분수: 1/1, 3/4, 7/8 등`
: `Edit measure ${measureNum} duration\nCurrent: ${curRat} (${curBeats} beats)\n\nEnter new value (beats or fraction):\n e.g. 4 = 4beats(4/4), 3 = 3beats(3/4)\n Fraction: 1/1, 3/4, 7/8`,
curBeats.toString()
);
if (input === null) return;
let newDur;
if (input.includes("/")) {
newDur = parseRational(input);
} else {
const b = parseFloat(input);
if (isNaN(b) || b <= 0) return;
newDur = b / 4; // beats → whole-note fraction
}
if (!newDur || newDur <= 0 || Math.abs(newDur - curDur) < 0.001) return;
pushUndo();
const newRat = durationFloatToRational(newDur);
// Update omrData stack
const sys = pg.omrData.systems[systemIdx];
if (sys) {
let globalBase = 1;
for (let si = 0; si < systemIdx; si++) {
globalBase += (pg.omrData.systems[si].stacks || []).length || 1;
}
const stackIdx = parseInt(measureNum) - globalBase;
if (sys.stacks && sys.stacks[stackIdx]) {
sys.stacks[stackIdx].duration = newRat;
}
// Update all omrData measures at this stack position (all parts)
const numStacks = (sys.stacks || []).length || 1;
for (let pi = 0; pi * numStacks + stackIdx < sys.measures.length; pi++) {
const mi = pi * numStacks + stackIdx;
if (sys.measures[mi]) sys.measures[mi].duration = newRat;
}
}
// Update systemsData measure
const sysInfo = systemsData[systemIdx];
if (sysInfo) {
const m = sysInfo.measures.find(m => String(m.number) === String(measureNum));
if (m) m.duration = newRat;
}
// Rebuild and re-render
rebuildSystemsAndNotes();
const ux = parseInt(document.getElementById("offset-x").value) || 0;
const uy = parseInt(document.getElementById("offset-y").value) || 0;
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
}
function _updateMeasureHighlight(measureNum, systemIdx) {
const svg = document.getElementById("marker-svg");
if (!svg) return;
// Remove old highlight
svg.querySelectorAll(".measure-highlight").forEach(el => el.remove());
if (!systemsData[systemIdx]) return;
const sys = systemsData[systemIdx];
const meas = sys.measures.find(m => String(m.number) === String(measureNum));
if (!meas) return;
const ux = parseInt(document.getElementById("offset-x").value) || 0;
const uy = parseInt(document.getElementById("offset-y").value) || 0;
const numStavesPerSys = sys.numStaves || 1;
const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys);
const sysStaves = staffSystems[systemIdx];
if (!sysStaves || sysStaves.length === 0) return;
const x = (meas.left || 0) + ux;
const w = meas.width || ((meas.right || 0) - (meas.left || 0));
const yTop = sysStaves[0].topLineY + uy - 10;
const yBot = sysStaves[sysStaves.length - 1].bottomLineY + uy + 10;
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.classList.add("measure-highlight");
rect.setAttribute("x", x);
rect.setAttribute("y", yTop);
rect.setAttribute("width", Math.max(w, 10));
rect.setAttribute("height", yBot - yTop);
rect.setAttribute("rx", "4");
// Insert before markers so it's behind them
const firstMarker = svg.querySelector("circle.marker");
if (firstMarker) svg.insertBefore(rect, firstMarker);
else svg.appendChild(rect);
}
function renderTimelinePanel(measureNum, systemIdx) {
const body = document.getElementById("timeline-body");
const title = document.getElementById("timeline-title");
if (!body) return;
timelinePanelMeasure = { measureNum, systemIdx };
_updateMeasureHighlight(measureNum, systemIdx);
// Collect notes for this measure
const measureNotes = [];
noteInfos.forEach((n, idx) => {
if (String(n.measureNum) === String(measureNum) && n.systemIdx === systemIdx) {
measureNotes.push({ n, idx });
}
});
const measDuration = _tlGetMeasureDuration(measureNum, systemIdx);
const staves = [...new Set(measureNotes.map(e => e.n.staff))].sort((a, b) => a - b);
const gridSize = parseFloat(document.getElementById("timeline-grid-select").value) || 0.125;
// Measure nav info
const measList = _tlGetMeasureList();
const curMeasIdx = measList.indexOf(String(measureNum));
const hasPrev = curMeasIdx > 0;
const hasNext = curMeasIdx < measList.length - 1;
// Track area: label is 56px (CSS), rest is track
const LABEL_W = 80;
const PAD_R = 8;
const bodyW = body.clientWidth || 320;
const trackW = bodyW - LABEL_W - PAD_R;
// onset → px helper
const onsetToPx = (onset) => LABEL_W + (onset / measDuration) * trackW;
let html = '';
// Navigation bar
html += '<div class="tl-nav">';
html += `<button id="tl-prev" ${hasPrev ? '' : 'disabled'} style="${hasPrev ? '' : 'opacity:0.3;cursor:default'}">&laquo;</button>`;
html += `<span style="color:#e94560;font-weight:bold;font-size:14px;line-height:24px">M${measureNum}</span>`;
html += `<button id="tl-next" ${hasNext ? '' : 'disabled'} style="${hasNext ? '' : 'opacity:0.3;cursor:default'}">&raquo;</button>`;
html += `<span style="color:#666;font-size:10px;margin-left:auto;line-height:24px">${durationFloatToRational(measDuration)} (${(measDuration * 4).toFixed(0)} beats)</span>`;
html += '</div>';
// Container for lanes + sync lines
html += '<div id="tl-container" style="position:relative">';
const onsetMap = {}; // onset → [{laneIdx, noteIdx}]
const laneTopOffsets = []; // track cumulative top for sync lines
staves.forEach((staffNum, si) => {
const staffNotes = measureNotes.filter(e => e.n.staff === staffNum);
const clefSign = staffNotes.length > 0 ? (staffNotes[0].n.clef?.sign || "G") : "G";
const ROW_H = 104;
// Pass 1: compute positions + collision detection for sub-row stacking
// Ignore voice — use only time overlap for row assignment
const notePlacements = []; // { n, idx, x, w, row }
// Sort all staff notes by onset
const sortedStaffNotes = [...staffNotes].sort((a, b) => (a.n.onsetDiv || 0) - (b.n.onsetDiv || 0));
const subRowEnds = []; // subRowEnds[i] = max logical end of notes in sub-row i
let totalRows = 0;
sortedStaffNotes.forEach(({ n, idx }) => {
const onset = n.onsetDiv || 0;
const dur = n.durationDiv || 0;
const x = onsetToPx(onset);
const w = Math.max(18, (dur / measDuration) * trackW);
const logicalEnd = onsetToPx(onset + dur);
// Find first sub-row where this note doesn't overlap
let subRow = 0;
while (subRow < subRowEnds.length && subRowEnds[subRow] > x + 2) {
subRow++;
}
if (subRow >= subRowEnds.length) subRowEnds.push(logicalEnd);
else subRowEnds[subRow] = logicalEnd;
if (subRow > totalRows) totalRows = subRow;
notePlacements.push({ n, idx, x, w, row: subRow });
});
totalRows += 1;
const laneH = Math.max(1, totalRows) * ROW_H + 8;
html += `<div class="tl-lane" data-staff="${staffNum}" style="height:${laneH}px">`;
html += `<div class="tl-lane-label"><span class="staff-id">S${staffNum}</span>${clefSign === "F" ? "Bass" : "Treble"}</div>`;
// Grid lines
for (let g = 0; g <= measDuration + 0.0001; g += gridSize) {
const x = onsetToPx(g);
const isBeat = Math.abs(g % 0.25) < 0.001;
const isBar = Math.abs(g) < 0.0001;
const cls = isBar ? 'tl-grid-line bar-start' : (isBeat ? 'tl-grid-line beat' : 'tl-grid-line');
html += `<div class="${cls}" style="left:${x}px"></div>`;
if (isBeat && si === staves.length - 1) {
const label = g < 0.001 ? '0' : durationFloatToRational(g);
html += `<div class="tl-grid-label" style="left:${x}px">${label}</div>`;
}
}
// Note blocks (from pre-computed placements)
notePlacements.forEach(({ n, idx, x, w, row }) => {
const top = 4 + row * ROW_H;
let cls = 'tl-note';
if (n.isRest) cls += ' rest';
if (n.orphan) cls += ' orphan';
if (idx === selectedIdx || tlSelectedIndices.has(idx)) cls += ' selected';
cls += ` voice${n.voice || 1}`;
// Check if part of multi-head chord
const chord = findOmrChord(n);
const isMultiChord = chord && chord.heads && chord.heads.length > 1;
if (isMultiChord) cls += ' chord-linked';
const acc = n.alter ? alterStr(n.alter) : '';
const pitchLabel = n.isRest ? 'R' : `${n.step}${acc}${n.octave}`;
const onset = n.onsetDiv || 0;
const tRat = n.timeOffsetRational || durationFloatToRational(onset);
// Build tooltip with chord info
let tooltip = `${pitchLabel} onset=${tRat} dur=${n.durationRational || '?'} voice ${n.voice}`;
if (isMultiChord) {
const others = chord.heads.filter(h => h.headId !== n.omrHeadId).map(h => {
const { step: s, octave: o } = omrPitchToStepOctave(h.pitch,
n.clef || { sign: "G", line: 2, octaveChange: 0 });
return `${s}${o}`;
});
tooltip += `\n🔗 화음: [${others.join(', ')}]과 묶임\nAlt+드래그: 전체 이동`;
}
if (n.tupletGroupId) cls += ' tuplet-grouped';
html += `<div class="${cls}" data-idx="${idx}" data-onset="${onset}" ` +
`data-chord-id="${n.omrChordId || ''}" ` +
`data-tuplet-group="${n.tupletGroupId || ''}" ` +
`data-tooltip="${tooltip.replace(/"/g, '&quot;')}" ` +
`style="left:${x}px;width:${w}px;top:${top}px">` +
`${pitchLabel}</div>`;
const onsetKey = onset.toFixed(6);
if (!onsetMap[onsetKey]) onsetMap[onsetKey] = [];
onsetMap[onsetKey].push({ laneIdx: si, noteIdx: idx });
});
html += '</div>';
});
// Sync lines spanning across staves
Object.entries(onsetMap).forEach(([onsetKey, entries]) => {
const uniqueLanes = new Set(entries.map(e => e.laneIdx));
if (uniqueLanes.size > 1) {
const onset = parseFloat(onsetKey);
const x = onsetToPx(onset);
html += `<div class="tl-sync-line" style="left:${x}px;top:0;height:100%"></div>`;
}
});
html += '</div>'; // tl-container
// Measure title update — clickable to edit duration
const beats = measDuration * 4;
const beatDisplay = Number.isInteger(beats) ? beats : beats.toFixed(1);
title.textContent = `M${measureNum} [${durationFloatToRational(measDuration)} = ${beatDisplay} beats]`;
title.style.cursor = "pointer";
title.title = currentLang === "ko" ? "클릭하여 마디 duration 수정" : "Click to edit measure duration";
title.onclick = () => _editMeasureDuration(measureNum, systemIdx);
body.innerHTML = html;
// Draw tuplet group brackets
const tupletGroups = {};
body.querySelectorAll(".tl-note[data-tuplet-group]").forEach(el => {
const gid = el.dataset.tupletGroup;
if (!gid) return;
if (!tupletGroups[gid]) tupletGroups[gid] = [];
tupletGroups[gid].push(el);
});
const container = document.getElementById("tl-container");
if (container) {
Object.entries(tupletGroups).forEach(([gid, els]) => {
if (els.length < 2) return;
const firstLeft = parseFloat(els[0].style.left);
const lastEl = els[els.length - 1];
const lastLeft = parseFloat(lastEl.style.left) + lastEl.offsetWidth;
const top = parseFloat(els[0].style.top) - 14;
const bracket = document.createElement("div");
bracket.className = "tl-tuplet-bracket";
bracket.style.left = firstLeft + "px";
bracket.style.width = (lastLeft - firstLeft) + "px";
bracket.style.top = top + "px";
bracket.textContent = "3";
bracket.dataset.tupletGroup = gid;
container.appendChild(bracket);
});
}
// Wire up nav buttons
const prevBtn = document.getElementById("tl-prev");
const nextBtn = document.getElementById("tl-next");
if (prevBtn) prevBtn.addEventListener("click", () => _tlNavigateMeasure(-1));
if (nextBtn) nextBtn.addEventListener("click", () => _tlNavigateMeasure(1));
// Setup drag
setupTimelineDrag(measDuration, trackW, LABEL_W, gridSize);
}
// ── Timeline Multi-Select State ──
let tlSelectedIndices = new Set(); // multi-select in timeline
function tlSelectOnly(idx) {
tlSelectedIndices.clear();
tlSelectedIndices.add(idx);
_tlUpdateSelectionVisuals();
}
function tlToggleSelect(idx) {
if (tlSelectedIndices.has(idx)) tlSelectedIndices.delete(idx);
else tlSelectedIndices.add(idx);
_tlUpdateSelectionVisuals();
}
function tlSelectRange(indices) {
tlSelectedIndices = new Set(indices);
_tlUpdateSelectionVisuals();
}
function _tlUpdateSelectionVisuals() {
const body = document.getElementById("timeline-body");
if (!body) return;
body.querySelectorAll(".tl-note").forEach(el => {
const idx = parseInt(el.dataset.idx);
if (tlSelectedIndices.has(idx)) el.classList.add("selected");
else el.classList.remove("selected");
});
// Sync to score markers
scoreSelectedIndices = new Set(tlSelectedIndices);
markerSvg.querySelectorAll("circle.marker").forEach(c => {
const i = parseInt(c.dataset.idx);
if (scoreSelectedIndices.has(i)) c.classList.add("multi-selected");
else c.classList.remove("multi-selected");
});
}
function setupTimelineDrag(measDuration, trackWidth, laneLeft, gridSize) {
const body = document.getElementById("timeline-body");
if (!body) return;
// Cleanup previous drag listeners
if (_tlDragCleanup) { _tlDragCleanup(); _tlDragCleanup = null; }
let dragMode = null; // "move" | "rubber"
let dragStartX = 0;
let dragStartY = 0;
// For "move" mode: info about the group being dragged
let dragOrigData = []; // [{el, origLeft, origOnset, idx}]
// For "rubber" band selection
let rubberEl = null;
function onMouseDown(e) {
const el = e.target.closest(".tl-note");
if (el) {
// Click on a note
const noteIdx = parseInt(el.dataset.idx);
if (e.ctrlKey || e.metaKey) {
// Ctrl+click: toggle in multi-selection (no drag)
tlToggleSelect(noteIdx);
selectNote(noteIdx);
scrollMarkerIntoView(noteIdx);
e.preventDefault();
return;
} else {
// Normal click
if (!tlSelectedIndices.has(noteIdx)) {
// Not in current selection: select only this
tlSelectOnly(noteIdx);
}
// else: keep multi-selection (for drag)
selectNote(noteIdx);
scrollMarkerIntoView(noteIdx);
}
// orphan notes are now draggable like normal notes
// Start "move" mode for all selected notes + their tuplet groups
dragMode = "move";
dragStartX = e.clientX;
dragOrigData = [];
const expandedIndices = new Set(tlSelectedIndices);
for (const si of tlSelectedIndices) {
const group = getTupletGroup(si);
if (group) group.forEach(gi => expandedIndices.add(gi));
}
body.querySelectorAll(".tl-note").forEach(noteEl => {
const idx = parseInt(noteEl.dataset.idx);
if (expandedIndices.has(idx)) {
dragOrigData.push({
el: noteEl,
origLeft: parseFloat(noteEl.style.left),
origOnset: parseFloat(noteEl.dataset.onset),
idx
});
noteEl.classList.add("dragging");
}
});
e.preventDefault();
} else {
// Click on empty space: start rubber-band selection
if (!e.ctrlKey && !e.metaKey) {
tlSelectedIndices.clear();
_tlUpdateSelectionVisuals();
}
dragMode = "rubber";
dragStartX = e.clientX;
dragStartY = e.clientY;
// Create rubber-band element
const bodyRect = body.getBoundingClientRect();
rubberEl = document.createElement("div");
rubberEl.className = "tl-rubber";
rubberEl.style.position = "absolute";
rubberEl.style.left = (e.clientX - bodyRect.left + body.scrollLeft) + "px";
rubberEl.style.top = (e.clientY - bodyRect.top + body.scrollTop) + "px";
rubberEl.style.width = "0px";
rubberEl.style.height = "0px";
body.style.position = "relative";
body.appendChild(rubberEl);
e.preventDefault();
}
}
function onMouseMove(e) {
if (dragMode === "move") {
const dx = e.clientX - dragStartX;
const snapEnabled = document.getElementById("timeline-snap")?.checked;
dragOrigData.forEach(d => {
let newLeft = d.origLeft + dx;
newLeft = Math.max(laneLeft, Math.min(newLeft, laneLeft + trackWidth - 16));
let newOnset = ((newLeft - laneLeft) / trackWidth) * measDuration;
if (snapEnabled) {
newOnset = Math.round(newOnset / gridSize) * gridSize;
newLeft = laneLeft + (newOnset / measDuration) * trackWidth;
}
newOnset = Math.max(0, newOnset);
d.el.style.left = newLeft + "px";
d.el.dataset.onset = newOnset;
});
} else if (dragMode === "rubber" && rubberEl) {
const bodyRect = body.getBoundingClientRect();
const curX = e.clientX - bodyRect.left + body.scrollLeft;
const curY = e.clientY - bodyRect.top + body.scrollTop;
const startX = dragStartX - bodyRect.left + body.scrollLeft;
const startY = dragStartY - bodyRect.top + body.scrollTop;
const rx = Math.min(startX, curX);
const ry = Math.min(startY, curY);
const rw = Math.abs(curX - startX);
const rh = Math.abs(curY - startY);
rubberEl.style.left = rx + "px";
rubberEl.style.top = ry + "px";
rubberEl.style.width = rw + "px";
rubberEl.style.height = rh + "px";
// Highlight notes within rubber-band
const selected = new Set(e.ctrlKey || e.metaKey ? tlSelectedIndices : []);
body.querySelectorAll(".tl-note").forEach(noteEl => {
const nr = noteEl.getBoundingClientRect();
const nx = nr.left - bodyRect.left + body.scrollLeft;
const ny = nr.top - bodyRect.top + body.scrollTop;
const nw = nr.width;
const nh = nr.height;
const overlap = !(nx + nw < rx || nx > rx + rw || ny + nh < ry || ny > ry + rh);
const idx = parseInt(noteEl.dataset.idx);
if (overlap) selected.add(idx);
if (selected.has(idx)) noteEl.classList.add("selected");
else noteEl.classList.remove("selected");
});
tlSelectedIndices = selected;
}
}
function onMouseUp(e) {
try {
if (dragMode === "move") {
// Apply all moved notes
let anyMoved = false;
dragOrigData.forEach(d => {
d.el.classList.remove("dragging");
const newOnset = parseFloat(d.el.dataset.onset);
if (Math.abs(newOnset - d.origOnset) > 0.001) {
anyMoved = true;
}
});
if (anyMoved) {
applyMultiTimeOffsetChange(dragOrigData.map(d => ({
idx: d.idx, newOnset: parseFloat(d.el.dataset.onset)
})), measDuration, e.altKey);
}
dragOrigData = [];
} else if (dragMode === "rubber") {
if (rubberEl) { rubberEl.remove(); rubberEl = null; }
// Final selection already set during mousemove
// Select the first note in selection for main marker
if (tlSelectedIndices.size > 0) {
const first = [...tlSelectedIndices].sort((a, b) => a - b)[0];
selectNote(first);
scrollMarkerIntoView(first);
}
}
} finally {
dragMode = null;
}
}
// Chord hover highlight
body.addEventListener("mouseenter", (e) => {
const el = e.target.closest(".tl-note.chord-linked");
if (!el) return;
const chordId = el.dataset.chordId;
if (!chordId) return;
body.querySelectorAll(`.tl-note[data-chord-id="${chordId}"]`).forEach(
n => n.classList.add("chord-hover")
);
}, true);
body.addEventListener("mouseleave", (e) => {
const el = e.target.closest(".tl-note.chord-linked");
if (!el) return;
body.querySelectorAll(".tl-note.chord-hover").forEach(
n => n.classList.remove("chord-hover")
);
}, true);
// Instant tooltip on TL note hover
let tlTooltipEl = null;
body.addEventListener("mouseenter", (e) => {
const el = e.target.closest(".tl-note");
if (!el) return;
const tip = el.dataset.tooltip;
if (!tip) return;
if (!tlTooltipEl) {
tlTooltipEl = document.createElement("div");
tlTooltipEl.id = "tl-tooltip";
document.body.appendChild(tlTooltipEl);
}
tlTooltipEl.textContent = tip;
tlTooltipEl.style.display = "block";
const rect = el.getBoundingClientRect();
const tipW = tlTooltipEl.offsetWidth;
const tipH = tlTooltipEl.offsetHeight;
let left = rect.left;
let top = rect.top - tipH - 4;
if (left + tipW > window.innerWidth) left = window.innerWidth - tipW - 4;
if (left < 0) left = 4;
if (top < 0) top = rect.bottom + 4;
tlTooltipEl.style.left = left + "px";
tlTooltipEl.style.top = top + "px";
}, true);
body.addEventListener("mouseleave", (e) => {
const el = e.target.closest(".tl-note");
if (!el) return;
if (tlTooltipEl) tlTooltipEl.style.display = "none";
}, true);
body.addEventListener("mousedown", onMouseDown);
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
_tlDragCleanup = () => {
body.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
}
function _checkMeasureOverflow(noteIdx, measDuration) {
const n = noteInfos[noteIdx];
const onset = n.onsetDiv || 0;
const dur = n.durationDiv || 0;
const end = onset + dur;
if (end > measDuration + 0.001) {
const needed = durationFloatToRational(end);
const current = durationFloatToRational(measDuration);
const msg = currentLang === "ko"
? `이 노트(onset ${durationFloatToRational(onset)} + dur ${n.durationRational || '?'})가 마디 범위(${current})를 초과합니다.\n\n마디 duration을 ${needed}(으)로 늘릴까요?`
: `Note (onset ${durationFloatToRational(onset)} + dur ${n.durationRational || '?'}) exceeds measure duration (${current}).\n\nExtend measure to ${needed}?`;
if (confirm(msg)) {
// Update stack duration in omrData
const pg = pages[currentPageIdx];
if (pg && pg.omrData) {
const sys = pg.omrData.systems[n.systemIdx];
if (sys) {
let globalBase = 1;
for (let si = 0; si < n.systemIdx; si++) {
globalBase += (pg.omrData.systems[si].stacks || []).length || 1;
}
const stackIdx = parseInt(n.measureNum) - globalBase;
if (sys.stacks && sys.stacks[stackIdx]) {
sys.stacks[stackIdx].duration = needed;
}
// Also update omrData measure duration
const numStacks = (sys.stacks || []).length || 1;
const mi = (n.partIndex || 0) * numStacks + stackIdx;
if (sys.measures[mi]) {
sys.measures[mi].duration = needed;
}
}
}
// Update systemsData measure
const sysInfo = systemsData[n.systemIdx];
if (sysInfo) {
const m = sysInfo.measures.find(m => String(m.number) === String(n.measureNum));
if (m) m.duration = needed;
}
}
}
}
function autoAlignMeasure() {
if (!timelinePanelMeasure) return;
const { measureNum, systemIdx } = timelinePanelMeasure;
const measNotes = noteInfos.filter(n =>
String(n.measureNum) === String(measureNum) && n.systemIdx === systemIdx
);
if (measNotes.length === 0) return;
pushUndo();
// Group by staff (ignore voice — Audiveris often mis-assigns voices)
const staffMap = {};
for (const n of measNotes) {
const s = n.staff || 1;
if (!staffMap[s]) staffMap[s] = [];
staffMap[s].push(n);
}
for (const s of Object.keys(staffMap)) {
const sNotes = staffMap[s];
sNotes.sort((a, b) => (a.onsetDiv || 0) - (b.onsetDiv || 0));
// Detect true polyphony: voices overlap in time on same staff
const voiceMap = {};
for (const n of sNotes) {
const v = n.voice || 1;
if (!voiceMap[v]) voiceMap[v] = [];
voiceMap[v].push(n);
}
const voiceKeys = Object.keys(voiceMap);
let truePolyphony = false;
if (voiceKeys.length > 1) {
for (let vi = 0; vi < voiceKeys.length && !truePolyphony; vi++) {
for (let vj = vi + 1; vj < voiceKeys.length && !truePolyphony; vj++) {
const notesA = voiceMap[voiceKeys[vi]];
const notesB = voiceMap[voiceKeys[vj]];
for (const a of notesA) {
const aStart = a.onsetDiv || 0;
const aDur = a.durationRational ? parseRational(a.durationRational) : (a.durationDiv || 0);
const aEnd = aStart + aDur;
for (const b of notesB) {
const bStart = b.onsetDiv || 0;
if (bStart >= aStart && bStart < aEnd - 0.001) {
truePolyphony = true; break;
}
}
if (truePolyphony) break;
}
}
}
}
// Only merge voices if NOT true polyphony
// Audiveris voice convention: staff1=1~4, staff2=5~8 — preserve staff base
if (!truePolyphony) {
const baseVoice = Math.min(...sNotes.map(n => n.voice || 1));
for (const n of sNotes) {
if (n.voice !== baseVoice) {
n.voice = baseVoice;
if (n._omrBased && n.omrChordId) {
const chord = findOmrChord(n);
if (chord) chord.voice = baseVoice;
}
}
}
}
// If true polyphony, align each voice independently
const voiceGroups = truePolyphony ? voiceKeys.map(v => voiceMap[v]) : [sNotes];
for (const groupNotes of voiceGroups) {
groupNotes.sort((a, b) => (a.onsetDiv || 0) - (b.onsetDiv || 0));
// Build clusters: same onset = one chord unit
const clusters = [];
let i = 0;
while (i < groupNotes.length) {
const onset = groupNotes[i].onsetDiv || 0;
const cluster = [groupNotes[i]];
while (i + 1 < groupNotes.length && Math.abs((groupNotes[i + 1].onsetDiv || 0) - onset) < 0.001) {
i++;
cluster.push(groupNotes[i]);
}
if (cluster[0].tupletGroupId) {
const gid = cluster[0].tupletGroupId;
while (i + 1 < groupNotes.length && groupNotes[i + 1].tupletGroupId === gid) {
i++;
cluster.push(groupNotes[i]);
}
}
const dur = Math.max(...cluster.map(n => {
if (n.durationRational) return parseRational(n.durationRational);
return n.durationDiv || 0;
}));
clusters.push({ notes: cluster, onset, dur });
i++;
}
// Compact: remove gaps
let expectedOnset = 0;
for (const cl of clusters) {
if (cl.onset > expectedOnset + 0.0001) {
const shift = cl.onset - expectedOnset;
for (const n of cl.notes) {
const newOnset = (n.onsetDiv || 0) - shift;
n.onsetDiv = newOnset;
if (n.durationRational) {
n.timeOffsetRational = durationFloatToRational(newOnset);
}
if (n._omrBased && n.omrChordId) {
const chord = findOmrChord(n);
if (chord) chord.timeOffset = durationFloatToRational(newOnset);
}
markModified(n);
}
cl.onset = expectedOnset;
}
expectedOnset = cl.onset + cl.dur;
}
}
}
renderTimelinePanel(measureNum, systemIdx);
_validateRhythmOmr();
}
function applyTimeOffsetChange(noteIdx, newOnset, measDuration) {
applyMultiTimeOffsetChange([{ idx: noteIdx, newOnset }], measDuration);
}
function applyMultiTimeOffsetChange(changes, measDuration, chordMode) {
if (!changes || changes.length === 0) return;
if (!isOmrMode()) return;
pushUndo();
for (const { idx, newOnset } of changes) {
if (idx < 0 || idx >= noteInfos.length) continue;
const n = noteInfos[idx];
const chord = findOmrChord(n);
if (!chord) { console.warn("[TL] chord not found for note", idx); continue; }
const newRational = durationFloatToRational(newOnset);
if (chordMode || !chord.heads || chord.heads.length <= 1) {
// Alt+drag (chord mode), rest chord (no heads), or single-head: move whole chord
chord.timeOffset = newRational;
} else {
// Normal drag: individual move — split head into new chord
const head = chord.heads.find(h => h.headId === n.omrHeadId);
if (head) {
chord.heads = chord.heads.filter(h => h.headId !== n.omrHeadId);
const newChordId = n.omrChordId + "_split_" + n.omrHeadId;
const newChord = {
chordId: newChordId,
heads: [head],
duration: chord.duration,
timeOffset: newRational,
voice: chord.voice,
};
const pg = pages[currentPageIdx];
const sys = pg.omrData.systems[n.systemIdx];
const meas = sys.measures[n._omrMeasureIdx];
meas.headChords.push(newChord);
n.omrChordId = newChordId;
}
}
n.onsetDiv = newOnset;
n.timeOffsetRational = newRational;
recordOmrEdit({
type: "change_time_offset",
chordId: n.omrChordId,
newTimeOffset: newRational,
});
}
// Refresh display
rebuildSystemsAndNotes();
const ux = parseInt(document.getElementById("offset-x").value) || 0;
const uy = parseInt(document.getElementById("offset-y").value) || 0;
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
if (selectedIdx >= 0 && selectedIdx < noteInfos.length) selectNote(selectedIdx);
// Check overflow for last changed note
const lastChange = changes[changes.length - 1];
if (lastChange) _checkMeasureOverflow(lastChange.idx, measDuration);
}
// Hook: update timeline panel when note selection changes
const _originalSelectNote = selectNote;
selectNote = function(idx) {
_originalSelectNote(idx);
// Preview sound on note selection (click / keyboard nav)
if (idx >= 0 && idx < noteInfos.length) {
_previewNote(noteInfos[idx]);
}
if (timelinePanelVisible && idx >= 0 && idx < noteInfos.length) {
const n = noteInfos[idx];
if (!timelinePanelMeasure ||
String(timelinePanelMeasure.measureNum) !== String(n.measureNum) ||
timelinePanelMeasure.systemIdx !== n.systemIdx) {
renderTimelinePanel(n.measureNum, n.systemIdx);
} else {
// Respect multi-select: if tlSelectedIndices has items, show them all
if (tlSelectedIndices.size > 1) {
_tlUpdateSelectionVisuals();
} else {
document.querySelectorAll("#timeline-body .tl-note.selected").forEach(el => el.classList.remove("selected"));
const el = document.querySelector(`#timeline-body .tl-note[data-idx="${idx}"]`);
if (el) el.classList.add("selected");
}
}
}
};
// ═══════════════════════════════════════════════════════════════════
async function startPlayback(fromTimeSec) {
if (noteInfos.length === 0) return;
stopPlayback();
const bpm = parseInt(document.getElementById("bpm-input").value) || 120;
playbackTimeline = buildTimeline(noteInfos, bpm);
if (playbackTimeline.length === 0) return;
playbackStartOffset = fromTimeSec || 0;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// Preload piano samples before starting
await preloadPianoSamples(audioCtx);
isPlaying = true;
playbackStartTime = performance.now();
// Skip to the first event at or after the offset
playbackEventIdx = 0;
if (playbackStartOffset > 0) {
while (playbackEventIdx < playbackTimeline.length &&
playbackTimeline[playbackEventIdx].timeSec < playbackStartOffset) {
playbackEventIdx++;
}
}
document.getElementById("btn-playall").textContent = "\u23F8"; // pause symbol
schedulePlayback();
}
function schedulePlayback() {
if (!isPlaying || playbackEventIdx >= playbackTimeline.length) {
if (isPlaying) updateProgressBar(1); // fill to end before stopping
stopPlayback();
return;
}
const elapsed = (performance.now() - playbackStartTime) / 1000 + playbackStartOffset;
// Update progress bar
const totalDuration = getPlaybackTotalDuration();
if (totalDuration > 0) updateProgressBar(elapsed / totalDuration);
// Schedule events that are due or slightly ahead (100ms lookahead)
while (playbackEventIdx < playbackTimeline.length) {
const evt = playbackTimeline[playbackEventIdx];
if (evt.timeSec > elapsed + 0.1) break;
// Play all notes in this event (slight legato overlap for smooth connection)
const startOffset = Math.max(0, evt.timeSec - elapsed);
const legato = Math.min(evt.durationSec * 1.15, evt.durationSec + 0.06);
evt.noteIndices.forEach(ni => {
const n = noteInfos[ni];
const freq = noteToFreq(n.step, n.octave, n.alter);
playNoteSound(freq, Math.min(legato, 2), startOffset, n.step, n.octave, n.alter);
});
// Schedule visual highlight
const highlightDelay = Math.max(0, (evt.timeSec - elapsed) * 1000);
const indices = evt.noteIndices;
setTimeout(() => {
if (!isPlaying) return;
highlightPlaybackNotes(indices);
}, highlightDelay);
playbackEventIdx++;
}
// Continue scheduling
playbackTimer = requestAnimationFrame(schedulePlayback);
}
function highlightPlaybackNotes(indices) {
// Remove previous playback highlights
markerSvg.querySelectorAll(".playback-highlight").forEach(el => el.classList.remove("playback-highlight"));
indices.forEach(idx => {
const circle = markerSvg.querySelector(`circle[data-idx="${idx}"]`);
if (circle) circle.classList.add("playback-highlight");
});
if (indices.length > 0) {
// Pick note on lowest staff number for cursor position
let cursorIdx = indices[0];
if (indices.length > 1) {
let minStaff = Infinity;
for (const ni of indices) {
const s = noteInfos[ni]?.staff ?? Infinity;
if (s < minStaff) { minStaff = s; cursorIdx = ni; }
}
}
// Use pxDefault (default-x based, always monotonic) for cursor X
const cursorNote = noteInfos[cursorIdx];
const cursorX = cursorNote.pxDefault ?? cursorNote.px;
// Place cursor line at pxDefault position
const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1;
const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys);
const sysStaves = staffSystems[cursorNote.systemIdx];
const uy = parseInt(document.getElementById("offset-y").value) || 0;
let topY, bottomY;
if (sysStaves && sysStaves.length > 0) {
topY = sysStaves[0].topLineY - 20 + uy;
bottomY = sysStaves[sysStaves.length - 1].bottomLineY + 20 + uy;
} else {
topY = cursorNote.py - 80;
bottomY = cursorNote.py + 80;
}
showPlaybackCursor(cursorX, topY, bottomY);
selectNote(cursorIdx);
// Auto-scroll to follow cursor
const wrapper = document.getElementById("canvas-wrapper");
const cx = cursorX * currentZoom;
const wRect = wrapper.getBoundingClientRect();
if (cx < wrapper.scrollLeft + 100 || cx > wrapper.scrollLeft + wRect.width - 100) {
wrapper.scrollTo({ left: cx - wRect.width / 3, behavior: "smooth" });
}
if (sysStaves && sysStaves.length > 0) {
const sysTopZoomed = sysStaves[0].topLineY * currentZoom;
if (sysTopZoomed < wrapper.scrollTop + 50 || sysTopZoomed > wrapper.scrollTop + wRect.height - 100) {
wrapper.scrollTo({ top: sysTopZoomed - 50, behavior: "smooth" });
}
}
}
}
function stopPlayback() {
isPlaying = false;
playbackStartOffset = 0;
if (playbackTimer) {
cancelAnimationFrame(playbackTimer);
playbackTimer = null;
}
if (audioCtx) {
audioCtx.close().catch(() => {});
audioCtx = null;
}
markerSvg.querySelectorAll(".playback-highlight").forEach(el => el.classList.remove("playback-highlight"));
// Don't hide cursor — keep it at last position so user can see where playback stopped
document.getElementById("btn-playall").textContent = "\u25B6"; // play symbol
}
function togglePlayback() {
if (isPlaying) stopPlayback();
else startPlayback(cursorSeekTime);
}
/** Get total duration of the playback timeline in seconds */
function getPlaybackTotalDuration() {
if (playbackTimeline.length === 0) return 0;
const last = playbackTimeline[playbackTimeline.length - 1];
return last.timeSec + last.durationSec;
}
/** Update the visual progress bar */
function updateProgressBar(fraction) {
const fill = document.getElementById("progress-bar-fill");
const timeLabel = document.getElementById("progress-time");
const clamped = Math.max(0, Math.min(1, fraction));
fill.style.width = (clamped * 100) + "%";
const total = getPlaybackTotalDuration();
const current = clamped * total;
timeLabel.textContent = formatTime(current) + " / " + formatTime(total);
}
function formatTime(sec) {
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return m + ":" + (s < 10 ? "0" : "") + s;
}
/** Seek: start playback from a specific fraction (0-1) of the timeline */
function seekPlayback(fraction) {
const bpm = parseInt(document.getElementById("bpm-input").value) || 120;
// Build timeline to know total duration
const tl = buildTimeline(noteInfos, bpm);
if (tl.length === 0) return;
const totalDur = tl[tl.length - 1].timeSec + tl[tl.length - 1].durationSec;
const seekTime = fraction * totalDur;
startPlayback(seekTime);
}
/** Find the closest note time to a click position on the score image */
function findTimeAtClick(clickX, clickY) {
if (noteInfos.length === 0 || playbackTimeline.length === 0) return 0;
const closestIdx = findNearestNote(clickX, clickY);
if (closestIdx < 0) return 0;
for (const evt of playbackTimeline) {
if (evt.noteIndices.includes(closestIdx)) return evt.timeSec;
}
return 0;
}
// ── Progress bar click to seek ──
document.getElementById("progress-bar-container").addEventListener("click", (e) => {
const rect = e.currentTarget.getBoundingClientRect();
const fraction = (e.clientX - rect.left) / rect.width;
seekPlayback(Math.max(0, Math.min(1, fraction)));
});
// ── Double-click on score to seek playback ──
// ── Shift+click on score to place cursor and set seek position ──
document.getElementById("canvas-wrapper").addEventListener("click", (e) => {
if (!e.shiftKey) return;
if (e.target.closest("circle.marker")) return;
if (noteInfos.length === 0) return;
const container = document.getElementById("canvas-container");
const containerRect = container.getBoundingClientRect();
const clickX = (e.clientX - containerRect.left) / currentZoom;
const clickY = (e.clientY - containerRect.top) / currentZoom;
const nearestIdx = findNearestNote(clickX, clickY);
if (nearestIdx < 0) return;
// Place cursor visually
placeCursorAtNote(nearestIdx);
// Store seek time so next play starts from here
cursorSeekTime = getTimeForNoteIdx(nearestIdx);
// Update progress bar to show seek position
const bpm = parseInt(document.getElementById("bpm-input").value) || 120;
const tl = buildTimeline(noteInfos, bpm);
if (tl.length > 0) {
const totalDur = tl[tl.length - 1].timeSec + tl[tl.length - 1].durationSec;
updateProgressBar(cursorSeekTime / totalDur);
}
// If currently playing, restart from new position
if (isPlaying) startPlayback(cursorSeekTime);
});
// ── Double-click on score to seek and immediately play ──
document.getElementById("canvas-wrapper").addEventListener("dblclick", (e) => {
// Only seek if clicking on empty area (not on a marker)
if (e.target.closest("circle.marker")) return;
const container = document.getElementById("canvas-container");
const containerRect = container.getBoundingClientRect();
const clickX = (e.clientX - containerRect.left) / currentZoom;
const clickY = (e.clientY - containerRect.top) / currentZoom;
// Need a timeline — build one if not playing
const bpm = parseInt(document.getElementById("bpm-input").value) || 120;
playbackTimeline = buildTimeline(noteInfos, bpm);
if (playbackTimeline.length === 0) return;
const seekTime = findTimeAtClick(clickX, clickY);
startPlayback(seekTime);
});
// Play single note button
document.getElementById("btn-play").addEventListener("click", async () => {
if (selectedIdx < 0) return;
const n = noteInfos[selectedIdx];
if (n.isRest) return;
const freq = noteToFreq(n.step, n.octave, n.alter);
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const name = sfName(n.step, n.octave, n.alter);
// Load sample if not cached
if (!pianoCache[name]) await loadPianoNote(ctx, name);
const buf = pianoCache[name];
if (buf) {
const src = ctx.createBufferSource();
src.buffer = buf;
const gain = ctx.createGain();
src.connect(gain);
gain.connect(ctx.destination);
gain.gain.setValueAtTime(masterVolume, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.0);
src.start();
src.stop(ctx.currentTime + 1.0);
} else {
// Fallback sine
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
osc.frequency.value = freq;
osc.connect(gain);
gain.connect(ctx.destination);
gain.gain.setValueAtTime(0.001, ctx.currentTime);
gain.gain.linearRampToValueAtTime(masterVolume * 0.32, ctx.currentTime + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5);
osc.start();
osc.stop(ctx.currentTime + 0.5);
}
});
// Play all / Stop buttons
document.getElementById("btn-playall").addEventListener("click", togglePlayback);
document.getElementById("btn-stop").addEventListener("click", stopPlayback);
// Debug export button
document.getElementById("btn-debug-export").addEventListener("click", () => {
const debugText = window._lastTimelineDebug;
if (!debugText) {
alert("No debug data yet. Press Play first to generate timeline debug info.");
return;
}
const blob = new Blob([debugText], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `timeline_debug_${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}.txt`;
a.click();
URL.revokeObjectURL(url);
});
// Timeline panel button + close
document.getElementById("btn-timeline").addEventListener("click", toggleTimelinePanel);
document.getElementById("timeline-close").addEventListener("click", () => {
timelinePanelVisible = false;
document.getElementById("timeline-panel").style.display = "none";
});
document.getElementById("timeline-grid-select").addEventListener("change", () => {
if (timelinePanelVisible && timelinePanelMeasure) {
renderTimelinePanel(timelinePanelMeasure.measureNum, timelinePanelMeasure.systemIdx);
}
});
// Drag support
let dragIdx = -1;
let dragStartY = 0;
let dragStartX = 0;
let dragOrigStaffPos = 0;
let dragIsXMode = false; // Alt+drag = X-axis anchor mode
// ── X-Anchor system: user-placed control points for X-axis warping ──
let xAnchors = []; // [{systemIdx, tenthsX, pixelX}]
// Score rubber-band: listen on canvas-wrapper since markerSvg has pointer-events:none on empty space
document.getElementById("canvas-wrapper").addEventListener("mousedown", (e) => {
if (staffAdjustMode || barlineMode || addMode) return;
// Only trigger on empty space (not on markers or other interactive elements)
if (e.target.closest("circle.marker") || e.target.closest(".staff-overlay") || e.target.closest(".barline-handle")) return;
if (e.button !== 0) return;
if (!e.ctrlKey && !e.metaKey) {
scoreSelectedIndices.clear();
_scoreUpdateSelectionVisuals();
}
_scoreRubberStart(e);
});
markerSvg.addEventListener("mousedown", (e) => {
if (staffAdjustMode || barlineMode || addMode) return;
const circle = e.target.closest("circle.marker");
if (!circle) return;
const tmpIdx = parseInt(circle.dataset.idx);
const tmpN = noteInfos[tmpIdx];
if (tmpN && tmpN.isRest) { selectNote(tmpIdx); return; }
// Ctrl+Click: multi-select toggle (no drag)
if (e.ctrlKey || e.metaKey) {
if (scoreSelectedIndices.has(tmpIdx)) {
scoreSelectedIndices.delete(tmpIdx);
if (selectedIdx === tmpIdx) selectedIdx = scoreSelectedIndices.size > 0 ? [...scoreSelectedIndices][0] : -1;
} else {
scoreSelectedIndices.add(tmpIdx);
selectedIdx = tmpIdx;
}
selectNote(selectedIdx);
_scoreUpdateSelectionVisuals();
_scoreCtrlHandled = true;
e.preventDefault();
return;
}
dragIdx = tmpIdx;
dragStartY = e.clientY;
dragStartX = e.clientX;
dragIsXMode = e.altKey;
if (dragIsXMode) {
// X-anchor mode: visual feedback
document.body.style.cursor = "ew-resize";
document.getElementById("status-mode").textContent = "X-ANCHOR";
} else {
pushUndo(); // save state before pitch drag begins
}
const n = noteInfos[dragIdx];
const ref = clefReferencePosition(n.clef);
dragOrigStaffPos = ref.staffPosition + (diatonicIndex(n.step, n.octave) - ref.diatonicIdx);
scoreSelectOnly(dragIdx);
e.preventDefault();
});
document.addEventListener("mousemove", (e) => {
if (dragIdx < 0) return;
try {
const n = noteInfos[dragIdx];
if (!n) { dragIdx = -1; return; }
if (dragIsXMode || e.altKey) {
// ── X-axis anchor drag: move note horizontally, warp system ──
if (!dragIsXMode) {
document.body.style.cursor = "ew-resize";
document.getElementById("status-mode").textContent = "X-ANCHOR";
}
dragIsXMode = true;
const dx = (e.clientX - dragStartX) / currentZoom;
if (Math.abs(dx) < 1) return;
const newPixelX = n.px + dx;
dragStartX = e.clientX; // incremental
// Update/add anchor for this note's tenths position
const tenthsX = layout.marginL + n.systemLeftMargin + n.measureStartX + n.defaultX;
const ux = parseFloat(offsetX.value || 0);
addOrUpdateAnchor(n.systemIdx, tenthsX, newPixelX - ux);
// Real-time recompute all notes in this system
recomputeWithAnchorsRealtime();
return;
}
// ── Y-axis pitch drag (existing) ──
const dy = (dragStartY - e.clientY) / currentZoom;
const stepsPerPixel = 1 / (5 * pixelsPerTenth);
const stepsMoved = Math.round(dy * stepsPerPixel);
if (stepsMoved === 0) return;
const newStaffPos = dragOrigStaffPos + stepsMoved;
const ref = clefReferencePosition(n.clef);
const newDiatonic = ref.diatonicIdx + (newStaffPos - ref.staffPosition);
const newOctave = Math.floor(newDiatonic / 7);
const newStepIdx = ((newDiatonic % 7) + 7) % 7;
n.step = STEPS[newStepIdx];
n.octave = newOctave;
n.alter = keyAlterForStep(n.step, n.fifths);
if (isOmrMode() && n._omrBased) {
const chord = findOmrChord(n);
const head = chord ? findOmrNoteInChord(chord, n) : null;
if (head) {
head.pitch = stepOctaveToOmrPitch(n.step, n.octave, n.clef.sign);
head.alter = n.alter;
head.hasAccidental = (n.alter !== keyAlterForStep(n.step, n.fifths));
} else {
console.warn("[drag] findOmrChord/head failed:", n.omrChordId, n.omrHeadId, n._omrMeasureIdx);
}
markModified(n);
applyPitchToXmlByHeadId(n);
} else {
applyPitchToXml(n);
applyAlterOnly(n);
}
recomputeAndUpdate(dragIdx);
_previewNoteDrag(n);
} catch (ex) {
console.error("[drag-error]", ex);
dragIdx = -1;
}
});
document.addEventListener("mouseup", () => {
if (dragIdx >= 0 && !dragIsXMode) {
const n = noteInfos[dragIdx];
if (n && n.omrHeadId) {
recordOmrEdit({ type: "change_pitch", headId: n.omrHeadId,
newPitch: stepOctaveToOmrPitch(n.step, n.octave, n.clef.sign) });
}
_previewNote(n);
}
if (dragIsXMode) {
document.body.style.cursor = "";
document.getElementById("status-mode").textContent = "";
}
dragIdx = -1; dragIsXMode = false;
});
function addOrUpdateAnchor(systemIdx, tenthsX, pixelX) {
// Replace if anchor already exists near this tenths position
const threshold = 5; // tenths
const existing = xAnchors.find(a => a.systemIdx === systemIdx && Math.abs(a.tenthsX - tenthsX) < threshold);
if (existing) {
existing.pixelX = pixelX;
} else {
xAnchors.push({ systemIdx, tenthsX, pixelX });
}
}
function recomputeWithAnchorsRealtime() {
const ux = parseFloat(offsetX.value || 0);
const uy = parseFloat(offsetY.value || 0);
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
// Fast SVG update: just move circles, don't rebuild DOM
noteInfos.forEach((n, idx) => {
const circle = markerSvg.querySelector(`circle.marker[data-idx="${idx}"]`);
if (circle) {
circle.setAttribute("cx", n.px);
// Don't update cy — only X changed
}
const accLabel = markerSvg.querySelector(`text.acc-label[data-idx="${idx}"]`);
if (accLabel) {
accLabel.setAttribute("x", n.px + 8);
}
});
}
function clearAnchors(systemIdx) {
if (systemIdx !== undefined) {
xAnchors = xAnchors.filter(a => a.systemIdx !== systemIdx);
} else {
xAnchors = [];
}
}
// ── Manual Staff Position Adjustment (Drag Mode) ──
let staffAdjustMode = false;
let staffAdjustOriginal = null;
let staffDragIdx = -1;
let staffDragStartX = 0;
let staffDragStartY = 0;
let staffDragShift = false;
let staffDragOrigData = null; // snapshot of the staff being dragged
let staffDragDebugLines = []; // debug-line elements for dragged staff (horizontal staff lines)
let staffDragMarkers = []; // {circle, origCx, origCy} for dragged staff's markers
let staffDragBarlines = []; // {el, origX1, origX2?, origX?} for system barlines
function enterStaffAdjustMode() {
if (!detectedStaves || detectedStaves.length === 0) {
console.warn("No staves to adjust");
return;
}
staffAdjustMode = true;
staffAdjustOriginal = JSON.parse(JSON.stringify(detectedStaves));
// Show staff lines if not already visible
if (!debugLinesVisible) toggleDebugLines();
// Render draggable staff overlays
renderStaffOverlays();
// Update button style
const btn = document.getElementById("btn-adjust-staves");
btn.style.background = "#ff6644";
btn.style.color = "#fff";
btn.title = "Click to exit staff adjustment mode (Esc)";
// Show status
const modeSpan = document.getElementById("status-mode");
modeSpan.textContent = t("staff_adjust_mode");
console.log("Staff adjust mode ON — drag to move staves (Shift+drag for horizontal)");
}
function exitStaffAdjustMode(applyChanges) {
staffAdjustMode = false;
staffDragIdx = -1;
// Remove overlays
markerSvg.querySelectorAll(".staff-overlay").forEach(el => el.remove());
// Reset button style
const btn = document.getElementById("btn-adjust-staves");
btn.style.background = "";
btn.style.color = "";
btn.title = "Manually adjust each staff position";
const modeSpan = document.getElementById("status-mode");
if (modeSpan.textContent === t("staff_adjust_mode")) modeSpan.textContent = "";
if (applyChanges) {
// Recompute with current (dragged) stave positions
recomputeAfterStaffAdjust();
} else if (staffAdjustOriginal) {
// Revert to original
detectedStaves = JSON.parse(JSON.stringify(staffAdjustOriginal));
recomputeAfterStaffAdjust();
}
// Refresh debug lines to show final positions
markerSvg.querySelectorAll(".debug-line").forEach(el => el.remove());
if (debugLinesVisible) {
debugLinesVisible = false;
toggleDebugLines();
}
staffAdjustOriginal = null;
console.log("Staff adjust mode OFF");
}
function recomputeAfterStaffAdjust() {
detectedStaves.sort((a, b) => a.topLineY - b.topLineY);
const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1;
reassignMeasuresToSystems(systemsData, detectedStaves, numStavesPerSys);
const ux = parseFloat(offsetX.value || 0);
const uy = parseFloat(offsetY.value || 0);
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
}
function renderStaffOverlays() {
// Remove old overlays
markerSvg.querySelectorAll(".staff-overlay").forEach(el => el.remove());
const uy = parseFloat(offsetY.value || 0);
const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1;
detectedStaves.forEach((staff, idx) => {
const sysIdx = Math.floor(idx / numStavesPerSys);
const staffInSys = idx % numStavesPerSys;
const pad = 15; // padding above/below staff lines for easier grabbing
const y = staff.topLineY + uy - pad;
const h = (staff.bottomLineY - staff.topLineY) + pad * 2;
const x = staff.leftX;
const w = staff.rightX - staff.leftX;
const color = idx % 2 === 0 ? "rgba(0,200,0,0.12)" : "rgba(255,140,0,0.12)";
const borderColor = idx % 2 === 0 ? "rgba(0,255,0,0.6)" : "rgba(255,165,0,0.6)";
const rect = document.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", x);
rect.setAttribute("y", y);
rect.setAttribute("width", w);
rect.setAttribute("height", h);
rect.setAttribute("fill", color);
rect.setAttribute("stroke", borderColor);
rect.setAttribute("stroke-width", "1.5");
rect.setAttribute("rx", "3");
rect.classList.add("staff-overlay");
rect.dataset.staffIdx = idx;
rect.dataset.origX = x;
rect.dataset.origY = y;
rect.dataset.origW = w;
rect.dataset.origH = h;
rect.style.cursor = "grab";
rect.style.pointerEvents = "all";
rect.style.transition = "x 0.1s, y 0.1s, width 0.1s, height 0.1s, fill 0.1s, stroke-width 0.1s";
// Hover: expand rect
const hoverPad = 8;
const hoverColor = idx % 2 === 0 ? "rgba(0,200,0,0.22)" : "rgba(255,140,0,0.22)";
rect.addEventListener("mouseenter", () => {
if (staffDragIdx >= 0) return; // don't expand while dragging
rect.setAttribute("x", parseFloat(rect.dataset.origX) - hoverPad);
rect.setAttribute("y", parseFloat(rect.dataset.origY) - hoverPad);
rect.setAttribute("width", parseFloat(rect.dataset.origW) + hoverPad * 2);
rect.setAttribute("height", parseFloat(rect.dataset.origH) + hoverPad * 2);
rect.setAttribute("fill", hoverColor);
rect.setAttribute("stroke-width", "2.5");
});
rect.addEventListener("mouseleave", () => {
rect.setAttribute("x", rect.dataset.origX);
rect.setAttribute("y", rect.dataset.origY);
rect.setAttribute("width", rect.dataset.origW);
rect.setAttribute("height", rect.dataset.origH);
rect.setAttribute("fill", color);
rect.setAttribute("stroke-width", "1.5");
});
// Label
const interpTag = staff.interpolated ? "*" : "";
const label = document.createElementNS(SVG_NS, "text");
label.setAttribute("x", x + 4);
label.setAttribute("y", y + 12);
label.setAttribute("fill", borderColor);
label.setAttribute("font-size", "11");
label.setAttribute("font-weight", "bold");
label.classList.add("staff-overlay");
label.style.pointerEvents = "none";
label.textContent = `S${sysIdx+1}-${staffInSys+1}${interpTag}`;
markerSvg.appendChild(rect);
markerSvg.appendChild(label);
});
}
// Staff overlay drag handlers (attached to markerSvg)
markerSvg.addEventListener("mousedown", (e) => {
if (!staffAdjustMode) return;
const overlay = e.target.closest(".staff-overlay");
if (!overlay || overlay.tagName !== "rect") return;
staffDragIdx = parseInt(overlay.dataset.staffIdx);
staffDragShift = e.shiftKey;
staffDragOrigData = JSON.parse(JSON.stringify(detectedStaves[staffDragIdx]));
// Shrink back to original size on drag start, disable transition for smooth drag
overlay.style.transition = "none";
overlay.setAttribute("x", overlay.dataset.origX);
overlay.setAttribute("y", overlay.dataset.origY);
overlay.setAttribute("width", overlay.dataset.origW);
overlay.setAttribute("height", overlay.dataset.origH);
overlay.setAttribute("stroke-width", "1.5");
staffDragStartX = e.clientX;
staffDragStartY = e.clientY;
// Collect debug lines belonging to this staff
staffDragDebugLines = Array.from(
markerSvg.querySelectorAll(`.debug-line[data-staff-idx="${staffDragIdx}"]`)
).map(el => {
const tag = el.tagName;
if (tag === "line") {
return { el, origY1: parseFloat(el.getAttribute("y1")), origY2: parseFloat(el.getAttribute("y2")) };
} else {
return { el, origY: parseFloat(el.getAttribute("y")) };
}
});
// Collect note markers belonging to this staff
// Determine which system/staff this detectedStaves index maps to
const numSPS = (systemsData.length > 0) ? systemsData[0].numStaves : 1;
const dragSysIdx = Math.floor(staffDragIdx / numSPS);
const dragStaffInSys = (staffDragIdx % numSPS) + 1; // 1-based staff number
staffDragMarkers = [];
markerSvg.querySelectorAll("circle.marker").forEach(circle => {
const idx = parseInt(circle.dataset.idx);
const n = noteInfos[idx];
if (n && n.systemIdx === dragSysIdx && n.staff === dragStaffInSys) {
const accLabel = markerSvg.querySelector(`text[data-idx="${idx}"]`);
staffDragMarkers.push({
circle,
origCx: parseFloat(circle.getAttribute("cx")),
origCy: parseFloat(circle.getAttribute("cy")),
label: accLabel,
origLabelX: accLabel ? parseFloat(accLabel.getAttribute("x")) : 0,
origLabelY: accLabel ? parseFloat(accLabel.getAttribute("y")) : 0
});
}
});
// Collect barlines (vertical debug lines) for this system
staffDragBarlines = Array.from(
markerSvg.querySelectorAll(`.debug-line[data-sys-idx="${dragSysIdx}"]`)
).map(el => {
if (el.tagName === "line") {
return { el, origX1: parseFloat(el.getAttribute("x1")), origX2: parseFloat(el.getAttribute("x2")),
origY1: parseFloat(el.getAttribute("y1")), origY2: parseFloat(el.getAttribute("y2")) };
} else {
return { el, origX: parseFloat(el.getAttribute("x")), origY: parseFloat(el.getAttribute("y")) };
}
});
overlay.style.cursor = "grabbing";
e.preventDefault();
e.stopPropagation();
}, true); // capture phase to intercept before note drag
document.addEventListener("mousemove", (e) => {
if (staffDragIdx < 0 || !staffAdjustMode) return;
const dx = (e.clientX - staffDragStartX) / currentZoom;
const dy = (e.clientY - staffDragStartY) / currentZoom;
const staff = detectedStaves[staffDragIdx];
const orig = staffDragOrigData;
const uy = parseFloat(offsetY.value || 0);
if (e.shiftKey) {
staff.leftX = Math.round(orig.leftX + dx);
staff.rightX = Math.round(orig.rightX + dx);
} else {
staff.topLineY = Math.round(orig.topLineY + dy);
staff.bottomLineY = Math.round(orig.bottomLineY + dy);
staff.lines = orig.lines.map(y => Math.round(y + dy));
}
// Move only the dragged overlay rect + its label (no full re-render)
const rect = markerSvg.querySelector(`.staff-overlay[data-staff-idx="${staffDragIdx}"]`);
if (rect) {
const pad = 15;
const newX = staff.leftX;
const newY = staff.topLineY + uy - pad;
const newW = staff.rightX - staff.leftX;
const newH = (staff.bottomLineY - staff.topLineY) + pad * 2;
rect.setAttribute("x", newX);
rect.setAttribute("y", newY);
rect.setAttribute("width", newW);
rect.setAttribute("height", newH);
rect.dataset.origX = newX;
rect.dataset.origY = newY;
rect.dataset.origW = newW;
rect.dataset.origH = newH;
// Move the overlay label (next sibling text element)
const label = rect.nextElementSibling;
if (label && label.tagName === "text") {
label.setAttribute("x", newX + 4);
label.setAttribute("y", newY + 12);
}
}
// Move debug lines (staff lines) for this staff
staffDragDebugLines.forEach(item => {
if (item.el.tagName === "line") {
if (e.shiftKey) {
// Horizontal mode: don't move lines vertically
} else {
item.el.setAttribute("y1", item.origY1 + dy);
item.el.setAttribute("y2", item.origY2 + dy);
}
} else {
// text label
if (!e.shiftKey) {
item.el.setAttribute("y", item.origY + dy);
}
}
});
// Move note markers for this staff
staffDragMarkers.forEach(item => {
if (e.shiftKey) {
item.circle.setAttribute("cx", item.origCx + dx);
if (item.label) item.label.setAttribute("x", item.origLabelX + dx);
} else {
item.circle.setAttribute("cy", item.origCy + dy);
if (item.label) item.label.setAttribute("y", item.origLabelY + dy);
}
});
// Move barlines (vertical debug lines) for this system
staffDragBarlines.forEach(item => {
if (item.el.tagName === "line") {
if (e.shiftKey) {
item.el.setAttribute("x1", item.origX1 + dx);
item.el.setAttribute("x2", item.origX2 + dx);
} else {
item.el.setAttribute("y1", item.origY1 + dy);
item.el.setAttribute("y2", item.origY2 + dy);
}
} else {
// measure number text
if (e.shiftKey) {
item.el.setAttribute("x", item.origX + dx);
} else {
item.el.setAttribute("y", item.origY + dy);
}
}
});
});
document.addEventListener("mouseup", (e) => {
if (staffDragIdx < 0 || !staffAdjustMode) return;
// Compute delta before clearing state — needed to update omrY/omrX
const dy = (e.clientY - staffDragStartY) / currentZoom;
const dx = (e.clientX - staffDragStartX) / currentZoom;
const wasShift = staffDragShift;
const draggedIdx = staffDragIdx;
const numSPS = (systemsData.length > 0) ? systemsData[0].numStaves : 1;
const dragSysIdx = Math.floor(draggedIdx / numSPS);
const dragStaffInSys = (draggedIdx % numSPS) + 1;
// Update omrY/omrX for notes on the dragged staff so they don't snap back
noteInfos.forEach(n => {
if (n.systemIdx === dragSysIdx && n.staff === dragStaffInSys) {
if (wasShift) {
if (n.omrX != null) n.omrX += dx;
} else {
if (n.omrY != null) n.omrY += dy;
}
}
});
staffDragIdx = -1;
staffDragOrigData = null;
staffDragDebugLines = [];
staffDragMarkers = [];
staffDragBarlines = [];
// Recompute marker positions with updated staff data
recomputeAfterStaffAdjust();
// Full refresh of debug lines + overlays after drag ends
markerSvg.querySelectorAll(".debug-line").forEach(el => el.remove());
if (debugLinesVisible) {
debugLinesVisible = false;
toggleDebugLines();
}
renderStaffOverlays();
});
document.getElementById("btn-adjust-staves").addEventListener("click", () => {
if (staffAdjustMode) {
exitStaffAdjustMode(true); // apply changes on toggle off
} else {
enterStaffAdjustMode();
}
});
// Esc exits staff adjust mode without applying
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && staffAdjustMode) {
exitStaffAdjustMode(false); // revert
e.preventDefault();
}
});
// ── Barline Show/Edit button handlers ──
document.getElementById("btn-show-barlines").addEventListener("click", toggleBarlineOverlays);
document.getElementById("btn-show-free-glyphs").addEventListener("click", toggleFreeGlyphs);
document.getElementById("btn-barline-mode").addEventListener("click", () => {
if (barlineMode) exitBarlineMode(true);
else enterBarlineMode();
});
// Barline toolbar buttons
document.getElementById("bl-btn-accept").addEventListener("click", () => exitBarlineMode(true));
document.getElementById("bl-btn-cancel").addEventListener("click", () => exitBarlineMode(false));
document.getElementById("bl-btn-autodetect").addEventListener("click", () => {
if (!barlineMode) return;
pushBarlineUndo();
const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1;
const suggested = detectBarlines(detectedStaves, numStavesPerSys);
// Merge: keep manual barlines, add suggested ones that aren't too close to existing
const kept = detectedBarlines.filter(b => b.source === "manual");
for (const s of suggested) {
const tooClose = kept.some(k => k.systemIdx === s.systemIdx && Math.abs(k.x - s.x) < 15);
if (!tooClose) kept.push(s);
}
detectedBarlines = kept.sort((a, b) => a.systemIdx - b.systemIdx || a.x - b.x);
renderBarlineOverlays();
updateBarlineCount();
recomputeWithBarlines();
});
// ── Barline Edit Mode ───────────────────────────────────────
let barlineMode = false;
let barlineOriginal = null; // snapshot for cancel
let selectedBarlineIdx = -1;
let barlineDragState = null; // {blIdx, startX, origX}
let ghostBarlineEl = null;
let barlineUndoStack = []; // per-operation undo for barline edits
let barlineRedoStack = [];
const MAX_BARLINE_UNDO = 30;
function pushBarlineUndo() {
barlineUndoStack.push(JSON.stringify(detectedBarlines));
if (barlineUndoStack.length > MAX_BARLINE_UNDO) barlineUndoStack.shift();
barlineRedoStack = [];
}
function undoBarline() {
if (barlineUndoStack.length === 0) return;
barlineRedoStack.push(JSON.stringify(detectedBarlines));
detectedBarlines = JSON.parse(barlineUndoStack.pop());
selectedBarlineIdx = -1;
renderBarlineOverlays();
updateBarlineCount();
}
function redoBarline() {
if (barlineRedoStack.length === 0) return;
barlineUndoStack.push(JSON.stringify(detectedBarlines));
detectedBarlines = JSON.parse(barlineRedoStack.pop());
selectedBarlineIdx = -1;
renderBarlineOverlays();
updateBarlineCount();
}
function enterBarlineMode() {
if (detectedStaves.length === 0) return;
// Initialize from XML if no barlines yet
if (detectedBarlines.length === 0) {
initBarlinesFromXML();
}
// Exit other modes
if (staffAdjustMode) exitStaffAdjustMode(true);
if (addMode) toggleAddMode();
barlineMode = true;
barlineOriginal = JSON.parse(JSON.stringify(detectedBarlines));
barlineUndoStack = [];
barlineRedoStack = [];
// Ensure barline overlays are visible
if (!barlineOverlaysVisible) {
barlineOverlaysVisible = true;
const btn = document.getElementById("btn-show-barlines");
if (btn) { btn.style.background = "#2a6a2a"; btn.style.color = "#fff"; }
}
renderBarlineOverlays();
// Show toolbar
document.getElementById("barline-toolbar").classList.add("active");
// Button style
const btn = document.getElementById("btn-barline-mode");
btn.style.background = "#ff6644";
btn.style.color = "#fff";
// Status
document.getElementById("status-mode").textContent = t("barline_edit_mode");
updateBarlineCount();
console.log("Barline edit mode ON");
}
/**
* Auto-create measures for a specific system that has no XML measures.
* Uses detected barlines if available, otherwise creates a default set of measures
* based on the system's pixel width and the average measure width from existing systems.
*/
function autoCreateMeasuresForSystem(targetSysIdx) {
if (!xmlDoc || !detectedStaves.length) return;
const numStavesPerSys = systemsData.length > 0 ? systemsData[0].numStaves : 1;
const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys);
if (targetSysIdx >= staffSystems.length) return;
const ss = staffSystems[targetSysIdx];
if (!ss || ss.length === 0) return;
const allParts = xmlDoc.querySelectorAll("part");
if (allParts.length === 0) return;
pushUndo();
// Get barlines for this system (if any were detected/manual)
const sysBarlines = detectedBarlines
.filter(b => b.systemIdx === targetSysIdx)
.sort((a, b) => a.x - b.x);
// Try to auto-detect barlines for this system if none exist
let boundaries;
if (sysBarlines.length >= 2) {
// Use existing barlines
const implicitBls = sysBarlines.filter(b => b.source === "implicit");
const internalBls = sysBarlines.filter(b => b.source !== "implicit");
const leftEdge = implicitBls.length > 0 ? implicitBls[0].x : ss[0].leftX;
const rightEdge = implicitBls.length > 1 ? implicitBls[implicitBls.length - 1].x : ss[0].rightX;
boundaries = [leftEdge, ...internalBls.map(b => b.x), rightEdge];
} else {
// No barlines: try auto-detect for just this system
const detected = detectBarlines(detectedStaves, numStavesPerSys);
const sysDetected = detected.filter(b => b.systemIdx === targetSysIdx).sort((a, b) => a.x - b.x);
if (sysDetected.length >= 2) {
// Use detected barlines as boundaries
boundaries = [ss[0].leftX, ...sysDetected.map(b => b.x), ss[0].rightX];
// Remove duplicates too close to edges
boundaries = boundaries.filter((b, i, arr) => {
if (i === 0 || i === arr.length - 1) return true;
return b - arr[0] > 15 && arr[arr.length - 1] - b > 15;
});
} else {
// Fallback: estimate measure count from average measure width in existing systems
let avgMeasureWidthPx = 200;
if (systemsData.length > 0) {
let totalW = 0, totalM = 0;
for (const sys of systemsData) {
if (sys.measures.length > 0) {
const sysStaves = staffSystems[sys.index];
if (sysStaves && sysStaves[0]) {
const pixW = sysStaves[0].rightX - sysStaves[0].leftX;
totalW += pixW;
totalM += sys.measures.length;
}
}
}
if (totalM > 0) avgMeasureWidthPx = totalW / totalM;
}
const systemPixelWidth = ss[0].rightX - ss[0].leftX;
const estimatedMeasures = Math.max(1, Math.round(systemPixelWidth / avgMeasureWidthPx));
const measurePixelWidth = systemPixelWidth / estimatedMeasures;
boundaries = [ss[0].leftX];
for (let i = 1; i <= estimatedMeasures; i++) {
boundaries.push(ss[0].leftX + i * measurePixelWidth);
}
}
}
if (boundaries.length < 2) return;
const numMeasures = boundaries.length - 1;
console.log(`autoCreateMeasuresForSystem: creating ${numMeasures} measures for system ${targetSysIdx}`);
// Collect last known attributes from XML
let lastDivisions = 1, lastBeats = 4, lastBeatType = 4, lastFifths = 0;
allParts[0].querySelectorAll("measure").forEach(mEl => {
const attr = mEl.querySelector("attributes");
if (!attr) return;
const d = attr.querySelector("divisions");
if (d) lastDivisions = parseInt(d.textContent) || 1;
const t = attr.querySelector("time");
if (t) {
lastBeats = parseInt(t.querySelector("beats")?.textContent || "4");
lastBeatType = parseInt(t.querySelector("beat-type")?.textContent || "4");
}
const k = attr.querySelector("key");
if (k) lastFifths = parseInt(k.querySelector("fifths")?.textContent || "0");
});
// Per-part clef info
const partClefs = [];
allParts.forEach(partEl => {
const clefs = [];
partEl.querySelectorAll("measure").forEach(mEl => {
const attr = mEl.querySelector("attributes");
if (!attr) return;
attr.querySelectorAll("clef").forEach(c => {
const num = parseInt(c.getAttribute("number") || "1");
clefs[num] = {
sign: c.querySelector("sign")?.textContent || "G",
line: parseInt(c.querySelector("line")?.textContent || "2")
};
});
});
partClefs.push(clefs);
});
// Find max measure number
let maxMeasureNum = 0;
allParts[0].querySelectorAll("measure").forEach(m => {
const n = parseInt(m.getAttribute("number")) || 0;
if (n > maxMeasureNum) maxMeasureNum = n;
});
// System distance from previous system
let systemDistance = 150;
for (let prev = Math.min(targetSysIdx, systemsData.length) - 1; prev >= 0; prev--) {
if (systemsData[prev] && systemsData[prev]._origSysDist) {
systemDistance = systemsData[prev]._origSysDist;
break;
}
}
const ppt = pixelsPerTenth > 0 ? pixelsPerTenth : 1;
const measureDuration = lastDivisions * lastBeats * (4 / lastBeatType);
for (let gi = 0; gi < numMeasures; gi++) {
const pixelWidth = boundaries[gi + 1] - boundaries[gi];
const widthTenths = Math.round(pixelWidth / ppt);
maxMeasureNum++;
const newNum = maxMeasureNum.toString();
allParts.forEach((partEl, pi) => {
const measureEl = xmlDoc.createElement("measure");
measureEl.setAttribute("number", newNum);
measureEl.setAttribute("width", widthTenths.toString());
// First measure needs <print new-system="yes">
if (gi === 0) {
const printEl = xmlDoc.createElement("print");
printEl.setAttribute("new-system", "yes");
const sysLayout = xmlDoc.createElement("system-layout");
const sysDistEl = xmlDoc.createElement("system-distance");
sysDistEl.textContent = systemDistance.toString();
sysLayout.appendChild(sysDistEl);
printEl.appendChild(sysLayout);
measureEl.appendChild(printEl);
}
// <attributes>
const attrEl = xmlDoc.createElement("attributes");
const divEl = xmlDoc.createElement("divisions");
divEl.textContent = lastDivisions.toString();
attrEl.appendChild(divEl);
if (gi === 0) {
const keyEl = xmlDoc.createElement("key");
const fifthsEl = xmlDoc.createElement("fifths");
fifthsEl.textContent = lastFifths.toString();
keyEl.appendChild(fifthsEl);
attrEl.appendChild(keyEl);
const timeEl = xmlDoc.createElement("time");
const beatsEl = xmlDoc.createElement("beats");
beatsEl.textContent = lastBeats.toString();
const beatTypeEl = xmlDoc.createElement("beat-type");
beatTypeEl.textContent = lastBeatType.toString();
timeEl.appendChild(beatsEl);
timeEl.appendChild(beatTypeEl);
attrEl.appendChild(timeEl);
// Clefs
const clefs = partClefs[pi];
if (clefs && clefs.length > 0) {
for (let ci = 1; ci < clefs.length; ci++) {
if (!clefs[ci]) continue;
const clefEl = xmlDoc.createElement("clef");
if (ci > 1) clefEl.setAttribute("number", ci.toString());
const signEl = xmlDoc.createElement("sign");
signEl.textContent = clefs[ci].sign;
const lineEl = xmlDoc.createElement("line");
lineEl.textContent = clefs[ci].line.toString();
clefEl.appendChild(signEl);
clefEl.appendChild(lineEl);
attrEl.appendChild(clefEl);
}
} else {
const clefEl = xmlDoc.createElement("clef");
const signEl = xmlDoc.createElement("sign");
signEl.textContent = "G";
const lineEl = xmlDoc.createElement("line");
lineEl.textContent = "2";
clefEl.appendChild(signEl);
clefEl.appendChild(lineEl);
attrEl.appendChild(clefEl);
}
}
measureEl.appendChild(attrEl);
// Full-measure rest
const noteEl = xmlDoc.createElement("note");
noteEl.appendChild(xmlDoc.createElement("rest"));
const durEl = xmlDoc.createElement("duration");
durEl.textContent = measureDuration.toString();
noteEl.appendChild(durEl);
const voiceEl = xmlDoc.createElement("voice");
voiceEl.textContent = "1";
noteEl.appendChild(voiceEl);
const typeEl = xmlDoc.createElement("type");
typeEl.textContent = "whole";
noteEl.appendChild(typeEl);
measureEl.appendChild(noteEl);
partEl.appendChild(measureEl);
});
}
// Re-parse everything
layout = parseScoreLayout(xmlDoc);
rebuildSystemsAndNotes();
const ux = parseInt(document.getElementById("offset-x").value) || 0;
const uy = parseInt(document.getElementById("offset-y").value) || 0;
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
console.log(`autoCreateMeasuresForSystem: done, systemsData now has ${systemsData.length} systems`);
}
/**
* Create XML <measure> elements for systems where barlines exist but XML measures are missing.
* Called on barline accept. Compares barline count per system with XML measure count,
* and creates missing measures in ALL parts.
*
* Returns true if any measures were created (caller should re-parse).
*/
function createMeasuresFromBarlines() {
if (!xmlDoc || !systemsData.length || detectedBarlines.length === 0) return false;
const numStavesPerSys = systemsData[0].numStaves;
const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys);
const allParts = xmlDoc.querySelectorAll("part");
if (allParts.length === 0) return false;
// Find the current highest measure number across all parts
let maxMeasureNum = 0;
allParts[0].querySelectorAll("measure").forEach(m => {
const n = parseInt(m.getAttribute("number")) || 0;
if (n > maxMeasureNum) maxMeasureNum = n;
});
// Collect last known attributes (divisions, beats, beatType, clef) from XML
let lastDivisions = 1, lastBeats = 4, lastBeatType = 4;
let lastClefSign = "G", lastClefLine = 2;
let lastFifths = 0;
const lastMeasure = allParts[0].querySelector("measure:last-of-type");
if (lastMeasure) {
// Walk all measures to find the last active attributes
allParts[0].querySelectorAll("measure").forEach(mEl => {
const attr = mEl.querySelector("attributes");
if (!attr) return;
const d = attr.querySelector("divisions");
if (d) lastDivisions = parseInt(d.textContent) || 1;
const t = attr.querySelector("time");
if (t) {
lastBeats = parseInt(t.querySelector("beats")?.textContent || "4");
lastBeatType = parseInt(t.querySelector("beat-type")?.textContent || "4");
}
const k = attr.querySelector("key");
if (k) lastFifths = parseInt(k.querySelector("fifths")?.textContent || "0");
const c = attr.querySelector("clef");
if (c) {
lastClefSign = c.querySelector("sign")?.textContent || "G";
lastClefLine = parseInt(c.querySelector("line")?.textContent || "2");
}
});
}
// Per-part last clef info (for multi-staff, e.g. treble+bass)
const partClefs = [];
allParts.forEach(partEl => {
const clefs = [];
partEl.querySelectorAll("measure").forEach(mEl => {
const attr = mEl.querySelector("attributes");
if (!attr) return;
attr.querySelectorAll("clef").forEach(c => {
const num = parseInt(c.getAttribute("number") || "1");
const sign = c.querySelector("sign")?.textContent || "G";
const line = parseInt(c.querySelector("line")?.textContent || "2");
clefs[num] = { sign, line };
});
});
partClefs.push(clefs);
});
let created = false;
// Check each image system: if it has barlines but fewer XML measures than expected
for (let sysIdx = 0; sysIdx < staffSystems.length; sysIdx++) {
const ss = staffSystems[sysIdx];
if (!ss || ss.length === 0) continue;
// Get barlines for this system, sorted by X
const sysBarlines = detectedBarlines
.filter(b => b.systemIdx === sysIdx)
.sort((a, b) => a.x - b.x);
if (sysBarlines.length < 2) continue; // need at least left+right edges
// Expected measures = number of gaps between barlines
// implicit barlines (left/right edge) define the outer boundaries
// Internal barlines define measure divisions
const implicitBls = sysBarlines.filter(b => b.source === "implicit");
const internalBls = sysBarlines.filter(b => b.source !== "implicit");
const expectedMeasures = internalBls.length + 1; // N internal barlines → N+1 measures
// How many XML measures does this system currently have?
// Note: systemsData may have fewer entries than staffSystems (Audiveris missed systems)
const xmlSys = sysIdx < systemsData.length ? systemsData[sysIdx] : null;
const currentMeasures = xmlSys ? xmlSys.measures.length : 0;
if (currentMeasures >= expectedMeasures) continue; // enough measures already
const measuresToCreate = expectedMeasures - currentMeasures;
if (!created) pushUndo(); // snapshot XML before first modification
console.log(`createMeasuresFromBarlines: system ${sysIdx} needs ${measuresToCreate} new measures (has ${currentMeasures}, expected ${expectedMeasures})`);
// Calculate measure widths from barline pixel positions → tenths
// Build sorted boundary positions (left edge, internal barlines, right edge)
const leftEdge = implicitBls.length > 0 ? implicitBls[0].x : ss[0].leftX;
const rightEdge = implicitBls.length > 1 ? implicitBls[implicitBls.length - 1].x : ss[0].rightX;
const boundaries = [leftEdge, ...internalBls.map(b => b.x), rightEdge];
// Total pixel width → use pixelsPerTenth for conversion
const ppt = pixelsPerTenth > 0 ? pixelsPerTenth : 1;
// Determine if this is a new system that needs <print new-system>
const needsNewSystem = !xmlSys || xmlSys.measures.length === 0;
// Get system-distance from previous system (for <print> element)
let systemDistance = 150; // reasonable default in tenths
if (sysIdx > 0) {
// Try to inherit from an existing system's distance
for (let prev = Math.min(sysIdx, systemsData.length) - 1; prev >= 0; prev--) {
if (systemsData[prev]._origSysDist) {
systemDistance = systemsData[prev]._origSysDist;
break;
}
}
}
// Create measures for each gap between boundaries
// Only create measures for gaps that don't have existing measures
const startGapIdx = currentMeasures; // existing measures cover gaps 0..currentMeasures-1
for (let gi = startGapIdx; gi < boundaries.length - 1; gi++) {
const pixelWidth = boundaries[gi + 1] - boundaries[gi];
const widthTenths = Math.round(pixelWidth / ppt);
maxMeasureNum++;
const newNum = maxMeasureNum.toString();
// Create measure in each part
allParts.forEach((partEl, pi) => {
const measureEl = xmlDoc.createElement("measure");
measureEl.setAttribute("number", newNum);
measureEl.setAttribute("width", widthTenths.toString());
// First measure of a new system needs <print new-system="yes">
if (gi === startGapIdx && needsNewSystem) {
const printEl = xmlDoc.createElement("print");
printEl.setAttribute("new-system", "yes");
// Add system-layout with system-distance
const sysLayout = xmlDoc.createElement("system-layout");
const sysDistEl = xmlDoc.createElement("system-distance");
sysDistEl.textContent = systemDistance.toString();
sysLayout.appendChild(sysDistEl);
printEl.appendChild(sysLayout);
measureEl.appendChild(printEl);
}
// Add <attributes> with inherited values
const attrEl = xmlDoc.createElement("attributes");
const divEl = xmlDoc.createElement("divisions");
divEl.textContent = lastDivisions.toString();
attrEl.appendChild(divEl);
// Only add key/time/clef on first measure of new system to avoid redundancy
if (gi === startGapIdx) {
const keyEl = xmlDoc.createElement("key");
const fifthsEl = xmlDoc.createElement("fifths");
fifthsEl.textContent = lastFifths.toString();
keyEl.appendChild(fifthsEl);
attrEl.appendChild(keyEl);
const timeEl = xmlDoc.createElement("time");
const beatsEl = xmlDoc.createElement("beats");
beatsEl.textContent = lastBeats.toString();
const beatTypeEl = xmlDoc.createElement("beat-type");
beatTypeEl.textContent = lastBeatType.toString();
timeEl.appendChild(beatsEl);
timeEl.appendChild(beatTypeEl);
attrEl.appendChild(timeEl);
// Add clef(s) for this part
const clefs = partClefs[pi];
if (clefs && clefs.length > 0) {
for (let ci = 1; ci < clefs.length; ci++) {
if (!clefs[ci]) continue;
const clefEl = xmlDoc.createElement("clef");
if (ci > 1) clefEl.setAttribute("number", ci.toString());
const signEl = xmlDoc.createElement("sign");
signEl.textContent = clefs[ci].sign;
const lineEl = xmlDoc.createElement("line");
lineEl.textContent = clefs[ci].line.toString();
clefEl.appendChild(signEl);
clefEl.appendChild(lineEl);
attrEl.appendChild(clefEl);
}
} else {
// Fallback: single clef
const clefEl = xmlDoc.createElement("clef");
const signEl = xmlDoc.createElement("sign");
signEl.textContent = lastClefSign;
const lineEl = xmlDoc.createElement("line");
lineEl.textContent = lastClefLine.toString();
clefEl.appendChild(signEl);
clefEl.appendChild(lineEl);
attrEl.appendChild(clefEl);
}
}
measureEl.appendChild(attrEl);
// Add a full-measure rest so the measure has valid duration
const measureDuration = lastDivisions * lastBeats * (4 / lastBeatType);
const noteEl = xmlDoc.createElement("note");
const restEl = xmlDoc.createElement("rest");
noteEl.appendChild(restEl);
const durEl = xmlDoc.createElement("duration");
durEl.textContent = measureDuration.toString();
noteEl.appendChild(durEl);
const voiceEl = xmlDoc.createElement("voice");
voiceEl.textContent = "1";
noteEl.appendChild(voiceEl);
const typeEl = xmlDoc.createElement("type");
typeEl.textContent = "whole";
noteEl.appendChild(typeEl);
partEl.appendChild(measureEl);
});
created = true;
}
}
if (created) {
console.log(`createMeasuresFromBarlines: created measures, new max number = ${maxMeasureNum}`);
// Re-parse everything
layout = parseScoreLayout(xmlDoc);
rebuildSystemsAndNotes();
const ux = parseInt(document.getElementById("offset-x").value) || 0;
const uy = parseInt(document.getElementById("offset-y").value) || 0;
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
}
return created;
}
function exitBarlineMode(accept) {
barlineMode = false;
selectedBarlineIdx = -1;
// Remove ghost
if (ghostBarlineEl) { ghostBarlineEl.remove(); ghostBarlineEl = null; }
// Hide toolbar
document.getElementById("barline-toolbar").classList.remove("active");
// Reset button
const btn = document.getElementById("btn-barline-mode");
btn.style.background = "";
btn.style.color = "";
const modeSpan = document.getElementById("status-mode");
if (modeSpan.textContent === t("barline_edit_mode")) modeSpan.textContent = "";
if (!accept && barlineOriginal) {
detectedBarlines = JSON.parse(JSON.stringify(barlineOriginal));
}
barlineOriginal = null;
renderBarlineOverlays();
// If accepted, create missing measures from barlines, then trigger X remap
if (accept && detectedBarlines.length > 0) {
const measuresCreated = createMeasuresFromBarlines();
if (measuresCreated) {
console.log("New measures created from barlines — re-initializing barlines from updated XML");
// Re-init barlines so newly created measures get proper XML-based boundaries
initBarlinesFromXML();
}
recomputeWithBarlines();
}
console.log("Barline edit mode OFF, accept=" + accept);
}
function recomputeWithBarlines() {
const ux = parseFloat(offsetX.value || 0);
const uy = parseFloat(offsetY.value || 0);
const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1;
const maps = buildBarlinePiecewiseMaps(layout, numStavesPerSys);
console.log("recomputeWithBarlines:", detectedBarlines.length, "barlines, piecewise maps:", maps);
if (maps) {
maps.forEach((segs, si) => {
if (segs) console.log(` Sys ${si}: ${segs.length} segments`, segs.map(s => `[img ${s.imgStart}-${s.imgEnd}, xml ${s.xmlStart.toFixed(0)}-${s.xmlEnd.toFixed(0)}]`).join(" "));
});
}
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
renderMarkers(noteInfos);
if (barlineOverlaysVisible) renderBarlineOverlays();
if (freeGlyphsVisible) renderFreeGlyphOverlays();
}
/**
* Initialize barlines from XML measure boundaries using linear pixel mapping.
* Each internal measure boundary becomes a barline. Staff left/right edges
* are NOT included (they serve as implicit anchors in the piecewise system).
*/
function initBarlinesFromXML() {
if (!systemsData.length || !layout) return;
const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1;
const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys);
const newBarlines = [];
systemsData.forEach((sys, sysIdx) => {
if (!sys.measures || sys.measures.length === 0) return;
const sysStaves = staffSystems[sysIdx];
if (!sysStaves || sysStaves.length === 0) return;
const imgLeft = sysStaves[0].leftX;
const imgRight = sysStaves[0].rightX;
const imgWidth = imgRight - imgLeft;
if (imgWidth <= 0) return;
const sysLeftTenths = layout.marginL + sys.leftMargin;
const sysTotalTenths = sys.cumulativeWidth;
if (sysTotalTenths <= 0) return;
// System left edge (implicit boundary)
newBarlines.push({
x: Math.round(imgLeft),
systemIdx: sysIdx,
confidence: 1.0,
source: "implicit"
});
// Internal measure boundaries (right edge of each measure except the last)
for (let mi = 0; mi < sys.measures.length - 1; mi++) {
const m = sys.measures[mi];
const boundaryTenths = m.startX + m.width; // relative to system
const ratio = boundaryTenths / sysTotalTenths;
const pixelX = imgLeft + ratio * imgWidth;
newBarlines.push({
x: Math.round(pixelX),
systemIdx: sysIdx,
confidence: 0.9,
source: "xml"
});
}
// System right edge (implicit boundary)
newBarlines.push({
x: Math.round(imgRight),
systemIdx: sysIdx,
confidence: 1.0,
source: "implicit"
});
});
detectedBarlines = newBarlines;
}
function updateBarlineCount() {
const el = document.getElementById("bl-count");
if (el) {
const total = detectedBarlines.length;
const uncertain = detectedBarlines.filter(b => b.confidence < 0.55).length;
el.textContent = `${total} barlines` + (uncertain > 0 ? ` (${uncertain} uncertain)` : "");
}
}
function selectBarline(idx) {
// Deselect previous
if (selectedBarlineIdx >= 0 && selectedBarlineIdx < detectedBarlines.length) {
const prev = detectedBarlines[selectedBarlineIdx];
if (prev.svgEl) prev.svgEl.classList.remove("bl-selected");
}
selectedBarlineIdx = idx;
if (idx >= 0 && idx < detectedBarlines.length) {
const bl = detectedBarlines[idx];
if (bl.svgEl) bl.svgEl.classList.add("bl-selected");
}
}
function deleteBarline(idx) {
if (idx < 0 || idx >= detectedBarlines.length) return;
const bl = detectedBarlines[idx];
if (bl.source === "implicit") return; // can't delete implicit (staff edges)
pushBarlineUndo();
detectedBarlines.splice(idx, 1);
selectedBarlineIdx = -1;
renderBarlineOverlays();
updateBarlineCount();
}
function insertBarline(x, systemIdx) {
pushBarlineUndo();
const newBl = { x: Math.round(x), systemIdx, confidence: 1.0, source: "manual" };
detectedBarlines.push(newBl);
detectedBarlines.sort((a, b) => a.systemIdx - b.systemIdx || a.x - b.x);
// Select the newly inserted barline
const newIdx = detectedBarlines.indexOf(newBl);
renderBarlineOverlays();
selectBarline(newIdx);
updateBarlineCount();
}
function showGhostBarline(x, yTop, yBot) {
if (!ghostBarlineEl) {
ghostBarlineEl = document.createElementNS(SVG_NS, "line");
ghostBarlineEl.classList.add("ghost-barline");
markerSvg.appendChild(ghostBarlineEl);
}
ghostBarlineEl.setAttribute("x1", x);
ghostBarlineEl.setAttribute("y1", yTop);
ghostBarlineEl.setAttribute("x2", x);
ghostBarlineEl.setAttribute("y2", yBot);
}
function hideGhostBarline() {
if (ghostBarlineEl) { ghostBarlineEl.remove(); ghostBarlineEl = null; }
}
/** Find which system a click Y belongs to */
function findSystemAtY(clickY) {
const uy = parseFloat(offsetY.value || 0);
const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1;
const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys);
for (let si = 0; si < staffSystems.length; si++) {
const ss = staffSystems[si];
if (!ss || ss.length === 0) continue;
const top = ss[0].topLineY - 30 + uy;
const bot = ss[ss.length - 1].bottomLineY + 30 + uy;
if (clickY >= top && clickY <= bot) return si;
}
return -1;
}
/** Find the barline index nearest to (x, sysIdx) within threshold */
function findNearestBarline(x, sysIdx, threshold) {
const ux = parseFloat(offsetX.value || 0);
let bestIdx = -1, bestDist = Infinity;
detectedBarlines.forEach((bl, i) => {
if (bl.systemIdx !== sysIdx) return;
const d = Math.abs((bl.x + ux) - x);
if (d < bestDist && d <= threshold) { bestDist = d; bestIdx = i; }
});
return bestIdx;
}
// ── Barline Edit Mode: mouse handlers ───────────────────────
// Mousedown on canvas-wrapper for barline mode
document.getElementById("canvas-wrapper").addEventListener("mousedown", (e) => {
if (!barlineMode || staffAdjustMode) return;
if (e.button !== 0) return;
const rect = scoreImage.getBoundingClientRect();
const px = (e.clientX - rect.left) / currentZoom;
const py = (e.clientY - rect.top) / currentZoom;
const sysIdx = findSystemAtY(py);
if (sysIdx < 0) return;
// Check if clicking on an existing barline
const nearIdx = findNearestBarline(px, sysIdx, 8 / currentZoom);
if (nearIdx >= 0) {
// Select and start drag
pushBarlineUndo();
selectBarline(nearIdx);
barlineDragState = {
blIdx: nearIdx,
startX: e.clientX,
origX: detectedBarlines[nearIdx].x
};
e.preventDefault();
e.stopPropagation();
} else if (!e.shiftKey) {
// Click empty space → insert barline
const ux = parseFloat(offsetX.value || 0);
insertBarline(px - ux, sysIdx);
e.preventDefault();
e.stopPropagation();
}
}, true);
// Mousemove for barline drag + ghost barline
document.addEventListener("mousemove", (e) => {
if (!barlineMode) return;
const rect = scoreImage.getBoundingClientRect();
const px = (e.clientX - rect.left) / currentZoom;
const py = (e.clientY - rect.top) / currentZoom;
// Drag in progress
if (barlineDragState) {
const dx = (e.clientX - barlineDragState.startX) / currentZoom;
let newX = Math.round(barlineDragState.origX + dx);
// Snap-to-feature (unless Shift held)
if (!e.shiftKey) {
newX = snapBarlineToFeature(newX, detectedBarlines[barlineDragState.blIdx].systemIdx);
}
detectedBarlines[barlineDragState.blIdx].x = newX;
// Update SVG element position
const bl = detectedBarlines[barlineDragState.blIdx];
const ux = parseFloat(offsetX.value || 0);
if (bl.svgEl) {
bl.svgEl.setAttribute("x1", newX + ux);
bl.svgEl.setAttribute("x2", newX + ux);
}
// Real-time note recompute during barline drag
const uy = parseFloat(offsetY.value || 0);
computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy);
noteInfos.forEach((n, idx) => {
const circle = markerSvg.querySelector(`circle.marker[data-idx="${idx}"]`);
if (circle) circle.setAttribute("cx", n.px);
const accLabel = markerSvg.querySelector(`text.acc-label[data-idx="${idx}"]`);
if (accLabel) accLabel.setAttribute("x", n.px + 8);
});
return;
}
// Ghost barline preview (when not dragging)
const sysIdx = findSystemAtY(py);
if (sysIdx >= 0) {
const nearIdx = findNearestBarline(px, sysIdx, 8 / currentZoom);
if (nearIdx < 0) {
// No barline nearby → show ghost
const uy = parseFloat(offsetY.value || 0);
const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1;
const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys);
const ss = staffSystems[sysIdx];
if (ss && ss.length > 0) {
const yTop = ss[0].topLineY - 8 + uy;
const yBot = ss[ss.length - 1].bottomLineY + 8 + uy;
showGhostBarline(px, yTop, yBot);
}
} else {
hideGhostBarline();
}
} else {
hideGhostBarline();
}
});
// Mouseup for barline drag
document.addEventListener("mouseup", (e) => {
if (!barlineMode || !barlineDragState) return;
// Mark as manual after drag (but keep implicit source for edge barlines)
const draggedBl = detectedBarlines[barlineDragState.blIdx];
if (draggedBl.source !== "implicit") {
draggedBl.source = "manual";
}
draggedBl.confidence = 1.0;
barlineDragState = null;
// Re-sort and re-render
detectedBarlines.sort((a, b) => a.systemIdx - b.systemIdx || a.x - b.x);
renderBarlineOverlays();
updateBarlineCount();
});
/** Snap barline X to nearest vertical feature within ±10px */
function snapBarlineToFeature(x, sysIdx) {
const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1;
const featureMap = buildVerticalFeatureMap(sysIdx, detectedStaves, numStavesPerSys);
if (!featureMap) return x;
const snapRange = 10;
let bestX = x, bestScore = 0;
for (let dx = -snapRange; dx <= snapRange; dx++) {
const cx = x + dx;
if (cx < 0 || cx >= featureMap.length) continue;
if (featureMap[cx] > bestScore) {
bestScore = featureMap[cx];
bestX = cx;
}
}
// Only snap if the feature is reasonably strong
return bestScore >= 0.4 ? bestX : x;
}
// ── Barline keyboard handler ────────────────────────────────
document.addEventListener("keydown", (e) => {
if (!barlineMode) return;
if (e.target.tagName === "INPUT") return;
// Ctrl+Z / Ctrl+Y for barline undo/redo
if (e.ctrlKey || e.metaKey) {
if (e.key === "z" || e.key === "Z") { e.preventDefault(); undoBarline(); return; }
if (e.key === "y" || e.key === "Y") { e.preventDefault(); redoBarline(); return; }
}
switch (e.key) {
case "Escape":
e.preventDefault();
exitBarlineMode(false);
return;
case "Enter":
e.preventDefault();
exitBarlineMode(true);
return;
case "Delete":
e.preventDefault();
if (selectedBarlineIdx >= 0) deleteBarline(selectedBarlineIdx);
return;
case "ArrowLeft":
e.preventDefault();
if (selectedBarlineIdx >= 0) {
pushBarlineUndo();
const delta = e.shiftKey ? -5 : -1;
nudgeBarline(selectedBarlineIdx, delta);
}
return;
case "ArrowRight":
e.preventDefault();
if (selectedBarlineIdx >= 0) {
pushBarlineUndo();
const delta = e.shiftKey ? 5 : 1;
nudgeBarline(selectedBarlineIdx, delta);
}
return;
case "Tab":
e.preventDefault();
navigateBarline(e.shiftKey ? -1 : 1);
return;
case "b": case "B":
// B toggles mode off
if (!e.ctrlKey && !e.metaKey) {
e.preventDefault();
exitBarlineMode(true);
}
return;
case "d": case "D":
e.preventDefault();
distributeBarlines();
return;
}
});
function nudgeBarline(idx, delta) {
if (idx < 0 || idx >= detectedBarlines.length) return;
detectedBarlines[idx].x += delta;
detectedBarlines[idx].source = "manual";
detectedBarlines[idx].confidence = 1.0;
const ux = parseFloat(offsetX.value || 0);
const bl = detectedBarlines[idx];
if (bl.svgEl) {
bl.svgEl.setAttribute("x1", bl.x + ux);
bl.svgEl.setAttribute("x2", bl.x + ux);
}
}
function navigateBarline(dir) {
if (detectedBarlines.length === 0) return;
if (selectedBarlineIdx < 0) {
selectBarline(0);
return;
}
let next = selectedBarlineIdx + dir;
if (next < 0) next = detectedBarlines.length - 1;
if (next >= detectedBarlines.length) next = 0;
selectBarline(next);
// Scroll into view
const bl = detectedBarlines[next];
if (bl && bl.svgEl) {
const rect = bl.svgEl.getBoundingClientRect();
const wrapper = document.getElementById("canvas-wrapper");
const wrapRect = wrapper.getBoundingClientRect();
if (rect.left < wrapRect.left || rect.right > wrapRect.right) {
bl.svgEl.scrollIntoView({ behavior: "smooth", inline: "center" });
}
}
}
/** Distribute barlines evenly between selected barline and next one in same system.
* Prompts user for number of divisions. */
function distributeBarlines() {
if (selectedBarlineIdx < 0) return;
const bl = detectedBarlines[selectedBarlineIdx];
// Find all barlines in same system, sorted by X
const sysBarlines = detectedBarlines
.map((b, i) => ({ b, i }))
.filter(({ b }) => b.systemIdx === bl.systemIdx)
.sort((a, b) => a.b.x - b.b.x);
const selInSys = sysBarlines.findIndex(({ i }) => i === selectedBarlineIdx);
if (selInSys < 0 || selInSys >= sysBarlines.length - 1) return;
const leftX = sysBarlines[selInSys].b.x;
const rightX = sysBarlines[selInSys + 1].b.x;
const gap = rightX - leftX;
if (gap < 30) return;
const input = prompt(
currentLang === "ko"
? `이 구간(${gap}px)을 몇 등분할까요?`
: `Divide this segment (${gap}px) into how many parts?`,
"2"
);
if (!input) return;
const n = parseInt(input);
if (isNaN(n) || n < 2 || n > 20) return;
pushBarlineUndo();
// Remove any existing barlines between left and right (exclusive)
detectedBarlines = detectedBarlines.filter(b => {
if (b.systemIdx !== bl.systemIdx) return true;
return b.x <= leftX || b.x >= rightX;
});
// Insert n-1 evenly spaced barlines
for (let k = 1; k < n; k++) {
const newX = Math.round(leftX + (gap * k) / n);
detectedBarlines.push({
x: newX, systemIdx: bl.systemIdx, confidence: 1.0, source: "manual"
});
}
detectedBarlines.sort((a, b) => a.systemIdx - b.systemIdx || a.x - b.x);
renderBarlineOverlays();
updateBarlineCount();
}
/** Copy barline pattern from one system to another (proportional scaling) */
function copyBarlinePattern(fromSysIdx, toSysIdx) {
const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1;
const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys);
const fromStaves = staffSystems[fromSysIdx];
const toStaves = staffSystems[toSysIdx];
if (!fromStaves || !toStaves || fromStaves.length === 0 || toStaves.length === 0) return;
const fromLeft = fromStaves[0].leftX;
const fromRight = fromStaves[0].rightX;
const fromWidth = fromRight - fromLeft;
if (fromWidth <= 0) return;
const toLeft = toStaves[0].leftX;
const toRight = toStaves[0].rightX;
const toWidth = toRight - toLeft;
if (toWidth <= 0) return;
// Get source barlines (non-implicit)
const srcBarlines = detectedBarlines
.filter(b => b.systemIdx === fromSysIdx)
.sort((a, b) => a.x - b.x);
if (srcBarlines.length === 0) return;
// Remove existing barlines in target system
detectedBarlines = detectedBarlines.filter(b => b.systemIdx !== toSysIdx);
// Copy with proportional scaling
srcBarlines.forEach(b => {
const ratio = (b.x - fromLeft) / fromWidth;
const newX = Math.round(toLeft + ratio * toWidth);
detectedBarlines.push({
x: newX, systemIdx: toSysIdx, confidence: 1.0, source: "manual"
});
});
detectedBarlines.sort((a, b) => a.systemIdx - b.systemIdx || a.x - b.x);
renderBarlineOverlays();
updateBarlineCount();
}
// ── Browser zoom compensation for UI bars ──
// Keep upload bar, toolbar, progress bar, status bar at constant size
// regardless of Ctrl+scroll browser zoom.
(function initZoomCompensation() {
// Fixed reference DPR = 1.0 (standard Windows 100% scaling).
// Using a fixed value avoids the bug where reloading while zoomed
// captures the zoomed DPR as baseline.
const baseDPR = 1.0;
const uiElements = [
document.getElementById("feedback-bar"),
document.getElementById("upload-bar"),
document.getElementById("toolbar"),
document.getElementById("progress-bar-container"),
document.getElementById("shortcut-bar"),
document.getElementById("barline-toolbar"),
document.getElementById("status-bar"),
].filter(Boolean);
uiElements.forEach(el => {
el.style.transformOrigin = "top left";
});
function applyZoomCompensation() {
const currentDPR = window.devicePixelRatio || 1;
const browserZoom = currentDPR / baseDPR;
const inverseScale = 1 / browserZoom;
uiElements.forEach(el => {
el.style.transform = `scale(${inverseScale})`;
el.style.width = (browserZoom * 100) + "%";
// margin-bottom compensation: element renders smaller but occupies original space
const naturalH = el.scrollHeight;
el.style.marginBottom = (naturalH * (inverseScale - 1)) + "px";
});
}
// Detect zoom changes — matchMedia on resolution is the reliable cross-browser method
function watchZoom() {
const mq = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
mq.addEventListener("change", () => {
applyZoomCompensation();
watchZoom(); // re-register since the resolution value changed
}, { once: true });
}
applyZoomCompensation();
watchZoom();
window.addEventListener("resize", applyZoomCompensation);
})();
// ═══════════════════════════════════════════════════════════════════
// ── Interactive Tutorial Tour ──
// ═══════════════════════════════════════════════════════════════════
(function initTutorial() {
const LS_KEY = "omr_corrector_skip_tutorial";
const STEPS = [
{ target: "#canvas-wrapper", text: "악보 영역입니다.\n마커를 클릭하면 음표가 선택됩니다.\nCtrl+클릭으로 여러 음표를 동시에 선택할 수 있습니다." },
{ target: "#btn-up", text: "음 높이 변경 버튼입니다.\n↑↓ 방향키로도 조작할 수 있습니다.\n선택한 음표의 피치를 반음 단위로 올리거나 내립니다." },
{ target: "#btn-dur-quarter", text: "음표 길이 변경 버튼입니다.\n숫자키 1(온음표)~7(32분음표)로 빠르게 바꿀 수 있습니다.\n점(.) 키로 점음표를 토글합니다." },
{ target: "#btn-sharp", text: "임시표(#/b/♮) 변경 버튼입니다.\n선택한 음표에 샵, 플랫, 내추럴 등을 적용합니다." },
{ target: "#btn-show-free-glyphs", text: "후보 기호 표시/숨김.\nOMR이 인식했지만 아직 배정되지 않은 기호들을 악보 위에 표시합니다.\n클릭하면 해당 기호의 종류를 변경하거나 삭제할 수 있습니다." },
{ target: "#btn-timeline", text: "타임라인(TL) 패널을 엽니다.\n마디 안에서 음표들의 시간 배치를 시각적으로 확인하고,\n드래그로 위치를 조정할 수 있습니다." },
{ target: "#btn-auto-align", text: "마디 자동 정렬 (Shift+A).\n선택한 마디의 음표 간격을 균등하게 맞춥니다." },
{ target: "#btn-download", text: "완성된 악보를 XML 또는 MML 파일로 내보냅니다." },
];
let currentStep = -1;
const prompt = document.getElementById("tour-prompt");
const promptInner = document.getElementById("tour-prompt-inner");
const skipCheck = document.getElementById("tour-skip-check");
const btnYes = document.getElementById("tour-btn-yes");
const btnNo = document.getElementById("tour-btn-no");
const overlay = document.getElementById("tour-overlay");
const spotlight = document.getElementById("tour-spotlight");
const tooltip = document.getElementById("tour-tooltip");
const tooltipBody = document.getElementById("tour-tooltip-body");
const stepInd = document.getElementById("tour-step-indicator");
const btnPrev = document.getElementById("tour-btn-prev");
const btnNext = document.getElementById("tour-btn-next");
const btnSkipTour = document.getElementById("tour-btn-skip");
const btnTutorial = document.getElementById("btn-tutorial");
if (!prompt || !btnTutorial) return; // safety
// ── Prompt Panel ──
function showPrompt() {
if (localStorage.getItem(LS_KEY) === "1") return;
prompt.classList.remove("hidden");
}
function hidePromptWithShrink(cb) {
const btnRect = btnTutorial.getBoundingClientRect();
const panelRect = promptInner.getBoundingClientRect();
const dx = (btnRect.left + btnRect.width / 2) - (panelRect.left + panelRect.width / 2);
const dy = (btnRect.top + btnRect.height / 2) - (panelRect.top + panelRect.height / 2);
promptInner.animate([
{ transform: "scale(1) translate(0px,0px)", opacity: 1 },
{ transform: "scale(0.06) translate(" + dx + "px," + dy + "px)", opacity: 0 }
], { duration: 400, easing: "cubic-bezier(0.4,0,0.2,1)", fill: "forwards" })
.onfinish = function() {
prompt.classList.add("hidden");
promptInner.getAnimations().forEach(function(a) { a.cancel(); });
if (cb) cb();
};
}
// ── Tour Engine ──
function startTour() {
prompt.classList.add("hidden");
currentStep = 0;
overlay.classList.remove("hidden");
showStep(0);
}
function showStep(idx) {
var step = STEPS[idx];
var el = document.querySelector(step.target);
if (!el) { if (idx < STEPS.length - 1) { currentStep++; showStep(currentStep); } else { endTour(); } return; }
var rect = el.getBoundingClientRect();
var pad = 8;
spotlight.style.top = (rect.top - pad) + "px";
spotlight.style.left = (rect.left - pad) + "px";
spotlight.style.width = (rect.width + pad * 2) + "px";
spotlight.style.height = (rect.height + pad * 2) + "px";
tooltipBody.textContent = "";
step.text.split("\n").forEach(function(line, i) {
if (i > 0) tooltipBody.appendChild(document.createElement("br"));
tooltipBody.appendChild(document.createTextNode(line));
});
stepInd.textContent = (idx + 1) + " / " + STEPS.length;
btnPrev.style.display = idx === 0 ? "none" : "";
btnNext.textContent = idx === STEPS.length - 1 ? "완료" : "다음";
tooltip.classList.remove("hidden");
positionTooltip(rect);
}
function positionTooltip(targetRect) {
tooltip.style.left = "0px";
tooltip.style.top = "0px";
var tw = tooltip.offsetWidth;
var th = tooltip.offsetHeight;
var gap = 14;
var vw = window.innerWidth;
var vh = window.innerHeight;
var top = targetRect.bottom + gap;
var left = targetRect.left + targetRect.width / 2 - tw / 2;
if (top + th > vh - 10) top = targetRect.top - th - gap;
if (top < 10) top = 10;
if (left < 10) left = 10;
if (left + tw > vw - 10) left = vw - tw - 10;
tooltip.style.top = top + "px";
tooltip.style.left = left + "px";
}
function nextStep() {
if (currentStep >= STEPS.length - 1) { endTour(); return; }
currentStep++;
showStep(currentStep);
}
function prevStep() {
if (currentStep > 0) { currentStep--; showStep(currentStep); }
}
function endTour() {
tooltip.classList.add("hidden");
overlay.classList.add("hidden");
currentStep = -1;
}
// ── Events ──
btnYes.addEventListener("click", function() {
if (skipCheck.checked) localStorage.setItem(LS_KEY, "1");
startTour();
});
btnNo.addEventListener("click", function() {
if (skipCheck.checked) localStorage.setItem(LS_KEY, "1");
hidePromptWithShrink();
});
btnNext.addEventListener("click", nextStep);
btnPrev.addEventListener("click", prevStep);
btnSkipTour.addEventListener("click", endTour);
btnTutorial.addEventListener("click", startTour);
overlay.addEventListener("click", function(e) {
if (e.target === overlay) endTour();
});
window.addEventListener("resize", function() {
if (currentStep >= 0) showStep(currentStep);
});
// ── Auto-show after short delay ──
setTimeout(showPrompt, 600);
})();