fix(audio): both sync offsets ship at zero

Shipping 175 ms as the built-in mic delay was wrong on principle, not just in value: it shifts
every creator's voice to correct one camera on one desk, and it hides whether a desync is theirs
or ours. A correction has to be something the creator turned on.

So DefaultMicSyncOffsetMs is 0. The control stays (slider 0..500, off/175/350 presets, applied value
logged) for anyone whose camera needs it; it is simply no longer pre-applied. The global offset was
already 0.

AudioSyncSettingsTests pins the rule: a fresh profile reads 0 for both, the mic delay refuses a
negative (advancing is the global offset's job) and clamps above 500, and set values round-trip.

676 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
This commit is contained in:
2026-11-12 14:16:20 -08:00
parent c1b3868913
commit 1eb04597d1
4 changed files with 59 additions and 9 deletions
+3 -3
View File
@@ -12,9 +12,9 @@
counts only audio whose write reached the pipe, and starts its clock at session open. Measured counts only audio whose write reached the pipe, and starts its clock at session open. Measured
across three recordings: +2.1% long → 226 ms short → 137 ms short → **lengths now match (34 ms in across three recordings: +2.1% long → 226 ms short → 137 ms short → **lengths now match (34 ms in
13 s)**. 13 s)**.
- **What is left is one number.** `5ae5280` gives the mic delay a 0..500 slider (off/175/350) and logs - **Sync answer (measured, settled).** Mic delay ships at **0** and should stay off; with it off the audio
the applied value. Measured on the last clip: `-185 ms`, i.e. sound still 185 ms EARLY, so the value path runs ~100 ms behind, so the **global** LIP-SYNC offset is **−130 ms**. Both offsets default to 0 —
to try is **350–360 ms**. `Audio.MicSyncOffsetMs` is stored per profile. a correction belongs to one camera, never to the product (`AudioSyncSettingsTests` pins it).
- **675 tests green, 0 warnings.** `scripts/avsync.py` measures any recording in seconds. - **675 tests green, 0 warnings.** `scripts/avsync.py` measures any recording in seconds.
## Next action ## Next action
+6 -3
View File
@@ -117,9 +117,12 @@ public partial class LayoutStore : IDisposable
return int.TryParse(value, out var ms) ? Math.Clamp(ms, -500, 500) : 0; return int.TryParse(value, out var ms) ? Math.Clamp(ms, -500, 500) : 0;
} }
/// <summary>The shipped default for the microphone-only delay. The creator's measurement for a /// <summary>
/// C920 pipeline (asked for +150..+200 ms); a camera with less latency wants less.</summary> /// The shipped default for the microphone-only delay: **zero**. A sync offset is a correction for one
public const int DefaultMicSyncOffsetMs = 175; /// camera on one desk, never a product default — shipping a number would shift every creator's audio
/// to fix one machine's camera, and would hide whether the fault is theirs or ours.
/// </summary>
public const int DefaultMicSyncOffsetMs = 0;
/// <summary>Microphone-only sync delay (0..500 ms), so the voice can wait for the camera's picture /// <summary>Microphone-only sync delay (0..500 ms), so the voice can wait for the camera's picture
/// without moving desktop or alert audio. Absent means the shipped default.</summary> /// without moving desktop or alert audio. Absent means the shipped default.</summary>
+3 -3
View File
@@ -294,9 +294,9 @@ devices, no timers).
samples reach the pipe, so the voice runs ahead of the lips. `AudioSyncDelay` now takes a channel samples reach the pipe, so the voice runs ahead of the lips. `AudioSyncDelay` now takes a channel
count, and `FillAndMix` runs a second line (**mono**) over `micChunk` *before* the mix — loopback and count, and `FillAndMix` runs a second line (**mono**) over `micChunk` *before* the mix — loopback and
alert audio are not tied to a camera frame and must not move with it. Seam: `Func<int> micSyncOffsetMs` alert audio are not tied to a camera frame and must not move with it. Seam: `Func<int> micSyncOffsetMs`
(VM `MicSyncOffsetMs`, **0..500 ms**, persisted `Audio.MicSyncOffsetMs`, shipped default **175** — (VM `MicSyncOffsetMs`, **0..500 ms**, persisted `Audio.MicSyncOffsetMs`, shipped default **0 / off** — a sync offset corrects one camera on
the creator's own C920 measurement, not a constant: the right number belongs to the machine). one desk, so it is opt-in, never a product default).
A **slider 0..500** plus `off / 175 / 350` presets sit in the LIP-SYNC bay under the whole-mix A **slider 0..500** (default 0) plus `off / 175 / 350` presets sit in the LIP-SYNC bay under the whole-mix
slider — presets alone could not express the value a slow camera actually needs. `Audio live: started` slider — presets alone could not express the value a slow camera actually needs. `Audio live: started`
logs the applied number, because the value lives in the layout DB and a preset click is invisible in a logs the applied number, because the value lives in the layout DB and a preset click is invisible in a
recording. Both lines are recording. Both lines are
+47
View File
@@ -0,0 +1,47 @@
using System;
using System.IO;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// Sync offsets ship at zero. A correction belongs to one camera on one desk, so a fresh profile must
/// not arrive with audio already shifted — a non-zero default would move every creator's voice to fix
/// one machine and would hide whether the fault is theirs or ours.
/// </summary>
public sealed class AudioSyncSettingsTests : IDisposable
{
private readonly string _db = Path.Combine(Path.GetTempPath(), $"ytLlive-syncsettings-{Guid.NewGuid():N}.db");
public void Dispose()
{
SqliteConnection.ClearAllPools();
try { File.Delete(_db); } catch (IOException) { /* best effort */ }
}
[Fact]
public void Both_offsets_start_at_zero_and_only_then_take_a_value()
{
using (var store = new LayoutStore(_db))
{
Assert.Equal(0, store.LoadMicSyncOffsetMs()); // opt-in, not pre-applied
Assert.Equal(0, store.LoadAudioSyncOffsetMs());
// A mic delay cannot be negative — advancing is the global offset's job.
store.SaveMicSyncOffsetMs(-120);
Assert.Equal(0, store.LoadMicSyncOffsetMs());
store.SaveMicSyncOffsetMs(600); // beyond the slider's range
Assert.Equal(500, store.LoadMicSyncOffsetMs());
store.SaveMicSyncOffsetMs(240);
store.SaveAudioSyncOffsetMs(-130);
}
using var reopened = new LayoutStore(_db);
Assert.Equal(240, reopened.LoadMicSyncOffsetMs());
Assert.Equal(-130, reopened.LoadAudioSyncOffsetMs());
}
}