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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Foolproof editor job transitions + Save As.** Opening another feedpak
or starting New now offers Save / Don't Save / Cancel when the current
job is dirty; a failed save blocks the transition. Active MIDI takes are
finalized first, then playback, scheduled voices, pending audio/load
requests, drags and the outgoing backend session are stopped before the
replacement job is installed. File -> Save As opens the native system
picker where available (download fallback) and mirrors later saves to
that chosen external copy for the rest of the session.
- **Authoritative musical-ruler tempo mapping.** Tempo Map's primary action is
now **Mark barline**: inside the mapped range it preserves the existing split
behavior, while a mark beyond the final beat closes the open measure at the
Expand Down Expand Up @@ -267,6 +275,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Loading a new feedpak while the old recording was playing no longer
leaves the old AudioBufferSource sounding under the new song. Audio-less
packs also clear the previous decoded buffer, and overlapping load/audio
requests cannot install stale results out of order. The same teardown now
also runs when a Guitar Pro / EOF **import** replaces the job (it took a
different code path than Open feedpak), so an audio-less import can no
longer inherit the previous recording, and the outgoing backend session is
disposed instead of leaked.
- The session-transition confirm prompt's Escape listener now rides the
screen teardown registry, and dismissing it resolves the pending prompt —
a re-injected editor screen can no longer strand an in-flight transition.
- Choosing the "New" format picker on a dirty job no longer double-prompts to
save (the picker already guarded the transition; the format buttons stopped
re-guarding).
- **The screen teardown left the guide/metronome timer running.** The audio
extraction (below) surfaced it: the old inline teardown cancelled the audio
source and the rAF frame but not the `setInterval` that schedules guide claps,
Expand Down
38 changes: 38 additions & 0 deletions routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3041,6 +3041,44 @@ def _safe_storage_asset(p) -> str:
global _sessions
_sessions = sessions

def _dispose_editor_session(session_id: str) -> bool:
session = sessions.pop(session_id, None)
if not session:
return False
# Native sloppak sessions point at the shared extraction cache; never
# remove that tree. Archive/create sessions own temporary sandboxes.
if session.get("format") != "sloppak":
shutil.rmtree(session.get("dir", ""), ignore_errors=True)
return True

@app.post("/api/plugins/editor/session/close")
async def close_editor_session(data: dict):
session_id = str(data.get("session_id") or "")
return {"closed": _dispose_editor_session(session_id)}

@app.get("/api/plugins/editor/session/export")
async def export_editor_session(session_id: str):
session = sessions.get(session_id)
if not session:
return JSONResponse({"error": "No active session"}, 404)
filename = str(session.get("filename") or "")
dlc_dir = get_dlc_dir()
if not filename or not dlc_dir:
return JSONResponse({"error": "Session has no saved feedpak"}, 409)
root = dlc_dir.resolve()
candidate = (root / filename).resolve()
try:
candidate.relative_to(root)
except ValueError:
return JSONResponse({"error": "forbidden"}, 403)
if not candidate.is_file():
return JSONResponse({"error": "Saved feedpak is not a packed file"}, 409)
Comment on lines +3059 to +3075

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Support authoring-form packages in export.

