Files
oak-editor/crates/oak-audio/src/waveform.rs
T
Mike-Solar 244d5e860f
CI / Build & test (Windows) (push) Failing after 7s
workspace: kebab-case crates, app under crates/oak-app, shared versions
All crates take the oak-* kebab-case naming (oak-audio, oak-codec,
oak-common, oak-core, oak-ffmpeg-link, oak-node, oak-otio, oak-plugin,
oak-render, oak-storage, oak-task, oak-timeline, oak-undo), with the
lib identifiers rewritten (oakrender:: -> oak_render::, oakcore_rs:: ->
oak_core::, ...) across all 226 referencing files.

The GUI application moves from the workspace root into
crates/oak-app/: src/, build.rs (paths fixed for the new location) and
tests/ travel with it, the root Cargo.toml becomes workspace-only
([workspace] + workspace.package + profiles), and the app package
inherits the workspace version. The screenshots example becomes a
standalone crate examples/simple_player/ with its own Cargo.toml.

Every crate now inherits the single workspace version
(version.workspace = true), and the workflows' crate paths and the
build docs follow the renames.

Validated with a clean cargo check --workspace.
2026-08-22 16:58:37 +08:00

831 lines
25 KiB
Rust

// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Visual waveform store (`olive::AudioVisualWaveform`). Holds channel-
//! interleaved min/max pairs at multiple mipmap levels for efficient display
//! at any zoom scale. `draw_sample()`/`draw_waveform()` are app-layer
//! QPainter helpers and live in the facade; this module only stores and
//! summarizes data.
use std::collections::BTreeMap;
use std::ffi::CStr;
use oak_codec::decoder::{receive_list_of_all_decoders, CodecStream, Decoder as _, RetrieveAudioStatus};
use oak_codec::ffmpeg::FFmpegDecoder;
use oak_codec::footagedescription::FootageDescription;
use oak_core::{Rational, TimeRange};
use crate::error::{Error, Result};
/// Maximum channel count accepted by [`extract`]. The C++ plane array is a
/// fixed `OAKAUDIO_EXTRACT_MAX_CHANNELS` (64) stack buffer; the Rust
/// rewrite rejects wider streams instead of overflowing.
///
/// `// CPP-PARITY: src/audio/c_api/waveform.cpp:67`.
pub const EXTRACT_MAX_CHANNELS: i32 = 64;
/// Minimum overridable sample rate. Must be a power of two.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:30`
/// (`AudioVisualWaveform::k_minimum_sample_rate`).
pub fn minimum_sample_rate() -> Rational {
Rational::new(1, 8)
}
/// Maximum overridable sample rate. Must be a power of two.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:31`
/// (`AudioVisualWaveform::k_maximum_sample_rate`).
pub fn maximum_sample_rate() -> Rational {
Rational::new(1024, 1)
}
/// One min/max pair for a single channel.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.h`
/// (`AudioVisualWaveform::SamplePerChannel`).
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct SamplePerChannel {
/// Minimum amplitude in the window.
pub min: f32,
/// Maximum amplitude in the window.
pub max: f32,
}
/// One display sample: a `SamplePerChannel` per channel, channel-interleaved.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.h`
/// (`AudioVisualWaveform::Sample`).
pub type Sample = Vec<SamplePerChannel>;
/// A visual waveform store with mipmapped min/max data.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.h`
/// (`AudioVisualWaveform`).
#[derive(Debug, Clone)]
pub struct AudioVisualWaveform {
/// Timeline time the stored data starts at (shifts on trim_in).
virtual_start: Rational,
channels: i32,
length: Rational,
// Channel-interleaved min/max samples, keyed by the mipmap sample rate.
mipmapped_data: BTreeMap<Rational, Sample>,
}
/// `floor(time * sample_rate) * channels` — every mipmap index is
/// channel-interleaved, so time conversions scale by the channel count.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:359`
/// (`AudioVisualWaveform::time_to_samples`).
fn time_to_samples(time: f64, sample_rate: f64, channels: i32) -> usize {
let v = (time * sample_rate).floor();
if v <= 0.0 {
return 0;
}
v as usize * channels.max(0) as usize
}
impl AudioVisualWaveform {
/// Create an empty waveform (channel count 0) with the full mipmap
/// chain pre-allocated.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:33`
/// (`AudioVisualWaveform::AudioVisualWaveform`): mipmaps from 1/8 to
/// 1024 points/second, doubling.
pub fn new() -> AudioVisualWaveform {
let mut w = AudioVisualWaveform {
virtual_start: Rational::NULL,
channels: 0,
length: Rational::NULL,
mipmapped_data: BTreeMap::new(),
};
let mut rate = minimum_sample_rate();
while rate <= maximum_sample_rate() {
w.mipmapped_data.insert(rate, Vec::new());
rate = rate * Rational::new(2, 1);
}
w
}
/// Channel count.
pub fn channel_count(&self) -> i32 {
self.channels
}
/// Replace the channel count.
pub fn set_channel_count(&mut self, channels: i32) {
self.channels = channels;
}
/// Length of the waveform in seconds.
pub fn length(&self) -> Rational {
self.length
}
/// Keep `virtual_start` consistent with a new write position.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:90`
/// (`AudioVisualWaveform::validate_virtual_start`): writing before the
/// current start prepends via a NEGATIVE trim_in.
fn validate_virtual_start(&mut self, new_start: Rational) {
if self.length.is_null() {
self.virtual_start = new_start;
} else if self.virtual_start > new_start {
self.trim_in(new_start - self.virtual_start);
}
}
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:40`
/// (`AudioVisualWaveform::overwrite_samples_from_buffer`).
#[allow(clippy::too_many_arguments)]
fn overwrite_samples_from_buffer(
planar: &[&[f32]],
sample_rate: i32,
start: Rational,
target_rate: f64,
channels: i32,
data: &mut Sample,
) -> (usize, usize) {
let sample_count = planar.first().map_or(0, |p| p.len());
let start_index = time_to_samples(start.to_f64(), target_rate, channels);
let samples_length = time_to_samples(
sample_count as f64 / f64::from(sample_rate),
target_rate,
channels,
);
let end_index = start_index + samples_length;
if data.len() < end_index {
data.resize(end_index, SamplePerChannel::default());
}
let chunk_size = f64::from(sample_rate) / target_rate;
let mut i = 0usize;
while i < samples_length {
let src_start = ((i as f64 * chunk_size).round() as usize) / channels as usize;
let src_end = (((i + channels as usize) as f64 * chunk_size).round() as usize
/ channels as usize)
.min(sample_count);
let summary = Self::sum_samples(planar, src_start, src_end - src_start);
data[i + start_index..i + start_index + summary.len()].copy_from_slice(&summary);
i += channels as usize;
}
(start_index, samples_length)
}
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:69`
/// (`AudioVisualWaveform::overwrite_samples_from_mipmap`): mipmaps are
/// powers of two so the integer chunk division is exact.
#[allow(clippy::too_many_arguments)]
fn overwrite_samples_from_mipmap(
input: &Sample,
input_sample_rate: f64,
start: Rational,
output_rate: f64,
channels: i32,
output_data: &mut Sample,
input_start: usize,
input_length: usize,
) -> (usize, usize) {
let start_index = time_to_samples(start.to_f64(), output_rate, channels);
let samples_length = time_to_samples(
(input_length / channels as usize) as f64 / input_sample_rate,
output_rate,
channels,
);
let end_index = start_index + samples_length;
if output_data.len() < end_index {
output_data.resize(end_index, SamplePerChannel::default());
}
let chunk_size = (input_sample_rate / output_rate) as usize;
let mut i = 0usize;
while i < samples_length {
let summary = Self::re_sum_samples(
&input[input_start + (i * chunk_size)..],
chunk_size * channels as usize,
channels,
);
output_data[i + start_index..i + start_index + summary.len()].copy_from_slice(&summary);
i += channels as usize;
}
(start_index, samples_length)
}
/// Write planar samples into the waveform, expanding as needed.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:98`
/// (`AudioVisualWaveform::overwrite_samples`): the largest mipmap is
/// filled from the raw samples, then each smaller mipmap from the one
/// before it.
pub fn overwrite_samples(&mut self, planar: &[&[f32]], sample_rate: i32, start: Rational) {
if self.channels == 0 {
// C++ logs "channel count is zero" and returns
return;
}
self.validate_virtual_start(start);
// Process the largest mipmap directly from the samples
let rates: Vec<Rational> = self.mipmapped_data.keys().copied().collect();
let channels = self.channels;
let rel_start = start - self.virtual_start;
let mut iter_input: Option<(Sample, f64, usize, usize)> = None;
for rate in rates.iter().rev() {
let out_rate = rate.to_f64();
match iter_input.take() {
None => {
let data = self.mipmapped_data.get_mut(rate).unwrap();
let (s, l) = Self::overwrite_samples_from_buffer(
planar,
sample_rate,
rel_start,
out_rate,
channels,
data,
);
iter_input = Some((data.clone(), out_rate, s, l));
}
Some((input, input_rate, input_start, input_length)) => {
let data = self.mipmapped_data.get_mut(rate).unwrap();
let (s, l) = Self::overwrite_samples_from_mipmap(
&input,
input_rate,
rel_start,
out_rate,
channels,
data,
input_start,
input_length,
);
iter_input = Some((data.clone(), out_rate, s, l));
}
}
}
let sample_count = planar.first().map_or(0, |p| p.len()) as i64;
let sample_length = Rational::new(sample_count, i64::from(sample_rate));
self.length = self.length.max(start + sample_length);
}
/// Copy min/max data from another waveform over a destination range.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:137`
/// (`AudioVisualWaveform::overwrite_sums`): source indexing uses the
/// SOURCE's channel count; a null `length` copies everything from
/// `offset`.
pub fn overwrite_sums(
&mut self,
sums: &AudioVisualWaveform,
dest: Rational,
offset: Rational,
length: Rational,
) {
self.validate_virtual_start(dest);
let rates: Vec<Rational> = self.mipmapped_data.keys().copied().collect();
for rate in rates {
let rate_dbl = rate.to_f64();
let their_arr = match sums.mipmapped_data.get(&rate) {
Some(a) => a,
None => continue,
};
// Get our destination sample
let our_start_index = time_to_samples(
(dest - self.virtual_start).to_f64(),
rate_dbl,
self.channels,
);
// Get our source sample, indexing with the SOURCE's channel count
let their_start_index = (offset.to_f64() * rate_dbl).floor() as usize
* sums.channel_count().max(0) as usize;
if their_start_index >= their_arr.len() {
continue;
}
// Determine how much we're copying
let mut copy_len = their_arr.len() - their_start_index;
if !length.is_null() {
copy_len = copy_len.min(time_to_samples(length.to_f64(), rate_dbl, self.channels));
if copy_len == 0 {
continue;
}
}
let their_slice = their_arr[their_start_index..their_start_index + copy_len].to_vec();
let our_arr = self.mipmapped_data.get_mut(&rate).unwrap();
// Determine end index of our array
let end_index = our_start_index + copy_len;
if our_arr.len() < end_index {
our_arr.resize(end_index, SamplePerChannel::default());
}
our_arr[our_start_index..end_index].copy_from_slice(&their_slice);
}
self.length = self.length.max(
dest + if length.is_null() {
sums.length() - offset
} else {
length
},
);
}
/// Write silence over a range.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:187`
/// (`AudioVisualWaveform::overwrite_silence`).
pub fn overwrite_silence(&mut self, start: Rational, length: Rational) {
self.validate_virtual_start(start);
for (rate, our_arr) in self.mipmapped_data.iter_mut() {
let rate_dbl = rate.to_f64();
let our_start_index = time_to_samples(
(start - self.virtual_start).to_f64(),
rate_dbl,
self.channels,
);
let our_length_index = time_to_samples(length.to_f64(), rate_dbl, self.channels);
let our_end_index = our_start_index + our_length_index;
if our_arr.len() < our_end_index {
our_arr.resize(our_end_index, SamplePerChannel::default());
}
for p in &mut our_arr[our_start_index..our_start_index + our_length_index] {
*p = SamplePerChannel::default();
}
}
self.length = self.length.max(start + length);
}
/// Trim the start of the waveform.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:218`
/// (`AudioVisualWaveform::trim_in`): a NEGATIVE length prepends silence
/// and leaves `length_` unchanged (the absolute end does not move).
pub fn trim_in(&mut self, length: Rational) {
if length.is_null() {
return;
}
self.virtual_start = self.virtual_start + length;
let negative = length < Rational::NULL || length.to_f64() < 0.0;
let abs_length = if negative {
Rational::NULL - length
} else {
length
};
for (rate, data) in self.mipmapped_data.iter_mut() {
let rate_dbl = rate.to_f64();
let chop_length = time_to_samples(abs_length.to_f64(), rate_dbl, self.channels);
if chop_length == 0 {
continue;
}
if !negative {
let drop = chop_length.min(data.len());
data.drain(..drop);
} else {
let mut padded = vec![SamplePerChannel::default(); chop_length];
padded.extend_from_slice(data);
*data = padded;
}
}
if !negative {
self.length = Rational::new(0, 1).max(self.length - abs_length);
}
// Prepending grows the data before the existing start, so the absolute
// end (which length_ tracks) is unchanged
}
/// Take a sub-waveform starting at `offset`.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:252`
/// (`AudioVisualWaveform::mid`).
pub fn mid(&self, offset: Rational, length: Rational) -> AudioVisualWaveform {
let mut mid = self.clone();
mid.trim_range(offset - self.virtual_start, length);
mid
}
/// Resize to `length`, truncating or padding with silence.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:268`
/// (`AudioVisualWaveform::resize`).
pub fn resize(&mut self, length: Rational) {
if self.length == length {
return;
}
for (rate, data) in self.mipmapped_data.iter_mut() {
let rate_dbl = rate.to_f64();
let chop_length = time_to_samples(length.to_f64(), rate_dbl, self.channels);
data.resize(chop_length, SamplePerChannel::default());
}
self.length = length;
}
/// Trim to a range starting at `in` with the given `length`.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:286`
/// (`AudioVisualWaveform::trim_range`).
pub fn trim_range(&mut self, r#in: Rational, length: Rational) {
self.trim_in(r#in);
self.resize(length);
}
/// Pick the smallest mipmap whose rate covers `scale`.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:365`
/// (`AudioVisualWaveform::get_mipmap_for_scale`): falls back to the
/// largest mipmap when none is sufficient.
fn get_mipmap_for_scale(&self, scale: f64) -> (&Rational, &Sample) {
for (rate, data) in self.mipmapped_data.iter() {
if rate.to_f64() >= scale {
return (rate, data);
}
}
self.mipmapped_data.iter().next_back().unwrap()
}
/// Return summarized min/max pairs for a time range.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:292`
/// (`AudioVisualWaveform::get_summary_from_time`): a start past the end
/// of the data returns zero pairs instead of underflowing (signed
/// `available` comparison).
pub fn get_summary_from_time(&self, start: Rational, length: Rational) -> Sample {
// Find mipmap that requires
let (rate, mipmap_data) = self.get_mipmap_for_scale(length.to_f64().recip_or_zero());
let rate_dbl = rate.to_f64();
let start_sample = time_to_samples(
(start - self.virtual_start).to_f64(),
rate_dbl,
self.channels,
);
let mut sample_length = time_to_samples(length.to_f64(), rate_dbl, self.channels);
// Determine if the array actually has this sample. Compare in signed
// arithmetic so a start past the end of the data doesn't underflow.
let available = mipmap_data.len() as i64 - start_sample as i64;
if available > 0 {
sample_length = sample_length.min(available as usize);
if sample_length > 0 {
return Self::re_sum_samples(
&mipmap_data[start_sample..],
sample_length,
self.channels,
);
}
}
// Return null samples
vec![SamplePerChannel::default(); self.channel_count().max(0) as usize]
}
/// Reduce planar samples into min/max pairs.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:329`
/// (`AudioVisualWaveform::sum_samples`; scalar fallback of
/// `expand_min_max_channel`, the SIMD path is numerically identical).
pub fn sum_samples(planar: &[&[f32]], start_index: usize, length: usize) -> Sample {
let mut summed = Vec::with_capacity(planar.len());
for data in planar {
let end = (start_index + length).min(data.len());
let mut min_val: f32;
let mut max_val: f32;
if start_index < end {
min_val = data[start_index];
max_val = data[start_index];
for &s in &data[start_index + 1..end] {
if s < min_val {
min_val = s;
}
if s > max_val {
max_val = s;
}
}
} else {
min_val = 0.0;
max_val = 0.0;
}
summed.push(SamplePerChannel {
min: min_val,
max: max_val,
});
}
summed
}
/// Merge already-summed min/max pairs across channels.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:353`
/// (`AudioVisualWaveform::re_sum_samples`): initialized from the FIRST
/// point rather than {0,0} — the engine version clamped all-positive
/// (resp. all-negative) ranges to zero; fixed in oakaudio (see the
/// comment in the C++ source).
pub fn re_sum_samples(
samples: &[SamplePerChannel],
nb_samples: usize,
nb_channels: i32,
) -> Sample {
let channel_count = nb_channels.max(0) as usize;
let mut summed = vec![SamplePerChannel::default(); channel_count];
let nb_samples = nb_samples.min(samples.len());
// Initialize from the first point instead of {0,0}
if nb_samples >= channel_count {
summed[..channel_count].copy_from_slice(&samples[..channel_count]);
}
let mut i = 0usize;
while i < nb_samples {
for j in 0..channel_count {
if i + j >= samples.len() {
break;
}
let sample = samples[i + j];
if sample.min < summed[j].min {
summed[j].min = sample.min;
}
if sample.max > summed[j].max {
summed[j].max = sample.max;
}
}
i += nb_channels.max(1) as usize;
}
summed
}
}
impl Default for AudioVisualWaveform {
fn default() -> AudioVisualWaveform {
AudioVisualWaveform::new()
}
}
/// f64 reciprocal that yields 0 for a zero denominator (0/0 or 0-length
/// rationals): C++ `length.flipped().to_double()` on a null rational is
/// NaN; NaN never satisfies `rate >= scale` so the largest mipmap is
/// picked. We mirror that by returning NaN for the null case.
trait RecipOrZero {
fn recip_or_zero(self) -> f64;
}
impl RecipOrZero for f64 {
fn recip_or_zero(self) -> f64 {
if self == 0.0 || self.is_nan() {
f64::NAN
} else {
1.0 / self
}
}
}
// ---- Whole-file extraction --------------------------------------------------
/// Result of [`extract`]: channel-interleaved min/max pairs.
#[derive(Debug, Clone)]
pub struct ExtractOutcome {
/// `points * channels` channel-interleaved min/max pairs.
pub points: Vec<SamplePerChannel>,
/// Channel count of the decoded stream.
pub channels: i32,
}
/// Append one decoded chunk's interleaved f32 samples to the per-channel
/// `pending` planes.
fn append_pending(pending: &mut Vec<Vec<f32>>, interleaved: &[f32], channels: i32) {
if pending.is_empty() {
pending.resize(channels.max(0) as usize, Vec::new());
}
let channels = channels.max(1) as usize;
for (i, &v) in interleaved.iter().enumerate() {
pending[i % channels].push(v);
}
}
/// Emit one channel-interleaved point per `samples_per_point` pending
/// samples; with `flush`, a trailing partial point is emitted too.
///
/// `// CPP-PARITY: src/audio/c_api/waveform.cpp:88` (`emit_points`).
fn emit_points(
pending: &mut Vec<Vec<f32>>,
channels: i32,
samples_per_point: i32,
points: &mut Vec<SamplePerChannel>,
flush: bool,
) {
if pending.is_empty() {
return;
}
loop {
let available = pending[0].len();
if available == 0 || (!flush && available < samples_per_point as usize) {
return;
}
let n = available.min(samples_per_point as usize);
let point = points.len() / channels as usize;
points.resize(
points.len() + channels as usize,
SamplePerChannel::default(),
);
for ch in 0..channels {
let plane = &mut pending[ch as usize];
let mut mn = plane[0];
let mut mx = mn;
for &v in &plane[1..n] {
mn = mn.min(v);
mx = mx.max(v);
}
points[point * channels as usize + ch as usize] = SamplePerChannel { min: mn, max: mx };
plane.drain(..n);
}
}
}
/// Probe a file with every registered decoder and return the first valid
/// description (non-empty decoder id and at least one stream).
///
/// Replaces the former `oakcodec_decoder_probe` C ABI call (mirrors
/// `probe_with_any_decoder` in oakcodec's ffi layer).
fn probe_description(filename: &str) -> Option<FootageDescription> {
for decoder in receive_list_of_all_decoders() {
// `continue`, not `?`: an unimplemented/unsupported decoder must
// fall through to the next one (the FFmpeg entry is the
// format-agnostic fallback last in the registry).
let Some(desc) = decoder.probe(filename, None) else {
continue;
};
if !desc.decoder().is_empty()
&& (desc.video_stream_count() > 0
|| desc.audio_stream_count() > 0
|| desc.subtitle_stream_count() > 0)
{
return Some(desc);
}
}
None
}
/// Decode a whole audio stream to a channel-interleaved min/max summary.
///
/// The stream is probed through oakcodec's in-process decoder registry and
/// decoded with oakcodec's FFmpeg decoder (interleaved f32 at the native
/// rate/layout), then reduced to one point per `samples_per_point` source
/// samples.
///
/// `// CPP-PARITY: src/audio/c_api/waveform.cpp:404`
/// (`oakaudio_waveform_extract`).
pub fn extract(
filename: &CStr,
stream_index: i32,
samples_per_point: i32,
) -> Result<ExtractOutcome> {
// Probe for the stream's native rate/layout with the in-process decoder
// registry (the Rust equivalent of the former oakcodec decoder probe
// C ABI call).
let filename_str = filename.to_string_lossy();
let desc = probe_description(&filename_str).ok_or(Box::new(Error::NotFound))?;
let ap = desc
.get_audio_stream(stream_index as usize)
.ok_or(Box::new(Error::NotFound))?;
// The probed stream metadata is a plain value type (oakcodec's
// `AudioParams`); the fields map one-to-one onto the former
// `oakcodec_audio_stream_info` POD.
let sample_rate = ap.sample_rate;
let channel_count = ap.channel_count();
let channel_layout = ap.channel_layout;
let container_stream_index = ap.stream_index;
let duration_ts = ap.duration;
let (time_base_num, time_base_den) = ap.time_base;
if sample_rate <= 0 || channel_count <= 0 {
return Err(Box::new(Error::Failed("invalid audio stream".to_string())));
}
let channels = channel_count;
if channels > EXTRACT_MAX_CHANNELS {
return Err(Box::new(Error::Failed(format!(
"stream has {channels} channels (max {EXTRACT_MAX_CHANNELS})"
))));
}
// Decode the whole stream through oakcodec's FFmpeg decoder
// (`retrieve_audio` delivers interleaved f32 at the requested native
// rate/layout; the C++ path ran the decode through an identity
// fb_audio_graph to obtain planar f32).
let decoder = FFmpegDecoder::new();
let stream = CodecStream::with_block(
filename_str.into_owned(),
container_stream_index,
None,
);
if let Err(e) = decoder.open(&stream) {
return Err(Box::new(Error::Failed(format!("failed to open decoder: {e:?}"))));
}
if time_base_num <= 0 || time_base_den <= 0 || duration_ts <= 0 {
let _ = decoder.close();
return Err(Box::new(Error::Failed("invalid audio stream duration".to_string())));
}
let duration_sec =
duration_ts as f64 * f64::from(time_base_num) / f64::from(time_base_den);
let total_frames = (duration_sec * f64::from(sample_rate)).round() as i64;
let layout_mask = if channel_layout != 0 {
channel_layout
} else {
ffmpeg_next::ChannelLayout::default(channels).bits()
};
let mut pending: Vec<Vec<f32>> = Vec::new();
let mut points: Vec<SamplePerChannel> = Vec::new();
/// Frames decoded per `retrieve_audio` call (~0.34 s at 48 kHz).
const CHUNK_FRAMES: i64 = 16384;
let mut result: Result<()> = Ok(());
let mut offset = 0i64;
while offset < total_frames {
let frames = (total_frames - offset).min(CHUNK_FRAMES);
let range = TimeRange::new(
Rational::new(offset, i64::from(sample_rate)),
Rational::new(offset + frames, i64::from(sample_rate)),
);
let mut buf = vec![0f32; frames as usize * channels as usize];
match decoder.retrieve_audio(&mut buf, &range, sample_rate, layout_mask) {
Ok(RetrieveAudioStatus::Success) => {
append_pending(&mut pending, &buf, channels);
emit_points(
&mut pending,
channels,
samples_per_point,
&mut points,
false,
);
}
Ok(status) => {
result = Err(Box::new(Error::Failed(format!("audio retrieve failed: {status:?}"))));
break;
}
Err(e) => {
result = Err(Box::new(Error::Failed(format!("audio retrieve failed: {e:?}"))));
break;
}
}
offset += frames;
}
if result.is_ok() {
// Emit the trailing partial window.
emit_points(&mut pending, channels, samples_per_point, &mut points, true);
}
let _ = decoder.close();
result?;
Ok(ExtractOutcome { points, channels })
}