diff --git a/src/rasdaq/Application.cs b/src/rasdaq/Application.cs index 59a4c51..98705c8 100644 --- a/src/rasdaq/Application.cs +++ b/src/rasdaq/Application.cs @@ -1,6 +1,8 @@ using OpenTK.Graphics.OpenGL4; using OpenTK.Windowing.Common; using OpenTK.Windowing.Desktop; + +using rasdaq.Audio; using rasdaq.Core.ECS; using rasdaq.Graphics; using rasdaq.Inputs; @@ -22,6 +24,8 @@ public sealed class Application : IDisposable public static Application? Instance { get; private set; } internal InputManager InputManager { get; set; } + public AudioManager AudioManager { get; private set; } + private List _worlds = new(); private GameWindow _gameWindow; @@ -57,6 +61,7 @@ public Application(int width, int height, string title) _gameWindow.FramebufferResize += OnFramebufferResize; InputManager = new InputManager(new GameWindowWrapper(_gameWindow)); + AudioManager = new AudioManager(); } internal void RegisterWorld(World world) diff --git a/src/rasdaq/Audio/Audio.cs b/src/rasdaq/Audio/Audio.cs new file mode 100644 index 0000000..d912484 --- /dev/null +++ b/src/rasdaq/Audio/Audio.cs @@ -0,0 +1,29 @@ +using OpenTK.Audio.OpenAL; + +using rasdaq.Logging; + +namespace rasdaq.Audio; + +public class Audio : IDisposable +{ + public int Handle; + + public Audio() + { + Handle = AL.GenBuffer(); + // Check for errors + ALError error = AL.GetError(); + if (error != ALError.NoError) + { + string err = $"OpenAL error while trying to create audio buffer: {error}"; + + Log.Error(err); + throw new Exception(err); + } + } + + public void Dispose() + { + AL.DeleteBuffer(Handle); + } +} \ No newline at end of file diff --git a/src/rasdaq/Audio/AudioManager.cs b/src/rasdaq/Audio/AudioManager.cs new file mode 100644 index 0000000..aff771d --- /dev/null +++ b/src/rasdaq/Audio/AudioManager.cs @@ -0,0 +1,159 @@ +using OpenTK.Audio.OpenAL; + +using rasdaq.Logging; + +namespace rasdaq.Audio; + +public class AudioManager +{ + private static void CheckALCError(ALDevice device) + { + AlcError error = ALC.GetError(device); + if (error != AlcError.NoError) + { + string err = $"OpenAL error: {error}"; + Log.Error(err); + throw new Exception(err); + } + } + + private static void CheckALError() + { + ALError error = AL.GetError(); + if (error != ALError.NoError) + { + string err = $"OpenAL error: {error}"; + Log.Error(err); + throw new Exception(err); + } + } + + /// + /// Initialize Audio for the Application, using the default audio device. + /// + /// + public AudioManager() + { + // Open the default audio device. + ALDevice device = ALC.OpenDevice(null); + if (device == ALDevice.Null) + { + string err = "Failed to open the default audio device."; + Log.Error(err); + throw new Exception(err); + } + + // Create a context for the device. + ALContext context = ALC.CreateContext(device, new ALContextAttributes(null, null, null, null, null)); + if (context == ALContext.Null) + { + ALC.CloseDevice(device); + string err = "Failed to create an OpenAL context."; + Log.Error(err); + throw new Exception(err); + } + + // Check if we have any errors. + AlcError error = ALC.GetError(device); + CheckALCError(device); + // If no errors, make the context current. + ALC.MakeContextCurrent(context); + } + + /// + /// Load a WAV file that contains PCM data only. + /// + /// Path to WAV file + /// A integer handle referring to the loaded audio + /// + public Audio LoadAudio(string path) + { + // Create an OpenAL buffer for the data to go in. + Audio audio = new Audio(); + + // Load the WAV file + WAVLoader.WAVData wavData = WAVLoader.LoadWav(path); + + // Discern the format of the audio data based on number of channels and bits per sample. + ALFormat format; + if (wavData.Format.NumChannels == 1) + { + if (wavData.Format.BitsPerSample == 8) + { + format = ALFormat.Mono8; + } + else if (wavData.Format.BitsPerSample == 16) + { + format = ALFormat.Mono16; + } + else + { + string err = $"Unsupported bits per sample: {wavData.Format.BitsPerSample}"; + Log.Error(err); + throw new Exception(err); + } + } + else if (wavData.Format.NumChannels == 2) + { + if (wavData.Format.BitsPerSample == 8) + { + format = ALFormat.Stereo8; + } + else if (wavData.Format.BitsPerSample == 16) + { + format = ALFormat.Stereo16; + } + else + { + string err = $"Unsupported bits per sample: {wavData.Format.BitsPerSample}"; + Log.Error(err); + throw new Exception(err); + } + } + else + { + string err = $"Unsupported number of channels: {wavData.Format.NumChannels}"; + Log.Error(err); + throw new Exception(err); + } + + // Load the audio data into the OpenAL buffer. + AL.BufferData(audio.Handle, format, wavData.Data.Data, (int)wavData.Format.SampleRate); + + // Check if we have any errors from trying to load the audio data. + CheckALError(); + + return audio; + } + + /// + /// Add a loaded audio file to an audio source, so that it can be played. + /// + /// Audio file + /// Audio source + public void AttachAudioToSource(Audio audio, AudioSource audioSource) + { + AL.Source(audioSource.Handle, ALSourcei.Buffer, audio.Handle); + CheckALError(); + } + + /// + /// Play back an audio source that has audio attached to it. + /// + /// + public void PlaySource(AudioSource audioSource) + { + AL.SourcePlay(audioSource.Handle); + CheckALError(); + } + + /// + /// Stop playback of an audio source. + /// + /// + public void StopSource(AudioSource audioSource) + { + AL.SourceStop(audioSource.Handle); + CheckALError(); + } +} \ No newline at end of file diff --git a/src/rasdaq/Audio/AudioSource.cs b/src/rasdaq/Audio/AudioSource.cs new file mode 100644 index 0000000..b94f547 --- /dev/null +++ b/src/rasdaq/Audio/AudioSource.cs @@ -0,0 +1,29 @@ +using OpenTK.Audio.OpenAL; + +using rasdaq.Logging; + +namespace rasdaq.Audio; + +public class AudioSource : IDisposable +{ + public int Handle; + + public AudioSource() + { + Handle = AL.GenSource(); + // Check for errors + ALError error = AL.GetError(); + if (error != ALError.NoError) + { + string err = $"OpenAL error while trying to create audio source: {error}"; + + Log.Error(err); + throw new Exception("OpenAL error while trying to create audio source: " + error); + } + } + + public void Dispose() + { + AL.DeleteSource(Handle); + } +} \ No newline at end of file diff --git a/src/rasdaq/Audio/WAVLoader.cs b/src/rasdaq/Audio/WAVLoader.cs new file mode 100644 index 0000000..508643e --- /dev/null +++ b/src/rasdaq/Audio/WAVLoader.cs @@ -0,0 +1,167 @@ +using System.Buffers.Binary; +using System.Text; + +namespace rasdaq.Audio; + +internal class WAVLoader +{ + /// + /// Format information about the WAV file. + /// + public struct FmtChunk + { + public ushort AudioFormat; + public ushort NumChannels; + public uint SampleRate; + public uint ByteRate; + public ushort BlockAlign; + public ushort BitsPerSample; + public FmtChunkExtension? Extension; + }; + /// + /// Extra format information for certain WAV files. + /// + public struct FmtChunkExtension + { + public ushort ValidBitsPerSample; + public uint SpeakerPositionMask; + public Guid SubFormat; + } + + /// + /// Holds the data of the WAV file, should be PCM data, as that is all is supported currently. + /// + public struct DataChunk + { + public uint Size; + public byte[] Data; + } + + /// + /// Holds all the information necessary about a WAV file, namely, + /// the format information and the data itself. + /// + public struct WAVData + { + public FmtChunk Format; + public DataChunk Data; + } + + private static byte[] ReadBytesFromStream(FileStream stream, int count) + { + byte[] buffer = new byte[count]; + stream.ReadExactly(buffer, 0, count); + return buffer; + } + + private static FmtChunk ProcessFmtChunck(FileStream stream) + { + uint size = BinaryPrimitives.ReadUInt32LittleEndian(ReadBytesFromStream(stream, 4)); + + FmtChunk chunk = new FmtChunk(); + chunk.AudioFormat = BinaryPrimitives.ReadUInt16LittleEndian(ReadBytesFromStream(stream, 2)); + chunk.NumChannels = BinaryPrimitives.ReadUInt16LittleEndian(ReadBytesFromStream(stream, 2)); + chunk.SampleRate = BinaryPrimitives.ReadUInt32LittleEndian(ReadBytesFromStream(stream, 4)); + chunk.ByteRate = BinaryPrimitives.ReadUInt32LittleEndian(ReadBytesFromStream(stream, 4)); + chunk.BlockAlign = BinaryPrimitives.ReadUInt16LittleEndian(ReadBytesFromStream(stream, 2)); + chunk.BitsPerSample = BinaryPrimitives.ReadUInt16LittleEndian(ReadBytesFromStream(stream, 2)); + + if (size > 16) + { + ushort extensionSize = BinaryPrimitives.ReadUInt16LittleEndian(ReadBytesFromStream(stream, 2)); + if (extensionSize == 22) + { + chunk.Extension = new FmtChunkExtension() + { + ValidBitsPerSample = BinaryPrimitives.ReadUInt16LittleEndian(ReadBytesFromStream(stream, 2)), + SpeakerPositionMask = BinaryPrimitives.ReadUInt32LittleEndian(ReadBytesFromStream(stream, 4)), + SubFormat = new Guid(ReadBytesFromStream(stream, 16)) + }; + } + } + + return chunk; + } + + private static DataChunk ProcessDataChunk(FileStream stream) + { + DataChunk chunk = new DataChunk(); + chunk.Size = BinaryPrimitives.ReadUInt32LittleEndian(ReadBytesFromStream(stream, 4)); + chunk.Data = ReadBytesFromStream(stream, (int)chunk.Size); + return chunk; + } + + /// + /// Load the WAV file at the given path and return a WAVData struct containing the format information and the data itself. + /// + /// The path to the WAV file. + /// A WAVData struct containing the format information and the data. + /// Thrown if the WAV file is not found or is invalid. + public static WAVData LoadWav(string path) + { + if (!File.Exists(path)) + { + throw new Exception("WAV file not found: " + path); + } + + using (FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read)) + { + // Read the RIFF header at the start of the WAV file. + byte[] fourcc = new byte[4]; + stream.ReadExactly(fourcc, 0, 4); + if (Encoding.ASCII.GetString(fourcc) != "RIFF") + { + throw new Exception("Invalid WAV file: missing RIFF header."); + } + + // Read the file size + byte[] byteFileSize = new byte[4]; + stream.ReadExactly(byteFileSize, 0, 4); + int fileSize = BinaryPrimitives.ReadInt32LittleEndian(byteFileSize) - 4; + + // Check file type is WAVE + byte[] byteFileType = new byte[4]; + stream.ReadExactly(byteFileType, 0, 4); + Encoding.UTF8.GetString(byteFileType); + if (Encoding.ASCII.GetString(byteFileType) != "WAVE") + { + throw new Exception("Invalid WAV file: file type not listed as WAVE in RIFF header."); + } + + FmtChunk fmtChunk = new FmtChunk(); + DataChunk dataChunk = new DataChunk(); + + // Now read the remaining chunks, we want a fmt chunk and a data chunk. + while (stream.Position < stream.Length - 4) + { + byte[] chunkId = new byte[4]; + stream.ReadExactly(chunkId, 0, 4); + + if (chunkId.SequenceEqual(Encoding.ASCII.GetBytes("fmt "))) + { + fmtChunk = ProcessFmtChunck(stream); + + if (fmtChunk.AudioFormat != 1) + { + throw new Exception("Unsupported WAV file: only PCM format is supported."); + } + } + else if (chunkId.SequenceEqual(Encoding.ASCII.GetBytes("data"))) + { + dataChunk = ProcessDataChunk(stream); + break; + } + else + { + throw new Exception("Unsupported WAV file: unknown chunk type " + Encoding.ASCII.GetString(chunkId)); + } + } + + return new WAVData() + { + Format = fmtChunk, + Data = dataChunk + }; + } + } +} \ No newline at end of file