Public Access
fix: recover from microphone disconnects
PR and Push Build/Test / build-and-test (push) Successful in 12m29s
PR and Push Build/Test / build-and-test (push) Successful in 12m29s
This commit is contained in:
@@ -65,6 +65,7 @@
|
||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
|
||||
<Compile Remove="Hotkeys\GlobalHotkeyService.cs" />
|
||||
<Compile Remove="Recording\NaudioCaptureSource.cs" />
|
||||
<Compile Remove="Recording\WindowsMicrophoneDeviceProvider.cs" />
|
||||
<Compile Remove="MeetingNotes\OutlookClassicMeetingMetadataProvider.Windows.cs" />
|
||||
<Compile Remove="Screenshots\ActiveWindowScreenshotCapture.Windows.cs" />
|
||||
<Compile Remove="Taskbar\UnoTaskbarIconService.Windows.cs" />
|
||||
|
||||
@@ -20,7 +20,11 @@ builder.Services.Configure<MeetingAssistantOptions>(builder.Configuration.GetSec
|
||||
builder.Services.AddSingleton<ILaunchProfileOptionsProvider, ConfigurationLaunchProfileOptionsProvider>();
|
||||
#if WINDOWS
|
||||
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<SystemAudioSource>();
|
||||
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;
|
||||
|
||||
public interface IMicrophoneDeviceProvider
|
||||
@@ -7,6 +5,4 @@ public interface IMicrophoneDeviceProvider
|
||||
IReadOnlyList<MicrophoneDevice> GetAvailableMicrophones();
|
||||
|
||||
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;
|
||||
return FindById(availableDevices, selected) ??
|
||||
FindById(availableDevices, configuredDeviceId) ??
|
||||
FindById(availableDevices, defaultDevice?.Id) ??
|
||||
availableDevices.FirstOrDefault() ??
|
||||
defaultDevice;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,41 +4,28 @@ using NAudio.Wave;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
internal sealed class NaudioCaptureAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private readonly IMicrophoneDeviceProvider microphones;
|
||||
private readonly ILogger<MicrophoneAudioSource> logger;
|
||||
private readonly IWaveIn capture;
|
||||
private readonly string sourceName;
|
||||
private readonly ILogger logger;
|
||||
|
||||
public MicrophoneAudioSource(
|
||||
IMicrophoneDeviceProvider microphones,
|
||||
ILogger<MicrophoneAudioSource> logger)
|
||||
public NaudioCaptureAudioSource(
|
||||
IWaveIn capture,
|
||||
string sourceName,
|
||||
ILogger logger)
|
||||
{
|
||||
this.microphones = microphones;
|
||||
this.capture = capture;
|
||||
this.sourceName = sourceName;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return CaptureAsync(new MeetingAssistantOptions(), cancellationToken);
|
||||
return CaptureWith(capture, sourceName, logger, cancellationToken);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||
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(
|
||||
private static async IAsyncEnumerable<AudioChunk> CaptureWith(
|
||||
IWaveIn capture,
|
||||
string sourceName,
|
||||
ILogger logger,
|
||||
@@ -124,6 +111,6 @@ public sealed class SystemAudioSource : IMeetingAudioSource
|
||||
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;
|
||||
|
||||
public sealed class WindowsMicrophoneDeviceProvider : IMicrophoneDeviceProvider
|
||||
public sealed class WindowsMicrophoneDeviceProvider : IMicrophoneDeviceProvider, IMicrophoneCaptureSourceFactory
|
||||
{
|
||||
private readonly MicrophoneDeviceSelection selection;
|
||||
private readonly ILogger<WindowsMicrophoneDeviceProvider> logger;
|
||||
@@ -42,21 +42,38 @@ public sealed class WindowsMicrophoneDeviceProvider : IMicrophoneDeviceProvider
|
||||
selection.Resolve(options.Recording.MicrophoneDeviceId, GetDefaultMicrophone(), devices));
|
||||
}
|
||||
|
||||
public IWaveIn CreateCapture(MeetingAssistantOptions options)
|
||||
public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options)
|
||||
{
|
||||
var current = GetMicrophoneSnapshot(options).Current;
|
||||
IWaveIn capture;
|
||||
if (current is null)
|
||||
{
|
||||
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(
|
||||
"Starting microphone capture from {MicrophoneName} ({MicrophoneDeviceId})",
|
||||
current.Name,
|
||||
current.Id);
|
||||
using var enumerator = new MMDeviceEnumerator();
|
||||
return new WasapiCapture(enumerator.GetDevice(current.Id));
|
||||
try
|
||||
{
|
||||
capture.WaveFormat = new WaveFormat(
|
||||
options.Recording.SampleRate,
|
||||
16,
|
||||
options.Recording.Channels);
|
||||
return new NaudioCaptureAudioSource(capture, "microphone", logger);
|
||||
}
|
||||
catch
|
||||
{
|
||||
capture.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static MicrophoneDevice? GetDefaultMicrophone()
|
||||
|
||||
Reference in New Issue
Block a user