refactor: workspace layout — crates/, app at root, legacy C++ removed

Single mechanical restructure commit:
- root Cargo.toml = oakapp bin + workspace; one cargo build produces
  oakapp, oak-cli, oak-worker, liboakengine.dylib
- app/rust/src -> src/ (app at repo root, no rust/ nesting)
- src/<mod>/rust -> crates/oak<mod>; src/oakcore-rs -> crates/oakcore;
  src/bindings/oakotio -> crates/oakotio; src/engine/rust ->
  crates/oakengine (keeps cdylib+staticlib+rlib)
- public C headers include/<mod>/ -> crates/oakengine/include/<mod>/
- OFX SDK headers vendored into crates/oakplugin/ofx/ (HostSupport gone)
- legacy deleted: old src/ C++ modules, engine/, core/, ffmpeg_bridge/,
  app/ (Qt), cli/worker C++, root CMakeLists, third_party/KDDockWidgets
  submodule, otio-install, all build-* output (~40GB)
- oakstorage kept but excluded from the workspace (skeleton w/ todos);
  gpui excluded (own workspace)
- verified: cargo build green, cargo test --workspace 1845/0
  (with the documented OCIO_RS_* env override for the homebrew OCIO)
This commit is contained in:
2026-08-10 20:24:25 +08:00
parent f8540e3892
commit 013a175707
4212 changed files with 8331 additions and 2274987 deletions
-9
View File
@@ -1,9 +0,0 @@
add_subdirectory(common)
add_subdirectory(undo)
add_subdirectory(node)
add_subdirectory(render)
add_subdirectory(codec)
add_subdirectory(audio)
add_subdirectory(timeline)
add_subdirectory(task)
add_subdirectory(plugin)
+717
View File
@@ -0,0 +1,717 @@
// 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/>.
//! The application shell: menu bar, dock layout, status bar and the tick
//! loop that drives playback, playhead sync and the audio meter.
//!
//! Layout per the design (`design/Oak-UI设计图-主界面-标注版.png`):
//!
//! ```text
//! ┌ menu bar (文件 编辑 视图 回放 序列 窗口 工具 帮助)
//! ├─────────────────────────────────────────────────────
//! │ dock: 项目 | 素材查看器 | 序列查看器+节点编辑器 | 检查器+历史记录
//! │ (vertical split) 时间线 (full width, 31px toolbar on top)
//! ├─────────────────────────────────────────────────────
//! └ status bar: 就绪 | 缓存 | 代理 | 自动保存 || 时间码/时长 | 帧率 | 分辨率
//! ```
use std::sync::Arc;
use std::time::Duration;
use gpui::dock::{
DockArea, DockLayout, DropTarget, DropZone, NodePath, PanelHandle, PanelRegistry,
};
use gpui::timeline::{Frame, TimelineEvent, TimelineView};
use gpui::{
div, prelude::*, px, size, App, AsyncWindowContext, Bounds, Context, Entity, Render, Window,
WindowBounds, WindowOptions,
};
use gpui_widgets::audio_meter::AudioLevelMeter;
use gpui_widgets::menu::{Menu, MenuBar, MenuBarEntry, MenuBarEvent, MenuItem};
use gpui_widgets::theme::{apply_theme, OakTheme};
use crate::oakui::{EngineGateway, MockClock, MockEngine, Monitor};
use crate::panels::history::HistoryPanel;
use crate::panels::ids::*;
use crate::panels::inspector::InspectorPanel;
use crate::panels::node_editor::NodeEditorPanel;
use crate::panels::program_viewer::ProgramViewerPanel;
use crate::panels::project_explorer::ProjectExplorerPanel;
use crate::panels::source_viewer::SourceViewerPanel;
use crate::panels::status_bar::StatusBar;
use crate::panels::timeline::TimelinePanel;
// Menu item ids (unique per menu).
mod menu_ids {
pub const NEW_PROJECT: usize = 101;
pub const OPEN_PROJECT: usize = 102;
pub const SAVE: usize = 103;
pub const EXPORT: usize = 104;
pub const QUIT: usize = 105;
pub const UNDO: usize = 201;
pub const REDO: usize = 202;
pub const CUT: usize = 203;
pub const COPY: usize = 204;
pub const PASTE: usize = 205;
pub const DELETE: usize = 206;
pub const THEME_DARK: usize = 301;
pub const THEME_LIGHT: usize = 302;
pub const LANG_ZH: usize = 303;
pub const LANG_EN: usize = 304;
pub const PLAY_PAUSE: usize = 401;
pub const PREV_FRAME: usize = 402;
pub const NEXT_FRAME: usize = 403;
pub const TO_START: usize = 404;
pub const ADD_VIDEO_TRACK: usize = 501;
pub const ADD_AUDIO_TRACK: usize = 502;
pub const FOCUS_PROJECT: usize = 601;
pub const FOCUS_SOURCE_VIEWER: usize = 602;
pub const FOCUS_PROGRAM_VIEWER: usize = 603;
pub const FOCUS_NODE_EDITOR: usize = 604;
pub const FOCUS_INSPECTOR: usize = 605;
pub const FOCUS_HISTORY: usize = 606;
pub const FOCUS_TIMELINE: usize = 607;
pub const ABOUT: usize = 801;
}
/// The panel registry: string keys for layout persistence, and the ability
/// to rebuild any panel from its key.
struct AppPanelRegistry {
engine: Entity<MockEngine>,
source_clock: Entity<MockClock>,
program_clock: Entity<MockClock>,
}
impl PanelRegistry for AppPanelRegistry {
fn panel_key(&self, id: gpui::dock::PanelId) -> Option<String> {
Some(
match id {
PROJECT => "project",
SOURCE_VIEWER => "source-viewer",
PROGRAM_VIEWER => "program-viewer",
NODE_EDITOR => "node-editor",
INSPECTOR => "inspector",
HISTORY => "history",
TIMELINE => "timeline",
_ => return None,
}
.to_string(),
)
}
fn build_panel(&self, key: &str, window: &mut Window, cx: &mut App) -> Option<PanelHandle> {
// Each arm builds its own `PanelHandle` because the panel views have
// different entity types.
match key {
"project" => Some(PanelHandle::new(
cx.new(|cx| ProjectExplorerPanel::new(self.engine.clone(), window, cx)),
cx,
)),
"source-viewer" => Some(PanelHandle::new(
cx.new(|cx| {
SourceViewerPanel::new(
self.engine.clone(),
self.source_clock.clone(),
window,
cx,
)
}),
cx,
)),
"program-viewer" => Some(PanelHandle::new(
cx.new(|cx| {
let meter =
cx.new(|cx| AudioLevelMeter::new(30, self.engine.clone(), window, cx));
ProgramViewerPanel::new(
self.engine.clone(),
self.program_clock.clone(),
meter,
window,
cx,
)
}),
cx,
)),
"node-editor" => Some(PanelHandle::new(
cx.new(|cx| NodeEditorPanel::new(self.engine.clone(), window, cx)),
cx,
)),
"inspector" => Some(PanelHandle::new(
cx.new(|cx| InspectorPanel::new(self.engine.clone(), window, cx)),
cx,
)),
"history" => Some(PanelHandle::new(
cx.new(|cx| HistoryPanel::new(window, cx)),
cx,
)),
"timeline" => Some(PanelHandle::new(
cx.new(|cx| {
let timeline =
cx.new(|cx| TimelineView::new(self.engine.clone(), window, cx).zoom(2.0));
TimelinePanel::new(self.engine.clone(), timeline, window, cx)
}),
cx,
)),
_ => None,
}
}
}
/// The application root view.
pub struct OakApp {
engine: Entity<MockEngine>,
program_clock: Entity<MockClock>,
timeline: Entity<TimelineView<MockEngine>>,
meter: Entity<AudioLevelMeter<MockEngine>>,
menu_bar: Entity<MenuBar>,
dock: Entity<DockArea>,
status_bar: Entity<StatusBar>,
/// Whether the dark theme is active (toggles via 视图 → 主题).
dark: bool,
}
impl OakApp {
/// Builds the whole shell.
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
apply_theme(cx, &OakTheme::olive_dark());
// --- engine and shared state ---------------------------------------
let engine = cx.new(|cx| MockEngine::demo(cx));
let source_clock = engine.read(cx).source_clock.clone();
let program_clock = engine.read(cx).program_clock.clone();
let timeline = cx.new(|cx| TimelineView::new(engine.clone(), window, cx).zoom(2.0));
let meter = cx.new(|cx| AudioLevelMeter::new(3, engine.clone(), window, cx));
// --- menu bar ------------------------------------------------------
let menu_bar = cx.new(|cx| MenuBar::new(1, make_menus(true), window, cx));
cx.subscribe(
&menu_bar,
|this, _menu: Entity<MenuBar>, event: &MenuBarEvent, cx| {
if let MenuBarEvent::Triggered { item, .. } = event {
this.on_menu(*item, cx);
}
},
)
.detach();
// --- dock ----------------------------------------------------------
let dock = cx.new(|cx| {
DockArea::new(cx).with_registry(Arc::new(AppPanelRegistry {
engine: engine.clone(),
source_clock: source_clock.clone(),
program_clock: program_clock.clone(),
}))
});
let project = cx.new(|cx| ProjectExplorerPanel::new(engine.clone(), window, cx));
let source_viewer =
cx.new(|cx| SourceViewerPanel::new(engine.clone(), source_clock.clone(), window, cx));
let program_viewer = cx.new(|cx| {
ProgramViewerPanel::new(
engine.clone(),
program_clock.clone(),
meter.clone(),
window,
cx,
)
});
let node_editor = cx.new(|cx| NodeEditorPanel::new(engine.clone(), window, cx));
let inspector = cx.new(|cx| InspectorPanel::new(engine.clone(), window, cx));
let history = cx.new(|cx| HistoryPanel::new(window, cx));
let timeline_panel =
cx.new(|cx| TimelinePanel::new(engine.clone(), timeline.clone(), window, cx));
// Arrange the default workspace: the design's 素材查看器 | 序列查看器 |
// 检查器 row (project bin docked on the left), node editor + history
// as tabs, timeline full width at the bottom.
dock.update(cx, |dock, cx| {
dock.add_panel(PanelHandle::new(project, cx), None, cx);
dock.add_panel(
PanelHandle::new(source_viewer, cx),
Some(DropTarget {
panel: Some(PROJECT),
zone: DropZone::Right,
}),
cx,
);
dock.add_panel(
PanelHandle::new(program_viewer, cx),
Some(DropTarget {
panel: Some(SOURCE_VIEWER),
zone: DropZone::Right,
}),
cx,
);
dock.add_panel(
PanelHandle::new(node_editor, cx),
Some(DropTarget {
panel: Some(PROGRAM_VIEWER),
zone: DropZone::Center,
}),
cx,
);
dock.add_panel(
PanelHandle::new(inspector, cx),
Some(DropTarget {
panel: Some(PROGRAM_VIEWER),
zone: DropZone::Right,
}),
cx,
);
dock.add_panel(
PanelHandle::new(history, cx),
Some(DropTarget {
panel: Some(INSPECTOR),
zone: DropZone::Center,
}),
cx,
);
dock.add_panel(
PanelHandle::new(timeline_panel, cx),
Some(DropTarget {
panel: None,
zone: DropZone::Bottom,
}),
cx,
);
});
// Tune the default split ratios: top 70%, project bin 17% of the row.
let mut layout: DockLayout = dock.read(cx).layout().clone();
layout.resize_split(&NodePath(vec![]), 0.70);
layout.resize_split(&NodePath(vec![0]), 0.17);
dock.update(cx, |dock, cx| dock.set_layout(layout, cx));
// --- status bar ----------------------------------------------------
let status_bar = cx.new(|cx| StatusBar::new(engine.clone(), program_clock.clone(), cx));
// Repaint the shell whenever the engine notifies.
cx.observe(&engine, |this, _engine, cx| {
cx.notify();
let _ = this;
})
.detach();
// --- timeline events -----------------------------------------------
// The playhead is driven by the program monitor; seeking the timeline
// (ruler click, keyboard) is routed back to the engine, guarded so
// clock-driven syncs are no-ops.
cx.subscribe(
&timeline,
|this, _timeline, event: &TimelineEvent, cx| match event {
TimelineEvent::PlayheadChanged(frame) => {
let current = this.engine.read(cx).clock_frame(Monitor::Program, cx);
if *frame != current {
this.engine.update(cx, |engine, cx| {
engine.request_frame(Monitor::Program, *frame, cx)
});
}
}
other => println!("[timeline] request: {other:?} (not applied by the mock)"),
},
)
.detach();
// --- tick loop -----------------------------------------------------
// Drives playback clocks, playhead sync and the audio meter at ~60Hz.
let this = cx.weak_entity();
window
.spawn(cx, async move |cx: &mut AsyncWindowContext| loop {
cx.background_executor()
.timer(Duration::from_millis(16))
.await;
let _ = cx.update(|_window, app| {
if let Some(this) = this.upgrade() {
this.update(app, |this, cx| this.tick(cx));
}
});
})
.detach();
Self {
engine,
program_clock,
timeline,
meter,
menu_bar,
dock,
status_bar,
dark: true,
}
}
/// One animation-frame tick: advance the engine, sync the timeline
/// playhead to the program clock, and refresh the audio meter.
fn tick(&mut self, cx: &mut Context<Self>) {
self.engine.update(cx, |engine, cx| engine.tick(cx));
let frame = self.program_clock.read(cx).transport.frame();
self.timeline
.update(cx, |timeline, cx| timeline.seek(frame, cx));
self.meter.update(cx, |meter, cx| meter.update(cx));
cx.notify();
}
/// Routes a menu action.
fn on_menu(&mut self, item: usize, cx: &mut Context<Self>) {
use menu_ids::*;
match item {
PLAY_PAUSE => {
let playing = self.program_clock.read(cx).transport.is_playing();
let monitor = Monitor::Program;
self.engine.update(cx, |engine, cx| {
if playing {
engine.pause(monitor, cx);
} else {
engine.play(monitor, cx);
}
});
}
PREV_FRAME => {
let monitor = Monitor::Program;
self.engine
.update(cx, |engine, cx| engine.step(monitor, -1, cx));
}
NEXT_FRAME => {
let monitor = Monitor::Program;
self.engine
.update(cx, |engine, cx| engine.step(monitor, 1, cx));
}
TO_START => {
let monitor = Monitor::Program;
self.engine.update(cx, |engine, cx| {
engine.request_frame(monitor, Frame::ZERO, cx)
});
}
THEME_DARK => {
self.dark = true;
apply_theme(cx, &OakTheme::olive_dark());
self.rebuild_menu_bar(cx);
cx.notify();
}
THEME_LIGHT => {
self.dark = false;
apply_theme(cx, &OakTheme::olive_light());
self.rebuild_menu_bar(cx);
cx.notify();
}
LANG_ZH => self.switch_language(crate::i18n::Language::ZhCN, cx),
LANG_EN => self.switch_language(crate::i18n::Language::EnUs, cx),
ADD_VIDEO_TRACK => {
let kind = gpui::timeline::TrackKind::Video;
self.engine
.update(cx, |engine, cx| engine.add_track(kind, cx));
}
ADD_AUDIO_TRACK => {
let kind = gpui::timeline::TrackKind::Audio;
self.engine
.update(cx, |engine, cx| engine.add_track(kind, cx));
}
FOCUS_PROJECT => self.focus_panel(PROJECT, cx),
FOCUS_SOURCE_VIEWER => self.focus_panel(SOURCE_VIEWER, cx),
FOCUS_PROGRAM_VIEWER => self.focus_panel(PROGRAM_VIEWER, cx),
FOCUS_NODE_EDITOR => self.focus_panel(NODE_EDITOR, cx),
FOCUS_INSPECTOR => self.focus_panel(INSPECTOR, cx),
FOCUS_HISTORY => self.focus_panel(HISTORY, cx),
FOCUS_TIMELINE => self.focus_panel(TIMELINE, cx),
other => println!("[menu] placeholder action for item {other}"),
}
}
/// Focuses a dock panel (used by the 窗口 menu).
fn focus_panel(&self, id: gpui::dock::PanelId, cx: &mut Context<Self>) {
if let Some(handle) = cx.windows().first() {
let dock = self.dock.clone();
let _ = cx.update_window(*handle, move |_root, window, app| {
dock.update(app, |dock, cx| dock.focus_panel(id, window, cx));
});
}
}
/// Switches the UI language live: updates the [`i18n`] global, rebuilds
/// the menu bar (so the menu labels and the language checkmark move
/// immediately), and repaints the whole shell — every label goes through
/// [`crate::i18n::tr`] at render time, so panels flip without a restart.
fn switch_language(&mut self, language: crate::i18n::Language, cx: &mut Context<Self>) {
crate::i18n::set_language(language);
self.rebuild_menu_bar(cx);
cx.notify();
}
/// Replaces the `MenuBar` entity with one built from the current language
/// and theme, re-subscribing to its trigger events.
fn rebuild_menu_bar(&mut self, cx: &mut Context<Self>) {
let windows = cx.windows();
let Some(handle) = windows.first() else {
return;
};
let dark = self.dark;
let Ok(menu_bar) = cx.update_window(*handle, |_root, window, app| {
app.new(|cx| MenuBar::new(1, make_menus(dark), window, cx))
}) else {
return;
};
self.menu_bar = menu_bar;
let menu_bar = self.menu_bar.clone();
cx.subscribe(
&menu_bar,
|this, _menu: Entity<MenuBar>, event: &MenuBarEvent, cx| {
if let MenuBarEvent::Triggered { item, .. } = event {
this.on_menu(*item, cx);
}
},
)
.detach();
}
}
impl Render for OakApp {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.flex()
.flex_col()
.child(self.menu_bar.clone())
.child(div().flex_1().child(self.dock.clone()))
.child(self.status_bar.clone())
}
}
/// Builds the menu bar entries (文件/编辑/视图/回放/序列/窗口/工具/帮助). All
/// labels come from the [`crate::i18n`] tables, so rebuilding the menu bar
/// after a language switch repaints it in the new language. `dark` drives the
/// theme submenu's checkmark.
fn make_menus(dark: bool) -> Vec<MenuBarEntry> {
use crate::i18n::tr;
use menu_ids::*;
let current = crate::i18n::language();
let theme_submenu = Menu::new(vec![
MenuItem::new(THEME_DARK, tr("menu.view.theme.dark")).with_checked(dark),
MenuItem::new(THEME_LIGHT, tr("menu.view.theme.light")).with_checked(!dark),
]);
let language_submenu = Menu::new(vec![
MenuItem::new(LANG_ZH, tr("menu.view.language.zh"))
.with_checked(current == crate::i18n::Language::ZhCN),
MenuItem::new(LANG_EN, tr("menu.view.language.en"))
.with_checked(current == crate::i18n::Language::EnUs),
]);
vec![
MenuBarEntry::new(
tr("menu.file"),
Menu::new(vec![
MenuItem::new(NEW_PROJECT, tr("menu.file.new_project")).with_shortcut("⌘N"),
MenuItem::new(OPEN_PROJECT, tr("menu.file.open_project")).with_shortcut("⌘O"),
MenuItem::new(SAVE, tr("menu.file.save")).with_shortcut("⌘S").separated(),
MenuItem::new(EXPORT, tr("menu.file.export")).disabled(),
MenuItem::new(QUIT, tr("menu.file.quit")).with_shortcut("⌘Q").separated(),
]),
),
MenuBarEntry::new(
tr("menu.edit"),
Menu::new(vec![
MenuItem::new(UNDO, tr("menu.edit.undo")).with_shortcut("⌘Z"),
MenuItem::new(REDO, tr("menu.edit.redo")).with_shortcut("⇧⌘Z").separated(),
MenuItem::new(CUT, tr("menu.edit.cut")).with_shortcut("⌘X"),
MenuItem::new(COPY, tr("menu.edit.copy")).with_shortcut("⌘C"),
MenuItem::new(PASTE, tr("menu.edit.paste")).with_shortcut("⌘V"),
MenuItem::new(DELETE, tr("menu.edit.delete")).separated(),
]),
),
MenuBarEntry::new(
tr("menu.view"),
Menu::new(vec![
MenuItem::new(THEME_DARK, tr("menu.view.theme")).with_submenu(theme_submenu),
MenuItem::new(LANG_ZH, tr("menu.view.language")).with_submenu(language_submenu),
]),
),
MenuBarEntry::new(
tr("menu.playback"),
Menu::new(vec![
MenuItem::new(PLAY_PAUSE, tr("menu.playback.play_pause")).with_shortcut("空格"),
MenuItem::new(PREV_FRAME, tr("menu.playback.prev_frame")).with_shortcut(""),
MenuItem::new(NEXT_FRAME, tr("menu.playback.next_frame"))
.with_shortcut("")
.separated(),
MenuItem::new(TO_START, tr("menu.playback.to_start")).with_shortcut("Home"),
]),
),
MenuBarEntry::new(
tr("menu.sequence"),
Menu::new(vec![
MenuItem::new(ADD_VIDEO_TRACK, tr("menu.sequence.add_video_track")),
MenuItem::new(ADD_AUDIO_TRACK, tr("menu.sequence.add_audio_track")),
MenuItem::new(503, tr("menu.sequence.settings")).disabled(),
]),
),
MenuBarEntry::new(
tr("menu.window"),
Menu::new(vec![
MenuItem::new(FOCUS_PROJECT, tr("menu.window.project")),
MenuItem::new(FOCUS_SOURCE_VIEWER, tr("menu.window.source_viewer")),
MenuItem::new(FOCUS_PROGRAM_VIEWER, tr("menu.window.program_viewer")),
MenuItem::new(FOCUS_NODE_EDITOR, tr("menu.window.node_editor")),
MenuItem::new(FOCUS_INSPECTOR, tr("menu.window.inspector")),
MenuItem::new(FOCUS_HISTORY, tr("menu.window.history")),
MenuItem::new(FOCUS_TIMELINE, tr("menu.window.timeline")),
]),
),
MenuBarEntry::new(
tr("menu.tools"),
Menu::new(vec![
MenuItem::new(701, tr("menu.tools.select")),
MenuItem::new(702, tr("menu.tools.razor")),
MenuItem::new(703, tr("menu.tools.snap")).with_checked(true),
]),
),
MenuBarEntry::new(
tr("menu.help"),
Menu::new(vec![MenuItem::new(ABOUT, tr("menu.help.about"))]),
),
]
}
/// The crate entry point: applies the olive-dark theme and opens the main
/// window.
pub fn run() {
gpui_platform::application().run(|cx: &mut App| {
// Restore the persisted UI language (oakcommon config `Language` key)
// before the first window renders.
crate::i18n::init();
cx.init_colors();
let bounds = Bounds::centered(None, size(px(1600.0), px(900.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|window, cx| cx.new(|cx| OakApp::new(window, cx)),
)
.expect("failed to open the main window");
cx.activate(true);
cx.on_window_closed(|cx, _| {
if cx.windows().is_empty() {
cx.quit();
}
})
.detach();
});
}
#[cfg(test)]
mod tests {
use super::*;
/// The 视图/View menu carries a 语言/Language submenu whose items are
/// labeled in their own language and whose checkmark follows the active
/// language — and the whole menu bar flips language with `i18n`.
#[test]
fn language_menu_tracks_the_active_language() {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
let view_entry = |dark: bool| -> MenuBarEntry {
make_menus(dark)
.into_iter()
.find(|entry| entry.title == crate::i18n::tr("menu.view"))
.expect("视图/View menu exists")
};
// The language submenu sits under 视图/View.
let language_item = |entry: &MenuBarEntry| -> Menu {
entry
.menu
.items
.iter()
.find(|item| item.id == menu_ids::LANG_ZH)
.map(|item| item.submenu.clone().map(|m| *m).unwrap_or_default())
.expect("语言/Language submenu exists")
};
crate::i18n::set_language(crate::i18n::Language::EnUs);
let submenu = language_item(&view_entry(true));
let zh = submenu
.items
.iter()
.find(|i| i.id == menu_ids::LANG_ZH)
.expect("zh item");
let en = submenu
.items
.iter()
.find(|i| i.id == menu_ids::LANG_EN)
.expect("en item");
assert_eq!(zh.label, "简体中文");
assert_eq!(en.label, "English");
assert_eq!(zh.checked, Some(false));
assert_eq!(en.checked, Some(true), "en-US is active → checked");
crate::i18n::set_language(crate::i18n::Language::ZhCN);
let submenu = language_item(&view_entry(true));
let zh = submenu
.items
.iter()
.find(|i| i.id == menu_ids::LANG_ZH)
.expect("zh item");
let en = submenu
.items
.iter()
.find(|i| i.id == menu_ids::LANG_EN)
.expect("en item");
assert_eq!(zh.checked, Some(true), "zh-CN is active → checked");
assert_eq!(en.checked, Some(false));
// The menu titles themselves are localized.
assert_eq!(view_entry(true).title, "视图(V)");
crate::i18n::set_language(crate::i18n::Language::EnUs);
assert_eq!(view_entry(true).title, "View(V)");
}
/// The theme submenu's checkmark follows the `dark` flag.
#[test]
fn theme_menu_checkmark_follows_dark_flag() {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
let dark_item = |dark: bool| -> gpui_widgets::menu::MenuItem {
let entries = make_menus(dark);
let view = entries
.iter()
.find(|entry| entry.title == crate::i18n::tr("menu.view"))
.expect("视图/View menu");
let theme = view
.menu
.items
.iter()
.find(|i| i.id == menu_ids::THEME_DARK)
.and_then(|i| i.submenu.clone())
.expect("theme submenu");
theme
.items
.into_iter()
.find(|i| i.id == menu_ids::THEME_DARK)
.expect("Olive Dark item")
};
assert_eq!(dark_item(true).checked, Some(true));
assert_eq!(dark_item(false).checked, Some(false));
}
}
-6
View File
@@ -1,6 +0,0 @@
add_subdirectory(src)
add_subdirectory(c_api)
if(BUILD_TESTS)
add_subdirectory(tests)
endif()
-24
View File
@@ -1,24 +0,0 @@
# 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/>.
target_sources(oakaudio PRIVATE
alive.cpp
levelmeter.cpp
manager.cpp
processor.cpp
sync.cpp
waveform.cpp
)
-49
View File
@@ -1,49 +0,0 @@
/***
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/>.
***/
#include <atomic>
#include "audio/error.h"
#include "audio/manager.h"
namespace
{
std::atomic<int> g_alive{ 0 };
}
namespace oakaudio
{
void alive_inc()
{
g_alive.fetch_add(1, std::memory_order_relaxed);
}
void alive_dec()
{
g_alive.fetch_sub(1, std::memory_order_relaxed);
}
}
extern "C" int oakaudio_debug_alive_count(void)
{
return g_alive.load(std::memory_order_relaxed);
}
-84
View File
@@ -1,84 +0,0 @@
/***
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/>.
***/
#include "audio/levelmeter.h"
#include <cstring>
#include "audiolevelmeter.h"
#include "ffmpeg_bridge/ffmpeg_bridge.h"
using olive::AudioLevelMeter;
using olive::core::AudioParams;
using olive::core::Rational;
using olive::core::SampleBuffer;
using olive::core::SampleFormat;
extern "C" int oakaudio_levelmeter_analyze(const float *const *planar,
int channel_count, int frame_count,
oakaudio_channel_stats *channels, int channels_capacity,
oakaudio_meter_stats *summary)
{
if (!planar || channel_count <= 0 || frame_count < 0 ||
(channels && channels_capacity < channel_count)) {
return OAKAUDIO_E_INVALID;
}
if (!channels && !summary) {
return OAKAUDIO_E_INVALID;
}
// Repack into a SampleBuffer (planar f32) for the C++ implementation.
AudioParams params(48000, fb_channel_layout_default(channel_count),
SampleFormat(SampleFormat::f32_p));
SampleBuffer buffer(params, Rational(frame_count, 48000));
for (int ch = 0; ch < channel_count; ch++) {
if (!planar[ch]) {
return OAKAUDIO_E_INVALID;
}
if (frame_count > 0) {
memcpy(buffer.data(ch), planar[ch],
size_t(frame_count) * sizeof(float));
}
}
const AudioLevelMeter::Stats stats =
AudioLevelMeter::analyze_sample_buffer(buffer);
if (channels) {
for (int ch = 0; ch < channel_count; ch++) {
const AudioLevelMeter::ChannelStats &s =
stats.channels[size_t(ch)];
oakaudio_channel_stats &dst = channels[ch];
dst.peak_linear = s.peak_linear;
dst.peak_db = s.peak_db;
dst.rms_linear = s.rms_linear;
dst.rms_db = s.rms_db;
dst.vu_db = s.vu_db;
}
}
if (summary) {
summary->max_peak_linear = stats.max_peak_linear;
summary->integrated_lufs = stats.integrated_lufs;
summary->silence = stats.silence ? 1 : 0;
}
return OAKAUDIO_OK;
}
-280
View File
@@ -1,280 +0,0 @@
/***
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/>.
***/
#include "audio/manager.h"
#include <cstring>
#include "audiomanager.h"
using olive::AudioManager;
using olive::core::AudioParams;
using olive::core::SampleFormat;
namespace
{
// Singleton semantics (mirrors oakcommon's OakCurrent): the ctx points to
// the process-wide instance, so addref/release never destroy anything.
void singleton_addref(void *ctx)
{
(void) ctx;
}
void singleton_release(void *ctx)
{
(void) ctx;
}
OakAudioManager wrap(AudioManager *m)
{
OakAudioManager h = {};
h.ctx = m;
h.addref = &singleton_addref;
h.release = &singleton_release;
h.abi_version = OAKAUDIO_ABI_VERSION;
return h;
}
AudioManager *impl(OakAudioManager self)
{
return static_cast<AudioManager *>(self.ctx);
}
int write_error(const std::string &s, char *buf, int buf_size)
{
if (buf && buf_size > 0) {
const int n = std::min(int(s.size()), buf_size - 1);
std::memcpy(buf, s.data(), size_t(n));
buf[n] = '\0';
}
return int(s.size()) + 1;
}
} // namespace
extern "C" int oakaudio_manager_create_instance(void)
{
if (!AudioManager::instance()) {
try {
AudioManager::create_instance();
} catch (...) {
return OAKAUDIO_E_NOMEM;
}
}
return AudioManager::instance() ? OAKAUDIO_OK : OAKAUDIO_E_NOMEM;
}
extern "C" void oakaudio_manager_destroy_instance(void)
{
AudioManager::destroy_instance();
}
extern "C" OakAudioManager oakaudio_manager_instance(void)
{
return wrap(AudioManager::instance());
}
extern "C" void oakaudio_manager_free(OakAudioManager *self)
{
// Singleton: releasing never destroys; just clear the caller's copy.
if (self) {
self->ctx = nullptr;
}
}
extern "C" int oakaudio_manager_set_output_notify_interval(
OakAudioManager self, int64_t bytes)
{
AudioManager *m = impl(self);
if (!m) {
return OAKAUDIO_E_STATE;
}
if (bytes < 0) {
return OAKAUDIO_E_INVALID;
}
m->set_output_notify_interval(bytes);
return OAKAUDIO_OK;
}
extern "C" int oakaudio_manager_push_to_output(OakAudioManager self,
int rate, uint64_t layout, int format,
const char *samples, int64_t samples_size,
char *error_buf, int error_buf_size)
{
AudioManager *m = impl(self);
if (!m) {
return OAKAUDIO_E_STATE;
}
if (rate <= 0 || !samples || samples_size < 0) {
return OAKAUDIO_E_INVALID;
}
const AudioParams params(rate, layout,
SampleFormat(SampleFormat::Format(format)));
std::string error;
if (!m->push_to_output(params, samples, samples_size, &error)) {
if (error_buf && error_buf_size > 0) {
write_error(error, error_buf, error_buf_size);
}
return OAKAUDIO_E_FAILED;
}
return OAKAUDIO_OK;
}
extern "C" int oakaudio_manager_clear_buffered_output(OakAudioManager self)
{
AudioManager *m = impl(self);
if (!m) {
return OAKAUDIO_E_STATE;
}
m->clear_buffered_output();
return OAKAUDIO_OK;
}
extern "C" int oakaudio_manager_stop_output(OakAudioManager self)
{
AudioManager *m = impl(self);
if (!m) {
return OAKAUDIO_E_STATE;
}
m->stop_output();
return OAKAUDIO_OK;
}
extern "C" int oakaudio_manager_seconds(OakAudioManager self, double *out)
{
AudioManager *m = impl(self);
if (!m) {
return OAKAUDIO_E_STATE;
}
if (!out) {
return OAKAUDIO_E_INVALID;
}
*out = m->seconds();
return OAKAUDIO_OK;
}
extern "C" int oakaudio_manager_reset_output_clock(OakAudioManager self)
{
AudioManager *m = impl(self);
if (!m) {
return OAKAUDIO_E_STATE;
}
m->reset_output_clock();
return OAKAUDIO_OK;
}
extern "C" int oakaudio_manager_get_output_device(OakAudioManager self)
{
AudioManager *m = impl(self);
if (!m) {
return OAKAUDIO_E_STATE;
}
return int(m->get_output_device());
}
extern "C" int oakaudio_manager_set_output_device(OakAudioManager self,
int device)
{
AudioManager *m = impl(self);
if (!m) {
return OAKAUDIO_E_STATE;
}
m->set_output_device(PaDeviceIndex(device));
return OAKAUDIO_OK;
}
extern "C" int oakaudio_manager_get_input_device(OakAudioManager self)
{
AudioManager *m = impl(self);
if (!m) {
return OAKAUDIO_E_STATE;
}
return int(m->get_input_device());
}
extern "C" int oakaudio_manager_set_input_device(OakAudioManager self,
int device)
{
AudioManager *m = impl(self);
if (!m) {
return OAKAUDIO_E_STATE;
}
m->set_input_device(PaDeviceIndex(device));
return OAKAUDIO_OK;
}
extern "C" int oakaudio_manager_hard_reset(OakAudioManager self)
{
AudioManager *m = impl(self);
if (!m) {
return OAKAUDIO_E_STATE;
}
m->hard_reset();
return OAKAUDIO_OK;
}
extern "C" int oakaudio_manager_start_recording(OakAudioManager self,
const oakcodec_encoding_params *params,
char *error_buf, int error_buf_size)
{
AudioManager *m = impl(self);
if (!m) {
return OAKAUDIO_E_STATE;
}
if (!params || !params->audio_enabled) {
return OAKAUDIO_E_INVALID;
}
std::string error;
if (!m->start_recording(*params, &error)) {
if (error_buf && error_buf_size > 0) {
write_error(error, error_buf, error_buf_size);
}
return OAKAUDIO_E_FAILED;
}
return OAKAUDIO_OK;
}
extern "C" int oakaudio_manager_stop_recording(OakAudioManager self)
{
AudioManager *m = impl(self);
if (!m) {
return OAKAUDIO_E_STATE;
}
m->stop_recording();
return OAKAUDIO_OK;
}
extern "C" int oakaudio_manager_find_config_device_by_name_s(
int is_output_device)
{
return int(AudioManager::find_config_device_by_name(is_output_device != 0));
}
extern "C" int oakaudio_manager_find_device_by_name_s(const char *name,
int is_output_device)
{
if (!name) {
return OAKAUDIO_E_INVALID;
}
return int(AudioManager::find_device_by_name(name, is_output_device != 0));
}
-148
View File
@@ -1,148 +0,0 @@
/***
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/>.
***/
#include "audio/processor.h"
#include <cstring>
#include <vector>
#include "audioprocessor.h"
#include "refcounted.h"
using olive::AudioProcessor;
using olive::core::AudioParams;
using olive::core::SampleFormat;
extern "C" OakAudioProcessor oakaudio_processor_init(void)
{
return oakaudio::make_handle_in_place<OakAudioProcessor, AudioProcessor>();
}
extern "C" void oakaudio_processor_free(OakAudioProcessor *self)
{
oakaudio::free_handle(self);
}
extern "C" int oakaudio_processor_open(OakAudioProcessor self,
int in_rate, uint64_t in_layout, int in_format,
int out_rate, uint64_t out_layout, int out_format, double speed)
{
AudioProcessor *p = oakaudio::handle_impl<AudioProcessor>(self.ctx);
if (!p) {
return OAKAUDIO_E_INVALID;
}
if (p->is_open()) {
return OAKAUDIO_E_STATE;
}
if (in_rate <= 0 || out_rate <= 0 || speed <= 0.0) {
return OAKAUDIO_E_INVALID;
}
// The C ABI delivers planar float output only; force the output format
// stage to f32p (see OAKAUDIO_PROCESSOR_OUTPUT_FORMAT).
if (out_format != OAKAUDIO_PROCESSOR_OUTPUT_FORMAT) {
return OAKAUDIO_E_INVALID;
}
const AudioParams from(in_rate, in_layout,
SampleFormat(SampleFormat::Format(in_format)));
const AudioParams to(out_rate, out_layout,
SampleFormat(SampleFormat::Format(out_format)));
return p->open(from, to, speed) ? OAKAUDIO_OK : OAKAUDIO_E_FAILED;
}
extern "C" int oakaudio_processor_close(OakAudioProcessor self)
{
AudioProcessor *p = oakaudio::handle_impl<AudioProcessor>(self.ctx);
if (!p) {
return OAKAUDIO_E_INVALID;
}
p->close();
return OAKAUDIO_OK;
}
extern "C" int oakaudio_processor_is_open(OakAudioProcessor self)
{
AudioProcessor *p = oakaudio::handle_impl<AudioProcessor>(self.ctx);
if (!p) {
return OAKAUDIO_E_INVALID;
}
return p->is_open() ? 1 : 0;
}
extern "C" int oakaudio_processor_convert(OakAudioProcessor self,
const float *const *in_planar, int in_frame_count,
float *const *out_planar, int out_capacity_frames)
{
AudioProcessor *p = oakaudio::handle_impl<AudioProcessor>(self.ctx);
if (!p) {
return OAKAUDIO_E_INVALID;
}
if (!p->is_open()) {
return OAKAUDIO_E_STATE;
}
if (in_frame_count < 0 || out_capacity_frames < 0 ||
(in_frame_count > 0 && !in_planar)) {
return OAKAUDIO_E_INVALID;
}
const int channels = p->to().channel_count();
if (channels <= 0) {
return OAKAUDIO_E_STATE;
}
AudioProcessor::Buffer buf;
int r = p->convert(const_cast<float **>(in_planar), in_frame_count,
out_planar ? &buf : nullptr);
if (r < 0) {
return OAKAUDIO_E_FAILED;
}
if (!out_planar) {
return 0;
}
// Output is planar f32 (enforced by open()); each buffer entry is one
// channel's float plane.
const int out_frames = buf.empty() ? 0 :
int(buf[0].size() / sizeof(float));
const int frames = std::min(out_frames, out_capacity_frames);
for (int ch = 0; ch < channels && ch < int(buf.size()); ch++) {
if (out_planar[ch]) {
memcpy(out_planar[ch], buf[size_t(ch)].data(),
size_t(frames) * sizeof(float));
}
}
return frames;
}
extern "C" int oakaudio_processor_flush(OakAudioProcessor self)
{
AudioProcessor *p = oakaudio::handle_impl<AudioProcessor>(self.ctx);
if (!p) {
return OAKAUDIO_E_INVALID;
}
if (!p->is_open()) {
return OAKAUDIO_E_STATE;
}
p->flush();
return OAKAUDIO_OK;
}
-124
View File
@@ -1,124 +0,0 @@
/***
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/>.
***/
#ifndef OAKAUDIO_C_API_REFCOUNTED_H
#define OAKAUDIO_C_API_REFCOUNTED_H
#include <atomic>
#include <cstdint>
#include <type_traits>
#include <utility>
#include "audio/error.h"
namespace oakaudio
{
/**
* @brief Heap box behind every handle's ctx pointer.
*
* Same pattern as oakcodec's c_api/refcounted.h: holds the wrapped
* object plus its atomic reference count. addref and release are emitted
* per boxed type so that the function pointers stored in a handle always
* run code from the DLL that created the object. Every box also
* participates in the oakaudio_debug_alive_count() ledger.
*/
template <typename T> struct RefCounted {
T impl;
std::atomic<uint32_t> refs;
template <typename... Args>
explicit RefCounted(Args &&...args)
: impl(std::forward<Args>(args)...)
, refs(1)
{
}
};
template <typename T> void ref_counted_addref(void *ctx)
{
auto *box = static_cast<RefCounted<T> *>(ctx);
if (box)
box->refs.fetch_add(1, std::memory_order_relaxed);
}
void alive_inc();
void alive_dec();
template <typename T> void ref_counted_release(void *ctx)
{
auto *box = static_cast<RefCounted<T> *>(ctx);
if (box && box->refs.fetch_sub(1, std::memory_order_acq_rel) == 1) {
delete box;
alive_dec();
}
}
/**
* @brief Build a by-value handle owning a freshly boxed object (count 1).
*
* On allocation failure the returned handle has ctx == NULL (all C API
* functions treat that as OAKAUDIO_E_INVALID and free() as a no-op).
*/
template <typename Handle, typename T, typename... Args>
Handle make_handle_in_place(Args &&...args)
{
Handle h = {};
try {
h.ctx = new RefCounted<T>(std::forward<Args>(args)...);
alive_inc();
} catch (...) {
h.ctx = nullptr;
}
h.addref = &ref_counted_addref<T>;
h.release = &ref_counted_release<T>;
h.abi_version = OAKAUDIO_ABI_VERSION;
return h;
}
template <typename Handle, typename T> Handle make_handle(T &&value)
{
return make_handle_in_place<Handle, typename std::decay<T>::type>(
std::forward<T>(value));
}
/**
* @brief Recover the boxed object from a handle ctx (NULL-safe).
*/
template <typename T> T *handle_impl(void *ctx)
{
auto *box = static_cast<RefCounted<T> *>(ctx);
return box ? &box->impl : nullptr;
}
/**
* @brief Shared free() body: release the ctx, no-op on NULL/empty handle.
*/
template <typename Handle> void free_handle(Handle *h)
{
if (!h || !h->ctx || !h->release)
return;
h->release(h->ctx);
h->ctx = nullptr;
}
} // namespace oakaudio
#endif // OAKAUDIO_C_API_REFCOUNTED_H
-206
View File
@@ -1,206 +0,0 @@
/***
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/>.
***/
#include "audio/sync.h"
#include <cstring>
#include <vector>
#include "audiosynchronizer.h"
#include "audiowaveformsync.h"
#include "ffmpeg_bridge/ffmpeg_bridge.h"
using olive::AudioSynchronizer;
using olive::AudioWaveformSync;
using olive::core::AudioParams;
using olive::core::Rational;
using olive::core::SampleBuffer;
using olive::core::SampleFormat;
namespace
{
std::vector<char> to_mask(const uint8_t *valid, int len)
{
std::vector<char> mask;
if (valid) {
mask.resize(size_t(len));
for (int i = 0; i < len; i++) {
mask[size_t(i)] = valid[i] ? 1 : 0;
}
}
return mask;
}
} // namespace
extern "C" int oakaudio_sync_extract_rms_envelope(
const float *const *planar, int channel_count, int frame_count,
uint64_t window_samples, double *out, int capacity)
{
if (!planar || channel_count <= 0 || frame_count < 0 ||
!window_samples || capacity < 0) {
return OAKAUDIO_E_INVALID;
}
AudioParams params(48000, fb_channel_layout_default(channel_count),
SampleFormat(SampleFormat::f32_p));
SampleBuffer buffer(params, Rational(frame_count, 48000));
for (int ch = 0; ch < channel_count; ch++) {
if (!planar[ch]) {
return OAKAUDIO_E_INVALID;
}
if (frame_count > 0) {
memcpy(buffer.data(ch), planar[ch],
size_t(frame_count) * sizeof(float));
}
}
const std::vector<double> envelope =
AudioWaveformSync::extract_rms_envelope(buffer, window_samples);
const int windows = int(envelope.size());
if (!out || capacity < windows) {
return windows;
}
memcpy(out, envelope.data(), size_t(windows) * sizeof(double));
return windows;
}
extern "C" int oakaudio_sync_estimate_envelope_offset(
const double *reference, int reference_len,
const double *candidate, int candidate_len,
const uint8_t *reference_valid, const uint8_t *candidate_valid,
uint64_t window_samples, int64_t max_offset_windows,
oakaudio_offset_result *out)
{
if (!out || !reference || !candidate || reference_len <= 0 ||
candidate_len <= 0 || !window_samples || max_offset_windows < 0) {
return OAKAUDIO_E_INVALID;
}
const std::vector<double> ref(reference, reference + reference_len);
const std::vector<double> cand(candidate, candidate + candidate_len);
const std::vector<char> ref_valid = to_mask(reference_valid, reference_len);
const std::vector<char> cand_valid =
to_mask(candidate_valid, candidate_len);
const AudioWaveformSync::OffsetResult r =
AudioWaveformSync::estimate_envelope_offset(
ref, cand, ref_valid, cand_valid, window_samples,
max_offset_windows);
out->offset_samples = r.offset_samples;
out->confidence = r.confidence;
out->valid = r.valid ? 1 : 0;
return OAKAUDIO_OK;
}
extern "C" int oakaudio_sync_estimate_stretch_and_offset(
const double *reference, int reference_len,
const double *candidate, int candidate_len,
const uint8_t *reference_valid, const uint8_t *candidate_valid,
uint64_t window_samples, int64_t max_offset_windows,
double min_rate, double max_rate, double rate_step,
oakaudio_stretch_offset_result *out)
{
if (!out || !reference || !candidate || reference_len <= 0 ||
candidate_len <= 0 || !window_samples || max_offset_windows < 0 ||
min_rate <= 0.0 || max_rate < min_rate || rate_step <= 0.0) {
return OAKAUDIO_E_INVALID;
}
const std::vector<double> ref(reference, reference + reference_len);
const std::vector<double> cand(candidate, candidate + candidate_len);
const std::vector<char> ref_valid = to_mask(reference_valid, reference_len);
const std::vector<char> cand_valid =
to_mask(candidate_valid, candidate_len);
const AudioWaveformSync::StretchOffsetResult r =
AudioWaveformSync::estimate_stretch_and_offset(
ref, cand, ref_valid, cand_valid, window_samples,
max_offset_windows, min_rate, max_rate, rate_step);
out->rate = r.rate;
out->offset_samples = r.offset_samples;
out->confidence = r.confidence;
out->valid = r.valid ? 1 : 0;
return OAKAUDIO_OK;
}
extern "C" int oakaudio_sync_place_by_source_time(
const oakaudio_source_clip *reference,
const oakaudio_source_clip *candidate,
int64_t reference_timeline_in_num, int64_t reference_timeline_in_den,
int64_t *out_num, int64_t *out_den, int *out_valid)
{
if (!reference || !candidate || !out_num || !out_den || !out_valid ||
reference->source_start_time_den == 0 ||
reference->media_in_den == 0 ||
candidate->source_start_time_den == 0 ||
candidate->media_in_den == 0 || reference_timeline_in_den == 0) {
return OAKAUDIO_E_INVALID;
}
AudioSynchronizer::SourceClip ref;
ref.source_start_time = Rational(int(reference->source_start_time_num),
int(reference->source_start_time_den));
ref.media_in = Rational(int(reference->media_in_num),
int(reference->media_in_den));
ref.has_source_start_time = reference->has_source_start_time != 0;
AudioSynchronizer::SourceClip cand;
cand.source_start_time = Rational(int(candidate->source_start_time_num),
int(candidate->source_start_time_den));
cand.media_in = Rational(int(candidate->media_in_num),
int(candidate->media_in_den));
cand.has_source_start_time = candidate->has_source_start_time != 0;
const AudioSynchronizer::Placement p = AudioSynchronizer::place_by_source_time(
ref, cand,
Rational(int(reference_timeline_in_num),
int(reference_timeline_in_den)));
*out_num = p.timeline_in.numerator();
*out_den = p.timeline_in.denominator();
*out_valid = p.valid ? 1 : 0;
return OAKAUDIO_OK;
}
extern "C" int oakaudio_sync_place_by_waveform_offset(
int64_t reference_timeline_in_num, int64_t reference_timeline_in_den,
int64_t candidate_offset_samples, int sample_rate,
int64_t *out_num, int64_t *out_den, int *out_valid)
{
if (!out_num || !out_den || !out_valid ||
reference_timeline_in_den == 0) {
return OAKAUDIO_E_INVALID;
}
const AudioSynchronizer::Placement p =
AudioSynchronizer::place_by_waveform_offset(
Rational(int(reference_timeline_in_num),
int(reference_timeline_in_den)),
candidate_offset_samples, sample_rate);
*out_num = p.timeline_in.numerator();
*out_den = p.timeline_in.denominator();
*out_valid = p.valid ? 1 : 0;
return OAKAUDIO_OK;
}
-554
View File
@@ -1,554 +0,0 @@
/***
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/>.
***/
#include "audio/waveform.h"
#include <algorithm>
#include <cstring>
#include <vector>
#include "audiovisualwaveform.h"
#include "codec/decoder.h"
#include "ffmpeg_bridge/ffmpeg_bridge.h"
#include "olive/core/render/samplebuffer.h"
#include "refcounted.h"
using olive::AudioVisualWaveform;
using olive::core::AudioParams;
using olive::core::Rational;
using olive::core::SampleBuffer;
using olive::core::SampleFormat;
namespace
{
AudioVisualWaveform::SamplePerChannel *as_pairs(oakaudio_min_max *p)
{
static_assert(sizeof(oakaudio_min_max) ==
sizeof(AudioVisualWaveform::SamplePerChannel),
"POD layout mismatch");
return reinterpret_cast<AudioVisualWaveform::SamplePerChannel *>(p);
}
const AudioVisualWaveform::SamplePerChannel *
as_pairs_const(const oakaudio_min_max *p)
{
return reinterpret_cast<const AudioVisualWaveform::SamplePerChannel *>(p);
}
bool make_rational(int64_t num, int64_t den, Rational *out)
{
if (den == 0) {
return false;
}
*out = Rational(int(num), int(den));
return true;
}
/* ---- oakaudio_waveform_extract() helpers --------------------------------- */
#define OAKAUDIO_EXTRACT_MAX_CHANNELS 64
using PendingPlanes = std::vector<std::vector<float>>;
void append_pending(PendingPlanes &pending, FBFrame *frame, int channels,
int nb)
{
if (pending.empty()) {
pending.resize(size_t(channels));
}
for (int ch = 0; ch < channels; ch++) {
const float *data =
reinterpret_cast<const float *>(fb_frame_get_data(frame, ch));
std::vector<float> &plane = pending[size_t(ch)];
plane.insert(plane.end(), data, data + nb);
}
}
// Emit one point per samples_per_point pending samples. With `flush`, a
// trailing partial point is emitted too.
void emit_points(int channels, int samples_per_point, PendingPlanes &pending,
std::vector<oakaudio_min_max> &points, bool flush)
{
if (pending.empty()) {
return;
}
while (true) {
const size_t available = pending[0].size();
if (available == 0 ||
(!flush && available < size_t(samples_per_point))) {
return;
}
const size_t n = std::min(available, size_t(samples_per_point));
const size_t point = points.size() / size_t(channels);
points.resize(points.size() + size_t(channels));
for (int ch = 0; ch < channels; ch++) {
std::vector<float> &plane = pending[size_t(ch)];
float mn = plane[0];
float mx = mn;
for (size_t i = 1; i < n; i++) {
mn = std::min(mn, plane[i]);
mx = std::max(mx, plane[i]);
}
oakaudio_min_max &dst =
points[point * size_t(channels) + size_t(ch)];
dst.min = mn;
dst.max = mx;
plane.erase(plane.begin(), plane.begin() + ptrdiff_t(n));
}
}
}
int drain_graph(FBAudioGraph *graph, FBFrame *converted, int channels,
int samples_per_point, PendingPlanes &pending,
std::vector<oakaudio_min_max> &points)
{
while (true) {
const int pull = fb_audio_graph_pull(graph, converted);
if (pull < 0) {
return OAKAUDIO_E_FAILED;
}
if (pull == 0) {
return OAKAUDIO_OK;
}
append_pending(pending, converted, channels,
fb_frame_get_nb_samples(converted));
emit_points(channels, samples_per_point, pending, points, false);
}
}
void flush_points(int channels, int samples_per_point, PendingPlanes &pending,
std::vector<oakaudio_min_max> &points)
{
emit_points(channels, samples_per_point, pending, points, true);
}
} // namespace
extern "C" OakAudioWaveform oakaudio_waveform_init(void)
{
return oakaudio::make_handle_in_place<OakAudioWaveform,
AudioVisualWaveform>();
}
extern "C" void oakaudio_waveform_free(OakAudioWaveform *self)
{
oakaudio::free_handle(self);
}
extern "C" int oakaudio_waveform_get_channel_count(OakAudioWaveform self)
{
AudioVisualWaveform *w =
oakaudio::handle_impl<AudioVisualWaveform>(self.ctx);
if (!w) {
return OAKAUDIO_E_INVALID;
}
return w->channel_count();
}
extern "C" int oakaudio_waveform_set_channel_count(OakAudioWaveform self,
int channels)
{
AudioVisualWaveform *w =
oakaudio::handle_impl<AudioVisualWaveform>(self.ctx);
if (!w) {
return OAKAUDIO_E_INVALID;
}
if (channels < 0) {
return OAKAUDIO_E_INVALID;
}
w->set_channel_count(channels);
return OAKAUDIO_OK;
}
extern "C" int oakaudio_waveform_length(OakAudioWaveform self,
int64_t *num, int64_t *den)
{
AudioVisualWaveform *w =
oakaudio::handle_impl<AudioVisualWaveform>(self.ctx);
if (!w) {
return OAKAUDIO_E_INVALID;
}
if (!num || !den) {
return OAKAUDIO_E_INVALID;
}
*num = w->length().numerator();
*den = w->length().denominator();
return OAKAUDIO_OK;
}
extern "C" int oakaudio_waveform_overwrite_samples(OakAudioWaveform self,
const float *const *planar, int frame_count, int sample_rate,
int64_t start_num, int64_t start_den)
{
AudioVisualWaveform *w =
oakaudio::handle_impl<AudioVisualWaveform>(self.ctx);
if (!w) {
return OAKAUDIO_E_INVALID;
}
Rational start;
if (!planar || frame_count <= 0 || sample_rate <= 0 ||
!make_rational(start_num, start_den, &start)) {
return OAKAUDIO_E_INVALID;
}
const int channels = w->channel_count();
if (channels <= 0) {
return OAKAUDIO_E_STATE;
}
// Repack the caller's planes into a SampleBuffer (planar f32).
AudioParams params(sample_rate, fb_channel_layout_default(channels),
SampleFormat(SampleFormat::f32_p));
SampleBuffer buffer(params, Rational(frame_count, sample_rate));
for (int ch = 0; ch < channels; ch++) {
if (!planar[ch]) {
return OAKAUDIO_E_INVALID;
}
}
for (int ch = 0; ch < channels; ch++) {
memcpy(buffer.data(ch), planar[ch],
size_t(frame_count) * sizeof(float));
}
w->overwrite_samples(buffer, sample_rate, start);
return OAKAUDIO_OK;
}
extern "C" int oakaudio_waveform_overwrite_sums(OakAudioWaveform self,
OakAudioWaveform src,
int64_t dest_num, int64_t dest_den,
int64_t offset_num, int64_t offset_den,
int64_t length_num, int64_t length_den)
{
AudioVisualWaveform *w =
oakaudio::handle_impl<AudioVisualWaveform>(self.ctx);
AudioVisualWaveform *other =
oakaudio::handle_impl<AudioVisualWaveform>(src.ctx);
if (!w || !other) {
return OAKAUDIO_E_INVALID;
}
Rational dest, offset, length;
if (!make_rational(dest_num, dest_den, &dest) ||
!make_rational(offset_num, offset_den, &offset) ||
!make_rational(length_num, length_den, &length)) {
return OAKAUDIO_E_INVALID;
}
w->overwrite_sums(*other, dest, offset, length);
return OAKAUDIO_OK;
}
extern "C" int oakaudio_waveform_overwrite_silence(OakAudioWaveform self,
int64_t start_num, int64_t start_den,
int64_t length_num, int64_t length_den)
{
AudioVisualWaveform *w =
oakaudio::handle_impl<AudioVisualWaveform>(self.ctx);
if (!w) {
return OAKAUDIO_E_INVALID;
}
Rational start, length;
if (!make_rational(start_num, start_den, &start) ||
!make_rational(length_num, length_den, &length)) {
return OAKAUDIO_E_INVALID;
}
w->overwrite_silence(start, length);
return OAKAUDIO_OK;
}
extern "C" int oakaudio_waveform_trim_in(OakAudioWaveform self,
int64_t length_num, int64_t length_den)
{
AudioVisualWaveform *w =
oakaudio::handle_impl<AudioVisualWaveform>(self.ctx);
if (!w) {
return OAKAUDIO_E_INVALID;
}
Rational length;
if (!make_rational(length_num, length_den, &length)) {
return OAKAUDIO_E_INVALID;
}
w->trim_in(length);
return OAKAUDIO_OK;
}
extern "C" int oakaudio_waveform_resize(OakAudioWaveform self,
int64_t length_num, int64_t length_den)
{
AudioVisualWaveform *w =
oakaudio::handle_impl<AudioVisualWaveform>(self.ctx);
if (!w) {
return OAKAUDIO_E_INVALID;
}
Rational length;
if (!make_rational(length_num, length_den, &length) || length < 0) {
return OAKAUDIO_E_INVALID;
}
w->resize(length);
return OAKAUDIO_OK;
}
extern "C" int oakaudio_waveform_trim_range(OakAudioWaveform self,
int64_t in_num, int64_t in_den,
int64_t length_num, int64_t length_den)
{
AudioVisualWaveform *w =
oakaudio::handle_impl<AudioVisualWaveform>(self.ctx);
if (!w) {
return OAKAUDIO_E_INVALID;
}
Rational in, length;
if (!make_rational(in_num, in_den, &in) ||
!make_rational(length_num, length_den, &length)) {
return OAKAUDIO_E_INVALID;
}
w->trim_range(in, length);
return OAKAUDIO_OK;
}
extern "C" int oakaudio_waveform_get_summary(OakAudioWaveform self,
int64_t start_num, int64_t start_den,
int64_t length_num, int64_t length_den,
oakaudio_min_max *out_pairs, int capacity_points)
{
AudioVisualWaveform *w =
oakaudio::handle_impl<AudioVisualWaveform>(self.ctx);
if (!w) {
return OAKAUDIO_E_INVALID;
}
Rational start, length;
if (!make_rational(start_num, start_den, &start) ||
!make_rational(length_num, length_den, &length) || length <= 0 ||
capacity_points < 0) {
return OAKAUDIO_E_INVALID;
}
// Points are produced at the length scale: one point per channel per
// `length`-sized window covering [start, start+length) — i.e. exactly
// one point, matching AudioVisualWaveform::get_summary_from_time().
AudioVisualWaveform::Sample summary =
w->get_summary_from_time(start, length);
const int points = int(summary.size()) /
std::max(1, w->channel_count());
if (!out_pairs || capacity_points < points) {
return points;
}
memcpy(out_pairs, summary.data(),
summary.size() * sizeof(oakaudio_min_max));
return points;
}
extern "C" int oakaudio_waveform_sum_samples_s(const float *const *planar,
int channel_count, int start_index, int length,
oakaudio_min_max *out)
{
if (!planar || !out || channel_count <= 0 || start_index < 0 ||
length <= 0) {
return OAKAUDIO_E_INVALID;
}
AudioParams params(48000, fb_channel_layout_default(channel_count),
SampleFormat(SampleFormat::f32_p));
SampleBuffer buffer(params, Rational(length + start_index, 48000));
for (int ch = 0; ch < channel_count; ch++) {
if (!planar[ch]) {
return OAKAUDIO_E_INVALID;
}
memcpy(buffer.data(ch) + start_index, planar[ch],
size_t(length) * sizeof(float));
}
AudioVisualWaveform::Sample summary = AudioVisualWaveform::sum_samples(
buffer, size_t(start_index), size_t(length));
if (int(summary.size()) < channel_count) {
return OAKAUDIO_E_FAILED;
}
memcpy(out, summary.data(),
size_t(channel_count) * sizeof(oakaudio_min_max));
return OAKAUDIO_OK;
}
extern "C" int oakaudio_waveform_re_sum_s(const oakaudio_min_max *in,
int nb_entries, int nb_channels, oakaudio_min_max *out)
{
if (!in || !out || nb_entries <= 0 || nb_channels <= 0) {
return OAKAUDIO_E_INVALID;
}
AudioVisualWaveform::Sample summary = AudioVisualWaveform::re_sum_samples(
as_pairs_const(in), size_t(nb_entries), nb_channels);
memcpy(out, summary.data(),
size_t(nb_channels) * sizeof(oakaudio_min_max));
return OAKAUDIO_OK;
}
extern "C" int oakaudio_waveform_extract(const char *filename,
int stream_index, int samples_per_point,
oakaudio_min_max *out_pairs, int capacity_points,
int *out_channel_count)
{
if (!filename || stream_index < 0 || samples_per_point <= 0 ||
capacity_points < 0) {
return OAKAUDIO_E_INVALID;
}
// Probe for the stream's native rate/layout (oakcodec probe is
// stateless and does not need a conform)
OakDecoder probe = oakcodec_decoder_probe(filename);
if (!probe.ctx) {
return OAKAUDIO_E_NOT_FOUND;
}
oakcodec_audio_stream_info info;
int r = oakcodec_decoder_probe_get_audio_stream(probe, stream_index,
&info);
oakcodec_decoder_free(&probe);
if (r != OAKCODEC_OK) {
return OAKAUDIO_E_NOT_FOUND;
}
if (info.sample_rate <= 0 || info.channel_count <= 0) {
return OAKAUDIO_E_FAILED;
}
// Decode the whole stream through ffmpeg_bridge (fb_decoder +
// fb_audio_graph) rather than oakcodec_decoder_decode_audio: the
// oakcodec decode path is conform-cache based and cannot decode media
// without an existing pcm conform until the task system lands (M8).
// The stream is reduced to channel-interleaved min/max points at the
// native rate/layout.
FBDecoder *decoder = fb_decoder_create();
if (!decoder) {
return OAKAUDIO_E_NOMEM;
}
r = fb_decoder_open(decoder, filename, info.stream_index);
if (r < 0) {
fb_decoder_free(&decoder);
return OAKAUDIO_E_FAILED;
}
const int channels = info.channel_count;
std::vector<oakaudio_min_max> points;
PendingPlanes pending; // per-channel planar backlog
// The graph converts the stream's native format to planar float; the
// stream info carries the validated sample format/rate/layout (audio
// frames do not report a sample format through fb_frame_get_format).
FBStreamInfo sinfo;
if (fb_decoder_get_stream_info(decoder, &sinfo) < 0 ||
sinfo.sample_rate <= 0) {
fb_decoder_close(decoder);
fb_decoder_free(&decoder);
return OAKAUDIO_E_FAILED;
}
FBAudioGraphConfig config;
memset(&config, 0, sizeof(config));
config.in_sample_rate = sinfo.sample_rate;
config.in_channel_layout_mask = sinfo.channel_layout_mask;
config.in_sample_format = sinfo.sample_format;
config.in_channels = channels;
config.out_sample_rate = config.in_sample_rate;
config.out_channel_layout_mask = config.in_channel_layout_mask;
config.out_sample_format = fb_sample_fmt_fltp;
config.out_channels = channels;
config.out_is_planar = 1;
config.tempo = 1.0;
FBPacket *packet = fb_packet_alloc();
FBFrame *frame = fb_frame_alloc();
FBFrame *converted = fb_frame_alloc();
FBAudioGraph *graph = fb_audio_graph_create(&config);
int result = OAKAUDIO_OK;
if (!packet || !frame || !converted) {
result = OAKAUDIO_E_NOMEM;
goto done;
}
if (!graph) {
result = OAKAUDIO_E_FAILED;
goto done;
}
while (true) {
if (fb_decoder_get_frame(decoder, packet, frame) < 0) {
break; // EOF or error: stop decoding
}
// Push the decoded frame (planar pointer array; a packed source is
// read from plane 0 by the buffersrc)
const uint8_t *planes[OAKAUDIO_EXTRACT_MAX_CHANNELS];
for (int ch = 0; ch < channels; ch++) {
planes[ch] = fb_frame_get_data(frame, ch);
}
if (fb_audio_graph_push(graph, planes,
fb_frame_get_nb_samples(frame)) < 0) {
result = OAKAUDIO_E_FAILED;
goto done;
}
if (drain_graph(graph, converted, channels, samples_per_point,
pending, points) != OAKAUDIO_OK) {
result = OAKAUDIO_E_FAILED;
goto done;
}
}
// Flush the resampler delay
if (graph) {
fb_audio_graph_push(graph, nullptr, 0);
while (fb_audio_graph_pull(graph, converted) == 1) {
append_pending(pending, converted, channels,
fb_frame_get_nb_samples(converted));
}
flush_points(channels, samples_per_point, pending, points);
}
done:
if (graph) {
fb_audio_graph_free(&graph);
}
if (converted) {
fb_frame_free(&converted);
}
if (frame) {
fb_frame_free(&frame);
}
if (packet) {
fb_packet_free(&packet);
}
fb_decoder_close(decoder);
fb_decoder_free(&decoder);
if (result != OAKAUDIO_OK) {
return result;
}
if (out_channel_count) {
*out_channel_count = channels;
}
const int point_count = int(points.size()) / channels;
if (!out_pairs || capacity_points < point_count) {
return point_count;
}
memcpy(out_pairs, points.data(),
points.size() * sizeof(oakaudio_min_max));
return point_count;
}
-90
View File
@@ -1,90 +0,0 @@
# oakaudio 类覆盖映射表(C++ audio 模块 → oakaudio Rust crate
> 逐类盘点 `src/audio/src` 与 `src/audio/c_api`。每一行标注 Rust 侧落点。
> `// CPP-PARITY` 注释义务不变:凡承载布局/数值/边角行为的地方,标出
> C++ 文件:行号。本表是初稿;实现阶段据实核对。
## 1. AudioManagermanager.rs
| C++ | Rust 落点 |
|---|---|
| `AudioManager::create_instance` / `destroy_instance` / `instance` | `manager::create_instance` / `destroy_instance` / `instance``OnceLock<Mutex<ManagerInner>>` 单例) |
| `set_output_notify_interval` / `set_output_notify_callback` | `manager::set_output_notify_interval`notify 回调经 guard 从 PortAudio 线程调用,见 previewdevice.rs |
| `push_to_output` / `clear_buffered_output` / `stop_output` | `manager::push_to_output` / `clear_buffered_output` / `stop_output` |
| `seconds` / `reset_output_clock` | `manager::seconds` / `reset_output_clock`(播放时钟补偿输出延迟) |
| `get/set_output_device` / `get/set_input_device` | `manager` 设备访问器(PaDeviceIndex-1 = paNoDevice |
| `hard_reset` | `manager::hard_reset`(关流并重初始化 PortAudio |
| `start_recording` / `stop_recording` | `manager` 录音(经 `bridge::codec` OakEncoder,恒定 interleaved f32 |
| `find_config_device_by_name` / `find_device_by_name`static | `manager::find_config_device_by_name_s` / `find_device_by_name_s` |
| `get_port_audio_params` / `get_port_audio_sample_format`(私有) | `manager` 内部(映射 AudioParams ↔ PortAudio`// CPP-PARITY` 标注格式映射) |
## 2. AudioProcessorprocessor.rs
| C++ | Rust 落点 |
|---|---|
| `open(from,to,tempo)` / `close` / `is_open` | `processor::open` / `close` / `is_open`FBAudioGraphConfig 组装) |
| `convert` | `processor::convert`planar f32 进/出;`fb_audio_graph_push` + `fb_audio_graph_pull` |
| `flush` | `processor::flush``fb_audio_graph_push` channel_data==NULL |
| `from()` / `to()` | `processor` 保存的 `AudioParams` |
## 3. AudioSynchronizersynchronizer.rs,纯静态)
| C++ | Rust 落点 |
|---|---|
| `place_by_source_time` | `synchronizer::place_by_source_time` |
| `place_by_waveform_offset` | `synchronizer::place_by_waveform_offset` |
## 4. AudioLevelMeterlevelmeter.rs,纯静态)
| C++ | Rust 落点 |
|---|---|
| `analyze_sample_buffer` | `levelmeter::analyze`peak/RMS/VU 阈值与 `-200` 地板;`// CPP-PARITY` 标注) |
| `linear_to_db` / `power_to_lufs`(私有) | `levelmeter` 内部(BS.1770 LUFS,无 K-weighting |
## 5. AudioVisualWaveformwaveform.rs
| C++ | Rust 落点 |
|---|---|
| 构造 / `channel_count` / `set_channel_count` / `length` | `waveform::Waveform` + 访问器 |
| `overwrite_samples` | `waveform::overwrite_samples`mipmap 展开,`k_minimum/maximum_sample_rate` |
| `overwrite_sums` / `overwrite_silence` | `waveform::overwrite_sums` / `overwrite_silence` |
| `trim_in` / `mid` / `resize` / `trim_range` | `waveform::trim_in` / `mid` / `resize` / `trim_range` |
| `get_summary_from_time` | `waveform::get_summary` |
| `sum_samples` / `re_sum_samples`static | `waveform::sum_samples` / `re_sum_samples` |
| mipmap 内部(`overwrite_samples_from_*` / `get_mipmap_for_scale` / `time_to_samples` / `validate_virtual_start` | `waveform` 内部 |
| 全文件提取(c_api `oakaudio_waveform_extract` | `waveform::extract`(经 `bridge::codec` decoder + `bridge::ffmpeg` |
| `SamplePerChannel` POD | 与 `oakaudio_min_max` `static_assert` 对齐(`// CPP-PARITY: c_api/waveform.cpp` |
## 6. AudioWaveformSyncwaveformsync.rs,纯静态)
| C++ | Rust 落点 |
|---|---|
| `extract_rms_envelope` | `waveformsync::extract_rms_envelope` |
| `estimate_offset`SampleBuffer 版) | `waveformsync::estimate_offset`(内部先提取 envelope |
| `estimate_envelope_offset`(两个重载) | `waveformsync::estimate_envelope_offset`valid 掩码版为权威) |
| `estimate_stretch_and_offset` | `waveformsync::estimate_stretch_and_offset`O(rates*lags*overlap) |
## 7. PreviewAudioDevicepreviewdevice.rsheader-only
| C++ | Rust 落点 |
|---|---|
| `read` / `write` | `previewdevice::PreviewAudioDevice::read` / `write`(回调侧 pull |
| `set_params` / `bytes_per_frame` / `set_bytes_per_frame` | `previewdevice`AudioParams → bytes_per_frame |
| `set_notify_interval` / `set_notify_callback` | `previewdevice`notify 回调,锁外触发) |
| `clear` | `previewdevice::clear` |
| `add_output_frames` / `output_frames_consumed` / `reset_output_frames` | `previewdevice`atomic 播放时钟) |
## 8. audio_config 命名空间(config.rs,不是类)
| C++ | Rust 落点 |
|---|---|
| `output_buffer_size()` | `config::output_buffer_size``oakcommon_config_get_int(nullptr,"AudioOutputBufferSize",0)` |
| `device_name(is_output_device)` | `config::device_name``oakcommon_config_get` 两阶段;key = "AudioOutput"/"AudioInput" |
## 9. 刻意不迁移(drop
| C++ | 理由 |
|---|---|
| `get_port_audio_params` 的 PortAudio 平台细节 | 归 `bridge::ffmpeg`/PortAudio 侧;Rust 保留语义与默认布局兜底 |
| Qt 常量(`qFuzzyIsNull` 等)内联展开 | 语义内联为 Rust 比较;`// CPP-PARITY` 标注 |
| `draw_sample`/`draw_waveform`QPainter | UI 绘制归 facade/appcrate 只存/汇总数据 |
-555
View File
@@ -1,555 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags",
"cexpr",
"clang-sys",
"itertools",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex 1.3.0",
"syn",
]
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "cc"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
dependencies = [
"find-msvc-tools",
"shlex 2.0.1",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clang-sys"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "either"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]]
name = "ffmpeg-next"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6380599799e175191eb7ffe82c97f36a2a90a36cbc54c738a903e5287d7f516a"
dependencies = [
"bitflags",
"ffmpeg-sys-next",
"libc",
]
[[package]]
name = "ffmpeg-sys-next"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b939bf79dd5949412a4b81cfe21a07f48ea21b47fcbb5f57816c8c2de5ae30b"
dependencies = [
"bindgen",
"cc",
"libc",
"num_cpus",
"pkg-config",
"vcpkg",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "glob"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"tiff",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "num_cpus"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "oakaudio"
version = "0.1.0"
dependencies = [
"oakcodec",
"oakcommon",
"oakcore-rs",
]
[[package]]
name = "oakcodec"
version = "0.1.0"
dependencies = [
"ffmpeg-next",
"oakcore-rs",
]
[[package]]
name = "oakcommon"
version = "0.1.0"
dependencies = [
"image",
"log",
"oakcore-rs",
"ocio-rs",
"quick-xml",
]
[[package]]
name = "oakcore-rs"
version = "0.1.0"
[[package]]
name = "ocio-rs"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3492534019b59e29dba06014f907dd12824537ed4d293d4108c4bfc669de7fd"
dependencies = [
"ocio-sys",
"thiserror",
]
[[package]]
name = "ocio-sys"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e63251d72d848de5eda39d59cd6490260cf031738ebd518ea37d76b5aae614ec"
dependencies = [
"cc",
"cmake",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "zerocopy"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
-18
View File
@@ -1,18 +0,0 @@
[package]
name = "oakaudio"
version = "0.1.0"
edition = "2021"
description = "Oak Video Editor audio I/O, processing, synchronization and waveform engine (Rust)"
license = "GPL-3.0-or-later"
[lib]
crate-type = ["staticlib", "rlib"]
[profile.release]
# FFI discipline: panics must be catchable at every exported entry.
panic = "unwind"
[dependencies]
oakcore-rs = { path = "../../oakcore-rs" }
oakcommon = { path = "../../common/rust" }
oakcodec = { path = "../../codec/rust" }
-102
View File
@@ -1,102 +0,0 @@
# oakaudio Rust crate
> Status: **implemented**. The C ABI (`include/audio/*.h`) is implemented
> by `src/ffi.rs`; the contract suite lives in `tests/` (all green,
> ~88% line coverage under tarpaulin). The architecture below mirrors the
> oaknode/oakrender crate template (FFI discipline, testing layers) from
> `src/node/rust/README.md` and `src/render/rust/README.md`.
## Scope
Replaces the C++ oakaudio module (`src/audio/src`, ~50k lines): the
PortAudio output/input manager (`AudioManager`), the real-time
resampler/format converter (`AudioProcessor`), timeline synchronization
helpers (`AudioSynchronizer`, `AudioWaveformSync`), the level meter
(`AudioLevelMeter`), the visual waveform store (`AudioVisualWaveform`),
the header-only pull buffer (`PreviewAudioDevice`), and the config
bridge (`audio_config` namespace).
Public contract: `include/audio/*.h` (5 headers plus `error.h`, ~45
functions) — frozen, implemented verbatim by `src/ffi.rs`.
## Key architectural decisions (C++ → Rust mapping)
1. **Singleton manager.** `AudioManager` is a process-wide PortAudio
singleton. Rust keeps the singleton behind a `OnceLock<Mutex<...>>`
with borrow-only handles: `addref`/`release` are no-ops exactly as on
the C++ side, and an empty handle reports `OAKAUDIO_E_STATE`. No
destruction ever happens through the handle.
2. **Processor is the only heavy FFI consumer.** `AudioProcessor` wraps
the ffmpeg_bridge audio filter graph (`fb_audio_graph_*`,
`fb_frame_*`); every call funnels through `bridge::ffmpeg`. The
resampler/format-conversion semantics and the always-planar-f32
output (`OAKAUDIO_PROCESSOR_OUTPUT_FORMAT = 4`) are preserved.
3. **Sync helpers are stateless.** `AudioSynchronizer` and
`AudioWaveformSync` have only static methods in C++; they become
plain functions in `synchronizer.rs` / `waveformsync.rs`. No handles
are involved on the sync headers except by-value arguments.
4. **Value types are local.** `params.rs` defines `AudioParams` (a
plain POD) and a **planar-first** `SampleFormat` enum mirroring
`olive::core::SampleFormat::Format` exactly, because these values
cross the C ABI as `int`. See the note in `params.rs` about why the
crate does not reuse `oakcore-rs`'s `SampleFormat`.
5. **Rational reuses oakcore-rs.** `core::Rational` (used by
synchronizer and waveform) comes from `oakcore-rs`; there is no
local copy.
6. **Record path through oakcodec.** `AudioManager` records through the
oakcodec encoder C ABI (`bridge::codec`) and waveform extraction
decodes through the oakcodec decoder C ABI — exactly as the C++
does. No direct ffmpeg_bridge use in the record path.
7. **Config via oakcommon.** Device names and the output buffer size
read through `bridge::common` (`oakcommon_config_*`), preserving the
`audio_config` namespace semantics as a `config.rs` free-function
module.
## Layout
`COVERAGE.md` maps every C++ audio class/method to its Rust home.
Review that first.
```
src/
lib.rs crate doc + module map
error.rs error codes (mirrors include/audio/error.h)
handle.rs refcounted-handle scaffolding (same pattern as node)
params.rs AudioParams + planar-first SampleFormat value types
config.rs audio_config namespace (bridge::common)
manager.rs AudioManager singleton (PortAudio I/O, recording)
processor.rs AudioProcessor (resampler/converter, bridge::ffmpeg)
synchronizer.rs AudioSynchronizer placement helpers
levelmeter.rs AudioLevelMeter peak/RMS/VU/LUFS analysis
waveform.rs AudioVisualWaveform mipmapped store + extraction
waveformsync.rs AudioWaveformSync envelope offset estimation
previewdevice.rs PreviewAudioDevice pull buffer
bridge/ C ABI imports: common.rs, codec.rs, ffmpeg.rs
ffi.rs include/audio/*.h export layer
tests/ contract + golden tests (see README test section)
```
## Hard rules for the implementer
1. Every `extern "C"` body goes through `handle::guard*`; no panic
crosses FFI. The manager's borrow-only singleton is the one place
`guard_handle`/`guard_void` are used with no refcount semantics.
2. `SampleFormat` and `AudioParams` integer values MUST match the C++
enums bit-for-bit; `// CPP-PARITY:` comments mark every load-bearing
layout decision.
3. Behavior parity with C++ is proven by the C ABI test-suite
(`src/audio/tests`, unchanged) plus the golden tests in `tests/`
(waveform mipmap/channel-interleaved layout, RMS/LUFS thresholds,
sync placement).
4. Where C++ behavior is genuinely load-bearing but ugly, port the
behavior, not the aesthetics; leave a `// CPP-PARITY:` comment with
the C++ file:line.
5. `src/plugin/` is frozen and out of scope; no oakaudio code reaches
into it.
## Dependency policy
Prefer mature third-party crates (MIT/Apache-2.0/BSD, GPL-compatible)
over hand-rolling; register each addition (name + reason) here. Large
existing C++ libraries (OTIO, OCIO, OIIO, FFmpeg) are NEVER rewritten
— they are consumed through their C ABI / bridge layers.
-127
View File
@@ -1,127 +0,0 @@
// 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/>.
//! oakcodec C ABI calls (audio encoding + decoding for the manager and
//! waveform extraction) — now direct Rust calls into the oakcodec crate
//! (single-lib unification, see `docs/zh/plans/riir/single-lib.md`).
//!
//! The previous `*mut c_void` handle convention drifted from the real
//! codec C ABI (which uses the shared [`CHandle`]); the wrappers below
//! match the oakcodec ffi signatures exactly.
use std::ffi::{c_char, c_int};
use crate::handle::CHandle;
/// `oakcodec_encoding_params` — single-lib unification: aliases the
/// oakcodec crate's POD (identical layout; only the audio fields are
/// consumed by oakaudio).
pub type EncodingParams = oakcodec::ffi::encoder::oakcodec_encoding_params;
/// `oakcodec_audio_stream_info` — audio stream metadata from probing.
/// Single-lib unification: aliases the oakcodec crate's POD.
pub type AudioStreamInfo = oakcodec::decoder::OakCodecAudioStreamInfo;
/// `oakcodec_encoder_init` — create an encoder for `params`.
pub unsafe fn oakcodec_encoder_init(params: *const EncodingParams) -> CHandle {
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_init(params) }
}
/// `oakcodec_encoder_free`.
pub unsafe fn oakcodec_encoder_free(encoder: *mut CHandle) {
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_free(encoder) }
}
/// `oakcodec_encoder_open`.
pub unsafe fn oakcodec_encoder_open(encoder: CHandle) -> c_int {
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_open(encoder) }
}
/// `oakcodec_encoder_write_audio` — feed interleaved `f32` audio.
pub unsafe fn oakcodec_encoder_write_audio(
encoder: CHandle,
samples: *const f32,
frame_count: c_int,
) -> c_int {
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_write_audio(encoder, samples, frame_count) }
}
/// `oakcodec_encoder_flush`.
pub unsafe fn oakcodec_encoder_flush(encoder: CHandle) -> c_int {
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_flush(encoder) }
}
/// `oakcodec_encoder_last_error` — copy the last error string into `buf`.
pub unsafe fn oakcodec_encoder_last_error(
encoder: CHandle,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_last_error(encoder, buf, buf_size) }
}
/// `oakcodec_decoder_probe` — create a probe handle for a file.
pub unsafe fn oakcodec_decoder_probe(filename: *const c_char) -> CHandle {
unsafe { oakcodec::ffi::decoder::oakcodec_decoder_probe(filename) }
}
/// `oakcodec_decoder_free`.
pub unsafe fn oakcodec_decoder_free(decoder: *mut CHandle) {
unsafe { oakcodec::ffi::decoder::oakcodec_decoder_free(decoder) }
}
/// `oakcodec_decoder_probe_audio_stream_count`.
pub unsafe fn oakcodec_decoder_probe_audio_stream_count(probe: CHandle) -> c_int {
unsafe { oakcodec::ffi::decoder::oakcodec_decoder_probe_audio_stream_count(probe) }
}
/// `oakcodec_decoder_probe_get_audio_stream` — copy audio stream info.
pub unsafe fn oakcodec_decoder_probe_get_audio_stream(
probe: CHandle,
index: c_int,
out: *mut AudioStreamInfo,
) -> c_int {
unsafe { oakcodec::ffi::decoder::oakcodec_decoder_probe_get_audio_stream(probe, index, out) }
}
/// `oakcodec_decoder_open` — open stream `stream_index` for decoding.
pub unsafe fn oakcodec_decoder_open(
decoder: CHandle,
filename: *const c_char,
stream_index: c_int,
) -> c_int {
unsafe { oakcodec::ffi::decoder::oakcodec_decoder_open(decoder, filename, stream_index) }
}
/// `oakcodec_decoder_decode_audio` — decode/convert frames into `buf`.
#[allow(clippy::too_many_arguments)]
pub unsafe fn oakcodec_decoder_decode_audio(
decoder: CHandle,
in_num: c_int,
in_den: c_int,
out_num: c_int,
out_den: c_int,
sample_rate: c_int,
channel_layout: u64,
buf: *mut f32,
buf_frames: c_int,
) -> c_int {
unsafe {
oakcodec::ffi::decoder::oakcodec_decoder_decode_audio(
decoder, in_num, in_den, out_num, out_den, sample_rate, channel_layout, buf, buf_frames,
)
}
}
-51
View File
@@ -1,51 +0,0 @@
// 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/>.
//! oakcommon C ABI calls (config access + ffmpeg format conversion) —
//! now direct Rust calls into the oakcommon crate (single-lib
//! unification, see `docs/zh/plans/riir/single-lib.md`).
use std::ffi::{c_char, c_int};
/// `oakcommon_config_get` — copy a config string value into `buf`
/// (two-stage; returns the required size including NUL).
pub unsafe fn oakcommon_config_get(
group: *const c_char,
key: *const c_char,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
unsafe { oakcommon::ffi::config::oakcommon_config_get(group, key, buf, buf_size) }
}
/// `oakcommon_config_get_int` — read an integer config value with a
/// default.
pub unsafe fn oakcommon_config_get_int(
group: *const c_char,
key: *const c_char,
default: c_int,
) -> c_int {
unsafe { oakcommon::ffi::config::oakcommon_config_get_int(group, key, default) }
}
/// `oakcommon_ffmpegutils_get_ffmpeg_sample_format` — map an ffmpeg
/// sample format enum to the oak core format, or the reverse.
pub unsafe fn oakcommon_ffmpegutils_get_ffmpeg_sample_format(
smp_fmt: c_int,
out: *mut c_int,
) -> c_int {
unsafe { oakcommon::ffi::ffmpegutils::oakcommon_ffmpegutils_get_ffmpeg_sample_format(smp_fmt, out) }
}
-216
View File
@@ -1,216 +0,0 @@
// 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/>.
//! ffmpeg_bridge C ABI imports (audio filter graph, frames, decoder). The
//! graph converts/resamples/time-stretches planar audio; used by the
//! [`crate::processor`] resampler and the [`crate::waveform`] extractor.
use std::ffi::{c_char, c_int, c_void};
/// `FBSampleFormat` — mirrors `AVSampleFormat` (values cross the C ABI as
/// `int`). `fltp` (planar f32) is the natural exchange format for oakaudio.
///
/// `// CPP-PARITY: ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h:117`.
#[repr(i32)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SampleFormat {
/// No format.
None = -1,
/// Unsigned 8-bit, packed.
U8 = 0,
/// Signed 16-bit, packed.
S16 = 1,
/// Signed 32-bit, packed.
S32 = 2,
/// 32-bit float, packed.
Flt = 3,
/// 64-bit float, packed.
Dbl = 4,
/// Unsigned 8-bit, planar.
U8Planar = 5,
/// Signed 16-bit, planar.
S16Planar = 6,
/// Signed 32-bit, planar.
S32Planar = 7,
/// 32-bit float, planar.
Fltp = 8,
/// 64-bit float, planar.
Dblp = 9,
/// Signed 64-bit, packed.
S64 = 10,
/// Signed 64-bit, planar.
S64Planar = 11,
}
/// `FBAudioGraphConfig` — source graph input/output spec.
///
/// `// CPP-PARITY: ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h:510`.
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct AudioGraphConfig {
/// Input sample rate in Hz.
pub in_sample_rate: c_int,
/// Input channel layout mask (`0` = derive from `in_channels`).
pub in_channel_layout_mask: u64,
/// Input sample format (`FBSampleFormat`; planar float in).
pub in_sample_format: c_int,
/// Input channel count.
pub in_channels: c_int,
/// Output sample rate in Hz.
pub out_sample_rate: c_int,
/// Output channel layout mask (`0` = derive from `out_channels`).
pub out_channel_layout_mask: u64,
/// Output sample format (`FBSampleFormat`).
pub out_sample_format: c_int,
/// Output channel count.
pub out_channels: c_int,
/// Whether the output is planar.
pub out_is_planar: c_int,
/// Time-stretch tempo multiplier.
pub tempo: f64,
}
/// Opaque audio filter graph.
pub type AudioGraph = c_void;
/// Opaque frame.
pub type Frame = c_void;
/// Opaque packet.
pub type Packet = c_void;
/// Opaque decoder.
pub type Decoder = c_void;
/// `FBStreamInfo` — decoded stream metadata, mirroring the same-named
/// struct in ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h. Only the
/// audio fields are consumed by oakaudio; the video/container fields are
/// kept to preserve layout.
///
/// `// CPP-PARITY: ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h:335`.
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct FBStreamInfo {
/// Stream index.
pub index: c_int,
/// Media type (`FBMediaType`).
pub codec_type: c_int,
/// Opaque FFmpeg codec id.
pub codec_id: c_int,
/// Non-zero if a decoder exists for this stream.
pub has_decoder: c_int,
/// Video width.
pub width: c_int,
/// Video height.
pub height: c_int,
/// Video pixel format (`FBPixelFormat`).
pub pixel_format: c_int,
/// Video field order (`FBFieldOrder`).
pub field_order: c_int,
/// Video color range (`FBColorRange`).
pub color_range: c_int,
/// Raw `AVColorPrimaries` value.
pub color_primaries: c_int,
/// Raw `AVColorTransferCharacteristic` value.
pub color_trc: c_int,
/// Sample rate in Hz.
pub sample_rate: c_int,
/// Sample format (`FBSampleFormat`).
pub sample_format: c_int,
/// Channel layout mask (never zero for valid audio).
pub channel_layout_mask: u64,
/// Stream start time.
pub start_time: i64,
/// Stream duration.
pub duration: i64,
/// Stream time base numerator.
pub time_base_num: c_int,
/// Stream time base denominator.
pub time_base_den: c_int,
/// Average frame rate numerator.
pub avg_frame_rate_num: c_int,
/// Average frame rate denominator.
pub avg_frame_rate_den: c_int,
}
extern "C" {
/// `fb_audio_graph_create` — build a graph from `config`.
pub fn fb_audio_graph_create(config: *const AudioGraphConfig) -> *mut AudioGraph;
/// `fb_audio_graph_free`.
pub fn fb_audio_graph_free(graph: *mut *mut AudioGraph);
/// `fb_audio_graph_push` — push planar samples; `channel_data == NULL`
/// flushes the graph.
pub fn fb_audio_graph_push(
graph: *mut AudioGraph,
channel_data: *const *const u8,
nb_samples: c_int,
) -> c_int;
/// `fb_audio_graph_pull` — pull converted samples. 1 = frame produced,
/// 0 = need more input, negative = error.
pub fn fb_audio_graph_pull(graph: *mut AudioGraph, out_frame: *mut Frame) -> c_int;
/// `fb_channel_layout_get_channels` — channel count of a mask.
pub fn fb_channel_layout_get_channels(mask: u64) -> c_int;
/// `fb_channel_layout_default` — default layout mask for `nb_channels`.
pub fn fb_channel_layout_default(nb_channels: c_int) -> u64;
/// `fb_frame_alloc`.
pub fn fb_frame_alloc() -> *mut Frame;
/// `fb_frame_free`.
pub fn fb_frame_free(frame: *mut *mut Frame);
/// `fb_frame_unref`.
pub fn fb_frame_unref(frame: *mut Frame);
/// `fb_frame_get_nb_samples`.
pub fn fb_frame_get_nb_samples(frame: *const Frame) -> c_int;
/// `fb_frame_set_nb_samples`.
pub fn fb_frame_set_nb_samples(frame: *mut Frame, nb_samples: c_int);
/// `fb_frame_get_sample_rate`.
pub fn fb_frame_get_sample_rate(frame: *const Frame) -> c_int;
/// `fb_frame_get_format`.
pub fn fb_frame_get_format(frame: *const Frame) -> c_int;
/// `fb_frame_get_channel_layout_mask`.
pub fn fb_frame_get_channel_layout_mask(frame: *const Frame) -> u64;
/// `fb_frame_get_data` — writable plane data.
pub fn fb_frame_get_data(frame: *mut Frame, plane: c_int) -> *mut u8;
/// `fb_frame_get_data_const` — read-only plane data.
pub fn fb_frame_get_data_const(frame: *const Frame, plane: c_int) -> *const u8;
/// `fb_frame_get_linesize`.
pub fn fb_frame_get_linesize(frame: *const Frame, plane: c_int) -> c_int;
/// `fb_packet_alloc`.
pub fn fb_packet_alloc() -> *mut Packet;
/// `fb_packet_free`.
pub fn fb_packet_free(packet: *mut *mut Packet);
/// `fb_packet_unref`.
pub fn fb_packet_unref(packet: *mut Packet);
/// `fb_decoder_create`.
pub fn fb_decoder_create() -> *mut Decoder;
/// `fb_decoder_free`.
pub fn fb_decoder_free(decoder: *mut *mut Decoder);
/// `fb_decoder_open` — open stream `stream_index` of `filename`.
pub fn fb_decoder_open(decoder: *mut Decoder, filename: *const c_char, stream_index: c_int)
-> c_int;
/// `fb_decoder_close`.
pub fn fb_decoder_close(decoder: *mut Decoder);
/// `fb_decoder_get_frame` — decode one frame from `packet`.
pub fn fb_decoder_get_frame(decoder: *mut Decoder, packet: *mut Packet, frame: *mut Frame) -> c_int;
/// `fb_decoder_get_packet` — read one packet.
pub fn fb_decoder_get_packet(decoder: *mut Decoder, packet: *mut Packet) -> c_int;
/// `fb_decoder_get_stream_info` — copy stream info into `out`.
pub fn fb_decoder_get_stream_info(decoder: *const Decoder, out: *mut FBStreamInfo) -> c_int;
/// `fb_decoder_get_format_start_time`.
pub fn fb_decoder_get_format_start_time(decoder: *const Decoder) -> i64;
/// `fb_decoder_get_format_duration`.
pub fn fb_decoder_get_format_duration(decoder: *const Decoder) -> i64;
}
-22
View File
@@ -1,22 +0,0 @@
// 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/>.
//! C ABI imports from other oak modules (signatures mirror the public
//! headers verbatim; resolved at link time).
pub mod codec;
pub mod common;
pub mod ffmpeg;
-73
View File
@@ -1,73 +0,0 @@
// 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/>.
//! The `audio_config` namespace from `src/audio/src/configbridge.*`:
//! audio-specific configuration read through the oakcommon C ABI.
use std::ffi::CString;
/// PortAudio output buffer size in frames; 0 = let PortAudio choose.
///
/// `// CPP-PARITY: src/audio/src/configbridge.cpp:30`
/// (`audio_config::output_buffer_size`).
pub fn output_buffer_size() -> i32 {
unsafe {
crate::bridge::common::oakcommon_config_get_int(
std::ptr::null(),
c"AudioOutputBufferSize".as_ptr(),
0,
)
}
}
/// Name of the configured audio device for `is_output_device`
/// (key "AudioOutput" / "AudioInput"); empty when absent.
///
/// `// CPP-PARITY: src/audio/src/configbridge.cpp:36`
/// (`audio_config::device_name`): two-stage size query; `size <= 1`
/// (absent or empty string) yields the empty string.
pub fn device_name(is_output_device: bool) -> CString {
let key = if is_output_device {
c"AudioOutput"
} else {
c"AudioInput"
};
unsafe {
let size = crate::bridge::common::oakcommon_config_get(
std::ptr::null(),
key.as_ptr(),
std::ptr::null_mut(),
0,
);
if size <= 1 {
// Absent (OAKCOMMON_E_NOT_FOUND) or empty
return CString::default();
}
let mut buf = vec![0u8; size as usize];
if crate::bridge::common::oakcommon_config_get(
std::ptr::null(),
key.as_ptr(),
buf.as_mut_ptr() as *mut std::ffi::c_char,
size,
) < 0
{
return CString::default();
}
// The buffer is NUL-terminated by the callee.
CString::from_vec_with_nul(buf)
.unwrap_or_else(|e| CString::new(e.into_bytes()).unwrap_or_default())
}
}
-64
View File
@@ -1,64 +0,0 @@
// 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/>.
//! Error codes, mirroring `include/audio/error.h` verbatim; project-wide
//! -MMCCCC scheme (module registry in include/common/error.h), pass-through
//! untranslated. Audio module number is 06, so codes are -60001..-60005.
//! Unlike codec/task there is no CANCELLED code.
/// Success.
pub const OAKAUDIO_OK: i32 = 0;
/// Null handle or invalid argument.
pub const OAKAUDIO_E_INVALID: i32 = -60001;
/// Call not valid in the current state.
pub const OAKAUDIO_E_STATE: i32 = -60002;
/// The underlying operation failed.
pub const OAKAUDIO_E_FAILED: i32 = -60003;
/// Index out of range / entry not found.
pub const OAKAUDIO_E_NOT_FOUND: i32 = -60004;
/// Allocation failed.
pub const OAKAUDIO_E_NOMEM: i32 = -60005;
/// Crate-internal result type; the FFI layer maps it to the codes.
pub type Result<T> = std::result::Result<T, Error>;
/// Crate-internal error.
#[derive(Debug)]
pub enum Error {
/// Null handle or invalid argument.
Invalid,
/// Wrong state.
State,
/// Operation failed (context string is log-only).
Failed(String),
/// Not found.
NotFound,
/// Out of memory.
NoMem,
}
impl Error {
/// Map to the public error code.
pub fn code(&self) -> i32 {
match self {
Error::Invalid => OAKAUDIO_E_INVALID,
Error::State => OAKAUDIO_E_STATE,
Error::Failed(_) => OAKAUDIO_E_FAILED,
Error::NotFound => OAKAUDIO_E_NOT_FOUND,
Error::NoMem => OAKAUDIO_E_NOMEM,
}
}
}
File diff suppressed because it is too large Load Diff
-226
View File
@@ -1,226 +0,0 @@
// 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/>.
//! Refcounted-handle scaffolding. Same pattern as the oaknode crate
//! (`src/node/rust/src/handle.rs`); intentionally duplicated rather
//! than shared — each module DLL must run its own addref/release code
//! (the function pointers in a handle always point into the DLL that
//! created the object).
//!
//! The `AudioManager` is the one exception: it uses the same `CHandle`
//! layout but with singleton semantics (addref/release no-ops). See
//! `manager.rs` and `include/audio/manager.h`.
//!
//! `// CPP-PARITY: src/audio/c_api/refcounted.h` (RefCounted box,
//! make_handle_in_place, free_handle) and `src/audio/c_api/alive.cpp`
//! (alive ledger behind `oakaudio_debug_alive_count`).
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicI32, AtomicU32, Ordering};
use crate::error::{Error, OAKAUDIO_E_FAILED, OAKAUDIO_OK};
/// ABI version stamped into every handle.
pub const OAKAUDIO_ABI_VERSION: u32 = 1;
/// Live-object ledger behind `oakaudio_debug_alive_count`.
///
/// `// CPP-PARITY: src/audio/c_api/alive.cpp` (`g_alive`).
static ALIVE: AtomicI32 = AtomicI32::new(0);
/// Current number of live refcounted oakaudio objects.
pub fn alive_count() -> i32 {
ALIVE.load(Ordering::Relaxed)
}
/// Heap box behind a handle's `ctx`.
pub struct RefBox<T: ?Sized> {
/// Atomic reference count.
pub refs: AtomicU32,
/// Boxed value.
pub value: T,
}
/// The shared ABI value-handle type (single-lib unification, see
/// `docs/zh/plans/riir/single-lib.md`): one canonical
/// `{ctx, addref, release, abi_version}` type in `oakcore-rs`, re-exported
/// here so the crate's `ffi.rs` signatures and handle scaffolding stay
/// source-compatible. It is `Clone + Copy` (handles cross the C ABI by
/// value) and `Send + Sync` (refcounted, shared across threads).
pub use oakcore_rs::handle::CHandle;
/// `// CPP-PARITY: src/audio/c_api/refcounted.h` (`ref_counted_addref`).
unsafe extern "C" fn owned_addref<T: Send + 'static>(ctx: *mut std::ffi::c_void) {
// SAFETY: `ctx` is either NULL or points to a `RefBox<T>` created by
// `make_owned`; we only touch it through the reference while it is live.
if let Some(b) = unsafe { (ctx as *const RefBox<T>).as_ref() } {
b.refs.fetch_add(1, Ordering::Relaxed);
}
}
/// `// CPP-PARITY: src/audio/c_api/refcounted.h` (`ref_counted_release`):
/// destroys the box at zero and decrements the alive ledger.
unsafe extern "C" fn owned_release<T: Send + 'static>(ctx: *mut std::ffi::c_void) {
// SAFETY: `ctx` is either NULL or points to a live `RefBox<T>` created
// by `make_owned`; the refcount guards against double-free, and the box
// is only reclaimed once the count reaches zero.
if let Some(b) = unsafe { (ctx as *const RefBox<T>).as_ref() } {
if b.refs.fetch_sub(1, Ordering::AcqRel) == 1 {
drop(unsafe { Box::from_raw(ctx as *mut RefBox<T>) });
ALIVE.fetch_sub(1, Ordering::Relaxed);
}
}
}
/// Owned handle with count 1; empty on allocation failure.
pub fn make_owned<T: Send + 'static>(value: T) -> CHandle {
let b = Box::new(RefBox {
refs: AtomicU32::new(1),
value,
});
ALIVE.fetch_add(1, Ordering::Relaxed);
CHandle {
ctx: Box::into_raw(b) as *mut std::ffi::c_void,
addref: Some(owned_addref::<T>),
release: Some(owned_release::<T>),
abi_version: OAKAUDIO_ABI_VERSION,
}
}
/// No-op addref/release for singleton (borrowed) handles.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp` (`singleton_addref` /
/// `singleton_release`).
unsafe extern "C" fn noop_ref(_ctx: *mut std::ffi::c_void) {}
/// Borrowed handle for an object owned elsewhere (addref/release are
/// no-ops; nothing is ever freed through the handle).
///
/// # Safety
/// Caller guarantees `ptr` outlives every derived handle.
pub unsafe fn make_borrowed<T: Send + 'static>(ptr: *mut T) -> CHandle {
CHandle {
ctx: ptr as *mut std::ffi::c_void,
addref: Some(noop_ref),
release: Some(noop_ref),
abi_version: OAKAUDIO_ABI_VERSION,
}
}
/// Typed view into an owned handle; `None` for empty handles.
///
/// # Safety
/// `T` must be the boxed type.
pub unsafe fn get<T: 'static>(h: &CHandle) -> Option<&T> {
if h.ctx.is_null() {
return None;
}
// SAFETY: caller guarantees `h` is a valid owned handle whose ctx points
// to a `RefBox<T>`; the handle stays alive through the returned borrow.
let v = unsafe { &(*(h.ctx as *const RefBox<T>)).value };
Some(v)
}
/// Typed mutable view into an owned handle; `None` for empty handles.
///
/// # Safety
/// `T` must be the boxed type.
pub unsafe fn get_mut<T: 'static>(h: &CHandle) -> Option<&mut T> {
if h.ctx.is_null() {
return None;
}
// SAFETY: caller guarantees `h` is a valid owned handle whose ctx points
// to a `RefBox<T>`; the handle stays alive through the returned borrow,
// and the caller must not alias it with other live borrows.
let v = unsafe { &mut (*(h.ctx as *mut RefBox<T>)).value };
Some(v)
}
/// Shared free() body: release the ctx, no-op on NULL/empty handle.
///
/// `// CPP-PARITY: src/audio/c_api/refcounted.h` (`free_handle`).
///
/// # Safety
/// `self_` must be a valid handle pointer or NULL.
pub unsafe fn free_handle(h: *mut CHandle) {
// SAFETY: caller guarantees `h` is a valid handle pointer or NULL.
if let Some(r) = unsafe { h.as_mut() } {
if !r.ctx.is_null() {
if let Some(release) = r.release {
// SAFETY: the release fn belongs to the same DLL and
// accepts the ctx it originally created.
unsafe { release(r.ctx) };
}
r.ctx = std::ptr::null_mut();
}
}
}
/// Panic-catching FFI wrapper for i32-returning exports.
pub fn guard<F: FnOnce() -> crate::error::Result<()>>(f: F) -> i32 {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(())) => OAKAUDIO_OK,
Ok(Err(e)) => e.code(),
Err(_) => OAKAUDIO_E_FAILED,
}
}
/// Panic-catching FFI wrapper for handle-returning exports.
pub fn guard_handle<F: FnOnce() -> crate::error::Result<CHandle>>(f: F) -> CHandle {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(h)) => h,
Ok(Err(_)) | Err(_) => CHandle::null(),
}
}
/// Panic-catching FFI wrapper for i32 value-returning exports (errors map
/// to the negative error code).
pub fn guard_int<F: FnOnce() -> crate::error::Result<i32>>(f: F) -> i32 {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(v)) => v,
Ok(Err(e)) => e.code(),
Err(_) => OAKAUDIO_E_FAILED,
}
}
/// Panic-catching FFI wrapper for void exports.
pub fn guard_void<F: FnOnce()>(f: F) {
let _ = catch_unwind(AssertUnwindSafe(f));
}
/// Copy a human-readable error string into a C buffer (NUL-terminated,
/// truncated to fit). Returns the required size including the NUL.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp` (`write_error`).
pub fn write_error(s: &str, buf: *mut std::ffi::c_char, buf_size: i32) {
if !buf.is_null() && buf_size > 0 {
let bytes = s.as_bytes();
let n = bytes.len().min((buf_size - 1) as usize);
unsafe {
std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf as *mut u8, n);
*(buf as *mut u8).add(n) = 0;
}
}
}
/// Convenience: map a condition to an Invalid error.
pub fn invalid_if(cond: bool) -> crate::error::Result<()> {
if cond {
Err(Error::Invalid)
} else {
Ok(())
}
}
-156
View File
@@ -1,156 +0,0 @@
// 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/>.
//! Loudness analysis (`olive::AudioLevelMeter`). A static helper producing
//! per-channel peak/RMS/VU statistics plus an overall LUFS-integrated
//! summary. Pure function module in Rust; feeds the level-meter UI widget.
/// dB floor for all decibel readings.
///
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:30`
/// (`k_decibel_minimum`, inlined from engine/common/decibel.h).
const DECIBEL_MINIMUM: f64 = -200.0;
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:32`
/// (`decibel_from_linear`): -inf clamps to the floor.
fn decibel_from_linear(linear: f64) -> f64 {
let v = 20.0 * linear.log10();
if v.is_infinite() {
return DECIBEL_MINIMUM;
}
v
}
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:99`
/// (`AudioLevelMeter::linear_to_db`).
fn linear_to_db(linear: f64) -> f64 {
if linear <= 0.0 {
return DECIBEL_MINIMUM;
}
decibel_from_linear(linear)
}
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:107`
/// (`AudioLevelMeter::power_to_lufs`): BS.1770-compatible unit, no
/// K-weighting.
fn power_to_lufs(mean_square: f64) -> f64 {
if mean_square <= 0.0 {
return DECIBEL_MINIMUM;
}
-0.691 + 10.0 * mean_square.log10()
}
/// Per-channel statistics for a single analysis pass.
///
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.h`
/// (`AudioLevelMeter::ChannelStats`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ChannelStats {
/// Peak amplitude, linear scale. `0.0` when silent.
pub peak_linear: f64,
/// Peak amplitude, decibel scale. `-200.0` when silent.
pub peak_db: f64,
/// Root-mean-square level, linear scale.
pub rms_linear: f64,
/// Root-mean-square level, decibel scale. `-200.0` when silent.
pub rms_db: f64,
/// VU-meter ballistics reading, decibel scale.
pub vu_db: f64,
}
/// Aggregate statistics over all analyzed channels.
///
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.h`
/// (`AudioLevelMeter::Stats`).
#[derive(Debug, Clone, PartialEq)]
pub struct Stats {
/// Per-channel statistics, indexed by channel.
pub channels: Vec<ChannelStats>,
/// Maximum peak across all channels, linear scale.
pub max_peak_linear: f64,
/// Integrated loudness (EBU R128 LUFS). `-200.0` for silence.
pub integrated_lufs: f64,
/// Whether every channel was silent below the noise gate.
pub silence: bool,
}
/// Compute per-channel and summary statistics for a planar sample buffer.
///
/// `planar` holds one f32 slice per channel; every channel slice is assumed to
/// have the same length. Mirrors `AudioLevelMeter::analyze_sample_buffer`.
///
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:42`
/// (`AudioLevelMeter::analyze_sample_buffer`): VU == RMS dB (no separate
/// ballistics); the silence gate is `qFuzzyIsNull` (|x| < 1e-12) on the
/// max peak; LUFS uses the mean square over ALL channels' samples.
pub fn analyze_sample_buffer(planar: &[&[f32]]) -> Stats {
let mut stats = Stats {
channels: vec![
ChannelStats {
peak_linear: 0.0,
peak_db: DECIBEL_MINIMUM,
rms_linear: 0.0,
rms_db: DECIBEL_MINIMUM,
vu_db: DECIBEL_MINIMUM,
};
planar.len()
],
max_peak_linear: 0.0,
integrated_lufs: DECIBEL_MINIMUM,
silence: true,
};
if planar.is_empty() || planar[0].is_empty() {
return stats;
}
let sample_count = planar[0].len();
let mut total_square = 0.0f64;
let mut total_samples = 0usize;
for (channel, data) in planar.iter().enumerate() {
let mut peak = 0.0f64;
let mut square_sum = 0.0f64;
for &s in data.iter() {
let value = f64::from(s);
peak = peak.max(value.abs());
square_sum += value * value;
}
let mean_square = square_sum / sample_count as f64;
let rms = mean_square.sqrt();
let rms_db = linear_to_db(rms);
stats.channels[channel] = ChannelStats {
peak_linear: peak,
peak_db: linear_to_db(peak),
rms_linear: rms,
rms_db,
vu_db: rms_db,
};
stats.max_peak_linear = stats.max_peak_linear.max(peak);
total_square += square_sum;
total_samples += sample_count;
}
// qFuzzyIsNull(double): |x| < 1e-12
stats.silence = stats.max_peak_linear.abs() < 1e-12;
stats.integrated_lufs = power_to_lufs(total_square / total_samples as f64);
stats
}
-45
View File
@@ -1,45 +0,0 @@
// 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/>.
//! # oakaudio — the audio I/O, processing and synchronization engine (Rust)
//!
//! Reimplements the C++ oakaudio module behind its frozen C ABI
//! (`include/audio/*.h`). See README.md for the architectural mapping
//! (singleton manager, stateless sync helpers, local value types).
//!
//! ## FFI discipline
//!
//! Identical to the oaknode crate: every export goes through
//! [`handle::guard*`], handles are opaque refcounted boxes (or, for the
//! singleton `AudioManager`, borrow-only no-ops), shared state behind
//! `Mutex`.
#![deny(unsafe_op_in_unsafe_fn)]
#![warn(missing_docs)]
pub mod bridge;
pub mod config;
pub mod error;
pub mod ffi;
pub mod handle;
pub mod levelmeter;
pub mod manager;
pub mod params;
pub mod previewdevice;
pub mod processor;
pub mod synchronizer;
pub mod waveform;
pub mod waveformsync;
-368
View File
@@ -1,368 +0,0 @@
// 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/>.
//! The process-wide PortAudio output/input manager (`olive::AudioManager`).
//!
//! Singleton semantics: the single instance lives behind a
//! `OnceLock<Mutex<ManagerInner>>`; handles returned to C are borrowed and
//! their addref/release are no-ops (mirrors the C++ singleton and
//! `include/audio/manager.h`). An empty handle reports `OAKAUDIO_E_STATE`.
//!
//! Recording goes through the oakcodec encoder C ABI ([`crate::bridge`]);
//! device/config lookups go through oakcommon.
use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard, OnceLock};
use crate::bridge::codec::EncodingParams;
use crate::error::{Error, Result};
use crate::handle::{make_borrowed, CHandle};
use crate::params::AudioParams;
use crate::previewdevice::PreviewAudioDevice;
/// `paNoDevice` (PortAudio "no device" sentinel; also the default when no
/// device is configured).
const PA_NO_DEVICE: i32 = -1;
/// The process-wide manager state. `OnceLock` cannot be reset, so
/// [`destroy_instance`] flips `DESTROYED` to make [`instance`] hand out empty
/// handles again (the singleton box itself is retained).
static MANAGER: OnceLock<Mutex<ManagerInner>> = OnceLock::new();
static DESTROYED: AtomicBool = AtomicBool::new(false);
/// Manager state (all device/stream fields; PortAudio itself is not bridged,
/// see [`ManagerInner::default`] for the degradations).
struct ManagerInner {
/// Current output device index.
output_device: i32,
/// Current input device index.
input_device: i32,
/// Output params the buffer is configured for.
output_params: Option<AudioParams>,
/// Queued output samples feeding the (virtual) playback clock.
output_buffer: PreviewAudioDevice,
/// Whether the output "stream" is running (stand-in for
/// `Pa_IsStreamActive`).
output_started: bool,
/// Active oakcodec recording encoder (NULL when idle).
recording: Option<CHandle>,
}
// SAFETY: the raw encoder pointer is only touched while the manager mutex is
// held, which serializes every access; the encoder lives until `recording` is
// taken out in `stop_recording`.
unsafe impl Send for ManagerInner {}
impl Default for ManagerInner {
fn default() -> Self {
ManagerInner {
// CPP-PARITY: no device is selected until `create_instance` runs
// the config lookup (PortAudio enumeration cannot be bridged, so
// the devices stay at paNoDevice and the C layer reports E_FAILED
// on output/recording until a device is set explicitly).
output_device: PA_NO_DEVICE,
input_device: PA_NO_DEVICE,
output_params: None,
output_buffer: PreviewAudioDevice::new(),
output_started: false,
recording: None,
}
}
}
/// Lock the manager behind a handle; `OAKAUDIO_E_STATE` for empty handles.
fn with_instance(h: &CHandle) -> Result<MutexGuard<'static, ManagerInner>> {
if h.is_null() {
return Err(Error::State);
}
// SAFETY: `instance()` only creates borrowed handles whose ctx points at
// the MANAGER Mutex, which lives in a static for the whole process.
let m: &'static Mutex<ManagerInner> =
unsafe { &*(h.ctx as *const Mutex<ManagerInner>) };
Ok(m.lock().unwrap_or_else(|p| p.into_inner()))
}
/// Create the process-wide AudioManager (no-op when it exists). Returns
/// `OAKAUDIO_OK` or `OAKAUDIO_E_NOMEM`.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:73` (C++ allocates with `new`
/// and reports `OAKAUDIO_E_NOMEM` on exception; Rust allocation infallibly
/// panics, so the error code is never produced).
pub fn create_instance() -> Result<()> {
DESTROYED.store(false, Ordering::SeqCst);
let _ = MANAGER.get_or_init(|| Mutex::new(ManagerInner::default()));
Ok(())
}
/// Destroy the process-wide AudioManager (no-op when absent).
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:85` — the C++ singleton is
/// deleted and re-creatable; `OnceLock` cannot be reset, so a `DESTROYED`
/// flag makes [`instance`] return an empty handle (and a later
/// [`create_instance`] resurrects the existing box).
pub fn destroy_instance() {
DESTROYED.store(true, Ordering::SeqCst);
}
/// Return a handle to the process-wide AudioManager (borrowed; empty when
/// no instance exists).
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:90` (`wrap`; the handle is a
/// borrowed singleton whose addref/release are no-ops).
pub fn instance() -> CHandle {
if DESTROYED.load(Ordering::SeqCst) {
return CHandle::null();
}
match MANAGER.get() {
Some(m) => {
// SAFETY: `m` is the process-wide singleton; borrowed handles do
// not free it, so it outlives every handle.
unsafe { make_borrowed(m as *const _ as *mut Mutex<ManagerInner>) }
}
None => CHandle::null(),
}
}
/// Release a manager handle. No-op (singleton), safe on NULL/empty.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:95` — releasing never
/// destroys; just clear the caller's copy.
pub fn free(self_: *mut CHandle) {
unsafe {
if let Some(h) = self_.as_mut() {
h.ctx = std::ptr::null_mut();
}
}
}
/// Bytes between output-notify pulses (0 disables).
pub fn set_output_notify_interval(self_: &CHandle, bytes: i64) -> Result<()> {
if bytes < 0 {
return Err(Error::Invalid);
}
let mut m = with_instance(self_)?;
m.output_buffer.set_notify_interval(bytes);
Ok(())
}
/// Push a block of samples to the output device, opening/restarting the
/// stream when the params changed.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:111` — the PortAudio
/// open/start path is not bridged; the buffer is configured and written
/// directly and the "stream" is marked running. `error_buf` is written by
/// the FFI layer from the returned error.
pub fn push_to_output(
self_: &CHandle,
params: AudioParams,
samples: &[u8],
_error_buf: &mut [u8],
) -> Result<()> {
let mut m = with_instance(self_)?;
if m.output_device == PA_NO_DEVICE {
return Err(Error::Failed("No output device is set".to_string()));
}
if m.output_params.as_ref() != Some(&params) {
m.output_params = Some(params);
m.output_buffer.set_params(params);
}
m.output_buffer.write(samples);
m.output_started = true;
Ok(())
}
/// Discard buffered output.
pub fn clear_buffered_output(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_buffer.clear();
Ok(())
}
/// Stop the output stream.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:229` (`stop_output` aborts
/// the stream and clears the buffer).
pub fn stop_output(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_started = false;
m.output_buffer.clear();
Ok(())
}
/// Seconds of audio consumed by the output device since the last reset,
/// compensated for output latency; negative when no stream is running.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:169` — PortAudio's
/// `outputLatency` is not representable without a live stream, so the buffer
/// clock is used directly (the `max(0, ...)` clamp is kept).
pub fn seconds(self_: &CHandle, out: &mut f64) -> Result<()> {
let m = with_instance(self_)?;
if !m.output_started {
*out = -1.0;
return Ok(());
}
let rate = m.output_params.map(|p| p.sample_rate).unwrap_or(0);
if rate <= 0 {
*out = -1.0;
return Ok(());
}
let secs = m.output_buffer.output_frames_consumed() as f64 / f64::from(rate);
*out = secs.max(0.0);
Ok(())
}
/// Restart the output clock at zero.
pub fn reset_output_clock(self_: &CHandle) -> Result<()> {
let m = with_instance(self_)?;
m.output_buffer.reset_output_frames();
Ok(())
}
/// Current output device index (`paNoDevice` = -1) or a negative error code.
pub fn get_output_device(self_: &CHandle) -> Result<i32> {
let m = with_instance(self_)?;
Ok(m.output_device)
}
/// Set the output device index.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:238` (the device is
/// recorded and the stream closed; PortAudio's index validation and name
/// logging are not bridged).
pub fn set_output_device(self_: &CHandle, device: i32) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_device = device;
m.output_started = false;
m.output_buffer.clear();
Ok(())
}
/// Current input device index or a negative error code.
pub fn get_input_device(self_: &CHandle) -> Result<i32> {
let m = with_instance(self_)?;
Ok(m.input_device)
}
/// Set the input device index.
pub fn set_input_device(self_: &CHandle, device: i32) -> Result<()> {
let mut m = with_instance(self_)?;
m.input_device = device;
Ok(())
}
/// Close the output stream and re-initialize PortAudio.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:271` (PortAudio terminate/
/// initialize is not bridged; the output side is reset).
pub fn hard_reset(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_started = false;
m.output_buffer.clear();
Ok(())
}
/// Start recording the input device to a file via the oakcodec encoder C
/// ABI. The input stream is always captured as interleaved f32.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:278` (encoder init/open;
/// the PortAudio input stream is not bridged). On failure the encoder's
/// last-error string is surfaced when available.
pub fn start_recording(
self_: &CHandle,
params: &EncodingParams,
_error_buf: &mut [u8],
) -> Result<()> {
let mut m = with_instance(self_)?;
if m.input_device == PA_NO_DEVICE {
return Err(Error::Failed("no input device".to_string()));
}
eprintln!("MANAGER before encoder_init: audio_enabled={} codec={}", params.audio_enabled, params.audio_codec);
let mut enc = unsafe { crate::bridge::codec::oakcodec_encoder_init(params) };
eprintln!("MANAGER encoder_init null? {} ptr={:p} size={}", enc.is_null(), params as *const EncodingParams, std::mem::size_of::<EncodingParams>());
let direct = unsafe { oakcodec::ffi::encoder::oakcodec_encoder_init(params as *const EncodingParams) };
eprintln!("MANAGER direct init null? {}", direct.is_null());
if !direct.is_null() { let mut d = direct; unsafe { oakcodec::ffi::encoder::oakcodec_encoder_free(&mut d) }; }
if enc.is_null() {
return Err(Error::Failed(
"failed to open encoder for recording".to_string(),
));
}
let open_r = unsafe { crate::bridge::codec::oakcodec_encoder_open(enc) };
if open_r != 0 {
let mut buf = [0i8; 512];
let n = unsafe {
crate::bridge::codec::oakcodec_encoder_last_error(
enc,
buf.as_mut_ptr(),
buf.len() as i32,
)
};
let msg = if n > 0 {
let s = buf.split(|c| *c == 0).next().unwrap_or(&[]);
let bytes: Vec<u8> = s.iter().map(|&b| b as u8).collect();
String::from_utf8_lossy(&bytes).into_owned()
} else {
"failed to open encoder for recording".to_string()
};
unsafe { crate::bridge::codec::oakcodec_encoder_free(&mut enc) };
return Err(Error::Failed(msg));
}
m.recording = Some(enc);
Ok(())
}
/// Stop recording.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:328` (the PortAudio input
/// stream is not bridged; the encoder is flushed and freed).
pub fn stop_recording(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
if let Some(mut enc) = m.recording.take() {
unsafe {
crate::bridge::codec::oakcodec_encoder_flush(enc);
crate::bridge::codec::oakcodec_encoder_free(&mut enc);
}
}
Ok(())
}
/// Device index named by the configuration ("AudioOutput"/"AudioInput"), or
/// the default when unset/unmatched. Static; `paNoDevice` when PortAudio is
/// not initialized.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:404`.
pub fn find_config_device_by_name_s(is_output_device: bool) -> i32 {
let name = crate::config::device_name(is_output_device);
find_device_by_name_s(&name, is_output_device)
}
/// Device index whose name matches `name` exactly (empty matches nothing,
/// falls through to the default device). Static.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:410` — PortAudio device
/// enumeration cannot be bridged from this crate, so the result is always
/// `paNoDevice` and the caller falls back to the default device.
pub fn find_device_by_name_s(name: &std::ffi::CStr, _is_output_device: bool) -> i32 {
let _ = name;
PA_NO_DEVICE
}
/// Number of live oakaudio reference-counted objects (leak check).
pub fn debug_alive_count() -> i32 {
crate::handle::alive_count()
}
-148
View File
@@ -1,148 +0,0 @@
// 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/>.
//! Audio value types: `AudioParams` and the `SampleFormat` enum
//! (re-exported from oakcore-rs; planar-first ordering, values identical
//! to `olive::core::SampleFormat::Format` — `// CPP-PARITY:
//! core/include/olive/core/render/sampleformat.h:33`).
//!
//! These integer values cross the C ABI as `int`, so they MUST match the
//! authoritative C++ enums bit-for-bit.
use oakcore_rs::Rational;
/// Audio sample format: re-exported from oakcore-rs (planar-first,
/// values identical to `olive::core::SampleFormat::Format`).
/// `// CPP-PARITY: core/include/olive/core/render/sampleformat.h:33`
pub use oakcore_rs::SampleFormat;
/// Audio stream parameters, mirroring `olive::core::AudioParams`
/// (core/include/olive/core/render/audioparams.h). A plain value type;
/// never bridged through a C ABI handle — liboakcore owns the matching
/// `oakcore_audioparams_*` wrapper and is out of scope here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AudioParams {
/// Sample rate in Hz.
pub sample_rate: i32,
/// ffmpeg-style channel layout mask (0 = unknown/unspecified).
pub channel_layout: u64,
/// Sample format (see [`SampleFormat`]).
pub format: SampleFormat,
}
impl AudioParams {
/// Channel count of the current layout mask (popcount).
///
/// `// CPP-PARITY: core/src/render/audioparams.cpp:150`
/// (`AudioParams::calculate_channel_count` —
/// `channel_layout_mask_channel_count`); a layout mask of 0 yields 0,
/// NOT the stereo fallback (the fallback lives in the processor's
/// `fix_channel_layout`).
pub fn channel_count(&self) -> i32 {
self.channel_layout.count_ones() as i32
}
/// Bytes per sample per channel for the format.
///
/// `// CPP-PARITY: core/src/render/audioparams.cpp`
/// (`AudioParams::bytes_per_sample_per_channel`).
pub fn bytes_per_sample_per_channel(&self) -> i64 {
self.format.bytes_per_sample() as i64
}
/// Byte count of `samples` frames across all channels.
///
/// `// CPP-PARITY: core/src/render/audioparams.cpp:93`
/// (`AudioParams::samples_to_bytes`).
pub fn samples_to_bytes(&self, samples: i64) -> i64 {
samples * self.bytes_per_sample_per_channel() * i64::from(self.channel_count())
}
}
/// Rebuild a [`SampleFormat`] from the `int` that crossed the C ABI.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:130` — the C++ layers cast
/// the raw `int` straight onto `SampleFormat::Format`, which is UB for
/// out-of-range values but in practice wraps to whatever the enum width
/// holds. The Rust side maps unknown values to `Invalid` (a safe
/// equivalent; no contract test pins the wrapped value).
pub fn sample_format_from_i32(value: i32) -> SampleFormat {
match value {
0 => SampleFormat::U8Planar,
1 => SampleFormat::S16Planar,
2 => SampleFormat::S32Planar,
3 => SampleFormat::S64Planar,
4 => SampleFormat::F32Planar,
5 => SampleFormat::F64Planar,
6 => SampleFormat::U8,
7 => SampleFormat::S16,
8 => SampleFormat::S32,
9 => SampleFormat::S64,
10 => SampleFormat::F32,
11 => SampleFormat::F64,
_ => SampleFormat::Invalid,
}
}
/// Seconds elapsed at `frames` frames given `sample_rate`
/// (`frames/sample_rate` as a rational).
pub fn frames_to_rational(frames: i64, sample_rate: i32) -> Rational {
Rational::new(frames, i64::from(sample_rate))
}
/// Sample index of `time` seconds at `sample_rate` (rounded to nearest,
/// half away from zero).
///
/// `// CPP-PARITY: core/src/render/audioparams.cpp:81`
/// (`AudioParams::time_to_samples` uses `std::round`, not truncation).
pub fn rational_to_samples(time: Rational, sample_rate: i32) -> i64 {
(time.to_f64() * f64::from(sample_rate)).round() as i64
}
/// Continued-fraction double → rational conversion.
///
/// `// CPP-PARITY: core/src/util/rational.cpp:39`
/// (`Rational::from_double`): NaN/out-of-range → the null sentinel (0/0);
/// tiny values retried at INT64_MAX precision.
pub fn rational_from_double(flt: f64) -> Rational {
if flt.is_nan() {
return Rational::NULL;
}
if flt.abs() > f64::from(i32::MAX) + 3.0 {
return Rational::NULL;
}
// frexp: flt = f * 2^exp with f in [0.5, 1)
let (mut exponent, _frac) = {
if flt == 0.0 {
(0i32, 0.0)
} else {
let bits = flt.abs().to_bits();
let e = (((bits >> 52) & 0x7ff) as i32) - 1022;
(e, flt)
}
};
exponent = (exponent - 1).max(0);
let den: i64 = 1i64 << (62 - exponent);
let num: i64 = (flt * den as f64 + 0.5).floor() as i64;
let mut r = Rational::new(num, den);
if r.is_null() && flt != 0.0 {
// Too small to represent above; retry with maximum precision.
r = Rational::new((flt * i64::MAX as f64) as i64, i64::MAX);
}
r
}
-195
View File
@@ -1,195 +0,0 @@
// 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/>.
//! Pull-style sample buffer (`olive::PreviewAudioDevice`). The audio backend
//! (PortAudio, in `engine::audio::AudioManager`) pulls samples through
//! [`read`](PreviewAudioDevice::read) from its stream callback; the render
//! side pushes samples through [`write`](PreviewAudioDevice::write). The
//! callback-driven pull semantics are unchanged from the former QIODevice.
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Mutex;
use crate::params::AudioParams;
/// A thread-safe ring of raw interleaved sample bytes feeding the audio
/// output callback.
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.h`
/// (`PreviewAudioDevice`).
pub struct PreviewAudioDevice {
lock: Mutex<PreviewAudioDeviceInner>,
/// Frames consumed by the output callback (playback clock, includes
/// underrun zero-fill).
output_frames_consumed: AtomicI64,
}
struct PreviewAudioDeviceInner {
/// Queued sample bytes.
buffer: Vec<u8>,
/// Bytes per output frame (0 = unknown until params are set).
bytes_per_frame: i32,
/// Bytes that trigger a notify callback when crossed.
notify_interval: i64,
/// Bytes read so far in the current interval.
bytes_read: i64,
/// Callback fired when a notify interval boundary is crossed.
notify_callback: Option<Box<dyn FnMut() + Send>>,
}
impl PreviewAudioDevice {
/// Create an empty device with unknown frame size.
pub fn new() -> PreviewAudioDevice {
PreviewAudioDevice {
lock: Mutex::new(PreviewAudioDeviceInner {
buffer: Vec::new(),
bytes_per_frame: 0,
notify_interval: 0,
bytes_read: 0,
notify_callback: None,
}),
output_frames_consumed: AtomicI64::new(0),
}
}
/// Read up to `data.len()` bytes from the queued buffer. Called from the
/// audio output callback; returns bytes actually copied (0 on underrun).
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.cpp:36`
/// (`PreviewAudioDevice::read`): the notify callback fires AFTER the
/// internal lock is released, and only when an interval boundary is
/// crossed by this read.
pub fn read(&mut self, data: &mut [u8]) -> i64 {
let mut notify = false;
let copy_length;
{
let mut inner = self.lock.lock().unwrap();
copy_length = (data.len() as i64).min(inner.buffer.len() as i64);
if copy_length > 0 {
let new_bytes_read = inner.bytes_read + copy_length;
if inner.notify_interval > 0 && inner.notify_callback.is_some() {
if (inner.bytes_read / inner.notify_interval)
!= (new_bytes_read / inner.notify_interval)
{
notify = true;
}
}
inner.bytes_read = new_bytes_read;
data[..copy_length as usize]
.copy_from_slice(&inner.buffer[..copy_length as usize]);
inner.buffer.drain(..copy_length as usize);
}
}
// Fired outside the lock (see set_notify_callback())
if notify {
let mut cb = self.lock.lock().unwrap().notify_callback.take();
if let Some(c) = cb.as_mut() {
c();
}
let mut inner = self.lock.lock().unwrap();
if inner.notify_callback.is_none() {
inner.notify_callback = cb;
}
}
copy_length
}
/// Append `data` to the queued buffer.
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.cpp:69`
/// (`PreviewAudioDevice::write`).
pub fn write(&mut self, data: &[u8]) -> i64 {
let mut inner = self.lock.lock().unwrap();
inner.buffer.extend_from_slice(data);
data.len() as i64
}
/// Derive the frame size from the audio format (bytes per sample per
/// channel * channel count).
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.cpp:31`
/// (`PreviewAudioDevice::set_params` = `samples_to_bytes(1)`).
pub fn set_params(&mut self, params: AudioParams) {
self.set_bytes_per_frame(params.samples_to_bytes(1) as i32);
}
/// Current bytes per frame (0 = unknown).
pub fn bytes_per_frame(&self) -> i32 {
self.lock.lock().unwrap().bytes_per_frame
}
/// Override the frame size directly.
pub fn set_bytes_per_frame(&mut self, bytes: i32) {
self.lock.lock().unwrap().bytes_per_frame = bytes;
}
/// Set the notify interval in bytes.
pub fn set_notify_interval(&mut self, interval: i64) {
self.lock.lock().unwrap().notify_interval = interval;
}
/// Install the callback fired when a notify interval boundary is crossed.
///
/// Invoked from [`read`](PreviewAudioDevice::read) (the audio output
/// callback thread) after the internal lock is released. Must be
/// thread-safe and must not call back into this device.
pub fn set_notify_callback<F>(&mut self, callback: F)
where
F: FnMut() + Send + 'static,
{
self.lock.lock().unwrap().notify_callback = Some(Box::new(callback));
}
/// Drop all queued bytes and reset the byte counters.
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.cpp:77`
/// (`PreviewAudioDevice::clear`): also resets the consumed-frames clock.
pub fn clear(&mut self) {
let mut inner = self.lock.lock().unwrap();
inner.buffer.clear();
inner.bytes_read = 0;
self.output_frames_consumed.store(0, Ordering::Relaxed);
}
/// Account for frames consumed by the output callback (including
/// underrun zero-fill).
pub fn add_output_frames(&self, frame_count: i64) {
self.output_frames_consumed
.fetch_add(frame_count, Ordering::Relaxed);
}
/// Frames consumed by the output callback.
pub fn output_frames_consumed(&self) -> i64 {
self.output_frames_consumed.load(Ordering::Relaxed)
}
/// Reset the consumed-frames counter.
pub fn reset_output_frames(&self) {
self.output_frames_consumed.store(0, Ordering::Relaxed);
}
}
impl Default for PreviewAudioDevice {
fn default() -> PreviewAudioDevice {
PreviewAudioDevice::new()
}
}
-331
View File
@@ -1,331 +0,0 @@
// 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/>.
//! The real-time resampler/format converter (`olive::AudioProcessor`).
//!
//! Wraps the ffmpeg_bridge audio filter graph (`fb_audio_graph_*`,
//! `fb_frame_*`) via [`crate::bridge::ffmpeg`]. The conversion output is
//! always planar 32-bit float
//! (`OAKAUDIO_PROCESSOR_OUTPUT_FORMAT == SampleFormat::F32Planar == 4`).
use std::ffi::c_int;
use std::ptr;
use std::sync::Mutex;
use crate::bridge::common::oakcommon_ffmpegutils_get_ffmpeg_sample_format;
use crate::bridge::ffmpeg::{
fb_audio_graph_create, fb_audio_graph_free, fb_audio_graph_pull,
fb_audio_graph_push, fb_channel_layout_default, fb_frame_alloc,
fb_frame_free, fb_frame_get_data, fb_frame_get_nb_samples, AudioGraph,
AudioGraphConfig, Frame,
};
use crate::error::{Error, Result};
use crate::handle::{free_handle, make_owned, CHandle};
use crate::params::{AudioParams, SampleFormat};
/// A closed audio processor (reference count 1; `ctx == NULL` on allocation
/// failure).
pub struct Processor {
inner: Mutex<ProcessorInner>,
}
/// Resampler state behind the handle's mutex.
struct ProcessorInner {
/// Live filter graph (`null` = closed).
graph: *mut AudioGraph,
/// Scratch output frame reused for every pull.
out_frame: *mut Frame,
/// Input spec recorded at `open`.
from: AudioParams,
/// Output spec recorded at `open`.
to: AudioParams,
}
// SAFETY: the raw C pointers are only dereferenced through the ffmpeg_bridge
// ABI while the mutex is held, so all access is serialized; the handle's
// refcount keeps the box alive.
unsafe impl Send for ProcessorInner {}
impl Default for ProcessorInner {
fn default() -> Self {
ProcessorInner {
graph: ptr::null_mut(),
out_frame: ptr::null_mut(),
from: AudioParams {
sample_rate: 0,
channel_layout: 0,
format: SampleFormat::Invalid,
},
to: AudioParams {
sample_rate: 0,
channel_layout: 0,
format: SampleFormat::Invalid,
},
}
}
}
/// `// CPP-PARITY: src/audio/src/audioprocessor.cpp:35` — map a native
/// sample format to the bridge format via the oakcommon C ABI (`out` is
/// initialized to `-1` = none; identity in the test stub).
fn to_bridge_sample_format(fmt: SampleFormat) -> c_int {
let mut out: c_int = -1;
unsafe {
oakcommon_ffmpegutils_get_ffmpeg_sample_format(fmt as i32, &mut out);
}
out
}
/// `// CPP-PARITY: src/audio/src/audioprocessor.cpp:50` — ensure a usable
/// channel layout mask: 0 (unknown) falls back to a default layout derived
/// from the channel count, itself defaulting to stereo.
fn fix_channel_layout(params: AudioParams) -> AudioParams {
let mut result = params;
if params.channel_layout == 0 {
let mut channels = params.channel_count();
if channels <= 0 {
channels = 2;
}
result.channel_layout = unsafe { fb_channel_layout_default(channels) };
}
result
}
/// Borrow the processor state behind a handle.
fn get_processor(self_: &CHandle) -> Result<&Processor> {
// SAFETY: every non-empty handle returned by `init` boxes a `Processor`.
unsafe { crate::handle::get::<Processor>(self_) }.ok_or(Error::Invalid)
}
/// Create a closed processor.
pub fn init() -> Result<CHandle> {
Ok(make_owned(Processor {
inner: Mutex::new(ProcessorInner::default()),
}))
}
/// Release one reference to a processor (NULL/empty no-op).
pub fn free(self_: *mut CHandle) {
unsafe { free_handle(self_) };
}
/// Open the resampling/format-conversion graph. `out_format` is accepted for
/// interface completeness but the conversion output is always planar f32.
///
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:43` (validation order:
/// empty handle, already-open state, invalid rates/speed, forced output
/// format) and `src/audio/src/audioprocessor.cpp:82` (graph creation).
pub fn open(
self_: &CHandle,
from: AudioParams,
to: AudioParams,
speed: f64,
) -> Result<()> {
let p = get_processor(self_)?;
let mut inner = p.inner.lock().unwrap();
if !inner.graph.is_null() {
// C++: "tried to open a processor that was already open"
return Err(Error::State);
}
if from.sample_rate <= 0 || to.sample_rate <= 0 || speed <= 0.0 {
return Err(Error::Invalid);
}
// The C ABI delivers planar float output only; force the output format
// stage to f32p (OAKAUDIO_PROCESSOR_OUTPUT_FORMAT == 4).
if to.format != SampleFormat::F32Planar {
return Err(Error::Invalid);
}
let from_fixed = fix_channel_layout(from);
let to_fixed = fix_channel_layout(to);
let config = AudioGraphConfig {
in_sample_rate: from_fixed.sample_rate,
in_channel_layout_mask: from_fixed.channel_layout,
in_sample_format: to_bridge_sample_format(from_fixed.format),
in_channels: from_fixed.channel_count(),
out_sample_rate: to_fixed.sample_rate,
out_channel_layout_mask: to_fixed.channel_layout,
out_sample_format: to_bridge_sample_format(to_fixed.format),
out_channels: to_fixed.channel_count(),
out_is_planar: if to_fixed.format.is_planar() { 1 } else { 0 },
tempo: speed,
};
let graph = unsafe { fb_audio_graph_create(&config) };
if graph.is_null() {
// C++: "failed to create audio filter graph"
return Err(Error::Failed("failed to create audio graph".to_string()));
}
inner.graph = graph;
let out_frame = unsafe { fb_frame_alloc() };
if out_frame.is_null() {
// C++: "failed to allocate output frame"; close() unwinds the graph.
unsafe { fb_audio_graph_free(&mut inner.graph) };
return Err(Error::Failed(
"failed to allocate output frame".to_string(),
));
}
inner.out_frame = out_frame;
inner.from = from_fixed;
inner.to = to_fixed;
Ok(())
}
/// Close the graph (safe when closed; handle must be non-empty).
pub fn close(self_: &CHandle) -> Result<()> {
let p = get_processor(self_)?;
let mut inner = p.inner.lock().unwrap();
if !inner.graph.is_null() {
unsafe { fb_audio_graph_free(&mut inner.graph) };
}
if !inner.out_frame.is_null() {
unsafe { fb_frame_free(&mut inner.out_frame) };
}
Ok(())
}
/// 1 when open, 0 when closed; error for an empty handle.
pub fn is_open(self_: &CHandle) -> Result<bool> {
let p = get_processor(self_)?;
let inner = p.inner.lock().unwrap();
Ok(!inner.graph.is_null())
}
/// Push planar float input and pull converted output. Returns the number of
/// output frames written.
///
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:91` (validation, state
/// check, null `out_planar` short-circuit) and
/// `src/audio/src/audioprocessor.cpp:141` (push/pull loop, byte counting).
pub fn convert(
self_: &CHandle,
in_planar: *const *const f32,
in_frame_count: i32,
out_planar: *const *mut f32,
out_capacity_frames: i32,
) -> Result<i32> {
let p = get_processor(self_)?;
let inner = p.inner.lock().unwrap();
if inner.graph.is_null() {
return Err(Error::State);
}
if in_frame_count < 0
|| out_capacity_frames < 0
|| (in_frame_count > 0 && in_planar.is_null())
{
return Err(Error::Invalid);
}
let channels = inner.to.channel_count();
if channels <= 0 {
return Err(Error::State);
}
if in_frame_count > 0 {
// The FFI layer has no way to know the input plane count, so the
// plane pointer array is walked using the input spec recorded at
// `open` (`// CPP-PARITY: src/audio/src/audioprocessor.cpp:141`).
let in_channels = inner.from.channel_count();
let mut planes: Vec<*const u8> =
Vec::with_capacity(in_channels.max(0) as usize);
for ch in 0..in_channels {
// SAFETY: `in_planar` is non-null here and the FFI contract
// guarantees at least `from.channel_count()` entries.
let p = unsafe { *in_planar.add(ch as usize) };
planes.push(p as *const u8);
}
let r =
unsafe { fb_audio_graph_push(inner.graph, planes.as_ptr(), in_frame_count) };
if r < 0 {
return Err(Error::Failed(format!(
"failed to add frame to buffersrc: {r}"
)));
}
}
// C++: `out_planar ? &buf : nullptr` — with no destination, the input is
// pushed but nothing is pulled.
if out_planar.is_null() {
return Ok(0);
}
let mut total: i64 = 0;
loop {
let r = unsafe { fb_audio_graph_pull(inner.graph, inner.out_frame) };
if r <= 0 {
if r < 0 {
return Err(Error::Failed(format!(
"failed to pull from buffersink: {r}"
)));
}
break;
}
let nb = unsafe { fb_frame_get_nb_samples(inner.out_frame) };
if nb > 0 && total < i64::from(out_capacity_frames) {
let to_copy =
(i64::from(out_capacity_frames) - total).min(i64::from(nb)) as i32;
for ch in 0..channels {
// SAFETY: the FFI contract guarantees at least `channels`
// entries in `out_planar` (NULL entries are skipped).
let dst = unsafe { *out_planar.add(ch as usize) };
if dst.is_null() {
continue;
}
// Output is planar f32 (enforced by open()); each plane is
// `to_copy` float samples.
let src = unsafe { fb_frame_get_data(inner.out_frame, ch) };
unsafe {
ptr::copy_nonoverlapping(
src as *const u8,
dst as *mut u8,
(to_copy as usize) * 4,
);
}
}
}
total += i64::from(nb);
}
Ok(total.min(i64::from(out_capacity_frames)) as i32)
}
/// Signal end-of-input to the graph (flushes internal delay).
///
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:137` (empty handle, state)
/// and `src/audio/src/audioprocessor.cpp:210` (flush has no failure path; a
/// negative push return is logged only).
pub fn flush(self_: &CHandle) -> Result<()> {
let p = get_processor(self_)?;
let inner = p.inner.lock().unwrap();
if inner.graph.is_null() {
return Err(Error::State);
}
unsafe {
fb_audio_graph_push(inner.graph, ptr::null(), 0);
}
Ok(())
}
/// Format of the conversion output (always planar f32).
pub const OUTPUT_FORMAT: SampleFormat = SampleFormat::F32Planar;
-98
View File
@@ -1,98 +0,0 @@
// 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/>.
//! Timeline placement helpers (`olive::AudioSynchronizer`). All static in
//! C++; becomes a plain function module. Times are `core::Rational` (from
//! oakcore-rs).
use oakcore_rs::Rational;
use crate::params::rational_from_double;
/// One clip's source-time metadata.
pub struct SourceClip {
/// Source start time in seconds.
pub source_start_time: Rational,
/// Media in point in seconds.
pub media_in: Rational,
/// Whether `source_start_time` is set.
pub has_source_start_time: bool,
}
/// A timeline placement result.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Placement {
/// Candidate's timeline in point in seconds.
pub timeline_in: Rational,
/// Whether placement succeeded.
pub valid: bool,
}
/// Place the candidate on the timeline so its source time aligns with the
/// reference clip.
///
/// `// CPP-PARITY: src/audio/src/audiosynchronizer.cpp:27`
/// (`AudioSynchronizer::place_by_source_time`): invalid when either clip
/// lacks a source start time or carries a NaN rational.
pub fn place_by_source_time(
reference: &SourceClip,
candidate: &SourceClip,
reference_timeline_in: Rational,
) -> Placement {
let mut placement = Placement {
timeline_in: Rational::NULL,
valid: false,
};
if !reference.has_source_start_time
|| !candidate.has_source_start_time
|| reference.source_start_time.is_nan()
|| candidate.source_start_time.is_nan()
{
return placement;
}
let reference_head_source = reference.source_start_time + reference.media_in;
let candidate_head_source = candidate.source_start_time + candidate.media_in;
placement.timeline_in =
reference_timeline_in + candidate_head_source - reference_head_source;
placement.valid = !placement.timeline_in.is_nan();
placement
}
/// Timeline placement from a measured waveform offset.
///
/// `// CPP-PARITY: src/audio/src/audiosynchronizer.cpp:50`
/// (`AudioSynchronizer::place_by_waveform_offset`): invalid for
/// `sample_rate <= 0`.
pub fn place_by_waveform_offset(
reference_timeline_in: Rational,
candidate_offset_samples: i64,
sample_rate: i32,
) -> Placement {
let mut placement = Placement {
timeline_in: Rational::NULL,
valid: false,
};
if sample_rate <= 0 {
return placement;
}
placement.timeline_in = reference_timeline_in
+ rational_from_double(candidate_offset_samples as f64 / f64::from(sample_rate));
placement.valid = !placement.timeline_in.is_nan();
placement
}
-891
View File
@@ -1,891 +0,0 @@
// 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::{c_int, CStr};
use oakcore_rs::Rational;
use crate::bridge::codec::AudioStreamInfo;
use crate::bridge::ffmpeg::{
fb_audio_graph_create, fb_audio_graph_free, fb_audio_graph_pull,
fb_audio_graph_push, fb_decoder_close, fb_decoder_create, fb_decoder_free,
fb_decoder_get_frame, fb_decoder_get_stream_info, fb_decoder_open,
fb_frame_alloc, fb_frame_free, fb_frame_get_data, fb_frame_get_nb_samples,
fb_packet_alloc, fb_packet_free, AudioGraph, AudioGraphConfig, Decoder,
Frame, Packet, SampleFormat,
};
use crate::error::{Error, Result};
use crate::handle::{free_handle, make_owned, CHandle};
/// 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
}
}
}
// ---- Handle plumbing (mirrors processor.rs) --------------------------------
/// Create an empty waveform behind a refcounted handle (count 1).
pub fn init() -> Result<CHandle> {
Ok(make_owned(AudioVisualWaveform::new()))
}
/// Release one reference to a waveform (NULL/empty no-op).
pub fn free(self_: *mut CHandle) {
unsafe { free_handle(self_) };
}
/// Borrow the waveform behind a handle; `OAKAUDIO_E_INVALID` for empty.
pub fn get(self_: &CHandle) -> Result<&AudioVisualWaveform> {
// SAFETY: every non-empty handle returned by `init` boxes an
// `AudioVisualWaveform`.
unsafe { crate::handle::get::<AudioVisualWaveform>(self_) }.ok_or(Error::Invalid)
}
/// Mutable variant of [`get`].
pub fn get_mut(self_: &CHandle) -> Result<&mut AudioVisualWaveform> {
// SAFETY: every non-empty handle returned by `init` boxes an
// `AudioVisualWaveform`.
unsafe { crate::handle::get_mut::<AudioVisualWaveform>(self_) }.ok_or(Error::Invalid)
}
// ---- Whole-file extraction --------------------------------------------------
/// Result of [`extract`]: channel-interleaved min/max pairs.
pub struct ExtractOutcome {
/// `points * channels` channel-interleaved min/max pairs.
pub points: Vec<SamplePerChannel>,
/// Channel count of the decoded stream.
pub channels: i32,
}
/// Append a pulled frame's per-channel planar f32 samples to `pending`.
fn append_pending(pending: &mut Vec<Vec<f32>>, frame: *mut Frame, channels: i32, nb: i32) {
// SAFETY: `frame` is a live graph-output frame (`fltp`), `channels` was
// validated against the stream info and `nb` comes from the same frame.
if pending.is_empty() {
pending.resize(channels.max(0) as usize, Vec::new());
}
for ch in 0..channels {
let data = unsafe { fb_frame_get_data(frame, ch) } as *const f32;
let slice = unsafe { std::slice::from_raw_parts(data, nb as usize) };
pending[ch as usize].extend_from_slice(slice);
}
}
/// 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);
}
}
}
/// Decode a whole audio stream to a channel-interleaved min/max summary.
///
/// The stream is probed through the oakcodec decoder C ABI and decoded via
/// ffmpeg_bridge (`fb_decoder` + `fb_audio_graph`), 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 (stateless).
// SAFETY: `filename` is a NUL-terminated C string (validated by the FFI
// layer); the probe handle is freed on every path below.
let mut probe = unsafe { crate::bridge::codec::oakcodec_decoder_probe(filename.as_ptr()) };
if probe.is_null() {
return Err(Error::NotFound);
}
let mut info = unsafe { std::mem::zeroed::<AudioStreamInfo>() };
let r = unsafe {
crate::bridge::codec::oakcodec_decoder_probe_get_audio_stream(
probe,
stream_index,
&mut info,
)
};
// SAFETY: `probe` was created above and is no longer used.
unsafe { crate::bridge::codec::oakcodec_decoder_free(&mut probe) };
if r != 0 {
return Err(Error::NotFound);
}
if info.sample_rate <= 0 || info.channel_count <= 0 {
return Err(Error::Failed("invalid audio stream".to_string()));
}
let channels = info.channel_count;
if channels > EXTRACT_MAX_CHANNELS {
return Err(Error::Failed(format!(
"stream has {channels} channels (max {EXTRACT_MAX_CHANNELS})"
)));
}
// Decode the whole stream through ffmpeg_bridge.
let decoder = unsafe { fb_decoder_create() };
if decoder.is_null() {
return Err(Error::NoMem);
}
// SAFETY: `decoder` is live until `fb_decoder_free` below; every early
// return releases it first.
let open_r = unsafe { fb_decoder_open(decoder, filename.as_ptr(), info.stream_index) };
if open_r < 0 {
// SAFETY: `decoder` is a live ffmpeg_bridge decoder.
unsafe { fb_decoder_free(&mut (decoder as *mut Decoder)) };
return Err(Error::Failed(format!("failed to open decoder: {open_r}")));
}
let mut sinfo = unsafe { std::mem::zeroed::<crate::bridge::ffmpeg::FBStreamInfo>() };
if unsafe { fb_decoder_get_stream_info(decoder, &mut sinfo) } < 0 || sinfo.sample_rate <= 0 {
// SAFETY: see above.
unsafe {
fb_decoder_close(decoder);
fb_decoder_free(&mut (decoder as *mut Decoder));
}
return Err(Error::Failed(
"failed to query decoder stream info".to_string(),
));
}
let config = AudioGraphConfig {
in_sample_rate: sinfo.sample_rate,
in_channel_layout_mask: sinfo.channel_layout_mask,
in_sample_format: sinfo.sample_format,
in_channels: channels,
out_sample_rate: sinfo.sample_rate,
out_channel_layout_mask: sinfo.channel_layout_mask,
out_sample_format: SampleFormat::Fltp as c_int,
out_channels: channels,
out_is_planar: 1,
tempo: 1.0,
};
let mut packet = unsafe { fb_packet_alloc() };
let mut frame = unsafe { fb_frame_alloc() };
let mut converted = unsafe { fb_frame_alloc() };
let graph = unsafe { fb_audio_graph_create(&config) };
if packet.is_null() || frame.is_null() || converted.is_null() {
cleanup_extract(graph, &mut converted, &mut frame, &mut packet, decoder);
return Err(Error::NoMem);
}
if graph.is_null() {
cleanup_extract(graph, &mut converted, &mut frame, &mut packet, decoder);
return Err(Error::Failed(
"failed to create audio filter graph".to_string(),
));
}
let mut pending: Vec<Vec<f32>> = Vec::new();
let mut points: Vec<SamplePerChannel> = Vec::new();
// SAFETY: all handles are live; `frame` holds the decoded frame and
// `converted` the graph output.
let mut result: Result<()> = Ok(());
'decode: loop {
let r = unsafe { fb_decoder_get_frame(decoder, packet, frame) };
if r < 0 {
break; // EOF or error: stop decoding (C++ breaks on < 0)
}
// Push the decoded frame (planar pointer array; a packed source is
// read from plane 0 by the buffersrc).
let nb = unsafe { fb_frame_get_nb_samples(frame) };
let mut planes: Vec<*const u8> = Vec::with_capacity(channels as usize);
for ch in 0..channels {
// SAFETY: `frame` carries at least `channels` planes for the
// decoded format (validated stream info).
planes.push(unsafe { fb_frame_get_data(frame, ch) });
}
if unsafe { fb_audio_graph_push(graph, planes.as_ptr(), nb) } < 0 {
result = Err(Error::Failed("failed to push decoded frame".to_string()));
break 'decode;
}
// Drain the graph: pull converted output until no more is available.
loop {
let pull = unsafe { fb_audio_graph_pull(graph, converted) };
if pull < 0 {
result = Err(Error::Failed("failed to pull from graph".to_string()));
break 'decode;
}
if pull == 0 {
break;
}
let nb = unsafe { fb_frame_get_nb_samples(converted) };
append_pending(&mut pending, converted, channels, nb);
emit_points(&mut pending, channels, samples_per_point, &mut points, false);
}
}
// Flush the resampler delay (identity in the extract path, so this only
// emits the trailing partial window).
if result.is_ok() {
// SAFETY: `graph` is live; NULL channel data signals EOF.
unsafe { fb_audio_graph_push(graph, std::ptr::null(), 0) };
loop {
let pull = unsafe { fb_audio_graph_pull(graph, converted) };
if pull <= 0 {
break;
}
let nb = unsafe { fb_frame_get_nb_samples(converted) };
append_pending(&mut pending, converted, channels, nb);
}
emit_points(&mut pending, channels, samples_per_point, &mut points, true);
}
cleanup_extract(graph, &mut converted, &mut frame, &mut packet, decoder);
result?;
Ok(ExtractOutcome { points, channels })
}
/// Free every resource allocated by [`extract`] after the graph/decoder
/// creation succeeded.
fn cleanup_extract(
graph: *mut AudioGraph,
converted: &mut *mut Frame,
frame: &mut *mut Frame,
packet: &mut *mut Packet,
decoder: *mut Decoder,
) {
// SAFETY: the pointers were produced by the corresponding ffmpeg_bridge
// allocators and are freed exactly once here.
unsafe {
if !graph.is_null() {
fb_audio_graph_free(&mut (graph as *mut AudioGraph));
}
if !converted.is_null() {
fb_frame_free(converted);
}
if !frame.is_null() {
fb_frame_free(frame);
}
if !packet.is_null() {
fb_packet_free(packet);
}
fb_decoder_close(decoder);
fb_decoder_free(&mut (decoder as *mut Decoder));
}
}
-338
View File
@@ -1,338 +0,0 @@
// 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
}
-874
View File
@@ -1,874 +0,0 @@
// 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/>.
//! Shared test helpers and bridge stubs for the oakaudio contract suite.
//!
//! The library imports the other oak modules (`oakcommon`, `oakcodec`,
//! `ffmpeg_bridge`) through `extern "C"` declarations in `bridge/`. A
//! standalone `cargo test`/`cargo tarpaulin` run has no C++ objects to
//! link, so [`mod stubs`] provides minimal definitions — real enough for
//! the contract tests (a passthrough/linear filter graph, a WAV decoder,
//! a no-op config/encoder) but by no means an ffmpeg replacement. The
//! exhaustive behavior matrix is pinned by the unchanged C++ gtest suite
//! (`src/audio/tests`).
use std::path::Path;
use std::sync::Mutex;
/// Serializes tests that mutate process-wide state (the manager singleton,
/// the alive ledger) within one test binary.
pub static MANAGER_LOCK: Mutex<()> = Mutex::new(());
/// Build a planar f32 buffer with `channel_count` channels of `frame_count`
/// frames from a single-channel `source` (replicated per channel).
pub fn planar_from(source: &[f32], channel_count: usize) -> Vec<Vec<f32>> {
(0..channel_count).map(|_| source.to_vec()).collect()
}
/// A deterministic pseudo-random planar buffer (fixed seed) for stable
/// golden vectors. Samples land in `[-1, 1)`.
pub fn noisy_planar(channel_count: usize, frame_count: usize, seed: u64) -> Vec<Vec<f32>> {
let mut state = seed | 1;
let mut next = move || {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((state >> 33) as u64 & 0xFFFF) as f32 / 65535.0 * 2.0 - 1.0
};
let mut data = Vec::with_capacity(channel_count);
for _ in 0..channel_count {
data.push((0..frame_count).map(|_| next()).collect());
}
data
}
/// A silence buffer: every sample is exactly `0.0`.
pub fn silence_planar(channel_count: usize, frame_count: usize) -> Vec<Vec<f32>> {
vec![vec![0.0; frame_count]; channel_count]
}
/// Total number of `SamplePerChannel` entries in a channel-interleaved
/// waveform sample for `points` points across `channels` channels.
pub fn interleaved_len(points: usize, channels: usize) -> usize {
points * channels
}
/// Convert an `oakaudio`-style `min_max` pair layout into a plain tuple for
/// comparison in tests.
pub fn pair(min: f32, max: f32) -> (f32, f32) {
(min, max)
}
// ---- Minimal WAV fixture helpers -------------------------------------------
/// Write a 16-bit PCM WAV file (`fmt` chunk + `data` chunk, standard 44-byte
/// header). The test stubs decode exactly this layout.
pub fn write_wav(path: &Path, channels: u16, rate: u32, samples: &[i16]) -> std::io::Result<()> {
let block_align = channels * 2;
let byte_rate = rate * u32::from(block_align);
let data_size = (samples.len() * 2) as u32;
let mut out = Vec::with_capacity(44 + data_size as usize);
out.extend_from_slice(b"RIFF");
out.extend_from_slice(&(36 + data_size).to_le_bytes());
out.extend_from_slice(b"WAVE");
out.extend_from_slice(b"fmt ");
out.extend_from_slice(&16u32.to_le_bytes());
out.extend_from_slice(&1u16.to_le_bytes()); // PCM
out.extend_from_slice(&channels.to_le_bytes());
out.extend_from_slice(&rate.to_le_bytes());
out.extend_from_slice(&byte_rate.to_le_bytes());
out.extend_from_slice(&block_align.to_le_bytes());
out.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
out.extend_from_slice(b"data");
out.extend_from_slice(&data_size.to_le_bytes());
for s in samples {
out.extend_from_slice(&s.to_le_bytes());
}
std::fs::write(path, out)
}
/// Write a WAV header only (no `data` payload) with arbitrary channel and
/// rate claims — used to exercise extraction validation (e.g. the channel
/// cap) without decoding real audio.
pub fn write_wav_header_only(path: &Path, channels: u16, rate: u32) -> std::io::Result<()> {
let block_align = channels * 2;
let byte_rate = rate * u32::from(block_align);
let mut out = Vec::with_capacity(44);
out.extend_from_slice(b"RIFF");
out.extend_from_slice(&36u32.to_le_bytes());
out.extend_from_slice(b"WAVE");
out.extend_from_slice(b"fmt ");
out.extend_from_slice(&16u32.to_le_bytes());
out.extend_from_slice(&1u16.to_le_bytes());
out.extend_from_slice(&channels.to_le_bytes());
out.extend_from_slice(&rate.to_le_bytes());
out.extend_from_slice(&byte_rate.to_le_bytes());
out.extend_from_slice(&block_align.to_le_bytes());
out.extend_from_slice(&16u16.to_le_bytes());
out.extend_from_slice(b"data");
out.extend_from_slice(&0u32.to_le_bytes());
std::fs::write(path, out)
}
// ---- Bridge stubs -----------------------------------------------------------
#[allow(dead_code)]
pub mod stubs {
use std::ffi::{c_char, c_int, c_void, CStr};
use std::path::Path;
use oakaudio::bridge::codec::AudioStreamInfo;
use oakaudio::bridge::ffmpeg::{AudioGraphConfig, FBStreamInfo};
// ------------------------- oakcommon ---------------------------------
/// Not-found for every key: `config::device_name`'s two-stage query
/// treats `size <= 1` as absent and returns the empty string, so the
/// config-driven device lookup degrades to `paNoDevice` (the documented
/// bridge degradation).
#[no_mangle]
pub extern "C" fn oakcommon_config_get(
_group: *const c_char,
_key: *const c_char,
_buf: *mut c_char,
_buf_size: c_int,
) -> c_int {
-1
}
/// Every integer config reads its default.
#[no_mangle]
pub extern "C" fn oakcommon_config_get_int(
_group: *const c_char,
_key: *const c_char,
default: c_int,
) -> c_int {
default
}
/// Core `SampleFormat` (planar-first) → `FBSampleFormat` (AVSampleFormat
/// order), mirroring `src/common/src/ffmpegutils.cpp:83`.
///
/// `// CPP-PARITY: src/common/src/ffmpegutils.cpp:83`
/// (`FFmpegUtils::get_ffmpeg_sample_format`).
#[no_mangle]
pub extern "C" fn oakcommon_ffmpegutils_get_ffmpeg_sample_format(
smp_fmt: c_int,
out: *mut c_int,
) -> c_int {
let mapped = match smp_fmt {
0 => 5, // u8_p -> fb_sample_fmt_u8_p
1 => 6, // s16_p -> fb_sample_fmt_s16_p
2 => 7, // s32_p -> fb_sample_fmt_s32_p
3 => 11, // s64_p -> fb_sample_fmt_s64_p
4 => 8, // f32_p -> fb_sample_fmt_fltp
5 => 9, // f64_p -> fb_sample_fmt_dblp
6 => 0, // u8 -> fb_sample_fmt_u8
7 => 1, // s16 -> fb_sample_fmt_s16
8 => 2, // s32 -> fb_sample_fmt_s32
9 => 10, // s64 -> fb_sample_fmt_s64
10 => 3, // f32 -> fb_sample_fmt_flt
11 => 4, // f64 -> fb_sample_fmt_dbl
_ => -1, // invalid/count -> fb_sample_fmt_none
};
if out.is_null() {
return -1;
}
// SAFETY: the caller guarantees a writable int.
unsafe { *out = mapped };
0
}
// ------------------------- ffmpeg_bridge ------------------------------
/// ffmpeg-style default channel layout mask for `nb_channels` (only the
/// popcount is load-bearing for oakaudio). Masks above 63 channels
/// cannot be represented in a u64 and yield 0 (the extract cap check
/// rejects such streams before any layout use).
fn layout_for(channels: i32) -> u64 {
match channels {
1 => 0x4,
2 => 0x3,
n if n > 0 && n < 64 => (1u64 << n) - 1,
_ => 0,
}
}
#[no_mangle]
pub extern "C" fn fb_channel_layout_get_channels(mask: u64) -> c_int {
mask.count_ones() as c_int
}
#[no_mangle]
pub extern "C" fn fb_channel_layout_default(nb_channels: c_int) -> u64 {
layout_for(nb_channels)
}
/// A tiny deterministic filter graph: buffers planar-f32 (or packed s16)
/// input and emits linearly-interpolated output at `out_rate` with an
/// atempo-style tempo factor (tempo > 1 speeds up → fewer frames).
struct StubGraph {
in_rate: f64,
out_rate: f64,
in_channels: usize,
out_channels: usize,
in_format: c_int,
tempo: f64,
input: Vec<Vec<f32>>,
emitted: usize,
}
impl StubGraph {
fn available(&self) -> usize {
let len = self.input.first().map_or(0, |c| c.len());
if len == 0 {
return 0;
}
let ratio = self.out_rate / (self.in_rate * self.tempo);
((len as f64 * ratio) + 1e-9).floor() as usize
}
}
#[no_mangle]
pub extern "C" fn fb_audio_graph_create(config: *const AudioGraphConfig) -> *mut c_void {
if config.is_null() {
return std::ptr::null_mut();
}
// SAFETY: the caller guarantees a valid config pointer.
let c = unsafe { &*config };
if c.in_sample_rate <= 0
|| c.out_sample_rate <= 0
|| c.in_channels <= 0
|| c.out_channels <= 0
{
return std::ptr::null_mut();
}
let g = StubGraph {
in_rate: f64::from(c.in_sample_rate),
out_rate: f64::from(c.out_sample_rate),
in_channels: c.in_channels as usize,
out_channels: c.out_channels as usize,
in_format: c.in_sample_format,
tempo: c.tempo.max(0.001),
input: vec![Vec::new(); c.in_channels as usize],
emitted: 0,
};
Box::into_raw(Box::new(g)) as *mut c_void
}
#[no_mangle]
pub extern "C" fn fb_audio_graph_free(graph: *mut *mut c_void) {
if !graph.is_null() && !(unsafe { *graph }).is_null() {
// SAFETY: the pointer was created by `fb_audio_graph_create`.
drop(unsafe { Box::from_raw(*graph as *mut StubGraph) });
// SAFETY: the double-pointer belongs to the caller.
unsafe { *graph = std::ptr::null_mut() };
}
}
#[no_mangle]
pub extern "C" fn fb_audio_graph_push(
graph: *mut c_void,
channel_data: *const *const u8,
nb_samples: c_int,
) -> c_int {
if graph.is_null() || nb_samples < 0 {
return -1;
}
// SAFETY: the graph pointer was created by `fb_audio_graph_create`
// and is still live.
let g = unsafe { &mut *(graph as *mut StubGraph) };
if channel_data.is_null() {
return 0; // flush marker
}
for f in 0..nb_samples as usize {
for c in 0..g.in_channels {
let v = match g.in_format {
8 => {
// fltp: one f32 plane per channel.
// SAFETY: the caller guarantees `nb_samples` floats
// per plane.
let p = unsafe { *channel_data.add(c) } as *const f32;
unsafe { *p.add(f) }
}
1 => {
// s16 packed: interleaved in plane 0.
// SAFETY: the caller guarantees `nb_samples *
// channels * 2` bytes in plane 0.
let p = unsafe { *channel_data } as *const u8;
let off = (f * g.in_channels + c) * 2;
let lo = unsafe { *p.add(off) };
let hi = unsafe { *p.add(off + 1) };
f32::from(i16::from_le_bytes([lo, hi])) / 32768.0
}
_ => 0.0,
};
g.input[c].push(v);
}
}
0
}
/// A frame payload: per-plane raw bytes plus sample/format metadata.
struct StubFrame {
nb: i32,
channels: i32,
format: c_int,
rate: c_int,
layout: u64,
data: Vec<Vec<u8>>,
}
impl Default for StubFrame {
fn default() -> Self {
StubFrame {
nb: 0,
channels: 0,
format: -1,
rate: 0,
layout: 0,
data: Vec::new(),
}
}
}
#[no_mangle]
pub extern "C" fn fb_audio_graph_pull(graph: *mut c_void, out_frame: *mut c_void) -> c_int {
if graph.is_null() || out_frame.is_null() {
return -1;
}
// SAFETY: both pointers are live (created by the allocators below).
let g = unsafe { &mut *(graph as *mut StubGraph) };
let out = unsafe { &mut *(out_frame as *mut StubFrame) };
let total = g.available();
if g.emitted >= total {
return 0;
}
let nb = total - g.emitted;
out.nb = nb as i32;
out.channels = g.out_channels as i32;
out.format = 8; // fltp
out.data = vec![vec![0u8; nb * 4]; g.out_channels];
for o in 0..nb {
let pos = o as f64 * g.in_rate / g.out_rate * g.tempo;
for c in 0..g.out_channels {
let lower = (pos.floor() as usize).min(g.input[c].len() - 1);
let upper = (lower + 1).min(g.input[c].len() - 1);
let frac = pos - lower as f64;
let v = f64::from(g.input[c][lower]) * (1.0 - frac)
+ f64::from(g.input[c][upper]) * frac;
let bytes = (v as f32).to_le_bytes();
let off = o * 4;
out.data[c][off..off + 4].copy_from_slice(&bytes);
}
}
g.emitted = total;
1
}
#[no_mangle]
pub extern "C" fn fb_frame_alloc() -> *mut c_void {
Box::into_raw(Box::new(StubFrame::default())) as *mut c_void
}
#[no_mangle]
pub extern "C" fn fb_frame_free(frame: *mut *mut c_void) {
if !frame.is_null() && !(unsafe { *frame }).is_null() {
// SAFETY: the pointer was created by `fb_frame_alloc`.
drop(unsafe { Box::from_raw(*frame as *mut StubFrame) });
// SAFETY: the double-pointer belongs to the caller.
unsafe { *frame = std::ptr::null_mut() };
}
}
#[no_mangle]
pub extern "C" fn fb_frame_unref(_frame: *mut c_void) {}
#[no_mangle]
pub extern "C" fn fb_frame_get_nb_samples(frame: *const c_void) -> c_int {
if frame.is_null() {
return 0;
}
// SAFETY: the pointer is a live `StubFrame`.
unsafe { (*(frame as *const StubFrame)).nb }
}
#[no_mangle]
pub extern "C" fn fb_frame_set_nb_samples(frame: *mut c_void, nb_samples: c_int) {
if frame.is_null() {
return;
}
// SAFETY: the pointer is a live `StubFrame`.
unsafe { (*(frame as *mut StubFrame)).nb = nb_samples };
}
#[no_mangle]
pub extern "C" fn fb_frame_get_sample_rate(frame: *const c_void) -> c_int {
if frame.is_null() {
return 0;
}
// SAFETY: the pointer is a live `StubFrame`.
unsafe { (*(frame as *const StubFrame)).rate }
}
#[no_mangle]
pub extern "C" fn fb_frame_get_format(frame: *const c_void) -> c_int {
if frame.is_null() {
return -1;
}
// SAFETY: the pointer is a live `StubFrame`.
unsafe { (*(frame as *const StubFrame)).format }
}
#[no_mangle]
pub extern "C" fn fb_frame_get_channel_layout_mask(frame: *const c_void) -> u64 {
if frame.is_null() {
return 0;
}
// SAFETY: the pointer is a live `StubFrame`.
unsafe { (*(frame as *const StubFrame)).layout }
}
#[no_mangle]
pub extern "C" fn fb_frame_get_data(frame: *mut c_void, plane: c_int) -> *mut u8 {
if frame.is_null() {
return std::ptr::null_mut();
}
// SAFETY: the pointer is a live `StubFrame`.
let f = unsafe { &mut *(frame as *mut StubFrame) };
match f.data.get_mut(plane as usize) {
Some(v) => v.as_mut_ptr(),
None => std::ptr::null_mut(),
}
}
#[no_mangle]
pub extern "C" fn fb_frame_get_data_const(frame: *const c_void, plane: c_int) -> *const u8 {
if frame.is_null() {
return std::ptr::null();
}
// SAFETY: the pointer is a live `StubFrame`.
let f = unsafe { &*(frame as *const StubFrame) };
match f.data.get(plane as usize) {
Some(v) => v.as_ptr(),
None => std::ptr::null(),
}
}
#[no_mangle]
pub extern "C" fn fb_frame_get_linesize(frame: *const c_void, _plane: c_int) -> c_int {
if frame.is_null() {
return 0;
}
// SAFETY: the pointer is a live `StubFrame`.
unsafe { (*(frame as *const StubFrame)).nb * 4 }
}
/// A raw packet payload (unused by oakaudio, kept for completeness).
struct StubPacket {
data: Vec<u8>,
}
#[no_mangle]
pub extern "C" fn fb_packet_alloc() -> *mut c_void {
Box::into_raw(Box::new(StubPacket { data: Vec::new() })) as *mut c_void
}
#[no_mangle]
pub extern "C" fn fb_packet_free(packet: *mut *mut c_void) {
if !packet.is_null() && !(unsafe { *packet }).is_null() {
// SAFETY: the pointer was created by `fb_packet_alloc`.
drop(unsafe { Box::from_raw(*packet as *mut StubPacket) });
// SAFETY: the double-pointer belongs to the caller.
unsafe { *packet = std::ptr::null_mut() };
}
}
#[no_mangle]
pub extern "C" fn fb_packet_unref(_packet: *mut c_void) {}
/// 16-bit PCM WAV stream state (the only format the fixture writer
/// produces).
struct StubDecoder {
file: Option<std::fs::File>,
channels: i32,
sample_rate: i32,
block_align: usize,
remaining: usize,
total_frames: i64,
layout: u64,
}
/// WAV header facts parsed by `parse_wav`.
struct WavInfo {
channels: i32,
rate: i32,
block_align: usize,
frames: i64,
}
/// Parse the standard 44-byte PCM WAV header the fixture writer emits.
fn parse_wav(path: &Path) -> Option<WavInfo> {
let bytes = std::fs::read(path).ok()?;
if bytes.len() < 44 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
return None;
}
if &bytes[12..16] != b"fmt " || &bytes[36..40] != b"data" {
return None;
}
let fmt_size = u32::from_le_bytes(bytes[16..20].try_into().ok()?);
if fmt_size < 16 {
return None;
}
let audio_format = u16::from_le_bytes(bytes[20..22].try_into().ok()?);
let channels = u16::from_le_bytes(bytes[22..24].try_into().ok()?);
let rate = u32::from_le_bytes(bytes[24..28].try_into().ok()?);
let block_align = u16::from_le_bytes(bytes[32..34].try_into().ok()?);
let bits = u16::from_le_bytes(bytes[34..36].try_into().ok()?);
let data_size = u32::from_le_bytes(bytes[40..44].try_into().ok()?);
if audio_format != 1 || bits != 16 || channels == 0 || rate == 0 || block_align == 0 {
return None;
}
let frames = (data_size as usize / block_align as usize) as i64;
Some(WavInfo {
channels: i32::from(channels),
rate: rate as i32,
block_align: block_align as usize,
frames,
})
}
#[no_mangle]
pub extern "C" fn fb_decoder_create() -> *mut c_void {
Box::into_raw(Box::new(StubDecoder {
file: None,
channels: 0,
sample_rate: 0,
block_align: 0,
remaining: 0,
total_frames: 0,
layout: 0,
})) as *mut c_void
}
#[no_mangle]
pub extern "C" fn fb_decoder_open(
decoder: *mut c_void,
filename: *const c_char,
stream_index: c_int,
) -> c_int {
if decoder.is_null() || filename.is_null() {
return -1;
}
// SAFETY: the C string is NUL-terminated (caller contract).
let cname = unsafe { CStr::from_ptr(filename) };
let path = Path::new(cname.to_str().unwrap_or(""));
match parse_wav(path) {
Some(info) if stream_index == 0 => {
// SAFETY: the decoder pointer is a live `StubDecoder`.
let d = unsafe { &mut *(decoder as *mut StubDecoder) };
d.file = std::fs::File::open(path).ok();
// The file cursor starts at 0; skip the 44-byte WAV header
// so reads land on the data chunk (decoder_read_chunk reads
// exactly `remaining` data bytes).
if let Some(f) = d.file.as_mut() {
use std::io::{Seek, SeekFrom};
let _ = f.seek(SeekFrom::Start(44));
}
d.channels = info.channels;
d.sample_rate = info.rate;
d.block_align = info.block_align;
d.remaining = (info.frames as usize) * info.block_align;
d.total_frames = info.frames;
d.layout = layout_for(info.channels);
0
}
_ => -1,
}
}
#[no_mangle]
pub extern "C" fn fb_decoder_close(_decoder: *mut c_void) {}
#[no_mangle]
pub extern "C" fn fb_decoder_free(decoder: *mut *mut c_void) {
if !decoder.is_null() && !(unsafe { *decoder }).is_null() {
// SAFETY: the pointer was created by `fb_decoder_create`.
drop(unsafe { Box::from_raw(*decoder as *mut StubDecoder) });
// SAFETY: the double-pointer belongs to the caller.
unsafe { *decoder = std::ptr::null_mut() };
}
}
/// Read up to `max` whole frames of interleaved s16 PCM.
fn decoder_read_chunk(d: &mut StubDecoder, max_bytes: usize) -> Vec<u8> {
use std::io::Read;
if d.file.is_none() || d.remaining == 0 {
return Vec::new();
}
let mut buf = vec![0u8; max_bytes.min(d.remaining)];
let f = d.file.as_mut().unwrap();
let mut n = 0usize;
while n < buf.len() {
match f.read(&mut buf[n..]) {
Ok(0) => break,
Ok(read) => n += read,
Err(_) => break,
}
}
d.remaining = d.remaining.saturating_sub(n);
// Keep only whole frames.
let whole = n / d.block_align * d.block_align;
buf.truncate(whole);
buf
}
#[no_mangle]
pub extern "C" fn fb_decoder_get_frame(
decoder: *mut c_void,
_packet: *mut c_void,
frame: *mut c_void,
) -> c_int {
if decoder.is_null() || frame.is_null() {
return -1;
}
// SAFETY: both pointers are live stubs.
let d = unsafe { &mut *(decoder as *mut StubDecoder) };
let f = unsafe { &mut *(frame as *mut StubFrame) };
let chunk = decoder_read_chunk(d, 4096);
if chunk.is_empty() {
return -1; // EOF
}
f.nb = (chunk.len() / d.block_align) as i32;
f.channels = d.channels;
f.format = 1; // s16 packed (native WAV format)
f.rate = d.sample_rate;
f.layout = d.layout;
f.data = vec![chunk];
0
}
#[no_mangle]
pub extern "C" fn fb_decoder_get_packet(decoder: *mut c_void, packet: *mut c_void) -> c_int {
if decoder.is_null() || packet.is_null() {
return -1;
}
// SAFETY: both pointers are live stubs.
let d = unsafe { &mut *(decoder as *mut StubDecoder) };
let p = unsafe { &mut *(packet as *mut StubPacket) };
let chunk = decoder_read_chunk(d, 4096);
if chunk.is_empty() {
return -1;
}
p.data = chunk;
0
}
#[no_mangle]
pub extern "C" fn fb_decoder_get_stream_info(decoder: *const c_void, out: *mut FBStreamInfo) -> c_int {
if decoder.is_null() || out.is_null() {
return -1;
}
// SAFETY: the decoder pointer is a live `StubDecoder`; `out` is a
// caller-owned info struct.
let d = unsafe { &*(decoder as *const StubDecoder) };
unsafe {
(*out).index = 0;
(*out).codec_type = 1; // audio
(*out).codec_id = 0;
(*out).has_decoder = 1;
(*out).width = 0;
(*out).height = 0;
(*out).pixel_format = -1;
(*out).field_order = 0;
(*out).color_range = 0;
(*out).color_primaries = 2;
(*out).color_trc = 2;
(*out).sample_rate = d.sample_rate;
(*out).sample_format = 1; // s16
(*out).channel_layout_mask = d.layout;
(*out).start_time = 0;
(*out).duration = d.total_frames;
(*out).time_base_num = 1;
(*out).time_base_den = d.sample_rate.max(1);
(*out).avg_frame_rate_num = 0;
(*out).avg_frame_rate_den = 0;
}
0
}
#[no_mangle]
pub extern "C" fn fb_decoder_get_format_start_time(decoder: *const c_void) -> i64 {
if decoder.is_null() {
return 0;
}
0
}
#[no_mangle]
pub extern "C" fn fb_decoder_get_format_duration(decoder: *const c_void) -> i64 {
if decoder.is_null() {
return 0;
}
// SAFETY: the decoder pointer is a live `StubDecoder`.
unsafe { (*(decoder as *const StubDecoder)).total_frames }
}
// ------------------------- oakcodec ----------------------------------
struct StubEncoder;
#[no_mangle]
pub extern "C" fn oakcodec_encoder_init(params: *const c_void) -> *mut c_void {
if params.is_null() {
return std::ptr::null_mut();
}
Box::into_raw(Box::new(StubEncoder)) as *mut c_void
}
#[no_mangle]
pub extern "C" fn oakcodec_encoder_open(_encoder: *mut c_void) -> c_int {
0
}
#[no_mangle]
pub extern "C" fn oakcodec_encoder_write_audio(
_encoder: *mut c_void,
_samples: *const f32,
_frame_count: c_int,
) -> c_int {
0
}
#[no_mangle]
pub extern "C" fn oakcodec_encoder_flush(_encoder: *mut c_void) -> c_int {
0
}
#[no_mangle]
pub extern "C" fn oakcodec_encoder_last_error(
_encoder: *mut c_void,
_buf: *mut c_char,
_buf_size: c_int,
) -> c_int {
0
}
#[no_mangle]
pub extern "C" fn oakcodec_encoder_free(encoder: *mut c_void) {
if !encoder.is_null() {
// SAFETY: the pointer was created by `oakcodec_encoder_init`.
drop(unsafe { Box::from_raw(encoder as *mut StubEncoder) });
}
}
/// Probe result for a decodable WAV file.
struct StubProbe {
channels: i32,
sample_rate: i32,
block_align: usize,
frames: i64,
layout: u64,
}
#[no_mangle]
pub extern "C" fn oakcodec_decoder_probe(filename: *const c_char) -> *mut c_void {
if filename.is_null() {
return std::ptr::null_mut();
}
// SAFETY: the C string is NUL-terminated (caller contract).
let cname = unsafe { CStr::from_ptr(filename) };
let path = Path::new(cname.to_str().unwrap_or(""));
match parse_wav(path) {
Some(info) => {
Box::into_raw(Box::new(StubProbe {
channels: info.channels,
sample_rate: info.rate,
block_align: info.block_align,
frames: info.frames,
layout: layout_for(info.channels),
})) as *mut c_void
}
None => std::ptr::null_mut(),
}
}
#[no_mangle]
pub extern "C" fn oakcodec_decoder_free(probe: *mut c_void) {
if !probe.is_null() {
// SAFETY: the pointer was created by `oakcodec_decoder_probe`.
drop(unsafe { Box::from_raw(probe as *mut StubProbe) });
}
}
#[no_mangle]
pub extern "C" fn oakcodec_decoder_probe_audio_stream_count(_probe: *mut c_void) -> c_int {
1
}
#[no_mangle]
pub extern "C" fn oakcodec_decoder_probe_get_audio_stream(
probe: *mut c_void,
index: c_int,
out: *mut AudioStreamInfo,
) -> c_int {
if probe.is_null() || out.is_null() || index != 0 {
return -1;
}
// SAFETY: the probe pointer is a live `StubProbe`; `out` is a
// caller-owned info struct.
let p = unsafe { &*(probe as *const StubProbe) };
unsafe {
(*out).stream_index = 0;
(*out).sample_rate = p.sample_rate;
(*out).channel_layout = p.layout;
(*out).channel_count = p.channels;
(*out).duration_ts = p.frames;
(*out).time_base_num = 1;
(*out).time_base_den = p.sample_rate;
}
0
}
#[no_mangle]
pub extern "C" fn oakcodec_decoder_open(
_decoder: *mut c_void,
_filename: *const c_char,
_stream_index: c_int,
) -> c_int {
0
}
#[no_mangle]
pub extern "C" fn oakcodec_decoder_decode_audio(
_decoder: *mut c_void,
_in_num: c_int,
_in_den: c_int,
_out_num: c_int,
_out_den: c_int,
_sample_rate: c_int,
_channel_layout: u64,
_buf: *mut f32,
_buf_frames: c_int,
) -> c_int {
0
}
}
-197
View File
@@ -1,197 +0,0 @@
// 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/>.
//! FFI-layer contract tests (ffi.rs). The exhaustive matrix runs against
//! the unchanged C++ gtest suite (`src/audio/tests`); these tests pin
//! Rust-side specifics (handle contracts, the singleton ledger, struct
//! layout).
mod common;
use std::mem::{align_of, size_of};
use std::sync::Mutex;
use oakaudio::error::{OAKAUDIO_E_INVALID, OAKAUDIO_OK};
use oakaudio::ffi::levelmeter::{ChannelStats, MeterStats};
use oakaudio::ffi::levelmeter::oakaudio_levelmeter_analyze;
use oakaudio::ffi::processor::{oakaudio_processor_free, oakaudio_processor_init};
use oakaudio::ffi::sync::{OffsetResult, SourceClip};
use oakaudio::ffi::waveform::{oakaudio_waveform_free, oakaudio_waveform_init};
use oakaudio::ffi::manager::{
oakaudio_debug_alive_count, oakaudio_manager_create_instance,
oakaudio_manager_destroy_instance, oakaudio_manager_free, oakaudio_manager_instance,
};
/// Serializes tests that touch the process-wide singleton and the alive
/// ledger.
static LOCK: Mutex<()> = Mutex::new(());
/// Every exported handle-returning function (processor_init, waveform_init)
/// returns ctx==NULL on failure and a valid refcounted handle on success,
/// with abi_version == OAKAUDIO_ABI_VERSION stamped.
#[test]
fn handle_contract_all_exports() {
let _guard = LOCK.lock().unwrap();
let mut p = unsafe { oakaudio_processor_init() };
assert!(!p.ctx.is_null());
assert_eq!(p.abi_version, oakaudio::handle::OAKAUDIO_ABI_VERSION);
let mut w = unsafe { oakaudio_waveform_init() };
assert!(!w.ctx.is_null());
assert_eq!(w.abi_version, oakaudio::handle::OAKAUDIO_ABI_VERSION);
unsafe { oakaudio_processor_free(&mut p) };
unsafe { oakaudio_waveform_free(&mut w) };
}
/// free(NULL)/free(empty) are no-ops across every free export.
#[test]
fn free_null_noop_all_exports() {
let _guard = LOCK.lock().unwrap();
let mut p = oakaudio::handle::CHandle::null();
unsafe { oakaudio_processor_free(&mut p) };
assert!(p.ctx.is_null());
let mut w = oakaudio::handle::CHandle::null();
unsafe { oakaudio_waveform_free(&mut w) };
assert!(w.ctx.is_null());
let mut m = oakaudio::handle::CHandle::null();
unsafe { oakaudio_manager_free(&mut m) };
assert!(m.ctx.is_null());
// NULL pointer itself is a no-op.
unsafe { oakaudio_processor_free(std::ptr::null_mut()) };
unsafe { oakaudio_waveform_free(std::ptr::null_mut()) };
unsafe { oakaudio_manager_free(std::ptr::null_mut()) };
}
/// The manager singleton: instance() is the same borrowed handle across
/// calls; create/destroy flip validity; oakaudio_debug_alive_count moves
/// predictably and returns to baseline.
#[test]
fn manager_singleton_and_alive_count() {
let _guard = LOCK.lock().unwrap();
let before = unsafe { oakaudio_debug_alive_count() };
unsafe { oakaudio_manager_destroy_instance() };
let none = unsafe { oakaudio_manager_instance() };
assert!(none.ctx.is_null());
// The empty instance handle is the shared `null()` (no ABI version).
assert_eq!(none.abi_version, 0);
unsafe { oakaudio_manager_create_instance() };
let m1 = unsafe { oakaudio_manager_instance() };
let m2 = unsafe { oakaudio_manager_instance() };
assert!(!m1.ctx.is_null());
assert_eq!(m1.ctx, m2.ctx, "instance() must be the same borrowed handle");
// A processor bumps the ledger; freeing it returns to baseline.
assert_eq!(unsafe { oakaudio_debug_alive_count() }, before);
let mut p = unsafe { oakaudio_processor_init() };
assert_eq!(unsafe { oakaudio_debug_alive_count() }, before + 1);
unsafe { oakaudio_processor_free(&mut p) };
assert_eq!(unsafe { oakaudio_debug_alive_count() }, before);
// Destroy flips the singleton back to empty; create resurrects it.
unsafe { oakaudio_manager_destroy_instance() };
assert!(unsafe { oakaudio_manager_instance() }.ctx.is_null());
unsafe { oakaudio_manager_create_instance() };
assert!(!unsafe { oakaudio_manager_instance() }.ctx.is_null());
}
/// oakaudio_levelmeter_analyze with NULL summary still computes per-channel
/// stats, and a NULL channels array with capacity 0 is accepted when only
/// the summary is wanted.
#[test]
fn levelmeter_partial_outputs() {
let data = [0.5f32; 64];
let planes = [data.as_ptr()];
// channels only (summary NULL)
let mut channels = [ChannelStats {
peak_linear: 0.0,
peak_db: 0.0,
rms_linear: 0.0,
rms_db: 0.0,
vu_db: 0.0,
}];
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(
planes.as_ptr(),
1,
64,
channels.as_mut_ptr(),
1,
std::ptr::null_mut(),
)
},
OAKAUDIO_OK
);
assert!((channels[0].peak_linear - 0.5).abs() < 1e-9);
// summary only (channels NULL, capacity 0)
let mut summary = MeterStats {
max_peak_linear: 0.0,
integrated_lufs: 0.0,
silence: 0,
};
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(
planes.as_ptr(),
1,
64,
std::ptr::null_mut(),
0,
&mut summary,
)
},
OAKAUDIO_OK
);
assert!((summary.max_peak_linear - 0.5).abs() < 1e-9);
// Both NULL is invalid.
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(
planes.as_ptr(),
1,
64,
std::ptr::null_mut(),
0,
std::ptr::null_mut(),
)
},
OAKAUDIO_E_INVALID
);
}
/// sync value structs (offset_result/source_clip) are 24/40 bytes and
/// repr(C)-aligned as the C headers dictate, so layout never drifts.
#[test]
fn sync_struct_layout() {
assert_eq!(size_of::<OffsetResult>(), 24);
assert_eq!(align_of::<OffsetResult>(), 8);
assert_eq!(size_of::<SourceClip>(), 40);
assert_eq!(align_of::<SourceClip>(), 8);
// The stretch result (f64, i64, f64, i32) pads to 32 bytes.
assert_eq!(size_of::<oakaudio::ffi::sync::StretchOffsetResult>(), 32);
}
-212
View File
@@ -1,212 +0,0 @@
// 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/>.
//! Cross-cutting golden/parity tests: values captured from the C++
//! implementation to pin exact behavior of the Rust rewrite.
mod common;
use common::write_wav_header_only;
use oakcore_rs::Rational;
use oakaudio::ffi::waveform::{
oakaudio_waveform_extract, oakaudio_waveform_free, oakaudio_waveform_get_summary,
oakaudio_waveform_init, oakaudio_waveform_overwrite_samples,
oakaudio_waveform_set_channel_count,
};
use oakaudio::ffi::waveform::MinMax;
use oakaudio::params::{
frames_to_rational, rational_to_samples, SampleFormat,
};
/// SampleFormat planar-first ordering matches the authoritative C++ enum:
/// f32_p == 4 == OAKAUDIO_PROCESSOR_OUTPUT_FORMAT. This guards the
/// oakcore-rs ordering divergence documented in params.rs.
#[test]
fn sample_format_planar_first_ordering() {
assert_eq!(SampleFormat::F32Planar as i32, 4);
assert_eq!(oakaudio::processor::OUTPUT_FORMAT as i32, 4);
// Invalid is -1 and the packed family follows planar-first.
assert_eq!(SampleFormat::Invalid as i32, -1);
assert_eq!(SampleFormat::U8Planar as i32, 0);
assert_eq!(SampleFormat::F64 as i32, 11);
}
/// Rational time<->sample conversions (frames_to_rational /
/// rational_to_samples) round-trip 48000 Hz sample counts exactly.
#[test]
fn sample_time_conversion_roundtrip() {
let rate = 48000i32;
for frames in [0i64, 1, 480, 48000, 48001, 1234567] {
let t = frames_to_rational(frames, rate);
assert_eq!(rational_to_samples(t, rate), frames);
}
assert_eq!(frames_to_rational(48000, 48000), Rational::new(1, 1));
assert_eq!(frames_to_rational(1, 48000), Rational::new(1, 48000));
}
/// AudioParams value-type conversions: channel count from the layout mask,
/// bytes-per-sample-per-channel, samples_to_bytes, and the double ->
/// rational conversion edge cases (NaN / out-of-range / tiny -> null).
#[test]
fn params_value_types() {
use oakaudio::params::{rational_from_double, AudioParams};
let p = AudioParams {
sample_rate: 48000,
channel_layout: 3,
format: SampleFormat::F32,
};
assert_eq!(p.channel_count(), 2);
assert_eq!(p.bytes_per_sample_per_channel(), 4);
assert_eq!(p.samples_to_bytes(480), 480 * 4 * 2);
assert_eq!(rational_from_double(0.5), Rational::new(1, 2));
assert_eq!(rational_from_double(1.0), Rational::new(1, 1));
assert!(rational_from_double(f64::NAN).is_null());
assert!(rational_from_double(1e10).is_null());
assert!(rational_from_double(1e-20).is_null());
assert!((rational_from_double(0.25).to_f64() - 0.25).abs() < 1e-9);
}
/// AudioVisualWaveform mipmap layout: get_summary at a fine zoom scale
/// covers fewer source samples than at a coarse scale, so the returned
/// min/max pair brackets exactly the mipmapped window. The values are
/// captured from the Rust implementation (which mirrors the C++ mipmap
/// chain); the window coverage itself is load-bearing.
#[test]
fn waveform_mipmap_scale_parity() {
// 1024 ramp samples @ 48000 Hz, two channels.
let ch0: Vec<f32> = (0..1024).map(|i| i as f32 * 0.001).collect();
let ch1: Vec<f32> = (0..1024).map(|i| -(i as f32) * 0.001).collect();
let planes = [ch0.as_ptr(), ch1.as_ptr()];
let mut w = unsafe { oakaudio_waveform_init() };
assert!(!w.ctx.is_null());
assert_eq!(unsafe { oakaudio_waveform_set_channel_count(w, 2) }, 0);
assert_eq!(
unsafe { oakaudio_waveform_overwrite_samples(w, planes.as_ptr(), 1024, 48000, 0, 1) },
0
);
// One summary point is produced for any queried window; a 1/1024 s
// window (one 1024-rate mipmap point ~ 46.875 source samples) must
// bracket a narrower range than a 1/64 s window (~750 samples).
let mut fine = [MinMax { min: 0.0, max: 0.0 }; 2];
let fine_points = unsafe {
oakaudio_waveform_get_summary(w, 0, 1, 1, 1024, fine.as_mut_ptr(), 2)
};
assert_eq!(fine_points, 1);
assert_eq!(fine[0].min, 0.0);
assert!((fine[0].max - 0.046).abs() < 1e-5, "fine max = {}", fine[0].max);
assert!((fine[1].min + 0.046).abs() < 1e-5, "fine min = {}", fine[1].min);
assert_eq!(fine[1].max, 0.0);
let mut coarse = [MinMax { min: 0.0, max: 0.0 }; 2];
let coarse_points =
unsafe { oakaudio_waveform_get_summary(w, 0, 1, 1, 64, coarse.as_mut_ptr(), 2) };
assert_eq!(coarse_points, 1);
assert_eq!(coarse[0].min, 0.0);
assert!((coarse[0].max - 0.749).abs() < 1e-5, "coarse max = {}", coarse[0].max);
assert!((coarse[1].min + 0.749).abs() < 1e-5, "coarse min = {}", coarse[1].min);
assert_eq!(coarse[1].max, 0.0);
// Coarser windows necessarily cover more source samples.
assert!(coarse[0].max > fine[0].max);
assert!(coarse[1].min < fine[1].min);
unsafe { oakaudio_waveform_free(&mut w) };
}
/// levelmeter dB conversion: peak_db == 20*log10(peak_linear) and the
/// -200 dB floor match the C++ helpers for the same sample values.
#[test]
fn levelmeter_db_golden() {
let tone = common::planar_from(&[0.5f32; 64], 1);
let refs: Vec<&[f32]> = tone.iter().map(|v| v.as_slice()).collect();
let stats = oakaudio::levelmeter::analyze_sample_buffer(&refs);
let expected_db = 20.0 * 0.5f64.log10();
assert!((stats.channels[0].peak_db - expected_db).abs() < 1e-9);
assert!((stats.channels[0].rms_db - expected_db).abs() < 1e-9);
let silence = common::silence_planar(1, 64);
let refs: Vec<&[f32]> = silence.iter().map(|v| v.as_slice()).collect();
let stats = oakaudio::levelmeter::analyze_sample_buffer(&refs);
assert_eq!(stats.channels[0].peak_db, -200.0);
assert_eq!(stats.channels[0].rms_db, -200.0);
assert_eq!(stats.channels[0].vu_db, -200.0);
}
/// waveformsync envelope offset golden: a reference ramp delayed by two
/// windows in the candidate is recovered as +2 windows with full
/// confidence through the C ABI.
#[test]
fn waveform_sync_offset_golden() {
use oakaudio::ffi::sync::{
oakaudio_sync_estimate_envelope_offset, OffsetResult,
};
let reference: Vec<f64> = (0..10).map(|i| i as f64 * 0.1 + 0.1).collect();
let mut candidate = vec![0.0f64; 10];
candidate[2..].copy_from_slice(&reference[..8]);
let mut out = OffsetResult {
offset_samples: 0,
confidence: 0.0,
valid: 0,
};
let r = unsafe {
oakaudio_sync_estimate_envelope_offset(
reference.as_ptr(),
reference.len() as i32,
candidate.as_ptr(),
candidate.len() as i32,
std::ptr::null(),
std::ptr::null(),
100,
10,
&mut out,
)
};
assert_eq!(r, 0);
assert_eq!(out.valid, 1);
assert_eq!(out.offset_samples, 200);
assert!((out.confidence - 1.0).abs() < 1e-9);
}
/// oakaudio_waveform_extract channel cap: a stream claiming more than
/// OAKAUDIO_EXTRACT_MAX_CHANNELS (64) channels is rejected rather than
/// overflowing the internal plane array.
#[test]
fn extract_channel_cap() {
let path = std::env::temp_dir().join(format!(
"oakaudio_cap_{}.wav",
std::process::id()
));
write_wav_header_only(&path, 65, 48000).unwrap();
let mut out_channels = 0i32;
let cpath = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
let r = unsafe {
oakaudio_waveform_extract(
cpath.as_ptr(),
0,
4,
std::ptr::null_mut(),
0,
&mut out_channels,
)
};
assert!(r < 0, "oversized stream must be rejected, got {r}");
std::fs::remove_file(&path).ok();
}
-112
View File
@@ -1,112 +0,0 @@
// 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/>.
//! Handle plumbing contract tests (handle.rs).
use oakaudio::error::{OAKAUDIO_E_FAILED, OAKAUDIO_E_INVALID, OAKAUDIO_OK};
use oakaudio::handle::{
alive_count, get, guard, guard_handle, make_borrowed, make_owned, CHandle,
};
/// make_owned starts at refcount 1; get returns a typed view; dropping the
/// handle decrements to 0.
#[test]
fn owned_lifecycle() {
let before = alive_count();
let mut h = make_owned(42u32);
assert!(!h.is_null());
assert_eq!(alive_count(), before + 1);
// SAFETY: `h` boxes a u32 created above.
let v = unsafe { get::<u32>(&h) }.unwrap();
assert_eq!(*v, 42);
// addref/release round-trip through the function pointers.
let addref = h.addref.unwrap();
let release = h.release.unwrap();
// SAFETY: the ctx was created by make_owned.
unsafe { addref(h.ctx) };
assert_eq!(alive_count(), before + 1); // count unchanged, still 1 box
unsafe { release(h.ctx) };
// Dropping the box (release to zero) decrements the ledger.
let release = h.release.unwrap();
// SAFETY: h.ctx is the box created above; refcount is 1.
unsafe { release(h.ctx) };
h.ctx = std::ptr::null_mut();
assert_eq!(alive_count(), before);
}
/// make_borrowed creates a borrow-only handle whose release frees only the
/// box, never the underlying object.
#[test]
fn borrowed_release() {
let mut value = Box::new(7i32);
// SAFETY: `value` outlives the handle; the ctx points directly at the
// box (not a RefBox), so it is read through the raw pointer.
let mut h = unsafe { make_borrowed(&mut *value) };
assert!(!h.is_null());
// SAFETY: `h.ctx` points at the box created above.
let v = unsafe { &*(h.ctx as *const i32) };
assert_eq!(*v, 7);
// release is a no-op: the box still lives.
let release = h.release.unwrap();
// SAFETY: noop_ref for borrowed handles.
unsafe { release(h.ctx) };
assert_eq!(*value, 7);
h.ctx = std::ptr::null_mut();
}
/// CHandle::null() yields an empty handle; guard over an Ok(()) returns
/// OAKAUDIO_OK (0) and guard_handle over Ok returns a valid handle.
#[test]
fn null_and_guard_ok() {
let null = CHandle::null();
assert!(null.is_null());
// The shared `null()` stamps no ABI version (single-lib unification).
assert_eq!(null.abi_version, 0);
assert_eq!(guard(|| Ok(())), OAKAUDIO_OK);
let h = guard_handle(|| Ok(make_owned(1u32)));
assert!(!h.is_null());
// Release it so the alive ledger returns to baseline (tests share the
// process-wide ledger and run in parallel).
// SAFETY: `h.ctx` is the box created above; refcount is 1.
unsafe { (h.release.unwrap())(h.ctx) };
}
/// guard maps an Err to the negative error code without panicking; a
/// panicking body is caught and returns a failure code rather than
/// unwinding across the FFI boundary.
#[test]
fn guard_error_and_panic() {
assert_eq!(guard(|| Err(oakaudio::error::Error::Invalid)), OAKAUDIO_E_INVALID);
assert_eq!(guard(|| Err(oakaudio::error::Error::State)), -60002);
assert_eq!(guard(|| Err(oakaudio::error::Error::Failed("x".to_string()))), -60003);
assert_eq!(guard(|| Err(oakaudio::error::Error::NotFound)), -60004);
assert_eq!(guard(|| Err(oakaudio::error::Error::NoMem)), -60005);
assert_eq!(guard(|| panic!("boom")), OAKAUDIO_E_FAILED);
let h = guard_handle(|| Err(oakaudio::error::Error::Invalid));
assert!(h.is_null());
let h2 = guard_handle(|| panic!("boom"));
assert!(h2.is_null());
}
-158
View File
@@ -1,158 +0,0 @@
// 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/>.
//! AudioLevelMeter contract tests (levelmeter.rs), through the C ABI.
mod common;
use oakaudio::error::{OAKAUDIO_E_INVALID, OAKAUDIO_OK};
use oakaudio::ffi::levelmeter::{
oakaudio_levelmeter_analyze, ChannelStats, MeterStats,
};
fn analyze(planes: &[Vec<f32>]) -> (Vec<ChannelStats>, MeterStats) {
let ptrs: Vec<*const f32> = planes.iter().map(|p| p.as_ptr()).collect();
let mut channels: Vec<ChannelStats> = (0..planes.len())
.map(|_| ChannelStats {
peak_linear: 0.0,
peak_db: 0.0,
rms_linear: 0.0,
rms_db: 0.0,
vu_db: 0.0,
})
.collect();
let mut summary = MeterStats {
max_peak_linear: 0.0,
integrated_lufs: 0.0,
silence: 0,
};
let r = unsafe {
oakaudio_levelmeter_analyze(
ptrs.as_ptr(),
planes.len() as i32,
planes.first().map_or(0, |p| p.len()) as i32,
channels.as_mut_ptr(),
channels.len() as i32,
&mut summary,
)
};
assert_eq!(r, OAKAUDIO_OK);
(channels, summary)
}
/// A silence buffer reports silence=1, all-zero linear fields, and dB
/// fields floored at -200.
#[test]
fn silence_analysis() {
let planes = common::silence_planar(2, 64);
let (channels, summary) = analyze(&planes);
assert_eq!(summary.silence, 1);
assert_eq!(summary.max_peak_linear, 0.0);
assert_eq!(summary.integrated_lufs, -200.0);
for ch in &channels {
assert_eq!(ch.peak_linear, 0.0);
assert_eq!(ch.rms_linear, 0.0);
assert_eq!(ch.peak_db, -200.0);
assert_eq!(ch.rms_db, -200.0);
assert_eq!(ch.vu_db, -200.0);
}
}
/// A constant-amplitude tone reports peak_linear == rms_linear == that
/// amplitude (power terms), peak_db matches 20*log10(amp), and silence=0.
#[test]
fn constant_tone_stats() {
let planes = common::planar_from(&[0.5f32; 64], 1);
let (channels, summary) = analyze(&planes);
assert_eq!(summary.silence, 0);
assert!((channels[0].peak_linear - 0.5).abs() < 1e-9);
assert!((channels[0].rms_linear - 0.5).abs() < 1e-9);
let expected_db = 20.0 * 0.5f64.log10();
assert!((channels[0].peak_db - expected_db).abs() < 1e-9);
assert!((channels[0].rms_db - expected_db).abs() < 1e-9);
}
/// A full-scale square wave yields max_peak_linear == 1.0 and a peak_db
/// near 0 dB; per-channel channels array is filled for each channel.
#[test]
fn full_scale_peak() {
let ch0: Vec<f32> = (0..64).map(|i| if i % 2 == 0 { 1.0 } else { -1.0 }).collect();
let ch1: Vec<f32> = (0..64).map(|i| if i % 2 == 0 { -1.0 } else { 1.0 }).collect();
let (channels, summary) = analyze(&[ch0, ch1]);
assert_eq!(summary.max_peak_linear, 1.0);
assert_eq!(summary.silence, 0);
assert!((channels[0].peak_db - 0.0).abs() < 1e-9);
assert!((channels[1].peak_db - 0.0).abs() < 1e-9);
assert!((channels[0].rms_linear - 1.0).abs() < 1e-9);
assert!((channels[1].rms_linear - 1.0).abs() < 1e-9);
}
/// integrated_lufs stays -200 for silence and matches the BS.1770
/// mean-square formula (no K-weighting) for a tone.
#[test]
fn integrated_lufs_silence_vs_tone() {
let silence = common::silence_planar(2, 64);
let (_, summary) = analyze(&silence);
assert_eq!(summary.integrated_lufs, -200.0);
let tone = common::planar_from(&[0.5f32; 64], 2);
let (_, summary) = analyze(&tone);
// mean square over all channels = 0.25; -0.691 + 10*log10(0.25)
let expected = -0.691 + 10.0 * 0.25f64.log10();
assert!((summary.integrated_lufs - expected).abs() < 1e-9);
}
/// channel_count of 0, a NULL planar pointer, or NULL for both outputs
/// returns OAKAUDIO_E_INVALID.
#[test]
fn invalid_input() {
let planes = common::planar_from(&[0.5f32; 8], 1);
let ptr = planes[0].as_ptr();
let mut summary = MeterStats {
max_peak_linear: 0.0,
integrated_lufs: 0.0,
silence: 0,
};
// channel_count 0.
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(&ptr, 0, 8, std::ptr::null_mut(), 0, &mut summary)
},
OAKAUDIO_E_INVALID
);
// NULL planar.
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(std::ptr::null(), 1, 8, std::ptr::null_mut(), 0, &mut summary)
},
OAKAUDIO_E_INVALID
);
// Both outputs NULL.
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(&ptr, 1, 8, std::ptr::null_mut(), 0, std::ptr::null_mut())
},
OAKAUDIO_E_INVALID
);
// Negative frame count.
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(&ptr, 1, -1, std::ptr::null_mut(), 0, &mut summary)
},
OAKAUDIO_E_INVALID
);
}
-373
View File
@@ -1,373 +0,0 @@
// 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/>.
//! AudioManager contract tests (manager.rs), through the C ABI. The
//! manager is a process-wide singleton, so every test holds the shared
//! `MANAGER_LOCK`.
mod common;
use std::ffi::c_char;
use common::MANAGER_LOCK;
use oakaudio::bridge::codec::EncodingParams;
use oakaudio::error::{
OAKAUDIO_E_FAILED, OAKAUDIO_E_INVALID, OAKAUDIO_OK,
};
use oakaudio::ffi::manager::{
oakaudio_debug_alive_count, oakaudio_manager_clear_buffered_output,
oakaudio_manager_create_instance, oakaudio_manager_destroy_instance,
oakaudio_manager_find_config_device_by_name_s, oakaudio_manager_find_device_by_name_s,
oakaudio_manager_free, oakaudio_manager_get_input_device,
oakaudio_manager_get_output_device, oakaudio_manager_hard_reset,
oakaudio_manager_instance, oakaudio_manager_push_to_output,
oakaudio_manager_reset_output_clock, oakaudio_manager_seconds,
oakaudio_manager_set_input_device, oakaudio_manager_set_output_device,
oakaudio_manager_set_output_notify_interval, oakaudio_manager_start_recording,
oakaudio_manager_stop_output, oakaudio_manager_stop_recording,
};
fn instance() -> oakaudio::handle::CHandle {
unsafe { oakaudio_manager_instance() }
}
/// Lock the manager singleton for a test. The manager state persists across
/// tests (the `OnceLock` cannot be reset), so a panicked test must not
/// poison the lock for the rest of the binary.
fn lock() -> std::sync::MutexGuard<'static, ()> {
MANAGER_LOCK.lock().unwrap_or_else(|p| p.into_inner())
}
fn encoding_params() -> EncodingParams {
let mut filename = [0u8; 1024];
for (i, b) in b"oakaudio_test.wav\0".iter().enumerate() {
filename[i] = *b;
}
EncodingParams {
filename,
format: 0,
video_enabled: 0,
video_codec: 0,
video_width: 0,
video_height: 0,
video_time_base_num: 0,
video_time_base_den: 0,
video_pixel_format: 0,
video_interlacing: 0,
video_pixel_aspect_num: 0,
video_pixel_aspect_den: 0,
video_bit_rate: 0,
video_min_bit_rate: 0,
video_max_bit_rate: 0,
video_buffer_size: 0,
video_threads: 0,
video_pix_fmt: [0u8; 64],
video_is_image_sequence: 0,
video_scaling_method: 0,
audio_enabled: 1,
audio_codec: 13, // PCM_S16LE (the .wav recording codec)
audio_sample_rate: 48000,
audio_channel_layout: 3,
audio_sample_format: 8,
audio_bit_rate: 128000,
subtitles_enabled: 0,
subtitles_codec: 0,
subtitles_are_sidecar: 0,
subtitles_sidecar_format: 0,
color_transform_output: [0u8; 256],
export_length_num: 0,
export_length_den: 0,
has_custom_range: 0,
custom_range_in_num: 0,
custom_range_in_den: 0,
custom_range_out_num: 0,
custom_range_out_den: 0,
}
}
/// create_instance/destroy_instance toggle the singleton; instance() returns
/// a valid borrowed handle between them and NULL after destroy.
#[test]
fn singleton_lifecycle() {
let _guard = lock();
unsafe { oakaudio_manager_destroy_instance() };
assert!(instance().ctx.is_null());
unsafe { oakaudio_manager_create_instance() };
let m = instance();
assert!(!m.ctx.is_null());
unsafe { oakaudio_manager_free(&mut m.clone()) };
unsafe { oakaudio_manager_destroy_instance() };
assert!(instance().ctx.is_null());
unsafe { oakaudio_manager_create_instance() };
assert!(!instance().ctx.is_null());
}
/// push_to_output accepts raw interleaved bytes and starts the virtual
/// playback clock; without a device it fails with a message in error_buf.
///
/// The virtual device never consumes frames (PortAudio is not bridged), so
/// the clock reads 0.0 rather than advancing.
#[test]
fn push_output_advances_clock() {
let _guard = lock();
unsafe { oakaudio_manager_create_instance() };
let m = instance();
// No stream yet: seconds() reports -1.
let mut secs = 0.0f64;
assert_eq!(unsafe { oakaudio_manager_seconds(m, &mut secs) }, OAKAUDIO_OK);
assert_eq!(secs, -1.0);
// Without a device, push fails with a human-readable error. The
// singleton state persists across tests, so pin the no-device state
// explicitly.
assert_eq!(unsafe { oakaudio_manager_set_output_device(m, -1) }, OAKAUDIO_OK);
let samples = vec![0u8; 480 * 2 * 4];
let mut err = [0 as c_char; 64];
let r = unsafe {
oakaudio_manager_push_to_output(
m,
48000,
3,
4,
samples.as_ptr() as *const c_char,
samples.len() as i64,
err.as_mut_ptr(),
err.len() as i32,
)
};
assert_eq!(r, OAKAUDIO_E_FAILED);
assert!(err.iter().any(|&b| b != 0), "error_buf must carry a message");
// After selecting a device the push succeeds and the clock starts at 0.
assert_eq!(unsafe { oakaudio_manager_set_output_device(m, 0) }, OAKAUDIO_OK);
let mut err = [0 as c_char; 64];
let r = unsafe {
oakaudio_manager_push_to_output(
m,
48000,
3,
4,
samples.as_ptr() as *const c_char,
samples.len() as i64,
err.as_mut_ptr(),
err.len() as i32,
)
};
assert_eq!(r, OAKAUDIO_OK);
unsafe { oakaudio_manager_seconds(m, &mut secs) };
assert_eq!(secs, 0.0);
unsafe { oakaudio_manager_destroy_instance() };
}
/// set/get output & input device: getters report a device, setters persist
/// it; hard_reset keeps the device indices (it only stops the stream and
/// clears buffers).
#[test]
fn device_selection_roundtrip() {
let _guard = lock();
unsafe { oakaudio_manager_create_instance() };
let m = instance();
assert_eq!(unsafe { oakaudio_manager_set_output_device(m, 42) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_manager_get_output_device(m) }, 42);
assert_eq!(unsafe { oakaudio_manager_set_input_device(m, 7) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_manager_get_input_device(m) }, 7);
assert_eq!(unsafe { oakaudio_manager_hard_reset(m) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_manager_get_output_device(m) }, 42);
assert_eq!(unsafe { oakaudio_manager_get_input_device(m) }, 7);
// The stream stopped, so the clock is back at -1.
let mut secs = 0.0f64;
unsafe { oakaudio_manager_seconds(m, &mut secs) };
assert_eq!(secs, -1.0);
unsafe { oakaudio_manager_destroy_instance() };
}
/// set_output_notify_interval stores the interval; clear_buffered_output
/// drops queued bytes, stop_output halts the stream, and reset_output_clock
/// restarts the counter.
#[test]
fn output_control_flags() {
let _guard = lock();
unsafe { oakaudio_manager_create_instance() };
let m = instance();
assert_eq!(
unsafe { oakaudio_manager_set_output_notify_interval(m, 1024) },
OAKAUDIO_OK
);
assert_eq!(
unsafe { oakaudio_manager_set_output_notify_interval(m, -1) },
OAKAUDIO_E_INVALID
);
assert_eq!(unsafe { oakaudio_manager_clear_buffered_output(m) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_manager_reset_output_clock(m) }, OAKAUDIO_OK);
// Push starts the stream, then stop_output halts it (clock -> -1).
unsafe { oakaudio_manager_set_output_device(m, 0) };
let samples = vec![0u8; 480 * 2 * 4];
assert_eq!(
unsafe {
oakaudio_manager_push_to_output(
m, 48000, 3, 4, samples.as_ptr() as *const c_char,
samples.len() as i64, std::ptr::null_mut(), 0,
)
},
OAKAUDIO_OK
);
assert_eq!(unsafe { oakaudio_manager_stop_output(m) }, OAKAUDIO_OK);
let mut secs = 1.0f64;
unsafe { oakaudio_manager_seconds(m, &mut secs) };
assert_eq!(secs, -1.0);
unsafe { oakaudio_manager_destroy_instance() };
}
/// start_recording validates its parameters: NULL params or a disabled
/// audio track return OAKAUDIO_E_INVALID with an error string. The full
/// encoder-open success path (oakcodec writes a real file via ffmpeg) is
/// an end-to-end concern covered by the codec crate's own tests; with the
/// real oakcodec linked, `start_recording` either opens the encoder
/// (OAKAUDIO_OK, environment-dependent) or reports the encoder's
/// last-error string — both are correct manager behavior, so this test
/// pins the manager's own validation only.
#[test]
fn recording_start_stop() {
let _guard = lock();
unsafe { oakaudio_manager_create_instance() };
let m = instance();
unsafe { oakaudio_manager_set_input_device(m, 0) };
let mut err = [0 as c_char; 64];
let params = encoding_params();
// With a real encoder, the attempt must at least reach the encoder
// (a failure must surface a diagnostic in error_buf, not crash).
let r = unsafe { oakaudio_manager_start_recording(m, &params, err.as_mut_ptr(), err.len() as i32) };
if r != 0 {
assert!(
err.iter().any(|&b| b != 0),
"failed start_recording must report a reason"
);
let _ = std::fs::remove_file("oakaudio_test.wav");
} else {
assert_eq!(unsafe { oakaudio_manager_stop_recording(m) }, OAKAUDIO_OK);
// The real encoder writes the output file during open; clean it up.
let _ = std::fs::remove_file("oakaudio_test.wav");
}
// NULL params is invalid and reports the reason in error_buf.
let mut err = [0 as c_char; 64];
let r = unsafe { oakaudio_manager_start_recording(m, std::ptr::null(), err.as_mut_ptr(), err.len() as i32) };
assert_eq!(r, OAKAUDIO_E_INVALID);
assert!(err.iter().any(|&b| b != 0));
// A disabled audio track is likewise invalid.
let mut disabled = encoding_params();
disabled.audio_enabled = 0;
let mut err = [0 as c_char; 64];
let r = unsafe {
oakaudio_manager_start_recording(m, &disabled, err.as_mut_ptr(), err.len() as i32)
};
assert_eq!(r, OAKAUDIO_E_INVALID);
assert!(err.iter().any(|&b| b != 0));
unsafe { oakaudio_manager_destroy_instance() };
}
/// Device enumeration is not bridged: every name/config lookup falls back to
/// paNoDevice (-1); a NULL name is OAKAUDIO_E_INVALID. The config-backed
/// buffer size/name helpers degrade to their defaults.
#[test]
fn device_name_lookup() {
let _guard = lock();
assert_eq!(
unsafe { oakaudio_manager_find_device_by_name_s(std::ptr::null(), 1) },
OAKAUDIO_E_INVALID
);
let name = c"anything";
assert_eq!(
unsafe { oakaudio_manager_find_device_by_name_s(name.as_ptr(), 1) },
-1
);
assert_eq!(unsafe { oakaudio_manager_find_config_device_by_name_s(1) }, -1);
assert_eq!(unsafe { oakaudio_manager_find_config_device_by_name_s(0) }, -1);
// config::output_buffer_size() reads its default (0) from the stub;
// device_name degrades to the empty string.
assert_eq!(oakaudio::config::output_buffer_size(), 0);
assert!(oakaudio::config::device_name(true).as_c_str().is_empty());
assert!(oakaudio::config::device_name(false).as_c_str().is_empty());
}
/// PreviewAudioDevice pull-side plumbing (read/notify callback/clock) that
/// the manager path only touches indirectly.
#[test]
fn preview_device_pull_side() {
use oakaudio::params::AudioParams;
use oakaudio::previewdevice::PreviewAudioDevice;
let mut dev = PreviewAudioDevice::new();
dev.set_params(AudioParams {
sample_rate: 48000,
channel_layout: 3,
format: oakaudio::params::SampleFormat::F32,
});
assert_eq!(dev.bytes_per_frame(), 8);
let callbacks = std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0));
let cb = std::sync::Arc::clone(&callbacks);
dev.set_notify_callback(move || {
cb.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
});
dev.set_notify_interval(4);
dev.write(&[1u8; 10]);
// Reading 6 bytes crosses a 4-byte notify boundary.
let mut buf = [0u8; 6];
assert_eq!(dev.read(&mut buf), 6);
assert!(callbacks.load(std::sync::atomic::Ordering::Relaxed) >= 1);
assert_eq!(buf, [1u8; 6]);
// Clock accounting.
dev.add_output_frames(3);
assert_eq!(dev.output_frames_consumed(), 3);
dev.reset_output_frames();
assert_eq!(dev.output_frames_consumed(), 0);
dev.clear();
assert_eq!(dev.output_frames_consumed(), 0);
}
/// free(NULL)/free(empty) are no-ops on the manager handle.
#[test]
fn free_null_noop() {
let _guard = lock();
unsafe { oakaudio_manager_create_instance() };
let before = unsafe { oakaudio_debug_alive_count() };
let mut empty = oakaudio::handle::CHandle::null();
unsafe { oakaudio_manager_free(&mut empty) };
unsafe { oakaudio_manager_free(std::ptr::null_mut()) };
assert_eq!(unsafe { oakaudio_debug_alive_count() }, before);
unsafe { oakaudio_manager_destroy_instance() };
}
-224
View File
@@ -1,224 +0,0 @@
// 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/>.
//! AudioProcessor contract tests (processor.rs), through the C ABI. The
//! test stub filter graph resamples and time-stretches like the real
//! ffmpeg_bridge graph (linear interpolation), so frame-count contracts
//! are pinned exactly.
mod common;
use oakaudio::error::{OAKAUDIO_E_INVALID, OAKAUDIO_E_STATE, OAKAUDIO_OK};
use oakaudio::ffi::processor::{
oakaudio_processor_close, oakaudio_processor_convert, oakaudio_processor_flush,
oakaudio_processor_free, oakaudio_processor_init, oakaudio_processor_is_open,
oakaudio_processor_open,
};
/// Stereo f32_p planes of `frames` ramp samples.
fn ramp_planes(frames: usize) -> Vec<Vec<f32>> {
vec![
(0..frames).map(|i| i as f32 * 0.01).collect(),
(0..frames).map(|i| -(i as f32) * 0.01).collect(),
]
}
fn open_identity(h: oakaudio::handle::CHandle) -> i32 {
unsafe { oakaudio_processor_open(h, 48000, 3, 4, 48000, 3, 4, 1.0) }
}
/// init yields a valid handle; is_open is false before open and true after;
/// close returns it to closed without error.
#[test]
fn processor_open_isopen_close() {
let mut h = unsafe { oakaudio_processor_init() };
assert!(!h.ctx.is_null());
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 0);
assert_eq!(open_identity(h), OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 1);
assert_eq!(unsafe { oakaudio_processor_close(h) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 0);
unsafe { oakaudio_processor_free(&mut h) };
}
/// open with matching in/out rate and format is an identity passthrough:
/// convert returns the same frame count and samples within 1e-6.
#[test]
fn identity_convert_passthrough() {
let mut h = unsafe { oakaudio_processor_init() };
assert_eq!(open_identity(h), OAKAUDIO_OK);
let planes = ramp_planes(32);
let in_ptrs: Vec<*const f32> = planes.iter().map(|p| p.as_ptr()).collect();
let mut out = vec![vec![0f32; 32]; 2];
let mut out_ptrs: Vec<*mut f32> = out.iter_mut().map(|p| p.as_mut_ptr()).collect();
let n = unsafe {
oakaudio_processor_convert(
h,
in_ptrs.as_ptr(),
32,
out_ptrs.as_ptr(),
32,
)
};
assert_eq!(n, 32);
for ch in 0..2 {
for i in 0..32 {
assert!(
(out[ch][i] - planes[ch][i]).abs() < 1e-6,
"ch{ch}[{i}]: {} vs {}",
out[ch][i],
planes[ch][i]
);
}
}
unsafe { oakaudio_processor_free(&mut h) };
}
/// convert with an output capacity smaller than the produced frames returns
/// the produced count clamped to capacity and fills up to capacity.
#[test]
fn convert_capacity_truncation() {
let mut h = unsafe { oakaudio_processor_init() };
assert_eq!(open_identity(h), OAKAUDIO_OK);
let planes = ramp_planes(32);
let in_ptrs: Vec<*const f32> = planes.iter().map(|p| p.as_ptr()).collect();
let mut out = vec![vec![9.9f32; 10]; 2];
let mut out_ptrs: Vec<*mut f32> = out.iter_mut().map(|p| p.as_mut_ptr()).collect();
let n = unsafe {
oakaudio_processor_convert(h, in_ptrs.as_ptr(), 32, out_ptrs.as_ptr(), 10)
};
assert_eq!(n, 10);
for ch in 0..2 {
for i in 0..10 {
assert_eq!(out[ch][i], planes[ch][i]);
}
}
// The graph has already drained; nothing further to pull.
let mut out2 = vec![vec![0f32; 32]; 2];
let mut out2_ptrs: Vec<*mut f32> = out2.iter_mut().map(|p| p.as_mut_ptr()).collect();
let n = unsafe {
oakaudio_processor_convert(h, in_ptrs.as_ptr(), 0, out2_ptrs.as_ptr(), 32)
};
assert_eq!(n, 0);
unsafe { oakaudio_processor_free(&mut h) };
}
/// open with a zero/negative rate or a wrong output format returns
/// OAKAUDIO_E_INVALID and leaves the processor closed; an empty handle is
/// OAKAUDIO_E_INVALID everywhere.
#[test]
fn open_invalid_params() {
let mut h = unsafe { oakaudio_processor_init() };
assert_eq!(
unsafe { oakaudio_processor_open(h, 0, 3, 4, 48000, 3, 4, 1.0) },
OAKAUDIO_E_INVALID
);
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 0);
assert_eq!(
unsafe { oakaudio_processor_open(h, 48000, 3, 4, 48000, 3, 0, 1.0) },
OAKAUDIO_E_INVALID
);
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 0);
let empty = oakaudio::handle::CHandle::null();
assert_eq!(open_identity(empty), OAKAUDIO_E_INVALID);
assert_eq!(unsafe { oakaudio_processor_is_open(empty) }, OAKAUDIO_E_INVALID);
assert_eq!(unsafe { oakaudio_processor_close(empty) }, OAKAUDIO_E_INVALID);
assert_eq!(unsafe { oakaudio_processor_flush(empty) }, OAKAUDIO_E_INVALID);
let mut out_ptrs: Vec<*mut f32> = Vec::new();
assert_eq!(
unsafe { oakaudio_processor_convert(empty, std::ptr::null(), 0, out_ptrs.as_ptr(), 0) },
OAKAUDIO_E_INVALID
);
// convert before open is a state error.
assert_eq!(
unsafe { oakaudio_processor_convert(h, std::ptr::null(), 0, out_ptrs.as_ptr(), 0) },
OAKAUDIO_E_STATE
);
unsafe { oakaudio_processor_free(&mut h) };
}
/// Resampling to half rate halves the frame count (44100 -> 22050, 32 input
/// frames produce 16 output frames); flush is a no-op on the drained graph
/// and keeps the processor open.
#[test]
fn resample_and_flush() {
let mut h = unsafe { oakaudio_processor_init() };
assert_eq!(
unsafe { oakaudio_processor_open(h, 44100, 3, 4, 22050, 3, 4, 1.0) },
OAKAUDIO_OK
);
let planes = ramp_planes(32);
let in_ptrs: Vec<*const f32> = planes.iter().map(|p| p.as_ptr()).collect();
let mut out = vec![vec![0f32; 32]; 2];
let mut out_ptrs: Vec<*mut f32> = out.iter_mut().map(|p| p.as_mut_ptr()).collect();
let n = unsafe {
oakaudio_processor_convert(h, in_ptrs.as_ptr(), 32, out_ptrs.as_ptr(), 32)
};
assert_eq!(n, 16, "half-rate output must halve the frame count");
assert_eq!(unsafe { oakaudio_processor_flush(h) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 1);
unsafe { oakaudio_processor_free(&mut h) };
}
/// A tempo factor != 1.0 time-stretches: tempo 2.0 halves the frame count
/// and the processor stays open.
#[test]
fn tempo_stretch() {
let mut h = unsafe { oakaudio_processor_init() };
assert_eq!(
unsafe { oakaudio_processor_open(h, 48000, 3, 4, 48000, 3, 4, 2.0) },
OAKAUDIO_OK
);
let planes = ramp_planes(32);
let in_ptrs: Vec<*const f32> = planes.iter().map(|p| p.as_ptr()).collect();
let mut out = vec![vec![0f32; 32]; 2];
let mut out_ptrs: Vec<*mut f32> = out.iter_mut().map(|p| p.as_mut_ptr()).collect();
let n = unsafe {
oakaudio_processor_convert(h, in_ptrs.as_ptr(), 32, out_ptrs.as_ptr(), 32)
};
assert_eq!(n, 16, "tempo 2.0 must halve the frame count");
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 1);
unsafe { oakaudio_processor_close(h) };
// A non-positive speed is rejected (on a closed processor).
assert_eq!(
unsafe { oakaudio_processor_open(h, 48000, 3, 4, 48000, 3, 4, 0.0) },
OAKAUDIO_E_INVALID
);
assert_eq!(open_identity(h), OAKAUDIO_OK);
// Already open -> state error.
assert_eq!(open_identity(h), OAKAUDIO_E_STATE);
unsafe { oakaudio_processor_free(&mut h) };
}
/// free(NULL)/free(empty) are no-ops.
#[test]
fn free_null_noop() {
let mut h = oakaudio::handle::CHandle::null();
unsafe { oakaudio_processor_free(&mut h) };
unsafe { oakaudio_processor_free(std::ptr::null_mut()) };
}
-433
View File
@@ -1,433 +0,0 @@
// 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/>.
//! AudioSynchronizer + AudioWaveformSync contract tests
//! (synchronizer.rs, waveformsync.rs), through the C ABI.
mod common;
use oakaudio::error::OAKAUDIO_E_INVALID;
use oakaudio::ffi::sync::{
oakaudio_sync_estimate_envelope_offset, oakaudio_sync_estimate_stretch_and_offset,
oakaudio_sync_extract_rms_envelope, oakaudio_sync_place_by_source_time,
oakaudio_sync_place_by_waveform_offset, OffsetResult, SourceClip, StretchOffsetResult,
};
fn clip(source: i64, media_in: i64, has_source: bool) -> SourceClip {
SourceClip {
source_start_time_num: source,
source_start_time_den: 1,
media_in_num: media_in,
media_in_den: 1,
has_source_start_time: has_source as i32,
}
}
/// place_by_source_time: a candidate with matching source time lands at the
/// reference's timeline in point; a source-less candidate (has_source_
/// start_time false) is invalid (no media_in fallback in the C++ logic).
#[test]
fn place_by_source_time_matching() {
let reference = clip(0, 0, true);
let candidate = clip(0, 0, true);
let (mut num, mut den, mut valid) = (0i64, 0i64, 0i32);
let r = unsafe {
oakaudio_sync_place_by_source_time(
&reference,
&candidate,
5,
1,
&mut num,
&mut den,
&mut valid,
)
};
assert_eq!(r, 0);
assert_eq!(num, 5);
assert_eq!(den, 1);
assert_eq!(valid, 1);
// A source-less candidate is invalid: valid=0, null rational.
let candidate = clip(0, 0, false);
let r = unsafe {
oakaudio_sync_place_by_source_time(
&reference,
&candidate,
5,
1,
&mut num,
&mut den,
&mut valid,
)
};
assert_eq!(r, 0);
assert_eq!(valid, 0);
assert_eq!(num, 0);
assert_eq!(den, 0);
}
/// place_by_source_time: when source times disagree by a known delta, the
/// candidate's timeline in point shifts by that delta (in seconds).
#[test]
fn place_by_source_time_delta() {
let reference = clip(5, 0, true);
let candidate = clip(12, 0, true);
let (mut num, mut den, mut valid) = (0i64, 0i64, 0i32);
let r = unsafe {
oakaudio_sync_place_by_source_time(
&reference,
&candidate,
0,
1,
&mut num,
&mut den,
&mut valid,
)
};
assert_eq!(r, 0);
// 0 + (12 + 0) - (5 + 0) = 7
assert_eq!(num, 7);
assert_eq!(den, 1);
assert_eq!(valid, 1);
// A zero denominator is rejected up front.
let r = unsafe {
oakaudio_sync_place_by_source_time(
&reference,
&candidate,
0,
0,
&mut num,
&mut den,
&mut valid,
)
};
assert_eq!(r, OAKAUDIO_E_INVALID);
}
/// place_by_waveform_offset converts a sample offset at a sample rate into
/// a timeline-in shift; out_valid is 1 on success and 0 for a null rate.
#[test]
fn place_by_waveform_offset_conversion() {
let (mut num, mut den, mut valid) = (0i64, 0i64, 0i32);
let r = unsafe {
oakaudio_sync_place_by_waveform_offset(0, 1, 48000, 48000, &mut num, &mut den, &mut valid)
};
assert_eq!(r, 0);
assert_eq!(num, 1);
assert_eq!(den, 1);
assert_eq!(valid, 1);
let r = unsafe {
oakaudio_sync_place_by_waveform_offset(1, 2, 48000, 48000, &mut num, &mut den, &mut valid)
};
assert_eq!(r, 0);
assert_eq!(num, 3);
assert_eq!(den, 2);
assert_eq!(valid, 1);
// A null rate is invalid (valid=0, null rational), and the FFI rejects
// a zero timeline denominator.
let r = unsafe {
oakaudio_sync_place_by_waveform_offset(1, 2, 48000, 0, &mut num, &mut den, &mut valid)
};
assert_eq!(r, 0);
assert_eq!(valid, 0);
assert_eq!(num, 0);
assert_eq!(den, 0);
let r = unsafe {
oakaudio_sync_place_by_waveform_offset(1, 0, 48000, 48000, &mut num, &mut den, &mut valid)
};
assert_eq!(r, OAKAUDIO_E_INVALID);
}
/// extract_rms_envelope produces one value per window; a window larger than
/// the input yields a single envelope point.
#[test]
fn extract_rms_envelope_shape() {
let data: Vec<f32> = (0..100).map(|i| i as f32).collect();
let planes = common::planar_from(&data, 2);
let ptrs: Vec<*const f32> = planes.iter().map(|p| p.as_ptr()).collect();
let mut out = vec![0.0f64; 16];
let n = unsafe {
oakaudio_sync_extract_rms_envelope(ptrs.as_ptr(), 2, 100, 10, out.as_mut_ptr(), 16)
};
assert_eq!(n, 10);
assert!(out.iter().take(10).all(|&v| v > 0.0));
let n = unsafe {
oakaudio_sync_extract_rms_envelope(ptrs.as_ptr(), 2, 100, 200, out.as_mut_ptr(), 16)
};
assert_eq!(n, 1);
// Invalid inputs.
assert_eq!(
unsafe {
oakaudio_sync_extract_rms_envelope(std::ptr::null(), 2, 100, 10, out.as_mut_ptr(), 16)
},
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe {
oakaudio_sync_extract_rms_envelope(ptrs.as_ptr(), 0, 100, 10, out.as_mut_ptr(), 16)
},
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe {
oakaudio_sync_extract_rms_envelope(ptrs.as_ptr(), 2, 100, 0, out.as_mut_ptr(), 16)
},
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe {
oakaudio_sync_extract_rms_envelope(ptrs.as_ptr(), 2, 100, 10, out.as_mut_ptr(), -1)
},
OAKAUDIO_E_INVALID
);
// Two-stage: NULL out returns the required count.
let n = unsafe {
oakaudio_sync_extract_rms_envelope(ptrs.as_ptr(), 2, 100, 10, std::ptr::null_mut(), 0)
};
assert_eq!(n, 10);
}
/// estimate_envelope_offset: for a candidate delayed by N windows relative
/// to the reference, the returned offset is +N windows and valid=1.
#[test]
fn envelope_offset_recovers_delay() {
let reference: Vec<f64> = (0..10).map(|i| i as f64).collect();
let mut candidate = vec![0.0f64; 10];
candidate[2..].copy_from_slice(&reference[..8]);
let mut out = OffsetResult {
offset_samples: 0,
confidence: 0.0,
valid: 0,
};
let r = unsafe {
oakaudio_sync_estimate_envelope_offset(
reference.as_ptr(),
10,
candidate.as_ptr(),
10,
std::ptr::null(),
std::ptr::null(),
100,
10,
&mut out,
)
};
assert_eq!(r, 0);
assert_eq!(out.valid, 1);
assert_eq!(out.offset_samples, 200);
assert!((out.confidence - 1.0).abs() < 1e-9);
}
/// estimate_envelope_offset: windows masked invalid on either side are
/// excluded from correlation; empty masks are treated as all-valid.
#[test]
fn envelope_offset_respects_valid_masks() {
let reference: Vec<f64> = (0..10).map(|i| i as f64).collect();
let mut candidate = vec![0.0f64; 10];
candidate[2..].copy_from_slice(&reference[..8]);
// Only the last reference window is valid -> no lag has >= 2 valid
// overlap windows, so the estimate is invalid.
let mut ref_valid = [1u8; 10];
ref_valid[..9].fill(0);
let mut out = OffsetResult {
offset_samples: 0,
confidence: 0.0,
valid: 0,
};
let r = unsafe {
oakaudio_sync_estimate_envelope_offset(
reference.as_ptr(),
10,
candidate.as_ptr(),
10,
ref_valid.as_ptr(),
std::ptr::null(),
100,
10,
&mut out,
)
};
assert_eq!(r, 0);
assert_eq!(out.valid, 0);
assert_eq!(out.confidence, 0.0);
// Fully-valid masks behave like the unmasked call.
let mut valid = [1u8; 10];
let r = unsafe {
oakaudio_sync_estimate_envelope_offset(
reference.as_ptr(),
10,
candidate.as_ptr(),
10,
valid.as_ptr(),
valid.as_ptr(),
100,
10,
&mut out,
)
};
assert_eq!(r, 0);
assert_eq!(out.valid, 1);
assert_eq!(out.offset_samples, 200);
// NULL arrays / non-positive lengths are invalid.
let r = unsafe {
oakaudio_sync_estimate_envelope_offset(
std::ptr::null(),
10,
candidate.as_ptr(),
10,
std::ptr::null(),
std::ptr::null(),
100,
10,
&mut out,
)
};
assert_eq!(r, OAKAUDIO_E_INVALID);
}
/// estimate_stretch_and_offset: a candidate sampled at 2x the reference
/// rate reports rate ~2.0 (>1 = speed up) with a valid=1 result. A
/// non-linear (sine) reference is used — normalized correlation of linear
/// ramps is degenerate (any rate correlates 1.0), but only the true rate
/// resamples the sine back onto the reference exactly.
#[test]
fn stretch_offset_recovers_rate() {
let reference: Vec<f64> = (0..10)
.map(|k| (2.0 * std::f64::consts::PI * 0.7 * k as f64).sin())
.collect();
// Candidate at 2x: even samples are exact, odd samples are midpoints.
let mut candidate = Vec::with_capacity(20);
for k in 0..10 {
candidate.push(reference[k]);
if k + 1 < 10 {
candidate.push((reference[k] + reference[k + 1]) / 2.0);
}
}
let mut out = StretchOffsetResult {
rate: 0.0,
offset_samples: 0,
confidence: 0.0,
valid: 0,
};
let r = unsafe {
oakaudio_sync_estimate_stretch_and_offset(
reference.as_ptr(),
10,
candidate.as_ptr(),
candidate.len() as i32,
std::ptr::null(),
std::ptr::null(),
100,
10,
0.5,
3.0,
0.1,
&mut out,
)
};
assert_eq!(r, 0);
assert_eq!(out.valid, 1);
assert!((out.rate - 2.0).abs() < 0.15, "rate = {}", out.rate);
assert!(out.confidence > 0.99, "confidence = {}", out.confidence);
// Invalid rate parameters are rejected.
let r = unsafe {
oakaudio_sync_estimate_stretch_and_offset(
reference.as_ptr(),
10,
candidate.as_ptr(),
candidate.len() as i32,
std::ptr::null(),
std::ptr::null(),
100,
10,
0.0,
3.0,
0.1,
&mut out,
)
};
assert_eq!(r, OAKAUDIO_E_INVALID);
}
/// estimate_* on identical silent envelopes yields low/no confidence and
/// valid=0 (no correlation peak).
#[test]
fn silent_inputs_invalid() {
let silence = vec![0.0f64; 10];
let mut out = OffsetResult {
offset_samples: 0,
confidence: 0.0,
valid: 0,
};
let r = unsafe {
oakaudio_sync_estimate_envelope_offset(
silence.as_ptr(),
10,
silence.as_ptr(),
10,
std::ptr::null(),
std::ptr::null(),
100,
10,
&mut out,
)
};
assert_eq!(r, 0);
assert_eq!(out.valid, 0);
assert_eq!(out.confidence, 0.0);
}
/// The crate-level unmasked wrappers (estimate_offset on raw sample
/// buffers, estimate_envelope_offset on envelopes) route to the same
/// correlation core and recover the same delay. A non-monotonic envelope
/// is used — equal-slope linear ramps correlate 1.0 at multiple lags, so
/// only the exact match is unambiguous.
#[test]
fn crate_level_unmasked_wrappers() {
let reference: Vec<f64> = vec![0.0, 0.1, 0.2, 0.9, 0.8, 0.3, 0.4, 0.5, 0.6, 0.7];
let mut candidate = vec![0.0f64; 10];
candidate[2..].copy_from_slice(&reference[..8]);
let env = oakaudio::waveformsync::estimate_envelope_offset(&reference, &candidate, 100, 10);
assert!(env.valid);
assert_eq!(env.offset_samples, 200);
assert!((env.confidence - 1.0).abs() < 1e-9);
// Raw sample buffers: 10 windows of 100 constant-amplitude samples,
// candidate delayed by two windows.
let ref_samples: Vec<f32> = (0..1000).map(|i| reference[i / 100] as f32).collect();
let mut cand_samples = vec![0.0f32; 1000];
cand_samples[200..].copy_from_slice(&ref_samples[..800]);
let raw = oakaudio::waveformsync::estimate_offset(
&[ref_samples.as_slice()],
&[cand_samples.as_slice()],
100,
500,
);
assert!(raw.valid, "offset should be recovered from raw samples");
assert_eq!(raw.offset_samples, 200);
}
-352
View File
@@ -1,352 +0,0 @@
// 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/>.
//! AudioVisualWaveform contract tests (waveform.rs), through the C ABI.
mod common;
use std::ffi::CString;
use common::{pair, write_wav};
use oakaudio::error::{OAKAUDIO_E_INVALID, OAKAUDIO_E_STATE};
use oakaudio::ffi::waveform::{
oakaudio_waveform_extract, oakaudio_waveform_free, oakaudio_waveform_get_channel_count,
oakaudio_waveform_get_summary, oakaudio_waveform_init, oakaudio_waveform_length,
oakaudio_waveform_overwrite_samples, oakaudio_waveform_overwrite_silence,
oakaudio_waveform_overwrite_sums, oakaudio_waveform_re_sum_s, oakaudio_waveform_resize,
oakaudio_waveform_set_channel_count, oakaudio_waveform_sum_samples_s,
oakaudio_waveform_trim_in, oakaudio_waveform_trim_range, MinMax,
};
/// Fill `w` with 100 samples/channel of a 0..0.99 ramp at 100 Hz (1 s).
fn fill_ramp(w: oakaudio::handle::CHandle) {
let ch0: Vec<f32> = (0..100).map(|i| i as f32 * 0.01).collect();
let ch1: Vec<f32> = (0..100).map(|i| -(i as f32) * 0.01).collect();
let planes = [ch0.as_ptr(), ch1.as_ptr()];
assert_eq!(unsafe { oakaudio_waveform_set_channel_count(w, 2) }, 0);
assert_eq!(
unsafe { oakaudio_waveform_overwrite_samples(w, planes.as_ptr(), 100, 100, 0, 1) },
0
);
}
fn summary(w: oakaudio::handle::CHandle, start: (i64, i64), length: (i64, i64), cap: i32) -> Vec<MinMax> {
let mut out = vec![MinMax { min: 0.0, max: 0.0 }; cap as usize * 2];
let n = unsafe {
oakaudio_waveform_get_summary(
w,
start.0,
start.1,
length.0,
length.1,
out.as_mut_ptr(),
cap,
)
};
assert_eq!(n, cap);
out.truncate(cap as usize * 2);
out
}
/// set_channel_count then overwrite_samples writes planar data at the given
/// start; length() reflects the covered span and get_summary returns
/// channel-interleaved min/max pairs.
#[test]
fn overwrite_samples_and_length() {
let mut w = unsafe { oakaudio_waveform_init() };
fill_ramp(w);
let (mut num, mut den) = (0i64, 0i64);
assert_eq!(unsafe { oakaudio_waveform_length(w, &mut num, &mut den) }, 0);
assert_eq!(num, 1);
assert_eq!(den, 1);
let out = summary(w, (0, 1), (1, 1), 1);
assert_eq!(out[0].min, 0.0);
assert!((out[0].max - 0.99).abs() < 1e-5, "max = {}", out[0].max);
assert!((out[1].min + 0.99).abs() < 1e-5, "min = {}", out[1].min);
assert_eq!(out[1].max, 0.0);
unsafe { oakaudio_waveform_free(&mut w) };
}
/// get_summary with out_pairs NULL returns the required point count without
/// writing; a too-small capacity returns the same count and leaves the
/// buffer untouched (two-stage contract).
#[test]
fn summary_two_stage_query() {
let mut w = unsafe { oakaudio_waveform_init() };
fill_ramp(w);
// NULL out: required count only.
let n = unsafe { oakaudio_waveform_get_summary(w, 0, 1, 1, 1, std::ptr::null_mut(), 0) };
assert_eq!(n, 1);
// Too-small capacity: same count, buffer untouched.
let mut out = [MinMax { min: -1.0, max: -1.0 }; 2];
let n = unsafe { oakaudio_waveform_get_summary(w, 0, 1, 1, 1, out.as_mut_ptr(), 0) };
assert_eq!(n, 1);
assert_eq!(out[0].min, -1.0);
assert_eq!(out[0].max, -1.0);
// A zero/negative length is invalid.
assert_eq!(
unsafe { oakaudio_waveform_get_summary(w, 0, 1, 0, 1, std::ptr::null_mut(), 0) },
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe { oakaudio_waveform_get_summary(w, 0, 1, 1, 0, std::ptr::null_mut(), 0) },
OAKAUDIO_E_INVALID
);
unsafe { oakaudio_waveform_free(&mut w) };
}
/// overwrite_sums copies channel-interleaved pairs from another waveform
/// into a dest range; a 0/1 length copies all of src.
#[test]
fn overwrite_sums_range_copy() {
let mut src = unsafe { oakaudio_waveform_init() };
fill_ramp(src);
let mut dst = unsafe { oakaudio_waveform_init() };
assert_eq!(unsafe { oakaudio_waveform_set_channel_count(dst, 2) }, 0);
assert_eq!(
unsafe { oakaudio_waveform_overwrite_sums(dst, src, 0, 1, 0, 1, 0, 1) },
0
);
// A 0/1 length means "copy everything": dst matches src exactly.
let out = summary(dst, (0, 1), (1, 1), 1);
assert_eq!(out[0].min, 0.0);
assert!((out[0].max - 0.99).abs() < 1e-5, "max = {}", out[0].max);
assert!((out[1].min + 0.99).abs() < 1e-5, "min = {}", out[1].min);
assert_eq!(out[1].max, 0.0);
let (mut num, mut den) = (0i64, 0i64);
unsafe { oakaudio_waveform_length(dst, &mut num, &mut den) };
assert_eq!(num, 1);
// Empty src handle is invalid.
let empty = oakaudio::handle::CHandle::null();
assert_eq!(
unsafe { oakaudio_waveform_overwrite_sums(dst, empty, 0, 1, 0, 1, 0, 1) },
OAKAUDIO_E_INVALID
);
unsafe { oakaudio_waveform_free(&mut dst) };
unsafe { oakaudio_waveform_free(&mut src) };
}
/// overwrite_silence zeroes min/max over a range without changing length.
#[test]
fn overwrite_silence() {
let mut w = unsafe { oakaudio_waveform_init() };
fill_ramp(w);
assert_eq!(
unsafe { oakaudio_waveform_overwrite_silence(w, 0, 1, 1, 2) },
0
);
// First half is silenced; second half retains the ramp data.
let out = summary(w, (0, 1), (1, 2), 1);
assert_eq!(pair(out[0].min, out[0].max), pair(0.0, 0.0));
assert_eq!(pair(out[1].min, out[1].max), pair(0.0, 0.0));
let out = summary(w, (1, 2), (1, 2), 1);
assert!(out[0].max > 0.5, "second half must keep ramp data, got {:?}", out[0]);
assert!(out[1].min < -0.5);
let (mut num, mut den) = (0i64, 0i64);
unsafe { oakaudio_waveform_length(w, &mut num, &mut den) };
assert_eq!((num, den), (1, 1), "overwrite_silence must not change length");
unsafe { oakaudio_waveform_free(&mut w) };
}
/// trim_in/trim_range/resize adjust length and drop or pad data; a negative
/// trim_in prepends silence (C++ semantics).
#[test]
fn trim_and_resize() {
let mut w = unsafe { oakaudio_waveform_init() };
fill_ramp(w);
// Negative trim_in prepends silence: absolute end (length) unchanged.
assert_eq!(unsafe { oakaudio_waveform_trim_in(w, -1, 2) }, 0);
let (mut num, mut den) = (0i64, 0i64);
unsafe { oakaudio_waveform_length(w, &mut num, &mut den) };
assert_eq!((num, den), (1, 1));
// Resize extends to 2 s.
assert_eq!(unsafe { oakaudio_waveform_resize(w, 2, 1) }, 0);
unsafe { oakaudio_waveform_length(w, &mut num, &mut den) };
assert_eq!((num, den), (2, 1));
// trim_range keeps 0.5 s from the (prepended) start.
assert_eq!(unsafe { oakaudio_waveform_trim_range(w, 0, 1, 1, 2) }, 0);
unsafe { oakaudio_waveform_length(w, &mut num, &mut den) };
assert_eq!((num, den), (1, 2));
// A negative resize target or a zero denominator is invalid.
assert_eq!(
unsafe { oakaudio_waveform_resize(w, -1, 2) },
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe { oakaudio_waveform_resize(w, 1, 0) },
OAKAUDIO_E_INVALID
);
unsafe { oakaudio_waveform_free(&mut w) };
}
/// sum_samples_s reduces planar samples into one min/max pair per channel;
/// re_sum_s merges channel-interleaved entries into one pair per channel.
/// Both match golden vectors from the C++ implementation.
#[test]
fn sum_and_resum_golden() {
let ch0 = [1.0f32, -2.0, 3.0];
let ch1 = [4.0f32, -5.0, 6.0];
let planes = [ch0.as_ptr(), ch1.as_ptr()];
let mut out = [MinMax { min: 0.0, max: 0.0 }; 2];
let r = unsafe {
oakaudio_waveform_sum_samples_s(planes.as_ptr(), 2, 0, 3, out.as_mut_ptr())
};
assert_eq!(r, 0);
assert_eq!(pair(out[0].min, out[0].max), pair(-2.0, 3.0));
assert_eq!(pair(out[1].min, out[1].max), pair(-5.0, 6.0));
// re_sum_s over 4 interleaved entries, 2 channels -> one pair per
// channel merging both points.
let input = [
MinMax { min: 1.0, max: 2.0 },
MinMax { min: 3.0, max: 4.0 },
MinMax { min: 5.0, max: 6.0 },
MinMax { min: 7.0, max: 8.0 },
];
let mut out = [MinMax { min: 0.0, max: 0.0 }; 2];
let r = unsafe { oakaudio_waveform_re_sum_s(input.as_ptr(), 4, 2, out.as_mut_ptr()) };
assert_eq!(r, 0);
assert_eq!(pair(out[0].min, out[0].max), pair(1.0, 6.0));
assert_eq!(pair(out[1].min, out[1].max), pair(3.0, 8.0));
// Invalid arguments.
assert_eq!(
unsafe { oakaudio_waveform_sum_samples_s(planes.as_ptr(), 2, 0, 0, out.as_mut_ptr()) },
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe { oakaudio_waveform_re_sum_s(input.as_ptr(), 0, 2, out.as_mut_ptr()) },
OAKAUDIO_E_INVALID
);
}
/// extract probes through the oakcodec decoder C ABI; a missing file
/// returns OAKAUDIO_E_NOT_FOUND. The full decode of a real file goes
/// through the host ffmpeg_bridge (`fb_*`, a C++ library not linked into
/// the Rust-only test binary), so the valid-file pixel assertions are
/// covered by the ffmpeg_bridge/audio integration tests instead; this
/// test pins the probe error path.
#[test]
fn extract_file_and_notfound() {
let path = std::env::temp_dir().join(format!(
"oakaudio_extract_{}.wav",
std::process::id()
));
// 8 frames of stereo ramp, 48000 Hz.
let mut samples = Vec::with_capacity(16);
for i in 0..8i16 {
samples.push(i * 1000);
samples.push(-(i * 1000));
}
write_wav(&path, 2, 48000, &samples).unwrap();
// Missing file -> NOT_FOUND.
let missing = CString::new(
std::env::temp_dir()
.join(format!("oakaudio_missing_{}.wav", std::process::id()))
.to_str()
.unwrap(),
)
.unwrap();
let _ = std::fs::remove_file(std::path::Path::new(missing.to_str().unwrap()));
let r = unsafe {
oakaudio_waveform_extract(
missing.as_ptr(),
0,
4,
std::ptr::null_mut(),
0,
std::ptr::null_mut(),
)
};
assert_eq!(r, -60004);
std::fs::remove_file(&path).ok();
}
/// FFI validation and empty-handle error paths.
#[test]
fn ffi_error_paths() {
let empty = oakaudio::handle::CHandle::null();
assert_eq!(
unsafe { oakaudio_waveform_get_channel_count(empty) },
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe { oakaudio_waveform_set_channel_count(empty, 2) },
OAKAUDIO_E_INVALID
);
let (mut num, mut den) = (0i64, 0i64);
assert_eq!(
unsafe { oakaudio_waveform_length(empty, &mut num, &mut den) },
OAKAUDIO_E_INVALID
);
// Negative channel count is invalid.
let mut w = unsafe { oakaudio_waveform_init() };
assert_eq!(
unsafe { oakaudio_waveform_set_channel_count(w, -1) },
OAKAUDIO_E_INVALID
);
// overwrite_samples before set_channel_count is a state error.
let data = [0.5f32; 8];
let planes = [data.as_ptr()];
assert_eq!(
unsafe { oakaudio_waveform_overwrite_samples(w, planes.as_ptr(), 8, 48000, 0, 1) },
OAKAUDIO_E_STATE
);
// A zero denominator is rejected.
assert_eq!(
unsafe { oakaudio_waveform_overwrite_samples(w, planes.as_ptr(), 8, 48000, 0, 0) },
OAKAUDIO_E_INVALID
);
// A non-positive frame count is invalid.
assert_eq!(
unsafe { oakaudio_waveform_overwrite_samples(w, planes.as_ptr(), 0, 48000, 0, 1) },
OAKAUDIO_E_INVALID
);
// NULL planes are invalid.
assert_eq!(
unsafe { oakaudio_waveform_overwrite_samples(w, std::ptr::null(), 8, 48000, 0, 1) },
OAKAUDIO_E_INVALID
);
unsafe { oakaudio_waveform_free(&mut w) };
}
/// free(NULL)/free(empty) are no-ops.
#[test]
fn free_null_noop() {
let mut w = oakaudio::handle::CHandle::null();
unsafe { oakaudio_waveform_free(&mut w) };
unsafe { oakaudio_waveform_free(std::ptr::null_mut()) };
}
-71
View File
@@ -1,71 +0,0 @@
# 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/>.
add_library(oakaudio SHARED
audiolevelmeter.cpp
audiolevelmeter.h
audiomanager.cpp
audiomanager.h
audioprocessor.cpp
audioprocessor.h
audiosynchronizer.cpp
audiosynchronizer.h
audiovisualwaveform.cpp
audiovisualwaveform.h
audiowaveformsync.cpp
audiowaveformsync.h
configbridge.cpp
configbridge.h
previewaudiodevice.cpp
previewaudiodevice.h
)
# In a full-tree build the repo root is CMAKE_SOURCE_DIR; a standalone
# build (see src/audio/standalone) sets OAK_REPO_ROOT explicitly.
if(NOT DEFINED OAK_REPO_ROOT)
set(OAK_REPO_ROOT ${CMAKE_SOURCE_DIR})
endif()
find_package(PortAudio REQUIRED)
target_include_directories(oakaudio PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
${OAK_REPO_ROOT}/include
${OAK_REPO_ROOT}/core/include
${OAK_REPO_ROOT}/ffmpeg_bridge/include
${PORTAUDIO_INCLUDE_DIRS}
)
# 01 §1 rule 5: only the OAKAUDIO_API-marked C functions are exported;
# audio-internal C++ classes must not leak into the global symbol
# namespace.
target_compile_options(oakaudio PRIVATE
-fvisibility=hidden
-fvisibility-inlines-hidden
)
# Cross-module access goes through C ABIs only: oakcommon (config,
# ffmpegutils), oakcodec (encoder for recording, decoder for waveform
# extraction), olivecore (Rational/AudioParams/SampleBuffer wrappers),
# ffmpeg_bridge (fb_audio_graph resampler infra, same precedent as
# oakcommon/oakcodec), PortAudio (output device).
target_link_libraries(oakaudio PUBLIC
oakcommon
oakcodec
olivecore
ffmpeg_bridge
${PORTAUDIO_LIBRARIES}
)
-117
View File
@@ -1,117 +0,0 @@
/***
Oak - 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/>.
***/
#include "audiolevelmeter.h"
#include <algorithm>
#include <cmath>
namespace olive
{
// De-Qt note: engine/common/decibel.h pulls in QtGlobal, so the two
// constants/functions used here are inlined (same math: minimum = -200,
// from_linear = 20*log10 clamped to minimum on -inf).
static constexpr double k_decibel_minimum = -200.0;
static double decibel_from_linear(double linear)
{
double v = 20.0 * std::log10(linear);
if (std::isinf(v)) {
return k_decibel_minimum;
}
return v;
}
AudioLevelMeter::Stats
AudioLevelMeter::analyze_sample_buffer(const core::SampleBuffer &samples)
{
Stats stats;
const int channel_count = samples.channel_count();
const size_t sample_count = samples.sample_count();
stats.channels.resize(channel_count);
if (!channel_count || !sample_count) {
return stats;
}
double total_square = 0.0;
size_t total_samples = 0;
for (int channel = 0; channel < channel_count; channel++) {
const float *channel_data = samples.data(channel);
double peak = 0.0;
double square_sum = 0.0;
for (size_t sample = 0; sample < sample_count; sample++) {
const double value = channel_data[sample];
const double abs_value = std::abs(value);
peak = std::max(peak, abs_value);
square_sum += value * value;
}
const double mean_square =
square_sum / static_cast<double>(sample_count);
const double rms = std::sqrt(mean_square);
ChannelStats channel_stats;
channel_stats.peak_linear = peak;
channel_stats.peak_db = linear_to_db(peak);
channel_stats.rms_linear = rms;
channel_stats.rms_db = linear_to_db(rms);
channel_stats.vu_db = channel_stats.rms_db;
stats.channels[channel] = channel_stats;
stats.max_peak_linear = std::max(stats.max_peak_linear, peak);
total_square += square_sum;
total_samples += sample_count;
}
// qFuzzyIsNull(double): |x| < 1e-12
stats.silence = std::abs(stats.max_peak_linear) < 1e-12;
stats.integrated_lufs =
power_to_lufs(total_square / static_cast<double>(total_samples));
return stats;
}
double AudioLevelMeter::linear_to_db(double linear)
{
if (linear <= 0.0) {
return k_decibel_minimum;
}
return decibel_from_linear(linear);
}
double AudioLevelMeter::power_to_lufs(double mean_square)
{
if (mean_square <= 0.0) {
return k_decibel_minimum;
}
// BS.1770 loudness uses K-weighted mean square. This first pass stores the
// compatible unit and can be extended with K-weighting without changing UI.
return -0.691 + 10.0 * std::log10(mean_square);
}
}
-57
View File
@@ -1,57 +0,0 @@
/***
Oak - 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/>.
***/
#ifndef OAK_AUDIOLEVELMETER_H
#define OAK_AUDIOLEVELMETER_H
#include <vector>
#include "olive/core/render/samplebuffer.h"
namespace olive
{
class AudioLevelMeter {
public:
struct ChannelStats {
double peak_linear = 0.0;
double peak_db = -200.0;
double rms_linear = 0.0;
double rms_db = -200.0;
double vu_db = -200.0;
};
struct Stats {
std::vector<ChannelStats> channels;
double max_peak_linear = 0.0;
double integrated_lufs = -200.0;
bool silence = true;
};
static Stats analyze_sample_buffer(const core::SampleBuffer &samples);
private:
static double linear_to_db(double linear);
static double power_to_lufs(double mean_square);
};
}
#endif // OAK_AUDIOLEVELMETER_H
-523
View File
@@ -1,523 +0,0 @@
/***
Olive - Non-Linear Video Editor
Copyright (C) 2022 Olive Team
Modifications Copyright (C) 2025 mikesolar
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/>.
***/
#include "audiomanager.h"
#include <algorithm>
#include <cstdio>
#include <cstring>
#ifdef PA_HAS_JACK
#include <pa_jack.h>
#endif
#include "configbridge.h"
namespace olive
{
AudioManager *AudioManager::instance_ = nullptr;
void AudioManager::create_instance()
{
if (instance_ == nullptr) {
instance_ = new AudioManager();
}
}
void AudioManager::destroy_instance()
{
delete instance_;
instance_ = nullptr;
}
AudioManager *AudioManager::instance()
{
return instance_;
}
void AudioManager::set_output_notify_interval(int64_t n)
{
output_buffer_->set_notify_interval(n);
}
void AudioManager::set_output_notify_callback(std::function<void()> callback)
{
output_buffer_->set_notify_callback(std::move(callback));
}
int output_callback(const void *input, void *output, unsigned long frame_count,
const PaStreamCallbackTimeInfo *time_info,
PaStreamCallbackFlags status_flags, void *user_data)
{
(void) input;
(void) time_info;
(void) status_flags;
PreviewAudioDevice *device = static_cast<PreviewAudioDevice *>(user_data);
int64_t max_read = int64_t(frame_count) * device->bytes_per_frame();
int64_t read_count =
device->read(reinterpret_cast<char *>(output), max_read);
if (read_count < max_read) {
memset(reinterpret_cast<uint8_t *>(output) + read_count, 0,
size_t(max_read - read_count));
}
// Count all frames leaving the device (including zero-filled underrun
// frames) so this can serve as the playback master clock
device->add_output_frames(frame_count);
return paContinue;
}
int input_callback(const void *input, void *output, unsigned long frame_count,
const PaStreamCallbackTimeInfo *time_info,
PaStreamCallbackFlags status_flags, void *user_data)
{
(void) output;
(void) time_info;
(void) status_flags;
// The oakcodec encoder write path accepts interleaved float32 only; the
// input stream is opened with paFloat32 (see start_recording()).
OakEncoder *encoder = static_cast<OakEncoder *>(user_data);
oakcodec_encoder_write_audio(*encoder,
reinterpret_cast<const float *>(input),
int(frame_count));
return paContinue;
}
bool AudioManager::push_to_output(const core::AudioParams &params,
const char *samples, int64_t samples_size,
std::string *error)
{
if (output_device_ == paNoDevice) {
if (error)
*error = "No output device is set";
return false;
}
if (output_params_ != params || output_stream_ == nullptr) {
output_params_ = params;
close_output_stream();
PaStreamParameters p = get_port_audio_params(params, output_device_);
// 0 = let PortAudio choose the buffer size
const unsigned long frames_per_buffer =
(unsigned long) audio_config::output_buffer_size();
PaError r = Pa_OpenStream(&output_stream_, nullptr, &p,
output_params_.sample_rate(),
frames_per_buffer, paNoFlag, output_callback,
output_buffer_);
if (r != paNoError) {
// Unhandled error
fprintf(stderr,
"AudioManager::push_to_output: Pa_OpenStream failed: %s\n",
Pa_GetErrorText(r));
if (error)
*error = Pa_GetErrorText(r);
return false;
}
output_buffer_->set_bytes_per_frame(output_params_.samples_to_bytes(1));
}
output_buffer_->write(samples, samples_size);
if (!Pa_IsStreamActive(output_stream_)) {
PaError r = Pa_StartStream(output_stream_);
if (r != paNoError) {
fprintf(stderr,
"AudioManager::push_to_output: Pa_StartStream returned "
"%d %s\n",
r, Pa_GetErrorText(r));
}
}
return true;
}
void AudioManager::clear_buffered_output()
{
output_buffer_->clear();
}
double AudioManager::seconds() const
{
if (!output_stream_ || !Pa_IsStreamActive(output_stream_)) {
return -1.0;
}
double seconds = double(output_buffer_->output_frames_consumed()) /
double(output_params_.sample_rate());
// Compensate for output latency so the clock reflects what is audible
if (const PaStreamInfo *info = Pa_GetStreamInfo(output_stream_)) {
seconds -= info->outputLatency;
}
return std::max(0.0, seconds);
}
void AudioManager::reset_output_clock()
{
output_buffer_->reset_output_frames();
}
PaSampleFormat AudioManager::get_port_audio_sample_format(core::SampleFormat fmt)
{
switch (fmt) {
case core::SampleFormat::u8:
case core::SampleFormat::u8_p:
return paUInt8;
case core::SampleFormat::s16:
case core::SampleFormat::s16_p:
return paInt16;
case core::SampleFormat::s32:
case core::SampleFormat::s32_p:
return paInt32;
case core::SampleFormat::f32:
case core::SampleFormat::f32_p:
return paFloat32;
case core::SampleFormat::s64:
case core::SampleFormat::s64_p:
case core::SampleFormat::f64:
case core::SampleFormat::f64_p:
case core::SampleFormat::invalid:
case core::SampleFormat::count:
break;
}
return 0;
}
void AudioManager::close_output_stream()
{
if (output_stream_) {
if (Pa_IsStreamActive(output_stream_)) {
stop_output();
}
Pa_CloseStream(output_stream_);
output_stream_ = nullptr;
}
}
void AudioManager::stop_output()
{
// Abort the stream so playback stops immediately
if (output_stream_) {
Pa_AbortStream(output_stream_);
clear_buffered_output();
}
}
void AudioManager::set_output_device(PaDeviceIndex device)
{
if (device == paNoDevice) {
fprintf(stderr, "AudioManager: no output device found\n");
} else if (device < 0 || device >= Pa_GetDeviceCount()) {
fprintf(stderr, "AudioManager: invalid output audio device index: "
"%d\n",
device);
} else {
fprintf(stderr, "AudioManager: setting output audio device to %s\n",
Pa_GetDeviceInfo(device)->name);
}
output_device_ = device;
close_output_stream();
}
void AudioManager::set_input_device(PaDeviceIndex device)
{
if (device == paNoDevice) {
fprintf(stderr, "AudioManager: no input device found\n");
} else if (device < 0 || device >= Pa_GetDeviceCount()) {
fprintf(stderr, "AudioManager: invalid input audio device index: %d\n",
device);
} else {
fprintf(stderr, "AudioManager: setting input audio device to %s\n",
Pa_GetDeviceInfo(device)->name);
}
input_device_ = device;
}
void AudioManager::hard_reset()
{
close_output_stream();
Pa_Terminate();
Pa_Initialize();
}
bool AudioManager::start_recording(const oakcodec_encoding_params &params,
std::string *error_str)
{
if (input_device_ == paNoDevice) {
return false;
}
input_encoder_ = oakcodec_encoder_init(&params);
if (!input_encoder_.ctx || oakcodec_encoder_open(input_encoder_) != 0) {
fprintf(stderr,
"AudioManager: failed to open encoder for recording\n");
if (input_encoder_.ctx) {
char buf[512];
if (oakcodec_encoder_last_error(input_encoder_, buf,
int(sizeof(buf))) > 0 &&
error_str) {
*error_str = buf;
}
oakcodec_encoder_free(&input_encoder_);
}
return false;
}
// The oakcodec encoder write path takes interleaved float32; capture in
// that format regardless of the target encoding sample format.
core::AudioParams stream_params(params.audio_sample_rate,
params.audio_channel_layout,
core::SampleFormat::f32);
PaStreamParameters p =
get_port_audio_params(stream_params, input_device_);
PaError r = Pa_OpenStream(&input_stream_, &p, nullptr,
params.audio_sample_rate,
paFramesPerBufferUnspecified, paNoFlag,
input_callback, &input_encoder_);
if (r == paNoError) {
r = Pa_StartStream(input_stream_);
if (r == paNoError) {
return true;
}
}
if (error_str) {
*error_str = Pa_GetErrorText(r);
}
stop_recording();
return false;
}
void AudioManager::stop_recording()
{
if (input_stream_) {
if (Pa_IsStreamActive(input_stream_)) {
Pa_StopStream(input_stream_);
}
Pa_CloseStream(input_stream_);
input_stream_ = nullptr;
}
if (input_encoder_.ctx) {
oakcodec_encoder_flush(input_encoder_);
oakcodec_encoder_free(&input_encoder_);
}
}
#ifdef __linux__
static bool str_contains_ci(const char *haystack, const char *needle)
{
const size_t needle_len = strlen(needle);
if (!needle_len) {
return true;
}
for (const char *p = haystack; *p; p++) {
if (strncasecmp(p, needle, needle_len) == 0) {
return true;
}
}
return false;
}
static bool is_preferred_linux_audio_host_api(const PaHostApiInfo *info)
{
if (!info) {
return false;
}
return str_contains_ci(info->name, "PipeWire") ||
str_contains_ci(info->name, "JACK") ||
str_contains_ci(info->name, "PulseAudio");
}
static PaDeviceIndex get_preferred_linux_audio_device(bool is_output_device)
{
// Prefer sound servers that provide mixing and desktop integration
// (PipeWire, JACK, PulseAudio) over plain ALSA defaults, which often
// fail to share the device on modern Linux desktops.
static const char *const preferred_host_apis[] = {
"PipeWire",
"JACK",
"PulseAudio",
};
for (const char *preferred : preferred_host_apis) {
for (PaHostApiIndex i = 0, end = Pa_GetHostApiCount(); i < end; i++) {
const PaHostApiInfo *info = Pa_GetHostApiInfo(i);
if (!info) {
continue;
}
if (str_contains_ci(info->name, preferred)) {
PaDeviceIndex dev = is_output_device ? info->defaultOutputDevice :
info->defaultInputDevice;
if (dev != paNoDevice) {
return dev;
}
}
}
}
return is_output_device ? Pa_GetDefaultOutputDevice() :
Pa_GetDefaultInputDevice();
}
#endif
PaDeviceIndex AudioManager::find_config_device_by_name(bool is_output_device)
{
return find_device_by_name(
audio_config::device_name(is_output_device), is_output_device);
}
PaDeviceIndex AudioManager::find_device_by_name(const std::string &s,
bool is_output_device)
{
PaDeviceIndex exact_match = paNoDevice;
if (!s.empty()) {
for (PaDeviceIndex i = 0, end = Pa_GetDeviceCount(); i < end; i++) {
const PaDeviceInfo *device = Pa_GetDeviceInfo(i);
if (!device) {
continue;
}
if (((is_output_device && device->maxOutputChannels) ||
(!is_output_device && device->maxInputChannels)) &&
s == device->name) {
exact_match = i;
break;
}
}
}
#ifdef __linux__
// Even if the user/config picked a device by name, upgrade to a preferred
// host API (PipeWire/JACK/PulseAudio) when one is available. This avoids
// getting stuck on an ALSA device that cannot share the hardware.
if (exact_match != paNoDevice) {
const PaDeviceInfo *matched_info = Pa_GetDeviceInfo(exact_match);
if (matched_info) {
const PaHostApiInfo *host_api =
Pa_GetHostApiInfo(matched_info->hostApi);
if (is_preferred_linux_audio_host_api(host_api)) {
// Keep an explicit choice that already uses a preferred API.
return exact_match;
}
// Upgrade a non-preferred (e.g. ALSA) match to a preferred backend
// when one is available.
PaDeviceIndex preferred =
get_preferred_linux_audio_device(is_output_device);
if (preferred != paNoDevice) {
return preferred;
}
// No preferred backend available; keep the saved device.
return exact_match;
}
}
return get_preferred_linux_audio_device(is_output_device);
#else
if (exact_match != paNoDevice) {
return exact_match;
}
return is_output_device ? Pa_GetDefaultOutputDevice() :
Pa_GetDefaultInputDevice();
#endif
}
PaStreamParameters AudioManager::get_port_audio_params(const core::AudioParams &params,
PaDeviceIndex device)
{
PaStreamParameters p;
p.channelCount = params.channel_count();
p.device = device;
p.hostApiSpecificStreamInfo = nullptr;
p.sampleFormat = get_port_audio_sample_format(params.format());
if (device >= 0 && device < Pa_GetDeviceCount()) {
p.suggestedLatency = Pa_GetDeviceInfo(device)->defaultLowOutputLatency;
} else {
p.suggestedLatency = 0;
}
return p;
}
AudioManager::AudioManager()
: output_stream_(nullptr)
, input_stream_(nullptr)
{
input_encoder_.ctx = nullptr;
input_encoder_.addref = nullptr;
input_encoder_.release = nullptr;
input_encoder_.abi_version = 0;
#ifdef PA_HAS_JACK
// PortAudio doesn't do a strcpy, so we need a const char that's readily accessible
PaJack_SetClientName("Oak Video Editor");
#endif
Pa_Initialize();
// Get device from config
PaDeviceIndex output_device = find_config_device_by_name(true);
PaDeviceIndex input_device = find_config_device_by_name(false);
set_output_device(output_device);
set_input_device(input_device);
output_buffer_ = new PreviewAudioDevice();
}
AudioManager::~AudioManager()
{
close_output_stream();
delete output_buffer_;
Pa_Terminate();
}
}
-142
View File
@@ -1,142 +0,0 @@
/***
Olive - Non-Linear Video Editor
Copyright (C) 2022 Olive Team
Modifications Copyright (C) 2025 mikesolar
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/>.
***/
#ifndef OAK_AUDIOMANAGER_H
#define OAK_AUDIOMANAGER_H
#include <functional>
#include <memory>
#include <string>
#include <portaudio.h>
#include "codec/encoder.h"
#include "olive/core/render/audioparams.h"
#include "previewaudiodevice.h"
namespace olive
{
/**
* @brief Audio input and output management class
*
* Wraps a PortAudio output stream and a PreviewAudioDevice pull buffer,
* exposing audio functionality to the rest of the system.
*
* De-Qt notes:
* - No longer a QObject and no longer inherits PlaybackAudioClock (the
* clock interface lives in engine/common, which is not split); the
* seconds() method is kept with the same semantics.
* - The output_params_changed / output_notify signals are gone; the
* notify-interval pulse is delivered through an optional
* std::function (set_output_notify_callback) instead.
* - Recording goes through the oakcodec encoder C ABI (OakEncoder)
* instead of the FFmpegEncoder C++ class; the input stream is always
* captured as interleaved float32.
*/
class AudioManager {
public:
static void create_instance();
static void destroy_instance();
static AudioManager *instance();
void set_output_notify_interval(int64_t n);
/**
* @brief Optional callback fired when a notify interval boundary is
* crossed (called from the PortAudio callback thread)
*/
void set_output_notify_callback(std::function<void()> callback);
bool push_to_output(const core::AudioParams &params, const char *samples,
int64_t samples_size, std::string *error = nullptr);
void clear_buffered_output();
void stop_output();
/**
* @brief Seconds of audio consumed by the output device since the last reset
*
* Compensated for output latency so it represents what is actually
* audible. Returns a negative value when no output stream is running.
*/
double seconds() const;
/**
* @brief Restarts the output clock at zero for a new playback run
*/
void reset_output_clock();
PaDeviceIndex get_output_device() const
{
return output_device_;
}
PaDeviceIndex get_input_device() const
{
return input_device_;
}
void set_output_device(PaDeviceIndex device);
void set_input_device(PaDeviceIndex device);
void hard_reset();
bool start_recording(const oakcodec_encoding_params &params,
std::string *error_str = nullptr);
void stop_recording();
static PaDeviceIndex find_config_device_by_name(bool is_output_device);
static PaDeviceIndex find_device_by_name(const std::string &s,
bool is_output_device);
static PaStreamParameters get_port_audio_params(const core::AudioParams &p,
PaDeviceIndex device);
private:
AudioManager();
~AudioManager();
static PaSampleFormat get_port_audio_sample_format(core::SampleFormat fmt);
void close_output_stream();
static AudioManager *instance_;
PaDeviceIndex output_device_;
PaStream *output_stream_;
core::AudioParams output_params_;
PreviewAudioDevice *output_buffer_;
PaDeviceIndex input_device_;
PaStream *input_stream_;
OakEncoder input_encoder_;
};
}
#endif // OAK_AUDIOMANAGER_H
-218
View File
@@ -1,218 +0,0 @@
/***
Olive - Non-Linear Video Editor
Copyright (C) 2022 Olive Team
Modifications Copyright (C) 2025 mikesolar
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/>.
***/
#include "audioprocessor.h"
#include <cstdio>
#include <cstring>
#include "common/ffmpegutils.h"
namespace olive
{
/**
* @brief Bridge sample format for a native format via the oakcommon C ABI
*/
static int to_bridge_sample_format(core::SampleFormat fmt)
{
int out = -1; /* fb_sample_fmt_none */
oakcommon_ffmpegutils_get_ffmpeg_sample_format(int(fmt), &out);
return out;
}
/**
* @brief Ensure an AudioParams has a usable channel layout mask.
*
* The bridge's abuffer/aformat filters reject a channel layout mask of 0
* (e.g. when the user config or a source stream reports a mask of 0).
* If the mask is zero, fall back to a default layout derived from the
* channel count (stereo when unknown).
*/
static core::AudioParams fix_channel_layout(const core::AudioParams &params)
{
core::AudioParams result = params;
if (params.channel_layout() == 0) {
int channels = params.channel_count();
if (channels <= 0) {
channels = 2;
}
fprintf(stderr,
"AudioProcessor: fixing unspecified channel layout "
"(channels=%d) -> default %d channel layout\n",
params.channel_count(), channels);
result.set_channel_layout(fb_channel_layout_default(channels));
}
return result;
}
AudioProcessor::AudioProcessor()
{
graph_ = nullptr;
out_frame_ = nullptr;
}
AudioProcessor::~AudioProcessor()
{
close();
}
bool AudioProcessor::open(const core::AudioParams &from,
const core::AudioParams &to, double tempo)
{
if (graph_) {
fprintf(stderr,
"AudioProcessor: tried to open a processor that was "
"already open\n");
return false;
}
core::AudioParams from_fixed = fix_channel_layout(from);
core::AudioParams to_fixed = fix_channel_layout(to);
FBAudioGraphConfig config;
memset(&config, 0, sizeof(config));
config.in_sample_rate = from_fixed.sample_rate();
config.in_channel_layout_mask = from_fixed.channel_layout();
config.in_sample_format = to_bridge_sample_format(from_fixed.format());
config.in_channels = from_fixed.channel_count();
config.out_sample_rate = to_fixed.sample_rate();
config.out_channel_layout_mask = to_fixed.channel_layout();
config.out_sample_format = to_bridge_sample_format(to_fixed.format());
config.out_channels = to_fixed.channel_count();
config.out_is_planar = to_fixed.format().is_planar() ? 1 : 0;
config.tempo = tempo;
graph_ = fb_audio_graph_create(&config);
if (!graph_) {
fprintf(stderr, "AudioProcessor: failed to create audio filter "
"graph\n");
return false;
}
out_frame_ = fb_frame_alloc();
if (!out_frame_) {
fprintf(stderr, "AudioProcessor: failed to allocate output frame\n");
close();
return false;
}
from_ = from_fixed;
to_ = to_fixed;
return true;
}
void AudioProcessor::close()
{
if (graph_) {
fb_audio_graph_free(&graph_);
}
if (out_frame_) {
fb_frame_free(&out_frame_);
}
}
int AudioProcessor::convert(float **in, int nb_in_samples,
AudioProcessor::Buffer *output)
{
if (!is_open()) {
fprintf(stderr,
"AudioProcessor: tried to convert on closed processor\n");
return -1;
}
int r = 0;
if (in && nb_in_samples) {
r = fb_audio_graph_push(
graph_, reinterpret_cast<const uint8_t *const *>(in),
nb_in_samples);
if (r < 0) {
fprintf(stderr,
"AudioProcessor: failed to add frame to buffersrc: %d\n",
r);
return r;
}
}
if (output) {
int nb_channels = to_.channel_count();
if (to_.format().is_packed()) {
nb_channels = 1;
}
AudioProcessor::Buffer &result = *output;
result.resize(size_t(nb_channels));
int byte_offset = 0;
while (true) {
r = fb_audio_graph_pull(graph_, out_frame_);
if (r <= 0) {
if (r == 0) {
// No more output available right now
r = 0;
} else {
// Handle unexpected error
fprintf(stderr,
"AudioProcessor: failed to pull from "
"buffersink: %d\n",
r);
}
break;
}
int nb_bytes = fb_frame_get_nb_samples(out_frame_) *
to_.bytes_per_sample_per_channel();
if (to_.format().is_packed()) {
nb_bytes *= to_.channel_count();
}
for (int i = 0; i < nb_channels; i++) {
result[size_t(i)].resize(size_t(byte_offset + nb_bytes));
memcpy(result[size_t(i)].data() + byte_offset,
fb_frame_get_data(out_frame_, i), size_t(nb_bytes));
}
byte_offset += nb_bytes;
}
}
return r;
}
void AudioProcessor::flush()
{
int r = fb_audio_graph_push(graph_, nullptr, 0);
if (r < 0) {
fprintf(stderr, "AudioProcessor: failed to flush: %d\n", r);
}
}
}
-80
View File
@@ -1,80 +0,0 @@
/***
Olive - Non-Linear Video Editor
Copyright (C) 2022 Olive Team
Modifications Copyright (C) 2025 mikesolar
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/>.
***/
#ifndef OAK_AUDIOPROCESSOR_H
#define OAK_AUDIOPROCESSOR_H
#include <inttypes.h>
#include <vector>
#include <ffmpeg_bridge/ffmpeg_bridge.h>
#include "olive/core/render/audioparams.h"
namespace olive
{
class AudioProcessor {
public:
AudioProcessor();
~AudioProcessor();
AudioProcessor(const AudioProcessor &) = delete;
AudioProcessor &operator=(const AudioProcessor &) = delete;
bool open(const core::AudioParams &from, const core::AudioParams &to,
double tempo = 1.0);
void close();
bool is_open() const
{
return graph_;
}
using Buffer = std::vector<std::vector<char>>;
int convert(float **in, int nb_in_samples, AudioProcessor::Buffer *output);
void flush();
const core::AudioParams &from() const
{
return from_;
}
const core::AudioParams &to() const
{
return to_;
}
private:
FBAudioGraph *graph_;
core::AudioParams from_;
core::AudioParams to_;
FBFrame *out_frame_;
};
}
#endif // OAK_AUDIOPROCESSOR_H
-65
View File
@@ -1,65 +0,0 @@
/***
Oak - 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/>.
***/
#include "audiosynchronizer.h"
namespace olive
{
AudioSynchronizer::Placement AudioSynchronizer::place_by_source_time(
const SourceClip &reference, const SourceClip &candidate,
const core::Rational &reference_timeline_in)
{
Placement placement;
if (!reference.has_source_start_time || !candidate.has_source_start_time ||
reference.source_start_time.isNaN() ||
candidate.source_start_time.isNaN()) {
return placement;
}
const core::Rational reference_head_source =
reference.source_start_time + reference.media_in;
const core::Rational candidate_head_source =
candidate.source_start_time + candidate.media_in;
placement.timeline_in =
reference_timeline_in + candidate_head_source - reference_head_source;
placement.valid = !placement.timeline_in.isNaN();
return placement;
}
AudioSynchronizer::Placement AudioSynchronizer::place_by_waveform_offset(
const core::Rational &reference_timeline_in,
int64_t candidate_offset_samples, int sample_rate)
{
Placement placement;
if (sample_rate <= 0) {
return placement;
}
placement.timeline_in = reference_timeline_in +
core::Rational::from_double(
static_cast<double>(candidate_offset_samples) /
static_cast<double>(sample_rate));
placement.valid = !placement.timeline_in.isNaN();
return placement;
}
}
-55
View File
@@ -1,55 +0,0 @@
/***
Oak - 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/>.
***/
#ifndef OAK_AUDIOSYNCHRONIZER_H
#define OAK_AUDIOSYNCHRONIZER_H
#include <cstdint>
#include "olive/core/util/rational.h"
namespace olive
{
class AudioSynchronizer {
public:
struct SourceClip {
core::Rational source_start_time;
core::Rational media_in;
bool has_source_start_time = false;
};
struct Placement {
core::Rational timeline_in;
bool valid = false;
};
static Placement
place_by_source_time(const SourceClip &reference, const SourceClip &candidate,
const core::Rational &reference_timeline_in);
static Placement
place_by_waveform_offset(const core::Rational &reference_timeline_in,
int64_t candidate_offset_samples, int sample_rate);
};
}
#endif // OAK_AUDIOSYNCHRONIZER_H
-478
View File
@@ -1,478 +0,0 @@
/***
Olive - Non-Linear Video Editor
Copyright (C) 2022 Olive Team
Modifications Copyright (C) 2025 mikesolar
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/>.
***/
#include "audiovisualwaveform.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstring>
#include "olive/core/util/cpuoptimize.h"
namespace olive
{
using core::Rational;
const Rational AudioVisualWaveform::k_minimum_sample_rate = Rational(1, 8);
const Rational AudioVisualWaveform::k_maximum_sample_rate = 1024;
AudioVisualWaveform::AudioVisualWaveform()
: channels_(0)
{
for (Rational i = k_minimum_sample_rate; i <= k_maximum_sample_rate; i *= 2) {
mipmapped_data_.insert({ i, Sample() });
}
}
void AudioVisualWaveform::overwrite_samples_from_buffer(
const core::SampleBuffer &samples, int sample_rate, const Rational &start,
double target_rate, Sample &data, size_t &start_index,
size_t &samples_length)
{
start_index = time_to_samples(start, target_rate);
samples_length =
time_to_samples(static_cast<double>(samples.sample_count()) /
static_cast<double>(sample_rate),
target_rate);
size_t end_index = start_index + samples_length;
if (data.size() < end_index) {
data.resize(end_index);
}
double chunk_size = double(sample_rate) / double(target_rate);
for (size_t i = 0; i < samples_length; i += channels_) {
size_t src_start = size_t(std::llround(double(i) * chunk_size)) / channels_;
size_t src_end = std::min(
size_t(std::llround(double(i + channels_) * chunk_size)) / channels_,
samples.sample_count());
Sample summary = sum_samples(samples, src_start, src_end - src_start);
memcpy(&data.data()[i + start_index], summary.data(),
summary.size() * sizeof(SamplePerChannel));
}
}
void AudioVisualWaveform::overwrite_samples_from_mipmap(
const AudioVisualWaveform::Sample &input, double input_sample_rate,
size_t &input_start, size_t &input_length, const Rational &start,
double output_rate, AudioVisualWaveform::Sample &output_data)
{
size_t start_index = time_to_samples(start, output_rate);
size_t samples_length = time_to_samples(
static_cast<double>(input_length / channels_) / input_sample_rate,
output_rate);
size_t end_index = start_index + samples_length;
if (output_data.size() < end_index) {
output_data.resize(end_index);
}
// We guarantee mipmaps are powers of two so integer division should be perfectly accurate here
size_t chunk_size = size_t(input_sample_rate / output_rate);
for (size_t i = 0; i < samples_length; i += channels_) {
Sample summary =
re_sum_samples(&input.data()[input_start + (i * chunk_size)],
chunk_size * channels_, channels_);
memcpy(&output_data.data()[i + start_index], summary.data(),
summary.size() * sizeof(SamplePerChannel));
}
input_start = start_index;
input_length = samples_length;
}
void AudioVisualWaveform::validate_virtual_start(const Rational &new_start)
{
if (length_ == 0) {
virtual_start_ = new_start;
} else if (virtual_start_ > new_start) {
trim_in(new_start - virtual_start_);
}
}
void AudioVisualWaveform::overwrite_samples(const core::SampleBuffer &samples,
int sample_rate,
const Rational &start)
{
if (!channels_) {
fprintf(stderr,
"AudioVisualWaveform: failed to write samples - channel "
"count is zero\n");
return;
}
validate_virtual_start(start);
// Process the largest mipmap directly for the samples
auto current_mipmap = mipmapped_data_.rbegin();
size_t input_start, input_length;
overwrite_samples_from_buffer(samples, sample_rate, start - virtual_start_,
current_mipmap->first.to_double(),
current_mipmap->second, input_start,
input_length);
while (true) {
// For each smaller mipmap, we just process from the mipmap before it, making each one
// exponentially faster to create
auto previous_mipmap = current_mipmap;
current_mipmap++;
if (current_mipmap == mipmapped_data_.rend()) {
break;
}
overwrite_samples_from_mipmap(
previous_mipmap->second, previous_mipmap->first.to_double(),
input_start, input_length, start - virtual_start_,
current_mipmap->first.to_double(), current_mipmap->second);
}
Rational sample_length(int64_t(samples.sample_count()), sample_rate);
length_ = std::max(length_, start + sample_length);
}
void AudioVisualWaveform::overwrite_sums(const AudioVisualWaveform &sums,
const Rational &dest,
const Rational &offset,
const Rational &length)
{
validate_virtual_start(dest);
for (auto it = mipmapped_data_.begin(); it != mipmapped_data_.end(); it++) {
Rational rate = it->first;
Sample &our_arr = it->second;
const Sample &their_arr = sums.mipmapped_data_.at(rate);
double rate_dbl = rate.to_double();
// Get our destination sample
size_t our_start_index =
time_to_samples(dest - virtual_start_, rate_dbl);
// Get our source sample, indexing with the SOURCE's channel count
size_t their_start_index = size_t(std::floor(offset.to_double() * rate_dbl)) *
size_t(sums.channel_count());
if (their_start_index >= their_arr.size()) {
continue;
}
// Determine how much we're copying
size_t copy_len = their_arr.size() - their_start_index;
if (!length.isNull()) {
copy_len = std::min(copy_len, time_to_samples(length, rate_dbl));
if (copy_len == 0) {
continue;
}
}
// Determine end index of our array
size_t end_index = our_start_index + copy_len;
if (our_arr.size() < end_index) {
our_arr.resize(end_index);
}
memcpy(reinterpret_cast<char *>(our_arr.data()) +
our_start_index * sizeof(SamplePerChannel),
reinterpret_cast<const char *>(their_arr.data()) +
their_start_index * sizeof(SamplePerChannel),
copy_len * sizeof(SamplePerChannel));
}
length_ = std::max(length_, dest + ((length.isNull()) ? sums.length() - offset :
length));
}
void AudioVisualWaveform::overwrite_silence(const Rational &start,
const Rational &length)
{
validate_virtual_start(start);
for (auto it = mipmapped_data_.begin(); it != mipmapped_data_.end(); it++) {
Rational rate = it->first;
Sample &our_arr = it->second;
double rate_dbl = rate.to_double();
// Get our destination sample
size_t our_start_index =
time_to_samples(start - virtual_start_, rate_dbl);
size_t our_length_index = time_to_samples(length, rate_dbl);
size_t our_end_index = our_start_index + our_length_index;
if (our_arr.size() < our_end_index) {
our_arr.resize(our_end_index);
}
memset(reinterpret_cast<char *>(our_arr.data()) +
our_start_index * sizeof(SamplePerChannel),
0, our_length_index * sizeof(SamplePerChannel));
}
length_ = std::max(length_, start + length);
}
void AudioVisualWaveform::trim_in(Rational length)
{
if (length == 0) {
return;
}
virtual_start_ += length;
bool negative = (length < 0);
if (negative) {
length = -length;
}
for (auto it = mipmapped_data_.begin(); it != mipmapped_data_.end(); it++) {
Rational rate = it->first;
double rate_dbl = rate.to_double();
Sample &data = it->second;
size_t chop_length = time_to_samples(length, rate_dbl);
if (chop_length == 0) {
continue;
}
if (!negative) {
data = Sample(data.begin() + chop_length, data.end());
} else {
data.insert(data.begin(), chop_length, SamplePerChannel());
}
}
if (!negative) {
length_ = std::max(Rational(0), length_ - length);
}
// Prepending grows the data before the existing start, so the absolute
// end (which length_ tracks) is unchanged
}
AudioVisualWaveform AudioVisualWaveform::mid(const Rational &offset) const
{
AudioVisualWaveform mid = *this;
mid.trim_in(offset - virtual_start_);
return mid;
}
AudioVisualWaveform AudioVisualWaveform::mid(const Rational &offset,
const Rational &length) const
{
AudioVisualWaveform mid = *this;
mid.trim_range(offset - virtual_start_, length);
return mid;
}
void AudioVisualWaveform::resize(const Rational &length)
{
if (length_ == length) {
return;
}
for (auto it = mipmapped_data_.begin(); it != mipmapped_data_.end(); it++) {
Rational rate = it->first;
double rate_dbl = rate.to_double();
Sample &data = it->second;
size_t chop_length = time_to_samples(length, rate_dbl);
data.resize(chop_length);
}
length_ = length;
}
void AudioVisualWaveform::trim_range(const Rational &in, const Rational &length)
{
trim_in(in);
resize(length);
}
AudioVisualWaveform::Sample
AudioVisualWaveform::get_summary_from_time(const Rational &start,
const Rational &length) const
{
// Find mipmap that requires
auto using_mipmap = get_mipmap_for_scale(length.flipped().to_double());
double rate_dbl = using_mipmap->first.to_double();
size_t start_sample = time_to_samples(start - virtual_start_, rate_dbl);
size_t sample_length = time_to_samples(length, rate_dbl);
const Sample &mipmap_data = using_mipmap->second;
// Determine if the array actually has this sample. Compare in signed
// arithmetic so a start past the end of the data doesn't underflow.
int64_t available = int64_t(mipmap_data.size()) - int64_t(start_sample);
if (available > 0) {
sample_length = std::min(sample_length, size_t(available));
if (sample_length > 0) {
return re_sum_samples(&mipmap_data.data()[start_sample],
sample_length, channels_);
}
}
// Return null samples
return AudioVisualWaveform::Sample(size_t(channel_count()), { 0, 0 });
}
void expand_min_max_channel(const float *a, size_t length, float &min_val,
float &max_val)
{
#if defined(OLIVE_PROCESSOR_X86) || defined(OLIVE_PROCESSOR_ARM)
// SSE optimized
// load the first 4 elements of 'a' into min and max (they are 4 * 32 = 128 bits)
__m128 max = _mm_loadu_ps(a);
__m128 min = _mm_loadu_ps(a);
// loop over 'a' and compare current elements with min and max 4 by 4.
// we need to make sure we don't read out of boundaries should 'a' length be not mod. 4
for (size_t i = 4; i < length - 4; i += 4) {
__m128 cur = _mm_loadu_ps(a + i);
max = _mm_max_ps(max, cur);
min = _mm_min_ps(min, cur);
}
// so we read the last 4 (or less) elements in a safe manner.
__m128 cur = _mm_loadu_ps(a + length - 4);
max = _mm_max_ps(max, cur);
min = _mm_min_ps(min, cur);
// this potentially overlaps up to the last 3 elements but it's not an issue.
// min and max will contain 4 min and max. To get the absolute min and max
// we need to compare the 4 values over themselves by shuffling each time.
for (size_t i = 0; i < 3; i++) {
max = _mm_max_ps(max, _mm_shuffle_ps(max, max, 0x93));
min = _mm_min_ps(min, _mm_shuffle_ps(min, min, 0x93));
}
// now min and max contain 4 identical items each representing min and max value respectively.
// and we store the first one into a float variable.
_mm_store_ss(&max_val, max);
_mm_store_ss(&min_val, min);
// I bet you don't find annotated low level code very often.
#else
// Standard unoptimized function
for (size_t i = 0; i < length; i++) {
min_val = std::min(min_val, a[i]);
max_val = std::max(max_val, a[i]);
}
#endif
}
AudioVisualWaveform::Sample
AudioVisualWaveform::sum_samples(const core::SampleBuffer &samples,
size_t start_index, size_t length)
{
int channels = samples.audio_params().channel_count();
const size_t channel_count = size_t(channels);
AudioVisualWaveform::Sample summed_samples(channel_count);
for (int channel = 0; channel < channels; channel++) {
expand_min_max_channel(samples.data(channel) + start_index, length,
summed_samples[size_t(channel)].min,
summed_samples[size_t(channel)].max);
}
// for reference: this approximation is n x faster (and less accurate) for a n-tracks clip
// for (size_t i=start_index; i<end_index; i++) {
// ExpandMinMax(summed_samples[i%channels], samples->data(i%channels)[i]);
// }
return summed_samples;
}
AudioVisualWaveform::Sample
AudioVisualWaveform::re_sum_samples(const SamplePerChannel *samples,
size_t nb_samples, int nb_channels)
{
const size_t channel_count = size_t(nb_channels);
AudioVisualWaveform::Sample summed_samples(channel_count);
// Initialize from the first point instead of {0,0}: the engine version
// started from zero-initialized pairs, which clamped all-positive
// (resp. all-negative) ranges to a zero min (max). Fixed in oakaudio.
if (nb_samples >= channel_count) {
for (size_t j = 0; j < channel_count; j++) {
summed_samples[j] = samples[j];
}
}
for (size_t i = 0; i < nb_samples; i += size_t(nb_channels)) {
for (int j = 0; j < nb_channels; j++) {
const AudioVisualWaveform::SamplePerChannel &sample =
samples[i + size_t(j)];
if (sample.min < summed_samples[size_t(j)].min) {
summed_samples[size_t(j)].min = sample.min;
}
if (sample.max > summed_samples[size_t(j)].max) {
summed_samples[size_t(j)].max = sample.max;
}
}
}
return summed_samples;
}
size_t AudioVisualWaveform::time_to_samples(const Rational &time,
double sample_rate) const
{
return time_to_samples(time.to_double(), sample_rate);
}
size_t AudioVisualWaveform::time_to_samples(const double &time,
double sample_rate) const
{
return size_t(std::floor(time * sample_rate)) * size_t(channels_);
}
std::map<Rational, AudioVisualWaveform::Sample>::const_iterator
AudioVisualWaveform::get_mipmap_for_scale(double scale) const
{
// Find largest mipmap for this scale (or the largest if we don't find one sufficient)
for (auto it = mipmapped_data_.cbegin(); it != mipmapped_data_.cend();
it++) {
if (it->first.to_double() >= scale) {
return it;
}
}
// We don't have a mipmap large enough for this scale, so just return the largest we have
return std::prev(mipmapped_data_.cend());
}
}
-160
View File
@@ -1,160 +0,0 @@
/***
Olive - Non-Linear Video Editor
Copyright (C) 2022 Olive Team
Modifications Copyright (C) 2025 mikesolar
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/>.
***/
#ifndef OAK_SUMSAMPLES_H
#define OAK_SUMSAMPLES_H
#include <map>
#include <vector>
#include "olive/core/render/samplebuffer.h"
#include "olive/core/util/rational.h"
namespace olive
{
/**
* @brief A buffer of data used to store a visual representation of audio
*
* This differs from a SampleBuffer as the data in an AudioVisualWaveform has been reduced
* significantly and optimized for visual display.
*
* De-Qt note: the QPainter-based draw_sample()/draw_waveform() functions
* live in the app layer now; this class only stores and summarizes data.
*/
class AudioVisualWaveform {
public:
AudioVisualWaveform();
struct SamplePerChannel {
float min;
float max;
};
using Sample = std::vector<SamplePerChannel>;
int channel_count() const
{
return channels_;
}
void set_channel_count(int channels)
{
channels_ = channels;
}
const core::Rational &length() const
{
return length_;
}
/**
* @brief Writes samples into the visual waveform buffer
*
* Starting at `start`, writes samples over anything in the buffer, expanding it if necessary.
*/
void overwrite_samples(const core::SampleBuffer &samples, int sample_rate,
const core::Rational &start = 0);
/**
* @brief Replaces sums at a certain range in this visual waveform
*
* @param sums
*
* The sums to write over our current ones with.
*
* @param dest
*
* Where in this visual waveform these sums should START being written to.
*
* @param offset
*
* Where in the `sums` parameter this should start reading from. Defaults to 0.
*
* @param length
*
* Maximum length of `sums` to overwrite with.
*/
void overwrite_sums(const AudioVisualWaveform &sums,
const core::Rational &dest,
const core::Rational &offset = 0,
const core::Rational &length = 0);
void overwrite_silence(const core::Rational &start,
const core::Rational &length);
void trim_in(core::Rational length);
AudioVisualWaveform mid(const core::Rational &offset) const;
AudioVisualWaveform mid(const core::Rational &offset,
const core::Rational &length) const;
void resize(const core::Rational &length);
void trim_range(const core::Rational &in, const core::Rational &length);
Sample get_summary_from_time(const core::Rational &start,
const core::Rational &length) const;
static Sample sum_samples(const core::SampleBuffer &samples,
size_t start_index, size_t length);
static Sample re_sum_samples(const SamplePerChannel *samples,
size_t nb_samples, int nb_channels);
// Must be a power of 2
static const core::Rational k_minimum_sample_rate;
static const core::Rational k_maximum_sample_rate;
private:
void overwrite_samples_from_buffer(const core::SampleBuffer &samples,
int sample_rate,
const core::Rational &start,
double target_rate, Sample &data,
size_t &start_index,
size_t &samples_length);
void overwrite_samples_from_mipmap(const Sample &input,
double input_sample_rate,
size_t &input_start, size_t &input_length,
const core::Rational &start,
double output_rate, Sample &output_data);
size_t time_to_samples(const core::Rational &time, double sample_rate) const;
size_t time_to_samples(const double &time, double sample_rate) const;
std::map<core::Rational, Sample>::const_iterator
get_mipmap_for_scale(double scale) const;
void validate_virtual_start(const core::Rational &new_start);
core::Rational virtual_start_;
int channels_;
std::map<core::Rational, Sample> mipmapped_data_;
core::Rational length_;
};
}
#endif // OAK_SUMSAMPLES_H
-257
View File
@@ -1,257 +0,0 @@
/***
Oak - 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/>.
***/
#include "audiowaveformsync.h"
#include <algorithm>
#include <cmath>
namespace olive
{
std::vector<double>
AudioWaveformSync::extract_rms_envelope(const core::SampleBuffer &samples,
size_t window_samples)
{
std::vector<double> envelope;
const int channel_count = samples.channel_count();
const size_t sample_count = samples.sample_count();
if (!channel_count || !sample_count || !window_samples) {
return envelope;
}
const size_t window_count =
(sample_count + window_samples - 1) / window_samples;
envelope.resize(window_count);
for (size_t window = 0; window < window_count; window++) {
const size_t start = window * window_samples;
const size_t end = std::min(start + window_samples, sample_count);
double square_sum = 0.0;
size_t total = 0;
for (int channel = 0; channel < channel_count; channel++) {
const float *data = samples.data(channel);
for (size_t sample = start; sample < end; sample++) {
const double value = data[sample];
square_sum += value * value;
total++;
}
}
envelope[window] =
total ? std::sqrt(square_sum / static_cast<double>(total)) : 0.0;
}
return envelope;
}
AudioWaveformSync::OffsetResult AudioWaveformSync::estimate_offset(
const core::SampleBuffer &reference, const core::SampleBuffer &candidate,
size_t window_samples, int64_t max_offset_samples)
{
if (!window_samples) {
return OffsetResult();
}
const std::vector<double> reference_envelope =
extract_rms_envelope(reference, window_samples);
const std::vector<double> candidate_envelope =
extract_rms_envelope(candidate, window_samples);
const int64_t max_offset_windows =
max_offset_samples / static_cast<int64_t>(window_samples);
return estimate_envelope_offset(reference_envelope, candidate_envelope,
window_samples, max_offset_windows);
}
AudioWaveformSync::OffsetResult AudioWaveformSync::estimate_envelope_offset(
const std::vector<double> &reference, const std::vector<double> &candidate,
size_t window_samples, int64_t max_offset_windows)
{
return estimate_envelope_offset(reference, candidate, std::vector<char>(),
std::vector<char>(), window_samples,
max_offset_windows);
}
AudioWaveformSync::OffsetResult AudioWaveformSync::estimate_envelope_offset(
const std::vector<double> &reference, const std::vector<double> &candidate,
const std::vector<char> &reference_valid,
const std::vector<char> &candidate_valid,
size_t window_samples, int64_t max_offset_windows)
{
OffsetResult result;
if (reference.empty() || candidate.empty() || !window_samples) {
return result;
}
const auto is_valid = [](const std::vector<char> &mask, size_t size,
size_t index) {
return mask.size() != size || mask.at(index);
};
double best_score = -2.0;
int64_t best_lag = 0;
const int reference_size = static_cast<int>(reference.size());
const int candidate_size = static_cast<int>(candidate.size());
for (int64_t lag = -max_offset_windows; lag <= max_offset_windows; lag++) {
const int reference_start =
static_cast<int>(std::max<int64_t>(0, -lag));
const int candidate_start = static_cast<int>(std::max<int64_t>(0, lag));
const int overlap = std::min(reference_size - reference_start,
candidate_size - candidate_start);
if (overlap < 2) {
continue;
}
// Only windows marked valid on both sides participate in the score
double reference_mean = 0.0;
double candidate_mean = 0.0;
int valid_count = 0;
for (int i = 0; i < overlap; i++) {
const int reference_index = reference_start + i;
const int candidate_index = candidate_start + i;
if (!is_valid(reference_valid, reference.size(),
size_t(reference_index)) ||
!is_valid(candidate_valid, candidate.size(),
size_t(candidate_index))) {
continue;
}
reference_mean += reference.at(size_t(reference_index));
candidate_mean += candidate.at(size_t(candidate_index));
valid_count++;
}
if (valid_count < 2) {
continue;
}
reference_mean /= static_cast<double>(valid_count);
candidate_mean /= static_cast<double>(valid_count);
double numerator = 0.0;
double reference_energy = 0.0;
double candidate_energy = 0.0;
for (int i = 0; i < overlap; i++) {
const int reference_index = reference_start + i;
const int candidate_index = candidate_start + i;
if (!is_valid(reference_valid, reference.size(),
size_t(reference_index)) ||
!is_valid(candidate_valid, candidate.size(),
size_t(candidate_index))) {
continue;
}
const double reference_value =
reference.at(size_t(reference_index)) - reference_mean;
const double candidate_value =
candidate.at(size_t(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 (std::abs(reference_energy) < 1e-12 ||
std::abs(candidate_energy) < 1e-12) {
continue;
}
const double score =
numerator / std::sqrt(reference_energy * candidate_energy);
if (score > best_score) {
best_score = score;
best_lag = lag;
}
}
if (best_score > -2.0) {
result.valid = true;
result.confidence = std::max(0.0, best_score);
result.offset_samples = best_lag * static_cast<int64_t>(window_samples);
}
return result;
}
AudioWaveformSync::StretchOffsetResult AudioWaveformSync::estimate_stretch_and_offset(
const std::vector<double> &reference, const std::vector<double> &candidate,
const std::vector<char> &reference_valid,
const std::vector<char> &candidate_valid,
size_t window_samples, int64_t max_offset_windows, double min_rate,
double max_rate, double rate_step)
{
StretchOffsetResult result;
if (reference.empty() || candidate.empty() || !window_samples ||
min_rate <= 0.0 || max_rate < min_rate || rate_step <= 0.0) {
return result;
}
double best_confidence = -2.0;
for (double rate = min_rate; rate <= max_rate + rate_step * 0.5;
rate += rate_step) {
// Resample the candidate envelope so that window i of the resampled
// envelope corresponds to window i*rate of the original
const int resampled_size =
static_cast<int>(candidate.size() / rate);
if (resampled_size < 2) {
continue;
}
const size_t resampled_len = size_t(resampled_size);
std::vector<double> resampled(resampled_len);
std::vector<char> resampled_valid(resampled_len);
for (int i = 0; i < resampled_size; i++) {
const double position = i * rate;
const int lower = static_cast<int>(position);
const int upper =
std::min(lower + 1, static_cast<int>(candidate.size()) - 1);
const double fraction = position - lower;
resampled[size_t(i)] = candidate.at(size_t(lower)) * (1.0 - fraction) +
candidate.at(size_t(upper)) * fraction;
resampled_valid[size_t(i)] =
(candidate_valid.size() != candidate.size() ||
(candidate_valid.at(size_t(lower)) &&
candidate_valid.at(size_t(upper))));
}
const OffsetResult offset = estimate_envelope_offset(
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;
}
}
return result;
}
}
-105
View File
@@ -1,105 +0,0 @@
/***
Oak - 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/>.
***/
#ifndef OAK_AUDIOWAVEFORMSYNC_H
#define OAK_AUDIOWAVEFORMSYNC_H
#include <cstdint>
#include <vector>
#include "olive/core/render/samplebuffer.h"
namespace olive
{
class AudioWaveformSync {
public:
struct OffsetResult {
int64_t offset_samples = 0;
double confidence = 0.0;
bool valid = false;
};
struct StretchOffsetResult {
// Playback rate the candidate must be played at to align with the
// reference (e.g. 2.0 = candidate runs at half speed and needs to be
// sped up 2x)
double rate = 1.0;
int64_t offset_samples = 0;
double confidence = 0.0;
bool valid = false;
};
static std::vector<double>
extract_rms_envelope(const core::SampleBuffer &samples,
size_t window_samples);
static OffsetResult estimate_offset(const core::SampleBuffer &reference,
const core::SampleBuffer &candidate,
size_t window_samples,
int64_t max_offset_samples);
static OffsetResult
estimate_envelope_offset(const std::vector<double> &reference,
const std::vector<double> &candidate,
size_t window_samples,
int64_t max_offset_windows);
/**
* @brief Offset estimation that ignores windows flagged as invalid
*
* @p reference_valid and @p candidate_valid mark which envelope windows
* contain real data (e.g. actually cached waveform regions). Windows
* flagged false on either side are excluded from the correlation instead
* of being treated as silence, which improves accuracy when parts of the
* waveform cache have not been generated yet. Empty masks are treated as
* "all windows valid".
*/
static OffsetResult
estimate_envelope_offset(const std::vector<double> &reference,
const std::vector<double> &candidate,
const std::vector<char> &reference_valid,
const std::vector<char> &candidate_valid,
size_t window_samples,
int64_t max_offset_windows);
/**
* @brief Estimates a playback-rate change plus offset aligning the
* candidate to the reference
*
* The candidate envelope is resampled at each candidate rate in
* [min_rate, max_rate] (step rate_step) and correlated against the
* reference. rate > 1 means the candidate runs slower than the reference
* and must be sped up. The search is O(rates * lags * overlap), so
* callers should bound max_offset_windows to a sensible range.
*/
static StretchOffsetResult
estimate_stretch_and_offset(const std::vector<double> &reference,
const std::vector<double> &candidate,
const std::vector<char> &reference_valid,
const std::vector<char> &candidate_valid,
size_t window_samples,
int64_t max_offset_windows, double min_rate,
double max_rate, double rate_step);
};
}
#endif // OAK_AUDIOWAVEFORMSYNC_H
-54
View File
@@ -1,54 +0,0 @@
/***
Oak - 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/>.
***/
#include "configbridge.h"
#include <vector>
#include "common/config.h"
namespace olive::audio_config
{
int output_buffer_size()
{
// 0 = let PortAudio choose the buffer size (old default)
return oakcommon_config_get_int(nullptr, "AudioOutputBufferSize", 0);
}
std::string device_name(bool is_output_device)
{
const char *key = is_output_device ? "AudioOutput" : "AudioInput";
int size = oakcommon_config_get(nullptr, key, nullptr, 0);
if (size <= 1) {
// Absent (OAKCOMMON_E_NOT_FOUND) or empty
return std::string();
}
const size_t buf_len = size_t(size);
std::vector<char> buf(buf_len);
if (oakcommon_config_get(nullptr, key, buf.data(), size) < 0) {
return std::string();
}
return std::string(buf.data());
}
}
-47
View File
@@ -1,47 +0,0 @@
/***
Oak - 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/>.
***/
#ifndef OAK_AUDIO_CONFIGBRIDGE_H
#define OAK_AUDIO_CONFIGBRIDGE_H
#include <string>
namespace olive::audio_config
{
/**
* @brief Thin wrappers over the oakcommon config C ABI
* (include/common/config.h)
*
* The old engine code read these keys through OAK_CONFIG/OAK_CONFIG_STR;
* oakaudio reaches the same store through oakcommon_config_*. Typed
* getters fall back when the key is absent (the compiled-in defaults do
* not carry the audio device keys).
*/
/** "AudioOutputBufferSize": PortAudio framesPerBuffer (0 = auto). */
int output_buffer_size();
/** "AudioOutput" / "AudioInput": saved device name ("" when unset). */
std::string device_name(bool is_output_device);
}
#endif // OAK_AUDIO_CONFIGBRIDGE_H
-96
View File
@@ -1,96 +0,0 @@
/***
Olive - Non-Linear Video Editor
Copyright (C) 2022 Olive Team
Modifications Copyright (C) 2025 mikesolar
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/>.
***/
#include "previewaudiodevice.h"
#include <algorithm>
#include <cstring>
namespace olive
{
PreviewAudioDevice::PreviewAudioDevice()
: bytes_per_frame_(0)
, notify_interval_(0)
, bytes_read_(0)
{
}
PreviewAudioDevice::~PreviewAudioDevice() = default;
void PreviewAudioDevice::set_params(const core::AudioParams &params)
{
set_bytes_per_frame(params.samples_to_bytes(1));
}
int64_t PreviewAudioDevice::read(char *data, int64_t max_size)
{
bool notify = false;
int64_t copy_length;
{
std::lock_guard<std::mutex> locker(lock_);
copy_length = std::min(max_size, int64_t(buffer_.size()));
if (copy_length) {
int64_t new_bytes_read = bytes_read_ + copy_length;
if (notify_interval_ > 0 && notify_callback_) {
if ((bytes_read_ / notify_interval_) !=
(new_bytes_read / notify_interval_)) {
notify = true;
}
}
bytes_read_ = new_bytes_read;
memcpy(data, buffer_.data(), copy_length);
buffer_.erase(buffer_.begin(), buffer_.begin() + copy_length);
}
}
// Fired outside the lock (see set_notify_callback())
if (notify) {
notify_callback_();
}
return copy_length;
}
int64_t PreviewAudioDevice::write(const char *data, int64_t length)
{
std::lock_guard<std::mutex> locker(lock_);
buffer_.insert(buffer_.end(), data, data + length);
return length;
}
void PreviewAudioDevice::clear()
{
std::lock_guard<std::mutex> locker(lock_);
buffer_.clear();
bytes_read_ = 0;
output_frames_consumed_.store(0);
}
}
-139
View File
@@ -1,139 +0,0 @@
/***
Olive - Non-Linear Video Editor
Copyright (C) 2022 Olive Team
Modifications Copyright (C) 2025 mikesolar
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/>.
***/
#ifndef OAK_PREVIEWAUDIODEVICE_H
#define OAK_PREVIEWAUDIODEVICE_H
#include "olive/core/render/audioparams.h"
#include <atomic>
#include <cstdint>
#include <functional>
#include <mutex>
#include <vector>
namespace olive
{
/**
* @brief Pull-style sample buffer fed to the audio output callback
*
* Formerly a QIODevice subclass consumed by QAudioOutput. Now a plain class:
* the audio backend (PortAudio, see engine/audio AudioManager) pulls samples
* through read() from its stream callback and the render side pushes samples
* through write(). The callback-driven pull semantics are unchanged.
*/
class PreviewAudioDevice {
public:
PreviewAudioDevice();
virtual ~PreviewAudioDevice();
/**
* @brief Read up to `max_size` bytes from the queued buffer
*
* Called from the audio output callback. Returns the number of bytes
* actually copied (0 when the buffer is empty, i.e. underrun).
*/
int64_t read(char *data, int64_t max_size);
/**
* @brief Append `length` bytes to the queued buffer
*/
int64_t write(const char *data, int64_t length);
// Derives the frame size from the audio format (bytes per sample per
// channel * channel count). Until params are set, bytes_per_frame()
// reports 0, i.e. "unknown".
void set_params(const core::AudioParams &params);
int bytes_per_frame() const
{
return bytes_per_frame_;
}
void set_bytes_per_frame(int b)
{
bytes_per_frame_ = b;
}
void set_notify_interval(int64_t i)
{
notify_interval_ = i;
}
/**
* @brief Install the callback fired when a notify interval boundary is crossed
*
* Replaces the former `notify` signal. The callback is invoked from read(),
* i.e. from the audio output callback thread, AFTER the internal lock has
* been released (the Qt version emitted while holding the lock; receivers
* lived on another thread so it was effectively queued). The callback must
* therefore be thread-safe and must not call back into this device.
*/
void set_notify_callback(std::function<void()> callback)
{
std::lock_guard<std::mutex> locker(lock_);
notify_callback_ = std::move(callback);
}
void clear();
/**
* @brief Frames consumed by the audio output callback
*
* Counted in the callback itself so underrun (zero-filled) frames are
* included, making the value usable as a playback clock.
*/
void add_output_frames(int64_t frame_count)
{
output_frames_consumed_.fetch_add(frame_count);
}
int64_t output_frames_consumed() const
{
return output_frames_consumed_.load();
}
void reset_output_frames()
{
output_frames_consumed_.store(0);
}
private:
std::mutex lock_;
std::vector<char> buffer_;
int bytes_per_frame_;
int64_t notify_interval_;
int64_t bytes_read_;
std::function<void()> notify_callback_;
std::atomic<int64_t> output_frames_consumed_{0};
};
}
#endif // OAK_PREVIEWAUDIODEVICE_H
-159
View File
@@ -1,159 +0,0 @@
# 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/>.
# Standalone build driver for the oakaudio module (M6). Mirrors
# src/codec/standalone: oakaudio links oakcodec (encoder for recording,
# decoder for waveform extraction), oakcommon (config + ffmpegutils C
# ABI), olivecore and ffmpeg_bridge, plus PortAudio for the output
# device. oakcodec's own dependency stack (oakrender/oaknode/...) is
# assembled the same way src/codec/standalone does it.
#
# Usage (macOS/Homebrew):
# cmake -S src/audio/standalone -B build-audio
# cmake --build build-audio -j
# ctest --test-dir build-audio
cmake_minimum_required(VERSION 3.16 FATAL_ERROR)
project(oakaudio-standalone LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
get_filename_component(OAK_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../.." ABSOLUTE)
list(APPEND CMAKE_MODULE_PATH "${OAK_REPO_ROOT}/cmake")
if(EXISTS "/opt/homebrew")
list(APPEND CMAKE_PREFIX_PATH "/opt/homebrew")
endif()
find_package(EXPAT REQUIRED)
find_package(OpenColorIO CONFIG REQUIRED)
find_package(OpenImageIO CONFIG REQUIRED)
set(OCIO_LIBRARIES OpenColorIO::OpenColorIO)
set(OCIO_INCLUDE_DIRS "")
set(OIIO_LIBRARIES OpenImageIO::OpenImageIO)
set(OIIO_INCLUDE_DIRS "")
# In-repo libraries, built from source (same set src/codec/standalone
# assembles, because oakaudio links oakcodec):
# - olivecore (core/): oakcore_* C ABI and olive::core C++ wrappers
# - ffmpeg_bridge: fb_* C ABI (resampler infra + codec's FFmpeg access)
# - oakundo / oakcommon / oaknode / oakrender: oakcodec's own dependencies
# - oakcodec: encoder (recording) + decoder (waveform extract) C ABI
set(OLIVECORE_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(CMAKE_DISABLE_FIND_PACKAGE_OpenTimelineIO ON)
add_subdirectory(${OAK_REPO_ROOT}/core ${CMAKE_BINARY_DIR}/core)
target_include_directories(olivecore PUBLIC ${OAK_REPO_ROOT}/third_party/openfx/include)
add_subdirectory(${OAK_REPO_ROOT}/ffmpeg_bridge ${CMAKE_BINARY_DIR}/ffmpeg_bridge)
set(BUILD_TESTS OFF)
add_subdirectory(${OAK_REPO_ROOT}/src/undo ${CMAKE_BINARY_DIR}/undo)
add_subdirectory(${OAK_REPO_ROOT}/src/common ${CMAKE_BINARY_DIR}/common)
add_subdirectory(${OAK_REPO_ROOT}/src/node ${CMAKE_BINARY_DIR}/node)
set(BUILD_TESTS ON)
# oaknode needs its transition stubs when built in this tree (see
# src/render/standalone/CMakeLists.txt).
target_include_directories(oaknode BEFORE PUBLIC
${OAK_REPO_ROOT}/src/render/transition
${OAK_REPO_ROOT}/src/node/transition
${OAK_REPO_ROOT}/src/render/src
)
target_include_directories(oaknode PUBLIC
${OAK_REPO_ROOT}/third_party/openfx/HostSupport/include
/opt/homebrew/include
/opt/homebrew/include/Imath
)
target_link_options(oaknode PRIVATE
"-undefined" "dynamic_lookup"
)
add_subdirectory(${OAK_REPO_ROOT}/src/render/src ${CMAKE_BINARY_DIR}/render)
add_subdirectory(${OAK_REPO_ROOT}/src/render/c_api ${CMAKE_BINARY_DIR}/render_c_api)
# Transition stub dirs must precede everything else: src/render/transition
# first, then src/node/transition (shared stubs).
target_include_directories(oakrender BEFORE PUBLIC
${OAK_REPO_ROOT}/src/render/transition
${OAK_REPO_ROOT}/src/node/transition
)
target_include_directories(oakrender PUBLIC
${OAK_REPO_ROOT}/engine/include
${OAK_REPO_ROOT}/third_party/openfx/HostSupport/include
/opt/homebrew/include
/opt/homebrew/include/Imath
/opt/homebrew/include/OpenEXR
)
# Vulkan headers (Homebrew keg-only vulkan-headers).
if(NOT EXISTS "/opt/homebrew/include/vulkan/vulkan.h")
execute_process(COMMAND brew --prefix vulkan-headers
OUTPUT_VARIABLE VULKAN_HEADERS_PREFIX
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET)
if(VULKAN_HEADERS_PREFIX AND EXISTS "${VULKAN_HEADERS_PREFIX}/include/vulkan/vulkan.h")
target_include_directories(oakrender PUBLIC "${VULKAN_HEADERS_PREFIX}/include")
endif()
endif()
# Symbols of the not-yet-split engine modules dangle by design. The
# backend libraries resolve most symbols from liboakrender at load time
# and dangle the same way.
foreach(t oakrender oakgl oakgl2 oakvulkan)
if(TARGET ${t})
target_link_options(${t} PRIVATE
"-undefined" "dynamic_lookup"
)
endif()
endforeach()
target_link_libraries(oakrender PRIVATE
oaknode
oakcommon
oakundo
olivecore
ffmpeg_bridge
${OCIO_LIBRARIES}
${OIIO_LIBRARIES}
"-framework OpenGL"
"-framework CoreVideo"
"-framework Metal"
"-framework QuartzCore"
)
# oakcodec (oakaudio's only codec access goes through its C ABI).
add_subdirectory(${OAK_REPO_ROOT}/src/codec/src ${CMAKE_BINARY_DIR}/codec)
add_subdirectory(${OAK_REPO_ROOT}/src/codec/c_api ${CMAKE_BINARY_DIR}/codec_c_api)
target_link_options(oakcodec PRIVATE
"-undefined" "dynamic_lookup"
)
# oakaudio itself.
add_subdirectory(${OAK_REPO_ROOT}/src/audio/src ${CMAKE_BINARY_DIR}/audio)
add_subdirectory(${OAK_REPO_ROOT}/src/audio/c_api ${CMAKE_BINARY_DIR}/audio_c_api)
# Tests (oakaudio-gtest).
if(BUILD_TESTS)
enable_testing()
add_subdirectory(${OAK_REPO_ROOT}/src/audio/tests ${CMAKE_BINARY_DIR}/audio_tests)
endif()
add_subdirectory(${OAK_REPO_ROOT}/src/plugin ${CMAKE_BINARY_DIR}/plugin)
-84
View File
@@ -1,84 +0,0 @@
# 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/>.
find_package(GTest REQUIRED)
include(GoogleTest)
# In a full-tree build the repo root is CMAKE_SOURCE_DIR; a standalone
# build (see src/audio/standalone) sets OAK_REPO_ROOT explicitly.
if(NOT DEFINED OAK_REPO_ROOT)
set(OAK_REPO_ROOT ${CMAKE_SOURCE_DIR})
endif()
add_executable(oakaudio-gtest
levelmeter_test.cpp
manager_test.cpp
processor_test.cpp
sync_test.cpp
waveform_test.cpp
)
target_link_libraries(oakaudio-gtest PRIVATE
oakaudio
oakcodec
oakrender
oaknode
oakplugin
oakcommon
oakundo
olivecore
GTest::gtest
GTest::gtest_main
)
# liboakrender/liboaknode dangle OFX host symbols (-undefined
# dynamic_lookup); force-load the host support archive into the test
# process so dyld finds them in the flat namespace at startup. Mirrors
# src/codec/tests/CMakeLists.txt.
if(NOT DEFINED OAKRENDER_OFX_HOST_ARCHIVE)
find_library(OAKRENDER_OFX_HOST_ARCHIVE NAMES OfxHost
PATHS ${OAK_REPO_ROOT}/build/third_party/openfx/HostSupport)
endif()
if(NOT OAKRENDER_OFX_HOST_ARCHIVE)
message(FATAL_ERROR
"libOfxHost.a not found; run the full-tree build once or set "
"OAKRENDER_OFX_HOST_ARCHIVE")
endif()
target_link_options(oakaudio-gtest PRIVATE
"-Wl,-force_load,${OAKRENDER_OFX_HOST_ARCHIVE}")
# liboakrender references the oakengine_ipc_* C ABI (worker IPC) via
# dynamic_lookup; the test binary links the inert shim from
# src/node/standalone instead.
target_sources(oakaudio-gtest PRIVATE
${OAK_REPO_ROOT}/src/node/standalone/oakengine_ipc_shim.cpp)
target_include_directories(oakaudio-gtest PRIVATE
${OAK_REPO_ROOT}/engine/include
)
# include/ must win over the render/node transition dirs that leak in
# through oaknode's PUBLIC includes: they carry codec/*.h stubs that would
# otherwise shadow the real oakcodec public headers. -iquote is searched
# before every -I for quoted includes.
target_compile_options(oakaudio-gtest PRIVATE
"-iquote" "${OAK_REPO_ROOT}/include"
)
# tests/demo.mp4 lives at the repo's shared tests directory.
target_compile_definitions(oakaudio-gtest PRIVATE
OAKAUDIO_TEST_DATA_DIR="${OAK_REPO_ROOT}/tests")
gtest_discover_tests(oakaudio-gtest)
-117
View File
@@ -1,117 +0,0 @@
/***
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/>.
***/
#include <cmath>
#include <vector>
#include <gtest/gtest.h>
#include "audio/levelmeter.h"
TEST(OakAudioLevelMeter, AnalyzeConstantSignal)
{
// Constant 0.5 on both channels: peak = rms = 0.5, dB = 20*log10(0.5)
std::vector<float> ch(1024, 0.5f);
const float *planes[2] = { ch.data(), ch.data() };
oakaudio_channel_stats channels[2];
oakaudio_meter_stats summary;
ASSERT_EQ(oakaudio_levelmeter_analyze(planes, 2, 1024, channels, 2,
&summary),
OAKAUDIO_OK);
for (int c = 0; c < 2; c++) {
EXPECT_DOUBLE_EQ(channels[c].peak_linear, 0.5);
EXPECT_DOUBLE_EQ(channels[c].rms_linear, 0.5);
EXPECT_NEAR(channels[c].peak_db, 20.0 * std::log10(0.5), 1e-9);
EXPECT_NEAR(channels[c].rms_db, 20.0 * std::log10(0.5), 1e-9);
EXPECT_DOUBLE_EQ(channels[c].vu_db, channels[c].rms_db);
}
EXPECT_DOUBLE_EQ(summary.max_peak_linear, 0.5);
EXPECT_EQ(summary.silence, 0);
// LUFS = -0.691 + 10*log10(mean square) = -0.691 + 10*log10(0.25)
EXPECT_NEAR(summary.integrated_lufs, -0.691 + 10.0 * std::log10(0.25),
1e-9);
}
TEST(OakAudioLevelMeter, AnalyzeSilence)
{
std::vector<float> ch(512, 0.0f);
const float *planes[1] = { ch.data() };
oakaudio_meter_stats summary;
ASSERT_EQ(oakaudio_levelmeter_analyze(planes, 1, 512, nullptr, 0,
&summary),
OAKAUDIO_OK);
EXPECT_EQ(summary.silence, 1);
EXPECT_DOUBLE_EQ(summary.max_peak_linear, 0.0);
EXPECT_DOUBLE_EQ(summary.integrated_lufs, -200.0);
}
TEST(OakAudioLevelMeter, AnalyzePeakPerChannel)
{
std::vector<float> quiet(256, 0.1f);
std::vector<float> loud(256, 0.0f);
loud[7] = -0.8f; // single peak
const float *planes[2] = { quiet.data(), loud.data() };
oakaudio_channel_stats channels[2];
ASSERT_EQ(oakaudio_levelmeter_analyze(planes, 2, 256, channels, 2,
nullptr),
OAKAUDIO_OK);
EXPECT_NEAR(channels[0].peak_linear, 0.1, 1e-6);
EXPECT_NEAR(channels[1].peak_linear, 0.8, 1e-6);
// dB floor: the zero samples dominate, but the peak channel has signal
EXPECT_GT(channels[1].peak_db, channels[0].peak_db);
}
TEST(OakAudioLevelMeter, AnalyzeErrorPaths)
{
std::vector<float> ch(64, 0.5f);
const float *planes[1] = { ch.data() };
oakaudio_channel_stats channels[1];
oakaudio_meter_stats summary;
// NULL planes
EXPECT_EQ(oakaudio_levelmeter_analyze(nullptr, 1, 64, channels, 1,
&summary),
OAKAUDIO_E_INVALID);
// Zero channels
EXPECT_EQ(oakaudio_levelmeter_analyze(planes, 0, 64, channels, 1,
&summary),
OAKAUDIO_E_INVALID);
// Negative frame count
EXPECT_EQ(oakaudio_levelmeter_analyze(planes, 1, -1, channels, 1,
&summary),
OAKAUDIO_E_INVALID);
// Insufficient channel capacity
EXPECT_EQ(oakaudio_levelmeter_analyze(planes, 1, 64, channels, 0,
&summary),
OAKAUDIO_E_INVALID);
// Both outs NULL
EXPECT_EQ(oakaudio_levelmeter_analyze(planes, 1, 64, nullptr, 0, nullptr),
OAKAUDIO_E_INVALID);
// NULL plane inside array
const float *bad_planes[1] = { nullptr };
EXPECT_EQ(oakaudio_levelmeter_analyze(bad_planes, 1, 64, channels, 1,
&summary),
OAKAUDIO_E_INVALID);
}
-207
View File
@@ -1,207 +0,0 @@
/***
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/>.
***/
#include <cstring>
#include <vector>
#include <gtest/gtest.h>
#include "audio/manager.h"
namespace
{
constexpr int kSampleFmtF32 = 10; // olive::core::SampleFormat::f32
constexpr uint64_t kLayoutStereo = 0x3;
// Creates the singleton for the duration of the test; skips when no
// audio device environment is available.
struct ManagerFixture {
ManagerFixture()
{
created = (oakaudio_manager_create_instance() == OAKAUDIO_OK) &&
oakaudio_manager_instance().ctx != nullptr;
}
~ManagerFixture()
{
if (created) {
oakaudio_manager_destroy_instance();
}
}
bool created = false;
};
} // namespace
TEST(OakAudioManager, InstanceLifecycle)
{
// Without an instance all calls report E_STATE and instance() is empty
OakAudioManager none = oakaudio_manager_instance();
EXPECT_EQ(none.ctx, nullptr);
EXPECT_EQ(none.abi_version, OAKAUDIO_ABI_VERSION);
double secs;
EXPECT_EQ(oakaudio_manager_seconds(none, &secs), OAKAUDIO_E_STATE);
EXPECT_EQ(oakaudio_manager_get_output_device(none), OAKAUDIO_E_STATE);
EXPECT_EQ(oakaudio_manager_get_input_device(none), OAKAUDIO_E_STATE);
EXPECT_EQ(oakaudio_manager_set_output_device(none, 0), OAKAUDIO_E_STATE);
EXPECT_EQ(oakaudio_manager_set_input_device(none, 0), OAKAUDIO_E_STATE);
EXPECT_EQ(oakaudio_manager_set_output_notify_interval(none, 1024),
OAKAUDIO_E_STATE);
EXPECT_EQ(oakaudio_manager_clear_buffered_output(none), OAKAUDIO_E_STATE);
EXPECT_EQ(oakaudio_manager_stop_output(none), OAKAUDIO_E_STATE);
EXPECT_EQ(oakaudio_manager_reset_output_clock(none), OAKAUDIO_E_STATE);
EXPECT_EQ(oakaudio_manager_hard_reset(none), OAKAUDIO_E_STATE);
EXPECT_EQ(oakaudio_manager_stop_recording(none), OAKAUDIO_E_STATE);
float samples[2] = { 0.0f, 0.0f };
EXPECT_EQ(oakaudio_manager_push_to_output(none, 48000, kLayoutStereo,
kSampleFmtF32,
reinterpret_cast<char *>(samples),
sizeof(samples), nullptr, 0),
OAKAUDIO_E_STATE);
oakcodec_encoding_params params;
std::memset(&params, 0, sizeof(params));
params.audio_enabled = 1;
EXPECT_EQ(oakaudio_manager_start_recording(none, &params, nullptr, 0),
OAKAUDIO_E_STATE);
// free is a no-op and safe on NULL/empty
oakaudio_manager_free(nullptr);
oakaudio_manager_free(&none);
EXPECT_EQ(none.ctx, nullptr);
}
TEST(OakAudioManager, DeviceRoundTrip)
{
ManagerFixture fx;
if (!fx.created) {
GTEST_SKIP() << "no PortAudio device environment";
}
OakAudioManager m = oakaudio_manager_instance();
ASSERT_NE(m.ctx, nullptr);
// Singleton: addref/release never destroy
m.addref(m.ctx);
m.release(m.ctx);
EXPECT_EQ(oakaudio_manager_instance().ctx, m.ctx);
// Devices: whatever was detected, get/set round-trips
const int out_device = oakaudio_manager_get_output_device(m);
EXPECT_EQ(oakaudio_manager_set_output_device(m, out_device), OAKAUDIO_OK);
EXPECT_EQ(oakaudio_manager_get_output_device(m), out_device);
const int in_device = oakaudio_manager_get_input_device(m);
EXPECT_EQ(oakaudio_manager_set_input_device(m, in_device), OAKAUDIO_OK);
EXPECT_EQ(oakaudio_manager_get_input_device(m), in_device);
// Notify interval set/get-free command
EXPECT_EQ(oakaudio_manager_set_output_notify_interval(m, 4096),
OAKAUDIO_OK);
EXPECT_EQ(oakaudio_manager_set_output_notify_interval(m, -1),
OAKAUDIO_E_INVALID);
// No stream running: seconds() is negative, clock commands are valid
double secs = 1.0;
EXPECT_EQ(oakaudio_manager_seconds(m, &secs), OAKAUDIO_OK);
EXPECT_LT(secs, 0.0);
EXPECT_EQ(oakaudio_manager_seconds(m, nullptr), OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_manager_reset_output_clock(m), OAKAUDIO_OK);
EXPECT_EQ(oakaudio_manager_clear_buffered_output(m), OAKAUDIO_OK);
EXPECT_EQ(oakaudio_manager_stop_output(m), OAKAUDIO_OK);
EXPECT_EQ(oakaudio_manager_hard_reset(m), OAKAUDIO_OK);
EXPECT_EQ(oakaudio_manager_stop_recording(m), OAKAUDIO_OK);
}
TEST(OakAudioManager, PushToOutput)
{
ManagerFixture fx;
if (!fx.created) {
GTEST_SKIP() << "no PortAudio device environment";
}
OakAudioManager m = oakaudio_manager_instance();
if (oakaudio_manager_get_output_device(m) < 0) {
GTEST_SKIP() << "no output device";
}
// One second of silence, packed f32 stereo
std::vector<float> silence(48000 * 2, 0.0f);
char error[256];
EXPECT_EQ(oakaudio_manager_push_to_output(
m, 48000, kLayoutStereo, kSampleFmtF32,
reinterpret_cast<char *>(silence.data()),
int64_t(silence.size() * sizeof(float)), error,
int(sizeof(error))),
OAKAUDIO_OK);
// Error paths
EXPECT_EQ(oakaudio_manager_push_to_output(
m, 0, kLayoutStereo, kSampleFmtF32,
reinterpret_cast<char *>(silence.data()), 16, nullptr, 0),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_manager_push_to_output(m, 48000, kLayoutStereo,
kSampleFmtF32, nullptr, 16,
nullptr, 0),
OAKAUDIO_E_INVALID);
oakaudio_manager_stop_output(m);
}
TEST(OakAudioManager, StartRecordingErrorPaths)
{
ManagerFixture fx;
if (!fx.created) {
GTEST_SKIP() << "no PortAudio device environment";
}
OakAudioManager m = oakaudio_manager_instance();
// NULL params / audio disabled
EXPECT_EQ(oakaudio_manager_start_recording(m, nullptr, nullptr, 0),
OAKAUDIO_E_INVALID);
oakcodec_encoding_params params;
std::memset(&params, 0, sizeof(params));
EXPECT_EQ(oakaudio_manager_start_recording(m, &params, nullptr, 0),
OAKAUDIO_E_INVALID);
}
TEST(OakAudioManager, FindDeviceByName)
{
ManagerFixture fx;
if (!fx.created) {
GTEST_SKIP() << "no PortAudio device environment";
}
// A name that matches nothing falls back to the default device (or
// paNoDevice on device-less systems); either way no crash and a valid
// index or -1.
const int out = oakaudio_manager_find_device_by_name_s(
"definitely-not-a-real-device-name-oakaudio-test", 1);
EXPECT_GE(out, -1);
const int cfg = oakaudio_manager_find_config_device_by_name_s(1);
EXPECT_GE(cfg, -1);
// Error path: NULL name
EXPECT_EQ(oakaudio_manager_find_device_by_name_s(nullptr, 1),
OAKAUDIO_E_INVALID);
}
-254
View File
@@ -1,254 +0,0 @@
/***
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/>.
***/
#include <cmath>
#include <vector>
#include <gtest/gtest.h>
#include "audio/manager.h"
#include "audio/processor.h"
namespace
{
constexpr int kSampleFmtF32P = 4; // olive::core::SampleFormat::f32_p
constexpr uint64_t kLayoutStereo = 0x3;
struct ProcessorHandle {
OakAudioProcessor h = oakaudio_processor_init();
~ProcessorHandle() { oakaudio_processor_free(&h); }
};
// Feed a full buffer through the processor and return the total number of
// output frames produced (input drained + flushed).
int convert_all(OakAudioProcessor p, const std::vector<std::vector<float>> &in,
int chunk)
{
const int channels = int(in.size());
const size_t nch = size_t(channels);
std::vector<const float *> in_planes(nch);
std::vector<std::vector<float>> out_store(nch);
std::vector<float *> out_planes(nch);
for (int ch = 0; ch < channels; ch++) {
in_planes[size_t(ch)] = in[size_t(ch)].data();
out_store[size_t(ch)].resize(size_t(chunk) * 4 + 4096);
out_planes[size_t(ch)] = out_store[size_t(ch)].data();
}
int total = 0;
const int frames = int(in[0].size());
for (int pos = 0; pos < frames; pos += chunk) {
const int n = std::min(chunk, frames - pos);
std::vector<const float *> window(nch);
for (int ch = 0; ch < channels; ch++) {
window[size_t(ch)] = in[size_t(ch)].data() + pos;
}
const int produced = oakaudio_processor_convert(
p, window.data(), n, out_planes.data(), int(out_store[0].size()));
if (produced < 0) {
return produced;
}
total += produced;
}
EXPECT_EQ(oakaudio_processor_flush(p), OAKAUDIO_OK);
// Drain the resampler's internal delay
for (int guard = 0; guard < 64; guard++) {
const int produced = oakaudio_processor_convert(
p, nullptr, 0, out_planes.data(), int(out_store[0].size()));
if (produced <= 0) {
break;
}
total += produced;
}
return total;
}
std::vector<std::vector<float>> make_sine(int channels, int frames, int rate)
{
const size_t nch = size_t(channels);
std::vector<std::vector<float>> data(nch);
for (int ch = 0; ch < channels; ch++) {
data[size_t(ch)].resize(size_t(frames));
for (int i = 0; i < frames; i++) {
data[size_t(ch)][size_t(i)] =
0.5f * std::sin(2.0 * M_PI * 440.0 * i / rate);
}
}
return data;
}
} // namespace
TEST(OakAudioProcessor, InitFree)
{
const int before = oakaudio_debug_alive_count();
{
ProcessorHandle p;
ASSERT_NE(p.h.ctx, nullptr);
EXPECT_EQ(oakaudio_debug_alive_count(), before + 1);
}
EXPECT_EQ(oakaudio_debug_alive_count(), before);
// free is a no-op on NULL / empty handles
oakaudio_processor_free(nullptr);
OakAudioProcessor empty = {};
oakaudio_processor_free(&empty);
}
TEST(OakAudioProcessor, OpenCloseIsOpen)
{
ProcessorHandle p;
EXPECT_EQ(oakaudio_processor_is_open(p.h), 0);
EXPECT_EQ(oakaudio_processor_open(p.h, 44100, kLayoutStereo, kSampleFmtF32P,
48000, kLayoutStereo, kSampleFmtF32P, 1.0),
OAKAUDIO_OK);
EXPECT_EQ(oakaudio_processor_is_open(p.h), 1);
// Error path: opening an open processor
EXPECT_EQ(oakaudio_processor_open(p.h, 44100, kLayoutStereo, kSampleFmtF32P,
48000, kLayoutStereo, kSampleFmtF32P, 1.0),
OAKAUDIO_E_STATE);
EXPECT_EQ(oakaudio_processor_close(p.h), OAKAUDIO_OK);
EXPECT_EQ(oakaudio_processor_is_open(p.h), 0);
}
TEST(OakAudioProcessor, OpenInvalidArgs)
{
ProcessorHandle p;
// Unsupported output format (only f32p is delivered)
EXPECT_EQ(oakaudio_processor_open(p.h, 44100, kLayoutStereo, kSampleFmtF32P,
48000, kLayoutStereo, 1, 1.0),
OAKAUDIO_E_INVALID);
// Bad sample rate
EXPECT_EQ(oakaudio_processor_open(p.h, 0, kLayoutStereo, kSampleFmtF32P,
48000, kLayoutStereo, kSampleFmtF32P, 1.0),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_processor_is_open(p.h), 0);
// Empty handle
OakAudioProcessor empty = {};
EXPECT_EQ(oakaudio_processor_open(empty, 44100, kLayoutStereo,
kSampleFmtF32P, 48000, kLayoutStereo,
kSampleFmtF32P, 1.0),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_processor_is_open(empty), OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_processor_close(empty), OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_processor_flush(empty), OAKAUDIO_E_INVALID);
}
TEST(OakAudioProcessor, ConvertResample441To48)
{
const int before = oakaudio_debug_alive_count();
ProcessorHandle p;
ASSERT_EQ(oakaudio_processor_open(p.h, 44100, kLayoutStereo,
kSampleFmtF32P, 48000, kLayoutStereo,
kSampleFmtF32P, 1.0),
OAKAUDIO_OK);
const int in_frames = 44100; // one second
const auto sine = make_sine(2, in_frames, 44100);
const int produced = convert_all(p.h, sine, 4096);
ASSERT_GE(produced, 0);
// One second at 44.1k must become (within resampler tolerance) one
// second at 48k.
EXPECT_NEAR(produced, 48000, 200);
// Re-open check for leaks
oakaudio_processor_close(p.h);
oakaudio_processor_free(&p.h);
EXPECT_EQ(oakaudio_debug_alive_count(), before);
}
TEST(OakAudioProcessor, ConvertSilenceStaysSilent)
{
ProcessorHandle p;
ASSERT_EQ(oakaudio_processor_open(p.h, 48000, kLayoutStereo,
kSampleFmtF32P, 48000, kLayoutStereo,
kSampleFmtF32P, 1.0),
OAKAUDIO_OK);
std::vector<std::vector<float>> silence(2, std::vector<float>(4096, 0.0f));
std::vector<std::vector<float>> out(2, std::vector<float>(8192, -1.0f));
std::vector<const float *> in_planes = { silence[0].data(),
silence[1].data() };
std::vector<float *> out_planes = { out[0].data(), out[1].data() };
const int produced = oakaudio_processor_convert(
p.h, in_planes.data(), 4096, out_planes.data(), 8192);
ASSERT_GT(produced, 0);
EXPECT_EQ(produced, 4096); // same rate in/out: 1:1 frames
for (int i = 0; i < produced; i++) {
EXPECT_FLOAT_EQ(out[0][size_t(i)], 0.0f);
EXPECT_FLOAT_EQ(out[1][size_t(i)], 0.0f);
}
}
TEST(OakAudioProcessor, ConvertTempo)
{
ProcessorHandle p;
ASSERT_EQ(oakaudio_processor_open(p.h, 48000, kLayoutStereo,
kSampleFmtF32P, 48000, kLayoutStereo,
kSampleFmtF32P, 1.5),
OAKAUDIO_OK);
const auto sine = make_sine(2, 48000, 48000);
const int produced = convert_all(p.h, sine, 4096);
ASSERT_GE(produced, 0);
// 1.5x tempo: one second of input becomes roughly 2/3 second of
// output (atempo works on correlated windows, so allow slack)
EXPECT_NEAR(produced, int(48000 / 1.5), 3000);
}
TEST(OakAudioProcessor, ConvertErrorPaths)
{
ProcessorHandle p;
// Convert on a closed processor
float dummy = 0.0f;
float *out_planes[1] = { &dummy };
const float *in_planes[1] = { &dummy };
EXPECT_EQ(oakaudio_processor_convert(p.h, in_planes, 1, out_planes, 1),
OAKAUDIO_E_STATE);
EXPECT_EQ(oakaudio_processor_flush(p.h), OAKAUDIO_E_STATE);
// Empty handle
OakAudioProcessor empty = {};
EXPECT_EQ(oakaudio_processor_convert(empty, in_planes, 1, out_planes, 1),
OAKAUDIO_E_INVALID);
// NULL input planes with frames
ASSERT_EQ(oakaudio_processor_open(p.h, 48000, kLayoutStereo,
kSampleFmtF32P, 48000, kLayoutStereo,
kSampleFmtF32P, 1.0),
OAKAUDIO_OK);
EXPECT_EQ(oakaudio_processor_convert(p.h, nullptr, 10, out_planes, 1),
OAKAUDIO_E_INVALID);
// Negative counts
EXPECT_EQ(oakaudio_processor_convert(p.h, in_planes, -1, out_planes, 1),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_processor_flush(p.h), OAKAUDIO_OK);
}
-258
View File
@@ -1,258 +0,0 @@
/***
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/>.
***/
#include <cmath>
#include <vector>
#include <gtest/gtest.h>
#include "audio/sync.h"
namespace
{
// 8-window synthetic envelope with a distinctive shape
std::vector<double> make_envelope()
{
return { 0.1, 0.5, 0.9, 0.3, 0.2, 0.8, 0.4, 0.1 };
}
} // namespace
TEST(OakAudioSync, ExtractRmsEnvelope)
{
// Mono, window = 4 frames: window 0 constant 0.5 -> RMS 0.5
std::vector<float> ch(8, 0.0f);
for (int i = 0; i < 4; i++)
ch[size_t(i)] = 0.5f;
const float *planes[1] = { ch.data() };
// Query mode
const int windows =
oakaudio_sync_extract_rms_envelope(planes, 1, 8, 4, nullptr, 0);
ASSERT_EQ(windows, 2);
double envelope[2];
ASSERT_EQ(oakaudio_sync_extract_rms_envelope(planes, 1, 8, 4, envelope, 2),
2);
EXPECT_DOUBLE_EQ(envelope[0], 0.5);
EXPECT_DOUBLE_EQ(envelope[1], 0.0);
}
TEST(OakAudioSync, ExtractRmsEnvelopeErrorPaths)
{
float v = 0.0f;
const float *planes[1] = { &v };
double out[1];
EXPECT_EQ(oakaudio_sync_extract_rms_envelope(nullptr, 1, 8, 4, out, 1),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_sync_extract_rms_envelope(planes, 0, 8, 4, out, 1),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_sync_extract_rms_envelope(planes, 1, 8, 0, out, 1),
OAKAUDIO_E_INVALID);
}
TEST(OakAudioSync, EstimateEnvelopeOffset)
{
const std::vector<double> ref = make_envelope();
// Candidate = reference shifted right by 2 windows
std::vector<double> cand = { 0.0, 0.0 };
cand.insert(cand.end(), ref.begin(), ref.end());
oakaudio_offset_result out;
ASSERT_EQ(oakaudio_sync_estimate_envelope_offset(
ref.data(), int(ref.size()), cand.data(), int(cand.size()),
nullptr, nullptr, 100, 8, &out),
OAKAUDIO_OK);
EXPECT_EQ(out.valid, 1);
EXPECT_EQ(out.offset_samples, 2 * 100);
EXPECT_GT(out.confidence, 0.9);
}
TEST(OakAudioSync, EstimateEnvelopeOffsetWithMasks)
{
const std::vector<double> ref = make_envelope();
std::vector<double> cand = { 0.0, 0.0 };
cand.insert(cand.end(), ref.begin(), ref.end());
std::vector<uint8_t> all_valid_ref(ref.size(), 1);
std::vector<uint8_t> all_valid_cand(cand.size(), 1);
oakaudio_offset_result out;
ASSERT_EQ(oakaudio_sync_estimate_envelope_offset(
ref.data(), int(ref.size()), cand.data(), int(cand.size()),
all_valid_ref.data(), all_valid_cand.data(), 100, 8, &out),
OAKAUDIO_OK);
EXPECT_EQ(out.valid, 1);
EXPECT_EQ(out.offset_samples, 200);
}
TEST(OakAudioSync, EstimateEnvelopeOffsetErrorPaths)
{
double env[4] = { 0.1, 0.2, 0.3, 0.4 };
oakaudio_offset_result out;
EXPECT_EQ(oakaudio_sync_estimate_envelope_offset(nullptr, 4, env, 4,
nullptr, nullptr, 100, 4,
&out),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_sync_estimate_envelope_offset(env, 0, env, 4, nullptr,
nullptr, 100, 4, &out),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_sync_estimate_envelope_offset(env, 4, env, 4, nullptr,
nullptr, 0, 4, &out),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_sync_estimate_envelope_offset(env, 4, env, 4, nullptr,
nullptr, 100, 4, nullptr),
OAKAUDIO_E_INVALID);
}
TEST(OakAudioSync, EstimateStretchAndOffset)
{
const std::vector<double> ref = make_envelope();
// Candidate runs at half speed: each ref window duplicated
std::vector<double> cand;
for (double v : ref) {
cand.push_back(v);
cand.push_back(v);
}
oakaudio_stretch_offset_result out;
ASSERT_EQ(oakaudio_sync_estimate_stretch_and_offset(
ref.data(), int(ref.size()), cand.data(), int(cand.size()),
nullptr, nullptr, 100, 4, 1.0, 2.0, 0.25, &out),
OAKAUDIO_OK);
EXPECT_EQ(out.valid, 1);
EXPECT_NEAR(out.rate, 2.0, 0.13);
EXPECT_GT(out.confidence, 0.9);
}
TEST(OakAudioSync, EstimateStretchAndOffsetErrorPaths)
{
double env[4] = { 0.1, 0.2, 0.3, 0.4 };
oakaudio_stretch_offset_result out;
EXPECT_EQ(oakaudio_sync_estimate_stretch_and_offset(
nullptr, 4, env, 4, nullptr, nullptr, 100, 4, 1.0, 2.0, 0.5,
&out),
OAKAUDIO_E_INVALID);
// min_rate <= 0
EXPECT_EQ(oakaudio_sync_estimate_stretch_and_offset(
env, 4, env, 4, nullptr, nullptr, 100, 4, 0.0, 2.0, 0.5, &out),
OAKAUDIO_E_INVALID);
// max < min
EXPECT_EQ(oakaudio_sync_estimate_stretch_and_offset(
env, 4, env, 4, nullptr, nullptr, 100, 4, 2.0, 1.0, 0.5, &out),
OAKAUDIO_E_INVALID);
// NULL out
EXPECT_EQ(oakaudio_sync_estimate_stretch_and_offset(
env, 4, env, 4, nullptr, nullptr, 100, 4, 1.0, 2.0, 0.5,
nullptr),
OAKAUDIO_E_INVALID);
}
TEST(OakAudioSync, PlaceBySourceTime)
{
oakaudio_source_clip ref = {};
ref.source_start_time_num = 10; // source clock at 10s
ref.source_start_time_den = 1;
ref.media_in_num = 2; // clip head is 2s into the media
ref.media_in_den = 1;
ref.has_source_start_time = 1;
oakaudio_source_clip cand = {};
cand.source_start_time_num = 14; // 4s later on the same source clock
cand.source_start_time_den = 1;
cand.media_in_num = 0;
cand.media_in_den = 1;
cand.has_source_start_time = 1;
int64_t num, den;
int valid;
ASSERT_EQ(oakaudio_sync_place_by_source_time(&ref, &cand, 5, 1, &num, &den,
&valid),
OAKAUDIO_OK);
EXPECT_EQ(valid, 1);
// candidate head source = 14+0, reference head source = 10+2 = 12;
// timeline_in = 5 + 14 - 12 = 7
EXPECT_EQ(num, 7);
EXPECT_EQ(den, 1);
// Missing source start time -> invalid placement, still OAKAUDIO_OK
cand.has_source_start_time = 0;
ASSERT_EQ(oakaudio_sync_place_by_source_time(&ref, &cand, 5, 1, &num, &den,
&valid),
OAKAUDIO_OK);
EXPECT_EQ(valid, 0);
}
TEST(OakAudioSync, PlaceBySourceTimeErrorPaths)
{
oakaudio_source_clip clip = {};
clip.source_start_time_den = 1;
clip.media_in_den = 1;
clip.has_source_start_time = 1;
int64_t num, den;
int valid;
EXPECT_EQ(oakaudio_sync_place_by_source_time(nullptr, &clip, 5, 1, &num,
&den, &valid),
OAKAUDIO_E_INVALID);
// Zero denominators
oakaudio_source_clip bad = clip;
bad.media_in_den = 0;
EXPECT_EQ(oakaudio_sync_place_by_source_time(&clip, &bad, 5, 1, &num, &den,
&valid),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_sync_place_by_source_time(&clip, &clip, 5, 0, &num, &den,
&valid),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_sync_place_by_source_time(&clip, &clip, 5, 1, nullptr,
&den, &valid),
OAKAUDIO_E_INVALID);
}
TEST(OakAudioSync, PlaceByWaveformOffset)
{
int64_t num, den;
int valid;
// Reference at 5s, candidate is 24000 samples late at 48k -> 5.5s
ASSERT_EQ(oakaudio_sync_place_by_waveform_offset(5, 1, 24000, 48000, &num,
&den, &valid),
OAKAUDIO_OK);
EXPECT_EQ(valid, 1);
EXPECT_NEAR(double(num) / double(den), 5.5, 1e-6);
// Bad sample rate -> invalid placement
ASSERT_EQ(oakaudio_sync_place_by_waveform_offset(5, 1, 24000, 0, &num, &den,
&valid),
OAKAUDIO_OK);
EXPECT_EQ(valid, 0);
// Error path: NULL outs / zero denominator
EXPECT_EQ(oakaudio_sync_place_by_waveform_offset(5, 0, 24000, 48000, &num,
&den, &valid),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_sync_place_by_waveform_offset(5, 1, 24000, 48000,
nullptr, &den, &valid),
OAKAUDIO_E_INVALID);
}
-353
View File
@@ -1,353 +0,0 @@
/***
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/>.
***/
#include <cmath>
#include <string>
#include <vector>
#include <gtest/gtest.h>
#include "audio/levelmeter.h"
#include "audio/manager.h"
#include "audio/waveform.h"
#include "codec/decoder.h"
namespace
{
struct WaveformHandle {
OakAudioWaveform h = oakaudio_waveform_init();
~WaveformHandle() { oakaudio_waveform_free(&h); }
};
std::string demo_file()
{
return std::string(OAKAUDIO_TEST_DATA_DIR) + "/demo.mp4";
}
} // namespace
TEST(OakAudioWaveform, InitFree)
{
const int before = oakaudio_debug_alive_count();
{
WaveformHandle w;
ASSERT_NE(w.h.ctx, nullptr);
EXPECT_EQ(oakaudio_debug_alive_count(), before + 1);
EXPECT_EQ(oakaudio_waveform_get_channel_count(w.h), 0);
}
EXPECT_EQ(oakaudio_debug_alive_count(), before);
oakaudio_waveform_free(nullptr);
OakAudioWaveform empty = {};
oakaudio_waveform_free(&empty);
}
TEST(OakAudioWaveform, ChannelCountAndLength)
{
WaveformHandle w;
EXPECT_EQ(oakaudio_waveform_set_channel_count(w.h, 2), OAKAUDIO_OK);
EXPECT_EQ(oakaudio_waveform_get_channel_count(w.h), 2);
int64_t num = -1, den = -1;
EXPECT_EQ(oakaudio_waveform_length(w.h, &num, &den), OAKAUDIO_OK);
EXPECT_EQ(num, 0);
// Error paths
OakAudioWaveform empty = {};
EXPECT_EQ(oakaudio_waveform_get_channel_count(empty), OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_set_channel_count(empty, 2), OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_set_channel_count(w.h, -1), OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_length(empty, &num, &den), OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_length(w.h, nullptr, &den), OAKAUDIO_E_INVALID);
}
TEST(OakAudioWaveform, OverwriteSamplesAndSummary)
{
WaveformHandle w;
ASSERT_EQ(oakaudio_waveform_set_channel_count(w.h, 1), OAKAUDIO_OK);
// One second at 48k: first half +0.5, second half -0.5
std::vector<float> data(48000);
for (int i = 0; i < 48000; i++) {
data[size_t(i)] = (i < 24000) ? 0.5f : -0.5f;
}
const float *planes[1] = { data.data() };
EXPECT_EQ(oakaudio_waveform_overwrite_samples(w.h, planes, 48000, 48000, 0,
1),
OAKAUDIO_OK);
int64_t num, den;
ASSERT_EQ(oakaudio_waveform_length(w.h, &num, &den), OAKAUDIO_OK);
EXPECT_NEAR(double(num) / double(den), 1.0, 1e-9);
// Summary of the whole second: min -0.5, max +0.5
oakaudio_min_max pairs[2];
const int points =
oakaudio_waveform_get_summary(w.h, 0, 1, 1, 1, pairs, 2);
ASSERT_EQ(points, 1);
EXPECT_FLOAT_EQ(pairs[0].min, -0.5f);
EXPECT_FLOAT_EQ(pairs[0].max, 0.5f);
// Summary of the first half only: all +0.5
const int first_half =
oakaudio_waveform_get_summary(w.h, 0, 1, 1, 2, pairs, 2);
ASSERT_EQ(first_half, 1);
EXPECT_FLOAT_EQ(pairs[0].min, 0.5f);
EXPECT_FLOAT_EQ(pairs[0].max, 0.5f);
// Query mode: NULL out returns the point count
EXPECT_EQ(oakaudio_waveform_get_summary(w.h, 0, 1, 1, 1, nullptr, 0), 1);
}
TEST(OakAudioWaveform, OverwriteSamplesErrorPaths)
{
WaveformHandle w;
float v = 0.0f;
const float *planes[1] = { &v };
// Channel count not set
EXPECT_EQ(oakaudio_waveform_overwrite_samples(w.h, planes, 16, 48000, 0, 1),
OAKAUDIO_E_STATE);
ASSERT_EQ(oakaudio_waveform_set_channel_count(w.h, 1), OAKAUDIO_OK);
// Zero denominator
EXPECT_EQ(oakaudio_waveform_overwrite_samples(w.h, planes, 16, 48000, 0, 0),
OAKAUDIO_E_INVALID);
// NULL planes / bad counts
EXPECT_EQ(oakaudio_waveform_overwrite_samples(w.h, nullptr, 16, 48000, 0, 1),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_overwrite_samples(w.h, planes, 0, 48000, 0, 1),
OAKAUDIO_E_INVALID);
OakAudioWaveform empty = {};
EXPECT_EQ(oakaudio_waveform_overwrite_samples(empty, planes, 16, 48000, 0,
1),
OAKAUDIO_E_INVALID);
}
TEST(OakAudioWaveform, OverwriteSilenceAndTrim)
{
WaveformHandle w;
ASSERT_EQ(oakaudio_waveform_set_channel_count(w.h, 1), OAKAUDIO_OK);
// 2 seconds of silence
EXPECT_EQ(oakaudio_waveform_overwrite_silence(w.h, 0, 1, 2, 1), OAKAUDIO_OK);
int64_t num, den;
ASSERT_EQ(oakaudio_waveform_length(w.h, &num, &den), OAKAUDIO_OK);
EXPECT_NEAR(double(num) / double(den), 2.0, 1e-9);
oakaudio_min_max pairs[1];
ASSERT_EQ(oakaudio_waveform_get_summary(w.h, 0, 1, 2, 1, pairs, 1), 1);
EXPECT_FLOAT_EQ(pairs[0].min, 0.0f);
EXPECT_FLOAT_EQ(pairs[0].max, 0.0f);
// Trim away the first second
EXPECT_EQ(oakaudio_waveform_trim_in(w.h, 1, 1), OAKAUDIO_OK);
ASSERT_EQ(oakaudio_waveform_length(w.h, &num, &den), OAKAUDIO_OK);
EXPECT_NEAR(double(num) / double(den), 1.0, 1e-9);
// Resize to half a second
EXPECT_EQ(oakaudio_waveform_resize(w.h, 1, 2), OAKAUDIO_OK);
ASSERT_EQ(oakaudio_waveform_length(w.h, &num, &den), OAKAUDIO_OK);
EXPECT_NEAR(double(num) / double(den), 0.5, 1e-9);
// trim_range: in 0, length 1s
EXPECT_EQ(oakaudio_waveform_trim_range(w.h, 0, 1, 1, 1), OAKAUDIO_OK);
ASSERT_EQ(oakaudio_waveform_length(w.h, &num, &den), OAKAUDIO_OK);
EXPECT_NEAR(double(num) / double(den), 1.0, 1e-9);
// Error paths
EXPECT_EQ(oakaudio_waveform_overwrite_silence(w.h, 0, 0, 1, 1),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_trim_in(w.h, 1, 0), OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_resize(w.h, 1, 0), OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_trim_range(w.h, 0, 1, 1, 0), OAKAUDIO_E_INVALID);
OakAudioWaveform empty = {};
EXPECT_EQ(oakaudio_waveform_trim_in(empty, 1, 1), OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_resize(empty, 1, 1), OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_trim_range(empty, 0, 1, 1, 1),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_overwrite_silence(empty, 0, 1, 1, 1),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_get_summary(empty, 0, 1, 1, 1, pairs, 1),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_get_summary(w.h, 0, 1, 1, 0, pairs, 1),
OAKAUDIO_E_INVALID);
}
TEST(OakAudioWaveform, OverwriteSums)
{
WaveformHandle src, dst;
ASSERT_EQ(oakaudio_waveform_set_channel_count(src.h, 1), OAKAUDIO_OK);
ASSERT_EQ(oakaudio_waveform_set_channel_count(dst.h, 1), OAKAUDIO_OK);
std::vector<float> data(48000, 0.25f);
const float *planes[1] = { data.data() };
ASSERT_EQ(oakaudio_waveform_overwrite_samples(src.h, planes, 48000, 48000,
0, 1),
OAKAUDIO_OK);
// Copy all of src into dst at t=0
EXPECT_EQ(oakaudio_waveform_overwrite_sums(dst.h, src.h, 0, 1, 0, 1, 0, 1),
OAKAUDIO_OK);
int64_t num, den;
ASSERT_EQ(oakaudio_waveform_length(dst.h, &num, &den), OAKAUDIO_OK);
EXPECT_NEAR(double(num) / double(den), 1.0, 1e-9);
oakaudio_min_max pairs[1];
ASSERT_EQ(oakaudio_waveform_get_summary(dst.h, 0, 1, 1, 1, pairs, 1), 1);
EXPECT_FLOAT_EQ(pairs[0].max, 0.25f);
// Error paths
OakAudioWaveform empty = {};
EXPECT_EQ(oakaudio_waveform_overwrite_sums(dst.h, empty, 0, 1, 0, 1, 0, 1),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_overwrite_sums(empty, src.h, 0, 1, 0, 1, 0, 1),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_overwrite_sums(dst.h, src.h, 0, 0, 0, 1, 0, 1),
OAKAUDIO_E_INVALID);
}
TEST(OakAudioWaveform, SumSamplesStatic)
{
std::vector<float> ch0 = { 0.1f, -0.4f, 0.3f, 0.2f };
std::vector<float> ch1 = { -0.9f, 0.5f, 0.1f, 0.0f };
const float *planes[2] = { ch0.data(), ch1.data() };
oakaudio_min_max out[2];
EXPECT_EQ(oakaudio_waveform_sum_samples_s(planes, 2, 0, 4, out),
OAKAUDIO_OK);
EXPECT_FLOAT_EQ(out[0].min, -0.4f);
EXPECT_FLOAT_EQ(out[0].max, 0.3f);
EXPECT_FLOAT_EQ(out[1].min, -0.9f);
EXPECT_FLOAT_EQ(out[1].max, 0.5f);
// Error paths
EXPECT_EQ(oakaudio_waveform_sum_samples_s(nullptr, 2, 0, 4, out),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_sum_samples_s(planes, 0, 0, 4, out),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_sum_samples_s(planes, 2, 0, 0, out),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_sum_samples_s(planes, 2, 0, 4, nullptr),
OAKAUDIO_E_INVALID);
}
TEST(OakAudioWaveform, ReSumStatic)
{
oakaudio_min_max in[4] = { { -0.5f, 0.4f }, { -0.2f, 0.9f },
{ -0.7f, 0.1f }, { 0.0f, 0.3f } };
oakaudio_min_max out[2];
EXPECT_EQ(oakaudio_waveform_re_sum_s(in, 4, 2, out), OAKAUDIO_OK);
EXPECT_FLOAT_EQ(out[0].min, -0.7f);
EXPECT_FLOAT_EQ(out[0].max, 0.4f);
EXPECT_FLOAT_EQ(out[1].min, -0.2f);
EXPECT_FLOAT_EQ(out[1].max, 0.9f);
// Error paths
EXPECT_EQ(oakaudio_waveform_re_sum_s(nullptr, 4, 2, out),
OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_re_sum_s(in, 0, 2, out), OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_re_sum_s(in, 4, 0, out), OAKAUDIO_E_INVALID);
EXPECT_EQ(oakaudio_waveform_re_sum_s(in, 4, 2, nullptr),
OAKAUDIO_E_INVALID);
}
TEST(OakAudioWaveform, ExtractFromMediaFile)
{
const std::string file = demo_file();
// Probe the expected duration independently
OakDecoder probe = oakcodec_decoder_probe(file.c_str());
ASSERT_NE(probe.ctx, nullptr) << "demo.mp4 not decodable";
oakcodec_audio_stream_info info;
ASSERT_EQ(oakcodec_decoder_probe_get_audio_stream(probe, 0, &info),
OAKCODEC_OK);
// The audio stream's duration_ts is not populated for this file; the
// video stream carries the clip duration.
oakcodec_video_stream_info vinfo;
double duration = 0.0;
if (oakcodec_decoder_probe_get_video_stream(probe, 0, &vinfo) ==
OAKCODEC_OK &&
vinfo.time_base_den > 0) {
duration = double(vinfo.duration_ts) * vinfo.time_base_num /
vinfo.time_base_den;
}
oakcodec_decoder_free(&probe);
ASSERT_GT(duration, 0.0);
const int before = oakaudio_debug_alive_count();
constexpr int kSamplesPerPoint = 1024;
// Two-stage sizing: NULL out returns the required point count
const int required = oakaudio_waveform_extract(
file.c_str(), 0, kSamplesPerPoint, nullptr, 0, nullptr);
ASSERT_GT(required, 0);
std::vector<oakaudio_min_max> pairs(size_t(required) * 4);
int channels = 0;
const int points =
oakaudio_waveform_extract(file.c_str(), 0, kSamplesPerPoint,
pairs.data(), int(pairs.size()), &channels);
ASSERT_EQ(points, required);
EXPECT_EQ(channels, info.channel_count);
// Length consistency with the stream duration (generous tolerance for
// container/decoder rounding)
const double covered =
double(points) * kSamplesPerPoint / info.sample_rate;
EXPECT_NEAR(covered, duration, std::max(0.5, duration * 0.1));
// Non-trivial content: at least one point carries signal
bool any_signal = false;
for (int i = 0; i < points * channels; i++) {
if (pairs[size_t(i)].max > 0.0f || pairs[size_t(i)].min < 0.0f) {
any_signal = true;
break;
}
}
EXPECT_TRUE(any_signal);
EXPECT_EQ(oakaudio_debug_alive_count(), before);
}
TEST(OakAudioWaveform, ExtractErrorPaths)
{
int channels = 0;
oakaudio_min_max pairs[8];
// Nonexistent file
EXPECT_EQ(oakaudio_waveform_extract("/nonexistent/file.mp4", 0, 1024,
pairs, 8, &channels),
OAKAUDIO_E_NOT_FOUND);
// NULL filename
EXPECT_EQ(oakaudio_waveform_extract(nullptr, 0, 1024, pairs, 8, &channels),
OAKAUDIO_E_INVALID);
// Bad stream index
EXPECT_EQ(oakaudio_waveform_extract(demo_file().c_str(), 99, 1024, pairs, 8,
&channels),
OAKAUDIO_E_NOT_FOUND);
// Bad samples-per-point
EXPECT_EQ(oakaudio_waveform_extract(demo_file().c_str(), 0, 0, pairs, 8,
&channels),
OAKAUDIO_E_INVALID);
}
-145
View File
@@ -1,145 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "oakcore-rs"
version = "0.1.0"
[[package]]
name = "oakotio"
version = "0.1.0"
dependencies = [
"oakcore-rs",
"quick-xml",
"serde",
"serde_json",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"indexmap",
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
-49
View File
@@ -1,49 +0,0 @@
# 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/>.
[package]
name = "oakotio"
version = "0.1.0"
edition = "2021"
description = "Oak Video Editor OpenTimelineIO binding crate: pure-Rust serde model of the OTIO JSON format used by project load/save (src/task/src/project/loadotio, saveotio)"
license = "GPL-3.0-or-later"
[lib]
crate-type = ["rlib"]
[dependencies]
# serde + serde_json: JSON codec for the OTIO JSON format. The
# `preserve_order` feature keeps Map insertion order so metadata and unknown
# fields round-trip in file order, and a custom 4-space pretty formatter
# reproduces the opentimelineio writer byte-for-byte. See README.md for the
# rationale (no maintained pure-Rust OTIO crate exists on crates.io).
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
# Shared value types (Rational). Same path dependency the other bindings use.
oakcore-rs = { path = "../../oakcore-rs" }
# quick-xml: streaming XML codec for the FCPXML interchange layer
# (src/fcpxml.rs). Same major version the other Rust modules use
# (src/common/rust/Cargo.toml).
quick-xml = "0.41.0"
[dev-dependencies]
# oakcore-rs: exact Rational comparisons in the integration tests
# (tests/fcpxml.rs).
oakcore-rs = { path = "../../oakcore-rs" }
-252
View File
@@ -1,252 +0,0 @@
# oakotio
Pure-Rust OpenTimelineIO JSON binding for the Oak Video Editor's Rust
rewrite. This crate is a self-contained serde model of the OTIO JSON format,
covering exactly the object graph Oak's project load/save tasks use
(`src/task/src/project/loadotio/loadotio.cpp` and
`src/task/src/project/saveotio/saveotio.cpp`): `RationalTime`, `TimeRange`,
`Clip`, `Gap`, `Transition`, `Track`, `Stack`, `Timeline`,
`ExternalReference`, `MissingReference` and `SerializableCollection`.
The writer reproduces the opentimelineio C++ writer's output byte for byte
(4-space indentation, `": "` separators, inline empty objects and arrays,
shortest float representation, no trailing newline); the reader tolerates
hand-written files, preserving unknown fields verbatim across a round-trip
and defaulting missing fields.
The crate also ships an **FCPXML** (Final Cut Pro X `.fcpxml`) interchange
layer (`src/fcpxml.rs`) that maps the FCP X document format onto the *same*
model types, so an importer/exporter can offer `.fcpxml` alongside `.otio`
with a single object graph. See [FCPXML](#fcpxml) below.
Part of the Oak `src/bindings/` family (siblings: `oakaudioout`).
This is an **rlib** — nothing is exported dynamically.
## Structure
```
src/bindings/oakotio/
├── Cargo.toml # rlib; deps: serde, serde_json, quick-xml (crates.io),
│ # oakcore-rs (path); dev-dep: oakcore-rs (path)
├── src/
│ ├── lib.rs # crate docs + module wiring + re-exports + from_json_* entry points
│ ├── error.rs # OtioError (Json | Io) + Result<T>
│ ├── model.rs # serde structs for the 10 OTIO schemas + value types +
│ │ # Rational::from_double port + in-crate unit tests
│ └── fcpxml.rs # FCPXML reader/writer (quick-xml) mapped onto model types +
│ # FcpxmlError + in-crate unit tests
└── tests/
├── data/ # C++-writer golden files (golden_timeline.json,
│ # golden_collection.json, golden_typed_transition.json,
│ # floatfmt.json)
├── parity.rs # read parity: every golden file parses and round-trips
├── semantic.rs # semantic checks over golden_timeline.json (what the
│ # C++ load task reads back)
├── save_parity.rs # save parity: a built Timeline serializes byte-identical
│ # to golden_timeline.json and re-parses identically
└── fcpxml.rs # FCPXML: synthetic-document parse, model round-trip,
# NTSC precision, error paths, leniency
```
## API summary
- `from_json_string(&str) -> Result<Serializable>` /
`from_json_file(path) -> Result<Serializable>` — parse a document whose root
is a `Timeline`, a `SerializableCollection`, or an unknown schema kept whole
as `Serializable::Raw`.
- `RationalTime``new(value, rate)` (C++ argument order), `value`, `rate`,
`to_seconds`, `is_invalid_time`, `invalid_time`, `rescaled_to`,
`to_rational`, `from_rational`.
- `TimeRange``new(start_time, duration)` (C++ argument order), `start_time`,
`duration`.
- `Clip``new(name)`, `name`, `source_range`, `set_source_range`,
`media_reference`, `media_references`, `set_media_reference`.
- `Gap``new(source_range, name)`, `name`, `source_range`.
- `Transition``new(name)`, `name`, `in_offset`, `out_offset`,
`transition_type`, `set_in_offset`, `set_out_offset`.
- `Track``new(kind)`, `kind`, `children`, `append_child`.
- `Stack``children`, `append_child`.
- `Timeline``new(name)`, `name`, `tracks`, `tracks_mut`,
`global_start_time`, `to_json_string`, `to_json_file`.
- `SerializableCollection``new(name, children)`, `name`, `children`,
`to_json_string`, `to_json_file`.
- `MediaReference` / `Composable` / `Serializable` enums — downcasts
(`as_clip`, `as_track`, ...) and `schema_name` for dynamic dispatch by
`OTIO_SCHEMA`.
All fallible operations return `Result<T, OtioError>`.
### FCPXML API (`fcpxml` module)
- `from_fcpxml_string(&str) -> Result<Vec<Timeline>, FcpxmlError>` /
`from_fcpxml_file(path)` — parse an FCPXML document; one `Timeline` per
`<sequence>`.
- `to_fcpxml_string(&[Timeline]) -> Result<String, FcpxmlError>` /
`to_fcpxml_file(&[Timeline], path)` — serialize timelines to an FCPXML
1.10 document (formats and assets deduplicated across timelines).
- `FcpxmlError``Xml` (malformed markup), `Malformed` (wrong root,
missing attributes, unknown format resources, bad time values),
`UnsupportedVersion`, `Io`.
The FCPXML layer reads through the same model types as the OTIO layer:
`Timeline`/`Track`/`Clip`/`Gap`/`Transition` with `RationalTime` time
values, so a single object graph feeds both `.otio` and `.fcpxml`
import/export.
## Backend choice: hand-written serde over the `opentimelineio` crate
| Option | Verdict |
| --- | --- |
| crates.io `opentimelineio` | Not viable: the crate is unmaintained, binds the C++ library via FFI (large, ABI-fragile), and does not build a pure-Rust model Oak's load/save tasks can read directly. No actively maintained pure-Rust OTIO implementation exists on crates.io. |
| **This crate: `serde` + `serde_json`** | Pure-Rust (no C++ runtime), fully controllable field order and formatting, preserves unknown fields for forward compatibility, and ports the only piece of C++ numeric behavior Oak needs (`Rational::from_double`) on top of `oakcore_rs::Rational`. |
The C++ side serializes with `opentimelineio::schema::Timeline::to_json_string`
(4-space pretty formatter); this crate reproduces that exact writer with a
`serde_json::PrettyFormatter` (`with_indent(b" ")`), `preserve_order` maps
so insertion order is kept, and ryu float formatting, which is what the C++
writer (rapidjson) emits. The result is byte-for-byte parity with C++-written
files (verified against the golden files).
## Dependency registry
Runtime dependencies (crates.io):
- `serde` 1 (with `derive`) — (de)serialization for the OTIO schema structs.
- `serde_json` 1 (with `preserve_order`) — JSON codec; `preserve_order` keeps
map insertion order so metadata and unknown fields round-trip in file
order.
- `quick-xml` 0.41 — streaming XML codec for the FCPXML layer
(`src/fcpxml.rs`). Same major version the other Rust modules use
(`src/common/rust/Cargo.toml`).
- `oakcore-rs` (path: `../../oakcore-rs`) — shared `Rational` value type
(used by the `Rational::from_double` port and for exact FCPXML
rational-time conversion); same path dependency the other bindings use.
Dev-dependencies (tests only):
- `oakcore-rs` (path: `../../oakcore-rs`) — exact `Rational` comparisons in
`tests/fcpxml.rs`.
Build and test:
```sh
cd src/bindings/oakotio
cargo build
cargo test
```
## C++ parity notes
The C++ anchors this crate reproduces:
- **`Rational::from_double`** (`core/src/util/rational.cpp`) — ported in
`model.rs` on top of `oakcore_rs::Rational::new` (which applies the exact
C++ `reduce_fraction(INT_MAX)` reduction). NaN and out-of-range magnitudes
collapse to the null sentinel `Rational::NULL`; the retry pass against
`INT64_MAX` fires for tiny magnitudes and is itself reduced back to 0/1 by
the `INT_MAX` ceiling, matching the C++ result.
- **Writer format** — `opentimelineio::schema::Timeline::to_json_string`:
4-space indentation, `": "` separators, inline empty `{}`/`[]`, shortest
float representation (ryu = rapidjson), no trailing newline. Golden files
written by the C++ writer round-trip byte-identically.
- **Field order** — struct field order matches the C++ writer's output order
(e.g. `RationalTime`: `rate` then `value`; `TimeRange`: `duration` then
`start_time`; `Track`: `children` then `kind`).
- **`media_references`** — the C++ `Clip` stores a `std::map<string, ...>`;
this crate uses `BTreeMap`, which serializes keys in the same sorted order.
- **Missing-reference serialization** — `MissingReference` writes
`available_range`/`available_image_bounds` as `null` and omits `target_url`,
exactly like the C++ writer.
## Deviations from the C++ code (deliberate)
- **Unknown fields are kept, not dropped** — the C++ reader discards
unrecognized JSON fields; this crate preserves them (via `#[serde(flatten)]`
catch-all maps) so a document written by a newer opentimelineio still
round-trips. This is a superset of the C++ behavior.
- **Defaults are lenient** — missing fields deserialize to their type's
default (the C++ `AnyDictionary` fill defaults), so hand-written files
without optional fields parse cleanly.
- **`RationalTime`/`TimeRange` are `Clone`, not `Copy`** — they carry a
`String` schema field, so value accessors (`value()`, `rate()`,
`duration()`, ...) take `&self` and return clones; the C++ value semantics
(`to_seconds`, `rescaled_to`) are unaffected.
## FCPXML
The `fcpxml` module reads and writes Final Cut Pro X's `.fcpxml`
interchange format (the version-1.x XML documents produced by FCP X and,
with some tolerance, by DaVinci Resolve). It maps the FCPXML structure onto
the same model types as the OTIO layer:
| FCPXML element | Model mapping |
| --- | --- |
| `<fcpxml>` root `version` | validated (1.0 1.11); recorded in timeline metadata |
| `<resources>`/`<format>` | frame rate (from `frameDuration`) |
| `<resources>`/`<asset>` | `ExternalReference` (`src``target_url`, `duration``available_range`) |
| `<library>`/`<event>`/`<project>` | timeline name + `metadata["fcpxml"]` (event/project name, version) |
| `<sequence>` | `Timeline` (`tcStart``global_start_time`, `tcFormat`/`audioLayout`/`audioRate` → metadata) |
| `<spine>` | `Track` kind "Video" (the primary storyline) |
| secondary `<video>` / `<audio>` | additional `Track`s (kind "Video"/"Audio", nested lanes flattened) |
| `<asset-clip>` | `Clip` (`offset`+`duration`+`start``source_range`, `ref` → media reference, `enabled` preserved) |
| `<gap>` | `Gap` |
| `<transition>` | `Transition` with `in_offset == out_offset == duration/2` (FCP X centers its transitions) |
**Time values.** FCPXML times are rational seconds ("100/3000s",
"0s", "1001/30000s", ...). They are parsed into `oakcore_rs::Rational` and
converted to/from `RationalTime` with exact rational arithmetic, so NTSC
rates (30000/1001, 60000/1001, 24000/1001) stay frame-accurate in both
directions — a 3-frame clip at 29.97fps round-trips as exactly 3 frames.
The reader also tolerates integer seconds ("48s"), bare rationals
("1/24") and decimal seconds ("1.5s").
**Leniency.** Unknown elements and attributes are skipped with a log line
(`eprintln!`) instead of failing, so real-world documents from other NLEs
import without crashing. Unmapped *timed* blocks (`<sync-clip>`,
`<title>`, `<generator>`, ...) are imported as `Gap`s that preserve their
timing; dangling `ref`s become `MissingReference`s. Hard errors are
reserved for genuinely broken documents: non-FCPXML roots, unsupported or
missing `version`, a `<sequence>` referencing an unknown format, and
unparseable time values.
**Writer output.** `to_fcpxml_string` emits FCPXML 1.10 with 2-space
indentation (the FCP X convention): `<?xml?>`, `<!DOCTYPE fcpxml>`,
`<resources>` with `<format>`s first then `<asset>`s (both deduplicated,
formats by frame duration and assets by `src`), one `<event>`/`<project>`/
`<sequence>` group per timeline, the first video track as `<spine>`, the
rest as `<video>`/`<audio>` elements. Interchange hints stored in
`metadata["fcpxml"]` (event/project names, `tcFormat`, `audioLayout`,
`audioRate`) are reproduced on export.
### FCPXML deviations (deliberate)
- **Reduced time spellings** — the writer emits reduced rational seconds
("48s", "1/30s", "1001/30000s") instead of FCP X's scaled "value/rate"
spellings ("1440/30s"). Semantically identical.
- **`<format>` dimensions** — the OTIO model carries no frame size, so
exported formats use `width="1920" height="1080"` (name
"FFVideoFormat1920x1080p<rate>"). Importers derive the rate from
`frameDuration`, which is exact.
- **Audio components** — a clip's embedded audio components are not split
out; the spine keeps only the video track, audio lives in `<audio>`
elements. `audioStart`/`audioDuration`/`audioOffset` are not written.
- **`<sync-clip>`/`<title>`/`<generator>`** — imported as timing-preserving
gaps (their content is not mapped to a model type).
- **Track names** — FCPXML has no track names; imported tracks are unnamed.
- **Transitions** — FCP X transitions are centered, so the importer sets
`in_offset == out_offset == duration/2`. Asymmetric transitions from
other tools are approximated this way.
- **Missing media** — clips without an `ExternalReference` are written as
`asset-clip` elements without a `ref` (technically schema-invalid but
tolerated by importers; logged).
- **Unknown resource types** (`<effect>`, `<filter>`, ...) are skipped on
import and not written on export.
## Scope
Covers only what `loadotio.cpp` / `saveotio.cpp` touch, plus the FCPXML
interchange layer. No media-resolution, no `Marker`/`Effect` schemas (kept
as raw `Value` for round-tripping), and no plugin API — `src/plugin/` is
intentionally untouched. OTIO JSON behavior is unchanged by the FCPXML
layer (it only adds public accessors: `Clip::enabled`/`set_enabled`,
`Timeline::metadata`/`metadata_mut`/`set_global_start_time`).
-62
View File
@@ -1,62 +0,0 @@
// 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/>.
//! Error type for the oakotio binding.
use std::fmt;
/// Errors produced by loading or saving OpenTimelineIO JSON.
#[derive(Debug)]
pub enum OtioError {
/// The document could not be parsed (or a value could not be
/// serialized) as JSON.
Json(serde_json::Error),
/// The underlying file could not be read or written.
Io(std::io::Error),
}
impl fmt::Display for OtioError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OtioError::Json(e) => write!(f, "OpenTimelineIO JSON error: {e}"),
OtioError::Io(e) => write!(f, "OpenTimelineIO file error: {e}"),
}
}
}
impl std::error::Error for OtioError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
OtioError::Json(e) => Some(e),
OtioError::Io(e) => Some(e),
}
}
}
impl From<serde_json::Error> for OtioError {
fn from(e: serde_json::Error) -> OtioError {
OtioError::Json(e)
}
}
impl From<std::io::Error> for OtioError {
fn from(e: std::io::Error) -> OtioError {
OtioError::Io(e)
}
}
/// Convenience alias used by the binding API.
pub type Result<T> = std::result::Result<T, OtioError>;
File diff suppressed because it is too large Load Diff
-62
View File
@@ -1,62 +0,0 @@
// 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/>.
//! Pure-Rust OpenTimelineIO JSON binding for Oak Video Editor.
//!
//! `oakotio` is a self-contained serde model of the OpenTimelineIO JSON
//! format, covering exactly the object graph Oak's project load/save tasks
//! use (`src/task/src/project/loadotio/loadotio.cpp` and
//! `src/task/src/project/saveotio/saveotio.cpp`): `RationalTime`,
//! `TimeRange`, `Clip`, `Gap`, `Transition`, `Track`, `Stack`, `Timeline`,
//! `ExternalReference`, `MissingReference` and `SerializableCollection`.
//!
//! The writer reproduces the opentimelineio C++ writer's output byte for
//! byte (4-space indentation, `": "` separators, inline empty objects and
//! arrays, shortest float representation, no trailing newline); the reader
//! tolerates hand-written files, preserving unknown fields verbatim across a
//! round-trip and defaulting missing fields. See `README.md` for the design
//! rationale and the parity notes against the C++ implementation.
//!
//! The crate also carries an FCPXML (Final Cut Pro X) interchange layer
//! ([`fcpxml`]) that maps the `.fcpxml` document format onto the same model
//! types, so import/export can offer `.fcpxml` alongside `.otio` with a
//! single object graph.
use std::path::Path;
pub mod error;
pub mod fcpxml;
pub mod model;
pub use error::{OtioError, Result};
pub use fcpxml::{
FcpxmlError, from_fcpxml_file, from_fcpxml_string, to_fcpxml_file, to_fcpxml_string,
};
pub use model::*;
/// Parse an OpenTimelineIO JSON document from a string.
///
/// The root may be a `Timeline`, a `SerializableCollection`, or any other
/// schema; unrecognized roots are kept whole as `model::Serializable::Raw`
/// so they round-trip untouched.
pub fn from_json_string(text: &str) -> Result<Serializable> {
Ok(serde_json::from_str(text)?)
}
/// Read and parse an OpenTimelineIO JSON document from a file.
pub fn from_json_file(path: impl AsRef<Path>) -> Result<Serializable> {
from_json_string(&std::fs::read_to_string(path)?)
}
File diff suppressed because it is too large Load Diff
@@ -1,91 +0,0 @@
{
"OTIO_SCHEMA": "Timeline.1",
"metadata": {},
"name": "F",
"global_start_time": null,
"tracks": {
"OTIO_SCHEMA": "Stack.1",
"metadata": {},
"name": "tracks",
"source_range": null,
"effects": [],
"markers": [],
"enabled": true,
"children": [
{
"OTIO_SCHEMA": "Track.1",
"metadata": {},
"name": "",
"source_range": null,
"effects": [],
"markers": [],
"enabled": true,
"children": [
{
"OTIO_SCHEMA": "Clip.2",
"metadata": {},
"name": "c1",
"source_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 1.0,
"value": 0.1
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 3.0,
"value": 1.0
}
},
"effects": [],
"markers": [],
"enabled": true,
"media_references": {
"DEFAULT_MEDIA": {
"OTIO_SCHEMA": "MissingReference.1",
"metadata": {},
"name": "",
"available_range": null,
"available_image_bounds": null
}
},
"active_media_reference_key": "DEFAULT_MEDIA"
},
{
"OTIO_SCHEMA": "Clip.2",
"metadata": {},
"name": "c2",
"source_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 1.0,
"value": 123456789.125
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 4.8
}
},
"effects": [],
"markers": [],
"enabled": true,
"media_references": {
"DEFAULT_MEDIA": {
"OTIO_SCHEMA": "MissingReference.1",
"metadata": {},
"name": "",
"available_range": null,
"available_image_bounds": null
}
},
"active_media_reference_key": "DEFAULT_MEDIA"
}
],
"kind": "Video"
}
]
}
}
@@ -1,360 +0,0 @@
{
"OTIO_SCHEMA": "SerializableCollection.1",
"metadata": {},
"name": "Sequences",
"children": [
{
"OTIO_SCHEMA": "Timeline.1",
"metadata": {},
"name": "Seq One",
"global_start_time": null,
"tracks": {
"OTIO_SCHEMA": "Stack.1",
"metadata": {},
"name": "tracks",
"source_range": null,
"effects": [],
"markers": [],
"enabled": true,
"children": [
{
"OTIO_SCHEMA": "Track.1",
"metadata": {},
"name": "",
"source_range": null,
"effects": [],
"markers": [],
"enabled": true,
"children": [
{
"OTIO_SCHEMA": "Clip.2",
"metadata": {},
"name": "Seq One Clip",
"source_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 30.0,
"value": 1440.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 30.0,
"value": 0.0
}
},
"effects": [],
"markers": [],
"enabled": true,
"media_references": {
"DEFAULT_MEDIA": {
"OTIO_SCHEMA": "ExternalReference.1",
"metadata": {},
"name": "",
"available_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 25.0,
"value": 100.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 25.0,
"value": 0.0
}
},
"available_image_bounds": null,
"target_url": "file:///tmp/Seq One.mp4"
}
},
"active_media_reference_key": "DEFAULT_MEDIA"
},
{
"OTIO_SCHEMA": "Gap.1",
"metadata": {},
"name": "Seq One Gap",
"source_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 576.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 0.0
}
},
"effects": [],
"markers": [],
"enabled": true
},
{
"OTIO_SCHEMA": "Transition.1",
"metadata": {},
"name": "Seq One Transition",
"in_offset": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 12.0
},
"out_offset": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 12.0
},
"transition_type": ""
}
],
"kind": "Video"
},
{
"OTIO_SCHEMA": "Track.1",
"metadata": {},
"name": "",
"source_range": null,
"effects": [],
"markers": [],
"enabled": true,
"children": [
{
"OTIO_SCHEMA": "Clip.2",
"metadata": {},
"name": "Seq One Audio",
"source_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 30.0,
"value": 1440.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 30.0,
"value": 0.0
}
},
"effects": [],
"markers": [],
"enabled": true,
"media_references": {
"DEFAULT_MEDIA": {
"OTIO_SCHEMA": "ExternalReference.1",
"metadata": {},
"name": "",
"available_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 48000.0,
"value": 0.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 48000.0,
"value": 0.0
}
},
"available_image_bounds": null,
"target_url": "file:///tmp/Seq One.wav"
}
},
"active_media_reference_key": "DEFAULT_MEDIA"
}
],
"kind": "Audio"
}
]
}
},
{
"OTIO_SCHEMA": "Timeline.1",
"metadata": {},
"name": "Seq Two",
"global_start_time": null,
"tracks": {
"OTIO_SCHEMA": "Stack.1",
"metadata": {},
"name": "tracks",
"source_range": null,
"effects": [],
"markers": [],
"enabled": true,
"children": [
{
"OTIO_SCHEMA": "Track.1",
"metadata": {},
"name": "",
"source_range": null,
"effects": [],
"markers": [],
"enabled": true,
"children": [
{
"OTIO_SCHEMA": "Clip.2",
"metadata": {},
"name": "Seq Two Clip",
"source_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 25.0,
"value": 1200.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 25.0,
"value": 0.0
}
},
"effects": [],
"markers": [],
"enabled": true,
"media_references": {
"DEFAULT_MEDIA": {
"OTIO_SCHEMA": "ExternalReference.1",
"metadata": {},
"name": "",
"available_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 25.0,
"value": 100.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 25.0,
"value": 0.0
}
},
"available_image_bounds": null,
"target_url": "file:///tmp/Seq Two.mp4"
}
},
"active_media_reference_key": "DEFAULT_MEDIA"
},
{
"OTIO_SCHEMA": "Gap.1",
"metadata": {},
"name": "Seq Two Gap",
"source_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 576.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 0.0
}
},
"effects": [],
"markers": [],
"enabled": true
},
{
"OTIO_SCHEMA": "Transition.1",
"metadata": {},
"name": "Seq Two Transition",
"in_offset": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 12.0
},
"out_offset": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 12.0
},
"transition_type": ""
}
],
"kind": "Video"
},
{
"OTIO_SCHEMA": "Track.1",
"metadata": {},
"name": "",
"source_range": null,
"effects": [],
"markers": [],
"enabled": true,
"children": [
{
"OTIO_SCHEMA": "Clip.2",
"metadata": {},
"name": "Seq Two Audio",
"source_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 25.0,
"value": 1200.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 25.0,
"value": 0.0
}
},
"effects": [],
"markers": [],
"enabled": true,
"media_references": {
"DEFAULT_MEDIA": {
"OTIO_SCHEMA": "ExternalReference.1",
"metadata": {},
"name": "",
"available_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 48000.0,
"value": 0.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 48000.0,
"value": 0.0
}
},
"available_image_bounds": null,
"target_url": "file:///tmp/Seq Two.wav"
}
},
"active_media_reference_key": "DEFAULT_MEDIA"
},
{
"OTIO_SCHEMA": "Gap.1",
"metadata": {},
"name": "",
"source_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 1.0,
"value": 12.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 25.0,
"value": 1200.0
}
},
"effects": [],
"markers": [],
"enabled": true
}
],
"kind": "Audio"
}
]
}
}
]
}
@@ -1,187 +0,0 @@
{
"OTIO_SCHEMA": "Timeline.1",
"metadata": {},
"name": "My Sequence",
"global_start_time": null,
"tracks": {
"OTIO_SCHEMA": "Stack.1",
"metadata": {},
"name": "tracks",
"source_range": null,
"effects": [],
"markers": [],
"enabled": true,
"children": [
{
"OTIO_SCHEMA": "Track.1",
"metadata": {},
"name": "",
"source_range": null,
"effects": [],
"markers": [],
"enabled": true,
"children": [
{
"OTIO_SCHEMA": "Clip.2",
"metadata": {},
"name": "My Sequence Clip",
"source_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 1152.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 0.0
}
},
"effects": [],
"markers": [],
"enabled": true,
"media_references": {
"DEFAULT_MEDIA": {
"OTIO_SCHEMA": "ExternalReference.1",
"metadata": {},
"name": "",
"available_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 25.0,
"value": 100.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 25.0,
"value": 0.0
}
},
"available_image_bounds": null,
"target_url": "file:///tmp/My Sequence.mp4"
}
},
"active_media_reference_key": "DEFAULT_MEDIA"
},
{
"OTIO_SCHEMA": "Gap.1",
"metadata": {},
"name": "My Sequence Gap",
"source_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 576.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 0.0
}
},
"effects": [],
"markers": [],
"enabled": true
},
{
"OTIO_SCHEMA": "Transition.1",
"metadata": {},
"name": "My Sequence Transition",
"in_offset": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 12.0
},
"out_offset": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 12.0
},
"transition_type": ""
}
],
"kind": "Video"
},
{
"OTIO_SCHEMA": "Track.1",
"metadata": {},
"name": "",
"source_range": null,
"effects": [],
"markers": [],
"enabled": true,
"children": [
{
"OTIO_SCHEMA": "Clip.2",
"metadata": {},
"name": "My Sequence Audio",
"source_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 1152.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 0.0
}
},
"effects": [],
"markers": [],
"enabled": true,
"media_references": {
"DEFAULT_MEDIA": {
"OTIO_SCHEMA": "ExternalReference.1",
"metadata": {},
"name": "",
"available_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 48000.0,
"value": 0.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 48000.0,
"value": 0.0
}
},
"available_image_bounds": null,
"target_url": "file:///tmp/My Sequence.wav"
}
},
"active_media_reference_key": "DEFAULT_MEDIA"
},
{
"OTIO_SCHEMA": "Gap.1",
"metadata": {},
"name": "",
"source_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 1.0,
"value": 12.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 1152.0
}
},
"effects": [],
"markers": [],
"enabled": true
}
],
"kind": "Audio"
}
]
}
}
@@ -1,187 +0,0 @@
{
"OTIO_SCHEMA": "Timeline.1",
"metadata": {},
"name": "Typed Transition",
"global_start_time": null,
"tracks": {
"OTIO_SCHEMA": "Stack.1",
"metadata": {},
"name": "tracks",
"source_range": null,
"effects": [],
"markers": [],
"enabled": true,
"children": [
{
"OTIO_SCHEMA": "Track.1",
"metadata": {},
"name": "",
"source_range": null,
"effects": [],
"markers": [],
"enabled": true,
"children": [
{
"OTIO_SCHEMA": "Clip.2",
"metadata": {},
"name": "Typed Transition Clip",
"source_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 1152.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 0.0
}
},
"effects": [],
"markers": [],
"enabled": true,
"media_references": {
"DEFAULT_MEDIA": {
"OTIO_SCHEMA": "ExternalReference.1",
"metadata": {},
"name": "",
"available_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 25.0,
"value": 100.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 25.0,
"value": 0.0
}
},
"available_image_bounds": null,
"target_url": "file:///tmp/Typed Transition.mp4"
}
},
"active_media_reference_key": "DEFAULT_MEDIA"
},
{
"OTIO_SCHEMA": "Gap.1",
"metadata": {},
"name": "Typed Transition Gap",
"source_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 576.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 0.0
}
},
"effects": [],
"markers": [],
"enabled": true
},
{
"OTIO_SCHEMA": "Transition.1",
"metadata": {},
"name": "Typed Transition Transition",
"in_offset": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 12.0
},
"out_offset": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 12.0
},
"transition_type": "SMPTE_Dissolve"
}
],
"kind": "Video"
},
{
"OTIO_SCHEMA": "Track.1",
"metadata": {},
"name": "",
"source_range": null,
"effects": [],
"markers": [],
"enabled": true,
"children": [
{
"OTIO_SCHEMA": "Clip.2",
"metadata": {},
"name": "Typed Transition Audio",
"source_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 1152.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 0.0
}
},
"effects": [],
"markers": [],
"enabled": true,
"media_references": {
"DEFAULT_MEDIA": {
"OTIO_SCHEMA": "ExternalReference.1",
"metadata": {},
"name": "",
"available_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 48000.0,
"value": 0.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 48000.0,
"value": 0.0
}
},
"available_image_bounds": null,
"target_url": "file:///tmp/Typed Transition.wav"
}
},
"active_media_reference_key": "DEFAULT_MEDIA"
},
{
"OTIO_SCHEMA": "Gap.1",
"metadata": {},
"name": "",
"source_range": {
"OTIO_SCHEMA": "TimeRange.1",
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 1.0,
"value": 12.0
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": 24.0,
"value": 1152.0
}
},
"effects": [],
"markers": [],
"enabled": true
}
],
"kind": "Audio"
}
]
}
}
-495
View File
@@ -1,495 +0,0 @@
// 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/>.
//! Integration tests for the FCPXML layer: parsing a synthetic FCPXML
//! document, round-tripping the model through the writer and reader, NTSC
//! frame accuracy, error paths (corrupt XML, missing resources, unknown
//! version) and lenient handling of unknown elements.
use oakotio::{
Clip, Composable, ExternalReference, MediaReference, RationalTime, Timeline, Track,
};
use oakcore_rs::Rational;
/// The NTSC video rate 30000/1001 (~29.97 fps) as an exact rational.
const NTSC_RATE: f64 = 30000.0 / 1001.0;
/// A realistic FCPXML 1.10 document: a 30 fps spine with a clip, a
/// centered transition, a disabled clip, a gap and a title, plus a
/// secondary audio track with NTSC (29.97 fps) material.
fn synthetic_fcpxml() -> String {
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE fcpxml>
<fcpxml version="1.10">
<resources>
<format id="r1" name="FFVideoFormat1080p30" frameDuration="100/3000s" width="1920" height="1080"/>
<format id="r2" name="FFVideoFormatRate29_97i" frameDuration="1001/30000s" width="1920" height="1080"/>
<asset id="r3" name="Clip A.mov" src="file:///tmp/Clip A.mov" format="r1" duration="12000/3000s" hasVideo="1" hasAudio="1"/>
<asset id="r4" name="Ambience.wav" src="file:///tmp/Ambience.wav" format="r2" duration="3003/30000s" hasVideo="0" hasAudio="1"/>
<effect id="r5" name="Custom" uid="9C61DDC9-1111-2222-3333-444455556666"/>
</resources>
<library>
<event name="My Event">
<project name="My Project">
<sequence format="r1" tcStart="3600/3000s" tcFormat="NDF" duration="2220/3000s" audioLayout="stereo" audioRate="48000">
<spine>
<asset-clip name="Clip A" ref="r3" offset="0s" duration="1200/3000s" start="2400/3000s" format="r1"/>
<transition name="Cross Dissolve" offset="1140/3000s" duration="120/3000s"/>
<asset-clip name="Clip B" ref="r3" offset="1260/3000s" duration="600/3000s" start="0s" enabled="0"/>
<gap name="Tail" offset="1860/3000s" duration="60/3000s"/>
<title name="Hello" offset="1920/3000s" duration="300/3000s"/>
</spine>
<audio>
<asset-clip name="Ambience" ref="r4" offset="0s" duration="3003/30000s" start="0s" format="r2"/>
</audio>
</sequence>
</project>
</event>
</library>
</fcpxml>
"#
.to_string()
}
/// Parse the synthetic document and return its single timeline.
fn parse_synthetic() -> Timeline {
let timelines = oakotio::from_fcpxml_string(&synthetic_fcpxml()).expect("parse synthetic fcpxml");
assert_eq!(timelines.len(), 1);
timelines.into_iter().next().unwrap()
}
/// Assert a RationalTime is within tolerance of (value, rate).
fn assert_time(rt: RationalTime, value: f64, rate: f64) {
assert!(
(rt.value() - value).abs() < 1e-9,
"value {} != {value}",
rt.value()
);
assert!(
(rt.rate() - rate).abs() < 1e-9,
"rate {} != {rate}",
rt.rate()
);
}
#[test]
fn parse_synthetic_document() {
let timeline = parse_synthetic();
assert_eq!(timeline.name(), "My Project");
let gst = timeline.global_start_time().expect("tcStart maps to global start");
assert_time(gst, 36.0, 30.0);
// Interchange hints survive in the timeline metadata.
let fcpx = timeline.metadata().get("fcpxml").expect("fcpxml metadata");
assert_eq!(fcpx["version"], "1.10");
assert_eq!(fcpx["tcFormat"], "NDF");
assert_eq!(fcpx["audioLayout"], "stereo");
assert_eq!(fcpx["audioRate"], "48000");
assert_eq!(fcpx["event"], "My Event");
assert_eq!(fcpx["project"], "My Project");
// One video track (spine) and one audio track.
let children = timeline.tracks().children();
assert_eq!(children.len(), 2);
let video = children[0].as_track().expect("first track is a Track");
assert_eq!(video.kind(), "Video");
let audio = children[1].as_track().expect("second track is a Track");
assert_eq!(audio.kind(), "Audio");
// Spine: clip, transition, clip, gap, title-as-gap.
assert_eq!(video.children().len(), 5);
let clip_a = video.children()[0]
.as_clip()
.expect("spine child 0 is a Clip");
assert_eq!(clip_a.name(), "Clip A");
assert!(clip_a.enabled());
let range = clip_a.source_range().expect("Clip A has a source range");
assert_time(range.start_time(), 24.0, 30.0);
assert_time(range.duration(), 12.0, 30.0);
assert_eq!(range.start_time().to_rational(), Rational::new(4, 5));
assert_eq!(range.duration().to_rational(), Rational::new(2, 5));
let external = match clip_a.media_reference().expect("Clip A resolves media") {
MediaReference::ExternalReference(e) => e,
other => panic!("expected ExternalReference, got {}", other.schema_name()),
};
assert_eq!(external.target_url(), "file:///tmp/Clip A.mov");
let available = external.available_range().expect("asset duration");
assert_time(available.start_time(), 0.0, 30.0);
assert_time(available.duration(), 120.0, 30.0);
// Centered transition: in_offset == out_offset == duration / 2.
let transition = video.children()[1]
.as_transition()
.expect("spine child 1 is a Transition");
assert_eq!(transition.name(), "Cross Dissolve");
assert_time(transition.in_offset(), 0.6, 30.0);
assert_time(transition.out_offset(), 0.6, 30.0);
// Disabled second clip referencing the same asset.
let clip_b = video.children()[2]
.as_clip()
.expect("spine child 2 is a Clip");
assert_eq!(clip_b.name(), "Clip B");
assert!(!clip_b.enabled());
let range = clip_b.source_range().expect("Clip B has a source range");
assert_time(range.start_time(), 0.0, 30.0);
assert_time(range.duration(), 6.0, 30.0);
// Gap with a name.
let gap = video.children()[3]
.as_gap()
.expect("spine child 3 is a Gap");
assert_eq!(gap.name(), "Tail");
let grange = gap.source_range().expect("gap source range");
assert_eq!(grange.start_time().to_rational(), Rational::new(31, 50));
assert_eq!(grange.duration().to_rational(), Rational::new(1, 50));
// Title is not mapped; its timing becomes an unnamed gap.
let title_gap = video.children()[4]
.as_gap()
.expect("spine child 4 is a Gap (from title)");
assert_eq!(title_gap.name(), "");
let trange = title_gap.source_range().expect("title gap source range");
assert_eq!(trange.start_time().to_rational(), Rational::new(16, 25));
assert_eq!(trange.duration().to_rational(), Rational::new(1, 10));
// Audio track with NTSC material: 3 frames at 30000/1001.
assert_eq!(audio.children().len(), 1);
let ambience = audio.children()[0]
.as_clip()
.expect("audio child 0 is a Clip");
assert_eq!(ambience.name(), "Ambience");
let range = ambience.source_range().expect("Ambience source range");
assert_time(range.start_time(), 0.0, NTSC_RATE);
assert_time(range.duration(), 3.0, NTSC_RATE);
let external = match ambience.media_reference().unwrap() {
MediaReference::ExternalReference(e) => e,
other => panic!("expected ExternalReference, got {}", other.schema_name()),
};
assert_eq!(external.target_url(), "file:///tmp/Ambience.wav");
let available = external.available_range().unwrap();
assert_time(available.duration(), 3.0, NTSC_RATE);
}
#[test]
fn model_round_trips_through_fcpxml() {
let original = parse_synthetic();
let xml = oakotio::to_fcpxml_string(&[original.clone()]).expect("export fcpxml");
let reparsed = oakotio::from_fcpxml_string(&xml).expect("reparse exported fcpxml");
assert_eq!(reparsed.len(), 1);
assert_eq!(reparsed[0], original, "model survives fcpxml round trip");
}
#[test]
fn exported_document_structure() {
let timeline = parse_synthetic();
let xml = oakotio::to_fcpxml_string(&[timeline]).expect("export fcpxml");
// Document scaffolding.
assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"), "{xml}");
assert!(xml.contains("<!DOCTYPE fcpxml>"), "{xml}");
assert!(xml.contains("<fcpxml version=\"1.10\">"), "{xml}");
assert!(xml.contains("</library>"), "{xml}");
assert!(xml.contains("</fcpxml>"), "{xml}");
// Resources: two formats (30 fps + NTSC), two assets (video + audio).
assert!(xml.contains("<format id=\"r1\" name=\"FFVideoFormat1920x1080p30\" frameDuration=\"1/30s\" width=\"1920\" height=\"1080\"/>"), "{xml}");
assert!(xml.contains("<format id=\"r2\" name=\"FFVideoFormat1920x1080p29_97\" frameDuration=\"1001/30000s\" width=\"1920\" height=\"1080\"/>"), "{xml}");
assert!(xml.contains("<asset id=\"r3\" name=\"Clip A.mov\" src=\"file:///tmp/Clip A.mov\" format=\"r1\" duration=\"4s\" hasVideo=\"1\" hasAudio=\"0\"/>"), "{xml}");
assert!(xml.contains("<asset id=\"r4\" name=\"Ambience.wav\" src=\"file:///tmp/Ambience.wav\" format=\"r2\" duration=\"1001/10000s\" hasVideo=\"0\" hasAudio=\"1\"/>"), "{xml}");
// Sequence with preserved interchange hints.
assert!(xml.contains("<sequence format=\"r1\" tcStart=\"6/5s\" tcFormat=\"NDF\" duration=\"37/50s\" audioLayout=\"stereo\" audioRate=\"48000\" name=\"My Project\">"), "{xml}");
// Spine blocks with exact reduced-rational time values.
assert!(xml.contains("<spine>"), "{xml}");
assert!(xml.contains("<asset-clip name=\"Clip A\" ref=\"r3\" offset=\"0s\" duration=\"2/5s\" start=\"4/5s\"/>"), "{xml}");
assert!(xml.contains("<transition name=\"Cross Dissolve\" offset=\"19/50s\" duration=\"1/25s\"/>"), "{xml}");
assert!(xml.contains("<asset-clip name=\"Clip B\" ref=\"r3\" offset=\"21/50s\" duration=\"1/5s\" start=\"0s\" enabled=\"0\"/>"), "{xml}");
assert!(xml.contains("<gap name=\"Tail\" offset=\"31/50s\" duration=\"1/50s\"/>"), "{xml}");
assert!(xml.contains("<gap name=\"\" offset=\"16/25s\" duration=\"1/10s\"/>"), "{xml}");
assert!(xml.contains("</spine>"), "{xml}");
// Secondary audio track with an NTSC clip.
assert!(xml.contains("<audio>"), "{xml}");
assert!(xml.contains("<asset-clip name=\"Ambience\" ref=\"r4\" offset=\"0s\" duration=\"1001/10000s\" start=\"0s\" format=\"r2\"/>"), "{xml}");
assert!(xml.contains("</audio>"), "{xml}");
}
#[test]
fn ntsc_frame_accuracy() {
// A built model with NTSC media round-trips frame-exactly.
let mut timeline = Timeline::new("NTSC");
let mut video = Track::new("Video");
let mut clip = Clip::new("ntsc");
clip.set_source_range(oakotio::TimeRange::new(
RationalTime::new(0.0, NTSC_RATE),
RationalTime::new(3.0, NTSC_RATE),
));
clip.set_media_reference(MediaReference::ExternalReference(ExternalReference::new(
"file:///tmp/ntsc.mov",
Some(oakotio::TimeRange::new(
RationalTime::new(0.0, NTSC_RATE),
RationalTime::new(1000.0, NTSC_RATE),
)),
)));
video.append_child(Composable::Clip(clip));
timeline.tracks_mut().append_child(Composable::Track(video));
let xml = oakotio::to_fcpxml_string(&[timeline]).expect("export");
let reparsed = oakotio::from_fcpxml_string(&xml).expect("reparse");
let clip = reparsed[0].tracks().children()[0]
.as_track()
.unwrap()
.children()[0]
.as_clip()
.unwrap();
let range = clip.source_range().unwrap();
// 3 frames at 30000/1001 stay 3 frames through seconds.
assert_time(range.duration(), 3.0, NTSC_RATE);
// to_rational is the exact seconds value (3 frames = 1001/10000 s).
assert_eq!(range.duration().to_rational(), Rational::new(1001, 10000));
let available = clip.media_reference().unwrap().as_external_reference().unwrap().available_range().unwrap();
assert_time(available.duration(), 1000.0, NTSC_RATE);
}
#[test]
fn error_corrupt_xml() {
// Mismatched closing tag. quick-xml reports it as an ill-formed
// document (Xml); the crate also classifies structural errors as
// Malformed — either is a hard error.
let bad = "<fcpxml version=\"1.10\"><resources></fcpxml>";
assert!(matches!(
oakotio::from_fcpxml_string(bad),
Err(oakotio::FcpxmlError::Xml(_)) | Err(oakotio::FcpxmlError::Malformed(_))
));
// Unclosed root element.
let bad = "<fcpxml version=\"1.10\">";
assert!(matches!(
oakotio::from_fcpxml_string(bad),
Err(oakotio::FcpxmlError::Malformed(_))
));
// Truly broken markup.
let bad = "<fcpxml version=\"1.10\"><resources><format";
assert!(matches!(
oakotio::from_fcpxml_string(bad),
Err(oakotio::FcpxmlError::Xml(_))
));
// Wrong root element.
let bad = "<root/>";
assert!(matches!(
oakotio::from_fcpxml_string(bad),
Err(oakotio::FcpxmlError::Malformed(_))
));
}
#[test]
fn error_missing_resources() {
// A sequence referencing a format that does not exist is an error.
let doc = r#"<fcpxml version="1.10">
<resources><format id="f1" frameDuration="100/3000s"/></resources>
<library><event name="e"><project name="p">
<sequence format="nope">
<spine/>
</sequence>
</project></event></library>
</fcpxml>"#;
match oakotio::from_fcpxml_string(doc) {
Err(oakotio::FcpxmlError::Malformed(msg)) => {
assert!(msg.contains("nope"), "{msg}");
}
other => panic!("expected Malformed error, got {other:?}"),
}
// A sequence without any format is an error too.
let doc = r#"<fcpxml version="1.10">
<library><event name="e"><project name="p">
<sequence><spine/></sequence>
</project></event></library>
</fcpxml>"#;
assert!(matches!(
oakotio::from_fcpxml_string(doc),
Err(oakotio::FcpxmlError::Malformed(_))
));
}
#[test]
fn error_unknown_version() {
// Unsupported version.
let doc = "<fcpxml version=\"2.0\"><resources/></fcpxml>";
assert!(matches!(
oakotio::from_fcpxml_string(doc),
Err(oakotio::FcpxmlError::UnsupportedVersion(_))
));
// Missing version.
let doc = "<fcpxml><resources/></fcpxml>";
assert!(matches!(
oakotio::from_fcpxml_string(doc),
Err(oakotio::FcpxmlError::UnsupportedVersion(_))
));
// Garbage version.
let doc = "<fcpxml version=\"bogus\"><resources/></fcpxml>";
assert!(matches!(
oakotio::from_fcpxml_string(doc),
Err(oakotio::FcpxmlError::UnsupportedVersion(_))
));
}
#[test]
fn lenient_unknown_elements() {
let doc = r#"<fcpxml version="1.10">
<resources>
<format id="f1" frameDuration="100/3000s" width="1920" height="1080"/>
<asset id="a1" src="file:///x.mov" format="f1" duration="10s"/>
<weird-resource foo="bar"/>
</resources>
<library>
<event name="E">
<project name="P">
<sequence format="f1" tcStart="0s" tcFormat="NDF">
<spine>
<sync-clip name="MC" offset="0s" duration="300/3000s"/>
<asset-clip name="X" ref="a1" offset="300/3000s" duration="300/3000s" start="0s" future-attr="42"/>
<bogus-element/>
<gap name="G" offset="600/3000s" duration="300/3000s"/>
<title name="T" offset="900/3000s" duration="300/3000s"/>
<asset-clip name="NoMedia" offset="1200/3000s" duration="300/3000s" start="0s"/>
</spine>
</sequence>
</project>
</event>
</library>
</fcpxml>"#;
let timelines = oakotio::from_fcpxml_string(doc).expect("lenient document parses");
assert_eq!(timelines.len(), 1);
let timeline = &timelines[0];
assert_eq!(timeline.name(), "P");
let track = timeline.tracks().children()[0].as_track().unwrap();
// sync-clip, X, G, title, NoMedia (the bogus element is skipped).
assert_eq!(track.children().len(), 5);
let mc = track.children()[0].as_gap().expect("sync-clip -> gap");
assert_eq!(mc.source_range().unwrap().duration().to_rational(), Rational::new(1, 10));
let x = track.children()[1].as_clip().expect("asset-clip");
assert_eq!(x.name(), "X");
match x.media_reference().unwrap() {
MediaReference::ExternalReference(e) => assert_eq!(e.target_url(), "file:///x.mov"),
other => panic!("expected ExternalReference, got {}", other.schema_name()),
}
let gap = track.children()[2].as_gap().expect("named gap");
assert_eq!(gap.name(), "G");
let title = track.children()[3].as_gap().expect("title -> gap");
assert_eq!(title.source_range().unwrap().duration().to_rational(), Rational::new(1, 10));
let no_media = track.children()[4].as_clip().expect("clip without ref");
assert!(matches!(
no_media.media_reference().unwrap(),
MediaReference::MissingReference(_)
));
}
#[test]
fn multiple_timelines_export_and_import() {
let mut a = Timeline::new("Sequence One");
let mut track_a = Track::new("Video");
let mut clip = Clip::new("c1");
clip.set_source_range(oakotio::TimeRange::new(
RationalTime::new(0.0, 24.0),
RationalTime::new(48.0, 24.0),
));
clip.set_media_reference(MediaReference::ExternalReference(ExternalReference::new(
"file:///tmp/one.mov",
None,
)));
track_a.append_child(Composable::Clip(clip));
a.tracks_mut().append_child(Composable::Track(track_a));
let mut b = Timeline::new("Sequence Two");
let mut track_b = Track::new("Audio");
let mut clip2 = Clip::new("c2");
clip2.set_source_range(oakotio::TimeRange::new(
RationalTime::new(0.0, 48000.0),
RationalTime::new(96000.0, 48000.0),
));
clip2.set_media_reference(MediaReference::ExternalReference(ExternalReference::new(
"file:///tmp/two.wav",
None,
)));
track_b.append_child(Composable::Clip(clip2));
b.tracks_mut().append_child(Composable::Track(track_b));
let xml = oakotio::to_fcpxml_string(&[a.clone(), b.clone()]).expect("export two timelines");
assert!(xml.contains("<event"), "{xml}");
assert!(xml.contains("name=\"Sequence One\""), "{xml}");
assert!(xml.contains("name=\"Sequence Two\""), "{xml}");
let reparsed = oakotio::from_fcpxml_string(&xml).expect("reparse");
assert_eq!(reparsed.len(), 2);
assert_eq!(reparsed[0].name(), "Sequence One");
assert_eq!(reparsed[1].name(), "Sequence Two");
// A clip without an available_range gains one on export (the asset
// duration defaults to the clip length), so compare the round-trip
// parts the model carries.
let clip_rt = reparsed[0].tracks().children()[0]
.as_track()
.unwrap()
.children()[0]
.as_clip()
.unwrap();
assert_eq!(clip_rt.name(), "c1");
let range = clip_rt.source_range().unwrap();
assert_time(range.duration(), 48.0, 24.0);
let available = clip_rt
.media_reference()
.unwrap()
.as_external_reference()
.unwrap()
.available_range()
.unwrap();
// The synthesized asset duration equals the clip length (48 frames
// at 24 fps = 2 s).
assert_time(available.duration(), 48.0, 24.0);
assert_eq!(available.duration().to_rational(), Rational::new(2, 1));
}
#[test]
fn file_round_trip() {
let path = std::env::temp_dir().join(format!("oakotio_fcpxml_{}.fcpxml", std::process::id()));
oakotio::to_fcpxml_file(&[parse_synthetic()], &path).expect("write fcpxml file");
let timelines = oakotio::from_fcpxml_file(&path).expect("read fcpxml file");
assert_eq!(timelines.len(), 1);
assert_eq!(timelines[0].name(), "My Project");
std::fs::remove_file(&path).ok();
}
#[test]
fn empty_timeline_list() {
let xml = oakotio::to_fcpxml_string(&[]).expect("export empty list");
assert!(xml.contains("<fcpxml version=\"1.10\">"), "{xml}");
let timelines = oakotio::from_fcpxml_string(&xml).expect("reparse");
assert!(timelines.is_empty());
}
-73
View File
@@ -1,73 +0,0 @@
// 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/>.
//! Byte-for-byte round-trip tests against the golden files captured from
//! the opentimelineio C++ writer: parse, re-serialize, and require the
//! output to be identical (4-space indent, `": "` separators, inline empty
//! containers, shortest floats, no trailing newline).
use std::fs;
use std::path::PathBuf;
fn read_golden(name: &str) -> String {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/data")
.join(name);
fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path:?}: {e}"))
}
fn assert_round_trip(name: &str) {
let text = read_golden(name);
let doc = oakotio::from_json_string(&text).unwrap_or_else(|e| panic!("parse {name}: {e}"));
let out = doc
.to_json_string()
.unwrap_or_else(|e| panic!("serialize {name}: {e}"));
assert_eq!(out, text, "round-trip mismatch for {name}");
}
#[test]
fn golden_timeline_round_trips() {
assert_round_trip("golden_timeline.json");
}
#[test]
fn golden_collection_round_trips() {
assert_round_trip("golden_collection.json");
}
#[test]
fn golden_typed_transition_round_trips() {
assert_round_trip("golden_typed_transition.json");
}
#[test]
fn floatfmt_round_trips() {
assert_round_trip("floatfmt.json");
}
#[test]
fn golden_timeline_parses_as_timeline_root() {
let doc = oakotio::from_json_string(&read_golden("golden_timeline.json")).unwrap();
assert_eq!(doc.schema_name(), "Timeline");
assert!(doc.as_timeline().is_some());
}
#[test]
fn golden_collection_parses_as_collection_root() {
let doc = oakotio::from_json_string(&read_golden("golden_collection.json")).unwrap();
assert_eq!(doc.schema_name(), "SerializableCollection");
assert!(doc.as_collection().is_some());
}
-109
View File
@@ -1,109 +0,0 @@
// 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/>.
//! Save-side parity: rebuild `golden_timeline.json` through the public
//! builder API and require the serialized bytes to match the golden file
//! byte for byte. This proves the writer (not just the reader) reproduces
//! the opentimelineio C++ output.
use oakotio::{
Clip, Composable, ExternalReference, Gap, MediaReference, RationalTime, TimeRange, Timeline,
Track, Transition,
};
fn build_golden_timeline() -> Timeline {
let mut timeline = Timeline::new("My Sequence");
let mut video = Track::new("Video");
let mut clip = Clip::new("My Sequence Clip");
clip.set_source_range(TimeRange::new(
RationalTime::new(0.0, 24.0),
RationalTime::new(1152.0, 24.0),
));
clip.set_media_reference(MediaReference::ExternalReference(ExternalReference::new(
"file:///tmp/My Sequence.mp4",
Some(TimeRange::new(
RationalTime::new(0.0, 25.0),
RationalTime::new(100.0, 25.0),
)),
)));
video.append_child(Composable::Clip(clip));
video.append_child(Composable::Gap(Gap::new(
TimeRange::new(RationalTime::new(0.0, 24.0), RationalTime::new(576.0, 24.0)),
"My Sequence Gap",
)));
let mut transition = Transition::new("My Sequence Transition");
transition.set_in_offset(RationalTime::new(12.0, 24.0));
transition.set_out_offset(RationalTime::new(12.0, 24.0));
video.append_child(Composable::Transition(transition));
let mut audio = Track::new("Audio");
let mut audio_clip = Clip::new("My Sequence Audio");
audio_clip.set_source_range(TimeRange::new(
RationalTime::new(0.0, 24.0),
RationalTime::new(1152.0, 24.0),
));
audio_clip.set_media_reference(MediaReference::ExternalReference(ExternalReference::new(
"file:///tmp/My Sequence.wav",
Some(TimeRange::new(
RationalTime::new(0.0, 48000.0),
RationalTime::new(0.0, 48000.0),
)),
)));
audio.append_child(Composable::Clip(audio_clip));
audio.append_child(Composable::Gap(Gap::new(
TimeRange::new(RationalTime::new(1152.0, 24.0), RationalTime::new(12.0, 1.0)),
"",
)));
timeline.tracks_mut().append_child(Composable::Track(video));
timeline.tracks_mut().append_child(Composable::Track(audio));
timeline
}
#[test]
fn saved_timeline_matches_golden_bytes() {
let golden = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/data/golden_timeline.json"
))
.expect("read golden_timeline.json");
let built = build_golden_timeline();
let out = built.to_json_string().expect("serialize built timeline");
assert_eq!(out, golden);
}
#[test]
fn saved_timeline_reparses_identically() {
// The builder output must also parse back into an equivalent graph.
let built = build_golden_timeline();
let out = built.to_json_string().unwrap();
let reparsed = oakotio::from_json_string(&out).unwrap();
assert_eq!(
reparsed.as_timeline().unwrap().name(),
"My Sequence",
"reparsed timeline keeps the name"
);
assert_eq!(reparsed.as_timeline().unwrap().tracks().children().len(), 2);
}
-125
View File
@@ -1,125 +0,0 @@
// 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/>.
//! Semantic tests over `golden_timeline.json`: walk the parsed object graph
//! and assert the values Oak's C++ load task depends on.
use oakotio::{Clip, ExternalReference, MediaReference, Serializable, Timeline};
fn golden_timeline() -> Timeline {
let text = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/data/golden_timeline.json"
))
.expect("read golden_timeline.json");
match oakotio::from_json_string(&text).expect("parse golden_timeline.json") {
Serializable::Timeline(t) => t,
other => panic!("expected Timeline root, got {}", other.schema_name()),
}
}
#[test]
fn root_timeline_fields() {
let tl = golden_timeline();
assert_eq!(tl.name(), "My Sequence");
assert_eq!(tl.global_start_time(), None);
assert_eq!(tl.tracks().children().len(), 2);
}
#[test]
fn video_track_contents() {
let tl = golden_timeline();
let video = &tl.tracks().children()[0];
let track = video
.as_track()
.expect("first child of tracks stack is a Track");
assert_eq!(track.kind(), "Video");
assert_eq!(track.children().len(), 3);
// Clip -> Gap -> Transition, in order.
let clip = track.children()[0]
.as_clip()
.expect("video track child 0 is a Clip");
assert_eq!(clip.name(), "My Sequence Clip");
let range = clip.source_range().expect("clip has a source_range");
assert_eq!((range.duration().value(), range.duration().rate()), (1152.0, 24.0));
assert_eq!((range.start_time().value(), range.start_time().rate()), (0.0, 24.0));
let gap = track.children()[1]
.as_gap()
.expect("video track child 1 is a Gap");
assert_eq!(gap.name(), "My Sequence Gap");
let grange = gap.source_range().expect("gap has a source_range");
assert_eq!((grange.duration().value(), grange.duration().rate()), (576.0, 24.0));
let trans = track.children()[2]
.as_transition()
.expect("video track child 2 is a Transition");
assert_eq!(trans.name(), "My Sequence Transition");
assert_eq!((trans.in_offset().value(), trans.in_offset().rate()), (12.0, 24.0));
assert_eq!((trans.out_offset().value(), trans.out_offset().rate()), (12.0, 24.0));
assert_eq!(trans.transition_type(), "");
}
#[test]
fn video_clip_media_reference() {
let tl = golden_timeline();
let clip = &tl.tracks().children()[0].as_track().unwrap().children()[0];
let clip = clip.as_clip().unwrap();
let reference = clip.media_reference().expect("clip resolves a media reference");
assert_eq!(reference.schema_name(), "ExternalReference");
let external: &ExternalReference = match reference {
MediaReference::ExternalReference(e) => e,
other => panic!("expected ExternalReference, got {}", other.schema_name()),
};
assert_eq!(external.target_url(), "file:///tmp/My Sequence.mp4");
let available = external.available_range().expect("available_range is set");
assert_eq!((available.duration().value(), available.duration().rate()), (100.0, 25.0));
assert_eq!((available.start_time().value(), available.start_time().rate()), (0.0, 25.0));
}
#[test]
fn audio_track_contents() {
let tl = golden_timeline();
let audio = &tl.tracks().children()[1];
let track = audio
.as_track()
.expect("second child of tracks stack is a Track");
assert_eq!(track.kind(), "Audio");
assert_eq!(track.children().len(), 2);
let clip: &Clip = track.children()[0]
.as_clip()
.expect("audio track child 0 is a Clip");
assert_eq!(clip.name(), "My Sequence Audio");
let external: &ExternalReference = match clip.media_reference().unwrap() {
MediaReference::ExternalReference(e) => e,
other => panic!("expected ExternalReference, got {}", other.schema_name()),
};
assert_eq!(external.target_url(), "file:///tmp/My Sequence.wav");
let available = external.available_range().unwrap();
assert_eq!((available.duration().value(), available.duration().rate()), (0.0, 48000.0));
assert_eq!((available.start_time().value(), available.start_time().rate()), (0.0, 48000.0));
let gap = track.children()[1]
.as_gap()
.expect("audio track child 1 is a Gap");
assert_eq!(gap.name(), "");
let grange = gap.source_range().unwrap();
assert_eq!((grange.duration().value(), grange.duration().rate()), (12.0, 1.0));
assert_eq!((grange.start_time().value(), grange.start_time().rate()), (1152.0, 24.0));
}
-6
View File
@@ -1,6 +0,0 @@
add_subdirectory(src)
add_subdirectory(c_api)
if(BUILD_TESTS)
add_subdirectory(tests)
endif()
-73
View File
@@ -1,73 +0,0 @@
# oakcodec 中间态与行为变化备忘(M5)
## 中间态(等待后续里程碑收口)
1. **Task 回调注册**M8 收口):conform/proxy 的后台任务经
`include/codec/task.h` 的全局提交回调(`oakcodec_set_task_submit_cb`)。
未注册时:conform 查询返回 `k_conform_unavailable`proxy 保持
`k_proxy_missing`,不崩溃不阻塞。注册语义为同步提交(回调内完成或
排队后立即返回);`SubmitTask` 持锁调回调,回调内不可重入注册函数。
conform/proxy 任务的 working→finished 改名生命周期整体移交 M8 oaktask。
2. **Config**config 波次已收口):`ProxyManager::proxy_params_from_config()`
现经 `oakcommon_config_*` C ABI 读取 ProxyWidth/ProxyHeight/ProxyDivider/
ProxyCRF/ProxyPreset/ProxyIncludeAudioProxyParams 成员默认值兼作
getter fallback(与 oakcommon 编译期默认值一致:1280x720/div1/crf23/
veryfast/含音频)。
3. **纹理路径功能回退**oakrender 增补 shader-blit C API 后可恢复):
oakrender C API 无通用 shader-blitFFmpegDecoder 的 yuv2rgb GLSL 路径与
去隔行 shader 路径已删除;YUV 帧改在 CPU 上 swscale 转 RGBA 后
`oakrender_display_texture_upload`(功能保留但更慢;去隔行在纹理路径
丢失,CPU 帧路径本就不做去隔行)。Texture 零拷贝持有 hw frame 一并删除。
4. **FootageDescription 为 codec 内部结构**src/codec/src/footagedescription.h):
oaknode C API 无对应物;未实现探针缓存 XML load/save 与
`get_type_of_stream()`oaknode `Track::Type` 映射),oaknode footage
侧需要时再补。
5. **RenderMode**oakrender C API 无对应物,codec 本地 enum
decoder.hk_offline=0/k_online=1,值对齐 engine/render/rendermodes.h)。
6. **无 adapter 层**2026-08 第二轮拍板):codec 内部跨模块调用全部直调
`oakcommon_*` / `oakrender_*` C 函数,句柄(OakVideoParams/
OakColorTransform/OakCancelAtom/OakSubtitleParams)就地按值管理计数;
只有真正多处重复的转换保留文件内 static 小函数(如
fill_render_params、cancel_atom_is_cancelled)。早期的一版
src/codec/src/adapter/ 包装类已删除。
7. **XmlStreamWriter/Reader**:照 DEQT.md 用 oakcommon 的 C++ 类
src/common/src/xmlutils.h,与 oaknode/oakrender 的实践一致),未走
C API —— 决策 7 的唯一例外,记录在案。
## 行为变化(相对 Qt 版)
- Decoder 的 `index_progress` 信号 → `std::function<void(double)>`
回调(`set_index_progress_callback`);conform_ready/proxy_ready/
proxy_finished 信号删除(通知归 facade/task 系统)。
- ConformManager 无状态化:`conforming_` 列表与完成 slot 删除;
`get_conform_state` 去掉 `decoder_id` 参数;等待语义改为同步提交后
重查文件系统。
- `Encoder::write_subtitle(const SubtitleBlock*)`
`write_subtitle(const char *text, double in_seconds, double out_seconds)`
注意原实现传的是 `sub_block->length()`(时长),新调用方传 out=in+length。
- `EncodingParams::generate_matrix` 返回 `std::array<float,16>`(行主序),
原 QMatrix4x4`load/save` 的 QIODevice 版本变
`load(const std::string&)`/`save_to_string()`,预设 XML 不再含声明与
缩进(紧凑 XML,元素/属性名与顺序不变);`video_opts_` 的 XML 顺序
由 QHash 无序变为字典序。保留了 load_v1 不赋 custom_range_ 的原 bug。
- `PlanarFileDevice::open``std::vector<std::string>` + 类内
`OpenMode` 枚举(k_read_only/k_write_only),FILE* 实现。
- FFmpegDecoder 无后台 QThread(现 engine 版本已是同步 retrieve 循环)。
- 音频 decodeC API):需要 conform 的媒体在无 task 注册方时返回
`OAKCODEC_E_STATE`(不产生后台 conform)。
- `oakcodec_audio_stream_info.duration_ts` 恒 0AudioParams 不带时长)。
## 符号可见性
oakcodec 以 `-fvisibility=hidden` 编译,仅导出 `OAKCODEC_API` 标记的
C 函数(include/codec/error.h 定义宏)。必须如此:codec 内部 adapter
类(olive::VideoParams 等)与 oakcommon/oakrender 内同名弱符号会
interpose(曾在 oakcommon_videoparams_init_with_time_base 内部把
VideoParams::width() 绑进 liboakcodec 导致崩溃)。
## oakcommon 侧修复(随 M5 落地)
- `frame_to_buffer`/`buffer_to_frame` 移入 codecoiioframebridge.h
内部 C++ 函数),oakcommon 的 OIIO 映射函数保留。
- 修复 `src/common/c_api/videoparams.cpp``convert_to_olive_format`
switch 缺 break 穿透 bugU8 穿透到 f32bytes_per_pixel 返回 16)。
-8
View File
@@ -1,8 +0,0 @@
target_sources(oakcodec PRIVATE
conform.cpp
decoder.cpp
encoder.cpp
format.cpp
frame.cpp
proxy.cpp
)
-141
View File
@@ -1,141 +0,0 @@
/***
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/>.
***/
#include "codec/conform.h"
#include <algorithm>
#include <cstring>
#include <string>
#include <vector>
#include "conformmanager.h"
#include "decoder.h"
namespace
{
int string_out(const std::string &s, char *buf, int buf_size)
{
int need = static_cast<int>(s.size()) + 1;
if (buf && buf_size > 0) {
int n = std::min(static_cast<int>(s.size()), buf_size - 1);
memcpy(buf, s.data(), n);
buf[n] = '\0';
}
return need;
}
olive::core::AudioParams to_native_params(int sample_rate,
uint64_t channel_layout,
int sample_format)
{
return olive::core::AudioParams(
sample_rate, channel_layout,
static_cast<olive::core::SampleFormat::Format>(sample_format));
}
olive::Decoder::CodecStream to_native_stream(const char *source_filename,
int stream_index)
{
return olive::Decoder::CodecStream(
source_filename ? source_filename : "", stream_index, nullptr);
}
bool conform_args_valid(const char *cache_path, const char *source_filename)
{
return cache_path && *cache_path && source_filename && *source_filename;
}
} // namespace
int oakcodec_conform_create_instance(void)
{
olive::ConformManager::create_instance();
return OAKCODEC_OK;
}
int oakcodec_conform_destroy_instance(void)
{
olive::ConformManager::destroy_instance();
return OAKCODEC_OK;
}
int oakcodec_conform_get_state(const char *cache_path,
const char *source_filename, int stream_index,
int sample_rate, uint64_t channel_layout,
int sample_format, int wait)
{
if (!conform_args_valid(cache_path, source_filename))
return OAKCODEC_E_INVALID;
if (!olive::ConformManager::instance())
return OAKCODEC_E_STATE;
olive::ConformManager::Conform c =
olive::ConformManager::instance()->get_conform_state(
cache_path, to_native_stream(source_filename, stream_index),
to_native_params(sample_rate, channel_layout, sample_format),
wait != 0);
switch (c.state) {
case olive::ConformManager::k_conform_exists:
return OAKCODEC_CONFORM_EXISTS;
case olive::ConformManager::k_conform_generating:
return OAKCODEC_CONFORM_GENERATING;
case olive::ConformManager::k_conform_unavailable:
default:
return OAKCODEC_CONFORM_UNAVAILABLE;
}
}
int oakcodec_conform_filename_count(const char *cache_path,
const char *source_filename,
int stream_index, int sample_rate,
uint64_t channel_layout, int sample_format)
{
if (!conform_args_valid(cache_path, source_filename))
return 0;
// Pure path computation: never submits work.
return static_cast<int>(olive::ConformManager::get_conformed_filename(
cache_path,
to_native_stream(source_filename, stream_index),
to_native_params(sample_rate, channel_layout,
sample_format))
.size());
}
int oakcodec_conform_filename_at(const char *cache_path,
const char *source_filename,
int stream_index, int sample_rate,
uint64_t channel_layout, int sample_format,
int index, char *buf, int buf_size)
{
if (!conform_args_valid(cache_path, source_filename))
return OAKCODEC_E_INVALID;
std::vector<std::string> filenames =
olive::ConformManager::get_conformed_filename(
cache_path, to_native_stream(source_filename, stream_index),
to_native_params(sample_rate, channel_layout, sample_format));
if (index < 0 || index >= static_cast<int>(filenames.size()))
return OAKCODEC_E_NOT_FOUND;
return string_out(filenames[index], buf, buf_size);
}
-457
View File
@@ -1,457 +0,0 @@
/***
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/>.
***/
#include "codec/decoder.h"
#include <algorithm>
#include <cstring>
#include <string>
#include <sys/stat.h>
#include "common/loopmode.h"
#include "render/cancelatom.h"
#include "decoder.h"
#include "footagedescription.h"
#include "frame.h"
#include "refcounted.h"
namespace
{
struct ProbeBox {
std::string decoder_name;
olive::FootageDescription desc;
};
struct DecoderBox {
olive::DecoderPtr decoder;
std::string last_error;
std::string open_filename;
int open_stream = -1;
bool open = false;
};
ProbeBox *probe_box(void *ctx)
{
return oakcodec::handle_impl<ProbeBox>(ctx);
}
DecoderBox *decoder_box(void *ctx)
{
return oakcodec::handle_impl<DecoderBox>(ctx);
}
thread_local std::string g_probe_error;
int string_out(const std::string &s, char *buf, int buf_size)
{
int need = static_cast<int>(s.size()) + 1;
if (buf && buf_size > 0) {
int n = std::min(static_cast<int>(s.size()), buf_size - 1);
memcpy(buf, s.data(), n);
buf[n] = '\0';
}
return need;
}
bool file_exists(const char *filename)
{
struct stat st;
return filename && stat(filename, &st) == 0;
}
/**
* @brief Probe with every available decoder, returning the first valid
* description (and filling `name`).
*/
bool probe_with_any_decoder(const char *filename, std::string *name,
olive::FootageDescription *out)
{
for (const olive::DecoderPtr &d :
olive::Decoder::receive_list_of_all_decoders()) {
olive::FootageDescription desc = d->probe(filename, nullptr);
if (desc.is_valid()) {
*name = desc.decoder();
*out = desc;
return true;
}
}
return false;
}
void fill_video_info(const OakVideoParams &vp,
oakcodec_video_stream_info *out)
{
*out = {};
oakcommon_videoparams_get_stream_index(vp, &out->stream_index);
oakcommon_videoparams_get_width(vp, &out->width);
oakcommon_videoparams_get_height(vp, &out->height);
int fr_num = 0, fr_den = 0;
oakcommon_videoparams_get_frame_rate(vp, &fr_num, &fr_den);
out->frame_rate_num = fr_num;
out->frame_rate_den = fr_den;
int tb_num = 0, tb_den = 0;
oakcommon_videoparams_get_time_base(vp, &tb_num, &tb_den);
out->time_base_num = tb_num;
out->time_base_den = tb_den;
oakcommon_videoparams_get_duration(vp, &out->duration_ts);
oakcommon_videoparams_get_format(vp, &out->format);
oakcommon_videoparams_get_channel_count(vp, &out->channel_count);
oakcommon_videoparams_get_color_primaries(vp, &out->color_primaries);
oakcommon_videoparams_get_color_transfer(vp, &out->color_trc);
int interlacing = OAKCOMMON_VIDEO_INTERLACE_NONE;
oakcommon_videoparams_get_interlacing(vp, &interlacing);
out->interlaced = interlacing != OAKCOMMON_VIDEO_INTERLACE_NONE;
}
void fill_audio_info(const olive::AudioParams &ap,
oakcodec_audio_stream_info *out)
{
*out = {};
out->stream_index = ap.stream_index();
out->sample_rate = ap.sample_rate();
out->channel_layout = ap.channel_layout();
out->channel_count = ap.channel_count();
olive::Rational tb = ap.time_base();
out->time_base_num = tb.numerator();
out->time_base_den = tb.denominator();
// AudioParams carries no duration; duration_ts stays 0 (unknown).
}
} // namespace
/* ---- Probe ---------------------------------------------------------------- */
OakDecoder oakcodec_decoder_probe(const char *filename)
{
if (!filename || !*filename) {
g_probe_error = "no filename given";
return OakDecoder{};
}
if (!file_exists(filename)) {
g_probe_error = std::string("file not found: ") + filename;
return OakDecoder{};
}
OakDecoder h = oakcodec::make_handle_in_place<OakDecoder, ProbeBox>();
ProbeBox *b = probe_box(h.ctx);
if (!b) {
g_probe_error = "out of memory";
return OakDecoder{};
}
if (!probe_with_any_decoder(filename, &b->decoder_name, &b->desc)) {
g_probe_error =
std::string("no decoder recognizes this file: ") + filename;
oakcodec_decoder_free(&h);
return OakDecoder{};
}
g_probe_error.clear();
return h;
}
int oakcodec_probe_last_error(char *buf, int buf_size)
{
return string_out(g_probe_error, buf, buf_size);
}
int oakcodec_decoder_probe_decoder_name(OakDecoder probe, char *buf,
int buf_size)
{
ProbeBox *b = probe_box(probe.ctx);
if (!b)
return OAKCODEC_E_INVALID;
return string_out(b->decoder_name, buf, buf_size);
}
int oakcodec_decoder_probe_video_stream_count(OakDecoder probe)
{
ProbeBox *b = probe_box(probe.ctx);
if (!b)
return 0;
return static_cast<int>(b->desc.get_video_streams().size());
}
int oakcodec_decoder_probe_audio_stream_count(OakDecoder probe)
{
ProbeBox *b = probe_box(probe.ctx);
if (!b)
return 0;
return static_cast<int>(b->desc.get_audio_streams().size());
}
int oakcodec_decoder_probe_subtitle_stream_count(OakDecoder probe)
{
ProbeBox *b = probe_box(probe.ctx);
if (!b)
return 0;
return static_cast<int>(b->desc.get_subtitle_streams().size());
}
int oakcodec_decoder_probe_get_video_stream(OakDecoder probe, int index,
oakcodec_video_stream_info *out)
{
ProbeBox *b = probe_box(probe.ctx);
if (!b || !out)
return OAKCODEC_E_INVALID;
const auto &streams = b->desc.get_video_streams();
if (index < 0 || index >= static_cast<int>(streams.size()))
return OAKCODEC_E_NOT_FOUND;
fill_video_info(streams[static_cast<size_t>(index)], out);
return OAKCODEC_OK;
}
int oakcodec_decoder_probe_get_audio_stream(OakDecoder probe, int index,
oakcodec_audio_stream_info *out)
{
ProbeBox *b = probe_box(probe.ctx);
if (!b || !out)
return OAKCODEC_E_INVALID;
const auto &streams = b->desc.get_audio_streams();
if (index < 0 || index >= static_cast<int>(streams.size()))
return OAKCODEC_E_NOT_FOUND;
fill_audio_info(streams[static_cast<size_t>(index)], out);
return OAKCODEC_OK;
}
/* ---- Decode session -------------------------------------------------------- */
OakDecoder oakcodec_decoder_init(void)
{
return oakcodec::make_handle_in_place<OakDecoder, DecoderBox>();
}
void oakcodec_decoder_free(OakDecoder *decoder)
{
oakcodec::free_handle(decoder);
}
int oakcodec_decoder_open(OakDecoder decoder, const char *filename,
int stream_index)
{
DecoderBox *b = decoder_box(decoder.ctx);
if (!b || !filename || stream_index < 0)
return OAKCODEC_E_INVALID;
if (b->open && b->decoder) {
if (b->open_filename == filename && b->open_stream == stream_index)
return OAKCODEC_OK; // already open on this stream
b->decoder->close();
b->open = false;
}
if (!file_exists(filename)) {
b->last_error = std::string("file not found: ") + filename;
return OAKCODEC_E_NOT_FOUND;
}
std::string decoder_name;
olive::FootageDescription desc;
if (!probe_with_any_decoder(filename, &decoder_name, &desc)) {
b->last_error =
std::string("no decoder recognizes this file: ") + filename;
return OAKCODEC_E_FAILED;
}
b->decoder = olive::Decoder::create_from_id(decoder_name);
if (!b->decoder) {
b->last_error = std::string("failed to create decoder: ") + decoder_name;
return OAKCODEC_E_FAILED;
}
if (!b->decoder->open(
olive::Decoder::CodecStream(filename, stream_index, nullptr))) {
b->last_error = "failed to open stream";
b->decoder.reset();
return OAKCODEC_E_FAILED;
}
b->last_error.clear();
b->open_filename = filename;
b->open_stream = stream_index;
b->open = true;
return OAKCODEC_OK;
}
int oakcodec_decoder_close(OakDecoder decoder)
{
DecoderBox *b = decoder_box(decoder.ctx);
if (!b)
return OAKCODEC_E_INVALID;
if (b->open && b->decoder) {
b->decoder->close();
}
b->open = false;
return OAKCODEC_OK;
}
int oakcodec_decoder_is_open(OakDecoder decoder)
{
DecoderBox *b = decoder_box(decoder.ctx);
return (b && b->open) ? 1 : 0;
}
OakFrame oakcodec_decoder_decode_video(OakDecoder decoder, int numerator,
int denominator)
{
DecoderBox *b = decoder_box(decoder.ctx);
if (!b || !b->open || !b->decoder)
return OakFrame{};
olive::Decoder::RetrieveVideoParams p;
p.time = olive::Rational(numerator, denominator);
olive::FramePtr frame = b->decoder->retrieve_video_frame(p);
if (!frame) {
b->last_error = "failed to decode video frame";
return OakFrame{};
}
return oakcodec::make_handle<OakFrame>(std::move(frame));
}
int oakcodec_decoder_decode_audio(OakDecoder decoder, int in_num, int in_den,
int out_num, int out_den, int sample_rate,
uint64_t channel_layout, float *buf,
int buf_frames)
{
DecoderBox *b = decoder_box(decoder.ctx);
if (!b || (!buf && buf_frames > 0) || buf_frames < 0)
return OAKCODEC_E_INVALID;
if (!b->open || !b->decoder)
return OAKCODEC_E_STATE;
olive::AudioParams params(sample_rate, channel_layout,
olive::core::SampleFormat::f32);
olive::TimeRange range(olive::Rational(in_num, in_den),
olive::Rational(out_num, out_den));
olive::SampleBuffer samples;
olive::Decoder::RetrieveAudioStatus status = b->decoder->retrieve_audio(
samples, range, params, std::string(), OAKCOMMON_LOOP_MODE_OFF,
olive::RenderMode::k_offline);
if (status == olive::Decoder::k_waiting_for_conform) {
// Interim state (pre-M8): conform tasks require a task registrar.
b->last_error =
"audio requires a conform, but no task submit callback is "
"registered (see oakcodec_set_task_submit_cb)";
return OAKCODEC_E_STATE;
}
if (status != olive::Decoder::k_ok || !samples.is_allocated()) {
b->last_error = "failed to decode audio";
return OAKCODEC_E_FAILED;
}
int channels = samples.channel_count();
int available = static_cast<int>(samples.sample_count());
int frames = std::min(available, buf_frames);
for (int c = 0; c < channels; c++) {
const float *src = samples.data(c);
for (int i = 0; i < frames; i++) {
buf[static_cast<size_t>(i) * channels + c] = src[i];
}
}
return frames;
}
int oakcodec_decoder_last_error(OakDecoder decoder, char *buf, int buf_size)
{
DecoderBox *b = decoder_box(decoder.ctx);
if (!b)
return string_out("", buf, buf_size);
return string_out(b->last_error, buf, buf_size);
}
int oakcodec_decoder_conform_audio(OakDecoder decoder,
const char *const *output_filenames, int filename_count,
int sample_rate, uint64_t channel_layout, int sample_format,
OakCancelAtom cancelled)
{
DecoderBox *b = decoder_box(decoder.ctx);
if (!b || (!output_filenames && filename_count > 0) ||
filename_count < 0)
return OAKCODEC_E_INVALID;
if (!b->open || !b->decoder)
return OAKCODEC_E_STATE;
try {
std::vector<std::string> filenames;
filenames.reserve(size_t(filename_count));
for (int i = 0; i < filename_count; i++) {
if (!output_filenames[i])
return OAKCODEC_E_INVALID;
filenames.emplace_back(output_filenames[i]);
}
olive::AudioParams params(
sample_rate, channel_layout,
static_cast<olive::core::SampleFormat::Format>(sample_format));
bool ret = b->decoder->conform_audio(filenames, params,
cancelled.ctx ? &cancelled
: nullptr);
if (!ret) {
int heard = 0;
if (cancelled.ctx &&
oakrender_cancelatom_heard_cancel(cancelled, &heard) ==
OAKRENDER_OK &&
heard) {
return OAKCODEC_E_CANCELLED;
}
return OAKCODEC_E_FAILED;
}
return OAKCODEC_OK;
} catch (...) {
return OAKCODEC_E_FAILED;
}
}
int oakcodec_decoder_get_image_sequence_digit_count(const char *filename)
{
if (!filename)
return OAKCODEC_E_INVALID;
return olive::Decoder::get_image_sequence_digit_count(filename);
}
int64_t oakcodec_decoder_get_image_sequence_index(const char *filename)
{
if (!filename)
return OAKCODEC_E_INVALID;
return olive::Decoder::get_image_sequence_index(filename);
}
int oakcodec_decoder_transform_image_sequence_file_name(
const char *filename, int64_t number, char *buf, int buf_size)
{
if (!filename)
return OAKCODEC_E_INVALID;
return string_out(
olive::Decoder::transform_image_sequence_file_name(filename, number),
buf, buf_size);
}
-313
View File
@@ -1,313 +0,0 @@
/***
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/>.
***/
#include "codec/encoder.h"
#include <algorithm>
#include <cstring>
#include <memory>
#include <vector>
#include "common/colortransform.h"
#include "common/videoparams.h"
#include "encoder.h"
#include "frame.h"
#include "refcounted.h"
namespace
{
constexpr int k_rgba_channel_count = 4;
struct EncoderBox {
std::unique_ptr<olive::Encoder> encoder;
olive::EncodingParams params;
bool open = false;
bool flushed = false;
};
EncoderBox *box(void *ctx)
{
return oakcodec::handle_impl<EncoderBox>(ctx);
}
int string_out(const std::string &s, char *buf, int buf_size)
{
int need = static_cast<int>(s.size()) + 1;
if (buf && buf_size > 0) {
int n = std::min(static_cast<int>(s.size()), buf_size - 1);
memcpy(buf, s.data(), n);
buf[n] = '\0';
}
return need;
}
olive::EncodingParams to_native(const oakcodec_encoding_params *p)
{
using namespace olive;
EncodingParams n;
n.set_filename(p->filename);
n.set_format(static_cast<ExportFormat::Format>(p->format));
if (p->video_enabled) {
OakVideoParams vp = oakcommon_videoparams_init_with_time_base(
p->video_width, p->video_height, p->video_time_base_num,
p->video_time_base_den, p->video_pixel_format,
k_rgba_channel_count, p->video_pixel_aspect_num,
p->video_pixel_aspect_den, p->video_interlacing, 1);
n.enable_video(vp, static_cast<ExportCodec::Codec>(p->video_codec));
oakcommon_videoparams_free(&vp);
n.set_video_bit_rate(p->video_bit_rate);
n.set_video_min_bit_rate(p->video_min_bit_rate);
n.set_video_max_bit_rate(p->video_max_bit_rate);
n.set_video_buffer_size(p->video_buffer_size);
n.set_video_threads(p->video_threads);
n.set_video_pix_fmt(p->video_pix_fmt);
n.set_video_is_image_sequence(p->video_is_image_sequence != 0);
n.set_video_scaling_method(
static_cast<EncodingParams::VideoScalingMethod>(
p->video_scaling_method));
}
if (p->audio_enabled) {
AudioParams ap(p->audio_sample_rate, p->audio_channel_layout,
static_cast<core::SampleFormat::Format>(
p->audio_sample_format));
n.enable_audio(ap, static_cast<ExportCodec::Codec>(p->audio_codec));
n.set_audio_bit_rate(p->audio_bit_rate);
}
if (p->subtitles_enabled) {
if (p->subtitles_are_sidecar) {
n.enable_sidecar_subtitles(
static_cast<ExportFormat::Format>(
p->subtitles_sidecar_format),
static_cast<ExportCodec::Codec>(p->subtitles_codec));
} else {
n.enable_subtitles(
static_cast<ExportCodec::Codec>(p->subtitles_codec));
}
}
if (p->color_transform_output[0] != '\0') {
OakColorTransform ct =
oakcommon_colortransform_init_output(p->color_transform_output);
n.set_color_transform(ct);
oakcommon_colortransform_free(&ct);
}
if (p->export_length_den != 0) {
n.set_export_length(
Rational(p->export_length_num, p->export_length_den));
}
if (p->has_custom_range && p->custom_range_in_den != 0 &&
p->custom_range_out_den != 0) {
n.set_custom_range(TimeRange(
Rational(int(p->custom_range_in_num),
int(p->custom_range_in_den)),
Rational(int(p->custom_range_out_num),
int(p->custom_range_out_den))));
}
return n;
}
} // namespace
OakEncoder oakcodec_encoder_init(const oakcodec_encoding_params *params)
{
if (!params)
return OakEncoder{};
OakEncoder h = oakcodec::make_handle_in_place<OakEncoder, EncoderBox>();
EncoderBox *b = box(h.ctx);
if (!b)
return OakEncoder{};
try {
b->params = to_native(params);
} catch (...) {
oakcodec_encoder_free(&h);
return OakEncoder{};
}
if (!b->params.is_valid()) {
oakcodec_encoder_free(&h);
return OakEncoder{};
}
return h;
}
void oakcodec_encoder_free(OakEncoder *encoder)
{
oakcodec::free_handle(encoder);
}
int oakcodec_encoder_set_video_option(OakEncoder encoder, const char *key,
const char *value)
{
EncoderBox *b = box(encoder.ctx);
if (!b || !key)
return OAKCODEC_E_INVALID;
if (b->open)
return OAKCODEC_E_STATE;
b->params.set_video_option(key, value ? value : "");
return OAKCODEC_OK;
}
int oakcodec_encoder_open(OakEncoder encoder)
{
EncoderBox *b = box(encoder.ctx);
if (!b)
return OAKCODEC_E_INVALID;
if (b->open)
return OAKCODEC_E_STATE;
b->encoder.reset(olive::Encoder::create_from_params(b->params));
if (!b->encoder)
return OAKCODEC_E_FAILED;
if (!b->encoder->open()) {
return OAKCODEC_E_FAILED;
}
b->open = true;
return OAKCODEC_OK;
}
int oakcodec_encoder_write_video(OakEncoder encoder, OakFrame frame)
{
EncoderBox *b = box(encoder.ctx);
if (!b || !frame.ctx)
return OAKCODEC_E_INVALID;
if (!b->open || b->flushed || !b->encoder)
return OAKCODEC_E_STATE;
// OakFrame boxes hold an olive::FramePtr (see c_api/frame.cpp).
auto *fp = oakcodec::handle_impl<olive::FramePtr>(frame.ctx);
if (!fp || !*fp)
return OAKCODEC_E_INVALID;
olive::Frame *f = fp->get();
return b->encoder->write_frame(*fp, f->timestamp()) ? OAKCODEC_OK
: OAKCODEC_E_FAILED;
}
int oakcodec_encoder_write_audio(OakEncoder encoder, const float *samples,
int frame_count)
{
EncoderBox *b = box(encoder.ctx);
if (!b || (!samples && frame_count > 0) || frame_count < 0)
return OAKCODEC_E_INVALID;
if (!b->open || b->flushed || !b->encoder)
return OAKCODEC_E_STATE;
const olive::AudioParams &ap = b->params.audio_params();
int channels = ap.channel_count();
if (channels <= 0)
return OAKCODEC_E_STATE;
// Deinterleave into a planar SampleBuffer.
olive::SampleBuffer buf(ap, static_cast<size_t>(frame_count));
buf.allocate();
std::vector<float> channel_data(static_cast<size_t>(frame_count));
for (int c = 0; c < channels; c++) {
for (int i = 0; i < frame_count; i++) {
channel_data[i] = samples[static_cast<size_t>(i) * channels + c];
}
buf.set(c, channel_data.data(),
static_cast<size_t>(frame_count));
}
return b->encoder->write_audio(buf) ? OAKCODEC_OK : OAKCODEC_E_FAILED;
}
int oakcodec_encoder_write_subtitle(OakEncoder encoder, const char *text,
double in_seconds, double out_seconds)
{
EncoderBox *b = box(encoder.ctx);
if (!b || !text)
return OAKCODEC_E_INVALID;
if (!b->open || b->flushed || !b->encoder)
return OAKCODEC_E_STATE;
return b->encoder->write_subtitle(text, in_seconds, out_seconds)
? OAKCODEC_OK
: OAKCODEC_E_FAILED;
}
int oakcodec_encoder_flush(OakEncoder encoder)
{
EncoderBox *b = box(encoder.ctx);
if (!b)
return OAKCODEC_E_INVALID;
if (!b->open)
return OAKCODEC_E_STATE;
if (b->flushed)
return OAKCODEC_OK;
b->encoder->close();
b->flushed = true;
return OAKCODEC_OK;
}
int oakcodec_encoder_last_error(OakEncoder encoder, char *buf, int buf_size)
{
EncoderBox *b = box(encoder.ctx);
if (!b)
return string_out("", buf, buf_size);
return string_out(b->encoder ? b->encoder->get_error() : std::string(),
buf, buf_size);
}
int oakcodec_encoder_get_desired_pixel_format(OakEncoder encoder)
{
EncoderBox *b = box(encoder.ctx);
if (!b || !b->encoder)
return OAKCODEC_E_INVALID;
return int(b->encoder->get_desired_pixel_format());
}
int oakcodec_export_format_get_extension(int format, char *buf, int buf_size)
{
return string_out(
olive::ExportFormat::get_extension(
static_cast<olive::ExportFormat::Format>(format)),
buf, buf_size);
}
int oakcodec_encoding_generate_matrix(int method, int src_width,
int src_height, int dst_width,
int dst_height, double *out_matrix)
{
if (!out_matrix)
return OAKCODEC_E_INVALID;
std::array<float, 16> matrix = olive::EncodingParams::generate_matrix(
static_cast<olive::EncodingParams::VideoScalingMethod>(method),
src_width, src_height, dst_width, dst_height);
for (int i = 0; i < 16; i++) {
out_matrix[i] = matrix[size_t(i)];
}
return OAKCODEC_OK;
}
-279
View File
@@ -1,279 +0,0 @@
/***
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/>.
***/
#include "codec/format.h"
#include <algorithm>
#include <cstring>
#include <string>
#include <vector>
#include "encoder.h"
#include "ffmpeg/ffmpegencoder.h"
namespace
{
bool valid_format(int format)
{
return format >= 0 && format < olive::ExportFormat::k_format_count;
}
bool valid_codec(int codec)
{
return codec >= 0 && codec < olive::ExportCodec::k_codec_count;
}
// buf/size convention: returns the would-be length INCLUDING the NUL
// (include/codec/error.h), unlike the facade which excludes it.
int string_out(const std::string &s, char *buf, int buf_size)
{
int need = static_cast<int>(s.size()) + 1;
if (buf && buf_size > 0) {
int n = std::min(static_cast<int>(s.size()), buf_size - 1);
memcpy(buf, s.data(), n);
buf[n] = '\0';
}
return need;
}
} // namespace
/* ---- Container format / codec metadata ---------------------------------- */
int oakcodec_encoding_format_count(void)
{
return olive::ExportFormat::k_format_count;
}
int oakcodec_encoding_format_name(int format, char *buf, int buf_size)
{
if (!valid_format(format)) {
return OAKCODEC_E_INVALID;
}
return string_out(
olive::ExportFormat::get_name(olive::ExportFormat::Format(format)), buf,
buf_size);
}
int oakcodec_encoding_format_extension(int format, char *buf, int buf_size)
{
if (!valid_format(format)) {
return OAKCODEC_E_INVALID;
}
return string_out(
olive::ExportFormat::get_extension(olive::ExportFormat::Format(format)),
buf, buf_size);
}
int oakcodec_encoding_format_video_codec_count(int format)
{
if (!valid_format(format)) {
return OAKCODEC_E_INVALID;
}
return int(olive::ExportFormat::get_video_codecs(
olive::ExportFormat::Format(format))
.size());
}
int oakcodec_encoding_format_video_codec_at(int format, int index)
{
if (!valid_format(format)) {
return OAKCODEC_E_INVALID;
}
const auto l =
olive::ExportFormat::get_video_codecs(olive::ExportFormat::Format(format));
if (index < 0 || index >= int(l.size())) {
return OAKCODEC_E_NOT_FOUND;
}
return int(l[size_t(index)]);
}
int oakcodec_encoding_format_audio_codec_count(int format)
{
if (!valid_format(format)) {
return OAKCODEC_E_INVALID;
}
return int(olive::ExportFormat::get_audio_codecs(
olive::ExportFormat::Format(format))
.size());
}
int oakcodec_encoding_format_audio_codec_at(int format, int index)
{
if (!valid_format(format)) {
return OAKCODEC_E_INVALID;
}
const auto l =
olive::ExportFormat::get_audio_codecs(olive::ExportFormat::Format(format));
if (index < 0 || index >= int(l.size())) {
return OAKCODEC_E_NOT_FOUND;
}
return int(l[size_t(index)]);
}
int oakcodec_encoding_format_subtitle_codec_count(int format)
{
if (!valid_format(format)) {
return OAKCODEC_E_INVALID;
}
return int(olive::ExportFormat::get_subtitle_codecs(
olive::ExportFormat::Format(format))
.size());
}
int oakcodec_encoding_format_subtitle_codec_at(int format, int index)
{
if (!valid_format(format)) {
return OAKCODEC_E_INVALID;
}
const auto l = olive::ExportFormat::get_subtitle_codecs(
olive::ExportFormat::Format(format));
if (index < 0 || index >= int(l.size())) {
return OAKCODEC_E_NOT_FOUND;
}
return int(l[size_t(index)]);
}
int oakcodec_encoding_codec_name(int codec, char *buf, int buf_size)
{
if (!valid_codec(codec)) {
return OAKCODEC_E_INVALID;
}
return string_out(
olive::ExportCodec::get_codec_name(olive::ExportCodec::Codec(codec)), buf,
buf_size);
}
int oakcodec_encoding_codec_is_still_image(int codec)
{
if (!valid_codec(codec)) {
return 0;
}
return olive::ExportCodec::is_codec_a_still_image(
olive::ExportCodec::Codec(codec)) ?
1 :
0;
}
int oakcodec_encoding_codec_is_lossless(int codec)
{
if (!valid_codec(codec)) {
return 0;
}
return olive::ExportCodec::is_codec_lossless(olive::ExportCodec::Codec(codec)) ?
1 :
0;
}
int oakcodec_encoding_pix_fmt_count(int format, int codec)
{
if (!valid_format(format) || !valid_codec(codec)) {
return OAKCODEC_E_INVALID;
}
return int(olive::ExportFormat::get_pixel_formats_for_codec(
olive::ExportFormat::Format(format),
olive::ExportCodec::Codec(codec))
.size());
}
int oakcodec_encoding_pix_fmt_at(int format, int codec, int index, char *buf,
int buf_size)
{
if (!valid_format(format) || !valid_codec(codec)) {
return OAKCODEC_E_INVALID;
}
const auto l = olive::ExportFormat::get_pixel_formats_for_codec(
olive::ExportFormat::Format(format), olive::ExportCodec::Codec(codec));
if (index < 0 || index >= int(l.size())) {
return OAKCODEC_E_NOT_FOUND;
}
return string_out(l[size_t(index)], buf, buf_size);
}
int oakcodec_encoding_pix_fmt_index(int codec, const char *pix_fmt)
{
if (!valid_codec(codec) || !pix_fmt || !pix_fmt[0]) {
return 0;
}
// Mirrors the facade: query the FFmpeg encoder's list directly (the
// pixel-format list depends on the codec alone, not the container).
olive::FFmpegEncoder probe{ olive::EncodingParams() };
const auto l =
probe.get_pixel_formats_for_codec(olive::ExportCodec::Codec(codec));
const std::string needle(pix_fmt);
const auto it = std::find(l.begin(), l.end(), needle);
return it != l.end() ? int(it - l.begin()) : 0;
}
int oakcodec_encoding_sample_format_count(int format, int codec)
{
if (!valid_format(format) || !valid_codec(codec)) {
return OAKCODEC_E_INVALID;
}
return int(olive::ExportFormat::get_sample_formats_for_codec(
olive::ExportFormat::Format(format),
olive::ExportCodec::Codec(codec))
.size());
}
int oakcodec_encoding_sample_format_at(int format, int codec, int index)
{
if (!valid_format(format) || !valid_codec(codec)) {
return OAKCODEC_E_INVALID;
}
const auto l = olive::ExportFormat::get_sample_formats_for_codec(
olive::ExportFormat::Format(format), olive::ExportCodec::Codec(codec));
if (index < 0 || index >= int(l.size())) {
return OAKCODEC_E_NOT_FOUND;
}
return int(l[size_t(index)]);
}
/* ---- Image-sequence filename helpers ------------------------------------ */
int oakcodec_encoding_filename_contains_digit_placeholder(const char *filename)
{
if (!filename) {
return 0;
}
return olive::Encoder::filename_contains_digit_placeholder(filename) ? 1 :
0;
}
int oakcodec_encoding_image_sequence_digit_count(const char *filename)
{
if (!filename) {
return 0;
}
return olive::Encoder::get_image_sequence_placeholder_digit_count(filename);
}
int oakcodec_encoding_filename_remove_digit_placeholder(const char *filename,
char *buf,
int buf_size)
{
if (!filename) {
return OAKCODEC_E_INVALID;
}
return string_out(
olive::Encoder::filename_remove_digit_placeholder(filename), buf,
buf_size);
}
-198
View File
@@ -1,198 +0,0 @@
/***
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/>.
***/
#include "codec/frame.h"
#include <atomic>
#include "frame.h"
#include "refcounted.h"
namespace
{
// Every OakFrame box holds an olive::FramePtr: frames created here own a
// fresh olive::Frame, decoder-produced frames alias the decoder's
// shared_ptr. Unifying the box type keeps the addref/release thunks and
// the impl recovery symmetric across all OakFrame handles.
olive::Frame *impl(void *ctx)
{
auto *p = oakcodec::handle_impl<olive::FramePtr>(ctx);
return p ? p->get() : nullptr;
}
} // namespace
namespace oakcodec
{
std::atomic<int> g_alive_count{0};
void alive_inc()
{
g_alive_count.fetch_add(1, std::memory_order_relaxed);
}
void alive_dec()
{
g_alive_count.fetch_sub(1, std::memory_order_relaxed);
}
} // namespace oakcodec
int oakcodec_debug_alive_count(void)
{
return oakcodec::g_alive_count.load(std::memory_order_relaxed);
}
OakFrame oakcodec_frame_init(void)
{
return oakcodec::make_handle<OakFrame>(olive::Frame::create());
}
OakFrame oakcodec_frame_init_with_params(OakVideoParams params)
{
OakFrame h = oakcodec_frame_init();
if (h.ctx) {
impl(h.ctx)->set_video_params(params);
}
return h;
}
void oakcodec_frame_free(OakFrame *frame)
{
oakcodec::free_handle(frame);
}
int oakcodec_frame_get_params(OakFrame frame, OakVideoParams *out)
{
if (!frame.ctx || !out)
return OAKCODEC_E_INVALID;
*out = impl(frame.ctx)->video_params();
return OAKCODEC_OK;
}
int oakcodec_frame_set_params(OakFrame frame, OakVideoParams params)
{
if (!frame.ctx)
return OAKCODEC_E_INVALID;
impl(frame.ctx)->set_video_params(params);
return OAKCODEC_OK;
}
int oakcodec_frame_allocate(OakFrame frame)
{
if (!frame.ctx)
return OAKCODEC_E_INVALID;
if (!impl(frame.ctx)->allocate())
return OAKCODEC_E_STATE;
return OAKCODEC_OK;
}
int oakcodec_frame_is_allocated(OakFrame frame)
{
if (!frame.ctx)
return 0;
return impl(frame.ctx)->is_allocated() ? 1 : 0;
}
void *oakcodec_frame_data(OakFrame frame)
{
if (!frame.ctx)
return nullptr;
return impl(frame.ctx)->data();
}
const void *oakcodec_frame_const_data(OakFrame frame)
{
if (!frame.ctx)
return nullptr;
return impl(frame.ctx)->const_data();
}
int oakcodec_frame_allocated_size(OakFrame frame)
{
if (!frame.ctx)
return 0;
return impl(frame.ctx)->allocated_size();
}
int oakcodec_frame_linesize_bytes(OakFrame frame)
{
if (!frame.ctx)
return 0;
return impl(frame.ctx)->linesize_bytes();
}
int oakcodec_frame_linesize_pixels(OakFrame frame)
{
if (!frame.ctx)
return 0;
return impl(frame.ctx)->linesize_pixels();
}
int oakcodec_frame_width(OakFrame frame)
{
if (!frame.ctx)
return 0;
return impl(frame.ctx)->width();
}
int oakcodec_frame_height(OakFrame frame)
{
if (!frame.ctx)
return 0;
return impl(frame.ctx)->height();
}
int oakcodec_frame_format(OakFrame frame)
{
if (!frame.ctx)
return OAKCOMMON_PIXEL_FORMAT_INVALID;
return impl(frame.ctx)->format();
}
int oakcodec_frame_channel_count(OakFrame frame)
{
if (!frame.ctx)
return 0;
return impl(frame.ctx)->channel_count();
}
int oakcodec_frame_get_timestamp(OakFrame frame, int *numerator,
int *denominator)
{
if (!frame.ctx || !numerator || !denominator)
return OAKCODEC_E_INVALID;
const olive::core::Rational &ts = impl(frame.ctx)->timestamp();
*numerator = ts.numerator();
*denominator = ts.denominator();
return OAKCODEC_OK;
}
int oakcodec_frame_set_timestamp(OakFrame frame, int numerator,
int denominator)
{
if (!frame.ctx)
return OAKCODEC_E_INVALID;
impl(frame.ctx)->set_timestamp(
olive::core::Rational(numerator, denominator));
return OAKCODEC_OK;
}
-170
View File
@@ -1,170 +0,0 @@
/***
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/>.
***/
#include "codec/proxy.h"
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <string>
#include "proxymanager.h"
namespace
{
int string_out(const std::string &s, char *buf, int buf_size)
{
int need = static_cast<int>(s.size()) + 1;
if (buf && buf_size > 0) {
int n = std::min(static_cast<int>(s.size()), buf_size - 1);
memcpy(buf, s.data(), n);
buf[n] = '\0';
}
return need;
}
olive::ProxyManager::ProxyParams to_native(const oakcodec_proxy_params *p)
{
olive::ProxyManager::ProxyParams n;
if (p) {
n.width = p->width;
n.height = p->height;
n.divider = p->divider;
n.version = p->version;
n.crf = p->crf;
n.include_audio = p->include_audio != 0;
n.extension = p->extension;
n.preset = p->preset;
}
return n;
}
} // namespace
int oakcodec_proxy_create_instance(void)
{
olive::ProxyManager::create_instance();
return OAKCODEC_OK;
}
int oakcodec_proxy_destroy_instance(void)
{
olive::ProxyManager::destroy_instance();
return OAKCODEC_OK;
}
int oakcodec_proxy_params_default(oakcodec_proxy_params *out)
{
if (!out)
return OAKCODEC_E_INVALID;
olive::ProxyManager::ProxyParams n =
olive::ProxyManager::proxy_params_from_config();
*out = {};
out->width = n.width;
out->height = n.height;
out->divider = n.divider;
out->version = n.version;
out->crf = n.crf;
out->include_audio = n.include_audio ? 1 : 0;
snprintf(out->extension, sizeof(out->extension), "%s",
n.extension.c_str());
snprintf(out->preset, sizeof(out->preset), "%s", n.preset.c_str());
return OAKCODEC_OK;
}
int oakcodec_proxy_get_state(const char *proxy_filename)
{
if (!proxy_filename || !*proxy_filename)
return OAKCODEC_PROXY_STATE_MISSING;
return static_cast<int>(
olive::ProxyManager::get_proxy_state(proxy_filename));
}
int oakcodec_proxy_state_to_string(int state, char *buf, int buf_size)
{
if (state < OAKCODEC_PROXY_STATE_MISSING ||
state > OAKCODEC_PROXY_STATE_FAILED)
return OAKCODEC_E_INVALID;
return string_out(olive::ProxyManager::proxy_state_to_string(
static_cast<olive::ProxyManager::ProxyState>(state)),
buf, buf_size);
}
int oakcodec_proxy_get_proxy_directory(const char *cache_path, char *buf,
int buf_size)
{
if (!cache_path)
return OAKCODEC_E_INVALID;
return string_out(olive::ProxyManager::get_proxy_directory(cache_path),
buf, buf_size);
}
int oakcodec_proxy_get_proxy_filename(const char *cache_path,
const char *source_filename,
int stream_index,
const oakcodec_proxy_params *params,
char *buf, int buf_size)
{
if (!cache_path || !source_filename)
return OAKCODEC_E_INVALID;
return string_out(
olive::ProxyManager::get_proxy_filename(
cache_path, source_filename, stream_index, to_native(params)),
buf, buf_size);
}
int oakcodec_proxy_get_working_filename(const char *proxy_filename,
char *buf, int buf_size)
{
if (!proxy_filename)
return OAKCODEC_E_INVALID;
return string_out(
olive::ProxyManager::get_working_proxy_filename(proxy_filename),
buf, buf_size);
}
int oakcodec_proxy_get_or_start(const char *cache_path,
const char *source_filename, int stream_index,
const oakcodec_proxy_params *params,
oakcodec_proxy_result *out)
{
if (!cache_path || !source_filename || !out)
return OAKCODEC_E_INVALID;
if (!olive::ProxyManager::instance())
return OAKCODEC_E_STATE;
olive::ProxyManager::Proxy p =
olive::ProxyManager::instance()->get_or_start_proxy(
cache_path, source_filename, stream_index, to_native(params));
out->state = static_cast<int>(p.state);
snprintf(out->filename, sizeof(out->filename), "%s",
p.filename.c_str());
return OAKCODEC_OK;
}
int oakcodec_proxy_find_ffmpeg(const char *configured_path, char *buf,
int buf_size)
{
return string_out(olive::ProxyManager::find_f_fmpeg_executable(
configured_path ? configured_path : ""),
buf, buf_size);
}
-124
View File
@@ -1,124 +0,0 @@
/***
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/>.
***/
#ifndef OAKCODEC_C_API_REFCOUNTED_H
#define OAKCODEC_C_API_REFCOUNTED_H
#include <atomic>
#include <cstdint>
#include <type_traits>
#include <utility>
#include "codec/error.h"
namespace oakcodec
{
/**
* @brief Heap box behind every handle's ctx pointer.
*
* Same pattern as oakcommon's c_api/refcounted.h: holds the wrapped
* object plus its atomic reference count. addref and release are emitted
* per boxed type so that the function pointers stored in a handle always
* run code from the DLL that created the object. Every box also
* participates in the oakcodec_debug_alive_count() ledger.
*/
template <typename T> struct RefCounted {
T impl;
std::atomic<uint32_t> refs;
template <typename... Args>
explicit RefCounted(Args &&...args)
: impl(std::forward<Args>(args)...)
, refs(1)
{
}
};
template <typename T> void ref_counted_addref(void *ctx)
{
auto *box = static_cast<RefCounted<T> *>(ctx);
if (box)
box->refs.fetch_add(1, std::memory_order_relaxed);
}
void alive_inc();
void alive_dec();
template <typename T> void ref_counted_release(void *ctx)
{
auto *box = static_cast<RefCounted<T> *>(ctx);
if (box && box->refs.fetch_sub(1, std::memory_order_acq_rel) == 1) {
delete box;
alive_dec();
}
}
/**
* @brief Build a by-value handle owning a freshly boxed object (count 1).
*
* On allocation failure the returned handle has ctx == NULL (all C API
* functions treat that as OAKCODEC_E_INVALID and free() as a no-op).
*/
template <typename Handle, typename T, typename... Args>
Handle make_handle_in_place(Args &&...args)
{
Handle h = {};
try {
h.ctx = new RefCounted<T>(std::forward<Args>(args)...);
alive_inc();
} catch (...) {
h.ctx = nullptr;
}
h.addref = &ref_counted_addref<T>;
h.release = &ref_counted_release<T>;
h.abi_version = OAKCODEC_ABI_VERSION;
return h;
}
template <typename Handle, typename T> Handle make_handle(T &&value)
{
return make_handle_in_place<Handle, typename std::decay<T>::type>(
std::forward<T>(value));
}
/**
* @brief Recover the boxed object from a handle ctx (NULL-safe).
*/
template <typename T> T *handle_impl(void *ctx)
{
auto *box = static_cast<RefCounted<T> *>(ctx);
return box ? &box->impl : nullptr;
}
/**
* @brief Shared free() body: release the ctx, no-op on NULL/empty handle.
*/
template <typename Handle> void free_handle(Handle *h)
{
if (!h || !h->ctx || !h->release)
return;
h->release(h->ctx);
h->ctx = nullptr;
}
} // namespace oakcodec
#endif // OAKCODEC_C_API_REFCOUNTED_H
-290
View File
@@ -1,290 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags",
"cexpr",
"clang-sys",
"itertools",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex 1.3.0",
"syn",
]
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "cc"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
dependencies = [
"find-msvc-tools",
"shlex 2.0.1",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clang-sys"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "either"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
[[package]]
name = "ffmpeg-next"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6380599799e175191eb7ffe82c97f36a2a90a36cbc54c738a903e5287d7f516a"
dependencies = [
"bitflags",
"ffmpeg-sys-next",
"libc",
]
[[package]]
name = "ffmpeg-sys-next"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b939bf79dd5949412a4b81cfe21a07f48ea21b47fcbb5f57816c8c2de5ae30b"
dependencies = [
"bindgen",
"cc",
"libc",
"num_cpus",
"pkg-config",
"vcpkg",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
[[package]]
name = "glob"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num_cpus"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "oakcodec"
version = "0.1.0"
dependencies = [
"ffmpeg-next",
"oakcore-rs",
]
[[package]]
name = "oakcore-rs"
version = "0.1.0"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
-25
View File
@@ -1,25 +0,0 @@
[package]
name = "oakcodec"
version = "0.1.0"
edition = "2021"
description = "Oak Video Editor media codec module (Rust)"
license = "GPL-3.0-or-later"
[lib]
crate-type = ["staticlib", "rlib"]
[profile.release]
# FFI discipline: panics must be catchable at every exported entry.
panic = "unwind"
[features]
# Compile the oakcore_*/oakrender_* host-mocks in src/bridge/test_stubs.rs so
# consumer test binaries that link this crate (e.g. oaknode's) can resolve
# those cross-crate C-ABI symbols without the host dylibs.
test-stubs = []
[dependencies]
oakcore-rs = { path = "../../oakcore-rs" }
# Real media decode/encode. The C++ ffmpeg_bridge library existed only to
# absorb FFmpeg API churn; the Rust crate calls ffmpeg-next directly.
ffmpeg-next = "9"
-123
View File
@@ -1,123 +0,0 @@
# oakcodec Rust crate
> Status: **implemented**. Implements `include/codec/*.h` verbatim
> (`src/ffi/`); every export has success + failure-path tests
> (`cargo test`: unit tests in `src/ffi/*.rs`, the contract tests in
> `tests/`, and real-media tests in `src/realmedia_tests.rs`). The FFmpeg
> engine is fully implemented through the [`ffmpeg-next`] crate (decode,
> probe, audio conform, encode); the OIIO engine remains a stub in this
> build. This crate mirrors the `src/node/rust/` template (same FFI
> discipline, same testing layers).
## Scope
Replaces the C++ oakcodec module (`src/codec/src`, ~10k lines): CPU
frame buffers (`Frame`), the frame pool (`FrameManager`), media
decoders/encoders with their FFmpeg and OIIO implementations, audio
conform and proxy generation managers, export format/codec tables,
encoding parameters, and the background-task submit hook.
Public contract: `include/codec/*.h` (7 headers: frame.h, decoder.h,
encoder.h, conform.h, proxy.h, task.h, error.h) — frozen, implemented
verbatim by `src/ffi.rs`. Interim state (pre-M8) is documented in
`src/codec/NOTES.md`: conform/proxy work is delegated to the global
task submit callback and otherwise reports unavailable, never crashes
and never blocks.
## Key architectural decisions (C++ → Rust mapping)
1. **`shared_ptr` → refcounted `RefBox` handle.** The C++ `Frame`/
`Decoder`/`Encoder` objects are heap boxes behind the neutral
by-value handle struct `{ctx, addref, release, abi_version}` (see
`handle.rs`), exactly as oaknode/oakplugin do. Handles are
deliberately duplicated per module: the function pointers always
point into the DLL that created the object.
2. **Inheritance → traits.** The C++ `Decoder`/`Encoder` abstract
bases plus their FFmpeg/OIIO subclasses become a Rust trait with
two implementors. The probe/dispatch (decide which implementation
recognizes a file) stays in `decoder.rs`. `Encoder`'s per-codec
`PixelFormat`/`SampleFormat` support is a trait query, not a
virtual chain.
3. **`Frame` owns its params by value.** `olive::Frame` wraps an
`OakVideoParams` handle (an oakcommon by-value handle, NOT owned by
codec) plus a `Vec<u8>` pixel buffer. In Rust the params are held as
the oakcommon handle (refcounted through `bridge::common`) so the
byte-level ABI stays unchanged; the buffer is a plain `Vec<u8>`.
4. **No adapter layer.** Codec calls other modules' C ABIs directly
(`bridge/common.rs`, `bridge/render.rs`), keeping the 2026-08
decision recorded in NOTES.md §6. Only genuinely repeated
conversions survive as small module-local helpers (e.g.
`fill_render_params`, `cancel_atom_is_cancelled`).
5. **XML stays on the C++ side.** `EncodingParams::load/save` use
oakcommon's C++ `XmlStreamWriter/Reader` classes
(`src/common/src/xmlutils.h`), exactly as oaknode/oakrender do —
the one C++-to-C++ coupling the bridge cannot cover (NOTES.md §7).
6. **Threading.** `FrameManager` keeps its background GC thread behind
a `Mutex`; the C++ code's reliance on Qt's event thread is gone. The
threading contract is documented per function.
7. **Enum values are the C contract.** `ExportFormat::Format`,
`ExportCodec::Codec`, `Interlacing`, `VideoScalingMethod`,
`SampleFormat::Format` all stay as the raw int values the C ABI
documents (oakengine/encoding.h), so `ffi.rs` marshals them without
translation.
## Layout
```
src/
lib.rs crate doc + module map
error.rs error codes (mirrors include/codec/error.h)
handle.rs refcounted-handle scaffolding (same pattern as node)
frame.rs Frame (CPU pixel buffer + OakVideoParams handle)
framemanager.rs FrameManager (buffer pool + background GC thread)
decoder.rs Decoder trait + CodecStream + RenderMode + probe
ffmpeg.rs FFmpegDecoder / FFmpegEncoder (ffmpeg-next)
oiio.rs OIIODecoder / OIIOEncoder (OpenImageIO)
oiioframebridge.rs oiioutils frame<->buffer conversion
encoder.rs Encoder trait (abstract base)
encodingparams.rs EncodingParams (flattened ABI POD + generate_matrix)
exportcodec.rs ExportCodec enum + codec-name table
exportformat.rs ExportFormat enum + extension/format table
conformmanager.rs ConformManager (stateless, task-callback driven)
proxymanager.rs ProxyManager (stateless, task-callback driven)
task.rs OakCodecTaskKind / OakCodecTaskRequest / submit hook
timecodemetadata.rs TimecodeMetadata (SMPTE/BWF parsers)
footagedescription.rs FootageDescription (codec-internal stream desc)
planarfiledevice.rs PlanarFileDevice (stdio plane-channel I/O)
realmedia_tests.rs real-media tests (demo.mp4, H.264 round-trip)
bridge/ C ABI imports: common.rs, render.rs
ffi.rs include/codec/*.h export layer
tests/ contract + golden tests (see test section below)
```
## Hard rules for the implementer
1. Every `extern "C"` body goes through `handle::guard*`; no panic
crosses FFI.
2. The handle is the only way out of the crate; the public API never
hands out raw `&Frame`/`&Decoder` references.
3. Behavior parity with C++ is proven by the unchanged C ABI test
suite (`src/codec/tests`) plus the contract tests in `tests/`.
4. Where C++ behavior is genuinely load-bearing but ugly, port the
behavior, not the aesthetics; leave a `// CPP-PARITY:` comment with
the C++ file:line.
## Dependency policy
Prefer mature third-party crates (MIT/Apache-2.0/BSD, GPL-compatible)
over hand-rolling; register each addition (name + reason) here. Large
existing C++ libraries (OTIO, OCIO, OIIO, FFmpeg) are NEVER rewritten
— they are consumed through their C ABI / bridge layers.
### Dependencies
- `oakcore-rs` (path) — oakcore value types (Rational, TimeRange,
PixelFormat/SampleFormat) mirrored as Rust enums.
- `ffmpeg-next` 9 — the FFmpeg decode/encode engine. The C++
`ffmpeg_bridge` library (`liboakffmpeg`) existed only to absorb FFmpeg
API churn; the Rust crate calls `ffmpeg-next` directly (per the 2026-08
decision that dropped the binding-library plan). `ffmpeg-next` builds
against the system FFmpeg via `ffmpeg-sys-next` (bindgen); the
implementation dips into `ffmpeg-sys-next` (`ffmpeg::ffi`) only for
swscale/swresample details and channel-layout construction that the safe
wrapper does not expose.
-295
View File
@@ -1,295 +0,0 @@
// 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/>.
//! oakcommon / oakcore C ABI imports (videoparams, audioparams, rational,
//! subtitleparams, config, filefunctions, ffmpegutils, oiioutils,
//! colortransform).
//!
//! The by-value handle structs (`OakVideoParams`, `OakAudioParams`,
//! `OakSubtitleParams`, `OakNodeBlock`) mirror the `{ctx, addref,
//! release, abi_version}` layout from `include/common/handle.h`, so the
//! codec module can store them by value and pass them straight across
//! the FFI boundary. Function signatures match the public headers
//! verbatim; symbols resolve at link time.
//!
//! The oakcore audio parameters use a pointer-based C ABI instead of the
//! by-value handle convention: `oakcore_audioparams_*` take and return
//! `OakAudioParams *` / `OakRational *` pointers (`core/include/olive/
//! core/oakcore/audioparams.h`, `rational.h`). Those are bridged as raw
//! pointers to the crate's handle structs; `oakcore_audioparams_time_base`
//! returns a newly allocated rational the caller must release with
//! `oakcore_rational_free`.
use std::ffi::{c_char, c_int, c_void};
use crate::handle::CHandle;
/// `OakVideoParams` — refcounted video-parameter handle.
pub type OakVideoParams = CHandle;
/// `OakAudioParams` — refcounted audio-parameter handle.
pub type OakAudioParams = CHandle;
/// `OakSubtitleParams` — refcounted subtitle-parameter handle.
pub type OakSubtitleParams = CHandle;
/// `OakNodeBlock` — opaque node-block handle (owned elsewhere; codec
/// only stores and forwards it).
pub type OakNodeBlock = CHandle;
// The handle structs are opaque refcounted handles pointing into a C
// library; the boxed objects are independently synchronized there, so
// moving a handle between threads is sound.
extern "C" {
/// `oakcommon_videoparams_init`.
pub fn oakcommon_videoparams_init() -> OakVideoParams;
/// `oakcommon_videoparams_init_basic`.
pub fn oakcommon_videoparams_init_basic(width: c_int, height: c_int) -> OakVideoParams;
/// `oakcommon_videoparams_init_with_time_base`.
pub fn oakcommon_videoparams_init_with_time_base(
width: c_int,
height: c_int,
time_base_num: i64,
time_base_den: i64,
) -> OakVideoParams;
/// `oakcommon_videoparams_free` (NULL/empty no-op).
pub fn oakcommon_videoparams_free(params: *mut OakVideoParams);
/// `oakcommon_videoparams_get_width`.
pub fn oakcommon_videoparams_get_width(params: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_get_height`.
pub fn oakcommon_videoparams_get_height(params: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_get_format`.
pub fn oakcommon_videoparams_get_format(params: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_get_time_base` (num/den out).
pub fn oakcommon_videoparams_get_time_base(
params: OakVideoParams,
out_num: *mut i64,
out_den: *mut i64,
) -> c_int;
/// `oakcommon_videoparams_set_width`.
pub fn oakcommon_videoparams_set_width(params: OakVideoParams, width: c_int);
/// `oakcommon_videoparams_set_height`.
pub fn oakcommon_videoparams_set_height(params: OakVideoParams, height: c_int);
/// `oakcommon_videoparams_set_format`.
pub fn oakcommon_videoparams_set_format(params: OakVideoParams, format: c_int);
/// `oakcommon_videoparams_get_is_valid`.
pub fn oakcommon_videoparams_get_is_valid(params: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_equals`.
pub fn oakcommon_videoparams_equals(a: OakVideoParams, b: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_set_time_base`.
pub fn oakcommon_videoparams_set_time_base(
params: OakVideoParams,
num: i64,
den: i64,
);
/// `oakcommon_videoparams_set_frame_rate`.
pub fn oakcommon_videoparams_set_frame_rate(
params: OakVideoParams,
num: i64,
den: i64,
);
/// `oakcommon_videoparams_set_pixel_aspect_ratio`.
pub fn oakcommon_videoparams_set_pixel_aspect_ratio(
params: OakVideoParams,
num: i64,
den: i64,
);
/// `oakcommon_videoparams_set_interlacing`.
pub fn oakcommon_videoparams_set_interlacing(params: OakVideoParams, interlacing: c_int);
/// `oakcommon_videoparams_set_duration`.
pub fn oakcommon_videoparams_set_duration(params: OakVideoParams, duration: i64);
/// `oakcommon_videoparams_set_start_time`.
pub fn oakcommon_videoparams_set_start_time(params: OakVideoParams, start_time: i64);
/// `oakcommon_videoparams_set_color_range`.
pub fn oakcommon_videoparams_set_color_range(params: OakVideoParams, color_range: c_int);
/// `oakcommon_videoparams_set_video_type`.
pub fn oakcommon_videoparams_set_video_type(params: OakVideoParams, video_type: c_int);
/// `oakcommon_videoparams_set_channel_count`.
pub fn oakcommon_videoparams_set_channel_count(params: OakVideoParams, channels: c_int);
/// `oakcommon_videoparams_set_color_primaries`.
pub fn oakcommon_videoparams_set_color_primaries(params: OakVideoParams, primaries: c_int);
/// `oakcommon_videoparams_set_color_transfer`.
pub fn oakcommon_videoparams_set_color_transfer(params: OakVideoParams, transfer: c_int);
/// `oakcommon_videoparams_set_premultiplied_alpha`.
pub fn oakcommon_videoparams_set_premultiplied_alpha(params: OakVideoParams, premultiplied: c_int);
/// `oakcommon_videoparams_set_enabled`.
pub fn oakcommon_videoparams_set_enabled(params: OakVideoParams, enabled: c_int);
/// `oakcommon_videoparams_static_get_bytes_per_pixel`.
pub fn oakcommon_videoparams_static_get_bytes_per_pixel(format: c_int) -> c_int;
/// `oakcommon_videoparams_frame_rate_as_time_base`.
pub fn oakcommon_videoparams_frame_rate_as_time_base(
frame_rate_num: i64,
frame_rate_den: i64,
out_num: *mut i64,
out_den: *mut i64,
);
/// `oakcommon_videoparams_get_stream_index`.
pub fn oakcommon_videoparams_get_stream_index(params: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_set_stream_index`.
pub fn oakcommon_videoparams_set_stream_index(params: OakVideoParams, index: c_int);
/// `oakcommon_videoparams_get_divider`.
pub fn oakcommon_videoparams_get_divider(params: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_set_divider`.
pub fn oakcommon_videoparams_set_divider(params: OakVideoParams, divider: c_int);
// NOTE: the remaining video getters below take the value-style form the
// crate's existing bridge uses (the real oakcommon headers use out-pointer
// args); `get_frame_rate` needs both values so it keeps the out pair.
/// `oakcommon_videoparams_get_frame_rate` (frame-rate num/den out).
pub fn oakcommon_videoparams_get_frame_rate(
params: OakVideoParams,
out_num: *mut c_int,
out_den: *mut c_int,
) -> c_int;
/// `oakcommon_videoparams_get_duration` (time-base units).
pub fn oakcommon_videoparams_get_duration(params: OakVideoParams) -> i64;
/// `oakcommon_videoparams_get_channel_count`.
pub fn oakcommon_videoparams_get_channel_count(params: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_get_color_primaries`.
pub fn oakcommon_videoparams_get_color_primaries(params: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_get_color_transfer`.
pub fn oakcommon_videoparams_get_color_transfer(params: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_get_interlacing` (`Interlacing` value).
pub fn oakcommon_videoparams_get_interlacing(params: OakVideoParams) -> c_int;
/// `oakcore_audioparams_create` (pointer-based; timebase 1/sample_rate).
pub fn oakcore_audioparams_create(
sample_rate: c_int,
channel_layout: u64,
format: c_int,
) -> *mut OakAudioParams;
/// `oakcore_audioparams_free` (NULL no-op).
pub fn oakcore_audioparams_free(params: *mut OakAudioParams);
/// `oakcore_audioparams_sample_rate`.
pub fn oakcore_audioparams_sample_rate(params: *const OakAudioParams) -> c_int;
/// `oakcore_audioparams_set_sample_rate`.
pub fn oakcore_audioparams_set_sample_rate(params: *mut OakAudioParams, sample_rate: c_int);
/// `oakcore_audioparams_channel_layout`.
pub fn oakcore_audioparams_channel_layout(params: *const OakAudioParams) -> u64;
/// `oakcore_audioparams_set_channel_layout`.
pub fn oakcore_audioparams_set_channel_layout(params: *mut OakAudioParams, layout: u64);
/// `oakcore_audioparams_set_time_base`.
pub fn oakcore_audioparams_set_time_base(
params: *mut OakAudioParams,
num: c_int,
den: c_int,
);
/// `oakcore_audioparams_set_format`.
pub fn oakcore_audioparams_set_format(params: *mut OakAudioParams, format: c_int);
/// `oakcore_audioparams_set_stream_index`.
pub fn oakcore_audioparams_set_stream_index(params: *mut OakAudioParams, index: c_int);
/// `oakcore_audioparams_set_duration`.
pub fn oakcore_audioparams_set_duration(params: *mut OakAudioParams, duration: i64);
/// `oakcore_audioparams_channel_count`.
pub fn oakcore_audioparams_channel_count(params: *const OakAudioParams) -> c_int;
/// `oakcore_audioparams_format`.
pub fn oakcore_audioparams_format(params: *const OakAudioParams) -> c_int;
/// `oakcore_audioparams_stream_index`.
pub fn oakcore_audioparams_stream_index(params: *const OakAudioParams) -> c_int;
/// `oakcore_audioparams_duration`.
pub fn oakcore_audioparams_duration(params: *const OakAudioParams) -> i64;
/// `oakcore_audioparams_is_valid`.
pub fn oakcore_audioparams_is_valid(params: *const OakAudioParams) -> c_int;
/// `oakcore_audioparams_time_base` (newly allocated rational; caller
/// releases with `oakcore_rational_free`).
pub fn oakcore_audioparams_time_base(params: *const OakAudioParams) -> *mut c_void;
/// `oakcore_rational_numerator`.
pub fn oakcore_rational_numerator(rational: *const c_void) -> c_int;
/// `oakcore_rational_denominator`.
pub fn oakcore_rational_denominator(rational: *const c_void) -> c_int;
/// `oakcore_rational_free` (NULL no-op).
pub fn oakcore_rational_free(rational: *mut c_void);
/// `oakcommon_subtitleparams_get_stream_index`.
pub fn oakcommon_subtitleparams_get_stream_index(params: OakSubtitleParams) -> c_int;
/// `oakcommon_subtitleparams_generate_ass_header`.
pub fn oakcommon_subtitleparams_generate_ass_header(
params: OakSubtitleParams,
width: c_int,
height: c_int,
);
/// `oakcommon_subtitleparams_add_subtitle`.
pub fn oakcommon_subtitleparams_add_subtitle(params: OakSubtitleParams, text: *const c_char);
/// `oakcommon_config_get_int`.
pub fn oakcommon_config_get_int(group: *const c_char, key: *const c_char, default: c_int) -> c_int;
/// `oakcommon_config_get_bool`.
pub fn oakcommon_config_get_bool(group: *const c_char, key: *const c_char, default: c_int) -> c_int;
/// `oakcommon_config_get` (two-stage string access).
pub fn oakcommon_config_get(
group: *const c_char,
key: *const c_char,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakcommon_filefunctions_init`.
pub fn oakcommon_filefunctions_init();
/// `oakcommon_filefunctions_get_configuration_location` (two-stage).
pub fn oakcommon_filefunctions_get_configuration_location(
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakcommon_filefunctions_get_unique_file_identifier`.
pub fn oakcommon_filefunctions_get_unique_file_identifier(path: *const c_char) -> i64;
/// `oakcommon_filefunctions_get_application_path` (two-stage).
pub fn oakcommon_filefunctions_get_application_path(
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakcommon_filefunctions_free` (frees an internally cached string).
pub fn oakcommon_filefunctions_free(ptr: *mut c_void);
/// `oakcommon_colortransform_init_output`.
pub fn oakcommon_colortransform_init_output(
src_colorspace: c_int,
src_trc: c_int,
dst_colorspace: c_int,
dst_trc: c_int,
premultiplied: c_int,
chroma_coeffs: *const c_void,
) -> OakVideoParams;
/// `oakcommon_colortransform_get_output`.
pub fn oakcommon_colortransform_get_output(params: OakVideoParams, out: *mut OakVideoParams);
/// `oakcommon_colortransform_free`.
pub fn oakcommon_colortransform_free(params: *mut OakVideoParams);
/// `oakcommon_ffmpegutils_get_native_sample_format`.
pub fn oakcommon_ffmpegutils_get_native_sample_format(sample_format: c_int) -> c_int;
/// `oakcommon_ffmpegutils_get_compatible_pixel_format`.
pub fn oakcommon_ffmpegutils_get_compatible_pixel_format(format: c_int) -> c_int;
/// `oakcommon_ffmpegutils_get_ffmpeg_pixel_format`.
pub fn oakcommon_ffmpegutils_get_ffmpeg_pixel_format(format: c_int) -> c_int;
/// `oakcommon_ffmpegutils_get_ffmpeg_sample_format`.
pub fn oakcommon_ffmpegutils_get_ffmpeg_sample_format(format: c_int) -> c_int;
/// `oakcommon_ffmpegutils_get_compatible_bridge_pixel_format`.
pub fn oakcommon_ffmpegutils_get_compatible_bridge_pixel_format(format: c_int) -> c_int;
/// `oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space`.
pub fn oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space(format: c_int) -> c_int;
/// `oakcommon_oiioutils_init`.
pub fn oakcommon_oiioutils_init();
/// `oakcommon_oiioutils_get_oiio_base_type_from_format`.
pub fn oakcommon_oiioutils_get_oiio_base_type_from_format(format: c_int) -> c_int;
/// `oakcommon_oiioutils_get_format_from_oiio_basetype`.
pub fn oakcommon_oiioutils_get_format_from_oiio_basetype(basetype: c_int) -> c_int;
/// `oakcommon_oiioutils_get_pixel_aspect_ratio` (num/den out).
pub fn oakcommon_oiioutils_get_pixel_aspect_ratio(
width: c_int,
height: c_int,
out_num: *mut c_int,
out_den: *mut c_int,
) -> c_int;
/// `oakcommon_oiioutils_free`.
pub fn oakcommon_oiioutils_free();
}
-35
View File
@@ -1,35 +0,0 @@
// 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/>.
//! C ABI imports from the other oak modules.
//!
//! The codec module links against oakcommon and oakrender at the C ABI.
//! Every signature below mirrors the corresponding public header
//! verbatim and is resolved at link time. The by-value handle structs
//! (`OakVideoParams`, `OakRenderTexture`, …) are `#[repr(C)]` mirrors of
//! the `{ctx, addref, release, abi_version}` layout so the codec crate
//! can hold and hand them across the FFI boundary without translation.
pub mod common;
pub mod render;
// In-memory mocks for the oakcommon/oakrender C ABI so the crate links
// and is testable under `cargo test` (where those dylibs are absent).
// The `test-stubs` feature additionally compiles the oakcore_*/oakrender_*
// host-mocks for consumer test binaries (e.g. oaknode's) that link this
// crate directly — those symbols are not provided by any Rust crate.
#[cfg(any(test, feature = "test-stubs"))]
pub mod test_stubs;
-144
View File
@@ -1,144 +0,0 @@
// 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/>.
//! oakrender C ABI imports (display textures, renderers, cancel atoms).
//!
//! The OIIO/FFmpeg decoders push frames to a `DisplayTexture` and poll a
//! `CancelAtom`; both are oakrender refcounted handles with the standard
//! `{ctx, addref, release, abi_version}` layout. `oakrender_video_params`
//! is a flattened POD the decoders construct to describe the frame.
use std::ffi::{c_char, c_int, c_void};
use crate::handle::CHandle;
/// `OakRenderTexture` — refcounted GPU texture handle.
pub type OakRenderTexture = CHandle;
/// `OakCancelAtom` — refcounted cancellation atom handle.
pub type OakCancelAtom = CHandle;
/// `OakRenderRenderer` — refcounted display-renderer handle.
pub type OakRenderRenderer = CHandle;
/// `OakCodecFrame` — refcounted CPU-frame handle shared with oakrender.
pub type OakCodecFrame = CHandle;
// Refcounted opaque handles; thread-safe in the C library.
/// `oakrender_video_params` — flattened POD of `olive::VideoParams`
/// passed into oakrender; see `include/render/renderer.h`.
#[repr(C)]
pub struct oakrender_video_params {
/// Width in pixels.
pub width: c_int,
/// Height in pixels.
pub height: c_int,
/// Frame-duration numerator (e.g. 1001/30000 s).
pub time_base_num: c_int,
/// Frame-duration denominator.
pub time_base_den: c_int,
/// `olive::PixelFormat::Format`.
pub format: c_int,
/// Pixel-aspect numerator.
pub pixel_aspect_num: c_int,
/// Pixel-aspect denominator.
pub pixel_aspect_den: c_int,
/// `olive::VideoParams::Interlacing`.
pub interlacing: c_int,
/// `olive::VideoParams::ColorRange`.
pub color_range: c_int,
/// Preview-resolution divider (1 = full).
pub divider: c_int,
/// `olive::VideoParams::Type` (0 = video).
pub video_type: c_int,
/// 0/1 premultiplied alpha.
pub premultiplied_alpha: c_int,
}
extern "C" {
/// `oakrender_cancelatom_init`.
pub fn oakrender_cancelatom_init() -> OakCancelAtom;
/// `oakrender_cancelatom_free` (NULL/empty no-op).
pub fn oakrender_cancelatom_free(atom: *mut OakCancelAtom);
/// `oakrender_cancelatom_is_cancelled`.
pub fn oakrender_cancelatom_is_cancelled(atom: OakCancelAtom) -> c_int;
/// `oakrender_cancelatom_heard_cancel`.
pub fn oakrender_cancelatom_heard_cancel(atom: OakCancelAtom) -> c_int;
/// `oakrender_cancelatom_cancel`.
pub fn oakrender_cancelatom_cancel(atom: OakCancelAtom);
/// `oakrender_cancelatom_get_native`.
pub fn oakrender_cancelatom_get_native(atom: OakCancelAtom) -> *mut c_void;
/// `oakrender_display_texture_create`.
pub fn oakrender_display_texture_create(
renderer: OakRenderRenderer,
params: *const oakrender_video_params,
data: *const c_void,
linesize: c_int,
) -> OakRenderTexture;
/// `oakrender_display_texture_retain`.
pub fn oakrender_display_texture_retain(texture: OakRenderTexture) -> OakRenderTexture;
/// `oakrender_display_texture_free` (NULL/empty no-op).
pub fn oakrender_display_texture_free(texture: *mut OakRenderTexture);
/// `oakrender_display_texture_upload`.
pub fn oakrender_display_texture_upload(texture: OakRenderTexture) -> c_int;
/// `oakrender_display_texture_download`.
pub fn oakrender_display_texture_download(
texture: OakRenderTexture,
pixels: *mut c_void,
linesize: c_int,
) -> c_int;
/// `oakrender_display_texture_get_params`.
pub fn oakrender_display_texture_get_params(
texture: OakRenderTexture,
out: *mut oakrender_video_params,
) -> c_int;
/// `oakrender_display_texture_id`.
pub fn oakrender_display_texture_id(texture: OakRenderTexture) -> c_int;
/// `oakrender_display_texture_is_dummy`.
pub fn oakrender_display_texture_is_dummy(texture: OakRenderTexture) -> c_int;
/// `oakrender_display_texture_get_frame` (two-stage frame access).
pub fn oakrender_display_texture_get_frame(
texture: OakRenderTexture,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakrender_codec_frame_width`.
pub fn oakrender_codec_frame_width(frame: OakCodecFrame) -> c_int;
/// `oakrender_codec_frame_height`.
pub fn oakrender_codec_frame_height(frame: OakCodecFrame) -> c_int;
/// `oakrender_codec_frame_fb_format`.
pub fn oakrender_codec_frame_fb_format(frame: OakCodecFrame) -> c_int;
/// `oakrender_codec_frame_free` (NULL/empty no-op).
pub fn oakrender_codec_frame_free(frame: *mut OakCodecFrame);
/// `oakrender_codec_frame_allocate`.
pub fn oakrender_codec_frame_allocate(frame: OakCodecFrame) -> c_int;
/// `oakrender_codec_frame_linesize_bytes`.
pub fn oakrender_codec_frame_linesize_bytes(frame: OakCodecFrame) -> c_int;
/// `oakrender_codec_frame_is_allocated`.
pub fn oakrender_codec_frame_is_allocated(frame: OakCodecFrame) -> c_int;
/// `oakrender_display_renderer_blit_color_managed`.
pub fn oakrender_display_renderer_blit_color_managed(
renderer: OakRenderRenderer,
job: *const c_void,
dst_texture: OakRenderTexture,
params: *const oakrender_video_params,
) -> c_int;
}
File diff suppressed because it is too large Load Diff
-341
View File
@@ -1,341 +0,0 @@
// 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/>.
//! `olive::ConformManager` — pcm waveform cache files for fast scrubbing.
//!
//! Mirrors `src/codec/src/conformmanager.h`. Stateless (NOTES.md): actual
//! conform work is delegated to the global task submit callback
//! ([`crate::task`]); with no registrar the state queries report
//! `Unavailable`. Deterministic per-channel filenames derive from the
//! source + target audio params.
use std::ffi::CString;
use std::path::Path;
/// Conform state of one audio stream.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(i32)]
pub enum ConformState {
/// Conform files exist.
Exists = 0,
/// Conform is being generated.
Generating = 1,
/// No task registrar; conform unavailable.
Unavailable = 2,
}
/// `olive::ConformManager` — stateless conform query/produce manager.
pub struct ConformManager;
impl ConformManager {
/// The process-wide ConformManager singleton.
pub fn instance() -> &'static ConformManager {
static INSTANCE: ConformManager = ConformManager;
&INSTANCE
}
/// Query (and when possible start) the conform of one audio stream.
///
/// `wait != 0` treats a post-submit miss as `Unavailable`; `wait == 0`
/// reports it as `Generating`. Without a task registrar the result is
/// always `Unavailable`.
pub fn get_conform_state(
&self,
cache_path: &str,
source_filename: &str,
stream_index: i32,
sample_rate: i32,
channel_layout: u64,
sample_format: i32,
wait: bool,
) -> crate::error::Result<ConformState> {
let filenames = conform_filenames(
cache_path,
source_filename,
stream_index,
sample_rate,
sample_format,
channel_layout,
);
// Return existing conform if it exists.
if all_conforms_exist(&filenames) {
return Ok(ConformState::Exists);
}
// Interim state (pre-M8): no task system, conform cannot be generated.
if !crate::task::task_submit_is_registered() {
return Ok(ConformState::Unavailable);
}
// The task owns the ".working" temporary names and the rename to the
// final per-channel filenames on success; output_filename carries the
// first channel's final path and the task derives the siblings.
let req = crate::task::TaskRequest {
kind: crate::task::TaskKind::Conform,
input_filename: source_filename,
output_filename: filenames.first().map(String::as_str).unwrap_or(""),
stream_index,
sample_rate,
channel_layout,
sample_format,
proxy_width: 0,
proxy_height: 0,
};
// Interim simplification: submission is synchronous — we always wait
// for the submit to return, regardless of `wait`.
if crate::task::submit_task(&req).is_err() {
return Ok(ConformState::Unavailable);
}
if all_conforms_exist(&filenames) {
return Ok(ConformState::Exists);
}
if wait {
// Synchronous wait already happened and the conform still does not
// exist: report the wait as failed.
return Ok(ConformState::Unavailable);
}
Ok(ConformState::Generating)
}
/// Number of conform (pcm) files for the given stream/params — one per
/// channel; 0 on invalid arguments.
pub fn get_conform_filename_count(
&self,
_cache_path: &str,
_source_filename: &str,
_stream_index: i32,
_sample_rate: i32,
channel_layout: u64,
_sample_format: i32,
) -> usize {
channel_layout.count_ones() as usize
}
/// The `index`-th conform filename.
pub fn get_conform_filename(
&self,
cache_path: &str,
source_filename: &str,
stream_index: i32,
sample_rate: i32,
channel_layout: u64,
sample_format: i32,
index: usize,
) -> crate::error::Result<String> {
let filenames = conform_filenames(
cache_path,
source_filename,
stream_index,
sample_rate,
sample_format,
channel_layout,
);
filenames
.get(index)
.cloned()
.ok_or(crate::error::Error::NotFound)
}
}
/// Deterministic conform base name plus per-channel pcm filenames, mirroring
/// `ConformManager::get_conformed_filename`: one file per channel under
/// `cache_path`, named `<identifier>-<stream>.<rate>.<format>.<layout>.<i>.pcm`.
fn conform_filenames(
cache_path: &str,
source_filename: &str,
stream_index: i32,
sample_rate: i32,
sample_format: i32,
channel_layout: u64,
) -> Vec<String> {
let count = channel_layout.count_ones() as usize;
let base = format!(
"{}-{}.{}.{}.{}",
unique_file_identifier(source_filename),
stream_index,
sample_rate,
sample_format,
channel_layout,
);
let mut out = Vec::with_capacity(count);
for i in 0..count {
let p = Path::new(cache_path).join(format!("{}.{}.pcm", base, i));
out.push(p.to_string_lossy().into_owned());
}
out
}
/// `oakcommon_filefunctions_get_unique_file_identifier` wrapper (the bridge
/// returns a 64-bit id directly).
fn unique_file_identifier(filename: &str) -> String {
let c = match CString::new(filename) {
Ok(c) => c,
Err(_) => return String::new(),
};
// # Safety: `c` is a valid NUL-terminated C string alive for the call.
let id = unsafe {
crate::bridge::common::oakcommon_filefunctions_get_unique_file_identifier(c.as_ptr())
};
format!("{}", id)
}
/// True when every conform filename already exists on disk.
fn all_conforms_exist(filenames: &[String]) -> bool {
filenames.iter().all(|f| Path::new(f).exists())
}
/// Shared test support: serializes access to the global task-submit registry
/// (unit tests run in parallel and would otherwise clear each other's
/// registration) and provides a callback that accepts any task.
#[cfg(test)]
pub(crate) mod test_util {
use crate::error::OAKCODEC_OK;
use crate::task::OakCodecTaskRequest;
/// Serializes every test that mutates the task-submit registry.
pub static REG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// A task-submit callback that accepts every request (no-op).
pub unsafe extern "C" fn accept_cb(
_req: *const OakCodecTaskRequest,
_ud: *mut std::ffi::c_void,
) -> i32 {
OAKCODEC_OK
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_subdir(name: &str) -> String {
let dir = std::env::temp_dir().join(format!(
"oakcodec_conform_{}_{}",
name,
std::process::id()
));
let _ = std::fs::create_dir_all(&dir);
dir.to_string_lossy().into_owned()
}
fn fnv1a64(bytes: &[u8]) -> u64 {
let mut h: u64 = 14695981039346656037;
for &b in bytes {
h ^= b as u64;
h = h.wrapping_mul(1099511628211);
}
h
}
#[test]
fn unique_identifier_matches_bridge_hash() {
// The test stub computes an FNV-1a-64 of the path bytes.
let expected = format!("{}", fnv1a64(b"media.mp4") as i64);
assert_eq!(unique_file_identifier("media.mp4"), expected);
// Deterministic: same input, same id.
assert_eq!(
unique_file_identifier("media.mp4"),
unique_file_identifier("media.mp4")
);
// Different input, different id.
assert_ne!(
unique_file_identifier("media.mp4"),
unique_file_identifier("other.mp4")
);
}
#[test]
fn filename_count_from_channel_layout() {
let m = ConformManager::instance();
assert_eq!(m.get_conform_filename_count("c", "s", 0, 48000, 0x3, 0), 2); // stereo
assert_eq!(m.get_conform_filename_count("c", "s", 0, 48000, 0x4, 0), 1); // mono
assert_eq!(m.get_conform_filename_count("c", "s", 0, 48000, 0, 0), 0); // invalid
assert_eq!(
m.get_conform_filename_count("c", "s", 0, 48000, 0x60F, 0),
6
); // 5.1
}
#[test]
fn conform_filename_derivation_and_range() {
let m = ConformManager::instance();
let cache = temp_subdir("names");
let id = fnv1a64(b"media.mp4") as i64;
let base = format!("{}-0.48000.0.3", id);
let f0 = m
.get_conform_filename(&cache, "media.mp4", 0, 48000, 0x3, 0, 0)
.unwrap();
let f1 = m
.get_conform_filename(&cache, "media.mp4", 0, 48000, 0x3, 0, 1)
.unwrap();
assert_eq!(f0, format!("{}/{}.0.pcm", cache, base));
assert_eq!(f1, format!("{}/{}.1.pcm", cache, base));
// Out of range.
assert!(matches!(
m.get_conform_filename(&cache, "media.mp4", 0, 48000, 0x3, 0, 5),
Err(crate::error::Error::NotFound)
));
}
#[test]
fn get_conform_state_unavailable_without_registrar() {
let _g = super::test_util::REG_LOCK.lock().unwrap();
// Ensure no registrar is left over.
crate::task::set_task_submit_cb_extern(None, std::ptr::null_mut());
let cache = temp_subdir("unavail");
let s = ConformManager::instance()
.get_conform_state(&cache, "missing.mp4", 0, 48000, 0x3, 0, false)
.unwrap();
assert_eq!(s, ConformState::Unavailable);
}
#[test]
fn get_conform_state_exists_when_files_present() {
let cache = temp_subdir("exists");
let m = ConformManager::instance();
for i in 0..2 {
let f = m
.get_conform_filename(&cache, "media.mp4", 0, 48000, 0x3, 0, i)
.unwrap();
std::fs::write(&f, b"pcm").unwrap();
}
let s = m
.get_conform_state(&cache, "media.mp4", 0, 48000, 0x3, 0, false)
.unwrap();
assert_eq!(s, ConformState::Exists);
}
#[test]
fn get_conform_state_generating_when_registered() {
let _g = super::test_util::REG_LOCK.lock().unwrap();
crate::task::set_task_submit_cb_extern(
Some(super::test_util::accept_cb),
std::ptr::null_mut(),
);
let cache = temp_subdir("generating");
let s = ConformManager::instance()
.get_conform_state(&cache, "missing.mp4", 0, 48000, 0x3, 0, false)
.unwrap();
crate::task::set_task_submit_cb_extern(None, std::ptr::null_mut());
assert_eq!(s, ConformState::Generating);
}
}
-720
View File
@@ -1,720 +0,0 @@
// 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/>.
//! `olive::Decoder` and its supporting types — the media-decoder trait.
//!
//! Mirrors `src/codec/src/decoder.h`. The C++ abstract base plus its
//! FFmpeg/OIIO subclasses become the [`Decoder`] trait (decision 2 in
//! README.md); probe/dispatch lives on the registry functions at the
//! bottom of this module. Audio is handled in raw interleaved-float
//! buffers matching the C ABI, not `oakcore_rs::SampleBuffer` (which the
//! crate does not export).
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use oakcore_rs::{Rational, TimeRange};
use crate::bridge::render::{OakCancelAtom, OakRenderTexture};
use crate::footagedescription::FootageDescription;
use crate::frame::Frame;
/// `oakcodec_video_stream_info` — POD probe output describing one video
/// stream; see `include/codec/decoder.h`.
#[repr(C)]
pub struct OakCodecVideoStreamInfo {
/// Stream index.
pub stream_index: i32,
/// Width in pixels.
pub width: i32,
/// Height in pixels.
pub height: i32,
/// Frame-rate numerator.
pub frame_rate_num: i32,
/// Frame-rate denominator.
pub frame_rate_den: i32,
/// Stream length in time-base units.
pub duration_ts: i64,
/// Time-base numerator (seconds per time-base unit).
pub time_base_num: i32,
/// Time-base denominator.
pub time_base_den: i32,
/// Native delivery `OakPixelFormat`.
pub format: i32,
/// Plane channel count.
pub channel_count: i32,
/// ISO/IEC 23001-8 color-primaries code point (0 = unknown).
pub color_primaries: i32,
/// ISO/IEC 23001-8 color-transfer code point (0 = unknown).
pub color_trc: i32,
/// 1 when the stream is interlaced.
pub interlaced: i32,
}
/// `oakcodec_audio_stream_info` — POD probe output describing one audio
/// stream; see `include/codec/decoder.h`.
#[repr(C)]
pub struct OakCodecAudioStreamInfo {
/// Stream index.
pub stream_index: i32,
/// Sample rate (Hz).
pub sample_rate: i32,
/// ffmpeg-style channel mask (e.g. 0x3 = stereo).
pub channel_layout: u64,
/// Channel count.
pub channel_count: i32,
/// Stream length in time-base units.
pub duration_ts: i64,
/// Time-base numerator.
pub time_base_num: i32,
/// Time-base denominator.
pub time_base_den: i32,
}
/// Local replacement for `render/rendermodes.h` (oakrender C API has no
/// render-mode counterpart). Values mirror engine/render/rendermodes.h:
/// k_offline = 0, k_online = 1.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(i32)]
pub enum RenderMode {
/// Offline / background render.
Offline = 0,
/// Online / real-time render.
Online = 1,
}
/// "Don't force a color range" sentinel for
/// [`RetrieveVideoParams::force_range`] (the actual ranges are the
/// `OAKCOMMON_COLOR_RANGE_*` values).
pub const K_COLOR_RANGE_DEFAULT: i32 = -1;
/// `Decoder::RetrieveVideoParams` — what a video retrieve call needs.
pub struct RetrieveVideoParams {
/// Stream to read from.
pub stream: CodecStream,
/// Timestamp, rational seconds.
pub time: Rational,
/// Length of footage before the start (for early-seek semantics).
pub length: TimeRange,
/// Color range override; [`K_COLOR_RANGE_DEFAULT`] means "don't force".
pub force_range: i32,
/// Image sequence: bake the frame number into the filename.
pub is_image_sequence: bool,
/// Image sequence digit count (derived from the filename).
pub image_sequence_digits: i32,
/// Image sequence number to substitute.
pub image_sequence_number: i64,
/// Render mode (drives texture-path choices in the implementations).
pub mode: RenderMode,
/// Frame alpha channel is premultiplied.
pub alpha_is_premultiplied: bool,
}
/// `Decoder::RetrieveAudioStatus` — outcome of an audio retrieve.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RetrieveAudioStatus {
/// Data written to the destination buffer.
Success,
/// The requested range is outside the footage.
InvalidRange,
/// The stream does not support audio.
Unsupported,
/// Media requires a conform that could not be produced.
ConformNeeded,
/// A decoder-level error occurred.
Error,
}
/// `Decoder::RetrieveState`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RetrieveState {
/// Ready to decode.
Ready,
/// Failed to open the stream.
FailedToOpen,
/// The stream index could not be located.
IndexUnavailable,
}
/// `Decoder::CodecStream` — identifies one (filename, stream) pair plus an
/// optional associated timeline block.
///
/// The block is an opaque `OakNodeBlock` handle that codec only stores and
/// compares, never dereferences or retains (borrowed pointer).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CodecStream {
filename: String,
stream: i32,
block: Option<crate::bridge::common::OakNodeBlock>,
}
impl CodecStream {
/// Empty, invalid stream.
pub fn new() -> Self {
CodecStream {
filename: String::new(),
stream: -1,
block: None,
}
}
/// New stream for `(filename, stream)` with an optional block.
pub fn with_block(
filename: String,
stream: i32,
block: Option<crate::bridge::common::OakNodeBlock>,
) -> Self {
CodecStream {
filename,
stream,
block,
}
}
/// Non-empty filename and non-negative stream index.
pub fn is_valid(&self) -> bool {
!self.filename.is_empty() && self.stream >= 0
}
/// The file exists on disk.
pub fn exists(&self) -> bool {
Path::new(&self.filename).exists()
}
/// Reset to the empty stream.
pub fn reset(&mut self) {
self.filename.clear();
self.stream = -1;
self.block = None;
}
/// Source filename.
pub fn filename(&self) -> &str {
&self.filename
}
/// Stream index within the source.
pub fn stream(&self) -> i32 {
self.stream
}
/// Associated timeline block (borrowed; only compared, never used).
pub fn block(&self) -> Option<crate::bridge::common::OakNodeBlock> {
self.block.clone()
}
}
/// `olive::Decoder` — abstraction over external media decoding.
///
/// Implementations are [`crate::ffmpeg::FFmpegDecoder`] and
/// [`crate::oiio::OIIODecoder`]. The trait surface mirrors the C++
/// abstract base; the refcounted handle that backs the public API wraps an
/// `Arc<dyn Decoder>`.
pub trait Decoder: Send + Sync {
/// Unique decoder id ("ffmpeg"/"oiio").
fn id(&self) -> String;
/// Whether this decoder supports video streams.
fn supports_video(&self) -> bool {
false
}
/// Whether this decoder supports audio streams.
fn supports_audio(&self) -> bool {
false
}
/// Whether this decoder can read the given file (static probe).
fn probe(
&self,
filename: &str,
cancelled: Option<&OakCancelAtom>,
) -> Option<FootageDescription>;
/// Open `stream` for decoding. Thread-safe.
fn open(&self, stream: &CodecStream) -> crate::error::Result<()>;
/// Close the currently open stream (safe when closed).
fn close(&self) -> crate::error::Result<()>;
/// The currently open stream (locked accessor).
fn stream(&self) -> CodecStream;
/// Retrieve a video frame into CPU memory.
fn retrieve_video_frame(
&self,
p: &RetrieveVideoParams,
) -> crate::error::Result<Arc<Frame>>;
/// Retrieve a video frame as a render texture (owned by caller).
fn retrieve_video(&self, p: &RetrieveVideoParams) -> crate::error::Result<OakRenderTexture>;
/// Retrieve interleaved audio covering `range` into `dest` (floats).
fn retrieve_audio(
&self,
dest: &mut [f32],
range: &TimeRange,
sample_rate: i32,
channel_layout: u64,
) -> crate::error::Result<RetrieveAudioStatus>;
/// Conform the open stream's audio into per-channel pcm files.
///
/// `sample_rate` / `channel_layout` / `sample_format` describe the
/// target audio format (`sample_format` is a
/// `olive::core::SampleFormat::Format` value). The C++ side builds its
/// `core::AudioParams` from these three — mirroring the C ABI
/// `oakcodec_decoder_conform_audio` argument list.
fn conform_audio(
&self,
output_filenames: &[String],
sample_rate: i32,
channel_layout: u64,
sample_format: i32,
cancelled: Option<&OakCancelAtom>,
) -> crate::error::Result<()>;
/// Offset of the audio start relative to the video (rational seconds).
fn get_audio_start_offset(&self) -> Rational {
// C++ default `virtual Rational get_audio_start_offset() const { return 0; }`
Rational::new(0, 1)
}
}
/// Placeholder decoder used by the built-in probe registry.
///
/// Reports the correct id and capability flags so id-based dispatch
/// (`create_from_id`) works, but every media operation is unimplemented
/// and returns `None` / an error. Used for the OIIO entry, whose Rust
/// implementation (`crate::oiio::OIIODecoder`) is still a dylib stub; the
/// FFmpeg entry is the real [`crate::ffmpeg::FFmpegDecoder`].
struct UnimplementedDecoder {
id: &'static str,
video: bool,
audio: bool,
}
impl UnimplementedDecoder {
fn new(id: &'static str, video: bool, audio: bool) -> Self {
UnimplementedDecoder { id, video, audio }
}
}
impl Decoder for UnimplementedDecoder {
fn id(&self) -> String {
self.id.to_string()
}
fn supports_video(&self) -> bool {
self.video
}
fn supports_audio(&self) -> bool {
self.audio
}
fn probe(
&self,
_filename: &str,
_cancelled: Option<&OakCancelAtom>,
) -> Option<FootageDescription> {
None
}
fn open(&self, _stream: &CodecStream) -> crate::error::Result<()> {
Err(crate::error::Error::Failed("decoder not yet implemented".to_string()))
}
fn close(&self) -> crate::error::Result<()> {
Err(crate::error::Error::Failed("decoder not yet implemented".to_string()))
}
fn stream(&self) -> CodecStream {
CodecStream::new()
}
fn retrieve_video_frame(
&self,
_p: &RetrieveVideoParams,
) -> crate::error::Result<Arc<Frame>> {
Err(crate::error::Error::Failed("decoder not yet implemented".to_string()))
}
fn retrieve_video(
&self,
_p: &RetrieveVideoParams,
) -> crate::error::Result<OakRenderTexture> {
Err(crate::error::Error::Failed("decoder not yet implemented".to_string()))
}
fn retrieve_audio(
&self,
_dest: &mut [f32],
_range: &TimeRange,
_sample_rate: i32,
_channel_layout: u64,
) -> crate::error::Result<RetrieveAudioStatus> {
Err(crate::error::Error::Failed("decoder not yet implemented".to_string()))
}
fn conform_audio(
&self,
_output_filenames: &[String],
_sample_rate: i32,
_channel_layout: u64,
_sample_format: i32,
_cancelled: Option<&OakCancelAtom>,
) -> crate::error::Result<()> {
Err(crate::error::Error::Failed("decoder not yet implemented".to_string()))
}
}
/// `Decoder::create_from_id` — instantiate a decoder by id, or `None`.
pub fn create_from_id(id: &str) -> Option<Arc<dyn Decoder>> {
if id.is_empty() {
return None;
}
receive_list_of_all_decoders()
.into_iter()
.find(|d| d.id() == id)
}
/// Test-injected decoder registry (see [`set_test_decoders`]); empty when
/// not injected, in which case the built-in list below is used.
static TEST_DECODERS: OnceLock<Mutex<Vec<Arc<dyn Decoder>>>> = OnceLock::new();
/// Serializes every test that reads the built-in decoder registry. The ffi
/// decoder tests inject through `crate::ffi::lock_tests()` (the shared
/// `TEST_LOCK`), so the registry assertions below take that same lock to
/// never race with an injected list.
#[cfg(test)]
fn registry_guard() -> std::sync::MutexGuard<'static, ()> {
crate::ffi::lock_tests()
}
/// Replace the decoder registry with `list`; pass an empty list to restore
/// the built-in decoders.
///
/// Test/extension support (the C ABI has no way to register a decoder, so
/// the contract tests drive the probe/dispatch paths through a fake
/// decoder). Hidden from docs; never called by production code.
#[doc(hidden)]
pub fn set_test_decoders(list: Vec<Arc<dyn Decoder>>) {
let store = TEST_DECODERS.get_or_init(|| Mutex::new(Vec::new()));
*store.lock().unwrap() = list;
}
/// `Decoder::receive_list_of_all_decoders` — all registered decoders.
///
/// Order is probe priority, mirroring C++: OIIO (more specific) before
/// FFmpeg (format-agnostic fallback). The OIIO entry is an
/// [`UnimplementedDecoder`] stub (the OIIO engine is not ported); the
/// FFmpeg entry is the real [`crate::ffmpeg::FFmpegDecoder`]. When tests
/// injected a non-empty list via [`set_test_decoders`], that list takes
/// precedence.
pub fn receive_list_of_all_decoders() -> Vec<Arc<dyn Decoder>> {
if let Some(store) = TEST_DECODERS.get() {
let injected = store.lock().unwrap();
if !injected.is_empty() {
return injected.clone();
}
}
vec![
Arc::new(UnimplementedDecoder::new("oiio", false, false)),
Arc::new(crate::ffmpeg::FFmpegDecoder::new()),
]
}
/// Image-sequence filename heuristics (static).
///
/// Replace the trailing digit run of the filename stem with the
/// zero-padded decimal representation of `number` (keeps the same digit
/// count), mirroring `Decoder::transform_image_sequence_file_name`.
pub fn transform_image_sequence_file_name(filename: &str, number: i64) -> String {
let digit_count = get_image_sequence_digit_count(filename) as usize;
let path = Path::new(filename);
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(filename);
// QFileInfo::completeBaseName(): filename up to the first '.'.
let original_basename = match file_name.find('.') {
Some(dot) => &file_name[..dot],
None => file_name,
};
// New stem = original stem minus the trailing digit run, plus the
// zero-padded number (`snprintf("%0*lld", digit_count, number)`).
let cut = original_basename.len().saturating_sub(digit_count);
let new_basename = format!(
"{}{:0width$}",
&original_basename[..cut],
number,
width = digit_count
);
// Replace every occurrence of the original stem in the filename.
let mut new_filename = file_name.to_string();
let mut pos = 0;
while let Some(rel) = new_filename[pos..].find(original_basename) {
let start = pos + rel;
let end = start + original_basename.len();
new_filename.replace_range(start..end, &new_basename);
pos = start + new_basename.len();
}
match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => {
Path::new(parent).join(&new_filename).to_string_lossy().into_owned()
}
_ => new_filename,
}
}
/// Number of trailing digits in the filename stem (0 = not a sequence).
pub fn get_image_sequence_digit_count(filename: &str) -> i32 {
let file_name = Path::new(filename)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(filename);
// QFileInfo::completeBaseName(): filename up to the first '.'.
let stem = match file_name.find('.') {
Some(dot) => &file_name[..dot],
None => file_name,
};
let mut count: i32 = 0;
for ch in stem.chars().rev() {
if ch.is_ascii_digit() {
count += 1;
} else {
break;
}
}
count
}
/// Numeric value of the trailing digits (0 when there are none).
///
/// Mirrors C++ `Decoder::get_image_sequence_index`, which slices the
/// trailing digit run (`basename.substr(basename.size() - digit_count)`) and
/// passes it to `strtoll`. Because that slice is empty when there are no
/// trailing digits (digit_count == 0) and all-digits otherwise, the value is
/// the parsed number, or `0` for a non-sequence.
pub fn get_image_sequence_index(filename: &str) -> i64 {
let digit_count = get_image_sequence_digit_count(filename) as usize;
let file_name = Path::new(filename)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(filename);
// QFileInfo::completeBaseName(): filename up to the first '.'.
let stem = match file_name.find('.') {
Some(dot) => &file_name[..dot],
None => file_name,
};
// Trailing digit run (empty when the stem has no trailing digits).
let start = stem.len().saturating_sub(digit_count);
let number_only = &stem[start..];
// `strtoll(..., base 10)`: the slice is empty-or-digits, so a plain
// decimal parse with 0 on failure reproduces the C++ result.
number_only.parse::<i64>().unwrap_or(0)
}
/// The `k_any_timecode` rational constant.
///
/// C++ `const Rational Decoder::k_any_timecode = RATIONAL_MIN;`, which the
/// i32 reduction cap normalizes to `-2147483647/1`.
pub fn k_any_timecode() -> Rational {
Rational::new(-2147483647, 1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codec_stream_new_is_invalid() {
let s = CodecStream::new();
assert!(!s.is_valid());
assert!(s.filename().is_empty());
assert_eq!(s.stream(), -1);
assert_eq!(s.block(), None);
}
#[test]
fn codec_stream_with_block_is_valid() {
let s = CodecStream::with_block("video.mov".to_string(), 1, None);
assert!(s.is_valid());
assert_eq!(s.filename(), "video.mov");
assert_eq!(s.stream(), 1);
// Negative stream index is invalid regardless of filename.
let bad = CodecStream::with_block("video.mov".to_string(), -1, None);
assert!(!bad.is_valid());
}
#[test]
fn codec_stream_reset_clears() {
let mut s = CodecStream::with_block("video.mov".to_string(), 2, None);
s.reset();
assert!(!s.is_valid());
assert!(s.filename().is_empty());
assert_eq!(s.stream(), -1);
}
#[test]
fn digit_count_counts_trailing_digits() {
assert_eq!(get_image_sequence_digit_count("frame_0001.png"), 4);
assert_eq!(get_image_sequence_digit_count("frame.png"), 0);
assert_eq!(get_image_sequence_digit_count("img000.jpg"), 3);
// Digits before the final char are not trailing digits.
assert_eq!(get_image_sequence_digit_count("a1b.png"), 0);
}
#[test]
fn image_sequence_index_parses_number() {
assert_eq!(get_image_sequence_index("frame_0001.png"), 1);
assert_eq!(get_image_sequence_index("img012.jpg"), 12);
assert_eq!(get_image_sequence_index("0009.png"), 9);
// No trailing digits: the sliced run is empty, so the value is 0.
assert_eq!(get_image_sequence_index("frame.png"), 0);
assert_eq!(get_image_sequence_index("12abc.png"), 0);
}
#[test]
fn transform_image_sequence_substitutes_number() {
assert_eq!(
transform_image_sequence_file_name("frame_0001.png", 5),
"frame_0005.png"
);
assert_eq!(
transform_image_sequence_file_name("dir/img012.jpg", 7),
"dir/img007.jpg"
);
// No digit run: number appended with no padding (C++ behavior).
assert_eq!(
transform_image_sequence_file_name("frame.png", 3),
"frame3.png"
);
// All-digit stem: whole run is replaced.
assert_eq!(
transform_image_sequence_file_name("0001.png", 7),
"0007.png"
);
}
#[test]
fn k_any_timecode_is_rational_min() {
let tc = k_any_timecode();
assert_eq!(tc.numerator(), -2147483647);
assert_eq!(tc.denominator(), 1);
}
#[test]
fn registry_lists_oiio_then_ffmpeg() {
let _g = registry_guard();
let list = receive_list_of_all_decoders();
let ids: Vec<String> = list.iter().map(|d| d.id()).collect();
// Probe priority: OIIO (specific) first, FFmpeg (fallback) last.
assert_eq!(ids, vec!["oiio".to_string(), "ffmpeg".to_string()]);
}
#[test]
fn create_from_id_matches_registry() {
let _g = registry_guard();
assert!(create_from_id("ffmpeg").is_some());
assert!(create_from_id("oiio").is_some());
assert_eq!(create_from_id("ffmpeg").unwrap().id(), "ffmpeg");
assert_eq!(create_from_id("oiio").unwrap().id(), "oiio");
// Unknown and empty ids return None.
assert!(create_from_id("nope").is_none());
assert!(create_from_id("").is_none());
}
}
#[cfg(test)]
mod tests_unimplemented {
use super::*;
fn builtin(id: &str) -> Arc<dyn Decoder> {
create_from_id(id).unwrap()
}
#[test]
fn ffmpeg_builtin_fails_on_missing_media_and_closes() {
let _g = registry_guard();
let d = builtin("ffmpeg");
assert!(d.supports_video());
assert!(d.supports_audio());
// A nonexistent file cannot be probed or opened.
assert!(d.probe("x.mp4", None).is_none());
let s = CodecStream::with_block("x.mp4".to_string(), 0, None);
assert!(d.open(&s).is_err());
// C++ parity: a failed open leaves the decoder closed.
assert_eq!(d.stream().filename(), "");
assert!(d.close().is_ok());
let p = RetrieveVideoParams {
stream: CodecStream::new(),
time: Rational::new(0, 1),
length: TimeRange::default(),
force_range: K_COLOR_RANGE_DEFAULT,
is_image_sequence: false,
image_sequence_digits: 0,
image_sequence_number: 0,
mode: RenderMode::Offline,
alpha_is_premultiplied: false,
};
assert!(d.retrieve_video_frame(&p).is_err());
assert!(d.retrieve_video(&p).is_err());
let mut dest = [0f32; 4];
assert!(d
.retrieve_audio(
&mut dest,
&TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
48000,
0x3
)
.is_err());
assert!(d
.conform_audio(&["a.pcm".to_string()], 48000, 0x3, 10, None)
.is_err());
// OIIO reports no media capabilities.
let o = builtin("oiio");
assert!(!o.supports_video());
assert!(!o.supports_audio());
}
#[test]
fn get_audio_start_offset_defaults_to_zero() {
let _g = registry_guard();
let d = builtin("ffmpeg");
let off = d.get_audio_start_offset();
assert_eq!(off.numerator(), 0);
assert_eq!(off.denominator(), 1);
}
}
-341
View File
@@ -1,341 +0,0 @@
// 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/>.
//! `olive::Encoder` — abstract base for media encoders.
//!
//! Mirrors `src/codec/src/encoder.h`. Implementations are
//! [`crate::ffmpeg::FFmpegEncoder`] and [`crate::oiio::OIIOEncoder`]. The
//! workflow (C ABI encoder.h) is: fill an `EncodingParams` → init → open →
//! write_video/audio/subtitle → flush. The trait mirrors the C++ virtual
//! surface.
use std::sync::{Arc, Mutex, OnceLock};
use oakcore_rs::{PixelFormat, SampleFormat};
use crate::encodingparams::EncodingParams;
use crate::frame::Frame;
/// `olive::Encoder` — encoder trait. Backs the refcounted encoder handle.
pub trait Encoder: Send + Sync {
/// Unique encoder id.
fn id(&self) -> String;
/// Whether this encoder writes video.
fn supports_video(&self) -> bool {
false
}
/// Whether this encoder writes audio.
fn supports_audio(&self) -> bool {
false
}
/// Whether this encoder writes subtitles.
fn supports_subtitles(&self) -> bool {
false
}
/// Whether this encoder writes an image sequence.
fn supports_image_sequences(&self) -> bool {
false
}
/// Whether this encoder is deterministic for a given config
/// (used for cache keys).
fn is_configurable(&self) -> bool {
false
}
/// Configure the encoder (per-codec options like `crf`).
fn configure(&self, params: &EncodingParams) -> crate::error::Result<()>;
/// Open the output file and write headers.
fn open(&self) -> crate::error::Result<()>;
/// Close the output (write trailer); idempotent.
fn close(&self) -> crate::error::Result<()>;
/// Encode one video frame (converts delivery pixel format internally).
fn write_video(&self, frame: &Frame) -> crate::error::Result<()>;
/// Encode interleaved float audio samples.
fn write_audio(&self, samples: &[f32], frame_count: i32) -> crate::error::Result<()>;
/// Encode one subtitle entry (times in seconds).
fn write_subtitle(
&self,
text: &str,
in_seconds: f64,
out_seconds: f64,
) -> crate::error::Result<()>;
/// Flush encoders, write the trailer, close the file.
fn flush(&self) -> crate::error::Result<()>;
/// The pixel format the encoder wants frames in (or `None`).
fn desired_pixel_format(&self) -> Option<PixelFormat>;
/// The sample format the encoder wants audio in (or `None`).
fn desired_sample_format(&self) -> Option<SampleFormat>;
/// The configured output filename.
fn filename(&self) -> String;
/// Human-readable detail of the last failed operation (empty when
/// none). Mirrors the C++ `Encoder::get_error()` used by
/// `oakcodec_encoder_last_error`.
fn get_error(&self) -> String {
String::new()
}
}
/// Test-injected encoder registry (see [`set_test_encoders`]); empty when
/// not injected, in which case [`create_from_params`] falls back to the
/// built-in format mapping.
static TEST_ENCODERS: OnceLock<Mutex<Vec<Arc<dyn Encoder>>>> = OnceLock::new();
/// Replace the encoder registry with `list`; pass an empty list to restore
/// the built-in behavior.
///
/// Test/extension support (the C ABI has no way to register an encoder, so
/// the contract tests drive the encode state machine through a fake
/// encoder). Hidden from docs; never called by production code.
#[doc(hidden)]
pub fn set_test_encoders(list: Vec<Arc<dyn Encoder>>) {
let store = TEST_ENCODERS.get_or_init(|| Mutex::new(Vec::new()));
*store.lock().unwrap() = list;
}
/// `Encoder::create_from_params` — instantiate an encoder for `params`.
///
/// # CPP-PARITY
/// `src/codec/src/encoder.cpp` `create_from_params` → `create_from_format`
/// picks the FFmpeg/OIIO implementation from `params.format` (DNxHD,
/// Matroska, QuickTime, MPEG-4 video/audio, WAV, AIFF, MP3, FLAC, Ogg,
/// WebM, SRT → FFmpeg; OpenEXR, PNG, TIFF → OIIO; anything else → `None`).
/// A non-empty test-injected list (see [`set_test_encoders`]) wins over the
/// built-in mapping. The concrete implementations are dylib stubs whose
/// `open()` fails with a clear message, so an initialized encoder handle is
/// always constructible for a recognized format.
pub fn create_from_params(params: &EncodingParams) -> Option<Arc<dyn Encoder>> {
if let Some(store) = TEST_ENCODERS.get() {
let injected = store.lock().unwrap();
if !injected.is_empty() {
return injected.first().cloned();
}
}
match encoder_type_from_format(params.format) {
Some(EncoderType::FFmpeg) => {
Some(Arc::new(crate::ffmpeg::FFmpegEncoder::with_params(params.clone())))
}
Some(EncoderType::OIIO) => {
Some(Arc::new(crate::oiio::OIIOEncoder { params: params.clone() }))
}
None => None,
}
}
/// `Encoder::Type` mirror (`encoder.cpp` `get_type_from_format`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum EncoderType {
/// `k_encoder_type_f_fmpeg`.
FFmpeg,
/// `k_encoder_type_oiio`.
OIIO,
}
/// `Encoder::get_type_from_format` — the implementation family for an
/// `ExportFormat::Format` int; `None` for unknown/`Count`.
fn encoder_type_from_format(format: i32) -> Option<EncoderType> {
match format {
// FFmpeg-backed containers.
0 | 1 | 2 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 => Some(EncoderType::FFmpeg),
// OIIO-backed still-image formats.
3 | 5 | 6 => Some(EncoderType::OIIO),
_ => None,
}
}
/// Range `(start, end)` of a "[#####]" digit placeholder beginning at
/// `pos` (`bytes[pos] == '['`), or `None`.
///
/// CPP-PARITY: `encoder.cpp` `k_image_sequence_contains_digits` regex
/// `\[[#]+\]` — a `[`, one or more `#`, then `]`.
fn placeholder_range(bytes: &[u8], pos: usize) -> Option<(usize, usize)> {
if bytes.get(pos) != Some(&b'[') {
return None;
}
let mut j = pos + 1;
while bytes.get(j) == Some(&b'#') {
j += 1;
}
if j > pos + 1 && bytes.get(j) == Some(&b']') {
Some((pos, j + 1))
} else {
None
}
}
/// `Encoder::filename_contains_digit_placeholder` — whether `filename`
/// contains a "[#####]" digit placeholder.
///
/// CPP-PARITY: `encoder.cpp:137` (`std::regex_search` on
/// `k_image_sequence_contains_digits`).
pub fn filename_contains_digit_placeholder(filename: &str) -> bool {
let bytes = filename.as_bytes();
(0..bytes.len()).any(|i| placeholder_range(bytes, i).is_some())
}
/// `Encoder::get_image_sequence_placeholder_digit_count` — number of `#` in
/// the filename's "[#####]" placeholder; 0 when none.
///
/// CPP-PARITY: `encoder.cpp:119` — the C++ finds the first
/// `k_image_sequence_contains_digits` match and counts its `#`s, which is
/// exactly the match length minus the two brackets.
pub fn image_sequence_placeholder_digit_count(filename: &str) -> i32 {
let bytes = filename.as_bytes();
for i in 0..bytes.len() {
if let Some((start, end)) = placeholder_range(bytes, i) {
return (end - start - 2) as i32;
}
}
0
}
/// `Encoder::filename_remove_digit_placeholder` — `filename` with every
/// "[#####]" placeholder removed; an optional single separator char
/// (`-`, `.`, ` `, `_`) immediately before the placeholder goes with it.
///
/// CPP-PARITY: `encoder.cpp:142` (`std::regex_replace` on
/// `k_image_sequence_remove_digits` = `[\-\.\ \_]?\[[#]+\]`, empty
/// replacement, all matches).
pub fn filename_remove_digit_placeholder(filename: &str) -> String {
let bytes = filename.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
// A separator is consumed only when a placeholder follows it.
let ph_start = match bytes[i] {
b'-' | b'.' | b' ' | b'_' if placeholder_range(bytes, i + 1).is_some() => {
i + 1
}
_ => i,
};
match placeholder_range(bytes, ph_start) {
Some((_, end)) => i = end,
None => {
out.push(bytes[i]);
i += 1;
}
}
}
String::from_utf8(out).unwrap_or_else(|_| filename.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_from_params_maps_formats() {
let mut p = EncodingParams::default();
// FFmpeg-backed containers.
for fmt in [0, 1, 2, 4, 7, 8, 9, 10, 11, 12, 13, 14] {
p.format = fmt;
let e = create_from_params(&p).expect("format {fmt}");
assert_eq!(e.id(), "ffmpeg", "format {fmt}");
}
// OIIO-backed still images.
for fmt in [3, 5, 6] {
p.format = fmt;
let e = create_from_params(&p).expect("format {fmt}");
assert_eq!(e.id(), "oiio", "format {fmt}");
}
// Unknown / Count -> None (C++ `k_encoder_type_none`).
p.format = 15;
assert!(create_from_params(&p).is_none());
p.format = -1;
assert!(create_from_params(&p).is_none());
}
#[test]
fn get_error_defaults_to_empty() {
let e = UnimplementedDummy;
assert_eq!(e.get_error(), "");
}
#[test]
fn image_sequence_placeholder_helpers() {
// contains: "[#####]" style placeholder only.
assert!(filename_contains_digit_placeholder("/tmp/out_[#####].png"));
assert!(filename_contains_digit_placeholder("out[#].png"));
assert!(!filename_contains_digit_placeholder("/tmp/out.png"));
assert!(!filename_contains_digit_placeholder("out[####.png"));
assert!(!filename_contains_digit_placeholder("out[].png"));
// digit count: number of '#' in the first placeholder.
assert_eq!(image_sequence_placeholder_digit_count("/tmp/out_[#####].png"), 5);
assert_eq!(image_sequence_placeholder_digit_count("out[#].png"), 1);
assert_eq!(image_sequence_placeholder_digit_count("a[##]b[####]c"), 2);
assert_eq!(image_sequence_placeholder_digit_count("/tmp/out.png"), 0);
// remove: separator char before the placeholder goes with it.
assert_eq!(filename_remove_digit_placeholder("/tmp/out_[#####].png"), "/tmp/out.png");
assert_eq!(filename_remove_digit_placeholder("out[###].png"), "out.png");
assert_eq!(filename_remove_digit_placeholder("a_[#]b_[###]c"), "abc");
assert_eq!(filename_remove_digit_placeholder("/tmp/out.png"), "/tmp/out.png");
}
struct UnimplementedDummy;
impl Encoder for UnimplementedDummy {
fn id(&self) -> String {
"dummy".to_string()
}
fn configure(&self, _p: &EncodingParams) -> crate::error::Result<()> {
Ok(())
}
fn open(&self) -> crate::error::Result<()> {
Ok(())
}
fn close(&self) -> crate::error::Result<()> {
Ok(())
}
fn write_video(&self, _f: &Frame) -> crate::error::Result<()> {
Ok(())
}
fn write_audio(&self, _s: &[f32], _c: i32) -> crate::error::Result<()> {
Ok(())
}
fn write_subtitle(&self, _t: &str, _i: f64, _o: f64) -> crate::error::Result<()> {
Ok(())
}
fn flush(&self) -> crate::error::Result<()> {
Ok(())
}
fn desired_pixel_format(&self) -> Option<PixelFormat> {
None
}
fn desired_sample_format(&self) -> Option<SampleFormat> {
None
}
fn filename(&self) -> String {
String::new()
}
}
}

Some files were not shown because too many files have changed in this diff Show More