M4: engine.audio — miniaudio, same narrow-shim shape as engine.physics
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
This commit is contained in:
@@ -65,6 +65,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Physics", "plugins\e
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Physics.Tests", "tests\Engine.Physics.Tests\Engine.Physics.Tests.csproj", "{70245995-311C-4CBE-A433-F4035303BEDB}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "engine.audio", "engine.audio", "{2D4F0C3C-7532-0E50-D9FD-7209344A42BE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Audio.Contracts", "plugins\engine.audio\Engine.Audio.Contracts\Engine.Audio.Contracts.csproj", "{2C2E545D-6937-476D-AC62-F96CEA6403F9}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Audio", "plugins\engine.audio\Engine.Audio\Engine.Audio.csproj", "{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Audio.Tests", "tests\Engine.Audio.Tests\Engine.Audio.Tests.csproj", "{E610BD6A-A732-4863-8D19-555B75134E13}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -327,6 +335,42 @@ Global
|
||||
{70245995-311C-4CBE-A433-F4035303BEDB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{70245995-311C-4CBE-A433-F4035303BEDB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{70245995-311C-4CBE-A433-F4035303BEDB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -360,5 +404,9 @@ Global
|
||||
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D} = {5BFA9F87-CB7C-360F-A763-812D3AAB4167}
|
||||
{A9205D28-762B-49C0-8CFF-8D59BA330786} = {5BFA9F87-CB7C-360F-A763-812D3AAB4167}
|
||||
{70245995-311C-4CBE-A433-F4035303BEDB} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{2D4F0C3C-7532-0E50-D9FD-7209344A42BE} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9} = {2D4F0C3C-7532-0E50-D9FD-7209344A42BE}
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB} = {2D4F0C3C-7532-0E50-D9FD-7209344A42BE}
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13} = {2D4F0C3C-7532-0E50-D9FD-7209344A42BE}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(lingua_audio_native C)
|
||||
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||
|
||||
add_library(lingua_audio SHARED lingua_audio.c)
|
||||
|
||||
if(UNIX AND NOT APPLE)
|
||||
find_package(Threads REQUIRED)
|
||||
target_link_libraries(lingua_audio PRIVATE Threads::Threads m dl)
|
||||
endif()
|
||||
|
||||
install(TARGETS lingua_audio RUNTIME DESTINATION . LIBRARY DESTINATION .)
|
||||
@@ -0,0 +1,169 @@
|
||||
// The same narrow-shim approach as native/physics-native/lingua_physics.c,
|
||||
// for the same reason: miniaudio's own ma_engine_config/ma_sound_config are
|
||||
// large structs with optional callbacks, not something worth replicating
|
||||
// byte-for-byte in C#. Every exported function here takes and returns only
|
||||
// plain int/float/bool scalars (a C string for the one thing that has to
|
||||
// be one, a file path), and handles are this shim's own array-index
|
||||
// handles into a fixed-capacity ma_sound table — not miniaudio's own
|
||||
// pointer types.
|
||||
//
|
||||
// One global ma_engine: nothing in M4's scope needs more than one audio
|
||||
// device active at a time.
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
#define MINIAUDIO_IMPLEMENTATION
|
||||
#include "miniaudio.h"
|
||||
|
||||
#if defined( _WIN32 )
|
||||
#define LINGUA_API __declspec( dllexport )
|
||||
#else
|
||||
#define LINGUA_API __attribute__( ( visibility( "default" ) ) )
|
||||
#endif
|
||||
|
||||
#define LINGUA_MAX_SOUNDS 64
|
||||
|
||||
static ma_engine g_engine;
|
||||
static int g_engineInitialized = 0;
|
||||
|
||||
static ma_sound g_sounds[LINGUA_MAX_SOUNDS];
|
||||
static int g_soundUsed[LINGUA_MAX_SOUNDS];
|
||||
|
||||
static int ValidSound( int handle )
|
||||
{
|
||||
return handle >= 0 && handle < LINGUA_MAX_SOUNDS && g_soundUsed[handle];
|
||||
}
|
||||
|
||||
// useNullBackend: real playback needs a real device, which automated
|
||||
// tests shouldn't depend on (nondeterministic across machines, and CI
|
||||
// almost never has one) or want (nobody expects a test run to make sound).
|
||||
// The null backend runs the exact same mixing/decoding/looping pipeline
|
||||
// against a device that discards its output — real coverage of this
|
||||
// shim's logic, silently.
|
||||
LINGUA_API int Lingua_Audio_Init( int useNullBackend )
|
||||
{
|
||||
if ( g_engineInitialized )
|
||||
return 1;
|
||||
|
||||
ma_result result;
|
||||
|
||||
if ( useNullBackend )
|
||||
{
|
||||
static ma_context context;
|
||||
ma_backend backends[] = { ma_backend_null };
|
||||
|
||||
result = ma_context_init( backends, 1, NULL, &context );
|
||||
if ( result != MA_SUCCESS )
|
||||
return 0;
|
||||
|
||||
ma_engine_config engineConfig = ma_engine_config_init();
|
||||
engineConfig.pContext = &context;
|
||||
result = ma_engine_init( &engineConfig, &g_engine );
|
||||
}
|
||||
else
|
||||
{
|
||||
result = ma_engine_init( NULL, &g_engine );
|
||||
}
|
||||
|
||||
if ( result != MA_SUCCESS )
|
||||
return 0;
|
||||
|
||||
g_engineInitialized = 1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
LINGUA_API void Lingua_Audio_Shutdown( void )
|
||||
{
|
||||
if ( !g_engineInitialized )
|
||||
return;
|
||||
|
||||
for ( int i = 0; i < LINGUA_MAX_SOUNDS; i++ )
|
||||
{
|
||||
if ( g_soundUsed[i] )
|
||||
{
|
||||
ma_sound_uninit( &g_sounds[i] );
|
||||
g_soundUsed[i] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
ma_engine_uninit( &g_engine );
|
||||
g_engineInitialized = 0;
|
||||
}
|
||||
|
||||
LINGUA_API int Lingua_Audio_LoadSound( const char* path )
|
||||
{
|
||||
if ( !g_engineInitialized )
|
||||
return -1;
|
||||
|
||||
int slot = -1;
|
||||
for ( int i = 0; i < LINGUA_MAX_SOUNDS; i++ )
|
||||
{
|
||||
if ( !g_soundUsed[i] )
|
||||
{
|
||||
slot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( slot < 0 )
|
||||
return -1;
|
||||
|
||||
if ( ma_sound_init_from_file( &g_engine, path, 0, NULL, NULL, &g_sounds[slot] ) != MA_SUCCESS )
|
||||
return -1;
|
||||
|
||||
g_soundUsed[slot] = 1;
|
||||
return slot;
|
||||
}
|
||||
|
||||
LINGUA_API void Lingua_Audio_UnloadSound( int handle )
|
||||
{
|
||||
if ( !ValidSound( handle ) )
|
||||
return;
|
||||
|
||||
ma_sound_uninit( &g_sounds[handle] );
|
||||
g_soundUsed[handle] = 0;
|
||||
}
|
||||
|
||||
// Always restarts from the first frame — the predictable behavior for
|
||||
// "trigger this sound effect now." A looping music track only needs this
|
||||
// called once; calling it again mid-loop is a deliberate restart, not a
|
||||
// bug.
|
||||
LINGUA_API void Lingua_Audio_Play( int handle )
|
||||
{
|
||||
if ( !ValidSound( handle ) )
|
||||
return;
|
||||
|
||||
ma_sound_seek_to_pcm_frame( &g_sounds[handle], 0 );
|
||||
ma_sound_start( &g_sounds[handle] );
|
||||
}
|
||||
|
||||
LINGUA_API void Lingua_Audio_Stop( int handle )
|
||||
{
|
||||
if ( !ValidSound( handle ) )
|
||||
return;
|
||||
|
||||
ma_sound_stop( &g_sounds[handle] );
|
||||
}
|
||||
|
||||
LINGUA_API void Lingua_Audio_SetVolume( int handle, float volume )
|
||||
{
|
||||
if ( !ValidSound( handle ) )
|
||||
return;
|
||||
|
||||
ma_sound_set_volume( &g_sounds[handle], volume );
|
||||
}
|
||||
|
||||
LINGUA_API void Lingua_Audio_SetLooping( int handle, bool loop )
|
||||
{
|
||||
if ( !ValidSound( handle ) )
|
||||
return;
|
||||
|
||||
ma_sound_set_looping( &g_sounds[handle], loop ? MA_TRUE : MA_FALSE );
|
||||
}
|
||||
|
||||
LINGUA_API bool Lingua_Audio_IsPlaying( int handle )
|
||||
{
|
||||
if ( !ValidSound( handle ) )
|
||||
return false;
|
||||
|
||||
return ma_sound_is_playing( &g_sounds[handle] ) ? true : false;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Audio.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// A sound clip attached to a GameObject. ClipPath is loaded lazily, the
|
||||
/// first time engine.audio sees this component — not eagerly on
|
||||
/// AddComponent, since Component construction has no plugin context to
|
||||
/// load anything through (same reason texture loading in engine.render
|
||||
/// happens in a system, not a component constructor).
|
||||
/// </summary>
|
||||
public sealed class AudioSource : Component
|
||||
{
|
||||
public string ClipPath = "";
|
||||
public float Volume = 1f;
|
||||
public bool Loop = false;
|
||||
public bool PlayOnAwake = false;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,18 @@
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Audio.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Gameplay-facing trigger for a GameObject's AudioSource — "play the
|
||||
/// bounce sound now," not a field on AudioSource itself, same split
|
||||
/// IPhysicsService makes between a Rigidbody's persistent data and an
|
||||
/// action performed on it (see that interface's own doc comment).
|
||||
/// </summary>
|
||||
public interface IAudioService
|
||||
{
|
||||
void Play(GameObject go);
|
||||
|
||||
void Stop(GameObject go);
|
||||
|
||||
bool IsPlaying(GameObject go);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Engine.Audio.Tests")]
|
||||
@@ -0,0 +1,38 @@
|
||||
using Engine.Audio.Contracts;
|
||||
using Engine.Kernel.Plugins;
|
||||
using Engine.Kernel.Scheduling;
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// M4's audio plugin: miniaudio, over the shim in native/audio-native/ —
|
||||
/// see that file's own doc comment for why nothing but scalars and a UTF-8
|
||||
/// path string cross the P/Invoke boundary. One system, on Stage.Update
|
||||
/// (not FixedUpdate — nothing about audio is physics-timed): sync new/
|
||||
/// changed/removed AudioSources every frame.
|
||||
/// </summary>
|
||||
public sealed class AudioPlugin : IPlugin
|
||||
{
|
||||
private AudioWorld? _world;
|
||||
|
||||
public void Configure(IPluginContext ctx)
|
||||
{
|
||||
_world = new AudioWorld(ctx.Log, useNullBackend: false);
|
||||
ctx.Services.Provide<IAudioService>(new AudioService(_world));
|
||||
|
||||
ctx.Schedule.Add(Stage.Update, Sync).Reads<AudioSource>();
|
||||
|
||||
ctx.Log.Info("audio engine ready (miniaudio)");
|
||||
}
|
||||
|
||||
public void Shutdown(IPluginContext ctx)
|
||||
{
|
||||
ctx.Schedule.RemoveAllFrom("engine.audio");
|
||||
ctx.Services.Revoke<IAudioService>();
|
||||
_world?.Dispose();
|
||||
_world = null;
|
||||
}
|
||||
|
||||
private void Sync(IWorld world) => _world!.Sync(world);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Engine.Audio.Contracts;
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Audio;
|
||||
|
||||
internal sealed class AudioService(AudioWorld world) : IAudioService
|
||||
{
|
||||
public void Play(GameObject go) => world.Play(go);
|
||||
|
||||
public void Stop(GameObject go) => world.Stop(go);
|
||||
|
||||
public bool IsPlaying(GameObject go) => world.IsPlaying(go);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Same reasoning as engine.physics' own native Content item: default
|
||||
DllImport probing already checks the calling assembly's directory,
|
||||
so this is copied straight alongside Engine.Audio.dll rather than
|
||||
through a runtimes/<rid>/native/ path. -->
|
||||
<Content Include="native/liblingua_audio.so">
|
||||
<Link>liblingua_audio.so</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
|
||||
<ProjectReference Include="..\Engine.Audio.Contracts\Engine.Audio.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Engine.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Matches native/audio-native/lingua_audio.c's exports one-to-one. Same
|
||||
/// choice as engine.physics' Native.cs: classic DllImport, not
|
||||
/// LibraryImport, so no AllowUnsafeBlocks is needed anywhere in this
|
||||
/// plugin — every parameter here is a plain scalar or a UTF-8 string,
|
||||
/// nothing that needs unsafe marshalling code.
|
||||
/// </summary>
|
||||
internal static class Native
|
||||
{
|
||||
private const string Lib = "lingua_audio";
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern int Lingua_Audio_Init(int useNullBackend);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_Audio_Shutdown();
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern int Lingua_Audio_LoadSound([MarshalAs(UnmanagedType.LPUTF8Str)] string path);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_Audio_UnloadSound(int handle);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_Audio_Play(int handle);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_Audio_Stop(int handle);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_Audio_SetVolume(int handle, float volume);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_Audio_SetLooping(int handle, [MarshalAs(UnmanagedType.U1)] bool loop);
|
||||
|
||||
[DllImport(Lib)]
|
||||
[return: MarshalAs(UnmanagedType.U1)]
|
||||
public static extern bool Lingua_Audio_IsPlaying(int handle);
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "engine.audio",
|
||||
"version": "0.1.0",
|
||||
"contracts": "Engine.Audio.Contracts.dll",
|
||||
"assembly": "Engine.Audio.dll",
|
||||
"dependsOn": {},
|
||||
"reloadable": true
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Engine.Audio;
|
||||
using Engine.Audio.Contracts;
|
||||
using Engine.Kernel.Diagnostics;
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Audio.Tests;
|
||||
|
||||
file sealed class RecordingLogger : ILogger
|
||||
{
|
||||
public List<string> Warnings { get; } = [];
|
||||
public List<string> Errors { get; } = [];
|
||||
|
||||
public void Info(string message) { }
|
||||
public void Warn(string message) => Warnings.Add(message);
|
||||
public void Error(string message) => Errors.Add(message);
|
||||
}
|
||||
|
||||
public class AudioWorldTests
|
||||
{
|
||||
private const string TestClip = "assets/test_tone.wav";
|
||||
|
||||
// Always the null backend: real playback needs a real audio device,
|
||||
// which a test run shouldn't depend on or be heard making noise from.
|
||||
// See AudioWorld's own doc comment.
|
||||
private static AudioWorld NewWorld(ILogger log) => new(log, useNullBackend: true);
|
||||
|
||||
[Fact]
|
||||
public void PlayOnAwake_StartsPlaybackAsSoonAsSourceIsSynced()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var log = new RecordingLogger();
|
||||
using var audio = NewWorld(log);
|
||||
|
||||
var go = world.CreateGameObject("Sfx");
|
||||
go.AddComponent<AudioSource>().ClipPath = TestClip;
|
||||
go.GetComponent<AudioSource>()!.PlayOnAwake = true;
|
||||
|
||||
audio.Sync(world);
|
||||
|
||||
Assert.True(audio.IsPlaying(go));
|
||||
Assert.Empty(log.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithoutPlayOnAwake_DoesNotAutoPlay_ButServicePlayStarts()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var log = new RecordingLogger();
|
||||
using var audio = NewWorld(log);
|
||||
|
||||
var go = world.CreateGameObject("Sfx");
|
||||
go.AddComponent<AudioSource>().ClipPath = TestClip;
|
||||
|
||||
audio.Sync(world);
|
||||
Assert.False(audio.IsPlaying(go));
|
||||
|
||||
audio.Play(go);
|
||||
Assert.True(audio.IsPlaying(go));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_StopsPlayback()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var log = new RecordingLogger();
|
||||
using var audio = NewWorld(log);
|
||||
|
||||
var go = world.CreateGameObject("Sfx");
|
||||
go.AddComponent<AudioSource>().ClipPath = TestClip;
|
||||
audio.Sync(world);
|
||||
|
||||
audio.Play(go);
|
||||
Assert.True(audio.IsPlaying(go));
|
||||
|
||||
audio.Stop(go);
|
||||
Assert.False(audio.IsPlaying(go));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingClip_WarnsAndDoesNotThrow()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var log = new RecordingLogger();
|
||||
using var audio = NewWorld(log);
|
||||
|
||||
var go = world.CreateGameObject("Broken");
|
||||
go.AddComponent<AudioSource>().ClipPath = "assets/does_not_exist.wav";
|
||||
|
||||
audio.Sync(world);
|
||||
audio.Sync(world); // a second sync shouldn't re-warn or throw either
|
||||
|
||||
Assert.Single(log.Warnings);
|
||||
Assert.False(audio.IsPlaying(go));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DestroyedGameObject_SoundCleanedUp_SubsequentSyncsStillWork()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var log = new RecordingLogger();
|
||||
using var audio = NewWorld(log);
|
||||
|
||||
var go = world.CreateGameObject("Temp");
|
||||
go.AddComponent<AudioSource>().ClipPath = TestClip;
|
||||
audio.Sync(world);
|
||||
audio.Play(go);
|
||||
|
||||
world.Destroy(go);
|
||||
audio.Sync(world); // should unload the now-orphaned sound, not throw
|
||||
|
||||
// A second GameObject reusing the same clip proves the world is
|
||||
// still in a usable state after the cleanup above.
|
||||
var go2 = world.CreateGameObject("Temp2");
|
||||
go2.AddComponent<AudioSource>().ClipPath = TestClip;
|
||||
audio.Sync(world);
|
||||
audio.Play(go2);
|
||||
|
||||
Assert.True(audio.IsPlaying(go2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="../../plugins/engine.audio/Engine.Audio/native/liblingua_audio.so">
|
||||
<Link>liblingua_audio.so</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="assets/test_tone.wav">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\plugins\engine.audio\Engine.Audio\Engine.Audio.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Binary file not shown.
Reference in New Issue
Block a user