From e8ff4df05f5562d6986ce6f71f5c9fb3c6b328ad Mon Sep 17 00:00:00 2001 From: gramps Date: Mon, 31 Aug 2026 18:30:03 -0700 Subject: [PATCH] TASK 22: global audio sync offset (positive delay at the mixer out) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AudioSyncOffsetMs (0..500ms, 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 and is out of the audio layer's scope. - Services/Audio/AudioSyncDelay.cs: pure delay line, flushed on Configure - AudioMixer: Func syncOffsetMs seam + delay applied post-limiter - LayoutStore.Settings + MainViewModel.Audio/VM: load/save + binding - PreviewPane mic bar: SYNC slider + status dot (IntToSyncBrushConverter) - AudioSyncDelayTests: identity, negative/beyond-500 clamps, 10ms→960 samples Reference (external scan): https://obs-versions.com/blog/how-to-fix-audio-delay-on-obs (audio ahead => positive delay). Verified: build 0 warnings; 3/3 delay tests pass. --- Controls/PreviewPane.xaml | 12 +++ HANDOFF.md | 158 +++++++--------------------- Helpers/IntToSyncBrushConverter.cs | 19 ++++ Services/Audio/AudioMixer.cs | 13 +++ Services/Audio/AudioSyncDelay.cs | 83 +++++++++++++++ Services/LayoutStore.Settings.cs | 13 +++ TASKS.md | 17 +-- Themes/Controls.xaml | 1 + ViewModels/MainViewModel.Audio.cs | 16 +++ ViewModels/MainViewModel.cs | 5 +- ai.md | 9 +- ytLive.Tests/AudioSyncDelayTests.cs | 77 ++++++++++++++ 12 files changed, 295 insertions(+), 128 deletions(-) create mode 100644 Helpers/IntToSyncBrushConverter.cs create mode 100644 Services/Audio/AudioSyncDelay.cs create mode 100644 ytLive.Tests/AudioSyncDelayTests.cs 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; + } +}