shuyi_platform/audio.rs
1//! [`Audio`]: the sound device a backend gives the engine, and the frame of
2//! audio [`App`](crate::App) hands it ([`AudioFrame`]).
3//!
4//! The screens' requests name the game's voice clips by its own variant
5//! type; what reaches the device names their files ([`SoundRequest`]), so
6//! a backend is the same for every game. The rules of playing (a cue's
7//! volume, picking a variant, fades, the music clock) are [`player`]'s, so a
8//! backend writes only its small [`Backend`](player::Backend) trait.
9
10pub mod player;
11
12use crate::alloc_prelude::*;
13
14use shuyi_assets::voice::{ClipVariant, clip_file};
15use shuyi_ui::audio::{AudioRequest, MusicCommand, SharedSamples, SoundId};
16
17use crate::platform::Millis;
18
19/// A sound or voice request as the device gets it: a screen's
20/// [`AudioRequest`] with each voice clip named by its file in the game's
21/// voice folder ([`clip_file`]). The music's requests are already
22/// [`MusicCommand`]s.
23#[derive(Debug, Clone, PartialEq, serde::Serialize)]
24#[non_exhaustive]
25pub enum SoundRequest {
26 /// Play a sound cue once, at `volume` (0–1) times the cue's own volume.
27 Sound {
28 /// The sound cue.
29 cue: String,
30 /// A multiplier on the cue's volume, 0–1.
31 volume: f32,
32 },
33 /// Play a sound cue until [`Stop`](Self::Stop) of its `id`: looped if
34 /// the cue says so, else once. Build it with [`SoundRequest::start`]
35 /// (ADR-0039).
36 #[non_exhaustive]
37 Start {
38 /// Its handle.
39 id: SoundId,
40 /// The sound cue.
41 cue: String,
42 /// A multiplier on the cue's volume, 0–1.
43 volume: f32,
44 },
45 /// Stop the sound started as `id`, at once: that one playback, not the
46 /// cue's others.
47 Stop {
48 /// Its handle.
49 id: SoundId,
50 },
51 /// Set the volume of the sound started as `id`.
52 Volume {
53 /// Its handle.
54 id: SoundId,
55 /// The new multiplier on the cue's volume, 0–1.
56 volume: f32,
57 },
58 /// Pause every sound started with a handle that is playing now; one
59 /// started after it plays. Sound cues played once, the music and the
60 /// voice go on. A sound paused keeps the volume it is given and sounds
61 /// at it when it resumes; one stopped while paused is gone.
62 PauseSounds,
63 /// Resume the sounds [`PauseSounds`](Self::PauseSounds) paused.
64 ResumeSounds,
65 /// Say a line: play its clip once, stopping the voice that is playing.
66 Voice {
67 /// The clip's file in the voice folder.
68 clip: String,
69 },
70 /// Stop the voice that is playing, if any.
71 StopVoice,
72 /// The clips a scene will ask for, in order, so the device can load
73 /// the next few ahead. Replaces the list sent before.
74 PreloadVoices {
75 /// Their files, in the order they are expected.
76 clips: Vec<String>,
77 },
78 /// Make `samples` the sound cue `cue`, which the manifest doesn't have:
79 /// [`Sound`](Self::Sound) and [`Start`](Self::Start) of it play it at its
80 /// own volume, full, once. A cue added before is replaced. Build it with
81 /// [`SoundRequest::add`] (ADR-0039). A record of it holds the samples'
82 /// rate, channels and frames, not the samples.
83 #[non_exhaustive]
84 Add {
85 /// The sound cue.
86 cue: String,
87 /// Its samples.
88 #[serde(serialize_with = "samples_summary")]
89 samples: SharedSamples,
90 },
91 /// Forget the sound cue `cue` that [`Add`](Self::Add) made, stopping
92 /// what plays it.
93 Remove {
94 /// The sound cue.
95 cue: String,
96 },
97}
98
99/// `samples` as a record writes it: its rate, channels and frames.
100fn samples_summary<S: serde::Serializer>(
101 samples: &SharedSamples,
102 out: S,
103) -> Result<S::Ok, S::Error> {
104 use serde::ser::SerializeStruct;
105 let mut summary = out.serialize_struct("Samples", 3)?;
106 summary.serialize_field("rate", &samples.rate())?;
107 summary.serialize_field("channels", &samples.channels())?;
108 summary.serialize_field("frames", &samples.frames())?;
109 summary.end()
110}
111
112impl SoundRequest {
113 /// A [`Start`](Self::Start) of `cue` as `id` at `volume`.
114 pub fn start(id: SoundId, cue: impl Into<String>, volume: f32) -> Self {
115 Self::Start {
116 id,
117 cue: cue.into(),
118 volume,
119 }
120 }
121
122 /// An [`Add`](Self::Add) of `samples` as `cue`.
123 pub fn add(cue: impl Into<String>, samples: SharedSamples) -> Self {
124 Self::Add {
125 cue: cue.into(),
126 samples,
127 }
128 }
129
130 /// `request` as the device gets it; `None` for a music request, which
131 /// the frame's music commands already say.
132 pub fn from_request<V: ClipVariant>(request: &AudioRequest<V>) -> Option<Self> {
133 Some(match request {
134 AudioRequest::PlaySound { cue, volume } => Self::Sound {
135 cue: cue.clone(),
136 volume: *volume,
137 },
138 AudioRequest::StartSound { id, cue, volume } => Self::start(*id, cue.clone(), *volume),
139 AudioRequest::StopSound { id } => Self::Stop { id: *id },
140 AudioRequest::SoundVolume { id, volume } => Self::Volume {
141 id: *id,
142 volume: *volume,
143 },
144 AudioRequest::PlayVoice { line, variant } => Self::Voice {
145 clip: clip_file(line, *variant),
146 },
147 AudioRequest::PauseSounds => Self::PauseSounds,
148 AudioRequest::ResumeSounds => Self::ResumeSounds,
149 AudioRequest::StopVoice => Self::StopVoice,
150 AudioRequest::PreloadVoices { lines } => Self::PreloadVoices {
151 clips: lines.iter().map(|(l, v)| clip_file(l, *v)).collect(),
152 },
153 AudioRequest::AddSound { cue, samples, .. } => Self::add(cue.clone(), samples.clone()),
154 AudioRequest::RemoveSound { cue } => Self::Remove { cue: cue.clone() },
155 // The music's, and any request this engine doesn't know yet.
156 _ => return None,
157 })
158 }
159}
160
161/// One frame's audio, as [`App`](crate::App) hands it to the device.
162#[derive(Debug, Clone, Copy)]
163pub struct AudioFrame<'a> {
164 /// The sound and voice requests the screens made, in order.
165 pub requests: &'a [SoundRequest],
166 /// What to do to the music, in order.
167 pub music: &'a [MusicCommand],
168 /// The player's music volume, 0–1: it multiplies every track's own
169 /// volume, a track already playing too.
170 pub music_volume: f32,
171 /// The player's sound volume, 0–1: it multiplies every sound's own.
172 pub sound_volume: f32,
173 /// How loud voice clips play, 0–1, the one playing too.
174 pub voice_volume: f32,
175}
176
177/// A backend's sound device.
178///
179/// **Pausing.** A backend that can pause a sound pauses it; one that
180/// can't mutes it (volume 0) and lets it run on silently. That holds for
181/// [`SoundRequest::PauseSounds`] and for [`suspend`](Self::suspend). A
182/// sound held either way keeps the volume it is given and sounds at it
183/// when it is released, and [`music_playing`](Self::music_playing) says
184/// where the track really is: a paused track's clock holds, a muted one's
185/// runs.
186pub trait Audio {
187 /// The music sounding at `now`: its cue and the seconds since it really
188 /// started (once its file had loaded), or `None` in silence: nothing
189 /// asked for, a track still loading or whose file failed. Of two
190 /// tracks sounding (a crossfade), the newest: the old one until the
191 /// new one has loaded.
192 fn music_playing(&self, now: Millis) -> Option<(&str, f64)>;
193
194 /// The voice clip sounding at `now`, by its file
195 /// ([`SoundRequest::Voice`]), or asked for and still loading: `None`
196 /// once it has ended, and for a clip stopped, replaced, whose file
197 /// failed or that loaded too late to be played. A clip held by
198 /// [`suspend`](Self::suspend) still sounds. `None` by default, for a
199 /// device that plays no voices.
200 fn voice_playing(&self, now: Millis) -> Option<&str> {
201 let _ = now;
202 None
203 }
204
205 /// Plays one frame's `frame` of audio at `now`: the volumes first, then
206 /// the requests and the music commands in order. It also checks on
207 /// what is still loading, so it is called every frame, with nothing to
208 /// play too.
209 fn play(&mut self, frame: &AudioFrame<'_>, now: Millis);
210
211 /// Stops the music, the voice and the sounds started with a handle:
212 /// the game has quit.
213 fn stop_all(&mut self);
214
215 /// Holds everything sounding (the sounds started with a handle, the
216 /// voice and the music) while the system suspends the game
217 /// ([`SystemEvent::Suspend`](crate::SystemEvent::Suspend)); sound
218 /// cues played once finish. Pauses, or mutes where the backend can't
219 /// pause. A second call does nothing.
220 ///
221 /// It has no default: every backend says what it can hold (ADR-0056).
222 fn suspend(&mut self);
223
224 /// Brings back what [`suspend`](Self::suspend) held, and leaves a
225 /// [`SoundRequest::PauseSounds`] pause in place. A call while not
226 /// suspended does nothing.
227 fn resume(&mut self);
228}
229
230/// A device that plays nothing: for a backend with no sound yet, and tests.
231#[derive(Debug, Clone, Copy, Default)]
232pub struct Silent;
233
234impl Audio for Silent {
235 fn music_playing(&self, _: Millis) -> Option<(&str, f64)> {
236 None
237 }
238
239 fn play(&mut self, _: &AudioFrame<'_>, _: Millis) {}
240
241 fn stop_all(&mut self) {}
242
243 fn suspend(&mut self) {}
244
245 fn resume(&mut self) {}
246}
247
248#[cfg(test)]
249mod tests {
250 use alloc::vec;
251
252 use shuyi_assets::dialogue::LineId;
253 use shuyi_ui::toy::Voice;
254
255 use super::*;
256
257 fn line(id: &str) -> LineId {
258 LineId::new(id)
259 }
260
261 #[test]
262 fn a_voice_is_its_clips_file_and_music_stays_with_the_commands() {
263 let play = |request: AudioRequest<Voice>| SoundRequest::from_request(&request);
264 assert_eq!(
265 play(AudioRequest::PlaySound {
266 cue: "click".into(),
267 volume: 0.5
268 }),
269 Some(SoundRequest::Sound {
270 cue: "click".into(),
271 volume: 0.5
272 })
273 );
274 let hello = line("intro.hello");
275 let only = clip_file(&hello, Voice::Only);
276 assert_eq!(
277 play(AudioRequest::PlayVoice {
278 line: hello.clone(),
279 variant: Voice::Only
280 }),
281 Some(SoundRequest::Voice { clip: only.clone() })
282 );
283 assert_eq!(
284 play(AudioRequest::PreloadVoices {
285 lines: vec![(hello.clone(), Voice::Only), (hello, Voice::Low)]
286 })
287 .map(|r| match r {
288 SoundRequest::PreloadVoices { clips } => clips.len(),
289 _ => 0,
290 }),
291 Some(2)
292 );
293 assert_eq!(play(AudioRequest::StopVoice), Some(SoundRequest::StopVoice));
294 let mut queue = shuyi_ui::audio::AudioQueue::<Voice>::default();
295 let id = queue.start_sound_at("hum", 0.5);
296 queue.set_sound_volume(id, 0.25);
297 queue.stop_sound(id, 0);
298 let got: Vec<_> = queue
299 .take()
300 .iter()
301 .filter_map(SoundRequest::from_request)
302 .collect();
303 assert_eq!(
304 got,
305 [
306 SoundRequest::start(id, "hum", 0.5),
307 SoundRequest::Volume { id, volume: 0.25 },
308 SoundRequest::Stop { id },
309 ]
310 );
311 assert_eq!(
312 play(AudioRequest::PauseSounds),
313 Some(SoundRequest::PauseSounds)
314 );
315 assert_eq!(
316 play(AudioRequest::ResumeSounds),
317 Some(SoundRequest::ResumeSounds)
318 );
319 assert_eq!(play(AudioRequest::play_music("theme", None)), None);
320 assert_eq!(play(AudioRequest::StopMusic), None);
321 }
322
323 #[test]
324 fn a_sound_added_reaches_the_device_and_its_record_holds_no_samples() {
325 let samples = shuyi_ui::audio::Samples::from_u8(11_025, 2, &[128; 8]);
326 let mut queue = shuyi_ui::audio::AudioQueue::<Voice>::default();
327 assert_eq!(
328 queue.add_sound("tone", samples.unwrap_or_else(|e| panic!("{e}"))),
329 Ok(())
330 );
331 queue.remove_sound("tone");
332 let got: Vec<_> = queue
333 .take()
334 .iter()
335 .filter_map(SoundRequest::from_request)
336 .collect();
337 let [
338 SoundRequest::Add { cue, samples, .. },
339 SoundRequest::Remove { cue: removed },
340 ] = &got[..]
341 else {
342 panic!("{got:?}");
343 };
344 assert_eq!((cue.as_str(), removed.as_str()), ("tone", "tone"));
345 assert_eq!(samples.frames(), 4);
346 let record = ron::to_string(&got[0]).unwrap_or_else(|e| panic!("{e}"));
347 assert_eq!(
348 record,
349 "Add(cue:\"tone\",samples:(rate:11025,channels:2,frames:4))"
350 );
351 }
352
353 #[test]
354 fn silence_plays_nothing() {
355 let mut silent = Silent;
356 let frame = AudioFrame {
357 requests: &[SoundRequest::StopVoice],
358 music: &[],
359 music_volume: 1.0,
360 sound_volume: 1.0,
361 voice_volume: 1.0,
362 };
363 silent.play(&frame, Millis(0));
364 silent.suspend();
365 silent.resume();
366 silent.stop_all();
367 assert_eq!(silent.music_playing(Millis(0)), None);
368 assert_eq!(silent.voice_playing(Millis(0)), None, "it plays no voices");
369 }
370}