Skip to content

fix: stretcher gapless playback race conditions and stability improvements#17

Merged
ivgtr merged 36 commits intomainfrom
fix/stretcher-gapless-race
Feb 10, 2026
Merged

fix: stretcher gapless playback race conditions and stability improvements#17
ivgtr merged 36 commits intomainfrom
fix/stretcher-gapless-race

Conversation

@ivgtr
Copy link
Owner

@ivgtr ivgtr commented Feb 10, 2026

Summary

  • Stretcher エンジンのギャップレス再生における複数の race condition を修正
  • pause 中の setTempo() で再生位置がずれるバグを修正
  • resume 時の fade-in アーティファクト、onAllDead 二重呼び出し、スケジューリング競合などを解消
  • バックグラウンドタブでの再生安定性を向上(lookahead 拡大、proactive schedule)
  • WSOLA の NCC 参照ウィンドウ修正と tempo≈1.0 バイパスの追加
  • テストカバレッジを大幅に拡充(unit + browser テスト追加)
  • デモサイトのセクション構造を examples/ に統合

Test plan

  • npm run typecheck パス
  • npm run lint パス
  • npm run test:unit — 593 テストすべてパス
  • npm run test:browser — ブラウザテスト通過確認
  • デモサイトで pause → setTempo → resume の位置ずれが解消されていることを確認

ivgtr added 30 commits February 10, 2026 00:05
…nsition timer

When onended fires before the transition setTimeout (common at 2x speed
and in background tabs), the old code path would stop the already-playing
gapless nextSource and create a new one from scratch, causing audio gaps.

Extract doTransition() helper and add handleCurrentSourceEnded() that
promotes the existing nextSource immediately when onended fires first,
with a guard in the setTimeout callback to skip if already promoted.
…ll buffer

- Add .catch() to dynamic import in play.ts to transition to stopped state on failure
- Replace non-null assertion with null check in chunk-player.ts handleCurrentSourceEnded
- stretched-playback-import-error: verify .catch() on dynamic import failure
- chunk-player-null-buffer: verify null buffer guard in handleCurrentSourceEnded
… chunk-player

Add 194 tests across 7 new files covering previously untested paths:
- Normal playback (play/pause/resume/seek/stop, events, edge cases)
- Stretched playback (engine mock, event relay, pending operations)
- Engine advanced paths (loop, buffering, seek, tempo change)
- WorkerManager (pool init, postConvert, crash/respawn, terminate)
- ChunkPlayer advanced (crossfade, lookahead, transition cancellation)

Includes shared mock helpers in tests/helpers/audio-mocks.ts.
Documents 3 confirmed potential bugs: loopEnd<=loopStart zero division,
playbackRate=0 zero division, stop+ended statechange double-fire.
…prevent double statechange

- loopEnd <= loopStart: fall back to non-loop position instead of NaN
- playbackRate <= 0: clamp initial value to 1, ignore setPlaybackRate(0)
- Stretched playback: introduce setState() helper to deduplicate state transitions
…hensive tests

- Extract calcLoopPosition/calcPlaybackPosition into playback-position.ts
  (fixes negative modulo bug when elapsed < loopStart)
- Extract createPlaybackStateManager into playback-state.ts (DRY: eliminates
  duplicated setState/startTimer/stopTimer between normal and stretched modes)
- Extract calcPositionInOriginalBuffer into stretcher/position-calc.ts
- Extract calcTransitionDelay into stretcher/transition-timing.ts
- Export trimOverlap from engine.ts for direct testing
- Add 74 new tests covering position calc, state management, trim-overlap,
  exitBuffering, transition timing, race conditions, conversion-scheduler
  edge cases, normal playback stress tests, and chunk management
Add comprehensive tests (91 cases) for context, buffer, nodes, waveform,
fade, scheduler, synth, and adapters modules. Extend audio-mocks with
AnalyserNode, BiquadFilterNode, StereoPannerNode, DynamicsCompressorNode
mocks and createMockPlayback helper.

Fix three bugs discovered during testing:
- waveform: blockSize=0 causing NaN when resolution > data.length
- waveform: extractRMS crash on 0-channel buffer with channel=-1
- adapters: whenPosition never resolving for already-passed positions
- Upgrade vitest to ^4.0.0, add @vitest/browser, @vitest/browser-playwright, playwright
- Convert vitest.config.ts to projects configuration (unit + browser)
- Add test:unit and test:browser npm scripts
- Fix URL mock to preserve constructor (vitest 4.x uses new URL() internally)
- Fix Worker mock to use function keyword (vitest 4.x requires it for new)
- Replace fake timers with real timers in import-error test (vitest 4.x blocks dynamic import() resolution with fake timers)
…cation

