libflo_audio/
lib.rs

1#![allow(clippy::needless_range_loop)]
2
3use wasm_bindgen::prelude::*;
4
5pub mod core;
6pub mod lossless;
7pub mod lossy;
8pub mod streaming;
9
10mod reader;
11mod writer;
12
13pub use core::{
14    compute_crc32, metadata::*, rice, ChannelData, FloFile, FloResult, FrameType, ResidualEncoding,
15    HEADER_SIZE, MAGIC, VERSION_MAJOR, VERSION_MINOR,
16};
17pub use lossless::{lpc, Decoder, Encoder};
18pub use lossy::{
19    deserialize_frame, serialize_frame, BlockSize, Mdct, PsychoacousticModel, QualityPreset,
20    TransformDecoder as LossyDecoder, TransformEncoder as LossyEncoder, TransformFrame, WindowType,
21};
22pub use reader::Reader;
23pub use streaming::{
24    DecoderState, EncodedFrame, StreamingAudioInfo, StreamingDecoder, StreamingEncoder,
25};
26pub use writer::Writer;
27
28// audio info for the info() function
29
30/// info about a flo file
31#[wasm_bindgen]
32#[derive(Debug, Clone)]
33pub struct AudioInfo {
34    /// version string like "1.2"
35    #[wasm_bindgen(skip)]
36    pub version: String,
37    /// Sample rate in Hz
38    pub sample_rate: u32,
39    /// Number of channels
40    pub channels: u8,
41    /// Bits per sample
42    pub bit_depth: u8,
43    /// Total number of frames
44    pub total_frames: u64,
45    /// Duration in seconds
46    pub duration_secs: f64,
47    /// File size in bytes
48    pub file_size: usize,
49    /// Compression ratio (original / compressed)
50    pub compression_ratio: f64,
51    /// Is CRC valid?
52    pub crc_valid: bool,
53    /// Is lossy compression mode?
54    pub is_lossy: bool,
55    /// Lossy quality 0-4 (only valid if is_lossy)
56    pub lossy_quality: u8,
57}
58
59#[wasm_bindgen]
60impl AudioInfo {
61    #[wasm_bindgen(getter)]
62    pub fn version(&self) -> String {
63        self.version.clone()
64    }
65}
66
67// result helpers
68
69/// turn an error into js
70fn to_js_err(e: String) -> JsValue {
71    JsValue::from_str(&e)
72}
73
74// api functions
75
76/// encode samples to flo lossless
77///
78/// # Arguments
79/// * `samples` - Interleaved audio samples (f32, -1.0 to 1.0)
80/// * `sample_rate` - Sample rate in Hz (e.g., 44100)
81/// * `channels` - Number of channels (1 or 2)
82/// * `bit_depth` - Bits per sample (16, 24, or 32)
83/// * `metadata` - Optional MessagePack metadata
84///
85/// # Returns
86/// flo™ file as byte array
87///
88/// # Note
89/// For advanced usage with custom compression levels (0-9),
90/// use the `Encoder` builder pattern directly.
91#[wasm_bindgen]
92pub fn encode(
93    samples: &[f32],
94    sample_rate: u32,
95    channels: u8,
96    bit_depth: u8,
97    metadata: Option<Vec<u8>>,
98) -> Result<Vec<u8>, JsValue> {
99    let encoder = Encoder::new(sample_rate, channels, bit_depth);
100    encoder
101        .encode(samples, &metadata.unwrap_or_default())
102        .map_err(to_js_err)
103}
104
105/// encode samples to flo lossy
106///
107/// # Arguments
108/// * `samples` - Interleaved audio samples (f32, -1.0 to 1.0)
109/// * `sample_rate` - Sample rate in Hz (e.g., 44100)
110/// * `channels` - Number of audio channels (1 or 2)
111/// * `bit_depth` - Bits per sample (typically 16)
112/// * `quality` - Quality level 0-4 (0=low/~64kbps, 4=transparent/~320kbps)
113/// * `metadata` - Optional MessagePack metadata
114///
115/// # Returns
116/// flo™ file as byte array
117///
118/// # Note
119/// For advanced usage with continuous quality control (0.0-1.0) or custom settings,
120/// use the `LossyEncoder` builder pattern directly.
121#[wasm_bindgen]
122pub fn encode_lossy(
123    samples: &[f32],
124    sample_rate: u32,
125    channels: u8,
126    _bit_depth: u8,
127    quality: u8,
128    metadata: Option<Vec<u8>>,
129) -> Result<Vec<u8>, JsValue> {
130    // quality levels to 0.0-1.0
131    let quality_f32 = match quality {
132        0 => 0.0,
133        1 => 0.35,
134        2 => 0.55,
135        3 => 0.75,
136        _ => 1.0,
137    };
138
139    let mut encoder = lossy::TransformEncoder::new(sample_rate, channels, quality_f32);
140    encoder
141        .encode_to_flo(samples, &metadata.unwrap_or_default())
142        .map_err(to_js_err)
143}
144
145/// encode to flo lossy with target bitrate
146///
147/// # Arguments
148/// * `samples` - Interleaved audio samples (f32, -1.0 to 1.0)
149/// * `sample_rate` - Sample rate in Hz (e.g., 44100)
150/// * `channels` - Number of audio channels
151/// * `bit_depth` - Bits per sample (16, 24, or 32)
152/// * `target_bitrate_kbps` - Target bitrate in kbps (e.g., 128, 192, 256, 320)
153/// * `metadata` - Optional MessagePack metadata
154///
155/// # Returns
156/// flo™ file as byte array
157#[wasm_bindgen]
158pub fn encode_with_bitrate(
159    samples: &[f32],
160    sample_rate: u32,
161    channels: u8,
162    _bit_depth: u8,
163    target_bitrate_kbps: u32,
164    metadata: Option<Vec<u8>>,
165) -> Result<Vec<u8>, JsValue> {
166    // bitrate to quality
167    let quality =
168        lossy::QualityPreset::from_bitrate(target_bitrate_kbps, sample_rate, channels).as_f32();
169    let mut encoder = lossy::TransformEncoder::new(sample_rate, channels, quality);
170    encoder
171        .encode_to_flo(samples, &metadata.unwrap_or_default())
172        .map_err(to_js_err)
173}
174
175/// decode flo file to samples
176///
177/// This automatically detects whether the file uses lossless or lossy encoding
178/// and dispatches to the appropriate decoder.
179///
180/// # Arguments
181/// * `data` - flo™ file bytes
182///
183/// # Returns
184/// Interleaved audio samples (f32, -1.0 to 1.0)
185#[wasm_bindgen]
186pub fn decode(data: &[u8]) -> Result<Vec<f32>, JsValue> {
187    // figure out if its transform/lossy
188    let reader = Reader::new();
189    let file = reader.read(data).map_err(to_js_err)?;
190
191    // any transform frames means lossy
192    let is_transform = file
193        .frames
194        .iter()
195        .any(|f| f.frame_type == (FrameType::Transform as u8));
196
197    if is_transform {
198        // lossy
199        decode_transform_file(&file).map_err(to_js_err)
200    } else {
201        // lossless
202        let decoder = Decoder::new();
203        decoder.decode_file(&file).map_err(to_js_err)
204    }
205}
206
207/// Decode a lossy flo™ file that uses transform-based compression
208///
209/// # Arguments
210/// * `file` - Parsed flo™ file
211///
212/// # Returns
213/// Interleaved audio samples (f32, -1.0 to 1.0)
214/// Decode a transform-based lossy file
215fn decode_transform_file(file: &FloFile) -> FloResult<Vec<f32>> {
216    let mut decoder = lossy::TransformDecoder::new(file.header.sample_rate, file.header.channels);
217    let mut all_samples = Vec::new();
218    let mut frame_count = 0;
219
220    for frame in &file.frames {
221        if frame.channels.is_empty() {
222            continue;
223        }
224
225        // transform data is in first channels residuals
226        let frame_data = &frame.channels[0].residuals;
227
228        if let Some(transform_frame) = lossy::deserialize_frame(frame_data) {
229            let samples = decoder.decode_frame(&transform_frame);
230
231            // skip first frame (pre-roll for overlap-add)
232            if frame_count > 0 {
233                all_samples.extend(samples);
234            }
235            frame_count += 1;
236        } else {
237            return Err("Failed to deserialize transform frame".to_string());
238        }
239    }
240
241    Ok(all_samples)
242}
243
244/// Validate flo™ file integrity
245///
246/// # Arguments
247/// * `data` - flo™ file bytes
248///
249/// # Returns
250/// true if file is valid and CRC matches
251#[wasm_bindgen]
252pub fn validate(data: &[u8]) -> Result<bool, JsValue> {
253    let reader = Reader::new();
254    match reader.read(data) {
255        Ok(file) => {
256            let start = (4 + file.header.header_size + file.header.toc_size) as usize;
257            let end = start + (file.header.data_size as usize);
258            if end <= data.len() {
259                let computed = core::crc32::compute(&data[start..end]);
260                Ok(computed == file.header.data_crc32)
261            } else {
262                Ok(false)
263            }
264        }
265        Err(_) => Ok(false),
266    }
267}
268
269/// Get information about a flo™ file
270///
271/// # Arguments
272/// * `data` - flo™ file bytes
273///
274/// # Returns
275/// AudioInfo struct with file details
276#[wasm_bindgen]
277pub fn info(data: &[u8]) -> Result<AudioInfo, JsValue> {
278    let reader = Reader::new();
279    let file = reader.read(data).map_err(to_js_err)?;
280
281    let duration_secs = file.header.total_frames as f64;
282    let original_size = ((file.header.total_frames as f64)
283        * (file.header.sample_rate as f64)
284        * (file.header.channels as f64)
285        * ((file.header.bit_depth as f64) / 8.0)) as usize;
286    let compression_ratio = if !data.is_empty() {
287        (original_size as f64) / (data.len() as f64)
288    } else {
289        0.0
290    };
291
292    // check crc
293    let start = (4 + file.header.header_size + file.header.toc_size) as usize;
294    let end = start + (file.header.data_size as usize);
295    let crc_valid = if end <= data.len() {
296        core::crc32::compute(&data[start..end]) == file.header.data_crc32
297    } else {
298        false
299    };
300
301    // lossy mode from flags
302    let is_lossy = (file.header.flags & 0x01) != 0;
303    let lossy_quality = ((file.header.flags >> 8) & 0x0f) as u8;
304
305    Ok(AudioInfo {
306        version: format!(
307            "{}.{}",
308            file.header.version_major, file.header.version_minor
309        ),
310        sample_rate: file.header.sample_rate,
311        channels: file.header.channels,
312        bit_depth: file.header.bit_depth,
313        total_frames: file.header.total_frames,
314        duration_secs,
315        file_size: data.len(),
316        compression_ratio,
317        crc_valid,
318        is_lossy,
319        lossy_quality,
320    })
321}
322
323/// get lib version
324#[wasm_bindgen]
325pub fn version() -> String {
326    format!("{}.{}", VERSION_MAJOR, VERSION_MINOR)
327}
328
329// streaming decoder wasm api
330
331#[wasm_bindgen]
332pub struct WasmStreamingDecoder {
333    inner: StreamingDecoder,
334}
335
336#[wasm_bindgen]
337impl WasmStreamingDecoder {
338    /// new streaming decoder
339    #[wasm_bindgen(constructor)]
340    pub fn new() -> Self {
341        Self {
342            inner: StreamingDecoder::new(),
343        }
344    }
345
346    /// feed data to the decoder, call as bytes come in from network
347    #[wasm_bindgen]
348    pub fn feed(&mut self, data: &[u8]) -> Result<bool, JsValue> {
349        self.inner.feed(data).map_err(to_js_err)
350    }
351
352    /// Check if the decoder is ready to produce audio
353    #[wasm_bindgen]
354    pub fn is_ready(&self) -> bool {
355        self.inner.state() == DecoderState::Ready
356    }
357
358    /// stream done?
359    #[wasm_bindgen]
360    pub fn is_finished(&self) -> bool {
361        self.inner.state() == DecoderState::Finished
362    }
363
364    /// Check if there was an error
365    #[wasm_bindgen]
366    pub fn has_error(&self) -> bool {
367        self.inner.state() == DecoderState::Error
368    }
369
370    /// Get the current state as a string
371    #[wasm_bindgen]
372    pub fn state(&self) -> String {
373        match self.inner.state() {
374            DecoderState::WaitingForHeader => "waiting_for_header".into(),
375            DecoderState::WaitingForToc => "waiting_for_toc".into(),
376            DecoderState::Ready => "ready".into(),
377            DecoderState::Finished => "finished".into(),
378            DecoderState::Error => "error".into(),
379        }
380    }
381
382    /// Get audio information (available after header is parsed)
383    ///
384    /// Returns null if header hasn't been parsed yet.
385    #[wasm_bindgen]
386    pub fn get_info(&self) -> Result<JsValue, JsValue> {
387        match self.inner.info() {
388            Some(info) => {
389                let obj = js_sys::Object::new();
390                js_sys::Reflect::set(&obj, &"sample_rate".into(), &info.sample_rate.into())?;
391                js_sys::Reflect::set(&obj, &"channels".into(), &info.channels.into())?;
392                js_sys::Reflect::set(&obj, &"bit_depth".into(), &info.bit_depth.into())?;
393                js_sys::Reflect::set(
394                    &obj,
395                    &"total_frames".into(),
396                    &(info.total_frames as f64).into(),
397                )?;
398                js_sys::Reflect::set(&obj, &"is_lossy".into(), &info.is_lossy.into())?;
399                Ok(obj.into())
400            }
401            None => Ok(JsValue::NULL),
402        }
403    }
404
405    /// decode all currently available samples
406    #[wasm_bindgen]
407    pub fn decode_available(&mut self) -> Result<Vec<f32>, JsValue> {
408        self.inner.decode_available().map_err(to_js_err)
409    }
410
411    /// Decode the next available frame
412    ///
413    /// Returns interleaved f32 samples for one frame, or null if no frame is ready.
414    /// This enables true streaming: decode and play frames as they arrive.
415    ///
416    /// Usage pattern:
417    /// ```js
418    /// while (true) {
419    ///     const samples = decoder.next_frame();
420    ///     if (samples === null) break; // No more frames ready
421    ///     playAudio(samples);
422    /// }
423    /// ```
424    #[wasm_bindgen]
425    pub fn next_frame(&mut self) -> Result<JsValue, JsValue> {
426        match self.inner.next_frame() {
427            Ok(Some(samples)) => {
428                let array = js_sys::Float32Array::new_with_length(samples.len() as u32);
429                array.copy_from(&samples);
430                Ok(array.into())
431            }
432            Ok(None) => Ok(JsValue::NULL),
433            Err(e) => Err(to_js_err(e)),
434        }
435    }
436
437    /// how many frames ready to decode
438    #[wasm_bindgen]
439    pub fn available_frames(&self) -> usize {
440        self.inner.available_frames()
441    }
442
443    /// current frame index
444    #[wasm_bindgen]
445    pub fn current_frame_index(&self) -> usize {
446        self.inner.current_frame_index()
447    }
448
449    /// Reset the decoder to initial state
450    ///
451    /// Use this to start decoding a new stream.
452    #[wasm_bindgen]
453    pub fn reset(&mut self) {
454        self.inner.reset();
455    }
456
457    /// bytes currently buffered
458    #[wasm_bindgen]
459    pub fn buffered_bytes(&self) -> usize {
460        self.inner.buffered_bytes()
461    }
462}
463
464impl Default for WasmStreamingDecoder {
465    fn default() -> Self {
466        Self::new()
467    }
468}
469
470/// Create metadata from basic fields and serialize to MessagePack
471///
472/// # Arguments
473/// * `title` - Optional title
474/// * `artist` - Optional artist
475/// * `album` - Optional album
476///
477/// # Returns
478/// MessagePack bytes containing metadata
479#[wasm_bindgen]
480pub fn create_metadata(
481    title: Option<String>,
482    artist: Option<String>,
483    album: Option<String>,
484) -> Result<Vec<u8>, JsValue> {
485    let meta = FloMetadata::with_basic(title, artist, album);
486    meta.to_msgpack()
487        .map_err(|e| JsValue::from_str(&e.to_string()))
488}
489
490/// Create metadata from a JavaScript object
491///
492/// Accepts an object with any of the supported metadata fields.
493/// See FloMetadata for available fields.
494///
495/// # Returns
496/// MessagePack bytes containing metadata
497#[wasm_bindgen]
498pub fn create_metadata_from_object(obj: JsValue) -> Result<Vec<u8>, JsValue> {
499    let meta: FloMetadata = serde_wasm_bindgen::from_value(obj)
500        .map_err(|e| JsValue::from_str(&format!("Invalid metadata: {}", e)))?;
501    meta.to_msgpack()
502        .map_err(|e| JsValue::from_str(&e.to_string()))
503}
504
505/// Extract metadata from a flo™ file
506///
507/// # Arguments
508/// * `data` - flo™ file bytes
509///
510/// # Returns
511/// JavaScript object with metadata fields (or null if no metadata)
512#[wasm_bindgen]
513pub fn get_metadata(data: &[u8]) -> Result<JsValue, JsValue> {
514    let reader = Reader::new();
515    let file = reader.read(data).map_err(to_js_err)?;
516
517    if file.metadata.is_empty() {
518        return Ok(JsValue::NULL);
519    }
520
521    let meta = FloMetadata::from_msgpack(&file.metadata)
522        .map_err(|e| JsValue::from_str(&format!("Invalid metadata: {}", e)))?;
523
524    serde_wasm_bindgen::to_value(&meta)
525        .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
526}
527
528/// Get cover art from a flo™ file
529///
530/// # Arguments
531/// * `data` - flo™ file bytes
532///
533/// # Returns
534/// Object with `mime_type` and `data` (Uint8Array) or null if no cover
535#[wasm_bindgen]
536pub fn get_cover_art(data: &[u8]) -> Result<JsValue, JsValue> {
537    let reader = Reader::new();
538    let file = reader.read(data).map_err(to_js_err)?;
539
540    if file.metadata.is_empty() {
541        return Ok(JsValue::NULL);
542    }
543
544    let meta = FloMetadata::from_msgpack(&file.metadata)
545        .map_err(|e| JsValue::from_str(&format!("Invalid metadata: {}", e)))?;
546
547    // try front cover first
548    if let Some(pic) = meta.front_cover().or_else(|| meta.any_picture()) {
549        let obj = js_sys::Object::new();
550        js_sys::Reflect::set(&obj, &"mime_type".into(), &pic.mime_type.clone().into())?;
551        js_sys::Reflect::set(
552            &obj,
553            &"data".into(),
554            &js_sys::Uint8Array::from(&pic.data[..]).into(),
555        )?;
556        if let Some(ref desc) = pic.description {
557            js_sys::Reflect::set(&obj, &"description".into(), &desc.clone().into())?;
558        }
559        Ok(obj.into())
560    } else {
561        Ok(JsValue::NULL)
562    }
563}
564
565/// Set a single field in existing metadata bytes
566///
567/// Uses serde to dynamically set fields - field names match FloMetadata struct.
568/// For complex fields (pictures, synced_lyrics, etc.) use create_metadata_from_object.
569///
570/// # Arguments
571/// * `metadata` - Existing MessagePack metadata bytes (or empty for new)
572/// * `field` - Field name (e.g., "title", "artist", "bpm")
573/// * `value` - Field value (string, number, or null)
574///
575/// # Returns
576/// Updated MessagePack metadata bytes
577#[wasm_bindgen]
578pub fn set_metadata_field(
579    metadata: Option<Vec<u8>>,
580    field: &str,
581    value: JsValue,
582) -> Result<Vec<u8>, JsValue> {
583    // parse or create new
584    let meta = match &metadata {
585        Some(data) if !data.is_empty() => FloMetadata::from_msgpack(data)
586            .map_err(|e| JsValue::from_str(&format!("Invalid metadata: {}", e)))?,
587        _ => FloMetadata::new(),
588    };
589
590    // modify via serde
591    let mut obj: serde_json::Value = serde_json::to_value(&meta)
592        .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))?;
593
594    // jsvalue to serde
595    let json_value = if value.is_null() || value.is_undefined() {
596        serde_json::Value::Null
597    } else if let Some(s) = value.as_string() {
598        serde_json::Value::String(s)
599    } else if let Some(n) = value.as_f64() {
600        serde_json::json!(n)
601    } else if let Some(b) = value.as_bool() {
602        serde_json::Value::Bool(b)
603    } else {
604        // try json for complex stuff
605        let js_json = js_sys::JSON::stringify(&value)
606            .map_err(|_| JsValue::from_str("Cannot serialize value"))?;
607        serde_json::from_str(&js_json.as_string().unwrap_or_default())
608            .map_err(|e| JsValue::from_str(&format!("Invalid JSON: {}", e)))?
609    };
610
611    // do it
612    if let serde_json::Value::Object(ref mut map) = obj {
613        map.insert(field.to_string(), json_value);
614    } else {
615        return Err(JsValue::from_str("Internal error: metadata not an object"));
616    }
617
618    // back to struct
619    let updated: FloMetadata = serde_json::from_value(obj)
620        .map_err(|e| JsValue::from_str(&format!("Invalid field '{}': {}", field, e)))?;
621
622    updated
623        .to_msgpack()
624        .map_err(|e| JsValue::from_str(&e.to_string()))
625}
626
627/// Get synced lyrics from a flo™ file
628///
629/// # Returns
630/// Array of synced lyrics objects or null if none
631#[wasm_bindgen]
632pub fn get_synced_lyrics(data: &[u8]) -> Result<JsValue, JsValue> {
633    let reader = Reader::new();
634    let file = reader.read(data).map_err(to_js_err)?;
635
636    if file.metadata.is_empty() {
637        return Ok(JsValue::NULL);
638    }
639
640    let meta = FloMetadata::from_msgpack(&file.metadata)
641        .map_err(|e| JsValue::from_str(&format!("Invalid metadata: {}", e)))?;
642
643    if meta.synced_lyrics.is_empty() {
644        return Ok(JsValue::NULL);
645    }
646
647    serde_wasm_bindgen::to_value(&meta.synced_lyrics)
648        .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
649}
650
651/// Get waveform data from a flo™ file for instant visualization
652///
653/// # Returns
654/// WaveformData object or null if not present
655#[wasm_bindgen]
656pub fn get_waveform_data(data: &[u8]) -> Result<JsValue, JsValue> {
657    let reader = Reader::new();
658    let file = reader.read(data).map_err(to_js_err)?;
659
660    if file.metadata.is_empty() {
661        return Ok(JsValue::NULL);
662    }
663
664    let meta = FloMetadata::from_msgpack(&file.metadata)
665        .map_err(|e| JsValue::from_str(&format!("Invalid metadata: {}", e)))?;
666
667    match meta.waveform_data {
668        Some(ref waveform) => serde_wasm_bindgen::to_value(waveform)
669            .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e))),
670        None => Ok(JsValue::NULL),
671    }
672}
673
674/// Get section markers from a flo™ file
675///
676/// # Returns
677/// Array of section markers or null if none
678#[wasm_bindgen]
679pub fn get_section_markers(data: &[u8]) -> Result<JsValue, JsValue> {
680    let reader = Reader::new();
681    let file = reader.read(data).map_err(to_js_err)?;
682
683    if file.metadata.is_empty() {
684        return Ok(JsValue::NULL);
685    }
686
687    let meta = FloMetadata::from_msgpack(&file.metadata)
688        .map_err(|e| JsValue::from_str(&format!("Invalid metadata: {}", e)))?;
689
690    if meta.section_markers.is_empty() {
691        return Ok(JsValue::NULL);
692    }
693
694    serde_wasm_bindgen::to_value(&meta.section_markers)
695        .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
696}
697
698// zero-copy metadata editing
699
700/// update metadata without re-encoding audio
701///
702/// # Arguments
703/// * `flo_data` - Original flo™ file bytes
704/// * `new_metadata` - New MessagePack metadata bytes (use create_metadata_*)
705///
706/// # Returns
707/// New flo™ file with updated metadata
708#[wasm_bindgen]
709pub fn update_metadata(flo_data: &[u8], new_metadata: &[u8]) -> Result<Vec<u8>, JsValue> {
710    update_metadata_bytes(flo_data, new_metadata).map_err(to_js_err)
711}
712
713/// update metadata without re-encoding (native)
714pub fn update_metadata_bytes(flo_data: &[u8], new_metadata: &[u8]) -> FloResult<Vec<u8>> {
715    // basic checks
716    if flo_data.len() < HEADER_SIZE as usize {
717        return Err("File too small to be valid flo".to_string());
718    }
719
720    // check magic
721    if flo_data[0..4] != MAGIC {
722        return Err("Invalid flo file: bad magic".to_string());
723    }
724
725    // read header for chunk sizes
726    let reader = Reader::new();
727    let file = reader.read(flo_data)?;
728
729    // find where metadata starts
730    // layout: magic(4) + header + toc + data + extra + metadata
731    let meta_offset = 4
732        + file.header.header_size as usize
733        + file.header.toc_size as usize
734        + file.header.data_size as usize
735        + file.header.extra_size as usize;
736
737    // copy up to metadata
738    let mut result = Vec::with_capacity(meta_offset + new_metadata.len());
739    result.extend_from_slice(&flo_data[..meta_offset]);
740
741    // add new metadata
742    result.extend_from_slice(new_metadata);
743
744    // fix meta_size in header
745    // header layout: version(2) + flags(2) + sample_rate(4) + ...
746    // meta_size is at offset: 4 + 58 = 62
747    let meta_size_offset = 4 + 2 + 2 + 4 + 1 + 1 + 8 + 1 + 3 + 4 + 8 + 8 + 8 + 8;
748    let new_meta_size = new_metadata.len() as u64;
749    result[meta_size_offset..meta_size_offset + 8].copy_from_slice(&new_meta_size.to_le_bytes());
750
751    Ok(result)
752}
753
754/// Replace just the metadata in a flo™ file (convenience function)
755///
756/// Takes a metadata object directly instead of MessagePack bytes.
757///
758/// # Arguments
759/// * `flo_data` - Original flo™ file bytes
760/// * `metadata` - JavaScript metadata object
761///
762/// # Returns
763/// New flo™ file with updated metadata
764#[wasm_bindgen]
765pub fn set_metadata(flo_data: &[u8], metadata: JsValue) -> Result<Vec<u8>, JsValue> {
766    let new_meta_bytes = create_metadata_from_object(metadata)?;
767    update_metadata(flo_data, &new_meta_bytes)
768}
769
770/// Remove all metadata from a flo™ file
771///
772/// # Arguments
773/// * `flo_data` - Original flo™ file bytes
774///
775/// # Returns
776/// New flo™ file with no metadata
777pub fn strip_metadata_bytes(flo_data: &[u8]) -> FloResult<Vec<u8>> {
778    update_metadata_bytes(flo_data, &[])
779}
780
781/// Remove all metadata from a flo™ file
782///
783/// # Arguments
784/// * `flo_data` - Original flo™ file bytes
785///
786/// # Returns
787/// New flo™ file with no metadata
788#[wasm_bindgen]
789pub fn strip_metadata(flo_data: &[u8]) -> Result<Vec<u8>, JsValue> {
790    strip_metadata_bytes(flo_data).map_err(to_js_err)
791}
792
793/// Get just the metadata bytes from a flo™ file
794///
795/// # Arguments
796/// * `flo_data` - flo™ file bytes
797///
798/// # Returns
799/// Raw MessagePack metadata bytes (or empty array)
800#[wasm_bindgen]
801pub fn get_metadata_bytes(flo_data: &[u8]) -> Result<Vec<u8>, JsValue> {
802    get_metadata_bytes_native(flo_data).map_err(to_js_err)
803}
804
805/// Get just the metadata bytes from a flo™ file
806///
807/// # Arguments
808/// * `flo_data` - flo™ file bytes
809///
810/// # Returns
811/// Raw MessagePack metadata bytes (or empty array)
812pub fn get_metadata_bytes_native(flo_data: &[u8]) -> FloResult<Vec<u8>> {
813    if flo_data.len() < HEADER_SIZE as usize {
814        return Err("File too small".to_string());
815    }
816
817    // just read header for metadata location
818    let reader = Reader::new();
819    let file = reader.read(flo_data)?;
820
821    Ok(file.metadata)
822}
823
824/// does the file have metadata?
825#[wasm_bindgen]
826pub fn has_metadata(flo_data: &[u8]) -> bool {
827    if flo_data.len() < HEADER_SIZE as usize {
828        return false;
829    }
830
831    // fast path, just read meta_size from header
832    let meta_size_offset = 4 + 2 + 2 + 4 + 1 + 1 + 8 + 1 + 3 + 4 + 8 + 8 + 8 + 8;
833    if flo_data.len() < meta_size_offset + 8 {
834        return false;
835    }
836
837    let meta_size = u64::from_le_bytes(
838        flo_data[meta_size_offset..meta_size_offset + 8]
839            .try_into()
840            .unwrap_or([0; 8]),
841    );
842
843    meta_size > 0
844}
845
846// tests
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851
852    #[test]
853    fn test_frame_type_conversion() {
854        assert_eq!(FrameType::from(0), FrameType::Silence);
855        assert_eq!(FrameType::from(8), FrameType::Alpc8);
856        assert_eq!(FrameType::from(254), FrameType::Raw);
857        assert!(FrameType::Alpc8.is_alpc());
858        assert!(!FrameType::Silence.is_alpc());
859        assert_eq!(FrameType::Alpc8.lpc_order(), Some(8));
860    }
861
862    #[test]
863    fn test_version() {
864        assert_eq!(version(), "1.2");
865    }
866
867    #[test]
868    fn test_update_metadata_preserves_audio() {
869        // Create a simple flo file with metadata
870        let samples: Vec<f32> = (0..4410).map(|i| (i as f32 * 0.01).sin() * 0.5).collect();
871        let encoder = Encoder::new(44100, 1, 16);
872        let original_meta = b"original metadata";
873        let flo_data = encoder.encode(&samples, original_meta).unwrap();
874
875        // Update metadata
876        let new_meta = b"new metadata that is longer!";
877        let updated = update_metadata_bytes(&flo_data, new_meta).unwrap();
878
879        // Verify the new file is valid
880        let reader = Reader::new();
881        let file = reader.read(&updated).unwrap();
882
883        // Check metadata was updated
884        assert_eq!(file.metadata, new_meta);
885        assert_eq!(file.header.meta_size, new_meta.len() as u64);
886
887        // Check audio data CRC is unchanged (proves audio wasn't touched)
888        let original_file = reader.read(&flo_data).unwrap();
889        assert_eq!(file.header.data_crc32, original_file.header.data_crc32);
890
891        // Decode and verify audio is identical
892        let decoder = Decoder::new();
893        let original_samples = decoder.decode(&flo_data).unwrap();
894        let updated_samples = decoder.decode(&updated).unwrap();
895        assert_eq!(original_samples, updated_samples);
896    }
897
898    #[test]
899    fn test_strip_metadata() {
900        let samples: Vec<f32> = vec![0.5; 1000];
901        let encoder = Encoder::new(44100, 1, 16);
902        let flo_with_meta = encoder.encode(&samples, b"some metadata here").unwrap();
903
904        // Strip metadata using empty bytes (simulates strip)
905        let stripped = update_metadata_bytes(&flo_with_meta, &[]).unwrap();
906
907        // Verify
908        let reader = Reader::new();
909        let file = reader.read(&stripped).unwrap();
910        assert!(file.metadata.is_empty());
911        assert_eq!(file.header.meta_size, 0);
912
913        // File should be smaller
914        assert!(stripped.len() < flo_with_meta.len());
915    }
916
917    #[test]
918    fn test_has_metadata() {
919        let samples: Vec<f32> = vec![0.5; 1000];
920        let encoder = Encoder::new(44100, 1, 16);
921
922        let with_meta = encoder.encode(&samples, b"metadata").unwrap();
923        let without_meta = encoder.encode(&samples, &[]).unwrap();
924
925        assert!(has_metadata(&with_meta));
926        assert!(!has_metadata(&without_meta));
927    }
928}