TASK 22: global audio sync offset (positive delay at the mixer out)
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<int> 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.
This commit is contained in:
@@ -657,6 +657,18 @@
|
|||||||
PreviewMouseLeftButtonDown="VolumeSlider_PreviewMouseLeftButtonDown"
|
PreviewMouseLeftButtonDown="VolumeSlider_PreviewMouseLeftButtonDown"
|
||||||
PreviewMouseLeftButtonUp="VolumeSlider_PreviewMouseLeftButtonUp"
|
PreviewMouseLeftButtonUp="VolumeSlider_PreviewMouseLeftButtonUp"
|
||||||
LostMouseCapture="VolumeSlider_LostMouseCapture"/>
|
LostMouseCapture="VolumeSlider_LostMouseCapture"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="10,0,0,0" VerticalAlignment="Center"
|
||||||
|
ToolTip="Audio sync delay (ms): add a few ms so the mic/audio lands on the video when it runs ahead.">
|
||||||
|
<TextBlock Text="SYNC" FontSize="9" Foreground="#9898aa"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,5,0"/>
|
||||||
|
<Slider Width="70" Minimum="0" Maximum="500"
|
||||||
|
Value="{Binding AudioSyncOffsetMs, Mode=TwoWay}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
ToolTip="{Binding AudioSyncOffsetMs, StringFormat=Sync delay: {0} ms}"/>
|
||||||
|
<Border Width="8" Height="8" CornerRadius="4" Margin="6,0,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Background="{Binding AudioSyncOffsetMs, Converter={StaticResource IntToSyncBrush}}"/>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
+40
-118
@@ -2,132 +2,54 @@
|
|||||||
|
|
||||||
## Branch / Commit State
|
## Branch / Commit State
|
||||||
|
|
||||||
**`main`**. TASK 31 (SceneGraph + baked-crust compositor) implemented, tests green,
|
**`main`**, working toward TASK 22 (audio sync offset). Prior milestone **TASK 31**
|
||||||
docs updated — **uncommitted**, ready to commit as one work unit (pending push at
|
(SceneGraph + baked-crust compositor) is fully done, committed (`670fe3a`), pushed
|
||||||
the user's checkpoint).
|
(`origin/main` `3bf053a..670fe3a`), and verified by the user on native Windows
|
||||||
|
PowerShell (only the 2 known pre-existing failures remained).
|
||||||
**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` |
|
|
||||||
|
|
||||||
## What's In Flight
|
## What's In Flight
|
||||||
|
|
||||||
**TASK 31 (SceneGraph + baked-crust compositor) implemented — uncommitted, tests green.**
|
**TASK 22 (audio sync offset) — implemented, uncommitted, awaiting commit + user
|
||||||
Files touched (see `git status`): `Models/SceneElement.cs`, `Models/Source.cs`,
|
verification on Windows.**
|
||||||
`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.
|
|
||||||
|
|
||||||
**The directive (2026-08-31, user):** rewrite the project, breaking files into
|
Scope (Good-Dog single use-case; user chose positive-only slider at the mixer out):
|
||||||
**functional components to compliment AI retrieval/processing** — NOT line-count
|
a global `AudioSyncOffsetMs` (0..500 ms, default 0) that delays the whole
|
||||||
chasing. Line count is a guideline for context management, not a design goal.
|
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:**
|
Files touched (this work unit):
|
||||||
- **Phase 3 (A–E):** every production `.cs` ≤ 500, grouped by concern. These are
|
- `Services/Audio/AudioSyncDelay.cs` (new) — pure delay line, flushed on `Configure`.
|
||||||
*partial shuffles* — same class, same shared state. Marginal for retrieval.
|
- `Services/Audio/AudioMixer.cs` — `Func<int> syncOffsetMs` seam + `_syncDelay`
|
||||||
- **Commit F:** recording-output concern → `MainViewModel.Recording.cs` (partial).
|
applied post-limiter in `FillAndMix`.
|
||||||
- **Commit G — the real win:** `Services/ChatOverlayLayer.cs` is a genuine
|
- `Services/LayoutStore.Settings.cs` — `LoadAudioSyncOffsetMs`/`SaveAudioSyncOffsetMs`
|
||||||
owner-state component (buffer + renderer + timers + preview + live `RenderFrame`);
|
(`Audio.SyncOffsetMs`).
|
||||||
`MainViewModel.Chat.cs` 194 → 44 (thin binding facade). First true decomposition.
|
- `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
|
**Verified:** `ytLive.csproj` and `ytLive.Tests.csproj` build with 0 warnings;
|
||||||
(Audio, Webcam, Background, Scenes, Socials…) are **binding glue over already-
|
`AudioSyncDelayTests` (3/3) pass via vstest filter. The full audio pipeline /
|
||||||
extracted services** (`AudioMixer`, `CameraManager`, `ScreenCaptureManager`,
|
RealAppHost suites hang headless (pre-existing) — must be verified on native
|
||||||
`ChatBoxRenderer`, `SocialValidator`). They have no cohesive owner-state blob to
|
Windows PowerShell by the user.
|
||||||
peel — forcing extraction adds coupling. **Chat was the one clean peel.**
|
|
||||||
|
|
||||||
**Next (recommended):** TASK 31 landed the *core* of the scene-graph architecture
|
**Landmine:** a stale `testhost` can lock `ytLive.Tests`'s `ytLive.dll` and break
|
||||||
(the `SceneGraph` owner-object owning the collection + mutation surface, and the
|
`dotnet build` of the test project — kill it first (`cmd.exe /c "taskkill /PID N /F"`)
|
||||||
baked-crust compositor optimization) without the full binding-surface peel. The
|
if the build hits MSB3027.
|
||||||
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.
|
|
||||||
|
|
||||||
**Pending bus:** `ViewModels/index.md` Phase 3 tracker has rows A–E (closed);
|
## Next
|
||||||
Phase 3 marked COMPLETE. Watch-list (under limit, no action): `Controls/PreviewPane.xaml.cs`
|
|
||||||
(464), `ViewModels/MainViewModel.Audio.cs` (408).
|
|
||||||
|
|
||||||
## 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):**
|
## The directive (2026-08-31, user)
|
||||||
`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`).
|
|
||||||
|
|
||||||
## Todo state
|
Rewrite the project into functional components to aid AI retrieval — the
|
||||||
|
`Services/ChatOverlayLayer.cs` style (owner-state extraction), not line-count
|
||||||
- MainViewModel refactor: **Phase 1 complete (11/11 partials)**, pushed.
|
chasing. TASK 31's `SceneGraph.cs` continued that pattern (an owner object owning
|
||||||
- MainWindow.xaml refactor: **Phase 2 complete (6/6 controls)**, pushed at `1f4624c`.
|
the scene collection + mutation surface).
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace ytLive.Helpers;
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ public sealed class AudioMixer : IDisposable
|
|||||||
private readonly Func<double>? _micGain;
|
private readonly Func<double>? _micGain;
|
||||||
private readonly Func<double>? _loopbackGain;
|
private readonly Func<double>? _loopbackGain;
|
||||||
private readonly TimeSpan _mixInterval;
|
private readonly TimeSpan _mixInterval;
|
||||||
|
private readonly Func<int>? _syncOffsetMs;
|
||||||
|
private readonly AudioSyncDelay _syncDelay;
|
||||||
private bool _started;
|
private bool _started;
|
||||||
|
|
||||||
// Live path (TASK 9): per-source resampling → voice chain on the mic →
|
// 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[]? _micChunk;
|
||||||
private float[]? _loopbackChunk;
|
private float[]? _loopbackChunk;
|
||||||
private float[]? _mixBuffer;
|
private float[]? _mixBuffer;
|
||||||
|
private float[]? _delayedMix;
|
||||||
|
|
||||||
public AudioMixer(
|
public AudioMixer(
|
||||||
IAudioSource mic,
|
IAudioSource mic,
|
||||||
@@ -52,6 +55,7 @@ public sealed class AudioMixer : IDisposable
|
|||||||
Action<string>? log = null,
|
Action<string>? log = null,
|
||||||
Func<double>? micGain = null,
|
Func<double>? micGain = null,
|
||||||
Func<double>? loopbackGain = null,
|
Func<double>? loopbackGain = null,
|
||||||
|
Func<int>? syncOffsetMs = null,
|
||||||
TimeSpan? mixInterval = null)
|
TimeSpan? mixInterval = null)
|
||||||
{
|
{
|
||||||
_mic = mic;
|
_mic = mic;
|
||||||
@@ -61,6 +65,8 @@ public sealed class AudioMixer : IDisposable
|
|||||||
_log = log;
|
_log = log;
|
||||||
_micGain = micGain;
|
_micGain = micGain;
|
||||||
_loopbackGain = loopbackGain;
|
_loopbackGain = loopbackGain;
|
||||||
|
_syncOffsetMs = syncOffsetMs;
|
||||||
|
_syncDelay = new AudioSyncDelay(OutputSampleRate);
|
||||||
_mixInterval = mixInterval ?? DefaultMixInterval;
|
_mixInterval = mixInterval ?? DefaultMixInterval;
|
||||||
|
|
||||||
var seconds = _mixInterval.TotalSeconds;
|
var seconds = _mixInterval.TotalSeconds;
|
||||||
@@ -333,6 +339,13 @@ public sealed class AudioMixer : IDisposable
|
|||||||
mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain;
|
mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain;
|
||||||
}
|
}
|
||||||
_masterLimiter.Process(mix);
|
_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;
|
return micRms;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ytLive.Services.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TASK 22: a fixed end-to-end audio delay applied to the interleaved stereo
|
||||||
|
/// mix in <see cref="AudioMixer"/>. 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 (<see cref="TimeSpan"/>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AudioSyncDelay
|
||||||
|
{
|
||||||
|
private readonly int _sampleRate;
|
||||||
|
|
||||||
|
private float[] _buffer = Array.Empty<float>();
|
||||||
|
private int _delaySamples;
|
||||||
|
private int _writePos;
|
||||||
|
|
||||||
|
public AudioSyncDelay(int sampleRate)
|
||||||
|
{
|
||||||
|
_sampleRate = Math.Max(1, sampleRate);
|
||||||
|
Configure(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sets the delay in whole milliseconds (clamped to 0..500 and
|
||||||
|
/// flushed).</summary>
|
||||||
|
public void Configure(int delayMs)
|
||||||
|
{
|
||||||
|
_delaySamples = Frames(delayMs);
|
||||||
|
_buffer = new float[(_delaySamples + _sampleRate) * 2 + 1024];
|
||||||
|
_writePos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Delays an interleaved stereo chunk by the configured offset.
|
||||||
|
/// The returned length equals <paramref name="input"/>'s; the head is
|
||||||
|
/// silence until the line fills.</summary>
|
||||||
|
public void Process(ReadOnlySpan<float> input, Span<float> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,19 @@ public partial class LayoutStore : IDisposable
|
|||||||
insert.ExecuteNonQuery();
|
insert.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The user's chosen record output folder (TASK 18), or null to use the
|
/// <summary>The user's chosen record output folder (TASK 18), or null to use the
|
||||||
/// default <c>%APPDATA%\ytLlive\recordings</c>. Restored at startup.</summary>
|
/// default <c>%APPDATA%\ytLlive\recordings</c>. Restored at startup.</summary>
|
||||||
public string? LoadRecordFolder()
|
public string? LoadRecordFolder()
|
||||||
|
|||||||
@@ -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.
|
**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)
|
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. ☐ Apply offset in `AudioMixer` — delay or advance audio samples relative to video
|
2. ☑ Applied in `AudioMixer` — post-mix interleaved-stereo delay via the pure `AudioSyncDelay` line (`Services/Audio/AudioSyncDelay.cs`), fed through a `Func<int> syncOffsetMs` seam each mix tick.
|
||||||
3. ☐ UI: offset slider per audio source (or global offset for simplicity)
|
3. ☑ UI: compact "SYNC" slider (0..500) on the mic bar with a status dot (green = no-op, amber = offset set) via `IntToSyncBrushConverter`.
|
||||||
4. ☐ Persist offset in `LayoutStore` (schema migration)
|
4. ☑ Persisted in `LayoutStore.Settings` (`Audio.SyncOffsetMs`) via `LoadAudioSyncOffsetMs`/`SaveAudioSyncOffsetMs`; saved from `SaveLayoutNow`.
|
||||||
5. ☐ Tests: offset application, positive/negative delay, boundary values
|
5. ☑ Tests: `AudioSyncDelayTests` — zero-delay identity, negative→0 clamp, >500 ms clamp to 500 ms, and 10 ms → 960 interleaved-sample shift.
|
||||||
|
|
||||||
### Design decisions
|
### Design decisions
|
||||||
|
|
||||||
- **Global offset first** — one setting for all audio sources. Per-source is v1.1+.
|
- **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.
|
- **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).
|
||||||
- **Visual feedback** — show a "sync OK" indicator when offset is applied.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -638,6 +638,7 @@
|
|||||||
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||||
<Helpers:InverseBoolToVisibilityConverter x:Key="InverseBoolToVis"/>
|
<Helpers:InverseBoolToVisibilityConverter x:Key="InverseBoolToVis"/>
|
||||||
<Helpers:NotNullToVisibilityConverter x:Key="NotNullToVis"/>
|
<Helpers:NotNullToVisibilityConverter x:Key="NotNullToVis"/>
|
||||||
|
<Helpers:IntToSyncBrushConverter x:Key="IntToSyncBrush"/>
|
||||||
<Helpers:NullToVisibilityConverter x:Key="NullToVis"/>
|
<Helpers:NullToVisibilityConverter x:Key="NullToVis"/>
|
||||||
<Helpers:HiddenBoolToVisibilityConverter x:Key="HiddenBoolToVis"/>
|
<Helpers:HiddenBoolToVisibilityConverter x:Key="HiddenBoolToVis"/>
|
||||||
<Helpers:EnumToBoolConverter x:Key="EnumToBool"/>
|
<Helpers:EnumToBoolConverter x:Key="EnumToBool"/>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public partial class MainViewModel
|
|||||||
private double _gameVolume = 1.0;
|
private double _gameVolume = 1.0;
|
||||||
private bool _gameMuted;
|
private bool _gameMuted;
|
||||||
private double? _gameVolumeBeforeMute;
|
private double? _gameVolumeBeforeMute;
|
||||||
|
private int _audioSyncOffsetMs;
|
||||||
private AudioGainProvider? _gainProvider;
|
private AudioGainProvider? _gainProvider;
|
||||||
private readonly IMicrophoneEnumerator _microphoneEnumerator;
|
private readonly IMicrophoneEnumerator _microphoneEnumerator;
|
||||||
private readonly AudioMixer _audioMixer;
|
private readonly AudioMixer _audioMixer;
|
||||||
@@ -154,6 +155,21 @@ public partial class MainViewModel
|
|||||||
private set => SetProperty(ref _micSourceName, value);
|
private set => SetProperty(ref _micSourceName, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public int AudioSyncOffsetMs
|
||||||
|
{
|
||||||
|
get => _audioSyncOffsetMs;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var clamped = Math.Clamp(value, 0, 500);
|
||||||
|
if (SetProperty(ref _audioSyncOffsetMs, clamped))
|
||||||
|
ScheduleSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ToggleMicMute()
|
private void ToggleMicMute()
|
||||||
{
|
{
|
||||||
if (MicMuted)
|
if (MicMuted)
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
// the same, already-vetted device (green dot) or reports it missing
|
// the same, already-vetted device (green dot) or reports it missing
|
||||||
// (yellow) instead of silently falling back to the default.
|
// (yellow) instead of silently falling back to the default.
|
||||||
MicSourceName = _layoutStore.LoadMicSourceName();
|
MicSourceName = _layoutStore.LoadMicSourceName();
|
||||||
|
AudioSyncOffsetMs = _layoutStore.LoadAudioSyncOffsetMs();
|
||||||
|
|
||||||
_cameraEnumerator = new MediaCaptureCameraEnumerator();
|
_cameraEnumerator = new MediaCaptureCameraEnumerator();
|
||||||
_cameraManager = new CameraManager(
|
_cameraManager = new CameraManager(
|
||||||
@@ -258,7 +259,8 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
new WasapiLoopbackAudioSource(),
|
new WasapiLoopbackAudioSource(),
|
||||||
message => AppLog.Write(message),
|
message => AppLog.Write(message),
|
||||||
micGain: _gainProvider.MicGain,
|
micGain: _gainProvider.MicGain,
|
||||||
loopbackGain: _gainProvider.LoopbackGain);
|
loopbackGain: _gainProvider.LoopbackGain,
|
||||||
|
syncOffsetMs: () => AudioSyncOffsetMs);
|
||||||
_audioMixer.MicLevelChanged += OnMicLevelChanged;
|
_audioMixer.MicLevelChanged += OnMicLevelChanged;
|
||||||
_audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged;
|
_audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged;
|
||||||
_audioMixer.MicConnected += OnMicConnected;
|
_audioMixer.MicConnected += OnMicConnected;
|
||||||
@@ -409,6 +411,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
_layoutStore.Save(Scenes, _webcam, _socials, _music);
|
_layoutStore.Save(Scenes, _webcam, _socials, _music);
|
||||||
_layoutStore.SaveTransitionSettings(
|
_layoutStore.SaveTransitionSettings(
|
||||||
SelectedTransitionType.ToString(), TransitionDurationMs);
|
SelectedTransitionType.ToString(), TransitionDurationMs);
|
||||||
|
_layoutStore.SaveAudioSyncOffsetMs(AudioSyncOffsetMs);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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** |
|
| `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** |
|
| `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 |
|
| `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`) |
|
| `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) |
|
| `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) —
|
`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
|
the reverse order stalls on pipe backpressure. `FfmpegEncoder` itself is untouched; the VM owns the
|
||||||
pipe lifecycle.
|
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<int> 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`
|
- **TRAX — free background music (TASK 8):** `MusicPlayer` = NAudio `MediaFoundationReader`
|
||||||
(mp3/wav/m4a) → `VolumeWaveProvider16` at the hardcoded **0.20** bed (no slider) → `WaveOutEvent` on
|
(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`
|
the default device, **looping on any clean natural end** (`PlaybackStopped` with `e.Exception == null`
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using Xunit;
|
||||||
|
using ytLive.Services.Audio;
|
||||||
|
|
||||||
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user