Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
unchanged. Tests: `tests/tempo_beat_drag.test.js`.

### Added
- **Onset detection strip (Shift+W / the new "Onsets" toolbar toggle).**
Amber blocks over the waveform band mark where sharp attacks are detected
in the recording — a visual hint of where notes/beats likely live while
charting by eye. Detection runs client-side from the existing waveform RMS
cache (no server round-trip): an onset fires where loudness rises sharply
above the local baseline, gated by a noise floor and a ~50 ms refractory
gap so one attack registers once; block brightness/height scale with
attack strength. Works as an overlay with the waveform on, or as a pure
"blocky" view with the waveform hidden (W off + Onsets on). Display only —
the strip never places notes. The analysis is cached per audio load and
recomputed on replace. Tests: `tests/onset_strip.test.js`.
- **Import GoPlayAlong projects (`.gp` + audio + a GoPlayAlong `.xml`).** A GoPlayAlong export is a `<track>` **sync sidecar** — it points at a Guitar Pro score and an audio file and stores the bar→audio sync points, but carries **no chart** — so feeding it to the arrangement importer failed with "not a recognised EOF arrangement XML". Now, in the New-dialog Content Import, drop the Guitar Pro tab + the audio + the GoPlayAlong `.xml` together: the `.xml` is content-sniffed and staged as a **GoPlayAlong sync source** (not mistaken for an EOF arrangement, and it prefills title/artist), and on Import the editor applies GoPlayAlong's **authored** per-bar sync instead of re-deriving it via onset detection. Under the hood: new `goplayalong.py` parser + `/api/plugins/editor/parse-goplayalong-sync` endpoint emit the same `sync_points` / `audio_offset` shape `autosync-gp` / `extract-gp-sync` return, which the existing `convert-gp` warp path consumes (`sync_applied: "warp"`). All GoPlayAlong UI logic is gated on a staged sidecar, so normal GP/EOF/audio imports are byte-for-byte unchanged. Tests: `tests/test_goplayalong.py` (10 cases vs a real export). Verified end-to-end against a real GoPlayAlong project ("Would?" — Alice in Chains): 73 sync points → the referenced `.gp` (87 bars, 7 tracks) → 73 warp anchors → a monotonic per-bar warp.
- **GP import with auto-sync now applies the full per-bar sync map (Songsterr-style), and the auto-sync audio can come from a YouTube URL.** Previously the auto-sync flow computed per-bar sync points but `convert-gp` applied only the scalar bar-1 offset, so recordings that drift from the tab's authored tempo went audibly out of sync over the song. Now: `convert-gp` accepts the `sync_points` payload back from the client and warps the whole converted chart (notes, sustains, beats, sections, handshapes, phrase levels, keys notation sidecars) onto the recording's timeline via core's new `lib.gp_autosync` warp helpers, responding with `sync_applied: "warp"`; it falls back to the scalar offset (`sync_applied: "offset"`) for GP3/4/5 files that use repeats/voltas/directions (their playback expansion can't be mapped from as-written sync points), when anchors degenerate, or when core lacks the new helpers. The create flow auto-runs the refine pass (onset phase sweep — requires the new core `refine_sync`, which this endpoint always imported but which never existed until now) right after the coarse DTW sync, and both refine calls send `gp_path` so refinement uses exact per-bar score times instead of a 4/4 approximation. The auto-sync section gains a YouTube URL input (reusing `/youtube-audio`) beside the file upload; the fetched audio becomes both the alignment target and the imported song audio.
- **Coarse triplet snap divisions — `1/3T` and `1/6T`.** The snap grid now offers
Expand Down
1 change: 1 addition & 0 deletions screen.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
<button onclick="editorZoom(-1)" class="px-1.5 py-0.5 bg-dark-600 hover:bg-dark-500 rounded text-xs">-</button>
<span id="editor-zoom-display" class="text-xs text-gray-400 w-8 text-center">100</span>
<button onclick="editorZoom(1)" class="px-1.5 py-0.5 bg-dark-600 hover:bg-dark-500 rounded text-xs">+</button>
<button id="editor-onset-btn" onclick="editorToggleOnsetStrip()" class="px-2 py-1 bg-dark-600 hover:bg-dark-500 rounded text-xs font-medium" title="Onset strip: amber blocks mark detected attacks in the recording (Shift+W). Display only — never places notes." aria-pressed="false">Onsets</button>

<div class="h-4 w-px bg-gray-700"></div>

