1
0
Fork 0

Rewrite AudioSource handling from scratch

This commit is contained in:
ivan tkachenko 2025-08-22 04:18:07 +03:00
parent e67de4556c
commit 73ad702684
2 changed files with 116 additions and 69 deletions

View File

@ -4,6 +4,7 @@
- Added a new track OnePartiyaUdar in Japanese language. - Added a new track OnePartiyaUdar in Japanese language.
- Remastered recently added tracks at conventional 44100 Hz for better stitching. - Remastered recently added tracks at conventional 44100 Hz for better stitching.
- Improved playback experience: use precise DSP time and up-front scheduing for seamless audio stitching, add custom Audio Sources to improve reliability.
## MuzikaGromche 1337.420.9001 - Multiverse Edition ## MuzikaGromche 1337.420.9001 - Multiverse Edition

View File

@ -2249,20 +2249,57 @@ namespace MuzikaGromche
} }
else else
{ {
Debug.Log($"{nameof(MuzikaGromche)} Patching {nameof(JesterAI)} with {nameof(MuzikaGromcheJesterNetworkBehaviour)} component");
networkPrefab.Prefab.AddComponent<MuzikaGromcheJesterNetworkBehaviour>(); networkPrefab.Prefab.AddComponent<MuzikaGromcheJesterNetworkBehaviour>();
Debug.Log($"{nameof(MuzikaGromche)} Patched JesterEnemy");
} }
} }
} }
class MuzikaGromcheJesterNetworkBehaviour : NetworkBehaviour class MuzikaGromcheJesterNetworkBehaviour : NetworkBehaviour
{ {
const string IntroAudioGameObjectName = "MuzikaGromcheAudio (Intro)";
const string LoopAudioGameObjectName = "MuzikaGromcheAudio (Loop)";
// Number of times a selected track has been played. // Number of times a selected track has been played.
// Increases by 1 with each ChooseTrackServerRpc call. // Increases by 1 with each ChooseTrackServerRpc call.
// Resets on SettingChanged. // Resets on SettingChanged.
private int SelectedTrackIndex = 0; private int SelectedTrackIndex = 0;
internal BeatTimeState? BeatTimeState = null; internal BeatTimeState? BeatTimeState = null;
internal AudioSource IntroAudioSource = null!;
internal AudioSource LoopAudioSource = null!;
void Awake()
{
var farAudioTransform = gameObject.transform.Find("FarAudio");
if (farAudioTransform == null)
{
Debug.LogError($"{nameof(MuzikaGromche)} JesterEnemy->FarAudio prefab not found!");
}
else
{
// Instead of hijacking farAudio and creatureVoice sources,
// create our own copies to ensure uniform playback experience.
// For reasons unknown adding them directly to the prefab didn't work.
var introAudioGameObject = Instantiate(farAudioTransform.gameObject, gameObject.transform);
introAudioGameObject.name = IntroAudioGameObjectName;
var loopAudioGameObject = Instantiate(farAudioTransform.gameObject, gameObject.transform);
loopAudioGameObject.name = LoopAudioGameObjectName;
IntroAudioSource = introAudioGameObject.GetComponent<AudioSource>();
IntroAudioSource.maxDistance = Plugin.AudioMaxDistance;
IntroAudioSource.dopplerLevel = 0;
IntroAudioSource.loop = false;
LoopAudioSource = loopAudioGameObject.GetComponent<AudioSource>();
LoopAudioSource.maxDistance = Plugin.AudioMaxDistance;
LoopAudioSource.dopplerLevel = 0;
LoopAudioSource.loop = true;
Debug.Log($"{nameof(MuzikaGromche)} {nameof(MuzikaGromcheJesterNetworkBehaviour)} Patched JesterEnemy");
}
}
public override void OnNetworkSpawn() public override void OnNetworkSpawn()
{ {
@ -2327,26 +2364,37 @@ namespace MuzikaGromche
SetTrackClientRpc(audioTrack.Name); SetTrackClientRpc(audioTrack.Name);
SelectedTrackIndex += 1; SelectedTrackIndex += 1;
} }
internal void PlayScheduledLoop()
{
double loopStartDspTime = AudioSettings.dspTime + IntroAudioSource.clip.length - IntroAudioSource.time;
LoopAudioSource.PlayScheduled(loopStartDspTime);
Debug.Log($"{nameof(MuzikaGromche)} Play Intro: dspTime={AudioSettings.dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, scheduled Loop={loopStartDspTime}");
}
} }
// farAudio is during windup, Intro overrides popGoesTheWeaselTheme
// creatureVoice is when popped, Loop overrides screamingSFX
[HarmonyPatch(typeof(JesterAI))] [HarmonyPatch(typeof(JesterAI))]
static class JesterPatch static class JesterPatch
{ {
#if DEBUG
[HarmonyPatch(nameof(JesterAI.SetJesterInitialValues))] [HarmonyPatch(nameof(JesterAI.SetJesterInitialValues))]
[HarmonyPostfix] [HarmonyPostfix]
static void AlmostInstantFollowTimerPostfix(JesterAI __instance) static void SetJesterInitialValuesPostfix(JesterAI __instance)
{ {
var behaviour = __instance.GetComponent<MuzikaGromcheJesterNetworkBehaviour>();
behaviour.IntroAudioSource.Stop();
behaviour.LoopAudioSource.Stop();
#if DEBUG
// Almost instant follow timer
__instance.beginCrankingTimer = 1f; __instance.beginCrankingTimer = 1f;
}
#endif #endif
}
class State class State
{ {
public required AudioSource farAudio; public required int currentBehaviourStateIndex;
public required int previousState; public required int previousState;
public required float stunNormalizedTimer;
} }
[HarmonyPatch(nameof(JesterAI.Update))] [HarmonyPatch(nameof(JesterAI.Update))]
@ -2355,20 +2403,10 @@ namespace MuzikaGromche
{ {
__state = new State __state = new State
{ {
farAudio = __instance.farAudio, currentBehaviourStateIndex = __instance.currentBehaviourStateIndex,
previousState = __instance.previousState, previousState = __instance.previousState,
stunNormalizedTimer = __instance.stunNormalizedTimer,
}; };
if (__instance.currentBehaviourStateIndex == 2 && __instance.previousState != 2)
{
// If just popped out, then override farAudio so that vanilla logic does not stop the modded Intro music.
// The game will stop farAudio it during its Update, so we temporarily set it to any other AudioSource
// which we don't care about stopping for now.
//
// Why creatureVoice though? We gonna need creatureVoice later in Postfix to schedule the Loop,
// but right now we still don't care if it's stopped, so it shouldn't matter.
// And it's cheaper and simpler than figuring out how to instantiate an AudioSource behaviour.
__instance.farAudio = __instance.creatureVoice;
}
} }
[HarmonyPatch(nameof(JesterAI.Update))] [HarmonyPatch(nameof(JesterAI.Update))]
@ -2378,76 +2416,84 @@ namespace MuzikaGromche
if (Plugin.CurrentTrack == null) if (Plugin.CurrentTrack == null)
{ {
#if DEBUG #if DEBUG
Debug.Log($"{nameof(MuzikaGromche)} CurrentTrack is not set!"); Debug.LogError($"{nameof(MuzikaGromche)} CurrentTrack is not set!");
#endif #endif
return; return;
} }
var behaviour = __instance.GetComponent<MuzikaGromcheJesterNetworkBehaviour>(); var behaviour = __instance.GetComponent<MuzikaGromcheJesterNetworkBehaviour>();
var introAudioSource = behaviour.IntroAudioSource;
var loopAudioSource = behaviour.LoopAudioSource;
if (__instance.previousState == 1 && __state.previousState != 1) // This switch statement resembles the one from JesterAI.Update
switch (__state.currentBehaviourStateIndex)
{
case 1:
if (__state.previousState != 1)
{ {
// if just started winding up // if just started winding up
// then stop the default music... // then stop the default music... (already done above)
__instance.farAudio.Stop(); // ...and set up both modded audio clips in advance
__instance.creatureVoice.Stop(); introAudioSource.clip = Plugin.CurrentTrack.LoadedIntro;
loopAudioSource.clip = Plugin.CurrentTrack.LoadedLoop;
// ...and start modded music
behaviour.BeatTimeState = new BeatTimeState(Plugin.CurrentTrack); behaviour.BeatTimeState = new BeatTimeState(Plugin.CurrentTrack);
// Set up custom popup timer, which is shorter than Start audio
// Set up custom popup timer, which is shorter than Intro audio
__instance.popUpTimer = Plugin.CurrentTrack.WindUpTimer; __instance.popUpTimer = Plugin.CurrentTrack.WindUpTimer;
// Override popGoesTheWeaselTheme with Start audio
__instance.farAudio.maxDistance = Plugin.AudioMaxDistance;
__instance.farAudio.clip = Plugin.CurrentTrack.LoadedIntro;
__instance.farAudio.loop = false;
if (Config.ShouldSkipWindingPhase) if (Config.ShouldSkipWindingPhase)
{ {
var rewind = 5f; var rewind = 5f;
__instance.popUpTimer = rewind; __instance.popUpTimer = rewind;
__instance.farAudio.time = Plugin.CurrentTrack.WindUpTimer - rewind; introAudioSource.time = Plugin.CurrentTrack.WindUpTimer - rewind;
} }
else else
{ {
// reset if previously skipped winding by assigning different starting time. // reset if previously skipped winding by assigning different starting time.
__instance.farAudio.time = 0; introAudioSource.time = 0f;
}
__instance.farAudio.Play();
Debug.Log($"{nameof(MuzikaGromche)} Playing Intro music: maxDistance: {__instance.farAudio.maxDistance}, minDistance: {__instance.farAudio.minDistance}, volume: {__instance.farAudio.volume}, spread: {__instance.farAudio.spread}");
} }
if (__instance.previousState != 2 && __state.previousState == 2) __instance.farAudio.Stop();
introAudioSource.Play();
behaviour.PlayScheduledLoop();
}
if (__instance.stunNormalizedTimer > 0f)
{
introAudioSource.Pause();
loopAudioSource.Stop();
}
else
{
if (!introAudioSource.isPlaying)
{
__instance.farAudio.Stop();
introAudioSource.UnPause();
behaviour.PlayScheduledLoop();
}
}
break;
case 2:
if (__state.previousState != 2)
{
__instance.creatureVoice.Stop();
}
break;
}
// transition away from state 2 ("poppedOut"), normally to state 0
if (__state.previousState == 2 && __instance.previousState != 2)
{ {
behaviour.BeatTimeState = null;
Plugin.ResetLightColor(); Plugin.ResetLightColor();
DiscoBallManager.Disable(); DiscoBallManager.Disable();
// Rotate track groups // Rotate track groups
__instance.GetComponent<MuzikaGromcheJesterNetworkBehaviour>()?.ChooseTrackServerRpc(); behaviour.ChooseTrackServerRpc();
} behaviour.BeatTimeState = null;
if (__instance.previousState == 2 && __state.previousState != 2)
{
// Restore stashed AudioSource. See the comment in Prefix
__instance.farAudio = __state.farAudio;
var time = __instance.farAudio.time;
var delay = Plugin.CurrentTrack.LoadedIntro.length - time;
// Override screamingSFX with Loop, delayed by the remaining time of the Intro audio
__instance.creatureVoice.Stop();
__instance.creatureVoice.maxDistance = Plugin.AudioMaxDistance;
__instance.creatureVoice.clip = Plugin.CurrentTrack.LoadedLoop;
__instance.creatureVoice.PlayDelayed(delay);
Debug.Log($"{nameof(MuzikaGromche)} Intro length: {Plugin.CurrentTrack.LoadedIntro.length}; played time: {time}");
Debug.Log($"{nameof(MuzikaGromche)} Playing loop music: maxDistance: {__instance.creatureVoice.maxDistance}, minDistance: {__instance.creatureVoice.minDistance}, volume: {__instance.creatureVoice.volume}, spread: {__instance.creatureVoice.spread}, in seconds: {delay}");
} }
// Manage the timeline: switch color of the lights according to the current playback/beat position. // Manage the timeline: switch color of the lights according to the current playback/beat position.
if ((__instance.previousState == 1 || __instance.previousState == 2) && behaviour.BeatTimeState is { } beatTimeState) else if ((__instance.previousState == 1 || __instance.previousState == 2) && behaviour.BeatTimeState is { } beatTimeState)
{ {
var events = beatTimeState.Update(intro: __instance.farAudio, loop: __instance.creatureVoice); var events = beatTimeState.Update(introAudioSource, loopAudioSource);
foreach (var ev in events) foreach (var ev in events)
{ {
switch (ev) switch (ev)