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 <C4SoundSystem.h>
19
20#include <C4Random.h>
21#include <C4Object.h>
22#include <C4Game.h>
23#include <C4Log.h>
24#include <C4Config.h>
25#include <C4Application.h>
26
27#include <algorithm>
28#include <iterator>
29#include <type_traits>
30#include <utility>
31#include <vector>
32
33bool IsSoundPlaying(const char *const name, const C4Object *const obj)
34{
35 return Application.SoundSystem->FindInst(wildcard: name, obj).has_value();
36}
37
38void SoundLevel(const char *const name, C4Object *const obj, const std::int32_t level)
39{
40 // Sound level zero? Stop
41 if (level <= 0) { StopSoundEffect(name, obj); return; }
42 // Set volume of existing instance or create new instance
43 const auto it = Application.SoundSystem->FindInst(wildcard: name, obj);
44 if (it)
45 {
46 (**it).volume = level;
47 }
48 else
49 {
50 StartSoundEffect(name, loop: true, volume: level, obj);
51 }
52}
53
54bool StartSoundEffect(const char *const name, const bool loop, const std::int32_t volume,
55 C4Object *const obj, const std::int32_t falloffDistance)
56{
57 return Application.SoundSystem->NewInstance(filename: name, loop, volume, pan: 0, obj, falloffDistance) != nullptr;
58}
59
60void StartSoundEffectAt(const char *const name, const std::int32_t x, const std::int32_t y)
61{
62 std::int32_t volume, pan;
63 Application.SoundSystem->GetVolumeByPos(x, y, volume, pan);
64 Application.SoundSystem->NewInstance(filename: name, loop: false, volume, pan, obj: nullptr, falloffDistance: 0);
65}
66
67void StopSoundEffect(const char *const name, const C4Object *const obj)
68{
69 if (const auto it = Application.SoundSystem->FindInst(wildcard: name, obj))
70 {
71 (**it).sample.instances.erase(position: *it);
72 }
73}
74
75C4SoundSystem::C4SoundSystem()
76{
77 // Load Sound.c4g
78 C4Group soundFolder;
79 if (soundFolder.Open(szGroupName: Config.AtExePath(C4CFN_Sound)))
80 {
81 LoadEffects(group&: soundFolder);
82 }
83 else
84 {
85 Log(id: C4ResStrTableKey::IDS_PRC_NOSND);
86 }
87}
88
89void C4SoundSystem::ClearPointers(const C4Object *const obj)
90{
91 for (auto &sample : samples)
92 {
93 sample.instances.remove_if(
94 pred: [&](auto &inst) { return obj == inst.GetObj() && !inst.DetachObj(); });
95 }
96}
97
98void C4SoundSystem::Execute()
99{
100 for (auto &sample : samples)
101 {
102 sample.Execute();
103 }
104}
105
106void C4SoundSystem::LoadEffects(C4Group &group)
107{
108 if (!Application.AudioSystem) return;
109
110 // Process segmented list of file types
111 for (const auto fileType : { "*.wav", "*.ogg", "*.mp3" })
112 {
113 char filename[_MAX_FNAME + 1];
114 // Search all sound files in group
115 group.ResetSearch();
116 while (group.FindNextEntry(szWildCard: fileType, sFileName: filename))
117 {
118 // Try to find existing sample of the same name
119 const auto existingSample = std::find_if(first: samples.cbegin(), last: samples.cend(),
120 pred: [&](const auto &sample) { return SEqualNoCase(filename, sample.name.c_str()); });
121 // Load sample
122 StdBuf buf;
123 if (!group.LoadEntry(szEntryName: filename, Buf&: buf)) continue;
124 try
125 {
126 samples.emplace_back(args&: filename, args: buf.getData(), args: buf.getSize());
127 // Overload (i.e. remove) existing sample of the same name
128 if (existingSample != samples.cend()) samples.erase(position: existingSample);
129 }
130 catch (const std::runtime_error &e)
131 {
132 LogNTr(level: spdlog::level::err, fmt: "Could not load sound effect \"{}/{}\": {}", args: group.GetFullName().getData(), args: +filename, args: e.what());
133 }
134 }
135 }
136}
137
138bool C4SoundSystem::ToggleOnOff()
139{
140 auto &enabled = GetCfgSoundEnabled();
141 return enabled = !enabled;
142}
143
144C4SoundSystem::Sample::Sample(const char *const name, const void *const buf, const std::size_t size)
145 : name{name}, sample{Application.AudioSystem->CreateSoundFile(buf, size)}, duration{sample->GetDuration()} {}
146
147void C4SoundSystem::Sample::Execute()
148{
149 // Execute each instance and remove it if necessary
150 instances.remove_if(pred: [](auto &inst) { return !inst.Execute(); });
151}
152
153bool C4SoundSystem::Instance::DetachObj()
154{
155 // Stop if looping (would most likely loop forever)
156 if (loop) return false;
157 // Otherwise: set volume by last position
158 const auto detachedObj = GetObj();
159 obj.emplace<const ObjPos>(args&: *detachedObj);
160 GetVolumeByPos(x: detachedObj->x, y: detachedObj->y, volume, pan);
161
162 // Do not stop instance
163 return true;
164}
165
166bool C4SoundSystem::Instance::Execute(const bool justStarted)
167{
168 // Object deleted? Detach object and check if this would delete this instance
169 const auto obj = GetObj();
170 if (obj && !obj->Status && !DetachObj()) return false;
171
172 // Remove instances that have stopped
173 if (channel && !channel->IsPlaying()) return false;
174
175 // Remove non-looping, inaudible sounds if half the time is up
176 std::uint32_t pos = ~0;
177 if (!loop && !channel && !justStarted)
178 {
179 pos = GetPlaybackPosition();
180 if (pos > sample.duration / 2) return false;
181 }
182
183 // Muted: No need to calculate new volume
184 if (!GetCfgSoundEnabled())
185 {
186 channel.reset();
187 return true;
188 }
189
190 // Calculate volume and pan
191 float vol = volume * Config.Sound.SoundVolume / 10000.0f, pan = this->pan / 100.0f;
192 if (obj)
193 {
194 std::int32_t audibility = obj->GetAudibility();
195 // apply custom falloff distance
196 if (falloffDistance != 0)
197 {
198 audibility = std::clamp<int32_t>(val: 100 + (audibility - 100) * AudibilityRadius / falloffDistance, lo: 0, hi: 100);
199 }
200 vol *= audibility / 100.0f;
201 pan += obj->GetAudiblePan() / 100.0f;
202 }
203
204 // Sound off? Release channel to make it available for other instances.
205 if (vol <= 0.0f)
206 {
207 channel.reset();
208 return true;
209 }
210
211 try
212 {
213 // Create/restore channel if necessary
214 const auto createChannel = !channel;
215 if (createChannel)
216 {
217 channel.reset(p: Application.AudioSystem->CreateSoundChannel(sound: sample.sample.get(), loop));
218 if (!justStarted)
219 {
220 if (pos == ~0) pos = GetPlaybackPosition();
221 channel->SetPosition(pos);
222 }
223 }
224
225 // Update volume
226 channel->SetVolumeAndPan(volume: vol, pan);
227 if (createChannel) channel->Unpause();
228 }
229 catch (const std::runtime_error &e)
230 {
231 spdlog::error(msg: e.what());
232 return false;
233 }
234
235 return true;
236}
237
238C4Object *C4SoundSystem::Instance::GetObj() const
239{
240 const auto ptr = std::get_if<C4Object *>(ptr: &obj);
241 return ptr ? *ptr : nullptr;
242}
243
244std::uint32_t C4SoundSystem::Instance::GetPlaybackPosition() const
245{
246 if (sample.duration == 0)
247 return 0;
248 return std::chrono::duration_cast<std::chrono::milliseconds>(
249 d: std::chrono::steady_clock::now() - startTime).count() % sample.duration;
250}
251
252bool C4SoundSystem::Instance::IsNear(const C4Object &obj2) const
253{
254 // Attached to object?
255 if (const auto objAsObject = std::get_if<C4Object *>(ptr: &obj); objAsObject && *objAsObject)
256 {
257 const auto x = (**objAsObject).x;
258 const auto y = (**objAsObject).y;
259 return (x - obj2.x) * (x - obj2.x) + (y - obj2.y) * (y - obj2.y) <=
260 NearSoundRadius * NearSoundRadius;
261 }
262
263 // Global or was attached to deleted object
264 // Deleted objects' sounds could be considered near,
265 // but "original" Clonk Rage behavior is to not honor them.
266 // This must not change, otherwise it would break some scenarios / packs (e.g. CMC)
267 return false;
268}
269
270auto C4SoundSystem::FindInst(const char *wildcard, const C4Object *const obj) ->
271 std::optional<decltype(Sample::instances)::iterator>
272{
273 const auto wildcardStr = PrepareFilename(filename: wildcard);
274 wildcard = wildcardStr.c_str();
275
276 for (auto &sample : samples)
277 {
278 // Skip samples whose names do not match the wildcard
279 if (!WildcardMatch(szFName1: wildcard, szFName2: sample.name.c_str())) continue;
280 // Try to find an instance that is bound to obj
281 auto it = std::find_if(first: sample.instances.begin(), last: sample.instances.end(),
282 pred: [&](const auto &inst) { return inst.GetObj() == obj; });
283 if (it != sample.instances.end()) return it;
284 }
285
286 // Not found
287 return {};
288}
289
290bool &C4SoundSystem::GetCfgSoundEnabled()
291{
292 return Game.IsRunning ? Config.Sound.RXSound : Config.Sound.FESamples;
293}
294
295void C4SoundSystem::GetVolumeByPos(std::int32_t x, std::int32_t y,
296 std::int32_t &volume, std::int32_t &pan)
297{
298 volume = Game.GraphicsSystem.GetAudibility(iX: x, iY: y, iPan: &pan);
299}
300
301auto C4SoundSystem::NewInstance(const char *filename, const bool loop,
302 const std::int32_t volume, const std::int32_t pan, C4Object *const obj,
303 const std::int32_t falloffDistance) -> Instance *
304{
305 if (!Application.AudioSystem) return nullptr;
306
307 const auto filenameStr = PrepareFilename(filename);
308 filename = filenameStr.c_str();
309
310 Sample *sample;
311 // Search for matching file if name contains no wildcard
312 if (filenameStr.find(c: '?') == std::string::npos)
313 {
314 const auto end = samples.end();
315 const auto it = std::find_if(first: samples.begin(), last: end,
316 pred: [&](const auto &sample) { return SEqualNoCase(filename, sample.name.c_str()); });
317 // File not found
318 if (it == end) return nullptr;
319 // Success: Found the file
320 sample = &*it;
321 }
322 // Randomly select any matching file if name contains wildcard
323 else
324 {
325 std::vector<Sample *> matches;
326 for (auto &sample : samples)
327 {
328 if (WildcardMatch(szFName1: filename, szFName2: sample.name.c_str()))
329 {
330 matches.push_back(x: &sample);
331 }
332 }
333 // File not found
334 if (matches.empty()) return nullptr;
335 // Success: Randomly select any of the matching files
336 sample = matches[SafeRandom(range: matches.size())];
337 }
338
339 // Too many instances?
340 if (!loop && sample->instances.size() >= MaxSoundInstances) return nullptr;
341
342 // Already playing near?
343 const auto nearIt = obj ?
344 std::find_if(first: sample->instances.cbegin(), last: sample->instances.cend(),
345 pred: [&](const auto &inst) { return inst.IsNear(*obj); }) :
346 std::find_if(first: sample->instances.cbegin(), last: sample->instances.cend(),
347 pred: [](const auto &inst) { return !inst.GetObj(); });
348 if (nearIt != sample->instances.cend()) return nullptr;
349
350 // Create instance
351 auto &inst = sample->instances.emplace_back(args&: *sample, args: loop, args: volume, args: obj, args: falloffDistance);
352 if (!inst.Execute(justStarted: true))
353 {
354 sample->instances.pop_back();
355 return nullptr;
356 }
357
358 return &inst;
359}
360
361std::string C4SoundSystem::PrepareFilename(const char *const filename)
362{
363 auto result = *GetExtension(fname: filename) ?
364 filename : std::string{filename} + ".wav";
365 std::replace(first: result.begin(), last: result.end(), old_value: '*', new_value: '?');
366 return result;
367}
368