SHA256
Compare commits
58
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
011c749df4
|
||
|
|
b20251659c | ||
|
|
985a2d241e
|
||
|
|
994bfde46d
|
||
|
|
c72b92d59f
|
||
|
|
92f9912b9d
|
||
|
|
369f6f9a0d
|
||
|
|
520d18846b
|
||
|
|
22bd38d008
|
||
|
|
27e7327c1e
|
||
|
|
2152625e45
|
||
|
|
a2e6c49d7a
|
||
|
|
aefa1409c1
|
||
|
|
e802b6a08a
|
||
|
|
8b9c009804
|
||
|
|
c8050abbc9
|
||
|
|
45a7637a36
|
||
|
|
7ba8944fbe
|
||
|
|
154c35219f
|
||
|
|
fd98d353d0
|
||
|
|
25646c3fa4
|
||
|
|
7b2951e649
|
||
|
|
6955762641
|
||
|
|
6ef1538c3f
|
||
|
|
61373f8271
|
||
|
|
8addbfb79d
|
||
|
|
e9b23b981f
|
||
|
|
a472c6f72e
|
||
|
|
ea8d4c91ac
|
||
|
|
5080e7ea1d
|
||
|
|
4e556a4632
|
||
|
|
f1e926f758 | ||
|
|
c62723cf5c
|
||
|
|
fee7077b16
|
||
|
|
9fbf057677
|
||
|
|
f153bf70aa
|
||
|
|
93539307b1
|
||
|
|
c0e902fc1a
|
||
|
|
dd2585ee9f
|
||
|
|
1a672cbf59
|
||
|
|
6d5880fe19
|
||
|
|
72d8d71928
|
||
|
|
413778e165
|
||
|
|
36552511f5
|
||
|
|
a1f9fa36ce
|
||
|
|
00971a6730
|
||
|
|
bd24152a2e
|
||
|
|
7aa8ae4b54
|
||
|
|
dee3b2dbcf
|
||
|
|
7eec3b4660
|
||
|
|
ce70d89cdb
|
||
|
|
7db1905906
|
||
|
|
bfc79ee4d3
|
||
|
|
b4c950c9f7
|
||
|
|
723f4446f5
|
||
|
|
6f4c632b41
|
||
|
|
863dcd2357
|
||
|
|
00048b7b06
|
No files matched your search
+9
-3
@@ -32,9 +32,11 @@ Thumbs.db
|
|||||||
desktop.ini
|
desktop.ini
|
||||||
|
|
||||||
## Agent Metadata
|
## Agent Metadata
|
||||||
.antigravitycli/
|
/.antigravitycli/
|
||||||
.agent/
|
/.agent/
|
||||||
GEMINI.md
|
/GEMINI.md
|
||||||
|
/.mcp.json
|
||||||
|
/CLAUDE.md
|
||||||
|
|
||||||
# Decompiled Game Code
|
# Decompiled Game Code
|
||||||
*.cs.txt
|
*.cs.txt
|
||||||
@@ -43,3 +45,7 @@ GEMINI.md
|
|||||||
/AsyncInputAutoplayFix.md
|
/AsyncInputAutoplayFix.md
|
||||||
/AudioMixerExplanation.md
|
/AudioMixerExplanation.md
|
||||||
/O5Theme.md
|
/O5Theme.md
|
||||||
|
|
||||||
|
# Developer Utility Files
|
||||||
|
/REVIEW_CHECKLIST.md
|
||||||
|
/REVIEW_FFMPEG.md
|
||||||
@@ -520,7 +520,9 @@ public class RenderSession
|
|||||||
}
|
}
|
||||||
|
|
||||||
RenderManager.Instance.levelPath = levelPath;
|
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;
|
RenderManager.Instance.stage = RenderStage.Load;
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +1,4 @@
|
|||||||
|
using ADOFAIRenderer.Localization;
|
||||||
using ADOFAIRenderer.UI;
|
using ADOFAIRenderer.UI;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
@@ -45,18 +46,18 @@ internal class FirstUpdateHook : MonoBehaviour
|
|||||||
var currentVersion = driverVer.Value.ToString("F2");
|
var currentVersion = driverVer.Value.ToString("F2");
|
||||||
|
|
||||||
CommonWarningDialogUI.ShowDialog(
|
CommonWarningDialogUI.ShowDialog(
|
||||||
"NVIDIA Graphics Driver Update Recommended",
|
LocalizationManager.Get("ui.driver_warning.title"),
|
||||||
$"Currently detected NVIDIA graphics driver version: <b><color=#ff5555>{currentVersion}</color></b>\n\nDriver version <b><color=#55ff55>610.00 or higher</color></b> is required to render video using hardware acceleration (NVENC).\n\nIf the driver version is lower, encoding may fail or fallback to software (CPU) encoding, significantly slowing down rendering. Please update your graphics driver.",
|
LocalizationManager.Get("ui.driver_warning.body", currentVersion),
|
||||||
new DialogButton("Update Driver", () =>
|
new DialogButton(LocalizationManager.Get("ui.driver_warning.btn_update"), () =>
|
||||||
{
|
{
|
||||||
Application.OpenURL("https://www.nvidia.com/Download/index.aspx");
|
Application.OpenURL("https://www.nvidia.com/Download/index.aspx");
|
||||||
Application.Quit();
|
Application.Quit();
|
||||||
}, new Color(0.0f, 0.8f, 1.0f, 1.0f)),
|
}, new Color(0.0f, 0.8f, 1.0f, 1.0f)),
|
||||||
new DialogButton("Disable Feature", () =>
|
new DialogButton(LocalizationManager.Get("ui.driver_warning.btn_disable"), () =>
|
||||||
{
|
{
|
||||||
RenderMod.IsFeatureDisabled = true;
|
RenderMod.IsFeatureDisabled = true;
|
||||||
}),
|
}),
|
||||||
new DialogButton("Continue Anyway", () =>
|
new DialogButton(LocalizationManager.Get("ui.driver_warning.btn_continue"), () =>
|
||||||
{
|
{
|
||||||
// ignored
|
// ignored
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -116,7 +116,11 @@ public static class PresetManager
|
|||||||
return Path.Combine(root, "UserData", "ADOFAIRenderer", "Presets");
|
return Path.Combine(root, "UserData", "ADOFAIRenderer", "Presets");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetSafeFileName(string name)
|
/// <summary>
|
||||||
|
/// File name (without extension) of the preset file for the given preset name: every character that is invalid
|
||||||
|
/// in a file name is replaced with '_'. Different preset names can map to the same file name (e.g. "a/b" and "a_b").
|
||||||
|
/// </summary>
|
||||||
|
internal static string GetSafeFileName(string name)
|
||||||
{
|
{
|
||||||
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
|
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
|
||||||
}
|
}
|
||||||
|
|||||||
+363
-96
@@ -5,15 +5,16 @@ using System.IO;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Buffers;
|
using System.Buffers;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
|
||||||
using ADOFAIRenderer.API;
|
using ADOFAIRenderer.API;
|
||||||
using ADOFAIRenderer.Core.Data;
|
using ADOFAIRenderer.Core.Data;
|
||||||
using ADOFAIRenderer.Data;
|
using ADOFAIRenderer.Data;
|
||||||
|
using ADOFAIRenderer.Patch.Engine;
|
||||||
using ADOFAIRenderer.UI;
|
using ADOFAIRenderer.UI;
|
||||||
using ADOFAIRenderer.UI.Window;
|
using ADOFAIRenderer.UI.Window;
|
||||||
using ADOFAIRenderer.Util;
|
using ADOFAIRenderer.Util;
|
||||||
using ADOFAIRenderer.Util.Audio;
|
using ADOFAIRenderer.Util.Audio;
|
||||||
using ADOFAIRenderer.Util.FFmpeg;
|
using ADOFAIRenderer.Util.FFmpeg;
|
||||||
|
using ADOFAIRenderer.Util.OS;
|
||||||
using FFmpeg.AutoGen;
|
using FFmpeg.AutoGen;
|
||||||
using HarmonyLib;
|
using HarmonyLib;
|
||||||
using MelonLoader;
|
using MelonLoader;
|
||||||
@@ -80,8 +81,28 @@ internal class RenderManager : MonoBehaviour
|
|||||||
public static bool IsActive => Instance && Instance.stage >= RenderStage.OnScene;
|
public static bool IsActive => Instance && Instance.stage >= RenderStage.OnScene;
|
||||||
public static bool IsCapturingActive => Instance && Instance.stage >= RenderStage.Render;
|
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;
|
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
|
// Statistics
|
||||||
public long framesSent;
|
public long framesSent;
|
||||||
private readonly Stopwatch _renderStopwatch = new();
|
private readonly Stopwatch _renderStopwatch = new();
|
||||||
@@ -91,7 +112,50 @@ internal class RenderManager : MonoBehaviour
|
|||||||
public long frameCount;
|
public long frameCount;
|
||||||
public bool skipPressToStart;
|
public bool skipPressToStart;
|
||||||
public string levelPath = "";
|
public string levelPath = "";
|
||||||
public bool IsRenderCancelled { get; private set; }
|
private readonly object _abortStateLock = new();
|
||||||
|
private volatile bool _isRenderCancelled;
|
||||||
|
private volatile bool _renderFailed;
|
||||||
|
private volatile string? _failureReason;
|
||||||
|
|
||||||
|
// ReSharper disable once MemberCanBePrivate.Global
|
||||||
|
public bool IsRenderCancelled => _isRenderCancelled;
|
||||||
|
public bool IsRenderFailed => _renderFailed;
|
||||||
|
|
||||||
|
// Cancelled and failed are mutually exclusive; this is true for either (render must stop).
|
||||||
|
public bool IsRenderAborted => _isRenderCancelled || _renderFailed;
|
||||||
|
|
||||||
|
private void MarkCancelled()
|
||||||
|
{
|
||||||
|
lock (_abortStateLock)
|
||||||
|
{
|
||||||
|
if (!_renderFailed) _isRenderCancelled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first failure reason wins; ignored once the render is already cancelled or failed.
|
||||||
|
private void MarkFailed(string reason)
|
||||||
|
{
|
||||||
|
lock (_abortStateLock)
|
||||||
|
{
|
||||||
|
if (_isRenderCancelled || _renderFailed) return;
|
||||||
|
_failureReason = reason;
|
||||||
|
_renderFailed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearAbortState()
|
||||||
|
{
|
||||||
|
lock (_abortStateLock)
|
||||||
|
{
|
||||||
|
_isRenderCancelled = false;
|
||||||
|
_renderFailed = false;
|
||||||
|
_failureReason = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildAbortMessage() => IsRenderFailed
|
||||||
|
? $"Rendering failed: {_failureReason ?? "unknown error"}"
|
||||||
|
: "Rendering was cancelled by user.";
|
||||||
public RenderConfig Configuration = null!;
|
public RenderConfig Configuration = null!;
|
||||||
|
|
||||||
// Render Sessions State (OOP Instance Fields)
|
// Render Sessions State (OOP Instance Fields)
|
||||||
@@ -102,7 +166,10 @@ internal class RenderManager : MonoBehaviour
|
|||||||
public int frameCountBase;
|
public int frameCountBase;
|
||||||
|
|
||||||
public OfflineAudioMixer? AudioMixer { get; private set; }
|
public OfflineAudioMixer? AudioMixer { get; private set; }
|
||||||
private AudioSyncCalculator SyncCalculator { get; set; } = null!;
|
|
||||||
|
/// <summary>Render time of the current frame in seconds (frameCount / RenderFPS), the time base of audio commands.</summary>
|
||||||
|
public double AudioTime => frameCount / Configuration.RenderFPS;
|
||||||
|
private AudioSyncCalculator? SyncCalculator { get; set; }
|
||||||
|
|
||||||
private FrameReader? _frameReader;
|
private FrameReader? _frameReader;
|
||||||
private FrameHookPipeline? _frameHookPipeline;
|
private FrameHookPipeline? _frameHookPipeline;
|
||||||
@@ -151,6 +218,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
|
|
||||||
public long frameCountForVideo;
|
public long frameCountForVideo;
|
||||||
public long lastVideoFrameIndex = -1;
|
public long lastVideoFrameIndex = -1;
|
||||||
|
// long cannot be volatile: access via Volatile.Read/Write (written on main thread, read on render thread)
|
||||||
public long lastRequestedVideoFrameIndex = -1;
|
public long lastRequestedVideoFrameIndex = -1;
|
||||||
public int readbackFailureCount;
|
public int readbackFailureCount;
|
||||||
public int throttleCount;
|
public int throttleCount;
|
||||||
@@ -168,10 +236,12 @@ internal class RenderManager : MonoBehaviour
|
|||||||
// Thread and Queue State
|
// Thread and Queue State
|
||||||
internal SortedList<long, CapturedFrame> ImageQueue = new();
|
internal SortedList<long, CapturedFrame> ImageQueue = new();
|
||||||
internal AutoResetEvent FrameAvailableEvent = new(false);
|
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 Thread? RenderThread;
|
||||||
public long passCount;
|
public long passCount;
|
||||||
public bool endRequested;
|
public volatile bool endRequested;
|
||||||
public bool endSuccessful;
|
public volatile bool endSuccessful;
|
||||||
public long endFrame = -1;
|
public long endFrame = -1;
|
||||||
|
|
||||||
// Private Instance Fields
|
// Private Instance Fields
|
||||||
@@ -181,6 +251,11 @@ internal class RenderManager : MonoBehaviour
|
|||||||
private GameObject? _debugMonitorObj;
|
private GameObject? _debugMonitorObj;
|
||||||
private GameObject? _escapeHandlerObj;
|
private GameObject? _escapeHandlerObj;
|
||||||
internal string? CurrentOutPath;
|
internal string? CurrentOutPath;
|
||||||
|
|
||||||
|
// Output file of the current render that is not finished yet.
|
||||||
|
// Set just before the encoder opens the file, cleared by the render thread after the encoder finished the file (End).
|
||||||
|
// Only this file is deleted when a render is cancelled, fails or the game exits (DeleteIncompleteOutputFile).
|
||||||
|
private string? _incompleteOutputPath;
|
||||||
private bool _renderSetup;
|
private bool _renderSetup;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
@@ -195,15 +270,20 @@ internal class RenderManager : MonoBehaviour
|
|||||||
|
|
||||||
public void ResetRenderState(bool success)
|
public void ResetRenderState(bool success)
|
||||||
{
|
{
|
||||||
IsRenderCancelled = !success;
|
if (success) _isRenderCancelled = false;
|
||||||
|
else MarkCancelled();
|
||||||
stage = RenderStage.None;
|
stage = RenderStage.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CleanUpActiveRender()
|
public void CleanUpActiveRender()
|
||||||
{
|
{
|
||||||
|
if (stage != RenderStage.None)
|
||||||
|
MarkFailed("the game exited or RenderManager was destroyed during rendering.");
|
||||||
ResetRenderState(success: false);
|
ResetRenderState(success: false);
|
||||||
|
|
||||||
AudioSpectrumGenerator.Clear();
|
AudioSpectrumGenerator.Clear();
|
||||||
|
// Cancels the render thread's audio waits, then releases the clip store (decoder thread, readers, chunks).
|
||||||
|
AudioMixer?.Dispose(); AudioMixer = null;
|
||||||
|
|
||||||
ResetFrameHookState();
|
ResetFrameHookState();
|
||||||
|
|
||||||
@@ -238,6 +318,32 @@ internal class RenderManager : MonoBehaviour
|
|||||||
{
|
{
|
||||||
RenderThread.Join(200);
|
RenderThread.Join(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The encoders are disposed above, which closes the output file. An unfinished file of this render is deleted.
|
||||||
|
DeleteIncompleteOutputFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes the output file of the current render when the render did not finish it (_incompleteOutputPath).
|
||||||
|
/// Files of earlier, finished renders are never deleted.
|
||||||
|
/// Returns true when a file was deleted.
|
||||||
|
/// </summary>
|
||||||
|
private bool DeleteIncompleteOutputFile()
|
||||||
|
{
|
||||||
|
var path = Interlocked.Exchange(ref _incompleteOutputPath, null);
|
||||||
|
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
Melon<RenderMod>.Logger.Msg($"[File Cleanup] Deleted incomplete video: {path}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Melon<RenderMod>.Logger.Error($"[File Cleanup] Failed to delete incomplete video: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Update()
|
private void Update()
|
||||||
@@ -283,8 +389,15 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Instance.stage < RenderStage.OnScene) return;
|
if (Instance.stage < RenderStage.OnScene) return;
|
||||||
|
if (stage >= RenderStage.Render && RenderThread is not { IsAlive: true })
|
||||||
|
{
|
||||||
|
MarkFailed("the render thread terminated unexpectedly.");
|
||||||
|
CancelRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2)
|
if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2)
|
||||||
{
|
{
|
||||||
|
MarkFailed("the level failed or the game controller was lost during rendering.");
|
||||||
ResetRenderState(success: false);
|
ResetRenderState(success: false);
|
||||||
StopFFmpeg(false);
|
StopFFmpeg(false);
|
||||||
EndRender();
|
EndRender();
|
||||||
@@ -310,36 +423,99 @@ internal class RenderManager : MonoBehaviour
|
|||||||
frameCount++;
|
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()
|
public void OnLastUpdate()
|
||||||
{
|
{
|
||||||
if (Instance.stage < RenderStage.Load) return;
|
if (Instance.stage < RenderStage.Load) return;
|
||||||
if (!Configuration.AdvancedConfig.NonTile) return;
|
|
||||||
foreach (var floor in scrLevelMaker.instance.listFloors
|
if (Configuration.AdvancedConfig.NonTile)
|
||||||
.Where(floor => floor.holdRenderer && floor.holdRenderer.m_meshRenderer.enabled))
|
|
||||||
{
|
{
|
||||||
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();
|
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
|
// ReSharper disable once Unity.IncorrectMethodSignature
|
||||||
public void OnPostFrame(RenderTexture? texture)
|
public void OnPostFrame(RenderTexture? texture)
|
||||||
{
|
{
|
||||||
if (Instance.stage < RenderStage.Render || !texture) return;
|
if (Instance.stage < RenderStage.Render) return;
|
||||||
|
|
||||||
|
// Audio commands of this frame are queued: update the song voice and let the render thread mix up to the next frame.
|
||||||
|
if (AudioMixer != null)
|
||||||
|
{
|
||||||
|
AudioMixer.UpdateBGM(AudioTime, dspTimeBase);
|
||||||
|
AudioMixer.Commit((frameCount + 1) / Configuration.RenderFPS);
|
||||||
|
EffectSource.DeactivateMappedSources(AudioMixer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!texture) return;
|
||||||
|
|
||||||
// [Backpressure Throttling]
|
// [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;
|
isThrottling = false;
|
||||||
|
var frameLimit = QueueFrameLimit = ComputeQueueFrameLimit(texture!);
|
||||||
|
|
||||||
while (Instance.stage >= RenderStage.Render)
|
while (Instance.stage >= RenderStage.Render)
|
||||||
{
|
{
|
||||||
|
if (RenderThread is not { IsAlive: true })
|
||||||
|
{
|
||||||
|
isThrottling = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
int count;
|
int count;
|
||||||
lock (ImageQueue)
|
lock (ImageQueue)
|
||||||
{
|
{
|
||||||
count = ImageQueue.Count;
|
count = ImageQueue.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count <= MaxQueuedFrames) break;
|
if (count <= frameLimit) break;
|
||||||
|
|
||||||
if (!isThrottling)
|
if (!isThrottling)
|
||||||
{
|
{
|
||||||
@@ -347,8 +523,11 @@ internal class RenderManager : MonoBehaviour
|
|||||||
throttleCount++;
|
throttleCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!FrameDequeuedEvent.WaitOne(ThrottleWaitMilliseconds))
|
||||||
|
{
|
||||||
AsyncGPUReadback.WaitAllRequests();
|
AsyncGPUReadback.WaitAllRequests();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
isThrottling = false;
|
isThrottling = false;
|
||||||
|
|
||||||
var currentFrame = frameCountForVideo++;
|
var currentFrame = frameCountForVideo++;
|
||||||
@@ -362,7 +541,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
if (videoFrameIndex > lastVideoFrameIndex)
|
if (videoFrameIndex > lastVideoFrameIndex)
|
||||||
{
|
{
|
||||||
lastVideoFrameIndex = videoFrameIndex;
|
lastVideoFrameIndex = videoFrameIndex;
|
||||||
lastRequestedVideoFrameIndex = videoFrameIndex;
|
Volatile.Write(ref lastRequestedVideoFrameIndex, videoFrameIndex);
|
||||||
|
|
||||||
var targetTexture = texture;
|
var targetTexture = texture;
|
||||||
if (HasActiveFrameHooks && _frameHookPipeline != null)
|
if (HasActiveFrameHooks && _frameHookPipeline != null)
|
||||||
@@ -403,7 +582,6 @@ internal class RenderManager : MonoBehaviour
|
|||||||
|
|
||||||
Application.wantsToQuit -= quitDelegate;
|
Application.wantsToQuit -= quitDelegate;
|
||||||
RenderConfigWindow.CreateWindow();
|
RenderConfigWindow.CreateWindow();
|
||||||
// ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "Assets/Scenes/RenderSettingsScene.unity");
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +618,9 @@ internal class RenderManager : MonoBehaviour
|
|||||||
yield return new WaitForSecondsRealtime(1);
|
yield return new WaitForSecondsRealtime(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancelled or failed during the countdown: do not start the encoder
|
||||||
|
if (stage != RenderStage.OnScene) yield break;
|
||||||
|
|
||||||
frameCount = 0; framesSent = 0;
|
frameCount = 0; framesSent = 0;
|
||||||
_renderStopwatch.Restart();
|
_renderStopwatch.Restart();
|
||||||
|
|
||||||
@@ -462,13 +643,20 @@ internal class RenderManager : MonoBehaviour
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Melon<RenderMod>.Logger.Error($"Encoder initialization error: {ex}");
|
Melon<RenderMod>.Logger.Error($"Encoder initialization error: {ex}");
|
||||||
ResetFrameHookState();
|
|
||||||
|
// Release the encoder/thread and restore time, input offset, stage and hooks before showing the dialog
|
||||||
|
MarkFailed($"failed to initialize the video encoder: {ex.Message}");
|
||||||
|
ResetRenderState(success: false);
|
||||||
|
StopFFmpeg(false);
|
||||||
|
RestoreRenderEnvironment();
|
||||||
|
|
||||||
CommonWarningDialogUI.ShowDialog(
|
CommonWarningDialogUI.ShowDialog(
|
||||||
"Encoder Error",
|
"Encoder Error",
|
||||||
$"Failed to initialize video encoder.\nError: {ex.Message}",
|
$"Failed to initialize video encoder.\nError: {ex.Message}",
|
||||||
new DialogButton("OK", () =>
|
new DialogButton("OK", () =>
|
||||||
{
|
{
|
||||||
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
||||||
|
Cursor.visible = true;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
yield break;
|
yield break;
|
||||||
@@ -479,11 +667,6 @@ internal class RenderManager : MonoBehaviour
|
|||||||
scrController.instance.levelWasSkipped = true;
|
scrController.instance.levelWasSkipped = true;
|
||||||
|
|
||||||
if (!scrConductor.instance || !scrConductor.instance.song) yield break;
|
if (!scrConductor.instance || !scrConductor.instance.song) yield break;
|
||||||
if (Configuration.AdvancedConfig.ProcessAudio)
|
|
||||||
{
|
|
||||||
AudioSpectrumGenerator.Initialize(scrConductor.instance.song.clip);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Configuration.AudioConfig.RenderAudio)
|
if (Configuration.AudioConfig.RenderAudio)
|
||||||
{
|
{
|
||||||
AudioMixer = new OfflineAudioMixer(Configuration.AudioConfig.AudioChannels, Configuration.AudioConfig.AudioSampleRate);
|
AudioMixer = new OfflineAudioMixer(Configuration.AudioConfig.AudioChannels, Configuration.AudioConfig.AudioSampleRate);
|
||||||
@@ -493,6 +676,11 @@ internal class RenderManager : MonoBehaviour
|
|||||||
SyncCalculator = new AudioSyncCalculator(Configuration.AudioConfig.AudioSampleRate, effectiveVideoFps);
|
SyncCalculator = new AudioSyncCalculator(Configuration.AudioConfig.AudioSampleRate, effectiveVideoFps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Configuration.AdvancedConfig.ProcessAudio)
|
||||||
|
{
|
||||||
|
AudioSpectrumGenerator.Initialize(scrConductor.instance.song.clip, AudioMixer);
|
||||||
|
}
|
||||||
|
|
||||||
scrConductor.instance.song.clip = null;
|
scrConductor.instance.song.clip = null;
|
||||||
scrConductor.instance.song.time = -1000;
|
scrConductor.instance.song.time = -1000;
|
||||||
}
|
}
|
||||||
@@ -511,13 +699,14 @@ internal class RenderManager : MonoBehaviour
|
|||||||
ImageQueue.Clear();
|
ImageQueue.Clear();
|
||||||
}
|
}
|
||||||
passCount = 0;
|
passCount = 0;
|
||||||
IsRenderCancelled = false;
|
ClearAbortState();
|
||||||
frameCountForVideo = 0;
|
frameCountForVideo = 0;
|
||||||
lastVideoFrameIndex = -1;
|
lastVideoFrameIndex = -1;
|
||||||
lastRequestedVideoFrameIndex = -1;
|
Volatile.Write(ref lastRequestedVideoFrameIndex, -1);
|
||||||
endRequested = false;
|
endRequested = false;
|
||||||
endSuccessful = false;
|
endSuccessful = false;
|
||||||
FrameAvailableEvent.Reset();
|
FrameAvailableEvent.Reset();
|
||||||
|
FrameDequeuedEvent.Reset();
|
||||||
|
|
||||||
var renderFPS = Configuration.RenderFPS;
|
var renderFPS = Configuration.RenderFPS;
|
||||||
var videoFPS = Configuration.VideoFPS;
|
var videoFPS = Configuration.VideoFPS;
|
||||||
@@ -564,6 +753,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
{
|
{
|
||||||
_allVideoFFmpegInstances.Add(videoFFmpeg);
|
_allVideoFFmpegInstances.Add(videoFFmpeg);
|
||||||
}
|
}
|
||||||
|
Volatile.Write(ref _incompleteOutputPath, CurrentOutPath);
|
||||||
videoFFmpeg.Start(CurrentOutPath);
|
videoFFmpeg.Start(CurrentOutPath);
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
@@ -580,49 +770,139 @@ internal class RenderManager : MonoBehaviour
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
RenderThread = new Thread(() =>
|
var encoder = videoFFmpeg;
|
||||||
|
RenderThread = new Thread(() => RunRenderThread(encoder))
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
Priority = ThreadPriority.AboveNormal
|
||||||
|
};
|
||||||
|
|
||||||
|
RenderThread.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Body of the render thread. Sends queued frames and their audio to the encoder until the render ends,
|
||||||
|
/// finishes the output file, and releases the encoder. An exception is handled as a render failure.
|
||||||
|
/// </summary>
|
||||||
|
private void RunRenderThread(FFmpegNativeEncoder videoFFmpeg)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
{
|
||||||
|
EncodeQueuedFrames(videoFFmpeg);
|
||||||
|
|
||||||
|
if (!IsRenderAborted)
|
||||||
|
{
|
||||||
|
// End throws when the file cannot be finished (e.g. the trailer cannot be written),
|
||||||
|
// which is handled as a render failure by the catch block below.
|
||||||
|
videoFFmpeg.End();
|
||||||
|
Volatile.Write(ref _incompleteOutputPath, null);
|
||||||
|
}
|
||||||
|
videoFFmpeg.Dispose();
|
||||||
|
UnregisterEncoder(videoFFmpeg);
|
||||||
|
|
||||||
|
endSuccessful = !IsRenderAborted;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (IsRenderAborted)
|
||||||
|
{
|
||||||
|
// Audio waits end with OperationCanceledException when the render is cancelled or has failed (OfflineAudioMixer.Cancel).
|
||||||
|
Melon<RenderMod>.Logger.Msg("Render thread stopped: the render was aborted.");
|
||||||
|
DisposeEncoderAfterStop(videoFFmpeg);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Melon<RenderMod>.Logger.Error($"FFmpeg rendering thread error: {ex}");
|
||||||
|
|
||||||
|
MarkFailed("encoder thread error. See the log for details.");
|
||||||
|
DisposeEncoderAfterStop(videoFFmpeg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases the encoder after the render thread stopped early (abort or error) and removes it from the encoder list.</summary>
|
||||||
|
private void DisposeEncoderAfterStop(FFmpegNativeEncoder videoFFmpeg)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
videoFFmpeg.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception disposeEx)
|
||||||
|
{
|
||||||
|
Melon<RenderMod>.Logger.Error($"Encoder dispose after the render thread stopped also failed: {disposeEx.Message}");
|
||||||
|
}
|
||||||
|
UnregisterEncoder(videoFFmpeg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 (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)
|
while (true)
|
||||||
{
|
{
|
||||||
CapturedFrame frame = default;
|
if (IsRenderAborted) return;
|
||||||
bool hasFrame = false;
|
|
||||||
|
if (TryDequeueNextFrame(out var frame))
|
||||||
|
{
|
||||||
|
FrameDequeuedEvent.Set();
|
||||||
|
SendVideoFrame(videoFFmpeg, frame);
|
||||||
|
WriteAudioForFrame(videoFFmpeg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ShouldStopEncodeLoop()) return;
|
||||||
|
|
||||||
|
FrameAvailableEvent.WaitOne(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the frame whose key equals passCount from ImageQueue and advances passCount.
|
||||||
|
/// Before the Render stage, passCount jumps to the first queued key (the first captured frame index is not 0).
|
||||||
|
/// Returns false when the queue is empty or the next frame has not arrived yet.
|
||||||
|
/// </summary>
|
||||||
|
private bool TryDequeueNextFrame(out CapturedFrame frame)
|
||||||
|
{
|
||||||
|
frame = default;
|
||||||
lock (ImageQueue)
|
lock (ImageQueue)
|
||||||
{
|
{
|
||||||
if (ImageQueue.Count > 0)
|
if (ImageQueue.Count == 0) return false;
|
||||||
{
|
|
||||||
var firstKey = ImageQueue.Keys[0];
|
|
||||||
|
|
||||||
|
var firstKey = ImageQueue.Keys[0];
|
||||||
if (firstKey > passCount && Instance.stage < RenderStage.Render)
|
if (firstKey > passCount && Instance.stage < RenderStage.Render)
|
||||||
{
|
{
|
||||||
passCount = firstKey;
|
passCount = firstKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (firstKey == passCount)
|
if (firstKey != passCount) return false;
|
||||||
{
|
|
||||||
frame = ImageQueue[firstKey];
|
frame = ImageQueue[firstKey];
|
||||||
ImageQueue.RemoveAt(0);
|
ImageQueue.RemoveAt(0);
|
||||||
passCount++;
|
passCount++;
|
||||||
hasFrame = true;
|
return true;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasFrame)
|
/// <summary>
|
||||||
|
/// Sends one captured frame to the encoder and releases its buffer (managed: ArrayPool, native: readback state).
|
||||||
|
/// A frame without data is skipped.
|
||||||
|
/// </summary>
|
||||||
|
private void SendVideoFrame(FFmpegNativeEncoder videoFFmpeg, in CapturedFrame frame)
|
||||||
{
|
{
|
||||||
if (frame.ManagedBuffer != null)
|
if (frame.ManagedBuffer != null)
|
||||||
{
|
{
|
||||||
if (frame.ManagedBuffer.Length > 0)
|
if (frame.ManagedBuffer.Length == 0) return;
|
||||||
{
|
|
||||||
videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length, frame.IsColorConverted);
|
videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length, frame.IsColorConverted);
|
||||||
Interlocked.Increment(ref framesSent);
|
Interlocked.Increment(ref framesSent);
|
||||||
|
|
||||||
ArrayPool<byte>.Shared.Return(frame.ManagedBuffer);
|
ArrayPool<byte>.Shared.Return(frame.ManagedBuffer);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else if (frame.NativePointer != IntPtr.Zero && frame.Length > 0)
|
if (frame.NativePointer == IntPtr.Zero || frame.Length <= 0) return;
|
||||||
{
|
|
||||||
videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length, frame.IsColorConverted);
|
videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length, frame.IsColorConverted);
|
||||||
Interlocked.Increment(ref framesSent);
|
Interlocked.Increment(ref framesSent);
|
||||||
|
|
||||||
@@ -632,19 +912,21 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Configuration.AudioConfig.RenderAudio)
|
/// <summary>
|
||||||
|
/// Mixes the audio of the frame that was just sent (index passCount - 1) and writes it to the encoder.
|
||||||
|
/// Does nothing when audio rendering is off.
|
||||||
|
/// </summary>
|
||||||
|
private void WriteAudioForFrame(FFmpegNativeEncoder videoFFmpeg)
|
||||||
{
|
{
|
||||||
var frameIdx = (int)(passCount - 1);
|
if (!Configuration.AudioConfig.RenderAudio || SyncCalculator is not { } syncCalculator) return;
|
||||||
var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
|
|
||||||
var frameTime = frameIdx / effectiveVideoFps;
|
|
||||||
|
|
||||||
var channels = Configuration.AudioConfig.AudioChannels;
|
var channels = Configuration.AudioConfig.AudioChannels;
|
||||||
var samplesNeeded = SyncCalculator.GetNextFrameSamples();
|
var samplesNeeded = syncCalculator.GetNextFrameSamples();
|
||||||
|
|
||||||
var audioBuffer = ArrayPool<float>.Shared.Rent(samplesNeeded * channels);
|
var audioBuffer = ArrayPool<float>.Shared.Rent(samplesNeeded * channels);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded, frameTime, dspTimeBase);
|
AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded);
|
||||||
videoFFmpeg.WriteAudioSamples(audioBuffer, samplesNeeded);
|
videoFFmpeg.WriteAudioSamples(audioBuffer, samplesNeeded);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -652,48 +934,25 @@ internal class RenderManager : MonoBehaviour
|
|||||||
ArrayPool<float>.Shared.Return(audioBuffer);
|
ArrayPool<float>.Shared.Return(audioBuffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IsRenderCancelled) break;
|
/// <summary>
|
||||||
|
/// True when the render is aborted, or when the end was requested and every requested frame has been sent.
|
||||||
if (endRequested)
|
/// </summary>
|
||||||
|
private bool ShouldStopEncodeLoop()
|
||||||
{
|
{
|
||||||
if (passCount > lastRequestedVideoFrameIndex) break;
|
if (IsRenderAborted) return true;
|
||||||
|
|
||||||
|
return endRequested && passCount > Volatile.Read(ref lastRequestedVideoFrameIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
FrameAvailableEvent.WaitOne(50);
|
private void UnregisterEncoder(FFmpegNativeEncoder videoFFmpeg)
|
||||||
}
|
|
||||||
|
|
||||||
if (!IsRenderCancelled) videoFFmpeg.End();
|
|
||||||
videoFFmpeg.Dispose();
|
|
||||||
|
|
||||||
lock (_allVideoFFmpegInstances)
|
|
||||||
{
|
{
|
||||||
_allVideoFFmpegInstances.Remove(videoFFmpeg);
|
|
||||||
}
|
|
||||||
|
|
||||||
_activeVideoFFmpeg = null;
|
|
||||||
endSuccessful = !IsRenderCancelled;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Melon<RenderMod>.Logger.Error($"FFmpeg rendering thread error: {ex}");
|
|
||||||
videoFFmpeg.Dispose();
|
|
||||||
lock (_allVideoFFmpegInstances)
|
lock (_allVideoFFmpegInstances)
|
||||||
{
|
{
|
||||||
_allVideoFFmpegInstances.Remove(videoFFmpeg);
|
_allVideoFFmpegInstances.Remove(videoFFmpeg);
|
||||||
}
|
}
|
||||||
_activeVideoFFmpeg = null;
|
_activeVideoFFmpeg = null;
|
||||||
}
|
}
|
||||||
})
|
|
||||||
{
|
|
||||||
IsBackground = true,
|
|
||||||
Priority = ThreadPriority.AboveNormal
|
|
||||||
};
|
|
||||||
|
|
||||||
RenderThread.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void CancelRender()
|
public void CancelRender()
|
||||||
{
|
{
|
||||||
@@ -708,8 +967,11 @@ internal class RenderManager : MonoBehaviour
|
|||||||
endRequested = true;
|
endRequested = true;
|
||||||
if (!success)
|
if (!success)
|
||||||
{
|
{
|
||||||
IsRenderCancelled = true;
|
MarkCancelled();
|
||||||
}
|
}
|
||||||
|
// The abort state is set first: the render thread reads it when its audio wait is cancelled.
|
||||||
|
if (success) AudioMixer?.Complete();
|
||||||
|
else AudioMixer?.Cancel();
|
||||||
FrameAvailableEvent.Set();
|
FrameAvailableEvent.Set();
|
||||||
skipPressToStart = false;
|
skipPressToStart = false;
|
||||||
_renderSetup = false;
|
_renderSetup = false;
|
||||||
@@ -722,9 +984,10 @@ internal class RenderManager : MonoBehaviour
|
|||||||
if (success) StartCoroutine(StopRenderCoroutine());
|
if (success) StartCoroutine(StopRenderCoroutine());
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Melon<RenderMod>.Logger.Error("Rendering failed or was cancelled. Safely cleaning up state and resources.");
|
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 })
|
if (RenderThread is { IsAlive: true })
|
||||||
{
|
{
|
||||||
RenderThread.Join(2000);
|
RenderThread.Join(2000);
|
||||||
@@ -781,30 +1044,26 @@ internal class RenderManager : MonoBehaviour
|
|||||||
scrController.instance.noFailInfiniteMargin = false;
|
scrController.instance.noFailInfiniteMargin = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Immediately delete cancelled incomplete temporary video file
|
// 6. Immediately delete the incomplete video file of this render
|
||||||
if (!string.IsNullOrEmpty(CurrentOutPath) && File.Exists(CurrentOutPath))
|
if (DeleteIncompleteOutputFile())
|
||||||
{
|
{
|
||||||
try
|
|
||||||
{
|
|
||||||
File.Delete(CurrentOutPath);
|
|
||||||
Melon<RenderMod>.Logger.Msg($"[File Cleanup] Deleted incomplete video: {CurrentOutPath}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Melon<RenderMod>.Logger.Error($"[File Cleanup] Failed to delete temporary video: {ex.Message}");
|
|
||||||
}
|
|
||||||
CurrentOutPath = null;
|
CurrentOutPath = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = BuildResult(false,
|
var result = BuildResult(false, BuildAbortMessage());
|
||||||
IsRenderCancelled ? "Rendering was cancelled by user." : "Rendering failed.");
|
|
||||||
TriggerOnComplete(result);
|
TriggerOnComplete(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private IEnumerator StopRenderCoroutine()
|
private IEnumerator StopRenderCoroutine()
|
||||||
{
|
{
|
||||||
yield return new WaitUntil(() => endSuccessful);
|
yield return new WaitUntil(() => endSuccessful || RenderThread is not { IsAlive: true });
|
||||||
|
if (!endSuccessful)
|
||||||
|
{
|
||||||
|
MarkFailed("the render thread terminated unexpectedly.");
|
||||||
|
CancelRender();
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
var result = BuildResult(success: true);
|
var result = BuildResult(success: true);
|
||||||
TriggerOnComplete(result);
|
TriggerOnComplete(result);
|
||||||
@@ -813,6 +1072,15 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void EndRender()
|
private void EndRender()
|
||||||
|
{
|
||||||
|
RestoreRenderEnvironment();
|
||||||
|
|
||||||
|
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
||||||
|
Cursor.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undoes what the render setup changed (time, input offset, auto, queues, session hooks), without leaving the scene.
|
||||||
|
private void RestoreRenderEnvironment()
|
||||||
{
|
{
|
||||||
Time.timeScale = 1f;
|
Time.timeScale = 1f;
|
||||||
Time.captureDeltaTime = 0f;
|
Time.captureDeltaTime = 0f;
|
||||||
@@ -846,13 +1114,12 @@ internal class RenderManager : MonoBehaviour
|
|||||||
|
|
||||||
_frameReader?.Dispose();
|
_frameReader?.Dispose();
|
||||||
ResetFrameHookState();
|
ResetFrameHookState();
|
||||||
AudioMixer?.Reset(); AudioMixer = null;
|
AudioSpectrumGenerator.Clear();
|
||||||
SyncCalculator.Reset();
|
AudioMixer?.Dispose(); AudioMixer = null;
|
||||||
|
SyncCalculator = null;
|
||||||
|
|
||||||
RenderContext.Reset();
|
RenderContext.Reset();
|
||||||
|
|
||||||
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
|
||||||
Cursor.visible = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private RenderResult BuildResult(bool success, string? errorMessage = null)
|
private RenderResult BuildResult(bool success, string? errorMessage = null)
|
||||||
|
|||||||
@@ -4,8 +4,6 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Legal Terms (Official)
|
|
||||||
|
|
||||||
Linking this Program statically or dynamically with other modules is making a combined work based on this Program. Thus, the terms and conditions of the GNU General Public License cover the whole combination.
|
Linking this Program statically or dynamically with other modules is making a combined work based on this Program. Thus, the terms and conditions of the GNU General Public License cover the whole combination.
|
||||||
|
|
||||||
As a special exception, the copyright holders of this Program give you permission to dynamically link this Program with independent modules that interact with or reference this Program solely through the public application programming interfaces (APIs), types, and data structures declared within the following namespaces:
|
As a special exception, the copyright holders of this Program give you permission to dynamically link this Program with independent modules that interact with or reference this Program solely through the public application programming interfaces (APIs), types, and data structures declared within the following namespaces:
|
||||||
@@ -22,21 +20,3 @@ An **"independent module"** is a separate and distinct software module that is n
|
|||||||
This exception does not grant any permission to modify the source code of this Program itself outside the terms of the GNU General Public License version 3.
|
This exception does not grant any permission to modify the source code of this Program itself outside the terms of the GNU General Public License version 3.
|
||||||
|
|
||||||
In accordance with section 7 of the GNU GPL version 3, if you modify this Program, you may extend this exception to your version of the Program, but you are not obligated to do so. If you do not wish to provide this exception in your derivative work, you may remove this exception statement from your version.
|
In accordance with section 7 of the GNU GPL version 3, if you modify this Program, you may extend this exception to your version of the Program, but you are not obligated to do so. If you do not wish to provide this exception in your derivative work, you may remove this exception statement from your version.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 조항 해설 (Korean Guide)
|
|
||||||
|
|
||||||
본 문서는 GNU General Public License 버전 3 (GPLv3) 제7조(추가적인 허가, Additional Permissions)에 따라 제공되는 공식 예외 조항입니다.
|
|
||||||
|
|
||||||
### 핵심 요약
|
|
||||||
|
|
||||||
- **동적 링킹(Dynamic Linking)만 허용**:
|
|
||||||
- 본 예외 조항은 **동적 링킹(런타임 참조 및 어셈블리 동적 로드)**에만 배타적으로 적용됩니다.
|
|
||||||
- ADOFAIRenderer 또는 그 일부 코드를 대상 모드/프로그램에 직접 병합하거나 포함(ILMerge, Costura, 바이너리 직접 내장/정적 컴파일 등)하는 **정적 링킹(Static Linking)은 본 예외 대상에서 제외**되며, 순수 GPLv3의 카피레프트(전염성) 의무가 그대로 적용됩니다.
|
|
||||||
- **외부 모드의 라이선스 자유**:
|
|
||||||
- 제3자 모드 또는 외부 프로그램이 동적 링킹을 통해 `ADOFAIRenderer.API` 및 `ADOFAIRenderer.Data` 네임스페이스의 공개 API, 클래스, 데이터 구조만을 참조·호출하여 연동하는 경우, 해당 모드는 **GPLv3의 카피레프트 의무를 적용받지 않습니다.**
|
|
||||||
- 외부 모드 개발자는 자신의 모드를 MIT, Apache-2.0, 독점/상용 라이선스 등 원하는 라이선스로 자유롭게 배포할 수 있습니다.
|
|
||||||
- **ADOFAIRenderer 코어 보호**:
|
|
||||||
- ADOFAIRenderer 자체의 소스 코드(렌더링 파이프라인, 코어 패치, 내부 유틸리티 등)를 수정하거나 재배포하는 경우에는 본 예외 조항이 적용되지 않으며, 순수 GPLv3 라이선스 조건이 그대로 적용됩니다.
|
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"ui.render_config.preset_name_placeholder": "New preset name...",
|
"ui.render_config.preset_name_placeholder": "New preset name...",
|
||||||
"ui.render_config.preset_save": "Save",
|
"ui.render_config.preset_save": "Save",
|
||||||
"ui.render_config.preset_delete": "Delete",
|
"ui.render_config.preset_delete": "Delete",
|
||||||
|
"ui.render_config.preset_delete_confirm": "Confirm?",
|
||||||
"ui.render_config.preset_overwrite_title": "Overwrite Preset",
|
"ui.render_config.preset_overwrite_title": "Overwrite Preset",
|
||||||
"ui.render_config.preset_overwrite_desc": "A preset named '{0}' already exists. Do you want to overwrite it?",
|
"ui.render_config.preset_overwrite_desc": "A preset named '{0}' already exists. Do you want to overwrite it?",
|
||||||
"ui.render_config.preset_delete_title": "Delete Preset",
|
"ui.render_config.preset_delete_title": "Delete Preset",
|
||||||
@@ -44,10 +45,33 @@
|
|||||||
"ui.render_config.random_seed": "Random Seed (Optional)",
|
"ui.render_config.random_seed": "Random Seed (Optional)",
|
||||||
"ui.render_config.validation_title": "Invalid Settings",
|
"ui.render_config.validation_title": "Invalid Settings",
|
||||||
"ui.render_config.validation_error": "Please check your input values:\n- Width and Height must be integers > 0\n- Render FPS and Video FPS must be > 0\n- Delay After End must be >= 0",
|
"ui.render_config.validation_error": "Please check your input values:\n- Width and Height must be integers > 0\n- Render FPS and Video FPS must be > 0\n- Delay After End must be >= 0",
|
||||||
|
"ui.render_config.validation_even_size": "Render width and height must be even numbers (current: {0} x {1}).",
|
||||||
"ui.render_config.invalid_path": "Output directory path is invalid:\n{0}",
|
"ui.render_config.invalid_path": "Output directory path is invalid:\n{0}",
|
||||||
"ui.render_config.planet_colors": "Planet Colors (Optional)",
|
"ui.render_config.planet_colors": "Planet Colors (Optional)",
|
||||||
"ui.render_config.fire_planet": "Fire Planet",
|
"ui.render_config.fire_planet": "Fire Planet",
|
||||||
"ui.render_config.ice_planet": "Ice Planet",
|
"ui.render_config.ice_planet": "Ice Planet",
|
||||||
"ui.render_config.wind_planet": "Wind Planet",
|
"ui.render_config.wind_planet": "Wind Planet",
|
||||||
"ui.render_config.expired": "(Expired)"
|
"ui.render_config.expired": "(Expired)",
|
||||||
|
"ui.ffmpeg_download.title": "FFmpeg Library Installation Required",
|
||||||
|
"ui.ffmpeg_download.desc_required": "FFmpeg shared libraries are required to render video.",
|
||||||
|
"ui.ffmpeg_download.desc_mac_build": "For macOS, reliable public builds are unavailable, so FFmpeg 8.1 must be built manually.",
|
||||||
|
"ui.ffmpeg_download.desc_mac_open": "Click the button below to open the official FFmpeg website.",
|
||||||
|
"ui.ffmpeg_download.desc_download": "Click the button below to automatically download and install from the internet.",
|
||||||
|
"ui.ffmpeg_download.desc_size": "<color=#aaaaaa><size=13>(Download size: ~70MB, extracted to UserLibs folder)</size></color>",
|
||||||
|
"ui.ffmpeg_download.btn_download": "Download & Install",
|
||||||
|
"ui.ffmpeg_download.btn_open_site": "Open Website",
|
||||||
|
"ui.ffmpeg_download.btn_cancel": "Cancel",
|
||||||
|
"ui.ffmpeg_download.btn_retry": "Retry",
|
||||||
|
"ui.ffmpeg_download.status_starting": "Starting download...",
|
||||||
|
"ui.ffmpeg_download.status_downloading": "Downloading... ({0}%)",
|
||||||
|
"ui.ffmpeg_download.status_extracting": "Extracting and copying library files...",
|
||||||
|
"ui.ffmpeg_download.progress_extracting": "Extracting...",
|
||||||
|
"ui.ffmpeg_download.status_complete": "FFmpeg installation complete!",
|
||||||
|
"ui.ffmpeg_download.status_cancelled": "Download was cancelled.",
|
||||||
|
"ui.ffmpeg_download.status_failed": "Installation failed: {0}",
|
||||||
|
"ui.driver_warning.title": "NVIDIA Graphics Driver Update Recommended",
|
||||||
|
"ui.driver_warning.body": "Currently detected NVIDIA graphics driver version: <b><color=#ff5555>{0}</color></b>\n\nDriver version <b><color=#55ff55>610.00 or higher</color></b> is required to render video using hardware acceleration (NVENC).\n\nIf the driver version is lower, encoding may fail or fallback to software (CPU) encoding, significantly slowing down rendering. Please update your graphics driver.",
|
||||||
|
"ui.driver_warning.btn_update": "Update Driver",
|
||||||
|
"ui.driver_warning.btn_disable": "Disable Feature",
|
||||||
|
"ui.driver_warning.btn_continue": "Continue Anyway"
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"ui.render_config.preset_name_placeholder": "새 프리셋 이름...",
|
"ui.render_config.preset_name_placeholder": "새 프리셋 이름...",
|
||||||
"ui.render_config.preset_save": "저장",
|
"ui.render_config.preset_save": "저장",
|
||||||
"ui.render_config.preset_delete": "삭제",
|
"ui.render_config.preset_delete": "삭제",
|
||||||
|
"ui.render_config.preset_delete_confirm": "삭제 확인",
|
||||||
"ui.render_config.preset_overwrite_title": "프리셋 덮어쓰기",
|
"ui.render_config.preset_overwrite_title": "프리셋 덮어쓰기",
|
||||||
"ui.render_config.preset_overwrite_desc": "'{0}' 프리셋이 이미 존재합니다. 덮어쓰시겠습니까?",
|
"ui.render_config.preset_overwrite_desc": "'{0}' 프리셋이 이미 존재합니다. 덮어쓰시겠습니까?",
|
||||||
"ui.render_config.preset_delete_title": "프리셋 삭제",
|
"ui.render_config.preset_delete_title": "프리셋 삭제",
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
"ui.render_config.audio_encoding": "오디오 인코딩 설정",
|
"ui.render_config.audio_encoding": "오디오 인코딩 설정",
|
||||||
"ui.render_config.global_settings": "전역 설정",
|
"ui.render_config.global_settings": "전역 설정",
|
||||||
"ui.render_config.advanced_settings": "고급 설정",
|
"ui.render_config.advanced_settings": "고급 설정",
|
||||||
"ui.render_config.go_render": "렌더 시작",
|
"ui.render_config.go_render": "렌더링하러 가다",
|
||||||
"ui.render_config.codec": "코덱",
|
"ui.render_config.codec": "코덱",
|
||||||
"ui.render_config.rate_control": "비트레이트 제어 모드",
|
"ui.render_config.rate_control": "비트레이트 제어 모드",
|
||||||
"ui.render_config.bitrate": "비트레이트 (kbps)",
|
"ui.render_config.bitrate": "비트레이트 (kbps)",
|
||||||
@@ -44,10 +45,33 @@
|
|||||||
"ui.render_config.random_seed": "랜덤 시드 (선택 사항)",
|
"ui.render_config.random_seed": "랜덤 시드 (선택 사항)",
|
||||||
"ui.render_config.validation_title": "설정 오류",
|
"ui.render_config.validation_title": "설정 오류",
|
||||||
"ui.render_config.validation_error": "입력값을 확인해 주세요:\n- 너비와 높이는 0보다 큰 정수여야 합니다.\n- 렌더 FPS와 영상 FPS는 0보다 커야 합니다.\n- 종료 후 대기 시간은 0 이상이어야 합니다.",
|
"ui.render_config.validation_error": "입력값을 확인해 주세요:\n- 너비와 높이는 0보다 큰 정수여야 합니다.\n- 렌더 FPS와 영상 FPS는 0보다 커야 합니다.\n- 종료 후 대기 시간은 0 이상이어야 합니다.",
|
||||||
|
"ui.render_config.validation_even_size": "렌더 해상도의 가로와 세로는 짝수여야 해요. (현재: {0} x {1})",
|
||||||
"ui.render_config.invalid_path": "출력 디렉토리 경로가 올바르지 않습니다:\n{0}",
|
"ui.render_config.invalid_path": "출력 디렉토리 경로가 올바르지 않습니다:\n{0}",
|
||||||
"ui.render_config.planet_colors": "행성 색상 설정 (선택 사항)",
|
"ui.render_config.planet_colors": "행성 색상 설정 (선택 사항)",
|
||||||
"ui.render_config.fire_planet": "불 행성",
|
"ui.render_config.fire_planet": "불 행성",
|
||||||
"ui.render_config.ice_planet": "얼음 행성",
|
"ui.render_config.ice_planet": "얼음 행성",
|
||||||
"ui.render_config.wind_planet": "바람 행성",
|
"ui.render_config.wind_planet": "바람 행성",
|
||||||
"ui.render_config.expired": "(만료됨)"
|
"ui.render_config.expired": "(만료됨)",
|
||||||
|
"ui.ffmpeg_download.title": "FFmpeg 라이브러리 설치 필요",
|
||||||
|
"ui.ffmpeg_download.desc_required": "영상을 렌더링하려면 FFmpeg 공유 라이브러리가 필요해요.",
|
||||||
|
"ui.ffmpeg_download.desc_mac_build": "macOS는 신뢰할 수 있는 공개 빌드가 없어서 FFmpeg 8.1을 직접 빌드해야 해요.",
|
||||||
|
"ui.ffmpeg_download.desc_mac_open": "아래 버튼을 누르면 FFmpeg 공식 웹사이트가 열려요.",
|
||||||
|
"ui.ffmpeg_download.desc_download": "아래 버튼을 누르면 인터넷에서 자동으로 내려받아 설치해요.",
|
||||||
|
"ui.ffmpeg_download.desc_size": "<color=#aaaaaa><size=13>(다운로드 크기: 약 70MB, UserLibs 폴더에 압축 해제)</size></color>",
|
||||||
|
"ui.ffmpeg_download.btn_download": "다운로드 및 설치",
|
||||||
|
"ui.ffmpeg_download.btn_open_site": "웹사이트 열기",
|
||||||
|
"ui.ffmpeg_download.btn_cancel": "취소",
|
||||||
|
"ui.ffmpeg_download.btn_retry": "다시 시도",
|
||||||
|
"ui.ffmpeg_download.status_starting": "다운로드를 시작하는 중...",
|
||||||
|
"ui.ffmpeg_download.status_downloading": "다운로드 중... ({0}%)",
|
||||||
|
"ui.ffmpeg_download.status_extracting": "라이브러리 파일을 압축 해제하고 복사하는 중...",
|
||||||
|
"ui.ffmpeg_download.progress_extracting": "압축 해제 중...",
|
||||||
|
"ui.ffmpeg_download.status_complete": "FFmpeg 설치가 끝났어요!",
|
||||||
|
"ui.ffmpeg_download.status_cancelled": "다운로드가 취소되었어요.",
|
||||||
|
"ui.ffmpeg_download.status_failed": "설치에 실패했어요: {0}",
|
||||||
|
"ui.driver_warning.title": "NVIDIA 그래픽 드라이버 업데이트 권장",
|
||||||
|
"ui.driver_warning.body": "현재 감지된 NVIDIA 그래픽 드라이버 버전: <b><color=#ff5555>{0}</color></b>\n\n하드웨어 가속(NVENC)으로 영상을 렌더링하려면 드라이버 버전 <b><color=#55ff55>610.00 이상</color></b>이 필요해요.\n\n드라이버 버전이 더 낮으면 인코딩이 실패하거나 소프트웨어(CPU) 인코딩으로 바뀌어 렌더링이 훨씬 느려질 수 있어요. 그래픽 드라이버를 업데이트해 주세요.",
|
||||||
|
"ui.driver_warning.btn_update": "드라이버 업데이트",
|
||||||
|
"ui.driver_warning.btn_disable": "기능 끄기",
|
||||||
|
"ui.driver_warning.btn_continue": "그래도 계속"
|
||||||
}
|
}
|
||||||
+235
-25
@@ -1,11 +1,33 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using ADOFAIRenderer.Core;
|
using ADOFAIRenderer.Core;
|
||||||
|
using ADOFAIRenderer.Util.Audio;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Clip;
|
||||||
using HarmonyLib;
|
using HarmonyLib;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.Audio;
|
using UnityEngine.Audio;
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Engine;
|
namespace ADOFAIRenderer.Patch.Engine;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records the file path of each external clip the game loads (always active, also outside rendering,
|
||||||
|
/// because levels load their song and PlaySound files before a render starts).
|
||||||
|
/// FindOrLoadAudioClipExternal returns the clip already in audioLib when its key exists, so the path is recorded
|
||||||
|
/// only when the key is missing and the file is about to be loaded.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.FindOrLoadAudioClipExternal))]
|
||||||
|
public static class AudioManagerFindOrLoadAudioClipExternalPatch
|
||||||
|
{
|
||||||
|
[HarmonyPrefix]
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
|
public static void Prefix(AudioManager __instance, string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path)) return;
|
||||||
|
if (__instance.audioLib.ContainsKey(AudioClipPaths.KeyOf(path))) return;
|
||||||
|
AudioClipPaths.Record(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HarmonyPatch(typeof(AudioManager), "_Play")]
|
[HarmonyPatch(typeof(AudioManager), "_Play")]
|
||||||
public static class AudioManagerPlayPatch
|
public static class AudioManagerPlayPatch
|
||||||
{
|
{
|
||||||
@@ -30,14 +52,27 @@ public static class AudioManagerPlayPatch
|
|||||||
double relativeStartTime = time - RenderManager.Instance.dspTimeBase;
|
double relativeStartTime = time - RenderManager.Instance.dspTimeBase;
|
||||||
|
|
||||||
// Register event to the offline mixer (default pitch = 1.0f) and map it to the AudioSource
|
// Register event to the offline mixer (default pitch = 1.0f) and map it to the AudioSource
|
||||||
RenderManager.Instance.AudioMixer?.RegisterSoundEvent(__result, snd, relativeStartTime, -1.0, volume, 1.0f);
|
var mixer = RenderManager.Instance.AudioMixer;
|
||||||
|
if (mixer == null) return;
|
||||||
|
mixer.RegisterSoundEvent(__result, snd, relativeStartTime, -1.0, volume, 1.0f, RenderManager.Instance.AudioTime);
|
||||||
|
EffectSource.DeactivateAtFrameEnd(__result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// scrConductor.PlayWithEndTime: registers a looping effect voice until the end time. The AudioSource it creates with
|
||||||
|
/// AudioManager.MakeSource is mapped to that voice (<see cref="AudioManagerMakeSourcePatch"/>).
|
||||||
|
/// </summary>
|
||||||
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")]
|
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")]
|
||||||
public static class PlayWithEndTimePatch
|
public static class PlayWithEndTimePatch
|
||||||
{
|
{
|
||||||
|
/// <summary>State registered by the running PlayWithEndTime call, until its MakeSource returns. Main thread only.</summary>
|
||||||
|
internal static SourceState? PendingState;
|
||||||
|
|
||||||
|
[HarmonyFinalizer]
|
||||||
|
public static void Finalizer() => PendingState = null;
|
||||||
|
|
||||||
[HarmonyPrefix]
|
[HarmonyPrefix]
|
||||||
public static bool Prefix(string snd, double time, double endTime, float volume, int priority)
|
public static bool Prefix(string snd, double time, double endTime, float volume, int priority)
|
||||||
{
|
{
|
||||||
@@ -54,59 +89,234 @@ public static class PlayWithEndTimePatch
|
|||||||
double relativeEndTime = endTime - RenderManager.Instance.dspTimeBase;
|
double relativeEndTime = endTime - RenderManager.Instance.dspTimeBase;
|
||||||
|
|
||||||
// Register looping sound event to the offline mixer (default pitch = 1.0f)
|
// Register looping sound event to the offline mixer (default pitch = 1.0f)
|
||||||
mixer.RegisterSoundEvent(snd, relativeStartTime, relativeEndTime, volume, 1.0f);
|
PendingState = mixer.RegisterSoundEvent(null, snd, relativeStartTime, relativeEndTime, volume, 1.0f, RenderManager.Instance.AudioTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true; // Let the original method execute
|
return true; // Let the original method execute
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps the AudioSource created inside scrConductor.PlayWithEndTime to its state (<see cref="PlayWithEndTimePatch.PendingState"/>).</summary>
|
||||||
|
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.MakeSource))]
|
||||||
|
public static class AudioManagerMakeSourcePatch
|
||||||
|
{
|
||||||
|
[HarmonyPostfix]
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
|
public static void Postfix(AudioSource __result)
|
||||||
|
{
|
||||||
|
var state = PlayWithEndTimePatch.PendingState;
|
||||||
|
if (state == null || !__result) return;
|
||||||
|
PlayWithEndTimePatch.PendingState = null;
|
||||||
|
var mixer = RenderManager.Instance.AudioMixer;
|
||||||
|
if (mixer == null) return;
|
||||||
|
mixer.MapSource(__result, state);
|
||||||
|
EffectSource.DeactivateAtFrameEnd(__result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AudioSource members of an effect source mapped to a <see cref="SourceState"/> during rendering: the patches below skip the
|
||||||
|
/// Unity implementation and read or change the state instead (isPlaying, time, timeSamples, pitch, SetScheduledEndTime, Stop).
|
||||||
|
/// The patches apply only while <see cref="TryGet"/> returns true: rendering is capturing (RenderManager.IsCapturingActive,
|
||||||
|
/// stage Render), the render has an audio mixer (created when the render starts, cleared when it ends), and the source is mapped
|
||||||
|
/// in that mixer (mappings are created only when RenderAudio is on). Otherwise they run the Unity implementation, so sources
|
||||||
|
/// without a mapping (the song, menu sounds) and all sources outside rendering behave as in the game.
|
||||||
|
///
|
||||||
|
/// The game object of a mapped source is deactivated at the end of the frame it was mapped in: the mixer produces its sound and the
|
||||||
|
/// state answers every member above, so Unity does not process the source. The game activates it again when AudioManager.MakeSource
|
||||||
|
/// reuses it. Active AudioSources add main-thread time per frame in proportion to their count, and dense levels keep thousands of
|
||||||
|
/// scheduled effect sources alive.
|
||||||
|
/// </summary>
|
||||||
|
internal static class EffectSource
|
||||||
|
{
|
||||||
|
/// <summary>Sources mapped during the current frame, deactivated by <see cref="DeactivateMappedSources"/>. Main thread only.</summary>
|
||||||
|
private static readonly List<AudioSource> PendingDeactivation = new();
|
||||||
|
|
||||||
|
/// <summary>Deactivates the game object of <paramref name="source"/> at the end of the current frame (<see cref="DeactivateMappedSources"/>).</summary>
|
||||||
|
public static void DeactivateAtFrameEnd(AudioSource source) => PendingDeactivation.Add(source);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deactivates the game objects of the sources mapped this frame that are still mapped and not destroyed.
|
||||||
|
/// Called by RenderManager.OnPostFrame after the audio commands of the frame are committed, which runs only in stage Render with
|
||||||
|
/// an audio mixer; sources are added only by the render-time registrations (AudioManager._Play and scrConductor.PlayWithEndTime
|
||||||
|
/// patches with RenderAudio on).
|
||||||
|
/// </summary>
|
||||||
|
public static void DeactivateMappedSources(OfflineAudioMixer mixer)
|
||||||
|
{
|
||||||
|
foreach (var source in PendingDeactivation)
|
||||||
|
{
|
||||||
|
if (source && mixer.TryGetSourceState(source, out _)) source.gameObject.SetActive(false);
|
||||||
|
}
|
||||||
|
PendingDeactivation.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// State mapped to <paramref name="source"/> in the mixer of the current render, with the current render time in <paramref name="now"/>.
|
||||||
|
/// False outside rendering (stage below Render), without a mixer, or for a source that is not mapped.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGet(AudioSource source, out SourceState state, out double now)
|
||||||
|
{
|
||||||
|
state = null!;
|
||||||
|
now = 0;
|
||||||
|
if (!RenderManager.IsCapturingActive) return false;
|
||||||
|
var mixer = RenderManager.Instance.AudioMixer;
|
||||||
|
if (mixer == null || !mixer.TryGetSourceState(source, out state)) return false;
|
||||||
|
now = RenderManager.Instance.AudioTime;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AudioSource.isPlaying getter (<see cref="EffectSource"/>): true from the scheduling call until Stop(), the scheduled end time or
|
||||||
|
/// the end of the clip, including before the scheduled start. AudioManager.Update returns a source to its reusable pool once this is false.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(AudioSource), "isPlaying", MethodType.Getter)]
|
||||||
|
public static class AudioSourceIsPlayingPatch
|
||||||
|
{
|
||||||
|
[HarmonyPrefix]
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
|
public static bool Prefix(AudioSource __instance, ref bool __result)
|
||||||
|
{
|
||||||
|
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
|
||||||
|
__result = state.IsPlaying(now);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AudioSource.time getter (<see cref="EffectSource"/>): clip position in seconds while playing, 0 while not playing.</summary>
|
||||||
|
[HarmonyPatch(typeof(AudioSource), "time", MethodType.Getter)]
|
||||||
|
public static class AudioSourceTimeGetterPatch
|
||||||
|
{
|
||||||
|
[HarmonyPrefix]
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
|
public static bool Prefix(AudioSource __instance, ref float __result)
|
||||||
|
{
|
||||||
|
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
|
||||||
|
__result = state.GetTime(now);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AudioSource.time setter (<see cref="EffectSource"/>): moves the playback position (clip seconds) from the current render time.
|
||||||
|
/// Set before the scheduled start (ffxPlaySound sets it right after AudioManager.Play), it becomes the start position.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(AudioSource), "time", MethodType.Setter)]
|
||||||
|
public static class AudioSourceTimePatch
|
||||||
|
{
|
||||||
|
[HarmonyPrefix]
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
|
public static bool Prefix(AudioSource __instance, float value)
|
||||||
|
{
|
||||||
|
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
|
||||||
|
state.SetTime(value, now);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AudioSource.timeSamples getter (<see cref="EffectSource"/>): clip position in samples of the clip.</summary>
|
||||||
|
[HarmonyPatch(typeof(AudioSource), "timeSamples", MethodType.Getter)]
|
||||||
|
public static class AudioSourceTimeSamplesGetterPatch
|
||||||
|
{
|
||||||
|
[HarmonyPrefix]
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
|
public static bool Prefix(AudioSource __instance, ref int __result)
|
||||||
|
{
|
||||||
|
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
|
||||||
|
__result = state.GetTimeSamples(now);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AudioSource.timeSamples setter (<see cref="EffectSource"/>): moves the playback position (samples of the clip).</summary>
|
||||||
|
[HarmonyPatch(typeof(AudioSource), "timeSamples", MethodType.Setter)]
|
||||||
|
public static class AudioSourceTimeSamplesPatch
|
||||||
|
{
|
||||||
|
[HarmonyPrefix]
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
|
public static bool Prefix(AudioSource __instance, int value)
|
||||||
|
{
|
||||||
|
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
|
||||||
|
state.SetTimeSamples(value, now);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AudioSource.pitch getter (<see cref="EffectSource"/>): the last pitch set.</summary>
|
||||||
|
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Getter)]
|
||||||
|
public static class AudioSourcePitchGetterPatch
|
||||||
|
{
|
||||||
|
[HarmonyPrefix]
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
|
public static bool Prefix(AudioSource __instance, ref float __result)
|
||||||
|
{
|
||||||
|
if (!EffectSource.TryGet(__instance, out var state, out _)) return true;
|
||||||
|
__result = state.GetPitch();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AudioSource.pitch setter (<see cref="EffectSource"/>): changes the playback rate from the current render time.</summary>
|
||||||
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
|
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
|
||||||
public static class AudioSourcePitchPatch
|
public static class AudioSourcePitchPatch
|
||||||
{
|
{
|
||||||
[HarmonyPostfix]
|
[HarmonyPrefix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static void Postfix(AudioSource __instance, float value)
|
public static bool Prefix(AudioSource __instance, float value)
|
||||||
{
|
{
|
||||||
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
|
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
|
||||||
{
|
state.SetPitch(value, now);
|
||||||
var mixer = RenderManager.Instance.AudioMixer;
|
return false;
|
||||||
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
|
|
||||||
{
|
|
||||||
soundEvent.Pitch = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>AudioSource.SetScheduledEndTime (<see cref="EffectSource"/>): DSP time (absolute) at which playback stops.</summary>
|
||||||
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
|
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
|
||||||
public static class AudioSourceSetScheduledEndTimePatch
|
public static class AudioSourceSetScheduledEndTimePatch
|
||||||
{
|
{
|
||||||
[HarmonyPostfix]
|
[HarmonyPrefix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static void Postfix(AudioSource __instance, double time)
|
public static bool Prefix(AudioSource __instance, double time)
|
||||||
{
|
{
|
||||||
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
|
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
|
||||||
{
|
state.SetEndTime(time - RenderManager.Instance.dspTimeBase, now);
|
||||||
var mixer = RenderManager.Instance.AudioMixer;
|
return false;
|
||||||
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
|
|
||||||
{
|
|
||||||
soundEvent.EndTime = time - RenderManager.Instance.dspTimeBase;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AudioSource.Stop() (<see cref="EffectSource"/>): stops playback at the current render time. The mapping stays, so the source
|
||||||
|
/// keeps reporting the stopped state. AudioManager.Update calls Stop() on sources it returns to the pool, which are no longer
|
||||||
|
/// playing, so their voices are left unchanged.
|
||||||
|
/// </summary>
|
||||||
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
|
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
|
||||||
public static class AudioSourceStopPatch
|
public static class AudioSourceStopPatch
|
||||||
{
|
{
|
||||||
[HarmonyPostfix]
|
[HarmonyPrefix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static void Postfix(AudioSource __instance)
|
public static bool Prefix(AudioSource __instance)
|
||||||
|
{
|
||||||
|
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
|
||||||
|
state.Stop(now);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AudioManager.StopAllSounds destroys every source in liveSources (all AudioManager._Play and scrConductor.PlayWithEndTime sources),
|
||||||
|
/// so every effect voice stops at the current render time, including ones scheduled to start later.
|
||||||
|
/// Callers include scrConductor.PlayHitTimes (from its second call), ScrubMusicToTile/Time, KillAllSoundsCoroutine,
|
||||||
|
/// scrController.FailAction and scnGame.Play.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.StopAllSounds))]
|
||||||
|
public static class AudioManagerStopAllSoundsPatch
|
||||||
|
{
|
||||||
|
[HarmonyPrefix]
|
||||||
|
public static void Prefix()
|
||||||
{
|
{
|
||||||
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
|
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
|
||||||
{
|
{
|
||||||
RenderManager.Instance.AudioMixer?.RemoveSourceMapping(__instance);
|
RenderManager.Instance.AudioMixer?.StopAllEffects(RenderManager.Instance.AudioTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using ADOFAIRenderer.Core;
|
||||||
|
using HarmonyLib;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Patch.Game;
|
||||||
|
|
||||||
|
[HarmonyPatch(typeof(DetailedResults))]
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
|
public static class DetailedResultsPatch
|
||||||
|
{
|
||||||
|
[HarmonyPrefix]
|
||||||
|
[HarmonyPatch("Show")]
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
|
public static bool PreventShow(DetailedResults __instance)
|
||||||
|
{
|
||||||
|
if (!RenderManager.IsActive) return true;
|
||||||
|
|
||||||
|
if (__instance)
|
||||||
|
{
|
||||||
|
__instance.enabled = false;
|
||||||
|
if (__instance.gameObject) __instance.gameObject.SetActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,4 +41,38 @@ public static class scrControllerPatch
|
|||||||
{
|
{
|
||||||
return !RenderManager.IsActive;
|
return !RenderManager.IsActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HarmonyPostfix]
|
||||||
|
[HarmonyPatch("OnLandOnPortal")]
|
||||||
|
public static void PreventWinTextsPostfix(scrController __instance)
|
||||||
|
{
|
||||||
|
if (!RenderManager.IsActive || __instance == null) return;
|
||||||
|
|
||||||
|
if (__instance.txtCongrats)
|
||||||
|
{
|
||||||
|
__instance.txtCongrats.text = string.Empty;
|
||||||
|
__instance.txtCongrats.gameObject.SetActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (__instance.txtAprilCongrats)
|
||||||
|
{
|
||||||
|
__instance.txtAprilCongrats.text = string.Empty;
|
||||||
|
__instance.txtAprilCongrats.gameObject.SetActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (__instance.txtAllStrictClear)
|
||||||
|
{
|
||||||
|
__instance.txtAllStrictClear.text = string.Empty;
|
||||||
|
__instance.txtAllStrictClear.gameObject.SetActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (__instance.detailedResults)
|
||||||
|
{
|
||||||
|
__instance.detailedResults.enabled = false;
|
||||||
|
if (__instance.detailedResults.gameObject != null)
|
||||||
|
{
|
||||||
|
__instance.detailedResults.gameObject.SetActive(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,12 @@ public class scrPressToStartPatch
|
|||||||
if (!RenderManager.Instance || !RenderManager.Instance.skipPressToStart) return true;
|
if (!RenderManager.Instance || !RenderManager.Instance.skipPressToStart) return true;
|
||||||
|
|
||||||
RenderManager.Instance.skipPressToStart = false;
|
RenderManager.Instance.skipPressToStart = false;
|
||||||
|
|
||||||
|
// scrPressToStart.ShowText ends with ShowDifficultyContainer(mode). Skipping ShowText skips that call too,
|
||||||
|
// which leaves the difficulty selector active and clickable. DontShow deactivates difficultyContainer
|
||||||
|
// and makes the keyboard difficulty shortcuts (difficultyUIMode != DontShow checks) do nothing.
|
||||||
|
if (scrUIController.instance)
|
||||||
|
scrUIController.instance.ShowDifficultyContainer(DifficultyUIMode.DontShow);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -9,7 +9,7 @@ using MelonLoader.Utils;
|
|||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using Object = UnityEngine.Object;
|
using Object = UnityEngine.Object;
|
||||||
|
|
||||||
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.4-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")]
|
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
|
||||||
|
|
||||||
namespace ADOFAIRenderer;
|
namespace ADOFAIRenderer;
|
||||||
|
|||||||
+75
-149
@@ -1,10 +1,12 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using MelonLoader;
|
||||||
|
using O5Kit.Core;
|
||||||
|
using O5Kit.Factory;
|
||||||
using TMPro;
|
using TMPro;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.UI;
|
using UnityEngine.UI;
|
||||||
using UnityEngine.UIElements;
|
using UnityEngine.UIElements;
|
||||||
using Button = UnityEngine.UI.Button;
|
|
||||||
using Image = UnityEngine.UI.Image;
|
using Image = UnityEngine.UI.Image;
|
||||||
|
|
||||||
namespace ADOFAIRenderer.UI;
|
namespace ADOFAIRenderer.UI;
|
||||||
@@ -29,6 +31,7 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
{
|
{
|
||||||
public static CommonWarningDialogUI? Instance { get; private set; }
|
public static CommonWarningDialogUI? Instance { get; private set; }
|
||||||
private readonly List<VisualElement> _disabledElements = new();
|
private readonly List<VisualElement> _disabledElements = new();
|
||||||
|
private O5Window? _window;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
@@ -37,6 +40,9 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
|
|
||||||
private void OnDestroy()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
|
_window?.Dispose();
|
||||||
|
_window = null;
|
||||||
|
|
||||||
foreach (var elem in _disabledElements)
|
foreach (var elem in _disabledElements)
|
||||||
{
|
{
|
||||||
if (elem != null)
|
if (elem != null)
|
||||||
@@ -87,9 +93,8 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
// Canvas setup
|
// Canvas setup
|
||||||
var canvas = gameObject.AddComponent<Canvas>();
|
var canvas = gameObject.AddComponent<Canvas>();
|
||||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||||
canvas.sortingOrder = 32766; // Just below FFmpegDownloadUI (32767)
|
canvas.sortingOrder = 32766;
|
||||||
|
|
||||||
// Responsive Canvas Scaler setup
|
|
||||||
var scaler = gameObject.AddComponent<CanvasScaler>();
|
var scaler = gameObject.AddComponent<CanvasScaler>();
|
||||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||||
@@ -98,13 +103,15 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
|
|
||||||
gameObject.AddComponent<GraphicRaycaster>();
|
gameObject.AddComponent<GraphicRaycaster>();
|
||||||
|
|
||||||
// 1. Create full screen overlay background
|
var ctx = RendererUIManager.Context;
|
||||||
var bgPanelObj = new GameObject("BackgroundPanel");
|
|
||||||
|
// 1. Create full screen backdrop (scrim) to block background input
|
||||||
|
var bgPanelObj = new GameObject("Backdrop");
|
||||||
bgPanelObj.transform.SetParent(transform, false);
|
bgPanelObj.transform.SetParent(transform, false);
|
||||||
bgPanelObj.layer = 5;
|
bgPanelObj.layer = 5;
|
||||||
|
|
||||||
var bgImage = bgPanelObj.AddComponent<Image>();
|
var bgImage = bgPanelObj.AddComponent<Image>();
|
||||||
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay
|
bgImage.color = new Color(0f, 0f, 0f, 0.65f);
|
||||||
bgImage.raycastTarget = true;
|
bgImage.raycastTarget = true;
|
||||||
|
|
||||||
var bgRect = bgPanelObj.GetComponent<RectTransform>();
|
var bgRect = bgPanelObj.GetComponent<RectTransform>();
|
||||||
@@ -113,112 +120,66 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
bgRect.offsetMin = Vector2.zero;
|
bgRect.offsetMin = Vector2.zero;
|
||||||
bgRect.offsetMax = Vector2.zero;
|
bgRect.offsetMax = Vector2.zero;
|
||||||
|
|
||||||
var font = ModAssets.NanumSquareNeoNormal;
|
// 2. Create actual native O5Window
|
||||||
|
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
|
||||||
|
{
|
||||||
|
Id = "RendererMod_WarningDialog",
|
||||||
|
Title = title,
|
||||||
|
Size = new Vector2(540, 240),
|
||||||
|
Draggable = true,
|
||||||
|
Resizable = false,
|
||||||
|
ShowCloseButton = false,
|
||||||
|
ShowOutline = true,
|
||||||
|
Padding = new RectOffset(16, 16, 16, 16)
|
||||||
|
});
|
||||||
|
|
||||||
// 2. Create modal container
|
// 3. Build content inside window.Content
|
||||||
var modalContainerObj = new GameObject("ModalContainer");
|
var content = _window.Content;
|
||||||
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
|
|
||||||
modalContainerObj.layer = 5;
|
|
||||||
|
|
||||||
var modalImage = modalContainerObj.AddComponent<Image>();
|
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
|
||||||
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // Matching background tone of existing UI
|
vLayout.spacing = 16f;
|
||||||
|
vLayout.padding = new RectOffset(16, 16, 12, 12);
|
||||||
|
vLayout.childControlWidth = true;
|
||||||
|
vLayout.childControlHeight = true;
|
||||||
|
vLayout.childForceExpandWidth = true;
|
||||||
|
vLayout.childForceExpandHeight = false;
|
||||||
|
|
||||||
var modalOutline = modalContainerObj.AddComponent<Outline>();
|
// Description text
|
||||||
modalOutline.effectColor = Color.white;
|
var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
|
||||||
modalOutline.effectDistance = new Vector2(3.0f, -3.0f); // Consistent with existing UI outline style
|
|
||||||
|
|
||||||
var modalRect = modalContainerObj.GetComponent<RectTransform>();
|
|
||||||
modalRect.anchorMin = new Vector2(0.5f, 0.5f);
|
|
||||||
modalRect.anchorMax = new Vector2(0.5f, 0.5f);
|
|
||||||
modalRect.pivot = new Vector2(0.5f, 0.5f);
|
|
||||||
modalRect.sizeDelta = new Vector2(650, 360);
|
|
||||||
|
|
||||||
// 3. Create title text
|
|
||||||
var titleObj = new GameObject("TitleText");
|
|
||||||
titleObj.transform.SetParent(modalContainerObj.transform, false);
|
|
||||||
titleObj.layer = 5;
|
|
||||||
|
|
||||||
var titleText = titleObj.AddComponent<TextMeshProUGUI>();
|
|
||||||
titleText.text = title;
|
|
||||||
titleText.fontSize = 24;
|
|
||||||
titleText.font = font;
|
|
||||||
titleText.color = new Color(1.0f, 0.8f, 0.2f, 1.0f); // Orange/yellow tone matching warning theme
|
|
||||||
titleText.alignment = TextAlignmentOptions.Center;
|
|
||||||
|
|
||||||
var titleRect = titleObj.GetComponent<RectTransform>();
|
|
||||||
titleRect.anchorMin = new Vector2(0, 1);
|
|
||||||
titleRect.anchorMax = new Vector2(1, 1);
|
|
||||||
titleRect.pivot = new Vector2(0.5f, 1);
|
|
||||||
titleRect.anchoredPosition = new Vector2(0, -35);
|
|
||||||
titleRect.sizeDelta = new Vector2(0, 40);
|
|
||||||
|
|
||||||
// 4. Create description text
|
|
||||||
var descObj = new GameObject("DescriptionText");
|
|
||||||
descObj.transform.SetParent(modalContainerObj.transform, false);
|
|
||||||
descObj.layer = 5;
|
|
||||||
|
|
||||||
var descText = descObj.AddComponent<TextMeshProUGUI>();
|
|
||||||
descText.text = description;
|
descText.text = description;
|
||||||
descText.fontSize = 20;
|
|
||||||
descText.font = font;
|
|
||||||
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
|
|
||||||
descText.alignment = TextAlignmentOptions.Center;
|
descText.alignment = TextAlignmentOptions.Center;
|
||||||
descText.lineSpacing = 8;
|
descText.textWrappingMode = TextWrappingModes.Normal;
|
||||||
|
descText.lineSpacing = 6;
|
||||||
|
descText.color = new Color(0.88f, 0.88f, 0.88f, 1f);
|
||||||
|
|
||||||
var descRect = descObj.GetComponent<RectTransform>();
|
// ReSharper disable once InconsistentNaming
|
||||||
descRect.anchorMin = new Vector2(0, 0.5f);
|
var descLE = descText.gameObject.AddComponent<LayoutElement>();
|
||||||
descRect.anchorMax = new Vector2(1, 1);
|
descLE.flexibleWidth = 1f;
|
||||||
descRect.pivot = new Vector2(0.5f, 0.5f);
|
descLE.flexibleHeight = 1f;
|
||||||
descRect.anchoredPosition = new Vector2(0, -65);
|
|
||||||
descRect.sizeDelta = new Vector2(-40, 120);
|
|
||||||
|
|
||||||
// 5. Create button container
|
// Button action row
|
||||||
var buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
|
var btnRowObj = new GameObject("ButtonRow");
|
||||||
buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
|
btnRowObj.transform.SetParent(content, false);
|
||||||
buttonContainerObj.layer = 5;
|
var btnRowRect = btnRowObj.AddComponent<RectTransform>();
|
||||||
|
var btnRowLE = btnRowObj.AddComponent<LayoutElement>();
|
||||||
|
btnRowLE.preferredHeight = 40f;
|
||||||
|
btnRowLE.flexibleWidth = 1f;
|
||||||
|
|
||||||
var btnContainerRect = buttonContainerObj.GetComponent<RectTransform>();
|
var hLayout = btnRowObj.AddComponent<HorizontalLayoutGroup>();
|
||||||
btnContainerRect.anchorMin = new Vector2(0, 0);
|
hLayout.spacing = 10f;
|
||||||
btnContainerRect.anchorMax = new Vector2(1, 0);
|
hLayout.childAlignment = TextAnchor.MiddleCenter;
|
||||||
btnContainerRect.pivot = new Vector2(0.5f, 0);
|
hLayout.childControlWidth = true;
|
||||||
btnContainerRect.anchoredPosition = new Vector2(0, 35);
|
hLayout.childControlHeight = true;
|
||||||
btnContainerRect.sizeDelta = new Vector2(0, 50);
|
hLayout.childForceExpandWidth = true;
|
||||||
|
hLayout.childForceExpandHeight = true;
|
||||||
|
|
||||||
// 6. Dynamic button layout and styling
|
for (var i = 0; i < buttons.Length; i++)
|
||||||
float totalWidth = 560f;
|
|
||||||
float spacing = 15f;
|
|
||||||
float buttonHeight = 45f;
|
|
||||||
int numButtons = buttons.Length;
|
|
||||||
|
|
||||||
float buttonWidth = numButtons == 1
|
|
||||||
? 200f
|
|
||||||
: (totalWidth - (spacing * (numButtons - 1))) / numButtons;
|
|
||||||
|
|
||||||
float startX = numButtons == 1
|
|
||||||
? 0
|
|
||||||
: (-totalWidth / 2f) + (buttonWidth / 2f);
|
|
||||||
|
|
||||||
for (int i = 0; i < numButtons; i++)
|
|
||||||
{
|
{
|
||||||
var btnData = buttons[i];
|
var btnData = buttons[i];
|
||||||
var btnObj = new GameObject($"Button_{i}");
|
var btn = O5Factory.Button(
|
||||||
btnObj.transform.SetParent(buttonContainerObj.transform, false);
|
ctx: ctx,
|
||||||
btnObj.layer = 5;
|
parent: btnRowRect,
|
||||||
|
onClick: () =>
|
||||||
// Define default colors
|
|
||||||
Color baseColor = btnData.BackgroundColor ?? new Color(0.2f, 0.2f, 0.23f, 1f); // Custom color or gray default
|
|
||||||
Color hoverColor = ApplyColorBrightness(baseColor, 1.2f);
|
|
||||||
Color pressedColor = ApplyColorBrightness(baseColor, 0.8f);
|
|
||||||
|
|
||||||
var btnImage = btnObj.AddComponent<Image>();
|
|
||||||
btnImage.color = baseColor;
|
|
||||||
btnImage.raycastTarget = true;
|
|
||||||
|
|
||||||
var btn = btnObj.AddComponent<Button>();
|
|
||||||
SetupButtonStyle(btn, baseColor, hoverColor, pressedColor);
|
|
||||||
|
|
||||||
// Register click callback
|
|
||||||
btn.onClick.AddListener(() =>
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -226,59 +187,24 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
MelonLoader.Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
|
Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
|
||||||
}
|
}
|
||||||
Destroy(gameObject);
|
Destroy(gameObject);
|
||||||
});
|
},
|
||||||
|
text: btnData.Text,
|
||||||
|
id: $"btn_dialog_{i}",
|
||||||
|
height: 40f
|
||||||
|
);
|
||||||
|
|
||||||
var btnRect = btnObj.GetComponent<RectTransform>();
|
if (btnData.BackgroundColor.HasValue)
|
||||||
btnRect.anchorMin = new Vector2(0.5f, 0.5f);
|
|
||||||
btnRect.anchorMax = new Vector2(0.5f, 0.5f);
|
|
||||||
btnRect.pivot = new Vector2(0.5f, 0.5f);
|
|
||||||
|
|
||||||
float posX = numButtons == 1
|
|
||||||
? 0
|
|
||||||
: startX + i * (buttonWidth + spacing);
|
|
||||||
|
|
||||||
btnRect.anchoredPosition = new Vector2(posX, 0);
|
|
||||||
btnRect.sizeDelta = new Vector2(buttonWidth, buttonHeight);
|
|
||||||
|
|
||||||
// Add text label
|
|
||||||
var txtObj = new GameObject("Text");
|
|
||||||
txtObj.transform.SetParent(btnObj.transform, false);
|
|
||||||
txtObj.layer = 5;
|
|
||||||
|
|
||||||
var text = txtObj.AddComponent<TextMeshProUGUI>();
|
|
||||||
text.text = btnData.Text;
|
|
||||||
text.fontSize = 20;
|
|
||||||
text.font = font;
|
|
||||||
text.color = Color.white;
|
|
||||||
text.alignment = TextAlignmentOptions.Center;
|
|
||||||
|
|
||||||
var txtRect = txtObj.GetComponent<RectTransform>();
|
|
||||||
txtRect.anchorMin = Vector2.zero;
|
|
||||||
txtRect.anchorMax = Vector2.one;
|
|
||||||
txtRect.offsetMin = Vector2.zero;
|
|
||||||
txtRect.offsetMax = Vector2.zero;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor)
|
|
||||||
{
|
{
|
||||||
button.transition = Selectable.Transition.ColorTint;
|
btn.NormalColor = btnData.BackgroundColor.Value;
|
||||||
var colors = button.colors;
|
btn.UpdateVisual();
|
||||||
colors.normalColor = normalColor;
|
|
||||||
colors.highlightedColor = hoverColor;
|
|
||||||
colors.pressedColor = pressedColor;
|
|
||||||
colors.disabledColor = new Color(0.3f, 0.3f, 0.3f, 1f);
|
|
||||||
button.colors = colors;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Color ApplyColorBrightness(Color color, float factor)
|
var le = btn.Rect.gameObject.AddComponent<LayoutElement>();
|
||||||
{
|
le.flexibleWidth = 1f;
|
||||||
float r = Mathf.Clamp01(color.r * factor);
|
le.preferredHeight = 40f;
|
||||||
float g = Mathf.Clamp01(color.g * factor);
|
}
|
||||||
float b = Mathf.Clamp01(color.b * factor);
|
|
||||||
return new Color(r, g, b, color.a);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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($"• 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>");
|
sb.AppendLine($"• Frames Sent: <b>{(RenderManager.Instance ? Interlocked.Read(ref RenderManager.Instance.framesSent) : 0)}</b>");
|
||||||
|
|
||||||
|
|||||||
+138
-195
@@ -1,14 +1,17 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using ADOFAIRenderer.Localization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using MelonLoader;
|
||||||
|
using MelonLoader.Utils;
|
||||||
|
using O5Kit.Core;
|
||||||
|
using O5Kit.Factory;
|
||||||
using TMPro;
|
using TMPro;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.UI;
|
using UnityEngine.UI;
|
||||||
using MelonLoader;
|
|
||||||
using MelonLoader.Utils;
|
|
||||||
|
|
||||||
namespace ADOFAIRenderer.UI;
|
namespace ADOFAIRenderer.UI;
|
||||||
|
|
||||||
@@ -22,6 +25,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
private GameObject _statusTextObj = null!;
|
private GameObject _statusTextObj = null!;
|
||||||
private TextMeshProUGUI _statusText = null!;
|
private TextMeshProUGUI _statusText = null!;
|
||||||
|
|
||||||
|
private O5Window? _window;
|
||||||
private bool _isDownloading;
|
private bool _isDownloading;
|
||||||
private string DownloadUrl => Application.platform switch
|
private string DownloadUrl => Application.platform switch
|
||||||
{
|
{
|
||||||
@@ -104,13 +108,15 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
|
|
||||||
gameObject.AddComponent<GraphicRaycaster>();
|
gameObject.AddComponent<GraphicRaycaster>();
|
||||||
|
|
||||||
// 1. Create full screen overlay background
|
var ctx = RendererUIManager.Context;
|
||||||
var bgPanelObj = new GameObject("BackgroundPanel");
|
|
||||||
|
// 1. Create full screen backdrop
|
||||||
|
var bgPanelObj = new GameObject("Backdrop");
|
||||||
bgPanelObj.transform.SetParent(transform, false);
|
bgPanelObj.transform.SetParent(transform, false);
|
||||||
bgPanelObj.layer = 5;
|
bgPanelObj.layer = 5;
|
||||||
|
|
||||||
var bgImage = bgPanelObj.AddComponent<Image>();
|
var bgImage = bgPanelObj.AddComponent<Image>();
|
||||||
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay
|
bgImage.color = new Color(0f, 0f, 0f, 0.65f);
|
||||||
bgImage.raycastTarget = true;
|
bgImage.raycastTarget = true;
|
||||||
|
|
||||||
var bgRect = bgPanelObj.GetComponent<RectTransform>();
|
var bgRect = bgPanelObj.GetComponent<RectTransform>();
|
||||||
@@ -119,196 +125,141 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
bgRect.offsetMin = Vector2.zero;
|
bgRect.offsetMin = Vector2.zero;
|
||||||
bgRect.offsetMax = Vector2.zero;
|
bgRect.offsetMax = Vector2.zero;
|
||||||
|
|
||||||
var font = ModAssets.NanumSquareNeoNormal;
|
// 2. Create actual native O5Window
|
||||||
|
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
|
||||||
|
{
|
||||||
|
Id = "RendererMod_FFmpegDownloadDialog",
|
||||||
|
Title = LocalizationManager.Get("ui.ffmpeg_download.title"),
|
||||||
|
Size = new Vector2(580, 270),
|
||||||
|
Draggable = true,
|
||||||
|
Resizable = false,
|
||||||
|
ShowCloseButton = false,
|
||||||
|
ShowOutline = true,
|
||||||
|
Padding = new RectOffset(16, 16, 16, 16)
|
||||||
|
});
|
||||||
|
|
||||||
// 2. Create modal container
|
var content = _window.Content;
|
||||||
var modalContainerObj = new GameObject("ModalContainer");
|
|
||||||
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
|
|
||||||
modalContainerObj.layer = 5;
|
|
||||||
|
|
||||||
var modalImage = modalContainerObj.AddComponent<Image>();
|
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
|
||||||
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f);
|
vLayout.spacing = 14f;
|
||||||
|
vLayout.padding = new RectOffset(16, 16, 10, 10);
|
||||||
|
vLayout.childControlWidth = true;
|
||||||
|
vLayout.childControlHeight = true;
|
||||||
|
vLayout.childForceExpandWidth = true;
|
||||||
|
vLayout.childForceExpandHeight = false;
|
||||||
|
|
||||||
var modalOutline = modalContainerObj.AddComponent<Outline>();
|
// 3. Description text
|
||||||
modalOutline.effectColor = Color.white;
|
var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
|
||||||
modalOutline.effectDistance = new Vector2(3.0f, -3.0f);
|
|
||||||
|
|
||||||
var modalRect = modalContainerObj.GetComponent<RectTransform>();
|
|
||||||
modalRect.anchorMin = new Vector2(0.5f, 0.5f);
|
|
||||||
modalRect.anchorMax = new Vector2(0.5f, 0.5f);
|
|
||||||
modalRect.pivot = new Vector2(0.5f, 0.5f);
|
|
||||||
modalRect.sizeDelta = new Vector2(600, 320);
|
|
||||||
|
|
||||||
// 4. Create title text
|
|
||||||
var titleObj = new GameObject("TitleText");
|
|
||||||
titleObj.transform.SetParent(modalContainerObj.transform, false);
|
|
||||||
titleObj.layer = 5;
|
|
||||||
|
|
||||||
var titleText = titleObj.AddComponent<TextMeshProUGUI>();
|
|
||||||
titleText.text = "FFmpeg Library Installation Required";
|
|
||||||
titleText.fontSize = 26;
|
|
||||||
titleText.font = font;
|
|
||||||
titleText.color = Color.white;
|
|
||||||
titleText.alignment = TextAlignmentOptions.Center;
|
|
||||||
|
|
||||||
var titleRect = titleObj.GetComponent<RectTransform>();
|
|
||||||
titleRect.anchorMin = new Vector2(0, 1);
|
|
||||||
titleRect.anchorMax = new Vector2(1, 1);
|
|
||||||
titleRect.pivot = new Vector2(0.5f, 1);
|
|
||||||
titleRect.anchoredPosition = new Vector2(0, -35);
|
|
||||||
titleRect.sizeDelta = new Vector2(0, 40);
|
|
||||||
|
|
||||||
// 5. Create description text
|
|
||||||
var descObj = new GameObject("DescriptionText");
|
|
||||||
descObj.transform.SetParent(modalContainerObj.transform, false);
|
|
||||||
descObj.layer = 5;
|
|
||||||
|
|
||||||
var descText = descObj.AddComponent<TextMeshProUGUI>();
|
|
||||||
var descStrBuilder = new StringBuilder();
|
var descStrBuilder = new StringBuilder();
|
||||||
descStrBuilder.AppendLine("FFmpeg shared libraries are required to render video.");
|
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_required"));
|
||||||
if (Application.platform == RuntimePlatform.OSXPlayer)
|
if (Application.platform == RuntimePlatform.OSXPlayer)
|
||||||
{
|
{
|
||||||
descStrBuilder.AppendLine("For macOS, reliable public builds are unavailable, so FFmpeg 8.1 must be built manually.");
|
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_mac_build"));
|
||||||
descStrBuilder.AppendLine("Click the button below to open the official FFmpeg website.");
|
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_mac_open"));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
descStrBuilder.AppendLine("Click the button below to automatically download and install from the internet.");
|
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_download"));
|
||||||
descStrBuilder.AppendLine("<color=#aaaaaa><size=13>(Download size: ~70MB, extracted to UserLibs folder)</size></color>");
|
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_size"));
|
||||||
}
|
}
|
||||||
|
|
||||||
descText.text = descStrBuilder.ToString();
|
descText.text = descStrBuilder.ToString();
|
||||||
descText.fontSize = 16;
|
|
||||||
descText.font = font;
|
|
||||||
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
|
|
||||||
descText.alignment = TextAlignmentOptions.Center;
|
descText.alignment = TextAlignmentOptions.Center;
|
||||||
descText.lineSpacing = 10;
|
descText.textWrappingMode = TextWrappingModes.Normal;
|
||||||
|
descText.lineSpacing = 5;
|
||||||
|
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
|
||||||
|
|
||||||
var descRect = descObj.GetComponent<RectTransform>();
|
var descLE = descText.gameObject.AddComponent<LayoutElement>();
|
||||||
descRect.anchorMin = new Vector2(0, 0.5f);
|
descLE.flexibleWidth = 1f;
|
||||||
descRect.anchorMax = new Vector2(1, 1);
|
descLE.flexibleHeight = 1f;
|
||||||
descRect.pivot = new Vector2(0.5f, 0.5f);
|
|
||||||
descRect.anchoredPosition = new Vector2(0, -60);
|
|
||||||
descRect.sizeDelta = new Vector2(-40, 100);
|
|
||||||
|
|
||||||
// 6. Create button container
|
// 4. Button container
|
||||||
_buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
|
_buttonContainerObj = new GameObject("ButtonContainer");
|
||||||
_buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
|
_buttonContainerObj.transform.SetParent(content, false);
|
||||||
_buttonContainerObj.layer = 5;
|
_buttonContainerObj.layer = 5;
|
||||||
|
|
||||||
var btnContainerRect = _buttonContainerObj.GetComponent<RectTransform>();
|
var btnContainerLE = _buttonContainerObj.AddComponent<LayoutElement>();
|
||||||
btnContainerRect.anchorMin = new Vector2(0, 0);
|
btnContainerLE.preferredHeight = 40f;
|
||||||
btnContainerRect.anchorMax = new Vector2(1, 0);
|
btnContainerLE.flexibleWidth = 1f;
|
||||||
btnContainerRect.pivot = new Vector2(0.5f, 0);
|
|
||||||
btnContainerRect.anchoredPosition = new Vector2(0, 35);
|
|
||||||
btnContainerRect.sizeDelta = new Vector2(0, 50);
|
|
||||||
|
|
||||||
// 7. Create download button
|
var btnLayout = _buttonContainerObj.AddComponent<HorizontalLayoutGroup>();
|
||||||
var downloadBtnObj = new GameObject("DownloadButton");
|
btnLayout.spacing = 10f;
|
||||||
downloadBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
|
btnLayout.childAlignment = TextAnchor.MiddleCenter;
|
||||||
downloadBtnObj.layer = 5;
|
btnLayout.childControlWidth = true;
|
||||||
|
btnLayout.childControlHeight = true;
|
||||||
|
btnLayout.childForceExpandWidth = true;
|
||||||
|
btnLayout.childForceExpandHeight = true;
|
||||||
|
|
||||||
var downloadImg = downloadBtnObj.AddComponent<Image>();
|
Action onDownloadClick = Application.platform != RuntimePlatform.OSXPlayer
|
||||||
downloadImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue
|
? StartDownloadProcess
|
||||||
downloadImg.raycastTarget = true;
|
: () => { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); };
|
||||||
|
|
||||||
var downloadBtn = downloadBtnObj.AddComponent<Button>();
|
var downloadBtn = O5Factory.Button(
|
||||||
SetupButtonStyle(downloadBtn, new Color(0.0f, 0.8f, 1.0f, 1.0f), new Color(0.2f, 0.9f, 1.0f, 1.0f), new Color(0.0f, 0.6f, 0.8f, 1.0f));
|
ctx: ctx,
|
||||||
if (Application.platform != RuntimePlatform.OSXPlayer) downloadBtn.onClick.AddListener(StartDownloadProcess);
|
parent: _buttonContainerObj.transform,
|
||||||
else
|
onClick: onDownloadClick,
|
||||||
{
|
text: LocalizationManager.Get(Application.platform != RuntimePlatform.OSXPlayer ? "ui.ffmpeg_download.btn_download" : "ui.ffmpeg_download.btn_open_site"),
|
||||||
downloadBtn.onClick.AddListener(OpenFFmpegWebsite);
|
id: "btn_ffmpeg_download",
|
||||||
void OpenFFmpegWebsite() { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); }
|
height: 40f
|
||||||
}
|
);
|
||||||
|
downloadBtn.NormalColor = ctx.Theme.ObjectActiveBright;
|
||||||
|
downloadBtn.UpdateVisual();
|
||||||
|
|
||||||
var downloadBtnRect = downloadBtnObj.GetComponent<RectTransform>();
|
var downloadBtnLE = downloadBtn.Rect.gameObject.AddComponent<LayoutElement>();
|
||||||
downloadBtnRect.anchorMin = new Vector2(0.5f, 0.5f);
|
downloadBtnLE.flexibleWidth = 1f;
|
||||||
downloadBtnRect.anchorMax = new Vector2(0.5f, 0.5f);
|
downloadBtnLE.preferredHeight = 40f;
|
||||||
downloadBtnRect.pivot = new Vector2(1f, 0.5f);
|
|
||||||
downloadBtnRect.anchoredPosition = new Vector2(-15, 0);
|
|
||||||
downloadBtnRect.sizeDelta = new Vector2(180, 45);
|
|
||||||
|
|
||||||
var downloadTextObj = new GameObject("DownloadText");
|
var cancelBtn = O5Factory.Button(
|
||||||
downloadTextObj.transform.SetParent(downloadBtnObj.transform, false);
|
ctx: ctx,
|
||||||
downloadTextObj.layer = 5;
|
parent: _buttonContainerObj.transform,
|
||||||
|
onClick: CancelProcess,
|
||||||
|
text: LocalizationManager.Get("ui.ffmpeg_download.btn_cancel"),
|
||||||
|
id: "btn_ffmpeg_cancel",
|
||||||
|
height: 40f
|
||||||
|
);
|
||||||
|
var cancelBtnLE = cancelBtn.Rect.gameObject.AddComponent<LayoutElement>();
|
||||||
|
cancelBtnLE.flexibleWidth = 1f;
|
||||||
|
cancelBtnLE.preferredHeight = 40f;
|
||||||
|
|
||||||
var downloadText = downloadTextObj.AddComponent<TextMeshProUGUI>();
|
// 5. Progress bar container
|
||||||
downloadText.text = Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website";
|
|
||||||
downloadText.fontSize = 20;
|
|
||||||
downloadText.font = font;
|
|
||||||
downloadText.color = Color.white;
|
|
||||||
downloadText.alignment = TextAlignmentOptions.Center;
|
|
||||||
|
|
||||||
var downloadTextRect = downloadTextObj.GetComponent<RectTransform>();
|
|
||||||
downloadTextRect.anchorMin = Vector2.zero;
|
|
||||||
downloadTextRect.anchorMax = Vector2.one;
|
|
||||||
downloadTextRect.offsetMin = Vector2.zero;
|
|
||||||
downloadTextRect.offsetMax = Vector2.zero;
|
|
||||||
|
|
||||||
// 8. Create cancel button
|
|
||||||
var cancelBtnObj = new GameObject("CancelButton");
|
|
||||||
cancelBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
|
|
||||||
cancelBtnObj.layer = 5;
|
|
||||||
|
|
||||||
var cancelImg = cancelBtnObj.AddComponent<Image>();
|
|
||||||
cancelImg.color = new Color(0.2f, 0.2f, 0.23f, 1f);
|
|
||||||
cancelImg.raycastTarget = true;
|
|
||||||
|
|
||||||
var cancelBtn = cancelBtnObj.AddComponent<Button>();
|
|
||||||
SetupButtonStyle(cancelBtn, new Color(0.2f, 0.2f, 0.23f, 1f), new Color(0.25f, 0.25f, 0.28f, 1f), new Color(0.15f, 0.15f, 0.18f, 1f));
|
|
||||||
cancelBtn.onClick.AddListener(CancelProcess);
|
|
||||||
|
|
||||||
var cancelBtnRect = cancelBtnObj.GetComponent<RectTransform>();
|
|
||||||
cancelBtnRect.anchorMin = new Vector2(0.5f, 0.5f);
|
|
||||||
cancelBtnRect.anchorMax = new Vector2(0.5f, 0.5f);
|
|
||||||
cancelBtnRect.pivot = new Vector2(0f, 0.5f);
|
|
||||||
cancelBtnRect.anchoredPosition = new Vector2(15, 0);
|
|
||||||
cancelBtnRect.sizeDelta = new Vector2(180, 45);
|
|
||||||
|
|
||||||
var cancelTextObj = new GameObject("CancelText");
|
|
||||||
cancelTextObj.transform.SetParent(cancelBtnObj.transform, false);
|
|
||||||
cancelTextObj.layer = 5;
|
|
||||||
|
|
||||||
var cancelText = cancelTextObj.AddComponent<TextMeshProUGUI>();
|
|
||||||
cancelText.text = "Cancel";
|
|
||||||
cancelText.fontSize = 20;
|
|
||||||
cancelText.font = font;
|
|
||||||
cancelText.color = new Color(0.9f, 0.9f, 0.9f, 1f);
|
|
||||||
cancelText.alignment = TextAlignmentOptions.Center;
|
|
||||||
|
|
||||||
var cancelTextRect = cancelTextObj.GetComponent<RectTransform>();
|
|
||||||
cancelTextRect.anchorMin = Vector2.zero;
|
|
||||||
cancelTextRect.anchorMax = Vector2.one;
|
|
||||||
cancelTextRect.offsetMin = Vector2.zero;
|
|
||||||
cancelTextRect.offsetMax = Vector2.zero;
|
|
||||||
|
|
||||||
// 9. Create progress bar container
|
|
||||||
_progressBarObj = new GameObject("ProgressBarContainer");
|
_progressBarObj = new GameObject("ProgressBarContainer");
|
||||||
_progressBarObj.transform.SetParent(modalContainerObj.transform, false);
|
_progressBarObj.transform.SetParent(content, false);
|
||||||
_progressBarObj.layer = 5;
|
_progressBarObj.layer = 5;
|
||||||
_progressBarObj.SetActive(false);
|
_progressBarObj.SetActive(false);
|
||||||
|
|
||||||
|
var pbLE = _progressBarObj.AddComponent<LayoutElement>();
|
||||||
|
pbLE.preferredHeight = 24f;
|
||||||
|
pbLE.flexibleWidth = 1f;
|
||||||
|
|
||||||
var barBgImg = _progressBarObj.AddComponent<Image>();
|
var barBgImg = _progressBarObj.AddComponent<Image>();
|
||||||
barBgImg.color = new Color(0.08f, 0.08f, 0.1f, 1.0f);
|
barBgImg.sprite = ctx.Sprites.RoundedControl;
|
||||||
|
barBgImg.type = Image.Type.Sliced;
|
||||||
|
barBgImg.color = ctx.Theme.ObjectBG;
|
||||||
|
|
||||||
var barBgOutline = _progressBarObj.AddComponent<Outline>();
|
var barOutlineObj = new GameObject("BarOutline");
|
||||||
barBgOutline.effectColor = Color.white;
|
barOutlineObj.transform.SetParent(_progressBarObj.transform, false);
|
||||||
barBgOutline.effectDistance = new Vector2(3.0f, -3.0f);
|
barOutlineObj.layer = 5;
|
||||||
|
|
||||||
var barRect = _progressBarObj.GetComponent<RectTransform>();
|
var barOutlineImg = barOutlineObj.AddComponent<Image>();
|
||||||
barRect.anchorMin = new Vector2(0.5f, 0);
|
barOutlineImg.sprite = ctx.Sprites.RoundedOutline;
|
||||||
barRect.anchorMax = new Vector2(0.5f, 0);
|
barOutlineImg.type = Image.Type.Sliced;
|
||||||
barRect.pivot = new Vector2(0.5f, 0);
|
barOutlineImg.color = ctx.Theme.ControlOutlineIdle;
|
||||||
barRect.anchoredPosition = new Vector2(0, 75);
|
barOutlineImg.raycastTarget = false;
|
||||||
barRect.sizeDelta = new Vector2(480, 24);
|
|
||||||
|
|
||||||
// 10. Create progress bar fill image
|
var barOutlineRect = barOutlineObj.GetComponent<RectTransform>();
|
||||||
|
barOutlineRect.anchorMin = Vector2.zero;
|
||||||
|
barOutlineRect.anchorMax = Vector2.one;
|
||||||
|
barOutlineRect.offsetMin = Vector2.zero;
|
||||||
|
barOutlineRect.offsetMax = Vector2.zero;
|
||||||
|
|
||||||
|
// Progress bar fill image
|
||||||
_progressBarFillObj = new GameObject("ProgressBarFill");
|
_progressBarFillObj = new GameObject("ProgressBarFill");
|
||||||
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
|
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
|
||||||
_progressBarFillObj.layer = 5;
|
_progressBarFillObj.layer = 5;
|
||||||
|
|
||||||
var fillImg = _progressBarFillObj.AddComponent<Image>();
|
var fillImg = _progressBarFillObj.AddComponent<Image>();
|
||||||
fillImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f);
|
fillImg.sprite = ctx.Sprites.RoundedControl;
|
||||||
|
fillImg.type = Image.Type.Sliced;
|
||||||
|
fillImg.color = ctx.Theme.ObjectActiveBright;
|
||||||
|
|
||||||
_fillRect = _progressBarFillObj.GetComponent<RectTransform>();
|
_fillRect = _progressBarFillObj.GetComponent<RectTransform>();
|
||||||
_fillRect.anchorMin = new Vector2(0, 0);
|
_fillRect.anchorMin = new Vector2(0, 0);
|
||||||
@@ -317,20 +268,17 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
_fillRect.anchoredPosition = Vector2.zero;
|
_fillRect.anchoredPosition = Vector2.zero;
|
||||||
_fillRect.sizeDelta = Vector2.zero;
|
_fillRect.sizeDelta = Vector2.zero;
|
||||||
|
|
||||||
// 11. Create progress text
|
// Progress text
|
||||||
var progressTextObj = new GameObject("ProgressBarText");
|
var progressTextObj = new GameObject("ProgressBarText");
|
||||||
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
|
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
|
||||||
progressTextObj.layer = 5;
|
progressTextObj.layer = 5;
|
||||||
|
|
||||||
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
|
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
|
||||||
_progressText.fontSize = 16;
|
_progressText.fontSize = 13;
|
||||||
_progressText.font = font;
|
_progressText.font = ctx.Fonts.Regular;
|
||||||
_progressText.color = Color.white;
|
_progressText.color = Color.white;
|
||||||
_progressText.alignment = TextAlignmentOptions.Center;
|
_progressText.alignment = TextAlignmentOptions.Center;
|
||||||
|
_progressText.raycastTarget = false;
|
||||||
var progressTextOutline = progressTextObj.AddComponent<Outline>();
|
|
||||||
progressTextOutline.effectColor = new Color(0, 0, 0, 0.8f);
|
|
||||||
progressTextOutline.effectDistance = new Vector2(1, -1);
|
|
||||||
|
|
||||||
var progressTextRect = progressTextObj.GetComponent<RectTransform>();
|
var progressTextRect = progressTextObj.GetComponent<RectTransform>();
|
||||||
progressTextRect.anchorMin = Vector2.zero;
|
progressTextRect.anchorMin = Vector2.zero;
|
||||||
@@ -338,35 +286,28 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
progressTextRect.offsetMin = Vector2.zero;
|
progressTextRect.offsetMin = Vector2.zero;
|
||||||
progressTextRect.offsetMax = Vector2.zero;
|
progressTextRect.offsetMax = Vector2.zero;
|
||||||
|
|
||||||
// 12. Create status text
|
// 6. Status text
|
||||||
_statusTextObj = new GameObject("StatusText");
|
_statusTextObj = new GameObject("StatusText");
|
||||||
_statusTextObj.transform.SetParent(modalContainerObj.transform, false);
|
_statusTextObj.transform.SetParent(content, false);
|
||||||
_statusTextObj.layer = 5;
|
_statusTextObj.layer = 5;
|
||||||
_statusTextObj.SetActive(false);
|
_statusTextObj.SetActive(false);
|
||||||
|
|
||||||
|
var statusLE = _statusTextObj.AddComponent<LayoutElement>();
|
||||||
|
statusLE.preferredHeight = 22f;
|
||||||
|
statusLE.flexibleWidth = 1f;
|
||||||
|
|
||||||
_statusText = _statusTextObj.AddComponent<TextMeshProUGUI>();
|
_statusText = _statusTextObj.AddComponent<TextMeshProUGUI>();
|
||||||
_statusText.fontSize = 16;
|
_statusText.fontSize = 14;
|
||||||
_statusText.font = font;
|
_statusText.font = ctx.Fonts.Regular;
|
||||||
_statusText.color = new Color(0.8f, 0.8f, 0.8f, 1f);
|
_statusText.color = new Color(0.8f, 0.8f, 0.8f, 1f);
|
||||||
_statusText.alignment = TextAlignmentOptions.Center;
|
_statusText.alignment = TextAlignmentOptions.Center;
|
||||||
|
_statusText.raycastTarget = false;
|
||||||
|
|
||||||
var statusRect = _statusTextObj.GetComponent<RectTransform>();
|
var statusRect = _statusTextObj.GetComponent<RectTransform>();
|
||||||
statusRect.anchorMin = new Vector2(0, 0);
|
statusRect.anchorMin = Vector2.zero;
|
||||||
statusRect.anchorMax = new Vector2(1, 0);
|
statusRect.anchorMax = Vector2.one;
|
||||||
statusRect.pivot = new Vector2(0.5f, 0);
|
statusRect.offsetMin = Vector2.zero;
|
||||||
statusRect.anchoredPosition = new Vector2(0, 35);
|
statusRect.offsetMax = Vector2.zero;
|
||||||
statusRect.sizeDelta = new Vector2(0, 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor)
|
|
||||||
{
|
|
||||||
button.transition = Selectable.Transition.ColorTint;
|
|
||||||
var colors = button.colors;
|
|
||||||
colors.normalColor = normalColor;
|
|
||||||
colors.highlightedColor = hoverColor;
|
|
||||||
colors.pressedColor = pressedColor;
|
|
||||||
colors.disabledColor = new Color(0.3f, 0.3f, 0.3f, 1f);
|
|
||||||
button.colors = colors;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Update()
|
private void Update()
|
||||||
@@ -401,7 +342,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
string tempZipPath = "";
|
string tempZipPath = "";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
UpdateStatus("Starting download...", new Color(0.8f, 0.8f, 0.8f));
|
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_starting"), new Color(0.8f, 0.8f, 0.8f));
|
||||||
UpdateProgress(0f, "0%");
|
UpdateProgress(0f, "0%");
|
||||||
|
|
||||||
var userLibsDir = MelonEnvironment.UserLibsDirectory;
|
var userLibsDir = MelonEnvironment.UserLibsDirectory;
|
||||||
@@ -421,19 +362,19 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
var progressReporter = new Progress<float>(percent =>
|
var progressReporter = new Progress<float>(percent =>
|
||||||
{
|
{
|
||||||
UpdateProgress(percent, $"{percent * 100f:F1}%");
|
UpdateProgress(percent, $"{percent * 100f:F1}%");
|
||||||
UpdateStatus($"Downloading... ({(percent * 100f):F1}%)", new Color(0f, 0.8f, 1f));
|
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_downloading", $"{percent * 100f:F1}"), new Color(0f, 0.8f, 1f));
|
||||||
});
|
});
|
||||||
|
|
||||||
await DownloadFileAsync(DownloadUrl, tempZipPath, progressReporter, _cts.Token);
|
await DownloadFileAsync(DownloadUrl, tempZipPath, progressReporter, _cts.Token);
|
||||||
|
|
||||||
// Extract after download complete
|
// Extract after download complete
|
||||||
UpdateStatus("Extracting and copying library files...", new Color(0.9f, 0.9f, 0.2f));
|
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_extracting"), new Color(0.9f, 0.9f, 0.2f));
|
||||||
UpdateProgress(0.99f, "Extracting...");
|
UpdateProgress(0.99f, LocalizationManager.Get("ui.ffmpeg_download.progress_extracting"));
|
||||||
|
|
||||||
await ExtractDllsAsync(tempZipPath, userLibsDir);
|
await ExtractDllsAsync(tempZipPath, userLibsDir);
|
||||||
|
|
||||||
// Update completion status
|
// Update completion status
|
||||||
UpdateStatus("FFmpeg installation complete!", new Color(0.2f, 0.9f, 0.2f));
|
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_complete"), new Color(0.2f, 0.9f, 0.2f));
|
||||||
UpdateProgress(1f, "100%");
|
UpdateProgress(1f, "100%");
|
||||||
|
|
||||||
await Task.Delay(1000);
|
await Task.Delay(1000);
|
||||||
@@ -455,12 +396,12 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
// ignored
|
// ignored
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UpdateStatus("Download was cancelled.", new Color(0.9f, 0.5f, 0.2f));
|
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_cancelled"), new Color(0.9f, 0.5f, 0.2f));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Melon<RenderMod>.Logger.Error($"Error during FFmpeg download/installation: {ex}");
|
Melon<RenderMod>.Logger.Error($"Error during FFmpeg download/installation: {ex}");
|
||||||
UpdateStatus($"Installation failed: {ex.Message}", new Color(1f, 0.3f, 0.3f));
|
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_failed", ex.Message), new Color(1f, 0.3f, 0.3f));
|
||||||
|
|
||||||
// Reset buttons on failure
|
// Reset buttons on failure
|
||||||
ExecuteOnMainThread(() =>
|
ExecuteOnMainThread(() =>
|
||||||
@@ -470,7 +411,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
var downloadBtnText = _buttonContainerObj.transform.Find("DownloadButton/DownloadText")?.GetComponent<TextMeshProUGUI>();
|
var downloadBtnText = _buttonContainerObj.transform.Find("DownloadButton/DownloadText")?.GetComponent<TextMeshProUGUI>();
|
||||||
if (downloadBtnText)
|
if (downloadBtnText)
|
||||||
{
|
{
|
||||||
downloadBtnText.text = "Retry";
|
downloadBtnText.text = LocalizationManager.Get("ui.ffmpeg_download.btn_retry");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -643,6 +584,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
|
|
||||||
private void OnDestroy()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
|
_window?.Dispose();
|
||||||
|
_window = null;
|
||||||
_cts?.Dispose();
|
_cts?.Dispose();
|
||||||
if (Instance == this)
|
if (Instance == this)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using System;
|
||||||
|
using ADOFAIRenderer.Localization;
|
||||||
|
using TMPro;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Component that binds a TextMeshProUGUI component to a localization key or dynamic string provider,
|
||||||
|
/// automatically updating the displayed text whenever the active language changes.
|
||||||
|
/// </summary>
|
||||||
|
internal class LocalizedText : MonoBehaviour
|
||||||
|
{
|
||||||
|
private TextMeshProUGUI? _textComponent;
|
||||||
|
private string? _key;
|
||||||
|
private Func<string>? _dynamicProvider;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the localization key. Setting this will clear any dynamic provider.
|
||||||
|
/// </summary>
|
||||||
|
public string? Key
|
||||||
|
{
|
||||||
|
get => _key;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_key = value;
|
||||||
|
_dynamicProvider = null;
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets a dynamic string provider function.
|
||||||
|
/// </summary>
|
||||||
|
public void SetDynamic(Func<string> provider)
|
||||||
|
{
|
||||||
|
_dynamicProvider = provider;
|
||||||
|
_key = null;
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Awake()
|
||||||
|
{
|
||||||
|
EnsureComponent();
|
||||||
|
LocalizationManager.OnLanguageChanged += HandleLanguageChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEnable()
|
||||||
|
{
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDestroy()
|
||||||
|
{
|
||||||
|
LocalizationManager.OnLanguageChanged -= HandleLanguageChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleLanguageChanged(SystemLanguage language)
|
||||||
|
{
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureComponent()
|
||||||
|
{
|
||||||
|
if (_textComponent == null)
|
||||||
|
{
|
||||||
|
_textComponent = GetComponent<TextMeshProUGUI>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refreshes the text using either the dynamic provider or the static localization key.
|
||||||
|
/// </summary>
|
||||||
|
public void Refresh()
|
||||||
|
{
|
||||||
|
EnsureComponent();
|
||||||
|
if (_textComponent == null) return;
|
||||||
|
|
||||||
|
if (_dynamicProvider != null)
|
||||||
|
{
|
||||||
|
_textComponent.text = _dynamicProvider();
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(_key))
|
||||||
|
{
|
||||||
|
_textComponent.text = LocalizationManager.Get(_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds a TextMeshProUGUI component to a localization key.
|
||||||
|
/// </summary>
|
||||||
|
public static LocalizedText Bind(TextMeshProUGUI target, string key)
|
||||||
|
{
|
||||||
|
if (target == null) throw new ArgumentNullException(nameof(target));
|
||||||
|
var component = target.GetComponent<LocalizedText>() ?? target.gameObject.AddComponent<LocalizedText>();
|
||||||
|
component._textComponent = target;
|
||||||
|
component.Key = key;
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds a TextMeshProUGUI component to a dynamic string provider function.
|
||||||
|
/// </summary>
|
||||||
|
public static LocalizedText Bind(TextMeshProUGUI target, Func<string> provider)
|
||||||
|
{
|
||||||
|
if (target == null) throw new ArgumentNullException(nameof(target));
|
||||||
|
var component = target.GetComponent<LocalizedText>() ?? target.gameObject.AddComponent<LocalizedText>();
|
||||||
|
component._textComponent = target;
|
||||||
|
component.SetDynamic(provider);
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,8 +94,15 @@ internal class RendererUIManager : MonoBehaviour
|
|||||||
);
|
);
|
||||||
|
|
||||||
_windowManager = O5WindowManager.Create(_context, transform, "RendererMod_Canvas");
|
_windowManager = O5WindowManager.Create(_context, transform, "RendererMod_Canvas");
|
||||||
|
|
||||||
|
// The O5Kit canvas is created with sortingOrder 32767. It is lowered so that the dialogs of
|
||||||
|
// CommonWarningDialogUI (32766) are drawn above the windows of this manager.
|
||||||
|
var windowCanvas = transform.Find("RendererMod_Canvas")?.GetComponent<Canvas>();
|
||||||
|
if (windowCanvas != null) windowCanvas.sortingOrder = WindowCanvasSortingOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const int WindowCanvasSortingOrder = 32000;
|
||||||
|
|
||||||
private void Update() {
|
private void Update() {
|
||||||
O5Object.TickAll();
|
O5Object.TickAll();
|
||||||
Context.Tooltip.Tick();
|
Context.Tooltip.Tick();
|
||||||
|
|||||||
+492
-200
File diff suppressed because it is too large.
Load diff
@@ -1,100 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Util.Audio;
|
|
||||||
|
|
||||||
internal static class AudioResampler
|
|
||||||
{
|
|
||||||
public static float[] Resample(float[] inputSamples, int inputChannels, int inputFrequency, int targetChannels, int targetFrequency)
|
|
||||||
{
|
|
||||||
// ── Safety Validation ──
|
|
||||||
if (inputSamples.Length == 0 ||
|
|
||||||
inputChannels <= 0 || targetChannels <= 0 ||
|
|
||||||
inputFrequency <= 0 || targetFrequency <= 0)
|
|
||||||
{
|
|
||||||
return Array.Empty<float>();
|
|
||||||
}
|
|
||||||
|
|
||||||
// If format is identical, return original samples
|
|
||||||
if (inputFrequency == targetFrequency && inputChannels == targetChannels)
|
|
||||||
return inputSamples;
|
|
||||||
|
|
||||||
int inputLength = inputSamples.Length / inputChannels;
|
|
||||||
int targetLength = (int)Math.Floor((double)inputLength * targetFrequency / inputFrequency);
|
|
||||||
float[] outputSamples = new float[targetLength * targetChannels];
|
|
||||||
|
|
||||||
for (int i = 0; i < targetLength; i++)
|
|
||||||
{
|
|
||||||
double srcIndex = (double)i * inputFrequency / targetFrequency;
|
|
||||||
long idxLow = (long)Math.Floor(srcIndex);
|
|
||||||
long idxHigh = idxLow + 1;
|
|
||||||
float t = (float)(srcIndex - idxLow);
|
|
||||||
|
|
||||||
if (idxHigh >= inputLength) idxHigh = inputLength - 1;
|
|
||||||
if (idxLow >= inputLength) idxLow = inputLength - 1;
|
|
||||||
|
|
||||||
if (targetChannels == 1) // 1. Mono Output
|
|
||||||
{
|
|
||||||
float valLow, valHigh;
|
|
||||||
if (inputChannels == 1) // Mono -> Mono
|
|
||||||
{
|
|
||||||
valLow = inputSamples[idxLow];
|
|
||||||
valHigh = inputSamples[idxHigh];
|
|
||||||
}
|
|
||||||
else // Multi -> Mono (Downmix: Average all channels)
|
|
||||||
{
|
|
||||||
float sumLow = 0f;
|
|
||||||
float sumHigh = 0f;
|
|
||||||
for (int c = 0; c < inputChannels; c++)
|
|
||||||
{
|
|
||||||
sumLow += inputSamples[idxLow * inputChannels + c];
|
|
||||||
sumHigh += inputSamples[idxHigh * inputChannels + c];
|
|
||||||
}
|
|
||||||
valLow = sumLow / inputChannels;
|
|
||||||
valHigh = sumHigh / inputChannels;
|
|
||||||
}
|
|
||||||
outputSamples[i] = valLow + t * (valHigh - valLow);
|
|
||||||
}
|
|
||||||
else if (targetChannels == 2) // 2. Stereo Output
|
|
||||||
{
|
|
||||||
if (inputChannels == 1) // Mono -> Stereo (Duplicate to Left/Right)
|
|
||||||
{
|
|
||||||
float valLow = inputSamples[idxLow];
|
|
||||||
float valHigh = inputSamples[idxHigh];
|
|
||||||
float val = valLow + t * (valHigh - valLow);
|
|
||||||
outputSamples[i * 2] = val;
|
|
||||||
outputSamples[i * 2 + 1] = val;
|
|
||||||
}
|
|
||||||
else // Stereo/Multi -> Stereo (Map Left/Right, ignore extra channels)
|
|
||||||
{
|
|
||||||
float valLowL = inputSamples[idxLow * inputChannels];
|
|
||||||
float valHighL = inputSamples[idxHigh * inputChannels];
|
|
||||||
outputSamples[i * 2] = valLowL + t * (valHighL - valLowL);
|
|
||||||
|
|
||||||
float valLowR = inputSamples[idxLow * inputChannels + 1];
|
|
||||||
float valHighR = inputSamples[idxHigh * inputChannels + 1];
|
|
||||||
outputSamples[i * 2 + 1] = valLowR + t * (valHighR - valLowR);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else // 3. Multi-channel Output (e.g., 5.1 Surround)
|
|
||||||
{
|
|
||||||
for (int c = 0; c < targetChannels; c++)
|
|
||||||
{
|
|
||||||
float valLow, valHigh;
|
|
||||||
if (c < inputChannels)
|
|
||||||
{
|
|
||||||
valLow = inputSamples[idxLow * inputChannels + c];
|
|
||||||
valHigh = inputSamples[idxHigh * inputChannels + c];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
valLow = inputSamples[idxLow * inputChannels];
|
|
||||||
valHigh = inputSamples[idxHigh * inputChannels];
|
|
||||||
}
|
|
||||||
outputSamples[i * targetChannels + c] = valLow + t * (valHigh - valLow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return outputSamples;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,164 +1,188 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using ADOFAIRenderer.Core;
|
using ADOFAIRenderer.Core;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
using MelonLoader;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Util.Audio;
|
namespace ADOFAIRenderer.Util.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replacement for AudioSource.GetSpectrumData during rendering: the spectrum of the song as the song AudioSource would output it.
|
||||||
|
///
|
||||||
|
/// Signal: the song clip from an <see cref="AudioClipStore"/> at the output sample rate (AudioSettings.outputSampleRate),
|
||||||
|
/// played at conductor.song.pitch (linear interpolation between frames) and scaled by conductor.song.volume.
|
||||||
|
/// Unity analyzes the output signal of the source: bin spacing follows the output rate, pitch moves the frequencies,
|
||||||
|
/// and volume scales the magnitudes (measured with a Unity 6000.3.10f1 test project).
|
||||||
|
/// Channel: stereo clips use the requested channel (0 = left, 1 = right); a mono clip is scaled by <see cref="MonoGain"/>.
|
||||||
|
/// Window: twice the requested spectrum length in output samples, centered on the current render time.
|
||||||
|
///
|
||||||
|
/// The song source is shared with the <see cref="OfflineAudioMixer"/> store when its output rate matches; otherwise a separate
|
||||||
|
/// store is created. A cursor at the analysis position keeps the needed chunks loaded. Main thread only.
|
||||||
|
/// </summary>
|
||||||
internal static class AudioSpectrumGenerator
|
internal static class AudioSpectrumGenerator
|
||||||
{
|
{
|
||||||
// ReSharper disable MemberCanBePrivate.Global
|
/// <summary>
|
||||||
public static float[]? AudioClipData { get; private set; }
|
/// Gain of a mono clip relative to one channel of a stereo clip with the same samples.
|
||||||
public static int Channels { get; private set; }
|
/// Measured with a Unity 6000.3.10f1 test project: the spectrum of a mono clip is about 0.70 times that of each stereo channel (1/√2).
|
||||||
public static int Frequency { get; private set; }
|
/// </summary>
|
||||||
public static int TotalSamples { get; private set; }
|
private const double MonoGain = 0.70710678118654752;
|
||||||
// ReSharper restore MemberCanBePrivate.Global
|
|
||||||
|
private static AudioClipStore? _ownStore;
|
||||||
|
private static AudioClipStore? _store;
|
||||||
|
private static AudioClipSource? _source;
|
||||||
|
private static AudioCursor? _cursor;
|
||||||
|
private static ChunkView _view;
|
||||||
|
private static bool _loadErrorLogged;
|
||||||
|
|
||||||
private static long _cachedFrame = -1;
|
private static long _cachedFrame = -1;
|
||||||
private static readonly Dictionary<int, float[]> CachedSpectrums = new();
|
private static readonly Dictionary<(int Size, int Channel), float[]> CachedSpectrums = new();
|
||||||
|
|
||||||
public static void Initialize(AudioClip? clip)
|
/// <summary>Registers the song clip. Call after the <see cref="OfflineAudioMixer"/> is created (null when audio is not rendered).</summary>
|
||||||
{
|
public static void Initialize(AudioClip? clip, OfflineAudioMixer? mixer)
|
||||||
if (!clip)
|
|
||||||
{
|
{
|
||||||
Clear();
|
Clear();
|
||||||
return;
|
if (!clip) return;
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Channels = clip.channels;
|
var rate = AudioSettings.outputSampleRate;
|
||||||
Frequency = clip.frequency;
|
if (mixer != null && mixer.Store.OutputRate == rate)
|
||||||
int samplesCount = clip.samples;
|
|
||||||
|
|
||||||
float[] rawSamples = new float[samplesCount * Channels];
|
|
||||||
clip.GetData(rawSamples, 0);
|
|
||||||
|
|
||||||
if (Channels == 1)
|
|
||||||
{
|
{
|
||||||
AudioClipData = rawSamples;
|
_store = mixer.Store;
|
||||||
TotalSamples = samplesCount;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
AudioClipData = new float[samplesCount];
|
_ownStore = new AudioClipStore(rate);
|
||||||
TotalSamples = samplesCount;
|
_ownStore.Start();
|
||||||
for (int i = 0; i < samplesCount; i++)
|
_store = _ownStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
_source = AudioClipResolver.GetOrAdd(_store, clip!.name, clip);
|
||||||
|
if (_source == null)
|
||||||
{
|
{
|
||||||
float sum = 0f;
|
Melon<RenderMod>.Logger.Error($"[AudioSpectrumGenerator] Song clip '{clip.name}' could not be loaded, the spectrum will be empty.");
|
||||||
for (int c = 0; c < Channels; c++)
|
return;
|
||||||
{
|
|
||||||
sum += rawSamples[i * Channels + c];
|
|
||||||
}
|
}
|
||||||
AudioClipData[i] = sum / Channels;
|
_cursor = _source.AddCursor(0, false);
|
||||||
}
|
Melon<RenderMod>.Logger.Msg($"[AudioSpectrumGenerator] Song '{clip.name}': {_source.Channels} ch at {rate} Hz ({(_ownStore != null ? "own store" : "shared with the mixer")})");
|
||||||
}
|
|
||||||
MelonLoader.MelonLogger.Msg($"[AudioSpectrumGenerator] Cached audio clip: {clip.name}, Samples: {TotalSamples}, Frequency: {Frequency}Hz, Channels: {Channels}");
|
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
MelonLoader.MelonLogger.Error($"[AudioSpectrumGenerator] Failed to initialize audio cache: {e}");
|
Melon<RenderMod>.Logger.Error($"[AudioSpectrumGenerator] Failed to initialize: {e}");
|
||||||
Clear();
|
Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases the chunk, the cursor and the own store. Call before the mixer store is disposed.</summary>
|
||||||
public static void Clear()
|
public static void Clear()
|
||||||
{
|
{
|
||||||
AudioClipData = null;
|
if (_source != null)
|
||||||
TotalSamples = 0;
|
|
||||||
Channels = 0;
|
|
||||||
Frequency = 0;
|
|
||||||
lock (CachedSpectrums)
|
|
||||||
{
|
{
|
||||||
|
_source.Release(_view);
|
||||||
|
if (_cursor != null) _source.RemoveCursor(_cursor);
|
||||||
|
}
|
||||||
|
_view = default;
|
||||||
|
_cursor = null;
|
||||||
|
_source = null;
|
||||||
|
_store = null;
|
||||||
|
_ownStore?.Dispose();
|
||||||
|
_ownStore = null;
|
||||||
|
_loadErrorLogged = false;
|
||||||
_cachedFrame = -1;
|
_cachedFrame = -1;
|
||||||
CachedSpectrums.Clear();
|
CachedSpectrums.Clear();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public static void GetSpectrumData(float[] samples, int channel, FFTWindow window)
|
public static void GetSpectrumData(float[] samples, int channel, FFTWindow window)
|
||||||
{
|
{
|
||||||
if (AudioClipData == null || AudioClipData.Length == 0 || !scrConductor.instance || !scrConductor.instance.hasSongStarted || !scrConductor.instance.song)
|
|
||||||
{
|
|
||||||
Array.Clear(samples, 0, samples.Length);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var conductor = scrConductor.instance;
|
|
||||||
var pitch = conductor.song.pitch;
|
|
||||||
var separateCountdownOffset = conductor.separateCountdownTime ?
|
|
||||||
conductor.crotchetAtStart * conductor.adjustedCountdownTicks : 0.0;
|
|
||||||
|
|
||||||
// Calculate the actual scheduled start time of the song
|
|
||||||
var songScheduledStartTime = conductor.skipOffset ?
|
|
||||||
conductor.dspTimeSong + conductor.addoffset / pitch :
|
|
||||||
conductor.dspTimeSong + separateCountdownOffset / pitch;
|
|
||||||
|
|
||||||
if (conductor.dspTime < songScheduledStartTime)
|
|
||||||
{
|
|
||||||
Array.Clear(samples, 0, samples.Length);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var songTime = (conductor.dspTime - conductor.dspTimeSong) * pitch - separateCountdownOffset;
|
|
||||||
|
|
||||||
var n = samples.Length;
|
var n = samples.Length;
|
||||||
if (n == 0 || (n & (n - 1)) != 0) // Ensure it's a power of 2
|
var conductor = scrConductor.instance;
|
||||||
|
if (_source == null || _store == null || !conductor || !conductor!.hasSongStarted || !conductor.song
|
||||||
|
|| n == 0 || (n & (n - 1)) != 0)
|
||||||
{
|
{
|
||||||
Array.Clear(samples, 0, samples.Length);
|
Array.Clear(samples, 0, n);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentFrame = (RenderManager.Instance != null) ? RenderManager.Instance.frameCount : -1;
|
var render = RenderManager.Instance;
|
||||||
|
var currentFrame = render.frameCount;
|
||||||
lock (CachedSpectrums)
|
|
||||||
{
|
|
||||||
if (currentFrame != _cachedFrame)
|
if (currentFrame != _cachedFrame)
|
||||||
{
|
{
|
||||||
_cachedFrame = currentFrame;
|
_cachedFrame = currentFrame;
|
||||||
CachedSpectrums.Clear();
|
CachedSpectrums.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CachedSpectrums.TryGetValue(n, out var cached))
|
var sourceChannel = _source.Channels == 1 ? 0 : Math.Min(Math.Max(channel, 0), _source.Channels - 1);
|
||||||
|
if (CachedSpectrums.TryGetValue((n, sourceChannel), out var cached))
|
||||||
{
|
{
|
||||||
Array.Copy(cached, samples, n);
|
Array.Copy(cached, samples, n);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Song position (seconds of song time) at the current render time, as in scrConductor.StartMusicCo:
|
||||||
|
// the song starts at dspTimeSong + separate countdown (crotchetAtStart * adjustedCountdownTicks in song time).
|
||||||
|
double pitch = conductor.song.pitch;
|
||||||
|
double volume = conductor.song.volume;
|
||||||
|
var countdown = conductor.separateCountdownTime ? conductor.crotchetAtStart * conductor.adjustedCountdownTicks : 0.0;
|
||||||
|
var now = render.dspTimeBase + render.AudioTime;
|
||||||
|
var songSeconds = (now - conductor.dspTimeSong) * pitch - countdown;
|
||||||
|
|
||||||
var fftSize = 2 * n;
|
var fftSize = 2 * n;
|
||||||
var frequency = Frequency;
|
var gain = volume * (_source.Channels == 1 ? MonoGain : 1.0);
|
||||||
var totalSamples = TotalSamples;
|
var first = songSeconds * _store.OutputRate - n * pitch;
|
||||||
var currentSampleIndex = (long)(songTime * frequency);
|
|
||||||
|
|
||||||
var windowSamples = new float[fftSize];
|
var windowSamples = new float[fftSize];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_cursor!.Position = Math.Min(Math.Max((long)first, 0), _source.TotalFrames - 1);
|
||||||
for (var i = 0; i < fftSize; i++)
|
for (var i = 0; i < fftSize; i++)
|
||||||
{
|
{
|
||||||
var idx = currentSampleIndex - fftSize / 2 + i;
|
var position = first + i * pitch;
|
||||||
if (idx >= 0 && idx < totalSamples)
|
var index = (long)Math.Floor(position);
|
||||||
{
|
var fraction = position - index;
|
||||||
windowSamples[i] = AudioClipData[idx];
|
var s0 = ReadSample(index, sourceChannel);
|
||||||
|
var s1 = fraction > 0 ? ReadSample(index + 1, sourceChannel) : 0.0;
|
||||||
|
windowSamples[i] = (float)((s0 + (s1 - s0) * fraction) * gain);
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
windowSamples[i] = 0f;
|
|
||||||
}
|
}
|
||||||
|
catch (InvalidOperationException e)
|
||||||
|
{
|
||||||
|
if (!_loadErrorLogged)
|
||||||
|
{
|
||||||
|
_loadErrorLogged = true;
|
||||||
|
Melon<RenderMod>.Logger.Error($"[AudioSpectrumGenerator] Song samples are unavailable, the spectrum will be empty: {e.Message}");
|
||||||
|
}
|
||||||
|
Array.Clear(samples, 0, n);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var spectrum = new float[n];
|
var spectrum = new float[n];
|
||||||
CalculateFFT(windowSamples, spectrum);
|
CalculateFFT(windowSamples, spectrum);
|
||||||
|
CachedSpectrums[(n, sourceChannel)] = spectrum;
|
||||||
lock (CachedSpectrums)
|
|
||||||
{
|
|
||||||
CachedSpectrums[n] = spectrum;
|
|
||||||
Array.Copy(spectrum, samples, n);
|
Array.Copy(spectrum, samples, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Sample of source channel <paramref name="channel"/> at <paramref name="frame"/>; 0 outside the clip or when the source failed.</summary>
|
||||||
|
/// <exception cref="InvalidOperationException">Chunk loading failed fatally (see <see cref="AudioClipStore.Acquire"/>).</exception>
|
||||||
|
private static double ReadSample(long frame, int channel)
|
||||||
|
{
|
||||||
|
var source = _source!;
|
||||||
|
if (frame < 0 || frame >= source.TotalFrames) return 0.0;
|
||||||
|
if (!_view.IsValid || !_view.Contains(frame))
|
||||||
|
{
|
||||||
|
source.Release(_view);
|
||||||
|
_view = default;
|
||||||
|
_view = _store!.Acquire(source, frame);
|
||||||
|
if (!_view.IsValid) return 0.0;
|
||||||
|
}
|
||||||
|
return _view.Data[(int)(frame - _view.StartFrame) * _view.Channels + channel];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void CalculateFFT(float[] samples, float[] spectrum)
|
private static void CalculateFFT(float[] samples, float[] spectrum)
|
||||||
{
|
{
|
||||||
var n = samples.Length;
|
var n = samples.Length;
|
||||||
|
|
||||||
// Apply Blackman-Harris window
|
// Blackman-Harris window, periodic form: w(i) = a0 - a1 cos(2πi/n) + a2 cos(4πi/n) - a3 cos(6πi/n), i = 0 ~ n - 1
|
||||||
const float a0 = 0.35875f;
|
const float a0 = 0.35875f;
|
||||||
const float a1 = 0.48829f;
|
const float a1 = 0.48829f;
|
||||||
const float a2 = 0.14128f;
|
const float a2 = 0.14128f;
|
||||||
@@ -169,7 +193,7 @@ internal static class AudioSpectrumGenerator
|
|||||||
|
|
||||||
for (var i = 0; i < n; i++)
|
for (var i = 0; i < n; i++)
|
||||||
{
|
{
|
||||||
var angle = 2.0 * Math.PI * i / (n - 1);
|
var angle = 2.0 * Math.PI * i / n;
|
||||||
var w = (float)(a0 - a1 * Math.Cos(angle) + a2 * Math.Cos(2.0 * angle) - a3 * Math.Cos(3.0 * angle));
|
var w = (float)(a0 - a1 * Math.Cos(angle) + a2 * Math.Cos(2.0 * angle) - a3 * Math.Cos(3.0 * angle));
|
||||||
re[i] = samples[i] * w;
|
re[i] = samples[i] * w;
|
||||||
im[i] = 0f;
|
im[i] = 0f;
|
||||||
@@ -230,12 +254,13 @@ internal static class AudioSpectrumGenerator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize and fill spectrum
|
// Magnitude scale 2.5 / n (n = FFT size, twice the spectrum length), clamped to 1.0: the scale of Unity GetSpectrumData.
|
||||||
var factor = 2.0f / n;
|
// A sine of amplitude A centered on a bin gives A * a0 * 1.25 with the Blackman-Harris window.
|
||||||
|
var factor = 2.5f / n;
|
||||||
var outLen = Math.Min(spectrum.Length, n / 2);
|
var outLen = Math.Min(spectrum.Length, n / 2);
|
||||||
for (var i = 0; i < outLen; i++)
|
for (var i = 0; i < outLen; i++)
|
||||||
{
|
{
|
||||||
spectrum[i] = (float)Math.Sqrt(re[i] * re[i] + im[i] * im[i]) * factor;
|
spectrum[i] = Math.Min((float)Math.Sqrt(re[i] * re[i] + im[i] * im[i]) * factor, 1f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,11 +15,6 @@ internal class AudioSyncCalculator
|
|||||||
{
|
{
|
||||||
_sampleRate = sampleRate;
|
_sampleRate = sampleRate;
|
||||||
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
|
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
|
||||||
|
|
||||||
// NTSC
|
|
||||||
if (Math.Abs(fps - 29.97) < 0.001) _fpsRational = (30000, 1001);
|
|
||||||
if (Math.Abs(fps - 59.94) < 0.001) _fpsRational = (60000, 1001);
|
|
||||||
if (Math.Abs(fps - 23.976) < 0.001 || Math.Abs(fps - 23.98) < 0.001) _fpsRational = (24000, 1001);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Reset()
|
public void Reset()
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// File path of every external audio clip the game loads, keyed by the game's audioLib key.
|
||||||
|
///
|
||||||
|
/// The game loads external clips with AudioManager.FindOrLoadAudioClipExternal(path, ...) and stores them in audioLib under
|
||||||
|
/// Path.GetFileName(path) + "*external" (the same string becomes AudioClip.name). Recorded paths:
|
||||||
|
/// - Song: Path.Combine(Path.GetDirectoryName(levelPath), levelData.songFilename) (scnGame song loading).
|
||||||
|
/// - PlaySound event sound: Path.Combine(Path.GetDirectoryName(levelPath), hitsound) (scnGame.LoadSoundCo);
|
||||||
|
/// ffxPlaySound plays it as hitsound + "*external".
|
||||||
|
/// <see cref="Record"/> is called only when the game actually loads the file (the key is not in audioLib yet),
|
||||||
|
/// so the recorded path is the file of the clip audioLib holds under that key.
|
||||||
|
/// Thread-safe.
|
||||||
|
/// </summary>
|
||||||
|
internal static class AudioClipPaths
|
||||||
|
{
|
||||||
|
/// <summary>Suffix of the game's audioLib keys for external clips.</summary>
|
||||||
|
public const string ExternalSuffix = "*external";
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> Paths = new();
|
||||||
|
|
||||||
|
/// <summary>audioLib key of an external clip loaded from <paramref name="path"/>.</summary>
|
||||||
|
public static string KeyOf(string path) => Path.GetFileName(path) + ExternalSuffix;
|
||||||
|
|
||||||
|
/// <summary>Records <paramref name="path"/> under <see cref="KeyOf"/>(path), replacing an earlier path of the same key.</summary>
|
||||||
|
public static void Record(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path)) return;
|
||||||
|
lock (Paths)
|
||||||
|
{
|
||||||
|
Paths[KeyOf(path)] = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Recorded path for an audioLib key (or AudioClip.name) of an external clip.</summary>
|
||||||
|
public static bool TryGetPath(string key, out string path)
|
||||||
|
{
|
||||||
|
lock (Paths)
|
||||||
|
{
|
||||||
|
return Paths.TryGetValue(key, out path!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using MelonLoader;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the <see cref="AudioClipSource"/> for a clip the game plays, keyed by the game's audioLib key (also AudioClip.name):
|
||||||
|
/// - Key recorded in <see cref="AudioClipPaths"/> and the file exists: decoded from the file with <see cref="FFmpegPCMReader"/>
|
||||||
|
/// (mono MP3 through <see cref="MP3MonoLayoutReader"/>)
|
||||||
|
/// (song files and PlaySound files of custom levels).
|
||||||
|
/// - Otherwise, or when FFmpeg cannot open the file: the Unity clip is copied with <see cref="UnityClipLoader"/>
|
||||||
|
/// (built-in hit sounds from Resources, official/DLC level songs from Addressables).
|
||||||
|
/// Main thread only (the Unity copy calls AudioClip.GetData).
|
||||||
|
/// </summary>
|
||||||
|
internal static class AudioClipResolver
|
||||||
|
{
|
||||||
|
/// <param name="store">Store of the current render.</param>
|
||||||
|
/// <param name="key">audioLib key of the clip (for example "song.ogg*external" or "sndKick").</param>
|
||||||
|
/// <param name="clip">Loaded Unity clip for the key, used when the clip has no file; may be null when the key has a file.</param>
|
||||||
|
/// <returns>The source, or null when neither the file nor the clip can be read.</returns>
|
||||||
|
public static AudioClipSource? GetOrAdd(AudioClipStore store, string key, AudioClip? clip)
|
||||||
|
{
|
||||||
|
if (store.TryGet(key, out var existing)) return existing;
|
||||||
|
|
||||||
|
if (AudioClipPaths.TryGetPath(key, out var path) && File.Exists(path))
|
||||||
|
{
|
||||||
|
IPCMReader? fileReader = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fileReader = OpenFile(path);
|
||||||
|
if (fileReader is MP3MonoLayoutReader)
|
||||||
|
Melon<RenderMod>.Logger.Warning($"[AudioClipResolver] Mono MP3 '{path}' is rendered like the game: half speed, one octave lower.");
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Melon<RenderMod>.Logger.Warning($"[AudioClipResolver] FFmpeg could not open '{path}' for '{key}', using the Unity clip: {e.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileReader != null)
|
||||||
|
{
|
||||||
|
var source = store.GetOrAdd(key, () => fileReader, () => OpenFile(path));
|
||||||
|
if (source == null) fileReader.Dispose();
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clip) return store.GetOrAdd(key, () => null);
|
||||||
|
return store.GetOrAdd(key, () => UnityClipLoader.Load(clip!, store));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Opens <paramref name="path"/> with FFmpeg; mono MP3 files are wrapped in <see cref="MP3MonoLayoutReader"/> (game layout).</summary>
|
||||||
|
private static IPCMReader OpenFile(string path)
|
||||||
|
{
|
||||||
|
var reader = new FFmpegPCMReader(path);
|
||||||
|
if (!reader.IsMP3 || reader.Channels != 1) return reader;
|
||||||
|
return new MP3MonoLayoutReader(reader);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,613 @@
|
|||||||
|
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 is { IsForwardOnly: true })
|
||||||
|
{
|
||||||
|
reader.Dispose();
|
||||||
|
reader = null;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>Longest wait for the decoder thread to stop in <see cref="Dispose"/>, in milliseconds.</summary>
|
||||||
|
private const int DecoderStopTimeoutMs = 3000;
|
||||||
|
|
||||||
|
private readonly Dictionary<string, AudioClipSource?> _sources = new();
|
||||||
|
private readonly 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, additional decode streams (see <see cref="AudioClipSource.MaxDecodeStreams"/>) are opened on the
|
||||||
|
/// decoder thread with <paramref name="openAdditionalReader"/>, or with <paramref name="createReader"/> when it is null.
|
||||||
|
/// </summary>
|
||||||
|
public AudioClipSource? GetOrAdd(string key, Func<IPCMReader?> createReader, Func<IPCMReader?>? openAdditionalReader = null)
|
||||||
|
{
|
||||||
|
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 : openAdditionalReader ?? 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();
|
||||||
|
|
||||||
|
// A decoder thread blocked inside a read (for example a file on an unresponsive drive) is left running:
|
||||||
|
// it is a background thread, and its sources keep their readers so the blocked call does not touch freed FFmpeg contexts.
|
||||||
|
var decoderStopped = _decoderThread == null || _decoderThread.Join(DecoderStopTimeoutMs);
|
||||||
|
if (!decoderStopped)
|
||||||
|
Melon<RenderMod>.Logger.Warning($"[AudioClipStore] Decoder thread did not stop within {DecoderStopTimeoutMs} ms; its readers are left open.");
|
||||||
|
|
||||||
|
LogMemoryUsage();
|
||||||
|
|
||||||
|
lock (_sources)
|
||||||
|
{
|
||||||
|
_sourceList.Clear();
|
||||||
|
_sources.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoderStopped)
|
||||||
|
{
|
||||||
|
foreach (var source in sources)
|
||||||
|
source.DisposeInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_poolLock)
|
||||||
|
{
|
||||||
|
while (_chunkPool.Count > 0)
|
||||||
|
AddAllocated(-_chunkPool.Pop().LongLength * sizeof(float));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
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.
|
||||||
|
///
|
||||||
|
/// MP3: decoded with the game's timing (MP3Sharp, see <see cref="MP3HeaderFrame"/>): encoder delay and padding are kept
|
||||||
|
/// (AV_CODEC_FLAG2_SKIP_MANUAL), frame 0 is the first sample of the first audio frame, and one frame of silence is placed
|
||||||
|
/// before it when the file starts with a Xing/Info/VBRI header frame. <see cref="TotalFrames"/> is the sum of all packet
|
||||||
|
/// durations plus that silence, counted once when the file is opened.
|
||||||
|
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__decoding.html (send/receive API)
|
||||||
|
/// Reference: https://ffmpeg.org/doxygen/trunk/group__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 _ptsOrigin; // Stream timestamp of output frame _leadFrames
|
||||||
|
private readonly long _leadFrames; // Silent frames placed before the first decoded frame (MP3 header frame, see class summary)
|
||||||
|
|
||||||
|
// Decoded samples not yet returned: frames [_pendingStart, _pendingStart + _pendingCount) at _pending[_pendingOffset * Channels].
|
||||||
|
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>True when the stream is MP3 and is decoded with the game's MP3 timing (see class summary).</summary>
|
||||||
|
public bool IsMP3 { get; }
|
||||||
|
|
||||||
|
/// <summary>Number of demuxer seeks performed (for diagnostics).</summary>
|
||||||
|
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;
|
||||||
|
_ptsOrigin = 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;
|
||||||
|
IsMP3 = stream->codecpar->codec_id == AVCodecID.AV_CODEC_ID_MP3;
|
||||||
|
if (IsMP3)
|
||||||
|
{
|
||||||
|
// Keep the encoder delay and padding the decoder would otherwise trim (exported as side data instead).
|
||||||
|
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__core.html (AV_CODEC_FLAG2_SKIP_MANUAL)
|
||||||
|
_codec->flags2 |= ffmpeg.AV_CODEC_FLAG2_SKIP_MANUAL;
|
||||||
|
}
|
||||||
|
ret = ffmpeg.avcodec_open2(_codec, decoder, null);
|
||||||
|
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.");
|
||||||
|
|
||||||
|
if (IsMP3)
|
||||||
|
{
|
||||||
|
_leadFrames = MP3HeaderFrame.SilentFrames(path);
|
||||||
|
ScanPackets(out _ptsOrigin, out var decodedFrames);
|
||||||
|
TotalFrames = _leadFrames + decodedFrames;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads every packet of the audio stream once (no decoding) and returns the first packet's timestamp and the summed
|
||||||
|
/// packet durations in frames, then moves the demuxer back to the first packet. Used for MP3, where every packet decodes
|
||||||
|
/// to a full frame when trimming is off.
|
||||||
|
/// </summary>
|
||||||
|
private void ScanPackets(out long firstPts, out long frames)
|
||||||
|
{
|
||||||
|
firstPts = ffmpeg.AV_NOPTS_VALUE;
|
||||||
|
long durationTicks = 0;
|
||||||
|
var packet = ffmpeg.av_packet_alloc();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int ret;
|
||||||
|
while ((ret = ffmpeg.av_read_frame(_format, packet)) >= 0)
|
||||||
|
{
|
||||||
|
if (packet->stream_index == _streamIndex)
|
||||||
|
{
|
||||||
|
if (firstPts == ffmpeg.AV_NOPTS_VALUE && packet->pts != ffmpeg.AV_NOPTS_VALUE) firstPts = packet->pts;
|
||||||
|
durationTicks += packet->duration;
|
||||||
|
}
|
||||||
|
ffmpeg.av_packet_unref(packet);
|
||||||
|
}
|
||||||
|
if (ret != ffmpeg.AVERROR_EOF)
|
||||||
|
throw new InvalidOperationException($"Failed to read '{Path}': {ErrorString(ret)}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ffmpeg.av_packet_free(&packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstPts == ffmpeg.AV_NOPTS_VALUE) firstPts = 0;
|
||||||
|
frames = ffmpeg.av_rescale_q(durationTicks, _timeBase, new AVRational { num = 1, den = SampleRate });
|
||||||
|
|
||||||
|
var seek = ffmpeg.av_seek_frame(_format, _streamIndex, firstPts, ffmpeg.AVSEEK_FLAG_BACKWARD);
|
||||||
|
if (seek < 0)
|
||||||
|
throw new InvalidOperationException($"Failed to rewind '{Path}': {ErrorString(seek)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private long EstimateTotalFrames(AVStream* stream)
|
||||||
|
{
|
||||||
|
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 = _ptsOrigin + ffmpeg.av_rescale_q(Math.Max(0, frame - _leadFrames - marginFrames), new AVRational { num = 1, den = SampleRate }, _timeBase);
|
||||||
|
var ret = ffmpeg.av_seek_frame(_format, _streamIndex, targetTs, ffmpeg.AVSEEK_FLAG_BACKWARD);
|
||||||
|
if (ret < 0)
|
||||||
|
ret = ffmpeg.av_seek_frame(_format, _streamIndex, _ptsOrigin, 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
|
||||||
|
? _leadFrames + ffmpeg.av_rescale_q(pts - _ptsOrigin, _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})";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects the VBR/info header frame ("Xing", "Info" or "VBRI") at the start of an MP3 file.
|
||||||
|
///
|
||||||
|
/// The game decodes MP3 files with MP3Sharp (Assembly-CSharp-firstpass, MP3Sharp.MP3Stream), which has no header-frame handling:
|
||||||
|
/// it decodes that frame as one frame of silence and keeps the encoder delay. FFmpeg's MP3 demuxer consumes the header frame
|
||||||
|
/// without output. Measured with the clip harness (LAME 192k CBR, LAME VBR, 44.1/48 kHz, with and without ID3v2):
|
||||||
|
/// MP3Sharp output is FFmpeg's untrimmed output preceded by one frame of silence when the header frame exists,
|
||||||
|
/// and identical in timing when it does not.
|
||||||
|
///
|
||||||
|
/// Frame layout used here (ISO/IEC 11172-3 and 13818-3 frame header; Xing/Info tag after the Layer III side information,
|
||||||
|
/// VBRI tag 32 bytes after the frame header):
|
||||||
|
/// - Header: 11 sync bits, version (2 bits: 3 = MPEG-1, 2 = MPEG-2, 0 = MPEG-2.5), layer (2 bits: 1 = III, 2 = II, 3 = I),
|
||||||
|
/// protection bit (0 = 16-bit CRC follows), bitrate index, sample rate index, padding, private, channel mode (3 = mono).
|
||||||
|
/// - Layer III side information: MPEG-1 32 bytes (17 mono), MPEG-2/2.5 17 bytes (9 mono).
|
||||||
|
/// - Samples per frame: Layer I 384, Layer II 1152, Layer III 1152 (MPEG-1) or 576 (MPEG-2/2.5).
|
||||||
|
/// - ID3v2 tags before the first frame: "ID3", version (2 bytes), flags (bit 0x10 = 10-byte footer), size (4 bytes, 7 bits each).
|
||||||
|
/// </summary>
|
||||||
|
internal static class MP3HeaderFrame
|
||||||
|
{
|
||||||
|
/// <summary>Bytes searched for the first frame sync after the ID3v2 tags.</summary>
|
||||||
|
private const int SyncSearchBytes = 64 * 1024;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frames of silence MP3Sharp outputs for the header frame of <paramref name="path"/>:
|
||||||
|
/// the samples per frame of the first frame when it is a Xing/Info/VBRI header frame, otherwise 0.
|
||||||
|
/// </summary>
|
||||||
|
public static int SilentFrames(string path)
|
||||||
|
{
|
||||||
|
// The game keeps the song open while it streams it (MP3Sharp.MP3Stream: new FileStream(path, FileMode.Open),
|
||||||
|
// read/write access with read sharing), so the file is opened with read/write/delete sharing.
|
||||||
|
using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||||
|
return SilentFrames(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int SilentFrames(Stream stream)
|
||||||
|
{
|
||||||
|
var tag = new byte[10];
|
||||||
|
long offset = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
stream.Position = offset;
|
||||||
|
if (ReadFully(stream, tag, 10) < 10 || tag[0] != 'I' || tag[1] != 'D' || tag[2] != '3') break;
|
||||||
|
var size = (tag[6] & 0x7F) << 21 | (tag[7] & 0x7F) << 14 | (tag[8] & 0x7F) << 7 | (tag[9] & 0x7F);
|
||||||
|
offset += 10 + size + ((tag[5] & 0x10) != 0 ? 10 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.Position = offset;
|
||||||
|
var data = new byte[SyncSearchBytes];
|
||||||
|
var length = ReadFully(stream, data, data.Length);
|
||||||
|
for (var i = 0; i + 4 <= length; i++)
|
||||||
|
{
|
||||||
|
if (!TryParseHeader(data, i, out var samplesPerFrame, out var tagOffset)) continue;
|
||||||
|
|
||||||
|
// Xing/Info after the side information; VBRI at a fixed offset of 32 bytes after the 4-byte header.
|
||||||
|
if (HasTag(data, length, i + tagOffset, "Xing") || HasTag(data, length, i + tagOffset, "Info")
|
||||||
|
|| HasTag(data, length, i + 4 + 32, "VBRI"))
|
||||||
|
return samplesPerFrame;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseHeader(byte[] data, int i, out int samplesPerFrame, out int tagOffset)
|
||||||
|
{
|
||||||
|
samplesPerFrame = 0;
|
||||||
|
tagOffset = 0;
|
||||||
|
if (data[i] != 0xFF || (data[i + 1] & 0xE0) != 0xE0) return false;
|
||||||
|
|
||||||
|
var version = (data[i + 1] >> 3) & 0x3;
|
||||||
|
var layer = (data[i + 1] >> 1) & 0x3;
|
||||||
|
var hasCrc = (data[i + 1] & 0x1) == 0;
|
||||||
|
var bitrateIndex = (data[i + 2] >> 4) & 0xF;
|
||||||
|
var sampleRateIndex = (data[i + 2] >> 2) & 0x3;
|
||||||
|
var mono = ((data[i + 3] >> 6) & 0x3) == 3;
|
||||||
|
if (version == 1 || layer == 0 || bitrateIndex == 0xF || sampleRateIndex == 3) return false;
|
||||||
|
|
||||||
|
var mpeg1 = version == 3;
|
||||||
|
samplesPerFrame = layer switch
|
||||||
|
{
|
||||||
|
3 => 384,
|
||||||
|
2 => 1152,
|
||||||
|
_ => mpeg1 ? 1152 : 576
|
||||||
|
};
|
||||||
|
var sideInfo = layer == 1 ? (mpeg1 ? (mono ? 17 : 32) : (mono ? 9 : 17)) : 0;
|
||||||
|
tagOffset = 4 + (hasCrc ? 2 : 0) + sideInfo;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasTag(byte[] data, int length, int at, string tag)
|
||||||
|
{
|
||||||
|
if (at < 0 || at + tag.Length > length) return false;
|
||||||
|
for (var k = 0; k < tag.Length; k++)
|
||||||
|
if (data[at + k] != tag[k]) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadFully(Stream stream, byte[] buffer, int count)
|
||||||
|
{
|
||||||
|
var total = 0;
|
||||||
|
int read;
|
||||||
|
while (total < count && (read = stream.Read(buffer, total, count - total)) > 0) total += read;
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IPCMReader"/> that reproduces the game's output for mono MP3 files.
|
||||||
|
///
|
||||||
|
/// MP3Sharp (Assembly-CSharp-firstpass, MP3Sharp.Buffer16BitStereo) writes every frame in stereo layout and fills only the
|
||||||
|
/// channel 0 slot for mono streams, while MP3Stream.ChannelCount reports 1. The game creates a 1-channel clip from that data,
|
||||||
|
/// so a mono MP3 plays as [s0, 0, s1, 0, ...]: twice as long, at half speed and one octave lower
|
||||||
|
/// (confirmed in game with TestMaps/mp3_mono, 2026-10-13).
|
||||||
|
///
|
||||||
|
/// Output: 1 channel at the inner reader's sample rate. Frame 2k is inner frame k, frame 2k + 1 is 0.
|
||||||
|
/// <see cref="TotalFrames"/> = 2 * inner TotalFrames.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class MP3MonoLayoutReader : IPCMReader
|
||||||
|
{
|
||||||
|
private readonly IPCMReader _inner;
|
||||||
|
private float[] _innerBuffer = Array.Empty<float>();
|
||||||
|
|
||||||
|
/// <param name="inner">Mono reader of the MP3 file with the game's MP3 timing (<see cref="FFmpegPCMReader.IsMP3"/>). Owned by this reader.</param>
|
||||||
|
public MP3MonoLayoutReader(IPCMReader inner)
|
||||||
|
{
|
||||||
|
if (inner.Channels != 1) throw new ArgumentException("Inner reader must be mono.", nameof(inner));
|
||||||
|
_inner = inner;
|
||||||
|
Position = inner.Position * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Channels => 1;
|
||||||
|
public int SampleRate => _inner.SampleRate;
|
||||||
|
public long TotalFrames => _inner.TotalFrames * 2;
|
||||||
|
public bool IsForwardOnly => _inner.IsForwardOnly;
|
||||||
|
public long Position { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Moves to <paramref name="frame"/>; the inner reader moves to the inner frame of the next even position.</summary>
|
||||||
|
public void Seek(long frame)
|
||||||
|
{
|
||||||
|
_inner.Seek((frame + 1) / 2);
|
||||||
|
Position = frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Read(float[] buffer, int frameCount)
|
||||||
|
{
|
||||||
|
var limit = (int)Math.Min(frameCount, TotalFrames - Position);
|
||||||
|
var written = 0;
|
||||||
|
while (written < limit)
|
||||||
|
{
|
||||||
|
if ((Position & 1) == 1)
|
||||||
|
{
|
||||||
|
buffer[written++] = 0f;
|
||||||
|
Position++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var want = (limit - written + 1) / 2;
|
||||||
|
if (_innerBuffer.Length < want) _innerBuffer = new float[want];
|
||||||
|
var got = _inner.Read(_innerBuffer, want);
|
||||||
|
if (got == 0) break;
|
||||||
|
|
||||||
|
for (var i = 0; i < got && written < limit; i++)
|
||||||
|
{
|
||||||
|
buffer[written++] = _innerBuffer[i];
|
||||||
|
Position++;
|
||||||
|
if (written >= limit) break;
|
||||||
|
buffer[written++] = 0f;
|
||||||
|
Position++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _inner.Dispose();
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,14 @@ namespace ADOFAIRenderer.Util.Audio.Filter;
|
|||||||
|
|
||||||
internal class Gain : IAudioFilter
|
internal class Gain : IAudioFilter
|
||||||
{
|
{
|
||||||
public float Level = 0f;
|
public double Level = 0;
|
||||||
|
|
||||||
public void Initialize(int sampleRate, int channels) { }
|
public void Initialize(int sampleRate, int channels) { }
|
||||||
|
|
||||||
public float ProcessSample(float sample, int channel) =>
|
public double ProcessSample(double sample, int channel) =>
|
||||||
sample * (float)Math.Pow(10, Level / 20);
|
sample * Math.Pow(10, Level / 20);
|
||||||
|
|
||||||
public void ProcessBlock(Span<float> buffer)
|
public void ProcessBlock(Span<double> buffer)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < buffer.Length; i++)
|
for (var i = 0; i < buffer.Length; i++)
|
||||||
buffer[i] = ProcessSample(buffer[i], 0);
|
buffer[i] = ProcessSample(buffer[i], 0);
|
||||||
|
|||||||
@@ -8,19 +8,19 @@ internal class HardClip : IAudioFilter
|
|||||||
// ReSharper disable once MemberCanBePrivate.Global
|
// ReSharper disable once MemberCanBePrivate.Global
|
||||||
// ReSharper disable once FieldCanBeMadeReadOnly.Global
|
// ReSharper disable once FieldCanBeMadeReadOnly.Global
|
||||||
// ReSharper disable once ConvertToConstant.Global
|
// ReSharper disable once ConvertToConstant.Global
|
||||||
public float Threshold = 1.0f;
|
public double Threshold = 1.0;
|
||||||
|
|
||||||
public void Initialize(int sampleRate, int channels) { }
|
public void Initialize(int sampleRate, int channels) { }
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public float ProcessSample(float sample, int channel)
|
public double ProcessSample(double sample, int channel)
|
||||||
{
|
{
|
||||||
if (sample > Threshold) return Threshold;
|
if (sample > Threshold) return Threshold;
|
||||||
if (sample < -Threshold) return -Threshold;
|
if (sample < -Threshold) return -Threshold;
|
||||||
return sample;
|
return sample;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ProcessBlock(Span<float> buffer)
|
public void ProcessBlock(Span<double> buffer)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < buffer.Length; i++)
|
for (var i = 0; i < buffer.Length; i++)
|
||||||
buffer[i] = ProcessSample(buffer[i], 0);
|
buffer[i] = ProcessSample(buffer[i], 0);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ internal interface IAudioFilter
|
|||||||
{
|
{
|
||||||
void Initialize(int sampleRate, int channels);
|
void Initialize(int sampleRate, int channels);
|
||||||
|
|
||||||
float ProcessSample(float sample, int channel);
|
double ProcessSample(double sample, int channel);
|
||||||
void ProcessBlock(Span<float> buffer);
|
void ProcessBlock(Span<double> buffer);
|
||||||
|
|
||||||
void Reset();
|
void Reset();
|
||||||
|
|
||||||
|
|||||||
@@ -6,84 +6,84 @@ namespace ADOFAIRenderer.Util.Audio.Filter;
|
|||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
internal class RMSCompressor : IAudioFilter
|
internal class RMSCompressor : IAudioFilter
|
||||||
{
|
{
|
||||||
private float _thresholdDb = -20f;
|
private double _thresholdDb = -20;
|
||||||
private float _ratio = 4f;
|
private double _ratio = 4;
|
||||||
private float _attackTime = 0.010f; // in seconds
|
private double _attackTime = 0.010; // in seconds
|
||||||
private float _releaseTime = 0.100f; // in seconds
|
private double _releaseTime = 0.100; // in seconds
|
||||||
private float _kneeDb = 5f; // in dB
|
private double _kneeDb = 5; // in dB
|
||||||
private float _rmsWindowTime = 0.030f; // in seconds
|
private double _rmsWindowTime = 0.030; // in seconds
|
||||||
private float _lookAheadTime; // in seconds
|
private double _lookAheadTime; // in seconds
|
||||||
private int _lookAheadSamples;
|
private int _lookAheadSamples;
|
||||||
|
|
||||||
private int _sampleRate = 44100;
|
private int _sampleRate = 44100;
|
||||||
private int _channels = 2;
|
private int _channels = 2;
|
||||||
|
|
||||||
private float _attackCoef;
|
private double _attackCoef;
|
||||||
private float _releaseCoef;
|
private double _releaseCoef;
|
||||||
private float _rmsCoef;
|
private double _rmsCoef;
|
||||||
|
|
||||||
// Per-channel state
|
// Per-channel state
|
||||||
private float[] _rmsState = Array.Empty<float>(); // running mean-square (MS) state
|
private double[] _rmsState = Array.Empty<double>(); // running mean-square (MS) state
|
||||||
private float[] _gainState = Array.Empty<float>(); // smoothed gain reduction in dB
|
private double[] _gainState = Array.Empty<double>(); // smoothed gain reduction in dB
|
||||||
private float[][] _delayBuffers = Array.Empty<float[]>(); // circular delay buffers
|
private double[][] _delayBuffers = Array.Empty<double[]>(); // circular delay buffers
|
||||||
private int[] _delayWriteIndices = Array.Empty<int>();
|
private int[] _delayWriteIndices = Array.Empty<int>();
|
||||||
|
|
||||||
public float LookAheadTime
|
public double LookAheadTime
|
||||||
{
|
{
|
||||||
get => _lookAheadTime;
|
get => _lookAheadTime;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_lookAheadTime = Math.Max(0f, Math.Min(1.0f, value));
|
_lookAheadTime = Math.Max(0, Math.Min(1.0, value));
|
||||||
UpdateLookAheadSettings();
|
UpdateLookAheadSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public double Latency => _lookAheadTime;
|
public double Latency => _lookAheadTime;
|
||||||
|
|
||||||
public float ThresholdDb
|
public double ThresholdDb
|
||||||
{
|
{
|
||||||
get => _thresholdDb;
|
get => _thresholdDb;
|
||||||
set => _thresholdDb = value;
|
set => _thresholdDb = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public float Ratio
|
public double Ratio
|
||||||
{
|
{
|
||||||
get => _ratio;
|
get => _ratio;
|
||||||
set => _ratio = Math.Max(1f, value);
|
set => _ratio = Math.Max(1, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public float AttackTime
|
public double AttackTime
|
||||||
{
|
{
|
||||||
get => _attackTime;
|
get => _attackTime;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_attackTime = Math.Max(0.0001f, value);
|
_attackTime = Math.Max(0.0001, value);
|
||||||
UpdateAttackCoef();
|
UpdateAttackCoef();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public float ReleaseTime
|
public double ReleaseTime
|
||||||
{
|
{
|
||||||
get => _releaseTime;
|
get => _releaseTime;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_releaseTime = Math.Max(0.0001f, value);
|
_releaseTime = Math.Max(0.0001, value);
|
||||||
UpdateReleaseCoef();
|
UpdateReleaseCoef();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public float KneeDb
|
public double KneeDb
|
||||||
{
|
{
|
||||||
get => _kneeDb;
|
get => _kneeDb;
|
||||||
set => _kneeDb = Math.Max(0f, value);
|
set => _kneeDb = Math.Max(0, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public float RmsWindowTime
|
public double RmsWindowTime
|
||||||
{
|
{
|
||||||
get => _rmsWindowTime;
|
get => _rmsWindowTime;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_rmsWindowTime = Math.Max(0.0001f, value);
|
_rmsWindowTime = Math.Max(0.0001, value);
|
||||||
UpdateRmsCoef();
|
UpdateRmsCoef();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,8 +98,8 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
UpdateRmsCoef();
|
UpdateRmsCoef();
|
||||||
UpdateLookAheadSettings();
|
UpdateLookAheadSettings();
|
||||||
|
|
||||||
_rmsState = new float[channels];
|
_rmsState = new double[channels];
|
||||||
_gainState = new float[channels];
|
_gainState = new double[channels];
|
||||||
|
|
||||||
Reset();
|
Reset();
|
||||||
}
|
}
|
||||||
@@ -114,33 +114,33 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
{
|
{
|
||||||
if (_channels <= 0) return;
|
if (_channels <= 0) return;
|
||||||
|
|
||||||
_delayBuffers = new float[_channels][];
|
_delayBuffers = new double[_channels][];
|
||||||
_delayWriteIndices = new int[_channels];
|
_delayWriteIndices = new int[_channels];
|
||||||
|
|
||||||
for (int i = 0; i < _channels; i++)
|
for (int i = 0; i < _channels; i++)
|
||||||
{
|
{
|
||||||
_delayBuffers[i] = _lookAheadSamples > 0 ? new float[_lookAheadSamples] : Array.Empty<float>();
|
_delayBuffers[i] = _lookAheadSamples > 0 ? new double[_lookAheadSamples] : Array.Empty<double>();
|
||||||
_delayWriteIndices[i] = 0;
|
_delayWriteIndices[i] = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateAttackCoef()
|
private void UpdateAttackCoef()
|
||||||
{
|
{
|
||||||
_attackCoef = (float)Math.Exp(-1.0 / (_sampleRate * _attackTime));
|
_attackCoef = Math.Exp(-1.0 / (_sampleRate * _attackTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateReleaseCoef()
|
private void UpdateReleaseCoef()
|
||||||
{
|
{
|
||||||
_releaseCoef = (float)Math.Exp(-1.0 / (_sampleRate * _releaseTime));
|
_releaseCoef = Math.Exp(-1.0 / (_sampleRate * _releaseTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateRmsCoef()
|
private void UpdateRmsCoef()
|
||||||
{
|
{
|
||||||
_rmsCoef = (float)Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime));
|
_rmsCoef = Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public float ProcessSample(float sample, int channel)
|
public double ProcessSample(double sample, int channel)
|
||||||
{
|
{
|
||||||
if (channel < 0 || channel >= _channels)
|
if (channel < 0 || channel >= _channels)
|
||||||
{
|
{
|
||||||
@@ -149,54 +149,54 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
|
|
||||||
// Calculate running Mean-Square (MS) level
|
// Calculate running Mean-Square (MS) level
|
||||||
// v_c[n] = alpha * v_c[n-1] + (1 - alpha) * x^2[n]
|
// v_c[n] = alpha * v_c[n-1] + (1 - alpha) * x^2[n]
|
||||||
float sq = sample * sample;
|
double sq = sample * sample;
|
||||||
_rmsState[channel] = _rmsCoef * _rmsState[channel] + (1.0f - _rmsCoef) * sq;
|
_rmsState[channel] = _rmsCoef * _rmsState[channel] + (1.0 - _rmsCoef) * sq;
|
||||||
|
|
||||||
float rmsSq = Math.Max(0f, _rmsState[channel]);
|
double rmsSq = Math.Max(0, _rmsState[channel]);
|
||||||
// Convert to dB. 10 * log10(rmsSq) is equivalent to 20 * log10(sqrt(rmsSq))
|
// Convert to dB. 10 * log10(rmsSq) is equivalent to 20 * log10(sqrt(rmsSq))
|
||||||
float rmsDb = 10.0f * (float)Math.Log10(rmsSq + 1e-12f);
|
double rmsDb = 10.0 * Math.Log10(rmsSq + 1e-12);
|
||||||
|
|
||||||
// Calculate static envelope curve (gain reduction in dB)
|
// Calculate static envelope curve (gain reduction in dB)
|
||||||
float gStatic = 0f;
|
double gStatic = 0;
|
||||||
if (_kneeDb > 0f)
|
if (_kneeDb > 0)
|
||||||
{
|
{
|
||||||
if (rmsDb > _thresholdDb + _kneeDb * 0.5f)
|
if (rmsDb > _thresholdDb + _kneeDb * 0.5)
|
||||||
{
|
{
|
||||||
gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb);
|
gStatic = (1 / _ratio - 1) * (rmsDb - _thresholdDb);
|
||||||
}
|
}
|
||||||
else if (rmsDb >= _thresholdDb - _kneeDb * 0.5f)
|
else if (rmsDb >= _thresholdDb - _kneeDb * 0.5)
|
||||||
{
|
{
|
||||||
float diff = rmsDb - _thresholdDb + _kneeDb * 0.5f;
|
double diff = rmsDb - _thresholdDb + _kneeDb * 0.5;
|
||||||
gStatic = (1f / _ratio - 1f) * diff * diff / (2f * _kneeDb);
|
gStatic = (1 / _ratio - 1) * diff * diff / (2 * _kneeDb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (rmsDb > _thresholdDb)
|
if (rmsDb > _thresholdDb)
|
||||||
{
|
{
|
||||||
gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb);
|
gStatic = (1 / _ratio - 1) * (rmsDb - _thresholdDb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply ballistics (attack and release) on the dB gain reduction
|
// Apply ballistics (attack and release) on the dB gain reduction
|
||||||
float lastGain = _gainState[channel];
|
double lastGain = _gainState[channel];
|
||||||
float targetGain = gStatic;
|
double targetGain = gStatic;
|
||||||
|
|
||||||
// Note: Since targetGain and lastGain are <= 0 (gain reduction in dB),
|
// Note: Since targetGain and lastGain are <= 0 (gain reduction in dB),
|
||||||
// targetGain < lastGain means target gain reduction is larger (more negative),
|
// targetGain < lastGain means target gain reduction is larger (more negative),
|
||||||
// so we are in the attack phase.
|
// so we are in the attack phase.
|
||||||
float coef = targetGain < lastGain ? _attackCoef : _releaseCoef;
|
double coef = targetGain < lastGain ? _attackCoef : _releaseCoef;
|
||||||
float currentGain = coef * lastGain + (1f - coef) * targetGain;
|
double currentGain = coef * lastGain + (1 - coef) * targetGain;
|
||||||
_gainState[channel] = currentGain;
|
_gainState[channel] = currentGain;
|
||||||
|
|
||||||
// Convert back to linear gain multiplier
|
// Convert back to linear gain multiplier
|
||||||
float linearGain = (float)Math.Pow(10.0, currentGain / 20.0);
|
double linearGain = Math.Pow(10.0, currentGain / 20.0);
|
||||||
|
|
||||||
if (_lookAheadSamples > 0 && _delayBuffers.Length > channel && _delayBuffers[channel].Length > 0)
|
if (_lookAheadSamples > 0 && _delayBuffers.Length > channel && _delayBuffers[channel].Length > 0)
|
||||||
{
|
{
|
||||||
var buffer = _delayBuffers[channel];
|
var buffer = _delayBuffers[channel];
|
||||||
int writeIdx = _delayWriteIndices[channel];
|
int writeIdx = _delayWriteIndices[channel];
|
||||||
float delayedSample = buffer[writeIdx];
|
double delayedSample = buffer[writeIdx];
|
||||||
buffer[writeIdx] = sample;
|
buffer[writeIdx] = sample;
|
||||||
_delayWriteIndices[channel] = (writeIdx + 1) % _lookAheadSamples;
|
_delayWriteIndices[channel] = (writeIdx + 1) % _lookAheadSamples;
|
||||||
return delayedSample * linearGain;
|
return delayedSample * linearGain;
|
||||||
@@ -205,7 +205,7 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
return sample * linearGain;
|
return sample * linearGain;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ProcessBlock(Span<float> buffer)
|
public void ProcessBlock(Span<double> buffer)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < buffer.Length; i++)
|
for (var i = 0; i < buffer.Length; i++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Mixer;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One playing sound of a <see cref="VoiceMixer"/> (the song, or one effect sound), created by <see cref="VoiceMixer.Play"/>.
|
||||||
|
///
|
||||||
|
/// Main thread: the Set methods queue a change that takes effect at a given time (seconds relative to the render start,
|
||||||
|
/// the time at which the game made the change). Changes of one voice must be queued in non-decreasing time order.
|
||||||
|
/// Render thread: <see cref="VoiceMixer.Mix"/> applies each change at sample round(time * OutputRate).
|
||||||
|
///
|
||||||
|
/// Playback model (output samples n, clip positions in frames of the source at the output sample rate):
|
||||||
|
/// - The voice is silent before its start sample and from its end sample on.
|
||||||
|
/// - Clip position at sample n = anchor position + (n - anchor sample) * pitch. A position or pitch change sets a new anchor
|
||||||
|
/// at the sample where it takes effect, so a pitch change keeps the position continuous.
|
||||||
|
/// - Looping: the position wraps at the clip length (<see cref="AudioClipSource.Duration"/> * output rate, fractional).
|
||||||
|
/// Not looping: the voice ends when the position reaches <see cref="AudioClipSource.TotalFrames"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class Voice
|
||||||
|
{
|
||||||
|
internal enum ChangeKind
|
||||||
|
{
|
||||||
|
Pitch,
|
||||||
|
Volume,
|
||||||
|
Loop,
|
||||||
|
Position,
|
||||||
|
EndTime,
|
||||||
|
Stop
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly struct Change
|
||||||
|
{
|
||||||
|
public readonly ChangeKind Kind;
|
||||||
|
public readonly long Sample;
|
||||||
|
public readonly double Value;
|
||||||
|
|
||||||
|
public Change(ChangeKind kind, long sample, double value)
|
||||||
|
{
|
||||||
|
Kind = kind;
|
||||||
|
Sample = sample;
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly VoiceMixer _mixer;
|
||||||
|
|
||||||
|
// ── Render thread state ──
|
||||||
|
|
||||||
|
/// <summary>Changes not yet applied, in time order. Render thread only.</summary>
|
||||||
|
internal readonly Queue<Change> Pending = new();
|
||||||
|
|
||||||
|
/// <summary>First output sample of playback.</summary>
|
||||||
|
internal long StartSample;
|
||||||
|
|
||||||
|
/// <summary>Output sample at which playback stops (exclusive); long.MaxValue when not set.</summary>
|
||||||
|
internal long EndSample = long.MaxValue;
|
||||||
|
|
||||||
|
internal long AnchorSample;
|
||||||
|
internal double AnchorPosition;
|
||||||
|
internal double Pitch;
|
||||||
|
internal double Volume;
|
||||||
|
internal bool Loop;
|
||||||
|
internal bool Finished;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Playback has ended and the voice was released by the render thread. Readable from any thread;
|
||||||
|
/// the value can lag behind the render thread.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsFinished => Volatile.Read(ref Finished);
|
||||||
|
|
||||||
|
/// <summary>Loop length in frames at the output sample rate (source duration * output rate).</summary>
|
||||||
|
internal double Period;
|
||||||
|
|
||||||
|
internal AudioCursor? Cursor;
|
||||||
|
internal ChunkView View;
|
||||||
|
|
||||||
|
internal Voice(VoiceMixer mixer, AudioClipSource? source, string name)
|
||||||
|
{
|
||||||
|
_mixer = mixer;
|
||||||
|
Source = source;
|
||||||
|
Name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clip played by this voice; null plays silence (the clip could not be loaded).</summary>
|
||||||
|
public AudioClipSource? Source { get; }
|
||||||
|
|
||||||
|
/// <summary>Name for logs (the game's clip key).</summary>
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>Sets the playback rate (1 = original speed). Negative values are treated as 0.</summary>
|
||||||
|
public void SetPitch(double pitch, double time) => _mixer.Enqueue(this, ChangeKind.Pitch, time, Math.Max(0.0, pitch));
|
||||||
|
|
||||||
|
/// <summary>Sets the linear gain applied to the clip samples.</summary>
|
||||||
|
public void SetVolume(double volume, double time) => _mixer.Enqueue(this, ChangeKind.Volume, time, volume);
|
||||||
|
|
||||||
|
/// <summary>Turns looping on or off. Turning it off lets the current repetition play to the end of the clip.</summary>
|
||||||
|
public void SetLoop(bool loop, double time) => _mixer.Enqueue(this, ChangeKind.Loop, time, loop ? 1.0 : 0.0);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves playback to <paramref name="clipSeconds"/> seconds into the clip.
|
||||||
|
/// Before the voice starts, this sets the position playback starts from.
|
||||||
|
/// </summary>
|
||||||
|
public void SetPosition(double clipSeconds, double time) => _mixer.Enqueue(this, ChangeKind.Position, time, Math.Max(0.0, clipSeconds));
|
||||||
|
|
||||||
|
/// <summary>Stops playback at <paramref name="endTime"/> (seconds relative to the render start).</summary>
|
||||||
|
public void SetEndTime(double endTime, double time) => _mixer.Enqueue(this, ChangeKind.EndTime, time, endTime);
|
||||||
|
|
||||||
|
/// <summary>Stops playback at <paramref name="time"/>.</summary>
|
||||||
|
public void Stop(double time) => _mixer.Enqueue(this, ChangeKind.Stop, time, 0.0);
|
||||||
|
|
||||||
|
/// <summary>Unwrapped clip position at output sample <paramref name="sample"/> (sample at or after the anchor).</summary>
|
||||||
|
internal double PositionAt(long sample) => AnchorPosition + (sample - AnchorSample) * Pitch;
|
||||||
|
|
||||||
|
/// <summary>Clip position at <paramref name="sample"/>, wrapped into the loop when looping.</summary>
|
||||||
|
internal double WrappedPositionAt(long sample)
|
||||||
|
{
|
||||||
|
var position = PositionAt(sample);
|
||||||
|
if (Loop && Period > 0 && position >= Period) position %= Period;
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies <paramref name="change"/> at output sample <paramref name="sample"/> (its own sample, or a later one when the change
|
||||||
|
/// arrived after its sample was mixed). Before the start sample, changes replace the starting state. Render thread only.
|
||||||
|
/// </summary>
|
||||||
|
internal void Apply(in Change change, long sample, int outputRate)
|
||||||
|
{
|
||||||
|
var at = Math.Max(sample, StartSample);
|
||||||
|
switch (change.Kind)
|
||||||
|
{
|
||||||
|
case ChangeKind.Pitch:
|
||||||
|
Reanchor(at);
|
||||||
|
Pitch = change.Value;
|
||||||
|
break;
|
||||||
|
case ChangeKind.Volume:
|
||||||
|
Volume = change.Value;
|
||||||
|
break;
|
||||||
|
case ChangeKind.Loop:
|
||||||
|
Reanchor(at);
|
||||||
|
Loop = change.Value != 0.0;
|
||||||
|
break;
|
||||||
|
case ChangeKind.Position:
|
||||||
|
AnchorSample = at;
|
||||||
|
AnchorPosition = change.Value * outputRate;
|
||||||
|
break;
|
||||||
|
case ChangeKind.EndTime:
|
||||||
|
EndSample = VoiceMixer.ToSample(change.Value, outputRate);
|
||||||
|
break;
|
||||||
|
case ChangeKind.Stop:
|
||||||
|
EndSample = Math.Min(EndSample, sample);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Reanchor(long sample)
|
||||||
|
{
|
||||||
|
AnchorPosition = WrappedPositionAt(sample);
|
||||||
|
AnchorSample = sample;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio.Mixer;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mixes every <see cref="Voice"/> into interleaved double samples at the output sample rate and channel count.
|
||||||
|
///
|
||||||
|
/// Time: output sample n is at n / OutputRate seconds relative to the render start (the same time base as the times passed
|
||||||
|
/// to <see cref="Play"/> and the Voice Set methods). A time t maps to sample round(t * OutputRate).
|
||||||
|
///
|
||||||
|
/// Threads:
|
||||||
|
/// - Main thread: <see cref="Play"/> and the Voice Set methods queue commands under a lock. Commands must be queued in
|
||||||
|
/// non-decreasing time order per voice.
|
||||||
|
/// - Render thread: <see cref="Mix"/> takes the queued commands and mixes a block of samples. A command takes effect at its sample;
|
||||||
|
/// a command whose sample is before the block being mixed takes effect at the start of that block.
|
||||||
|
/// Chunks are read through <see cref="AudioClipStore.Acquire"/>, which waits for the decoder thread.
|
||||||
|
///
|
||||||
|
/// Sample reading: clip positions are in frames of the source at the output rate (sources are resampled when loaded).
|
||||||
|
/// An integer position copies the stored sample; a fractional position interpolates linearly between the frame and the next one
|
||||||
|
/// (the next frame of a looping voice after the last frame is frame 0; after the end of a non-looping clip it is 0).
|
||||||
|
/// Channel mapping follows <see cref="ChunkView.GetSample"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class VoiceMixer : IDisposable
|
||||||
|
{
|
||||||
|
private enum CommandKind
|
||||||
|
{
|
||||||
|
Play,
|
||||||
|
Change
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly struct Command
|
||||||
|
{
|
||||||
|
public readonly CommandKind Kind;
|
||||||
|
public readonly Voice Voice;
|
||||||
|
public readonly Voice.Change Change;
|
||||||
|
|
||||||
|
public Command(CommandKind kind, Voice voice, Voice.Change change)
|
||||||
|
{
|
||||||
|
Kind = kind;
|
||||||
|
Voice = voice;
|
||||||
|
Change = change;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly AudioClipStore _store;
|
||||||
|
private readonly CancellationToken _cancellationToken;
|
||||||
|
|
||||||
|
private readonly object _commandLock = new();
|
||||||
|
private List<Command> _commands = new();
|
||||||
|
private List<Command> _takenCommands = new();
|
||||||
|
|
||||||
|
// Render thread only.
|
||||||
|
private readonly List<Voice> _voices = new();
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <param name="store">Store the voices' sources belong to; its OutputRate is the mixer's sample rate.</param>
|
||||||
|
/// <param name="outputChannels">Interleaved channel count of the mixed output.</param>
|
||||||
|
/// <param name="cancellationToken">Cancels waits for chunks in <see cref="Mix"/>.</param>
|
||||||
|
public VoiceMixer(AudioClipStore store, int outputChannels, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (outputChannels < 1) throw new ArgumentOutOfRangeException(nameof(outputChannels));
|
||||||
|
_store = store;
|
||||||
|
OutputRate = store.OutputRate;
|
||||||
|
OutputChannels = outputChannels;
|
||||||
|
_cancellationToken = cancellationToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int OutputRate { get; }
|
||||||
|
|
||||||
|
public int OutputChannels { get; }
|
||||||
|
|
||||||
|
/// <summary>Voices currently mixed (started or waiting to start). Render thread only.</summary>
|
||||||
|
public int ActiveVoiceCount => _voices.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Output sample containing <paramref name="seconds"/> (seconds × rate rounded down), the sample at which
|
||||||
|
/// AudioSource.PlayScheduled starts a sound scheduled between two output samples.
|
||||||
|
/// </summary>
|
||||||
|
public static long ToSample(double seconds, int rate) => (long)Math.Floor(seconds * rate);
|
||||||
|
|
||||||
|
// ── Main thread ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schedules <paramref name="source"/> to start at <paramref name="startTime"/>.
|
||||||
|
/// A start time before <paramref name="time"/> (the current time) starts the voice at <paramref name="time"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="source">Clip to play; null plays silence.</param>
|
||||||
|
/// <param name="name">Name for logs.</param>
|
||||||
|
/// <param name="startTime">Start of playback, seconds relative to the render start.</param>
|
||||||
|
/// <param name="volume">Linear gain.</param>
|
||||||
|
/// <param name="pitch">Playback rate (1 = original speed); negative values are treated as 0.</param>
|
||||||
|
/// <param name="loop">Repeats the clip until the voice is stopped.</param>
|
||||||
|
/// <param name="time">Current time, seconds relative to the render start.</param>
|
||||||
|
public Voice Play(AudioClipSource? source, string name, double startTime, double volume, double pitch, bool loop, double time)
|
||||||
|
{
|
||||||
|
var voice = new Voice(this, source, name)
|
||||||
|
{
|
||||||
|
StartSample = Math.Max(ToSample(startTime, OutputRate), ToSample(time, OutputRate)),
|
||||||
|
Volume = volume,
|
||||||
|
Pitch = Math.Max(0.0, pitch),
|
||||||
|
Loop = loop,
|
||||||
|
Period = source != null ? source.Duration * OutputRate : 0.0
|
||||||
|
};
|
||||||
|
voice.AnchorSample = voice.StartSample;
|
||||||
|
|
||||||
|
lock (_commandLock)
|
||||||
|
{
|
||||||
|
_commands.Add(new Command(CommandKind.Play, voice, default));
|
||||||
|
}
|
||||||
|
return voice;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Enqueue(Voice voice, Voice.ChangeKind kind, double time, double value)
|
||||||
|
{
|
||||||
|
var change = new Voice.Change(kind, ToSample(time, OutputRate), value);
|
||||||
|
lock (_commandLock)
|
||||||
|
{
|
||||||
|
_commands.Add(new Command(CommandKind.Change, voice, change));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render thread ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the mix of output samples [<paramref name="firstSample"/>, firstSample + frames) to <paramref name="destination"/>
|
||||||
|
/// (interleaved, frames = destination.Length / OutputChannels). Blocks are expected in increasing sample order.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">Chunk loading failed fatally (see <see cref="AudioClipStore.Acquire"/>).</exception>
|
||||||
|
/// <exception cref="OperationCanceledException">The cancellation token was canceled while waiting for a chunk.</exception>
|
||||||
|
public void Mix(long firstSample, Span<double> destination)
|
||||||
|
{
|
||||||
|
if (_disposed) throw new ObjectDisposedException(nameof(VoiceMixer));
|
||||||
|
var frames = destination.Length / OutputChannels;
|
||||||
|
var endSample = firstSample + frames;
|
||||||
|
|
||||||
|
TakeCommands();
|
||||||
|
|
||||||
|
for (var i = _voices.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var voice = _voices[i];
|
||||||
|
var sample = firstSample;
|
||||||
|
while (!voice.Finished)
|
||||||
|
{
|
||||||
|
var segmentEnd = endSample;
|
||||||
|
if (voice.Pending.Count > 0)
|
||||||
|
{
|
||||||
|
var next = voice.Pending.Peek();
|
||||||
|
if (next.Sample <= sample)
|
||||||
|
{
|
||||||
|
voice.Apply(voice.Pending.Dequeue(), sample, OutputRate);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
segmentEnd = Math.Min(segmentEnd, next.Sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segmentEnd > sample)
|
||||||
|
MixSegment(voice, sample, segmentEnd, destination.Slice((int)(sample - firstSample) * OutputChannels));
|
||||||
|
sample = segmentEnd;
|
||||||
|
if (sample >= endSample) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!voice.Finished && endSample >= voice.EndSample)
|
||||||
|
voice.Finished = true;
|
||||||
|
if (voice.Finished)
|
||||||
|
{
|
||||||
|
ReleaseVoice(voice);
|
||||||
|
_voices.RemoveAt(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TakeCommands()
|
||||||
|
{
|
||||||
|
lock (_commandLock)
|
||||||
|
{
|
||||||
|
(_commands, _takenCommands) = (_takenCommands, _commands);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var command in _takenCommands)
|
||||||
|
{
|
||||||
|
var voice = command.Voice;
|
||||||
|
if (command.Kind == CommandKind.Play)
|
||||||
|
{
|
||||||
|
if (voice.Source is { IsFailed: false } source)
|
||||||
|
voice.Cursor = source.AddCursor(0, voice.Loop);
|
||||||
|
_voices.Add(voice);
|
||||||
|
}
|
||||||
|
else if (!voice.Finished)
|
||||||
|
{
|
||||||
|
voice.Pending.Enqueue(command.Change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_takenCommands.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mixes <paramref name="voice"/> over samples [from, to) with its current state into <paramref name="destination"/> (starting at sample from).</summary>
|
||||||
|
private void MixSegment(Voice voice, long from, long to, Span<double> destination)
|
||||||
|
{
|
||||||
|
var start = Math.Max(from, voice.StartSample);
|
||||||
|
var end = Math.Min(to, voice.EndSample);
|
||||||
|
if (start >= end) return;
|
||||||
|
|
||||||
|
var source = voice.Source;
|
||||||
|
if (source == null || source.IsFailed)
|
||||||
|
{
|
||||||
|
voice.Finished = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalFrames = source.TotalFrames;
|
||||||
|
if (voice.Volume == 0.0)
|
||||||
|
{
|
||||||
|
// Nothing to add; a non-looping voice still ends at the end of the clip.
|
||||||
|
if (!voice.Loop && voice.PositionAt(end - 1) >= totalFrames) voice.Finished = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var channels = OutputChannels;
|
||||||
|
var volume = voice.Volume;
|
||||||
|
var offset = (int)(start - from) * channels;
|
||||||
|
|
||||||
|
UpdateCursor(voice, (long)voice.WrappedPositionAt(start));
|
||||||
|
|
||||||
|
for (var n = start; n < end; n++, offset += channels)
|
||||||
|
{
|
||||||
|
var position = voice.WrappedPositionAt(n);
|
||||||
|
if (position >= totalFrames)
|
||||||
|
{
|
||||||
|
if (!voice.Loop)
|
||||||
|
{
|
||||||
|
voice.Finished = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
position = totalFrames - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var index = (long)position;
|
||||||
|
var fraction = position - index;
|
||||||
|
|
||||||
|
if (!EnsureView(voice, index)) return;
|
||||||
|
var view = voice.View;
|
||||||
|
|
||||||
|
if (fraction == 0.0)
|
||||||
|
{
|
||||||
|
for (var c = 0; c < channels; c++)
|
||||||
|
destination[offset + c] += view.GetSample(index, c, channels) * volume;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextIndex = index + 1;
|
||||||
|
var nextInView = view.Contains(nextIndex);
|
||||||
|
for (var c = 0; c < channels; c++)
|
||||||
|
{
|
||||||
|
double low = view.GetSample(index, c, channels);
|
||||||
|
double high = nextInView ? view.GetSample(nextIndex, c, channels) : ReadOutside(voice, nextIndex, c);
|
||||||
|
destination[offset + c] += (low + fraction * (high - low)) * volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sample of frame <paramref name="frame"/> outside the voice's current view (next chunk, loop start, or past the end).</summary>
|
||||||
|
private double ReadOutside(Voice voice, long frame, int channel)
|
||||||
|
{
|
||||||
|
var source = voice.Source!;
|
||||||
|
if (frame >= source.TotalFrames)
|
||||||
|
{
|
||||||
|
if (!voice.Loop) return 0.0;
|
||||||
|
frame = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var view = _store.Acquire(source, frame, _cancellationToken);
|
||||||
|
if (!view.IsValid) return 0.0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return view.GetSample(frame, channel, OutputChannels);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
source.Release(view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Makes voice.View the chunk containing <paramref name="frame"/>. False when the source cannot provide it (voice finished).</summary>
|
||||||
|
private bool EnsureView(Voice voice, long frame)
|
||||||
|
{
|
||||||
|
if (voice.View.IsValid && voice.View.Contains(frame)) return true;
|
||||||
|
|
||||||
|
var source = voice.Source!;
|
||||||
|
if (voice.View.IsValid)
|
||||||
|
{
|
||||||
|
source.Release(voice.View);
|
||||||
|
voice.View = default;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateCursor(voice, frame);
|
||||||
|
voice.View = _store.Acquire(source, frame, _cancellationToken);
|
||||||
|
if (voice.View.IsValid) return true;
|
||||||
|
|
||||||
|
voice.Finished = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void UpdateCursor(Voice voice, long frame)
|
||||||
|
{
|
||||||
|
var cursor = voice.Cursor;
|
||||||
|
if (cursor == null) return;
|
||||||
|
if (cursor.Loop != voice.Loop) cursor.Loop = voice.Loop;
|
||||||
|
if (cursor.Position != frame) cursor.Position = frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReleaseVoice(Voice voice)
|
||||||
|
{
|
||||||
|
var source = voice.Source;
|
||||||
|
if (source == null) return;
|
||||||
|
if (voice.View.IsValid)
|
||||||
|
{
|
||||||
|
source.Release(voice.View);
|
||||||
|
voice.View = default;
|
||||||
|
}
|
||||||
|
if (voice.Cursor != null)
|
||||||
|
{
|
||||||
|
source.RemoveCursor(voice.Cursor);
|
||||||
|
voice.Cursor = null;
|
||||||
|
}
|
||||||
|
voice.Pending.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases every voice's chunk and cursor. Render thread (or after the render thread stopped).</summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
foreach (var voice in _voices)
|
||||||
|
ReleaseVoice(voice);
|
||||||
|
_voices.Clear();
|
||||||
|
lock (_commandLock)
|
||||||
|
{
|
||||||
|
_commands.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+282
-340
@@ -1,416 +1,358 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Clip;
|
||||||
using ADOFAIRenderer.Util.Audio.Filter;
|
using ADOFAIRenderer.Util.Audio.Filter;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Mixer;
|
||||||
using MelonLoader;
|
using MelonLoader;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Util.Audio;
|
namespace ADOFAIRenderer.Util.Audio;
|
||||||
|
|
||||||
internal class AudioClipCache
|
/// <summary>
|
||||||
|
/// Audio of one render: the song and effect sounds as <see cref="Voice"/>s of a <see cref="VoiceMixer"/>, clips from an
|
||||||
|
/// <see cref="AudioClipStore"/>, followed by the filter chain (RMS compressor → look-ahead limiter → hard clip → -1 dB gain).
|
||||||
|
///
|
||||||
|
/// Time: seconds relative to the render start (RenderManager.dspTimeBase); output sample n is at n / sample rate.
|
||||||
|
/// The limiter delays its output by its look-ahead (<see cref="IAudioFilter.Latency"/>), so output sample n is mixed from
|
||||||
|
/// voices at sample n + latency, and the filters are primed with samples [0, latency) before the first block.
|
||||||
|
///
|
||||||
|
/// Threads:
|
||||||
|
/// - Main thread: <see cref="InitializeBGM"/>, <see cref="CacheClip"/>, <see cref="RegisterSoundEvent"/>, <see cref="UpdateBGM"/>,
|
||||||
|
/// source/voice mapping, and <see cref="Commit"/> after each frame (all commands with an earlier time have been queued).
|
||||||
|
/// - Render thread: <see cref="GenerateAudioForFrame"/> waits until the main thread has committed the end of the samples it mixes,
|
||||||
|
/// so every command takes effect at its own sample. <see cref="Complete"/> ends the waiting when the main thread stops.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OfflineAudioMixer : IDisposable
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, CachedClipData> _cache = new();
|
/// <summary>Gain applied to every clip.</summary>
|
||||||
|
private const double ClipVolume = 0.5;
|
||||||
|
|
||||||
public class CachedClipData
|
/// <summary>Additional gain of effect sounds (registered with <see cref="RegisterSoundEvent"/>).</summary>
|
||||||
{
|
private const double EffectVolume = 2.0 / 3.0;
|
||||||
public float[] Samples = null!; // Pre-resampled to target channels and sample rate
|
|
||||||
public double Duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CachedClipData? GetOrCache(string clipName, int targetChannels, int targetSampleRate, bool allowLoad = true)
|
/// <summary>
|
||||||
{
|
/// Smallest number of tracked effect voices at which finished ones are removed from the list used by <see cref="StopAllEffects"/>.
|
||||||
if (string.IsNullOrEmpty(clipName)) return null;
|
/// After each removal the next threshold is twice the remaining count (at least this value).
|
||||||
|
/// </summary>
|
||||||
|
private const int EffectVoicePruneCount = 1024;
|
||||||
|
|
||||||
lock (_cache)
|
/// <summary>Longest wait for a commit before checking for cancellation again, in milliseconds.</summary>
|
||||||
{
|
private const int CommitWaitMs = 50;
|
||||||
if (_cache.TryGetValue(clipName, out var cached))
|
|
||||||
return cached;
|
|
||||||
|
|
||||||
if (!allowLoad) return null;
|
private readonly int _channels;
|
||||||
|
private readonly int _sampleRate;
|
||||||
|
private readonly AudioClipStore _store;
|
||||||
|
private readonly VoiceMixer _mixer;
|
||||||
|
private readonly CancellationTokenSource _cancellation = new();
|
||||||
|
private readonly List<IAudioFilter> _filters = new();
|
||||||
|
private readonly int _latencySamples;
|
||||||
|
|
||||||
if (!AudioManager.Instance) return null;
|
// Main thread.
|
||||||
|
private ConditionalWeakTable<AudioSource, SourceState> _sourceStates = new();
|
||||||
|
private readonly List<Voice> _effectVoices = new();
|
||||||
|
private int _effectVoicePruneAt = EffectVoicePruneCount;
|
||||||
|
private AudioClipSource? _bgmSource;
|
||||||
|
private string _bgmKey = "";
|
||||||
|
private Voice? _bgmVoice;
|
||||||
|
private double _bgmStart = double.NaN;
|
||||||
|
private double _bgmPitch = double.NaN;
|
||||||
|
private double _bgmVolume = double.NaN;
|
||||||
|
|
||||||
var clip = AudioManager.Instance.FindOrLoadAudioClip(clipName);
|
// Shared: committed output sample (voices are final before it), guarded by _commitLock.
|
||||||
if (!clip) return null;
|
private readonly object _commitLock = new();
|
||||||
|
private long _committedSample = long.MinValue;
|
||||||
|
|
||||||
try
|
// Render thread (and Dispose, under _renderLock).
|
||||||
{
|
private readonly object _renderLock = new();
|
||||||
var totalSamples = clip.samples;
|
private long _nextOutputSample;
|
||||||
var channels = clip.channels;
|
private bool _primed;
|
||||||
var rawSamples = new float[totalSamples * channels];
|
private double[] _mixBuffer = Array.Empty<double>();
|
||||||
clip.GetData(rawSamples, 0);
|
private bool _disposed;
|
||||||
|
|
||||||
// Auto-resample during cache generation
|
|
||||||
var resampled = AudioResampler.Resample(rawSamples, channels, clip.frequency, targetChannels, targetSampleRate);
|
|
||||||
|
|
||||||
var data = new CachedClipData
|
|
||||||
{
|
|
||||||
Samples = resampled,
|
|
||||||
Duration = (double)resampled.Length / (targetChannels * targetSampleRate)
|
|
||||||
};
|
|
||||||
|
|
||||||
_cache[clipName] = data;
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
MelonLogger.Error($"[AudioClipCache] Failed to cache and resample clip {clipName}: {e}");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Clear()
|
|
||||||
{
|
|
||||||
lock (_cache)
|
|
||||||
{
|
|
||||||
_cache.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class OfflineAudioMixer
|
|
||||||
{
|
|
||||||
public class SoundEvent
|
|
||||||
{
|
|
||||||
public string ClipName = null!;
|
|
||||||
public double StartTime; // In seconds relative to dspTimeBase
|
|
||||||
public double EndTime; // In seconds relative to dspTimeBase (-1 if one-shot)
|
|
||||||
public float Volume;
|
|
||||||
public float Pitch;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly List<SoundEvent> _activeEvents = new();
|
|
||||||
private ConditionalWeakTable<AudioSource, SoundEvent> _sourceMappings = new();
|
|
||||||
private readonly object _lock = new();
|
|
||||||
|
|
||||||
// Target Audio Settings
|
|
||||||
private readonly int _targetChannels;
|
|
||||||
private readonly int _targetSampleRate;
|
|
||||||
private const float ClipVolume = .5f;
|
|
||||||
|
|
||||||
// Audio Filter Settings
|
|
||||||
private readonly LinkedList<IAudioFilter> _filters = new();
|
|
||||||
|
|
||||||
// BGM Cached Samples (Pre-resampled)
|
|
||||||
private float[]? _bgmSamples;
|
|
||||||
private double _bgmDuration;
|
|
||||||
|
|
||||||
// Clip Cache
|
|
||||||
private readonly AudioClipCache _clipCache = new();
|
|
||||||
private bool _isPrimed;
|
|
||||||
|
|
||||||
public OfflineAudioMixer(int targetChannels, int targetSampleRate)
|
public OfflineAudioMixer(int targetChannels, int targetSampleRate)
|
||||||
{
|
{
|
||||||
_targetChannels = targetChannels;
|
_channels = targetChannels;
|
||||||
_targetSampleRate = targetSampleRate;
|
_sampleRate = targetSampleRate;
|
||||||
|
_store = new AudioClipStore(targetSampleRate);
|
||||||
|
_mixer = new VoiceMixer(_store, targetChannels, _cancellation.Token);
|
||||||
|
|
||||||
var rmsCompressor = new RMSCompressor
|
var rmsCompressor = new RMSCompressor
|
||||||
{
|
{
|
||||||
ThresholdDb = -10, Ratio = 2,
|
ThresholdDb = -10, Ratio = 2,
|
||||||
AttackTime = 0.1f, ReleaseTime = 0.1f,
|
AttackTime = 0.1, ReleaseTime = 0.1,
|
||||||
KneeDb = 10, RmsWindowTime = 0.02f
|
KneeDb = 10, RmsWindowTime = 0.02
|
||||||
};
|
};
|
||||||
rmsCompressor.Initialize(_targetSampleRate, _targetChannels);
|
|
||||||
_filters.AddLast(rmsCompressor);
|
|
||||||
|
|
||||||
var limiter = new RMSCompressor
|
var limiter = new RMSCompressor
|
||||||
{
|
{
|
||||||
ThresholdDb = 0, Ratio = float.PositiveInfinity,
|
ThresholdDb = 0, Ratio = double.PositiveInfinity,
|
||||||
AttackTime = 0f, ReleaseTime = 0.05f,
|
AttackTime = 0, ReleaseTime = 0.05,
|
||||||
KneeDb = 0, RmsWindowTime = 0.02f,
|
KneeDb = 0, RmsWindowTime = 0.02,
|
||||||
LookAheadTime = 0.01f
|
LookAheadTime = 0.01
|
||||||
};
|
};
|
||||||
limiter.Initialize(_targetSampleRate, _targetChannels);
|
_filters.Add(rmsCompressor);
|
||||||
_filters.AddLast(limiter);
|
_filters.Add(limiter);
|
||||||
|
_filters.Add(new HardClip());
|
||||||
|
_filters.Add(new Gain { Level = -1 });
|
||||||
|
|
||||||
var hardClip = new HardClip();
|
double latency = 0;
|
||||||
hardClip.Initialize(_targetSampleRate, _targetChannels);
|
foreach (var filter in _filters)
|
||||||
_filters.AddLast(hardClip);
|
{
|
||||||
|
filter.Initialize(_sampleRate, _channels);
|
||||||
|
latency += filter.Latency;
|
||||||
|
}
|
||||||
|
_latencySamples = (int)Math.Round(latency * _sampleRate);
|
||||||
|
|
||||||
var truePeakSafetyGain = new Gain { Level = -1f };
|
_store.Start();
|
||||||
truePeakSafetyGain.Initialize(_targetSampleRate, _targetChannels);
|
|
||||||
_filters.AddLast(truePeakSafetyGain);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Clip store of this render (output rate = mixer sample rate), also read by <see cref="AudioSpectrumGenerator"/>.</summary>
|
||||||
|
internal AudioClipStore Store => _store;
|
||||||
|
|
||||||
|
// ── Main thread ──
|
||||||
|
|
||||||
|
/// <summary>Registers the song clip. The song voice is created and kept in sync by <see cref="UpdateBGM"/>.</summary>
|
||||||
public void InitializeBGM(AudioClip? clip)
|
public void InitializeBGM(AudioClip? clip)
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
{
|
||||||
if (!clip)
|
if (!clip)
|
||||||
{
|
{
|
||||||
_bgmSamples = null;
|
_bgmSource = null;
|
||||||
_bgmDuration = 0;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
_bgmKey = clip!.name;
|
||||||
|
_bgmSource = AudioClipResolver.GetOrAdd(_store, _bgmKey, clip);
|
||||||
|
if (_bgmSource == null)
|
||||||
{
|
{
|
||||||
int originalSamples = clip.samples;
|
Melon<RenderMod>.Logger.Error($"[OfflineAudioMixer] Song clip '{_bgmKey}' could not be loaded, the song will be silent.");
|
||||||
int originalChannels = clip.channels;
|
return;
|
||||||
float[] rawSamples = new float[originalSamples * originalChannels];
|
|
||||||
clip.GetData(rawSamples, 0);
|
|
||||||
|
|
||||||
// Pre-resample the BGM to the target format
|
|
||||||
_bgmSamples = AudioResampler.Resample(rawSamples, originalChannels, clip.frequency, _targetChannels, _targetSampleRate);
|
|
||||||
_bgmDuration = (double)_bgmSamples.Length / (_targetChannels * _targetSampleRate);
|
|
||||||
|
|
||||||
Melon<RenderMod>.Logger.Msg($"[OfflineAudioMixer] Initialized resampled BGM: {clip.name}, Samples: {_bgmSamples.Length / _targetChannels}, TargetFreq: {_targetSampleRate}Hz, TargetChannels: {_targetChannels}");
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Melon<RenderMod>.Logger.Error($"[OfflineAudioMixer] Failed to initialize and resample BGM: {e}");
|
|
||||||
_bgmSamples = null;
|
|
||||||
_bgmDuration = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Melon<RenderMod>.Logger.Msg($"[OfflineAudioMixer] Song '{_bgmKey}': {_bgmSource.Channels} ch, {_bgmSource.Duration:F2} s, {_bgmSource.Policy}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CacheClip(string clipName)
|
/// <summary>
|
||||||
|
/// Updates the song voice from the conductor (start time, pitch) and the level volume.
|
||||||
|
/// A changed start time restarts the song voice at position (now - start) * pitch; pitch and volume changes take effect at <paramref name="now"/>.
|
||||||
|
/// Start time: dspTimeSong + separate countdown (crotchetAtStart / pitch * adjustedCountdownTicks when separateCountdownTime),
|
||||||
|
/// the time scrConductor.StartMusicCo schedules the song at.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="now">Current time, seconds relative to the render start.</param>
|
||||||
|
/// <param name="dspTimeBase">AudioSettings.dspTime at the render start.</param>
|
||||||
|
public void UpdateBGM(double now, double dspTimeBase)
|
||||||
{
|
{
|
||||||
_clipCache.GetOrCache(clipName, _targetChannels, _targetSampleRate, allowLoad: true);
|
if (_bgmSource == null) return;
|
||||||
}
|
|
||||||
|
|
||||||
public void RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
var ev = new SoundEvent
|
|
||||||
{
|
|
||||||
ClipName = clipName,
|
|
||||||
StartTime = startTime,
|
|
||||||
EndTime = endTime,
|
|
||||||
Volume = volume,
|
|
||||||
Pitch = pitch
|
|
||||||
};
|
|
||||||
_activeEvents.Add(ev);
|
|
||||||
|
|
||||||
if (!source) return;
|
|
||||||
_sourceMappings.Remove(source);
|
|
||||||
_sourceMappings.Add(source, ev);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RegisterSoundEvent(string clipName, double startTime, double endTime, float volume, float pitch)
|
|
||||||
{
|
|
||||||
RegisterSoundEvent(null, clipName, startTime, endTime, volume, pitch);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool TryGetSoundEvent(AudioSource? source, out SoundEvent ev)
|
|
||||||
{
|
|
||||||
if (source == null)
|
|
||||||
{
|
|
||||||
ev = null!;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
return _sourceMappings.TryGetValue(source, out ev);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool RemoveSourceMapping(AudioSource source)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
return _sourceMappings.Remove(source);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Reset()
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
_activeEvents.Clear();
|
|
||||||
_sourceMappings = new ConditionalWeakTable<AudioSource, SoundEvent>();
|
|
||||||
_bgmSamples = null;
|
|
||||||
_bgmDuration = 0;
|
|
||||||
_clipCache.Clear();
|
|
||||||
_isPrimed = false;
|
|
||||||
foreach (var filter in _filters)
|
|
||||||
{
|
|
||||||
filter.Reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PrimeFilters(double totalLatency, double dspTimeBase)
|
|
||||||
{
|
|
||||||
int latencySamples = (int)Math.Round(totalLatency * _targetSampleRate);
|
|
||||||
if (latencySamples <= 0) return;
|
|
||||||
|
|
||||||
// Rent temporary buffer to prime filters with initial audio
|
|
||||||
float[] tempBuffer = System.Buffers.ArrayPool<float>.Shared.Rent(latencySamples * _targetChannels);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Array.Clear(tempBuffer, 0, latencySamples * _targetChannels);
|
|
||||||
|
|
||||||
// Mix starting from -totalLatency to 0
|
|
||||||
MixBGM(tempBuffer, latencySamples, -totalLatency, dspTimeBase, totalLatency);
|
|
||||||
MixEffectSounds(tempBuffer, latencySamples, -totalLatency, totalLatency);
|
|
||||||
|
|
||||||
// Feed the buffer into filters to prime circular delay buffers
|
|
||||||
foreach (var filter in _filters)
|
|
||||||
{
|
|
||||||
filter.ProcessBlock(tempBuffer.AsSpan(0, latencySamples * _targetChannels));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
System.Buffers.ArrayPool<float>.Shared.Return(tempBuffer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void GenerateAudioForFrame(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase)
|
|
||||||
{
|
|
||||||
// mixedBuffer size must be at least samplesNeeded * _targetChannels
|
|
||||||
Array.Clear(mixedBuffer, 0, samplesNeeded * _targetChannels);
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
double totalLatency = 0;
|
|
||||||
foreach (var filter in _filters)
|
|
||||||
{
|
|
||||||
totalLatency += filter.Latency;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prime filters on first frame execution if latency exists
|
|
||||||
if (!_isPrimed && totalLatency > 0)
|
|
||||||
{
|
|
||||||
PrimeFilters(totalLatency, dspTimeBase);
|
|
||||||
_isPrimed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Mix BGM
|
|
||||||
MixBGM(mixedBuffer, samplesNeeded, frameStartTime, dspTimeBase, totalLatency);
|
|
||||||
|
|
||||||
// 2. Mix Hitsounds/Effect Sounds
|
|
||||||
MixEffectSounds(mixedBuffer, samplesNeeded, frameStartTime, totalLatency);
|
|
||||||
|
|
||||||
foreach (var filter in _filters)
|
|
||||||
filter.ProcessBlock(mixedBuffer.AsSpan(0, samplesNeeded * _targetChannels));
|
|
||||||
|
|
||||||
for (var i = 0; i < samplesNeeded * _targetChannels; i++)
|
|
||||||
{
|
|
||||||
if (Math.Abs(mixedBuffer[i]) > 1) Melon<RenderMod>.Logger.Msg("Warning Why Audio Sample Is Larger Than 1");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void MixBGM(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase, double totalLatency)
|
|
||||||
{
|
|
||||||
if (_bgmSamples == null || _bgmSamples.Length == 0) return;
|
|
||||||
|
|
||||||
var conductor = scrConductor.instance;
|
var conductor = scrConductor.instance;
|
||||||
if (!conductor) return;
|
if (!conductor) return;
|
||||||
|
|
||||||
double pitch = conductor.song.pitch;
|
double pitch = conductor.song.pitch;
|
||||||
var dspTimeSong = conductor.dspTimeSong;
|
var countdown = conductor.separateCountdownTime ? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks : 0.0;
|
||||||
var separateCountdownOffset = conductor.separateCountdownTime
|
var start = conductor.dspTimeSong + countdown - dspTimeBase;
|
||||||
? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks
|
var levelVolume = scnGame.instance ? scnGame.instance.levelData.volume * 0.01 : 1.0;
|
||||||
: 0.0;
|
var volume = levelVolume * ClipVolume;
|
||||||
|
|
||||||
// BGM Playback start time relative to dspTimeBase
|
if (_bgmVoice == null || start != _bgmStart)
|
||||||
var time1 = dspTimeSong + separateCountdownOffset;
|
|
||||||
var relativeTime1 = time1 - dspTimeBase;
|
|
||||||
var adjustedRelativeTime1 = relativeTime1 - totalLatency;
|
|
||||||
|
|
||||||
var totalSamples = _bgmSamples.Length / _targetChannels;
|
|
||||||
|
|
||||||
for (int s = 0; s < samplesNeeded; s++)
|
|
||||||
{
|
{
|
||||||
double sampleTime = frameStartTime + (double)s / _targetSampleRate;
|
// Song position at time t is (t - start) * pitch, also when the start is already past.
|
||||||
double clipTime = (sampleTime - adjustedRelativeTime1) * pitch;
|
_bgmVoice?.Stop(now);
|
||||||
|
_bgmVoice = _mixer.Play(_bgmSource, _bgmKey, Math.Max(start, now), volume, pitch, false, now);
|
||||||
if (clipTime >= 0 && clipTime < _bgmDuration)
|
if (start < now) _bgmVoice.SetPosition((now - start) * pitch, now);
|
||||||
{
|
_bgmStart = start;
|
||||||
double sampleIdxDouble = clipTime * _targetSampleRate;
|
_bgmPitch = pitch;
|
||||||
long idxLow = (long)Math.Floor(sampleIdxDouble);
|
_bgmVolume = volume;
|
||||||
long idxHigh = idxLow + 1;
|
return;
|
||||||
float t = (float)(sampleIdxDouble - idxLow);
|
|
||||||
|
|
||||||
if (idxHigh >= totalSamples) idxHigh = totalSamples - 1;
|
|
||||||
if (idxLow >= totalSamples) idxLow = totalSamples - 1;
|
|
||||||
|
|
||||||
for (var c = 0; c < _targetChannels; c++)
|
|
||||||
{
|
|
||||||
float valLow = _bgmSamples[idxLow * _targetChannels + c];
|
|
||||||
float valHigh = _bgmSamples[idxHigh * _targetChannels + c];
|
|
||||||
mixedBuffer[s * _targetChannels + c] += (valLow + t * (valHigh - valLow)) * (scnGame.instance.levelData.volume * 0.01f)
|
|
||||||
* ClipVolume;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pitch != _bgmPitch)
|
||||||
|
{
|
||||||
|
_bgmVoice.SetPitch(pitch, now);
|
||||||
|
_bgmPitch = pitch;
|
||||||
}
|
}
|
||||||
|
if (volume != _bgmVolume)
|
||||||
|
{
|
||||||
|
_bgmVoice.SetVolume(volume, now);
|
||||||
|
_bgmVolume = volume;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void MixEffectSounds(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double totalLatency)
|
/// <summary>Prepares the source of the game clip <paramref name="clipName"/> (audioLib key) so its first chunk starts loading.</summary>
|
||||||
|
public void CacheClip(string clipName)
|
||||||
{
|
{
|
||||||
for (var i = _activeEvents.Count - 1; i >= 0; i--)
|
GetOrAddSource(clipName);
|
||||||
{
|
|
||||||
var ev = _activeEvents[i];
|
|
||||||
var clipData = _clipCache.GetOrCache(ev.ClipName, _targetChannels, _targetSampleRate, allowLoad: false);
|
|
||||||
if (clipData == null)
|
|
||||||
{
|
|
||||||
_activeEvents.RemoveAt(i);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var isSoundFinished = false;
|
private AudioClipSource? GetOrAddSource(string clipName)
|
||||||
var totalSamples = clipData.Samples.Length / _targetChannels;
|
|
||||||
double adjustedStartTime = ev.StartTime - totalLatency;
|
|
||||||
double adjustedEndTime = ev.EndTime >= 0 ? ev.EndTime - totalLatency : -1.0;
|
|
||||||
|
|
||||||
for (var s = 0; s < samplesNeeded; s++)
|
|
||||||
{
|
{
|
||||||
var sampleTime = frameStartTime + (double)s / _targetSampleRate;
|
if (string.IsNullOrEmpty(clipName)) return null;
|
||||||
|
if (_store.TryGet(clipName, out var existing)) return existing;
|
||||||
// If it is a looping sound and has reached or exceeded its end time, stop playing.
|
var clip = AudioManager.Instance ? AudioManager.Instance.FindOrLoadAudioClip(clipName) : null;
|
||||||
if (adjustedEndTime >= 0 && sampleTime >= adjustedEndTime)
|
return AudioClipResolver.GetOrAdd(_store, clipName, clip);
|
||||||
{
|
|
||||||
isSoundFinished = true;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var elapsed = sampleTime - adjustedStartTime;
|
/// <summary>
|
||||||
if (elapsed < 0) continue; // Sound hasn't started yet
|
/// Starts an effect sound at <paramref name="startTime"/>. An <paramref name="endTime"/> of 0 or more makes it loop until then.
|
||||||
|
/// <paramref name="source"/> (when given) is mapped to the returned state, which reports and applies later changes through the AudioSource.
|
||||||
var soundTime = elapsed * ev.Pitch;
|
/// </summary>
|
||||||
|
public SourceState RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch, double now)
|
||||||
if (soundTime >= clipData.Duration)
|
|
||||||
{
|
{
|
||||||
if (ev.EndTime < 0) // One-shot
|
var clipSource = GetOrAddSource(clipName);
|
||||||
|
var loop = endTime >= 0;
|
||||||
|
var voice = _mixer.Play(clipSource, clipName, startTime, volume * ClipVolume * EffectVolume, pitch, loop, now);
|
||||||
|
if (loop) voice.SetEndTime(endTime, now);
|
||||||
|
|
||||||
|
if (_effectVoices.Count >= _effectVoicePruneAt)
|
||||||
{
|
{
|
||||||
isSoundFinished = true;
|
_effectVoices.RemoveAll(v => v.IsFinished);
|
||||||
break;
|
_effectVoicePruneAt = Math.Max(EffectVoicePruneCount, _effectVoices.Count * 2);
|
||||||
|
}
|
||||||
|
_effectVoices.Add(voice);
|
||||||
|
|
||||||
|
var state = new SourceState(voice, Math.Max(startTime, now), pitch, loop, loop ? endTime : double.PositiveInfinity,
|
||||||
|
clipSource?.Duration ?? 0.0, _sampleRate);
|
||||||
|
if (source) MapSource(source!, state);
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Looping
|
/// <summary>
|
||||||
soundTime %= clipData.Duration;
|
/// Maps <paramref name="source"/> to <paramref name="state"/> (replaces an earlier mapping) and takes the clip length and
|
||||||
|
/// sample rate from the clip of the source.
|
||||||
|
/// </summary>
|
||||||
|
public void MapSource(AudioSource source, SourceState state)
|
||||||
|
{
|
||||||
|
state.BindClip(source.clip);
|
||||||
|
_sourceStates.Remove(source);
|
||||||
|
_sourceStates.Add(source, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Linear interpolation for pitch shifting in the mixer loop
|
/// <summary>State of the effect sound last started for <paramref name="source"/>. The mapping stays after the sound ends or stops.</summary>
|
||||||
double sampleIdxDouble = soundTime * _targetSampleRate;
|
public bool TryGetSourceState(AudioSource? source, out SourceState state)
|
||||||
long idxLow = (long)Math.Floor(sampleIdxDouble);
|
|
||||||
long idxHigh = idxLow + 1;
|
|
||||||
float t = (float)(sampleIdxDouble - idxLow);
|
|
||||||
|
|
||||||
if (idxHigh >= totalSamples) idxHigh = totalSamples - 1;
|
|
||||||
if (idxLow >= totalSamples) idxLow = totalSamples - 1;
|
|
||||||
|
|
||||||
for (var c = 0; c < _targetChannels; c++)
|
|
||||||
{
|
{
|
||||||
float valLow = clipData.Samples[idxLow * _targetChannels + c];
|
state = null!;
|
||||||
float valHigh = clipData.Samples[idxHigh * _targetChannels + c];
|
return source != null && _sourceStates.TryGetValue(source, out state);
|
||||||
mixedBuffer[s * _targetChannels + c] += (valLow + t * (valHigh - valLow)) * ev.Volume
|
}
|
||||||
* ClipVolume * 2 / 3;
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops every effect sound at <paramref name="now"/>, including ones scheduled to start later
|
||||||
|
/// (AudioManager.StopAllSounds destroys every AudioSource in liveSources, which holds the sources of
|
||||||
|
/// AudioManager._Play and scrConductor.PlayWithEndTime). The song voice keeps playing.
|
||||||
|
/// </summary>
|
||||||
|
public void StopAllEffects(double now)
|
||||||
|
{
|
||||||
|
foreach (var voice in _effectVoices)
|
||||||
|
voice.Stop(now);
|
||||||
|
_effectVoices.Clear();
|
||||||
|
_sourceStates = new ConditionalWeakTable<AudioSource, SourceState>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks every command with a time before <paramref name="time"/> as queued
|
||||||
|
/// (called after each frame with the time of the next frame).
|
||||||
|
/// </summary>
|
||||||
|
public void Commit(double time)
|
||||||
|
{
|
||||||
|
var sample = VoiceMixer.ToSample(time, _sampleRate);
|
||||||
|
lock (_commitLock)
|
||||||
|
{
|
||||||
|
if (sample <= _committedSample) return;
|
||||||
|
_committedSample = sample;
|
||||||
|
Monitor.PulseAll(_commitLock);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up finished sound events (both one-shot and looping)
|
/// <summary>No more commands will be queued (render ending): mixing no longer waits for commits.</summary>
|
||||||
if (isSoundFinished)
|
public void Complete()
|
||||||
{
|
{
|
||||||
_activeEvents.RemoveAt(i);
|
lock (_commitLock)
|
||||||
|
{
|
||||||
|
_committedSample = long.MaxValue;
|
||||||
|
Monitor.PulseAll(_commitLock);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Stops waiting for commits and chunks; the render thread's current or next mix throws OperationCanceledException.</summary>
|
||||||
|
public void Cancel()
|
||||||
|
{
|
||||||
|
_cancellation.Cancel();
|
||||||
|
Complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render thread ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the next <paramref name="samplesNeeded"/> output frames (interleaved float, continuing from the previous call).
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">Chunk loading failed fatally (out of memory or the decoder thread stopped).</exception>
|
||||||
|
/// <exception cref="OperationCanceledException">The render was canceled (<see cref="Cancel"/>).</exception>
|
||||||
|
public void GenerateAudioForFrame(float[] mixedBuffer, int samplesNeeded)
|
||||||
|
{
|
||||||
|
lock (_renderLock)
|
||||||
|
{
|
||||||
|
if (_disposed) throw new ObjectDisposedException(nameof(OfflineAudioMixer));
|
||||||
|
|
||||||
|
if (!_primed)
|
||||||
|
{
|
||||||
|
_primed = true;
|
||||||
|
if (_latencySamples > 0)
|
||||||
|
{
|
||||||
|
var prime = MixBlock(0, _latencySamples);
|
||||||
|
foreach (var filter in _filters)
|
||||||
|
filter.ProcessBlock(prime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var block = MixBlock(_nextOutputSample + _latencySamples, samplesNeeded);
|
||||||
|
foreach (var filter in _filters)
|
||||||
|
filter.ProcessBlock(block);
|
||||||
|
_nextOutputSample += samplesNeeded;
|
||||||
|
|
||||||
|
for (var i = 0; i < block.Length; i++)
|
||||||
|
mixedBuffer[i] = (float)block[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mixes voices at samples [first, first + frames) into the reused double buffer, after the main thread committed them.</summary>
|
||||||
|
private Span<double> MixBlock(long first, int frames)
|
||||||
|
{
|
||||||
|
WaitForCommit(first + frames);
|
||||||
|
|
||||||
|
var length = frames * _channels;
|
||||||
|
if (_mixBuffer.Length < length) _mixBuffer = new double[length];
|
||||||
|
var block = _mixBuffer.AsSpan(0, length);
|
||||||
|
block.Clear();
|
||||||
|
_mixer.Mix(first, block);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WaitForCommit(long endSample)
|
||||||
|
{
|
||||||
|
lock (_commitLock)
|
||||||
|
{
|
||||||
|
while (_committedSample < endSample)
|
||||||
|
{
|
||||||
|
_cancellation.Token.ThrowIfCancellationRequested();
|
||||||
|
Monitor.Wait(_commitLock, CommitWaitMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_cancellation.Token.ThrowIfCancellationRequested();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancels waits, then releases the voices, the clip store (decoder thread) and the filter state.
|
||||||
|
/// Waits for a running <see cref="GenerateAudioForFrame"/> to return.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Cancel();
|
||||||
|
lock (_renderLock)
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_mixer.Dispose();
|
||||||
|
_store.Dispose();
|
||||||
|
foreach (var filter in _filters)
|
||||||
|
filter.Reset();
|
||||||
|
}
|
||||||
|
_sourceStates = new ConditionalWeakTable<AudioSource, SourceState>();
|
||||||
|
_effectVoices.Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using System;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Mixer;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Playback state of a game AudioSource mapped to an effect <see cref="Voice"/>, as the AudioSource reports it to the game
|
||||||
|
/// (isPlaying, time, timeSamples, pitch). Main thread only. Times are seconds relative to the render start (the render clock that
|
||||||
|
/// also drives AudioSettings.dspTime during rendering); clip positions are seconds into the clip.
|
||||||
|
///
|
||||||
|
/// Reported values:
|
||||||
|
/// - Playing: from the scheduled start until Stop(), the scheduled end time, or the natural end of the clip (not looping).
|
||||||
|
/// Before the start, isPlaying is true and time is the position playback will start from.
|
||||||
|
/// - time while playing: start position + elapsed time * pitch since the last change, wrapped at the clip length when looping.
|
||||||
|
/// time is 0 while not playing.
|
||||||
|
/// - timeSamples while playing: floor(time * clip frequency). While not playing: the last value set through time or timeSamples,
|
||||||
|
/// reset to 0 by Stop().
|
||||||
|
/// - pitch: the last finite value set (NaN and infinity are ignored). Playback uses max(0, pitch).
|
||||||
|
/// Changes made while not playing are stored and do not reach the voice.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SourceState
|
||||||
|
{
|
||||||
|
private readonly double _start;
|
||||||
|
private double _anchorTime;
|
||||||
|
private double _anchorPosition;
|
||||||
|
private double _pitch;
|
||||||
|
private readonly bool _loop;
|
||||||
|
private double _endTime = double.PositiveInfinity;
|
||||||
|
private bool _stopped;
|
||||||
|
private int _offsetSamples;
|
||||||
|
private double _length;
|
||||||
|
private int _frequency;
|
||||||
|
|
||||||
|
/// <param name="voice">Voice that plays the sound.</param>
|
||||||
|
/// <param name="start">Start of playback (seconds relative to the render start, not before the time it was scheduled).</param>
|
||||||
|
/// <param name="pitch">Pitch at the start.</param>
|
||||||
|
/// <param name="loop">Repeats the clip.</param>
|
||||||
|
/// <param name="endTime">Scheduled end time (seconds relative to the render start), or infinity.</param>
|
||||||
|
/// <param name="length">Clip length in seconds (replaced by the game clip in <see cref="BindClip"/>).</param>
|
||||||
|
/// <param name="frequency">Clip sample rate for timeSamples (replaced by the game clip in <see cref="BindClip"/>).</param>
|
||||||
|
public SourceState(Voice voice, double start, double pitch, bool loop, double endTime, double length, int frequency)
|
||||||
|
{
|
||||||
|
Voice = voice;
|
||||||
|
_start = start;
|
||||||
|
_anchorTime = start;
|
||||||
|
_pitch = pitch;
|
||||||
|
_loop = loop;
|
||||||
|
_endTime = endTime;
|
||||||
|
_length = length;
|
||||||
|
_frequency = Math.Max(1, frequency);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Voice Voice { get; }
|
||||||
|
|
||||||
|
/// <summary>Takes the clip length and sample rate from the clip of the game AudioSource.</summary>
|
||||||
|
public void BindClip(AudioClip? clip)
|
||||||
|
{
|
||||||
|
if (!clip) return;
|
||||||
|
_length = clip!.length;
|
||||||
|
_frequency = Math.Max(1, clip.frequency);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Scheduled end time (see AudioSource.SetScheduledEndTime), seconds relative to the render start.</summary>
|
||||||
|
public void SetEndTime(double endTime, double now)
|
||||||
|
{
|
||||||
|
if (!IsPlaying(now)) return;
|
||||||
|
_endTime = endTime;
|
||||||
|
Voice.SetEndTime(endTime, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsPlaying(double now) => !_stopped && now < Math.Min(_endTime, NaturalEnd());
|
||||||
|
|
||||||
|
public float GetTime(double now) => IsPlaying(now) ? (float)PositionAt(now) : 0f;
|
||||||
|
|
||||||
|
public int GetTimeSamples(double now) => IsPlaying(now) ? (int)Math.Floor(PositionAt(now) * _frequency) : _offsetSamples;
|
||||||
|
|
||||||
|
public float GetPitch() => (float)_pitch;
|
||||||
|
|
||||||
|
public void SetTime(float seconds, double now)
|
||||||
|
{
|
||||||
|
_offsetSamples = (int)Math.Floor((double)seconds * _frequency);
|
||||||
|
Seek(seconds, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetTimeSamples(int samples, double now)
|
||||||
|
{
|
||||||
|
_offsetSamples = samples;
|
||||||
|
Seek((double)samples / _frequency, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetPitch(float pitch, double now)
|
||||||
|
{
|
||||||
|
if (float.IsNaN(pitch) || float.IsInfinity(pitch)) return;
|
||||||
|
var playing = IsPlaying(now);
|
||||||
|
if (playing) Reanchor(now);
|
||||||
|
_pitch = pitch;
|
||||||
|
if (playing) Voice.SetPitch(pitch, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop(double now)
|
||||||
|
{
|
||||||
|
if (IsPlaying(now)) Voice.Stop(now);
|
||||||
|
_stopped = true;
|
||||||
|
_offsetSamples = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double PlaybackPitch => Math.Max(0.0, _pitch);
|
||||||
|
|
||||||
|
private void Seek(double seconds, double now)
|
||||||
|
{
|
||||||
|
if (!IsPlaying(now)) return;
|
||||||
|
var position = Math.Max(0.0, seconds);
|
||||||
|
_anchorTime = Math.Max(now, _start);
|
||||||
|
_anchorPosition = position;
|
||||||
|
Voice.SetPosition(position, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Reanchor(double now)
|
||||||
|
{
|
||||||
|
var time = Math.Max(now, _start);
|
||||||
|
_anchorPosition = PositionAt(time);
|
||||||
|
_anchorTime = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clip position at <paramref name="now"/> (the start position before the start), wrapped when looping.</summary>
|
||||||
|
private double PositionAt(double now)
|
||||||
|
{
|
||||||
|
var position = _anchorPosition + (Math.Max(now, _anchorTime) - _anchorTime) * PlaybackPitch;
|
||||||
|
if (_loop && _length > 0 && position >= _length) position %= _length;
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Time at which the clip position reaches the clip length (infinity when looping or at pitch 0).</summary>
|
||||||
|
private double NaturalEnd()
|
||||||
|
{
|
||||||
|
if (_loop || PlaybackPitch <= 0) return double.PositiveInfinity;
|
||||||
|
return _anchorTime + Math.Max(0.0, _length - _anchorPosition) / PlaybackPitch;
|
||||||
|
}
|
||||||
|
}
|
||||||
+311
-127
@@ -23,7 +23,7 @@ namespace ADOFAIRenderer.Util.FFmpeg;
|
|||||||
/// 2. Codec & Hardware Device Discovery:
|
/// 2. Codec & Hardware Device Discovery:
|
||||||
/// a) avcodec_find_decoder: Locate codec implementation by codec ID.
|
/// a) avcodec_find_decoder: Locate codec implementation by codec ID.
|
||||||
/// b) avcodec_get_hw_config: Query supported hardware configurations for the decoder.
|
/// b) avcodec_get_hw_config: Query supported hardware configurations for the decoder.
|
||||||
/// c) Prioritized HW device creation (D3D11VA → DXVA2 → QSV → CUDA → D3D12VA → VAAPI → VideoToolbox).
|
/// c) HW device creation for the reported configurations, in the order of PreferredHwDeviceTypes.
|
||||||
/// d) Attach hw_device_ctx and register custom get_format callback.
|
/// d) Attach hw_device_ctx and register custom get_format callback.
|
||||||
/// e) If HW initialization fails or is unsupported, fall back to CPU software decoding
|
/// e) If HW initialization fails or is unsupported, fall back to CPU software decoding
|
||||||
/// with multi-threading enabled (thread_count = 0, FF_THREAD_FRAME).
|
/// with multi-threading enabled (thread_count = 0, FF_THREAD_FRAME).
|
||||||
@@ -35,8 +35,10 @@ namespace ADOFAIRenderer.Util.FFmpeg;
|
|||||||
/// c) (HW Transfer) For hardware frames, av_hwframe_transfer_data downloads GPU surface data
|
/// c) (HW Transfer) For hardware frames, av_hwframe_transfer_data downloads GPU surface data
|
||||||
/// into system RAM as NV12 (av_frame_copy_props preserves timestamps).
|
/// into system RAM as NV12 (av_frame_copy_props preserves timestamps).
|
||||||
/// d) (Output Dispatch):
|
/// d) (Output Dispatch):
|
||||||
/// - TryReadNV12Frame: Zero-copy direct memory transfer for NV12 frames, sws_scale fallback.
|
/// - TryReadNV12Frame: NV12 frames are copied row by row (Buffer.MemoryCopy) into the output buffers,
|
||||||
/// - TryReadYUV420PFrame: Zero-copy planar copy for YUV420P/YUVJ420P, sws_scale fallback.
|
/// other formats are converted with sws_scale.
|
||||||
|
/// - TryReadYUV420PFrame: YUV420P/YUVJ420P planes are copied row by row into the output buffers,
|
||||||
|
/// other formats are converted with sws_scale.
|
||||||
/// - TryReadFrame: sws_scale conversion to RGBA32 with optional vertical flip (vflip).
|
/// - TryReadFrame: sws_scale conversion to RGBA32 with optional vertical flip (vflip).
|
||||||
///
|
///
|
||||||
/// 4. Seeking & Scrubbing (SeekToTime / SeekToFrame):
|
/// 4. Seeking & Scrubbing (SeekToTime / SeekToFrame):
|
||||||
@@ -76,9 +78,12 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Preferred hardware device types in priority order.
|
/// Preferred hardware device types in priority order.
|
||||||
/// Modern Windows platforms prioritize D3D11VA for wide driver support and direct integration,
|
/// The candidates are the hardware configurations the selected decoder reports (avcodec_get_hw_config),
|
||||||
/// followed by DXVA2 (DirectX 9 / legacy), Intel QSV, NVIDIA CUDA, and DirectX 12 D3D12VA.
|
/// sorted by this list; configurations not in the list follow in reported order.
|
||||||
/// Non-Windows systems fall back to VAAPI or VideoToolbox.
|
/// Windows: D3D11VA, then DXVA2 (DirectX 9), CUDA and D3D12VA. Other platforms: VAAPI or VideoToolbox.
|
||||||
|
/// The QSV entry matches only decoders that report a QSV configuration. The native decoders returned by
|
||||||
|
/// avcodec_find_decoder report DXVA2/D3D11VA/D3D12VA/CUDA/VAAPI/Vulkan in the bundled FFmpeg 8 build;
|
||||||
|
/// the QSV decoders (h264_qsv, etc.) are separate decoders selected by name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly AVHWDeviceType[] PreferredHwDeviceTypes = new[]
|
private static readonly AVHWDeviceType[] PreferredHwDeviceTypes = new[]
|
||||||
{
|
{
|
||||||
@@ -191,6 +196,13 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
private bool _isOpened;
|
private bool _isOpened;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
private bool _isDraining;
|
private bool _isDraining;
|
||||||
|
|
||||||
|
/// <summary>Number of video packets skipped because the decoder rejected them as invalid data.</summary>
|
||||||
|
private int _skippedPacketCount;
|
||||||
|
|
||||||
|
/// <summary>Number of skipped packets for which a warning is logged.</summary>
|
||||||
|
private const int MaxLoggedSkippedPackets = 5;
|
||||||
|
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
|
|
||||||
// Fields to detect SwsContext updates
|
// Fields to detect SwsContext updates
|
||||||
@@ -199,6 +211,12 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
private int _srcWidth;
|
private int _srcWidth;
|
||||||
private int _srcHeight;
|
private int _srcHeight;
|
||||||
|
|
||||||
|
/// <summary>YUV→RGB matrix id (<c>SWS_CS_*</c>) applied to the context. 0 when no matrix is set (non-RGBA output).</summary>
|
||||||
|
private int _srcSwsColorspace;
|
||||||
|
|
||||||
|
/// <summary>Whether the source range applied to the context is the full range.</summary>
|
||||||
|
private bool _srcFullRange;
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
// Public Properties
|
// Public Properties
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
@@ -289,6 +307,59 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Color matrix id used to convert the YUV frames of this stream to RGB, selected from the colorspace tag of the stream.
|
||||||
|
/// The ids are shared with the <c>ColorMatrix</c> uniform of ColorConversion.compute:
|
||||||
|
/// 0 = BT.709 (also used when the stream has no usable tag), 1 = BT.601, 2 = BT.2020 (non-constant luminance).
|
||||||
|
/// </summary>
|
||||||
|
public int ColorMatrix
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_pCodecContext != null && _pCodecContext->colorspace != AVColorSpace.AVCOL_SPC_UNSPECIFIED)
|
||||||
|
{
|
||||||
|
return GetColorMatrixId(_pCodecContext->colorspace);
|
||||||
|
}
|
||||||
|
if (_pFormatContext != null && _videoStreamIndex >= 0)
|
||||||
|
{
|
||||||
|
return GetColorMatrixId(_pFormatContext->streams[_videoStreamIndex]->codecpar->color_space);
|
||||||
|
}
|
||||||
|
return ColorMatrixBT709;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Color matrix id of BT.709.</summary>
|
||||||
|
// ReSharper disable InconsistentNaming
|
||||||
|
public const int ColorMatrixBT709 = 0;
|
||||||
|
|
||||||
|
/// <summary>Color matrix id of BT.601 (SMPTE 170M / BT.470 BG).</summary>
|
||||||
|
public const int ColorMatrixBT601 = 1;
|
||||||
|
|
||||||
|
/// <summary>Color matrix id of BT.2020 (non-constant luminance).</summary>
|
||||||
|
public const int ColorMatrixBT2020 = 2;
|
||||||
|
// ReSharper restore InconsistentNaming
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a colorspace tag to a color matrix id (see <see cref="ColorMatrix"/>).
|
||||||
|
/// Tags that have no matrix of their own (unspecified, RGB, other standards) use BT.709.
|
||||||
|
/// </summary>
|
||||||
|
private static int GetColorMatrixId(AVColorSpace colorspace)
|
||||||
|
{
|
||||||
|
switch (colorspace)
|
||||||
|
{
|
||||||
|
case AVColorSpace.AVCOL_SPC_SMPTE170M:
|
||||||
|
case AVColorSpace.AVCOL_SPC_BT470BG:
|
||||||
|
return ColorMatrixBT601;
|
||||||
|
case AVColorSpace.AVCOL_SPC_BT2020_NCL:
|
||||||
|
return ColorMatrixBT2020;
|
||||||
|
default:
|
||||||
|
return ColorMatrixBT709;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a value indicating whether hardware-accelerated decoding is currently active.
|
/// Gets a value indicating whether hardware-accelerated decoding is currently active.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -371,6 +442,16 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
if (_videoStreamIndex == -1)
|
if (_videoStreamIndex == -1)
|
||||||
throw new Exception("Failed to find video stream.");
|
throw new Exception("Failed to find video stream.");
|
||||||
|
|
||||||
|
// Only the video stream is used. The demuxer does not need to return the packets of the other streams
|
||||||
|
// (audio, subtitles, ...) once they are marked as discarded.
|
||||||
|
// Reference: AVStream.discard - "Selects which packets can be discarded at will and do not need to be demuxed."
|
||||||
|
// https://ffmpeg.org/doxygen/trunk/structAVStream.html
|
||||||
|
for (int i = 0; i < _pFormatContext->nb_streams; i++)
|
||||||
|
{
|
||||||
|
if (i != _videoStreamIndex)
|
||||||
|
_pFormatContext->streams[i]->discard = AVDiscard.AVDISCARD_ALL;
|
||||||
|
}
|
||||||
|
|
||||||
var stream = _pFormatContext->streams[_videoStreamIndex];
|
var stream = _pFormatContext->streams[_videoStreamIndex];
|
||||||
_timeBase = stream->time_base;
|
_timeBase = stream->time_base;
|
||||||
|
|
||||||
@@ -441,9 +522,13 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
TotalFrames = 0;
|
TotalFrames = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Duration = _pFormatContext->duration > 0
|
// Duration of the video stream (AVStream.duration, in the stream time base).
|
||||||
? _pFormatContext->duration / (double)ffmpeg.AV_TIME_BASE
|
// When the stream does not carry a duration, the container duration (AVFormatContext.duration,
|
||||||
: (stream->duration > 0 ? stream->duration * ffmpeg.av_q2d(stream->time_base) : 0);
|
// in AV_TIME_BASE units, the longest of all streams) is used.
|
||||||
|
// Reference: https://ffmpeg.org/doxygen/trunk/structAVStream.html, https://ffmpeg.org/doxygen/trunk/structAVFormatContext.html
|
||||||
|
Duration = stream->duration > 0
|
||||||
|
? stream->duration * ffmpeg.av_q2d(stream->time_base)
|
||||||
|
: _pFormatContext->duration > 0 ? _pFormatContext->duration / (double)ffmpeg.AV_TIME_BASE : 0;
|
||||||
|
|
||||||
// 9. Allocate frame and packet buffers
|
// 9. Allocate frame and packet buffers
|
||||||
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html
|
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html
|
||||||
@@ -581,9 +666,11 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
_getFormatDelegate = GetFormat;
|
_getFormatDelegate = GetFormat;
|
||||||
_pCodecContext->get_format = _getFormatDelegate;
|
_pCodecContext->get_format = _getFormatDelegate;
|
||||||
|
|
||||||
// Hardware decoding requires single-threaded decoding (thread_count = 1).
|
// Hardware decoding runs with one decoder thread (thread_count = 1).
|
||||||
// Multi-threading (thread_count = 0 / FF_THREAD_FRAME) is incompatible with fixed-function
|
// The GPU performs the decoding; the decoder thread only parses packets and submits them.
|
||||||
// GPU decoders and causes surface pool allocation exhaustion or crashes in get_format.
|
// With frame threading, libavcodec adds one hardware surface per thread to the frame pool
|
||||||
|
// (avcodec_get_hw_frames_parameters). The software fallback in GetFormat keeps this thread count.
|
||||||
|
// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavcodec/decode.c
|
||||||
_pCodecContext->thread_count = 1;
|
_pCodecContext->thread_count = 1;
|
||||||
|
|
||||||
// Attempt opening codec context with hardware configuration
|
// Attempt opening codec context with hardware configuration
|
||||||
@@ -655,6 +742,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads packets from the container until a packet belonging to the video stream is found,
|
/// Reads packets from the container until a packet belonging to the video stream is found,
|
||||||
/// then sends it to the decoder via <c>avcodec_send_packet</c>.
|
/// then sends it to the decoder via <c>avcodec_send_packet</c>.
|
||||||
|
/// A packet that the decoder rejects as invalid data (<c>AVERROR_INVALIDDATA</c>, e.g. a damaged packet)
|
||||||
|
/// is skipped and the next packet is read. Any other decoder error is thrown.
|
||||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
|
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>True if a video packet was sent; false on EOF.</returns>
|
/// <returns>True if a video packet was sent; false on EOF.</returns>
|
||||||
@@ -670,6 +759,18 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
{
|
{
|
||||||
ret = ffmpeg.avcodec_send_packet(_pCodecContext, _pPacket);
|
ret = ffmpeg.avcodec_send_packet(_pCodecContext, _pPacket);
|
||||||
ffmpeg.av_packet_unref(_pPacket);
|
ffmpeg.av_packet_unref(_pPacket);
|
||||||
|
|
||||||
|
if (ret == ffmpeg.AVERROR_INVALIDDATA)
|
||||||
|
{
|
||||||
|
// The warning is logged for the first few packets only (a damaged file can have many).
|
||||||
|
_skippedPacketCount++;
|
||||||
|
if (_skippedPacketCount <= MaxLoggedSkippedPackets)
|
||||||
|
{
|
||||||
|
Melon<RenderMod>.Logger.Warning($"[FFmpegNativeDecoder] Skipped an invalid video packet ({_skippedPacketCount}): {GetFFmpegErrorString(ret)}");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (ret < 0)
|
if (ret < 0)
|
||||||
{
|
{
|
||||||
throw new Exception($"Error sending packet to decoder: {GetFFmpegErrorString(ret)}");
|
throw new Exception($"Error sending packet to decoder: {GetFFmpegErrorString(ret)}");
|
||||||
@@ -743,12 +844,67 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lazily initializes or updates the <see cref="SwsContext"/> when source/destination formats or dimensions change.
|
/// Selects the YUV→RGB conversion matrix (libswscale <c>SWS_CS_*</c>) from the colorspace tag of a frame.
|
||||||
|
/// The matrix set is the same as that of the compute shader path (ColorConversion.compute, <c>ColorMatrix</c> uniform):
|
||||||
|
/// BT.709 (also for frames without a usable tag), BT.601 and BT.2020, so both paths give the same colors.
|
||||||
|
/// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html (sws_getCoefficients)
|
||||||
|
/// </summary>
|
||||||
|
private static int GetSwsColorspace(AVColorSpace colorspace)
|
||||||
|
{
|
||||||
|
switch (GetColorMatrixId(colorspace))
|
||||||
|
{
|
||||||
|
case ColorMatrixBT601:
|
||||||
|
return ffmpeg.SWS_CS_ITU601;
|
||||||
|
case ColorMatrixBT2020:
|
||||||
|
return ffmpeg.SWS_CS_BT2020;
|
||||||
|
default:
|
||||||
|
return ffmpeg.SWS_CS_ITU709;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true when the Y/U/V samples of the frame use the full range (0-255) instead of the limited range (16-235 / 16-240).
|
||||||
|
/// The range is given by the frame's color_range tag or by a deprecated full range pixel format (YUVJ*).
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsFullRange(AVFrame* frame)
|
||||||
|
{
|
||||||
|
if (frame->color_range == AVColorRange.AVCOL_RANGE_JPEG) return true;
|
||||||
|
|
||||||
|
switch ((AVPixelFormat)frame->format)
|
||||||
|
{
|
||||||
|
case AVPixelFormat.AV_PIX_FMT_YUVJ420P:
|
||||||
|
case AVPixelFormat.AV_PIX_FMT_YUVJ422P:
|
||||||
|
case AVPixelFormat.AV_PIX_FMT_YUVJ444P:
|
||||||
|
case AVPixelFormat.AV_PIX_FMT_YUVJ440P:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lazily initializes or updates the <see cref="SwsContext"/> when source/destination formats, dimensions,
|
||||||
|
/// or (for RGBA output) the colorspace / range of the source frame change.
|
||||||
|
/// For RGBA output the YUV→RGB matrix and the source range are taken from <paramref name="colorFrame"/>.
|
||||||
/// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html
|
/// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void InitOrUpdateSwsContext(AVPixelFormat srcFormat, int srcWidth, int srcHeight, AVPixelFormat dstFormat)
|
/// <param name="srcFormat">Pixel format of the source frame.</param>
|
||||||
|
/// <param name="srcWidth">Width of the source frame in pixels.</param>
|
||||||
|
/// <param name="srcHeight">Height of the source frame in pixels.</param>
|
||||||
|
/// <param name="dstFormat">Pixel format of the output.</param>
|
||||||
|
/// <param name="colorFrame">Source frame whose colorspace / range tags are applied. Required for RGBA output.</param>
|
||||||
|
private void InitOrUpdateSwsContext(AVPixelFormat srcFormat, int srcWidth, int srcHeight, AVPixelFormat dstFormat, AVFrame* colorFrame = null)
|
||||||
{
|
{
|
||||||
if (_pSwsContext != null && _srcPixFmt == srcFormat && _srcWidth == srcWidth && _srcHeight == srcHeight && _dstPixFmt == dstFormat)
|
var swsColorspace = 0;
|
||||||
|
var fullRange = false;
|
||||||
|
if (colorFrame != null && dstFormat == AVPixelFormat.AV_PIX_FMT_RGBA)
|
||||||
|
{
|
||||||
|
swsColorspace = GetSwsColorspace(colorFrame->colorspace);
|
||||||
|
fullRange = IsFullRange(colorFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_pSwsContext != null && _srcPixFmt == srcFormat && _srcWidth == srcWidth && _srcHeight == srcHeight && _dstPixFmt == dstFormat
|
||||||
|
&& _srcSwsColorspace == swsColorspace && _srcFullRange == fullRange)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -763,6 +919,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
_srcWidth = srcWidth;
|
_srcWidth = srcWidth;
|
||||||
_srcHeight = srcHeight;
|
_srcHeight = srcHeight;
|
||||||
_dstPixFmt = dstFormat;
|
_dstPixFmt = dstFormat;
|
||||||
|
_srcSwsColorspace = swsColorspace;
|
||||||
|
_srcFullRange = fullRange;
|
||||||
|
|
||||||
_pSwsContext = ffmpeg.sws_getContext(
|
_pSwsContext = ffmpeg.sws_getContext(
|
||||||
srcWidth, srcHeight, srcFormat,
|
srcWidth, srcHeight, srcFormat,
|
||||||
@@ -774,6 +932,44 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
{
|
{
|
||||||
throw new Exception($"Failed to initialize SwsContext (src: {srcFormat} {srcWidth}x{srcHeight} -> dst: {dstFormat} {Width}x{Height}).");
|
throw new Exception($"Failed to initialize SwsContext (src: {srcFormat} {srcWidth}x{srcHeight} -> dst: {dstFormat} {Width}x{Height}).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (swsColorspace != 0)
|
||||||
|
{
|
||||||
|
ApplySwsColorspace(swsColorspace, fullRange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the YUV→RGB matrix and the source range of <see cref="_pSwsContext"/>.
|
||||||
|
/// The destination range, brightness, contrast and saturation keep the values of the context.
|
||||||
|
/// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html (sws_setColorspaceDetails)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="swsColorspace">Matrix id (<c>SWS_CS_*</c>) of the source.</param>
|
||||||
|
/// <param name="fullRange">True when the source uses the full range.</param>
|
||||||
|
private void ApplySwsColorspace(int swsColorspace, bool fullRange)
|
||||||
|
{
|
||||||
|
int* currentInvTable;
|
||||||
|
int* currentTable;
|
||||||
|
int srcRange, dstRange, brightness, contrast, saturation;
|
||||||
|
|
||||||
|
// Returns a negative value when the context cannot change its colorspace details (e.g. YUV to YUV conversion).
|
||||||
|
if (ffmpeg.sws_getColorspaceDetails(_pSwsContext, ¤tInvTable, &srcRange, ¤tTable, &dstRange,
|
||||||
|
&brightness, &contrast, &saturation) < 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var coefficients = ffmpeg.sws_getCoefficients(swsColorspace);
|
||||||
|
var invTable = new int_array4();
|
||||||
|
var table = new int_array4();
|
||||||
|
for (uint i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
invTable[i] = coefficients[i];
|
||||||
|
table[i] = currentTable[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpeg.sws_setColorspaceDetails(_pSwsContext, invTable, fullRange ? 1 : 0, table, dstRange,
|
||||||
|
brightness, contrast, saturation);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -865,7 +1061,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
if (frame == null) return false;
|
if (frame == null) return false;
|
||||||
|
|
||||||
timestamp = GetTimestampAndUpdateIndex(frame);
|
timestamp = GetTimestampAndUpdateIndex(frame);
|
||||||
InitOrUpdateSwsContext((AVPixelFormat)frame->format, frame->width, frame->height, AVPixelFormat.AV_PIX_FMT_RGBA);
|
InitOrUpdateSwsContext((AVPixelFormat)frame->format, frame->width, frame->height, AVPixelFormat.AV_PIX_FMT_RGBA, frame);
|
||||||
|
|
||||||
ClearConvertedFramePointers();
|
ClearConvertedFramePointers();
|
||||||
if (vflip)
|
if (vflip)
|
||||||
@@ -929,7 +1125,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="yBuffer">Pointer to destination buffer for Y plane (Width * Height bytes).</param>
|
/// <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="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="uvStride">Row stride in bytes for UV plane.</param>
|
||||||
/// <param name="timestamp">Timestamp of the output frame in seconds.</param>
|
/// <param name="timestamp">Timestamp of the output frame in seconds.</param>
|
||||||
/// <returns>True if a frame was successfully retrieved; false on EOF.</returns>
|
/// <returns>True if a frame was successfully retrieved; false on EOF.</returns>
|
||||||
@@ -982,7 +1178,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
int w = Width;
|
int w = Width;
|
||||||
int h = Height;
|
int h = Height;
|
||||||
int uvH = (h + 1) / 2;
|
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)
|
// Copy Y plane (row by row to safely skip linesize GPU padding)
|
||||||
for (int y = 0; y < h; y++)
|
for (int y = 0; y < h; y++)
|
||||||
@@ -1043,7 +1239,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
int w = Width;
|
int w = Width;
|
||||||
int h = Height;
|
int h = Height;
|
||||||
int uvH = (h + 1) / 2;
|
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)
|
if (yBuffer.Length < (long)w * h || uvBuffer.Length < (long)uvRowBytes * uvH)
|
||||||
{
|
{
|
||||||
@@ -1212,107 +1408,24 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Decodes and discards a specified number of frames immediately without GPU-to-CPU transfer overhead.
|
/// Maximum distance in frames over which <see cref="SeekToTime"/> moves forward by decoding.
|
||||||
|
/// Decoding forward needs no keyframe seek and no decoder flush, which is cheaper than seeking for short distances.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="count">Number of frames to discard.</param>
|
private const int MaxForwardDecodeFrames = 60;
|
||||||
private void SkipFrames(int count)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
if (!_isOpened || _disposed) return;
|
|
||||||
if (count <= 0) return;
|
|
||||||
|
|
||||||
int skipped = 0;
|
|
||||||
if (_hasCachedFrame)
|
|
||||||
{
|
|
||||||
ffmpeg.av_frame_unref(_pCachedFrame);
|
|
||||||
_hasCachedFrame = false;
|
|
||||||
skipped++;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (skipped < count)
|
|
||||||
{
|
|
||||||
int ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame);
|
|
||||||
if (ret == 0)
|
|
||||||
{
|
|
||||||
GetTimestampAndUpdateIndex(_pDecodedFrame);
|
|
||||||
ffmpeg.av_frame_unref(_pDecodedFrame);
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
|
||||||
{
|
|
||||||
if (_isDraining) break;
|
|
||||||
|
|
||||||
if (!ReadAndSendPacket())
|
|
||||||
{
|
|
||||||
_isDraining = true;
|
|
||||||
ffmpeg.avcodec_send_packet(_pCodecContext, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (ret == ffmpeg.AVERROR_EOF)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new Exception($"Error skipping frames: {GetFFmpegErrorString(ret)}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeks to a specific playback time in seconds using backward keyframe seek followed by forward decode.
|
/// Decodes forward from the current decoder position until the first frame whose presentation time is at least
|
||||||
/// Intermediate frames are discarded without GPU-to-host memory transfer to maximize seek speed;
|
/// <paramref name="timeSeconds"/> minus half a frame, and keeps that frame in <see cref="_pCachedFrame"/>
|
||||||
/// only the target frame is transferred and preserved in <see cref="_pCachedFrame"/>.
|
/// for the next <c>TryRead*</c> call.
|
||||||
|
/// Frames before the target are discarded without GPU-to-host memory transfer; only the target frame is transferred.
|
||||||
|
/// When the end of the stream is reached before the target, no frame is cached.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="timeSeconds">Target time in seconds to seek to.</param>
|
/// <param name="timeSeconds">Target time in seconds.</param>
|
||||||
public void SeekToTime(double timeSeconds)
|
private void DecodeToTarget(double timeSeconds)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
if (!_isOpened || _disposed) return;
|
|
||||||
|
|
||||||
if (timeSeconds < 0) timeSeconds = 0;
|
|
||||||
double targetFrameIndex = timeSeconds * FPS;
|
|
||||||
|
|
||||||
// If already at target index or very close future, skip frames instead of seek to avoid I-frame seek latency
|
|
||||||
bool useSkip = targetFrameIndex >= _currentFrameIndex && targetFrameIndex - _currentFrameIndex < 60;
|
|
||||||
if (useSkip)
|
|
||||||
{
|
|
||||||
SkipFrames((int)(targetFrameIndex - _currentFrameIndex));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_hasCachedFrame)
|
|
||||||
{
|
|
||||||
ffmpeg.av_frame_unref(_pCachedFrame);
|
|
||||||
_hasCachedFrame = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
long targetPts = (long)Math.Round(timeSeconds / ffmpeg.av_q2d(_timeBase));
|
|
||||||
int ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, targetPts, ffmpeg.AVSEEK_FLAG_BACKWARD);
|
|
||||||
if (ret < 0)
|
|
||||||
{
|
|
||||||
// Fallback to beginning of file on seek failure
|
|
||||||
ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, 0, ffmpeg.AVSEEK_FLAG_BACKWARD);
|
|
||||||
if (ret < 0)
|
|
||||||
{
|
|
||||||
throw new Exception($"Seek failed: {GetFFmpegErrorString(ret)}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush decoder internal buffers
|
|
||||||
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
|
|
||||||
ffmpeg.avcodec_flush_buffers(_pCodecContext);
|
|
||||||
_isDraining = false;
|
|
||||||
|
|
||||||
// Decode frames until reaching the target presentation timestamp
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame);
|
int ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame);
|
||||||
if (ret == 0)
|
if (ret == 0)
|
||||||
{
|
{
|
||||||
double currentPts = GetFrameTimestamp(_pDecodedFrame);
|
double currentPts = GetFrameTimestamp(_pDecodedFrame);
|
||||||
@@ -1343,28 +1456,18 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
_hasCachedFrame = true;
|
_hasCachedFrame = true;
|
||||||
break;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discard non-matching intermediate frame without transferring to CPU
|
// Discard non-matching intermediate frame without transferring to CPU
|
||||||
long ptsValue = _pDecodedFrame->best_effort_timestamp != ffmpeg.AV_NOPTS_VALUE
|
GetTimestampAndUpdateIndex(_pDecodedFrame);
|
||||||
? _pDecodedFrame->best_effort_timestamp
|
|
||||||
: _pDecodedFrame->pts;
|
|
||||||
if (ptsValue == ffmpeg.AV_NOPTS_VALUE)
|
|
||||||
{
|
|
||||||
_currentFrameIndex++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_currentFrameIndex = (long)Math.Round(currentPts * FPS);
|
|
||||||
}
|
|
||||||
ffmpeg.av_frame_unref(_pDecodedFrame);
|
ffmpeg.av_frame_unref(_pDecodedFrame);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
||||||
{
|
{
|
||||||
if (_isDraining) break;
|
if (_isDraining) return;
|
||||||
|
|
||||||
if (!ReadAndSendPacket())
|
if (!ReadAndSendPacket())
|
||||||
{
|
{
|
||||||
@@ -1374,7 +1477,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
}
|
}
|
||||||
else if (ret == ffmpeg.AVERROR_EOF)
|
else if (ret == ffmpeg.AVERROR_EOF)
|
||||||
{
|
{
|
||||||
break;
|
return;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1382,6 +1485,87 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves the playback position to a time in seconds.
|
||||||
|
/// The next <c>TryRead*</c> call returns the first frame whose presentation time is at least
|
||||||
|
/// <paramref name="timeSeconds"/> minus half a frame (the frame nearest to the target time).
|
||||||
|
/// Both paths below return that same frame:
|
||||||
|
/// - Forward decode: the target is ahead of the current position by less than <see cref="MaxForwardDecodeFrames"/> frames.
|
||||||
|
/// Frames are decoded and discarded up to the target.
|
||||||
|
/// - Keyframe seek: all other cases (target before the current position, far ahead, or the target frame already read).
|
||||||
|
/// A backward keyframe seek is followed by a forward decode up to the target.
|
||||||
|
/// The target frame is transferred and preserved in <see cref="_pCachedFrame"/>; when it is already there, nothing is decoded.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="timeSeconds">Target time in seconds to seek to.</param>
|
||||||
|
public void SeekToTime(double timeSeconds)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_isOpened || _disposed) return;
|
||||||
|
|
||||||
|
if (timeSeconds < 0) timeSeconds = 0;
|
||||||
|
|
||||||
|
// Earliest presentation time that satisfies the target (target minus half a frame).
|
||||||
|
double threshold = timeSeconds - (0.5 / FPS);
|
||||||
|
|
||||||
|
// Index of the first frame that satisfies the target. The epsilon keeps a frame that lies exactly on the
|
||||||
|
// margin from being excluded by floating point rounding.
|
||||||
|
double targetFrameIndex = Math.Ceiling(timeSeconds * FPS - 0.5 - 1e-6);
|
||||||
|
|
||||||
|
if (_hasCachedFrame)
|
||||||
|
{
|
||||||
|
// A frame is already positioned for the next read (e.g. by a previous SeekToTime call).
|
||||||
|
double cachedPts = GetFrameTimestamp(_pCachedFrame);
|
||||||
|
if (cachedPts >= threshold)
|
||||||
|
{
|
||||||
|
// The cached frame is the first frame that satisfies the target.
|
||||||
|
// When the target lies before it, the frames in between are no longer available and a keyframe seek is required.
|
||||||
|
if (cachedPts - threshold < 1.0 / FPS) return;
|
||||||
|
}
|
||||||
|
else if (targetFrameIndex - _currentFrameIndex < MaxForwardDecodeFrames)
|
||||||
|
{
|
||||||
|
// The cached frame lies before the target: drop it and continue decoding forward.
|
||||||
|
GetTimestampAndUpdateIndex(_pCachedFrame);
|
||||||
|
ffmpeg.av_frame_unref(_pCachedFrame);
|
||||||
|
_hasCachedFrame = false;
|
||||||
|
DecodeToTarget(timeSeconds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (targetFrameIndex > _currentFrameIndex && targetFrameIndex - _currentFrameIndex < MaxForwardDecodeFrames)
|
||||||
|
{
|
||||||
|
// The target frame has not been read yet and is close: decode forward instead of seeking.
|
||||||
|
DecodeToTarget(timeSeconds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_hasCachedFrame)
|
||||||
|
{
|
||||||
|
ffmpeg.av_frame_unref(_pCachedFrame);
|
||||||
|
_hasCachedFrame = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
long targetPts = (long)Math.Round(timeSeconds / ffmpeg.av_q2d(_timeBase));
|
||||||
|
int ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, targetPts, ffmpeg.AVSEEK_FLAG_BACKWARD);
|
||||||
|
if (ret < 0)
|
||||||
|
{
|
||||||
|
// Fallback to beginning of file on seek failure
|
||||||
|
ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, 0, ffmpeg.AVSEEK_FLAG_BACKWARD);
|
||||||
|
if (ret < 0)
|
||||||
|
{
|
||||||
|
throw new Exception($"Seek failed: {GetFFmpegErrorString(ret)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush decoder internal buffers
|
||||||
|
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
|
||||||
|
ffmpeg.avcodec_flush_buffers(_pCodecContext);
|
||||||
|
_isDraining = false;
|
||||||
|
|
||||||
|
// Decode frames until reaching the target presentation timestamp
|
||||||
|
DecodeToTarget(timeSeconds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
+513
-163
File diff suppressed because it is too large.
Load diff
+43
-6
@@ -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)
|
public void RequestFrameCapture(Texture texture, long currentFrame)
|
||||||
{
|
{
|
||||||
if (!RenderManager.IsCapturingActive) return;
|
if (!RenderManager.IsCapturingActive) return;
|
||||||
@@ -140,7 +150,7 @@ namespace ADOFAIRenderer.Util
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (RenderManager.Instance && RenderManager.Instance.IsRenderCancelled)
|
if (RenderManager.Instance && RenderManager.Instance.IsRenderAborted)
|
||||||
{
|
{
|
||||||
RecycleState(state);
|
RecycleState(state);
|
||||||
return;
|
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.");
|
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 = CreateBlackFrame(state, out var byteSize);
|
||||||
var blackFrame = ArrayPool<byte>.Shared.Rent(byteSize);
|
|
||||||
Array.Clear(blackFrame, 0, byteSize);
|
|
||||||
|
|
||||||
var capFrame = new RenderManager.CapturedFrame
|
var capFrame = new RenderManager.CapturedFrame
|
||||||
{
|
{
|
||||||
ManagedBuffer = blackFrame, Length = byteSize,
|
ManagedBuffer = blackFrame, Length = byteSize,
|
||||||
IsColorConverted = true
|
IsColorConverted = state.IsGPUConversion
|
||||||
};
|
};
|
||||||
PushToQueue(frame, capFrame);
|
PushToQueue(frame, capFrame);
|
||||||
RecycleState(state);
|
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)
|
internal void RecycleState(ReadbackRequestState state)
|
||||||
{
|
{
|
||||||
lock (_poolLock)
|
lock (_poolLock)
|
||||||
|
|||||||
+2
-1
@@ -4,6 +4,7 @@ using System.Linq;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using ADOFAIRenderer.Data;
|
using ADOFAIRenderer.Data;
|
||||||
using ADOFAIRenderer.Util.FFmpeg;
|
using ADOFAIRenderer.Util.FFmpeg;
|
||||||
|
using ADOFAIRenderer.Util.OS;
|
||||||
using MelonLoader;
|
using MelonLoader;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@ internal static class GPUUtil
|
|||||||
!int.TryParse(regMatch.Groups[4].Value, out var build)) continue;
|
!int.TryParse(regMatch.Groups[4].Value, out var build)) continue;
|
||||||
|
|
||||||
var nvidiaVerInt = sub % 10 * 10000 + build;
|
var nvidiaVerInt = sub % 10 * 10000 + build;
|
||||||
_cachedNvidiaVersion = nvidiaVerInt / 100f;
|
_cachedNvidiaVersion = nvidiaVerInt / 100.0;
|
||||||
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}");
|
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}");
|
||||||
return _cachedNvidiaVersion;
|
return _cachedNvidiaVersion;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MelonLoader;
|
using MelonLoader;
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Util;
|
namespace ADOFAIRenderer.Util.OS;
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
[SuppressMessage("ReSharper", "UnusedMember.Local")]
|
[SuppressMessage("ReSharper", "UnusedMember.Local")]
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading;
|
||||||
|
using MelonLoader;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.OS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends native desktop notifications on Windows, macOS, and Linux.
|
||||||
|
/// <para>
|
||||||
|
/// - <b>Windows</b>: Toast notification shown by a hidden <c>powershell.exe</c> process
|
||||||
|
/// (WinRT <c>ToastNotificationManager</c>, script passed with <c>-EncodedCommand</c>).
|
||||||
|
/// With a folder path, the script also registers the <c>adofai-renderer://</c> URL protocol under
|
||||||
|
/// <c>HKCU\Software\Classes\adofai-renderer</c> (open command: <c>explorer.exe "<folder>"</c>)
|
||||||
|
/// so the toast button opens the folder; the key is overwritten on each notification and stays registered.
|
||||||
|
/// When PowerShell fails, a Win32 <c>Shell_NotifyIcon</c> balloon is shown via P/Invoke.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) with <c>xdg-open</c> for folder actions.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
internal static class OSNotification
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a native desktop notification.
|
||||||
|
/// If <paramref name="folderPath"/> is provided, Windows and Linux will attempt to open
|
||||||
|
/// the folder when the notification is clicked/dismissed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="title">The notification title.</param>
|
||||||
|
/// <param name="message">The notification body text.</param>
|
||||||
|
/// <param name="folderPath">Optional folder path to open on click (Windows balloon click / Linux action).</param>
|
||||||
|
public static void Show(string title, string message, string? folderPath = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
{
|
||||||
|
WindowsNotification.Send(title, message, folderPath);
|
||||||
|
}
|
||||||
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||||
|
{
|
||||||
|
MacNotification.Send(title, message);
|
||||||
|
}
|
||||||
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
{
|
||||||
|
LinuxNotification.Send(title, message, folderPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning($"[OSNotification] Failed to send notification: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Windows — Toast (primary) + Shell_NotifyIcon (fallback)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Windows notification with two strategies:
|
||||||
|
/// <para>1. <b>Toast</b> via PowerShell — persists in Action Center.</para>
|
||||||
|
/// <para>2. <b>Shell_NotifyIcon balloon</b> via P/Invoke — fallback if toast fails.</para>
|
||||||
|
/// </summary>
|
||||||
|
private static class WindowsNotification
|
||||||
|
{
|
||||||
|
public static void Send(string title, string message, string? folderPath)
|
||||||
|
{
|
||||||
|
var thread = new Thread(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!TrySendToast(title, message, folderPath))
|
||||||
|
{
|
||||||
|
SendBalloon(title, message, folderPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning($"[OSNotification] Windows notification failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
Name = "OSNotification-Win"
|
||||||
|
};
|
||||||
|
thread.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Toast via PowerShell
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to show a Windows Toast notification via PowerShell.
|
||||||
|
/// With a folder path, registers the adofai-renderer:// protocol in HKCU first (see class summary).
|
||||||
|
/// Waits up to 5 seconds for the process. Returns true when it exits with code 0,
|
||||||
|
/// false when it exits with another code, does not exit within 5 seconds, or fails to start.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TrySendToast(string title, string message, string? folderPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string xmlTitle = System.Security.SecurityElement.Escape(title) ?? title;
|
||||||
|
string xmlMessage = System.Security.SecurityElement.Escape(message) ?? message;
|
||||||
|
|
||||||
|
string toastXml;
|
||||||
|
string psScript;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(folderPath))
|
||||||
|
{
|
||||||
|
var escapedFolder = folderPath.Replace("'", "''");
|
||||||
|
|
||||||
|
toastXml =
|
||||||
|
"<toast launch=\"adofai-renderer://\" activationType=\"protocol\">" +
|
||||||
|
"<visual><binding template=\"ToastGeneric\">" +
|
||||||
|
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
||||||
|
"</binding></visual>" +
|
||||||
|
"<actions>" +
|
||||||
|
"<action content=\"Open Render Folder\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
|
||||||
|
"</actions></toast>";
|
||||||
|
|
||||||
|
psScript =
|
||||||
|
"$protocolPath = 'HKCU:\\Software\\Classes\\adofai-renderer'\n" +
|
||||||
|
"$null = New-Item -Path $protocolPath -Force\n" +
|
||||||
|
"$null = New-ItemProperty -Path $protocolPath -Name 'URL Protocol' -Value '' -PropertyType String -Force\n" +
|
||||||
|
"Set-Item -Path $protocolPath -Value 'URL:adofai-renderer Protocol' | Out-Null\n" +
|
||||||
|
"$cmdPath = \"$protocolPath\\shell\\open\\command\"\n" +
|
||||||
|
"$null = New-Item -Path $cmdPath -Force\n" +
|
||||||
|
$"Set-Item -Path $cmdPath -Value 'explorer.exe \"{escapedFolder}\"' | Out-Null\n" +
|
||||||
|
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||||
|
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||||
|
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
||||||
|
$"$xml.LoadXml('{toastXml}')\n" +
|
||||||
|
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
toastXml =
|
||||||
|
"<toast><visual><binding template=\"ToastGeneric\">" +
|
||||||
|
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
||||||
|
"</binding></visual></toast>";
|
||||||
|
|
||||||
|
psScript =
|
||||||
|
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||||
|
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||||
|
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
||||||
|
$"$xml.LoadXml('{toastXml}')\n" +
|
||||||
|
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bytes = System.Text.Encoding.Unicode.GetBytes(psScript);
|
||||||
|
string base64 = Convert.ToBase64String(bytes);
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "powershell.exe",
|
||||||
|
Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}",
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
|
||||||
|
using var process = new Process();
|
||||||
|
process.StartInfo = startInfo;
|
||||||
|
process.Start();
|
||||||
|
process.WaitForExit(5000);
|
||||||
|
|
||||||
|
// If PowerShell exited with error, treat as failure
|
||||||
|
if (process.ExitCode != 0)
|
||||||
|
{
|
||||||
|
string stderr = process.StandardError.ReadToEnd().Trim();
|
||||||
|
if (!string.IsNullOrEmpty(stderr))
|
||||||
|
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {stderr}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Balloon via Shell_NotifyIcon
|
||||||
|
|
||||||
|
// ReSharper disable InconsistentNaming
|
||||||
|
private const int NIM_ADD = 0x00;
|
||||||
|
private const int NIM_DELETE = 0x02;
|
||||||
|
|
||||||
|
private const int NIF_MESSAGE = 0x01;
|
||||||
|
private const int NIF_ICON = 0x02;
|
||||||
|
private const int NIF_TIP = 0x04;
|
||||||
|
private const int NIF_INFO = 0x10;
|
||||||
|
|
||||||
|
private const int NIIF_INFO = 0x01;
|
||||||
|
|
||||||
|
private const int WM_USER = 0x0400;
|
||||||
|
private const int WM_TRAYICON = WM_USER + 1;
|
||||||
|
private const int NIN_BALLOONUSERCLICK = 0x0405;
|
||||||
|
|
||||||
|
private const int WS_OVERLAPPED = 0x00000000;
|
||||||
|
private const uint WM_CLOSE = 0x0010;
|
||||||
|
private const int TRAY_ICON_UID = 29471;
|
||||||
|
// ReSharper restore InconsistentNaming
|
||||||
|
|
||||||
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern bool Shell_NotifyIconW(int dwMessage, ref NotifyIconDataW lpData);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern IntPtr CreateWindowExW(
|
||||||
|
int dwExStyle, string lpClassName, string lpWindowName,
|
||||||
|
int dwStyle, int x, int y, int nWidth, int nHeight,
|
||||||
|
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool DestroyWindow(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool GetMessageW(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr TranslateMessage(ref Msg lpMsg);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr DispatchMessageW(ref Msg lpMsg);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
private struct NotifyIconDataW
|
||||||
|
{
|
||||||
|
public int cbSize;
|
||||||
|
public IntPtr hWnd;
|
||||||
|
public int uID;
|
||||||
|
public int uFlags;
|
||||||
|
public int uCallbackMessage;
|
||||||
|
public IntPtr hIcon;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||||
|
public string szTip;
|
||||||
|
public int dwState;
|
||||||
|
public int dwStateMask;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||||
|
public string szInfo;
|
||||||
|
public int uTimeoutOrVersion;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
|
||||||
|
public string szInfoTitle;
|
||||||
|
public int dwInfoFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct Msg
|
||||||
|
{
|
||||||
|
public IntPtr hwnd;
|
||||||
|
public uint message;
|
||||||
|
public IntPtr wParam;
|
||||||
|
public IntPtr lParam;
|
||||||
|
public uint time;
|
||||||
|
public int pt_x;
|
||||||
|
public int pt_y;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SendBalloon(string title, string message, string? folderPath)
|
||||||
|
{
|
||||||
|
if (title.Length > 63) title = title.Substring(0, 63);
|
||||||
|
if (message.Length > 255) message = message.Substring(0, 255);
|
||||||
|
|
||||||
|
IntPtr hWnd = IntPtr.Zero;
|
||||||
|
var nid = new NotifyIconDataW();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var hwndMessage = new IntPtr(-3);
|
||||||
|
hWnd = CreateWindowExW(
|
||||||
|
0, "Static", "ADOFAIRendererNotify",
|
||||||
|
WS_OVERLAPPED, 0, 0, 0, 0,
|
||||||
|
hwndMessage, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
|
||||||
|
|
||||||
|
if (hWnd == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning("[OSNotification] Cannot create message window.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512);
|
||||||
|
|
||||||
|
nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW));
|
||||||
|
nid.hWnd = hWnd;
|
||||||
|
nid.uID = TRAY_ICON_UID;
|
||||||
|
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_INFO;
|
||||||
|
nid.uCallbackMessage = WM_TRAYICON;
|
||||||
|
nid.hIcon = hIcon;
|
||||||
|
nid.szTip = "ADOFAI Renderer";
|
||||||
|
nid.szInfo = message;
|
||||||
|
nid.szInfoTitle = title;
|
||||||
|
nid.dwInfoFlags = NIIF_INFO;
|
||||||
|
nid.uTimeoutOrVersion = 4;
|
||||||
|
|
||||||
|
Shell_NotifyIconW(NIM_ADD, ref nid);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(folderPath))
|
||||||
|
{
|
||||||
|
var capturedFolder = folderPath;
|
||||||
|
var closeTimer = new System.Threading.Timer(_ =>
|
||||||
|
{
|
||||||
|
try { PostMessageW(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); }
|
||||||
|
catch { /* ignored */ }
|
||||||
|
}, null, 12000, Timeout.Infinite);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (GetMessageW(out var msg, IntPtr.Zero, 0, 0))
|
||||||
|
{
|
||||||
|
if (msg.message == WM_CLOSE)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (msg.message == WM_TRAYICON)
|
||||||
|
{
|
||||||
|
int notifyMsg = (int)(long)msg.lParam;
|
||||||
|
if (notifyMsg == NIN_BALLOONUSERCLICK)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo();
|
||||||
|
psi.FileName = capturedFolder;
|
||||||
|
psi.UseShellExecute = true;
|
||||||
|
Process.Start(psi);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning(
|
||||||
|
$"[OSNotification] Cannot open folder: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TranslateMessage(ref msg);
|
||||||
|
DispatchMessageW(ref msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
closeTimer.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Thread.Sleep(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning($"[OSNotification] Balloon notification failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (hWnd != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
nid.uFlags = 0;
|
||||||
|
Shell_NotifyIconW(NIM_DELETE, ref nid);
|
||||||
|
DestroyWindow(hWnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region macOS — osascript
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// macOS notification using <c>osascript -e 'display notification ...'</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static class MacNotification
|
||||||
|
{
|
||||||
|
public static void Send(string title, string message)
|
||||||
|
{
|
||||||
|
// AppleScript string escaping: backslash and double-quote
|
||||||
|
string escapedTitle = EscapeAppleScript(title);
|
||||||
|
string escapedMessage = EscapeAppleScript(message);
|
||||||
|
|
||||||
|
// Use ProcessStartInfo with ArgumentList to avoid shell-level escaping issues.
|
||||||
|
// Each argument is passed directly to the process without shell interpretation.
|
||||||
|
var script =
|
||||||
|
$"display notification \"{escapedMessage}\" with title \"{escapedTitle}\"";
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "osascript",
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add("-e");
|
||||||
|
startInfo.ArgumentList.Add(script);
|
||||||
|
|
||||||
|
RunProcess(startInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapeAppleScript(string value)
|
||||||
|
{
|
||||||
|
// In AppleScript string literals, escape backslash and double-quote
|
||||||
|
return value
|
||||||
|
.Replace("\\", "\\\\")
|
||||||
|
.Replace("\"", "\\\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Linux — notify-send / gdbus
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Linux notification using <c>notify-send</c> (libnotify CLI).
|
||||||
|
/// Falls back to <c>gdbus</c> (D-Bus direct call) if notify-send is unavailable.
|
||||||
|
/// </summary>
|
||||||
|
private static class LinuxNotification
|
||||||
|
{
|
||||||
|
public static void Send(string title, string message, string? folderPath)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(folderPath))
|
||||||
|
{
|
||||||
|
ShowWithAction(title, message, folderPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ShowSimple(title, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ShowSimple(string title, string message)
|
||||||
|
{
|
||||||
|
// Try notify-send first
|
||||||
|
if (TryNotifySend(title, message))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Fallback to gdbus
|
||||||
|
TryGdbus(title, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ShowWithAction(string title, string message, string folderPath)
|
||||||
|
{
|
||||||
|
// Run on a background thread because notify-send with --action blocks
|
||||||
|
// until the notification is dismissed or the action is clicked.
|
||||||
|
System.Threading.Tasks.Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "notify-send",
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add("-a");
|
||||||
|
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
||||||
|
startInfo.ArgumentList.Add("--action=open_folder=Open Render Folder");
|
||||||
|
startInfo.ArgumentList.Add(title);
|
||||||
|
startInfo.ArgumentList.Add(message);
|
||||||
|
|
||||||
|
using var process = new Process();
|
||||||
|
process.StartInfo = startInfo;
|
||||||
|
process.Start();
|
||||||
|
string output = process.StandardOutput.ReadToEnd().Trim();
|
||||||
|
process.WaitForExit();
|
||||||
|
|
||||||
|
if (output == "open_folder")
|
||||||
|
{
|
||||||
|
var openInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "xdg-open",
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
openInfo.ArgumentList.Add(folderPath);
|
||||||
|
|
||||||
|
RunProcess(openInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning($"[OSNotification] Linux notification processing failed: {ex.Message}");
|
||||||
|
|
||||||
|
// Fallback: show without action
|
||||||
|
ShowSimple(title, message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryNotifySend(string title, string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "notify-send",
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add("-a");
|
||||||
|
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
||||||
|
startInfo.ArgumentList.Add(title);
|
||||||
|
startInfo.ArgumentList.Add(message);
|
||||||
|
|
||||||
|
RunProcess(startInfo);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryGdbus(string title, string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Direct D-Bus call to org.freedesktop.Notifications
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "gdbus",
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add("call");
|
||||||
|
startInfo.ArgumentList.Add("--session");
|
||||||
|
startInfo.ArgumentList.Add("--dest=org.freedesktop.Notifications");
|
||||||
|
startInfo.ArgumentList.Add("--object-path=/org/freedesktop/Notifications");
|
||||||
|
startInfo.ArgumentList.Add("--method=org.freedesktop.Notifications.Notify");
|
||||||
|
startInfo.ArgumentList.Add("ADOFAI Renderer"); // app_name
|
||||||
|
startInfo.ArgumentList.Add("0"); // replaces_id
|
||||||
|
startInfo.ArgumentList.Add(""); // app_icon
|
||||||
|
startInfo.ArgumentList.Add(title); // summary
|
||||||
|
startInfo.ArgumentList.Add(message); // body
|
||||||
|
startInfo.ArgumentList.Add("[]"); // actions
|
||||||
|
startInfo.ArgumentList.Add("{}"); // hints
|
||||||
|
startInfo.ArgumentList.Add("-1"); // expire_timeout
|
||||||
|
|
||||||
|
RunProcess(startInfo);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning($"[OSNotification] gdbus notification failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Shared utility
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts a process fire-and-forget. Does not redirect output or wait for exit.
|
||||||
|
/// </summary>
|
||||||
|
private static void RunProcess(ProcessStartInfo startInfo)
|
||||||
|
{
|
||||||
|
using var process = new Process();
|
||||||
|
process.StartInfo = startInfo;
|
||||||
|
process.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.OS;
|
||||||
|
|
||||||
|
internal static class RegistryUtil
|
||||||
|
{
|
||||||
|
private static readonly Type? RegistryKeyType;
|
||||||
|
private static readonly Type? RegistryHiveType;
|
||||||
|
private static readonly Type? RegistryViewType;
|
||||||
|
private static readonly System.Reflection.MethodInfo? OpenBaseKeyMethod;
|
||||||
|
private static readonly System.Reflection.MethodInfo? OpenSubKeyMethod;
|
||||||
|
private static readonly System.Reflection.MethodInfo? GetSubKeyNamesMethod;
|
||||||
|
private static readonly System.Reflection.MethodInfo? GetValueMethod;
|
||||||
|
|
||||||
|
static RegistryUtil()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RegistryKeyType = Type.GetType("Microsoft.Win32.RegistryKey");
|
||||||
|
RegistryHiveType = Type.GetType("Microsoft.Win32.RegistryHive");
|
||||||
|
RegistryViewType = Type.GetType("Microsoft.Win32.RegistryView");
|
||||||
|
|
||||||
|
if (RegistryKeyType == null || RegistryHiveType == null || RegistryViewType == null)
|
||||||
|
{
|
||||||
|
var mscorlib = typeof(object).Assembly;
|
||||||
|
RegistryKeyType = mscorlib.GetType("Microsoft.Win32.RegistryKey");
|
||||||
|
RegistryHiveType = mscorlib.GetType("Microsoft.Win32.RegistryHive");
|
||||||
|
RegistryViewType = mscorlib.GetType("Microsoft.Win32.RegistryView");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RegistryKeyType != null)
|
||||||
|
{
|
||||||
|
OpenBaseKeyMethod = RegistryKeyType.GetMethod("OpenBaseKey", new[] { RegistryHiveType!, RegistryViewType! });
|
||||||
|
OpenSubKeyMethod = RegistryKeyType.GetMethod("OpenSubKey", new[] { typeof(string) });
|
||||||
|
GetSubKeyNamesMethod = RegistryKeyType.GetMethod("GetSubKeyNames");
|
||||||
|
GetValueMethod = RegistryKeyType.GetMethod("GetValue", new[] { typeof(string) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CloseKey(object? key)
|
||||||
|
{
|
||||||
|
if (key == null || RegistryKeyType == null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (key is IDisposable disposable)
|
||||||
|
{
|
||||||
|
disposable.Dispose();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var closeMethod = RegistryKeyType.GetMethod("Close");
|
||||||
|
closeMethod?.Invoke(key, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
|
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
|
||||||
|
{
|
||||||
|
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
||||||
|
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
||||||
|
|
||||||
|
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
||||||
|
if (baseKey == null) return null;
|
||||||
|
|
||||||
|
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { parentKeyPath });
|
||||||
|
if (key == null)
|
||||||
|
{
|
||||||
|
CloseKey(baseKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[]? subkeyNames = GetSubKeyNamesMethod.Invoke(key, null) as string[];
|
||||||
|
CloseKey(key);
|
||||||
|
CloseKey(baseKey);
|
||||||
|
return subkeyNames;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
|
public static string? GetHKLMValue(string keyPath, string valueName)
|
||||||
|
{
|
||||||
|
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
||||||
|
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
||||||
|
|
||||||
|
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
||||||
|
if (baseKey == null) return null;
|
||||||
|
|
||||||
|
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { keyPath });
|
||||||
|
if (key == null)
|
||||||
|
{
|
||||||
|
CloseKey(baseKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? value = GetValueMethod.Invoke(key, new object[] { valueName }) as string;
|
||||||
|
CloseKey(key);
|
||||||
|
CloseKey(baseKey);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,578 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Threading;
|
|
||||||
using MelonLoader;
|
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Util
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Sends native desktop notifications on Windows, macOS, and Linux.
|
|
||||||
/// <para>
|
|
||||||
/// - <b>Windows</b>: Uses Win32 <c>Shell_NotifyIcon</c> (system tray balloon) via P/Invoke.
|
|
||||||
/// No PowerShell, no UWP, no WinRT dependencies.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c>.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) with <c>xdg-open</c> for folder actions.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
internal static class OSNotification
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Sends a native desktop notification.
|
|
||||||
/// If <paramref name="folderPath"/> is provided, Windows and Linux will attempt to open
|
|
||||||
/// the folder when the notification is clicked/dismissed.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="title">The notification title.</param>
|
|
||||||
/// <param name="message">The notification body text.</param>
|
|
||||||
/// <param name="folderPath">Optional folder path to open on click (Windows balloon click / Linux action).</param>
|
|
||||||
public static void Show(string title, string message, string? folderPath = null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
|
||||||
{
|
|
||||||
WindowsNotification.Send(title, message, folderPath);
|
|
||||||
}
|
|
||||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
|
||||||
{
|
|
||||||
MacNotification.Send(title, message);
|
|
||||||
}
|
|
||||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
|
||||||
{
|
|
||||||
LinuxNotification.Send(title, message, folderPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning($"[OSNotification] Failed to send notification: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Windows — Toast (primary) + Shell_NotifyIcon (fallback)
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Windows notification with two strategies:
|
|
||||||
/// <para>1. <b>Toast</b> via PowerShell — persists in Action Center.</para>
|
|
||||||
/// <para>2. <b>Shell_NotifyIcon balloon</b> via P/Invoke — fallback if toast fails.</para>
|
|
||||||
/// </summary>
|
|
||||||
private static class WindowsNotification
|
|
||||||
{
|
|
||||||
public static void Send(string title, string message, string? folderPath)
|
|
||||||
{
|
|
||||||
var thread = new Thread(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!TrySendToast(title, message, folderPath))
|
|
||||||
{
|
|
||||||
SendBalloon(title, message, folderPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning($"[OSNotification] Windows notification failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
{
|
|
||||||
IsBackground = true,
|
|
||||||
Name = "OSNotification-Win"
|
|
||||||
};
|
|
||||||
thread.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Toast via PowerShell
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Attempts to show a Windows Toast notification via PowerShell.
|
|
||||||
/// Returns true if the process launched successfully, false otherwise.
|
|
||||||
/// </summary>
|
|
||||||
private static bool TrySendToast(string title, string message, string? folderPath)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
string xmlTitle = System.Security.SecurityElement.Escape(title) ?? title;
|
|
||||||
string xmlMessage = System.Security.SecurityElement.Escape(message) ?? message;
|
|
||||||
|
|
||||||
string toastXml;
|
|
||||||
string psScript;
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(folderPath))
|
|
||||||
{
|
|
||||||
var escapedFolder = folderPath.Replace("'", "''");
|
|
||||||
|
|
||||||
toastXml =
|
|
||||||
"<toast launch=\"adofai-renderer://\" activationType=\"protocol\">" +
|
|
||||||
"<visual><binding template=\"ToastGeneric\">" +
|
|
||||||
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
|
||||||
"</binding></visual>" +
|
|
||||||
"<actions>" +
|
|
||||||
"<action content=\"Open Render Folder\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
|
|
||||||
"</actions></toast>";
|
|
||||||
|
|
||||||
psScript =
|
|
||||||
"$protocolPath = 'HKCU:\\Software\\Classes\\adofai-renderer'\n" +
|
|
||||||
"$null = New-Item -Path $protocolPath -Force\n" +
|
|
||||||
"$null = New-ItemProperty -Path $protocolPath -Name 'URL Protocol' -Value '' -PropertyType String -Force\n" +
|
|
||||||
"Set-Item -Path $protocolPath -Value 'URL:adofai-renderer Protocol' | Out-Null\n" +
|
|
||||||
"$cmdPath = \"$protocolPath\\shell\\open\\command\"\n" +
|
|
||||||
"$null = New-Item -Path $cmdPath -Force\n" +
|
|
||||||
$"Set-Item -Path $cmdPath -Value 'explorer.exe \"{escapedFolder}\"' | Out-Null\n" +
|
|
||||||
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
|
||||||
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
|
||||||
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
|
||||||
$"$xml.LoadXml('{toastXml}')\n" +
|
|
||||||
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
toastXml =
|
|
||||||
"<toast><visual><binding template=\"ToastGeneric\">" +
|
|
||||||
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
|
||||||
"</binding></visual></toast>";
|
|
||||||
|
|
||||||
psScript =
|
|
||||||
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
|
||||||
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
|
||||||
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
|
||||||
$"$xml.LoadXml('{toastXml}')\n" +
|
|
||||||
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] bytes = System.Text.Encoding.Unicode.GetBytes(psScript);
|
|
||||||
string base64 = Convert.ToBase64String(bytes);
|
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "powershell.exe",
|
|
||||||
Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}",
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
|
|
||||||
using var process = new Process();
|
|
||||||
process.StartInfo = startInfo;
|
|
||||||
process.Start();
|
|
||||||
process.WaitForExit(5000);
|
|
||||||
|
|
||||||
// If PowerShell exited with error, treat as failure
|
|
||||||
if (process.ExitCode != 0)
|
|
||||||
{
|
|
||||||
string stderr = process.StandardError.ReadToEnd().Trim();
|
|
||||||
if (!string.IsNullOrEmpty(stderr))
|
|
||||||
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {stderr}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {ex.Message}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Balloon via Shell_NotifyIcon
|
|
||||||
|
|
||||||
// ReSharper disable InconsistentNaming
|
|
||||||
private const int NIM_ADD = 0x00;
|
|
||||||
private const int NIM_DELETE = 0x02;
|
|
||||||
|
|
||||||
private const int NIF_MESSAGE = 0x01;
|
|
||||||
private const int NIF_ICON = 0x02;
|
|
||||||
private const int NIF_TIP = 0x04;
|
|
||||||
private const int NIF_INFO = 0x10;
|
|
||||||
|
|
||||||
private const int NIIF_INFO = 0x01;
|
|
||||||
|
|
||||||
private const int WM_USER = 0x0400;
|
|
||||||
private const int WM_TRAYICON = WM_USER + 1;
|
|
||||||
private const int NIN_BALLOONUSERCLICK = 0x0405;
|
|
||||||
|
|
||||||
private const int WS_OVERLAPPED = 0x00000000;
|
|
||||||
private const uint WM_CLOSE = 0x0010;
|
|
||||||
private const int TRAY_ICON_UID = 29471;
|
|
||||||
// ReSharper restore InconsistentNaming
|
|
||||||
|
|
||||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
|
||||||
private static extern bool Shell_NotifyIconW(int dwMessage, ref NotifyIconDataW lpData);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
||||||
private static extern IntPtr CreateWindowExW(
|
|
||||||
int dwExStyle, string lpClassName, string lpWindowName,
|
|
||||||
int dwStyle, int x, int y, int nWidth, int nHeight,
|
|
||||||
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool DestroyWindow(IntPtr hWnd);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool GetMessageW(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern IntPtr TranslateMessage(ref Msg lpMsg);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern IntPtr DispatchMessageW(ref Msg lpMsg);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
||||||
private struct NotifyIconDataW
|
|
||||||
{
|
|
||||||
public int cbSize;
|
|
||||||
public IntPtr hWnd;
|
|
||||||
public int uID;
|
|
||||||
public int uFlags;
|
|
||||||
public int uCallbackMessage;
|
|
||||||
public IntPtr hIcon;
|
|
||||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
|
||||||
public string szTip;
|
|
||||||
public int dwState;
|
|
||||||
public int dwStateMask;
|
|
||||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
|
||||||
public string szInfo;
|
|
||||||
public int uTimeoutOrVersion;
|
|
||||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
|
|
||||||
public string szInfoTitle;
|
|
||||||
public int dwInfoFlags;
|
|
||||||
}
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
private struct Msg
|
|
||||||
{
|
|
||||||
public IntPtr hwnd;
|
|
||||||
public uint message;
|
|
||||||
public IntPtr wParam;
|
|
||||||
public IntPtr lParam;
|
|
||||||
public uint time;
|
|
||||||
public int pt_x;
|
|
||||||
public int pt_y;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SendBalloon(string title, string message, string? folderPath)
|
|
||||||
{
|
|
||||||
if (title.Length > 63) title = title.Substring(0, 63);
|
|
||||||
if (message.Length > 255) message = message.Substring(0, 255);
|
|
||||||
|
|
||||||
IntPtr hWnd = IntPtr.Zero;
|
|
||||||
var nid = new NotifyIconDataW();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var hwndMessage = new IntPtr(-3);
|
|
||||||
hWnd = CreateWindowExW(
|
|
||||||
0, "Static", "ADOFAIRendererNotify",
|
|
||||||
WS_OVERLAPPED, 0, 0, 0, 0,
|
|
||||||
hwndMessage, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
|
|
||||||
|
|
||||||
if (hWnd == IntPtr.Zero)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning("[OSNotification] Cannot create message window.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512);
|
|
||||||
|
|
||||||
nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW));
|
|
||||||
nid.hWnd = hWnd;
|
|
||||||
nid.uID = TRAY_ICON_UID;
|
|
||||||
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_INFO;
|
|
||||||
nid.uCallbackMessage = WM_TRAYICON;
|
|
||||||
nid.hIcon = hIcon;
|
|
||||||
nid.szTip = "ADOFAI Renderer";
|
|
||||||
nid.szInfo = message;
|
|
||||||
nid.szInfoTitle = title;
|
|
||||||
nid.dwInfoFlags = NIIF_INFO;
|
|
||||||
nid.uTimeoutOrVersion = 4;
|
|
||||||
|
|
||||||
Shell_NotifyIconW(NIM_ADD, ref nid);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(folderPath))
|
|
||||||
{
|
|
||||||
var capturedFolder = folderPath;
|
|
||||||
var closeTimer = new System.Threading.Timer(_ =>
|
|
||||||
{
|
|
||||||
try { PostMessageW(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); }
|
|
||||||
catch { /* ignored */ }
|
|
||||||
}, null, 12000, Timeout.Infinite);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (GetMessageW(out var msg, IntPtr.Zero, 0, 0))
|
|
||||||
{
|
|
||||||
if (msg.message == WM_CLOSE)
|
|
||||||
break;
|
|
||||||
|
|
||||||
if (msg.message == WM_TRAYICON)
|
|
||||||
{
|
|
||||||
int notifyMsg = (int)(long)msg.lParam;
|
|
||||||
if (notifyMsg == NIN_BALLOONUSERCLICK)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var psi = new ProcessStartInfo();
|
|
||||||
psi.FileName = capturedFolder;
|
|
||||||
psi.UseShellExecute = true;
|
|
||||||
Process.Start(psi);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning(
|
|
||||||
$"[OSNotification] Cannot open folder: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TranslateMessage(ref msg);
|
|
||||||
DispatchMessageW(ref msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
closeTimer.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Thread.Sleep(500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning($"[OSNotification] Balloon notification failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (hWnd != IntPtr.Zero)
|
|
||||||
{
|
|
||||||
nid.uFlags = 0;
|
|
||||||
Shell_NotifyIconW(NIM_DELETE, ref nid);
|
|
||||||
DestroyWindow(hWnd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region macOS — osascript
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// macOS notification using <c>osascript -e 'display notification ...'</c>.
|
|
||||||
/// </summary>
|
|
||||||
private static class MacNotification
|
|
||||||
{
|
|
||||||
public static void Send(string title, string message)
|
|
||||||
{
|
|
||||||
// AppleScript string escaping: backslash and double-quote
|
|
||||||
string escapedTitle = EscapeAppleScript(title);
|
|
||||||
string escapedMessage = EscapeAppleScript(message);
|
|
||||||
|
|
||||||
// Use ProcessStartInfo with ArgumentList to avoid shell-level escaping issues.
|
|
||||||
// Each argument is passed directly to the process without shell interpretation.
|
|
||||||
var script =
|
|
||||||
$"display notification \"{escapedMessage}\" with title \"{escapedTitle}\"";
|
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "osascript",
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
startInfo.ArgumentList.Add("-e");
|
|
||||||
startInfo.ArgumentList.Add(script);
|
|
||||||
|
|
||||||
RunProcess(startInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string EscapeAppleScript(string value)
|
|
||||||
{
|
|
||||||
// In AppleScript string literals, escape backslash and double-quote
|
|
||||||
return value
|
|
||||||
.Replace("\\", "\\\\")
|
|
||||||
.Replace("\"", "\\\"");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Linux — notify-send / gdbus
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Linux notification using <c>notify-send</c> (libnotify CLI).
|
|
||||||
/// Falls back to <c>gdbus</c> (D-Bus direct call) if notify-send is unavailable.
|
|
||||||
/// </summary>
|
|
||||||
private static class LinuxNotification
|
|
||||||
{
|
|
||||||
public static void Send(string title, string message, string? folderPath)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(folderPath))
|
|
||||||
{
|
|
||||||
ShowWithAction(title, message, folderPath);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ShowSimple(title, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ShowSimple(string title, string message)
|
|
||||||
{
|
|
||||||
// Try notify-send first
|
|
||||||
if (TryNotifySend(title, message))
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Fallback to gdbus
|
|
||||||
TryGdbus(title, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ShowWithAction(string title, string message, string folderPath)
|
|
||||||
{
|
|
||||||
// Run on a background thread because notify-send with --action blocks
|
|
||||||
// until the notification is dismissed or the action is clicked.
|
|
||||||
System.Threading.Tasks.Task.Run(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "notify-send",
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
startInfo.ArgumentList.Add("-a");
|
|
||||||
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
|
||||||
startInfo.ArgumentList.Add("--action=open_folder=Open Render Folder");
|
|
||||||
startInfo.ArgumentList.Add(title);
|
|
||||||
startInfo.ArgumentList.Add(message);
|
|
||||||
|
|
||||||
using var process = new Process();
|
|
||||||
process.StartInfo = startInfo;
|
|
||||||
process.Start();
|
|
||||||
string output = process.StandardOutput.ReadToEnd().Trim();
|
|
||||||
process.WaitForExit();
|
|
||||||
|
|
||||||
if (output == "open_folder")
|
|
||||||
{
|
|
||||||
var openInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "xdg-open",
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
openInfo.ArgumentList.Add(folderPath);
|
|
||||||
|
|
||||||
RunProcess(openInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning($"[OSNotification] Linux notification processing failed: {ex.Message}");
|
|
||||||
|
|
||||||
// Fallback: show without action
|
|
||||||
ShowSimple(title, message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryNotifySend(string title, string message)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "notify-send",
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
startInfo.ArgumentList.Add("-a");
|
|
||||||
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
|
||||||
startInfo.ArgumentList.Add(title);
|
|
||||||
startInfo.ArgumentList.Add(message);
|
|
||||||
|
|
||||||
RunProcess(startInfo);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void TryGdbus(string title, string message)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Direct D-Bus call to org.freedesktop.Notifications
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "gdbus",
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
startInfo.ArgumentList.Add("call");
|
|
||||||
startInfo.ArgumentList.Add("--session");
|
|
||||||
startInfo.ArgumentList.Add("--dest=org.freedesktop.Notifications");
|
|
||||||
startInfo.ArgumentList.Add("--object-path=/org/freedesktop/Notifications");
|
|
||||||
startInfo.ArgumentList.Add("--method=org.freedesktop.Notifications.Notify");
|
|
||||||
startInfo.ArgumentList.Add("ADOFAI Renderer"); // app_name
|
|
||||||
startInfo.ArgumentList.Add("0"); // replaces_id
|
|
||||||
startInfo.ArgumentList.Add(""); // app_icon
|
|
||||||
startInfo.ArgumentList.Add(title); // summary
|
|
||||||
startInfo.ArgumentList.Add(message); // body
|
|
||||||
startInfo.ArgumentList.Add("[]"); // actions
|
|
||||||
startInfo.ArgumentList.Add("{}"); // hints
|
|
||||||
startInfo.ArgumentList.Add("-1"); // expire_timeout
|
|
||||||
|
|
||||||
RunProcess(startInfo);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning($"[OSNotification] gdbus notification failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Shared utility
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Starts a process fire-and-forget. Does not redirect output or wait for exit.
|
|
||||||
/// </summary>
|
|
||||||
private static void RunProcess(ProcessStartInfo startInfo)
|
|
||||||
{
|
|
||||||
using var process = new Process();
|
|
||||||
process.StartInfo = startInfo;
|
|
||||||
process.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Util
|
|
||||||
{
|
|
||||||
internal static class RegistryUtil
|
|
||||||
{
|
|
||||||
private static readonly Type? RegistryKeyType;
|
|
||||||
private static readonly Type? RegistryHiveType;
|
|
||||||
private static readonly Type? RegistryViewType;
|
|
||||||
private static readonly System.Reflection.MethodInfo? OpenBaseKeyMethod;
|
|
||||||
private static readonly System.Reflection.MethodInfo? OpenSubKeyMethod;
|
|
||||||
private static readonly System.Reflection.MethodInfo? GetSubKeyNamesMethod;
|
|
||||||
private static readonly System.Reflection.MethodInfo? GetValueMethod;
|
|
||||||
|
|
||||||
static RegistryUtil()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
RegistryKeyType = Type.GetType("Microsoft.Win32.RegistryKey");
|
|
||||||
RegistryHiveType = Type.GetType("Microsoft.Win32.RegistryHive");
|
|
||||||
RegistryViewType = Type.GetType("Microsoft.Win32.RegistryView");
|
|
||||||
|
|
||||||
if (RegistryKeyType == null || RegistryHiveType == null || RegistryViewType == null)
|
|
||||||
{
|
|
||||||
var mscorlib = typeof(object).Assembly;
|
|
||||||
RegistryKeyType = mscorlib.GetType("Microsoft.Win32.RegistryKey");
|
|
||||||
RegistryHiveType = mscorlib.GetType("Microsoft.Win32.RegistryHive");
|
|
||||||
RegistryViewType = mscorlib.GetType("Microsoft.Win32.RegistryView");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (RegistryKeyType != null)
|
|
||||||
{
|
|
||||||
OpenBaseKeyMethod = RegistryKeyType.GetMethod("OpenBaseKey", new[] { RegistryHiveType!, RegistryViewType! });
|
|
||||||
OpenSubKeyMethod = RegistryKeyType.GetMethod("OpenSubKey", new[] { typeof(string) });
|
|
||||||
GetSubKeyNamesMethod = RegistryKeyType.GetMethod("GetSubKeyNames");
|
|
||||||
GetValueMethod = RegistryKeyType.GetMethod("GetValue", new[] { typeof(string) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void CloseKey(object? key)
|
|
||||||
{
|
|
||||||
if (key == null || RegistryKeyType == null) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (key is IDisposable disposable)
|
|
||||||
{
|
|
||||||
disposable.Dispose();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var closeMethod = RegistryKeyType.GetMethod("Close");
|
|
||||||
closeMethod?.Invoke(key, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReSharper disable once InconsistentNaming
|
|
||||||
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
|
|
||||||
{
|
|
||||||
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
|
||||||
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
|
||||||
|
|
||||||
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
|
||||||
if (baseKey == null) return null;
|
|
||||||
|
|
||||||
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { parentKeyPath });
|
|
||||||
if (key == null)
|
|
||||||
{
|
|
||||||
CloseKey(baseKey);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
string[]? subkeyNames = GetSubKeyNamesMethod.Invoke(key, null) as string[];
|
|
||||||
CloseKey(key);
|
|
||||||
CloseKey(baseKey);
|
|
||||||
return subkeyNames;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReSharper disable once InconsistentNaming
|
|
||||||
public static string? GetHKLMValue(string keyPath, string valueName)
|
|
||||||
{
|
|
||||||
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
|
||||||
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
|
||||||
|
|
||||||
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
|
||||||
if (baseKey == null) return null;
|
|
||||||
|
|
||||||
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { keyPath });
|
|
||||||
if (key == null)
|
|
||||||
{
|
|
||||||
CloseKey(baseKey);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
string? value = GetValueMethod.Invoke(key, new object[] { valueName }) as string;
|
|
||||||
CloseKey(key);
|
|
||||||
CloseKey(baseKey);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+14
-3
@@ -20,6 +20,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
private static readonly int UTex = Shader.PropertyToID("_UTex");
|
private static readonly int UTex = Shader.PropertyToID("_UTex");
|
||||||
private static readonly int VTex = Shader.PropertyToID("_VTex");
|
private static readonly int VTex = Shader.PropertyToID("_VTex");
|
||||||
private static readonly int ColorRange = Shader.PropertyToID("ColorRange");
|
private static readonly int ColorRange = Shader.PropertyToID("ColorRange");
|
||||||
|
private static readonly int ColorMatrix = Shader.PropertyToID("ColorMatrix");
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
private enum GPUMode
|
private enum GPUMode
|
||||||
@@ -96,7 +97,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
_kernelIndex = _computeShader.FindKernel("CSMain_NV12ToRGBA");
|
_kernelIndex = _computeShader.FindKernel("CSMain_NV12ToRGBA");
|
||||||
|
|
||||||
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
|
_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(_textureY);
|
||||||
ConfigureTexture(_textureUV);
|
ConfigureTexture(_textureUV);
|
||||||
|
|
||||||
@@ -117,8 +118,8 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
_kernelIndex = _computeShader.FindKernel("CSMain_YUV420PToRGBA");
|
_kernelIndex = _computeShader.FindKernel("CSMain_YUV420PToRGBA");
|
||||||
|
|
||||||
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
|
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
|
||||||
_textureU = 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 / 2, h / 2, TextureFormat.R8, false);
|
_textureV = new Texture2D((w + 1) / 2, (h + 1) / 2, TextureFormat.R8, false);
|
||||||
ConfigureTexture(_textureY);
|
ConfigureTexture(_textureY);
|
||||||
ConfigureTexture(_textureU);
|
ConfigureTexture(_textureU);
|
||||||
ConfigureTexture(_textureV);
|
ConfigureTexture(_textureV);
|
||||||
@@ -189,6 +190,14 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
var vidOffset = scrVfxPlus.instance ? scrVfxPlus.instance.vidOffset : 0.0;
|
var vidOffset = scrVfxPlus.instance ? scrVfxPlus.instance.vidOffset : 0.0;
|
||||||
var targetVideoTime = songTime + vidOffset;
|
var targetVideoTime = songTime + vidOffset;
|
||||||
|
|
||||||
|
// When looping is enabled, the playback position is the elapsed time wrapped into [0, Duration).
|
||||||
|
// When it is disabled, the position keeps increasing and the last frame stays on screen after the end.
|
||||||
|
var videoDuration = _decoder.Duration;
|
||||||
|
if (_isLooping && targetVideoTime >= 0 && videoDuration > 0)
|
||||||
|
{
|
||||||
|
targetVideoTime %= videoDuration;
|
||||||
|
}
|
||||||
|
|
||||||
if (targetVideoTime < 0)
|
if (targetVideoTime < 0)
|
||||||
{
|
{
|
||||||
if (_targetTexture)
|
if (_targetTexture)
|
||||||
@@ -307,6 +316,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
_computeShader.SetInt(Width, w);
|
_computeShader.SetInt(Width, w);
|
||||||
_computeShader.SetInt(Height, h);
|
_computeShader.SetInt(Height, h);
|
||||||
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
|
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
|
||||||
|
_computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix);
|
||||||
|
|
||||||
int threadGroupsX = (w + 7) / 8;
|
int threadGroupsX = (w + 7) / 8;
|
||||||
int threadGroupsY = (h + 7) / 8;
|
int threadGroupsY = (h + 7) / 8;
|
||||||
@@ -341,6 +351,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
_computeShader.SetInt(Width, w);
|
_computeShader.SetInt(Width, w);
|
||||||
_computeShader.SetInt(Height, h);
|
_computeShader.SetInt(Height, h);
|
||||||
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
|
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
|
||||||
|
_computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix);
|
||||||
|
|
||||||
int threadGroupsX = (w + 7) / 8;
|
int threadGroupsX = (w + 7) / 8;
|
||||||
int threadGroupsY = (h + 7) / 8;
|
int threadGroupsY = (h + 7) / 8;
|
||||||
|
|||||||
Reference in new issue
Block a user