- context.browser.test.ts: AudioContext lifecycle, resume, currentTime
- nodes.browser.test.ts: node creation, chain signal routing, rampGain
- fade.browser.test.ts: linear/exponential/equal-power ramps, crossfade
- play.browser.test.ts: onended, pause/resume, seek, loop, through chain
- stretcher.browser.test.ts: WSOLA integration, tempo change, seek, ended
- Install @biomejs/biome, configure biome.json (space indent, double quotes, semicolons)
- Replace `lint` script with Biome, add `format` and `check` scripts
- Auto-format all src/ and tests/ files to match Biome rules
- Update architecture table (10 → 14 modules: stretcher, player, playback-state, playback-position)
- Update test setup to Vitest 4.x projects config
- Add testing policy, bug prevention checklist, and new module guide
- Add Biome commands to Commands section
- Add /quality-check, /review, /add-test custom skills
- Add PreCommit hook (typecheck) to settings.json
PreCommit is not a valid Claude Code hook event name.
Replace with /commit skill for pre-commit checks.
…und tab resilience

LOOKAHEAD_THRESHOLD_SEC 1.5→3.0 so throttled setInterval (1000ms) still
gets 2-3 check opportunities. New PROACTIVE_SCHEDULE_THRESHOLD_SEC (5.0)
used in onChunkReady (Worker callback, not throttled) for earlier scheduling.
chunk-player-throttle: lookahead under 1000ms throttle, onended-before-timer
fallback, full background cycle scenario.
engine-background-resilience: proactive schedule threshold validation,
buffering transitions, disposed safety.
Apply Hann window to prevOutputFrame so NCC comparison is symmetric
between reference and search frames. Add early return for tempo within
±0.001 of 1.0 to skip WSOLA entirely, preventing NCC search artifacts
that cause audible wobble at identity speed.
Skip handleResult when chunk state is not 'converting', preventing
stale worker results from overwriting re-queued chunks after tempo change.
…nce setTempo

- Extract tryScheduleNext with dedup guard to prevent double-scheduling
  from lookahead and proactive paths
- Add expectedTransitionFrom to reject stale onTransition callbacks
- Scale crossfadeKeep by min(1, ratio) so trimOverlap works at high tempo
- Debounce rapid setTempo calls (50ms) to batch re-conversion requests
… on completion

- Add allDeadFired flag to prevent onAllDead from being called more than once
- Delete postTimes entries on result, cancelled, error, and worker crash
  to prevent memory leak over long playback sessions
Add skipFadeIn parameter to playChunk/playCurrentChunk and pass true
from engine.resume() so that resuming after pause does not apply
an unnecessary 0.1s equal-power fade-in.
When setTempo was called during pause, it triggered enterBuffering
which led to playCurrentChunk on chunk ready, resuming playback
without user intent. Now setTempo during pause only stores the new
tempo and defers the buffering flow until resume is explicitly called.
Save position with old tempo before updating currentTempo in setTempo()
pause branch, and reuse saved bufferingResumePosition in resume() instead
of recalculating with new tempo. Also extend position-calc to return
bufferingResumePosition for paused state.
Add disposed checks to handleResult and handleError to prevent
state mutations and callback invocations after scheduler disposal.
Handle bufferingResumePosition in resume path so that pause→seek→resume
plays from the seek target. Prevent seek during pause from triggering
enterBuffering prematurely.
Cover edge cases across chunk-player, engine, and worker-manager:
- AudioNode disconnect on consecutive playChunk (P-02, L-01)
- scheduleNext after pause (P-01)
- buffering/pause/ended state transitions (S-01 through S-10)
- stale transition guards on seek/setTempo (C-01 through C-05)
- rapid seek and pause/resume toggles (R-01, R-02)
- cancelChunk/terminate timing in worker-manager (W-01 through W-03, M-01, M-02)
CS-03: seek→setTempo / setTempo→seek sequential execution
CS-05: restorePreviousTempo→handleTempoChange chain
P-03: pause immediately after doTransition
P-04: getCurrentPosition accuracy during crossfade
R-03: start→pause before buffering completes (2 scenarios)
MTP-a~d: main-thread-processor postTimes/cancelledChunks lifecycle
Change drawWaveform to accept a progress ratio (0-1) instead of a
static fill style, coloring played bars darker and unplayed bars
lighter. Wire progress through all call sites in demo-controller
(onFrame, resetCursor, resize, seek, updateWaveform).
Interactive demo visualizing WSOLA stretcher chunk buffering behavior.
Includes chunk state legend, real-time event log, and tempo select control.
Merge separate demos/ and use-cases/ sections into a single examples/
directory. Remove chunk-buffering and react-integration pages that are
no longer needed.
@ivgtr ivgtr self-assigned this Feb 10, 2026
@ivgtr ivgtr added the release:minor Trigger minor release label Feb 10, 2026
@ivgtr ivgtr merged commit 228f9ee into main Feb 10, 2026
2 checks passed
@ivgtr ivgtr deleted the fix/stretcher-gapless-race branch February 11, 2026 11:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:minor Trigger minor release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant