A modern, network-enabled music player platform built on Rockbox technology. rockboxd.tsiry-sandratraina.com
rust deno navidrome airplay libadwaita zig mpris snapcast mpd rockbox audio subsonic
2

Configure Feed

Select the types of activity you want to include in your feed.

wasm: Rockbox pcmbuf crossfade in JS + crossfade settings UI

Port the original pcmbuf crossfade (via its faithful extraction in
crates/rockbox-playback/src/crossfade.rs) into the decoder worker — no second
player needed: like pcmbuf, we mix at the PCM level into the single output
stream.

Worker:
- Q16 gains (MIXFADE_UNITY), Bresenham MixFader ramps, mixfade_sample
rounding and saturating clip16 — byte-faithful to apps/pcmbuf.c.
- Same parameters/semantics as the firmware: mode (off / auto track change /
manual skip / shuffle / shuffle-or-manual / always), fade-out delay 0-7 s +
duration 0-15 s, fade-in delay + duration, and fade-out mixmode
(crossfade = both fade, mix = outgoing stays full volume).
- Output pipeline: crossfade mixer -> tail holdback -> worklet. The newest
fade_out_delay+duration seconds are held back so a transition always has an
outgoing tail; natural end hands it to the next track (no flush, seamless),
manual skip fades from ~now (worklet high-water drops to 0.6 s when a
manual-capable mode is on so the response is quick).
- finalizeCrossfade plays the tail's fade out when the incoming track is
shorter than the region; per-track elapsed epoch (trackBase) keeps the
progress display sane across unflushed transitions.

API: setCrossfade(mode, {fadeOutDelay, fadeOutDuration, fadeInDelay,
fadeInDuration, mixMode}) with CrossfadeMode + CrossfadeMixMode exported as
TypeScript enums (raw ints also accepted); persisted and restored like the
other settings.

Example: Crossfade card in the DSP panel (mode, four fade sliders with
Rockbox ranges, mixmode), persisted via jotai.

