Latency slider: one slider now means one value, and a move arrives in seconds
Two bugs behind the 2026-08-14 field report ("the latency slider does nothing" — a WASAPI-only
machine, auto-tune off, a clap test showing no change either way, while Ed's own two-slider rig
audibly works). Both found by MEASURING, not reasoning: --latency-lab drives the real playout
with realtime-paced sender/device shapes and records actual buffered depth once a second.
1. The slider wrote to a value nothing read. ReconcileReplicasLocked tags every session with an
OUTPUT LANE (WasapiLane/AsioLane — never Mixed while a device is ticked), and the render path
resolved its target from that lane. But MainForm.MaxLatencyBoxRoute sends the single slider to
RenderRoute.Mixed in EVERY non-BothIndependent mode. So in classic modes the slider — and the
auto-tune with it — updated a value the audio never consumed, leaving the real target on
LaneLatency's 30 ms default for the whole session; raise AND lower equally inert (lower also
matched on route, so it skipped every playing session). Only BothIndependent worked, because
there the slider writes WasapiLane, which IS what its sessions read. That's precisely "works on
mine, dead on his". Fix: the lane tag says which DEVICE a session renders through, not which
knob governs it. One slider now means ONE value (sharedLatency) read by every session whatever
its lane; two sliders only in BothIndependent, seeded from the shared value on entry so audio
doesn't jump. Arming also uses the session's own route target (matters in BothIndependent).
Measured, classic path: -0.15ms/s (STALLED) -> +2.07ms/s (GROWS).
2. Even correctly wired, a raise crawled. Steady-state depth feedback is capped at 0.3% (~3ms of
catch-up per second) AND only recomputed at the 10s drift-window boundary, so a 400 ms raise
took over two minutes and still read as a dead slider (Ed: "it needs to change the value
quickly not over minutes"). A raise can't be met by dropping audio, only by banking it, so a
DELIBERATE move (the hard setter — never the auto-tune's soft one, leaving its parked descent
behaviour untouched) now engages a fast approach: 5% bias, recomputed 5x/sec, self-clearing
within 15 ms of target. Audible glide, no gap, no click — the user asked for the change and
hears it happen. Measured: 49ms -> 324ms within 10s, then holds.
Gate: new step pins the invariant fast and deterministically — what the slider sets IS what the
session's route reads, a lower reaches the session, and two-slider mode keeps its lanes separate.
74/74 + relay. --latency-lab (six scenarios) / --latency-lab classic (the faithful app path) stay
in the tree as the measuring harness.
NOT released. Test build for the reporting user: D:\Dropbox\remsound-latency-slider-test.zip
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -88,6 +88,11 @@ internal static class CommandLine
|
||||
return WithConsole(() => SelfTest.Run(args));
|
||||
case "--perftest": case "--perf-test":
|
||||
return WithConsole(() => RunPerfTest(args));
|
||||
case "--latency-lab":
|
||||
// Diagnostic harness (2026-08-14 latency-slider field report): drives the real
|
||||
// playout with realtime-paced sender/device shapes and MEASURES whether the
|
||||
// buffered depth converges to a raised target. Developer tool, ~8 min runtime.
|
||||
return WithConsole(() => LatencyLab.Run(args));
|
||||
case "--diagnostics": case "--diag":
|
||||
return WithConsole(() => RunDiagnostics(ValueAfter(args, raw)));
|
||||
case "--log":
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using RemSound.Core;
|
||||
using RemSound.Receiver;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Measured latency-slider lab (<c>--latency-lab</c>), built for the 2026-08-14 field report:
|
||||
/// raising the receive-latency slider audibly lengthens the delay on one machine and does nothing
|
||||
/// on another — both plain WASAPI, both ordinary consumer outputs, auto-tune off. The mechanism
|
||||
/// under test is SessionPlayout's depth-feedback resampler bias (raise = play ≤0.3% slow until the
|
||||
/// ring grows to target ≈ +3ms of depth per second). This drives the REAL playout objects with a
|
||||
/// realtime-paced producer (sender-shaped writes) and consumer (device-shaped reads), samples the
|
||||
/// actual buffered depth once a second, and prints growth rates — so the diagnosis rests on
|
||||
/// numbers from the shipped code, not on reasoning about it (the standing audio rule).
|
||||
///
|
||||
/// Scenarios vary the two things that differ between working and stalled rigs: the write shape
|
||||
/// (10ms PCM-ish vs 20ms Opus-frame-ish vs jittery arrival) and the read shape (clean 10ms
|
||||
/// callbacks vs chunky 21ms callbacks with periodic double-gulps). Tier 2 re-runs the key shapes
|
||||
/// through PlayoutEngine's Mixed-route Read — the exact single-slider plumbing a fresh install
|
||||
/// uses — in case the stall lives above SessionPlayout.
|
||||
/// </summary>
|
||||
internal static class LatencyLab
|
||||
{
|
||||
private const int SampleRate = 48000;
|
||||
private const int BytesPerFrame = 2 * sizeof(float); // stereo float mix bus
|
||||
private const int StartTargetMs = 30;
|
||||
private const int RaisedTargetMs = 330;
|
||||
private const int SettleSeconds = 15; // let the 10s drift window engage before judging
|
||||
private const int MeasureSeconds = 60; // growth window: expect ~+3ms/s => ~+180ms
|
||||
|
||||
public static int Run() => Run([]);
|
||||
|
||||
public static int Run(string[] args)
|
||||
{
|
||||
Console.WriteLine("RemSound latency lab - measuring depth-target convergence with the shipped playout code.");
|
||||
Console.WriteLine($"Raise under test: {StartTargetMs}ms -> {RaisedTargetMs}ms. Expected growth at full depth-bias: ~3ms/s.");
|
||||
Console.WriteLine();
|
||||
|
||||
var results = new List<string>();
|
||||
|
||||
// T3 — the FAITHFUL classic-mode app path (the field bug). Wires the engine exactly as the
|
||||
// running app does: CompositeRenderBackend marks the WASAPI lane active (a ticked WASAPI
|
||||
// output, no ASIO), which tags every session RenderRoute.WasapiLane; the single latency
|
||||
// slider in every non-BothIndependent mode drives RenderRoute.Mixed (MainForm.MaxLatencyBox-
|
||||
// Route). Run this alone with: --latency-lab classic
|
||||
if (args.Any(a => string.Equals(a, "classic", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
RunClassicAppPath(results, "T3 classic app path: WASAPI lane active, slider drives Mixed");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("=== SUMMARY ===");
|
||||
foreach (var line in results) Console.WriteLine(" " + line);
|
||||
return 0;
|
||||
}
|
||||
RunScenario(results, "T1 clean: write 10ms/10ms, read 10ms/10ms", writeMs: 10, writeJitterMs: 0, readMs: 10, gulpEvery: 0, engineMixed: false);
|
||||
RunScenario(results, "T1 opus-frame: write 20ms/20ms, read 10ms/10ms", writeMs: 20, writeJitterMs: 0, readMs: 10, gulpEvery: 0, engineMixed: false);
|
||||
RunScenario(results, "T1 gulpy device: write 20ms/20ms, read 21ms + 42ms gulp each ~1s", writeMs: 20, writeJitterMs: 0, readMs: 21, gulpEvery: 48, engineMixed: false);
|
||||
RunScenario(results, "T1 jittery net: write 20ms +/-15ms, read 10ms/10ms", writeMs: 20, writeJitterMs: 15, readMs: 10, gulpEvery: 0, engineMixed: false);
|
||||
RunScenario(results, "T2 engine-Mixed clean: write 20ms/20ms, read 10ms/10ms", writeMs: 20, writeJitterMs: 0, readMs: 10, gulpEvery: 0, engineMixed: true);
|
||||
RunScenario(results, "T2 engine-Mixed gulpy: write 20ms/20ms, read 21ms + 42ms gulp", writeMs: 20, writeJitterMs: 0, readMs: 21, gulpEvery: 48, engineMixed: true);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("=== SUMMARY ===");
|
||||
foreach (var line in results) Console.WriteLine(" " + line);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>The classic-mode reproduction: engine wired exactly as the shipped app wires it for a
|
||||
/// plain WASAPI setup, then the slider raised through the very call MainForm makes. Measures the
|
||||
/// buffered depth the same way as the other scenarios.</summary>
|
||||
private static void RunClassicAppPath(List<string> results, string name)
|
||||
{
|
||||
Console.WriteLine($"--- {name} ---");
|
||||
var endpoint = new IPEndPoint(IPAddress.Loopback, 47831);
|
||||
var engine = new PlayoutEngine(new ReceiverDiagnostics());
|
||||
|
||||
// 1. CompositeRenderBackend.SetOutputDevices: one WASAPI output ticked, no ASIO.
|
||||
engine.SetLaneActive(RenderRoute.WasapiLane, true);
|
||||
engine.SetLaneActive(RenderRoute.AsioLane, false);
|
||||
// 2. The slider's startup value, applied the way MainForm applies it in classic mode.
|
||||
engine.SetMaxLatencyMs(RenderRoute.Mixed, StartTargetMs);
|
||||
// 3. A peer's stream arrives — ReconcileReplicasLocked tags it with the active lane.
|
||||
var session = engine.GetOrCreateSession(endpoint, 1, capacityBytes: 4 * 1024 * 1024);
|
||||
Console.WriteLine($" session route after arrival: {session.Route} (slider writes to: {RenderRoute.Mixed})");
|
||||
|
||||
const int WriteMs = 20, ReadMs = 10;
|
||||
var stop = false;
|
||||
var writeBlock = new byte[WriteMs * SampleRate / 1000 * BytesPerFrame];
|
||||
FillSine(writeBlock);
|
||||
|
||||
var producer = new Thread(() =>
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var nextDueMs = 0.0;
|
||||
while (!Volatile.Read(ref stop))
|
||||
{
|
||||
session.Write(writeBlock);
|
||||
session.NoteFramesQueued(engine.TargetLatencyMs); // AudioReceiver's queued-callback lambda
|
||||
nextDueMs += WriteMs;
|
||||
var sleep = nextDueMs - sw.Elapsed.TotalMilliseconds;
|
||||
if (sleep > 0) Thread.Sleep((int)sleep);
|
||||
}
|
||||
}) { IsBackground = true, Name = "lab-producer" };
|
||||
|
||||
var readFrames = ReadMs * SampleRate / 1000;
|
||||
var readBytes = new byte[readFrames * BytesPerFrame];
|
||||
var consumer = new Thread(() =>
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var nextDueMs = 0.0;
|
||||
while (!Volatile.Read(ref stop))
|
||||
{
|
||||
engine.Read(readBytes, 0, readBytes.Length);
|
||||
nextDueMs += ReadMs;
|
||||
var sleep = nextDueMs - sw.Elapsed.TotalMilliseconds;
|
||||
if (sleep > 0) Thread.Sleep((int)sleep);
|
||||
}
|
||||
}) { IsBackground = true, Name = "lab-consumer" };
|
||||
|
||||
producer.Start();
|
||||
Thread.Sleep(120);
|
||||
consumer.Start();
|
||||
Thread.Sleep(SettleSeconds * 1000);
|
||||
var settled = session.BufferedMs;
|
||||
Console.WriteLine($" settled at slider {StartTargetMs}ms: buffered={settled}ms");
|
||||
|
||||
// 4. The user drags the slider up mid-stream — MainForm's exact call.
|
||||
engine.SetMaxLatencyMs(RenderRoute.Mixed, RaisedTargetMs);
|
||||
for (var s = 0; s < MeasureSeconds; s++)
|
||||
{
|
||||
Thread.Sleep(1000);
|
||||
if (s % 10 == 9) Console.WriteLine($" t+{s + 1,2}s buffered={session.BufferedMs,4}ms");
|
||||
}
|
||||
var after = session.BufferedMs;
|
||||
var growth = (after - settled) / (double)MeasureSeconds;
|
||||
var verdict = growth >= 2.0 ? "GROWS (slider works)" : growth >= 0.5 ? "SLOW" : "STALLED (the field bug)";
|
||||
Console.WriteLine($" RAISE via slider: {settled}ms -> {after}ms in {MeasureSeconds}s = {growth:F2}ms/s {verdict}");
|
||||
|
||||
engine.SetMaxLatencyMs(RenderRoute.Mixed, StartTargetMs);
|
||||
Thread.Sleep(3000);
|
||||
Console.WriteLine($" LOWER via slider: back to {StartTargetMs}ms -> buffered={session.BufferedMs}ms after 3s");
|
||||
Console.WriteLine();
|
||||
results.Add($"{name}: raise {growth:F2}ms/s [{verdict}], lower -> {session.BufferedMs}ms");
|
||||
|
||||
stop = true;
|
||||
producer.Join(2000);
|
||||
consumer.Join(2000);
|
||||
}
|
||||
|
||||
private static void RunScenario(List<string> results, string name, int writeMs, int writeJitterMs, int readMs, int gulpEvery, bool engineMixed)
|
||||
{
|
||||
Console.WriteLine($"--- {name} ---");
|
||||
var endpoint = new IPEndPoint(IPAddress.Loopback, 47831);
|
||||
var diagnostics = new ReceiverDiagnostics();
|
||||
PlayoutEngine? engine = null;
|
||||
SessionPlayout session;
|
||||
var target = StartTargetMs;
|
||||
if (engineMixed)
|
||||
{
|
||||
engine = new PlayoutEngine(diagnostics);
|
||||
engine.SetMaxLatencyMs(StartTargetMs);
|
||||
session = engine.GetOrCreateSession(endpoint, 1, capacityBytes: 4 * 1024 * 1024);
|
||||
}
|
||||
else
|
||||
{
|
||||
session = new SessionPlayout(endpoint, 1, capacityBytes: 4 * 1024 * 1024);
|
||||
}
|
||||
|
||||
var stop = false;
|
||||
var writeBlock = new byte[writeMs * SampleRate / 1000 * BytesPerFrame];
|
||||
FillSine(writeBlock); // real signal, not silence - keeps every probe honest
|
||||
var rng = new Random(12345);
|
||||
|
||||
// Producer: sender-shaped realtime writes, exactly the AudioReceiver wiring
|
||||
// (Write then NoteFramesQueued with the CURRENT target, like the queued-callback lambda).
|
||||
var producer = new Thread(() =>
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var nextDueMs = 0.0;
|
||||
while (!Volatile.Read(ref stop))
|
||||
{
|
||||
session.Write(writeBlock);
|
||||
session.NoteFramesQueued(engineMixed ? engine!.TargetLatencyMs : Volatile.Read(ref target));
|
||||
nextDueMs += writeMs;
|
||||
var jitter = writeJitterMs > 0 ? rng.Next(-writeJitterMs, writeJitterMs + 1) : 0;
|
||||
var sleep = nextDueMs + jitter - sw.Elapsed.TotalMilliseconds;
|
||||
if (sleep > 0) Thread.Sleep((int)sleep);
|
||||
}
|
||||
}) { IsBackground = true, Name = "lab-producer" };
|
||||
|
||||
// Consumer: device-shaped reads. Clean cadence, or chunky with a periodic double-gulp.
|
||||
var readBlockFrames = readMs * SampleRate / 1000;
|
||||
var readFloats = new float[readBlockFrames * 2 * 2]; // x2 room for the gulp read
|
||||
var readBytes = new byte[readFloats.Length * sizeof(float)];
|
||||
var consumer = new Thread(() =>
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var nextDueMs = 0.0;
|
||||
var n = 0;
|
||||
while (!Volatile.Read(ref stop))
|
||||
{
|
||||
var gulp = gulpEvery > 0 && ++n % gulpEvery == 0;
|
||||
var frames = gulp ? readBlockFrames * 2 : readBlockFrames;
|
||||
if (engineMixed)
|
||||
{
|
||||
engine!.Read(readBytes, 0, frames * BytesPerFrame);
|
||||
}
|
||||
else
|
||||
{
|
||||
session.ReadFloats(readFloats.AsSpan(0, frames * 2), frames, Volatile.Read(ref target), Volatile.Read(ref target), smoothness: 3);
|
||||
}
|
||||
nextDueMs += gulp ? readMs * 2 : readMs;
|
||||
var sleep = nextDueMs - sw.Elapsed.TotalMilliseconds;
|
||||
if (sleep > 0) Thread.Sleep((int)sleep);
|
||||
}
|
||||
}) { IsBackground = true, Name = "lab-consumer" };
|
||||
|
||||
producer.Start();
|
||||
Thread.Sleep(120); // pre-fill past the arming threshold so playback starts
|
||||
consumer.Start();
|
||||
|
||||
// Phase A: settle at the low target so the 10s drift-measurement window engages.
|
||||
Thread.Sleep(SettleSeconds * 1000);
|
||||
var settled = session.BufferedMs;
|
||||
Console.WriteLine($" settled at target {StartTargetMs}ms: buffered={settled}ms ratio={session.DriftResamplerRatio:F6} updates={session.DriftResamplerUpdates}");
|
||||
|
||||
// Phase B: RAISE - the shipped raise path (engine hard setter for tier 2; the value the
|
||||
// reads/queued-callbacks see for tier 1). Then measure depth once a second.
|
||||
if (engineMixed) engine!.SetMaxLatencyMs(RaisedTargetMs); else Volatile.Write(ref target, RaisedTargetMs);
|
||||
var samples = new List<int>();
|
||||
for (var s = 0; s < MeasureSeconds; s++)
|
||||
{
|
||||
Thread.Sleep(1000);
|
||||
samples.Add(session.BufferedMs);
|
||||
if (s % 5 == 4)
|
||||
Console.WriteLine($" t+{s + 1,2}s buffered={session.BufferedMs,4}ms ratio={session.DriftResamplerRatio:F6} updates={session.DriftResamplerUpdates}");
|
||||
}
|
||||
|
||||
// Growth rate over the measure window (simple end-to-end slope; the per-5s prints show shape).
|
||||
var growthMsPerSec = (samples[^1] - settled) / (double)MeasureSeconds;
|
||||
var verdict = growthMsPerSec >= 2.0 ? "GROWS (mechanism working)"
|
||||
: growthMsPerSec >= 0.5 ? "SLOW (partially working)"
|
||||
: "STALLED (the field bug)";
|
||||
Console.WriteLine($" RAISE result: {settled}ms -> {samples[^1]}ms in {MeasureSeconds}s = {growthMsPerSec:F2}ms/s {verdict}");
|
||||
|
||||
// Phase C: LOWER sanity - the drain path should snap back within a couple of seconds.
|
||||
if (engineMixed) engine!.SetMaxLatencyMs(StartTargetMs);
|
||||
else { Volatile.Write(ref target, StartTargetMs); session.DisarmAndRequestDrain(); }
|
||||
Thread.Sleep(3000);
|
||||
var afterLower = session.BufferedMs;
|
||||
Console.WriteLine($" LOWER result: back to target {StartTargetMs}ms -> buffered={afterLower}ms after 3s");
|
||||
Console.WriteLine();
|
||||
|
||||
results.Add($"{name}: raise {growthMsPerSec:F2}ms/s [{verdict}], lower -> {afterLower}ms");
|
||||
|
||||
stop = true;
|
||||
producer.Join(2000);
|
||||
consumer.Join(2000);
|
||||
session.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>A -12 dB 440 Hz sine so the ring carries real audio (probes and concealment
|
||||
/// behave as in the field; silence would short-circuit none of them but costs nothing to avoid).</summary>
|
||||
private static void FillSine(byte[] block)
|
||||
{
|
||||
var floats = System.Runtime.InteropServices.MemoryMarshal.Cast<byte, float>(block.AsSpan());
|
||||
for (var i = 0; i < floats.Length; i += 2)
|
||||
{
|
||||
var sample = (float)(0.25 * Math.Sin(2 * Math.PI * 440 * (i / 2) / SampleRate));
|
||||
floats[i] = sample;
|
||||
floats[i + 1] = sample;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,7 @@ internal static class SelfTest
|
||||
RunStep(results, "Service folder repair (reproduce wrong-owner lockout → detect → repair → verify)", ServiceAccessRepairLoop);
|
||||
RunStep(results, "Elevated verbs carry the real user (SID pass-through) + logs stay readable", ElevatedIdentityPassThrough);
|
||||
RunStep(results, "About box shows only the newest releases (screen-reader-safe size)", AboutBoxNotesTrimmed);
|
||||
RunStep(results, "Latency slider reaches the streams it governs (one slider = one value)", LatencySliderReachesSessions);
|
||||
RunStep(results, "Long-run hygiene (log rotation, crash-report cap, priority-mode scope)", LongRunHygiene);
|
||||
RunStep(results, "Service startup volume (boot-once decision + settings round-trip)", ServiceStartupVolume);
|
||||
RunStep(results, "Update install window (same-day, wraparound, retry timing)", UpdateInstallWindow);
|
||||
@@ -2949,6 +2950,59 @@ internal static class SelfTest
|
||||
return $"shipped About text: 5 versions, {shown.Length} chars (was ~70,000 — the screen-reader crash)";
|
||||
}
|
||||
|
||||
/// <summary>The latency slider must actually govern the streams that are playing. THE 2026-08-14
|
||||
/// FIELD BUG: incoming sessions are tagged with an output LANE (WasapiLane/AsioLane — never Mixed
|
||||
/// while a device is ticked), but the single slider writes RenderRoute.Mixed, and the render path
|
||||
/// resolved its target per-lane — so in every classic mode the slider (and the auto-tune) wrote a
|
||||
/// value nothing read, leaving the real target on the 30 ms default for the whole session. Raise
|
||||
/// and lower both inert; only BothIndependent worked. Measured end-to-end by
|
||||
/// <c>--latency-lab classic</c> (stalled -0.15ms/s → +2.07ms/s after the fix); pinned here as the
|
||||
/// fast, deterministic invariant: what the slider sets IS what the session's route reads, and a
|
||||
/// lower reaches the session. Also checks the two-slider mode keeps its lanes genuinely separate.</summary>
|
||||
private static string? LatencySliderReachesSessions()
|
||||
{
|
||||
var endpoint = new IPEndPoint(IPAddress.Loopback, 47832);
|
||||
|
||||
// --- Single-slider mode (WasapiOnly and every other classic mode) ---
|
||||
var engine = new RemSound.Receiver.PlayoutEngine(new RemSound.Receiver.ReceiverDiagnostics());
|
||||
engine.SetIndependentLaneLatency(false);
|
||||
engine.SetLaneActive(RenderRoute.WasapiLane, true); // one WASAPI output ticked...
|
||||
engine.SetLaneActive(RenderRoute.AsioLane, false); // ...no ASIO, exactly as CompositeRenderBackend reports
|
||||
engine.SetMaxLatencyMs(RenderRoute.Mixed, 30); // MainForm's call in classic modes
|
||||
var session = engine.GetOrCreateSession(endpoint, 1, capacityBytes: 1024 * 1024);
|
||||
Check(session.Route == RenderRoute.WasapiLane,
|
||||
$"a stream must land on the active output lane (got {session.Route}) — the mismatch the slider used to ignore");
|
||||
|
||||
engine.SetMaxLatencyMs(RenderRoute.Mixed, 330); // the user drags the slider up
|
||||
Check(engine.TargetLatencyMsFor(session.Route) == 330,
|
||||
$"the target the render path reads for this stream must BE the slider's value (got {engine.TargetLatencyMsFor(session.Route)}ms for a 330ms slider)");
|
||||
Check(engine.MaxLatencyMsFor(session.Route) == 330, "the max must follow the slider too");
|
||||
|
||||
// A LOWER must reach the session (disarm + drain). Arm it first, then lower and prove the
|
||||
// very next read returns nothing — that IS the disarm, and it's what refills to the new depth.
|
||||
var block = new byte[48000 * 8 / 2]; // 500ms stereo float — must exceed the 330ms target, or it never arms
|
||||
session.Write(block);
|
||||
session.NoteFramesQueued(engine.TargetLatencyMsFor(session.Route));
|
||||
var scratch = new float[960 * 2];
|
||||
Check(session.ReadFloats(scratch, 960, 330, 330) > 0, "the session must be armed and producing before the lower");
|
||||
engine.SetMaxLatencyMs(RenderRoute.Mixed, 30);
|
||||
Check(session.ReadFloats(scratch, 960, 30, 30) == 0,
|
||||
"lowering the slider must disarm+drain this stream — matching on the slider's route is what made 'lower' inert");
|
||||
|
||||
// --- Two-slider mode (BothIndependent): lanes stay genuinely separate ---
|
||||
var indep = new RemSound.Receiver.PlayoutEngine(new RemSound.Receiver.ReceiverDiagnostics());
|
||||
indep.SetMaxLatencyMs(RenderRoute.Mixed, 250); // whatever the single slider last held...
|
||||
indep.SetIndependentLaneLatency(true); // ...seeds both lanes, so audio doesn't jump
|
||||
Check(indep.TargetLatencyMsFor(RenderRoute.WasapiLane) == 250 && indep.TargetLatencyMsFor(RenderRoute.AsioLane) == 250,
|
||||
"entering two-slider mode must seed both lanes from the shared value (no jump at the changeover)");
|
||||
indep.SetMaxLatencyMs(RenderRoute.WasapiLane, 40);
|
||||
indep.SetMaxLatencyMs(RenderRoute.AsioLane, 8);
|
||||
Check(indep.TargetLatencyMsFor(RenderRoute.WasapiLane) == 40 && indep.TargetLatencyMsFor(RenderRoute.AsioLane) == 8,
|
||||
"each lane must hold its own target in two-slider mode");
|
||||
|
||||
return "one slider now governs every stream (raise + lower reach it); two-slider mode keeps its lanes separate";
|
||||
}
|
||||
|
||||
/// <summary>Issue #23 boot self-heal decision core. Scenario: at the boot lock screen the machine's
|
||||
/// speakers audibly play (Windows tune, NVDA) but a capture attached in the first seconds of boot
|
||||
/// taps an engine mix the logon-session audio was never wired into — the endpoint's own METER shows
|
||||
|
||||
Reference in New Issue
Block a user