Emulate AudioSource Playback State For Effect Sounds

This commit is contained in:
copecone committed 2026-10-14 21:55:13 +09:00
1 parent 5136127e39
commit 69d098e04c
3 files changed
+290 -95

No files matched your search

+129 -78
View File
@@ -1,7 +1,7 @@
using System;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.Util.Audio;
using ADOFAIRenderer.Util.Audio.Clip;
using ADOFAIRenderer.Util.Audio.Mixer;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Audio;
@@ -63,11 +63,11 @@ public static class AudioManagerPlayPatch
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")]
public static class PlayWithEndTimePatch
{
/// <summary>Voice registered by the running PlayWithEndTime call, until its MakeSource returns. Main thread only.</summary>
internal static Voice? PendingVoice;
/// <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() => PendingVoice = null;
public static void Finalizer() => PendingState = null;
[HarmonyPrefix]
public static bool Prefix(string snd, double time, double endTime, float volume, int priority)
@@ -85,14 +85,14 @@ public static class PlayWithEndTimePatch
double relativeEndTime = endTime - RenderManager.Instance.dspTimeBase;
// Register looping sound event to the offline mixer (default pitch = 1.0f)
PendingVoice = mixer.RegisterSoundEvent(null, snd, relativeStartTime, relativeEndTime, volume, 1.0f, RenderManager.Instance.AudioTime);
PendingState = mixer.RegisterSoundEvent(null, snd, relativeStartTime, relativeEndTime, volume, 1.0f, RenderManager.Instance.AudioTime);
}
}
return true; // Let the original method execute
}
}
/// <summary>Maps the AudioSource created inside scrConductor.PlayWithEndTime to its voice (<see cref="PlayWithEndTimePatch.PendingVoice"/>).</summary>
/// <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
{
@@ -100,115 +100,166 @@ public static class AudioManagerMakeSourcePatch
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __result)
{
var voice = PlayWithEndTimePatch.PendingVoice;
if (voice == null || !__result) return;
PlayWithEndTimePatch.PendingVoice = null;
RenderManager.Instance.AudioMixer?.MapSource(__result, voice);
var state = PlayWithEndTimePatch.PendingState;
if (state == null || !__result) return;
PlayWithEndTimePatch.PendingState = null;
RenderManager.Instance.AudioMixer?.MapSource(__result, state);
}
}
/// <summary>
/// AudioSource.isPlaying getter: a source mapped to a voice reports true until the voice has finished, including before its
/// scheduled start, as a source started with PlayScheduled does. During rendering PlayScheduled is blocked, so without this
/// the game sees every effect source as stopped and AudioManager.Update returns it to the reusable pool on the next frame.
/// Voice completion is set by the render thread, which mixes behind the main thread, so the value turns false at or after
/// the render time the voice ends.
/// 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).
/// Sources without a mapping (the song, menu sounds) use the Unity implementation.
/// </summary>
internal static class EffectSource
{
/// <summary>State mapped to <paramref name="source"/> while capturing, with the current render time in <paramref name="now"/>.</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
{
[HarmonyPostfix]
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance, ref bool __result)
public static bool Prefix(AudioSource __instance, ref bool __result)
{
if (__result || !RenderManager.IsCapturingActive) return;
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null && mixer.TryGetVoice(__instance, out var voice))
{
__result = !voice.IsFinished;
}
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
__result = state.IsPlaying(now);
return false;
}
}
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
public static class AudioSourcePitchPatch
/// <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
{
[HarmonyPostfix]
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance, float value)
public static bool Prefix(AudioSource __instance, ref float __result)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null && mixer.TryGetVoice(__instance, out var voice))
{
voice.SetPitch(value, RenderManager.Instance.AudioTime);
}
}
}
}
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
public static class AudioSourceSetScheduledEndTimePatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance, double time)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null && mixer.TryGetVoice(__instance, out var voice))
{
voice.SetEndTime(time - RenderManager.Instance.dspTimeBase, RenderManager.Instance.AudioTime);
}
}
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
__result = state.GetTime(now);
return false;
}
}
/// <summary>
/// AudioSource.time setter: moves the playback position of the mapped voice (clip seconds) from the current render time.
/// 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
{
[HarmonyPostfix]
[HarmonyPrefix]
// 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)
{
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null && mixer.TryGetVoice(__instance, out var voice))
{
voice.SetPosition(value, RenderManager.Instance.AudioTime);
}
}
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)]
public static class AudioSourcePitchPatch
{
[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.SetPitch(value, now);
return false;
}
}
/// <summary>AudioSource.SetScheduledEndTime (<see cref="EffectSource"/>): DSP time (absolute) at which playback stops.</summary>
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
public static class AudioSourceSetScheduledEndTimePatch
{
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, double time)
{
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
state.SetEndTime(time - RenderManager.Instance.dspTimeBase, now);
return false;
}
}
/// <summary>
/// AudioSource.Stop(): stops the mapped voice at the current render time and removes the mapping.
/// AudioManager.Update calls Stop() on sources it recycles, which are finished (<see cref="AudioSourceIsPlayingPatch"/>).
/// 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])]
public static class AudioSourceStopPatch
{
[HarmonyPostfix]
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance)
public static bool Prefix(AudioSource __instance)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
var mixer = RenderManager.Instance.AudioMixer;
if (mixer == null) return;
if (mixer.TryGetVoice(__instance, out var voice))
{
voice.Stop(RenderManager.Instance.AudioTime);
}
mixer.RemoveSourceMapping(__instance);
}
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
state.Stop(now);
return false;
}
}