native/audio-native/lingua_audio.c wraps miniaudio (vendored at pinned release 0.11.25 — dual public domain/MIT-0, confirmed from the license statement in the file itself) the same way lingua_physics.c wraps Box3D: ma_engine_config/ma_sound_config are large structs with optional callbacks, so nothing but plain int/float/bool/UTF-8-path scalars crosses the P/Invoke boundary, and handles are this shim's own array-index handles into a fixed ma_sound table, not miniaudio's own pointer types. Same DllImport-not-LibraryImport choice too, for the same reason: zero AllowUnsafeBlocks needed anywhere in this plugin. One real addition beyond mirroring the physics shim: Lingua_Audio_Init takes a useNullBackend flag. miniaudio's null backend runs the exact same load/decode/mix/loop pipeline against a device that discards its output — real coverage of this shim's logic without depending on real audio hardware being present (most CI runners have none) or making an automated test run produce actual sound, which nobody expects. Real gameplay (AudioPlugin) always requests the real backend; only Engine.Audio.Tests asks for the null one. engine.audio adds an AudioSource component (ClipPath/Volume/Loop/ PlayOnAwake) and AudioWorld, which — same shape as PhysicsWorld, same reason: no destruction event exists to hook — diffs Query<AudioSource>() against its own tracked set every Stage.Update, loading a clip the first time it sees one, applying Volume/Loop changes only when they actually changed, and unloading sounds whose GameObject is gone. IAudioService exposes Play/Stop/IsPlaying for gameplay code to trigger a sound instead of it only ever firing on PlayOnAwake. 5 new tests, all against the null backend, all passed after fixing one real bug they caught: AudioWorld originally re-attempted loading (and re-warned about) a missing clip on every single Sync call instead of once — MissingClip_WarnsAndDoesNotThrow failed on the first run with 2 warnings instead of 1, fixed by tracking failed-load GameObjects the same way PhysicsWorld already tracks missing-collider ones. Full suite: 86 tests. Not yet verified: actual audible playback through a real device — the null-backend tests prove this plugin's own logic, but nobody has listened to real output yet. Worth doing deliberately, not as a surprise mid-session — flagging rather than just doing it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
112 lines
3.7 KiB
C#
112 lines
3.7 KiB
C#
using Engine.Audio.Contracts;
|
|
using Engine.Kernel.Diagnostics;
|
|
using Engine.Kernel.World;
|
|
|
|
namespace Engine.Audio;
|
|
|
|
/// <summary>
|
|
/// Owns the one native miniaudio engine this plugin creates, and the
|
|
/// GameObject <-> loaded-sound handle mapping. <see cref="Sync"/> loads a
|
|
/// clip the first time it sees a GameObject's AudioSource, applies
|
|
/// Volume/Loop changes without re-touching the native side when nothing
|
|
/// changed, fires PlayOnAwake exactly once, and — same no-destruction-event
|
|
/// problem PhysicsWorld has — unloads sounds for GameObjects that no
|
|
/// longer have an AudioSource.
|
|
///
|
|
/// useNullBackend exists for exactly one reason: Engine.Audio.Tests uses
|
|
/// it. Real gameplay always gets a real device (see AudioPlugin) — nothing
|
|
/// about the null backend belongs in a shipped build's own choice.
|
|
/// </summary>
|
|
internal sealed class AudioWorld : IDisposable
|
|
{
|
|
private readonly ILogger _log;
|
|
private readonly Dictionary<GameObject, int> _handles = [];
|
|
private readonly Dictionary<GameObject, (float Volume, bool Loop)> _lastSynced = [];
|
|
private readonly HashSet<GameObject> _warnedFailedToLoad = [];
|
|
|
|
public AudioWorld(ILogger log, bool useNullBackend)
|
|
{
|
|
_log = log;
|
|
|
|
if (Native.Lingua_Audio_Init(useNullBackend ? 1 : 0) == 0)
|
|
_log.Error("Failed to initialize the audio engine — no sounds will play.");
|
|
}
|
|
|
|
public void Sync(IWorld world)
|
|
{
|
|
var live = new HashSet<GameObject>();
|
|
|
|
foreach (var go in world.Query<AudioSource>())
|
|
{
|
|
live.Add(go);
|
|
var src = go.GetComponent<AudioSource>()!;
|
|
|
|
if (_warnedFailedToLoad.Contains(go))
|
|
continue;
|
|
|
|
if (!_handles.TryGetValue(go, out var handle))
|
|
{
|
|
handle = Native.Lingua_Audio_LoadSound(src.ClipPath);
|
|
if (handle < 0)
|
|
{
|
|
_log.Warn($"'{go.Name}' AudioSource failed to load '{src.ClipPath}'.");
|
|
_warnedFailedToLoad.Add(go);
|
|
continue;
|
|
}
|
|
|
|
_handles[go] = handle;
|
|
Native.Lingua_Audio_SetVolume(handle, src.Volume);
|
|
Native.Lingua_Audio_SetLooping(handle, src.Loop);
|
|
_lastSynced[go] = (src.Volume, src.Loop);
|
|
|
|
if (src.PlayOnAwake)
|
|
Native.Lingua_Audio_Play(handle);
|
|
|
|
continue;
|
|
}
|
|
|
|
var last = _lastSynced[go];
|
|
if (last.Volume != src.Volume)
|
|
Native.Lingua_Audio_SetVolume(handle, src.Volume);
|
|
if (last.Loop != src.Loop)
|
|
Native.Lingua_Audio_SetLooping(handle, src.Loop);
|
|
|
|
_lastSynced[go] = (src.Volume, src.Loop);
|
|
}
|
|
|
|
foreach (var stale in _handles.Keys.Where(go => !live.Contains(go)).ToList())
|
|
{
|
|
Native.Lingua_Audio_UnloadSound(_handles[stale]);
|
|
_handles.Remove(stale);
|
|
_lastSynced.Remove(stale);
|
|
}
|
|
|
|
_warnedFailedToLoad.RemoveWhere(go => !live.Contains(go));
|
|
}
|
|
|
|
public void Play(GameObject go)
|
|
{
|
|
if (_handles.TryGetValue(go, out var handle))
|
|
Native.Lingua_Audio_Play(handle);
|
|
}
|
|
|
|
public void Stop(GameObject go)
|
|
{
|
|
if (_handles.TryGetValue(go, out var handle))
|
|
Native.Lingua_Audio_Stop(handle);
|
|
}
|
|
|
|
public bool IsPlaying(GameObject go) =>
|
|
_handles.TryGetValue(go, out var handle) && Native.Lingua_Audio_IsPlaying(handle);
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var handle in _handles.Values)
|
|
Native.Lingua_Audio_UnloadSound(handle);
|
|
|
|
_handles.Clear();
|
|
_lastSynced.Clear();
|
|
Native.Lingua_Audio_Shutdown();
|
|
}
|
|
}
|