diff --git a/source/Vignette/Allocation/IObjectPool.cs b/source/Vignette/Allocation/IObjectPool.cs
new file mode 100644
index 0000000..548a775
--- /dev/null
+++ b/source/Vignette/Allocation/IObjectPool.cs
@@ -0,0 +1,23 @@
+// Copyright (c) Cosyne
+// Licensed under GPL 3.0 with SDK Exception. See LICENSE for details.
+
+namespace Vignette.Allocation;
+
+///
+/// Defines a mechanism for objects that can pool .
+///
+/// The type of object being pooled.
+public interface IObjectPool
+{
+ ///
+ /// Gets one from the pool.
+ ///
+ T Get();
+
+ ///
+ /// Returns back to the pool.
+ ///
+ /// The to return.
+ /// if the item has been returned. Otherwise, .
+ bool Return(T item);
+}
diff --git a/source/Vignette/Audio/AudioManager.cs b/source/Vignette/Audio/AudioManager.cs
new file mode 100644
index 0000000..e461756
--- /dev/null
+++ b/source/Vignette/Audio/AudioManager.cs
@@ -0,0 +1,222 @@
+// Copyright (c) Cosyne
+// Licensed under GPL 3.0 with SDK Exception. See LICENSE for details.
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using Sekai.Audio;
+using Vignette.Allocation;
+
+namespace Vignette.Audio;
+
+public sealed class AudioManager : IObjectPool
+{
+ private const int max_buffer_size = 8192;
+ private const int max_buffer_count = 500;
+ private readonly AudioDevice device;
+ private readonly ConcurrentBag bufferPool = new();
+ private readonly List controllers = new();
+
+ internal AudioManager(AudioDevice device)
+ {
+ this.device = device;
+ }
+
+ ///
+ /// Creates a new for a .
+ ///
+ /// The audio stream to attach to the controller.
+ /// An audio controller.
+ public IAudioController GetController(AudioStream stream)
+ {
+ return new StreamingAudioController(device.CreateSource(), stream, this);
+ }
+
+ internal void Update()
+ {
+ for (int i = 0; i < controllers.Count; i++)
+ {
+ controllers[i].Update();
+ }
+ }
+
+ ///
+ /// Returns an back to the .
+ ///
+ /// The controller to return.
+ public void Return(IAudioController controller)
+ {
+ if (controller is not StreamingAudioController streaming)
+ {
+ return;
+ }
+
+ if (!controllers.Remove(streaming))
+ {
+ return;
+ }
+
+ streaming.Dispose();
+ }
+
+ AudioBuffer IObjectPool.Get()
+ {
+ if (!bufferPool.TryTake(out var buffer))
+ {
+ buffer = device.CreateBuffer();
+ }
+
+ return buffer;
+ }
+
+ bool IObjectPool.Return(AudioBuffer item)
+ {
+ if (bufferPool.Count >= max_buffer_count)
+ {
+ item.Dispose();
+ return false;
+ }
+
+ bufferPool.Add(item);
+ return true;
+ }
+
+ private sealed class StreamingAudioController : IAudioController, IDisposable
+ {
+ public bool Loop { get; set; }
+
+ public TimeSpan Position
+ {
+ get => getTimeFromByteCount((int)stream.Position, stream.Format, stream.SampleRate);
+ set => seek(getByteCountFromTime(value, stream.Format, stream.SampleRate));
+ }
+
+ public TimeSpan Duration => getTimeFromByteCount((int)stream.Length, stream.Format, stream.SampleRate);
+
+ public TimeSpan Buffered => getTimeFromByteCount(buffered, stream.Format, stream.SampleRate);
+
+ public AudioSourceState State => source.State;
+
+ private int buffered;
+ private bool isDisposed;
+ private const int max_buffer_stream = 4;
+ private readonly AudioSource source;
+ private readonly AudioStream stream;
+ private readonly IObjectPool bufferPool;
+
+ public StreamingAudioController(AudioSource source, AudioStream stream, IObjectPool bufferPool)
+ {
+ this.source = source;
+ this.stream = stream;
+ this.bufferPool = bufferPool;
+ }
+
+ public void Play()
+ {
+ if (State != AudioSourceState.Paused)
+ {
+ seek(0);
+
+ for (int i = 0; i < max_buffer_stream; i++)
+ {
+ var buffer = bufferPool.Get();
+
+ if (!allocate(buffer))
+ {
+ break;
+ }
+
+ source.Enqueue(buffer);
+ }
+ }
+
+ source.Play();
+ }
+
+ public void Stop()
+ {
+ seek(0);
+ }
+
+ public void Pause()
+ {
+ source.Pause();
+ }
+
+ public void Update()
+ {
+ while (source.TryDequeue(out var buffer))
+ {
+ if (!allocate(buffer))
+ {
+ source.Loop = Loop;
+ break;
+ }
+
+ source.Enqueue(buffer);
+ }
+ }
+
+ public void Dispose()
+ {
+ if (isDisposed)
+ {
+ return;
+ }
+
+ source.Stop();
+
+ while(source.TryDequeue(out var buffer))
+ {
+ bufferPool.Return(buffer);
+ }
+
+ source.Dispose();
+
+ isDisposed = false;
+ }
+
+ private void seek(int position)
+ {
+ source.Stop();
+ source.Clear();
+ stream.Position = buffered = position;
+ }
+
+ private bool allocate(AudioBuffer buffer)
+ {
+ Span data = stackalloc byte[max_buffer_size];
+ int read = stream.Read(data);
+
+ if (read <= 0)
+ {
+ return false;
+ }
+
+ buffer.SetData(data[..read], stream.Format, stream.SampleRate);
+ buffered += read;
+
+ return true;
+ }
+ }
+
+ private static int getChannelCount(AudioFormat format)
+ {
+ return format is AudioFormat.Stereo8 or AudioFormat.Stereo16 ? 2 : 1;
+ }
+
+ private static int getSamplesCount(AudioFormat format)
+ {
+ return format is AudioFormat.Stereo8 or AudioFormat.Mono8 ? 8 : 16;
+ }
+
+ private static int getByteCountFromTime(TimeSpan time, AudioFormat format, int sampleRate)
+ {
+ return (int)time.TotalSeconds * sampleRate * getChannelCount(format) * (getSamplesCount(format) / 8);
+ }
+
+ private static TimeSpan getTimeFromByteCount(int count, AudioFormat format, int sampleRate)
+ {
+ return TimeSpan.FromSeconds(count / (sampleRate * getChannelCount(format) * (getSamplesCount(format) / 8)));
+ }
+}
diff --git a/source/Vignette/Audio/AudioStream.cs b/source/Vignette/Audio/AudioStream.cs
new file mode 100644
index 0000000..0063857
--- /dev/null
+++ b/source/Vignette/Audio/AudioStream.cs
@@ -0,0 +1,113 @@
+// Copyright (c) Cosyne
+// Licensed under GPL 3.0 with SDK Exception. See LICENSE for details.
+
+using System.IO;
+using Sekai.Audio;
+
+namespace Vignette.Audio;
+
+///
+/// A containing pulse code modulation (PCM) audio data.
+///
+public class AudioStream : Stream
+{
+ ///
+ /// The audio stream's sample rate
+ ///
+ public int SampleRate { get; }
+
+ ///
+ /// The audio stream's format.
+ ///
+ public AudioFormat Format { get; }
+
+ public override bool CanRead => stream.CanRead;
+
+ public override bool CanSeek => stream.CanSeek;
+
+ public override bool CanWrite => stream.CanWrite;
+
+ public override long Length => stream.Length;
+
+ public override long Position
+ {
+ get => stream.Position;
+ set => stream.Position = value;
+ }
+
+ private bool isDisposed;
+ private readonly MemoryStream stream;
+
+ public AudioStream(byte[] buffer, AudioFormat format, int sampleRate)
+ : this(buffer, true, format, sampleRate)
+ {
+ }
+
+ public AudioStream(byte[] buffer, bool writable, AudioFormat format, int sampleRate)
+ {
+ Format = format;
+ stream = new MemoryStream(buffer, writable);
+ SampleRate = sampleRate;
+ }
+
+ public AudioStream(byte[] buffer, int index, int count, AudioFormat format, int sampleRate)
+ : this(buffer, index, count, true, format, sampleRate)
+ {
+ }
+
+ public AudioStream(byte[] buffer, int index, int count, bool writable, AudioFormat format, int sampleRate)
+ {
+ Format = format;
+ stream = new MemoryStream(buffer, index, count, writable);
+ SampleRate = sampleRate;
+ }
+
+ public AudioStream(int capacity, AudioFormat format, int sampleRate)
+ {
+ Format = format;
+ stream = new MemoryStream(capacity);
+ SampleRate = sampleRate;
+ }
+
+ public AudioStream(AudioFormat format, int sampleRate)
+ : this(0, format, sampleRate)
+ {
+ }
+
+ public override void Flush()
+ {
+ stream.Flush();
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ return stream.Read(buffer, offset, count);
+ }
+
+ public override long Seek(long offset, SeekOrigin origin)
+ {
+ return stream.Seek(offset, origin);
+ }
+
+ public override void SetLength(long value)
+ {
+ stream.SetLength(value);
+ }
+
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ stream.Write(buffer, offset, count);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (isDisposed)
+ {
+ return;
+ }
+
+ stream.Dispose();
+
+ isDisposed = true;
+ }
+}
diff --git a/source/Vignette/Audio/IAudioController.cs b/source/Vignette/Audio/IAudioController.cs
new file mode 100644
index 0000000..420aee4
--- /dev/null
+++ b/source/Vignette/Audio/IAudioController.cs
@@ -0,0 +1,53 @@
+// Copyright (c) Cosyne
+// Licensed under GPL 3.0 with SDK Exception. See LICENSE for details.
+
+using System;
+using Sekai.Audio;
+
+namespace Vignette.Audio;
+
+///
+/// Provides access to audio playback controls.
+///
+public interface IAudioController
+{
+ ///
+ /// Gets or sets whether audio playback should loop.
+ ///
+ bool Loop { get; set; }
+
+ ///
+ /// Gets or seeks the current playback position.
+ ///
+ TimeSpan Position { get; set; }
+
+ ///
+ /// Gets total playable duration.
+ ///
+ TimeSpan Duration { get; }
+
+ ///
+ /// Gets the duration of the buffered data.
+ ///
+ TimeSpan Buffered { get; }
+
+ ///
+ /// Gets the state of this audio controller.
+ ///
+ AudioSourceState State { get; }
+
+ ///
+ /// Starts audio playback.
+ ///
+ void Play();
+
+ ///
+ /// Stops audio playback.
+ ///
+ void Stop();
+
+ ///
+ /// Pauses audio playback.
+ ///
+ void Pause();
+}
diff --git a/source/Vignette/Content/WaveAudioLoader.cs b/source/Vignette/Content/WaveAudioLoader.cs
new file mode 100644
index 0000000..abe462f
--- /dev/null
+++ b/source/Vignette/Content/WaveAudioLoader.cs
@@ -0,0 +1,54 @@
+// Copyright (c) Cosyne
+// Licensed under GPL 3.0 with SDK Exception. See LICENSE for details.
+
+using System;
+using System.Text;
+using Sekai.Audio;
+using Vignette.Audio;
+
+namespace Vignette.Content;
+
+internal sealed class WaveAudioLoader : IContentLoader
+{
+ public AudioStream Load(ReadOnlySpan bytes)
+ {
+ if (!MemoryExtensions.SequenceEqual(bytes[0..4], header_riff))
+ throw new ArgumentException(@"Failed to find ""RIFF"" header at position 0.", nameof(bytes));
+
+ if (!MemoryExtensions.SequenceEqual(bytes[8..12], header_wave))
+ throw new ArgumentException(@"Failed to find ""WAVE"" header at position 8.", nameof(bytes));
+
+ if (!MemoryExtensions.SequenceEqual(bytes[12..16], header_fmt))
+ throw new ArgumentException(@"Failed to find ""fmt "" header at position 12.", nameof(bytes));
+
+ if (!MemoryExtensions.SequenceEqual(bytes[36..40], header_data))
+ throw new ArgumentException(@"Failed to find ""data"" header at position 36.", nameof(bytes));
+
+ short contentType = BitConverter.ToInt16(bytes[20..22]);
+
+ if (contentType != 1)
+ {
+ throw new ArgumentException(@"Content is not PCM data.", nameof(bytes));
+ }
+
+ short numChannels = BitConverter.ToInt16(bytes[22..24]);
+ short bitsPerSamp = BitConverter.ToInt16(bytes[34..36]);
+
+ var format = numChannels == 2
+ ? bitsPerSamp == 8 ? AudioFormat.Stereo8 : AudioFormat.Stereo16
+ : bitsPerSamp == 8 ? AudioFormat.Mono8 : AudioFormat.Mono16;
+
+ int rate = BitConverter.ToInt32(bytes[24..28]);
+ int size = BitConverter.ToInt32(bytes[40..44]);
+
+ var stream = new AudioStream(size, format, rate);
+ stream.Write(bytes[44..size]);
+
+ return stream;
+ }
+
+ private static readonly byte[] header_riff = Encoding.ASCII.GetBytes("RIFF");
+ private static readonly byte[] header_wave = Encoding.ASCII.GetBytes("WAVE");
+ private static readonly byte[] header_data = Encoding.ASCII.GetBytes("data");
+ private static readonly byte[] header_fmt = Encoding.ASCII.GetBytes("fmt ");
+}
diff --git a/source/Vignette/VignetteGame.cs b/source/Vignette/VignetteGame.cs
index 8bb3f1b..13eef01 100644
--- a/source/Vignette/VignetteGame.cs
+++ b/source/Vignette/VignetteGame.cs
@@ -3,6 +3,7 @@
using System;
using Sekai;
+using Vignette.Audio;
using Vignette.Content;
using Vignette.Graphics;
@@ -13,11 +14,13 @@ public sealed class VignetteGame : Game
private Window root = null!;
private Camera camera = null!;
private Renderer renderer = null!;
+ private AudioManager audio = null!;
private ContentManager content = null!;
private ServiceLocator services = null!;
public override void Load()
{
+ audio = new(Audio);
content = new(Storage);
content.Add(new ShaderLoader(), ".hlsl");
content.Add(new TextureLoader(Graphics), ".png", ".jpg", ".jpeg", ".bmp", ".gif");
@@ -25,6 +28,7 @@ public override void Load()
renderer = new(Graphics);
services = new();
+ services.Add(audio);
services.Add(content);
root = new(services)
@@ -41,6 +45,7 @@ public override void Draw()
public override void Update(TimeSpan elapsed)
{
camera.ViewSize = Window.Size;
+ audio.Update();
root.Update(elapsed);
}