This demo is intentionally simple in UI but non-trivial in audio behavior.
If you are new to Web Audio, read this once before editing src/main.ts.
Web Audio is a graph of connected nodes:
source -> processor -> gain -> destination
In this demo, that graph is:
- Source:
AudioBufferSourceNodein Buffer modeHTMLAudioElement(wrapped byMediaElementAudioSourceNode) in Element mode
- Processor:
SoundTouchNodefrom@soundtouchjs/audio-worklet
- Output control:
GainNodefor volume
- Speakers:
audioCtx.destination
The demo shows two common Web Audio input strategies:
- Buffer mode:
- You fetch/decode audio into an
AudioBuffer - You create an
AudioBufferSourceNodeto play it - Good for precise seeking and custom transport logic
- Element mode:
- You use a regular
<audio>element as source - Good when you want native media controls or browser buffering behavior
Both modes route through SoundTouchNode so pitch and tempo controls are consistent.
AudioContextstarts suspended in many browsers until user interaction.
- That is why the demo calls
audioCtx.resume()in play paths.
AudioBufferSourceNodeis one-shot.
- After
start(), you cannot restart the same node. - Pause/resume works by creating a new source node and starting from an offset.
createMediaElementSource()should be done once per media element and context.
- The demo stores
elementSourceNodeand reuses it.
- Time domains matter.
audioCtx.currentTimeis wall-clock time in the audio engine.pauseOffsetis source time (position in track).- With tempo changes, converting between these domains is required for accurate progress/seek.
- Looping belongs to the source transport.
- Buffer mode:
sourceNode.loop - Element mode:
audioEl.loop SoundTouchNodedoes processing, not transport lifecycle.
The demo uses a recommended pairing:
- Set source playback speed with transport playback rate:
- Buffer mode:
sourceNode.playbackRate.value = tempo - Element mode:
audioEl.playbackRate = tempo
- Mirror that rate to SoundTouch:
stNode.playbackRate.value = tempo
This keeps SoundTouch's pitch compensation aligned with source speed.
- Apply pitch controls separately:
stNode.pitch.valuefor continuous ratiostNode.pitchSemitones.valuefor key changes in semitones
- Set output volume after processing:
gainNode.gain.value
Browsers often apply their own pitch correction when media playback rate changes. If that remains enabled, both browser and SoundTouch try to affect pitch.
Setting audioEl.preservesPitch = false ensures SoundTouch is the single pitch authority.
Buffer mode tracks three things:
playStartTime:
audioCtx.currentTimeat the moment playback started/resumed
pauseOffset:
- Source position in seconds where playback should start next
currentTempo:
- Needed to convert elapsed wall time into elapsed source time
When pausing or changing tempo during playback, the demo does:
pauseOffset += (audioCtx.currentTime - playStartTime) * currentTempo
This is the key conversion that keeps state coherent.
- Toggle state is centralized in
setLoop(enabled). - Existing active source gets updated immediately.
- New buffer sources inherit loop flag on creation.
- Progress display wraps with modulo when loop is on.
Without wrapped progress, UI would pin at 100% while audio continues looping.
- Forgot
await SoundTouchNode.register(...)
- Symptom: node creation fails because processor is unknown.
- Source playback rate and
stNode.playbackRateare out of sync
- Symptom: pitch sounds wrong when changing tempo.
- Reusing a started
AudioBufferSourceNode
- Symptom: no sound after pause/resume attempt.
- Not calling
audioCtx.resume()from a user gesture path
- Symptom: graph appears connected but silent.
- Leaving
audioEl.preservesPitchenabled
- Symptom: double pitch handling artifacts.
- Graph setup and processor registration:
src/main.tsinit block - Buffer transport lifecycle:
bufferPlay()andbufferPause() - Element transport lifecycle:
elementPlay()andconnectAudioElement() - Loop behavior:
setLoop()andupdateProgress() - Tempo and pitch controls: slider event handlers at the bottom
- Change one transport variable at a time (loop, then tempo, then seek).
- Verify both modes after every change.
- Keep comments focused on cause/effect, not UI wording.
- If behavior differs between modes, check source-specific APIs first.
- SoundTouchNode (AudioWorklet main-thread API): https://cutterscrossing.com/SoundTouchJS/?path=/docs/audio-worklet-soundtouchnode--docs
- AudioWorklet docs index: https://cutterscrossing.com/SoundTouchJS/?path=/docs/getting-started--docs
- Core docs index: https://cutterscrossing.com/SoundTouchJS/?path=/docs/core-soundtouch--docs
The demo uses circular sample buffers by default and can be switched to FIFO buffers:
- URL: open the demo with
?sampleBufferType=fifo - UI: use the "Use FIFO sample buffers" checkbox (it reloads with the query flag)
Default (no query flag) keeps circular sample buffers enabled.
The demo defaults to Lanczos interpolation (lanczos) and supports a linear override.
- URL: open the demo with
?interpolationStrategy=linear - UI: use the "Use linear interpolation" checkbox (it reloads with the query flag)
Unchecked uses default Lanczos behavior. Checked forces linear interpolation for side-by-side listening tests.