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:
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
add_subdirectory(src)
|
||||
add_subdirectory(c_api)
|
||||
|
||||
if(BUILD_TESTS)
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
@@ -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
|
||||
)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
# oakaudio 类覆盖映射表(C++ audio 模块 → oakaudio Rust crate)
|
||||
|
||||
> 逐类盘点 `src/audio/src` 与 `src/audio/c_api`。每一行标注 Rust 侧落点。
|
||||
> `// CPP-PARITY` 注释义务不变:凡承载布局/数值/边角行为的地方,标出
|
||||
> C++ 文件:行号。本表是初稿;实现阶段据实核对。
|
||||
|
||||
## 1. AudioManager(manager.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. AudioProcessor(processor.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. AudioSynchronizer(synchronizer.rs,纯静态)
|
||||
|
||||
| C++ | Rust 落点 |
|
||||
|---|---|
|
||||
| `place_by_source_time` | `synchronizer::place_by_source_time` |
|
||||
| `place_by_waveform_offset` | `synchronizer::place_by_waveform_offset` |
|
||||
|
||||
## 4. AudioLevelMeter(levelmeter.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. AudioVisualWaveform(waveform.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. AudioWaveformSync(waveformsync.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. PreviewAudioDevice(previewdevice.rs,header-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/app;crate 只存/汇总数据 |
|
||||
Generated
-555
@@ -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",
|
||||
]
|
||||
@@ -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" }
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(¶ms) {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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, ¶ms, 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() };
|
||||
}
|
||||
@@ -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()) };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()) };
|
||||
}
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 ¶ms,
|
||||
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 ¶ms,
|
||||
std::string *error_str)
|
||||
{
|
||||
if (input_device_ == paNoDevice) {
|
||||
return false;
|
||||
}
|
||||
|
||||
input_encoder_ = oakcodec_encoder_init(¶ms);
|
||||
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 ¶ms,
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 ¶ms, 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 ¶ms,
|
||||
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
|
||||
@@ -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 ¶ms)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 ¶ms)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 ¶ms);
|
||||
|
||||
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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(¶ms, 0, sizeof(params));
|
||||
params.audio_enabled = 1;
|
||||
EXPECT_EQ(oakaudio_manager_start_recording(none, ¶ms, 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(¶ms, 0, sizeof(params));
|
||||
EXPECT_EQ(oakaudio_manager_start_recording(m, ¶ms, 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Generated
-145
@@ -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"
|
||||
@@ -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" }
|
||||
@@ -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`).
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
add_subdirectory(src)
|
||||
add_subdirectory(c_api)
|
||||
|
||||
if(BUILD_TESTS)
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
@@ -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/ProxyIncludeAudio,ProxyParams 成员默认值兼作
|
||||
getter fallback(与 oakcommon 编译期默认值一致:1280x720/div1/crf23/
|
||||
veryfast/含音频)。
|
||||
3. **纹理路径功能回退**(oakrender 增补 shader-blit C API 后可恢复):
|
||||
oakrender C API 无通用 shader-blit,FFmpegDecoder 的 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.h,k_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 循环)。
|
||||
- 音频 decode(C API):需要 conform 的媒体在无 task 注册方时返回
|
||||
`OAKCODEC_E_STATE`(不产生后台 conform)。
|
||||
- `oakcodec_audio_stream_info.duration_ts` 恒 0(AudioParams 不带时长)。
|
||||
|
||||
## 符号可见性
|
||||
|
||||
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` 移入 codec(oiioframebridge.h,
|
||||
内部 C++ 函数),oakcommon 的 OIIO 映射函数保留。
|
||||
- 修复 `src/common/c_api/videoparams.cpp` 的 `convert_to_olive_format`
|
||||
switch 缺 break 穿透 bug(U8 穿透到 f32,bytes_per_pixel 返回 16)。
|
||||
@@ -1,8 +0,0 @@
|
||||
target_sources(oakcodec PRIVATE
|
||||
conform.cpp
|
||||
decoder.cpp
|
||||
encoder.cpp
|
||||
format.cpp
|
||||
frame.cpp
|
||||
proxy.cpp
|
||||
)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
Generated
-290
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user