diff --git a/screen.html b/screen.html index e1736ca6..d9d63f4a 100644 --- a/screen.html +++ b/screen.html @@ -49,6 +49,7 @@ 0:00 / 0:00 + M-- -- diff --git a/screen.js b/screen.js index 7882f4f6..c8f5c710 100644 --- a/screen.js +++ b/screen.js @@ -4681,6 +4681,16 @@ function updatePlayIcon() { } } +function updateMeasureDisplay() { + const el = document.getElementById('editor-measure-display'); + if (!el) return; + const selectedIdx = S.tempoMapMode ? S.tempoSel : -1; + const r = _editorMeasureSignatureReadoutPure(S.beats || [], S.cursorTime || 0, selectedIdx); + el.textContent = r.label; + el.title = r.measure === null + ? 'No measure grid available' + : `Measure ${r.measure}, time signature ${r.numerator}/${r.denominator}`; +} function updateTimeDisplay() { const el = document.getElementById('editor-time-display'); if (!el) return; @@ -4690,6 +4700,7 @@ function updateTimeDisplay() { return m + ':' + String(s).padStart(2, '0'); }; el.textContent = fmt(S.cursorTime) + ' / ' + fmt(S.duration); + updateMeasureDisplay(); } // ════════════════════════════════════════════════════════════════════ @@ -5973,6 +5984,42 @@ function _tempoResolvedMeasureIdx() { return measures[0].i; } + +/* @pure:measure-readout:start */ +function _editorMeasureSignatureReadoutPure(beats, time, selectedIdx) { + if (!Array.isArray(beats) || !beats.length) return { label: 'M-- --', measure: null, numerator: null, denominator: null }; + let idx = Number.isInteger(selectedIdx) && selectedIdx >= 0 && selectedIdx < beats.length && beats[selectedIdx] && beats[selectedIdx].measure > 0 + ? selectedIdx + : -1; + const t = Number.isFinite(Number(time)) ? Number(time) : 0; + if (idx < 0) { + for (let i = 0; i < beats.length; i++) { + const b = beats[i]; + if (!b || b.measure <= 0) continue; + if ((Number(b.time) || 0) <= t + 1e-6) idx = i; + else break; + } + } + if (idx < 0) idx = beats.findIndex(b => b && b.measure > 0); + if (idx < 0) return { label: 'M-- --', measure: null, numerator: null, denominator: null }; + const downbeat = beats[idx]; + let nextIdx = beats.length; + for (let i = idx + 1; i < beats.length; i++) { + if (beats[i] && beats[i].measure > 0) { nextIdx = i; break; } + } + let numerator = Math.max(1, nextIdx - idx); + if (nextIdx === beats.length) { + let prevIdx = -1; + for (let i = idx - 1; i >= 0; i--) { + if (beats[i] && beats[i].measure > 0) { prevIdx = i; break; } + } + if (prevIdx >= 0) numerator = Math.max(1, idx - prevIdx); + } + const den = _tempoNormalizeDenominatorPure(downbeat.den); + const measure = downbeat.measure; + return { label: `M${measure} ${numerator}/${den}`, measure, numerator, denominator: den }; +} +/* @pure:measure-readout:end */ function updateBPMDisplay() { const el = document.getElementById('editor-bpm'); if (!el || S.beats.length < 2) return; @@ -6000,6 +6047,7 @@ function updateTempoSigDisplay() { } if (numEl) numEl.value = String(_tempoMeasureBeatCount(d)); if (denEl) denEl.value = String(_tempoMeasureDenominator(d)); + updateMeasureDisplay(); } // Defer a `resizeCanvas` until layout has settled — used when a diff --git a/tests/measure_readout.test.js b/tests/measure_readout.test.js new file mode 100644 index 00000000..259e4f1b --- /dev/null +++ b/tests/measure_readout.test.js @@ -0,0 +1,66 @@ +'use strict'; +/* + * Measure/signature readout helper tests for screen.js. + * + * Run: node tests/measure_readout.test.js + */ +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'); +const ts = src.match(/\/\* @pure:tempo-map-timesig:start \*\/[\s\S]*?\/\* @pure:tempo-map-timesig:end \*\//); +const mr = src.match(/\/\* @pure:measure-readout:start \*\/[\s\S]*?\/\* @pure:measure-readout:end \*\//); +if (!ts || !mr) { + console.error('FAIL: required pure blocks not found in screen.js'); + process.exit(1); +} + +const api = new Function( + '"use strict";' + ts[0] + '\n' + mr[0] + '\nreturn { _editorMeasureSignatureReadoutPure };' +)(); + +let pass = 0; +let fail = 0; +function t(name, fn) { + try { fn(); pass++; console.log(' ok ' + name); } + catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } +} + +const beats = [ + { time: 0, measure: 1, den: 4 }, + { time: 1, measure: -1 }, + { time: 2, measure: -1 }, + { time: 3, measure: -1 }, + { time: 4, measure: 2, den: 8 }, + { time: 5, measure: -1 }, + { time: 6, measure: -1 }, + { time: 7, measure: -1 }, + { time: 8, measure: 3 }, + { time: 9, measure: -1 }, + { time: 10, measure: -1 }, + { time: 11, measure: -1 }, +]; + +t('follows the cursor measure in note view', () => { + assert.deepStrictEqual(api._editorMeasureSignatureReadoutPure(beats, 4.25, -1), { + label: 'M2 4/8', measure: 2, numerator: 4, denominator: 8, + }); +}); + +t('prefers selected tempo sync point over cursor time', () => { + assert.strictEqual(api._editorMeasureSignatureReadoutPure(beats, 1.2, 4).label, 'M2 4/8'); +}); + +t('falls back to denominator 4 when no denominator is authored', () => { + assert.strictEqual(api._editorMeasureSignatureReadoutPure(beats, 8.1, -1).label, 'M3 4/4'); +}); + +t('returns empty label shape when there is no downbeat grid', () => { + assert.deepStrictEqual(api._editorMeasureSignatureReadoutPure([{ time: 0, measure: -1 }], 0, -1), { + label: 'M-- --', measure: null, numerator: null, denominator: null, + }); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); \ No newline at end of file