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:
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user