183 lines
6.3 KiB
C#
183 lines
6.3 KiB
C#
using System;
|
|
using Godot;
|
|
|
|
internal enum MovieAudioRoute
|
|
{
|
|
Muted = 0,
|
|
Music = 1,
|
|
SoundEffect = 2,
|
|
Voice = 3,
|
|
Movie = 4,
|
|
}
|
|
|
|
/// <summary>Godot-main-thread PCM sink and sound-hardware clock for one movie playback.</summary>
|
|
internal sealed class MovieAudioOutput : IDisposable
|
|
{
|
|
private const int MaximumPushFrames = 4096;
|
|
|
|
private readonly IMovieDecoder _decoder;
|
|
private readonly AudioStreamPlayer _player;
|
|
private readonly int _sampleRate;
|
|
private readonly double _outputLatencySeconds;
|
|
private AudioStreamGeneratorPlayback? _playback;
|
|
private MovieAudioChunk? _pending;
|
|
private int _pendingOffsetFrames;
|
|
private long _pendingGapFrames;
|
|
private long _submittedFrames;
|
|
private long _clockMs;
|
|
private bool _timelineAnchored;
|
|
private bool _started;
|
|
private bool _prerolling;
|
|
private bool _submissionCompleted;
|
|
private bool _disposed;
|
|
|
|
public MovieAudioRoute Route { get; }
|
|
public long ClockMs => _clockMs;
|
|
public long SubmittedThroughMs => _sampleRate <= 0 ? 0 : _submittedFrames * 1000 / _sampleRate;
|
|
public int BufferUnderruns => _playback?.GetSkips() ?? 0;
|
|
|
|
public MovieAudioOutput(Node owner, IMovieDecoder decoder, MovieAudioRoute route,
|
|
double outputLatencySeconds)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(owner);
|
|
_decoder = decoder ?? throw new ArgumentNullException(nameof(decoder));
|
|
MovieAudioInfo info = decoder.AudioInfo
|
|
?? throw new ArgumentException("movie decoder has no audio stream", nameof(decoder));
|
|
if (info.SampleRate <= 0 || info.Channels != 2)
|
|
throw new ArgumentOutOfRangeException(nameof(decoder), "movie PCM must be stereo with a positive sample rate");
|
|
|
|
Route = route;
|
|
_sampleRate = info.SampleRate;
|
|
_outputLatencySeconds = Math.Max(0, outputLatencySeconds);
|
|
var generator = new AudioStreamGenerator
|
|
{
|
|
MixRate = _sampleRate,
|
|
BufferLength = 0.25f,
|
|
};
|
|
_player = new AudioStreamPlayer
|
|
{
|
|
Stream = generator,
|
|
Bus = RouteBus(route),
|
|
VolumeDb = route == MovieAudioRoute.Muted ? -80.0f : 0.0f,
|
|
};
|
|
owner.AddChild(_player);
|
|
}
|
|
|
|
public void Update()
|
|
{
|
|
if (_disposed) return;
|
|
if (_started)
|
|
{
|
|
double audibleSeconds = _player.GetPlaybackPosition()
|
|
+ AudioServer.GetTimeSinceLastMix()
|
|
- _outputLatencySeconds;
|
|
long candidate = (long)Math.Floor(Math.Max(0, audibleSeconds) * 1000.0);
|
|
if (candidate > _clockMs) _clockMs = candidate;
|
|
}
|
|
FeedAvailablePcm();
|
|
_decoder.AdvancePlaybackClock(_clockMs);
|
|
}
|
|
|
|
private void FeedAvailablePcm()
|
|
{
|
|
while (true)
|
|
{
|
|
if (_pending == null && !_decoder.TryTakeAudioChunk(out _pending))
|
|
{
|
|
if (_decoder.AudioDecodingCompleted && !_submissionCompleted)
|
|
{
|
|
_submissionCompleted = true;
|
|
_decoder.MarkAudioSubmitted();
|
|
}
|
|
return;
|
|
}
|
|
if (_pending == null) return;
|
|
if (!_started) Start();
|
|
|
|
if (_pendingOffsetFrames == 0 && _pendingGapFrames == 0)
|
|
{
|
|
long audibleFrame = _clockMs * _sampleRate / 1000;
|
|
if (audibleFrame > _submittedFrames) _submittedFrames = audibleFrame;
|
|
long targetFrame = MovieAudioTimeline.PresentationFrame(
|
|
_pending.PresentationTimeMs, _sampleRate);
|
|
MovieAudioAdjustment adjustment = MovieAudioTimeline.Align(
|
|
_submittedFrames, targetFrame, _pending.FrameCount, _sampleRate, _timelineAnchored);
|
|
_pendingGapFrames = adjustment.GapFrames;
|
|
_pendingOffsetFrames = adjustment.SkipFrames;
|
|
_timelineAnchored = true;
|
|
}
|
|
|
|
int available = _playback!.GetFramesAvailable();
|
|
if (available <= 0) return;
|
|
if (_pendingGapFrames > 0)
|
|
{
|
|
int count = (int)Math.Min(Math.Min(_pendingGapFrames, available), MaximumPushFrames);
|
|
if (!_playback.PushBuffer(new Vector2[count])) return;
|
|
FinishPreroll();
|
|
_pendingGapFrames -= count;
|
|
_submittedFrames += count;
|
|
continue;
|
|
}
|
|
|
|
int remaining = _pending.FrameCount - _pendingOffsetFrames;
|
|
if (remaining <= 0)
|
|
{
|
|
_pending = null;
|
|
_pendingOffsetFrames = 0;
|
|
continue;
|
|
}
|
|
int frames = Math.Min(Math.Min(remaining, available), MaximumPushFrames);
|
|
var output = new Vector2[frames];
|
|
for (int index = 0; index < frames; index++)
|
|
{
|
|
int source = checked((_pendingOffsetFrames + index) * 2);
|
|
output[index] = new Vector2(
|
|
_pending.InterleavedStereo[source],
|
|
_pending.InterleavedStereo[source + 1]);
|
|
}
|
|
if (!_playback.PushBuffer(output)) return;
|
|
FinishPreroll();
|
|
_pendingOffsetFrames += frames;
|
|
_submittedFrames += frames;
|
|
if (_pendingOffsetFrames == _pending.FrameCount)
|
|
{
|
|
_pending = null;
|
|
_pendingOffsetFrames = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void Start()
|
|
{
|
|
_player.Play();
|
|
_player.StreamPaused = true;
|
|
_playback = _player.GetStreamPlayback() as AudioStreamGeneratorPlayback
|
|
?? throw new InvalidOperationException("Godot did not create movie audio generator playback");
|
|
_started = true;
|
|
_prerolling = true;
|
|
}
|
|
|
|
private void FinishPreroll()
|
|
{
|
|
if (!_prerolling) return;
|
|
_prerolling = false;
|
|
_player.StreamPaused = false;
|
|
}
|
|
|
|
private static StringName RouteBus(MovieAudioRoute route) => route switch
|
|
{
|
|
MovieAudioRoute.Music => "Music",
|
|
MovieAudioRoute.SoundEffect => "SFX",
|
|
MovieAudioRoute.Voice => "Voice",
|
|
_ => "Movie",
|
|
};
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
_player.Stop();
|
|
_player.QueueFree();
|
|
}
|
|
}
|