From 83eca866a87a76ab79608dc7318118625de73d04 Mon Sep 17 00:00:00 2001 From: Ayoub-glitsh Date: Fri, 24 Apr 2026 19:23:53 +0100 Subject: [PATCH 1/2] fix #807: add --packet-encoding option to roc-send The --rate flag only sets the input device sample rate, but the packet encoding defaults to L16_Stereo at 44100 Hz. When input rate differs from packet encoding rate, the sender creates a resampler even if the user doesn't want one. Add --packet-encoding option that lets users register a custom RTP encoding and select it for outgoing packets. This allows sending at 48kHz (or any rate) without resampling: roc-send --rate=48000 --packet-encoding=96:s16/48000/stereo ... The option accepts the format '://', e.g.: 96:s16/48000/stereo 97:s16/48000/mono The encoding is registered in the context encoding map and the sender is configured to use it as the packet encoding. --- src/tools/roc_send/cmdline.ggo | 3 +++ src/tools/roc_send/main.cpp | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/tools/roc_send/cmdline.ggo b/src/tools/roc_send/cmdline.ggo index 90e48d170..94fbde67e 100644 --- a/src/tools/roc_send/cmdline.ggo +++ b/src/tools/roc_send/cmdline.ggo @@ -50,6 +50,9 @@ section "Options" option "rate" - "Override input sample rate, Hz" int optional + option "packet-encoding" - "Register custom packet encoding (e.g. \"96:s16/48000/stereo\")" + typestr="ENCODING" string optional + option "latency-backend" - "Which latency to use in latency tuner" values="niq" default="niq" enum optional diff --git a/src/tools/roc_send/main.cpp b/src/tools/roc_send/main.cpp index ca3af5dd0..551c3ce96 100644 --- a/src/tools/roc_send/main.cpp +++ b/src/tools/roc_send/main.cpp @@ -20,6 +20,7 @@ #include "roc_node/context.h" #include "roc_node/sender.h" #include "roc_pipeline/sender_sink.h" +#include "roc_rtp/encoding.h" #include "roc_sndio/backend_dispatcher.h" #include "roc_sndio/backend_map.h" #include "roc_sndio/print_supported.h" @@ -268,6 +269,22 @@ int main(int argc, char** argv) { return 1; } + if (args.packet_encoding_given) { + rtp::Encoding enc; + if (!rtp::parse_encoding(args.packet_encoding_arg, enc)) { + roc_log(LogError, + "invalid --packet-encoding: bad format," + " expected \"://\"," + " e.g. \"96:s16/48000/stereo\""); + return 1; + } + if (!context.encoding_map().add_encoding(enc)) { + roc_log(LogError, "invalid --packet-encoding: failed to register encoding"); + return 1; + } + sender_config.payload_type = enc.payload_type; + } + sndio::BackendDispatcher backend_dispatcher(context.arena()); if (args.list_supported_given) { if (!address::print_supported(context.arena())) { From 61fe66044fe6d4f77fa3c75412bf2a02d83b6ae7 Mon Sep 17 00:00:00 2001 From: Ayoub-glitsh Date: Fri, 24 Apr 2026 19:51:32 +0100 Subject: [PATCH 2/2] test #764: add unit tests for LatencyTuner Tests cover: - Initialization: valid/invalid configs, intact vs tuning profiles - Bounds checking: latency at target, above max, below min, stalling suppression, no-metrics-yet behavior, intact profile ignores bounds - Scaling: no scaling before first interval, near-1.0 at target, above-1.0 when latency high, below-1.0 when latency low, clamped to scaling_tolerance, intact profile never produces scaling - E2E backend: no update without e2e metrics, terminates on out-of-bounds --- src/tests/roc_audio/test_latency_tuner.cpp | 361 +++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 src/tests/roc_audio/test_latency_tuner.cpp diff --git a/src/tests/roc_audio/test_latency_tuner.cpp b/src/tests/roc_audio/test_latency_tuner.cpp new file mode 100644 index 000000000..1c249974e --- /dev/null +++ b/src/tests/roc_audio/test_latency_tuner.cpp @@ -0,0 +1,361 @@ +/* + * Copyright (c) 2024 Roc Streaming authors + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +#include + +#include "roc_audio/latency_tuner.h" +#include "roc_core/time.h" +#include "roc_packet/units.h" + +namespace roc { +namespace audio { + +namespace { + +enum { + SampleRate = 44100, + NumCh = 2, + ChMask = 0x3 +}; + +const SampleSpec sample_spec(SampleRate, + Sample_RawFormat, + ChanLayout_Surround, + ChanOrder_Smpte, + ChMask); + +// Build a fully-specified config with explicit values so tests are deterministic. +LatencyConfig make_config(core::nanoseconds_t target, + core::nanoseconds_t tolerance, + LatencyTunerProfile profile = LatencyTunerProfile_Gradual, + LatencyTunerBackend backend = LatencyTunerBackend_Niq) { + LatencyConfig config; + config.tuner_backend = backend; + config.tuner_profile = profile; + config.target_latency = target; + config.latency_tolerance = tolerance; + config.stale_tolerance = tolerance / 4; + config.scaling_interval = 5 * core::Millisecond; + config.scaling_tolerance = 0.005f; + return config; +} + +// Feed the tuner N times with the given niq_latency and advance stream by one +// scaling_interval worth of samples each time. +void feed_niq(LatencyTuner& tuner, + core::nanoseconds_t niq_latency, + size_t iterations, + core::nanoseconds_t step = 5 * core::Millisecond) { + LatencyMetrics lm; + lm.niq_latency = niq_latency; + packet::LinkMetrics link; + + const packet::stream_timestamp_t step_samples = + sample_spec.ns_2_stream_timestamp(step); + + for (size_t i = 0; i < iterations; i++) { + tuner.write_metrics(lm, link); + CHECK(tuner.update_stream()); + tuner.advance_stream(step_samples); + } +} + +} // namespace + +// --------------------------------------------------------------------------- +// Initialization +// --------------------------------------------------------------------------- + +TEST_GROUP(latency_tuner_init) {}; + +TEST(latency_tuner_init, intact_profile_no_target_needed) { + // Intact profile: no tuning, no bounds — target_latency may be zero. + LatencyConfig config; + config.tuner_backend = LatencyTunerBackend_Niq; + config.tuner_profile = LatencyTunerProfile_Intact; + + LatencyTuner tuner(config, sample_spec); + CHECK(tuner.is_valid()); +} + +TEST(latency_tuner_init, gradual_profile_valid_config) { + LatencyConfig config = + make_config(200 * core::Millisecond, 50 * core::Millisecond); + LatencyTuner tuner(config, sample_spec); + CHECK(tuner.is_valid()); +} + +TEST(latency_tuner_init, responsive_profile_valid_config) { + LatencyConfig config = make_config( + 200 * core::Millisecond, 50 * core::Millisecond, LatencyTunerProfile_Responsive); + LatencyTuner tuner(config, sample_spec); + CHECK(tuner.is_valid()); +} + +TEST(latency_tuner_init, invalid_negative_target_latency) { + LatencyConfig config = + make_config(200 * core::Millisecond, 50 * core::Millisecond); + config.target_latency = -1; + LatencyTuner tuner(config, sample_spec); + CHECK_FALSE(tuner.is_valid()); +} + +TEST(latency_tuner_init, invalid_negative_tolerance) { + LatencyConfig config = + make_config(200 * core::Millisecond, 50 * core::Millisecond); + config.latency_tolerance = -1; + LatencyTuner tuner(config, sample_spec); + CHECK_FALSE(tuner.is_valid()); +} + +// --------------------------------------------------------------------------- +// Bounds checking +// --------------------------------------------------------------------------- + +TEST_GROUP(latency_tuner_bounds) {}; + +TEST(latency_tuner_bounds, latency_at_target_stays_alive) { + LatencyConfig config = + make_config(200 * core::Millisecond, 50 * core::Millisecond); + LatencyTuner tuner(config, sample_spec); + CHECK(tuner.is_valid()); + + // Feed exactly target latency — should never go out of bounds. + feed_niq(tuner, 200 * core::Millisecond, 100); +} + +TEST(latency_tuner_bounds, latency_above_max_terminates) { + LatencyConfig config = + make_config(200 * core::Millisecond, 50 * core::Millisecond); + LatencyTuner tuner(config, sample_spec); + CHECK(tuner.is_valid()); + + // Feed latency well above target + tolerance (200 + 50 = 250ms). + LatencyMetrics lm; + lm.niq_latency = 400 * core::Millisecond; + packet::LinkMetrics link; + + tuner.write_metrics(lm, link); + CHECK_FALSE(tuner.update_stream()); +} + +TEST(latency_tuner_bounds, latency_below_min_terminates) { + LatencyConfig config = + make_config(200 * core::Millisecond, 50 * core::Millisecond); + LatencyTuner tuner(config, sample_spec); + CHECK(tuner.is_valid()); + + // Feed latency well below target - tolerance (200 - 50 = 150ms). + LatencyMetrics lm; + lm.niq_latency = 10 * core::Millisecond; + packet::LinkMetrics link; + + tuner.write_metrics(lm, link); + CHECK_FALSE(tuner.update_stream()); +} + +TEST(latency_tuner_bounds, stalling_suppresses_low_latency_termination) { + LatencyConfig config = + make_config(200 * core::Millisecond, 50 * core::Millisecond); + // stale_tolerance = 50ms / 4 = 12.5ms + LatencyTuner tuner(config, sample_spec); + CHECK(tuner.is_valid()); + + // Latency below min, but queue is stalling (burst drop scenario). + // Tuner should NOT terminate — it defers to watchdog. + LatencyMetrics lm; + lm.niq_latency = 10 * core::Millisecond; + lm.niq_stalling = 100 * core::Millisecond; // well above stale_tolerance + packet::LinkMetrics link; + + tuner.write_metrics(lm, link); + CHECK(tuner.update_stream()); // should survive +} + +TEST(latency_tuner_bounds, no_metrics_yet_does_not_terminate) { + // Before any metrics arrive, update_stream() should return true. + LatencyConfig config = + make_config(200 * core::Millisecond, 50 * core::Millisecond); + LatencyTuner tuner(config, sample_spec); + CHECK(tuner.is_valid()); + + CHECK(tuner.update_stream()); +} + +TEST(latency_tuner_bounds, intact_profile_ignores_out_of_bounds) { + // Intact profile has no bounds checking — should never terminate. + LatencyConfig config; + config.tuner_backend = LatencyTunerBackend_Niq; + config.tuner_profile = LatencyTunerProfile_Intact; + + LatencyTuner tuner(config, sample_spec); + CHECK(tuner.is_valid()); + + LatencyMetrics lm; + lm.niq_latency = 10000 * core::Millisecond; // absurdly high + packet::LinkMetrics link; + + tuner.write_metrics(lm, link); + CHECK(tuner.update_stream()); // intact profile never terminates +} + +// --------------------------------------------------------------------------- +// Scaling / freq coefficient +// --------------------------------------------------------------------------- + +TEST_GROUP(latency_tuner_scaling) {}; + +TEST(latency_tuner_scaling, no_scaling_before_first_interval) { + // fetch_scaling() returns 0 until the first scaling interval elapses. + LatencyConfig config = + make_config(200 * core::Millisecond, 50 * core::Millisecond); + LatencyTuner tuner(config, sample_spec); + CHECK(tuner.is_valid()); + + LatencyMetrics lm; + lm.niq_latency = 200 * core::Millisecond; + packet::LinkMetrics link; + + tuner.write_metrics(lm, link); + CHECK(tuner.update_stream()); + // No advance_stream yet — scaling interval not elapsed. + DOUBLES_EQUAL(0.0, (double)tuner.fetch_scaling(), 1e-6); +} + +TEST(latency_tuner_scaling, scaling_near_one_when_at_target) { + // When latency == target, freq_coeff should stay very close to 1.0. + LatencyConfig config = + make_config(200 * core::Millisecond, 50 * core::Millisecond); + LatencyTuner tuner(config, sample_spec); + CHECK(tuner.is_valid()); + + feed_niq(tuner, 200 * core::Millisecond, 200); + + const float scaling = tuner.fetch_scaling(); + if (scaling != 0) { + // Allow ±0.5% (scaling_tolerance). + CHECK(scaling >= 0.995f); + CHECK(scaling <= 1.005f); + } +} + +TEST(latency_tuner_scaling, scaling_above_one_when_latency_high) { + // When latency > target, freq_coeff should drift above 1.0 (speed up sender). + LatencyConfig config = + make_config(200 * core::Millisecond, 100 * core::Millisecond); + LatencyTuner tuner(config, sample_spec); + CHECK(tuner.is_valid()); + + // Feed latency significantly above target for many intervals. + feed_niq(tuner, 280 * core::Millisecond, 500); + + const float scaling = tuner.fetch_scaling(); + if (scaling != 0) { + CHECK(scaling > 1.0f); + } +} + +TEST(latency_tuner_scaling, scaling_below_one_when_latency_low) { + // When latency < target, freq_coeff should drift below 1.0 (slow down sender). + LatencyConfig config = + make_config(200 * core::Millisecond, 100 * core::Millisecond); + LatencyTuner tuner(config, sample_spec); + CHECK(tuner.is_valid()); + + feed_niq(tuner, 120 * core::Millisecond, 500); + + const float scaling = tuner.fetch_scaling(); + if (scaling != 0) { + CHECK(scaling < 1.0f); + } +} + +TEST(latency_tuner_scaling, scaling_clamped_to_tolerance) { + // Even with extreme latency, freq_coeff must stay within ±scaling_tolerance. + LatencyConfig config = + make_config(200 * core::Millisecond, 500 * core::Millisecond); + config.scaling_tolerance = 0.005f; + LatencyTuner tuner(config, sample_spec); + CHECK(tuner.is_valid()); + + feed_niq(tuner, 5000 * core::Millisecond, 1000); + + const float scaling = tuner.fetch_scaling(); + if (scaling != 0) { + CHECK(scaling <= 1.0f + config.scaling_tolerance); + CHECK(scaling >= 1.0f - config.scaling_tolerance); + } +} + +TEST(latency_tuner_scaling, intact_profile_never_produces_scaling) { + // Intact profile disables tuning — fetch_scaling() should always return 0. + LatencyConfig config; + config.tuner_backend = LatencyTunerBackend_Niq; + config.tuner_profile = LatencyTunerProfile_Intact; + + LatencyTuner tuner(config, sample_spec); + CHECK(tuner.is_valid()); + + LatencyMetrics lm; + lm.niq_latency = 500 * core::Millisecond; + packet::LinkMetrics link; + + const packet::stream_timestamp_t step = + sample_spec.ns_2_stream_timestamp(5 * core::Millisecond); + + for (size_t i = 0; i < 200; i++) { + tuner.write_metrics(lm, link); + tuner.update_stream(); + tuner.advance_stream(step); + DOUBLES_EQUAL(0.0, (double)tuner.fetch_scaling(), 1e-6); + } +} + +// --------------------------------------------------------------------------- +// E2E backend +// --------------------------------------------------------------------------- + +TEST_GROUP(latency_tuner_e2e) {}; + +TEST(latency_tuner_e2e, no_update_without_e2e_metrics) { + // E2E backend: if no e2e_latency has been reported, update_stream() returns true + // (no data yet, not an error). + LatencyConfig config = make_config(200 * core::Millisecond, + 50 * core::Millisecond, + LatencyTunerProfile_Gradual, + LatencyTunerBackend_E2e); + LatencyTuner tuner(config, sample_spec); + CHECK(tuner.is_valid()); + + // Only provide niq metrics — e2e backend should ignore them. + LatencyMetrics lm; + lm.niq_latency = 400 * core::Millisecond; // would be out of bounds for niq + packet::LinkMetrics link; + + tuner.write_metrics(lm, link); + CHECK(tuner.update_stream()); // no e2e data yet, should not terminate +} + +TEST(latency_tuner_e2e, terminates_on_e2e_out_of_bounds) { + LatencyConfig config = make_config(200 * core::Millisecond, + 50 * core::Millisecond, + LatencyTunerProfile_Gradual, + LatencyTunerBackend_E2e); + LatencyTuner tuner(config, sample_spec); + CHECK(tuner.is_valid()); + + LatencyMetrics lm; + lm.e2e_latency = 400 * core::Millisecond; // above max (200+50=250ms) + packet::LinkMetrics link; + + tuner.write_metrics(lm, link); + CHECK_FALSE(tuner.update_stream()); +} + +} // namespace audio +} // namespace roc