CI / Build & test (Windows) (push) Failing after 7s
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.
342 lines
10 KiB
Rust
342 lines
10 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/>.
|
|
|
|
//! Waveform-based audio synchronization (`olive::AudioWaveformSync`). Pure
|
|
//! static helpers that correlate RMS envelopes to estimate sample offsets and
|
|
//! playback-rate corrections. No shared state.
|
|
|
|
/// A candidate offset and its correlation confidence.
|
|
///
|
|
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.h`
|
|
/// (`AudioWaveformSync::OffsetResult`).
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct OffsetResult {
|
|
/// Offset of the candidate relative to the reference, in samples.
|
|
pub offset_samples: i64,
|
|
/// Normalized correlation confidence in `[0, 1]`.
|
|
pub confidence: f64,
|
|
/// Whether an offset could be determined.
|
|
pub valid: bool,
|
|
}
|
|
|
|
/// A playback-rate change plus offset aligning candidate to reference.
|
|
///
|
|
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.h`
|
|
/// (`AudioWaveformSync::StretchOffsetResult`).
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct StretchOffsetResult {
|
|
/// Rate the candidate must play at to align (`2.0` = candidate runs at
|
|
/// half speed and must be sped up 2x).
|
|
pub rate: f64,
|
|
/// Offset in samples.
|
|
pub offset_samples: i64,
|
|
/// Normalized correlation confidence in `[0, 1]`.
|
|
pub confidence: f64,
|
|
/// Whether a rate+offset could be determined.
|
|
pub valid: bool,
|
|
}
|
|
|
|
/// Extract a windowed RMS envelope from a planar sample buffer.
|
|
///
|
|
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:28`
|
|
/// (`AudioWaveformSync::extract_rms_envelope`): the trailing partial window
|
|
/// is kept; the mean is over ALL channels' samples in the window.
|
|
pub fn extract_rms_envelope(planar: &[&[f32]], window_samples: usize) -> Vec<f64> {
|
|
let mut envelope = Vec::new();
|
|
|
|
let channel_count = planar.len();
|
|
let sample_count = if channel_count > 0 {
|
|
planar[0].len()
|
|
} else {
|
|
0
|
|
};
|
|
if channel_count == 0 || sample_count == 0 || window_samples == 0 {
|
|
return envelope;
|
|
}
|
|
|
|
let window_count = sample_count.div_ceil(window_samples);
|
|
envelope.resize(window_count, 0.0);
|
|
|
|
for window in 0..window_count {
|
|
let start = window * window_samples;
|
|
let end = (start + window_samples).min(sample_count);
|
|
let mut square_sum = 0.0f64;
|
|
let mut total = 0usize;
|
|
|
|
for data in planar.iter() {
|
|
for &s in &data[start..end] {
|
|
let value = f64::from(s);
|
|
square_sum += value * value;
|
|
total += 1;
|
|
}
|
|
}
|
|
|
|
envelope[window] = if total > 0 {
|
|
(square_sum / total as f64).sqrt()
|
|
} else {
|
|
0.0
|
|
};
|
|
}
|
|
|
|
envelope
|
|
}
|
|
|
|
/// Estimate a plain sample offset between two planar buffers.
|
|
///
|
|
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:65`
|
|
/// (`AudioWaveformSync::estimate_offset`).
|
|
pub fn estimate_offset(
|
|
reference: &[&[f32]],
|
|
candidate: &[&[f32]],
|
|
window_samples: usize,
|
|
max_offset_samples: i64,
|
|
) -> OffsetResult {
|
|
if window_samples == 0 {
|
|
return OffsetResult {
|
|
offset_samples: 0,
|
|
confidence: 0.0,
|
|
valid: false,
|
|
};
|
|
}
|
|
|
|
let reference_envelope = extract_rms_envelope(reference, window_samples);
|
|
let candidate_envelope = extract_rms_envelope(candidate, window_samples);
|
|
let max_offset_windows = max_offset_samples / window_samples as i64;
|
|
|
|
estimate_envelope_offset(
|
|
&reference_envelope,
|
|
&candidate_envelope,
|
|
window_samples,
|
|
max_offset_windows,
|
|
)
|
|
}
|
|
|
|
/// Estimate an offset from RMS envelopes, treating both as fully valid.
|
|
///
|
|
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:84`
|
|
/// (`AudioWaveformSync::estimate_envelope_offset`, unmasked overload).
|
|
pub fn estimate_envelope_offset(
|
|
reference: &[f64],
|
|
candidate: &[f64],
|
|
window_samples: usize,
|
|
max_offset_windows: i64,
|
|
) -> OffsetResult {
|
|
estimate_envelope_offset_valid(
|
|
reference,
|
|
candidate,
|
|
&[],
|
|
&[],
|
|
window_samples,
|
|
max_offset_windows,
|
|
)
|
|
}
|
|
|
|
/// Estimate an offset from RMS envelopes, excluding windows flagged invalid.
|
|
///
|
|
/// Empty masks are treated as "all windows valid". This is the variant the
|
|
/// frozen C ABI exposes.
|
|
///
|
|
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:95`
|
|
/// (`AudioWaveformSync::estimate_envelope_offset`, masked overload): a mask
|
|
/// whose size does NOT match its envelope is ignored entirely
|
|
/// (`mask.size() != size || mask.at(index)` — load-bearing); lags with
|
|
/// fewer than 2 valid overlap windows are skipped; windows whose
|
|
/// correlation energy is zero (`qFuzzyIsNull`, < 1e-12) are skipped;
|
|
/// confidence is `max(0, best_score)`.
|
|
pub fn estimate_envelope_offset_valid(
|
|
reference: &[f64],
|
|
candidate: &[f64],
|
|
reference_valid: &[bool],
|
|
candidate_valid: &[bool],
|
|
window_samples: usize,
|
|
max_offset_windows: i64,
|
|
) -> OffsetResult {
|
|
let mut result = OffsetResult {
|
|
offset_samples: 0,
|
|
confidence: 0.0,
|
|
valid: false,
|
|
};
|
|
if reference.is_empty() || candidate.is_empty() || window_samples == 0 {
|
|
return result;
|
|
}
|
|
|
|
let is_valid =
|
|
|mask: &[bool], size: usize, index: usize| -> bool { mask.len() != size || mask[index] };
|
|
|
|
let mut best_score = -2.0f64;
|
|
let mut best_lag = 0i64;
|
|
|
|
let reference_size = reference.len() as i64;
|
|
let candidate_size = candidate.len() as i64;
|
|
|
|
for lag in -max_offset_windows..=max_offset_windows {
|
|
let reference_start = 0i64.max(-lag);
|
|
let candidate_start = 0i64.max(lag);
|
|
let overlap = (reference_size - reference_start).min(candidate_size - candidate_start);
|
|
|
|
if overlap < 2 {
|
|
continue;
|
|
}
|
|
|
|
// Only windows marked valid on both sides participate in the score
|
|
let mut reference_mean = 0.0f64;
|
|
let mut candidate_mean = 0.0f64;
|
|
let mut valid_count = 0i64;
|
|
for i in 0..overlap {
|
|
let reference_index = (reference_start + i) as usize;
|
|
let candidate_index = (candidate_start + i) as usize;
|
|
if !is_valid(reference_valid, reference.len(), reference_index)
|
|
|| !is_valid(candidate_valid, candidate.len(), candidate_index)
|
|
{
|
|
continue;
|
|
}
|
|
reference_mean += reference[reference_index];
|
|
candidate_mean += candidate[candidate_index];
|
|
valid_count += 1;
|
|
}
|
|
|
|
if valid_count < 2 {
|
|
continue;
|
|
}
|
|
|
|
reference_mean /= valid_count as f64;
|
|
candidate_mean /= valid_count as f64;
|
|
|
|
let mut numerator = 0.0f64;
|
|
let mut reference_energy = 0.0f64;
|
|
let mut candidate_energy = 0.0f64;
|
|
for i in 0..overlap {
|
|
let reference_index = (reference_start + i) as usize;
|
|
let candidate_index = (candidate_start + i) as usize;
|
|
if !is_valid(reference_valid, reference.len(), reference_index)
|
|
|| !is_valid(candidate_valid, candidate.len(), candidate_index)
|
|
{
|
|
continue;
|
|
}
|
|
let reference_value = reference[reference_index] - reference_mean;
|
|
let candidate_value = candidate[candidate_index] - candidate_mean;
|
|
numerator += reference_value * candidate_value;
|
|
reference_energy += reference_value * reference_value;
|
|
candidate_energy += candidate_value * candidate_value;
|
|
}
|
|
|
|
// qFuzzyIsNull(double): |x| < 1e-12
|
|
if reference_energy.abs() < 1e-12 || candidate_energy.abs() < 1e-12 {
|
|
continue;
|
|
}
|
|
|
|
let score = numerator / (reference_energy * candidate_energy).sqrt();
|
|
if score > best_score {
|
|
best_score = score;
|
|
best_lag = lag;
|
|
}
|
|
}
|
|
|
|
if best_score > -2.0 {
|
|
result.valid = true;
|
|
result.confidence = best_score.max(0.0);
|
|
result.offset_samples = best_lag * window_samples as i64;
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
/// Estimate a playback-rate change plus offset aligning the candidate to the
|
|
/// reference, resampling the candidate at each rate in `[min_rate, max_rate]`.
|
|
///
|
|
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:202`
|
|
/// (`AudioWaveformSync::estimate_stretch_and_offset`): the rate loop upper
|
|
/// bound is `max_rate + rate_step * 0.5` (a half-step tolerance, so
|
|
/// floating-point step accumulation still reaches max_rate); a resampled
|
|
/// window is valid only when BOTH source windows are valid; a wrong-sized
|
|
/// candidate mask means all-valid.
|
|
pub fn estimate_stretch_and_offset(
|
|
reference: &[f64],
|
|
candidate: &[f64],
|
|
reference_valid: &[bool],
|
|
candidate_valid: &[bool],
|
|
window_samples: usize,
|
|
max_offset_windows: i64,
|
|
min_rate: f64,
|
|
max_rate: f64,
|
|
rate_step: f64,
|
|
) -> StretchOffsetResult {
|
|
let mut result = StretchOffsetResult {
|
|
rate: 1.0,
|
|
offset_samples: 0,
|
|
confidence: 0.0,
|
|
valid: false,
|
|
};
|
|
if reference.is_empty()
|
|
|| candidate.is_empty()
|
|
|| window_samples == 0
|
|
|| min_rate <= 0.0
|
|
|| max_rate < min_rate
|
|
|| rate_step <= 0.0
|
|
{
|
|
return result;
|
|
}
|
|
|
|
let mut best_confidence = -2.0f64;
|
|
|
|
let mut rate = min_rate;
|
|
while rate <= max_rate + rate_step * 0.5 {
|
|
// Resample the candidate envelope so that window i of the resampled
|
|
// envelope corresponds to window i*rate of the original
|
|
let resampled_size = (candidate.len() as f64 / rate) as i64;
|
|
if resampled_size < 2 {
|
|
rate += rate_step;
|
|
continue;
|
|
}
|
|
|
|
let resampled_len = resampled_size as usize;
|
|
let mut resampled = vec![0.0f64; resampled_len];
|
|
let mut resampled_valid = vec![false; resampled_len];
|
|
for i in 0..resampled_size as usize {
|
|
let position = i as f64 * rate;
|
|
let lower = position as usize;
|
|
let upper = (lower + 1).min(candidate.len() - 1);
|
|
let fraction = position - lower as f64;
|
|
|
|
resampled[i] = candidate[lower] * (1.0 - fraction) + candidate[upper] * fraction;
|
|
|
|
resampled_valid[i] = candidate_valid.len() != candidate.len()
|
|
|| (candidate_valid[lower] && candidate_valid[upper]);
|
|
}
|
|
|
|
let offset = estimate_envelope_offset_valid(
|
|
reference,
|
|
&resampled,
|
|
reference_valid,
|
|
&resampled_valid,
|
|
window_samples,
|
|
max_offset_windows,
|
|
);
|
|
|
|
if offset.valid && offset.confidence > best_confidence {
|
|
best_confidence = offset.confidence;
|
|
result.valid = true;
|
|
result.rate = rate;
|
|
result.confidence = offset.confidence;
|
|
result.offset_samples = offset.offset_samples;
|
|
}
|
|
|
|
rate += rate_step;
|
|
}
|
|
|
|
result
|
|
}
|