record-player 0.1.0

Standalone Bitneedle record player, acoustic scratch engine, and browser command protocol
Documentation

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-player is the main crate. It owns transport state, commands, playback state, mixing decisions, stylus calibration, and the ScratchAcousticDsp used by the AudioWorklet.
  • player-wasm is the decoder-worker crate. It reads Bitneedle PNG records and exposes the record/ECDC functions required by this player.
  • app is the browser host, decoder worker, PCM-window worker, AudioWorklet, IndexedDB stores, public JavaScript API, and optional canvas renderer.

The workspace is:

[workspace]
members = [".", "player-wasm"]
resolver = "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-unknown Rust target;
  • wasm-pack;
  • a browser with AudioWorklet, WebAssembly, IndexedDB and SharedArrayBuffer support.
rustup target add wasm32-unknown-unknown
cargo install wasm-pack

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:

export BITNEEDLE_SHARED_DIR=/Users/jamie/wavey.ai/yl.vin/apps/shared
export BITNEEDLE_ONNX_RUNTIME_DIR=/Users/jamie/wavey.ai/yl.vin/vendor/wasm/onnxruntime-web
export BITNEEDLE_ENCODEC_BUNDLES_DIR=/Users/jamie/wavey.ai/yl.vin/vendor/wasm/encodec-rs/bundles

Build and run

cd app
npm run build
npm run dev

Open the URL printed by the development server, normally:

http://localhost:5193

The build:

  1. clears app/dist;
  2. copies app/src into app/dist;
  3. builds the root record-player crate into app/dist/wasm/record-player;
  4. builds player-wasm into app/dist/wasm/player-wasm;
  5. copies the shared decoder scripts;
  6. 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.vin.yl.player

It also dispatches vin.yl.player.ready after the Rust core worker is ready:

window.addEventListener("vin.yl.player.ready", event => {
  const player = event.detail;
  console.log(player.getState());
});

Load and transport

const player = globalThis.vin.yl.player;

await player.loadRecord(file);
await player.play();
await player.pause();
await player.togglePlayback();

player.seekSeconds(42.5);
player.seekRatio(0.5);
await player.setNeedleLifted(false);

loadRecord expects a browser File containing a Bitneedle PNG.

Deck controls

await player.setRpm(45);
await player.setVolume(0.8);
await player.setCrossfader(0.5);
  • RPM is clamped to 16..90 by 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.beginScratch({
  pointerId: 1,
  positionFrames: player.getState().positionFrames,
  rotationDegrees: 0,
  rate: 0,
  impulse: 0.22
});

await player.updateScratch({
  positionFrames: 120000,
  rotationDegrees: -18,
  rate: -0.8,
  impulse: 0.2
});

await player.endScratch({
  rotationDegrees: -18,
  resumePlayback: true
});

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.subscribe(state => {
  console.log({
    ready: state.ready,
    playing: state.playing,
    needleLifted: state.needleLifted,
    scratching: state.scratching,
    positionSeconds: state.positionSeconds,
    durationSeconds: state.durationSeconds,
    positionRatio: state.positionRatio,
    rpm: state.rpm,
    nativeRpm: state.nativeRpm,
    playbackRate: state.playbackRate,
    volume: state.volume,
    crossfader: state.crossfader,
    recordProfile: state.recordProfile,
    payloadContainer: state.payloadContainer,
    releaseId: state.releaseId,
    recordHash: state.recordHash,
    recordImageUrl: state.recordImageUrl,
    rotationDegrees: state.rotationDegrees,
    sampleRate: state.sampleRate,
    positionFrames: state.positionFrames
  });
});

unsubscribe();

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.startScratchRecording({ name: "flare take 1" });

// Scratch through the canvas or the programmatic scratch API.

const performance = await player.stopScratchRecording({ save: true });

Manual recorder lifecycle:

const recorder = player.createScratchRecorder({ name: "orbit" });
recorder.start();
const performance = recorder.stop();
await player.scratches.save(performance);

Replay modes:

await player.replayScratch(performance, { effects: "original" });
await player.replayScratch(performance, { effects: "dry" });
await player.replayScratch(performance, {
  effects: { acoustic: true, surface: false }
});

player.cancelScratchReplay();

dry retains the recorded mechanics—position, direction, rate, motor handoff, spring and interpolation—but disables acoustic coloration and surface layers.

Persistence API:

await player.scratches.save(performance);
const saved = await player.scratches.get(performance.id);
const list = await player.scratches.list({ limit: 50 });
await player.scratches.delete(performance.id);
await player.scratches.clear();

const json = player.scratches.export(performance);
const imported = player.scratches.import(json);

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.querySelector("#player-canvas");
const controller = player.canvas.mount(canvas, {
  components: {
    crossfader: false
  },
  theme: {
    background: "transparent"
  },
  strobeLightOn: true
});

The bundled page mounts its canvas automatically.

Components

Every visual/control group can be toggled independently:

player.canvas.configure({
  components: {
    record: true,
    syncRings: true,
    strobeLamp: true,
    spindle: true,
    stylus: true,
    needlePoint: true,
    tonearmGuide: true,
    startStop: true,
    needle: true,
    rpm: true,
    volume: true,
    crossfader: true,
    seek: true,
    labels: true
  }
});

Or individually:

player.canvas.setComponentVisible("crossfader", false);
player.canvas.setComponentVisible("tonearmGuide", false);
player.canvas.setStrobeLight(true);

Theme

player.canvas.setTheme({
  background: "transparent",
  line: "#050505",
  mutedLine: "rgba(5,5,5,0.28)",
  controlFill: "rgba(255,255,255,0.08)",
  controlActive: "#050505",
  controlText: "#050505",
  controlActiveText: "#f00020",
  accent: "#00bfd3",
  accentSecondary: "#ef035c",
  accentTertiary: "#f3b511",
  recordFallback: "transparent",
  turntableRing: "rgba(5,5,5,0.18)",
  turntableRingStrong: "rgba(5,5,5,0.34)",
  label: "transparent",
  syncDot: "rgba(0,0,0,0.23)",
  syncLit: "#00bfd3",
  lamp: "#00bfd3",
  tonearm: "#050505",
  tonearmGuide: "rgba(5,5,5,0.35)",
  stylus: "#00bfd3",
  stylusGlow: "rgba(0,191,211,0.65)"
});

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.22 of 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 .
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 , 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 acoustic::{
    AcousticConfig,
    AcousticStatus,
    ScratchAcousticDsp,
};

The root crate can be tested natively:

cargo test

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/wasm for .wasm files;
  • JavaScript MIME types for .js and .mjs;
  • COOP same-origin;
  • COEP require-corp;
  • same-origin access to workers, WASM, ONNX models and .data files;
  • 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.

cargo package -p record-player
cargo publish -p record-player

Publishing record-player does not package or publish player-wasm; it is not a dependency of the root crate.