Skip to content
Open
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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
### Added

- **Practice loops are easier to control and understand** — A/B and section
loops now share clear start/count-in/repeat choices, can be saved per song,
show their bounds on the timeline, and remain visible in the game HUD.
Seeking stays free inside the loop while outside seeks restart from A using
the selected first-pass behavior.
- **`chart-transform` capability domain (#952)** — plugins can now remap the
chart before rendering and scoring through a core-owned provider
coordinator. Synchronous transforms run after difficulty filtering; host
Expand Down
110 changes: 80 additions & 30 deletions static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,22 +79,27 @@
scheduleCreditsHide,
showCountOverlay,
showSongCreditsOverlay,
startCountIn,
startSongCountIn,
} from './js/count-in.js';

import {
_loopMutationGen,
cancelLoopOperations,
clearLoop,
deleteSelectedLoop,
getLoopState,
handleLoopBoundary,
loadSavedLoop,
loadSavedLoops,
loopA,
loopB,
pulseLoopIndicator,
saveCurrentLoop,
setLoop,
setLoopEnd,
setLoopStart,
startLoop,
updateLoopPreference,
updateLoopUI,
} from './js/loops.js';

Expand Down Expand Up @@ -286,8 +291,40 @@
setPlayButtonState, jucePlayer, _audioTime, _audioDuration, _songEventPayload,
_markPlaybackPaused, _markPlaybackResumed, _emitPlaybackStopped, _emitSongPositionChanged,
_waitForSongReady, _resetAudioSeekState, _audioSeek, togglePlay, seekBy, audioSeekGen,
setLoopPlayStartTargetResolver, setLoopRestartHandler,
} from './js/transport.js';

// Timeline navigation stays free while paused. When Play is pressed with an
// active loop, the transport consults this hook and starts from A.
setLoopPlayStartTargetResolver((requestedTime) => {
const state = getLoopState();
if (!state.active
|| !Number.isFinite(state.loopA)
|| !Number.isFinite(state.loopB)
|| !Number.isFinite(requestedTime)) {
return requestedTime;
}
return requestedTime >= state.loopA && requestedTime < state.loopB
? requestedTime
: state.loopA;
});

// Returning to A because the user tried to play/seek outside an active loop
// is a real loop restart. Route it through the controller so the selected
// first-pass policy is honored: count-in restarts with four beats, while
// immediate starts without one.
setLoopRestartHandler(async ({ trigger }) => {
const from = _audioTime();
const completed = await startLoop({
source: trigger === 'play' ? 'outside-play' : 'outside-seek',
});
return {
completed: !!completed,
from,
to: completed ? _audioTime() : NaN,
};
});


// Demo analytics — real impl set by demo.js; no-op in normal builds
window.feedBackDemoTrack = window.feedBackDemoTrack ?? null;
Expand Down Expand Up @@ -779,7 +816,10 @@
// loopA/loopB bindings at call time.
seek(seconds, reason, options) {
_recordPlaybackBridge('playback.window-feedBack-transport', 'window.feedBack.seek', reason || 'plugin-command');
return _audioSeek(seconds, reason || 'plugin-command');
return _audioSeek(seconds, reason || 'plugin-command', {
...(options || {}),
restartActiveLoopWhilePlaying: true,
});
},
setLoop(a, b, options) {
_recordPlaybackBridge('playback.loop-api', 'window.feedBack.setLoop', options && options.reason || 'plugin-command');
Expand All @@ -789,9 +829,13 @@
_recordPlaybackBridge('playback.loop-api', 'window.feedBack.clearLoop', options && options.reason || 'plugin-command');
clearLoop(options);
},
startLoop(options) {
_recordPlaybackBridge('playback.loop-api', 'window.feedBack.startLoop', options && options.reason || 'plugin-command');
return startLoop(options);
},
getLoop(options) {
_recordPlaybackBridge('playback.loop-api', 'window.feedBack.getLoop', options && options.reason || 'plugin-command');
return { loopA, loopB };
return getLoopState();
},
});
if (_feedBackExisting && _feedBackExisting !== window.feedBack) {
Expand All @@ -803,10 +847,12 @@
}
window.feedback = window.feedBack;
window.slopsmith = window.feedback;
window.feedBack.on('loop:restart', pulseLoopIndicator);

