Compare commits

2 Commits
Author SHA256 Message Date
copecone 61373f8271 Resolve Audio Clips From Level Files 2026-10-13 02:00:18 +09:00
copecone 8addbfb79d Match Game MP3 Decoding Timing 2026-10-13 01:59:53 +09:00
8 changed files with 398 additions and 13 deletions

No files matched your search

+20
View File
@@ -1,11 +1,31 @@
using System; using System;
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using ADOFAIRenderer.Util.Audio.Clip;
using HarmonyLib; using HarmonyLib;
using UnityEngine; using UnityEngine;
using UnityEngine.Audio; using UnityEngine.Audio;
namespace ADOFAIRenderer.Patch.Engine; namespace ADOFAIRenderer.Patch.Engine;
/// <summary>
/// Records the file path of each external clip the game loads (always active, also outside rendering,
/// because levels load their song and PlaySound files before a render starts).
/// FindOrLoadAudioClipExternal returns the clip already in audioLib when its key exists, so the path is recorded
/// only when the key is missing and the file is about to be loaded.
/// </summary>
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.FindOrLoadAudioClipExternal))]
public static class AudioManagerFindOrLoadAudioClipExternalPatch
{
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static void Prefix(AudioManager __instance, string path)
{
if (string.IsNullOrEmpty(path)) return;
if (__instance.audioLib.ContainsKey(AudioClipPaths.KeyOf(path))) return;
AudioClipPaths.Record(path);
}
}
[HarmonyPatch(typeof(AudioManager), "_Play")] [HarmonyPatch(typeof(AudioManager), "_Play")]
public static class AudioManagerPlayPatch public static class AudioManagerPlayPatch
{ {
+46
View File
@@ -0,0 +1,46 @@
using System.Collections.Generic;
using System.IO;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// File path of every external audio clip the game loads, keyed by the game's audioLib key.
///
/// The game loads external clips with AudioManager.FindOrLoadAudioClipExternal(path, ...) and stores them in audioLib under
/// Path.GetFileName(path) + "*external" (the same string becomes AudioClip.name). Recorded paths:
/// - Song: Path.Combine(Path.GetDirectoryName(levelPath), levelData.songFilename) (scnGame song loading).
/// - PlaySound event sound: Path.Combine(Path.GetDirectoryName(levelPath), hitsound) (scnGame.LoadSoundCo);
/// ffxPlaySound plays it as hitsound + "*external".
/// <see cref="Record"/> is called only when the game actually loads the file (the key is not in audioLib yet),
/// so the recorded path is the file of the clip audioLib holds under that key.
/// Thread-safe.
/// </summary>
internal static class AudioClipPaths
{
/// <summary>Suffix of the game's audioLib keys for external clips.</summary>
public const string ExternalSuffix = "*external";
private static readonly Dictionary<string, string> Paths = new();
/// <summary>audioLib key of an external clip loaded from <paramref name="path"/>.</summary>
public static string KeyOf(string path) => Path.GetFileName(path) + ExternalSuffix;
/// <summary>Records <paramref name="path"/> under <see cref="KeyOf"/>(path), replacing an earlier path of the same key.</summary>
public static void Record(string path)
{
if (string.IsNullOrEmpty(path)) return;
lock (Paths)
{
Paths[KeyOf(path)] = path;
}
}
/// <summary>Recorded path for an audioLib key (or AudioClip.name) of an external clip.</summary>
public static bool TryGetPath(string key, out string path)
{
lock (Paths)
{
return Paths.TryGetValue(key, out path!);
}
}
}
+60
View File
@@ -0,0 +1,60 @@
using System;
using System.IO;
using MelonLoader;
using UnityEngine;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Opens the <see cref="AudioClipSource"/> for a clip the game plays, keyed by the game's audioLib key (also AudioClip.name):
/// - Key recorded in <see cref="AudioClipPaths"/> and the file exists: decoded from the file with <see cref="FFmpegPCMReader"/>
/// (mono MP3 through <see cref="MP3MonoLayoutReader"/>)
/// (song files and PlaySound files of custom levels).
/// - Otherwise, or when FFmpeg cannot open the file: the Unity clip is copied with <see cref="UnityClipLoader"/>
/// (built-in hit sounds from Resources, official/DLC level songs from Addressables).
/// Main thread only (the Unity copy calls AudioClip.GetData).
/// </summary>
internal static class AudioClipResolver
{
/// <param name="store">Store of the current render.</param>
/// <param name="key">audioLib key of the clip (for example "song.ogg*external" or "sndKick").</param>
/// <param name="clip">Loaded Unity clip for the key, used when the clip has no file; may be null when the key has a file.</param>
/// <returns>The source, or null when neither the file nor the clip can be read.</returns>
public static AudioClipSource? GetOrAdd(AudioClipStore store, string key, AudioClip? clip)
{
if (store.TryGet(key, out var existing)) return existing;
if (AudioClipPaths.TryGetPath(key, out var path) && File.Exists(path))
{
IPCMReader? fileReader = null;
try
{
fileReader = OpenFile(path);
if (fileReader is MP3MonoLayoutReader)
Melon<RenderMod>.Logger.Warning($"[AudioClipResolver] Mono MP3 '{path}' is rendered like the game: half speed, one octave lower.");
}
catch (Exception e)
{
Melon<RenderMod>.Logger.Warning($"[AudioClipResolver] FFmpeg could not open '{path}' for '{key}', using the Unity clip: {e.Message}");
}
if (fileReader != null)
{
var source = store.GetOrAdd(key, () => fileReader, () => OpenFile(path));
if (source == null) fileReader.Dispose();
return source;
}
}
if (!clip) return store.GetOrAdd(key, () => null);
return store.GetOrAdd(key, () => UnityClipLoader.Load(clip!, store));
}
/// <summary>Opens <paramref name="path"/> with FFmpeg; mono MP3 files are wrapped in <see cref="MP3MonoLayoutReader"/> (game layout).</summary>
private static IPCMReader OpenFile(string path)
{
var reader = new FFmpegPCMReader(path);
if (!reader.IsMP3 || reader.Channels != 1) return reader;
return new MP3MonoLayoutReader(reader);
}
}
+5
View File
@@ -545,6 +545,11 @@ internal sealed class AudioClipSource
try try
{ {
reader = _openReader(); reader = _openReader();
if (reader is { IsForwardOnly: true })
{
reader.Dispose();
reader = null;
}
if (reader != null) if (reader != null)
{ {
var stream = new DecodeStream(reader, Frequency); var stream = new DecodeStream(reader, Frequency);
+18 -7
View File
@@ -26,6 +26,9 @@ internal sealed class AudioClipStore : IDisposable
/// <summary>Longest wait of a consumer between checks when no chunk is loaded, in milliseconds.</summary> /// <summary>Longest wait of a consumer between checks when no chunk is loaded, in milliseconds.</summary>
private const int ConsumerWaitMs = 50; private const int ConsumerWaitMs = 50;
/// <summary>Longest wait for the decoder thread to stop in <see cref="Dispose"/>, in milliseconds.</summary>
private const int DecoderStopTimeoutMs = 3000;
private readonly Dictionary<string, AudioClipSource?> _sources = new(); private readonly Dictionary<string, AudioClipSource?> _sources = new();
private readonly List<AudioClipSource> _sourceList = new(); private readonly List<AudioClipSource> _sourceList = new();
private readonly Stack<float[]> _chunkPool = new(); private readonly Stack<float[]> _chunkPool = new();
@@ -128,10 +131,10 @@ internal sealed class AudioClipStore : IDisposable
/// <summary> /// <summary>
/// Returns the source registered under <paramref name="key"/>, creating it with <paramref name="createReader"/> on first use. /// Returns the source registered under <paramref name="key"/>, creating it with <paramref name="createReader"/> on first use.
/// A key whose reader could not be created is remembered as null and not retried. /// A key whose reader could not be created is remembered as null and not retried.
/// For readers that can seek, <paramref name="createReader"/> is kept and called again on the decoder thread /// For readers that can seek, additional decode streams (see <see cref="AudioClipSource.MaxDecodeStreams"/>) are opened on the
/// to open additional decode streams (see <see cref="AudioClipSource.MaxDecodeStreams"/>). /// decoder thread with <paramref name="openAdditionalReader"/>, or with <paramref name="createReader"/> when it is null.
/// </summary> /// </summary>
public AudioClipSource? GetOrAdd(string key, Func<IPCMReader?> createReader) public AudioClipSource? GetOrAdd(string key, Func<IPCMReader?> createReader, Func<IPCMReader?>? openAdditionalReader = null)
{ {
lock (_sources) lock (_sources)
{ {
@@ -147,7 +150,7 @@ internal sealed class AudioClipStore : IDisposable
{ {
try try
{ {
source = new AudioClipSource(this, key, reader, reader.IsForwardOnly ? null : createReader, OutputRate); source = new AudioClipSource(this, key, reader, reader.IsForwardOnly ? null : openAdditionalReader ?? createReader, OutputRate);
} }
catch catch
{ {
@@ -315,7 +318,12 @@ internal sealed class AudioClipStore : IDisposable
_stopRequested = true; _stopRequested = true;
_serviceRequested.Set(); _serviceRequested.Set();
NotifyChunkLoaded(); NotifyChunkLoaded();
_decoderThread?.Join();
// A decoder thread blocked inside a read (for example a file on an unresponsive drive) is left running:
// it is a background thread, and its sources keep their readers so the blocked call does not touch freed FFmpeg contexts.
var decoderStopped = _decoderThread == null || _decoderThread.Join(DecoderStopTimeoutMs);
if (!decoderStopped)
Melon<RenderMod>.Logger.Warning($"[AudioClipStore] Decoder thread did not stop within {DecoderStopTimeoutMs} ms; its readers are left open.");
LogMemoryUsage(); LogMemoryUsage();
@@ -325,8 +333,11 @@ internal sealed class AudioClipStore : IDisposable
_sources.Clear(); _sources.Clear();
} }
foreach (var source in sources) if (decoderStopped)
source.DisposeInternal(); {
foreach (var source in sources)
source.DisposeInternal();
}
lock (_poolLock) lock (_poolLock)
{ {
+69 -6
View File
@@ -15,6 +15,11 @@ namespace ADOFAIRenderer.Util.Audio.Clip;
/// ///
/// <see cref="TotalFrames"/> comes from the stream duration (or the container duration) and may differ from the decoded length; /// <see cref="TotalFrames"/> comes from the stream duration (or the container duration) and may differ from the decoded length;
/// reads stop at whichever ends first. /// reads stop at whichever ends first.
///
/// MP3: decoded with the game's timing (MP3Sharp, see <see cref="MP3HeaderFrame"/>): encoder delay and padding are kept
/// (AV_CODEC_FLAG2_SKIP_MANUAL), frame 0 is the first sample of the first audio frame, and one frame of silence is placed
/// before it when the file starts with a Xing/Info/VBRI header frame. <see cref="TotalFrames"/> is the sum of all packet
/// durations plus that silence, counted once when the file is opened.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__decoding.html (send/receive API) /// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__decoding.html (send/receive API)
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__decoding.html (av_read_frame, av_seek_frame) /// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__decoding.html (av_read_frame, av_seek_frame)
/// </summary> /// </summary>
@@ -33,7 +38,8 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
private AVFrame* _frame; private AVFrame* _frame;
private readonly int _streamIndex; private readonly int _streamIndex;
private readonly AVRational _timeBase; private readonly AVRational _timeBase;
private readonly long _startPts; private readonly long _ptsOrigin; // Stream timestamp of output frame _leadFrames
private readonly long _leadFrames; // Silent frames placed before the first decoded frame (MP3 header frame, see class summary)
// Decoded samples not yet returned: frames [_pendingStart, _pendingStart + _pendingCount) at _pending[_pendingOffset * Channels]. // Decoded samples not yet returned: frames [_pendingStart, _pendingStart + _pendingCount) at _pending[_pendingOffset * Channels].
private float[] _pending = Array.Empty<float>(); private float[] _pending = Array.Empty<float>();
@@ -53,6 +59,9 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
public bool IsForwardOnly => false; public bool IsForwardOnly => false;
public long Position { get; private set; } public long Position { get; private set; }
/// <summary>True when the stream is MP3 and is decoded with the game's MP3 timing (see class summary).</summary>
public bool IsMP3 { get; }
/// <summary>Number of demuxer seeks performed (for diagnostics).</summary> /// <summary>Number of demuxer seeks performed (for diagnostics).</summary>
public int SeekCount { get; private set; } public int SeekCount { get; private set; }
@@ -80,13 +89,20 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
var stream = _format->streams[_streamIndex]; var stream = _format->streams[_streamIndex];
_timeBase = stream->time_base; _timeBase = stream->time_base;
_startPts = stream->start_time != ffmpeg.AV_NOPTS_VALUE ? stream->start_time : 0; _ptsOrigin = stream->start_time != ffmpeg.AV_NOPTS_VALUE ? stream->start_time : 0;
_codec = ffmpeg.avcodec_alloc_context3(decoder); _codec = ffmpeg.avcodec_alloc_context3(decoder);
if (_codec == null) throw new InvalidOperationException("avcodec_alloc_context3 failed."); if (_codec == null) throw new InvalidOperationException("avcodec_alloc_context3 failed.");
ret = ffmpeg.avcodec_parameters_to_context(_codec, stream->codecpar); ret = ffmpeg.avcodec_parameters_to_context(_codec, stream->codecpar);
if (ret < 0) throw new InvalidOperationException($"avcodec_parameters_to_context failed: {ErrorString(ret)}"); if (ret < 0) throw new InvalidOperationException($"avcodec_parameters_to_context failed: {ErrorString(ret)}");
_codec->pkt_timebase = _timeBase; _codec->pkt_timebase = _timeBase;
IsMP3 = stream->codecpar->codec_id == AVCodecID.AV_CODEC_ID_MP3;
if (IsMP3)
{
// Keep the encoder delay and padding the decoder would otherwise trim (exported as side data instead).
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__core.html (AV_CODEC_FLAG2_SKIP_MANUAL)
_codec->flags2 |= ffmpeg.AV_CODEC_FLAG2_SKIP_MANUAL;
}
ret = ffmpeg.avcodec_open2(_codec, decoder, null); ret = ffmpeg.avcodec_open2(_codec, decoder, null);
if (ret < 0) throw new InvalidOperationException($"Failed to open audio decoder for '{path}': {ErrorString(ret)}"); if (ret < 0) throw new InvalidOperationException($"Failed to open audio decoder for '{path}': {ErrorString(ret)}");
@@ -94,7 +110,16 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
SampleRate = _codec->sample_rate; SampleRate = _codec->sample_rate;
if (Channels <= 0 || SampleRate <= 0) throw new InvalidOperationException($"Invalid audio format in '{path}': {Channels}ch {SampleRate}Hz."); if (Channels <= 0 || SampleRate <= 0) throw new InvalidOperationException($"Invalid audio format in '{path}': {Channels}ch {SampleRate}Hz.");
TotalFrames = EstimateTotalFrames(stream); if (IsMP3)
{
_leadFrames = MP3HeaderFrame.SilentFrames(path);
ScanPackets(out _ptsOrigin, out var decodedFrames);
TotalFrames = _leadFrames + decodedFrames;
}
else
{
TotalFrames = EstimateTotalFrames(stream);
}
SwrContext* swr = null; SwrContext* swr = null;
ret = ffmpeg.swr_alloc_set_opts2(&swr, ret = ffmpeg.swr_alloc_set_opts2(&swr,
@@ -118,6 +143,44 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
} }
} }
/// <summary>
/// Reads every packet of the audio stream once (no decoding) and returns the first packet's timestamp and the summed
/// packet durations in frames, then moves the demuxer back to the first packet. Used for MP3, where every packet decodes
/// to a full frame when trimming is off.
/// </summary>
private void ScanPackets(out long firstPts, out long frames)
{
firstPts = ffmpeg.AV_NOPTS_VALUE;
long durationTicks = 0;
var packet = ffmpeg.av_packet_alloc();
try
{
int ret;
while ((ret = ffmpeg.av_read_frame(_format, packet)) >= 0)
{
if (packet->stream_index == _streamIndex)
{
if (firstPts == ffmpeg.AV_NOPTS_VALUE && packet->pts != ffmpeg.AV_NOPTS_VALUE) firstPts = packet->pts;
durationTicks += packet->duration;
}
ffmpeg.av_packet_unref(packet);
}
if (ret != ffmpeg.AVERROR_EOF)
throw new InvalidOperationException($"Failed to read '{Path}': {ErrorString(ret)}");
}
finally
{
ffmpeg.av_packet_free(&packet);
}
if (firstPts == ffmpeg.AV_NOPTS_VALUE) firstPts = 0;
frames = ffmpeg.av_rescale_q(durationTicks, _timeBase, new AVRational { num = 1, den = SampleRate });
var seek = ffmpeg.av_seek_frame(_format, _streamIndex, firstPts, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (seek < 0)
throw new InvalidOperationException($"Failed to rewind '{Path}': {ErrorString(seek)}");
}
private long EstimateTotalFrames(AVStream* stream) private long EstimateTotalFrames(AVStream* stream)
{ {
if (stream->duration != ffmpeg.AV_NOPTS_VALUE && stream->duration > 0) if (stream->duration != ffmpeg.AV_NOPTS_VALUE && stream->duration > 0)
@@ -142,10 +205,10 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
return; return;
} }
var targetTs = _startPts + ffmpeg.av_rescale_q(Math.Max(0, frame - marginFrames), new AVRational { num = 1, den = SampleRate }, _timeBase); var targetTs = _ptsOrigin + ffmpeg.av_rescale_q(Math.Max(0, frame - _leadFrames - marginFrames), new AVRational { num = 1, den = SampleRate }, _timeBase);
var ret = ffmpeg.av_seek_frame(_format, _streamIndex, targetTs, ffmpeg.AVSEEK_FLAG_BACKWARD); var ret = ffmpeg.av_seek_frame(_format, _streamIndex, targetTs, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (ret < 0) if (ret < 0)
ret = ffmpeg.av_seek_frame(_format, _streamIndex, _startPts, ffmpeg.AVSEEK_FLAG_BACKWARD); ret = ffmpeg.av_seek_frame(_format, _streamIndex, _ptsOrigin, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (ret < 0) if (ret < 0)
throw new InvalidOperationException($"Failed to seek '{Path}' to frame {frame}: {ErrorString(ret)}"); throw new InvalidOperationException($"Failed to seek '{Path}' to frame {frame}: {ErrorString(ret)}");
@@ -261,7 +324,7 @@ internal sealed unsafe class FFmpegPCMReader : IPCMReader
{ {
var pts = _frame->best_effort_timestamp; var pts = _frame->best_effort_timestamp;
_nextDecodedFrame = pts != ffmpeg.AV_NOPTS_VALUE _nextDecodedFrame = pts != ffmpeg.AV_NOPTS_VALUE
? ffmpeg.av_rescale_q(pts - _startPts, _timeBase, new AVRational { num = 1, den = SampleRate }) ? _leadFrames + ffmpeg.av_rescale_q(pts - _ptsOrigin, _timeBase, new AVRational { num = 1, den = SampleRate })
: Position; : Position;
_syncToTimestamp = false; _syncToTimestamp = false;
} }
+107
View File
@@ -0,0 +1,107 @@
using System;
using System.IO;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Detects the VBR/info header frame ("Xing", "Info" or "VBRI") at the start of an MP3 file.
///
/// The game decodes MP3 files with MP3Sharp (Assembly-CSharp-firstpass, MP3Sharp.MP3Stream), which has no header-frame handling:
/// it decodes that frame as one frame of silence and keeps the encoder delay. FFmpeg's MP3 demuxer consumes the header frame
/// without output. Measured with the clip harness (LAME 192k CBR, LAME VBR, 44.1/48 kHz, with and without ID3v2):
/// MP3Sharp output is FFmpeg's untrimmed output preceded by one frame of silence when the header frame exists,
/// and identical in timing when it does not.
///
/// Frame layout used here (ISO/IEC 11172-3 and 13818-3 frame header; Xing/Info tag after the Layer III side information,
/// VBRI tag 32 bytes after the frame header):
/// - Header: 11 sync bits, version (2 bits: 3 = MPEG-1, 2 = MPEG-2, 0 = MPEG-2.5), layer (2 bits: 1 = III, 2 = II, 3 = I),
/// protection bit (0 = 16-bit CRC follows), bitrate index, sample rate index, padding, private, channel mode (3 = mono).
/// - Layer III side information: MPEG-1 32 bytes (17 mono), MPEG-2/2.5 17 bytes (9 mono).
/// - Samples per frame: Layer I 384, Layer II 1152, Layer III 1152 (MPEG-1) or 576 (MPEG-2/2.5).
/// - ID3v2 tags before the first frame: "ID3", version (2 bytes), flags (bit 0x10 = 10-byte footer), size (4 bytes, 7 bits each).
/// </summary>
internal static class MP3HeaderFrame
{
/// <summary>Bytes searched for the first frame sync after the ID3v2 tags.</summary>
private const int SyncSearchBytes = 64 * 1024;
/// <summary>
/// Frames of silence MP3Sharp outputs for the header frame of <paramref name="path"/>:
/// the samples per frame of the first frame when it is a Xing/Info/VBRI header frame, otherwise 0.
/// </summary>
public static int SilentFrames(string path)
{
using var file = File.OpenRead(path);
return SilentFrames(file);
}
public static int SilentFrames(Stream stream)
{
var tag = new byte[10];
long offset = 0;
while (true)
{
stream.Position = offset;
if (ReadFully(stream, tag, 10) < 10 || tag[0] != 'I' || tag[1] != 'D' || tag[2] != '3') break;
var size = (tag[6] & 0x7F) << 21 | (tag[7] & 0x7F) << 14 | (tag[8] & 0x7F) << 7 | (tag[9] & 0x7F);
offset += 10 + size + ((tag[5] & 0x10) != 0 ? 10 : 0);
}
stream.Position = offset;
var data = new byte[SyncSearchBytes];
var length = ReadFully(stream, data, data.Length);
for (var i = 0; i + 4 <= length; i++)
{
if (!TryParseHeader(data, i, out var samplesPerFrame, out var tagOffset)) continue;
// Xing/Info after the side information; VBRI at a fixed offset of 32 bytes after the 4-byte header.
if (HasTag(data, length, i + tagOffset, "Xing") || HasTag(data, length, i + tagOffset, "Info")
|| HasTag(data, length, i + 4 + 32, "VBRI"))
return samplesPerFrame;
return 0;
}
return 0;
}
private static bool TryParseHeader(byte[] data, int i, out int samplesPerFrame, out int tagOffset)
{
samplesPerFrame = 0;
tagOffset = 0;
if (data[i] != 0xFF || (data[i + 1] & 0xE0) != 0xE0) return false;
var version = (data[i + 1] >> 3) & 0x3;
var layer = (data[i + 1] >> 1) & 0x3;
var hasCrc = (data[i + 1] & 0x1) == 0;
var bitrateIndex = (data[i + 2] >> 4) & 0xF;
var sampleRateIndex = (data[i + 2] >> 2) & 0x3;
var mono = ((data[i + 3] >> 6) & 0x3) == 3;
if (version == 1 || layer == 0 || bitrateIndex == 0xF || sampleRateIndex == 3) return false;
var mpeg1 = version == 3;
samplesPerFrame = layer switch
{
3 => 384,
2 => 1152,
_ => mpeg1 ? 1152 : 576
};
var sideInfo = layer == 1 ? (mpeg1 ? (mono ? 17 : 32) : (mono ? 9 : 17)) : 0;
tagOffset = 4 + (hasCrc ? 2 : 0) + sideInfo;
return true;
}
private static bool HasTag(byte[] data, int length, int at, string tag)
{
if (at < 0 || at + tag.Length > length) return false;
for (var k = 0; k < tag.Length; k++)
if (data[at + k] != tag[k]) return false;
return true;
}
private static int ReadFully(Stream stream, byte[] buffer, int count)
{
var total = 0;
int read;
while (total < count && (read = stream.Read(buffer, total, count - total)) > 0) total += read;
return total;
}
}
+73
View File
@@ -0,0 +1,73 @@
using System;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// <see cref="IPCMReader"/> that reproduces the game's output for mono MP3 files.
///
/// MP3Sharp (Assembly-CSharp-firstpass, MP3Sharp.Buffer16BitStereo) writes every frame in stereo layout and fills only the
/// channel 0 slot for mono streams, while MP3Stream.ChannelCount reports 1. The game creates a 1-channel clip from that data,
/// so a mono MP3 plays as [s0, 0, s1, 0, ...]: twice as long, at half speed and one octave lower
/// (confirmed in game with TestMaps/mp3_mono, 2026-10-13).
///
/// Output: 1 channel at the inner reader's sample rate. Frame 2k is inner frame k, frame 2k + 1 is 0.
/// <see cref="TotalFrames"/> = 2 * inner TotalFrames.
/// </summary>
internal sealed class MP3MonoLayoutReader : IPCMReader
{
private readonly IPCMReader _inner;
private float[] _innerBuffer = Array.Empty<float>();
/// <param name="inner">Mono reader of the MP3 file with the game's MP3 timing (<see cref="FFmpegPCMReader.IsMP3"/>). Owned by this reader.</param>
public MP3MonoLayoutReader(IPCMReader inner)
{
if (inner.Channels != 1) throw new ArgumentException("Inner reader must be mono.", nameof(inner));
_inner = inner;
Position = inner.Position * 2;
}
public int Channels => 1;
public int SampleRate => _inner.SampleRate;
public long TotalFrames => _inner.TotalFrames * 2;
public bool IsForwardOnly => _inner.IsForwardOnly;
public long Position { get; private set; }
/// <summary>Moves to <paramref name="frame"/>; the inner reader moves to the inner frame of the next even position.</summary>
public void Seek(long frame)
{
_inner.Seek((frame + 1) / 2);
Position = frame;
}
public int Read(float[] buffer, int frameCount)
{
var limit = (int)Math.Min(frameCount, TotalFrames - Position);
var written = 0;
while (written < limit)
{
if ((Position & 1) == 1)
{
buffer[written++] = 0f;
Position++;
continue;
}
var want = (limit - written + 1) / 2;
if (_innerBuffer.Length < want) _innerBuffer = new float[want];
var got = _inner.Read(_innerBuffer, want);
if (got == 0) break;
for (var i = 0; i < got && written < limit; i++)
{
buffer[written++] = _innerBuffer[i];
Position++;
if (written >= limit) break;
buffer[written++] = 0f;
Position++;
}
}
return written;
}
public void Dispose() => _inner.Dispose();
}