debuggage des stream

This commit is contained in:
2025-11-14 10:43:53 +01:00
parent de84cbafbb
commit 1c2d30cbe9
44 changed files with 2018 additions and 717 deletions

449
Cargo.lock generated
View File

@@ -6,7 +6,7 @@ version = 4
name = "PMOMusic"
version = "0.1.0"
dependencies = [
"axum 0.8.6",
"axum 0.8.7",
"console-subscriber",
"pmoapp",
"pmoaudio-ext",
@@ -103,7 +103,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -130,7 +130,7 @@ checksum = "f548ad2c4031f2902e3edc1f29c29e835829437de49562d8eb5dc5584d3a1043"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -163,7 +163,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -174,7 +174,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -252,9 +252,9 @@ dependencies = [
[[package]]
name = "axum"
version = "0.8.6"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871"
checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425"
dependencies = [
"axum-core 0.5.5",
"bytes",
@@ -338,9 +338,9 @@ dependencies = [
[[package]]
name = "axum-server"
version = "0.7.2"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "495c05f60d6df0093e8fb6e74aa5846a0ad06abaf96d76166283720bf740f8ab"
checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9"
dependencies = [
"bytes",
"fs-err",
@@ -373,7 +373,7 @@ dependencies = [
"parking_lot",
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
"toml_edit 0.23.7",
]
@@ -439,7 +439,7 @@ dependencies = [
"indexmap 2.12.0",
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
"uuid",
]
@@ -470,7 +470,7 @@ dependencies = [
"regex",
"rustc-hash",
"shlex",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -509,6 +509,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]]
name = "built"
version = "0.7.1"
@@ -538,7 +547,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -555,9 +564,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.10.1"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "c_linked_list"
@@ -567,9 +576,9 @@ checksum = "4964518bd3b4a8190e832886cdc0da9794f12e8e6c1613a9e90ff331c4c8724b"
[[package]]
name = "cc"
version = "1.2.44"
version = "1.2.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3"
checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -608,6 +617,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.42"
@@ -619,7 +634,7 @@ dependencies = [
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
"windows-link",
]
[[package]]
@@ -890,9 +905,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.6"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
@@ -945,7 +960,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -965,7 +980,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -999,6 +1014,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "dispatch2"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.10.0",
"objc2",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
@@ -1007,7 +1032,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -1063,7 +1088,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -1074,9 +1099,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased-serde"
version = "0.4.8"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "259d404d09818dec19332e31d94558aeb442fea04c817006456c24b5460bbd4b"
checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3"
dependencies = [
"serde",
"serde_core",
@@ -1116,9 +1141,9 @@ dependencies = [
[[package]]
name = "exr"
version = "1.73.0"
version = "1.74.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0"
checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
dependencies = [
"bit_field",
"half",
@@ -1170,7 +1195,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -1184,9 +1209,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.4"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127"
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
[[package]]
name = "flacenc"
@@ -1325,7 +1350,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -1366,9 +1391,9 @@ checksum = "8f5f3913fa0bfe7ee1fd8248b6b9f42a5af4b9d65ec2dd2c3c26132b950ecfc2"
[[package]]
name = "generic-array"
version = "0.14.9"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
@@ -1619,9 +1644,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]]
name = "hyper"
version = "1.7.0"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
dependencies = [
"atomic-waker",
"bytes",
@@ -1687,9 +1712,9 @@ dependencies = [
[[package]]
name = "hyper-util"
version = "0.1.17"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8"
checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -1907,7 +1932,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -2066,7 +2091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link 0.2.1",
"windows-link",
]
[[package]]
@@ -2168,7 +2193,7 @@ checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -2479,6 +2504,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nom"
version = "7.1.3"
@@ -2557,7 +2594,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -2618,7 +2655,166 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
name = "objc2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.10.0",
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-data"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.10.0",
"dispatch2",
"objc2",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.10.0",
"dispatch2",
"objc2",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-core-image"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-location"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.10.0",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.10.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.10.0",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.10.0",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-ui-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.10.0",
"block2",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-location",
"objc2-core-text",
"objc2-foundation",
"objc2-quartz-core",
"objc2-user-notifications",
]
[[package]]
name = "objc2-user-notifications"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
@@ -2670,9 +2866,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "openssl"
version = "0.10.74"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
@@ -2691,7 +2887,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -2702,9 +2898,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.110"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
@@ -2730,14 +2926,18 @@ dependencies = [
[[package]]
name = "os_info"
version = "3.12.0"
version = "3.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3"
checksum = "7c39b5918402d564846d5aba164c09a66cc88d232179dfd3e3c619a25a268392"
dependencies = [
"android_system_properties",
"log",
"plist",
"nix",
"objc2",
"objc2-foundation",
"objc2-ui-kit",
"serde",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2766,7 +2966,7 @@ dependencies = [
"libc",
"redox_syscall",
"smallvec",
"windows-link 0.2.1",
"windows-link",
]
[[package]]
@@ -2798,7 +2998,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -2819,19 +3019,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plist"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [
"base64 0.22.1",
"indexmap 2.12.0",
"quick-xml 0.38.3",
"serde",
"time",
]
[[package]]
name = "pmoapp"
version = "0.1.0"
@@ -2888,7 +3075,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"axum 0.8.6",
"axum 0.8.7",
"bytes",
"chrono",
"futures-util",
@@ -2917,7 +3104,7 @@ name = "pmocache"
version = "0.1.0"
dependencies = [
"anyhow",
"axum 0.8.6",
"axum 0.8.7",
"bytes",
"chrono",
"futures-util",
@@ -2943,7 +3130,7 @@ name = "pmoconfig"
version = "0.1.0"
dependencies = [
"anyhow",
"axum 0.8.6",
"axum 0.8.7",
"dirs",
"lazy_static",
"log",
@@ -2962,7 +3149,7 @@ name = "pmocovers"
version = "0.1.0"
dependencies = [
"anyhow",
"axum 0.8.6",
"axum 0.8.7",
"image",
"once_cell",
"pmocache",
@@ -2983,7 +3170,7 @@ version = "0.1.0"
dependencies = [
"bevy_reflect",
"bevy_reflect_derive",
"quick-xml 0.38.3",
"quick-xml 0.38.4",
"serde",
"utoipa",
"utoipa-swagger-ui",
@@ -3017,7 +3204,7 @@ dependencies = [
"once_cell",
"pmodidl",
"pmoupnp",
"quick-xml 0.38.3",
"quick-xml 0.38.4",
]
[[package]]
@@ -3025,7 +3212,7 @@ name = "pmomediaserver"
version = "0.1.0"
dependencies = [
"async-trait",
"axum 0.8.6",
"axum 0.8.7",
"bevy_reflect",
"once_cell",
"pmoconfig",
@@ -3035,7 +3222,7 @@ dependencies = [
"pmoserver",
"pmosource",
"pmoupnp",
"quick-xml 0.38.3",
"quick-xml 0.38.4",
"serde",
"serde_json",
"thiserror 1.0.69",
@@ -3062,7 +3249,7 @@ dependencies = [
"anyhow",
"async-stream",
"async-trait",
"axum 0.8.6",
"axum 0.8.7",
"bytes",
"chrono",
"claxon",
@@ -3123,7 +3310,7 @@ name = "pmoqobuz"
version = "0.1.0"
dependencies = [
"anyhow",
"axum 0.8.6",
"axum 0.8.7",
"chrono",
"hex",
"mockito",
@@ -3152,7 +3339,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-stream",
"axum 0.8.6",
"axum 0.8.7",
"axum-embed",
"axum-server",
"futures",
@@ -3176,7 +3363,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"axum 0.8.6",
"axum 0.8.7",
"lazy_static",
"pmoaudiocache",
"pmoconfig",
@@ -3198,7 +3385,7 @@ name = "pmoupnp"
version = "0.1.0"
dependencies = [
"anyhow",
"axum 0.8.6",
"axum 0.8.7",
"base64 0.22.1",
"bevy_reflect",
"bevy_reflect_derive",
@@ -3297,7 +3484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -3334,7 +3521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
dependencies = [
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -3357,7 +3544,7 @@ dependencies = [
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -3421,9 +3608,9 @@ dependencies = [
[[package]]
name = "quick-xml"
version = "0.38.3"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
"serde",
@@ -3431,9 +3618,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.41"
version = "1.0.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
dependencies = [
"proc-macro2",
]
@@ -3722,7 +3909,7 @@ dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.108",
"syn 2.0.110",
"walkdir",
]
@@ -3895,7 +4082,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -4315,9 +4502,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.108"
version = "2.0.110"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917"
checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
dependencies = [
"proc-macro2",
"quote",
@@ -4341,7 +4528,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -4444,7 +4631,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -4455,7 +4642,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -4563,7 +4750,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -4803,7 +4990,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -4936,7 +5123,7 @@ dependencies = [
"proc-macro2",
"quote",
"regex",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -4945,7 +5132,7 @@ version = "9.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55"
dependencies = [
"axum 0.8.6",
"axum 0.8.7",
"base64 0.22.1",
"mime_guess",
"regex",
@@ -5001,7 +5188,7 @@ checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -5101,7 +5288,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
"wasm-bindgen-shared",
]
@@ -5149,9 +5336,9 @@ dependencies = [
[[package]]
name = "weezl"
version = "0.1.10"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "wgpu-types"
@@ -5252,9 +5439,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link 0.2.1",
"windows-link",
"windows-result 0.4.1",
"windows-strings 0.5.1",
"windows-strings",
]
[[package]]
@@ -5265,7 +5452,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -5276,15 +5463,9 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
name = "windows-link"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -5293,13 +5474,13 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.5.3"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
"windows-link",
"windows-result 0.4.1",
"windows-strings",
]
[[package]]
@@ -5311,31 +5492,13 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link 0.1.3",
"windows-link",
]
[[package]]
@@ -5344,7 +5507,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link 0.2.1",
"windows-link",
]
[[package]]
@@ -5389,7 +5552,7 @@ version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link 0.2.1",
"windows-link",
]
[[package]]
@@ -5429,7 +5592,7 @@ version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
@@ -5656,7 +5819,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
"synstructure",
]
@@ -5677,7 +5840,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]
@@ -5697,7 +5860,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
"synstructure",
]
@@ -5737,7 +5900,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
"syn 2.0.110",
]
[[package]]

View File

@@ -0,0 +1,108 @@
//! Shared broadcast pacing logic for streaming sinks.
//!
//! Provides intelligent backpressure based on audio timing:
//! - Detects TopZeroSync (when audio timestamp resets to 0)
//! - Drops frames that are late (audio_ts < elapsed)
//! - Paces broadcast to match audio playback rate
use std::time::Instant;
use tracing::{debug, info, warn};
/// Error returned when a frame should be skipped (too late)
#[derive(Debug)]
pub struct SkipFrame;
/// Manages broadcast pacing with TopZeroSync detection
pub struct BroadcastPacer {
/// Start time (reset on TopZeroSync)
start_time: Instant,
/// Maximum allowed lead time before sleeping (0 = no pacing)
max_lead_time: f64,
/// Label for logging (e.g., "FLAC" or "OGG")
label: String,
}
impl BroadcastPacer {
/// Create a new broadcast pacer
///
/// # Arguments
///
/// * `max_lead_time` - Maximum lead time in seconds (0 = no pacing)
/// * `label` - Label for logging
pub fn new(max_lead_time: f64, label: impl Into<String>) -> Self {
Self {
start_time: Instant::now(),
max_lead_time: max_lead_time.max(0.0),
label: label.into(),
}
}
/// Check timing and apply pacing
///
/// This function:
/// 1. Detects TopZeroSync (audio_timestamp < 0.1 after >1s) and resets timer
/// 2. Drops frames that are late (audio_ts < elapsed)
/// 3. Sleeps if too far ahead (lead_time > max_lead_time)
///
/// # Returns
///
/// - `Ok(())` if frame is on time or successfully paced
/// - `Err(SkipFrame)` if frame is too late and should be dropped
pub async fn check_and_pace(&mut self, audio_timestamp: f64) -> Result<(), SkipFrame> {
// ╔═══════════════════════════════════════════════════════════════╗
// ║ 1. DÉTECTION TopZeroSync ║
// ║ Si le timestamp revient proche de 0, reset l'horloge ║
// ╚═══════════════════════════════════════════════════════════════╝
let elapsed_since_start = self.start_time.elapsed().as_secs_f64();
if audio_timestamp < 0.1 && elapsed_since_start > 1.0 {
self.start_time = Instant::now();
info!(
"{} broadcaster: TopZeroSync detected, resetting timer",
self.label
);
}
// ╔═══════════════════════════════════════════════════════════════╗
// ║ 2. CALCUL DU LEAD TIME ║
// ║ lead_time > 0 : en avance (OK) ║
// ║ lead_time < 0 : en retard (SKIP) ║
// ╚═══════════════════════════════════════════════════════════════╝
let elapsed = self.start_time.elapsed().as_secs_f64();
let lead_time = audio_timestamp - elapsed;
// ╔═══════════════════════════════════════════════════════════════╗
// ║ 3. DROP FRAMES EN RETARD (tolérance zéro) ║
// ╚═══════════════════════════════════════════════════════════════╝
if lead_time < 0.0 {
warn!(
"{}: Dropping late frame: audio_ts={:.3}s, elapsed={:.3}s, lag={:.3}s",
self.label,
audio_timestamp,
elapsed,
-lead_time
);
return Err(SkipFrame);
}
// ╔═══════════════════════════════════════════════════════════════╗
// ║ 4. BACKPRESSURE NATURELLE - Pas de sleep ! ║
// ║ ║
// ║ Le pacing vient de : ║
// ║ - TimerBufferNode en amont (envoi régulier à 50ms/chunk) ║
// ║ - Capacité limitée du broadcast channel ║
// ║ - Client HTTP qui lit à vitesse réelle ║
// ║ ║
// ║ Pas besoin de sleep explicite qui causerait des bursts ║
// ╚═══════════════════════════════════════════════════════════════╝
// Log pour info si on est très en avance, mais on ne dort PAS
if self.max_lead_time > 0.0 && lead_time > self.max_lead_time {
debug!(
"{} broadcaster: lead_time={:.3}s > max={:.3}s (audio_ts={:.3}s, elapsed={:.3}s) - relying on natural backpressure",
self.label, lead_time, self.max_lead_time, audio_timestamp, elapsed
);
}
Ok(())
}
}

View File

@@ -93,10 +93,16 @@ impl NodeLogic for FlacCacheSinkLogic {
loop {
// Attendre le premier chunk audio pour cette track
tracing::debug!("FlacCacheSink: Waiting for first audio chunk (track_number={})", track_number);
let (first_segment, track_metadata) = if let Some(metadata) = next_track_metadata.take() {
tracing::debug!(
"FlacCacheSink: Waiting for first audio chunk (track_number={})",
track_number
);
let (first_segment, track_metadata) = if let Some(metadata) = next_track_metadata.take()
{
// On a déjà reçu le TrackBoundary en Phase 3 de la track précédente
tracing::debug!("FlacCacheSink: Using TrackBoundary metadata from previous track's Phase 3");
tracing::debug!(
"FlacCacheSink: Using TrackBoundary metadata from previous track's Phase 3"
);
// Attendre juste le premier chunk
match wait_for_first_audio_chunk(&mut rx, &stop_token).await {
Ok(chunk) => {
@@ -296,18 +302,30 @@ impl NodeLogic for FlacCacheSinkLogic {
url
}
Err(e) if e.is_transient() => {
tracing::debug!("FlacCacheSink: Transient error getting cover URL for pk {}: {}", pk, e);
tracing::debug!(
"FlacCacheSink: Transient error getting cover URL for pk {}: {}",
pk,
e
);
None
}
Err(e) => {
tracing::warn!("FlacCacheSink: Cannot obtain cover URL for audio asset {}: {}", pk, e);
tracing::warn!(
"FlacCacheSink: Cannot obtain cover URL for audio asset {}: {}",
pk,
e
);
None
}
};
if let Some(cover_url) = url {
tracing::debug!("FlacCacheSink: Attempting to cache cover from URL: {}", cover_url);
match self.covers
tracing::debug!(
"FlacCacheSink: Attempting to cache cover from URL: {}",
cover_url
);
match self
.covers
.add_from_url(&cover_url, self.collection.as_deref())
.await
{
@@ -323,7 +341,11 @@ impl NodeLogic for FlacCacheSinkLogic {
}
}
Err(e) => {
tracing::warn!("FlacCacheSink: Failed to cache cover for audio asset {}: {}", pk, e);
tracing::warn!(
"FlacCacheSink: Failed to cache cover for audio asset {}: {}",
pk,
e
);
}
}
} else {
@@ -339,20 +361,27 @@ impl NodeLogic for FlacCacheSinkLogic {
playlist_handle.push(pk.clone()).await.map_err(|e| {
AudioError::ProcessingError(format!("Failed to add to playlist: {}", e))
})?;
tracing::info!("FlacCacheSink: Successfully pushed to playlist in {:?}", push_start.elapsed());
tracing::info!(
"FlacCacheSink: Successfully pushed to playlist in {:?}",
push_start.elapsed()
);
}
// Si EndOfStream a été reçu pendant le prebuffer, on a déjà tout traité
// Il faut juste attendre que le pump se termine et retourner
if end_of_stream_received {
tracing::debug!("FlacCacheSink: EndOfStream was received during prebuffer, track complete");
tracing::debug!(
"FlacCacheSink: EndOfStream was received during prebuffer, track complete"
);
drop(pump_handle);
track_number += 1;
continue; // Passer à la track suivante (qui n'arrivera pas car EndOfStream)
}
// Phase 3: Continuer à dispatcher jusqu'au TrackBoundary
tracing::debug!("FlacCacheSink: Continuing dispatch until TrackBoundary (pump runs in background)");
tracing::debug!(
"FlacCacheSink: Continuing dispatch until TrackBoundary (pump runs in background)"
);
let mut track_tx = track_tx_opt; // track_tx_opt contient Some(track_tx) car end_of_stream_received est false
let mut pump_handle = Some(pump_handle);
let mut pump_closed = false;
@@ -384,7 +413,9 @@ impl NodeLogic for FlacCacheSinkLogic {
if tx.send(segment).await.is_err() {
// Le pump a fermé son channel - cela peut arriver si le fichier
// était déjà en cache (add_from_reader retourne immédiatement)
tracing::debug!("FlacCacheSink: pump closed track_tx, checking pump status");
tracing::debug!(
"FlacCacheSink: pump closed track_tx, checking pump status"
);
drop(track_tx.take());
// Attendre que le pump se termine et vérifier le résultat
@@ -397,13 +428,21 @@ impl NodeLogic for FlacCacheSinkLogic {
}
Ok(Err(e)) => {
// Le pump a rencontré une erreur
tracing::error!("FlacCacheSink: pump died with error: {}", e);
tracing::error!(
"FlacCacheSink: pump died with error: {}",
e
);
return Err(e);
}
Err(e) => {
// Le pump task a paniqué
tracing::error!("FlacCacheSink: pump task panicked: {}", e);
return Err(AudioError::ProcessingError("Pump task panicked".to_string()));
tracing::error!(
"FlacCacheSink: pump task panicked: {}",
e
);
return Err(AudioError::ProcessingError(
"Pump task panicked".to_string(),
));
}
}
}
@@ -534,7 +573,9 @@ async fn wait_for_first_audio_chunk(
_AudioSegment::Sync(marker) => match &**marker {
SyncMarker::TrackBoundary { .. } => {
// On ne devrait pas recevoir de TrackBoundary ici car on l'a déjà
tracing::warn!("FlacCacheSink: Unexpected TrackBoundary while waiting for first chunk");
tracing::warn!(
"FlacCacheSink: Unexpected TrackBoundary while waiting for first chunk"
);
continue;
}
SyncMarker::EndOfStream => {

View File

@@ -374,8 +374,8 @@ mod tests {
// Real-world example: first frame at 0, false positive at 7
let data = vec![
0xFF, 0xF8, 0xC9, 0xA8, // Valid frame header at position 0
0x00, 0x8D, 0x4C,
0xFF, 0xFE, 0x00, 0x00, // False positive at position 7 (0xFE has reserved bit set)
0x00, 0x8D, 0x4C, 0xFF, 0xFE, 0x00,
0x00, // False positive at position 7 (0xFE has reserved bit set)
];
// Position 0 should be valid

View File

@@ -10,6 +10,9 @@ mod flac_cache_sink;
#[cfg(feature = "cache-sink")]
pub use flac_cache_sink::{FlacCacheSink, FlacCacheSinkStats, TrackStats};
#[cfg(feature = "http-stream")]
mod broadcast_pacing;
#[cfg(feature = "http-stream")]
mod flac_frame_utils;
@@ -17,10 +20,12 @@ mod flac_frame_utils;
mod streaming_flac_sink;
#[cfg(feature = "http-stream")]
pub use streaming_flac_sink::{StreamingFlacSink, StreamHandle, MetadataSnapshot, FlacClientStream, IcyClientStream};
pub use streaming_flac_sink::{
FlacClientStream, IcyClientStream, MetadataSnapshot, StreamHandle, StreamingFlacSink,
};
#[cfg(feature = "http-stream")]
mod streaming_ogg_flac_sink;
#[cfg(feature = "http-stream")]
pub use streaming_ogg_flac_sink::{StreamingOggFlacSink, OggFlacStreamHandle, OggFlacClientStream};
pub use streaming_ogg_flac_sink::{OggFlacClientStream, OggFlacStreamHandle, StreamingOggFlacSink};

View File

@@ -62,7 +62,7 @@ use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Duration;
use super::flac_frame_utils;
use super::{broadcast_pacing::BroadcastPacer, flac_frame_utils};
use async_trait::async_trait;
use bytes::Bytes;
use pmoaudio::{
@@ -81,16 +81,29 @@ use tracing::{debug, error, info, trace, warn};
/// Standard value used by most streaming servers.
const DEFAULT_ICY_METAINT: usize = 16000;
/// Broadcast channel capacity for FLAC bytes.
/// Set to 128 to provide ~10 seconds of buffer for network jitter.
/// With TimerNode pacing the stream to real-time, this is sufficient
/// while keeping metadata synchronized (larger buffers cause metadata drift).
const BROADCAST_CAPACITY: usize = 128;
/// Maximum lead time for HTTP broadcast pacing (in seconds).
/// Default maximum lead time for HTTP broadcast pacing (in seconds).
/// The broadcaster will sleep if it's ahead of real-time by more than this amount.
/// This is much smaller than the pipeline TimerNode's 3.0s to provide tighter control.
const BROADCAST_MAX_LEAD_TIME: f64 = 0.5;
const DEFAULT_BROADCAST_MAX_LEAD_TIME: f64 = 0.5;
/// Calculate broadcast channel capacity based on max_lead_time.
///
/// Estimates the number of items needed to buffer max_lead_time seconds of audio.
/// Assumes ~20 items per second (50ms per chunk).
///
/// # Arguments
///
/// * `max_lead_time` - Maximum lead time in seconds
///
/// # Returns
///
/// Broadcast channel capacity (minimum 100 items)
fn calculate_broadcast_capacity(max_lead_time: f64) -> usize {
// Estimation: ~20 items/second (chunks de 50ms en moyenne)
// Pour 10s: 200 items
let estimated_items_per_second = 20.0;
let capacity = (max_lead_time * estimated_items_per_second) as usize;
capacity.max(100) // Minimum 100 items
}
/// PCM chunk with audio data and timestamp for precise pacing.
#[derive(Debug)]
@@ -190,7 +203,11 @@ impl StreamHandle {
/// Subscribe to the FLAC stream with custom ICY metadata interval.
pub fn subscribe_icy_with_interval(&self, metaint: usize) -> IcyClientStream {
let count = self.active_clients.fetch_add(1, Ordering::SeqCst);
debug!("New ICY client subscribed (total: {}, metaint: {})", count + 1, metaint);
debug!(
"New ICY client subscribed (total: {}, metaint: {})",
count + 1,
metaint
);
IcyClientStream {
rx: self.flac_broadcast.subscribe(),
@@ -254,7 +271,10 @@ impl AsyncRead for FlacClientStream {
if let Some(header) = header_opt {
self.buffer.extend(header.iter());
info!("Sending cached FLAC header to new client ({} bytes)", header.len());
info!(
"Sending cached FLAC header to new client ({} bytes)",
header.len()
);
self.state = FlacStreamState::Streaming;
continue; // Now copy header to output buffer
} else {
@@ -413,7 +433,10 @@ impl AsyncRead for IcyClientStream {
if let Some(header) = header_opt {
self.buffer.extend(header.iter());
info!("Sending cached FLAC header to new ICY client ({} bytes)", header.len());
info!(
"Sending cached FLAC header to new ICY client ({} bytes)",
header.len()
);
self.state = FlacStreamState::Streaming;
continue; // Now copy header to output buffer
} else {
@@ -533,6 +556,7 @@ struct StreamingFlacSinkLogic {
flac_header: Arc<RwLock<Option<Bytes>>>,
encoder_state: Option<EncoderState>,
sample_rate: Option<u32>,
broadcast_max_lead_time: f64,
}
impl StreamingFlacSinkLogic {
@@ -542,12 +566,16 @@ impl StreamingFlacSinkLogic {
return Ok(()); // Already initialized
}
info!("Initializing FLAC encoder with sample rate: {} Hz", sample_rate);
info!(
"Initializing FLAC encoder with sample rate: {} Hz",
sample_rate
);
// Take the PCM receiver (we only initialize once)
let pcm_rx = self.pcm_rx.take().ok_or_else(|| {
AudioError::ProcessingError("PCM receiver already consumed".into())
})?;
let pcm_rx = self
.pcm_rx
.take()
.ok_or_else(|| AudioError::ProcessingError("PCM receiver already consumed".into()))?;
// Create shared timestamp for pacing
let current_timestamp = Arc::new(RwLock::new(0.0f64));
@@ -565,15 +593,26 @@ impl StreamingFlacSinkLogic {
// Start the FLAC encoder
let flac_stream = encode_flac_stream(pcm_reader, pcm_format, self.encoder_options.clone())
.await
.map_err(|e| AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e)))?;
.map_err(|e| {
AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e))
})?;
info!("FLAC encoder initialized successfully");
// Spawn broadcaster task with timestamp for pacing
let flac_broadcast = self.flac_broadcast.clone();
let flac_header = self.flac_header.clone();
let max_lead = self.broadcast_max_lead_time;
let broadcaster_task = tokio::spawn(async move {
if let Err(e) = broadcast_flac_stream(flac_stream, flac_broadcast, flac_header, current_timestamp).await {
if let Err(e) = broadcast_flac_stream(
flac_stream,
flac_broadcast,
flac_header,
current_timestamp,
max_lead,
)
.await
{
error!("Broadcaster task error: {}", e);
}
});
@@ -682,10 +721,19 @@ impl NodeLogic for StreamingFlacSinkLogic {
bytes: pcm_bytes,
timestamp_sec: seg.timestamp_sec,
};
let send_start = std::time::Instant::now();
if let Err(e) = self.pcm_tx.send(pcm_chunk).await {
warn!("Failed to send PCM data to encoder: {}", e);
break;
}
let send_duration = send_start.elapsed();
if send_duration.as_millis() >= 50 {
debug!(
"StreamingFlacSink: pcm_tx send blocked for {:.3}s (ts={:.3}s)",
send_duration.as_secs_f64(),
seg.timestamp_sec
);
}
}
_AudioSegment::Sync(marker) => {
@@ -728,7 +776,6 @@ impl NodeLogic for StreamingFlacSinkLogic {
}
}
/// Broadcaster task: reads FLAC bytes from encoder and broadcasts to all clients.
/// Implements precise real-time pacing based on audio timestamps.
/// Ensures data is sent at FLAC frame boundaries to prevent sync errors in strict decoders like FFPlay.
@@ -737,8 +784,12 @@ async fn broadcast_flac_stream(
broadcast_tx: broadcast::Sender<Bytes>,
header_cache: Arc<RwLock<Option<Bytes>>>,
current_timestamp: Arc<RwLock<f64>>,
broadcast_max_lead_time: f64,
) -> Result<(), AudioError> {
info!("Broadcaster task started with FLAC frame boundary detection");
info!(
"Broadcaster task started with FLAC frame boundary detection (max_lead={:.3}s)",
broadcast_max_lead_time
);
// Use larger read buffer (16KB) to reduce syscalls and accumulator for frame boundary detection
// The accumulator is necessary to ensure we only send complete FLAC frames
@@ -746,9 +797,17 @@ async fn broadcast_flac_stream(
let mut accumulator = Vec::with_capacity(32768); // Pre-allocate to reduce reallocations
let mut total_bytes = 0u64;
let mut header_captured = false;
let start_time = std::time::Instant::now();
let mut pacer = BroadcastPacer::new(broadcast_max_lead_time, "FLAC");
let mut stats_last_log = std::time::Instant::now();
// Timing instrumentation for burst detection
let mut last_broadcast_time = std::time::Instant::now();
let mut broadcast_count = 0u64;
let mut total_read_time = 0.0f64;
let mut read_count = 0u64;
loop {
let read_start = std::time::Instant::now();
match flac_stream.read(&mut read_buffer).await {
Ok(0) => {
// EOF - send any remaining data
@@ -760,14 +819,38 @@ async fn broadcast_flac_stream(
break;
}
Ok(n) => {
let read_duration = read_start.elapsed().as_secs_f64();
read_count += 1;
total_read_time += read_duration;
if read_duration > 0.01 {
debug!(
"FLAC: flac_stream.read() took {:.3}s for {} bytes (avg: {:.3}s over {} reads)",
read_duration,
n,
total_read_time / read_count as f64,
read_count
);
}
total_bytes += n as u64;
if total_bytes % 100000 == 0 || total_bytes < 10000 {
trace!("Read {} bytes from FLAC encoder (total: {})", n, total_bytes);
trace!(
"Read {} bytes from FLAC encoder (total: {})",
n,
total_bytes
);
}
// Append to accumulator
accumulator.extend_from_slice(&read_buffer[..n]);
trace!(
"FLAC: accumulator now {} bytes after reading {} bytes",
accumulator.len(),
n
);
// Find where to split: position of last sync code (start of last incomplete frame)
// Everything before this position contains only complete frames
let boundary = flac_frame_utils::find_complete_frames_boundary(&accumulator);
@@ -781,18 +864,33 @@ async fn broadcast_flac_stream(
// Only broadcast if we have at least one complete frame (1KB minimum to avoid excessive small sends)
if boundary >= 1024 {
// Precise pacing based on audio timestamp
// ╔═══════════════════════════════════════════════════════════════╗
// ║ BACKPRESSURE INTELLIGENTE BASÉE SUR LE TIMING ║
// ║ ║
// ║ BroadcastPacer gère : ║
// ║ 1. Détection TopZeroSync (audio_ts < 0.1) ║
// ║ 2. Drop des chunks en retard (audio_ts < elapsed) ║
// ║ 3. Pacing pour contrôler le débit (max_lead_time) ║
// ║ ║
// ║ Cela crée la backpressure vers TimerBufferNode tout en ║
// ║ permettant de dropper les chunks vraiment périmés. ║
// ╚═══════════════════════════════════════════════════════════════╝
let audio_timestamp = *current_timestamp.read().await;
let elapsed = start_time.elapsed().as_secs_f64();
let lead_time = audio_timestamp - elapsed;
if lead_time > BROADCAST_MAX_LEAD_TIME {
let sleep_duration = lead_time - BROADCAST_MAX_LEAD_TIME;
if stats_last_log.elapsed() >= Duration::from_secs(1) {
debug!(
"Broadcaster pacing: sleeping {:.3}s (audio_ts={:.3}s, elapsed={:.3}s, lead={:.3}s)",
sleep_duration, audio_timestamp, elapsed, lead_time
"Broadcaster pacing snapshot: audio_ts={:.3}s buffer_bytes={}",
audio_timestamp,
accumulator.len()
);
tokio::time::sleep(tokio::time::Duration::from_secs_f64(sleep_duration)).await;
stats_last_log = std::time::Instant::now();
}
// Check timing et apply pacing (skip si en retard)
if pacer.check_and_pace(audio_timestamp).await.is_err() {
// Chunk en retard : vider l'accumulator et continuer
accumulator.clear();
continue;
}
// Split at boundary to avoid copying - extract prefix, keep suffix
@@ -800,6 +898,31 @@ async fn broadcast_flac_stream(
let to_send = std::mem::replace(&mut accumulator, remaining);
let bytes = Bytes::from(to_send);
// Measure broadcast interval for burst detection
let broadcast_interval = last_broadcast_time.elapsed().as_secs_f64();
last_broadcast_time = std::time::Instant::now();
broadcast_count += 1;
// Log if interval is unusual (too short = burst, too long = stall)
if broadcast_interval < 0.01 || broadcast_interval > 0.1 {
debug!(
"FLAC: broadcast interval {:.3}s ({}ms) - size={} bytes (count={})",
broadcast_interval,
(broadcast_interval * 1000.0) as u32,
bytes.len(),
broadcast_count
);
}
// Periodic stats
if broadcast_count % 100 == 0 {
debug!(
"FLAC: {} broadcasts sent, accumulator={} bytes remaining",
broadcast_count,
accumulator.len()
);
}
// Capture first chunk as header if it contains "fLaC"
if !header_captured && bytes.len() >= 4 && &bytes[0..4] == b"fLaC" {
*header_cache.write().await = Some(bytes.clone());
@@ -812,7 +935,11 @@ async fn broadcast_flac_stream(
// No receivers, but that's okay - clients may not be connected yet
trace!("No active receivers for FLAC broadcast: {}", e);
} else if num_receivers > 0 {
trace!("Broadcasted {} bytes to {} receivers", bytes.len(), num_receivers);
trace!(
"Broadcasted {} bytes to {} receivers",
bytes.len(),
num_receivers
);
}
}
}
@@ -857,9 +984,19 @@ impl StreamingFlacSink {
/// A tuple of `(sink, handle)` where:
/// - `sink` is added to the audio pipeline
/// - `handle` is used by HTTP handlers to serve streams
pub fn new(
pub fn new(encoder_options: EncoderOptions, bits_per_sample: u8) -> (Self, StreamHandle) {
Self::with_max_broadcast_lead(
encoder_options,
bits_per_sample,
DEFAULT_BROADCAST_MAX_LEAD_TIME,
)
}
/// Create a sink with a custom broadcast pacing limit.
pub fn with_max_broadcast_lead(
encoder_options: EncoderOptions,
bits_per_sample: u8,
broadcast_max_lead_time: f64,
) -> (Self, StreamHandle) {
// Validate bit depth
if ![16, 24, 32].contains(&bits_per_sample) {
@@ -872,8 +1009,15 @@ impl StreamingFlacSink {
// Shared metadata
let metadata = Arc::new(RwLock::new(MetadataSnapshot::default()));
// Calculate broadcast capacity based on max_lead_time
let broadcast_capacity = calculate_broadcast_capacity(broadcast_max_lead_time);
info!(
"StreamingFlacSink: using broadcast capacity of {} items (max_lead_time={:.1}s)",
broadcast_capacity, broadcast_max_lead_time
);
// Broadcast channel for FLAC bytes
let (flac_broadcast, _) = broadcast::channel(BROADCAST_CAPACITY);
let (flac_broadcast, _) = broadcast::channel(broadcast_capacity);
// FLAC header cache
let flac_header = Arc::new(RwLock::new(None));
@@ -900,6 +1044,7 @@ impl StreamingFlacSink {
flac_header,
encoder_state: None,
sample_rate: None,
broadcast_max_lead_time: broadcast_max_lead_time.max(0.0),
};
let sink = Self {

View File

@@ -53,7 +53,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::task::{Context, Poll};
use super::flac_frame_utils;
use super::{broadcast_pacing::BroadcastPacer, flac_frame_utils};
use async_trait::async_trait;
use bytes::Bytes;
use pmoaudio::{
@@ -68,13 +68,18 @@ use tokio::sync::{broadcast, mpsc, RwLock};
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, trace, warn};
/// Broadcast channel capacity for OGG-FLAC bytes.
/// Same as StreamingFlacSink for consistency.
const BROADCAST_CAPACITY: usize = 128;
/// Default maximum lead time for HTTP broadcast pacing (in seconds).
const DEFAULT_BROADCAST_MAX_LEAD_TIME: f64 = 0.0;
/// Maximum lead time for HTTP broadcast pacing (in seconds).
/// The broadcaster will sleep if it's ahead of real-time by more than this amount.
const BROADCAST_MAX_LEAD_TIME: f64 = 0.5;
/// Calculate broadcast channel capacity based on max lead time.
///
/// Estimate: ~20 OGG pages per second (assuming 50ms chunks).
/// Minimum capacity: 100 items for buffering even with 0 lead time.
fn calculate_broadcast_capacity(max_lead_time: f64) -> usize {
let estimated_items_per_second = 20.0;
let capacity = (max_lead_time * estimated_items_per_second) as usize;
capacity.max(100) // Minimum 100 items
}
/// PCM chunk with audio data and timestamp for precise pacing.
#[derive(Debug)]
@@ -167,7 +172,10 @@ impl AsyncRead for OggFlacClientStream {
if let Some(header) = header_opt {
self.buffer.extend(header.iter());
info!("Sending cached OGG-FLAC header to new client ({} bytes)", header.len());
info!(
"Sending cached OGG-FLAC header to new client ({} bytes)",
header.len()
);
self.state = OggFlacStreamState::Streaming;
continue; // Now copy header to output buffer
} else {
@@ -203,7 +211,7 @@ impl AsyncRead for OggFlacClientStream {
// Schedule a wakeup after a small delay to avoid busy-loop polling.
let waker = cx.waker().clone();
tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
tokio::time::sleep(tokio::time::Duration::from_micros(100)).await;
waker.wake();
});
return Poll::Pending;
@@ -248,6 +256,7 @@ struct StreamingOggFlacSinkLogic {
ogg_header: Arc<RwLock<Option<Bytes>>>,
encoder_state: Option<EncoderState>,
sample_rate: Option<u32>,
broadcast_max_lead_time: f64,
}
impl StreamingOggFlacSinkLogic {
@@ -257,12 +266,16 @@ impl StreamingOggFlacSinkLogic {
return Ok(()); // Already initialized
}
info!("Initializing OGG-FLAC encoder with sample rate: {} Hz", sample_rate);
info!(
"Initializing OGG-FLAC encoder with sample rate: {} Hz",
sample_rate
);
// Take the PCM receiver (we only initialize once)
let pcm_rx = self.pcm_rx.take().ok_or_else(|| {
AudioError::ProcessingError("PCM receiver already consumed".into())
})?;
let pcm_rx = self
.pcm_rx
.take()
.ok_or_else(|| AudioError::ProcessingError("PCM receiver already consumed".into()))?;
// Create shared timestamp for pacing
let current_timestamp = Arc::new(RwLock::new(0.0f64));
@@ -280,15 +293,26 @@ impl StreamingOggFlacSinkLogic {
// Start the FLAC encoder
let flac_stream = encode_flac_stream(pcm_reader, pcm_format, self.encoder_options.clone())
.await
.map_err(|e| AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e)))?;
.map_err(|e| {
AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e))
})?;
info!("OGG-FLAC encoder initialized successfully");
// Spawn OGG wrapper + broadcaster task with timestamp for pacing
let ogg_broadcast = self.ogg_broadcast.clone();
let ogg_header = self.ogg_header.clone();
let max_lead = self.broadcast_max_lead_time;
let broadcaster_task = tokio::spawn(async move {
if let Err(e) = broadcast_ogg_flac_stream(flac_stream, ogg_broadcast, ogg_header, current_timestamp).await {
if let Err(e) = broadcast_ogg_flac_stream(
flac_stream,
ogg_broadcast,
ogg_header,
current_timestamp,
max_lead,
)
.await
{
error!("OGG broadcaster task error: {}", e);
}
});
@@ -464,6 +488,19 @@ impl StreamingOggFlacSink {
pub fn new(
encoder_options: EncoderOptions,
bits_per_sample: u8,
) -> (Self, OggFlacStreamHandle) {
Self::with_max_broadcast_lead(
encoder_options,
bits_per_sample,
DEFAULT_BROADCAST_MAX_LEAD_TIME,
)
}
/// Create a sink with a custom broadcast pacing limit.
pub fn with_max_broadcast_lead(
encoder_options: EncoderOptions,
bits_per_sample: u8,
broadcast_max_lead_time: f64,
) -> (Self, OggFlacStreamHandle) {
// Validate bit depth
if ![16, 24, 32].contains(&bits_per_sample) {
@@ -477,7 +514,14 @@ impl StreamingOggFlacSink {
let metadata = Arc::new(RwLock::new(MetadataSnapshot::default()));
// Broadcast channel for OGG-FLAC bytes
let (ogg_broadcast, _) = broadcast::channel(BROADCAST_CAPACITY);
// Capacity calculated from max_lead_time to ensure enough buffering
let broadcast_capacity = calculate_broadcast_capacity(broadcast_max_lead_time);
tracing::debug!(
"OGG-FLAC broadcast capacity: {} items (for {:.1}s max lead time)",
broadcast_capacity,
broadcast_max_lead_time
);
let (ogg_broadcast, _) = broadcast::channel(broadcast_capacity);
// OGG-FLAC header cache
let ogg_header = Arc::new(RwLock::new(None));
@@ -504,6 +548,7 @@ impl StreamingOggFlacSink {
ogg_header,
encoder_state: None,
sample_rate: None,
broadcast_max_lead_time: broadcast_max_lead_time.max(0.0),
};
let sink = Self {
@@ -709,17 +754,27 @@ async fn broadcast_ogg_flac_stream(
broadcast_tx: broadcast::Sender<Bytes>,
header_cache: Arc<RwLock<Option<Bytes>>>,
current_timestamp: Arc<RwLock<f64>>,
broadcast_max_lead_time: f64,
) -> Result<(), AudioError> {
info!("OGG-FLAC broadcaster task started with FLAC frame boundary detection");
info!(
"OGG-FLAC broadcaster task started with FLAC frame boundary detection (max_lead={:.3}s)",
broadcast_max_lead_time
);
let stream_serial = rand::random::<u32>();
let mut ogg_writer = OggPageWriter::new(stream_serial);
let mut total_ogg_bytes = 0u64;
let mut header_captured = false;
let start_time = std::time::Instant::now();
let mut pacer = BroadcastPacer::new(broadcast_max_lead_time, "OGG");
let mut last_granule_update_time = 0.0f64;
// Timing instrumentation for burst detection
let mut last_broadcast_time = std::time::Instant::now();
let mut broadcast_count = 0u64;
let mut total_read_time = 0.0f64;
let mut read_count = 0u64;
// Step 1: Read FLAC header (fLaC + metadata blocks)
let flac_header = read_flac_header(&mut flac_stream).await?;
info!("Read FLAC header: {} bytes", flac_header.len());
@@ -731,7 +786,10 @@ async fn broadcast_ogg_flac_stream(
// Step 2: Create OGG-FLAC identification packet (BOS)
// Format according to https://xiph.org/flac/ogg_mapping.html
let ogg_flac_id = create_ogg_flac_identification(&flac_header)?;
info!("Created OGG-FLAC identification packet: {} bytes", ogg_flac_id.len());
info!(
"Created OGG-FLAC identification packet: {} bytes",
ogg_flac_id.len()
);
let bos_page = ogg_writer.create_page(&ogg_flac_id, true, false, false);
let bos_bytes = Bytes::from(bos_page);
@@ -747,7 +805,10 @@ async fn broadcast_ogg_flac_stream(
cached_header.extend_from_slice(&comment_bytes);
*header_cache.write().await = Some(Bytes::from(cached_header));
header_captured = true;
info!("OGG-FLAC header cached ({} bytes: BOS + Vorbis Comment)", bos_bytes.len() + comment_bytes.len());
info!(
"OGG-FLAC header cached ({} bytes: BOS + Vorbis Comment)",
bos_bytes.len() + comment_bytes.len()
);
// Broadcast header
let _ = broadcast_tx.send(bos_bytes);
@@ -761,6 +822,7 @@ async fn broadcast_ogg_flac_stream(
let mut flac_accumulator = Vec::with_capacity(32768);
loop {
let read_start = std::time::Instant::now();
match flac_stream.read(&mut read_buffer).await {
Ok(0) => {
// EOF - create final page with EOS flag and any remaining data
@@ -769,7 +831,10 @@ async fn broadcast_ogg_flac_stream(
let eos_bytes = Bytes::from(eos_page);
total_ogg_bytes += eos_bytes.len() as u64;
let _ = broadcast_tx.send(eos_bytes);
info!("Sent final EOS page with {} bytes of data", flac_accumulator.len());
info!(
"Sent final EOS page with {} bytes of data",
flac_accumulator.len()
);
} else {
// Send empty EOS page
let eos_page = ogg_writer.create_page(&[], false, true, false);
@@ -779,13 +844,36 @@ async fn broadcast_ogg_flac_stream(
info!("Sent empty EOS page");
}
info!("OGG-FLAC stream ended, total OGG bytes: {}", total_ogg_bytes);
info!(
"OGG-FLAC stream ended, total OGG bytes: {}",
total_ogg_bytes
);
break;
}
Ok(n) => {
let read_duration = read_start.elapsed().as_secs_f64();
read_count += 1;
total_read_time += read_duration;
if read_duration > 0.01 {
debug!(
"OGG: flac_stream.read() took {:.3}s for {} bytes (avg: {:.3}s over {} reads)",
read_duration,
n,
total_read_time / read_count as f64,
read_count
);
}
// Append to accumulator
flac_accumulator.extend_from_slice(&read_buffer[..n]);
trace!(
"OGG: accumulator now {} bytes after reading {} bytes",
flac_accumulator.len(),
n
);
// Process complete FLAC frames one at a time
// OGG-FLAC spec requires: "Each audio data packet contains one complete FLAC frame"
loop {
@@ -804,7 +892,9 @@ async fn broadcast_ogg_flac_stream(
if byte1 == 0xFF && byte2 >= 0xF8 && byte2 <= 0xFE {
// Validate frame header with CRC-8 to avoid false positives
if flac_frame_utils::validate_frame_header_crc(&flac_accumulator, i) {
if let Some(samples) = flac_frame_utils::parse_flac_block_size(&flac_accumulator, i) {
if let Some(samples) =
flac_frame_utils::parse_flac_block_size(&flac_accumulator, i)
{
sync_data.push((i, samples));
}
}
@@ -823,26 +913,34 @@ async fn broadcast_ogg_flac_stream(
// Verify first frame starts at position 0 (otherwise we have garbage data)
if first_frame_start != 0 {
warn!("OGG-FLAC: Skipping {} bytes of garbage data before first frame", first_frame_start);
warn!(
"OGG-FLAC: Skipping {} bytes of garbage data before first frame",
first_frame_start
);
flac_accumulator.drain(0..first_frame_start);
continue;
}
// Extract just the first frame
let first_frame: Vec<u8> = flac_accumulator.drain(0..second_frame_start).collect();
let first_frame: Vec<u8> =
flac_accumulator.drain(0..second_frame_start).collect();
// Precise pacing based on audio timestamp
// ╔═══════════════════════════════════════════════════════════════╗
// ║ BACKPRESSURE INTELLIGENTE BASÉE SUR LE TIMING ║
// ║ ║
// ║ BroadcastPacer gère : ║
// ║ 1. Détection TopZeroSync (audio_ts < 0.1) ║
// ║ 2. Drop des chunks en retard (audio_ts < elapsed) ║
// ║ 3. Pacing pour contrôler le débit (max_lead_time) ║
// ║ ║
// ║ Cela crée la backpressure vers TimerBufferNode tout en ║
// ║ permettant de dropper les chunks vraiment périmés. ║
// ╚═══════════════════════════════════════════════════════════════╝
let audio_timestamp = *current_timestamp.read().await;
let elapsed = start_time.elapsed().as_secs_f64();
let lead_time = audio_timestamp - elapsed;
if lead_time > BROADCAST_MAX_LEAD_TIME {
let sleep_duration = lead_time - BROADCAST_MAX_LEAD_TIME;
debug!(
"OGG broadcaster pacing: sleeping {:.3}s (audio_ts={:.3}s, elapsed={:.3}s, lead={:.3}s)",
sleep_duration, audio_timestamp, elapsed, lead_time
);
tokio::time::sleep(tokio::time::Duration::from_secs_f64(sleep_duration)).await;
// Check timing et apply pacing (skip si en retard)
if pacer.check_and_pace(audio_timestamp).await.is_err() {
continue; // Skip ce chunk (trop en retard)
}
// Update granule position (cumulative sample count)
@@ -853,10 +951,41 @@ async fn broadcast_ogg_flac_stream(
let ogg_bytes = Bytes::from(ogg_page);
total_ogg_bytes += ogg_bytes.len() as u64;
if let Err(e) = broadcast_tx.send(ogg_bytes.clone()) {
// Measure broadcast interval for burst detection
let broadcast_interval = last_broadcast_time.elapsed().as_secs_f64();
last_broadcast_time = std::time::Instant::now();
broadcast_count += 1;
// Log if interval is unusual (too short = burst, too long = stall)
if broadcast_interval < 0.01 || broadcast_interval > 0.1 {
debug!(
"OGG: broadcast interval {:.3}s ({}ms) - frame_size={} bytes, samples={} (count={})",
broadcast_interval,
(broadcast_interval * 1000.0) as u32,
first_frame.len(),
first_frame_samples,
broadcast_count
);
}
// Periodic stats
if broadcast_count % 100 == 0 {
debug!(
"OGG: {} broadcasts sent, avg_interval={:.3}s, accumulator={} bytes",
broadcast_count,
last_broadcast_time.elapsed().as_secs_f64() / broadcast_count as f64,
flac_accumulator.len()
);
}
// Envoyer au broadcast
match broadcast_tx.send(ogg_bytes.clone()) {
Ok(n) => {
trace!("Broadcasted OGG page with 1 FLAC frame ({} bytes), {} samples ({} bytes total with OGG overhead) to {} receivers", first_frame.len(), first_frame_samples, ogg_bytes.len(), n);
}
Err(e) => {
trace!("No active receivers for OGG-FLAC broadcast: {}", e);
} else {
trace!("Broadcasted OGG page with 1 FLAC frame ({} bytes), {} samples ({} bytes total with OGG overhead)", first_frame.len(), first_frame_samples, ogg_bytes.len());
}
}
}
}
@@ -897,13 +1026,17 @@ fn extract_sample_rate_from_streaminfo(flac_header: &[u8]) -> Result<u32, AudioE
// First metadata block should be STREAMINFO (type 0)
let block_type = flac_header[4] & 0x7F;
if block_type != 0 {
return Err(AudioError::ProcessingError("First block is not STREAMINFO".into()));
return Err(AudioError::ProcessingError(
"First block is not STREAMINFO".into(),
));
}
// STREAMINFO data starts at offset 8 (after magic + block header)
// Sample rate is at offset 10-12 of STREAMINFO data (bytes 18-20 of header)
if flac_header.len() < 21 {
return Err(AudioError::ProcessingError("STREAMINFO block truncated".into()));
return Err(AudioError::ProcessingError(
"STREAMINFO block truncated".into(),
));
}
// Sample rate: 20 bits starting at byte 10 of STREAMINFO
@@ -917,7 +1050,9 @@ fn extract_sample_rate_from_streaminfo(flac_header: &[u8]) -> Result<u32, AudioE
let sample_rate = (byte10 << 12) | (byte11 << 4) | (byte12 >> 4);
if sample_rate == 0 {
return Err(AudioError::ProcessingError("Invalid sample rate (0)".into()));
return Err(AudioError::ProcessingError(
"Invalid sample rate (0)".into(),
));
}
Ok(sample_rate)
@@ -929,12 +1064,15 @@ async fn read_flac_header(stream: &mut FlacEncodedStream) -> Result<Vec<u8>, Aud
let mut buffer = [0u8; 4];
// Read "fLaC" magic
stream.read_exact(&mut buffer).await.map_err(|e| {
AudioError::ProcessingError(format!("Failed to read FLAC magic: {}", e))
})?;
stream
.read_exact(&mut buffer)
.await
.map_err(|e| AudioError::ProcessingError(format!("Failed to read FLAC magic: {}", e)))?;
if &buffer != b"fLaC" {
return Err(AudioError::ProcessingError("Invalid FLAC stream: missing fLaC magic".into()));
return Err(AudioError::ProcessingError(
"Invalid FLAC stream: missing fLaC magic".into(),
));
}
header.extend_from_slice(&buffer);
@@ -948,7 +1086,8 @@ async fn read_flac_header(stream: &mut FlacEncodedStream) -> Result<Vec<u8>, Aud
})?;
let is_last = (block_header[0] & 0x80) != 0;
let block_length = u32::from_be_bytes([0, block_header[1], block_header[2], block_header[3]]) as usize;
let block_length =
u32::from_be_bytes([0, block_header[1], block_header[2], block_header[3]]) as usize;
header.extend_from_slice(&block_header);
@@ -984,11 +1123,14 @@ fn create_ogg_flac_identification(flac_header: &[u8]) -> Result<Vec<u8>, AudioEr
let first_block_type = flac_header[4] & 0x7F; // Remove last-metadata-block flag
if first_block_type != 0 {
return Err(AudioError::ProcessingError("First FLAC metadata block is not STREAMINFO".into()));
return Err(AudioError::ProcessingError(
"First FLAC metadata block is not STREAMINFO".into(),
));
}
// Extract block length (3 bytes big-endian after type byte)
let block_length = u32::from_be_bytes([0, flac_header[5], flac_header[6], flac_header[7]]) as usize;
let block_length =
u32::from_be_bytes([0, flac_header[5], flac_header[6], flac_header[7]]) as usize;
info!("STREAMINFO block_length = {} bytes", block_length);
@@ -1007,7 +1149,10 @@ fn create_ogg_flac_identification(flac_header: &[u8]) -> Result<Vec<u8>, AudioEr
// Extract just the STREAMINFO block (type + length + data)
let streaminfo = &flac_header[4..4 + streaminfo_size];
info!("Extracted STREAMINFO: {} bytes (type+length+data)", streaminfo.len());
info!(
"Extracted STREAMINFO: {} bytes (type+length+data)",
streaminfo.len()
);
let mut packet = Vec::new();
@@ -1075,7 +1220,13 @@ impl OggPageWriter {
self.granule_position += samples;
}
fn create_page(&mut self, packet_data: &[u8], is_bos: bool, is_eos: bool, is_continuation: bool) -> Vec<u8> {
fn create_page(
&mut self,
packet_data: &[u8],
is_bos: bool,
is_eos: bool,
is_continuation: bool,
) -> Vec<u8> {
use std::io::Write;
let mut segments = Vec::new();
@@ -1117,7 +1268,8 @@ impl OggPageWriter {
page.write_all(&[header_type]).unwrap();
// Granule position
page.write_all(&self.granule_position.to_le_bytes()).unwrap();
page.write_all(&self.granule_position.to_le_bytes())
.unwrap();
// Stream serial number
page.write_all(&self.stream_serial.to_le_bytes()).unwrap();

View File

@@ -205,11 +205,8 @@ impl NodeLogic for PlaylistSourceLogic {
Ok(m) => m,
Err(e) => {
tracing::warn!("PlaylistSourceLogic: failed to get metadata: {}", e);
let error_marker = AudioSegment::new_error(
0,
0.0,
format!("Failed to get metadata: {}", e),
);
let error_marker =
AudioSegment::new_error(0, 0.0, format!("Failed to get metadata: {}", e));
send_to_children!(error_marker);
continue;
}
@@ -224,11 +221,8 @@ impl NodeLogic for PlaylistSourceLogic {
Ok(p) => p,
Err(e) => {
tracing::warn!("PlaylistSourceLogic: failed to get file path: {}", e);
let error_marker = AudioSegment::new_error(
0,
0.0,
format!("Failed to get file path: {}", e),
);
let error_marker =
AudioSegment::new_error(0, 0.0, format!("Failed to get file path: {}", e));
send_to_children!(error_marker);
continue;
}
@@ -290,7 +284,10 @@ async fn decode_and_emit_track(
const MIN_FILE_SIZE: u64 = 512 * 1024; // 512 KB (prebuffer size)
if file_size >= MIN_FILE_SIZE || cache.is_download_complete(cache_pk) {
tracing::trace!("decode_and_emit_track: file ready ({} bytes), starting decode", file_size);
tracing::trace!(
"decode_and_emit_track: file ready ({} bytes), starting decode",
file_size
);
break;
}
@@ -417,7 +414,8 @@ async fn decode_and_emit_track(
let frames = pending.len() / frame_bytes;
if frames > 0 {
let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64;
let segment = bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?;
let segment =
bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?;
for tx in output {
tx.send(segment.clone())
.await
@@ -609,7 +607,8 @@ impl PlaylistSource {
chunk_frames: usize,
poll_interval_ms: u64,
) -> Self {
let logic = PlaylistSourceLogic::new(playlist_handle, cache, chunk_frames, poll_interval_ms);
let logic =
PlaylistSourceLogic::new(playlist_handle, cache, chunk_frames, poll_interval_ms);
Self {
inner: Node::new_source(logic),
}
@@ -626,10 +625,7 @@ impl AudioPipelineNode for PlaylistSource {
self.inner.register(child)
}
async fn run(
self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await
}
}
@@ -732,8 +728,7 @@ mod tests {
assert_eq!(segment.timestamp_sec, 0.0);
match &segment.segment {
pmoaudio::_AudioSegment::Chunk(chunk) => {
match chunk.as_ref() {
pmoaudio::_AudioSegment::Chunk(chunk) => match chunk.as_ref() {
AudioChunk::I16(data) => {
let frames = data.get_frames();
assert_eq!(frames.len(), 2);
@@ -742,8 +737,7 @@ mod tests {
assert_eq!(data.get_sample_rate(), 44100);
}
_ => panic!("Expected I16 chunk"),
}
}
},
_ => panic!("Expected audio chunk"),
}
}
@@ -808,8 +802,7 @@ mod tests {
let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap();
match &segment.segment {
pmoaudio::_AudioSegment::Chunk(chunk) => {
match chunk.as_ref() {
pmoaudio::_AudioSegment::Chunk(chunk) => match chunk.as_ref() {
AudioChunk::I24(data) => {
let frames = data.get_frames();
assert_eq!(frames.len(), 1);
@@ -817,8 +810,7 @@ mod tests {
assert_eq!(frames[0][1].as_i32(), -1000);
}
_ => panic!("Expected I24 chunk"),
}
}
},
_ => panic!("Expected audio chunk"),
}
}
@@ -843,16 +835,14 @@ mod tests {
let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap();
match &segment.segment {
pmoaudio::_AudioSegment::Chunk(chunk) => {
match chunk.as_ref() {
pmoaudio::_AudioSegment::Chunk(chunk) => match chunk.as_ref() {
AudioChunk::I32(data) => {
let frames = data.get_frames();
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], [4096, 8192]);
}
_ => panic!("Expected I32 chunk"),
}
}
},
_ => panic!("Expected audio chunk"),
}
}

View File

@@ -6,7 +6,9 @@ use tokio::fs::File;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let path_str = std::env::args().nth(1).expect("Usage: check_flac_bits <file.flac>");
let path_str = std::env::args()
.nth(1)
.expect("Usage: check_flac_bits <file.flac>");
let path = Path::new(&path_str);
println!("Checking: {}", path.display());

View File

@@ -88,7 +88,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
match result {
Ok(()) => {
println!();
println!("✓ Conversion completed successfully in {:.2}s", elapsed.as_secs_f64());
println!(
"✓ Conversion completed successfully in {:.2}s",
elapsed.as_secs_f64()
);
println!(" Output file: {}", output_path);
println!();

View File

@@ -45,7 +45,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Gérer Ctrl+C pour arrêt propre
tokio::spawn(async move {
tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
tokio::signal::ctrl_c()
.await
.expect("Failed to listen for Ctrl+C");
println!("\nArrêt demandé...");
stop_token_clone.cancel();
});

View File

@@ -52,8 +52,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
resampler.register(Box::new(converter));
converter.register(Box::new(sink));
println!("Pipeline créé: FileSource → Resampling({} Hz) → ToI24 → AudioSink",
target_sample_rate);
println!(
"Pipeline créé: FileSource → Resampling({} Hz) → ToI24 → AudioSink",
target_sample_rate
);
println!("Démarrage de la lecture...");
println!("Appuyez sur Ctrl+C pour arrêter");
@@ -63,7 +65,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Gérer Ctrl+C
tokio::spawn(async move {
tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
tokio::signal::ctrl_c()
.await
.expect("Failed to listen for Ctrl+C");
println!("\nArrêt demandé...");
stop_token_clone.cancel();
});

View File

@@ -678,9 +678,11 @@ impl AudioIntegerChunk {
AudioIntegerChunk::I16(d) => {
Box::new(d.get_frames().iter().map(|f| [f[0] as i32, f[1] as i32]))
}
AudioIntegerChunk::I24(d) => {
Box::new(d.get_frames().iter().map(|f| [f[0].as_i32(), f[1].as_i32()]))
}
AudioIntegerChunk::I24(d) => Box::new(
d.get_frames()
.iter()
.map(|f| [f[0].as_i32(), f[1].as_i32()]),
),
AudioIntegerChunk::I32(d) => Box::new(d.get_frames().iter().map(|f| [f[0], f[1]])),
}
}

View File

@@ -247,11 +247,7 @@ fn i16_stereo_to_pairs_f32_inner(
}
/// Convertit deux canaux i16 (L/R) en pairs f32 normalisées [-1.0, 1.0]
pub fn i16_stereo_to_pairs_f32(
left: &[i16],
right: &[i16],
out_pairs: &mut [[f32; 2]],
) {
pub fn i16_stereo_to_pairs_f32(left: &[i16], right: &[i16], out_pairs: &mut [[f32; 2]]) {
i16_stereo_to_pairs_f32_inner(left, right, out_pairs, 32768.0);
}
@@ -332,11 +328,7 @@ fn pairs_f32_to_i16_stereo_inner(
}
/// Convertit pairs f32 normalisées [-1.0, 1.0] en deux canaux i16 (L/R)
pub fn pairs_f32_to_i16_stereo(
input_pairs: &[[f32; 2]],
left: &mut [i16],
right: &mut [i16],
) {
pub fn pairs_f32_to_i16_stereo(input_pairs: &[[f32; 2]], left: &mut [i16], right: &mut [i16]) {
pairs_f32_to_i16_stereo_inner(input_pairs, left, right, 32768.0);
}
@@ -399,11 +391,7 @@ fn i24_as_i32_stereo_to_pairs_f32_inner(
}
/// Convertit deux canaux i32 (contenant des valeurs I24) en pairs f32 normalisées
pub fn i24_as_i32_stereo_to_pairs_f32(
left: &[i32],
right: &[i32],
out_pairs: &mut [[f32; 2]],
) {
pub fn i24_as_i32_stereo_to_pairs_f32(left: &[i32], right: &[i32], out_pairs: &mut [[f32; 2]]) {
i24_as_i32_stereo_to_pairs_f32_inner(left, right, out_pairs, 8388608.0);
}

View File

@@ -124,6 +124,7 @@ pub use nodes::{
flac_file_sink::{FlacFileSink, FlacFileSinkStats},
http_source::HttpSource,
resampling_node::ResamplingNode,
timer_buffer_node::TimerBufferNode,
timer_node::TimerNode,
AudioError, AudioNode, TypedAudioNode,
};

View File

@@ -220,8 +220,7 @@ impl AudioSinkLogic {
chunk.sample_rate()
);
}
crate::_AudioSegment::Sync(marker) => {
match **marker {
crate::_AudioSegment::Sync(marker) => match **marker {
SyncMarker::TrackBoundary { .. } => {
tracing::debug!("AudioSink (null): TrackBoundary received");
}
@@ -232,8 +231,7 @@ impl AudioSinkLogic {
_ => {
tracing::trace!("AudioSink (null): sync marker");
}
}
}
},
}
}
}
@@ -273,12 +271,15 @@ impl NodeLogic for AudioSinkLogic {
.default_output_device()
.ok_or_else(|| AudioError::ProcessingError("No output device available".to_string()))?;
tracing::debug!("Using audio device: {}", device.name().unwrap_or_else(|_| "Unknown".to_string()));
tracing::debug!(
"Using audio device: {}",
device.name().unwrap_or_else(|_| "Unknown".to_string())
);
// Obtenir la config par défaut
let config = device
.default_output_config()
.map_err(|e| AudioError::ProcessingError(format!("Failed to get output config: {}", e)))?;
let config = device.default_output_config().map_err(|e| {
AudioError::ProcessingError(format!("Failed to get output config: {}", e))
})?;
let sample_format = config.sample_format();
let sample_rate = config.sample_rate().0;
@@ -467,7 +468,9 @@ impl NodeLogic for AudioSinkLogic {
// Le buffer continue automatiquement - pas besoin d'action
}
SyncMarker::EndOfStream => {
tracing::debug!("AudioSink: EndOfStream received, waiting for playback to finish");
tracing::debug!(
"AudioSink: EndOfStream received, waiting for playback to finish"
);
// Marquer la fin et attendre que le buffer se vide
buffer.lock().unwrap().mark_end();
@@ -576,10 +579,7 @@ impl AudioPipelineNode for AudioSink {
panic!("AudioSink is a terminal node and cannot have children");
}
async fn run(
self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await
}
}

View File

@@ -41,7 +41,11 @@ impl NodeLogic for FileSourceLogic {
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
tracing::debug!("FileSourceLogic::process started, path={:?}, {} children", self.path, output.len());
tracing::debug!(
"FileSourceLogic::process started, path={:?}, {} children",
self.path,
output.len()
);
// Macro helper pour envoyer à tous les enfants
macro_rules! send_to_children {
@@ -55,9 +59,9 @@ impl NodeLogic for FileSourceLogic {
}
// Ouvrir le fichier
let file = File::open(&self.path).await.map_err(|e| {
AudioError::IoError(format!("Failed to open {:?}: {}", self.path, e))
})?;
let file = File::open(&self.path)
.await
.map_err(|e| AudioError::IoError(format!("Failed to open {:?}: {}", self.path, e)))?;
// Décoder le flux audio
let mut stream = decode_audio_stream(file)
@@ -256,10 +260,7 @@ impl AudioPipelineNode for FileSource {
self.inner.register(child)
}
async fn run(
self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await
}
}
@@ -399,7 +400,6 @@ fn bytes_to_segment(
}))
}
impl TypedAudioNode for FileSource {
fn input_type(&self) -> Option<TypeRequirement> {
// FileSource est une source, elle ne consomme pas d'audio
@@ -464,11 +464,7 @@ mod tests {
impl TestCollectorNode {
fn new(test_tx: mpsc::Sender<Arc<AudioSegment>>) -> Self {
let (tx, rx) = mpsc::channel(16);
Self {
tx,
rx,
test_tx,
}
Self { tx, rx, test_tx }
}
}

View File

@@ -71,7 +71,10 @@ impl NodeLogic for FlacFileSinkLogic {
let mut rx = input.expect("FlacFileSink must have input");
let mut track_number = 0;
tracing::debug!("FlacFileSinkLogic::process started, base_path={:?}", self.base_path);
tracing::debug!(
"FlacFileSinkLogic::process started, base_path={:?}",
self.base_path
);
loop {
// Vérifier si l'arrêt a été demandé
@@ -81,7 +84,8 @@ impl NodeLogic for FlacFileSinkLogic {
}
// Attendre le premier chunk audio pour cette track, en capturant les métadonnées du TrackBoundary
let (first_segment, track_metadata) = match wait_for_first_audio_chunk_with_metadata(&mut rx, &stop_token).await {
let (first_segment, track_metadata) =
match wait_for_first_audio_chunk_with_metadata(&mut rx, &stop_token).await {
Ok(result) => result,
Err(_) => {
// Plus d'audio disponible ou arrêt demandé
@@ -96,7 +100,9 @@ impl NodeLogic for FlacFileSinkLogic {
tracing::debug!(
"FlacFileSinkLogic: encoding track {} with {}bit @ {}Hz",
track_number, bits_per_sample, sample_rate
track_number,
bits_per_sample,
sample_rate
);
let format = PcmFormat {
@@ -308,10 +314,7 @@ impl FlacFileSink {
///
/// * `base_path` - Chemin de base pour les fichiers FLAC
/// * `channel_size` - Taille du buffer MPSC (nombre de segments en attente avant backpressure)
pub fn with_channel_size<P: Into<PathBuf>>(
base_path: P,
channel_size: usize,
) -> Self {
pub fn with_channel_size<P: Into<PathBuf>>(base_path: P, channel_size: usize) -> Self {
Self::with_config(base_path, channel_size, EncoderOptions::default())
}
@@ -360,7 +363,13 @@ fn generate_track_path(base_path: &Path, track_number: usize) -> PathBuf {
async fn wait_for_first_audio_chunk_with_metadata(
rx: &mut mpsc::Receiver<Arc<AudioSegment>>,
stop_token: &CancellationToken,
) -> Result<(Arc<AudioSegment>, Option<Arc<tokio::sync::RwLock<dyn pmometadata::TrackMetadata>>>), AudioError> {
) -> Result<
(
Arc<AudioSegment>,
Option<Arc<tokio::sync::RwLock<dyn pmometadata::TrackMetadata>>>,
),
AudioError,
> {
let mut track_metadata: Option<Arc<tokio::sync::RwLock<dyn pmometadata::TrackMetadata>>> = None;
loop {
@@ -738,10 +747,7 @@ impl AudioPipelineNode for FlacFileSink {
panic!("FlacFileSink is a terminal node and cannot have children");
}
async fn run(
self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await
}
}
@@ -768,7 +774,6 @@ mod tests {
#[tokio::test]
async fn test_flac_file_sink_writes_metadata() {
let temp_dir = tempfile::tempdir().unwrap();
let output_path = temp_dir.path().join("output_with_metadata.flac");
@@ -779,9 +784,8 @@ mod tests {
let sink = FlacFileSink::with_channel_size(&output_path, 16);
let tx = sink.get_tx().unwrap();
let stop_token = CancellationToken::new();
let sink_handle = tokio::spawn(async move {
Box::new(sink).run(stop_token).await.unwrap()
});
let sink_handle =
tokio::spawn(async move { Box::new(sink).run(stop_token).await.unwrap() });
// Envoyer des segments avec métadonnées
tokio::spawn(async move {
@@ -792,13 +796,25 @@ mod tests {
// TrackBoundary avec métadonnées
let mut metadata = MemoryTrackMetadata::new();
metadata.set_title(Some("Test Track Title".to_string())).await.unwrap();
metadata.set_artist(Some("Test Artist".to_string())).await.unwrap();
metadata.set_album(Some("Test Album".to_string())).await.unwrap();
metadata
.set_title(Some("Test Track Title".to_string()))
.await
.unwrap();
metadata
.set_artist(Some("Test Artist".to_string()))
.await
.unwrap();
metadata
.set_album(Some("Test Album".to_string()))
.await
.unwrap();
metadata.set_year(Some(2024)).await.unwrap();
let track_boundary =
crate::AudioSegment::new_track_boundary(0, 0.0, std::sync::Arc::new(tokio::sync::RwLock::new(metadata)));
let track_boundary = crate::AudioSegment::new_track_boundary(
0,
0.0,
std::sync::Arc::new(tokio::sync::RwLock::new(metadata)),
);
tx.send(track_boundary).await.unwrap();
// Générer et envoyer des chunks audio
@@ -900,9 +916,8 @@ mod tests {
let sink = FlacFileSink::with_channel_size(&output_path, 16);
let tx = sink.get_tx().unwrap();
let stop_token = CancellationToken::new();
let sink_handle = tokio::spawn(async move {
Box::new(sink).run(stop_token).await.unwrap()
});
let sink_handle =
tokio::spawn(async move { Box::new(sink).run(stop_token).await.unwrap() });
// Lire le fichier input et envoyer les segments au sink
tokio::spawn(async move {

View File

@@ -138,9 +138,7 @@ impl NodeLogic for HttpSourceLogic {
}
// Effectuer la requête HTTP
let response = reqwest::get(&self.url)
.await
.map_err(|e| {
let response = reqwest::get(&self.url).await.map_err(|e| {
AudioError::ProcessingError(format!("HTTP request failed for {}: {}", self.url, e))
})?;
@@ -158,7 +156,8 @@ impl NodeLogic for HttpSourceLogic {
// Convertir le stream de bytes en AsyncRead
let bytes_stream = response.bytes_stream();
let stream_reader = StreamReader::new(bytes_stream.map(|result| {
let stream_reader =
StreamReader::new(bytes_stream.map(|result| {
result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
}));
@@ -183,11 +182,8 @@ impl NodeLogic for HttpSourceLogic {
send_to_children!(AudioSegment::new_top_zero_sync());
// Émettre TrackBoundary avec les métadonnées HTTP
let track_boundary = AudioSegment::new_track_boundary(
0,
0.0,
Arc::new(tokio::sync::RwLock::new(metadata)),
);
let track_boundary =
AudioSegment::new_track_boundary(0, 0.0, Arc::new(tokio::sync::RwLock::new(metadata)));
send_to_children!(track_boundary);
// Préparer la lecture des chunks audio
@@ -523,10 +519,7 @@ impl AudioPipelineNode for HttpSource {
self.inner.register(child)
}
async fn run(
self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await
}
}
@@ -698,7 +691,10 @@ mod tests {
}
// Vérifications
assert_eq!(received_frames, frames, "Tous les frames doivent être reçus");
assert_eq!(
received_frames, frames,
"Tous les frames doivent être reçus"
);
assert!(seen_top_zero, "TopZeroSync doit être émis");
assert!(seen_track_boundary, "TrackBoundary doit être émis");
assert!(seen_eos, "EndOfStream doit être émis");
@@ -725,7 +721,8 @@ mod tests {
bits_per_sample: 16,
};
let mut flac_stream = encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
let mut flac_stream =
encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
.await
.unwrap();
@@ -798,7 +795,10 @@ mod tests {
assert!(result.is_err(), "Doit retourner une erreur pour HTTP 404");
if let Err(AudioError::ProcessingError(msg)) = result {
assert!(msg.contains("404"), "Le message d'erreur doit mentionner le code 404");
assert!(
msg.contains("404"),
"Le message d'erreur doit mentionner le code 404"
);
} else {
panic!("Le type d'erreur doit être ProcessingError");
}
@@ -854,7 +854,8 @@ mod tests {
bits_per_sample: 16,
};
let mut flac_stream = encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
let mut flac_stream =
encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
.await
.unwrap();
@@ -898,6 +899,9 @@ mod tests {
}
}
assert!(found_title, "Le nom du fichier doit être utilisé comme titre");
assert!(
found_title,
"Le nom du fichier doit être utilisé comme titre"
);
}
}

View File

@@ -25,6 +25,7 @@ pub mod file_source;
pub mod flac_file_sink;
pub mod http_source;
pub mod resampling_node;
pub mod timer_buffer_node;
pub mod timer_node;
// Modules temporairement désactivés

View File

@@ -95,7 +95,9 @@ impl ResamplingLogic {
bit_depth
);
let resampler = build_resampler(source_sr, self.target_sample_rate, bit_depth)
.map_err(|e| AudioError::ProcessingError(format!("Resampler init failed: {}", e)))?;
.map_err(|e| {
AudioError::ProcessingError(format!("Resampler init failed: {}", e))
})?;
self.current_resampler = Some(ResamplerState {
source_hz: source_sr,
resampler,
@@ -111,7 +113,12 @@ impl ResamplingLogic {
let (resampled_left, resampled_right) = resampling(&left, &right, &mut state.resampler);
// Recréer le chunk avec le nouveau sample rate
reconstruct_chunk(chunk, resampled_left, resampled_right, self.target_sample_rate)
reconstruct_chunk(
chunk,
resampled_left,
resampled_right,
self.target_sample_rate,
)
}
}
@@ -360,10 +367,7 @@ impl AudioPipelineNode for ResamplingNode {
self.inner.register(child)
}
async fn run(
self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await
}
}
@@ -418,11 +422,7 @@ mod tests {
#[test]
fn test_reconstruct_chunk_i16() {
let original = AudioChunk::I16(AudioChunkData::new(
vec![[100, 200]],
44100,
0.0,
));
let original = AudioChunk::I16(AudioChunkData::new(vec![[100, 200]], 44100, 0.0));
let left = vec![100i32, 300i32];
let right = vec![200i32, 400i32];
@@ -495,7 +495,7 @@ mod tests {
// Créer un TrackBoundary
let metadata = Arc::new(tokio::sync::RwLock::new(
pmometadata::MemoryTrackMetadata::new()
pmometadata::MemoryTrackMetadata::new(),
));
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata);
@@ -568,7 +568,11 @@ mod tests {
// 100 frames @ 44.1kHz ≈ 109 frames @ 48kHz
if let AudioChunk::I16(data) = chunk.as_ref() {
let frames = data.get_frames().len();
assert!(frames >= 105 && frames <= 115, "Expected ~109 frames, got {}", frames);
assert!(
frames >= 105 && frames <= 115,
"Expected ~109 frames, got {}",
frames
);
}
} else {
panic!("Expected audio chunk");

View File

@@ -0,0 +1,357 @@
//! TimerBufferNode - Maintient un tampon temporel capacitif avant diffusion
//!
//! Ce node implémente un buffer capacitif qui accumule un temps configurable
//! de données audio avant de les diffuser. Une fois le buffer rempli, il
//! maintient ce niveau en diffusant les données au même rythme qu'elles arrivent.
//!
//! # Use Cases
//!
//! - **Buffering initial**: Accumule N secondes de données avant de commencer la lecture
//! - **Smoothing**: Absorbe les variations de débit entre source et sink
//! - **Streaming**: Pré-charge un buffer pour éviter les coupures
//!
//! # Exemple
//!
//! ```no_run
//! use pmoaudio::{HttpSource, TimerBufferNode, AudioSink};
//!
//! let mut source = HttpSource::new(url);
//! let mut buffer = TimerBufferNode::new(3.0); // Buffer 3s avant de commencer
//! let mut sink = AudioSink::new();
//!
//! source.register(Box::new(buffer));
//! buffer.register(Box::new(sink));
//! ```
//!
//! # Architecture
//!
//! ```text
//! HttpSource → TimerBufferNode → AudioSink
//! ↓ ↓ ↓
//! Flux réseau Buffer 3s Lecture stable
//! variable capacitif sans coupures
//! ```
//!
//! Le TimerBufferNode:
//! 1. Accumule les chunks dans un buffer jusqu'à atteindre `capacity_sec`
//! 2. Une fois plein, diffuse les chunks en mode FIFO
//! 3. Maintient un niveau constant d'environ `capacity_sec` secondes
//!
//! # Markers Supportés
//!
//! - **TopZeroSync**: Vide le buffer et reset le compteur
//! - **TrackBoundary**: Passthrough transparent
//! - **Heartbeat**: Passthrough transparent
//! - **EndOfStream**: Flush le buffer restant avant propagation
//!
//! # Performance
//!
//! - **CPU**: Minimal (VecDeque efficace)
//! - **Latency**: Ajoute `capacity_sec` de buffering initial
//! - **Memory**: Proportionnel à `capacity_sec` (ex: ~3MB pour 3s @ 48kHz stéréo)
use crate::{
nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE, DEFAULT_CHUNK_DURATION_MS},
pipeline::{AudioPipelineNode, Node, NodeLogic},
type_constraints::TypeRequirement,
AudioSegment, SyncMarker, _AudioSegment,
};
use std::{collections::VecDeque, sync::Arc};
use tokio::sync::mpsc;
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
// ═══════════════════════════════════════════════════════════════════════════
// TimerBufferNodeLogic - Logique pure de buffering capacitif
// ═══════════════════════════════════════════════════════════════════════════
/// Logique pure de buffering temporel capacitif
///
/// Maintient un buffer de taille fixe (en secondes) et diffuse les segments
/// en mode FIFO une fois le buffer rempli.
pub struct TimerBufferNodeLogic {
/// Capacité du buffer en secondes
capacity_sec: f64,
/// Temps actuellement bufferisé en secondes
buffered_time_sec: f64,
/// Durée par défaut d'un chunk (fallback)
default_chunk_duration_sec: f64,
/// Timestamp du chunk précédent (pour estimer les durées)
prev_input_ts: Option<f64>,
/// Buffer FIFO de segments avec leurs durées
buffer: VecDeque<(Arc<AudioSegment>, f64)>,
/// Nombre de chunks traités (pour instrumentation)
chunk_count: u64,
/// Nombre de chunks flushés (pour instrumentation)
flush_count: u64,
/// Dernier log d'instrumentation
last_stats_log: Option<Instant>,
}
impl TimerBufferNodeLogic {
pub fn new(capacity_sec: f64) -> Self {
Self {
capacity_sec: capacity_sec.max(0.0),
buffered_time_sec: 0.0,
default_chunk_duration_sec: DEFAULT_CHUNK_DURATION_MS / 1000.0,
prev_input_ts: None,
buffer: VecDeque::new(),
chunk_count: 0,
flush_count: 0,
last_stats_log: None,
}
}
/// Estime la durée d'un chunk basé sur le delta de timestamps
fn estimate_duration(&mut self, ts: f64) -> f64 {
if let Some(prev) = self.prev_input_ts {
let delta = (ts - prev).clamp(0.0, 10.0);
self.prev_input_ts = Some(ts);
if delta == 0.0 {
self.default_chunk_duration_sec
} else {
delta
}
} else {
self.prev_input_ts = Some(ts);
self.default_chunk_duration_sec
}
}
/// Flush un segment du buffer vers les outputs
async fn flush_one(
&mut self,
output: &[mpsc::Sender<Arc<AudioSegment>>],
) -> Result<(), AudioError> {
if let Some((segment, duration)) = self.buffer.pop_front() {
self.flush_count += 1;
self.buffered_time_sec = (self.buffered_time_sec - duration).max(0.0);
tracing::trace!(
"TimerBufferNode: flushing segment (ts={:.3}s, duration={:.3}s, remaining={:.3}s, {} items in buffer)",
segment.timestamp_sec,
duration,
self.buffered_time_sec,
self.buffer.len()
);
for tx in output {
tx.send(segment.clone())
.await
.map_err(|_| AudioError::ChildDied)?;
}
}
Ok(())
}
fn maybe_log_stats(&mut self) {
let now = Instant::now();
let should_log = match self.last_stats_log {
None => true,
Some(last) => now.duration_since(last).as_secs() >= 1,
};
if should_log {
self.last_stats_log = Some(now);
tracing::debug!(
"TimerBufferNode stats: chunks_received={} chunks_flushed={} buffered={:.3}s capacity={:.3}s buffer_items={}",
self.chunk_count,
self.flush_count,
self.buffered_time_sec,
self.capacity_sec,
self.buffer.len()
);
}
}
}
#[async_trait::async_trait]
impl NodeLogic for TimerBufferNodeLogic {
async fn process(
&mut self,
input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
let mut rx = input.expect("TimerBufferNode must have input");
tracing::info!(
"TimerBufferNodeLogic::process started (capacity={:.1}s), {} children",
self.capacity_sec,
output.len()
);
loop {
// ╔═══════════════════════════════════════════════════════════════╗
// ║ LOGIQUE CAPACITIVE PAR BACKPRESSURE NATURELLE ║
// ║ ║
// ║ Si le buffer >= capacity, on flush en continu (boucle) ║
// ║ sans recevoir de nouveaux segments. Cela force la ║
// ║ backpressure en amont si le sink en aval est lent. ║
// ╚═══════════════════════════════════════════════════════════════╝
if self.buffered_time_sec >= self.capacity_sec && !self.buffer.is_empty() {
self.flush_one(&output).await?;
continue;
}
let segment = tokio::select! {
_ = stop_token.cancelled() => {
tracing::debug!("TimerBufferNode cancelled");
break;
}
result = rx.recv() => {
match result {
Some(seg) => seg,
None => {
tracing::debug!("TimerBufferNode received EOF");
break;
}
}
}
};
match &segment.segment {
_AudioSegment::Sync(marker) => {
match &**marker {
SyncMarker::TopZeroSync => {
// Reset le buffer complètement
self.buffer.clear();
self.buffered_time_sec = 0.0;
self.prev_input_ts = Some(0.0);
self.chunk_count = 0;
self.flush_count = 0;
tracing::debug!("TimerBufferNode: TopZeroSync received, buffer reset");
}
_ => {
// Autres markers: passthrough transparent
}
}
// Propager le marker immédiatement
for tx in &output {
tx.send(segment.clone())
.await
.map_err(|_| AudioError::ChildDied)?;
}
}
_AudioSegment::Chunk(chunk) => {
self.chunk_count += 1;
// Calculer la durée du chunk
let frames = chunk.len() as f64;
let sample_rate = chunk.sample_rate() as f64;
let duration = if frames > 0.0 && sample_rate > 0.0 {
frames / sample_rate
} else {
self.estimate_duration(segment.timestamp_sec)
};
tracing::trace!(
"TimerBufferNode: received chunk (ts={:.3}s, duration={:.3}s, buffered={:.3}s, capacity={:.3}s)",
segment.timestamp_sec,
duration,
self.buffered_time_sec,
self.capacity_sec
);
// Ajouter le chunk au buffer
self.buffer.push_back((segment.clone(), duration));
self.buffered_time_sec += duration;
// ╔═══════════════════════════════════════════════════════════╗
// ║ FLUSH IMMÉDIAT : Vider aussi vite que possible ║
// ║ ║
// ║ Le send() bloquera si le sink est lent, créant ║
// ║ naturellement la backpressure. Le buffer se remplit ║
// ║ pendant que send() attend, jusqu'à atteindre capacity. ║
// ╚═══════════════════════════════════════════════════════════╝
self.flush_one(&output).await?;
self.maybe_log_stats();
}
}
}
// EOF reçu, flusher le buffer restant
tracing::info!(
"TimerBufferNode: EOF received, flushing remaining buffer ({:.3}s, {} items)",
self.buffered_time_sec,
self.buffer.len()
);
while !self.buffer.is_empty() {
self.flush_one(&output).await?;
}
tracing::debug!("TimerBufferNodeLogic::process finished");
Ok(())
}
}
// ═══════════════════════════════════════════════════════════════════════════
// TimerBufferNode - Wrapper utilisant Node<TimerBufferNodeLogic>
// ═══════════════════════════════════════════════════════════════════════════
pub struct TimerBufferNode {
inner: Node<TimerBufferNodeLogic>,
}
impl TimerBufferNode {
/// Crée un TimerBufferNode avec une capacité donnée
///
/// # Arguments
///
/// * `capacity_sec` - Capacité du buffer en secondes (ex: 3.0 pour 3s)
///
/// # Exemples
///
/// ```no_run
/// use pmoaudio::TimerBufferNode;
///
/// // Buffer 3 secondes avant de commencer la diffusion
/// let buffer = TimerBufferNode::new(3.0);
/// ```
pub fn new(capacity_sec: f64) -> Self {
Self::with_channel_size(capacity_sec, DEFAULT_CHANNEL_SIZE)
}
/// Crée un TimerBufferNode avec une taille de buffer MPSC personnalisée
///
/// # Arguments
///
/// * `capacity_sec` - Capacité du buffer en secondes
/// * `channel_size` - Taille du buffer MPSC (nombre de segments en attente)
pub fn with_channel_size(capacity_sec: f64, channel_size: usize) -> Self {
let logic = TimerBufferNodeLogic::new(capacity_sec);
Self {
inner: Node::new_with_input(logic, channel_size),
}
}
}
#[async_trait::async_trait]
impl AudioPipelineNode for TimerBufferNode {
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
self.inner.get_tx()
}
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
self.inner.register(child);
}
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await
}
}
impl TypedAudioNode for TimerBufferNode {
fn input_type(&self) -> Option<TypeRequirement> {
// Accepte n'importe quel type
Some(TypeRequirement::any())
}
fn output_type(&self) -> Option<TypeRequirement> {
// Passthrough: produit le même type qu'il consomme
Some(TypeRequirement::any())
}
}

View File

@@ -73,15 +73,46 @@ use tokio_util::sync::CancellationToken;
pub struct TimerNodeLogic {
/// Avance maximale tolérée en secondes (buffer)
max_lead_time_sec: f64,
/// Tolérance supplémentaire avant de resynchroniser l'horloge
catchup_slack_sec: f64,
/// Instant de référence (reset au TopZeroSync)
start_time: Option<Instant>,
/// Nombre de chunks traités (pour instrumentation)
chunk_count: u64,
/// Dernier log d'instrumentation
last_stats_log: Option<Instant>,
}
impl TimerNodeLogic {
pub fn new(max_lead_time_sec: f64) -> Self {
let max_lead = max_lead_time_sec.max(0.0);
let slack = (max_lead * 0.25).max(0.5);
Self {
max_lead_time_sec: max_lead_time_sec.max(0.0),
max_lead_time_sec: max_lead,
catchup_slack_sec: slack,
start_time: None,
chunk_count: 0,
last_stats_log: None,
}
}
fn maybe_log_stats(&mut self, chunk_timestamp: f64, elapsed: f64, lead_time: f64) {
let now = Instant::now();
let should_log = match self.last_stats_log {
None => true,
Some(last) => now.duration_since(last) >= Duration::from_secs(1),
};
if should_log {
self.last_stats_log = Some(now);
tracing::debug!(
"TimerNode stats: chunks={} ts={:.3}s elapsed={:.3}s lead={:.3}s max={:.3}s",
self.chunk_count,
chunk_timestamp,
elapsed,
lead_time,
self.max_lead_time_sec
);
}
}
}
@@ -104,10 +135,20 @@ impl NodeLogic for TimerNodeLogic {
// Macro helper pour envoyer à tous les enfants
macro_rules! send_to_children {
($segment:expr) => {
for tx in &output {
for (idx, tx) in output.iter().enumerate() {
let send_start = Instant::now();
tx.send($segment.clone())
.await
.map_err(|_| AudioError::ChildDied)?;
let send_duration = send_start.elapsed();
if send_duration.as_millis() >= 50 {
tracing::debug!(
"TimerNode: send to child {} blocked for {:.3}s (segment ts={:.3}s)",
idx,
send_duration.as_secs_f64(),
$segment.timestamp_sec
);
}
}
};
}
@@ -149,9 +190,34 @@ impl NodeLogic for TimerNodeLogic {
_AudioSegment::Chunk(_) => {
// Vérifier le pacing seulement si on a un timer de référence
if let Some(start) = self.start_time {
self.chunk_count += 1;
let chunk_timestamp = segment.timestamp_sec;
let elapsed = start.elapsed().as_secs_f64();
let lead_time = chunk_timestamp - elapsed;
let mut elapsed = start.elapsed().as_secs_f64();
let mut lead_time = chunk_timestamp - elapsed;
// Si on a accumulé beaucoup trop d'avance (source ultra rapide),
// on recale l'horloge pour éviter de dormir pendant des dizaines de secondes.
let catchup_threshold = self.max_lead_time_sec + self.catchup_slack_sec;
if lead_time > catchup_threshold {
let desired_elapsed =
(chunk_timestamp - self.max_lead_time_sec).max(0.0);
let adjust = (desired_elapsed - elapsed).max(0.0);
let new_start =
Instant::now() - Duration::from_secs_f64(desired_elapsed);
self.start_time = Some(new_start);
elapsed = desired_elapsed;
lead_time = chunk_timestamp - elapsed;
tracing::warn!(
"TimerNode: lead {:.3}s > {:.3}s (max {:.3}s + slack {:.3}s) → fast-forward clock by {:.3}s",
chunk_timestamp - start.elapsed().as_secs_f64(),
catchup_threshold,
self.max_lead_time_sec,
self.catchup_slack_sec,
adjust
);
}
self.maybe_log_stats(chunk_timestamp, elapsed, lead_time);
tracing::trace!(
"TimerNodeLogic: chunk received (ts={:.3}s, elapsed={:.3}s, lead_time={:.3}s, max_lead={:.1}s)",
@@ -159,9 +225,9 @@ impl NodeLogic for TimerNodeLogic {
);
if lead_time > self.max_lead_time_sec {
// On est trop en avance, attendre
let sleep_duration = lead_time - self.max_lead_time_sec;
tracing::debug!(
// On est trop en avance, attendre juste assez pour retomber à max_lead_time
let sleep_duration = (lead_time - self.max_lead_time_sec).max(0.0);
tracing::trace!(
"TimerNodeLogic: SLEEPING {:.3}s (lead_time={:.3}s > max={:.1}s, chunk_ts={:.3}s)",
sleep_duration,
lead_time,
@@ -274,3 +340,4 @@ impl TypedAudioNode for TimerNode {
Some(TypeRequirement::any())
}
}

View File

@@ -104,10 +104,7 @@ pub trait AudioPipelineNode: Send + 'static {
/// - Un seul `cancel()` par nœud (en sortant de la boucle de travail)
/// - L'enfant ne cancel JAMAIS le parent
/// - `cancel()` est idempotent (pas de problème si appelé plusieurs fois)
async fn run(
self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError>;
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError>;
/// Lance le pipeline en arrière-plan et retourne un handle de contrôle
///
@@ -148,9 +145,7 @@ pub trait AudioPipelineNode: Send + 'static {
let stop_token = CancellationToken::new();
let token_for_task = stop_token.clone();
let join_handle = tokio::spawn(async move {
self.run(token_for_task).await
});
let join_handle = tokio::spawn(async move { self.run(token_for_task).await });
PipelineHandle {
stop_token,
@@ -373,12 +368,14 @@ impl PipelineHandle {
pub async fn wait(self) -> Result<(), AudioError> {
match self.join_handle.await {
Ok(result) => result,
Err(e) if e.is_panic() => Err(AudioError::ProcessingError(
format!("Pipeline task panicked: {}", e)
)),
Err(e) => Err(AudioError::ProcessingError(
format!("Pipeline task cancelled: {}", e)
)),
Err(e) if e.is_panic() => Err(AudioError::ProcessingError(format!(
"Pipeline task panicked: {}",
e
))),
Err(e) => Err(AudioError::ProcessingError(format!(
"Pipeline task cancelled: {}",
e
))),
}
}
@@ -506,10 +503,7 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
self.children.push(child);
}
async fn run(
mut self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
async fn run(mut self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
let Node {
mut logic,
rx,
@@ -528,9 +522,7 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
for (i, child) in children.into_iter().enumerate() {
tracing::debug!("Spawning child {}", i);
let child_token = stop_token.child_token();
let handle = tokio::spawn(async move {
child.run(child_token).await
});
let handle = tokio::spawn(async move { child.run(child_token).await });
child_handles.push(handle);
}
tracing::debug!("All {} children spawned", child_handles.len());
@@ -573,9 +565,10 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
// Un enfant a paniqué
tracing::error!("Child panicked: {}", e);
if !has_error {
first_error = Some(AudioError::ProcessingError(
format!("Child task panicked: {}", e)
));
first_error = Some(AudioError::ProcessingError(format!(
"Child task panicked: {}",
e
)));
has_error = true;
}
}
@@ -595,7 +588,8 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
// PHASE 3: EXÉCUTER LA LOGIQUE MÉTIER EN RACE AVEC LE MONITORING
// ═══════════════════════════════════════════════════════════════════
let (stop_reason, process_result, child_monitor_consumed) = if let Some(monitor) = &mut child_monitor {
let (stop_reason, process_result, child_monitor_consumed) =
if let Some(monitor) = &mut child_monitor {
// Il y a des enfants à surveiller
tokio::select! {
// Cancel externe demandé

View File

@@ -4,8 +4,13 @@ use tokio::sync::RwLock;
use pmometadata::TrackMetadata;
pub enum SyncMarker {
TrackBoundary { metadata: Arc<RwLock<dyn TrackMetadata>> },
StreamMetadata { key: String, value: String },
TrackBoundary {
metadata: Arc<RwLock<dyn TrackMetadata>>,
},
StreamMetadata {
key: String,
value: String,
},
TopZeroSync,
Heartbeat,
EndOfStream,

View File

@@ -298,7 +298,6 @@ mod tests {
{
let meta = track.read().await;
assert_eq!(meta.get_title().await.unwrap(), Some("Title".into()));
assert_eq!(meta.get_artist().await.unwrap(), Some("Artist".into()));
assert_eq!(meta.get_album().await.unwrap(), Some("Album".into()));

View File

@@ -70,7 +70,8 @@ pub struct Cache<C: CacheConfig> {
impl<C: CacheConfig> Cache<C> {
/// Retourne le chemin du fichier marker de complétion
fn get_completion_marker_path(&self, pk: &str) -> PathBuf {
self.get_file_path(pk).with_extension(format!("{}.complete", C::file_extension()))
self.get_file_path(pk)
.with_extension(format!("{}.complete", C::file_extension()))
}
/// Vérifie si un fichier est en cache et complet
@@ -118,10 +119,15 @@ impl<C: CacheConfig> Cache<C> {
};
if let Some(download) = download_handle {
tracing::debug!("Download already in progress for pk {}, waiting for prebuffering", pk);
tracing::debug!(
"Download already in progress for pk {}, waiting for prebuffering",
pk
);
if self.min_prebuffer_size > 0 {
download.wait_until_min_size(self.min_prebuffer_size).await
download
.wait_until_min_size(self.min_prebuffer_size)
.await
.map_err(|e| anyhow!("Prebuffering failed: {}", e))?;
tracing::debug!("Prebuffering complete for pk {}", pk);
}
@@ -138,9 +144,15 @@ impl<C: CacheConfig> Cache<C> {
async fn finalize_download(&self, pk: &str, download: Arc<Download>) -> Result<String> {
// Attendre le prébuffering (pour le cache progressif)
if self.min_prebuffer_size > 0 {
download.wait_until_min_size(self.min_prebuffer_size).await
download
.wait_until_min_size(self.min_prebuffer_size)
.await
.map_err(|e| anyhow!("Prebuffering failed: {}", e))?;
tracing::debug!("Prebuffering complete for pk {} ({} bytes)", pk, self.min_prebuffer_size);
tracing::debug!(
"Prebuffering complete for pk {} ({} bytes)",
pk,
self.min_prebuffer_size
);
}
// Lancer une tâche de nettoyage et marquage de complétion en background
@@ -155,7 +167,11 @@ impl<C: CacheConfig> Cache<C> {
// Créer le fichier marker de complétion si le téléchargement a réussi
if result.is_ok() {
if let Err(e) = std::fs::write(&completion_marker, "") {
tracing::warn!("Failed to create completion marker for pk {}: {}", pk_clone, e);
tracing::warn!(
"Failed to create completion marker for pk {}: {}",
pk_clone,
e
);
} else {
tracing::debug!("Created completion marker for pk {}", pk_clone);
}
@@ -368,7 +384,8 @@ impl<C: CacheConfig> Cache<C> {
where
R: AsyncRead + Send + Unpin + 'static,
{
self.add_from_reader_with_pk(source_uri, reader, length, collection, None).await
self.add_from_reader_with_pk(source_uri, reader, length, collection, None)
.await
}
/// Ajoute un fichier à partir d'un flux avec un pk explicite optionnel.

View File

@@ -163,20 +163,29 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
}
if !file_path.exists() {
tracing::warn!("is_valid_pk({}): File not created after 1s despite DB entry existing", pk);
tracing::warn!(
"is_valid_pk({}): File not created after 1s despite DB entry existing",
pk
);
return false;
}
tracing::debug!("is_valid_pk({}): File created after {}ms", pk, attempts * 10);
tracing::debug!(
"is_valid_pk({}): File created after {}ms",
pk,
attempts * 10
);
}
// Vérifier d'abord si le marker de completion existe
let completion_marker = file_path.with_extension(
format!("{}.complete", C::file_extension())
);
let completion_marker =
file_path.with_extension(format!("{}.complete", C::file_extension()));
if completion_marker.exists() {
tracing::debug!("is_valid_pk({}): Completion marker found, file is complete", pk);
tracing::debug!(
"is_valid_pk({}): Completion marker found, file is complete",
pk
);
return true;
}
@@ -190,7 +199,11 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
tracing::debug!("is_valid_pk({}): No marker but file is recent ({}s), download in progress", pk, age_secs);
return true;
} else {
tracing::debug!("is_valid_pk({}): No marker and file is old ({}s), incomplete download", pk, age_secs);
tracing::debug!(
"is_valid_pk({}): No marker and file is old ({}s), incomplete download",
pk,
age_secs
);
return false;
}
}
@@ -198,7 +211,10 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
}
// Ne peut pas vérifier le statut - rejeter par sécurité
tracing::debug!("is_valid_pk({}): Could not check file status, rejecting", pk);
tracing::debug!(
"is_valid_pk({}): Could not check file status, rejecting",
pk
);
false
}
}

View File

@@ -295,14 +295,28 @@ fn test_metadata_types() {
db.add(pk, None, None).unwrap();
// Tester les différents types de métadonnées
db.set_a_metadata(pk, "string_val", Value::String("test".to_string())).unwrap();
db.set_a_metadata(pk, "string_val", Value::String("test".to_string()))
.unwrap();
db.set_a_metadata(pk, "number_val", json!(42)).unwrap();
db.set_a_metadata(pk, "bool_val", Value::Bool(true)).unwrap();
db.set_a_metadata(pk, "bool_val", Value::Bool(true))
.unwrap();
db.set_a_metadata(pk, "null_val", Value::Null).unwrap();
// Vérifier les valeurs
assert_eq!(db.get_metadata_value(pk, "string_val").unwrap(), Some(Value::String("test".to_string())));
assert_eq!(db.get_metadata_value(pk, "number_val").unwrap(), Some(json!(42)));
assert_eq!(db.get_metadata_value(pk, "bool_val").unwrap(), Some(Value::Bool(true)));
assert_eq!(db.get_metadata_value(pk, "null_val").unwrap(), Some(Value::Null));
assert_eq!(
db.get_metadata_value(pk, "string_val").unwrap(),
Some(Value::String("test".to_string()))
);
assert_eq!(
db.get_metadata_value(pk, "number_val").unwrap(),
Some(json!(42))
);
assert_eq!(
db.get_metadata_value(pk, "bool_val").unwrap(),
Some(Value::Bool(true))
);
assert_eq!(
db.get_metadata_value(pk, "null_val").unwrap(),
Some(Value::Null)
);
}

View File

@@ -1,6 +1,6 @@
use image::{ImageBuffer, Rgba};
use pmocovers::cache;
use tempfile::TempDir;
use image::{ImageBuffer, Rgba};
fn create_test_cache() -> (TempDir, cache::Cache) {
let temp_dir = tempfile::tempdir().unwrap();
@@ -19,7 +19,10 @@ fn create_test_image(width: u32, height: u32) -> Vec<u8> {
});
let mut buffer = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Png)
img.write_to(
&mut std::io::Cursor::new(&mut buffer),
image::ImageFormat::Png,
)
.unwrap();
buffer
}

View File

@@ -90,7 +90,10 @@ async fn test_generate_variant() {
// Créer et ajouter une image
let img = create_test_image(400, 400);
let mut buffer = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Png)
img.write_to(
&mut std::io::Cursor::new(&mut buffer),
image::ImageFormat::Png,
)
.unwrap();
let test_file = tempfile::NamedTempFile::with_suffix(".png").unwrap();
@@ -130,7 +133,10 @@ async fn test_generate_variant_caching() {
// Créer et ajouter une image
let img = create_test_image(400, 400);
let mut buffer = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Png)
img.write_to(
&mut std::io::Cursor::new(&mut buffer),
image::ImageFormat::Png,
)
.unwrap();
let test_file = tempfile::NamedTempFile::with_suffix(".png").unwrap();

View File

@@ -255,10 +255,9 @@ where
// Try to extract genre and track_number from extra fields
let extra = metadata.get_extra().await.ok().flatten();
let genre = extra.as_ref().and_then(|e| e.get("genre").cloned());
let track_number = extra.as_ref().and_then(|e| {
e.get("track_number")
.and_then(|s| s.parse::<u32>().ok())
});
let track_number = extra
.as_ref()
.and_then(|e| e.get("track_number").and_then(|s| s.parse::<u32>().ok()));
Some(ExtractedMetadata {
title,
@@ -457,8 +456,7 @@ unsafe fn setup_metadata(
// Set the metadata on the encoder
let mut metadata_array = [meta];
let set_success =
FLAC__stream_encoder_set_metadata(encoder, metadata_array.as_mut_ptr(), 1);
let set_success = FLAC__stream_encoder_set_metadata(encoder, metadata_array.as_mut_ptr(), 1);
if set_success == 0 {
return Err(FlacError::LibFlacInit(

View File

@@ -0,0 +1,12 @@
host:
http_port: '8080'
cover_cache:
directory: cache_covers
size: 2000
audio_cache:
directory: cache_audio
size: 500
logger:
buffer_capacity: 200
enable_console: true
min_level: INFO

View File

@@ -84,10 +84,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Block Information:");
println!(" Event ID: {}", block.event);
println!(" Songs: {}", block.song_count());
println!(
" Duration: {:.1} minutes",
block.length as f64 / 60000.0
);
println!(" Duration: {:.1} minutes", block.length as f64 / 60000.0);
println!();
// Afficher la liste des pistes

View File

@@ -46,7 +46,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.add_directive("pmoaudio_ext=debug".parse()?)
.add_directive("pmoplaylist=debug".parse()?)
.add_directive("pmoparadise=debug".parse()?)
.add_directive("pmoaudiocache=debug".parse()?)
.add_directive("pmoaudiocache=debug".parse()?),
)
.init();
@@ -89,7 +89,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialiser les caches et le gestionnaire de playlist
// ═══════════════════════════════════════════════════════════════════════════
let base_dir = std::env::var("PMO_CONFIG_DIR").unwrap_or_else(|_| "/tmp/pmomusic_test".to_string());
let base_dir =
std::env::var("PMO_CONFIG_DIR").unwrap_or_else(|_| "/tmp/pmomusic_test".to_string());
std::fs::create_dir_all(&base_dir)?;
tracing::info!("Initializing caches in: {}", base_dir);
@@ -130,8 +131,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing::info!("Creating playlist: {}", playlist_id);
// Créer une playlist éphémère (non persistante) pour cet exemple
let writer = playlist_manager.get_write_handle(playlist_id.clone()).await?;
writer.set_title(format!("Radio Paradise - Channel {}", channel_id)).await?;
let writer = playlist_manager
.get_write_handle(playlist_id.clone())
.await?;
writer
.set_title(format!("Radio Paradise - Channel {}", channel_id))
.await?;
writer.flush().await?; // Vider la playlist si elle existait
tracing::debug!("Playlist created and flushed");
@@ -178,7 +183,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Créer la source Radio Paradise
let mut download_source = RadioParadiseStreamSource::new(client);
download_source.push_block_id(block.event);
tracing::debug!("RadioParadiseStreamSource created with block {}", block.event);
tracing::debug!(
"RadioParadiseStreamSource created with block {}",
block.event
);
// Créer le sink de cache FLAC
let mut cache_sink = FlacCacheSink::new(audio_cache.clone(), cover_cache.clone());

View File

@@ -9,7 +9,7 @@
//!
//! Architecture:
//! ```text
//! RadioParadiseStreamSource → TimerNode → StreamingFlacSink
//! RadioParadiseStreamSource → TimerBufferNode → StreamingFlacSink
//! ↓
//! StreamHandle
//! ↓
@@ -38,11 +38,11 @@ use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use pmoaudio::{AudioPipelineNode, TimerNode};
use pmoaudio::{AudioPipelineNode, TimerBufferNode};
use pmoaudio_ext::{StreamingFlacSink, StreamingOggFlacSink};
use pmoflac::EncoderOptions;
use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource, END_OF_BLOCKS_SIGNAL};
use pmoserver::{ServerBuilder, init_logging};
use pmoserver::{init_logging, ServerBuilder};
use std::env;
use std::sync::Arc;
use tokio_util::io::ReaderStream;
@@ -210,25 +210,40 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut source_flac = RadioParadiseStreamSource::new(client.clone());
source_flac.push_block_id(block.event);
source_flac.push_block_id(END_OF_BLOCKS_SIGNAL); // Signal: no more blocks after this one
tracing::debug!("RadioParadiseStreamSource (FLAC) created with block {} + END signal", block.event);
tracing::debug!(
"RadioParadiseStreamSource (FLAC) created with block {} + END signal",
block.event
);
// Use SMALL channel size to make backpressure more reactive
// Instead of trying to buffer 3s of audio (60 chunks), use a much smaller buffer
// This forces tighter backpressure control
let max_lead_time = 3.0;
let channel_size = 8; // Small buffer for reactive backpressure
tracing::debug!("Using channel size: {} chunks ({:.1}s buffer at 50ms/chunk)", channel_size, channel_size as f64 * 0.05);
let buffer_sec = 10.0;
let max_lead_time = buffer_sec;
let channel_size = 512;
tracing::debug!(
"Using channel size: {} chunks ({:.1}s buffer à 50ms/chunk)",
channel_size,
channel_size as f64 * 0.05
);
let mut timer_flac = TimerNode::with_channel_size(max_lead_time, channel_size);
tracing::debug!("TimerNode (FLAC) created with {:.1}s max lead time, {} chunk buffer", max_lead_time, channel_size);
let mut timer_flac = TimerBufferNode::with_channel_size(buffer_sec, channel_size);
tracing::debug!(
"TimerBufferNode (FLAC) created with {:.1}s buffer, {} chunk queue",
buffer_sec,
channel_size
);
// StreamingFlacSink doesn't take channel_size - it uses bits_per_sample (16, 24, or 32)
let (streaming_sink, stream_handle) = StreamingFlacSink::new(encoder_options.clone(), 16);
let (streaming_sink, stream_handle) =
StreamingFlacSink::with_max_broadcast_lead(encoder_options.clone(), 16, max_lead_time);
tracing::debug!("StreamingFlacSink created");
timer_flac.register(Box::new(streaming_sink));
source_flac.register(Box::new(timer_flac));
tracing::info!("Pipeline 1 connected: RadioParadiseStreamSource → TimerNode → StreamingFlacSink");
tracing::info!(
"Pipeline 1 connected: RadioParadiseStreamSource → TimerBufferNode → StreamingFlacSink"
);
// ─────────────────────────────────────────────────────────────────────────
// Pipeline 2: OGG-FLAC streaming
@@ -237,18 +252,29 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut source_ogg = RadioParadiseStreamSource::new(client);
source_ogg.push_block_id(block.event);
source_ogg.push_block_id(END_OF_BLOCKS_SIGNAL); // Signal: no more blocks after this one
tracing::debug!("RadioParadiseStreamSource (OGG) created with block {} + END signal", block.event);
tracing::debug!(
"RadioParadiseStreamSource (OGG) created with block {} + END signal",
block.event
);
let mut timer_ogg = TimerNode::with_channel_size(max_lead_time, channel_size);
tracing::debug!("TimerNode (OGG) created with {:.1}s max lead time, {} chunk buffer", max_lead_time, channel_size);
let mut timer_ogg = TimerBufferNode::with_channel_size(buffer_sec, channel_size);
tracing::debug!(
"TimerBufferNode (OGG) created with {:.1}s buffer, {} chunk queue",
buffer_sec,
channel_size
);
// StreamingOggFlacSink doesn't take channel_size - it uses bits_per_sample (16, 24, or 32)
let (ogg_sink, ogg_handle) = StreamingOggFlacSink::new(encoder_options, 16);
let (ogg_sink, ogg_handle) =
StreamingOggFlacSink::with_max_broadcast_lead(encoder_options, 16, max_lead_time);
tracing::debug!("StreamingOggFlacSink created");
timer_ogg.register(Box::new(ogg_sink));
source_ogg.register(Box::new(timer_ogg));
tracing::info!("Pipeline 2 connected: RadioParadiseStreamSource → TimerNode → StreamingOggFlacSink");
tracing::info!(
"Pipeline 2 connected: RadioParadiseStreamSource → TimerBufferNode → StreamingOggFlacSink"
);
// ═══════════════════════════════════════════════════════════════════════════
// Setup pmoserver with streaming routes
@@ -256,8 +282,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing::info!("Setting up pmoserver...");
let mut server = ServerBuilder::new("RadioParadiseStreamTest", "http://localhost", 8080)
.build();
let mut server =
ServerBuilder::new("RadioParadiseStreamTest", "http://localhost", 8080).build();
let app_state = Arc::new(AppState {
stream_handle,
@@ -265,12 +291,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
});
// Add streaming routes
server.add_handler_with_state("/test/stream", stream_handler, app_state.clone()).await;
server.add_handler_with_state("/test/stream-icy", stream_icy_handler, app_state.clone()).await;
server.add_handler_with_state("/test/stream-ogg", stream_ogg_handler, app_state.clone()).await;
server
.add_handler_with_state("/test/stream", stream_handler, app_state.clone())
.await;
server
.add_handler_with_state("/test/stream-icy", stream_icy_handler, app_state.clone())
.await;
server
.add_handler_with_state("/test/stream-ogg", stream_ogg_handler, app_state.clone())
.await;
// Add metadata route
server.add_handler_with_state("/test/metadata", metadata_handler, app_state.clone()).await;
server
.add_handler_with_state("/test/metadata", metadata_handler, app_state.clone())
.await;
// Add health check
server.add_handler("/test/health", health_handler).await;

View File

@@ -140,7 +140,8 @@ impl RadioParadiseClient {
url.query_pairs_mut()
.append_pair("bitrate", "4") // FLAC lossless
.append_pair("info", "true")
.append_pair("channel", &self.channel.to_string());
// RP API expects `chan` rather than `channel` for channel selection.
.append_pair("chan", &self.channel.to_string());
if let Some(event_id) = event {
url.query_pairs_mut()

View File

@@ -91,13 +91,15 @@ impl NodeStats {
/// Enregistre l'envoi d'un segment
pub fn record_segment_sent(&self, bytes: usize) {
self.segments_sent.fetch_add(1, Ordering::Relaxed);
self.bytes_processed.fetch_add(bytes as u64, Ordering::Relaxed);
self.bytes_processed
.fetch_add(bytes as u64, Ordering::Relaxed);
}
/// Enregistre un événement de backpressure
pub fn record_backpressure(&self, duration_ms: u64) {
self.backpressure_blocks.fetch_add(1, Ordering::Relaxed);
self.backpressure_time_ms.fetch_add(duration_ms, Ordering::Relaxed);
self.backpressure_time_ms
.fetch_add(duration_ms, Ordering::Relaxed);
}
/// Retourne un rapport formaté des statistiques
@@ -112,7 +114,11 @@ impl NodeStats {
let first_ts = self.first_segment_timestamp.load(Ordering::Relaxed);
let last_ts = self.last_segment_timestamp.load(Ordering::Relaxed);
let first_ts_sec = if first_ts == u64::MAX { 0.0 } else { first_ts as f64 / 1000.0 };
let first_ts_sec = if first_ts == u64::MAX {
0.0
} else {
first_ts as f64 / 1000.0
};
let last_ts_sec = last_ts as f64 / 1000.0;
let audio_duration = last_ts_sec - first_ts_sec;
@@ -126,12 +132,27 @@ impl NodeStats {
Audio: {:.1}s (first: {:.1}s, last: {:.1}s) | Real-time ratio: {:.1}%\n\
Backpressure: {} blocks, {:.2}s total ({:.1}% of time)",
self.name,
elapsed, received, sent, received.saturating_sub(sent),
mb, throughput_mbps,
audio_duration, first_ts_sec, last_ts_sec,
if audio_duration > 0.0 { (elapsed / audio_duration) * 100.0 } else { 0.0 },
bp_blocks, bp_time_ms as f64 / 1000.0,
if elapsed > 0.0 { (bp_time_ms as f64 / 1000.0 / elapsed) * 100.0 } else { 0.0 }
elapsed,
received,
sent,
received.saturating_sub(sent),
mb,
throughput_mbps,
audio_duration,
first_ts_sec,
last_ts_sec,
if audio_duration > 0.0 {
(elapsed / audio_duration) * 100.0
} else {
0.0
},
bp_blocks,
bp_time_ms as f64 / 1000.0,
if elapsed > 0.0 {
(bp_time_ms as f64 / 1000.0 / elapsed) * 100.0
} else {
0.0
}
)
}
}

View File

@@ -97,7 +97,9 @@ impl RadioParadiseStreamSourceLogic {
block.length as f64 / 60000.0,
block.url
);
let response = self.client.client
let response = self
.client
.client
.get(&block.url)
.timeout(self.client.block_timeout)
.send()
@@ -125,9 +127,9 @@ impl RadioParadiseStreamSourceLogic {
// Créer un stream reader
tracing::debug!("Creating byte stream reader");
let byte_stream = response.bytes_stream().map(|result| {
result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
});
let byte_stream = response
.bytes_stream()
.map(|result| result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)));
let stream_reader = StreamReader::new(byte_stream);
tracing::debug!("Stream reader created");
@@ -140,7 +142,11 @@ impl RadioParadiseStreamSourceLogic {
let stream_info = decoder.info().clone();
let sample_rate = stream_info.sample_rate;
let bits_per_sample = stream_info.bits_per_sample;
tracing::debug!("FLAC decoder initialized: {}Hz, {} bits/sample", sample_rate, bits_per_sample);
tracing::debug!(
"FLAC decoder initialized: {}Hz, {} bits/sample",
sample_rate,
bits_per_sample
);
// Préparer les songs ordonnées pour tracking
let songs = block.songs_ordered();
@@ -165,13 +171,16 @@ impl RadioParadiseStreamSourceLogic {
// Envoyer TrackBoundary pour la première song AVANT le premier chunk audio
// Même si son elapsed > 0, cela garantit que FlacCacheSink a des métadonnées
// dès le début (sinon il attendrait indéfiniment un TrackBoundary)
let mut next_song: Option<(usize, &Song)> = if let Some((idx, song)) = songs.get(0).copied() {
tracing::debug!("Sending TrackBoundary for first song (idx={}, elapsed={}ms) at timestamp 0",
idx, song.elapsed);
let mut next_song: Option<(usize, &Song)> = if let Some((idx, song)) = songs.get(0).copied()
{
tracing::debug!(
"Sending TrackBoundary for first song (idx={}, elapsed={}ms) at timestamp 0",
idx,
song.elapsed
);
let metadata = song_to_metadata(song, block).await;
let track_boundary = AudioSegment::new_track_boundary(
*order,
0.0, // timestamp = 0 au début du stream
*order, 0.0, // timestamp = 0 au début du stream
metadata,
);
self.send_to_children(output, track_boundary).await?;
@@ -183,7 +192,6 @@ impl RadioParadiseStreamSourceLogic {
};
tracing::debug!("Starting audio chunk loop");
// Buffer pour lecture
let bytes_per_sample = (bits_per_sample / 8) as usize;
let frame_bytes = bytes_per_sample * 2; // stereo
@@ -196,6 +204,7 @@ impl RadioParadiseStreamSourceLogic {
let mut chunk_count = 0;
let mut total_bytes_decoded = 0u64;
let expected_duration_sec = block.length as f64 / 1000.0;
let mut stats_last_log = Instant::now();
loop {
// Vérifier stop_token
@@ -213,7 +222,9 @@ impl RadioParadiseStreamSourceLogic {
// Remplir le buffer
if pending.len() < chunk_byte_len {
let read = decoder.read(&mut read_buf).await
let read = decoder
.read(&mut read_buf)
.await
.map_err(|e| AudioError::ProcessingError(format!("Read error: {}", e)))?;
if read == 0 {
@@ -263,22 +274,35 @@ impl RadioParadiseStreamSourceLogic {
);
let metadata = song_to_metadata(song, block).await;
let timestamp_sec = total_samples as f64 / sample_rate as f64;
let track_boundary = AudioSegment::new_track_boundary(
*order,
timestamp_sec,
metadata,
);
let track_boundary =
AudioSegment::new_track_boundary(*order, timestamp_sec, metadata);
self.send_to_children(output, track_boundary).await?;
// Passer à la song suivante
song_index += 1;
next_song = songs.get(song_index).copied();
tracing::debug!("Moved to next song, song_index={}, next_song present={}", song_index, next_song.is_some());
tracing::debug!(
"Moved to next song, song_index={}, next_song present={}",
song_index,
next_song.is_some()
);
}
}
// Envoyer le chunk audio
let timestamp_sec = total_samples as f64 / sample_rate as f64;
if stats_last_log.elapsed() >= Duration::from_secs(1) {
let real_elapsed = start_instant.elapsed().as_secs_f64();
tracing::debug!(
"RP timing: chunk={} ts={:.3}s real_elapsed={:.3}s delta={:.3}s chunk_len={} frames",
chunk_count,
timestamp_sec,
real_elapsed,
timestamp_sec - real_elapsed,
chunk_len
);
stats_last_log = Instant::now();
}
let audio_segment = pcm_to_audio_segment(
&pcm_data,
*order,
@@ -295,7 +319,11 @@ impl RadioParadiseStreamSourceLogic {
// Retourner le timestamp du dernier chunk (durée totale du bloc) et l'instant de début
let final_timestamp = total_samples as f64 / sample_rate as f64;
tracing::debug!("Block decode complete: {} samples, {:.2}s duration", total_samples, final_timestamp);
tracing::debug!(
"Block decode complete: {} samples, {:.2}s duration",
total_samples,
final_timestamp
);
Ok((final_timestamp, start_instant))
}
@@ -312,7 +340,9 @@ impl RadioParadiseStreamSourceLogic {
let capacity_before = tx.capacity();
tracing::trace!(
"send_to_children: Sending to child {} (channel capacity={}, timestamp={:.3}s)",
i, capacity_before, segment.timestamp_sec
i,
capacity_before,
segment.timestamp_sec
);
let send_start = std::time::Instant::now();
@@ -325,8 +355,11 @@ impl RadioParadiseStreamSourceLogic {
let duration_ms = send_duration.as_millis() as u64;
self.stats.record_backpressure(duration_ms);
tracing::debug!(
"send_to_children: Send to child {} BLOCKED for {:.3}s (backpressure triggered, timestamp={:.3}s)",
i, send_duration.as_secs_f64(), segment.timestamp_sec
"send_to_children: Send to child {} BLOCKED for {:.3}s (channel capacity before send={}, timestamp={:.3}s)",
i,
send_duration.as_secs_f64(),
capacity_before,
segment.timestamp_sec
);
}
@@ -519,7 +552,10 @@ impl NodeLogic for RadioParadiseStreamSourceLogic {
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
tracing::debug!("RadioParadiseStreamSource::process() started, block_queue has {} items", self.block_queue.len());
tracing::debug!(
"RadioParadiseStreamSource::process() started, block_queue has {} items",
self.block_queue.len()
);
for (i, event_id) in self.block_queue.iter().enumerate() {
tracing::debug!(" block_queue[{}] = {}", i, event_id);
}
@@ -544,7 +580,9 @@ impl NodeLogic for RadioParadiseStreamSourceLogic {
// Vérifier si c'est le signal de fin
if id == END_OF_BLOCKS_SIGNAL {
tracing::info!("Received END_OF_BLOCKS_SIGNAL, finishing after current block");
tracing::info!(
"Received END_OF_BLOCKS_SIGNAL, finishing after current block"
);
break None;
}
@@ -573,10 +611,10 @@ impl NodeLogic for RadioParadiseStreamSourceLogic {
// Récupérer les métadonnées du bloc
tracing::debug!("Fetching block metadata for event_id {}...", event_id);
let block = self.client
.get_block(Some(event_id))
.await
.map_err(|e| AudioError::ProcessingError(format!("Failed to get block: {}", e)))?;
let block =
self.client.get_block(Some(event_id)).await.map_err(|e| {
AudioError::ProcessingError(format!("Failed to get block: {}", e))
})?;
tracing::debug!("Block metadata received: url={}", block.url);
// Marquer comme téléchargé
@@ -584,15 +622,24 @@ impl NodeLogic for RadioParadiseStreamSourceLogic {
// Télécharger et décoder le bloc
tracing::info!("Starting download and decode for block {}...", event_id);
let (block_duration, start_instant) = self.download_and_decode_block(&block, &output, &stop_token, &mut order)
let (block_duration, start_instant) = self
.download_and_decode_block(&block, &output, &stop_token, &mut order)
.await?;
last_timestamp = block_duration;
last_start_instant = Some(start_instant);
tracing::info!("Finished download and decode for block {} (duration: {:.2}s)", event_id, block_duration);
tracing::info!(
"Finished download and decode for block {} (duration: {:.2}s)",
event_id,
block_duration
);
}
// Envoyer EndOfStream avec le timestamp du dernier chunk
tracing::info!("Sending EndOfStream with timestamp {:.2}s to {} outputs", last_timestamp, output.len());
tracing::info!(
"Sending EndOfStream with timestamp {:.2}s to {} outputs",
last_timestamp,
output.len()
);
let eos = AudioSegment::new_end_of_stream(order, last_timestamp);
for tx in &output {
tx.send(eos.clone())
@@ -692,7 +739,8 @@ mod tests {
#[test]
fn test_cache_fifo_basic() {
let client = create_test_client();
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
let mut logic =
RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
// Ajouter 5 blocs
for i in 1..=5 {
@@ -709,7 +757,8 @@ mod tests {
#[test]
fn test_cache_fifo_exactly_10_elements() {
let client = create_test_client();
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
let mut logic =
RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
// Ajouter exactement 10 blocs
for i in 1..=10 {
@@ -717,7 +766,11 @@ mod tests {
}
// Vérifier qu'on a exactement 10 éléments
assert_eq!(logic.recent_blocks.len(), 10, "Cache should have exactly 10 elements");
assert_eq!(
logic.recent_blocks.len(),
10,
"Cache should have exactly 10 elements"
);
// Tous devraient être dans le cache
for i in 1..=10 {
@@ -728,7 +781,8 @@ mod tests {
#[test]
fn test_cache_fifo_eviction_oldest() {
let client = create_test_client();
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
let mut logic =
RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
// Remplir le cache avec 10 éléments (1..=10)
for i in 1..=10 {
@@ -739,10 +793,17 @@ mod tests {
logic.mark_block_downloaded(11);
// Le cache doit toujours avoir 10 éléments
assert_eq!(logic.recent_blocks.len(), 10, "Cache should still have 10 elements");
assert_eq!(
logic.recent_blocks.len(),
10,
"Cache should still have 10 elements"
);
// Le premier (plus ancien) doit avoir été évincé
assert!(!logic.is_recent_block(1), "Oldest block (1) should be evicted");
assert!(
!logic.is_recent_block(1),
"Oldest block (1) should be evicted"
);
// Les éléments 2..=11 doivent être présents
for i in 2..=11 {
@@ -753,7 +814,8 @@ mod tests {
#[test]
fn test_cache_fifo_multiple_evictions() {
let client = create_test_client();
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
let mut logic =
RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
// Remplir avec 10 éléments
for i in 1..=10 {
@@ -766,7 +828,11 @@ mod tests {
}
// Toujours 10 éléments
assert_eq!(logic.recent_blocks.len(), 10, "Cache should have 10 elements");
assert_eq!(
logic.recent_blocks.len(),
10,
"Cache should have 10 elements"
);
// Les 5 premiers doivent avoir été évincés
for i in 1..=5 {
@@ -782,7 +848,8 @@ mod tests {
#[test]
fn test_cache_never_exceeds_capacity() {
let client = create_test_client();
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
let mut logic =
RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
// Vérifier la capacité pré-allouée
assert_eq!(logic.recent_blocks.capacity(), RECENT_BLOCKS_CACHE_SIZE);
@@ -812,7 +879,8 @@ mod tests {
#[test]
fn test_cache_fifo_order_preserved() {
let client = create_test_client();
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
let mut logic =
RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
// Ajouter 10 éléments
for i in 1..=10 {
@@ -830,7 +898,8 @@ mod tests {
#[test]
fn test_block_queue_push() {
let client = create_test_client();
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
let mut logic =
RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
// Tester push_block_id
logic.push_block_id(100);

View File

@@ -58,8 +58,7 @@ impl RadioParadiseSource {
#[cfg(feature = "server")]
pub fn from_registry(_client: RadioParadiseClient) -> Result<Self> {
Err(MusicSourceError::SourceUnavailable(
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead."
.to_string(),
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead.".to_string(),
))
}
@@ -139,8 +138,7 @@ impl MusicSource for RadioParadiseSource {
async fn resolve_uri(&self, _object_id: &str) -> Result<String> {
Err(MusicSourceError::SourceUnavailable(
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead."
.to_string(),
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead.".to_string(),
))
}
@@ -150,8 +148,7 @@ impl MusicSource for RadioParadiseSource {
async fn append_track(&self, _track: Item) -> Result<()> {
Err(MusicSourceError::SourceUnavailable(
"RadioParadiseSource is deprecated and does not support FIFO operations."
.to_string(),
"RadioParadiseSource is deprecated and does not support FIFO operations.".to_string(),
))
}

View File

@@ -48,6 +48,7 @@ async fn test_get_current_block() {
.and(path("/api/get_block"))
.and(query_param("bitrate", "4"))
.and(query_param("info", "true"))
.and(query_param("chan", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(1234, 5678)))
.mount(&mock_server)
.await;
@@ -82,6 +83,7 @@ async fn test_get_specific_block() {
.and(path("/api/get_block"))
.and(query_param("bitrate", "4"))
.and(query_param("info", "true"))
.and(query_param("chan", "0"))
.and(query_param("event", "5678"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(5678, 9012)))
.mount(&mock_server)
@@ -99,12 +101,38 @@ async fn test_get_specific_block() {
assert_eq!(block.end_event, 9012);
}
#[tokio::test]
async fn test_get_block_respects_channel() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/get_block"))
.and(query_param("bitrate", "4"))
.and(query_param("info", "true"))
.and(query_param("chan", "2"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(2222, 3333)))
.mount(&mock_server)
.await;
let client = RadioParadiseClient::builder()
.api_base(format!("{}/api", mock_server.uri()))
.channel(2)
.build()
.await
.unwrap();
let block = client.get_block(None).await.unwrap();
assert_eq!(block.event, 2222);
assert_eq!(block.end_event, 3333);
}
#[tokio::test]
async fn test_now_playing() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/get_block"))
.and(query_param("chan", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(1234, 5678)))
.mount(&mock_server)
.await;
@@ -133,6 +161,8 @@ async fn test_prefetch_next() {
// First block
Mock::given(method("GET"))
.and(path("/api/get_block"))
.and(query_param("chan", "0"))
.and(query_param("event", "1234"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(1234, 5678)))
.mount(&mock_server)
@@ -140,6 +170,8 @@ async fn test_prefetch_next() {
// Next block
Mock::given(method("GET"))
.and(path("/api/get_block"))
.and(query_param("chan", "0"))
.and(query_param("event", "5678"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(5678, 9012)))
.mount(&mock_server)

View File

@@ -23,11 +23,7 @@ use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use tracing::Level;
use tracing_subscriber::{
Registry,
filter::LevelFilter,
layer::SubscriberExt,
reload,
util::SubscriberInitExt,
Registry, filter::LevelFilter, layer::SubscriberExt, reload, util::SubscriberInitExt,
};
/// Représente une entrée de log
@@ -75,6 +71,11 @@ impl LogState {
}
}
/// Définit le niveau initial avant tout rechargement dynamique
pub fn set_initial_level(&self, level: Level) {
*self.max_level.write().unwrap() = level;
}
pub fn get_max_level(&self) -> Level {
*self.max_level.read().unwrap()
}
@@ -266,15 +267,41 @@ impl Default for LoggingOptions {
/// ```
pub fn init_logging() -> LogState {
let config = get_config();
// Créer un filtre rechargeable qui commence à TRACE
// Créer un filtre rechargeable qui commence au niveau déterminé par
// RUST_LOG (prioritaire) ou la configuration.
let log_level = match config.get_log_min_level() {
Ok(l) => match string_to_level(&l) {
Some(lev) => level_to_levelfilter(lev),
None => LevelFilter::TRACE,
},
Err(_) => LevelFilter::TRACE,
let (initial_level, level_source) = match std::env::var("RUST_LOG") {
Ok(value) => {
let trimmed = value.trim();
if let Some(level) = string_to_level(trimmed) {
(level, format!("RUST_LOG ({})", trimmed))
} else {
eprintln!(
"⚠️ Invalid RUST_LOG value '{}', falling back to configuration",
value
);
let fallback = config
.get_log_min_level()
.ok()
.and_then(|cfg| string_to_level(cfg.trim()))
.unwrap_or(Level::TRACE);
(fallback, "config".to_string())
}
}
Err(_) => {
let level = config
.get_log_min_level()
.ok()
.and_then(|cfg| string_to_level(cfg.trim()))
.unwrap_or(Level::TRACE);
(level, "config".to_string())
}
};
let log_level = level_to_levelfilter(initial_level);
eprintln!(
" Initial log level set to {:?} (source: {})",
initial_level, level_source
);
let (filter, reload_handle) = reload::Layer::new(log_level);
@@ -285,6 +312,7 @@ pub fn init_logging() -> LogState {
// Créer le LogState avec le handle de rechargement
let log_state = LogState::new(buffer_capacity, reload_handle);
log_state.set_initial_level(initial_level);
// Construire le subscriber avec le filtre rechargeable AVANT le SseLayer
// L'ordre est important : le filtre doit être appliqué en premier

View File

@@ -55,11 +55,10 @@ pub fn get_audio_cache() -> Option<Arc<AudioCache>> {
/// ```
pub fn build_cover_url(pk: &str, size: Option<usize>) -> anyhow::Result<String> {
// Récupérer l'URL de base depuis la variable d'environnement ou une config
let base_url = std::env::var("PMO_SERVER_URL")
.unwrap_or_else(|_| "http://localhost:8080".to_string());
let base_url =
std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
let cache = get_cover_cache()
.ok_or_else(|| anyhow::anyhow!("No registered cover cache"))?;
let cache = get_cover_cache().ok_or_else(|| anyhow::anyhow!("No registered cover cache"))?;
let param = match size {
Some(size_) => Some(size_.to_string()),
@@ -86,11 +85,10 @@ pub fn build_cover_url(pk: &str, size: Option<usize>) -> anyhow::Result<String>
/// ```
pub fn build_audio_url(pk: &str, param: Option<&str>) -> anyhow::Result<String> {
// Récupérer l'URL de base depuis la variable d'environnement ou une config
let base_url = std::env::var("PMO_SERVER_URL")
.unwrap_or_else(|_| "http://localhost:8080".to_string());
let base_url =
std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
let cache = get_audio_cache()
.ok_or_else(|| anyhow::anyhow!("No registered audio cache"))?;
let cache = get_audio_cache().ok_or_else(|| anyhow::anyhow!("No registered audio cache"))?;
let route = cache.route_for(pk, param);
Ok(format!("{}{}", base_url, route))

View File

@@ -60,7 +60,9 @@ impl UpnpObject for Service {
// eventSubURL
let mut event_sub_url = Element::new("eventSubURL");
event_sub_url.children.push(XMLNode::Text(self.event_route()));
event_sub_url
.children
.push(XMLNode::Text(self.event_route()));
elem.children.push(XMLNode::Element(event_sub_url));
elem