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); }