Expand Down
145 changes: 143 additions & 2 deletions screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -1563,10 +1563,19 @@ function drawNow() {
function drawWaveform(w) {
ctx.fillStyle = '#08081a';
ctx.fillRect(0, 0, w, WAVEFORM_H);
if (typeof editorWaveformVisible !== 'undefined' && !editorWaveformVisible) return;
// The onset strip is independent of the waveform toggle: waveform off +
// onsets on = the pure "blocky" detection view; both on = an overlay.
// (typeof guards keep drawWaveform extractable by the render test.)
const drawOnsets = () => {
if (typeof _drawOnsetStrip === 'function') _drawOnsetStrip(w);
};
if (typeof editorWaveformVisible !== 'undefined' && !editorWaveformVisible) {
drawOnsets();
return;
}
const pk = S.waveformPeaks;
const dur = S.duration || 0;
if (!pk || !pk.bins || dur <= 0) return;
if (!pk || !pk.bins || dur <= 0) { drawOnsets(); return; }

const N = pk.bins;
const mid = WAVEFORM_H / 2;
Expand Down Expand Up @@ -1619,6 +1628,40 @@ function drawWaveform(w) {
const h = (cnt ? Math.sqrt(sumSq / cnt) : 0) * amp;
if (h > 0.5) ctx.fillRect(px, mid - h, 1, Math.max(1, 2 * h));
}

drawOnsets();
}

// Detected-onset blocks over the waveform band — a visual hint of where
// transients (likely note/beat events) live in the recording. Display
// only: the strip never places notes (D22).
function _drawOnsetStrip(w) {
if (!_onsetStripEnabled()) return;
const onsets = _ensureOnsets();
if (!onsets || !onsets.length) return;
const dur = S.duration || 0;
if (dur <= 0) return;
const xLo = Math.max(LABEL_W, Math.floor(timeToX(0)));
const xHi = Math.min(w, Math.ceil(timeToX(dur)));
// onsets are time-sorted and timeToX is monotonic, so the on-screen pixel
// is non-decreasing across the array. Binary-search the first visible
// onset (px >= xLo) and stop at the first past xHi — no full-array scan.
let lo = 0, hi = onsets.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (Math.round(timeToX(onsets[mid].t)) < xLo) lo = mid + 1;
else hi = mid;
}
for (let i = lo; i < onsets.length; i++) {
const o = onsets[i];
const px = Math.round(timeToX(o.t));
if (px > xHi) break;
// Stronger attacks read brighter and taller — quiet ghost hits stay
// visible but understated.
ctx.fillStyle = `rgba(255,190,80,${(0.30 + 0.45 * o.s).toFixed(3)})`;
const h = Math.round((WAVEFORM_H - 6) * (0.55 + 0.45 * o.s));
ctx.fillRect(px - 1, WAVEFORM_H - 3 - h, 3, h);
}
}

