SHA256
forked from ADOFAIEditorForum/ADOFAIRenderer
Add Sync Between AudioSource and SoundEvent
This commit is contained in:
7 files changed
+213
-110
No files matched your search
@@ -0,0 +1,322 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Util.Audio
|
||||
{
|
||||
public static class AudioClipCache
|
||||
{
|
||||
private static readonly Dictionary<string, CachedClipData> Cache = new();
|
||||
|
||||
public class CachedClipData
|
||||
{
|
||||
public float[] Samples = null!; // Pre-resampled to target channels and sample rate
|
||||
public double Duration;
|
||||
}
|
||||
|
||||
public static CachedClipData? GetOrCache(string clipName, int targetChannels, int targetSampleRate, bool allowLoad = true)
|
||||
{
|
||||
if (string.IsNullOrEmpty(clipName)) return null;
|
||||
|
||||
lock (Cache)
|
||||
{
|
||||
if (Cache.TryGetValue(clipName, out var cached))
|
||||
return cached;
|
||||
|
||||
if (!allowLoad) return null;
|
||||
|
||||
if (AudioManager.Instance == null) return null;
|
||||
|
||||
AudioClip clip = AudioManager.Instance.FindOrLoadAudioClip(clipName);
|
||||
if (clip == null) return null;
|
||||
|
||||
try
|
||||
{
|
||||
int totalSamples = clip.samples;
|
||||
int channels = clip.channels;
|
||||
float[] rawSamples = new float[totalSamples * channels];
|
||||
clip.GetData(rawSamples, 0);
|
||||
|
||||
// Auto-resample during cache generation
|
||||
float[] 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)
|
||||
{
|
||||
MelonLoader.MelonLogger.Error($"[AudioClipCache] Failed to cache and resample clip {clipName}: {e}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
lock (Cache)
|
||||
{
|
||||
Cache.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static 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 static readonly List<SoundEvent> ActiveEvents = new();
|
||||
private static System.Runtime.CompilerServices.ConditionalWeakTable<AudioSource, SoundEvent> _sourceMappings = new();
|
||||
private static readonly object Lock = new();
|
||||
|
||||
// Target Audio Settings
|
||||
private static int _targetChannels = 2;
|
||||
private static int _targetSampleRate = 44100;
|
||||
private static readonly float MasterVolume = 0.2f;
|
||||
|
||||
// BGM Cached Samples (Pre-resampled)
|
||||
private static float[]? _bgmSamples;
|
||||
private static double _bgmDuration;
|
||||
|
||||
public static void InitializeBGM(AudioClip? clip, int targetChannels, int targetSampleRate)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_targetChannels = targetChannels;
|
||||
_targetSampleRate = targetSampleRate;
|
||||
|
||||
if (!clip)
|
||||
{
|
||||
_bgmSamples = null;
|
||||
_bgmDuration = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
int originalSamples = clip.samples;
|
||||
int originalChannels = clip.channels;
|
||||
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);
|
||||
|
||||
MelonLoader.MelonLogger.Msg($"[OfflineAudioMixer] Initialized resampled BGM: {clip.name}, Samples: {_bgmSamples.Length / targetChannels}, TargetFreq: {targetSampleRate}Hz, TargetChannels: {targetChannels}");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
MelonLoader.MelonLogger.Error($"[OfflineAudioMixer] Failed to initialize and resample BGM: {e}");
|
||||
_bgmSamples = null;
|
||||
_bgmDuration = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static 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 != null)
|
||||
{
|
||||
_sourceMappings.Remove(source);
|
||||
_sourceMappings.Add(source, ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void RegisterSoundEvent(string clipName, double startTime, double endTime, float volume, float pitch)
|
||||
{
|
||||
RegisterSoundEvent(null, clipName, startTime, endTime, volume, pitch);
|
||||
}
|
||||
|
||||
public static bool TryGetSoundEvent(AudioSource? source, out SoundEvent ev)
|
||||
{
|
||||
if (source == null)
|
||||
{
|
||||
ev = null!;
|
||||
return false;
|
||||
}
|
||||
lock (Lock)
|
||||
{
|
||||
return _sourceMappings.TryGetValue(source, out ev);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool RemoveSourceMapping(AudioSource source)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
return _sourceMappings.Remove(source);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Reset()
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
ActiveEvents.Clear();
|
||||
_sourceMappings = new System.Runtime.CompilerServices.ConditionalWeakTable<AudioSource, SoundEvent>();
|
||||
_bgmSamples = null;
|
||||
_bgmDuration = 0;
|
||||
AudioClipCache.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public static 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)
|
||||
{
|
||||
// 1. Mix BGM
|
||||
MixBGM(mixedBuffer, samplesNeeded, frameStartTime, dspTimeBase);
|
||||
|
||||
// 2. Mix Hitsounds/Effect Sounds
|
||||
MixEffectSounds(mixedBuffer, samplesNeeded, frameStartTime);
|
||||
|
||||
// 3. Saturation Guard (Clamping)
|
||||
for (int i = 0; i < samplesNeeded * _targetChannels; i++)
|
||||
{
|
||||
if (mixedBuffer[i] > 1.0f) mixedBuffer[i] = 1.0f;
|
||||
else if (mixedBuffer[i] < -1.0f) mixedBuffer[i] = -1.0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void MixBGM(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase)
|
||||
{
|
||||
if (_bgmSamples == null || _bgmSamples.Length == 0) return;
|
||||
|
||||
var conductor = scrConductor.instance;
|
||||
if (!conductor) return;
|
||||
|
||||
double pitch = conductor.song.pitch;
|
||||
double dspTimeSong = conductor.dspTimeSong;
|
||||
double separateCountdownOffset = conductor.separateCountdownTime
|
||||
? (conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks)
|
||||
: 0.0;
|
||||
|
||||
// BGM Playback start time relative to dspTimeBase
|
||||
double time1 = dspTimeSong + separateCountdownOffset;
|
||||
double relativeTime1 = time1 - dspTimeBase;
|
||||
|
||||
int totalSamples = _bgmSamples.Length / _targetChannels;
|
||||
|
||||
for (int s = 0; s < samplesNeeded; s++)
|
||||
{
|
||||
double sampleTime = frameStartTime + (double)s / _targetSampleRate;
|
||||
double clipTime = (sampleTime - relativeTime1) * pitch;
|
||||
|
||||
if (clipTime >= 0 && clipTime < _bgmDuration)
|
||||
{
|
||||
double sampleIdxDouble = clipTime * _targetSampleRate;
|
||||
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 (int 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)) * MasterVolume;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void MixEffectSounds(float[] mixedBuffer, int samplesNeeded, double frameStartTime)
|
||||
{
|
||||
for (int i = ActiveEvents.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var ev = ActiveEvents[i];
|
||||
var clipData = AudioClipCache.GetOrCache(ev.ClipName, _targetChannels, _targetSampleRate, allowLoad: false);
|
||||
if (clipData == null)
|
||||
{
|
||||
ActiveEvents.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
bool isSoundFinished = false;
|
||||
int totalSamples = clipData.Samples.Length / _targetChannels;
|
||||
|
||||
for (int s = 0; s < samplesNeeded; s++)
|
||||
{
|
||||
double sampleTime = frameStartTime + (double)s / _targetSampleRate;
|
||||
|
||||
// If it is a looping sound and has reached or exceeded its end time, stop playing.
|
||||
if (ev.EndTime >= 0 && sampleTime >= ev.EndTime)
|
||||
{
|
||||
isSoundFinished = true;
|
||||
break;
|
||||
}
|
||||
|
||||
double elapsed = sampleTime - ev.StartTime;
|
||||
if (elapsed < 0) continue; // Sound hasn't started yet
|
||||
|
||||
double soundTime = elapsed * ev.Pitch;
|
||||
|
||||
if (soundTime >= clipData.Duration)
|
||||
{
|
||||
if (ev.EndTime < 0) // One-shot
|
||||
{
|
||||
isSoundFinished = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Looping
|
||||
soundTime %= clipData.Duration;
|
||||
}
|
||||
|
||||
// Linear interpolation for pitch shifting in the mixer loop
|
||||
double sampleIdxDouble = soundTime * _targetSampleRate;
|
||||
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 (int c = 0; c < _targetChannels; c++)
|
||||
{
|
||||
float valLow = clipData.Samples[idxLow * _targetChannels + c];
|
||||
float valHigh = clipData.Samples[idxHigh * _targetChannels + c];
|
||||
mixedBuffer[s * _targetChannels + c] += (valLow + t * (valHigh - valLow)) * ev.Volume * MasterVolume;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up finished sound events (both one-shot and looping)
|
||||
if (isSoundFinished)
|
||||
{
|
||||
ActiveEvents.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user