Add synchronized MPEG movie audio

This commit is contained in:
gamer147
2026-07-25 11:49:50 -04:00
parent 1828701872
commit 843b282cc4
24 changed files with 1336 additions and 558 deletions

View File

@@ -42,6 +42,7 @@ public class MovieOpcodeTests
params FfmpegVideoFrame[] frames) : IFfmpegFrameSource
{
private readonly Queue<FfmpegVideoFrame> _frames = new(frames);
private readonly Queue<FfmpegAudioChunk> _audio = new();
private int _decodeCalls;
public FfmpegMovieInfo Info { get; } = info;
public int FailOnDecodeCall { get; init; } = -1;
@@ -53,6 +54,12 @@ public class MovieOpcodeTests
throw new InvalidDataException("synthetic decode failure");
return _frames.TryDequeue(out frame!);
}
public void EnqueueAudio(params FfmpegAudioChunk[] chunks)
{
foreach (FfmpegAudioChunk chunk in chunks) _audio.Enqueue(chunk);
}
public bool TryDecodeNextAudioChunk(out FfmpegAudioChunk chunk)
=> _audio.TryDequeue(out chunk!);
public void Dispose() => Disposed = true;
}
@@ -201,6 +208,91 @@ public class MovieOpcodeTests
Assert.Equal("synthetic decode failure", decoder.Failure);
}
[Fact]
public void FfmpegAudioDecoderQueuesTimestampedPcmAndCompletesAfterSubmissionAndClock()
{
var source = new FakeFfmpegFrameSource(
new FfmpegMovieInfo(1, 1, 100, 20, 1, true, 1000, 2, 50),
SyntheticMovieFrame(1, 0), SyntheticMovieFrame(2, 50));
source.EnqueueAudio(
new FfmpegAudioChunk(new float[100], 50, 0),
new FfmpegAudioChunk(Enumerable.Repeat(0.25f, 100).ToArray(), 50, 50));
using var clock = new ManualMoviePacingClock();
using var decoder = new FfmpegMovieDecoder(source, clock);
var chunks = new List<MovieAudioChunk>();
Assert.True(SpinWait.SpinUntil(() =>
{
while (decoder.TryTakeAudioChunk(out var chunk)) chunks.Add(chunk);
return decoder.AudioDecodingCompleted && chunks.Count == 2;
}, 1000));
Assert.Equal(new long[] { 0, 50 }, chunks.Select(chunk => chunk.PresentationTimeMs));
Assert.Equal(100, chunks.Sum(chunk => chunk.FrameCount));
decoder.MarkAudioSubmitted();
clock.AdvanceTo(99);
Assert.False(decoder.IsCompleted);
clock.AdvanceTo(100);
Assert.True(SpinWait.SpinUntil(() => decoder.IsCompleted, 1000));
Assert.Null(decoder.Failure);
}
[Fact]
public void MovieAudioTimelineDoesNotSpliceContinuousMpegBlocksAtRoundedMillisecondTimestamps()
{
const int sampleRate = 44100;
const int blockFrames = 1152;
const int blockCount = 4093; // OP.AGF's installed MPEG audio block count
long submittedFrames = 0;
bool anchored = false;
for (int block = 0; block < blockCount; block++)
{
long exactStartFrame = block * (long)blockFrames;
long roundedDownTimestampMs = exactStartFrame * 1000 / sampleRate;
long targetFrame = MovieAudioTimeline.PresentationFrame(
roundedDownTimestampMs, sampleRate);
MovieAudioAdjustment adjustment = MovieAudioTimeline.Align(
submittedFrames, targetFrame, blockFrames, sampleRate, anchored);
Assert.Equal(default, adjustment);
submittedFrames += blockFrames;
anchored = true;
}
Assert.Equal(blockCount * (long)blockFrames, submittedFrames);
}
[Fact]
public void MovieAudioTimelineStillPreservesMaterialGapsAndOverlaps()
{
const int sampleRate = 44100;
var initialGap = MovieAudioTimeline.Align(
submittedFrames: 0,
targetFrame: MovieAudioTimeline.PresentationFrame(100, sampleRate),
chunkFrames: 1152,
sampleRate: sampleRate,
timelineAnchored: false);
Assert.Equal(new MovieAudioAdjustment(4410, 0), initialGap);
var laterGap = MovieAudioTimeline.Align(
submittedFrames: 2304,
targetFrame: MovieAudioTimeline.PresentationFrame(100, sampleRate),
chunkFrames: 1152,
sampleRate: sampleRate,
timelineAnchored: true);
Assert.Equal(new MovieAudioAdjustment(2106, 0), laterGap);
var overlap = MovieAudioTimeline.Align(
submittedFrames: 2304,
targetFrame: MovieAudioTimeline.PresentationFrame(40, sampleRate),
chunkFrames: 1152,
sampleRate: sampleRate,
timelineAnchored: true);
Assert.Equal(new MovieAudioAdjustment(0, 540), overlap);
}
[Fact]
public void InitialRootFlagStartsSetAndClearsWhenExitScriptRuns()
{
@@ -413,39 +505,6 @@ public class MovieOpcodeTests
Assert.Equal(new byte[] { 0, 0, 1, 0xba }, movie.Bytes[..4]);
}
[Fact]
public void Sc0000MoviePayloadDecodesAn800By600FrameOnWindows()
{
if (!OperatingSystem.IsWindows()) return;
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
var resources = new ResourceMap(catalog, new Sys4AssetStore(catalog, Paths.GameDir));
var entry = resources.ResolveMovie(0x33)!;
using var decoder = new DirectShowMovieDecoder(resources.ReadMovie(entry));
Assert.True(decoder.StopTimeMs > 0, "DirectShow should expose a positive IMediaPosition stop time");
var deadline = DateTime.UtcNow.AddSeconds(10);
RgbaImage? frame = null;
while (DateTime.UtcNow < deadline && !decoder.TryTakeFrame(out frame))
Thread.Sleep(20);
Assert.NotNull(frame);
Assert.Equal(800, frame!.Width);
Assert.Equal(600, frame.Height);
Assert.Equal(800 * 600 * 4, frame.Pixels.Length);
byte[] firstPixels = frame.Pixels;
var changeDeadline = DateTime.UtcNow.AddSeconds(2);
bool changed = false;
while (DateTime.UtcNow < changeDeadline && !changed)
{
Thread.Sleep(20);
if (decoder.TryTakeFrame(out var later))
changed = !firstPixels.AsSpan().SequenceEqual(later.Pixels);
}
Assert.True(changed, "DirectShow should deliver changing MPEG frames, not one retained still");
}
[Theory]
[InlineData(0x2be3, "MVB961.AGF", 280, 352, 500)]
[InlineData(0x2b94, "MVB238.AGF", 280, 352, 866)]
@@ -485,6 +544,42 @@ public class MovieOpcodeTests
Assert.True(changed, $"{expectedName} should deliver changing decoded frames");
}
[Theory]
[InlineData(0x33, "CHAPTER.AGF")]
[InlineData(0x2bf1, "MVS001.AGF")]
[InlineData(0x335f, "LOGO.AGF")]
public void FfmpegShimDecodesRepresentativeMpegAudio(int resourceId, string expectedName)
{
if (!OperatingSystem.IsWindows()) return;
ConfigureFfmpegNativeProbe();
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
var resources = new ResourceMap(catalog, new Sys4AssetStore(catalog, Paths.GameDir));
var payload = resources.ReadMovie(resources.ResolveMovie(resourceId)!);
using var movie = new FfmpegMovieSession(payload);
Assert.Equal(expectedName, payload.Name);
Assert.True(movie.Info.HasAudio);
Assert.Equal(44100, movie.Info.AudioSampleRate);
Assert.Equal(2, movie.Info.AudioChannels);
Assert.True(movie.TryDecodeNextAudioChunk(out var first));
Assert.True(first.FrameCount > 0);
Assert.Equal(first.FrameCount * 2, first.InterleavedStereo.Length);
Assert.True(first.PresentationTimeMs >= 0);
Assert.All(first.InterleavedStereo, sample => Assert.True(float.IsFinite(sample)));
bool heardSignal = first.InterleavedStereo.Any(sample => Math.Abs(sample) > 0.00001f);
long priorTimestamp = first.PresentationTimeMs;
for (int block = 0; block < 500 && !heardSignal
&& movie.TryDecodeNextAudioChunk(out var later); block++)
{
Assert.True(later.PresentationTimeMs >= priorTimestamp);
Assert.All(later.InterleavedStereo, sample => Assert.True(float.IsFinite(sample)));
priorTimestamp = later.PresentationTimeMs;
heardSignal = later.InterleavedStereo.Any(sample => Math.Abs(sample) > 0.00001f);
}
Assert.True(heardSignal, $"{expectedName} should contain non-silent MPEG audio");
}
[Fact]
public void FfmpegShimRejectsTruncatedMovieWithBoundedDiagnostic()
{