refactor(app): compose content effects and audio startup
Move Phase-2 startup behind typed composition/publication boundaries, replace the GameWindow-capturing PhysicsScript gate with a focused deferred source, and own animation-hook registrations reversibly. Make OpenAL construction and teardown transactional so every device, context, source, and buffer prefix is retryable without replay.
This commit is contained in:
parent
1d51e35c14
commit
60a1698ce7
12 changed files with 1901 additions and 152 deletions
|
|
@ -52,11 +52,10 @@ namespace AcDream.App.Audio;
|
|||
public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
||||
{
|
||||
// ── Backends ─────────────────────────────────────────────────────────────
|
||||
private readonly ALContext? _alc;
|
||||
private readonly AL? _al;
|
||||
private readonly Device* _device;
|
||||
private readonly Context* _context;
|
||||
private readonly bool _available;
|
||||
private AL? _al;
|
||||
private OpenAlResourceLifetime? _resources;
|
||||
private bool _available;
|
||||
private bool _disposed;
|
||||
|
||||
// ── Pools ────────────────────────────────────────────────────────────────
|
||||
private const int PoolSize3D = 16; // retail 16-slot voice pool
|
||||
|
|
@ -91,44 +90,48 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
|||
public bool IsAvailable => _available;
|
||||
|
||||
public OpenAlAudioEngine()
|
||||
: this(new SilkOpenAlResourceApiFactory())
|
||||
{
|
||||
}
|
||||
|
||||
internal OpenAlAudioEngine(IOpenAlResourceApiFactory apiFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiFactory);
|
||||
IOpenAlResourceApi api;
|
||||
try
|
||||
{
|
||||
_alc = ALContext.GetApi(soft: true);
|
||||
_al = AL.GetApi(soft: true);
|
||||
_device = _alc.OpenDevice(string.Empty);
|
||||
if (_device == null)
|
||||
api = apiFactory.Create();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_al = api.AudioApi;
|
||||
_resources = new OpenAlResourceLifetime(api);
|
||||
try
|
||||
{
|
||||
if (!_resources.TryOpenDevice())
|
||||
{
|
||||
_available = false;
|
||||
return;
|
||||
}
|
||||
_context = _alc.CreateContext(_device, null);
|
||||
if (_context == null)
|
||||
if (!_resources.TryCreateContext())
|
||||
{
|
||||
_alc.CloseDevice(_device);
|
||||
_device = null;
|
||||
_available = false;
|
||||
DisableAfterInitializationFailure(
|
||||
new InvalidOperationException("OpenAL could not create a context."));
|
||||
return;
|
||||
}
|
||||
if (!_alc.MakeContextCurrent(_context))
|
||||
if (!_resources.TryMakeCurrent())
|
||||
{
|
||||
_alc.DestroyContext(_context);
|
||||
_context = null;
|
||||
_alc.CloseDevice(_device);
|
||||
_device = null;
|
||||
_available = false;
|
||||
DisableAfterInitializationFailure(
|
||||
new InvalidOperationException("OpenAL could not activate its context."));
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialise 3D source pool.
|
||||
for (int i = 0; i < PoolSize3D; i++)
|
||||
{
|
||||
uint src = _al.GenSource();
|
||||
_al.SetSourceProperty(src, SourceFloat.Gain, 1f);
|
||||
_al.SetSourceProperty(src, SourceFloat.MaxDistance, 1000f);
|
||||
_al.SetSourceProperty(src, SourceFloat.RolloffFactor, 1f);
|
||||
_al.SetSourceProperty(src, SourceFloat.ReferenceDistance, 2f);
|
||||
_al.SetSourceProperty(src, SourceBoolean.Looping, false);
|
||||
uint src = _resources.Create3DSource();
|
||||
_pool3D[i] = new Slot3D { SourceId = src, InUse = false };
|
||||
}
|
||||
|
||||
|
|
@ -136,61 +139,57 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
|||
// ignore 3D position.
|
||||
for (int i = 0; i < PoolSizeUi; i++)
|
||||
{
|
||||
uint src = _al.GenSource();
|
||||
_al.SetSourceProperty(src, SourceBoolean.SourceRelative, true);
|
||||
_al.SetSourceProperty(src, SourceFloat.Gain, 1f);
|
||||
_al.SetSourceProperty(src, SourceBoolean.Looping, false);
|
||||
uint src = _resources.CreateUiSource();
|
||||
_poolUi[i] = src;
|
||||
}
|
||||
|
||||
// Global distance model = inverse-square clamped (classic retail feel).
|
||||
_al.DistanceModel(DistanceModel.InverseDistanceClamped);
|
||||
api.SelectRetailDistanceModel();
|
||||
|
||||
_available = true;
|
||||
}
|
||||
catch
|
||||
catch (OpenAlInitializationException)
|
||||
{
|
||||
// OpenAL driver unavailable (headless CI, missing libopenal).
|
||||
_available = false;
|
||||
throw;
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
DisableAfterInitializationFailure(failure);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_available || _al is null) return;
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_available = false;
|
||||
_resources?.RetryCleanup();
|
||||
_disposed = _resources is null || _resources.IsCleanupComplete;
|
||||
}
|
||||
|
||||
internal bool IsDisposalComplete =>
|
||||
_disposed || _resources is null || _resources.IsCleanupComplete;
|
||||
|
||||
private void DisableAfterInitializationFailure(Exception failure)
|
||||
{
|
||||
_available = false;
|
||||
if (_resources is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < PoolSize3D; i++)
|
||||
{
|
||||
var slot = _pool3D[i];
|
||||
if (slot is null) continue;
|
||||
_al.SourceStop(slot.SourceId);
|
||||
_al.DeleteSource(slot.SourceId);
|
||||
}
|
||||
for (int i = 0; i < PoolSizeUi; i++)
|
||||
{
|
||||
_al.SourceStop(_poolUi[i]);
|
||||
_al.DeleteSource(_poolUi[i]);
|
||||
}
|
||||
foreach (var kv in _ambientSources)
|
||||
{
|
||||
_al.SourceStop(kv.Value);
|
||||
_al.DeleteSource(kv.Value);
|
||||
}
|
||||
foreach (var buf in _bufferByWaveId.Values)
|
||||
{
|
||||
_al.DeleteBuffer(buf);
|
||||
}
|
||||
if (_context != null && _alc is not null)
|
||||
{
|
||||
_alc.MakeContextCurrent(null);
|
||||
_alc.DestroyContext(_context);
|
||||
}
|
||||
if (_device != null && _alc is not null)
|
||||
_alc.CloseDevice(_device);
|
||||
_resources.RetryCleanup();
|
||||
}
|
||||
catch { /* shutdown — ignore */ }
|
||||
catch (AggregateException cleanupFailure)
|
||||
{
|
||||
throw new OpenAlInitializationException(
|
||||
failure,
|
||||
_resources,
|
||||
cleanupFailure);
|
||||
}
|
||||
|
||||
_al = null;
|
||||
}
|
||||
|
||||
// ── IAudioEngine ─────────────────────────────────────────────────────────
|
||||
|
|
@ -339,10 +338,11 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
|||
if (_bufferByWaveId.TryGetValue(waveId, out var existing)) return existing;
|
||||
|
||||
uint buf = _al.GenBuffer();
|
||||
_resources!.OwnBuffer(buf);
|
||||
BufferFormat fmt = PickFormat(wave);
|
||||
if (fmt == 0)
|
||||
{
|
||||
_al.DeleteBuffer(buf);
|
||||
_resources.ReleaseBuffer(buf);
|
||||
_bufferByWaveId[waveId] = 0;
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
335
src/AcDream.App/Audio/OpenAlResourceLifetime.cs
Normal file
335
src/AcDream.App/Audio/OpenAlResourceLifetime.cs
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
using AcDream.App.Rendering;
|
||||
using Silk.NET.OpenAL;
|
||||
|
||||
namespace AcDream.App.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Narrow native-resource surface used by the OpenAL construction transaction.
|
||||
/// Runtime playback still calls Silk's typed <see cref="AL"/> API directly.
|
||||
/// </summary>
|
||||
internal interface IOpenAlResourceApi
|
||||
{
|
||||
AL? AudioApi { get; }
|
||||
ALContext? ContextApi { get; }
|
||||
|
||||
nint OpenDevice();
|
||||
nint CreateContext(nint device);
|
||||
bool MakeContextCurrent(nint context);
|
||||
uint GenerateSource();
|
||||
void Configure3DSource(uint source);
|
||||
void ConfigureUiSource(uint source);
|
||||
void SelectRetailDistanceModel();
|
||||
void StopSource(uint source);
|
||||
void DeleteSource(uint source);
|
||||
void DeleteBuffer(uint buffer);
|
||||
void DestroyContext(nint context);
|
||||
void CloseDevice(nint device);
|
||||
}
|
||||
|
||||
internal interface IOpenAlResourceApiFactory
|
||||
{
|
||||
IOpenAlResourceApi Create();
|
||||
}
|
||||
|
||||
internal sealed class SilkOpenAlResourceApiFactory : IOpenAlResourceApiFactory
|
||||
{
|
||||
public IOpenAlResourceApi Create() => new SilkOpenAlResourceApi();
|
||||
}
|
||||
|
||||
internal sealed unsafe class SilkOpenAlResourceApi : IOpenAlResourceApi
|
||||
{
|
||||
public SilkOpenAlResourceApi()
|
||||
{
|
||||
ContextApi = ALContext.GetApi(soft: true);
|
||||
AudioApi = AL.GetApi(soft: true);
|
||||
}
|
||||
|
||||
public AL AudioApi { get; }
|
||||
public ALContext ContextApi { get; }
|
||||
|
||||
public nint OpenDevice() => (nint)ContextApi.OpenDevice(string.Empty);
|
||||
|
||||
public nint CreateContext(nint device) =>
|
||||
(nint)ContextApi.CreateContext((Device*)device, null);
|
||||
|
||||
public bool MakeContextCurrent(nint context) =>
|
||||
ContextApi.MakeContextCurrent((Context*)context);
|
||||
|
||||
public uint GenerateSource() => AudioApi.GenSource();
|
||||
|
||||
public void Configure3DSource(uint source)
|
||||
{
|
||||
AudioApi.SetSourceProperty(source, SourceFloat.Gain, 1f);
|
||||
AudioApi.SetSourceProperty(source, SourceFloat.MaxDistance, 1000f);
|
||||
AudioApi.SetSourceProperty(source, SourceFloat.RolloffFactor, 1f);
|
||||
AudioApi.SetSourceProperty(source, SourceFloat.ReferenceDistance, 2f);
|
||||
AudioApi.SetSourceProperty(source, SourceBoolean.Looping, false);
|
||||
}
|
||||
|
||||
public void ConfigureUiSource(uint source)
|
||||
{
|
||||
AudioApi.SetSourceProperty(source, SourceBoolean.SourceRelative, true);
|
||||
AudioApi.SetSourceProperty(source, SourceFloat.Gain, 1f);
|
||||
AudioApi.SetSourceProperty(source, SourceBoolean.Looping, false);
|
||||
}
|
||||
|
||||
public void SelectRetailDistanceModel() =>
|
||||
AudioApi.DistanceModel(DistanceModel.InverseDistanceClamped);
|
||||
|
||||
public void StopSource(uint source) => AudioApi.SourceStop(source);
|
||||
|
||||
public void DeleteSource(uint source) => AudioApi.DeleteSource(source);
|
||||
|
||||
public void DeleteBuffer(uint buffer) => AudioApi.DeleteBuffer(buffer);
|
||||
|
||||
public void DestroyContext(nint context) =>
|
||||
ContextApi.DestroyContext((Context*)context);
|
||||
|
||||
public void CloseDevice(nint device) =>
|
||||
ContextApi.CloseDevice((Device*)device);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns every native handle immediately after acquisition. Cleanup is
|
||||
/// all-attempted, reverse dependency ordered, retryable, and never replays a
|
||||
/// successful release.
|
||||
/// </summary>
|
||||
internal sealed class OpenAlResourceLifetime : IRetryableResourceCleanup
|
||||
{
|
||||
private sealed class SourceState(uint id)
|
||||
{
|
||||
public uint Id { get; } = id;
|
||||
public bool Released { get; set; }
|
||||
}
|
||||
|
||||
private sealed class BufferState(uint id)
|
||||
{
|
||||
public uint Id { get; } = id;
|
||||
public bool Released { get; set; }
|
||||
}
|
||||
|
||||
private readonly IOpenAlResourceApi _api;
|
||||
private readonly List<SourceState> _sources = [];
|
||||
private readonly List<BufferState> _buffers = [];
|
||||
private nint _device;
|
||||
private nint _context;
|
||||
private bool _contextCurrent;
|
||||
private bool _cleanupActive;
|
||||
|
||||
public OpenAlResourceLifetime(IOpenAlResourceApi api)
|
||||
{
|
||||
_api = api ?? throw new ArgumentNullException(nameof(api));
|
||||
}
|
||||
|
||||
public nint Device => _device;
|
||||
public nint Context => _context;
|
||||
|
||||
public bool IsCleanupComplete =>
|
||||
_sources.All(static source => source.Released)
|
||||
&& _buffers.All(static buffer => buffer.Released)
|
||||
&& _context == 0
|
||||
&& _device == 0;
|
||||
|
||||
public bool TryOpenDevice()
|
||||
{
|
||||
if (_device != 0)
|
||||
throw new InvalidOperationException("The OpenAL device is already open.");
|
||||
_device = _api.OpenDevice();
|
||||
return _device != 0;
|
||||
}
|
||||
|
||||
public bool TryCreateContext()
|
||||
{
|
||||
if (_device == 0)
|
||||
throw new InvalidOperationException("An OpenAL device is required before its context.");
|
||||
if (_context != 0)
|
||||
throw new InvalidOperationException("The OpenAL context already exists.");
|
||||
_context = _api.CreateContext(_device);
|
||||
return _context != 0;
|
||||
}
|
||||
|
||||
public bool TryMakeCurrent()
|
||||
{
|
||||
if (_context == 0)
|
||||
throw new InvalidOperationException("An OpenAL context is required before activation.");
|
||||
_contextCurrent = _api.MakeContextCurrent(_context);
|
||||
return _contextCurrent;
|
||||
}
|
||||
|
||||
public uint Create3DSource()
|
||||
{
|
||||
uint source = _api.GenerateSource();
|
||||
_sources.Add(new SourceState(source));
|
||||
_api.Configure3DSource(source);
|
||||
return source;
|
||||
}
|
||||
|
||||
public uint CreateUiSource()
|
||||
{
|
||||
uint source = _api.GenerateSource();
|
||||
_sources.Add(new SourceState(source));
|
||||
_api.ConfigureUiSource(source);
|
||||
return source;
|
||||
}
|
||||
|
||||
public void OwnBuffer(uint buffer)
|
||||
{
|
||||
if (buffer == 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(buffer));
|
||||
if (_buffers.Any(existing => existing.Id == buffer && !existing.Released))
|
||||
throw new InvalidOperationException($"OpenAL buffer {buffer} is already owned.");
|
||||
_buffers.Add(new BufferState(buffer));
|
||||
}
|
||||
|
||||
public void ReleaseBuffer(uint buffer)
|
||||
{
|
||||
BufferState state = _buffers.LastOrDefault(candidate =>
|
||||
candidate.Id == buffer && !candidate.Released)
|
||||
?? throw new InvalidOperationException($"OpenAL buffer {buffer} is not owned.");
|
||||
_api.DeleteBuffer(buffer);
|
||||
state.Released = true;
|
||||
}
|
||||
|
||||
public void RetryCleanup()
|
||||
{
|
||||
if (_cleanupActive || IsCleanupComplete)
|
||||
return;
|
||||
|
||||
_cleanupActive = true;
|
||||
List<Exception>? failures = null;
|
||||
try
|
||||
{
|
||||
// Sources borrow buffers; retire all sources first. Each deletion
|
||||
// is still attempted even when SourceStop reports a driver error.
|
||||
for (int i = _sources.Count - 1; i >= 0; i--)
|
||||
{
|
||||
SourceState source = _sources[i];
|
||||
if (source.Released)
|
||||
continue;
|
||||
|
||||
Exception? stopFailure = null;
|
||||
try
|
||||
{
|
||||
_api.StopSource(source.Id);
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
stopFailure = failure;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_api.DeleteSource(source.Id);
|
||||
source.Released = true;
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
(failures ??= []).Add(new AggregateException(
|
||||
$"OpenAL source {source.Id} could not be released.",
|
||||
stopFailure is null ? [failure] : [stopFailure, failure]));
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = _buffers.Count - 1; i >= 0; i--)
|
||||
{
|
||||
BufferState buffer = _buffers[i];
|
||||
if (buffer.Released)
|
||||
continue;
|
||||
try
|
||||
{
|
||||
_api.DeleteBuffer(buffer.Id);
|
||||
buffer.Released = true;
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
(failures ??= []).Add(new InvalidOperationException(
|
||||
$"OpenAL buffer {buffer.Id} could not be released.",
|
||||
failure));
|
||||
}
|
||||
}
|
||||
|
||||
bool childrenReleased =
|
||||
_sources.All(static source => source.Released)
|
||||
&& _buffers.All(static buffer => buffer.Released);
|
||||
if (childrenReleased && _context != 0)
|
||||
{
|
||||
if (_contextCurrent)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_api.MakeContextCurrent(0))
|
||||
throw new InvalidOperationException(
|
||||
"OpenAL rejected clearing the current context.");
|
||||
_contextCurrent = false;
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
(failures ??= []).Add(new InvalidOperationException(
|
||||
"The current OpenAL context could not be cleared.",
|
||||
failure));
|
||||
}
|
||||
}
|
||||
|
||||
if (!_contextCurrent)
|
||||
{
|
||||
try
|
||||
{
|
||||
_api.DestroyContext(_context);
|
||||
_context = 0;
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
(failures ??= []).Add(new InvalidOperationException(
|
||||
"The OpenAL context could not be destroyed.",
|
||||
failure));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_context == 0 && _device != 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
_api.CloseDevice(_device);
|
||||
_device = 0;
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
(failures ??= []).Add(new InvalidOperationException(
|
||||
"The OpenAL device could not be closed.",
|
||||
failure));
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_cleanupActive = false;
|
||||
}
|
||||
|
||||
if (failures is not null)
|
||||
throw new AggregateException(
|
||||
"OpenAL native-resource cleanup remains incomplete.",
|
||||
failures);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class OpenAlInitializationException : AggregateException,
|
||||
IRetryableResourceCleanup
|
||||
{
|
||||
private readonly OpenAlResourceLifetime _lifetime;
|
||||
|
||||
public OpenAlInitializationException(
|
||||
Exception initializationFailure,
|
||||
OpenAlResourceLifetime lifetime,
|
||||
AggregateException cleanupFailure)
|
||||
: base(
|
||||
"OpenAL initialization failed and native-resource cleanup remains incomplete.",
|
||||
[initializationFailure, .. cleanupFailure.InnerExceptions])
|
||||
{
|
||||
_lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
|
||||
}
|
||||
|
||||
public bool IsCleanupComplete => _lifetime.IsCleanupComplete;
|
||||
|
||||
public void RetryCleanup() => _lifetime.RetryCleanup();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue