1use crate::prelude::*;
16
17mod formats;
18
19use alloc::collections::{BTreeMap, BTreeSet};
20
21use serde::{Deserialize, Serialize};
22
23use crate::error::{ContentError, line_of};
24use crate::ron::parse_ron;
25
26pub use formats::{
27 AudioManifestRon, MAX_SOUND_BYTES, MusicTrack, OggVorbis, OggVorbisTrack, Sound, Wav,
28};
29
30pub const LICENSES: [&str; 4] = ["CC0-1.0", "CC-BY-3.0", "CC-BY-4.0", OWN];
32
33pub const OWN: &str = "Own";
35
36pub const MAX_VOLUME: u8 = 100;
38
39pub const LENGTH_TOLERANCE_MS: u32 = 20;
42
43#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
46#[non_exhaustive]
47pub struct AudioManifest {
48 pub music_fade_ms: u32,
52 pub music_fade_in_ms: u32,
55 pub music_overlap: bool,
59 pub sounds: BTreeMap<String, SoundCue>,
61 pub music: BTreeMap<String, MusicCue>,
63 pub pools: BTreeMap<String, Vec<String>>,
65 pub credits: BTreeMap<String, Credit>,
67}
68
69impl AudioManifest {
70 #[must_use]
73 pub const fn music_switch(&self) -> MusicSwitch {
74 MusicSwitch::new(
75 self.music_fade_ms,
76 self.music_fade_in_ms,
77 self.music_overlap,
78 )
79 }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89#[non_exhaustive]
90pub struct MusicSwitch {
91 pub out_ms: u32,
93 pub in_ms: u32,
96 pub overlap: bool,
99}
100
101impl MusicSwitch {
102 #[must_use]
104 pub const fn new(out_ms: u32, in_ms: u32, overlap: bool) -> Self {
105 Self {
106 out_ms,
107 in_ms,
108 overlap,
109 }
110 }
111
112 #[must_use]
115 pub const fn after_fade(out_ms: u32) -> Self {
116 Self::new(out_ms, 0, false)
117 }
118
119 #[must_use]
122 pub const fn crossfade(ms: u32) -> Self {
123 Self::new(ms, ms, true)
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[non_exhaustive]
131pub struct SoundCue {
132 pub files: Vec<String>,
135 pub volume: u8,
137 pub credit: CreditRef,
139 pub looped: bool,
143}
144
145impl SoundCue {
146 #[must_use]
149 pub const fn new(files: Vec<String>, volume: u8, credit: CreditRef) -> Self {
150 Self {
151 files,
152 volume,
153 credit,
154 looped: false,
155 }
156 }
157
158 #[must_use]
160 pub const fn with_looped(mut self, looped: bool) -> Self {
161 self.looped = looped;
162 self
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct MusicCue {
169 pub file: String,
171 pub volume: u8,
173 pub looped: bool,
175 pub length_ms: u32,
180 pub credit: CreditRef,
182}
183
184fn yes() -> bool {
185 true
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
190pub enum CreditRef {
191 Own,
193 Credit(String),
195 Credits(Vec<String>),
198}
199
200impl CreditRef {
201 pub fn ids(&self) -> &[String] {
203 match self {
204 Self::Own => &[],
205 Self::Credit(id) => core::slice::from_ref(id),
206 Self::Credits(ids) => ids,
207 }
208 }
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
213pub struct Credit {
214 pub title: String,
216 pub author: String,
218 pub source: String,
220 pub license: String,
222 pub tags: Vec<String>,
224 pub note: String,
226}
227
228#[derive(Deserialize)]
229#[serde(deny_unknown_fields)]
230struct RawFile {
231 music_fade_ms: u32,
232 #[serde(default)]
233 music_fade_in_ms: u32,
234 #[serde(default)]
235 music_overlap: bool,
236 sounds: Vec<RawSound>,
237 music: Vec<RawMusic>,
238 pools: Vec<RawPool>,
239 credits: Vec<RawCredit>,
240}
241
242#[derive(Deserialize)]
243#[serde(deny_unknown_fields)]
244struct RawSound {
245 id: String,
246 files: Vec<String>,
247 volume: u8,
248 credit: CreditRef,
249 #[serde(default)]
250 looped: bool,
251}
252
253#[derive(Deserialize)]
254#[serde(deny_unknown_fields)]
255struct RawMusic {
256 id: String,
257 file: String,
258 volume: u8,
259 #[serde(default = "yes")]
260 looped: bool,
261 length_ms: u32,
262 credit: CreditRef,
263}
264
265#[derive(Deserialize)]
266#[serde(deny_unknown_fields)]
267struct RawCredit {
268 id: String,
269 #[serde(default)]
270 title: String,
271 #[serde(default)]
272 author: String,
273 #[serde(default)]
274 source: String,
275 license: String,
276 #[serde(default)]
277 tags: Vec<String>,
278 #[serde(default)]
279 note: String,
280}
281
282#[derive(Deserialize)]
283#[serde(deny_unknown_fields)]
284struct RawPool {
285 id: String,
286 music: Vec<String>,
287}
288
289pub type Bytes = Option<alloc::borrow::Cow<'static, [u8]>>;
291
292#[non_exhaustive]
296pub struct Files<'a> {
297 pub sound: &'a dyn Fn(&str) -> Bytes,
299 pub sound_shown: &'a dyn Fn(&str) -> String,
302 pub sound_left_out: &'a dyn Fn(&str) -> bool,
305 pub music_dir: &'a str,
307 pub music: Option<&'a dyn Fn(&str) -> Bytes>,
309}
310
311impl<'a> Files<'a> {
312 #[must_use]
317 pub const fn new(
318 sound: &'a dyn Fn(&str) -> Bytes,
319 sound_shown: &'a dyn Fn(&str) -> String,
320 sound_left_out: &'a dyn Fn(&str) -> bool,
321 music_dir: &'a str,
322 ) -> Self {
323 Self {
324 sound,
325 sound_shown,
326 sound_left_out,
327 music_dir,
328 music: None,
329 }
330 }
331
332 #[must_use]
335 pub const fn with_music(self, music: Option<&'a dyn Fn(&str) -> Bytes>) -> Self {
336 Self { music, ..self }
337 }
338}
339
340pub const BAKED_SOUND_DIR: &str = "audio";
343
344pub const BAKED_MUSIC_DIR: &str = "music";
346
347#[must_use]
349pub fn without_extension(file: &str) -> &str {
350 let name = file.rfind('/').unwrap_or(0);
351 match file[name..].rfind('.') {
352 Some(dot) => &file[..name + dot],
353 None => file,
354 }
355}
356
357pub const SAMPLE_RATE: u32 = 44_100;
360
361pub fn format_problem(name: &str, bytes: &[u8]) -> Option<String> {
367 let format = if has_extension(name, &["wav"]) {
368 wav_format(bytes).ok_or("not a PCM WAV file")
369 } else {
370 vorbis_format(bytes).ok_or("not an OGG Vorbis file")
371 };
372 match format {
373 Err(why) => Some(why.to_owned()),
374 Ok((channels, rate)) => playable_problem(channels, rate),
375 }
376}
377
378fn playable_problem(channels: u16, rate: u32) -> Option<String> {
382 if !(1..=2).contains(&channels) {
383 Some(format!("{channels} channels; must be mono or stereo"))
384 } else if rate != SAMPLE_RATE {
385 Some(format!("{rate} Hz; must be {SAMPLE_RATE} Hz"))
386 } else {
387 None
388 }
389}
390
391fn u16_at(bytes: &[u8], at: usize) -> Option<u16> {
392 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
393}
394
395fn u32_at(bytes: &[u8], at: usize) -> Option<u32> {
396 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
397}
398
399fn wav_format(bytes: &[u8]) -> Option<(u16, u32)> {
401 if bytes.get(..4)? != b"RIFF" || bytes.get(8..12)? != b"WAVE" {
402 return None;
403 }
404 let mut at = 12;
405 loop {
406 let id = bytes.get(at..at + 4)?;
407 let size = usize::try_from(u32_at(bytes, at + 4)?).ok()?;
408 if id == b"fmt " {
409 let encoding = u16_at(bytes, at + 8)?;
411 return [1, 3]
412 .contains(&encoding)
413 .then_some((u16_at(bytes, at + 10)?, u32_at(bytes, at + 12)?));
414 }
415 at = at.checked_add(size.checked_add(8 + size % 2)?)?;
416 }
417}
418
419fn vorbis_format(bytes: &[u8]) -> Option<(u16, u32)> {
422 if bytes.get(..4)? != b"OggS" {
423 return None;
424 }
425 let packet = 27 + usize::from(*bytes.get(26)?);
426 if bytes.get(packet..packet + 7)? != b"\x01vorbis" {
427 return None;
428 }
429 let channels = u16::from(*bytes.get(packet + 11)?);
430 Some((channels, u32_at(bytes, packet + 12)?))
431}
432
433pub fn ogg_length_ms(bytes: &[u8]) -> Option<u32> {
438 let (_, rate) = vorbis_format(bytes)?;
439 let rate = u64::from(rate);
440 let samples = last_granule(bytes)?;
441 let ms = samples
442 .checked_mul(1000)?
443 .checked_add(rate / 2)?
444 .checked_div(rate)?;
445 u32::try_from(ms).ok()
446}
447
448fn last_granule(bytes: &[u8]) -> Option<u64> {
453 let ends_file = |&at: &usize| {
454 bytes.get(at..at + 4) == Some(b"OggS")
455 && ogg_page_len(bytes, at).and_then(|len| at.checked_add(len)) == Some(bytes.len())
456 };
457 let at = (0..bytes.len()).rev().find(ends_file)?;
458 let granule = u64::from_le_bytes(bytes.get(at + 6..at + 14)?.try_into().ok()?);
459 (granule != u64::MAX).then_some(granule)
460}
461
462fn ogg_page_len(bytes: &[u8], at: usize) -> Option<usize> {
465 let segments = usize::from(*bytes.get(at + 26)?);
466 let table = bytes.get(at + 27..at + 27 + segments)?;
467 let body: usize = table.iter().map(|&s| usize::from(s)).sum();
468 Some(27 + segments + body)
469}
470
471pub fn from_source(
473 file: &str,
474 source: &str,
475 files: &Files<'_>,
476) -> Result<AudioManifest, Vec<ContentError>> {
477 let raw: RawFile = parse_ron(file, source).map_err(|e| vec![e])?;
478 let mut check = Check {
479 file,
480 source,
481 files,
482 errors: Vec::new(),
483 cue_ids: BTreeSet::new(),
484 manifest: AudioManifest {
485 music_fade_ms: raw.music_fade_ms,
486 music_fade_in_ms: raw.music_fade_in_ms,
487 music_overlap: raw.music_overlap,
488 ..AudioManifest::default()
489 },
490 };
491 for c in raw.credits {
492 check.credit(c);
493 }
494 for s in raw.sounds {
495 check.sound(s);
496 }
497 for m in raw.music {
498 check.music(m);
499 }
500 for p in raw.pools {
501 check.pool(p);
502 }
503 if check.errors.is_empty() {
504 Ok(check.manifest)
505 } else {
506 Err(check.errors)
507 }
508}
509
510struct Check<'a> {
512 file: &'a str,
513 source: &'a str,
514 files: &'a Files<'a>,
515 errors: Vec<ContentError>,
516 cue_ids: BTreeSet<String>,
518 manifest: AudioManifest,
519}
520
521impl Check<'_> {
522 fn err(&mut self, id: &str, message: String) {
524 let e = ContentError::new(self.file, message);
525 self.errors
526 .push(match line_of(self.source, &format!("id: \"{id}\"")) {
527 Some(l) => e.at(l, None),
528 None => e,
529 });
530 }
531
532 fn new_cue(&mut self, id: &str) {
533 if id.is_empty() {
534 self.err(id, "a cue id is empty".into());
535 }
536 if !self.cue_ids.insert(id.to_owned()) {
537 self.err(id, format!("duplicate cue id \"{id}\""));
538 }
539 }
540
541 fn volume(&mut self, id: &str, volume: u8) {
542 if volume > MAX_VOLUME {
543 self.err(
544 id,
545 format!("\"{id}\": volume {volume} is over {MAX_VOLUME} (percent)"),
546 );
547 }
548 }
549
550 fn credit_ref(&mut self, id: &str, credit: &CreditRef) {
551 if matches!(credit, CreditRef::Credits(ids) if ids.is_empty()) {
552 self.err(id, format!("\"{id}\" has an empty credit list"));
553 }
554 for c in credit.ids() {
555 if !self.manifest.credits.contains_key(c) {
556 self.err(id, format!("\"{id}\" names unknown credit \"{c}\""));
557 }
558 }
559 }
560
561 fn credit(&mut self, c: RawCredit) {
562 let id = c.id;
563 let entry = Credit {
564 title: c.title,
565 author: c.author,
566 source: c.source,
567 license: c.license,
568 tags: c.tags,
569 note: c.note,
570 };
571 if self.manifest.credits.contains_key(&id) {
572 self.err(&id, format!("duplicate credit id \"{id}\""));
573 }
574 for problem in credit_problems(&entry) {
575 self.err(&id, format!("credit \"{id}\": {problem}"));
576 }
577 self.manifest.credits.insert(id, entry);
578 }
579
580 fn sound(&mut self, s: RawSound) {
581 let id = s.id;
582 self.new_cue(&id);
583 if s.files.is_empty() {
584 self.err(&id, format!("sound \"{id}\" has no files"));
585 }
586 for f in &s.files {
587 let path = (self.files.sound_shown)(f);
588 if !has_extension(f, &["ogg", "wav"]) {
589 self.err(&id, format!("sound \"{id}\": \"{f}\" must be .ogg or .wav"));
590 } else if !(self.files.sound_left_out)(f) {
591 match (self.files.sound)(f) {
592 None => self.err(&id, format!("sound \"{id}\": file {path} not found")),
593 Some(bytes) => {
594 if let Some(why) = format_problem(f, &bytes) {
595 self.err(&id, format!("sound \"{id}\": {path}: {why}"));
596 }
597 }
598 }
599 }
600 }
601 self.volume(&id, s.volume);
602 self.credit_ref(&id, &s.credit);
603 let entry = SoundCue {
604 files: s.files,
605 volume: s.volume,
606 credit: s.credit,
607 looped: s.looped,
608 };
609 self.manifest.sounds.insert(id, entry);
610 }
611
612 fn music(&mut self, m: RawMusic) {
613 let id = m.id;
614 self.new_cue(&id);
615 let f = &m.file;
616 if !has_extension(f, &["ogg"]) {
617 self.err(&id, format!("music \"{id}\": \"{f}\" must be .ogg"));
618 } else if let Some(read) = self.files.music {
619 let dir = self.files.music_dir;
620 match read(f) {
621 None => self.err(&id, format!("music \"{id}\": file {dir}/{f} not found")),
622 Some(bytes) => {
623 let problem = format_problem(f, &bytes)
624 .or_else(|| length_problem(m.length_ms, ogg_length_ms(&bytes)));
625 if let Some(why) = problem {
626 self.err(&id, format!("music \"{id}\": {dir}/{f}: {why}"));
627 }
628 }
629 }
630 }
631 if m.length_ms == 0 {
632 let why = "length_ms is 0; it is the track's length in milliseconds";
633 self.err(&id, format!("music \"{id}\": {why}"));
634 }
635 self.volume(&id, m.volume);
636 self.credit_ref(&id, &m.credit);
637 let entry = MusicCue {
638 file: m.file,
639 volume: m.volume,
640 looped: m.looped,
641 length_ms: m.length_ms,
642 credit: m.credit,
643 };
644 self.manifest.music.insert(id, entry);
645 }
646
647 fn pool(&mut self, RawPool { id, music }: RawPool) {
648 self.new_cue(&id);
649 if music.is_empty() {
650 self.err(&id, format!("pool \"{id}\" is empty"));
651 }
652 for m in &music {
653 if self.manifest.sounds.contains_key(m) {
654 let why = format!("pool \"{id}\" names \"{m}\", a sound; pools hold music cues");
655 self.err(&id, why);
656 } else if !self.manifest.music.contains_key(m) {
657 self.err(
658 &id,
659 format!("pool \"{id}\" names unknown music cue \"{m}\""),
660 );
661 }
662 }
663 self.manifest.pools.insert(id, music);
664 }
665}
666
667const NO_LENGTH: &str = "its length can't be read (no last Ogg page)";
669
670fn length_problem(length_ms: u32, actual: Option<u32>) -> Option<String> {
675 match actual {
676 None => Some(NO_LENGTH.into()),
677 Some(ms) if ms.abs_diff(length_ms) > LENGTH_TOLERANCE_MS => Some(format!(
678 "it plays for {ms} ms, but length_ms is {length_ms}; write length_ms: {ms}"
679 )),
680 Some(_) => None,
681 }
682}
683
684fn credit_problems(c: &Credit) -> Vec<String> {
687 let mut problems = Vec::new();
688 if !LICENSES.contains(&c.license.as_str()) {
689 problems.push(format!(
690 "license \"{}\" is not allowed (ADR-0027: {})",
691 c.license,
692 LICENSES.join(", ")
693 ));
694 }
695 if c.license != OWN {
696 for (field, value) in [("title", &c.title), ("author", &c.author)] {
697 if value.trim().is_empty() {
698 problems.push(format!("{field} is missing"));
699 }
700 }
701 if !c.source.starts_with("https://") && !c.source.starts_with("http://") {
702 problems.push("source must be a web link (http:// or https://)".into());
703 }
704 }
705 problems
706}
707
708fn has_extension(file: &str, allowed: &[&str]) -> bool {
709 file.rsplit_once('.')
710 .is_some_and(|(stem, ext)| !stem.is_empty() && allowed.contains(&ext))
711}
712
713#[cfg(test)]
714mod tests {
715 use alloc::borrow::Cow;
716
717 use super::*;
718
719 #[test]
720 fn without_extension_takes_the_last_names_extension_alone() {
721 assert_eq!(without_extension("sfx/click.wav"), "sfx/click");
722 assert_eq!(without_extension("click.a.ogg"), "click.a");
723 assert_eq!(without_extension("v1.2/click"), "v1.2/click");
724 assert_eq!(without_extension("deep/v1.2/x.ogg"), "deep/v1.2/x");
725 assert_eq!(without_extension("click"), "click");
726 }
727
728 const EMPTY: &str = "(music_fade_ms: 500, sounds: [], music: [], pools: [], credits: [])";
729
730 const FULL: &str = r#"(
731 music_fade_ms: 250,
732 sounds: [
733 (id: "ui_tick", files: ["sfx/ui_tick.wav"], volume: 100, credit: Own),
734 (id: "footfall", files: ["sfx/step_1.ogg", "sfx/step_2.ogg"], volume: 60, credit: Credit("steps")),
735 ],
736 music: [
737 (id: "title", file: "title.ogg", volume: 80, length_ms: 2000, credit: Credit("sunrise")),
738 (id: "sting", file: "sting.ogg", volume: 100, looped: false, length_ms: 2000, credit: Own),
739 ],
740 pools: [
741 (id: "skirmish", music: ["title", "sting"]),
742 ],
743 credits: [
744 (id: "sunrise", title: "New Sunrise", author: "nene",
745 source: "https://opengameart.org/content/new-sunrise", license: "CC0-1.0",
746 tags: ["music", "title"], note: "V1 file"),
747 (id: "steps", title: "Footsteps", author: "someone",
748 source: "https://freesound.org/s/1/", license: "CC-BY-4.0"),
749 (id: "ours", license: "Own"),
750 ],
751)"#;
752
753 fn wav(channels: u16, rate: u32, encoding: u16) -> Vec<u8> {
756 let mut w = b"RIFF\0\0\0\0WAVELIST\x05\0\0\0abcde\0fmt \x10\0\0\0".to_vec();
757 w.extend_from_slice(&encoding.to_le_bytes());
758 w.extend_from_slice(&channels.to_le_bytes());
759 w.extend_from_slice(&rate.to_le_bytes());
760 w.extend_from_slice(&[0; 8]);
761 w
762 }
763
764 fn ogg(channels: u8, rate: u32) -> Vec<u8> {
766 let mut o = b"OggS".to_vec();
767 o.extend_from_slice(&[0; 22]);
768 o.extend_from_slice(&[2, 30, 0]);
769 o.extend_from_slice(b"\x01vorbis\0\0\0\0");
770 o.push(channels);
771 o.extend_from_slice(&rate.to_le_bytes());
772 o
773 }
774
775 fn ogg_page(granule: u64, body: &[u8]) -> Vec<u8> {
778 let mut p = b"OggS\0\x04".to_vec();
779 p.extend_from_slice(&granule.to_le_bytes());
780 p.extend_from_slice(&[0; 12]);
781 p.extend_from_slice(&[1, u8::try_from(body.len()).unwrap_or(u8::MAX)]);
782 p.extend_from_slice(body);
783 p
784 }
785
786 fn ogg_file(rate: u32, samples: u64) -> Vec<u8> {
790 let mut o = ogg(2, rate);
791 o.resize(29 + 30, 0);
792 o.extend_from_slice(&ogg_page(samples, &[0; 40]));
793 o
794 }
795
796 fn good(name: &str) -> Vec<u8> {
799 if has_extension(name, &["wav"]) {
800 wav(2, SAMPLE_RATE, 1)
801 } else {
802 ogg_file(SAMPLE_RATE, u64::from(SAMPLE_RATE) * 2)
803 }
804 }
805
806 fn reading<'a>(
809 sound: &'a dyn Fn(&str) -> Bytes,
810 music: Option<&'a dyn Fn(&str) -> Bytes>,
811 ) -> Files<'a> {
812 Files {
813 sound,
814 sound_shown: &|f: &str| format!("base/audio/{f}"),
815 sound_left_out: &|_: &str| false,
816 music_dir: "music",
817 music,
818 }
819 }
820
821 fn all_files() -> Files<'static> {
822 reading(
823 &|p: &str| Some(Cow::Owned(good(p))),
824 Some(&|f: &str| Some(Cow::Owned(good(f)))),
825 )
826 }
827
828 fn errors(source: &str) -> Vec<String> {
829 errors_with(source, &all_files())
830 }
831
832 fn errors_with(source: &str, files: &Files<'_>) -> Vec<String> {
833 match from_source("a.ron", source, files) {
834 Ok(_) => vec![],
835 Err(e) => e.iter().map(ToString::to_string).collect(),
836 }
837 }
838
839 #[test]
840 fn the_empty_manifest_is_valid() {
841 let m = from_source("a.ron", EMPTY, &all_files());
842 assert_eq!(
843 m,
844 Ok(AudioManifest {
845 music_fade_ms: 500,
846 ..AudioManifest::default()
847 })
848 );
849 }
850
851 #[test]
854 fn the_default_switch_is_the_fade_out_unless_the_file_says_more() {
855 let m = from_source("a.ron", EMPTY, &all_files()).unwrap_or_default();
856 assert_eq!(m.music_switch(), MusicSwitch::after_fade(500));
857 let source = EMPTY.replace(
858 "music_fade_ms: 500,",
859 "music_fade_ms: 800, music_fade_in_ms: 300, music_overlap: true,",
860 );
861 let m = from_source("a.ron", &source, &all_files()).unwrap_or_default();
862 assert_eq!((m.music_fade_in_ms, m.music_overlap), (300, true));
863 assert_eq!(m.music_switch(), MusicSwitch::new(800, 300, true));
864 }
865
866 #[test]
867 fn the_switch_constructors_set_its_three_fields() {
868 let s = MusicSwitch::new(1, 2, true);
869 assert_eq!((s.out_ms, s.in_ms, s.overlap), (1, 2, true));
870 assert_eq!(
871 MusicSwitch::after_fade(500),
872 MusicSwitch::new(500, 0, false)
873 );
874 assert_eq!(
875 MusicSwitch::crossfade(750),
876 MusicSwitch::new(750, 750, true)
877 );
878 }
879
880 #[test]
881 fn a_full_manifest_loads() {
882 let m = from_source("a.ron", FULL, &all_files()).unwrap_or_default();
883 assert_eq!(m.music_fade_ms, 250);
884 assert_eq!(
885 m.sounds
886 .get("footfall")
887 .map(|s| (&s.files, s.volume, &s.credit)),
888 Some((
889 &vec!["sfx/step_1.ogg".to_owned(), "sfx/step_2.ogg".to_owned()],
890 60,
891 &CreditRef::Credit("steps".into())
892 ))
893 );
894 assert_eq!(
895 m.sounds.get("ui_tick").map(|s| (&s.credit, s.looped)),
896 Some((&CreditRef::Own, false)),
897 "a sound plays once unless it says it loops"
898 );
899 let title = m.music.get("title");
900 assert_eq!(
901 title.map(|t| (t.file.as_str(), t.volume)),
902 Some(("title.ogg", 80))
903 );
904 assert_eq!(
905 title.map(|t| t.looped),
906 Some(true),
907 "music loops by default"
908 );
909 assert_eq!(m.music.get("sting").map(|t| t.looped), Some(false));
910 assert_eq!(title.map(|t| t.length_ms), Some(2000));
911 assert_eq!(
912 m.pools.get("skirmish"),
913 Some(&vec!["title".to_owned(), "sting".to_owned()])
914 );
915 let sunrise = m.credits.get("sunrise").cloned().unwrap_or_default();
916 assert_eq!(
917 (sunrise.title.as_str(), sunrise.author.as_str()),
918 ("New Sunrise", "nene")
919 );
920 assert_eq!(sunrise.license, "CC0-1.0");
921 assert_eq!(sunrise.tags, ["music", "title"]);
922 assert_eq!(sunrise.note, "V1 file");
923 assert_eq!(m.credits.get("ours").map(|c| c.title.as_str()), Some(""));
924 }
925
926 #[test]
927 fn a_sound_may_loop() {
928 let source = r#"(music_fade_ms: 0, music: [], pools: [], credits: [], sounds: [
929 (id: "rain", files: ["sfx/ui_tick.wav"], volume: 40, credit: Own, looped: true),
930 ])"#;
931 let m = from_source("a.ron", source, &all_files()).unwrap_or_default();
932 assert_eq!(m.sounds.get("rain").map(|s| s.looped), Some(true));
933 }
934
935 #[test]
936 fn duplicate_ids_are_refused() {
937 let dup = FULL.replace(r#"(id: "sting""#, r#"(id: "ui_tick""#);
938 assert_eq!(
939 errors(&dup.replace(r#""title", "sting""#, r#""title""#)),
940 ["a.ron:4: duplicate cue id \"ui_tick\""]
941 );
942 let pool = FULL.replace(r#"(id: "skirmish""#, r#"(id: "title""#);
943 assert_eq!(errors(&pool), ["a.ron:8: duplicate cue id \"title\""]);
944 let credit = FULL.replace(r#"(id: "ours""#, r#"(id: "steps""#);
945 assert_eq!(errors(&credit), ["a.ron:18: duplicate credit id \"steps\""]);
946 let empty = FULL.replace(r#"(id: "sting""#, r#"(id: """#);
947 assert_eq!(
948 errors(&empty.replace(r#""title", "sting""#, r#""title""#)),
949 ["a.ron:9: a cue id is empty"]
950 );
951 }
952
953 #[test]
954 fn missing_files_are_refused() {
955 let no_step_2 = |p: &str| (p != "sfx/step_2.ogg").then(|| Cow::Owned(good(p)));
956 let no_title = |f: &str| (f != "title.ogg").then(|| Cow::Owned(good(f)));
957 let files = reading(&no_step_2, Some(&no_title));
958 assert_eq!(
959 errors_with(FULL, &files),
960 [
961 "a.ron:5: sound \"footfall\": file base/audio/sfx/step_2.ogg not found",
962 "a.ron:8: music \"title\": file music/title.ogg not found",
963 ]
964 );
965 let files = reading(&|p: &str| Some(Cow::Owned(good(p))), None);
967 assert_eq!(errors_with(FULL, &files), Vec::<String>::new());
968 }
969
970 #[test]
971 fn a_sound_file_the_build_leaves_out_is_not_checked() {
972 let no_step_2 = |p: &str| (p != "sfx/step_2.ogg").then(|| Cow::Owned(good(p)));
973 let files = Files {
974 sound_left_out: &|p: &str| p == "sfx/step_2.ogg",
975 ..reading(&no_step_2, None)
976 };
977 assert_eq!(errors_with(FULL, &files), Vec::<String>::new());
978 let m = from_source("a.ron", FULL, &files).unwrap_or_default();
979 assert!(
980 m.sounds["footfall"]
981 .files
982 .contains(&"sfx/step_2.ogg".to_owned())
983 );
984 let wrong = FULL.replace("sfx/step_2.ogg", "sfx/step_2.mp3");
986 let files = Files {
987 sound_left_out: &|p: &str| p == "sfx/step_2.mp3",
988 ..reading(&no_step_2, None)
989 };
990 assert_eq!(
991 errors_with(&wrong, &files),
992 ["a.ron:5: sound \"footfall\": \"sfx/step_2.mp3\" must be .ogg or .wav"]
993 );
994 }
995
996 #[test]
997 fn unplayable_files_are_refused() {
998 let swapped = |name: &str| {
999 let bytes = if has_extension(name, &["wav"]) {
1000 ogg(2, SAMPLE_RATE)
1001 } else {
1002 wav(2, SAMPLE_RATE, 1)
1003 };
1004 Some(Cow::Owned(bytes))
1005 };
1006 let files = reading(&swapped, Some(&swapped));
1007 assert_eq!(
1008 errors_with(FULL, &files),
1009 [
1010 "a.ron:4: sound \"ui_tick\": base/audio/sfx/ui_tick.wav: not a PCM WAV file",
1011 "a.ron:5: sound \"footfall\": base/audio/sfx/step_1.ogg: not an OGG Vorbis file",
1012 "a.ron:5: sound \"footfall\": base/audio/sfx/step_2.ogg: not an OGG Vorbis file",
1013 "a.ron:8: music \"title\": music/title.ogg: not an OGG Vorbis file",
1014 "a.ron:9: music \"sting\": music/sting.ogg: not an OGG Vorbis file",
1015 ]
1016 );
1017 }
1018
1019 #[test]
1020 fn file_headers_must_be_44_1_khz_mono_or_stereo() {
1021 let ok = |name, bytes: Vec<u8>| format_problem(name, &bytes);
1022 assert_eq!(ok("a.wav", wav(1, 44_100, 1)), None);
1023 assert_eq!(ok("a.wav", wav(2, 44_100, 3)), None, "float WAV");
1024 assert_eq!(ok("a.ogg", ogg(1, 44_100)), None);
1025 assert_eq!(ok("a.ogg", ogg(2, 44_100)), None);
1026 assert_eq!(
1027 ok("a.wav", wav(2, 48_000, 1)).as_deref(),
1028 Some("48000 Hz; must be 44100 Hz")
1029 );
1030 assert_eq!(
1031 ok("a.ogg", ogg(2, 22_050)).as_deref(),
1032 Some("22050 Hz; must be 44100 Hz")
1033 );
1034 assert_eq!(
1035 ok("a.ogg", ogg(6, 44_100)).as_deref(),
1036 Some("6 channels; must be mono or stereo")
1037 );
1038 assert_eq!(
1039 ok("a.wav", wav(0, 44_100, 1)).as_deref(),
1040 Some("0 channels; must be mono or stereo")
1041 );
1042 let not_wav = Some("not a PCM WAV file");
1043 assert_eq!(ok("a.wav", wav(2, 44_100, 2)).as_deref(), not_wav, "ADPCM");
1044 assert_eq!(
1045 ok("a.wav", b"RIFF\0\0\0\0WAVE".to_vec()).as_deref(),
1046 not_wav
1047 );
1048 assert_eq!(
1049 ok("a.wav", b"RIFX\0\0\0\0WAVE".to_vec()).as_deref(),
1050 not_wav
1051 );
1052 assert_eq!(
1053 ok("a.wav", b"RIFF\0\0\0\0AVI ".to_vec()).as_deref(),
1054 not_wav
1055 );
1056 let mut rifx = wav(2, 44_100, 1);
1058 rifx[3] = b'X';
1059 assert_eq!(ok("a.wav", rifx).as_deref(), not_wav);
1060 let mut avi = wav(2, 44_100, 1);
1061 avi[8..12].copy_from_slice(b"AVI ");
1062 assert_eq!(ok("a.wav", avi).as_deref(), not_wav);
1063 let mut huge = wav(2, 44_100, 1);
1064 huge[16..20].copy_from_slice(&u32::MAX.to_le_bytes());
1065 assert_eq!(ok("a.wav", huge).as_deref(), not_wav);
1066 assert_eq!(ok("a.wav", Vec::new()).as_deref(), not_wav);
1067 let not_ogg = Some("not an OGG Vorbis file");
1068 let mut opus = ogg(2, 44_100);
1069 opus[30..36].copy_from_slice(b"opus!!");
1070 assert_eq!(ok("a.ogg", opus).as_deref(), not_ogg);
1071 assert_eq!(ok("a.ogg", b"OggS".to_vec()).as_deref(), not_ogg);
1072 assert_eq!(ok("a.ogg", b"ID3\x03".to_vec()).as_deref(), not_ogg);
1073 let short = ogg(2, 44_100)[..40].to_vec();
1074 assert_eq!(ok("a.ogg", short).as_deref(), not_ogg);
1075 }
1076
1077 #[test]
1078 fn an_ogg_length_is_its_last_granule_over_its_sample_rate() {
1079 assert_eq!(ogg_length_ms(&ogg_file(44_100, 5_898_057)), Some(133_743));
1081 assert_eq!(ogg_length_ms(&ogg_file(44_100, 44_100)), Some(1000));
1082 assert_eq!(ogg_length_ms(&ogg_file(22_050, 44_100)), Some(2000));
1083 assert_eq!(ogg_length_ms(&ogg_file(44_100, 0)), Some(0));
1084 assert_eq!(ogg_length_ms(&ogg_file(2000, 2)), Some(1));
1086 assert_eq!(ogg_length_ms(&ogg_file(2000, 3)), Some(2));
1087 assert_eq!(ogg_length_ms(&ogg_file(3000, 4)), Some(1));
1088 assert_eq!(ogg_length_ms(&ogg_file(3000, 5)), Some(2));
1089 let mut two = ogg_file(44_100, 44_100);
1091 two.extend_from_slice(&ogg_page(88_200, b"tail"));
1092 assert_eq!(ogg_length_ms(&two), Some(2000));
1093 let mut bare = ogg_file(44_100, 44_100);
1095 let mut page = ogg_page(132_300, &[]);
1096 page.truncate(27);
1097 page[26] = 0;
1098 bare.extend_from_slice(&page);
1099 assert_eq!(ogg_length_ms(&bare), Some(3000));
1100 }
1101
1102 #[test]
1103 fn oggs_inside_the_audio_data_is_not_a_page() {
1104 let mut body = b"OggS".to_vec();
1107 body.resize(60, 0);
1108 let mut o = ogg_file(44_100, 1);
1109 o.extend_from_slice(&ogg_page(44_100, &body));
1110 assert_eq!(ogg_length_ms(&o), Some(1000));
1111 let mut o = ogg_file(44_100, 1);
1113 o.extend_from_slice(&ogg_page(88_200, b"....OggS"));
1114 assert_eq!(ogg_length_ms(&o), Some(2000));
1115 }
1116
1117 #[test]
1118 fn a_file_without_a_readable_end_has_no_length() {
1119 let whole = ogg_file(44_100, 44_100);
1120 assert_eq!(ogg_length_ms(&whole), Some(1000));
1121 assert_eq!(ogg_length_ms(&whole[..whole.len() - 1]), None);
1123 let mut junk = whole.clone();
1124 junk.push(0);
1125 assert_eq!(ogg_length_ms(&junk), None);
1126 assert_eq!(ogg_length_ms(&ogg_file(44_100, u64::MAX)), None);
1128 assert_eq!(ogg_length_ms(&ogg_file(44_100, u64::MAX - 1)), None);
1130 assert_eq!(ogg_length_ms(&ogg_file(1, u64::from(u32::MAX))), None);
1131 assert_eq!(ogg_length_ms(&ogg_file(0, 44_100)), None);
1132 assert_eq!(ogg_length_ms(&wav(2, 44_100, 1)), None);
1134 assert_eq!(ogg_length_ms(&ogg_page(44_100, &[0; 40])), None);
1135 assert_eq!(ogg_length_ms(&[]), None);
1136 assert_eq!(ogg_length_ms(&ogg(2, 44_100)), None);
1138 }
1139
1140 fn with_sting_length(length_ms: u32) -> String {
1142 FULL.replace(
1143 "length_ms: 2000, credit: Own",
1144 &format!("length_ms: {length_ms}, credit: Own"),
1145 )
1146 }
1147
1148 #[test]
1149 fn a_music_length_must_match_its_file() {
1150 for near in [1980, 2020] {
1152 let errors = errors(&with_sting_length(near));
1153 assert_eq!(errors, Vec::<String>::new(), "{near}");
1154 }
1155 for wrong in [1979, 2021] {
1156 assert_eq!(
1157 errors(&with_sting_length(wrong)),
1158 [format!(
1159 "a.ron:9: music \"sting\": music/sting.ogg: it plays for 2000 ms, \
1160 but length_ms is {wrong}; write length_ms: 2000"
1161 )]
1162 );
1163 }
1164 let headers = reading(
1166 &|p: &str| Some(Cow::Owned(good(p))),
1167 Some(&|_: &str| Some(Cow::Owned(ogg(2, SAMPLE_RATE)))),
1168 );
1169 assert_eq!(
1170 errors_with(FULL, &headers),
1171 [
1172 "a.ron:8: music \"title\": music/title.ogg: its length can't be read (no last Ogg page)",
1173 "a.ron:9: music \"sting\": music/sting.ogg: its length can't be read (no last Ogg page)",
1174 ]
1175 );
1176 }
1177
1178 #[test]
1179 fn a_music_length_of_zero_is_refused() {
1180 let unseen = reading(&|p: &str| Some(Cow::Owned(good(p))), None);
1182 assert_eq!(
1183 errors_with(&with_sting_length(1), &unseen),
1184 Vec::<String>::new()
1185 );
1186 assert_eq!(
1187 errors_with(&with_sting_length(0), &unseen),
1188 ["a.ron:9: music \"sting\": length_ms is 0; it is the track's length in milliseconds"]
1189 );
1190 let missing = FULL.replace("looped: false, length_ms: 2000, ", "looped: false, ");
1192 assert_eq!(errors(&missing).len(), 1);
1193 }
1194
1195 #[test]
1196 fn file_formats_are_checked() {
1197 let mp3 = FULL.replace("title.ogg", "title.mp3");
1198 assert_eq!(
1199 errors(&mp3),
1200 ["a.ron:8: music \"title\": \"title.mp3\" must be .ogg"]
1201 );
1202 let wav_music = FULL.replace("title.ogg", "title.wav");
1203 assert_eq!(errors(&wav_music).len(), 1);
1204 let flac = FULL.replace("ui_tick.wav", "ui_tick.flac");
1205 assert_eq!(
1206 errors(&flac),
1207 ["a.ron:4: sound \"ui_tick\": \"sfx/ui_tick.flac\" must be .ogg or .wav"]
1208 );
1209 assert_eq!(errors(&FULL.replace("sfx/ui_tick.wav", ".wav")).len(), 1);
1210 assert_eq!(errors(&FULL.replace("sfx/ui_tick.wav", "wav")).len(), 1);
1211 let none = FULL.replace(r#"["sfx/ui_tick.wav"]"#, "[]");
1212 assert_eq!(errors(&none), ["a.ron:4: sound \"ui_tick\" has no files"]);
1213 }
1214
1215 #[test]
1216 fn unknown_licenses_are_refused() {
1217 let nc = FULL.replace("\"CC-BY-4.0\"", "\"CC-BY-NC-4.0\"");
1218 assert_eq!(
1219 errors(&nc),
1220 [
1221 "a.ron:18: credit \"steps\": license \"CC-BY-NC-4.0\" is not allowed \
1222 (ADR-0027: CC0-1.0, CC-BY-3.0, CC-BY-4.0, Own)"
1223 ]
1224 );
1225 let by3 = FULL.replace("\"CC-BY-4.0\"", "\"CC-BY-3.0\"");
1227 assert_eq!(errors(&by3), Vec::<String>::new());
1228 }
1229
1230 #[test]
1231 fn third_party_credits_need_title_author_and_link() {
1232 let bare = FULL.replace(
1233 r#"title: "Footsteps", author: "someone","#,
1234 r#"title: " ", author: "","#,
1235 );
1236 let bare = bare.replace("https://freesound.org/s/1/", "freesound 1");
1237 assert_eq!(
1238 errors(&bare),
1239 [
1240 "a.ron:18: credit \"steps\": title is missing",
1241 "a.ron:18: credit \"steps\": author is missing",
1242 "a.ron:18: credit \"steps\": source must be a web link (http:// or https://)",
1243 ]
1244 );
1245 let http = FULL.replace("https://freesound", "http://freesound");
1246 assert_eq!(errors(&http), Vec::<String>::new());
1247 }
1248
1249 #[test]
1250 fn credits_must_exist() {
1251 let unknown = FULL.replace(r#"Credit("steps")"#, r#"Credit("stepz")"#);
1252 assert_eq!(
1253 errors(&unknown),
1254 ["a.ron:5: \"footfall\" names unknown credit \"stepz\""]
1255 );
1256 let unknown = FULL.replace(r#"Credit("sunrise")"#, r#"Credit("dawn")"#);
1257 assert_eq!(
1258 errors(&unknown),
1259 ["a.ron:8: \"title\" names unknown credit \"dawn\""]
1260 );
1261 }
1262
1263 #[test]
1264 fn a_cue_may_name_several_credits() {
1265 let several = FULL.replace(r#"Credit("steps")"#, r#"Credits(["steps", "sunrise"])"#);
1266 let m = from_source("a.ron", &several, &all_files()).unwrap_or_default();
1267 let ids = m.sounds.get("footfall").map(|s| s.credit.ids().to_vec());
1268 assert_eq!(ids, Some(vec!["steps".to_owned(), "sunrise".to_owned()]));
1269 let got = CreditRef::Own.ids();
1270 assert!(got.is_empty(), "{got:?}");
1271 let unknown = FULL.replace(r#"Credit("steps")"#, r#"Credits(["steps", "stepz"])"#);
1272 assert_eq!(
1273 errors(&unknown),
1274 ["a.ron:5: \"footfall\" names unknown credit \"stepz\""]
1275 );
1276 let empty = FULL.replace(r#"Credit("steps")"#, "Credits([])");
1277 assert_eq!(
1278 errors(&empty),
1279 ["a.ron:5: \"footfall\" has an empty credit list"]
1280 );
1281 }
1282
1283 #[test]
1284 fn pools_hold_only_music_cues() {
1285 let sound = FULL.replace(r#""title", "sting""#, r#""title", "ui_tick""#);
1286 assert_eq!(
1287 errors(&sound),
1288 ["a.ron:12: pool \"skirmish\" names \"ui_tick\", a sound; pools hold music cues"]
1289 );
1290 let unknown = FULL.replace(r#""title", "sting""#, r#""title", "nope""#);
1291 assert_eq!(
1292 errors(&unknown),
1293 ["a.ron:12: pool \"skirmish\" names unknown music cue \"nope\""]
1294 );
1295 let empty = FULL.replace(r#""title", "sting""#, "");
1296 assert_eq!(errors(&empty), ["a.ron:12: pool \"skirmish\" is empty"]);
1297 }
1298
1299 #[test]
1300 fn volumes_are_percentages() {
1301 assert_eq!(
1302 errors(&FULL.replace("volume: 60", "volume: 100")),
1303 Vec::<String>::new()
1304 );
1305 assert_eq!(
1306 errors(&FULL.replace("volume: 60", "volume: 101")),
1307 ["a.ron:5: \"footfall\": volume 101 is over 100 (percent)"]
1308 );
1309 assert_eq!(
1310 errors(&FULL.replace("volume: 80", "volume: 101")),
1311 ["a.ron:8: \"title\": volume 101 is over 100 (percent)"]
1312 );
1313 }
1314
1315 #[test]
1316 fn syntax_and_unknown_fields_are_refused() {
1317 assert_eq!(errors("(").len(), 1);
1318 assert_eq!(
1319 errors(&FULL.replace("volume: 80", "volume: 80, loud: true")).len(),
1320 1
1321 );
1322 assert_eq!(errors(&EMPTY.replace("pools: [], ", "")).len(), 1);
1323 }
1324
1325 #[test]
1326 fn the_constructors_give_the_literals() {
1327 fn none(_: &str) -> Bytes {
1328 None
1329 }
1330 fn shown(name: &str) -> String {
1331 format!("base/audio/{name}")
1332 }
1333 fn kept(_: &str) -> bool {
1334 false
1335 }
1336 const FILES: Files<'static> = Files::new(&none, &shown, &kept, "music");
1338 const WITH_MUSIC: Files<'static> = FILES.with_music(Some(&none));
1339 assert_eq!(
1340 SoundCue::new(alloc::vec!["sfx/a.wav".into()], 60, CreditRef::Own),
1341 SoundCue {
1342 files: alloc::vec!["sfx/a.wav".into()],
1343 volume: 60,
1344 credit: CreditRef::Own,
1345 looped: false,
1346 }
1347 );
1348 assert!(
1349 SoundCue::new(Vec::new(), 60, CreditRef::Own)
1350 .with_looped(true)
1351 .looped
1352 );
1353 assert_eq!((FILES.music_dir, FILES.music.is_none()), ("music", true));
1354 assert_eq!((FILES.sound_shown)("x.wav"), "base/audio/x.wav");
1355 assert!(!(FILES.sound_left_out)("x.wav"));
1356 assert!((FILES.sound)("x.wav").is_none());
1357 assert!(
1358 WITH_MUSIC
1359 .music
1360 .is_some_and(|music| music("a.ogg").is_none())
1361 );
1362 }
1363}