record-player 0.1.0

Standalone Bitneedle record player, acoustic scratch engine, and browser command protocol
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
# 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:

```toml
[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.

```bash
rustup target add wasm32-unknown-unknown
cargo install wasm-pack
```

`SharedArrayBuffer` requires a cross-origin-isolated page. The bundled development server sends:

```text
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:

```text
../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:

```text
/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:

```text
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:

```bash
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

```bash
cd app
npm run build
npm run dev
```

Open the URL printed by the development server, normally:

```text
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:

```text
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

```text
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:

```js
globalThis.vin.yl.player
```

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

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

### Load and transport

```js
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

```js
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

```js
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

```js
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:

```js
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:

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

Replay modes:

```js
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:

```js
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:

```text
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.

```js
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:

```js
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:

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

### Theme

```js
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 `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:

```rust
pub use acoustic::{
    AcousticConfig,
    AcousticStatus,
    ScratchAcousticDsp,
};
```

The root crate can be tested natively:

```bash
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`](./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.

```bash
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.