diff --git a/crates/erika/src/presenter.rs b/crates/erika/src/presenter.rs index 56fd5a2..33b8449 100644 --- a/crates/erika/src/presenter.rs +++ b/crates/erika/src/presenter.rs @@ -75,6 +75,7 @@ const DANMAKU_PREPARE_REFRESH_MARGIN: Duration = Duration::from_secs(4); const DANMAKU_PLAN_LOOKAHEAD: Duration = Duration::from_secs(8); const DANMAKU_PLAN_LOOKBACK_PADDING: Duration = Duration::from_secs(2); const DEFAULT_SUBTITLE_FONT_SCALE: f64 = 1.0; +const AUDIO_DELAY_LIMIT_SECONDS: f64 = 10.0; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TransitionFramePolicy { @@ -248,6 +249,7 @@ pub struct PresenterRuntime { current_surface_metrics: Option, current_danmaku_viewport: Option, subtitle_font_scale: f64, + audio_delay: f64, subtitles: SubtitleFrameState, overlay: OverlayTimeline, render_test_pattern_when_idle: bool, @@ -550,6 +552,7 @@ impl PresenterRuntime { current_surface_metrics: None, current_danmaku_viewport: None, subtitle_font_scale: DEFAULT_SUBTITLE_FONT_SCALE, + audio_delay: 0.0, subtitles: SubtitleFrameState::default(), overlay: config.overlay, render_test_pattern_when_idle: config.render_test_pattern_when_idle, @@ -709,6 +712,18 @@ impl PresenterRuntime { self.refresh_current_overlay(); } + /// Sets the audio delay in seconds with mpv `audio-delay` semantics: a + /// positive value delays audio relative to the video. The value is + /// clamped to ±10 s and takes effect from the next decoded audio frame; + /// already-queued output is not rewritten. + pub fn set_audio_delay(&mut self, delay_seconds: f64) { + self.audio_delay = normalize_audio_delay(delay_seconds); + } + + pub fn audio_delay(&self) -> f64 { + self.audio_delay + } + pub fn set_danmaku_timeline(&mut self, timeline: DanmakuTimeline) { self.danmaku_session.replace_default_track( timeline, @@ -1965,6 +1980,14 @@ impl PresenterRuntime { self.audio_configured = true; self.last_audio_clock_report = None; } + let mut frame = frame; + // The audio clock is anchored on frame pts, so shifting the pts here + // delays (or advances) audio relative to the video while clock sync, + // subtitles, and danmaku follow automatically. + frame.frame.pts = frame + .frame + .pts + .map(|pts| shifted_audio_pts(pts, self.audio_delay)); match self.audio_output.push(frame.frame) { Ok(_) => self.stats.pushed_audio_frames += 1, Err(error) => { @@ -2158,6 +2181,31 @@ fn normalize_subtitle_font_scale(scale: f64) -> f64 { } } +fn normalize_audio_delay(delay_seconds: f64) -> f64 { + if delay_seconds.is_finite() { + delay_seconds.clamp(-AUDIO_DELAY_LIMIT_SECONDS, AUDIO_DELAY_LIMIT_SECONDS) + } else { + 0.0 + } +} + +/// Maps a decoded audio frame's pts to its output pts for a given +/// `audio-delay`. A positive delay pushes the audio clock forwards (`pts + +/// delay`), which delays audio content relative to the video the clock +/// drives; a negative delay pulls it backwards, clamping at zero. +fn shifted_audio_pts(pts: Duration, delay_seconds: f64) -> Duration { + let delay = normalize_audio_delay(delay_seconds); + if delay == 0.0 { + return pts; + } + let magnitude = Duration::from_secs_f64(delay.abs()); + if delay > 0.0 { + pts.saturating_add(magnitude) + } else { + pts.saturating_sub(magnitude) + } +} + fn bump_generation(current_generation: &mut u64, danmaku_generation: &mut u64) { *danmaku_generation = danmaku_generation.saturating_add(1).max(1); *current_generation = current_generation @@ -3323,6 +3371,37 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text assert_eq!(presenter.volume(), 1.0); } + #[test] + fn audio_pts_shift_follows_mpv_audio_delay_semantics() { + let pts = Duration::from_secs(10); + + // Positive delay pushes audio content later than the video. + assert_eq!(shifted_audio_pts(pts, 2.0), Duration::from_secs(12)); + assert_eq!(shifted_audio_pts(pts, -2.0), Duration::from_secs(8)); + assert_eq!(shifted_audio_pts(pts, 0.0), pts); + // Underflow clamps at zero; delay magnitude clamps to ±10 s. + assert_eq!( + shifted_audio_pts(Duration::from_secs(1), -5.0), + Duration::ZERO + ); + assert_eq!(shifted_audio_pts(pts, 1_000.0), Duration::from_secs(20)); + assert_eq!(shifted_audio_pts(pts, f64::NAN), pts); + } + + #[test] + #[cfg(feature = "wgpu")] + fn presenter_audio_delay_setter_clamps_range() { + let mut presenter = PresenterRuntime::new(PresenterConfig::default()).unwrap(); + + assert_eq!(presenter.audio_delay(), 0.0); + presenter.set_audio_delay(25.0); + assert_eq!(presenter.audio_delay(), 10.0); + presenter.set_audio_delay(-25.0); + assert_eq!(presenter.audio_delay(), -10.0); + presenter.set_audio_delay(f64::NAN); + assert_eq!(presenter.audio_delay(), 0.0); + } + #[test] fn surface_dimensions_are_converted_to_full_output_danmaku_viewport() { let viewport = surface_metrics_to_viewport(SurfaceMetrics::new(1600, 900, 2.0)); diff --git a/crates/erika_capi/include/erika.h b/crates/erika_capi/include/erika.h index 9b0fa5c..2d93fc8 100644 --- a/crates/erika_capi/include/erika.h +++ b/crates/erika_capi/include/erika.h @@ -438,6 +438,9 @@ ErikaStatus erika_presenter_set_playback_rate(ErikaPresenterHandle *handle, doub ErikaStatus erika_presenter_set_volume(ErikaPresenterHandle *handle, double volume); ErikaStatus erika_presenter_set_upscaler(ErikaPresenterHandle *handle, int32_t mode); ErikaStatus erika_presenter_set_subtitle_scale(ErikaPresenterHandle *handle, double scale); +/* Positive audio-delay plays audio later than video. seconds is clamped to + * ±10 and applies from the next decoded frame. */ +ErikaStatus erika_presenter_set_audio_delay(ErikaPresenterHandle *handle, double seconds); ErikaStatus erika_presenter_set_output_headroom( ErikaPresenterHandle *handle, float headroom, diff --git a/crates/erika_capi/src/android_jni.rs b/crates/erika_capi/src/android_jni.rs index 3031606..335bf2c 100644 --- a/crates/erika_capi/src/android_jni.rs +++ b/crates/erika_capi/src/android_jni.rs @@ -763,6 +763,10 @@ unsafe fn invoke_presenter( let scale = required_f64(args, "scale")?; status_value(unsafe { erika_presenter_set_subtitle_scale(handle, scale) }) } + "setAudioDelay" => { + let seconds = required_f64(args, "seconds")?; + status_value(unsafe { erika_presenter_set_audio_delay(handle, seconds) }) + } "setOutputHeadroom" => { let headroom = required_f64(args, "headroom")? as f32; let known = diff --git a/crates/erika_capi/src/lib.rs b/crates/erika_capi/src/lib.rs index 81aee1c..cb78de2 100644 --- a/crates/erika_capi/src/lib.rs +++ b/crates/erika_capi/src/lib.rs @@ -1637,6 +1637,25 @@ pub unsafe extern "C" fn erika_presenter_set_volume( target_os = "android", target_env = "ohos" ))] +/// # Safety +/// `handle` must be a live pointer returned by `erika_presenter_create*`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_presenter_set_audio_delay( + handle: *mut ErikaPresenterHandle, + seconds: f64, +) -> ErikaStatus { + with_presenter_mut(handle, |handle| { + handle.presenter.set_audio_delay(seconds); + ErikaStatus::Ok + }) +} + +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android" +))] #[unsafe(no_mangle)] pub unsafe extern "C" fn erika_presenter_set_upscaler( handle: *mut ErikaPresenterHandle, @@ -2434,6 +2453,20 @@ pub unsafe extern "C" fn erika_presenter_set_volume( target_env = "ohos" )))] #[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_presenter_set_audio_delay( + _handle: *mut std::ffi::c_void, + _seconds: f64, +) -> ErikaStatus { + ErikaStatus::PlayerError +} + +#[cfg(not(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android" +)))] +#[unsafe(no_mangle)] pub unsafe extern "C" fn erika_presenter_set_upscaler( _handle: *mut std::ffi::c_void, _mode: i32, @@ -4092,6 +4125,32 @@ mod tests { target_env = "ohos" ))] #[test] + fn c_presenter_audio_delay_setter_accepts_valid_handle() { + assert_eq!( + unsafe { erika_presenter_set_audio_delay(std::ptr::null_mut(), 1.0) }, + ErikaStatus::NullPointer + ); + + let handle = erika_presenter_create(); + assert!(!handle.is_null()); + assert_eq!( + unsafe { erika_presenter_set_audio_delay(handle, -0.5) }, + ErikaStatus::Ok + ); + assert_eq!( + unsafe { erika_presenter_set_audio_delay(handle, 1_000.0) }, + ErikaStatus::Ok + ); + unsafe { erika_presenter_destroy(handle) }; + } + + #[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android" + ))] + #[test] fn c_presenter_set_upscaler_accepts_valid_handle() { assert_eq!( unsafe { diff --git a/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaFlutterPlugin.kt b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaFlutterPlugin.kt index eda00f5..6f2d15c 100644 --- a/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaFlutterPlugin.kt +++ b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaFlutterPlugin.kt @@ -1720,6 +1720,7 @@ class ErikaFlutterPlugin : "setVolume", "setUpscaler", "setSubtitleScale", + "setAudioDelay", "getUpscalerStatus", "getOutputStatus", "getPresenterStats", diff --git a/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift b/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift index 163529f..95365f5 100644 --- a/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift +++ b/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift @@ -356,6 +356,7 @@ private final class ErikaNativeLibrary { typealias SetVolumeFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 typealias SetUpscalerFn = @convention(c) (UnsafeMutableRawPointer?, Int32) -> Int32 typealias SetSubtitleScaleFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 + typealias SetAudioDelayFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 typealias GetUpscalerStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 typealias GetOutputStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 typealias SelectTrackFn = @convention(c) (UnsafeMutableRawPointer?, Int64) -> Int32 @@ -418,6 +419,7 @@ private final class ErikaNativeLibrary { let setVolume: SetVolumeFn? let setUpscaler: SetUpscalerFn? let setSubtitleScale: SetSubtitleScaleFn? + let setAudioDelay: SetAudioDelayFn? let getUpscalerStatus: GetUpscalerStatusFn? let getOutputStatus: GetOutputStatusFn? let selectAudioTrack: SelectTrackFn @@ -476,6 +478,7 @@ private final class ErikaNativeLibrary { setVolume = Self.loadOptional("erika_presenter_set_volume", from: libraryHandle, as: SetVolumeFn.self) setUpscaler = Self.loadOptional("erika_presenter_set_upscaler", from: libraryHandle, as: SetUpscalerFn.self) setSubtitleScale = Self.loadOptional("erika_presenter_set_subtitle_scale", from: libraryHandle, as: SetSubtitleScaleFn.self) + setAudioDelay = Self.loadOptional("erika_presenter_set_audio_delay", from: libraryHandle, as: SetAudioDelayFn.self) getUpscalerStatus = Self.loadOptional("erika_presenter_get_upscaler_status", from: libraryHandle, as: GetUpscalerStatusFn.self) getOutputStatus = Self.loadOptional("erika_presenter_get_output_status", from: libraryHandle, as: GetOutputStatusFn.self) selectAudioTrack = try Self.load("erika_presenter_select_audio_track", from: libraryHandle, as: SelectTrackFn.self) @@ -674,6 +677,14 @@ private final class ErikaPlayerHost { try check(setSubtitleScale(handle, clampedScale), operation: "set_subtitle_scale") } + func setAudioDelay(_ seconds: Double) throws { + guard let setAudioDelay = library.setAudioDelay else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_audio_delay") + } + let clampedSeconds = seconds.isFinite ? min(max(seconds, -10.0), 10.0) : 0.0 + try check(setAudioDelay(handle, clampedSeconds), operation: "set_audio_delay") + } + func upscalerStatus() throws -> [String: Any] { guard let getStatus = library.getUpscalerStatus else { throw ErikaPluginError.symbolMissing("erika_presenter_get_upscaler_status") @@ -1482,6 +1493,13 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan } try playerHost(from: args).setSubtitleScale(scale) result(nil) + case "setAudioDelay": + let args = try dictionaryArgs(call.arguments) + guard let seconds = doubleValue(args["seconds"]) else { + throw ErikaPluginError.invalidArguments("seconds is required.") + } + try playerHost(from: args).setAudioDelay(seconds) + result(nil) case "getUpscalerStatus": let args = try dictionaryArgs(call.arguments) result(try playerHost(from: args).upscalerStatus()) diff --git a/packages/erika_flutter/lib/src/erika_player.dart b/packages/erika_flutter/lib/src/erika_player.dart index d3504ec..0f8f2df 100644 --- a/packages/erika_flutter/lib/src/erika_player.dart +++ b/packages/erika_flutter/lib/src/erika_player.dart @@ -777,6 +777,22 @@ class ErikaPlayer { }); } + /// Sets the audio delay with mpv `audio-delay` semantics: a positive + /// [delay] plays audio later than the video. Clamped to plus or minus + /// 10 seconds. + Future setAudioDelay(Duration delay) async { + final playerId = await ensureCreated(); + final seconds = + (delay.inMicroseconds / Duration.microsecondsPerSecond).clamp( + -10.0, + 10.0, + ); + await _invoke('setAudioDelay', { + 'playerId': playerId, + 'seconds': seconds, + }); + } + Future getUpscalerStatus() async { final playerId = await ensureCreated(); final status = await _channel.invokeMethod>( diff --git a/packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift b/packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift index cc599a7..0ab2d78 100644 --- a/packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift +++ b/packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift @@ -227,6 +227,7 @@ private final class ErikaNativeLibrary { typealias SetVolumeFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 typealias SetUpscalerFn = @convention(c) (UnsafeMutableRawPointer?, Int32) -> Int32 typealias SetSubtitleScaleFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 + typealias SetAudioDelayFn = @convention(c) (UnsafeMutableRawPointer?, Double) -> Int32 typealias GetUpscalerStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 typealias GetOutputStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 typealias SelectTrackFn = @convention(c) (UnsafeMutableRawPointer?, Int64) -> Int32 @@ -289,6 +290,7 @@ private final class ErikaNativeLibrary { let setVolume: SetVolumeFn? let setUpscaler: SetUpscalerFn? let setSubtitleScale: SetSubtitleScaleFn? + let setAudioDelay: SetAudioDelayFn? let getUpscalerStatus: GetUpscalerStatusFn? let getOutputStatus: GetOutputStatusFn? let selectAudioTrack: SelectTrackFn @@ -341,6 +343,7 @@ private final class ErikaNativeLibrary { setVolume = Self.loadOptional("erika_presenter_set_volume", from: libraryHandle, as: SetVolumeFn.self) setUpscaler = Self.loadOptional("erika_presenter_set_upscaler", from: libraryHandle, as: SetUpscalerFn.self) setSubtitleScale = Self.loadOptional("erika_presenter_set_subtitle_scale", from: libraryHandle, as: SetSubtitleScaleFn.self) + setAudioDelay = Self.loadOptional("erika_presenter_set_audio_delay", from: libraryHandle, as: SetAudioDelayFn.self) getUpscalerStatus = Self.loadOptional("erika_presenter_get_upscaler_status", from: libraryHandle, as: GetUpscalerStatusFn.self) getOutputStatus = Self.loadOptional("erika_presenter_get_output_status", from: libraryHandle, as: GetOutputStatusFn.self) selectAudioTrack = try Self.load("erika_presenter_select_audio_track", from: libraryHandle, as: SelectTrackFn.self) @@ -552,6 +555,14 @@ private final class ErikaPlayerHost { try check(setSubtitleScale(handle, clampedScale), operation: "set_subtitle_scale") } + func setAudioDelay(_ seconds: Double) throws { + guard let setAudioDelay = library.setAudioDelay else { + throw ErikaPluginError.symbolMissing("erika_presenter_set_audio_delay") + } + let clampedSeconds = seconds.isFinite ? min(max(seconds, -10.0), 10.0) : 0.0 + try check(setAudioDelay(handle, clampedSeconds), operation: "set_audio_delay") + } + func upscalerStatus() throws -> [String: Any] { guard let getStatus = library.getUpscalerStatus else { throw ErikaPluginError.symbolMissing("erika_presenter_get_upscaler_status") @@ -1512,6 +1523,14 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan } try host.setSubtitleScale(scale) result(nil) + case "setAudioDelay": + let args = try dictionaryArgs(call.arguments) + let host = try playerHost(from: args) + guard let seconds = doubleValue(args["seconds"]) else { + throw ErikaPluginError.invalidArguments("seconds is required.") + } + try host.setAudioDelay(seconds) + result(nil) case "getUpscalerStatus": let args = try dictionaryArgs(call.arguments) result(try playerHost(from: args).upscalerStatus()) diff --git a/packages/erika_flutter/test/erika_player_test.dart b/packages/erika_flutter/test/erika_player_test.dart index 1a23d78..de4b4f4 100644 --- a/packages/erika_flutter/test/erika_player_test.dart +++ b/packages/erika_flutter/test/erika_player_test.dart @@ -402,6 +402,28 @@ void main() { await player.dispose(); }); + test('audio delay is forwarded in clamped seconds', () async { + final player = ErikaPlayer(); + + await player.setAudioDelay(const Duration(milliseconds: -250)); + await player.setAudioDelay(const Duration(seconds: 25)); + + final calls = playerCalls + .where((MethodCall call) => call.method == 'setAudioDelay') + .toList(); + expect(calls, hasLength(2)); + expect(calls[0].arguments, { + 'playerId': 7, + 'seconds': -0.25, + }); + expect(calls[1].arguments, { + 'playerId': 7, + 'seconds': 10.0, + }); + + await player.dispose(); + }); + test('window overlay methods forward surface geometry', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(playerChannel, (MethodCall call) async { diff --git a/packages/erika_flutter/windows/erika_flutter_plugin.cpp b/packages/erika_flutter/windows/erika_flutter_plugin.cpp index 56cbf6b..a36b403 100644 --- a/packages/erika_flutter/windows/erika_flutter_plugin.cpp +++ b/packages/erika_flutter/windows/erika_flutter_plugin.cpp @@ -640,6 +640,7 @@ struct ErikaFlutterPlugin::ErikaNativeLibrary { using SetVolumeFn = ErikaStatus (*)(ErikaPresenterHandle*, double); using SetUpscalerFn = ErikaStatus (*)(ErikaPresenterHandle*, int32_t); using SetSubtitleScaleFn = ErikaStatus (*)(ErikaPresenterHandle*, double); + using SetAudioDelayFn = ErikaStatus (*)(ErikaPresenterHandle*, double); using GetUpscalerStatusFn = ErikaStatus (*)(ErikaPresenterHandle*, ErikaUpscalerStatus*); using GetOutputStatusFn = @@ -748,6 +749,7 @@ struct ErikaFlutterPlugin::ErikaNativeLibrary { SetVolumeFn set_volume = nullptr; SetUpscalerFn set_upscaler = nullptr; SetSubtitleScaleFn set_subtitle_scale = nullptr; + SetAudioDelayFn set_audio_delay = nullptr; GetUpscalerStatusFn get_upscaler_status = nullptr; GetOutputStatusFn get_output_status = nullptr; SelectTrackFn select_audio_track = nullptr; @@ -807,6 +809,8 @@ struct ErikaFlutterPlugin::ErikaNativeLibrary { LoadOptional("erika_presenter_set_upscaler"); set_subtitle_scale = LoadOptional( "erika_presenter_set_subtitle_scale"); + set_audio_delay = LoadOptional( + "erika_presenter_set_audio_delay"); get_upscaler_status = LoadOptional( "erika_presenter_get_upscaler_status"); get_output_status = LoadOptional( @@ -1163,6 +1167,16 @@ struct ErikaFlutterPlugin::PlayerHost { Check(library->set_subtitle_scale(handle, clamped), "set_subtitle_scale"); } + void SetAudioDelay(double seconds) { + if (library->set_audio_delay == nullptr) { + throw PluginError( + "Missing Erika C ABI symbol: erika_presenter_set_audio_delay"); + } + const double clamped = + std::isfinite(seconds) ? std::clamp(seconds, -10.0, 10.0) : 0.0; + Check(library->set_audio_delay(handle, clamped), "set_audio_delay"); + } + EncodableValue GetUpscalerStatus() { if (library->get_upscaler_status == nullptr) { throw PluginError("Missing Erika C ABI symbol: erika_presenter_get_upscaler_status"); @@ -2143,6 +2157,10 @@ void ErikaFlutterPlugin::HandleMethodCall( PlayerFromArgs(args).SetSubtitleScale( DoubleValue(FindArg(args, "scale")).value_or(1.0)); result->Success(); + } else if (method == "setAudioDelay") { + PlayerFromArgs(args).SetAudioDelay( + DoubleValue(FindArg(args, "seconds")).value_or(0.0)); + result->Success(); } else if (method == "getUpscalerStatus") { result->Success(PlayerFromArgs(args).GetUpscalerStatus()); } else if (method == "getOutputStatus") {