Public Access
Make meeting lifecycle stateful
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user