Make meeting lifecycle stateful

This commit is contained in:
2026-05-27 12:55:17 +02:00
parent d607b957bb
commit e85274829a
91 changed files with 4076 additions and 479 deletions
@@ -12,20 +12,18 @@ public sealed class AsrDiagnosticService
this.pipelineFactory = pipelineFactory;
}
public async Task<AsrDiagnosticResult> TranscribeWavAsync(string path, CancellationToken cancellationToken)
public Task<AsrDiagnosticResult> TranscribeWavAsync(string path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("A WAV file path is required.", nameof(path));
}
return TranscribeWavAsync(path, launchProfileName: null, cancellationToken);
}
var fullPath = Path.GetFullPath(Environment.ExpandEnvironmentVariables(path));
if (!File.Exists(fullPath))
{
throw new FileNotFoundException($"WAV file was not found at '{fullPath}'.", fullPath);
}
await using var pipeline = pipelineFactory.Create();
public async Task<AsrDiagnosticResult> TranscribeWavAsync(
string path,
string? launchProfileName,
CancellationToken cancellationToken)
{
var fullPath = ResolveExistingWavPath(path);
await using var pipeline = pipelineFactory.Create(launchProfileName);
await pipeline.InitializeAsync(cancellationToken);
var liveTranscriptTask = CollectLiveTranscriptAsync(pipeline, cancellationToken);
await WriteWavToPipelineAsync(fullPath, pipeline, paceAudio: true, cancellationToken);
@@ -39,7 +37,17 @@ public sealed class AsrDiagnosticService
CancellationToken cancellationToken)
{
var fullPath = ResolveExistingWavPath(path);
await using var pipeline = pipelineFactory.Create();
return await DiarizeWavAsync(fullPath, options, launchProfileName: null, cancellationToken);
}
public async Task<AsrDiagnosticResult> DiarizeWavAsync(
string path,
SpeechRecognitionPipelineOptions options,
string? launchProfileName,
CancellationToken cancellationToken)
{
var fullPath = ResolveExistingWavPath(path);
await using var pipeline = pipelineFactory.Create(launchProfileName);
await pipeline.InitializeAsync(cancellationToken);
var liveTranscriptTask = CollectLiveTranscriptAsync(pipeline, cancellationToken);
await WriteWavToPipelineAsync(fullPath, pipeline, paceAudio: true, cancellationToken);
@@ -99,7 +107,9 @@ public sealed class AsrDiagnosticService
string path,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
using var reader = new WaveFileReader(path);
var reader = new WaveFileReader(path);
try
{
var format = reader.WaveFormat;
if (format.Encoding != WaveFormatEncoding.Pcm || format.BitsPerSample != 16)
{
@@ -113,6 +123,18 @@ public sealed class AsrDiagnosticService
{
yield return new AudioChunk(buffer[..read], format.SampleRate, format.Channels);
}
}
finally
{
try
{
reader.Dispose();
}
catch (NotSupportedException)
{
// NAudio can throw while disposing reader streams from async iterators; diagnostics should not fail after reading all chunks.
}
}
}
}
@@ -0,0 +1,18 @@
namespace MeetingAssistant.Transcription;
public sealed class AzureSpeechRecognitionPipelineBuilder :
SpeechRecognitionPipelineBuilder,
ISpeechRecognitionPipelineBuilder
{
public AzureSpeechRecognitionPipelineBuilder(IServiceProvider services)
: base(services)
{
}
public string ProviderName => "azure-speech";
public ISpeechRecognitionPipeline Create(MeetingAssistantOptions options)
{
return new AzureSpeechRecognitionPipeline(CreateProfiled<AzureSpeechStreamingTranscriptionProvider>(options));
}
}
@@ -25,7 +25,9 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
SpeechRecognitionPipelineOptions pipelineOptions,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await using var enumerator = audio.GetAsyncEnumerator(cancellationToken);
var enumerator = audio.GetAsyncEnumerator(cancellationToken);
try
{
if (!await enumerator.MoveNextAsync())
{
yield break;
@@ -94,6 +96,18 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
}
await pumpTask.WaitAsync(cancellationToken);
}
finally
{
try
{
await enumerator.DisposeAsync();
}
catch (NotSupportedException)
{
// Some diagnostic async enumerable sources expose DisposeAsync but throw after the stream has been read.
}
}
}
internal ConversationTranscriber CreateTranscriber(SpeechConfig speechConfig, AudioConfig audioConfig)
@@ -150,7 +164,7 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
$"Azure Speech key is not configured. Set AzureSpeech:Key or environment variable {azure.KeyEnv}.");
}
var speechConfig = SpeechConfig.FromEndpoint(GetEndpoint(azure), key);
var speechConfig = CreateSpeechConfig(azure, key);
var autoDetectLanguages = GetAutoDetectLanguages(azure);
if (autoDetectLanguages.Count == 0)
{
@@ -167,6 +181,13 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
speechConfig.SetProperty(
PropertyId.SpeechServiceResponse_DiarizeIntermediateResults,
azure.DiarizeIntermediateResults ? "true" : "false");
if (!string.IsNullOrWhiteSpace(azure.PostProcessingOption))
{
logger.LogWarning(
"Azure Speech post-processing option {PostProcessingOption} is configured but skipped because the live Azure backend uses ConversationTranscriber; Speech SDK post-processing is documented for SpeechRecognizer.",
azure.PostProcessingOption.Trim());
}
return speechConfig;
}
@@ -185,10 +206,17 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
}
internal static Uri GetEndpoint(AzureSpeechOptions options)
{
return string.IsNullOrWhiteSpace(options.Endpoint)
? throw new InvalidOperationException("Azure Speech endpoint must be configured.")
: new Uri(options.Endpoint);
}
internal static SpeechConfig CreateSpeechConfig(AzureSpeechOptions options, string key)
{
if (!string.IsNullOrWhiteSpace(options.Endpoint))
{
return new Uri(options.Endpoint);
return SpeechConfig.FromEndpoint(GetEndpoint(options), key);
}
if (string.IsNullOrWhiteSpace(options.Region))
@@ -196,7 +224,7 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
throw new InvalidOperationException("Azure Speech region or endpoint must be configured.");
}
return new Uri($"wss://{options.Region}.stt.speech.microsoft.com/speech/universal/v2");
return SpeechConfig.FromSubscription(key, options.Region.Trim());
}
internal static string NormalizeLanguageIdMode(string? value)
@@ -1,29 +1,75 @@
using MeetingAssistant.LaunchProfiles;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Transcription;
public sealed class ConfiguredSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
{
private readonly IServiceProvider services;
private readonly IReadOnlyDictionary<string, ISpeechRecognitionPipelineBuilder> builders;
private readonly MeetingAssistantOptions options;
private readonly ILaunchProfileOptionsProvider? launchProfiles;
public ConfiguredSpeechRecognitionPipelineFactory(
IServiceProvider services,
IEnumerable<ISpeechRecognitionPipelineBuilder> builders,
IOptions<MeetingAssistantOptions> options)
: this(builders, options, launchProfiles: null)
{
}
public ConfiguredSpeechRecognitionPipelineFactory(
IEnumerable<ISpeechRecognitionPipelineBuilder> builders,
IOptions<MeetingAssistantOptions> options,
ILaunchProfileOptionsProvider? launchProfiles)
{
this.services = services;
this.options = options.Value;
this.launchProfiles = launchProfiles;
this.builders = builders
.GroupBy(builder => builder.ProviderName, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
group => group.Key,
group => group.Count() == 1
? group.Single()
: throw new InvalidOperationException(
$"Speech recognition pipeline provider '{group.Key}' is registered more than once."),
StringComparer.OrdinalIgnoreCase);
if (this.builders.Count == 0)
{
throw new InvalidOperationException("No speech recognition pipeline builders are registered.");
}
}
public ISpeechRecognitionPipeline Create()
{
return options.Recording.TranscriptionProvider switch
{
"funasr" => ActivatorUtilities.CreateInstance<FunAsrSpeechRecognitionPipeline>(services),
"whisper-local" => ActivatorUtilities.CreateInstance<WhisperLocalSpeechRecognitionPipeline>(services),
"azure-speech" => ActivatorUtilities.CreateInstance<AzureSpeechRecognitionPipeline>(services),
_ => throw new InvalidOperationException(
$"Unsupported speech recognition pipeline '{options.Recording.TranscriptionProvider}'.")
};
return Create(null);
}
public ISpeechRecognitionPipeline Create(string? launchProfileName)
{
var profileOptions = ResolveOptions(launchProfileName);
if (!builders.TryGetValue(profileOptions.Recording.TranscriptionProvider, out var builder))
{
throw new InvalidOperationException(
$"Unsupported speech recognition pipeline '{profileOptions.Recording.TranscriptionProvider}'.");
}
return builder.Create(profileOptions);
}
private MeetingAssistantOptions ResolveOptions(string? launchProfileName)
{
if (launchProfiles is not null)
{
return launchProfiles.GetRequiredProfile(launchProfileName).Options;
}
if (string.IsNullOrWhiteSpace(launchProfileName) ||
launchProfileName.Equals(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, StringComparison.OrdinalIgnoreCase))
{
return options;
}
throw new InvalidOperationException(
$"Launch profile '{launchProfileName}' was requested, but no launch profile provider is registered.");
}
}
@@ -0,0 +1,22 @@
namespace MeetingAssistant.Transcription;
public sealed class FunAsrSpeechRecognitionPipelineBuilder :
SpeechRecognitionPipelineBuilder,
ISpeechRecognitionPipelineBuilder
{
public FunAsrSpeechRecognitionPipelineBuilder(IServiceProvider services)
: base(services)
{
}
public string ProviderName => "funasr";
public ISpeechRecognitionPipeline Create(MeetingAssistantOptions options)
{
var backendLifecycle = GetRequiredService<IFunAsrBackendLifecycle>();
return new FunAsrSpeechRecognitionPipeline(
CreateProfiled<FunAsrStreamingTranscriptionProvider>(options),
backendLifecycle,
CreateProfiled<FunAsrTranscriptFinalizer>(options));
}
}
@@ -4,5 +4,20 @@ public interface IDictationWordStore
{
Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<string>> ReadWordsAsync(
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return ReadWordsAsync(cancellationToken);
}
Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken);
Task<IReadOnlyList<string>> AddWordAsync(
string word,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return AddWordAsync(word, cancellationToken);
}
}
@@ -27,6 +27,11 @@ public interface ISpeechRecognitionPipeline : IAsyncDisposable
public interface ISpeechRecognitionPipelineFactory
{
ISpeechRecognitionPipeline Create();
ISpeechRecognitionPipeline Create(string? launchProfileName)
{
return Create();
}
}
public sealed record SpeechRecognitionPipelineOptions(
@@ -0,0 +1,8 @@
namespace MeetingAssistant.Transcription;
public interface ISpeechRecognitionPipelineBuilder
{
string ProviderName { get; }
ISpeechRecognitionPipeline Create(MeetingAssistantOptions options);
}
@@ -13,7 +13,14 @@ public sealed class MarkdownDictationWordStore : IDictationWordStore
public async Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken)
{
var path = GetPath();
return await ReadWordsAsync(options, cancellationToken);
}
public async Task<IReadOnlyList<string>> ReadWordsAsync(
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var path = GetPath(options);
if (!File.Exists(path))
{
return [];
@@ -25,7 +32,15 @@ public sealed class MarkdownDictationWordStore : IDictationWordStore
public async Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken)
{
var path = GetPath();
return await AddWordAsync(word, options, cancellationToken);
}
public async Task<IReadOnlyList<string>> AddWordAsync(
string word,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var path = GetPath(options);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var existing = File.Exists(path)
? await File.ReadAllLinesAsync(path, cancellationToken)
@@ -39,7 +54,7 @@ public sealed class MarkdownDictationWordStore : IDictationWordStore
return words;
}
private string GetPath()
private static string GetPath(MeetingAssistantOptions options)
{
return VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath);
}
@@ -0,0 +1,25 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Transcription;
public abstract class SpeechRecognitionPipelineBuilder
{
private readonly IServiceProvider services;
protected SpeechRecognitionPipelineBuilder(IServiceProvider services)
{
this.services = services;
}
protected T CreateProfiled<T>(MeetingAssistantOptions options)
{
return ActivatorUtilities.CreateInstance<T>(services, Options.Create(options));
}
protected T GetRequiredService<T>()
where T : notnull
{
return services.GetRequiredService<T>();
}
}
@@ -0,0 +1,20 @@
namespace MeetingAssistant.Transcription;
public sealed class WhisperLocalSpeechRecognitionPipelineBuilder :
SpeechRecognitionPipelineBuilder,
ISpeechRecognitionPipelineBuilder
{
public WhisperLocalSpeechRecognitionPipelineBuilder(IServiceProvider services)
: base(services)
{
}
public string ProviderName => "whisper-local";
public ISpeechRecognitionPipeline Create(MeetingAssistantOptions options)
{
return new WhisperLocalSpeechRecognitionPipeline(
CreateProfiled<WhisperLocalStreamingTranscriptionProvider>(options),
CreateProfiled<PyannoteTranscriptFinalizer>(options));
}
}