/* ================================================================ 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: "오선 조정", // 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", 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 let layout = null; // ScoreLayout let systemsData = []; // parsed systems let pixelsPerTenth = 1; let currentZoom = 1; // ── Undo/Redo ──────────────────────────────────────────────── const MAX_UNDO = 50; let undoStack = []; // array of xmlDoc clones let redoStack = []; // ── Multi-page ─────────────────────────────────────────────── let pages = []; // array of { imageFile, xmlFile, imageUrl, xmlText, xmlDoc, noteInfos, systemsData, layout, detectedStaves, undoStack, redoStack, pixelsPerTenth } let currentPageIdx = 0; // ── 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": "𝅘𝅥𝅯" }; 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 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 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 for a given staff number. * MusicXML can have multiple 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 */ function parseStaffInfo(attrEl) { const stavesEl = attrEl?.querySelector("staves"); const numStaves = stavesEl ? parseInt(stavesEl.textContent) : 1; // staff-layout can appear in or 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 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 */ 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 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 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 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"); alter = alterEl ? parseFloat(alterEl.textContent) : 0; } 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"), 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 []; // Step 1: compute measure durations from XML time signatures // Parse time signatures to know how many divisions per measure const measureDurations = {}; // measureNum → duration in divisions 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"); } } // Measure duration = beats * divisions * (4 / beatType) const mNum = mEl.getAttribute("number"); measureDurations[mNum] = currentBeats * currentDivisions * (4 / currentBeatType); }); } } // Persist final time signature for next page carryBeats = currentBeats; carryBeatType = currentBeatType; // Clamp measure durations to actual note content (handles anacrusis, split measures, OMR gaps) // Always scan actual content regardless of whether time sig exists 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) { // If time sig duration exists, take the smaller of the two // If no time sig was parsed for this measure, use actual content const existing = measureDurations[mNum]; measureDurations[mNum] = existing ? Math.min(existing, maxCursor) : maxCursor; } }); } } // Step 2: pre-compute measure start positions (in divisions) from measure durations // This avoids sequential accumulation which breaks with multi-part (P1 then P2) ordering const measureStartLookup = {}; // measureNum (string) → absolute start in divisions { let accum = 0; // Get sorted measure numbers from P1 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 = []; notes.forEach((n, idx) => { if (n.isRest) return; // rests have no pitch — skip to avoid NaN frequency 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`); 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 }; } // ── Image-based staff line detection ───────────────────────── let detectedStaves = []; // array of { topLineY, bottomLineY, lineSpacing, lines: [y1..y5] } 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; // === 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; } /** 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; } // ── 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); // 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 => { // X: use the specific staff's leftX/rightX for this note const staffMaps = sysStaffXMaps[n.systemIdx]; const xMap = staffMaps ? (staffMaps[n.staff - 1] || staffMaps[0]) : null; const xTenths = layoutInfo.marginL + n.systemLeftMargin + n.measureStartX + n.defaultX; if (xMap && xMap.imgWidth > 0) { const xRatio = (xTenths - xMap.xmlLeftTenths) / xMap.xmlWidthTenths; n.px = xMap.imgLeftX + xRatio * xMap.imgWidth + userOffsetX; } else { n.px = xTenths * ppt + userOffsetX; } // Y: image-based if staves detected, else fallback to XML const sysStaves = staffSystems[n.systemIdx]; const staffData = sysStaves ? sysStaves[n.staff - 1] : 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 { let staffTopY = n.systemTopY; if (n.staff > 1) staffTopY += (n.staff - 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 { const ref = clefReferencePosition(n.clef); const noteDiatonic = diatonicIndex(n.step, n.octave); const staffPos = ref.staffPosition + (noteDiatonic - ref.diatonicIdx); let staffTopY = n.systemTopY; if (n.staff > 1) staffTopY += (n.staff - 1) * (40 + n.staffDistance); const yTenths = staffTopY + 40 - (staffPos * 5); n.py = yTenths * ppt + userOffsetY; } }); } // ── 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 const sys = systemsData[sysIdx]; // Build X map (same as computeNotePositions) const imgLeftX = sysStaves[0].leftX; const imgRightX = sysStaves[0].rightX; const xmlLeftTenths = layout.marginL + sys.leftMargin; const xmlWidthTenths = sys.cumulativeWidth; const imgWidth = imgRightX - imgLeftX; // Convert clickX (pixel) → XML tenths let xTenths; 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); } // 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"); } } } if (snapMeasureDur === 0) snapMeasureDur = snapDivisions * 4; // Grid size based on selected insertion duration const durMultiplier = DURATION_TYPES[addDurationType] || 1; const gridSize = Math.max(1, Math.round(snapDivisions * durMultiplier)); const xRatioInMeasure = Math.max(0, Math.min(1, defaultX / measureWidth)); const rawOnset = xRatioInMeasure * snapMeasureDur; const snappedOnset = Math.round(rawOnset / gridSize) * gridSize; // Build onset→px mapping from existing notes in this measure+system const onsetPxMap = []; // sorted array of { onset, px } for (const n of noteInfos) { if (n.measureNum === measureNum && n.systemIdx === sysIdx && !n.isRest) { // Avoid duplicates at same onset if (!onsetPxMap.some(e => e.onset === n.onsetDiv)) { onsetPxMap.push({ onset: n.onsetDiv, px: n.px }); } } } onsetPxMap.sort((a, b) => a.onset - b.onset); // Compute snapped pixel X from onset→px map let snappedPx; let snappedDefaultX; // Check if snappedOnset matches an existing onset exactly const exactMatch = onsetPxMap.find(e => e.onset === snappedOnset); if (exactMatch) { snappedPx = exactMatch.px; // Back-calculate defaultX for XML insertion snappedDefaultX = snappedOnset / snapMeasureDur * measureWidth; } else if (onsetPxMap.length >= 2) { // Interpolate between two nearest existing onsets let lo = null, hi = null; for (const e of onsetPxMap) { if (e.onset <= snappedOnset) lo = e; } for (const e of onsetPxMap) { if (e.onset >= snappedOnset) { hi = e; break; } } if (lo && hi && lo.onset !== hi.onset) { const t = (snappedOnset - lo.onset) / (hi.onset - lo.onset); snappedPx = lo.px + t * (hi.px - lo.px); } else if (lo) { snappedPx = lo.px; } else if (hi) { snappedPx = hi.px; } else { // Fallback to linear const snappedXRatio = snapMeasureDur > 0 ? snappedOnset / snapMeasureDur : 0; snappedDefaultX = snappedXRatio * measureWidth; const snappedXTenths = mStartTenths + snappedDefaultX; if (imgWidth > 0) { const sRatio = (snappedXTenths - xmlLeftTenths) / xmlWidthTenths; snappedPx = imgLeftX + sRatio * imgWidth + ux; } else { snappedPx = snappedXTenths * pixelsPerTenth + ux; } } if (snappedDefaultX === undefined) snappedDefaultX = snappedOnset / snapMeasureDur * measureWidth; } else { // Fewer than 2 notes: fallback to linear mapping const snappedXRatio = snapMeasureDur > 0 ? snappedOnset / snapMeasureDur : 0; snappedDefaultX = snappedXRatio * measureWidth; const snappedXTenths = mStartTenths + snappedDefaultX; if (imgWidth > 0) { const sRatio = (snappedXTenths - xmlLeftTenths) / xmlWidthTenths; snappedPx = imgLeftX + sRatio * imgWidth + ux; } else { snappedPx = snappedXTenths * pixelsPerTenth + ux; } } if (snappedDefaultX === undefined) snappedDefaultX = snappedOnset / snapMeasureDur * measureWidth; // 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) { if (!n.divisions || n.divisions <= 0) return `${n.durationDiv}div`; const beats = n.durationDiv / n.divisions; const durNames = I18N[currentLang].dur; 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 label = `M${n.measureNum} ${pitch} ${dur}`; 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"); 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 + 3); txt.classList.add("acc-label"); txt.dataset.idx = idx; txt.textContent = alterStr(n.alter); frag.appendChild(txt); } }); markerSvg.appendChild(frag); } // ── 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 each measure barline (magenta, thinner) 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); } }); }); } } function updateSingleMarker(idx) { const n = noteInfos[idx]; const circle = markerSvg.querySelector(`circle[data-idx="${idx}"]`); 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" : "")); circle.innerHTML = `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); circle.innerHTML = `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 + 3); 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.querySelector("type"); const dotEl = n.element.querySelector("dot"); const durLabel = (typeEl ? typeEl.textContent : "?") + (dotEl ? "." : ""); 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); 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 { selectNote(idx); hideChordPopup(); } } // ================================================================ // Section 5: Undo/Redo + Editing // ================================================================ /** Save current XML state for undo */ function pushUndo() { if (!xmlDoc) return; undoStack.push(xmlDoc.cloneNode(true)); if (undoStack.length > MAX_UNDO) undoStack.shift(); redoStack = []; // new edit clears redo } /** Restore XML from a snapshot and re-parse everything */ function restoreFromSnapshot(snapshot) { xmlDoc = snapshot; layout = parseScoreLayout(xmlDoc); systemsData = parseSystems(xmlDoc, layout); const nsps = systemsData.length > 0 ? systemsData[0].numStaves : 1; reassignMeasuresToSystems(systemsData, detectedStaves, nsps); const prevSelected = selectedIdx; noteInfos = parseNotes(xmlDoc, systemsData); 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 (prevSelected >= 0 && prevSelected < noteInfos.length) { selectNote(prevSelected); } else { selectNote(-1); } } function undo() { if (undoStack.length === 0) return; redoStack.push(xmlDoc.cloneNode(true)); const snapshot = undoStack.pop(); restoreFromSnapshot(snapshot); } function redo() { if (redoStack.length === 0) return; undoStack.push(xmlDoc.cloneNode(true)); const snapshot = redoStack.pop(); restoreFromSnapshot(snapshot); } // ── Pitch Editing ──────────────────────────────────────────── function raiseNote() { if (selectedIdx < 0) return; const n = noteInfos[selectedIdx]; if (n.isRest) return; 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); applyPitchToXml(n); applyAlterOnly(n); recomputeAndUpdate(selectedIdx); } function lowerNote() { if (selectedIdx < 0) return; const n = noteInfos[selectedIdx]; if (n.isRest) return; 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); applyPitchToXml(n); applyAlterOnly(n); recomputeAndUpdate(selectedIdx); } function setAccidental(alterValue) { if (selectedIdx < 0) return; const n = noteInfos[selectedIdx]; if (n.isRest) return; pushUndo(); if (n.alter === alterValue) { n.alter = 0; } else { n.alter = alterValue; } applyAccidentalToXml(n); recomputeAndUpdate(selectedIdx); } // ── Note Deletion ──────────────────────────────────────────── function deleteSelectedNote() { if (selectedIdx < 0) return; const n = noteInfos[selectedIdx]; if (n.isRest) return; // Already a rest, nothing to delete pushUndo(); const noteEl = n.element; const measureEl = noteEl.parentNode; if (n.isChord) { // Chord note: remove entirely (no duration impact) measureEl.removeChild(noteEl); // If only one chord note remains at this onset, remove its tag 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) { const chordTag = siblings[0].element.querySelector("chord"); if (chordTag) chordTag.remove(); siblings[0].isChord = false; } // Remove from noteInfos and re-render noteInfos.splice(selectedIdx, 1); // Fix indices renderMarkers(noteInfos); selectNote(Math.min(selectedIdx, noteInfos.length - 1)); } else { // Regular note: replace with rest of same duration const pitch = noteEl.querySelector("pitch"); if (pitch) noteEl.removeChild(pitch); // Add const restEl = xmlDoc.createElement("rest"); // Insert rest before for proper XML order const durEl = noteEl.querySelector("duration"); if (durEl) noteEl.insertBefore(restEl, durEl); else noteEl.appendChild(restEl); // Remove unnecessary elements ["stem", "beam", "lyric", "accidental", "notehead", "tie", "slur"].forEach(tag => { noteEl.querySelectorAll(tag).forEach(el => el.remove()); }); // Update noteInfo to rest state n.isRest = true; n.step = "R"; markModified(n); n.octave = 0; n.alter = 0; // Re-render marker as rest marker updateSingleMarker(selectedIdx); // Navigate to next note const nextIdx = selectedIdx < noteInfos.length - 1 ? selectedIdx + 1 : selectedIdx - 1; if (nextIdx >= 0) selectNote(nextIdx); } } function markModified(n) { n.modified = true; n.element.setAttribute("data-modified", "1"); } 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 only in pitch (no ) — 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 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); } // ── Phase 3A: Note Insertion ──────────────────────────────── /** * Insert a new note at the position determined by pixelToStaffPitch(). * Default duration: quarter note. Appended at end of measure (voice 1). */ function insertNoteAtPosition(info) { if (!xmlDoc || !info.measureEl) return; pushUndo(); // Find divisions for this measure let divisions = 1; const attrEl = info.measureEl.querySelector("attributes"); if (attrEl) { const divEl = attrEl.querySelector("divisions"); if (divEl) divisions = parseInt(divEl.textContent) || 1; } if (divisions === 1) { for (const n of noteInfos) { if (n.measureNum === info.measureNum) { divisions = n.divisions || 1; break; } } } const durMultiplier = DURATION_TYPES[addDurationType] || 1; const durationDiv = Math.max(1, Math.round(divisions * durMultiplier)); // 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; // Use pre-computed snapped onset from pixelToStaffPitch const targetOnset = info.snappedOnset != null ? info.snappedOnset : 0; // 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: // This positions the new note at the correct onset without disrupting existing voice structure // Build the element const noteEl = xmlDoc.createElement("note"); noteEl.setAttribute("default-x", Math.round(info.defaultX).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 voiceEl = xmlDoc.createElement("voice"); voiceEl.textContent = "1"; 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"); // Compute current cursor position at end of measure let endCursor = 0; let maxCursor = 0; for (const child of measureEl.children) { if (child.tagName === "note") { const dur = parseInt(child.querySelector("duration")?.textContent || "0"); if (!child.querySelector("chord")) endCursor += dur; if (endCursor > maxCursor) maxCursor = endCursor; } else if (child.tagName === "forward") { endCursor += parseInt(child.querySelector("duration")?.textContent || "0"); if (endCursor > maxCursor) maxCursor = endCursor; } else if (child.tagName === "backup") { endCursor -= parseInt(child.querySelector("duration")?.textContent || "0"); } } // Append: backup to 0 (from current endCursor), forward to targetOnset, then note 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); // Re-parse and render layout = parseScoreLayout(xmlDoc); systemsData = parseSystems(xmlDoc, layout); { const ns = systemsData.length > 0 ? systemsData[0].numStaves : 1; reassignMeasuresToSystems(systemsData, detectedStaves, ns); } noteInfos = parseNotes(xmlDoc, systemsData); 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 note const newIdx = noteInfos.findIndex(n => n.measureNum === info.measureNum && n.step === info.step && n.octave === info.octave && n.element === noteEl ); if (newIdx >= 0) selectNote(newIdx); else selectNote(noteInfos.length - 1); updatePageStatus(); } /** * Toggle selected note between note and rest. * Note→rest: remove pitch, add . * Rest→note: add pitch (default C4 or last used pitch), remove . */ function toggleNoteRest() { if (selectedIdx < 0 || selectedIdx >= noteInfos.length) return; pushUndo(); const n = noteInfos[selectedIdx]; const nEl = n.element; if (n.isRest) { // Rest → Note: add pitch 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); // Insert pitch before 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 { // Note → Rest: same as deleteSelectedNote's non-chord path 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); } // Re-parse to update positions layout = parseScoreLayout(xmlDoc); systemsData = parseSystems(xmlDoc, layout); { const ns = systemsData.length > 0 ? systemsData[0].numStaves : 1; reassignMeasuresToSystems(systemsData, detectedStaves, ns); } noteInfos = parseNotes(xmlDoc, systemsData); 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); // Try to re-select same position 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 element const noteEl = xmlDoc.createElement("note"); // must be first child const chordEl = xmlDoc.createElement("chord"); noteEl.appendChild(chordEl); // 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); // const durEl = xmlDoc.createElement("duration"); durEl.textContent = durText; noteEl.appendChild(durEl); // const voiceEl = xmlDoc.createElement("voice"); voiceEl.textContent = voiceText; noteEl.appendChild(voiceEl); // const typeEl = xmlDoc.createElement("type"); typeEl.textContent = typeText; noteEl.appendChild(typeEl); // const stemEl = xmlDoc.createElement("stem"); stemEl.textContent = stemText; noteEl.appendChild(stemEl); // (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); systemsData = parseSystems(xmlDoc, layout); { const ns = systemsData.length > 0 ? systemsData[0].numStaves : 1; reassignMeasuresToSystems(systemsData, detectedStaves, ns); } noteInfos = parseNotes(xmlDoc, systemsData); 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, }; // Key bindings: numpad/number → type name const KEY_TO_TYPE = { "1": "whole", "2": "half", "4": "quarter", "5": "eighth", "6": "16th", }; /** * 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.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"); } function changeDuration(newType) { if (selectedIdx < 0) return; const n = noteInfos[selectedIdx]; if (n.isRest) return; const divisions = n.divisions || 1; const multiplier = DURATION_TYPES[newType]; if (multiplier === undefined) return; 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(); const group = getChordGroup(selectedIdx); const isChord = group.length > 1; const applyAll = isChord && confirm("화음 전체의 박자를 변경할까요?\n\n[확인] = 화음 전체\n[취소] = 선택 노트만"); const targets = applyAll ? group : [n]; for (const cn of targets) { applyDurationToElement(cn.element, newDurDiv, newType); cn.durationDiv = newDurDiv; } 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; 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 group = getChordGroup(selectedIdx); const isChord = group.length > 1; const applyAll = isChord && confirm("화음 전체의 점음표를 변경할까요?\n\n[확인] = 화음 전체\n[취소] = 선택 노트만"); const targets = applyAll ? group : [n]; for (const cn of targets) { const el = cn.element; const dot = el.querySelector("dot"); if (hasDot) { if (dot) dot.remove(); } else { if (!dot) { const newDot = xmlDoc.createElement("dot"); const tEl = el.querySelector("type"); if (tEl) tEl.after(newDot); else { const dEl = el.querySelector("duration"); if (dEl) dEl.after(newDot); } } } const dEl = el.querySelector("duration"); if (dEl) dEl.textContent = newDurDiv.toString(); cn.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 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; } } } } function refreshAfterDurationChange() { recalcBackups(); layout = parseScoreLayout(xmlDoc); systemsData = parseSystems(xmlDoc, layout); { const ns = systemsData.length > 0 ? systemsData[0].numStaves : 1; reassignMeasuresToSystems(systemsData, detectedStaves, ns); } noteInfos = parseNotes(xmlDoc, systemsData); 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 ref = clefReferencePosition(n.clef); const noteDiatonic = diatonicIndex(n.step, n.octave); const staffPos = ref.staffPosition + (noteDiatonic - ref.diatonicIdx); const uy = parseFloat(offsetY.value || 0); const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1; const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys); const sysStaves = staffSystems[n.systemIdx]; const staffData = sysStaves ? sysStaves[n.staff - 1] : null; if (staffData) { const halfSpacing = staffData.lineSpacing / 2; n.py = staffData.bottomLineY - (staffPos * halfSpacing) + uy; } else { let staffTopY = n.systemTopY; if (n.staff > 1) staffTopY += (n.staff - 1) * (40 + n.staffDistance); const yTenths = staffTopY + 40 - (staffPos * 5); n.py = yTenths * pixelsPerTenth + uy; } updateSingleMarker(idx); selectNote(idx); } function navigateNote(direction) { if (noteInfos.length === 0) return; 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() { if (!xmlDoc) return; // Clone and strip data-modified attributes before export const exportDoc = xmlDoc.cloneNode(true); exportDoc.querySelectorAll("[data-modified]").forEach(el => el.removeAttribute("data-modified")); 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); } // ================================================================ // 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 } } /** 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.pixelsPerTenth = pixelsPerTenth; pg.undoStack = undoStack; pg.redoStack = redoStack; pg.selectedIdx = selectedIdx; pg.carryBeats = carryBeats; pg.carryBeatType = carryBeatType; } /** 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; 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; } // 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); renderMarkers(noteInfos); if (selectedIdx >= 0 && selectedIdx < noteInfos.length) selectNote(selectedIdx); else { selectedIdx = -1; statusSel.textContent = t("no_sel"); } 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); // 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); } systemsData = parseSystems(xmlDoc, layout); 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); } // Reassign measures to systems using image-detected staff widths (fixes OMR system break errors) reassignMeasuresToSystems(systemsData, detectedStaves, numStavesPerSys); noteInfos = parseNotes(xmlDoc, systemsData); 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); 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.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 detectMethod = detectedStaves.length > 0 ? `IMG(${detectedStaves.length} staves)` : "XML fallback"; const numParts = xmlDoc.querySelectorAll("part").length; statusTotal.textContent = `P${currentPageIdx + 1}/${pages.length} | ${noteInfos.length} notes | ${numParts} parts | ${systemsData.length} sys | ${detectMethod}`; loadStatus.textContent = `Page ${currentPageIdx + 1}: ${noteInfos.length} notes, ${detectedStaves.length} staves`; 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() { const imageFiles = Array.from(imageInput.files).sort((a, b) => a.name.localeCompare(b.name)); const xmlFiles = Array.from(xmlInput.files).sort((a, b) => a.name.localeCompare(b.name)); 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.`); } // 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], 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 = []; selectedIdx = -1; cursorSeekTime = 0; carryBeats = 4; carryBeatType = 4; currentPageIdx = 0; loadStatus.textContent = t("loading_pages")(numPages); loadBtn.disabled = true; try { await loadPage(0); } 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) { 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++) { pages.push({ imageFile: null, xmlFile: null, imageUrl: imageUrls[i], // pre-set URL xmlText: xmlTexts[i], // pre-set XML text xmlDoc: null, 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 } = event.data; if (!images || !xmls || images.length === 0 || xmls.length === 0) return; try { await loadFromData(images, xmls); } 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); markerSvg.addEventListener("mouseover", (e) => { const circle = e.target.closest("circle.marker"); if (circle && !circle.classList.contains("ghost-marker")) showMarkerTooltip(circle); }); markerSvg.addEventListener("mouseout", (e) => { const circle = e.target.closest("circle.marker"); if (circle) hideMarkerTooltip(); }); markerSvg.addEventListener("click", (e) => { if (staffAdjustMode) return; // suppress note clicks in staff adjust mode if (addMode) { // In add mode, clicks on empty area add notes (handled below) // Clicks on existing markers still select them if (e.target.closest("circle.marker") && !e.target.classList.contains("ghost-marker")) { onMarkerClick(e); } return; } onMarkerClick(e); }); // Ghost marker mousemove document.getElementById("canvas-wrapper").addEventListener("mousemove", (e) => { if (staffAdjustMode) return; if (!addMode || !xmlDoc) { 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; const info = pixelToStaffPitch(px, py); if (info) { showGhostMarker(info.snappedPx, info.snappedPy, info.step, info.octave, info.beatLabel); } else { hideGhostMarker(); } }); // Add mode click: insert note at click position document.getElementById("canvas-wrapper").addEventListener("click", (e) => { if (staffAdjustMode) return; if (!addMode || !xmlDoc) return; if (e.target.closest("circle.marker") && !e.target.classList.contains("ghost-marker")) return; if (e.shiftKey) return; // Shift+click is seek, not add const container = document.getElementById("canvas-container"); const rect = container.getBoundingClientRect(); const px = (e.clientX - rect.left) / currentZoom; const py = (e.clientY - rect.top) / currentZoom; const info = pixelToStaffPitch(px, py); if (!info || !info.measureEl) return; 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", raiseNote); document.getElementById("btn-down").addEventListener("click", lowerNote); document.getElementById("btn-dblsharp").addEventListener("click", () => setAccidental(2)); document.getElementById("btn-sharp").addEventListener("click", () => setAccidental(1)); document.getElementById("btn-flat").addEventListener("click", () => setAccidental(-1)); document.getElementById("btn-dblflat").addEventListener("click", () => setAccidental(-2)); document.getElementById("btn-natural").addEventListener("click", () => setAccidental(0)); document.getElementById("btn-delete").addEventListener("click", deleteSelectedNote); 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-dot").addEventListener("click", toggleDot); 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-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))); 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; // 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(); raiseNote(); break; case "ArrowDown": e.preventDefault(); lowerNote(); break; case "Tab": e.preventDefault(); navigateNote(e.shiftKey ? -1 : 1); break; case "Escape": selectNote(-1); hideChordPopup(); break; case " ": e.preventDefault(); togglePlayback(); break; case "#": setAccidental(1); break; case "b": setAccidental(-1); break; case "n": setAccidental(0); break; case "N": e.preventDefault(); toggleAddMode(); break; case "r": e.preventDefault(); toggleNoteRest(); break; case "A": e.preventDefault(); addChordNote(); break; case "Delete": e.preventDefault(); deleteSelectedNote(); 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": 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 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 const src = audioCtx.createBufferSource(); src.buffer = buf; const gain = audioCtx.createGain(); src.connect(gain); gain.connect(audioCtx.destination); gain.gain.setValueAtTime(vol, t); // Fade out near note end const fadeStart = t + Math.max(duration * 0.7, duration - 0.1); gain.gain.setValueAtTime(vol, fadeStart); gain.gain.exponentialRampToValueAtTime(0.001, t + duration); src.start(t); src.stop(t + duration + 0.05); } else { // Fallback: simple sine const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = "sine"; osc.frequency.value = freq; osc.connect(gain); gain.connect(audioCtx.destination); gain.gain.setValueAtTime(0.001, t); gain.gain.linearRampToValueAtTime(vol * 0.24, t + 0.02); gain.gain.exponentialRampToValueAtTime(0.001, t + duration * 0.95); osc.start(t); osc.stop(t + duration); } } 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 const startOffset = Math.max(0, evt.timeSec - elapsed); evt.noteIndices.forEach(ni => { const n = noteInfos[ni]; const freq = noteToFreq(n.step, n.octave, n.alter); playNoteSound(freq, Math.min(evt.durationSec, 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"); }); // Draw red vertical cursor line at current playback X if (indices.length > 0) { placeCursorAtNote(indices[0]); selectNote(indices[0]); const circle = markerSvg.querySelector(`circle[data-idx="${indices[0]}"]`); 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 + 100 || cx > wrapper.scrollLeft + wRect.width - 100) { wrapper.scrollTo({ left: cx - wRect.width / 3, behavior: "smooth" }); } if (cy < wrapper.scrollTop + 50 || cy > wrapper.scrollTop + wRect.height - 50) { wrapper.scrollTo({ top: cy - wRect.height / 3, 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); // Drag support let dragIdx = -1; let dragStartY = 0; let dragOrigStaffPos = 0; markerSvg.addEventListener("mousedown", (e) => { if (staffAdjustMode) return; // suppress note drag in staff adjust mode 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; } // Can't drag rests dragIdx = tmpIdx; dragStartY = e.clientY; pushUndo(); // save state before drag begins const n = noteInfos[dragIdx]; const ref = clefReferencePosition(n.clef); dragOrigStaffPos = ref.staffPosition + (diatonicIndex(n.step, n.octave) - ref.diatonicIdx); selectNote(dragIdx); e.preventDefault(); }); document.addEventListener("mousemove", (e) => { if (dragIdx < 0) return; 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 n = noteInfos[dragIdx]; 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); applyPitchToXml(n); applyAlterOnly(n); recomputeAndUpdate(dragIdx); }); document.addEventListener("mouseup", () => { dragIdx = -1; }); // ── 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; 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(); } }); // ── 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("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); })();