diff --git a/MeetingAssistant.Tests/AudioMixingTests.cs b/MeetingAssistant.Tests/AudioMixingTests.cs index 4c97fdf..71517fb 100644 --- a/MeetingAssistant.Tests/AudioMixingTests.cs +++ b/MeetingAssistant.Tests/AudioMixingTests.cs @@ -58,14 +58,21 @@ public sealed class AudioMixingTests [Fact] public async Task CompositeAudioSourceWaitsForMatchingStreamsBeforeMixing() { - var microphone = new DelayedAudioSource(TimeSpan.Zero, Pcm16(2_000)); - var system = new DelayedAudioSource(TimeSpan.FromMilliseconds(100), Pcm16(10_000)); + var microphone = new ControlledAudioSource(Pcm16(2_000)); + var system = new ControlledAudioSource(Pcm16(10_000)); var source = CreateSource(microphone, system); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await using var chunks = source.CaptureAsync(cancellation.Token).GetAsyncEnumerator(cancellation.Token); + var nextChunk = chunks.MoveNextAsync().AsTask(); - var chunks = await ReadChunks(source); + microphone.Release(); + await microphone.WaitUntilDeliveredAsync(); + Assert.False(nextChunk.IsCompleted); - var chunk = Assert.Single(chunks); - Assert.Equal(12_000, BitConverter.ToInt16(chunk.Pcm)); + system.Release(); + Assert.True(await nextChunk); + Assert.Equal(12_000, BitConverter.ToInt16(chunks.Current.Pcm)); + Assert.False(await chunks.MoveNextAsync()); } [Fact] @@ -187,6 +194,30 @@ public sealed class AudioMixingTests } } + private sealed class ControlledAudioSource : IMeetingAudioSource + { + private readonly AudioChunk chunk; + private readonly TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource delivered = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ControlledAudioSource(byte[] pcm) + { + chunk = new AudioChunk(pcm, 16000, 1); + } + + public void Release() => release.TrySetResult(); + + public Task WaitUntilDeliveredAsync() => delivered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + public async IAsyncEnumerable CaptureAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await release.Task.WaitAsync(cancellationToken); + yield return chunk; + delivered.TrySetResult(); + } + } + private sealed class DelayedAudioSource : IMeetingAudioSource { private readonly TimeSpan delay; diff --git a/openspec/changes/archive/2026-05-19-define-meeting-assistant-v1/specs/meeting-recording/spec.md b/openspec/changes/archive/2026-05-19-define-meeting-assistant-v1/specs/meeting-recording/spec.md index 2df0794..323f0bb 100644 --- a/openspec/changes/archive/2026-05-19-define-meeting-assistant-v1/specs/meeting-recording/spec.md +++ b/openspec/changes/archive/2026-05-19-define-meeting-assistant-v1/specs/meeting-recording/spec.md @@ -22,6 +22,12 @@ Meeting Assistant SHALL capture microphone input and computer output and combine - **WHEN** automated tests run without live audio devices - **THEN** Meeting Assistant verifies the audio mixer through deterministic source abstractions rather than depending on physical microphone or speaker devices +#### Scenario: Matching-stream verification controls source delivery +- **GIVEN** a microphone chunk has been accepted and matching system audio is pending within the supported skew window +- **WHEN** automated verification releases the matching system chunk +- **THEN** the mixer emits one combined chunk containing both sources +- **AND** source delivery is coordinated explicitly rather than assuming a timer continuation will run before the skew cutoff + ### Requirement: Stopping recording drains captured audio through transcription Meeting Assistant SHALL stop capturing new audio when recording mode is stopped, but it SHALL allow already captured audio to finish running through the configured speech recognition pipeline before the recording session completes.