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 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#[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 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#[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#[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#[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#[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
250fn 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 let mut reader = Cursor::new(payload);
273 let mut chunks = Vec::new();
274 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 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 local_offset += layout.stride;
310 }
311
312 global_offset += metadata.audio_length;
313 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 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 let mut ecdc = b"ECDC{\"x\":1}".to_vec();
2355 ecdc.extend_from_slice(&2u32.to_be_bytes()); ecdc.extend_from_slice(&[0, 0, 0, 0]); ecdc.extend_from_slice(&[1, 2]); 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); 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}