From c674e4515bb5218d8f5ebd582fbc3e8a71cf7eaa Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:12:57 +0300 Subject: [PATCH] feat(audio): migrate Symphonia 0.5 -> 0.6 (#999) * feat(audio): migrate Symphonia 0.5 -> 0.6 Port the audio + analysis pipeline to the Symphonia 0.6 API: - bump symphonia to 0.6 and symphonia-adapter-libopus to 0.3; drop rodio's symphonia-all feature to avoid a duplicate symphonia-core 0.5 - remove the local symphonia-format-isomp4 0.5 patch and rely on upstream 0.6 - switch codec registries to register_enabled_codecs / make_audio_decoder with AudioCodecParameters + AudioDecoderOptions - rework decode.rs SizedDecoder and analysis decode loops for the new AudioDecoder trait, GenericAudioBufferRef, Time/Timestamp newtypes, and next_packet() -> Result> Streaming regression fixes: - ProbeSeekGate hides seekability during probe for non-MP4 progressive streams so Symphonia 0.6's trailing-metadata scan no longer forces a full download before ranged FLAC/MP3/OGG playback can start - guard the streaming probe() with a 20s timeout on a worker thread so a stalled ranged source (e.g. right after a server switch) can no longer hang playback start until a player restart; add probe start/done diagnostics * docs(changelog): note Symphonia 0.6 migration and streaming fixes (#999) Add CHANGELOG entries (Changed + Fixed) and a CONTRIBUTORS line for the Symphonia 0.6 migration, ranged-stream start-latency fix, and probe timeout. * test(audio): cover streaming probe path and ProbeSeekGate Add unit tests for SizedDecoder::new_streaming (success + garbage) and ProbeSeekGate (seekability toggle, byte_len, read/seek passthrough) to restore decode.rs above the 70% hot-path coverage gate (67.3% -> 79.4%). --- CHANGELOG.md | 18 + src-tauri/Cargo.lock | 134 ++- src-tauri/Cargo.toml | 19 +- src-tauri/crates/psysonic-analysis/Cargo.toml | 4 +- .../src/analysis_cache/compute.rs | 97 ++- .../crates/psysonic-analysis/src/codec.rs | 13 +- src-tauri/crates/psysonic-audio/Cargo.toml | 6 +- src-tauri/crates/psysonic-audio/src/codec.rs | 13 +- src-tauri/crates/psysonic-audio/src/decode.rs | 398 ++++++--- .../patches/symphonia-format-isomp4/.cargo-ok | 1 - .../.cargo_vcs_info.json | 6 - .../symphonia-format-isomp4/Cargo.lock | 94 --- .../symphonia-format-isomp4/Cargo.toml | 59 -- .../symphonia-format-isomp4/Cargo.toml.orig | 20 - .../patches/symphonia-format-isomp4/README.md | 15 - .../symphonia-format-isomp4/src/atoms/alac.rs | 60 -- .../symphonia-format-isomp4/src/atoms/co64.rs | 41 - .../symphonia-format-isomp4/src/atoms/ctts.rs | 29 - .../symphonia-format-isomp4/src/atoms/edts.rs | 43 - .../symphonia-format-isomp4/src/atoms/elst.rs | 71 -- .../symphonia-format-isomp4/src/atoms/esds.rs | 368 --------- .../symphonia-format-isomp4/src/atoms/flac.rs | 77 -- .../symphonia-format-isomp4/src/atoms/ftyp.rs | 54 -- .../symphonia-format-isomp4/src/atoms/hdlr.rs | 80 -- .../symphonia-format-isomp4/src/atoms/ilst.rs | 767 ------------------ .../symphonia-format-isomp4/src/atoms/mdhd.rs | 93 --- .../symphonia-format-isomp4/src/atoms/mdia.rs | 63 -- .../symphonia-format-isomp4/src/atoms/mehd.rs | 41 - .../symphonia-format-isomp4/src/atoms/meta.rs | 66 -- .../symphonia-format-isomp4/src/atoms/mfhd.rs | 35 - .../symphonia-format-isomp4/src/atoms/minf.rs | 54 -- .../symphonia-format-isomp4/src/atoms/mod.rs | 482 ----------- .../symphonia-format-isomp4/src/atoms/moof.rs | 60 -- .../symphonia-format-isomp4/src/atoms/moov.rs | 103 --- .../symphonia-format-isomp4/src/atoms/mvex.rs | 51 -- .../symphonia-format-isomp4/src/atoms/mvhd.rs | 80 -- .../symphonia-format-isomp4/src/atoms/opus.rs | 72 -- .../symphonia-format-isomp4/src/atoms/sidx.rs | 89 -- .../symphonia-format-isomp4/src/atoms/smhd.rs | 40 - .../symphonia-format-isomp4/src/atoms/stbl.rs | 108 --- .../symphonia-format-isomp4/src/atoms/stco.rs | 41 - .../symphonia-format-isomp4/src/atoms/stsc.rs | 109 --- .../symphonia-format-isomp4/src/atoms/stsd.rs | 539 ------------ .../symphonia-format-isomp4/src/atoms/stss.rs | 28 - .../symphonia-format-isomp4/src/atoms/stsz.rs | 58 -- .../symphonia-format-isomp4/src/atoms/stts.rs | 105 --- .../symphonia-format-isomp4/src/atoms/tfhd.rs | 84 -- .../symphonia-format-isomp4/src/atoms/tkhd.rs | 89 -- .../symphonia-format-isomp4/src/atoms/traf.rs | 64 -- .../symphonia-format-isomp4/src/atoms/trak.rs | 64 -- .../symphonia-format-isomp4/src/atoms/trex.rs | 48 -- .../symphonia-format-isomp4/src/atoms/trun.rs | 321 -------- .../symphonia-format-isomp4/src/atoms/udta.rs | 53 -- .../symphonia-format-isomp4/src/atoms/wave.rs | 41 - .../symphonia-format-isomp4/src/demuxer.rs | 667 --------------- .../symphonia-format-isomp4/src/fourcc.rs | 31 - .../patches/symphonia-format-isomp4/src/fp.rs | 78 -- .../symphonia-format-isomp4/src/lib.rs | 22 - .../symphonia-format-isomp4/src/stream.rs | 444 ---------- src/config/settingsCredits.ts | 1 + 60 files changed, 451 insertions(+), 6360 deletions(-) delete mode 100644 src-tauri/patches/symphonia-format-isomp4/.cargo-ok delete mode 100644 src-tauri/patches/symphonia-format-isomp4/.cargo_vcs_info.json delete mode 100644 src-tauri/patches/symphonia-format-isomp4/Cargo.lock delete mode 100644 src-tauri/patches/symphonia-format-isomp4/Cargo.toml delete mode 100644 src-tauri/patches/symphonia-format-isomp4/Cargo.toml.orig delete mode 100644 src-tauri/patches/symphonia-format-isomp4/README.md delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/alac.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/co64.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/ctts.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/edts.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/elst.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/esds.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/flac.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/ftyp.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/hdlr.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/ilst.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/mdhd.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/mdia.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/mehd.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/meta.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/mfhd.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/minf.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/mod.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/moof.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/moov.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/mvex.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/mvhd.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/opus.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/sidx.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/smhd.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/stbl.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/stco.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/stsc.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/stsd.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/stss.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/stsz.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/stts.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/tfhd.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/tkhd.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/traf.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/trak.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/trex.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/trun.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/udta.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/atoms/wave.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/demuxer.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/fourcc.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/fp.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/lib.rs delete mode 100644 src-tauri/patches/symphonia-format-isomp4/src/stream.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 586580e3..e300a5d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Audio — Symphonia 0.6 upgrade + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#999](https://github.com/Psychotoxical/psysonic/pull/999)** + +* Audio decode + analysis pipeline ported to **Symphonia 0.6** (new `AudioDecoder` API, `GenericAudioBufferRef`, `Time`/`Timestamp` units); `symphonia-adapter-libopus` bumped to **0.3** and the vendored `symphonia-format-isomp4` 0.5 patch dropped in favour of upstream ISO-BMFF fixes. +* `rodio`'s `symphonia-all` feature dropped so the workspace no longer pulls a duplicate `symphonia-core`. + + + ## Fixed ### Servers — complete border on the active server card @@ -42,6 +51,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Audio streaming — start latency and stall recovery + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#999](https://github.com/Psychotoxical/psysonic/pull/999)** + +* Ranged-HTTP FLAC/MP3/OGG streams start playing as soon as enough data is buffered again, instead of waiting for the whole file to download (Symphonia 0.6's trailing-metadata probe scan is skipped for progressive non-MP4 streams). +* The streaming format probe now runs under a 20s timeout on a worker thread, so a stalled stream (e.g. right after a server switch) no longer blocks playback start until a manual player restart. + + + ## [1.47.0] > **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2794f1a7..f571f8c0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -95,12 +95,6 @@ dependencies = [ "derive_arbitrary", ] -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - [[package]] name = "assert-json-diff" version = "2.0.2" @@ -4549,6 +4543,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -4656,7 +4656,6 @@ dependencies = [ "cpal", "dasp_sample", "num-rational", - "symphonia", "thiserror 2.0.18", ] @@ -5464,9 +5463,9 @@ dependencies = [ [[package]] name = "symphonia" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a" dependencies = [ "lazy_static", "symphonia-bundle-flac", @@ -5477,9 +5476,7 @@ dependencies = [ "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", @@ -5487,9 +5484,9 @@ dependencies = [ [[package]] name = "symphonia-adapter-libopus" -version = "0.2.9" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bfc8e95f95c23ed1b5328eb66920ad28d9968c797f9c7aa755d4b45a5f47a41" +checksum = "c6febe6f88f9a9483db7e5b72a2dad916d8f8eb588d18905a39c861319fc7fa1" dependencies = [ "log", "opusic-sys", @@ -5498,44 +5495,44 @@ dependencies = [ [[package]] name = "symphonia-bundle-flac" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +checksum = "ee69ad01236a67260b82fd1ff9790dd75ead29f2f46af145e63b7e72273e0e03" dependencies = [ "log", + "symphonia-common", "symphonia-core", "symphonia-metadata", - "symphonia-utils-xiph", ] [[package]] name = "symphonia-bundle-mp3" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +checksum = "350f1f2f2e19ad4dd315db94304d1eb361b29af070681f94e51b8fdaad769546" dependencies = [ "lazy_static", "log", "symphonia-core", - "symphonia-metadata", ] [[package]] name = "symphonia-codec-aac" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +checksum = "f1979c515a76371b186aad2feff5f23e21cbec775bf95de08bf1e3af92a2ad76" dependencies = [ "lazy_static", "log", + "symphonia-common", "symphonia-core", ] [[package]] name = "symphonia-codec-adpcm" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +checksum = "4ebbdfd76d6cc5a601c6292a44357c5b7c82f2cd7cdc0f171421f5c5cff0ea1f" dependencies = [ "log", "symphonia-core", @@ -5543,19 +5540,20 @@ dependencies = [ [[package]] name = "symphonia-codec-alac" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +checksum = "3a149cbfc7fb5c405d123a273227d31de17138419552112bf1aa7b73e65827b8" dependencies = [ "log", + "symphonia-common", "symphonia-core", ] [[package]] name = "symphonia-codec-pcm" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +checksum = "50baee168f0e9dcf6ba7fc06e8b57eb62072a4490cc7cf13af77e72baae5d328" dependencies = [ "log", "symphonia-core", @@ -5563,80 +5561,69 @@ dependencies = [ [[package]] name = "symphonia-codec-vorbis" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +checksum = "45b07b4423cd8e0fc472575909a5554b12c2f58e3c190b38c24f042e732fd8de" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-common" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55" dependencies = [ "log", "symphonia-core", - "symphonia-utils-xiph", + "symphonia-metadata", ] [[package]] name = "symphonia-core" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1" dependencies = [ - "arrayvec", - "bitflags 1.3.2", + "bitflags 2.11.1", "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", + "num-complex", + "smallvec", ] [[package]] name = "symphonia-format-isomp4" -version = "0.5.5" -dependencies = [ - "encoding_rs", - "log", - "symphonia-core", - "symphonia-metadata", - "symphonia-utils-xiph", -] - -[[package]] -name = "symphonia-format-mkv" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +checksum = "2d179a01305b3505940135a9f0180d6ef4b487912748fe97554756f120fbd05e" dependencies = [ - "lazy_static", "log", + "symphonia-common", "symphonia-core", "symphonia-metadata", - "symphonia-utils-xiph", ] [[package]] name = "symphonia-format-ogg" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433" dependencies = [ "log", + "symphonia-common", "symphonia-core", "symphonia-metadata", - "symphonia-utils-xiph", ] [[package]] name = "symphonia-format-riff" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +checksum = "17424452a777666d3eaf09a5c651029b15b6a333812fcc5b5474f2a3f0cff3f0" dependencies = [ "extended", "log", @@ -5646,26 +5633,17 @@ dependencies = [ [[package]] name = "symphonia-metadata" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681" dependencies = [ - "encoding_rs", "lazy_static", "log", + "regex-lite", + "smallvec", "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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5d358fd0..47b6a3c3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -51,8 +51,8 @@ tauri-plugin-dialog = "2" tauri-plugin-fs = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -rodio = { version = "0.22", default-features = false, features = ["playback", "symphonia-all"] } -symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] } +rodio = { version = "0.22", default-features = false, features = ["playback"] } +symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] } reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "multipart", "query", "form", "rustls", "blocking", "gzip", "brotli"] } futures-util = "0.3" md5 = "0.8" @@ -69,7 +69,7 @@ thread-priority = "3" lofty = "0.24" sysinfo = { version = "0.39", default-features = false, features = ["disk", "system"] } id3 = "1.17" -symphonia-adapter-libopus = "0.2.9" +symphonia-adapter-libopus = "0.3" rusqlite = { version = "0.40", features = ["bundled"] } ebur128 = "0.1" dasp_sample = "0.11.0" @@ -102,11 +102,10 @@ windows = { version = "0.62", features = [ "Win32_UI_WindowsAndMessaging", ] } -[patch.crates-io] -# Local patch for Symphonia's isomp4 demuxer: -# - Fixes descriptor.unwrap() panic on malformed esds atoms (older iTunes M4A) -# - Tolerates SL predefined=0x01 (null) used by some older iTunes-purchased files -# - Gracefully skips malformed trak atoms (e.g. MJPEG cover-art streams) instead -# of failing the entire probe -symphonia-format-isomp4 = { path = "patches/symphonia-format-isomp4" } +# NOTE: The local `symphonia-format-isomp4` path patch (0.5-based) was removed for +# the Symphonia 0.6 migration. Symphonia 0.6 upstream already covers the esds +# missing-descriptor and SL predefined=null fixes. The malformed-trak skip and +# moov-at-end tail scan are validated against the fixture corpus on stock 0.6; if a +# case regresses, re-create the patch from the 0.6 isomp4 source and re-add the +# [patch.crates-io] entry here. See workdocs task 2026-05-symphonia-0.6-migration. diff --git a/src-tauri/crates/psysonic-analysis/Cargo.toml b/src-tauri/crates/psysonic-analysis/Cargo.toml index 6f64289a..bf6cde10 100644 --- a/src-tauri/crates/psysonic-analysis/Cargo.toml +++ b/src-tauri/crates/psysonic-analysis/Cargo.toml @@ -17,8 +17,8 @@ futures-util = "0.3" ebur128 = "0.1" md5 = "0.8" rusqlite = { version = "0.40", features = ["bundled"] } -symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] } -symphonia-adapter-libopus = "0.2.9" +symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] } +symphonia-adapter-libopus = "0.3" oximedia-mir = { version = "0.1.7", default-features = false, features = ["tempo", "mood"] } [dev-dependencies] diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs b/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs index 86dfdbf4..b6901190 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs @@ -2,13 +2,12 @@ use std::io::Cursor; use std::time::Instant; use ebur128::{EbuR128, Mode as Ebur128Mode}; -use symphonia::core::audio::SampleBuffer; -use symphonia::core::codecs::{Decoder, DecoderOptions, CODEC_TYPE_NULL}; +use symphonia::core::codecs::audio::{AudioDecoder, AudioDecoderOptions}; use symphonia::core::errors::Error as SymphoniaError; -use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo}; +use symphonia::core::formats::probe::Hint; +use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo, TrackType}; use symphonia::core::io::MediaSourceStream; use symphonia::core::meta::MetadataOptions; -use symphonia::core::probe::Hint; use symphonia::core::units::Time; use tauri::{Manager, Runtime}; use psysonic_core::track_enrichment::TrackEnrichmentOutcome; @@ -299,7 +298,7 @@ fn analyze_loudness_and_waveform( /// when the container reports total track length. struct DecodeSession { format: Box, - decoder: Box, + decoder: Box, track_id: u32, timeline_hint: Option, } @@ -334,25 +333,25 @@ fn open_decode_session(bytes: &[u8], format_hint: Option<&str>) -> Option 0); - let codec_params = track.codec_params.clone(); - let decoder = match make_decoder(&codec_params, &DecoderOptions::default()) { + let timeline_hint = track.num_frames.filter(|&n| n > 0); + let audio_params = track.codec_params.as_ref()?.audio()?.clone(); + let decoder = match make_decoder(&audio_params, &AudioDecoderOptions::default().gapless(false)) { Ok(v) => v, Err(e) => { crate::app_deprintln!("[analysis] decoder make failed: {}", e); @@ -372,8 +371,9 @@ fn count_mono_frames_from_audio_bytes(bytes: &[u8], format_hint: Option<&str>) - let mut total: u64 = 0; let mut loop_i: u32 = 0; - while let Ok(packet) = format.next_packet() { - if packet.track_id() != track_id { + let mut samples_buf: Vec = Vec::new(); + while let Ok(Some(packet)) = format.next_packet() { + if packet.track_id != track_id { continue; } let decoded = match decoder.decode(&packet) { @@ -382,14 +382,12 @@ fn count_mono_frames_from_audio_bytes(bytes: &[u8], format_hint: Option<&str>) - Err(SymphoniaError::ResetRequired) => break, Err(_) => break, }; - let spec = *decoded.spec(); - let n_ch = spec.channels.count(); + let n_ch = decoded.spec().channels().count(); if n_ch == 0 { continue; } - let mut samples = SampleBuffer::::new(decoded.capacity() as u64, spec); - samples.copy_interleaved_ref(decoded); - let n = samples.samples().len(); + decoded.copy_to_vec_interleaved(&mut samples_buf); + let n = samples_buf.len(); if n < n_ch || !n.is_multiple_of(n_ch) { continue; } @@ -460,8 +458,9 @@ fn decode_scan_pcm( } let bin_grid_frames = decoded_frames.max(1); - while let Ok(packet) = format.next_packet() { - if packet.track_id() != track_id { + let mut samples_buf: Vec = Vec::new(); + while let Ok(Some(packet)) = format.next_packet() { + if packet.track_id != track_id { continue; } let decoded = match decoder.decode(&packet) { @@ -471,15 +470,14 @@ fn decode_scan_pcm( Err(_) => break, }; - let spec = *decoded.spec(); - let n_ch = spec.channels.count(); + let n_ch = decoded.spec().channels().count(); if n_ch == 0 { continue; } if loudness_target_lufs.is_some() && ebu.is_none() { - let ch = spec.channels.count() as u32; - let sr = spec.rate; + let ch = decoded.spec().channels().count() as u32; + let sr = decoded.spec().rate(); match EbuR128::new(ch, sr, Ebur128Mode::I | Ebur128Mode::TRUE_PEAK) { Ok(v) => { ebu = Some(v); @@ -497,9 +495,8 @@ fn decode_scan_pcm( } } - let mut samples = SampleBuffer::::new(decoded.capacity() as u64, spec); - samples.copy_interleaved_ref(decoded); - let slice = samples.samples(); + decoded.copy_to_vec_interleaved(&mut samples_buf); + let slice = samples_buf.as_slice(); if slice.len() < n_ch || !slice.len().is_multiple_of(n_ch) { continue; } @@ -531,7 +528,7 @@ fn decode_scan_pcm( if loudness_target_lufs.is_some() { if let Some(e) = ebu.as_mut() { - match e.add_frames_f32(samples.samples()) { + match e.add_frames_f32(&samples_buf) { Ok(_) => fed_any_frames = true, Err(err) => { crate::app_deprintln!("[analysis] loudness add_frames failed: {}", err); @@ -634,9 +631,11 @@ pub fn audio_duration_from_bytes(bytes: &[u8]) -> Option { let session = open_decode_session(bytes, None)?; let sample_rate = session .format - .default_track() + .default_track(TrackType::Audio) .or_else(|| session.format.tracks().first()) - .and_then(|t| t.codec_params.sample_rate) + .and_then(|t| t.codec_params.as_ref()) + .and_then(|c| c.audio()) + .and_then(|a| a.sample_rate) .filter(|&sr| sr > 0)?; let frames = session.timeline_hint?; Some(frames as f64 / sample_rate as f64) @@ -659,7 +658,8 @@ pub fn decode_mono_pcm_window( } = open_decode_session(bytes, None).ok_or_else(|| "failed to open audio decode session".to_string())?; if start_sec.is_finite() && start_sec > 0.0 { - let time: Time = start_sec.max(0.0).into(); + let time = Time::try_from_secs_f64(start_sec.max(0.0)) + .ok_or_else(|| "pcm window: invalid seek time".to_string())?; format .seek( SeekMode::Accurate, @@ -693,7 +693,7 @@ pub fn decode_mono_pcm_limited( fn decode_mono_pcm_from_session( format: &mut Box, - decoder: &mut Box, + decoder: &mut Box, track_id: u32, max_seconds: Option, ) -> Result<(Vec, f32), String> { @@ -701,9 +701,10 @@ fn decode_mono_pcm_from_session( let mut sample_rate = 0_f32; let mut max_frames: Option = None; let mut loop_i: u32 = 0; + let mut samples_buf: Vec = Vec::new(); - while let Ok(packet) = format.next_packet() { - if packet.track_id() != track_id { + while let Ok(Some(packet)) = format.next_packet() { + if packet.track_id != track_id { continue; } let decoded = match decoder.decode(&packet) { @@ -713,13 +714,12 @@ fn decode_mono_pcm_from_session( Err(_) => break, }; - let spec = *decoded.spec(); - let n_ch = spec.channels.count(); + let n_ch = decoded.spec().channels().count(); if n_ch == 0 { continue; } if sample_rate <= 0.0 { - sample_rate = spec.rate as f32; + sample_rate = decoded.spec().rate() as f32; if sample_rate <= 0.0 { return Err("invalid sample rate".to_string()); } @@ -732,9 +732,8 @@ fn decode_mono_pcm_from_session( }); } - let mut samples = SampleBuffer::::new(decoded.capacity() as u64, spec); - samples.copy_interleaved_ref(decoded); - let slice = samples.samples(); + decoded.copy_to_vec_interleaved(&mut samples_buf); + let slice = samples_buf.as_slice(); if slice.len() < n_ch || !slice.len().is_multiple_of(n_ch) { continue; } diff --git a/src-tauri/crates/psysonic-analysis/src/codec.rs b/src-tauri/crates/psysonic-analysis/src/codec.rs index a589b1b3..e50fd1cd 100644 --- a/src-tauri/crates/psysonic-analysis/src/codec.rs +++ b/src-tauri/crates/psysonic-analysis/src/codec.rs @@ -1,21 +1,22 @@ //! Symphonia codec registry — mirrors `psysonic-audio::codec` (Opus via libopus). use std::sync::OnceLock; -use symphonia::core::codecs::{CodecRegistry, DecoderOptions}; +use symphonia::core::codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions}; +use symphonia::core::codecs::registry::CodecRegistry; pub(crate) fn psysonic_codec_registry() -> &'static CodecRegistry { static REGISTRY: OnceLock = OnceLock::new(); REGISTRY.get_or_init(|| { let mut registry = CodecRegistry::new(); symphonia::default::register_enabled_codecs(&mut registry); - registry.register_all::(); + registry.register_audio_decoder::(); registry }) } pub(crate) fn make_decoder( - params: &symphonia::core::codecs::CodecParameters, - opts: &DecoderOptions, -) -> Result, symphonia::core::errors::Error> { - psysonic_codec_registry().make(params, opts) + params: &AudioCodecParameters, + opts: &AudioDecoderOptions, +) -> Result, symphonia::core::errors::Error> { + psysonic_codec_registry().make_audio_decoder(params, opts) } diff --git a/src-tauri/crates/psysonic-audio/Cargo.toml b/src-tauri/crates/psysonic-audio/Cargo.toml index 400036f4..c9dcfda5 100644 --- a/src-tauri/crates/psysonic-audio/Cargo.toml +++ b/src-tauri/crates/psysonic-audio/Cargo.toml @@ -15,9 +15,9 @@ serde_json = "1" tokio = { version = "1", features = ["rt", "time", "sync"] } reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls", "blocking", "gzip", "brotli"] } futures-util = "0.3" -rodio = { version = "0.22", default-features = false, features = ["playback", "symphonia-all"] } -symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] } -symphonia-adapter-libopus = "0.2.9" +rodio = { version = "0.22", default-features = false, features = ["playback"] } +symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] } +symphonia-adapter-libopus = "0.3" ringbuf = "0.5" biquad = "0.6" dasp_sample = "0.11.0" diff --git a/src-tauri/crates/psysonic-audio/src/codec.rs b/src-tauri/crates/psysonic-audio/src/codec.rs index c5a124cc..1165b7a5 100644 --- a/src-tauri/crates/psysonic-audio/src/codec.rs +++ b/src-tauri/crates/psysonic-audio/src/codec.rs @@ -1,21 +1,22 @@ //! Symphonia codec registry (incl. Opus) and radio decoder factory. use std::sync::OnceLock; -use symphonia::core::codecs::{CodecRegistry, DecoderOptions}; +use symphonia::core::codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions}; +use symphonia::core::codecs::registry::CodecRegistry; pub(crate) fn psysonic_codec_registry() -> &'static CodecRegistry { static REGISTRY: OnceLock = OnceLock::new(); REGISTRY.get_or_init(|| { let mut registry = CodecRegistry::new(); symphonia::default::register_enabled_codecs(&mut registry); - registry.register_all::(); + registry.register_audio_decoder::(); registry }) } pub(crate) fn try_make_radio_decoder( - params: &symphonia::core::codecs::CodecParameters, - opts: &DecoderOptions, -) -> Result, symphonia::core::errors::Error> { - psysonic_codec_registry().make(params, opts) + params: &AudioCodecParameters, + opts: &AudioDecoderOptions, +) -> Result, symphonia::core::errors::Error> { + psysonic_codec_registry().make_audio_decoder(params, opts) } diff --git a/src-tauri/crates/psysonic-audio/src/decode.rs b/src-tauri/crates/psysonic-audio/src/decode.rs index d504c875..fccdadbc 100644 --- a/src-tauri/crates/psysonic-audio/src/decode.rs +++ b/src-tauri/crates/psysonic-audio/src/decode.rs @@ -1,19 +1,20 @@ //! Symphonia `SizedDecoder`, gapless trim, and `build_source` / `build_streaming_source`. use std::io::{Cursor, Read, Seek}; -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; use rodio::source::UniformSourceIterator; use rodio::Source; use symphonia::core::{ - audio::{AudioBufferRef, SampleBuffer, SignalSpec}, - codecs::{DecoderOptions, CODEC_TYPE_NULL}, + audio::{AudioSpec, GenericAudioBufferRef}, + codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions}, + formats::probe::Hint, formats::{FormatOptions, FormatReader, SeekMode, SeekTo}, + common::Limit, io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions}, meta::MetadataOptions, - probe::Hint, - units::{self, Time}, + units::{Time, Timestamp}, }; use super::codec::{psysonic_codec_registry, try_make_radio_decoder}; @@ -52,6 +53,46 @@ impl MediaSource for SizedCursorSource { fn byte_len(&self) -> Option { Some(self.len) } } +// ─── ProbeSeekGate — temporarily hide seekability during probing ────────────── +// +// Symphonia 0.6's `Probe::probe` scans for *trailing* metadata (ID3v1/APEv2/…) +// whenever the source reports `is_seekable() == true` and a known `byte_len()`. +// That scan seeks to the end of the stream. For a progressive ranged-HTTP source +// this forces a download all the way to EOF before the first sample can play +// (FLAC/MP3/OGG regressed to "won't start until fully downloaded"). +// +// These formats are demuxed sequentially from the start, and their seek paths +// re-check `is_seekable()` dynamically, so we can advertise the source as +// non-seekable for the duration of the probe (skipping the trailing scan) and +// flip it back to seekable afterwards to preserve scrubbing. MP4/ISO-BMFF is +// excluded because its demuxer captures seekability at construction and relies +// on seeking to locate `moov` (its tail is prefetched separately instead). +struct ProbeSeekGate { + inner: Box, + seekable: Arc, +} + +impl Read for ProbeSeekGate { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.inner.read(buf) + } +} + +impl Seek for ProbeSeekGate { + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { + self.inner.seek(pos) + } +} + +impl MediaSource for ProbeSeekGate { + fn is_seekable(&self) -> bool { + self.seekable.load(Ordering::Relaxed) && self.inner.is_seekable() + } + fn byte_len(&self) -> Option { + self.inner.byte_len() + } +} + // ─── SizedDecoder — symphonia decoder with correct byte_len ─────────────────── // // Replaces rodio::Decoder::new() which wraps the source in ReadSeekSource @@ -65,19 +106,19 @@ impl MediaSource for SizedCursorSource { /// playback is genuinely lossless. pub(crate) fn log_codec_resolution( tag: &str, - params: &symphonia::core::codecs::CodecParameters, + params: &AudioCodecParameters, container_hint: Option<&str>, ) { let codec_name = symphonia::default::get_codecs() - .get_codec(params.codec) - .map(|d| d.short_name) + .get_audio_decoder(params.codec) + .map(|d| d.codec.info.short_name) .unwrap_or("?"); let rate = params.sample_rate.map(|r| format!("{} Hz", r)).unwrap_or_else(|| "? Hz".into()); let bits = params.bits_per_sample .or(params.bits_per_coded_sample) .map(|b| format!("{}-bit", b)) .unwrap_or_else(|| "?-bit".into()); - let ch = params.channels + let ch = params.channels.as_ref() .map(|c| format!("{}ch", c.count())) .unwrap_or_else(|| "?ch".into()); let lossless = codec_name.starts_with("pcm") @@ -99,14 +140,20 @@ const DECODE_MAX_RETRIES: usize = 3; /// this limit so a handful of corrupt MP3 frames never aborts an otherwise /// playable track (VLC-style frame dropping). const MAX_CONSECUTIVE_DECODE_ERRORS: usize = 100; +/// Wall-clock cap for the streaming `probe()` call. A ranged-HTTP source whose +/// download stalls (e.g. right after a server switch) can otherwise block the +/// probe — and therefore playback start — indefinitely. On timeout we abort with +/// an error so the player can recover/retry instead of hanging until a restart. +const STREAM_PROBE_TIMEOUT: Duration = Duration::from_secs(20); pub(crate) struct SizedDecoder { - decoder: Box, + decoder: Box, current_frame_offset: usize, format: Box, total_duration: Option