function _currentPlaybackSnapshot() {
const song = window.feedBack && window.feedBack.currentSong || null;
const time = _audioTime();
const appLoop = getLoopState();
return {
currentTime: Number.isFinite(time) ? time : null,
mediaTime: Number.isFinite(time) ? time : null,
Expand All @@ -819,7 +865,12 @@
routeState: song || audio.src || window._juceAudioUrl ? 'active' : 'unavailable',
loopA,
loopB,
loop: loopA !== null && loopB !== null ? { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } : { enabled: false, state: 'inactive' },
loop: {
startTime: appLoop.configured ? loopA : null,
endTime: appLoop.configured ? loopB : null,
enabled: appLoop.active,
state: appLoop.state,
},
currentSong: song ? {
targetId: song.filename ? `target-${String(song.filename).length}-${String(song.arrangementIndex ?? song.arrangement ?? '').length}` : undefined,
sourceKind: song.format || 'local',
Expand Down Expand Up @@ -920,7 +971,9 @@
if (!Number.isFinite(seconds) || seconds < 0) {
throw new Error(`Invalid seek time: ${time}`);
}
return _audioSeek(seconds, reason || 'playback-command');
return _audioSeek(seconds, reason || 'playback-command', {
restartActiveLoopWhilePlaying: true,
});
},
setLoop({ startTime, endTime }) {
return setLoop(startTime, endTime, { emitTransportEvent: false });
Expand Down Expand Up @@ -1125,8 +1178,8 @@
// Editor → Highway handoff (Editor ⇄ 3D Highway region round-trip). The
// editor's "Loop in 3D" button stashes a pending loop + return context, then
// calls playSong(). Once the chart is ready (playSong's own clearLoop() has
// already run, so the loop won't be wiped), arm the loop over the selected
// region and start playback so the user lands inside the loop directly.
// already run, so the loop won't be wiped), configure the selected region
// through the same preference-aware controller used by Practice & Loops.
window.feedBack.on('song:ready', () => {
_updateEditRegionBtn();
const pend = window._pendingHighwayLoop;
Expand All @@ -1137,8 +1190,10 @@
if (want && currentFilename && want !== currentFilename) return;
window._pendingHighwayLoop = null;
window._highwayReturnCtx = pend.returnCtx || null;
Promise.resolve(setLoop(pend.a, pend.b))
.then((ok) => { if (ok && !S.isPlaying) return togglePlay(); })
Promise.resolve(setLoop(pend.a, pend.b, {
activation: 'preference',
source: 'editor',
}))
.catch((err) => console.warn('[app] loop-in-3d apply failed:', err));
_updateEditRegionBtn();
});
Expand All @@ -1152,6 +1207,10 @@

async function changeArrangement(index) {
if (currentFilename) {
// Invalidate a pending loop start/wrap before the arrangement's own
// pause/reconnect/restore sequence begins. An already-active loop keeps
// its time-based bounds, matching the existing arrangement contract.
cancelLoopOperations();
// Tear down any pending fresh-load credits before switching: the
// no-count-in hold timer would otherwise fire togglePlay() against the
// incoming (still-loading) arrangement. hideSongCreditsOverlay() clears
Expand Down Expand Up @@ -1298,21 +1357,11 @@
} catch (_) { /* host misbehaviour — treat as no loop */ }
}
const hasLoop = loopA != null && loopB != null;
const target = hasLoop ? loopA : 0;
const r = await _audioSeek(target, 'song-restart');
if (!r.completed) return false;
if (hasLoop) {
// Verify the seek actually landed at loop A (JUCE may clamp / HTML5 may
// snap) before the count-in fixes the visuals there — otherwise the
// count-in would start from loopA while the audio backend sits
// elsewhere. ~50 ms tolerance, matching the loop paths.
if (Number.isFinite(r.to) && Math.abs(r.to - target) > 0.05) {
console.warn('[restart] seek landed at', r.to, 'but loop A is', target, '— skipping count-in');
return false;
}
await startCountIn({ immediate: true });
return true;
return startLoop({ source: 'song-restart' });
}
const r = await _audioSeek(0, 'song-restart');
if (!r.completed) return false;
if (!S.isPlaying) await togglePlay();
return true;
}
Expand Down Expand Up @@ -1449,7 +1498,7 @@
// it *dismisses* the prompt → Stay → drops you back into the (resumed) song —
// so a second Escape does NOT leave. Leaving is the explicit, default-focused
// "Leave" button, so Space/Enter (or click) is the keyboard "just get me out".
function _openExitConfirm() {

Check warning on line 1501 in static/app.js

View workflow job for this annotation

GitHub Actions / ci / lint

File has too many lines (2401). Maximum allowed is 1500
_exitConfirmOpen = true;
// Freeze the song while the user decides: cancel any pending count-in (so it
// can't start playback behind the modal) and pause if we're playing. Stay
Expand Down Expand Up @@ -1794,10 +1843,11 @@
window.feedBack.emit('song:ended', _songEventPayload());
jucePlayer.pause().catch((err) => console.warn('[app] end-of-track pause error:', err));
}
// A-B loop: count-in then seek back to A
else if (loopA !== null && loopB !== null && ct >= loopB) {
// The unified controller distinguishes configured/armed bounds from an
// active loop and applies the selected repeat policy.
else if (getLoopState().active && S.isPlaying && ct >= loopB) {
S.lastAudioTime = loopB;
startCountIn();
handleLoopBoundary(ct).catch((err) => console.warn('[loop] wrap failed:', err));
}
// Detect and fix audio time jumps (browser seeking bug; skip for JUCE — position is polled)
else if (!window._juceMode && S.isPlaying && Math.abs(ct - S.lastAudioTime) > 30 && S.lastAudioTime > 0) {
Expand Down Expand Up @@ -2298,11 +2348,11 @@
syncLibrarySong,
handleSliderInput,
playSong,
// count-in is a module now, so section-practice reaches it through the seam too —
// these are simply count-in's own exports, handed across.
startCountIn,
// Section-practice teardown cancels an in-flight controller count-in
// through the seam without importing the controller back into itself.
_cancelCountIn,
_updateEditRegionBtn,
updateLoopUI,
// section-practice reaches the loop module through the seam, not by importing it:
// loops imports section-practice (clearLoop drops its selection), so the reverse
// edge has to be indirection or the graph cycles. These are simply the loop
Expand Down Expand Up @@ -2335,10 +2385,10 @@
retuneSong, saveCurrentLoop, saveSettings, seekBy,
setAvOffsetMs, setFavView, setInstrumentPathway, setLibView,
setLibraryProvider, setLoopEnd, setLoopStart, setMastery,
setSpeed, setViz, showScreen, sortFavorites,
setSpeed, setViz, showScreen, sortFavorites, startLoop,
sortLibrary, syncLibrarySong, toggleAllArtists, toggleAllFavoriteArtists,
toggleLibFilters, togglePlay, toggleSectionPracticePopover, uiPrompt,
updatePlugin, uploadSongs,
updateLoopPreference, updatePlugin, uploadSongs,

// These four are invisible to every static scan. app.js:2156-2157 picks the
// handler NAME at runtime —
Expand Down
22 changes: 21 additions & 1 deletion static/capabilities/playback.js
Original file line number Diff line number Diff line change
Expand Up @@ -1032,7 +1032,27 @@
_setState(shouldPlay ? 'playing' : 'paused', { requesterId: source.requesterId, readiness: 'ready', reason: source.reason });
}
else if (name === 'loop-restarted') {
if (currentSession.loop) currentSession.loop.lastRestartAt = _now();
const startTime = _number(source.loopA, null);
const endTime = _number(source.loopB, null);
const lastRestartAt = _now();
if (startTime != null && endTime != null) {
// The core loop bridge emits one legacy loop:restart event for
// both the initial pass and later wraps. Promote a previously
// armed snapshot to active here without requiring a duplicate
// loop-set event.
_updateLoopFromSnapshot({
...currentSession.loop,
startTime,
endTime,
enabled: true,
state: 'active',
requesterId: source.requesterId,
lastRestartAt,
});
} else if (currentSession.loop) {
currentSession.loop.lastRestartAt = lastRestartAt;
currentSession.media.loop = _clone(currentSession.loop);
}
} else if (name === 'loop-stale') {
if (currentSession.loop) currentSession.loop.state = 'stale';
}
Expand Down
15 changes: 15 additions & 0 deletions static/highway.js
Original file line number Diff line number Diff line change
Expand Up @@ -1498,7 +1498,7 @@
hwState._filteredHandShapes = null;
hwState._phrasesHaveHandShapes = false;
_restageChartTransform();
return;

Check warning on line 1501 in static/highway.js

View workflow job for this annotation

GitHub Actions / ci / lint

File has too many lines (2943). Maximum allowed is 1500
}
const outNotes = [];
const outChords = [];
Expand Down Expand Up @@ -2475,6 +2475,21 @@
hwState._chartLastAdvanceAt = newPerfNow;
}
},
// Count-ins deliberately hold the chart at their start position while
// the backing transport is stopped. A normal setTime(t) creates a live
// interpolation anchor, which makes smooth renderers creep forward for
// up to _CHART_MAX_INTERP_MS before stall detection catches up. Keep
// the raw position but clear the live anchor so the bundle reports
// isPlaying=false immediately. The first genuinely advancing audio
// sample re-anchors through setTime() and resumes smooth motion.
freezeTime(t) {
hwState.chartTime = t + hwState.songOffset;
hwState.currentTime = hwState.chartTime + hwState.avOffsetSec;
hwState._chartAnchorAudioT = t;
hwState._chartAnchorPerfNow = NaN;
hwState._chartLastAdvanceAt = 0;
hwState._chartObservedRate = 1;
},
setAvOffset(ms) { hwState.avOffsetSec = (Number(ms) || 0) / 1000; hwState.currentTime = hwState.chartTime + hwState.avOffsetSec; },
getAvOffset() { return hwState.avOffsetSec * 1000; },

Expand Down
Loading
Loading