Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions crates/erika/src/presenter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -248,6 +249,7 @@ pub struct PresenterRuntime {
current_surface_metrics: Option<SurfaceMetrics>,
current_danmaku_viewport: Option<DanmakuViewport>,
subtitle_font_scale: f64,
audio_delay: f64,
subtitles: SubtitleFrameState,
overlay: OverlayTimeline,
render_test_pattern_when_idle: bool,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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));
Expand Down
3 changes: 3 additions & 0 deletions crates/erika_capi/include/erika.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions crates/erika_capi/src/android_jni.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
59 changes: 59 additions & 0 deletions crates/erika_capi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1720,6 +1720,7 @@ class ErikaFlutterPlugin :
"setVolume",
"setUpscaler",
"setSubtitleScale",
"setAudioDelay",
"getUpscalerStatus",
"getOutputStatus",
"getPresenterStats",
Expand Down
18 changes: 18 additions & 0 deletions packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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())
Expand Down
16 changes: 16 additions & 0 deletions packages/erika_flutter/lib/src/erika_player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> setAudioDelay(Duration delay) async {
final playerId = await ensureCreated();
final seconds =
(delay.inMicroseconds / Duration.microsecondsPerSecond).clamp(
-10.0,
10.0,
);
await _invoke('setAudioDelay', <String, Object?>{
'playerId': playerId,
'seconds': seconds,
});
}

Future<ErikaUpscalerStatus> getUpscalerStatus() async {
final playerId = await ensureCreated();
final status = await _channel.invokeMethod<Map<dynamic, dynamic>>(
Expand Down
19 changes: 19 additions & 0 deletions packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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())
Expand Down
22 changes: 22 additions & 0 deletions packages/erika_flutter/test/erika_player_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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, <String, Object?>{
'playerId': 7,
'seconds': -0.25,
});
expect(calls[1].arguments, <String, Object?>{
'playerId': 7,
'seconds': 10.0,
});

await player.dispose();
});

test('window overlay methods forward surface geometry', () async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(playerChannel, (MethodCall call) async {
Expand Down
Loading
Loading