/* * OpenClonk, http://www.openclonk.org * * Copyright (c) 1998-2000, Matthes Bender * Copyright (c) 2001-2009, RedWolf Design GmbH, http://www.clonk.de/ * Copyright (c) 2009-2016, The OpenClonk Team and contributors * * Distributed under the terms of the ISC license; see accompanying file * "COPYING" for details. * * "Clonk" is a registered trademark of Matthes Bender, used with permission. * See accompanying file "TRADEMARK" for details. * * To redistribute this file separately, substitute the full license texts * for the above references. */ /* Handles Music.ocg and randomly plays songs */ #include "C4Include.h" #include "platform/C4MusicSystem.h" #include "game/C4Application.h" #include "game/C4GraphicsSystem.h" #include "lib/C4Random.h" #include "platform/C4MusicFile.h" #include "platform/C4Window.h" C4MusicSystem::C4MusicSystem(): playlist(), music_break_min(DefaultMusicBreak), music_break_max(DefaultMusicBreak), wait_time_end() { } C4MusicSystem::~C4MusicSystem() { Clear(); } #if AUDIO_TK == AUDIO_TK_OPENAL void C4MusicSystem::SelectContext() { alcMakeContextCurrent(alcContext); } #endif bool C4MusicSystem::InitializeMOD() { #if AUDIO_TK == AUDIO_TK_SDL_MIXER SDL_version compile_version; const SDL_version * link_version; MIX_VERSION(&compile_version); link_version=Mix_Linked_Version(); LogF("SDL_mixer runtime version is %d.%d.%d (compiled with %d.%d.%d)", link_version->major, link_version->minor, link_version->patch, compile_version.major, compile_version.minor, compile_version.patch); if (SDL_InitSubSystem(SDL_INIT_AUDIO) != 0) { LogF("SDL_InitSubSystem(SDL_INIT_AUDIO): %s", SDL_GetError()); return false; } //frequency, format, stereo, chunksize if (Mix_OpenAudio(44100, AUDIO_S16SYS, 2, 1024)) { LogF("SDL_mixer: %s", SDL_GetError()); return false; } MODInitialized = true; return true; #elif AUDIO_TK == AUDIO_TK_OPENAL alcDevice = alcOpenDevice(nullptr); if (!alcDevice) { LogF("Sound system: OpenAL create context error"); return false; } alcContext = alcCreateContext(alcDevice, nullptr); if (!alcContext) { LogF("Sound system: OpenAL create context error"); return false; } #ifndef __APPLE__ if (!alutInitWithoutContext(nullptr, nullptr)) { LogF("Sound system: ALUT init error"); return false; } #endif MODInitialized = true; return true; #endif return false; } void C4MusicSystem::DeinitializeMOD() { #if AUDIO_TK == AUDIO_TK_SDL_MIXER Mix_CloseAudio(); SDL_QuitSubSystem(SDL_INIT_AUDIO); #elif AUDIO_TK == AUDIO_TK_OPENAL #ifndef __APPLE__ alutExit(); #endif alcDestroyContext(alcContext); alcCloseDevice(alcDevice); alcContext = nullptr; alcDevice = nullptr; #endif MODInitialized = false; } bool C4MusicSystem::Init(const char * PlayList) { // init mod if (!MODInitialized && !InitializeMOD()) return false; // Might be reinitialisation ClearSongs(); // Global music file LoadDir(Config.AtSystemDataPath(C4CFN_Music)); // User music file LoadDir(Config.AtUserDataPath(C4CFN_Music)); // read MoreMusic.txt LoadMoreMusic(); // set play list SCounter = 0; if (PlayList) SetPlayList(PlayList); else SetPlayList(nullptr); // set initial volume UpdateVolume(); // ok return true; } bool C4MusicSystem::InitForScenario(C4Group & hGroup) { // check if the scenario contains music bool fLocalMusic = false; StdStrBuf MusicDir; if (GrpContainsMusic(hGroup)) { // clear global songs ClearSongs(); fLocalMusic = true; // add songs MusicDir.Take(Game.ScenarioFile.GetFullName()); LoadDir(MusicDir.getData()); // log LogF(LoadResStr("IDS_PRC_LOCALMUSIC"), MusicDir.getData()); } // check for music folders in group set C4Group *pMusicFolder = nullptr; while ((pMusicFolder = Game.GroupSet.FindGroup(C4GSCnt_Music, pMusicFolder))) { if (!fLocalMusic) { // clear global songs ClearSongs(); fLocalMusic = true; } // add songs MusicDir.Take(pMusicFolder->GetFullName()); MusicDir.AppendChar(DirectorySeparator); MusicDir.Append(C4CFN_Music); LoadDir(MusicDir.getData()); // log LogF(LoadResStr("IDS_PRC_LOCALMUSIC"), MusicDir.getData()); } // no music? if (!SongCount) return false; // set play list SetPlayList(nullptr); // ok return true; } void C4MusicSystem::Load(const char *szFile) { // safety if (!szFile || !*szFile) return; C4MusicFile *NewSong=nullptr; #if AUDIO_TK == AUDIO_TK_OPENAL // openal: Only ogg supported const char *szExt = GetExtension(szFile); if (SEqualNoCase(szExt, "ogg")) NewSong = new C4MusicFileOgg; #elif AUDIO_TK == AUDIO_TK_SDL_MIXER if (GetMusicFileTypeByExtension(GetExtension(szFile)) == MUSICTYPE_UNKNOWN) return; NewSong = new C4MusicFileSDL; #endif // unrecognized type/mod not initialized? if (!NewSong) return; // init music file NewSong->Init(szFile); // add song to list (push back) C4MusicFile *pCurr = Songs; while (pCurr && pCurr->pNext) pCurr = pCurr->pNext; if (pCurr) pCurr->pNext = NewSong; else Songs = NewSong; NewSong->pNext = nullptr; // count songs SongCount++; playlist_valid = false; } void C4MusicSystem::LoadDir(const char *szPath) { char Path[_MAX_FNAME + 1], File[_MAX_FNAME + 1]; C4Group *pDirGroup = nullptr; // split path SCopy(szPath, Path, _MAX_FNAME); char *pFileName = GetFilename(Path); SCopy(pFileName, File); *(pFileName - 1) = 0; // no file name? if (!File[0]) // -> add the whole directory SCopy("*", File); // no wildcard match? else if (!SSearch(File, "*?")) { // then it's either a file or a directory - do the test with C4Group pDirGroup = new C4Group(); if (!pDirGroup->Open(szPath)) { // so it must be a file if (!pDirGroup->Open(Path)) { // -> file/dir doesn't exist LogF("Music File not found: %s", szPath); delete pDirGroup; return; } // mother group is open... proceed with normal handling } else { // ok, set wildcard (load the whole directory) SCopy(szPath, Path); SCopy("*", File); } } // open directory group, if not already done so if (!pDirGroup) { pDirGroup = new C4Group(); if (!pDirGroup->Open(Path)) { LogF("Music File not found: %s", szPath); delete pDirGroup; return; } } // search file(s) char szFile[_MAX_FNAME + 1]; pDirGroup->ResetSearch(); while (pDirGroup->FindNextEntry(File, szFile)) { char strFullPath[_MAX_FNAME + 1]; sprintf(strFullPath, "%s%c%s", Path, DirectorySeparator, szFile); Load(strFullPath); } // free it delete pDirGroup; } void C4MusicSystem::LoadMoreMusic() { StdStrBuf MoreMusicFile; // load MoreMusic.txt if (!MoreMusicFile.LoadFromFile(Config.AtUserDataPath(C4CFN_MoreMusic))) return; // read contents char *pPos = MoreMusicFile.getMData(); while (pPos && *pPos) { // get line char szLine[1024 + 1]; SCopyUntil(pPos, szLine, '\n', 1024); pPos = strchr(pPos, '\n'); if (pPos) pPos++; // remove leading whitespace char *pLine = szLine; while (*pLine == ' ' || *pLine == '\t' || *pLine == '\r') pLine++; // and whitespace at end char *p = pLine + strlen(pLine) - 1; while (*p == ' ' || *p == '\t' || *p == '\r') { *p = 0; --p; } // comment? if (*pLine == '#') { // might be a "directive" if (SEqual(pLine, "#clear")) ClearSongs(); continue; } // try to load file(s) LoadDir(pLine); } } void C4MusicSystem::ClearSongs() { Stop(); while (Songs) { C4MusicFile *pFile = Songs; Songs = pFile->pNext; delete pFile; } SongCount = 0; FadeMusicFile = upcoming_music_file = PlayMusicFile = nullptr; playlist_valid = false; } void C4MusicSystem::Clear() { #if AUDIO_TK == AUDIO_TK_SDL_MIXER // Stop a fadeout Mix_HaltMusic(); #endif ClearSongs(); if (MODInitialized) { DeinitializeMOD(); } } void C4MusicSystem::ClearGame() { game_music_level = 100; music_break_min = music_break_max = DefaultMusicBreak; music_break_chance = DefaultMusicBreakChance; music_max_position_memory = DefaultMusicMaxPositionMemory; SetPlayList(nullptr); is_waiting = false; upcoming_music_file = nullptr; } void C4MusicSystem::Execute(bool force_song_execution) { // Execute music fading if (FadeMusicFile) { C4TimeMilliseconds tNow = C4TimeMilliseconds::Now(); // Fading done? if (tNow >= FadeTimeEnd) { FadeMusicFile->Stop(); FadeMusicFile = nullptr; if (PlayMusicFile) { PlayMusicFile->SetVolume(Volume); } else if (upcoming_music_file) { // Fade end -> start desired next immediately force_song_execution = true; } } else { // Fade process int fade_volume = 1000 * (tNow - FadeTimeStart) / (FadeTimeEnd - FadeTimeStart); FadeMusicFile->SetVolume(Volume * (1000 - fade_volume) / 1000); if (PlayMusicFile) PlayMusicFile->SetVolume(Volume * fade_volume / 1000); } } // Ensure a piece is played #if AUDIO_TK != AUDIO_TK_SDL_MIXER if (!::Game.iTick35 || !::Game.IsRunning || force_song_execution || ::Game.IsPaused()) #else (void) force_song_execution; #endif { if (!PlayMusicFile) { if (!is_waiting || (C4TimeMilliseconds::Now() >= wait_time_end)) { // Play a song if no longer in silence mode and nothing is playing right now C4MusicFile *next_file = upcoming_music_file; is_waiting = false; upcoming_music_file = nullptr; if (next_file) Play(next_file, false, 0.0); else Play(); } } else { // Calls NotifySuccess if a new piece had been selected. PlayMusicFile->CheckIfPlaying(); } } } bool C4MusicSystem::Play(const char *szSongname, bool fLoop, int fadetime_ms, double max_resume_time, bool allow_break) { // pause is done is_waiting = false; upcoming_music_file = nullptr; // music off? if (Game.IsRunning ? !Config.Sound.RXMusic : !Config.Sound.FEMusic) return false; // info if (::Config.Sound.Verbose) { LogF(R"(MusicSystem: Play("%s", %s, %d, %.3lf, %s))", szSongname ? szSongname : "(null)", fLoop ? "true" : "false", fadetime_ms, max_resume_time, allow_break ? "true" : "false"); } C4MusicFile* NewFile = nullptr; // Specified song name if (szSongname && szSongname[0]) { // Search in list for (NewFile = Songs; NewFile; NewFile = NewFile->pNext) { char songname[_MAX_FNAME + 1]; SCopy(szSongname, songname); DefaultExtension(songname, "mid"); if (SEqual(GetFilename(NewFile->FileName), songname)) break; SCopy(szSongname, songname); DefaultExtension(songname, "ogg"); if (SEqual(GetFilename(NewFile->FileName), songname)) break; } } else { // When resuming, prefer songs that were interrupted before if (max_resume_time > 0) { C4TimeMilliseconds t_now = C4TimeMilliseconds::Now(); for (C4MusicFile *check_file = Songs; check_file; check_file = check_file->pNext) if (!check_file->NoPlay) { if (check_file->HasResumePos() && check_file->GetRemainingTime() > max_resume_time) if (!music_max_position_memory || (t_now - check_file->GetLastInterruptionTime() <= music_max_position_memory*1000)) if (!NewFile || NewFile->LastPlayed < check_file->LastPlayed) NewFile = check_file; } } // Random song if (!NewFile) { // Intead of a new song, is a break also allowed? if (allow_break) ScheduleWaitTime(); if (!is_waiting) { if (::Config.Sound.Verbose) LogF(" ASongCount=%d SCounter=%d", ASongCount, SCounter); // try to find random song int32_t new_file_playability = 0, new_file_num_rolls = 0; for (C4MusicFile *check_file = Songs; check_file; check_file = check_file->pNext) { if (!check_file->NoPlay) { // Categorize song playability: // 0 = no song found yet // 1 = song was played recently // 2 = song not played recently // 3 = song was not played yet int32_t check_file_playability = (check_file->LastPlayed < 0) ? 3 : (SCounter - check_file->LastPlayed <= ASongCount / 2) ? 1 : 2; if (::Config.Sound.Verbose) LogF(" Song LastPlayed %d [%d] (%s)", int(check_file->LastPlayed), int(check_file_playability), check_file->GetDebugInfo().getData()); if (check_file_playability > new_file_playability) { // Found much better fit. Play this and reset number of songs found in same plyability new_file_num_rolls = 1; NewFile = check_file; new_file_playability = check_file_playability; } else if (check_file_playability == new_file_playability) { // Found a fit in the same playability category: Roll for it if (!UnsyncedRandom(++new_file_num_rolls)) NewFile = check_file; } else { // Worse playability - ignore this song } } } } } } // File (or wait time) found? if (!NewFile && !is_waiting) return false; // Stop/Fade out old music bool is_fading = (fadetime_ms && NewFile != PlayMusicFile && PlayMusicFile); if (!is_fading) { Stop(); } else { C4TimeMilliseconds tNow = C4TimeMilliseconds::Now(); if (FadeMusicFile) { if (FadeMusicFile == NewFile && FadeMusicFile->IsLooping() == fLoop && tNow < FadeTimeEnd) { // Fading back to a song while it wasn't fully faded out yet. Just swap our pointers and fix timings for that. FadeMusicFile = PlayMusicFile; PlayMusicFile = NewFile; FadeTimeEnd = tNow + fadetime_ms * (tNow - FadeTimeStart) / (FadeTimeEnd - FadeTimeStart); FadeTimeStart = FadeTimeEnd - fadetime_ms; return true; } else { // Fading to a third song while the previous wasn't faded out yet // That's pretty chaotic anyway, so just cancel the last song // Also happens if fading should already be done, in which case it won't harm to stop now // (It would stop on next call to Execute() anyway) // Also happens when fading back to the same song but loop status changes, but that should be really uncommon. FadeMusicFile->Stop(); } } FadeMusicFile = PlayMusicFile; PlayMusicFile = nullptr; FadeTimeStart = tNow; FadeTimeEnd = FadeTimeStart + fadetime_ms; } // Waiting? if (!NewFile) return false; // If the old file is being faded out and a new file would just start, start delayed and without fading // so the beginning of a song isn't faded unnecesserily (because our songs often start very abruptly) if (is_fading && (!NewFile->HasResumePos() || NewFile->GetRemainingTime() <= max_resume_time)) { upcoming_music_file = NewFile; is_waiting = true; wait_time_end = FadeTimeEnd; return false; } if (!Play(NewFile, fLoop, max_resume_time)) return false; if (is_fading) PlayMusicFile->SetVolume(0); return true; } bool C4MusicSystem::Play(C4MusicFile *NewFile, bool fLoop, double max_resume_time) { // info if (::Config.Sound.Verbose) { LogF(R"(MusicSystem: PlaySong("%s", %s, %.3lf))", NewFile->GetDebugInfo().getData(), fLoop ? "true" : "false", max_resume_time); } // Play new song directly if (!NewFile->Play(fLoop, max_resume_time)) return false; PlayMusicFile = NewFile; NewFile->LastPlayed = SCounter++; Loop = fLoop; // Set volume PlayMusicFile->SetVolume(Volume); // Message first time a piece is played if (!NewFile->HasBeenAnnounced()) NewFile->Announce(); return true; } void C4MusicSystem::NotifySuccess() { // nothing played? if (!PlayMusicFile) return; // loop? if (Loop) if (PlayMusicFile->Play()) return; // clear last played piece Stop(); // force a wait time after this song? ScheduleWaitTime(); } bool C4MusicSystem::ScheduleWaitTime() { // Roll for scheduling a break after the next piece. if (SCounter < 3) return false; // But not right away. if (int32_t(UnsyncedRandom(100)) >= music_break_chance) return false; if (music_break_max > 0) { int32_t music_break = music_break_min; if (music_break_max > music_break_min) music_break += UnsyncedRandom(music_break_max - music_break_min); // note that UnsyncedRandom has limited range if (music_break > 0) { is_waiting = true; wait_time_end = C4TimeMilliseconds::Now() + music_break; if (::Config.Sound.Verbose) { LogF("MusicSystem: Pause (%d msecs)", (int)music_break); } // After wait, do not resume previously started songs for (C4MusicFile *check_file = Songs; check_file; check_file = check_file->pNext) check_file->ClearResumePos(); } } return is_waiting; } void C4MusicSystem::FadeOut(int fadeout_ms) { // Kill any previous fading music and schedule current piece to fade if (PlayMusicFile) { if (FadeMusicFile) FadeMusicFile->Stop(); FadeMusicFile = PlayMusicFile; PlayMusicFile = nullptr; FadeTimeStart = C4TimeMilliseconds::Now(); FadeTimeEnd = FadeTimeStart + fadeout_ms; } } bool C4MusicSystem::Stop() { if (PlayMusicFile) { PlayMusicFile->Stop(); PlayMusicFile=nullptr; } if (FadeMusicFile) { FadeMusicFile->Stop(); FadeMusicFile = nullptr; } return true; } void C4MusicSystem::UpdateVolume() { // Save volume for next file int32_t config_volume = Clamp(Config.Sound.MusicVolume, 0, 100); Volume = config_volume * game_music_level / 100; // Tell it to the act file if (PlayMusicFile) PlayMusicFile->SetVolume(Volume); } MusicType GetMusicFileTypeByExtension(const char* ext) { if (SEqualNoCase(ext, "mid")) return MUSICTYPE_MID; #if AUDIO_TK == AUDIO_TK_SDL_MIXER else if (SEqualNoCase(ext, "xm") || SEqualNoCase(ext, "it") || SEqualNoCase(ext, "s3m") || SEqualNoCase(ext, "mod")) return MUSICTYPE_MOD; #ifdef USE_MP3 else if (SEqualNoCase(ext, "mp3")) return MUSICTYPE_MP3; #endif #endif else if (SEqualNoCase(ext, "ogg")) return MUSICTYPE_OGG; return MUSICTYPE_UNKNOWN; } bool C4MusicSystem::GrpContainsMusic(C4Group &rGrp) { // search for known file extensions return rGrp.FindEntry("*.mid") #ifdef USE_MP3 || rGrp.FindEntry("*.mp3") #endif || rGrp.FindEntry("*.xm") || rGrp.FindEntry("*.it") || rGrp.FindEntry("*.s3m") || rGrp.FindEntry("*.mod") || rGrp.FindEntry("*.ogg"); } int C4MusicSystem::SetPlayList(const char *szPlayList, bool fForceSwitch, int fadetime_ms, double max_resume_time) { // Shortcut if no change if (playlist_valid && playlist == szPlayList) return 0; // info if (::Config.Sound.Verbose) { LogF(R"(MusicSystem: SetPlayList("%s", %s, %d, %.3lf))", szPlayList ? szPlayList : "(null)", fForceSwitch ? "true" : "false", fadetime_ms, max_resume_time); } // reset C4MusicFile *pFile; for (pFile = Songs; pFile; pFile = pFile->pNext) { pFile->NoPlay = true; } ASongCount = 0; if (szPlayList && *szPlayList) { // match char szFileName[_MAX_FNAME + 1]; for (int cnt = 0; SGetModule(szPlayList, cnt, szFileName, _MAX_FNAME); cnt++) for (pFile = Songs; pFile; pFile = pFile->pNext) if (pFile->NoPlay) if (WildcardMatch(szFileName, GetFilename(pFile->FileName)) || pFile->HasCategory(szFileName)) { ASongCount++; pFile->NoPlay = false; } } else { // default: all files except the ones beginning with an at ('@') // Ignore frontend and credits music for (pFile = Songs; pFile; pFile = pFile->pNext) if (*GetFilename(pFile->FileName) != '@' && !pFile->HasCategory("frontend") && !pFile->HasCategory("credits")) { ASongCount++; pFile->NoPlay = false; } } // Force switch of music if currently playing piece is not in list or idle because no music file matched if (fForceSwitch) { if (PlayMusicFile) { fForceSwitch = PlayMusicFile->NoPlay; } else { fForceSwitch = (!is_waiting || C4TimeMilliseconds::Now() >= wait_time_end); } if (fForceSwitch) { // Switch music. Switching to a break is also allowed, but won't be done if there is a piece to resume // Otherwise breaks would never occur if the playlist changes often. Play(nullptr, false, fadetime_ms, max_resume_time, PlayMusicFile != nullptr); } } // Remember setting (e.g. to be saved in savegames) playlist.Copy(szPlayList); playlist_valid = true; // do not re-calculate available song if playlist is reset to same value in the future return ASongCount; } bool C4MusicSystem::ToggleOnOff() { // // command key for music toggle pressed // use different settings for game/menu (lobby also counts as "menu", so go by Game.IsRunning-flag rather than startup) if (Game.IsRunning) { // game music Config.Sound.RXMusic = !Config.Sound.RXMusic; if (!Config.Sound.RXMusic) Stop(); else Play(); ::GraphicsSystem.FlashMessageOnOff(LoadResStr("IDS_CTL_MUSIC"), !!Config.Sound.RXMusic); } else { // game menu Config.Sound.FEMusic = !Config.Sound.FEMusic; if (!Config.Sound.FEMusic) Stop(); else Play(); } // key processed return true; } void C4MusicSystem::CompileFunc(StdCompiler *comp) { comp->Value(mkNamingAdapt(playlist, "PlayList", StdCopyStrBuf())); comp->Value(mkNamingAdapt(game_music_level, "Volume", 100)); comp->Value(mkNamingAdapt(music_break_min, "MusicBreakMin", DefaultMusicBreak)); comp->Value(mkNamingAdapt(music_break_max, "MusicBreakMax", DefaultMusicBreak)); comp->Value(mkNamingAdapt(music_break_chance, "MusicBreakChance", DefaultMusicBreakChance)); comp->Value(mkNamingAdapt(music_max_position_memory, "MusicMaxPositionMemory", DefaultMusicMaxPositionMemory)); // Wait time is not saved - begin savegame resume with a fresh song! // Reflect loaded values immediately if (comp->isDeserializer()) { SetGameMusicLevel(game_music_level); SetPlayList(playlist.getData()); } } int32_t C4MusicSystem::SetGameMusicLevel(int32_t volume_percent) { game_music_level = Clamp(volume_percent, 0, 500); // allow max 5x the user setting UpdateVolume(); return game_music_level; } const int32_t C4MusicSystem::DefaultMusicBreak = 120000; // two minutes default music break time const int32_t C4MusicSystem::DefaultMusicBreakChance = 50; // ...with a 50% chance const int32_t C4MusicSystem::DefaultMusicMaxPositionMemory = 420; // after this time (in seconds) a piece is no longer continued at the position where it was interrupted