Skip to main content

bitneedle_player/
lib.rs

1use base64::{engine::general_purpose, Engine as _};
2use encodec_rs::arithmetic::ArithmeticDecoder;
3use encodec_rs::binary::{read_chunk_payload, read_ecdc_header, read_exactly};
4use encodec_rs::format::{
5    ecdc_chunk_layout_from_metadata, ecdc_frame_ranges, ecdc_lm_frame_length, validate_metadata,
6    EcdcChunkLayout, EcdcMetadata, ARITHMETIC_TOTAL_RANGE_BITS, DEFAULT_FP_SCALE,
7    DEFAULT_MIN_RANGE, QUANTIZED_LM_BITSTREAM_VERSION,
8};
9use encodec_rs::metadata::OnnxFrameBundleMetadata;
10use encodec_rs::quantized_lm::{QuantizedLm, QuantizedLmState, QuantizedLmWeights};
11use encodec_rs::stable_hash::stable_hash_hex;
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Map, Value};
14use std::io::Cursor;
15use wasm_bindgen::prelude::*;
16
17const SCRATCH_DISPLAY_NAME_MAX_LENGTH: usize = 32;
18const OPUS_CHUNK_CACHE_KEY_FORMAT: &str = "bitneedle-opus-chunk-cache-keys-v1";
19const OPUS_CHUNK_CACHE_STORE_NAME: &str = "opus-chunks";
20const OPUS_CHUNK_CACHE_VERSION: &str = "bitneedle-opus-chunk-cache-v2";
21const OPUS_CHUNK_CACHE_OUTPUT_CODEC: &str = "soundkit_opus_packets";
22const OPUS_CHUNK_CACHE_BITRATE: u32 = 64_000;
23const OPUS_CHUNK_CACHE_KEY_DOMAIN: &str = "bitneedle.opus-chunk-cache-key.v1";
24
25#[wasm_bindgen(js_name = initPanicHook)]
26pub fn init_panic_hook() {
27    console_error_panic_hook::set_once();
28}
29
30#[wasm_bindgen(js_name = playerAppBuildInfoJson)]
31pub fn player_app_build_info_json() -> String {
32    json!({
33        "crate": "player-wasm",
34        "api": "bitneedle-player-app-wasm",
35        "version": env!("CARGO_PKG_VERSION"),
36        "builtFrom": "bitneedle/player-wasm",
37        "recordProfiles": record_core::known_record_profile_names(),
38    })
39    .to_string()
40}
41
42#[derive(Debug, Clone, Serialize)]
43#[serde(rename_all = "camelCase")]
44struct PlayerLmEcdcChunk {
45    offset: usize,
46    samples: usize,
47    frame_length: usize,
48    payload: Vec<u8>,
49}
50
51#[derive(Debug, Clone, Serialize)]
52#[serde(rename_all = "camelCase")]
53struct PlayerLmEcdcChunks {
54    metadata: EcdcMetadata,
55    chunks: Vec<PlayerLmEcdcChunk>,
56}
57
58#[derive(Debug, Clone, Serialize)]
59#[serde(rename_all = "camelCase")]
60struct Bcs2OpusChunkCacheKeys {
61    format: &'static str,
62    store_name: &'static str,
63    cache_version: &'static str,
64    output_codec: &'static str,
65    bitrate: u32,
66    keys: Vec<String>,
67}
68
69#[wasm_bindgen(js_name = stableHashHex)]
70pub fn wasm_stable_hash_hex(bytes: &[u8]) -> String {
71    stable_hash_hex(bytes)
72}
73
74#[wasm_bindgen(js_name = bcs2OpusChunkCacheKeysJson)]
75pub fn wasm_bcs2_opus_chunk_cache_keys_json(bcs2: &[u8]) -> Result<String, JsValue> {
76    let keys = bcs2_opus_chunk_cache_keys(bcs2).map_err(to_js_error)?;
77    serde_json::to_string(&keys).map_err(to_js_error)
78}
79
80#[wasm_bindgen(js_name = ecdcMetadata)]
81pub fn wasm_ecdc_metadata(payload: &[u8]) -> Result<JsValue, JsValue> {
82    let metadata: EcdcMetadata =
83        read_ecdc_header(&mut Cursor::new(payload)).map_err(to_js_error)?;
84    to_js_value(&metadata)
85}
86
87#[wasm_bindgen(js_name = ecdcFrameRanges)]
88pub fn wasm_ecdc_frame_ranges(payload: &[u8]) -> Result<JsValue, JsValue> {
89    let mut reader = Cursor::new(payload);
90    let _metadata: EcdcMetadata = read_ecdc_header(&mut reader).map_err(to_js_error)?;
91    let payload_start = reader.position() as usize;
92    let ranges = ecdc_frame_ranges(payload, payload_start).map_err(to_js_error)?;
93    let mapped: Vec<Value> = ranges
94        .into_iter()
95        .map(|range| {
96            json!({
97                "start": range.start,
98                "end": range.end,
99                "byteLength": range.end - range.start,
100            })
101        })
102        .collect();
103    to_js_value(&mapped)
104}
105
106#[wasm_bindgen(js_name = ecdcChunkLayoutFromMetadata)]
107pub fn wasm_ecdc_chunk_layout_from_metadata(
108    bundle_json: &str,
109    payload: &[u8],
110    chunk_count: usize,
111) -> Result<JsValue, JsValue> {
112    let meta = parse_encodec_bundle_metadata(bundle_json)?;
113    let metadata: EcdcMetadata =
114        read_ecdc_header(&mut Cursor::new(payload)).map_err(to_js_error)?;
115    // `chunk_count` here is the stream-wide total across every revolution, so the
116    // old single-object cross-check (chunk_count == segment_starts(this header))
117    // no longer holds for a multi-revolution payload. The chunk layout (samples +
118    // stride) is uniform for the profile/bundle, so the per-object default is the
119    // correct answer; per-revolution chunk counts are already enforced inside the
120    // decode loop in `lmEcdcDecodeChunks`.
121    let _ = chunk_count;
122    let layout: EcdcChunkLayout =
123        ecdc_chunk_layout_from_metadata(&meta, &metadata).map_err(to_js_error)?;
124    to_js_value(&json!({
125        "samples": layout.samples,
126        "stride": layout.stride,
127    }))
128}
129
130/// Crop a guarded decode down to its owned audio: drops the left/right guard
131/// samples the encoder padded each revolution with, leaving exactly the owned
132/// (audible) samples. Mirrors `encodec_rs::wasm::ecdc_crop_decoded_owned_audio`
133/// so the player worker can call it on the bitneedle-player module.
134#[wasm_bindgen(js_name = ecdcCropDecodedOwnedAudio)]
135pub fn wasm_ecdc_crop_decoded_owned_audio(
136    bundle_json: &str,
137    decoded_audio: &[f32],
138) -> Result<Vec<f32>, JsValue> {
139    let meta = parse_encodec_bundle_metadata(bundle_json)?;
140    let channels = meta.channels;
141    if channels == 0 {
142        return Err(to_js_error("bundle channels must be positive"));
143    }
144    if decoded_audio.len() % channels != 0 {
145        return Err(to_js_error(format!(
146            "decoded audio length {} is not divisible by channels {}",
147            decoded_audio.len(),
148            channels
149        )));
150    }
151    let decoded_samples = decoded_audio.len() / channels;
152    // The decoded-block output geometry is now owned by the BRS1 ECDC payload
153    // profile (record_core::ecdc), not the codec bundle: decode the full block,
154    // then retain [offset .. offset + output). The discarded tail is derived.
155    let left_guard = record_core::ecdc::ECDC_OUTPUT_OFFSET_SAMPLES as usize;
156    let owned_samples = record_core::ecdc::ECDC_OUTPUT_SAMPLES as usize;
157    let right_guard = (record_core::ecdc::ECDC_BLOCK_SAMPLES
158        - record_core::ecdc::ECDC_OUTPUT_OFFSET_SAMPLES
159        - record_core::ecdc::ECDC_OUTPUT_SAMPLES) as usize;
160    let required = left_guard
161        .checked_add(owned_samples)
162        .and_then(|value| value.checked_add(right_guard))
163        .ok_or_else(|| to_js_error("guarded decode geometry overflows usize"))?;
164    if decoded_samples < required {
165        return Err(to_js_error(format!(
166            "decoded audio has {} samples per channel but guarded profile requires at least {}",
167            decoded_samples, required
168        )));
169    }
170    let mut cropped = vec![0.0_f32; channels * owned_samples];
171    for channel in 0..channels {
172        let src_base = channel * decoded_samples + left_guard;
173        let dst_base = channel * owned_samples;
174        cropped[dst_base..dst_base + owned_samples]
175            .copy_from_slice(&decoded_audio[src_base..src_base + owned_samples]);
176    }
177    Ok(cropped)
178}
179
180/// One encoded revolution at the BRS1 boundary: a shared codec/container
181/// description plus only the bytes that vary per revolution (the headerless
182/// codec payload body). Mirrors the `EncodedPayload` shape from the WASM
183/// headerless-ECDC contract.
184#[wasm_bindgen]
185pub struct WasmEncodedPayload {
186    descriptor: JsValue,
187    payload: Vec<u8>,
188}
189
190#[wasm_bindgen]
191impl WasmEncodedPayload {
192    #[wasm_bindgen(getter)]
193    pub fn descriptor(&self) -> JsValue {
194        self.descriptor.clone()
195    }
196
197    #[wasm_bindgen(getter)]
198    pub fn payload(&self) -> Vec<u8> {
199        self.payload.clone()
200    }
201}
202
203/// Split a standalone ECDC byte stream (one fixed-profile revolution block) into
204/// the shared `PayloadDescriptor` and the headerless codec payload bytes stored
205/// in the BRS1 groove. The ECDC outer header is lifted into the descriptor.
206#[wasm_bindgen(js_name = ecdcStandaloneToPayload)]
207pub fn wasm_ecdc_standalone_to_payload(
208    sample_rate: u32,
209    channels: u8,
210    ecdc_bytes: &[u8],
211) -> Result<WasmEncodedPayload, JsValue> {
212    let (descriptor, payload) =
213        record_core::ecdc::standalone_ecdc_to_payload(sample_rate, channels, ecdc_bytes)
214            .map_err(to_js_error)?;
215    Ok(WasmEncodedPayload {
216        descriptor: to_js_value(&descriptor)?,
217        payload,
218    })
219}
220
221/// Reconstruct a decodable standalone ECDC byte stream from a `PayloadDescriptor`
222/// (JSON) and the headerless codec payload bytes. Inverse of
223/// [`wasm_ecdc_standalone_to_payload`]; lets the player feed the existing decode
224/// path without storing repeated ECDC headers in the groove.
225#[wasm_bindgen(js_name = payloadToStandaloneEcdc)]
226pub fn wasm_payload_to_standalone_ecdc(
227    descriptor_json: &str,
228    payload: &[u8],
229) -> Result<Vec<u8>, JsValue> {
230    let descriptor: record_core::PayloadDescriptor =
231        serde_json::from_str(descriptor_json).map_err(to_js_error)?;
232    record_core::ecdc::payload_to_standalone_ecdc(&descriptor, payload).map_err(to_js_error)
233}
234
235/// Verify that two `PayloadDescriptor`s (JSON) are identical across every field,
236/// so a record builder can confirm all revolutions share one descriptor before
237/// storing it once. Errors name the differing field.
238#[wasm_bindgen(js_name = validateSharedPayloadDescriptor)]
239pub fn wasm_validate_shared_payload_descriptor(
240    expected_json: &str,
241    actual_json: &str,
242) -> Result<(), JsValue> {
243    let expected: record_core::PayloadDescriptor =
244        serde_json::from_str(expected_json).map_err(to_js_error)?;
245    let actual: record_core::PayloadDescriptor =
246        serde_json::from_str(actual_json).map_err(to_js_error)?;
247    record_core::validate_shared_payload_descriptor(&expected, &actual).map_err(to_js_error)
248}
249
250/// True when the next four bytes at the reader's position are the "ECDC" object
251/// header magic — i.e. the current revolution's chunks have ended and the next
252/// concatenated ECDC object begins.
253fn next_is_ecdc_magic(payload: &[u8], reader: &Cursor<&[u8]>) -> bool {
254    let pos = reader.position() as usize;
255    payload.get(pos..pos + 4) == Some(b"ECDC")
256}
257
258#[wasm_bindgen(js_name = lmEcdcDecodeChunks)]
259pub fn wasm_lm_ecdc_decode_chunks(bundle_json: &str, payload: &[u8]) -> Result<JsValue, JsValue> {
260    let meta = parse_encodec_bundle_metadata(bundle_json)?;
261    // The final compact Bitneedle format stores one complete ECDC object per
262    // physical revolution and concatenates them back-to-back into a single
263    // payload (payload_entry_index == revolution_index). Each object is fully
264    // self-delimiting: its header declares `audio_length`, and with the bundle's
265    // stride that yields exactly how many transport chunks belong to it. So we
266    // walk object-by-object, reading precisely the implied chunk count for each
267    // revolution rather than draining the whole buffer. Draining the buffer is
268    // what the single-object reader used to do, and on a multi-revolution
269    // payload it ran straight into the next object's "ECDC" magic and tried to
270    // read it as a chunk length (the "stream ended early with 1161947177 bytes
271    // remaining" failure — 1161947177 is the ASCII bytes `E C D C`).
272    let mut reader = Cursor::new(payload);
273    let mut chunks = Vec::new();
274    // Sample offsets must be global across the whole stream so downstream PCM
275    // overlap-add stays continuous; we carry a running base across revolutions.
276    let mut global_offset = 0usize;
277    let mut aggregate_metadata: Option<EcdcMetadata> = None;
278
279    while (reader.position() as usize) < payload.len() {
280        let metadata: EcdcMetadata = read_ecdc_header(&mut reader).map_err(to_js_error)?;
281        validate_metadata(&meta, &metadata).map_err(to_js_error)?;
282        if !metadata.use_lm {
283            return Err(to_js_error("ECDC payload does not use LM coding"));
284        }
285
286        // A revolution's transport chunks run until the next object's "ECDC"
287        // header or end-of-stream. We can't predict the count from the bundle
288        // stride: the old continuous-overlap encoding (stride < segment_samples)
289        // implied 2 windows for a 64000-sample span, but a per-revolution object
290        // is one self-contained non-overlapping segment and emits a single chunk.
291        // The 4-byte "ECDC" magic is an unambiguous boundary sentinel — no chunk
292        // length prefix could legitimately equal 0x45434443 (~1.1 GiB).
293        let layout = ecdc_chunk_layout_from_metadata(&meta, &metadata).map_err(to_js_error)?;
294        let mut local_offset = 0usize;
295        while (reader.position() as usize) < payload.len() && !next_is_ecdc_magic(payload, &reader)
296        {
297            let payload = read_chunk_payload(&mut reader, true).map_err(to_js_error)?;
298            let samples = (metadata.audio_length.saturating_sub(local_offset)).min(layout.samples);
299            let frame_length =
300                ecdc_lm_frame_length(&metadata, samples, meta.segment_samples, meta.frame_length);
301            chunks.push(PlayerLmEcdcChunk {
302                offset: global_offset + local_offset,
303                samples,
304                frame_length,
305                payload,
306            });
307            // Successive chunks inside one revolution advance by the overlap-add
308            // stride; with one chunk per revolution this loop runs once.
309            local_offset += layout.stride;
310        }
311
312        global_offset += metadata.audio_length;
313        // Report a single aggregate metadata block spanning every revolution:
314        // keep the codec/version fields from the first object (uniform across
315        // the stream) but surface the total sample count.
316        match aggregate_metadata.as_mut() {
317            Some(existing) => existing.audio_length = global_offset,
318            None => {
319                let mut first = metadata;
320                first.audio_length = global_offset;
321                aggregate_metadata = Some(first);
322            }
323        }
324    }
325
326    let metadata =
327        aggregate_metadata.ok_or_else(|| to_js_error("ECDC payload contained no revolutions"))?;
328    to_js_value(&PlayerLmEcdcChunks { metadata, chunks })
329}
330
331#[wasm_bindgen]
332pub struct QuantizedLmChunkDecoder {
333    meta: OnnxFrameBundleMetadata,
334    lm: QuantizedLm,
335    state: QuantizedLmState,
336    lm_window_frame_length: usize,
337    input_symbols: Vec<usize>,
338    decoder: ArithmeticDecoder,
339    scale: f32,
340    pulled_steps: usize,
341}
342
343#[wasm_bindgen]
344impl QuantizedLmChunkDecoder {
345    #[wasm_bindgen(constructor)]
346    pub fn new(
347        bundle_json: &str,
348        weights: &[u8],
349        payload: &[u8],
350    ) -> Result<QuantizedLmChunkDecoder, JsValue> {
351        let meta = parse_encodec_bundle_metadata(bundle_json)?;
352        validate_encodec_lm_metadata(&meta).map_err(to_js_error)?;
353        let weights = QuantizedLmWeights::from_bytes(weights).map_err(to_js_error)?;
354        weights
355            .validate_for_codebooks(meta.num_codebooks)
356            .map_err(to_js_error)?;
357        let lm_window_frame_length = weights.frame_length.max(1);
358        let lm = QuantizedLm::new(weights);
359        let state = lm.initial_state();
360        let mut cursor = Cursor::new(payload);
361        let scale = if meta.normalize {
362            let bytes = read_exactly(&mut cursor, 4).map_err(to_js_error)?;
363            f32::from_be_bytes(bytes.try_into().expect("slice length"))
364        } else {
365            1.0
366        };
367        let remaining = payload.len().saturating_sub(cursor.position() as usize);
368        let encoded = read_exactly(&mut cursor, remaining).map_err(to_js_error)?;
369        Ok(Self {
370            input_symbols: vec![0; meta.num_codebooks],
371            meta,
372            lm,
373            state,
374            lm_window_frame_length,
375            decoder: ArithmeticDecoder::new(encoded, ARITHMETIC_TOTAL_RANGE_BITS)
376                .map_err(to_js_error)?,
377            scale,
378            pulled_steps: 0,
379        })
380    }
381
382    pub fn bitstream_version(&self) -> u8 {
383        QUANTIZED_LM_BITSTREAM_VERSION
384    }
385
386    #[wasm_bindgen(js_name = lmWindowFrameLength)]
387    pub fn lm_window_frame_length(&self) -> usize {
388        self.lm_window_frame_length
389    }
390
391    pub fn scale(&self) -> f32 {
392        self.scale
393    }
394
395    pub fn pull(&mut self) -> Result<Vec<u16>, JsValue> {
396        if self.pulled_steps > 0 && self.pulled_steps % self.lm_window_frame_length == 0 {
397            self.state = self.lm.initial_state();
398            self.input_symbols.fill(0);
399        }
400        let logits = self
401            .lm
402            .forward_step(&mut self.state, &self.input_symbols)
403            .map_err(to_js_error)?;
404        let pdf = encodec_probability_columns_from_logits(&logits, &self.meta, 1.0)
405            .map_err(to_js_error)?;
406        let symbols = self
407            .decoder
408            .pull_symbols(
409                &pdf,
410                self.meta.lm_cardinality(),
411                self.meta.num_codebooks,
412                DEFAULT_FP_SCALE,
413                DEFAULT_MIN_RANGE,
414            )
415            .map_err(to_js_error)?;
416        for (dst, symbol) in self.input_symbols.iter_mut().zip(symbols.iter().copied()) {
417            *dst = symbol + 1;
418        }
419        self.pulled_steps += 1;
420        symbols
421            .into_iter()
422            .map(|symbol| {
423                u16::try_from(symbol)
424                    .map_err(|_| to_js_error(format!("LM symbol {symbol} does not fit u16")))
425            })
426            .collect()
427    }
428}
429
430#[wasm_bindgen(js_name = normalizeRecordTextFieldText)]
431pub fn wasm_normalize_record_text_field_text(value: &str) -> String {
432    normalize_record_text_field_text(value)
433}
434
435#[wasm_bindgen(js_name = recordDisplayMetadataJson)]
436pub fn wasm_record_display_metadata_json(
437    record_json: &str,
438    fallback_record_profile: &str,
439) -> String {
440    record_display_metadata_json(record_json, fallback_record_profile)
441}
442
443#[wasm_bindgen(js_name = recordVerificationMetaJson)]
444pub fn wasm_record_verification_meta_json(verification_json: &str) -> String {
445    record_verification_meta_json(verification_json)
446}
447
448
449#[wasm_bindgen(js_name = recordProfileFromHeaderValidationJson)]
450pub fn wasm_record_profile_from_header_validation_json(header_json: &str) -> String {
451    record_profile_from_header_validation_json(header_json)
452}
453
454#[wasm_bindgen(js_name = recordTextFromHeaderValidationJson)]
455pub fn wasm_record_text_from_header_validation_json(header_json: &str) -> String {
456    record_text_from_header_validation_json(header_json)
457}
458
459#[wasm_bindgen(js_name = recordPlaybackMetadataFromHeaderJson)]
460pub fn wasm_record_playback_metadata_from_header_json(header_json: &str) -> String {
461    record_playback_metadata_from_header_json(header_json)
462}
463
464#[wasm_bindgen(js_name = resolvePlaybackPayloadMetadataJson)]
465pub fn wasm_resolve_playback_payload_metadata_json(
466    header_metadata_json: &str,
467    payload_metadata_json: &str,
468    length_prefixed_entries: bool,
469) -> String {
470    resolve_playback_payload_metadata_json(
471        header_metadata_json,
472        payload_metadata_json,
473        length_prefixed_entries,
474    )
475}
476
477#[wasm_bindgen(js_name = resolveClipRevolutionsJson)]
478pub fn wasm_resolve_clip_revolutions_json(
479    start_time: f64,
480    end_time: f64,
481    duration_seconds: f64,
482    revolution_count: f64,
483) -> String {
484    resolve_clip_revolutions_json(start_time, end_time, duration_seconds, revolution_count)
485}
486
487#[wasm_bindgen(js_name = scratchSampleTokenFromBytes)]
488pub fn wasm_scratch_sample_token_from_bytes(random_bytes: &[u8], fallback: JsValue) -> f64 {
489    scratch_sample_token_from_bytes(random_bytes, js_value_to_f64(&fallback, 0.0))
490}
491
492#[wasm_bindgen(js_name = normalizeScratchSampleId)]
493pub fn wasm_normalize_scratch_sample_id(value: JsValue) -> f64 {
494    normalize_scratch_sample_id(js_value_to_f64(&value, 0.0))
495}
496
497#[wasm_bindgen(js_name = scratchSampleTokenHex)]
498pub fn wasm_scratch_sample_token_hex(value: JsValue) -> String {
499    scratch_sample_token_hex(js_value_to_f64(&value, 0.0))
500}
501
502#[wasm_bindgen(js_name = scratchClipIdForSampleId)]
503pub fn wasm_scratch_clip_id_for_sample_id(value: JsValue) -> String {
504    scratch_clip_id_for_sample_id(js_value_to_f64(&value, 0.0))
505}
506
507#[wasm_bindgen(js_name = scratchClipSampleIdJson)]
508pub fn wasm_scratch_clip_sample_id_json(clip_json: &str) -> f64 {
509    scratch_clip_sample_id_json(clip_json)
510}
511
512#[wasm_bindgen(js_name = isValidScratchAnonUserId)]
513pub fn wasm_is_valid_scratch_anon_user_id(value: &str) -> bool {
514    is_valid_scratch_anon_user_id(value)
515}
516
517#[wasm_bindgen(js_name = scratchAnonUserIdFromRandom)]
518pub fn wasm_scratch_anon_user_id_from_random(random_part: &str) -> String {
519    scratch_anon_user_id_from_random(random_part)
520}
521
522#[wasm_bindgen(js_name = normalizeScratchDisplayName)]
523pub fn wasm_normalize_scratch_display_name(value: &str) -> String {
524    normalize_scratch_display_name(value)
525}
526
527#[wasm_bindgen(js_name = scratchDisplayNameKey)]
528pub fn wasm_scratch_display_name_key(name: &str) -> String {
529    scratch_display_name_key(name)
530}
531
532#[wasm_bindgen(js_name = stableLocalRecordIdFromMetaJson)]
533pub fn wasm_stable_local_record_id_from_meta_json(meta_json: &str, file_name: &str) -> String {
534    stable_local_record_id_from_meta_json(meta_json, file_name)
535}
536
537#[wasm_bindgen(js_name = scratchVisitorWalletAddressFromBytes)]
538pub fn wasm_scratch_visitor_wallet_address_from_bytes(random_bytes: &[u8]) -> String {
539    scratch_visitor_wallet_address_from_bytes(random_bytes)
540}
541
542#[wasm_bindgen(js_name = isValidScratchWalletAddress)]
543pub fn wasm_is_valid_scratch_wallet_address(value: &str) -> bool {
544    is_valid_scratch_wallet_address(value)
545}
546
547#[wasm_bindgen(js_name = shortScratchAddress)]
548pub fn wasm_short_scratch_address(address: &str, chars: JsValue) -> String {
549    short_scratch_address(address, js_value_to_f64(&chars, 4.0))
550}
551
552#[wasm_bindgen(js_name = normalizeScratchRemoteControlRevision)]
553pub fn wasm_normalize_scratch_remote_control_revision(value: JsValue) -> u32 {
554    normalize_scratch_remote_control_revision(js_value_to_f64(&value, 0.0))
555}
556
557#[wasm_bindgen(js_name = bumpScratchRemoteControlRevision)]
558pub fn wasm_bump_scratch_remote_control_revision(value: JsValue) -> u32 {
559    bump_scratch_remote_control_revision(js_value_to_f64(&value, 0.0))
560}
561
562#[wasm_bindgen(js_name = ensureScratchRemoteControlRevisionJson)]
563pub fn wasm_ensure_scratch_remote_control_revision_json(state_json: &str) -> String {
564    ensure_scratch_remote_control_revision_json(state_json)
565}
566
567#[wasm_bindgen(js_name = shouldApplyRemoteScratchControlsJson)]
568pub fn wasm_should_apply_remote_scratch_controls_json(
569    state_json: &str,
570    command_json: &str,
571) -> String {
572    should_apply_remote_scratch_controls_json(state_json, command_json)
573}
574
575#[wasm_bindgen(js_name = normalizeRecordProfileName)]
576pub fn wasm_normalize_record_profile_name(record_profile: &str) -> Result<String, JsValue> {
577    normalize_record_profile_name(record_profile).map_err(to_js_error)
578}
579
580#[wasm_bindgen(js_name = recordProfileSpecJson)]
581pub fn wasm_record_profile_spec_json(record_profile: &str) -> Result<String, JsValue> {
582    record_profile_spec_json(record_profile).map_err(to_js_error)
583}
584
585#[wasm_bindgen(js_name = getRecordRpm)]
586pub fn wasm_get_record_rpm(record_profile: &str) -> Result<f64, JsValue> {
587    record_rpm(record_profile).map_err(to_js_error)
588}
589
590#[wasm_bindgen(js_name = resolveRecordRpm)]
591pub fn wasm_resolve_record_rpm(
592    rpm_candidate: JsValue,
593    record_profile: &str,
594) -> Result<f64, JsValue> {
595    resolve_record_rpm_number(js_value_to_f64(&rpm_candidate, f64::NAN), record_profile)
596        .map_err(to_js_error)
597}
598
599#[wasm_bindgen(js_name = secondsToRevolutions)]
600pub fn wasm_seconds_to_revolutions(
601    seconds: JsValue,
602    record_profile: &str,
603    rpm_candidate: JsValue,
604) -> Result<f64, JsValue> {
605    let seconds = js_value_to_f64(&seconds, 0.0);
606    if !(seconds.is_finite() && seconds > 0.0) {
607        return Ok(0.0);
608    }
609    seconds_to_revolutions_number(
610        seconds,
611        record_profile,
612        js_value_to_f64(&rpm_candidate, f64::NAN),
613    )
614    .map_err(to_js_error)
615}
616
617#[wasm_bindgen(js_name = resolveLeadInTurns)]
618pub fn wasm_resolve_lead_in_turns(record_profile: &str) -> Result<f64, JsValue> {
619    profile_turns(record_profile)
620        .map(|turns| turns.lead_in_turns)
621        .map_err(to_js_error)
622}
623
624#[wasm_bindgen(js_name = resolveDeadwaxTurns)]
625pub fn wasm_resolve_deadwax_turns(record_profile: &str) -> Result<f64, JsValue> {
626    profile_turns(record_profile)
627        .map(|turns| turns.run_out_turns)
628        .map_err(to_js_error)
629}
630
631#[wasm_bindgen(js_name = resolvePlaybackRate)]
632pub fn wasm_resolve_playback_rate(
633    value: JsValue,
634    default_rate: f64,
635    min_rate: f64,
636    max_rate: f64,
637) -> f64 {
638    resolve_playback_rate_number(
639        js_value_to_f64(&value, default_rate),
640        default_rate,
641        min_rate,
642        max_rate,
643    )
644}
645
646#[wasm_bindgen(js_name = resolvePhysicalRpm)]
647pub fn wasm_resolve_physical_rpm(
648    rpm_candidate: JsValue,
649    record_profile: &str,
650    playback_rate: JsValue,
651    default_rate: f64,
652    min_rate: f64,
653    max_rate: f64,
654) -> Result<f64, JsValue> {
655    resolve_physical_rpm_number(
656        js_value_to_f64(&rpm_candidate, f64::NAN),
657        record_profile,
658        js_value_to_f64(&playback_rate, default_rate),
659        default_rate,
660        min_rate,
661        max_rate,
662    )
663    .map_err(to_js_error)
664}
665
666#[wasm_bindgen(js_name = resolveSecondsPerTurn)]
667pub fn wasm_resolve_seconds_per_turn(
668    rpm_candidate: JsValue,
669    record_profile: &str,
670    playback_rate: JsValue,
671    default_rate: f64,
672    min_rate: f64,
673    max_rate: f64,
674) -> Result<f64, JsValue> {
675    resolve_seconds_per_turn_number(
676        js_value_to_f64(&rpm_candidate, f64::NAN),
677        record_profile,
678        js_value_to_f64(&playback_rate, default_rate),
679        default_rate,
680        min_rate,
681        max_rate,
682    )
683    .map_err(to_js_error)
684}
685
686#[wasm_bindgen(js_name = resolveLeadInDurationSeconds)]
687pub fn wasm_resolve_lead_in_duration_seconds(
688    record_profile: &str,
689    rpm_candidate: JsValue,
690    playback_rate: JsValue,
691    default_rate: f64,
692    min_rate: f64,
693    max_rate: f64,
694) -> Result<f64, JsValue> {
695    profile_turn_duration_seconds(
696        profile_turns(record_profile)
697            .map_err(to_js_error)?
698            .lead_in_turns,
699        js_value_to_f64(&rpm_candidate, f64::NAN),
700        record_profile,
701        js_value_to_f64(&playback_rate, default_rate),
702        default_rate,
703        min_rate,
704        max_rate,
705    )
706    .map_err(to_js_error)
707}
708
709#[wasm_bindgen(js_name = resolveDeadwaxDurationSeconds)]
710pub fn wasm_resolve_deadwax_duration_seconds(
711    record_profile: &str,
712    rpm_candidate: JsValue,
713    playback_rate: JsValue,
714    default_rate: f64,
715    min_rate: f64,
716    max_rate: f64,
717) -> Result<f64, JsValue> {
718    profile_turn_duration_seconds(
719        profile_turns(record_profile)
720            .map_err(to_js_error)?
721            .run_out_turns,
722        js_value_to_f64(&rpm_candidate, f64::NAN),
723        record_profile,
724        js_value_to_f64(&playback_rate, default_rate),
725        default_rate,
726        min_rate,
727        max_rate,
728    )
729    .map_err(to_js_error)
730}
731
732#[wasm_bindgen(js_name = createPlayerEcdcCacheProofContextJson)]
733pub fn wasm_create_player_ecdc_cache_proof_context_json(ecdc: &[u8]) -> String {
734    create_player_ecdc_cache_proof_context(ecdc)
735        .and_then(|context| serde_json::to_string(&context).ok())
736        .unwrap_or_else(|| "null".to_string())
737}
738
739#[wasm_bindgen(js_name = playerEcdcCacheProofForChunkJson)]
740pub fn wasm_player_ecdc_cache_proof_for_chunk_json(
741    context_json: &str,
742    chunk_index: usize,
743) -> String {
744    player_ecdc_cache_proof_for_chunk_json(context_json, chunk_index)
745}
746
747fn normalize_record_text_field_text(value: &str) -> String {
748    value
749        .replace('\0', " ")
750        .split_whitespace()
751        .collect::<Vec<_>>()
752        .join(" ")
753}
754
755fn record_display_metadata_json(record_json: &str, fallback_record_profile: &str) -> String {
756    let record = serde_json::from_str::<Value>(record_json).unwrap_or(Value::Null);
757    record_display_metadata_value(&record, fallback_record_profile).to_string()
758}
759
760fn record_display_metadata_value(record: &Value, fallback_record_profile: &str) -> Value {
761    let meta = record
762        .get("meta")
763        .filter(|value| value.is_object())
764        .unwrap_or(&Value::Null);
765    let title = text_from_first_value(&[record, meta], &["title"]);
766    let artist = text_from_first_value(&[record, meta], &["artist"]);
767    let explicit_profile = text_from_first_value(&[record, meta], &["recordProfile"]);
768    let record_profile = if explicit_profile.is_empty() {
769        normalize_record_text_field_text(fallback_record_profile)
770    } else {
771        explicit_profile
772    };
773    let normalized_profile = normalize_record_profile_name(&record_profile).unwrap_or_default();
774    let verified = boolish_true(meta.get("bitneedleVerified"));
775    let title_for_label = if title.is_empty() {
776        "Untitled record".to_string()
777    } else {
778        title.clone()
779    };
780    let display_label = if artist.is_empty() {
781        title_for_label.clone()
782    } else {
783        format!("{title_for_label} - {artist}")
784    };
785
786    json!({
787        "title": title,
788        "artist": artist,
789        "displayLabel": display_label,
790        "profileDisplay": record_profile_display_text(&record_profile, verified),
791        "recordProfile": normalized_profile,
792        "verified": verified,
793    })
794}
795
796fn record_profile_display_text(record_profile: &str, verified: bool) -> String {
797    let Ok(normalized) = normalize_record_profile_name(record_profile) else {
798        return String::new();
799    };
800    let Ok(label) = record_profile_label(&normalized) else {
801        return String::new();
802    };
803    if verified {
804        format!("{label} \u{00b7} Verified")
805    } else {
806        label.to_string()
807    }
808}
809
810fn record_verification_meta_json(verification_json: &str) -> String {
811    let Ok(verification) = serde_json::from_str::<Value>(verification_json) else {
812        return "{}".to_string();
813    };
814    if !verification.is_object() {
815        return "{}".to_string();
816    }
817    let verified = verification
818        .get("ok")
819        .and_then(Value::as_bool)
820        .unwrap_or(false);
821    json!({
822        "verification": verification,
823        "bitneedleVerification": text_from_value(verification.get("code")),
824        "bitneedleVerified": if verified { "true" } else { "false" },
825        "signatureKeyId": text_from_value(verification.get("keyId")),
826    })
827    .to_string()
828}
829
830fn boolish_true(value: Option<&Value>) -> bool {
831    match value {
832        Some(Value::Bool(value)) => *value,
833        Some(Value::String(value)) => value.trim().eq_ignore_ascii_case("true"),
834        _ => false,
835    }
836}
837
838fn record_profile_from_header_validation_json(header_json: &str) -> String {
839    let Ok(header) = serde_json::from_str::<Value>(header_json) else {
840        return String::new();
841    };
842    record_profile_from_header_value(&header)
843}
844
845fn record_profile_from_header_value(header: &Value) -> String {
846    for path in [
847        &["descriptor", "recordProfile"][..],
848        &["record", "recordProfile"][..],
849        &["recordProfile"][..],
850    ] {
851        if let Some(text) = value_at_path(header, path).and_then(Value::as_str) {
852            if let Ok(profile) = record_core::normalize_record_profile_name(text) {
853                return profile;
854            }
855        }
856    }
857    String::new()
858}
859
860fn record_text_from_header_validation_json(header_json: &str) -> String {
861    let Ok(header) = serde_json::from_str::<Value>(header_json) else {
862        return json!({ "title": "", "artist": "", "meta": {} }).to_string();
863    };
864    record_text_from_header_value(&header).to_string()
865}
866
867fn record_playback_metadata_from_header_json(header_json: &str) -> String {
868    let Ok(header) = serde_json::from_str::<Value>(header_json) else {
869        return playback_metadata_json(
870            String::new(),
871            String::new(),
872            Value::Array(vec![]),
873            Value::Array(vec![]),
874        )
875        .to_string();
876    };
877    let parsed = record_text_from_header_value(&header);
878    let meta = parsed.get("meta").unwrap_or(&Value::Null);
879    playback_metadata_json(
880        text_from_value(meta.get("payloadContainer")),
881        text_from_value(meta.get("entryContainer")),
882        meta.get("trackListing")
883            .cloned()
884            .unwrap_or_else(|| Value::Array(vec![])),
885        meta.get("dummySpiralRegions")
886            .cloned()
887            .unwrap_or_else(|| Value::Array(vec![])),
888    )
889    .to_string()
890}
891
892fn resolve_playback_payload_metadata_json(
893    header_metadata_json: &str,
894    payload_metadata_json: &str,
895    _length_prefixed_entries: bool,
896) -> String {
897    let header = serde_json::from_str::<Value>(header_metadata_json).unwrap_or(Value::Null);
898    let payload = serde_json::from_str::<Value>(payload_metadata_json).unwrap_or(Value::Null);
899    resolve_playback_payload_metadata_value(&header, &payload).to_string()
900}
901
902fn resolve_playback_payload_metadata_value(header: &Value, payload: &Value) -> Value {
903    let header_payload_container = text_from_value(header.get("payloadContainer"));
904    let payload_container = if header_payload_container.is_empty() {
905        normalize_payload_container(&payload_container_from_metadata(payload))
906    } else {
907        normalize_payload_container(&header_payload_container)
908    };
909    let entry_container = "single".to_string();
910    let header_tracks = array_value(header.get("trackListing"));
911    let track_listing = if header_tracks
912        .as_array()
913        .is_some_and(|items| !items.is_empty())
914    {
915        header_tracks
916    } else {
917        array_value(payload.get("trackListing"))
918    };
919    let header_dummy_spiral_regions =
920        normalize_dummy_spiral_regions(array_value(header.get("dummySpiralRegions")));
921    let dummy_spiral_regions = if header_dummy_spiral_regions
922        .as_array()
923        .is_some_and(|items| !items.is_empty())
924    {
925        header_dummy_spiral_regions
926    } else {
927        normalize_dummy_spiral_regions(array_value(payload.get("dummySpiralRegions")))
928    };
929
930    json!({
931        "payloadContainer": payload_container,
932        "payloadCodec": payload_codec_from_metadata(payload),
933        "entryContainer": entry_container,
934        "trackListing": track_listing.as_array().cloned().unwrap_or_default(),
935        "dummySpiralRegions": dummy_spiral_regions.as_array().cloned().unwrap_or_default(),
936    })
937}
938
939fn payload_descriptor_from_metadata(metadata: &Value) -> Option<&Value> {
940    metadata
941        .get("payloadDescriptors")
942        .and_then(Value::as_array)
943        .and_then(|items| items.first())
944        .filter(|value| value.is_object())
945}
946
947fn payload_codec_from_metadata(metadata: &Value) -> String {
948    text_from_value(metadata.get("payloadCodec"))
949}
950
951fn payload_container_from_metadata(metadata: &Value) -> String {
952    let descriptor = payload_descriptor_from_metadata(metadata);
953    text_from_value(
954        descriptor
955            .and_then(|value| value.get("container"))
956            .or_else(|| metadata.get("payloadContainer")),
957    )
958}
959
960fn normalize_payload_container(value: &str) -> String {
961    value.trim().to_uppercase()
962}
963
964fn resolve_clip_revolutions_json(
965    start_time: f64,
966    end_time: f64,
967    duration_seconds: f64,
968    revolution_count: f64,
969) -> String {
970    let duration = finite_nonnegative(duration_seconds);
971    let count = finite_nonnegative(revolution_count).floor();
972    if !(duration > 0.0 && count > 0.0) {
973        return "[]".to_string();
974    }
975
976    let last_index = (count as u64).saturating_sub(1);
977    let start_ratio =
978        (finite_or_zero(start_time).min(finite_or_zero(end_time)) / duration).clamp(0.0, 1.0);
979    let end_ratio =
980        (finite_or_zero(start_time).max(finite_or_zero(end_time)) / duration).clamp(0.0, 1.0);
981    let first = ((start_ratio * count).floor() as u64).min(last_index);
982    let last = (((end_ratio - f64::EPSILON).max(0.0) * count).floor() as u64).min(last_index);
983    if last < first {
984        return "[]".to_string();
985    }
986    serde_json::to_string(&(first..=last).collect::<Vec<_>>()).unwrap_or_else(|_| "[]".to_string())
987}
988
989#[derive(Debug, Clone, Copy)]
990struct ProfileTurns {
991    lead_in_turns: f64,
992    run_out_turns: f64,
993}
994
995#[derive(Debug, Clone, Serialize)]
996#[serde(rename_all = "camelCase")]
997struct PlayerRecordProfileSpec {
998    name: String,
999    label: String,
1000    spindle_hole_radius: i32,
1001    label_radius: i32,
1002    label_clearance: i32,
1003    outer_radius: i32,
1004    outer_rim_thickness: i32,
1005    lead_in_band_thickness: i32,
1006    lead_in_turns: f64,
1007    run_out_turns: f64,
1008}
1009
1010fn normalize_record_profile_name(record_profile: &str) -> Result<String, String> {
1011    record_core::normalize_record_profile_name(record_profile)
1012        .map_err(|error| format!("unknown record profile {record_profile}: {error:#}"))
1013}
1014
1015fn record_rpm(record_profile: &str) -> Result<f64, String> {
1016    let normalized = normalize_record_profile_name(record_profile)?;
1017    match normalized.as_str() {
1018        "single45" => Ok(45.0),
1019        "lp" => Ok(33.3333333333),
1020        _ => Err(format!("record profile {normalized} has no player RPM")),
1021    }
1022}
1023
1024fn resolve_record_rpm_number(rpm_candidate: f64, record_profile: &str) -> Result<f64, String> {
1025    let record_rpm = record_rpm(record_profile)?;
1026    if rpm_candidate.is_finite() && rpm_candidate > 0.0 {
1027        Ok(rpm_candidate)
1028    } else {
1029        Ok(record_rpm)
1030    }
1031}
1032
1033fn seconds_to_revolutions_number(
1034    seconds: f64,
1035    record_profile: &str,
1036    rpm_candidate: f64,
1037) -> Result<f64, String> {
1038    if !(seconds.is_finite() && seconds > 0.0) {
1039        return Ok(0.0);
1040    }
1041    Ok((seconds * resolve_record_rpm_number(rpm_candidate, record_profile)?) / 60.0)
1042}
1043
1044fn resolve_playback_rate_number(
1045    value: f64,
1046    default_rate: f64,
1047    min_rate: f64,
1048    max_rate: f64,
1049) -> f64 {
1050    let default_value = if default_rate.is_finite() && default_rate > 0.0 {
1051        default_rate
1052    } else {
1053        1.0
1054    };
1055    let min = if min_rate.is_finite() && min_rate > 0.0 {
1056        min_rate
1057    } else {
1058        default_value
1059    };
1060    let max = if max_rate.is_finite() && max_rate >= min {
1061        max_rate
1062    } else {
1063        default_value.max(min)
1064    };
1065    let value = if value.is_finite() {
1066        value
1067    } else {
1068        default_value
1069    };
1070    value.clamp(min, max)
1071}
1072
1073fn resolve_physical_rpm_number(
1074    rpm_candidate: f64,
1075    record_profile: &str,
1076    playback_rate: f64,
1077    default_rate: f64,
1078    min_rate: f64,
1079    max_rate: f64,
1080) -> Result<f64, String> {
1081    Ok(resolve_record_rpm_number(rpm_candidate, record_profile)?
1082        * resolve_playback_rate_number(playback_rate, default_rate, min_rate, max_rate))
1083}
1084
1085fn resolve_seconds_per_turn_number(
1086    rpm_candidate: f64,
1087    record_profile: &str,
1088    playback_rate: f64,
1089    default_rate: f64,
1090    min_rate: f64,
1091    max_rate: f64,
1092) -> Result<f64, String> {
1093    let rpm = resolve_physical_rpm_number(
1094        rpm_candidate,
1095        record_profile,
1096        playback_rate,
1097        default_rate,
1098        min_rate,
1099        max_rate,
1100    )?;
1101    if rpm > 0.0 {
1102        Ok(60.0 / rpm)
1103    } else {
1104        Ok(0.0)
1105    }
1106}
1107
1108fn profile_turn_duration_seconds(
1109    turns: f64,
1110    rpm_candidate: f64,
1111    record_profile: &str,
1112    playback_rate: f64,
1113    default_rate: f64,
1114    min_rate: f64,
1115    max_rate: f64,
1116) -> Result<f64, String> {
1117    let rpm = resolve_physical_rpm_number(
1118        rpm_candidate,
1119        record_profile,
1120        playback_rate,
1121        default_rate,
1122        min_rate,
1123        max_rate,
1124    )?;
1125    if turns > 0.0 && rpm > 0.0 {
1126        Ok(turns * (60.0 / rpm))
1127    } else {
1128        Ok(0.0)
1129    }
1130}
1131
1132fn profile_turns(record_profile: &str) -> Result<ProfileTurns, String> {
1133    let _ = normalize_record_profile_name(record_profile)?;
1134    Ok(ProfileTurns {
1135        lead_in_turns: 2.0,
1136        run_out_turns: 2.0,
1137    })
1138}
1139
1140fn record_profile_label(record_profile: &str) -> Result<&'static str, String> {
1141    match record_profile {
1142        "single45" => Ok("45"),
1143        "lp" => Ok("LP"),
1144        _ => Err(format!(
1145            "record profile {record_profile} has no player label"
1146        )),
1147    }
1148}
1149
1150fn record_profile_spec(record_profile: &str) -> Result<PlayerRecordProfileSpec, String> {
1151    let normalized = normalize_record_profile_name(record_profile)?;
1152    let geometry = record_core::describe_record_profile(&normalized)
1153        .map_err(|error| format!("failed to describe record profile {normalized}: {error:#}"))?;
1154    let turns = profile_turns(&normalized)?;
1155    Ok(PlayerRecordProfileSpec {
1156        name: geometry.record_profile.clone(),
1157        label: record_profile_label(&geometry.record_profile)?.to_string(),
1158        spindle_hole_radius: geometry.spindle_hole_radius,
1159        label_radius: geometry.label_radius,
1160        label_clearance: geometry.payload_inner_radius - geometry.label_radius,
1161        outer_radius: geometry.outer_radius,
1162        outer_rim_thickness: geometry.outer_rim_thickness,
1163        lead_in_band_thickness: geometry.lead_in_band_thickness,
1164        lead_in_turns: turns.lead_in_turns,
1165        run_out_turns: turns.run_out_turns,
1166    })
1167}
1168
1169fn record_profile_spec_json(record_profile: &str) -> Result<String, String> {
1170    record_profile_spec(record_profile)
1171        .and_then(|spec| serde_json::to_string(&spec).map_err(|error| error.to_string()))
1172}
1173
1174fn js_value_to_f64(value: &JsValue, default_value: f64) -> f64 {
1175    let number = value
1176        .as_f64()
1177        .or_else(|| {
1178            value
1179                .as_string()
1180                .and_then(|text| text.trim().parse::<f64>().ok())
1181        })
1182        .unwrap_or(default_value);
1183    if number.is_finite() {
1184        number
1185    } else {
1186        default_value
1187    }
1188}
1189
1190fn scratch_sample_token_from_bytes(random_bytes: &[u8], fallback: f64) -> f64 {
1191    let mut token = 0.0;
1192    for (index, byte) in random_bytes.iter().copied().take(7).enumerate() {
1193        let value = if index == 0 { byte & 0x1f } else { byte };
1194        token = (token * 256.0) + f64::from(value);
1195    }
1196    if token.fract() == 0.0 && token > 0.0 && token <= 9_007_199_254_740_991.0 {
1197        token
1198    } else if fallback.is_finite() && fallback > 0.0 {
1199        fallback
1200    } else {
1201        0.0
1202    }
1203}
1204
1205fn normalize_scratch_sample_id(value: f64) -> f64 {
1206    if value.is_finite() && value.fract() == 0.0 && value > 0.0 && value <= 9_007_199_254_740_991.0
1207    {
1208        value
1209    } else {
1210        0.0
1211    }
1212}
1213
1214fn scratch_sample_token_hex(value: f64) -> String {
1215    let token = normalize_scratch_sample_id(value);
1216    if token == 0.0 {
1217        String::new()
1218    } else {
1219        format!("{:014x}", token as u64)
1220    }
1221}
1222
1223fn scratch_clip_id_for_sample_id(value: f64) -> String {
1224    let hex = scratch_sample_token_hex(value);
1225    if hex.is_empty() {
1226        String::new()
1227    } else {
1228        format!("scratch-sample-{hex}")
1229    }
1230}
1231
1232fn scratch_clip_sample_id_json(clip_json: &str) -> f64 {
1233    let clip = serde_json::from_str::<Value>(clip_json).unwrap_or(Value::Null);
1234    let sample = clip
1235        .get("sampleId")
1236        .and_then(number_like_value_to_f64)
1237        .unwrap_or(0.0);
1238    normalize_scratch_sample_id(sample)
1239}
1240
1241fn is_valid_scratch_anon_user_id(value: &str) -> bool {
1242    let normalized = value.to_ascii_lowercase();
1243    let Some(rest) = normalized.strip_prefix("bnanon_") else {
1244        return false;
1245    };
1246    let len = rest.len();
1247    (12..=80).contains(&len)
1248        && rest
1249            .bytes()
1250            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
1251}
1252
1253fn scratch_anon_user_id_from_random(random_part: &str) -> String {
1254    let suffix = random_part
1255        .to_ascii_lowercase()
1256        .bytes()
1257        .filter(|byte| byte.is_ascii_alphanumeric() || *byte == b'-')
1258        .take(72)
1259        .map(char::from)
1260        .collect::<String>();
1261    format!("bnanon_{suffix}")
1262}
1263
1264fn normalize_scratch_display_name(value: &str) -> String {
1265    value
1266        .chars()
1267        .filter(|ch| !ch.is_control() && *ch != '\u{007f}')
1268        .collect::<String>()
1269        .split_whitespace()
1270        .collect::<Vec<_>>()
1271        .join(" ")
1272        .chars()
1273        .take(SCRATCH_DISPLAY_NAME_MAX_LENGTH)
1274        .collect()
1275}
1276
1277fn scratch_display_name_key(name: &str) -> String {
1278    normalize_scratch_display_name(name).to_ascii_lowercase()
1279}
1280
1281fn stable_local_record_id_from_meta_json(meta_json: &str, file_name: &str) -> String {
1282    let meta = serde_json::from_str::<Value>(meta_json).unwrap_or(Value::Null);
1283    let source = ["releaseId", "recordPngSha256", "chunkStreamSha256"]
1284        .into_iter()
1285        .find_map(|key| {
1286            let value = text_from_value(meta.get(key));
1287            if value.is_empty() {
1288                None
1289            } else {
1290                Some(value)
1291            }
1292        })
1293        .unwrap_or_else(|| file_name.trim().to_string());
1294    let safe = sanitize_local_record_id_source(&source);
1295    if safe.is_empty() {
1296        String::new()
1297    } else {
1298        format!("local-record-{safe}")
1299    }
1300}
1301
1302fn sanitize_local_record_id_source(source: &str) -> String {
1303    let trimmed = source.trim();
1304    let trimmed = trimmed
1305        .strip_prefix("0x")
1306        .or_else(|| trimmed.strip_prefix("0X"))
1307        .unwrap_or(trimmed);
1308    let mut safe = String::new();
1309    let mut previous_dash = false;
1310    for ch in trimmed.chars() {
1311        let mapped = if ch.is_ascii_alphanumeric() || ch == '_' {
1312            Some(ch)
1313        } else if ch == '-' {
1314            Some('-')
1315        } else {
1316            Some('-')
1317        };
1318        if let Some(ch) = mapped {
1319            if ch == '-' {
1320                if !previous_dash && !safe.is_empty() {
1321                    safe.push('-');
1322                }
1323                previous_dash = true;
1324            } else {
1325                safe.push(ch);
1326                previous_dash = false;
1327            }
1328        }
1329        if safe.len() >= 72 {
1330            break;
1331        }
1332    }
1333    while safe.ends_with('-') {
1334        safe.pop();
1335    }
1336    safe
1337}
1338
1339fn scratch_visitor_wallet_address_from_bytes(random_bytes: &[u8]) -> String {
1340    let mut bytes = [0u8; 20];
1341    for (target, source) in bytes.iter_mut().zip(random_bytes.iter().copied()) {
1342        *target = source;
1343    }
1344    format!(
1345        "0x{}",
1346        bytes
1347            .iter()
1348            .map(|byte| format!("{byte:02x}"))
1349            .collect::<String>()
1350    )
1351}
1352
1353fn is_valid_scratch_wallet_address(value: &str) -> bool {
1354    let text = value.trim();
1355    text.len() == 42
1356        && text.starts_with("0x")
1357        && text.as_bytes()[2..]
1358            .iter()
1359            .all(|byte| byte.is_ascii_hexdigit())
1360}
1361
1362fn short_scratch_address(address: &str, chars: f64) -> String {
1363    let text = address.to_string();
1364    let chars = if chars.is_finite() && chars > 0.0 {
1365        chars.floor() as usize
1366    } else {
1367        4
1368    };
1369    if text.len() <= (chars * 2) + 2 {
1370        if text.is_empty() {
1371            "LOCAL".to_string()
1372        } else {
1373            text
1374        }
1375    } else {
1376        format!("{}...{}", &text[..chars + 2], &text[text.len() - chars..])
1377    }
1378}
1379
1380fn number_like_value_to_f64(value: &Value) -> Option<f64> {
1381    match value {
1382        Value::Number(number) => number.as_f64(),
1383        Value::String(text) => text.trim().parse::<f64>().ok(),
1384        _ => None,
1385    }
1386}
1387
1388#[derive(Debug, Clone, Deserialize, Serialize)]
1389#[serde(rename_all = "camelCase")]
1390struct ScratchRemoteControlState {
1391    control_revision: u32,
1392    control_intent: bool,
1393}
1394
1395#[derive(Debug, Clone, Deserialize)]
1396#[serde(rename_all = "camelCase")]
1397struct ScratchRemoteControlInput {
1398    control_revision: Value,
1399    control_intent: Option<bool>,
1400}
1401
1402#[derive(Debug, Clone, Serialize)]
1403#[serde(rename_all = "camelCase")]
1404struct ScratchRemoteControlResolution {
1405    apply: bool,
1406    control_revision: u32,
1407    control_intent: bool,
1408}
1409
1410fn normalize_scratch_remote_control_revision(value: f64) -> u32 {
1411    if !(value.is_finite() && value > 0.0) {
1412        return 0;
1413    }
1414    let truncated = value.trunc();
1415    if truncated >= u64::MAX as f64 {
1416        return 0;
1417    }
1418    (truncated as u64 & 0xffff_ffff) as u32
1419}
1420
1421fn bump_scratch_remote_control_revision(value: f64) -> u32 {
1422    let next = normalize_scratch_remote_control_revision(value).wrapping_add(1);
1423    if next == 0 {
1424        1
1425    } else {
1426        next
1427    }
1428}
1429
1430fn scratch_remote_control_state_from_json(state_json: &str) -> ScratchRemoteControlState {
1431    let value = serde_json::from_str::<Value>(state_json).unwrap_or(Value::Null);
1432    ScratchRemoteControlState {
1433        control_revision: normalize_scratch_remote_control_revision(
1434            value
1435                .get("controlRevision")
1436                .and_then(number_like_value_to_f64)
1437                .unwrap_or(0.0),
1438        ),
1439        control_intent: value
1440            .get("controlIntent")
1441            .and_then(Value::as_bool)
1442            .unwrap_or(false),
1443    }
1444}
1445
1446fn scratch_remote_control_input_from_json(command_json: &str) -> ScratchRemoteControlInput {
1447    let value = serde_json::from_str::<Value>(command_json).unwrap_or(Value::Null);
1448    ScratchRemoteControlInput {
1449        control_revision: value.get("controlRevision").cloned().unwrap_or(Value::Null),
1450        control_intent: value.get("controlIntent").and_then(Value::as_bool),
1451    }
1452}
1453
1454fn ensure_scratch_remote_control_revision_json(state_json: &str) -> String {
1455    let mut state = scratch_remote_control_state_from_json(state_json);
1456    if state.control_revision == 0 {
1457        state.control_revision = 1;
1458        state.control_intent = false;
1459    }
1460    serde_json::to_string(&state).unwrap_or_else(|_| "{}".to_string())
1461}
1462
1463fn should_apply_remote_scratch_controls_json(state_json: &str, command_json: &str) -> String {
1464    let state = scratch_remote_control_state_from_json(state_json);
1465    let command = scratch_remote_control_input_from_json(command_json);
1466    let incoming_revision = normalize_scratch_remote_control_revision(
1467        number_like_value_to_f64(&command.control_revision).unwrap_or(0.0),
1468    );
1469    if incoming_revision == 0 {
1470        return serde_json::to_string(&ScratchRemoteControlResolution {
1471            apply: true,
1472            control_revision: state.control_revision,
1473            control_intent: state.control_intent,
1474        })
1475        .unwrap_or_else(|_| "{}".to_string());
1476    }
1477
1478    let incoming_intent = command.control_intent.unwrap_or(false);
1479    if incoming_revision > state.control_revision
1480        || (incoming_revision == state.control_revision && incoming_intent && !state.control_intent)
1481    {
1482        return serde_json::to_string(&ScratchRemoteControlResolution {
1483            apply: true,
1484            control_revision: incoming_revision,
1485            control_intent: incoming_intent || state.control_intent,
1486        })
1487        .unwrap_or_else(|_| "{}".to_string());
1488    }
1489
1490    serde_json::to_string(&ScratchRemoteControlResolution {
1491        apply: false,
1492        control_revision: state.control_revision,
1493        control_intent: state.control_intent,
1494    })
1495    .unwrap_or_else(|_| "{}".to_string())
1496}
1497
1498fn record_text_from_header_value(header: &Value) -> Value {
1499    let source_value = header
1500        .get("descriptor")
1501        .filter(|value| value.is_object())
1502        .cloned()
1503        .unwrap_or_else(|| {
1504            if header.is_object() {
1505                header.clone()
1506            } else {
1507                Value::Null
1508            }
1509        });
1510    let chunk_stream_value = object_field(header, "chunkStream")
1511        .map(Value::Object)
1512        .unwrap_or(Value::Null);
1513    let record_value = object_field(header, "record")
1514        .map(Value::Object)
1515        .unwrap_or(Value::Null);
1516    let arbitrary = parse_arbitrary_metadata(&source_value);
1517
1518    let track_listing = first_array_value(
1519        &[&arbitrary, &source_value, &chunk_stream_value],
1520        &["trackListing"],
1521    );
1522    let dummy_spiral_regions = normalize_dummy_spiral_regions(first_array_value(
1523        &[&arbitrary, &source_value, &chunk_stream_value],
1524        &["dummySpiralRegions"],
1525    ));
1526    let payload_container = text_from_first_value(
1527        &[&arbitrary, &source_value, &chunk_stream_value],
1528        &["payloadContainer"],
1529    );
1530    let entry_container = text_from_first_value(
1531        &[&arbitrary, &source_value, &chunk_stream_value],
1532        &["entryContainer"],
1533    );
1534
1535    let mut meta = Map::new();
1536    insert_text(&mut meta, "releaseId", &source_value, &["releaseId"]);
1537    insert_text(
1538        &mut meta,
1539        "catalogNumber",
1540        &source_value,
1541        &["catalogNumber"],
1542    );
1543    insert_text(&mut meta, "label", &source_value, &["label"]);
1544    insert_text(
1545        &mut meta,
1546        "artworkCredit",
1547        &source_value,
1548        &["artworkCredit"],
1549    );
1550    insert_text(&mut meta, "license", &source_value, &["license"]);
1551    insert_text(&mut meta, "canonicalUrl", &source_value, &["canonicalUrl"]);
1552    insert_text(&mut meta, "createdAt", &source_value, &["createdAt"]);
1553    insert_text(
1554        &mut meta,
1555        "arbitraryMetadata",
1556        &source_value,
1557        &["arbitraryMetadata"],
1558    );
1559    meta.insert(
1560        "recordProfile".to_string(),
1561        Value::String(record_profile_from_header_value(header)),
1562    );
1563    meta.insert(
1564        "streamByteLength".to_string(),
1565        first_truthy_value(
1566            &[&source_value, &chunk_stream_value],
1567            &["streamByteLength", "byteLength"],
1568        )
1569        .unwrap_or_else(|| Value::String(String::new())),
1570    );
1571    meta.insert(
1572        "payloadByteLength".to_string(),
1573        first_truthy_value(
1574            &[&source_value, &chunk_stream_value],
1575            &["payloadByteLength"],
1576        )
1577        .unwrap_or_else(|| Value::String(String::new())),
1578    );
1579    meta.insert(
1580        "recordPngByteLength".to_string(),
1581        first_truthy_value(&[&record_value], &["pngByteLength"])
1582            .unwrap_or_else(|| Value::String(String::new())),
1583    );
1584    insert_text(&mut meta, "recordPngSha256", &record_value, &["pngSha256"]);
1585    insert_text(
1586        &mut meta,
1587        "chunkStreamSha256",
1588        &chunk_stream_value,
1589        &["sha256"],
1590    );
1591    meta.insert(
1592        "chunkCount".to_string(),
1593        first_truthy_value(&[&chunk_stream_value], &["chunkCount"])
1594            .unwrap_or_else(|| Value::String(String::new())),
1595    );
1596    meta.insert(
1597        "revolutionCount".to_string(),
1598        chunk_stream_revolution_count_value(&chunk_stream_value)
1599            .unwrap_or_else(|| Value::String(String::new())),
1600    );
1601    meta.insert(
1602        "payloadContainer".to_string(),
1603        Value::String(payload_container),
1604    );
1605    meta.insert("entryContainer".to_string(), Value::String(entry_container));
1606    meta.insert("trackListing".to_string(), track_listing);
1607    meta.insert("dummySpiralRegions".to_string(), dummy_spiral_regions);
1608
1609    json!({
1610        "title": text_from_first_value(&[&source_value], &["title"]),
1611        "artist": text_from_first_value(&[&source_value], &["artist"]),
1612        "meta": Value::Object(meta),
1613    })
1614}
1615
1616fn playback_metadata_json(
1617    payload_container: String,
1618    entry_container: String,
1619    track_listing: Value,
1620    dummy_spiral_regions: Value,
1621) -> Value {
1622    json!({
1623        "payloadContainer": payload_container,
1624        "entryContainer": entry_container,
1625        "trackListing": track_listing.as_array().cloned().unwrap_or_default(),
1626        "dummySpiralRegions": dummy_spiral_regions.as_array().cloned().unwrap_or_default(),
1627    })
1628}
1629
1630fn object_field(value: &Value, key: &str) -> Option<Map<String, Value>> {
1631    value.get(key)?.as_object().cloned()
1632}
1633
1634fn value_at_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
1635    let mut cursor = value;
1636    for key in path {
1637        cursor = cursor.get(*key)?;
1638    }
1639    Some(cursor)
1640}
1641
1642fn parse_arbitrary_metadata(source: &Value) -> Value {
1643    let raw = source
1644        .get("arbitraryMetadata")
1645        .or_else(|| source.get("arbitrary_metadata"))
1646        .and_then(Value::as_str)
1647        .unwrap_or("");
1648    if raw.is_empty() {
1649        return Value::Object(Map::new());
1650    }
1651    serde_json::from_str::<Value>(raw)
1652        .ok()
1653        .filter(Value::is_object)
1654        .unwrap_or_else(|| Value::Object(Map::new()))
1655}
1656
1657fn chunk_stream_revolution_count_value(chunk_stream: &Value) -> Option<Value> {
1658    let tracks = value_at_path(chunk_stream, &["metadata", "tracks"]).and_then(Value::as_array)?;
1659    let mut total = 0_u64;
1660    for track in tracks {
1661        let Some(count) = first_numeric_value(&[track], &["revolutionCount", "revolution_count"])
1662        else {
1663            continue;
1664        };
1665        if count.is_finite() && count > 0.0 {
1666            total = total.saturating_add(count.floor() as u64);
1667        }
1668    }
1669    if total > 0 {
1670        Some(json!(total))
1671    } else {
1672        None
1673    }
1674}
1675
1676fn normalize_dummy_spiral_regions(regions: Value) -> Value {
1677    let Some(regions) = regions.as_array() else {
1678        return Value::Array(vec![]);
1679    };
1680    let normalized = regions
1681        .iter()
1682        .filter_map(|region| {
1683            let mut object = region.as_object()?.clone();
1684            let carrier_pixel_start = nonnegative_usize(
1685                object
1686                    .get("carrierPixelStart")
1687                    .or_else(|| object.get("spiralPixelStart")),
1688            );
1689            let pixel_count = nonnegative_usize(
1690                object
1691                    .get("pixelCount")
1692                    .or_else(|| object.get("spiralPixelCount")),
1693            );
1694            if pixel_count == 0 {
1695                return None;
1696            }
1697            let spiral_pixel_start = nonnegative_usize(
1698                object
1699                    .get("spiralPixelStart")
1700                    .or_else(|| object.get("carrierPixelStart")),
1701            );
1702            object.insert("carrierPixelStart".to_string(), json!(carrier_pixel_start));
1703            object.insert("spiralPixelStart".to_string(), json!(spiral_pixel_start));
1704            object.insert("pixelCount".to_string(), json!(pixel_count));
1705            object.insert("codecCarrier".to_string(), Value::Bool(false));
1706            Some(Value::Object(object))
1707        })
1708        .collect();
1709    Value::Array(normalized)
1710}
1711
1712fn nonnegative_usize(value: Option<&Value>) -> usize {
1713    let number = match value {
1714        Some(Value::Number(number)) => number.as_f64().unwrap_or(0.0),
1715        Some(Value::String(text)) => text.parse::<f64>().unwrap_or(0.0),
1716        _ => 0.0,
1717    };
1718    if number.is_finite() && number > 0.0 {
1719        number.floor() as usize
1720    } else {
1721        0
1722    }
1723}
1724
1725fn first_array_value(sources: &[&Value], keys: &[&str]) -> Value {
1726    for source in sources {
1727        for key in keys {
1728            if let Some(value) = source.get(*key) {
1729                if value.is_array() {
1730                    return value.clone();
1731                }
1732            }
1733        }
1734    }
1735    Value::Array(vec![])
1736}
1737
1738fn array_value(value: Option<&Value>) -> Value {
1739    value
1740        .filter(|value| value.is_array())
1741        .cloned()
1742        .unwrap_or_else(|| Value::Array(vec![]))
1743}
1744
1745fn text_from_first_value(sources: &[&Value], keys: &[&str]) -> String {
1746    for source in sources {
1747        for key in keys {
1748            let text = text_from_value(source.get(*key));
1749            if !text.is_empty() {
1750                return text;
1751            }
1752        }
1753    }
1754    String::new()
1755}
1756
1757fn text_from_value(value: Option<&Value>) -> String {
1758    value
1759        .and_then(Value::as_str)
1760        .map(normalize_record_text_field_text)
1761        .unwrap_or_default()
1762}
1763
1764fn insert_text(meta: &mut Map<String, Value>, output_key: &str, source: &Value, keys: &[&str]) {
1765    meta.insert(
1766        output_key.to_string(),
1767        Value::String(text_from_first_value(&[source], keys)),
1768    );
1769}
1770
1771fn first_truthy_value(sources: &[&Value], keys: &[&str]) -> Option<Value> {
1772    for source in sources {
1773        for key in keys {
1774            if let Some(value) = source.get(*key) {
1775                if value_is_truthy(value) {
1776                    return Some(value.clone());
1777                }
1778            }
1779        }
1780    }
1781    None
1782}
1783
1784fn first_numeric_value(sources: &[&Value], keys: &[&str]) -> Option<f64> {
1785    for source in sources {
1786        for key in keys {
1787            if let Some(value) = numeric_value(source.get(*key)) {
1788                if value != 0.0 {
1789                    return Some(value);
1790                }
1791            }
1792        }
1793    }
1794    None
1795}
1796
1797fn numeric_value(value: Option<&Value>) -> Option<f64> {
1798    let value = match value? {
1799        Value::Number(number) => number.as_f64(),
1800        Value::String(text) => text.trim().parse::<f64>().ok(),
1801        _ => None,
1802    }?;
1803    value.is_finite().then_some(value)
1804}
1805
1806fn finite_or_zero(value: f64) -> f64 {
1807    if value.is_finite() {
1808        value
1809    } else {
1810        0.0
1811    }
1812}
1813
1814fn finite_nonnegative(value: f64) -> f64 {
1815    finite_or_zero(value).max(0.0)
1816}
1817
1818fn value_is_truthy(value: &Value) -> bool {
1819    match value {
1820        Value::Null => false,
1821        Value::Bool(value) => *value,
1822        Value::Number(number) => number.as_f64().is_some_and(|value| value != 0.0),
1823        Value::String(text) => !text.is_empty(),
1824        Value::Array(values) => !values.is_empty(),
1825        Value::Object(values) => !values.is_empty(),
1826    }
1827}
1828
1829fn read_u32_be(bytes: &[u8], offset: usize) -> Option<u32> {
1830    let slice = bytes.get(offset..offset + 4)?;
1831    Some(u32::from_be_bytes(slice.try_into().ok()?))
1832}
1833
1834#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1835#[serde(rename_all = "camelCase")]
1836struct EcdcCacheChunk {
1837    chunk_index: usize,
1838    chunk_offset: usize,
1839    chunk_byte_length: usize,
1840}
1841
1842#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1843#[serde(rename_all = "camelCase")]
1844struct EcdcCacheProofContext {
1845    format: String,
1846    header_base64url: String,
1847    header_byte_length: usize,
1848    chunks: Vec<EcdcCacheChunk>,
1849}
1850
1851#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1852#[serde(rename_all = "camelCase")]
1853struct EcdcCacheProof {
1854    record_header: String,
1855    chunk_index: usize,
1856    chunk_offset: usize,
1857    chunk_byte_length: usize,
1858}
1859
1860fn create_player_ecdc_cache_proof_context(ecdc: &[u8]) -> Option<EcdcCacheProofContext> {
1861    if ecdc.len() < 8 || ecdc.get(0..4)? != b"ECDC" {
1862        return None;
1863    }
1864    let metadata_start = ecdc.iter().position(|byte| *byte == b'{')?;
1865    let header_end = json_object_end(ecdc, metadata_start)?;
1866    if header_end > ecdc.len() {
1867        return None;
1868    }
1869    serde_json::from_slice::<Value>(&ecdc[metadata_start..header_end]).ok()?;
1870
1871    // ECDC v2 frame layout: [4B BE payload_len][4B overhead][payload_len bytes]
1872    let mut chunks = Vec::new();
1873    let mut offset = header_end;
1874    while offset < ecdc.len() {
1875        if offset + 8 > ecdc.len() {
1876            break;
1877        }
1878        let payload_len = read_u32_be(ecdc, offset)? as usize;
1879        let end = offset.checked_add(8 + payload_len)?;
1880        if end > ecdc.len() {
1881            break;
1882        }
1883        chunks.push(EcdcCacheChunk {
1884            chunk_index: chunks.len(),
1885            chunk_offset: offset,
1886            chunk_byte_length: 8 + payload_len,
1887        });
1888        offset = end;
1889    }
1890    if chunks.is_empty() {
1891        return None;
1892    }
1893    Some(EcdcCacheProofContext {
1894        format: "ecdc-v2".to_string(),
1895        header_base64url: general_purpose::URL_SAFE_NO_PAD.encode(&ecdc[..header_end]),
1896        header_byte_length: header_end,
1897        chunks,
1898    })
1899}
1900
1901fn player_ecdc_cache_proof_for_chunk_json(context_json: &str, chunk_index: usize) -> String {
1902    let Ok(context) = serde_json::from_str::<EcdcCacheProofContext>(context_json) else {
1903        return "null".to_string();
1904    };
1905    let Some(chunk) = context
1906        .chunks
1907        .get(chunk_index)
1908        .or_else(|| context.chunks.first())
1909    else {
1910        return "null".to_string();
1911    };
1912    let proof = EcdcCacheProof {
1913        record_header: format!(
1914            "{}:{}",
1915            if context.format.is_empty() {
1916                "ecdc-v2"
1917            } else {
1918                &context.format
1919            },
1920            context.header_base64url
1921        ),
1922        chunk_index,
1923        chunk_offset: chunk.chunk_offset,
1924        chunk_byte_length: chunk.chunk_byte_length,
1925    };
1926    serde_json::to_string(&proof).unwrap_or_else(|_| "null".to_string())
1927}
1928
1929fn json_object_end(bytes: &[u8], start: usize) -> Option<usize> {
1930    if bytes.get(start).copied()? != b'{' {
1931        return None;
1932    }
1933    let mut depth = 0i32;
1934    let mut in_string = false;
1935    let mut escaped = false;
1936    for (index, byte) in bytes.iter().enumerate().skip(start) {
1937        if in_string {
1938            if escaped {
1939                escaped = false;
1940            } else if *byte == b'\\' {
1941                escaped = true;
1942            } else if *byte == b'"' {
1943                in_string = false;
1944            }
1945            continue;
1946        }
1947        match *byte {
1948            b'"' => in_string = true,
1949            b'{' => depth += 1,
1950            b'}' => {
1951                depth -= 1;
1952                if depth == 0 {
1953                    return Some(index + 1);
1954                }
1955                if depth < 0 {
1956                    return None;
1957                }
1958            }
1959            _ => {}
1960        }
1961    }
1962    None
1963}
1964
1965fn bcs2_opus_chunk_cache_keys(bcs2: &[u8]) -> Result<Bcs2OpusChunkCacheKeys, String> {
1966    let stream = record_core::parse_chunk_stream(bcs2).map_err(|error| error.to_string())?;
1967    let keys = stream
1968        .chunks
1969        .iter()
1970        .map(|chunk| opus_chunk_cache_key_u64_hex(&chunk.payload))
1971        .collect::<Vec<_>>();
1972    Ok(Bcs2OpusChunkCacheKeys {
1973        format: OPUS_CHUNK_CACHE_KEY_FORMAT,
1974        store_name: OPUS_CHUNK_CACHE_STORE_NAME,
1975        cache_version: OPUS_CHUNK_CACHE_VERSION,
1976        output_codec: OPUS_CHUNK_CACHE_OUTPUT_CODEC,
1977        bitrate: OPUS_CHUNK_CACHE_BITRATE,
1978        keys,
1979    })
1980}
1981
1982fn opus_chunk_cache_key_u64_hex(source_payload: &[u8]) -> String {
1983    let source_payload_hash = stable_hash_hex(source_payload);
1984    let preimage = format!(
1985        "{OPUS_CHUNK_CACHE_KEY_DOMAIN}\n\
1986         source_payload_sha256={source_payload_hash}\n\
1987         output_codec={OPUS_CHUNK_CACHE_OUTPUT_CODEC}\n\
1988         bitrate={OPUS_CHUNK_CACHE_BITRATE}\n\
1989         cache_version={OPUS_CHUNK_CACHE_VERSION}\n"
1990    );
1991    let full_hash = stable_hash_hex(preimage.as_bytes());
1992    full_hash.chars().take(16).collect()
1993}
1994
1995fn parse_encodec_bundle_metadata(bundle_json: &str) -> Result<OnnxFrameBundleMetadata, JsValue> {
1996    serde_json::from_str(bundle_json).map_err(to_js_error)
1997}
1998
1999fn validate_encodec_lm_metadata(meta: &OnnxFrameBundleMetadata) -> Result<(), String> {
2000    meta.lm_dim().map_err(|error| error.to_string())?;
2001    meta.lm_num_layers().map_err(|error| error.to_string())?;
2002    meta.lm_past_context().map_err(|error| error.to_string())?;
2003    if meta.lm_cardinality() == 0 {
2004        return Err("LM cardinality must be non-zero".to_string());
2005    }
2006    Ok(())
2007}
2008
2009fn encodec_probability_columns_from_logits(
2010    logits: &[f32],
2011    meta: &OnnxFrameBundleMetadata,
2012    lm_tau: f64,
2013) -> Result<Vec<f64>, String> {
2014    let card = meta.lm_cardinality();
2015    let codebooks = meta.num_codebooks;
2016    if logits.len() != card * codebooks {
2017        return Err(format!(
2018            "LM logits length {} does not match cardinality {} * codebooks {}",
2019            logits.len(),
2020            card,
2021            codebooks
2022        ));
2023    }
2024
2025    let mut pdf = vec![0.0_f64; card * codebooks];
2026    let mut quantized = vec![0.0_f64; card];
2027    let mut probs = vec![0.0_f64; card];
2028    let uniform = 1.0 / card as f64;
2029    let near_pdf_threshold = 0.25 / DEFAULT_FP_SCALE as f64;
2030    let logit_step = meta.lm_entropy_logit_step();
2031
2032    for codebook in 0..codebooks {
2033        let mut max_value = f64::NEG_INFINITY;
2034        let mut min_value = f64::INFINITY;
2035        for bin in 0..card {
2036            let raw = logits[bin * codebooks + codebook] as f64 / lm_tau;
2037            let quantized_value = quantize_encodec_logit(raw, logit_step);
2038            quantized[bin] = quantized_value;
2039            max_value = max_value.max(quantized_value);
2040            min_value = min_value.min(quantized_value);
2041        }
2042
2043        let mut denom = 0.0_f64;
2044        for bin in 0..card {
2045            let value = (quantized[bin] - max_value).exp();
2046            probs[bin] = value;
2047            denom += value;
2048        }
2049        if !denom.is_finite() || denom <= 0.0 {
2050            for bin in 0..card {
2051                pdf[bin * codebooks + codebook] = uniform;
2052            }
2053            continue;
2054        }
2055
2056        let mut max_pdf = 0.0_f64;
2057        let mut min_pdf = f64::INFINITY;
2058        for prob in probs.iter_mut() {
2059            *prob /= denom;
2060            max_pdf = max_pdf.max(*prob);
2061            min_pdf = min_pdf.min(*prob);
2062        }
2063        let near_uniform = (max_value - min_value) <= (2.0 * logit_step)
2064            || (max_pdf - min_pdf) <= near_pdf_threshold;
2065        for bin in 0..card {
2066            pdf[bin * codebooks + codebook] = if near_uniform { uniform } else { probs[bin] };
2067        }
2068    }
2069
2070    Ok(pdf)
2071}
2072
2073fn quantize_encodec_logit(value: f64, step: f64) -> f64 {
2074    let eps = 2_f64.powi(-40);
2075    let y = value / step;
2076    (y + 0.5 - eps).floor() * step
2077}
2078
2079fn to_js_value<T: Serialize + ?Sized>(value: &T) -> Result<JsValue, JsValue> {
2080    let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true);
2081    value.serialize(&serializer).map_err(to_js_error)
2082}
2083
2084fn to_js_error(error: impl std::fmt::Display) -> JsValue {
2085    JsValue::from_str(&error.to_string())
2086}
2087
2088#[cfg(test)]
2089mod tests {
2090    use super::*;
2091
2092    #[test]
2093    fn extracts_record_text_and_playback_metadata_from_header_json() {
2094        let header = json!({
2095            "descriptor": {
2096                "title": "  Westside\u{0000}Demo  ",
2097                "artist": " Lori   Asha ",
2098                "recordProfile": "single45",
2099                "arbitraryMetadata": serde_json::to_string(&json!({
2100                    "payloadContainer": "ECDC",
2101                    "trackListing": [{"title": "A"}],
2102                    "dummySpiralRegions": [{"spiralPixelStart": 7, "spiralPixelCount": 3}]
2103                })).unwrap()
2104            },
2105            "chunkStream": {
2106                "byteLength": 123,
2107                "chunkCount": 2,
2108                "metadata": {
2109                    "tracks": [{ "title": "A", "first_revolution_index": 0, "revolution_count": 7 }]
2110                },
2111                "sha256": "abc"
2112            }
2113        });
2114
2115        let parsed: Value = serde_json::from_str(&record_text_from_header_validation_json(
2116            &header.to_string(),
2117        ))
2118        .unwrap();
2119        assert_eq!(parsed["title"], "Westside Demo");
2120        assert_eq!(parsed["artist"], "Lori Asha");
2121        assert_eq!(parsed["meta"]["recordProfile"], "single45");
2122        assert_eq!(parsed["meta"]["chunkCount"], 2);
2123        assert_eq!(parsed["meta"]["revolutionCount"], 7);
2124        assert_eq!(parsed["meta"]["trackListing"][0]["title"], "A");
2125        assert_eq!(
2126            parsed["meta"]["dummySpiralRegions"][0]["carrierPixelStart"],
2127            7
2128        );
2129        assert_eq!(parsed["meta"]["dummySpiralRegions"][0]["pixelCount"], 3);
2130
2131        let playback: Value = serde_json::from_str(&record_playback_metadata_from_header_json(
2132            &header.to_string(),
2133        ))
2134        .unwrap();
2135        assert_eq!(playback["payloadContainer"], "ECDC");
2136        assert_eq!(playback["entryContainer"], "");
2137        assert_eq!(playback["trackListing"][0]["title"], "A");
2138    }
2139
2140    #[test]
2141    fn resolves_record_display_metadata_from_canonical_fields() {
2142        let record = json!({
2143            "title": "  Westside  ",
2144            "artist": " Lori   Asha ",
2145            "recordProfile": "single45",
2146            "meta": {
2147                "title": "Metadata Title",
2148                "artist": "Metadata Artist",
2149                "recordProfile": "lp",
2150                "bitneedleVerified": "true"
2151            }
2152        });
2153
2154        let metadata = record_display_metadata_value(&record, "");
2155        assert_eq!(metadata["title"], "Westside");
2156        assert_eq!(metadata["artist"], "Lori Asha");
2157        assert_eq!(metadata["displayLabel"], "Westside - Lori Asha");
2158        assert_eq!(metadata["profileDisplay"], "45 \u{00b7} Verified");
2159        assert_eq!(metadata["recordProfile"], "single45");
2160        assert_eq!(metadata["verified"], true);
2161    }
2162
2163    #[test]
2164    fn builds_record_verification_meta_without_header_aliases() {
2165        let meta: Value = serde_json::from_str(&record_verification_meta_json(
2166            r#"{"ok":true,"code":" abc  123 ","keyId":" key-1 "}"#,
2167        ))
2168        .unwrap();
2169        assert_eq!(meta["bitneedleVerification"], "abc 123");
2170        assert_eq!(meta["bitneedleVerified"], "true");
2171        assert_eq!(meta["signatureKeyId"], "key-1");
2172        assert!(meta.get("X-Bitneedle-Verified").is_none());
2173        assert!(meta.get("X-Bitneedle-Verification").is_none());
2174    }
2175
2176    #[test]
2177    fn resolves_playback_payload_metadata_from_header_and_payload() {
2178        let header = json!({
2179            "payloadContainer": "ecdc",
2180            "entryContainer": "",
2181            "trackListing": [{ "title": "Header track" }],
2182            "dummySpiralRegions": [{ "spiralPixelStart": 8, "pixelCount": 4 }]
2183        });
2184        let payload = json!({
2185            "payloadContainer": "mossnano",
2186            "payloadCodec": "moss-audio-tokenizer-nano-rvq16",
2187            "entryContainer": "single",
2188            "payloadDescriptors": [{ "container": "ignored" }],
2189            "trackListing": [{ "title": "Payload track" }],
2190            "dummySpiralRegions": [{ "spiralPixelStart": 20, "pixelCount": 5 }]
2191        });
2192
2193        let metadata = resolve_playback_payload_metadata_value(&header, &payload);
2194        assert_eq!(metadata["payloadContainer"], "ECDC");
2195        assert_eq!(metadata["payloadCodec"], "moss-audio-tokenizer-nano-rvq16");
2196        assert_eq!(metadata["entryContainer"], "single");
2197        assert_eq!(metadata["trackListing"][0]["title"], "Header track");
2198        assert_eq!(metadata["dummySpiralRegions"][0]["carrierPixelStart"], 8);
2199        assert_eq!(metadata["dummySpiralRegions"][0]["pixelCount"], 4);
2200    }
2201
2202    #[test]
2203    fn ignores_legacy_snake_case_record_metadata_aliases() {
2204        let header = json!({
2205            "descriptor": {
2206                "record_profile": "single45",
2207                "arbitrary_metadata": serde_json::to_string(&json!({
2208                    "payload_container": "ECDC",
2209                    "track_listing": [{"title": "legacy"}],
2210                    "dummy_spiral_regions": [{"spiralPixelStart": 7, "spiralPixelCount": 3}]
2211                })).unwrap()
2212            },
2213            "chunkStream": {
2214                "payload_container": "ECDC",
2215                "track_listing": [{"title": "legacy"}],
2216                "dummy_spiral_regions": [{"spiralPixelStart": 7, "spiralPixelCount": 3}]
2217            }
2218        });
2219
2220        assert_eq!(
2221            record_profile_from_header_validation_json(&header.to_string()),
2222            ""
2223        );
2224        let playback: Value = serde_json::from_str(&record_playback_metadata_from_header_json(
2225            &header.to_string(),
2226        ))
2227        .unwrap();
2228        assert_eq!(playback["payloadContainer"], "");
2229        assert_eq!(playback["entryContainer"], "");
2230        assert_eq!(playback["trackListing"].as_array().unwrap().len(), 0);
2231        assert_eq!(playback["dummySpiralRegions"].as_array().unwrap().len(), 0);
2232    }
2233
2234    #[test]
2235    fn resolves_record_profile_and_playback_math() {
2236        let profile: Value =
2237            serde_json::from_str(&record_profile_spec_json("single45").unwrap()).unwrap();
2238        assert_eq!(profile["name"], "single45");
2239        assert_eq!(profile["label"], "45");
2240        assert_eq!(profile["leadInTurns"], 2.0);
2241        assert!(profile["spindleHoleRadius"].as_i64().unwrap() > 0);
2242
2243        assert_eq!(normalize_record_profile_name("lp").unwrap(), "lp");
2244        assert!(normalize_record_profile_name("45rpm").is_err());
2245        assert!(normalize_record_profile_name("12 inch").is_err());
2246        assert!(normalize_record_profile_name("single12").is_err());
2247        assert!((record_rpm("lp").unwrap() - 33.3333333333).abs() < 1e-9);
2248
2249        let playback_rate = resolve_playback_rate_number(4.0, 1.0, 0.92, 1.25);
2250        assert_eq!(playback_rate, 1.25);
2251
2252        let revolutions = seconds_to_revolutions_number(120.0, "single45", f64::NAN).unwrap();
2253        assert_eq!(revolutions, 90.0);
2254
2255        let seconds_per_turn =
2256            resolve_seconds_per_turn_number(f64::NAN, "single45", 1.0, 1.0, 0.92, 1.25).unwrap();
2257        assert!((seconds_per_turn - (60.0 / 45.0)).abs() < 1e-9);
2258    }
2259
2260    #[test]
2261    fn resolves_scratch_identity_rules() {
2262        assert_eq!(
2263            scratch_sample_token_from_bytes(&[0xff, 0, 0, 0, 0, 0, 1], 99.0),
2264            8725724278030337.0
2265        );
2266        assert_eq!(
2267            scratch_sample_token_hex(8725724278030337.0),
2268            "1f000000000001"
2269        );
2270        assert_eq!(
2271            scratch_clip_id_for_sample_id(8725724278030337.0),
2272            "scratch-sample-1f000000000001"
2273        );
2274        assert_eq!(
2275            scratch_clip_sample_id_json(r#"{"sampleId":8725724278030337}"#),
2276            8725724278030337.0
2277        );
2278        assert!(is_valid_scratch_anon_user_id("bnanon_123456789abc"));
2279        assert!(!is_valid_scratch_anon_user_id("anon_123456789abc"));
2280        assert_eq!(
2281            scratch_anon_user_id_from_random("ABC_de--123!!!!"),
2282            "bnanon_abcde--123"
2283        );
2284        assert_eq!(
2285            normalize_scratch_display_name("  A\u{0000}   Name\t "),
2286            "A Name"
2287        );
2288        assert_eq!(
2289            scratch_display_name_key("  Mixed CASE  Name "),
2290            "mixed case name"
2291        );
2292        assert_eq!(
2293            stable_local_record_id_from_meta_json(
2294                r#"{"releaseId":"0xabc 123","recordPngSha256":"ignored"}"#,
2295                "fallback.png",
2296            ),
2297            "local-record-abc-123"
2298        );
2299        assert_eq!(
2300            scratch_visitor_wallet_address_from_bytes(&[1, 2, 3]),
2301            "0x0102030000000000000000000000000000000000"
2302        );
2303        assert!(is_valid_scratch_wallet_address(
2304            "0x0102030000000000000000000000000000000000"
2305        ));
2306        assert_eq!(
2307            short_scratch_address("0x0102030000000000000000000000000000000000", 4.0),
2308            "0x0102...0000"
2309        );
2310    }
2311
2312    #[test]
2313    fn resolves_remote_scratch_control_revision_protocol() {
2314        assert_eq!(normalize_scratch_remote_control_revision(0.0), 0);
2315        assert_eq!(normalize_scratch_remote_control_revision(3.7), 3);
2316        assert_eq!(bump_scratch_remote_control_revision(0.0), 1);
2317        assert_eq!(bump_scratch_remote_control_revision(f64::from(u32::MAX)), 1);
2318
2319        let ensured: Value = serde_json::from_str(&ensure_scratch_remote_control_revision_json(
2320            r#"{"controlRevision":0,"controlIntent":true}"#,
2321        ))
2322        .unwrap();
2323        assert_eq!(ensured["controlRevision"], 1);
2324        assert_eq!(ensured["controlIntent"], false);
2325
2326        let newer: Value = serde_json::from_str(&should_apply_remote_scratch_controls_json(
2327            r#"{"controlRevision":2,"controlIntent":false}"#,
2328            r#"{"controlRevision":3,"controlIntent":false}"#,
2329        ))
2330        .unwrap();
2331        assert_eq!(newer["apply"], true);
2332        assert_eq!(newer["controlRevision"], 3);
2333
2334        let intent_tie: Value = serde_json::from_str(&should_apply_remote_scratch_controls_json(
2335            r#"{"controlRevision":3,"controlIntent":false}"#,
2336            r#"{"controlRevision":3,"controlIntent":true}"#,
2337        ))
2338        .unwrap();
2339        assert_eq!(intent_tie["apply"], true);
2340        assert_eq!(intent_tie["controlIntent"], true);
2341
2342        let stale: Value = serde_json::from_str(&should_apply_remote_scratch_controls_json(
2343            r#"{"controlRevision":4,"controlIntent":true}"#,
2344            r#"{"controlRevision":3,"controlIntent":true}"#,
2345        ))
2346        .unwrap();
2347        assert_eq!(stale["apply"], false);
2348        assert_eq!(stale["controlRevision"], 4);
2349    }
2350
2351    #[test]
2352    fn creates_ecdc_cache_proof_context() {
2353        // ECDC v2 frame: [4B BE payload_len][4B overhead][payload bytes]
2354        let mut ecdc = b"ECDC{\"x\":1}".to_vec();
2355        ecdc.extend_from_slice(&2u32.to_be_bytes()); // payload_len = 2
2356        ecdc.extend_from_slice(&[0, 0, 0, 0]); // 4-byte overhead
2357        ecdc.extend_from_slice(&[1, 2]); // 2 bytes payload
2358
2359        let context = create_player_ecdc_cache_proof_context(&ecdc).unwrap();
2360        assert_eq!(context.header_byte_length, 11);
2361        assert_eq!(context.chunks[0].chunk_offset, 11);
2362        assert_eq!(context.chunks[0].chunk_byte_length, 10); // 8 header + 2 payload
2363
2364        let context_json = serde_json::to_string(&context).unwrap();
2365        let proof: Value =
2366            serde_json::from_str(&player_ecdc_cache_proof_for_chunk_json(&context_json, 3))
2367                .unwrap();
2368        assert_eq!(proof["chunkIndex"], 3);
2369        assert_eq!(proof["chunkOffset"], 11);
2370        assert_eq!(proof["chunkByteLength"], 10);
2371        assert!(proof["recordHeader"]
2372            .as_str()
2373            .unwrap()
2374            .starts_with("ecdc-v2:"));
2375    }
2376
2377    #[test]
2378    fn derives_ordered_bcs2_opus_chunk_cache_keys() {
2379        let input = record_cut::RecordStreamInput {
2380            payload_descriptors: vec![record_cut::PayloadDescriptorInput::from_container("TEST")],
2381            tracks: vec![record_cut::TrackInput {
2382                title: "Test Track".to_string(),
2383                first_revolution_index: Some(0),
2384                revolution_count: Some(2),
2385            }],
2386            track_gaps: vec![],
2387        };
2388        let entries = vec![
2389            record_cut::PayloadEntryInput {
2390                payload_descriptor_index: 0,
2391                bytes: b"first".to_vec(),
2392            },
2393            record_cut::PayloadEntryInput {
2394                payload_descriptor_index: 0,
2395                bytes: b"second".to_vec(),
2396            },
2397        ];
2398        let stream = record_cut::encode_record_stream(&input, &entries).unwrap();
2399        let parsed = record_core::parse_chunk_stream(&stream).unwrap();
2400
2401        let keys = bcs2_opus_chunk_cache_keys(&stream).unwrap();
2402
2403        assert_eq!(keys.format, OPUS_CHUNK_CACHE_KEY_FORMAT);
2404        assert_eq!(keys.store_name, OPUS_CHUNK_CACHE_STORE_NAME);
2405        assert_eq!(keys.keys.len(), 2);
2406        assert_eq!(keys.keys[0].len(), 16);
2407        assert_eq!(keys.keys[1].len(), 16);
2408        assert_ne!(keys.keys[0], keys.keys[1]);
2409        assert_eq!(
2410            keys.keys[0],
2411            opus_chunk_cache_key_u64_hex(&parsed.chunks[0].payload)
2412        );
2413        assert_eq!(
2414            keys.keys[1],
2415            opus_chunk_cache_key_u64_hex(&parsed.chunks[1].payload)
2416        );
2417    }
2418}