Skip to main content

runmat_runtime/builtins/io/audio/
mod.rs

1//! Audio file metadata and sample decoding builtins.
2
3use std::io::{Read, Seek, SeekFrom};
4use std::path::{Path, PathBuf};
5
6use runmat_builtins::{
7    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
8    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
9    NumericDType, StructValue, Tensor, Value,
10};
11use runmat_filesystem as fs;
12use runmat_macros::runtime_builtin;
13
14use crate::builtins::common::fs::expand_user_path;
15use crate::builtins::common::spec::{
16    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
17    ReductionNaN, ResidencyPolicy, ShapeRequirements,
18};
19use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
20
21const AUDIOINFO_BUILTIN_NAME: &str = "audioinfo";
22const AUDIOREAD_BUILTIN_NAME: &str = "audioread";
23const MAX_AUDIOINFO_PREFIX_BYTES: u64 = 1024 * 1024;
24const MAX_AUDIOINFO_TAIL_BYTES: u64 = 64 * 1024;
25
26const AUDIOINFO_OUTPUTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
27    name: "info",
28    ty: BuiltinParamType::Any,
29    arity: BuiltinParamArity::Required,
30    default: None,
31    description: "Audio metadata structure.",
32}];
33const AUDIOINFO_INPUTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
34    name: "filename",
35    ty: BuiltinParamType::StringScalar,
36    arity: BuiltinParamArity::Required,
37    default: None,
38    description: "Audio file path.",
39}];
40const AUDIOINFO_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
41    label: "info = audioinfo(filename)",
42    inputs: &AUDIOINFO_INPUTS,
43    outputs: &AUDIOINFO_OUTPUTS,
44}];
45
46const AUDIOINFO_ERROR_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
47    code: "RM.AUDIOINFO.ARGUMENT",
48    identifier: Some("RunMat:audioinfo:InvalidArgument"),
49    when: "Filename is missing or cannot be interpreted as a scalar path.",
50    message: "audioinfo: invalid filename",
51};
52const AUDIOINFO_ERROR_IO: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
53    code: "RM.AUDIOINFO.IO",
54    identifier: Some("RunMat:audioinfo:Io"),
55    when: "The audio file cannot be read.",
56    message: "audioinfo: unable to read file",
57};
58const AUDIOINFO_ERROR_FORMAT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
59    code: "RM.AUDIOINFO.FORMAT",
60    identifier: Some("RunMat:audioinfo:UnsupportedFormat"),
61    when: "The file is not a supported audio container or has malformed metadata.",
62    message: "audioinfo: unsupported or invalid audio file",
63};
64const AUDIOINFO_ERRORS: [BuiltinErrorDescriptor; 3] = [
65    AUDIOINFO_ERROR_ARGUMENT,
66    AUDIOINFO_ERROR_IO,
67    AUDIOINFO_ERROR_FORMAT,
68];
69
70pub const AUDIOINFO_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
71    signatures: &AUDIOINFO_SIGNATURES,
72    output_mode: BuiltinOutputMode::Fixed,
73    completion_policy: BuiltinCompletionPolicy::Public,
74    errors: &AUDIOINFO_ERRORS,
75};
76
77#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::audio")]
78pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
79    name: "audioinfo",
80    op_kind: GpuOpKind::Custom("io-audioinfo"),
81    supported_precisions: &[],
82    broadcast: BroadcastSemantics::None,
83    provider_hooks: &[],
84    constant_strategy: ConstantStrategy::InlineLiteral,
85    residency: ResidencyPolicy::GatherImmediately,
86    nan_mode: ReductionNaN::Include,
87    two_pass_threshold: None,
88    workgroup_size: None,
89    accepts_nan_mode: false,
90    notes: "Runs on the host; file metadata inspection is not an acceleration operation.",
91};
92
93#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::audio")]
94pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
95    name: "audioinfo",
96    shape: ShapeRequirements::Any,
97    constant_strategy: ConstantStrategy::InlineLiteral,
98    elementwise: None,
99    reduction: None,
100    emits_nan: false,
101    notes: "Not eligible for fusion; performs host-side file I/O.",
102};
103
104const AUDIOREAD_OUTPUTS: [BuiltinParamDescriptor; 2] = [
105    BuiltinParamDescriptor {
106        name: "y",
107        ty: BuiltinParamType::NumericArray,
108        arity: BuiltinParamArity::Required,
109        default: None,
110        description: "Decoded samples as an N-by-C matrix.",
111    },
112    BuiltinParamDescriptor {
113        name: "Fs",
114        ty: BuiltinParamType::NumericScalar,
115        arity: BuiltinParamArity::Optional,
116        default: None,
117        description: "Sample rate in Hz.",
118    },
119];
120const AUDIOREAD_OUTPUTS_SAMPLES: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
121    name: "y",
122    ty: BuiltinParamType::NumericArray,
123    arity: BuiltinParamArity::Required,
124    default: None,
125    description: "Decoded samples as an N-by-C matrix.",
126}];
127const AUDIOREAD_INPUTS_FILENAME: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
128    name: "filename",
129    ty: BuiltinParamType::StringScalar,
130    arity: BuiltinParamArity::Required,
131    default: None,
132    description: "Audio file path.",
133}];
134const AUDIOREAD_INPUTS_RANGE: [BuiltinParamDescriptor; 2] = [
135    BuiltinParamDescriptor {
136        name: "filename",
137        ty: BuiltinParamType::StringScalar,
138        arity: BuiltinParamArity::Required,
139        default: None,
140        description: "Audio file path.",
141    },
142    BuiltinParamDescriptor {
143        name: "samples",
144        ty: BuiltinParamType::NumericArray,
145        arity: BuiltinParamArity::Optional,
146        default: None,
147        description: "One-based inclusive frame range [first last].",
148    },
149];
150const AUDIOREAD_INPUTS_RANGE_NATIVE: [BuiltinParamDescriptor; 3] = [
151    BuiltinParamDescriptor {
152        name: "filename",
153        ty: BuiltinParamType::StringScalar,
154        arity: BuiltinParamArity::Required,
155        default: None,
156        description: "Audio file path.",
157    },
158    BuiltinParamDescriptor {
159        name: "samples",
160        ty: BuiltinParamType::NumericArray,
161        arity: BuiltinParamArity::Optional,
162        default: None,
163        description: "One-based inclusive frame range [first last].",
164    },
165    BuiltinParamDescriptor {
166        name: "datatype",
167        ty: BuiltinParamType::StringScalar,
168        arity: BuiltinParamArity::Optional,
169        default: Some("\"double\""),
170        description: "Output class; \"native\" preserves representable source classes.",
171    },
172];
173const AUDIOREAD_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
174    BuiltinSignatureDescriptor {
175        label: "y = audioread(filename)",
176        inputs: &AUDIOREAD_INPUTS_FILENAME,
177        outputs: &AUDIOREAD_OUTPUTS_SAMPLES,
178    },
179    BuiltinSignatureDescriptor {
180        label: "[y, Fs] = audioread(filename)",
181        inputs: &AUDIOREAD_INPUTS_FILENAME,
182        outputs: &AUDIOREAD_OUTPUTS,
183    },
184    BuiltinSignatureDescriptor {
185        label: "y = audioread(filename, samples)",
186        inputs: &AUDIOREAD_INPUTS_RANGE,
187        outputs: &AUDIOREAD_OUTPUTS_SAMPLES,
188    },
189    BuiltinSignatureDescriptor {
190        label: "[y, Fs] = audioread(filename, samples, datatype)",
191        inputs: &AUDIOREAD_INPUTS_RANGE_NATIVE,
192        outputs: &AUDIOREAD_OUTPUTS,
193    },
194];
195const AUDIOREAD_ERROR_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
196    code: "RM.AUDIOREAD.ARGUMENT",
197    identifier: Some("RunMat:audioread:InvalidArgument"),
198    when: "Filename, sample range, datatype, or output count is invalid.",
199    message: "audioread: invalid argument",
200};
201const AUDIOREAD_ERROR_IO: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
202    code: "RM.AUDIOREAD.IO",
203    identifier: Some("RunMat:audioread:Io"),
204    when: "The audio file cannot be read.",
205    message: "audioread: unable to read file",
206};
207const AUDIOREAD_ERROR_FORMAT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
208    code: "RM.AUDIOREAD.FORMAT",
209    identifier: Some("RunMat:audioread:UnsupportedFormat"),
210    when: "The file is not a supported audio container, uses unsupported audio coding, or is malformed.",
211    message: "audioread: unsupported or invalid audio file",
212};
213const AUDIOREAD_ERRORS: [BuiltinErrorDescriptor; 3] = [
214    AUDIOREAD_ERROR_ARGUMENT,
215    AUDIOREAD_ERROR_IO,
216    AUDIOREAD_ERROR_FORMAT,
217];
218
219pub const AUDIOREAD_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
220    signatures: &AUDIOREAD_SIGNATURES,
221    output_mode: BuiltinOutputMode::ByRequestedOutputCount,
222    completion_policy: BuiltinCompletionPolicy::Public,
223    errors: &AUDIOREAD_ERRORS,
224};
225
226#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::audio")]
227pub const AUDIOREAD_GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
228    name: "audioread",
229    op_kind: GpuOpKind::Custom("io-audioread"),
230    supported_precisions: &[],
231    broadcast: BroadcastSemantics::None,
232    provider_hooks: &[],
233    constant_strategy: ConstantStrategy::InlineLiteral,
234    residency: ResidencyPolicy::GatherImmediately,
235    nan_mode: ReductionNaN::Include,
236    two_pass_threshold: None,
237    workgroup_size: None,
238    accepts_nan_mode: false,
239    notes: "Runs on the host; file I/O and audio decoding are not acceleration operations.",
240};
241
242#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::audio")]
243pub const AUDIOREAD_FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
244    name: "audioread",
245    shape: ShapeRequirements::Any,
246    constant_strategy: ConstantStrategy::InlineLiteral,
247    elementwise: None,
248    reduction: None,
249    emits_nan: false,
250    notes: "Not eligible for fusion; performs host-side file I/O and decoding.",
251};
252
253fn audioinfo_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
254    audioinfo_error_with(error, error.message)
255}
256
257fn audioinfo_error_with(
258    error: &'static BuiltinErrorDescriptor,
259    message: impl Into<String>,
260) -> RuntimeError {
261    let mut builder = build_runtime_error(message).with_builtin(AUDIOINFO_BUILTIN_NAME);
262    if let Some(identifier) = error.identifier {
263        builder = builder.with_identifier(identifier);
264    }
265    builder.build()
266}
267
268fn audioinfo_error_with_source<E>(
269    error: &'static BuiltinErrorDescriptor,
270    message: impl Into<String>,
271    source: E,
272) -> RuntimeError
273where
274    E: std::error::Error + Send + Sync + 'static,
275{
276    let mut builder = build_runtime_error(message)
277        .with_builtin(AUDIOINFO_BUILTIN_NAME)
278        .with_source(source);
279    if let Some(identifier) = error.identifier {
280        builder = builder.with_identifier(identifier);
281    }
282    builder.build()
283}
284
285fn audioread_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
286    audioread_error_with(error, error.message)
287}
288
289fn audioread_error_with(
290    error: &'static BuiltinErrorDescriptor,
291    message: impl Into<String>,
292) -> RuntimeError {
293    let mut builder = build_runtime_error(message).with_builtin(AUDIOREAD_BUILTIN_NAME);
294    if let Some(identifier) = error.identifier {
295        builder = builder.with_identifier(identifier);
296    }
297    builder.build()
298}
299
300fn audioread_error_with_source<E>(
301    error: &'static BuiltinErrorDescriptor,
302    message: impl Into<String>,
303    source: E,
304) -> RuntimeError
305where
306    E: std::error::Error + Send + Sync + 'static,
307{
308    let mut builder = build_runtime_error(message)
309        .with_builtin(AUDIOREAD_BUILTIN_NAME)
310        .with_source(source);
311    if let Some(identifier) = error.identifier {
312        builder = builder.with_identifier(identifier);
313    }
314    builder.build()
315}
316
317fn map_audioinfo_control_flow(err: RuntimeError) -> RuntimeError {
318    let identifier = err.identifier().map(|value| value.to_string());
319    let message = err.message().to_string();
320    let mut builder = build_runtime_error(message)
321        .with_builtin(AUDIOINFO_BUILTIN_NAME)
322        .with_source(err);
323    if let Some(identifier) = identifier {
324        builder = builder.with_identifier(identifier);
325    }
326    builder.build()
327}
328
329fn map_audioread_control_flow(err: RuntimeError) -> RuntimeError {
330    let identifier = err.identifier().map(|value| value.to_string());
331    let message = err.message().to_string();
332    let mut builder = build_runtime_error(message)
333        .with_builtin(AUDIOREAD_BUILTIN_NAME)
334        .with_source(err);
335    if let Some(identifier) = identifier {
336        builder = builder.with_identifier(identifier);
337    }
338    builder.build()
339}
340
341#[runtime_builtin(
342    name = "audioinfo",
343    category = "io/audio",
344    summary = "Read metadata from an audio file.",
345    keywords = "audioinfo,audio,wav,flac,aiff,mp3,ogg,metadata,sample rate,channels",
346    accel = "cpu",
347    type_resolver(crate::builtins::io::type_resolvers::struct_type),
348    descriptor(crate::builtins::io::audio::AUDIOINFO_DESCRIPTOR),
349    builtin_path = "crate::builtins::io::audio"
350)]
351async fn audioinfo_builtin(filename: Value) -> BuiltinResult<Value> {
352    let filename = gather_if_needed_async(&filename)
353        .await
354        .map_err(map_audioinfo_control_flow)?;
355    let path = resolve_audioinfo_path(&filename)?;
356    let scan = read_audioinfo_scan(&path).await?;
357    let metadata = AudioMetadata::parse(&scan).map_err(|message| {
358        audioinfo_error_with(&AUDIOINFO_ERROR_FORMAT, format!("audioinfo: {message}"))
359    })?;
360    Ok(Value::Struct(
361        metadata.into_struct(&path, scan.file_size as f64),
362    ))
363}
364
365struct AudioInfoScan {
366    prefix: Vec<u8>,
367    tail: Option<Vec<u8>>,
368    file_size: u64,
369}
370
371async fn read_audioinfo_scan(path: &Path) -> BuiltinResult<AudioInfoScan> {
372    let mut file = fs::File::open_async(path).await.map_err(|err| {
373        audioinfo_error_with_source(
374            &AUDIOINFO_ERROR_IO,
375            format!("audioinfo: unable to read \"{}\" ({err})", path.display()),
376            err,
377        )
378    })?;
379    let metadata = match file.metadata_async().await {
380        Ok(metadata) => metadata,
381        Err(err) if err.kind() == std::io::ErrorKind::Unsupported => {
382            fs::metadata_async(path).await.map_err(|err| {
383                audioinfo_error_with_source(
384                    &AUDIOINFO_ERROR_IO,
385                    format!(
386                        "audioinfo: unable to inspect \"{}\" after opening ({err})",
387                        path.display()
388                    ),
389                    err,
390                )
391            })?
392        }
393        Err(err) => {
394            return Err(audioinfo_error_with_source(
395                &AUDIOINFO_ERROR_IO,
396                format!(
397                    "audioinfo: unable to inspect opened file \"{}\" ({err})",
398                    path.display()
399                ),
400                err,
401            ));
402        }
403    };
404    let file_size = metadata.len();
405    let prefix_len = file_size.min(MAX_AUDIOINFO_PREFIX_BYTES);
406    let mut bytes = Vec::new();
407    file.by_ref()
408        .take(prefix_len)
409        .read_to_end(&mut bytes)
410        .map_err(|err| {
411            audioinfo_error_with_source(
412                &AUDIOINFO_ERROR_IO,
413                format!("audioinfo: unable to read \"{}\" ({err})", path.display()),
414                err,
415            )
416        })?;
417    let tail = if bytes.starts_with(b"OggS") && file_size > prefix_len {
418        let tail_len = file_size.min(MAX_AUDIOINFO_TAIL_BYTES);
419        let tail_start = file_size.saturating_sub(tail_len);
420        match file.seek(SeekFrom::Start(tail_start)) {
421            Ok(_) => {
422                let mut tail = Vec::new();
423                match file.take(tail_len).read_to_end(&mut tail) {
424                    Ok(_) => Some(tail),
425                    Err(_) => None,
426                }
427            }
428            Err(_) => None,
429        }
430    } else {
431        None
432    };
433    Ok(AudioInfoScan {
434        prefix: bytes,
435        tail,
436        file_size,
437    })
438}
439
440#[runtime_builtin(
441    name = "audioread",
442    category = "io/audio",
443    summary = "Read audio samples from a file.",
444    keywords = "audioread,audio,wav,rf64,pcm,float,sample rate,channels",
445    accel = "cpu",
446    type_resolver(crate::builtins::io::type_resolvers::audioread_type),
447    descriptor(crate::builtins::io::audio::AUDIOREAD_DESCRIPTOR),
448    builtin_path = "crate::builtins::io::audio"
449)]
450async fn audioread_builtin(filename: Value, args: Vec<Value>) -> BuiltinResult<Value> {
451    let filename = gather_if_needed_async(&filename)
452        .await
453        .map_err(map_audioread_control_flow)?;
454    let mut gathered_args = Vec::with_capacity(args.len());
455    for arg in args {
456        gathered_args.push(
457            gather_if_needed_async(&arg)
458                .await
459                .map_err(map_audioread_control_flow)?,
460        );
461    }
462    let options = parse_audioread_options(&gathered_args)?;
463    let path = resolve_audioread_path(&filename)?;
464    let bytes = fs::read_async(&path).await.map_err(|err| {
465        audioread_error_with_source(
466            &AUDIOREAD_ERROR_IO,
467            format!("audioread: unable to read \"{}\" ({err})", path.display()),
468            err,
469        )
470    })?;
471    let decoded = decode_audio_samples(&bytes, options).map_err(|message| {
472        audioread_error_with(&AUDIOREAD_ERROR_FORMAT, format!("audioread: {message}"))
473    })?;
474
475    match crate::output_count::current_output_count() {
476        None => Ok(Value::Tensor(decoded.samples)),
477        Some(0) => Ok(Value::OutputList(Vec::new())),
478        Some(1) => Ok(Value::OutputList(vec![Value::Tensor(decoded.samples)])),
479        Some(2) => Ok(Value::OutputList(vec![
480            Value::Tensor(decoded.samples),
481            Value::Num(decoded.sample_rate),
482        ])),
483        Some(_) => Err(audioread_error_with(
484            &AUDIOREAD_ERROR_ARGUMENT,
485            "audioread: too many output arguments",
486        )),
487    }
488}
489
490#[derive(Debug, Clone, PartialEq)]
491struct AudioMetadata {
492    format: &'static str,
493    compression_method: String,
494    num_channels: u16,
495    sample_rate: f64,
496    total_samples: Option<u64>,
497    bits_per_sample: Option<u16>,
498    bit_rate: Option<f64>,
499}
500
501impl AudioMetadata {
502    fn parse(scan: &AudioInfoScan) -> Result<Self, String> {
503        let bytes = scan.prefix.as_slice();
504        if bytes.len() < 4 {
505            return Err("file is too small to contain audio metadata".to_string());
506        }
507        if bytes.starts_with(b"RIFF") || bytes.starts_with(b"RF64") {
508            return parse_wave(bytes);
509        }
510        if bytes.starts_with(b"fLaC") {
511            return parse_flac(bytes);
512        }
513        if bytes.starts_with(b"FORM") {
514            return parse_aiff(bytes);
515        }
516        if bytes.starts_with(b"OggS") {
517            return parse_ogg_vorbis(scan);
518        }
519        if let Some(mp3) = parse_mp3(bytes, scan.file_size) {
520            return Ok(mp3);
521        }
522        Err("unsupported audio format".to_string())
523    }
524
525    fn into_struct(self, path: &Path, file_size: f64) -> StructValue {
526        let total_samples = self.total_samples.map(|v| v as f64).unwrap_or(f64::NAN);
527        let duration = self
528            .total_samples
529            .map(|samples| samples as f64 / self.sample_rate)
530            .unwrap_or_else(|| {
531                self.bit_rate
532                    .filter(|rate| *rate > 0.0)
533                    .map(|rate| file_size * 8.0 / rate)
534                    .unwrap_or(f64::NAN)
535            });
536        let mut out = StructValue::new();
537        out.insert(
538            "Filename",
539            Value::String(path.to_string_lossy().into_owned()),
540        );
541        out.insert(
542            "CompressionMethod",
543            Value::String(self.compression_method.clone()),
544        );
545        out.insert("NumChannels", Value::Num(self.num_channels as f64));
546        out.insert("SampleRate", Value::Num(self.sample_rate));
547        out.insert("TotalSamples", Value::Num(total_samples));
548        out.insert("Duration", Value::Num(duration));
549        out.insert(
550            "BitsPerSample",
551            Value::Num(self.bits_per_sample.map(|v| v as f64).unwrap_or(f64::NAN)),
552        );
553        out.insert("BitRate", Value::Num(self.bit_rate.unwrap_or(f64::NAN)));
554        out.insert("FileSize", Value::Num(file_size));
555        out.insert("Format", Value::String(self.format.to_string()));
556        out
557    }
558}
559
560fn parse_wave(bytes: &[u8]) -> Result<AudioMetadata, String> {
561    let (format, data_bytes) = parse_wave_metadata(bytes)?;
562    let total_samples = if format.block_align > 0 {
563        Some(data_bytes / format.block_align as u64)
564    } else {
565        None
566    };
567    Ok(AudioMetadata {
568        format: "WAV",
569        compression_method: wave_compression_name(format.effective_format_tag()).to_string(),
570        num_channels: format.channels,
571        sample_rate: format.sample_rate as f64,
572        total_samples,
573        bits_per_sample: Some(
574            format
575                .valid_bits_per_sample
576                .unwrap_or(format.bits_per_sample),
577        ),
578        bit_rate: Some(format.byte_rate as f64 * 8.0),
579    })
580}
581
582#[derive(Debug, Clone, Copy, PartialEq, Eq)]
583struct AudioreadOptions {
584    range: Option<(usize, usize)>,
585    native: bool,
586}
587
588#[derive(Debug, Clone)]
589struct DecodedAudio {
590    samples: Tensor,
591    sample_rate: f64,
592}
593
594fn parse_audioread_options(args: &[Value]) -> BuiltinResult<AudioreadOptions> {
595    let mut range = None;
596    let mut native = false;
597    match args {
598        [] => {}
599        [single] => {
600            if let Some(text) = scalar_text(single) {
601                native = parse_audioread_datatype(&text)?;
602            } else {
603                range = Some(parse_sample_range(single)?);
604            }
605        }
606        [samples, datatype] => {
607            range = Some(parse_sample_range(samples)?);
608            let text = scalar_text(datatype).ok_or_else(|| {
609                audioread_error_with(
610                    &AUDIOREAD_ERROR_ARGUMENT,
611                    "audioread: datatype must be \"native\" or \"double\"",
612                )
613            })?;
614            native = parse_audioread_datatype(&text)?;
615        }
616        _ => {
617            return Err(audioread_error_with(
618                &AUDIOREAD_ERROR_ARGUMENT,
619                "audioread: too many input arguments",
620            ));
621        }
622    }
623    Ok(AudioreadOptions { range, native })
624}
625
626fn parse_audioread_datatype(value: &str) -> BuiltinResult<bool> {
627    match value.trim().to_ascii_lowercase().as_str() {
628        "double" => Ok(false),
629        "native" => Ok(true),
630        _ => Err(audioread_error_with(
631            &AUDIOREAD_ERROR_ARGUMENT,
632            "audioread: datatype must be \"native\" or \"double\"",
633        )),
634    }
635}
636
637fn parse_sample_range(value: &Value) -> BuiltinResult<(usize, usize)> {
638    let data: Vec<f64> = match value {
639        Value::Tensor(t) => t.data.clone(),
640        Value::Num(n) => vec![*n],
641        Value::Int(i) => vec![i.to_f64()],
642        _ => {
643            return Err(audioread_error_with(
644                &AUDIOREAD_ERROR_ARGUMENT,
645                "audioread: sample range must be a two-element numeric vector",
646            ));
647        }
648    };
649    if data.len() != 2 {
650        return Err(audioread_error_with(
651            &AUDIOREAD_ERROR_ARGUMENT,
652            "audioread: sample range must be [first last]",
653        ));
654    }
655    let first = parse_positive_integer(data[0], "first sample")?;
656    let last = parse_positive_integer(data[1], "last sample")?;
657    if first > last {
658        return Err(audioread_error_with(
659            &AUDIOREAD_ERROR_ARGUMENT,
660            "audioread: sample range first sample must be less than or equal to last sample",
661        ));
662    }
663    Ok((first, last))
664}
665
666fn parse_positive_integer(value: f64, label: &str) -> BuiltinResult<usize> {
667    if !value.is_finite() || value < 1.0 || value.fract() != 0.0 {
668        return Err(audioread_error_with(
669            &AUDIOREAD_ERROR_ARGUMENT,
670            format!("audioread: {label} must be a positive integer"),
671        ));
672    }
673    if value > usize::MAX as f64 {
674        return Err(audioread_error_with(
675            &AUDIOREAD_ERROR_ARGUMENT,
676            format!("audioread: {label} is too large"),
677        ));
678    }
679    Ok(value as usize)
680}
681
682fn scalar_text(value: &Value) -> Option<String> {
683    match value {
684        Value::String(s) => Some(s.clone()),
685        Value::CharArray(ca) if ca.rows == 1 => Some(ca.data.iter().collect()),
686        Value::StringArray(sa) if sa.data.len() == 1 => Some(sa.data[0].clone()),
687        _ => None,
688    }
689}
690
691fn decode_audio_samples(bytes: &[u8], options: AudioreadOptions) -> Result<DecodedAudio, String> {
692    if bytes.starts_with(b"RIFF") || bytes.starts_with(b"RF64") {
693        decode_wave_samples(bytes, options)
694    } else if bytes.starts_with(b"fLaC")
695        || bytes.starts_with(b"FORM")
696        || bytes.starts_with(b"OggS")
697        || parse_mp3(bytes, bytes.len() as u64).is_some()
698    {
699        Err("sample decoding is currently implemented for WAV/RF64 PCM and IEEE-float audio; compressed containers are metadata-only".to_string())
700    } else {
701        Err("unsupported audio format".to_string())
702    }
703}
704
705fn decode_wave_samples(bytes: &[u8], options: AudioreadOptions) -> Result<DecodedAudio, String> {
706    let parsed = parse_wave_container(bytes)?;
707    let fmt = parsed.format;
708    let channels = fmt.channels as usize;
709    let block_align = fmt.block_align as usize;
710    let bytes_per_channel = usize::from(fmt.bits_per_sample.div_ceil(8));
711    if channels == 0 || block_align == 0 || bytes_per_channel == 0 {
712        return Err("WAVE fmt chunk has invalid sample layout".to_string());
713    }
714    let minimum_block_align = channels
715        .checked_mul(bytes_per_channel)
716        .ok_or_else(|| "WAVE channel layout overflows platform limits".to_string())?;
717    if block_align < minimum_block_align {
718        return Err("WAVE block alignment is smaller than the channel sample width".to_string());
719    }
720    if parsed.data_len % block_align != 0 {
721        return Err("WAVE data chunk is not aligned to whole sample frames".to_string());
722    }
723    let total_frames = parsed.data_len / block_align;
724    let (start_frame, end_frame_exclusive) = match options.range {
725        Some((first, last)) => {
726            if last > total_frames {
727                return Err("sample range exceeds the available audio frames".to_string());
728            }
729            (first - 1, last)
730        }
731        None => (0, total_frames),
732    };
733    let frame_count = end_frame_exclusive - start_frame;
734    let sample_count = frame_count
735        .checked_mul(channels)
736        .ok_or_else(|| "decoded audio dimensions overflow platform limits".to_string())?;
737    let mut out = Vec::with_capacity(sample_count);
738    let data = &bytes[parsed.data_offset..parsed.data_offset + parsed.data_len];
739    let native_dtype = if options.native {
740        native_wave_dtype(fmt)
741    } else {
742        NumericDType::F64
743    };
744    for channel in 0..channels {
745        for frame in start_frame..end_frame_exclusive {
746            let sample_offset = frame
747                .checked_mul(block_align)
748                .and_then(|base| base.checked_add(channel * bytes_per_channel))
749                .ok_or_else(|| "sample offset overflows platform limits".to_string())?;
750            let sample_bytes = &data[sample_offset..sample_offset + bytes_per_channel];
751            let value = decode_wave_sample(sample_bytes, fmt, options.native)?;
752            out.push(value);
753        }
754    }
755    let samples = Tensor::new_with_dtype(out, vec![frame_count, channels], native_dtype)
756        .map_err(|err| format!("decoded audio tensor shape is invalid: {err}"))?;
757    Ok(DecodedAudio {
758        samples,
759        sample_rate: fmt.sample_rate as f64,
760    })
761}
762
763fn native_wave_dtype(fmt: WaveFormat) -> NumericDType {
764    match (fmt.effective_format_tag(), fmt.effective_bits_per_sample()) {
765        (0x0001, 8) => NumericDType::U8,
766        (0x0003, 32) => NumericDType::F32,
767        _ => NumericDType::F64,
768    }
769}
770
771fn decode_wave_sample(bytes: &[u8], fmt: WaveFormat, native: bool) -> Result<f64, String> {
772    match (fmt.effective_format_tag(), fmt.effective_bits_per_sample()) {
773        (0x0001, 8) => {
774            let raw = bytes[0] as f64;
775            Ok(if native { raw } else { (raw - 128.0) / 128.0 })
776        }
777        (0x0001, 16) => {
778            let raw = i16::from_le_bytes([bytes[0], bytes[1]]);
779            Ok(if native {
780                raw as f64
781            } else {
782                raw as f64 / 32768.0
783            })
784        }
785        (0x0001, 24) => {
786            let raw = sign_extend_24(bytes);
787            Ok(if native {
788                raw as f64
789            } else {
790                raw as f64 / 8_388_608.0
791            })
792        }
793        (0x0001, 32) => {
794            let raw = i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
795            Ok(if native {
796                raw as f64
797            } else {
798                raw as f64 / 2_147_483_648.0
799            })
800        }
801        (0x0003, 32) => Ok(f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as f64),
802        (0x0003, 64) => Ok(f64::from_le_bytes([
803            bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
804        ])),
805        (tag, bits) => Err(format!(
806            "unsupported WAVE sample encoding tag 0x{tag:04x} with {bits} bits per sample"
807        )),
808    }
809}
810
811fn sign_extend_24(bytes: &[u8]) -> i32 {
812    let mut value = ((bytes[2] as i32) << 16) | ((bytes[1] as i32) << 8) | bytes[0] as i32;
813    if value & 0x0080_0000 != 0 {
814        value |= !0x00ff_ffff;
815    }
816    value
817}
818
819#[derive(Debug, Clone, Copy)]
820struct WaveContainer {
821    format: WaveFormat,
822    data_offset: usize,
823    data_len: usize,
824}
825
826fn parse_wave_metadata(bytes: &[u8]) -> Result<(WaveFormat, u64), String> {
827    if bytes.len() < 12 || &bytes[8..12] != b"WAVE" {
828        return Err("RIFF/RF64 file is not a WAVE container".to_string());
829    }
830    let is_rf64 = bytes.starts_with(b"RF64");
831    let mut pos = 12usize;
832    let mut fmt: Option<WaveFormat> = None;
833    let mut data_bytes: Option<u64> = None;
834    let mut rf64_data_size: Option<u64> = None;
835    while pos + 8 <= bytes.len() {
836        let id = &bytes[pos..pos + 4];
837        let declared_size = read_u32_le(bytes, pos + 4)?;
838        pos += 8;
839        if is_rf64 && id == b"data" && declared_size == u32::MAX {
840            let data_size = rf64_data_size.ok_or_else(|| {
841                "RF64 data chunk uses sentinel size without ds64 metadata".to_string()
842            })?;
843            data_bytes = Some(data_size);
844            break;
845        }
846        let size = declared_size as usize;
847        match id {
848            b"ds64" if is_rf64 => {
849                let end = pos
850                    .checked_add(size)
851                    .ok_or_else(|| "WAVE chunk size overflows address space".to_string())?;
852                if end > bytes.len() {
853                    return Err("WAVE chunk extends past end of file".to_string());
854                }
855                if size < 24 {
856                    return Err("RF64 ds64 chunk is too short".to_string());
857                }
858                rf64_data_size = Some(read_u64_le(bytes, pos + 8)?);
859            }
860            b"fmt " => {
861                let end = pos
862                    .checked_add(size)
863                    .ok_or_else(|| "WAVE chunk size overflows address space".to_string())?;
864                if end > bytes.len() {
865                    return Err("WAVE chunk extends past end of file".to_string());
866                }
867                fmt = Some(parse_wave_fmt(&bytes[pos..end])?);
868            }
869            b"data" => {
870                data_bytes = Some(size as u64);
871                break;
872            }
873            _ => {
874                let end = pos
875                    .checked_add(size)
876                    .ok_or_else(|| "WAVE chunk size overflows address space".to_string())?;
877                if end > bytes.len() {
878                    return Err("WAVE chunk extends past end of file".to_string());
879                }
880            }
881        }
882        let end = pos
883            .checked_add(size)
884            .ok_or_else(|| "WAVE chunk size overflows address space".to_string())?;
885        pos = end
886            .checked_add(size % 2)
887            .ok_or_else(|| "WAVE chunk padding overflows address space".to_string())?;
888    }
889
890    let fmt = fmt.ok_or_else(|| "WAVE file is missing a fmt chunk".to_string())?;
891    let data_bytes = data_bytes.ok_or_else(|| "WAVE file is missing a data chunk".to_string())?;
892    Ok((fmt, data_bytes))
893}
894
895fn parse_wave_container(bytes: &[u8]) -> Result<WaveContainer, String> {
896    if bytes.len() < 12 || &bytes[8..12] != b"WAVE" {
897        return Err("RIFF/RF64 file is not a WAVE container".to_string());
898    }
899    let is_rf64 = bytes.starts_with(b"RF64");
900    let mut pos = 12usize;
901    let mut fmt: Option<WaveFormat> = None;
902    let mut data: Option<(usize, usize)> = None;
903    let mut rf64_data_size: Option<u64> = None;
904    while pos.checked_add(8).is_some_and(|end| end <= bytes.len()) {
905        let id_end = pos
906            .checked_add(4)
907            .ok_or_else(|| "WAVE chunk offset overflows platform limits".to_string())?;
908        let size_offset = pos
909            .checked_add(4)
910            .ok_or_else(|| "WAVE chunk offset overflows platform limits".to_string())?;
911        let id = &bytes[pos..id_end];
912        let declared_size = read_u32_le(bytes, size_offset)?;
913        pos = pos
914            .checked_add(8)
915            .ok_or_else(|| "WAVE chunk offset overflows platform limits".to_string())?;
916        let size = if is_rf64 && id == b"data" && declared_size == u32::MAX {
917            let actual = rf64_data_size.ok_or_else(|| {
918                "RF64 data chunk uses sentinel size without ds64 metadata".to_string()
919            })?;
920            usize::try_from(actual)
921                .map_err(|_| "RF64 data chunk is too large for this platform".to_string())?
922        } else {
923            declared_size as usize
924        };
925        let chunk_end = pos
926            .checked_add(size)
927            .ok_or_else(|| "WAVE chunk size overflows platform limits".to_string())?;
928        if chunk_end > bytes.len() {
929            return Err("WAVE chunk extends past end of file".to_string());
930        }
931        match id {
932            b"ds64" if is_rf64 => {
933                if size < 24 {
934                    return Err("RF64 ds64 chunk is too short".to_string());
935                }
936                rf64_data_size = Some(read_u64_le(bytes, pos + 8)?);
937            }
938            b"fmt " => fmt = Some(parse_wave_fmt(&bytes[pos..chunk_end])?),
939            b"data" => data = Some((pos, size)),
940            _ => {}
941        }
942        let padded_size = size
943            .checked_add(size % 2)
944            .ok_or_else(|| "WAVE padded chunk size overflows platform limits".to_string())?;
945        pos = pos
946            .checked_add(padded_size)
947            .ok_or_else(|| "WAVE chunk offset overflows platform limits".to_string())?;
948    }
949
950    let fmt = fmt.ok_or_else(|| "WAVE file is missing a fmt chunk".to_string())?;
951    let (data_offset, data_len) =
952        data.ok_or_else(|| "WAVE file is missing a data chunk".to_string())?;
953    Ok(WaveContainer {
954        format: fmt,
955        data_offset,
956        data_len,
957    })
958}
959
960#[derive(Debug, Clone, Copy)]
961struct WaveFormat {
962    format_tag: u16,
963    subformat_tag: Option<u16>,
964    channels: u16,
965    sample_rate: u32,
966    byte_rate: u32,
967    block_align: u16,
968    bits_per_sample: u16,
969    valid_bits_per_sample: Option<u16>,
970}
971
972impl WaveFormat {
973    fn effective_format_tag(self) -> u16 {
974        self.subformat_tag.unwrap_or(self.format_tag)
975    }
976
977    fn effective_bits_per_sample(self) -> u16 {
978        self.valid_bits_per_sample.unwrap_or(self.bits_per_sample)
979    }
980}
981
982fn parse_wave_fmt(chunk: &[u8]) -> Result<WaveFormat, String> {
983    if chunk.len() < 16 {
984        return Err("WAVE fmt chunk is too short".to_string());
985    }
986    let format_tag = u16::from_le_bytes([chunk[0], chunk[1]]);
987    let channels = u16::from_le_bytes([chunk[2], chunk[3]]);
988    let sample_rate = u32::from_le_bytes([chunk[4], chunk[5], chunk[6], chunk[7]]);
989    let byte_rate = u32::from_le_bytes([chunk[8], chunk[9], chunk[10], chunk[11]]);
990    let block_align = u16::from_le_bytes([chunk[12], chunk[13]]);
991    let bits_per_sample = u16::from_le_bytes([chunk[14], chunk[15]]);
992    if channels == 0 || sample_rate == 0 || block_align == 0 {
993        return Err(
994            "WAVE fmt chunk has invalid channel count, sample rate, or block alignment".to_string(),
995        );
996    }
997
998    let mut subformat_tag = None;
999    let mut valid_bits_per_sample = None;
1000    if format_tag == 0xFFFE {
1001        if chunk.len() < 40 {
1002            return Err("WAVE extensible fmt chunk is too short".to_string());
1003        }
1004        let cb_size = u16::from_le_bytes([chunk[16], chunk[17]]);
1005        if cb_size < 22 {
1006            return Err("WAVE extensible fmt chunk has invalid extension size".to_string());
1007        }
1008        let valid = u16::from_le_bytes([chunk[18], chunk[19]]);
1009        valid_bits_per_sample = if valid == 0 { None } else { Some(valid) };
1010        subformat_tag = wave_extensible_subformat_tag(&chunk[24..40]);
1011    }
1012    Ok(WaveFormat {
1013        format_tag,
1014        subformat_tag,
1015        channels,
1016        sample_rate,
1017        byte_rate,
1018        block_align,
1019        bits_per_sample,
1020        valid_bits_per_sample,
1021    })
1022}
1023
1024fn wave_compression_name(tag: u16) -> &'static str {
1025    match tag {
1026        0x0001 => "PCM",
1027        0x0003 => "IEEE Float",
1028        0x0006 => "A-law",
1029        0x0007 => "mu-law",
1030        0xFFFE => "Extensible",
1031        _ => "Unknown",
1032    }
1033}
1034
1035fn wave_extensible_subformat_tag(guid: &[u8]) -> Option<u16> {
1036    const BASE_TAIL: [u8; 12] = [
1037        0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71,
1038    ];
1039    if guid.len() == 16 && guid[2..4] == [0x00, 0x00] && guid[4..16] == BASE_TAIL {
1040        Some(u16::from_le_bytes([guid[0], guid[1]]))
1041    } else {
1042        None
1043    }
1044}
1045
1046fn parse_flac(bytes: &[u8]) -> Result<AudioMetadata, String> {
1047    let mut pos = 4usize;
1048    while pos + 4 <= bytes.len() {
1049        let header = bytes[pos];
1050        let block_type = header & 0x7F;
1051        let last = header & 0x80 != 0;
1052        let len = ((bytes[pos + 1] as usize) << 16)
1053            | ((bytes[pos + 2] as usize) << 8)
1054            | bytes[pos + 3] as usize;
1055        pos += 4;
1056        if pos + len > bytes.len() {
1057            return Err("FLAC metadata block extends past end of file".to_string());
1058        }
1059        if block_type == 0 {
1060            if len < 34 {
1061                return Err("FLAC STREAMINFO block is too short".to_string());
1062            }
1063            let stream = &bytes[pos..pos + len];
1064            let packed = u64::from_be_bytes([
1065                stream[10], stream[11], stream[12], stream[13], stream[14], stream[15], stream[16],
1066                stream[17],
1067            ]);
1068            let sample_rate = ((packed >> 44) & 0xFFFFF) as u32;
1069            let channels = (((packed >> 41) & 0x7) + 1) as u16;
1070            let bits_per_sample = (((packed >> 36) & 0x1F) + 1) as u16;
1071            let total_samples = packed & 0x000F_FFFF_FFFF;
1072            if sample_rate == 0 {
1073                return Err("FLAC STREAMINFO has zero sample rate".to_string());
1074            }
1075            return Ok(AudioMetadata {
1076                format: "FLAC",
1077                compression_method: "FLAC".to_string(),
1078                num_channels: channels,
1079                sample_rate: sample_rate as f64,
1080                total_samples: if total_samples == 0 {
1081                    None
1082                } else {
1083                    Some(total_samples)
1084                },
1085                bits_per_sample: Some(bits_per_sample),
1086                bit_rate: None,
1087            });
1088        }
1089        pos += len;
1090        if last {
1091            break;
1092        }
1093    }
1094    Err("FLAC file is missing STREAMINFO metadata".to_string())
1095}
1096
1097fn parse_aiff(bytes: &[u8]) -> Result<AudioMetadata, String> {
1098    if bytes.len() < 12 {
1099        return Err("AIFF file is too short".to_string());
1100    }
1101    let form = &bytes[8..12];
1102    if form != b"AIFF" && form != b"AIFC" {
1103        return Err("FORM container is not AIFF or AIFC".to_string());
1104    }
1105    let mut pos = 12usize;
1106    while pos + 8 <= bytes.len() {
1107        let id = &bytes[pos..pos + 4];
1108        let size = read_u32_be(bytes, pos + 4)? as usize;
1109        pos += 8;
1110        let end = pos
1111            .checked_add(size)
1112            .ok_or_else(|| "AIFF chunk size overflows address space".to_string())?;
1113        if end > bytes.len() {
1114            return Err("AIFF chunk extends past end of file".to_string());
1115        }
1116        if id == b"COMM" {
1117            if size < 18 {
1118                return Err("AIFF COMM chunk is too short".to_string());
1119            }
1120            let channels = u16::from_be_bytes([bytes[pos], bytes[pos + 1]]);
1121            let total_samples = read_u32_be(bytes, pos + 2)? as u64;
1122            let bits_per_sample = u16::from_be_bytes([bytes[pos + 6], bytes[pos + 7]]);
1123            let sample_rate = read_ieee_extended_80(&bytes[pos + 8..pos + 18])?;
1124            let compression_method = if form == b"AIFC" && size >= 22 {
1125                match &bytes[pos + 18..pos + 22] {
1126                    b"NONE" => "PCM",
1127                    b"fl32" | b"FL32" => "IEEE Float",
1128                    b"fl64" | b"FL64" => "IEEE Float",
1129                    b"ulaw" | b"ULAW" => "mu-law",
1130                    b"alaw" | b"ALAW" => "A-law",
1131                    code => std::str::from_utf8(code).unwrap_or("Unknown"),
1132                }
1133                .to_string()
1134            } else {
1135                "PCM".to_string()
1136            };
1137            return Ok(AudioMetadata {
1138                format: if form == b"AIFC" { "AIFC" } else { "AIFF" },
1139                compression_method,
1140                num_channels: channels,
1141                sample_rate,
1142                total_samples: Some(total_samples),
1143                bits_per_sample: Some(bits_per_sample),
1144                bit_rate: Some(sample_rate * channels as f64 * bits_per_sample as f64),
1145            });
1146        }
1147        pos = end
1148            .checked_add(size % 2)
1149            .ok_or_else(|| "AIFF chunk padding overflows address space".to_string())?;
1150    }
1151    Err("AIFF file is missing COMM metadata".to_string())
1152}
1153
1154fn parse_ogg_vorbis(scan: &AudioInfoScan) -> Result<AudioMetadata, String> {
1155    let prefix = scan.prefix.as_slice();
1156    let first = parse_ogg_page(prefix, 0)?;
1157    if first.body.len() < 30 || &first.body[1..7] != b"vorbis" || first.body[0] != 1 {
1158        return Err("Ogg container is not Vorbis audio".to_string());
1159    }
1160    let channels = first.body[11] as u16;
1161    let sample_rate = u32::from_le_bytes([
1162        first.body[12],
1163        first.body[13],
1164        first.body[14],
1165        first.body[15],
1166    ]);
1167    if channels == 0 || sample_rate == 0 {
1168        return Err(
1169            "Vorbis identification header has invalid channel count or sample rate".to_string(),
1170        );
1171    }
1172    let nominal_bitrate = i32::from_le_bytes([
1173        first.body[20],
1174        first.body[21],
1175        first.body[22],
1176        first.body[23],
1177    ]);
1178    let total_samples = match scan.tail.as_deref() {
1179        Some(bytes) => find_last_ogg_granule(bytes),
1180        None if scan.file_size <= prefix.len() as u64 => find_last_ogg_granule(prefix),
1181        None => None,
1182    };
1183    Ok(AudioMetadata {
1184        format: "OGG",
1185        compression_method: "Vorbis".to_string(),
1186        num_channels: channels,
1187        sample_rate: sample_rate as f64,
1188        total_samples,
1189        bits_per_sample: None,
1190        bit_rate: if nominal_bitrate > 0 {
1191            Some(nominal_bitrate as f64)
1192        } else {
1193            None
1194        },
1195    })
1196}
1197
1198struct OggPage {
1199    granule_position: u64,
1200    body: Vec<u8>,
1201    next_pos: usize,
1202}
1203
1204fn parse_ogg_page(bytes: &[u8], pos: usize) -> Result<OggPage, String> {
1205    if pos + 27 > bytes.len() || &bytes[pos..pos + 4] != b"OggS" {
1206        return Err("invalid Ogg page header".to_string());
1207    }
1208    let segments = bytes[pos + 26] as usize;
1209    if pos + 27 + segments > bytes.len() {
1210        return Err("Ogg segment table extends past end of file".to_string());
1211    }
1212    let mut body_len = 0usize;
1213    for len in &bytes[pos + 27..pos + 27 + segments] {
1214        body_len += *len as usize;
1215    }
1216    let body_start = pos + 27 + segments;
1217    if body_start + body_len > bytes.len() {
1218        return Err("Ogg page body extends past end of file".to_string());
1219    }
1220    let granule_position = u64::from_le_bytes([
1221        bytes[pos + 6],
1222        bytes[pos + 7],
1223        bytes[pos + 8],
1224        bytes[pos + 9],
1225        bytes[pos + 10],
1226        bytes[pos + 11],
1227        bytes[pos + 12],
1228        bytes[pos + 13],
1229    ]);
1230    Ok(OggPage {
1231        granule_position,
1232        body: bytes[body_start..body_start + body_len].to_vec(),
1233        next_pos: body_start + body_len,
1234    })
1235}
1236
1237fn find_last_ogg_granule(bytes: &[u8]) -> Option<u64> {
1238    let mut pos = 0usize;
1239    let mut last = None;
1240    while pos + 27 <= bytes.len() {
1241        let Some(offset) = find_signature(&bytes[pos..], b"OggS") else {
1242            break;
1243        };
1244        pos += offset;
1245        match parse_ogg_page(bytes, pos) {
1246            Ok(page) => {
1247                if page.granule_position != u64::MAX {
1248                    last = Some(page.granule_position);
1249                }
1250                pos = page.next_pos;
1251            }
1252            Err(_) => break,
1253        }
1254    }
1255    last
1256}
1257
1258fn parse_mp3(bytes: &[u8], file_size: u64) -> Option<AudioMetadata> {
1259    let mut pos = skip_id3v2(bytes);
1260    while pos + 4 <= bytes.len() {
1261        if bytes[pos] == 0xFF && (bytes[pos + 1] & 0xE0) == 0xE0 {
1262            if let Some(frame) = parse_mpeg_audio_header(&bytes[pos..pos + 4]) {
1263                let payload_bytes = file_size.saturating_sub(pos as u64) as f64;
1264                let duration = if frame.bit_rate > 0 {
1265                    Some(payload_bytes * 8.0 / frame.bit_rate as f64)
1266                } else {
1267                    None
1268                };
1269                let total_samples =
1270                    duration.map(|seconds| (seconds * frame.sample_rate as f64).round() as u64);
1271                return Some(AudioMetadata {
1272                    format: "MP3",
1273                    compression_method: frame.layer.to_string(),
1274                    num_channels: frame.channels,
1275                    sample_rate: frame.sample_rate as f64,
1276                    total_samples,
1277                    bits_per_sample: None,
1278                    bit_rate: Some(frame.bit_rate as f64),
1279                });
1280            }
1281        }
1282        pos += 1;
1283    }
1284    None
1285}
1286
1287fn skip_id3v2(bytes: &[u8]) -> usize {
1288    if bytes.len() >= 10 && &bytes[0..3] == b"ID3" {
1289        let size = ((bytes[6] as usize & 0x7F) << 21)
1290            | ((bytes[7] as usize & 0x7F) << 14)
1291            | ((bytes[8] as usize & 0x7F) << 7)
1292            | (bytes[9] as usize & 0x7F);
1293        10 + size
1294    } else {
1295        0
1296    }
1297}
1298
1299struct MpegFrame {
1300    layer: &'static str,
1301    sample_rate: u32,
1302    bit_rate: u32,
1303    channels: u16,
1304}
1305
1306fn parse_mpeg_audio_header(header: &[u8]) -> Option<MpegFrame> {
1307    let version_id = (header[1] >> 3) & 0x03;
1308    let layer_id = (header[1] >> 1) & 0x03;
1309    let bitrate_index = (header[2] >> 4) & 0x0F;
1310    let sample_rate_index = (header[2] >> 2) & 0x03;
1311    let channel_mode = (header[3] >> 6) & 0x03;
1312    if version_id == 1
1313        || layer_id == 0
1314        || bitrate_index == 0
1315        || bitrate_index == 0x0F
1316        || sample_rate_index == 0x03
1317    {
1318        return None;
1319    }
1320    let version = match version_id {
1321        3 => MpegVersion::V1,
1322        2 => MpegVersion::V2,
1323        0 => MpegVersion::V25,
1324        _ => return None,
1325    };
1326    let layer = match layer_id {
1327        3 => MpegLayer::LayerI,
1328        2 => MpegLayer::LayerII,
1329        1 => MpegLayer::LayerIII,
1330        _ => return None,
1331    };
1332    let sample_rate = mpeg_sample_rate(version, sample_rate_index)?;
1333    let bit_rate = mpeg_bit_rate(version, layer, bitrate_index)? * 1000;
1334    Some(MpegFrame {
1335        layer: match layer {
1336            MpegLayer::LayerI => "MPEG Layer I",
1337            MpegLayer::LayerII => "MPEG Layer II",
1338            MpegLayer::LayerIII => "MPEG Layer III",
1339        },
1340        sample_rate,
1341        bit_rate,
1342        channels: if channel_mode == 3 { 1 } else { 2 },
1343    })
1344}
1345
1346#[derive(Debug, Clone, Copy)]
1347enum MpegVersion {
1348    V1,
1349    V2,
1350    V25,
1351}
1352
1353#[derive(Debug, Clone, Copy)]
1354enum MpegLayer {
1355    LayerI,
1356    LayerII,
1357    LayerIII,
1358}
1359
1360fn mpeg_sample_rate(version: MpegVersion, index: u8) -> Option<u32> {
1361    let base = match index {
1362        0 => 44_100,
1363        1 => 48_000,
1364        2 => 32_000,
1365        _ => return None,
1366    };
1367    Some(match version {
1368        MpegVersion::V1 => base,
1369        MpegVersion::V2 => base / 2,
1370        MpegVersion::V25 => base / 4,
1371    })
1372}
1373
1374fn mpeg_bit_rate(version: MpegVersion, layer: MpegLayer, index: u8) -> Option<u32> {
1375    const V1_L1: [u32; 16] = [
1376        0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0,
1377    ];
1378    const V1_L2: [u32; 16] = [
1379        0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0,
1380    ];
1381    const V1_L3: [u32; 16] = [
1382        0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0,
1383    ];
1384    const V2_L1: [u32; 16] = [
1385        0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0,
1386    ];
1387    const V2_L23: [u32; 16] = [
1388        0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0,
1389    ];
1390    let table = match (version, layer) {
1391        (MpegVersion::V1, MpegLayer::LayerI) => V1_L1,
1392        (MpegVersion::V1, MpegLayer::LayerII) => V1_L2,
1393        (MpegVersion::V1, MpegLayer::LayerIII) => V1_L3,
1394        (_, MpegLayer::LayerI) => V2_L1,
1395        (_, MpegLayer::LayerII | MpegLayer::LayerIII) => V2_L23,
1396    };
1397    table.get(index as usize).copied().filter(|rate| *rate > 0)
1398}
1399
1400fn read_ieee_extended_80(bytes: &[u8]) -> Result<f64, String> {
1401    if bytes.len() != 10 {
1402        return Err("AIFF sample rate field must be 80 bits".to_string());
1403    }
1404    let sign = if bytes[0] & 0x80 != 0 { -1.0 } else { 1.0 };
1405    let exponent = (((bytes[0] & 0x7F) as u16) << 8) | bytes[1] as u16;
1406    let mantissa = u64::from_be_bytes([
1407        bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9],
1408    ]);
1409    if exponent == 0 && mantissa == 0 {
1410        return Ok(0.0);
1411    }
1412    let fraction = mantissa as f64 / (1u64 << 63) as f64;
1413    Ok(sign * fraction * 2f64.powi(exponent as i32 - 16383))
1414}
1415
1416fn resolve_audioinfo_path(value: &Value) -> BuiltinResult<PathBuf> {
1417    match value {
1418        Value::String(s) => normalize_audioinfo_path(s),
1419        Value::CharArray(ca) if ca.rows == 1 => {
1420            let text: String = ca.data.iter().collect();
1421            normalize_audioinfo_path(&text)
1422        }
1423        Value::StringArray(sa) if sa.data.len() == 1 => normalize_audioinfo_path(&sa.data[0]),
1424        _ => Err(audioinfo_error(&AUDIOINFO_ERROR_ARGUMENT)),
1425    }
1426}
1427
1428fn normalize_audioinfo_path(raw: &str) -> BuiltinResult<PathBuf> {
1429    if raw.trim().is_empty() {
1430        return Err(audioinfo_error_with(
1431            &AUDIOINFO_ERROR_ARGUMENT,
1432            "audioinfo: filename must not be empty",
1433        ));
1434    }
1435    let expanded = expand_user_path(raw, AUDIOINFO_BUILTIN_NAME)
1436        .map_err(|msg| audioinfo_error_with(&AUDIOINFO_ERROR_ARGUMENT, msg))?;
1437    Ok(Path::new(&expanded).to_path_buf())
1438}
1439
1440fn resolve_audioread_path(value: &Value) -> BuiltinResult<PathBuf> {
1441    match value {
1442        Value::String(s) => normalize_audioread_path(s),
1443        Value::CharArray(ca) if ca.rows == 1 => {
1444            let text: String = ca.data.iter().collect();
1445            normalize_audioread_path(&text)
1446        }
1447        Value::StringArray(sa) if sa.data.len() == 1 => normalize_audioread_path(&sa.data[0]),
1448        _ => Err(audioread_error(&AUDIOREAD_ERROR_ARGUMENT)),
1449    }
1450}
1451
1452fn normalize_audioread_path(raw: &str) -> BuiltinResult<PathBuf> {
1453    if raw.trim().is_empty() {
1454        return Err(audioread_error_with(
1455            &AUDIOREAD_ERROR_ARGUMENT,
1456            "audioread: filename must not be empty",
1457        ));
1458    }
1459    let expanded = expand_user_path(raw, AUDIOREAD_BUILTIN_NAME)
1460        .map_err(|msg| audioread_error_with(&AUDIOREAD_ERROR_ARGUMENT, msg))?;
1461    Ok(Path::new(&expanded).to_path_buf())
1462}
1463
1464fn read_u32_le(bytes: &[u8], pos: usize) -> Result<u32, String> {
1465    if pos + 4 > bytes.len() {
1466        return Err("unexpected end of file".to_string());
1467    }
1468    Ok(u32::from_le_bytes([
1469        bytes[pos],
1470        bytes[pos + 1],
1471        bytes[pos + 2],
1472        bytes[pos + 3],
1473    ]))
1474}
1475
1476fn read_u32_be(bytes: &[u8], pos: usize) -> Result<u32, String> {
1477    if pos + 4 > bytes.len() {
1478        return Err("unexpected end of file".to_string());
1479    }
1480    Ok(u32::from_be_bytes([
1481        bytes[pos],
1482        bytes[pos + 1],
1483        bytes[pos + 2],
1484        bytes[pos + 3],
1485    ]))
1486}
1487
1488fn read_u64_le(bytes: &[u8], pos: usize) -> Result<u64, String> {
1489    if pos + 8 > bytes.len() {
1490        return Err("unexpected end of file".to_string());
1491    }
1492    Ok(u64::from_le_bytes([
1493        bytes[pos],
1494        bytes[pos + 1],
1495        bytes[pos + 2],
1496        bytes[pos + 3],
1497        bytes[pos + 4],
1498        bytes[pos + 5],
1499        bytes[pos + 6],
1500        bytes[pos + 7],
1501    ]))
1502}
1503
1504fn find_signature(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1505    haystack
1506        .windows(needle.len())
1507        .position(|window| window == needle)
1508}
1509
1510#[cfg(test)]
1511mod tests {
1512    use super::*;
1513    use futures::executor::block_on;
1514    use runmat_time::unix_timestamp_ms;
1515    use std::fs;
1516    use std::sync::atomic::{AtomicU64, Ordering};
1517    use std::sync::Arc;
1518
1519    static NEXT_ID: AtomicU64 = AtomicU64::new(0);
1520
1521    fn temp_path(ext: &str) -> PathBuf {
1522        let millis = unix_timestamp_ms();
1523        let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
1524        let mut path = std::env::temp_dir();
1525        path.push(format!(
1526            "runmat_audioinfo_{}_{}_{}.{}",
1527            std::process::id(),
1528            millis,
1529            unique,
1530            ext
1531        ));
1532        path
1533    }
1534
1535    fn field<'a>(value: &'a Value, name: &str) -> &'a Value {
1536        let Value::Struct(st) = value else {
1537            panic!("expected struct");
1538        };
1539        st.fields
1540            .get(name)
1541            .unwrap_or_else(|| panic!("missing {name}"))
1542    }
1543
1544    fn tensor(value: Value) -> Tensor {
1545        match value {
1546            Value::Tensor(t) => t,
1547            other => panic!("expected tensor, got {other:?}"),
1548        }
1549    }
1550
1551    fn output_list(value: Value) -> Vec<Value> {
1552        match value {
1553            Value::OutputList(values) => values,
1554            other => panic!("expected output list, got {other:?}"),
1555        }
1556    }
1557
1558    fn wav_fixture(sample_rate: u32, channels: u16, bits: u16, frames: u32) -> Vec<u8> {
1559        let block_align = channels * (bits / 8);
1560        let byte_rate = sample_rate * block_align as u32;
1561        let data_size = frames * block_align as u32;
1562        let riff_size = 36 + data_size;
1563        let mut bytes = Vec::new();
1564        bytes.extend_from_slice(b"RIFF");
1565        bytes.extend_from_slice(&riff_size.to_le_bytes());
1566        bytes.extend_from_slice(b"WAVE");
1567        bytes.extend_from_slice(b"fmt ");
1568        bytes.extend_from_slice(&16u32.to_le_bytes());
1569        bytes.extend_from_slice(&1u16.to_le_bytes());
1570        bytes.extend_from_slice(&channels.to_le_bytes());
1571        bytes.extend_from_slice(&sample_rate.to_le_bytes());
1572        bytes.extend_from_slice(&byte_rate.to_le_bytes());
1573        bytes.extend_from_slice(&block_align.to_le_bytes());
1574        bytes.extend_from_slice(&bits.to_le_bytes());
1575        bytes.extend_from_slice(b"data");
1576        bytes.extend_from_slice(&data_size.to_le_bytes());
1577        bytes.resize(bytes.len() + data_size as usize, 0);
1578        bytes
1579    }
1580
1581    fn wav_with_payload(
1582        sample_rate: u32,
1583        channels: u16,
1584        bits: u16,
1585        format_tag: u16,
1586        payload: &[u8],
1587    ) -> Vec<u8> {
1588        let block_align = channels * (bits / 8);
1589        let byte_rate = sample_rate * block_align as u32;
1590        let riff_size = 36 + payload.len() as u32;
1591        let mut bytes = Vec::new();
1592        bytes.extend_from_slice(b"RIFF");
1593        bytes.extend_from_slice(&riff_size.to_le_bytes());
1594        bytes.extend_from_slice(b"WAVE");
1595        bytes.extend_from_slice(b"fmt ");
1596        bytes.extend_from_slice(&16u32.to_le_bytes());
1597        bytes.extend_from_slice(&format_tag.to_le_bytes());
1598        bytes.extend_from_slice(&channels.to_le_bytes());
1599        bytes.extend_from_slice(&sample_rate.to_le_bytes());
1600        bytes.extend_from_slice(&byte_rate.to_le_bytes());
1601        bytes.extend_from_slice(&block_align.to_le_bytes());
1602        bytes.extend_from_slice(&bits.to_le_bytes());
1603        bytes.extend_from_slice(b"data");
1604        bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
1605        bytes.extend_from_slice(payload);
1606        if !payload.len().is_multiple_of(2) {
1607            bytes.push(0);
1608        }
1609        bytes
1610    }
1611
1612    fn pcm16_wav(sample_rate: u32, channels: u16, samples_interleaved: &[i16]) -> Vec<u8> {
1613        let mut payload = Vec::new();
1614        for sample in samples_interleaved {
1615            payload.extend_from_slice(&sample.to_le_bytes());
1616        }
1617        wav_with_payload(sample_rate, channels, 16, 1, &payload)
1618    }
1619
1620    fn pcm8_wav(sample_rate: u32, channels: u16, samples_interleaved: &[u8]) -> Vec<u8> {
1621        wav_with_payload(sample_rate, channels, 8, 1, samples_interleaved)
1622    }
1623
1624    fn float32_wav(sample_rate: u32, channels: u16, samples_interleaved: &[f32]) -> Vec<u8> {
1625        let mut payload = Vec::new();
1626        for sample in samples_interleaved {
1627            payload.extend_from_slice(&sample.to_le_bytes());
1628        }
1629        wav_with_payload(sample_rate, channels, 32, 3, &payload)
1630    }
1631
1632    fn rf64_pcm16_wav(sample_rate: u32, channels: u16, samples_interleaved: &[i16]) -> Vec<u8> {
1633        let mut payload = Vec::new();
1634        for sample in samples_interleaved {
1635            payload.extend_from_slice(&sample.to_le_bytes());
1636        }
1637        let bits = 16u16;
1638        let block_align = channels * (bits / 8);
1639        let byte_rate = sample_rate * block_align as u32;
1640        let data_size = payload.len() as u64;
1641        let riff_size = 36u64 + data_size;
1642        let sample_count = data_size / block_align as u64;
1643        let mut bytes = Vec::new();
1644        bytes.extend_from_slice(b"RF64");
1645        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
1646        bytes.extend_from_slice(b"WAVE");
1647        bytes.extend_from_slice(b"ds64");
1648        bytes.extend_from_slice(&28u32.to_le_bytes());
1649        bytes.extend_from_slice(&riff_size.to_le_bytes());
1650        bytes.extend_from_slice(&data_size.to_le_bytes());
1651        bytes.extend_from_slice(&sample_count.to_le_bytes());
1652        bytes.extend_from_slice(&0u32.to_le_bytes());
1653        bytes.extend_from_slice(b"fmt ");
1654        bytes.extend_from_slice(&16u32.to_le_bytes());
1655        bytes.extend_from_slice(&1u16.to_le_bytes());
1656        bytes.extend_from_slice(&channels.to_le_bytes());
1657        bytes.extend_from_slice(&sample_rate.to_le_bytes());
1658        bytes.extend_from_slice(&byte_rate.to_le_bytes());
1659        bytes.extend_from_slice(&block_align.to_le_bytes());
1660        bytes.extend_from_slice(&bits.to_le_bytes());
1661        bytes.extend_from_slice(b"data");
1662        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
1663        bytes.extend_from_slice(&payload);
1664        bytes
1665    }
1666
1667    fn flac_fixture(sample_rate: u32, channels: u16, bits: u16, total_samples: u64) -> Vec<u8> {
1668        let mut bytes = Vec::new();
1669        bytes.extend_from_slice(b"fLaC");
1670        bytes.push(0x80);
1671        bytes.extend_from_slice(&[0x00, 0x00, 0x22]);
1672        let mut streaminfo = vec![0u8; 34];
1673        streaminfo[0..2].copy_from_slice(&4096u16.to_be_bytes());
1674        streaminfo[2..4].copy_from_slice(&4096u16.to_be_bytes());
1675        let packed = ((sample_rate as u64) << 44)
1676            | (((channels as u64) - 1) << 41)
1677            | (((bits as u64) - 1) << 36)
1678            | (total_samples & 0x000F_FFFF_FFFF);
1679        streaminfo[10..18].copy_from_slice(&packed.to_be_bytes());
1680        bytes.extend_from_slice(&streaminfo);
1681        bytes
1682    }
1683
1684    fn rf64_fixture(sample_rate: u32, channels: u16, bits: u16, frames: u64) -> Vec<u8> {
1685        let block_align = channels * (bits / 8);
1686        let byte_rate = sample_rate * block_align as u32;
1687        let data_size = frames * block_align as u64;
1688        let mut bytes = Vec::new();
1689        bytes.extend_from_slice(b"RF64");
1690        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
1691        bytes.extend_from_slice(b"WAVE");
1692        bytes.extend_from_slice(b"ds64");
1693        bytes.extend_from_slice(&24u32.to_le_bytes());
1694        bytes.extend_from_slice(&(36u64 + data_size).to_le_bytes());
1695        bytes.extend_from_slice(&data_size.to_le_bytes());
1696        bytes.extend_from_slice(&frames.to_le_bytes());
1697        bytes.extend_from_slice(b"fmt ");
1698        bytes.extend_from_slice(&16u32.to_le_bytes());
1699        bytes.extend_from_slice(&1u16.to_le_bytes());
1700        bytes.extend_from_slice(&channels.to_le_bytes());
1701        bytes.extend_from_slice(&sample_rate.to_le_bytes());
1702        bytes.extend_from_slice(&byte_rate.to_le_bytes());
1703        bytes.extend_from_slice(&block_align.to_le_bytes());
1704        bytes.extend_from_slice(&bits.to_le_bytes());
1705        bytes.extend_from_slice(b"data");
1706        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
1707        bytes
1708    }
1709
1710    fn ogg_vorbis_identification_fixture(granule: u64) -> Vec<u8> {
1711        let mut body = vec![0u8; 30];
1712        body[0] = 1;
1713        body[1..7].copy_from_slice(b"vorbis");
1714        body[11] = 2;
1715        body[12..16].copy_from_slice(&44_100u32.to_le_bytes());
1716        body[20..24].copy_from_slice(&128_000i32.to_le_bytes());
1717
1718        let mut bytes = Vec::new();
1719        bytes.extend_from_slice(b"OggS");
1720        bytes.push(0);
1721        bytes.push(0);
1722        bytes.extend_from_slice(&granule.to_le_bytes());
1723        bytes.extend_from_slice(&1u32.to_le_bytes());
1724        bytes.extend_from_slice(&0u32.to_le_bytes());
1725        bytes.extend_from_slice(&0u32.to_le_bytes());
1726        bytes.push(1);
1727        bytes.push(body.len() as u8);
1728        bytes.extend_from_slice(&body);
1729        bytes
1730    }
1731
1732    fn audio_scan(prefix: Vec<u8>) -> AudioInfoScan {
1733        AudioInfoScan {
1734            file_size: prefix.len() as u64,
1735            prefix,
1736            tail: None,
1737        }
1738    }
1739
1740    fn audio_scan_with_file_size(prefix: Vec<u8>, file_size: u64) -> AudioInfoScan {
1741        AudioInfoScan {
1742            prefix,
1743            tail: None,
1744            file_size,
1745        }
1746    }
1747
1748    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1749    #[test]
1750    fn audioinfo_descriptor_covers_core_form() {
1751        assert_eq!(
1752            AUDIOINFO_DESCRIPTOR.signatures[0].label,
1753            "info = audioinfo(filename)"
1754        );
1755    }
1756
1757    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1758    #[test]
1759    fn audioread_descriptor_covers_core_forms() {
1760        let labels: Vec<&str> = AUDIOREAD_DESCRIPTOR
1761            .signatures
1762            .iter()
1763            .map(|sig| sig.label)
1764            .collect();
1765        assert_eq!(
1766            labels,
1767            vec![
1768                "y = audioread(filename)",
1769                "[y, Fs] = audioread(filename)",
1770                "y = audioread(filename, samples)",
1771                "[y, Fs] = audioread(filename, samples, datatype)",
1772            ]
1773        );
1774    }
1775
1776    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1777    #[test]
1778    fn audioinfo_reads_wav_metadata() {
1779        let _lock = runmat_filesystem::provider_override_lock();
1780        let path = temp_path("wav");
1781        fs::write(&path, wav_fixture(44_100, 2, 16, 4)).expect("write fixture");
1782
1783        let info = block_on(audioinfo_builtin(Value::from(
1784            path.to_string_lossy().into_owned(),
1785        )))
1786        .expect("audioinfo");
1787
1788        assert_eq!(field(&info, "Format"), &Value::String("WAV".to_string()));
1789        assert_eq!(
1790            field(&info, "CompressionMethod"),
1791            &Value::String("PCM".to_string())
1792        );
1793        assert_eq!(field(&info, "NumChannels"), &Value::Num(2.0));
1794        assert_eq!(field(&info, "SampleRate"), &Value::Num(44_100.0));
1795        assert_eq!(field(&info, "TotalSamples"), &Value::Num(4.0));
1796        assert_eq!(field(&info, "BitsPerSample"), &Value::Num(16.0));
1797        let _ = fs::remove_file(path);
1798    }
1799
1800    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1801    #[test]
1802    fn audioinfo_reads_rf64_data_size_from_ds64() {
1803        let metadata =
1804            AudioMetadata::parse(&audio_scan(rf64_fixture(48_000, 2, 16, 5))).expect("rf64");
1805        assert_eq!(metadata.format, "WAV");
1806        assert_eq!(metadata.total_samples, Some(5));
1807        assert_eq!(metadata.num_channels, 2);
1808    }
1809
1810    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1811    #[test]
1812    fn audioinfo_keeps_rf64_sample_count_in_u64_space() {
1813        let block_align = 2u64 * (16u64 / 8);
1814        let frames = (u32::MAX as u64 / block_align) + 10;
1815        let metadata =
1816            AudioMetadata::parse(&audio_scan(rf64_fixture(48_000, 2, 16, frames))).expect("rf64");
1817        assert_eq!(metadata.total_samples, Some(frames));
1818    }
1819
1820    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1821    #[test]
1822    fn audioread_reads_mono_pcm16_as_normalized_double() {
1823        let _lock = runmat_filesystem::provider_override_lock();
1824        let path = temp_path("wav");
1825        fs::write(&path, pcm16_wav(8_000, 1, &[-32768, 0, 32767])).expect("write fixture");
1826
1827        let y = tensor(
1828            block_on(audioread_builtin(
1829                Value::from(path.to_string_lossy().into_owned()),
1830                Vec::new(),
1831            ))
1832            .expect("audioread"),
1833        );
1834
1835        assert_eq!(y.shape, vec![3, 1]);
1836        assert_eq!(y.dtype, NumericDType::F64);
1837        assert_eq!(y.data[0], -1.0);
1838        assert_eq!(y.data[1], 0.0);
1839        assert!((y.data[2] - (32767.0 / 32768.0)).abs() < 1e-12);
1840        let _ = fs::remove_file(path);
1841    }
1842
1843    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1844    #[test]
1845    fn audioread_stereo_output_is_column_major_by_channel() {
1846        let _lock = runmat_filesystem::provider_override_lock();
1847        let path = temp_path("wav");
1848        fs::write(
1849            &path,
1850            pcm16_wav(
1851                44_100,
1852                2,
1853                &[
1854                    32767, -32768, // frame 1: left, right
1855                    0, 16384, // frame 2: left, right
1856                ],
1857            ),
1858        )
1859        .expect("write fixture");
1860
1861        let y = tensor(
1862            block_on(audioread_builtin(
1863                Value::from(path.to_string_lossy().into_owned()),
1864                Vec::new(),
1865            ))
1866            .expect("audioread"),
1867        );
1868
1869        assert_eq!(y.shape, vec![2, 2]);
1870        assert!((y.data[0] - (32767.0 / 32768.0)).abs() < 1e-12);
1871        assert_eq!(y.data[1], 0.0);
1872        assert_eq!(y.data[2], -1.0);
1873        assert_eq!(y.data[3], 0.5);
1874        let _ = fs::remove_file(path);
1875    }
1876
1877    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1878    #[test]
1879    fn audioread_sample_range_is_one_based_inclusive_and_returns_fs() {
1880        let _lock = runmat_filesystem::provider_override_lock();
1881        let path = temp_path("wav");
1882        fs::write(&path, pcm16_wav(22_050, 1, &[-32768, -16384, 0, 16384])).expect("write fixture");
1883        let _guard = crate::output_count::push_output_count(Some(2));
1884
1885        let outputs = output_list(
1886            block_on(audioread_builtin(
1887                Value::from(path.to_string_lossy().into_owned()),
1888                vec![Value::Tensor(
1889                    Tensor::new(vec![2.0, 3.0], vec![1, 2]).expect("range"),
1890                )],
1891            ))
1892            .expect("audioread"),
1893        );
1894
1895        let y = tensor(outputs[0].clone());
1896        assert_eq!(outputs[1], Value::Num(22_050.0));
1897        assert_eq!(y.shape, vec![2, 1]);
1898        assert_eq!(y.data, vec![-0.5, 0.0]);
1899        let _ = fs::remove_file(path);
1900    }
1901
1902    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1903    #[test]
1904    fn audioread_native_uint8_preserves_dtype_and_values() {
1905        let _lock = runmat_filesystem::provider_override_lock();
1906        let path = temp_path("wav");
1907        fs::write(&path, pcm8_wav(11_025, 1, &[0, 128, 255])).expect("write fixture");
1908
1909        let y = tensor(
1910            block_on(audioread_builtin(
1911                Value::from(path.to_string_lossy().into_owned()),
1912                vec![Value::from("native")],
1913            ))
1914            .expect("audioread"),
1915        );
1916
1917        assert_eq!(y.dtype, NumericDType::U8);
1918        assert_eq!(y.data, vec![0.0, 128.0, 255.0]);
1919        let _ = fs::remove_file(path);
1920    }
1921
1922    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1923    #[test]
1924    fn audioread_reads_float32_wave() {
1925        let _lock = runmat_filesystem::provider_override_lock();
1926        let path = temp_path("wav");
1927        fs::write(&path, float32_wav(48_000, 1, &[-0.25, 0.0, 0.5])).expect("write fixture");
1928
1929        let y = tensor(
1930            block_on(audioread_builtin(
1931                Value::from(path.to_string_lossy().into_owned()),
1932                Vec::new(),
1933            ))
1934            .expect("audioread"),
1935        );
1936
1937        assert_eq!(y.shape, vec![3, 1]);
1938        assert_eq!(y.data, vec![-0.25, 0.0, 0.5]);
1939        let _ = fs::remove_file(path);
1940    }
1941
1942    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1943    #[test]
1944    fn audioread_reads_rf64_data_size_from_ds64() {
1945        let _lock = runmat_filesystem::provider_override_lock();
1946        let path = temp_path("wav");
1947        fs::write(&path, rf64_pcm16_wav(32_000, 1, &[0, 16384])).expect("write fixture");
1948
1949        let outputs = {
1950            let _guard = crate::output_count::push_output_count(Some(2));
1951            output_list(
1952                block_on(audioread_builtin(
1953                    Value::from(path.to_string_lossy().into_owned()),
1954                    Vec::new(),
1955                ))
1956                .expect("audioread"),
1957            )
1958        };
1959
1960        let y = tensor(outputs[0].clone());
1961        assert_eq!(outputs[1], Value::Num(32_000.0));
1962        assert_eq!(y.shape, vec![2, 1]);
1963        assert_eq!(y.data, vec![0.0, 0.5]);
1964        let _ = fs::remove_file(path);
1965    }
1966
1967    #[cfg(not(target_arch = "wasm32"))]
1968    #[test]
1969    fn audioread_reads_via_active_filesystem_provider() {
1970        let _lock = runmat_filesystem::provider_override_lock();
1971        let dir = tempfile::tempdir().expect("tempdir");
1972        let provider =
1973            runmat_filesystem::SandboxFsProvider::new(dir.path().to_path_buf()).expect("sandbox");
1974        let _guard = runmat_filesystem::replace_provider(Arc::new(provider));
1975        block_on(runmat_filesystem::write_async(
1976            "/audio.wav",
1977            pcm16_wav(16_000, 1, &[0, 16384]),
1978        ))
1979        .expect("provider write");
1980
1981        let outputs = {
1982            let _out_guard = crate::output_count::push_output_count(Some(2));
1983            output_list(
1984                block_on(audioread_builtin(Value::from("/audio.wav"), Vec::new()))
1985                    .expect("audioread"),
1986            )
1987        };
1988
1989        let y = tensor(outputs[0].clone());
1990        assert_eq!(outputs[1], Value::Num(16_000.0));
1991        assert_eq!(y.shape, vec![2, 1]);
1992        assert_eq!(y.data, vec![0.0, 0.5]);
1993    }
1994
1995    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1996    #[test]
1997    fn audioinfo_reads_flac_streaminfo() {
1998        let _lock = runmat_filesystem::provider_override_lock();
1999        let path = temp_path("flac");
2000        fs::write(&path, flac_fixture(48_000, 2, 24, 96_000)).expect("write fixture");
2001
2002        let info = block_on(audioinfo_builtin(Value::from(
2003            path.to_string_lossy().into_owned(),
2004        )))
2005        .expect("audioinfo");
2006
2007        assert_eq!(field(&info, "Format"), &Value::String("FLAC".to_string()));
2008        assert_eq!(field(&info, "NumChannels"), &Value::Num(2.0));
2009        assert_eq!(field(&info, "SampleRate"), &Value::Num(48_000.0));
2010        assert_eq!(field(&info, "TotalSamples"), &Value::Num(96_000.0));
2011        assert_eq!(field(&info, "Duration"), &Value::Num(2.0));
2012        assert_eq!(field(&info, "BitsPerSample"), &Value::Num(24.0));
2013        let _ = fs::remove_file(path);
2014    }
2015
2016    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2017    #[test]
2018    fn audioinfo_rejects_unknown_data() {
2019        let _lock = runmat_filesystem::provider_override_lock();
2020        let path = temp_path("bin");
2021        fs::write(&path, b"not audio").expect("write fixture");
2022
2023        let err = block_on(audioinfo_builtin(Value::from(
2024            path.to_string_lossy().into_owned(),
2025        )))
2026        .expect_err("format error");
2027        assert!(err.message().contains("unsupported audio format"));
2028        let _ = fs::remove_file(path);
2029    }
2030
2031    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2032    #[test]
2033    fn audioinfo_parses_mp3_frame_header() {
2034        let mut bytes = vec![0xFF, 0xFB, 0x90, 0x64];
2035        bytes.resize(417, 0);
2036        let metadata = AudioMetadata::parse(&audio_scan(bytes)).expect("mp3 metadata");
2037        assert_eq!(metadata.format, "MP3");
2038        assert_eq!(metadata.compression_method, "MPEG Layer III");
2039        assert_eq!(metadata.num_channels, 2);
2040        assert_eq!(metadata.sample_rate, 44_100.0);
2041        assert_eq!(metadata.bit_rate, Some(128_000.0));
2042    }
2043
2044    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2045    #[test]
2046    fn audioinfo_mp3_duration_uses_actual_file_size() {
2047        let bytes = vec![0xFF, 0xFB, 0x90, 0x64];
2048        let metadata =
2049            AudioMetadata::parse(&audio_scan_with_file_size(bytes, 16_000)).expect("mp3 metadata");
2050        assert_eq!(metadata.total_samples, Some(44_100));
2051    }
2052
2053    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2054    #[test]
2055    fn audioinfo_large_non_ogg_scan_skips_tail_window() {
2056        let path = temp_path("wav");
2057        let bytes = wav_fixture(44_100, 1, 16, 600_000);
2058        fs::write(&path, &bytes).expect("write fixture");
2059
2060        let scan = block_on(read_audioinfo_scan(&path)).expect("audio scan");
2061        assert_eq!(scan.file_size, bytes.len() as u64);
2062        assert!(scan.tail.is_none());
2063
2064        let _ = fs::remove_file(path);
2065    }
2066
2067    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2068    #[test]
2069    fn audioinfo_ogg_large_file_without_tail_granule_reports_unknown_samples() {
2070        let scan = audio_scan_with_file_size(ogg_vorbis_identification_fixture(0), 1_000_000);
2071        let metadata = AudioMetadata::parse(&scan).expect("ogg metadata");
2072        assert_eq!(metadata.format, "OGG");
2073        assert_eq!(metadata.total_samples, None);
2074    }
2075}