Skip to main content

shuyi_cli/export/playdate/
audio.rs

1//! The Playdate's sounds and music, baked for `pdc`: every sound file of the
2//! audio the game ships there, and each of its music tracks, decoded (OGG
3//! Vorbis or WAV), mixed down to mono and written as an IMA ADPCM WAV at its
4//! own 44.1 kHz, under [`BAKED_SOUND_DIR`] and [`BAKED_MUSIC_DIR`], where
5//! `shuyi-playdate` opens them. `pdc` turns each into a `.pda`.
6//!
7//! Decoding music takes seconds, so each file made is kept in the build's
8//! cache under its source's checksum, in a folder named after [`FORMAT`], so a
9//! new conversion never reuses an old file.
10
11use std::collections::BTreeMap;
12use std::fs;
13use std::io::Cursor;
14use std::path::{Path, PathBuf};
15
16use shuyi_assets::audio::{BAKED_MUSIC_DIR, BAKED_SOUND_DIR, SAMPLE_RATE, without_extension};
17
18use crate::game::GameAudio;
19
20/// The conversion's version: the cache folder is named after it, so a change
21/// to what is written must change it.
22pub const FORMAT: u32 = 1;
23
24/// Bytes in one ADPCM block: what both tools the SDK's docs name (ffmpeg's
25/// `adpcm_ima_wav` and Audacity) write for mono at 44.1 kHz, so it is what
26/// `pdc` is known to read.
27pub const BLOCK_BYTES: usize = 1024;
28
29/// Samples in one mono block: the one in its header, then two a byte.
30pub const BLOCK_SAMPLES: usize = (BLOCK_BYTES - 4) * 2 + 1;
31
32/// One file to bake.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Job {
35    /// The file's name, for errors.
36    pub name: String,
37    /// What is in it.
38    pub bytes: Vec<u8>,
39    /// Where it goes in the `.pdx`'s source, with `.wav`:
40    /// `audio/sfx/click.wav`.
41    pub out: String,
42}
43
44/// The files to bake of what the game ships: each sound file once, unless the
45/// build leaves it out, and the file of each music cue, read from `root`'s
46/// music folder where the game doesn't read them itself. Two files that would
47/// land on one path are an error, and so is a file that can't be read.
48pub fn jobs(audio: &GameAudio, root: &Path) -> Result<Vec<Job>, String> {
49    let mut jobs: BTreeMap<String, Job> = BTreeMap::new();
50    let mut add = |name: String, bytes: Vec<u8>, dir: &str, file: &str| {
51        let out = format!("{dir}/{}.wav", without_extension(file));
52        match jobs.get(&out) {
53            Some(job) if job.name != name => Err(format!(
54                "{} and {name} would both be {out} on the Playdate",
55                job.name
56            )),
57            Some(_) => Ok(()),
58            None => {
59                let job = Job {
60                    name,
61                    bytes,
62                    out: out.clone(),
63                };
64                jobs.insert(out, job);
65                Ok(())
66            }
67        }
68    };
69    let files = &audio.files;
70    for (cue, sound) in &audio.manifest.sounds {
71        for file in &sound.files {
72            if (files.sound_left_out)(file) {
73                continue;
74            }
75            let bytes = (files.sound)(file).ok_or_else(|| {
76                format!(
77                    "sound \"{cue}\": {}: no such file",
78                    (files.sound_shown)(file)
79                )
80            })?;
81            add(
82                (files.sound_shown)(file),
83                bytes.into_owned(),
84                BAKED_SOUND_DIR,
85                file,
86            )?;
87        }
88    }
89    for (cue, track) in &audio.manifest.music {
90        let shown = format!("{}/{}", files.music_dir, track.file);
91        let bytes = match files.music {
92            Some(read) => read(&track.file).map(std::borrow::Cow::into_owned),
93            None => fs::read(root.join(&shown)).ok(),
94        };
95        let bytes = bytes.ok_or_else(|| format!("music \"{cue}\": {shown}: no such file"))?;
96        add(shown, bytes, BAKED_MUSIC_DIR, &track.file)?;
97    }
98    Ok(jobs.into_values().collect())
99}
100
101/// The mono samples of the audio file `bytes` named `name` (`.wav`, or else
102/// OGG Vorbis), at [`SAMPLE_RATE`]: a stereo file's channels are averaged.
103pub fn decode(name: &str, bytes: &[u8]) -> Result<Vec<i16>, String> {
104    let is_wav = Path::new(name)
105        .extension()
106        .is_some_and(|ext| ext.eq_ignore_ascii_case("wav"));
107    let (channels, rate, samples) = if is_wav {
108        decode_wav(bytes)
109    } else {
110        decode_ogg(bytes)
111    }
112    .map_err(|e| format!("{name}: {e}"))?;
113    if rate != SAMPLE_RATE {
114        return Err(format!("{name}: {rate} Hz; must be {SAMPLE_RATE} Hz"));
115    }
116    Ok(mono(&samples, channels))
117}
118
119/// An OGG Vorbis file's channels, sample rate and interleaved samples.
120fn decode_ogg(bytes: &[u8]) -> Result<(u16, u32, Vec<i16>), String> {
121    let mut reader =
122        lewton::inside_ogg::OggStreamReader::new(Cursor::new(bytes)).map_err(|e| e.to_string())?;
123    let channels = u16::from(reader.ident_hdr.audio_channels);
124    let rate = reader.ident_hdr.audio_sample_rate;
125    let mut samples = Vec::new();
126    while let Some(packet) = reader.read_dec_packet_itl().map_err(|e| e.to_string())? {
127        samples.extend(packet);
128    }
129    Ok((channels, rate, samples))
130}
131
132/// A PCM (16-bit) or float (32-bit) WAV file's channels, sample rate and
133/// interleaved samples.
134fn decode_wav(bytes: &[u8]) -> Result<(u16, u32, Vec<i16>), String> {
135    let reader = hound::WavReader::new(Cursor::new(bytes)).map_err(|e| e.to_string())?;
136    let spec = reader.spec();
137    let samples: Result<Vec<i16>, hound::Error> = match (spec.sample_format, spec.bits_per_sample) {
138        (hound::SampleFormat::Int, 16) => reader.into_samples::<i16>().collect(),
139        (hound::SampleFormat::Float, 32) => reader
140            .into_samples::<f32>()
141            .map(|s| s.map(float_to_i16))
142            .collect(),
143        (format, bits) => {
144            return Err(format!(
145                "{bits}-bit {format:?} samples; must be 16-bit PCM or 32-bit float"
146            ));
147        }
148    };
149    Ok((
150        spec.channels,
151        spec.sample_rate,
152        samples.map_err(|e| e.to_string())?,
153    ))
154}
155
156/// A float sample (−1 to 1) as a 16-bit one, clamped.
157fn float_to_i16(sample: f32) -> i16 {
158    // Clamped into the i16 range first, so the cast never truncates.
159    #[allow(clippy::cast_possible_truncation)]
160    let sample = (sample * 32_767.0).round().clamp(-32_768.0, 32_767.0) as i16;
161    sample
162}
163
164/// Interleaved `samples` of `channels` channels averaged into one.
165fn mono(samples: &[i16], channels: u16) -> Vec<i16> {
166    let channels = usize::from(channels.max(1));
167    samples
168        .chunks(channels)
169        .map(|frame| {
170            let sum: i32 = frame.iter().map(|&s| i32::from(s)).sum();
171            // The average of i16s is an i16.
172            #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
173            let mean = (sum / frame.len() as i32) as i16;
174            mean
175        })
176        .collect()
177}
178
179/// IMA ADPCM's step sizes.
180const STEPS: [i32; 89] = [
181    7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31, 34, 37, 41, 45, 50, 55, 60, 66,
182    73, 80, 88, 97, 107, 118, 130, 143, 157, 173, 190, 209, 230, 253, 279, 307, 337, 371, 408, 449,
183    494, 544, 598, 658, 724, 796, 876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066, 2272,
184    2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358, 5894, 6484, 7132, 7845, 8630, 9493,
185    10442, 11487, 12635, 13899, 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767,
186];
187
188/// How each code moves the step index.
189const INDEX_MOVES: [i32; 8] = [-1, -1, -1, -1, 2, 4, 6, 8];
190
191/// The IMA ADPCM coder's state: the last sample decoded and the step index.
192#[derive(Debug, Clone, Copy, Default)]
193struct Coder {
194    predictor: i32,
195    index: i32,
196}
197
198impl Coder {
199    /// The step for the current index.
200    fn step(self) -> i32 {
201        STEPS[usize::try_from(self.index).unwrap_or(0)]
202    }
203
204    /// The 4-bit code for `sample`, moving the state as a decoder would.
205    fn encode(&mut self, sample: i16) -> u8 {
206        let mut diff = i32::from(sample) - self.predictor;
207        let mut code = 0;
208        if diff < 0 {
209            code = 8;
210            diff = -diff;
211        }
212        let mut step = self.step();
213        for bit in [4, 2, 1] {
214            if diff >= step {
215                code |= bit;
216                diff -= step;
217            }
218            step >>= 1;
219        }
220        self.decode(code);
221        code
222    }
223
224    /// Moves the state by `code` and returns the sample it stands for.
225    fn decode(&mut self, code: u8) -> i16 {
226        let step = self.step();
227        let mut delta = step >> 3;
228        if code & 4 != 0 {
229            delta += step;
230        }
231        if code & 2 != 0 {
232            delta += step >> 1;
233        }
234        if code & 1 != 0 {
235            delta += step >> 2;
236        }
237        if code & 8 != 0 {
238            delta = -delta;
239        }
240        self.predictor = (self.predictor + delta).clamp(-32_768, 32_767);
241        self.index = (self.index + INDEX_MOVES[usize::from(code & 7)]).clamp(0, 88);
242        // Clamped to the i16 range above.
243        #[allow(clippy::cast_possible_truncation)]
244        let sample = self.predictor as i16;
245        sample
246    }
247}
248
249/// `samples` (mono, [`SAMPLE_RATE`]) as an IMA ADPCM WAV file: blocks of
250/// [`BLOCK_BYTES`], the last padded with silence, and a `fact` chunk with the
251/// real number of samples, to which `pdc` trims it, so a looped track loops
252/// with no gap.
253pub fn adpcm_wav(samples: &[i16]) -> Vec<u8> {
254    let blocks = samples.len().div_ceil(BLOCK_SAMPLES).max(1);
255    let mut data = Vec::with_capacity(blocks * BLOCK_BYTES);
256    let mut coder = Coder::default();
257    for block in 0..blocks {
258        let start = block * BLOCK_SAMPLES;
259        let sample = |i: usize| samples.get(start + i).copied().unwrap_or(0);
260        // The header: the block's first sample as is, and the step index.
261        let first = sample(0);
262        coder.predictor = i32::from(first);
263        data.extend_from_slice(&first.to_le_bytes());
264        data.push(u8::try_from(coder.index).unwrap_or(0));
265        data.push(0);
266        for pair in 0..(BLOCK_BYTES - 4) {
267            let low = coder.encode(sample(1 + pair * 2));
268            let high = coder.encode(sample(2 + pair * 2));
269            // Two 4-bit codes: the first in the low half of the byte.
270            data.push(low + (high << 4));
271        }
272    }
273    let u32_of = |n: usize| u32::try_from(n).unwrap_or(u32::MAX);
274    let block_bytes = u16::try_from(BLOCK_BYTES).unwrap_or(u16::MAX);
275    let block_samples = u16::try_from(BLOCK_SAMPLES).unwrap_or(u16::MAX);
276    // A whole number of blocks a second, rounded down, as ffmpeg writes it.
277    let bytes_per_second = SAMPLE_RATE * u32::from(block_bytes) / u32::from(block_samples);
278    let mut wav = Vec::with_capacity(60 + data.len());
279    wav.extend_from_slice(b"RIFF");
280    wav.extend_from_slice(&u32_of(52 + data.len()).to_le_bytes());
281    wav.extend_from_slice(b"WAVEfmt ");
282    wav.extend_from_slice(&20u32.to_le_bytes());
283    wav.extend_from_slice(&0x11u16.to_le_bytes()); // IMA ADPCM
284    wav.extend_from_slice(&1u16.to_le_bytes()); // mono
285    wav.extend_from_slice(&SAMPLE_RATE.to_le_bytes());
286    wav.extend_from_slice(&bytes_per_second.to_le_bytes());
287    wav.extend_from_slice(&block_bytes.to_le_bytes());
288    wav.extend_from_slice(&4u16.to_le_bytes()); // bits a sample
289    wav.extend_from_slice(&2u16.to_le_bytes()); // the extra bytes below
290    wav.extend_from_slice(&block_samples.to_le_bytes());
291    wav.extend_from_slice(b"fact");
292    wav.extend_from_slice(&4u32.to_le_bytes());
293    wav.extend_from_slice(&u32_of(samples.len()).to_le_bytes());
294    wav.extend_from_slice(b"data");
295    wav.extend_from_slice(&u32_of(data.len()).to_le_bytes());
296    wav.extend_from_slice(&data);
297    wav
298}
299
300/// The cache of baked files under the build's folder `build`.
301pub fn cache_dir(build: &Path) -> PathBuf {
302    build.join(format!("audio-v{FORMAT}"))
303}
304
305/// Bakes `jobs` into `into`, through the cache in `cache`, which holds each
306/// result under its source's checksum and length.
307pub fn bake(jobs: &[Job], cache: &Path, into: &Path) -> Result<(), String> {
308    for job in jobs {
309        let key = format!(
310            "{:08x}-{}.wav",
311            crc32fast::hash(&job.bytes),
312            job.bytes.len()
313        );
314        let cached = cache.join(key);
315        if !cached.is_file() {
316            write(&cached, &adpcm_wav(&decode(&job.name, &job.bytes)?))?;
317        }
318        let to = into.join(&job.out);
319        if let Some(dir) = to.parent() {
320            fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
321        }
322        fs::copy(&cached, &to)
323            .map_err(|e| format!("copy {} to {}: {e}", cached.display(), to.display()))?;
324    }
325    Ok(())
326}
327
328/// Writes `bytes` to `path`, making its folder.
329fn write(path: &Path, bytes: &[u8]) -> Result<(), String> {
330    if let Some(dir) = path.parent() {
331        fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
332    }
333    fs::write(path, bytes).map_err(|e| format!("write {}: {e}", path.display()))
334}
335
336#[cfg(test)]
337mod tests;