BytorioGet started

DOCUMENTATION / experiments/vorbis-prototype/WASM-COMPARISON.md

Shared libvorbisfile seeking: WASM comparison

Decision

Compiling libvorbisfile ourselves is a better candidate for native/browser music parity than the package's reset-and-discard path. The prototype uses the same ov_pcm_seek and ov_read_float operations as native, and successfully loops without a decoded PCM cache. This supports proceeding to a small console integration; it does not establish production readiness or mobile support.

Run mise run //experiments/vorbis-prototype:compare from the repository root. The task builds both native and WASM code and records comparison.json and PCM captures in build/vorbis-prototype/. The retained run is comparison-results.json. The interactive page has a libvorbisfile WASM loop button (mise run //experiments/vorbis-prototype:serve).

Implementation

Emscripten 6.0.12 compiles the pinned libogg 1.3.5/libvorbis 1.3.7 sources with -O3, a fixed 8 MiB linear memory, and no filesystem. The generated WASM binary is 176,792 bytes (uncompressed). wasm.c supplies bounded reads and byte seeks over one resident compressed buffer to ov_open_callbacks. The worker opens, seeks, reads at most 1,024 PCM frames per request, and closes the decoder. It transfers PCM to the main-thread producer, which feeds the existing probe's AudioWorklet queue. Library callbacks, parsing and decoding all run in the worker.

One worker owns one decoder. Closing clears libvorbisfile before freeing the compressed buffer; freeing the client terminates its worker. A separate stress case keeps the module alive for 128 open/seek/read/close cycles to check that termination is not hiding retained native allocations. The bridge is a probe, not a public SDK API or an Odin binding.

The first attempted build opened with a null callback data-source pointer; libvorbisfile treats that as unavailable input. The measured build passes the resident source pointer. The failing attempt is excluded from the retained run.

Results

Both candidates run in fresh desktop Chromium processes with the same fixtures, 8,192-frame stereo queue, eight-second rendered PCM capture and comparison tolerance of 0.00002. Both decode the complete 180-second stereo track, repeat a late-track loop four times, and decode the mono fixture once and then 24 times. The package additionally exercises its PCM-cache workaround; custom WASM also has the 128-cycle lifecycle check. Timings are observations, not portable limits.

Measurement Package 0.1.20 Custom libvorbisfile WASM
Ordinary streamed playback No underruns No underruns
Late-track loop without cache 89,344 silent frames (1.86 s in 8 s) No underruns
Late-track loop setup 954–1,098 ms, reset and decode from start 0.5–3.8 ms including worker round trip and first read
Actual ov_pcm_seek call Not exposed 0.1–2.9 ms
PCM loop cache 960,000 bytes removes gaps Not needed
Full decode and loop sample parity Pass Pass
Rendered loop parity, no cache Fail Pass
250 ms main-thread producer stall 5,888 silent frames 5,696 silent frames

All successful decode comparisons have equal sample counts and maximum absolute error below 0.00000025 against native output. The full-file native baseline was also checked against FFmpeg's independent decoder in the original experiment. Loop points are frame 7,212,000 to 7,332,000 exclusive, deliberately not chosen as zero crossings; parity tests correct sample sequencing, not arbitrary click removal. The playback probe's startupMs starts after decoder initialization; do not interpret it as cold page-to-audio latency.

Memory

The custom module's fixed linear memory stayed at 8,388,608 bytes. Sampled mallinfo().uordblks reached 1,423,448 bytes, including the 1,249,816-byte compressed track. That leaves about 174 KB of sampled live decoder/allocator allocations beyond input. Static data, stack and free allocator space are part of the 8 MiB capacity but not that live-allocation count. Sampling after API calls can miss transient allocations inside a call; the fixed capacity still bounds the module's entire linear memory for these successful runs.

After every one of 128 open/seek/read/close cycles, live allocator usage returned to its initial value of zero. That is evidence of clean ownership for this fixture and lifecycle, not proof about every malformed file or decoder path.

Whole-browser private memory Package Custom WASM
Initial 80.7 MiB 78.2 MiB
Sampled maximum 507.6 MiB 365.0 MiB
After freeing workers and main-thread GC 293.0 MiB 325.9 MiB

These totals include Chromium, audio devices, JavaScript allocation churn, compressed-input copies, transferred PCM and capture overhead. Sampling is once per second, and scenario mixes include the additional cases described above. The custom run has a lower observed peak but a higher retained total; we cannot claim universally lower browser memory from this experiment. Main-thread performance.memory does not include worker/WASM heaps. The package's internal allocator is not instrumented here, so no direct per-decoder allocator comparison is claimed. The custom module's fixed heap and ownership measurements are the stronger evidence for predictable budgeting.

The output ring costs 65,536 bytes and capture costs 3,072,000 bytes, outside the WASM heap. Main-thread compressed input and worker message copies are outside it too. An 8 MiB decoder module cannot accommodate every possible cartridge blob; the limit is a deliberate probe setting, not a proposed cartridge constraint.

Remaining integration work

The API choice follows Xiph's documented memory-backed callbacks and sample-accurate seeking. No production runtime, cartridge format or SDK changes are included.