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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
its user guide. Drag the thumb, or click the rail to jump. When the grid
already fits, nothing changes: no bar, and the wheel keeps panning the
timeline exactly as before.
- **Leaving the Song Editor stops its playback.** Playback that was running
when you navigated away just kept going — leave the editor mid-play, start
an actual song, and you got two mixes on top of each other. The editor did
have a teardown that stops audio, but it only ran at the top of a *new*
injection, so it fired when you came *back*, never when you left. The
screen-visibility observer now handles the hide as well as the show. An
in-flight MIDI take is finalized rather than discarded (held notes capped,
device session released), and this is deliberately not the full teardown —
the host can re-show the same injection without re-running the module, so
the editor stays intact and resumable.

### Changed

Expand Down
40 changes: 35 additions & 5 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,25 @@
if (typeof _cancelPendingDraw === 'function') { try { _cancelPendingDraw(); } catch (_) {} }
};

// Leaving the Song Editor has to silence it. The Web Audio graph and the
// rAF transport both outlive the screen's DOM, so playback that was running
// when you navigated away just keeps going — testers hit this by starting
// playback, leaving the editor, and then launching an actual song, ending up
// with two mixes playing over each other.
//
// Deliberately NOT the full __editorScreenTeardown: the host hides a screen
// by dropping its `active` class, and can re-show that SAME injection without
// re-running this module. A full teardown would strip the global listeners
// and leave a dead editor behind. Stopping is exactly what the Stop button
// does, so the session stays intact and resumable.
function _editorOnScreenHidden() {
// An in-flight MIDI take finalizes rather than vanishing — this caps held
// notes, stops playback and releases the MIDI session. No-op when idle.
try { editorStopRecordMidi(); } catch (_) { /* never block navigation */ }
// Catch-all: covers plain playback, and is idempotent after the above.
try { stopPlayback(); } catch (_) { /* never block navigation */ }
}

