vin.yl.player
A standalone Bitneedle picture-record player with a Rust transport model, a Rust acoustic scratch engine, a player-only record decoder, shared-memory PCM windowing, frame-timed scratch-performance replay, and an optional canvas turntable UI.
The repository deliberately separates the real-time renderer from record parsing:
record-playeris the main crate. It owns transport state, commands, playback state, mixing decisions, stylus calibration, and theScratchAcousticDspused by the AudioWorklet.player-wasmis the decoder-worker crate. It reads Bitneedle PNG records and exposes the record/ECDC functions required by this player.appis the browser host, decoder worker, PCM-window worker, AudioWorklet, IndexedDB stores, public JavaScript API, and optional canvas renderer.
The workspace is:
[]
= [".", "player-wasm"]
= "2"
Status
This is a focused standalone player rather than a copy of the legacy play application. It currently includes:
- Bitneedle PNG inspection and ECDC decoding;
- Rust/WASM transport and acoustic rendering;
- motor playback, braking, pitch/RPM changes, scratching and needle lift;
- double-buffered shared-memory PCM windows;
- IndexedDB PCM caching;
- frame-timed scratch-performance capture, persistence and replay;
- a configurable radial canvas UI with a stylus, concentric turntable, Technics-style pitch control, strobe rows and lamp.
It does not currently include the legacy application's TAPE master monitor, remote scratch sessions, two-deck playback, lead-in/deadwax foley asset, HTML-audio fallback, waveform UI, sample library, or authoring tools.
Requirements
Install:
- Node.js 20 or newer;
- Rust and Cargo;
- the
wasm32-unknown-unknownRust target; wasm-pack;- a browser with AudioWorklet, WebAssembly, IndexedDB and
SharedArrayBuffersupport.
SharedArrayBuffer requires a cross-origin-isolated page. The bundled development server sends:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
A production host must send equivalent headers for the player page and its worker/WASM assets.
Repository dependencies
player-wasm/Cargo.toml currently references Bitneedle Rust crates through local paths such as:
../bitneedle/record-core
../bitneedle/record-decode
../bitneedle/record-descriptor
../bitneedle/record-sidecar
../bitneedle/encodec-rs
With this repository at /path/to/vin.yl.player, the expected sibling layout is therefore:
/path/to/
├── vin.yl.player/
└── bitneedle/
The browser build also needs five shared decoder helper scripts, ONNX Runtime Web assets, and the EnCodec ONNX bundles. Their locations can be supplied with environment variables.
Environment variables
| Variable | Required | Default | Purpose |
|---|---|---|---|
BITNEEDLE_SHARED_DIR |
When the default layout is absent | ../bitneedle-platform/apps/shared |
Directory containing the shared decoder helper scripts. |
BITNEEDLE_ONNX_RUNTIME_DIR |
When the default layout is absent | ../bitneedle-platform/vendor/wasm/onnxruntime-web |
ONNX Runtime Web distribution copied into app/dist/wasm/onnxruntime-web. |
BITNEEDLE_ENCODEC_BUNDLES_DIR |
When the default layout is absent | ../bitneedle-platform/vendor/wasm/encodec-rs/bundles |
EnCodec ONNX bundles copied into app/dist/wasm/encodec-rs/onnx-bundles. |
PORT |
No | 5193 |
Port used by the development server. A positional argument to npm run dev -- 8000 also works. |
BITNEEDLE_SHARED_DIR must contain:
browser-formatting.js
encodec-bundle-names.js
onnx-runtime-session.js
onnx-worker-tensors.js
ecdc-pcm-layout.js
Example using the paths from the YL.VIN repository:
Build and run
Open the URL printed by the development server, normally:
http://localhost:5193
The build:
- clears
app/dist; - copies
app/srcintoapp/dist; - builds the root
record-playercrate intoapp/dist/wasm/record-player; - builds
player-wasmintoapp/dist/wasm/player-wasm; - copies the shared decoder scripts;
- copies ONNX Runtime Web and the EnCodec bundles.
Generated browser modules:
app/dist/wasm/record-player/record_player.js
app/dist/wasm/record-player/record_player_bg.wasm
app/dist/wasm/player-wasm/player_wasm.js
app/dist/wasm/player-wasm/player_wasm_bg.wasm
Architecture
Bitneedle PNG
│
▼
record-decoder-worker.js
│ player-wasm + ONNX Runtime + EnCodec bundles
│ decoded signed 16-bit PCM channels
▼
IndexedDB PCM cache
│
▼
pcm-window-worker.js
│ one-second source chunks
│ alternating 12-second SharedArrayBuffer banks
▼
player-worklet.js
│ record-player / ScratchAcousticDsp
│ motor, hand control, interpolation, acoustic texture
▼
GainNode
▼
AudioContext destination
The main thread routes commands and publishes state. It does not assemble a whole floating-point record for the AudioWorklet. The PCM-window worker fills one inactive shared bank while the worklet reads the other, then announces a completed window swap.
The current bank length is twelve seconds at the source sample rate. Source chunks are one second. The Rust engine requests replacement windows before the stylus reaches a bank edge, with extra projection in the current direction of travel. A six-millisecond fade masks a temporary window miss rather than producing a hard discontinuity.
JavaScript API
The player publishes itself at:
globalThis...
It also dispatches vin.yl.player.ready after the Rust core worker is ready:
window.;
Load and transport
const player = globalThis...;
await player.;
await player.;
await player.;
await player.;
player.;
player.;
await player.;
loadRecord expects a browser File containing a Bitneedle PNG.
Deck controls
await player.;
await player.;
await player.;
- RPM is clamped to
16..90by the host. - Volume is clamped to
0..1. - Crossfader is clamped to
0..1.
The canvas presents pitch as a Technics-style ±8% control around the record's native RPM, but the lower-level API accepts an absolute RPM.
Programmatic scratching
await player.;
await player.;
await player.;
Pointer geometry belongs in the UI layer. The player API accepts normalized engine commands expressed in source frames, rates and rotation degrees.
State subscription
const unsubscribe = player.;
;
player.getState() returns the same immutable snapshot immediately.
Scratch performances
A saved scratch is a versioned stream of engine commands, not rendered audio and not raw pointer coordinates. Events are timestamped in audio frames and replayed inside the AudioWorklet, including events that fall partway through a 128-frame render quantum.
Start and stop the default recorder:
player.;
// Scratch through the canvas or the programmatic scratch API.
const performance = await player.;
Manual recorder lifecycle:
const recorder = player.;
recorder.;
const performance = recorder.;
await player..;
Replay modes:
await player.;
await player.;
await player.;
player.;
dry retains the recorded mechanics—position, direction, rate, motor handoff, spring and interpolation—but disables acoustic coloration and surface layers.
Persistence API:
await player..;
const saved = await player..;
const list = await player..;
await player..;
await player..;
const json = player..;
const imported = player..;
IndexedDB details:
database: vin.yl.player
store: scratch-performances
version: 2
indexes: recordHash, createdAt, [recordHash, createdAt]
list() and clear() are scoped to the currently loaded record unless a query overrides recordHash.
Canvas UI
The canvas is optional and consumes only the public player API. It does not reach into WASM, PCM banks, decoder state or the AudioWorklet.
const canvas = document.;
const controller = player..;
The bundled page mounts its canvas automatically.
Components
Every visual/control group can be toggled independently:
player..;
Or individually:
player..;
player..;
player..;
Theme
player..;
The current canvas configuration is available through player.canvas.getConfig(). Call player.canvas.destroy() to unmount it.
The physical strobe dots always rotate. Inside the diffuse lamp beam, a separately calibrated sample is drawn so the matching row appears stationary while the same dots remain visibly in motion outside the light.
Acoustics
The acoustic engine is implemented in src/acoustic.rs and exported as ScratchAcousticDsp by the main record-player crate. The AudioWorklet is the only browser component that instantiates it.
One transport and acoustic clock
Normal playback and scratching use the same rendered groove position. Motor spin-up, braking, grabbing, reversing, releasing and seeking do not switch between unrelated audio engines. The Rust DSP position is the authoritative audio clock; the host mirrors it into public state and the canvas uses that state to move the record and stylus.
There are two control conditions:
- motor control: the platter moves toward a motor-delivered target rate;
- hand control: the target position and rate come from scratch events.
Both feed the same damped rate spring:
| Constant | Current value | Role |
|---|---|---|
RATE_SPRING_OMEGA |
70 rad/s |
Rate coupling stiffness. |
RATE_SPRING_ZETA |
0.85 |
Slightly underdamped response for a small reversal snap. |
MOTOR_SPINUP_SECONDS |
0.30 s |
Motor delivery ramp from rest. |
MOTOR_BRAKE_SECONDS |
0.32 s |
Motor delivery ramp toward zero. |
GRIP_ATTACK_SECONDS |
0.10 s |
Hand ownership fade-in. |
GRIP_RELEASE_SECONDS |
0.045 s |
Hand ownership release. |
POSITION_CATCHUP_SECONDS |
0.28 s |
Gentle position-error correction while scratching. |
STILL_SNAP_SECONDS |
0.03 s |
Collapses residual target error when the hand becomes still. |
DEADZONE_RATE |
0.006 |
Below this rate the cartridge output is silent. |
The old acoustics document listed a 0.22 s motion hold. The current standalone engine uses MOTION_HOLD_SECONDS = 0.05 s and MOTION_HOLD_RELEASE_SECONDS = 0.06 s.
Stylus sampling
Each output frame samples the source at a fractional groove position using four-point Catmull-Rom interpolation. The same neighborhood produces slope and curvature estimates used by the source-texture layer.
A speed-dependent one-pole low-pass models tracing and drag:
- maximum cutoff:
19 kHz; - nominal-speed knee:
0.95×; - slow movement becomes progressively duller;
- tracing loss starts above
2.5×and reduces the high-frequency cutoff.
Movement gain is zero inside the deadzone and otherwise remains bounded between 0.68 and 1.08, with a small presence lift near true speed.
Wow and flutter
Wow and flutter alter the sampled source position rather than running as a post-effect:
- wow period defaults to
1.8 s; - flutter defaults to
6.4 Hz; - wow phase follows record motion, so it slows and reverses with the groove;
- depth is
0.0012 × clamp(|rate|, 0, 1.2); - flutter depth is
0.22of wow depth; - modulation is disabled below
|rate| = 0.18.
AcousticConfig allows the maximum rate, wow period, flutter frequency, acoustic effects and surface effects to be configured when the DSP is constructed. The browser host currently uses the defaults and switches effect groups during scratch replay through setEffects.
Surface and handling layers
All layers are deliberately reduced near true-speed playback and become more apparent during handling:
| Layer | Current source | Current tuning |
|---|---|---|
| Contact bed | Filtered pseudo-random noise | CONTACT_NOISE_GAIN = 8e-5, with a 94% dip around 1×. |
| Groove grain | Position-keyed deterministic noise | Repeats at the same groove position when scrubbed backwards and forwards. |
| Source texture | Local waveform slope and curvature | SOURCE_TEXTURE_GAIN = 1.8e-4. |
| Dust flecks | Sparse position-keyed cells | DUST_FLECK_GAIN = 4.5e-5. |
| Acceleration texture | Difference between current and previous effective rates | Mixed into the source-texture response. |
| Contact impulse | Explicit gesture impulse plus decay | CONTACT_IMPULSE_DECAY = 0.985. |
Because groove grain and dust are keyed to source position, their texture is spatially stable rather than being unrelated white noise on every pass.
Effect groups
The DSP exposes two replay-selectable groups:
- acoustic: wow/flutter, drag/tracing response, movement gain and program-correlated source texture;
- surface: contact bed, deterministic groove grain, dust and contact impulses.
Original replay enables both. Dry replay disables both while retaining mechanical motion and interpolation. A custom object can enable either group independently.
Needle lift and needle point
Needle lift mutes cartridge output without requiring the visual platter to stop. The canvas tonearm and stylus are presentation components driven from player state. Dragging the needle point seeks through player.seekRatio; it does not directly mutate the AudioWorklet or transport internals.
The standalone build does not currently synthesize the legacy needle-drop thump/crackle asset, lead-in static or deadwax loop described in the old application document.
Window stability
The DSP owns only the current source window, not the full decoded record. It requests a replacement window when the rendered or projected stylus position approaches an edge.
Current tuning:
| Constant | Value |
|---|---|
| Shared bank duration | 12 s |
| Source chunk duration | 1 s |
WINDOW_REQUEST_MARGIN_SECONDS |
0.75 s, speed-scaled |
WINDOW_REQUEST_PROJECT_SECONDS |
0.18 s |
WINDOW_MISS_FADE_SECONDS |
0.006 s |
At rates above 2×, request checks are throttled to roughly 30 ms; otherwise they run at roughly 80 ms. The worklet fades through a short window miss instead of abruptly holding or zeroing a sample.
Scratch replay resolution
Scratch events are captured against the AudioContext clock in source-sample frames. The worklet receives the complete performance and applies events at their declared frame offsets. If an event falls inside the current Web Audio render quantum, the worklet divides processing at that event boundary rather than waiting for a main-thread timer or animation frame.
This is at least as precise as the legacy telemetry format, which intentionally throttled pointer-derived events. The current format records the normalized commands actually sent to the engine and preserves their audio-frame timing.
Motion and canvas sync
The canvas advances the visible record from the published RPM during ordinary motor playback and follows explicit rotation state during scratching. Audio remains authoritative; the canvas never writes directly to DSP memory. The strobe renderer is visual calibration rather than an audio clock:
- physical rows rotate continuously;
- only the dots under the lamp receive the calibrated stroboscopic sample;
- the row matching the current pitch appears steady under the lamp;
- the same dots remain visibly moving outside the beam.
Rust API
The main crate exports the transport types plus:
pub use ;
The root crate can be tested natively:
Build the complete browser application through app/scripts/build.mjs; it supplies the wasm feature and correct output names.
Deployment checklist
A static deployment must preserve:
application/wasmfor.wasmfiles;- JavaScript MIME types for
.jsand.mjs; - COOP
same-origin; - COEP
require-corp; - same-origin access to workers, WASM, ONNX models and
.datafiles; - all files generated under
app/dist/wasm; - the copied decoder helper scripts at the root of
app/dist.
Do not open app/dist/index.html with file://; workers, modules, AudioWorklet and cross-origin isolation require an HTTP server.
Additional API reference
See API.md for the method-by-method API notes. The source of truth remains app/src/player-host.js, app/src/player-canvas.js, app/src/player-worklet.js and src/acoustic.rs.
Publishing record-player
The root crate is the workspace default and is publishable. The local player-wasm workspace member is marked publish = false, so it cannot be accidentally uploaded to crates.io.
Publishing record-player does not package or publish player-wasm; it is not a dependency of the root crate.