From 1c2d30cbe9c49160d89d8dff8ff4b274d37bc1ae Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 14 Nov 2025 10:43:53 +0100 Subject: [PATCH] debuggage des stream --- Cargo.lock | 449 ++++++++++++------ pmoaudio-ext/src/sinks/broadcast_pacing.rs | 108 +++++ pmoaudio-ext/src/sinks/flac_cache_sink.rs | 73 ++- pmoaudio-ext/src/sinks/flac_frame_utils.rs | 4 +- pmoaudio-ext/src/sinks/mod.rs | 9 +- pmoaudio-ext/src/sinks/streaming_flac_sink.rs | 213 +++++++-- .../src/sinks/streaming_ogg_flac_sink.rs | 268 ++++++++--- pmoaudio-ext/src/sources/playlist_source.rs | 92 ++-- pmoaudio/examples/check_flac_bits.rs | 4 +- pmoaudio/examples/convert_to_flac24.rs | 5 +- pmoaudio/examples/play_audio.rs | 4 +- pmoaudio/examples/play_with_resampling.rs | 10 +- pmoaudio/src/audio_chunk.rs | 8 +- pmoaudio/src/dsp/int_float.rs | 18 +- pmoaudio/src/lib.rs | 1 + pmoaudio/src/nodes/audio_sink.rs | 74 +-- pmoaudio/src/nodes/file_source.rs | 24 +- pmoaudio/src/nodes/flac_file_sink.rs | 75 +-- pmoaudio/src/nodes/http_source.rs | 58 +-- pmoaudio/src/nodes/mod.rs | 1 + pmoaudio/src/nodes/resampling_node.rs | 30 +- pmoaudio/src/nodes/timer_buffer_node.rs | 357 ++++++++++++++ pmoaudio/src/nodes/timer_node.rs | 81 +++- pmoaudio/src/pipeline.rs | 166 ++++--- pmoaudio/src/sync_marker.rs | 9 +- pmoaudiocache/src/track_metadata.rs | 41 +- pmocache/src/cache.rs | 31 +- pmocache/src/cache_trait.rs | 32 +- pmocache/tests/test_db.rs | 26 +- pmocovers/tests/test_cache.rs | 9 +- pmocovers/tests/test_webp.rs | 14 +- pmoflac/src/encoder.rs | 10 +- pmoparadise/.pmomusic/config.yaml | 12 + pmoparadise/examples/download_block.rs | 5 +- pmoparadise/examples/play_and_cache.rs | 18 +- pmoparadise/examples/stream_block.rs | 90 ++-- pmoparadise/src/client.rs | 3 +- pmoparadise/src/node_stats.rs | 39 +- .../src/radio_paradise_stream_source.rs | 153 ++++-- pmoparadise/src/source.rs | 9 +- pmoparadise/tests/integration_tests.rs | 32 ++ pmoserver/src/logs/mod.rs | 52 +- pmoupnp/src/cache_registry.rs | 14 +- pmoupnp/src/services/service_methods.rs | 4 +- 44 files changed, 2018 insertions(+), 717 deletions(-) create mode 100644 pmoaudio-ext/src/sinks/broadcast_pacing.rs create mode 100644 pmoaudio/src/nodes/timer_buffer_node.rs create mode 100644 pmoparadise/.pmomusic/config.yaml diff --git a/Cargo.lock b/Cargo.lock index 0cee647c..c07fcb80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/pmoaudio-ext/src/sinks/broadcast_pacing.rs b/pmoaudio-ext/src/sinks/broadcast_pacing.rs new file mode 100644 index 00000000..f0f409fd --- /dev/null +++ b/pmoaudio-ext/src/sinks/broadcast_pacing.rs @@ -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) -> 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(()) + } +} diff --git a/pmoaudio-ext/src/sinks/flac_cache_sink.rs b/pmoaudio-ext/src/sinks/flac_cache_sink.rs index 50add148..dba70812 100755 --- a/pmoaudio-ext/src/sinks/flac_cache_sink.rs +++ b/pmoaudio-ext/src/sinks/flac_cache_sink.rs @@ -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 => { diff --git a/pmoaudio-ext/src/sinks/flac_frame_utils.rs b/pmoaudio-ext/src/sinks/flac_frame_utils.rs index 99145761..2b9a5bb1 100644 --- a/pmoaudio-ext/src/sinks/flac_frame_utils.rs +++ b/pmoaudio-ext/src/sinks/flac_frame_utils.rs @@ -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 diff --git a/pmoaudio-ext/src/sinks/mod.rs b/pmoaudio-ext/src/sinks/mod.rs index e8241678..07b68315 100755 --- a/pmoaudio-ext/src/sinks/mod.rs +++ b/pmoaudio-ext/src/sinks/mod.rs @@ -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}; diff --git a/pmoaudio-ext/src/sinks/streaming_flac_sink.rs b/pmoaudio-ext/src/sinks/streaming_flac_sink.rs index 16cd6ea3..95cdb56f 100644 --- a/pmoaudio-ext/src/sinks/streaming_flac_sink.rs +++ b/pmoaudio-ext/src/sinks/streaming_flac_sink.rs @@ -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>>, encoder_state: Option, sample_rate: Option, + 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, header_cache: Arc>>, current_timestamp: Arc>, + 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 { diff --git a/pmoaudio-ext/src/sinks/streaming_ogg_flac_sink.rs b/pmoaudio-ext/src/sinks/streaming_ogg_flac_sink.rs index 54e751a0..89ca8743 100644 --- a/pmoaudio-ext/src/sinks/streaming_ogg_flac_sink.rs +++ b/pmoaudio-ext/src/sinks/streaming_ogg_flac_sink.rs @@ -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>>, encoder_state: Option, sample_rate: Option, + 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, header_cache: Arc>>, current_timestamp: Arc>, + 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::(); 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 = flac_accumulator.drain(0..second_frame_start).collect(); + let first_frame: Vec = + 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 Result> 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, 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, 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, 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, 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 { + fn create_page( + &mut self, + packet_data: &[u8], + is_bos: bool, + is_eos: bool, + is_continuation: bool, + ) -> Vec { 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(); diff --git a/pmoaudio-ext/src/sources/playlist_source.rs b/pmoaudio-ext/src/sources/playlist_source.rs index 5bca30a9..960b6830 100644 --- a/pmoaudio-ext/src/sources/playlist_source.rs +++ b/pmoaudio-ext/src/sources/playlist_source.rs @@ -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, - stop_token: CancellationToken, - ) -> Result<(), AudioError> { + async fn run(self: Box, 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"), } } diff --git a/pmoaudio/examples/check_flac_bits.rs b/pmoaudio/examples/check_flac_bits.rs index af56a809..675a91ae 100755 --- a/pmoaudio/examples/check_flac_bits.rs +++ b/pmoaudio/examples/check_flac_bits.rs @@ -6,7 +6,9 @@ use tokio::fs::File; #[tokio::main] async fn main() -> Result<(), Box> { - let path_str = std::env::args().nth(1).expect("Usage: check_flac_bits "); + let path_str = std::env::args() + .nth(1) + .expect("Usage: check_flac_bits "); let path = Path::new(&path_str); println!("Checking: {}", path.display()); diff --git a/pmoaudio/examples/convert_to_flac24.rs b/pmoaudio/examples/convert_to_flac24.rs index 97e563cd..b27b7bb0 100755 --- a/pmoaudio/examples/convert_to_flac24.rs +++ b/pmoaudio/examples/convert_to_flac24.rs @@ -88,7 +88,10 @@ async fn main() -> Result<(), Box> { 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!(); diff --git a/pmoaudio/examples/play_audio.rs b/pmoaudio/examples/play_audio.rs index 3a1cfa70..7578d1c4 100644 --- a/pmoaudio/examples/play_audio.rs +++ b/pmoaudio/examples/play_audio.rs @@ -45,7 +45,9 @@ async fn main() -> Result<(), Box> { // 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(); }); diff --git a/pmoaudio/examples/play_with_resampling.rs b/pmoaudio/examples/play_with_resampling.rs index b23fa792..2b856672 100644 --- a/pmoaudio/examples/play_with_resampling.rs +++ b/pmoaudio/examples/play_with_resampling.rs @@ -52,8 +52,10 @@ async fn main() -> Result<(), Box> { 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> { // 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(); }); diff --git a/pmoaudio/src/audio_chunk.rs b/pmoaudio/src/audio_chunk.rs index 88dfe268..941a9f04 100755 --- a/pmoaudio/src/audio_chunk.rs +++ b/pmoaudio/src/audio_chunk.rs @@ -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]])), } } diff --git a/pmoaudio/src/dsp/int_float.rs b/pmoaudio/src/dsp/int_float.rs index 15a6ccee..3bcfc355 100755 --- a/pmoaudio/src/dsp/int_float.rs +++ b/pmoaudio/src/dsp/int_float.rs @@ -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); } diff --git a/pmoaudio/src/lib.rs b/pmoaudio/src/lib.rs index dd3c9a6c..838d94a4 100755 --- a/pmoaudio/src/lib.rs +++ b/pmoaudio/src/lib.rs @@ -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, }; diff --git a/pmoaudio/src/nodes/audio_sink.rs b/pmoaudio/src/nodes/audio_sink.rs index e71798d1..4ba91f19 100644 --- a/pmoaudio/src/nodes/audio_sink.rs +++ b/pmoaudio/src/nodes/audio_sink.rs @@ -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, - stop_token: CancellationToken, - ) -> Result<(), AudioError> { + async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { Box::new(self.inner).run(stop_token).await } } diff --git a/pmoaudio/src/nodes/file_source.rs b/pmoaudio/src/nodes/file_source.rs index 84cc4616..c0bddeb9 100755 --- a/pmoaudio/src/nodes/file_source.rs +++ b/pmoaudio/src/nodes/file_source.rs @@ -41,7 +41,11 @@ impl NodeLogic for FileSourceLogic { output: Vec>>, 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, - stop_token: CancellationToken, - ) -> Result<(), AudioError> { + async fn run(self: Box, 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 { // FileSource est une source, elle ne consomme pas d'audio @@ -464,11 +464,7 @@ mod tests { impl TestCollectorNode { fn new(test_tx: mpsc::Sender>) -> Self { let (tx, rx) = mpsc::channel(16); - Self { - tx, - rx, - test_tx, - } + Self { tx, rx, test_tx } } } diff --git a/pmoaudio/src/nodes/flac_file_sink.rs b/pmoaudio/src/nodes/flac_file_sink.rs index 0d7783ec..8b22d8d3 100755 --- a/pmoaudio/src/nodes/flac_file_sink.rs +++ b/pmoaudio/src/nodes/flac_file_sink.rs @@ -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>( - base_path: P, - channel_size: usize, - ) -> Self { + pub fn with_channel_size>(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>, stop_token: &CancellationToken, -) -> Result<(Arc, Option>>), AudioError> { +) -> Result< + ( + Arc, + Option>>, + ), + AudioError, +> { let mut track_metadata: Option>> = 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, - stop_token: CancellationToken, - ) -> Result<(), AudioError> { + async fn run(self: Box, 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 { diff --git a/pmoaudio/src/nodes/http_source.rs b/pmoaudio/src/nodes/http_source.rs index dec9ffd1..1ec55a1c 100755 --- a/pmoaudio/src/nodes/http_source.rs +++ b/pmoaudio/src/nodes/http_source.rs @@ -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, - stop_token: CancellationToken, - ) -> Result<(), AudioError> { + async fn run(self: Box, 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" + ); } } diff --git a/pmoaudio/src/nodes/mod.rs b/pmoaudio/src/nodes/mod.rs index 877073bf..ddb694cf 100755 --- a/pmoaudio/src/nodes/mod.rs +++ b/pmoaudio/src/nodes/mod.rs @@ -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 diff --git a/pmoaudio/src/nodes/resampling_node.rs b/pmoaudio/src/nodes/resampling_node.rs index cc2723cf..397ebaa3 100644 --- a/pmoaudio/src/nodes/resampling_node.rs +++ b/pmoaudio/src/nodes/resampling_node.rs @@ -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, - stop_token: CancellationToken, - ) -> Result<(), AudioError> { + async fn run(self: Box, 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"); diff --git a/pmoaudio/src/nodes/timer_buffer_node.rs b/pmoaudio/src/nodes/timer_buffer_node.rs new file mode 100644 index 00000000..2468733c --- /dev/null +++ b/pmoaudio/src/nodes/timer_buffer_node.rs @@ -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, + /// Buffer FIFO de segments avec leurs durées + buffer: VecDeque<(Arc, 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, +} + +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>], + ) -> 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>>, + output: Vec>>, + 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 +// ═══════════════════════════════════════════════════════════════════════════ + +pub struct TimerBufferNode { + inner: Node, +} + +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>> { + self.inner.get_tx() + } + + fn register(&mut self, child: Box) { + self.inner.register(child); + } + + async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { + Box::new(self.inner).run(stop_token).await + } +} + +impl TypedAudioNode for TimerBufferNode { + fn input_type(&self) -> Option { + // Accepte n'importe quel type + Some(TypeRequirement::any()) + } + + fn output_type(&self) -> Option { + // Passthrough: produit le même type qu'il consomme + Some(TypeRequirement::any()) + } +} diff --git a/pmoaudio/src/nodes/timer_node.rs b/pmoaudio/src/nodes/timer_node.rs index 6e872907..eb10f999 100644 --- a/pmoaudio/src/nodes/timer_node.rs +++ b/pmoaudio/src/nodes/timer_node.rs @@ -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, + /// Nombre de chunks traités (pour instrumentation) + chunk_count: u64, + /// Dernier log d'instrumentation + last_stats_log: Option, } 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()) } } + diff --git a/pmoaudio/src/pipeline.rs b/pmoaudio/src/pipeline.rs index 4c8b9594..3610463f 100755 --- a/pmoaudio/src/pipeline.rs +++ b/pmoaudio/src/pipeline.rs @@ -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, - stop_token: CancellationToken, - ) -> Result<(), AudioError>; + async fn run(self: Box, 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 AudioPipelineNode for Node { self.children.push(child); } - async fn run( - mut self: Box, - stop_token: CancellationToken, - ) -> Result<(), AudioError> { + async fn run(mut self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { let Node { mut logic, rx, @@ -528,9 +522,7 @@ impl AudioPipelineNode for Node { 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 AudioPipelineNode for Node { // 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 AudioPipelineNode for Node { // 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É diff --git a/pmoaudio/src/sync_marker.rs b/pmoaudio/src/sync_marker.rs index d187cc92..57ba0ec0 100755 --- a/pmoaudio/src/sync_marker.rs +++ b/pmoaudio/src/sync_marker.rs @@ -4,8 +4,13 @@ use tokio::sync::RwLock; use pmometadata::TrackMetadata; pub enum SyncMarker { - TrackBoundary { metadata: Arc> }, - StreamMetadata { key: String, value: String }, + TrackBoundary { + metadata: Arc>, + }, + StreamMetadata { + key: String, + value: String, + }, TopZeroSync, Heartbeat, EndOfStream, diff --git a/pmoaudiocache/src/track_metadata.rs b/pmoaudiocache/src/track_metadata.rs index 83a04a94..af9f2e92 100644 --- a/pmoaudiocache/src/track_metadata.rs +++ b/pmoaudiocache/src/track_metadata.rs @@ -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()); + } } } diff --git a/pmocache/src/cache.rs b/pmocache/src/cache.rs index 4ba635da..3833ff95 100755 --- a/pmocache/src/cache.rs +++ b/pmocache/src/cache.rs @@ -70,7 +70,8 @@ pub struct Cache { impl Cache { /// 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 Cache { }; 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 Cache { async fn finalize_download(&self, pk: &str, download: Arc) -> Result { // 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 Cache { // 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 Cache { 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. diff --git a/pmocache/src/cache_trait.rs b/pmocache/src/cache_trait.rs index 0874414b..edd0f6d3 100644 --- a/pmocache/src/cache_trait.rs +++ b/pmocache/src/cache_trait.rs @@ -163,20 +163,29 @@ pub trait FileCache: 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: 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: 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 } } diff --git a/pmocache/tests/test_db.rs b/pmocache/tests/test_db.rs index 1066e7a2..0a42c844 100644 --- a/pmocache/tests/test_db.rs +++ b/pmocache/tests/test_db.rs @@ -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) + ); } diff --git a/pmocovers/tests/test_cache.rs b/pmocovers/tests/test_cache.rs index 7c95acf2..da0ec543 100644 --- a/pmocovers/tests/test_cache.rs +++ b/pmocovers/tests/test_cache.rs @@ -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 { }); 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 } diff --git a/pmocovers/tests/test_webp.rs b/pmocovers/tests/test_webp.rs index 8bb7b1a2..4d70f665 100644 --- a/pmocovers/tests/test_webp.rs +++ b/pmocovers/tests/test_webp.rs @@ -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(); diff --git a/pmoflac/src/encoder.rs b/pmoflac/src/encoder.rs index 2383e71d..444de9b9 100755 --- a/pmoflac/src/encoder.rs +++ b/pmoflac/src/encoder.rs @@ -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::().ok()) - }); + let track_number = extra + .as_ref() + .and_then(|e| e.get("track_number").and_then(|s| s.parse::().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( diff --git a/pmoparadise/.pmomusic/config.yaml b/pmoparadise/.pmomusic/config.yaml new file mode 100644 index 00000000..ae069315 --- /dev/null +++ b/pmoparadise/.pmomusic/config.yaml @@ -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 diff --git a/pmoparadise/examples/download_block.rs b/pmoparadise/examples/download_block.rs index 47cdeda8..a5d5f3bb 100644 --- a/pmoparadise/examples/download_block.rs +++ b/pmoparadise/examples/download_block.rs @@ -84,10 +84,7 @@ async fn main() -> Result<(), Box> { 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 diff --git a/pmoparadise/examples/play_and_cache.rs b/pmoparadise/examples/play_and_cache.rs index 52fe6f5c..862d8446 100644 --- a/pmoparadise/examples/play_and_cache.rs +++ b/pmoparadise/examples/play_and_cache.rs @@ -46,7 +46,7 @@ async fn main() -> Result<(), Box> { .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> { // 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> { 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> { // 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()); diff --git a/pmoparadise/examples/stream_block.rs b/pmoparadise/examples/stream_block.rs index 5447ff80..7ecc1887 100644 --- a/pmoparadise/examples/stream_block.rs +++ b/pmoparadise/examples/stream_block.rs @@ -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> { 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> { 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> { 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> { }); // 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; diff --git a/pmoparadise/src/client.rs b/pmoparadise/src/client.rs index 6c9725e1..a2aef0a2 100644 --- a/pmoparadise/src/client.rs +++ b/pmoparadise/src/client.rs @@ -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() diff --git a/pmoparadise/src/node_stats.rs b/pmoparadise/src/node_stats.rs index 2bc0b27b..758985a7 100644 --- a/pmoparadise/src/node_stats.rs +++ b/pmoparadise/src/node_stats.rs @@ -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 + } ) } } diff --git a/pmoparadise/src/radio_paradise_stream_source.rs b/pmoparadise/src/radio_paradise_stream_source.rs index 0f72bd7f..c5bf5f7e 100644 --- a/pmoparadise/src/radio_paradise_stream_source.rs +++ b/pmoparadise/src/radio_paradise_stream_source.rs @@ -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>>, 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); diff --git a/pmoparadise/src/source.rs b/pmoparadise/src/source.rs index 946af700..e9e91328 100644 --- a/pmoparadise/src/source.rs +++ b/pmoparadise/src/source.rs @@ -58,8 +58,7 @@ impl RadioParadiseSource { #[cfg(feature = "server")] pub fn from_registry(_client: RadioParadiseClient) -> Result { 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 { 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(), )) } diff --git a/pmoparadise/tests/integration_tests.rs b/pmoparadise/tests/integration_tests.rs index b14577ea..5173cc97 100644 --- a/pmoparadise/tests/integration_tests.rs +++ b/pmoparadise/tests/integration_tests.rs @@ -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) diff --git a/pmoserver/src/logs/mod.rs b/pmoserver/src/logs/mod.rs index 80a4b3e2..d0259d47 100644 --- a/pmoserver/src/logs/mod.rs +++ b/pmoserver/src/logs/mod.rs @@ -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 diff --git a/pmoupnp/src/cache_registry.rs b/pmoupnp/src/cache_registry.rs index ee5a2840..913381ca 100644 --- a/pmoupnp/src/cache_registry.rs +++ b/pmoupnp/src/cache_registry.rs @@ -55,11 +55,10 @@ pub fn get_audio_cache() -> Option> { /// ``` pub fn build_cover_url(pk: &str, size: Option) -> anyhow::Result { // 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) -> anyhow::Result /// ``` pub fn build_audio_url(pk: &str, param: Option<&str>) -> anyhow::Result { // 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)) diff --git a/pmoupnp/src/services/service_methods.rs b/pmoupnp/src/services/service_methods.rs index 3d3e5374..6595260c 100644 --- a/pmoupnp/src/services/service_methods.rs +++ b/pmoupnp/src/services/service_methods.rs @@ -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