// Handle Enter key in add-note dialog
_globalListeners.add(document, 'keydown', (e) => {
if (e.key === 'Enter' && addNoteData) {
Expand Down Expand Up @@ -1829,7 +1848,7 @@
// the same save path as the Save button (in-place sloppak write, not the
// heavy create-mode build).
if (S.sessionId) {
try { await saveCDLC(); } catch (e) { /* surfaced via setStatus */ }

Check warning on line 1851 in src/main.js

View workflow job for this annotation

GitHub Actions / lint

'e' is defined but never used. Allowed unused caught errors must match /^_/u
}
// Capture where we are so the return trip lands on the same spot.
const returnCtx = {
Expand Down Expand Up @@ -2137,17 +2156,28 @@
initToolbars();
initMixerPanel();

// Observe screen visibility for resize + the entry landing. Held in
// _editorScreenObs so the teardown can disconnect it on re-injection.
// Observe screen visibility for resize + the entry landing, and — the
// other direction — to silence the editor when you navigate away. Held
// in _editorScreenObs so the teardown can disconnect it on re-injection.
const screen = document.getElementById('plugin-editor');
// Seeded from the CURRENT state so the first mutation can't read as a
// transition that never happened.
let wasActive = !!(screen && screen.classList.contains('active'));
const obs = new MutationObserver(() => {
const screen = document.getElementById('plugin-editor');
if (screen && screen.classList.contains('active')) {
if (!screen) return;
const active = screen.classList.contains('active');
// The filter is `class`, not one specific class, so ignore unrelated
// class churn — this must only act on a real show/hide transition.
if (active === wasActive) return;
wasActive = active;
if (active) {
setTimeout(resizeCanvas, 50);
// Entering the Song Editor with nothing loaded → offer Load / Create.
setTimeout(_editorMaybeShowStartLanding, 80);
} else {
_editorOnScreenHidden();
}
});
const screen = document.getElementById('plugin-editor');
if (screen) obs.observe(screen, { attributes: true, attributeFilter: ['class'] });
_editorScreenObs = obs;
// Also cover the case where the editor screen is already active at init().
Expand Down
155 changes: 155 additions & 0 deletions tests/leaving_stops_playback.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/*
* Leaving the Song Editor must silence it.
*
* The Web Audio graph and the rAF transport outlive the screen's DOM, and the
* host hides a screen by dropping its `active` class — it does not re-run the
* editor module. __editorScreenTeardown (which DOES stop audio) only runs at
* the top of a NEW injection, so navigating away left playback running: start
* playback, leave the editor, launch an actual song, and you get two mixes at
* once. Reported by Christian 2026-07-19.
*
* Pins two things by brace-extracting the real source from src/main.js
* (main.js is the entry orchestrator and exports nothing, so the sliced-env
* convention is the only way in — same as tests/boot_teardown.test.js):
*
* 1. _editorOnScreenHidden stops a MIDI take AND playback, and neither
* throw escapes to block navigation.
* 2. The screen observer calls it on active->inactive, does NOT call it on
* inactive->active, and ignores unrelated class churn.
*
* Run: node --test tests/leaving_stops_playback.test.mjs
*/
import assert from 'node:assert';
import fs from 'node:fs';
import test from 'node:test';

const src = fs.readFileSync(new URL('../src/main.js', import.meta.url), 'utf8');

function extractFn(name) {
const start = src.indexOf('function ' + name);
assert.ok(start >= 0, `function ${name} must exist in src/main.js`);
const open = src.indexOf('{', start);
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);
}
throw new Error('unbalanced braces extracting ' + name);
}

function makeHidden(over = {}) {
const calls = { stopRec: 0, stopPlay: 0 };
const deps = {
editorStopRecordMidi: over.editorStopRecordMidi
|| (() => { calls.stopRec++; }),
stopPlayback: over.stopPlayback || (() => { calls.stopPlay++; }),
};
const names = Object.keys(deps);
const fn = new Function(
...names,
'"use strict";' + extractFn('_editorOnScreenHidden') + '\nreturn _editorOnScreenHidden;',
)(...names.map((n) => deps[n]));
return { fn, calls };
}

test('the screen observer is wired to silence the editor on hide', () => {
// The guard that names the bug. On pre-fix main the observer has only an
// active branch, so nothing ever stops playback when you navigate away —
// and every behavioural test below would pass against a handler that is
// never called.
// Anchor on the SCREEN observer specifically — main.js has more than one
// MutationObserver (the v3 topbar watcher comes first in the file), and a
// naive indexOf + fixed-window slice reads the wrong one, so this guard
// would pass or fail for reasons having nothing to do with the fix.
const at = src.indexOf('const obs = new MutationObserver');
assert.ok(at >= 0, 'the screen observer must exist');
const end = src.indexOf('_editorScreenObs = obs', at);
assert.ok(end > at, 'the screen observer must be held in _editorScreenObs');
const body = src.slice(at, end);
assert.ok(
body.includes('_editorOnScreenHidden()'),
'the screen observer must call _editorOnScreenHidden when the editor stops being active',
);
});

test('leaving stops both the MIDI take and playback', () => {
const { fn, calls } = makeHidden();
fn();
assert.strictEqual(calls.stopRec, 1, 'an in-flight take must be finalized');
assert.strictEqual(calls.stopPlay, 1, 'playback must be stopped');
});

test('a throwing take-stop still stops playback and never blocks navigation', () => {
// If finalizing the take blew up and took the whole handler with it, the
// audio would keep playing — the exact bug, with extra steps.
const calls = { stopPlay: 0 };
const { fn } = makeHidden({
editorStopRecordMidi: () => { throw new Error('midi device vanished'); },
stopPlayback: () => { calls.stopPlay++; },
});
assert.doesNotThrow(() => fn());
assert.strictEqual(calls.stopPlay, 1, 'playback still stopped after a take-stop failure');
});

test('a throwing stopPlayback never escapes to block navigation', () => {
const { fn } = makeHidden({
stopPlayback: () => { throw new Error('no audio context'); },
});
assert.doesNotThrow(() => fn());
});

// ── The observer wiring ──────────────────────────────────────────────
// Rebuilt from the same shape as init()'s MutationObserver callback. This
// pins the transition logic; the extraction above pins what it calls.

function makeObserverCallback(startActive) {
const calls = { hidden: 0, resize: 0, landing: 0 };
let active = startActive;
let wasActive = startActive;
const el = { classList: { contains: () => active } };
const cb = () => {
if (!el) return;
const now = el.classList.contains();
if (now === wasActive) return;
wasActive = now;
if (now) { calls.resize++; calls.landing++; } else { calls.hidden++; }
};
return { cb, calls, setActive: (v) => { active = v; } };
}

test('active -> inactive silences the editor', () => {
const o = makeObserverCallback(true);
o.setActive(false);
o.cb();
assert.strictEqual(o.calls.hidden, 1);
});

test('inactive -> active does NOT silence (it is an entry, not an exit)', () => {
const o = makeObserverCallback(false);
o.setActive(true);
o.cb();
assert.strictEqual(o.calls.hidden, 0, 'entering must never stop playback');
assert.strictEqual(o.calls.resize, 1, 'entering still resizes the canvas');
});

test('unrelated class churn while hidden does not re-fire the stop', () => {
// attributeFilter is `class`, not one specific class — any class change on
// #plugin-editor wakes the observer. Without the transition guard this
// would call stopPlayback on every such mutation.
const o = makeObserverCallback(true);
o.setActive(false);
o.cb();
o.cb();
o.cb();
assert.strictEqual(o.calls.hidden, 1, 'only the real transition counts');
});

test('re-showing the same injection works after a hide', () => {
// The host may re-show this injection without re-running the module, so
// the observer has to survive a full hide/show cycle.
const o = makeObserverCallback(true);
o.setActive(false); o.cb();
o.setActive(true); o.cb();
assert.strictEqual(o.calls.hidden, 1);
assert.strictEqual(o.calls.resize, 1, 'coming back still resizes');
});
Loading