Save As calls this endpoint after saveCDLC(), but directory-form sloppaks intentionally save in place and remain directories. This is_file() check therefore returns 409 for valid .feedpak/ or .sloppak/ sessions, so Save As fails. Zip the directory for export or materialize a packed file before exporting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@routes.py` around lines 3059 - 3075, The export_editor_session endpoint must
support valid directory-form .feedpak and .sloppak sessions instead of rejecting
them via candidate.is_file(). Preserve the existing path-safety checks, and when
candidate is a directory, zip or otherwise materialize it into a packed file for
export; continue returning the current conflict response only for missing or
unsupported saved paths.

return FileResponse(
candidate,
media_type="application/zip",
filename=candidate.name,
)

# Cache compat probes for the slopsmith core converter signatures —
# each function is stable for the process lifetime, so the
# inspect.signature call only needs to run once per converter.
Expand Down
3 changes: 2 additions & 1 deletion src/arrangement.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// stays in main.js (draw, updateStatus, updateArrangementSelector,
// effectiveAudioOffset) routes through host.

import { S } from './state.js';
import { S, markSessionDirty } from './state.js';
import { _editorEscHtml, _editorPromptText, setStatus } from './ui.js';
import { flattenChords } from './chords.js';
import { KEYS_PATTERN } from './keys.js';
Expand Down Expand Up @@ -188,6 +188,7 @@ export async function editorRemoveArrangement() {

// Then update frontend state
S.arrangements.splice(removeIdx, 1);
markSessionDirty();
// The splice renumbers every arrangement after removeIdx, so history
// commands tagged with the old indices (and the note indices inside them)
// would undo into the wrong arrangement. Same rationale as the
Expand Down
28 changes: 24 additions & 4 deletions src/audio.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import { setStatus } from './ui.js';
// The rAF handle for the playback loop. Module-scope so playbackTick and
// teardownAudio share it; main.js reaches the cancel through teardownAudio().
let rafId = null;
let audioLoadController = null;
let audioLoadGeneration = 0;

// Lazily create the shared AudioContext. Compose mode never decodes a
// recording (loadAudio is the only other creation site), yet the transport
Expand All @@ -52,20 +54,37 @@ function _ensureAudioCtx() {
}

export async function loadAudio(url) {
if (!url) return;
if (!url) return false;
cancelAudioLoad();
const generation = audioLoadGeneration;
audioLoadController = typeof AbortController === 'function' ? new AbortController() : null;
try {
_ensureAudioCtx();
const resp = await fetch(url);
const resp = await fetch(url, audioLoadController ? { signal: audioLoadController.signal } : undefined);
const buf = await resp.arrayBuffer();
S.audioBuffer = await S.audioCtx.decodeAudioData(buf);
const decoded = await S.audioCtx.decodeAudioData(buf);
if (generation !== audioLoadGeneration) return false;
S.audioBuffer = decoded;
S.duration = S.audioBuffer.duration;
// A new recording is loaded — re-arm the hearing-safety fade so it
// applies to this recording too, not just the session's first one.
_mixResetFirstPlay();
host.editorApplyScrollBounds();
computeWaveform();
return true;
} catch (e) {
console.error('Audio load error:', e);
if (e && e.name !== 'AbortError') console.error('Audio load error:', e);
return false;
} finally {
if (generation === audioLoadGeneration) audioLoadController = null;
}
}

export function cancelAudioLoad() {
audioLoadGeneration++;
if (audioLoadController) {
try { audioLoadController.abort(); } catch (_) {}
audioLoadController = null;
}
}

Expand Down Expand Up @@ -1175,6 +1194,7 @@ export function initAudio() {
// S.playing and syncing drops it — _guideTimerSync stops the timer when nothing
// wants it — and _guideCancelVoices silences any queued oscillators (Codex).
export function teardownAudio() {
cancelAudioLoad();
try { if (S.audioSource) { S.audioSource.stop(); S.audioSource = null; } } catch (_) { /* already stopped */ }
try { if (rafId) { cancelAnimationFrame(rafId); rafId = null; } } catch (_) { /* no frame queued */ }
S.playing = false;
Expand Down
26 changes: 21 additions & 5 deletions src/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
import { host } from './host.js';
import { KEYS_PATTERN, isKeysMode, updatePianoRange } from './keys.js';
import { _seedExtendedStringsFromTuning } from './lanes.js';
import { S } from './state.js';
import { S, markSessionDirty } from './state.js';
import { disposeBackendSession, stopSessionProcesses } from './session-lifecycle.js';
import { _liftAllBeats, _restoreBeatLocks, _syncAppliedMessagePure } from './tempo.js';
import { _editorEscHtml, _installModalKeyboard, setStatus } from './ui.js';

Expand Down Expand Up @@ -88,13 +89,15 @@
'🎵 Blank — start from audio',
'Audio + an empty arrangement (drum tab optional). Chart it yourself '
+ 'in the editor. No Guitar Pro / XML needed.',
() => { window.editorShowCreateModal(); window.editorSetCreateMode('blank'); },
// Raw opener, not window.editorShowCreateModal: the transition was
// already guarded when this picker opened; the wrapper would re-prompt.
() => { editorShowCreateModal(); window.editorSetCreateMode('blank'); },
));
inner.appendChild(mkBtn(
'🎸 Import from Guitar Pro',
'Build a chart from a Guitar Pro file (.gp3–.gp8), saved as a native '
+ '.feedpak.',
() => { window.editorShowCreateModal(); window.editorSetCreateMode('gp'); },
() => { editorShowCreateModal(); window.editorSetCreateMode('gp'); },
));

const cancel = document.createElement('div');
Expand Down Expand Up @@ -426,7 +429,7 @@
// Open the freshly-written sloppak via the existing load
// path so the editor state initialises identically to a
// normal sloppak load.
await host.loadCDLC(data.filename);
await host.loadCDLC(data.filename, { skipGuard: true });
} catch (e) {
status.textContent = 'Failed: ' + e.message;
status.className = 'text-xs mb-2 min-h-[1em] text-red-400';
Expand Down Expand Up @@ -1572,7 +1575,7 @@
// Left in place rather than deleted, because deleting them is a separate change
// from the bug fix that made them redundant. They arrived with the same
// half-wired Create-New redesign (977ec65, #45).
function _populateCreateArrButtons() {

Check warning on line 1578 in src/create.js

View workflow job for this annotation

GitHub Actions / lint

'_populateCreateArrButtons' is defined but never used
const wrap = document.getElementById('editor-create-arr-buttons');
if (!wrap) return;
wrap.replaceChildren();
Expand Down Expand Up @@ -1745,7 +1748,7 @@
createState.lastSync = { ...createState.lastSync, ...data };
}
return data;
} catch (e) {

Check warning on line 1751 in src/create.js

View workflow job for this annotation

GitHub Actions / lint

'e' is defined but never used. Allowed unused caught errors must match /^_/u
return null;
}
}
Expand Down Expand Up @@ -2119,7 +2122,7 @@
}
editorHideCreateModal();
host.kickLibraryRescan();
await host.loadCDLC(data.filename);
await host.loadCDLC(data.filename, { skipGuard: true });
} catch (e) {
if (status) status.textContent = 'Failed: ' + e.message;
if (btn) btn.disabled = false;
Expand All @@ -2136,10 +2139,23 @@

// Load into editor
window.editorHideCreateModal();
// Same outgoing-job teardown loadCDLC performs: stop the old playback,
// the pending audio load and any drag, and drop the decoded buffer. An
// audio-less import skips the loadAudio() branch below, so without this the
// previous recording keeps sounding under the new chart and S.audioBuffer
// stays stale. Dispose the old backend session too so its sandbox isn't leaked.
const oldSessionId = S.sessionId;
stopSessionProcesses(); // also cancels the outgoing audio load
S.audioBuffer = null;
S.waveformPeaks = null;
S.title = data.title || '';
S.artist = data.artist || '';
S.filename = '';
S.sessionId = data.session_id;
if (oldSessionId && oldSessionId !== data.session_id) {
await disposeBackendSession(oldSessionId);
}
markSessionDirty();
Comment on lines +2142 to +2158

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Blocking await on a timeout-less network call mid-transition.

disposeBackendSession (session-lifecycle.js) does a plain await fetch(...) with no AbortController/timeout. Awaiting it here, before the remaining S.format/S.arrangements/DOM updates and host.loadAudio, means a slow or hung backend /session/close call stalls the entire "apply create result" flow — the create modal is already hidden (line 2141) but the new session's UI (title, buttons, arrangement view) won't render until the fetch settles. Since disposeBackendSession already treats this as best-effort and swallows errors, there's no need to block on it here.

🔧 Proposed fix: don't block the UI transition on backend cleanup
     S.sessionId = data.session_id;
     if (oldSessionId && oldSessionId !== data.session_id) {
-        await disposeBackendSession(oldSessionId);
+        disposeBackendSession(oldSessionId); // best-effort, fire-and-forget
     }
     markSessionDirty();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Same outgoing-job teardown loadCDLC performs: stop the old playback,
// the pending audio load and any drag, and drop the decoded buffer. An
// audio-less import skips the loadAudio() branch below, so without this the
// previous recording keeps sounding under the new chart and S.audioBuffer
// stays stale. Dispose the old backend session too so its sandbox isn't leaked.
const oldSessionId = S.sessionId;
stopSessionProcesses(); // also cancels the outgoing audio load
S.audioBuffer = null;
S.waveformPeaks = null;
S.title = data.title || '';
S.artist = data.artist || '';
S.filename = '';
S.sessionId = data.session_id;
if (oldSessionId && oldSessionId !== data.session_id) {
await disposeBackendSession(oldSessionId);
}
markSessionDirty();
// Same outgoing-job teardown loadCDLC performs: stop the old playback,
// the pending audio load and any drag, and drop the decoded buffer. An
// audio-less import skips the loadAudio() branch below, so without this the
// previous recording keeps sounding under the new chart and S.audioBuffer
// stays stale. Dispose the old backend session too so its sandbox isn't leaked.
const oldSessionId = S.sessionId;
stopSessionProcesses(); // also cancels the outgoing audio load
S.audioBuffer = null;
S.waveformPeaks = null;
S.title = data.title || '';
S.artist = data.artist || '';
S.filename = '';
S.sessionId = data.session_id;
if (oldSessionId && oldSessionId !== data.session_id) {
disposeBackendSession(oldSessionId); // best-effort, fire-and-forget
}
markSessionDirty();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/create.js` around lines 2142 - 2158, Remove the blocking await of
disposeBackendSession in the create-result transition after updating
S.sessionId, so backend cleanup runs best-effort without delaying the remaining
session state, format, arrangement, DOM, and audio updates. Preserve the
oldSessionId and changed-session guard, and invoke cleanup without awaiting it.

S.format = 'sloppak';
S.arrangements = data.arrangements || [];
// Create-mode import — the source builds tuning to the actual string count,
Expand Down
Loading
Loading