debuggage des stream
This commit is contained in:
449
Cargo.lock
generated
449
Cargo.lock
generated
@@ -6,7 +6,7 @@ version = 4
|
||||
name = "PMOMusic"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum 0.8.6",
|
||||
"axum 0.8.7",
|
||||
"console-subscriber",
|
||||
"pmoapp",
|
||||
"pmoaudio-ext",
|
||||
@@ -103,7 +103,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -130,7 +130,7 @@ checksum = "f548ad2c4031f2902e3edc1f29c29e835829437de49562d8eb5dc5584d3a1043"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -163,7 +163,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -174,7 +174,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -252,9 +252,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.6"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871"
|
||||
checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425"
|
||||
dependencies = [
|
||||
"axum-core 0.5.5",
|
||||
"bytes",
|
||||
@@ -338,9 +338,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "axum-server"
|
||||
version = "0.7.2"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "495c05f60d6df0093e8fb6e74aa5846a0ad06abaf96d76166283720bf740f8ab"
|
||||
checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fs-err",
|
||||
@@ -373,7 +373,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
"toml_edit 0.23.7",
|
||||
]
|
||||
|
||||
@@ -439,7 +439,7 @@ dependencies = [
|
||||
"indexmap 2.12.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -470,7 +470,7 @@ dependencies = [
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -509,6 +509,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "built"
|
||||
version = "0.7.1"
|
||||
@@ -538,7 +547,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -555,9 +564,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.10.1"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
||||
|
||||
[[package]]
|
||||
name = "c_linked_list"
|
||||
@@ -567,9 +576,9 @@ checksum = "4964518bd3b4a8190e832886cdc0da9794f12e8e6c1613a9e90ff331c4c8724b"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.44"
|
||||
version = "1.2.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3"
|
||||
checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -608,6 +617,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.42"
|
||||
@@ -619,7 +634,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link 0.2.1",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -890,9 +905,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
@@ -945,7 +960,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -965,7 +980,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -999,6 +1014,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
@@ -1007,7 +1032,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1063,7 +1088,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1074,9 +1099,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "erased-serde"
|
||||
version = "0.4.8"
|
||||
version = "0.4.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "259d404d09818dec19332e31d94558aeb442fea04c817006456c24b5460bbd4b"
|
||||
checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
@@ -1116,9 +1141,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "exr"
|
||||
version = "1.73.0"
|
||||
version = "1.74.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0"
|
||||
checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
|
||||
dependencies = [
|
||||
"bit_field",
|
||||
"half",
|
||||
@@ -1170,7 +1195,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1184,9 +1209,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127"
|
||||
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
|
||||
|
||||
[[package]]
|
||||
name = "flacenc"
|
||||
@@ -1325,7 +1350,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1366,9 +1391,9 @@ checksum = "8f5f3913fa0bfe7ee1fd8248b6b9f42a5af4b9d65ec2dd2c3c26132b950ecfc2"
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.9"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
@@ -1619,9 +1644,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.7.0"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e"
|
||||
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1687,9 +1712,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8"
|
||||
checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -1907,7 +1932,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2066,7 +2091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link 0.2.1",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2168,7 +2193,7 @@ checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2479,6 +2504,18 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@@ -2557,7 +2594,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2618,7 +2655,166 @@ dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05"
|
||||
dependencies = [
|
||||
"objc2-encode",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-cloud-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-data"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-graphics"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-image"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-location"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
|
||||
|
||||
[[package]]
|
||||
name = "objc2-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-ui-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image",
|
||||
"objc2-core-location",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"objc2-user-notifications",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-user-notifications"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2670,9 +2866,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.74"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cfg-if",
|
||||
@@ -2691,7 +2887,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2702,9 +2898,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.110"
|
||||
version = "0.9.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -2730,14 +2926,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "os_info"
|
||||
version = "3.12.0"
|
||||
version = "3.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3"
|
||||
checksum = "7c39b5918402d564846d5aba164c09a66cc88d232179dfd3e3c619a25a268392"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"log",
|
||||
"plist",
|
||||
"nix",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-ui-kit",
|
||||
"serde",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2766,7 +2966,7 @@ dependencies = [
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link 0.2.1",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2798,7 +2998,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2819,19 +3019,6 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.12.0",
|
||||
"quick-xml 0.38.3",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pmoapp"
|
||||
version = "0.1.0"
|
||||
@@ -2888,7 +3075,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum 0.8.6",
|
||||
"axum 0.8.7",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures-util",
|
||||
@@ -2917,7 +3104,7 @@ name = "pmocache"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.6",
|
||||
"axum 0.8.7",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures-util",
|
||||
@@ -2943,7 +3130,7 @@ name = "pmoconfig"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.6",
|
||||
"axum 0.8.7",
|
||||
"dirs",
|
||||
"lazy_static",
|
||||
"log",
|
||||
@@ -2962,7 +3149,7 @@ name = "pmocovers"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.6",
|
||||
"axum 0.8.7",
|
||||
"image",
|
||||
"once_cell",
|
||||
"pmocache",
|
||||
@@ -2983,7 +3170,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"bevy_reflect",
|
||||
"bevy_reflect_derive",
|
||||
"quick-xml 0.38.3",
|
||||
"quick-xml 0.38.4",
|
||||
"serde",
|
||||
"utoipa",
|
||||
"utoipa-swagger-ui",
|
||||
@@ -3017,7 +3204,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"pmodidl",
|
||||
"pmoupnp",
|
||||
"quick-xml 0.38.3",
|
||||
"quick-xml 0.38.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3025,7 +3212,7 @@ name = "pmomediaserver"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum 0.8.6",
|
||||
"axum 0.8.7",
|
||||
"bevy_reflect",
|
||||
"once_cell",
|
||||
"pmoconfig",
|
||||
@@ -3035,7 +3222,7 @@ dependencies = [
|
||||
"pmoserver",
|
||||
"pmosource",
|
||||
"pmoupnp",
|
||||
"quick-xml 0.38.3",
|
||||
"quick-xml 0.38.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
@@ -3062,7 +3249,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"axum 0.8.6",
|
||||
"axum 0.8.7",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"claxon",
|
||||
@@ -3123,7 +3310,7 @@ name = "pmoqobuz"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.6",
|
||||
"axum 0.8.7",
|
||||
"chrono",
|
||||
"hex",
|
||||
"mockito",
|
||||
@@ -3152,7 +3339,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
"axum 0.8.6",
|
||||
"axum 0.8.7",
|
||||
"axum-embed",
|
||||
"axum-server",
|
||||
"futures",
|
||||
@@ -3176,7 +3363,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum 0.8.6",
|
||||
"axum 0.8.7",
|
||||
"lazy_static",
|
||||
"pmoaudiocache",
|
||||
"pmoconfig",
|
||||
@@ -3198,7 +3385,7 @@ name = "pmoupnp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.6",
|
||||
"axum 0.8.7",
|
||||
"base64 0.22.1",
|
||||
"bevy_reflect",
|
||||
"bevy_reflect_derive",
|
||||
@@ -3297,7 +3484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3334,7 +3521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3357,7 +3544,7 @@ dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3421,9 +3608,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.3"
|
||||
version = "0.38.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89"
|
||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -3431,9 +3618,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.41"
|
||||
version = "1.0.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
|
||||
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
@@ -3722,7 +3909,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rust-embed-utils",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -3895,7 +4082,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4315,9 +4502,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.108"
|
||||
version = "2.0.110"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917"
|
||||
checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4341,7 +4528,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4444,7 +4631,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4455,7 +4642,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4563,7 +4750,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4803,7 +4990,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4936,7 +5123,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4945,7 +5132,7 @@ version = "9.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55"
|
||||
dependencies = [
|
||||
"axum 0.8.6",
|
||||
"axum 0.8.7",
|
||||
"base64 0.22.1",
|
||||
"mime_guess",
|
||||
"regex",
|
||||
@@ -5001,7 +5188,7 @@ checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5101,7 +5288,7 @@ dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -5149,9 +5336,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.10"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-types"
|
||||
@@ -5252,9 +5439,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link 0.2.1",
|
||||
"windows-link",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5265,7 +5452,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5276,15 +5463,9 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
@@ -5293,13 +5474,13 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
"windows-link",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5311,31 +5492,13 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5344,7 +5507,7 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5389,7 +5552,7 @@ version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5429,7 +5592,7 @@ version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
@@ -5656,7 +5819,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -5677,7 +5840,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5697,7 +5860,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -5737,7 +5900,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
108
pmoaudio-ext/src/sinks/broadcast_pacing.rs
Normal file
108
pmoaudio-ext/src/sinks/broadcast_pacing.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -93,10 +93,16 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
|
||||
loop {
|
||||
// Attendre le premier chunk audio pour cette track
|
||||
tracing::debug!("FlacCacheSink: Waiting for first audio chunk (track_number={})", track_number);
|
||||
let (first_segment, track_metadata) = if let Some(metadata) = next_track_metadata.take() {
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Waiting for first audio chunk (track_number={})",
|
||||
track_number
|
||||
);
|
||||
let (first_segment, track_metadata) = if let Some(metadata) = next_track_metadata.take()
|
||||
{
|
||||
// On a déjà reçu le TrackBoundary en Phase 3 de la track précédente
|
||||
tracing::debug!("FlacCacheSink: Using TrackBoundary metadata from previous track's Phase 3");
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Using TrackBoundary metadata from previous track's Phase 3"
|
||||
);
|
||||
// Attendre juste le premier chunk
|
||||
match wait_for_first_audio_chunk(&mut rx, &stop_token).await {
|
||||
Ok(chunk) => {
|
||||
@@ -296,18 +302,30 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
url
|
||||
}
|
||||
Err(e) if e.is_transient() => {
|
||||
tracing::debug!("FlacCacheSink: Transient error getting cover URL for pk {}: {}", pk, e);
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Transient error getting cover URL for pk {}: {}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("FlacCacheSink: Cannot obtain cover URL for audio asset {}: {}", pk, e);
|
||||
tracing::warn!(
|
||||
"FlacCacheSink: Cannot obtain cover URL for audio asset {}: {}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(cover_url) = url {
|
||||
tracing::debug!("FlacCacheSink: Attempting to cache cover from URL: {}", cover_url);
|
||||
match self.covers
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Attempting to cache cover from URL: {}",
|
||||
cover_url
|
||||
);
|
||||
match self
|
||||
.covers
|
||||
.add_from_url(&cover_url, self.collection.as_deref())
|
||||
.await
|
||||
{
|
||||
@@ -323,7 +341,11 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("FlacCacheSink: Failed to cache cover for audio asset {}: {}", pk, e);
|
||||
tracing::warn!(
|
||||
"FlacCacheSink: Failed to cache cover for audio asset {}: {}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -339,20 +361,27 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
playlist_handle.push(pk.clone()).await.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to add to playlist: {}", e))
|
||||
})?;
|
||||
tracing::info!("FlacCacheSink: Successfully pushed to playlist in {:?}", push_start.elapsed());
|
||||
tracing::info!(
|
||||
"FlacCacheSink: Successfully pushed to playlist in {:?}",
|
||||
push_start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
// Si EndOfStream a été reçu pendant le prebuffer, on a déjà tout traité
|
||||
// Il faut juste attendre que le pump se termine et retourner
|
||||
if end_of_stream_received {
|
||||
tracing::debug!("FlacCacheSink: EndOfStream was received during prebuffer, track complete");
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: EndOfStream was received during prebuffer, track complete"
|
||||
);
|
||||
drop(pump_handle);
|
||||
track_number += 1;
|
||||
continue; // Passer à la track suivante (qui n'arrivera pas car EndOfStream)
|
||||
}
|
||||
|
||||
// Phase 3: Continuer à dispatcher jusqu'au TrackBoundary
|
||||
tracing::debug!("FlacCacheSink: Continuing dispatch until TrackBoundary (pump runs in background)");
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Continuing dispatch until TrackBoundary (pump runs in background)"
|
||||
);
|
||||
let mut track_tx = track_tx_opt; // track_tx_opt contient Some(track_tx) car end_of_stream_received est false
|
||||
let mut pump_handle = Some(pump_handle);
|
||||
let mut pump_closed = false;
|
||||
@@ -384,7 +413,9 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
if tx.send(segment).await.is_err() {
|
||||
// Le pump a fermé son channel - cela peut arriver si le fichier
|
||||
// était déjà en cache (add_from_reader retourne immédiatement)
|
||||
tracing::debug!("FlacCacheSink: pump closed track_tx, checking pump status");
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: pump closed track_tx, checking pump status"
|
||||
);
|
||||
drop(track_tx.take());
|
||||
|
||||
// Attendre que le pump se termine et vérifier le résultat
|
||||
@@ -397,13 +428,21 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
// Le pump a rencontré une erreur
|
||||
tracing::error!("FlacCacheSink: pump died with error: {}", e);
|
||||
tracing::error!(
|
||||
"FlacCacheSink: pump died with error: {}",
|
||||
e
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
Err(e) => {
|
||||
// Le pump task a paniqué
|
||||
tracing::error!("FlacCacheSink: pump task panicked: {}", e);
|
||||
return Err(AudioError::ProcessingError("Pump task panicked".to_string()));
|
||||
tracing::error!(
|
||||
"FlacCacheSink: pump task panicked: {}",
|
||||
e
|
||||
);
|
||||
return Err(AudioError::ProcessingError(
|
||||
"Pump task panicked".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -534,7 +573,9 @@ async fn wait_for_first_audio_chunk(
|
||||
_AudioSegment::Sync(marker) => match &**marker {
|
||||
SyncMarker::TrackBoundary { .. } => {
|
||||
// On ne devrait pas recevoir de TrackBoundary ici car on l'a déjà
|
||||
tracing::warn!("FlacCacheSink: Unexpected TrackBoundary while waiting for first chunk");
|
||||
tracing::warn!(
|
||||
"FlacCacheSink: Unexpected TrackBoundary while waiting for first chunk"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
SyncMarker::EndOfStream => {
|
||||
|
||||
@@ -374,8 +374,8 @@ mod tests {
|
||||
// Real-world example: first frame at 0, false positive at 7
|
||||
let data = vec![
|
||||
0xFF, 0xF8, 0xC9, 0xA8, // Valid frame header at position 0
|
||||
0x00, 0x8D, 0x4C,
|
||||
0xFF, 0xFE, 0x00, 0x00, // False positive at position 7 (0xFE has reserved bit set)
|
||||
0x00, 0x8D, 0x4C, 0xFF, 0xFE, 0x00,
|
||||
0x00, // False positive at position 7 (0xFE has reserved bit set)
|
||||
];
|
||||
|
||||
// Position 0 should be valid
|
||||
|
||||
@@ -10,6 +10,9 @@ mod flac_cache_sink;
|
||||
#[cfg(feature = "cache-sink")]
|
||||
pub use flac_cache_sink::{FlacCacheSink, FlacCacheSinkStats, TrackStats};
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
mod broadcast_pacing;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
mod flac_frame_utils;
|
||||
|
||||
@@ -17,10 +20,12 @@ mod flac_frame_utils;
|
||||
mod streaming_flac_sink;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
pub use streaming_flac_sink::{StreamingFlacSink, StreamHandle, MetadataSnapshot, FlacClientStream, IcyClientStream};
|
||||
pub use streaming_flac_sink::{
|
||||
FlacClientStream, IcyClientStream, MetadataSnapshot, StreamHandle, StreamingFlacSink,
|
||||
};
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
mod streaming_ogg_flac_sink;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
pub use streaming_ogg_flac_sink::{StreamingOggFlacSink, OggFlacStreamHandle, OggFlacClientStream};
|
||||
pub use streaming_ogg_flac_sink::{OggFlacClientStream, OggFlacStreamHandle, StreamingOggFlacSink};
|
||||
|
||||
@@ -62,7 +62,7 @@ use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::flac_frame_utils;
|
||||
use super::{broadcast_pacing::BroadcastPacer, flac_frame_utils};
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use pmoaudio::{
|
||||
@@ -81,16 +81,29 @@ use tracing::{debug, error, info, trace, warn};
|
||||
/// Standard value used by most streaming servers.
|
||||
const DEFAULT_ICY_METAINT: usize = 16000;
|
||||
|
||||
/// Broadcast channel capacity for FLAC bytes.
|
||||
/// Set to 128 to provide ~10 seconds of buffer for network jitter.
|
||||
/// With TimerNode pacing the stream to real-time, this is sufficient
|
||||
/// while keeping metadata synchronized (larger buffers cause metadata drift).
|
||||
const BROADCAST_CAPACITY: usize = 128;
|
||||
|
||||
/// Maximum lead time for HTTP broadcast pacing (in seconds).
|
||||
/// Default maximum lead time for HTTP broadcast pacing (in seconds).
|
||||
/// The broadcaster will sleep if it's ahead of real-time by more than this amount.
|
||||
/// This is much smaller than the pipeline TimerNode's 3.0s to provide tighter control.
|
||||
const BROADCAST_MAX_LEAD_TIME: f64 = 0.5;
|
||||
const DEFAULT_BROADCAST_MAX_LEAD_TIME: f64 = 0.5;
|
||||
|
||||
/// Calculate broadcast channel capacity based on max_lead_time.
|
||||
///
|
||||
/// Estimates the number of items needed to buffer max_lead_time seconds of audio.
|
||||
/// Assumes ~20 items per second (50ms per chunk).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `max_lead_time` - Maximum lead time in seconds
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Broadcast channel capacity (minimum 100 items)
|
||||
fn calculate_broadcast_capacity(max_lead_time: f64) -> usize {
|
||||
// Estimation: ~20 items/second (chunks de 50ms en moyenne)
|
||||
// Pour 10s: 200 items
|
||||
let estimated_items_per_second = 20.0;
|
||||
let capacity = (max_lead_time * estimated_items_per_second) as usize;
|
||||
capacity.max(100) // Minimum 100 items
|
||||
}
|
||||
|
||||
/// PCM chunk with audio data and timestamp for precise pacing.
|
||||
#[derive(Debug)]
|
||||
@@ -190,7 +203,11 @@ impl StreamHandle {
|
||||
/// Subscribe to the FLAC stream with custom ICY metadata interval.
|
||||
pub fn subscribe_icy_with_interval(&self, metaint: usize) -> IcyClientStream {
|
||||
let count = self.active_clients.fetch_add(1, Ordering::SeqCst);
|
||||
debug!("New ICY client subscribed (total: {}, metaint: {})", count + 1, metaint);
|
||||
debug!(
|
||||
"New ICY client subscribed (total: {}, metaint: {})",
|
||||
count + 1,
|
||||
metaint
|
||||
);
|
||||
|
||||
IcyClientStream {
|
||||
rx: self.flac_broadcast.subscribe(),
|
||||
@@ -254,7 +271,10 @@ impl AsyncRead for FlacClientStream {
|
||||
|
||||
if let Some(header) = header_opt {
|
||||
self.buffer.extend(header.iter());
|
||||
info!("Sending cached FLAC header to new client ({} bytes)", header.len());
|
||||
info!(
|
||||
"Sending cached FLAC header to new client ({} bytes)",
|
||||
header.len()
|
||||
);
|
||||
self.state = FlacStreamState::Streaming;
|
||||
continue; // Now copy header to output buffer
|
||||
} else {
|
||||
@@ -413,7 +433,10 @@ impl AsyncRead for IcyClientStream {
|
||||
|
||||
if let Some(header) = header_opt {
|
||||
self.buffer.extend(header.iter());
|
||||
info!("Sending cached FLAC header to new ICY client ({} bytes)", header.len());
|
||||
info!(
|
||||
"Sending cached FLAC header to new ICY client ({} bytes)",
|
||||
header.len()
|
||||
);
|
||||
self.state = FlacStreamState::Streaming;
|
||||
continue; // Now copy header to output buffer
|
||||
} else {
|
||||
@@ -533,6 +556,7 @@ struct StreamingFlacSinkLogic {
|
||||
flac_header: Arc<RwLock<Option<Bytes>>>,
|
||||
encoder_state: Option<EncoderState>,
|
||||
sample_rate: Option<u32>,
|
||||
broadcast_max_lead_time: f64,
|
||||
}
|
||||
|
||||
impl StreamingFlacSinkLogic {
|
||||
@@ -542,12 +566,16 @@ impl StreamingFlacSinkLogic {
|
||||
return Ok(()); // Already initialized
|
||||
}
|
||||
|
||||
info!("Initializing FLAC encoder with sample rate: {} Hz", sample_rate);
|
||||
info!(
|
||||
"Initializing FLAC encoder with sample rate: {} Hz",
|
||||
sample_rate
|
||||
);
|
||||
|
||||
// Take the PCM receiver (we only initialize once)
|
||||
let pcm_rx = self.pcm_rx.take().ok_or_else(|| {
|
||||
AudioError::ProcessingError("PCM receiver already consumed".into())
|
||||
})?;
|
||||
let pcm_rx = self
|
||||
.pcm_rx
|
||||
.take()
|
||||
.ok_or_else(|| AudioError::ProcessingError("PCM receiver already consumed".into()))?;
|
||||
|
||||
// Create shared timestamp for pacing
|
||||
let current_timestamp = Arc::new(RwLock::new(0.0f64));
|
||||
@@ -565,15 +593,26 @@ impl StreamingFlacSinkLogic {
|
||||
// Start the FLAC encoder
|
||||
let flac_stream = encode_flac_stream(pcm_reader, pcm_format, self.encoder_options.clone())
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e))
|
||||
})?;
|
||||
|
||||
info!("FLAC encoder initialized successfully");
|
||||
|
||||
// Spawn broadcaster task with timestamp for pacing
|
||||
let flac_broadcast = self.flac_broadcast.clone();
|
||||
let flac_header = self.flac_header.clone();
|
||||
let max_lead = self.broadcast_max_lead_time;
|
||||
let broadcaster_task = tokio::spawn(async move {
|
||||
if let Err(e) = broadcast_flac_stream(flac_stream, flac_broadcast, flac_header, current_timestamp).await {
|
||||
if let Err(e) = broadcast_flac_stream(
|
||||
flac_stream,
|
||||
flac_broadcast,
|
||||
flac_header,
|
||||
current_timestamp,
|
||||
max_lead,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Broadcaster task error: {}", e);
|
||||
}
|
||||
});
|
||||
@@ -682,10 +721,19 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
||||
bytes: pcm_bytes,
|
||||
timestamp_sec: seg.timestamp_sec,
|
||||
};
|
||||
let send_start = std::time::Instant::now();
|
||||
if let Err(e) = self.pcm_tx.send(pcm_chunk).await {
|
||||
warn!("Failed to send PCM data to encoder: {}", e);
|
||||
break;
|
||||
}
|
||||
let send_duration = send_start.elapsed();
|
||||
if send_duration.as_millis() >= 50 {
|
||||
debug!(
|
||||
"StreamingFlacSink: pcm_tx send blocked for {:.3}s (ts={:.3}s)",
|
||||
send_duration.as_secs_f64(),
|
||||
seg.timestamp_sec
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_AudioSegment::Sync(marker) => {
|
||||
@@ -728,7 +776,6 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Broadcaster task: reads FLAC bytes from encoder and broadcasts to all clients.
|
||||
/// Implements precise real-time pacing based on audio timestamps.
|
||||
/// Ensures data is sent at FLAC frame boundaries to prevent sync errors in strict decoders like FFPlay.
|
||||
@@ -737,8 +784,12 @@ async fn broadcast_flac_stream(
|
||||
broadcast_tx: broadcast::Sender<Bytes>,
|
||||
header_cache: Arc<RwLock<Option<Bytes>>>,
|
||||
current_timestamp: Arc<RwLock<f64>>,
|
||||
broadcast_max_lead_time: f64,
|
||||
) -> Result<(), AudioError> {
|
||||
info!("Broadcaster task started with FLAC frame boundary detection");
|
||||
info!(
|
||||
"Broadcaster task started with FLAC frame boundary detection (max_lead={:.3}s)",
|
||||
broadcast_max_lead_time
|
||||
);
|
||||
|
||||
// Use larger read buffer (16KB) to reduce syscalls and accumulator for frame boundary detection
|
||||
// The accumulator is necessary to ensure we only send complete FLAC frames
|
||||
@@ -746,9 +797,17 @@ async fn broadcast_flac_stream(
|
||||
let mut accumulator = Vec::with_capacity(32768); // Pre-allocate to reduce reallocations
|
||||
let mut total_bytes = 0u64;
|
||||
let mut header_captured = false;
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut pacer = BroadcastPacer::new(broadcast_max_lead_time, "FLAC");
|
||||
let mut stats_last_log = std::time::Instant::now();
|
||||
|
||||
// Timing instrumentation for burst detection
|
||||
let mut last_broadcast_time = std::time::Instant::now();
|
||||
let mut broadcast_count = 0u64;
|
||||
let mut total_read_time = 0.0f64;
|
||||
let mut read_count = 0u64;
|
||||
|
||||
loop {
|
||||
let read_start = std::time::Instant::now();
|
||||
match flac_stream.read(&mut read_buffer).await {
|
||||
Ok(0) => {
|
||||
// EOF - send any remaining data
|
||||
@@ -760,14 +819,38 @@ async fn broadcast_flac_stream(
|
||||
break;
|
||||
}
|
||||
Ok(n) => {
|
||||
let read_duration = read_start.elapsed().as_secs_f64();
|
||||
read_count += 1;
|
||||
total_read_time += read_duration;
|
||||
|
||||
if read_duration > 0.01 {
|
||||
debug!(
|
||||
"FLAC: flac_stream.read() took {:.3}s for {} bytes (avg: {:.3}s over {} reads)",
|
||||
read_duration,
|
||||
n,
|
||||
total_read_time / read_count as f64,
|
||||
read_count
|
||||
);
|
||||
}
|
||||
|
||||
total_bytes += n as u64;
|
||||
if total_bytes % 100000 == 0 || total_bytes < 10000 {
|
||||
trace!("Read {} bytes from FLAC encoder (total: {})", n, total_bytes);
|
||||
trace!(
|
||||
"Read {} bytes from FLAC encoder (total: {})",
|
||||
n,
|
||||
total_bytes
|
||||
);
|
||||
}
|
||||
|
||||
// Append to accumulator
|
||||
accumulator.extend_from_slice(&read_buffer[..n]);
|
||||
|
||||
trace!(
|
||||
"FLAC: accumulator now {} bytes after reading {} bytes",
|
||||
accumulator.len(),
|
||||
n
|
||||
);
|
||||
|
||||
// Find where to split: position of last sync code (start of last incomplete frame)
|
||||
// Everything before this position contains only complete frames
|
||||
let boundary = flac_frame_utils::find_complete_frames_boundary(&accumulator);
|
||||
@@ -781,18 +864,33 @@ async fn broadcast_flac_stream(
|
||||
|
||||
// Only broadcast if we have at least one complete frame (1KB minimum to avoid excessive small sends)
|
||||
if boundary >= 1024 {
|
||||
// Precise pacing based on audio timestamp
|
||||
// ╔═══════════════════════════════════════════════════════════════╗
|
||||
// ║ BACKPRESSURE INTELLIGENTE BASÉE SUR LE TIMING ║
|
||||
// ║ ║
|
||||
// ║ BroadcastPacer gère : ║
|
||||
// ║ 1. Détection TopZeroSync (audio_ts < 0.1) ║
|
||||
// ║ 2. Drop des chunks en retard (audio_ts < elapsed) ║
|
||||
// ║ 3. Pacing pour contrôler le débit (max_lead_time) ║
|
||||
// ║ ║
|
||||
// ║ Cela crée la backpressure vers TimerBufferNode tout en ║
|
||||
// ║ permettant de dropper les chunks vraiment périmés. ║
|
||||
// ╚═══════════════════════════════════════════════════════════════╝
|
||||
let audio_timestamp = *current_timestamp.read().await;
|
||||
let elapsed = start_time.elapsed().as_secs_f64();
|
||||
let lead_time = audio_timestamp - elapsed;
|
||||
|
||||
if lead_time > BROADCAST_MAX_LEAD_TIME {
|
||||
let sleep_duration = lead_time - BROADCAST_MAX_LEAD_TIME;
|
||||
if stats_last_log.elapsed() >= Duration::from_secs(1) {
|
||||
debug!(
|
||||
"Broadcaster pacing: sleeping {:.3}s (audio_ts={:.3}s, elapsed={:.3}s, lead={:.3}s)",
|
||||
sleep_duration, audio_timestamp, elapsed, lead_time
|
||||
"Broadcaster pacing snapshot: audio_ts={:.3}s buffer_bytes={}",
|
||||
audio_timestamp,
|
||||
accumulator.len()
|
||||
);
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs_f64(sleep_duration)).await;
|
||||
stats_last_log = std::time::Instant::now();
|
||||
}
|
||||
|
||||
// Check timing et apply pacing (skip si en retard)
|
||||
if pacer.check_and_pace(audio_timestamp).await.is_err() {
|
||||
// Chunk en retard : vider l'accumulator et continuer
|
||||
accumulator.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split at boundary to avoid copying - extract prefix, keep suffix
|
||||
@@ -800,6 +898,31 @@ async fn broadcast_flac_stream(
|
||||
let to_send = std::mem::replace(&mut accumulator, remaining);
|
||||
let bytes = Bytes::from(to_send);
|
||||
|
||||
// Measure broadcast interval for burst detection
|
||||
let broadcast_interval = last_broadcast_time.elapsed().as_secs_f64();
|
||||
last_broadcast_time = std::time::Instant::now();
|
||||
broadcast_count += 1;
|
||||
|
||||
// Log if interval is unusual (too short = burst, too long = stall)
|
||||
if broadcast_interval < 0.01 || broadcast_interval > 0.1 {
|
||||
debug!(
|
||||
"FLAC: broadcast interval {:.3}s ({}ms) - size={} bytes (count={})",
|
||||
broadcast_interval,
|
||||
(broadcast_interval * 1000.0) as u32,
|
||||
bytes.len(),
|
||||
broadcast_count
|
||||
);
|
||||
}
|
||||
|
||||
// Periodic stats
|
||||
if broadcast_count % 100 == 0 {
|
||||
debug!(
|
||||
"FLAC: {} broadcasts sent, accumulator={} bytes remaining",
|
||||
broadcast_count,
|
||||
accumulator.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Capture first chunk as header if it contains "fLaC"
|
||||
if !header_captured && bytes.len() >= 4 && &bytes[0..4] == b"fLaC" {
|
||||
*header_cache.write().await = Some(bytes.clone());
|
||||
@@ -812,7 +935,11 @@ async fn broadcast_flac_stream(
|
||||
// No receivers, but that's okay - clients may not be connected yet
|
||||
trace!("No active receivers for FLAC broadcast: {}", e);
|
||||
} else if num_receivers > 0 {
|
||||
trace!("Broadcasted {} bytes to {} receivers", bytes.len(), num_receivers);
|
||||
trace!(
|
||||
"Broadcasted {} bytes to {} receivers",
|
||||
bytes.len(),
|
||||
num_receivers
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -857,9 +984,19 @@ impl StreamingFlacSink {
|
||||
/// A tuple of `(sink, handle)` where:
|
||||
/// - `sink` is added to the audio pipeline
|
||||
/// - `handle` is used by HTTP handlers to serve streams
|
||||
pub fn new(
|
||||
pub fn new(encoder_options: EncoderOptions, bits_per_sample: u8) -> (Self, StreamHandle) {
|
||||
Self::with_max_broadcast_lead(
|
||||
encoder_options,
|
||||
bits_per_sample,
|
||||
DEFAULT_BROADCAST_MAX_LEAD_TIME,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a sink with a custom broadcast pacing limit.
|
||||
pub fn with_max_broadcast_lead(
|
||||
encoder_options: EncoderOptions,
|
||||
bits_per_sample: u8,
|
||||
broadcast_max_lead_time: f64,
|
||||
) -> (Self, StreamHandle) {
|
||||
// Validate bit depth
|
||||
if ![16, 24, 32].contains(&bits_per_sample) {
|
||||
@@ -872,8 +1009,15 @@ impl StreamingFlacSink {
|
||||
// Shared metadata
|
||||
let metadata = Arc::new(RwLock::new(MetadataSnapshot::default()));
|
||||
|
||||
// Calculate broadcast capacity based on max_lead_time
|
||||
let broadcast_capacity = calculate_broadcast_capacity(broadcast_max_lead_time);
|
||||
info!(
|
||||
"StreamingFlacSink: using broadcast capacity of {} items (max_lead_time={:.1}s)",
|
||||
broadcast_capacity, broadcast_max_lead_time
|
||||
);
|
||||
|
||||
// Broadcast channel for FLAC bytes
|
||||
let (flac_broadcast, _) = broadcast::channel(BROADCAST_CAPACITY);
|
||||
let (flac_broadcast, _) = broadcast::channel(broadcast_capacity);
|
||||
|
||||
// FLAC header cache
|
||||
let flac_header = Arc::new(RwLock::new(None));
|
||||
@@ -900,6 +1044,7 @@ impl StreamingFlacSink {
|
||||
flac_header,
|
||||
encoder_state: None,
|
||||
sample_rate: None,
|
||||
broadcast_max_lead_time: broadcast_max_lead_time.max(0.0),
|
||||
};
|
||||
|
||||
let sink = Self {
|
||||
|
||||
@@ -53,7 +53,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use super::flac_frame_utils;
|
||||
use super::{broadcast_pacing::BroadcastPacer, flac_frame_utils};
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use pmoaudio::{
|
||||
@@ -68,13 +68,18 @@ use tokio::sync::{broadcast, mpsc, RwLock};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, error, info, trace, warn};
|
||||
|
||||
/// Broadcast channel capacity for OGG-FLAC bytes.
|
||||
/// Same as StreamingFlacSink for consistency.
|
||||
const BROADCAST_CAPACITY: usize = 128;
|
||||
/// Default maximum lead time for HTTP broadcast pacing (in seconds).
|
||||
const DEFAULT_BROADCAST_MAX_LEAD_TIME: f64 = 0.0;
|
||||
|
||||
/// Maximum lead time for HTTP broadcast pacing (in seconds).
|
||||
/// The broadcaster will sleep if it's ahead of real-time by more than this amount.
|
||||
const BROADCAST_MAX_LEAD_TIME: f64 = 0.5;
|
||||
/// Calculate broadcast channel capacity based on max lead time.
|
||||
///
|
||||
/// Estimate: ~20 OGG pages per second (assuming 50ms chunks).
|
||||
/// Minimum capacity: 100 items for buffering even with 0 lead time.
|
||||
fn calculate_broadcast_capacity(max_lead_time: f64) -> usize {
|
||||
let estimated_items_per_second = 20.0;
|
||||
let capacity = (max_lead_time * estimated_items_per_second) as usize;
|
||||
capacity.max(100) // Minimum 100 items
|
||||
}
|
||||
|
||||
/// PCM chunk with audio data and timestamp for precise pacing.
|
||||
#[derive(Debug)]
|
||||
@@ -167,7 +172,10 @@ impl AsyncRead for OggFlacClientStream {
|
||||
|
||||
if let Some(header) = header_opt {
|
||||
self.buffer.extend(header.iter());
|
||||
info!("Sending cached OGG-FLAC header to new client ({} bytes)", header.len());
|
||||
info!(
|
||||
"Sending cached OGG-FLAC header to new client ({} bytes)",
|
||||
header.len()
|
||||
);
|
||||
self.state = OggFlacStreamState::Streaming;
|
||||
continue; // Now copy header to output buffer
|
||||
} else {
|
||||
@@ -203,7 +211,7 @@ impl AsyncRead for OggFlacClientStream {
|
||||
// Schedule a wakeup after a small delay to avoid busy-loop polling.
|
||||
let waker = cx.waker().clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||
tokio::time::sleep(tokio::time::Duration::from_micros(100)).await;
|
||||
waker.wake();
|
||||
});
|
||||
return Poll::Pending;
|
||||
@@ -248,6 +256,7 @@ struct StreamingOggFlacSinkLogic {
|
||||
ogg_header: Arc<RwLock<Option<Bytes>>>,
|
||||
encoder_state: Option<EncoderState>,
|
||||
sample_rate: Option<u32>,
|
||||
broadcast_max_lead_time: f64,
|
||||
}
|
||||
|
||||
impl StreamingOggFlacSinkLogic {
|
||||
@@ -257,12 +266,16 @@ impl StreamingOggFlacSinkLogic {
|
||||
return Ok(()); // Already initialized
|
||||
}
|
||||
|
||||
info!("Initializing OGG-FLAC encoder with sample rate: {} Hz", sample_rate);
|
||||
info!(
|
||||
"Initializing OGG-FLAC encoder with sample rate: {} Hz",
|
||||
sample_rate
|
||||
);
|
||||
|
||||
// Take the PCM receiver (we only initialize once)
|
||||
let pcm_rx = self.pcm_rx.take().ok_or_else(|| {
|
||||
AudioError::ProcessingError("PCM receiver already consumed".into())
|
||||
})?;
|
||||
let pcm_rx = self
|
||||
.pcm_rx
|
||||
.take()
|
||||
.ok_or_else(|| AudioError::ProcessingError("PCM receiver already consumed".into()))?;
|
||||
|
||||
// Create shared timestamp for pacing
|
||||
let current_timestamp = Arc::new(RwLock::new(0.0f64));
|
||||
@@ -280,15 +293,26 @@ impl StreamingOggFlacSinkLogic {
|
||||
// Start the FLAC encoder
|
||||
let flac_stream = encode_flac_stream(pcm_reader, pcm_format, self.encoder_options.clone())
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e))
|
||||
})?;
|
||||
|
||||
info!("OGG-FLAC encoder initialized successfully");
|
||||
|
||||
// Spawn OGG wrapper + broadcaster task with timestamp for pacing
|
||||
let ogg_broadcast = self.ogg_broadcast.clone();
|
||||
let ogg_header = self.ogg_header.clone();
|
||||
let max_lead = self.broadcast_max_lead_time;
|
||||
let broadcaster_task = tokio::spawn(async move {
|
||||
if let Err(e) = broadcast_ogg_flac_stream(flac_stream, ogg_broadcast, ogg_header, current_timestamp).await {
|
||||
if let Err(e) = broadcast_ogg_flac_stream(
|
||||
flac_stream,
|
||||
ogg_broadcast,
|
||||
ogg_header,
|
||||
current_timestamp,
|
||||
max_lead,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("OGG broadcaster task error: {}", e);
|
||||
}
|
||||
});
|
||||
@@ -464,6 +488,19 @@ impl StreamingOggFlacSink {
|
||||
pub fn new(
|
||||
encoder_options: EncoderOptions,
|
||||
bits_per_sample: u8,
|
||||
) -> (Self, OggFlacStreamHandle) {
|
||||
Self::with_max_broadcast_lead(
|
||||
encoder_options,
|
||||
bits_per_sample,
|
||||
DEFAULT_BROADCAST_MAX_LEAD_TIME,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a sink with a custom broadcast pacing limit.
|
||||
pub fn with_max_broadcast_lead(
|
||||
encoder_options: EncoderOptions,
|
||||
bits_per_sample: u8,
|
||||
broadcast_max_lead_time: f64,
|
||||
) -> (Self, OggFlacStreamHandle) {
|
||||
// Validate bit depth
|
||||
if ![16, 24, 32].contains(&bits_per_sample) {
|
||||
@@ -477,7 +514,14 @@ impl StreamingOggFlacSink {
|
||||
let metadata = Arc::new(RwLock::new(MetadataSnapshot::default()));
|
||||
|
||||
// Broadcast channel for OGG-FLAC bytes
|
||||
let (ogg_broadcast, _) = broadcast::channel(BROADCAST_CAPACITY);
|
||||
// Capacity calculated from max_lead_time to ensure enough buffering
|
||||
let broadcast_capacity = calculate_broadcast_capacity(broadcast_max_lead_time);
|
||||
tracing::debug!(
|
||||
"OGG-FLAC broadcast capacity: {} items (for {:.1}s max lead time)",
|
||||
broadcast_capacity,
|
||||
broadcast_max_lead_time
|
||||
);
|
||||
let (ogg_broadcast, _) = broadcast::channel(broadcast_capacity);
|
||||
|
||||
// OGG-FLAC header cache
|
||||
let ogg_header = Arc::new(RwLock::new(None));
|
||||
@@ -504,6 +548,7 @@ impl StreamingOggFlacSink {
|
||||
ogg_header,
|
||||
encoder_state: None,
|
||||
sample_rate: None,
|
||||
broadcast_max_lead_time: broadcast_max_lead_time.max(0.0),
|
||||
};
|
||||
|
||||
let sink = Self {
|
||||
@@ -709,17 +754,27 @@ async fn broadcast_ogg_flac_stream(
|
||||
broadcast_tx: broadcast::Sender<Bytes>,
|
||||
header_cache: Arc<RwLock<Option<Bytes>>>,
|
||||
current_timestamp: Arc<RwLock<f64>>,
|
||||
broadcast_max_lead_time: f64,
|
||||
) -> Result<(), AudioError> {
|
||||
info!("OGG-FLAC broadcaster task started with FLAC frame boundary detection");
|
||||
info!(
|
||||
"OGG-FLAC broadcaster task started with FLAC frame boundary detection (max_lead={:.3}s)",
|
||||
broadcast_max_lead_time
|
||||
);
|
||||
|
||||
let stream_serial = rand::random::<u32>();
|
||||
let mut ogg_writer = OggPageWriter::new(stream_serial);
|
||||
|
||||
let mut total_ogg_bytes = 0u64;
|
||||
let mut header_captured = false;
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut pacer = BroadcastPacer::new(broadcast_max_lead_time, "OGG");
|
||||
let mut last_granule_update_time = 0.0f64;
|
||||
|
||||
// Timing instrumentation for burst detection
|
||||
let mut last_broadcast_time = std::time::Instant::now();
|
||||
let mut broadcast_count = 0u64;
|
||||
let mut total_read_time = 0.0f64;
|
||||
let mut read_count = 0u64;
|
||||
|
||||
// Step 1: Read FLAC header (fLaC + metadata blocks)
|
||||
let flac_header = read_flac_header(&mut flac_stream).await?;
|
||||
info!("Read FLAC header: {} bytes", flac_header.len());
|
||||
@@ -731,7 +786,10 @@ async fn broadcast_ogg_flac_stream(
|
||||
// Step 2: Create OGG-FLAC identification packet (BOS)
|
||||
// Format according to https://xiph.org/flac/ogg_mapping.html
|
||||
let ogg_flac_id = create_ogg_flac_identification(&flac_header)?;
|
||||
info!("Created OGG-FLAC identification packet: {} bytes", ogg_flac_id.len());
|
||||
info!(
|
||||
"Created OGG-FLAC identification packet: {} bytes",
|
||||
ogg_flac_id.len()
|
||||
);
|
||||
|
||||
let bos_page = ogg_writer.create_page(&ogg_flac_id, true, false, false);
|
||||
let bos_bytes = Bytes::from(bos_page);
|
||||
@@ -747,7 +805,10 @@ async fn broadcast_ogg_flac_stream(
|
||||
cached_header.extend_from_slice(&comment_bytes);
|
||||
*header_cache.write().await = Some(Bytes::from(cached_header));
|
||||
header_captured = true;
|
||||
info!("OGG-FLAC header cached ({} bytes: BOS + Vorbis Comment)", bos_bytes.len() + comment_bytes.len());
|
||||
info!(
|
||||
"OGG-FLAC header cached ({} bytes: BOS + Vorbis Comment)",
|
||||
bos_bytes.len() + comment_bytes.len()
|
||||
);
|
||||
|
||||
// Broadcast header
|
||||
let _ = broadcast_tx.send(bos_bytes);
|
||||
@@ -761,6 +822,7 @@ async fn broadcast_ogg_flac_stream(
|
||||
let mut flac_accumulator = Vec::with_capacity(32768);
|
||||
|
||||
loop {
|
||||
let read_start = std::time::Instant::now();
|
||||
match flac_stream.read(&mut read_buffer).await {
|
||||
Ok(0) => {
|
||||
// EOF - create final page with EOS flag and any remaining data
|
||||
@@ -769,7 +831,10 @@ async fn broadcast_ogg_flac_stream(
|
||||
let eos_bytes = Bytes::from(eos_page);
|
||||
total_ogg_bytes += eos_bytes.len() as u64;
|
||||
let _ = broadcast_tx.send(eos_bytes);
|
||||
info!("Sent final EOS page with {} bytes of data", flac_accumulator.len());
|
||||
info!(
|
||||
"Sent final EOS page with {} bytes of data",
|
||||
flac_accumulator.len()
|
||||
);
|
||||
} else {
|
||||
// Send empty EOS page
|
||||
let eos_page = ogg_writer.create_page(&[], false, true, false);
|
||||
@@ -779,13 +844,36 @@ async fn broadcast_ogg_flac_stream(
|
||||
info!("Sent empty EOS page");
|
||||
}
|
||||
|
||||
info!("OGG-FLAC stream ended, total OGG bytes: {}", total_ogg_bytes);
|
||||
info!(
|
||||
"OGG-FLAC stream ended, total OGG bytes: {}",
|
||||
total_ogg_bytes
|
||||
);
|
||||
break;
|
||||
}
|
||||
Ok(n) => {
|
||||
let read_duration = read_start.elapsed().as_secs_f64();
|
||||
read_count += 1;
|
||||
total_read_time += read_duration;
|
||||
|
||||
if read_duration > 0.01 {
|
||||
debug!(
|
||||
"OGG: flac_stream.read() took {:.3}s for {} bytes (avg: {:.3}s over {} reads)",
|
||||
read_duration,
|
||||
n,
|
||||
total_read_time / read_count as f64,
|
||||
read_count
|
||||
);
|
||||
}
|
||||
|
||||
// Append to accumulator
|
||||
flac_accumulator.extend_from_slice(&read_buffer[..n]);
|
||||
|
||||
trace!(
|
||||
"OGG: accumulator now {} bytes after reading {} bytes",
|
||||
flac_accumulator.len(),
|
||||
n
|
||||
);
|
||||
|
||||
// Process complete FLAC frames one at a time
|
||||
// OGG-FLAC spec requires: "Each audio data packet contains one complete FLAC frame"
|
||||
loop {
|
||||
@@ -804,7 +892,9 @@ async fn broadcast_ogg_flac_stream(
|
||||
if byte1 == 0xFF && byte2 >= 0xF8 && byte2 <= 0xFE {
|
||||
// Validate frame header with CRC-8 to avoid false positives
|
||||
if flac_frame_utils::validate_frame_header_crc(&flac_accumulator, i) {
|
||||
if let Some(samples) = flac_frame_utils::parse_flac_block_size(&flac_accumulator, i) {
|
||||
if let Some(samples) =
|
||||
flac_frame_utils::parse_flac_block_size(&flac_accumulator, i)
|
||||
{
|
||||
sync_data.push((i, samples));
|
||||
}
|
||||
}
|
||||
@@ -823,26 +913,34 @@ async fn broadcast_ogg_flac_stream(
|
||||
|
||||
// Verify first frame starts at position 0 (otherwise we have garbage data)
|
||||
if first_frame_start != 0 {
|
||||
warn!("OGG-FLAC: Skipping {} bytes of garbage data before first frame", first_frame_start);
|
||||
warn!(
|
||||
"OGG-FLAC: Skipping {} bytes of garbage data before first frame",
|
||||
first_frame_start
|
||||
);
|
||||
flac_accumulator.drain(0..first_frame_start);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract just the first frame
|
||||
let first_frame: Vec<u8> = flac_accumulator.drain(0..second_frame_start).collect();
|
||||
let first_frame: Vec<u8> =
|
||||
flac_accumulator.drain(0..second_frame_start).collect();
|
||||
|
||||
// Precise pacing based on audio timestamp
|
||||
// ╔═══════════════════════════════════════════════════════════════╗
|
||||
// ║ BACKPRESSURE INTELLIGENTE BASÉE SUR LE TIMING ║
|
||||
// ║ ║
|
||||
// ║ BroadcastPacer gère : ║
|
||||
// ║ 1. Détection TopZeroSync (audio_ts < 0.1) ║
|
||||
// ║ 2. Drop des chunks en retard (audio_ts < elapsed) ║
|
||||
// ║ 3. Pacing pour contrôler le débit (max_lead_time) ║
|
||||
// ║ ║
|
||||
// ║ Cela crée la backpressure vers TimerBufferNode tout en ║
|
||||
// ║ permettant de dropper les chunks vraiment périmés. ║
|
||||
// ╚═══════════════════════════════════════════════════════════════╝
|
||||
let audio_timestamp = *current_timestamp.read().await;
|
||||
let elapsed = start_time.elapsed().as_secs_f64();
|
||||
let lead_time = audio_timestamp - elapsed;
|
||||
|
||||
if lead_time > BROADCAST_MAX_LEAD_TIME {
|
||||
let sleep_duration = lead_time - BROADCAST_MAX_LEAD_TIME;
|
||||
debug!(
|
||||
"OGG broadcaster pacing: sleeping {:.3}s (audio_ts={:.3}s, elapsed={:.3}s, lead={:.3}s)",
|
||||
sleep_duration, audio_timestamp, elapsed, lead_time
|
||||
);
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs_f64(sleep_duration)).await;
|
||||
// Check timing et apply pacing (skip si en retard)
|
||||
if pacer.check_and_pace(audio_timestamp).await.is_err() {
|
||||
continue; // Skip ce chunk (trop en retard)
|
||||
}
|
||||
|
||||
// Update granule position (cumulative sample count)
|
||||
@@ -853,10 +951,41 @@ async fn broadcast_ogg_flac_stream(
|
||||
let ogg_bytes = Bytes::from(ogg_page);
|
||||
total_ogg_bytes += ogg_bytes.len() as u64;
|
||||
|
||||
if let Err(e) = broadcast_tx.send(ogg_bytes.clone()) {
|
||||
trace!("No active receivers for OGG-FLAC broadcast: {}", e);
|
||||
} else {
|
||||
trace!("Broadcasted OGG page with 1 FLAC frame ({} bytes), {} samples ({} bytes total with OGG overhead)", first_frame.len(), first_frame_samples, ogg_bytes.len());
|
||||
// Measure broadcast interval for burst detection
|
||||
let broadcast_interval = last_broadcast_time.elapsed().as_secs_f64();
|
||||
last_broadcast_time = std::time::Instant::now();
|
||||
broadcast_count += 1;
|
||||
|
||||
// Log if interval is unusual (too short = burst, too long = stall)
|
||||
if broadcast_interval < 0.01 || broadcast_interval > 0.1 {
|
||||
debug!(
|
||||
"OGG: broadcast interval {:.3}s ({}ms) - frame_size={} bytes, samples={} (count={})",
|
||||
broadcast_interval,
|
||||
(broadcast_interval * 1000.0) as u32,
|
||||
first_frame.len(),
|
||||
first_frame_samples,
|
||||
broadcast_count
|
||||
);
|
||||
}
|
||||
|
||||
// Periodic stats
|
||||
if broadcast_count % 100 == 0 {
|
||||
debug!(
|
||||
"OGG: {} broadcasts sent, avg_interval={:.3}s, accumulator={} bytes",
|
||||
broadcast_count,
|
||||
last_broadcast_time.elapsed().as_secs_f64() / broadcast_count as f64,
|
||||
flac_accumulator.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Envoyer au broadcast
|
||||
match broadcast_tx.send(ogg_bytes.clone()) {
|
||||
Ok(n) => {
|
||||
trace!("Broadcasted OGG page with 1 FLAC frame ({} bytes), {} samples ({} bytes total with OGG overhead) to {} receivers", first_frame.len(), first_frame_samples, ogg_bytes.len(), n);
|
||||
}
|
||||
Err(e) => {
|
||||
trace!("No active receivers for OGG-FLAC broadcast: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -897,13 +1026,17 @@ fn extract_sample_rate_from_streaminfo(flac_header: &[u8]) -> Result<u32, AudioE
|
||||
// First metadata block should be STREAMINFO (type 0)
|
||||
let block_type = flac_header[4] & 0x7F;
|
||||
if block_type != 0 {
|
||||
return Err(AudioError::ProcessingError("First block is not STREAMINFO".into()));
|
||||
return Err(AudioError::ProcessingError(
|
||||
"First block is not STREAMINFO".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// STREAMINFO data starts at offset 8 (after magic + block header)
|
||||
// Sample rate is at offset 10-12 of STREAMINFO data (bytes 18-20 of header)
|
||||
if flac_header.len() < 21 {
|
||||
return Err(AudioError::ProcessingError("STREAMINFO block truncated".into()));
|
||||
return Err(AudioError::ProcessingError(
|
||||
"STREAMINFO block truncated".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Sample rate: 20 bits starting at byte 10 of STREAMINFO
|
||||
@@ -917,7 +1050,9 @@ fn extract_sample_rate_from_streaminfo(flac_header: &[u8]) -> Result<u32, AudioE
|
||||
let sample_rate = (byte10 << 12) | (byte11 << 4) | (byte12 >> 4);
|
||||
|
||||
if sample_rate == 0 {
|
||||
return Err(AudioError::ProcessingError("Invalid sample rate (0)".into()));
|
||||
return Err(AudioError::ProcessingError(
|
||||
"Invalid sample rate (0)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(sample_rate)
|
||||
@@ -929,12 +1064,15 @@ async fn read_flac_header(stream: &mut FlacEncodedStream) -> Result<Vec<u8>, Aud
|
||||
let mut buffer = [0u8; 4];
|
||||
|
||||
// Read "fLaC" magic
|
||||
stream.read_exact(&mut buffer).await.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to read FLAC magic: {}", e))
|
||||
})?;
|
||||
stream
|
||||
.read_exact(&mut buffer)
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Failed to read FLAC magic: {}", e)))?;
|
||||
|
||||
if &buffer != b"fLaC" {
|
||||
return Err(AudioError::ProcessingError("Invalid FLAC stream: missing fLaC magic".into()));
|
||||
return Err(AudioError::ProcessingError(
|
||||
"Invalid FLAC stream: missing fLaC magic".into(),
|
||||
));
|
||||
}
|
||||
|
||||
header.extend_from_slice(&buffer);
|
||||
@@ -948,7 +1086,8 @@ async fn read_flac_header(stream: &mut FlacEncodedStream) -> Result<Vec<u8>, Aud
|
||||
})?;
|
||||
|
||||
let is_last = (block_header[0] & 0x80) != 0;
|
||||
let block_length = u32::from_be_bytes([0, block_header[1], block_header[2], block_header[3]]) as usize;
|
||||
let block_length =
|
||||
u32::from_be_bytes([0, block_header[1], block_header[2], block_header[3]]) as usize;
|
||||
|
||||
header.extend_from_slice(&block_header);
|
||||
|
||||
@@ -984,11 +1123,14 @@ fn create_ogg_flac_identification(flac_header: &[u8]) -> Result<Vec<u8>, AudioEr
|
||||
|
||||
let first_block_type = flac_header[4] & 0x7F; // Remove last-metadata-block flag
|
||||
if first_block_type != 0 {
|
||||
return Err(AudioError::ProcessingError("First FLAC metadata block is not STREAMINFO".into()));
|
||||
return Err(AudioError::ProcessingError(
|
||||
"First FLAC metadata block is not STREAMINFO".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Extract block length (3 bytes big-endian after type byte)
|
||||
let block_length = u32::from_be_bytes([0, flac_header[5], flac_header[6], flac_header[7]]) as usize;
|
||||
let block_length =
|
||||
u32::from_be_bytes([0, flac_header[5], flac_header[6], flac_header[7]]) as usize;
|
||||
|
||||
info!("STREAMINFO block_length = {} bytes", block_length);
|
||||
|
||||
@@ -1007,18 +1149,21 @@ fn create_ogg_flac_identification(flac_header: &[u8]) -> Result<Vec<u8>, AudioEr
|
||||
// Extract just the STREAMINFO block (type + length + data)
|
||||
let streaminfo = &flac_header[4..4 + streaminfo_size];
|
||||
|
||||
info!("Extracted STREAMINFO: {} bytes (type+length+data)", streaminfo.len());
|
||||
info!(
|
||||
"Extracted STREAMINFO: {} bytes (type+length+data)",
|
||||
streaminfo.len()
|
||||
);
|
||||
|
||||
let mut packet = Vec::new();
|
||||
|
||||
// OGG-FLAC identification header
|
||||
packet.push(0x7F); // Byte 0: 0x7F
|
||||
packet.extend_from_slice(b"FLAC"); // Bytes 1-4: "FLAC"
|
||||
packet.push(0x01); // Byte 5: Major version
|
||||
packet.push(0x00); // Byte 6: Minor version
|
||||
packet.push(0x7F); // Byte 0: 0x7F
|
||||
packet.extend_from_slice(b"FLAC"); // Bytes 1-4: "FLAC"
|
||||
packet.push(0x01); // Byte 5: Major 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(b"fLaC"); // Bytes 9-12: Native FLAC signature
|
||||
packet.extend_from_slice(streaminfo); // Bytes 13+: STREAMINFO block only
|
||||
packet.extend_from_slice(b"fLaC"); // Bytes 9-12: Native FLAC signature
|
||||
packet.extend_from_slice(streaminfo); // Bytes 13+: STREAMINFO block only
|
||||
|
||||
Ok(packet)
|
||||
}
|
||||
@@ -1075,7 +1220,13 @@ impl OggPageWriter {
|
||||
self.granule_position += samples;
|
||||
}
|
||||
|
||||
fn create_page(&mut self, packet_data: &[u8], is_bos: bool, is_eos: bool, is_continuation: bool) -> Vec<u8> {
|
||||
fn create_page(
|
||||
&mut self,
|
||||
packet_data: &[u8],
|
||||
is_bos: bool,
|
||||
is_eos: bool,
|
||||
is_continuation: bool,
|
||||
) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
|
||||
let mut segments = Vec::new();
|
||||
@@ -1117,7 +1268,8 @@ impl OggPageWriter {
|
||||
page.write_all(&[header_type]).unwrap();
|
||||
|
||||
// Granule position
|
||||
page.write_all(&self.granule_position.to_le_bytes()).unwrap();
|
||||
page.write_all(&self.granule_position.to_le_bytes())
|
||||
.unwrap();
|
||||
|
||||
// Stream serial number
|
||||
page.write_all(&self.stream_serial.to_le_bytes()).unwrap();
|
||||
|
||||
@@ -205,11 +205,8 @@ impl NodeLogic for PlaylistSourceLogic {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!("PlaylistSourceLogic: failed to get metadata: {}", e);
|
||||
let error_marker = AudioSegment::new_error(
|
||||
0,
|
||||
0.0,
|
||||
format!("Failed to get metadata: {}", e),
|
||||
);
|
||||
let error_marker =
|
||||
AudioSegment::new_error(0, 0.0, format!("Failed to get metadata: {}", e));
|
||||
send_to_children!(error_marker);
|
||||
continue;
|
||||
}
|
||||
@@ -224,11 +221,8 @@ impl NodeLogic for PlaylistSourceLogic {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!("PlaylistSourceLogic: failed to get file path: {}", e);
|
||||
let error_marker = AudioSegment::new_error(
|
||||
0,
|
||||
0.0,
|
||||
format!("Failed to get file path: {}", e),
|
||||
);
|
||||
let error_marker =
|
||||
AudioSegment::new_error(0, 0.0, format!("Failed to get file path: {}", e));
|
||||
send_to_children!(error_marker);
|
||||
continue;
|
||||
}
|
||||
@@ -290,7 +284,10 @@ async fn decode_and_emit_track(
|
||||
const MIN_FILE_SIZE: u64 = 512 * 1024; // 512 KB (prebuffer size)
|
||||
|
||||
if file_size >= MIN_FILE_SIZE || cache.is_download_complete(cache_pk) {
|
||||
tracing::trace!("decode_and_emit_track: file ready ({} bytes), starting decode", file_size);
|
||||
tracing::trace!(
|
||||
"decode_and_emit_track: file ready ({} bytes), starting decode",
|
||||
file_size
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -417,7 +414,8 @@ async fn decode_and_emit_track(
|
||||
let frames = pending.len() / frame_bytes;
|
||||
if frames > 0 {
|
||||
let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64;
|
||||
let segment = bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?;
|
||||
let segment =
|
||||
bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?;
|
||||
for tx in output {
|
||||
tx.send(segment.clone())
|
||||
.await
|
||||
@@ -609,7 +607,8 @@ impl PlaylistSource {
|
||||
chunk_frames: usize,
|
||||
poll_interval_ms: u64,
|
||||
) -> Self {
|
||||
let logic = PlaylistSourceLogic::new(playlist_handle, cache, chunk_frames, poll_interval_ms);
|
||||
let logic =
|
||||
PlaylistSourceLogic::new(playlist_handle, cache, chunk_frames, poll_interval_ms);
|
||||
Self {
|
||||
inner: Node::new_source(logic),
|
||||
}
|
||||
@@ -626,10 +625,7 @@ impl AudioPipelineNode for PlaylistSource {
|
||||
self.inner.register(child)
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
@@ -712,9 +708,9 @@ mod tests {
|
||||
// Frame 2: L=300, R=400
|
||||
let chunk_bytes = vec![
|
||||
100u8, 0, // L1
|
||||
200, 0, // R1
|
||||
44, 1, // L2 (300 = 0x012C)
|
||||
144, 1, // R2 (400 = 0x0190)
|
||||
200, 0, // R1
|
||||
44, 1, // L2 (300 = 0x012C)
|
||||
144, 1, // R2 (400 = 0x0190)
|
||||
];
|
||||
|
||||
let info = StreamInfo {
|
||||
@@ -732,18 +728,16 @@ mod tests {
|
||||
assert_eq!(segment.timestamp_sec, 0.0);
|
||||
|
||||
match &segment.segment {
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => {
|
||||
match chunk.as_ref() {
|
||||
AudioChunk::I16(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(frames[0], [100, 200]);
|
||||
assert_eq!(frames[1], [300, 400]);
|
||||
assert_eq!(data.get_sample_rate(), 44100);
|
||||
}
|
||||
_ => panic!("Expected I16 chunk"),
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => match chunk.as_ref() {
|
||||
AudioChunk::I16(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(frames[0], [100, 200]);
|
||||
assert_eq!(frames[1], [300, 400]);
|
||||
assert_eq!(data.get_sample_rate(), 44100);
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected I16 chunk"),
|
||||
},
|
||||
_ => panic!("Expected audio chunk"),
|
||||
}
|
||||
}
|
||||
@@ -753,7 +747,7 @@ mod tests {
|
||||
// Create mock PCM data (2 frames, mono, 16-bit)
|
||||
let chunk_bytes = vec![
|
||||
100u8, 0, // Frame 1
|
||||
200, 0, // Frame 2
|
||||
200, 0, // Frame 2
|
||||
];
|
||||
|
||||
let info = StreamInfo {
|
||||
@@ -808,17 +802,15 @@ mod tests {
|
||||
let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap();
|
||||
|
||||
match &segment.segment {
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => {
|
||||
match chunk.as_ref() {
|
||||
AudioChunk::I24(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0][0].as_i32(), 1000);
|
||||
assert_eq!(frames[0][1].as_i32(), -1000);
|
||||
}
|
||||
_ => panic!("Expected I24 chunk"),
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => match chunk.as_ref() {
|
||||
AudioChunk::I24(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0][0].as_i32(), 1000);
|
||||
assert_eq!(frames[0][1].as_i32(), -1000);
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected I24 chunk"),
|
||||
},
|
||||
_ => panic!("Expected audio chunk"),
|
||||
}
|
||||
}
|
||||
@@ -843,16 +835,14 @@ mod tests {
|
||||
let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap();
|
||||
|
||||
match &segment.segment {
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => {
|
||||
match chunk.as_ref() {
|
||||
AudioChunk::I32(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0], [4096, 8192]);
|
||||
}
|
||||
_ => panic!("Expected I32 chunk"),
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => match chunk.as_ref() {
|
||||
AudioChunk::I32(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0], [4096, 8192]);
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected I32 chunk"),
|
||||
},
|
||||
_ => panic!("Expected audio chunk"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ use tokio::fs::File;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let path_str = std::env::args().nth(1).expect("Usage: check_flac_bits <file.flac>");
|
||||
let path_str = std::env::args()
|
||||
.nth(1)
|
||||
.expect("Usage: check_flac_bits <file.flac>");
|
||||
let path = Path::new(&path_str);
|
||||
|
||||
println!("Checking: {}", path.display());
|
||||
|
||||
@@ -88,7 +88,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!();
|
||||
println!("✓ Conversion completed successfully in {:.2}s", elapsed.as_secs_f64());
|
||||
println!(
|
||||
"✓ Conversion completed successfully in {:.2}s",
|
||||
elapsed.as_secs_f64()
|
||||
);
|
||||
println!(" Output file: {}", output_path);
|
||||
println!();
|
||||
|
||||
|
||||
@@ -45,7 +45,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Gérer Ctrl+C pour arrêt propre
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("Failed to listen for Ctrl+C");
|
||||
println!("\nArrêt demandé...");
|
||||
stop_token_clone.cancel();
|
||||
});
|
||||
|
||||
@@ -52,8 +52,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
resampler.register(Box::new(converter));
|
||||
converter.register(Box::new(sink));
|
||||
|
||||
println!("Pipeline créé: FileSource → Resampling({} Hz) → ToI24 → AudioSink",
|
||||
target_sample_rate);
|
||||
println!(
|
||||
"Pipeline créé: FileSource → Resampling({} Hz) → ToI24 → AudioSink",
|
||||
target_sample_rate
|
||||
);
|
||||
println!("Démarrage de la lecture...");
|
||||
println!("Appuyez sur Ctrl+C pour arrêter");
|
||||
|
||||
@@ -63,7 +65,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Gérer Ctrl+C
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("Failed to listen for Ctrl+C");
|
||||
println!("\nArrêt demandé...");
|
||||
stop_token_clone.cancel();
|
||||
});
|
||||
|
||||
@@ -678,9 +678,11 @@ impl AudioIntegerChunk {
|
||||
AudioIntegerChunk::I16(d) => {
|
||||
Box::new(d.get_frames().iter().map(|f| [f[0] as i32, f[1] as i32]))
|
||||
}
|
||||
AudioIntegerChunk::I24(d) => {
|
||||
Box::new(d.get_frames().iter().map(|f| [f[0].as_i32(), f[1].as_i32()]))
|
||||
}
|
||||
AudioIntegerChunk::I24(d) => Box::new(
|
||||
d.get_frames()
|
||||
.iter()
|
||||
.map(|f| [f[0].as_i32(), f[1].as_i32()]),
|
||||
),
|
||||
AudioIntegerChunk::I32(d) => Box::new(d.get_frames().iter().map(|f| [f[0], f[1]])),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,11 +247,7 @@ fn i16_stereo_to_pairs_f32_inner(
|
||||
}
|
||||
|
||||
/// Convertit deux canaux i16 (L/R) en pairs f32 normalisées [-1.0, 1.0]
|
||||
pub fn i16_stereo_to_pairs_f32(
|
||||
left: &[i16],
|
||||
right: &[i16],
|
||||
out_pairs: &mut [[f32; 2]],
|
||||
) {
|
||||
pub fn i16_stereo_to_pairs_f32(left: &[i16], right: &[i16], out_pairs: &mut [[f32; 2]]) {
|
||||
i16_stereo_to_pairs_f32_inner(left, right, out_pairs, 32768.0);
|
||||
}
|
||||
|
||||
@@ -332,11 +328,7 @@ fn pairs_f32_to_i16_stereo_inner(
|
||||
}
|
||||
|
||||
/// Convertit pairs f32 normalisées [-1.0, 1.0] en deux canaux i16 (L/R)
|
||||
pub fn pairs_f32_to_i16_stereo(
|
||||
input_pairs: &[[f32; 2]],
|
||||
left: &mut [i16],
|
||||
right: &mut [i16],
|
||||
) {
|
||||
pub fn pairs_f32_to_i16_stereo(input_pairs: &[[f32; 2]], left: &mut [i16], right: &mut [i16]) {
|
||||
pairs_f32_to_i16_stereo_inner(input_pairs, left, right, 32768.0);
|
||||
}
|
||||
|
||||
@@ -399,11 +391,7 @@ fn i24_as_i32_stereo_to_pairs_f32_inner(
|
||||
}
|
||||
|
||||
/// Convertit deux canaux i32 (contenant des valeurs I24) en pairs f32 normalisées
|
||||
pub fn i24_as_i32_stereo_to_pairs_f32(
|
||||
left: &[i32],
|
||||
right: &[i32],
|
||||
out_pairs: &mut [[f32; 2]],
|
||||
) {
|
||||
pub fn i24_as_i32_stereo_to_pairs_f32(left: &[i32], right: &[i32], out_pairs: &mut [[f32; 2]]) {
|
||||
i24_as_i32_stereo_to_pairs_f32_inner(left, right, out_pairs, 8388608.0);
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ pub use nodes::{
|
||||
flac_file_sink::{FlacFileSink, FlacFileSinkStats},
|
||||
http_source::HttpSource,
|
||||
resampling_node::ResamplingNode,
|
||||
timer_buffer_node::TimerBufferNode,
|
||||
timer_node::TimerNode,
|
||||
AudioError, AudioNode, TypedAudioNode,
|
||||
};
|
||||
|
||||
@@ -220,20 +220,18 @@ impl AudioSinkLogic {
|
||||
chunk.sample_rate()
|
||||
);
|
||||
}
|
||||
crate::_AudioSegment::Sync(marker) => {
|
||||
match **marker {
|
||||
SyncMarker::TrackBoundary { .. } => {
|
||||
tracing::debug!("AudioSink (null): TrackBoundary received");
|
||||
}
|
||||
SyncMarker::EndOfStream => {
|
||||
tracing::debug!("AudioSink (null): EndOfStream received");
|
||||
return Ok(());
|
||||
}
|
||||
_ => {
|
||||
tracing::trace!("AudioSink (null): sync marker");
|
||||
}
|
||||
crate::_AudioSegment::Sync(marker) => match **marker {
|
||||
SyncMarker::TrackBoundary { .. } => {
|
||||
tracing::debug!("AudioSink (null): TrackBoundary received");
|
||||
}
|
||||
}
|
||||
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()
|
||||
.ok_or_else(|| AudioError::ProcessingError("No output device available".to_string()))?;
|
||||
|
||||
tracing::debug!("Using audio device: {}", device.name().unwrap_or_else(|_| "Unknown".to_string()));
|
||||
tracing::debug!(
|
||||
"Using audio device: {}",
|
||||
device.name().unwrap_or_else(|_| "Unknown".to_string())
|
||||
);
|
||||
|
||||
// Obtenir la config par défaut
|
||||
let config = device
|
||||
.default_output_config()
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Failed to get output config: {}", e)))?;
|
||||
let config = device.default_output_config().map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to get output config: {}", e))
|
||||
})?;
|
||||
|
||||
let sample_format = config.sample_format();
|
||||
let sample_rate = config.sample_rate().0;
|
||||
@@ -298,9 +299,9 @@ impl NodeLogic for AudioSinkLogic {
|
||||
let stream_thread = thread::spawn(move || {
|
||||
// Créer le stream selon le format hardware
|
||||
let stream = match sample_format {
|
||||
cpal::SampleFormat::I16 => {
|
||||
tracing::debug!("Using I16 output format");
|
||||
match device.build_output_stream(
|
||||
cpal::SampleFormat::I16 => {
|
||||
tracing::debug!("Using I16 output format");
|
||||
match device.build_output_stream(
|
||||
&config.into(),
|
||||
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
||||
let mut buf = buffer_clone.lock().unwrap();
|
||||
@@ -323,10 +324,10 @@ impl NodeLogic for AudioSinkLogic {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
cpal::SampleFormat::U16 => {
|
||||
tracing::debug!("Using U16 output format");
|
||||
match device.build_output_stream(
|
||||
}
|
||||
cpal::SampleFormat::U16 => {
|
||||
tracing::debug!("Using U16 output format");
|
||||
match device.build_output_stream(
|
||||
&config.into(),
|
||||
move |data: &mut [u16], _: &cpal::OutputCallbackInfo| {
|
||||
let mut buf = buffer_clone.lock().unwrap();
|
||||
@@ -348,10 +349,10 @@ impl NodeLogic for AudioSinkLogic {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
cpal::SampleFormat::F32 => {
|
||||
tracing::debug!("Using F32 output format");
|
||||
match device.build_output_stream(
|
||||
}
|
||||
cpal::SampleFormat::F32 => {
|
||||
tracing::debug!("Using F32 output format");
|
||||
match device.build_output_stream(
|
||||
&config.into(),
|
||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||
let mut buf = buffer_clone.lock().unwrap();
|
||||
@@ -371,10 +372,10 @@ impl NodeLogic for AudioSinkLogic {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::error!("Unsupported sample format: {:?}", sample_format);
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
tracing::error!("Unsupported sample format: {:?}", sample_format);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -467,7 +468,9 @@ impl NodeLogic for AudioSinkLogic {
|
||||
// Le buffer continue automatiquement - pas besoin d'action
|
||||
}
|
||||
SyncMarker::EndOfStream => {
|
||||
tracing::debug!("AudioSink: EndOfStream received, waiting for playback to finish");
|
||||
tracing::debug!(
|
||||
"AudioSink: EndOfStream received, waiting for playback to finish"
|
||||
);
|
||||
// Marquer la fin et attendre que le buffer se vide
|
||||
buffer.lock().unwrap().mark_end();
|
||||
|
||||
@@ -576,10 +579,7 @@ impl AudioPipelineNode for AudioSink {
|
||||
panic!("AudioSink is a terminal node and cannot have children");
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,11 @@ impl NodeLogic for FileSourceLogic {
|
||||
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
tracing::debug!("FileSourceLogic::process started, path={:?}, {} children", self.path, output.len());
|
||||
tracing::debug!(
|
||||
"FileSourceLogic::process started, path={:?}, {} children",
|
||||
self.path,
|
||||
output.len()
|
||||
);
|
||||
|
||||
// Macro helper pour envoyer à tous les enfants
|
||||
macro_rules! send_to_children {
|
||||
@@ -55,9 +59,9 @@ impl NodeLogic for FileSourceLogic {
|
||||
}
|
||||
|
||||
// Ouvrir le fichier
|
||||
let file = File::open(&self.path).await.map_err(|e| {
|
||||
AudioError::IoError(format!("Failed to open {:?}: {}", self.path, e))
|
||||
})?;
|
||||
let file = File::open(&self.path)
|
||||
.await
|
||||
.map_err(|e| AudioError::IoError(format!("Failed to open {:?}: {}", self.path, e)))?;
|
||||
|
||||
// Décoder le flux audio
|
||||
let mut stream = decode_audio_stream(file)
|
||||
@@ -256,10 +260,7 @@ impl AudioPipelineNode for FileSource {
|
||||
self.inner.register(child)
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
@@ -399,7 +400,6 @@ fn bytes_to_segment(
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
impl TypedAudioNode for FileSource {
|
||||
fn input_type(&self) -> Option<TypeRequirement> {
|
||||
// FileSource est une source, elle ne consomme pas d'audio
|
||||
@@ -464,11 +464,7 @@ mod tests {
|
||||
impl TestCollectorNode {
|
||||
fn new(test_tx: mpsc::Sender<Arc<AudioSegment>>) -> Self {
|
||||
let (tx, rx) = mpsc::channel(16);
|
||||
Self {
|
||||
tx,
|
||||
rx,
|
||||
test_tx,
|
||||
}
|
||||
Self { tx, rx, test_tx }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,10 @@ impl NodeLogic for FlacFileSinkLogic {
|
||||
let mut rx = input.expect("FlacFileSink must have input");
|
||||
let mut track_number = 0;
|
||||
|
||||
tracing::debug!("FlacFileSinkLogic::process started, base_path={:?}", self.base_path);
|
||||
tracing::debug!(
|
||||
"FlacFileSinkLogic::process started, base_path={:?}",
|
||||
self.base_path
|
||||
);
|
||||
|
||||
loop {
|
||||
// Vérifier si l'arrêt a été demandé
|
||||
@@ -81,13 +84,14 @@ impl NodeLogic for FlacFileSinkLogic {
|
||||
}
|
||||
|
||||
// Attendre le premier chunk audio pour cette track, en capturant les métadonnées du TrackBoundary
|
||||
let (first_segment, track_metadata) = match wait_for_first_audio_chunk_with_metadata(&mut rx, &stop_token).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
// Plus d'audio disponible ou arrêt demandé
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let (first_segment, track_metadata) =
|
||||
match wait_for_first_audio_chunk_with_metadata(&mut rx, &stop_token).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
// Plus d'audio disponible ou arrêt demandé
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Extraire les informations du premier chunk
|
||||
let first_chunk = first_segment.as_chunk().unwrap();
|
||||
@@ -96,7 +100,9 @@ impl NodeLogic for FlacFileSinkLogic {
|
||||
|
||||
tracing::debug!(
|
||||
"FlacFileSinkLogic: encoding track {} with {}bit @ {}Hz",
|
||||
track_number, bits_per_sample, sample_rate
|
||||
track_number,
|
||||
bits_per_sample,
|
||||
sample_rate
|
||||
);
|
||||
|
||||
let format = PcmFormat {
|
||||
@@ -308,10 +314,7 @@ impl FlacFileSink {
|
||||
///
|
||||
/// * `base_path` - Chemin de base pour les fichiers FLAC
|
||||
/// * `channel_size` - Taille du buffer MPSC (nombre de segments en attente avant backpressure)
|
||||
pub fn with_channel_size<P: Into<PathBuf>>(
|
||||
base_path: P,
|
||||
channel_size: usize,
|
||||
) -> Self {
|
||||
pub fn with_channel_size<P: Into<PathBuf>>(base_path: P, channel_size: usize) -> Self {
|
||||
Self::with_config(base_path, channel_size, EncoderOptions::default())
|
||||
}
|
||||
|
||||
@@ -360,7 +363,13 @@ fn generate_track_path(base_path: &Path, track_number: usize) -> PathBuf {
|
||||
async fn wait_for_first_audio_chunk_with_metadata(
|
||||
rx: &mut mpsc::Receiver<Arc<AudioSegment>>,
|
||||
stop_token: &CancellationToken,
|
||||
) -> Result<(Arc<AudioSegment>, Option<Arc<tokio::sync::RwLock<dyn pmometadata::TrackMetadata>>>), AudioError> {
|
||||
) -> Result<
|
||||
(
|
||||
Arc<AudioSegment>,
|
||||
Option<Arc<tokio::sync::RwLock<dyn pmometadata::TrackMetadata>>>,
|
||||
),
|
||||
AudioError,
|
||||
> {
|
||||
let mut track_metadata: Option<Arc<tokio::sync::RwLock<dyn pmometadata::TrackMetadata>>> = None;
|
||||
|
||||
loop {
|
||||
@@ -738,10 +747,7 @@ impl AudioPipelineNode for FlacFileSink {
|
||||
panic!("FlacFileSink is a terminal node and cannot have children");
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
@@ -768,7 +774,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_flac_file_sink_writes_metadata() {
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let output_path = temp_dir.path().join("output_with_metadata.flac");
|
||||
|
||||
@@ -779,9 +784,8 @@ mod tests {
|
||||
let sink = FlacFileSink::with_channel_size(&output_path, 16);
|
||||
let tx = sink.get_tx().unwrap();
|
||||
let stop_token = CancellationToken::new();
|
||||
let sink_handle = tokio::spawn(async move {
|
||||
Box::new(sink).run(stop_token).await.unwrap()
|
||||
});
|
||||
let sink_handle =
|
||||
tokio::spawn(async move { Box::new(sink).run(stop_token).await.unwrap() });
|
||||
|
||||
// Envoyer des segments avec métadonnées
|
||||
tokio::spawn(async move {
|
||||
@@ -792,13 +796,25 @@ mod tests {
|
||||
|
||||
// TrackBoundary avec métadonnées
|
||||
let mut metadata = MemoryTrackMetadata::new();
|
||||
metadata.set_title(Some("Test Track Title".to_string())).await.unwrap();
|
||||
metadata.set_artist(Some("Test Artist".to_string())).await.unwrap();
|
||||
metadata.set_album(Some("Test Album".to_string())).await.unwrap();
|
||||
metadata
|
||||
.set_title(Some("Test Track Title".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
metadata
|
||||
.set_artist(Some("Test Artist".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
metadata
|
||||
.set_album(Some("Test Album".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
metadata.set_year(Some(2024)).await.unwrap();
|
||||
|
||||
let track_boundary =
|
||||
crate::AudioSegment::new_track_boundary(0, 0.0, std::sync::Arc::new(tokio::sync::RwLock::new(metadata)));
|
||||
let track_boundary = crate::AudioSegment::new_track_boundary(
|
||||
0,
|
||||
0.0,
|
||||
std::sync::Arc::new(tokio::sync::RwLock::new(metadata)),
|
||||
);
|
||||
tx.send(track_boundary).await.unwrap();
|
||||
|
||||
// Générer et envoyer des chunks audio
|
||||
@@ -900,9 +916,8 @@ mod tests {
|
||||
let sink = FlacFileSink::with_channel_size(&output_path, 16);
|
||||
let tx = sink.get_tx().unwrap();
|
||||
let stop_token = CancellationToken::new();
|
||||
let sink_handle = tokio::spawn(async move {
|
||||
Box::new(sink).run(stop_token).await.unwrap()
|
||||
});
|
||||
let sink_handle =
|
||||
tokio::spawn(async move { Box::new(sink).run(stop_token).await.unwrap() });
|
||||
|
||||
// Lire le fichier input et envoyer les segments au sink
|
||||
tokio::spawn(async move {
|
||||
|
||||
@@ -114,7 +114,7 @@ impl HttpSourceLogic {
|
||||
self.url.clone()
|
||||
}
|
||||
|
||||
pub fn get_chunc_frames(&self) -> usize {
|
||||
pub fn get_chunc_frames(&self) -> usize {
|
||||
self.chunk_frames
|
||||
}
|
||||
}
|
||||
@@ -138,11 +138,9 @@ impl NodeLogic for HttpSourceLogic {
|
||||
}
|
||||
|
||||
// Effectuer la requête HTTP
|
||||
let response = reqwest::get(&self.url)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("HTTP request failed for {}: {}", self.url, e))
|
||||
})?;
|
||||
let response = reqwest::get(&self.url).await.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("HTTP request failed for {}: {}", self.url, e))
|
||||
})?;
|
||||
|
||||
// Vérifier le status
|
||||
if !response.status().is_success() {
|
||||
@@ -158,9 +156,10 @@ impl NodeLogic for HttpSourceLogic {
|
||||
|
||||
// Convertir le stream de bytes en AsyncRead
|
||||
let bytes_stream = response.bytes_stream();
|
||||
let stream_reader = StreamReader::new(bytes_stream.map(|result| {
|
||||
result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||
}));
|
||||
let stream_reader =
|
||||
StreamReader::new(bytes_stream.map(|result| {
|
||||
result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||
}));
|
||||
|
||||
// Décoder le flux audio
|
||||
let mut stream = decode_audio_stream(stream_reader)
|
||||
@@ -183,11 +182,8 @@ impl NodeLogic for HttpSourceLogic {
|
||||
send_to_children!(AudioSegment::new_top_zero_sync());
|
||||
|
||||
// Émettre TrackBoundary avec les métadonnées HTTP
|
||||
let track_boundary = AudioSegment::new_track_boundary(
|
||||
0,
|
||||
0.0,
|
||||
Arc::new(tokio::sync::RwLock::new(metadata)),
|
||||
);
|
||||
let track_boundary =
|
||||
AudioSegment::new_track_boundary(0, 0.0, Arc::new(tokio::sync::RwLock::new(metadata)));
|
||||
send_to_children!(track_boundary);
|
||||
|
||||
// Préparer la lecture des chunks audio
|
||||
@@ -523,10 +519,7 @@ impl AudioPipelineNode for HttpSource {
|
||||
self.inner.register(child)
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
@@ -698,7 +691,10 @@ mod tests {
|
||||
}
|
||||
|
||||
// Vérifications
|
||||
assert_eq!(received_frames, frames, "Tous les frames doivent être reçus");
|
||||
assert_eq!(
|
||||
received_frames, frames,
|
||||
"Tous les frames doivent être reçus"
|
||||
);
|
||||
assert!(seen_top_zero, "TopZeroSync doit être émis");
|
||||
assert!(seen_track_boundary, "TrackBoundary doit être émis");
|
||||
assert!(seen_eos, "EndOfStream doit être émis");
|
||||
@@ -725,9 +721,10 @@ mod tests {
|
||||
bits_per_sample: 16,
|
||||
};
|
||||
|
||||
let mut flac_stream = encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut flac_stream =
|
||||
encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut flac_data = Vec::new();
|
||||
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");
|
||||
|
||||
if let Err(AudioError::ProcessingError(msg)) = result {
|
||||
assert!(msg.contains("404"), "Le message d'erreur doit mentionner le code 404");
|
||||
assert!(
|
||||
msg.contains("404"),
|
||||
"Le message d'erreur doit mentionner le code 404"
|
||||
);
|
||||
} else {
|
||||
panic!("Le type d'erreur doit être ProcessingError");
|
||||
}
|
||||
@@ -854,9 +854,10 @@ mod tests {
|
||||
bits_per_sample: 16,
|
||||
};
|
||||
|
||||
let mut flac_stream = encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut flac_stream =
|
||||
encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut flac_data = Vec::new();
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod file_source;
|
||||
pub mod flac_file_sink;
|
||||
pub mod http_source;
|
||||
pub mod resampling_node;
|
||||
pub mod timer_buffer_node;
|
||||
pub mod timer_node;
|
||||
|
||||
// Modules temporairement désactivés
|
||||
|
||||
@@ -95,7 +95,9 @@ impl ResamplingLogic {
|
||||
bit_depth
|
||||
);
|
||||
let resampler = build_resampler(source_sr, self.target_sample_rate, bit_depth)
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Resampler init failed: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Resampler init failed: {}", e))
|
||||
})?;
|
||||
self.current_resampler = Some(ResamplerState {
|
||||
source_hz: source_sr,
|
||||
resampler,
|
||||
@@ -111,7 +113,12 @@ impl ResamplingLogic {
|
||||
let (resampled_left, resampled_right) = resampling(&left, &right, &mut state.resampler);
|
||||
|
||||
// Recréer le chunk avec le nouveau sample rate
|
||||
reconstruct_chunk(chunk, resampled_left, resampled_right, self.target_sample_rate)
|
||||
reconstruct_chunk(
|
||||
chunk,
|
||||
resampled_left,
|
||||
resampled_right,
|
||||
self.target_sample_rate,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,10 +367,7 @@ impl AudioPipelineNode for ResamplingNode {
|
||||
self.inner.register(child)
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
@@ -418,11 +422,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_reconstruct_chunk_i16() {
|
||||
let original = AudioChunk::I16(AudioChunkData::new(
|
||||
vec![[100, 200]],
|
||||
44100,
|
||||
0.0,
|
||||
));
|
||||
let original = AudioChunk::I16(AudioChunkData::new(vec![[100, 200]], 44100, 0.0));
|
||||
|
||||
let left = vec![100i32, 300i32];
|
||||
let right = vec![200i32, 400i32];
|
||||
@@ -495,7 +495,7 @@ mod tests {
|
||||
|
||||
// Créer un TrackBoundary
|
||||
let metadata = Arc::new(tokio::sync::RwLock::new(
|
||||
pmometadata::MemoryTrackMetadata::new()
|
||||
pmometadata::MemoryTrackMetadata::new(),
|
||||
));
|
||||
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata);
|
||||
|
||||
@@ -568,7 +568,11 @@ mod tests {
|
||||
// 100 frames @ 44.1kHz ≈ 109 frames @ 48kHz
|
||||
if let AudioChunk::I16(data) = chunk.as_ref() {
|
||||
let frames = data.get_frames().len();
|
||||
assert!(frames >= 105 && frames <= 115, "Expected ~109 frames, got {}", frames);
|
||||
assert!(
|
||||
frames >= 105 && frames <= 115,
|
||||
"Expected ~109 frames, got {}",
|
||||
frames
|
||||
);
|
||||
}
|
||||
} else {
|
||||
panic!("Expected audio chunk");
|
||||
|
||||
357
pmoaudio/src/nodes/timer_buffer_node.rs
Normal file
357
pmoaudio/src/nodes/timer_buffer_node.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -73,15 +73,46 @@ use tokio_util::sync::CancellationToken;
|
||||
pub struct TimerNodeLogic {
|
||||
/// Avance maximale tolérée en secondes (buffer)
|
||||
max_lead_time_sec: f64,
|
||||
/// Tolérance supplémentaire avant de resynchroniser l'horloge
|
||||
catchup_slack_sec: f64,
|
||||
/// Instant de référence (reset au TopZeroSync)
|
||||
start_time: Option<Instant>,
|
||||
/// Nombre de chunks traités (pour instrumentation)
|
||||
chunk_count: u64,
|
||||
/// Dernier log d'instrumentation
|
||||
last_stats_log: Option<Instant>,
|
||||
}
|
||||
|
||||
impl TimerNodeLogic {
|
||||
pub fn new(max_lead_time_sec: f64) -> Self {
|
||||
let max_lead = max_lead_time_sec.max(0.0);
|
||||
let slack = (max_lead * 0.25).max(0.5);
|
||||
Self {
|
||||
max_lead_time_sec: max_lead_time_sec.max(0.0),
|
||||
max_lead_time_sec: max_lead,
|
||||
catchup_slack_sec: slack,
|
||||
start_time: None,
|
||||
chunk_count: 0,
|
||||
last_stats_log: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_log_stats(&mut self, chunk_timestamp: f64, elapsed: f64, lead_time: f64) {
|
||||
let now = Instant::now();
|
||||
let should_log = match self.last_stats_log {
|
||||
None => true,
|
||||
Some(last) => now.duration_since(last) >= Duration::from_secs(1),
|
||||
};
|
||||
|
||||
if should_log {
|
||||
self.last_stats_log = Some(now);
|
||||
tracing::debug!(
|
||||
"TimerNode stats: chunks={} ts={:.3}s elapsed={:.3}s lead={:.3}s max={:.3}s",
|
||||
self.chunk_count,
|
||||
chunk_timestamp,
|
||||
elapsed,
|
||||
lead_time,
|
||||
self.max_lead_time_sec
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,10 +135,20 @@ impl NodeLogic for TimerNodeLogic {
|
||||
// Macro helper pour envoyer à tous les enfants
|
||||
macro_rules! send_to_children {
|
||||
($segment:expr) => {
|
||||
for tx in &output {
|
||||
for (idx, tx) in output.iter().enumerate() {
|
||||
let send_start = Instant::now();
|
||||
tx.send($segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
let send_duration = send_start.elapsed();
|
||||
if send_duration.as_millis() >= 50 {
|
||||
tracing::debug!(
|
||||
"TimerNode: send to child {} blocked for {:.3}s (segment ts={:.3}s)",
|
||||
idx,
|
||||
send_duration.as_secs_f64(),
|
||||
$segment.timestamp_sec
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -149,9 +190,34 @@ impl NodeLogic for TimerNodeLogic {
|
||||
_AudioSegment::Chunk(_) => {
|
||||
// Vérifier le pacing seulement si on a un timer de référence
|
||||
if let Some(start) = self.start_time {
|
||||
self.chunk_count += 1;
|
||||
let chunk_timestamp = segment.timestamp_sec;
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
let lead_time = chunk_timestamp - elapsed;
|
||||
let mut elapsed = start.elapsed().as_secs_f64();
|
||||
let mut lead_time = chunk_timestamp - elapsed;
|
||||
|
||||
// Si on a accumulé beaucoup trop d'avance (source ultra rapide),
|
||||
// on recale l'horloge pour éviter de dormir pendant des dizaines de secondes.
|
||||
let catchup_threshold = self.max_lead_time_sec + self.catchup_slack_sec;
|
||||
if lead_time > catchup_threshold {
|
||||
let desired_elapsed =
|
||||
(chunk_timestamp - self.max_lead_time_sec).max(0.0);
|
||||
let adjust = (desired_elapsed - elapsed).max(0.0);
|
||||
let new_start =
|
||||
Instant::now() - Duration::from_secs_f64(desired_elapsed);
|
||||
self.start_time = Some(new_start);
|
||||
elapsed = desired_elapsed;
|
||||
lead_time = chunk_timestamp - elapsed;
|
||||
tracing::warn!(
|
||||
"TimerNode: lead {:.3}s > {:.3}s (max {:.3}s + slack {:.3}s) → fast-forward clock by {:.3}s",
|
||||
chunk_timestamp - start.elapsed().as_secs_f64(),
|
||||
catchup_threshold,
|
||||
self.max_lead_time_sec,
|
||||
self.catchup_slack_sec,
|
||||
adjust
|
||||
);
|
||||
}
|
||||
|
||||
self.maybe_log_stats(chunk_timestamp, elapsed, lead_time);
|
||||
|
||||
tracing::trace!(
|
||||
"TimerNodeLogic: chunk received (ts={:.3}s, elapsed={:.3}s, lead_time={:.3}s, max_lead={:.1}s)",
|
||||
@@ -159,9 +225,9 @@ impl NodeLogic for TimerNodeLogic {
|
||||
);
|
||||
|
||||
if lead_time > self.max_lead_time_sec {
|
||||
// On est trop en avance, attendre
|
||||
let sleep_duration = lead_time - self.max_lead_time_sec;
|
||||
tracing::debug!(
|
||||
// On est trop en avance, attendre juste assez pour retomber à max_lead_time
|
||||
let sleep_duration = (lead_time - self.max_lead_time_sec).max(0.0);
|
||||
tracing::trace!(
|
||||
"TimerNodeLogic: SLEEPING {:.3}s (lead_time={:.3}s > max={:.1}s, chunk_ts={:.3}s)",
|
||||
sleep_duration,
|
||||
lead_time,
|
||||
@@ -274,3 +340,4 @@ impl TypedAudioNode for TimerNode {
|
||||
Some(TypeRequirement::any())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,10 +104,7 @@ pub trait AudioPipelineNode: Send + 'static {
|
||||
/// - Un seul `cancel()` par nœud (en sortant de la boucle de travail)
|
||||
/// - L'enfant ne cancel JAMAIS le parent
|
||||
/// - `cancel()` est idempotent (pas de problème si appelé plusieurs fois)
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError>;
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError>;
|
||||
|
||||
/// Lance le pipeline en arrière-plan et retourne un handle de contrôle
|
||||
///
|
||||
@@ -148,9 +145,7 @@ pub trait AudioPipelineNode: Send + 'static {
|
||||
let stop_token = CancellationToken::new();
|
||||
let token_for_task = stop_token.clone();
|
||||
|
||||
let join_handle = tokio::spawn(async move {
|
||||
self.run(token_for_task).await
|
||||
});
|
||||
let join_handle = tokio::spawn(async move { self.run(token_for_task).await });
|
||||
|
||||
PipelineHandle {
|
||||
stop_token,
|
||||
@@ -373,12 +368,14 @@ impl PipelineHandle {
|
||||
pub async fn wait(self) -> Result<(), AudioError> {
|
||||
match self.join_handle.await {
|
||||
Ok(result) => result,
|
||||
Err(e) if e.is_panic() => Err(AudioError::ProcessingError(
|
||||
format!("Pipeline task panicked: {}", e)
|
||||
)),
|
||||
Err(e) => Err(AudioError::ProcessingError(
|
||||
format!("Pipeline task cancelled: {}", e)
|
||||
)),
|
||||
Err(e) if e.is_panic() => Err(AudioError::ProcessingError(format!(
|
||||
"Pipeline task panicked: {}",
|
||||
e
|
||||
))),
|
||||
Err(e) => Err(AudioError::ProcessingError(format!(
|
||||
"Pipeline task cancelled: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,10 +503,7 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
|
||||
self.children.push(child);
|
||||
}
|
||||
|
||||
async fn run(
|
||||
mut self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
async fn run(mut self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
let Node {
|
||||
mut logic,
|
||||
rx,
|
||||
@@ -528,9 +522,7 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
|
||||
for (i, child) in children.into_iter().enumerate() {
|
||||
tracing::debug!("Spawning child {}", i);
|
||||
let child_token = stop_token.child_token();
|
||||
let handle = tokio::spawn(async move {
|
||||
child.run(child_token).await
|
||||
});
|
||||
let handle = tokio::spawn(async move { child.run(child_token).await });
|
||||
child_handles.push(handle);
|
||||
}
|
||||
tracing::debug!("All {} children spawned", child_handles.len());
|
||||
@@ -573,9 +565,10 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
|
||||
// Un enfant a paniqué
|
||||
tracing::error!("Child panicked: {}", e);
|
||||
if !has_error {
|
||||
first_error = Some(AudioError::ProcessingError(
|
||||
format!("Child task panicked: {}", e)
|
||||
));
|
||||
first_error = Some(AudioError::ProcessingError(format!(
|
||||
"Child task panicked: {}",
|
||||
e
|
||||
)));
|
||||
has_error = true;
|
||||
}
|
||||
}
|
||||
@@ -595,78 +588,79 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
|
||||
// PHASE 3: EXÉCUTER LA LOGIQUE MÉTIER EN RACE AVEC LE MONITORING
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
let (stop_reason, process_result, child_monitor_consumed) = if let Some(monitor) = &mut child_monitor {
|
||||
// Il y a des enfants à surveiller
|
||||
tokio::select! {
|
||||
// Cancel externe demandé
|
||||
_ = stop_token.cancelled() => {
|
||||
tracing::debug!("Node cancelled via stop_token");
|
||||
(StopReason::Cancelled, Ok(()), false)
|
||||
}
|
||||
let (stop_reason, process_result, child_monitor_consumed) =
|
||||
if let Some(monitor) = &mut child_monitor {
|
||||
// Il y a des enfants à surveiller
|
||||
tokio::select! {
|
||||
// Cancel externe demandé
|
||||
_ = stop_token.cancelled() => {
|
||||
tracing::debug!("Node cancelled via stop_token");
|
||||
(StopReason::Cancelled, Ok(()), false)
|
||||
}
|
||||
|
||||
// Monitoring des enfants - retourne quand tous sont terminés ou sur erreur
|
||||
child_result = monitor => {
|
||||
match child_result {
|
||||
Ok(Ok(())) => {
|
||||
// Tous les enfants terminés avec succès
|
||||
// Le parent devrait aussi terminer bientôt
|
||||
tracing::debug!("All children finished successfully");
|
||||
(StopReason::Completed, Ok(()), true)
|
||||
// Monitoring des enfants - retourne quand tous sont terminés ou sur erreur
|
||||
child_result = monitor => {
|
||||
match child_result {
|
||||
Ok(Ok(())) => {
|
||||
// Tous les enfants terminés avec succès
|
||||
// Le parent devrait aussi terminer bientôt
|
||||
tracing::debug!("All children finished successfully");
|
||||
(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);
|
||||
(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)
|
||||
}
|
||||
|
||||
// Logique métier du nœud
|
||||
process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => {
|
||||
tracing::info!("Node logic.process() returned");
|
||||
match process_result {
|
||||
Ok(()) => {
|
||||
tracing::info!("Node process completed successfully");
|
||||
(StopReason::Completed, Ok(()), false)
|
||||
}
|
||||
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
|
||||
process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => {
|
||||
tracing::info!("Node logic.process() returned");
|
||||
match process_result {
|
||||
Ok(()) => {
|
||||
tracing::info!("Node process completed successfully");
|
||||
(StopReason::Completed, Ok(()), false)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Node process error: {}", e);
|
||||
(StopReason::Error(e.clone()), Err(e), false)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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É
|
||||
|
||||
@@ -4,8 +4,13 @@ use tokio::sync::RwLock;
|
||||
use pmometadata::TrackMetadata;
|
||||
|
||||
pub enum SyncMarker {
|
||||
TrackBoundary { metadata: Arc<RwLock<dyn TrackMetadata>> },
|
||||
StreamMetadata { key: String, value: String },
|
||||
TrackBoundary {
|
||||
metadata: Arc<RwLock<dyn TrackMetadata>>,
|
||||
},
|
||||
StreamMetadata {
|
||||
key: String,
|
||||
value: String,
|
||||
},
|
||||
TopZeroSync,
|
||||
Heartbeat,
|
||||
EndOfStream,
|
||||
|
||||
@@ -296,27 +296,26 @@ mod tests {
|
||||
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_artist().await.unwrap(), Some("Artist".into()));
|
||||
assert_eq!(meta.get_album().await.unwrap(), Some("Album".into()));
|
||||
assert_eq!(meta.get_year().await.unwrap(), Some(2024));
|
||||
assert_eq!(
|
||||
meta.get_duration().await.unwrap(),
|
||||
Some(Duration::from_secs(90))
|
||||
);
|
||||
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_event().await.unwrap(), Some("event".into()));
|
||||
assert_eq!(meta.get_rating().await.unwrap(), Some(4.5));
|
||||
assert_eq!(
|
||||
meta.get_cover_url().await.unwrap(),
|
||||
Some("http://cover".into())
|
||||
);
|
||||
assert_eq!(meta.get_cover_pk().await.unwrap(), Some("cover123".into()));
|
||||
assert!(meta.get_updated_at().await.unwrap().is_some());
|
||||
}
|
||||
assert_eq!(meta.get_title().await.unwrap(), Some("Title".into()));
|
||||
assert_eq!(meta.get_artist().await.unwrap(), Some("Artist".into()));
|
||||
assert_eq!(meta.get_album().await.unwrap(), Some("Album".into()));
|
||||
assert_eq!(meta.get_year().await.unwrap(), Some(2024));
|
||||
assert_eq!(
|
||||
meta.get_duration().await.unwrap(),
|
||||
Some(Duration::from_secs(90))
|
||||
);
|
||||
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_event().await.unwrap(), Some("event".into()));
|
||||
assert_eq!(meta.get_rating().await.unwrap(), Some(4.5));
|
||||
assert_eq!(
|
||||
meta.get_cover_url().await.unwrap(),
|
||||
Some("http://cover".into())
|
||||
);
|
||||
assert_eq!(meta.get_cover_pk().await.unwrap(), Some("cover123".into()));
|
||||
assert!(meta.get_updated_at().await.unwrap().is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,8 @@ pub struct Cache<C: CacheConfig> {
|
||||
impl<C: CacheConfig> Cache<C> {
|
||||
/// Retourne le chemin du fichier marker de complétion
|
||||
fn get_completion_marker_path(&self, pk: &str) -> PathBuf {
|
||||
self.get_file_path(pk).with_extension(format!("{}.complete", C::file_extension()))
|
||||
self.get_file_path(pk)
|
||||
.with_extension(format!("{}.complete", C::file_extension()))
|
||||
}
|
||||
|
||||
/// Vérifie si un fichier est en cache et complet
|
||||
@@ -118,10 +119,15 @@ impl<C: CacheConfig> Cache<C> {
|
||||
};
|
||||
|
||||
if let Some(download) = download_handle {
|
||||
tracing::debug!("Download already in progress for pk {}, waiting for prebuffering", pk);
|
||||
tracing::debug!(
|
||||
"Download already in progress for pk {}, waiting for prebuffering",
|
||||
pk
|
||||
);
|
||||
|
||||
if self.min_prebuffer_size > 0 {
|
||||
download.wait_until_min_size(self.min_prebuffer_size).await
|
||||
download
|
||||
.wait_until_min_size(self.min_prebuffer_size)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Prebuffering failed: {}", e))?;
|
||||
tracing::debug!("Prebuffering complete for pk {}", pk);
|
||||
}
|
||||
@@ -138,9 +144,15 @@ impl<C: CacheConfig> Cache<C> {
|
||||
async fn finalize_download(&self, pk: &str, download: Arc<Download>) -> Result<String> {
|
||||
// Attendre le prébuffering (pour le cache progressif)
|
||||
if self.min_prebuffer_size > 0 {
|
||||
download.wait_until_min_size(self.min_prebuffer_size).await
|
||||
download
|
||||
.wait_until_min_size(self.min_prebuffer_size)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Prebuffering failed: {}", e))?;
|
||||
tracing::debug!("Prebuffering complete for pk {} ({} bytes)", pk, self.min_prebuffer_size);
|
||||
tracing::debug!(
|
||||
"Prebuffering complete for pk {} ({} bytes)",
|
||||
pk,
|
||||
self.min_prebuffer_size
|
||||
);
|
||||
}
|
||||
|
||||
// Lancer une tâche de nettoyage et marquage de complétion en background
|
||||
@@ -155,7 +167,11 @@ impl<C: CacheConfig> Cache<C> {
|
||||
// Créer le fichier marker de complétion si le téléchargement a réussi
|
||||
if result.is_ok() {
|
||||
if let Err(e) = std::fs::write(&completion_marker, "") {
|
||||
tracing::warn!("Failed to create completion marker for pk {}: {}", pk_clone, e);
|
||||
tracing::warn!(
|
||||
"Failed to create completion marker for pk {}: {}",
|
||||
pk_clone,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
tracing::debug!("Created completion marker for pk {}", pk_clone);
|
||||
}
|
||||
@@ -368,7 +384,8 @@ impl<C: CacheConfig> Cache<C> {
|
||||
where
|
||||
R: AsyncRead + Send + Unpin + 'static,
|
||||
{
|
||||
self.add_from_reader_with_pk(source_uri, reader, length, collection, None).await
|
||||
self.add_from_reader_with_pk(source_uri, reader, length, collection, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Ajoute un fichier à partir d'un flux avec un pk explicite optionnel.
|
||||
|
||||
@@ -163,20 +163,29 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
|
||||
}
|
||||
|
||||
if !file_path.exists() {
|
||||
tracing::warn!("is_valid_pk({}): File not created after 1s despite DB entry existing", pk);
|
||||
tracing::warn!(
|
||||
"is_valid_pk({}): File not created after 1s despite DB entry existing",
|
||||
pk
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
tracing::debug!("is_valid_pk({}): File created after {}ms", pk, attempts * 10);
|
||||
tracing::debug!(
|
||||
"is_valid_pk({}): File created after {}ms",
|
||||
pk,
|
||||
attempts * 10
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier d'abord si le marker de completion existe
|
||||
let completion_marker = file_path.with_extension(
|
||||
format!("{}.complete", C::file_extension())
|
||||
);
|
||||
let completion_marker =
|
||||
file_path.with_extension(format!("{}.complete", C::file_extension()));
|
||||
|
||||
if completion_marker.exists() {
|
||||
tracing::debug!("is_valid_pk({}): Completion marker found, file is complete", pk);
|
||||
tracing::debug!(
|
||||
"is_valid_pk({}): Completion marker found, file is complete",
|
||||
pk
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -190,7 +199,11 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
|
||||
tracing::debug!("is_valid_pk({}): No marker but file is recent ({}s), download in progress", pk, age_secs);
|
||||
return true;
|
||||
} else {
|
||||
tracing::debug!("is_valid_pk({}): No marker and file is old ({}s), incomplete download", pk, age_secs);
|
||||
tracing::debug!(
|
||||
"is_valid_pk({}): No marker and file is old ({}s), incomplete download",
|
||||
pk,
|
||||
age_secs
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -198,7 +211,10 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
|
||||
}
|
||||
|
||||
// Ne peut pas vérifier le statut - rejeter par sécurité
|
||||
tracing::debug!("is_valid_pk({}): Could not check file status, rejecting", pk);
|
||||
tracing::debug!(
|
||||
"is_valid_pk({}): Could not check file status, rejecting",
|
||||
pk
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,14 +295,28 @@ fn test_metadata_types() {
|
||||
db.add(pk, None, None).unwrap();
|
||||
|
||||
// Tester les différents types de métadonnées
|
||||
db.set_a_metadata(pk, "string_val", Value::String("test".to_string())).unwrap();
|
||||
db.set_a_metadata(pk, "string_val", Value::String("test".to_string()))
|
||||
.unwrap();
|
||||
db.set_a_metadata(pk, "number_val", json!(42)).unwrap();
|
||||
db.set_a_metadata(pk, "bool_val", Value::Bool(true)).unwrap();
|
||||
db.set_a_metadata(pk, "bool_val", Value::Bool(true))
|
||||
.unwrap();
|
||||
db.set_a_metadata(pk, "null_val", Value::Null).unwrap();
|
||||
|
||||
// Vérifier les valeurs
|
||||
assert_eq!(db.get_metadata_value(pk, "string_val").unwrap(), Some(Value::String("test".to_string())));
|
||||
assert_eq!(db.get_metadata_value(pk, "number_val").unwrap(), Some(json!(42)));
|
||||
assert_eq!(db.get_metadata_value(pk, "bool_val").unwrap(), Some(Value::Bool(true)));
|
||||
assert_eq!(db.get_metadata_value(pk, "null_val").unwrap(), Some(Value::Null));
|
||||
assert_eq!(
|
||||
db.get_metadata_value(pk, "string_val").unwrap(),
|
||||
Some(Value::String("test".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_metadata_value(pk, "number_val").unwrap(),
|
||||
Some(json!(42))
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_metadata_value(pk, "bool_val").unwrap(),
|
||||
Some(Value::Bool(true))
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_metadata_value(pk, "null_val").unwrap(),
|
||||
Some(Value::Null)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use image::{ImageBuffer, Rgba};
|
||||
use pmocovers::cache;
|
||||
use tempfile::TempDir;
|
||||
use image::{ImageBuffer, Rgba};
|
||||
|
||||
fn create_test_cache() -> (TempDir, cache::Cache) {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
@@ -19,8 +19,11 @@ fn create_test_image(width: u32, height: u32) -> Vec<u8> {
|
||||
});
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
img.write_to(
|
||||
&mut std::io::Cursor::new(&mut buffer),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap();
|
||||
buffer
|
||||
}
|
||||
|
||||
|
||||
@@ -90,8 +90,11 @@ async fn test_generate_variant() {
|
||||
// Créer et ajouter une image
|
||||
let img = create_test_image(400, 400);
|
||||
let mut buffer = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
img.write_to(
|
||||
&mut std::io::Cursor::new(&mut buffer),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let test_file = tempfile::NamedTempFile::with_suffix(".png").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
|
||||
let img = create_test_image(400, 400);
|
||||
let mut buffer = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
img.write_to(
|
||||
&mut std::io::Cursor::new(&mut buffer),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let test_file = tempfile::NamedTempFile::with_suffix(".png").unwrap();
|
||||
std::fs::write(test_file.path(), &buffer).unwrap();
|
||||
|
||||
@@ -255,10 +255,9 @@ where
|
||||
// Try to extract genre and track_number from extra fields
|
||||
let extra = metadata.get_extra().await.ok().flatten();
|
||||
let genre = extra.as_ref().and_then(|e| e.get("genre").cloned());
|
||||
let track_number = extra.as_ref().and_then(|e| {
|
||||
e.get("track_number")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
});
|
||||
let track_number = extra
|
||||
.as_ref()
|
||||
.and_then(|e| e.get("track_number").and_then(|s| s.parse::<u32>().ok()));
|
||||
|
||||
Some(ExtractedMetadata {
|
||||
title,
|
||||
@@ -457,8 +456,7 @@ unsafe fn setup_metadata(
|
||||
|
||||
// Set the metadata on the encoder
|
||||
let mut metadata_array = [meta];
|
||||
let set_success =
|
||||
FLAC__stream_encoder_set_metadata(encoder, metadata_array.as_mut_ptr(), 1);
|
||||
let set_success = FLAC__stream_encoder_set_metadata(encoder, metadata_array.as_mut_ptr(), 1);
|
||||
|
||||
if set_success == 0 {
|
||||
return Err(FlacError::LibFlacInit(
|
||||
|
||||
12
pmoparadise/.pmomusic/config.yaml
Normal file
12
pmoparadise/.pmomusic/config.yaml
Normal 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
|
||||
@@ -84,10 +84,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Block Information:");
|
||||
println!(" Event ID: {}", block.event);
|
||||
println!(" Songs: {}", block.song_count());
|
||||
println!(
|
||||
" Duration: {:.1} minutes",
|
||||
block.length as f64 / 60000.0
|
||||
);
|
||||
println!(" Duration: {:.1} minutes", block.length as f64 / 60000.0);
|
||||
println!();
|
||||
|
||||
// Afficher la liste des pistes
|
||||
|
||||
@@ -46,7 +46,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.add_directive("pmoaudio_ext=debug".parse()?)
|
||||
.add_directive("pmoplaylist=debug".parse()?)
|
||||
.add_directive("pmoparadise=debug".parse()?)
|
||||
.add_directive("pmoaudiocache=debug".parse()?)
|
||||
.add_directive("pmoaudiocache=debug".parse()?),
|
||||
)
|
||||
.init();
|
||||
|
||||
@@ -89,7 +89,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialiser les caches et le gestionnaire de playlist
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let base_dir = std::env::var("PMO_CONFIG_DIR").unwrap_or_else(|_| "/tmp/pmomusic_test".to_string());
|
||||
let base_dir =
|
||||
std::env::var("PMO_CONFIG_DIR").unwrap_or_else(|_| "/tmp/pmomusic_test".to_string());
|
||||
std::fs::create_dir_all(&base_dir)?;
|
||||
|
||||
tracing::info!("Initializing caches in: {}", base_dir);
|
||||
@@ -130,8 +131,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing::info!("Creating playlist: {}", playlist_id);
|
||||
|
||||
// Créer une playlist éphémère (non persistante) pour cet exemple
|
||||
let writer = playlist_manager.get_write_handle(playlist_id.clone()).await?;
|
||||
writer.set_title(format!("Radio Paradise - Channel {}", channel_id)).await?;
|
||||
let writer = playlist_manager
|
||||
.get_write_handle(playlist_id.clone())
|
||||
.await?;
|
||||
writer
|
||||
.set_title(format!("Radio Paradise - Channel {}", channel_id))
|
||||
.await?;
|
||||
writer.flush().await?; // Vider la playlist si elle existait
|
||||
tracing::debug!("Playlist created and flushed");
|
||||
|
||||
@@ -178,7 +183,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Créer la source Radio Paradise
|
||||
let mut download_source = RadioParadiseStreamSource::new(client);
|
||||
download_source.push_block_id(block.event);
|
||||
tracing::debug!("RadioParadiseStreamSource created with block {}", block.event);
|
||||
tracing::debug!(
|
||||
"RadioParadiseStreamSource created with block {}",
|
||||
block.event
|
||||
);
|
||||
|
||||
// Créer le sink de cache FLAC
|
||||
let mut cache_sink = FlacCacheSink::new(audio_cache.clone(), cover_cache.clone());
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
//!
|
||||
//! Architecture:
|
||||
//! ```text
|
||||
//! RadioParadiseStreamSource → TimerNode → StreamingFlacSink
|
||||
//! ↓
|
||||
//! StreamHandle
|
||||
//! ↓
|
||||
//! pmoserver (Axum)
|
||||
//! ↓
|
||||
//! VLC / Media Player Client
|
||||
//! RadioParadiseStreamSource → TimerBufferNode → StreamingFlacSink
|
||||
//! ↓
|
||||
//! StreamHandle
|
||||
//! ↓
|
||||
//! pmoserver (Axum)
|
||||
//! ↓
|
||||
//! VLC / Media Player Client
|
||||
//! ```
|
||||
//!
|
||||
//! Usage:
|
||||
@@ -38,11 +38,11 @@ use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use pmoaudio::{AudioPipelineNode, TimerNode};
|
||||
use pmoaudio::{AudioPipelineNode, TimerBufferNode};
|
||||
use pmoaudio_ext::{StreamingFlacSink, StreamingOggFlacSink};
|
||||
use pmoflac::EncoderOptions;
|
||||
use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource, END_OF_BLOCKS_SIGNAL};
|
||||
use pmoserver::{ServerBuilder, init_logging};
|
||||
use pmoserver::{init_logging, ServerBuilder};
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
use tokio_util::io::ReaderStream;
|
||||
@@ -210,25 +210,40 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut source_flac = RadioParadiseStreamSource::new(client.clone());
|
||||
source_flac.push_block_id(block.event);
|
||||
source_flac.push_block_id(END_OF_BLOCKS_SIGNAL); // Signal: no more blocks after this one
|
||||
tracing::debug!("RadioParadiseStreamSource (FLAC) created with block {} + END signal", block.event);
|
||||
tracing::debug!(
|
||||
"RadioParadiseStreamSource (FLAC) created with block {} + END signal",
|
||||
block.event
|
||||
);
|
||||
|
||||
// Use SMALL channel size to make backpressure more reactive
|
||||
// Instead of trying to buffer 3s of audio (60 chunks), use a much smaller buffer
|
||||
// This forces tighter backpressure control
|
||||
let max_lead_time = 3.0;
|
||||
let channel_size = 8; // Small buffer for reactive backpressure
|
||||
tracing::debug!("Using channel size: {} chunks ({:.1}s buffer at 50ms/chunk)", channel_size, channel_size as f64 * 0.05);
|
||||
let buffer_sec = 10.0;
|
||||
let max_lead_time = buffer_sec;
|
||||
let channel_size = 512;
|
||||
tracing::debug!(
|
||||
"Using channel size: {} chunks ({:.1}s buffer à 50ms/chunk)",
|
||||
channel_size,
|
||||
channel_size as f64 * 0.05
|
||||
);
|
||||
|
||||
let mut timer_flac = TimerNode::with_channel_size(max_lead_time, channel_size);
|
||||
tracing::debug!("TimerNode (FLAC) created with {:.1}s max lead time, {} chunk buffer", max_lead_time, channel_size);
|
||||
let mut timer_flac = TimerBufferNode::with_channel_size(buffer_sec, channel_size);
|
||||
tracing::debug!(
|
||||
"TimerBufferNode (FLAC) created with {:.1}s buffer, {} chunk queue",
|
||||
buffer_sec,
|
||||
channel_size
|
||||
);
|
||||
|
||||
// StreamingFlacSink doesn't take channel_size - it uses bits_per_sample (16, 24, or 32)
|
||||
let (streaming_sink, stream_handle) = StreamingFlacSink::new(encoder_options.clone(), 16);
|
||||
let (streaming_sink, stream_handle) =
|
||||
StreamingFlacSink::with_max_broadcast_lead(encoder_options.clone(), 16, max_lead_time);
|
||||
tracing::debug!("StreamingFlacSink created");
|
||||
|
||||
timer_flac.register(Box::new(streaming_sink));
|
||||
source_flac.register(Box::new(timer_flac));
|
||||
tracing::info!("Pipeline 1 connected: RadioParadiseStreamSource → TimerNode → StreamingFlacSink");
|
||||
tracing::info!(
|
||||
"Pipeline 1 connected: RadioParadiseStreamSource → TimerBufferNode → StreamingFlacSink"
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Pipeline 2: OGG-FLAC streaming
|
||||
@@ -237,18 +252,29 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut source_ogg = RadioParadiseStreamSource::new(client);
|
||||
source_ogg.push_block_id(block.event);
|
||||
source_ogg.push_block_id(END_OF_BLOCKS_SIGNAL); // Signal: no more blocks after this one
|
||||
tracing::debug!("RadioParadiseStreamSource (OGG) created with block {} + END signal", block.event);
|
||||
tracing::debug!(
|
||||
"RadioParadiseStreamSource (OGG) created with block {} + END signal",
|
||||
block.event
|
||||
);
|
||||
|
||||
let mut timer_ogg = TimerNode::with_channel_size(max_lead_time, channel_size);
|
||||
tracing::debug!("TimerNode (OGG) created with {:.1}s max lead time, {} chunk buffer", max_lead_time, channel_size);
|
||||
let mut timer_ogg = TimerBufferNode::with_channel_size(buffer_sec, channel_size);
|
||||
tracing::debug!(
|
||||
"TimerBufferNode (OGG) created with {:.1}s buffer, {} chunk queue",
|
||||
buffer_sec,
|
||||
channel_size
|
||||
);
|
||||
|
||||
// StreamingOggFlacSink doesn't take channel_size - it uses bits_per_sample (16, 24, or 32)
|
||||
let (ogg_sink, ogg_handle) = StreamingOggFlacSink::new(encoder_options, 16);
|
||||
let (ogg_sink, ogg_handle) =
|
||||
StreamingOggFlacSink::with_max_broadcast_lead(encoder_options, 16, max_lead_time);
|
||||
tracing::debug!("StreamingOggFlacSink created");
|
||||
|
||||
timer_ogg.register(Box::new(ogg_sink));
|
||||
source_ogg.register(Box::new(timer_ogg));
|
||||
tracing::info!("Pipeline 2 connected: RadioParadiseStreamSource → TimerNode → StreamingOggFlacSink");
|
||||
|
||||
tracing::info!(
|
||||
"Pipeline 2 connected: RadioParadiseStreamSource → TimerBufferNode → StreamingOggFlacSink"
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Setup pmoserver with streaming routes
|
||||
@@ -256,8 +282,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
tracing::info!("Setting up pmoserver...");
|
||||
|
||||
let mut server = ServerBuilder::new("RadioParadiseStreamTest", "http://localhost", 8080)
|
||||
.build();
|
||||
let mut server =
|
||||
ServerBuilder::new("RadioParadiseStreamTest", "http://localhost", 8080).build();
|
||||
|
||||
let app_state = Arc::new(AppState {
|
||||
stream_handle,
|
||||
@@ -265,12 +291,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
|
||||
// Add streaming routes
|
||||
server.add_handler_with_state("/test/stream", stream_handler, app_state.clone()).await;
|
||||
server.add_handler_with_state("/test/stream-icy", stream_icy_handler, app_state.clone()).await;
|
||||
server.add_handler_with_state("/test/stream-ogg", stream_ogg_handler, app_state.clone()).await;
|
||||
server
|
||||
.add_handler_with_state("/test/stream", stream_handler, app_state.clone())
|
||||
.await;
|
||||
server
|
||||
.add_handler_with_state("/test/stream-icy", stream_icy_handler, app_state.clone())
|
||||
.await;
|
||||
server
|
||||
.add_handler_with_state("/test/stream-ogg", stream_ogg_handler, app_state.clone())
|
||||
.await;
|
||||
|
||||
// Add metadata route
|
||||
server.add_handler_with_state("/test/metadata", metadata_handler, app_state.clone()).await;
|
||||
server
|
||||
.add_handler_with_state("/test/metadata", metadata_handler, app_state.clone())
|
||||
.await;
|
||||
|
||||
// Add health check
|
||||
server.add_handler("/test/health", health_handler).await;
|
||||
|
||||
@@ -140,7 +140,8 @@ impl RadioParadiseClient {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("bitrate", "4") // FLAC lossless
|
||||
.append_pair("info", "true")
|
||||
.append_pair("channel", &self.channel.to_string());
|
||||
// RP API expects `chan` rather than `channel` for channel selection.
|
||||
.append_pair("chan", &self.channel.to_string());
|
||||
|
||||
if let Some(event_id) = event {
|
||||
url.query_pairs_mut()
|
||||
|
||||
@@ -91,13 +91,15 @@ impl NodeStats {
|
||||
/// Enregistre l'envoi d'un segment
|
||||
pub fn record_segment_sent(&self, bytes: usize) {
|
||||
self.segments_sent.fetch_add(1, Ordering::Relaxed);
|
||||
self.bytes_processed.fetch_add(bytes as u64, Ordering::Relaxed);
|
||||
self.bytes_processed
|
||||
.fetch_add(bytes as u64, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Enregistre un événement de backpressure
|
||||
pub fn record_backpressure(&self, duration_ms: u64) {
|
||||
self.backpressure_blocks.fetch_add(1, Ordering::Relaxed);
|
||||
self.backpressure_time_ms.fetch_add(duration_ms, Ordering::Relaxed);
|
||||
self.backpressure_time_ms
|
||||
.fetch_add(duration_ms, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Retourne un rapport formaté des statistiques
|
||||
@@ -112,7 +114,11 @@ impl NodeStats {
|
||||
let first_ts = self.first_segment_timestamp.load(Ordering::Relaxed);
|
||||
let last_ts = self.last_segment_timestamp.load(Ordering::Relaxed);
|
||||
|
||||
let first_ts_sec = if first_ts == u64::MAX { 0.0 } else { first_ts as f64 / 1000.0 };
|
||||
let first_ts_sec = if first_ts == u64::MAX {
|
||||
0.0
|
||||
} else {
|
||||
first_ts as f64 / 1000.0
|
||||
};
|
||||
let last_ts_sec = last_ts as f64 / 1000.0;
|
||||
let audio_duration = last_ts_sec - first_ts_sec;
|
||||
|
||||
@@ -126,12 +132,27 @@ impl NodeStats {
|
||||
Audio: {:.1}s (first: {:.1}s, last: {:.1}s) | Real-time ratio: {:.1}%\n\
|
||||
Backpressure: {} blocks, {:.2}s total ({:.1}% of time)",
|
||||
self.name,
|
||||
elapsed, received, sent, received.saturating_sub(sent),
|
||||
mb, throughput_mbps,
|
||||
audio_duration, first_ts_sec, last_ts_sec,
|
||||
if audio_duration > 0.0 { (elapsed / audio_duration) * 100.0 } else { 0.0 },
|
||||
bp_blocks, bp_time_ms as f64 / 1000.0,
|
||||
if elapsed > 0.0 { (bp_time_ms as f64 / 1000.0 / elapsed) * 100.0 } else { 0.0 }
|
||||
elapsed,
|
||||
received,
|
||||
sent,
|
||||
received.saturating_sub(sent),
|
||||
mb,
|
||||
throughput_mbps,
|
||||
audio_duration,
|
||||
first_ts_sec,
|
||||
last_ts_sec,
|
||||
if audio_duration > 0.0 {
|
||||
(elapsed / audio_duration) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
bp_blocks,
|
||||
bp_time_ms as f64 / 1000.0,
|
||||
if elapsed > 0.0 {
|
||||
(bp_time_ms as f64 / 1000.0 / elapsed) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,9 @@ impl RadioParadiseStreamSourceLogic {
|
||||
block.length as f64 / 60000.0,
|
||||
block.url
|
||||
);
|
||||
let response = self.client.client
|
||||
let response = self
|
||||
.client
|
||||
.client
|
||||
.get(&block.url)
|
||||
.timeout(self.client.block_timeout)
|
||||
.send()
|
||||
@@ -125,9 +127,9 @@ impl RadioParadiseStreamSourceLogic {
|
||||
|
||||
// Créer un stream reader
|
||||
tracing::debug!("Creating byte stream reader");
|
||||
let byte_stream = response.bytes_stream().map(|result| {
|
||||
result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||
});
|
||||
let byte_stream = response
|
||||
.bytes_stream()
|
||||
.map(|result| result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)));
|
||||
let stream_reader = StreamReader::new(byte_stream);
|
||||
tracing::debug!("Stream reader created");
|
||||
|
||||
@@ -140,7 +142,11 @@ impl RadioParadiseStreamSourceLogic {
|
||||
let stream_info = decoder.info().clone();
|
||||
let sample_rate = stream_info.sample_rate;
|
||||
let bits_per_sample = stream_info.bits_per_sample;
|
||||
tracing::debug!("FLAC decoder initialized: {}Hz, {} bits/sample", sample_rate, bits_per_sample);
|
||||
tracing::debug!(
|
||||
"FLAC decoder initialized: {}Hz, {} bits/sample",
|
||||
sample_rate,
|
||||
bits_per_sample
|
||||
);
|
||||
|
||||
// Préparer les songs ordonnées pour tracking
|
||||
let songs = block.songs_ordered();
|
||||
@@ -165,13 +171,16 @@ impl RadioParadiseStreamSourceLogic {
|
||||
// Envoyer TrackBoundary pour la première song AVANT le premier chunk audio
|
||||
// Même si son elapsed > 0, cela garantit que FlacCacheSink a des métadonnées
|
||||
// dès le début (sinon il attendrait indéfiniment un TrackBoundary)
|
||||
let mut next_song: Option<(usize, &Song)> = if let Some((idx, song)) = songs.get(0).copied() {
|
||||
tracing::debug!("Sending TrackBoundary for first song (idx={}, elapsed={}ms) at timestamp 0",
|
||||
idx, song.elapsed);
|
||||
let mut next_song: Option<(usize, &Song)> = if let Some((idx, song)) = songs.get(0).copied()
|
||||
{
|
||||
tracing::debug!(
|
||||
"Sending TrackBoundary for first song (idx={}, elapsed={}ms) at timestamp 0",
|
||||
idx,
|
||||
song.elapsed
|
||||
);
|
||||
let metadata = song_to_metadata(song, block).await;
|
||||
let track_boundary = AudioSegment::new_track_boundary(
|
||||
*order,
|
||||
0.0, // timestamp = 0 au début du stream
|
||||
*order, 0.0, // timestamp = 0 au début du stream
|
||||
metadata,
|
||||
);
|
||||
self.send_to_children(output, track_boundary).await?;
|
||||
@@ -183,7 +192,6 @@ impl RadioParadiseStreamSourceLogic {
|
||||
};
|
||||
tracing::debug!("Starting audio chunk loop");
|
||||
|
||||
|
||||
// Buffer pour lecture
|
||||
let bytes_per_sample = (bits_per_sample / 8) as usize;
|
||||
let frame_bytes = bytes_per_sample * 2; // stereo
|
||||
@@ -196,6 +204,7 @@ impl RadioParadiseStreamSourceLogic {
|
||||
let mut chunk_count = 0;
|
||||
let mut total_bytes_decoded = 0u64;
|
||||
let expected_duration_sec = block.length as f64 / 1000.0;
|
||||
let mut stats_last_log = Instant::now();
|
||||
|
||||
loop {
|
||||
// Vérifier stop_token
|
||||
@@ -213,7 +222,9 @@ impl RadioParadiseStreamSourceLogic {
|
||||
|
||||
// Remplir le buffer
|
||||
if pending.len() < chunk_byte_len {
|
||||
let read = decoder.read(&mut read_buf).await
|
||||
let read = decoder
|
||||
.read(&mut read_buf)
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Read error: {}", e)))?;
|
||||
|
||||
if read == 0 {
|
||||
@@ -263,22 +274,35 @@ impl RadioParadiseStreamSourceLogic {
|
||||
);
|
||||
let metadata = song_to_metadata(song, block).await;
|
||||
let timestamp_sec = total_samples as f64 / sample_rate as f64;
|
||||
let track_boundary = AudioSegment::new_track_boundary(
|
||||
*order,
|
||||
timestamp_sec,
|
||||
metadata,
|
||||
);
|
||||
let track_boundary =
|
||||
AudioSegment::new_track_boundary(*order, timestamp_sec, metadata);
|
||||
self.send_to_children(output, track_boundary).await?;
|
||||
|
||||
// Passer à la song suivante
|
||||
song_index += 1;
|
||||
next_song = songs.get(song_index).copied();
|
||||
tracing::debug!("Moved to next song, song_index={}, next_song present={}", song_index, next_song.is_some());
|
||||
tracing::debug!(
|
||||
"Moved to next song, song_index={}, next_song present={}",
|
||||
song_index,
|
||||
next_song.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Envoyer le chunk audio
|
||||
let timestamp_sec = total_samples as f64 / sample_rate as f64;
|
||||
if stats_last_log.elapsed() >= Duration::from_secs(1) {
|
||||
let real_elapsed = start_instant.elapsed().as_secs_f64();
|
||||
tracing::debug!(
|
||||
"RP timing: chunk={} ts={:.3}s real_elapsed={:.3}s delta={:.3}s chunk_len={} frames",
|
||||
chunk_count,
|
||||
timestamp_sec,
|
||||
real_elapsed,
|
||||
timestamp_sec - real_elapsed,
|
||||
chunk_len
|
||||
);
|
||||
stats_last_log = Instant::now();
|
||||
}
|
||||
let audio_segment = pcm_to_audio_segment(
|
||||
&pcm_data,
|
||||
*order,
|
||||
@@ -295,7 +319,11 @@ impl RadioParadiseStreamSourceLogic {
|
||||
|
||||
// Retourner le timestamp du dernier chunk (durée totale du bloc) et l'instant de début
|
||||
let final_timestamp = total_samples as f64 / sample_rate as f64;
|
||||
tracing::debug!("Block decode complete: {} samples, {:.2}s duration", total_samples, final_timestamp);
|
||||
tracing::debug!(
|
||||
"Block decode complete: {} samples, {:.2}s duration",
|
||||
total_samples,
|
||||
final_timestamp
|
||||
);
|
||||
|
||||
Ok((final_timestamp, start_instant))
|
||||
}
|
||||
@@ -312,7 +340,9 @@ impl RadioParadiseStreamSourceLogic {
|
||||
let capacity_before = tx.capacity();
|
||||
tracing::trace!(
|
||||
"send_to_children: Sending to child {} (channel capacity={}, timestamp={:.3}s)",
|
||||
i, capacity_before, segment.timestamp_sec
|
||||
i,
|
||||
capacity_before,
|
||||
segment.timestamp_sec
|
||||
);
|
||||
|
||||
let send_start = std::time::Instant::now();
|
||||
@@ -325,8 +355,11 @@ impl RadioParadiseStreamSourceLogic {
|
||||
let duration_ms = send_duration.as_millis() as u64;
|
||||
self.stats.record_backpressure(duration_ms);
|
||||
tracing::debug!(
|
||||
"send_to_children: Send to child {} BLOCKED for {:.3}s (backpressure triggered, timestamp={:.3}s)",
|
||||
i, send_duration.as_secs_f64(), segment.timestamp_sec
|
||||
"send_to_children: Send to child {} BLOCKED for {:.3}s (channel capacity before send={}, timestamp={:.3}s)",
|
||||
i,
|
||||
send_duration.as_secs_f64(),
|
||||
capacity_before,
|
||||
segment.timestamp_sec
|
||||
);
|
||||
}
|
||||
|
||||
@@ -519,7 +552,10 @@ impl NodeLogic for RadioParadiseStreamSourceLogic {
|
||||
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
tracing::debug!("RadioParadiseStreamSource::process() started, block_queue has {} items", self.block_queue.len());
|
||||
tracing::debug!(
|
||||
"RadioParadiseStreamSource::process() started, block_queue has {} items",
|
||||
self.block_queue.len()
|
||||
);
|
||||
for (i, event_id) in self.block_queue.iter().enumerate() {
|
||||
tracing::debug!(" block_queue[{}] = {}", i, event_id);
|
||||
}
|
||||
@@ -544,7 +580,9 @@ impl NodeLogic for RadioParadiseStreamSourceLogic {
|
||||
|
||||
// Vérifier si c'est le signal de fin
|
||||
if id == END_OF_BLOCKS_SIGNAL {
|
||||
tracing::info!("Received END_OF_BLOCKS_SIGNAL, finishing after current block");
|
||||
tracing::info!(
|
||||
"Received END_OF_BLOCKS_SIGNAL, finishing after current block"
|
||||
);
|
||||
break None;
|
||||
}
|
||||
|
||||
@@ -573,10 +611,10 @@ impl NodeLogic for RadioParadiseStreamSourceLogic {
|
||||
|
||||
// Récupérer les métadonnées du bloc
|
||||
tracing::debug!("Fetching block metadata for event_id {}...", event_id);
|
||||
let block = self.client
|
||||
.get_block(Some(event_id))
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Failed to get block: {}", e)))?;
|
||||
let block =
|
||||
self.client.get_block(Some(event_id)).await.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to get block: {}", e))
|
||||
})?;
|
||||
tracing::debug!("Block metadata received: url={}", block.url);
|
||||
|
||||
// Marquer comme téléchargé
|
||||
@@ -584,15 +622,24 @@ impl NodeLogic for RadioParadiseStreamSourceLogic {
|
||||
|
||||
// Télécharger et décoder le bloc
|
||||
tracing::info!("Starting download and decode for block {}...", event_id);
|
||||
let (block_duration, start_instant) = self.download_and_decode_block(&block, &output, &stop_token, &mut order)
|
||||
let (block_duration, start_instant) = self
|
||||
.download_and_decode_block(&block, &output, &stop_token, &mut order)
|
||||
.await?;
|
||||
last_timestamp = block_duration;
|
||||
last_start_instant = Some(start_instant);
|
||||
tracing::info!("Finished download and decode for block {} (duration: {:.2}s)", event_id, block_duration);
|
||||
tracing::info!(
|
||||
"Finished download and decode for block {} (duration: {:.2}s)",
|
||||
event_id,
|
||||
block_duration
|
||||
);
|
||||
}
|
||||
|
||||
// Envoyer EndOfStream avec le timestamp du dernier chunk
|
||||
tracing::info!("Sending EndOfStream with timestamp {:.2}s to {} outputs", last_timestamp, output.len());
|
||||
tracing::info!(
|
||||
"Sending EndOfStream with timestamp {:.2}s to {} outputs",
|
||||
last_timestamp,
|
||||
output.len()
|
||||
);
|
||||
let eos = AudioSegment::new_end_of_stream(order, last_timestamp);
|
||||
for tx in &output {
|
||||
tx.send(eos.clone())
|
||||
@@ -692,7 +739,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cache_fifo_basic() {
|
||||
let client = create_test_client();
|
||||
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
let mut logic =
|
||||
RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
|
||||
// Ajouter 5 blocs
|
||||
for i in 1..=5 {
|
||||
@@ -709,7 +757,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cache_fifo_exactly_10_elements() {
|
||||
let client = create_test_client();
|
||||
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
let mut logic =
|
||||
RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
|
||||
// Ajouter exactement 10 blocs
|
||||
for i in 1..=10 {
|
||||
@@ -717,7 +766,11 @@ mod tests {
|
||||
}
|
||||
|
||||
// Vérifier qu'on a exactement 10 éléments
|
||||
assert_eq!(logic.recent_blocks.len(), 10, "Cache should have exactly 10 elements");
|
||||
assert_eq!(
|
||||
logic.recent_blocks.len(),
|
||||
10,
|
||||
"Cache should have exactly 10 elements"
|
||||
);
|
||||
|
||||
// Tous devraient être dans le cache
|
||||
for i in 1..=10 {
|
||||
@@ -728,7 +781,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cache_fifo_eviction_oldest() {
|
||||
let client = create_test_client();
|
||||
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
let mut logic =
|
||||
RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
|
||||
// Remplir le cache avec 10 éléments (1..=10)
|
||||
for i in 1..=10 {
|
||||
@@ -739,10 +793,17 @@ mod tests {
|
||||
logic.mark_block_downloaded(11);
|
||||
|
||||
// Le cache doit toujours avoir 10 éléments
|
||||
assert_eq!(logic.recent_blocks.len(), 10, "Cache should still have 10 elements");
|
||||
assert_eq!(
|
||||
logic.recent_blocks.len(),
|
||||
10,
|
||||
"Cache should still have 10 elements"
|
||||
);
|
||||
|
||||
// Le premier (plus ancien) doit avoir été évincé
|
||||
assert!(!logic.is_recent_block(1), "Oldest block (1) should be evicted");
|
||||
assert!(
|
||||
!logic.is_recent_block(1),
|
||||
"Oldest block (1) should be evicted"
|
||||
);
|
||||
|
||||
// Les éléments 2..=11 doivent être présents
|
||||
for i in 2..=11 {
|
||||
@@ -753,7 +814,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cache_fifo_multiple_evictions() {
|
||||
let client = create_test_client();
|
||||
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
let mut logic =
|
||||
RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
|
||||
// Remplir avec 10 éléments
|
||||
for i in 1..=10 {
|
||||
@@ -766,7 +828,11 @@ mod tests {
|
||||
}
|
||||
|
||||
// Toujours 10 éléments
|
||||
assert_eq!(logic.recent_blocks.len(), 10, "Cache should have 10 elements");
|
||||
assert_eq!(
|
||||
logic.recent_blocks.len(),
|
||||
10,
|
||||
"Cache should have 10 elements"
|
||||
);
|
||||
|
||||
// Les 5 premiers doivent avoir été évincés
|
||||
for i in 1..=5 {
|
||||
@@ -782,7 +848,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cache_never_exceeds_capacity() {
|
||||
let client = create_test_client();
|
||||
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
let mut logic =
|
||||
RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
|
||||
// Vérifier la capacité pré-allouée
|
||||
assert_eq!(logic.recent_blocks.capacity(), RECENT_BLOCKS_CACHE_SIZE);
|
||||
@@ -812,7 +879,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_cache_fifo_order_preserved() {
|
||||
let client = create_test_client();
|
||||
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
let mut logic =
|
||||
RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
|
||||
// Ajouter 10 éléments
|
||||
for i in 1..=10 {
|
||||
@@ -830,7 +898,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_block_queue_push() {
|
||||
let client = create_test_client();
|
||||
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
let mut logic =
|
||||
RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
|
||||
// Tester push_block_id
|
||||
logic.push_block_id(100);
|
||||
|
||||
@@ -58,8 +58,7 @@ impl RadioParadiseSource {
|
||||
#[cfg(feature = "server")]
|
||||
pub fn from_registry(_client: RadioParadiseClient) -> Result<Self> {
|
||||
Err(MusicSourceError::SourceUnavailable(
|
||||
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead."
|
||||
.to_string(),
|
||||
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -139,8 +138,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
|
||||
async fn resolve_uri(&self, _object_id: &str) -> Result<String> {
|
||||
Err(MusicSourceError::SourceUnavailable(
|
||||
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead."
|
||||
.to_string(),
|
||||
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -150,8 +148,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
|
||||
async fn append_track(&self, _track: Item) -> Result<()> {
|
||||
Err(MusicSourceError::SourceUnavailable(
|
||||
"RadioParadiseSource is deprecated and does not support FIFO operations."
|
||||
.to_string(),
|
||||
"RadioParadiseSource is deprecated and does not support FIFO operations.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ async fn test_get_current_block() {
|
||||
.and(path("/api/get_block"))
|
||||
.and(query_param("bitrate", "4"))
|
||||
.and(query_param("info", "true"))
|
||||
.and(query_param("chan", "0"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(1234, 5678)))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
@@ -82,6 +83,7 @@ async fn test_get_specific_block() {
|
||||
.and(path("/api/get_block"))
|
||||
.and(query_param("bitrate", "4"))
|
||||
.and(query_param("info", "true"))
|
||||
.and(query_param("chan", "0"))
|
||||
.and(query_param("event", "5678"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(5678, 9012)))
|
||||
.mount(&mock_server)
|
||||
@@ -99,12 +101,38 @@ async fn test_get_specific_block() {
|
||||
assert_eq!(block.end_event, 9012);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_block_respects_channel() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/get_block"))
|
||||
.and(query_param("bitrate", "4"))
|
||||
.and(query_param("info", "true"))
|
||||
.and(query_param("chan", "2"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(2222, 3333)))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let client = RadioParadiseClient::builder()
|
||||
.api_base(format!("{}/api", mock_server.uri()))
|
||||
.channel(2)
|
||||
.build()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let block = client.get_block(None).await.unwrap();
|
||||
assert_eq!(block.event, 2222);
|
||||
assert_eq!(block.end_event, 3333);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_now_playing() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/get_block"))
|
||||
.and(query_param("chan", "0"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(1234, 5678)))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
@@ -133,6 +161,8 @@ async fn test_prefetch_next() {
|
||||
|
||||
// First block
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/get_block"))
|
||||
.and(query_param("chan", "0"))
|
||||
.and(query_param("event", "1234"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(1234, 5678)))
|
||||
.mount(&mock_server)
|
||||
@@ -140,6 +170,8 @@ async fn test_prefetch_next() {
|
||||
|
||||
// Next block
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/get_block"))
|
||||
.and(query_param("chan", "0"))
|
||||
.and(query_param("event", "5678"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_block_json(5678, 9012)))
|
||||
.mount(&mock_server)
|
||||
|
||||
@@ -23,11 +23,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::{
|
||||
Registry,
|
||||
filter::LevelFilter,
|
||||
layer::SubscriberExt,
|
||||
reload,
|
||||
util::SubscriberInitExt,
|
||||
Registry, filter::LevelFilter, layer::SubscriberExt, reload, util::SubscriberInitExt,
|
||||
};
|
||||
|
||||
/// Représente une entrée de log
|
||||
@@ -75,6 +71,11 @@ impl LogState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Définit le niveau initial avant tout rechargement dynamique
|
||||
pub fn set_initial_level(&self, level: Level) {
|
||||
*self.max_level.write().unwrap() = level;
|
||||
}
|
||||
|
||||
pub fn get_max_level(&self) -> Level {
|
||||
*self.max_level.read().unwrap()
|
||||
}
|
||||
@@ -266,15 +267,41 @@ impl Default for LoggingOptions {
|
||||
/// ```
|
||||
pub fn init_logging() -> LogState {
|
||||
let config = get_config();
|
||||
// Créer un filtre rechargeable qui commence à TRACE
|
||||
// Créer un filtre rechargeable qui commence au niveau déterminé par
|
||||
// RUST_LOG (prioritaire) ou la configuration.
|
||||
|
||||
let log_level = match config.get_log_min_level() {
|
||||
Ok(l) => match string_to_level(&l) {
|
||||
Some(lev) => level_to_levelfilter(lev),
|
||||
None => LevelFilter::TRACE,
|
||||
},
|
||||
Err(_) => LevelFilter::TRACE,
|
||||
let (initial_level, level_source) = match std::env::var("RUST_LOG") {
|
||||
Ok(value) => {
|
||||
let trimmed = value.trim();
|
||||
if let Some(level) = string_to_level(trimmed) {
|
||||
(level, format!("RUST_LOG ({})", trimmed))
|
||||
} else {
|
||||
eprintln!(
|
||||
"⚠️ Invalid RUST_LOG value '{}', falling back to configuration",
|
||||
value
|
||||
);
|
||||
let fallback = config
|
||||
.get_log_min_level()
|
||||
.ok()
|
||||
.and_then(|cfg| string_to_level(cfg.trim()))
|
||||
.unwrap_or(Level::TRACE);
|
||||
(fallback, "config".to_string())
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let level = config
|
||||
.get_log_min_level()
|
||||
.ok()
|
||||
.and_then(|cfg| string_to_level(cfg.trim()))
|
||||
.unwrap_or(Level::TRACE);
|
||||
(level, "config".to_string())
|
||||
}
|
||||
};
|
||||
let log_level = level_to_levelfilter(initial_level);
|
||||
eprintln!(
|
||||
"ℹ️ Initial log level set to {:?} (source: {})",
|
||||
initial_level, level_source
|
||||
);
|
||||
|
||||
let (filter, reload_handle) = reload::Layer::new(log_level);
|
||||
|
||||
@@ -285,6 +312,7 @@ pub fn init_logging() -> LogState {
|
||||
|
||||
// Créer le LogState avec le handle de rechargement
|
||||
let log_state = LogState::new(buffer_capacity, reload_handle);
|
||||
log_state.set_initial_level(initial_level);
|
||||
|
||||
// Construire le subscriber avec le filtre rechargeable AVANT le SseLayer
|
||||
// L'ordre est important : le filtre doit être appliqué en premier
|
||||
|
||||
@@ -55,11 +55,10 @@ pub fn get_audio_cache() -> Option<Arc<AudioCache>> {
|
||||
/// ```
|
||||
pub fn build_cover_url(pk: &str, size: Option<usize>) -> anyhow::Result<String> {
|
||||
// Récupérer l'URL de base depuis la variable d'environnement ou une config
|
||||
let base_url = std::env::var("PMO_SERVER_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
let base_url =
|
||||
std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
|
||||
let cache = get_cover_cache()
|
||||
.ok_or_else(|| anyhow::anyhow!("No registered cover cache"))?;
|
||||
let cache = get_cover_cache().ok_or_else(|| anyhow::anyhow!("No registered cover cache"))?;
|
||||
|
||||
let param = match size {
|
||||
Some(size_) => Some(size_.to_string()),
|
||||
@@ -86,11 +85,10 @@ pub fn build_cover_url(pk: &str, size: Option<usize>) -> anyhow::Result<String>
|
||||
/// ```
|
||||
pub fn build_audio_url(pk: &str, param: Option<&str>) -> anyhow::Result<String> {
|
||||
// Récupérer l'URL de base depuis la variable d'environnement ou une config
|
||||
let base_url = std::env::var("PMO_SERVER_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
let base_url =
|
||||
std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
|
||||
let cache = get_audio_cache()
|
||||
.ok_or_else(|| anyhow::anyhow!("No registered audio cache"))?;
|
||||
let cache = get_audio_cache().ok_or_else(|| anyhow::anyhow!("No registered audio cache"))?;
|
||||
|
||||
let route = cache.route_for(pk, param);
|
||||
Ok(format!("{}{}", base_url, route))
|
||||
|
||||
@@ -60,7 +60,9 @@ impl UpnpObject for Service {
|
||||
|
||||
// eventSubURL
|
||||
let mut event_sub_url = Element::new("eventSubURL");
|
||||
event_sub_url.children.push(XMLNode::Text(self.event_route()));
|
||||
event_sub_url
|
||||
.children
|
||||
.push(XMLNode::Text(self.event_route()));
|
||||
elem.children.push(XMLNode::Element(event_sub_url));
|
||||
|
||||
elem
|
||||
|
||||
Reference in New Issue
Block a user