1/*
2 * LegacyClonk
3 *
4 * Copyright (c) 1998-2000, Matthes Bender (RedWolf Design)
5 * Copyright (c) 2017-2022, The LegacyClonk Team and contributors
6 *
7 * Distributed under the terms of the ISC license; see accompanying file
8 * "COPYING" for details.
9 *
10 * "Clonk" is a registered trademark of Matthes Bender, used with permission.
11 * See accompanying file "TRADEMARK" for details.
12 *
13 * To redistribute this file separately, substitute the full license texts
14 * for the above references.
15 */
16
17#include <C4Include.h>
18#include <C4MusicSystem.h>
19
20#include <C4Application.h>
21#include <C4Random.h>
22#include <C4Log.h>
23#include <C4Game.h>
24#include <C4Wrappers.h>
25
26#include <algorithm>
27#include <cstring>
28#include <initializer_list>
29#include <utility>
30
31static constexpr std::initializer_list<const char *> MusicFileExtensions{
32 "it", "mid", "mod", "mp3", "ogg", "s3m", "xm"};
33
34// helper
35const char *SGetRelativePath(const char *strPath);
36
37C4MusicSystem::C4MusicSystem()
38{
39 if (!Application.AudioSystem) return;
40
41 // Load songs from global music file
42 LoadDir(path: Config.AtExePath(C4CFN_Music));
43 // Read MoreMusic.txt
44 LoadMoreMusic();
45}
46
47void C4MusicSystem::Execute()
48{
49 if (!Application.AudioSystem) return;
50
51 if (!Application.AudioSystem->IsMusicPlaying())
52 {
53 ClearPlayingSong();
54 Play();
55 }
56}
57
58void C4MusicSystem::Play(const char *const songname, const bool loop)
59{
60 if (!Application.AudioSystem) return;
61
62 // Music disabled by user or scenario?
63 if (!(Game.IsMusicEnabled || (!Game.IsRunning && GetCfgMusicEnabled()))) return;
64
65 const Song *newSong = nullptr;
66
67 // Play specified song
68 if (songname && songname[0])
69 {
70 newSong = FindSong(name: songname);
71 }
72 // Play random song
73 else
74 {
75 // Create list of enabled songs excluding the most recently played song
76 std::vector<Song *> enabledSongs;
77 for (auto &song : songs)
78 {
79 if (song.enabled && &song != mostRecentlyPlayed) enabledSongs.emplace_back(args: &song);
80 }
81 // No songs? Then play most recently played song again if it is enabled
82 if (enabledSongs.empty())
83 {
84 if (mostRecentlyPlayed && mostRecentlyPlayed->enabled) newSong = mostRecentlyPlayed;
85 }
86 // If there are enabled songs, randomly select one of them
87 else
88 {
89 newSong = enabledSongs[SafeRandom(range: enabledSongs.size())];
90 }
91 }
92
93 // File not found?
94 if (!newSong) return;
95
96 // Stop old music
97 Stop();
98
99 Log(id: C4ResStrTableKey::IDS_PRC_PLAYMUSIC, args: GetFilename(path: newSong->name.c_str()));
100
101 // Load and play music file
102 try
103 {
104 char *data; std::size_t size;
105 if (!C4Group_ReadFile(szFilename: newSong->name.c_str(), pData: &data, iSize: &size))
106 {
107 throw std::runtime_error("Cannot read file");
108 }
109 playingFileContents.reset(p: data);
110 playingFile.reset(p: Application.AudioSystem->CreateMusicFile(buf: data, size));
111 Application.AudioSystem->PlayMusic(music: playingFile.get(), loop);
112 UpdateVolume();
113 Application.AudioSystem->UnpauseMusic();
114 }
115
116 catch (const std::runtime_error &e)
117 {
118 LogNTr(level: spdlog::level::err, fmt: "Cannot play music file {}: {}", args: newSong->name, args: e.what());
119 ClearPlayingSong();
120 return;
121 }
122 catch (...)
123 {
124 ClearPlayingSong();
125 throw;
126 }
127
128 mostRecentlyPlayed = newSong;
129}
130
131void C4MusicSystem::PlayFrontendMusic()
132{
133 if (!Application.AudioSystem) return;
134
135 SetPlayList("Frontend.*");
136 Play();
137}
138
139void C4MusicSystem::PlayScenarioMusic(C4Group &group)
140{
141 if (!Application.AudioSystem) return;
142
143 std::list<std::string> musicDirs;
144
145 // Check if the scenario contains music
146 if (std::any_of(first: std::cbegin(cont: MusicFileExtensions), last: std::cend(cont: MusicFileExtensions),
147 pred: [&](const auto ext) { return group.FindEntry(szWildCard: (std::string("*.") + ext).c_str()); }))
148 {
149 musicDirs.emplace_back(args: Game.ScenarioFile.GetFullName().getData());
150 }
151
152 // Check for music folders in group set
153 for (C4Group *group = nullptr; group = Game.GroupSet.FindGroup(C4GSCnt_Music, pAfter: group); )
154 {
155 musicDirs.emplace_back(args: std::string() +
156 group->GetFullName().getData() + DirectorySeparator + C4CFN_Music);
157 }
158
159 // Clear old songs
160 if (!musicDirs.empty()) ClearSongs();
161
162 // Load music from each directory
163 for (const auto &musicDir : musicDirs)
164 {
165 LoadDir(path: musicDir.c_str());
166 Log(id: C4ResStrTableKey::IDS_PRC_LOCALMUSIC, args: Config.AtExeRelativePath(szFilename: musicDir.c_str()));
167 }
168
169 if (Config.Sound.RXMusic)
170 {
171 Game.IsMusicEnabled = true;
172 }
173
174 SetPlayList(nullptr);
175 if (Game.IsMusicEnabled)
176 {
177 Play();
178 }
179}
180
181long C4MusicSystem::SetPlayList(const char *const playlist)
182{
183 if (!Application.AudioSystem) return 0;
184
185 if (playlist)
186 {
187 // Disable all songs
188 for (auto &song : songs)
189 {
190 song.enabled = false;
191 }
192
193 auto startIt = playlist;
194 const char *const endIt = std::strchr(s: playlist, c: 0);
195 while (true)
196 {
197 // Read next filename from playlist string
198 const auto delimIt = std::find(first: startIt, last: endIt, val: ';');
199 const std::string filename(startIt, delimIt);
200
201 // Enable matching songs
202 for (auto &song : songs)
203 {
204 if (song.enabled) continue;
205 song.enabled = WildcardMatch(
206 szFName1: filename.c_str(), szFName2: GetFilename(path: song.name.c_str()));
207 }
208
209 if (delimIt == endIt) break;
210 startIt = delimIt + 1;
211 }
212 }
213 else
214 {
215 /* Default: all files except the ones beginning with an at ('@')
216 and frontend and credits music. */
217 for (auto &song : songs)
218 {
219 const auto filename = GetFilename(path: song.name.c_str());
220 const auto ignorePrefix = { "@", "Credits.", "Frontend." };
221 song.enabled = !std::any_of(first: ignorePrefix.begin(), last: ignorePrefix.end(),
222 pred: [&](const auto &prefix) { return SEqual2(filename, prefix); });
223 }
224 }
225
226 // Return number of playlist entries
227 return std::count_if(first: songs.cbegin(), last: songs.cend(),
228 pred: [](const auto &song) { return song.enabled; });
229}
230
231void C4MusicSystem::Stop(const int fadeoutMS)
232{
233 if (!Application.AudioSystem) return;
234
235 if (fadeoutMS == 0)
236 {
237 ClearPlayingSong();
238 }
239 else
240 {
241 if (Application.AudioSystem->IsMusicPlaying())
242 {
243 Application.AudioSystem->FadeOutMusic(ms: fadeoutMS);
244 }
245 }
246}
247
248bool C4MusicSystem::ToggleOnOff(const bool changeConfig)
249{
250 bool enabled;
251 if (changeConfig)
252 {
253 enabled = GetCfgMusicEnabled() = !GetCfgMusicEnabled();
254 }
255 else
256 {
257 enabled = !Application.AudioSystem->IsMusicPlaying();
258 }
259
260 if (Game.IsRunning)
261 {
262 Game.IsMusicEnabled = enabled;
263 }
264
265 if (enabled)
266 {
267 Play();
268 }
269 else
270 {
271 Stop();
272 }
273
274 if (Game.IsRunning)
275 {
276 Game.GraphicsSystem.FlashMessageOnOff(strWhat: LoadResStr(id: C4ResStrTableKey::IDS_CTL_MUSIC), fOn: enabled);
277 }
278 return enabled;
279}
280
281void C4MusicSystem::UpdateVolume()
282{
283 if (!Application.AudioSystem) return;
284
285 if (Application.AudioSystem->IsMusicPlaying())
286 {
287 float volume = Config.Sound.MusicVolume / 100.0f;
288 if (Game.IsRunning) volume *= Game.iMusicLevel / 100.0f;
289 Application.AudioSystem->SetMusicVolume(volume);
290 }
291}
292
293bool &C4MusicSystem::GetCfgMusicEnabled()
294{
295 return Game.IsRunning ? Config.Sound.RXMusic : Config.Sound.FEMusic;
296}
297
298void C4MusicSystem::ClearPlayingSong()
299{
300 Application.AudioSystem->StopMusic();
301 playingFile.reset();
302 playingFileContents.reset();
303}
304
305void C4MusicSystem::ClearSongs()
306{
307 Stop();
308 mostRecentlyPlayed = nullptr;
309 songs.clear();
310}
311
312auto C4MusicSystem::FindSong(const std::string &name) const -> const Song *
313{
314 // Try exact path
315 auto pathIt = std::find_if(first: songs.cbegin(), last: songs.cend(),
316 pred: [&](const auto &song) { return name == song.name; });
317 if (pathIt != songs.cend()) return &*pathIt;
318
319 // Try exact filename
320 auto filenameIt = std::find_if(first: songs.cbegin(), last: songs.cend(),
321 pred: [&](const auto &song) { return name == GetFilename(song.name.c_str()); });
322 if (filenameIt != songs.cend()) return &*filenameIt;
323
324 // Try all known extensions
325 for (const auto &song : songs)
326 {
327 const auto songFilename = GetFilename(path: song.name.c_str());
328 if (std::any_of(first: std::cbegin(cont: MusicFileExtensions), last: std::cend(cont: MusicFileExtensions),
329 pred: [&](const auto &ext) { return name + "." + ext == songFilename; }))
330 {
331 return &song;
332 }
333 }
334
335 // Not found
336 return nullptr;
337}
338
339void C4MusicSystem::LoadDir(const char *const path)
340{
341 // Split path
342 const auto filenameStart = GetFilename(path);
343 std::string file{filenameStart};
344 std::string dir = (filenameStart == path ?
345 std::string{} : std::string{path, filenameStart - 1});
346
347 // Open group
348 C4Group dirGroup;
349 bool success = false;
350 // No filename?
351 if (file.empty())
352 {
353 // Add the whole directory
354 file = "*";
355 }
356 // No wildcard in filename?
357 else if (file.find_first_of(s: "*?") == std::string::npos)
358 {
359 // Then it's either a file or a directory - do the test with C4Group
360 success = dirGroup.Open(szGroupName: path);
361 if (success)
362 {
363 dir = path;
364 file = "*";
365 }
366 // If not successful, it must be a file
367 }
368 // Open directory group, if not already done so
369 if (!success) success = dirGroup.Open(szGroupName: dir.c_str());
370 if (!success)
371 {
372 Log(id: C4ResStrTableKey::IDS_PRC_MUSICFILENOTFOUND, args: path);
373 return;
374 }
375
376 // Search music files and add them to song list
377 char entry[_MAX_FNAME + 1];
378 dirGroup.ResetSearch();
379 while (dirGroup.FindNextEntry(szWildCard: file.c_str(), sFileName: entry))
380 {
381 const auto fullPath = dir + DirectorySeparator + entry;
382 const auto fileExt = GetExtension(fname: entry);
383 if (std::any_of(first: std::cbegin(cont: MusicFileExtensions), last: std::cend(cont: MusicFileExtensions),
384 pred: [&](const auto &musicExt) { return SEqualNoCase(fileExt, musicExt); }))
385 {
386 songs.emplace_back(args: fullPath);
387 }
388 }
389}
390
391void C4MusicSystem::LoadMoreMusic()
392{
393 // Read MoreMusic.txt file or cancel if not present
394 CStdFile MoreMusicFile;
395 std::uint8_t *fileContentsTmp; size_t size;
396 if (!MoreMusicFile.Load(szFileName: Config.AtExePath(C4CFN_MoreMusic), lpbpBuf: &fileContentsTmp, ipSize: &size)) return;
397 std::unique_ptr<std::uint8_t[]> fileContents(fileContentsTmp);
398
399 // read contents
400 const char *rest = reinterpret_cast<const char *>(fileContents.get());
401 const auto end = rest + size;
402
403 while (rest != end)
404 {
405 // Get next line
406 const auto lineEnd = std::find(first: rest, last: end, val: '\n');
407 std::string line(rest, lineEnd);
408 rest = (lineEnd == end ? end : lineEnd + 1);
409
410 // Trim
411 constexpr char whitespace[] = " \t\r";
412 const auto trimLeft = line.find_first_not_of(s: whitespace);
413 // Skip if line would be empty after trimming whitespace
414 if (trimLeft == std::string::npos) continue;
415 const auto trimRight = line.find_last_not_of(s: whitespace) + 1;
416 line = line.substr(pos: trimLeft, n: trimRight - trimLeft);
417
418 // Reset playlist
419 if (line == "#clear")
420 {
421 ClearSongs();
422 }
423 // Comment
424 else if (line[0] == '#')
425 {
426 continue;
427 }
428 // Add
429 else
430 {
431 LoadDir(path: line.c_str());
432 }
433 }
434}
435