fix(audio): a sync offset is saved the moment it changes

Both offsets rode the debounced layout save, so a value set seconds before the app closed was lost
without a word. That is what happened to the -130 ms offset on 2026-11-12: two clips were measured
against a setting that was not in force, the numbers disagreed by ~65 ms, and the disagreement was
blamed on the audio path.

Setters now write the row immediately (two tiny rows), keeping ScheduleSave for the rest of the
layout. AudioSyncSettingsTests gains the regression: set both offsets on a view model and read them
back through a fresh store with no debounce flush.

MyMistakes records the general shape — "a setting that didn't stick" is almost always ScheduleSave —
with the check list and the two related rules: offsets never ship with a non-zero default, and
scripts/avsync.py only resolves ~50 ms, so below that the creator's judgement wins.

677 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:21:48 -08:00
parent 1eb04597d1
commit cc75212997
3 changed files with 63 additions and 0 deletions
+31
View File
@@ -35,6 +35,37 @@ max 200.
## 🔬 Recipes registry ## 🔬 Recipes registry
### A SETTING THAT "DIDN'T STICK" — CHECK THE DEBOUNCE FIRST (RECIPE, 2026-11-12)
**Symptom:** a value is set in the UI, behaves correctly for one session, and is gone after a
restart. It looks like a wiring bug in the control; it is almost always `ScheduleSave()`.
**Cause:** `MainViewModel.ScheduleSave()` restarts a debounce timer that later calls `Save()`, which
writes the whole layout. Anything changed in the last few seconds before the app closes is lost —
and none of it is logged, so the next session reads a value the creator never chose.
**Quick fix:** for anything small and decisive (a sync offset, a toggle the creator just clicked),
call the `_layoutStore.Save…()` method **in the setter itself**, before `ScheduleSave()`. Two rows
cost nothing; a lost setting costs a whole reproduce-and-test cycle. Live example:
`MainViewModel.Audio.cs` → `AudioSyncOffsetMs` / `MicSyncOffsetMs`.
**Check list when a remembered setting misbehaves:** grep the property for `ScheduleSave`, confirm a
direct save exists, then reproduce with a *restart* (not just a new session) before debugging deeper.
**Bit us:** the lip-sync offset. `Audio.SyncOffsetMs` was set to −130, lost on close, and two clips
were measured against a setting that was not in force — the numbers disagreed by ~65 ms and the
disagreement was blamed on the audio path.
### A SYNC OFFSET IS NEVER A PRODUCT DEFAULT (2026-11-12)
**Mistake:** `MicSyncOffsetMs` shipped at **175 ms** because that suited one C920 on one desk. It
shifted every creator's voice to correct one machine and hid whether a desync was the app's fault.
**Rule:** offsets ship at 0 and are opt-in. `AudioSyncSettingsTests` pins it. And the tool that
measures them, `scripts/avsync.py`, varies **±35 ms between clips** on identical settings — it can
answer "is this 100 ms out?" but never "is this 20 ms out?". Below ~50 ms the creator's judgement is
the better instrument, so stop tuning rather than chasing the tool's noise.
### A LOOSE FILE IN `Assets/` IS NOT AN ASSET — THE CSProj LIST IS (RECIPE, 2026-11-04) ### A LOOSE FILE IN `Assets/` IS NOT AN ASSET — THE CSProj LIST IS (RECIPE, 2026-11-04)
**Problem:** four untracked `Assets/llamacasty-*` exports sat in the tree for days, reported in **Problem:** four untracked `Assets/llamacasty-*` exports sat in the tree for days, reported in
+4
View File
@@ -243,6 +243,9 @@ public partial class MainViewModel
var clamped = Math.Clamp(value, -500, 500); var clamped = Math.Clamp(value, -500, 500);
if (!SetProperty(ref _audioSyncOffsetMs, clamped)) return; if (!SetProperty(ref _audioSyncOffsetMs, clamped)) return;
OnPropertyChanged(nameof(SyncOffsetLabel)); OnPropertyChanged(nameof(SyncOffsetLabel));
// Written at once, not through the debounced layout save: a sync offset set seconds before
// closing the app used to be lost, and losing it costs a whole record-and-measure cycle.
if (_layoutStore is { } store) store.SaveAudioSyncOffsetMs(clamped);
ScheduleSave(); ScheduleSave();
} }
} }
@@ -264,6 +267,7 @@ public partial class MainViewModel
var clamped = Math.Clamp(value, 0, 500); var clamped = Math.Clamp(value, 0, 500);
if (!SetProperty(ref _micSyncOffsetMs, clamped)) return; if (!SetProperty(ref _micSyncOffsetMs, clamped)) return;
OnPropertyChanged(nameof(MicSyncOffsetLabel)); OnPropertyChanged(nameof(MicSyncOffsetLabel));
if (_layoutStore is { } store) store.SaveMicSyncOffsetMs(clamped); // immediate, see above
ScheduleSave(); ScheduleSave();
} }
} }
+28
View File
@@ -3,6 +3,7 @@ using System.IO;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Xunit; using Xunit;
using ytLive.Services; using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests; namespace ytLive.Tests;
@@ -11,6 +12,8 @@ namespace ytLive.Tests;
/// not arrive with audio already shifted — a non-zero default would move every creator's voice to fix /// 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. /// one machine and would hide whether the fault is theirs or ours.
/// </summary> /// </summary>
// The view model below touches the static LayoutPathOverride, so it shares the layout collection.
[Collection("MainViewModelLayoutPath")]
public sealed class AudioSyncSettingsTests : IDisposable public sealed class AudioSyncSettingsTests : IDisposable
{ {
private readonly string _db = Path.Combine(Path.GetTempPath(), $"ytLlive-syncsettings-{Guid.NewGuid():N}.db"); private readonly string _db = Path.Combine(Path.GetTempPath(), $"ytLlive-syncsettings-{Guid.NewGuid():N}.db");
@@ -44,4 +47,29 @@ public sealed class AudioSyncSettingsTests : IDisposable
Assert.Equal(240, reopened.LoadMicSyncOffsetMs()); Assert.Equal(240, reopened.LoadMicSyncOffsetMs());
Assert.Equal(-130, reopened.LoadAudioSyncOffsetMs()); Assert.Equal(-130, reopened.LoadAudioSyncOffsetMs());
} }
/// <summary>
/// A slider move is persisted as it happens. It used to ride the debounced layout save, so a value
/// set seconds before closing the app was lost — which cost a record-and-measure cycle on 2026-11-12
/// when a −130 ms offset vanished between two clips.
/// </summary>
[Fact]
public void A_slider_move_survives_without_any_debounce_flush()
{
MainViewModel.LayoutPathOverride = _db;
try
{
var vm = new MainViewModel();
vm.AudioSyncOffsetMs = -130;
vm.MicSyncOffsetMs = 240;
using var store = new LayoutStore(_db);
Assert.Equal(-130, store.LoadAudioSyncOffsetMs());
Assert.Equal(240, store.LoadMicSyncOffsetMs());
}
finally
{
MainViewModel.LayoutPathOverride = null;
}
}
} }