补充 AudioService 异步播放入口

This commit is contained in:
JSD\13999
2026-05-19 20:42:36 +08:00
parent 436cf6ef56
commit afcb02591d
3 changed files with 200 additions and 40 deletions

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks;
using FlowScope.Resources; using FlowScope.Resources;
using UnityEngine; using UnityEngine;
@@ -35,48 +36,49 @@ namespace FlowScope.Audio
public IAudioHandle PlayBgm(string key, bool loop = true, float fadeIn = 0f) public IAudioHandle PlayBgm(string key, bool loop = true, float fadeIn = 0f)
{ {
EnsureInitialized(); EnsureInitialized();
var clipHandle = LoadClip(key); var clipHandle = LoadClipIfCompleted(key);
return PlayBgm(clipHandle, loop, fadeIn);
}
StopBgm(); public async Task<IAudioHandle> PlayBgmAsync(
_bgmSource = gameObject.AddComponent<AudioSource>(); string key,
_bgmResource = clipHandle; bool loop = true,
_bgmSource.clip = clipHandle.Asset; float fadeIn = 0f,
_bgmSource.loop = loop; CancellationToken cancellationToken = default)
_bgmSource.volume = fadeIn > 0f ? 0f : EffectiveBgmVolume; {
_bgmSource.Play(); EnsureInitialized();
var clipHandle = await LoadClipAsync(key, cancellationToken);
if (fadeIn > 0f) return PlayBgm(clipHandle, loop, fadeIn);
{
_bgmFade = StartCoroutine(FadeVolume(_bgmSource, 0f, EffectiveBgmVolume, fadeIn, null));
}
var source = _bgmSource;
return new AudioHandle(
() => source != null && source.isPlaying,
fadeOut => StopBgmSource(source, fadeOut));
} }
public IAudioHandle PlaySfx(string key) public IAudioHandle PlaySfx(string key)
{ {
EnsureInitialized(); EnsureInitialized();
var clipHandle = LoadClipIfCompleted(key);
var slot = GetSfxSlot(key); var slot = GetSfxSlot(key);
if (slot == null) if (slot == null)
{ {
clipHandle.Dispose();
return AudioHandle.Stopped; return AudioHandle.Stopped;
} }
var clipHandle = LoadClip(key); return PlaySfx(slot, clipHandle);
slot.Release(); }
slot.Resource = clipHandle;
slot.Source.clip = clipHandle.Asset;
slot.Source.loop = false;
slot.Source.volume = EffectiveSfxVolume;
slot.Sequence = ++_playSequence;
slot.Source.Play();
return new AudioHandle( public async Task<IAudioHandle> PlaySfxAsync(
() => slot.Source != null && slot.Source.isPlaying, string key,
fadeOut => StopSfxSlot(slot, fadeOut)); CancellationToken cancellationToken = default)
{
EnsureInitialized();
var clipHandle = await LoadClipAsync(key, cancellationToken);
var slot = GetSfxSlot(key);
if (slot == null)
{
clipHandle.Dispose();
return AudioHandle.Stopped;
}
return PlaySfx(slot, clipHandle);
} }
public void StopBgm(float fadeOut = 0f) public void StopBgm(float fadeOut = 0f)
@@ -122,6 +124,42 @@ namespace FlowScope.Audio
} }
} }
private IAudioHandle PlayBgm(IResourceHandle<AudioClip> clipHandle, bool loop, float fadeIn)
{
StopBgm();
_bgmSource = gameObject.AddComponent<AudioSource>();
_bgmResource = clipHandle;
_bgmSource.clip = clipHandle.Asset;
_bgmSource.loop = loop;
_bgmSource.volume = fadeIn > 0f ? 0f : EffectiveBgmVolume;
_bgmSource.Play();
if (fadeIn > 0f)
{
_bgmFade = StartCoroutine(FadeVolume(_bgmSource, 0f, EffectiveBgmVolume, fadeIn, null));
}
var source = _bgmSource;
return new AudioHandle(
() => source != null && source.isPlaying,
fadeOut => StopBgmSource(source, fadeOut));
}
private IAudioHandle PlaySfx(SfxSlot slot, IResourceHandle<AudioClip> clipHandle)
{
slot.Release();
slot.Resource = clipHandle;
slot.Source.clip = clipHandle.Asset;
slot.Source.loop = false;
slot.Source.volume = EffectiveSfxVolume;
slot.Sequence = ++_playSequence;
slot.Source.Play();
return new AudioHandle(
() => slot.Source != null && slot.Source.isPlaying,
fadeOut => StopSfxSlot(slot, fadeOut));
}
private float EffectiveBgmVolume => _mute ? 0f : _bgmVolume; private float EffectiveBgmVolume => _mute ? 0f : _bgmVolume;
private float EffectiveSfxVolume => _mute ? 0f : _sfxVolume; private float EffectiveSfxVolume => _mute ? 0f : _sfxVolume;
@@ -145,20 +183,18 @@ namespace FlowScope.Audio
} }
} }
private IResourceHandle<AudioClip> LoadClip(string key) private IResourceHandle<AudioClip> LoadClipIfCompleted(string key)
{ {
try try
{ {
var handle = _resources.LoadAsync<AudioClip>(key, CancellationToken.None) var task = _resources.LoadAsync<AudioClip>(key, CancellationToken.None);
.GetAwaiter() if (!task.IsCompleted)
.GetResult();
if (handle == null || handle.Asset == null)
{ {
handle?.Dispose(); throw new InvalidOperationException(
throw new InvalidOperationException($"Audio clip is missing: {key}"); $"Audio clip load is asynchronous: {key}. Use PlayBgmAsync or PlaySfxAsync.");
} }
return handle; return ValidateLoadedClip(key, task.Result);
} }
catch (Exception exception) catch (Exception exception)
{ {
@@ -166,6 +202,34 @@ namespace FlowScope.Audio
} }
} }
private async Task<IResourceHandle<AudioClip>> LoadClipAsync(
string key,
CancellationToken cancellationToken)
{
try
{
var handle = await _resources.LoadAsync<AudioClip>(key, cancellationToken);
return ValidateLoadedClip(key, handle);
}
catch (Exception exception)
{
throw new InvalidOperationException($"Failed to load audio clip '{key}'.", exception);
}
}
private static IResourceHandle<AudioClip> ValidateLoadedClip(
string key,
IResourceHandle<AudioClip> handle)
{
if (handle == null || handle.Asset == null)
{
handle?.Dispose();
throw new InvalidOperationException($"Audio clip is missing: {key}");
}
return handle;
}
private SfxSlot GetSfxSlot(string key) private SfxSlot GetSfxSlot(string key)
{ {
foreach (var slot in _sfxSlots) foreach (var slot in _sfxSlots)

View File

@@ -1,9 +1,22 @@
using System.Threading;
using System.Threading.Tasks;
namespace FlowScope.Audio namespace FlowScope.Audio
{ {
public interface IAudioService public interface IAudioService
{ {
IAudioHandle PlayBgm(string key, bool loop = true, float fadeIn = 0f); IAudioHandle PlayBgm(string key, bool loop = true, float fadeIn = 0f);
Task<IAudioHandle> PlayBgmAsync(
string key,
bool loop = true,
float fadeIn = 0f,
CancellationToken cancellationToken = default);
IAudioHandle PlaySfx(string key); IAudioHandle PlaySfx(string key);
Task<IAudioHandle> PlaySfxAsync(
string key,
CancellationToken cancellationToken = default);
void StopBgm(float fadeOut = 0f); void StopBgm(float fadeOut = 0f);
void SetBgmVolume(float volume); void SetBgmVolume(float volume);
void SetSfxVolume(float volume); void SetSfxVolume(float volume);

View File

@@ -162,6 +162,51 @@ namespace FlowScope.Tests.PlayMode.Audio
Assert.That(exception.Message, Does.Contain("missing-bgm")); Assert.That(exception.Message, Does.Contain("missing-bgm"));
} }
[Test]
public void PlayBgm_WhenResourceLoadIsPending_ThrowsInsteadOfBlocking()
{
var service = CreateService();
var pending = resources.AddPending("slow-bgm");
var sourceCountBeforePlay = Object.FindObjectsOfType<AudioSource>().Length;
var exception = Assert.Throws<InvalidOperationException>(() => service.PlayBgm("slow-bgm"));
Assert.That(exception.Message, Does.Contain("slow-bgm"));
Assert.That(Object.FindObjectsOfType<AudioSource>().Length, Is.EqualTo(sourceCountBeforePlay));
pending.SetResult(bgmA);
}
[Test]
public void PlaySfx_WhenResourceLoadIsPending_ThrowsWithoutCreatingSlot()
{
var service = CreateService();
var pending = resources.AddPending("slow-sfx");
var sourceCountBeforePlay = Object.FindObjectsOfType<AudioSource>().Length;
var exception = Assert.Throws<InvalidOperationException>(() => service.PlaySfx("slow-sfx"));
Assert.That(exception.Message, Does.Contain("slow-sfx"));
Assert.That(Object.FindObjectsOfType<AudioSource>().Length, Is.EqualTo(sourceCountBeforePlay));
pending.SetResult(sfx);
}
[UnityTest]
public System.Collections.IEnumerator PlayBgmAsync_WhenResourceCompletes_PlaysClip()
{
var service = CreateService();
var pending = resources.AddPending("slow-bgm");
IAudioHandle handle = null;
var task = service.PlayBgmAsync("slow-bgm");
Assert.That(task.IsCompleted, Is.False);
pending.SetResult(bgmA);
yield return AwaitTask(task, result => handle = result);
yield return null;
Assert.That(handle.IsPlaying, Is.True);
}
private AudioService CreateService(AudioServiceConfig config = null) private AudioService CreateService(AudioServiceConfig config = null)
{ {
var gameObject = new GameObject("AudioServiceTest"); var gameObject = new GameObject("AudioServiceTest");
@@ -182,6 +227,26 @@ namespace FlowScope.Tests.PlayMode.Audio
Assert.That(condition(), Is.True, message); Assert.That(condition(), Is.True, message);
} }
private static System.Collections.IEnumerator AwaitTask<T>(Task<T> task, Action<T> onCompleted)
{
while (!task.IsCompleted)
{
yield return null;
}
if (task.IsFaulted)
{
throw task.Exception.GetBaseException();
}
if (task.IsCanceled)
{
throw new OperationCanceledException();
}
onCompleted(task.Result);
}
private static AudioClip CreateClip(string name, float lengthSeconds) private static AudioClip CreateClip(string name, float lengthSeconds)
{ {
var sampleCount = Mathf.CeilToInt(44100 * lengthSeconds); var sampleCount = Mathf.CeilToInt(44100 * lengthSeconds);
@@ -199,21 +264,39 @@ namespace FlowScope.Tests.PlayMode.Audio
private sealed class FakeResourceService : IResourceService private sealed class FakeResourceService : IResourceService
{ {
private readonly Dictionary<string, AudioClip> clips = new(); private readonly Dictionary<string, AudioClip> clips = new();
private readonly Dictionary<string, TaskCompletionSource<AudioClip>> pendingClips = new();
public void Add(string key, AudioClip clip) public void Add(string key, AudioClip clip)
{ {
clips[key] = clip; clips[key] = clip;
} }
public Task<IResourceHandle<T>> LoadAsync<T>(string key, CancellationToken cancellationToken) public TaskCompletionSource<AudioClip> AddPending(string key)
{
var pending = new TaskCompletionSource<AudioClip>();
pendingClips[key] = pending;
return pending;
}
public async Task<IResourceHandle<T>> LoadAsync<T>(string key, CancellationToken cancellationToken)
where T : class where T : class
{ {
if (typeof(T) != typeof(AudioClip) || !clips.TryGetValue(key, out var clip)) if (typeof(T) != typeof(AudioClip))
{ {
throw new InvalidOperationException($"missing resource: {key}"); throw new InvalidOperationException($"missing resource: {key}");
} }
return Task.FromResult<IResourceHandle<T>>(new FakeResourceHandle<T>(key, clip as T)); if (!clips.TryGetValue(key, out var clip))
{
if (!pendingClips.TryGetValue(key, out var pending))
{
throw new InvalidOperationException($"missing resource: {key}");
}
clip = await pending.Task;
}
return new FakeResourceHandle<T>(key, clip as T);
} }
public IResourceGroup CreateGroup() public IResourceGroup CreateGroup()