fix: recover from microphone disconnects
PR and Push Build/Test / build-and-test (push) Successful in 12m29s

This commit is contained in:
2026-08-03 15:57:30 +02:00
parent 75250f6041
commit b9547ae4c4
16 changed files with 574 additions and 40 deletions
@@ -102,6 +102,23 @@ public sealed class AudioMixingTests
Assert.Equal(2_000, BitConverter.ToInt16(chunks[0].Pcm)); Assert.Equal(2_000, BitConverter.ToInt16(chunks[0].Pcm));
} }
[Fact]
public async Task CompositeAudioSourceKeepsSystemAudioWhileMicrophoneIsRecovering()
{
var microphone = new WaitingAudioSource();
var system = new FixedAudioSource(Pcm16(10_000));
var source = CreateSource(microphone, system);
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await using var chunks = source
.CaptureAsync(new MeetingAssistantOptions(), cancellation.Token)
.GetAsyncEnumerator(cancellation.Token);
Assert.True(await chunks.MoveNextAsync());
Assert.Equal(10_000, BitConverter.ToInt16(chunks.Current.Pcm));
await cancellation.CancelAsync();
}
[Fact] [Fact]
public void AdaptiveEchoCancellerReducesEchoFromMicrophoneSignal() public void AdaptiveEchoCancellerReducesEchoFromMicrophoneSignal()
{ {
@@ -221,6 +238,16 @@ public sealed class AudioMixingTests
} }
} }
private sealed class WaitingAudioSource : IMeetingAudioSource
{
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
yield break;
}
}
private sealed class OptionsCapturingAudioSource : IMeetingAudioSource private sealed class OptionsCapturingAudioSource : IMeetingAudioSource
{ {
private readonly AudioChunk chunk; private readonly AudioChunk chunk;
@@ -0,0 +1,109 @@
using MeetingAssistant.Recording;
using Microsoft.Extensions.Logging.Abstractions;
namespace MeetingAssistant.Tests;
public sealed class MicrophoneAudioSourceTests
{
[Fact]
public async Task CaptureMovesToNewlyResolvedMicrophoneWhenCurrentCaptureFails()
{
var captureSources = new SequenceMicrophoneCaptureSourceFactory(
new FailingAfterChunkAudioSource(Pcm16(1_000)),
new ActiveAudioSource(Pcm16(2_000)));
var source = new MicrophoneAudioSource(
captureSources,
NullLogger<MicrophoneAudioSource>.Instance,
TimeSpan.Zero);
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await using var chunks = source
.CaptureAsync(new MeetingAssistantOptions(), cancellation.Token)
.GetAsyncEnumerator(cancellation.Token);
Assert.True(await chunks.MoveNextAsync());
Assert.Equal(1_000, BitConverter.ToInt16(chunks.Current.Pcm));
Assert.True(await chunks.MoveNextAsync());
Assert.Equal(2_000, BitConverter.ToInt16(chunks.Current.Pcm));
Assert.Equal(2, captureSources.CaptureCreationCount);
await cancellation.CancelAsync();
}
[Fact]
public async Task CaptureWaitsForMicrophoneToBecomeAvailable()
{
var captureSources = new InitiallyUnavailableMicrophoneCaptureSourceFactory(
new ActiveAudioSource(Pcm16(3_000)));
var source = new MicrophoneAudioSource(
captureSources,
NullLogger<MicrophoneAudioSource>.Instance,
TimeSpan.Zero);
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await using var chunks = source
.CaptureAsync(new MeetingAssistantOptions(), cancellation.Token)
.GetAsyncEnumerator(cancellation.Token);
Assert.True(await chunks.MoveNextAsync());
Assert.Equal(3_000, BitConverter.ToInt16(chunks.Current.Pcm));
Assert.Equal(2, captureSources.CaptureCreationCount);
await cancellation.CancelAsync();
}
private static byte[] Pcm16(short sample)
{
return BitConverter.GetBytes(sample);
}
private sealed class SequenceMicrophoneCaptureSourceFactory(params IMeetingAudioSource[] sources)
: IMicrophoneCaptureSourceFactory
{
private readonly Queue<IMeetingAudioSource> sources = new(sources);
public int CaptureCreationCount { get; private set; }
public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options)
{
CaptureCreationCount++;
return sources.Dequeue();
}
}
private sealed class InitiallyUnavailableMicrophoneCaptureSourceFactory(IMeetingAudioSource availableSource)
: IMicrophoneCaptureSourceFactory
{
public int CaptureCreationCount { get; private set; }
public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options)
{
CaptureCreationCount++;
if (CaptureCreationCount == 1)
{
throw new InvalidOperationException("No microphone is currently available.");
}
return availableSource;
}
}
private sealed class FailingAfterChunkAudioSource(byte[] pcm) : IMeetingAudioSource
{
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
yield return new AudioChunk(pcm, 16000, 1);
throw new InvalidOperationException("The active microphone was disconnected.");
}
}
private sealed class ActiveAudioSource(byte[] pcm) : IMeetingAudioSource
{
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return new AudioChunk(pcm, 16000, 1);
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
}
}
@@ -51,4 +51,17 @@ public sealed class MicrophoneSelectionTests
Assert.Equal("runtime-id", selected?.Id); Assert.Equal("runtime-id", selected?.Id);
} }
[Fact]
public void UnavailableDefaultMicrophoneFallsBackToAnotherActiveDevice()
{
var selection = new MicrophoneDeviceSelection();
var selected = selection.Resolve(
configuredDeviceId: null,
new MicrophoneDevice("disconnected-id", "disconnected microphone"),
[new MicrophoneDevice("backup-id", "backup microphone")]);
Assert.Equal("backup-id", selected?.Id);
}
} }
+1
View File
@@ -65,6 +65,7 @@
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'"> <ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
<Compile Remove="Hotkeys\GlobalHotkeyService.cs" /> <Compile Remove="Hotkeys\GlobalHotkeyService.cs" />
<Compile Remove="Recording\NaudioCaptureSource.cs" /> <Compile Remove="Recording\NaudioCaptureSource.cs" />
<Compile Remove="Recording\WindowsMicrophoneDeviceProvider.cs" />
<Compile Remove="MeetingNotes\OutlookClassicMeetingMetadataProvider.Windows.cs" /> <Compile Remove="MeetingNotes\OutlookClassicMeetingMetadataProvider.Windows.cs" />
<Compile Remove="Screenshots\ActiveWindowScreenshotCapture.Windows.cs" /> <Compile Remove="Screenshots\ActiveWindowScreenshotCapture.Windows.cs" />
<Compile Remove="Taskbar\UnoTaskbarIconService.Windows.cs" /> <Compile Remove="Taskbar\UnoTaskbarIconService.Windows.cs" />
+5 -1
View File
@@ -20,7 +20,11 @@ builder.Services.Configure<MeetingAssistantOptions>(builder.Configuration.GetSec
builder.Services.AddSingleton<ILaunchProfileOptionsProvider, ConfigurationLaunchProfileOptionsProvider>(); builder.Services.AddSingleton<ILaunchProfileOptionsProvider, ConfigurationLaunchProfileOptionsProvider>();
#if WINDOWS #if WINDOWS
builder.Services.AddSingleton<MicrophoneDeviceSelection>(); builder.Services.AddSingleton<MicrophoneDeviceSelection>();
builder.Services.AddSingleton<IMicrophoneDeviceProvider, WindowsMicrophoneDeviceProvider>(); builder.Services.AddSingleton<WindowsMicrophoneDeviceProvider>();
builder.Services.AddSingleton<IMicrophoneDeviceProvider>(services =>
services.GetRequiredService<WindowsMicrophoneDeviceProvider>());
builder.Services.AddSingleton<IMicrophoneCaptureSourceFactory>(services =>
services.GetRequiredService<WindowsMicrophoneDeviceProvider>());
builder.Services.AddSingleton<MicrophoneAudioSource>(); builder.Services.AddSingleton<MicrophoneAudioSource>();
builder.Services.AddSingleton<SystemAudioSource>(); builder.Services.AddSingleton<SystemAudioSource>();
builder.Services.AddSingleton<IAcousticEchoCancellerFactory, AdaptiveFilterAcousticEchoCancellerFactory>(); builder.Services.AddSingleton<IAcousticEchoCancellerFactory, AdaptiveFilterAcousticEchoCancellerFactory>();
@@ -0,0 +1,6 @@
namespace MeetingAssistant.Recording;
public interface IMicrophoneCaptureSourceFactory
{
IMeetingAudioSource CreateCapture(MeetingAssistantOptions options);
}
@@ -1,5 +1,3 @@
using NAudio.Wave;
namespace MeetingAssistant.Recording; namespace MeetingAssistant.Recording;
public interface IMicrophoneDeviceProvider public interface IMicrophoneDeviceProvider
@@ -7,6 +5,4 @@ public interface IMicrophoneDeviceProvider
IReadOnlyList<MicrophoneDevice> GetAvailableMicrophones(); IReadOnlyList<MicrophoneDevice> GetAvailableMicrophones();
MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options); MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options);
IWaveIn CreateCapture(MeetingAssistantOptions options);
} }
@@ -0,0 +1,145 @@
using System.Runtime.CompilerServices;
namespace MeetingAssistant.Recording;
public sealed class MicrophoneAudioSource : IMeetingAudioSource
{
private static readonly TimeSpan DefaultRecoveryDelay = TimeSpan.FromSeconds(1);
private readonly IMicrophoneCaptureSourceFactory captureSources;
private readonly ILogger<MicrophoneAudioSource> logger;
private readonly TimeSpan recoveryDelay;
public MicrophoneAudioSource(
IMicrophoneCaptureSourceFactory captureSources,
ILogger<MicrophoneAudioSource> logger)
: this(captureSources, logger, DefaultRecoveryDelay)
{
}
internal MicrophoneAudioSource(
IMicrophoneCaptureSourceFactory captureSources,
ILogger<MicrophoneAudioSource> logger,
TimeSpan recoveryDelay)
{
this.captureSources = captureSources;
this.logger = logger;
this.recoveryDelay = recoveryDelay;
}
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
{
return CaptureAsync(new MeetingAssistantOptions(), cancellationToken);
}
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
MeetingAssistantOptions options,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var failedAttempts = 0;
while (!cancellationToken.IsCancellationRequested)
{
IAsyncEnumerator<AudioChunk>? capture = null;
Exception? failure = null;
try
{
capture = captureSources
.CreateCapture(options)
.CaptureAsync(options, cancellationToken)
.GetAsyncEnumerator(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (Exception exception)
{
failure = exception;
}
if (cancellationToken.IsCancellationRequested)
{
yield break;
}
if (capture is not null)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
var hasNext = false;
try
{
hasNext = await capture.MoveNextAsync();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (Exception exception)
{
failure = exception;
}
if (cancellationToken.IsCancellationRequested || failure is not null || !hasNext)
{
break;
}
if (failedAttempts > 0)
{
logger.LogInformation(
"Microphone capture recovered after {FailedAttemptCount} failed attempt(s)",
failedAttempts);
failedAttempts = 0;
}
yield return capture.Current;
}
}
finally
{
try
{
await capture.DisposeAsync();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (Exception exception)
{
failure ??= exception;
}
}
}
if (cancellationToken.IsCancellationRequested)
{
yield break;
}
failedAttempts++;
logger.LogWarning(
failure,
"Microphone capture stopped unexpectedly; re-resolving an available microphone in {RecoveryDelay}",
recoveryDelay);
if (!await WaitForRecoveryAsync(cancellationToken))
{
yield break;
}
}
}
private async Task<bool> WaitForRecoveryAsync(CancellationToken cancellationToken)
{
try
{
await Task.Delay(recoveryDelay, cancellationToken);
return true;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return false;
}
}
}
@@ -34,6 +34,8 @@ public sealed class MicrophoneDeviceSelection
var selected = SelectedDeviceId; var selected = SelectedDeviceId;
return FindById(availableDevices, selected) ?? return FindById(availableDevices, selected) ??
FindById(availableDevices, configuredDeviceId) ?? FindById(availableDevices, configuredDeviceId) ??
FindById(availableDevices, defaultDevice?.Id) ??
availableDevices.FirstOrDefault() ??
defaultDevice; defaultDevice;
} }
@@ -4,41 +4,28 @@ using NAudio.Wave;
namespace MeetingAssistant.Recording; namespace MeetingAssistant.Recording;
public sealed class MicrophoneAudioSource : IMeetingAudioSource internal sealed class NaudioCaptureAudioSource : IMeetingAudioSource
{ {
private readonly IMicrophoneDeviceProvider microphones; private readonly IWaveIn capture;
private readonly ILogger<MicrophoneAudioSource> logger; private readonly string sourceName;
private readonly ILogger logger;
public MicrophoneAudioSource( public NaudioCaptureAudioSource(
IMicrophoneDeviceProvider microphones, IWaveIn capture,
ILogger<MicrophoneAudioSource> logger) string sourceName,
ILogger logger)
{ {
this.microphones = microphones; this.capture = capture;
this.sourceName = sourceName;
this.logger = logger; this.logger = logger;
} }
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken) public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
{ {
return CaptureAsync(new MeetingAssistantOptions(), cancellationToken); return CaptureWith(capture, sourceName, logger, cancellationToken);
} }
public IAsyncEnumerable<AudioChunk> CaptureAsync( private static async IAsyncEnumerable<AudioChunk> CaptureWith(
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return CaptureAsync(microphones.CreateCapture(options), options, cancellationToken);
}
private IAsyncEnumerable<AudioChunk> CaptureAsync(
IWaveIn capture,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
capture.WaveFormat = new WaveFormat(options.Recording.SampleRate, 16, options.Recording.Channels);
return CaptureWith(capture, "microphone", logger, cancellationToken);
}
internal static async IAsyncEnumerable<AudioChunk> CaptureWith(
IWaveIn capture, IWaveIn capture,
string sourceName, string sourceName,
ILogger logger, ILogger logger,
@@ -124,6 +111,6 @@ public sealed class SystemAudioSource : IMeetingAudioSource
WaveFormat = new WaveFormat(options.Recording.SampleRate, 16, options.Recording.Channels) WaveFormat = new WaveFormat(options.Recording.SampleRate, 16, options.Recording.Channels)
}; };
return MicrophoneAudioSource.CaptureWith(capture, "system", logger, cancellationToken); return new NaudioCaptureAudioSource(capture, "system", logger).CaptureAsync(cancellationToken);
} }
} }
@@ -3,7 +3,7 @@ using NAudio.Wave;
namespace MeetingAssistant.Recording; namespace MeetingAssistant.Recording;
public sealed class WindowsMicrophoneDeviceProvider : IMicrophoneDeviceProvider public sealed class WindowsMicrophoneDeviceProvider : IMicrophoneDeviceProvider, IMicrophoneCaptureSourceFactory
{ {
private readonly MicrophoneDeviceSelection selection; private readonly MicrophoneDeviceSelection selection;
private readonly ILogger<WindowsMicrophoneDeviceProvider> logger; private readonly ILogger<WindowsMicrophoneDeviceProvider> logger;
@@ -42,21 +42,38 @@ public sealed class WindowsMicrophoneDeviceProvider : IMicrophoneDeviceProvider
selection.Resolve(options.Recording.MicrophoneDeviceId, GetDefaultMicrophone(), devices)); selection.Resolve(options.Recording.MicrophoneDeviceId, GetDefaultMicrophone(), devices));
} }
public IWaveIn CreateCapture(MeetingAssistantOptions options) public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options)
{ {
var current = GetMicrophoneSnapshot(options).Current; var current = GetMicrophoneSnapshot(options).Current;
IWaveIn capture;
if (current is null) if (current is null)
{ {
logger.LogInformation("Starting microphone capture from Windows default capture endpoint"); logger.LogInformation("Starting microphone capture from Windows default capture endpoint");
return new WasapiCapture(); capture = new WasapiCapture();
}
else
{
logger.LogInformation(
"Starting microphone capture from {MicrophoneName} ({MicrophoneDeviceId})",
current.Name,
current.Id);
using var enumerator = new MMDeviceEnumerator();
capture = new WasapiCapture(enumerator.GetDevice(current.Id));
} }
logger.LogInformation( try
"Starting microphone capture from {MicrophoneName} ({MicrophoneDeviceId})", {
current.Name, capture.WaveFormat = new WaveFormat(
current.Id); options.Recording.SampleRate,
using var enumerator = new MMDeviceEnumerator(); 16,
return new WasapiCapture(enumerator.GetDevice(current.Id)); options.Recording.Channels);
return new NaudioCaptureAudioSource(capture, "microphone", logger);
}
catch
{
capture.Dispose();
throw;
}
} }
private static MicrophoneDevice? GetDefaultMicrophone() private static MicrophoneDevice? GetDefaultMicrophone()
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-03
@@ -0,0 +1,60 @@
## Context
The Windows microphone source currently creates one NAudio `IWaveIn` for the lifetime of a recording. When the endpoint is unplugged, NAudio reports a WASAPI exception through `RecordingStopped`; the source completes exceptionally, the composite source treats that as fatal, and `MeetingRecordingCoordinator` ends the run. The composite source already tolerates a temporarily quiet microphone by mixing system audio with synthetic silence after its alignment timeout, so recovery can be isolated to the microphone side.
The microphone selection provider already re-enumerates active endpoints whenever it creates a capture. Its selection rules ignore an unavailable runtime/configured device and fall back to the current Windows default. The missing behavior is retrying that resolution after an active capture fails.
## Goals / Non-Goals
**Goals:**
- Keep the active meeting run alive when microphone capture fails or stops unexpectedly.
- Re-resolve the effective microphone on every recovery attempt so another active endpoint can take over.
- Keep system-loopback audio flowing while microphone recovery is pending.
- Verify recovery deterministically through the public audio-source contract without physical audio devices.
**Non-Goals:**
- Recover system-loopback capture failures.
- Persist or change the user's runtime microphone selection.
- Add UI, endpoint, or configuration controls for recovery.
- Splice or manufacture microphone audio for the disconnected interval.
## Decisions
### Keep retry orchestration outside the NAudio adapter
`MicrophoneAudioSource` will own a recovery loop and ask `IMicrophoneDeviceProvider` for a new capture source on each attempt. The Windows provider will continue to own endpoint enumeration and selection, while an NAudio-specific adapter will own one `IWaveIn` lifetime.
This keeps device selection and WASAPI details behind a narrow boundary and makes the observable recovery behavior testable with deterministic capture sources. Retrying the same `IWaveIn` instance was rejected because a disconnected WASAPI client is not a reliable basis for endpoint failover.
### Treat unexpected completion and capture exceptions as recoverable
While the recording cancellation token remains active, microphone-source creation failures, capture exceptions, and clean-but-unexpected capture completion will all trigger another attempt. Cancellation remains the only normal terminal condition for the microphone stream.
This deliberately contains microphone failures without changing the composite source's handling of system-audio failures.
### Re-resolve after a bounded delay
Each recovery attempt will call the provider again after a short fixed delay. Recreating through the provider re-enumerates active devices and applies the existing runtime selection, configured selection, and Windows-default fallback rules. The delay prevents a busy loop while Windows is still updating endpoint state.
No new setting is introduced because recovery timing is an internal reliability detail and does not need user tuning for the current scope.
### Reuse the composite source's missing-stream behavior
The recovering microphone enumerable remains active between attempts instead of completing. The independently pumped system source therefore continues writing chunks, and the composite source's existing alignment timeout mixes those chunks with silent microphone samples until real microphone chunks resume.
## Risks / Trade-offs
- **Windows endpoint enumeration can lag behind physical disconnects** → Retry through fresh provider calls until the device list and default endpoint stabilize.
- **A persistent microphone or driver failure can retry indefinitely** → Use a delay, log each failed attempt, and stop immediately when the recording is canceled.
- **The replacement endpoint can have different native capabilities** → Continue requesting the run's configured PCM format through the same NAudio adapter; failed formats remain recoverable and retryable.
- **There is an unavoidable microphone gap during failover** → Preserve the meeting and system audio rather than inventing microphone samples; the mixed stream contains silence for the missing microphone interval.
## Migration Plan
No data or configuration migration is required. Deploy the updated executable normally. Rollback consists of restoring the previous executable; existing meeting artifacts are unaffected.
## Open Questions
None for this change.
@@ -0,0 +1,26 @@
## Why
Unplugging the active microphone currently propagates a WASAPI capture error through the recording pipeline and terminates the active meeting recording. Recording must remain available through transient device changes so that already-captured meeting work and continued system audio are not lost.
## What Changes
- Recover microphone capture when the active Windows capture endpoint disappears or otherwise stops unexpectedly.
- Re-resolve the effective microphone for each recovery attempt so an available configured, runtime-selected, default, or fallback endpoint can take over.
- Keep the active recording and its independent system-audio capture alive while no microphone is temporarily available.
- Log microphone recovery failures and successful capture restarts without terminating the meeting run.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `meeting-recording`: Active recording becomes resilient to microphone endpoint disconnection and automatically resumes microphone capture from an available endpoint.
## Impact
- Affects the Windows microphone capture source and device-provider boundary.
- Adds behavior tests around the public meeting audio-source contract.
- Does not change recording endpoints, tray controls, system-loopback capture, or transcription-provider APIs.
@@ -0,0 +1,125 @@
## MODIFIED Requirements
### Requirement: Recording mode captures microphone and computer output
Meeting Assistant SHALL capture microphone input and computer output and combine them into one audio stream for transcription.
Meeting Assistant SHALL capture audio as 16 kHz mono PCM chunks for the existing recording and transcription pipeline.
Meeting Assistant SHALL capture microphone and system loopback as separate input streams before producing the final mono chunks.
Meeting Assistant SHALL clean the microphone stream with a local acoustic echo cancellation stage that uses system loopback as the far-end reference.
Meeting Assistant SHALL produce final mono chunks by adding the cleaned microphone samples and system samples.
Meeting Assistant SHALL align microphone and system samples through per-source buffers before mixing and SHALL NOT emit normal live audio chunks that contain only one source while the other source is merely delayed.
When one source stays quiet beyond the alignment timeout, Meeting Assistant SHALL mix the available source with synthetic silence for the missing source instead of blocking transcription.
Meeting Assistant SHALL allow the final microphone/system mono mix to apply configurable microphone and system gain before combining samples.
Meeting Assistant SHALL use the active run or launch profile recording options when configuring capture format and final microphone/system gains.
Meeting Assistant SHALL clamp mixed samples after gain is applied.
Meeting Assistant SHALL write only the mixed stream to the temporary WAV used by transcription and finalization.
Meeting Assistant SHALL allow `Recording:MicrophoneDeviceId` to select a Windows microphone capture endpoint.
When `Recording:MicrophoneDeviceId` is blank or absent, Meeting Assistant SHALL use the Windows default capture endpoint.
When a microphone is selected from the tray icon menu, Meeting Assistant SHALL use that selected microphone for later recording starts until another microphone is selected or the process exits.
The tray icon right-click menu SHALL expose a `Microphone` submenu listing active microphone capture endpoints.
The `Microphone` submenu SHALL mark exactly one effective microphone as checked.
When no runtime microphone override is selected, the checked microphone SHALL be the configured microphone when it is available, otherwise the Windows default capture endpoint.
When the active microphone endpoint disappears, microphone capture fails, or microphone capture stops unexpectedly while a meeting recording is active, Meeting Assistant SHALL keep the meeting recording active and SHALL repeatedly re-resolve and restart microphone capture until capture succeeds or the recording is stopped.
Each microphone recovery attempt SHALL re-enumerate active microphone endpoints and apply the existing runtime-selected, configured, and Windows-default selection rules so an available endpoint can take over.
While microphone recovery is pending, Meeting Assistant SHALL keep system-loopback capture active and SHALL continue producing mixed audio with synthetic silence for the missing microphone stream.
#### Scenario: Both sources produce audio
- **WHEN** microphone and computer output audio chunks are available
- **THEN** Meeting Assistant mixes them into one PCM stream before transcription
#### Scenario: Mixed audio uses cleaned microphone and system audio
- **GIVEN** the echo canceller cleans a microphone chunk to sample `2000`
- **AND** the matching system chunk has sample `10000`
- **WHEN** microphone and system chunks are mixed with gains `1` and `1`
- **THEN** the mixed sample is `12000`
#### Scenario: Temporary recording stores only the mixed stream
- **GIVEN** Meeting Assistant has mixed microphone and system audio into one PCM chunk
- **WHEN** Meeting Assistant appends the chunk to the temporary recording
- **THEN** the main temporary WAV contains the mixed PCM
- **AND** no microphone or system sidecar WAV is written
#### Scenario: Launch profile recording options configure capture and gains
- **GIVEN** an active launch profile configures sample format and microphone/system mix gains
- **WHEN** Meeting Assistant captures and mixes audio for that run
- **THEN** the microphone and system capture sources receive that launch profile recording configuration
- **AND** the mixed output uses that launch profile's microphone/system gains
#### Scenario: Delayed sources are buffered before mixing
- **GIVEN** microphone audio arrives before matching system audio
- **WHEN** matching system audio arrives after a short delay
- **THEN** Meeting Assistant emits one mixed chunk for the aligned samples
- **AND** it does not emit separate microphone-only and system-only chunks for that delayed pair
#### Scenario: Quiet system audio does not block microphone transcription
- **GIVEN** microphone audio arrives
- **AND** system loopback audio does not arrive within the alignment timeout
- **WHEN** Meeting Assistant mixes the available audio
- **THEN** it emits the microphone audio mixed with silent system audio
- **AND** live transcription can continue while system loopback is quiet
#### Scenario: Continuous microphone audio does not suppress the alignment timeout
- **GIVEN** microphone audio keeps arriving
- **AND** system loopback audio stays unavailable past the alignment timeout
- **WHEN** Meeting Assistant checks the buffered microphone audio
- **THEN** it emits the buffered microphone audio mixed with silent system audio
- **AND** it does not wait indefinitely for a loopback chunk
#### Scenario: Device-level capture cannot be verified in tests
- **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: Configured microphone is used for capture
- **GIVEN** `Recording:MicrophoneDeviceId` identifies an active microphone endpoint
- **WHEN** Meeting Assistant starts microphone capture
- **THEN** it captures from that endpoint
#### Scenario: Blank microphone setting uses Windows default
- **GIVEN** `Recording:MicrophoneDeviceId` is blank
- **WHEN** Meeting Assistant starts microphone capture
- **THEN** it captures from the Windows default capture endpoint
#### Scenario: Tray menu lists microphones with current selection checked
- **GIVEN** active microphone endpoints `integrated microphone` and `other microphone`
- **AND** `integrated microphone` is the effective microphone
- **WHEN** the taskbar menu is opened
- **THEN** it shows a `Microphone` submenu
- **AND** the `integrated microphone` item is checked
- **AND** the `other microphone` item is unchecked
#### Scenario: Tray microphone selection changes later capture
- **GIVEN** active microphone endpoints `integrated microphone` and `other microphone`
- **WHEN** the user selects `other microphone` from the taskbar microphone submenu
- **THEN** later recording starts capture from `other microphone`
#### Scenario: Disconnected microphone fails over during recording
- **GIVEN** a meeting is actively recording from one microphone and another microphone is available
- **WHEN** the active microphone is disconnected and its capture fails
- **THEN** the meeting recording remains active
- **AND** Meeting Assistant re-resolves the effective microphone and resumes capture from the available microphone
#### Scenario: Recording continues while no microphone is available
- **GIVEN** a meeting is actively recording
- **WHEN** the active microphone disconnects and no microphone is temporarily available
- **THEN** Meeting Assistant keeps the meeting recording and system-loopback capture active
- **AND** emits system audio mixed with synthetic microphone silence
- **WHEN** a microphone becomes available
- **THEN** Meeting Assistant resumes microphone capture for the same meeting run
@@ -0,0 +1,14 @@
## 1. Microphone recovery behavior
- [x] 1.1 Add a failing behavior test proving active microphone capture moves to a newly resolved capture source after the current source fails.
- [x] 1.2 Refactor the microphone device-provider boundary so recovery orchestration is platform-independent and individual NAudio capture lifetimes remain Windows-specific.
- [x] 1.3 Implement bounded-delay microphone recovery that re-resolves devices after creation failures, capture failures, and unexpected capture completion until recording cancellation.
- [x] 1.4 Add coverage proving capture recovers when no microphone is initially available and a later resolution succeeds.
- [x] 1.5 Add a failing selection test and fall back to an active endpoint when the selected and Windows-default endpoints are unavailable.
## 2. Verification
- [x] 2.1 Refactor the touched capture path for DRYness, SOLID boundaries, and KISS while preserving behavior.
- [x] 2.2 Run the focused microphone-selection and audio-source behavior tests plus the Windows application build.
- [x] 2.3 Run the full solution test suite and `openspec validate recover-microphone-disconnect --strict`.
- [x] 2.4 Verify the local health and recording-status surfaces without interrupting an active meeting run.