| 1 | /* |
| 2 | * LegacyClonk |
| 3 | * |
| 4 | * Copyright (c) 2017-2021, The LegacyClonk Team and contributors |
| 5 | * |
| 6 | * Distributed under the terms of the ISC license; see accompanying file |
| 7 | * "COPYING" for details. |
| 8 | * |
| 9 | * "Clonk" is a registered trademark of Matthes Bender, used with permission. |
| 10 | * See accompanying file "TRADEMARK" for details. |
| 11 | * |
| 12 | * To redistribute this file separately, substitute the full license texts |
| 13 | * for the above references. |
| 14 | */ |
| 15 | |
| 16 | #include "C4Application.h" |
| 17 | #include "C4AudioSystemSdl.h" |
| 18 | #include "C4Config.h" |
| 19 | #include "C4Log.h" |
| 20 | |
| 21 | #include "StdHelpers.h" |
| 22 | #include "StdSdlSubSystem.h" |
| 23 | |
| 24 | #include <algorithm> |
| 25 | #include <atomic> |
| 26 | #include <cmath> |
| 27 | #include <format> |
| 28 | #include <stdexcept> |
| 29 | #include <optional> |
| 30 | #include <span> |
| 31 | |
| 32 | #include <SDL_mixer.h> |
| 33 | |
| 34 | class C4AudioSystemSdl : public C4AudioSystem |
| 35 | { |
| 36 | public: |
| 37 | C4AudioSystemSdl(int maxChannels, bool preferLinearResampling); |
| 38 | ~C4AudioSystemSdl() noexcept override; |
| 39 | |
| 40 | void FadeOutMusic(std::int32_t ms) override; |
| 41 | bool IsMusicPlaying() const override; |
| 42 | void PlayMusic(const MusicFile *const music, bool loop) override; |
| 43 | void SetMusicVolume(float volume) override; |
| 44 | void StopMusic() override; |
| 45 | void UnpauseMusic() override; |
| 46 | |
| 47 | private: |
| 48 | static constexpr int Frequency = 44100; |
| 49 | static constexpr Uint16 Format = AUDIO_S16SYS; |
| 50 | static constexpr int NumChannels = 2; |
| 51 | static constexpr int BytesPerSecond = |
| 52 | Frequency * (SDL_AUDIO_BITSIZE(Format) / 8) * NumChannels; |
| 53 | static constexpr int InvalidChannel{-1}; |
| 54 | |
| 55 | // Smart pointers for SDL_mixer objects |
| 56 | using SDLMixChunkUniquePtr = C4DeleterFunctionUniquePtr<Mix_FreeChunk>; |
| 57 | using SDLMixMusicUniquePtr = C4DeleterFunctionUniquePtr<Mix_FreeMusic>; |
| 58 | |
| 59 | std::optional<StdSdlSubSystem> system; |
| 60 | |
| 61 | static void ThrowIfFailed(const char *funcName, bool failed, std::string_view errorMessage = {}); |
| 62 | |
| 63 | template <typename T> |
| 64 | using SampleLoadFunc = T *(*)(SDL_RWops *, int); |
| 65 | |
| 66 | template <typename T> |
| 67 | static T *LoadSampleCheckMpegLayer3Header(SampleLoadFunc<T> loadFunc, const char *funcName, const void *buf, const std::size_t size); |
| 68 | |
| 69 | public: |
| 70 | |
| 71 | class MusicFileSdl : public MusicFile |
| 72 | { |
| 73 | public: |
| 74 | MusicFileSdl(const void *buf, std::size_t size); |
| 75 | |
| 76 | private: |
| 77 | SDLMixMusicUniquePtr sample; |
| 78 | |
| 79 | friend class C4AudioSystemSdl; |
| 80 | }; |
| 81 | |
| 82 | MusicFile *CreateMusicFile(const void *buf, std::size_t size) override { return new MusicFileSdl{buf, size}; } |
| 83 | |
| 84 | class SoundFileSdl; |
| 85 | |
| 86 | class SoundChannelSdl : public SoundChannel |
| 87 | { |
| 88 | public: |
| 89 | // Plays sound file. If supported by audio library, playback starts paused. |
| 90 | SoundChannelSdl(const SoundFileSdl *const sound, bool loop); |
| 91 | ~SoundChannelSdl() override; |
| 92 | |
| 93 | public: |
| 94 | bool IsPlaying() const override; |
| 95 | void SetPosition(std::uint32_t ms) override; |
| 96 | void SetVolumeAndPan(float volume, float pan) override; |
| 97 | void Unpause() override; |
| 98 | int GetChannelId() const { return channel.load(m: std::memory_order_acquire); } |
| 99 | void ClearChannelId() { channel.store(i: InvalidChannel, m: std::memory_order_release); } |
| 100 | |
| 101 | private: |
| 102 | std::atomic_int channel; |
| 103 | }; |
| 104 | |
| 105 | SoundChannel *CreateSoundChannel(const SoundFile *const sound, bool loop) override |
| 106 | { |
| 107 | const auto channel = new SoundChannelSdl{static_cast<const SoundFileSdl *>(sound), loop}; |
| 108 | playingChannels[channel->GetChannelId()] = channel; |
| 109 | return channel; |
| 110 | } |
| 111 | |
| 112 | class SoundFileSdl : public SoundFile |
| 113 | { |
| 114 | public: |
| 115 | SoundFileSdl(const void *buf, std::size_t size); |
| 116 | |
| 117 | public: |
| 118 | std::uint32_t GetDuration() const override; |
| 119 | |
| 120 | private: |
| 121 | const SDLMixChunkUniquePtr sample; |
| 122 | |
| 123 | friend class C4AudioSystemSdl; |
| 124 | }; |
| 125 | |
| 126 | virtual SoundFile *CreateSoundFile(const void *buf, std::size_t size) override { return new SoundFileSdl{buf, size}; } |
| 127 | |
| 128 | private: |
| 129 | std::vector<SoundChannelSdl *> playingChannels; |
| 130 | |
| 131 | static inline C4AudioSystemSdl *instance{nullptr}; |
| 132 | static void ChannelFinished(int channel); |
| 133 | }; |
| 134 | |
| 135 | // this is used instead of MIX_MAX_VOLUME, because MIX_MAX_VOLUME is very loud and easily leads to clipping |
| 136 | // the lower volume gives more headroom until clipping occurs |
| 137 | // the selected volume is chosen to be similar to FMod's original volume |
| 138 | static constexpr auto MaximumMusicVolume = 80; |
| 139 | |
| 140 | // higher than MaximumMusicVolume to compensate for lower maximum panning volume |
| 141 | static constexpr auto MaximumSoundVolume = 100; |
| 142 | |
| 143 | C4AudioSystemSdl::C4AudioSystemSdl(const int maxChannels, const bool preferLinearResampling) |
| 144 | { |
| 145 | assert(!instance); |
| 146 | instance = this; |
| 147 | |
| 148 | auto logger = Application.LogSystem.CreateLoggerWithDifferentName(config&: Config.Logging.AudioSystem, name: "C4AudioSystem" ); |
| 149 | |
| 150 | // Check SDL_mixer version |
| 151 | SDL_version compile_version; |
| 152 | MIX_VERSION(&compile_version); |
| 153 | const auto link_version = Mix_Linked_Version(); |
| 154 | logger->info(fmt: "SDL_mixer runtime version is {}.{}.{} (compiled with {}.{}.{})" , |
| 155 | args: link_version->major, args: link_version->minor, args: link_version->patch, |
| 156 | args&: compile_version.major, args&: compile_version.minor, args&: compile_version.patch); |
| 157 | |
| 158 | // Try to enable linear resampling if requested |
| 159 | if (preferLinearResampling) |
| 160 | { |
| 161 | if (!SDL_SetHint(SDL_HINT_AUDIO_RESAMPLING_MODE, value: "linear" )) |
| 162 | logger->error(msg: "SDL_SetHint(SDL_HINT_AUDIO_RESAMPLING_MODE, \"linear\") failed" ); |
| 163 | } |
| 164 | |
| 165 | // Initialize SDL_mixer |
| 166 | StdSdlSubSystem system{SDL_INIT_AUDIO}; |
| 167 | ThrowIfFailed(funcName: "Mix_OpenAudioDevice" , |
| 168 | failed: Mix_OpenAudioDevice(frequency: Frequency, format: Format, channels: NumChannels, chunksize: 1024, device: nullptr, SDL_AUDIO_ALLOW_ANY_CHANGE & ~SDL_AUDIO_ALLOW_CHANNELS_CHANGE) != 0); |
| 169 | |
| 170 | int frequency; |
| 171 | Uint16 format; |
| 172 | int channels; |
| 173 | Mix_QuerySpec(frequency: &frequency, format: &format, channels: &channels); |
| 174 | |
| 175 | logger->debug(fmt: "SDL_mixer device spec: frequency = {} Hz, format = {}, channels = {}" , args&: frequency, args&: format, args&: channels); |
| 176 | |
| 177 | Mix_AllocateChannels(numchans: maxChannels); |
| 178 | Mix_ChannelFinished(channel_finished: ChannelFinished); |
| 179 | this->system.emplace(args: std::move(system)); |
| 180 | playingChannels.resize(new_size: maxChannels); |
| 181 | } |
| 182 | |
| 183 | C4AudioSystemSdl::~C4AudioSystemSdl() noexcept |
| 184 | { |
| 185 | Mix_CloseAudio(); |
| 186 | Mix_Quit(); |
| 187 | } |
| 188 | |
| 189 | void C4AudioSystemSdl::ThrowIfFailed(const char *const funcName, const bool failed, std::string_view errorMessage) |
| 190 | { |
| 191 | if (failed) |
| 192 | { |
| 193 | if (errorMessage.empty()) |
| 194 | { |
| 195 | errorMessage = Mix_GetError(); |
| 196 | } |
| 197 | |
| 198 | throw std::runtime_error{std::format(fmt: "SDL_mixer: {} failed: {}" , args: funcName, args&: errorMessage)}; |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | void C4AudioSystemSdl::FadeOutMusic(const std::int32_t ms) |
| 203 | { |
| 204 | ThrowIfFailed(funcName: "Mix_FadeOutMusic" , failed: Mix_FadeOutMusic(ms) != 1); |
| 205 | } |
| 206 | |
| 207 | bool C4AudioSystemSdl::IsMusicPlaying() const |
| 208 | { |
| 209 | return Mix_PlayingMusic() == 1; |
| 210 | } |
| 211 | |
| 212 | void C4AudioSystemSdl::PlayMusic(const C4AudioSystem::MusicFile *const music, const bool loop) |
| 213 | { |
| 214 | ThrowIfFailed(funcName: "Mix_PlayMusic" , failed: Mix_PlayMusic(music: static_cast<const MusicFileSdl *const>(music)->sample.get(), loops: (loop ? -1 : 1)) == -1); |
| 215 | } |
| 216 | |
| 217 | void C4AudioSystemSdl::SetMusicVolume(const float volume) |
| 218 | { |
| 219 | Mix_VolumeMusic(volume: std::lrint(x: volume * MaximumMusicVolume)); |
| 220 | } |
| 221 | |
| 222 | void C4AudioSystemSdl::StopMusic() |
| 223 | { |
| 224 | Mix_HaltMusic(); |
| 225 | } |
| 226 | |
| 227 | void C4AudioSystemSdl::UnpauseMusic() { /* Not supported */ } |
| 228 | |
| 229 | template <typename T> |
| 230 | T *C4AudioSystemSdl::(const SampleLoadFunc<T> loadFunc, const char *const funcName, const void *const buf, const std::size_t size) |
| 231 | { |
| 232 | const auto direct = loadFunc(SDL_RWFromConstMem(mem: buf, size), SDL_TRUE); |
| 233 | if (direct) |
| 234 | { |
| 235 | return direct; |
| 236 | } |
| 237 | const std::string error{Mix_GetError()}; |
| 238 | |
| 239 | // According to http://www.idea2ic.com/File_Formats/MPEG%20Audio%20Frame%20Header.pdf |
| 240 | // Maximum possible frame size = 144 * max bit rate / min sample rate + padding |
| 241 | // chosen values are limited to layer 3 |
| 242 | static constexpr std::size_t MaxFrameSize{144 * 320'000 / 8'000 + 1}; |
| 243 | |
| 244 | const std::span data{reinterpret_cast<const std::byte *>(buf), size}; |
| 245 | const std::size_t limit{std::min(a: data.size(), b: MaxFrameSize)}; |
| 246 | |
| 247 | for (std::size_t i{0}; i < limit - 4; ++i) |
| 248 | { |
| 249 | // first 8 of 11 frame sync bits |
| 250 | if (data[i] != std::byte{0xFF}) continue; |
| 251 | |
| 252 | const auto byte2 = data[i + 1]; |
| 253 | // rest of the sync bits + check for Layer 3 (SDL_mixer only accepts layer 3) |
| 254 | if ((byte2 & std::byte{0xE6}) != std::byte{0xE2}) continue; |
| 255 | // MPEG version bit value 01 is reserved |
| 256 | if ((byte2 & std::byte{0x18}) == std::byte{0x08}) continue; |
| 257 | |
| 258 | const auto byte3 = data[i + 2]; |
| 259 | // bitrate index 1111 is invalid |
| 260 | if ((byte3 & std::byte{0xF0}) == std::byte{0xF0}) continue; |
| 261 | // sampling rate index 11 is reserved |
| 262 | if ((byte3 & std::byte{0x0C}) == std::byte{0x0C}) continue; |
| 263 | |
| 264 | const auto byte4 = data[i + 3]; |
| 265 | // emphasis bit value 10 is reserved |
| 266 | if ((byte4 & std::byte{0x03}) == std::byte{0x02}) continue; |
| 267 | |
| 268 | // at this point there seems to be a valid MPEG frame header |
| 269 | const auto sample = loadFunc(SDL_RWFromConstMem(mem: data.data() + i, size: size - i), SDL_TRUE); |
| 270 | if (sample) |
| 271 | { |
| 272 | return sample; |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | ThrowIfFailed(funcName, failed: true, errorMessage: error); |
| 277 | return nullptr; |
| 278 | } |
| 279 | |
| 280 | C4AudioSystemSdl::MusicFileSdl::MusicFileSdl(const void *const buf, const std::size_t size) |
| 281 | : sample{LoadSampleCheckMpegLayer3Header(loadFunc: Mix_LoadMUS_RW, funcName: "Mix_LoadMUS_RW" , buf, size)} |
| 282 | {} |
| 283 | |
| 284 | C4AudioSystemSdl::SoundFileSdl::SoundFileSdl(const void *const buf, const std::size_t size) |
| 285 | : sample{LoadSampleCheckMpegLayer3Header(loadFunc: Mix_LoadWAV_RW, funcName: "Mix_LoadWAV_RW" , buf, size)} |
| 286 | {} |
| 287 | |
| 288 | std::uint32_t C4AudioSystemSdl::SoundFileSdl::GetDuration() const |
| 289 | { |
| 290 | return 1000 * sample->alen / BytesPerSecond; |
| 291 | } |
| 292 | |
| 293 | C4AudioSystemSdl::SoundChannelSdl::SoundChannelSdl(const SoundFileSdl *const sound, const bool loop) |
| 294 | : channel{Mix_PlayChannel(channel: -1, chunk: sound->sample.get(), loops: (loop ? -1 : 0))} |
| 295 | { |
| 296 | ThrowIfFailed(funcName: "Mix_PlayChannel" , failed: GetChannelId() == InvalidChannel); |
| 297 | } |
| 298 | |
| 299 | C4AudioSystemSdl::SoundChannelSdl::~SoundChannelSdl() |
| 300 | { |
| 301 | if (const auto ch = channel.load(m: std::memory_order::acquire); ch != InvalidChannel) |
| 302 | { |
| 303 | Mix_HaltChannel(channel: ch); |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | bool C4AudioSystemSdl::SoundChannelSdl::IsPlaying() const |
| 308 | { |
| 309 | const auto ch = channel.load(m: std::memory_order::acquire); |
| 310 | return ch != InvalidChannel && Mix_Playing(channel: ch) == 1; |
| 311 | } |
| 312 | |
| 313 | void C4AudioSystemSdl::SoundChannelSdl::SetPosition(const std::uint32_t ms) |
| 314 | { |
| 315 | // Not supported |
| 316 | } |
| 317 | |
| 318 | void C4AudioSystemSdl::SoundChannelSdl::SetVolumeAndPan(const float volume, const float pan) |
| 319 | { |
| 320 | const auto ch = channel.load(m: std::memory_order::acquire); |
| 321 | if (ch == InvalidChannel) return; |
| 322 | |
| 323 | Mix_Volume(channel: ch, volume: std::lrint(x: volume * MaximumSoundVolume)); |
| 324 | const Uint8 |
| 325 | left = static_cast<Uint8>(std::clamp(val: std::lrint(x: (1.0f - pan) * 192.0f), lo: 0L, hi: 192L)), |
| 326 | right = static_cast<Uint8>(std::clamp(val: std::lrint(x: (1.0f + pan) * 192.0f), lo: 0L, hi: 192L)); |
| 327 | ThrowIfFailed(funcName: "Mix_SetPanning" , failed: Mix_SetPanning(channel: ch, left, right) == 0); |
| 328 | } |
| 329 | |
| 330 | void C4AudioSystemSdl::SoundChannelSdl::Unpause() { /* Not supported */ } |
| 331 | |
| 332 | void C4AudioSystemSdl::ChannelFinished(int channel) |
| 333 | { |
| 334 | auto &soundInstance = instance->playingChannels[channel]; |
| 335 | soundInstance->ClearChannelId(); |
| 336 | soundInstance = nullptr; |
| 337 | } |
| 338 | |
| 339 | C4AudioSystem *CreateC4AudioSystemSdl(int maxChannels, const bool preferLinearResampling) |
| 340 | { |
| 341 | return new C4AudioSystemSdl{maxChannels, preferLinearResampling}; |
| 342 | } |
| 343 | |