function drawLanes(w) {
Expand Down Expand Up @@ -3470,6 +3513,7 @@ const EDITOR_SHORTCUT_COMMANDS = Object.freeze([
{ id: 'toggleWaveform', label: 'Show/hide waveform', group: 'View', status: 'ready', keys: { feedback: 'W', eof: 'F5' } },
{ id: 'toggleGuideClap', label: 'Toggle guide claps', group: 'Preview', status: 'ready', keys: { feedback: 'C', eof: 'C' } },
{ id: 'toggleMetronome', label: 'Toggle metronome click', group: 'Preview', status: 'ready', keys: { feedback: '', eof: '' } },
{ id: 'toggleOnsetStrip', label: 'Toggle onset detection strip', group: 'View', status: 'ready', keys: { feedback: 'Shift+W', eof: 'Shift+W' } },
{ id: 'showShortcutHelp', label: 'Show shortcut help', group: 'View', status: 'ready', keys: { feedback: '?', eof: '?' } },
{ id: 'openCommandPalette', label: 'Open command palette', group: 'View', status: 'ready', keys: { feedback: 'Ctrl+K', eof: 'Ctrl+K' } },
{ id: 'importMidi', label: 'Import MIDI / keys', group: 'File', status: 'ready', keys: { feedback: '', eof: 'F6' } },
Expand Down Expand Up @@ -3586,6 +3630,7 @@ function _editorEofCommandForKeyPure(e, mode) {
if (sig === 'F2') return 'save';
if (sig === 'F5') return 'toggleWaveform';
if (plain && key === 'c') return 'toggleGuideClap';
if (shift && key === 'w') return 'toggleOnsetStrip';
if (shift && key === '?') return 'showShortcutHelp';
if (ctrl && key === 'k') return 'openCommandPalette';
if (sig === 'F6') return 'importMidi';
Expand Down Expand Up @@ -3697,6 +3742,7 @@ function _editorFeedbackCommandForKeyPure(e, mode) {
if (ctrl && key === 's') return 'save';
if (plain && key === 'w') return 'toggleWaveform';
if (plain && key === 'c') return 'toggleGuideClap';
if (shift && key === 'w') return 'toggleOnsetStrip';
if (shift && key === '?') return 'showShortcutHelp';
if (ctrl && key === 'k') return 'openCommandPalette';
if (sig === 'PageUp') return 'prevBeat';
Expand Down Expand Up @@ -4273,6 +4319,7 @@ function _editorRunEofCommand(cmd) {
case 'toggleWaveform': return _editorToggleWaveform();
case 'toggleGuideClap': return _editorToggleGuideClap();
case 'toggleMetronome': return _editorToggleMetronome();
case 'toggleOnsetStrip': return _editorToggleOnsetStrip();
case 'showShortcutHelp': return _editorShowShortcutDiscovery('Shortcut help');
case 'openCommandPalette': return _editorShowShortcutDiscovery('Command palette');
case 'importMidi': _editorOpenImportMidi(); return true;
Expand Down Expand Up @@ -5269,7 +5316,101 @@ function computeWaveform() {
// zoom, yet bounded (≈1 MB of typed arrays for a 5-minute song).
const binSamples = Math.max(64, Math.round(S.audioBuffer.sampleRate * 0.003));
S.waveformPeaks = _buildWaveformPeaks(data, binSamples);
// New audio ⇒ any cached onset analysis is stale.
_onsetCache = null;
}

/* @pure:onset-strip:start */
// Transient/onset estimation from the waveform RMS cache — a cheap
// client-side "where do events probably live" hint (no server round-trip,
// no DSP deps). An onset fires where the RMS rises sharply above the local
// baseline (the mean of the preceding window), gated by an absolute noise
// floor and a refractory gap so one attack registers once. Returns
// [{t, s}] — time in seconds and a 0..1 strength.
function _onsetTimesFromPeaksPure(rms, binSec, opts) {
if (!rms || !rms.length || !(binSec > 0)) return [];
const o = opts || {};
const baselineBins = Math.max(2, o.baselineBins || 16);
const ratio = o.ratio || 1.5;
const floorFrac = o.floorFrac || 0.05;
const riseFrac = o.riseFrac || 0.03;
const minGapSec = o.minGapSec || 0.05;
let global = 0;
for (let i = 0; i < rms.length; i++) if (rms[i] > global) global = rms[i];
if (!(global > 0)) return [];
const floor = global * floorFrac;
const refractory = Math.max(1, Math.round(minGapSec / binSec));
const out = [];
let sum = 0;
for (let i = 0; i < Math.min(baselineBins, rms.length); i++) sum += rms[i];
let lastOnset = -Infinity;
for (let i = baselineBins; i < rms.length; i++) {
const base = sum / baselineBins;
const v = rms[i];
if (v > floor && v > rms[i - 1]
&& v > base * ratio && v - base > global * riseFrac
&& i - lastOnset >= refractory) {
out.push({
t: i * binSec,
s: Math.max(0, Math.min(1, (v - base) / global)),
});
lastOnset = i;
}
// Slide the baseline window.
sum += v - rms[i - baselineBins];
}
return out;
}
/* @pure:onset-strip:end */

// ── Onset strip toggle + lazy cache ──────────────────────────────────
let _onsetCache = null; // [{t, s}] for the CURRENT waveformPeaks
let _onsetStripOn = null; // cached enabled flag; null until first read

function _onsetStripEnabled() {
// Cache the flag so the draw path (every frame during playback) doesn't
// hit localStorage synchronously. Seeded once from storage, then kept in
// sync by _editorToggleOnsetStrip.
if (_onsetStripOn === null) {
try { _onsetStripOn = localStorage.getItem('editorOnsetStrip') === '1'; }
catch (_) { _onsetStripOn = false; }
}
return _onsetStripOn;
}

function _ensureOnsets() {
if (_onsetCache) return _onsetCache;
const pk = S.waveformPeaks;
const dur = S.duration || 0;
if (!pk || !pk.bins || !pk.rms || dur <= 0) return null;
_onsetCache = _onsetTimesFromPeaksPure(pk.rms, dur / pk.bins);
return _onsetCache;
}

function _refreshOnsetBtn() {
const btn = document.getElementById('editor-onset-btn');
if (!btn) return;
const on = _onsetStripEnabled();
btn.classList.toggle('bg-accent', on);
btn.classList.toggle('hover:bg-accent-light', on);
btn.classList.toggle('bg-dark-600', !on);
btn.classList.toggle('hover:bg-dark-500', !on);
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
}

function _editorToggleOnsetStrip() {
const next = !_onsetStripEnabled();
_onsetStripOn = next;
try { localStorage.setItem('editorOnsetStrip', next ? '1' : '0'); } catch (_) {}
_refreshOnsetBtn();
draw();
setStatus(next
? 'Onset strip on — amber blocks mark detected attacks in the recording (display only)'
: 'Onset strip off');
return true;
}
window.editorToggleOnsetStrip = _editorToggleOnsetStrip;
_refreshOnsetBtn();

function _startAudioSourceAtCursor() {
S.audioSource = S.audioCtx.createBufferSource();
Expand Down
96 changes: 96 additions & 0 deletions tests/onset_strip.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
'use strict';
/*
* Tests for the onset strip's pure detector (@pure:onset-strip block):
* _onsetTimesFromPeaksPure estimates transient positions from the waveform
* RMS cache — the display-only "blocky" hint of where events likely live
* in the recording (D22: never places notes).
*
* Detection rule under test: an onset fires where RMS rises sharply above
* the local baseline (mean of the preceding window), gated by an absolute
* noise floor and a refractory gap so one attack registers exactly once.
*
* Run: node tests/onset_strip.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 m = src.match(/\/\* @pure:onset-strip:start \*\/[\s\S]*?\/\* @pure:onset-strip:end \*\//);
if (!m) {
console.error('FAIL: @pure:onset-strip block not found in screen.js');
process.exit(1);
}
const { _onsetTimesFromPeaksPure } = new Function(
'"use strict";' + m[0] + '\nreturn { _onsetTimesFromPeaksPure };'
)();

let pass = 0, fail = 0;
function t(name, fn) {
try { fn(); pass++; console.log(' ok ' + name); }
catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); }
}

const BIN = 0.003; // ~3 ms bins, matching computeWaveform

// Quiet floor with an attack+decay burst at each given bin index.
function signal(len, bursts, quiet = 0.02, peak = 0.9) {
const rms = new Float32Array(len).fill(quiet);
for (const b of bursts) {
for (let k = 0; k < 20 && b + k < len; k++) {
rms[b + k] = Math.max(rms[b + k], peak * Math.pow(0.82, k));
}
}
return rms;
}

t('detects each isolated attack exactly once', () => {
const rms = signal(2000, [200, 700, 1300]);
const out = _onsetTimesFromPeaksPure(rms, BIN);
assert.strictEqual(out.length, 3);
const times = out.map(o => o.t);
assert.ok(Math.abs(times[0] - 200 * BIN) < 0.01);
assert.ok(Math.abs(times[1] - 700 * BIN) < 0.01);
assert.ok(Math.abs(times[2] - 1300 * BIN) < 0.01);
});

t('refractory gap: bins inside one attack do not double-fire', () => {
// A single 60 ms-wide burst — the decay bins must not re-trigger.
const rms = signal(600, [300]);
assert.strictEqual(_onsetTimesFromPeaksPure(rms, BIN).length, 1);
});

t('two hits past the refractory gap both fire (drum flam spacing ~60 ms)', () => {
const rms = signal(800, [300, 322]); // 22 bins ≈ 66 ms apart
assert.strictEqual(_onsetTimesFromPeaksPure(rms, BIN).length, 2);
});

t('silence and near-silence produce no onsets', () => {
assert.deepStrictEqual(_onsetTimesFromPeaksPure(new Float32Array(500), BIN), []);
const noise = new Float32Array(500).fill(0.001);
assert.deepStrictEqual(_onsetTimesFromPeaksPure(noise, BIN), []);
});

t('a slow swell (no sharp rise) is not an onset', () => {
const rms = new Float32Array(1000);
for (let i = 0; i < 1000; i++) rms[i] = 0.9 * (i / 1000); // linear ramp
assert.deepStrictEqual(_onsetTimesFromPeaksPure(rms, BIN), []);
});

t('strength scales with attack size', () => {
const rms = signal(1200, [200], 0.02, 0.9);
for (let k = 0; k < 20; k++) rms[800 + k] = Math.max(rms[800 + k], 0.25 * Math.pow(0.82, k));
const out = _onsetTimesFromPeaksPure(rms, BIN);
assert.strictEqual(out.length, 2);
assert.ok(out[0].s > out[1].s, 'louder attack → higher strength');
for (const o of out) assert.ok(o.s > 0 && o.s <= 1);
});

t('degenerate inputs return []', () => {
assert.deepStrictEqual(_onsetTimesFromPeaksPure(null, BIN), []);
assert.deepStrictEqual(_onsetTimesFromPeaksPure(new Float32Array(0), BIN), []);
assert.deepStrictEqual(_onsetTimesFromPeaksPure(new Float32Array(100).fill(0.5), 0), []);
});

console.log(`\n${pass} passed, ${fail} failed`);
if (fail) process.exit(1);