Spaces:
Running
Running
| /* ================================================================ | |
| OMR Corrector — corrector.js | |
| Original image + MusicXML note marker overlay & pitch editor | |
| ================================================================ */ | |
| ; | |
| // ── i18n (Korean / English) ────────────────────────────────── | |
| let currentLang = "ko"; | |
| const I18N = { | |
| ko: { | |
| images: "이미지", load: "불러오기", download: "XML 다운로드", | |
| staff_lines: "오선 표시", adjust_staves: "오선 조정", | |
| bpm: "BPM", vol: "음량", zoom: "확대", | |
| x_off: "X 오프셋", y_off: "Y 오프셋", | |
| staff_dist: "보표 간격", sys_dist: "단 간격 보정", | |
| no_sel: "선택 없음", chord_select: "음표 선택:", | |
| sc_select: "선택", sc_pitch: "음높이", sc_acc: "임시표", | |
| sc_dur: "박자", sc_dot: "점", sc_del: "삭제", | |
| sc_rest: "음표↔쉼표", sc_add: "추가 모드", sc_chord: "화음 추가", | |
| sc_play: "재생", sc_seek: "탐색", sc_dblclick: "탐색+재생", | |
| sc_page: "페이지", sc_undo: "실행취소/다시실행", | |
| staff_adjust_mode: "오선 조정", | |
| show_barlines: "마디선", barline_mode: "마디선 편집", free_glyphs: "후보 기호 표시", | |
| barline_edit_mode: "마디선 편집", bl_autodetect: "이미지 감지", | |
| bl_accept: "확정 (Enter)", bl_cancel: "취소 (Esc)", | |
| sc_barline: "마디선 편집", sc_xanchor: "X위치 조정", | |
| sc_timesig: "박자표", sc_keysig: "조표", sc_clef: "음자리표", | |
| // titles | |
| undo_title: "실행취소 (Ctrl+Z)", redo_title: "다시실행 (Ctrl+Y)", | |
| pitch_up: "음높이 올림 (↑)", pitch_down: "음높이 내림 (↓)", | |
| dblsharp: "겹올림표 (𝄪)", sharp: "올림표 (#)", flat: "내림표 (b)", | |
| dblflat: "겹내림표 (𝄫)", natural: "제자리표 (n)", delete_note: "음표 삭제 (Del)", | |
| whole: "온음표 (1)", half: "2분음표 (2)", quarter: "4분음표 (4)", | |
| eighth: "8분음표 (5)", sixteenth: "16분음표 (6)", dot: "점 토글 (.)", | |
| prev_note: "이전 음표 (Shift+Tab)", next_note: "다음 음표 (Tab)", | |
| play_note: "음표 재생", play_all: "전체 재생 (Space)", stop: "정지", | |
| prev_page: "이전 페이지", next_page: "다음 페이지", | |
| // dynamic strings | |
| loading_page: p => `${p}페이지 불러오는 중...`, | |
| loading_pages: n => `${n}페이지 불러오는 중...`, | |
| loading_piano: n => `피아노 로딩 중 (${n}개 음)...`, | |
| select_prompt: "이미지와 XML/MXL 파일을 선택 후 불러오기를 누르세요.", | |
| error_prefix: "오류: ", | |
| // duration names for tooltip | |
| dur: {4:"온음표",3:"점2분",2:"2분음표",1.5:"점4분",1:"4분음표",0.75:"점8분",0.5:"8분음표",0.375:"점16분",0.25:"16분음표",0.125:"32분음표"}, | |
| }, | |
| en: { | |
| images: "Images", load: "Load", download: "Download XML", | |
| staff_lines: "Staff Lines", adjust_staves: "Adjust Staves", | |
| bpm: "BPM", vol: "Vol", zoom: "Zoom", | |
| x_off: "X off", y_off: "Y off", | |
| staff_dist: "Staff dist", sys_dist: "Sys dist adj", | |
| no_sel: "No selection", chord_select: "Select note:", | |
| sc_select: "Select", sc_pitch: "Pitch", sc_acc: "Accidental", | |
| sc_dur: "Duration", sc_dot: "Dot", sc_del: "Delete", | |
| sc_rest: "Note↔Rest", sc_add: "Add Mode", sc_chord: "Add Chord", | |
| sc_play: "Play", sc_seek: "Seek", sc_dblclick: "Seek+Play", | |
| sc_page: "Page", sc_undo: "Undo/Redo", | |
| staff_adjust_mode: "STAFF ADJUST", | |
| show_barlines: "Barlines", barline_mode: "Barline Edit", free_glyphs: "Candidates", | |
| barline_edit_mode: "BARLINE EDIT", bl_autodetect: "Image Detect", | |
| bl_accept: "Accept (Enter)", bl_cancel: "Cancel (Esc)", | |
| sc_barline: "Barline Edit", sc_xanchor: "X Position", | |
| sc_timesig: "Time Sig", sc_keysig: "Key Sig", sc_clef: "Clef", | |
| undo_title: "Undo (Ctrl+Z)", redo_title: "Redo (Ctrl+Y)", | |
| pitch_up: "Pitch Up (↑)", pitch_down: "Pitch Down (↓)", | |
| dblsharp: "Double Sharp (𝄪)", sharp: "Sharp (#)", flat: "Flat (b)", | |
| dblflat: "Double Flat (𝄫)", natural: "Natural (n)", delete_note: "Delete Note (Del)", | |
| whole: "Whole Note (1)", half: "Half Note (2)", quarter: "Quarter Note (4)", | |
| eighth: "Eighth Note (5)", sixteenth: "16th Note (6)", dot: "Dot Toggle (.)", | |
| prev_note: "Previous Note (Shift+Tab)", next_note: "Next Note (Tab)", | |
| play_note: "Play Note Sound", play_all: "Play All (Space)", stop: "Stop", | |
| prev_page: "Previous Page", next_page: "Next Page", | |
| loading_page: p => `Loading page ${p}...`, | |
| loading_pages: n => `Loading ${n} pages...`, | |
| loading_piano: n => `Loading piano (${n} notes)...`, | |
| select_prompt: "Select image(s) and XML/MXL file(s), then click Load.", | |
| error_prefix: "Error: ", | |
| dur: {4:"whole",3:"half.",2:"half",1.5:"quarter.",1:"quarter",0.75:"8th.",0.5:"8th",0.375:"16th.",0.25:"16th",0.125:"32nd"}, | |
| } | |
| }; | |
| function t(key) { return I18N[currentLang][key] || I18N.en[key] || key; } | |
| function applyI18n() { | |
| document.querySelectorAll("[data-i18n]").forEach(el => { | |
| const key = el.dataset.i18n; | |
| const val = I18N[currentLang][key]; | |
| if (val && typeof val === "string") el.textContent = val; | |
| }); | |
| document.querySelectorAll("[data-i18n-title]").forEach(el => { | |
| const key = el.dataset.i18nTitle; | |
| const val = I18N[currentLang][key]; | |
| if (val && typeof val === "string") el.title = val; | |
| }); | |
| // Update lang button label | |
| const langBtn = document.getElementById("btn-lang"); | |
| if (langBtn) langBtn.textContent = currentLang === "ko" ? "EN" : "한"; | |
| } | |
| function toggleLang() { | |
| currentLang = currentLang === "ko" ? "en" : "ko"; | |
| applyI18n(); | |
| // Re-render status if selection exists | |
| if (selectedIdx >= 0) selectNote(selectedIdx); | |
| else statusSel.textContent = t("no_sel"); | |
| } | |
| // ── Global State ────────────────────────────────────────────── | |
| let xmlDoc = null; // parsed MusicXML DOM | |
| let noteInfos = []; // array of NoteInfo objects | |
| let selectedIdx = -1; // index into noteInfos (primary selection) | |
| let scoreSelectedIndices = new Set(); // multi-select for score markers | |
| let _scoreCtrlHandled = false; // flag to prevent double-toggle on ctrl+click | |
| let layout = null; // ScoreLayout | |
| let systemsData = []; // parsed systems | |
| let pixelsPerTenth = 1; | |
| let currentZoom = 0.5; | |
| // ── Undo/Redo ──────────────────────────────────────────────── | |
| const MAX_UNDO = 50; | |
| let undoStack = []; // array of xmlDoc clones | |
| let redoStack = []; | |
| // ── Multi-page ─────────────────────────────────────────────── | |
| let pages = []; // array of { imageFile, xmlFile, omrFile, imageUrl, xmlText, xmlDoc, noteInfos, systemsData, layout, detectedStaves, undoStack, redoStack, pixelsPerTenth, omrEdits } | |
| let currentPageIdx = 0; | |
| // ── OMR Edit Tracking ─────────────────────────────────────── | |
| // Each page accumulates omrEdits[]. "Apply to OMR" sends them to server. | |
| let omrEdits = []; // current page's pending edits | |
| let freeGlyphData = []; // [{glyphId, x, y, w, h, systemIdx}, ...] | |
| let freeGlyphsVisible = false; | |
| // ── Cross-page time signature carry-over ───────────────────── | |
| // Persisted across page loads so page 2+ inherits the last time sig from previous pages | |
| let carryBeats = 4, carryBeatType = 4; | |
| // ── Add Mode (Phase 3A) ───────────────────────────────────── | |
| let addMode = false; | |
| let ghostMarker = null; // SVG circle for ghost preview | |
| let ghostLabel = null; // SVG text for pitch label | |
| let addDurationType = "quarter"; // pending insertion duration type | |
| // ── Volume ────────────────────────────────────────────────── | |
| let masterVolume = 0.5; // 0.0 ~ 1.0 | |
| const DUR_SYMBOLS = { "whole": "𝅝", "half": "𝅗𝅥", "quarter": "♩", "eighth": "♪", "16th": "𝅘𝅥𝅯", "32nd": "𝅘𝅥𝅰" }; | |
| const STEPS = ["C","D","E","F","G","A","B"]; | |
| const STEP_INDEX = { C:0, D:1, E:2, F:3, G:4, A:5, B:6 }; | |
| function alterStr(a) { return a===2?"x":a===1?"#":a===-1?"b":a===-2?"bb":""; } | |
| // Key signature: fifths → map of step → alter | |
| // Sharp order: F C G D A E B, Flat order: B E A D G C F | |
| function keyAlterFromFifths(fifths) { | |
| const map = {}; | |
| if (fifths > 0) { | |
| const sharpOrder = ["F","C","G","D","A","E","B"]; | |
| for (let i = 0; i < Math.min(fifths, 7); i++) map[sharpOrder[i]] = 1; | |
| } else if (fifths < 0) { | |
| const flatOrder = ["B","E","A","D","G","C","F"]; | |
| for (let i = 0; i < Math.min(-fifths, 7); i++) map[flatOrder[i]] = -1; | |
| } | |
| return map; | |
| } | |
| // Apply key signature alter to a note's step (only if note has no explicit accidental override) | |
| function keyAlterForStep(step, fifths) { | |
| const map = keyAlterFromFifths(fifths); | |
| return map[step] || 0; | |
| } | |
| const VOICE_COLORS = { 1:"voice1", 2:"voice2", 3:"voice3", 4:"voice4" }; | |
| // For multi-part: part 0 uses voice1-4, part 1 uses voice3-4 + voice1-2 (cycle) | |
| function getMarkerClass(partIndex, voice) { | |
| if (partIndex === 0) return VOICE_COLORS[voice] || "voice1"; | |
| // Part 1+: shift colors so parts are visually distinct | |
| const shifted = ((voice - 1 + partIndex * 2) % 4) + 1; | |
| return VOICE_COLORS[shifted] || "voice1"; | |
| } | |
| // ── DOM refs ────────────────────────────────────────────────── | |
| const imageInput = document.getElementById("image-input"); | |
| const xmlInput = document.getElementById("xml-input"); | |
| const omrInput = document.getElementById("omr-input"); | |
| const dpiInput = document.getElementById("dpi-input"); | |
| const loadBtn = document.getElementById("load-btn"); | |
| const loadStatus = document.getElementById("load-status"); | |
| const scoreImage = document.getElementById("score-image"); | |
| const markerSvg = document.getElementById("marker-svg"); | |
| const statusSel = document.getElementById("status-selection"); | |
| const statusTotal = document.getElementById("status-total"); | |
| const zoomSlider = document.getElementById("zoom-slider"); | |
| const zoomLabel = document.getElementById("zoom-label"); | |
| const offsetX = document.getElementById("offset-x"); | |
| const offsetY = document.getElementById("offset-y"); | |
| const chordPopup = document.getElementById("chord-popup"); | |
| const chordList = document.getElementById("chord-popup-list"); | |
| const volSlider = document.getElementById("vol-slider"); | |
| const volLabel = document.getElementById("vol-label"); | |
| if (volSlider) { | |
| volSlider.addEventListener("input", () => { | |
| masterVolume = parseInt(volSlider.value) / 100; | |
| if (volLabel) volLabel.textContent = volSlider.value + "%"; | |
| }); | |
| } | |
| // ================================================================ | |
| // Section 1: XML Parsing | |
| // ================================================================ | |
| function parseScoreLayout(doc) { | |
| const defaults = doc.querySelector("defaults"); | |
| const scaling = defaults?.querySelector("scaling"); | |
| const mm = parseFloat(scaling?.querySelector("millimeters")?.textContent || "7.112"); | |
| const tpu = parseFloat(scaling?.querySelector("tenths")?.textContent || "40"); | |
| const pl = defaults?.querySelector("page-layout"); | |
| const pageH = parseFloat(pl?.querySelector("page-height")?.textContent || "1800"); | |
| const pageW = parseFloat(pl?.querySelector("page-width")?.textContent || "1300"); | |
| const pm = pl?.querySelector("page-margins"); | |
| const marginL = parseFloat(pm?.querySelector("left-margin")?.textContent || "80"); | |
| const marginR = parseFloat(pm?.querySelector("right-margin")?.textContent || "80"); | |
| const marginT = parseFloat(pm?.querySelector("top-margin")?.textContent || "80"); | |
| // Parse default staff-distance from <defaults><staff-layout> | |
| let defaultStaffDistance = 65; // MusicXML default | |
| const defaultStaffLayouts = defaults?.querySelectorAll("staff-layout"); | |
| if (defaultStaffLayouts) { | |
| defaultStaffLayouts.forEach(sl => { | |
| const sd = sl.querySelector("staff-distance"); | |
| if (sd) defaultStaffDistance = parseFloat(sd.textContent); | |
| }); | |
| } | |
| return { mm, tpu, pageH, pageW, marginL, marginR, marginT, defaultStaffDistance }; | |
| } | |
| /** Parse clef from <attributes> for a given staff number. | |
| * MusicXML can have multiple <clef number="N"> for multi-staff. */ | |
| function parseClefs(attrEl) { | |
| const clefs = {}; | |
| if (!attrEl) return clefs; | |
| const clefEls = attrEl.querySelectorAll("clef"); | |
| clefEls.forEach(clefEl => { | |
| const num = parseInt(clefEl.getAttribute("number") || "1"); | |
| const sign = clefEl.querySelector("sign")?.textContent || "G"; | |
| const line = parseInt(clefEl.querySelector("line")?.textContent || "2"); | |
| const oc = parseInt(clefEl.querySelector("clef-octave-change")?.textContent || "0"); | |
| clefs[num] = { sign, line, octaveChange: oc }; | |
| }); | |
| // If only one clef without number attribute | |
| if (Object.keys(clefs).length === 0 && attrEl.querySelector("clef")) { | |
| const clefEl = attrEl.querySelector("clef"); | |
| clefs[1] = { | |
| sign: clefEl.querySelector("sign")?.textContent || "G", | |
| line: parseInt(clefEl.querySelector("line")?.textContent || "2"), | |
| octaveChange: parseInt(clefEl.querySelector("clef-octave-change")?.textContent || "0"), | |
| }; | |
| } | |
| return clefs; | |
| } | |
| /** Parse number of staves and staff-distance from <attributes> */ | |
| function parseStaffInfo(attrEl) { | |
| const stavesEl = attrEl?.querySelector("staves"); | |
| const numStaves = stavesEl ? parseInt(stavesEl.textContent) : 1; | |
| // staff-layout can appear in <attributes> or <print> | |
| let staffDistance = 65; // default distance in tenths between staves | |
| const staffLayoutEl = attrEl?.querySelector("staff-layout"); | |
| if (staffLayoutEl) { | |
| const sd = staffLayoutEl.querySelector("staff-distance"); | |
| if (sd) staffDistance = parseFloat(sd.textContent); | |
| } | |
| return { numStaves, staffDistance }; | |
| } | |
| /** Parse staff-distance from <print> element */ | |
| function parseStaffDistanceFromPrint(printEl) { | |
| if (!printEl) return null; | |
| const staffLayoutEl = printEl.querySelector("staff-layout"); | |
| if (!staffLayoutEl) return null; | |
| const sd = staffLayoutEl.querySelector("staff-distance"); | |
| return sd ? parseFloat(sd.textContent) : null; | |
| } | |
| /** Parse all systems (system breaks) and their measures with widths */ | |
| // ── .omr-based system parser ───────────────────────────────────── | |
| function parseSystemsFromOmr(omrData) { | |
| if (!omrData || !omrData.systems || omrData.systems.length === 0) return null; | |
| const systems = []; | |
| let globalMeasureNum = 1; | |
| for (let si = 0; si < omrData.systems.length; si++) { | |
| const sys = omrData.systems[si]; | |
| const staves = sys.staves || []; | |
| const numStaves = staves.length; | |
| // topY: first staff's first line y1 | |
| let topY = 0, leftX = 0, rightX = 0; | |
| if (staves.length > 0 && staves[0].lines && staves[0].lines.length > 0) { | |
| topY = staves[0].lines[0].y1; | |
| leftX = staves[0].left; | |
| rightX = staves[0].right; | |
| } | |
| // staffDistance: gap between last line of staff 0 and first line of staff 1 (pixels) | |
| let staffDistance = 65; | |
| if (numStaves >= 2 && staves[0].lines.length >= 5 && staves[1].lines.length >= 1) { | |
| staffDistance = staves[1].lines[0].y1 - staves[0].lines[4].y1; | |
| } | |
| // Measures from stacks | |
| const measures = []; | |
| for (const stack of (sys.stacks || [])) { | |
| const left = stack.left; | |
| const right = stack.right; | |
| measures.push({ | |
| number: String(globalMeasureNum++), | |
| left, | |
| right, | |
| width: right - left, | |
| startX: left - leftX, | |
| systemIdx: si, | |
| duration: stack.duration || "1", | |
| element: null, // no MusicXML element | |
| }); | |
| } | |
| systems.push({ | |
| index: si, | |
| topY, | |
| leftX, | |
| rightX, | |
| leftMargin: 0, // pixel 기반이라 margin 불필요 | |
| measures, | |
| cumulativeWidth: rightX - leftX, | |
| numStaves, | |
| staffDistance, | |
| staves, // raw staff data for coordinate mapping | |
| _omrBased: true, // flag to distinguish from XML-based | |
| }); | |
| } | |
| return systems; | |
| } | |
| /** | |
| * Convert .omr staff-relative diatonic pitch to MusicXML step+octave. | |
| * pitch 0 = middle line of staff. | |
| * Audiveris convention: positive = above middle line, negative = below. | |
| * (stepOctaveToOmrPitch returns midDiatonic - noteDiatonic, so positive = below) | |
| * Actually checking: stepOctaveToOmrPitch returns midDiatonic - diatonic → positive means note is BELOW mid. | |
| * So noteDiatonic = midDiatonic - omrPitch. | |
| */ | |
| function omrPitchToStepOctave(pitch, clef) { | |
| if (!clef) clef = { sign: "G", line: 2, octaveChange: 0 }; | |
| const ref = clefReferencePosition(clef); | |
| // ref.diatonicIdx = diatonicIndex of the note on staff line ref.staffPosition | |
| // staffPosition 0 = bottom line, 2 = line 2, etc. | |
| // Middle line = staff position 4 (3rd line from bottom, 0-indexed: line positions 0,2,4,6,8) | |
| // pitch 0 = middle line (staff position 4) | |
| // pitch +1 = one diatonic step above middle → staffPosition 5 | |
| // So: noteDiatonic = ref.diatonicIdx + (pitch - ref.staffPosition + 4) | |
| // Wait, let me derive from stepOctaveToOmrPitch: | |
| // omrPitch = midDiatonic - noteDiatonic | |
| // where midDiatonic is the note on middle line (staffPos 4) | |
| // ref gives us: ref.diatonicIdx is on ref.staffPosition | |
| // midDiatonic = ref.diatonicIdx + (4 - ref.staffPosition) | |
| // So: noteDiatonic = midDiatonic - omrPitch = ref.diatonicIdx + (4 - ref.staffPosition) - pitch | |
| // But Audiveris pitch sign: in the .omr file, head pitch is "staff-relative diatonic" | |
| // From Audiveris source: pitch 0 = middle line, positive = above, negative = below | |
| // But stepOctaveToOmrPitch returns midDiatonic - diatonic = positive when note is BELOW middle | |
| // The .omr file stores what Audiveris computes, which uses the OPPOSITE convention | |
| // Let me verify: secret_p01 head pitch=7.0 with onset=7/8 in measure 1 | |
| // This is the first actual note. In "말할 수 없는 비밀", first note is likely high. | |
| // pitch=7 with treble clef: midLine = B4 | |
| // If Audiveris convention: pitch>0 = above → diatonic = B4_diatonic + 7 = 41 + 7 = 48 | |
| // 48 = octave*7+step → oct=6, step=6=B → B6? That's very high. | |
| // If opposite: diatonic = 41 - 7 = 34 → oct=4, step=6=B → B4? That's middle line itself for treble. | |
| // Hmm, let me re-check. Actually: | |
| // stepOctaveToOmrPitch: return midDiatonic - diatonic | |
| // For B4 on treble: midDiatonic=41, diatonic=41 → returns 0. So B4=pitch 0. ✓ | |
| // For C5: diatonic=42, returns 41-42=-1. So pitch=-1 means one above. ✓ | |
| // For A4: diatonic=40, returns 41-40=1. So pitch=1 means one below. ✓ | |
| // So positive pitch = below middle line. Negative pitch = above. | |
| // .omr stores this same convention (head pitch="7" means 7 diatonic steps below middle line) | |
| // Therefore: noteDiatonic = midDiatonic - omrPitch | |
| const midDiatonic = ref.diatonicIdx + (4 - ref.staffPosition); | |
| const noteDiatonic = midDiatonic - pitch; | |
| const octave = Math.floor(noteDiatonic / 7); | |
| const stepIdx = ((noteDiatonic % 7) + 7) % 7; // handle negative modulo | |
| return { step: STEPS[stepIdx], octave }; | |
| } | |
| /** | |
| * Parse rational string "3/8" → float 0.375. Also handles "0" and integers. | |
| */ | |
| function parseRational(s) { | |
| if (!s || s === "0") return 0; | |
| const parts = String(s).split("/"); | |
| if (parts.length === 2) return parseInt(parts[0]) / parseInt(parts[1]); | |
| return parseFloat(s) || 0; | |
| } | |
| /** Convert float duration to closest rational string (e.g. 0.375 → "3/8") */ | |
| function gcd(a, b) { while (b) { [a, b] = [b, a % b]; } return a; } | |
| function durationFloatToRational(f) { | |
| if (Math.abs(f) < 0.001) return "0"; | |
| const table = [ | |
| [1, "1/1"], [0.75, "3/4"], [0.5, "1/2"], [0.375, "3/8"], | |
| [1/3, "1/3"], [0.25, "1/4"], [0.1875, "3/16"], | |
| [1/6, "1/6"], [0.125, "1/8"], [0.09375, "3/32"], | |
| [1/12, "1/12"], [0.0625, "1/16"], [1/24, "1/24"], [0.03125, "1/32"], | |
| ]; | |
| for (const [val, str] of table) { | |
| if (Math.abs(f - val) < 0.001) return str; | |
| } | |
| // Fallback: n/96 — LCM(48,32), supports both triplets and 32nd notes exactly | |
| const n = Math.round(f * 96); | |
| if (n <= 0) return "0"; | |
| // Simplify fraction | |
| const g = gcd(n, 96); | |
| return `${n / g}/${96 / g}`; | |
| } | |
| /** | |
| * Build noteInfos entirely from .omr data (no MusicXML dependency). | |
| * Each noteInfo has the same shape as parseNotes() output for compatibility. | |
| */ | |
| function parseNotesFromOmr(omrData, systemsData) { | |
| if (!omrData || !omrData.systems) return null; | |
| const notes = []; | |
| let globalMeasureBase = 1; | |
| for (let si = 0; si < omrData.systems.length; si++) { | |
| const sys = omrData.systems[si]; | |
| const sysInfo = systemsData[si]; | |
| if (!sysInfo) continue; | |
| // Initialize clef/keySig state from this system's inters | |
| const currentClefs = {}; // staffId → { sign, line, octaveChange } | |
| let currentFifths = 0; | |
| // Set default clefs from .omr clef inters (sorted by X to get first occurrence) | |
| const sortedClefs = [...(sys.clefs || [])].sort((a, b) => { | |
| const ax = a.bounds ? a.bounds.x : 0; | |
| const bx = b.bounds ? b.bounds.x : 0; | |
| return ax - bx; | |
| }); | |
| for (const c of sortedClefs) { | |
| if (!currentClefs[c.staff]) { | |
| // Convert .omr kind (TREBLE/BASS/ALTO/TENOR) → MusicXML sign (G/F/C) | |
| const kind = (c.kind || "").toUpperCase(); | |
| let sign, line; | |
| if (kind === "TREBLE" || kind === "G") { sign = "G"; line = 2; } | |
| else if (kind === "BASS" || kind === "F") { sign = "F"; line = 4; } | |
| else if (kind === "ALTO" || kind === "C") { sign = "C"; line = 3; } | |
| else if (kind === "TENOR") { sign = "C"; line = 4; } | |
| else { sign = "G"; line = 2; } // fallback to treble | |
| currentClefs[c.staff] = { sign, line, octaveChange: 0 }; | |
| } | |
| } | |
| // Key signature | |
| if (sys.keySigs && sys.keySigs.length > 0) { | |
| currentFifths = parseInt(sys.keySigs[0].fifths) || 0; | |
| } | |
| // sys.measures may contain multiple parts' measures (e.g., piano = 2 parts) | |
| // Each part has N measures corresponding to N stacks. Measures from different | |
| // parts at the same stack position share the same global measure number. | |
| const numStacks = (sys.stacks || []).length || 1; | |
| const measuresPerPart = numStacks; // each part has one measure per stack | |
| const numParts = Math.max(1, Math.ceil(sys.measures.length / measuresPerPart)); | |
| for (let mi = 0; mi < sys.measures.length; mi++) { | |
| const meas = sys.measures[mi]; | |
| const stackIdx = mi % measuresPerPart; | |
| const partIdx = Math.floor(mi / measuresPerPart); | |
| const measNum = String(globalMeasureBase + stackIdx); | |
| const measInfo = sysInfo.measures[stackIdx]; | |
| // Process headChords | |
| for (const hc of meas.headChords) { | |
| const isMultiHead = hc.heads.length > 1; | |
| for (let hi = 0; hi < hc.heads.length; hi++) { | |
| const head = hc.heads[hi]; | |
| const clef = currentClefs[head.staff] || currentClefs[Object.keys(currentClefs)[0]] || { sign: "G", line: 2, octaveChange: 0 }; | |
| const { step, octave } = omrPitchToStepOctave(head.pitch, clef); | |
| // Key signature: apply implied alter when no explicit accidental | |
| const alter = head.hasAccidental ? (head.alter || 0) : keyAlterForStep(step, currentFifths); | |
| const durFloat = parseRational(hc.duration); | |
| const onsetFloat = parseRational(hc.timeOffset); | |
| notes.push({ | |
| element: null, // no MusicXML DOM element | |
| measureNum: measNum, | |
| step, octave, alter, | |
| fifths: currentFifths, | |
| voice: hc.voice || 1, | |
| staff: head.staff, | |
| defaultX: head.bounds ? head.bounds.x : 0, | |
| partIndex: partIdx, | |
| systemIdx: si, | |
| measureStartX: measInfo ? measInfo.startX : 0, | |
| systemLeftMargin: sysInfo.leftX || 0, | |
| systemTopY: sysInfo.topY || 0, | |
| staffDistance: sysInfo.staffDistance || 0, | |
| clef: { ...clef }, | |
| isChord: hi > 0, // 2nd+ head in same chord | |
| isRest: false, | |
| divisions: 1, // rational → use 1 as base | |
| durationDiv: durFloat, // float (e.g. 0.125 for 1/8) | |
| onsetDiv: onsetFloat, | |
| measureIdx: measNum, | |
| modified: false, | |
| omrX: head.bounds ? head.bounds.x + head.bounds.w / 2 : null, | |
| omrY: head.bounds ? head.bounds.y + head.bounds.h / 2 : null, | |
| grade: head.grade, | |
| omrChordId: hc.chordId, | |
| omrHeadId: head.headId, | |
| px: 0, py: 0, | |
| // New .omr-specific fields | |
| durationRational: hc.duration, | |
| timeOffsetRational: hc.timeOffset, | |
| headShape: head.shape, | |
| tupletGroupId: hc.tupletGroupId || null, | |
| _omrBased: true, | |
| _omrMeasureIdx: mi, | |
| _isHeadChord: true, | |
| orphan: !!hc.orphan, | |
| }); | |
| } | |
| } | |
| // Process restChords | |
| for (const rc of meas.restChords) { | |
| const staff = rc.staff || (sys.staves.length > 0 ? sys.staves[0].staffId : 1); | |
| const clef = currentClefs[staff] || currentClefs[Object.keys(currentClefs)[0]] || { sign: "G", line: 2, octaveChange: 0 }; | |
| const durFloat = parseRational(rc.duration); | |
| const onsetFloat = parseRational(rc.timeOffset); | |
| notes.push({ | |
| element: null, | |
| measureNum: measNum, | |
| step: "R", octave: 0, alter: 0, | |
| fifths: currentFifths, | |
| voice: rc.voice || 1, | |
| staff, | |
| defaultX: rc.bounds ? rc.bounds.x : 0, | |
| partIndex: partIdx, | |
| systemIdx: si, | |
| measureStartX: measInfo ? measInfo.startX : 0, | |
| systemLeftMargin: sysInfo.leftX || 0, | |
| systemTopY: sysInfo.topY || 0, | |
| staffDistance: sysInfo.staffDistance || 0, | |
| clef: { ...clef }, | |
| isChord: false, | |
| isRest: true, | |
| divisions: 1, | |
| durationDiv: durFloat, | |
| onsetDiv: onsetFloat, | |
| measureIdx: measNum, | |
| modified: false, | |
| omrX: rc.bounds ? rc.bounds.x + rc.bounds.w / 2 : null, | |
| omrY: rc.bounds ? rc.bounds.y + rc.bounds.h / 2 : null, | |
| grade: rc.chordGrade, | |
| omrChordId: rc.chordId, | |
| omrHeadId: null, | |
| px: 0, py: 0, | |
| durationRational: rc.duration, | |
| timeOffsetRational: rc.timeOffset, | |
| restShape: rc.restShape, | |
| _omrBased: true, | |
| _omrMeasureIdx: mi, | |
| _isHeadChord: false, | |
| orphan: !!rc.orphan, | |
| }); | |
| } | |
| } | |
| globalMeasureBase += numStacks; | |
| } | |
| // ── Voice merge: within each staff+measure, merge non-overlapping voices ── | |
| const byStaffMeas = {}; | |
| for (let i = 0; i < notes.length; i++) { | |
| const key = `${notes[i].staff}_${notes[i].measureNum}`; | |
| if (!byStaffMeas[key]) byStaffMeas[key] = []; | |
| byStaffMeas[key].push(i); | |
| } | |
| for (const indices of Object.values(byStaffMeas)) { | |
| // Group by voice | |
| const voiceGroups = {}; | |
| for (const i of indices) { | |
| const v = notes[i].voice || 1; | |
| if (!voiceGroups[v]) voiceGroups[v] = []; | |
| voiceGroups[v].push(i); | |
| } | |
| const voiceKeys = Object.keys(voiceGroups).sort((a, b) => parseInt(a) - parseInt(b)); | |
| if (voiceKeys.length <= 1) continue; | |
| // Try merging each voice into the first non-overlapping voice | |
| const primaryVoice = parseInt(voiceKeys[0]); | |
| for (let vi = 1; vi < voiceKeys.length; vi++) { | |
| const srcVoice = parseInt(voiceKeys[vi]); | |
| const srcIndices = voiceGroups[srcVoice]; | |
| // Check overlap between primaryVoice notes and srcVoice notes | |
| const primaryIndices = voiceGroups[primaryVoice]; | |
| let overlaps = false; | |
| for (const si of srcIndices) { | |
| const sStart = notes[si].onsetDiv || 0; | |
| const sEnd = sStart + (notes[si].durationDiv || 0); | |
| for (const pi of primaryIndices) { | |
| const pStart = notes[pi].onsetDiv || 0; | |
| const pEnd = pStart + (notes[pi].durationDiv || 0); | |
| if (sStart < pEnd && sEnd > pStart) { overlaps = true; break; } | |
| } | |
| if (overlaps) break; | |
| } | |
| if (!overlaps) { | |
| // Merge: change voice in noteInfos AND omrData chords | |
| for (const si of srcIndices) { | |
| notes[si].voice = primaryVoice; | |
| // Update omrData chord voice | |
| const n = notes[si]; | |
| const sysIdx = n.systemIdx; | |
| const omrMi = n._omrMeasureIdx; | |
| if (omrData.systems[sysIdx] && omrData.systems[sysIdx].measures[omrMi]) { | |
| const m = omrData.systems[sysIdx].measures[omrMi]; | |
| const hc = m.headChords.find(c => c.chordId === n.omrChordId); | |
| if (hc) hc.voice = primaryVoice; | |
| const rc = m.restChords.find(c => c.chordId === n.omrChordId); | |
| if (rc) rc.voice = primaryVoice; | |
| } | |
| } | |
| voiceGroups[primaryVoice].push(...srcIndices); | |
| } | |
| } | |
| } | |
| return notes; | |
| } | |
| function parseSystems(doc, layoutInfo) { | |
| const parts = doc.querySelectorAll("part"); | |
| if (parts.length === 0) return []; | |
| const part = parts[0]; // Use first part for layout (measures/widths are same across parts) | |
| // Count total staves across ALL parts (e.g., Piano1=2 + Piano2=2 = 4) | |
| let totalStavesPerSystem = 0; | |
| parts.forEach(p => { | |
| const firstAttr = p.querySelector("measure > attributes"); | |
| if (firstAttr) { | |
| const si = parseStaffInfo(firstAttr); | |
| totalStavesPerSystem += si.numStaves; | |
| } else { | |
| totalStavesPerSystem += 1; | |
| } | |
| }); | |
| const measures = part.querySelectorAll("measure"); | |
| const systems = []; | |
| let currentSystem = null; | |
| let numStaves = totalStavesPerSystem; // total across ALL parts | |
| let staffDistance = layoutInfo.defaultStaffDistance; // from <defaults> | |
| measures.forEach((mEl, mIdx) => { | |
| const printEl = mEl.querySelector("print"); | |
| const attrEl = mEl.querySelector("attributes"); | |
| const isNewSystem = mIdx === 0 | |
| || printEl?.getAttribute("new-system") === "yes" | |
| || printEl?.getAttribute("new-page") === "yes"; | |
| // Update staff distance from attributes (but NOT numStaves — that's the total across parts) | |
| if (attrEl) { | |
| if (attrEl.querySelector("staff-layout")) { | |
| const si = parseStaffInfo(attrEl); | |
| staffDistance = si.staffDistance; | |
| } | |
| } | |
| // Check staff-distance in print element too | |
| const printSD = parseStaffDistanceFromPrint(printEl); | |
| if (printSD !== null) staffDistance = printSD; | |
| if (isNewSystem) { | |
| const sysLayout = printEl?.querySelector("system-layout"); | |
| const sysMar = sysLayout?.querySelector("system-margins"); | |
| const sysLeftM = parseFloat(sysMar?.querySelector("left-margin")?.textContent || "0"); | |
| const topSysDist = parseFloat(sysLayout?.querySelector("top-system-distance")?.textContent || "0"); | |
| const sysDist = parseFloat(sysLayout?.querySelector("system-distance")?.textContent || "0"); | |
| let topY; | |
| if (systems.length === 0) { | |
| // First system | |
| topY = layoutInfo.marginT + topSysDist; | |
| } else { | |
| const prev = systems[systems.length - 1]; | |
| // Previous system total height = staff1(40) + staffDistance + staff2(40) + ... for each staff | |
| const prevTotalHeight = 40 + (prev.numStaves - 1) * (prev.staffDistance + 40); | |
| topY = prev.topY + prevTotalHeight + sysDist; | |
| } | |
| currentSystem = { | |
| index: systems.length, | |
| topY, | |
| leftMargin: sysLeftM, | |
| measures: [], | |
| cumulativeWidth: 0, | |
| numStaves, | |
| staffDistance, | |
| _origSysDist: sysDist, | |
| }; | |
| systems.push(currentSystem); | |
| } | |
| // Update current system's staff info if changed mid-system | |
| if (currentSystem) { | |
| currentSystem.numStaves = numStaves; | |
| currentSystem.staffDistance = staffDistance; | |
| } | |
| const width = parseFloat(mEl.getAttribute("width") || "200"); | |
| const measureStartX = currentSystem.cumulativeWidth; | |
| currentSystem.measures.push({ | |
| element: mEl, | |
| number: mEl.getAttribute("number"), | |
| width, | |
| startX: measureStartX, | |
| systemIdx: currentSystem.index, | |
| }); | |
| currentSystem.cumulativeWidth += width; | |
| }); | |
| // Validate: if calculated system positions overflow page-height, | |
| // the numStaves is likely inflated by OMR misdetection. | |
| // Reduce numStaves until systems fit within page bounds. | |
| if (systems.length >= 2 && layoutInfo.pageH > 0) { | |
| const lastSys = systems[systems.length - 1]; | |
| const lastHeight = 40 + (lastSys.numStaves - 1) * (lastSys.staffDistance + 40); | |
| const totalUsed = lastSys.topY + lastHeight; | |
| if (totalUsed > layoutInfo.pageH) { | |
| // Try reducing numStaves until it fits | |
| for (let tryStaves = totalStavesPerSystem - 1; tryStaves >= 1; tryStaves--) { | |
| // Recalculate all topY with reduced numStaves | |
| let fits = true; | |
| let testTopY = systems[0].topY; // first system stays | |
| for (let i = 1; i < systems.length; i++) { | |
| const prev = systems[i - 1]; | |
| const prevH = 40 + (tryStaves - 1) * (prev.staffDistance + 40); | |
| testTopY = testTopY + prevH + systems[i]._origSysDist; | |
| } | |
| const testLastH = 40 + (tryStaves - 1) * (lastSys.staffDistance + 40); | |
| if (testTopY + testLastH <= layoutInfo.pageH) { | |
| // This numStaves fits — apply it | |
| console.log(`Staff count adjusted: ${totalStavesPerSystem} → ${tryStaves} (page overflow fix)`); | |
| totalStavesPerSystem = tryStaves; | |
| // Recalculate all system topY and numStaves | |
| for (let i = 0; i < systems.length; i++) { | |
| systems[i].numStaves = tryStaves; | |
| if (i > 0) { | |
| const prev = systems[i - 1]; | |
| const prevH = 40 + (prev.numStaves - 1) * (prev.staffDistance + 40); | |
| systems[i].topY = prev.topY + prevH + systems[i]._origSysDist; | |
| } | |
| } | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| return systems; | |
| } | |
| /** | |
| * Reassign measures to systems based on image-detected staff pixel widths. | |
| * The XML <print new-system> breaks can be wrong (OMR errors), so we use the | |
| * actual staff pixel widths to determine how many measures belong in each system. | |
| * | |
| * Algorithm: each system's share of total measures (by tenths width) should be | |
| * proportional to its share of total image pixel width. | |
| */ | |
| function reassignMeasuresToSystems(systems, staves, numStavesPerSys) { | |
| if (systems.length < 2 || staves.length < numStavesPerSys) return; | |
| const staffSystems = mapStavesToSystems(staves, numStavesPerSys); | |
| if (staffSystems.length !== systems.length) { | |
| console.log(`reassignMeasures: system count mismatch XML=${systems.length} IMG=${staffSystems.length}, skipping`); | |
| return; | |
| } | |
| // Get image pixel width per system (from first staff of each system) | |
| const sysPixelWidths = staffSystems.map(ss => { | |
| if (!ss || ss.length === 0 || !ss[0].leftX) return 0; | |
| return ss[0].rightX - ss[0].leftX; | |
| }); | |
| if (sysPixelWidths.some(w => w <= 0)) return; // missing X data | |
| // Collect all measures in order | |
| const allMeasures = []; | |
| systems.forEach(sys => sys.measures.forEach(m => allMeasures.push(m))); | |
| if (allMeasures.length === 0) return; | |
| const totalTenths = allMeasures.reduce((s, m) => s + m.width, 0); | |
| const totalPixels = sysPixelWidths.reduce((s, w) => s + w, 0); | |
| if (totalTenths <= 0 || totalPixels <= 0) return; | |
| // Check if the current assignment already matches image proportions | |
| // Compare each system's tenths-ratio vs pixel-ratio | |
| const currentRatios = systems.map(sys => sys.cumulativeWidth / totalTenths); | |
| const imgRatios = sysPixelWidths.map(pw => pw / totalPixels); | |
| const maxRatioError = Math.max(...currentRatios.map((cr, i) => Math.abs(cr - imgRatios[i]))); | |
| if (maxRatioError < 0.08) { | |
| // Ratios are consistent (within 8%) — no reassignment needed | |
| return; | |
| } | |
| console.log(`reassignMeasures: ratio mismatch detected (max error ${(maxRatioError*100).toFixed(1)}%), redistributing`); | |
| console.log(` XML ratios: [${currentRatios.map(r => (r*100).toFixed(1)+'%').join(', ')}]`); | |
| console.log(` IMG ratios: [${imgRatios.map(r => (r*100).toFixed(1)+'%').join(', ')}]`); | |
| // Target tenths per system based on image proportions | |
| const sysTargetTenths = sysPixelWidths.map(pw => (pw / totalPixels) * totalTenths); | |
| let mIdx = 0; | |
| for (let sysIdx = 0; sysIdx < systems.length; sysIdx++) { | |
| const sys = systems[sysIdx]; | |
| sys.measures = []; | |
| sys.cumulativeWidth = 0; | |
| const target = sysTargetTenths[sysIdx]; | |
| while (mIdx < allMeasures.length) { | |
| // Last system gets all remaining measures | |
| if (sysIdx === systems.length - 1) { | |
| const m = allMeasures[mIdx]; | |
| m.systemIdx = sysIdx; | |
| m.startX = sys.cumulativeWidth; | |
| sys.measures.push(m); | |
| sys.cumulativeWidth += m.width; | |
| mIdx++; | |
| continue; | |
| } | |
| const m = allMeasures[mIdx]; | |
| const newCum = sys.cumulativeWidth + m.width; | |
| // Each system must have at least 1 measure | |
| if (sys.measures.length === 0) { | |
| m.systemIdx = sysIdx; | |
| m.startX = sys.cumulativeWidth; | |
| sys.measures.push(m); | |
| sys.cumulativeWidth = newCum; | |
| mIdx++; | |
| continue; | |
| } | |
| // Should this measure go in this system or the next? | |
| // Include it if adding it brings cumulative closer to target | |
| if (Math.abs(newCum - target) < Math.abs(sys.cumulativeWidth - target)) { | |
| m.systemIdx = sysIdx; | |
| m.startX = sys.cumulativeWidth; | |
| sys.measures.push(m); | |
| sys.cumulativeWidth = newCum; | |
| mIdx++; | |
| } else { | |
| break; | |
| } | |
| } | |
| } | |
| // Log the result | |
| systems.forEach((sys, i) => { | |
| const nums = sys.measures.map(m => m.number).join(','); | |
| console.log(` System ${i}: measures [${nums}] (${sys.measures.length}), width=${sys.cumulativeWidth.toFixed(0)} tenths`); | |
| }); | |
| } | |
| /** Parse all notes from ALL parts, with per-staff clef tracking and timing */ | |
| function parseNotes(doc, systems) { | |
| const notes = []; | |
| const allParts = doc.querySelectorAll("part"); | |
| // Compute staff offset per part: P1 staves 1,2 → global 1,2; P2 staves 1,2 → global 3,4 | |
| // If systems have adjusted numStaves (overflow fix), clamp offsets so all parts | |
| // map into the actual stave range. | |
| // If systems adjusted numStaves due to page overflow, later parts share the same | |
| // physical staves as P1. Set their offset to 0 so globalStaff maps correctly. | |
| const adjustedNumStaves = systems.length > 0 ? systems[0].numStaves : 99; | |
| const partStaffOffsets = []; | |
| let staffAccum = 0; | |
| allParts.forEach(p => { | |
| const offset = staffAccum >= adjustedNumStaves ? 0 : staffAccum; | |
| partStaffOffsets.push(offset); | |
| const firstAttr = p.querySelector("measure > attributes"); | |
| const si = firstAttr ? parseStaffInfo(firstAttr) : { numStaves: 1 }; | |
| staffAccum += si.numStaves; | |
| }); | |
| allParts.forEach((partEl, partIdx) => { | |
| const staffOffset = partStaffOffsets[partIdx]; | |
| let currentClefs = { 1: { sign: "G", line: 2, octaveChange: 0 } }; | |
| let divisions = 1; | |
| let currentFifths = 0; | |
| // Build measure lookup from systems (systems were parsed from parts[0] but measure numbers are shared) | |
| const measuresByNum = {}; | |
| systems.forEach(sys => { | |
| sys.measures.forEach(mInfo => { measuresByNum[mInfo.number] = mInfo; }); | |
| }); | |
| const measures = partEl.querySelectorAll("measure"); | |
| measures.forEach(mEl => { | |
| const mNum = mEl.getAttribute("number"); | |
| const mInfo = measuresByNum[mNum]; | |
| if (!mInfo) return; // measure not in systems (shouldn't happen) | |
| const sys = systems[mInfo.systemIdx]; | |
| // Check for clef/divisions changes in attributes | |
| const attrEl = mEl.querySelector("attributes"); | |
| if (attrEl) { | |
| const divEl = attrEl.querySelector("divisions"); | |
| if (divEl) divisions = parseInt(divEl.textContent) || 1; | |
| const fifthsEl = attrEl.querySelector("key > fifths"); | |
| if (fifthsEl) currentFifths = parseInt(fifthsEl.textContent) || 0; | |
| const newClefs = parseClefs(attrEl); | |
| Object.assign(currentClefs, newClefs); | |
| const si = parseStaffInfo(attrEl); | |
| if (si.numStaves >= 2 && !currentClefs[2]) { | |
| currentClefs[2] = { sign: "F", line: 4, octaveChange: 0 }; | |
| } | |
| } | |
| // Mid-measure clef changes | |
| const midClefs = mEl.querySelectorAll("attributes clef"); | |
| midClefs.forEach(clefEl => { | |
| const num = parseInt(clefEl.getAttribute("number") || "1"); | |
| currentClefs[num] = { | |
| sign: clefEl.querySelector("sign")?.textContent || "G", | |
| line: parseInt(clefEl.querySelector("line")?.textContent || "2"), | |
| octaveChange: parseInt(clefEl.querySelector("clef-octave-change")?.textContent || "0"), | |
| }; | |
| }); | |
| // Parse notes — single time cursor with forward/backup for multi-voice | |
| const children = mEl.children; | |
| let cursorTime = 0; | |
| let lastOnset = 0; | |
| for (let ci = 0; ci < children.length; ci++) { | |
| const child = children[ci]; | |
| if (child.tagName === "forward") { | |
| const dur = parseInt(child.querySelector("duration")?.textContent || "0"); | |
| cursorTime += dur; | |
| } else if (child.tagName === "backup") { | |
| const dur = parseInt(child.querySelector("duration")?.textContent || "0"); | |
| cursorTime -= dur; | |
| if (cursorTime < 0) cursorTime = 0; | |
| } else if (child.tagName === "note") { | |
| const nEl = child; | |
| const isRest = nEl.querySelector("rest") !== null; | |
| const isGrace = nEl.querySelector("grace") !== null; | |
| const voice = parseInt(nEl.querySelector("voice")?.textContent || "1"); | |
| const duration = parseInt(nEl.querySelector("duration")?.textContent || "0"); | |
| const isChord = nEl.querySelector("chord") !== null; | |
| const onsetDiv = isChord ? lastOnset : cursorTime; | |
| if (!isChord) { | |
| lastOnset = cursorTime; | |
| cursorTime += duration; | |
| } | |
| if (isGrace) continue; | |
| const localStaff = parseInt(nEl.querySelector("staff")?.textContent || "1"); | |
| const globalStaff = localStaff + staffOffset; | |
| const defaultX = parseFloat(nEl.getAttribute("default-x") || "0"); | |
| const _dataPx = nEl.hasAttribute("data-px") ? parseFloat(nEl.getAttribute("data-px")) : null; | |
| // Restore .omr data from DOM attributes (persisted by matchOmrGrades on first load) | |
| const _omrX = nEl.hasAttribute("data-omr-x") ? parseFloat(nEl.getAttribute("data-omr-x")) : _dataPx; | |
| const _omrY = nEl.hasAttribute("data-omr-y") ? parseFloat(nEl.getAttribute("data-omr-y")) : null; | |
| const _omrGrade = nEl.hasAttribute("data-omr-grade") ? parseFloat(nEl.getAttribute("data-omr-grade")) : undefined; | |
| const _omrChordId = nEl.getAttribute("data-omr-chord-id") || null; | |
| const _omrHeadId = nEl.getAttribute("data-omr-head-id") || null; | |
| const clef = currentClefs[localStaff] || currentClefs[1] || { sign: "G", line: 2, octaveChange: 0 }; | |
| let step = "R", octave = 0, alter = 0; | |
| if (!isRest) { | |
| const pitchEl = nEl.querySelector("pitch"); | |
| if (!pitchEl) continue; | |
| step = pitchEl.querySelector("step")?.textContent || "C"; | |
| octave = parseInt(pitchEl.querySelector("octave")?.textContent || "4"); | |
| const alterEl = pitchEl.querySelector("alter"); | |
| const hasExplicitAccidental = nEl.querySelector("accidental") !== null; | |
| if (alterEl) { | |
| alter = parseFloat(alterEl.textContent); | |
| } else if (!hasExplicitAccidental) { | |
| // No <alter> and no <accidental>: apply key signature | |
| alter = keyAlterForStep(step, currentFifths); | |
| } | |
| } | |
| notes.push({ | |
| element: nEl, | |
| measureNum: mInfo.number, | |
| step, octave, alter, | |
| fifths: currentFifths, | |
| voice, staff: globalStaff, defaultX, | |
| partIndex: partIdx, | |
| systemIdx: mInfo.systemIdx, | |
| measureStartX: mInfo.startX, | |
| systemLeftMargin: sys.leftMargin, | |
| systemTopY: sys.topY, | |
| staffDistance: sys.staffDistance, | |
| clef: { ...clef }, | |
| isChord, | |
| isRest, | |
| divisions, | |
| durationDiv: duration, | |
| onsetDiv: onsetDiv, | |
| measureIdx: mInfo.number, | |
| modified: nEl.hasAttribute("data-modified"), | |
| omrX: _omrX, // pixel X from .omr (or data-px for Add Mode) | |
| omrY: _omrY, // pixel Y from .omr | |
| grade: _omrGrade, // recognition confidence from .omr | |
| omrChordId: _omrChordId, | |
| omrHeadId: _omrHeadId, | |
| px: 0, py: 0, | |
| }); | |
| } | |
| } | |
| }); | |
| }); | |
| return notes; | |
| } | |
| /** Build a playback timeline: array of { timeSeconds, durationSeconds, noteIndices[] } | |
| * Groups simultaneous notes (chords, multi-voice) into single events. */ | |
| function buildTimeline(notes, bpm) { | |
| if (notes.length === 0) return []; | |
| // Check if notes are .omr-based (rational durations) or XML-based (divisions) | |
| const isOmrBased = notes.length > 0 && notes[0]._omrBased; | |
| // Step 1: compute measure durations | |
| const measureDurations = {}; // measureNum → duration (in whole notes for omr, divisions for xml) | |
| if (isOmrBased) { | |
| // .omr mode: get measure durations from systemsData stacks | |
| for (const sys of systemsData) { | |
| for (const m of sys.measures) { | |
| measureDurations[m.number] = parseRational(m.duration || "1"); | |
| } | |
| } | |
| // Auto-correct: if any note's onset+duration exceeds stack duration, extend it | |
| // This fixes Audiveris miscalculated stack durations without modifying omrData | |
| notes.forEach(n => { | |
| const mNum = String(n.measureNum); | |
| const end = (n.onsetDiv || 0) + (n.durationDiv || 0); | |
| if (measureDurations[mNum] !== undefined && end > measureDurations[mNum] + 0.001) { | |
| measureDurations[mNum] = end; | |
| } | |
| }); | |
| } else { | |
| // Legacy XML mode | |
| let currentBeats = carryBeats, currentBeatType = carryBeatType, currentDivisions = 1; | |
| if (xmlDoc) { | |
| const parts = xmlDoc.querySelectorAll("part"); | |
| if (parts.length > 0) { | |
| const measures = parts[0].querySelectorAll("measure"); | |
| measures.forEach(mEl => { | |
| const attrEl = mEl.querySelector("attributes"); | |
| if (attrEl) { | |
| const divEl = attrEl.querySelector("divisions"); | |
| if (divEl) currentDivisions = parseInt(divEl.textContent) || 1; | |
| const timeEl = attrEl.querySelector("time"); | |
| if (timeEl) { | |
| currentBeats = parseInt(timeEl.querySelector("beats")?.textContent || "4"); | |
| currentBeatType = parseInt(timeEl.querySelector("beat-type")?.textContent || "4"); | |
| } | |
| } | |
| const mNum = mEl.getAttribute("number"); | |
| measureDurations[mNum] = currentBeats * currentDivisions * (4 / currentBeatType); | |
| }); | |
| } | |
| } | |
| carryBeats = currentBeats; | |
| carryBeatType = currentBeatType; | |
| // Reconcile with actual note content | |
| if (xmlDoc) { | |
| const parts = xmlDoc.querySelectorAll("part"); | |
| if (parts.length > 0) { | |
| const measures = parts[0].querySelectorAll("measure"); | |
| measures.forEach(mEl => { | |
| const mNum = mEl.getAttribute("number"); | |
| let cursor = 0, maxCursor = 0; | |
| for (const el of mEl.children) { | |
| if (el.tagName === "note") { | |
| const durEl = el.querySelector("duration"); | |
| if (durEl && !el.querySelector("chord")) { | |
| cursor += parseInt(durEl.textContent) || 0; | |
| } | |
| if (cursor > maxCursor) maxCursor = cursor; | |
| } else if (el.tagName === "forward") { | |
| const durEl = el.querySelector("duration"); | |
| if (durEl) cursor += parseInt(durEl.textContent) || 0; | |
| if (cursor > maxCursor) maxCursor = cursor; | |
| } else if (el.tagName === "backup") { | |
| const durEl = el.querySelector("duration"); | |
| if (durEl) cursor -= parseInt(durEl.textContent) || 0; | |
| } | |
| } | |
| if (maxCursor > 0) { | |
| const existing = measureDurations[mNum]; | |
| measureDurations[mNum] = existing ? Math.max(existing, maxCursor) : maxCursor; | |
| } | |
| }); | |
| } | |
| } | |
| } | |
| // Step 2: pre-compute measure start positions | |
| const measureStartLookup = {}; | |
| { | |
| let accum = 0; | |
| const sortedMeasures = Object.keys(measureDurations).sort((a, b) => parseInt(a) - parseInt(b)); | |
| for (const mNum of sortedMeasures) { | |
| measureStartLookup[mNum] = accum; | |
| accum += measureDurations[mNum]; | |
| } | |
| } | |
| const noteEvents = []; | |
| let misplacedFiltered = 0; | |
| if (isOmrBased) { | |
| // .omr mode: durations are in whole-note fractions, convert to seconds | |
| // whole note = 4 beats, so 1 whole note at BPM = 4 * (60/bpm) seconds | |
| const wholeNoteSec = 4 * (60 / bpm); | |
| notes.forEach((n, idx) => { | |
| if (n.isRest) return; | |
| // orphan notes (no slot assigned by Audiveris) still included with onset=0 fallback | |
| const measureStart = measureStartLookup[n.measureNum] || 0; | |
| const absOnset = measureStart + (n.onsetDiv || 0); // in whole-note fractions | |
| const timeSec = absOnset * wholeNoteSec; | |
| const durSec = (n.durationDiv || 0) * wholeNoteSec; | |
| noteEvents.push({ | |
| idx, | |
| absOnsetDiv: absOnset, | |
| onsetInMeasure: n.onsetDiv, | |
| durationDiv: n.durationDiv, | |
| timeSec, | |
| durSec, | |
| measureNum: n.measureNum, | |
| _px: n.defaultX || 0, | |
| _voice: n.voice, | |
| _partIndex: n.partIndex || 0, | |
| }); | |
| }); | |
| // Note: orphan notes (no slot from Audiveris) are included with onset=0 fallback. | |
| } else { | |
| // Legacy XML mode | |
| notes.forEach((n, idx) => { | |
| if (n.isRest) return; | |
| const measureStartDiv = measureStartLookup[n.measureNum] || 0; | |
| const divisions = n.divisions || 1; | |
| const absOnsetDiv = measureStartDiv + n.onsetDiv; | |
| const secPerDiv = 60 / (bpm * divisions); | |
| const timeSec = absOnsetDiv * secPerDiv; | |
| const durSec = n.durationDiv * secPerDiv; | |
| noteEvents.push({ | |
| idx, | |
| absOnsetDiv, | |
| onsetInMeasure: n.onsetDiv, | |
| durationDiv: n.durationDiv, | |
| timeSec, | |
| durSec, | |
| measureNum: n.measureNum, | |
| }); | |
| }); | |
| } | |
| // Step 3: group by onset time (within 0.02s tolerance) | |
| noteEvents.sort((a, b) => a.timeSec - b.timeSec || a.idx - b.idx); | |
| const timeline = []; | |
| let i = 0; | |
| while (i < noteEvents.length) { | |
| const t = noteEvents[i].timeSec; | |
| const group = []; | |
| let maxDur = 0; | |
| while (i < noteEvents.length && Math.abs(noteEvents[i].timeSec - t) < 0.02) { | |
| group.push(noteEvents[i].idx); | |
| if (noteEvents[i].durSec > maxDur) maxDur = noteEvents[i].durSec; | |
| i++; | |
| } | |
| timeline.push({ timeSec: t, durationSec: maxDur, noteIndices: group }); | |
| } | |
| console.log(`Timeline: ${timeline.length} events from ${notes.length} notes, total ${timeline.length > 0 ? timeline[timeline.length-1].timeSec.toFixed(1) : 0}s`); | |
| // DEBUG: build debug dump for playback diagnosis (downloadable via DBG button) | |
| window._lastTimelineDebug = (() => { | |
| const lines = []; | |
| lines.push(`=== buildTimeline DEBUG ===`); | |
| const orphanCount = notes.filter(n => n.orphan).length; | |
| lines.push(`isOmrBased: ${isOmrBased}, BPM: ${bpm}, Total notes: ${notes.length}, Orphans: ${orphanCount}, Misplaced onset=0: ${misplacedFiltered}, NoteEvents: ${noteEvents.length}, Groups: ${timeline.length}`); | |
| lines.push(``); | |
| lines.push(`--- measureDurations ---`); | |
| const sortedMD = Object.keys(measureDurations).sort((a, b) => parseInt(a) - parseInt(b)); | |
| for (const k of sortedMD) lines.push(` m${k}: dur=${measureDurations[k]}`); | |
| lines.push(``); | |
| lines.push(`--- measureStartLookup ---`); | |
| const sortedMS = Object.keys(measureStartLookup).sort((a, b) => parseInt(a) - parseInt(b)); | |
| for (const k of sortedMS) lines.push(` m${k}: start=${measureStartLookup[k]}`); | |
| lines.push(``); | |
| lines.push(`--- noteEvents (sorted by timeSec, first 80) ---`); | |
| for (let ei = 0; ei < Math.min(80, noteEvents.length); ei++) { | |
| const e = noteEvents[ei]; | |
| const n = notes[e.idx]; | |
| const px = n.pxDefault?.toFixed(0) || n.defaultX?.toFixed(0) || '?'; | |
| lines.push(` [${ei}] m${e.measureNum} onset=${e.onsetInMeasure?.toFixed?.(4) ?? e.onsetInMeasure} abs=${e.absOnsetDiv?.toFixed?.(4) ?? e.absOnsetDiv} t=${e.timeSec.toFixed(3)}s dur=${e.durSec.toFixed(3)}s | ${n.step}${n.octave} staff=${n.staff} voice=${n.voice} part=${n.partIndex} px=${px} div=${n.divisions || '?'} grade=${n.grade || '?'} chordId=${n.omrChordId||'?'} tRat=${n.timeOffsetRational||'?'}`); | |
| } | |
| if (noteEvents.length > 80) lines.push(` ... (${noteEvents.length - 80} more)`); | |
| lines.push(``); | |
| lines.push(`--- timeline groups (first 50) ---`); | |
| for (let ti = 0; ti < Math.min(50, timeline.length); ti++) { | |
| const g = timeline[ti]; | |
| const noteDescs = g.noteIndices.map(idx => { | |
| const n = notes[idx]; | |
| return `${n.step}${n.octave}(s${n.staff}v${n.voice}g=${n.grade||'?'})`; | |
| }).join(", "); | |
| lines.push(` [${ti}] t=${g.timeSec.toFixed(3)}s dur=${g.durationSec.toFixed(3)}s | ${noteDescs}`); | |
| } | |
| if (timeline.length > 50) lines.push(` ... (${timeline.length - 50} more)`); | |
| return lines.join("\n"); | |
| })(); | |
| return timeline; | |
| } | |
| // ================================================================ | |
| // Section 2: Coordinate Mapping | |
| // ================================================================ | |
| function computePixelsPerTenthFromImage(imageWidth, pageWidthTenths) { | |
| // Most accurate: directly map XML page width to actual image width | |
| return imageWidth / pageWidthTenths; | |
| } | |
| function computePixelsPerTenthFromDpi(dpi, mm, tpu) { | |
| return (dpi * mm) / (tpu * 25.4); | |
| } | |
| function diatonicIndex(step, octave) { | |
| return octave * 7 + STEP_INDEX[step]; | |
| } | |
| function clefReferencePosition(clef) { | |
| // staffPosition: 0 = bottom line (line 1), 2 = line 2, etc. | |
| const linePos = (clef.line - 1) * 2; | |
| let refStep, refBaseOctave; | |
| if (clef.sign === "G") { refStep = "G"; refBaseOctave = 4; } | |
| else if (clef.sign === "F") { refStep = "F"; refBaseOctave = 3; } | |
| else if (clef.sign === "C") { refStep = "C"; refBaseOctave = 4; } | |
| else { refStep = "G"; refBaseOctave = 4; } | |
| const refOctave = refBaseOctave + clef.octaveChange; | |
| const refDiatonic = diatonicIndex(refStep, refOctave); | |
| return { diatonicIdx: refDiatonic, staffPosition: linePos }; | |
| } | |
| // ── OMR data conversion helpers ────────────────────────────── | |
| /** | |
| * Convert .omr staves data to the same format as detectStaffLines() output. | |
| * .omr staves have precise spline points; we use y1 (start) for each line. | |
| */ | |
| function omrStavesToDetected(omrData) { | |
| const result = []; | |
| for (const sys of omrData.systems) { | |
| for (const staff of sys.staves) { | |
| if (staff.lines.length < 5) continue; | |
| const ys = staff.lines.map(l => l.y1); | |
| ys.sort((a, b) => a - b); | |
| const spacing = (ys[4] - ys[0]) / 4; | |
| result.push({ | |
| lines: ys, | |
| topLineY: ys[0], | |
| bottomLineY: ys[4], | |
| lineSpacing: spacing, | |
| leftX: staff.left, | |
| rightX: staff.right, | |
| }); | |
| } | |
| } | |
| result.sort((a, b) => a.topLineY - b.topLineY); | |
| return result; | |
| } | |
| /** | |
| * Convert .omr barlines to detectedBarlines format. | |
| * Maps each barline's staff ID to a system index using staff→system mapping. | |
| */ | |
| function omrBarlinesToDetected(omrData, staves, numStavesPerSys) { | |
| const result = []; | |
| let staffOffset = 0; | |
| for (let sysIdx = 0; sysIdx < omrData.systems.length; sysIdx++) { | |
| const sys = omrData.systems[sysIdx]; | |
| const sysStaffIds = sys.staves.map(s => s.staffId); | |
| for (const bl of sys.barlines) { | |
| if (!bl.bounds) continue; | |
| // Skip left/right system-end barlines (they're structural, not measure barlines) | |
| if (bl.staffEnd === "LEFT" || bl.staffEnd === "RIGHT") continue; | |
| result.push({ | |
| x: bl.bounds.x + bl.bounds.w / 2, | |
| systemIdx: sysIdx, | |
| confidence: bl.grade, | |
| source: "omr", | |
| }); | |
| } | |
| } | |
| return result; | |
| } | |
| /** | |
| * Match .omr head grades to noteInfos by measure + voice + order. | |
| * Copies grade into each noteInfo as a one-time operation at load. | |
| */ | |
| function matchOmrGrades(notes, omrData) { | |
| // Build a flat list of measure→chords from all systems | |
| // measureId in .omr is 1-based per system; MusicXML measure number is global. | |
| // We match by global order: .omr systems in order, measures in order within each system. | |
| const omrMeasures = []; | |
| for (const sys of omrData.systems) { | |
| // Build global→local staff mapping for this system | |
| const staffIds = sys.staves.map(s => s.staffId).sort((a, b) => a - b); | |
| const staffGlobalToLocal = {}; | |
| staffIds.forEach((gid, idx) => { staffGlobalToLocal[gid] = idx + 1; }); | |
| for (const meas of sys.measures) { | |
| omrMeasures.push({ ...meas, _staffMap: staffGlobalToLocal }); | |
| } | |
| } | |
| // Group noteInfos by MusicXML measure number (1-based string) | |
| const notesByMeasure = {}; | |
| for (const n of notes) { | |
| const mNum = n.measureNum || "0"; | |
| if (!notesByMeasure[mNum]) notesByMeasure[mNum] = []; | |
| notesByMeasure[mNum].push(n); | |
| } | |
| // Match: for each MusicXML measure (in order), pair with .omr measure (in order) | |
| const xmlMeasureNums = Object.keys(notesByMeasure).sort((a, b) => parseInt(a) - parseInt(b)); | |
| let matched = 0, unmatched = 0; | |
| for (let mi = 0; mi < xmlMeasureNums.length && mi < omrMeasures.length; mi++) { | |
| const mNum = xmlMeasureNums[mi]; | |
| const xmlNotes = notesByMeasure[mNum]; | |
| const omrMeas = omrMeasures[mi]; | |
| // Separate XML notes by staff, then match chord-by-chord | |
| // .omr headChords are ordered per measure; XML notes are in document order | |
| // Both come from Audiveris, so order should align. | |
| const xmlNotesNonChord = []; | |
| for (let ni = 0; ni < xmlNotes.length; ni++) { | |
| const n = xmlNotes[ni]; | |
| // Skip chord continuation notes and rests | |
| // (headChords in .omr only contain pitched notes, not rests) | |
| if (n.isChord || n.isRest) continue; | |
| xmlNotesNonChord.push(n); | |
| } | |
| // Match head-chords to non-chord XML notes | |
| // headChords may span both staves; XML notes are interleaved by voice | |
| // Group by staff for better matching | |
| const omrByStaff = {}; | |
| const staffMap = omrMeas._staffMap || {}; | |
| for (const hc of omrMeas.headChords) { | |
| if (hc.heads.length === 0) continue; | |
| const globalStaff = hc.heads[0].staff; | |
| const localStaff = staffMap[globalStaff] || globalStaff; | |
| if (!omrByStaff[localStaff]) omrByStaff[localStaff] = []; | |
| omrByStaff[localStaff].push(hc); | |
| } | |
| const xmlByStaff = {}; | |
| for (const n of xmlNotesNonChord) { | |
| const staff = n.staff || 1; | |
| if (!xmlByStaff[staff]) xmlByStaff[staff] = []; | |
| xmlByStaff[staff].push(n); | |
| } | |
| for (const staffKey of Object.keys(xmlByStaff)) { | |
| const xmlStaffNotes = xmlByStaff[staffKey]; | |
| const omrStaffChords = omrByStaff[staffKey] || []; | |
| // Match by nearest X position instead of sequential index | |
| // This prevents misalignment when notes are deleted (become rests) | |
| const usedOmrIndices = new Set(); | |
| for (let xi = 0; xi < xmlStaffNotes.length; xi++) { | |
| const n = xmlStaffNotes[xi]; | |
| // Find closest unmatched omr headChord by X (bounds center) | |
| let bestCi = -1, bestDist = Infinity; | |
| for (let oi = 0; oi < omrStaffChords.length; oi++) { | |
| if (usedOmrIndices.has(oi)) continue; | |
| const hc = omrStaffChords[oi]; | |
| if (hc.heads.length === 0 || !hc.heads[0].bounds) continue; | |
| const omrCx = hc.heads[0].bounds.x + hc.heads[0].bounds.w / 2; | |
| const dist = Math.abs(omrCx - n.defaultX); | |
| if (dist < bestDist) { bestDist = dist; bestCi = oi; } | |
| } | |
| if (bestCi < 0) continue; | |
| usedOmrIndices.add(bestCi); | |
| const ci = bestCi; | |
| const hc = omrStaffChords[ci]; | |
| // Use the minimum head grade (most uncertain note in chord) | |
| const minGrade = Math.min(...hc.heads.map(h => h.grade)); | |
| n.grade = minGrade; | |
| n.omrChordId = hc.chordId; | |
| n.omrHeadId = hc.heads.length > 0 ? hc.heads[0].headId : null; | |
| // Copy pixel-perfect position from Audiveris head bounds | |
| if (hc.heads.length > 0 && hc.heads[0].bounds) { | |
| const hb = hc.heads[0].bounds; | |
| n.omrX = hb.x + hb.w / 2; | |
| n.omrY = hb.y + hb.h / 2; | |
| } | |
| // Persist to DOM so re-parse doesn't need re-matching | |
| if (n.element) { | |
| if (n.omrX != null) n.element.setAttribute("data-omr-x", n.omrX); | |
| if (n.omrY != null) n.element.setAttribute("data-omr-y", n.omrY); | |
| if (n.grade != null) n.element.setAttribute("data-omr-grade", n.grade); | |
| if (n.omrChordId != null) n.element.setAttribute("data-omr-chord-id", n.omrChordId); | |
| if (n.omrHeadId != null) n.element.setAttribute("data-omr-head-id", n.omrHeadId); | |
| } | |
| matched++; | |
| // Also assign grade + bounds to chord members (notes with isChord=true following this note) | |
| const nIdx = xmlNotes.indexOf(n); | |
| if (nIdx >= 0) { | |
| let chordHeadIdx = 1; // first head already used for primary note | |
| for (let ci2 = nIdx + 1; ci2 < xmlNotes.length; ci2++) { | |
| if (!xmlNotes[ci2].isChord) break; | |
| xmlNotes[ci2].grade = minGrade; | |
| xmlNotes[ci2].omrChordId = hc.chordId; | |
| xmlNotes[ci2].omrHeadId = chordHeadIdx < hc.heads.length ? hc.heads[chordHeadIdx].headId : null; | |
| if (chordHeadIdx < hc.heads.length && hc.heads[chordHeadIdx].bounds) { | |
| const hb2 = hc.heads[chordHeadIdx].bounds; | |
| xmlNotes[ci2].omrX = hb2.x + hb2.w / 2; | |
| xmlNotes[ci2].omrY = hb2.y + hb2.h / 2; | |
| } | |
| // Persist chord member to DOM too | |
| if (xmlNotes[ci2].element) { | |
| if (xmlNotes[ci2].omrX != null) xmlNotes[ci2].element.setAttribute("data-omr-x", xmlNotes[ci2].omrX); | |
| if (xmlNotes[ci2].omrY != null) xmlNotes[ci2].element.setAttribute("data-omr-y", xmlNotes[ci2].omrY); | |
| if (xmlNotes[ci2].grade != null) xmlNotes[ci2].element.setAttribute("data-omr-grade", xmlNotes[ci2].grade); | |
| if (xmlNotes[ci2].omrChordId != null) xmlNotes[ci2].element.setAttribute("data-omr-chord-id", xmlNotes[ci2].omrChordId); | |
| if (xmlNotes[ci2].omrHeadId != null) xmlNotes[ci2].element.setAttribute("data-omr-head-id", xmlNotes[ci2].omrHeadId); | |
| } | |
| chordHeadIdx++; | |
| } | |
| } | |
| } | |
| unmatched += omrStaffChords.length - usedOmrIndices.size + Math.max(0, xmlStaffNotes.length - usedOmrIndices.size); | |
| } | |
| // Also assign grades for rest-chords (no head grade, but mark as recognized) | |
| // Rests don't need grade matching — they're typically high confidence | |
| } | |
| console.log(`OMR grade matching: ${matched} matched, ${unmatched} unmatched across ${xmlMeasureNums.length} measures`); | |
| // Extract free glyphs for overlay display | |
| freeGlyphData = []; | |
| for (let si = 0; si < omrData.systems.length; si++) { | |
| const sys = omrData.systems[si]; | |
| if (!sys.freeGlyphs) continue; | |
| for (const fg of sys.freeGlyphs) { | |
| freeGlyphData.push({ ...fg, systemIdx: si }); | |
| } | |
| } | |
| console.log(`Free glyphs: ${freeGlyphData.length} candidates`); | |
| if (freeGlyphsVisible) renderFreeGlyphOverlays(); | |
| } | |
| // ── Free glyph overlays ────────────────────────────────────── | |
| const _GLYPH_SHAPES = [ | |
| { cat: "Notes", cat_ko: "음표", items: [ | |
| { shape: "NOTEHEAD_BLACK", label: "● Black (filled)", label_ko: "● 검은 음표머리" }, | |
| { shape: "NOTEHEAD_VOID", label: "○ Void (half)", label_ko: "○ 빈 음표머리 (2분)" }, | |
| { shape: "WHOLE_NOTE", label: "◎ Whole", label_ko: "◎ 온음표" }, | |
| ]}, | |
| { cat: "Rests", cat_ko: "쉼표", items: [ | |
| { shape: "QUARTER_REST", label: "𝄾 Quarter", label_ko: "𝄾 4분쉼표" }, | |
| { shape: "EIGHTH_REST", label: "𝄿 Eighth", label_ko: "𝄿 8분쉼표" }, | |
| { shape: "HALF_REST", label: "▬ Half", label_ko: "▬ 2분쉼표" }, | |
| { shape: "WHOLE_REST", label: "▄ Whole", label_ko: "▄ 온쉼표" }, | |
| ]}, | |
| { cat: "Accidentals", cat_ko: "임시표", items: [ | |
| { shape: "SHARP", label: "♯ Sharp", label_ko: "♯ 올림표" }, | |
| { shape: "FLAT", label: "♭ Flat", label_ko: "♭ 내림표" }, | |
| { shape: "NATURAL", label: "♮ Natural", label_ko: "♮ 제자리표" }, | |
| ]}, | |
| // { cat: "Clefs", cat_ko: "음자리표", items: [ | |
| // { shape: "G_CLEF", label: "𝄞 Treble", label_ko: "𝄞 높은음자리표" }, | |
| // { shape: "F_CLEF", label: "𝄢 Bass", label_ko: "𝄢 낮은음자리표" }, | |
| // { shape: "C_CLEF", label: "𝄡 Alto/Tenor", label_ko: "𝄡 가온음자리표" }, | |
| // ]}, | |
| // { cat: "Dynamics", cat_ko: "셈여림", items: [ | |
| // { shape: "DYNAMICS_F", label: "f Forte", label_ko: "f 포르테" }, | |
| // { shape: "DYNAMICS_P", label: "p Piano", label_ko: "p 피아노" }, | |
| // ]}, | |
| // { cat: "Other", cat_ko: "기타", items: [ | |
| // { shape: "FERMATA", label: "𝄐 Fermata", label_ko: "𝄐 늘임표" }, | |
| // { shape: "FLAG_8TH", label: "⚑ 8th flag", label_ko: "⚑ 8분 꼬리" }, | |
| // { shape: "FLAG_16TH", label: "⚑⚑ 16th flag", label_ko: "⚑⚑ 16분 꼬리" }, | |
| // { shape: "DOT", label: "• Dot", label_ko: "• 점" }, | |
| // ]}, | |
| ]; | |
| function toggleFreeGlyphs() { | |
| freeGlyphsVisible = !freeGlyphsVisible; | |
| console.log(`toggleFreeGlyphs: visible=${freeGlyphsVisible}, data=${freeGlyphData.length} glyphs`); | |
| if (freeGlyphsVisible && freeGlyphData.length === 0) { | |
| const pg = pages[currentPageIdx]; | |
| const hasOmr = pg && pg.omrData; | |
| const fgCount = hasOmr ? (pg.omrData.systems || []).reduce((s, sys) => s + (sys.freeGlyphs || []).length, 0) : 0; | |
| console.warn(`No freeGlyphData. omrData=${!!hasOmr}, server freeGlyphs=${fgCount}`); | |
| document.getElementById("status-selection").textContent = hasOmr | |
| ? (fgCount > 0 ? `Free glyphs: ${fgCount} (re-match needed)` : "No free glyphs from server — reload .omr") | |
| : "No .omr loaded"; | |
| } | |
| renderFreeGlyphOverlays(); | |
| const btn = document.getElementById("btn-show-free-glyphs"); | |
| if (btn) { | |
| btn.style.background = freeGlyphsVisible ? "#a66200" : ""; | |
| btn.style.color = freeGlyphsVisible ? "#fff" : ""; | |
| } | |
| } | |
| function renderFreeGlyphOverlays() { | |
| const svg = document.getElementById("marker-svg"); | |
| svg.querySelectorAll(".free-glyph-box").forEach(el => el.remove()); | |
| if (!freeGlyphsVisible || !freeGlyphData.length) return; | |
| const ux = parseFloat(document.getElementById("offset-x").value || 0); | |
| const uy = parseFloat(document.getElementById("offset-y").value || 0); | |
| freeGlyphData.forEach((g, idx) => { | |
| const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); | |
| rect.setAttribute("x", g.x + ux); | |
| rect.setAttribute("y", g.y + uy); | |
| rect.setAttribute("width", g.w); | |
| rect.setAttribute("height", g.h); | |
| rect.classList.add("free-glyph-box"); | |
| if (g._assigned) rect.classList.add("assigned"); | |
| rect.dataset.fgIdx = idx; | |
| rect.addEventListener("click", (e) => { e.stopPropagation(); openGlyphAssignPopup(idx, e); }); | |
| svg.appendChild(rect); | |
| }); | |
| } | |
| function openGlyphAssignPopup(fgIdx, event) { | |
| const popup = document.getElementById("glyph-popup"); | |
| const list = document.getElementById("glyph-popup-list"); | |
| list.innerHTML = ""; | |
| const g = freeGlyphData[fgIdx]; | |
| // Navigate timeline to the measure containing this glyph | |
| const glyphCx = g.x + g.w / 2; | |
| let closestIdx = -1, closestDist = Infinity; | |
| noteInfos.forEach((n, i) => { | |
| if (n.systemIdx !== g.systemIdx) return; | |
| const d = Math.abs(n.px - glyphCx); | |
| if (d < closestDist) { closestDist = d; closestIdx = i; } | |
| }); | |
| if (closestIdx >= 0) { | |
| const n = noteInfos[closestIdx]; | |
| renderTimelinePanel(n.measureNum, n.systemIdx); | |
| } | |
| const isKo = currentLang === "ko"; | |
| for (const cat of _GLYPH_SHAPES) { | |
| const catLi = document.createElement("li"); | |
| catLi.className = "glyph-cat"; | |
| catLi.textContent = isKo ? cat.cat_ko : cat.cat; | |
| list.appendChild(catLi); | |
| for (const item of cat.items) { | |
| const li = document.createElement("li"); | |
| li.textContent = isKo ? item.label_ko : item.label; | |
| li.addEventListener("click", () => { | |
| assignGlyphShape(fgIdx, item.shape); | |
| popup.classList.add("hidden"); | |
| }); | |
| list.appendChild(li); | |
| } | |
| } | |
| // Position popup at cursor, clamped to viewport | |
| popup.classList.remove("hidden"); | |
| const popupRect = popup.getBoundingClientRect(); | |
| const vw = window.innerWidth; | |
| const vh = window.innerHeight; | |
| let left = event.clientX + 5; | |
| let top = event.clientY + 5; | |
| if (left + popupRect.width > vw) left = vw - popupRect.width - 5; | |
| if (top + popupRect.height > vh) top = event.clientY - popupRect.height - 5; | |
| if (top < 0) top = 5; | |
| if (left < 0) left = 5; | |
| popup.style.position = "fixed"; | |
| popup.style.left = left + "px"; | |
| popup.style.top = top + "px"; | |
| } | |
| function glyphYToPitch(centerY, omrData, systemIdx) { | |
| // Find the closest staff and compute staff-relative pitch | |
| if (!omrData || !omrData.systems || !omrData.systems[systemIdx]) return { pitch: 0, staffId: "1" }; | |
| const sys = omrData.systems[systemIdx]; | |
| let bestStaff = null, bestDist = Infinity; | |
| for (const staff of sys.staves) { | |
| if (!staff.lines || staff.lines.length < 5) continue; | |
| const topY = staff.lines[0].y1; | |
| const botY = staff.lines[4].y1; | |
| const midY = (topY + botY) / 2; | |
| const dist = Math.abs(centerY - midY); | |
| if (dist < bestDist) { | |
| bestDist = dist; | |
| bestStaff = staff; | |
| } | |
| } | |
| if (!bestStaff || bestStaff.lines.length < 5) return { pitch: 0, staffId: "1" }; | |
| const topY = bestStaff.lines[0].y1; | |
| const botY = bestStaff.lines[4].y1; | |
| const interline = (botY - topY) / 4; | |
| const midY = (topY + botY) / 2; // middle line = pitch 0 | |
| // pitch increases downward: positive = below middle line | |
| const halfSteps = Math.round((centerY - midY) / (interline / 2)); | |
| return { pitch: halfSteps, staffId: String(bestStaff.staffId) }; | |
| } | |
| function assignGlyphShape(fgIdx, shape) { | |
| const g = freeGlyphData[fgIdx]; | |
| const pg = pages[currentPageIdx]; | |
| if (!pg || !pg.omrData) return; | |
| const centerX = g.x + g.w / 2; | |
| const centerY = g.y + g.h / 2; | |
| const { pitch, staffId } = glyphYToPitch(centerY, pg.omrData, g.systemIdx); | |
| pushUndo(); | |
| recordOmrEdit({ | |
| type: "assign_glyph", | |
| glyphId: g.glyphId, | |
| shape: shape, | |
| staff: staffId, | |
| pitch: pitch, | |
| systemIdx: g.systemIdx, | |
| }); | |
| // ── Also add to omrData for immediate visual feedback ── | |
| const sys = pg.omrData.systems[g.systemIdx]; | |
| if (sys) { | |
| // Find which measure this glyph falls in by X coordinate | |
| const sysInfo = systemsData[g.systemIdx]; | |
| let targetMeas = null; | |
| if (sysInfo) { | |
| for (const m of sysInfo.measures) { | |
| if (centerX >= (m.left || 0) && centerX <= (m.right || Infinity)) { | |
| // Find corresponding omrData measure | |
| let globalBase = 1; | |
| for (let si = 0; si < g.systemIdx; si++) { | |
| globalBase += (pg.omrData.systems[si].stacks || []).length || 1; | |
| } | |
| const numStacks = (sys.stacks || []).length || 1; | |
| const stackIdx = parseInt(m.number) - globalBase; | |
| if (stackIdx >= 0 && stackIdx < sys.measures.length) { | |
| targetMeas = sys.measures[stackIdx]; | |
| } | |
| break; | |
| } | |
| } | |
| } | |
| if (!targetMeas && sys.measures.length > 0) targetMeas = sys.measures[0]; | |
| if (targetMeas) { | |
| // Generate unique ID | |
| let maxId = 0; | |
| for (const s of pg.omrData.systems) { | |
| for (const m of s.measures) { | |
| for (const hc of m.headChords) { const id = parseInt(hc.chordId) || 0; if (id > maxId) maxId = id; } | |
| for (const rc of m.restChords) { const id = parseInt(rc.chordId) || 0; if (id > maxId) maxId = id; } | |
| } | |
| } | |
| const newId = String(maxId + 1); | |
| const NOTE_SHAPES = ["NOTEHEAD_BLACK", "NOTEHEAD_VOID", "WHOLE_NOTE"]; | |
| const REST_SHAPES = ["QUARTER_REST", "EIGHTH_REST", "HALF_REST", "WHOLE_REST", "16TH_REST", "32ND_REST"]; | |
| const SHAPE_TO_DUR = { | |
| "NOTEHEAD_BLACK": "1/4", "NOTEHEAD_VOID": "1/2", "WHOLE_NOTE": "1/1", | |
| "QUARTER_REST": "1/4", "EIGHTH_REST": "1/8", "HALF_REST": "1/2", "WHOLE_REST": "1/1", | |
| "16TH_REST": "1/16", "32ND_REST": "1/32", | |
| }; | |
| const dur = SHAPE_TO_DUR[shape] || "1/4"; | |
| // Estimate timeOffset from X position within measure | |
| const measLeft = sysInfo ? (sysInfo.measures.find(m => { | |
| let globalBase = 1; | |
| for (let si = 0; si < g.systemIdx; si++) globalBase += (pg.omrData.systems[si].stacks || []).length || 1; | |
| const numStacks = (sys.stacks || []).length || 1; | |
| const stackIdx = parseInt(m.number) - globalBase; | |
| return sys.measures[stackIdx] === targetMeas; | |
| }) || {}).left || 0 : 0; | |
| const measRight = sysInfo ? (sysInfo.measures.find(m => { | |
| let globalBase = 1; | |
| for (let si = 0; si < g.systemIdx; si++) globalBase += (pg.omrData.systems[si].stacks || []).length || 1; | |
| const stackIdx = parseInt(m.number) - globalBase; | |
| return sys.measures[stackIdx] === targetMeas; | |
| }) || {}).right || (measLeft + 200) : measLeft + 200; | |
| const measDur = parseRational(targetMeas.duration || "1"); | |
| const ratio = measRight > measLeft ? Math.max(0, Math.min(1, (centerX - measLeft) / (measRight - measLeft))) : 0; | |
| const gridSize = 0.125; // 1/8 default snap | |
| let onset = Math.round((ratio * measDur) / gridSize) * gridSize; | |
| onset = Math.max(0, Math.min(onset, measDur - gridSize)); | |
| const timeOffset = durationFloatToRational(onset); | |
| if (NOTE_SHAPES.includes(shape)) { | |
| targetMeas.headChords.push({ | |
| chordId: newId, | |
| duration: dur, | |
| timeOffset, | |
| voice: 1, | |
| dotsNumber: 0, | |
| heads: [{ | |
| headId: newId + "-h1", | |
| pitch, | |
| alter: 0, | |
| staff: parseInt(staffId), | |
| shape, | |
| grade: 0.5, | |
| bounds: { x: Math.round(g.x), y: Math.round(g.y), w: Math.round(g.w), h: Math.round(g.h) }, | |
| }], | |
| }); | |
| } else if (REST_SHAPES.includes(shape)) { | |
| targetMeas.restChords.push({ | |
| chordId: newId, | |
| restShape: shape, | |
| duration: dur, | |
| timeOffset, | |
| voice: 1, | |
| staff: parseInt(staffId), | |
| bounds: { x: Math.round(g.x), y: Math.round(g.y), w: Math.round(g.w), h: Math.round(g.h) }, | |
| }); | |
| } | |
| // Accidentals: just record edit, server handles association | |
| } | |
| } | |
| // Mark as assigned visually | |
| g._assigned = true; | |
| renderFreeGlyphOverlays(); | |
| // Rebuild to show the new note/rest | |
| rebuildSystemsAndNotes(); | |
| const ux = parseInt(document.getElementById("offset-x").value) || 0; | |
| const uy = parseInt(document.getElementById("offset-y").value) || 0; | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| } | |
| // Close glyph popup on outside click | |
| document.addEventListener("click", (e) => { | |
| const popup = document.getElementById("glyph-popup"); | |
| if (popup && !popup.contains(e.target) && !e.target.classList.contains("free-glyph-box")) { | |
| popup.classList.add("hidden"); | |
| } | |
| }); | |
| // ── Image-based staff line detection ───────────────────────── | |
| let detectedStaves = []; // array of { topLineY, bottomLineY, lineSpacing, lines: [y1..y5] } | |
| let cachedImageData = null; // cached {data, width, height} from last detectStaffLines call | |
| let detectedBarlines = []; // array of {x, systemIdx, confidence, source:'auto'|'manual'|'xml'} | |
| let barlineOverlaysVisible = false; | |
| function detectStaffLines(imgElement) { | |
| const canvas = document.createElement("canvas"); | |
| const w = imgElement.naturalWidth; | |
| const h = imgElement.naturalHeight; | |
| canvas.width = w; | |
| canvas.height = h; | |
| const ctx = canvas.getContext("2d"); | |
| ctx.drawImage(imgElement, 0, 0); | |
| const imgData = ctx.getImageData(0, 0, w, h); | |
| const pixels = imgData.data; | |
| // Cache imageData for barline detection reuse | |
| cachedImageData = { data: pixels, width: w, height: h }; | |
| // === Step 1: Run-length based horizontal line detection === | |
| // For each row, measure what fraction of the width is "dark" in long continuous runs. | |
| // Staff lines span nearly the full page width; watermarks, text, beams don't. | |
| const darkThreshold = 160; // pixel brightness below this = dark | |
| const minRunLength = Math.floor(w * 0.15); // a "line" run must be >= 15% of width | |
| const lineScore = new Float32Array(h); // fraction of width covered by long dark runs | |
| // Also track the leftmost start and rightmost end of long runs per row | |
| const lineLeftX = new Int32Array(h).fill(w); // leftmost start of a long run | |
| const lineRightX = new Int32Array(h).fill(0); // rightmost end of a long run | |
| for (let y = 0; y < h; y++) { | |
| let runStart = -1; | |
| let totalLinePx = 0; | |
| for (let x = 0; x <= w; x++) { | |
| let isDark = false; | |
| if (x < w) { | |
| const idx = (y * w + x) * 4; | |
| const gray = (pixels[idx] + pixels[idx + 1] + pixels[idx + 2]) / 3; | |
| isDark = gray < darkThreshold; | |
| } | |
| if (isDark) { | |
| if (runStart < 0) runStart = x; | |
| } else { | |
| if (runStart >= 0) { | |
| const runLen = x - runStart; | |
| if (runLen >= minRunLength) { | |
| totalLinePx += runLen; | |
| if (runStart < lineLeftX[y]) lineLeftX[y] = runStart; | |
| if (x > lineRightX[y]) lineRightX[y] = x; | |
| } | |
| runStart = -1; | |
| } | |
| } | |
| } | |
| lineScore[y] = totalLinePx / w; | |
| } | |
| // === Step 2: Find candidate line rows === | |
| // Staff line rows have high lineScore (>30% coverage by long dark runs) | |
| const scoreSorted = Array.from(lineScore).filter(v => v > 0).sort((a, b) => b - a); | |
| // Adaptive threshold: staff lines should be in top scores | |
| // Use 50% of the score at the 5th percentile of non-zero values | |
| let lineThreshold = 0.3; // default minimum | |
| if (scoreSorted.length > 20) { | |
| const p5 = scoreSorted[Math.floor(scoreSorted.length * 0.05)]; | |
| lineThreshold = Math.max(0.25, p5 * 0.5); | |
| } | |
| const candidates = []; | |
| for (let y = 1; y < h - 1; y++) { | |
| if (lineScore[y] >= lineThreshold) { | |
| candidates.push(y); | |
| } | |
| } | |
| // === Step 3: Merge adjacent rows into single line positions === | |
| const merged = []; | |
| let i = 0; | |
| while (i < candidates.length) { | |
| let j = i; | |
| let sumY = 0, sumScore = 0, count = 0; | |
| while (j < candidates.length && candidates[j] - candidates[i] <= 3) { | |
| sumY += candidates[j] * lineScore[candidates[j]]; // weighted by score | |
| sumScore += lineScore[candidates[j]]; | |
| count++; | |
| j++; | |
| } | |
| merged.push(Math.round(sumY / sumScore)); // score-weighted centroid | |
| i = j; | |
| } | |
| // === Step 4: Group into staves of 5 with consistent spacing === | |
| const staves = groupIntoStaves(merged); | |
| // === Step 5: Compute left/right X extent for each staff === | |
| // Average the leftX/rightX across the 5 staff lines of each staff | |
| staves.forEach(staff => { | |
| let sumL = 0, sumR = 0, count = 0; | |
| staff.lines.forEach(ly => { | |
| // Check a few rows around the detected line Y | |
| for (let dy = -1; dy <= 1; dy++) { | |
| const yy = ly + dy; | |
| if (yy >= 0 && yy < h && lineLeftX[yy] < w && lineRightX[yy] > 0) { | |
| sumL += lineLeftX[yy]; | |
| sumR += lineRightX[yy]; | |
| count++; | |
| } | |
| } | |
| }); | |
| staff.leftX = count > 0 ? Math.round(sumL / count) : 0; | |
| staff.rightX = count > 0 ? Math.round(sumR / count) : w; | |
| }); | |
| console.log(`Staff detection: ${candidates.length} candidate rows → ${merged.length} line positions → ${staves.length} staves`); | |
| console.log(` lineThreshold=${lineThreshold.toFixed(3)}, minRunLength=${minRunLength}`); | |
| staves.forEach((s, si) => console.log(` Staff ${si}: lines=[${s.lines.join(",")}] spacing=${s.lineSpacing.toFixed(1)} X=[${s.leftX}..${s.rightX}]`)); | |
| return staves; | |
| } | |
| function groupIntoStaves(lines) { | |
| if (lines.length < 5) return []; | |
| const staves = []; | |
| const usedLines = new Set(); // track used Y values | |
| // Spacing-based search: for each pair (i, j), treat gap as candidate spacing, | |
| // then look for 3 more lines at 2x, 3x, 4x that spacing. | |
| // This handles noise lines between staff lines. | |
| const tolerance = 3; // pixels | |
| function findNearest(targetY) { | |
| let best = -1, bestDist = Infinity; | |
| for (let k = 0; k < lines.length; k++) { | |
| const dist = Math.abs(lines[k] - targetY); | |
| if (dist < bestDist) { bestDist = dist; best = k; } | |
| } | |
| return bestDist <= tolerance ? best : -1; | |
| } | |
| // Collect all valid 5-line groups, sorted by quality (lowest maxDev first) | |
| const candidates = []; | |
| for (let i = 0; i < lines.length; i++) { | |
| for (let j = i + 1; j < lines.length; j++) { | |
| const spacing = lines[j] - lines[i]; | |
| if (spacing < 5 || spacing > 35) continue; | |
| // Look for lines at i + 2*spacing, 3*spacing, 4*spacing | |
| const idx2 = findNearest(lines[i] + spacing * 2); | |
| if (idx2 < 0) continue; | |
| const idx3 = findNearest(lines[i] + spacing * 3); | |
| if (idx3 < 0) continue; | |
| const idx4 = findNearest(lines[i] + spacing * 4); | |
| if (idx4 < 0) continue; | |
| const group = [lines[i], lines[j], lines[idx2], lines[idx3], lines[idx4]]; | |
| group.sort((a, b) => a - b); | |
| const totalSpan = group[4] - group[0]; | |
| if (totalSpan >= 150) continue; | |
| const spacings = []; | |
| for (let k = 1; k < 5; k++) spacings.push(group[k] - group[k - 1]); | |
| const avgSpacing = spacings.reduce((a, b) => a + b, 0) / 4; | |
| const maxDev = Math.max(...spacings.map(s => Math.abs(s - avgSpacing))); | |
| if (maxDev < avgSpacing * 0.2) { | |
| candidates.push({ group, avgSpacing, maxDev }); | |
| } | |
| } | |
| } | |
| // Sort by quality: lowest deviation first | |
| candidates.sort((a, b) => a.maxDev - b.maxDev); | |
| // Greedily pick non-overlapping staves | |
| for (const c of candidates) { | |
| // Check none of the lines are already used | |
| if (c.group.some(y => usedLines.has(y))) continue; | |
| staves.push({ | |
| lines: c.group, | |
| topLineY: c.group[0], | |
| bottomLineY: c.group[4], | |
| lineSpacing: c.avgSpacing, | |
| }); | |
| c.group.forEach(y => usedLines.add(y)); | |
| } | |
| // Sort staves top to bottom | |
| staves.sort((a, b) => a.topLineY - b.topLineY); | |
| // Validate: all staves should have similar spacing (reject outliers) | |
| if (staves.length >= 2) { | |
| const spacings = staves.map(s => s.lineSpacing); | |
| const medianSpacing = spacings.slice().sort((a, b) => a - b)[Math.floor(spacings.length / 2)]; | |
| return staves.filter(s => Math.abs(s.lineSpacing - medianSpacing) < medianSpacing * 0.3); | |
| } | |
| return staves; | |
| } | |
| // ── Image-based barline detection (vertical run-length) ───── | |
| /** | |
| * Detect barlines in each staff system by scanning columns for vertical dark runs. | |
| * Must be called AFTER detectStaffLines (uses cachedImageData and staves). | |
| * @param {Array} staves - detected staff array | |
| * @param {number} numStavesPerSys - staves per system (e.g. 2 for piano) | |
| * @returns {Array} barlines - [{x, systemIdx, confidence, source}] | |
| */ | |
| function detectBarlines(staves, numStavesPerSys) { | |
| if (!cachedImageData || staves.length === 0) return []; | |
| const { data: pixels, width: imgW, height: imgH } = cachedImageData; | |
| const darkThreshold = 160; | |
| const staffSystems = mapStavesToSystems(staves, numStavesPerSys); | |
| const allBarlines = []; | |
| staffSystems.forEach((sysStaves, sysIdx) => { | |
| if (!sysStaves || sysStaves.length === 0) return; | |
| // Scan region: from first staff top to last staff bottom in this system | |
| const scanTop = Math.max(0, sysStaves[0].topLineY - 5); | |
| const scanBot = Math.min(imgH - 1, sysStaves[sysStaves.length - 1].bottomLineY + 5); | |
| const staffHeight = scanBot - scanTop; | |
| if (staffHeight < 10) return; | |
| const scanLeft = Math.max(0, sysStaves[0].leftX - 10); | |
| const scanRight = Math.min(imgW - 1, sysStaves[0].rightX + 10); | |
| // For each column, compute vertical dark run coverage within the staff region | |
| const columnScores = []; // {x, coverage, maxRun} | |
| for (let x = scanLeft; x <= scanRight; x++) { | |
| let darkCount = 0; | |
| let maxRun = 0, currentRun = 0; | |
| for (let y = scanTop; y <= scanBot; y++) { | |
| const idx = (y * imgW + x) * 4; | |
| const gray = (pixels[idx] + pixels[idx + 1] + pixels[idx + 2]) / 3; | |
| if (gray < darkThreshold) { | |
| darkCount++; | |
| currentRun++; | |
| if (currentRun > maxRun) maxRun = currentRun; | |
| } else { | |
| currentRun = 0; | |
| } | |
| } | |
| const coverage = darkCount / staffHeight; | |
| // Barline criterion: high coverage AND a long continuous vertical run | |
| // Staff lines contribute ~5px each (5 lines) = ~25px, but barlines span 80%+ | |
| if (coverage >= 0.55 && maxRun >= staffHeight * 0.35) { | |
| columnScores.push({ x, coverage, maxRun }); | |
| } | |
| } | |
| // Cluster adjacent candidate columns (barlines are 1-4px wide) | |
| const clusters = []; | |
| let ci = 0; | |
| while (ci < columnScores.length) { | |
| let cj = ci; | |
| let sumX = 0, sumCov = 0, bestCov = 0, count = 0; | |
| while (cj < columnScores.length && columnScores[cj].x - columnScores[ci].x <= 6) { | |
| sumX += columnScores[cj].x * columnScores[cj].coverage; | |
| sumCov += columnScores[cj].coverage; | |
| if (columnScores[cj].coverage > bestCov) bestCov = columnScores[cj].coverage; | |
| count++; | |
| cj++; | |
| } | |
| const centerX = Math.round(sumX / sumCov); | |
| const clusterWidth = count; | |
| // Stem filter: stems are typically 1-2px wide with lower coverage | |
| // Barlines: wider cluster OR very high coverage | |
| const isLikelyBarline = (bestCov >= 0.70) || (clusterWidth >= 2 && bestCov >= 0.55); | |
| if (isLikelyBarline) { | |
| // Confidence scoring | |
| let confidence; | |
| if (bestCov >= 0.85) confidence = 1.0; // high | |
| else if (bestCov >= 0.70) confidence = 0.7; // medium | |
| else confidence = 0.4; // low | |
| // Penalize very wide clusters (likely thick noteheads or brackets) | |
| if (clusterWidth > 6) confidence *= 0.5; | |
| clusters.push({ x: centerX, confidence, clusterWidth }); | |
| } | |
| ci = cj; | |
| } | |
| // Remove duplicates too close together (keep higher confidence) | |
| const minSeparation = 15; | |
| const filtered = []; | |
| for (const c of clusters) { | |
| const existing = filtered.find(f => Math.abs(f.x - c.x) < minSeparation); | |
| if (existing) { | |
| if (c.confidence > existing.confidence) { | |
| existing.x = c.x; | |
| existing.confidence = c.confidence; | |
| } | |
| } else { | |
| filtered.push({ ...c }); | |
| } | |
| } | |
| // Add to results | |
| filtered.forEach(b => { | |
| allBarlines.push({ | |
| x: b.x, | |
| systemIdx: sysIdx, | |
| confidence: Math.min(1.0, b.confidence), | |
| source: "auto" | |
| }); | |
| }); | |
| }); | |
| // Sort by system, then by X | |
| allBarlines.sort((a, b) => a.systemIdx - b.systemIdx || a.x - b.x); | |
| console.log(`Barline detection: ${allBarlines.length} barlines across ${staffSystems.length} systems`); | |
| staffSystems.forEach((_, si) => { | |
| const sysB = allBarlines.filter(b => b.systemIdx === si); | |
| console.log(` System ${si}: ${sysB.length} barlines at X=[${sysB.map(b=>b.x).join(",")}]`); | |
| }); | |
| return allBarlines; | |
| } | |
| /** Build a column-wise vertical feature map for snap-to-feature. | |
| * Returns Float32Array of per-column "barline-ness" scores for the given system. */ | |
| function buildVerticalFeatureMap(sysIdx, staves, numStavesPerSys) { | |
| if (!cachedImageData) return null; | |
| const { data: pixels, width: imgW, height: imgH } = cachedImageData; | |
| const darkThreshold = 160; | |
| const staffSystems = mapStavesToSystems(staves, numStavesPerSys); | |
| const sysStaves = staffSystems[sysIdx]; | |
| if (!sysStaves || sysStaves.length === 0) return null; | |
| const scanTop = Math.max(0, sysStaves[0].topLineY - 5); | |
| const scanBot = Math.min(imgH - 1, sysStaves[sysStaves.length - 1].bottomLineY + 5); | |
| const staffHeight = scanBot - scanTop; | |
| if (staffHeight < 10) return null; | |
| const scores = new Float32Array(imgW); | |
| for (let x = 0; x < imgW; x++) { | |
| let darkCount = 0; | |
| for (let y = scanTop; y <= scanBot; y++) { | |
| const idx = (y * imgW + x) * 4; | |
| const gray = (pixels[idx] + pixels[idx + 1] + pixels[idx + 2]) / 3; | |
| if (gray < darkThreshold) darkCount++; | |
| } | |
| scores[x] = darkCount / staffHeight; | |
| } | |
| return scores; | |
| } | |
| /** Interpolate missing staves when detected count doesn't match expected. | |
| * Inserts synthetic staves into the LARGEST gaps first, using within-system | |
| * spacing from detected stave pairs to predict exact positions. */ | |
| function interpolateMissingStaves(staves, expectedCount, numStavesPerSys) { | |
| if (staves.length === 0 || staves.length >= expectedCount || numStavesPerSys < 1) return staves; | |
| if (staves.length < 2) return staves; | |
| const avgLineSpacing = staves.reduce((s, st) => s + st.lineSpacing, 0) / staves.length; | |
| const staffSpan = avgLineSpacing * 4; // height of one stave (5 lines) | |
| const toInsertTotal = expectedCount - staves.length; | |
| // Compute all gaps between consecutive staves | |
| const gapInfos = []; | |
| for (let i = 1; i < staves.length; i++) { | |
| gapInfos.push({ | |
| idx: i, // index of the stave AFTER the gap | |
| gap: staves[i].topLineY - staves[i - 1].bottomLineY | |
| }); | |
| } | |
| // Estimate "normal" gap sizes by looking at the smallest gaps | |
| // For piano (numStavesPerSys=2): within-system gaps are the smallest | |
| // Sort gaps ascending, take the bottom half as "normal" gaps | |
| const sortedGapValues = gapInfos.map(g => g.gap).sort((a, b) => a - b); | |
| // The smallest gap is most likely a within-system gap (treble→bass) | |
| const withinGap = numStavesPerSys > 1 ? sortedGapValues[0] : 0; | |
| // Normal between-system gap: second-smallest distinct gap cluster | |
| const normalMaxGap = numStavesPerSys > 1 | |
| ? sortedGapValues.filter(g => g < sortedGapValues[0] * 2.0).reduce((a, b) => Math.max(a, b), sortedGapValues[0]) | |
| : sortedGapValues[0]; | |
| // Sort gaps DESCENDING — insert into largest gaps first | |
| const gapsBySize = gapInfos.slice().sort((a, b) => b.gap - a.gap); | |
| // A gap needs a staff inserted if it's significantly larger than normalMaxGap | |
| // Threshold: a normal gap + one staff height + one within-system gap | |
| const missingThreshold = normalMaxGap + staffSpan * 0.5; | |
| const avgLeftX = Math.round(staves.reduce((s, st) => s + st.leftX, 0) / staves.length); | |
| const avgRightX = Math.round(staves.reduce((s, st) => s + st.rightX, 0) / staves.length); | |
| function makeStaff(topY, refStaff) { | |
| const sp = refStaff ? refStaff.lineSpacing : avgLineSpacing; | |
| const lines = []; | |
| for (let k = 0; k < 5; k++) lines.push(Math.round(topY + k * sp)); | |
| return { | |
| lines, | |
| topLineY: lines[0], | |
| bottomLineY: lines[4], | |
| lineSpacing: sp, | |
| leftX: refStaff ? refStaff.leftX : avgLeftX, | |
| rightX: refStaff ? refStaff.rightX : avgRightX, | |
| interpolated: true | |
| }; | |
| } | |
| // Decide which gaps get insertions — insert into ALL gaps > threshold (don't limit by expectedCount) | |
| const insertions = []; // {afterStaveIdx, count} | |
| for (const gapInfo of gapsBySize) { | |
| if (gapInfo.gap <= missingThreshold) break; // remaining gaps are too small | |
| // How many staves fit in this gap? | |
| const extraSpace = gapInfo.gap - normalMaxGap; | |
| const possibleCount = Math.max(1, Math.round(extraSpace / (staffSpan + withinGap))); | |
| insertions.push({ afterIdx: gapInfo.idx - 1, gap: gapInfo.gap, count: possibleCount }); | |
| } | |
| // Build result array with insertions | |
| const result = []; | |
| const insertMap = new Map(); | |
| insertions.forEach(ins => insertMap.set(ins.afterIdx, ins)); | |
| for (let i = 0; i < staves.length; i++) { | |
| result.push(staves[i]); | |
| const ins = insertMap.get(i); | |
| if (ins) { | |
| // Insert synthetic staves after staves[i], before staves[i+1] | |
| for (let m = 0; m < ins.count; m++) { | |
| // Position using withinGap/betweenGap pattern instead of even distribution | |
| // Alternate: within-gap after previous, then between-gap, then within-gap, etc. | |
| let insertY; | |
| if (ins.count === 1) { | |
| // Single missing staff: place withinGap after previous | |
| insertY = staves[i].bottomLineY + withinGap; | |
| } else { | |
| // Multiple missing: place using alternating gaps from the previous staff | |
| const prevBottom = m === 0 ? staves[i].bottomLineY | |
| : result[result.length - 1].bottomLineY; | |
| const gapType = (result.length % numStavesPerSys === 0) ? normalMaxGap : withinGap; | |
| insertY = prevBottom + gapType; | |
| } | |
| const ref = staves[i]; | |
| const synth = makeStaff(Math.round(insertY), ref); | |
| result.push(synth); | |
| console.log(` Interpolated staff at Y=${synth.topLineY} (gap=${ins.gap}px between Staff ${i} and ${i+1})`); | |
| } | |
| } | |
| } | |
| // If total staves is odd for multi-staff systems, append one more after the last | |
| if (numStavesPerSys > 1 && result.length % numStavesPerSys !== 0) { | |
| const lastStaff = result[result.length - 1]; | |
| const insertY = lastStaff.bottomLineY + withinGap; | |
| const synth = makeStaff(Math.round(insertY), lastStaff); | |
| result.push(synth); | |
| console.log(` Interpolated staff at Y=${synth.topLineY} (appended to complete system pair)`); | |
| } | |
| result.sort((a, b) => a.topLineY - b.topLineY); | |
| console.log(`Stave interpolation: ${staves.length} detected → ${result.length} total (expected ${expectedCount})`); | |
| console.log(` withinGap=${withinGap.toFixed(0)}, normalMaxGap=${normalMaxGap.toFixed(0)}, threshold=${missingThreshold.toFixed(0)}`); | |
| return result; | |
| } | |
| /** Map detected staves to systems/staff-numbers. | |
| * For piano: staves come in pairs (treble, bass). | |
| * For single-staff: each staff = one system. */ | |
| function mapStavesToSystems(staves, numStavesPerSystem) { | |
| // Group staves into systems | |
| const systems = []; | |
| for (let i = 0; i < staves.length; i += numStavesPerSystem) { | |
| const group = staves.slice(i, i + numStavesPerSystem); | |
| systems.push(group); | |
| } | |
| return systems; | |
| } | |
| // ── Barline-based piecewise X mapping ──────────────────────── | |
| /** | |
| * Build per-system piecewise maps from detected barlines + XML measure boundaries. | |
| * | |
| * Strategy: each barline is matched to the nearest XML measure boundary. | |
| * This creates control points (pixelX ↔ tenthsX). Between control points, | |
| * X is linearly interpolated. Moving a barline changes WHERE a measure | |
| * boundary sits in pixel space, which warps the notes accordingly. | |
| * | |
| * Returns array indexed by systemIdx, each element is array of {xmlStart, xmlEnd, imgStart, imgEnd}. | |
| */ | |
| function buildBarlinePiecewiseMaps(layoutInfo, numStavesPerSys) { | |
| const hasBarlines = detectedBarlines && detectedBarlines.length > 0; | |
| const hasAnchors = xAnchors && xAnchors.length > 0; | |
| if ((!hasBarlines && !hasAnchors) || !systemsData.length || !layoutInfo) return null; | |
| const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys); | |
| const result = []; | |
| systemsData.forEach((sys, sysIdx) => { | |
| if (!sys.measures || sys.measures.length === 0) { result.push(null); return; } | |
| const sysLeftTenths = layoutInfo.marginL + sys.leftMargin; | |
| const sysTotalTenths = sys.cumulativeWidth; | |
| if (sysTotalTenths <= 0) { result.push(null); return; } | |
| // Get image extent from staff data | |
| const sysStaves = staffSystems[sysIdx]; | |
| const sysBarlines = hasBarlines | |
| ? detectedBarlines.filter(b => b.systemIdx === sysIdx).sort((a, b) => a.x - b.x) | |
| : []; | |
| const imgLeft = (sysStaves && sysStaves[0]) ? sysStaves[0].leftX | |
| : (sysBarlines.length > 0 ? sysBarlines[0].x : null); | |
| const imgRight = (sysStaves && sysStaves[0]) ? sysStaves[0].rightX | |
| : (sysBarlines.length > 0 ? sysBarlines[sysBarlines.length - 1].x : null); | |
| if (imgLeft == null || imgRight == null) { result.push(null); return; } | |
| const imgWidth = imgRight - imgLeft; | |
| if (imgWidth <= 0) { result.push(null); return; } | |
| // Build all XML measure boundaries (in tenths from page left) | |
| const boundaries = [sysLeftTenths]; | |
| sys.measures.forEach(m => { | |
| boundaries.push(sysLeftTenths + m.startX + m.width); | |
| }); | |
| // Build control points: staff edges or implicit barlines as first/last anchor. | |
| const controlPoints = []; // {imgX, tenthsX, priority} | |
| // Check if implicit barlines exist for this system (user may have dragged them) | |
| const sysImplicits = sysBarlines.filter(b => b.source === "implicit").sort((a, b) => a.x - b.x); | |
| const anchorLeft = sysImplicits.length >= 1 ? sysImplicits[0].x : imgLeft; | |
| const anchorRight = sysImplicits.length >= 2 ? sysImplicits[sysImplicits.length - 1].x : imgRight; | |
| // Anchor: left edge (implicit barline position or staff edge) | |
| controlPoints.push({ imgX: anchorLeft, tenthsX: boundaries[0], priority: 0 }); | |
| // 1) Add xAnchors for this system (highest priority — user-placed) | |
| const sysAnchors = hasAnchors ? xAnchors.filter(a => a.systemIdx === sysIdx) : []; | |
| for (const anchor of sysAnchors) { | |
| controlPoints.push({ imgX: anchor.pixelX, tenthsX: anchor.tenthsX, priority: 2 }); | |
| } | |
| // 2) Add detected barlines matched to measure boundaries (lower priority) | |
| const usedBoundaries = new Set([0, boundaries.length - 1]); | |
| const anchorWidth = anchorRight - anchorLeft; | |
| for (let bi = 0; bi < sysBarlines.length; bi++) { | |
| if (sysBarlines[bi].source === "implicit") continue; // already used as edge anchors | |
| const blRatio = (sysBarlines[bi].x - anchorLeft) / anchorWidth; | |
| if (blRatio < 0.02 || blRatio > 0.98) continue; | |
| let bestIdx = -1, bestDist = Infinity; | |
| for (let mi = 1; mi < boundaries.length - 1; mi++) { | |
| if (usedBoundaries.has(mi)) continue; | |
| const mRatio = (boundaries[mi] - sysLeftTenths) / sysTotalTenths; | |
| const dist = Math.abs(blRatio - mRatio); | |
| if (dist < bestDist) { bestDist = dist; bestIdx = mi; } | |
| } | |
| if (bestIdx >= 0 && bestDist < 0.3) { | |
| usedBoundaries.add(bestIdx); | |
| controlPoints.push({ imgX: sysBarlines[bi].x, tenthsX: boundaries[bestIdx], priority: 1 }); | |
| } | |
| } | |
| // Anchor: right edge (implicit barline position or staff edge) | |
| controlPoints.push({ imgX: anchorRight, tenthsX: boundaries[boundaries.length - 1], priority: 0 }); | |
| // Deduplicate: if two control points have close tenthsX, keep higher priority | |
| controlPoints.sort((a, b) => a.tenthsX - b.tenthsX); | |
| const deduped = []; | |
| for (const cp of controlPoints) { | |
| const last = deduped[deduped.length - 1]; | |
| if (last && Math.abs(cp.tenthsX - last.tenthsX) < 5) { | |
| // Close in tenths space: keep higher priority (or replace) | |
| if (cp.priority > last.priority) { | |
| deduped[deduped.length - 1] = cp; | |
| } | |
| } else { | |
| deduped.push(cp); | |
| } | |
| } | |
| // Sort by tenthsX for segment building | |
| deduped.sort((a, b) => a.tenthsX - b.tenthsX); | |
| // Build segments between consecutive control points | |
| const segments = []; | |
| for (let i = 0; i < deduped.length - 1; i++) { | |
| segments.push({ | |
| xmlStart: deduped[i].tenthsX, | |
| xmlEnd: deduped[i + 1].tenthsX, | |
| imgStart: deduped[i].imgX, | |
| imgEnd: deduped[i + 1].imgX | |
| }); | |
| } | |
| result.push(segments.length > 0 ? segments : null); | |
| }); | |
| return result; | |
| } | |
| /** | |
| * Map an X coordinate (in tenths) to pixel using piecewise barline mapping. | |
| * Returns null if no piecewise mapping available for this system. | |
| */ | |
| function mapXViaPiecewise(xTenths, systemIdx, piecewiseMaps) { | |
| if (!piecewiseMaps || !piecewiseMaps[systemIdx]) return null; | |
| const segments = piecewiseMaps[systemIdx]; | |
| // Find which segment this xTenths falls into | |
| for (const seg of segments) { | |
| if (xTenths >= seg.xmlStart && xTenths <= seg.xmlEnd) { | |
| const xmlWidth = seg.xmlEnd - seg.xmlStart; | |
| if (xmlWidth <= 0) return seg.imgStart; | |
| const ratio = (xTenths - seg.xmlStart) / xmlWidth; | |
| return seg.imgStart + ratio * (seg.imgEnd - seg.imgStart); | |
| } | |
| } | |
| // Outside all segments: extrapolate from nearest | |
| if (segments.length > 0) { | |
| if (xTenths < segments[0].xmlStart) { | |
| // Before first segment: extrapolate left | |
| const seg = segments[0]; | |
| const xmlWidth = seg.xmlEnd - seg.xmlStart; | |
| if (xmlWidth <= 0) return seg.imgStart; | |
| const ratio = (xTenths - seg.xmlStart) / xmlWidth; | |
| return seg.imgStart + ratio * (seg.imgEnd - seg.imgStart); | |
| } | |
| // After last segment: extrapolate right | |
| const seg = segments[segments.length - 1]; | |
| const xmlWidth = seg.xmlEnd - seg.xmlStart; | |
| if (xmlWidth <= 0) return seg.imgEnd; | |
| const ratio = (xTenths - seg.xmlStart) / xmlWidth; | |
| return seg.imgStart + ratio * (seg.imgEnd - seg.imgStart); | |
| } | |
| return null; | |
| } | |
| // ── Note positioning (image-based Y, XML-based X) ─────────── | |
| function computeNotePositions(notes, layoutInfo, ppt, userOffsetX, userOffsetY) { | |
| const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1; | |
| const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys); | |
| // Build piecewise barline X mapping per system (if barlines available) | |
| // Maps: for each system, an array of {xmlStartTenths, xmlEndTenths, imgStartX, imgEndX} | |
| const barlinePiecewise = buildBarlinePiecewiseMaps(layoutInfo, numStavesPerSys); | |
| // Precompute per-system per-staff X mapping from image-detected staff extents | |
| const sysStaffXMaps = systemsData.map((sys, sysIdx) => { | |
| const sysStaves = staffSystems[sysIdx]; | |
| if (!sysStaves || sysStaves.length === 0) return null; | |
| const xmlLeftTenths = layoutInfo.marginL + sys.leftMargin; | |
| const xmlWidthTenths = sys.cumulativeWidth; | |
| // Build a map per staff within this system | |
| return sysStaves.map(stave => { | |
| if (!stave || stave.leftX == null) return null; | |
| const imgLeftX = stave.leftX; | |
| const imgRightX = stave.rightX; | |
| const imgWidth = imgRightX - imgLeftX; | |
| return imgWidth > 0 ? { imgLeftX, imgRightX, imgWidth, xmlLeftTenths, xmlWidthTenths } : null; | |
| }); | |
| }); | |
| notes.forEach(n => { | |
| // n.staff from .omr is page-global (1-based); convert to system-local (1-based) | |
| const localStaffIdx = ((n.staff - 1) % numStavesPerSys) + 1; | |
| // Compute default-x based position (for playback cursor — guaranteed monotonic) | |
| const staffMaps = sysStaffXMaps[n.systemIdx]; | |
| const xMap = staffMaps ? (staffMaps[localStaffIdx - 1] || staffMaps[0]) : null; | |
| const xTenths = layoutInfo.marginL + n.systemLeftMargin + n.measureStartX + n.defaultX; | |
| if (n._omrBased) { | |
| // .omr notes: defaultX is already in pixels, skip tenths mapping | |
| n.pxDefault = n.defaultX + userOffsetX; | |
| } else { | |
| const piecewiseX = mapXViaPiecewise(xTenths, n.systemIdx, barlinePiecewise); | |
| if (piecewiseX !== null) { | |
| n.pxDefault = piecewiseX + userOffsetX; | |
| } else if (xMap && xMap.imgWidth > 0) { | |
| const xRatio = (xTenths - xMap.xmlLeftTenths) / xMap.xmlWidthTenths; | |
| n.pxDefault = xMap.imgLeftX + xRatio * xMap.imgWidth + userOffsetX; | |
| } else { | |
| n.pxDefault = xTenths * ppt + userOffsetX; | |
| } | |
| } | |
| // X for markers: prefer .omr pixel-perfect X if available | |
| if (n.omrX != null) { | |
| n.px = n.omrX + userOffsetX; | |
| } else { | |
| n.px = n.pxDefault; | |
| } | |
| // Y: use .omr pixel-perfect Y for unedited notes, staff-based for edited notes | |
| if (n.omrY != null && !n.modified) { | |
| n.py = n.omrY + userOffsetY; | |
| } else { | |
| // Staff-based Y calculation (for edited notes, rests, or no .omr data) | |
| const sysStaves = staffSystems[n.systemIdx]; | |
| const staffData = sysStaves ? sysStaves[localStaffIdx - 1] : null; | |
| // Helper: get OMR staff pixel data for this note | |
| const _pg = pages[currentPageIdx]; | |
| const _omrSys = n._omrBased && _pg && _pg.omrData && _pg.omrData.systems[n.systemIdx]; | |
| const _omrStaff = _omrSys && _omrSys.staves[localStaffIdx - 1]; | |
| const _omrLines = (_omrStaff && _omrStaff.lines && _omrStaff.lines.length >= 5) ? _omrStaff.lines : null; | |
| if (n.isRest) { | |
| // Rest: position at middle of staff (line 3 = staffPos 4) | |
| if (staffData) { | |
| n.py = (staffData.topLineY + staffData.bottomLineY) / 2 + userOffsetY; | |
| } else if (_omrLines) { | |
| n.py = (_omrLines[0].y1 + _omrLines[4].y1) / 2 + userOffsetY; | |
| } else { | |
| let staffTopY = n.systemTopY; | |
| if (localStaffIdx > 1) staffTopY += (localStaffIdx - 1) * (40 + n.staffDistance); | |
| n.py = (staffTopY + 20) * ppt + userOffsetY; | |
| } | |
| } else if (staffData) { | |
| const ref = clefReferencePosition(n.clef); | |
| const noteDiatonic = diatonicIndex(n.step, n.octave); | |
| const staffPos = ref.staffPosition + (noteDiatonic - ref.diatonicIdx); | |
| const halfSpacing = staffData.lineSpacing / 2; | |
| n.py = staffData.bottomLineY - (staffPos * halfSpacing) + userOffsetY; | |
| } else if (_omrLines) { | |
| // OMR mode: use .omr staves pixel coordinates directly | |
| const ref = clefReferencePosition(n.clef); | |
| const noteDiatonic = diatonicIndex(n.step, n.octave); | |
| const staffPos = ref.staffPosition + (noteDiatonic - ref.diatonicIdx); | |
| const botLineY = _omrLines[4].y1; | |
| const halfInterline = (_omrLines[4].y1 - _omrLines[0].y1) / 8; | |
| n.py = botLineY - (staffPos * halfInterline) + userOffsetY; | |
| } else { | |
| const ref = clefReferencePosition(n.clef); | |
| const noteDiatonic = diatonicIndex(n.step, n.octave); | |
| const staffPos = ref.staffPosition + (noteDiatonic - ref.diatonicIdx); | |
| let staffTopY = n.systemTopY; | |
| if (localStaffIdx > 1) staffTopY += (localStaffIdx - 1) * (40 + n.staffDistance); | |
| const yTenths = staffTopY + 40 - (staffPos * 5); | |
| n.py = yTenths * ppt + userOffsetY; | |
| } | |
| } // end of omrY else block | |
| }); | |
| } | |
| // ── Reverse mapping: pixel position → staff/pitch/measure ──── | |
| /** | |
| * Given a pixel (clickX, clickY) on the score image, determine: | |
| * { systemIdx, staffGlobal, clef, step, octave, measureNum, measureEl, snappedPy } | |
| * Returns null if position can't be resolved. | |
| */ | |
| function pixelToStaffPitch(clickX, clickY) { | |
| if (!systemsData.length || !detectedStaves.length) return null; | |
| const numStavesPerSys = systemsData[0].numStaves; | |
| const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys); | |
| const uy = parseInt(document.getElementById("offset-y").value) || 0; | |
| const ux = parseInt(document.getElementById("offset-x").value) || 0; | |
| // 1) Find system by Y | |
| let sysIdx = -1; | |
| let bestDist = Infinity; | |
| for (let si = 0; si < staffSystems.length; si++) { | |
| const sysStaves = staffSystems[si]; | |
| if (!sysStaves || sysStaves.length === 0) continue; | |
| const sysTop = sysStaves[0].topLineY - 40 + uy; | |
| const sysBot = sysStaves[sysStaves.length - 1].bottomLineY + 40 + uy; | |
| if (clickY >= sysTop && clickY <= sysBot) { sysIdx = si; break; } | |
| const mid = (sysTop + sysBot) / 2; | |
| const d = Math.abs(clickY - mid); | |
| if (d < bestDist) { bestDist = d; sysIdx = si; } | |
| } | |
| if (sysIdx < 0) return null; | |
| const sysStaves = staffSystems[sysIdx]; | |
| // 2) Find closest staff within system by Y | |
| let staffLocal = 0; // 0-based index into sysStaves | |
| bestDist = Infinity; | |
| for (let si = 0; si < sysStaves.length; si++) { | |
| const mid = (sysStaves[si].topLineY + sysStaves[si].bottomLineY) / 2 + uy; | |
| const d = Math.abs(clickY - mid); | |
| if (d < bestDist) { bestDist = d; staffLocal = si; } | |
| } | |
| const staffData = sysStaves[staffLocal]; | |
| const staffGlobal = staffLocal + 1; // 1-based | |
| // 3) Y → diatonic index → step + octave | |
| // Reverse of: py = bottomLineY - (staffPos * halfSpacing) + uy | |
| const halfSpacing = staffData.lineSpacing / 2; | |
| const staffPos = (staffData.bottomLineY + uy - clickY) / halfSpacing; | |
| const roundedStaffPos = Math.round(staffPos); | |
| // We need clef to convert staffPos → diatonic | |
| // Find the clef for this staff from the nearest note, or default | |
| let clef = { sign: staffLocal === 0 ? "G" : "F", line: staffLocal === 0 ? 2 : 4, octaveChange: 0 }; | |
| // Try to get clef from existing notes in this system+staff | |
| for (const n of noteInfos) { | |
| if (n.systemIdx === sysIdx && n.staff === staffGlobal) { | |
| clef = n.clef; | |
| break; | |
| } | |
| } | |
| const ref = clefReferencePosition(clef); | |
| const diatonic = ref.diatonicIdx + (roundedStaffPos - ref.staffPosition); | |
| const octave = Math.floor(diatonic / 7); | |
| const stepIdx = ((diatonic % 7) + 7) % 7; // handle negative | |
| const step = STEPS[stepIdx]; | |
| // Snapped Y position (for ghost marker) | |
| const snappedPy = staffData.bottomLineY + uy - roundedStaffPos * halfSpacing; | |
| // 4) X → measure | |
| // If sysIdx is beyond systemsData (e.g., Audiveris missed this system), flag it | |
| if (sysIdx >= systemsData.length) { | |
| return { systemIdx: sysIdx, staffGlobal: staffLocal + 1, staffLocal, clef, | |
| step, octave, measureNum: null, measureEl: null, defaultX: 0, | |
| snappedPy, snappedPx: clickX, snappedOnset: 0, beatLabel: "", | |
| _missingSystem: true }; | |
| } | |
| // ── OMR mode: use noteInfos px ranges to find measure, skip layout dependency ── | |
| if (isOmrMode() && !layout) { | |
| const sys = systemsData[sysIdx]; | |
| const cxNoOff = clickX - ux; | |
| // Find measure by comparing click X against existing notes' px ranges per measure | |
| let bestMeas = null; | |
| for (const m of sys.measures) { | |
| const measNotes = noteInfos.filter(n => n.systemIdx === sysIdx && String(n.measureNum) === String(m.number)); | |
| if (measNotes.length === 0) continue; | |
| const minPx = Math.min(...measNotes.map(n => n.omrX || n.px)) - 20; | |
| const maxPx = Math.max(...measNotes.map(n => n.omrX || n.px)) + 20; | |
| if (cxNoOff >= minPx && cxNoOff <= maxPx) { bestMeas = m; break; } | |
| if (!bestMeas) bestMeas = m; // fallback to first | |
| } | |
| if (!bestMeas && sys.measures.length > 0) bestMeas = sys.measures[0]; | |
| if (!bestMeas) return null; | |
| const measureNum = bestMeas.number; | |
| const measDuration = parseRational(bestMeas.duration || "1"); | |
| // Use measure left/right bounds from systemsData for precise X mapping | |
| const measLeft = bestMeas.left || 0; | |
| const measRight = bestMeas.right || (measLeft + 200); | |
| const measPxWidth = measRight - measLeft; | |
| // Grid size from timeline grid select, or default 1/8 | |
| const gridSel = document.getElementById("timeline-grid-select"); | |
| const gridSize = gridSel ? parseFloat(gridSel.value) || (measDuration / 8) : (measDuration / 8); | |
| // Map click X → onset via measure pixel bounds | |
| let snappedOnset = 0, snappedPx = clickX; | |
| if (measPxWidth > 0) { | |
| const ratio = Math.max(0, Math.min(1, (cxNoOff - measLeft) / measPxWidth)); | |
| const rawOnset = ratio * measDuration; | |
| snappedOnset = Math.round(rawOnset / gridSize) * gridSize; | |
| snappedOnset = Math.max(0, Math.min(snappedOnset, measDuration - gridSize)); | |
| // Compute snapped pixel position for ghost marker | |
| snappedPx = measLeft + (snappedOnset / measDuration) * measPxWidth + ux; | |
| } | |
| const beatLabel = `${durationFloatToRational(snappedOnset) || '0'}`; | |
| // For insertNoteOmr, snappedOnset needs to be in divisions*4 scale (divisions=1 → scale=4) | |
| const snappedOnsetDivScale = snappedOnset * 4; | |
| return { systemIdx: sysIdx, staffGlobal, staffLocal, clef, step, octave, | |
| measureNum, measureEl: null, defaultX: 0, | |
| snappedPy, snappedPx, snappedOnset: snappedOnsetDivScale, | |
| beatLabel, _omrMode: true }; | |
| } | |
| const sys = systemsData[sysIdx]; | |
| // Build per-staff X map (same as computeNotePositions) | |
| const xmlLeftTenths = layout.marginL + sys.leftMargin; | |
| const xmlWidthTenths = sys.cumulativeWidth; | |
| // Use the clicked staff's extents (not always staff[0]) | |
| const clickedStave = sysStaves[staffLocal] || sysStaves[0]; | |
| const imgLeftX = clickedStave.leftX; | |
| const imgRightX = clickedStave.rightX; | |
| const imgWidth = imgRightX - imgLeftX; | |
| // Convert clickX (pixel) → XML tenths using piecewise first, then staff linear | |
| const barlinePiecewise = buildBarlinePiecewiseMaps(layout, numStavesPerSys); | |
| let xTenths; | |
| // Try reverse piecewise: find which segment contains clickX and reverse-map | |
| const revPiecewise = barlinePiecewise[sysIdx]; | |
| let foundPiecewise = false; | |
| if (revPiecewise && revPiecewise.length > 0) { | |
| const cxNoOffset = clickX - ux; | |
| for (const seg of revPiecewise) { | |
| if (cxNoOffset >= seg.imgStartX && cxNoOffset <= seg.imgEndX) { | |
| const ratio = (seg.imgEndX - seg.imgStartX) > 0 | |
| ? (cxNoOffset - seg.imgStartX) / (seg.imgEndX - seg.imgStartX) : 0; | |
| xTenths = seg.xmlStartTenths + ratio * (seg.xmlEndTenths - seg.xmlStartTenths); | |
| foundPiecewise = true; | |
| break; | |
| } | |
| } | |
| } | |
| if (!foundPiecewise) { | |
| if (imgWidth > 0) { | |
| const xRatio = (clickX - ux - imgLeftX) / imgWidth; | |
| xTenths = xmlLeftTenths + xRatio * xmlWidthTenths; | |
| } else { | |
| xTenths = (clickX - ux) / pixelsPerTenth; | |
| } | |
| } | |
| // Find which measure this X falls into | |
| let measureNum = null; | |
| let measureEl = null; | |
| let defaultX = 0; | |
| for (const m of sys.measures) { | |
| const mStart = layout.marginL + sys.leftMargin + m.startX; | |
| const mEnd = mStart + m.width; | |
| if (xTenths >= mStart && xTenths < mEnd) { | |
| measureNum = m.number; | |
| measureEl = m.element; | |
| defaultX = xTenths - mStart; | |
| break; | |
| } | |
| } | |
| // Fallback: last measure | |
| if (!measureNum && sys.measures.length > 0) { | |
| const last = sys.measures[sys.measures.length - 1]; | |
| measureNum = last.number; | |
| measureEl = last.element; | |
| defaultX = xTenths - (layout.marginL + sys.leftMargin + last.startX); | |
| } | |
| // System exists in XML but has 0 measures | |
| if (!measureNum && sys.measures.length === 0) { | |
| return { systemIdx: sysIdx, staffGlobal: staffLocal + 1, staffLocal, clef, | |
| step, octave, measureNum: null, measureEl: null, defaultX: 0, | |
| snappedPy, snappedPx: clickX, snappedOnset: 0, beatLabel: "", | |
| _missingSystem: true }; | |
| } | |
| // 5) Compute snapped onset + snapped pixel X | |
| // Find measure width and total duration for grid snap | |
| let measureWidth = 200; | |
| let mStartTenths = 0; | |
| for (const m of sys.measures) { | |
| if (m.number === measureNum) { measureWidth = m.width; mStartTenths = layout.marginL + sys.leftMargin + m.startX; break; } | |
| } | |
| // Get divisions and measure total duration | |
| let snapDivisions = 1; | |
| const snapAttrEl = measureEl ? measureEl.querySelector("attributes") : null; | |
| if (snapAttrEl) { | |
| const divEl = snapAttrEl.querySelector("divisions"); | |
| if (divEl) snapDivisions = parseInt(divEl.textContent) || 1; | |
| } | |
| if (snapDivisions === 1) { | |
| for (const n of noteInfos) { | |
| if (n.measureNum === measureNum) { snapDivisions = n.divisions || 1; break; } | |
| } | |
| } | |
| let snapMeasureDur = 0; | |
| if (measureEl) { | |
| let cur = 0; | |
| for (const child of measureEl.children) { | |
| if (child.tagName === "note") { | |
| const dur = parseInt(child.querySelector("duration")?.textContent || "0"); | |
| if (!child.querySelector("chord")) cur += dur; | |
| if (cur > snapMeasureDur) snapMeasureDur = cur; | |
| } else if (child.tagName === "forward") { | |
| cur += parseInt(child.querySelector("duration")?.textContent || "0"); | |
| if (cur > snapMeasureDur) snapMeasureDur = cur; | |
| } else if (child.tagName === "backup") { | |
| cur -= parseInt(child.querySelector("duration")?.textContent || "0"); | |
| } | |
| } | |
| } | |
| // Clamp snapMeasureDur to time-signature duration (prevents multi-voice inflation) | |
| // Walk backwards from current measure to find effective time signature | |
| if (snapMeasureDur > 0 && measureEl && xmlDoc) { | |
| let tsBeats = 4, tsBeatType = 4; | |
| const partEl = measureEl.closest("part"); | |
| if (partEl) { | |
| const allMeasures = partEl.querySelectorAll("measure"); | |
| const curNum = parseInt(measureEl.getAttribute("number") || "0"); | |
| for (const m of allMeasures) { | |
| const mn = parseInt(m.getAttribute("number") || "0"); | |
| if (mn > curNum) break; | |
| const timeEl = m.querySelector("attributes > time"); | |
| if (timeEl) { | |
| tsBeats = parseInt(timeEl.querySelector("beats")?.textContent || "4") || 4; | |
| tsBeatType = parseInt(timeEl.querySelector("beat-type")?.textContent || "4") || 4; | |
| } | |
| } | |
| } | |
| const timeSigDur = tsBeats * snapDivisions * (4 / tsBeatType); | |
| if (timeSigDur > 0) snapMeasureDur = Math.min(snapMeasureDur, timeSigDur); | |
| } | |
| if (snapMeasureDur === 0) snapMeasureDur = snapDivisions * 4; | |
| // Build onset↔px map from existing notes in this measure+system | |
| const onsetPxPairs = []; | |
| for (const n of noteInfos) { | |
| if (n.measureNum === measureNum && n.systemIdx === sysIdx && !n.isRest) { | |
| if (!onsetPxPairs.some(e => e.onset === n.onsetDiv)) { | |
| onsetPxPairs.push({ onset: n.onsetDiv, px: n.px }); | |
| } | |
| } | |
| } | |
| onsetPxPairs.sort((a, b) => a.onset - b.onset); | |
| const durMultiplier = DURATION_TYPES[addDurationType] || 1; | |
| const gridSize = snapDivisions * 0.25; | |
| let snappedOnset, snappedPx; | |
| if (onsetPxPairs.length >= 2) { | |
| // Add measure end boundary (onset=snapMeasureDur, px=end barline) for extrapolation | |
| // Estimate measure end px from last two notes' spacing | |
| const lastPair = onsetPxPairs[onsetPxPairs.length - 1]; | |
| const prevPair = onsetPxPairs[onsetPxPairs.length - 2]; | |
| const pxPerOnset = (lastPair.onset - prevPair.onset) > 0 | |
| ? (lastPair.px - prevPair.px) / (lastPair.onset - prevPair.onset) : 0; | |
| if (lastPair.onset < snapMeasureDur && pxPerOnset > 0) { | |
| onsetPxPairs.push({ onset: snapMeasureDur, px: lastPair.px + pxPerOnset * (snapMeasureDur - lastPair.onset) }); | |
| } | |
| // Convert clickX → onset using existing notes' px→onset mapping | |
| let rawOnset; | |
| const cxNoOff = clickX; | |
| if (cxNoOff <= onsetPxPairs[0].px) { | |
| // Extrapolate before first note | |
| rawOnset = onsetPxPairs[0].onset - (onsetPxPairs[0].px - cxNoOff) / (pxPerOnset || 1); | |
| if (rawOnset < 0) rawOnset = 0; | |
| } else if (cxNoOff >= onsetPxPairs[onsetPxPairs.length - 1].px) { | |
| // Extrapolate after last note | |
| rawOnset = onsetPxPairs[onsetPxPairs.length - 1].onset + (cxNoOff - onsetPxPairs[onsetPxPairs.length - 1].px) / (pxPerOnset || 1); | |
| } else { | |
| for (let i = 0; i < onsetPxPairs.length - 1; i++) { | |
| if (cxNoOff >= onsetPxPairs[i].px && cxNoOff <= onsetPxPairs[i + 1].px) { | |
| const pxRange = onsetPxPairs[i + 1].px - onsetPxPairs[i].px; | |
| const ratio = pxRange > 0 ? (cxNoOff - onsetPxPairs[i].px) / pxRange : 0; | |
| rawOnset = onsetPxPairs[i].onset + ratio * (onsetPxPairs[i + 1].onset - onsetPxPairs[i].onset); | |
| break; | |
| } | |
| } | |
| if (rawOnset == null) rawOnset = 0; | |
| } | |
| // Snap onset to grid, allow up to measure end | |
| snappedOnset = Math.round(rawOnset / gridSize) * gridSize; | |
| if (snappedOnset >= snapMeasureDur) snappedOnset = snapMeasureDur - gridSize; | |
| if (snappedOnset < 0) snappedOnset = 0; | |
| // Convert snappedOnset → pixel using same note mapping (reverse direction) | |
| const exactMatch = onsetPxPairs.find(e => e.onset === snappedOnset); | |
| if (exactMatch) { | |
| snappedPx = exactMatch.px; | |
| } else { | |
| let lo = onsetPxPairs[0], hi = onsetPxPairs[onsetPxPairs.length - 1]; | |
| for (let i = 0; i < onsetPxPairs.length - 1; i++) { | |
| if (onsetPxPairs[i].onset <= snappedOnset && onsetPxPairs[i + 1].onset >= snappedOnset) { | |
| lo = onsetPxPairs[i]; hi = onsetPxPairs[i + 1]; break; | |
| } | |
| } | |
| const range = hi.onset - lo.onset; | |
| const ratio = range > 0 ? (snappedOnset - lo.onset) / range : 0; | |
| snappedPx = lo.px + ratio * (hi.px - lo.px); | |
| } | |
| } else { | |
| // Fallback: XML tenths mapping (no/few existing notes) | |
| const xRatioInMeasure = Math.max(0, Math.min(1, defaultX / measureWidth)); | |
| const rawOnset = xRatioInMeasure * snapMeasureDur; | |
| snappedOnset = Math.round(rawOnset / gridSize) * gridSize; | |
| const maxOnset = snapMeasureDur - gridSize; | |
| if (maxOnset > 0 && snappedOnset > maxOnset) snappedOnset = maxOnset; | |
| if (snappedOnset < 0) snappedOnset = 0; | |
| const snappedDefaultXFb = snapMeasureDur > 0 ? (snappedOnset / snapMeasureDur * measureWidth) : 0; | |
| const snappedXTenths = mStartTenths + snappedDefaultXFb; | |
| const piecewisePx = mapXViaPiecewise(snappedXTenths, sysIdx, barlinePiecewise); | |
| if (piecewisePx !== null) { | |
| snappedPx = piecewisePx + ux; | |
| } else if (imgWidth > 0) { | |
| const sRatio = (snappedXTenths - xmlLeftTenths) / xmlWidthTenths; | |
| snappedPx = imgLeftX + sRatio * imgWidth + ux; | |
| } else { | |
| snappedPx = snappedXTenths * pixelsPerTenth + ux; | |
| } | |
| } | |
| const snappedDefaultX = snapMeasureDur > 0 ? (snappedOnset / snapMeasureDur * measureWidth) : 0; | |
| // Beat label: e.g. "beat 1", "beat 1.5" | |
| const beatNum = snappedOnset / snapDivisions; | |
| const beatLabel = Number.isInteger(beatNum) ? `beat ${beatNum + 1}` : `beat ${(beatNum + 1).toFixed(1)}`; | |
| return { systemIdx: sysIdx, staffGlobal, staffLocal, clef, step, octave, measureNum, measureEl, defaultX: snappedDefaultX, snappedPy, snappedPx, snappedOnset, beatLabel }; | |
| } | |
| // ── Add Mode: ghost marker + toggle ───────────────────────── | |
| function toggleAddMode() { | |
| addMode = !addMode; | |
| const statusEl = document.getElementById("status-mode"); | |
| if (statusEl) statusEl.textContent = addMode ? `[ADD ${DUR_SYMBOLS[addDurationType] || addDurationType}]` : ""; | |
| if (!addMode) hideGhostMarker(); | |
| } | |
| function showGhostMarker(px, py, step, octave, beatLabel) { | |
| if (!ghostMarker) { | |
| ghostMarker = document.createElementNS("http://www.w3.org/2000/svg", "circle"); | |
| ghostMarker.setAttribute("r", "8"); | |
| ghostMarker.classList.add("ghost-marker"); | |
| ghostMarker.style.pointerEvents = "none"; | |
| } | |
| if (!ghostLabel) { | |
| ghostLabel = document.createElementNS("http://www.w3.org/2000/svg", "text"); | |
| ghostLabel.classList.add("ghost-label"); | |
| ghostLabel.style.pointerEvents = "none"; | |
| } | |
| ghostMarker.setAttribute("cx", px); | |
| ghostMarker.setAttribute("cy", py); | |
| ghostLabel.setAttribute("x", px + 12); | |
| ghostLabel.setAttribute("y", py + 4); | |
| ghostLabel.textContent = `${step}${octave} [${beatLabel || ""}]`; | |
| if (!ghostMarker.parentNode) markerSvg.appendChild(ghostMarker); | |
| if (!ghostLabel.parentNode) markerSvg.appendChild(ghostLabel); | |
| } | |
| function hideGhostMarker() { | |
| if (ghostMarker && ghostMarker.parentNode) ghostMarker.remove(); | |
| if (ghostLabel && ghostLabel.parentNode) ghostLabel.remove(); | |
| } | |
| // ================================================================ | |
| // Section 3: SVG Marker Rendering | |
| // ================================================================ | |
| const SVG_NS = "http://www.w3.org/2000/svg"; | |
| // Playback cursor line (persistent SVG element, shown/hidden as needed) | |
| let playbackCursorLine = null; | |
| function ensurePlaybackCursor() { | |
| if (!playbackCursorLine) { | |
| playbackCursorLine = document.createElementNS(SVG_NS, "line"); | |
| playbackCursorLine.setAttribute("stroke", "#ff3333"); | |
| playbackCursorLine.setAttribute("stroke-width", "2.5"); | |
| playbackCursorLine.setAttribute("stroke-opacity", "0.85"); | |
| playbackCursorLine.style.display = "none"; | |
| playbackCursorLine.classList.add("playback-cursor"); | |
| } | |
| // Always re-append so it's on top of all markers | |
| if (playbackCursorLine.parentNode) playbackCursorLine.remove(); | |
| markerSvg.appendChild(playbackCursorLine); | |
| } | |
| function showPlaybackCursor(x, topY, bottomY) { | |
| ensurePlaybackCursor(); | |
| playbackCursorLine.setAttribute("x1", x); | |
| playbackCursorLine.setAttribute("x2", x); | |
| playbackCursorLine.setAttribute("y1", topY); | |
| playbackCursorLine.setAttribute("y2", bottomY); | |
| playbackCursorLine.style.display = ""; | |
| } | |
| function hidePlaybackCursor() { | |
| if (playbackCursorLine) playbackCursorLine.style.display = "none"; | |
| } | |
| /** Place cursor line at a note's X, spanning its system's full staff height */ | |
| function placeCursorAtNote(noteIdx) { | |
| const n = noteInfos[noteIdx]; | |
| if (!n) return; | |
| const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1; | |
| const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys); | |
| const sysStaves = staffSystems[n.systemIdx]; | |
| const uy = parseInt(document.getElementById("offset-y").value) || 0; | |
| let topY, bottomY; | |
| if (sysStaves && sysStaves.length > 0) { | |
| topY = sysStaves[0].topLineY - 20 + uy; | |
| bottomY = sysStaves[sysStaves.length - 1].bottomLineY + 20 + uy; | |
| } else { | |
| topY = n.py - 80; | |
| bottomY = n.py + 80; | |
| } | |
| showPlaybackCursor(n.px, topY, bottomY); | |
| } | |
| /** Find the nearest note index to a given X pixel coordinate */ | |
| /** Find the nearest note to a click position, considering both X and Y. | |
| * First determines which system the click is in (by Y), then finds nearest X within that system. */ | |
| function findNearestNote(clickX, clickY) { | |
| if (noteInfos.length === 0) return -1; | |
| // Determine which system the click is in using detected staves | |
| const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1; | |
| const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys); | |
| const uy = parseInt(document.getElementById("offset-y").value) || 0; | |
| let clickedSysIdx = -1; | |
| for (let si = 0; si < staffSystems.length; si++) { | |
| const sysStaves = staffSystems[si]; | |
| if (!sysStaves || sysStaves.length === 0) continue; | |
| const sysTop = sysStaves[0].topLineY - 40 + uy; | |
| const sysBot = sysStaves[sysStaves.length - 1].bottomLineY + 40 + uy; | |
| if (clickY >= sysTop && clickY <= sysBot) { | |
| clickedSysIdx = si; | |
| break; | |
| } | |
| } | |
| // If between systems, find closest system by Y distance | |
| if (clickedSysIdx < 0) { | |
| let bestDist = Infinity; | |
| for (let si = 0; si < staffSystems.length; si++) { | |
| const sysStaves = staffSystems[si]; | |
| if (!sysStaves || sysStaves.length === 0) continue; | |
| const mid = (sysStaves[0].topLineY + sysStaves[sysStaves.length - 1].bottomLineY) / 2 + uy; | |
| const d = Math.abs(clickY - mid); | |
| if (d < bestDist) { bestDist = d; clickedSysIdx = si; } | |
| } | |
| } | |
| // Find nearest note by X within that system | |
| let closestIdx = -1; | |
| let closestDist = Infinity; | |
| noteInfos.forEach((n, i) => { | |
| if (clickedSysIdx >= 0 && n.systemIdx !== clickedSysIdx) return; | |
| const dist = Math.abs(n.px - clickX); | |
| if (dist < closestDist) { closestDist = dist; closestIdx = i; } | |
| }); | |
| return closestIdx; | |
| } | |
| /** Get the playback time (seconds) for a given note index from the current timeline */ | |
| function getTimeForNoteIdx(noteIdx) { | |
| const bpm = parseInt(document.getElementById("bpm-input").value) || 120; | |
| const tl = buildTimeline(noteInfos, bpm); | |
| for (const evt of tl) { | |
| if (evt.noteIndices.includes(noteIdx)) return evt.timeSec; | |
| } | |
| return 0; | |
| } | |
| function clearMarkers() { | |
| markerSvg.innerHTML = ""; | |
| playbackCursorLine = null; // was inside innerHTML, so reference is stale | |
| } | |
| function durationLabel(n) { | |
| const durNames = I18N[currentLang].dur; | |
| if (n._omrBased && n.durationRational) { | |
| // OMR mode: durationRational is whole=1 based (e.g., "1/8" for eighth) | |
| // Convert to quarter=1 based for durNames lookup | |
| const wholeVal = parseRational(n.durationRational); | |
| const quarterVal = wholeVal * 4; // whole=1 → quarter=1 | |
| const name = durNames[quarterVal] || n.durationRational; | |
| return name; | |
| } | |
| if (!n.divisions || n.divisions <= 0) return `${n.durationDiv}div`; | |
| const beats = n.durationDiv / n.divisions; | |
| const name = durNames[beats] || `${beats}`; | |
| return `${name}(${beats})`; | |
| } | |
| // Hover tooltip element (reused) | |
| let hoverTooltip = null; | |
| function getHoverTooltip() { | |
| if (hoverTooltip) return hoverTooltip; | |
| const g = document.createElementNS(SVG_NS, "g"); | |
| g.setAttribute("id", "marker-tooltip"); | |
| g.style.pointerEvents = "none"; | |
| const rect = document.createElementNS(SVG_NS, "rect"); | |
| rect.setAttribute("rx", "4"); | |
| rect.setAttribute("ry", "4"); | |
| rect.setAttribute("fill", "rgba(0,0,0,0.85)"); | |
| rect.setAttribute("stroke", "#888"); | |
| rect.setAttribute("stroke-width", "1"); | |
| g.appendChild(rect); | |
| const text = document.createElementNS(SVG_NS, "text"); | |
| text.setAttribute("fill", "#fff"); | |
| text.setAttribute("font-size", "12"); | |
| text.setAttribute("font-family", "monospace"); | |
| g.appendChild(text); | |
| hoverTooltip = g; | |
| return g; | |
| } | |
| function showMarkerTooltip(circle) { | |
| const idx = parseInt(circle.dataset.idx); | |
| if (isNaN(idx) || !noteInfos[idx]) return; | |
| const n = noteInfos[idx]; | |
| const accStr = alterStr(n.alter); | |
| const pitch = n.isRest ? "Rest" : `${n.step}${accStr}${n.octave}`; | |
| const dur = durationLabel(n); | |
| const gradeStr = n.grade !== undefined ? ` G:${(n.grade * 100).toFixed(0)}%` : ""; | |
| // Check chord members | |
| let chordStr = ""; | |
| if (!n.isRest) { | |
| const chordMates = noteInfos.filter((mn, mi) => mi !== idx && !mn.isRest && | |
| String(mn.measureNum) === String(n.measureNum) && mn.voice === n.voice && | |
| Math.abs((mn.onsetDiv || 0) - (n.onsetDiv || 0)) < 0.001); | |
| if (chordMates.length > 0) { | |
| chordStr = ` [${chordMates.map(m => `${m.step}${alterStr(m.alter)}${m.octave}`).join("+")}]`; | |
| } | |
| } | |
| const label = `M${n.measureNum} ${pitch} ${dur}${gradeStr}${chordStr}`; | |
| const g = getHoverTooltip(); | |
| const text = g.querySelector("text"); | |
| const rect = g.querySelector("rect"); | |
| // Scale inverse to zoom so tooltip stays constant screen size | |
| const invZoom = 1 / currentZoom; | |
| const fontSize = 36 * invZoom; | |
| const charW = fontSize * 0.62; | |
| const padX = 8 * invZoom, padY = 6 * invZoom; | |
| const h = fontSize + padY; | |
| text.setAttribute("font-size", fontSize); | |
| text.textContent = label; | |
| const cx = parseFloat(circle.getAttribute("cx")); | |
| const cy = parseFloat(circle.getAttribute("cy")); | |
| const textLen = label.length * charW; | |
| text.setAttribute("x", cx - textLen / 2 + padX); | |
| text.setAttribute("y", cy - (20 * invZoom)); | |
| rect.setAttribute("x", cx - textLen / 2); | |
| rect.setAttribute("y", cy - (20 * invZoom) - fontSize); | |
| rect.setAttribute("width", textLen + padX * 2); | |
| rect.setAttribute("height", h + padY * 2); | |
| rect.setAttribute("rx", 4 * invZoom); | |
| rect.setAttribute("ry", 4 * invZoom); | |
| if (!g.parentNode) markerSvg.appendChild(g); | |
| g.style.display = ""; | |
| } | |
| function hideMarkerTooltip() { | |
| if (hoverTooltip) hoverTooltip.style.display = "none"; | |
| } | |
| function renderMarkers(notes) { | |
| clearMarkers(); | |
| hideMarkerTooltip(); | |
| const frag = document.createDocumentFragment(); | |
| notes.forEach((n, idx) => { | |
| const circle = document.createElementNS(SVG_NS, "circle"); | |
| circle.setAttribute("cx", n.px); | |
| circle.setAttribute("cy", n.py); | |
| circle.setAttribute("r", "8"); | |
| circle.classList.add("marker", getMarkerClass(n.partIndex || 0, n.voice)); | |
| if (n.isRest) circle.classList.add("rest-marker"); | |
| if (n.modified) circle.classList.add("modified"); | |
| if (n.grade !== undefined) { | |
| if (n.grade < 0.4) circle.classList.add("grade-low"); | |
| else if (n.grade < 0.6) circle.classList.add("grade-mid"); | |
| else if (n.grade < 0.75) circle.classList.add("grade-ok"); | |
| // >= 0.75: no extra class (default green/blue marker) | |
| } | |
| circle.dataset.idx = idx; | |
| frag.appendChild(circle); | |
| // Accidental label | |
| if (n.alter !== 0) { | |
| const txt = document.createElementNS(SVG_NS, "text"); | |
| txt.setAttribute("x", n.px + 8); | |
| txt.setAttribute("y", n.py + 5); | |
| txt.classList.add("acc-label"); | |
| txt.dataset.idx = idx; | |
| txt.textContent = alterStr(n.alter); | |
| frag.appendChild(txt); | |
| } | |
| }); | |
| markerSvg.appendChild(frag); | |
| // Render rhythm validation warnings after markers | |
| renderRhythmWarnings(); | |
| // Re-apply multi-select visuals after re-render | |
| if (scoreSelectedIndices.size > 0) _scoreUpdateSelectionVisuals(); | |
| // Re-render free glyph overlays if visible | |
| if (freeGlyphsVisible) renderFreeGlyphOverlays(); | |
| // Auto-refresh timeline panel if visible | |
| if (typeof timelinePanelVisible !== "undefined" && timelinePanelVisible && timelinePanelMeasure) { | |
| renderTimelinePanel(timelinePanelMeasure.measureNum, timelinePanelMeasure.systemIdx); | |
| } | |
| } | |
| // ── Debug: draw staff lines to verify Y positions ────────── | |
| let debugLinesVisible = false; | |
| function toggleDebugLines() { | |
| debugLinesVisible = !debugLinesVisible; | |
| const existing = markerSvg.querySelectorAll(".debug-line"); | |
| if (!debugLinesVisible) { | |
| existing.forEach(el => el.remove()); | |
| return; | |
| } | |
| const imgW = scoreImage.naturalWidth; | |
| const imgH = scoreImage.naturalHeight; | |
| const uy = parseFloat(offsetY.value || 0); | |
| const ux = parseFloat(offsetX.value || 0); | |
| // Draw detected staff lines (image-based) | |
| if (detectedStaves.length > 0) { | |
| detectedStaves.forEach((staff, sIdx) => { | |
| const color = sIdx % 2 === 0 ? "rgba(0,255,0,0.5)" : "rgba(255,165,0,0.5)"; | |
| staff.lines.forEach(lineY => { | |
| const line = document.createElementNS(SVG_NS, "line"); | |
| line.setAttribute("x1", 0); | |
| line.setAttribute("y1", lineY + uy); | |
| line.setAttribute("x2", imgW); | |
| line.setAttribute("y2", lineY + uy); | |
| line.setAttribute("stroke", color); | |
| line.setAttribute("stroke-width", "1"); | |
| line.classList.add("debug-line"); | |
| line.dataset.staffIdx = sIdx; | |
| line.style.pointerEvents = "none"; | |
| markerSvg.appendChild(line); | |
| }); | |
| // Label | |
| const midY = (staff.topLineY + staff.bottomLineY) / 2 + uy; | |
| const txt = document.createElementNS(SVG_NS, "text"); | |
| txt.setAttribute("x", 5); | |
| txt.setAttribute("y", midY); | |
| txt.setAttribute("fill", color); | |
| txt.setAttribute("font-size", "10"); | |
| txt.classList.add("debug-line"); | |
| txt.dataset.staffIdx = sIdx; | |
| txt.style.pointerEvents = "none"; | |
| txt.textContent = `Staff ${sIdx + 1} (sp=${staff.lineSpacing.toFixed(1)})`; | |
| markerSvg.appendChild(txt); | |
| }); | |
| } | |
| // Draw measure barlines (vertical debug lines) to diagnose X alignment | |
| if (layout && systemsData.length > 0) { | |
| const ppt = pixelsPerTenth; | |
| const numStavesPerSys2 = systemsData.length > 0 ? systemsData[0].numStaves : 1; | |
| const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys2); | |
| systemsData.forEach((sys, sysIdx) => { | |
| const sysStaves = staffSystems[sysIdx]; | |
| let yTop, yBot; | |
| if (sysStaves && sysStaves.length > 0) { | |
| yTop = sysStaves[0].topLineY - 10 + uy; | |
| yBot = sysStaves[sysStaves.length - 1].bottomLineY + 10 + uy; | |
| } else { | |
| yTop = sys.topY * ppt + uy - 5; | |
| yBot = yTop + 80; | |
| } | |
| // Use image-based X mapping if available (same logic as computeNotePositions) | |
| let xMap = null; | |
| if (sysStaves && sysStaves.length > 0 && sysStaves[0].leftX != null) { | |
| const imgLeftX = sysStaves[0].leftX; | |
| const imgRightX = sysStaves[0].rightX; | |
| const imgWidth = imgRightX - imgLeftX; | |
| const xmlLeftTenths = layout.marginL + sys.leftMargin; | |
| const xmlWidthTenths = sys.cumulativeWidth; | |
| if (imgWidth > 0 && xmlWidthTenths > 0) { | |
| xMap = { imgLeftX, imgWidth, xmlLeftTenths, xmlWidthTenths }; | |
| } | |
| } | |
| function tenthsToX(tenthsFromPageLeft) { | |
| if (xMap) { | |
| const ratio = (tenthsFromPageLeft - xMap.xmlLeftTenths) / xMap.xmlWidthTenths; | |
| return xMap.imgLeftX + ratio * xMap.imgWidth + ux; | |
| } | |
| return tenthsFromPageLeft * ppt + ux; | |
| } | |
| // Draw system start line (cyan) | |
| const sysStartX = tenthsToX(layout.marginL + sys.leftMargin); | |
| const sysLine = document.createElementNS(SVG_NS, "line"); | |
| sysLine.setAttribute("x1", sysStartX); | |
| sysLine.setAttribute("y1", yTop); | |
| sysLine.setAttribute("x2", sysStartX); | |
| sysLine.setAttribute("y2", yBot); | |
| sysLine.setAttribute("stroke", "rgba(0,255,255,0.7)"); | |
| sysLine.setAttribute("stroke-width", "2"); | |
| sysLine.classList.add("debug-line"); | |
| sysLine.dataset.sysIdx = sysIdx; | |
| sysLine.style.pointerEvents = "none"; | |
| markerSvg.appendChild(sysLine); | |
| // Draw XML measure barlines (magenta) — skip if detected barlines are shown | |
| if (!barlineOverlaysVisible) { | |
| sys.measures.forEach((m, mIdx) => { | |
| const barX = tenthsToX(layout.marginL + sys.leftMargin + m.startX + m.width); | |
| const barLine = document.createElementNS(SVG_NS, "line"); | |
| barLine.setAttribute("x1", barX); | |
| barLine.setAttribute("y1", yTop); | |
| barLine.setAttribute("x2", barX); | |
| barLine.setAttribute("y2", yBot); | |
| barLine.setAttribute("stroke", "rgba(255,0,255,0.5)"); | |
| barLine.setAttribute("stroke-width", "1"); | |
| barLine.classList.add("debug-line"); | |
| barLine.dataset.sysIdx = sysIdx; | |
| barLine.style.pointerEvents = "none"; | |
| markerSvg.appendChild(barLine); | |
| // Measure number label | |
| if (mIdx === 0 || mIdx === sys.measures.length - 1) { | |
| const mLabelX = tenthsToX(layout.marginL + sys.leftMargin + m.startX); | |
| const lbl = document.createElementNS(SVG_NS, "text"); | |
| lbl.setAttribute("x", mLabelX + 2); | |
| lbl.setAttribute("y", yTop - 2); | |
| lbl.setAttribute("fill", "rgba(255,0,255,0.8)"); | |
| lbl.setAttribute("font-size", "9"); | |
| lbl.classList.add("debug-line"); | |
| lbl.dataset.sysIdx = sysIdx; | |
| lbl.style.pointerEvents = "none"; | |
| lbl.textContent = `m${m.number}`; | |
| markerSvg.appendChild(lbl); | |
| } | |
| }); | |
| } | |
| }); | |
| } | |
| } | |
| // ── Barline overlay rendering ──────────────────────────────── | |
| function renderBarlineOverlays() { | |
| // Remove existing barline overlays | |
| markerSvg.querySelectorAll(".barline-overlay, .barline-label").forEach(el => el.remove()); | |
| if (!barlineOverlaysVisible || detectedBarlines.length === 0) return; | |
| const uy = parseFloat(offsetY.value || 0); | |
| const ux = parseFloat(offsetX.value || 0); | |
| const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1; | |
| const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys); | |
| detectedBarlines.forEach((bl, blIdx) => { | |
| const sysStaves = staffSystems[bl.systemIdx]; | |
| if (!sysStaves || sysStaves.length === 0) return; | |
| const yTop = sysStaves[0].topLineY - 8 + uy; | |
| const yBot = sysStaves[sysStaves.length - 1].bottomLineY + 8 + uy; | |
| const x = bl.x + ux; | |
| const line = document.createElementNS(SVG_NS, "line"); | |
| line.setAttribute("x1", x); | |
| line.setAttribute("y1", yTop); | |
| line.setAttribute("x2", x); | |
| line.setAttribute("y2", yBot); | |
| line.classList.add("barline-overlay"); | |
| line.dataset.blIdx = blIdx; | |
| // Confidence class | |
| if (bl.source === "manual") { | |
| line.classList.add("manual"); | |
| } else if (bl.source === "implicit") { | |
| line.classList.add("implicit"); | |
| } else if (bl.confidence >= 0.8) { | |
| line.classList.add("high"); | |
| } else if (bl.confidence >= 0.55) { | |
| line.classList.add("medium"); | |
| } else { | |
| line.classList.add("low"); | |
| } | |
| // In barline edit mode, make interactive | |
| if (barlineMode) { | |
| line.style.pointerEvents = "all"; | |
| line.style.cursor = "ew-resize"; | |
| } | |
| markerSvg.appendChild(line); | |
| // Store reference for quick access | |
| bl.svgEl = line; | |
| }); | |
| // Add measure number labels between barlines per system | |
| staffSystems.forEach((sysStaves, sysIdx) => { | |
| if (!sysStaves || sysStaves.length === 0) return; | |
| const sysBarlines = detectedBarlines | |
| .map((bl, i) => ({ ...bl, _idx: i })) | |
| .filter(bl => bl.systemIdx === sysIdx) | |
| .sort((a, b) => a.x - b.x); | |
| const yTop = sysStaves[0].topLineY - 12 + uy; | |
| sysBarlines.forEach((bl, i) => { | |
| if (i === 0) return; // no label before first barline | |
| const prevX = sysBarlines[i - 1].x + ux; | |
| const curX = bl.x + ux; | |
| const midX = (prevX + curX) / 2; | |
| const lbl = document.createElementNS(SVG_NS, "text"); | |
| lbl.setAttribute("x", midX); | |
| lbl.setAttribute("y", yTop); | |
| lbl.setAttribute("text-anchor", "middle"); | |
| lbl.classList.add("barline-label"); | |
| lbl.textContent = `${i}`; | |
| markerSvg.appendChild(lbl); | |
| }); | |
| }); | |
| } | |
| function toggleBarlineOverlays() { | |
| barlineOverlaysVisible = !barlineOverlaysVisible; | |
| renderBarlineOverlays(); | |
| const btn = document.getElementById("btn-show-barlines"); | |
| if (btn) { | |
| btn.style.background = barlineOverlaysVisible ? "#2a6a2a" : ""; | |
| btn.style.color = barlineOverlaysVisible ? "#fff" : ""; | |
| } | |
| } | |
| function _setSvgTitle(el, text) { | |
| let titleEl = el.querySelector("title"); | |
| if (!titleEl) { | |
| titleEl = document.createElementNS(SVG_NS, "title"); | |
| el.appendChild(titleEl); | |
| } | |
| titleEl.textContent = text; | |
| } | |
| function updateSingleMarker(idx) { | |
| const n = noteInfos[idx]; | |
| const circle = markerSvg.querySelector(`circle[data-idx="${idx}"]`); | |
| console.log("[updateMarker] idx=", idx, "circle?", !!circle, "px=", n.px, "py=", n.py); | |
| if (!circle) return; | |
| circle.setAttribute("cx", n.px); | |
| circle.setAttribute("cy", n.py); | |
| if (n.isRest) { | |
| circle.setAttribute("r", "6"); | |
| circle.setAttribute("class", "marker rest-marker" + (n.modified ? " modified" : "")); | |
| _setSvgTitle(circle, `M${n.measureNum}: rest staff${n.staff} (voice ${n.voice})`); | |
| // Remove accidental label if any | |
| const accLabel = markerSvg.querySelector(`text[data-idx="${idx}"]`); | |
| if (accLabel) accLabel.remove(); | |
| return; | |
| } | |
| if (n.modified) circle.classList.add("modified"); | |
| else circle.classList.remove("modified"); | |
| const accStr = alterStr(n.alter); | |
| _setSvgTitle(circle, `M${n.measureNum}: ${n.step}${accStr}${n.octave} staff${n.staff} (voice ${n.voice})`); | |
| let accLabel = markerSvg.querySelector(`text[data-idx="${idx}"]`); | |
| if (n.alter !== 0) { | |
| if (!accLabel) { | |
| accLabel = document.createElementNS(SVG_NS, "text"); | |
| accLabel.classList.add("acc-label"); | |
| accLabel.dataset.idx = idx; | |
| markerSvg.appendChild(accLabel); | |
| } | |
| accLabel.setAttribute("x", n.px + 8); | |
| accLabel.setAttribute("y", n.py + 5); | |
| accLabel.textContent = accStr; | |
| } else if (accLabel) { | |
| accLabel.remove(); | |
| } | |
| } | |
| // ================================================================ | |
| // Section 4: Note Selection & Interaction | |
| // ================================================================ | |
| function selectNote(idx) { | |
| if (selectedIdx >= 0) { | |
| const prev = markerSvg.querySelector(`circle[data-idx="${selectedIdx}"]`); | |
| if (prev) prev.classList.remove("selected"); | |
| } | |
| selectedIdx = idx; | |
| if (idx >= 0 && idx < noteInfos.length) { | |
| const circle = markerSvg.querySelector(`circle[data-idx="${idx}"]`); | |
| if (circle) circle.classList.add("selected"); | |
| const n = noteInfos[idx]; | |
| const accStr = alterStr(n.alter); | |
| const partLabel = (n.partIndex !== undefined && n.partIndex > 0) ? ` P${n.partIndex + 1}` : ""; | |
| // Show duration info | |
| const typeEl = n.element ? n.element.querySelector("type") : null; | |
| const dotEl = n.element ? n.element.querySelector("dot") : null; | |
| let durText; | |
| if (typeEl) { | |
| durText = typeEl.textContent; | |
| } else if (n._omrBased && n.durationRational) { | |
| // Convert rational duration to note type name: "1/1"→whole, "1/2"→half, "1/4"→quarter, "1/8"→eighth, "1/16"→16th | |
| const RAT_TO_TYPE = {"1/1":"whole","1/2":"half","1/4":"quarter","1/8":"eighth","1/16":"16th","1/32":"32nd", | |
| "3/8":"quarter.","3/16":"eighth.","3/4":"half.","3/2":"whole."}; | |
| durText = RAT_TO_TYPE[n.durationRational] || n.durationRational; | |
| } else { | |
| durText = "?"; | |
| } | |
| const durLabel = durText + (dotEl || (n._omrBased && (findOmrChord(n) || {}).dotsNumber) ? "." : ""); | |
| const pitchLabel = n.isRest ? "rest" : `${n.step}${accStr}${n.octave}`; | |
| statusSel.textContent = `M${n.measureNum}: ${pitchLabel} [${durLabel}] staff${n.staff}${partLabel} (voice ${n.voice})`; | |
| } else { | |
| statusSel.textContent = t("no_sel"); | |
| } | |
| } | |
| function findChordsAtPosition(idx) { | |
| const target = noteInfos[idx]; | |
| const threshold = 8; | |
| const matches = []; | |
| noteInfos.forEach((n, i) => { | |
| if (n.systemIdx === target.systemIdx | |
| && Math.abs(n.px - target.px) < threshold | |
| && Math.abs(n.py - target.py) < 100) { | |
| matches.push(i); | |
| } | |
| }); | |
| return matches; | |
| } | |
| function showChordPopup(indices, clickX, clickY) { | |
| chordList.innerHTML = ""; | |
| indices.forEach(idx => { | |
| const n = noteInfos[idx]; | |
| const li = document.createElement("li"); | |
| const dot = document.createElement("span"); | |
| dot.classList.add("voice-dot"); | |
| const colors = { 1:"#4488ff", 2:"#ff4444", 3:"#44aa44", 4:"#ffaa00" }; | |
| dot.style.background = colors[n.voice] || "#888"; | |
| const accStr = alterStr(n.alter); | |
| li.appendChild(dot); | |
| li.appendChild(document.createTextNode(`${n.step}${accStr}${n.octave} staff${n.staff} (v${n.voice})`)); | |
| li.addEventListener("click", (e) => { | |
| e.stopPropagation(); | |
| selectNote(idx); | |
| hideChordPopup(); | |
| }); | |
| chordList.appendChild(li); | |
| }); | |
| chordPopup.style.left = clickX + "px"; | |
| chordPopup.style.top = clickY + "px"; | |
| chordPopup.classList.remove("hidden"); | |
| } | |
| function hideChordPopup() { | |
| chordPopup.classList.add("hidden"); | |
| } | |
| function onMarkerClick(e) { | |
| const circle = e.target.closest("circle.marker"); | |
| if (!circle) return; | |
| const idx = parseInt(circle.dataset.idx); | |
| if (e.ctrlKey || e.metaKey) { | |
| // Already handled in mousedown — skip to prevent double-toggle | |
| if (_scoreCtrlHandled) { _scoreCtrlHandled = false; return; } | |
| if (scoreSelectedIndices.has(idx)) { | |
| scoreSelectedIndices.delete(idx); | |
| if (selectedIdx === idx) selectedIdx = scoreSelectedIndices.size > 0 ? [...scoreSelectedIndices][0] : -1; | |
| } else { | |
| scoreSelectedIndices.add(idx); | |
| selectedIdx = idx; | |
| } | |
| _scoreUpdateSelectionVisuals(); | |
| hideChordPopup(); | |
| return; | |
| } | |
| const chordNotes = findChordsAtPosition(idx); | |
| if (chordNotes.length > 1) { | |
| const rect = document.getElementById("canvas-wrapper").getBoundingClientRect(); | |
| showChordPopup(chordNotes, e.clientX - rect.left + 10, e.clientY - rect.top); | |
| } else { | |
| scoreSelectOnly(idx); | |
| hideChordPopup(); | |
| } | |
| } | |
| function scoreSelectOnly(idx) { | |
| scoreSelectedIndices.clear(); | |
| scoreSelectedIndices.add(idx); | |
| selectNote(idx); | |
| _scoreUpdateSelectionVisuals(); | |
| } | |
| function _scoreUpdateSelectionVisuals() { | |
| markerSvg.querySelectorAll("circle.marker").forEach(c => { | |
| const i = parseInt(c.dataset.idx); | |
| if (scoreSelectedIndices.has(i)) c.classList.add("multi-selected"); | |
| else c.classList.remove("multi-selected"); | |
| }); | |
| // Update status bar | |
| if (scoreSelectedIndices.size > 1) { | |
| document.getElementById("status-selection").textContent = | |
| `${scoreSelectedIndices.size}개 선택`; | |
| } | |
| // Sync to TL selection | |
| tlSelectedIndices = new Set(scoreSelectedIndices); | |
| _tlUpdateSelectionVisuals(); | |
| } | |
| // ── Score rubber-band drag selection ── | |
| let _scoreRubberEl = null; | |
| let _scoreRubberStartX = 0; | |
| let _scoreRubberStartY = 0; | |
| let _scoreRubberActive = false; | |
| let _scoreRubberJustFinished = false; | |
| function _scoreRubberStart(e) { | |
| const wrapper = document.getElementById("canvas-wrapper"); | |
| const wrapRect = wrapper.getBoundingClientRect(); | |
| _scoreRubberStartX = e.clientX; | |
| _scoreRubberStartY = e.clientY; | |
| _scoreRubberActive = true; | |
| _scoreRubberEl = document.createElement("div"); | |
| _scoreRubberEl.className = "score-rubber"; | |
| _scoreRubberEl.style.position = "absolute"; | |
| _scoreRubberEl.style.left = (e.clientX - wrapRect.left + wrapper.scrollLeft) + "px"; | |
| _scoreRubberEl.style.top = (e.clientY - wrapRect.top + wrapper.scrollTop) + "px"; | |
| _scoreRubberEl.style.width = "0px"; | |
| _scoreRubberEl.style.height = "0px"; | |
| wrapper.appendChild(_scoreRubberEl); | |
| e.preventDefault(); | |
| } | |
| document.addEventListener("mousemove", (e) => { | |
| if (!_scoreRubberActive || !_scoreRubberEl) return; | |
| const wrapper = document.getElementById("canvas-wrapper"); | |
| const wrapRect = wrapper.getBoundingClientRect(); | |
| const curX = e.clientX - wrapRect.left + wrapper.scrollLeft; | |
| const curY = e.clientY - wrapRect.top + wrapper.scrollTop; | |
| const startX = _scoreRubberStartX - wrapRect.left + wrapper.scrollLeft; | |
| const startY = _scoreRubberStartY - wrapRect.top + wrapper.scrollTop; | |
| const left = Math.min(startX, curX); | |
| const top = Math.min(startY, curY); | |
| const width = Math.abs(curX - startX); | |
| const height = Math.abs(curY - startY); | |
| _scoreRubberEl.style.left = left + "px"; | |
| _scoreRubberEl.style.top = top + "px"; | |
| _scoreRubberEl.style.width = width + "px"; | |
| _scoreRubberEl.style.height = height + "px"; | |
| // Live selection: find markers inside rubber rect | |
| const rubberRect = { left, top, right: left + width, bottom: top + height }; | |
| const ctrlHeld = e.ctrlKey || e.metaKey; | |
| if (!ctrlHeld) scoreSelectedIndices.clear(); | |
| markerSvg.querySelectorAll("circle.marker").forEach(c => { | |
| const cx = parseFloat(c.getAttribute("cx")) * currentZoom; | |
| const cy = parseFloat(c.getAttribute("cy")) * currentZoom; | |
| if (cx >= rubberRect.left && cx <= rubberRect.right && | |
| cy >= rubberRect.top && cy <= rubberRect.bottom) { | |
| scoreSelectedIndices.add(parseInt(c.dataset.idx)); | |
| } | |
| }); | |
| _scoreUpdateSelectionVisuals(); | |
| }); | |
| document.addEventListener("mouseup", (e) => { | |
| if (!_scoreRubberActive) return; | |
| _scoreRubberActive = false; | |
| const didDrag = Math.abs(e.clientX - _scoreRubberStartX) > 5 || Math.abs(e.clientY - _scoreRubberStartY) > 5; | |
| _scoreRubberJustFinished = didDrag; | |
| if (_scoreRubberEl) { _scoreRubberEl.remove(); _scoreRubberEl = null; } | |
| if (didDrag && scoreSelectedIndices.size > 0) { | |
| const first = [...scoreSelectedIndices].sort((a, b) => a - b)[0]; | |
| selectNote(first); | |
| } | |
| }); | |
| // ================================================================ | |
| // Section 5: Undo/Redo + Editing | |
| // ================================================================ | |
| /** Save current state for undo */ | |
| function pushUndo() { | |
| const entry = { omrEditsLen: omrEdits.length }; | |
| const pg = pages[currentPageIdx]; | |
| if (isOmrMode()) { | |
| // OMR mode: snapshot omrData only, no xmlDoc dependency | |
| entry.omrDataSnapshot = JSON.parse(JSON.stringify(pg.omrData)); | |
| } else { | |
| // XML mode: snapshot xmlDoc | |
| if (!xmlDoc) return; | |
| entry.xml = xmlDoc.cloneNode(true); | |
| } | |
| undoStack.push(entry); | |
| if (undoStack.length > MAX_UNDO) undoStack.shift(); | |
| redoStack = []; // new edit clears redo | |
| } | |
| /** Restore state from a snapshot and re-parse everything */ | |
| function restoreFromSnapshot(entry) { | |
| if (entry.omrEditsLen != null) { | |
| omrEdits.length = entry.omrEditsLen; | |
| updateApplyBadge(); | |
| } | |
| const prevSelected = selectedIdx; | |
| const ux = parseInt(document.getElementById("offset-x").value) || 0; | |
| const uy = parseInt(document.getElementById("offset-y").value) || 0; | |
| if (entry.omrDataSnapshot) { | |
| // OMR mode: restore omrData, rebuild from it | |
| const pg = pages[currentPageIdx]; | |
| if (pg) pg.omrData = entry.omrDataSnapshot; | |
| rebuildSystemsAndNotes(); | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| } else { | |
| // XML mode: restore xmlDoc | |
| const snapshot = entry.xml || entry; | |
| xmlDoc = snapshot; | |
| layout = parseScoreLayout(xmlDoc); | |
| rebuildSystemsAndNotes(); | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| } | |
| renderMarkers(noteInfos); | |
| if (prevSelected >= 0 && prevSelected < noteInfos.length) { | |
| selectNote(prevSelected); | |
| } else { | |
| selectNote(-1); | |
| } | |
| } | |
| function _saveCurrentSnapshot() { | |
| const entry = { omrEditsLen: omrEdits.length }; | |
| const pg = pages[currentPageIdx]; | |
| if (isOmrMode()) { | |
| entry.omrDataSnapshot = JSON.parse(JSON.stringify(pg.omrData)); | |
| } else if (xmlDoc) { | |
| entry.xml = xmlDoc.cloneNode(true); | |
| } | |
| return entry; | |
| } | |
| function undo() { | |
| if (undoStack.length === 0) return; | |
| redoStack.push(_saveCurrentSnapshot()); | |
| restoreFromSnapshot(undoStack.pop()); | |
| } | |
| function redo() { | |
| if (redoStack.length === 0) return; | |
| undoStack.push(_saveCurrentSnapshot()); | |
| restoreFromSnapshot(redoStack.pop()); | |
| } | |
| // ── OMR Mode Helpers ───────────────────────────────────────── | |
| function isOmrMode() { | |
| const pg = pages[currentPageIdx]; | |
| return !!(pg && pg.omrData && pg.omrData.systems && pg.omrData.systems.length > 0); | |
| } | |
| function findOmrChord(n) { | |
| const pg = pages[currentPageIdx]; | |
| if (!pg || !pg.omrData) return null; | |
| const sys = pg.omrData.systems[n.systemIdx]; | |
| if (!sys) return null; | |
| const meas = sys.measures[n._omrMeasureIdx]; | |
| if (!meas) return null; | |
| const arr = n._isHeadChord ? meas.headChords : meas.restChords; | |
| return arr.find(c => c.chordId === n.omrChordId) || null; | |
| } | |
| function findOmrNoteInChord(chord, n) { | |
| if (!chord || !chord.heads) return null; | |
| return chord.heads.find(h => h.headId === n.omrHeadId) || null; | |
| } | |
| // ── OMR Edit Helpers ───────────────────────────────────────── | |
| /** | |
| * Convert MusicXML step+octave to Audiveris .omr staff-relative pitch. | |
| * Pitch 0 = middle line of staff. Increases DOWNWARD. | |
| * Treble (G clef): 0=B4, -1=C5, +1=A4 | |
| * Bass (F clef): 0=D3, -1=E3, +1=C3 | |
| */ | |
| const _DIATONIC = { C: 0, D: 1, E: 2, F: 3, G: 4, A: 5, B: 6 }; | |
| function stepOctaveToOmrPitch(step, octave, clefSign) { | |
| const diatonic = _DIATONIC[step] + octave * 7; | |
| // Middle line reference: Treble=B4(41), Bass=D3(22), Alto=B3(34) | |
| let midDiatonic; | |
| if (clefSign === "F") midDiatonic = _DIATONIC["D"] + 3 * 7; // D3 = 22 | |
| else if (clefSign === "C") midDiatonic = _DIATONIC["B"] + 3 * 7; // B3 = 34 | |
| else midDiatonic = _DIATONIC["B"] + 4 * 7; // B4 = 41 (treble default) | |
| return midDiatonic - diatonic; // positive = below middle line | |
| } | |
| function recordOmrEdit(edit) { | |
| if (!edit) return; | |
| omrEdits.push(edit); | |
| updateApplyBadge(); | |
| } | |
| function updateApplyBadge() { | |
| const badge = document.getElementById("omr-apply-badge"); | |
| if (badge) { | |
| badge.textContent = omrEdits.length > 0 ? omrEdits.length : ""; | |
| badge.style.display = omrEdits.length > 0 ? "inline-block" : "none"; | |
| } | |
| } | |
| // ── Multi-select editing dispatch ───────────────────────────── | |
| // Returns the active multi-selection indices (TL or score, whichever is active) | |
| function _getActiveSelection() { | |
| if (timelinePanelVisible && tlSelectedIndices.size > 1) return tlSelectedIndices; | |
| if (scoreSelectedIndices.size > 1) return scoreSelectedIndices; | |
| return null; | |
| } | |
| // Wraps single-note edit functions to apply to all selected notes. | |
| // Undo is pushed once for the batch. | |
| function _multiEdit(singleFn) { | |
| const sel = _getActiveSelection(); | |
| if (!sel) { | |
| singleFn(); | |
| return; | |
| } | |
| pushUndo(); | |
| const indices = [...sel].sort((a, b) => a - b); | |
| const _skipUndo = true; | |
| for (const idx of indices) { | |
| selectedIdx = idx; | |
| singleFn(_skipUndo); | |
| } | |
| // Re-render all markers and restore primary selection | |
| const ux = parseFloat(offsetX.value || 0); | |
| const uy = parseFloat(offsetY.value || 0); | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| if (indices.length > 0) selectNote(indices[0]); | |
| } | |
| // Delete multiple notes safely: collect IDs first, delete by ID in reverse order | |
| function _multiDelete() { | |
| const sel = _getActiveSelection(); | |
| if (!sel) { | |
| deleteSelectedNote(); | |
| return; | |
| } | |
| pushUndo(); | |
| // Collect stable identifiers for each selected note (reverse order: delete from end first) | |
| const targets = [...sel] | |
| .sort((a, b) => b - a) | |
| .map(idx => noteInfos[idx]) | |
| .filter(n => n); | |
| for (const target of targets) { | |
| // Find current index by headId or chordId (stable after re-index) | |
| const curIdx = noteInfos.findIndex(ni => | |
| target.omrHeadId ? (ni.omrHeadId === target.omrHeadId) : | |
| target.omrChordId ? (ni.omrChordId === target.omrChordId && ni.isRest === target.isRest) : | |
| false); | |
| if (curIdx < 0) continue; | |
| selectedIdx = curIdx; | |
| deleteSelectedNote(true); | |
| } | |
| scoreSelectedIndices.clear(); | |
| tlSelectedIndices.clear(); | |
| _scoreUpdateSelectionVisuals(); | |
| _tlUpdateSelectionVisuals(); | |
| const ux = parseFloat(offsetX.value || 0); | |
| const uy = parseFloat(offsetY.value || 0); | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| selectNote(-1); | |
| } | |
| // ── Pitch Editing ──────────────────────────────────────────── | |
| function raiseNote(skipUndo) { | |
| if (selectedIdx < 0) return; | |
| const n = noteInfos[selectedIdx]; | |
| if (n.isRest) return; | |
| if (!skipUndo) pushUndo(); | |
| const si = STEP_INDEX[n.step]; | |
| if (si === 6) { n.step = "C"; n.octave += 1; } | |
| else { n.step = STEPS[si + 1]; } | |
| n.alter = keyAlterForStep(n.step, n.fifths); | |
| if (isOmrMode() && n._omrBased) { | |
| // Direct omrData modification (Audiveris-style) | |
| const chord = findOmrChord(n); | |
| const head = chord ? findOmrNoteInChord(chord, n) : null; | |
| if (head) { | |
| head.pitch = stepOctaveToOmrPitch(n.step, n.octave, n.clef.sign); | |
| head.alter = n.alter; | |
| head.hasAccidental = (n.alter !== keyAlterForStep(n.step, n.fifths)); | |
| } else { | |
| console.warn("[raiseNote] findOmrChord/head failed:", n.omrChordId, n.omrHeadId, n._omrMeasureIdx); | |
| } | |
| markModified(n); | |
| applyPitchToXmlByHeadId(n); | |
| } else { | |
| applyPitchToXml(n); | |
| applyAlterOnly(n); | |
| } | |
| if (n.omrHeadId) { | |
| recordOmrEdit({ type: "change_pitch", headId: n.omrHeadId, | |
| newPitch: stepOctaveToOmrPitch(n.step, n.octave, n.clef.sign) }); | |
| } | |
| recomputeAndUpdate(selectedIdx); | |
| _previewNote(n); | |
| } | |
| function lowerNote(skipUndo) { | |
| if (selectedIdx < 0) return; | |
| const n = noteInfos[selectedIdx]; | |
| if (n.isRest) return; | |
| if (!skipUndo) pushUndo(); | |
| const si = STEP_INDEX[n.step]; | |
| if (si === 0) { n.step = "B"; n.octave -= 1; } | |
| else { n.step = STEPS[si - 1]; } | |
| n.alter = keyAlterForStep(n.step, n.fifths); | |
| if (isOmrMode() && n._omrBased) { | |
| const chord = findOmrChord(n); | |
| const head = chord ? findOmrNoteInChord(chord, n) : null; | |
| if (head) { | |
| head.pitch = stepOctaveToOmrPitch(n.step, n.octave, n.clef.sign); | |
| head.alter = n.alter; | |
| head.hasAccidental = (n.alter !== keyAlterForStep(n.step, n.fifths)); | |
| } else { | |
| console.warn("[lowerNote] findOmrChord/head failed:", n.omrChordId, n.omrHeadId, n._omrMeasureIdx); | |
| } | |
| markModified(n); | |
| applyPitchToXmlByHeadId(n); | |
| } else { | |
| applyPitchToXml(n); | |
| applyAlterOnly(n); | |
| } | |
| if (n.omrHeadId) { | |
| recordOmrEdit({ type: "change_pitch", headId: n.omrHeadId, | |
| newPitch: stepOctaveToOmrPitch(n.step, n.octave, n.clef.sign) }); | |
| } | |
| recomputeAndUpdate(selectedIdx); | |
| _previewNote(n); | |
| } | |
| function setAccidental(alterValue, skipUndo) { | |
| if (selectedIdx < 0) return; | |
| const n = noteInfos[selectedIdx]; | |
| if (n.isRest) return; | |
| if (!skipUndo) pushUndo(); | |
| if (n.alter === alterValue) { n.alter = 0; } | |
| else { n.alter = alterValue; } | |
| if (isOmrMode() && n._omrBased) { | |
| // Direct omrData modification | |
| const chord = findOmrChord(n); | |
| const head = chord ? findOmrNoteInChord(chord, n) : null; | |
| if (head) { | |
| head.alter = n.alter; | |
| head.hasAccidental = (n.alter !== 0); | |
| } else { | |
| console.warn("[setAccidental] findOmrChord/head failed:", n.omrChordId, n.omrHeadId, n._omrMeasureIdx); | |
| } | |
| markModified(n); | |
| applyPitchToXmlByHeadId(n); | |
| } else { | |
| applyAccidentalToXml(n); | |
| } | |
| // Record OMR edit | |
| if (n.omrHeadId) { | |
| if (n.alter === 0) { | |
| recordOmrEdit({ type: "remove_accidental", headId: n.omrHeadId }); | |
| } else { | |
| const accName = n.alter===2?"double-sharp":n.alter===1?"sharp":n.alter===-1?"flat":n.alter===-2?"flat-flat":"natural"; | |
| recordOmrEdit({ type: "add_accidental", headId: n.omrHeadId, accidental: accName }); | |
| } | |
| } | |
| recomputeAndUpdate(selectedIdx); | |
| _previewNote(n); | |
| } | |
| // ── Note Deletion ──────────────────────────────────────────── | |
| function deleteSelectedNote(skipUndo) { | |
| if (selectedIdx < 0) return; | |
| const n = noteInfos[selectedIdx]; | |
| if (!skipUndo) pushUndo(); | |
| // Rest → Delete = remove entirely | |
| if (n.isRest) { | |
| if (isOmrMode() && n._omrBased) { | |
| const pg = pages[currentPageIdx]; | |
| const sys = pg.omrData.systems[n.systemIdx]; | |
| const meas = sys.measures[n._omrMeasureIdx]; | |
| if (meas && meas.restChords) { | |
| meas.restChords = meas.restChords.filter(rc => rc.chordId !== n.omrChordId); | |
| } | |
| rebuildSystemsAndNotes(); | |
| const ux = parseInt(document.getElementById("offset-x").value) || 0; | |
| const uy = parseInt(document.getElementById("offset-y").value) || 0; | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| } else { | |
| // XML mode: remove element from DOM | |
| if (n.element && n.element.parentNode) { | |
| n.element.parentNode.removeChild(n.element); | |
| } | |
| noteInfos.splice(selectedIdx, 1); | |
| renderMarkers(noteInfos); | |
| } | |
| selectNote(Math.min(selectedIdx, noteInfos.length - 1)); | |
| return; | |
| } | |
| // Record OMR edit before modifying data | |
| if (n.omrHeadId && n.omrChordId) { | |
| const durType = n.element ? (n.element.querySelector("type")?.textContent || "quarter") : (n.headShape || "quarter"); | |
| const restShapeMap = { whole:"WHOLE_REST", half:"HALF_REST", quarter:"QUARTER_REST", | |
| eighth:"EIGHTH_REST", "16th":"16TH_REST", "32nd":"32ND_REST", | |
| NOTEHEAD_VOID:"HALF_REST", NOTEHEAD_BLACK:"QUARTER_REST" }; | |
| recordOmrEdit({ type: "delete_note", headId: n.omrHeadId, | |
| chordId: n.omrChordId, restShape: restShapeMap[durType] || "QUARTER_REST" }); | |
| } | |
| if (isOmrMode() && n._omrBased) { | |
| // ── OMR direct modification (Audiveris-style) ── | |
| const chord = findOmrChord(n); | |
| const isMultiHead = chord && chord.heads && chord.heads.length > 1; | |
| if (isMultiHead) { | |
| // Chord note: remove this head only, keep other heads | |
| chord.heads = chord.heads.filter(h => h.headId !== n.omrHeadId); | |
| // Rebuild from modified omrData | |
| rebuildSystemsAndNotes(); | |
| const ux = parseInt(document.getElementById("offset-x").value) || 0; | |
| const uy = parseInt(document.getElementById("offset-y").value) || 0; | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| selectNote(Math.min(selectedIdx, noteInfos.length - 1)); | |
| } else { | |
| // Single note → convert headChord to restChord in omrData | |
| if (chord) { | |
| const pg = pages[currentPageIdx]; | |
| const sys = pg.omrData.systems[n.systemIdx]; | |
| const meas = sys.measures[n._omrMeasureIdx]; | |
| // Build restChord from headChord data | |
| const restShape = n.headShape === "NOTEHEAD_VOID" ? "HALF_REST" : | |
| n.headShape === "WHOLE_NOTE" ? "WHOLE_REST" : "QUARTER_REST"; | |
| const newRest = { | |
| chordId: chord.chordId, | |
| restShape: restShape, | |
| duration: chord.duration, | |
| timeOffset: chord.timeOffset, | |
| voice: chord.voice, | |
| staff: n.staff, | |
| bounds: chord.heads[0] ? chord.heads[0].bounds : null, | |
| chordGrade: chord.heads[0] ? chord.heads[0].grade : 0, | |
| orphan: chord.orphan || false, | |
| }; | |
| meas.restChords.push(newRest); | |
| // Remove from headChords | |
| meas.headChords = meas.headChords.filter(hc => hc.chordId !== chord.chordId); | |
| } | |
| // Update noteInfo to rest state | |
| n.isRest = true; | |
| n.step = "R"; | |
| n.octave = 0; | |
| n.alter = 0; | |
| n._isHeadChord = false; | |
| markModified(n); | |
| // Rebuild from omrData | |
| rebuildSystemsAndNotes(); | |
| const ux = parseInt(document.getElementById("offset-x").value) || 0; | |
| const uy = parseInt(document.getElementById("offset-y").value) || 0; | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| const nextIdx = selectedIdx < noteInfos.length - 1 ? selectedIdx + 1 : selectedIdx - 1; | |
| if (nextIdx >= 0) selectNote(nextIdx); | |
| } | |
| } else { | |
| // ── XML mode ── | |
| if (n.isChord) { | |
| if (n.element && n.element.parentNode) { | |
| n.element.parentNode.removeChild(n.element); | |
| } | |
| const siblings = noteInfos.filter((other, i) => | |
| i !== selectedIdx && | |
| other.measureNum === n.measureNum && | |
| other.onsetDiv === n.onsetDiv && | |
| other.voice === n.voice | |
| ); | |
| if (siblings.length === 1 && siblings[0].isChord) { | |
| if (siblings[0].element) { | |
| const chordTag = siblings[0].element.querySelector("chord"); | |
| if (chordTag) chordTag.remove(); | |
| } | |
| siblings[0].isChord = false; | |
| } | |
| noteInfos.splice(selectedIdx, 1); | |
| renderMarkers(noteInfos); | |
| selectNote(Math.min(selectedIdx, noteInfos.length - 1)); | |
| } else { | |
| if (n.element) { | |
| const noteEl = n.element; | |
| const pitch = noteEl.querySelector("pitch"); | |
| if (pitch) noteEl.removeChild(pitch); | |
| const restEl = xmlDoc.createElement("rest"); | |
| const durEl = noteEl.querySelector("duration"); | |
| if (durEl) noteEl.insertBefore(restEl, durEl); | |
| else noteEl.appendChild(restEl); | |
| ["stem", "beam", "lyric", "accidental", "notehead", "tie", "slur"].forEach(tag => { | |
| noteEl.querySelectorAll(tag).forEach(el => el.remove()); | |
| }); | |
| } | |
| n.isRest = true; | |
| n.step = "R"; | |
| markModified(n); | |
| n.octave = 0; | |
| n.alter = 0; | |
| updateSingleMarker(selectedIdx); | |
| const nextIdx = selectedIdx < noteInfos.length - 1 ? selectedIdx + 1 : selectedIdx - 1; | |
| if (nextIdx >= 0) selectNote(nextIdx); | |
| } | |
| } | |
| } | |
| const _modifiedChordIds = new Set(); | |
| function markModified(n) { | |
| n.modified = true; | |
| n.omrY = null; // Clear pixel-perfect Y so staff-based Y is used after pitch change | |
| if (n.element) n.element.setAttribute("data-modified", "1"); | |
| if (n.omrChordId) _modifiedChordIds.add(n.omrChordId); | |
| } | |
| function applyPitchToXml(n) { | |
| const pitchEl = n.element.querySelector("pitch"); | |
| if (!pitchEl) return; | |
| pitchEl.querySelector("step").textContent = n.step; | |
| pitchEl.querySelector("octave").textContent = n.octave.toString(); | |
| markModified(n); | |
| } | |
| // Apply pitch change to xmlDoc by headId (for OMR mode where n.element doesn't exist) | |
| function applyPitchToXmlByHeadId(n) { | |
| if (!xmlDoc || !n.omrHeadId) return; | |
| const noteEl = xmlDoc.querySelector(`[data-omr-head-id="${n.omrHeadId}"]`); | |
| if (!noteEl) return; | |
| const pitchEl = noteEl.querySelector("pitch"); | |
| if (!pitchEl) return; | |
| const stepEl = pitchEl.querySelector("step"); | |
| const octaveEl = pitchEl.querySelector("octave"); | |
| if (stepEl) stepEl.textContent = n.step; | |
| if (octaveEl) octaveEl.textContent = n.octave.toString(); | |
| // Also update alter | |
| let alterEl = pitchEl.querySelector("alter"); | |
| if (n.alter !== 0) { | |
| if (!alterEl) { | |
| alterEl = xmlDoc.createElement("alter"); | |
| if (stepEl) stepEl.after(alterEl); | |
| else pitchEl.appendChild(alterEl); | |
| } | |
| alterEl.textContent = n.alter.toString(); | |
| } else if (alterEl) { | |
| alterEl.remove(); | |
| } | |
| noteEl.setAttribute("data-modified", "1"); | |
| } | |
| // Apply only <alter> in pitch (no <accidental>) — for key signature auto-apply | |
| function applyAlterOnly(n) { | |
| const pitchEl = n.element.querySelector("pitch"); | |
| if (!pitchEl) return; | |
| let alterEl = pitchEl.querySelector("alter"); | |
| if (n.alter !== 0) { | |
| if (!alterEl) { | |
| alterEl = xmlDoc.createElement("alter"); | |
| pitchEl.querySelector("step").after(alterEl); | |
| } | |
| alterEl.textContent = n.alter.toString(); | |
| } else if (alterEl) { | |
| alterEl.remove(); | |
| } | |
| // Remove <accidental> since key-sig accidentals are implicit | |
| const accEl = n.element.querySelector("accidental"); | |
| if (accEl) accEl.remove(); | |
| markModified(n); | |
| } | |
| function applyAccidentalToXml(n) { | |
| const pitchEl = n.element.querySelector("pitch"); | |
| if (!pitchEl) return; | |
| let alterEl = pitchEl.querySelector("alter"); | |
| if (n.alter !== 0) { | |
| if (!alterEl) { | |
| alterEl = xmlDoc.createElement("alter"); | |
| const stepEl = pitchEl.querySelector("step"); | |
| stepEl.after(alterEl); | |
| } | |
| alterEl.textContent = n.alter.toString(); | |
| } else if (alterEl) { | |
| alterEl.remove(); | |
| } | |
| let accEl = n.element.querySelector("accidental"); | |
| if (n.alter !== 0) { | |
| const accName = n.alter===2?"double-sharp":n.alter===1?"sharp":n.alter===-1?"flat":n.alter===-2?"flat-flat":"natural"; | |
| if (!accEl) { | |
| accEl = xmlDoc.createElement("accidental"); | |
| pitchEl.after(accEl); | |
| } | |
| accEl.textContent = accName; | |
| } else if (accEl) { | |
| accEl.remove(); | |
| } | |
| markModified(n); | |
| } | |
| // ── B3: Time Signature Editing ────────────────────────────── | |
| function editTimeSignature() { | |
| if (selectedIdx < 0) { alert(currentLang === "ko" ? "먼저 음표를 선택하세요." : "Select a note first."); return; } | |
| const n = noteInfos[selectedIdx]; | |
| const measureEl = n.element.parentNode; | |
| // Find current time sig for this measure | |
| let curBeats = 4, curBeatType = 4; | |
| // Walk back through XML to find last time sig | |
| const allParts = xmlDoc.querySelectorAll("part"); | |
| if (allParts.length > 0) { | |
| let found = false; | |
| const measures = allParts[0].querySelectorAll("measure"); | |
| for (const m of measures) { | |
| const timeEl = m.querySelector("attributes > time"); | |
| if (timeEl) { | |
| curBeats = parseInt(timeEl.querySelector("beats")?.textContent || "4"); | |
| curBeatType = parseInt(timeEl.querySelector("beat-type")?.textContent || "4"); | |
| } | |
| if (m.getAttribute("number") === n.measureNum) { found = true; break; } | |
| } | |
| } | |
| const input = prompt( | |
| currentLang === "ko" | |
| ? `박자표 변경 (마디 ${n.measureNum})\n현재: ${curBeats}/${curBeatType}\n새 박자표 (예: 3/4, 6/8):` | |
| : `Change time signature (measure ${n.measureNum})\nCurrent: ${curBeats}/${curBeatType}\nNew (e.g. 3/4, 6/8):`, | |
| `${curBeats}/${curBeatType}` | |
| ); | |
| if (!input) return; | |
| const parts = input.split("/"); | |
| if (parts.length !== 2) return; | |
| const newBeats = parseInt(parts[0]); | |
| const newBeatType = parseInt(parts[1]); | |
| if (isNaN(newBeats) || isNaN(newBeatType) || newBeats < 1 || newBeatType < 1) return; | |
| if (newBeats === curBeats && newBeatType === curBeatType) return; | |
| // beatType must be power of 2 | |
| if ((newBeatType & (newBeatType - 1)) !== 0) { | |
| alert(currentLang === "ko" ? "분모는 2의 거듭제곱이어야 합니다 (2, 4, 8, 16)." : "Denominator must be a power of 2."); | |
| return; | |
| } | |
| pushUndo(); | |
| // Find or create <attributes><time> in the target measure (in all parts for consistency) | |
| allParts.forEach(part => { | |
| const measures = part.querySelectorAll("measure"); | |
| for (const m of measures) { | |
| if (m.getAttribute("number") !== n.measureNum) continue; | |
| let attrEl = m.querySelector("attributes"); | |
| if (!attrEl) { | |
| attrEl = xmlDoc.createElement("attributes"); | |
| m.insertBefore(attrEl, m.firstChild); | |
| } | |
| let timeEl = attrEl.querySelector("time"); | |
| if (!timeEl) { | |
| timeEl = xmlDoc.createElement("time"); | |
| attrEl.appendChild(timeEl); | |
| } | |
| let beatsEl = timeEl.querySelector("beats"); | |
| if (!beatsEl) { beatsEl = xmlDoc.createElement("beats"); timeEl.appendChild(beatsEl); } | |
| let btEl = timeEl.querySelector("beat-type"); | |
| if (!btEl) { btEl = xmlDoc.createElement("beat-type"); timeEl.appendChild(btEl); } | |
| beatsEl.textContent = newBeats.toString(); | |
| btEl.textContent = newBeatType.toString(); | |
| } | |
| }); | |
| refreshAfterDurationChange(); | |
| } | |
| // ── B4: Key Signature Editing ────────────────────────────── | |
| const KEY_SIG_NAMES = { | |
| "-7":"Cb","−6":"Gb","-6":"Gb","-5":"Db","-4":"Ab","-3":"Eb","-2":"Bb","-1":"F", | |
| "0":"C","1":"G","2":"D","3":"A","4":"E","5":"B","6":"F#","7":"C#" | |
| }; | |
| function editKeySignature() { | |
| if (selectedIdx < 0) { alert(currentLang === "ko" ? "먼저 음표를 선택하세요." : "Select a note first."); return; } | |
| const n = noteInfos[selectedIdx]; | |
| const curFifths = n.fifths || 0; | |
| const curName = KEY_SIG_NAMES[curFifths.toString()] || `${curFifths}`; | |
| const input = prompt( | |
| currentLang === "ko" | |
| ? `조표 변경 (마디 ${n.measureNum})\n현재: ${curName} (fifths=${curFifths})\n새 fifths 값 (-7~+7):\n -3=Eb, -2=Bb, -1=F, 0=C, 1=G, 2=D, 3=A\n\n같은 값 입력 = 현재 조표를 누락된 음표에 재적용` | |
| : `Change key signature (measure ${n.measureNum})\nCurrent: ${curName} (fifths=${curFifths})\nNew fifths value (-7 to +7):\n -3=Eb, -2=Bb, -1=F, 0=C, 1=G, 2=D, 3=A\n\nSame value = re-apply key to notes missing alter`, | |
| curFifths.toString() | |
| ); | |
| if (input === null) return; | |
| const newFifths = parseInt(input); | |
| if (isNaN(newFifths) || newFifths < -7 || newFifths > 7) return; | |
| // Same value = re-apply key signature to notes missing alter (fix Audiveris gaps) | |
| if (newFifths === curFifths) { | |
| pushUndo(); | |
| const fixed = reapplyKeySignature(); | |
| if (fixed > 0) { | |
| refreshAfterDurationChange(); | |
| alert(currentLang === "ko" | |
| ? `조표 재적용: ${fixed}개 음표에 alter 추가됨` | |
| : `Key re-applied: ${fixed} notes fixed`); | |
| } else { | |
| alert(currentLang === "ko" | |
| ? "수정할 음표가 없습니다 (모든 음표에 이미 alter가 있음)" | |
| : "No notes to fix (all notes already have alter)"); | |
| undoStack.pop(); // nothing changed, remove undo entry | |
| } | |
| return; | |
| } | |
| pushUndo(); | |
| const oldKeyAlters = keyAlterFromFifths(curFifths); | |
| const newKeyAlters = keyAlterFromFifths(newFifths); | |
| // ── OMR direct modification ── | |
| if (isOmrMode()) { | |
| const pg = pages[currentPageIdx]; | |
| const sys = pg.omrData.systems[n.systemIdx]; | |
| if (!sys) return; | |
| // Update keySigs in omrData | |
| if (!sys.keySigs || sys.keySigs.length === 0) { | |
| sys.keySigs = [{ fifths: newFifths }]; | |
| } else { | |
| sys.keySigs[0].fifths = newFifths; | |
| } | |
| // Find affected measure range: from selected measure's system onward until next keySig change | |
| const targetMeasNum = parseInt(n.measureNum); | |
| // Re-alter notes from this measure onward in all systems until a system with different keySig | |
| for (let si = n.systemIdx; si < pg.omrData.systems.length; si++) { | |
| const curSys = pg.omrData.systems[si]; | |
| // Stop at systems that have their own keySig (different from what we're setting) | |
| if (si > n.systemIdx && curSys.keySigs && curSys.keySigs.length > 0) { | |
| const existingFifths = parseInt(curSys.keySigs[0].fifths); | |
| if (!isNaN(existingFifths) && existingFifths !== newFifths) break; | |
| // Same fifths → update it too | |
| curSys.keySigs[0].fifths = newFifths; | |
| } | |
| for (const meas of curSys.measures) { | |
| for (const hc of meas.headChords) { | |
| for (const head of hc.heads) { | |
| const { step } = omrPitchToStepOctave(head.pitch, | |
| noteInfos.find(ni => ni.systemIdx === si && ni.staff === head.staff)?.clef | |
| || { sign: "G", line: 2, octaveChange: 0 }); | |
| const curAlter = head.alter || 0; | |
| const oldKeyAlter = oldKeyAlters[step] || 0; | |
| // Skip notes with intentional explicit accidentals (differs from old key) | |
| if (Math.abs(curAlter - oldKeyAlter) > 0.01) continue; | |
| const newAlter = newKeyAlters[step] || 0; | |
| if (Math.abs(curAlter - newAlter) < 0.01) continue; | |
| head.alter = newAlter; | |
| } | |
| } | |
| } | |
| } | |
| // Update noteInfos fifths for affected notes | |
| for (let i = 0; i < noteInfos.length; i++) { | |
| const ni = noteInfos[i]; | |
| if (ni.systemIdx >= n.systemIdx) { | |
| ni.fifths = newFifths; | |
| } | |
| } | |
| recordOmrEdit({ type: "change_key_signature", systemIdx: n.systemIdx, newFifths }); | |
| refreshAfterDurationChange(); | |
| return; | |
| } | |
| // Update XML fifths in all parts | |
| const allParts = xmlDoc.querySelectorAll("part"); | |
| allParts.forEach(part => { | |
| const measures = part.querySelectorAll("measure"); | |
| let pastTarget = false; | |
| let nextKeyMeasure = null; // measure number where next key change occurs | |
| // First pass: find next key change after target measure | |
| let foundTarget = false; | |
| for (const m of measures) { | |
| const mNum = m.getAttribute("number"); | |
| if (mNum === n.measureNum) { foundTarget = true; continue; } | |
| if (foundTarget && m.querySelector("attributes > key > fifths")) { | |
| nextKeyMeasure = mNum; | |
| break; | |
| } | |
| } | |
| // Second pass: update fifths and re-alter notes in affected range | |
| let inRange = false; | |
| for (const m of measures) { | |
| const mNum = m.getAttribute("number"); | |
| if (mNum === n.measureNum) { | |
| inRange = true; | |
| // Set the new key signature | |
| let attrEl = m.querySelector("attributes"); | |
| if (!attrEl) { | |
| attrEl = xmlDoc.createElement("attributes"); | |
| m.insertBefore(attrEl, m.firstChild); | |
| } | |
| let keyEl = attrEl.querySelector("key"); | |
| if (!keyEl) { keyEl = xmlDoc.createElement("key"); attrEl.appendChild(keyEl); } | |
| let fifthsEl = keyEl.querySelector("fifths"); | |
| if (!fifthsEl) { fifthsEl = xmlDoc.createElement("fifths"); keyEl.appendChild(fifthsEl); } | |
| fifthsEl.textContent = newFifths.toString(); | |
| } | |
| if (mNum === nextKeyMeasure) inRange = false; // stop at next key change | |
| if (!inRange) continue; | |
| // Re-alter notes in this measure that don't have explicit accidentals | |
| const noteEls = m.querySelectorAll("note"); | |
| for (const noteEl of noteEls) { | |
| if (noteEl.querySelector("rest")) continue; | |
| const pitchEl = noteEl.querySelector("pitch"); | |
| if (!pitchEl) continue; | |
| const step = pitchEl.querySelector("step")?.textContent; | |
| if (!step) continue; | |
| const hasExplicitAccidental = noteEl.querySelector("accidental") !== null; | |
| if (hasExplicitAccidental) continue; // user/OMR set this explicitly, don't touch | |
| // Determine what alter this note currently has (explicit or from old key) | |
| let alterEl = pitchEl.querySelector("alter"); | |
| const curAlter = alterEl ? parseFloat(alterEl.textContent) : (oldKeyAlters[step] || 0); | |
| // What alter should it have under the new key? | |
| const newAlter = newKeyAlters[step] || 0; | |
| // If note had an explicit alter that matches old key, it was key-derived → update | |
| // If note had an explicit alter that differs from old key, it was intentional → skip | |
| if (alterEl && Math.abs(curAlter - (oldKeyAlters[step] || 0)) > 0.01) continue; | |
| if (Math.abs(curAlter - newAlter) < 0.01) continue; // no change needed | |
| // Update <alter> element | |
| if (newAlter !== 0) { | |
| if (!alterEl) { | |
| alterEl = xmlDoc.createElement("alter"); | |
| pitchEl.querySelector("step").after(alterEl); | |
| } | |
| alterEl.textContent = newAlter.toString(); | |
| } else if (alterEl) { | |
| alterEl.remove(); | |
| } | |
| } | |
| } | |
| }); | |
| refreshAfterDurationChange(); | |
| } | |
| /** | |
| * Re-apply key signature: for notes missing <alter> where the key implies one, | |
| * write the key-implied alter. Fixes Audiveris exports where key signature | |
| * was not recognized on some staves but notes were still written without alter. | |
| */ | |
| function reapplyKeySignature() { | |
| if (!xmlDoc) return; | |
| const allParts = xmlDoc.querySelectorAll("part"); | |
| let fixCount = 0; | |
| allParts.forEach(part => { | |
| let currentFifths = 0; | |
| const measures = part.querySelectorAll("measure"); | |
| measures.forEach(mEl => { | |
| const attrEl = mEl.querySelector("attributes"); | |
| if (attrEl) { | |
| const fifthsEl = attrEl.querySelector("key > fifths"); | |
| if (fifthsEl) currentFifths = parseInt(fifthsEl.textContent) || 0; | |
| } | |
| if (currentFifths === 0) return; // C major, nothing to apply | |
| const keyAlters = keyAlterFromFifths(currentFifths); | |
| const noteEls = mEl.querySelectorAll("note"); | |
| for (const noteEl of noteEls) { | |
| if (noteEl.querySelector("rest")) continue; | |
| const pitchEl = noteEl.querySelector("pitch"); | |
| if (!pitchEl) continue; | |
| const step = pitchEl.querySelector("step")?.textContent; | |
| if (!step) continue; | |
| const expectedAlter = keyAlters[step]; | |
| if (!expectedAlter) continue; // this step is not affected by key sig | |
| const alterEl = pitchEl.querySelector("alter"); | |
| const accEl = noteEl.querySelector("accidental"); | |
| // Only fix notes that have NO alter AND NO accidental | |
| // (i.e., Audiveris just didn't know about the key signature) | |
| if (!alterEl && !accEl) { | |
| const newAlterEl = xmlDoc.createElement("alter"); | |
| newAlterEl.textContent = expectedAlter.toString(); | |
| pitchEl.querySelector("step").after(newAlterEl); | |
| fixCount++; | |
| } | |
| } | |
| }); | |
| }); | |
| return fixCount; | |
| } | |
| // ── B5: Rhythm Validation ────────────────────────────────── | |
| /** | |
| * Validate rhythm: check each measure's total duration per voice against time signature. | |
| * Returns array of { measureNum, voice, expected, actual, partIdx } for mismatches. | |
| */ | |
| function _validateRhythmOmr() { | |
| const warnings = []; | |
| // Group noteInfos by measureNum+voice, sum durations | |
| const measVoice = {}; // "measNum|voice" → { totalDur, count, measureNum, voice, systemIdx } | |
| noteInfos.forEach(n => { | |
| if (n.isChord) return; // skip chord sub-notes, only count first head | |
| const key = `${n.measureNum}|${n.voice}`; | |
| const dur = n.durationDiv || 0; | |
| if (!measVoice[key]) { | |
| measVoice[key] = { totalDur: 0, count: 0, measureNum: String(n.measureNum), voice: n.voice, systemIdx: n.systemIdx }; | |
| } | |
| measVoice[key].totalDur += dur; | |
| measVoice[key].count++; | |
| }); | |
| // Find the primary (most notes) voice per measure to filter minor voices | |
| const measPrimary = {}; | |
| for (const info of Object.values(measVoice)) { | |
| const mKey = info.measureNum; | |
| if (!measPrimary[mKey] || info.count > measPrimary[mKey]) { | |
| measPrimary[mKey] = info.count; | |
| } | |
| } | |
| for (const [key, info] of Object.entries(measVoice)) { | |
| const expected = _tlGetMeasureDuration(info.measureNum, info.systemIdx); | |
| if (expected <= 0) continue; | |
| // Only validate the primary voice (most notes) per measure — secondary voices in OMR are unreliable | |
| if (info.count < measPrimary[info.measureNum]) continue; | |
| // Warn if voice total duration doesn't match measure duration | |
| if (Math.abs(info.totalDur - expected) > 0.01) { | |
| const expBeats = (expected * 4).toFixed(1); | |
| const actBeats = (info.totalDur * 4).toFixed(1); | |
| warnings.push({ | |
| measureNum: info.measureNum, | |
| voice: info.voice, | |
| expected: expBeats, | |
| actual: actBeats, | |
| }); | |
| } | |
| } | |
| return warnings; | |
| } | |
| function validateRhythm() { | |
| if (isOmrMode()) return _validateRhythmOmr(); | |
| if (!xmlDoc) return []; | |
| const warnings = []; | |
| const allParts = xmlDoc.querySelectorAll("part"); | |
| allParts.forEach((part, partIdx) => { | |
| let divisions = 1, beats = 4, beatType = 4; | |
| const measures = part.querySelectorAll("measure"); | |
| measures.forEach(mEl => { | |
| const attrEl = mEl.querySelector("attributes"); | |
| if (attrEl) { | |
| const divEl = attrEl.querySelector("divisions"); | |
| if (divEl) divisions = parseInt(divEl.textContent) || 1; | |
| const timeEl = attrEl.querySelector("time"); | |
| if (timeEl) { | |
| beats = parseInt(timeEl.querySelector("beats")?.textContent || "4"); | |
| beatType = parseInt(timeEl.querySelector("beat-type")?.textContent || "4"); | |
| } | |
| } | |
| const expectedDur = divisions * beats * (4 / beatType); | |
| const mNum = mEl.getAttribute("number"); | |
| // Compute actual duration per voice | |
| const voiceDurations = {}; // voice → total duration | |
| let cursor = 0; | |
| for (const child of mEl.children) { | |
| if (child.tagName === "note") { | |
| const dur = parseInt(child.querySelector("duration")?.textContent || "0"); | |
| const isChord = !!child.querySelector("chord"); | |
| const voice = child.querySelector("voice")?.textContent || "1"; | |
| if (!isChord) { | |
| if (!voiceDurations[voice]) voiceDurations[voice] = 0; | |
| voiceDurations[voice] += dur; | |
| } | |
| } else if (child.tagName === "forward") { | |
| } | |
| } | |
| for (const [voice, actual] of Object.entries(voiceDurations)) { | |
| if (Math.abs(actual - expectedDur) > 0.5) { | |
| warnings.push({ measureNum: mNum, voice, expected: expectedDur, actual, partIdx }); | |
| } | |
| } | |
| }); | |
| }); | |
| return warnings; | |
| } | |
| /** | |
| * Render rhythm warnings on the marker SVG as colored rectangles behind measures. | |
| */ | |
| function renderRhythmWarnings() { | |
| // Remove old warnings | |
| markerSvg.querySelectorAll(".rhythm-warning").forEach(el => el.remove()); | |
| const warnings = validateRhythm(); | |
| if (warnings.length === 0) return; | |
| // Build set of measure numbers with warnings | |
| const warnMeasures = new Set(warnings.map(w => w.measureNum)); | |
| // For each warned measure, find its X range from noteInfos and draw a background rect | |
| const uy = parseFloat(offsetY.value || 0); | |
| for (const mNum of warnMeasures) { | |
| const measNotes = noteInfos.filter(n => n.measureNum === mNum); | |
| if (measNotes.length === 0) continue; | |
| let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; | |
| for (const n of measNotes) { | |
| if (n.px < minX) minX = n.px; | |
| if (n.px > maxX) maxX = n.px; | |
| if (n.py < minY) minY = n.py; | |
| if (n.py > maxY) maxY = n.py; | |
| } | |
| const pad = 15; | |
| const rect = document.createElementNS(SVG_NS, "rect"); | |
| rect.setAttribute("x", minX - pad); | |
| rect.setAttribute("y", minY - pad); | |
| rect.setAttribute("width", Math.max(maxX - minX + pad * 2, 30)); | |
| rect.setAttribute("height", Math.max(maxY - minY + pad * 2, 30)); | |
| rect.setAttribute("fill", "rgba(255, 40, 40, 0.15)"); | |
| rect.setAttribute("stroke", "rgba(255, 60, 60, 0.6)"); | |
| rect.setAttribute("stroke-width", "1.5"); | |
| rect.setAttribute("rx", "4"); | |
| rect.classList.add("rhythm-warning"); | |
| rect.style.pointerEvents = "none"; | |
| // Tooltip with details | |
| const w = warnings.filter(w2 => w2.measureNum === mNum); | |
| const detail = w.map(w2 => | |
| `V${w2.voice}: ${w2.actual}/${w2.expected}` | |
| ).join(", "); | |
| const title = document.createElementNS(SVG_NS, "title"); | |
| title.textContent = currentLang === "ko" | |
| ? `마디 ${mNum} 박자 불일치: ${detail}` | |
| : `Measure ${mNum} rhythm mismatch: ${detail}`; | |
| rect.appendChild(title); | |
| // Insert before markers so it appears behind them | |
| if (markerSvg.firstChild) { | |
| markerSvg.insertBefore(rect, markerSvg.firstChild); | |
| } else { | |
| markerSvg.appendChild(rect); | |
| } | |
| } | |
| } | |
| // ── B6: Clef Editing ─────────────────────────────────────── | |
| const CLEF_OPTIONS = [ | |
| { label: "𝄞 Treble (G2)", sign: "G", line: 2 }, | |
| { label: "𝄢 Bass (F4)", sign: "F", line: 4 }, | |
| { label: "𝄡 Alto (C3)", sign: "C", line: 3 }, | |
| { label: "𝄡 Tenor (C4)", sign: "C", line: 4 }, | |
| ]; | |
| function editClef() { | |
| if (selectedIdx < 0) { alert(currentLang === "ko" ? "먼저 음표를 선택하세요." : "Select a note first."); return; } | |
| const n = noteInfos[selectedIdx]; | |
| const curClef = n.clef; | |
| const curDesc = `${curClef.sign}${curClef.line}`; | |
| const options = CLEF_OPTIONS.map((c, i) => `${i + 1}. ${c.label}`).join("\n"); | |
| const input = prompt( | |
| currentLang === "ko" | |
| ? `음자리표 변경 (마디 ${n.measureNum}, 보표 ${n.staff})\n현재: ${curDesc}\n선택:\n${options}` | |
| : `Change clef (measure ${n.measureNum}, staff ${n.staff})\nCurrent: ${curDesc}\nChoose:\n${options}`, | |
| CLEF_OPTIONS.findIndex(c => c.sign === curClef.sign && c.line === curClef.line) + 1 || "1" | |
| ); | |
| if (!input) return; | |
| const choice = parseInt(input) - 1; | |
| if (choice < 0 || choice >= CLEF_OPTIONS.length) return; | |
| const newClef = CLEF_OPTIONS[choice]; | |
| if (newClef.sign === curClef.sign && newClef.line === curClef.line) return; | |
| pushUndo(); | |
| const oldClefRef = clefReferencePosition(curClef); | |
| const newClefRef = clefReferencePosition({ sign: newClef.sign, line: newClef.line, octaveChange: 0 }); | |
| // Helper: diatonic index → (step, octave) | |
| function diatonicToStepOctave(di) { | |
| const oct = Math.floor(di / 7); | |
| const rem = ((di % 7) + 7) % 7; // handle negatives | |
| return { step: STEPS[rem], octave: oct }; | |
| } | |
| // Determine local staff number within the part | |
| const allParts = xmlDoc.querySelectorAll("part"); | |
| let localStaff = n.staff; | |
| let staffAccum = 0; | |
| for (const p of allParts) { | |
| const firstAttr = p.querySelector("measure > attributes"); | |
| const numStaves = firstAttr ? parseInt(firstAttr.querySelector("staves")?.textContent || "1") : 1; | |
| if (n.staff <= staffAccum + numStaves) { | |
| localStaff = n.staff - staffAccum; | |
| const measures = p.querySelectorAll("measure"); | |
| let inRange = false; | |
| let nextClefMeasure = null; | |
| // Find next clef change for this staff after target measure | |
| let foundTarget = false; | |
| for (const m of measures) { | |
| const mNum = m.getAttribute("number"); | |
| if (mNum === n.measureNum) { foundTarget = true; continue; } | |
| if (foundTarget) { | |
| const clefs = m.querySelectorAll("attributes clef"); | |
| for (const c of clefs) { | |
| const num = parseInt(c.getAttribute("number") || "1"); | |
| if (num === localStaff) { nextClefMeasure = mNum; break; } | |
| } | |
| if (nextClefMeasure) break; | |
| } | |
| } | |
| for (const m of measures) { | |
| const mNum = m.getAttribute("number"); | |
| if (mNum === n.measureNum) { | |
| inRange = true; | |
| // Write new clef to XML | |
| let attrEl = m.querySelector("attributes"); | |
| if (!attrEl) { | |
| attrEl = xmlDoc.createElement("attributes"); | |
| m.insertBefore(attrEl, m.firstChild); | |
| } | |
| let clefEl = null; | |
| const existingClefs = attrEl.querySelectorAll("clef"); | |
| for (const c of existingClefs) { | |
| const num = parseInt(c.getAttribute("number") || "1"); | |
| if (num === localStaff) { clefEl = c; break; } | |
| } | |
| if (!clefEl) { | |
| clefEl = xmlDoc.createElement("clef"); | |
| clefEl.setAttribute("number", localStaff.toString()); | |
| attrEl.appendChild(clefEl); | |
| } | |
| let signEl = clefEl.querySelector("sign"); | |
| if (!signEl) { signEl = xmlDoc.createElement("sign"); clefEl.appendChild(signEl); } | |
| signEl.textContent = newClef.sign; | |
| let lineEl = clefEl.querySelector("line"); | |
| if (!lineEl) { lineEl = xmlDoc.createElement("line"); clefEl.appendChild(lineEl); } | |
| lineEl.textContent = newClef.line.toString(); | |
| const ocEl = clefEl.querySelector("clef-octave-change"); | |
| if (ocEl) ocEl.remove(); | |
| } | |
| if (mNum === nextClefMeasure) inRange = false; | |
| if (!inRange) continue; | |
| // Re-interpret pitches on this staff | |
| const noteEls = m.querySelectorAll("note"); | |
| for (const noteEl of noteEls) { | |
| if (noteEl.querySelector("rest")) continue; | |
| const noteStaff = parseInt(noteEl.querySelector("staff")?.textContent || "1"); | |
| if (noteStaff !== localStaff) continue; | |
| const pitchEl = noteEl.querySelector("pitch"); | |
| if (!pitchEl) continue; | |
| const step = pitchEl.querySelector("step")?.textContent; | |
| const octave = parseInt(pitchEl.querySelector("octave")?.textContent || "4"); | |
| if (!step) continue; | |
| // Compute staff position under OLD clef | |
| const noteDiatonic = diatonicIndex(step, octave); | |
| const staffPos = oldClefRef.staffPosition + (noteDiatonic - oldClefRef.diatonicIdx); | |
| // Re-interpret under NEW clef → new diatonic index | |
| const newDiatonic = newClefRef.diatonicIdx + (staffPos - newClefRef.staffPosition); | |
| const newNote = diatonicToStepOctave(newDiatonic); | |
| // Write back to XML | |
| pitchEl.querySelector("step").textContent = newNote.step; | |
| pitchEl.querySelector("octave").textContent = newNote.octave.toString(); | |
| // Clear alter (accidentals become invalid after clef change) | |
| const alterEl = pitchEl.querySelector("alter"); | |
| if (alterEl) alterEl.remove(); | |
| const accEl = noteEl.querySelector("accidental"); | |
| if (accEl) accEl.remove(); | |
| } | |
| } | |
| break; | |
| } | |
| staffAccum += numStaves; | |
| } | |
| refreshAfterDurationChange(); | |
| } | |
| // ── Phase 3A: Note Insertion ──────────────────────────────── | |
| /** | |
| * Find the lowest available voice number for a given onset position and staff. | |
| * Walks the measure XML to determine which voices are occupied at targetOnset. | |
| * (Inspired by BeadSolver's BOS/EOS voice boundary concept) | |
| */ | |
| function findAvailableVoice(measureEl, targetOnset, localStaff) { | |
| const usedVoices = new Set(); | |
| let cursor = 0; | |
| for (const child of measureEl.children) { | |
| if (child.tagName === "note") { | |
| const dur = parseInt(child.querySelector("duration")?.textContent || "0"); | |
| const isChord = !!child.querySelector("chord"); | |
| const noteStaff = parseInt(child.querySelector("staff")?.textContent || "1"); | |
| const noteVoice = parseInt(child.querySelector("voice")?.textContent || "1"); | |
| if (!isChord) { | |
| // This note spans [cursor, cursor+dur). If targetOnset falls in this range | |
| // and it's on the same staff, that voice is occupied. | |
| if (cursor <= targetOnset && cursor + dur > targetOnset && noteStaff === localStaff) { | |
| usedVoices.add(noteVoice); | |
| } | |
| cursor += dur; | |
| } else if (noteStaff === localStaff) { | |
| // Chord members share the previous note's onset | |
| if (cursor <= targetOnset && cursor + dur > targetOnset) { | |
| usedVoices.add(noteVoice); | |
| } | |
| } | |
| } else if (child.tagName === "backup") { | |
| cursor -= parseInt(child.querySelector("duration")?.textContent || "0"); | |
| } else if (child.tagName === "forward") { | |
| cursor += parseInt(child.querySelector("duration")?.textContent || "0"); | |
| } | |
| } | |
| // Return the lowest unused voice number (1-4) | |
| for (let v = 1; v <= 4; v++) { | |
| if (!usedVoices.has(v)) return v; | |
| } | |
| return 1; // fallback | |
| } | |
| /** | |
| * Insert a new note directly into omrData (OMR mode). | |
| * Adds a headChord to the target measure in omrData, then rebuilds. | |
| */ | |
| function insertNoteOmr(info) { | |
| console.log("[insertNoteOmr] called, info=", JSON.stringify({measureNum:info.measureNum, systemIdx:info.systemIdx, step:info.step, octave:info.octave, staffGlobal:info.staffGlobal, snappedPx:info.snappedPx, snappedPy:info.snappedPy, snappedOnset:info.snappedOnset, _omrMode:info._omrMode})); | |
| if (!info.measureNum) { console.warn("[insertNoteOmr] BAIL: no measureNum"); return; } | |
| const pg = pages[currentPageIdx]; | |
| if (!pg || !pg.omrData) { console.warn("[insertNoteOmr] BAIL: no pg or omrData"); return; } | |
| // Find the target measure in omrData | |
| const sys = pg.omrData.systems[info.systemIdx]; | |
| if (!sys) { console.warn("[insertNoteOmr] BAIL: no system at idx", info.systemIdx, "total systems=", pg.omrData.systems.length); return; } | |
| // Compute measure index: omrData measures don't have a .number field. | |
| // Measure number is derived from system position during parsing. | |
| let globalMeasureBase = 1; | |
| for (let si = 0; si < info.systemIdx; si++) { | |
| const s = pg.omrData.systems[si]; | |
| globalMeasureBase += (s.stacks || []).length || 1; | |
| } | |
| const numStacks = (sys.stacks || []).length || 1; | |
| const stackIdx = parseInt(info.measureNum) - globalMeasureBase; | |
| if (stackIdx < 0 || stackIdx >= numStacks) { | |
| console.warn("[insertNoteOmr] BAIL: stackIdx", stackIdx, "out of range, measureNum=", info.measureNum, "globalMeasureBase=", globalMeasureBase, "numStacks=", numStacks); | |
| return; | |
| } | |
| // Determine which part this staff belongs to (for multi-part systems like piano) | |
| let partIdx = 0; | |
| const existingNote = noteInfos.find(n => n.systemIdx === info.systemIdx && n.staff === info.staffGlobal); | |
| if (existingNote) partIdx = existingNote.partIndex || 0; | |
| const mi = partIdx * numStacks + stackIdx; | |
| const meas = sys.measures[mi]; | |
| if (!meas) { console.warn("[insertNoteOmr] BAIL: no measure at mi=", mi, "total measures=", sys.measures.length, "partIdx=", partIdx, "stackIdx=", stackIdx); return; } | |
| console.log("[insertNoteOmr] found measure mi=", mi, "headChords=", meas.headChords.length, "restChords=", meas.restChords.length); | |
| pushUndo(); | |
| const measDuration = parseRational(meas.duration || "1"); | |
| // snappedOnset from pixelToStaffPitch is in divisions*4 scale (OMR: div=1 → scale 0..4) | |
| // Convert to whole-note fraction: divide by 4 | |
| let onset = (info.snappedOnset || 0) / 4; | |
| onset = Math.max(0, Math.min(onset, measDuration)); | |
| const timeOffsetRat = durationFloatToRational(onset); | |
| console.log("[insertNoteOmr] onset=", onset, "measDuration=", measDuration, "timeOffsetRat=", timeOffsetRat); | |
| // Duration from selected add type | |
| const durRat = TYPE_TO_RATIONAL[addDurationType] || "1/4"; | |
| const headShape = TYPE_TO_HEAD_SHAPE[addDurationType] || "NOTEHEAD_BLACK"; | |
| // Generate unique IDs (simple incrementing from max existing) | |
| let maxChordId = 0, maxHeadId = 0; | |
| for (const s of pg.omrData.systems) { | |
| for (const m of s.measures) { | |
| for (const hc of m.headChords) { | |
| const cid = parseInt(hc.chordId) || 0; | |
| if (cid > maxChordId) maxChordId = cid; | |
| for (const h of hc.heads) { | |
| const hid = parseInt(h.headId) || 0; | |
| if (hid > maxHeadId) maxHeadId = hid; | |
| } | |
| } | |
| for (const rc of m.restChords) { | |
| const cid = parseInt(rc.chordId) || 0; | |
| if (cid > maxChordId) maxChordId = cid; | |
| } | |
| } | |
| } | |
| const newChordId = String(maxChordId + 1); | |
| const newHeadId = String(maxHeadId + 1); | |
| // Build pitch | |
| const omrPitch = stepOctaveToOmrPitch(info.step, info.octave, info.clef.sign); | |
| // Determine voice: find first voice where new note doesn't overlap, else create new | |
| const newDur = parseRational(durRat); | |
| const newEnd = onset + newDur; | |
| const staffChords = [...meas.headChords, ...meas.restChords].filter(c => | |
| (c.heads ? c.heads.some(h => h.staff === info.staffGlobal) : (c.staff === info.staffGlobal)) | |
| ); | |
| let newVoice = 1; | |
| if (staffChords.length > 0) { | |
| // Group existing chords by voice | |
| const voiceRanges = {}; | |
| for (const c of staffChords) { | |
| const v = c.voice || 1; | |
| if (!voiceRanges[v]) voiceRanges[v] = []; | |
| const cOnset = parseRational(c.timeOffset || "0"); | |
| const cEnd = cOnset + parseRational(c.duration || "0"); | |
| voiceRanges[v].push([cOnset, cEnd]); | |
| } | |
| // Find first voice with no overlap | |
| let found = false; | |
| for (const v of Object.keys(voiceRanges).sort((a, b) => parseInt(a) - parseInt(b))) { | |
| const ranges = voiceRanges[v]; | |
| const overlaps = ranges.some(([s, e]) => onset < e && newEnd > s); | |
| if (!overlaps) { newVoice = parseInt(v); found = true; break; } | |
| } | |
| if (!found) { | |
| // All voices overlap — create new voice | |
| const maxV = Math.max(...Object.keys(voiceRanges).map(Number)); | |
| newVoice = maxV + 1; | |
| } | |
| } | |
| // Build headChord object | |
| const newChord = { | |
| chordId: newChordId, | |
| duration: durRat, | |
| timeOffset: timeOffsetRat, | |
| voice: newVoice, | |
| dotsNumber: 0, | |
| heads: [{ | |
| headId: newHeadId, | |
| pitch: omrPitch, | |
| alter: 0, | |
| staff: info.staffGlobal, | |
| shape: headShape, | |
| grade: 1.0, | |
| bounds: { x: Math.round(info.snappedPx || 0), y: Math.round(info.snappedPy || 0), w: 12, h: 12 }, | |
| }], | |
| }; | |
| meas.headChords.push(newChord); | |
| console.log("[insertNoteOmr] pushed newChord id=", newChordId, "headId=", newHeadId, "now headChords.length=", meas.headChords.length); | |
| // Rebuild from omrData | |
| rebuildSystemsAndNotes(); | |
| console.log("[insertNoteOmr] after rebuild, noteInfos.length=", noteInfos.length); | |
| const ux = parseInt(document.getElementById("offset-x").value) || 0; | |
| const uy = parseInt(document.getElementById("offset-y").value) || 0; | |
| console.log("[insertNoteOmr] layout=", !!layout, "pixelsPerTenth=", pixelsPerTenth, "ux=", ux, "uy=", uy); | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| // Select the newly added note | |
| const newIdx = noteInfos.findIndex(n => | |
| n.omrChordId === newChordId && n.omrHeadId === newHeadId | |
| ); | |
| console.log("[insertNoteOmr] newIdx=", newIdx, "searching for chordId=", newChordId, "headId=", newHeadId); | |
| if (newIdx >= 0) { | |
| console.log("[insertNoteOmr] selecting note at idx=", newIdx, "px=", noteInfos[newIdx].px, "py=", noteInfos[newIdx].py); | |
| selectNote(newIdx); | |
| } else { | |
| console.warn("[insertNoteOmr] note NOT FOUND in noteInfos after rebuild! Dumping omrChordIds:", noteInfos.map(n => n.omrChordId).filter((v,i,a) => a.indexOf(v) === i)); | |
| } | |
| } | |
| /** | |
| * Insert a new note at the position determined by pixelToStaffPitch(). | |
| * Default duration: quarter note. Voice auto-determined by onset overlap. | |
| */ | |
| function insertNoteAtPosition(info) { | |
| if (!xmlDoc || !info.measureEl) return; | |
| pushUndo(); | |
| // Determine which part's measure to insert into based on staffGlobal | |
| const allParts = xmlDoc.querySelectorAll("part"); | |
| let targetPart = allParts[0]; | |
| let localStaff = info.staffGlobal; | |
| let staffAccum = 0; | |
| for (let pi = 0; pi < allParts.length; pi++) { | |
| const firstAttr = allParts[pi].querySelector("measure > attributes"); | |
| const numStaves = firstAttr ? parseInt(firstAttr.querySelector("staves")?.textContent || "1") : 1; | |
| if (info.staffGlobal <= staffAccum + numStaves) { | |
| targetPart = allParts[pi]; | |
| localStaff = info.staffGlobal - staffAccum; | |
| break; | |
| } | |
| staffAccum += numStaves; | |
| } | |
| // Find the correct measure element in the target part | |
| let measureEl = null; | |
| const measures = targetPart.querySelectorAll("measure"); | |
| for (const m of measures) { | |
| if (m.getAttribute("number") === info.measureNum) { measureEl = m; break; } | |
| } | |
| if (!measureEl) measureEl = info.measureEl; | |
| // Find divisions for the actual target measure | |
| let divisions = 1; | |
| let divEl = measureEl.querySelector("attributes > divisions"); | |
| if (divEl) { | |
| divisions = parseInt(divEl.textContent) || 1; | |
| } else { | |
| // Fallback: get from noteInfos or walk earlier measures in same part | |
| for (const n of noteInfos) { | |
| if (n.measureNum === info.measureNum) { divisions = n.divisions || 1; break; } | |
| } | |
| } | |
| // Scale up divisions if current value can't represent the selected duration | |
| // e.g. divisions=1 can't represent eighth (needs 0.5 duration) → scale to 2 | |
| const durMultiplier = DURATION_TYPES[addDurationType] || 1; | |
| const requiredDivisions = Math.ceil(1 / durMultiplier); // eighth→2, 16th→4, quarter→1 | |
| if (divisions < requiredDivisions) { | |
| const scaleFactor = requiredDivisions / divisions; | |
| // Update <divisions> in XML (create <attributes> if needed) | |
| let attrEl = measureEl.querySelector("attributes"); | |
| if (!attrEl) { | |
| attrEl = xmlDoc.createElement("attributes"); | |
| measureEl.insertBefore(attrEl, measureEl.firstChild); | |
| } | |
| divEl = attrEl.querySelector("divisions"); | |
| if (!divEl) { | |
| divEl = xmlDoc.createElement("divisions"); | |
| attrEl.insertBefore(divEl, attrEl.firstChild); | |
| } | |
| divEl.textContent = requiredDivisions.toString(); | |
| // Scale all existing <duration> values in this measure | |
| for (const child of measureEl.children) { | |
| if (child.tagName === "note" || child.tagName === "forward" || child.tagName === "backup") { | |
| const durChild = child.querySelector("duration"); | |
| if (durChild) { | |
| const oldDur = parseInt(durChild.textContent) || 0; | |
| durChild.textContent = Math.round(oldDur * scaleFactor).toString(); | |
| } | |
| } | |
| } | |
| divisions = requiredDivisions; | |
| } | |
| const durationDiv = Math.max(1, Math.round(divisions * durMultiplier)); | |
| // Use pre-computed snapped onset from pixelToStaffPitch, scaled to actual divisions | |
| let targetOnset = info.snappedOnset != null ? info.snappedOnset : 0; | |
| // snappedOnset was computed with original divisions; scale if divisions changed | |
| const origDivisions = info.snappedOnset != null ? ((() => { | |
| // Re-derive original divisions from noteInfos (before our scaling) | |
| for (const n of noteInfos) { | |
| if (n.measureNum === info.measureNum) return n.divisions || 1; | |
| } | |
| return 1; | |
| })()) : 1; | |
| if (origDivisions !== divisions) { | |
| targetOnset = Math.round(targetOnset * (divisions / origDivisions)); | |
| } | |
| targetOnset = Math.round(targetOnset); // ensure integer | |
| // Find insertion point: walk XML children, track cursor, insert where cursor passes targetOnset | |
| // Strategy: backup to 0, forward to targetOnset, then insert note | |
| // We append: <backup duration=currentEndPos/> <forward duration=targetOnset/> <note .../> | |
| // This positions the new note at the correct onset without disrupting existing voice structure | |
| // Build the <note> element | |
| const noteEl = xmlDoc.createElement("note"); | |
| noteEl.setAttribute("default-x", Math.round(info.defaultX).toString()); | |
| // Store pixel X so note stays at visual position after re-parse | |
| const _ux = parseFloat(document.getElementById("offset-x").value || 0); | |
| noteEl.setAttribute("data-px", (info.snappedPx - _ux).toString()); | |
| // Get fifths from nearby note in same measure | |
| let fifths = 0; | |
| for (const ni of noteInfos) { | |
| if (ni.measureNum === info.measureNum) { fifths = ni.fifths || 0; break; } | |
| } | |
| const alter = keyAlterForStep(info.step, fifths); | |
| const pitchEl = xmlDoc.createElement("pitch"); | |
| const stepEl = xmlDoc.createElement("step"); | |
| stepEl.textContent = info.step; | |
| if (alter !== 0) { | |
| const alterEl = xmlDoc.createElement("alter"); | |
| alterEl.textContent = alter.toString(); | |
| pitchEl.appendChild(stepEl); | |
| pitchEl.appendChild(alterEl); | |
| } else { | |
| pitchEl.appendChild(stepEl); | |
| } | |
| const octaveEl = xmlDoc.createElement("octave"); | |
| octaveEl.textContent = info.octave.toString(); | |
| pitchEl.appendChild(octaveEl); | |
| noteEl.appendChild(pitchEl); | |
| const durElNew = xmlDoc.createElement("duration"); | |
| durElNew.textContent = durationDiv.toString(); | |
| noteEl.appendChild(durElNew); | |
| const autoVoice = findAvailableVoice(measureEl, targetOnset, localStaff || 1); | |
| const voiceEl = xmlDoc.createElement("voice"); | |
| voiceEl.textContent = autoVoice.toString(); | |
| noteEl.appendChild(voiceEl); | |
| const typeEl = xmlDoc.createElement("type"); | |
| typeEl.textContent = addDurationType; | |
| noteEl.appendChild(typeEl); | |
| const stemEl = xmlDoc.createElement("stem"); | |
| stemEl.textContent = info.staffLocal === 0 ? "down" : "up"; | |
| noteEl.appendChild(stemEl); | |
| if (localStaff > 0) { | |
| const staffEl = xmlDoc.createElement("staff"); | |
| staffEl.textContent = localStaff.toString(); | |
| noteEl.appendChild(staffEl); | |
| } | |
| noteEl.setAttribute("data-modified", "1"); | |
| // Find correct insertion point by walking XML children and tracking per-voice cursors. | |
| // Goal: insert the note into the voice's timeline at targetOnset without disrupting others. | |
| const children = Array.from(measureEl.children); | |
| let cursor = 0; // global cursor position | |
| let insertBeforeEl = null; | |
| let cursorAtInsert = 0; | |
| // Find the last element in the measure where cursor <= targetOnset for this voice | |
| // We track voice membership by looking at <voice> inside <note> elements | |
| for (let i = 0; i < children.length; i++) { | |
| const ch = children[i]; | |
| if (ch.tagName === "note") { | |
| const dur = parseInt(ch.querySelector("duration")?.textContent || "0"); | |
| const isChord = !!ch.querySelector("chord"); | |
| const noteVoice = parseInt(ch.querySelector("voice")?.textContent || "1"); | |
| if (!isChord) { | |
| if (noteVoice === autoVoice && cursor >= targetOnset && insertBeforeEl === null) { | |
| insertBeforeEl = ch; | |
| cursorAtInsert = cursor; | |
| } | |
| cursor += dur; | |
| } | |
| } else if (ch.tagName === "forward") { | |
| cursor += parseInt(ch.querySelector("duration")?.textContent || "0"); | |
| } else if (ch.tagName === "backup") { | |
| cursor -= parseInt(ch.querySelector("duration")?.textContent || "0"); | |
| } | |
| } | |
| // Compute the cursor position at end of measure (for backup calculation) | |
| let endCursor = 0; | |
| for (const ch of children) { | |
| if (ch.tagName === "note") { | |
| if (!ch.querySelector("chord")) endCursor += parseInt(ch.querySelector("duration")?.textContent || "0"); | |
| } else if (ch.tagName === "forward") { | |
| endCursor += parseInt(ch.querySelector("duration")?.textContent || "0"); | |
| } else if (ch.tagName === "backup") { | |
| endCursor -= parseInt(ch.querySelector("duration")?.textContent || "0"); | |
| } | |
| } | |
| // Strategy: always append backup→forward→note at end (safest for MusicXML voice model) | |
| if (endCursor > 0) { | |
| const backupEl = xmlDoc.createElement("backup"); | |
| const backupDur = xmlDoc.createElement("duration"); | |
| backupDur.textContent = endCursor.toString(); | |
| backupEl.appendChild(backupDur); | |
| measureEl.appendChild(backupEl); | |
| } | |
| if (targetOnset > 0) { | |
| const forwardEl = xmlDoc.createElement("forward"); | |
| const forwardDur = xmlDoc.createElement("duration"); | |
| forwardDur.textContent = targetOnset.toString(); | |
| forwardEl.appendChild(forwardDur); | |
| measureEl.appendChild(forwardEl); | |
| } | |
| measureEl.appendChild(noteEl); | |
| // Stop playback if active (timeline would be stale after XML change) | |
| if (isPlaying) stopPlayback(); | |
| // Re-parse and render (uses refreshAfterDurationChange which re-matches omrX/omrY/grades) | |
| const savedStep = info.step, savedOctave = info.octave, savedMeasure = info.measureNum; | |
| const savedPx = info.snappedPx; // pixel X where user clicked | |
| const savedPy = info.snappedPy; // pixel Y where user clicked | |
| refreshAfterDurationChange(); | |
| // Select the newly added note (omrX is now set via data-px in parseNotes) | |
| const newIdx = noteInfos.findIndex(n => | |
| n.measureNum === savedMeasure && n.step === savedStep && n.octave === savedOctave | |
| && n.element.getAttribute("data-modified") === "1" | |
| ); | |
| if (newIdx >= 0) selectNote(newIdx); | |
| else selectNote(noteInfos.length - 1); | |
| } | |
| /** | |
| * Toggle selected note between note and rest. | |
| * Note→rest: remove pitch, add <rest/>. | |
| * Rest→note: add pitch (default C4 or last used pitch), remove <rest/>. | |
| */ | |
| function toggleNoteRest() { | |
| if (selectedIdx < 0 || selectedIdx >= noteInfos.length) return; | |
| pushUndo(); | |
| const n = noteInfos[selectedIdx]; | |
| if (isOmrMode() && n._omrBased) { | |
| // ── OMR direct modification ── | |
| const pg = pages[currentPageIdx]; | |
| const sys = pg.omrData.systems[n.systemIdx]; | |
| const meas = sys.measures[n._omrMeasureIdx]; | |
| if (n.isRest) { | |
| // Rest → Note: convert restChord to headChord | |
| const rc = meas.restChords.find(c => c.chordId === n.omrChordId); | |
| if (rc) { | |
| const newHead = { | |
| chordId: rc.chordId, | |
| heads: [{ | |
| headId: rc.chordId + "-h1", | |
| pitch: 0, // middle line (will be C4 in treble) | |
| alter: 0, | |
| staff: rc.staff || n.staff, | |
| shape: "NOTEHEAD_BLACK", | |
| bounds: rc.bounds, | |
| grade: rc.chordGrade || 0.5, | |
| }], | |
| duration: rc.duration, | |
| timeOffset: rc.timeOffset, | |
| voice: rc.voice, | |
| orphan: rc.orphan || false, | |
| }; | |
| meas.headChords.push(newHead); | |
| meas.restChords = meas.restChords.filter(c => c.chordId !== rc.chordId); | |
| } | |
| n.isRest = false; | |
| n.step = "C"; | |
| n.octave = 4; | |
| n.alter = 0; | |
| n._isHeadChord = true; | |
| } else { | |
| // Note → Rest: convert headChord to restChord | |
| const chord = findOmrChord(n); | |
| if (chord) { | |
| const restShape = n.headShape === "NOTEHEAD_VOID" ? "HALF_REST" : | |
| n.headShape === "WHOLE_NOTE" ? "WHOLE_REST" : "QUARTER_REST"; | |
| const newRest = { | |
| chordId: chord.chordId, | |
| restShape: restShape, | |
| duration: chord.duration, | |
| timeOffset: chord.timeOffset, | |
| voice: chord.voice, | |
| staff: n.staff, | |
| bounds: chord.heads[0] ? chord.heads[0].bounds : null, | |
| chordGrade: chord.heads[0] ? chord.heads[0].grade : 0, | |
| orphan: chord.orphan || false, | |
| }; | |
| meas.restChords.push(newRest); | |
| meas.headChords = meas.headChords.filter(hc => hc.chordId !== chord.chordId); | |
| } | |
| n.isRest = true; | |
| n.step = "R"; | |
| n.octave = 0; | |
| n.alter = 0; | |
| n._isHeadChord = false; | |
| } | |
| markModified(n); | |
| // Rebuild from modified omrData | |
| rebuildSystemsAndNotes(); | |
| const ux = parseInt(document.getElementById("offset-x").value) || 0; | |
| const uy = parseInt(document.getElementById("offset-y").value) || 0; | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| if (selectedIdx < noteInfos.length) selectNote(selectedIdx); | |
| updatePageStatus(); | |
| } else { | |
| // ── XML mode ── | |
| const nEl = n.element; | |
| if (n.isRest) { | |
| const restEl = nEl.querySelector("rest"); | |
| if (restEl) restEl.remove(); | |
| const pitchEl = xmlDoc.createElement("pitch"); | |
| const stepEl = xmlDoc.createElement("step"); | |
| stepEl.textContent = "C"; | |
| const octaveEl = xmlDoc.createElement("octave"); | |
| octaveEl.textContent = "4"; | |
| pitchEl.appendChild(stepEl); | |
| pitchEl.appendChild(octaveEl); | |
| const durEl = nEl.querySelector("duration"); | |
| if (durEl) nEl.insertBefore(pitchEl, durEl); | |
| else nEl.appendChild(pitchEl); | |
| const stemEl = xmlDoc.createElement("stem"); | |
| stemEl.textContent = "up"; | |
| nEl.appendChild(stemEl); | |
| n.isRest = false; | |
| n.step = "C"; | |
| n.octave = 4; | |
| n.alter = 0; | |
| markModified(n); | |
| } else { | |
| const pitch = nEl.querySelector("pitch"); | |
| if (pitch) pitch.remove(); | |
| const restEl = xmlDoc.createElement("rest"); | |
| const durEl = nEl.querySelector("duration"); | |
| if (durEl) nEl.insertBefore(restEl, durEl); | |
| else nEl.appendChild(restEl); | |
| ["stem", "beam", "lyric", "accidental", "notehead", "tie", "slur"].forEach(tag => { | |
| nEl.querySelectorAll(tag).forEach(el => el.remove()); | |
| }); | |
| n.isRest = true; | |
| n.step = "R"; | |
| n.octave = 0; | |
| n.alter = 0; | |
| markModified(n); | |
| } | |
| layout = parseScoreLayout(xmlDoc); | |
| rebuildSystemsAndNotes(); | |
| const ux = parseInt(document.getElementById("offset-x").value) || 0; | |
| const uy = parseInt(document.getElementById("offset-y").value) || 0; | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| if (selectedIdx < noteInfos.length) selectNote(selectedIdx); | |
| updatePageStatus(); | |
| } | |
| } | |
| // ── Phase 3B: Chord Addition ──────────────────────────────── | |
| function addChordNote() { | |
| if (selectedIdx < 0 || selectedIdx >= noteInfos.length) return; | |
| const n = noteInfos[selectedIdx]; | |
| if (n.isRest) return; // Can't add chord to a rest | |
| pushUndo(); | |
| // Compute pitch a diatonic 3rd above (step + 2) | |
| const si = STEP_INDEX[n.step]; | |
| const newSi = (si + 2) % 7; | |
| const newStep = STEPS[newSi]; | |
| const newOctave = (si + 2 >= 7) ? n.octave + 1 : n.octave; | |
| // Clone duration/voice/staff/type from the selected note | |
| const srcEl = n.element; | |
| const durText = srcEl.querySelector("duration")?.textContent || "1"; | |
| const voiceText = srcEl.querySelector("voice")?.textContent || "1"; | |
| const typeText = srcEl.querySelector("type")?.textContent || "quarter"; | |
| const staffText = srcEl.querySelector("staff")?.textContent || null; | |
| const stemText = srcEl.querySelector("stem")?.textContent || "up"; | |
| // Build the new <note> element | |
| const noteEl = xmlDoc.createElement("note"); | |
| // <chord/> must be first child | |
| const chordEl = xmlDoc.createElement("chord"); | |
| noteEl.appendChild(chordEl); | |
| // <pitch> | |
| const pitchEl = xmlDoc.createElement("pitch"); | |
| const stepEl = xmlDoc.createElement("step"); | |
| stepEl.textContent = newStep; | |
| const octaveEl = xmlDoc.createElement("octave"); | |
| octaveEl.textContent = newOctave.toString(); | |
| pitchEl.appendChild(stepEl); | |
| pitchEl.appendChild(octaveEl); | |
| noteEl.appendChild(pitchEl); | |
| // <duration> | |
| const durEl = xmlDoc.createElement("duration"); | |
| durEl.textContent = durText; | |
| noteEl.appendChild(durEl); | |
| // <voice> | |
| const voiceEl = xmlDoc.createElement("voice"); | |
| voiceEl.textContent = voiceText; | |
| noteEl.appendChild(voiceEl); | |
| // <type> | |
| const typeEl = xmlDoc.createElement("type"); | |
| typeEl.textContent = typeText; | |
| noteEl.appendChild(typeEl); | |
| // <stem> | |
| const stemEl = xmlDoc.createElement("stem"); | |
| stemEl.textContent = stemText; | |
| noteEl.appendChild(stemEl); | |
| // <staff> (if multi-staff) | |
| if (staffText) { | |
| const staffEl = xmlDoc.createElement("staff"); | |
| staffEl.textContent = staffText; | |
| noteEl.appendChild(staffEl); | |
| } | |
| // Copy default-x from source note | |
| const dx = srcEl.getAttribute("default-x"); | |
| if (dx) noteEl.setAttribute("default-x", dx); | |
| noteEl.setAttribute("data-modified", "1"); | |
| // Insert right after the selected note's element in the measure | |
| const measureEl = srcEl.parentNode; | |
| const nextSibling = srcEl.nextSibling; | |
| if (nextSibling) measureEl.insertBefore(noteEl, nextSibling); | |
| else measureEl.appendChild(noteEl); | |
| // Re-parse and render | |
| layout = parseScoreLayout(xmlDoc); | |
| rebuildSystemsAndNotes(); | |
| const ux = parseInt(document.getElementById("offset-x").value) || 0; | |
| const uy = parseInt(document.getElementById("offset-y").value) || 0; | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| // Select the newly added chord note | |
| const newIdx = noteInfos.findIndex(ni => ni.element === noteEl); | |
| if (newIdx >= 0) selectNote(newIdx); | |
| updatePageStatus(); | |
| } | |
| // ── Phase 2: Duration Change ──────────────────────────────── | |
| // MusicXML type string → multiplier relative to quarter note (divisions = quarter) | |
| const DURATION_TYPES = { | |
| "whole": 4, | |
| "half": 2, | |
| "quarter": 1, | |
| "eighth": 0.5, | |
| "16th": 0.25, | |
| "32nd": 0.125, | |
| }; | |
| // Key bindings: numpad/number → type name | |
| const KEY_TO_TYPE = { | |
| "1": "whole", | |
| "2": "half", | |
| "4": "quarter", | |
| "5": "eighth", | |
| "6": "16th", | |
| "7": "32nd", | |
| }; | |
| /** | |
| * Change selected note's duration. | |
| * @param {string} newType — "whole", "half", "quarter", "eighth", "16th" | |
| */ | |
| /** Find all noteInfos in the same chord group (same measure, onset, voice). */ | |
| function getChordGroup(idx) { | |
| const n = noteInfos[idx]; | |
| return noteInfos.filter(other => | |
| !other.isRest && | |
| other.measureNum === n.measureNum && | |
| other.onsetDiv === n.onsetDiv && | |
| other.voice === n.voice && | |
| other.partIndex === n.partIndex | |
| ); | |
| } | |
| /** Apply duration+type change to a single XML note element. */ | |
| function applyDurationToElement(nEl, newDurDiv, newType) { | |
| const durEl = nEl.querySelector("duration"); | |
| if (durEl) durEl.textContent = newDurDiv.toString(); | |
| let typeEl = nEl.querySelector("type"); | |
| if (!typeEl) { | |
| typeEl = xmlDoc.createElement("type"); | |
| const durAfter = nEl.querySelector("duration"); | |
| if (durAfter) durAfter.after(typeEl); | |
| else nEl.appendChild(typeEl); | |
| } | |
| typeEl.textContent = newType; | |
| nEl.setAttribute("data-modified", "1"); | |
| } | |
| // Map MusicXML type → omrData rational duration string | |
| const TYPE_TO_RATIONAL = { | |
| "whole": "1/1", "half": "1/2", "quarter": "1/4", "eighth": "1/8", "16th": "1/16", "32nd": "1/32", | |
| }; | |
| // Map MusicXML type → omrData headShape | |
| const TYPE_TO_HEAD_SHAPE = { | |
| "whole": "WHOLE_NOTE", "half": "NOTEHEAD_VOID", "quarter": "NOTEHEAD_BLACK", | |
| "eighth": "NOTEHEAD_BLACK", "16th": "NOTEHEAD_BLACK", "32nd": "NOTEHEAD_BLACK", | |
| }; | |
| function changeDuration(newType) { | |
| if (selectedIdx < 0) return; | |
| const n = noteInfos[selectedIdx]; | |
| // Allow rest duration change in OMR mode, block in XML mode (complex backup recalc) | |
| if (n.isRest && !(isOmrMode() && n._omrBased)) return; | |
| const multiplier = DURATION_TYPES[newType]; | |
| if (multiplier === undefined) return; | |
| if (isOmrMode() && n._omrBased) { | |
| // ── OMR direct modification ── | |
| const newRational = TYPE_TO_RATIONAL[newType]; | |
| if (!newRational || n.durationRational === newRational) return; | |
| pushUndo(); | |
| const chord = findOmrChord(n); | |
| if (chord) { | |
| chord.duration = newRational; | |
| if (chord.heads) { | |
| const newShape = TYPE_TO_HEAD_SHAPE[newType] || "NOTEHEAD_BLACK"; | |
| for (const h of chord.heads) h.shape = newShape; | |
| } | |
| if (chord.restShape !== undefined) { | |
| const REST_SHAPES = {"whole":"WHOLE_REST","half":"HALF_REST","quarter":"QUARTER_REST", | |
| "eighth":"EIGHTH_REST","16th":"16TH_REST","32nd":"32ND_REST"}; | |
| chord.restShape = REST_SHAPES[newType] || chord.restShape; | |
| } | |
| } else { | |
| console.warn("[changeDuration] findOmrChord failed:", n.omrChordId, n._omrMeasureIdx, n.systemIdx); | |
| } | |
| n.durationDiv = parseRational(newRational); | |
| n.durationRational = newRational; | |
| n.headShape = TYPE_TO_HEAD_SHAPE[newType] || n.headShape; | |
| markModified(n); | |
| if (n.omrHeadId && n.omrChordId) { | |
| recordOmrEdit({ type: "change_duration", headId: n.omrHeadId, | |
| chordId: n.omrChordId, newDuration: newType }); | |
| } | |
| // Apply to all notes in same tuplet group | |
| const tupGroup = getTupletGroup(selectedIdx); | |
| if (tupGroup) { | |
| for (const gi of tupGroup) { | |
| if (gi === selectedIdx) continue; | |
| const gn = noteInfos[gi]; | |
| if (!gn || !gn._omrBased) continue; | |
| const gc = findOmrChord(gn); | |
| if (gc) { | |
| gc.duration = newRational; | |
| if (gc.heads) { | |
| const newShape = TYPE_TO_HEAD_SHAPE[newType] || "NOTEHEAD_BLACK"; | |
| for (const h of gc.heads) h.shape = newShape; | |
| } | |
| } | |
| gn.durationDiv = parseRational(newRational); | |
| gn.durationRational = newRational; | |
| gn.headShape = TYPE_TO_HEAD_SHAPE[newType] || gn.headShape; | |
| markModified(gn); | |
| } | |
| } | |
| refreshAfterDurationChange(); | |
| // Check if note now overflows measure | |
| const measDur = _tlGetMeasureDuration(n.measureNum, n.systemIdx); | |
| if (measDur > 0) _checkMeasureOverflow(selectedIdx, measDur); | |
| } else { | |
| // ── XML mode ── | |
| const divisions = n.divisions || 1; | |
| const hasDot = n.element.querySelector("dot") !== null; | |
| const dotMult = hasDot ? 1.5 : 1.0; | |
| const newDurDiv = Math.round(divisions * multiplier * dotMult); | |
| if (newDurDiv === n.durationDiv) return; | |
| pushUndo(); | |
| applyDurationToElement(n.element, newDurDiv, newType); | |
| n.durationDiv = newDurDiv; | |
| refreshAfterDurationChange(); | |
| } | |
| } | |
| let _tupletGroupCounter = 0; | |
| /** Get all noteInfo indices in the same tuplet group */ | |
| function getTupletGroup(idx) { | |
| const n = noteInfos[idx]; | |
| if (!n || !n.tupletGroupId) return null; | |
| const gid = n.tupletGroupId; | |
| return noteInfos.map((ni, i) => ni.tupletGroupId === gid ? i : -1).filter(i => i >= 0); | |
| } | |
| /** | |
| * Apply/remove triplet to selected notes (TL multi-select or single). | |
| * Triplet: duration × 2/3, assigns tuplet group. Un-triplet: × 3/2, removes group. | |
| */ | |
| function applyTuplet(mode) { | |
| const targets = tlSelectedIndices.size > 1 ? [...tlSelectedIndices] | |
| : scoreSelectedIndices.size > 1 ? [...scoreSelectedIndices] | |
| : (selectedIdx >= 0 ? [selectedIdx] : []); | |
| if (targets.length === 0) return; | |
| if (!isOmrMode()) return; | |
| const multiplier = mode === "triplet" ? 2/3 : 3/2; | |
| pushUndo(); | |
| const groupId = mode === "triplet" ? `tg_${++_tupletGroupCounter}` : null; | |
| for (const idx of targets) { | |
| const n = noteInfos[idx]; | |
| if (!n || !n._omrBased) continue; | |
| const chord = findOmrChord(n); | |
| if (!chord) continue; | |
| const curDur = parseRational(chord.duration); | |
| const newDur = curDur * multiplier; | |
| const newRational = durationFloatToRational(newDur); | |
| chord.duration = newRational; | |
| chord.tupletGroupId = groupId; | |
| n.durationDiv = newDur; | |
| n.durationRational = newRational; | |
| n.tupletGroupId = groupId; | |
| markModified(n); | |
| if (n.omrHeadId && n.omrChordId) { | |
| recordOmrEdit({ type: "change_duration", headId: n.omrHeadId, | |
| chordId: n.omrChordId, newDuration: newRational }); | |
| } | |
| } | |
| refreshAfterDurationChange(); | |
| } | |
| /** | |
| * Toggle dot on selected note. | |
| * Dotted = duration * 1.5, undotted = restore base. | |
| */ | |
| function toggleDot() { | |
| if (selectedIdx < 0) return; | |
| const n = noteInfos[selectedIdx]; | |
| if (n.isRest) return; | |
| if (isOmrMode() && n._omrBased) { | |
| // ── OMR direct modification ── | |
| // Determine current dot state from durationRational | |
| const chord = findOmrChord(n); | |
| if (!chord) return; | |
| // Dotted = base * 1.5, so if current matches a dotted value, it has a dot | |
| // Simple approach: toggle dots field | |
| const hasDot = (chord.dotsNumber || 0) > 0; | |
| pushUndo(); | |
| if (hasDot) { | |
| chord.dotsNumber = 0; | |
| const curDur = parseRational(chord.duration); | |
| const newDur = curDur / 1.5; | |
| chord.duration = durationFloatToRational(newDur); | |
| } else { | |
| chord.dotsNumber = 1; | |
| const curDur = parseRational(chord.duration); | |
| const newDur = curDur * 1.5; | |
| chord.duration = durationFloatToRational(newDur); | |
| } | |
| n.durationDiv = parseRational(chord.duration); | |
| n.durationRational = chord.duration; | |
| markModified(n); | |
| if (n.omrHeadId) { | |
| recordOmrEdit({ type: "toggle_dot", headId: n.omrHeadId, addDot: !hasDot }); | |
| } | |
| refreshAfterDurationChange(); | |
| } else { | |
| // ── XML mode ── | |
| const nEl = n.element; | |
| const divisions = n.divisions || 1; | |
| const hasDot = nEl.querySelector("dot") !== null; | |
| const typeEl = nEl.querySelector("type"); | |
| const typeName = typeEl?.textContent || "quarter"; | |
| const baseMultiplier = DURATION_TYPES[typeName] || 1; | |
| const newDurDiv = hasDot | |
| ? Math.round(divisions * baseMultiplier) | |
| : Math.round(divisions * baseMultiplier * 1.5); | |
| pushUndo(); | |
| const dot = nEl.querySelector("dot"); | |
| if (hasDot) { | |
| if (dot) dot.remove(); | |
| } else { | |
| if (!dot) { | |
| const newDot = xmlDoc.createElement("dot"); | |
| const tEl = nEl.querySelector("type"); | |
| if (tEl) tEl.after(newDot); | |
| else { | |
| const dEl = nEl.querySelector("duration"); | |
| if (dEl) dEl.after(newDot); | |
| } | |
| } | |
| } | |
| const dEl = nEl.querySelector("duration"); | |
| if (dEl) dEl.textContent = newDurDiv.toString(); | |
| n.durationDiv = newDurDiv; | |
| refreshAfterDurationChange(); | |
| } | |
| } | |
| /** Convert a duration in divisions to a MusicXML type name. */ | |
| function divToTypeName(dur, divisions) { | |
| const ratio = dur / divisions; // relative to quarter note | |
| if (Math.abs(ratio - 4) < 0.01) return "whole"; | |
| if (Math.abs(ratio - 3) < 0.01) return "half"; // dotted half | |
| if (Math.abs(ratio - 2) < 0.01) return "half"; | |
| if (Math.abs(ratio - 1.5) < 0.01) return "quarter"; // dotted quarter | |
| if (Math.abs(ratio - 1) < 0.01) return "quarter"; | |
| if (Math.abs(ratio - 0.75) < 0.01) return "eighth"; // dotted eighth | |
| if (Math.abs(ratio - 0.5) < 0.01) return "eighth"; | |
| if (Math.abs(ratio - 0.25) < 0.01) return "16th"; | |
| if (Math.abs(ratio - 0.125) < 0.01) return "32nd"; | |
| return null; | |
| } | |
| /** After duration edit: re-parse notes and re-render. */ | |
| /** | |
| * Recalculate <backup> durations in each measure. | |
| * | |
| * Strategy: walk the measure in two passes. | |
| * Pass 1: compute each voice's total duration. | |
| * Pass 2: backup = cursorTime (rewind to measure start, the common case). | |
| * | |
| * This handles the typical pattern where backup always rewinds to | |
| * the beginning of the measure for the next voice. | |
| */ | |
| function recalcBackups() { | |
| const measures = xmlDoc.querySelectorAll("measure"); | |
| for (const mEl of measures) { | |
| const children = mEl.children; | |
| let cursorTime = 0; | |
| for (let i = 0; i < children.length; i++) { | |
| const child = children[i]; | |
| if (child.tagName === "note") { | |
| const isChord = child.querySelector("chord") !== null; | |
| if (!isChord) { | |
| const dur = parseInt(child.querySelector("duration")?.textContent || "0"); | |
| cursorTime += dur; | |
| } | |
| } else if (child.tagName === "forward") { | |
| const dur = parseInt(child.querySelector("duration")?.textContent || "0"); | |
| cursorTime += dur; | |
| } else if (child.tagName === "backup") { | |
| // Rewind cursor to 0 (measure start), set backup duration accordingly | |
| const durEl = child.querySelector("duration"); | |
| if (durEl && cursorTime > 0) { | |
| durEl.textContent = cursorTime.toString(); | |
| } | |
| cursorTime = 0; | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Central function: rebuild systemsData + noteInfos from the best available source. | |
| * OMR-primary mode (.omr data available) → parseSystemsFromOmr + parseNotesFromOmr | |
| * Legacy XML mode (no .omr) → parseSystems + parseNotes from xmlDoc | |
| */ | |
| function rebuildSystemsAndNotes() { | |
| const pg = pages[currentPageIdx]; | |
| const omrData = pg && pg.omrData; | |
| const hasOmrSystems = omrData && omrData.systems && omrData.systems.length > 0; | |
| if (hasOmrSystems) { | |
| const omrSystems = parseSystemsFromOmr(omrData); | |
| const omrNotes = omrSystems ? parseNotesFromOmr(omrData, omrSystems) : null; | |
| if (omrSystems && omrNotes) { | |
| systemsData = omrSystems; | |
| noteInfos = omrNotes; | |
| // Restore modified flags from prior edits | |
| if (_modifiedChordIds.size > 0) { | |
| let restored = 0; | |
| for (const n of noteInfos) { | |
| if (n.omrChordId && _modifiedChordIds.has(n.omrChordId)) { | |
| n.modified = true; | |
| n.omrY = null; | |
| restored++; | |
| } | |
| } | |
| console.log(`[rebuild] restored ${restored} modified flags from ${_modifiedChordIds.size} tracked edits`); | |
| } | |
| return; | |
| } | |
| } | |
| // Fallback: XML mode | |
| systemsData = parseSystems(xmlDoc, layout); | |
| { const ns = systemsData.length > 0 ? systemsData[0].numStaves : 1; reassignMeasuresToSystems(systemsData, detectedStaves, ns); } | |
| noteInfos = parseNotes(xmlDoc, systemsData); | |
| } | |
| function refreshAfterDurationChange() { | |
| if (!isOmrMode()) { | |
| recalcBackups(); | |
| layout = parseScoreLayout(xmlDoc); | |
| } | |
| rebuildSystemsAndNotes(); | |
| const ux = parseFloat(offsetX.value || 0); | |
| const uy = parseFloat(offsetY.value || 0); | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| // Try to re-select near same position | |
| if (selectedIdx >= 0 && selectedIdx < noteInfos.length) { | |
| selectNote(selectedIdx); | |
| } else if (noteInfos.length > 0) { | |
| selectNote(noteInfos.length - 1); | |
| } | |
| updatePageStatus(); | |
| } | |
| function recomputeAndUpdate(idx) { | |
| const n = noteInfos[idx]; | |
| const uy = parseFloat(offsetY.value || 0); | |
| const ref = clefReferencePosition(n.clef); | |
| const noteDiatonic = diatonicIndex(n.step, n.octave); | |
| const staffPos = ref.staffPosition + (noteDiatonic - ref.diatonicIdx); | |
| // Try detected staves first (image-based pixel coords) | |
| const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1; | |
| const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys); | |
| const sysStaves = staffSystems[n.systemIdx]; | |
| // n.staff from .omr is page-global (1-based); convert to system-local (1-based) | |
| const localStaffIdx = ((n.staff - 1) % numStavesPerSys) + 1; | |
| const staffData = sysStaves ? sysStaves[localStaffIdx - 1] : null; | |
| if (staffData) { | |
| const halfSpacing = staffData.lineSpacing / 2; | |
| const oldPy = n.py; | |
| n.py = staffData.bottomLineY - (staffPos * halfSpacing) + uy; | |
| console.log("[recompute] staffData branch: oldPy=", oldPy, "newPy=", n.py, | |
| "botY=", staffData.bottomLineY, "halfSp=", halfSpacing, "staffPos=", staffPos, | |
| "step=", n.step, "oct=", n.octave); | |
| } else if (n._omrBased) { | |
| // OMR mode: use .omr staves pixel coordinates directly | |
| const pg = pages[currentPageIdx]; | |
| const omrSys = pg && pg.omrData && pg.omrData.systems[n.systemIdx]; | |
| const omrStaff = omrSys && omrSys.staves[localStaffIdx - 1]; | |
| console.log("[recompute-omr] staff=", n.staff, "sysIdx=", n.systemIdx, | |
| "omrSys?", !!omrSys, "staves.len=", omrSys ? omrSys.staves.length : "N/A", | |
| "omrStaff?", !!omrStaff, "lines?", omrStaff && omrStaff.lines ? omrStaff.lines.length : "N/A", | |
| "staffPos=", staffPos); | |
| if (omrStaff && omrStaff.lines && omrStaff.lines.length >= 5) { | |
| const topLineY = omrStaff.lines[0].y1; | |
| const botLineY = omrStaff.lines[4].y1; | |
| const interline = (botLineY - topLineY) / 4; | |
| const halfInterline = interline / 2; | |
| n.py = botLineY - (staffPos * halfInterline) + uy; | |
| console.log("[recompute-omr] topY=", topLineY, "botY=", botLineY, "halfIL=", halfInterline, "→ py=", n.py); | |
| } else { | |
| console.warn("[recompute-omr] FALLBACK — no OMR staff lines, py unchanged:", n.py); | |
| } | |
| } else { | |
| let staffTopY = n.systemTopY; | |
| if (localStaffIdx > 1) staffTopY += (localStaffIdx - 1) * (40 + n.staffDistance); | |
| const yTenths = staffTopY + 40 - (staffPos * 5); | |
| n.py = yTenths * pixelsPerTenth + uy; | |
| } | |
| updateSingleMarker(idx); | |
| selectNote(idx); | |
| // Refresh timeline if visible | |
| if (typeof timelinePanelVisible !== "undefined" && timelinePanelVisible && timelinePanelMeasure) { | |
| renderTimelinePanel(timelinePanelMeasure.measureNum, timelinePanelMeasure.systemIdx); | |
| } | |
| } | |
| function navigateNote(direction) { | |
| if (noteInfos.length === 0) return; | |
| scoreSelectedIndices.clear(); | |
| _scoreUpdateSelectionVisuals(); | |
| if (selectedIdx < 0) { selectNote(0); return; } | |
| let next = selectedIdx + direction; | |
| if (next < 0) next = noteInfos.length - 1; | |
| if (next >= noteInfos.length) next = 0; | |
| selectNote(next); | |
| const circle = markerSvg.querySelector(`circle[data-idx="${next}"]`); | |
| if (circle) { | |
| const wrapper = document.getElementById("canvas-wrapper"); | |
| const cx = parseFloat(circle.getAttribute("cx")) * currentZoom; | |
| const cy = parseFloat(circle.getAttribute("cy")) * currentZoom; | |
| const wRect = wrapper.getBoundingClientRect(); | |
| if (cx < wrapper.scrollLeft || cx > wrapper.scrollLeft + wRect.width) | |
| wrapper.scrollLeft = cx - wRect.width / 2; | |
| if (cy < wrapper.scrollTop || cy > wrapper.scrollTop + wRect.height) | |
| wrapper.scrollTop = cy - wRect.height / 2; | |
| } | |
| } | |
| // ================================================================ | |
| // Section 6: Download | |
| // ================================================================ | |
| function downloadModifiedXml() { | |
| // OMR-primary mode: generate MusicXML from noteInfos (full rewrite) | |
| if (isOmrMode() && noteInfos.length > 0 && noteInfos[0]._omrBased) { | |
| const xmlStr = generateMusicXmlFromNoteInfos(); | |
| if (xmlStr) { | |
| const blob = new Blob([xmlStr], { type: "application/xml" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = "corrected.musicxml"; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| return; | |
| } | |
| } | |
| if (!xmlDoc) return; | |
| // Legacy XML mode: clone and strip custom attributes | |
| const exportDoc = xmlDoc.cloneNode(true); | |
| exportDoc.querySelectorAll("[data-modified]").forEach(el => el.removeAttribute("data-modified")); | |
| exportDoc.querySelectorAll("[data-px]").forEach(el => el.removeAttribute("data-px")); | |
| exportDoc.querySelectorAll("[data-omr-x]").forEach(el => el.removeAttribute("data-omr-x")); | |
| exportDoc.querySelectorAll("[data-omr-y]").forEach(el => el.removeAttribute("data-omr-y")); | |
| exportDoc.querySelectorAll("[data-omr-grade]").forEach(el => el.removeAttribute("data-omr-grade")); | |
| exportDoc.querySelectorAll("[data-omr-chord-id]").forEach(el => el.removeAttribute("data-omr-chord-id")); | |
| exportDoc.querySelectorAll("[data-omr-head-id]").forEach(el => el.removeAttribute("data-omr-head-id")); | |
| const serializer = new XMLSerializer(); | |
| const xmlStr = serializer.serializeToString(exportDoc); | |
| const blob = new Blob([xmlStr], { type: "application/xml" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = "corrected.musicxml"; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| async function downloadMml() { | |
| // Get XML string (same logic as downloadModifiedXml) | |
| let xmlStr; | |
| if (isOmrMode() && noteInfos.length > 0 && noteInfos[0]._omrBased) { | |
| xmlStr = generateMusicXmlFromNoteInfos(); | |
| } else if (xmlDoc) { | |
| const exportDoc = xmlDoc.cloneNode(true); | |
| exportDoc.querySelectorAll("[data-modified],[data-px],[data-omr-x],[data-omr-y],[data-omr-grade],[data-omr-chord-id],[data-omr-head-id]").forEach(el => { | |
| el.removeAttribute("data-modified"); el.removeAttribute("data-px"); | |
| el.removeAttribute("data-omr-x"); el.removeAttribute("data-omr-y"); | |
| el.removeAttribute("data-omr-grade"); el.removeAttribute("data-omr-chord-id"); | |
| el.removeAttribute("data-omr-head-id"); | |
| }); | |
| xmlStr = new XMLSerializer().serializeToString(exportDoc); | |
| } | |
| if (!xmlStr) { alert("No XML data to convert."); return; } | |
| // Send to server for MML conversion | |
| const formData = new FormData(); | |
| formData.append("xml", new Blob([xmlStr], { type: "application/xml" }), "score.musicxml"); | |
| try { | |
| const resp = await fetch("/api/xml-to-mml", { method: "POST", body: formData }); | |
| if (!resp.ok) { alert("MML conversion failed: " + resp.status); return; } | |
| const data = await resp.json(); | |
| if (data.warnings && data.warnings.length) console.warn("MML warnings:", data.warnings); | |
| const blob = new Blob([data.mml], { type: "text/plain" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; a.download = "corrected.mml"; a.click(); | |
| URL.revokeObjectURL(url); | |
| } catch (e) { alert("MML conversion error: " + e.message); } | |
| } | |
| /** | |
| * Generate MusicXML from current noteInfos (OMR-primary mode). | |
| * Focuses on pitch and duration. Ignores tie/slur/dynamics. | |
| */ | |
| function generateMusicXmlFromNoteInfos() { | |
| const pg = pages[currentPageIdx]; | |
| const omrData = pg && pg.omrData; | |
| const DIVISIONS = 24; // quarter=24 divisions (triplets divide evenly) | |
| const numStavesPerSys = systemsData.length > 0 ? systemsData[0].numStaves : 1; | |
| // Group notes by partIndex, then measureNum | |
| const partMap = {}; // partIndex → { measureNum → [noteInfo...] } | |
| for (const n of noteInfos) { | |
| const pi = n.partIndex || 0; | |
| if (!partMap[pi]) partMap[pi] = {}; | |
| const mNum = n.measureNum || "1"; | |
| if (!partMap[pi][mNum]) partMap[pi][mNum] = []; | |
| partMap[pi][mNum].push(n); | |
| } | |
| const partIndices = Object.keys(partMap).sort((a, b) => parseInt(a) - parseInt(b)); | |
| // Build barline map: measureNum → barline shape | |
| const barlineMap = {}; | |
| if (omrData && omrData.systems) { | |
| let globalMeasBase = 1; | |
| for (const sys of omrData.systems) { | |
| const stacks = sys.stacks || []; | |
| const barlines = sys.barlines || []; | |
| for (let si = 0; si < stacks.length; si++) { | |
| const stack = stacks[si]; | |
| const measNum = String(globalMeasBase + si); | |
| for (const bl of barlines) { | |
| if (!bl.bounds) continue; | |
| const blX = bl.bounds.x; | |
| const shape = (bl.shape || "").toUpperCase(); | |
| if (Math.abs(blX - stack.left) < 20) { | |
| if (shape.includes("REPEAT_START") || shape === "REPEAT_BOTH") { | |
| if (!barlineMap[measNum]) barlineMap[measNum] = {}; | |
| barlineMap[measNum].left = shape; | |
| } | |
| } | |
| if (Math.abs(blX - stack.right) < 20) { | |
| if (!barlineMap[measNum]) barlineMap[measNum] = {}; | |
| barlineMap[measNum].right = shape; | |
| } | |
| } | |
| } | |
| globalMeasBase += stacks.length; | |
| } | |
| } | |
| // Get time signature from omrData or default 4/4 | |
| let beats = 4, beatType = 4; | |
| if (omrData && omrData.systems && omrData.systems.length > 0) { | |
| const sys0 = omrData.systems[0]; | |
| if (sys0.timeSigs && sys0.timeSigs.length > 0) { | |
| beats = sys0.timeSigs[0].numerator || 4; | |
| beatType = sys0.timeSigs[0].denominator || 4; | |
| } | |
| } | |
| // Helper: get duration as whole=1 based float (always use durationRational for consistency) | |
| function getDurWhole(n) { | |
| if (n.durationRational) return parseRational(n.durationRational); | |
| if (n.durationDiv >= 1) return n.durationDiv / 4; | |
| return n.durationDiv; | |
| } | |
| // Helper: whole-based duration → MusicXML duration integer | |
| function durToDivisions(durWhole) { | |
| return Math.round(durWhole * DIVISIONS * 4); | |
| } | |
| // Helper: whole-based duration → type name | |
| function durToType(durWhole) { | |
| const baseDurs = [ | |
| [1.0, "whole"], [0.5, "half"], [0.25, "quarter"], | |
| [0.125, "eighth"], [0.0625, "16th"], [0.03125, "32nd"] | |
| ]; | |
| for (const [val, name] of baseDurs) { | |
| if (Math.abs(durWhole - val) < 0.001) return name; | |
| if (Math.abs(durWhole - val * 1.5) < 0.001) return name; // dotted | |
| if (Math.abs(durWhole - val * 1.75) < 0.001) return name; // double-dotted | |
| } | |
| let closest = "quarter"; | |
| let minDiff = Infinity; | |
| for (const [val, name] of baseDurs) { | |
| const diff = Math.abs(durWhole - val); | |
| if (diff < minDiff) { minDiff = diff; closest = name; } | |
| } | |
| return closest; | |
| } | |
| // Helper: check if duration is dotted | |
| function isDotted(n) { | |
| const chord = findOmrChord(n); | |
| if (chord && (chord.dotsNumber || 0) > 0) return true; | |
| const durW = getDurWhole(n); | |
| const baseDurs = [1.0, 0.5, 0.25, 0.125, 0.0625, 0.03125]; | |
| for (const base of baseDurs) { | |
| if (Math.abs(durW - base * 1.5) < 0.001) return true; | |
| } | |
| return false; | |
| } | |
| // Helper: convert page-global staff to part-local (1-based) | |
| const numPartsTotal = partIndices.length || 1; | |
| const numStavesPerPart = Math.max(1, Math.round(numStavesPerSys / numPartsTotal)); | |
| function localStaffOf(n) { | |
| return ((n.staff || 1) - 1) % numStavesPerPart + 1; | |
| } | |
| // Build XML string | |
| let xml = '<?xml version="1.0" encoding="UTF-8"?>\n'; | |
| xml += '<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 4.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">\n'; | |
| xml += '<score-partwise version="4.0">\n'; | |
| // Part list | |
| xml += ' <part-list>\n'; | |
| for (const pi of partIndices) { | |
| const id = `P${parseInt(pi) + 1}`; | |
| xml += ` <score-part id="${id}"><part-name>Part ${parseInt(pi) + 1}</part-name></score-part>\n`; | |
| } | |
| xml += ' </part-list>\n'; | |
| // Parts | |
| for (const pi of partIndices) { | |
| const id = `P${parseInt(pi) + 1}`; | |
| xml += ` <part id="${id}">\n`; | |
| const measures = partMap[pi]; | |
| const measureNums = Object.keys(measures).sort((a, b) => parseInt(a) - parseInt(b)); | |
| // Determine number of staves for this part using system-local staff values | |
| const localStaffSet = new Set(); | |
| for (const mNum of measureNums) { | |
| for (const n of measures[mNum]) { | |
| localStaffSet.add(localStaffOf(n)); | |
| } | |
| } | |
| const numStaves = localStaffSet.size || 1; | |
| const minLocalStaff = Math.min(...localStaffSet); | |
| const staffOffset = minLocalStaff - 1; | |
| // Track running state for mid-piece changes | |
| let prevFifths = null, prevBeats = null, prevBeatType = null; | |
| const prevClefs = {}; // staffNum → {sign, line} | |
| for (let mi = 0; mi < measureNums.length; mi++) { | |
| const mNum = measureNums[mi]; | |
| const mNotes = measures[mNum]; | |
| xml += ` <measure number="${mNum}">\n`; | |
| // Determine current measure's key/time/clef | |
| const curFifths = mNotes[0] ? (mNotes[0].fifths || 0) : 0; | |
| let curBeats = beats, curBeatType = beatType; | |
| if (omrData && mNotes[0] && mNotes[0].systemIdx != null) { | |
| const sys = omrData.systems[mNotes[0].systemIdx]; | |
| if (sys && sys.timeSigs && sys.timeSigs.length > 0) { | |
| curBeats = sys.timeSigs[0].numerator || beats; | |
| curBeatType = sys.timeSigs[0].denominator || beatType; | |
| } | |
| } | |
| const keyChanged = prevFifths !== null && curFifths !== prevFifths; | |
| const timeChanged = prevBeats !== null && (curBeats !== prevBeats || curBeatType !== prevBeatType); | |
| let clefChanged = false; | |
| const curClefs = {}; | |
| if (numStaves > 1) { | |
| for (let si = 1; si <= numStaves; si++) { | |
| const staffNote = mNotes.find(n => (localStaffOf(n) - staffOffset) === si); | |
| const clef = staffNote ? staffNote.clef : { sign: "G", line: 2 }; | |
| curClefs[si] = clef; | |
| if (prevClefs[si] && (prevClefs[si].sign !== clef.sign || prevClefs[si].line !== clef.line)) { | |
| clefChanged = true; | |
| } | |
| } | |
| } else { | |
| const clef = mNotes[0] ? (mNotes[0].clef || { sign: "G", line: 2 }) : { sign: "G", line: 2 }; | |
| curClefs[1] = clef; | |
| if (prevClefs[1] && (prevClefs[1].sign !== clef.sign || prevClefs[1].line !== clef.line)) { | |
| clefChanged = true; | |
| } | |
| } | |
| if (mi === 0 || keyChanged || timeChanged || clefChanged) { | |
| xml += ' <attributes>\n'; | |
| if (mi === 0) { | |
| xml += ` <divisions>${DIVISIONS}</divisions>\n`; | |
| if (numStaves > 1) xml += ` <staves>${numStaves}</staves>\n`; | |
| } | |
| if (mi === 0 || keyChanged) { | |
| xml += ` <key><fifths>${curFifths}</fifths></key>\n`; | |
| } | |
| if (mi === 0 || timeChanged) { | |
| xml += ` <time><beats>${curBeats}</beats><beat-type>${curBeatType}</beat-type></time>\n`; | |
| } | |
| if (mi === 0 || clefChanged) { | |
| if (numStaves > 1) { | |
| for (let si = 1; si <= numStaves; si++) { | |
| const clef = curClefs[si] || { sign: "G", line: 2 }; | |
| xml += ` <clef number="${si}"><sign>${clef.sign}</sign><line>${clef.line}</line></clef>\n`; | |
| } | |
| } else { | |
| const clef = curClefs[1] || { sign: "G", line: 2 }; | |
| xml += ` <clef><sign>${clef.sign}</sign><line>${clef.line}</line></clef>\n`; | |
| } | |
| } | |
| xml += ' </attributes>\n'; | |
| } | |
| prevFifths = curFifths; | |
| prevBeats = curBeats; | |
| prevBeatType = curBeatType; | |
| Object.assign(prevClefs, curClefs); | |
| // Sort notes by onset, then voice, then staff | |
| const sorted = [...mNotes].sort((a, b) => { | |
| const oa = a.onsetDiv || 0, ob = b.onsetDiv || 0; | |
| if (oa !== ob) return oa - ob; | |
| const va = a.voice || 1, vb = b.voice || 1; | |
| if (va !== vb) return va - vb; | |
| return (a.staff || 1) - (b.staff || 1); | |
| }); | |
| // Group by voice, write each voice, insert backup between voices | |
| const voiceMap = {}; | |
| for (const n of sorted) { | |
| const v = n.voice || 1; | |
| if (!voiceMap[v]) voiceMap[v] = []; | |
| voiceMap[v].push(n); | |
| } | |
| const voices = Object.keys(voiceMap).sort((a, b) => parseInt(a) - parseInt(b)); | |
| let prevVoiceTotal = 0; | |
| let firstVoice = true; | |
| for (const v of voices) { | |
| const vNotes = voiceMap[v]; | |
| // Backup before second+ voice — use actual duration of previous voice | |
| if (!firstVoice) { | |
| xml += ` <backup><duration>${prevVoiceTotal}</duration></backup>\n`; | |
| } | |
| firstVoice = false; | |
| // Forward to first note's onset if > 0 | |
| const firstOnsetWhole = vNotes[0].onsetDiv || 0; | |
| const firstOnsetDiv = durToDivisions(firstOnsetWhole); | |
| if (firstOnsetDiv > 0) { | |
| xml += ` <forward><duration>${firstOnsetDiv}</duration></forward>\n`; | |
| } | |
| // Voice remapping: staff 2 voices 5-8 → 1-4 | |
| const exportVoice = parseInt(v) > 4 ? parseInt(v) - 4 : parseInt(v); | |
| // Track onset to detect chords and accumulate voice total | |
| let prevOnset = -1; | |
| let voiceTotal = firstOnsetDiv; | |
| for (const n of vNotes) { | |
| const durW = getDurWhole(n); | |
| const dur = durToDivisions(durW); | |
| const type = durToType(durW); | |
| const dot = isDotted(n); | |
| const isChordNote = (n.onsetDiv === prevOnset && prevOnset >= 0); | |
| const localStaff = localStaffOf(n) - staffOffset; | |
| xml += ' <note>\n'; | |
| if (isChordNote) { | |
| xml += ' <chord/>\n'; | |
| } | |
| if (n.isRest) { | |
| xml += ' <rest/>\n'; | |
| } else { | |
| xml += ' <pitch>\n'; | |
| xml += ` <step>${n.step}</step>\n`; | |
| if (n.alter && n.alter !== 0) { | |
| xml += ` <alter>${n.alter}</alter>\n`; | |
| } | |
| xml += ` <octave>${n.octave}</octave>\n`; | |
| xml += ' </pitch>\n'; | |
| } | |
| xml += ` <duration>${dur}</duration>\n`; | |
| xml += ` <voice>${exportVoice}</voice>\n`; | |
| // Tuplet: adjust type to notated value and add time-modification | |
| let finalType = type; | |
| let isTuplet = !!n.tupletGroupId; | |
| if (isTuplet) { | |
| const notatedDur = durW * 1.5; | |
| finalType = durToType(notatedDur); | |
| } | |
| xml += ` <type>${finalType}</type>\n`; | |
| if (dot) { | |
| xml += ' <dot/>\n'; | |
| } | |
| if (isTuplet) { | |
| xml += ' <time-modification>\n'; | |
| xml += ' <actual-notes>3</actual-notes>\n'; | |
| xml += ' <normal-notes>2</normal-notes>\n'; | |
| xml += ' </time-modification>\n'; | |
| } | |
| if (numStaves > 1) { | |
| xml += ` <staff>${localStaff}</staff>\n`; | |
| } | |
| xml += ' </note>\n'; | |
| if (!isChordNote) { | |
| prevOnset = n.onsetDiv; | |
| voiceTotal += dur; | |
| } | |
| } | |
| prevVoiceTotal = voiceTotal; | |
| } | |
| // Barline at end of measure | |
| const blInfo = barlineMap[mNum]; | |
| if (blInfo) { | |
| const shape = blInfo.right || ""; | |
| let barStyle = "", repeatDir = ""; | |
| if (shape.includes("FINAL") || shape === "THIN_THICK") barStyle = "light-heavy"; | |
| else if (shape === "DOUBLE" || shape === "THIN_THIN") barStyle = "light-light"; | |
| else if (shape.includes("REPEAT_END") || shape === "REPEAT_BOTH") { | |
| barStyle = "light-heavy"; repeatDir = "backward"; | |
| } | |
| if (blInfo.left && (blInfo.left.includes("REPEAT_START") || blInfo.left === "REPEAT_BOTH")) { | |
| xml += ' <barline location="left">\n'; | |
| xml += ' <bar-style>heavy-light</bar-style>\n'; | |
| xml += ' <repeat direction="forward"/>\n'; | |
| xml += ' </barline>\n'; | |
| } | |
| if (barStyle) { | |
| xml += ' <barline location="right">\n'; | |
| xml += ` <bar-style>${barStyle}</bar-style>\n`; | |
| if (repeatDir) xml += ` <repeat direction="${repeatDir}"/>\n`; | |
| xml += ' </barline>\n'; | |
| } | |
| } | |
| xml += ' </measure>\n'; | |
| } | |
| xml += ' </part>\n'; | |
| } | |
| xml += '</score-partwise>\n'; | |
| return xml; | |
| } | |
| // ── Apply OMR Edits ───────────────────────────────────────── | |
| async function applyOmrEdits() { | |
| if (omrEdits.length === 0) { | |
| loadStatus.textContent = "No pending OMR edits"; | |
| return; | |
| } | |
| const pg = pages[currentPageIdx]; | |
| if (!pg || !pg.omrFile) { | |
| loadStatus.textContent = "No .omr file loaded for this page"; | |
| return; | |
| } | |
| const btn = document.getElementById("omr-apply-btn"); | |
| const origText = btn.textContent; | |
| btn.disabled = true; | |
| btn.textContent = "Applying..."; | |
| loadStatus.textContent = `Applying ${omrEdits.length} edits to OMR...`; | |
| try { | |
| const formData = new FormData(); | |
| formData.append("omr_file", pg.omrFile); | |
| formData.append("edits", JSON.stringify(omrEdits)); | |
| formData.append("sheet_number", "1"); | |
| const resp = await fetch("/api/omr/apply-edits", { method: "POST", body: formData }); | |
| if (!resp.ok) { | |
| const err = await resp.json().catch(() => ({ detail: resp.statusText })); | |
| throw new Error(err.detail || `Server error ${resp.status}`); | |
| } | |
| const result = await resp.json(); | |
| // Check edit results for errors | |
| const errors = (result.editResults || []).filter(r => r.status !== "ok"); | |
| if (errors.length > 0) { | |
| console.warn("Some edits failed:", errors); | |
| loadStatus.textContent = `${errors.length} edit(s) failed — check console`; | |
| } | |
| // Keep current xmlDoc (edits already applied via applyPitchToXmlByHeadId). | |
| // Audiveris CLI re-export ignores pitch attribute changes, so server XML is unmodified. | |
| // Only use server XML as fallback if we don't have a local xmlDoc. | |
| if (!xmlDoc && result.xml) { | |
| const parser = new DOMParser(); | |
| const newXmlDoc = parser.parseFromString(result.xml, "text/xml"); | |
| if (!newXmlDoc.querySelector("parsererror")) { | |
| xmlDoc = newXmlDoc; | |
| pg.xmlDoc = xmlDoc; | |
| } | |
| } | |
| // Update omrData | |
| if (result.omrData) { | |
| pg.omrData = result.omrData; | |
| } | |
| // Update omrFile so next Apply uses the modified .omr (not the original) | |
| if (result.omrFileBase64) { | |
| const bin = Uint8Array.from(atob(result.omrFileBase64), c => c.charCodeAt(0)); | |
| pg.omrFile = new Blob([bin], { type: "application/octet-stream" }); | |
| } | |
| // Clear edits (they've been applied to .omr on server) | |
| omrEdits = []; | |
| pg.omrEdits = []; | |
| undoStack = []; | |
| redoStack = []; | |
| pg.undoStack = []; | |
| pg.redoStack = []; | |
| // Keep existing noteInfos — they already reflect in-memory edits. | |
| // Only clear modified flags since edits are now persisted in .omr. | |
| noteInfos.forEach(n => { n.modified = false; }); | |
| // Also update in-memory omrData heads to match current noteInfos | |
| // (so that future parseNotesFromOmr on page switch uses edited values) | |
| if (pg.omrData) { | |
| noteInfos.forEach(n => { | |
| if (!n._omrBased || !n.omrHeadId) return; | |
| const sys = pg.omrData.systems[n.systemIdx]; | |
| if (!sys) return; | |
| for (const m of sys.measures) { | |
| for (const ch of m.chords) { | |
| for (const h of ch.heads) { | |
| if (String(h.headId) === String(n.omrHeadId)) { | |
| h.pitch = stepOctaveToOmrPitch(n.step, n.octave, n.clef.sign); | |
| h.alter = n.alter; | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| renderMarkers(noteInfos); | |
| if (selectedIdx >= 0 && selectedIdx < noteInfos.length) selectNote(selectedIdx); | |
| updateApplyBadge(); | |
| loadStatus.textContent = `OMR edits applied — ${(result.editResults || []).filter(r => r.status === "ok").length} succeeded`; | |
| } catch (e) { | |
| console.error("Apply OMR edits failed:", e); | |
| loadStatus.textContent = `Apply failed: ${e.message}`; | |
| } finally { | |
| btn.disabled = false; | |
| btn.innerHTML = 'Apply to OMR <span id="omr-apply-badge" style="background:#e44;color:#fff;border-radius:8px;padding:0 5px;margin-left:4px;font-size:10px;display:none"></span>'; | |
| updateApplyBadge(); | |
| } | |
| } | |
| // Show/hide the Apply button based on whether .omr is loaded | |
| function updateOmrApplyVisibility() { | |
| const btn = document.getElementById("omr-apply-btn"); | |
| if (!btn) return; | |
| const pg = pages[currentPageIdx]; | |
| btn.style.display = (pg && pg.omrFile) ? "inline-block" : "none"; | |
| } | |
| // ================================================================ | |
| // Section 7: Load & Init | |
| // ================================================================ | |
| function applyZoom(value) { | |
| currentZoom = value / 100; | |
| const container = document.getElementById("canvas-container"); | |
| container.style.transform = `scale(${currentZoom})`; | |
| zoomLabel.textContent = value + "%"; | |
| } | |
| function recomputeAll() { | |
| if (!layout || !systemsData) return; | |
| // Use image-based scaling as primary, DPI as fallback | |
| const imgW = scoreImage.naturalWidth; | |
| if (imgW > 0 && layout.pageW > 0) { | |
| pixelsPerTenth = imgW / layout.pageW; | |
| } else { | |
| const dpi = parseInt(dpiInput.value) || 300; | |
| pixelsPerTenth = computePixelsPerTenthFromDpi(dpi, layout.mm, layout.tpu); | |
| } | |
| // Apply user staff-distance override | |
| const userStaffDist = parseFloat(document.getElementById("staff-dist-input").value); | |
| const sysDistAdj = parseFloat(document.getElementById("sys-dist-adj").value) || 0; | |
| if (!isNaN(userStaffDist)) { | |
| systemsData.forEach(sys => { sys.staffDistance = userStaffDist; }); | |
| // Also update noteInfos | |
| noteInfos.forEach(n => { n.staffDistance = userStaffDist; }); | |
| } | |
| // Recalculate system topY with sys-dist adjustment | |
| recalcSystemPositions(sysDistAdj); | |
| const ux = parseFloat(offsetX.value || 0); | |
| const uy = parseFloat(offsetY.value || 0); | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| if (selectedIdx >= 0) selectNote(selectedIdx); | |
| // Refresh debug lines if visible | |
| if (debugLinesVisible) { | |
| markerSvg.querySelectorAll(".debug-line").forEach(el => el.remove()); | |
| toggleDebugLines(); | |
| debugLinesVisible = true; // toggleDebugLines flips it, so flip back | |
| } | |
| if (freeGlyphsVisible) renderFreeGlyphOverlays(); | |
| } | |
| /** Recalculate system topY values, optionally adjusting system-distance */ | |
| function recalcSystemPositions(sysDistAdj) { | |
| for (let i = 0; i < systemsData.length; i++) { | |
| const sys = systemsData[i]; | |
| if (i === 0) { | |
| // First system: topY stays as originally parsed | |
| // (already set from top-system-distance) | |
| } else { | |
| const prev = systemsData[i - 1]; | |
| const prevTotalHeight = 40 + (prev.numStaves - 1) * (40 + prev.staffDistance); | |
| // Use original system-distance + user adjustment | |
| sys.topY = prev.topY + prevTotalHeight + sys._origSysDist + sysDistAdj; | |
| } | |
| } | |
| // Update noteInfos to reference new systemTopY | |
| noteInfos.forEach(n => { | |
| n.systemTopY = systemsData[n.systemIdx].topY; | |
| }); | |
| } | |
| async function readFileAsText(file) { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => resolve(reader.result); | |
| reader.onerror = reject; | |
| reader.readAsText(file); | |
| }); | |
| } | |
| async function readFileAsArrayBuffer(file) { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => resolve(reader.result); | |
| reader.onerror = reject; | |
| reader.readAsArrayBuffer(file); | |
| }); | |
| } | |
| async function extractXmlFromMxl(file) { | |
| const buf = await readFileAsArrayBuffer(file); | |
| if (typeof JSZip === "undefined") { | |
| await loadScript("https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"); | |
| } | |
| const zip = await JSZip.loadAsync(buf); | |
| const xmlFile = Object.keys(zip.files).find(name => | |
| name.endsWith(".xml") && !name.startsWith("META-INF") | |
| ); | |
| if (!xmlFile) throw new Error("No XML file found in MXL archive"); | |
| return await zip.files[xmlFile].async("text"); | |
| } | |
| function loadScript(src) { | |
| return new Promise((resolve, reject) => { | |
| const s = document.createElement("script"); | |
| s.src = src; | |
| s.onload = resolve; | |
| s.onerror = reject; | |
| document.head.appendChild(s); | |
| }); | |
| } | |
| /** Save current page state back to pages[] before switching */ | |
| function saveCurrentPageState() { | |
| if (pages.length === 0 || currentPageIdx >= pages.length) return; | |
| if (!xmlDoc) return; // nothing loaded yet — don't overwrite with stale/null data | |
| const pg = pages[currentPageIdx]; | |
| pg.xmlDoc = xmlDoc; | |
| pg.noteInfos = noteInfos; | |
| pg.systemsData = systemsData; | |
| pg.layout = layout; | |
| pg.detectedStaves = detectedStaves; | |
| pg.detectedBarlines = detectedBarlines; | |
| pg.pixelsPerTenth = pixelsPerTenth; | |
| pg.undoStack = undoStack; | |
| pg.redoStack = redoStack; | |
| pg.selectedIdx = selectedIdx; | |
| pg.carryBeats = carryBeats; | |
| pg.carryBeatType = carryBeatType; | |
| pg.omrEdits = omrEdits; | |
| } | |
| /** Load a single page by index — sets all globals and renders */ | |
| async function loadPage(pageIdx) { | |
| if (pageIdx < 0 || pageIdx >= pages.length) return; | |
| // Save current page state before switching | |
| saveCurrentPageState(); | |
| currentPageIdx = pageIdx; | |
| const pg = pages[pageIdx]; | |
| // Update page indicator | |
| document.getElementById("page-indicator").textContent = `${pageIdx + 1} / ${pages.length}`; | |
| // If page already parsed, restore state | |
| if (pg.xmlDoc) { | |
| xmlDoc = pg.xmlDoc; | |
| noteInfos = pg.noteInfos; | |
| systemsData = pg.systemsData; | |
| layout = pg.layout; | |
| detectedStaves = pg.detectedStaves; | |
| detectedBarlines = pg.detectedBarlines || []; | |
| pixelsPerTenth = pg.pixelsPerTenth; | |
| undoStack = pg.undoStack || []; | |
| redoStack = pg.redoStack || []; | |
| selectedIdx = pg.selectedIdx >= 0 ? pg.selectedIdx : -1; | |
| if (pg.carryBeats != null) { carryBeats = pg.carryBeats; carryBeatType = pg.carryBeatType; } | |
| omrEdits = pg.omrEdits || []; | |
| updateApplyBadge(); | |
| // Load image | |
| scoreImage.src = pg.imageUrl; | |
| await new Promise((resolve, reject) => { | |
| scoreImage.onload = resolve; | |
| scoreImage.onerror = reject; | |
| }); | |
| markerSvg.setAttribute("width", scoreImage.naturalWidth); | |
| markerSvg.setAttribute("height", scoreImage.naturalHeight); | |
| // Auto-fit zoom to canvas width on first load | |
| if (!pg._zoomApplied) { | |
| const wrapper = document.getElementById("canvas-wrapper"); | |
| const fitZoom = Math.floor((wrapper.clientWidth / scoreImage.naturalWidth) * 100); | |
| const clampedZoom = Math.max(25, Math.min(200, fitZoom)); | |
| zoomSlider.value = clampedZoom; | |
| applyZoom(clampedZoom); | |
| pg._zoomApplied = true; | |
| } | |
| renderMarkers(noteInfos); | |
| if (selectedIdx >= 0 && selectedIdx < noteInfos.length) selectNote(selectedIdx); | |
| else { selectedIdx = -1; statusSel.textContent = t("no_sel"); } | |
| // ── Cached page debug ── | |
| { | |
| const hasOmr = !!(pg.omrData && pg.omrData.systems && pg.omrData.systems.length > 0); | |
| const mode = hasOmr ? "OMR-primary(cached)" : "XML-legacy(cached)"; | |
| const xVals = noteInfos.filter(n => !n.isRest).map(n => n.px); | |
| const yVals = noteInfos.filter(n => !n.isRest).map(n => n.py); | |
| const xMin = xVals.length ? Math.min(...xVals).toFixed(0) : "N/A"; | |
| const xMax = xVals.length ? Math.max(...xVals).toFixed(0) : "N/A"; | |
| const omrXVals = noteInfos.filter(n => n.omrX != null).map(n => n.omrX); | |
| const omrXMin = omrXVals.length ? Math.min(...omrXVals).toFixed(0) : "N/A"; | |
| const omrXMax = omrXVals.length ? Math.max(...omrXVals).toFixed(0) : "N/A"; | |
| console.log(`[PAGE ${pageIdx+1} cached] mode=${mode} img=${scoreImage.naturalWidth}x${scoreImage.naturalHeight} notes=${noteInfos.length} px=[${xMin}..${xMax}] omrX=[${omrXMin}..${omrXMax}]`); | |
| if (hasOmr) { | |
| const omrSys = pg.omrData.systems; | |
| console.log(`[PAGE ${pageIdx+1} cached] omr systems=${omrSys.length}, staves/sys=[${omrSys.map(s=>(s.staves||[]).length).join(",")}]`); | |
| } | |
| // Barlines | |
| const bls = pg.detectedBarlines || detectedBarlines || []; | |
| if (bls.length > 0) { | |
| const blBySys = {}; | |
| bls.forEach(bl => { if (!blBySys[bl.systemIdx]) blBySys[bl.systemIdx] = []; blBySys[bl.systemIdx].push(bl.x); }); | |
| Object.keys(blBySys).forEach(si => { | |
| const xs = blBySys[si].sort((a,b) => a-b); | |
| console.log(`[PAGE ${pageIdx+1} cached] barlines sys${si}(${xs.length}): x=[${xs[0]?.toFixed(0)}..${xs[xs.length-1]?.toFixed(0)}]`); | |
| }); | |
| } | |
| } | |
| updatePageStatus(); | |
| return; | |
| } | |
| // First-time parse for this page | |
| loadStatus.textContent = t("loading_page")(pageIdx + 1); | |
| // Load image | |
| if (!pg.imageUrl && pg.imageFile) pg.imageUrl = URL.createObjectURL(pg.imageFile); | |
| scoreImage.src = pg.imageUrl; | |
| await new Promise((resolve, reject) => { | |
| scoreImage.onload = resolve; | |
| scoreImage.onerror = reject; | |
| }); | |
| markerSvg.setAttribute("width", scoreImage.naturalWidth); | |
| markerSvg.setAttribute("height", scoreImage.naturalHeight); | |
| // Auto-fit zoom to canvas width on first load | |
| if (!pg._zoomApplied) { | |
| const wrapper = document.getElementById("canvas-wrapper"); | |
| const fitZoom = Math.floor((wrapper.clientWidth / scoreImage.naturalWidth) * 100); | |
| const clampedZoom = Math.max(25, Math.min(200, fitZoom)); | |
| zoomSlider.value = clampedZoom; | |
| applyZoom(clampedZoom); | |
| pg._zoomApplied = true; | |
| } | |
| // Parse XML — from pre-loaded text or from file | |
| let xmlText; | |
| if (pg.xmlText) { | |
| xmlText = pg.xmlText; | |
| } else if (pg.xmlFile) { | |
| if (pg.xmlFile.name.toLowerCase().endsWith(".mxl")) { | |
| xmlText = await extractXmlFromMxl(pg.xmlFile); | |
| } else { | |
| xmlText = await readFileAsText(pg.xmlFile); | |
| } | |
| } else { | |
| throw new Error("No XML data available for page " + (pageIdx + 1)); | |
| } | |
| const parser = new DOMParser(); | |
| xmlDoc = parser.parseFromString(xmlText, "application/xml"); | |
| if (xmlDoc.querySelector("parsererror")) { | |
| throw new Error("XML parse error: " + xmlDoc.querySelector("parsererror").textContent); | |
| } | |
| layout = parseScoreLayout(xmlDoc); | |
| const imgW = scoreImage.naturalWidth; | |
| if (imgW > 0 && layout.pageW > 0) { | |
| pixelsPerTenth = imgW / layout.pageW; | |
| const effectiveDpi = (pixelsPerTenth * layout.tpu * 25.4) / layout.mm; | |
| dpiInput.value = Math.round(effectiveDpi); | |
| } else { | |
| const dpi = parseInt(dpiInput.value) || 300; | |
| pixelsPerTenth = computePixelsPerTenthFromDpi(dpi, layout.mm, layout.tpu); | |
| } | |
| // Use .omr data for staves/barlines if available, else fall back to image detection | |
| const omrData = pg.omrData; | |
| let useOmrPrimary = false; | |
| if (omrData && omrData.systems && omrData.systems.length > 0) { | |
| // Try .omr-based parsing (Phase 6) | |
| const omrSystems = parseSystemsFromOmr(omrData); | |
| const omrNotes = omrSystems ? parseNotesFromOmr(omrData, omrSystems) : null; | |
| if (omrSystems && omrNotes) { | |
| systemsData = omrSystems; | |
| noteInfos = omrNotes; | |
| useOmrPrimary = true; | |
| console.log(`OMR-primary mode: ${systemsData.length} systems, ${noteInfos.length} notes from .omr`); | |
| } | |
| } | |
| if (!useOmrPrimary) { | |
| // Legacy XML-based parsing | |
| systemsData = parseSystems(xmlDoc, layout); | |
| } | |
| if (omrData && omrData.systems && omrData.systems.length > 0) { | |
| detectedStaves = omrStavesToDetected(omrData); | |
| console.log(`Using OMR staff data: ${detectedStaves.length} staves from .omr`); | |
| } else { | |
| detectedStaves = detectStaffLines(scoreImage); | |
| } | |
| // Interpolate missing staves before reassignment | |
| const numStavesPerSys = systemsData.length > 0 ? systemsData[0].numStaves : 1; | |
| const expectedStaves = systemsData.length * numStavesPerSys; | |
| if (detectedStaves.length < expectedStaves) { | |
| detectedStaves = interpolateMissingStaves(detectedStaves, expectedStaves, numStavesPerSys); | |
| } | |
| if (!useOmrPrimary) { | |
| // Reassign measures to systems using image-detected staff widths (legacy XML mode only) | |
| reassignMeasuresToSystems(systemsData, detectedStaves, numStavesPerSys); | |
| } | |
| // Use .omr barlines if available, else init from XML | |
| if (omrData && omrData.systems && omrData.systems.length > 0) { | |
| detectedBarlines = omrBarlinesToDetected(omrData, detectedStaves, numStavesPerSys); | |
| console.log(`Using OMR barline data: ${detectedBarlines.length} barlines from .omr`); | |
| } else { | |
| initBarlinesFromXML(); | |
| } | |
| if (!useOmrPrimary) { | |
| // Legacy: parse notes from MusicXML | |
| noteInfos = parseNotes(xmlDoc, systemsData); | |
| // Match .omr grades to noteInfos (1-time copy at load) | |
| if (omrData && omrData.systems) { | |
| matchOmrGrades(noteInfos, omrData); | |
| } | |
| } else { | |
| // OMR-primary mode: still need to extract free glyphs from omrData | |
| freeGlyphData = []; | |
| for (let si = 0; si < omrData.systems.length; si++) { | |
| const sys = omrData.systems[si]; | |
| if (!sys.freeGlyphs) continue; | |
| for (const fg of sys.freeGlyphs) { | |
| freeGlyphData.push({ ...fg, systemIdx: si }); | |
| } | |
| } | |
| console.log(`Free glyphs (OMR-primary): ${freeGlyphData.length} candidates`); | |
| if (freeGlyphsVisible) renderFreeGlyphOverlays(); | |
| } | |
| if (detectedStaves.length !== expectedStaves) { | |
| console.warn(`Page ${pageIdx + 1}: found ${detectedStaves.length} staves, systems expect ${systemsData.length * numStavesPerSys}`); | |
| } | |
| const ux = parseFloat(offsetX.value || 0); | |
| const uy = parseFloat(offsetY.value || 0); | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| // ── Page load debug ── | |
| { | |
| const imgW = scoreImage.naturalWidth, imgH = scoreImage.naturalHeight; | |
| const hasOmr = !!(pg.omrData && pg.omrData.systems && pg.omrData.systems.length > 0); | |
| const mode = useOmrPrimary ? "OMR-primary" : "XML-legacy"; | |
| const nNotes = noteInfos.length; | |
| const staffInfo = detectedStaves.map((s, i) => `[${i}] topY=${s.topLineY?.toFixed(0)} botY=${s.bottomLineY?.toFixed(0)} L=${s.leftX?.toFixed(0)} R=${s.rightX?.toFixed(0)}`); | |
| const xVals = noteInfos.filter(n => !n.isRest).map(n => n.px); | |
| const yVals = noteInfos.filter(n => !n.isRest).map(n => n.py); | |
| const xMin = xVals.length ? Math.min(...xVals).toFixed(0) : "N/A"; | |
| const xMax = xVals.length ? Math.max(...xVals).toFixed(0) : "N/A"; | |
| const yMin = yVals.length ? Math.min(...yVals).toFixed(0) : "N/A"; | |
| const yMax = yVals.length ? Math.max(...yVals).toFixed(0) : "N/A"; | |
| const omrXVals = noteInfos.filter(n => n.omrX != null).map(n => n.omrX); | |
| const omrXMin = omrXVals.length ? Math.min(...omrXVals).toFixed(0) : "N/A"; | |
| const omrXMax = omrXVals.length ? Math.max(...omrXVals).toFixed(0) : "N/A"; | |
| const staffSample = noteInfos.slice(0, 5).map(n => `staff=${n.staff} sys=${n.systemIdx} omrBased=${n._omrBased}`); | |
| console.log(`[PAGE ${pageIdx+1}] mode=${mode} hasOmr=${hasOmr} img=${imgW}x${imgH} ppt=${pixelsPerTenth.toFixed(3)}`); | |
| console.log(`[PAGE ${pageIdx+1}] notes=${nNotes} px=[${xMin}..${xMax}] py=[${yMin}..${yMax}] omrX=[${omrXMin}..${omrXMax}]`); | |
| console.log(`[PAGE ${pageIdx+1}] systems=${systemsData.length} numStavesPerSys=${numStavesPerSys} staves=${detectedStaves.length}`); | |
| console.log(`[PAGE ${pageIdx+1}] staves:`, staffInfo); | |
| console.log(`[PAGE ${pageIdx+1}] first 5 notes staff info:`, staffSample); | |
| if (hasOmr) { | |
| const omrSys = pg.omrData.systems; | |
| console.log(`[PAGE ${pageIdx+1}] omrData: ${omrSys.length} systems, staves per sys: [${omrSys.map(s => (s.staves||[]).length).join(",")}]`); | |
| // First system staves coordinate ranges | |
| if (omrSys[0] && omrSys[0].staves) { | |
| const s0 = omrSys[0].staves; | |
| console.log(`[PAGE ${pageIdx+1}] omr sys0 staves:`, s0.map((st, i) => `[${i}] left=${st.left} right=${st.right} lines=${st.lines?.length}`)); | |
| } | |
| // Barline X ranges per system | |
| omrSys.forEach((sys, si) => { | |
| const stacks = sys.stacks || []; | |
| if (stacks.length > 0) { | |
| const lefts = stacks.map(s => s.left); | |
| const rights = stacks.map(s => s.right); | |
| console.log(`[PAGE ${pageIdx+1}] omr sys${si} stacks(${stacks.length}): left=[${Math.min(...lefts).toFixed(0)}..${Math.max(...lefts).toFixed(0)}] right=[${Math.min(...rights).toFixed(0)}..${Math.max(...rights).toFixed(0)}]`); | |
| } | |
| }); | |
| } | |
| // Detected barlines debug | |
| if (detectedBarlines.length > 0) { | |
| const blBySys = {}; | |
| detectedBarlines.forEach(bl => { | |
| if (!blBySys[bl.systemIdx]) blBySys[bl.systemIdx] = []; | |
| blBySys[bl.systemIdx].push(bl.x); | |
| }); | |
| Object.keys(blBySys).forEach(si => { | |
| const xs = blBySys[si].sort((a,b) => a-b); | |
| console.log(`[PAGE ${pageIdx+1}] barlines sys${si}(${xs.length}): x=[${xs[0]?.toFixed(0)}..${xs[xs.length-1]?.toFixed(0)}]`); | |
| }); | |
| } | |
| // Layout info | |
| console.log(`[PAGE ${pageIdx+1}] layout: pageW=${layout.pageW} marginL=${layout.marginL} marginR=${layout.marginR}`); | |
| } | |
| renderMarkers(noteInfos); | |
| selectedIdx = -1; | |
| undoStack = []; | |
| redoStack = []; | |
| // Save parsed state | |
| pg.xmlDoc = xmlDoc; | |
| pg.noteInfos = noteInfos; | |
| pg.systemsData = systemsData; | |
| pg.layout = layout; | |
| pg.detectedStaves = detectedStaves; | |
| pg.detectedBarlines = detectedBarlines; | |
| pg.pixelsPerTenth = pixelsPerTenth; | |
| pg.undoStack = undoStack; | |
| pg.redoStack = redoStack; | |
| pg.selectedIdx = -1; | |
| updatePageStatus(); | |
| if (systemsData.length > 0) { | |
| document.getElementById("staff-dist-input").value = systemsData[0].staffDistance; | |
| } | |
| const soundEl = xmlDoc.querySelector("sound[tempo]"); | |
| if (soundEl) { | |
| const tempo = parseFloat(soundEl.getAttribute("tempo")); | |
| if (tempo > 0) document.getElementById("bpm-input").value = Math.round(tempo); | |
| } | |
| } | |
| function updatePageStatus() { | |
| const pg = pages[currentPageIdx]; | |
| const hasOmr = pg && pg.omrData && pg.omrData.systems && pg.omrData.systems.length > 0; | |
| const detectMethod = hasOmr ? `OMR(${detectedStaves.length} staves)` : detectedStaves.length > 0 ? `IMG(${detectedStaves.length} staves)` : "XML fallback"; | |
| const numParts = xmlDoc.querySelectorAll("part").length; | |
| const editStr = omrEdits.length > 0 ? ` | ${omrEdits.length} pending` : ""; | |
| statusTotal.textContent = `P${currentPageIdx + 1}/${pages.length} | ${noteInfos.length} notes | ${numParts} parts | ${systemsData.length} sys | ${detectMethod}${editStr}`; | |
| loadStatus.textContent = `Page ${currentPageIdx + 1}: ${noteInfos.length} notes, ${detectedStaves.length} staves`; | |
| updateOmrApplyVisibility(); | |
| cursorSeekTime = 0; | |
| const initBpm = parseInt(document.getElementById("bpm-input").value) || 120; | |
| const initTl = buildTimeline(noteInfos, initBpm); | |
| if (initTl.length > 0) { | |
| const totalDur = initTl[initTl.length - 1].timeSec + initTl[initTl.length - 1].durationSec; | |
| document.getElementById("progress-time").textContent = "0:00 / " + formatTime(totalDur); | |
| } | |
| if (noteInfos.length > 0) { | |
| placeCursorAtNote(0); | |
| } | |
| } | |
| async function loadFiles() { | |
| let imageFiles = Array.from(imageInput.files).sort((a, b) => a.name.localeCompare(b.name)); | |
| let xmlFiles = Array.from(xmlInput.files).sort((a, b) => a.name.localeCompare(b.name)); | |
| let omrFiles = omrInput ? Array.from(omrInput.files).sort((a, b) => a.name.localeCompare(b.name)) : []; | |
| // If no files selected, try loading default test set from server | |
| if (imageFiles.length === 0 && xmlFiles.length === 0) { | |
| try { | |
| const resp = await fetch("/api/test-files"); | |
| if (resp.ok) { | |
| const data = await resp.json(); | |
| if (data.sets && data.sets.length > 0) { | |
| const testName = data.sets.find(s => s.startsWith("secret")) || data.sets[0]; | |
| loadStatus.textContent = `Loading test set: ${testName}...`; | |
| // Fetch PNG | |
| const pngResp = await fetch(`/api/test-file/${testName}.png`); | |
| const pngBlob = await pngResp.blob(); | |
| imageFiles = [new File([pngBlob], `${testName}.png`, { type: "image/png" })]; | |
| // Fetch MXL (XML) | |
| const mxlResp = await fetch(`/api/test-file/${testName}.mxl`); | |
| const mxlBlob = await mxlResp.blob(); | |
| xmlFiles = [new File([mxlBlob], `${testName}.mxl`, { type: "application/octet-stream" })]; | |
| // Fetch OMR | |
| const omrResp = await fetch(`/api/test-file/${testName}.omr`); | |
| const omrBlob = await omrResp.blob(); | |
| omrFiles = [new File([omrBlob], `${testName}.omr`, { type: "application/octet-stream" })]; | |
| console.log(`Auto-loaded test set: ${testName}`); | |
| } | |
| } | |
| } catch (e) { | |
| console.warn("Test file auto-load failed:", e); | |
| } | |
| } | |
| if (imageFiles.length === 0 || xmlFiles.length === 0) { | |
| loadStatus.textContent = t("select_prompt"); | |
| return; | |
| } | |
| // Match pages: pair by sorted order. If counts differ, use min. | |
| const numPages = Math.min(imageFiles.length, xmlFiles.length); | |
| if (imageFiles.length !== xmlFiles.length) { | |
| console.warn(`File count mismatch: ${imageFiles.length} images, ${xmlFiles.length} XMLs. Using ${numPages} pages.`); | |
| } | |
| // Parse .omr files via server API (optional) | |
| const omrDataArray = []; | |
| for (let i = 0; i < omrFiles.length && i < numPages; i++) { | |
| try { | |
| const formData = new FormData(); | |
| formData.append("omr_file", omrFiles[i]); | |
| formData.append("sheet_number", "1"); | |
| const resp = await fetch("/api/parse-omr", { method: "POST", body: formData }); | |
| if (resp.ok) { | |
| omrDataArray.push(await resp.json()); | |
| console.log(`OMR data loaded for page ${i + 1}: ${omrFiles[i].name}`); | |
| } else { | |
| console.warn(`OMR parse failed for ${omrFiles[i].name}: ${resp.status}`); | |
| omrDataArray.push(null); | |
| } | |
| } catch (e) { | |
| console.warn(`OMR fetch error for ${omrFiles[i].name}:`, e); | |
| omrDataArray.push(null); | |
| } | |
| } | |
| // Stop any active playback and release old object URLs | |
| stopPlayback(); | |
| for (const oldPg of pages) { | |
| if (oldPg.imageUrl) URL.revokeObjectURL(oldPg.imageUrl); | |
| } | |
| pages = []; | |
| for (let i = 0; i < numPages; i++) { | |
| pages.push({ | |
| imageFile: imageFiles[i], | |
| xmlFile: xmlFiles[i], | |
| omrData: omrDataArray[i] || null, | |
| omrFile: i < omrFiles.length ? omrFiles[i] : null, | |
| omrEdits: [], | |
| imageUrl: null, | |
| xmlDoc: null, | |
| noteInfos: null, | |
| systemsData: null, | |
| layout: null, | |
| detectedStaves: null, | |
| pixelsPerTenth: 1, | |
| undoStack: [], | |
| redoStack: [], | |
| selectedIdx: -1, | |
| carryBeats: null, // time sig carry-over: set after first parse | |
| carryBeatType: null, | |
| }); | |
| } | |
| // Reset globals so saveCurrentPageState() inside loadPage() | |
| // doesn't write stale data into the fresh pages array | |
| xmlDoc = null; | |
| noteInfos = []; | |
| systemsData = []; | |
| layout = null; | |
| detectedStaves = []; | |
| undoStack = []; | |
| redoStack = []; | |
| omrEdits = []; | |
| freeGlyphData = []; | |
| selectedIdx = -1; | |
| cursorSeekTime = 0; | |
| carryBeats = 4; | |
| carryBeatType = 4; | |
| currentPageIdx = 0; | |
| loadStatus.textContent = t("loading_pages")(numPages); | |
| loadBtn.disabled = true; | |
| try { | |
| await loadPage(0); | |
| // Preload piano samples so drag/click preview uses real piano | |
| if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| preloadPianoSamples(audioCtx); | |
| } catch (err) { | |
| loadStatus.textContent = t("error_prefix") + err.message; | |
| console.error(err); | |
| } finally { | |
| loadBtn.disabled = false; | |
| } | |
| } | |
| function prevPage() { | |
| if (pages.length <= 1 || currentPageIdx <= 0) return; | |
| stopPlayback(); | |
| loadPage(currentPageIdx - 1); | |
| } | |
| function nextPage() { | |
| if (pages.length <= 1 || currentPageIdx >= pages.length - 1) return; | |
| stopPlayback(); | |
| loadPage(currentPageIdx + 1); | |
| } | |
| // ── External data loading (for iframe embed / postMessage) ─── | |
| /** | |
| * Load corrector data programmatically without file inputs. | |
| * @param {string[]} imageUrls - array of image URLs (one per page) | |
| * @param {string[]} xmlTexts - array of XML text strings (one per page) | |
| */ | |
| async function loadFromData(imageUrls, xmlTexts, omrDataArray, omrFileUrls) { | |
| const numPages = Math.min(imageUrls.length, xmlTexts.length); | |
| if (numPages === 0) return; | |
| stopPlayback(); | |
| for (const oldPg of pages) { | |
| if (oldPg.imageUrl) URL.revokeObjectURL(oldPg.imageUrl); | |
| } | |
| pages = []; | |
| for (let i = 0; i < numPages; i++) { | |
| // Fetch .omr file as blob if URL provided (for Apply to OMR) | |
| let omrFile = null; | |
| if (omrFileUrls && omrFileUrls[i]) { | |
| try { | |
| const resp = await fetch(omrFileUrls[i]); | |
| if (resp.ok) { | |
| const blob = await resp.blob(); | |
| const stem = imageUrls[i].split("/").pop().replace(/\.[^.]+$/, "") || `page_${i+1}`; | |
| omrFile = new File([blob], stem + ".omr"); | |
| } | |
| } catch(e) { console.warn("Failed to fetch .omr file:", e); } | |
| } | |
| pages.push({ | |
| imageFile: null, | |
| xmlFile: null, | |
| imageUrl: imageUrls[i], // pre-set URL | |
| xmlText: xmlTexts[i], // pre-set XML text | |
| xmlDoc: null, | |
| omrData: (omrDataArray && omrDataArray[i]) || null, | |
| omrFile: omrFile, | |
| noteInfos: null, | |
| systemsData: null, | |
| layout: null, | |
| detectedStaves: null, | |
| pixelsPerTenth: 1, | |
| undoStack: [], | |
| redoStack: [], | |
| selectedIdx: -1, | |
| carryBeats: null, | |
| carryBeatType: null, | |
| }); | |
| } | |
| xmlDoc = null; | |
| noteInfos = []; | |
| systemsData = []; | |
| layout = null; | |
| detectedStaves = []; | |
| undoStack = []; | |
| redoStack = []; | |
| selectedIdx = -1; | |
| cursorSeekTime = 0; | |
| carryBeats = 4; | |
| carryBeatType = 4; | |
| currentPageIdx = 0; | |
| loadStatus.textContent = t("loading_pages")(numPages); | |
| // Hide file inputs when embedded, but keep page-nav visible | |
| const hideIds = ["image-input", "xml-input", "dpi-input", "load-btn"]; | |
| hideIds.forEach(id => { const el = document.getElementById(id); if (el) el.closest("label")?.style.setProperty("display", "none") || (el.style.display = "none"); }); | |
| // Also hide the "Images:" and "MusicXML:" labels | |
| document.querySelectorAll("#upload-bar label").forEach(lbl => { | |
| const inp = lbl.querySelector("input[type='file'], #dpi-input"); | |
| if (inp) lbl.style.display = "none"; | |
| }); | |
| const loadBtnEl = document.getElementById("load-btn"); | |
| if (loadBtnEl) loadBtnEl.style.display = "none"; | |
| try { | |
| await loadPage(0); | |
| } catch (err) { | |
| loadStatus.textContent = t("error_prefix") + err.message; | |
| console.error(err); | |
| } | |
| } | |
| // Listen for postMessage from parent (Gradio iframe embed) | |
| window.addEventListener("message", async (event) => { | |
| if (!event.data || event.data.type !== "corrector-load") return; | |
| const { images, xmls, omrDataArray, omrFileUrls } = event.data; | |
| if (!images || !xmls || images.length === 0 || xmls.length === 0) return; | |
| try { | |
| await loadFromData(images, xmls, omrDataArray, omrFileUrls); | |
| } catch (err) { | |
| console.error("loadFromData error:", err); | |
| } | |
| }); | |
| // ── Event Listeners ─────────────────────────────────────────── | |
| loadBtn.addEventListener("click", loadFiles); | |
| document.getElementById("btn-prev-page").addEventListener("click", prevPage); | |
| document.getElementById("btn-next-page").addEventListener("click", nextPage); | |
| // Try loading from server session first (set by /api/convert), then fallback to loadFiles | |
| async function loadFromSession() { | |
| try { | |
| const resp = await fetch("/api/session-data"); | |
| if (!resp.ok) return false; | |
| const data = await resp.json(); | |
| if (!data.image_urls?.length || !data.xml_texts?.length) return false; | |
| await loadFromData(data.image_urls, data.xml_texts, | |
| data.omr_data_array || [], data.omr_file_urls || []); | |
| return true; | |
| } catch (e) { | |
| console.warn("Session data load failed:", e); | |
| return false; | |
| } | |
| } | |
| (async () => { | |
| const loaded = await loadFromSession(); | |
| if (!loaded) { | |
| loadFiles().catch(err => { | |
| console.error("Auto-load error:", err); | |
| document.getElementById("load-status").textContent = "Auto-load error: " + err.message; | |
| }); | |
| } | |
| })(); | |
| markerSvg.addEventListener("mouseover", (e) => { | |
| const circle = e.target.closest("circle.marker"); | |
| if (circle && !circle.classList.contains("ghost-marker")) { | |
| showMarkerTooltip(circle); | |
| const idx = parseInt(circle.dataset.idx); | |
| const n = noteInfos[idx]; | |
| if (n && !n.isRest) { | |
| // Highlight notes in same chord (same measure, onset, voice) | |
| markerSvg.querySelectorAll("circle.marker").forEach(m => { | |
| const mi = parseInt(m.dataset.idx); | |
| if (mi === idx) return; | |
| const mn = noteInfos[mi]; | |
| if (mn && !mn.isRest && | |
| String(mn.measureNum) === String(n.measureNum) && | |
| mn.voice === n.voice && | |
| Math.abs((mn.onsetDiv || 0) - (n.onsetDiv || 0)) < 0.001) { | |
| m.classList.add("chord-hover"); | |
| } | |
| }); | |
| } | |
| } | |
| }); | |
| markerSvg.addEventListener("mouseout", (e) => { | |
| const circle = e.target.closest("circle.marker"); | |
| if (circle) { | |
| hideMarkerTooltip(); | |
| markerSvg.querySelectorAll("circle.chord-hover").forEach(m => m.classList.remove("chord-hover")); | |
| } | |
| }); | |
| markerSvg.addEventListener("click", (e) => { | |
| if (staffAdjustMode) return; // suppress note clicks in staff adjust mode | |
| if (addMode) { | |
| if (e.target.closest("circle.marker") && !e.target.classList.contains("ghost-marker")) { | |
| onMarkerClick(e); | |
| } | |
| return; | |
| } | |
| if (!e.target.closest("circle.marker")) { | |
| // After rubber-band drag, skip the clear | |
| if (_scoreRubberJustFinished) { _scoreRubberJustFinished = false; return; } | |
| // Clicked empty space: clear multi-select | |
| if (!e.ctrlKey && !e.metaKey) { | |
| scoreSelectedIndices.clear(); | |
| _scoreUpdateSelectionVisuals(); | |
| selectNote(-1); | |
| } | |
| return; | |
| } | |
| onMarkerClick(e); | |
| }); | |
| // Ghost marker mousemove | |
| let _ghostDebugThrottle = 0; | |
| document.getElementById("canvas-wrapper").addEventListener("mousemove", (e) => { | |
| if (staffAdjustMode) return; | |
| if (!addMode || (!xmlDoc && !isOmrMode())) { hideGhostMarker(); return; } | |
| const container = document.getElementById("canvas-container"); | |
| const rect = container.getBoundingClientRect(); | |
| const px = (e.clientX - rect.left) / currentZoom; | |
| const py = (e.clientY - rect.top) / currentZoom; | |
| let info; | |
| try { | |
| info = pixelToStaffPitch(px, py); | |
| } catch (err) { | |
| if (Date.now() - _ghostDebugThrottle > 2000) { | |
| console.error("[ghostMove] pixelToStaffPitch crashed:", err); | |
| _ghostDebugThrottle = Date.now(); | |
| } | |
| hideGhostMarker(); | |
| return; | |
| } | |
| if (!info && Date.now() - _ghostDebugThrottle > 2000) { | |
| console.log("[ghostMove] info=null, px=", Math.round(px), "py=", Math.round(py), | |
| "systemsData.len=", systemsData.length, "detectedStaves.len=", detectedStaves.length); | |
| _ghostDebugThrottle = Date.now(); | |
| } | |
| if (info && !info._missingSystem) { | |
| showGhostMarker(info.snappedPx, info.snappedPy, info.step, info.octave, info.beatLabel); | |
| } else if (info && info._missingSystem) { | |
| // Show ghost at click position with hint that measures will be auto-created | |
| showGhostMarker(info.snappedPx, info.snappedPy, info.step, info.octave, "new"); | |
| } else { | |
| hideGhostMarker(); | |
| } | |
| }); | |
| // Add mode click: insert note at click position | |
| document.getElementById("canvas-wrapper").addEventListener("click", (e) => { | |
| if (!addMode) return; | |
| console.log("[addClick] addMode=true, staffAdjustMode=", staffAdjustMode, | |
| "xmlDoc=", !!xmlDoc, "isOmrMode=", isOmrMode(), | |
| "target=", e.target.tagName, e.target.className); | |
| if (staffAdjustMode) { console.log("[addClick] blocked: staffAdjustMode"); return; } | |
| if (!addMode || (!xmlDoc && !isOmrMode())) { console.log("[addClick] blocked: no xmlDoc and not omrMode"); return; } | |
| if (e.target.closest("circle.marker") && !e.target.classList.contains("ghost-marker")) { console.log("[addClick] blocked: clicked on existing marker"); return; } | |
| if (e.shiftKey) { console.log("[addClick] blocked: shiftKey"); return; } | |
| const container = document.getElementById("canvas-container"); | |
| const rect = container.getBoundingClientRect(); | |
| const px = (e.clientX - rect.left) / currentZoom; | |
| const py = (e.clientY - rect.top) / currentZoom; | |
| console.log("[addClick] px=", Math.round(px), "py=", Math.round(py)); | |
| let info; | |
| try { | |
| info = pixelToStaffPitch(px, py); | |
| } catch (err) { | |
| console.error("[addClick] pixelToStaffPitch crashed:", err); | |
| return; | |
| } | |
| console.log("[addClick] info=", info); | |
| // If clicked on a system with no XML measures, auto-create measures first | |
| if (info && info._missingSystem) { | |
| console.log(`[addClick] system ${info.systemIdx} has no measures — auto-creating`); | |
| autoCreateMeasuresForSystem(info.systemIdx); | |
| info = pixelToStaffPitch(px, py); | |
| } | |
| if (!info || (!info.measureEl && !isOmrMode())) { console.log("[addClick] blocked: no info or no measureEl"); return; } | |
| if (!info.measureNum) { console.log("[addClick] blocked: no measureNum"); return; } | |
| console.log("[addClick] inserting note, omrMode=", isOmrMode(), "measure=", info.measureNum, "step=", info.step, info.octave); | |
| if (isOmrMode()) { | |
| insertNoteOmr(info); | |
| } else { | |
| insertNoteAtPosition(info); | |
| } | |
| }); | |
| document.addEventListener("click", (e) => { | |
| if (!chordPopup.contains(e.target)) hideChordPopup(); | |
| }); | |
| document.getElementById("btn-undo").addEventListener("click", undo); | |
| document.getElementById("btn-redo").addEventListener("click", redo); | |
| document.getElementById("btn-up").addEventListener("click", () => _multiEdit(raiseNote)); | |
| document.getElementById("btn-down").addEventListener("click", () => _multiEdit(lowerNote)); | |
| document.getElementById("btn-dblsharp").addEventListener("click", () => _multiEdit((sk) => setAccidental(2, sk))); | |
| document.getElementById("btn-sharp").addEventListener("click", () => _multiEdit((sk) => setAccidental(1, sk))); | |
| document.getElementById("btn-flat").addEventListener("click", () => _multiEdit((sk) => setAccidental(-1, sk))); | |
| document.getElementById("btn-dblflat").addEventListener("click", () => _multiEdit((sk) => setAccidental(-2, sk))); | |
| document.getElementById("btn-natural").addEventListener("click", () => _multiEdit((sk) => setAccidental(0, sk))); | |
| document.getElementById("btn-delete").addEventListener("click", () => _multiDelete()); | |
| document.getElementById("btn-dur-whole").addEventListener("click", () => changeDuration("whole")); | |
| document.getElementById("btn-dur-half").addEventListener("click", () => changeDuration("half")); | |
| document.getElementById("btn-dur-quarter").addEventListener("click", () => changeDuration("quarter")); | |
| document.getElementById("btn-dur-eighth").addEventListener("click", () => changeDuration("eighth")); | |
| document.getElementById("btn-dur-16th").addEventListener("click", () => changeDuration("16th")); | |
| document.getElementById("btn-dur-32nd").addEventListener("click", () => changeDuration("32nd")); | |
| document.getElementById("btn-dur-dot").addEventListener("click", toggleDot); | |
| document.getElementById("btn-triplet").addEventListener("click", () => applyTuplet("triplet")); | |
| document.getElementById("btn-untriplet").addEventListener("click", () => applyTuplet("untriplet")); | |
| document.getElementById("btn-auto-align").addEventListener("click", autoAlignMeasure); | |
| document.getElementById("btn-tl-auto-align").addEventListener("click", autoAlignMeasure); | |
| document.getElementById("btn-rest-toggle").addEventListener("click", toggleNoteRest); | |
| document.getElementById("btn-prev").addEventListener("click", () => navigateNote(-1)); | |
| document.getElementById("btn-next").addEventListener("click", () => navigateNote(1)); | |
| document.getElementById("btn-download").addEventListener("click", downloadModifiedXml); | |
| document.getElementById("btn-download-mml").addEventListener("click", downloadMml); | |
| document.getElementById("btn-debug-lines").addEventListener("click", toggleDebugLines); | |
| document.getElementById("btn-lang").addEventListener("click", toggleLang); | |
| applyI18n(); // apply initial language | |
| zoomSlider.addEventListener("input", (e) => applyZoom(parseInt(e.target.value))); | |
| applyZoom(parseInt(zoomSlider.value)); // apply initial zoom | |
| dpiInput.addEventListener("change", () => { | |
| if (!layout) return; | |
| const dpi = parseInt(dpiInput.value) || 300; | |
| pixelsPerTenth = computePixelsPerTenthFromDpi(dpi, layout.mm, layout.tpu); | |
| const ux = parseFloat(offsetX.value || 0); | |
| const uy = parseFloat(offsetY.value || 0); | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| if (selectedIdx >= 0) selectNote(selectedIdx); | |
| }); | |
| offsetX.addEventListener("change", recomputeAll); | |
| offsetY.addEventListener("change", recomputeAll); | |
| document.getElementById("staff-dist-input").addEventListener("change", recomputeAll); | |
| document.getElementById("sys-dist-adj").addEventListener("change", recomputeAll); | |
| // Keyboard shortcuts | |
| document.addEventListener("keydown", (e) => { | |
| if (e.target.tagName === "INPUT") return; | |
| // Barline mode intercepts all keys (handled by its own keydown listener) | |
| if (barlineMode) return; | |
| // Ctrl+Z / Ctrl+Y for undo/redo | |
| if (e.ctrlKey || e.metaKey) { | |
| if (e.key === "z" || e.key === "Z") { e.preventDefault(); undo(); return; } | |
| if (e.key === "y" || e.key === "Y") { e.preventDefault(); redo(); return; } | |
| } | |
| switch (e.key) { | |
| case "ArrowUp": e.preventDefault(); _multiEdit(raiseNote); break; | |
| case "ArrowDown": e.preventDefault(); _multiEdit(lowerNote); break; | |
| case "Tab": e.preventDefault(); navigateNote(e.shiftKey ? -1 : 1); break; | |
| case "Escape": selectNote(-1); scoreSelectedIndices.clear(); _scoreUpdateSelectionVisuals(); hideChordPopup(); break; | |
| case " ": e.preventDefault(); togglePlayback(); break; | |
| case "#": _multiEdit((sk) => setAccidental(1, sk)); break; | |
| case "b": _multiEdit((sk) => setAccidental(-1, sk)); break; | |
| case "n": _multiEdit((sk) => setAccidental(0, sk)); break; | |
| case "N": e.preventDefault(); toggleAddMode(); break; | |
| case "A": e.preventDefault(); autoAlignMeasure(); break; | |
| case "B": | |
| // Enter barline mode (only if not in other modes) | |
| if (!staffAdjustMode && !addMode && !barlineMode) { | |
| e.preventDefault(); | |
| enterBarlineMode(); | |
| } | |
| break; | |
| case "r": e.preventDefault(); toggleNoteRest(); break; | |
| case "t": e.preventDefault(); toggleTimelinePanel(); break; | |
| case "T": e.preventDefault(); editTimeSignature(); break; | |
| case "K": e.preventDefault(); editKeySignature(); break; | |
| case "C": e.preventDefault(); editClef(); break; | |
| case "A": e.preventDefault(); addChordNote(); break; | |
| case "Delete": e.preventDefault(); _multiDelete(); break; | |
| case "PageUp": e.preventDefault(); prevPage(); break; | |
| case "PageDown": e.preventDefault(); nextPage(); break; | |
| case ".": e.preventDefault(); toggleDot(); break; | |
| case "1": case "2": case "4": case "5": case "6": case "7": | |
| if (KEY_TO_TYPE[e.key]) { | |
| e.preventDefault(); | |
| if (addMode) { | |
| addDurationType = KEY_TO_TYPE[e.key]; | |
| const statusEl = document.getElementById("status-mode"); | |
| if (statusEl) statusEl.textContent = `[ADD ${DUR_SYMBOLS[addDurationType] || addDurationType}]`; | |
| } else { | |
| changeDuration(KEY_TO_TYPE[e.key]); | |
| } | |
| } | |
| break; | |
| } | |
| }); | |
| // ================================================================ | |
| // Section 8: Playback Engine | |
| // ================================================================ | |
| let audioCtx = null; | |
| let playbackTimeline = []; | |
| let playbackTimer = null; | |
| let playbackStartTime = 0; | |
| let playbackEventIdx = 0; | |
| let isPlaying = false; | |
| let playbackStartOffset = 0; // seconds offset for seek | |
| let cursorSeekTime = 0; // time in seconds where the red cursor is parked | |
| function _previewNote(n) { | |
| if (!n || n.isRest) return; | |
| if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| const freq = noteToFreq(n.step, n.octave, n.alter); | |
| playNoteSound(freq, 0.3, 0, n.step, n.octave, n.alter); | |
| } | |
| let _dragPreviewGain = null; | |
| let _dragPreviewSrc = null; | |
| function _previewNoteDrag(n) { | |
| if (!n || n.isRest) return; | |
| if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| if (_dragPreviewGain) { try { _dragPreviewGain.gain.setValueAtTime(0, audioCtx.currentTime); } catch(e){} } | |
| if (_dragPreviewSrc) { try { _dragPreviewSrc.stop(); } catch(e){} } | |
| _dragPreviewGain = null; _dragPreviewSrc = null; | |
| const freq = noteToFreq(n.step, n.octave, n.alter); | |
| const t = audioCtx.currentTime; | |
| const dur = 0.25; | |
| const name = sfName(n.step, n.octave, n.alter || 0); | |
| const buf = name ? pianoCache[name] : null; | |
| const gain = audioCtx.createGain(); | |
| gain.connect(audioCtx.destination); | |
| gain.gain.setValueAtTime(0.001, t); | |
| gain.gain.linearRampToValueAtTime(masterVolume * 0.7, t + 0.005); | |
| gain.gain.setValueAtTime(masterVolume * 0.6, t + dur * 0.6); | |
| gain.gain.exponentialRampToValueAtTime(0.001, t + dur); | |
| _dragPreviewGain = gain; | |
| if (buf) { | |
| const src = audioCtx.createBufferSource(); | |
| src.buffer = buf; | |
| src.connect(gain); | |
| src.start(t); | |
| src.stop(t + dur + 0.05); | |
| _dragPreviewSrc = src; | |
| } else { | |
| const osc = audioCtx.createOscillator(); | |
| osc.type = "sine"; | |
| osc.frequency.value = freq; | |
| osc.connect(gain); | |
| osc.start(t); | |
| osc.stop(t + dur + 0.05); | |
| _dragPreviewSrc = osc; | |
| } | |
| } | |
| function noteToFreq(step, octave, alter) { | |
| const midi = (octave + 1) * 12 + [0,2,4,5,7,9,11][STEP_INDEX[step]] + alter; | |
| return 440 * Math.pow(2, (midi - 69) / 12); | |
| } | |
| // ── Piano Sample System ───────────────────────────────────── | |
| // Loads real piano samples from MIDI.js soundfonts (CDN). | |
| // Falls back to FM synth if samples unavailable. | |
| const PIANO_CDN = "https://gleitz.github.io/midi-js-soundfonts/FatBoy/acoustic_grand_piano-mp3"; | |
| const pianoCache = {}; // "C4" → AudioBuffer | |
| const pianoLoading = {}; // "C4" → Promise | |
| // Convert corrector (step, octave, alter) → soundfont note name | |
| // Soundfont uses flats: Db, Eb, Gb, Ab, Bb | |
| // Handles double sharp (alter=2) and double flat (alter=-2) | |
| function sfName(step, octave, alter) { | |
| const a = Math.round(alter); | |
| if (a === 0) return step + octave; | |
| // Normalize: convert step+alter to MIDI semitone, then back to nearest natural/flat name | |
| const SEMI = { C:0, D:2, E:4, F:5, G:7, A:9, B:11 }; | |
| const semi = (SEMI[step] + a + 12) % 12; | |
| const octShift = Math.floor((SEMI[step] + a) / 12); | |
| const resultOct = octave + octShift; | |
| // Map semitone back to soundfont name (prefer flats for black keys) | |
| const SF_MAP = ["C","Db","D","Eb","E","F","Gb","G","Ab","A","Bb","B"]; | |
| return SF_MAP[semi] + resultOct; | |
| } | |
| async function loadPianoNote(ctx, name) { | |
| if (pianoCache[name]) return pianoCache[name]; | |
| if (pianoLoading[name]) return pianoLoading[name]; | |
| pianoLoading[name] = (async () => { | |
| try { | |
| const resp = await fetch(`${PIANO_CDN}/${name}.mp3`); | |
| if (!resp.ok) return null; | |
| const buf = await resp.arrayBuffer(); | |
| const audioBuf = await ctx.decodeAudioData(buf); | |
| pianoCache[name] = audioBuf; | |
| return audioBuf; | |
| } catch(e) { console.warn(`Piano load failed: ${name}`, e); return null; } | |
| })(); | |
| return pianoLoading[name]; | |
| } | |
| // Preload all unique notes in current noteInfos | |
| async function preloadPianoSamples(ctx) { | |
| const names = new Set(); | |
| for (const n of noteInfos) { | |
| if (n.isRest) continue; | |
| names.add(sfName(n.step, n.octave, n.alter)); | |
| } | |
| const unloaded = [...names].filter(nm => !pianoCache[nm]); | |
| if (unloaded.length === 0) return; | |
| loadStatus.textContent = t("loading_piano")(unloaded.length); | |
| await Promise.all(unloaded.map(nm => loadPianoNote(ctx, nm))); | |
| const loaded = [...names].filter(nm => pianoCache[nm]); | |
| const failed = [...names].filter(nm => !pianoCache[nm]); | |
| console.log(`Piano preload: ${loaded.length} loaded, ${failed.length} failed`, failed.length > 0 ? failed : ""); | |
| loadStatus.textContent = currentLang === "ko" ? `피아노 로딩 완료 (${loaded.length}/${names.size})` : `Piano loaded (${loaded.length}/${names.size} notes)`; | |
| } | |
| function playNoteSound(freq, duration, startOffset, step, octave, alter) { | |
| if (!audioCtx) return; | |
| const t = audioCtx.currentTime + startOffset; | |
| const vol = masterVolume; | |
| // Try piano sample | |
| const name = (step && octave != null) ? sfName(step, octave, alter || 0) : null; | |
| const buf = name ? pianoCache[name] : null; | |
| console.log(`playNote: ${step}${alter>0?'#':alter<0?'b':''}${octave} → sf="${name}" piano=${!!buf} freq=${freq.toFixed(1)}`); | |
| if (buf) { | |
| // Play real piano sample with smooth envelope | |
| const src = audioCtx.createBufferSource(); | |
| src.buffer = buf; | |
| const gain = audioCtx.createGain(); | |
| src.connect(gain); | |
| gain.connect(audioCtx.destination); | |
| // Soft attack (avoid click) | |
| gain.gain.setValueAtTime(0.001, t); | |
| gain.gain.linearRampToValueAtTime(vol, t + 0.008); | |
| // Sustain then smooth release | |
| const releaseDur = Math.min(0.08, duration * 0.3); | |
| const sustainEnd = t + duration - releaseDur; | |
| gain.gain.setValueAtTime(vol, sustainEnd); | |
| gain.gain.exponentialRampToValueAtTime(0.001, t + duration); | |
| src.start(t); | |
| src.stop(t + duration + 0.1); | |
| } else { | |
| // Fallback: sine with smooth ADSR | |
| const osc = audioCtx.createOscillator(); | |
| const gain = audioCtx.createGain(); | |
| osc.type = "sine"; | |
| osc.frequency.value = freq; | |
| osc.connect(gain); | |
| gain.connect(audioCtx.destination); | |
| // Attack | |
| gain.gain.setValueAtTime(0.001, t); | |
| gain.gain.linearRampToValueAtTime(vol * 0.24, t + 0.015); | |
| // Release | |
| const releaseDur = Math.min(0.06, duration * 0.3); | |
| const sustainEnd = t + duration - releaseDur; | |
| gain.gain.setValueAtTime(vol * 0.24, sustainEnd); | |
| gain.gain.exponentialRampToValueAtTime(0.001, t + duration); | |
| osc.start(t); | |
| osc.stop(t + duration + 0.1); | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════ | |
| // TIMELINE PANEL (Piano Roll TimeOffset Editor) — right side panel | |
| // ═══════════════════════════════════════════════════════════════════ | |
| let timelinePanelVisible = true; | |
| let timelinePanelMeasure = null; // { measureNum, systemIdx } | |
| let _tlDragCleanup = null; // cleanup function for drag listeners | |
| /** Scroll the score so the marker for noteIdx is visible */ | |
| function scrollMarkerIntoView(idx) { | |
| if (idx < 0 || idx >= noteInfos.length) return; | |
| const circle = markerSvg.querySelector(`circle[data-idx="${idx}"]`); | |
| if (!circle) return; | |
| const wrapper = document.getElementById("canvas-wrapper"); | |
| const cx = parseFloat(circle.getAttribute("cx")) * currentZoom; | |
| const cy = parseFloat(circle.getAttribute("cy")) * currentZoom; | |
| const wRect = wrapper.getBoundingClientRect(); | |
| const margin = 80; | |
| // Horizontal scroll | |
| if (cx < wrapper.scrollLeft + margin || cx > wrapper.scrollLeft + wRect.width - margin) { | |
| wrapper.scrollLeft = cx - wRect.width / 2; | |
| } | |
| // Vertical scroll | |
| if (cy < wrapper.scrollTop + margin || cy > wrapper.scrollTop + wRect.height - margin) { | |
| wrapper.scrollTop = cy - wRect.height / 2; | |
| } | |
| } | |
| function toggleTimelinePanel() { | |
| const panel = document.getElementById("timeline-panel"); | |
| timelinePanelVisible = !timelinePanelVisible; | |
| panel.style.display = timelinePanelVisible ? "flex" : "none"; | |
| if (timelinePanelVisible && selectedIdx >= 0) { | |
| renderTimelinePanel(noteInfos[selectedIdx].measureNum, noteInfos[selectedIdx].systemIdx); | |
| } else if (!timelinePanelVisible) { | |
| // Remove measure highlight when TL is hidden | |
| document.querySelectorAll(".measure-highlight").forEach(el => el.remove()); | |
| } | |
| } | |
| /** Get sorted list of all measure numbers in current page */ | |
| function _tlGetMeasureList() { | |
| const seen = new Set(); | |
| noteInfos.forEach(n => seen.add(String(n.measureNum))); | |
| return [...seen].sort((a, b) => parseInt(a) - parseInt(b)); | |
| } | |
| function _tlNavigateMeasure(delta) { | |
| if (!timelinePanelMeasure) return; | |
| const list = _tlGetMeasureList(); | |
| const curIdx = list.indexOf(String(timelinePanelMeasure.measureNum)); | |
| const newIdx = curIdx + delta; | |
| if (newIdx < 0 || newIdx >= list.length) return; | |
| const newMeasNum = list[newIdx]; | |
| // Find a note in this measure to get systemIdx | |
| const sample = noteInfos.find(n => String(n.measureNum) === newMeasNum); | |
| if (sample) { | |
| const sampleIdx = noteInfos.indexOf(sample); | |
| renderTimelinePanel(newMeasNum, sample.systemIdx); | |
| selectNote(sampleIdx); | |
| scrollMarkerIntoView(sampleIdx); | |
| } | |
| } | |
| function _tlGetMeasureDuration(measureNum, systemIdx) { | |
| // Always use systemsData — it has .number field and is kept in sync for both modes | |
| for (const sys of systemsData) { | |
| const m = sys.measures.find(m => String(m.number) === String(measureNum)); | |
| if (m && m.duration) return parseRational(m.duration); | |
| } | |
| return 1; | |
| } | |
| function _editMeasureDuration(measureNum, systemIdx) { | |
| if (!isOmrMode()) return; | |
| const pg = pages[currentPageIdx]; | |
| if (!pg || !pg.omrData) return; | |
| const curDur = _tlGetMeasureDuration(measureNum, systemIdx); | |
| const curBeats = curDur * 4; | |
| const curRat = durationFloatToRational(curDur); | |
| const input = prompt( | |
| currentLang === "ko" | |
| ? `마디 ${measureNum} duration 수정\n현재: ${curRat} (${curBeats} beats)\n\n새 값 입력 (beats 또는 분수):\n 예: 4 = 4beats(4/4), 3 = 3beats(3/4), 3.5 = 7/8\n 분수: 1/1, 3/4, 7/8 등` | |
| : `Edit measure ${measureNum} duration\nCurrent: ${curRat} (${curBeats} beats)\n\nEnter new value (beats or fraction):\n e.g. 4 = 4beats(4/4), 3 = 3beats(3/4)\n Fraction: 1/1, 3/4, 7/8`, | |
| curBeats.toString() | |
| ); | |
| if (input === null) return; | |
| let newDur; | |
| if (input.includes("/")) { | |
| newDur = parseRational(input); | |
| } else { | |
| const b = parseFloat(input); | |
| if (isNaN(b) || b <= 0) return; | |
| newDur = b / 4; // beats → whole-note fraction | |
| } | |
| if (!newDur || newDur <= 0 || Math.abs(newDur - curDur) < 0.001) return; | |
| pushUndo(); | |
| const newRat = durationFloatToRational(newDur); | |
| // Update omrData stack | |
| const sys = pg.omrData.systems[systemIdx]; | |
| if (sys) { | |
| let globalBase = 1; | |
| for (let si = 0; si < systemIdx; si++) { | |
| globalBase += (pg.omrData.systems[si].stacks || []).length || 1; | |
| } | |
| const stackIdx = parseInt(measureNum) - globalBase; | |
| if (sys.stacks && sys.stacks[stackIdx]) { | |
| sys.stacks[stackIdx].duration = newRat; | |
| } | |
| // Update all omrData measures at this stack position (all parts) | |
| const numStacks = (sys.stacks || []).length || 1; | |
| for (let pi = 0; pi * numStacks + stackIdx < sys.measures.length; pi++) { | |
| const mi = pi * numStacks + stackIdx; | |
| if (sys.measures[mi]) sys.measures[mi].duration = newRat; | |
| } | |
| } | |
| // Update systemsData measure | |
| const sysInfo = systemsData[systemIdx]; | |
| if (sysInfo) { | |
| const m = sysInfo.measures.find(m => String(m.number) === String(measureNum)); | |
| if (m) m.duration = newRat; | |
| } | |
| // Rebuild and re-render | |
| rebuildSystemsAndNotes(); | |
| const ux = parseInt(document.getElementById("offset-x").value) || 0; | |
| const uy = parseInt(document.getElementById("offset-y").value) || 0; | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| } | |
| function _updateMeasureHighlight(measureNum, systemIdx) { | |
| const svg = document.getElementById("marker-svg"); | |
| if (!svg) return; | |
| // Remove old highlight | |
| svg.querySelectorAll(".measure-highlight").forEach(el => el.remove()); | |
| if (!systemsData[systemIdx]) return; | |
| const sys = systemsData[systemIdx]; | |
| const meas = sys.measures.find(m => String(m.number) === String(measureNum)); | |
| if (!meas) return; | |
| const ux = parseInt(document.getElementById("offset-x").value) || 0; | |
| const uy = parseInt(document.getElementById("offset-y").value) || 0; | |
| const numStavesPerSys = sys.numStaves || 1; | |
| const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys); | |
| const sysStaves = staffSystems[systemIdx]; | |
| if (!sysStaves || sysStaves.length === 0) return; | |
| const x = (meas.left || 0) + ux; | |
| const w = meas.width || ((meas.right || 0) - (meas.left || 0)); | |
| const yTop = sysStaves[0].topLineY + uy - 10; | |
| const yBot = sysStaves[sysStaves.length - 1].bottomLineY + uy + 10; | |
| const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); | |
| rect.classList.add("measure-highlight"); | |
| rect.setAttribute("x", x); | |
| rect.setAttribute("y", yTop); | |
| rect.setAttribute("width", Math.max(w, 10)); | |
| rect.setAttribute("height", yBot - yTop); | |
| rect.setAttribute("rx", "4"); | |
| // Insert before markers so it's behind them | |
| const firstMarker = svg.querySelector("circle.marker"); | |
| if (firstMarker) svg.insertBefore(rect, firstMarker); | |
| else svg.appendChild(rect); | |
| } | |
| function renderTimelinePanel(measureNum, systemIdx) { | |
| const body = document.getElementById("timeline-body"); | |
| const title = document.getElementById("timeline-title"); | |
| if (!body) return; | |
| timelinePanelMeasure = { measureNum, systemIdx }; | |
| _updateMeasureHighlight(measureNum, systemIdx); | |
| // Collect notes for this measure | |
| const measureNotes = []; | |
| noteInfos.forEach((n, idx) => { | |
| if (String(n.measureNum) === String(measureNum) && n.systemIdx === systemIdx) { | |
| measureNotes.push({ n, idx }); | |
| } | |
| }); | |
| const measDuration = _tlGetMeasureDuration(measureNum, systemIdx); | |
| const staves = [...new Set(measureNotes.map(e => e.n.staff))].sort((a, b) => a - b); | |
| const gridSize = parseFloat(document.getElementById("timeline-grid-select").value) || 0.125; | |
| // Measure nav info | |
| const measList = _tlGetMeasureList(); | |
| const curMeasIdx = measList.indexOf(String(measureNum)); | |
| const hasPrev = curMeasIdx > 0; | |
| const hasNext = curMeasIdx < measList.length - 1; | |
| // Track area: label is 56px (CSS), rest is track | |
| const LABEL_W = 80; | |
| const PAD_R = 8; | |
| const bodyW = body.clientWidth || 320; | |
| const trackW = bodyW - LABEL_W - PAD_R; | |
| // onset → px helper | |
| const onsetToPx = (onset) => LABEL_W + (onset / measDuration) * trackW; | |
| let html = ''; | |
| // Navigation bar | |
| html += '<div class="tl-nav">'; | |
| html += `<button id="tl-prev" ${hasPrev ? '' : 'disabled'} style="${hasPrev ? '' : 'opacity:0.3;cursor:default'}">«</button>`; | |
| html += `<span style="color:#e94560;font-weight:bold;font-size:14px;line-height:24px">M${measureNum}</span>`; | |
| html += `<button id="tl-next" ${hasNext ? '' : 'disabled'} style="${hasNext ? '' : 'opacity:0.3;cursor:default'}">»</button>`; | |
| html += `<span style="color:#666;font-size:10px;margin-left:auto;line-height:24px">${durationFloatToRational(measDuration)} (${(measDuration * 4).toFixed(0)} beats)</span>`; | |
| html += '</div>'; | |
| // Container for lanes + sync lines | |
| html += '<div id="tl-container" style="position:relative">'; | |
| const onsetMap = {}; // onset → [{laneIdx, noteIdx}] | |
| const laneTopOffsets = []; // track cumulative top for sync lines | |
| staves.forEach((staffNum, si) => { | |
| const staffNotes = measureNotes.filter(e => e.n.staff === staffNum); | |
| const clefSign = staffNotes.length > 0 ? (staffNotes[0].n.clef?.sign || "G") : "G"; | |
| const ROW_H = 104; | |
| // Pass 1: compute positions + collision detection for sub-row stacking | |
| // Ignore voice — use only time overlap for row assignment | |
| const notePlacements = []; // { n, idx, x, w, row } | |
| // Sort all staff notes by onset | |
| const sortedStaffNotes = [...staffNotes].sort((a, b) => (a.n.onsetDiv || 0) - (b.n.onsetDiv || 0)); | |
| const subRowEnds = []; // subRowEnds[i] = max logical end of notes in sub-row i | |
| let totalRows = 0; | |
| sortedStaffNotes.forEach(({ n, idx }) => { | |
| const onset = n.onsetDiv || 0; | |
| const dur = n.durationDiv || 0; | |
| const x = onsetToPx(onset); | |
| const w = Math.max(18, (dur / measDuration) * trackW); | |
| const logicalEnd = onsetToPx(onset + dur); | |
| // Find first sub-row where this note doesn't overlap | |
| let subRow = 0; | |
| while (subRow < subRowEnds.length && subRowEnds[subRow] > x + 2) { | |
| subRow++; | |
| } | |
| if (subRow >= subRowEnds.length) subRowEnds.push(logicalEnd); | |
| else subRowEnds[subRow] = logicalEnd; | |
| if (subRow > totalRows) totalRows = subRow; | |
| notePlacements.push({ n, idx, x, w, row: subRow }); | |
| }); | |
| totalRows += 1; | |
| const laneH = Math.max(1, totalRows) * ROW_H + 8; | |
| html += `<div class="tl-lane" data-staff="${staffNum}" style="height:${laneH}px">`; | |
| html += `<div class="tl-lane-label"><span class="staff-id">S${staffNum}</span>${clefSign === "F" ? "Bass" : "Treble"}</div>`; | |
| // Grid lines | |
| for (let g = 0; g <= measDuration + 0.0001; g += gridSize) { | |
| const x = onsetToPx(g); | |
| const isBeat = Math.abs(g % 0.25) < 0.001; | |
| const isBar = Math.abs(g) < 0.0001; | |
| const cls = isBar ? 'tl-grid-line bar-start' : (isBeat ? 'tl-grid-line beat' : 'tl-grid-line'); | |
| html += `<div class="${cls}" style="left:${x}px"></div>`; | |
| if (isBeat && si === staves.length - 1) { | |
| const label = g < 0.001 ? '0' : durationFloatToRational(g); | |
| html += `<div class="tl-grid-label" style="left:${x}px">${label}</div>`; | |
| } | |
| } | |
| // Note blocks (from pre-computed placements) | |
| notePlacements.forEach(({ n, idx, x, w, row }) => { | |
| const top = 4 + row * ROW_H; | |
| let cls = 'tl-note'; | |
| if (n.isRest) cls += ' rest'; | |
| if (n.orphan) cls += ' orphan'; | |
| if (idx === selectedIdx || tlSelectedIndices.has(idx)) cls += ' selected'; | |
| cls += ` voice${n.voice || 1}`; | |
| // Check if part of multi-head chord | |
| const chord = findOmrChord(n); | |
| const isMultiChord = chord && chord.heads && chord.heads.length > 1; | |
| if (isMultiChord) cls += ' chord-linked'; | |
| const acc = n.alter ? alterStr(n.alter) : ''; | |
| const pitchLabel = n.isRest ? 'R' : `${n.step}${acc}${n.octave}`; | |
| const onset = n.onsetDiv || 0; | |
| const tRat = n.timeOffsetRational || durationFloatToRational(onset); | |
| // Build tooltip with chord info | |
| let tooltip = `${pitchLabel} onset=${tRat} dur=${n.durationRational || '?'} voice ${n.voice}`; | |
| if (isMultiChord) { | |
| const others = chord.heads.filter(h => h.headId !== n.omrHeadId).map(h => { | |
| const { step: s, octave: o } = omrPitchToStepOctave(h.pitch, | |
| n.clef || { sign: "G", line: 2, octaveChange: 0 }); | |
| return `${s}${o}`; | |
| }); | |
| tooltip += `\n🔗 화음: [${others.join(', ')}]과 묶임\nAlt+드래그: 전체 이동`; | |
| } | |
| if (n.tupletGroupId) cls += ' tuplet-grouped'; | |
| html += `<div class="${cls}" data-idx="${idx}" data-onset="${onset}" ` + | |
| `data-chord-id="${n.omrChordId || ''}" ` + | |
| `data-tuplet-group="${n.tupletGroupId || ''}" ` + | |
| `data-tooltip="${tooltip.replace(/"/g, '"')}" ` + | |
| `style="left:${x}px;width:${w}px;top:${top}px">` + | |
| `${pitchLabel}</div>`; | |
| const onsetKey = onset.toFixed(6); | |
| if (!onsetMap[onsetKey]) onsetMap[onsetKey] = []; | |
| onsetMap[onsetKey].push({ laneIdx: si, noteIdx: idx }); | |
| }); | |
| html += '</div>'; | |
| }); | |
| // Sync lines spanning across staves | |
| Object.entries(onsetMap).forEach(([onsetKey, entries]) => { | |
| const uniqueLanes = new Set(entries.map(e => e.laneIdx)); | |
| if (uniqueLanes.size > 1) { | |
| const onset = parseFloat(onsetKey); | |
| const x = onsetToPx(onset); | |
| html += `<div class="tl-sync-line" style="left:${x}px;top:0;height:100%"></div>`; | |
| } | |
| }); | |
| html += '</div>'; // tl-container | |
| // Measure title update — clickable to edit duration | |
| const beats = measDuration * 4; | |
| const beatDisplay = Number.isInteger(beats) ? beats : beats.toFixed(1); | |
| title.textContent = `M${measureNum} [${durationFloatToRational(measDuration)} = ${beatDisplay} beats]`; | |
| title.style.cursor = "pointer"; | |
| title.title = currentLang === "ko" ? "클릭하여 마디 duration 수정" : "Click to edit measure duration"; | |
| title.onclick = () => _editMeasureDuration(measureNum, systemIdx); | |
| body.innerHTML = html; | |
| // Draw tuplet group brackets | |
| const tupletGroups = {}; | |
| body.querySelectorAll(".tl-note[data-tuplet-group]").forEach(el => { | |
| const gid = el.dataset.tupletGroup; | |
| if (!gid) return; | |
| if (!tupletGroups[gid]) tupletGroups[gid] = []; | |
| tupletGroups[gid].push(el); | |
| }); | |
| const container = document.getElementById("tl-container"); | |
| if (container) { | |
| Object.entries(tupletGroups).forEach(([gid, els]) => { | |
| if (els.length < 2) return; | |
| const firstLeft = parseFloat(els[0].style.left); | |
| const lastEl = els[els.length - 1]; | |
| const lastLeft = parseFloat(lastEl.style.left) + lastEl.offsetWidth; | |
| const top = parseFloat(els[0].style.top) - 14; | |
| const bracket = document.createElement("div"); | |
| bracket.className = "tl-tuplet-bracket"; | |
| bracket.style.left = firstLeft + "px"; | |
| bracket.style.width = (lastLeft - firstLeft) + "px"; | |
| bracket.style.top = top + "px"; | |
| bracket.textContent = "3"; | |
| bracket.dataset.tupletGroup = gid; | |
| container.appendChild(bracket); | |
| }); | |
| } | |
| // Wire up nav buttons | |
| const prevBtn = document.getElementById("tl-prev"); | |
| const nextBtn = document.getElementById("tl-next"); | |
| if (prevBtn) prevBtn.addEventListener("click", () => _tlNavigateMeasure(-1)); | |
| if (nextBtn) nextBtn.addEventListener("click", () => _tlNavigateMeasure(1)); | |
| // Setup drag | |
| setupTimelineDrag(measDuration, trackW, LABEL_W, gridSize); | |
| } | |
| // ── Timeline Multi-Select State ── | |
| let tlSelectedIndices = new Set(); // multi-select in timeline | |
| function tlSelectOnly(idx) { | |
| tlSelectedIndices.clear(); | |
| tlSelectedIndices.add(idx); | |
| _tlUpdateSelectionVisuals(); | |
| } | |
| function tlToggleSelect(idx) { | |
| if (tlSelectedIndices.has(idx)) tlSelectedIndices.delete(idx); | |
| else tlSelectedIndices.add(idx); | |
| _tlUpdateSelectionVisuals(); | |
| } | |
| function tlSelectRange(indices) { | |
| tlSelectedIndices = new Set(indices); | |
| _tlUpdateSelectionVisuals(); | |
| } | |
| function _tlUpdateSelectionVisuals() { | |
| const body = document.getElementById("timeline-body"); | |
| if (!body) return; | |
| body.querySelectorAll(".tl-note").forEach(el => { | |
| const idx = parseInt(el.dataset.idx); | |
| if (tlSelectedIndices.has(idx)) el.classList.add("selected"); | |
| else el.classList.remove("selected"); | |
| }); | |
| // Sync to score markers | |
| scoreSelectedIndices = new Set(tlSelectedIndices); | |
| markerSvg.querySelectorAll("circle.marker").forEach(c => { | |
| const i = parseInt(c.dataset.idx); | |
| if (scoreSelectedIndices.has(i)) c.classList.add("multi-selected"); | |
| else c.classList.remove("multi-selected"); | |
| }); | |
| } | |
| function setupTimelineDrag(measDuration, trackWidth, laneLeft, gridSize) { | |
| const body = document.getElementById("timeline-body"); | |
| if (!body) return; | |
| // Cleanup previous drag listeners | |
| if (_tlDragCleanup) { _tlDragCleanup(); _tlDragCleanup = null; } | |
| let dragMode = null; // "move" | "rubber" | |
| let dragStartX = 0; | |
| let dragStartY = 0; | |
| // For "move" mode: info about the group being dragged | |
| let dragOrigData = []; // [{el, origLeft, origOnset, idx}] | |
| // For "rubber" band selection | |
| let rubberEl = null; | |
| function onMouseDown(e) { | |
| const el = e.target.closest(".tl-note"); | |
| if (el) { | |
| // Click on a note | |
| const noteIdx = parseInt(el.dataset.idx); | |
| if (e.ctrlKey || e.metaKey) { | |
| // Ctrl+click: toggle in multi-selection (no drag) | |
| tlToggleSelect(noteIdx); | |
| selectNote(noteIdx); | |
| scrollMarkerIntoView(noteIdx); | |
| e.preventDefault(); | |
| return; | |
| } else { | |
| // Normal click | |
| if (!tlSelectedIndices.has(noteIdx)) { | |
| // Not in current selection: select only this | |
| tlSelectOnly(noteIdx); | |
| } | |
| // else: keep multi-selection (for drag) | |
| selectNote(noteIdx); | |
| scrollMarkerIntoView(noteIdx); | |
| } | |
| // orphan notes are now draggable like normal notes | |
| // Start "move" mode for all selected notes + their tuplet groups | |
| dragMode = "move"; | |
| dragStartX = e.clientX; | |
| dragOrigData = []; | |
| const expandedIndices = new Set(tlSelectedIndices); | |
| for (const si of tlSelectedIndices) { | |
| const group = getTupletGroup(si); | |
| if (group) group.forEach(gi => expandedIndices.add(gi)); | |
| } | |
| body.querySelectorAll(".tl-note").forEach(noteEl => { | |
| const idx = parseInt(noteEl.dataset.idx); | |
| if (expandedIndices.has(idx)) { | |
| dragOrigData.push({ | |
| el: noteEl, | |
| origLeft: parseFloat(noteEl.style.left), | |
| origOnset: parseFloat(noteEl.dataset.onset), | |
| idx | |
| }); | |
| noteEl.classList.add("dragging"); | |
| } | |
| }); | |
| e.preventDefault(); | |
| } else { | |
| // Click on empty space: start rubber-band selection | |
| if (!e.ctrlKey && !e.metaKey) { | |
| tlSelectedIndices.clear(); | |
| _tlUpdateSelectionVisuals(); | |
| } | |
| dragMode = "rubber"; | |
| dragStartX = e.clientX; | |
| dragStartY = e.clientY; | |
| // Create rubber-band element | |
| const bodyRect = body.getBoundingClientRect(); | |
| rubberEl = document.createElement("div"); | |
| rubberEl.className = "tl-rubber"; | |
| rubberEl.style.position = "absolute"; | |
| rubberEl.style.left = (e.clientX - bodyRect.left + body.scrollLeft) + "px"; | |
| rubberEl.style.top = (e.clientY - bodyRect.top + body.scrollTop) + "px"; | |
| rubberEl.style.width = "0px"; | |
| rubberEl.style.height = "0px"; | |
| body.style.position = "relative"; | |
| body.appendChild(rubberEl); | |
| e.preventDefault(); | |
| } | |
| } | |
| function onMouseMove(e) { | |
| if (dragMode === "move") { | |
| const dx = e.clientX - dragStartX; | |
| const snapEnabled = document.getElementById("timeline-snap")?.checked; | |
| dragOrigData.forEach(d => { | |
| let newLeft = d.origLeft + dx; | |
| newLeft = Math.max(laneLeft, Math.min(newLeft, laneLeft + trackWidth - 16)); | |
| let newOnset = ((newLeft - laneLeft) / trackWidth) * measDuration; | |
| if (snapEnabled) { | |
| newOnset = Math.round(newOnset / gridSize) * gridSize; | |
| newLeft = laneLeft + (newOnset / measDuration) * trackWidth; | |
| } | |
| newOnset = Math.max(0, newOnset); | |
| d.el.style.left = newLeft + "px"; | |
| d.el.dataset.onset = newOnset; | |
| }); | |
| } else if (dragMode === "rubber" && rubberEl) { | |
| const bodyRect = body.getBoundingClientRect(); | |
| const curX = e.clientX - bodyRect.left + body.scrollLeft; | |
| const curY = e.clientY - bodyRect.top + body.scrollTop; | |
| const startX = dragStartX - bodyRect.left + body.scrollLeft; | |
| const startY = dragStartY - bodyRect.top + body.scrollTop; | |
| const rx = Math.min(startX, curX); | |
| const ry = Math.min(startY, curY); | |
| const rw = Math.abs(curX - startX); | |
| const rh = Math.abs(curY - startY); | |
| rubberEl.style.left = rx + "px"; | |
| rubberEl.style.top = ry + "px"; | |
| rubberEl.style.width = rw + "px"; | |
| rubberEl.style.height = rh + "px"; | |
| // Highlight notes within rubber-band | |
| const selected = new Set(e.ctrlKey || e.metaKey ? tlSelectedIndices : []); | |
| body.querySelectorAll(".tl-note").forEach(noteEl => { | |
| const nr = noteEl.getBoundingClientRect(); | |
| const nx = nr.left - bodyRect.left + body.scrollLeft; | |
| const ny = nr.top - bodyRect.top + body.scrollTop; | |
| const nw = nr.width; | |
| const nh = nr.height; | |
| const overlap = !(nx + nw < rx || nx > rx + rw || ny + nh < ry || ny > ry + rh); | |
| const idx = parseInt(noteEl.dataset.idx); | |
| if (overlap) selected.add(idx); | |
| if (selected.has(idx)) noteEl.classList.add("selected"); | |
| else noteEl.classList.remove("selected"); | |
| }); | |
| tlSelectedIndices = selected; | |
| } | |
| } | |
| function onMouseUp(e) { | |
| try { | |
| if (dragMode === "move") { | |
| // Apply all moved notes | |
| let anyMoved = false; | |
| dragOrigData.forEach(d => { | |
| d.el.classList.remove("dragging"); | |
| const newOnset = parseFloat(d.el.dataset.onset); | |
| if (Math.abs(newOnset - d.origOnset) > 0.001) { | |
| anyMoved = true; | |
| } | |
| }); | |
| if (anyMoved) { | |
| applyMultiTimeOffsetChange(dragOrigData.map(d => ({ | |
| idx: d.idx, newOnset: parseFloat(d.el.dataset.onset) | |
| })), measDuration, e.altKey); | |
| } | |
| dragOrigData = []; | |
| } else if (dragMode === "rubber") { | |
| if (rubberEl) { rubberEl.remove(); rubberEl = null; } | |
| // Final selection already set during mousemove | |
| // Select the first note in selection for main marker | |
| if (tlSelectedIndices.size > 0) { | |
| const first = [...tlSelectedIndices].sort((a, b) => a - b)[0]; | |
| selectNote(first); | |
| scrollMarkerIntoView(first); | |
| } | |
| } | |
| } finally { | |
| dragMode = null; | |
| } | |
| } | |
| // Chord hover highlight | |
| body.addEventListener("mouseenter", (e) => { | |
| const el = e.target.closest(".tl-note.chord-linked"); | |
| if (!el) return; | |
| const chordId = el.dataset.chordId; | |
| if (!chordId) return; | |
| body.querySelectorAll(`.tl-note[data-chord-id="${chordId}"]`).forEach( | |
| n => n.classList.add("chord-hover") | |
| ); | |
| }, true); | |
| body.addEventListener("mouseleave", (e) => { | |
| const el = e.target.closest(".tl-note.chord-linked"); | |
| if (!el) return; | |
| body.querySelectorAll(".tl-note.chord-hover").forEach( | |
| n => n.classList.remove("chord-hover") | |
| ); | |
| }, true); | |
| // Instant tooltip on TL note hover | |
| let tlTooltipEl = null; | |
| body.addEventListener("mouseenter", (e) => { | |
| const el = e.target.closest(".tl-note"); | |
| if (!el) return; | |
| const tip = el.dataset.tooltip; | |
| if (!tip) return; | |
| if (!tlTooltipEl) { | |
| tlTooltipEl = document.createElement("div"); | |
| tlTooltipEl.id = "tl-tooltip"; | |
| document.body.appendChild(tlTooltipEl); | |
| } | |
| tlTooltipEl.textContent = tip; | |
| tlTooltipEl.style.display = "block"; | |
| const rect = el.getBoundingClientRect(); | |
| const tipW = tlTooltipEl.offsetWidth; | |
| const tipH = tlTooltipEl.offsetHeight; | |
| let left = rect.left; | |
| let top = rect.top - tipH - 4; | |
| if (left + tipW > window.innerWidth) left = window.innerWidth - tipW - 4; | |
| if (left < 0) left = 4; | |
| if (top < 0) top = rect.bottom + 4; | |
| tlTooltipEl.style.left = left + "px"; | |
| tlTooltipEl.style.top = top + "px"; | |
| }, true); | |
| body.addEventListener("mouseleave", (e) => { | |
| const el = e.target.closest(".tl-note"); | |
| if (!el) return; | |
| if (tlTooltipEl) tlTooltipEl.style.display = "none"; | |
| }, true); | |
| body.addEventListener("mousedown", onMouseDown); | |
| document.addEventListener("mousemove", onMouseMove); | |
| document.addEventListener("mouseup", onMouseUp); | |
| _tlDragCleanup = () => { | |
| body.removeEventListener("mousedown", onMouseDown); | |
| document.removeEventListener("mousemove", onMouseMove); | |
| document.removeEventListener("mouseup", onMouseUp); | |
| }; | |
| } | |
| function _checkMeasureOverflow(noteIdx, measDuration) { | |
| const n = noteInfos[noteIdx]; | |
| const onset = n.onsetDiv || 0; | |
| const dur = n.durationDiv || 0; | |
| const end = onset + dur; | |
| if (end > measDuration + 0.001) { | |
| const needed = durationFloatToRational(end); | |
| const current = durationFloatToRational(measDuration); | |
| const msg = currentLang === "ko" | |
| ? `이 노트(onset ${durationFloatToRational(onset)} + dur ${n.durationRational || '?'})가 마디 범위(${current})를 초과합니다.\n\n마디 duration을 ${needed}(으)로 늘릴까요?` | |
| : `Note (onset ${durationFloatToRational(onset)} + dur ${n.durationRational || '?'}) exceeds measure duration (${current}).\n\nExtend measure to ${needed}?`; | |
| if (confirm(msg)) { | |
| // Update stack duration in omrData | |
| const pg = pages[currentPageIdx]; | |
| if (pg && pg.omrData) { | |
| const sys = pg.omrData.systems[n.systemIdx]; | |
| if (sys) { | |
| let globalBase = 1; | |
| for (let si = 0; si < n.systemIdx; si++) { | |
| globalBase += (pg.omrData.systems[si].stacks || []).length || 1; | |
| } | |
| const stackIdx = parseInt(n.measureNum) - globalBase; | |
| if (sys.stacks && sys.stacks[stackIdx]) { | |
| sys.stacks[stackIdx].duration = needed; | |
| } | |
| // Also update omrData measure duration | |
| const numStacks = (sys.stacks || []).length || 1; | |
| const mi = (n.partIndex || 0) * numStacks + stackIdx; | |
| if (sys.measures[mi]) { | |
| sys.measures[mi].duration = needed; | |
| } | |
| } | |
| } | |
| // Update systemsData measure | |
| const sysInfo = systemsData[n.systemIdx]; | |
| if (sysInfo) { | |
| const m = sysInfo.measures.find(m => String(m.number) === String(n.measureNum)); | |
| if (m) m.duration = needed; | |
| } | |
| } | |
| } | |
| } | |
| function autoAlignMeasure() { | |
| if (!timelinePanelMeasure) return; | |
| const { measureNum, systemIdx } = timelinePanelMeasure; | |
| const measNotes = noteInfos.filter(n => | |
| String(n.measureNum) === String(measureNum) && n.systemIdx === systemIdx | |
| ); | |
| if (measNotes.length === 0) return; | |
| pushUndo(); | |
| // Group by staff (ignore voice — Audiveris often mis-assigns voices) | |
| const staffMap = {}; | |
| for (const n of measNotes) { | |
| const s = n.staff || 1; | |
| if (!staffMap[s]) staffMap[s] = []; | |
| staffMap[s].push(n); | |
| } | |
| for (const s of Object.keys(staffMap)) { | |
| const sNotes = staffMap[s]; | |
| sNotes.sort((a, b) => (a.onsetDiv || 0) - (b.onsetDiv || 0)); | |
| // Detect true polyphony: voices overlap in time on same staff | |
| const voiceMap = {}; | |
| for (const n of sNotes) { | |
| const v = n.voice || 1; | |
| if (!voiceMap[v]) voiceMap[v] = []; | |
| voiceMap[v].push(n); | |
| } | |
| const voiceKeys = Object.keys(voiceMap); | |
| let truePolyphony = false; | |
| if (voiceKeys.length > 1) { | |
| for (let vi = 0; vi < voiceKeys.length && !truePolyphony; vi++) { | |
| for (let vj = vi + 1; vj < voiceKeys.length && !truePolyphony; vj++) { | |
| const notesA = voiceMap[voiceKeys[vi]]; | |
| const notesB = voiceMap[voiceKeys[vj]]; | |
| for (const a of notesA) { | |
| const aStart = a.onsetDiv || 0; | |
| const aDur = a.durationRational ? parseRational(a.durationRational) : (a.durationDiv || 0); | |
| const aEnd = aStart + aDur; | |
| for (const b of notesB) { | |
| const bStart = b.onsetDiv || 0; | |
| if (bStart >= aStart && bStart < aEnd - 0.001) { | |
| truePolyphony = true; break; | |
| } | |
| } | |
| if (truePolyphony) break; | |
| } | |
| } | |
| } | |
| } | |
| // Only merge voices if NOT true polyphony | |
| // Audiveris voice convention: staff1=1~4, staff2=5~8 — preserve staff base | |
| if (!truePolyphony) { | |
| const baseVoice = Math.min(...sNotes.map(n => n.voice || 1)); | |
| for (const n of sNotes) { | |
| if (n.voice !== baseVoice) { | |
| n.voice = baseVoice; | |
| if (n._omrBased && n.omrChordId) { | |
| const chord = findOmrChord(n); | |
| if (chord) chord.voice = baseVoice; | |
| } | |
| } | |
| } | |
| } | |
| // If true polyphony, align each voice independently | |
| const voiceGroups = truePolyphony ? voiceKeys.map(v => voiceMap[v]) : [sNotes]; | |
| for (const groupNotes of voiceGroups) { | |
| groupNotes.sort((a, b) => (a.onsetDiv || 0) - (b.onsetDiv || 0)); | |
| // Build clusters: same onset = one chord unit | |
| const clusters = []; | |
| let i = 0; | |
| while (i < groupNotes.length) { | |
| const onset = groupNotes[i].onsetDiv || 0; | |
| const cluster = [groupNotes[i]]; | |
| while (i + 1 < groupNotes.length && Math.abs((groupNotes[i + 1].onsetDiv || 0) - onset) < 0.001) { | |
| i++; | |
| cluster.push(groupNotes[i]); | |
| } | |
| if (cluster[0].tupletGroupId) { | |
| const gid = cluster[0].tupletGroupId; | |
| while (i + 1 < groupNotes.length && groupNotes[i + 1].tupletGroupId === gid) { | |
| i++; | |
| cluster.push(groupNotes[i]); | |
| } | |
| } | |
| const dur = Math.max(...cluster.map(n => { | |
| if (n.durationRational) return parseRational(n.durationRational); | |
| return n.durationDiv || 0; | |
| })); | |
| clusters.push({ notes: cluster, onset, dur }); | |
| i++; | |
| } | |
| // Compact: remove gaps | |
| let expectedOnset = 0; | |
| for (const cl of clusters) { | |
| if (cl.onset > expectedOnset + 0.0001) { | |
| const shift = cl.onset - expectedOnset; | |
| for (const n of cl.notes) { | |
| const newOnset = (n.onsetDiv || 0) - shift; | |
| n.onsetDiv = newOnset; | |
| if (n.durationRational) { | |
| n.timeOffsetRational = durationFloatToRational(newOnset); | |
| } | |
| if (n._omrBased && n.omrChordId) { | |
| const chord = findOmrChord(n); | |
| if (chord) chord.timeOffset = durationFloatToRational(newOnset); | |
| } | |
| markModified(n); | |
| } | |
| cl.onset = expectedOnset; | |
| } | |
| expectedOnset = cl.onset + cl.dur; | |
| } | |
| } | |
| } | |
| renderTimelinePanel(measureNum, systemIdx); | |
| _validateRhythmOmr(); | |
| } | |
| function applyTimeOffsetChange(noteIdx, newOnset, measDuration) { | |
| applyMultiTimeOffsetChange([{ idx: noteIdx, newOnset }], measDuration); | |
| } | |
| function applyMultiTimeOffsetChange(changes, measDuration, chordMode) { | |
| if (!changes || changes.length === 0) return; | |
| if (!isOmrMode()) return; | |
| pushUndo(); | |
| for (const { idx, newOnset } of changes) { | |
| if (idx < 0 || idx >= noteInfos.length) continue; | |
| const n = noteInfos[idx]; | |
| const chord = findOmrChord(n); | |
| if (!chord) { console.warn("[TL] chord not found for note", idx); continue; } | |
| const newRational = durationFloatToRational(newOnset); | |
| if (chordMode || !chord.heads || chord.heads.length <= 1) { | |
| // Alt+drag (chord mode), rest chord (no heads), or single-head: move whole chord | |
| chord.timeOffset = newRational; | |
| } else { | |
| // Normal drag: individual move — split head into new chord | |
| const head = chord.heads.find(h => h.headId === n.omrHeadId); | |
| if (head) { | |
| chord.heads = chord.heads.filter(h => h.headId !== n.omrHeadId); | |
| const newChordId = n.omrChordId + "_split_" + n.omrHeadId; | |
| const newChord = { | |
| chordId: newChordId, | |
| heads: [head], | |
| duration: chord.duration, | |
| timeOffset: newRational, | |
| voice: chord.voice, | |
| }; | |
| const pg = pages[currentPageIdx]; | |
| const sys = pg.omrData.systems[n.systemIdx]; | |
| const meas = sys.measures[n._omrMeasureIdx]; | |
| meas.headChords.push(newChord); | |
| n.omrChordId = newChordId; | |
| } | |
| } | |
| n.onsetDiv = newOnset; | |
| n.timeOffsetRational = newRational; | |
| recordOmrEdit({ | |
| type: "change_time_offset", | |
| chordId: n.omrChordId, | |
| newTimeOffset: newRational, | |
| }); | |
| } | |
| // Refresh display | |
| rebuildSystemsAndNotes(); | |
| const ux = parseInt(document.getElementById("offset-x").value) || 0; | |
| const uy = parseInt(document.getElementById("offset-y").value) || 0; | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| if (selectedIdx >= 0 && selectedIdx < noteInfos.length) selectNote(selectedIdx); | |
| // Check overflow for last changed note | |
| const lastChange = changes[changes.length - 1]; | |
| if (lastChange) _checkMeasureOverflow(lastChange.idx, measDuration); | |
| } | |
| // Hook: update timeline panel when note selection changes | |
| const _originalSelectNote = selectNote; | |
| selectNote = function(idx) { | |
| _originalSelectNote(idx); | |
| // Preview sound on note selection (click / keyboard nav) | |
| if (idx >= 0 && idx < noteInfos.length) { | |
| _previewNote(noteInfos[idx]); | |
| } | |
| if (timelinePanelVisible && idx >= 0 && idx < noteInfos.length) { | |
| const n = noteInfos[idx]; | |
| if (!timelinePanelMeasure || | |
| String(timelinePanelMeasure.measureNum) !== String(n.measureNum) || | |
| timelinePanelMeasure.systemIdx !== n.systemIdx) { | |
| renderTimelinePanel(n.measureNum, n.systemIdx); | |
| } else { | |
| // Respect multi-select: if tlSelectedIndices has items, show them all | |
| if (tlSelectedIndices.size > 1) { | |
| _tlUpdateSelectionVisuals(); | |
| } else { | |
| document.querySelectorAll("#timeline-body .tl-note.selected").forEach(el => el.classList.remove("selected")); | |
| const el = document.querySelector(`#timeline-body .tl-note[data-idx="${idx}"]`); | |
| if (el) el.classList.add("selected"); | |
| } | |
| } | |
| } | |
| }; | |
| // ═══════════════════════════════════════════════════════════════════ | |
| async function startPlayback(fromTimeSec) { | |
| if (noteInfos.length === 0) return; | |
| stopPlayback(); | |
| const bpm = parseInt(document.getElementById("bpm-input").value) || 120; | |
| playbackTimeline = buildTimeline(noteInfos, bpm); | |
| if (playbackTimeline.length === 0) return; | |
| playbackStartOffset = fromTimeSec || 0; | |
| audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| // Preload piano samples before starting | |
| await preloadPianoSamples(audioCtx); | |
| isPlaying = true; | |
| playbackStartTime = performance.now(); | |
| // Skip to the first event at or after the offset | |
| playbackEventIdx = 0; | |
| if (playbackStartOffset > 0) { | |
| while (playbackEventIdx < playbackTimeline.length && | |
| playbackTimeline[playbackEventIdx].timeSec < playbackStartOffset) { | |
| playbackEventIdx++; | |
| } | |
| } | |
| document.getElementById("btn-playall").textContent = "\u23F8"; // pause symbol | |
| schedulePlayback(); | |
| } | |
| function schedulePlayback() { | |
| if (!isPlaying || playbackEventIdx >= playbackTimeline.length) { | |
| if (isPlaying) updateProgressBar(1); // fill to end before stopping | |
| stopPlayback(); | |
| return; | |
| } | |
| const elapsed = (performance.now() - playbackStartTime) / 1000 + playbackStartOffset; | |
| // Update progress bar | |
| const totalDuration = getPlaybackTotalDuration(); | |
| if (totalDuration > 0) updateProgressBar(elapsed / totalDuration); | |
| // Schedule events that are due or slightly ahead (100ms lookahead) | |
| while (playbackEventIdx < playbackTimeline.length) { | |
| const evt = playbackTimeline[playbackEventIdx]; | |
| if (evt.timeSec > elapsed + 0.1) break; | |
| // Play all notes in this event (slight legato overlap for smooth connection) | |
| const startOffset = Math.max(0, evt.timeSec - elapsed); | |
| const legato = Math.min(evt.durationSec * 1.15, evt.durationSec + 0.06); | |
| evt.noteIndices.forEach(ni => { | |
| const n = noteInfos[ni]; | |
| const freq = noteToFreq(n.step, n.octave, n.alter); | |
| playNoteSound(freq, Math.min(legato, 2), startOffset, n.step, n.octave, n.alter); | |
| }); | |
| // Schedule visual highlight | |
| const highlightDelay = Math.max(0, (evt.timeSec - elapsed) * 1000); | |
| const indices = evt.noteIndices; | |
| setTimeout(() => { | |
| if (!isPlaying) return; | |
| highlightPlaybackNotes(indices); | |
| }, highlightDelay); | |
| playbackEventIdx++; | |
| } | |
| // Continue scheduling | |
| playbackTimer = requestAnimationFrame(schedulePlayback); | |
| } | |
| function highlightPlaybackNotes(indices) { | |
| // Remove previous playback highlights | |
| markerSvg.querySelectorAll(".playback-highlight").forEach(el => el.classList.remove("playback-highlight")); | |
| indices.forEach(idx => { | |
| const circle = markerSvg.querySelector(`circle[data-idx="${idx}"]`); | |
| if (circle) circle.classList.add("playback-highlight"); | |
| }); | |
| if (indices.length > 0) { | |
| // Pick note on lowest staff number for cursor position | |
| let cursorIdx = indices[0]; | |
| if (indices.length > 1) { | |
| let minStaff = Infinity; | |
| for (const ni of indices) { | |
| const s = noteInfos[ni]?.staff ?? Infinity; | |
| if (s < minStaff) { minStaff = s; cursorIdx = ni; } | |
| } | |
| } | |
| // Use pxDefault (default-x based, always monotonic) for cursor X | |
| const cursorNote = noteInfos[cursorIdx]; | |
| const cursorX = cursorNote.pxDefault ?? cursorNote.px; | |
| // Place cursor line at pxDefault position | |
| const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1; | |
| const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys); | |
| const sysStaves = staffSystems[cursorNote.systemIdx]; | |
| const uy = parseInt(document.getElementById("offset-y").value) || 0; | |
| let topY, bottomY; | |
| if (sysStaves && sysStaves.length > 0) { | |
| topY = sysStaves[0].topLineY - 20 + uy; | |
| bottomY = sysStaves[sysStaves.length - 1].bottomLineY + 20 + uy; | |
| } else { | |
| topY = cursorNote.py - 80; | |
| bottomY = cursorNote.py + 80; | |
| } | |
| showPlaybackCursor(cursorX, topY, bottomY); | |
| selectNote(cursorIdx); | |
| // Auto-scroll to follow cursor | |
| const wrapper = document.getElementById("canvas-wrapper"); | |
| const cx = cursorX * currentZoom; | |
| const wRect = wrapper.getBoundingClientRect(); | |
| if (cx < wrapper.scrollLeft + 100 || cx > wrapper.scrollLeft + wRect.width - 100) { | |
| wrapper.scrollTo({ left: cx - wRect.width / 3, behavior: "smooth" }); | |
| } | |
| if (sysStaves && sysStaves.length > 0) { | |
| const sysTopZoomed = sysStaves[0].topLineY * currentZoom; | |
| if (sysTopZoomed < wrapper.scrollTop + 50 || sysTopZoomed > wrapper.scrollTop + wRect.height - 100) { | |
| wrapper.scrollTo({ top: sysTopZoomed - 50, behavior: "smooth" }); | |
| } | |
| } | |
| } | |
| } | |
| function stopPlayback() { | |
| isPlaying = false; | |
| playbackStartOffset = 0; | |
| if (playbackTimer) { | |
| cancelAnimationFrame(playbackTimer); | |
| playbackTimer = null; | |
| } | |
| if (audioCtx) { | |
| audioCtx.close().catch(() => {}); | |
| audioCtx = null; | |
| } | |
| markerSvg.querySelectorAll(".playback-highlight").forEach(el => el.classList.remove("playback-highlight")); | |
| // Don't hide cursor — keep it at last position so user can see where playback stopped | |
| document.getElementById("btn-playall").textContent = "\u25B6"; // play symbol | |
| } | |
| function togglePlayback() { | |
| if (isPlaying) stopPlayback(); | |
| else startPlayback(cursorSeekTime); | |
| } | |
| /** Get total duration of the playback timeline in seconds */ | |
| function getPlaybackTotalDuration() { | |
| if (playbackTimeline.length === 0) return 0; | |
| const last = playbackTimeline[playbackTimeline.length - 1]; | |
| return last.timeSec + last.durationSec; | |
| } | |
| /** Update the visual progress bar */ | |
| function updateProgressBar(fraction) { | |
| const fill = document.getElementById("progress-bar-fill"); | |
| const timeLabel = document.getElementById("progress-time"); | |
| const clamped = Math.max(0, Math.min(1, fraction)); | |
| fill.style.width = (clamped * 100) + "%"; | |
| const total = getPlaybackTotalDuration(); | |
| const current = clamped * total; | |
| timeLabel.textContent = formatTime(current) + " / " + formatTime(total); | |
| } | |
| function formatTime(sec) { | |
| const m = Math.floor(sec / 60); | |
| const s = Math.floor(sec % 60); | |
| return m + ":" + (s < 10 ? "0" : "") + s; | |
| } | |
| /** Seek: start playback from a specific fraction (0-1) of the timeline */ | |
| function seekPlayback(fraction) { | |
| const bpm = parseInt(document.getElementById("bpm-input").value) || 120; | |
| // Build timeline to know total duration | |
| const tl = buildTimeline(noteInfos, bpm); | |
| if (tl.length === 0) return; | |
| const totalDur = tl[tl.length - 1].timeSec + tl[tl.length - 1].durationSec; | |
| const seekTime = fraction * totalDur; | |
| startPlayback(seekTime); | |
| } | |
| /** Find the closest note time to a click position on the score image */ | |
| function findTimeAtClick(clickX, clickY) { | |
| if (noteInfos.length === 0 || playbackTimeline.length === 0) return 0; | |
| const closestIdx = findNearestNote(clickX, clickY); | |
| if (closestIdx < 0) return 0; | |
| for (const evt of playbackTimeline) { | |
| if (evt.noteIndices.includes(closestIdx)) return evt.timeSec; | |
| } | |
| return 0; | |
| } | |
| // ── Progress bar click to seek ── | |
| document.getElementById("progress-bar-container").addEventListener("click", (e) => { | |
| const rect = e.currentTarget.getBoundingClientRect(); | |
| const fraction = (e.clientX - rect.left) / rect.width; | |
| seekPlayback(Math.max(0, Math.min(1, fraction))); | |
| }); | |
| // ── Double-click on score to seek playback ── | |
| // ── Shift+click on score to place cursor and set seek position ── | |
| document.getElementById("canvas-wrapper").addEventListener("click", (e) => { | |
| if (!e.shiftKey) return; | |
| if (e.target.closest("circle.marker")) return; | |
| if (noteInfos.length === 0) return; | |
| const container = document.getElementById("canvas-container"); | |
| const containerRect = container.getBoundingClientRect(); | |
| const clickX = (e.clientX - containerRect.left) / currentZoom; | |
| const clickY = (e.clientY - containerRect.top) / currentZoom; | |
| const nearestIdx = findNearestNote(clickX, clickY); | |
| if (nearestIdx < 0) return; | |
| // Place cursor visually | |
| placeCursorAtNote(nearestIdx); | |
| // Store seek time so next play starts from here | |
| cursorSeekTime = getTimeForNoteIdx(nearestIdx); | |
| // Update progress bar to show seek position | |
| const bpm = parseInt(document.getElementById("bpm-input").value) || 120; | |
| const tl = buildTimeline(noteInfos, bpm); | |
| if (tl.length > 0) { | |
| const totalDur = tl[tl.length - 1].timeSec + tl[tl.length - 1].durationSec; | |
| updateProgressBar(cursorSeekTime / totalDur); | |
| } | |
| // If currently playing, restart from new position | |
| if (isPlaying) startPlayback(cursorSeekTime); | |
| }); | |
| // ── Double-click on score to seek and immediately play ── | |
| document.getElementById("canvas-wrapper").addEventListener("dblclick", (e) => { | |
| // Only seek if clicking on empty area (not on a marker) | |
| if (e.target.closest("circle.marker")) return; | |
| const container = document.getElementById("canvas-container"); | |
| const containerRect = container.getBoundingClientRect(); | |
| const clickX = (e.clientX - containerRect.left) / currentZoom; | |
| const clickY = (e.clientY - containerRect.top) / currentZoom; | |
| // Need a timeline — build one if not playing | |
| const bpm = parseInt(document.getElementById("bpm-input").value) || 120; | |
| playbackTimeline = buildTimeline(noteInfos, bpm); | |
| if (playbackTimeline.length === 0) return; | |
| const seekTime = findTimeAtClick(clickX, clickY); | |
| startPlayback(seekTime); | |
| }); | |
| // Play single note button | |
| document.getElementById("btn-play").addEventListener("click", async () => { | |
| if (selectedIdx < 0) return; | |
| const n = noteInfos[selectedIdx]; | |
| if (n.isRest) return; | |
| const freq = noteToFreq(n.step, n.octave, n.alter); | |
| const ctx = new (window.AudioContext || window.webkitAudioContext)(); | |
| const name = sfName(n.step, n.octave, n.alter); | |
| // Load sample if not cached | |
| if (!pianoCache[name]) await loadPianoNote(ctx, name); | |
| const buf = pianoCache[name]; | |
| if (buf) { | |
| const src = ctx.createBufferSource(); | |
| src.buffer = buf; | |
| const gain = ctx.createGain(); | |
| src.connect(gain); | |
| gain.connect(ctx.destination); | |
| gain.gain.setValueAtTime(masterVolume, ctx.currentTime); | |
| gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.0); | |
| src.start(); | |
| src.stop(ctx.currentTime + 1.0); | |
| } else { | |
| // Fallback sine | |
| const osc = ctx.createOscillator(); | |
| const gain = ctx.createGain(); | |
| osc.type = "sine"; | |
| osc.frequency.value = freq; | |
| osc.connect(gain); | |
| gain.connect(ctx.destination); | |
| gain.gain.setValueAtTime(0.001, ctx.currentTime); | |
| gain.gain.linearRampToValueAtTime(masterVolume * 0.32, ctx.currentTime + 0.02); | |
| gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5); | |
| osc.start(); | |
| osc.stop(ctx.currentTime + 0.5); | |
| } | |
| }); | |
| // Play all / Stop buttons | |
| document.getElementById("btn-playall").addEventListener("click", togglePlayback); | |
| document.getElementById("btn-stop").addEventListener("click", stopPlayback); | |
| // Debug export button | |
| document.getElementById("btn-debug-export").addEventListener("click", () => { | |
| const debugText = window._lastTimelineDebug; | |
| if (!debugText) { | |
| alert("No debug data yet. Press Play first to generate timeline debug info."); | |
| return; | |
| } | |
| const blob = new Blob([debugText], { type: "text/plain" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = `timeline_debug_${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}.txt`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| }); | |
| // Timeline panel button + close | |
| document.getElementById("btn-timeline").addEventListener("click", toggleTimelinePanel); | |
| document.getElementById("timeline-close").addEventListener("click", () => { | |
| timelinePanelVisible = false; | |
| document.getElementById("timeline-panel").style.display = "none"; | |
| }); | |
| document.getElementById("timeline-grid-select").addEventListener("change", () => { | |
| if (timelinePanelVisible && timelinePanelMeasure) { | |
| renderTimelinePanel(timelinePanelMeasure.measureNum, timelinePanelMeasure.systemIdx); | |
| } | |
| }); | |
| // Drag support | |
| let dragIdx = -1; | |
| let dragStartY = 0; | |
| let dragStartX = 0; | |
| let dragOrigStaffPos = 0; | |
| let dragIsXMode = false; // Alt+drag = X-axis anchor mode | |
| // ── X-Anchor system: user-placed control points for X-axis warping ── | |
| let xAnchors = []; // [{systemIdx, tenthsX, pixelX}] | |
| // Score rubber-band: listen on canvas-wrapper since markerSvg has pointer-events:none on empty space | |
| document.getElementById("canvas-wrapper").addEventListener("mousedown", (e) => { | |
| if (staffAdjustMode || barlineMode || addMode) return; | |
| // Only trigger on empty space (not on markers or other interactive elements) | |
| if (e.target.closest("circle.marker") || e.target.closest(".staff-overlay") || e.target.closest(".barline-handle")) return; | |
| if (e.button !== 0) return; | |
| if (!e.ctrlKey && !e.metaKey) { | |
| scoreSelectedIndices.clear(); | |
| _scoreUpdateSelectionVisuals(); | |
| } | |
| _scoreRubberStart(e); | |
| }); | |
| markerSvg.addEventListener("mousedown", (e) => { | |
| if (staffAdjustMode || barlineMode || addMode) return; | |
| const circle = e.target.closest("circle.marker"); | |
| if (!circle) return; | |
| const tmpIdx = parseInt(circle.dataset.idx); | |
| const tmpN = noteInfos[tmpIdx]; | |
| if (tmpN && tmpN.isRest) { selectNote(tmpIdx); return; } | |
| // Ctrl+Click: multi-select toggle (no drag) | |
| if (e.ctrlKey || e.metaKey) { | |
| if (scoreSelectedIndices.has(tmpIdx)) { | |
| scoreSelectedIndices.delete(tmpIdx); | |
| if (selectedIdx === tmpIdx) selectedIdx = scoreSelectedIndices.size > 0 ? [...scoreSelectedIndices][0] : -1; | |
| } else { | |
| scoreSelectedIndices.add(tmpIdx); | |
| selectedIdx = tmpIdx; | |
| } | |
| selectNote(selectedIdx); | |
| _scoreUpdateSelectionVisuals(); | |
| _scoreCtrlHandled = true; | |
| e.preventDefault(); | |
| return; | |
| } | |
| dragIdx = tmpIdx; | |
| dragStartY = e.clientY; | |
| dragStartX = e.clientX; | |
| dragIsXMode = e.altKey; | |
| if (dragIsXMode) { | |
| // X-anchor mode: visual feedback | |
| document.body.style.cursor = "ew-resize"; | |
| document.getElementById("status-mode").textContent = "X-ANCHOR"; | |
| } else { | |
| pushUndo(); // save state before pitch drag begins | |
| } | |
| const n = noteInfos[dragIdx]; | |
| const ref = clefReferencePosition(n.clef); | |
| dragOrigStaffPos = ref.staffPosition + (diatonicIndex(n.step, n.octave) - ref.diatonicIdx); | |
| scoreSelectOnly(dragIdx); | |
| e.preventDefault(); | |
| }); | |
| document.addEventListener("mousemove", (e) => { | |
| if (dragIdx < 0) return; | |
| try { | |
| const n = noteInfos[dragIdx]; | |
| if (!n) { dragIdx = -1; return; } | |
| if (dragIsXMode || e.altKey) { | |
| // ── X-axis anchor drag: move note horizontally, warp system ── | |
| if (!dragIsXMode) { | |
| document.body.style.cursor = "ew-resize"; | |
| document.getElementById("status-mode").textContent = "X-ANCHOR"; | |
| } | |
| dragIsXMode = true; | |
| const dx = (e.clientX - dragStartX) / currentZoom; | |
| if (Math.abs(dx) < 1) return; | |
| const newPixelX = n.px + dx; | |
| dragStartX = e.clientX; // incremental | |
| // Update/add anchor for this note's tenths position | |
| const tenthsX = layout.marginL + n.systemLeftMargin + n.measureStartX + n.defaultX; | |
| const ux = parseFloat(offsetX.value || 0); | |
| addOrUpdateAnchor(n.systemIdx, tenthsX, newPixelX - ux); | |
| // Real-time recompute all notes in this system | |
| recomputeWithAnchorsRealtime(); | |
| return; | |
| } | |
| // ── Y-axis pitch drag (existing) ── | |
| const dy = (dragStartY - e.clientY) / currentZoom; | |
| const stepsPerPixel = 1 / (5 * pixelsPerTenth); | |
| const stepsMoved = Math.round(dy * stepsPerPixel); | |
| if (stepsMoved === 0) return; | |
| const newStaffPos = dragOrigStaffPos + stepsMoved; | |
| const ref = clefReferencePosition(n.clef); | |
| const newDiatonic = ref.diatonicIdx + (newStaffPos - ref.staffPosition); | |
| const newOctave = Math.floor(newDiatonic / 7); | |
| const newStepIdx = ((newDiatonic % 7) + 7) % 7; | |
| n.step = STEPS[newStepIdx]; | |
| n.octave = newOctave; | |
| n.alter = keyAlterForStep(n.step, n.fifths); | |
| if (isOmrMode() && n._omrBased) { | |
| const chord = findOmrChord(n); | |
| const head = chord ? findOmrNoteInChord(chord, n) : null; | |
| if (head) { | |
| head.pitch = stepOctaveToOmrPitch(n.step, n.octave, n.clef.sign); | |
| head.alter = n.alter; | |
| head.hasAccidental = (n.alter !== keyAlterForStep(n.step, n.fifths)); | |
| } else { | |
| console.warn("[drag] findOmrChord/head failed:", n.omrChordId, n.omrHeadId, n._omrMeasureIdx); | |
| } | |
| markModified(n); | |
| applyPitchToXmlByHeadId(n); | |
| } else { | |
| applyPitchToXml(n); | |
| applyAlterOnly(n); | |
| } | |
| recomputeAndUpdate(dragIdx); | |
| _previewNoteDrag(n); | |
| } catch (ex) { | |
| console.error("[drag-error]", ex); | |
| dragIdx = -1; | |
| } | |
| }); | |
| document.addEventListener("mouseup", () => { | |
| if (dragIdx >= 0 && !dragIsXMode) { | |
| const n = noteInfos[dragIdx]; | |
| if (n && n.omrHeadId) { | |
| recordOmrEdit({ type: "change_pitch", headId: n.omrHeadId, | |
| newPitch: stepOctaveToOmrPitch(n.step, n.octave, n.clef.sign) }); | |
| } | |
| _previewNote(n); | |
| } | |
| if (dragIsXMode) { | |
| document.body.style.cursor = ""; | |
| document.getElementById("status-mode").textContent = ""; | |
| } | |
| dragIdx = -1; dragIsXMode = false; | |
| }); | |
| function addOrUpdateAnchor(systemIdx, tenthsX, pixelX) { | |
| // Replace if anchor already exists near this tenths position | |
| const threshold = 5; // tenths | |
| const existing = xAnchors.find(a => a.systemIdx === systemIdx && Math.abs(a.tenthsX - tenthsX) < threshold); | |
| if (existing) { | |
| existing.pixelX = pixelX; | |
| } else { | |
| xAnchors.push({ systemIdx, tenthsX, pixelX }); | |
| } | |
| } | |
| function recomputeWithAnchorsRealtime() { | |
| const ux = parseFloat(offsetX.value || 0); | |
| const uy = parseFloat(offsetY.value || 0); | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| // Fast SVG update: just move circles, don't rebuild DOM | |
| noteInfos.forEach((n, idx) => { | |
| const circle = markerSvg.querySelector(`circle.marker[data-idx="${idx}"]`); | |
| if (circle) { | |
| circle.setAttribute("cx", n.px); | |
| // Don't update cy — only X changed | |
| } | |
| const accLabel = markerSvg.querySelector(`text.acc-label[data-idx="${idx}"]`); | |
| if (accLabel) { | |
| accLabel.setAttribute("x", n.px + 8); | |
| } | |
| }); | |
| } | |
| function clearAnchors(systemIdx) { | |
| if (systemIdx !== undefined) { | |
| xAnchors = xAnchors.filter(a => a.systemIdx !== systemIdx); | |
| } else { | |
| xAnchors = []; | |
| } | |
| } | |
| // ── Manual Staff Position Adjustment (Drag Mode) ── | |
| let staffAdjustMode = false; | |
| let staffAdjustOriginal = null; | |
| let staffDragIdx = -1; | |
| let staffDragStartX = 0; | |
| let staffDragStartY = 0; | |
| let staffDragShift = false; | |
| let staffDragOrigData = null; // snapshot of the staff being dragged | |
| let staffDragDebugLines = []; // debug-line elements for dragged staff (horizontal staff lines) | |
| let staffDragMarkers = []; // {circle, origCx, origCy} for dragged staff's markers | |
| let staffDragBarlines = []; // {el, origX1, origX2?, origX?} for system barlines | |
| function enterStaffAdjustMode() { | |
| if (!detectedStaves || detectedStaves.length === 0) { | |
| console.warn("No staves to adjust"); | |
| return; | |
| } | |
| staffAdjustMode = true; | |
| staffAdjustOriginal = JSON.parse(JSON.stringify(detectedStaves)); | |
| // Show staff lines if not already visible | |
| if (!debugLinesVisible) toggleDebugLines(); | |
| // Render draggable staff overlays | |
| renderStaffOverlays(); | |
| // Update button style | |
| const btn = document.getElementById("btn-adjust-staves"); | |
| btn.style.background = "#ff6644"; | |
| btn.style.color = "#fff"; | |
| btn.title = "Click to exit staff adjustment mode (Esc)"; | |
| // Show status | |
| const modeSpan = document.getElementById("status-mode"); | |
| modeSpan.textContent = t("staff_adjust_mode"); | |
| console.log("Staff adjust mode ON — drag to move staves (Shift+drag for horizontal)"); | |
| } | |
| function exitStaffAdjustMode(applyChanges) { | |
| staffAdjustMode = false; | |
| staffDragIdx = -1; | |
| // Remove overlays | |
| markerSvg.querySelectorAll(".staff-overlay").forEach(el => el.remove()); | |
| // Reset button style | |
| const btn = document.getElementById("btn-adjust-staves"); | |
| btn.style.background = ""; | |
| btn.style.color = ""; | |
| btn.title = "Manually adjust each staff position"; | |
| const modeSpan = document.getElementById("status-mode"); | |
| if (modeSpan.textContent === t("staff_adjust_mode")) modeSpan.textContent = ""; | |
| if (applyChanges) { | |
| // Recompute with current (dragged) stave positions | |
| recomputeAfterStaffAdjust(); | |
| } else if (staffAdjustOriginal) { | |
| // Revert to original | |
| detectedStaves = JSON.parse(JSON.stringify(staffAdjustOriginal)); | |
| recomputeAfterStaffAdjust(); | |
| } | |
| // Refresh debug lines to show final positions | |
| markerSvg.querySelectorAll(".debug-line").forEach(el => el.remove()); | |
| if (debugLinesVisible) { | |
| debugLinesVisible = false; | |
| toggleDebugLines(); | |
| } | |
| staffAdjustOriginal = null; | |
| console.log("Staff adjust mode OFF"); | |
| } | |
| function recomputeAfterStaffAdjust() { | |
| detectedStaves.sort((a, b) => a.topLineY - b.topLineY); | |
| const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1; | |
| reassignMeasuresToSystems(systemsData, detectedStaves, numStavesPerSys); | |
| const ux = parseFloat(offsetX.value || 0); | |
| const uy = parseFloat(offsetY.value || 0); | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| } | |
| function renderStaffOverlays() { | |
| // Remove old overlays | |
| markerSvg.querySelectorAll(".staff-overlay").forEach(el => el.remove()); | |
| const uy = parseFloat(offsetY.value || 0); | |
| const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1; | |
| detectedStaves.forEach((staff, idx) => { | |
| const sysIdx = Math.floor(idx / numStavesPerSys); | |
| const staffInSys = idx % numStavesPerSys; | |
| const pad = 15; // padding above/below staff lines for easier grabbing | |
| const y = staff.topLineY + uy - pad; | |
| const h = (staff.bottomLineY - staff.topLineY) + pad * 2; | |
| const x = staff.leftX; | |
| const w = staff.rightX - staff.leftX; | |
| const color = idx % 2 === 0 ? "rgba(0,200,0,0.12)" : "rgba(255,140,0,0.12)"; | |
| const borderColor = idx % 2 === 0 ? "rgba(0,255,0,0.6)" : "rgba(255,165,0,0.6)"; | |
| const rect = document.createElementNS(SVG_NS, "rect"); | |
| rect.setAttribute("x", x); | |
| rect.setAttribute("y", y); | |
| rect.setAttribute("width", w); | |
| rect.setAttribute("height", h); | |
| rect.setAttribute("fill", color); | |
| rect.setAttribute("stroke", borderColor); | |
| rect.setAttribute("stroke-width", "1.5"); | |
| rect.setAttribute("rx", "3"); | |
| rect.classList.add("staff-overlay"); | |
| rect.dataset.staffIdx = idx; | |
| rect.dataset.origX = x; | |
| rect.dataset.origY = y; | |
| rect.dataset.origW = w; | |
| rect.dataset.origH = h; | |
| rect.style.cursor = "grab"; | |
| rect.style.pointerEvents = "all"; | |
| rect.style.transition = "x 0.1s, y 0.1s, width 0.1s, height 0.1s, fill 0.1s, stroke-width 0.1s"; | |
| // Hover: expand rect | |
| const hoverPad = 8; | |
| const hoverColor = idx % 2 === 0 ? "rgba(0,200,0,0.22)" : "rgba(255,140,0,0.22)"; | |
| rect.addEventListener("mouseenter", () => { | |
| if (staffDragIdx >= 0) return; // don't expand while dragging | |
| rect.setAttribute("x", parseFloat(rect.dataset.origX) - hoverPad); | |
| rect.setAttribute("y", parseFloat(rect.dataset.origY) - hoverPad); | |
| rect.setAttribute("width", parseFloat(rect.dataset.origW) + hoverPad * 2); | |
| rect.setAttribute("height", parseFloat(rect.dataset.origH) + hoverPad * 2); | |
| rect.setAttribute("fill", hoverColor); | |
| rect.setAttribute("stroke-width", "2.5"); | |
| }); | |
| rect.addEventListener("mouseleave", () => { | |
| rect.setAttribute("x", rect.dataset.origX); | |
| rect.setAttribute("y", rect.dataset.origY); | |
| rect.setAttribute("width", rect.dataset.origW); | |
| rect.setAttribute("height", rect.dataset.origH); | |
| rect.setAttribute("fill", color); | |
| rect.setAttribute("stroke-width", "1.5"); | |
| }); | |
| // Label | |
| const interpTag = staff.interpolated ? "*" : ""; | |
| const label = document.createElementNS(SVG_NS, "text"); | |
| label.setAttribute("x", x + 4); | |
| label.setAttribute("y", y + 12); | |
| label.setAttribute("fill", borderColor); | |
| label.setAttribute("font-size", "11"); | |
| label.setAttribute("font-weight", "bold"); | |
| label.classList.add("staff-overlay"); | |
| label.style.pointerEvents = "none"; | |
| label.textContent = `S${sysIdx+1}-${staffInSys+1}${interpTag}`; | |
| markerSvg.appendChild(rect); | |
| markerSvg.appendChild(label); | |
| }); | |
| } | |
| // Staff overlay drag handlers (attached to markerSvg) | |
| markerSvg.addEventListener("mousedown", (e) => { | |
| if (!staffAdjustMode) return; | |
| const overlay = e.target.closest(".staff-overlay"); | |
| if (!overlay || overlay.tagName !== "rect") return; | |
| staffDragIdx = parseInt(overlay.dataset.staffIdx); | |
| staffDragShift = e.shiftKey; | |
| staffDragOrigData = JSON.parse(JSON.stringify(detectedStaves[staffDragIdx])); | |
| // Shrink back to original size on drag start, disable transition for smooth drag | |
| overlay.style.transition = "none"; | |
| overlay.setAttribute("x", overlay.dataset.origX); | |
| overlay.setAttribute("y", overlay.dataset.origY); | |
| overlay.setAttribute("width", overlay.dataset.origW); | |
| overlay.setAttribute("height", overlay.dataset.origH); | |
| overlay.setAttribute("stroke-width", "1.5"); | |
| staffDragStartX = e.clientX; | |
| staffDragStartY = e.clientY; | |
| // Collect debug lines belonging to this staff | |
| staffDragDebugLines = Array.from( | |
| markerSvg.querySelectorAll(`.debug-line[data-staff-idx="${staffDragIdx}"]`) | |
| ).map(el => { | |
| const tag = el.tagName; | |
| if (tag === "line") { | |
| return { el, origY1: parseFloat(el.getAttribute("y1")), origY2: parseFloat(el.getAttribute("y2")) }; | |
| } else { | |
| return { el, origY: parseFloat(el.getAttribute("y")) }; | |
| } | |
| }); | |
| // Collect note markers belonging to this staff | |
| // Determine which system/staff this detectedStaves index maps to | |
| const numSPS = (systemsData.length > 0) ? systemsData[0].numStaves : 1; | |
| const dragSysIdx = Math.floor(staffDragIdx / numSPS); | |
| const dragStaffInSys = (staffDragIdx % numSPS) + 1; // 1-based staff number | |
| staffDragMarkers = []; | |
| markerSvg.querySelectorAll("circle.marker").forEach(circle => { | |
| const idx = parseInt(circle.dataset.idx); | |
| const n = noteInfos[idx]; | |
| if (n && n.systemIdx === dragSysIdx && n.staff === dragStaffInSys) { | |
| const accLabel = markerSvg.querySelector(`text[data-idx="${idx}"]`); | |
| staffDragMarkers.push({ | |
| circle, | |
| origCx: parseFloat(circle.getAttribute("cx")), | |
| origCy: parseFloat(circle.getAttribute("cy")), | |
| label: accLabel, | |
| origLabelX: accLabel ? parseFloat(accLabel.getAttribute("x")) : 0, | |
| origLabelY: accLabel ? parseFloat(accLabel.getAttribute("y")) : 0 | |
| }); | |
| } | |
| }); | |
| // Collect barlines (vertical debug lines) for this system | |
| staffDragBarlines = Array.from( | |
| markerSvg.querySelectorAll(`.debug-line[data-sys-idx="${dragSysIdx}"]`) | |
| ).map(el => { | |
| if (el.tagName === "line") { | |
| return { el, origX1: parseFloat(el.getAttribute("x1")), origX2: parseFloat(el.getAttribute("x2")), | |
| origY1: parseFloat(el.getAttribute("y1")), origY2: parseFloat(el.getAttribute("y2")) }; | |
| } else { | |
| return { el, origX: parseFloat(el.getAttribute("x")), origY: parseFloat(el.getAttribute("y")) }; | |
| } | |
| }); | |
| overlay.style.cursor = "grabbing"; | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| }, true); // capture phase to intercept before note drag | |
| document.addEventListener("mousemove", (e) => { | |
| if (staffDragIdx < 0 || !staffAdjustMode) return; | |
| const dx = (e.clientX - staffDragStartX) / currentZoom; | |
| const dy = (e.clientY - staffDragStartY) / currentZoom; | |
| const staff = detectedStaves[staffDragIdx]; | |
| const orig = staffDragOrigData; | |
| const uy = parseFloat(offsetY.value || 0); | |
| if (e.shiftKey) { | |
| staff.leftX = Math.round(orig.leftX + dx); | |
| staff.rightX = Math.round(orig.rightX + dx); | |
| } else { | |
| staff.topLineY = Math.round(orig.topLineY + dy); | |
| staff.bottomLineY = Math.round(orig.bottomLineY + dy); | |
| staff.lines = orig.lines.map(y => Math.round(y + dy)); | |
| } | |
| // Move only the dragged overlay rect + its label (no full re-render) | |
| const rect = markerSvg.querySelector(`.staff-overlay[data-staff-idx="${staffDragIdx}"]`); | |
| if (rect) { | |
| const pad = 15; | |
| const newX = staff.leftX; | |
| const newY = staff.topLineY + uy - pad; | |
| const newW = staff.rightX - staff.leftX; | |
| const newH = (staff.bottomLineY - staff.topLineY) + pad * 2; | |
| rect.setAttribute("x", newX); | |
| rect.setAttribute("y", newY); | |
| rect.setAttribute("width", newW); | |
| rect.setAttribute("height", newH); | |
| rect.dataset.origX = newX; | |
| rect.dataset.origY = newY; | |
| rect.dataset.origW = newW; | |
| rect.dataset.origH = newH; | |
| // Move the overlay label (next sibling text element) | |
| const label = rect.nextElementSibling; | |
| if (label && label.tagName === "text") { | |
| label.setAttribute("x", newX + 4); | |
| label.setAttribute("y", newY + 12); | |
| } | |
| } | |
| // Move debug lines (staff lines) for this staff | |
| staffDragDebugLines.forEach(item => { | |
| if (item.el.tagName === "line") { | |
| if (e.shiftKey) { | |
| // Horizontal mode: don't move lines vertically | |
| } else { | |
| item.el.setAttribute("y1", item.origY1 + dy); | |
| item.el.setAttribute("y2", item.origY2 + dy); | |
| } | |
| } else { | |
| // text label | |
| if (!e.shiftKey) { | |
| item.el.setAttribute("y", item.origY + dy); | |
| } | |
| } | |
| }); | |
| // Move note markers for this staff | |
| staffDragMarkers.forEach(item => { | |
| if (e.shiftKey) { | |
| item.circle.setAttribute("cx", item.origCx + dx); | |
| if (item.label) item.label.setAttribute("x", item.origLabelX + dx); | |
| } else { | |
| item.circle.setAttribute("cy", item.origCy + dy); | |
| if (item.label) item.label.setAttribute("y", item.origLabelY + dy); | |
| } | |
| }); | |
| // Move barlines (vertical debug lines) for this system | |
| staffDragBarlines.forEach(item => { | |
| if (item.el.tagName === "line") { | |
| if (e.shiftKey) { | |
| item.el.setAttribute("x1", item.origX1 + dx); | |
| item.el.setAttribute("x2", item.origX2 + dx); | |
| } else { | |
| item.el.setAttribute("y1", item.origY1 + dy); | |
| item.el.setAttribute("y2", item.origY2 + dy); | |
| } | |
| } else { | |
| // measure number text | |
| if (e.shiftKey) { | |
| item.el.setAttribute("x", item.origX + dx); | |
| } else { | |
| item.el.setAttribute("y", item.origY + dy); | |
| } | |
| } | |
| }); | |
| }); | |
| document.addEventListener("mouseup", (e) => { | |
| if (staffDragIdx < 0 || !staffAdjustMode) return; | |
| // Compute delta before clearing state — needed to update omrY/omrX | |
| const dy = (e.clientY - staffDragStartY) / currentZoom; | |
| const dx = (e.clientX - staffDragStartX) / currentZoom; | |
| const wasShift = staffDragShift; | |
| const draggedIdx = staffDragIdx; | |
| const numSPS = (systemsData.length > 0) ? systemsData[0].numStaves : 1; | |
| const dragSysIdx = Math.floor(draggedIdx / numSPS); | |
| const dragStaffInSys = (draggedIdx % numSPS) + 1; | |
| // Update omrY/omrX for notes on the dragged staff so they don't snap back | |
| noteInfos.forEach(n => { | |
| if (n.systemIdx === dragSysIdx && n.staff === dragStaffInSys) { | |
| if (wasShift) { | |
| if (n.omrX != null) n.omrX += dx; | |
| } else { | |
| if (n.omrY != null) n.omrY += dy; | |
| } | |
| } | |
| }); | |
| staffDragIdx = -1; | |
| staffDragOrigData = null; | |
| staffDragDebugLines = []; | |
| staffDragMarkers = []; | |
| staffDragBarlines = []; | |
| // Recompute marker positions with updated staff data | |
| recomputeAfterStaffAdjust(); | |
| // Full refresh of debug lines + overlays after drag ends | |
| markerSvg.querySelectorAll(".debug-line").forEach(el => el.remove()); | |
| if (debugLinesVisible) { | |
| debugLinesVisible = false; | |
| toggleDebugLines(); | |
| } | |
| renderStaffOverlays(); | |
| }); | |
| document.getElementById("btn-adjust-staves").addEventListener("click", () => { | |
| if (staffAdjustMode) { | |
| exitStaffAdjustMode(true); // apply changes on toggle off | |
| } else { | |
| enterStaffAdjustMode(); | |
| } | |
| }); | |
| // Esc exits staff adjust mode without applying | |
| document.addEventListener("keydown", (e) => { | |
| if (e.key === "Escape" && staffAdjustMode) { | |
| exitStaffAdjustMode(false); // revert | |
| e.preventDefault(); | |
| } | |
| }); | |
| // ── Barline Show/Edit button handlers ── | |
| document.getElementById("btn-show-barlines").addEventListener("click", toggleBarlineOverlays); | |
| document.getElementById("btn-show-free-glyphs").addEventListener("click", toggleFreeGlyphs); | |
| document.getElementById("btn-barline-mode").addEventListener("click", () => { | |
| if (barlineMode) exitBarlineMode(true); | |
| else enterBarlineMode(); | |
| }); | |
| // Barline toolbar buttons | |
| document.getElementById("bl-btn-accept").addEventListener("click", () => exitBarlineMode(true)); | |
| document.getElementById("bl-btn-cancel").addEventListener("click", () => exitBarlineMode(false)); | |
| document.getElementById("bl-btn-autodetect").addEventListener("click", () => { | |
| if (!barlineMode) return; | |
| pushBarlineUndo(); | |
| const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1; | |
| const suggested = detectBarlines(detectedStaves, numStavesPerSys); | |
| // Merge: keep manual barlines, add suggested ones that aren't too close to existing | |
| const kept = detectedBarlines.filter(b => b.source === "manual"); | |
| for (const s of suggested) { | |
| const tooClose = kept.some(k => k.systemIdx === s.systemIdx && Math.abs(k.x - s.x) < 15); | |
| if (!tooClose) kept.push(s); | |
| } | |
| detectedBarlines = kept.sort((a, b) => a.systemIdx - b.systemIdx || a.x - b.x); | |
| renderBarlineOverlays(); | |
| updateBarlineCount(); | |
| recomputeWithBarlines(); | |
| }); | |
| // ── Barline Edit Mode ─────────────────────────────────────── | |
| let barlineMode = false; | |
| let barlineOriginal = null; // snapshot for cancel | |
| let selectedBarlineIdx = -1; | |
| let barlineDragState = null; // {blIdx, startX, origX} | |
| let ghostBarlineEl = null; | |
| let barlineUndoStack = []; // per-operation undo for barline edits | |
| let barlineRedoStack = []; | |
| const MAX_BARLINE_UNDO = 30; | |
| function pushBarlineUndo() { | |
| barlineUndoStack.push(JSON.stringify(detectedBarlines)); | |
| if (barlineUndoStack.length > MAX_BARLINE_UNDO) barlineUndoStack.shift(); | |
| barlineRedoStack = []; | |
| } | |
| function undoBarline() { | |
| if (barlineUndoStack.length === 0) return; | |
| barlineRedoStack.push(JSON.stringify(detectedBarlines)); | |
| detectedBarlines = JSON.parse(barlineUndoStack.pop()); | |
| selectedBarlineIdx = -1; | |
| renderBarlineOverlays(); | |
| updateBarlineCount(); | |
| } | |
| function redoBarline() { | |
| if (barlineRedoStack.length === 0) return; | |
| barlineUndoStack.push(JSON.stringify(detectedBarlines)); | |
| detectedBarlines = JSON.parse(barlineRedoStack.pop()); | |
| selectedBarlineIdx = -1; | |
| renderBarlineOverlays(); | |
| updateBarlineCount(); | |
| } | |
| function enterBarlineMode() { | |
| if (detectedStaves.length === 0) return; | |
| // Initialize from XML if no barlines yet | |
| if (detectedBarlines.length === 0) { | |
| initBarlinesFromXML(); | |
| } | |
| // Exit other modes | |
| if (staffAdjustMode) exitStaffAdjustMode(true); | |
| if (addMode) toggleAddMode(); | |
| barlineMode = true; | |
| barlineOriginal = JSON.parse(JSON.stringify(detectedBarlines)); | |
| barlineUndoStack = []; | |
| barlineRedoStack = []; | |
| // Ensure barline overlays are visible | |
| if (!barlineOverlaysVisible) { | |
| barlineOverlaysVisible = true; | |
| const btn = document.getElementById("btn-show-barlines"); | |
| if (btn) { btn.style.background = "#2a6a2a"; btn.style.color = "#fff"; } | |
| } | |
| renderBarlineOverlays(); | |
| // Show toolbar | |
| document.getElementById("barline-toolbar").classList.add("active"); | |
| // Button style | |
| const btn = document.getElementById("btn-barline-mode"); | |
| btn.style.background = "#ff6644"; | |
| btn.style.color = "#fff"; | |
| // Status | |
| document.getElementById("status-mode").textContent = t("barline_edit_mode"); | |
| updateBarlineCount(); | |
| console.log("Barline edit mode ON"); | |
| } | |
| /** | |
| * Auto-create measures for a specific system that has no XML measures. | |
| * Uses detected barlines if available, otherwise creates a default set of measures | |
| * based on the system's pixel width and the average measure width from existing systems. | |
| */ | |
| function autoCreateMeasuresForSystem(targetSysIdx) { | |
| if (!xmlDoc || !detectedStaves.length) return; | |
| const numStavesPerSys = systemsData.length > 0 ? systemsData[0].numStaves : 1; | |
| const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys); | |
| if (targetSysIdx >= staffSystems.length) return; | |
| const ss = staffSystems[targetSysIdx]; | |
| if (!ss || ss.length === 0) return; | |
| const allParts = xmlDoc.querySelectorAll("part"); | |
| if (allParts.length === 0) return; | |
| pushUndo(); | |
| // Get barlines for this system (if any were detected/manual) | |
| const sysBarlines = detectedBarlines | |
| .filter(b => b.systemIdx === targetSysIdx) | |
| .sort((a, b) => a.x - b.x); | |
| // Try to auto-detect barlines for this system if none exist | |
| let boundaries; | |
| if (sysBarlines.length >= 2) { | |
| // Use existing barlines | |
| const implicitBls = sysBarlines.filter(b => b.source === "implicit"); | |
| const internalBls = sysBarlines.filter(b => b.source !== "implicit"); | |
| const leftEdge = implicitBls.length > 0 ? implicitBls[0].x : ss[0].leftX; | |
| const rightEdge = implicitBls.length > 1 ? implicitBls[implicitBls.length - 1].x : ss[0].rightX; | |
| boundaries = [leftEdge, ...internalBls.map(b => b.x), rightEdge]; | |
| } else { | |
| // No barlines: try auto-detect for just this system | |
| const detected = detectBarlines(detectedStaves, numStavesPerSys); | |
| const sysDetected = detected.filter(b => b.systemIdx === targetSysIdx).sort((a, b) => a.x - b.x); | |
| if (sysDetected.length >= 2) { | |
| // Use detected barlines as boundaries | |
| boundaries = [ss[0].leftX, ...sysDetected.map(b => b.x), ss[0].rightX]; | |
| // Remove duplicates too close to edges | |
| boundaries = boundaries.filter((b, i, arr) => { | |
| if (i === 0 || i === arr.length - 1) return true; | |
| return b - arr[0] > 15 && arr[arr.length - 1] - b > 15; | |
| }); | |
| } else { | |
| // Fallback: estimate measure count from average measure width in existing systems | |
| let avgMeasureWidthPx = 200; | |
| if (systemsData.length > 0) { | |
| let totalW = 0, totalM = 0; | |
| for (const sys of systemsData) { | |
| if (sys.measures.length > 0) { | |
| const sysStaves = staffSystems[sys.index]; | |
| if (sysStaves && sysStaves[0]) { | |
| const pixW = sysStaves[0].rightX - sysStaves[0].leftX; | |
| totalW += pixW; | |
| totalM += sys.measures.length; | |
| } | |
| } | |
| } | |
| if (totalM > 0) avgMeasureWidthPx = totalW / totalM; | |
| } | |
| const systemPixelWidth = ss[0].rightX - ss[0].leftX; | |
| const estimatedMeasures = Math.max(1, Math.round(systemPixelWidth / avgMeasureWidthPx)); | |
| const measurePixelWidth = systemPixelWidth / estimatedMeasures; | |
| boundaries = [ss[0].leftX]; | |
| for (let i = 1; i <= estimatedMeasures; i++) { | |
| boundaries.push(ss[0].leftX + i * measurePixelWidth); | |
| } | |
| } | |
| } | |
| if (boundaries.length < 2) return; | |
| const numMeasures = boundaries.length - 1; | |
| console.log(`autoCreateMeasuresForSystem: creating ${numMeasures} measures for system ${targetSysIdx}`); | |
| // Collect last known attributes from XML | |
| let lastDivisions = 1, lastBeats = 4, lastBeatType = 4, lastFifths = 0; | |
| allParts[0].querySelectorAll("measure").forEach(mEl => { | |
| const attr = mEl.querySelector("attributes"); | |
| if (!attr) return; | |
| const d = attr.querySelector("divisions"); | |
| if (d) lastDivisions = parseInt(d.textContent) || 1; | |
| const t = attr.querySelector("time"); | |
| if (t) { | |
| lastBeats = parseInt(t.querySelector("beats")?.textContent || "4"); | |
| lastBeatType = parseInt(t.querySelector("beat-type")?.textContent || "4"); | |
| } | |
| const k = attr.querySelector("key"); | |
| if (k) lastFifths = parseInt(k.querySelector("fifths")?.textContent || "0"); | |
| }); | |
| // Per-part clef info | |
| const partClefs = []; | |
| allParts.forEach(partEl => { | |
| const clefs = []; | |
| partEl.querySelectorAll("measure").forEach(mEl => { | |
| const attr = mEl.querySelector("attributes"); | |
| if (!attr) return; | |
| attr.querySelectorAll("clef").forEach(c => { | |
| const num = parseInt(c.getAttribute("number") || "1"); | |
| clefs[num] = { | |
| sign: c.querySelector("sign")?.textContent || "G", | |
| line: parseInt(c.querySelector("line")?.textContent || "2") | |
| }; | |
| }); | |
| }); | |
| partClefs.push(clefs); | |
| }); | |
| // Find max measure number | |
| let maxMeasureNum = 0; | |
| allParts[0].querySelectorAll("measure").forEach(m => { | |
| const n = parseInt(m.getAttribute("number")) || 0; | |
| if (n > maxMeasureNum) maxMeasureNum = n; | |
| }); | |
| // System distance from previous system | |
| let systemDistance = 150; | |
| for (let prev = Math.min(targetSysIdx, systemsData.length) - 1; prev >= 0; prev--) { | |
| if (systemsData[prev] && systemsData[prev]._origSysDist) { | |
| systemDistance = systemsData[prev]._origSysDist; | |
| break; | |
| } | |
| } | |
| const ppt = pixelsPerTenth > 0 ? pixelsPerTenth : 1; | |
| const measureDuration = lastDivisions * lastBeats * (4 / lastBeatType); | |
| for (let gi = 0; gi < numMeasures; gi++) { | |
| const pixelWidth = boundaries[gi + 1] - boundaries[gi]; | |
| const widthTenths = Math.round(pixelWidth / ppt); | |
| maxMeasureNum++; | |
| const newNum = maxMeasureNum.toString(); | |
| allParts.forEach((partEl, pi) => { | |
| const measureEl = xmlDoc.createElement("measure"); | |
| measureEl.setAttribute("number", newNum); | |
| measureEl.setAttribute("width", widthTenths.toString()); | |
| // First measure needs <print new-system="yes"> | |
| if (gi === 0) { | |
| const printEl = xmlDoc.createElement("print"); | |
| printEl.setAttribute("new-system", "yes"); | |
| const sysLayout = xmlDoc.createElement("system-layout"); | |
| const sysDistEl = xmlDoc.createElement("system-distance"); | |
| sysDistEl.textContent = systemDistance.toString(); | |
| sysLayout.appendChild(sysDistEl); | |
| printEl.appendChild(sysLayout); | |
| measureEl.appendChild(printEl); | |
| } | |
| // <attributes> | |
| const attrEl = xmlDoc.createElement("attributes"); | |
| const divEl = xmlDoc.createElement("divisions"); | |
| divEl.textContent = lastDivisions.toString(); | |
| attrEl.appendChild(divEl); | |
| if (gi === 0) { | |
| const keyEl = xmlDoc.createElement("key"); | |
| const fifthsEl = xmlDoc.createElement("fifths"); | |
| fifthsEl.textContent = lastFifths.toString(); | |
| keyEl.appendChild(fifthsEl); | |
| attrEl.appendChild(keyEl); | |
| const timeEl = xmlDoc.createElement("time"); | |
| const beatsEl = xmlDoc.createElement("beats"); | |
| beatsEl.textContent = lastBeats.toString(); | |
| const beatTypeEl = xmlDoc.createElement("beat-type"); | |
| beatTypeEl.textContent = lastBeatType.toString(); | |
| timeEl.appendChild(beatsEl); | |
| timeEl.appendChild(beatTypeEl); | |
| attrEl.appendChild(timeEl); | |
| // Clefs | |
| const clefs = partClefs[pi]; | |
| if (clefs && clefs.length > 0) { | |
| for (let ci = 1; ci < clefs.length; ci++) { | |
| if (!clefs[ci]) continue; | |
| const clefEl = xmlDoc.createElement("clef"); | |
| if (ci > 1) clefEl.setAttribute("number", ci.toString()); | |
| const signEl = xmlDoc.createElement("sign"); | |
| signEl.textContent = clefs[ci].sign; | |
| const lineEl = xmlDoc.createElement("line"); | |
| lineEl.textContent = clefs[ci].line.toString(); | |
| clefEl.appendChild(signEl); | |
| clefEl.appendChild(lineEl); | |
| attrEl.appendChild(clefEl); | |
| } | |
| } else { | |
| const clefEl = xmlDoc.createElement("clef"); | |
| const signEl = xmlDoc.createElement("sign"); | |
| signEl.textContent = "G"; | |
| const lineEl = xmlDoc.createElement("line"); | |
| lineEl.textContent = "2"; | |
| clefEl.appendChild(signEl); | |
| clefEl.appendChild(lineEl); | |
| attrEl.appendChild(clefEl); | |
| } | |
| } | |
| measureEl.appendChild(attrEl); | |
| // Full-measure rest | |
| const noteEl = xmlDoc.createElement("note"); | |
| noteEl.appendChild(xmlDoc.createElement("rest")); | |
| const durEl = xmlDoc.createElement("duration"); | |
| durEl.textContent = measureDuration.toString(); | |
| noteEl.appendChild(durEl); | |
| const voiceEl = xmlDoc.createElement("voice"); | |
| voiceEl.textContent = "1"; | |
| noteEl.appendChild(voiceEl); | |
| const typeEl = xmlDoc.createElement("type"); | |
| typeEl.textContent = "whole"; | |
| noteEl.appendChild(typeEl); | |
| measureEl.appendChild(noteEl); | |
| partEl.appendChild(measureEl); | |
| }); | |
| } | |
| // Re-parse everything | |
| layout = parseScoreLayout(xmlDoc); | |
| rebuildSystemsAndNotes(); | |
| const ux = parseInt(document.getElementById("offset-x").value) || 0; | |
| const uy = parseInt(document.getElementById("offset-y").value) || 0; | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| console.log(`autoCreateMeasuresForSystem: done, systemsData now has ${systemsData.length} systems`); | |
| } | |
| /** | |
| * Create XML <measure> elements for systems where barlines exist but XML measures are missing. | |
| * Called on barline accept. Compares barline count per system with XML measure count, | |
| * and creates missing measures in ALL parts. | |
| * | |
| * Returns true if any measures were created (caller should re-parse). | |
| */ | |
| function createMeasuresFromBarlines() { | |
| if (!xmlDoc || !systemsData.length || detectedBarlines.length === 0) return false; | |
| const numStavesPerSys = systemsData[0].numStaves; | |
| const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys); | |
| const allParts = xmlDoc.querySelectorAll("part"); | |
| if (allParts.length === 0) return false; | |
| // Find the current highest measure number across all parts | |
| let maxMeasureNum = 0; | |
| allParts[0].querySelectorAll("measure").forEach(m => { | |
| const n = parseInt(m.getAttribute("number")) || 0; | |
| if (n > maxMeasureNum) maxMeasureNum = n; | |
| }); | |
| // Collect last known attributes (divisions, beats, beatType, clef) from XML | |
| let lastDivisions = 1, lastBeats = 4, lastBeatType = 4; | |
| let lastClefSign = "G", lastClefLine = 2; | |
| let lastFifths = 0; | |
| const lastMeasure = allParts[0].querySelector("measure:last-of-type"); | |
| if (lastMeasure) { | |
| // Walk all measures to find the last active attributes | |
| allParts[0].querySelectorAll("measure").forEach(mEl => { | |
| const attr = mEl.querySelector("attributes"); | |
| if (!attr) return; | |
| const d = attr.querySelector("divisions"); | |
| if (d) lastDivisions = parseInt(d.textContent) || 1; | |
| const t = attr.querySelector("time"); | |
| if (t) { | |
| lastBeats = parseInt(t.querySelector("beats")?.textContent || "4"); | |
| lastBeatType = parseInt(t.querySelector("beat-type")?.textContent || "4"); | |
| } | |
| const k = attr.querySelector("key"); | |
| if (k) lastFifths = parseInt(k.querySelector("fifths")?.textContent || "0"); | |
| const c = attr.querySelector("clef"); | |
| if (c) { | |
| lastClefSign = c.querySelector("sign")?.textContent || "G"; | |
| lastClefLine = parseInt(c.querySelector("line")?.textContent || "2"); | |
| } | |
| }); | |
| } | |
| // Per-part last clef info (for multi-staff, e.g. treble+bass) | |
| const partClefs = []; | |
| allParts.forEach(partEl => { | |
| const clefs = []; | |
| partEl.querySelectorAll("measure").forEach(mEl => { | |
| const attr = mEl.querySelector("attributes"); | |
| if (!attr) return; | |
| attr.querySelectorAll("clef").forEach(c => { | |
| const num = parseInt(c.getAttribute("number") || "1"); | |
| const sign = c.querySelector("sign")?.textContent || "G"; | |
| const line = parseInt(c.querySelector("line")?.textContent || "2"); | |
| clefs[num] = { sign, line }; | |
| }); | |
| }); | |
| partClefs.push(clefs); | |
| }); | |
| let created = false; | |
| // Check each image system: if it has barlines but fewer XML measures than expected | |
| for (let sysIdx = 0; sysIdx < staffSystems.length; sysIdx++) { | |
| const ss = staffSystems[sysIdx]; | |
| if (!ss || ss.length === 0) continue; | |
| // Get barlines for this system, sorted by X | |
| const sysBarlines = detectedBarlines | |
| .filter(b => b.systemIdx === sysIdx) | |
| .sort((a, b) => a.x - b.x); | |
| if (sysBarlines.length < 2) continue; // need at least left+right edges | |
| // Expected measures = number of gaps between barlines | |
| // implicit barlines (left/right edge) define the outer boundaries | |
| // Internal barlines define measure divisions | |
| const implicitBls = sysBarlines.filter(b => b.source === "implicit"); | |
| const internalBls = sysBarlines.filter(b => b.source !== "implicit"); | |
| const expectedMeasures = internalBls.length + 1; // N internal barlines → N+1 measures | |
| // How many XML measures does this system currently have? | |
| // Note: systemsData may have fewer entries than staffSystems (Audiveris missed systems) | |
| const xmlSys = sysIdx < systemsData.length ? systemsData[sysIdx] : null; | |
| const currentMeasures = xmlSys ? xmlSys.measures.length : 0; | |
| if (currentMeasures >= expectedMeasures) continue; // enough measures already | |
| const measuresToCreate = expectedMeasures - currentMeasures; | |
| if (!created) pushUndo(); // snapshot XML before first modification | |
| console.log(`createMeasuresFromBarlines: system ${sysIdx} needs ${measuresToCreate} new measures (has ${currentMeasures}, expected ${expectedMeasures})`); | |
| // Calculate measure widths from barline pixel positions → tenths | |
| // Build sorted boundary positions (left edge, internal barlines, right edge) | |
| const leftEdge = implicitBls.length > 0 ? implicitBls[0].x : ss[0].leftX; | |
| const rightEdge = implicitBls.length > 1 ? implicitBls[implicitBls.length - 1].x : ss[0].rightX; | |
| const boundaries = [leftEdge, ...internalBls.map(b => b.x), rightEdge]; | |
| // Total pixel width → use pixelsPerTenth for conversion | |
| const ppt = pixelsPerTenth > 0 ? pixelsPerTenth : 1; | |
| // Determine if this is a new system that needs <print new-system> | |
| const needsNewSystem = !xmlSys || xmlSys.measures.length === 0; | |
| // Get system-distance from previous system (for <print> element) | |
| let systemDistance = 150; // reasonable default in tenths | |
| if (sysIdx > 0) { | |
| // Try to inherit from an existing system's distance | |
| for (let prev = Math.min(sysIdx, systemsData.length) - 1; prev >= 0; prev--) { | |
| if (systemsData[prev]._origSysDist) { | |
| systemDistance = systemsData[prev]._origSysDist; | |
| break; | |
| } | |
| } | |
| } | |
| // Create measures for each gap between boundaries | |
| // Only create measures for gaps that don't have existing measures | |
| const startGapIdx = currentMeasures; // existing measures cover gaps 0..currentMeasures-1 | |
| for (let gi = startGapIdx; gi < boundaries.length - 1; gi++) { | |
| const pixelWidth = boundaries[gi + 1] - boundaries[gi]; | |
| const widthTenths = Math.round(pixelWidth / ppt); | |
| maxMeasureNum++; | |
| const newNum = maxMeasureNum.toString(); | |
| // Create measure in each part | |
| allParts.forEach((partEl, pi) => { | |
| const measureEl = xmlDoc.createElement("measure"); | |
| measureEl.setAttribute("number", newNum); | |
| measureEl.setAttribute("width", widthTenths.toString()); | |
| // First measure of a new system needs <print new-system="yes"> | |
| if (gi === startGapIdx && needsNewSystem) { | |
| const printEl = xmlDoc.createElement("print"); | |
| printEl.setAttribute("new-system", "yes"); | |
| // Add system-layout with system-distance | |
| const sysLayout = xmlDoc.createElement("system-layout"); | |
| const sysDistEl = xmlDoc.createElement("system-distance"); | |
| sysDistEl.textContent = systemDistance.toString(); | |
| sysLayout.appendChild(sysDistEl); | |
| printEl.appendChild(sysLayout); | |
| measureEl.appendChild(printEl); | |
| } | |
| // Add <attributes> with inherited values | |
| const attrEl = xmlDoc.createElement("attributes"); | |
| const divEl = xmlDoc.createElement("divisions"); | |
| divEl.textContent = lastDivisions.toString(); | |
| attrEl.appendChild(divEl); | |
| // Only add key/time/clef on first measure of new system to avoid redundancy | |
| if (gi === startGapIdx) { | |
| const keyEl = xmlDoc.createElement("key"); | |
| const fifthsEl = xmlDoc.createElement("fifths"); | |
| fifthsEl.textContent = lastFifths.toString(); | |
| keyEl.appendChild(fifthsEl); | |
| attrEl.appendChild(keyEl); | |
| const timeEl = xmlDoc.createElement("time"); | |
| const beatsEl = xmlDoc.createElement("beats"); | |
| beatsEl.textContent = lastBeats.toString(); | |
| const beatTypeEl = xmlDoc.createElement("beat-type"); | |
| beatTypeEl.textContent = lastBeatType.toString(); | |
| timeEl.appendChild(beatsEl); | |
| timeEl.appendChild(beatTypeEl); | |
| attrEl.appendChild(timeEl); | |
| // Add clef(s) for this part | |
| const clefs = partClefs[pi]; | |
| if (clefs && clefs.length > 0) { | |
| for (let ci = 1; ci < clefs.length; ci++) { | |
| if (!clefs[ci]) continue; | |
| const clefEl = xmlDoc.createElement("clef"); | |
| if (ci > 1) clefEl.setAttribute("number", ci.toString()); | |
| const signEl = xmlDoc.createElement("sign"); | |
| signEl.textContent = clefs[ci].sign; | |
| const lineEl = xmlDoc.createElement("line"); | |
| lineEl.textContent = clefs[ci].line.toString(); | |
| clefEl.appendChild(signEl); | |
| clefEl.appendChild(lineEl); | |
| attrEl.appendChild(clefEl); | |
| } | |
| } else { | |
| // Fallback: single clef | |
| const clefEl = xmlDoc.createElement("clef"); | |
| const signEl = xmlDoc.createElement("sign"); | |
| signEl.textContent = lastClefSign; | |
| const lineEl = xmlDoc.createElement("line"); | |
| lineEl.textContent = lastClefLine.toString(); | |
| clefEl.appendChild(signEl); | |
| clefEl.appendChild(lineEl); | |
| attrEl.appendChild(clefEl); | |
| } | |
| } | |
| measureEl.appendChild(attrEl); | |
| // Add a full-measure rest so the measure has valid duration | |
| const measureDuration = lastDivisions * lastBeats * (4 / lastBeatType); | |
| const noteEl = xmlDoc.createElement("note"); | |
| const restEl = xmlDoc.createElement("rest"); | |
| noteEl.appendChild(restEl); | |
| const durEl = xmlDoc.createElement("duration"); | |
| durEl.textContent = measureDuration.toString(); | |
| noteEl.appendChild(durEl); | |
| const voiceEl = xmlDoc.createElement("voice"); | |
| voiceEl.textContent = "1"; | |
| noteEl.appendChild(voiceEl); | |
| const typeEl = xmlDoc.createElement("type"); | |
| typeEl.textContent = "whole"; | |
| noteEl.appendChild(typeEl); | |
| partEl.appendChild(measureEl); | |
| }); | |
| created = true; | |
| } | |
| } | |
| if (created) { | |
| console.log(`createMeasuresFromBarlines: created measures, new max number = ${maxMeasureNum}`); | |
| // Re-parse everything | |
| layout = parseScoreLayout(xmlDoc); | |
| rebuildSystemsAndNotes(); | |
| const ux = parseInt(document.getElementById("offset-x").value) || 0; | |
| const uy = parseInt(document.getElementById("offset-y").value) || 0; | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| } | |
| return created; | |
| } | |
| function exitBarlineMode(accept) { | |
| barlineMode = false; | |
| selectedBarlineIdx = -1; | |
| // Remove ghost | |
| if (ghostBarlineEl) { ghostBarlineEl.remove(); ghostBarlineEl = null; } | |
| // Hide toolbar | |
| document.getElementById("barline-toolbar").classList.remove("active"); | |
| // Reset button | |
| const btn = document.getElementById("btn-barline-mode"); | |
| btn.style.background = ""; | |
| btn.style.color = ""; | |
| const modeSpan = document.getElementById("status-mode"); | |
| if (modeSpan.textContent === t("barline_edit_mode")) modeSpan.textContent = ""; | |
| if (!accept && barlineOriginal) { | |
| detectedBarlines = JSON.parse(JSON.stringify(barlineOriginal)); | |
| } | |
| barlineOriginal = null; | |
| renderBarlineOverlays(); | |
| // If accepted, create missing measures from barlines, then trigger X remap | |
| if (accept && detectedBarlines.length > 0) { | |
| const measuresCreated = createMeasuresFromBarlines(); | |
| if (measuresCreated) { | |
| console.log("New measures created from barlines — re-initializing barlines from updated XML"); | |
| // Re-init barlines so newly created measures get proper XML-based boundaries | |
| initBarlinesFromXML(); | |
| } | |
| recomputeWithBarlines(); | |
| } | |
| console.log("Barline edit mode OFF, accept=" + accept); | |
| } | |
| function recomputeWithBarlines() { | |
| const ux = parseFloat(offsetX.value || 0); | |
| const uy = parseFloat(offsetY.value || 0); | |
| const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1; | |
| const maps = buildBarlinePiecewiseMaps(layout, numStavesPerSys); | |
| console.log("recomputeWithBarlines:", detectedBarlines.length, "barlines, piecewise maps:", maps); | |
| if (maps) { | |
| maps.forEach((segs, si) => { | |
| if (segs) console.log(` Sys ${si}: ${segs.length} segments`, segs.map(s => `[img ${s.imgStart}-${s.imgEnd}, xml ${s.xmlStart.toFixed(0)}-${s.xmlEnd.toFixed(0)}]`).join(" ")); | |
| }); | |
| } | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| renderMarkers(noteInfos); | |
| if (barlineOverlaysVisible) renderBarlineOverlays(); | |
| if (freeGlyphsVisible) renderFreeGlyphOverlays(); | |
| } | |
| /** | |
| * Initialize barlines from XML measure boundaries using linear pixel mapping. | |
| * Each internal measure boundary becomes a barline. Staff left/right edges | |
| * are NOT included (they serve as implicit anchors in the piecewise system). | |
| */ | |
| function initBarlinesFromXML() { | |
| if (!systemsData.length || !layout) return; | |
| const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1; | |
| const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys); | |
| const newBarlines = []; | |
| systemsData.forEach((sys, sysIdx) => { | |
| if (!sys.measures || sys.measures.length === 0) return; | |
| const sysStaves = staffSystems[sysIdx]; | |
| if (!sysStaves || sysStaves.length === 0) return; | |
| const imgLeft = sysStaves[0].leftX; | |
| const imgRight = sysStaves[0].rightX; | |
| const imgWidth = imgRight - imgLeft; | |
| if (imgWidth <= 0) return; | |
| const sysLeftTenths = layout.marginL + sys.leftMargin; | |
| const sysTotalTenths = sys.cumulativeWidth; | |
| if (sysTotalTenths <= 0) return; | |
| // System left edge (implicit boundary) | |
| newBarlines.push({ | |
| x: Math.round(imgLeft), | |
| systemIdx: sysIdx, | |
| confidence: 1.0, | |
| source: "implicit" | |
| }); | |
| // Internal measure boundaries (right edge of each measure except the last) | |
| for (let mi = 0; mi < sys.measures.length - 1; mi++) { | |
| const m = sys.measures[mi]; | |
| const boundaryTenths = m.startX + m.width; // relative to system | |
| const ratio = boundaryTenths / sysTotalTenths; | |
| const pixelX = imgLeft + ratio * imgWidth; | |
| newBarlines.push({ | |
| x: Math.round(pixelX), | |
| systemIdx: sysIdx, | |
| confidence: 0.9, | |
| source: "xml" | |
| }); | |
| } | |
| // System right edge (implicit boundary) | |
| newBarlines.push({ | |
| x: Math.round(imgRight), | |
| systemIdx: sysIdx, | |
| confidence: 1.0, | |
| source: "implicit" | |
| }); | |
| }); | |
| detectedBarlines = newBarlines; | |
| } | |
| function updateBarlineCount() { | |
| const el = document.getElementById("bl-count"); | |
| if (el) { | |
| const total = detectedBarlines.length; | |
| const uncertain = detectedBarlines.filter(b => b.confidence < 0.55).length; | |
| el.textContent = `${total} barlines` + (uncertain > 0 ? ` (${uncertain} uncertain)` : ""); | |
| } | |
| } | |
| function selectBarline(idx) { | |
| // Deselect previous | |
| if (selectedBarlineIdx >= 0 && selectedBarlineIdx < detectedBarlines.length) { | |
| const prev = detectedBarlines[selectedBarlineIdx]; | |
| if (prev.svgEl) prev.svgEl.classList.remove("bl-selected"); | |
| } | |
| selectedBarlineIdx = idx; | |
| if (idx >= 0 && idx < detectedBarlines.length) { | |
| const bl = detectedBarlines[idx]; | |
| if (bl.svgEl) bl.svgEl.classList.add("bl-selected"); | |
| } | |
| } | |
| function deleteBarline(idx) { | |
| if (idx < 0 || idx >= detectedBarlines.length) return; | |
| const bl = detectedBarlines[idx]; | |
| if (bl.source === "implicit") return; // can't delete implicit (staff edges) | |
| pushBarlineUndo(); | |
| detectedBarlines.splice(idx, 1); | |
| selectedBarlineIdx = -1; | |
| renderBarlineOverlays(); | |
| updateBarlineCount(); | |
| } | |
| function insertBarline(x, systemIdx) { | |
| pushBarlineUndo(); | |
| const newBl = { x: Math.round(x), systemIdx, confidence: 1.0, source: "manual" }; | |
| detectedBarlines.push(newBl); | |
| detectedBarlines.sort((a, b) => a.systemIdx - b.systemIdx || a.x - b.x); | |
| // Select the newly inserted barline | |
| const newIdx = detectedBarlines.indexOf(newBl); | |
| renderBarlineOverlays(); | |
| selectBarline(newIdx); | |
| updateBarlineCount(); | |
| } | |
| function showGhostBarline(x, yTop, yBot) { | |
| if (!ghostBarlineEl) { | |
| ghostBarlineEl = document.createElementNS(SVG_NS, "line"); | |
| ghostBarlineEl.classList.add("ghost-barline"); | |
| markerSvg.appendChild(ghostBarlineEl); | |
| } | |
| ghostBarlineEl.setAttribute("x1", x); | |
| ghostBarlineEl.setAttribute("y1", yTop); | |
| ghostBarlineEl.setAttribute("x2", x); | |
| ghostBarlineEl.setAttribute("y2", yBot); | |
| } | |
| function hideGhostBarline() { | |
| if (ghostBarlineEl) { ghostBarlineEl.remove(); ghostBarlineEl = null; } | |
| } | |
| /** Find which system a click Y belongs to */ | |
| function findSystemAtY(clickY) { | |
| const uy = parseFloat(offsetY.value || 0); | |
| const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1; | |
| const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys); | |
| for (let si = 0; si < staffSystems.length; si++) { | |
| const ss = staffSystems[si]; | |
| if (!ss || ss.length === 0) continue; | |
| const top = ss[0].topLineY - 30 + uy; | |
| const bot = ss[ss.length - 1].bottomLineY + 30 + uy; | |
| if (clickY >= top && clickY <= bot) return si; | |
| } | |
| return -1; | |
| } | |
| /** Find the barline index nearest to (x, sysIdx) within threshold */ | |
| function findNearestBarline(x, sysIdx, threshold) { | |
| const ux = parseFloat(offsetX.value || 0); | |
| let bestIdx = -1, bestDist = Infinity; | |
| detectedBarlines.forEach((bl, i) => { | |
| if (bl.systemIdx !== sysIdx) return; | |
| const d = Math.abs((bl.x + ux) - x); | |
| if (d < bestDist && d <= threshold) { bestDist = d; bestIdx = i; } | |
| }); | |
| return bestIdx; | |
| } | |
| // ── Barline Edit Mode: mouse handlers ─────────────────────── | |
| // Mousedown on canvas-wrapper for barline mode | |
| document.getElementById("canvas-wrapper").addEventListener("mousedown", (e) => { | |
| if (!barlineMode || staffAdjustMode) return; | |
| if (e.button !== 0) return; | |
| const rect = scoreImage.getBoundingClientRect(); | |
| const px = (e.clientX - rect.left) / currentZoom; | |
| const py = (e.clientY - rect.top) / currentZoom; | |
| const sysIdx = findSystemAtY(py); | |
| if (sysIdx < 0) return; | |
| // Check if clicking on an existing barline | |
| const nearIdx = findNearestBarline(px, sysIdx, 8 / currentZoom); | |
| if (nearIdx >= 0) { | |
| // Select and start drag | |
| pushBarlineUndo(); | |
| selectBarline(nearIdx); | |
| barlineDragState = { | |
| blIdx: nearIdx, | |
| startX: e.clientX, | |
| origX: detectedBarlines[nearIdx].x | |
| }; | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| } else if (!e.shiftKey) { | |
| // Click empty space → insert barline | |
| const ux = parseFloat(offsetX.value || 0); | |
| insertBarline(px - ux, sysIdx); | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| } | |
| }, true); | |
| // Mousemove for barline drag + ghost barline | |
| document.addEventListener("mousemove", (e) => { | |
| if (!barlineMode) return; | |
| const rect = scoreImage.getBoundingClientRect(); | |
| const px = (e.clientX - rect.left) / currentZoom; | |
| const py = (e.clientY - rect.top) / currentZoom; | |
| // Drag in progress | |
| if (barlineDragState) { | |
| const dx = (e.clientX - barlineDragState.startX) / currentZoom; | |
| let newX = Math.round(barlineDragState.origX + dx); | |
| // Snap-to-feature (unless Shift held) | |
| if (!e.shiftKey) { | |
| newX = snapBarlineToFeature(newX, detectedBarlines[barlineDragState.blIdx].systemIdx); | |
| } | |
| detectedBarlines[barlineDragState.blIdx].x = newX; | |
| // Update SVG element position | |
| const bl = detectedBarlines[barlineDragState.blIdx]; | |
| const ux = parseFloat(offsetX.value || 0); | |
| if (bl.svgEl) { | |
| bl.svgEl.setAttribute("x1", newX + ux); | |
| bl.svgEl.setAttribute("x2", newX + ux); | |
| } | |
| // Real-time note recompute during barline drag | |
| const uy = parseFloat(offsetY.value || 0); | |
| computeNotePositions(noteInfos, layout, pixelsPerTenth, ux, uy); | |
| noteInfos.forEach((n, idx) => { | |
| const circle = markerSvg.querySelector(`circle.marker[data-idx="${idx}"]`); | |
| if (circle) circle.setAttribute("cx", n.px); | |
| const accLabel = markerSvg.querySelector(`text.acc-label[data-idx="${idx}"]`); | |
| if (accLabel) accLabel.setAttribute("x", n.px + 8); | |
| }); | |
| return; | |
| } | |
| // Ghost barline preview (when not dragging) | |
| const sysIdx = findSystemAtY(py); | |
| if (sysIdx >= 0) { | |
| const nearIdx = findNearestBarline(px, sysIdx, 8 / currentZoom); | |
| if (nearIdx < 0) { | |
| // No barline nearby → show ghost | |
| const uy = parseFloat(offsetY.value || 0); | |
| const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1; | |
| const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys); | |
| const ss = staffSystems[sysIdx]; | |
| if (ss && ss.length > 0) { | |
| const yTop = ss[0].topLineY - 8 + uy; | |
| const yBot = ss[ss.length - 1].bottomLineY + 8 + uy; | |
| showGhostBarline(px, yTop, yBot); | |
| } | |
| } else { | |
| hideGhostBarline(); | |
| } | |
| } else { | |
| hideGhostBarline(); | |
| } | |
| }); | |
| // Mouseup for barline drag | |
| document.addEventListener("mouseup", (e) => { | |
| if (!barlineMode || !barlineDragState) return; | |
| // Mark as manual after drag (but keep implicit source for edge barlines) | |
| const draggedBl = detectedBarlines[barlineDragState.blIdx]; | |
| if (draggedBl.source !== "implicit") { | |
| draggedBl.source = "manual"; | |
| } | |
| draggedBl.confidence = 1.0; | |
| barlineDragState = null; | |
| // Re-sort and re-render | |
| detectedBarlines.sort((a, b) => a.systemIdx - b.systemIdx || a.x - b.x); | |
| renderBarlineOverlays(); | |
| updateBarlineCount(); | |
| }); | |
| /** Snap barline X to nearest vertical feature within ±10px */ | |
| function snapBarlineToFeature(x, sysIdx) { | |
| const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1; | |
| const featureMap = buildVerticalFeatureMap(sysIdx, detectedStaves, numStavesPerSys); | |
| if (!featureMap) return x; | |
| const snapRange = 10; | |
| let bestX = x, bestScore = 0; | |
| for (let dx = -snapRange; dx <= snapRange; dx++) { | |
| const cx = x + dx; | |
| if (cx < 0 || cx >= featureMap.length) continue; | |
| if (featureMap[cx] > bestScore) { | |
| bestScore = featureMap[cx]; | |
| bestX = cx; | |
| } | |
| } | |
| // Only snap if the feature is reasonably strong | |
| return bestScore >= 0.4 ? bestX : x; | |
| } | |
| // ── Barline keyboard handler ──────────────────────────────── | |
| document.addEventListener("keydown", (e) => { | |
| if (!barlineMode) return; | |
| if (e.target.tagName === "INPUT") return; | |
| // Ctrl+Z / Ctrl+Y for barline undo/redo | |
| if (e.ctrlKey || e.metaKey) { | |
| if (e.key === "z" || e.key === "Z") { e.preventDefault(); undoBarline(); return; } | |
| if (e.key === "y" || e.key === "Y") { e.preventDefault(); redoBarline(); return; } | |
| } | |
| switch (e.key) { | |
| case "Escape": | |
| e.preventDefault(); | |
| exitBarlineMode(false); | |
| return; | |
| case "Enter": | |
| e.preventDefault(); | |
| exitBarlineMode(true); | |
| return; | |
| case "Delete": | |
| e.preventDefault(); | |
| if (selectedBarlineIdx >= 0) deleteBarline(selectedBarlineIdx); | |
| return; | |
| case "ArrowLeft": | |
| e.preventDefault(); | |
| if (selectedBarlineIdx >= 0) { | |
| pushBarlineUndo(); | |
| const delta = e.shiftKey ? -5 : -1; | |
| nudgeBarline(selectedBarlineIdx, delta); | |
| } | |
| return; | |
| case "ArrowRight": | |
| e.preventDefault(); | |
| if (selectedBarlineIdx >= 0) { | |
| pushBarlineUndo(); | |
| const delta = e.shiftKey ? 5 : 1; | |
| nudgeBarline(selectedBarlineIdx, delta); | |
| } | |
| return; | |
| case "Tab": | |
| e.preventDefault(); | |
| navigateBarline(e.shiftKey ? -1 : 1); | |
| return; | |
| case "b": case "B": | |
| // B toggles mode off | |
| if (!e.ctrlKey && !e.metaKey) { | |
| e.preventDefault(); | |
| exitBarlineMode(true); | |
| } | |
| return; | |
| case "d": case "D": | |
| e.preventDefault(); | |
| distributeBarlines(); | |
| return; | |
| } | |
| }); | |
| function nudgeBarline(idx, delta) { | |
| if (idx < 0 || idx >= detectedBarlines.length) return; | |
| detectedBarlines[idx].x += delta; | |
| detectedBarlines[idx].source = "manual"; | |
| detectedBarlines[idx].confidence = 1.0; | |
| const ux = parseFloat(offsetX.value || 0); | |
| const bl = detectedBarlines[idx]; | |
| if (bl.svgEl) { | |
| bl.svgEl.setAttribute("x1", bl.x + ux); | |
| bl.svgEl.setAttribute("x2", bl.x + ux); | |
| } | |
| } | |
| function navigateBarline(dir) { | |
| if (detectedBarlines.length === 0) return; | |
| if (selectedBarlineIdx < 0) { | |
| selectBarline(0); | |
| return; | |
| } | |
| let next = selectedBarlineIdx + dir; | |
| if (next < 0) next = detectedBarlines.length - 1; | |
| if (next >= detectedBarlines.length) next = 0; | |
| selectBarline(next); | |
| // Scroll into view | |
| const bl = detectedBarlines[next]; | |
| if (bl && bl.svgEl) { | |
| const rect = bl.svgEl.getBoundingClientRect(); | |
| const wrapper = document.getElementById("canvas-wrapper"); | |
| const wrapRect = wrapper.getBoundingClientRect(); | |
| if (rect.left < wrapRect.left || rect.right > wrapRect.right) { | |
| bl.svgEl.scrollIntoView({ behavior: "smooth", inline: "center" }); | |
| } | |
| } | |
| } | |
| /** Distribute barlines evenly between selected barline and next one in same system. | |
| * Prompts user for number of divisions. */ | |
| function distributeBarlines() { | |
| if (selectedBarlineIdx < 0) return; | |
| const bl = detectedBarlines[selectedBarlineIdx]; | |
| // Find all barlines in same system, sorted by X | |
| const sysBarlines = detectedBarlines | |
| .map((b, i) => ({ b, i })) | |
| .filter(({ b }) => b.systemIdx === bl.systemIdx) | |
| .sort((a, b) => a.b.x - b.b.x); | |
| const selInSys = sysBarlines.findIndex(({ i }) => i === selectedBarlineIdx); | |
| if (selInSys < 0 || selInSys >= sysBarlines.length - 1) return; | |
| const leftX = sysBarlines[selInSys].b.x; | |
| const rightX = sysBarlines[selInSys + 1].b.x; | |
| const gap = rightX - leftX; | |
| if (gap < 30) return; | |
| const input = prompt( | |
| currentLang === "ko" | |
| ? `이 구간(${gap}px)을 몇 등분할까요?` | |
| : `Divide this segment (${gap}px) into how many parts?`, | |
| "2" | |
| ); | |
| if (!input) return; | |
| const n = parseInt(input); | |
| if (isNaN(n) || n < 2 || n > 20) return; | |
| pushBarlineUndo(); | |
| // Remove any existing barlines between left and right (exclusive) | |
| detectedBarlines = detectedBarlines.filter(b => { | |
| if (b.systemIdx !== bl.systemIdx) return true; | |
| return b.x <= leftX || b.x >= rightX; | |
| }); | |
| // Insert n-1 evenly spaced barlines | |
| for (let k = 1; k < n; k++) { | |
| const newX = Math.round(leftX + (gap * k) / n); | |
| detectedBarlines.push({ | |
| x: newX, systemIdx: bl.systemIdx, confidence: 1.0, source: "manual" | |
| }); | |
| } | |
| detectedBarlines.sort((a, b) => a.systemIdx - b.systemIdx || a.x - b.x); | |
| renderBarlineOverlays(); | |
| updateBarlineCount(); | |
| } | |
| /** Copy barline pattern from one system to another (proportional scaling) */ | |
| function copyBarlinePattern(fromSysIdx, toSysIdx) { | |
| const numStavesPerSys = (systemsData.length > 0) ? systemsData[0].numStaves : 1; | |
| const staffSystems = mapStavesToSystems(detectedStaves, numStavesPerSys); | |
| const fromStaves = staffSystems[fromSysIdx]; | |
| const toStaves = staffSystems[toSysIdx]; | |
| if (!fromStaves || !toStaves || fromStaves.length === 0 || toStaves.length === 0) return; | |
| const fromLeft = fromStaves[0].leftX; | |
| const fromRight = fromStaves[0].rightX; | |
| const fromWidth = fromRight - fromLeft; | |
| if (fromWidth <= 0) return; | |
| const toLeft = toStaves[0].leftX; | |
| const toRight = toStaves[0].rightX; | |
| const toWidth = toRight - toLeft; | |
| if (toWidth <= 0) return; | |
| // Get source barlines (non-implicit) | |
| const srcBarlines = detectedBarlines | |
| .filter(b => b.systemIdx === fromSysIdx) | |
| .sort((a, b) => a.x - b.x); | |
| if (srcBarlines.length === 0) return; | |
| // Remove existing barlines in target system | |
| detectedBarlines = detectedBarlines.filter(b => b.systemIdx !== toSysIdx); | |
| // Copy with proportional scaling | |
| srcBarlines.forEach(b => { | |
| const ratio = (b.x - fromLeft) / fromWidth; | |
| const newX = Math.round(toLeft + ratio * toWidth); | |
| detectedBarlines.push({ | |
| x: newX, systemIdx: toSysIdx, confidence: 1.0, source: "manual" | |
| }); | |
| }); | |
| detectedBarlines.sort((a, b) => a.systemIdx - b.systemIdx || a.x - b.x); | |
| renderBarlineOverlays(); | |
| updateBarlineCount(); | |
| } | |
| // ── Browser zoom compensation for UI bars ── | |
| // Keep upload bar, toolbar, progress bar, status bar at constant size | |
| // regardless of Ctrl+scroll browser zoom. | |
| (function initZoomCompensation() { | |
| // Fixed reference DPR = 1.0 (standard Windows 100% scaling). | |
| // Using a fixed value avoids the bug where reloading while zoomed | |
| // captures the zoomed DPR as baseline. | |
| const baseDPR = 1.0; | |
| const uiElements = [ | |
| document.getElementById("feedback-bar"), | |
| document.getElementById("upload-bar"), | |
| document.getElementById("toolbar"), | |
| document.getElementById("progress-bar-container"), | |
| document.getElementById("shortcut-bar"), | |
| document.getElementById("barline-toolbar"), | |
| document.getElementById("status-bar"), | |
| ].filter(Boolean); | |
| uiElements.forEach(el => { | |
| el.style.transformOrigin = "top left"; | |
| }); | |
| function applyZoomCompensation() { | |
| const currentDPR = window.devicePixelRatio || 1; | |
| const browserZoom = currentDPR / baseDPR; | |
| const inverseScale = 1 / browserZoom; | |
| uiElements.forEach(el => { | |
| el.style.transform = `scale(${inverseScale})`; | |
| el.style.width = (browserZoom * 100) + "%"; | |
| // margin-bottom compensation: element renders smaller but occupies original space | |
| const naturalH = el.scrollHeight; | |
| el.style.marginBottom = (naturalH * (inverseScale - 1)) + "px"; | |
| }); | |
| } | |
| // Detect zoom changes — matchMedia on resolution is the reliable cross-browser method | |
| function watchZoom() { | |
| const mq = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); | |
| mq.addEventListener("change", () => { | |
| applyZoomCompensation(); | |
| watchZoom(); // re-register since the resolution value changed | |
| }, { once: true }); | |
| } | |
| applyZoomCompensation(); | |
| watchZoom(); | |
| window.addEventListener("resize", applyZoomCompensation); | |
| })(); | |
| // ═══════════════════════════════════════════════════════════════════ | |
| // ── Interactive Tutorial Tour ── | |
| // ═══════════════════════════════════════════════════════════════════ | |
| (function initTutorial() { | |
| const LS_KEY = "omr_corrector_skip_tutorial"; | |
| const STEPS = [ | |
| { target: "#canvas-wrapper", text: "악보 영역입니다.\n마커를 클릭하면 음표가 선택됩니다.\nCtrl+클릭으로 여러 음표를 동시에 선택할 수 있습니다." }, | |
| { target: "#btn-up", text: "음 높이 변경 버튼입니다.\n↑↓ 방향키로도 조작할 수 있습니다.\n선택한 음표의 피치를 반음 단위로 올리거나 내립니다." }, | |
| { target: "#btn-dur-quarter", text: "음표 길이 변경 버튼입니다.\n숫자키 1(온음표)~7(32분음표)로 빠르게 바꿀 수 있습니다.\n점(.) 키로 점음표를 토글합니다." }, | |
| { target: "#btn-sharp", text: "임시표(#/b/♮) 변경 버튼입니다.\n선택한 음표에 샵, 플랫, 내추럴 등을 적용합니다." }, | |
| { target: "#btn-show-free-glyphs", text: "후보 기호 표시/숨김.\nOMR이 인식했지만 아직 배정되지 않은 기호들을 악보 위에 표시합니다.\n클릭하면 해당 기호의 종류를 변경하거나 삭제할 수 있습니다." }, | |
| { target: "#btn-timeline", text: "타임라인(TL) 패널을 엽니다.\n마디 안에서 음표들의 시간 배치를 시각적으로 확인하고,\n드래그로 위치를 조정할 수 있습니다." }, | |
| { target: "#btn-auto-align", text: "마디 자동 정렬 (Shift+A).\n선택한 마디의 음표 간격을 균등하게 맞춥니다." }, | |
| { target: "#btn-download", text: "완성된 악보를 XML 또는 MML 파일로 내보냅니다." }, | |
| ]; | |
| let currentStep = -1; | |
| const prompt = document.getElementById("tour-prompt"); | |
| const promptInner = document.getElementById("tour-prompt-inner"); | |
| const skipCheck = document.getElementById("tour-skip-check"); | |
| const btnYes = document.getElementById("tour-btn-yes"); | |
| const btnNo = document.getElementById("tour-btn-no"); | |
| const overlay = document.getElementById("tour-overlay"); | |
| const spotlight = document.getElementById("tour-spotlight"); | |
| const tooltip = document.getElementById("tour-tooltip"); | |
| const tooltipBody = document.getElementById("tour-tooltip-body"); | |
| const stepInd = document.getElementById("tour-step-indicator"); | |
| const btnPrev = document.getElementById("tour-btn-prev"); | |
| const btnNext = document.getElementById("tour-btn-next"); | |
| const btnSkipTour = document.getElementById("tour-btn-skip"); | |
| const btnTutorial = document.getElementById("btn-tutorial"); | |
| if (!prompt || !btnTutorial) return; // safety | |
| // ── Prompt Panel ── | |
| function showPrompt() { | |
| if (localStorage.getItem(LS_KEY) === "1") return; | |
| prompt.classList.remove("hidden"); | |
| } | |
| function hidePromptWithShrink(cb) { | |
| const btnRect = btnTutorial.getBoundingClientRect(); | |
| const panelRect = promptInner.getBoundingClientRect(); | |
| const dx = (btnRect.left + btnRect.width / 2) - (panelRect.left + panelRect.width / 2); | |
| const dy = (btnRect.top + btnRect.height / 2) - (panelRect.top + panelRect.height / 2); | |
| promptInner.animate([ | |
| { transform: "scale(1) translate(0px,0px)", opacity: 1 }, | |
| { transform: "scale(0.06) translate(" + dx + "px," + dy + "px)", opacity: 0 } | |
| ], { duration: 400, easing: "cubic-bezier(0.4,0,0.2,1)", fill: "forwards" }) | |
| .onfinish = function() { | |
| prompt.classList.add("hidden"); | |
| promptInner.getAnimations().forEach(function(a) { a.cancel(); }); | |
| if (cb) cb(); | |
| }; | |
| } | |
| // ── Tour Engine ── | |
| function startTour() { | |
| prompt.classList.add("hidden"); | |
| currentStep = 0; | |
| overlay.classList.remove("hidden"); | |
| showStep(0); | |
| } | |
| function showStep(idx) { | |
| var step = STEPS[idx]; | |
| var el = document.querySelector(step.target); | |
| if (!el) { if (idx < STEPS.length - 1) { currentStep++; showStep(currentStep); } else { endTour(); } return; } | |
| var rect = el.getBoundingClientRect(); | |
| var pad = 8; | |
| spotlight.style.top = (rect.top - pad) + "px"; | |
| spotlight.style.left = (rect.left - pad) + "px"; | |
| spotlight.style.width = (rect.width + pad * 2) + "px"; | |
| spotlight.style.height = (rect.height + pad * 2) + "px"; | |
| tooltipBody.textContent = ""; | |
| step.text.split("\n").forEach(function(line, i) { | |
| if (i > 0) tooltipBody.appendChild(document.createElement("br")); | |
| tooltipBody.appendChild(document.createTextNode(line)); | |
| }); | |
| stepInd.textContent = (idx + 1) + " / " + STEPS.length; | |
| btnPrev.style.display = idx === 0 ? "none" : ""; | |
| btnNext.textContent = idx === STEPS.length - 1 ? "완료" : "다음"; | |
| tooltip.classList.remove("hidden"); | |
| positionTooltip(rect); | |
| } | |
| function positionTooltip(targetRect) { | |
| tooltip.style.left = "0px"; | |
| tooltip.style.top = "0px"; | |
| var tw = tooltip.offsetWidth; | |
| var th = tooltip.offsetHeight; | |
| var gap = 14; | |
| var vw = window.innerWidth; | |
| var vh = window.innerHeight; | |
| var top = targetRect.bottom + gap; | |
| var left = targetRect.left + targetRect.width / 2 - tw / 2; | |
| if (top + th > vh - 10) top = targetRect.top - th - gap; | |
| if (top < 10) top = 10; | |
| if (left < 10) left = 10; | |
| if (left + tw > vw - 10) left = vw - tw - 10; | |
| tooltip.style.top = top + "px"; | |
| tooltip.style.left = left + "px"; | |
| } | |
| function nextStep() { | |
| if (currentStep >= STEPS.length - 1) { endTour(); return; } | |
| currentStep++; | |
| showStep(currentStep); | |
| } | |
| function prevStep() { | |
| if (currentStep > 0) { currentStep--; showStep(currentStep); } | |
| } | |
| function endTour() { | |
| tooltip.classList.add("hidden"); | |
| overlay.classList.add("hidden"); | |
| currentStep = -1; | |
| } | |
| // ── Events ── | |
| btnYes.addEventListener("click", function() { | |
| if (skipCheck.checked) localStorage.setItem(LS_KEY, "1"); | |
| startTour(); | |
| }); | |
| btnNo.addEventListener("click", function() { | |
| if (skipCheck.checked) localStorage.setItem(LS_KEY, "1"); | |
| hidePromptWithShrink(); | |
| }); | |
| btnNext.addEventListener("click", nextStep); | |
| btnPrev.addEventListener("click", prevStep); | |
| btnSkipTour.addEventListener("click", endTour); | |
| btnTutorial.addEventListener("click", startTour); | |
| overlay.addEventListener("click", function(e) { | |
| if (e.target === overlay) endTour(); | |
| }); | |
| window.addEventListener("resize", function() { | |
| if (currentStep >= 0) showStep(currentStep); | |
| }); | |
| // ── Auto-show after short delay ── | |
| setTimeout(showPrompt, 600); | |
| })(); | |