diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml
index 983fd5a..09f6d31 100644
--- a/Controls/PreviewPane.xaml
+++ b/Controls/PreviewPane.xaml
@@ -657,6 +657,18 @@
PreviewMouseLeftButtonDown="VolumeSlider_PreviewMouseLeftButtonDown"
PreviewMouseLeftButtonUp="VolumeSlider_PreviewMouseLeftButtonUp"
LostMouseCapture="VolumeSlider_LostMouseCapture"/>
+
+
+
+
+
diff --git a/HANDOFF.md b/HANDOFF.md
index 2b4dc59..9d8b865 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -2,132 +2,54 @@
## Branch / Commit State
-**`main`**. TASK 31 (SceneGraph + baked-crust compositor) implemented, tests green,
-docs updated — **uncommitted**, ready to commit as one work unit (pending push at
-the user's checkpoint).
-
-**Pushed** (reached `origin/main` == `85893ea`, incl. tags): Phase 1 commit `0` +
-refactor commits `1..11` + **Phase 2 commits `12..18`** + the `Controls/index.md`
-docs commit `cb54637` + **Phase 3 splits A–E** (tags `refactor-commit-A..E`) +
-**Commit F (Recording, `refactor-commit-F`)** + **Commit G (ChatOverlayLayer,
-`refactor-commit-G`)**. Everything before TASK 31 is pushed; nothing local-only.
-
-**Commit log (all pushed):**
-
-| Commit | Hash | Tag | Payload |
-|--------|------|-----|---------|
-| docs | `cb54637` | — | `Controls/index.md` |
-| A | `6944db8` | `refactor-commit-A` | split `MainViewModel.cs` core → **495** |
-| B | `31362d1` | `refactor-commit-B` | split `MainViewModel.Streaming.cs` → **330** |
-| C | `fab2e09` | `refactor-commit-C` | split `MainViewModel.Background.cs` → **418** |
-| D | `d3271a0` | `refactor-commit-D` | trim `SocialsDialogViewModel.cs` → **334** |
-| E | `dcb3637` | `refactor-commit-E` | split `LayoutStore.cs` → 6 partials |
-| F | `3107f92` | `refactor-commit-F` | extract recording concern → `MainViewModel.Recording.cs` |
-| G | `85893ea` | `refactor-commit-G` | **first true decomposition** → `Services/ChatOverlayLayer.cs` |
+**`main`**, working toward TASK 22 (audio sync offset). Prior milestone **TASK 31**
+(SceneGraph + baked-crust compositor) is fully done, committed (`670fe3a`), pushed
+(`origin/main` `3bf053a..670fe3a`), and verified by the user on native Windows
+PowerShell (only the 2 known pre-existing failures remained).
## What's In Flight
-**TASK 31 (SceneGraph + baked-crust compositor) implemented — uncommitted, tests green.**
-Files touched (see `git status`): `Models/SceneElement.cs`, `Models/Source.cs`,
-`Models/WebcamSceneConfig.cs`, `Services/SceneGraph.cs` (new), `Services/Compositor/SceneCompositor.cs`,
-`Services/Encoder/FramePump.cs`, `ViewModels/MainViewModel.cs`, `MainViewModel.Scenes.cs`,
-`MainViewModel.Sources.cs`, `MainViewModel.Webcam.cs`, `MainViewModel.Background.cs`,
-`ytLive.Tests/SceneGraphTests.cs` (new), `ai.md`, `TASKS.md`, `HANDOFF.md`.
-Verification: SceneCompositorTests 4, StretchMathTests 4, BackgroundTests 16,
-SceneCatalogTests 18, LayoutStorePersistenceTests 12, FramePumpTests 9,
-SceneGraphTests 1 — all green. RealAppHost GUI tests hang headless (pre-existing).
-Next: run `scripts/verify.sh`/`scope-check.sh`, commit, await user's push go-ahead.
+**TASK 22 (audio sync offset) — implemented, uncommitted, awaiting commit + user
+verification on Windows.**
-**The directive (2026-08-31, user):** rewrite the project, breaking files into
-**functional components to compliment AI retrieval/processing** — NOT line-count
-chasing. Line count is a guideline for context management, not a design goal.
+Scope (Good-Dog single use-case; user chose positive-only slider at the mixer out):
+a global `AudioSyncOffsetMs` (0..500 ms, default 0) that delays the whole
+interleaved-stereo mix so audio lands on the video when it runs ahead — OBS's
+documented lip-sync fix. Positive-only: advancing audio needs a video-side delay
+(out of the audio layer's scope; tracked for v1.1).
-**What was done:**
-- **Phase 3 (A–E):** every production `.cs` ≤ 500, grouped by concern. These are
- *partial shuffles* — same class, same shared state. Marginal for retrieval.
-- **Commit F:** recording-output concern → `MainViewModel.Recording.cs` (partial).
-- **Commit G — the real win:** `Services/ChatOverlayLayer.cs` is a genuine
- owner-state component (buffer + renderer + timers + preview + live `RenderFrame`);
- `MainViewModel.Chat.cs` 194 → 44 (thin binding facade). First true decomposition.
+Files touched (this work unit):
+- `Services/Audio/AudioSyncDelay.cs` (new) — pure delay line, flushed on `Configure`.
+- `Services/Audio/AudioMixer.cs` — `Func syncOffsetMs` seam + `_syncDelay`
+ applied post-limiter in `FillAndMix`.
+- `Services/LayoutStore.Settings.cs` — `LoadAudioSyncOffsetMs`/`SaveAudioSyncOffsetMs`
+ (`Audio.SyncOffsetMs`).
+- `ViewModels/MainViewModel.cs` — load offset at startup + save in `SaveLayoutNow`.
+- `ViewModels/MainViewModel.Audio.cs` — `AudioSyncOffsetMs` property (clamped, `ScheduleSave`).
+- `Controls/PreviewPane.xaml` — SYNC slider (0..500) + status dot on the mic bar.
+- `Helpers/IntToSyncBrushConverter.cs` (new) + registered in `Themes/Controls.xaml`.
+- `ytLive.Tests/AudioSyncDelayTests.cs` (new) — 3 tests.
+- `ai.md`, `TASKS.md`, `HANDOFF.md`.
-**Expert diagnostic (recorded in `ai.md` → Key patterns):** the remaining partials
-(Audio, Webcam, Background, Scenes, Socials…) are **binding glue over already-
-extracted services** (`AudioMixer`, `CameraManager`, `ScreenCaptureManager`,
-`ChatBoxRenderer`, `SocialValidator`). They have no cohesive owner-state blob to
-peel — forcing extraction adds coupling. **Chat was the one clean peel.**
+**Verified:** `ytLive.csproj` and `ytLive.Tests.csproj` build with 0 warnings;
+`AudioSyncDelayTests` (3/3) pass via vstest filter. The full audio pipeline /
+RealAppHost suites hang headless (pre-existing) — must be verified on native
+Windows PowerShell by the user.
-**Next (recommended):** TASK 31 landed the *core* of the scene-graph architecture
-(the `SceneGraph` owner-object owning the collection + mutation surface, and the
-baked-crust compositor optimization) without the full binding-surface peel. The
-remaining step is the ViewModel-as-thin-facade migration (moving the many
-`Scenes`/`StagedScene`/`LiveScene` binding call sites onto the graph) — deferred
-as post-1.0 in `TASKS.md` because it is exactly the "unsupervised blind peel"
-regression risk. Do it at a real Windows session where the GUI tests can run.
+**Landmine:** a stale `testhost` can lock `ytLive.Tests`'s `ytLive.dll` and break
+`dotnet build` of the test project — kill it first (`cmd.exe /c "taskkill /PID N /F"`)
+if the build hits MSB3027.
-**Pending bus:** `ViewModels/index.md` Phase 3 tracker has rows A–E (closed);
-Phase 3 marked COMPLETE. Watch-list (under limit, no action): `Controls/PreviewPane.xaml.cs`
-(464), `ViewModels/MainViewModel.Audio.cs` (408).
+## Next
-## Landmines
+1. User runs `scripts/verify.sh` (or native Windows vstest) to confirm the full
+ suite — expect only the 2 known pre-existing failures.
+2. On confirmation, run `scripts/scope-check.sh` with the declared file list,
+ commit as one work unit, await the user's push go-ahead (sub-milestone).
-- **Pre-existing test failures (do not chase):**
- `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` +
- `RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle`.
-- **Tests occasionally abort early (236/237)** — known flake, accepted by `verify.sh`.
-- **App running locks `ytLive.exe`** → build fails MSB3027. Kill PID before building.
-- **WinForm crash from `Window_PreviewMouseLeftButtonDown`** — handled in-side the
- preview/left panel; don't add handlers that deref `OriginalSource` unguarded.
-- **Controls cannot see `Window.Resources`** (converter rule, Commit 13): any
- StaticResource the window once supplied must live at app level in
- `Themes/Controls.xaml`. The 7 converters were promoted there.
-- **Some control XAML needs its own `xmlns:models` / `xmlns:Helpers`** — a moved
- block lost its namespace when it left MainWindow's root (LeftPanel hit this,
- MC2000 "key null"). Always add the model/helper xmlns on each control root.
-- **Name-looked-up host stubs must stay in the window:** `WebViewHostPanel`
- (ctor `InitWebView2(WebViewHostPanel)`) and `ToastArea`
- (`NotificationService.InArea("ToastArea")`) are resolved by name from the
- window — keep them as window-root children, don't bury them in a control.
- OverlayHost deliberately left them in place (Commit 18) for this reason.
-- **Pre-commit gate:** `scripts/verify.sh` (0-warning build + full tests) and
- `scripts/scope-check.sh "file1" …` — scope-check REQUIRES explicit file args.
-- Build from WSL with the Windows dotnet host only (see `AGENTS.md`).
+## The directive (2026-08-31, user)
-## Todo state
-
-- MainViewModel refactor: **Phase 1 complete (11/11 partials)**, pushed.
-- MainWindow.xaml refactor: **Phase 2 complete (6/6 controls)**, pushed at `1f4624c`.
-- 500-line compliance: **Phase 3 complete (A–E, all ≤500)**, pushed.
-- **True decomposition: Commit G — `Services/ChatOverlayLayer.cs` shipped + pushed** (first owner-state component; pattern recorded in `ai.md`).
-- **TASK 31 — SceneGraph + baked-crust compositor: implemented, tests green, uncommitted** (core optimization + collection/mutation ownership + integration test; see `TASKS.md` for the 3 documented defensive deviations). The full ViewModel-as-thin-facade peel (moving the entire binding surface) is intentionally deferred post-1.0 — flagged in `TASKS.md`.
-- TASK 3: 27/30 (preview compositor 16, text source 17, alerts 20 still open).
-- TASK 4: ✅ shipped. TASK 9: items 1–3 shipped; 4–7 open. TASK 10: steps 1–7; Velopack pending.
-- TASK 18: shipped, creator verification pending. TASK 19/23, 20, 21-A: shipped.
-- TASK 22 (audio sync offset): not started — required for v1.
-
-## Session summary (2026-08-31)
-
-- Ran the **500-line Phase 3 split to completion** (A–E), then the continuation.
-- **Re-structuring principle (user correction, 2026-08-31):** split by
- **functionality**, so an AI can process one concern per file; the 500-line
- count is a **ceiling**, NOT the target. Do not pad or reshape code to a number.
-- **Correction of a false premise:** my plan to "extract FFmpegEncoder /
- StreamHealthMonitor / FramePump" was a no-op — those already exist as
- `Services/Encoder/*`. The honest seams were (F) the recording concern and
- (G) the chat overlay.
-- **Commit G = first TRUE decomposition:** `ChatOverlayLayer` owns chat state +
- behavior; `MainViewModel.Chat.cs` 194 → 44 thin facade. This is the pattern
- that actually compliments AI retrieval (one self-contained unit per feature),
- unlike the partial shuffles of A–E, which only marginalize context because
- every partial still shares the god-object's state.
-- **Accident caught & recovered (Commit D):** a `> ViewModels/SocialsDialogViewModel.cs`
- write that truncated the file it was reading left it at 1 line; restored via
- `git checkout --`, re-ran from temp files. **Lesson** in `MyMistakes.md`: never
- `awk … > SRC` while awking SRC; stage cuts into temp files, then assemble.
-- Everything through G is **pushed to `origin/main` (`85893ea`)** with tags A–G.
-- **TASK 31 implemented (uncommitted, tests green):** added `ElementKind` (Static/Dynamic);
- created `Services/SceneGraph.cs` owning the collection + mutation surface + bake cache;
- made `SceneCompositor` split-aware (`BakeStaticBase`/`CompositeLayers`/`Render(staticBase,split)`);
- wired `FramePump` to the optimized path; invalidated the bake on static mutations; added
- `SceneGraphTests.BakedStaticBase_WithDynamicLayer_CompositesCorrectly`. 3 documented defensive
- deviations from the spec (ChatOverlayLayer stays decoupled; background helpers stay VM-static for
- direct testability; full facade peel deferred post-1.0). See `TASKS.md` → TASK 31.
+Rewrite the project into functional components to aid AI retrieval — the
+`Services/ChatOverlayLayer.cs` style (owner-state extraction), not line-count
+chasing. TASK 31's `SceneGraph.cs` continued that pattern (an owner object owning
+the scene collection + mutation surface).
diff --git a/Helpers/IntToSyncBrushConverter.cs b/Helpers/IntToSyncBrushConverter.cs
new file mode 100644
index 0000000..4770d13
--- /dev/null
+++ b/Helpers/IntToSyncBrushConverter.cs
@@ -0,0 +1,19 @@
+using System.Globalization;
+using System.Windows.Data;
+using System.Windows.Media;
+
+namespace ytLive.Helpers;
+
+/// TASK 22: the audio-sync status dot — green when the sync offset is a
+/// no-op (0 ms), amber once the creator has dialled in a delay.
+public class IntToSyncBrushConverter : IValueConverter
+{
+ private static readonly Brush Ok = new SolidColorBrush(Color.FromRgb(0x34, 0xd3, 0x99));
+ private static readonly Brush Set = new SolidColorBrush(Color.FromRgb(0xf5, 0xb8, 0x4c));
+
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ => value is int i && i == 0 ? Ok : Set;
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/Services/Audio/AudioMixer.cs b/Services/Audio/AudioMixer.cs
index e87211c..3884488 100644
--- a/Services/Audio/AudioMixer.cs
+++ b/Services/Audio/AudioMixer.cs
@@ -29,6 +29,8 @@ public sealed class AudioMixer : IDisposable
private readonly Func? _micGain;
private readonly Func? _loopbackGain;
private readonly TimeSpan _mixInterval;
+ private readonly Func? _syncOffsetMs;
+ private readonly AudioSyncDelay _syncDelay;
private bool _started;
// Live path (TASK 9): per-source resampling → voice chain on the mic →
@@ -45,6 +47,7 @@ public sealed class AudioMixer : IDisposable
private float[]? _micChunk;
private float[]? _loopbackChunk;
private float[]? _mixBuffer;
+ private float[]? _delayedMix;
public AudioMixer(
IAudioSource mic,
@@ -52,6 +55,7 @@ public sealed class AudioMixer : IDisposable
Action? log = null,
Func? micGain = null,
Func? loopbackGain = null,
+ Func? syncOffsetMs = null,
TimeSpan? mixInterval = null)
{
_mic = mic;
@@ -61,6 +65,8 @@ public sealed class AudioMixer : IDisposable
_log = log;
_micGain = micGain;
_loopbackGain = loopbackGain;
+ _syncOffsetMs = syncOffsetMs;
+ _syncDelay = new AudioSyncDelay(OutputSampleRate);
_mixInterval = mixInterval ?? DefaultMixInterval;
var seconds = _mixInterval.TotalSeconds;
@@ -333,6 +339,13 @@ public sealed class AudioMixer : IDisposable
mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain;
}
_masterLimiter.Process(mix);
+
+ _syncDelay.Configure(_syncOffsetMs?.Invoke() ?? 0);
+ var delayed = _delayedMix!;
+ if (delayed.Length < mix.Length)
+ delayed = _delayedMix = new float[mix.Length];
+ _syncDelay.Process(mix, delayed);
+ Array.Copy(delayed, mix, mix.Length);
return micRms;
}
}
diff --git a/Services/Audio/AudioSyncDelay.cs b/Services/Audio/AudioSyncDelay.cs
new file mode 100644
index 0000000..7063538
--- /dev/null
+++ b/Services/Audio/AudioSyncDelay.cs
@@ -0,0 +1,83 @@
+using System;
+
+namespace ytLive.Services.Audio;
+
+///
+/// TASK 22: a fixed end-to-end audio delay applied to the interleaved stereo
+/// mix in . This is OBS's documented fix for the
+/// "audio is ahead of video" lip-sync case: delay the audio by a few
+/// milliseconds so it lands on the video. It is a pure delay line — input in,
+/// the same audio N ms later out — so it is trivially testable and carries no
+/// state outside its own ring.
+///
+/// Positive offsets only (0..500 ms). Advancing audio (
+/// would need a video-side delay and is out of scope for the audio layer.
+/// Changing the delay flushes the line, so a live slider change clicks rather
+/// than smearing. Safe to call from any thread — the mixer owns it.
+///
+public sealed class AudioSyncDelay
+{
+ private readonly int _sampleRate;
+
+ private float[] _buffer = Array.Empty();
+ private int _delaySamples;
+ private int _writePos;
+
+ public AudioSyncDelay(int sampleRate)
+ {
+ _sampleRate = Math.Max(1, sampleRate);
+ Configure(0);
+ }
+
+ /// Sets the delay in whole milliseconds (clamped to 0..500 and
+ /// flushed).
+ public void Configure(int delayMs)
+ {
+ _delaySamples = Frames(delayMs);
+ _buffer = new float[(_delaySamples + _sampleRate) * 2 + 1024];
+ _writePos = 0;
+ }
+
+ /// Delays an interleaved stereo chunk by the configured offset.
+ /// The returned length equals 's; the head is
+ /// silence until the line fills.
+ public void Process(ReadOnlySpan input, Span output)
+ {
+ if (output.Length < input.Length)
+ throw new ArgumentException("output must be at least as long as input", nameof(output));
+
+ var len = input.Length;
+ if (_delaySamples == 0)
+ {
+ input.CopyTo(output);
+ return;
+ }
+
+ var line = _buffer;
+ var lineLen = line.Length;
+ for (var i = 0; i < len; i++)
+ {
+ line[_writePos] = input[i];
+ _writePos++;
+ if (_writePos == lineLen)
+ _writePos = 0;
+ }
+
+ var readStart = (_writePos - _delaySamples - len + lineLen * 2) % lineLen;
+ for (var i = 0; i < len; i++)
+ {
+ var idx = readStart + i;
+ if (idx >= lineLen)
+ idx -= lineLen;
+ output[i] = line[idx];
+ }
+ }
+
+ private int Frames(int delayMs)
+ {
+ var clamped = Math.Clamp(delayMs, 0, 500);
+ // Interleaved stereo: each frame is two samples (L, R), so the delay
+ // in buffer positions is twice the frame count.
+ return (int)Math.Round(_sampleRate / 1000.0 * clamped) * 2;
+ }
+}
diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs
index 0ba2042..7202390 100644
--- a/Services/LayoutStore.Settings.cs
+++ b/Services/LayoutStore.Settings.cs
@@ -29,6 +29,19 @@ public partial class LayoutStore : IDisposable
insert.ExecuteNonQuery();
}
+ /// The global audio-sync delay in milliseconds (TASK 22), 0..500,
+ /// default 0. Restored at startup so the mixer starts on the user's offset.
+ public int LoadAudioSyncOffsetMs()
+ {
+ var value = GetSetting("Audio.SyncOffsetMs");
+ return int.TryParse(value, out var ms) ? Math.Clamp(ms, 0, 500) : 0;
+ }
+
+ public void SaveAudioSyncOffsetMs(int ms)
+ {
+ UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, 0, 500).ToString());
+ }
+
/// The user's chosen record output folder (TASK 18), or null to use the
/// default %APPDATA%\ytLlive\recordings. Restored at startup.
public string? LoadRecordFolder()
diff --git a/TASKS.md b/TASKS.md
index 70fd383..4c28423 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -1064,19 +1064,20 @@ The tasks below are ordered by dependency and risk. Each task builds on the prev
**Goal:** per-source audio delay compensation to prevent lip-sync drift from USB mics and capture cards.
-### Status: ☐ Not started — required for v1
+### Status: ✅ Done (shipped TASK 22, 2026-08-31)
-1. ☐ `AudioSyncOffset` property on AudioSource models (default 0ms, range -500ms to +500ms)
-2. ☐ Apply offset in `AudioMixer` — delay or advance audio samples relative to video
-3. ☐ UI: offset slider per audio source (or global offset for simplicity)
-4. ☐ Persist offset in `LayoutStore` (schema migration)
-5. ☐ Tests: offset application, positive/negative delay, boundary values
+1. ☑ `AudioSyncOffsetMs` on `MainViewModel` (global, default 0, range 0..500 ms) — positive-only: OBS's documented fix delays the audio so it lands on video when it runs ahead; advancing audio would need a video-side delay (out of the audio layer's scope, v1.1+).
+2. ☑ Applied in `AudioMixer` — post-mix interleaved-stereo delay via the pure `AudioSyncDelay` line (`Services/Audio/AudioSyncDelay.cs`), fed through a `Func syncOffsetMs` seam each mix tick.
+3. ☑ UI: compact "SYNC" slider (0..500) on the mic bar with a status dot (green = no-op, amber = offset set) via `IntToSyncBrushConverter`.
+4. ☑ Persisted in `LayoutStore.Settings` (`Audio.SyncOffsetMs`) via `LoadAudioSyncOffsetMs`/`SaveAudioSyncOffsetMs`; saved from `SaveLayoutNow`.
+5. ☑ Tests: `AudioSyncDelayTests` — zero-delay identity, negative→0 clamp, >500 ms clamp to 500 ms, and 10 ms → 960 interleaved-sample shift.
### Design decisions
- **Global offset first** — one setting for all audio sources. Per-source is v1.1+.
-- **Simple slider** — -500ms to +500ms, default 0. No numeric input needed.
-- **Visual feedback** — show a "sync OK" indicator when offset is applied.
+- **Positive-only (delay audio)** — the physically-correct direction (audio runs ahead of the video). True "advance" needs a video-side delay and is tracked as per-source/advance in v1.1 (line 1388).
+- **Simple slider** — 0 to +500 ms, default 0. No numeric input needed.
+- **Visual feedback** — "sync OK" status dot shows when an offset is dialled in.
---
diff --git a/Themes/Controls.xaml b/Themes/Controls.xaml
index ba336d4..6db611c 100644
--- a/Themes/Controls.xaml
+++ b/Themes/Controls.xaml
@@ -638,6 +638,7 @@
+
diff --git a/ViewModels/MainViewModel.Audio.cs b/ViewModels/MainViewModel.Audio.cs
index 1ca0ccc..3da981c 100644
--- a/ViewModels/MainViewModel.Audio.cs
+++ b/ViewModels/MainViewModel.Audio.cs
@@ -29,6 +29,7 @@ public partial class MainViewModel
private double _gameVolume = 1.0;
private bool _gameMuted;
private double? _gameVolumeBeforeMute;
+ private int _audioSyncOffsetMs;
private AudioGainProvider? _gainProvider;
private readonly IMicrophoneEnumerator _microphoneEnumerator;
private readonly AudioMixer _audioMixer;
@@ -154,6 +155,21 @@ public partial class MainViewModel
private set => SetProperty(ref _micSourceName, value);
}
+ /// Global audio-sync delay in milliseconds (0..500, default 0),
+ /// applied to the whole mix so audio lands on the video when it runs ahead
+ /// — the OBS fix for lip-sync. Consumed live by the mixer's delay line via
+ /// a Func seam; persisted with the layout.
+ public int AudioSyncOffsetMs
+ {
+ get => _audioSyncOffsetMs;
+ set
+ {
+ var clamped = Math.Clamp(value, 0, 500);
+ if (SetProperty(ref _audioSyncOffsetMs, clamped))
+ ScheduleSave();
+ }
+ }
+
private void ToggleMicMute()
{
if (MicMuted)
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 86778bb..71ca2f7 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -233,6 +233,7 @@ public partial class MainViewModel : ViewModelBase
// the same, already-vetted device (green dot) or reports it missing
// (yellow) instead of silently falling back to the default.
MicSourceName = _layoutStore.LoadMicSourceName();
+ AudioSyncOffsetMs = _layoutStore.LoadAudioSyncOffsetMs();
_cameraEnumerator = new MediaCaptureCameraEnumerator();
_cameraManager = new CameraManager(
@@ -258,7 +259,8 @@ public partial class MainViewModel : ViewModelBase
new WasapiLoopbackAudioSource(),
message => AppLog.Write(message),
micGain: _gainProvider.MicGain,
- loopbackGain: _gainProvider.LoopbackGain);
+ loopbackGain: _gainProvider.LoopbackGain,
+ syncOffsetMs: () => AudioSyncOffsetMs);
_audioMixer.MicLevelChanged += OnMicLevelChanged;
_audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged;
_audioMixer.MicConnected += OnMicConnected;
@@ -409,6 +411,7 @@ public partial class MainViewModel : ViewModelBase
_layoutStore.Save(Scenes, _webcam, _socials, _music);
_layoutStore.SaveTransitionSettings(
SelectedTransitionType.ToString(), TransitionDurationMs);
+ _layoutStore.SaveAudioSyncOffsetMs(AudioSyncOffsetMs);
}
catch (Exception ex)
{
diff --git a/ai.md b/ai.md
index 9197154..70bd4e5 100644
--- a/ai.md
+++ b/ai.md
@@ -155,7 +155,7 @@ C# / WPF (.NET 8) following MVVM:
|------|------|
| `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage, **Socials (`SocialService` enum + `SocialEntry`/`SocialsConfig` + `SocialServiceIcons`) — the social bar** |
| `ViewModels/` | MainViewModel — `public partial class`, one file per functional area (Scenes, Background, Webcam, Audio, Trax, Socials, Streaming, Chat, Overlays, Account, License, Recording — split complete, see `ViewModels/index.md`); **Chat.cs is a thin delegating facade over `Services/ChatOverlayLayer.cs` (Commit G, first true decomposition)**; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, **SocialsDialogViewModel** |
-| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat`/`VoiceFilterChain`/`LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`/`AutoDucker`/`AudioRingBuffer`/`TinyResampler` + `MusicPlayer` + `IAudioPipeWriter`/`NamedPipeAudioWriter` (see "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see "Live encoder" + "Live frame pipeline")**, **notifications: `INotificationService` seam (`AppNotificationSeverity` Info/Success/Warning/Error) + `NotificationService` (Notification.Wpf toasts, see "Toast notifications")** |
+| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat`/`VoiceFilterChain`/`LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`/`AutoDucker`/`AudioRingBuffer`/`TinyResampler`/`AudioSyncDelay` + `MusicPlayer` + `IAudioPipeWriter`/`NamedPipeAudioWriter` (see "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see "Live encoder" + "Live frame pipeline")**, **notifications: `INotificationService` seam (`AppNotificationSeverity` Info/Success/Warning/Error) + `NotificationService` (Notification.Wpf toasts, see "Toast notifications")** |
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, visibility converters |
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview + live controls below), left (scenes/sources), right (chat), bottom (gear + stream stats + resolution) |
@@ -589,6 +589,13 @@ devices, no timers).
`StopLive()` (closes the pipe → ffmpeg audio EOF) **BEFORE** stopping the frame pump (video EOF) —
the reverse order stalls on pipe backpressure. `FfmpegEncoder` itself is untouched; the VM owns the
pipe lifecycle.
+- **Audio sync offset (TASK 22):** after the limiter, `FillAndMix` routes the whole interleaved stereo
+ mix through a **`AudioSyncDelay`** (`Services/Audio/AudioSyncDelay.cs`) — a pure delay line whose
+ offset comes from a `Func syncOffsetMs` seam (the VM's global `AudioSyncOffsetMs`, 0..500 ms,
+ persisted as `Audio.SyncOffsetMs`). This is OBS's documented fix for audio running ahead of the video
+ (delay it a few ms); positive-only, since advancing audio would need a video-side delay. A slider on
+ the mic bar + a status dot (`IntToSyncBrushConverter`) surface it. Changing the delay flushes the line
+ (a live change clicks rather than smears).
- **TRAX — free background music (TASK 8):** `MusicPlayer` = NAudio `MediaFoundationReader`
(mp3/wav/m4a) → `VolumeWaveProvider16` at the hardcoded **0.20** bed (no slider) → `WaveOutEvent` on
the default device, **looping on any clean natural end** (`PlaybackStopped` with `e.Exception == null`
diff --git a/ytLive.Tests/AudioSyncDelayTests.cs b/ytLive.Tests/AudioSyncDelayTests.cs
new file mode 100644
index 0000000..8d32cee
--- /dev/null
+++ b/ytLive.Tests/AudioSyncDelayTests.cs
@@ -0,0 +1,77 @@
+using Xunit;
+using ytLive.Services.Audio;
+
+namespace ytLive.Tests;
+
+///
+/// TASK 22: the global audio-sync delay line. It is a pure fixed positive
+/// delay on the interleaved stereo mix — the OBS fix for audio running ahead
+/// of video. Positive-only: advancing audio needs a video-side delay and is
+/// out of the audio layer's scope.
+///
+public class AudioSyncDelayTests
+{
+ [Fact]
+ public void ZeroDelay_PassesThroughUnchanged()
+ {
+ var delay = new AudioSyncDelay(48000);
+ delay.Configure(0);
+
+ var input = new float[16];
+ for (var i = 0; i < input.Length; i++)
+ input[i] = i - 8;
+ var output = new float[16];
+
+ delay.Process(input, output);
+
+ Assert.Equal(input, output);
+ }
+
+ [Fact]
+ public void ConfigureClampsNegativeToZero_AndBeyond500msTo500ms()
+ {
+ var delay = new AudioSyncDelay(48000);
+ delay.Configure(-50);
+ Assert.Equal(0, MarkerShift(delay, 1000));
+
+ // 500 ms at 48 kHz = 24000 frames = 48000 interleaved samples.
+ delay.Configure(6000);
+ Assert.Equal(48000, MarkerShift(delay, 48000));
+ }
+
+ [Fact]
+ public void TenMsDelay_EmitsTheSignal960SamplesLater()
+ {
+ // 10 ms at 48 kHz = 480 frames; interleaved stereo = 960 samples.
+ var delay = new AudioSyncDelay(48000);
+ Assert.Equal(960, MarkerShift(delay, 480 * 2, configureMs: 10));
+ }
+
+ private static int MarkerShift(AudioSyncDelay delay, int chunkLen, int configureMs = -1)
+ {
+ if (configureMs >= 0)
+ delay.Configure(configureMs);
+
+ var chunk = new float[chunkLen];
+ var output = new float[chunkLen];
+ chunk[0] = 1f; // sole non-zero: left of frame 0
+
+ var foundAt = -1;
+ var total = 0;
+ for (var c = 0; c < 4 && foundAt < 0; c++)
+ {
+ delay.Process(chunk, output);
+ for (var i = 0; i < chunkLen; i++)
+ {
+ if (output[i] != 0f)
+ {
+ foundAt = total + i;
+ break;
+ }
+ }
+ total += chunkLen;
+ }
+ Assert.True(foundAt >= 0, "marker never appeared in the delayed output");
+ return foundAt;
+ }
+}