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" name = "PMOMusic"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum 0.8.6", "axum 0.8.7",
"console-subscriber", "console-subscriber",
"pmoapp", "pmoapp",
"pmoaudio-ext", "pmoaudio-ext",
@@ -103,7 +103,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -130,7 +130,7 @@ checksum = "f548ad2c4031f2902e3edc1f29c29e835829437de49562d8eb5dc5584d3a1043"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -163,7 +163,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -174,7 +174,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -252,9 +252,9 @@ dependencies = [
[[package]] [[package]]
name = "axum" name = "axum"
version = "0.8.6" version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425"
dependencies = [ dependencies = [
"axum-core 0.5.5", "axum-core 0.5.5",
"bytes", "bytes",
@@ -338,9 +338,9 @@ dependencies = [
[[package]] [[package]]
name = "axum-server" name = "axum-server"
version = "0.7.2" version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "495c05f60d6df0093e8fb6e74aa5846a0ad06abaf96d76166283720bf740f8ab" checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9"
dependencies = [ dependencies = [
"bytes", "bytes",
"fs-err", "fs-err",
@@ -373,7 +373,7 @@ dependencies = [
"parking_lot", "parking_lot",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
"toml_edit 0.23.7", "toml_edit 0.23.7",
] ]
@@ -439,7 +439,7 @@ dependencies = [
"indexmap 2.12.0", "indexmap 2.12.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
"uuid", "uuid",
] ]
@@ -470,7 +470,7 @@ dependencies = [
"regex", "regex",
"rustc-hash", "rustc-hash",
"shlex", "shlex",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -509,6 +509,15 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]] [[package]]
name = "built" name = "built"
version = "0.7.1" version = "0.7.1"
@@ -538,7 +547,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -555,9 +564,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.10.1" version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]] [[package]]
name = "c_linked_list" name = "c_linked_list"
@@ -567,9 +576,9 @@ checksum = "4964518bd3b4a8190e832886cdc0da9794f12e8e6c1613a9e90ff331c4c8724b"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.44" version = "1.2.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@@ -608,6 +617,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.42" version = "0.4.42"
@@ -619,7 +634,7 @@ dependencies = [
"num-traits", "num-traits",
"serde", "serde",
"wasm-bindgen", "wasm-bindgen",
"windows-link 0.2.1", "windows-link",
] ]
[[package]] [[package]]
@@ -890,9 +905,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.6" version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"typenum", "typenum",
@@ -945,7 +960,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -965,7 +980,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -999,6 +1014,16 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@@ -1007,7 +1032,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -1063,7 +1088,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -1074,9 +1099,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]] [[package]]
name = "erased-serde" name = "erased-serde"
version = "0.4.8" version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "259d404d09818dec19332e31d94558aeb442fea04c817006456c24b5460bbd4b" checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3"
dependencies = [ dependencies = [
"serde", "serde",
"serde_core", "serde_core",
@@ -1116,9 +1141,9 @@ dependencies = [
[[package]] [[package]]
name = "exr" name = "exr"
version = "1.73.0" version = "1.74.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
dependencies = [ dependencies = [
"bit_field", "bit_field",
"half", "half",
@@ -1170,7 +1195,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -1184,9 +1209,9 @@ dependencies = [
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.4" version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
[[package]] [[package]]
name = "flacenc" name = "flacenc"
@@ -1325,7 +1350,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -1366,9 +1391,9 @@ checksum = "8f5f3913fa0bfe7ee1fd8248b6b9f42a5af4b9d65ec2dd2c3c26132b950ecfc2"
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.9" version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [ dependencies = [
"typenum", "typenum",
"version_check", "version_check",
@@ -1619,9 +1644,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.7.0" version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
@@ -1687,9 +1712,9 @@ dependencies = [
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.17" version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
@@ -1907,7 +1932,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -2066,7 +2091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"windows-link 0.2.1", "windows-link",
] ]
[[package]] [[package]]
@@ -2168,7 +2193,7 @@ checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -2479,6 +2504,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" 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]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -2557,7 +2594,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -2618,7 +2655,166 @@ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "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]] [[package]]
@@ -2670,9 +2866,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.74" version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"cfg-if", "cfg-if",
@@ -2691,7 +2887,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -2702,9 +2898,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.110" version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
@@ -2730,14 +2926,18 @@ dependencies = [
[[package]] [[package]]
name = "os_info" name = "os_info"
version = "3.12.0" version = "3.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3" checksum = "7c39b5918402d564846d5aba164c09a66cc88d232179dfd3e3c619a25a268392"
dependencies = [ dependencies = [
"android_system_properties",
"log", "log",
"plist", "nix",
"objc2",
"objc2-foundation",
"objc2-ui-kit",
"serde", "serde",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -2766,7 +2966,7 @@ dependencies = [
"libc", "libc",
"redox_syscall", "redox_syscall",
"smallvec", "smallvec",
"windows-link 0.2.1", "windows-link",
] ]
[[package]] [[package]]
@@ -2798,7 +2998,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -2819,19 +3019,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 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]] [[package]]
name = "pmoapp" name = "pmoapp"
version = "0.1.0" version = "0.1.0"
@@ -2888,7 +3075,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"axum 0.8.6", "axum 0.8.7",
"bytes", "bytes",
"chrono", "chrono",
"futures-util", "futures-util",
@@ -2917,7 +3104,7 @@ name = "pmocache"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum 0.8.6", "axum 0.8.7",
"bytes", "bytes",
"chrono", "chrono",
"futures-util", "futures-util",
@@ -2943,7 +3130,7 @@ name = "pmoconfig"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum 0.8.6", "axum 0.8.7",
"dirs", "dirs",
"lazy_static", "lazy_static",
"log", "log",
@@ -2962,7 +3149,7 @@ name = "pmocovers"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum 0.8.6", "axum 0.8.7",
"image", "image",
"once_cell", "once_cell",
"pmocache", "pmocache",
@@ -2983,7 +3170,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"bevy_reflect", "bevy_reflect",
"bevy_reflect_derive", "bevy_reflect_derive",
"quick-xml 0.38.3", "quick-xml 0.38.4",
"serde", "serde",
"utoipa", "utoipa",
"utoipa-swagger-ui", "utoipa-swagger-ui",
@@ -3017,7 +3204,7 @@ dependencies = [
"once_cell", "once_cell",
"pmodidl", "pmodidl",
"pmoupnp", "pmoupnp",
"quick-xml 0.38.3", "quick-xml 0.38.4",
] ]
[[package]] [[package]]
@@ -3025,7 +3212,7 @@ name = "pmomediaserver"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum 0.8.6", "axum 0.8.7",
"bevy_reflect", "bevy_reflect",
"once_cell", "once_cell",
"pmoconfig", "pmoconfig",
@@ -3035,7 +3222,7 @@ dependencies = [
"pmoserver", "pmoserver",
"pmosource", "pmosource",
"pmoupnp", "pmoupnp",
"quick-xml 0.38.3", "quick-xml 0.38.4",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 1.0.69", "thiserror 1.0.69",
@@ -3062,7 +3249,7 @@ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
"async-trait", "async-trait",
"axum 0.8.6", "axum 0.8.7",
"bytes", "bytes",
"chrono", "chrono",
"claxon", "claxon",
@@ -3123,7 +3310,7 @@ name = "pmoqobuz"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum 0.8.6", "axum 0.8.7",
"chrono", "chrono",
"hex", "hex",
"mockito", "mockito",
@@ -3152,7 +3339,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
"axum 0.8.6", "axum 0.8.7",
"axum-embed", "axum-embed",
"axum-server", "axum-server",
"futures", "futures",
@@ -3176,7 +3363,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"axum 0.8.6", "axum 0.8.7",
"lazy_static", "lazy_static",
"pmoaudiocache", "pmoaudiocache",
"pmoconfig", "pmoconfig",
@@ -3198,7 +3385,7 @@ name = "pmoupnp"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum 0.8.6", "axum 0.8.7",
"base64 0.22.1", "base64 0.22.1",
"bevy_reflect", "bevy_reflect",
"bevy_reflect_derive", "bevy_reflect_derive",
@@ -3297,7 +3484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -3334,7 +3521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
dependencies = [ dependencies = [
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -3357,7 +3544,7 @@ dependencies = [
"itertools 0.14.0", "itertools 0.14.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -3421,9 +3608,9 @@ dependencies = [
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.3" version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [ dependencies = [
"memchr", "memchr",
"serde", "serde",
@@ -3431,9 +3618,9 @@ dependencies = [
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.41" version = "1.0.42"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@@ -3722,7 +3909,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"rust-embed-utils", "rust-embed-utils",
"syn 2.0.108", "syn 2.0.110",
"walkdir", "walkdir",
] ]
@@ -3895,7 +4082,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -4315,9 +4502,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.108" version = "2.0.110"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -4341,7 +4528,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -4444,7 +4631,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -4455,7 +4642,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -4563,7 +4750,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -4803,7 +4990,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -4936,7 +5123,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"regex", "regex",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -4945,7 +5132,7 @@ version = "9.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55"
dependencies = [ dependencies = [
"axum 0.8.6", "axum 0.8.7",
"base64 0.22.1", "base64 0.22.1",
"mime_guess", "mime_guess",
"regex", "regex",
@@ -5001,7 +5188,7 @@ checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -5101,7 +5288,7 @@ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -5149,9 +5336,9 @@ dependencies = [
[[package]] [[package]]
name = "weezl" name = "weezl"
version = "0.1.10" version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "wgpu-types" name = "wgpu-types"
@@ -5252,9 +5439,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [ dependencies = [
"windows-implement", "windows-implement",
"windows-interface", "windows-interface",
"windows-link 0.2.1", "windows-link",
"windows-result 0.4.1", "windows-result 0.4.1",
"windows-strings 0.5.1", "windows-strings",
] ]
[[package]] [[package]]
@@ -5265,7 +5452,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -5276,15 +5463,9 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "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]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
@@ -5293,13 +5474,13 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]] [[package]]
name = "windows-registry" name = "windows-registry"
version = "0.5.3" version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [ dependencies = [
"windows-link 0.1.3", "windows-link",
"windows-result 0.3.4", "windows-result 0.4.1",
"windows-strings 0.4.2", "windows-strings",
] ]
[[package]] [[package]]
@@ -5311,31 +5492,13 @@ dependencies = [
"windows-targets 0.52.6", "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]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [ dependencies = [
"windows-link 0.2.1", "windows-link",
]
[[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",
] ]
[[package]] [[package]]
@@ -5344,7 +5507,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [ dependencies = [
"windows-link 0.2.1", "windows-link",
] ]
[[package]] [[package]]
@@ -5389,7 +5552,7 @@ version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [ dependencies = [
"windows-link 0.2.1", "windows-link",
] ]
[[package]] [[package]]
@@ -5429,7 +5592,7 @@ version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [ dependencies = [
"windows-link 0.2.1", "windows-link",
"windows_aarch64_gnullvm 0.53.1", "windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1", "windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1", "windows_i686_gnu 0.53.1",
@@ -5656,7 +5819,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
"synstructure", "synstructure",
] ]
@@ -5677,7 +5840,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[package]]
@@ -5697,7 +5860,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
"synstructure", "synstructure",
] ]
@@ -5737,7 +5900,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.108", "syn 2.0.110",
] ]
[[package]] [[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 { loop {
// Attendre le premier chunk audio pour cette track // Attendre le premier chunk audio pour cette track
tracing::debug!("FlacCacheSink: Waiting for first audio chunk (track_number={})", track_number); tracing::debug!(
let (first_segment, track_metadata) = if let Some(metadata) = next_track_metadata.take() { "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 // 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 // Attendre juste le premier chunk
match wait_for_first_audio_chunk(&mut rx, &stop_token).await { match wait_for_first_audio_chunk(&mut rx, &stop_token).await {
Ok(chunk) => { Ok(chunk) => {
@@ -296,18 +302,30 @@ impl NodeLogic for FlacCacheSinkLogic {
url url
} }
Err(e) if e.is_transient() => { 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 None
} }
Err(e) => { 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 None
} }
}; };
if let Some(cover_url) = url { if let Some(cover_url) = url {
tracing::debug!("FlacCacheSink: Attempting to cache cover from URL: {}", cover_url); tracing::debug!(
match self.covers "FlacCacheSink: Attempting to cache cover from URL: {}",
cover_url
);
match self
.covers
.add_from_url(&cover_url, self.collection.as_deref()) .add_from_url(&cover_url, self.collection.as_deref())
.await .await
{ {
@@ -323,7 +341,11 @@ impl NodeLogic for FlacCacheSinkLogic {
} }
} }
Err(e) => { 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 { } else {
@@ -339,20 +361,27 @@ impl NodeLogic for FlacCacheSinkLogic {
playlist_handle.push(pk.clone()).await.map_err(|e| { playlist_handle.push(pk.clone()).await.map_err(|e| {
AudioError::ProcessingError(format!("Failed to add to playlist: {}", 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é // 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 // Il faut juste attendre que le pump se termine et retourner
if end_of_stream_received { 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); drop(pump_handle);
track_number += 1; track_number += 1;
continue; // Passer à la track suivante (qui n'arrivera pas car EndOfStream) continue; // Passer à la track suivante (qui n'arrivera pas car EndOfStream)
} }
// Phase 3: Continuer à dispatcher jusqu'au TrackBoundary // 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 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_handle = Some(pump_handle);
let mut pump_closed = false; let mut pump_closed = false;
@@ -384,7 +413,9 @@ impl NodeLogic for FlacCacheSinkLogic {
if tx.send(segment).await.is_err() { if tx.send(segment).await.is_err() {
// Le pump a fermé son channel - cela peut arriver si le fichier // Le pump a fermé son channel - cela peut arriver si le fichier
// était déjà en cache (add_from_reader retourne immédiatement) // é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()); drop(track_tx.take());
// Attendre que le pump se termine et vérifier le résultat // Attendre que le pump se termine et vérifier le résultat
@@ -397,13 +428,21 @@ impl NodeLogic for FlacCacheSinkLogic {
} }
Ok(Err(e)) => { Ok(Err(e)) => {
// Le pump a rencontré une erreur // 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); return Err(e);
} }
Err(e) => { Err(e) => {
// Le pump task a paniqué // Le pump task a paniqué
tracing::error!("FlacCacheSink: pump task panicked: {}", e); tracing::error!(
return Err(AudioError::ProcessingError("Pump task panicked".to_string())); "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 { _AudioSegment::Sync(marker) => match &**marker {
SyncMarker::TrackBoundary { .. } => { SyncMarker::TrackBoundary { .. } => {
// On ne devrait pas recevoir de TrackBoundary ici car on l'a déjà // 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; continue;
} }
SyncMarker::EndOfStream => { SyncMarker::EndOfStream => {

View File

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

View File

@@ -10,6 +10,9 @@ mod flac_cache_sink;
#[cfg(feature = "cache-sink")] #[cfg(feature = "cache-sink")]
pub use flac_cache_sink::{FlacCacheSink, FlacCacheSinkStats, TrackStats}; pub use flac_cache_sink::{FlacCacheSink, FlacCacheSinkStats, TrackStats};
#[cfg(feature = "http-stream")]
mod broadcast_pacing;
#[cfg(feature = "http-stream")] #[cfg(feature = "http-stream")]
mod flac_frame_utils; mod flac_frame_utils;
@@ -17,10 +20,12 @@ mod flac_frame_utils;
mod streaming_flac_sink; mod streaming_flac_sink;
#[cfg(feature = "http-stream")] #[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")] #[cfg(feature = "http-stream")]
mod streaming_ogg_flac_sink; mod streaming_ogg_flac_sink;
#[cfg(feature = "http-stream")] #[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::task::{Context, Poll};
use std::time::Duration; use std::time::Duration;
use super::flac_frame_utils; use super::{broadcast_pacing::BroadcastPacer, flac_frame_utils};
use async_trait::async_trait; use async_trait::async_trait;
use bytes::Bytes; use bytes::Bytes;
use pmoaudio::{ use pmoaudio::{
@@ -81,16 +81,29 @@ use tracing::{debug, error, info, trace, warn};
/// Standard value used by most streaming servers. /// Standard value used by most streaming servers.
const DEFAULT_ICY_METAINT: usize = 16000; const DEFAULT_ICY_METAINT: usize = 16000;
/// Broadcast channel capacity for FLAC bytes. /// Default maximum lead time for HTTP broadcast pacing (in seconds).
/// 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).
/// The broadcaster will sleep if it's ahead of real-time by more than this amount. /// 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 DEFAULT_BROADCAST_MAX_LEAD_TIME: f64 = 0.5;
const 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. /// PCM chunk with audio data and timestamp for precise pacing.
#[derive(Debug)] #[derive(Debug)]
@@ -190,7 +203,11 @@ impl StreamHandle {
/// Subscribe to the FLAC stream with custom ICY metadata interval. /// Subscribe to the FLAC stream with custom ICY metadata interval.
pub fn subscribe_icy_with_interval(&self, metaint: usize) -> IcyClientStream { pub fn subscribe_icy_with_interval(&self, metaint: usize) -> IcyClientStream {
let count = self.active_clients.fetch_add(1, Ordering::SeqCst); 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 { IcyClientStream {
rx: self.flac_broadcast.subscribe(), rx: self.flac_broadcast.subscribe(),
@@ -254,7 +271,10 @@ impl AsyncRead for FlacClientStream {
if let Some(header) = header_opt { if let Some(header) = header_opt {
self.buffer.extend(header.iter()); 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; self.state = FlacStreamState::Streaming;
continue; // Now copy header to output buffer continue; // Now copy header to output buffer
} else { } else {
@@ -413,7 +433,10 @@ impl AsyncRead for IcyClientStream {
if let Some(header) = header_opt { if let Some(header) = header_opt {
self.buffer.extend(header.iter()); 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; self.state = FlacStreamState::Streaming;
continue; // Now copy header to output buffer continue; // Now copy header to output buffer
} else { } else {
@@ -533,6 +556,7 @@ struct StreamingFlacSinkLogic {
flac_header: Arc<RwLock<Option<Bytes>>>, flac_header: Arc<RwLock<Option<Bytes>>>,
encoder_state: Option<EncoderState>, encoder_state: Option<EncoderState>,
sample_rate: Option<u32>, sample_rate: Option<u32>,
broadcast_max_lead_time: f64,
} }
impl StreamingFlacSinkLogic { impl StreamingFlacSinkLogic {
@@ -542,12 +566,16 @@ impl StreamingFlacSinkLogic {
return Ok(()); // Already initialized 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) // Take the PCM receiver (we only initialize once)
let pcm_rx = self.pcm_rx.take().ok_or_else(|| { let pcm_rx = self
AudioError::ProcessingError("PCM receiver already consumed".into()) .pcm_rx
})?; .take()
.ok_or_else(|| AudioError::ProcessingError("PCM receiver already consumed".into()))?;
// Create shared timestamp for pacing // Create shared timestamp for pacing
let current_timestamp = Arc::new(RwLock::new(0.0f64)); let current_timestamp = Arc::new(RwLock::new(0.0f64));
@@ -565,15 +593,26 @@ impl StreamingFlacSinkLogic {
// Start the FLAC encoder // Start the FLAC encoder
let flac_stream = encode_flac_stream(pcm_reader, pcm_format, self.encoder_options.clone()) let flac_stream = encode_flac_stream(pcm_reader, pcm_format, self.encoder_options.clone())
.await .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"); info!("FLAC encoder initialized successfully");
// Spawn broadcaster task with timestamp for pacing // Spawn broadcaster task with timestamp for pacing
let flac_broadcast = self.flac_broadcast.clone(); let flac_broadcast = self.flac_broadcast.clone();
let flac_header = self.flac_header.clone(); let flac_header = self.flac_header.clone();
let max_lead = self.broadcast_max_lead_time;
let broadcaster_task = tokio::spawn(async move { 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); error!("Broadcaster task error: {}", e);
} }
}); });
@@ -682,10 +721,19 @@ impl NodeLogic for StreamingFlacSinkLogic {
bytes: pcm_bytes, bytes: pcm_bytes,
timestamp_sec: seg.timestamp_sec, timestamp_sec: seg.timestamp_sec,
}; };
let send_start = std::time::Instant::now();
if let Err(e) = self.pcm_tx.send(pcm_chunk).await { if let Err(e) = self.pcm_tx.send(pcm_chunk).await {
warn!("Failed to send PCM data to encoder: {}", e); warn!("Failed to send PCM data to encoder: {}", e);
break; 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) => { _AudioSegment::Sync(marker) => {
@@ -728,7 +776,6 @@ impl NodeLogic for StreamingFlacSinkLogic {
} }
} }
/// Broadcaster task: reads FLAC bytes from encoder and broadcasts to all clients. /// Broadcaster task: reads FLAC bytes from encoder and broadcasts to all clients.
/// Implements precise real-time pacing based on audio timestamps. /// 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. /// 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>, broadcast_tx: broadcast::Sender<Bytes>,
header_cache: Arc<RwLock<Option<Bytes>>>, header_cache: Arc<RwLock<Option<Bytes>>>,
current_timestamp: Arc<RwLock<f64>>, current_timestamp: Arc<RwLock<f64>>,
broadcast_max_lead_time: f64,
) -> Result<(), AudioError> { ) -> 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 // 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 // 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 accumulator = Vec::with_capacity(32768); // Pre-allocate to reduce reallocations
let mut total_bytes = 0u64; let mut total_bytes = 0u64;
let mut header_captured = false; 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 { loop {
let read_start = std::time::Instant::now();
match flac_stream.read(&mut read_buffer).await { match flac_stream.read(&mut read_buffer).await {
Ok(0) => { Ok(0) => {
// EOF - send any remaining data // EOF - send any remaining data
@@ -760,14 +819,38 @@ async fn broadcast_flac_stream(
break; break;
} }
Ok(n) => { 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; total_bytes += n as u64;
if total_bytes % 100000 == 0 || total_bytes < 10000 { 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 // Append to accumulator
accumulator.extend_from_slice(&read_buffer[..n]); 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) // Find where to split: position of last sync code (start of last incomplete frame)
// Everything before this position contains only complete frames // Everything before this position contains only complete frames
let boundary = flac_frame_utils::find_complete_frames_boundary(&accumulator); 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) // Only broadcast if we have at least one complete frame (1KB minimum to avoid excessive small sends)
if boundary >= 1024 { 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 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 { if stats_last_log.elapsed() >= Duration::from_secs(1) {
let sleep_duration = lead_time - BROADCAST_MAX_LEAD_TIME;
debug!( debug!(
"Broadcaster pacing: sleeping {:.3}s (audio_ts={:.3}s, elapsed={:.3}s, lead={:.3}s)", "Broadcaster pacing snapshot: audio_ts={:.3}s buffer_bytes={}",
sleep_duration, audio_timestamp, elapsed, lead_time 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 // 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 to_send = std::mem::replace(&mut accumulator, remaining);
let bytes = Bytes::from(to_send); 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" // Capture first chunk as header if it contains "fLaC"
if !header_captured && bytes.len() >= 4 && &bytes[0..4] == b"fLaC" { if !header_captured && bytes.len() >= 4 && &bytes[0..4] == b"fLaC" {
*header_cache.write().await = Some(bytes.clone()); *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 // No receivers, but that's okay - clients may not be connected yet
trace!("No active receivers for FLAC broadcast: {}", e); trace!("No active receivers for FLAC broadcast: {}", e);
} else if num_receivers > 0 { } 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: /// A tuple of `(sink, handle)` where:
/// - `sink` is added to the audio pipeline /// - `sink` is added to the audio pipeline
/// - `handle` is used by HTTP handlers to serve streams /// - `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, encoder_options: EncoderOptions,
bits_per_sample: u8, bits_per_sample: u8,
broadcast_max_lead_time: f64,
) -> (Self, StreamHandle) { ) -> (Self, StreamHandle) {
// Validate bit depth // Validate bit depth
if ![16, 24, 32].contains(&bits_per_sample) { if ![16, 24, 32].contains(&bits_per_sample) {
@@ -872,8 +1009,15 @@ impl StreamingFlacSink {
// Shared metadata // Shared metadata
let metadata = Arc::new(RwLock::new(MetadataSnapshot::default())); 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 // Broadcast channel for FLAC bytes
let (flac_broadcast, _) = broadcast::channel(BROADCAST_CAPACITY); let (flac_broadcast, _) = broadcast::channel(broadcast_capacity);
// FLAC header cache // FLAC header cache
let flac_header = Arc::new(RwLock::new(None)); let flac_header = Arc::new(RwLock::new(None));
@@ -900,6 +1044,7 @@ impl StreamingFlacSink {
flac_header, flac_header,
encoder_state: None, encoder_state: None,
sample_rate: None, sample_rate: None,
broadcast_max_lead_time: broadcast_max_lead_time.max(0.0),
}; };
let sink = Self { let sink = Self {

View File

@@ -53,7 +53,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::task::{Context, Poll}; use std::task::{Context, Poll};
use super::flac_frame_utils; use super::{broadcast_pacing::BroadcastPacer, flac_frame_utils};
use async_trait::async_trait; use async_trait::async_trait;
use bytes::Bytes; use bytes::Bytes;
use pmoaudio::{ use pmoaudio::{
@@ -68,13 +68,18 @@ use tokio::sync::{broadcast, mpsc, RwLock};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, trace, warn}; use tracing::{debug, error, info, trace, warn};
/// Broadcast channel capacity for OGG-FLAC bytes. /// Default maximum lead time for HTTP broadcast pacing (in seconds).
/// Same as StreamingFlacSink for consistency. const DEFAULT_BROADCAST_MAX_LEAD_TIME: f64 = 0.0;
const BROADCAST_CAPACITY: usize = 128;
/// Maximum lead time for HTTP broadcast pacing (in seconds). /// Calculate broadcast channel capacity based on max lead time.
/// The broadcaster will sleep if it's ahead of real-time by more than this amount. ///
const BROADCAST_MAX_LEAD_TIME: f64 = 0.5; /// 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. /// PCM chunk with audio data and timestamp for precise pacing.
#[derive(Debug)] #[derive(Debug)]
@@ -167,7 +172,10 @@ impl AsyncRead for OggFlacClientStream {
if let Some(header) = header_opt { if let Some(header) = header_opt {
self.buffer.extend(header.iter()); 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; self.state = OggFlacStreamState::Streaming;
continue; // Now copy header to output buffer continue; // Now copy header to output buffer
} else { } else {
@@ -203,7 +211,7 @@ impl AsyncRead for OggFlacClientStream {
// Schedule a wakeup after a small delay to avoid busy-loop polling. // Schedule a wakeup after a small delay to avoid busy-loop polling.
let waker = cx.waker().clone(); let waker = cx.waker().clone();
tokio::spawn(async move { 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(); waker.wake();
}); });
return Poll::Pending; return Poll::Pending;
@@ -248,6 +256,7 @@ struct StreamingOggFlacSinkLogic {
ogg_header: Arc<RwLock<Option<Bytes>>>, ogg_header: Arc<RwLock<Option<Bytes>>>,
encoder_state: Option<EncoderState>, encoder_state: Option<EncoderState>,
sample_rate: Option<u32>, sample_rate: Option<u32>,
broadcast_max_lead_time: f64,
} }
impl StreamingOggFlacSinkLogic { impl StreamingOggFlacSinkLogic {
@@ -257,12 +266,16 @@ impl StreamingOggFlacSinkLogic {
return Ok(()); // Already initialized 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) // Take the PCM receiver (we only initialize once)
let pcm_rx = self.pcm_rx.take().ok_or_else(|| { let pcm_rx = self
AudioError::ProcessingError("PCM receiver already consumed".into()) .pcm_rx
})?; .take()
.ok_or_else(|| AudioError::ProcessingError("PCM receiver already consumed".into()))?;
// Create shared timestamp for pacing // Create shared timestamp for pacing
let current_timestamp = Arc::new(RwLock::new(0.0f64)); let current_timestamp = Arc::new(RwLock::new(0.0f64));
@@ -280,15 +293,26 @@ impl StreamingOggFlacSinkLogic {
// Start the FLAC encoder // Start the FLAC encoder
let flac_stream = encode_flac_stream(pcm_reader, pcm_format, self.encoder_options.clone()) let flac_stream = encode_flac_stream(pcm_reader, pcm_format, self.encoder_options.clone())
.await .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"); info!("OGG-FLAC encoder initialized successfully");
// Spawn OGG wrapper + broadcaster task with timestamp for pacing // Spawn OGG wrapper + broadcaster task with timestamp for pacing
let ogg_broadcast = self.ogg_broadcast.clone(); let ogg_broadcast = self.ogg_broadcast.clone();
let ogg_header = self.ogg_header.clone(); let ogg_header = self.ogg_header.clone();
let max_lead = self.broadcast_max_lead_time;
let broadcaster_task = tokio::spawn(async move { 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); error!("OGG broadcaster task error: {}", e);
} }
}); });
@@ -464,6 +488,19 @@ impl StreamingOggFlacSink {
pub fn new( pub fn new(
encoder_options: EncoderOptions, encoder_options: EncoderOptions,
bits_per_sample: u8, 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) { ) -> (Self, OggFlacStreamHandle) {
// Validate bit depth // Validate bit depth
if ![16, 24, 32].contains(&bits_per_sample) { if ![16, 24, 32].contains(&bits_per_sample) {
@@ -477,7 +514,14 @@ impl StreamingOggFlacSink {
let metadata = Arc::new(RwLock::new(MetadataSnapshot::default())); let metadata = Arc::new(RwLock::new(MetadataSnapshot::default()));
// Broadcast channel for OGG-FLAC bytes // 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 // OGG-FLAC header cache
let ogg_header = Arc::new(RwLock::new(None)); let ogg_header = Arc::new(RwLock::new(None));
@@ -504,6 +548,7 @@ impl StreamingOggFlacSink {
ogg_header, ogg_header,
encoder_state: None, encoder_state: None,
sample_rate: None, sample_rate: None,
broadcast_max_lead_time: broadcast_max_lead_time.max(0.0),
}; };
let sink = Self { let sink = Self {
@@ -709,17 +754,27 @@ async fn broadcast_ogg_flac_stream(
broadcast_tx: broadcast::Sender<Bytes>, broadcast_tx: broadcast::Sender<Bytes>,
header_cache: Arc<RwLock<Option<Bytes>>>, header_cache: Arc<RwLock<Option<Bytes>>>,
current_timestamp: Arc<RwLock<f64>>, current_timestamp: Arc<RwLock<f64>>,
broadcast_max_lead_time: f64,
) -> Result<(), AudioError> { ) -> 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 stream_serial = rand::random::<u32>();
let mut ogg_writer = OggPageWriter::new(stream_serial); let mut ogg_writer = OggPageWriter::new(stream_serial);
let mut total_ogg_bytes = 0u64; let mut total_ogg_bytes = 0u64;
let mut header_captured = false; 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; 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) // Step 1: Read FLAC header (fLaC + metadata blocks)
let flac_header = read_flac_header(&mut flac_stream).await?; let flac_header = read_flac_header(&mut flac_stream).await?;
info!("Read FLAC header: {} bytes", flac_header.len()); 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) // Step 2: Create OGG-FLAC identification packet (BOS)
// Format according to https://xiph.org/flac/ogg_mapping.html // Format according to https://xiph.org/flac/ogg_mapping.html
let ogg_flac_id = create_ogg_flac_identification(&flac_header)?; 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_page = ogg_writer.create_page(&ogg_flac_id, true, false, false);
let bos_bytes = Bytes::from(bos_page); let bos_bytes = Bytes::from(bos_page);
@@ -747,7 +805,10 @@ async fn broadcast_ogg_flac_stream(
cached_header.extend_from_slice(&comment_bytes); cached_header.extend_from_slice(&comment_bytes);
*header_cache.write().await = Some(Bytes::from(cached_header)); *header_cache.write().await = Some(Bytes::from(cached_header));
header_captured = true; 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 // Broadcast header
let _ = broadcast_tx.send(bos_bytes); let _ = broadcast_tx.send(bos_bytes);
@@ -761,6 +822,7 @@ async fn broadcast_ogg_flac_stream(
let mut flac_accumulator = Vec::with_capacity(32768); let mut flac_accumulator = Vec::with_capacity(32768);
loop { loop {
let read_start = std::time::Instant::now();
match flac_stream.read(&mut read_buffer).await { match flac_stream.read(&mut read_buffer).await {
Ok(0) => { Ok(0) => {
// EOF - create final page with EOS flag and any remaining data // 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); let eos_bytes = Bytes::from(eos_page);
total_ogg_bytes += eos_bytes.len() as u64; total_ogg_bytes += eos_bytes.len() as u64;
let _ = broadcast_tx.send(eos_bytes); 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 { } else {
// Send empty EOS page // Send empty EOS page
let eos_page = ogg_writer.create_page(&[], false, true, false); 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!("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; break;
} }
Ok(n) => { 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 // Append to accumulator
flac_accumulator.extend_from_slice(&read_buffer[..n]); 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 // Process complete FLAC frames one at a time
// OGG-FLAC spec requires: "Each audio data packet contains one complete FLAC frame" // OGG-FLAC spec requires: "Each audio data packet contains one complete FLAC frame"
loop { loop {
@@ -804,7 +892,9 @@ async fn broadcast_ogg_flac_stream(
if byte1 == 0xFF && byte2 >= 0xF8 && byte2 <= 0xFE { if byte1 == 0xFF && byte2 >= 0xF8 && byte2 <= 0xFE {
// Validate frame header with CRC-8 to avoid false positives // Validate frame header with CRC-8 to avoid false positives
if flac_frame_utils::validate_frame_header_crc(&flac_accumulator, i) { 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)); 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) // Verify first frame starts at position 0 (otherwise we have garbage data)
if first_frame_start != 0 { 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); flac_accumulator.drain(0..first_frame_start);
continue; continue;
} }
// Extract just the first frame // 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 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 { // Check timing et apply pacing (skip si en retard)
let sleep_duration = lead_time - BROADCAST_MAX_LEAD_TIME; if pacer.check_and_pace(audio_timestamp).await.is_err() {
debug!( continue; // Skip ce chunk (trop en retard)
"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;
} }
// Update granule position (cumulative sample count) // Update granule position (cumulative sample count)
@@ -853,10 +951,41 @@ async fn broadcast_ogg_flac_stream(
let ogg_bytes = Bytes::from(ogg_page); let ogg_bytes = Bytes::from(ogg_page);
total_ogg_bytes += ogg_bytes.len() as u64; total_ogg_bytes += ogg_bytes.len() as u64;
if let Err(e) = broadcast_tx.send(ogg_bytes.clone()) { // Measure broadcast interval for burst detection
trace!("No active receivers for OGG-FLAC broadcast: {}", e); let broadcast_interval = last_broadcast_time.elapsed().as_secs_f64();
} else { last_broadcast_time = std::time::Instant::now();
trace!("Broadcasted OGG page with 1 FLAC frame ({} bytes), {} samples ({} bytes total with OGG overhead)", first_frame.len(), first_frame_samples, ogg_bytes.len()); 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);
}
} }
} }
} }
@@ -897,13 +1026,17 @@ fn extract_sample_rate_from_streaminfo(flac_header: &[u8]) -> Result<u32, AudioE
// First metadata block should be STREAMINFO (type 0) // First metadata block should be STREAMINFO (type 0)
let block_type = flac_header[4] & 0x7F; let block_type = flac_header[4] & 0x7F;
if block_type != 0 { 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) // 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) // Sample rate is at offset 10-12 of STREAMINFO data (bytes 18-20 of header)
if flac_header.len() < 21 { 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 // 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); let sample_rate = (byte10 << 12) | (byte11 << 4) | (byte12 >> 4);
if sample_rate == 0 { 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) Ok(sample_rate)
@@ -929,12 +1064,15 @@ async fn read_flac_header(stream: &mut FlacEncodedStream) -> Result<Vec<u8>, Aud
let mut buffer = [0u8; 4]; let mut buffer = [0u8; 4];
// Read "fLaC" magic // Read "fLaC" magic
stream.read_exact(&mut buffer).await.map_err(|e| { stream
AudioError::ProcessingError(format!("Failed to read FLAC magic: {}", e)) .read_exact(&mut buffer)
})?; .await
.map_err(|e| AudioError::ProcessingError(format!("Failed to read FLAC magic: {}", e)))?;
if &buffer != b"fLaC" { 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); 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 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); 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 let first_block_type = flac_header[4] & 0x7F; // Remove last-metadata-block flag
if first_block_type != 0 { 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) // 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); info!("STREAMINFO block_length = {} bytes", block_length);
@@ -1007,18 +1149,21 @@ fn create_ogg_flac_identification(flac_header: &[u8]) -> Result<Vec<u8>, AudioEr
// Extract just the STREAMINFO block (type + length + data) // Extract just the STREAMINFO block (type + length + data)
let streaminfo = &flac_header[4..4 + streaminfo_size]; 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(); let mut packet = Vec::new();
// OGG-FLAC identification header // OGG-FLAC identification header
packet.push(0x7F); // Byte 0: 0x7F packet.push(0x7F); // Byte 0: 0x7F
packet.extend_from_slice(b"FLAC"); // Bytes 1-4: "FLAC" packet.extend_from_slice(b"FLAC"); // Bytes 1-4: "FLAC"
packet.push(0x01); // Byte 5: Major version packet.push(0x01); // Byte 5: Major version
packet.push(0x00); // Byte 6: Minor version packet.push(0x00); // Byte 6: Minor version
packet.extend_from_slice(&1u16.to_be_bytes()); // Bytes 7-8: 1 header packet (Vorbis Comment) packet.extend_from_slice(&1u16.to_be_bytes()); // Bytes 7-8: 1 header packet (Vorbis Comment)
packet.extend_from_slice(b"fLaC"); // Bytes 9-12: Native FLAC signature packet.extend_from_slice(b"fLaC"); // Bytes 9-12: Native FLAC signature
packet.extend_from_slice(streaminfo); // Bytes 13+: STREAMINFO block only packet.extend_from_slice(streaminfo); // Bytes 13+: STREAMINFO block only
Ok(packet) Ok(packet)
} }
@@ -1075,7 +1220,13 @@ impl OggPageWriter {
self.granule_position += samples; 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; use std::io::Write;
let mut segments = Vec::new(); let mut segments = Vec::new();
@@ -1117,7 +1268,8 @@ impl OggPageWriter {
page.write_all(&[header_type]).unwrap(); page.write_all(&[header_type]).unwrap();
// Granule position // 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 // Stream serial number
page.write_all(&self.stream_serial.to_le_bytes()).unwrap(); page.write_all(&self.stream_serial.to_le_bytes()).unwrap();

View File

@@ -205,11 +205,8 @@ impl NodeLogic for PlaylistSourceLogic {
Ok(m) => m, Ok(m) => m,
Err(e) => { Err(e) => {
tracing::warn!("PlaylistSourceLogic: failed to get metadata: {}", e); tracing::warn!("PlaylistSourceLogic: failed to get metadata: {}", e);
let error_marker = AudioSegment::new_error( let error_marker =
0, AudioSegment::new_error(0, 0.0, format!("Failed to get metadata: {}", e));
0.0,
format!("Failed to get metadata: {}", e),
);
send_to_children!(error_marker); send_to_children!(error_marker);
continue; continue;
} }
@@ -224,11 +221,8 @@ impl NodeLogic for PlaylistSourceLogic {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
tracing::warn!("PlaylistSourceLogic: failed to get file path: {}", e); tracing::warn!("PlaylistSourceLogic: failed to get file path: {}", e);
let error_marker = AudioSegment::new_error( let error_marker =
0, AudioSegment::new_error(0, 0.0, format!("Failed to get file path: {}", e));
0.0,
format!("Failed to get file path: {}", e),
);
send_to_children!(error_marker); send_to_children!(error_marker);
continue; continue;
} }
@@ -290,7 +284,10 @@ async fn decode_and_emit_track(
const MIN_FILE_SIZE: u64 = 512 * 1024; // 512 KB (prebuffer size) const MIN_FILE_SIZE: u64 = 512 * 1024; // 512 KB (prebuffer size)
if file_size >= MIN_FILE_SIZE || cache.is_download_complete(cache_pk) { 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; break;
} }
@@ -417,7 +414,8 @@ async fn decode_and_emit_track(
let frames = pending.len() / frame_bytes; let frames = pending.len() / frame_bytes;
if frames > 0 { if frames > 0 {
let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64; 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 { for tx in output {
tx.send(segment.clone()) tx.send(segment.clone())
.await .await
@@ -609,7 +607,8 @@ impl PlaylistSource {
chunk_frames: usize, chunk_frames: usize,
poll_interval_ms: u64, poll_interval_ms: u64,
) -> Self { ) -> 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 { Self {
inner: Node::new_source(logic), inner: Node::new_source(logic),
} }
@@ -626,10 +625,7 @@ impl AudioPipelineNode for PlaylistSource {
self.inner.register(child) self.inner.register(child)
} }
async fn run( async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await Box::new(self.inner).run(stop_token).await
} }
} }
@@ -712,9 +708,9 @@ mod tests {
// Frame 2: L=300, R=400 // Frame 2: L=300, R=400
let chunk_bytes = vec![ let chunk_bytes = vec![
100u8, 0, // L1 100u8, 0, // L1
200, 0, // R1 200, 0, // R1
44, 1, // L2 (300 = 0x012C) 44, 1, // L2 (300 = 0x012C)
144, 1, // R2 (400 = 0x0190) 144, 1, // R2 (400 = 0x0190)
]; ];
let info = StreamInfo { let info = StreamInfo {
@@ -732,18 +728,16 @@ mod tests {
assert_eq!(segment.timestamp_sec, 0.0); assert_eq!(segment.timestamp_sec, 0.0);
match &segment.segment { match &segment.segment {
pmoaudio::_AudioSegment::Chunk(chunk) => { pmoaudio::_AudioSegment::Chunk(chunk) => match chunk.as_ref() {
match chunk.as_ref() { AudioChunk::I16(data) => {
AudioChunk::I16(data) => { let frames = data.get_frames();
let frames = data.get_frames(); assert_eq!(frames.len(), 2);
assert_eq!(frames.len(), 2); assert_eq!(frames[0], [100, 200]);
assert_eq!(frames[0], [100, 200]); assert_eq!(frames[1], [300, 400]);
assert_eq!(frames[1], [300, 400]); assert_eq!(data.get_sample_rate(), 44100);
assert_eq!(data.get_sample_rate(), 44100);
}
_ => panic!("Expected I16 chunk"),
} }
} _ => panic!("Expected I16 chunk"),
},
_ => panic!("Expected audio chunk"), _ => panic!("Expected audio chunk"),
} }
} }
@@ -753,7 +747,7 @@ mod tests {
// Create mock PCM data (2 frames, mono, 16-bit) // Create mock PCM data (2 frames, mono, 16-bit)
let chunk_bytes = vec![ let chunk_bytes = vec![
100u8, 0, // Frame 1 100u8, 0, // Frame 1
200, 0, // Frame 2 200, 0, // Frame 2
]; ];
let info = StreamInfo { let info = StreamInfo {
@@ -808,17 +802,15 @@ mod tests {
let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap(); let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap();
match &segment.segment { match &segment.segment {
pmoaudio::_AudioSegment::Chunk(chunk) => { pmoaudio::_AudioSegment::Chunk(chunk) => match chunk.as_ref() {
match chunk.as_ref() { AudioChunk::I24(data) => {
AudioChunk::I24(data) => { let frames = data.get_frames();
let frames = data.get_frames(); assert_eq!(frames.len(), 1);
assert_eq!(frames.len(), 1); assert_eq!(frames[0][0].as_i32(), 1000);
assert_eq!(frames[0][0].as_i32(), 1000); assert_eq!(frames[0][1].as_i32(), -1000);
assert_eq!(frames[0][1].as_i32(), -1000);
}
_ => panic!("Expected I24 chunk"),
} }
} _ => panic!("Expected I24 chunk"),
},
_ => panic!("Expected audio chunk"), _ => panic!("Expected audio chunk"),
} }
} }
@@ -843,16 +835,14 @@ mod tests {
let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap(); let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap();
match &segment.segment { match &segment.segment {
pmoaudio::_AudioSegment::Chunk(chunk) => { pmoaudio::_AudioSegment::Chunk(chunk) => match chunk.as_ref() {
match chunk.as_ref() { AudioChunk::I32(data) => {
AudioChunk::I32(data) => { let frames = data.get_frames();
let frames = data.get_frames(); assert_eq!(frames.len(), 1);
assert_eq!(frames.len(), 1); assert_eq!(frames[0], [4096, 8192]);
assert_eq!(frames[0], [4096, 8192]);
}
_ => panic!("Expected I32 chunk"),
} }
} _ => panic!("Expected I32 chunk"),
},
_ => panic!("Expected audio chunk"), _ => panic!("Expected audio chunk"),
} }
} }

View File

@@ -6,7 +6,9 @@ use tokio::fs::File;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { 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); let path = Path::new(&path_str);
println!("Checking: {}", path.display()); println!("Checking: {}", path.display());

View File

@@ -88,7 +88,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
match result { match result {
Ok(()) => { Ok(()) => {
println!(); 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!(" Output file: {}", output_path);
println!(); println!();

View File

@@ -45,7 +45,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Gérer Ctrl+C pour arrêt propre // Gérer Ctrl+C pour arrêt propre
tokio::spawn(async move { 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é..."); println!("\nArrêt demandé...");
stop_token_clone.cancel(); stop_token_clone.cancel();
}); });

View File

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

View File

@@ -678,9 +678,11 @@ impl AudioIntegerChunk {
AudioIntegerChunk::I16(d) => { AudioIntegerChunk::I16(d) => {
Box::new(d.get_frames().iter().map(|f| [f[0] as i32, f[1] as i32])) Box::new(d.get_frames().iter().map(|f| [f[0] as i32, f[1] as i32]))
} }
AudioIntegerChunk::I24(d) => { AudioIntegerChunk::I24(d) => Box::new(
Box::new(d.get_frames().iter().map(|f| [f[0].as_i32(), f[1].as_i32()])) 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]])), 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] /// Convertit deux canaux i16 (L/R) en pairs f32 normalisées [-1.0, 1.0]
pub fn i16_stereo_to_pairs_f32( pub fn i16_stereo_to_pairs_f32(left: &[i16], right: &[i16], out_pairs: &mut [[f32; 2]]) {
left: &[i16],
right: &[i16],
out_pairs: &mut [[f32; 2]],
) {
i16_stereo_to_pairs_f32_inner(left, right, out_pairs, 32768.0); 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) /// Convertit pairs f32 normalisées [-1.0, 1.0] en deux canaux i16 (L/R)
pub fn pairs_f32_to_i16_stereo( pub fn pairs_f32_to_i16_stereo(input_pairs: &[[f32; 2]], left: &mut [i16], right: &mut [i16]) {
input_pairs: &[[f32; 2]],
left: &mut [i16],
right: &mut [i16],
) {
pairs_f32_to_i16_stereo_inner(input_pairs, left, right, 32768.0); 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 /// Convertit deux canaux i32 (contenant des valeurs I24) en pairs f32 normalisées
pub fn i24_as_i32_stereo_to_pairs_f32( pub fn i24_as_i32_stereo_to_pairs_f32(left: &[i32], right: &[i32], out_pairs: &mut [[f32; 2]]) {
left: &[i32],
right: &[i32],
out_pairs: &mut [[f32; 2]],
) {
i24_as_i32_stereo_to_pairs_f32_inner(left, right, out_pairs, 8388608.0); 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}, flac_file_sink::{FlacFileSink, FlacFileSinkStats},
http_source::HttpSource, http_source::HttpSource,
resampling_node::ResamplingNode, resampling_node::ResamplingNode,
timer_buffer_node::TimerBufferNode,
timer_node::TimerNode, timer_node::TimerNode,
AudioError, AudioNode, TypedAudioNode, AudioError, AudioNode, TypedAudioNode,
}; };

View File

@@ -220,20 +220,18 @@ impl AudioSinkLogic {
chunk.sample_rate() chunk.sample_rate()
); );
} }
crate::_AudioSegment::Sync(marker) => { crate::_AudioSegment::Sync(marker) => match **marker {
match **marker { SyncMarker::TrackBoundary { .. } => {
SyncMarker::TrackBoundary { .. } => { tracing::debug!("AudioSink (null): TrackBoundary received");
tracing::debug!("AudioSink (null): TrackBoundary received");
}
SyncMarker::EndOfStream => {
tracing::debug!("AudioSink (null): EndOfStream received");
return Ok(());
}
_ => {
tracing::trace!("AudioSink (null): sync marker");
}
} }
} SyncMarker::EndOfStream => {
tracing::debug!("AudioSink (null): EndOfStream received");
return Ok(());
}
_ => {
tracing::trace!("AudioSink (null): sync marker");
}
},
} }
} }
} }
@@ -273,12 +271,15 @@ impl NodeLogic for AudioSinkLogic {
.default_output_device() .default_output_device()
.ok_or_else(|| AudioError::ProcessingError("No output device available".to_string()))?; .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 // Obtenir la config par défaut
let config = device let config = device.default_output_config().map_err(|e| {
.default_output_config() AudioError::ProcessingError(format!("Failed to get output config: {}", e))
.map_err(|e| AudioError::ProcessingError(format!("Failed to get output config: {}", e)))?; })?;
let sample_format = config.sample_format(); let sample_format = config.sample_format();
let sample_rate = config.sample_rate().0; let sample_rate = config.sample_rate().0;
@@ -298,9 +299,9 @@ impl NodeLogic for AudioSinkLogic {
let stream_thread = thread::spawn(move || { let stream_thread = thread::spawn(move || {
// Créer le stream selon le format hardware // Créer le stream selon le format hardware
let stream = match sample_format { let stream = match sample_format {
cpal::SampleFormat::I16 => { cpal::SampleFormat::I16 => {
tracing::debug!("Using I16 output format"); tracing::debug!("Using I16 output format");
match device.build_output_stream( match device.build_output_stream(
&config.into(), &config.into(),
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| { move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
let mut buf = buffer_clone.lock().unwrap(); let mut buf = buffer_clone.lock().unwrap();
@@ -323,10 +324,10 @@ impl NodeLogic for AudioSinkLogic {
return; return;
} }
} }
} }
cpal::SampleFormat::U16 => { cpal::SampleFormat::U16 => {
tracing::debug!("Using U16 output format"); tracing::debug!("Using U16 output format");
match device.build_output_stream( match device.build_output_stream(
&config.into(), &config.into(),
move |data: &mut [u16], _: &cpal::OutputCallbackInfo| { move |data: &mut [u16], _: &cpal::OutputCallbackInfo| {
let mut buf = buffer_clone.lock().unwrap(); let mut buf = buffer_clone.lock().unwrap();
@@ -348,10 +349,10 @@ impl NodeLogic for AudioSinkLogic {
return; return;
} }
} }
} }
cpal::SampleFormat::F32 => { cpal::SampleFormat::F32 => {
tracing::debug!("Using F32 output format"); tracing::debug!("Using F32 output format");
match device.build_output_stream( match device.build_output_stream(
&config.into(), &config.into(),
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
let mut buf = buffer_clone.lock().unwrap(); let mut buf = buffer_clone.lock().unwrap();
@@ -371,10 +372,10 @@ impl NodeLogic for AudioSinkLogic {
return; return;
} }
} }
} }
_ => { _ => {
tracing::error!("Unsupported sample format: {:?}", sample_format); tracing::error!("Unsupported sample format: {:?}", sample_format);
return; return;
} }
}; };
@@ -467,7 +468,9 @@ impl NodeLogic for AudioSinkLogic {
// Le buffer continue automatiquement - pas besoin d'action // Le buffer continue automatiquement - pas besoin d'action
} }
SyncMarker::EndOfStream => { 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 // Marquer la fin et attendre que le buffer se vide
buffer.lock().unwrap().mark_end(); buffer.lock().unwrap().mark_end();
@@ -576,10 +579,7 @@ impl AudioPipelineNode for AudioSink {
panic!("AudioSink is a terminal node and cannot have children"); panic!("AudioSink is a terminal node and cannot have children");
} }
async fn run( async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await Box::new(self.inner).run(stop_token).await
} }
} }

View File

@@ -41,7 +41,11 @@ impl NodeLogic for FileSourceLogic {
output: Vec<mpsc::Sender<Arc<AudioSegment>>>, output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
stop_token: CancellationToken, stop_token: CancellationToken,
) -> Result<(), AudioError> { ) -> 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 helper pour envoyer à tous les enfants
macro_rules! send_to_children { macro_rules! send_to_children {
@@ -55,9 +59,9 @@ impl NodeLogic for FileSourceLogic {
} }
// Ouvrir le fichier // Ouvrir le fichier
let file = File::open(&self.path).await.map_err(|e| { let file = File::open(&self.path)
AudioError::IoError(format!("Failed to open {:?}: {}", self.path, e)) .await
})?; .map_err(|e| AudioError::IoError(format!("Failed to open {:?}: {}", self.path, e)))?;
// Décoder le flux audio // Décoder le flux audio
let mut stream = decode_audio_stream(file) let mut stream = decode_audio_stream(file)
@@ -256,10 +260,7 @@ impl AudioPipelineNode for FileSource {
self.inner.register(child) self.inner.register(child)
} }
async fn run( async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await Box::new(self.inner).run(stop_token).await
} }
} }
@@ -399,7 +400,6 @@ fn bytes_to_segment(
})) }))
} }
impl TypedAudioNode for FileSource { impl TypedAudioNode for FileSource {
fn input_type(&self) -> Option<TypeRequirement> { fn input_type(&self) -> Option<TypeRequirement> {
// FileSource est une source, elle ne consomme pas d'audio // FileSource est une source, elle ne consomme pas d'audio
@@ -464,11 +464,7 @@ mod tests {
impl TestCollectorNode { impl TestCollectorNode {
fn new(test_tx: mpsc::Sender<Arc<AudioSegment>>) -> Self { fn new(test_tx: mpsc::Sender<Arc<AudioSegment>>) -> Self {
let (tx, rx) = mpsc::channel(16); let (tx, rx) = mpsc::channel(16);
Self { Self { tx, rx, test_tx }
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 rx = input.expect("FlacFileSink must have input");
let mut track_number = 0; 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 { loop {
// Vérifier si l'arrêt a été demandé // Vérifier si l'arrêt a été demandé
@@ -81,13 +84,14 @@ impl NodeLogic for FlacFileSinkLogic {
} }
// Attendre le premier chunk audio pour cette track, en capturant les métadonnées du TrackBoundary // 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) =
Ok(result) => result, match wait_for_first_audio_chunk_with_metadata(&mut rx, &stop_token).await {
Err(_) => { Ok(result) => result,
// Plus d'audio disponible ou arrêt demandé Err(_) => {
return Ok(()); // Plus d'audio disponible ou arrêt demandé
} return Ok(());
}; }
};
// Extraire les informations du premier chunk // Extraire les informations du premier chunk
let first_chunk = first_segment.as_chunk().unwrap(); let first_chunk = first_segment.as_chunk().unwrap();
@@ -96,7 +100,9 @@ impl NodeLogic for FlacFileSinkLogic {
tracing::debug!( tracing::debug!(
"FlacFileSinkLogic: encoding track {} with {}bit @ {}Hz", "FlacFileSinkLogic: encoding track {} with {}bit @ {}Hz",
track_number, bits_per_sample, sample_rate track_number,
bits_per_sample,
sample_rate
); );
let format = PcmFormat { let format = PcmFormat {
@@ -308,10 +314,7 @@ impl FlacFileSink {
/// ///
/// * `base_path` - Chemin de base pour les fichiers FLAC /// * `base_path` - Chemin de base pour les fichiers FLAC
/// * `channel_size` - Taille du buffer MPSC (nombre de segments en attente avant backpressure) /// * `channel_size` - Taille du buffer MPSC (nombre de segments en attente avant backpressure)
pub fn with_channel_size<P: Into<PathBuf>>( pub fn with_channel_size<P: Into<PathBuf>>(base_path: P, channel_size: usize) -> Self {
base_path: P,
channel_size: usize,
) -> Self {
Self::with_config(base_path, channel_size, EncoderOptions::default()) 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( async fn wait_for_first_audio_chunk_with_metadata(
rx: &mut mpsc::Receiver<Arc<AudioSegment>>, rx: &mut mpsc::Receiver<Arc<AudioSegment>>,
stop_token: &CancellationToken, 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; let mut track_metadata: Option<Arc<tokio::sync::RwLock<dyn pmometadata::TrackMetadata>>> = None;
loop { loop {
@@ -738,10 +747,7 @@ impl AudioPipelineNode for FlacFileSink {
panic!("FlacFileSink is a terminal node and cannot have children"); panic!("FlacFileSink is a terminal node and cannot have children");
} }
async fn run( async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await Box::new(self.inner).run(stop_token).await
} }
} }
@@ -768,7 +774,6 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_flac_file_sink_writes_metadata() { async fn test_flac_file_sink_writes_metadata() {
let temp_dir = tempfile::tempdir().unwrap(); let temp_dir = tempfile::tempdir().unwrap();
let output_path = temp_dir.path().join("output_with_metadata.flac"); 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 sink = FlacFileSink::with_channel_size(&output_path, 16);
let tx = sink.get_tx().unwrap(); let tx = sink.get_tx().unwrap();
let stop_token = CancellationToken::new(); let stop_token = CancellationToken::new();
let sink_handle = tokio::spawn(async move { let sink_handle =
Box::new(sink).run(stop_token).await.unwrap() tokio::spawn(async move { Box::new(sink).run(stop_token).await.unwrap() });
});
// Envoyer des segments avec métadonnées // Envoyer des segments avec métadonnées
tokio::spawn(async move { tokio::spawn(async move {
@@ -792,13 +796,25 @@ mod tests {
// TrackBoundary avec métadonnées // TrackBoundary avec métadonnées
let mut metadata = MemoryTrackMetadata::new(); let mut metadata = MemoryTrackMetadata::new();
metadata.set_title(Some("Test Track Title".to_string())).await.unwrap(); metadata
metadata.set_artist(Some("Test Artist".to_string())).await.unwrap(); .set_title(Some("Test Track Title".to_string()))
metadata.set_album(Some("Test Album".to_string())).await.unwrap(); .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(); metadata.set_year(Some(2024)).await.unwrap();
let track_boundary = let track_boundary = crate::AudioSegment::new_track_boundary(
crate::AudioSegment::new_track_boundary(0, 0.0, std::sync::Arc::new(tokio::sync::RwLock::new(metadata))); 0,
0.0,
std::sync::Arc::new(tokio::sync::RwLock::new(metadata)),
);
tx.send(track_boundary).await.unwrap(); tx.send(track_boundary).await.unwrap();
// Générer et envoyer des chunks audio // Générer et envoyer des chunks audio
@@ -900,9 +916,8 @@ mod tests {
let sink = FlacFileSink::with_channel_size(&output_path, 16); let sink = FlacFileSink::with_channel_size(&output_path, 16);
let tx = sink.get_tx().unwrap(); let tx = sink.get_tx().unwrap();
let stop_token = CancellationToken::new(); let stop_token = CancellationToken::new();
let sink_handle = tokio::spawn(async move { let sink_handle =
Box::new(sink).run(stop_token).await.unwrap() tokio::spawn(async move { Box::new(sink).run(stop_token).await.unwrap() });
});
// Lire le fichier input et envoyer les segments au sink // Lire le fichier input et envoyer les segments au sink
tokio::spawn(async move { tokio::spawn(async move {

View File

@@ -114,7 +114,7 @@ impl HttpSourceLogic {
self.url.clone() self.url.clone()
} }
pub fn get_chunc_frames(&self) -> usize { pub fn get_chunc_frames(&self) -> usize {
self.chunk_frames self.chunk_frames
} }
} }
@@ -138,11 +138,9 @@ impl NodeLogic for HttpSourceLogic {
} }
// Effectuer la requête HTTP // Effectuer la requête HTTP
let response = reqwest::get(&self.url) let response = reqwest::get(&self.url).await.map_err(|e| {
.await AudioError::ProcessingError(format!("HTTP request failed for {}: {}", self.url, e))
.map_err(|e| { })?;
AudioError::ProcessingError(format!("HTTP request failed for {}: {}", self.url, e))
})?;
// Vérifier le status // Vérifier le status
if !response.status().is_success() { if !response.status().is_success() {
@@ -158,9 +156,10 @@ impl NodeLogic for HttpSourceLogic {
// Convertir le stream de bytes en AsyncRead // Convertir le stream de bytes en AsyncRead
let bytes_stream = response.bytes_stream(); let bytes_stream = response.bytes_stream();
let stream_reader = StreamReader::new(bytes_stream.map(|result| { let stream_reader =
result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) StreamReader::new(bytes_stream.map(|result| {
})); result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
}));
// Décoder le flux audio // Décoder le flux audio
let mut stream = decode_audio_stream(stream_reader) let mut stream = decode_audio_stream(stream_reader)
@@ -183,11 +182,8 @@ impl NodeLogic for HttpSourceLogic {
send_to_children!(AudioSegment::new_top_zero_sync()); send_to_children!(AudioSegment::new_top_zero_sync());
// Émettre TrackBoundary avec les métadonnées HTTP // Émettre TrackBoundary avec les métadonnées HTTP
let track_boundary = AudioSegment::new_track_boundary( let track_boundary =
0, AudioSegment::new_track_boundary(0, 0.0, Arc::new(tokio::sync::RwLock::new(metadata)));
0.0,
Arc::new(tokio::sync::RwLock::new(metadata)),
);
send_to_children!(track_boundary); send_to_children!(track_boundary);
// Préparer la lecture des chunks audio // Préparer la lecture des chunks audio
@@ -523,10 +519,7 @@ impl AudioPipelineNode for HttpSource {
self.inner.register(child) self.inner.register(child)
} }
async fn run( async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await Box::new(self.inner).run(stop_token).await
} }
} }
@@ -698,7 +691,10 @@ mod tests {
} }
// Vérifications // 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_top_zero, "TopZeroSync doit être émis");
assert!(seen_track_boundary, "TrackBoundary doit être émis"); assert!(seen_track_boundary, "TrackBoundary doit être émis");
assert!(seen_eos, "EndOfStream doit être émis"); assert!(seen_eos, "EndOfStream doit être émis");
@@ -725,9 +721,10 @@ mod tests {
bits_per_sample: 16, bits_per_sample: 16,
}; };
let mut flac_stream = encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default()) let mut flac_stream =
.await encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
.unwrap(); .await
.unwrap();
let mut flac_data = Vec::new(); let mut flac_data = Vec::new();
tokio::io::copy(&mut flac_stream, &mut flac_data) tokio::io::copy(&mut flac_stream, &mut flac_data)
@@ -798,7 +795,10 @@ mod tests {
assert!(result.is_err(), "Doit retourner une erreur pour HTTP 404"); assert!(result.is_err(), "Doit retourner une erreur pour HTTP 404");
if let Err(AudioError::ProcessingError(msg)) = result { 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 { } else {
panic!("Le type d'erreur doit être ProcessingError"); panic!("Le type d'erreur doit être ProcessingError");
} }
@@ -854,9 +854,10 @@ mod tests {
bits_per_sample: 16, bits_per_sample: 16,
}; };
let mut flac_stream = encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default()) let mut flac_stream =
.await encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
.unwrap(); .await
.unwrap();
let mut flac_data = Vec::new(); let mut flac_data = Vec::new();
tokio::io::copy(&mut flac_stream, &mut flac_data) tokio::io::copy(&mut flac_stream, &mut flac_data)
@@ -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 flac_file_sink;
pub mod http_source; pub mod http_source;
pub mod resampling_node; pub mod resampling_node;
pub mod timer_buffer_node;
pub mod timer_node; pub mod timer_node;
// Modules temporairement désactivés // Modules temporairement désactivés

View File

@@ -95,7 +95,9 @@ impl ResamplingLogic {
bit_depth bit_depth
); );
let resampler = build_resampler(source_sr, self.target_sample_rate, 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 { self.current_resampler = Some(ResamplerState {
source_hz: source_sr, source_hz: source_sr,
resampler, resampler,
@@ -111,7 +113,12 @@ impl ResamplingLogic {
let (resampled_left, resampled_right) = resampling(&left, &right, &mut state.resampler); let (resampled_left, resampled_right) = resampling(&left, &right, &mut state.resampler);
// Recréer le chunk avec le nouveau sample rate // 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) self.inner.register(child)
} }
async fn run( async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await Box::new(self.inner).run(stop_token).await
} }
} }
@@ -418,11 +422,7 @@ mod tests {
#[test] #[test]
fn test_reconstruct_chunk_i16() { fn test_reconstruct_chunk_i16() {
let original = AudioChunk::I16(AudioChunkData::new( let original = AudioChunk::I16(AudioChunkData::new(vec![[100, 200]], 44100, 0.0));
vec![[100, 200]],
44100,
0.0,
));
let left = vec![100i32, 300i32]; let left = vec![100i32, 300i32];
let right = vec![200i32, 400i32]; let right = vec![200i32, 400i32];
@@ -495,7 +495,7 @@ mod tests {
// Créer un TrackBoundary // Créer un TrackBoundary
let metadata = Arc::new(tokio::sync::RwLock::new( let metadata = Arc::new(tokio::sync::RwLock::new(
pmometadata::MemoryTrackMetadata::new() pmometadata::MemoryTrackMetadata::new(),
)); ));
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata); let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata);
@@ -568,7 +568,11 @@ mod tests {
// 100 frames @ 44.1kHz ≈ 109 frames @ 48kHz // 100 frames @ 44.1kHz ≈ 109 frames @ 48kHz
if let AudioChunk::I16(data) = chunk.as_ref() { if let AudioChunk::I16(data) = chunk.as_ref() {
let frames = data.get_frames().len(); 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 { } else {
panic!("Expected audio chunk"); 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 { pub struct TimerNodeLogic {
/// Avance maximale tolérée en secondes (buffer) /// Avance maximale tolérée en secondes (buffer)
max_lead_time_sec: f64, 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) /// Instant de référence (reset au TopZeroSync)
start_time: Option<Instant>, 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 { impl TimerNodeLogic {
pub fn new(max_lead_time_sec: f64) -> Self { 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 { 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, 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 helper pour envoyer à tous les enfants
macro_rules! send_to_children { macro_rules! send_to_children {
($segment:expr) => { ($segment:expr) => {
for tx in &output { for (idx, tx) in output.iter().enumerate() {
let send_start = Instant::now();
tx.send($segment.clone()) tx.send($segment.clone())
.await .await
.map_err(|_| AudioError::ChildDied)?; .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(_) => { _AudioSegment::Chunk(_) => {
// Vérifier le pacing seulement si on a un timer de référence // Vérifier le pacing seulement si on a un timer de référence
if let Some(start) = self.start_time { if let Some(start) = self.start_time {
self.chunk_count += 1;
let chunk_timestamp = segment.timestamp_sec; let chunk_timestamp = segment.timestamp_sec;
let elapsed = start.elapsed().as_secs_f64(); let mut elapsed = start.elapsed().as_secs_f64();
let lead_time = chunk_timestamp - elapsed; 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!( tracing::trace!(
"TimerNodeLogic: chunk received (ts={:.3}s, elapsed={:.3}s, lead_time={:.3}s, max_lead={:.1}s)", "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 { if lead_time > self.max_lead_time_sec {
// On est trop en avance, attendre // On est trop en avance, attendre juste assez pour retomber à max_lead_time
let sleep_duration = lead_time - self.max_lead_time_sec; let sleep_duration = (lead_time - self.max_lead_time_sec).max(0.0);
tracing::debug!( tracing::trace!(
"TimerNodeLogic: SLEEPING {:.3}s (lead_time={:.3}s > max={:.1}s, chunk_ts={:.3}s)", "TimerNodeLogic: SLEEPING {:.3}s (lead_time={:.3}s > max={:.1}s, chunk_ts={:.3}s)",
sleep_duration, sleep_duration,
lead_time, lead_time,
@@ -274,3 +340,4 @@ impl TypedAudioNode for TimerNode {
Some(TypeRequirement::any()) 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) /// - Un seul `cancel()` par nœud (en sortant de la boucle de travail)
/// - L'enfant ne cancel JAMAIS le parent /// - L'enfant ne cancel JAMAIS le parent
/// - `cancel()` est idempotent (pas de problème si appelé plusieurs fois) /// - `cancel()` est idempotent (pas de problème si appelé plusieurs fois)
async fn run( async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError>;
self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError>;
/// Lance le pipeline en arrière-plan et retourne un handle de contrôle /// 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 stop_token = CancellationToken::new();
let token_for_task = stop_token.clone(); let token_for_task = stop_token.clone();
let join_handle = tokio::spawn(async move { let join_handle = tokio::spawn(async move { self.run(token_for_task).await });
self.run(token_for_task).await
});
PipelineHandle { PipelineHandle {
stop_token, stop_token,
@@ -373,12 +368,14 @@ impl PipelineHandle {
pub async fn wait(self) -> Result<(), AudioError> { pub async fn wait(self) -> Result<(), AudioError> {
match self.join_handle.await { match self.join_handle.await {
Ok(result) => result, Ok(result) => result,
Err(e) if e.is_panic() => Err(AudioError::ProcessingError( Err(e) if e.is_panic() => Err(AudioError::ProcessingError(format!(
format!("Pipeline task panicked: {}", e) "Pipeline task panicked: {}",
)), e
Err(e) => Err(AudioError::ProcessingError( ))),
format!("Pipeline task cancelled: {}", 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); self.children.push(child);
} }
async fn run( async fn run(mut self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
mut self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
let Node { let Node {
mut logic, mut logic,
rx, rx,
@@ -528,9 +522,7 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
for (i, child) in children.into_iter().enumerate() { for (i, child) in children.into_iter().enumerate() {
tracing::debug!("Spawning child {}", i); tracing::debug!("Spawning child {}", i);
let child_token = stop_token.child_token(); let child_token = stop_token.child_token();
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move { child.run(child_token).await });
child.run(child_token).await
});
child_handles.push(handle); child_handles.push(handle);
} }
tracing::debug!("All {} children spawned", child_handles.len()); tracing::debug!("All {} children spawned", child_handles.len());
@@ -573,9 +565,10 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
// Un enfant a paniqué // Un enfant a paniqué
tracing::error!("Child panicked: {}", e); tracing::error!("Child panicked: {}", e);
if !has_error { if !has_error {
first_error = Some(AudioError::ProcessingError( first_error = Some(AudioError::ProcessingError(format!(
format!("Child task panicked: {}", e) "Child task panicked: {}",
)); e
)));
has_error = true; has_error = true;
} }
} }
@@ -595,78 +588,79 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
// PHASE 3: EXÉCUTER LA LOGIQUE MÉTIER EN RACE AVEC LE MONITORING // 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) =
// Il y a des enfants à surveiller if let Some(monitor) = &mut child_monitor {
tokio::select! { // Il y a des enfants à surveiller
// Cancel externe demandé tokio::select! {
_ = stop_token.cancelled() => { // Cancel externe demandé
tracing::debug!("Node cancelled via stop_token"); _ = stop_token.cancelled() => {
(StopReason::Cancelled, Ok(()), false) tracing::debug!("Node cancelled via stop_token");
} (StopReason::Cancelled, Ok(()), false)
}
// Monitoring des enfants - retourne quand tous sont terminés ou sur erreur // Monitoring des enfants - retourne quand tous sont terminés ou sur erreur
child_result = monitor => { child_result = monitor => {
match child_result { match child_result {
Ok(Ok(())) => { Ok(Ok(())) => {
// Tous les enfants terminés avec succès // Tous les enfants terminés avec succès
// Le parent devrait aussi terminer bientôt // Le parent devrait aussi terminer bientôt
tracing::debug!("All children finished successfully"); tracing::debug!("All children finished successfully");
(StopReason::Completed, Ok(()), true) (StopReason::Completed, Ok(()), true)
}
Ok(Err(e)) => {
// Un enfant a eu une erreur - arrêter immédiatement
tracing::warn!("Child error: {}", e);
(StopReason::Error(e.clone()), Err(e), true)
}
Err(e) => {
// Le monitor task a paniqué
let error = AudioError::ProcessingError(
format!("Child monitor panicked: {}", e)
);
(StopReason::Error(error.clone()), Err(error), true)
}
} }
Ok(Err(e)) => { }
// Un enfant a eu une erreur - arrêter immédiatement
tracing::warn!("Child error: {}", e); // Logique métier du nœud
(StopReason::Error(e.clone()), Err(e), true) process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => {
} tracing::info!("Node logic.process() returned");
Err(e) => { match process_result {
// Le monitor task a paniqué Ok(()) => {
let error = AudioError::ProcessingError( tracing::info!("Node process completed successfully");
format!("Child monitor panicked: {}", e) (StopReason::Completed, Ok(()), false)
); }
(StopReason::Error(error.clone()), Err(error), true) Err(e) => {
tracing::error!("Node process error: {}", e);
(StopReason::Error(e.clone()), Err(e), false)
}
} }
} }
} }
} else {
// Pas d'enfants (nœud terminal) - juste exécuter la logique
tokio::select! {
// Cancel externe demandé
_ = stop_token.cancelled() => {
tracing::debug!("Node cancelled via stop_token");
(StopReason::Cancelled, Ok(()), true) // true car pas de monitor à attendre
}
// Logique métier du nœud // Logique métier du nœud
process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => { process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => {
tracing::info!("Node logic.process() returned"); match process_result {
match process_result { Ok(()) => {
Ok(()) => { tracing::debug!("Node process completed successfully (terminal)");
tracing::info!("Node process completed successfully"); (StopReason::Completed, Ok(()), true) // true car pas de monitor
(StopReason::Completed, Ok(()), false) }
} Err(e) => {
Err(e) => { tracing::error!("Node process error: {}", e);
tracing::error!("Node process error: {}", e); (StopReason::Error(e.clone()), Err(e), true) // true car pas de monitor
(StopReason::Error(e.clone()), Err(e), false) }
} }
} }
} }
} };
} else {
// Pas d'enfants (nœud terminal) - juste exécuter la logique
tokio::select! {
// Cancel externe demandé
_ = stop_token.cancelled() => {
tracing::debug!("Node cancelled via stop_token");
(StopReason::Cancelled, Ok(()), true) // true car pas de monitor à attendre
}
// Logique métier du nœud
process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => {
match process_result {
Ok(()) => {
tracing::debug!("Node process completed successfully (terminal)");
(StopReason::Completed, Ok(()), true) // true car pas de monitor
}
Err(e) => {
tracing::error!("Node process error: {}", e);
(StopReason::Error(e.clone()), Err(e), true) // true car pas de monitor
}
}
}
}
};
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// PHASE 4: CLEANUP COORDONNÉ // PHASE 4: CLEANUP COORDONNÉ

View File

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

View File

@@ -296,27 +296,26 @@ mod tests {
meta.set_cover_pk(Some("cover123".into())).await.unwrap(); meta.set_cover_pk(Some("cover123".into())).await.unwrap();
} }
{ {
let meta = track.read().await; let meta = track.read().await;
assert_eq!(meta.get_title().await.unwrap(), Some("Title".into()));
assert_eq!(meta.get_title().await.unwrap(), Some("Title".into())); assert_eq!(meta.get_artist().await.unwrap(), Some("Artist".into()));
assert_eq!(meta.get_artist().await.unwrap(), Some("Artist".into())); assert_eq!(meta.get_album().await.unwrap(), Some("Album".into()));
assert_eq!(meta.get_album().await.unwrap(), Some("Album".into())); assert_eq!(meta.get_year().await.unwrap(), Some(2024));
assert_eq!(meta.get_year().await.unwrap(), Some(2024)); assert_eq!(
assert_eq!( meta.get_duration().await.unwrap(),
meta.get_duration().await.unwrap(), Some(Duration::from_secs(90))
Some(Duration::from_secs(90)) );
); assert_eq!(meta.get_track_id().await.unwrap(), Some("trk".into()));
assert_eq!(meta.get_track_id().await.unwrap(), Some("trk".into())); assert_eq!(meta.get_channel_id().await.unwrap(), Some("chn".into()));
assert_eq!(meta.get_channel_id().await.unwrap(), Some("chn".into())); assert_eq!(meta.get_event().await.unwrap(), Some("event".into()));
assert_eq!(meta.get_event().await.unwrap(), Some("event".into())); assert_eq!(meta.get_rating().await.unwrap(), Some(4.5));
assert_eq!(meta.get_rating().await.unwrap(), Some(4.5)); assert_eq!(
assert_eq!( meta.get_cover_url().await.unwrap(),
meta.get_cover_url().await.unwrap(), Some("http://cover".into())
Some("http://cover".into()) );
); assert_eq!(meta.get_cover_pk().await.unwrap(), Some("cover123".into()));
assert_eq!(meta.get_cover_pk().await.unwrap(), Some("cover123".into())); assert!(meta.get_updated_at().await.unwrap().is_some());
assert!(meta.get_updated_at().await.unwrap().is_some()); }
}
} }
} }

View File

@@ -70,7 +70,8 @@ pub struct Cache<C: CacheConfig> {
impl<C: CacheConfig> Cache<C> { impl<C: CacheConfig> Cache<C> {
/// Retourne le chemin du fichier marker de complétion /// Retourne le chemin du fichier marker de complétion
fn get_completion_marker_path(&self, pk: &str) -> PathBuf { 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 /// 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 { 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 { 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))?; .map_err(|e| anyhow!("Prebuffering failed: {}", e))?;
tracing::debug!("Prebuffering complete for pk {}", pk); 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> { async fn finalize_download(&self, pk: &str, download: Arc<Download>) -> Result<String> {
// Attendre le prébuffering (pour le cache progressif) // Attendre le prébuffering (pour le cache progressif)
if self.min_prebuffer_size > 0 { 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))?; .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 // 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 // Créer le fichier marker de complétion si le téléchargement a réussi
if result.is_ok() { if result.is_ok() {
if let Err(e) = std::fs::write(&completion_marker, "") { 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 { } else {
tracing::debug!("Created completion marker for pk {}", pk_clone); tracing::debug!("Created completion marker for pk {}", pk_clone);
} }
@@ -368,7 +384,8 @@ impl<C: CacheConfig> Cache<C> {
where where
R: AsyncRead + Send + Unpin + 'static, 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. /// 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() { 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; 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 // Vérifier d'abord si le marker de completion existe
let completion_marker = file_path.with_extension( let completion_marker =
format!("{}.complete", C::file_extension()) file_path.with_extension(format!("{}.complete", C::file_extension()));
);
if completion_marker.exists() { 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; 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); tracing::debug!("is_valid_pk({}): No marker but file is recent ({}s), download in progress", pk, age_secs);
return true; return true;
} else { } 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; return false;
} }
} }
@@ -198,7 +211,10 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
} }
// Ne peut pas vérifier le statut - rejeter par sécurité // 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 false
} }
} }

View File

@@ -295,14 +295,28 @@ fn test_metadata_types() {
db.add(pk, None, None).unwrap(); db.add(pk, None, None).unwrap();
// Tester les différents types de métadonnées // 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, "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(); db.set_a_metadata(pk, "null_val", Value::Null).unwrap();
// Vérifier les valeurs // Vérifier les valeurs
assert_eq!(db.get_metadata_value(pk, "string_val").unwrap(), Some(Value::String("test".to_string()))); assert_eq!(
assert_eq!(db.get_metadata_value(pk, "number_val").unwrap(), Some(json!(42))); db.get_metadata_value(pk, "string_val").unwrap(),
assert_eq!(db.get_metadata_value(pk, "bool_val").unwrap(), Some(Value::Bool(true))); Some(Value::String("test".to_string()))
assert_eq!(db.get_metadata_value(pk, "null_val").unwrap(), Some(Value::Null)); );
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 pmocovers::cache;
use tempfile::TempDir; use tempfile::TempDir;
use image::{ImageBuffer, Rgba};
fn create_test_cache() -> (TempDir, cache::Cache) { fn create_test_cache() -> (TempDir, cache::Cache) {
let temp_dir = tempfile::tempdir().unwrap(); let temp_dir = tempfile::tempdir().unwrap();
@@ -19,8 +19,11 @@ fn create_test_image(width: u32, height: u32) -> Vec<u8> {
}); });
let mut buffer = Vec::new(); let mut buffer = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Png) img.write_to(
.unwrap(); &mut std::io::Cursor::new(&mut buffer),
image::ImageFormat::Png,
)
.unwrap();
buffer buffer
} }

View File

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

View File

@@ -255,10 +255,9 @@ where
// Try to extract genre and track_number from extra fields // Try to extract genre and track_number from extra fields
let extra = metadata.get_extra().await.ok().flatten(); let extra = metadata.get_extra().await.ok().flatten();
let genre = extra.as_ref().and_then(|e| e.get("genre").cloned()); let genre = extra.as_ref().and_then(|e| e.get("genre").cloned());
let track_number = extra.as_ref().and_then(|e| { let track_number = extra
e.get("track_number") .as_ref()
.and_then(|s| s.parse::<u32>().ok()) .and_then(|e| e.get("track_number").and_then(|s| s.parse::<u32>().ok()));
});
Some(ExtractedMetadata { Some(ExtractedMetadata {
title, title,
@@ -457,8 +456,7 @@ unsafe fn setup_metadata(
// Set the metadata on the encoder // Set the metadata on the encoder
let mut metadata_array = [meta]; let mut metadata_array = [meta];
let set_success = let set_success = FLAC__stream_encoder_set_metadata(encoder, metadata_array.as_mut_ptr(), 1);
FLAC__stream_encoder_set_metadata(encoder, metadata_array.as_mut_ptr(), 1);
if set_success == 0 { if set_success == 0 {
return Err(FlacError::LibFlacInit( 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!("Block Information:");
println!(" Event ID: {}", block.event); println!(" Event ID: {}", block.event);
println!(" Songs: {}", block.song_count()); println!(" Songs: {}", block.song_count());
println!( println!(" Duration: {:.1} minutes", block.length as f64 / 60000.0);
" Duration: {:.1} minutes",
block.length as f64 / 60000.0
);
println!(); println!();
// Afficher la liste des pistes // 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("pmoaudio_ext=debug".parse()?)
.add_directive("pmoplaylist=debug".parse()?) .add_directive("pmoplaylist=debug".parse()?)
.add_directive("pmoparadise=debug".parse()?) .add_directive("pmoparadise=debug".parse()?)
.add_directive("pmoaudiocache=debug".parse()?) .add_directive("pmoaudiocache=debug".parse()?),
) )
.init(); .init();
@@ -89,7 +89,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialiser les caches et le gestionnaire de playlist // 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)?; std::fs::create_dir_all(&base_dir)?;
tracing::info!("Initializing caches in: {}", 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); tracing::info!("Creating playlist: {}", playlist_id);
// Créer une playlist éphémère (non persistante) pour cet exemple // Créer une playlist éphémère (non persistante) pour cet exemple
let writer = playlist_manager.get_write_handle(playlist_id.clone()).await?; let writer = playlist_manager
writer.set_title(format!("Radio Paradise - Channel {}", channel_id)).await?; .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 writer.flush().await?; // Vider la playlist si elle existait
tracing::debug!("Playlist created and flushed"); 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 // Créer la source Radio Paradise
let mut download_source = RadioParadiseStreamSource::new(client); let mut download_source = RadioParadiseStreamSource::new(client);
download_source.push_block_id(block.event); 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 // Créer le sink de cache FLAC
let mut cache_sink = FlacCacheSink::new(audio_cache.clone(), cover_cache.clone()); let mut cache_sink = FlacCacheSink::new(audio_cache.clone(), cover_cache.clone());

View File

@@ -9,13 +9,13 @@
//! //!
//! Architecture: //! Architecture:
//! ```text //! ```text
//! RadioParadiseStreamSource → TimerNode → StreamingFlacSink //! RadioParadiseStreamSource → TimerBufferNode → StreamingFlacSink
//! ↓ //!
//! StreamHandle //! StreamHandle
//! ↓ //!
//! pmoserver (Axum) //! pmoserver (Axum)
//! ↓ //!
//! VLC / Media Player Client //! VLC / Media Player Client
//! ``` //! ```
//! //!
//! Usage: //! Usage:
@@ -38,11 +38,11 @@ use axum::{
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use pmoaudio::{AudioPipelineNode, TimerNode}; use pmoaudio::{AudioPipelineNode, TimerBufferNode};
use pmoaudio_ext::{StreamingFlacSink, StreamingOggFlacSink}; use pmoaudio_ext::{StreamingFlacSink, StreamingOggFlacSink};
use pmoflac::EncoderOptions; use pmoflac::EncoderOptions;
use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource, END_OF_BLOCKS_SIGNAL}; use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource, END_OF_BLOCKS_SIGNAL};
use pmoserver::{ServerBuilder, init_logging}; use pmoserver::{init_logging, ServerBuilder};
use std::env; use std::env;
use std::sync::Arc; use std::sync::Arc;
use tokio_util::io::ReaderStream; 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()); let mut source_flac = RadioParadiseStreamSource::new(client.clone());
source_flac.push_block_id(block.event); source_flac.push_block_id(block.event);
source_flac.push_block_id(END_OF_BLOCKS_SIGNAL); // Signal: no more blocks after this one 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 // Use SMALL channel size to make backpressure more reactive
// Instead of trying to buffer 3s of audio (60 chunks), use a much smaller buffer // Instead of trying to buffer 3s of audio (60 chunks), use a much smaller buffer
// This forces tighter backpressure control // This forces tighter backpressure control
let max_lead_time = 3.0; let buffer_sec = 10.0;
let channel_size = 8; // Small buffer for reactive backpressure let max_lead_time = buffer_sec;
tracing::debug!("Using channel size: {} chunks ({:.1}s buffer at 50ms/chunk)", channel_size, channel_size as f64 * 0.05); 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); let mut timer_flac = TimerBufferNode::with_channel_size(buffer_sec, channel_size);
tracing::debug!("TimerNode (FLAC) created with {:.1}s max lead time, {} chunk buffer", max_lead_time, 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) // 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"); tracing::debug!("StreamingFlacSink created");
timer_flac.register(Box::new(streaming_sink)); timer_flac.register(Box::new(streaming_sink));
source_flac.register(Box::new(timer_flac)); 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 // 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); let mut source_ogg = RadioParadiseStreamSource::new(client);
source_ogg.push_block_id(block.event); source_ogg.push_block_id(block.event);
source_ogg.push_block_id(END_OF_BLOCKS_SIGNAL); // Signal: no more blocks after this one 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); let mut timer_ogg = TimerBufferNode::with_channel_size(buffer_sec, channel_size);
tracing::debug!("TimerNode (OGG) created with {:.1}s max lead time, {} chunk buffer", max_lead_time, 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) // 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"); tracing::debug!("StreamingOggFlacSink created");
timer_ogg.register(Box::new(ogg_sink)); timer_ogg.register(Box::new(ogg_sink));
source_ogg.register(Box::new(timer_ogg)); 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 // Setup pmoserver with streaming routes
@@ -256,8 +282,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing::info!("Setting up pmoserver..."); tracing::info!("Setting up pmoserver...");
let mut server = ServerBuilder::new("RadioParadiseStreamTest", "http://localhost", 8080) let mut server =
.build(); ServerBuilder::new("RadioParadiseStreamTest", "http://localhost", 8080).build();
let app_state = Arc::new(AppState { let app_state = Arc::new(AppState {
stream_handle, stream_handle,
@@ -265,12 +291,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}); });
// Add streaming routes // Add streaming routes
server.add_handler_with_state("/test/stream", stream_handler, app_state.clone()).await; server
server.add_handler_with_state("/test/stream-icy", stream_icy_handler, app_state.clone()).await; .add_handler_with_state("/test/stream", stream_handler, app_state.clone())
server.add_handler_with_state("/test/stream-ogg", stream_ogg_handler, app_state.clone()).await; .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 // 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 // Add health check
server.add_handler("/test/health", health_handler).await; server.add_handler("/test/health", health_handler).await;

View File

@@ -140,7 +140,8 @@ impl RadioParadiseClient {
url.query_pairs_mut() url.query_pairs_mut()
.append_pair("bitrate", "4") // FLAC lossless .append_pair("bitrate", "4") // FLAC lossless
.append_pair("info", "true") .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 { if let Some(event_id) = event {
url.query_pairs_mut() url.query_pairs_mut()

View File

@@ -91,13 +91,15 @@ impl NodeStats {
/// Enregistre l'envoi d'un segment /// Enregistre l'envoi d'un segment
pub fn record_segment_sent(&self, bytes: usize) { pub fn record_segment_sent(&self, bytes: usize) {
self.segments_sent.fetch_add(1, Ordering::Relaxed); 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 /// Enregistre un événement de backpressure
pub fn record_backpressure(&self, duration_ms: u64) { pub fn record_backpressure(&self, duration_ms: u64) {
self.backpressure_blocks.fetch_add(1, Ordering::Relaxed); 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 /// Retourne un rapport formaté des statistiques
@@ -112,7 +114,11 @@ impl NodeStats {
let first_ts = self.first_segment_timestamp.load(Ordering::Relaxed); let first_ts = self.first_segment_timestamp.load(Ordering::Relaxed);
let last_ts = self.last_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 last_ts_sec = last_ts as f64 / 1000.0;
let audio_duration = last_ts_sec - first_ts_sec; 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\ Audio: {:.1}s (first: {:.1}s, last: {:.1}s) | Real-time ratio: {:.1}%\n\
Backpressure: {} blocks, {:.2}s total ({:.1}% of time)", Backpressure: {} blocks, {:.2}s total ({:.1}% of time)",
self.name, self.name,
elapsed, received, sent, received.saturating_sub(sent), elapsed,
mb, throughput_mbps, received,
audio_duration, first_ts_sec, last_ts_sec, sent,
if audio_duration > 0.0 { (elapsed / audio_duration) * 100.0 } else { 0.0 }, received.saturating_sub(sent),
bp_blocks, bp_time_ms as f64 / 1000.0, mb,
if elapsed > 0.0 { (bp_time_ms as f64 / 1000.0 / elapsed) * 100.0 } else { 0.0 } 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.length as f64 / 60000.0,
block.url block.url
); );
let response = self.client.client let response = self
.client
.client
.get(&block.url) .get(&block.url)
.timeout(self.client.block_timeout) .timeout(self.client.block_timeout)
.send() .send()
@@ -125,9 +127,9 @@ impl RadioParadiseStreamSourceLogic {
// Créer un stream reader // Créer un stream reader
tracing::debug!("Creating byte stream reader"); tracing::debug!("Creating byte stream reader");
let byte_stream = response.bytes_stream().map(|result| { let byte_stream = response
result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) .bytes_stream()
}); .map(|result| result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)));
let stream_reader = StreamReader::new(byte_stream); let stream_reader = StreamReader::new(byte_stream);
tracing::debug!("Stream reader created"); tracing::debug!("Stream reader created");
@@ -140,7 +142,11 @@ impl RadioParadiseStreamSourceLogic {
let stream_info = decoder.info().clone(); let stream_info = decoder.info().clone();
let sample_rate = stream_info.sample_rate; let sample_rate = stream_info.sample_rate;
let bits_per_sample = stream_info.bits_per_sample; 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 // Préparer les songs ordonnées pour tracking
let songs = block.songs_ordered(); let songs = block.songs_ordered();
@@ -165,13 +171,16 @@ impl RadioParadiseStreamSourceLogic {
// Envoyer TrackBoundary pour la première song AVANT le premier chunk audio // 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 // 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) // 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() { 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); 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 metadata = song_to_metadata(song, block).await;
let track_boundary = AudioSegment::new_track_boundary( let track_boundary = AudioSegment::new_track_boundary(
*order, *order, 0.0, // timestamp = 0 au début du stream
0.0, // timestamp = 0 au début du stream
metadata, metadata,
); );
self.send_to_children(output, track_boundary).await?; self.send_to_children(output, track_boundary).await?;
@@ -183,7 +192,6 @@ impl RadioParadiseStreamSourceLogic {
}; };
tracing::debug!("Starting audio chunk loop"); tracing::debug!("Starting audio chunk loop");
// Buffer pour lecture // Buffer pour lecture
let bytes_per_sample = (bits_per_sample / 8) as usize; let bytes_per_sample = (bits_per_sample / 8) as usize;
let frame_bytes = bytes_per_sample * 2; // stereo let frame_bytes = bytes_per_sample * 2; // stereo
@@ -196,6 +204,7 @@ impl RadioParadiseStreamSourceLogic {
let mut chunk_count = 0; let mut chunk_count = 0;
let mut total_bytes_decoded = 0u64; let mut total_bytes_decoded = 0u64;
let expected_duration_sec = block.length as f64 / 1000.0; let expected_duration_sec = block.length as f64 / 1000.0;
let mut stats_last_log = Instant::now();
loop { loop {
// Vérifier stop_token // Vérifier stop_token
@@ -213,7 +222,9 @@ impl RadioParadiseStreamSourceLogic {
// Remplir le buffer // Remplir le buffer
if pending.len() < chunk_byte_len { 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)))?; .map_err(|e| AudioError::ProcessingError(format!("Read error: {}", e)))?;
if read == 0 { if read == 0 {
@@ -263,22 +274,35 @@ impl RadioParadiseStreamSourceLogic {
); );
let metadata = song_to_metadata(song, block).await; let metadata = song_to_metadata(song, block).await;
let timestamp_sec = total_samples as f64 / sample_rate as f64; let timestamp_sec = total_samples as f64 / sample_rate as f64;
let track_boundary = AudioSegment::new_track_boundary( let track_boundary =
*order, AudioSegment::new_track_boundary(*order, timestamp_sec, metadata);
timestamp_sec,
metadata,
);
self.send_to_children(output, track_boundary).await?; self.send_to_children(output, track_boundary).await?;
// Passer à la song suivante // Passer à la song suivante
song_index += 1; song_index += 1;
next_song = songs.get(song_index).copied(); 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 // Envoyer le chunk audio
let timestamp_sec = total_samples as f64 / sample_rate as f64; 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( let audio_segment = pcm_to_audio_segment(
&pcm_data, &pcm_data,
*order, *order,
@@ -295,7 +319,11 @@ impl RadioParadiseStreamSourceLogic {
// Retourner le timestamp du dernier chunk (durée totale du bloc) et l'instant de début // 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; 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)) Ok((final_timestamp, start_instant))
} }
@@ -312,7 +340,9 @@ impl RadioParadiseStreamSourceLogic {
let capacity_before = tx.capacity(); let capacity_before = tx.capacity();
tracing::trace!( tracing::trace!(
"send_to_children: Sending to child {} (channel capacity={}, timestamp={:.3}s)", "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(); let send_start = std::time::Instant::now();
@@ -325,8 +355,11 @@ impl RadioParadiseStreamSourceLogic {
let duration_ms = send_duration.as_millis() as u64; let duration_ms = send_duration.as_millis() as u64;
self.stats.record_backpressure(duration_ms); self.stats.record_backpressure(duration_ms);
tracing::debug!( tracing::debug!(
"send_to_children: Send to child {} BLOCKED for {:.3}s (backpressure triggered, timestamp={:.3}s)", "send_to_children: Send to child {} BLOCKED for {:.3}s (channel capacity before send={}, timestamp={:.3}s)",
i, send_duration.as_secs_f64(), segment.timestamp_sec 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>>>, output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
stop_token: CancellationToken, stop_token: CancellationToken,
) -> Result<(), AudioError> { ) -> 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() { for (i, event_id) in self.block_queue.iter().enumerate() {
tracing::debug!(" block_queue[{}] = {}", i, event_id); tracing::debug!(" block_queue[{}] = {}", i, event_id);
} }
@@ -544,7 +580,9 @@ impl NodeLogic for RadioParadiseStreamSourceLogic {
// Vérifier si c'est le signal de fin // Vérifier si c'est le signal de fin
if id == END_OF_BLOCKS_SIGNAL { 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; break None;
} }
@@ -573,10 +611,10 @@ impl NodeLogic for RadioParadiseStreamSourceLogic {
// Récupérer les métadonnées du bloc // Récupérer les métadonnées du bloc
tracing::debug!("Fetching block metadata for event_id {}...", event_id); tracing::debug!("Fetching block metadata for event_id {}...", event_id);
let block = self.client let block =
.get_block(Some(event_id)) self.client.get_block(Some(event_id)).await.map_err(|e| {
.await AudioError::ProcessingError(format!("Failed to get block: {}", e))
.map_err(|e| AudioError::ProcessingError(format!("Failed to get block: {}", e)))?; })?;
tracing::debug!("Block metadata received: url={}", block.url); tracing::debug!("Block metadata received: url={}", block.url);
// Marquer comme téléchargé // Marquer comme téléchargé
@@ -584,15 +622,24 @@ impl NodeLogic for RadioParadiseStreamSourceLogic {
// Télécharger et décoder le bloc // Télécharger et décoder le bloc
tracing::info!("Starting download and decode for block {}...", event_id); 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?; .await?;
last_timestamp = block_duration; last_timestamp = block_duration;
last_start_instant = Some(start_instant); 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 // 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); let eos = AudioSegment::new_end_of_stream(order, last_timestamp);
for tx in &output { for tx in &output {
tx.send(eos.clone()) tx.send(eos.clone())
@@ -692,7 +739,8 @@ mod tests {
#[test] #[test]
fn test_cache_fifo_basic() { fn test_cache_fifo_basic() {
let client = create_test_client(); 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 // Ajouter 5 blocs
for i in 1..=5 { for i in 1..=5 {
@@ -709,7 +757,8 @@ mod tests {
#[test] #[test]
fn test_cache_fifo_exactly_10_elements() { fn test_cache_fifo_exactly_10_elements() {
let client = create_test_client(); 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 // Ajouter exactement 10 blocs
for i in 1..=10 { for i in 1..=10 {
@@ -717,7 +766,11 @@ mod tests {
} }
// Vérifier qu'on a exactement 10 éléments // 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 // Tous devraient être dans le cache
for i in 1..=10 { for i in 1..=10 {
@@ -728,7 +781,8 @@ mod tests {
#[test] #[test]
fn test_cache_fifo_eviction_oldest() { fn test_cache_fifo_eviction_oldest() {
let client = create_test_client(); 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) // Remplir le cache avec 10 éléments (1..=10)
for i in 1..=10 { for i in 1..=10 {
@@ -739,10 +793,17 @@ mod tests {
logic.mark_block_downloaded(11); logic.mark_block_downloaded(11);
// Le cache doit toujours avoir 10 éléments // 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é // 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 // Les éléments 2..=11 doivent être présents
for i in 2..=11 { for i in 2..=11 {
@@ -753,7 +814,8 @@ mod tests {
#[test] #[test]
fn test_cache_fifo_multiple_evictions() { fn test_cache_fifo_multiple_evictions() {
let client = create_test_client(); 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 // Remplir avec 10 éléments
for i in 1..=10 { for i in 1..=10 {
@@ -766,7 +828,11 @@ mod tests {
} }
// Toujours 10 éléments // 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 // Les 5 premiers doivent avoir été évincés
for i in 1..=5 { for i in 1..=5 {
@@ -782,7 +848,8 @@ mod tests {
#[test] #[test]
fn test_cache_never_exceeds_capacity() { fn test_cache_never_exceeds_capacity() {
let client = create_test_client(); 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 // Vérifier la capacité pré-allouée
assert_eq!(logic.recent_blocks.capacity(), RECENT_BLOCKS_CACHE_SIZE); assert_eq!(logic.recent_blocks.capacity(), RECENT_BLOCKS_CACHE_SIZE);
@@ -812,7 +879,8 @@ mod tests {
#[test] #[test]
fn test_cache_fifo_order_preserved() { fn test_cache_fifo_order_preserved() {
let client = create_test_client(); 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 // Ajouter 10 éléments
for i in 1..=10 { for i in 1..=10 {
@@ -830,7 +898,8 @@ mod tests {
#[test] #[test]
fn test_block_queue_push() { fn test_block_queue_push() {
let client = create_test_client(); 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 // Tester push_block_id
logic.push_block_id(100); logic.push_block_id(100);

View File

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

View File

@@ -48,6 +48,7 @@ async fn test_get_current_block() {
.and(path("/api/get_block")) .and(path("/api/get_block"))
.and(query_param("bitrate", "4")) .and(query_param("bitrate", "4"))
.and(query_param("info", "true")) .and(query_param("info", "true"))
.and(query_param("chan", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(1234, 5678))) .respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(1234, 5678)))
.mount(&mock_server) .mount(&mock_server)
.await; .await;
@@ -82,6 +83,7 @@ async fn test_get_specific_block() {
.and(path("/api/get_block")) .and(path("/api/get_block"))
.and(query_param("bitrate", "4")) .and(query_param("bitrate", "4"))
.and(query_param("info", "true")) .and(query_param("info", "true"))
.and(query_param("chan", "0"))
.and(query_param("event", "5678")) .and(query_param("event", "5678"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(5678, 9012))) .respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(5678, 9012)))
.mount(&mock_server) .mount(&mock_server)
@@ -99,12 +101,38 @@ async fn test_get_specific_block() {
assert_eq!(block.end_event, 9012); 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] #[tokio::test]
async fn test_now_playing() { async fn test_now_playing() {
let mock_server = MockServer::start().await; let mock_server = MockServer::start().await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/api/get_block")) .and(path("/api/get_block"))
.and(query_param("chan", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(1234, 5678))) .respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(1234, 5678)))
.mount(&mock_server) .mount(&mock_server)
.await; .await;
@@ -133,6 +161,8 @@ async fn test_prefetch_next() {
// First block // First block
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/api/get_block"))
.and(query_param("chan", "0"))
.and(query_param("event", "1234")) .and(query_param("event", "1234"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(1234, 5678))) .respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(1234, 5678)))
.mount(&mock_server) .mount(&mock_server)
@@ -140,6 +170,8 @@ async fn test_prefetch_next() {
// Next block // Next block
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/api/get_block"))
.and(query_param("chan", "0"))
.and(query_param("event", "5678")) .and(query_param("event", "5678"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(5678, 9012))) .respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(5678, 9012)))
.mount(&mock_server) .mount(&mock_server)

View File

@@ -23,11 +23,7 @@ use serde::{Deserialize, Serialize};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tracing::Level; use tracing::Level;
use tracing_subscriber::{ use tracing_subscriber::{
Registry, Registry, filter::LevelFilter, layer::SubscriberExt, reload, util::SubscriberInitExt,
filter::LevelFilter,
layer::SubscriberExt,
reload,
util::SubscriberInitExt,
}; };
/// Représente une entrée de log /// 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 { pub fn get_max_level(&self) -> Level {
*self.max_level.read().unwrap() *self.max_level.read().unwrap()
} }
@@ -266,15 +267,41 @@ impl Default for LoggingOptions {
/// ``` /// ```
pub fn init_logging() -> LogState { pub fn init_logging() -> LogState {
let config = get_config(); 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() { let (initial_level, level_source) = match std::env::var("RUST_LOG") {
Ok(l) => match string_to_level(&l) { Ok(value) => {
Some(lev) => level_to_levelfilter(lev), let trimmed = value.trim();
None => LevelFilter::TRACE, if let Some(level) = string_to_level(trimmed) {
}, (level, format!("RUST_LOG ({})", trimmed))
Err(_) => LevelFilter::TRACE, } 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); 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 // Créer le LogState avec le handle de rechargement
let log_state = LogState::new(buffer_capacity, reload_handle); 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 // Construire le subscriber avec le filtre rechargeable AVANT le SseLayer
// L'ordre est important : le filtre doit être appliqué en premier // 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> { 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 // Récupérer l'URL de base depuis la variable d'environnement ou une config
let base_url = std::env::var("PMO_SERVER_URL") let base_url =
.unwrap_or_else(|_| "http://localhost:8080".to_string()); std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
let cache = get_cover_cache() let cache = get_cover_cache().ok_or_else(|| anyhow::anyhow!("No registered cover cache"))?;
.ok_or_else(|| anyhow::anyhow!("No registered cover cache"))?;
let param = match size { let param = match size {
Some(size_) => Some(size_.to_string()), 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> { 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 // Récupérer l'URL de base depuis la variable d'environnement ou une config
let base_url = std::env::var("PMO_SERVER_URL") let base_url =
.unwrap_or_else(|_| "http://localhost:8080".to_string()); std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
let cache = get_audio_cache() let cache = get_audio_cache().ok_or_else(|| anyhow::anyhow!("No registered audio cache"))?;
.ok_or_else(|| anyhow::anyhow!("No registered audio cache"))?;
let route = cache.route_for(pk, param); let route = cache.route_for(pk, param);
Ok(format!("{}{}", base_url, route)) Ok(format!("{}{}", base_url, route))

View File

@@ -60,7 +60,9 @@ impl UpnpObject for Service {
// eventSubURL // eventSubURL
let mut event_sub_url = Element::new("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.children.push(XMLNode::Element(event_sub_url));
elem elem