fix: address loop practice review feedback

Signed-off-by: Viktor Olausson <viktor.olausson@gmail.com>
This commit is contained in:
Viktor Olausson 2026-07-20 10:03:47 +02:00
parent 3d6e7e5dac
commit a8db82b61e
7 changed files with 51 additions and 9 deletions

View File

@ -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

View File

@ -1034,6 +1034,7 @@
else if (name === 'loop-restarted') {
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
@ -1046,9 +1047,12 @@
enabled: true,
state: 'active',
requesterId: source.requesterId,
lastRestartAt,
});
} else if (currentSession.loop) {
currentSession.loop.lastRestartAt = lastRestartAt;
currentSession.media.loop = _clone(currentSession.loop);
}
if (currentSession.loop) currentSession.loop.lastRestartAt = _now();
} else if (name === 'loop-stale') {
if (currentSession.loop) currentSession.loop.state = 'stale';
}

View File

@ -900,7 +900,7 @@ export let _resetJuceAudioShimChain = function () {};
if (wantsPause && seekTime !== undefined) {
enqueue(async (gen) => {
const r = await _audioSeek(seekTime, 'audio-element-shim', {
restartActiveLoopWhilePlaying: true,
restartActiveLoopWhilePlaying: forUpcomingPlay,
});
if (!r.completed) return; // seek cancelled by teardown
if (gen !== _juceShimGen) return;

View File

@ -77,6 +77,10 @@ html { scroll-behavior: smooth; }
border-color: rgba(64, 128, 224, 0.5);
color: #bfdbfe;
}
.section-practice-pill.section-practice-pill--section-selected {
border-color: #4080e0;
color: #93c5fd;
}
.section-practice-pill.section-practice-pill--active {
border-color: #4080e0;
color: #93c5fd;

View File

@ -630,6 +630,7 @@ html[data-scoreboard="off"] #v3-live-performance-hud { display: none !important;
border-color: rgba(203, 213, 225, .85);
}
#v3-player-rail .section-practice-control--v3 .section-practice-pill[aria-expanded="true"],
#v3-player-rail .section-practice-control--v3 .section-practice-pill.section-practice-pill--section-selected,
#v3-player-rail .section-practice-control--v3 .section-practice-pill.section-practice-pill--active,
#v3-player-rail .section-practice-control--v3 .section-practice-pill.section-practice-pill--armed {
color: #e2e8f0;
@ -637,6 +638,7 @@ html[data-scoreboard="off"] #v3-live-performance-hud { display: none !important;
box-shadow: none;
}
#v3-player-rail .section-practice-control--v3 .section-practice-pill[aria-expanded="true"] .v3-rail-border,
#v3-player-rail .section-practice-control--v3 .section-practice-pill.section-practice-pill--section-selected .v3-rail-border,
#v3-player-rail .section-practice-control--v3 .section-practice-pill.section-practice-pill--active .v3-rail-border,
#v3-player-rail .section-practice-control--v3 .section-practice-pill.section-practice-pill--armed .v3-rail-border {
border-color: #22d3ee;
@ -824,14 +826,14 @@ html[data-scoreboard="off"] #v3-live-performance-hud { display: none !important;
width: .85rem;
height: .85rem;
fill: none;
stroke: currentColor;
stroke: currentcolor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
opacity: .9;
}
.v3-loop-indicator-label {
color: currentColor;
color: currentcolor;
font-weight: 750;
letter-spacing: .08em;
text-transform: uppercase;

View File

@ -10,7 +10,9 @@ const ROOT = path.join(__dirname, '..', '..');
const HTML = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'index.html'), 'utf8');
const SECTION = fs.readFileSync(path.join(ROOT, 'static', 'js', 'section-practice.js'), 'utf8');
const LOOPS = fs.readFileSync(path.join(ROOT, 'static', 'js', 'loops.js'), 'utf8');
const JUCE_AUDIO = fs.readFileSync(path.join(ROOT, 'static', 'js', 'juce-audio.js'), 'utf8');
const APP = fs.readFileSync(path.join(ROOT, 'static', 'app.js'), 'utf8');
const STYLE_CSS = fs.readFileSync(path.join(ROOT, 'static', 'style.css'), 'utf8');
const V3_CSS = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'v3.css'), 'utf8');
function practiceMarkup() {
@ -123,6 +125,29 @@ test('returning to A gives the loop indicator one restrained pulse', () => {
assert.match(V3_CSS, /@media\s*\(prefers-reduced-motion:\s*reduce\)/);
});
test('selected Section Practice remains visible without replacing loop lifecycle styling', () => {
assert.match(SECTION, /classList\.toggle\('section-practice-pill--section-selected',\s*_sectionPracticeMode\)/);
assert.match(STYLE_CSS, /\.section-practice-pill\.section-practice-pill--section-selected\s*\{/);
assert.match(V3_CSS, /\.section-practice-pill\.section-practice-pill--section-selected/);
assert.match(V3_CSS, /\.section-practice-pill\.section-practice-pill--active/);
assert.match(V3_CSS, /\.section-practice-pill\.section-practice-pill--armed/);
});
test('JUCE pause-and-seek restarts an outside loop only when playback follows', () => {
const start = JUCE_AUDIO.indexOf('function flushJuceShimBatchNow(');
const end = JUCE_AUDIO.indexOf('function scheduleJuceShimBatchFlush(', start);
assert.notEqual(start, -1);
assert.notEqual(end, -1);
const flush = JUCE_AUDIO.slice(start, end);
const pauseAndSeekStart = flush.indexOf('if (wantsPause && seekTime !== undefined)');
const pauseAndSeekEnd = flush.indexOf('if (wantsPause) {', pauseAndSeekStart);
assert.notEqual(pauseAndSeekStart, -1);
assert.notEqual(pauseAndSeekEnd, -1);
const pauseAndSeek = flush.slice(pauseAndSeekStart, pauseAndSeekEnd);
assert.match(pauseAndSeek, /restartActiveLoopWhilePlaying:\s*forUpcomingPlay/);
assert.doesNotMatch(pauseAndSeek, /restartActiveLoopWhilePlaying:\s*true/);
});
test('timeline seeks stay free while paused and restart active loops while playing', () => {
assert.match(APP, /setLoopPlayStartTargetResolver\(\(requestedTime\)/);
assert.match(APP, /setLoopRestartHandler\(async\s*\(\{\s*trigger\s*\}\)\s*=>\s*\{[\s\S]*startLoop\(\{/);

View File

@ -177,10 +177,11 @@ test('a loop restart promotes an armed observer snapshot without a second loop-s
loopB: 7,
currentTime: 3,
});
const loop = diagnosticsSnapshot(window).state.loop;
assert.equal(loop.state, 'active');
assert.equal(loop.enabled, true);
assert.ok(loop.lastRestartAt);
const state = diagnosticsSnapshot(window).state;
assert.equal(state.loop.state, 'active');
assert.equal(state.loop.enabled, true);
assert.ok(state.loop.lastRestartAt);
assert.equal(state.media.loop.lastRestartAt, state.loop.lastRestartAt);
assert.equal(loopSetEvents.length, 1);
});