+436 -24
+3 -2
WEBASSEMBLY.md
··· 112 112 113 113 ## Known limitations 114 114 115 - - **Crossfade / crossfeed / PBE / dither / pitch** aren't exposed: the WASM DSP 116 - surface is what `rockbox-ffi`'s `dsp.rs` exposes. 115 + - **Dither / pitch** aren't exposed: the WASM DSP surface is what 116 + `rockbox-ffi`'s `dsp.rs` exposes. (Crossfeed and PBE are; crossfade is the 117 + Rockbox pcmbuf algorithm ported to JS in the decoder worker.) 117 118 - **Live streams** aren't seekable and report `duration_ms: 0`. 118 119 - Large finite files are decoded whole before playback (the incremental, 119 120 seek-while-streaming decoder needs a codec thread, which the single-threaded
+4 -1
bindings/wasm/README.md
··· 8 8 - 🎧 **Decodes** FLAC, MP3, Vorbis, Opus, ALAC, AAC, WavPack, WMA, APE, Musepack, 9 9 WAV/AIFF and more — the real Rockbox codecs, not the browser's. 10 10 - 🎚️ **10-band parametric EQ**, tone controls, ReplayGain, channel mixing, 11 - stereo width, surround and a compressor — the Rockbox DSP chain. 11 + stereo width, crossfeed, PBE, surround and a compressor — the Rockbox DSP chain. 12 + - 🎛️ **Rockbox crossfade** — the original pcmbuf algorithm (same modes and 13 + parameters: fade in/out delay + duration, crossfade/mix) ported to JS. 12 14 - 📻 **Live internet radio** (Icecast/SHOUTcast) with ICY now-playing metadata. 13 15 - 📦 **The wasm is bundled in** — one `npm install`, nothing to download or 14 16 serve separately. ··· 144 146 | `setChannelMode(mode)` / `setStereoWidth(pct)` | `ChannelMode.Stereo\|.Mono\|…` | 145 147 | `setSurround(delayMs, balance, fx1, fx2)` | Haas surround | 146 148 | `setCompressor(thr, makeup, ratio, knee, rel, atk)` | dynamic-range compressor | 149 + | `setCrossfade(mode, opts?)` | `CrossfadeMode.Off\|.AutoSkip\|.ManualSkip\|.Shuffle\|.ShuffleOrManualSkip\|.Always`; opts: fade in/out delay + duration (s), `mixMode` | 147 150 148 151 Settings-value **enums** are exported (and each setter also accepts the raw 149 152 int):
+72 -1
bindings/wasm/example/src/DspPanel.tsx
··· 1 1 import { type ReactNode } from "react"; 2 2 import { useAtom } from "jotai"; 3 - import { RockboxPlayer, ReplayGainMode, ChannelMode, CrossfeedMode } from "rockbox-wasm"; 3 + import { 4 + RockboxPlayer, 5 + ReplayGainMode, 6 + ChannelMode, 7 + CrossfeedMode, 8 + CrossfadeMode, 9 + CrossfadeMixMode, 10 + } from "rockbox-wasm"; 4 11 import * as S from "./settings"; 5 12 6 13 /** Runs `fn` with the (booted) player — boots on first use. */ ··· 96 103 const [channel, setChannel] = useAtom(S.channelAtom); 97 104 const [width, setWidth] = useAtom(S.widthAtom); 98 105 106 + // Crossfade (Rockbox pcmbuf) 107 + const [xfMode, setXfMode] = useAtom(S.xfModeAtom); 108 + const [xfFoDelay, setXfFoDelay] = useAtom(S.xfFoDelayAtom); 109 + const [xfFoDur, setXfFoDur] = useAtom(S.xfFoDurAtom); 110 + const [xfFiDelay, setXfFiDelay] = useAtom(S.xfFiDelayAtom); 111 + const [xfFiDur, setXfFiDur] = useAtom(S.xfFiDurAtom); 112 + const [xfMix, setXfMix] = useAtom(S.xfMixAtom); 113 + const xf = (over: Partial<{ 114 + mode: CrossfadeMode; foDelay: number; foDur: number; 115 + fiDelay: number; fiDur: number; mix: CrossfadeMixMode; 116 + }> = {}) => 117 + apply((p) => 118 + p.setCrossfade(over.mode ?? xfMode, { 119 + fadeOutDelay: over.foDelay ?? xfFoDelay, 120 + fadeOutDuration: over.foDur ?? xfFoDur, 121 + fadeInDelay: over.fiDelay ?? xfFiDelay, 122 + fadeInDuration: over.fiDur ?? xfFiDur, 123 + mixMode: over.mix ?? xfMix, 124 + }), 125 + ); 126 + 99 127 return ( 100 128 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> 101 129 <Card title="ReplayGain"> ··· 204 232 <Field label="Stereo width" value={`${width}%`}> 205 233 <Slider min={0} max={255} value={width} 206 234 onChange={(v) => { setWidth(v); apply((p) => p.setStereoWidth(v)); }} /> 235 + </Field> 236 + </Card> 237 + 238 + <Card title="Crossfade"> 239 + <Field label="Mode"> 240 + <Select<CrossfadeMode> 241 + value={xfMode} 242 + options={[ 243 + [CrossfadeMode.Off, "Off"], 244 + [CrossfadeMode.AutoSkip, "Auto track change"], 245 + [CrossfadeMode.ManualSkip, "Manual skip"], 246 + [CrossfadeMode.Shuffle, "Shuffle"], 247 + [CrossfadeMode.ShuffleOrManualSkip, "Shuffle or manual skip"], 248 + [CrossfadeMode.Always, "Always"], 249 + ]} 250 + onChange={(v) => { setXfMode(v); xf({ mode: v }); }} 251 + /> 252 + </Field> 253 + <Field label="Fade-out delay" value={`${xfFoDelay} s`}> 254 + <Slider min={0} max={7} value={xfFoDelay} 255 + onChange={(v) => { setXfFoDelay(v); xf({ foDelay: v }); }} /> 256 + </Field> 257 + <Field label="Fade-out duration" value={`${xfFoDur} s`}> 258 + <Slider min={0} max={15} value={xfFoDur} 259 + onChange={(v) => { setXfFoDur(v); xf({ foDur: v }); }} /> 260 + </Field> 261 + <Field label="Fade-in delay" value={`${xfFiDelay} s`}> 262 + <Slider min={0} max={7} value={xfFiDelay} 263 + onChange={(v) => { setXfFiDelay(v); xf({ fiDelay: v }); }} /> 264 + </Field> 265 + <Field label="Fade-in duration" value={`${xfFiDur} s`}> 266 + <Slider min={0} max={15} value={xfFiDur} 267 + onChange={(v) => { setXfFiDur(v); xf({ fiDur: v }); }} /> 268 + </Field> 269 + <Field label="Fade-out mode"> 270 + <Select<CrossfadeMixMode> 271 + value={xfMix} 272 + options={[ 273 + [CrossfadeMixMode.Crossfade, "Crossfade (both fade)"], 274 + [CrossfadeMixMode.Mix, "Mix (outgoing stays full)"], 275 + ]} 276 + onChange={(v) => { setXfMix(v); xf({ mix: v }); }} 277 + /> 207 278 </Field> 208 279 </Card> 209 280 </div>
+29
bindings/wasm/example/src/settings.ts
··· 9 9 ReplayGainMode, 10 10 ChannelMode, 11 11 CrossfeedMode, 12 + CrossfadeMode, 13 + CrossfadeMixMode, 12 14 } from "rockbox-wasm"; 13 15 14 16 const CUTOFFS = RockboxPlayer.EQ_BAND_CUTOFFS; ··· 41 43 export const channelAtom = atomWithStorage<ChannelMode>("rb.channel", ChannelMode.Stereo); 42 44 export const widthAtom = atomWithStorage("rb.width", 100); 43 45 46 + // Crossfade (Rockbox pcmbuf) — delays 0–7 s, durations 0–15 s. 47 + export const xfModeAtom = atomWithStorage<CrossfadeMode>("rb.xfMode", CrossfadeMode.Off); 48 + export const xfFoDelayAtom = atomWithStorage("rb.xfFoDelay", 0); 49 + export const xfFoDurAtom = atomWithStorage("rb.xfFoDur", 2); 50 + export const xfFiDelayAtom = atomWithStorage("rb.xfFiDelay", 0); 51 + export const xfFiDurAtom = atomWithStorage("rb.xfFiDur", 2); 52 + export const xfMixAtom = atomWithStorage<CrossfadeMixMode>("rb.xfMix", CrossfadeMixMode.Crossfade); 53 + 44 54 export interface Settings { 45 55 volume: number; 46 56 eqEnabled: boolean; ··· 61 71 compRatio: number; 62 72 channel: ChannelMode; 63 73 width: number; 74 + xfMode: CrossfadeMode; 75 + xfFoDelay: number; 76 + xfFoDur: number; 77 + xfFiDelay: number; 78 + xfFiDur: number; 79 + xfMix: CrossfadeMixMode; 64 80 } 65 81 66 82 /** Read the current value of every setting atom as a plain snapshot. */ ··· 85 101 compRatio: useAtomValue(compRatioAtom), 86 102 channel: useAtomValue(channelAtom), 87 103 width: useAtomValue(widthAtom), 104 + xfMode: useAtomValue(xfModeAtom), 105 + xfFoDelay: useAtomValue(xfFoDelayAtom), 106 + xfFoDur: useAtomValue(xfFoDurAtom), 107 + xfFiDelay: useAtomValue(xfFiDelayAtom), 108 + xfFiDur: useAtomValue(xfFiDurAtom), 109 + xfMix: useAtomValue(xfMixAtom), 88 110 }; 89 111 } 90 112 ··· 102 124 p.setCompressor(s.compThresh, 0, s.compRatio, 0, 0, 0); 103 125 p.setChannelMode(s.channel); 104 126 p.setStereoWidth(s.width); 127 + p.setCrossfade(s.xfMode, { 128 + fadeOutDelay: s.xfFoDelay, 129 + fadeOutDuration: s.xfFoDur, 130 + fadeInDelay: s.xfFiDelay, 131 + fadeInDuration: s.xfFiDur, 132 + mixMode: s.xfMix, 133 + }); 105 134 }
+31
bindings/wasm/index.d.ts
··· 51 51 Custom = "custom", 52 52 } 53 53 54 + /** When a track change crossfades (Rockbox's `crossfade` setting). 55 + * Setters also accept the raw int (0–5, in this order). */ 56 + export enum CrossfadeMode { 57 + Off = "off", 58 + AutoSkip = "auto-skip", 59 + ManualSkip = "manual-skip", 60 + Shuffle = "shuffle", 61 + ShuffleOrManualSkip = "shuffle-or-manual", 62 + Always = "always", 63 + } 64 + 65 + /** How the outgoing track behaves during the crossfade overlap 66 + * (`crossfade_fade_out_mixmode`). */ 67 + export enum CrossfadeMixMode { 68 + /** Both tracks fade — outgoing ramps to silence as incoming ramps up. */ 69 + Crossfade = "crossfade", 70 + /** Outgoing stays at full volume; the incoming track is mixed on top. */ 71 + Mix = "mix", 72 + } 73 + 74 + /** Crossfade options (seconds; Rockbox ranges — delays 0–7 s, durations 0–15 s). */ 75 + export interface CrossfadeOptions { 76 + fadeOutDelay?: number; 77 + fadeOutDuration?: number; 78 + fadeInDelay?: number; 79 + fadeInDuration?: number; 80 + mixMode?: CrossfadeMixMode | number; 81 + } 82 + 54 83 export interface TrackMetadata { 55 84 codec?: string; 56 85 title?: string; ··· 171 200 ): void; 172 201 /** Perceptual Bass Enhancement: strength 0–100, precut in tenths of dB (≤0). */ 173 202 setPbe(strength: number, precut?: number): void; 203 + /** Rockbox crossfade (the pcmbuf algorithm). See CrossfadeMode / CrossfadeOptions. */ 204 + setCrossfade(mode: CrossfadeMode | number, opts?: CrossfadeOptions): void; 174 205 setChannelMode(mode: ChannelMode | number): void; 175 206 setStereoWidth(percent: number): void; 176 207 setCompressor(threshold: number, makeup: number, ratio: number, knee: number, release: number, attack: number): void;
+267 -20
bindings/wasm/src/rockbox-decoder-worker.js
··· 55 55 let loadToken = 0; // bumped on every track change / stop 56 56 let curInRate = 0; 57 57 58 + // ── Crossfade (Rockbox pcmbuf port — see crates/rockbox-playback/src/crossfade.rs) 59 + // Settings in seconds, mirroring apps/settings_list.c ranges & defaults. 60 + let xfadeCfg = { mode: 'off', foDelay: 0, foDur: 2, fiDelay: 0, fiDur: 2, mix: 'crossfade' }; 61 + let xfade = null; // active mixer state (armed at a crossfaded transition) 62 + let holdChunks = []; // outgoing-tail holdback: decoded PCM not yet sent to the worklet 63 + let holdLen = 0; // frames currently held 64 + let postedFrames = 0; // frames posted to the worklet since its last flush 65 + let trackBase = 0; // posted-frame epoch where the current track audibly starts 66 + 58 67 // Finite-track decoded PCM, kept alive so we can seek within it. 59 68 let rawPtr = 0, rawLen = 0, rawRate = 0, finitePos = 0; 60 69 ··· 84 93 case 'stop': return stop(); 85 94 case 'next': return skip(+1); 86 95 case 'prev': return skip(-1); 87 - case 'skipTo': return startTrack(msg.index, 0, true); 96 + case 'skipTo': return beginManual(msg.index); 88 97 case 'seek': return seek(msg.ms); 89 98 case 'shuffle': shuffle = !!msg.enabled; return emitStatus(); 90 99 case 'repeat': repeat = msg.mode | 0; return emitStatus(); 100 + case 'crossfade': return setXfadeCfg(msg); 91 101 case 'dsp': return applyDsp(msg.name, msg.args); 92 102 } 93 103 }; 94 104 105 + function setXfadeCfg(m) { 106 + const MODES = ['off', 'auto-skip', 'manual-skip', 'shuffle', 'shuffle-or-manual', 'always']; 107 + xfadeCfg = { 108 + mode: typeof m.mode === 'number' ? (MODES[m.mode] || 'off') : (m.mode || 'off'), 109 + foDelay: Math.max(0, +m.fadeOutDelay || 0), 110 + foDur: Math.max(0, +m.fadeOutDuration || 0), 111 + fiDelay: Math.max(0, +m.fadeInDelay || 0), 112 + fiDur: Math.max(0, +m.fadeInDuration || 0), 113 + mix: (m.mixMode === 1 || m.mixMode === 'mix') ? 'mix' : 'crossfade', 114 + }; 115 + } 116 + 95 117 function onInit(msg) { 96 118 sampleRate = msg.sampleRate; 97 119 dsp = Module._rb_dsp_new(sampleRate); ··· 108 130 } 109 131 110 132 // ── Worklet transport ───────────────────────────────────────────────────── 111 - function flushWorklet() { pcmPort && pcmPort.postMessage({ type: 'flush' }); wlConsumed = 0; wlQueued = 0; } 133 + function flushWorklet() { 134 + pcmPort && pcmPort.postMessage({ type: 'flush' }); 135 + wlConsumed = 0; wlQueued = 0; postedFrames = 0; trackBase = 0; 136 + } 112 137 function setWorkletPaused(v) { pcmPort && pcmPort.postMessage({ type: 'paused', value: v }); } 113 138 114 139 /** Copy `procLen` i16 samples at HEAP16[procPtr…] out of the heap, free the 115 - * heap buffer, and transfer the copy to the worklet. */ 140 + * heap buffer, and run the copy through the output pipeline 141 + * (crossfade mixer → tail holdback → worklet). */ 116 142 function postPcm(procPtr, procLen) { 117 143 const start = procPtr >> 1; 118 144 const copy = Module.HEAP16.slice(start, start + procLen); // detached copy 119 145 Module._rb_buffer_free(procPtr, procLen); 120 - pcmPort.postMessage({ pcm: copy.buffer }, [copy.buffer]); 146 + pushPcm(copy); 147 + } 148 + 149 + /** Final hop: transfer an Int16Array to the worklet. */ 150 + function emitPcm(arr) { 151 + pcmPort.postMessage({ pcm: arr.buffer }, [arr.buffer]); 121 152 // Local accounting: count the queued frames immediately. The worklet's 122 153 // periodic 'level' reports overwrite this with the truth, but those are 123 154 // macrotasks — a tight decode loop chained on microtasks would never see 124 155 // them, think the queue is empty, and blast the whole track through with 125 156 // no backpressure. 126 - wlQueued += procLen >> 1; 157 + const frames = arr.length >> 1; 158 + wlQueued += frames; 159 + postedFrames += frames; 160 + } 161 + 162 + /** Output pipeline: crossfade-mix if a fade is armed, then hold back the 163 + * newest `holdFrames()` so a transition has an outgoing tail to fade. */ 164 + function pushPcm(arr) { 165 + if (xfade) { 166 + for (const part of xfadeMix(arr)) holdPush(part); 167 + return; 168 + } 169 + holdPush(arr); 170 + } 171 + 172 + function holdPush(arr) { 173 + const H = holdFrames(); 174 + if (H <= 0) { emitPcm(arr); return; } 175 + holdChunks.push(arr); 176 + holdLen += arr.length >> 1; 177 + while (holdChunks.length && holdLen - (holdChunks[0].length >> 1) >= H) { 178 + const c = holdChunks.shift(); 179 + holdLen -= c.length >> 1; 180 + emitPcm(c); 181 + } 182 + } 183 + 184 + /** Take the held tail as one flat buffer (for a crossfaded transition). */ 185 + function takeTail() { 186 + const tail = new Int16Array(holdLen * 2); 187 + let off = 0; 188 + for (const c of holdChunks) { tail.set(c, off); off += c.length; } 189 + holdChunks = []; holdLen = 0; 190 + return tail; 191 + } 192 + 193 + /** Send everything held to the worklet (plain, non-crossfaded track end). */ 194 + function flushHold() { 195 + for (const c of holdChunks) emitPcm(c); 196 + holdChunks = []; holdLen = 0; 197 + } 198 + 199 + function dropHold() { holdChunks = []; holdLen = 0; } 200 + 201 + // ── Rockbox pcmbuf crossfade, ported from apps/pcmbuf.c via 202 + // crates/rockbox-playback/src/crossfade.rs (Q16 gains, Bresenham ramps, 203 + // saturating mix). ────────────────────────────────────────────────────── 204 + const XF_UNITY = 1 << 16; 205 + 206 + /** Linear fade stepper — pcmbuf.c mixfader_init / mixfader_step. */ 207 + class MixFader { 208 + constructor(start, end, nframes) { 209 + const nsamp2 = nframes * 2; 210 + this.endfac = end; 211 + this.nsamp2 = nsamp2; 212 + if (nsamp2 === 0) { this.factor = end; this.ferr = 0; this.dfquo = 0; this.dfrem = 0; this.dfinc = 0; return; } 213 + const dfact2 = 2 * Math.abs(end - start); 214 + this.factor = start; 215 + this.ferr = dfact2 >> 1; 216 + this.dfinc = end < start ? -1 : 1; 217 + this.dfquo = Math.trunc(dfact2 / nsamp2) * this.dfinc; 218 + this.dfrem = dfact2 - Math.trunc(dfact2 / nsamp2) * nsamp2; 219 + } 220 + step() { 221 + if (this.factor === this.endfac) return; 222 + this.factor += this.dfquo; 223 + this.ferr += this.dfrem; 224 + if (this.ferr >= this.nsamp2) { this.factor += this.dfinc; this.ferr -= this.nsamp2; } 225 + } 226 + } 227 + 228 + /** pcmbuf.c mixfade_sample(): apply a Q16 gain with rounding. */ 229 + const mixfadeSample = (factor, s) => (factor * s + (XF_UNITY >> 1)) >> 16; 230 + const clip16 = (s) => (s > 32767 ? 32767 : s < -32768 ? -32768 : s); 231 + 232 + /** Does a transition crossfade? `auto` = the track ended on its own. */ 233 + function xfadeApplies(auto) { 234 + switch (xfadeCfg.mode) { 235 + case 'auto-skip': return auto; 236 + case 'manual-skip': return !auto; 237 + case 'shuffle': return shuffle; 238 + case 'shuffle-or-manual': return shuffle || !auto; 239 + case 'always': return true; 240 + default: return false; 241 + } 242 + } 243 + 244 + /** Outgoing tail to keep for the fade-out (frames). */ 245 + function holdFrames() { 246 + if (xfadeCfg.mode === 'off') return 0; 247 + return Math.round((xfadeCfg.foDelay + xfadeCfg.foDur) * sampleRate); 248 + } 249 + 250 + /** Arm the mixer with the outgoing track's tail at a transition. */ 251 + function armCrossfade(tail) { 252 + const fr = (s) => Math.round(s * sampleRate); 253 + const foDelay = fr(xfadeCfg.foDelay), foDur = fr(xfadeCfg.foDur); 254 + const fiDelay = fr(xfadeCfg.fiDelay), fiDur = fr(xfadeCfg.fiDur); 255 + const region = Math.max(foDelay + foDur, fiDelay + fiDur, tail.length >> 1); 256 + xfade = { 257 + tail, pos: 0, region, foDelay, foDur, fiDelay, fiDur, 258 + mix: xfadeCfg.mix === 'mix', 259 + fo: new MixFader(XF_UNITY, 0, foDur), 260 + fi: new MixFader(0, XF_UNITY, fiDur), 261 + }; 262 + } 263 + 264 + /** Gains for the current region frame (stepping the faders). */ 265 + function xfadeGains(x) { 266 + let og; 267 + if (x.mix) og = XF_UNITY; 268 + else if (x.pos < x.foDelay) og = XF_UNITY; 269 + else if (x.pos < x.foDelay + x.foDur) { og = x.fo.factor; x.fo.step(); } 270 + else og = 0; 271 + let ig; 272 + if (x.pos < x.fiDelay) ig = 0; 273 + else if (x.pos < x.fiDelay + x.fiDur) { ig = x.fi.factor; x.fi.step(); } 274 + else ig = XF_UNITY; 275 + return [og, ig]; 127 276 } 128 277 129 - const highWater = () => 3 * sampleRate; // keep ≤ ~3 s buffered in the worklet 278 + /** Mix an incoming chunk against the outgoing tail; returns arrays to emit. 279 + * Frames past the crossfade region pass through untouched. */ 280 + function xfadeMix(chunk) { 281 + const x = xfade; 282 + const chunkFrames = chunk.length >> 1; 283 + const n = Math.min(x.region - x.pos, chunkFrames); 284 + const tailFrames = x.tail.length >> 1; 285 + const out = new Int16Array(n * 2); 286 + for (let f = 0; f < n; f++) { 287 + const [og, ig] = xfadeGains(x); 288 + const t = x.pos < tailFrames ? x.pos * 2 : -1; 289 + const tl = t >= 0 ? x.tail[t] : 0; 290 + const tr = t >= 0 ? x.tail[t + 1] : 0; 291 + out[f * 2] = clip16(mixfadeSample(og, tl) + mixfadeSample(ig, chunk[f * 2])); 292 + out[f * 2 + 1] = clip16(mixfadeSample(og, tr) + mixfadeSample(ig, chunk[f * 2 + 1])); 293 + x.pos++; 294 + } 295 + const parts = [out]; 296 + if (x.pos >= x.region) { 297 + xfade = null; 298 + if (n < chunkFrames) parts.push(chunk.slice(n * 2)); // remainder passes through 299 + } 300 + return parts; 301 + } 302 + 303 + /** Incoming track ended while the fade was still active — play the rest of 304 + * the outgoing tail out through its fade so it doesn't cut. */ 305 + function finalizeCrossfade() { 306 + const x = xfade; 307 + if (!x) return null; 308 + xfade = null; 309 + const tailFrames = x.tail.length >> 1; 310 + const n = Math.max(0, tailFrames - x.pos); 311 + if (n <= 0) return null; 312 + const out = new Int16Array(n * 2); 313 + for (let f = 0; f < n; f++) { 314 + const [og] = xfadeGains(x); 315 + out[f * 2] = clip16(mixfadeSample(og, x.tail[x.pos * 2])); 316 + out[f * 2 + 1] = clip16(mixfadeSample(og, x.tail[x.pos * 2 + 1])); 317 + x.pos++; 318 + } 319 + return out; 320 + } 321 + 322 + // Keep the worklet queue short when crossfade can trigger on a manual skip, 323 + // so the fade starts near "now" (queued audio can't be pulled back). 324 + const highWater = () => { 325 + const manualCapable = xfadeCfg.mode !== 'off' && xfadeCfg.mode !== 'auto-skip'; 326 + return manualCapable ? Math.round(0.6 * sampleRate) : 3 * sampleRate; 327 + }; 130 328 function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } 131 329 132 330 /** ··· 219 417 return e === 'bin' ? 'mp3' : e; 220 418 } 221 419 222 - async function startTrack(i, seekMs, autoplay) { 420 + async function startTrack(i, seekMs, autoplay, xfadeTail = null) { 223 421 if (i < 0 || i >= queue.length) { stop(); return; } 224 422 const token = ++loadToken; 225 423 const url = queue[i]; ··· 230 428 index = i; 231 429 curInRate = 0; 232 430 seekBaseMs = seekMs || 0; 233 - flushWorklet(); 234 - // Hold the worklet while the new track loads/prebuffers; the buffered path 235 - // unpauses on play and the segment loop unpauses once prebuffered. 236 - setWorkletPaused(true); 431 + if (xfadeTail && xfadeTail.length) { 432 + // Crossfaded transition: keep the worklet playing (no flush, no pause); 433 + // the incoming track's PCM will be mixed against this outgoing tail. 434 + xfade = null; // a still-active fade's remainder was captured in the tail 435 + armCrossfade(xfadeTail); 436 + trackBase = postedFrames + Math.round(xfadeCfg.fiDelay * sampleRate); 437 + } else { 438 + xfade = null; 439 + dropHold(); // a skip discards un-played audio 440 + flushWorklet(); 441 + // Hold the worklet while the new track loads/prebuffers; the buffered path 442 + // unpauses on play and the segment loop unpauses once prebuffered. 443 + setWorkletPaused(true); 444 + } 237 445 Module._rb_dsp_flush(dsp); 238 446 239 447 let resp; ··· 312 520 postMessage({ type: 'track', index: i, url, live: false, metadata: curMeta }); 313 521 if (autoplay || playing) { playing = true; userPaused = false; setWorkletPaused(false); } 314 522 emitStatus(); 315 - // Stream the decoded buffer; when it's fully posted, let the worklet PLAY 316 - // it out before advancing (advancing early would flush the queued tail). 317 - // A seek can rewind the cursor while draining — go stream again. 523 + // Stream the decoded buffer; when it's fully posted, either crossfade into 524 + // the next track (natural end) or let the worklet PLAY the queue out before 525 + // advancing. A seek can rewind the cursor while draining — stream again. 318 526 for (;;) { 319 527 const finished = await streamRaw(rawPtr, rawLen, rawRate, () => finitePos, (v) => (finitePos = v), token); 320 528 if (!finished || token !== loadToken) return; 529 + const fin = finalizeCrossfade(); // this track started mid-fade and is short 530 + if (fin) holdPush(fin); 531 + const nxt = nextAfterEnd(); 532 + if (nxt != null && xfadeApplies(true) && holdLen > 0) { 533 + const tail = takeTail(); 534 + freeRaw(); 535 + startTrack(nxt, 0, true, tail); 536 + return; 537 + } 538 + flushHold(); 321 539 const r = await waitForDrain(token, () => finitePos < rawLen); 322 540 if (r === 'aborted') return; 323 541 if (r === 'again') continue; ··· 384 602 */ 385 603 async function runSegmentLoop(reader, pending, done, ext, token, url, i, demux) { 386 604 streaming = true; 387 - const prebuf = Math.round(LIVE_PREBUFFER_SEC * sampleRate); 605 + // Prebuffer target must stay below the worklet high-water mark or the 606 + // backpressure gate would stop feeding before playback ever starts. 607 + const prebuf = Math.min(Math.round(LIVE_PREBUFFER_SEC * sampleRate), 608 + Math.max(4096, highWater() - 8192)); 388 609 let gotMeta = false; 389 610 let started = false; 390 611 const maybeStart = () => { ··· 414 635 if (token === loadToken && pending.length) await decodeSegment(pending, ext, token, !gotMeta, maybeStart); 415 636 if (token === loadToken) { 416 637 if (!started && !userPaused) setWorkletPaused(false); 417 - // Everything is decoded but the worklet may hold several seconds of 418 - // un-played audio — advancing now would flush it. Play it out first. 419 - if ((await waitForDrain(token)) === 'done') advanceAfterEnd(); 638 + await finishTrack(token); 420 639 } 421 640 } catch (err) { 422 - if (token === loadToken) { postMessage({ type: 'error', message: `stream error: ${url} (${err})`, index: i }); advanceAfterEnd(); } 641 + if (token === loadToken) { postMessage({ type: 'error', message: `stream error: ${url} (${err})`, index: i }); flushHold(); advanceAfterEnd(); } 642 + } 643 + } 644 + 645 + /** What plays after the current track ends naturally (or null to stop). */ 646 + function nextAfterEnd() { 647 + if (repeat === 1) return index; // repeat one 648 + const next = index + 1; 649 + if (next < queue.length) return next; 650 + if (repeat === 2 && queue.length) return 0; // repeat all → wrap 651 + return null; 652 + } 653 + 654 + /** Natural end of a track: crossfade into the next when configured, else 655 + * play the held tail + worklet queue out, then advance. */ 656 + async function finishTrack(token) { 657 + if (token !== loadToken) return; 658 + const fin = finalizeCrossfade(); // still mid-fade (very short track) 659 + if (fin) holdPush(fin); 660 + const nxt = nextAfterEnd(); 661 + if (nxt != null && xfadeApplies(true) && holdLen > 0) { 662 + startTrack(nxt, 0, true, takeTail()); 663 + return; 423 664 } 665 + flushHold(); 666 + if ((await waitForDrain(token)) === 'done') advanceAfterEnd(); 424 667 } 425 668 426 669 /** Decode one self-contained encoded packet in memory and stream its PCM. ··· 480 723 loadToken++; 481 724 playing = false; userPaused = false; live = false; streaming = false; 482 725 freeRaw(); 726 + xfade = null; 727 + dropHold(); 483 728 flushWorklet(); 484 729 curMeta = null; 485 730 emitStatus(); ··· 499 744 function seek(ms) { 500 745 if (live || !rawPtr) return; // live isn't seekable 501 746 seekBaseMs = ms; 747 + xfade = null; 748 + dropHold(); 502 749 flushWorklet(); 503 750 Module._rb_dsp_flush(dsp); 504 751 curInRate = 0; ··· 595 842 state: stateName(), 596 843 index, 597 844 live, 598 - elapsed_ms: seekBaseMs + Math.round(wlConsumed * 1000 / sampleRate), 845 + elapsed_ms: seekBaseMs + Math.round(Math.max(0, wlConsumed - trackBase) * 1000 / sampleRate), 599 846 duration_ms: curMeta ? (curMeta.duration_ms | 0) : 0, 600 847 metadata: curMeta, 601 848 });
+30
bindings/wasm/src/rockbox.js
··· 26 26 MonoLeft: 'mono-left', MonoRight: 'mono-right', Karaoke: 'karaoke', Swap: 'swap', 27 27 }); 28 28 export const CrossfeedMode = Object.freeze({ Off: 'off', Meier: 'meier', Custom: 'custom' }); 29 + // Rockbox crossfade (pcmbuf): when a track change crossfades… 30 + export const CrossfadeMode = Object.freeze({ 31 + Off: 'off', AutoSkip: 'auto-skip', ManualSkip: 'manual-skip', 32 + Shuffle: 'shuffle', ShuffleOrManualSkip: 'shuffle-or-manual', Always: 'always', 33 + }); 34 + // …and how the outgoing track behaves during the overlap. 35 + export const CrossfadeMixMode = Object.freeze({ Crossfade: 'crossfade', Mix: 'mix' }); 29 36 30 37 const REPEAT_NUM = { off: 0, one: 1, all: 2 }; 31 38 const REPEAT_STR = ['off', 'one', 'all']; ··· 152 159 this._save('pbe', { strength, precut }); 153 160 this._dsp('set_pbe', [strength | 0, precut | 0]); 154 161 } 162 + 163 + /** 164 + * Rockbox crossfade (the pcmbuf algorithm, ported to JS). `mode`: 165 + * CrossfadeMode.Off | .AutoSkip | .ManualSkip | .Shuffle | 166 + * .ShuffleOrManualSkip | .Always (or the raw 0–5 int). Options in seconds 167 + * (Rockbox ranges — delays 0–7 s, durations 0–15 s): 168 + * `{ fadeOutDelay, fadeOutDuration, fadeInDelay, fadeInDuration, mixMode }` 169 + * with `mixMode`: CrossfadeMixMode.Crossfade (both fade) | .Mix (outgoing 170 + * stays at full volume). 171 + */ 172 + setCrossfade(mode, opts = {}) { 173 + const cfg = { 174 + mode: typeof mode === 'number' ? mode : String(mode), 175 + fadeOutDelay: +opts.fadeOutDelay || 0, 176 + fadeOutDuration: opts.fadeOutDuration != null ? +opts.fadeOutDuration : 2, 177 + fadeInDelay: +opts.fadeInDelay || 0, 178 + fadeInDuration: opts.fadeInDuration != null ? +opts.fadeInDuration : 2, 179 + mixMode: opts.mixMode ?? 'crossfade', 180 + }; 181 + this._save('xfade', cfg); 182 + this._post({ cmd: 'crossfade', ...cfg }); 183 + } 155 184 /** ChannelMode.Stereo | .Mono | … (or the raw 0–6 index). */ 156 185 setChannelMode(mode) { const n = toNum(mode, CHAN_NUM); this._save('channelMode', n); this._dsp('set_channel_config', [n]); } 157 186 setStereoWidth(pct) { this._save('stereoWidth', pct | 0); this._dsp('set_stereo_width', [pct | 0]); } ··· 213 242 if (s.rgMode != null) this._dsp('set_replaygain', [s.rgMode | 0, s.rgNoclip ? 1 : 0, +s.rgPreamp || 0]); 214 243 if (s.crossfeed) this._dsp('set_crossfeed', [s.crossfeed.mode | 0, s.crossfeed.directGain | 0, s.crossfeed.crossLfGain | 0, s.crossfeed.crossHfGain | 0, s.crossfeed.hfCutoff | 0]); 215 244 if (s.pbe) this._dsp('set_pbe', [s.pbe.strength | 0, s.pbe.precut | 0]); 245 + if (s.xfade) this._post({ cmd: 'crossfade', ...s.xfade }); 216 246 } 217 247 218 248 // ── Internal ────────────────────────────────────────────────────────────────