From 531dc5db187ae815d30f3e4e14133264a8f91458 Mon Sep 17 00:00:00 2001 From: Michael Wilson Date: Mon, 26 Jan 2026 21:13:21 -0500 Subject: [PATCH] Switch to Symphonia. The backing decoder has been switched to Symphonia which supports many more file formats than just WAV. --- .licensure.yml | 6 + Cargo.lock | 227 ++++++++ Cargo.toml | 5 +- README.md | 28 +- assets/1Channel44.1k.aac | Bin 0 -> 270 bytes assets/1Channel44.1k.flac | Bin 0 -> 8408 bytes assets/1Channel44.1k.mp3 | Bin 0 -> 2478 bytes assets/1Channel44.1k.ogg | Bin 0 -> 3756 bytes assets/1Channel44.1k_alac.m4a | Bin 0 -> 22960 bytes assets/2Channel44.1k_alac.m4a | Bin 0 -> 45088 bytes .../a-really-cool-song/backing-track.mp3 | Bin 0 -> 13903 bytes .../a-really-cool-song/backing-track.wav | Bin 132990 -> 0 bytes examples/songs/a-really-cool-song/song.yaml | 4 +- .../songs/a-really-fast-one/backing-track.aac | Bin 0 -> 316 bytes .../songs/a-really-fast-one/backing-track.wav | Bin 132990 -> 0 bytes examples/songs/a-really-fast-one/song.yaml | 4 +- .../another-cool-song/backing-track.flac | Bin 0 -> 8428 bytes .../songs/another-cool-song/backing-track.wav | Bin 132990 -> 0 bytes examples/songs/another-cool-song/song.yaml | 4 +- examples/songs/dsl-light-show-song/song.mid | 0 examples/songs/dsl-light-show-song/song.mp3 | Bin 0 -> 13903 bytes examples/songs/dsl-light-show-song/song.yaml | 3 +- examples/songs/outro-tape/backing-track.m4a | Bin 0 -> 45088 bytes examples/songs/outro-tape/backing-track.wav | Bin 132990 -> 0 bytes examples/songs/outro-tape/song.yaml | 4 +- examples/songs/sound-check/backing-track.flac | Bin 0 -> 8428 bytes examples/songs/sound-check/backing-track.wav | Bin 132990 -> 0 bytes examples/songs/sound-check/song.yaml | 4 +- examples/songs/the-slow-one/backing-track.ogg | Bin 0 -> 4524 bytes examples/songs/the-slow-one/backing-track.wav | Bin 132990 -> 0 bytes examples/songs/the-slow-one/song.yaml | 4 +- src/audio/cpal.rs | 3 +- src/audio/sample_source.rs | 9 +- src/audio/sample_source/audio.rs | 549 ++++++++++++++++++ src/audio/sample_source/channel_mapped.rs | 63 +- src/audio/sample_source/error.rs | 8 +- src/audio/sample_source/factory.rs | 37 +- src/audio/sample_source/memory.rs | 4 +- src/audio/sample_source/tests.rs | 223 ++++++- src/audio/sample_source/traits.rs | 39 +- src/audio/sample_source/transcoder.rs | 14 +- src/audio/sample_source/wav.rs | 202 ------- src/config/audio.rs | 22 +- src/main.rs | 7 +- src/songs.rs | 67 ++- 45 files changed, 1136 insertions(+), 404 deletions(-) create mode 100644 assets/1Channel44.1k.aac create mode 100644 assets/1Channel44.1k.flac create mode 100644 assets/1Channel44.1k.mp3 create mode 100644 assets/1Channel44.1k.ogg create mode 100644 assets/1Channel44.1k_alac.m4a create mode 100644 assets/2Channel44.1k_alac.m4a create mode 100644 examples/songs/a-really-cool-song/backing-track.mp3 delete mode 100644 examples/songs/a-really-cool-song/backing-track.wav create mode 100644 examples/songs/a-really-fast-one/backing-track.aac delete mode 100644 examples/songs/a-really-fast-one/backing-track.wav create mode 100644 examples/songs/another-cool-song/backing-track.flac delete mode 100644 examples/songs/another-cool-song/backing-track.wav create mode 100644 examples/songs/dsl-light-show-song/song.mid create mode 100644 examples/songs/dsl-light-show-song/song.mp3 create mode 100644 examples/songs/outro-tape/backing-track.m4a delete mode 100644 examples/songs/outro-tape/backing-track.wav create mode 100644 examples/songs/sound-check/backing-track.flac delete mode 100644 examples/songs/sound-check/backing-track.wav create mode 100644 examples/songs/the-slow-one/backing-track.ogg delete mode 100644 examples/songs/the-slow-one/backing-track.wav create mode 100644 src/audio/sample_source/audio.rs delete mode 100644 src/audio/sample_source/wav.rs diff --git a/.licensure.yml b/.licensure.yml index 2d15c450..2197e8c9 100644 --- a/.licensure.yml +++ b/.licensure.yml @@ -10,6 +10,12 @@ excludes: - Cargo.toml - .*\.yaml - .*\.wav + - .*\.mp3 + - .*\.flac + - .*\.ogg + - .*\.aac + - .*\.alac + - .*\.m4a - .*\.mid - lcov.info - .*\.tosc diff --git a/Cargo.lock b/Cargo.lock index f07f16da..0786c292 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-stream" version = "0.3.6" @@ -253,6 +259,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + [[package]] name = "byteorder" version = "1.5.0" @@ -626,6 +638,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fastrand" version = "2.3.0" @@ -1111,6 +1129,15 @@ dependencies = [ "libc", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.7.3" @@ -1200,6 +1227,7 @@ dependencies = [ "serde_yml", "shh", "spin_sleep", + "symphonia", "tempfile", "thiserror 2.0.17", "tokio", @@ -2104,6 +2132,201 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-caf", + "symphonia-format-isomp4", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-caf" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" @@ -2448,10 +2671,14 @@ version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] diff --git a/Cargo.toml b/Cargo.toml index c3a921be..19534ec7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ cpal = "0.15.3" rayon = "1.8.0" num_cpus = "1.16.0" duration-string = "0.5.2" -hound = "3.5.1" +symphonia = { version = "0.5", features = ["all"] } midir = "0.10.1" midly = "0.5.3" nodi = { version = "1.0.3", features = ["hybrid-sleep", "midir"] } @@ -50,13 +50,14 @@ tokio = { version = "1.42.0", features = [ tonic = "0.12.3" tonic-reflection = "0.12.3" tracing = "0.1.41" -tracing-subscriber = "0.3.19" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } crossbeam-channel = "0.5.15" pest = "2.7" pest_derive = "2.7" [dev-dependencies] tempfile = "3.14.0" +hound = "3.5.1" [build-dependencies] prost-build = "0.13.4" diff --git a/README.md b/README.md index c3c8ee93..240621d5 100644 --- a/README.md +++ b/README.md @@ -81,10 +81,25 @@ MIDI device, you would use the string `UltraLite-mk5`. ## File formats +### Configuration files + `mtrack` now uses [config-rs](https://github.com/rust-cli/config-rs) for configuration parsing, which means we should support any of the configuration file formats that it supports. Testing for anything other than YAML is limited at the moment. +### Audio files + +`mtrack` supports a wide variety of audio formats through the [symphonia](https://github.com/pdeljanov/Symphonia) library. Supported formats include: + +- **WAV** (PCM, various bit depths) +- **FLAC** (Free Lossless Audio Codec) +- **MP3** (MPEG Audio Layer III) +- **OGG Vorbis** +- **AAC** (Advanced Audio Coding) +- **ALAC** (Apple Lossless, in M4A containers) + +All audio files are automatically transcoded to match your audio device's configuration (sample rate, bit depth, and format). Files can be mixed and matched within a song - for example, you can use a WAV file for your click track and an MP3 file for your backing track. + ## Structure of an mtrack repository and supporting files ### Song repository @@ -163,6 +178,7 @@ tracks: file: Backing Tracks.wav file_channel: 2 # Our keys file has two channels, but we're only interested in one. +# Note: You can use any supported audio format (WAV, MP3, FLAC, OGG, AAC, ALAC, etc.) - name: keys file: Keys.wav file_channel: 1 @@ -205,7 +221,7 @@ $ mtrack songs --init /mnt/song-storage ``` This will create a file called `song.yaml` in each subfolder of `/mnt/storage`. The name of the -subfolder determines the song's name. WAV files are used as tracks. The track's name is +subfolder determines the song's name. Audio files (WAV, MP3, FLAC, OGG, AAC, ALAC, etc.) are used as tracks. The track's name is determined using the file name and the number of channels within the file. MIDI files are used as MIDI playback, MIDI files that start with `dmx_` will be used as light shows. You can edit the generated files to refine the settings to your needs. @@ -247,12 +263,11 @@ audio: # Run `mtrack devices` to see a list of the devices that mtrack recognizes. device: UltraLite-mk5 - # (Optional) The buffer size to use for background reads. Defaults to 1024 samples. + # (Optional) The buffer size for decoded audio samples. This controls how many samples + # per channel are buffered internally before being returned. Larger values reduce I/O + # operations but use more memory. Defaults to 1024 samples per channel. buffer_size: 1024 - # (Optional) The threshold for triggering background reads. Defaults to 256 samples. - buffer_threshold: 256 - # (Optional) The sample rate to use for the audio device. Defaults to 44100. sample_rate: 44100 @@ -768,7 +783,7 @@ lighting: - file: "lighting/outro.light" # Multiple shows can be referenced tracks: - name: "backing-track" - file: "backing-track.wav" + file: "backing-track.wav" # Can be WAV, MP3, FLAC, OGG, AAC, ALAC, etc. ``` The `.light` files use the DSL format and can reference logical groups defined in your `mtrack.yaml`: @@ -1415,6 +1430,7 @@ DMX is expected to be well supported through OLA, but the devices that have been - Entec DMX USB Pro - RatPac Satellite (Art-Net and sACN) +- Cinelex Skycast A (sACN) ### General disclaimer diff --git a/assets/1Channel44.1k.aac b/assets/1Channel44.1k.aac new file mode 100644 index 0000000000000000000000000000000000000000..8dc33ce1fdd74fba8e7ef317c9828d295a60d44a GIT binary patch literal 270 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 AmH+?% literal 0 HcmV?d00001 diff --git a/assets/1Channel44.1k_alac.m4a b/assets/1Channel44.1k_alac.m4a new file mode 100644 index 0000000000000000000000000000000000000000..9276fa12cc2ae00bbee9bfa332bc131838b4a6d9 GIT binary patch literal 22960 zcmeI)F>ljA6u|M9v_uuEz!DuIlp#bBVrVKiRF&93V(0)Ys1*YPrst?p?KrY8kqTo6 zz6NV0J_a9y55T~{0CRX}JFOE2Bv$`Vitc>({Lc5gA<_*eBAt=Fn7%xC))i@rtH)Uq z>u)F`Tcb1#MNU4)Ca@x#B6qhM+D}0M0R#|0009ILKmY**5I_KddI&5&Eq-z&P>;Q4 zl?Wh!00IagfB*srAbOjsz+gG!Z}m0R#|0009ILKmY**5U7tpaU`%Kvi+PZ2J5r! ztP}wR5cq$A#igg0&@_L1qPq#K*{ zwSUon)05uu*ZjS~nTgUOg-DjnCd;;~U+c3u3MM8BT}-1Lk0&E-cv-xXv~P<3j!8o| z?uz@2?O7Ui%l5a-zK^v1CbQXcGhED35Cx}NyOMu;*=oDmeX5rJO^hefesudz9_eq2 z_wZdA)y-^<;^K89Yineyr*%i?t{KIO1oP41aPRTnzVH75f#-D) literal 0 HcmV?d00001 diff --git a/assets/2Channel44.1k_alac.m4a b/assets/2Channel44.1k_alac.m4a new file mode 100644 index 0000000000000000000000000000000000000000..670642e6cc4a8b6cb9285603ba2524da363b9a4c GIT binary patch literal 45088 zcmeI&J#W)M7{Ku-X{jO<(F`3Tl)*$97@8^#6;dSzkQh1?76f8o!1Np?svSr6B~oGR zz-OrhpP>x>7!0s6Fv5luo^zb$g&`YKqyI_Cy*%gV?tV8}vWV0=_H6L%;E5+v6j%4s zIMQERL`t0`2t+)vUD!$K(hIAbn#CP# z{17Sp5`6=izJz4)g^rFa^&c-LS?(3}f!{XqPc`tyo@Q%q%uUL(>AJI0$Of0zS+Q;- zFgDc`wIEDwo=P+1c_#^@$n<=Td68K@`zL+*dR2dUl|9+)nlQ<8NE#3O)3&Q$Xx|+9 zeG>+5PN5q0`WcM&b_!?)=x~ zd2e!i^$X*l>F8bKXAiri(r&d6nyvk2`%pWL3mdy7Yx6UX{ZZ#(d+)*CeyjBt|J{bW literal 0 HcmV?d00001 diff --git a/examples/songs/a-really-cool-song/backing-track.mp3 b/examples/songs/a-really-cool-song/backing-track.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..1364df2023f7c6a4b2742c03fb6e60ea228e07f2 GIT binary patch literal 13903 zcmeI2F-yZx6os!?#6c|x;wB=lElJu6x)u9|LKSUO$ReUO!46g^#Z`BIfO~Nkada1F ze}Hs#ba3lX&s8$Im2kb>kCvCbKzeS@N2blGJZ@C z|6J?6oIB>`uG3U$xTr;=K9Ww+E%ro=Cyn%P-gZW}6fWshi z2a$cBPf|AL)_xBwqwq9&O}B0RFHZo*ol*XisEfp~dvTp;2A_MJ;;?)}lxtM?+>fd4 zsW9c+O(m1vB(oP&Sc7|u2!0Ajmfu-n<^bPA^$L0V4 literal 0 HcmV?d00001 diff --git a/examples/songs/a-really-cool-song/backing-track.wav b/examples/songs/a-really-cool-song/backing-track.wav deleted file mode 100644 index 8f41580499aaff4cac00a15715379813bdc9b8a7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 132990 zcmeIwv1-Bq5C-5gh~nnx;NS}ck~B+4!3f<-1_if>I#k*z6(6M!(MR#2>f#*eCi!l; zgC7p=kCP07y0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U pAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0tB8+;18`F5sCl+ diff --git a/examples/songs/a-really-cool-song/song.yaml b/examples/songs/a-really-cool-song/song.yaml index 05935c77..2909ecfc 100755 --- a/examples/songs/a-really-cool-song/song.yaml +++ b/examples/songs/a-really-cool-song/song.yaml @@ -109,8 +109,8 @@ tracks: - name: click file: click.wav - name: backing-track-l - file: backing-track.wav + file: backing-track.mp3 # MP3 format example file_channel: 1 - name: backing-track-r - file: backing-track.wav + file: backing-track.mp3 # MP3 format example file_channel: 2 \ No newline at end of file diff --git a/examples/songs/a-really-fast-one/backing-track.aac b/examples/songs/a-really-fast-one/backing-track.aac new file mode 100644 index 0000000000000000000000000000000000000000..998161e3a2950ab4626ddb830d9fab138fe7fc47 GIT binary patch literal 316 zcmezWF`$9@{-1kH3_giv$!11+hK72E1_lgH3LFO|EdB#!8TbED6kti{ks0Nng#iGK Cu817~ literal 0 HcmV?d00001 diff --git a/examples/songs/a-really-fast-one/backing-track.wav b/examples/songs/a-really-fast-one/backing-track.wav deleted file mode 100644 index 8f41580499aaff4cac00a15715379813bdc9b8a7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 132990 zcmeIwv1-Bq5C-5gh~nnx;NS}ck~B+4!3f<-1_if>I#k*z6(6M!(MR#2>f#*eCi!l; zgC7p=kCP07y0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U pAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0tB8+;18`F5sCl+ diff --git a/examples/songs/a-really-fast-one/song.yaml b/examples/songs/a-really-fast-one/song.yaml index bed88cd2..82be1928 100755 --- a/examples/songs/a-really-fast-one/song.yaml +++ b/examples/songs/a-really-fast-one/song.yaml @@ -9,8 +9,8 @@ tracks: - name: click file: click.wav - name: backing-track-l - file: backing-track.wav + file: backing-track.aac # AAC format example file_channel: 1 - name: backing-track-r - file: backing-track.wav + file: backing-track.aac # AAC format example file_channel: 2 \ No newline at end of file diff --git a/examples/songs/another-cool-song/backing-track.flac b/examples/songs/another-cool-song/backing-track.flac new file mode 100644 index 0000000000000000000000000000000000000000..a2cfcf8fe971b624e00411e5e0bb97f31f9884cc GIT binary patch literal 8428 zcmYfENpxmlU{Dfb5CT#H3=BeCN1O{77{ZjU%Ncf;UhsP*{C?tL<=J(rEDQ`8JU}%* ziDhYKMta72h6V;eF$O^(PR&csPf1OQPpY&Ha&-)F4dMq1rX-f6+8P-cndllA>Ka)B z&1NXc%uS6iN=-{G0_sS$H4uU8EwV*6w~;{sC^HI1Ltr!nMnhmU1V%$(Gz3ONU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$( zGz5lv2>kyMDZ^k7^F`oJ5TDTk##ibE@tF)^{OuP(d}cEk|H#w-KPqKd_!N)8ME;oo E00iJVYXATM literal 0 HcmV?d00001 diff --git a/examples/songs/another-cool-song/backing-track.wav b/examples/songs/another-cool-song/backing-track.wav deleted file mode 100644 index 8f41580499aaff4cac00a15715379813bdc9b8a7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 132990 zcmeIwv1-Bq5C-5gh~nnx;NS}ck~B+4!3f<-1_if>I#k*z6(6M!(MR#2>f#*eCi!l; zgC7p=kCP07y0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U pAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0tB8+;18`F5sCl+ diff --git a/examples/songs/another-cool-song/song.yaml b/examples/songs/another-cool-song/song.yaml index b6935412..377fee4f 100755 --- a/examples/songs/another-cool-song/song.yaml +++ b/examples/songs/another-cool-song/song.yaml @@ -20,8 +20,8 @@ tracks: - name: click file: click.wav - name: backing-track-l - file: backing-track.wav + file: backing-track.flac # FLAC format example file_channel: 1 - name: backing-track-r - file: backing-track.wav + file: backing-track.flac # FLAC format example file_channel: 2 \ No newline at end of file diff --git a/examples/songs/dsl-light-show-song/song.mid b/examples/songs/dsl-light-show-song/song.mid new file mode 100644 index 00000000..e69de29b diff --git a/examples/songs/dsl-light-show-song/song.mp3 b/examples/songs/dsl-light-show-song/song.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..1364df2023f7c6a4b2742c03fb6e60ea228e07f2 GIT binary patch literal 13903 zcmeI2F-yZx6os!?#6c|x;wB=lElJu6x)u9|LKSUO$ReUO!46g^#Z`BIfO~Nkada1F ze}Hs#ba3lX&s8$Im2kb>kCvCbKzeS@N2blGJZ@C z|6J?6oIB>`uG3U$xTr;=K9Ww+E%ro=Cyn%P-gZW}6fWshi z2a$cBPf|AL)_xBwqwq9&O}B0RFHZo*ol*XisEfp~dvTp;2A_MJ;;?)}lxtM?+>fd4 zsW9c+O(m1vB(oP&Sc7|u2!0Ajmfu-n<^bPA^$L0V4 literal 0 HcmV?d00001 diff --git a/examples/songs/dsl-light-show-song/song.yaml b/examples/songs/dsl-light-show-song/song.yaml index 50dd6024..f61c5097 100644 --- a/examples/songs/dsl-light-show-song/song.yaml +++ b/examples/songs/dsl-light-show-song/song.yaml @@ -7,4 +7,5 @@ lighting: file: "lighting/outro.light" tracks: - name: "Main Track" - audio_file: "song.wav" + file: "song.mp3" # MP3 format example - can use WAV, MP3, FLAC, OGG, AAC, ALAC, etc. + file_channel: 1 diff --git a/examples/songs/outro-tape/backing-track.m4a b/examples/songs/outro-tape/backing-track.m4a new file mode 100644 index 0000000000000000000000000000000000000000..670642e6cc4a8b6cb9285603ba2524da363b9a4c GIT binary patch literal 45088 zcmeI&J#W)M7{Ku-X{jO<(F`3Tl)*$97@8^#6;dSzkQh1?76f8o!1Np?svSr6B~oGR zz-OrhpP>x>7!0s6Fv5luo^zb$g&`YKqyI_Cy*%gV?tV8}vWV0=_H6L%;E5+v6j%4s zIMQERL`t0`2t+)vUD!$K(hIAbn#CP# z{17Sp5`6=izJz4)g^rFa^&c-LS?(3}f!{XqPc`tyo@Q%q%uUL(>AJI0$Of0zS+Q;- zFgDc`wIEDwo=P+1c_#^@$n<=Td68K@`zL+*dR2dUl|9+)nlQ<8NE#3O)3&Q$Xx|+9 zeG>+5PN5q0`WcM&b_!?)=x~ zd2e!i^$X*l>F8bKXAiri(r&d6nyvk2`%pWL3mdy7Yx6UX{ZZ#(d+)*CeyjBt|J{bW literal 0 HcmV?d00001 diff --git a/examples/songs/outro-tape/backing-track.wav b/examples/songs/outro-tape/backing-track.wav deleted file mode 100644 index 8f41580499aaff4cac00a15715379813bdc9b8a7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 132990 zcmeIwv1-Bq5C-5gh~nnx;NS}ck~B+4!3f<-1_if>I#k*z6(6M!(MR#2>f#*eCi!l; zgC7p=kCP07y0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U pAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0tB8+;18`F5sCl+ diff --git a/examples/songs/outro-tape/song.yaml b/examples/songs/outro-tape/song.yaml index dc039893..a33c3efe 100755 --- a/examples/songs/outro-tape/song.yaml +++ b/examples/songs/outro-tape/song.yaml @@ -7,8 +7,8 @@ midi_event: tracks: - name: backing-track-l - file: backing-track.wav + file: backing-track.m4a # ALAC/Apple Lossless format example (M4A container) file_channel: 1 - name: backing-track-r - file: backing-track.wav + file: backing-track.m4a # ALAC/Apple Lossless format example (M4A container) file_channel: 2 \ No newline at end of file diff --git a/examples/songs/sound-check/backing-track.flac b/examples/songs/sound-check/backing-track.flac new file mode 100644 index 0000000000000000000000000000000000000000..a2cfcf8fe971b624e00411e5e0bb97f31f9884cc GIT binary patch literal 8428 zcmYfENpxmlU{Dfb5CT#H3=BeCN1O{77{ZjU%Ncf;UhsP*{C?tL<=J(rEDQ`8JU}%* ziDhYKMta72h6V;eF$O^(PR&csPf1OQPpY&Ha&-)F4dMq1rX-f6+8P-cndllA>Ka)B z&1NXc%uS6iN=-{G0_sS$H4uU8EwV*6w~;{sC^HI1Ltr!nMnhmU1V%$(Gz3ONU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$( zGz5lv2>kyMDZ^k7^F`oJ5TDTk##ibE@tF)^{OuP(d}cEk|H#w-KPqKd_!N)8ME;oo E00iJVYXATM literal 0 HcmV?d00001 diff --git a/examples/songs/sound-check/backing-track.wav b/examples/songs/sound-check/backing-track.wav deleted file mode 100644 index 8f41580499aaff4cac00a15715379813bdc9b8a7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 132990 zcmeIwv1-Bq5C-5gh~nnx;NS}ck~B+4!3f<-1_if>I#k*z6(6M!(MR#2>f#*eCi!l; zgC7p=kCP07y0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U pAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0tB8+;18`F5sCl+ diff --git a/examples/songs/sound-check/song.yaml b/examples/songs/sound-check/song.yaml index 324fc736..8d098c6d 100755 --- a/examples/songs/sound-check/song.yaml +++ b/examples/songs/sound-check/song.yaml @@ -7,8 +7,8 @@ midi_event: tracks: - name: backing-track-l - file: backing-track.wav + file: backing-track.flac # FLAC format example file_channel: 1 - name: backing-track-r - file: backing-track.wav + file: backing-track.flac # FLAC format example file_channel: 2 \ No newline at end of file diff --git a/examples/songs/the-slow-one/backing-track.ogg b/examples/songs/the-slow-one/backing-track.ogg new file mode 100644 index 0000000000000000000000000000000000000000..eea7b457c1777f7ab2fe96dbe244f56201841d0b GIT binary patch literal 4524 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 CPBxqX literal 0 HcmV?d00001 diff --git a/examples/songs/the-slow-one/backing-track.wav b/examples/songs/the-slow-one/backing-track.wav deleted file mode 100644 index 8f41580499aaff4cac00a15715379813bdc9b8a7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 132990 zcmeIwv1-Bq5C-5gh~nnx;NS}ck~B+4!3f<-1_if>I#k*z6(6M!(MR#2>f#*eCi!l; zgC7p=kCP07y0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U pAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0tB8+;18`F5sCl+ diff --git a/examples/songs/the-slow-one/song.yaml b/examples/songs/the-slow-one/song.yaml index c16db74a..e5ec45e6 100755 --- a/examples/songs/the-slow-one/song.yaml +++ b/examples/songs/the-slow-one/song.yaml @@ -57,8 +57,8 @@ tracks: - name: click file: click.wav - name: backing-track-l - file: backing-track.wav + file: backing-track.ogg # OGG Vorbis format example file_channel: 1 - name: backing-track-r - file: backing-track.wav + file: backing-track.ogg # OGG Vorbis format example file_channel: 2 \ No newline at end of file diff --git a/src/audio/cpal.rs b/src/audio/cpal.rs index 0221fa65..1f6f3a9f 100644 --- a/src/audio/cpal.rs +++ b/src/audio/cpal.rs @@ -338,7 +338,7 @@ impl OutputManager { }; // Create the output stream based on the target format - // Map hound::SampleFormat to the appropriate CPAL stream type + // Map SampleFormat to the appropriate CPAL stream type let stream_result = if target_format.sample_format == crate::audio::SampleFormat::Float { @@ -575,7 +575,6 @@ impl AudioDevice for Device { mappings, self.target_format.clone(), self.audio_config.buffer_size(), - self.audio_config.buffer_threshold(), )?; // Add all sources to the output manager diff --git a/src/audio/sample_source.rs b/src/audio/sample_source.rs index 54d88b47..02391ced 100644 --- a/src/audio/sample_source.rs +++ b/src/audio/sample_source.rs @@ -11,13 +11,13 @@ // You should have received a copy of the GNU General Public License along with // this program. If not, see . // +pub mod audio; pub mod channel_mapped; pub mod error; pub mod factory; pub mod memory; pub mod traits; pub mod transcoder; -pub mod wav; #[cfg(test)] mod tests; @@ -26,11 +26,8 @@ mod tests; pub use channel_mapped::create_channel_mapped_sample_source; #[cfg(test)] pub use channel_mapped::ChannelMappedSource; -pub use factory::{create_sample_source_from_file, create_sample_source_from_file_with_seek}; -pub use traits::{ChannelMappedSampleSource, SampleSource}; -pub use wav::WavSampleSource; +pub use factory::create_sample_source_from_file; +pub use traits::ChannelMappedSampleSource; #[cfg(test)] pub use memory::MemorySampleSource; -#[cfg(test)] -pub use traits::SampleSourceTestExt; diff --git a/src/audio/sample_source/audio.rs b/src/audio/sample_source/audio.rs new file mode 100644 index 00000000..007fabe8 --- /dev/null +++ b/src/audio/sample_source/audio.rs @@ -0,0 +1,549 @@ +// Copyright (C) 2026 Michael Wilson +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +// +use std::fs::File; +use std::path::Path; + +use symphonia::core::audio::{AudioBuffer, AudioBufferRef, Signal}; +use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; +use symphonia::core::errors::Error as SymphoniaError; +use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo}; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; +use symphonia::default::get_codecs; +use symphonia::default::get_probe; + +use super::error::SampleSourceError; +use super::traits::SampleSource; + +#[cfg(test)] +use super::traits::SampleSourceTestExt; + +/// A sample source that reads audio files (WAV, MP3, FLAC, etc.) and provides scaled samples +/// This uses symphonia to decode various audio formats - no transcoding logic +pub struct AudioSampleSource { + format_reader: Box, + decoder: Box, + track_id: u32, + is_finished: bool, + // Buffered reading to reduce I/O operations + sample_buffer: Vec, + buffer_position: usize, + buffer_size: usize, + // Leftover samples from the last decoded packet that didn't fit in the buffer + leftover_samples: Vec, + // WAV / PCM metadata for scaling & reporting + bits_per_sample: u16, + channels: u16, + sample_rate: u32, + sample_format: crate::audio::SampleFormat, + duration: std::time::Duration, +} + +impl SampleSource for AudioSampleSource { + fn next_sample(&mut self) -> Result, SampleSourceError> { + if self.is_finished { + return Ok(None); + } + + // Check if we need to refill the buffer + if self.buffer_position >= self.sample_buffer.len() { + self.refill_buffer()?; + + // If buffer is still empty after refill, we're finished + if self.sample_buffer.is_empty() { + self.is_finished = true; + return Ok(None); + } + } + + // Return the next sample from the buffer + let sample = self.sample_buffer[self.buffer_position]; + self.buffer_position += 1; + Ok(Some(sample)) + } + + fn channel_count(&self) -> u16 { + self.channels + } + + fn sample_rate(&self) -> u32 { + self.sample_rate + } + + fn bits_per_sample(&self) -> u16 { + self.bits_per_sample + } + + fn sample_format(&self) -> crate::audio::SampleFormat { + self.sample_format + } + + fn duration(&self) -> Option { + Some(self.duration) + } +} + +impl AudioSampleSource { + /// Creates a new audio sample source from a file path + /// Supports WAV, MP3, FLAC, and other formats supported by symphonia + pub fn from_file>( + path: P, + start_time: Option, + buffer_size: usize, + ) -> Result { + // Open the file + let file = File::open(&path)?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + // Create a hint to help the format registry guess the format + let mut hint = Hint::new(); + if let Some(extension) = path.as_ref().extension().and_then(|ext| ext.to_str()) { + hint.with_extension(extension); + } + + // Probe the format + let meta_opts: MetadataOptions = Default::default(); + let fmt_opts: FormatOptions = Default::default(); + let probe = get_probe(); + let file_path = path.as_ref().to_string_lossy().to_string(); + let probed = probe + .format(&hint, mss, &fmt_opts, &meta_opts) + .map_err(|e| { + SampleSourceError::SampleConversionFailed(format!("'{}': {}", file_path, e)) + })?; + + let mut format_reader = probed.format; + + // Find the first audio track (need to do this before moving format_reader) + let track = format_reader + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .ok_or_else(|| { + SampleSourceError::SampleConversionFailed("No audio track found".to_string()) + })?; + + let track_id = track.id; + let params = &track.codec_params; + + // Get the sample rate and bits per sample + let sample_rate = params.sample_rate.ok_or_else(|| { + SampleSourceError::SampleConversionFailed("Sample rate not specified".to_string()) + })?; + let bits_per_sample = params.bits_per_sample.unwrap_or(16) as u16; // Default to 16-bit if not specified + + // Determine sample format from codec + let sample_format = if params.codec == symphonia::core::codecs::CODEC_TYPE_PCM_F32LE + || params.codec == symphonia::core::codecs::CODEC_TYPE_PCM_F32BE + || params.codec == symphonia::core::codecs::CODEC_TYPE_PCM_F64LE + || params.codec == symphonia::core::codecs::CODEC_TYPE_PCM_F64BE + { + crate::audio::SampleFormat::Float + } else { + crate::audio::SampleFormat::Int + }; + + // Calculate duration + let duration = if let Some(n_frames) = params.n_frames { + std::time::Duration::from_secs_f64(n_frames as f64 / sample_rate as f64) + } else { + // If duration is unknown, we'll set it to zero and update it as we read + std::time::Duration::ZERO + }; + + // Create the decoder + let decoder_opts: DecoderOptions = Default::default(); + let mut decoder = get_codecs().make(params, &decoder_opts).map_err(|e| { + SampleSourceError::SampleConversionFailed(format!("'{}': {}", file_path, e)) + })?; + + // Determine channels. Prefer container/codec metadata, but if it's + // missing we proactively decode the first audio packet to derive the + // actual channel count. If we still can't determine it, we fail. + // + // Here, a value of 0 means "unspecified" and is only used inside this + // constructor; if it remains 0 after probing, we return an error and + // never construct an AudioSampleSource with channel_count == 0. + let channels = params.channels.map(|c| c.count() as u16).unwrap_or(0); + + // In tests we sometimes want to exercise the channel‑detection path + // even for formats where the container/codec already reports the + // channel count. This is controlled by an env var so production + // behaviour is unaffected. + let force_detect = cfg!(test) && std::env::var("MTRACK_FORCE_DETECT_CHANNELS").is_ok(); + + let (channels, initial_leftover) = if channels > 0 && !force_detect { + (channels, Vec::new()) + } else { + Self::detect_channels_and_prime_buffer( + format_reader.as_mut(), + decoder.as_mut(), + track_id, + )? + }; + + let mut source = Self { + format_reader, + decoder, + track_id, + is_finished: false, + sample_buffer: Vec::with_capacity(buffer_size * channels as usize), + buffer_position: 0, + buffer_size, + leftover_samples: initial_leftover, + bits_per_sample, + channels, + sample_rate, + sample_format, + duration, + }; + + // If start_time is provided, seek to that position + if let Some(start) = start_time { + // Any samples decoded while probing for channels belong to the + // beginning of the stream. If the caller requested a non‑zero + // start time, those samples are no longer relevant and must not + // be returned ahead of the seek target. + source.leftover_samples.clear(); + + use symphonia::core::units::Time; + let seek_to = SeekTo::Time { + time: Time::from(start), + track_id: Some(track_id), + }; + source.format_reader.seek(SeekMode::Accurate, seek_to)?; + } + + Ok(source) + } + + /// Helper function to read the next packet with common error handling. + /// Returns: + /// - `Ok(Some(packet))` if a packet was successfully read + /// - `Ok(None)` if EOF was reached (UnexpectedEof or DecodeError) + /// - `Err(...)` if an error occurred that should be returned + /// + /// Note: ResetRequired errors are propagated to callers so they can reset the decoder. + fn read_next_packet( + format_reader: &mut dyn FormatReader, + ) -> Result, SampleSourceError> { + match format_reader.next_packet() { + Ok(packet) => Ok(Some(packet)), + Err(SymphoniaError::ResetRequired) => { + // ResetRequired is propagated to callers so they can reset the decoder + Err(SampleSourceError::AudioError(SymphoniaError::ResetRequired)) + } + Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + // End of file - we're done reading + Ok(None) + } + Err(SymphoniaError::DecodeError(_)) => { + // Some decoders return DecodeError at EOF instead of IoError + Ok(None) + } + Err(e) => Err(SampleSourceError::AudioError(e)), + } + } + + /// Refills the sample buffer by reading a chunk from the audio file + fn refill_buffer(&mut self) -> Result<(), SampleSourceError> { + // Clear the buffer and reset position + self.sample_buffer.clear(); + self.buffer_position = 0; + + let mut samples_read = 0; + let target_samples = self.buffer_size * self.channels as usize; + // First, add any leftover samples from the previous buffer fill + if !self.leftover_samples.is_empty() { + let to_take = target_samples.min(self.leftover_samples.len()); + self.sample_buffer + .extend_from_slice(&self.leftover_samples[..to_take]); + samples_read += to_take; + + // Keep the rest as leftover for next time + if self.leftover_samples.len() > to_take { + self.leftover_samples.drain(..to_take); + } else { + self.leftover_samples.clear(); + } + // If leftover samples completely filled the buffer, we're done for this iteration + if samples_read >= target_samples { + return Ok(()); + } + } + + // Read packets until we reach the end of the file or fill our buffer. + // + // NOTE: We intentionally *do not* special‑case "no progress" here based on + // samples_read. Some formats (e.g. Ogg/Vorbis) have multiple header packets + // that decode to zero PCM frames before the first audio packet. Treating + // those as "no progress" would cause us to bail out early and never see + // the real audio data. + loop { + // Read the next packet + let packet = match Self::read_next_packet(self.format_reader.as_mut()) { + Ok(Some(packet)) => packet, + Ok(None) => { + // EOF reached + break; + } + Err(SampleSourceError::AudioError(SymphoniaError::ResetRequired)) => { + // The codec needs to be reset after a discontinuity (e.g., after seeking). + // Reset the decoder and continue reading the next packet. + self.decoder.reset(); + continue; + } + Err(e) => { + // For very small files, some errors might indicate EOF + // Check if we've read any samples - if not, this might be a false error + if samples_read == 0 && self.sample_buffer.is_empty() { + break; + } + return Err(e); + } + }; + + // Only process packets from the track we're interested in + if packet.track_id() != self.track_id { + continue; + } + + // Decode the packet + let decoded = match self.decoder.decode(&packet) { + Ok(decoded) => decoded, + Err(SymphoniaError::ResetRequired) => { + // The codec needs to be reset. Reset and retry decoding the same packet. + self.decoder.reset(); + match self.decoder.decode(&packet) { + Ok(decoded) => decoded, + Err(e) => return Err(SampleSourceError::AudioError(e)), + } + } + Err(e) => return Err(SampleSourceError::AudioError(e)), + }; + + // Convert the decoded buffer to f32 samples. Channel count is + // established during construction; here we only care about the + // sample data. + let (samples, _decoded_channels) = Self::decode_buffer_to_f32(decoded)?; + + // Add samples to the buffer + if !samples.is_empty() { + let remaining = target_samples.saturating_sub(samples_read); + if remaining > 0 { + let to_take = remaining.min(samples.len()); + self.sample_buffer.extend_from_slice(&samples[..to_take]); + samples_read += to_take; + + // If we have more samples than we can fit, save them as leftover + if samples.len() > to_take { + self.leftover_samples.extend_from_slice(&samples[to_take..]); + // Buffer is full for this iteration, break to avoid infinite loops + // Leftover samples will be used in the next refill_buffer call + break; + } + + // For very small files, if we got a small number of samples (less than 32 total), + // the file is likely exhausted. Break immediately to avoid calling next_packet() again + // which might block indefinitely. This handles edge cases with tiny files + // (like the test file with only 3 samples). + // We use a fixed threshold (32) rather than channels-based to catch all tiny files. + // This is critical for preventing hangs on very small audio files. + if samples.len() < 32 { + break; + } + } else { + // Buffer is full, save all samples as leftover and break + self.leftover_samples.extend_from_slice(&samples); + break; + } + } + + // If we've filled our target buffer, break for this iteration + // This prevents infinite loops while still allowing us to read all samples + // across multiple refill_buffer calls + if samples_read >= target_samples { + break; + } + } + + // If we read no samples and have no leftovers, we're at the end of the file + if samples_read == 0 && self.leftover_samples.is_empty() { + self.is_finished = true; + } + + Ok(()) + } + + /// When codec/channel metadata is missing, read and decode packets until we + /// see the first audio buffer for our track, and derive the channel count + /// from that buffer. The decoded samples are returned so they can be used + /// as the initial contents of the sample buffer. + fn detect_channels_and_prime_buffer( + format_reader: &mut dyn FormatReader, + decoder: &mut dyn symphonia::core::codecs::Decoder, + track_id: u32, + ) -> Result<(u16, Vec), SampleSourceError> { + loop { + let packet = match Self::read_next_packet(format_reader) { + Ok(Some(packet)) => packet, + Ok(None) => { + // EOF reached + break; + } + Err(SampleSourceError::AudioError(SymphoniaError::ResetRequired)) => { + // The codec needs to be reset after a discontinuity. + // Reset the decoder and continue reading the next packet. + decoder.reset(); + continue; + } + Err(e) => return Err(e), + }; + + if packet.track_id() != track_id { + continue; + } + + let decoded = match decoder.decode(&packet) { + Ok(decoded) => decoded, + Err(SymphoniaError::ResetRequired) => { + // The codec needs to be reset. Reset and retry decoding the same packet. + decoder.reset(); + match decoder.decode(&packet) { + Ok(decoded) => decoded, + Err(e) => return Err(SampleSourceError::AudioError(e)), + } + } + Err(e) => return Err(SampleSourceError::AudioError(e)), + }; + + let (samples, channels) = Self::decode_buffer_to_f32(decoded)?; + if channels > 0 && !samples.is_empty() { + return Ok((channels as u16, samples)); + } + } + + Err(SampleSourceError::SampleConversionFailed( + "Channels not specified".to_string(), + )) + } + + /// Converts a decoded AudioBufferRef to a Vec of interleaved samples + /// and returns the channel count as observed in the decoded buffer. + fn decode_buffer_to_f32( + decoded: AudioBufferRef, + ) -> Result<(Vec, usize), SampleSourceError> { + match decoded { + AudioBufferRef::F32(buf) => Ok(Self::interleave_planar_samples(&buf, |sample| sample)), + AudioBufferRef::F64(buf) => Ok(Self::interleave_planar_samples(&buf, |sample| { + sample as f32 + })), + AudioBufferRef::S8(buf) => Ok(Self::interleave_planar_samples(&buf, |sample| { + Self::scale_s8(sample) + })), + AudioBufferRef::S16(buf) => Ok(Self::interleave_planar_samples(&buf, |sample| { + Self::scale_s16(sample) + })), + AudioBufferRef::S24(buf) => Ok(Self::interleave_planar_samples(&buf, |sample| { + Self::scale_s24(sample.inner()) + })), + AudioBufferRef::S32(buf) => Ok(Self::interleave_planar_samples(&buf, |sample| { + Self::scale_s32(sample) + })), + AudioBufferRef::U8(buf) => Ok(Self::interleave_planar_samples(&buf, |sample| { + Self::scale_u8(sample) + })), + AudioBufferRef::U16(buf) => Ok(Self::interleave_planar_samples(&buf, |sample| { + Self::scale_u16(sample) + })), + AudioBufferRef::U24(buf) => Ok(Self::interleave_planar_samples(&buf, |sample| { + Self::scale_u24(sample.inner()) + })), + AudioBufferRef::U32(buf) => Ok(Self::interleave_planar_samples(&buf, |sample| { + Self::scale_u32(sample) + })), + } + } + + /// Helper to interleave planar samples from a generic AudioBuffer. + /// The closure receives a single sample value and returns the f32 sample value. + fn interleave_planar_samples(buf: &AudioBuffer, convert: F) -> (Vec, usize) + where + T: symphonia::core::sample::Sample, + F: Fn(T) -> f32, + { + let frames = buf.frames(); + let channels = buf.spec().channels.count(); + let planes = buf.planes(); + let mut samples = Vec::with_capacity(frames * channels); + for frame_idx in 0..frames { + for ch_idx in 0..channels { + samples.push(convert(planes.planes()[ch_idx][frame_idx])); + } + } + (samples, channels) + } + + // Scaling helpers for all integer formats. These are `pub(crate)` so they can + // be validated directly in unit tests. + + #[inline] + pub(crate) fn scale_s8(sample: i8) -> f32 { + sample as f32 / (1i64 << 7) as f32 + } + + #[inline] + pub(crate) fn scale_s16(sample: i16) -> f32 { + sample as f32 / (1i64 << 15) as f32 + } + + #[inline] + pub(crate) fn scale_s24(sample: i32) -> f32 { + sample as f32 / (1i64 << 23) as f32 + } + + #[inline] + pub(crate) fn scale_s32(sample: i32) -> f32 { + sample as f32 / (1i64 << 31) as f32 + } + + #[inline] + pub(crate) fn scale_u8(sample: u8) -> f32 { + (sample as f32 / u8::MAX as f32) * 2.0 - 1.0 + } + + #[inline] + pub(crate) fn scale_u16(sample: u16) -> f32 { + (sample as f32 / u16::MAX as f32) * 2.0 - 1.0 + } + + #[inline] + pub(crate) fn scale_u24(sample: u32) -> f32 { + let max = (1u32 << 24) - 1; + (sample as f32 / max as f32) * 2.0 - 1.0 + } + + #[inline] + pub(crate) fn scale_u32(sample: u32) -> f32 { + (sample as f32 / u32::MAX as f32) * 2.0 - 1.0 + } +} + +#[cfg(test)] +impl SampleSourceTestExt for AudioSampleSource { + fn is_finished(&self) -> bool { + self.is_finished + } +} diff --git a/src/audio/sample_source/channel_mapped.rs b/src/audio/sample_source/channel_mapped.rs index d87273d5..58905569 100644 --- a/src/audio/sample_source/channel_mapped.rs +++ b/src/audio/sample_source/channel_mapped.rs @@ -13,7 +13,7 @@ // use crate::audio::TargetFormat; -use super::error::TranscodingError; +use super::error::SampleSourceError; use super::traits::{ChannelMappedSampleSource, SampleSource}; use super::transcoder::AudioTranscoder; @@ -40,27 +40,10 @@ impl ChannelMappedSource { } impl ChannelMappedSampleSource for ChannelMappedSource { - fn next_sample(&mut self) -> Result, TranscodingError> { + fn next_sample(&mut self) -> Result, SampleSourceError> { self.source.next_sample() } - fn next_frame(&mut self, output: &mut [f32]) -> Result, TranscodingError> { - let channel_count = self.source_channel_count as usize; - if output.len() < channel_count { - return Err(TranscodingError::SampleConversionFailed(format!( - "Output buffer too small: need {} samples", - channel_count - ))); - } - for out in output.iter_mut().take(channel_count) { - match self.source.next_sample()? { - Some(sample) => *out = sample, - None => return Ok(None), - } - } - Ok(Some(channel_count)) - } - fn channel_mappings(&self) -> &Vec> { &self.channel_mappings } @@ -70,51 +53,18 @@ impl ChannelMappedSampleSource for ChannelMappedSource { } } -/// A wrapper that makes Box work with AudioTranscoder -struct SampleSourceWrapper { - source: Box, -} - -impl SampleSource for SampleSourceWrapper { - fn next_sample(&mut self) -> Result, TranscodingError> { - self.source.next_sample() - } - - fn channel_count(&self) -> u16 { - self.source.channel_count() - } - - fn sample_rate(&self) -> u32 { - self.source.sample_rate() - } - - fn bits_per_sample(&self) -> u16 { - self.source.bits_per_sample() - } - - fn sample_format(&self) -> crate::audio::SampleFormat { - self.source.sample_format() - } - - fn duration(&self) -> Option { - self.source.duration() - } -} - /// Create a ChannelMappedSampleSource from a generic SampleSource pub fn create_channel_mapped_sample_source( source: Box, target_format: TargetFormat, channel_mappings: Vec>, - _buffer_size: usize, - _buffer_threshold: usize, -) -> Result, TranscodingError> { +) -> Result, SampleSourceError> { let source_format = TargetFormat::new( source.sample_rate(), source.sample_format(), source.bits_per_sample(), ) - .map_err(|e| TranscodingError::SampleConversionFailed(e.to_string()))?; + .map_err(|e| SampleSourceError::SampleConversionFailed(e.to_string()))?; let needs_transcoding = source_format.sample_rate != target_format.sample_rate || source_format.sample_format != target_format.sample_format @@ -122,10 +72,9 @@ pub fn create_channel_mapped_sample_source( let channel_count = source.channel_count(); let sample_source: Box = if needs_transcoding { - // Create a wrapper that can be used with AudioTranscoder - let wrapper = SampleSourceWrapper { source }; + // Box now implements SampleSource directly, so we can use it with AudioTranscoder let transcoder = - AudioTranscoder::new(wrapper, &source_format, &target_format, channel_count)?; + AudioTranscoder::new(source, &source_format, &target_format, channel_count)?; Box::new(transcoder) } else { source diff --git a/src/audio/sample_source/error.rs b/src/audio/sample_source/error.rs index 851df334..45f80e26 100644 --- a/src/audio/sample_source/error.rs +++ b/src/audio/sample_source/error.rs @@ -11,17 +11,17 @@ // You should have received a copy of the GNU General Public License along with // this program. If not, see . // -/// Error types for transcoding operations +/// Error types for sample source operations #[derive(Debug, thiserror::Error)] -pub enum TranscodingError { +pub enum SampleSourceError { #[error("Resampling failed: {0}Hz -> {1}Hz")] ResamplingFailed(u32, u32), #[error("Sample conversion failed for {0}")] SampleConversionFailed(String), - #[error("WAV file error: {0}")] - WavError(#[from] hound::Error), + #[error("Audio file error: {0}")] + AudioError(#[from] symphonia::core::errors::Error), #[error("IO error: {0}")] IoError(#[from] std::io::Error), diff --git a/src/audio/sample_source/factory.rs b/src/audio/sample_source/factory.rs index 9656e083..463679ab 100644 --- a/src/audio/sample_source/factory.rs +++ b/src/audio/sample_source/factory.rs @@ -11,40 +11,17 @@ // You should have received a copy of the GNU General Public License along with // this program. If not, see . // -use std::path::Path; - -use super::error::TranscodingError; +use super::audio::AudioSampleSource; +use super::error::SampleSourceError; use super::traits::SampleSource; -use super::wav::WavSampleSource; /// Create a SampleSource from a file, automatically detecting the file type -pub fn create_sample_source_from_file>( - path: P, -) -> Result, TranscodingError> { - create_sample_source_from_file_with_seek(path, None) -} - -pub fn create_sample_source_from_file_with_seek>( +pub fn create_sample_source_from_file>( path: P, start_time: Option, -) -> Result, TranscodingError> { + buffer_size: usize, +) -> Result, SampleSourceError> { let path = path.as_ref(); - - // Get file extension to determine type - let extension = path - .extension() - .and_then(|ext| ext.to_str()) - .unwrap_or("") - .to_lowercase(); - - match extension.as_str() { - "wav" => { - let wav_source = WavSampleSource::from_file_with_seek(path, start_time)?; - Ok(Box::new(wav_source)) - } - _ => Err(TranscodingError::SampleConversionFailed(format!( - "Unsupported file format: {}", - extension - ))), - } + let audio_source = AudioSampleSource::from_file(path, start_time, buffer_size)?; + Ok(Box::new(audio_source)) } diff --git a/src/audio/sample_source/memory.rs b/src/audio/sample_source/memory.rs index 950774eb..7e03c7f1 100644 --- a/src/audio/sample_source/memory.rs +++ b/src/audio/sample_source/memory.rs @@ -14,7 +14,7 @@ // These imports are used in the impl blocks below, but the linter may not see them // in non-test mode due to #[cfg(test)] blocks #[allow(unused_imports)] -use super::error::TranscodingError; +use super::error::SampleSourceError; #[allow(unused_imports)] use super::traits::SampleSource; @@ -46,7 +46,7 @@ impl MemorySampleSource { #[cfg(test)] impl SampleSource for MemorySampleSource { - fn next_sample(&mut self) -> Result, TranscodingError> { + fn next_sample(&mut self) -> Result, SampleSourceError> { if self.current_index >= self.samples.len() { Ok(None) } else { diff --git a/src/audio/sample_source/tests.rs b/src/audio/sample_source/tests.rs index 0f665399..60842beb 100644 --- a/src/audio/sample_source/tests.rs +++ b/src/audio/sample_source/tests.rs @@ -13,12 +13,67 @@ // #[cfg(test)] mod tests { + use crate::audio::sample_source::audio::AudioSampleSource; + use crate::audio::sample_source::create_sample_source_from_file; + use crate::audio::sample_source::memory::MemorySampleSource; + use crate::audio::sample_source::traits::{SampleSource, SampleSourceTestExt}; use crate::audio::sample_source::transcoder::AudioTranscoder; - use crate::audio::sample_source::{ - create_sample_source_from_file, MemorySampleSource, SampleSource, SampleSourceTestExt, - WavSampleSource, - }; use crate::audio::TargetFormat; + + // --------------------------------------------------------------------- + // Scaling helpers – direct unit tests for all integer formats + // --------------------------------------------------------------------- + + #[test] + fn test_integer_scaling_signed_ranges() { + // S8 + assert!((AudioSampleSource::scale_s8(0) - 0.0).abs() < 1e-7); + assert!(AudioSampleSource::scale_s8(i8::MAX) <= 1.0 + 1e-7); + assert!(AudioSampleSource::scale_s8(i8::MIN) >= -1.0 - 1e-7); + + // S16 + assert!((AudioSampleSource::scale_s16(0) - 0.0).abs() < 1e-7); + assert!(AudioSampleSource::scale_s16(i16::MAX) <= 1.0 + 1e-7); + assert!(AudioSampleSource::scale_s16(i16::MIN) >= -1.0 - 1e-7); + + // S24 + assert!((AudioSampleSource::scale_s24(0) - 0.0).abs() < 1e-7); + assert!(AudioSampleSource::scale_s24((1 << 23) - 1) <= 1.0 + 1e-7); + assert!(AudioSampleSource::scale_s24(-(1 << 23)) >= -1.0 - 1e-7); + + // S32 + assert!((AudioSampleSource::scale_s32(0) - 0.0).abs() < 1e-7); + assert!(AudioSampleSource::scale_s32(i32::MAX) <= 1.0 + 1e-7); + assert!(AudioSampleSource::scale_s32(i32::MIN) >= -1.0 - 1e-7); + } + + #[test] + fn test_integer_scaling_unsigned_ranges() { + // U8 + assert!((AudioSampleSource::scale_u8(0) + 1.0).abs() < 1e-7); + assert!((AudioSampleSource::scale_u8(u8::MAX) - 1.0).abs() < 1e-7); + let mid_u8 = AudioSampleSource::scale_u8(128); + assert!(mid_u8 > -0.01 && mid_u8 < 0.01); + + // U16 + assert!((AudioSampleSource::scale_u16(0) + 1.0).abs() < 1e-7); + assert!((AudioSampleSource::scale_u16(u16::MAX) - 1.0).abs() < 1e-7); + let mid_u16 = AudioSampleSource::scale_u16(u16::MAX / 2); + assert!(mid_u16 > -0.01 && mid_u16 < 0.01); + + // U24 + let max_u24 = (1u32 << 24) - 1; + assert!((AudioSampleSource::scale_u24(0) + 1.0).abs() < 1e-7); + assert!((AudioSampleSource::scale_u24(max_u24) - 1.0).abs() < 1e-7); + let mid_u24 = AudioSampleSource::scale_u24(max_u24 / 2); + assert!(mid_u24 > -0.01 && mid_u24 < 0.01); + + // U32 + assert!((AudioSampleSource::scale_u32(0) + 1.0).abs() < 1e-7); + assert!((AudioSampleSource::scale_u32(u32::MAX) - 1.0).abs() < 1e-7); + let mid_u32 = AudioSampleSource::scale_u32(u32::MAX / 2); + assert!(mid_u32 > -0.01 && mid_u32 < 0.01); + } use crate::testutil::audio_test_utils::calculate_snr; use rand; @@ -252,7 +307,7 @@ mod tests { match converter { Ok(converter) => { - // Transcoding is now handled internally by WavSampleSource + // Transcoding is now handled internally by AudioSampleSource // The old needs_resampling check is no longer needed let _needs_resampling = should_need_resampling; // Test that the converter was created successfully @@ -279,7 +334,7 @@ mod tests { AudioTranscoder::new(mock_source, &source_format, &target_format, 1).unwrap(); // Should not need resampling - // Transcoding is now handled internally by WavSampleSource + // Transcoding is now handled internally by AudioSampleSource // Test that samples are returned unchanged when no resampling is needed let mut output_samples = Vec::with_capacity(10); @@ -1316,7 +1371,7 @@ mod tests { write_wav_with_bits(wav_path.clone(), vec![samples], 44100, 16).unwrap(); // Test reading the WAV file using generic function - let mut wav_source = create_sample_source_from_file(&wav_path).unwrap(); + let mut wav_source = create_sample_source_from_file(&wav_path, None, 1024).unwrap(); let mut read_samples = Vec::new(); loop { @@ -1365,7 +1420,7 @@ mod tests { write_wav_with_bits(wav_path.clone(), vec![samples], 44100, 24).unwrap(); // Test reading the WAV file using generic function - let mut wav_source = create_sample_source_from_file(&wav_path).unwrap(); + let mut wav_source = create_sample_source_from_file(&wav_path, None, 1024).unwrap(); let mut read_samples = Vec::new(); loop { @@ -1414,7 +1469,7 @@ mod tests { write_wav(wav_path.clone(), vec![samples], 44100).unwrap(); // Test reading the WAV file using generic function - let mut wav_source = create_sample_source_from_file(&wav_path).unwrap(); + let mut wav_source = create_sample_source_from_file(&wav_path, None, 1024).unwrap(); let mut read_samples = Vec::new(); loop { @@ -1464,7 +1519,7 @@ mod tests { write_wav(wav_path.clone(), vec![left_samples, right_samples], 44100).unwrap(); // Test reading the WAV file - let mut wav_source = WavSampleSource::from_file(&wav_path).unwrap(); + let mut wav_source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap(); let mut read_samples = Vec::new(); loop { @@ -1512,7 +1567,7 @@ mod tests { write_wav(wav_path.clone(), vec![Vec::::new()], 44100).unwrap(); // Test reading the empty WAV file - let mut wav_source = WavSampleSource::from_file(&wav_path).unwrap(); + let mut wav_source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap(); // Should return None immediately match wav_source.next_sample() { @@ -1530,7 +1585,7 @@ mod tests { let wav_path = std::path::Path::new("nonexistent_file.wav"); // Should return an error for nonexistent file - if WavSampleSource::from_file(wav_path).is_ok() { + if AudioSampleSource::from_file(wav_path, None, 1024).is_ok() { panic!("Expected error for nonexistent file") } } @@ -1547,7 +1602,7 @@ mod tests { let samples: Vec = vec![1000, 2000, 3000]; write_wav(wav_path.clone(), vec![samples], 44100).unwrap(); - let mut wav_source = WavSampleSource::from_file(&wav_path).unwrap(); + let mut wav_source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap(); // Initially not finished assert!(!wav_source.is_finished()); @@ -1615,9 +1670,9 @@ mod tests { write_wav_with_bits(wav_32_path.clone(), vec![samples_32], sample_rate, 32).unwrap(); // Read samples from each WAV file - let mut wav_16_source = WavSampleSource::from_file(&wav_16_path).unwrap(); - let mut wav_24_source = WavSampleSource::from_file(&wav_24_path).unwrap(); - let mut wav_32_source = WavSampleSource::from_file(&wav_32_path).unwrap(); + let mut wav_16_source = AudioSampleSource::from_file(&wav_16_path, None, 1024).unwrap(); + let mut wav_24_source = AudioSampleSource::from_file(&wav_24_path, None, 1024).unwrap(); + let mut wav_32_source = AudioSampleSource::from_file(&wav_32_path, None, 1024).unwrap(); let mut samples_16_read = Vec::new(); let mut samples_24_read = Vec::new(); @@ -1716,7 +1771,7 @@ mod tests { write_wav(wav_path.clone(), vec![samples], sample_rate).unwrap(); // Test reading the WAV file - let mut wav_source = WavSampleSource::from_file(&wav_path).unwrap(); + let mut wav_source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap(); let mut read_samples = Vec::new(); loop { @@ -1763,7 +1818,7 @@ mod tests { // Test seeking to 5 seconds let seek_time = Duration::from_secs(5); let mut wav_source = - WavSampleSource::from_file_with_seek(&wav_path, Some(seek_time)).unwrap(); + AudioSampleSource::from_file(&wav_path, Some(seek_time), 1024).unwrap(); // Read a few samples and verify we can read after seeking // At 5 seconds, we should be at sample index ~220500 (5 * 44100) @@ -1779,12 +1834,67 @@ mod tests { // Test seeking to 0 (should work like from_file) let mut wav_source_start = - WavSampleSource::from_file_with_seek(&wav_path, Some(std::time::Duration::ZERO)) - .unwrap(); + AudioSampleSource::from_file(&wav_path, Some(std::time::Duration::ZERO), 1024).unwrap(); let start_sample = wav_source_start.next_sample().unwrap(); assert!(start_sample.is_some(), "Should have samples from start"); } + #[test] + fn test_wav_sample_source_seek_clears_leftover_samples() { + use crate::testutil::write_wav; + use std::time::Duration; + use tempfile::tempdir; + + let tempdir = tempdir().unwrap(); + let wav_path = tempdir.path().join("test_seek_leftover.wav"); + + // Create a WAV file with 4 seconds of samples at 44100 Hz. + // First half (0-2s) is positive; second half (2-4s) is negative so we + // can distinguish pre‑seek from post‑seek regions by sign alone. + let sample_rate = 44100u32; + let duration_secs = 4; + let total_samples = sample_rate as usize * duration_secs; + + let samples: Vec = (0..total_samples) + .map(|i| { + if i < (sample_rate as usize * 2) { + 1000 + } else { + -1000 + } + }) + .collect(); + + write_wav(wav_path.clone(), vec![samples], sample_rate).unwrap(); + + // Force AudioSampleSource to go through detect_channels_and_prime_buffer so + // that it decodes some audio at the beginning of the stream and stores it + // in leftover_samples before we seek. + std::env::set_var("MTRACK_FORCE_DETECT_CHANNELS", "1"); + + // Seek to 3 seconds, which lies firmly in the negative region. + let seek_time = Duration::from_secs(3); + let mut wav_source = + AudioSampleSource::from_file(&wav_path, Some(seek_time), 1024).unwrap(); + + let first_sample = wav_source + .next_sample() + .unwrap() + .expect("expected sample after seeking"); + + // If leftover_samples were not cleared before seeking, we'd first see + // positive samples from the start of the file. With the bug fixed we + // should start in the negative region. + assert!( + first_sample < 0.0, + "expected first sample after seek to come from post‑seek (negative) region, got {}", + first_sample + ); + + // Clean up the env var so it doesn't affect other tests. + std::env::remove_var("MTRACK_FORCE_DETECT_CHANNELS"); + } + #[test] fn test_wav_sample_source_4channel() { use crate::testutil::write_wav_with_bits; @@ -1808,10 +1918,10 @@ mod tests { .unwrap(); // Test reading the WAV file - let mut wav_source = WavSampleSource::from_file(&wav_path).unwrap(); + let mut wav_source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap(); // Verify channel count - assert_eq!(wav_source.channels(), 4); + assert_eq!(wav_source.channel_count(), 4); // Read samples and verify interleaving let mut samples_read = Vec::new(); @@ -1880,10 +1990,10 @@ mod tests { .unwrap(); // Test reading the WAV file - let mut wav_source = WavSampleSource::from_file(&wav_path).unwrap(); + let mut wav_source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap(); // Verify channel count and sample rate - assert_eq!(wav_source.channels(), 6); + assert_eq!(wav_source.channel_count(), 6); assert_eq!(wav_source.sample_rate(), 48000); // Read samples and verify interleaving @@ -1925,4 +2035,69 @@ mod tests { ); } } + + fn decode_some_samples_from>(path: P) { + let path = path.as_ref(); + + assert!( + path.exists(), + "expected audio fixture to exist at {:?}", + path + ); + + let mut source = create_sample_source_from_file(path, None, 1024) + .expect("failed to create sample source"); + + // Basic sanity checks: metadata should look sensible. + assert!(source.sample_rate() > 0, "sample_rate should be > 0"); + assert!(source.channel_count() > 0, "channel_count should be > 0"); + + // Try to read a small number of samples to ensure we actually decode audio. + let mut count = 0usize; + const MAX_SAMPLES: usize = 2048; + while count < MAX_SAMPLES { + match source.next_sample() { + Ok(Some(_)) => count += 1, + Ok(None) => break, + Err(e) => panic!("error while decoding samples: {}", e), + } + } + + assert!(count > 0, "no samples decoded from test file: {:?}", path); + } + + #[test] + fn test_symphonia_can_decode_wav() { + decode_some_samples_from(std::path::Path::new("assets/1Channel44.1k.wav")); + } + + #[test] + fn test_symphonia_can_decode_flac() { + decode_some_samples_from(std::path::Path::new("assets/1Channel44.1k.flac")); + } + + #[test] + fn test_symphonia_can_decode_ogg_vorbis() { + decode_some_samples_from(std::path::Path::new("assets/1Channel44.1k.ogg")); + } + + #[test] + fn test_symphonia_can_decode_mp3() { + decode_some_samples_from(std::path::Path::new("assets/1Channel44.1k.mp3")); + } + + #[test] + fn test_symphonia_can_decode_aac() { + decode_some_samples_from(std::path::Path::new("assets/1Channel44.1k.aac")); + } + + #[test] + fn test_symphonia_can_decode_alac() { + decode_some_samples_from(std::path::Path::new("assets/1Channel44.1k_alac.m4a")); + } + + #[test] + fn test_symphonia_can_decode_alac_stereo() { + decode_some_samples_from(std::path::Path::new("assets/2Channel44.1k_alac.m4a")); + } } diff --git a/src/audio/sample_source/traits.rs b/src/audio/sample_source/traits.rs index 3bdf3232..dd5a3965 100644 --- a/src/audio/sample_source/traits.rs +++ b/src/audio/sample_source/traits.rs @@ -11,7 +11,7 @@ // You should have received a copy of the GNU General Public License along with // this program. If not, see . // -use super::error::TranscodingError; +use super::error::SampleSourceError; /// A source of audio samples that processes an iterator pub trait SampleSource: Send + Sync { @@ -19,7 +19,7 @@ pub trait SampleSource: Send + Sync { /// Returns Ok(Some(sample)) if a sample is available /// Returns Ok(None) if the source is finished /// Returns Err(error) if an error occurred - fn next_sample(&mut self) -> Result, TranscodingError>; + fn next_sample(&mut self) -> Result, SampleSourceError>; /// Get the number of channels in this source fn channel_count(&self) -> u16; @@ -38,6 +38,35 @@ pub trait SampleSource: Send + Sync { fn duration(&self) -> Option; } +/// Blanket implementation for Box +/// This allows Box to be used directly with generic functions +/// that require S: SampleSource, eliminating the need for wrapper types. +impl SampleSource for Box { + fn next_sample(&mut self) -> Result, SampleSourceError> { + (**self).next_sample() + } + + fn channel_count(&self) -> u16 { + (**self).channel_count() + } + + fn sample_rate(&self) -> u32 { + (**self).sample_rate() + } + + fn bits_per_sample(&self) -> u16 { + (**self).bits_per_sample() + } + + fn sample_format(&self) -> crate::audio::SampleFormat { + (**self).sample_format() + } + + fn duration(&self) -> Option { + (**self).duration() + } +} + /// A sample source with explicit channel mapping information /// This replaces the complex SongSource architecture with a simpler, more debuggable approach pub trait ChannelMappedSampleSource: Send + Sync { @@ -45,7 +74,7 @@ pub trait ChannelMappedSampleSource: Send + Sync { /// Returns Ok(Some(sample)) if a sample is available /// Returns Ok(None) if the source is finished /// Returns Err(error) if an error occurred - fn next_sample(&mut self) -> Result, TranscodingError>; + fn next_sample(&mut self) -> Result, SampleSourceError>; /// Get the next frame of samples (all channels for one time step) /// Writes samples directly into the provided output slice @@ -53,10 +82,10 @@ pub trait ChannelMappedSampleSource: Send + Sync { /// Returns Ok(None) if the source is finished /// Returns Err(error) if an error occurred /// The output slice must have capacity for at least source_channel_count() samples - fn next_frame(&mut self, output: &mut [f32]) -> Result, TranscodingError> { + fn next_frame(&mut self, output: &mut [f32]) -> Result, SampleSourceError> { let channel_count = self.source_channel_count() as usize; if output.len() < channel_count { - return Err(TranscodingError::SampleConversionFailed(format!( + return Err(SampleSourceError::SampleConversionFailed(format!( "Output buffer too small: need {} samples", channel_count ))); diff --git a/src/audio/sample_source/transcoder.rs b/src/audio/sample_source/transcoder.rs index 496e581b..88647443 100644 --- a/src/audio/sample_source/transcoder.rs +++ b/src/audio/sample_source/transcoder.rs @@ -17,7 +17,7 @@ use rubato::{ }; use std::sync::Mutex; -use super::error::TranscodingError; +use super::error::SampleSourceError; use super::traits::SampleSource; // Import VecResampler trait to bring methods into scope for method resolution @@ -126,7 +126,7 @@ impl SampleSource for AudioTranscoder where S: SampleSource, { - fn next_sample(&mut self) -> Result, TranscodingError> { + fn next_sample(&mut self) -> Result, SampleSourceError> { // If no resampler, just pass through directly if self.resampler.is_none() { return self.source.next_sample(); @@ -176,7 +176,7 @@ where source_format: &TargetFormat, target_format: &TargetFormat, channels: u16, - ) -> Result { + ) -> Result { let needs_resampling = source_format.sample_rate != target_format.sample_rate; let (resampler, output_scratch) = if needs_resampling { @@ -199,7 +199,7 @@ where channels as usize, ) .map_err(|_e| { - TranscodingError::ResamplingFailed( + SampleSourceError::ResamplingFailed( source_format.sample_rate, target_format.sample_rate, ) @@ -226,7 +226,7 @@ where /// Fill the output FIFO by reading from source and processing through resampler. /// This uses rubato's standard process_into_buffer pattern for streaming resampling. - fn fill_output_fifo(&mut self) -> Result<(), TranscodingError> { + fn fill_output_fifo(&mut self) -> Result<(), SampleSourceError> { let resampler_mutex = match self.resampler.as_ref() { Some(r) => r, None => return Ok(()), // No resampling needed @@ -286,7 +286,7 @@ where None, ) .map_err(|_e| { - TranscodingError::ResamplingFailed(self.source_rate, self.target_rate) + SampleSourceError::ResamplingFailed(self.source_rate, self.target_rate) })?; drop(resampler); // Release lock before drain @@ -320,7 +320,7 @@ where None, ) .map_err(|_e| { - TranscodingError::ResamplingFailed(self.source_rate, self.target_rate) + SampleSourceError::ResamplingFailed(self.source_rate, self.target_rate) })?; drop(resampler); // Release lock before drain diff --git a/src/audio/sample_source/wav.rs b/src/audio/sample_source/wav.rs deleted file mode 100644 index a87df265..00000000 --- a/src/audio/sample_source/wav.rs +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright (C) 2026 Michael Wilson -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -// -use hound::WavReader; -use std::io::BufReader; -use std::path::Path; - -use super::error::TranscodingError; -use super::traits::SampleSource; - -#[cfg(test)] -use super::traits::SampleSourceTestExt; - -/// A sample source that reads WAV files and provides scaled samples -/// This is the raw WAV reading component - no transcoding logic -pub struct WavSampleSource { - wav_reader: hound::WavReader>, - is_finished: bool, - // Buffered reading to reduce I/O operations - sample_buffer: Vec, - buffer_position: usize, - buffer_size: usize, - // WAV file metadata for direct parsing - bits_per_sample: u16, - channels: u16, - sample_rate: u32, - sample_format: crate::audio::SampleFormat, - duration: std::time::Duration, -} - -impl SampleSource for WavSampleSource { - fn next_sample(&mut self) -> Result, TranscodingError> { - if self.is_finished { - return Ok(None); - } - - // Check if we need to refill the buffer - if self.buffer_position >= self.sample_buffer.len() { - self.refill_buffer()?; - - // If buffer is still empty after refill, we're finished - if self.sample_buffer.is_empty() { - self.is_finished = true; - return Ok(None); - } - } - - // Return the next sample from the buffer - let sample = self.sample_buffer[self.buffer_position]; - self.buffer_position += 1; - Ok(Some(sample)) - } - - fn channel_count(&self) -> u16 { - self.channels - } - - fn sample_rate(&self) -> u32 { - self.sample_rate - } - - fn bits_per_sample(&self) -> u16 { - self.bits_per_sample - } - - fn sample_format(&self) -> crate::audio::SampleFormat { - self.sample_format - } - - fn duration(&self) -> Option { - Some(self.duration) - } -} - -impl WavSampleSource { - /// Creates a new WAV sample source from a file path - pub fn from_file>(path: P) -> Result { - Self::from_file_with_seek(path, None) - } - - /// Creates a new WAV sample source from a file path, optionally seeking to a start time - pub fn from_file_with_seek>( - path: P, - start_time: Option, - ) -> Result { - let mut wav_reader = WavReader::open(&path)?; - let spec = wav_reader.spec(); - let duration = std::time::Duration::from_secs( - u64::from(wav_reader.duration()) / u64::from(spec.sample_rate), - ); - - // If start_time is provided, seek to that position - if let Some(start) = start_time { - // Calculate frame position using precise floating point math to avoid rounding errors - // hound's seek() takes a frame position, where a frame is one sample per channel - // For a 2-channel file: frame 0 = samples [0,1], frame 1 = samples [2,3], etc. - // So frame_position = time * sample_rate (NOT divided by channels) - let frame_position = start.as_secs_f64() * spec.sample_rate as f64; - // Round to nearest frame to ensure consistent seeking across files - let frame_position = frame_position.round() as u32; - wav_reader.seek(frame_position)?; - } - - // Use a reasonable buffer size - 1024 samples per channel - let buffer_size = 1024; - - let sample_format = match spec.sample_format { - hound::SampleFormat::Float => crate::audio::SampleFormat::Float, - hound::SampleFormat::Int => crate::audio::SampleFormat::Int, - }; - - Ok(Self { - wav_reader, - is_finished: false, - sample_buffer: Vec::with_capacity(buffer_size), - buffer_position: 0, - buffer_size, - bits_per_sample: spec.bits_per_sample, - channels: spec.channels, - sample_rate: spec.sample_rate, - sample_format, - duration, - }) - } - - /// Refills the sample buffer by reading a chunk from the WAV file - fn refill_buffer(&mut self) -> Result<(), TranscodingError> { - // Clear the buffer and reset position - self.sample_buffer.clear(); - self.buffer_position = 0; - - // Read samples directly using the samples iterator (still more efficient than per-sample I/O) - let mut samples_read = 0; - let spec = self.wav_reader.spec(); - - // Read samples in the correct format based on the WAV file's actual format - if spec.sample_format == hound::SampleFormat::Float { - // For float WAV files, read as f32 - for sample_result in self.wav_reader.samples::().take(self.buffer_size) { - match sample_result { - Ok(sample) => { - // Float samples are already in the correct range [-1.0, 1.0] - self.sample_buffer.push(sample); - samples_read += 1; - } - Err(e) => return Err(TranscodingError::WavError(e)), - } - } - } else { - // For integer WAV files, read as i32 - for sample_result in self.wav_reader.samples::().take(self.buffer_size) { - match sample_result { - Ok(sample) => { - // Convert i32 to f32 with proper scaling - // Use i64 to avoid overflow for 32-bit samples - let scale_factor = 1.0 / (1i64 << (self.bits_per_sample - 1)) as f32; - let result = sample as f32 * scale_factor; - self.sample_buffer.push(result); - samples_read += 1; - } - Err(e) => return Err(TranscodingError::WavError(e)), - } - } - } - - // If we read no samples, we're at the end of the file - if samples_read == 0 { - self.is_finished = true; - } - - Ok(()) - } - - /// Returns the number of channels in the WAV file - #[cfg(test)] - pub fn channels(&self) -> u16 { - self.channels - } - - /// Returns the sample rate of the WAV file - #[cfg(test)] - pub fn sample_rate(&self) -> u32 { - self.sample_rate - } -} - -#[cfg(test)] -impl SampleSourceTestExt for WavSampleSource { - fn is_finished(&self) -> bool { - self.is_finished - } -} diff --git a/src/config/audio.rs b/src/config/audio.rs index d1a103f2..04e7f01b 100644 --- a/src/config/audio.rs +++ b/src/config/audio.rs @@ -19,6 +19,7 @@ use serde::Deserialize; use crate::audio::SampleFormat; const DEFAULT_AUDIO_PLAYBACK_DELAY: Duration = Duration::ZERO; +const DEFAULT_BUFFER_SIZE: usize = 1024; /// A YAML representation of the audio configuration. #[derive(Deserialize, Clone)] @@ -29,12 +30,6 @@ pub struct Audio { /// Controls how long to wait before playback of an audio file starts. playback_delay: Option, - /// Buffer size for background reading (number of frames to buffer ahead) - buffer_size: Option, - - /// Threshold for triggering background reads (when buffer drops below this) - buffer_threshold: Option, - /// Target sample rate in Hz (default: 44100) sample_rate: Option, @@ -43,6 +38,9 @@ pub struct Audio { /// Target bits per sample (default: 32) bits_per_sample: Option, + + /// Buffer size for decoded audio samples (default: 1024 samples per channel) + buffer_size: Option, } impl Audio { @@ -51,11 +49,10 @@ impl Audio { Audio { device: device.to_string(), playback_delay: None, - buffer_size: None, - buffer_threshold: None, sample_rate: None, sample_format: None, bits_per_sample: None, + buffer_size: None, } } @@ -90,13 +87,8 @@ impl Audio { self.bits_per_sample.unwrap_or(32) } - /// Returns the buffer size for background reading (default: 1024) + /// Returns the buffer size for decoded audio samples (default: 1024 samples per channel) pub fn buffer_size(&self) -> usize { - self.buffer_size.unwrap_or(1024) - } - - /// Returns the buffer threshold for triggering background reads (default: 256) - pub fn buffer_threshold(&self) -> usize { - self.buffer_threshold.unwrap_or(256) + self.buffer_size.unwrap_or(DEFAULT_BUFFER_SIZE) } } diff --git a/src/main.rs b/src/main.rs index a0a4356b..5f666f63 100644 --- a/src/main.rs +++ b/src/main.rs @@ -272,7 +272,12 @@ fn verify_light_show(show_path: &str, config_path: Option<&str>) -> Result<(), B #[tokio::main] async fn main() { - tracing_subscriber::fmt::init(); + // Initialize tracing with a filter that sets default logging to off, with mtrack at info level + // This prevents noisy INFO messages from symphonia crates (which are suppressed by the default "off") + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("off,mtrack=info")); + + tracing_subscriber::fmt().with_env_filter(filter).init(); if let Err(e) = run().await { eprintln!("Error: {}", e); diff --git a/src/songs.rs b/src/songs.rs index 075aaa41..38140469 100644 --- a/src/songs.rs +++ b/src/songs.rs @@ -29,7 +29,6 @@ use nodi::Sheet; use tracing::{debug, info, warn}; -use crate::audio::sample_source::SampleSource; use crate::audio::TargetFormat; use crate::config; use crate::proto::player; @@ -115,7 +114,7 @@ pub struct Song { } /// A simple sample for songs. Boils down to i32 or f32, which we can be reasonably assured that -/// hound is able to read. +/// symphonia is able to read. impl Song { // Create a new song. pub fn new(start_path: &Path, config: &config::Song) -> Result> { @@ -346,7 +345,11 @@ impl Song { // Check if any track has different sample rate, format, or bit depth self.tracks.iter().any(|track| { // Use the generic SampleSource infrastructure to check transcoding needs - match crate::audio::sample_source::create_sample_source_from_file(&track.file) { + match crate::audio::sample_source::create_sample_source_from_file( + &track.file, + None, + 1024, + ) { Ok(sample_source) => { // Create source format from the SampleSource metadata let source_format = TargetFormat::new( @@ -386,13 +389,10 @@ impl Song { track_mappings: &HashMap>, target_format: TargetFormat, buffer_size: usize, - buffer_threshold: usize, ) -> Result>, Box> { use crate::audio::sample_source::create_channel_mapped_sample_source; - use crate::audio::sample_source::{ - create_sample_source_from_file, create_sample_source_from_file_with_seek, - }; + use crate::audio::sample_source::create_sample_source_from_file; let mut sources = Vec::new(); @@ -412,9 +412,20 @@ impl Song { sorted_files.sort_by_key(|(path, _)| path.clone()); for (file_path, tracks) in sorted_files { - // Get the audio file info to determine the actual number of channels - let wav_source = crate::audio::sample_source::WavSampleSource::from_file(&file_path)?; - let wav_channels = wav_source.channel_count(); + // Create the sample source once and reuse it for both metadata and playback + // This avoids creating two instances which can cause issues with symphonia's global state + let sample_source = create_sample_source_from_file( + &file_path, + if start_time == Duration::ZERO { + None + } else { + Some(start_time) + }, + buffer_size, + )?; + + // Get the channel count from the source we just created + let wav_channels = sample_source.channel_count(); // Create channel mappings for each channel in the WAV file let mut channel_mappings = Vec::new(); @@ -434,18 +445,10 @@ impl Song { channel_mappings.push(labels); } - // Create the channel mapped source for this file, with optional seeking - let sample_source = if start_time == Duration::ZERO { - create_sample_source_from_file(&file_path)? - } else { - create_sample_source_from_file_with_seek(&file_path, Some(start_time))? - }; let source = create_channel_mapped_sample_source( sample_source, target_format.clone(), channel_mappings, - buffer_size, - buffer_threshold, )?; sources.push(source); @@ -640,10 +643,14 @@ impl Track { let file_channel = config.file_channel(); let name = config.name(); - let source = create_sample_source_from_file(&track_file)?; + let source = create_sample_source_from_file(&track_file, None, 1024)?; + + // Extract all metadata before the source might be dropped or cause issues let sample_rate = source.sample_rate(); let duration = source.duration().unwrap_or(Duration::ZERO); - if source.channel_count() > 1 && file_channel.is_none() { + let channel_count = source.channel_count(); + + if channel_count > 1 && file_channel.is_none() { return Err(format!( "track {} has more than one channel but file_channel is not specified", name, @@ -652,12 +659,13 @@ impl Track { } let file_channel = file_channel.unwrap_or(1); + let sample_format = source.sample_format(); Ok(Track { name: name.to_string(), file: track_file.clone(), file_channel, sample_rate, - sample_format: source.sample_format(), + sample_format, duration, }) } @@ -674,7 +682,7 @@ impl Track { assert_eq!(extension, "wav", "Expected file name to end in '.wav'"); let track_name = stem.to_string(); - let source = create_sample_source_from_file(track_path)?; + let source = create_sample_source_from_file(track_path, None, 1024)?; let sample_rate = source.sample_rate(); let sample_format = source.sample_format(); let duration = source.duration().unwrap_or(Duration::ZERO); @@ -1252,7 +1260,8 @@ mod test { // Measure file reading time with sample source let start = Instant::now(); - let mut source = crate::audio::sample_source::create_sample_source_from_file(&wav_path)?; + let mut source = + crate::audio::sample_source::create_sample_source_from_file(&wav_path, None, 1024)?; println!( "WAV file spec: {}Hz, {}bit, {}ch", source.sample_rate(), @@ -1274,13 +1283,15 @@ mod test { ); println!("Samples read: {}", samples_read); - // Measure WavSampleSource performance + // Measure AudioSampleSource performance let start = Instant::now(); - let mut wav_source = crate::audio::sample_source::WavSampleSource::from_file(&wav_path)?; + let mut wav_source = crate::audio::sample_source::audio::AudioSampleSource::from_file( + &wav_path, None, 1024, + )?; let mut samples_processed = 0; loop { - match crate::audio::sample_source::SampleSource::next_sample(&mut wav_source) { + match crate::audio::sample_source::traits::SampleSource::next_sample(&mut wav_source) { Ok(Some(_)) => samples_processed += 1, Ok(None) => break, Err(e) => return Err(e.into()), @@ -1288,9 +1299,9 @@ mod test { } let wav_source_time = start.elapsed(); - println!("WavSampleSource processing time: {:?}", wav_source_time); + println!("AudioSampleSource processing time: {:?}", wav_source_time); println!( - "WavSampleSource speed: {:.2} MB/s", + "AudioSampleSource speed: {:.2} MB/s", (samples_processed * 4) as f64 / wav_source_time.as_secs_f64() / 1_000_000.0 ); println!("Samples processed: {}", samples_processed);