补充 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.Generic;
using System.Threading;
using System.Threading.Tasks;
using FlowScope.Resources;
using UnityEngine;
@@ -35,48 +36,49 @@ namespace FlowScope.Audio
public IAudioHandle PlayBgm(string key, bool loop = true, float fadeIn = 0f)
{
EnsureInitialized();
var clipHandle = LoadClip(key);
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 clipHandle = LoadClipIfCompleted(key);
return PlayBgm(clipHandle, loop, fadeIn);
}
var source = _bgmSource;
return new AudioHandle(
() => source != null && source.isPlaying,
fadeOut => StopBgmSource(source, fadeOut));
public async Task<IAudioHandle> PlayBgmAsync(
string key,
bool loop = true,
float fadeIn = 0f,
CancellationToken cancellationToken = default)
{
EnsureInitialized();
var clipHandle = await LoadClipAsync(key, cancellationToken);
return PlayBgm(clipHandle, loop, fadeIn);
}
public IAudioHandle PlaySfx(string key)
{
EnsureInitialized();
var clipHandle = LoadClipIfCompleted(key);
var slot = GetSfxSlot(key);
if (slot == null)
{
clipHandle.Dispose();
return AudioHandle.Stopped;
}
var clipHandle = LoadClip(key);
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 PlaySfx(slot, clipHandle);
}
return new AudioHandle(
() => slot.Source != null && slot.Source.isPlaying,
fadeOut => StopSfxSlot(slot, fadeOut));
public async Task<IAudioHandle> PlaySfxAsync(
string key,
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)
@@ -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 EffectiveSfxVolume => _mute ? 0f : _sfxVolume;
@@ -145,13 +183,44 @@ namespace FlowScope.Audio
}
}
private IResourceHandle<AudioClip> LoadClip(string key)
private IResourceHandle<AudioClip> LoadClipIfCompleted(string key)
{
try
{
var handle = _resources.LoadAsync<AudioClip>(key, CancellationToken.None)
.GetAwaiter()
.GetResult();
var task = _resources.LoadAsync<AudioClip>(key, CancellationToken.None);
if (!task.IsCompleted)
{
throw new InvalidOperationException(
$"Audio clip load is asynchronous: {key}. Use PlayBgmAsync or PlaySfxAsync.");
}
return ValidateLoadedClip(key, task.Result);
}
catch (Exception exception)
{
throw new InvalidOperationException($"Failed to load audio clip '{key}'.", exception);
}
}
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();
@@ -160,11 +229,6 @@ namespace FlowScope.Audio
return handle;
}
catch (Exception exception)
{
throw new InvalidOperationException($"Failed to load audio clip '{key}'.", exception);
}
}
private SfxSlot GetSfxSlot(string key)
{

View File

@@ -1,9 +1,22 @@
using System.Threading;
using System.Threading.Tasks;
namespace FlowScope.Audio
{
public interface IAudioService
{
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);
Task<IAudioHandle> PlaySfxAsync(
string key,
CancellationToken cancellationToken = default);
void StopBgm(float fadeOut = 0f);
void SetBgmVolume(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"));
}
[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)
{
var gameObject = new GameObject("AudioServiceTest");
@@ -182,6 +227,26 @@ namespace FlowScope.Tests.PlayMode.Audio
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)
{
var sampleCount = Mathf.CeilToInt(44100 * lengthSeconds);
@@ -199,21 +264,39 @@ namespace FlowScope.Tests.PlayMode.Audio
private sealed class FakeResourceService : IResourceService
{
private readonly Dictionary<string, AudioClip> clips = new();
private readonly Dictionary<string, TaskCompletionSource<AudioClip>> pendingClips = new();
public void Add(string key, AudioClip 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
{
if (typeof(T) != typeof(AudioClip) || !clips.TryGetValue(key, out var clip))
if (typeof(T) != typeof(AudioClip))
{
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()