SHA256
Compare commits
9
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
27e7327c1e
|
||
|
|
2152625e45
|
||
|
|
aefa1409c1
|
||
|
|
e802b6a08a
|
||
|
|
c8050abbc9
|
||
|
|
45a7637a36
|
||
|
|
6955762641
|
||
|
|
ea8d4c91ac
|
||
|
|
5080e7ea1d
|
No files matched your search
@@ -520,7 +520,9 @@ public class RenderSession
|
||||
}
|
||||
|
||||
RenderManager.Instance.levelPath = levelPath;
|
||||
RenderManager.Instance.Configuration = renderConfig;
|
||||
// The render uses its own copy of the configuration: values adjusted during the render
|
||||
// (e.g. the audio sample rate in SetupPlay) stay in this copy and do not change the caller's object.
|
||||
RenderManager.Instance.Configuration = renderConfig.Clone();
|
||||
|
||||
RenderManager.Instance.stage = RenderStage.Load;
|
||||
|
||||
|
||||
+87
-11
@@ -5,7 +5,6 @@ using System.IO;
|
||||
using System.Threading;
|
||||
using System.Buffers;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using ADOFAIRenderer.API;
|
||||
using ADOFAIRenderer.Core.Data;
|
||||
using ADOFAIRenderer.Data;
|
||||
@@ -81,7 +80,27 @@ internal class RenderManager : MonoBehaviour
|
||||
public static bool IsActive => Instance && Instance.stage >= RenderStage.OnScene;
|
||||
public static bool IsCapturingActive => Instance && Instance.stage >= RenderStage.Render;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of frames in ImageQueue. The main thread waits (throttles) while the queue holds more frames.
|
||||
/// </summary>
|
||||
public const int MaxQueuedFrames = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound of the readback buffer bytes of the frames in ImageQueue (4 GiB).
|
||||
/// The frame limit becomes min(MaxQueuedFrames, MaxQueuedBytes / bytes per frame) (see ComputeQueueFrameLimit),
|
||||
/// so it only lowers the frame count at resolutions where MaxQueuedFrames frames exceed this size.
|
||||
/// </summary>
|
||||
public const long MaxQueuedBytes = 4L * 1024 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Wait time of the throttle loop (OnPostFrame) for the render thread to take a frame from ImageQueue.
|
||||
/// When it elapses without a dequeue, AsyncGPUReadback.WaitAllRequests() is called once so the readbacks
|
||||
/// still in flight complete (the render thread may be waiting for one of them to fill a gap in ImageQueue).
|
||||
/// </summary>
|
||||
private const int ThrottleWaitMilliseconds = 10;
|
||||
|
||||
/// <summary>Frame limit of ImageQueue applied by the last throttle check (MaxQueuedFrames or lower).</summary>
|
||||
public int QueueFrameLimit { get; private set; } = MaxQueuedFrames;
|
||||
|
||||
// Statistics
|
||||
public long framesSent;
|
||||
@@ -213,6 +232,8 @@ internal class RenderManager : MonoBehaviour
|
||||
// Thread and Queue State
|
||||
internal SortedList<long, CapturedFrame> ImageQueue = new();
|
||||
internal AutoResetEvent FrameAvailableEvent = new(false);
|
||||
// Set by the render thread each time it takes a frame from ImageQueue; the throttle loop waits on it.
|
||||
internal AutoResetEvent FrameDequeuedEvent = new(false);
|
||||
public Thread? RenderThread;
|
||||
public long passCount;
|
||||
public volatile bool endRequested;
|
||||
@@ -396,27 +417,74 @@ internal class RenderManager : MonoBehaviour
|
||||
frameCount++;
|
||||
}
|
||||
|
||||
// Floors that own a hold renderer, collected from scrLevelMaker.listFloors (see RefreshHoldFloors).
|
||||
private readonly List<scrFloor> _holdFloors = new();
|
||||
private List<scrFloor>? _holdFloorsSource;
|
||||
private int _holdFloorsSourceCount = -1;
|
||||
|
||||
public void OnLastUpdate()
|
||||
{
|
||||
if (Instance.stage < RenderStage.Load) return;
|
||||
if (!Configuration.AdvancedConfig.NonTile) return;
|
||||
foreach (var floor in scrLevelMaker.instance.listFloors
|
||||
.Where(floor => floor.holdRenderer && floor.holdRenderer.m_meshRenderer.enabled))
|
||||
|
||||
if (Configuration.AdvancedConfig.NonTile)
|
||||
{
|
||||
floor.holdRenderer.m_meshRenderer.enabled = false;
|
||||
// scrHoldRenderer.UpdateColor sets m_meshRenderer.enabled = true every time it runs
|
||||
// (scrHoldRenderer.cs, called from scrPlanet/scrPlayer), so the hold meshes are disabled again every frame.
|
||||
RefreshHoldFloors();
|
||||
foreach (var floor in _holdFloors)
|
||||
{
|
||||
if (!floor || !floor.holdRenderer) continue;
|
||||
var meshRenderer = floor.holdRenderer.m_meshRenderer;
|
||||
if (meshRenderer && meshRenderer.enabled) meshRenderer.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
TriggerOnLastUpdate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the hold floor list when scrLevelMaker.listFloors is a different list or has a different count
|
||||
/// (hold renderers are created together with the floors in scrLevelMaker).
|
||||
/// </summary>
|
||||
private void RefreshHoldFloors()
|
||||
{
|
||||
var floors = scrLevelMaker.instance.listFloors;
|
||||
if (ReferenceEquals(floors, _holdFloorsSource) && floors.Count == _holdFloorsSourceCount) return;
|
||||
|
||||
_holdFloors.Clear();
|
||||
foreach (var floor in floors)
|
||||
{
|
||||
if (floor && floor.holdRenderer) _holdFloors.Add(floor);
|
||||
}
|
||||
_holdFloorsSource = floors;
|
||||
_holdFloorsSourceCount = floors.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frame limit of ImageQueue for the given capture texture: MaxQueuedBytes divided by the readback bytes of
|
||||
/// one frame (YUV 4:2:0 when the GPU conversion is active, RGBA otherwise), within 4 to MaxQueuedFrames.
|
||||
/// </summary>
|
||||
private int ComputeQueueFrameLimit(RenderTexture texture)
|
||||
{
|
||||
var frameBytes = _frameReader?.GetFrameBufferBytes(texture.width, texture.height)
|
||||
?? (long)texture.width * texture.height * 4;
|
||||
if (frameBytes <= 0) return MaxQueuedFrames;
|
||||
|
||||
return (int)Math.Min(MaxQueuedFrames, Math.Max(4, MaxQueuedBytes / frameBytes));
|
||||
}
|
||||
|
||||
// ReSharper disable once Unity.IncorrectMethodSignature
|
||||
public void OnPostFrame(RenderTexture? texture)
|
||||
{
|
||||
if (Instance.stage < RenderStage.Render || !texture) return;
|
||||
|
||||
// [Backpressure Throttling]
|
||||
// The main thread waits while ImageQueue holds more than QueueFrameLimit frames. Only the render thread
|
||||
// removes frames, and it signals FrameDequeuedEvent for each one. The wait has a timeout so that the loop
|
||||
// re-checks the render thread state and pumps pending readback completions (see ThrottleWaitMilliseconds).
|
||||
isThrottling = false;
|
||||
|
||||
var frameLimit = QueueFrameLimit = ComputeQueueFrameLimit(texture!);
|
||||
|
||||
while (Instance.stage >= RenderStage.Render)
|
||||
{
|
||||
if (RenderThread is not { IsAlive: true })
|
||||
@@ -431,7 +499,7 @@ internal class RenderManager : MonoBehaviour
|
||||
count = ImageQueue.Count;
|
||||
}
|
||||
|
||||
if (count <= MaxQueuedFrames) break;
|
||||
if (count <= frameLimit) break;
|
||||
|
||||
if (!isThrottling)
|
||||
{
|
||||
@@ -439,7 +507,10 @@ internal class RenderManager : MonoBehaviour
|
||||
throttleCount++;
|
||||
}
|
||||
|
||||
AsyncGPUReadback.WaitAllRequests();
|
||||
if (!FrameDequeuedEvent.WaitOne(ThrottleWaitMilliseconds))
|
||||
{
|
||||
AsyncGPUReadback.WaitAllRequests();
|
||||
}
|
||||
}
|
||||
isThrottling = false;
|
||||
|
||||
@@ -619,6 +690,7 @@ internal class RenderManager : MonoBehaviour
|
||||
endRequested = false;
|
||||
endSuccessful = false;
|
||||
FrameAvailableEvent.Reset();
|
||||
FrameDequeuedEvent.Reset();
|
||||
|
||||
var renderFPS = Configuration.RenderFPS;
|
||||
var videoFPS = Configuration.VideoFPS;
|
||||
@@ -682,7 +754,7 @@ internal class RenderManager : MonoBehaviour
|
||||
throw;
|
||||
}
|
||||
|
||||
var encoder = videoFFmpeg!;
|
||||
var encoder = videoFFmpeg;
|
||||
RenderThread = new Thread(() => RunRenderThread(encoder))
|
||||
{
|
||||
IsBackground = true,
|
||||
@@ -734,14 +806,18 @@ internal class RenderManager : MonoBehaviour
|
||||
/// <summary>
|
||||
/// Takes frames from ImageQueue in passCount order and sends each one to the encoder, followed by the audio
|
||||
/// samples of that frame. Waits on FrameAvailableEvent (50ms) while no frame is ready.
|
||||
/// Returns when the render is aborted or all requested frames have been sent after endRequested.
|
||||
/// Returns when the render is aborted (checked before every frame, frames left in ImageQueue are released
|
||||
/// by the cleanup after the thread ends) or all requested frames have been sent after endRequested.
|
||||
/// </summary>
|
||||
private void EncodeQueuedFrames(FFmpegNativeEncoder videoFFmpeg)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (IsRenderAborted) return;
|
||||
|
||||
if (TryDequeueNextFrame(out var frame))
|
||||
{
|
||||
FrameDequeuedEvent.Set();
|
||||
SendVideoFrame(videoFFmpeg, frame);
|
||||
WriteAudioForFrame(videoFFmpeg);
|
||||
continue;
|
||||
@@ -884,7 +960,7 @@ internal class RenderManager : MonoBehaviour
|
||||
var reason = IsRenderFailed ? $"FAILED ({_failureReason})" : "CANCELLED";
|
||||
Melon<RenderMod>.Logger.Error($"Rendering {reason}. Safely cleaning up state and resources.");
|
||||
|
||||
// 1. Wait for render background thread to finish processing remaining queued frames and terminate
|
||||
// 1. Wait (up to 2s) for the render background thread to see the abort flag and terminate
|
||||
if (RenderThread is { IsAlive: true })
|
||||
{
|
||||
RenderThread.Join(2000);
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ using MelonLoader.Utils;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.8-INDEV", "ADOFAI Editor Forum")]
|
||||
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.4.0-BETA", "ADOFAI Editor Forum")]
|
||||
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
|
||||
|
||||
namespace ADOFAIRenderer;
|
||||
|
||||
@@ -164,7 +164,7 @@ internal class DebugMonitorUI : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine($"• Queue: <b>{queueCount}</b> / {RenderManager.MaxQueuedFrames} frames");
|
||||
sb.AppendLine($"• Queue: <b>{queueCount}</b> / {(RenderManager.Instance ? RenderManager.Instance.QueueFrameLimit : RenderManager.MaxQueuedFrames)} frames");
|
||||
sb.AppendLine($"• Rendered Frames: <b>{(RenderManager.Instance ? RenderManager.Instance.frameCount : 0)}</b>");
|
||||
sb.AppendLine($"• Frames Sent: <b>{(RenderManager.Instance ? Interlocked.Read(ref RenderManager.Instance.framesSent) : 0)}</b>");
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ using ADOFAIRenderer.Data;
|
||||
using ADOFAIRenderer.Localization;
|
||||
using ADOFAIRenderer.Util.OS;
|
||||
using MelonLoader;
|
||||
using Newtonsoft.Json;
|
||||
using O5Kit.Control;
|
||||
using O5Kit.Core;
|
||||
using O5Kit.Factory;
|
||||
@@ -32,6 +33,14 @@ internal static class RenderConfigWindow
|
||||
private static Coroutine? _deleteResetCoroutine;
|
||||
private static string? _lastLoadedPresetName;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration edited by this window. Kept for the whole game session; a render started from the window
|
||||
/// receives a copy of it (RenderSession.Start), so values adjusted during the render are not written back here.
|
||||
/// </summary>
|
||||
private static RenderConfig? _uiConfig;
|
||||
|
||||
private static RenderConfig UiConfig => _uiConfig ??= new RenderConfig();
|
||||
|
||||
// Controls cache for validation & sync
|
||||
private static O5Slider? _renderFpsSlider;
|
||||
private static O5Slider? _videoFPSSlider;
|
||||
@@ -111,8 +120,7 @@ internal static class RenderConfigWindow
|
||||
ReloadPresetDropdown(GlobalSettings.Current.LastPresetName);
|
||||
}
|
||||
|
||||
// ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
|
||||
var config = RenderManager.Instance.Configuration ??= new RenderConfig();
|
||||
var config = UiConfig;
|
||||
ApplyConfigToUI(config);
|
||||
|
||||
SetWindowActive(true);
|
||||
@@ -125,8 +133,7 @@ internal static class RenderConfigWindow
|
||||
var (_, scrollContent, _) =
|
||||
O5Factory.ScrollView(ctx, content, spacing: 10f, padding: 8f);
|
||||
|
||||
// ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
|
||||
var config = RenderManager.Instance.Configuration ??= new RenderConfig();
|
||||
var config = UiConfig;
|
||||
EnsureDefaultConfig(config);
|
||||
|
||||
double[] renderFpsSnap = { 23.976, 24, 29.97, 30, 59.94, 60, 144,
|
||||
@@ -744,11 +751,14 @@ internal static class RenderConfigWindow
|
||||
{
|
||||
EnsureDefaultConfig(config);
|
||||
|
||||
_renderFpsSlider?.Set((float) config.RenderFPS, false);
|
||||
_videoFPSSlider?.Set((float) config.VideoFPS, false);
|
||||
_renderWidthSlider?.Set(config.RenderWidth, false);
|
||||
_renderHeightSlider?.Set(config.RenderHeight, false);
|
||||
_delayAfterEndSlider?.Set((float) config.DelayAfterEnd, false);
|
||||
// Sliders show the config values as they are (noFilter: true).
|
||||
// O5Slider.Set applies the slider filter (snapping to the nearest snap value) unless noFilter is set;
|
||||
// the filter is meant for dragging, and typed values are not snapped either (O5Slider.SetFromInput).
|
||||
_renderFpsSlider?.Set(config.RenderFPS, false, noFilter: true);
|
||||
_videoFPSSlider?.Set(config.VideoFPS, false, noFilter: true);
|
||||
_renderWidthSlider?.Set(config.RenderWidth, false, noFilter: true);
|
||||
_renderHeightSlider?.Set(config.RenderHeight, false, noFilter: true);
|
||||
_delayAfterEndSlider?.Set(config.DelayAfterEnd, false, noFilter: true);
|
||||
_showJudgementToggle?.Set(config.ShowJudgementText, false);
|
||||
|
||||
_codecDropdown?.Set(config.VideoConfig.Codec, false);
|
||||
@@ -756,13 +766,13 @@ internal static class RenderConfigWindow
|
||||
_bitrateInput?.Set(config.VideoConfig.Bitrate.ToString(), false);
|
||||
_maxBitrateInput?.Set(config.VideoConfig.MaxBitrate.ToString(), false);
|
||||
_qpInput?.Set(config.VideoConfig.QP.ToString(), false);
|
||||
_keyframeIntervalSlider?.Set(config.VideoConfig.KeyframeInterval, false);
|
||||
_keyframeIntervalSlider?.Set(config.VideoConfig.KeyframeInterval, false, noFilter: true);
|
||||
_forceSoftwareToggle?.Set(config.VideoConfig.ForceSoftwareEncoder, false);
|
||||
|
||||
_renderAudioToggle?.Set(config.AudioConfig.RenderAudio, false);
|
||||
_sampleRateDropdown?.Set(config.AudioConfig.AudioSampleRate, false);
|
||||
_channelsDropdown?.Set(config.AudioConfig.AudioChannels, false);
|
||||
_audioBitrateSlider?.Set(config.AudioConfig.AudioBitrate / 1000.0, false);
|
||||
_audioBitrateSlider?.Set(config.AudioConfig.AudioBitrate / 1000.0, false, noFilter: true);
|
||||
|
||||
_outputDirInput?.Set(GlobalSettings.EffectiveOutputDirectory, false);
|
||||
|
||||
@@ -789,17 +799,51 @@ internal static class RenderConfigWindow
|
||||
UpdateVideoCodecUI(config.VideoConfig.Codec, config);
|
||||
UpdateRateControlUI(config.VideoConfig.RateControlMode, noAnimate: true);
|
||||
|
||||
if (!string.IsNullOrEmpty(GlobalSettings.Current.LastPresetName))
|
||||
UpdatePresetLabel(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the preset dropdown from the config values.
|
||||
/// Every value equals the last loaded preset (_lastLoadedPresetName): the preset name is shown.
|
||||
/// Any value differs (edited, or the preset file changed): the Custom label is shown (MarkConfigModified),
|
||||
/// the same state as after editing a value in the window.
|
||||
/// </summary>
|
||||
private static void UpdatePresetLabel(RenderConfig config)
|
||||
{
|
||||
if (_presetDropdown == null) return;
|
||||
|
||||
var presetName = _lastLoadedPresetName;
|
||||
var preset = string.IsNullOrEmpty(presetName)
|
||||
? null
|
||||
: PresetManager.GetPresets().FirstOrDefault(p => string.Equals(p.Name, presetName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (preset == null || !ConfigValuesEqual(config, preset.Config))
|
||||
{
|
||||
_presetDropdown?.Set(GlobalSettings.Current.LastPresetName, false);
|
||||
_lastLoadedPresetName = GlobalSettings.Current.LastPresetName;
|
||||
UpdateDeleteButtonState();
|
||||
MarkConfigModified();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_presetDropdown.Values.Contains(CustomPresetLabel))
|
||||
{
|
||||
_presetDropdown.SetValues(_presetDropdown.Values.Where(v => v != CustomPresetLabel).ToList());
|
||||
}
|
||||
_presetDropdown.Set(preset.Name, false);
|
||||
UpdateDeleteButtonState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two configs by their serialized JSON (the same form as the preset files), so every saved value is compared.
|
||||
/// </summary>
|
||||
private static bool ConfigValuesEqual(RenderConfig a, RenderConfig b)
|
||||
{
|
||||
var settings = new JsonSerializerSettings();
|
||||
settings.Converters.Add(new ColorJsonConverter());
|
||||
return JsonConvert.SerializeObject(a, settings) == JsonConvert.SerializeObject(b, settings);
|
||||
}
|
||||
|
||||
private static void TryGoRender()
|
||||
{
|
||||
var config = RenderManager.Instance.Configuration;
|
||||
var config = UiConfig;
|
||||
SyncUItoConfig(config);
|
||||
|
||||
// Sync Output Directory
|
||||
|
||||
@@ -1,608 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using MelonLoader;
|
||||
|
||||
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||
|
||||
/// <summary>
|
||||
/// Read position registered on an <see cref="AudioClipSource"/> by one consumer (a playing voice or the spectrum generator).
|
||||
/// For a windowed source, the chunk containing <see cref="Position"/> and the following chunks
|
||||
/// (<see cref="AudioClipSource.SlotCount"/> chunks in total) are kept loaded.
|
||||
/// </summary>
|
||||
internal sealed class AudioCursor
|
||||
{
|
||||
private readonly AudioClipSource _source;
|
||||
private long _position;
|
||||
private volatile bool _loop;
|
||||
|
||||
internal AudioCursor(AudioClipSource source, long position, bool loop)
|
||||
{
|
||||
_source = source;
|
||||
_position = position;
|
||||
_loop = loop;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frame in the source at the output sample rate (0 ~ TotalFrames - 1).
|
||||
/// Written by the consumer that reads the source; read by the decoder thread.
|
||||
/// Moving into another chunk wakes the decoder thread.
|
||||
/// </summary>
|
||||
public long Position
|
||||
{
|
||||
get => Interlocked.Read(ref _position);
|
||||
set
|
||||
{
|
||||
var previous = Interlocked.Exchange(ref _position, value);
|
||||
if (previous / _source.FramesPerChunk != value / _source.FramesPerChunk)
|
||||
_source.OnCursorMoved();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>When true, the chunk after the last chunk is chunk 0.</summary>
|
||||
public bool Loop
|
||||
{
|
||||
get => _loop;
|
||||
set
|
||||
{
|
||||
_loop = value;
|
||||
_source.OnCursorMoved();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One loaded chunk of an <see cref="AudioClipSource"/>, held by a consumer between
|
||||
/// <see cref="AudioClipSource.TryAcquire"/> and <see cref="AudioClipSource.Release"/>.
|
||||
/// The chunk is not freed while held.
|
||||
/// </summary>
|
||||
internal readonly struct ChunkView
|
||||
{
|
||||
/// <summary>Interleaved samples. Frame f of the source is at (f - StartFrame) * Channels.</summary>
|
||||
public readonly float[] Data;
|
||||
|
||||
/// <summary>Source frame of the first frame in <see cref="Data"/>.</summary>
|
||||
public readonly long StartFrame;
|
||||
|
||||
/// <summary>Valid frames in this chunk. Frames past the end of the source are not included.</summary>
|
||||
public readonly int FrameCount;
|
||||
|
||||
/// <summary>Channel count of the stored samples (same as the source clip).</summary>
|
||||
public readonly int Channels;
|
||||
|
||||
public readonly int ChunkIndex;
|
||||
|
||||
internal ChunkView(float[] data, long startFrame, int frameCount, int channels, int chunkIndex)
|
||||
{
|
||||
Data = data;
|
||||
StartFrame = startFrame;
|
||||
FrameCount = frameCount;
|
||||
Channels = channels;
|
||||
ChunkIndex = chunkIndex;
|
||||
}
|
||||
|
||||
public bool IsValid => Data != null;
|
||||
|
||||
public long EndFrame => StartFrame + FrameCount;
|
||||
|
||||
public bool Contains(long frame) => frame >= StartFrame && frame < StartFrame + FrameCount;
|
||||
|
||||
/// <summary>
|
||||
/// Sample of <paramref name="frame"/> mapped to output channel <paramref name="outChannel"/> of <paramref name="outChannels"/>.
|
||||
/// Mapping: mono output averages all source channels; a mono source is copied to every output channel;
|
||||
/// otherwise output channel c takes source channel c, and output channels beyond the source take source channel 0.
|
||||
/// <paramref name="frame"/> must be inside this chunk.
|
||||
/// </summary>
|
||||
public float GetSample(long frame, int outChannel, int outChannels)
|
||||
{
|
||||
var baseIndex = (int)(frame - StartFrame) * Channels;
|
||||
if (Channels == 1)
|
||||
return Data[baseIndex];
|
||||
|
||||
if (outChannels == 1)
|
||||
{
|
||||
var sum = 0f;
|
||||
for (var c = 0; c < Channels; c++)
|
||||
sum += Data[baseIndex + c];
|
||||
return sum / Channels;
|
||||
}
|
||||
|
||||
return Data[baseIndex + (outChannel < Channels ? outChannel : 0)];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How an <see cref="AudioClipSource"/> keeps its chunks.
|
||||
/// </summary>
|
||||
internal enum ChunkPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Only the chunks required by registered cursors (current chunk and the next SlotCount - 1 chunks per cursor),
|
||||
/// chunks requested by a waiting consumer, and chunks currently held are kept. Other chunks are freed and reloaded on demand.
|
||||
/// Used for readers that can seek (files decoded with FFmpeg) when the clip is longer than one chunk.
|
||||
/// </summary>
|
||||
Windowed,
|
||||
|
||||
/// <summary>
|
||||
/// All chunks are loaded once, in order from chunk 0, starting when the source is created, and kept until the store is disposed.
|
||||
/// Used for forward-only readers (clip data copied from Unity, released as it is consumed) and for clips of one chunk or less.
|
||||
/// </summary>
|
||||
Resident
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decoded audio of one clip, resampled to the output sample rate and stored in fixed-size chunks.
|
||||
/// Channel count stays as in the clip.
|
||||
///
|
||||
/// Chunks: chunk k covers output frames [k * FramesPerChunk, (k + 1) * FramesPerChunk), where
|
||||
/// <see cref="FramesPerChunk"/> = <see cref="ChunkFloats"/> / Channels (rounded down; a frame never spans two chunks).
|
||||
/// Windowed chunks use pooled arrays of ChunkFloats floats; resident chunks use arrays of their exact length.
|
||||
/// See <see cref="ChunkPolicy"/> for which chunks are kept.
|
||||
///
|
||||
/// Threads: cursors and TryAcquire/Release may be used from any thread.
|
||||
/// Loading and freeing run only on the store's decoder thread (<see cref="AudioClipStore"/>), through
|
||||
/// <see cref="FreeUnused"/>, <see cref="TryGetNextLoad"/> and <see cref="LoadChunk"/>.
|
||||
/// </summary>
|
||||
internal sealed class AudioClipSource
|
||||
{
|
||||
/// <summary>Floats per chunk array: 2^22 floats = 16 MiB.</summary>
|
||||
public const int ChunkFloats = 1 << 22;
|
||||
|
||||
/// <summary>Chunks kept per cursor of a windowed source: the chunk containing the cursor and the next SlotCount - 1 chunks.</summary>
|
||||
public const int SlotCount = 3;
|
||||
|
||||
/// <summary>Load priority of a chunk a consumer is waiting for (lowest value is loaded first).</summary>
|
||||
internal const int PriorityDemanded = 0;
|
||||
|
||||
/// <summary>Load priority of slot s of a cursor window is PriorityWindow + s.</summary>
|
||||
internal const int PriorityWindow = 1;
|
||||
|
||||
/// <summary>Load priority of resident chunks nobody is waiting for.</summary>
|
||||
internal const int PriorityResident = PriorityWindow + SlotCount;
|
||||
|
||||
/// <summary>
|
||||
/// Most decode streams (reader + resampler) a windowed source opens. A load continues the stream positioned at the chunk start,
|
||||
/// otherwise opens another stream up to this count, otherwise restarts the least recently used stream.
|
||||
/// Each stream holds one open reader (file handle and decoder state, measured 0.3 ~ 2.7 MiB for ogg/flac/mp3 with the clip harness).
|
||||
/// </summary>
|
||||
public const int MaxDecodeStreams = 4;
|
||||
|
||||
/// <summary>Decode stream limit of this source (<see cref="MaxDecodeStreams"/> unless changed for tests).</summary>
|
||||
internal int DecodeStreamLimit { get; set; } = MaxDecodeStreams;
|
||||
|
||||
private readonly AudioClipStore _store;
|
||||
private readonly IPCMReader _firstReader;
|
||||
private readonly Func<IPCMReader?>? _openReader;
|
||||
|
||||
// Guarded by _sync: chunk table, hold counts, cursors, demand set, loaded set.
|
||||
private readonly object _sync = new();
|
||||
private readonly float[]?[] _chunks;
|
||||
private readonly int[] _holdCounts;
|
||||
private readonly List<AudioCursor> _cursors = new();
|
||||
private readonly HashSet<int> _demanded = new();
|
||||
private readonly HashSet<int> _loaded = new();
|
||||
private int _residentLoadedCount; // Resident: chunks [0, _residentLoadedCount) are loaded
|
||||
|
||||
// Guarded by _loadLock: decode streams.
|
||||
private readonly object _loadLock = new();
|
||||
private readonly List<DecodeStream> _streams = new();
|
||||
private long _streamUseCounter;
|
||||
private bool _openFailed;
|
||||
|
||||
private volatile bool _isFailed;
|
||||
|
||||
public string Key { get; }
|
||||
|
||||
/// <summary>Channel count of the stored samples (same as the clip).</summary>
|
||||
public int Channels { get; }
|
||||
|
||||
/// <summary>Sample rate of the stored samples in Hz (the output sample rate).</summary>
|
||||
public int Frequency { get; }
|
||||
|
||||
/// <summary>Frame count at <see cref="Frequency"/>: ceil(clip frames * Frequency / clip rate).</summary>
|
||||
public long TotalFrames { get; }
|
||||
|
||||
/// <summary>Clip length in seconds: clip frames / clip rate.</summary>
|
||||
public double Duration { get; }
|
||||
|
||||
public int FramesPerChunk { get; }
|
||||
|
||||
public int ChunkCount { get; }
|
||||
|
||||
public ChunkPolicy Policy { get; }
|
||||
|
||||
public bool IsResident => Policy == ChunkPolicy.Resident;
|
||||
|
||||
/// <summary>True after loading failed. A failed source returns no chunks.</summary>
|
||||
public bool IsFailed => _isFailed;
|
||||
|
||||
/// <summary>True when every chunk of a resident source is loaded.</summary>
|
||||
public bool IsFullyLoaded
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync) return _loaded.Count == ChunkCount;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Number of chunk loads performed (for diagnostics).</summary>
|
||||
public int LoadCount { get; private set; }
|
||||
|
||||
/// <summary>Decode streams opened so far (for diagnostics).</summary>
|
||||
public int StreamCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_loadLock) return _streams.Count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <param name="store">Store that owns this source.</param>
|
||||
/// <param name="key">Clip name or path the source is registered under.</param>
|
||||
/// <param name="reader">First reader; also defines the clip format.</param>
|
||||
/// <param name="openReader">Opens another reader of the same clip for an additional decode stream, or null when not possible.</param>
|
||||
/// <param name="outputRate">Output sample rate in Hz.</param>
|
||||
internal AudioClipSource(AudioClipStore store, string key, IPCMReader reader, Func<IPCMReader?>? openReader, int outputRate)
|
||||
{
|
||||
if (reader.Channels <= 0) throw new ArgumentException("Reader channel count must be positive.", nameof(reader));
|
||||
if (reader.SampleRate <= 0) throw new ArgumentException("Reader sample rate must be positive.", nameof(reader));
|
||||
if (reader.TotalFrames < 0) throw new ArgumentException("Reader frame count must not be negative.", nameof(reader));
|
||||
|
||||
_store = store;
|
||||
_firstReader = reader;
|
||||
_openReader = openReader;
|
||||
Key = key;
|
||||
Channels = reader.Channels;
|
||||
Frequency = outputRate;
|
||||
Duration = (double)reader.TotalFrames / reader.SampleRate;
|
||||
TotalFrames = CeilDiv(reader.TotalFrames * outputRate, reader.SampleRate);
|
||||
FramesPerChunk = ChunkFloats / Channels;
|
||||
ChunkCount = (int)CeilDiv(TotalFrames, FramesPerChunk);
|
||||
Policy = reader.IsForwardOnly || ChunkCount <= 1 ? ChunkPolicy.Resident : ChunkPolicy.Windowed;
|
||||
|
||||
_chunks = new float[ChunkCount][];
|
||||
_holdCounts = new int[ChunkCount];
|
||||
_streams.Add(new DecodeStream(reader, outputRate));
|
||||
}
|
||||
|
||||
// ── Cursors ──
|
||||
|
||||
/// <summary>Registers a read position. The decoder thread loads the chunks it requires.</summary>
|
||||
public AudioCursor AddCursor(long position, bool loop)
|
||||
{
|
||||
var cursor = new AudioCursor(this, position, loop);
|
||||
lock (_sync)
|
||||
{
|
||||
_cursors.Add(cursor);
|
||||
}
|
||||
_store.RequestService();
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public void RemoveCursor(AudioCursor cursor)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_cursors.Remove(cursor);
|
||||
}
|
||||
_store.RequestService();
|
||||
}
|
||||
|
||||
internal void OnCursorMoved()
|
||||
{
|
||||
if (!IsResident) _store.RequestService();
|
||||
}
|
||||
|
||||
// ── Consumer access ──
|
||||
|
||||
/// <summary>Chunk index containing <paramref name="frame"/> (0 ~ TotalFrames - 1).</summary>
|
||||
public int ChunkIndexOf(long frame) => (int)(frame / FramesPerChunk);
|
||||
|
||||
/// <summary>
|
||||
/// Holds the chunk containing <paramref name="frame"/> when it is loaded.
|
||||
/// When it is not loaded, records a load request (kept until the chunk is acquired), wakes the decoder thread and returns false.
|
||||
/// Every successful call must be paired with <see cref="Release"/>.
|
||||
/// </summary>
|
||||
public bool TryAcquire(long frame, out ChunkView view)
|
||||
{
|
||||
view = default;
|
||||
if (_isFailed || frame < 0 || frame >= TotalFrames) return false;
|
||||
|
||||
var index = ChunkIndexOf(frame);
|
||||
lock (_sync)
|
||||
{
|
||||
var data = _chunks[index];
|
||||
if (data != null)
|
||||
{
|
||||
_demanded.Remove(index);
|
||||
_holdCounts[index]++;
|
||||
view = CreateView(index, data);
|
||||
return true;
|
||||
}
|
||||
_demanded.Add(index);
|
||||
}
|
||||
_store.RequestService();
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Release(in ChunkView view)
|
||||
{
|
||||
if (!view.IsValid) return;
|
||||
lock (_sync)
|
||||
{
|
||||
if (_holdCounts[view.ChunkIndex] > 0)
|
||||
_holdCounts[view.ChunkIndex]--;
|
||||
}
|
||||
}
|
||||
|
||||
private ChunkView CreateView(int index, float[] data)
|
||||
{
|
||||
var start = (long)index * FramesPerChunk;
|
||||
var count = (int)Math.Min(FramesPerChunk, TotalFrames - start);
|
||||
return new ChunkView(data, start, count, Channels, index);
|
||||
}
|
||||
|
||||
// ── Decoder thread ──
|
||||
|
||||
/// <summary>
|
||||
/// Windowed: frees loaded chunks that are not required (see <see cref="ChunkPolicy.Windowed"/>) and not held.
|
||||
/// Resident: does nothing. Decoder thread only.
|
||||
/// </summary>
|
||||
internal void FreeUnused()
|
||||
{
|
||||
if (IsResident) return;
|
||||
|
||||
List<float[]>? freed = null;
|
||||
lock (_sync)
|
||||
{
|
||||
if (_loaded.Count == 0) return;
|
||||
List<int>? remove = null;
|
||||
foreach (var index in _loaded)
|
||||
{
|
||||
if (_holdCounts[index] > 0 || IsRequired(index)) continue;
|
||||
(remove ??= new List<int>()).Add(index);
|
||||
}
|
||||
if (remove == null) return;
|
||||
|
||||
foreach (var index in remove)
|
||||
{
|
||||
(freed ??= new List<float[]>()).Add(_chunks[index]!);
|
||||
_chunks[index] = null;
|
||||
_loaded.Remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var data in freed!)
|
||||
_store.ReturnChunkArray(data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Next chunk to load and its priority (lower loads first), or false when nothing is missing.
|
||||
/// Windowed: demanded chunks, then each cursor's window by slot.
|
||||
/// Resident: the next chunk in order; priority is <see cref="PriorityDemanded"/> while a consumer is waiting on this source.
|
||||
/// Decoder thread only.
|
||||
/// </summary>
|
||||
internal bool TryGetNextLoad(out int index, out int priority)
|
||||
{
|
||||
index = -1;
|
||||
priority = int.MaxValue;
|
||||
if (_isFailed) return false;
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (IsResident)
|
||||
{
|
||||
if (_residentLoadedCount >= ChunkCount) return false;
|
||||
index = _residentLoadedCount;
|
||||
priority = _demanded.Count > 0 ? PriorityDemanded : PriorityResident;
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var demanded in _demanded)
|
||||
{
|
||||
if (_chunks[demanded] != null) continue;
|
||||
index = demanded;
|
||||
priority = PriorityDemanded;
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var cursor in _cursors)
|
||||
{
|
||||
if (!TryGetWindowStart(cursor, out var first, out var loop)) continue;
|
||||
for (var slot = 0; slot < SlotCount; slot++)
|
||||
{
|
||||
if (!TryWindowIndex(first, slot, loop, out var candidate)) break;
|
||||
if (_chunks[candidate] != null) continue;
|
||||
if (PriorityWindow + slot < priority)
|
||||
{
|
||||
index = candidate;
|
||||
priority = PriorityWindow + slot;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return index >= 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when a demand or a cursor window covers <paramref name="index"/>. Caller holds _sync.</summary>
|
||||
private bool IsRequired(int index)
|
||||
{
|
||||
if (_demanded.Contains(index)) return true;
|
||||
foreach (var cursor in _cursors)
|
||||
{
|
||||
if (!TryGetWindowStart(cursor, out var first, out var loop)) continue;
|
||||
for (var slot = 0; slot < SlotCount; slot++)
|
||||
{
|
||||
if (!TryWindowIndex(first, slot, loop, out var candidate)) break;
|
||||
if (candidate == index) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetWindowStart(AudioCursor cursor, out int first, out bool loop)
|
||||
{
|
||||
var position = cursor.Position;
|
||||
loop = cursor.Loop;
|
||||
first = 0;
|
||||
if (position < 0) position = 0;
|
||||
if (position >= TotalFrames)
|
||||
{
|
||||
if (!loop || TotalFrames == 0) return false;
|
||||
position %= TotalFrames;
|
||||
}
|
||||
first = ChunkIndexOf(position);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryWindowIndex(int first, int slot, bool loop, out int index)
|
||||
{
|
||||
index = first + slot;
|
||||
if (index < ChunkCount) return true;
|
||||
if (!loop) return false;
|
||||
index %= ChunkCount;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads chunk <paramref name="index"/> if it is not loaded. Resident sources load only the next chunk in order.
|
||||
/// Decoder thread only.
|
||||
/// </summary>
|
||||
/// <returns>True when this call loaded the chunk.</returns>
|
||||
internal bool LoadChunk(int index)
|
||||
{
|
||||
if (_isFailed || index < 0 || index >= ChunkCount) return false;
|
||||
|
||||
lock (_loadLock)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_chunks[index] != null) return false;
|
||||
if (IsResident && index != _residentLoadedCount)
|
||||
throw new InvalidOperationException($"Resident clip '{Key}' loads chunks in order: expected {_residentLoadedCount}, got {index}.");
|
||||
}
|
||||
|
||||
float[]? data = null;
|
||||
try
|
||||
{
|
||||
var start = (long)index * FramesPerChunk;
|
||||
var frames = (int)Math.Min(FramesPerChunk, TotalFrames - start);
|
||||
data = IsResident ? _store.AllocateExactArray(frames * Channels) : _store.RentChunkArray();
|
||||
var stream = SelectStream(start);
|
||||
stream.LastUse = ++_streamUseCounter;
|
||||
stream.Fill(start, frames, data);
|
||||
}
|
||||
catch (OutOfMemoryException e)
|
||||
{
|
||||
ReturnArray(data);
|
||||
Fail();
|
||||
_store.ReportFatal(this, e);
|
||||
return false;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
ReturnArray(data);
|
||||
Fail();
|
||||
_store.ReportSourceFailure(this, e);
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
_chunks[index] = data;
|
||||
_loaded.Add(index);
|
||||
if (IsResident) _residentLoadedCount++;
|
||||
}
|
||||
LoadCount++;
|
||||
|
||||
// A resident source never reads again after its last chunk; the reader releases its data now.
|
||||
if (IsResident && _residentLoadedCount == ChunkCount)
|
||||
DisposeStreams();
|
||||
}
|
||||
|
||||
_store.NotifyChunkLoaded();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode stream for a load starting at output frame <paramref name="start"/>:
|
||||
/// the stream positioned at <paramref name="start"/>; otherwise a new stream while fewer than <see cref="DecodeStreamLimit"/>
|
||||
/// are open (windowed sources only); otherwise the least recently used stream. Caller holds _loadLock.
|
||||
/// </summary>
|
||||
private DecodeStream SelectStream(long start)
|
||||
{
|
||||
DecodeStream? oldest = null;
|
||||
foreach (var stream in _streams)
|
||||
{
|
||||
if (stream.NextOutFrame == start) return stream;
|
||||
if (oldest == null || stream.LastUse < oldest.LastUse) oldest = stream;
|
||||
}
|
||||
|
||||
if (!IsResident && !_openFailed && _openReader != null && _streams.Count < DecodeStreamLimit)
|
||||
{
|
||||
IPCMReader? reader = null;
|
||||
try
|
||||
{
|
||||
reader = _openReader();
|
||||
if (reader != null)
|
||||
{
|
||||
var stream = new DecodeStream(reader, Frequency);
|
||||
_streams.Add(stream);
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
reader?.Dispose();
|
||||
Melon<RenderMod>.Logger.Warning($"[AudioClipSource] Could not open another decode stream for '{Key}', reusing open streams: {e.Message}");
|
||||
}
|
||||
_openFailed = true;
|
||||
}
|
||||
|
||||
return oldest!;
|
||||
}
|
||||
|
||||
private void Fail()
|
||||
{
|
||||
_isFailed = true;
|
||||
if (IsResident) DisposeStreams();
|
||||
}
|
||||
|
||||
private void DisposeStreams()
|
||||
{
|
||||
foreach (var stream in _streams)
|
||||
stream.Dispose();
|
||||
_streams.Clear();
|
||||
}
|
||||
|
||||
private void ReturnArray(float[]? data)
|
||||
{
|
||||
if (data == null) return;
|
||||
if (IsResident) _store.FreeExactArray(data);
|
||||
else _store.ReturnChunkArray(data);
|
||||
}
|
||||
|
||||
/// <summary>Releases all chunk arrays and readers. Called by the store after the decoder thread stopped.</summary>
|
||||
internal void DisposeInternal()
|
||||
{
|
||||
lock (_loadLock)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
foreach (var index in _loaded)
|
||||
{
|
||||
ReturnArray(_chunks[index]);
|
||||
_chunks[index] = null;
|
||||
}
|
||||
_loaded.Clear();
|
||||
_cursors.Clear();
|
||||
_demanded.Clear();
|
||||
}
|
||||
DisposeStreams();
|
||||
_firstReader.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static long CeilDiv(long a, long b) => (a + b - 1) / b;
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using MelonLoader;
|
||||
|
||||
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||
|
||||
/// <summary>
|
||||
/// Clip sources of one render, keyed by clip name or path, with a shared pool of chunk arrays and one decoder thread.
|
||||
///
|
||||
/// Threads:
|
||||
/// - Decoder thread (<see cref="Start"/>): the only thread that decodes, resamples, loads and frees chunks.
|
||||
/// Each step loads one chunk, the one with the lowest priority value over all sources
|
||||
/// (a chunk a consumer waits for, then cursor windows by slot, then resident chunks in order).
|
||||
/// - Consumers (render thread, main thread for the spectrum): <see cref="Acquire"/> waits for the decoder thread and never loads.
|
||||
/// - Main thread: <see cref="GetOrAdd"/> creates readers (for Unity clips this copies the clip data).
|
||||
///
|
||||
/// Memory has no limit; current and peak usage are tracked and logged by <see cref="LogMemoryUsage"/>.
|
||||
/// An <see cref="OutOfMemoryException"/> while loading is kept in <see cref="FatalError"/> and rethrown by <see cref="Acquire"/>.
|
||||
/// </summary>
|
||||
internal sealed class AudioClipStore : IDisposable
|
||||
{
|
||||
/// <summary>Longest wait of the decoder thread between checks when nothing signals it, in milliseconds.</summary>
|
||||
private const int DecoderIdleWaitMs = 50;
|
||||
|
||||
/// <summary>Longest wait of a consumer between checks when no chunk is loaded, in milliseconds.</summary>
|
||||
private const int ConsumerWaitMs = 50;
|
||||
|
||||
private readonly Dictionary<string, AudioClipSource?> _sources = new();
|
||||
private readonly List<AudioClipSource> _sourceList = new();
|
||||
private readonly Stack<float[]> _chunkPool = new();
|
||||
private readonly object _poolLock = new();
|
||||
private readonly object _signal = new();
|
||||
private readonly AutoResetEvent _serviceRequested = new(false);
|
||||
|
||||
private Thread? _decoderThread;
|
||||
private volatile bool _stopRequested;
|
||||
private long _allocatedBytes;
|
||||
private long _peakBytes;
|
||||
private volatile Exception? _fatalError;
|
||||
private volatile bool _disposed;
|
||||
|
||||
/// <summary>Sample rate every source is resampled to, in Hz.</summary>
|
||||
public int OutputRate { get; }
|
||||
|
||||
/// <summary>Bytes of sample arrays currently allocated (loaded chunks, pooled chunk arrays and copied Unity clip data).</summary>
|
||||
public long AllocatedBytes => Interlocked.Read(ref _allocatedBytes);
|
||||
|
||||
/// <summary>Highest value of <see cref="AllocatedBytes"/> since creation.</summary>
|
||||
public long PeakBytes => Interlocked.Read(ref _peakBytes);
|
||||
|
||||
/// <summary>Out-of-memory error raised while loading, or null.</summary>
|
||||
public Exception? FatalError => _fatalError;
|
||||
|
||||
public AudioClipStore(int outputRate)
|
||||
{
|
||||
if (outputRate <= 0) throw new ArgumentOutOfRangeException(nameof(outputRate));
|
||||
OutputRate = outputRate;
|
||||
}
|
||||
|
||||
// ── Decoder thread ──
|
||||
|
||||
/// <summary>Starts the decoder thread. Called once.</summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_decoderThread != null) throw new InvalidOperationException("Decoder thread already started.");
|
||||
_decoderThread = new Thread(DecoderLoop)
|
||||
{
|
||||
Name = "Audio Clip Decoder",
|
||||
IsBackground = true
|
||||
};
|
||||
_decoderThread.Start();
|
||||
}
|
||||
|
||||
private void DecoderLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!_stopRequested)
|
||||
{
|
||||
if (!LoadNext())
|
||||
_serviceRequested.WaitOne(DecoderIdleWaitMs);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Decoder thread stopped: {e}");
|
||||
_fatalError ??= e;
|
||||
NotifyChunkLoaded();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frees unused windowed chunks, then loads the single chunk with the lowest priority value over all sources.
|
||||
/// Runs on the decoder thread (tests may call it directly while the thread is not started).
|
||||
/// </summary>
|
||||
/// <returns>True when a chunk was loaded or a load was attempted.</returns>
|
||||
internal bool LoadNext()
|
||||
{
|
||||
AudioClipSource[] sources;
|
||||
lock (_sources)
|
||||
{
|
||||
if (_disposed) return false;
|
||||
sources = _sourceList.ToArray();
|
||||
}
|
||||
|
||||
AudioClipSource? best = null;
|
||||
var bestIndex = -1;
|
||||
var bestPriority = int.MaxValue;
|
||||
foreach (var source in sources)
|
||||
{
|
||||
source.FreeUnused();
|
||||
if (!source.TryGetNextLoad(out var index, out var priority) || priority >= bestPriority) continue;
|
||||
best = source;
|
||||
bestIndex = index;
|
||||
bestPriority = priority;
|
||||
}
|
||||
|
||||
if (best == null) return false;
|
||||
best.LoadChunk(bestIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void RequestService() => _serviceRequested.Set();
|
||||
|
||||
// ── Sources ──
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// For readers that can seek, <paramref name="createReader"/> is kept and called again on the decoder thread
|
||||
/// to open additional decode streams (see <see cref="AudioClipSource.MaxDecodeStreams"/>).
|
||||
/// </summary>
|
||||
public AudioClipSource? GetOrAdd(string key, Func<IPCMReader?> createReader)
|
||||
{
|
||||
lock (_sources)
|
||||
{
|
||||
if (_disposed) return null;
|
||||
if (_sources.TryGetValue(key, out var existing)) return existing;
|
||||
}
|
||||
|
||||
AudioClipSource? source = null;
|
||||
try
|
||||
{
|
||||
var reader = createReader();
|
||||
if (reader != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
source = new AudioClipSource(this, key, reader, reader.IsForwardOnly ? null : createReader, OutputRate);
|
||||
}
|
||||
catch
|
||||
{
|
||||
reader.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OutOfMemoryException e)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Out of memory while opening clip '{key}' ({AllocatedBytes / 1048576.0:F1} MiB allocated): {e}");
|
||||
_fatalError ??= e;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Failed to open clip '{key}': {e}");
|
||||
}
|
||||
|
||||
lock (_sources)
|
||||
{
|
||||
if (_disposed || _sources.TryGetValue(key, out var raced))
|
||||
{
|
||||
source?.DisposeInternal();
|
||||
return _disposed ? null : _sources[key];
|
||||
}
|
||||
_sources[key] = source;
|
||||
if (source != null) _sourceList.Add(source);
|
||||
}
|
||||
|
||||
if (source != null)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Msg($"[AudioClipStore] Opened clip '{key}': {source.Channels}ch, {source.Duration:F3}s, {source.TotalFrames} frames at {OutputRate}Hz, {source.ChunkCount} chunk(s), {source.Policy}");
|
||||
RequestService();
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
public bool TryGet(string key, out AudioClipSource? source)
|
||||
{
|
||||
lock (_sources)
|
||||
{
|
||||
return _sources.TryGetValue(key, out source);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Consumers ──
|
||||
|
||||
/// <summary>
|
||||
/// Holds the chunk containing <paramref name="frame"/>, waiting for the decoder thread until it is loaded.
|
||||
/// Returns an invalid view when the source failed, the frame is outside the source, or the store was disposed.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Loading ran out of memory or the decoder thread stopped.</exception>
|
||||
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was canceled while waiting.</exception>
|
||||
public ChunkView Acquire(AudioClipSource source, long frame, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (frame < 0 || frame >= source.TotalFrames) return default;
|
||||
|
||||
while (true)
|
||||
{
|
||||
ThrowIfFatal();
|
||||
if (source.IsFailed || _disposed) return default;
|
||||
if (source.TryAcquire(frame, out var view)) return view;
|
||||
|
||||
lock (_signal)
|
||||
{
|
||||
if (source.TryAcquire(frame, out view)) return view;
|
||||
if (source.IsFailed || _fatalError != null || _disposed) continue;
|
||||
Monitor.Wait(_signal, ConsumerWaitMs);
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfFatal()
|
||||
{
|
||||
var error = _fatalError;
|
||||
if (error != null)
|
||||
throw new InvalidOperationException("Audio clip loading failed: " + error.Message, error);
|
||||
}
|
||||
|
||||
internal void NotifyChunkLoaded()
|
||||
{
|
||||
lock (_signal)
|
||||
{
|
||||
Monitor.PulseAll(_signal);
|
||||
}
|
||||
}
|
||||
|
||||
internal void ReportSourceFailure(AudioClipSource source, Exception error)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Failed to load clip '{source.Key}', it will be silent: {error}");
|
||||
NotifyChunkLoaded();
|
||||
}
|
||||
|
||||
internal void ReportFatal(AudioClipSource source, OutOfMemoryException error)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Out of memory while loading clip '{source.Key}' ({AllocatedBytes / 1048576.0:F1} MiB allocated): {error}");
|
||||
_fatalError ??= error;
|
||||
NotifyChunkLoaded();
|
||||
}
|
||||
|
||||
// ── Arrays ──
|
||||
|
||||
internal float[] RentChunkArray()
|
||||
{
|
||||
lock (_poolLock)
|
||||
{
|
||||
if (_chunkPool.Count > 0) return _chunkPool.Pop();
|
||||
}
|
||||
var data = new float[AudioClipSource.ChunkFloats];
|
||||
AddAllocated(data.LongLength * sizeof(float));
|
||||
return data;
|
||||
}
|
||||
|
||||
internal void ReturnChunkArray(float[] data)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
AddAllocated(-data.LongLength * sizeof(float));
|
||||
return;
|
||||
}
|
||||
lock (_poolLock)
|
||||
{
|
||||
_chunkPool.Push(data);
|
||||
}
|
||||
}
|
||||
|
||||
internal float[] AllocateExactArray(int floats)
|
||||
{
|
||||
var data = new float[floats];
|
||||
AddAllocated(data.LongLength * sizeof(float));
|
||||
return data;
|
||||
}
|
||||
|
||||
internal void FreeExactArray(float[] data) => AddAllocated(-data.LongLength * sizeof(float));
|
||||
|
||||
private void AddAllocated(long bytes)
|
||||
{
|
||||
var current = Interlocked.Add(ref _allocatedBytes, bytes);
|
||||
long peak;
|
||||
while (current > (peak = Interlocked.Read(ref _peakBytes)))
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _peakBytes, current, peak) == peak) break;
|
||||
}
|
||||
}
|
||||
|
||||
public void LogMemoryUsage()
|
||||
{
|
||||
int sourceCount;
|
||||
lock (_sources) sourceCount = _sourceList.Count;
|
||||
Melon<RenderMod>.Logger.Msg($"[AudioClipStore] Clips: {sourceCount}, sample memory now {AllocatedBytes / 1048576.0:F1} MiB, peak {PeakBytes / 1048576.0:F1} MiB");
|
||||
}
|
||||
|
||||
/// <summary>Stops the decoder thread, then releases every source and pooled array.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
AudioClipSource[] sources;
|
||||
lock (_sources)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
sources = _sourceList.ToArray();
|
||||
}
|
||||
|
||||
_stopRequested = true;
|
||||
_serviceRequested.Set();
|
||||
NotifyChunkLoaded();
|
||||
_decoderThread?.Join();
|
||||
|
||||
LogMemoryUsage();
|
||||
|
||||
lock (_sources)
|
||||
{
|
||||
_sourceList.Clear();
|
||||
_sources.Clear();
|
||||
}
|
||||
|
||||
foreach (var source in sources)
|
||||
source.DisposeInternal();
|
||||
|
||||
lock (_poolLock)
|
||||
{
|
||||
while (_chunkPool.Count > 0)
|
||||
AddAllocated(-_chunkPool.Pop().LongLength * sizeof(float));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||
|
||||
/// <summary>
|
||||
/// One reader plus its resampler, producing output-rate frames of a clip as a continuous stream.
|
||||
/// Consecutive <see cref="Fill"/> calls whose ranges follow each other continue the stream (reader position and resampler state);
|
||||
/// any other range starts a new stream near the requested position.
|
||||
///
|
||||
/// Restart: the input position is moved back by a preroll and down to a multiple of in_rate / gcd(in_rate, out_rate),
|
||||
/// where input frame k * (in_rate / gcd) maps exactly to output frame k * (out_rate / gcd);
|
||||
/// the resampler is reset and output before the requested frame is discarded.
|
||||
/// Measured with the clip harness: output is bit-identical to one continuous conversion of the whole clip.
|
||||
/// Used by one thread at a time (the store's decoder thread).
|
||||
/// </summary>
|
||||
internal sealed class DecodeStream : IDisposable
|
||||
{
|
||||
/// <summary>Input frames read from the reader per resampler call.</summary>
|
||||
private const int ReadBlockFrames = 4096;
|
||||
|
||||
private readonly PCMResampler? _resampler;
|
||||
private readonly int _channels;
|
||||
private readonly int _outputRate;
|
||||
private readonly float[] _readBuffer;
|
||||
private float[]? _discardBuffer;
|
||||
private bool _flushing;
|
||||
private bool _ended;
|
||||
|
||||
private readonly long _inputStep;
|
||||
private readonly long _outputStep;
|
||||
private readonly long _prerollInputFrames;
|
||||
|
||||
public IPCMReader Reader { get; }
|
||||
|
||||
/// <summary>Output frame the stream continues at, or -1 when the stream is not positioned.</summary>
|
||||
public long NextOutFrame { get; private set; } = -1;
|
||||
|
||||
/// <summary>Value of the owner's use counter at the last <see cref="Fill"/> (for least-recently-used selection).</summary>
|
||||
public long LastUse { get; set; }
|
||||
|
||||
/// <summary>Number of times the stream started anew (seek + resampler reset).</summary>
|
||||
public int RestartCount { get; private set; }
|
||||
|
||||
public DecodeStream(IPCMReader reader, int outputRate)
|
||||
{
|
||||
Reader = reader;
|
||||
_channels = reader.Channels;
|
||||
_outputRate = outputRate;
|
||||
_readBuffer = new float[ReadBlockFrames * _channels];
|
||||
|
||||
if (reader.SampleRate != outputRate)
|
||||
{
|
||||
_resampler = new PCMResampler(_channels, reader.SampleRate, outputRate);
|
||||
var gcd = GCD(reader.SampleRate, outputRate);
|
||||
_inputStep = reader.SampleRate / gcd;
|
||||
_outputStep = outputRate / gcd;
|
||||
|
||||
// History fed before the first kept output frame when a stream starts mid-clip.
|
||||
// The filter spans about filter_size input frames per output frame at ratios up to 1:1
|
||||
// and filter_size * (input rate / output rate) input frames when downsampling.
|
||||
var ratio = Math.Max(1L, (reader.SampleRate + outputRate - 1L) / outputRate);
|
||||
_prerollInputFrames = 4L * _resampler.FilterSize * ratio + _inputStep;
|
||||
}
|
||||
else
|
||||
{
|
||||
_inputStep = 1;
|
||||
_outputStep = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes output frames [start, start + frames) into <paramref name="data"/> and zeros the rest of the array.
|
||||
/// Frames past the end of the decoded input are zero.
|
||||
/// </summary>
|
||||
public unsafe void Fill(long start, int frames, float[] data)
|
||||
{
|
||||
var channels = _channels;
|
||||
var filled = 0;
|
||||
|
||||
if (_resampler == null)
|
||||
{
|
||||
if (Reader.Position != start)
|
||||
{
|
||||
Reader.Seek(start);
|
||||
RestartCount++;
|
||||
}
|
||||
while (filled < frames)
|
||||
{
|
||||
var request = Math.Min(ReadBlockFrames, frames - filled);
|
||||
var read = Reader.Read(_readBuffer, request);
|
||||
if (read <= 0) break;
|
||||
Array.Copy(_readBuffer, 0, data, filled * channels, read * channels);
|
||||
filled += read;
|
||||
}
|
||||
Array.Clear(data, filled * channels, data.Length - filled * channels);
|
||||
NextOutFrame = start + frames;
|
||||
return;
|
||||
}
|
||||
|
||||
long discard = 0;
|
||||
if (NextOutFrame != start)
|
||||
{
|
||||
// Input frame of the requested output frame, moved back by the preroll and down to a multiple of _inputStep.
|
||||
var inputFrame = start * Reader.SampleRate / _outputRate - _prerollInputFrames;
|
||||
var step = inputFrame > 0 ? inputFrame / _inputStep : 0;
|
||||
|
||||
_resampler.Reset();
|
||||
Reader.Seek(step * _inputStep);
|
||||
_flushing = false;
|
||||
_ended = false;
|
||||
discard = start - step * _outputStep;
|
||||
RestartCount++;
|
||||
}
|
||||
|
||||
fixed (float* pIn = _readBuffer)
|
||||
fixed (float* pOut = data)
|
||||
{
|
||||
while (discard > 0 && !_ended)
|
||||
{
|
||||
_discardBuffer ??= new float[ReadBlockFrames * channels];
|
||||
fixed (float* pDiscard = _discardBuffer)
|
||||
{
|
||||
var capacity = (int)Math.Min(ReadBlockFrames, discard);
|
||||
discard -= Pull(pIn, pDiscard, capacity);
|
||||
}
|
||||
}
|
||||
|
||||
while (filled < frames && !_ended)
|
||||
filled += Pull(pIn, pOut + (long)filled * channels, frames - filled);
|
||||
}
|
||||
|
||||
Array.Clear(data, filled * channels, data.Length - filled * channels);
|
||||
NextOutFrame = _ended ? -1 : start + frames;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes 1 ~ <paramref name="capacity"/> resampled frames to <paramref name="output"/>, reading input as needed.
|
||||
/// Returns 0 and sets _ended once the input and the resampler's flushed tail are exhausted.
|
||||
/// </summary>
|
||||
private unsafe int Pull(float* input, float* output, int capacity)
|
||||
{
|
||||
var resampler = _resampler!;
|
||||
if (!_flushing)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var produced = resampler.Convert(input, 0, output, capacity);
|
||||
if (produced > 0) return produced;
|
||||
|
||||
var read = Reader.Read(_readBuffer, ReadBlockFrames);
|
||||
if (read <= 0)
|
||||
{
|
||||
_flushing = true;
|
||||
break;
|
||||
}
|
||||
|
||||
produced = resampler.Convert(input, read, output, capacity);
|
||||
if (produced > 0) return produced;
|
||||
}
|
||||
}
|
||||
|
||||
// Input is exhausted: take the resampler's remaining tail until it returns 0.
|
||||
var flushed = resampler.Flush(output, capacity);
|
||||
if (flushed > 0) return flushed;
|
||||
|
||||
_ended = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_resampler?.Dispose();
|
||||
Reader.Dispose();
|
||||
}
|
||||
|
||||
private static int GCD(int a, int b)
|
||||
{
|
||||
while (b != 0) (a, b) = (b, a % b);
|
||||
return a;
|
||||
}
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using FFmpeg.AutoGen;
|
||||
|
||||
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IPCMReader"/> that decodes the best audio stream of a file with libavformat/libavcodec.
|
||||
/// Output keeps the stream's sample rate and channel count; libswresample converts only the sample format to interleaved float.
|
||||
///
|
||||
/// Frame positions: frame 0 is the first sample the decoder outputs for the stream's start time.
|
||||
/// The position of the first frame decoded after opening or seeking is taken from its timestamp
|
||||
/// (best_effort_timestamp - stream start_time, in samples); following frames are counted sequentially.
|
||||
/// Seek moves the demuxer to <see cref="SeekMarginSeconds"/> before the target and discards decoded samples before the target.
|
||||
///
|
||||
/// <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.
|
||||
/// 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)
|
||||
/// </summary>
|
||||
internal sealed unsafe class FFmpegPCMReader : IPCMReader
|
||||
{
|
||||
/// <summary>
|
||||
/// Time the demuxer is moved before a seek target, in seconds. Decoders that depend on previous packets
|
||||
/// (for example the MP3 bit reservoir) produce settled output after this margin; the samples in it are discarded.
|
||||
/// </summary>
|
||||
private const double SeekMarginSeconds = 0.25;
|
||||
|
||||
private AVFormatContext* _format;
|
||||
private AVCodecContext* _codec;
|
||||
private SwrContext* _swr;
|
||||
private AVPacket* _packet;
|
||||
private AVFrame* _frame;
|
||||
private readonly int _streamIndex;
|
||||
private readonly AVRational _timeBase;
|
||||
private readonly long _startPts;
|
||||
|
||||
// Decoded samples not yet returned: frames [_pendingStart, _pendingStart + _pendingCount) at _pending[_pendingOffset * Channels].
|
||||
private float[] _pending = Array.Empty<float>();
|
||||
private int _pendingOffset;
|
||||
private int _pendingCount;
|
||||
private long _pendingStart;
|
||||
|
||||
private long _nextDecodedFrame; // Frame index of the next decoded frame when counting sequentially
|
||||
private bool _syncToTimestamp; // True until the first frame after open/seek sets _nextDecodedFrame from its timestamp
|
||||
private bool _flushSent;
|
||||
private bool _endOfStream;
|
||||
|
||||
public string Path { get; }
|
||||
public int Channels { get; }
|
||||
public int SampleRate { get; }
|
||||
public long TotalFrames { get; }
|
||||
public bool IsForwardOnly => false;
|
||||
public long Position { get; private set; }
|
||||
|
||||
/// <summary>Number of demuxer seeks performed (for diagnostics).</summary>
|
||||
public int SeekCount { get; private set; }
|
||||
|
||||
public FFmpegPCMReader(string path)
|
||||
{
|
||||
Path = path;
|
||||
try
|
||||
{
|
||||
AVFormatContext* format = null;
|
||||
var ret = ffmpeg.avformat_open_input(&format, path, null, null);
|
||||
if (ret < 0) throw new InvalidOperationException($"Failed to open audio file '{path}': {ErrorString(ret)}");
|
||||
_format = format;
|
||||
|
||||
ret = ffmpeg.avformat_find_stream_info(_format, null);
|
||||
if (ret < 0) throw new InvalidOperationException($"Failed to read stream info of '{path}': {ErrorString(ret)}");
|
||||
|
||||
AVCodec* decoder = null;
|
||||
_streamIndex = ffmpeg.av_find_best_stream(_format, AVMediaType.AVMEDIA_TYPE_AUDIO, -1, -1, &decoder, 0);
|
||||
if (_streamIndex < 0 || decoder == null) throw new InvalidOperationException($"No decodable audio stream in '{path}': {ErrorString(_streamIndex)}");
|
||||
|
||||
for (var i = 0; i < (int)_format->nb_streams; i++)
|
||||
{
|
||||
if (i != _streamIndex) _format->streams[i]->discard = AVDiscard.AVDISCARD_ALL;
|
||||
}
|
||||
|
||||
var stream = _format->streams[_streamIndex];
|
||||
_timeBase = stream->time_base;
|
||||
_startPts = stream->start_time != ffmpeg.AV_NOPTS_VALUE ? stream->start_time : 0;
|
||||
|
||||
_codec = ffmpeg.avcodec_alloc_context3(decoder);
|
||||
if (_codec == null) throw new InvalidOperationException("avcodec_alloc_context3 failed.");
|
||||
ret = ffmpeg.avcodec_parameters_to_context(_codec, stream->codecpar);
|
||||
if (ret < 0) throw new InvalidOperationException($"avcodec_parameters_to_context failed: {ErrorString(ret)}");
|
||||
_codec->pkt_timebase = _timeBase;
|
||||
ret = ffmpeg.avcodec_open2(_codec, decoder, null);
|
||||
if (ret < 0) throw new InvalidOperationException($"Failed to open audio decoder for '{path}': {ErrorString(ret)}");
|
||||
|
||||
Channels = _codec->ch_layout.nb_channels;
|
||||
SampleRate = _codec->sample_rate;
|
||||
if (Channels <= 0 || SampleRate <= 0) throw new InvalidOperationException($"Invalid audio format in '{path}': {Channels}ch {SampleRate}Hz.");
|
||||
|
||||
TotalFrames = EstimateTotalFrames(stream);
|
||||
|
||||
SwrContext* swr = null;
|
||||
ret = ffmpeg.swr_alloc_set_opts2(&swr,
|
||||
&_codec->ch_layout, AVSampleFormat.AV_SAMPLE_FMT_FLT, SampleRate,
|
||||
&_codec->ch_layout, _codec->sample_fmt, SampleRate, 0, null);
|
||||
if (ret < 0 || swr == null) throw new InvalidOperationException($"swr_alloc_set_opts2 failed: {ErrorString(ret)}");
|
||||
_swr = swr;
|
||||
ret = ffmpeg.swr_init(_swr);
|
||||
if (ret < 0) throw new InvalidOperationException($"swr_init failed: {ErrorString(ret)}");
|
||||
|
||||
_packet = ffmpeg.av_packet_alloc();
|
||||
_frame = ffmpeg.av_frame_alloc();
|
||||
if (_packet == null || _frame == null) throw new InvalidOperationException("Failed to allocate packet/frame.");
|
||||
|
||||
_syncToTimestamp = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private long EstimateTotalFrames(AVStream* stream)
|
||||
{
|
||||
if (stream->duration != ffmpeg.AV_NOPTS_VALUE && stream->duration > 0)
|
||||
return ffmpeg.av_rescale_q(stream->duration, _timeBase, new AVRational { num = 1, den = SampleRate });
|
||||
if (_format->duration != ffmpeg.AV_NOPTS_VALUE && _format->duration > 0)
|
||||
return ffmpeg.av_rescale(_format->duration, SampleRate, ffmpeg.AV_TIME_BASE);
|
||||
throw new InvalidOperationException($"Audio file '{Path}' has no duration.");
|
||||
}
|
||||
|
||||
public void Seek(long frame)
|
||||
{
|
||||
if (frame < 0 || frame > TotalFrames) throw new ArgumentOutOfRangeException(nameof(frame));
|
||||
if (frame == Position) return;
|
||||
|
||||
var marginFrames = (long)(SeekMarginSeconds * SampleRate);
|
||||
|
||||
// Target inside the pending block or less than the seek margin after it: decode forward and discard.
|
||||
if (!_syncToTimestamp && frame >= _pendingStart + _pendingOffset
|
||||
&& frame <= _pendingStart + _pendingOffset + _pendingCount + marginFrames)
|
||||
{
|
||||
Position = frame;
|
||||
return;
|
||||
}
|
||||
|
||||
var targetTs = _startPts + ffmpeg.av_rescale_q(Math.Max(0, frame - marginFrames), new AVRational { num = 1, den = SampleRate }, _timeBase);
|
||||
var ret = ffmpeg.av_seek_frame(_format, _streamIndex, targetTs, ffmpeg.AVSEEK_FLAG_BACKWARD);
|
||||
if (ret < 0)
|
||||
ret = ffmpeg.av_seek_frame(_format, _streamIndex, _startPts, ffmpeg.AVSEEK_FLAG_BACKWARD);
|
||||
if (ret < 0)
|
||||
throw new InvalidOperationException($"Failed to seek '{Path}' to frame {frame}: {ErrorString(ret)}");
|
||||
|
||||
ffmpeg.avcodec_flush_buffers(_codec);
|
||||
SeekCount++;
|
||||
_pendingCount = 0;
|
||||
_pendingOffset = 0;
|
||||
_syncToTimestamp = true;
|
||||
_flushSent = false;
|
||||
_endOfStream = false;
|
||||
Position = frame;
|
||||
}
|
||||
|
||||
public int Read(float[] buffer, int frameCount)
|
||||
{
|
||||
var limit = (int)Math.Min(frameCount, TotalFrames - Position);
|
||||
var written = 0;
|
||||
while (written < limit)
|
||||
{
|
||||
if (_pendingCount == 0 || Position >= _pendingStart + _pendingOffset + _pendingCount)
|
||||
{
|
||||
if (!DecodeNextFrame()) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
var pendingPosition = _pendingStart + _pendingOffset;
|
||||
if (Position > pendingPosition)
|
||||
{
|
||||
// Discard decoded samples before the read position (after a seek).
|
||||
var skip = (int)Math.Min(Position - pendingPosition, _pendingCount);
|
||||
_pendingOffset += skip;
|
||||
_pendingCount -= skip;
|
||||
continue;
|
||||
}
|
||||
if (Position < pendingPosition)
|
||||
{
|
||||
// A gap in the decoded timestamps: fill with silence up to the next decoded sample.
|
||||
var gap = (int)Math.Min(pendingPosition - Position, limit - written);
|
||||
Array.Clear(buffer, written * Channels, gap * Channels);
|
||||
written += gap;
|
||||
Position += gap;
|
||||
continue;
|
||||
}
|
||||
|
||||
var count = Math.Min(_pendingCount, limit - written);
|
||||
Array.Copy(_pending, _pendingOffset * Channels, buffer, written * Channels, count * Channels);
|
||||
_pendingOffset += count;
|
||||
_pendingCount -= count;
|
||||
written += count;
|
||||
Position += count;
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
/// <summary>Decodes the next audio frame into the pending buffer. Returns false at the end of the stream.</summary>
|
||||
private bool DecodeNextFrame()
|
||||
{
|
||||
while (!_endOfStream)
|
||||
{
|
||||
var ret = ffmpeg.avcodec_receive_frame(_codec, _frame);
|
||||
if (ret == 0)
|
||||
{
|
||||
StoreFrame();
|
||||
ffmpeg.av_frame_unref(_frame);
|
||||
return true;
|
||||
}
|
||||
if (ret == ffmpeg.AVERROR_EOF)
|
||||
{
|
||||
_endOfStream = true;
|
||||
break;
|
||||
}
|
||||
if (ret != ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
||||
throw new InvalidOperationException($"Audio decoding failed in '{Path}': {ErrorString(ret)}");
|
||||
|
||||
if (_flushSent)
|
||||
{
|
||||
_endOfStream = true;
|
||||
break;
|
||||
}
|
||||
|
||||
ret = ffmpeg.av_read_frame(_format, _packet);
|
||||
if (ret < 0)
|
||||
{
|
||||
if (ret != ffmpeg.AVERROR_EOF)
|
||||
throw new InvalidOperationException($"Failed to read '{Path}': {ErrorString(ret)}");
|
||||
ffmpeg.avcodec_send_packet(_codec, null);
|
||||
_flushSent = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_packet->stream_index != _streamIndex) continue;
|
||||
ret = ffmpeg.avcodec_send_packet(_codec, _packet);
|
||||
// A corrupt packet is skipped; decoding continues with the next one.
|
||||
if (ret < 0 && ret != ffmpeg.AVERROR_INVALIDDATA && ret != ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
||||
throw new InvalidOperationException($"Failed to send audio packet of '{Path}': {ErrorString(ret)}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
ffmpeg.av_packet_unref(_packet);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void StoreFrame()
|
||||
{
|
||||
var samples = _frame->nb_samples;
|
||||
if (samples <= 0) return;
|
||||
|
||||
if (_syncToTimestamp)
|
||||
{
|
||||
var pts = _frame->best_effort_timestamp;
|
||||
_nextDecodedFrame = pts != ffmpeg.AV_NOPTS_VALUE
|
||||
? ffmpeg.av_rescale_q(pts - _startPts, _timeBase, new AVRational { num = 1, den = SampleRate })
|
||||
: Position;
|
||||
_syncToTimestamp = false;
|
||||
}
|
||||
|
||||
if (_pending.Length < samples * Channels)
|
||||
_pending = new float[samples * Channels];
|
||||
|
||||
fixed (float* pPending = _pending)
|
||||
{
|
||||
var outPlane = (byte*)pPending;
|
||||
var converted = ffmpeg.swr_convert(_swr, &outPlane, samples, _frame->extended_data, samples);
|
||||
if (converted < 0)
|
||||
throw new InvalidOperationException($"Sample format conversion failed in '{Path}': {ErrorString(converted)}");
|
||||
samples = converted;
|
||||
}
|
||||
|
||||
_pendingStart = _nextDecodedFrame;
|
||||
_pendingOffset = 0;
|
||||
_pendingCount = samples;
|
||||
_nextDecodedFrame += samples;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_frame != null)
|
||||
{
|
||||
var frame = _frame;
|
||||
ffmpeg.av_frame_free(&frame);
|
||||
_frame = null;
|
||||
}
|
||||
if (_packet != null)
|
||||
{
|
||||
var packet = _packet;
|
||||
ffmpeg.av_packet_free(&packet);
|
||||
_packet = null;
|
||||
}
|
||||
if (_swr != null)
|
||||
{
|
||||
var swr = _swr;
|
||||
ffmpeg.swr_free(&swr);
|
||||
_swr = null;
|
||||
}
|
||||
if (_codec != null)
|
||||
{
|
||||
var codec = _codec;
|
||||
ffmpeg.avcodec_free_context(&codec);
|
||||
_codec = null;
|
||||
}
|
||||
if (_format != null)
|
||||
{
|
||||
var format = _format;
|
||||
ffmpeg.avformat_close_input(&format);
|
||||
_format = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ErrorString(int error)
|
||||
{
|
||||
var buffer = stackalloc byte[1024];
|
||||
ffmpeg.av_strerror(error, buffer, 1024);
|
||||
return $"{Marshal.PtrToStringAnsi((IntPtr)buffer)} ({error})";
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||
|
||||
/// <summary>
|
||||
/// Sequential reader of decoded PCM at the clip's own sample rate and channel count.
|
||||
/// Samples are interleaved 32-bit float. Positions and counts are in frames (one frame = one sample per channel).
|
||||
/// Used by one thread at a time (the store's decoder thread). <see cref="IDisposable.Dispose"/> may be called more than once.
|
||||
/// </summary>
|
||||
internal interface IPCMReader : IDisposable
|
||||
{
|
||||
/// <summary>Channel count of the decoded audio (1 or more).</summary>
|
||||
int Channels { get; }
|
||||
|
||||
/// <summary>Sample rate of the decoded audio in Hz.</summary>
|
||||
int SampleRate { get; }
|
||||
|
||||
/// <summary>Total frame count of the clip at <see cref="SampleRate"/>.</summary>
|
||||
long TotalFrames { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the data can be read only once, from frame 0 to the end (data is released as it is read).
|
||||
/// The source then keeps every chunk (<see cref="ChunkPolicy.Resident"/>).
|
||||
/// </summary>
|
||||
bool IsForwardOnly { get; }
|
||||
|
||||
/// <summary>Frame index that the next <see cref="Read"/> starts at.</summary>
|
||||
long Position { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Moves the read position to <paramref name="frame"/> (0 ~ <see cref="TotalFrames"/>).
|
||||
/// A forward-only reader accepts only frames whose data has not been released.
|
||||
/// </summary>
|
||||
void Seek(long frame);
|
||||
|
||||
/// <summary>
|
||||
/// Reads up to <paramref name="frameCount"/> frames into <paramref name="buffer"/> starting at index 0 and advances the position.
|
||||
/// <paramref name="buffer"/> must hold at least frameCount * Channels floats.
|
||||
/// </summary>
|
||||
/// <returns>Frames read. 0 at the end of the clip.</returns>
|
||||
int Read(float[] buffer, int frameCount);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||
|
||||
/// <summary>
|
||||
/// Forward-only <see cref="IPCMReader"/> over PCM held in memory as a list of blocks.
|
||||
/// Block b holds frames [b * FramesPerBlock, (b + 1) * FramesPerBlock) interleaved; the last block may be shorter.
|
||||
/// Each block is released (passed to the release callback and dropped) as soon as the read position passes its end,
|
||||
/// and all remaining blocks are released on <see cref="Dispose"/>.
|
||||
/// </summary>
|
||||
internal sealed class MemoryPCMReader : IPCMReader
|
||||
{
|
||||
private readonly float[]?[] _blocks;
|
||||
private readonly Action<float[]>? _onBlockReleased;
|
||||
private int _releasedCount; // Blocks [0, _releasedCount) are released
|
||||
|
||||
public int Channels { get; }
|
||||
public int SampleRate { get; }
|
||||
public long TotalFrames { get; }
|
||||
public bool IsForwardOnly => true;
|
||||
public long Position { get; private set; }
|
||||
|
||||
/// <summary>Frames per block (all blocks except the last).</summary>
|
||||
public int FramesPerBlock { get; }
|
||||
|
||||
public MemoryPCMReader(IReadOnlyList<float[]> blocks, int framesPerBlock, int channels, int sampleRate, long totalFrames,
|
||||
Action<float[]>? onBlockReleased)
|
||||
{
|
||||
if (channels <= 0) throw new ArgumentOutOfRangeException(nameof(channels));
|
||||
if (framesPerBlock <= 0) throw new ArgumentOutOfRangeException(nameof(framesPerBlock));
|
||||
if (totalFrames > (long)blocks.Count * framesPerBlock) throw new ArgumentException("Blocks hold fewer frames than totalFrames.", nameof(blocks));
|
||||
|
||||
_blocks = new float[blocks.Count][];
|
||||
for (var i = 0; i < blocks.Count; i++) _blocks[i] = blocks[i];
|
||||
_onBlockReleased = onBlockReleased;
|
||||
FramesPerBlock = framesPerBlock;
|
||||
Channels = channels;
|
||||
SampleRate = sampleRate;
|
||||
TotalFrames = totalFrames;
|
||||
}
|
||||
|
||||
public void Seek(long frame)
|
||||
{
|
||||
if (frame < 0 || frame > TotalFrames) throw new ArgumentOutOfRangeException(nameof(frame));
|
||||
if (frame < (long)_releasedCount * FramesPerBlock)
|
||||
throw new InvalidOperationException($"Frame {frame} was already released by the forward-only reader.");
|
||||
Position = frame;
|
||||
}
|
||||
|
||||
public int Read(float[] buffer, int frameCount)
|
||||
{
|
||||
var total = (int)Math.Min(frameCount, TotalFrames - Position);
|
||||
if (total <= 0) return 0;
|
||||
|
||||
var written = 0;
|
||||
while (written < total)
|
||||
{
|
||||
var blockIndex = (int)(Position / FramesPerBlock);
|
||||
var block = _blocks[blockIndex] ?? throw new InvalidOperationException($"Block {blockIndex} was already released.");
|
||||
var offset = (int)(Position - (long)blockIndex * FramesPerBlock);
|
||||
var count = Math.Min(total - written, FramesPerBlock - offset);
|
||||
Array.Copy(block, offset * Channels, buffer, written * Channels, count * Channels);
|
||||
written += count;
|
||||
Position += count;
|
||||
ReleaseBlocksBefore((int)(Position / FramesPerBlock));
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
private void ReleaseBlocksBefore(int blockIndex)
|
||||
{
|
||||
while (_releasedCount < blockIndex && _releasedCount < _blocks.Length)
|
||||
{
|
||||
var block = _blocks[_releasedCount];
|
||||
_blocks[_releasedCount] = null;
|
||||
_releasedCount++;
|
||||
if (block != null) _onBlockReleased?.Invoke(block);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => ReleaseBlocksBefore(_blocks.Length);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
using System;
|
||||
using FFmpeg.AutoGen;
|
||||
|
||||
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||
|
||||
/// <summary>
|
||||
/// Sample rate converter for interleaved 32-bit float PCM (libswresample).
|
||||
/// Input and output have the same channel count; only the sample rate changes.
|
||||
/// One instance keeps filter state between calls, so consecutive <see cref="Convert"/> calls form one continuous stream.
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lswr.html
|
||||
/// </summary>
|
||||
internal sealed unsafe class PCMResampler : IDisposable
|
||||
{
|
||||
private SwrContext* _swr;
|
||||
private bool _flushed;
|
||||
|
||||
public int Channels { get; }
|
||||
public int InputRate { get; }
|
||||
public int OutputRate { get; }
|
||||
|
||||
/// <summary>Value of the SwrContext "filter_size" option (filter length per phase in taps at a 1:1 ratio).</summary>
|
||||
public int FilterSize { get; }
|
||||
|
||||
public PCMResampler(int channels, int inputRate, int outputRate)
|
||||
{
|
||||
if (channels <= 0) throw new ArgumentOutOfRangeException(nameof(channels));
|
||||
if (inputRate <= 0) throw new ArgumentOutOfRangeException(nameof(inputRate));
|
||||
if (outputRate <= 0) throw new ArgumentOutOfRangeException(nameof(outputRate));
|
||||
|
||||
Channels = channels;
|
||||
InputRate = inputRate;
|
||||
OutputRate = outputRate;
|
||||
|
||||
AVChannelLayout layout;
|
||||
ffmpeg.av_channel_layout_default(&layout, channels);
|
||||
|
||||
SwrContext* swr = null;
|
||||
var ret = ffmpeg.swr_alloc_set_opts2(
|
||||
&swr,
|
||||
&layout, AVSampleFormat.AV_SAMPLE_FMT_FLT, outputRate,
|
||||
&layout, AVSampleFormat.AV_SAMPLE_FMT_FLT, inputRate,
|
||||
0, null);
|
||||
ffmpeg.av_channel_layout_uninit(&layout);
|
||||
if (ret < 0 || swr == null)
|
||||
throw new InvalidOperationException($"swr_alloc_set_opts2 failed ({ret}).");
|
||||
_swr = swr;
|
||||
|
||||
long filterSize;
|
||||
FilterSize = ffmpeg.av_opt_get_int(_swr, "filter_size", 0, &filterSize) >= 0 ? (int)filterSize : 32;
|
||||
|
||||
Reset();
|
||||
}
|
||||
|
||||
/// <summary>Discards all buffered input and filter history. The next call starts a new stream.</summary>
|
||||
public void Reset()
|
||||
{
|
||||
// swr_init on an initialized context re-initializes it.
|
||||
// Reference: https://ffmpeg.org/doxygen/trunk/group__lswr.html (swr_init, swr_close)
|
||||
var ret = ffmpeg.swr_init(_swr);
|
||||
if (ret < 0)
|
||||
throw new InvalidOperationException($"swr_init failed ({ret}).");
|
||||
_flushed = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feeds <paramref name="inputFrames"/> frames and writes up to <paramref name="outputCapacity"/> frames.
|
||||
/// Input that does not fit in the output is buffered inside the context and returned by later calls.
|
||||
/// <paramref name="inputFrames"/> may be 0 to take only buffered output.
|
||||
/// </summary>
|
||||
/// <returns>Frames written to <paramref name="output"/>.</returns>
|
||||
public int Convert(float* input, int inputFrames, float* output, int outputCapacity)
|
||||
{
|
||||
if (_flushed) throw new InvalidOperationException("Resampler was flushed. Call Reset first.");
|
||||
|
||||
// swr_convert treats a null input array as a flush request, so a non-null pointer is passed even for 0 frames.
|
||||
float dummy = 0;
|
||||
var inPlane = (byte*)(input != null ? input : &dummy);
|
||||
var outPlane = (byte*)output;
|
||||
var ret = ffmpeg.swr_convert(_swr, &outPlane, outputCapacity, &inPlane, inputFrames);
|
||||
if (ret < 0)
|
||||
throw new InvalidOperationException($"swr_convert failed ({ret}).");
|
||||
return ret;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends the input stream and writes up to <paramref name="outputCapacity"/> remaining frames.
|
||||
/// Call repeatedly until it returns 0. <see cref="Convert"/> is not allowed afterwards until <see cref="Reset"/>.
|
||||
/// </summary>
|
||||
public int Flush(float* output, int outputCapacity)
|
||||
{
|
||||
_flushed = true;
|
||||
var outPlane = (byte*)output;
|
||||
var ret = ffmpeg.swr_convert(_swr, &outPlane, outputCapacity, null, 0);
|
||||
if (ret < 0)
|
||||
throw new InvalidOperationException($"swr_convert (flush) failed ({ret}).");
|
||||
return ret;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_swr == null) return;
|
||||
var swr = _swr;
|
||||
ffmpeg.swr_free(&swr);
|
||||
_swr = null;
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||
|
||||
/// <summary>
|
||||
/// Copies a Unity <see cref="AudioClip"/> into memory for clips without a file on disk
|
||||
/// (built-in hit sounds from Resources, official/DLC level audio).
|
||||
/// <see cref="AudioClip.GetData(float[], int)"/> runs on the main thread only, so the whole clip is copied once on the main thread
|
||||
/// and the decoder thread reads the copy through a <see cref="MemoryPCMReader"/>.
|
||||
/// Streaming clips (<see cref="AudioClipLoadType.Streaming"/>) are rejected because GetData does not read them.
|
||||
/// Reference: https://docs.unity3d.com/ScriptReference/AudioClip.GetData.html
|
||||
/// </summary>
|
||||
internal static class UnityClipLoader
|
||||
{
|
||||
/// <summary>
|
||||
/// Copies <paramref name="clip"/> in blocks of <see cref="AudioClipSource.ChunkFloats"/> floats or less
|
||||
/// (FramesPerBlock = ChunkFloats / channels frames per GetData call). Main thread only.
|
||||
/// Block memory is counted in <paramref name="store"/> and released block by block as the decoder thread reads it.
|
||||
/// </summary>
|
||||
public static MemoryPCMReader Load(AudioClip clip, AudioClipStore store)
|
||||
{
|
||||
if (!clip) throw new ArgumentNullException(nameof(clip));
|
||||
if (clip.loadType == AudioClipLoadType.Streaming)
|
||||
throw new NotSupportedException($"AudioClip '{clip.name}' is a streaming clip; GetData cannot read it.");
|
||||
|
||||
if (clip.loadState == AudioDataLoadState.Unloaded)
|
||||
clip.LoadAudioData();
|
||||
|
||||
var channels = clip.channels;
|
||||
var totalFrames = (long)clip.samples;
|
||||
var framesPerBlock = AudioClipSource.ChunkFloats / channels;
|
||||
var blocks = new List<float[]>();
|
||||
try
|
||||
{
|
||||
for (long start = 0; start < totalFrames; start += framesPerBlock)
|
||||
{
|
||||
var frames = (int)Math.Min(framesPerBlock, totalFrames - start);
|
||||
var block = store.AllocateExactArray(frames * channels);
|
||||
blocks.Add(block);
|
||||
// GetData fills the whole array; the offset is in frames (samples per channel).
|
||||
if (!clip.GetData(block, (int)start))
|
||||
throw new InvalidOperationException($"AudioClip.GetData failed for '{clip.name}' at frame {start} (load state {clip.loadState}).");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
foreach (var block in blocks) store.FreeExactArray(block);
|
||||
throw;
|
||||
}
|
||||
|
||||
return new MemoryPCMReader(blocks, framesPerBlock, channels, clip.frequency, totalFrames, store.FreeExactArray);
|
||||
}
|
||||
}
|
||||
@@ -1125,7 +1125,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="yBuffer">Pointer to destination buffer for Y plane (Width * Height bytes).</param>
|
||||
/// <param name="yStride">Row stride in bytes for Y plane.</param>
|
||||
/// <param name="uvBuffer">Pointer to destination buffer for interleaved UV plane (Width * (Height / 2) bytes).</param>
|
||||
/// <param name="uvBuffer">Pointer to destination buffer for interleaved UV plane (((Width + 1) / 2) * 2 * ((Height + 1) / 2) bytes).</param>
|
||||
/// <param name="uvStride">Row stride in bytes for UV plane.</param>
|
||||
/// <param name="timestamp">Timestamp of the output frame in seconds.</param>
|
||||
/// <returns>True if a frame was successfully retrieved; false on EOF.</returns>
|
||||
@@ -1178,7 +1178,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
int w = Width;
|
||||
int h = Height;
|
||||
int uvH = (h + 1) / 2;
|
||||
int uvRowBytes = (w / 2) * 2;
|
||||
int uvRowBytes = ((w + 1) / 2) * 2;
|
||||
|
||||
// Copy Y plane (row by row to safely skip linesize GPU padding)
|
||||
for (int y = 0; y < h; y++)
|
||||
@@ -1239,7 +1239,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
||||
int w = Width;
|
||||
int h = Height;
|
||||
int uvH = (h + 1) / 2;
|
||||
int uvRowBytes = (w / 2) * 2;
|
||||
int uvRowBytes = ((w + 1) / 2) * 2;
|
||||
|
||||
if (yBuffer.Length < (long)w * h || uvBuffer.Length < (long)uvRowBytes * uvH)
|
||||
{
|
||||
|
||||
@@ -201,6 +201,12 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
/// </summary>
|
||||
private AVPixelFormat _dstPixFmt = AVPixelFormat.AV_PIX_FMT_YUV420P;
|
||||
|
||||
/// <summary>
|
||||
/// Color range of the encoder input and output (codec context color_range of the selected encoder).
|
||||
/// Full range (JPEG) for every encoder except libvvenc, limited range (MPEG) for libvvenc.
|
||||
/// </summary>
|
||||
private AVColorRange _colorRange = AVColorRange.AVCOL_RANGE_JPEG;
|
||||
|
||||
/// <summary>Pixel format of the finally selected and running encoder.</summary>
|
||||
public AVPixelFormat SelectedPixelFormat => _dstPixFmt;
|
||||
|
||||
@@ -255,6 +261,9 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
/// <summary>Whether audio encoding is enabled.</summary>
|
||||
private bool _audioEnabled;
|
||||
|
||||
/// <summary>Bytes of one input sample of every channel (interleaved input format × channel count).</summary>
|
||||
private int _audioInputBytesPerSample;
|
||||
|
||||
/// <summary>Encoding settings passed at construction time (immutable).</summary>
|
||||
private readonly EncoderSettings _settings;
|
||||
|
||||
@@ -267,26 +276,6 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
// Utility Methods
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Converts the application's Codec enum value to the AVCodecID used internally by FFmpeg.
|
||||
/// AVCodecID identifies "which codec it is (e.g., H.264, AV1)",
|
||||
/// while the actual encoder implementation (e.g., libx264, h264_nvenc) is selected separately.
|
||||
/// </summary>
|
||||
// ReSharper disable once InconsistentNaming
|
||||
// ReSharper disable once UnusedMember.Local
|
||||
private AVCodecID GetAVCodecID()
|
||||
{
|
||||
return _settings.Codec switch
|
||||
{
|
||||
Codec.H264 => AVCodecID.AV_CODEC_ID_H264,
|
||||
Codec.H265 => AVCodecID.AV_CODEC_ID_HEVC,
|
||||
Codec.AV1 => AVCodecID.AV_CODEC_ID_AV1,
|
||||
Codec.VP9 => AVCodecID.AV_CODEC_ID_VP9,
|
||||
Codec.VVC => AVCodecID.AV_CODEC_ID_VVC,
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of candidate encoders to try for the selected codec in priority order.
|
||||
///
|
||||
@@ -416,7 +405,19 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
// ── Basic video parameters configuration ──
|
||||
_pCodecContext->width = _settings.Width;
|
||||
_pCodecContext->height = _settings.Height;
|
||||
_pCodecContext->color_range = AVColorRange.AVCOL_RANGE_JPEG;
|
||||
|
||||
// ── Output color tags ──
|
||||
// Matrix: BT.709, the RGB→YUV matrix of both conversion paths
|
||||
// (GPU: RGB2YUV_BT709_Full in ColorConversion.compute, CPU: ConfigureSwsColorspace).
|
||||
// Players use this tag to convert the YUV frames back to RGB.
|
||||
// Range: full (0-255) for every encoder except libvvenc, limited (16-235) for libvvenc.
|
||||
// The FFmpeg libvvenc wrapper passes the matrix/primaries/transfer tags to VVenC but not the range,
|
||||
// so a VVC stream is read as limited range; its input is converted to limited range to match.
|
||||
// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavcodec/libvvenc.c (vvenc_set_color_format)
|
||||
_pCodecContext->color_range = encoder == VideoEncoder.LibVvenc
|
||||
? AVColorRange.AVCOL_RANGE_MPEG
|
||||
: AVColorRange.AVCOL_RANGE_JPEG;
|
||||
_pCodecContext->colorspace = AVColorSpace.AVCOL_SPC_BT709;
|
||||
// thread_count = 0: FFmpeg automatically allocates threads according to the system CPU core count.
|
||||
// For libvvenc, the FFmpeg wrapper passes thread_count as the VVenC internal thread count,
|
||||
// so setting 0 (auto) is required for parallel encoding utilizing all cores.
|
||||
@@ -690,7 +691,11 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
|
||||
_pHwFrame = ffmpeg.av_frame_alloc();
|
||||
if (_pHwFrame == null)
|
||||
{
|
||||
// The codec context (and the frames context it holds) is released by Dispose in the catch block of Start.
|
||||
ffmpeg.av_buffer_unref(&hwDeviceCtx);
|
||||
throw new Exception("Failed to allocate QSV hardware frame structure.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -769,6 +774,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
var accelStatus = isHardware ? "Hardware Acceleration Enabled" : "Software CPU Usage";
|
||||
Melon<RenderMod>.Logger.Msg($"Video encoder successfully initialized: {encoder.Name} ({accelStatus})");
|
||||
_dstPixFmt = selectedPixFmt;
|
||||
_colorRange = _pCodecContext->color_range;
|
||||
openedSuccessfully = true;
|
||||
break;
|
||||
}
|
||||
@@ -797,8 +803,9 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
// Copy parameters set in codec context (resolution, codec ID, profile, etc.)
|
||||
// to stream's codecpar. Information to be written to container header.
|
||||
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__core.html
|
||||
if (ffmpeg.avcodec_parameters_from_context(_pVideoStream->codecpar, _pCodecContext) < 0)
|
||||
throw new Exception("Failed to copy codec parameters to stream.");
|
||||
var paramResult = ffmpeg.avcodec_parameters_from_context(_pVideoStream->codecpar, _pCodecContext);
|
||||
if (paramResult < 0)
|
||||
throw new Exception($"Failed to copy codec parameters to stream — {GetFFmpegErrorString(paramResult)} (Code: {paramResult})");
|
||||
|
||||
// Stream time base: unit of the timestamps stored in the container.
|
||||
// Set to the codec time base (1 / framerate) so that every frame interval is a whole number of ticks.
|
||||
@@ -823,15 +830,17 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__io.html
|
||||
if ((_pFormatContext->oformat->flags & ffmpeg.AVFMT_NOFILE) == 0)
|
||||
{
|
||||
if (ffmpeg.avio_open(&_pFormatContext->pb, outputPath, ffmpeg.AVIO_FLAG_WRITE) < 0)
|
||||
throw new Exception($"Cannot open output file {outputPath}.");
|
||||
var avioResult = ffmpeg.avio_open(&_pFormatContext->pb, outputPath, ffmpeg.AVIO_FLAG_WRITE);
|
||||
if (avioResult < 0)
|
||||
throw new Exception($"Cannot open output file {outputPath} — {GetFFmpegErrorString(avioResult)} (Code: {avioResult})");
|
||||
}
|
||||
|
||||
// Write file header (ftyp, moov, container metadata).
|
||||
// Packets can be written with av_interleaved_write_frame after this point.
|
||||
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__encoding.html
|
||||
if (ffmpeg.avformat_write_header(_pFormatContext, null) < 0)
|
||||
throw new Exception("Failed to write file header.");
|
||||
var headerResult = ffmpeg.avformat_write_header(_pFormatContext, null);
|
||||
if (headerResult < 0)
|
||||
throw new Exception($"Failed to write file header — {GetFFmpegErrorString(headerResult)} (Code: {headerResult})");
|
||||
|
||||
// ── Step 6: Initialize frame buffers and colorspace converter ──
|
||||
// av_frame_alloc: Allocates AVFrame structure on heap (no pixel data yet).
|
||||
@@ -865,6 +874,8 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
if (_pSwsContext == null)
|
||||
throw new Exception("Failed to initialize software scaler.");
|
||||
|
||||
ConfigureSwsColorspace();
|
||||
|
||||
_isInitialized = true;
|
||||
_loggedConversionMode = false;
|
||||
}
|
||||
@@ -1160,10 +1171,42 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed || !_isInitialized) return;
|
||||
ValidateInputLength(buffer.Length, isColorConverted: false);
|
||||
WriteFrameFromBuffer((byte*)buffer.GetUnsafeReadOnlyPtr());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Number of bytes read from the input buffer of one frame.
|
||||
/// RGBA input (isColorConverted = false): Width × Height × 4
|
||||
/// Pre-converted NV12: Width × Height (Y) + Width × (Height / 2) (interleaved UV)
|
||||
/// Pre-converted YUV420P: Width × Height (Y) + 2 × (Width / 2) × (Height / 2) (U, V)
|
||||
/// Returns 0 for a pre-converted input of another format (WritePreConvertedFrame rejects it).
|
||||
/// </summary>
|
||||
private long GetRequiredInputLength(bool isColorConverted)
|
||||
{
|
||||
long w = _settings.Width, h = _settings.Height;
|
||||
if (!isColorConverted) return w * h * 4;
|
||||
|
||||
return _dstPixFmt switch
|
||||
{
|
||||
AVPixelFormat.AV_PIX_FMT_NV12 => w * h + w * (h / 2),
|
||||
AVPixelFormat.AV_PIX_FMT_YUV420P => w * h + 2 * (w / 2) * (h / 2),
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws an ArgumentException when the input buffer of a frame is smaller than the bytes read from it
|
||||
/// (GetRequiredInputLength), so that the copy does not read outside the buffer.
|
||||
/// </summary>
|
||||
private void ValidateInputLength(long length, bool isColorConverted)
|
||||
{
|
||||
var required = GetRequiredInputLength(isColorConverted);
|
||||
if (length < required)
|
||||
throw new ArgumentException($"Frame buffer is too small: {length} bytes, {required} bytes required for {_settings.Width}x{_settings.Height} {(isColorConverted ? _dstPixFmt.ToString() : "RGBA")} input.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a frame from IntPtr (native pointer).
|
||||
/// Can be used from external native plugins or custom capture sources.
|
||||
@@ -1182,6 +1225,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
: "[Encoder] CPU color conversion fallback enabled (RGBA Input, using sws_scale)");
|
||||
}
|
||||
|
||||
ValidateInputLength(length, isColorConverted);
|
||||
if (isColorConverted) WritePreConvertedFrame((byte*)pointer.ToPointer());
|
||||
else WriteFrameFromBuffer((byte*)pointer.ToPointer());
|
||||
}
|
||||
@@ -1196,6 +1240,10 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed || !_isInitialized) return;
|
||||
if (length > buffer.Length)
|
||||
throw new ArgumentException($"Frame length ({length} bytes) exceeds the buffer size ({buffer.Length} bytes).");
|
||||
ValidateInputLength(length, isColorConverted);
|
||||
|
||||
fixed (byte* pBuffer = buffer)
|
||||
{
|
||||
if (!_loggedConversionMode)
|
||||
@@ -1213,7 +1261,51 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the format, size and color range of the destination frame (_pDstFrame) and allocates a new pixel buffer for it.
|
||||
/// Sets the RGB→YUV conversion of the CPU conversion path (_pSwsContext) to the BT.709 matrix
|
||||
/// and the color range of the encoder (_colorRange), so that it produces the same colors as the GPU path
|
||||
/// (RGB2YUV_BT709_Full in ColorConversion.compute) and as the tags of the output stream.
|
||||
/// A new libswscale context uses the BT.601 matrix (SWS_CS_DEFAULT = SWS_CS_ITU601) and the limited range.
|
||||
///
|
||||
/// For RGB input, "table" holds the coefficients of the output YUV space and dstRange its range (1 = full, 0 = limited).
|
||||
/// The source (RGB) values, brightness, contrast and saturation keep the values of the context.
|
||||
///
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html (sws_setColorspaceDetails, sws_getCoefficients)
|
||||
/// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libswscale/swscale.h (SWS_CS_ITU709, SWS_CS_DEFAULT)
|
||||
/// </summary>
|
||||
private void ConfigureSwsColorspace()
|
||||
{
|
||||
int* currentInvTable;
|
||||
int* currentTable;
|
||||
int srcRange, dstRange, brightness, contrast, saturation;
|
||||
|
||||
var ret = ffmpeg.sws_getColorspaceDetails(_pSwsContext, ¤tInvTable, &srcRange, ¤tTable, &dstRange,
|
||||
&brightness, &contrast, &saturation);
|
||||
if (ret < 0)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Warning($"[Encoder] Cannot read the colorspace details of the software scaler: {GetFFmpegErrorString(ret)} (Code: {ret}). The CPU conversion path keeps the libswscale defaults.");
|
||||
return;
|
||||
}
|
||||
|
||||
var coefficients = ffmpeg.sws_getCoefficients(ffmpeg.SWS_CS_ITU709);
|
||||
var invTable = new int_array4();
|
||||
var table = new int_array4();
|
||||
for (uint i = 0; i < 4; i++)
|
||||
{
|
||||
invTable[i] = currentInvTable[i];
|
||||
table[i] = coefficients[i];
|
||||
}
|
||||
|
||||
var fullRange = _colorRange == AVColorRange.AVCOL_RANGE_JPEG;
|
||||
ret = ffmpeg.sws_setColorspaceDetails(_pSwsContext, invTable, srcRange, table, fullRange ? 1 : 0,
|
||||
brightness, contrast, saturation);
|
||||
if (ret < 0)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Warning($"[Encoder] Cannot set the colorspace details of the software scaler: {GetFFmpegErrorString(ret)} (Code: {ret}). The CPU conversion path keeps the libswscale defaults.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the format, size, color range and matrix of the destination frame (_pDstFrame) and allocates a new pixel buffer for it.
|
||||
/// av_frame_get_buffer allocates memory matching format, width and height and sets the data[]/linesize[] pointers.
|
||||
/// Second argument 0 means default alignment.
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html (av_frame_get_buffer)
|
||||
@@ -1223,7 +1315,8 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
_pDstFrame->format = (int)_dstPixFmt;
|
||||
_pDstFrame->width = _settings.Width;
|
||||
_pDstFrame->height = _settings.Height;
|
||||
_pDstFrame->color_range = AVColorRange.AVCOL_RANGE_JPEG;
|
||||
_pDstFrame->color_range = _colorRange;
|
||||
_pDstFrame->colorspace = AVColorSpace.AVCOL_SPC_BT709;
|
||||
|
||||
var ret = ffmpeg.av_frame_get_buffer(_pDstFrame, 0);
|
||||
if (ret < 0)
|
||||
@@ -1262,7 +1355,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
|
||||
switch (_dstPixFmt)
|
||||
{
|
||||
case AVPixelFormat.AV_PIX_FMT_NV12 or AVPixelFormat.AV_PIX_FMT_QSV:
|
||||
case AVPixelFormat.AV_PIX_FMT_NV12:
|
||||
{
|
||||
// Copy Y Plane
|
||||
var pSrcY = buffer;
|
||||
@@ -1485,7 +1578,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
var response = ffmpeg.avcodec_send_frame(_pCodecContext, pFrame);
|
||||
if (response < 0)
|
||||
{
|
||||
throw new Exception($"Error occurred while sending frame to encoder. (Code: {response})");
|
||||
throw new Exception($"Error occurred while sending frame to encoder: {GetFFmpegErrorString(response)} (Code: {response})");
|
||||
}
|
||||
|
||||
// Pop all encoded packets and write to file.
|
||||
@@ -1499,7 +1592,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
// EOF: Encoder flush complete (all delayed frames outputted) → exit loop
|
||||
if (response == ffmpeg.AVERROR(ffmpeg.EAGAIN) || response == ffmpeg.AVERROR_EOF) break;
|
||||
if (response < 0)
|
||||
throw new Exception($"Error occurred while receiving packet from encoder. (Code: {response})");
|
||||
throw new Exception($"Error occurred while receiving packet from encoder: {GetFFmpegErrorString(response)} (Code: {response})");
|
||||
|
||||
// Convert PTS/DTS from codec timebase → stream timebase
|
||||
ffmpeg.av_packet_rescale_ts(_pPacket, _pCodecContext->time_base, _pVideoStream->time_base);
|
||||
@@ -1510,7 +1603,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
// and written in time order. This function is used even when only video is present.
|
||||
response = ffmpeg.av_interleaved_write_frame(_pFormatContext, _pPacket);
|
||||
if (response < 0)
|
||||
throw new Exception($"Error occurred while writing packet to file. (Code: {response})");
|
||||
throw new Exception($"Error occurred while writing packet to file: {GetFFmpegErrorString(response)} (Code: {response})");
|
||||
|
||||
// Unref packet data reference for reuse in next receive_packet.
|
||||
ffmpeg.av_packet_unref(_pPacket);
|
||||
@@ -1775,6 +1868,10 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
? audioSettings.InputSampleFormat
|
||||
: AVSampleFormat.AV_SAMPLE_FMT_FLT;
|
||||
|
||||
// Bytes of one sample of every channel in the interleaved input (e.g. FLT stereo: 4 × 2 = 8 bytes).
|
||||
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__sampfmts.html (av_get_bytes_per_sample)
|
||||
_audioInputBytesPerSample = ffmpeg.av_get_bytes_per_sample(inputSampleFmt) * audioSettings.Channels;
|
||||
|
||||
// Allocate SwrContext
|
||||
fixed (SwrContext** ppSwr = &_pSwrContext)
|
||||
{
|
||||
@@ -1838,6 +1935,8 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed || !_isInitialized || !_audioEnabled) return;
|
||||
if (numSamples < 0 || (long)numSamples * _audioInputBytesPerSample > (long)samples.Length * sizeof(float))
|
||||
throw new ArgumentException($"Audio sample count ({numSamples} per channel) exceeds the buffer size ({samples.Length} floats).");
|
||||
|
||||
fixed (float* pSamples = samples)
|
||||
{
|
||||
@@ -1872,6 +1971,19 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
/// swr_convert: https://ffmpeg.org/doxygen/trunk/group__lswr.html
|
||||
/// av_audio_fifo: https://ffmpeg.org/doxygen/trunk/group__lavu__audiofifo.html
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Makes the buffer of the reusable audio frame (_pAudioFrame) writable before samples are read into it.
|
||||
/// When the audio encoder still references the buffer, av_frame_make_writable allocates a new buffer.
|
||||
/// Throws an exception when the buffer cannot be allocated.
|
||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html (av_frame_make_writable)
|
||||
/// </summary>
|
||||
private void MakeAudioFrameWritable()
|
||||
{
|
||||
var ret = ffmpeg.av_frame_make_writable(_pAudioFrame);
|
||||
if (ret < 0)
|
||||
throw new Exception($"Failed to make the audio frame writable: {GetFFmpegErrorString(ret)} (Code: {ret})");
|
||||
}
|
||||
|
||||
private void WriteAudioSamplesInternal(byte* inputBuffer, int numSamples)
|
||||
{
|
||||
// ── 1. Resampling: Input format → Encoder format ──
|
||||
@@ -1910,7 +2022,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
var frameSize = _pAudioCodecContext->frame_size > 0 ? _pAudioCodecContext->frame_size : 1024;
|
||||
while (ffmpeg.av_audio_fifo_size(_pAudioFifo) >= frameSize)
|
||||
{
|
||||
ffmpeg.av_frame_make_writable(_pAudioFrame);
|
||||
MakeAudioFrameWritable();
|
||||
_pAudioFrame->nb_samples = frameSize;
|
||||
|
||||
var read = ffmpeg.av_audio_fifo_read(_pAudioFifo, (void**)&_pAudioFrame->data, frameSize);
|
||||
@@ -1989,7 +2101,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
|
||||
var remaining = ffmpeg.av_audio_fifo_size(_pAudioFifo);
|
||||
if (remaining > 0)
|
||||
{
|
||||
ffmpeg.av_frame_make_writable(_pAudioFrame);
|
||||
MakeAudioFrameWritable();
|
||||
_pAudioFrame->nb_samples = remaining;
|
||||
|
||||
var read = ffmpeg.av_audio_fifo_read(_pAudioFifo, (void**)&_pAudioFrame->data, remaining);
|
||||
|
||||
+42
-5
@@ -73,6 +73,16 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bytes of the readback buffer of one frame: Width x Height x 3 / 2 when the GPU conversion to NV12 / YUV420P
|
||||
/// is used, Width x Height x 4 (RGBA) otherwise. The same size as ReadbackRequestState.Setup uses.
|
||||
/// </summary>
|
||||
public long GetFrameBufferBytes(int width, int height)
|
||||
{
|
||||
var pixels = (long)width * height;
|
||||
return _useGPUConversion ? pixels * 3 / 2 : pixels * 4;
|
||||
}
|
||||
|
||||
public void RequestFrameCapture(Texture texture, long currentFrame)
|
||||
{
|
||||
if (!RenderManager.IsCapturingActive) return;
|
||||
@@ -154,14 +164,11 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
Melon<RenderMod>.Logger.Warning($"[OOM Prevent] GPU Readback failed for frame {frame}. Queue deadlock prevented by sending a black frame.");
|
||||
|
||||
var byteSize = state.Width * state.Height * 3 / 2;
|
||||
var blackFrame = ArrayPool<byte>.Shared.Rent(byteSize);
|
||||
Array.Clear(blackFrame, 0, byteSize);
|
||||
|
||||
var blackFrame = CreateBlackFrame(state, out var byteSize);
|
||||
var capFrame = new RenderManager.CapturedFrame
|
||||
{
|
||||
ManagedBuffer = blackFrame, Length = byteSize,
|
||||
IsColorConverted = true
|
||||
IsColorConverted = state.IsGPUConversion
|
||||
};
|
||||
PushToQueue(frame, capFrame);
|
||||
RecycleState(state);
|
||||
@@ -190,6 +197,36 @@ namespace ADOFAIRenderer.Util
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a black frame in the same format as the frames of this reader, from ArrayPool<byte>.Shared
|
||||
/// (returned to the pool by the render thread after encoding).
|
||||
///
|
||||
/// GPU conversion (NV12 / YUV420P, full range): Width × Height × 3 / 2 bytes.
|
||||
/// Y plane (Width × Height bytes) = 0, U/V data (the rest) = 128.
|
||||
/// 128 is the zero chroma value of the 8-bit full range: the U/V offset 0.5 of RGB2YUV_BT709_Full
|
||||
/// in ColorConversion.compute, which the encoder input of this path uses.
|
||||
/// Both NV12 (interleaved UV) and YUV420P (U plane, then V plane) store all chroma after the Y plane,
|
||||
/// so the same fill applies to both.
|
||||
/// No GPU conversion (RGBA input, converted by the encoder): Width × Height × 4 bytes, all 0.
|
||||
/// </summary>
|
||||
private static byte[] CreateBlackFrame(ReadbackRequestState state, out int byteSize)
|
||||
{
|
||||
var lumaSize = state.Width * state.Height;
|
||||
byteSize = state.IsGPUConversion ? lumaSize * 3 / 2 : lumaSize * 4;
|
||||
|
||||
var frame = ArrayPool<byte>.Shared.Rent(byteSize);
|
||||
if (state.IsGPUConversion)
|
||||
{
|
||||
Array.Clear(frame, 0, lumaSize);
|
||||
frame.AsSpan(lumaSize, byteSize - lumaSize).Fill(128);
|
||||
}
|
||||
else
|
||||
{
|
||||
Array.Clear(frame, 0, byteSize);
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
internal void RecycleState(ReadbackRequestState state)
|
||||
{
|
||||
lock (_poolLock)
|
||||
|
||||
@@ -97,7 +97,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
||||
_kernelIndex = _computeShader.FindKernel("CSMain_NV12ToRGBA");
|
||||
|
||||
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
|
||||
_textureUV = new Texture2D(w / 2, h / 2, TextureFormat.RG16, false);
|
||||
_textureUV = new Texture2D((w + 1) / 2, (h + 1) / 2, TextureFormat.RG16, false);
|
||||
ConfigureTexture(_textureY);
|
||||
ConfigureTexture(_textureUV);
|
||||
|
||||
@@ -118,8 +118,8 @@ internal class VideoBGPlayer : MonoBehaviour
|
||||
_kernelIndex = _computeShader.FindKernel("CSMain_YUV420PToRGBA");
|
||||
|
||||
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
|
||||
_textureU = new Texture2D(w / 2, h / 2, TextureFormat.R8, false);
|
||||
_textureV = new Texture2D(w / 2, h / 2, TextureFormat.R8, false);
|
||||
_textureU = new Texture2D((w + 1) / 2, (h + 1) / 2, TextureFormat.R8, false);
|
||||
_textureV = new Texture2D((w + 1) / 2, (h + 1) / 2, TextureFormat.R8, false);
|
||||
ConfigureTexture(_textureY);
|
||||
ConfigureTexture(_textureU);
|
||||
ConfigureTexture(_textureV);
|
||||
|
||||
Reference in new issue
Block a user