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

+21 -17
View File
@@ -50,7 +50,7 @@ internal sealed class OfflineAudioMixer : IDisposable
private readonly int _latencySamples;
// Main thread.
private ConditionalWeakTable<AudioSource, Voice> _sourceVoices = new();
private ConditionalWeakTable<AudioSource, SourceState> _sourceStates = new();
private readonly List<Voice> _effectVoices = new();
private int _effectVoicePruneAt = EffectVoicePruneCount;
private AudioClipSource? _bgmSource;
@@ -191,9 +191,9 @@ internal sealed class OfflineAudioMixer : IDisposable
/// <summary>
/// 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 voice for later changes through the AudioSource.
/// <paramref name="source"/> (when given) is mapped to the returned state, which reports and applies later changes through the AudioSource.
/// </summary>
public Voice RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch, double now)
public SourceState RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch, double now)
{
var clipSource = GetOrAddSource(clipName);
var loop = endTime >= 0;
@@ -207,26 +207,30 @@ internal sealed class OfflineAudioMixer : IDisposable
}
_effectVoices.Add(voice);
if (source) MapSource(source!, voice);
return 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;
}
/// <summary>Maps <paramref name="source"/> to <paramref name="voice"/> for later changes through the AudioSource (replaces an earlier mapping).</summary>
public void MapSource(AudioSource source, Voice voice)
/// <summary>
/// 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)
{
_sourceVoices.Remove(source);
_sourceVoices.Add(source, voice);
state.BindClip(source.clip);
_sourceStates.Remove(source);
_sourceStates.Add(source, state);
}
/// <summary>Voice started for <paramref name="source"/> by <see cref="RegisterSoundEvent"/>.</summary>
public bool TryGetVoice(AudioSource? source, out Voice voice)
/// <summary>State of the effect sound last started for <paramref name="source"/>. The mapping stays after the sound ends or stops.</summary>
public bool TryGetSourceState(AudioSource? source, out SourceState state)
{
voice = null!;
return source != null && _sourceVoices.TryGetValue(source, out voice);
state = null!;
return source != null && _sourceStates.TryGetValue(source, out state);
}
public bool RemoveSourceMapping(AudioSource source) => _sourceVoices.Remove(source);
/// <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
@@ -237,7 +241,7 @@ internal sealed class OfflineAudioMixer : IDisposable
foreach (var voice in _effectVoices)
voice.Stop(now);
_effectVoices.Clear();
_sourceVoices = new ConditionalWeakTable<AudioSource, Voice>();
_sourceStates = new ConditionalWeakTable<AudioSource, SourceState>();
}
/// <summary>
@@ -348,7 +352,7 @@ internal sealed class OfflineAudioMixer : IDisposable
foreach (var filter in _filters)
filter.Reset();
}
_sourceVoices = new ConditionalWeakTable<AudioSource, Voice>();
_sourceStates = new ConditionalWeakTable<AudioSource, SourceState>();
_effectVoices.Clear();
}
}
+140
View File
@@ -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;
}
}