Warm up pyannote and FunASR runtimes
PR and Push Build/Test / build-and-test (push) Successful in 6m48s

This commit is contained in:
2026-05-28 13:05:27 +02:00
parent 7ff93b73b3
commit c46c587e65
14 changed files with 696 additions and 20 deletions
@@ -0,0 +1,123 @@
using MeetingAssistant;
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Tests;
public sealed class PyannoteDiarizationWarmupHostedServiceTests
{
[Fact]
public async Task HostedServiceWarmsEnabledValidationRuntimeWithoutBlockingStartup()
{
var commandRunner = new BlockingCommandRunner();
var finalizer = new PyannoteTranscriptFinalizer(
commandRunner,
Options.Create(new MeetingAssistantOptions()),
NullLogger<PyannoteTranscriptFinalizer>.Instance);
var service = new PyannoteDiarizationWarmupHostedService(
finalizer,
new FakeLaunchProfileOptionsProvider(new MeetingAssistantOptions
{
Recording = { TranscriptionProvider = "azure-speech" },
SpeakerIdentification =
{
PyannoteValidation =
{
Enabled = true,
Diarization =
{
Enabled = true,
DockerCommand = "docker",
Image = "meeting-assistant-pyannote-validation:local",
ModelsFolder = Path.Combine(
Path.GetTempPath(),
"meeting-assistant-tests",
Guid.NewGuid().ToString("N"),
"models"),
Token = "hf_test",
TokenEnv = "",
CommandTimeout = TimeSpan.FromMinutes(1)
}
}
}
}),
NullLogger<PyannoteDiarizationWarmupHostedService>.Instance);
await service.StartAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(1));
await commandRunner.WaitForRunAsync();
await service.StopAsync(CancellationToken.None);
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("meeting-assistant-pyannote-validation:local"));
Assert.True(commandRunner.RunCancellationWasObserved);
}
private sealed class FakeLaunchProfileOptionsProvider : ILaunchProfileOptionsProvider
{
private readonly MeetingAssistantOptions options;
public FakeLaunchProfileOptionsProvider(MeetingAssistantOptions options)
{
this.options = options;
}
public LaunchProfile GetRequiredProfile(string? name)
{
return new LaunchProfile(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, options);
}
public IReadOnlyList<LaunchProfile> GetProfiles()
{
return [GetRequiredProfile(null)];
}
public IReadOnlyList<LaunchProfileHotkey> GetHotkeys()
{
return [];
}
}
private sealed class BlockingCommandRunner : ICommandRunner
{
private readonly TaskCompletionSource runStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
public IReadOnlyList<CapturedCommand> Commands { get; private set; } = [];
public bool RunCancellationWasObserved { get; private set; }
public Task WaitForRunAsync()
{
return runStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public async Task<CommandResult> RunAsync(
string fileName,
IReadOnlyList<string> arguments,
CancellationToken cancellationToken,
IReadOnlyDictionary<string, string>? environment = null)
{
Commands = Commands.Append(new CapturedCommand(fileName, arguments)).ToList();
if (arguments.Contains("inspect"))
{
return new CommandResult(0, "image-id", "");
}
runStarted.TrySetResult();
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
catch (OperationCanceledException)
{
RunCancellationWasObserved = true;
throw;
}
return new CommandResult(0, "", "");
}
}
private sealed record CapturedCommand(string FileName, IReadOnlyList<string> Arguments);
}
@@ -181,6 +181,54 @@ public sealed class PyannoteTranscriptFinalizerTests
Assert.Equal("hf_azure", commandRunner.Environment["HF_TOKEN"]);
}
[Fact]
public async Task WarmUpBuildsImageAndDownloadsConfiguredModelWithoutAudioInput()
{
var commandRunner = new CapturingCommandRunner("");
var finalizer = CreateFinalizer(commandRunner, token: "hf_test");
var diarization = new PyannoteDiarizationOptions
{
Enabled = true,
DockerCommand = "docker",
BaseImage = "python:3.11-slim",
Image = "meeting-assistant-pyannote-warmup:local",
ModelsFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "models"),
Model = "pyannote/speaker-diarization-3.1",
Token = "hf_test",
TokenEnv = "",
CommandTimeout = TimeSpan.FromMinutes(1)
};
await finalizer.WarmUpAsync(diarization, CancellationToken.None);
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("inspect"));
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("build"));
var runCommand = commandRunner.Commands.Last();
Assert.Contains("run", runCommand.Arguments);
Assert.Contains("meeting-assistant-pyannote-warmup:local", runCommand.Arguments);
Assert.DoesNotContain(runCommand.Arguments, argument => argument.Contains("/workspace/input.wav", StringComparison.Ordinal));
Assert.Contains(runCommand.Arguments, argument => argument.Contains("Pipeline.from_pretrained", StringComparison.Ordinal));
Assert.Contains(runCommand.Arguments, argument => argument.Contains("pyannote/speaker-diarization-3.1", StringComparison.Ordinal));
Assert.Equal("hf_test", commandRunner.Environment["HF_TOKEN"]);
}
[Fact]
public async Task WarmUpSkipsWhenTokenIsMissing()
{
var commandRunner = new CapturingCommandRunner("");
var finalizer = CreateFinalizer(commandRunner, token: null);
await finalizer.WarmUpAsync(new PyannoteDiarizationOptions
{
Enabled = true,
Token = "",
TokenEnv = "",
ModelsFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "models")
}, CancellationToken.None);
Assert.Empty(commandRunner.Commands);
}
[Fact]
public async Task FinalizerSkipsPyannoteWhenTokenIsMissing()
{
@@ -1,3 +1,5 @@
using MeetingAssistant;
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Logging.Abstractions;
@@ -27,18 +29,118 @@ public sealed class SpeechRecognitionPipelineHostedServiceTests
Assert.True(pipeline.Disposed);
}
[Fact]
public async Task HostedServiceWarmsConfiguredLaunchProfilePipelinesWithoutBlockingStartup()
{
var defaultPipeline = new CapturingSpeechRecognitionPipeline();
var funAsrPipeline = new CapturingSpeechRecognitionPipeline { BlockReadinessUntilCancelled = true };
var englishPipeline = new CapturingSpeechRecognitionPipeline();
var service = new SpeechRecognitionPipelineHostedService(
new CapturingSpeechRecognitionPipelineFactory(new Dictionary<string, ISpeechRecognitionPipeline>
{
[ConfigurationLaunchProfileOptionsProvider.DefaultProfileName] = defaultPipeline,
["funasr"] = funAsrPipeline,
["english"] = englishPipeline
}),
new FakeLaunchProfileOptionsProvider(
[
new LaunchProfile(
ConfigurationLaunchProfileOptionsProvider.DefaultProfileName,
new MeetingAssistantOptions
{
Recording = { TranscriptionProvider = "azure-speech" }
}),
new LaunchProfile(
"funasr",
new MeetingAssistantOptions
{
Recording = { TranscriptionProvider = "funasr" }
}),
new LaunchProfile(
"english",
new MeetingAssistantOptions
{
Recording = { TranscriptionProvider = "azure-speech" }
})
]),
NullLogger<SpeechRecognitionPipelineHostedService>.Instance);
var startTask = service.StartAsync(CancellationToken.None);
await startTask.WaitAsync(TimeSpan.FromSeconds(1));
await defaultPipeline.WaitForInitializeAsync();
await defaultPipeline.WaitForReadinessAsync();
await funAsrPipeline.WaitForInitializeAsync();
await funAsrPipeline.WaitForReadinessAsync();
await service.StopAsync(CancellationToken.None);
Assert.Equal(1, defaultPipeline.InitializeCount);
Assert.Equal(1, funAsrPipeline.InitializeCount);
Assert.True(defaultPipeline.Disposed);
Assert.True(funAsrPipeline.Disposed);
Assert.True(funAsrPipeline.ReadinessCancellationWasObserved);
Assert.Equal(0, englishPipeline.InitializeCount);
Assert.False(englishPipeline.Disposed);
}
private sealed class CapturingSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
{
private readonly ISpeechRecognitionPipeline pipeline;
private readonly IReadOnlyDictionary<string, ISpeechRecognitionPipeline> pipelines;
public CapturingSpeechRecognitionPipelineFactory(ISpeechRecognitionPipeline pipeline)
: this(new Dictionary<string, ISpeechRecognitionPipeline>
{
this.pipeline = pipeline;
[ConfigurationLaunchProfileOptionsProvider.DefaultProfileName] = pipeline
})
{
}
public CapturingSpeechRecognitionPipelineFactory(
IReadOnlyDictionary<string, ISpeechRecognitionPipeline> pipelines)
{
this.pipelines = pipelines;
}
public ISpeechRecognitionPipeline Create()
{
return pipeline;
return Create(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName);
}
public ISpeechRecognitionPipeline Create(string? launchProfileName)
{
var profileName = string.IsNullOrWhiteSpace(launchProfileName)
? ConfigurationLaunchProfileOptionsProvider.DefaultProfileName
: launchProfileName;
return pipelines[profileName];
}
}
private sealed class FakeLaunchProfileOptionsProvider : ILaunchProfileOptionsProvider
{
private readonly IReadOnlyList<LaunchProfile> profiles;
public FakeLaunchProfileOptionsProvider(IReadOnlyList<LaunchProfile> profiles)
{
this.profiles = profiles;
}
public LaunchProfile GetRequiredProfile(string? name)
{
var profileName = string.IsNullOrWhiteSpace(name)
? ConfigurationLaunchProfileOptionsProvider.DefaultProfileName
: name;
return profiles.Single(profile => profile.Name.Equals(profileName, StringComparison.OrdinalIgnoreCase));
}
public IReadOnlyList<LaunchProfile> GetProfiles()
{
return profiles;
}
public IReadOnlyList<LaunchProfileHotkey> GetHotkeys()
{
return [];
}
}
+2 -1
View File
@@ -181,7 +181,7 @@ public sealed class AzureSpeechOptions
public string KeyEnv { get; set; } = "AZURE_SPEECH_KEY";
public TimeSpan RecognitionStopTimeout { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan RecognitionStopTimeout { get; set; } = TimeSpan.FromMinutes(3);
public bool DiarizeIntermediateResults { get; set; } = true;
@@ -321,6 +321,7 @@ public sealed class SpeakerIdentityPyannoteValidationOptions
public PyannoteDiarizationOptions Diarization { get; set; } = new()
{
Enabled = true,
CommandTimeout = TimeSpan.FromHours(1),
AlignmentMode = PyannoteAlignmentMode.PyannoteTurns
};
}
+1
View File
@@ -89,6 +89,7 @@ builder.Services.AddSingleton<MeetingRecordingCoordinator>();
builder.Services.AddHostedService<TemporaryRecordingCleanupHostedService>();
builder.Services.AddHostedService<SpeakerIdentityDatabaseInitializer>();
builder.Services.AddHostedService<SpeechRecognitionPipelineHostedService>();
builder.Services.AddHostedService<PyannoteDiarizationWarmupHostedService>();
#if WINDOWS
builder.Services.AddHostedService<GlobalHotkeyService>();
builder.Services.AddHostedService<UnoTaskbarIconService>();
@@ -0,0 +1,125 @@
using MeetingAssistant.LaunchProfiles;
namespace MeetingAssistant.Transcription;
public sealed class PyannoteDiarizationWarmupHostedService : IHostedService
{
private readonly PyannoteTranscriptFinalizer finalizer;
private readonly ILaunchProfileOptionsProvider launchProfiles;
private readonly ILogger<PyannoteDiarizationWarmupHostedService> logger;
private CancellationTokenSource? startupCancellation;
private Task? startupTask;
public PyannoteDiarizationWarmupHostedService(
PyannoteTranscriptFinalizer finalizer,
ILaunchProfileOptionsProvider launchProfiles,
ILogger<PyannoteDiarizationWarmupHostedService> logger)
{
this.finalizer = finalizer;
this.launchProfiles = launchProfiles;
this.logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
startupCancellation = cancellation;
startupTask = Task.Run(
() => WarmUpEnabledRuntimesAsync(cancellation.Token),
CancellationToken.None);
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
var cancellation = startupCancellation;
var task = startupTask;
if (cancellation is null || task is null)
{
return;
}
startupCancellation = null;
startupTask = null;
await cancellation.CancelAsync();
try
{
await task.WaitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
}
finally
{
cancellation.Dispose();
}
}
private async Task WarmUpEnabledRuntimesAsync(CancellationToken cancellationToken)
{
foreach (var diarization in GetEnabledDiarizationOptions())
{
try
{
logger.LogInformation(
"Starting pyannote warm-up for image {Image} and model {Model}",
diarization.Image,
diarization.Model);
await finalizer.WarmUpAsync(diarization, cancellationToken);
logger.LogInformation(
"Finished pyannote warm-up for image {Image} and model {Model}",
diarization.Image,
diarization.Model);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogInformation("Pyannote warm-up was cancelled during application shutdown");
throw;
}
catch (Exception exception)
{
logger.LogError(
exception,
"Pyannote warm-up failed for image {Image} and model {Model}; diarization can still retry on demand",
diarization.Image,
diarization.Model);
}
}
}
private IEnumerable<PyannoteDiarizationOptions> GetEnabledDiarizationOptions()
{
return launchProfiles.GetProfiles()
.SelectMany(profile => GetEnabledDiarizationOptions(profile.Options))
.DistinctBy(CreateWarmUpKey);
}
private static IEnumerable<PyannoteDiarizationOptions> GetEnabledDiarizationOptions(
MeetingAssistantOptions options)
{
if (options.Recording.TranscriptionProvider.Equals("whisper-local", StringComparison.OrdinalIgnoreCase) &&
options.WhisperLocal.Diarization.Enabled)
{
yield return options.WhisperLocal.Diarization;
}
if (options.SpeakerIdentification.PyannoteValidation.Enabled &&
options.SpeakerIdentification.PyannoteValidation.Diarization.Enabled)
{
yield return options.SpeakerIdentification.PyannoteValidation.Diarization;
}
}
private static string CreateWarmUpKey(PyannoteDiarizationOptions diarization)
{
return string.Join(
'\u001f',
diarization.DockerCommand,
diarization.Image,
diarization.Model,
VaultPath.Resolve(diarization.ModelsFolder),
diarization.Token,
diarization.TokenEnv,
diarization.BuildImage.ToString());
}
}
@@ -82,6 +82,58 @@ public sealed class PyannoteTranscriptFinalizer
return segments;
}
public async Task WarmUpAsync(
PyannoteDiarizationOptions diarization,
CancellationToken cancellationToken)
{
if (!diarization.Enabled)
{
return;
}
var token = ResolveToken(diarization);
if (string.IsNullOrWhiteSpace(token))
{
logger.LogWarning(
"Pyannote warm-up is enabled but no Hugging Face token is configured directly or via {TokenEnv}",
diarization.TokenEnv);
return;
}
var modelsFolder = VaultPath.Resolve(diarization.ModelsFolder);
Directory.CreateDirectory(modelsFolder);
using var timeoutSource = diarization.CommandTimeout > TimeSpan.Zero
? new CancellationTokenSource(diarization.CommandTimeout)
: null;
using var linkedSource = timeoutSource is null
? null
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutSource.Token);
try
{
if (diarization.BuildImage)
{
await EnsureDockerImageAsync(diarization, modelsFolder, linkedSource?.Token ?? cancellationToken);
}
var result = await commandRunner.RunAsync(
diarization.DockerCommand,
BuildWarmUpDockerArguments(diarization, modelsFolder),
linkedSource?.Token ?? cancellationToken,
new Dictionary<string, string> { ["HF_TOKEN"] = token });
if (result.ExitCode != 0)
{
throw new InvalidOperationException(
$"pyannote warm-up failed with exit code {result.ExitCode}: {result.StandardError}");
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && timeoutSource?.IsCancellationRequested == true)
{
throw new TimeoutException(
$"pyannote warm-up timed out after {diarization.CommandTimeout}.");
}
}
private async Task<CommandResult> RunDiarizationAsync(
string fullAudioPath,
string token,
@@ -189,6 +241,33 @@ public sealed class PyannoteTranscriptFinalizer
];
}
private string[] BuildWarmUpDockerArguments(
PyannoteDiarizationOptions diarization,
string modelsFolder)
{
return
[
"run",
"--rm",
"-e",
"HF_TOKEN",
"-v",
$"{modelsFolder}:/workspace/cache",
"-e",
"HF_HOME=/workspace/cache/huggingface",
"-e",
"XDG_CACHE_HOME=/workspace/cache",
"-e",
"PIP_CACHE_DIR=/workspace/cache/pip",
"-e",
"TORCH_HOME=/workspace/cache/torch",
diarization.Image,
"sh",
"-lc",
BuildWarmUpPythonCommand(diarization)
];
}
private static string BuildPythonCommand(
PyannoteDiarizationOptions diarization,
SpeechRecognitionPipelineOptions pipelineOptions)
@@ -217,6 +296,21 @@ public sealed class PyannoteTranscriptFinalizer
+ "python /tmp/meeting_assistant_pyannote.py";
}
private static string BuildWarmUpPythonCommand(PyannoteDiarizationOptions diarization)
{
var model = diarization.Model;
return
"cat > /tmp/meeting_assistant_pyannote_warmup.py <<'PY'\n"
+ "import os\n"
+ "import torch\n"
+ "from pyannote.audio import Pipeline\n"
+ $"pipeline = Pipeline.from_pretrained({JsonSerializer.Serialize(model)}, token=os.environ.get('HF_TOKEN'))\n"
+ "pipeline.to(torch.device('cpu'))\n"
+ "print('pyannote warm-up complete')\n"
+ "PY\n"
+ "python /tmp/meeting_assistant_pyannote_warmup.py";
}
private static string BuildPipelineInvocation(SpeechRecognitionPipelineOptions pipelineOptions)
{
return pipelineOptions.NumSpeakers is > 0
@@ -1,27 +1,41 @@
using MeetingAssistant.LaunchProfiles;
namespace MeetingAssistant.Transcription;
public sealed class SpeechRecognitionPipelineHostedService : IHostedService
{
private readonly ISpeechRecognitionPipelineFactory pipelineFactory;
private readonly ILaunchProfileOptionsProvider? launchProfiles;
private readonly ILogger<SpeechRecognitionPipelineHostedService> logger;
private CancellationTokenSource? startupCancellation;
private Task? startupTask;
private ISpeechRecognitionPipeline? startupPipeline;
private List<ISpeechRecognitionPipeline> startupPipelines = [];
public SpeechRecognitionPipelineHostedService(
ISpeechRecognitionPipelineFactory pipelineFactory,
ILogger<SpeechRecognitionPipelineHostedService> logger)
: this(pipelineFactory, null, logger)
{
}
public SpeechRecognitionPipelineHostedService(
ISpeechRecognitionPipelineFactory pipelineFactory,
ILaunchProfileOptionsProvider? launchProfiles,
ILogger<SpeechRecognitionPipelineHostedService> logger)
{
this.pipelineFactory = pipelineFactory;
this.launchProfiles = launchProfiles;
this.logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
startupCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
startupPipeline = pipelineFactory.Create();
var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var pipelines = CreateStartupPipelines();
startupCancellation = cancellation;
startupPipelines = pipelines;
startupTask = Task.Run(
() => WarmUpPipelineAsync(startupPipeline, startupCancellation.Token),
() => WarmUpPipelinesAsync(pipelines, cancellation.Token),
CancellationToken.None);
return Task.CompletedTask;
}
@@ -30,15 +44,15 @@ public sealed class SpeechRecognitionPipelineHostedService : IHostedService
{
var cancellation = startupCancellation;
var task = startupTask;
var pipeline = startupPipeline;
if (cancellation is null || task is null || pipeline is null)
var pipelines = startupPipelines;
if (cancellation is null || task is null)
{
return;
}
startupCancellation = null;
startupTask = null;
startupPipeline = null;
startupPipelines = [];
await cancellation.CancelAsync();
try
{
@@ -48,12 +62,44 @@ public sealed class SpeechRecognitionPipelineHostedService : IHostedService
{
}
finally
{
foreach (var pipeline in pipelines)
{
await pipeline.DisposeAsync();
}
cancellation.Dispose();
}
}
private List<ISpeechRecognitionPipeline> CreateStartupPipelines()
{
if (launchProfiles is null)
{
return [pipelineFactory.Create()];
}
return launchProfiles.GetProfiles()
.Where(ShouldWarmUpProfile)
.Select(profile => pipelineFactory.Create(profile.Name))
.ToList();
}
private static bool ShouldWarmUpProfile(LaunchProfile profile)
{
return profile.Name.Equals(
ConfigurationLaunchProfileOptionsProvider.DefaultProfileName,
StringComparison.OrdinalIgnoreCase) ||
profile.Options.Recording.TranscriptionProvider.Equals("funasr", StringComparison.OrdinalIgnoreCase);
}
private async Task WarmUpPipelinesAsync(
IReadOnlyList<ISpeechRecognitionPipeline> pipelines,
CancellationToken cancellationToken)
{
await Task.WhenAll(pipelines.Select(pipeline => WarmUpPipelineAsync(pipeline, cancellationToken)));
}
private async Task WarmUpPipelineAsync(
ISpeechRecognitionPipeline pipeline,
CancellationToken cancellationToken)
+2 -2
View File
@@ -89,7 +89,7 @@
"de-DE"
],
"KeyEnv": "AZURE_SPEECH_KEY",
"RecognitionStopTimeout": "00:00:10",
"RecognitionStopTimeout": "00:03:00",
"DiarizeIntermediateResults": true,
"PostProcessingOption": "",
"PhraseListWeight": 1.5
@@ -124,7 +124,7 @@
"AnnotationSource": "SpeakerDiarization",
"AlignmentMode": "PyannoteTurns",
"TokenEnv": "HF_TOKEN",
"CommandTimeout": "00:30:00"
"CommandTimeout": "01:00:00"
}
}
},
+6 -6
View File
@@ -165,7 +165,7 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
"Region": "germanywestcentral",
"Language": "en-US",
"KeyEnv": "AZURE_SPEECH_KEY",
"RecognitionStopTimeout": "00:00:10",
"RecognitionStopTimeout": "00:03:00",
"DiarizeIntermediateResults": true
},
"SpeakerIdentification": {
@@ -183,7 +183,7 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
"ModelsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Pyannote\\models",
"Model": "pyannote/speaker-diarization-3.1",
"TokenEnv": "HF_TOKEN",
"CommandTimeout": "00:30:00"
"CommandTimeout": "01:00:00"
}
}
},
@@ -228,15 +228,15 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
}
```
`funasr` streams 16 kHz PCM audio to the configured FunASR WebSocket endpoint. When `FunAsr:Backend:Enabled` is true, Meeting Assistant manages a local Docker-backed FunASR runtime: on application startup it begins a non-blocking warm-up that pulls the configured online streaming image, prepares the mounted model and hotword folder, replaces any stale container with the configured name, starts the two-pass WebSocket server mapped to the endpoint port, keeps the container alive after the FunASR startup script backgrounds the server process, and stops the container when the app shuts down. Recording can start while the backend is still warming up; captured audio is queued and then streamed once the WebSocket backend is healthy. Docker commands are bounded by `CommandTimeout`, and backend WebSocket readiness is bounded by `StartupTimeout`. FunASR response fields such as `spk_name`, `spk`, and sentence-level speaker metadata are preserved in transcript segments; missing speaker fields are written as `Unknown`.
`funasr` streams 16 kHz PCM audio to the configured FunASR WebSocket endpoint. When `FunAsr:Backend:Enabled` is true, Meeting Assistant manages a local Docker-backed FunASR runtime: on application startup it begins a non-blocking warm-up for the default launch profile and any named launch profile that selects `funasr`. That warm-up pulls the configured online streaming image, prepares the mounted model and hotword folder, replaces any stale container with the configured name, starts the two-pass WebSocket server mapped to the endpoint port, keeps the container alive after the FunASR startup script backgrounds the server process, and stops it when the app shuts down. Recording can start while the backend is still warming up; captured audio is queued and then streamed once the WebSocket backend is healthy. Docker commands are bounded by `CommandTimeout`, and backend WebSocket readiness is bounded by `StartupTimeout`. FunASR response fields such as `spk_name`, `spk`, and sentence-level speaker metadata are preserved in transcript segments; missing speaker fields are written as `Unknown`.
During recording, Meeting Assistant writes the mixed PCM stream to a temporary WAV under `TemporaryRecordingsFolder` only so the final diarization pass has audio to process. After capture stops and the streaming ASR provider drains the already-captured audio, FunASR Python `AutoModel` can run with VAD, punctuation, and CAMPPlus (`spk_model="cam++"`) over that temporary WAV. If sentence-level speaker labels are returned, the transcript markdown is rewritten with the final speaker-attributed segments. If final diarization is disabled or returns no segments, the live streaming transcript is kept. Temporary WAV files are deleted after the run completes, and stale temporary recordings from interrupted runs are deleted when the application starts.
`whisper-local` uses Whisper.NET with a local ggml model and remains available by setting `Recording:TranscriptionProvider` to `whisper-local`. The model file is not committed to this repository; place it at the configured `ModelPath` before using live transcription. When `WhisperLocal:Diarization:Enabled` is true, the final post-processing pass runs pyannote in Docker over the temporary WAV and reads the Hugging Face token from `WhisperLocal:Diarization:Token` or `TokenEnv` (`HF_TOKEN` by default). `AnnotationSource` selects pyannote's `speaker_diarization` or `exclusive_speaker_diarization` output. `AlignmentMode` selects whether Meeting Assistant assigns the best-overlap pyannote speaker label to each Whisper segment or rebuilds the finished transcript as pyannote speaker-turn segments with matching Whisper text. When `BuildImage` is true, Meeting Assistant builds the configured local pyannote Docker image if Docker does not already have it, so Python packages are installed once instead of during every diarization call. The configured `ModelsFolder` is mounted as the persistent pyannote model cache and stores Hugging Face, torch, and pip cache artifacts so model downloads are reused across runs. If pyannote is disabled, has no token, or returns no turns, the live Whisper transcript is kept.
`whisper-local` uses Whisper.NET with a local ggml model and remains available by setting `Recording:TranscriptionProvider` to `whisper-local`. The model file is not committed to this repository; place it at the configured `ModelPath` before using live transcription. When `WhisperLocal:Diarization:Enabled` is true, the final post-processing pass runs pyannote in Docker over the temporary WAV and reads the Hugging Face token from `WhisperLocal:Diarization:Token` or `TokenEnv` (`HF_TOKEN` by default). `AnnotationSource` selects pyannote's `speaker_diarization` or `exclusive_speaker_diarization` output. `AlignmentMode` selects whether Meeting Assistant assigns the best-overlap pyannote speaker label to each Whisper segment or rebuilds the finished transcript as pyannote speaker-turn segments with matching Whisper text. When `BuildImage` is true, Meeting Assistant builds the configured local pyannote Docker image if Docker does not already have it, so Python packages are installed once instead of during every diarization call. The configured `ModelsFolder` is mounted as the persistent pyannote model cache and stores Hugging Face, torch, and pip cache artifacts so model downloads are reused across runs. Active pyannote runtimes are also warmed up on application start so image setup and model download do not wait for the first diarization request. If pyannote is disabled, has no token, or returns no turns, the live Whisper transcript is kept.
`azure-speech` streams captured PCM audio to Azure AI Speech using the Speech SDK `ConversationTranscriber`. Configure `AzureSpeech:Endpoint`, `Region`, `Language`, and either `Key` or `KeyEnv`; this repository uses `AZURE_SPEECH_KEY` by default and does not store the key in appsettings. `DiarizeIntermediateResults` enables diarization for intermediate and final conversation transcription results. Azure returns generic speaker IDs such as `Guest-1` and `Guest-2`, which Meeting Assistant writes directly into live transcript segments. `RecognitionStopTimeout` bounds SDK shutdown after the captured audio stream has been closed, which keeps short diagnostic runs from waiting indefinitely for final service events. Since Azure Speech emits diarized live transcript segments, the Azure pipeline keeps those segments as the finished transcript instead of running pyannote.
`azure-speech` streams captured PCM audio to Azure AI Speech using the Speech SDK `ConversationTranscriber`. Configure `AzureSpeech:Endpoint`, `Region`, `Language`, and either `Key` or `KeyEnv`; this repository uses `AZURE_SPEECH_KEY` by default and does not store the key in appsettings. `DiarizeIntermediateResults` enables diarization for intermediate and final conversation transcription results. Azure returns generic speaker IDs such as `Guest-1` and `Guest-2`, which Meeting Assistant writes directly into live transcript segments. `RecognitionStopTimeout` bounds SDK shutdown after the captured audio stream has been closed; the default is 3 minutes so Azure has time to finalize longer speaker-recognition samples without letting diagnostic runs wait indefinitely. Since Azure Speech emits diarized live transcript segments, the Azure pipeline keeps those segments as the finished transcript instead of running pyannote.
Speaker identity matching keeps candidate samples only after a diarized speaker has at least `SpeakerIdentification:MinimumSampleSpeechDuration` of continuous speech, defaulting to 30 seconds. Adjacent same-speaker segments may be combined when the gap is no larger than `MaximumSampleSegmentGap`, but a different speaker resets the pending span. `SpeakerIdentification:PyannoteValidation` is an optional secondary confidence layer. When enabled, pyannote rejects multi-speaker samples and must confirm an Azure-confirmed identity match before Meeting Assistant accepts it. It uses the same Docker-based pyannote runtime shape as local Whisper finalization and reads its Hugging Face token from the nested diarization options.
Speaker identity matching keeps candidate samples only after a diarized speaker has at least `SpeakerIdentification:MinimumSampleSpeechDuration` of continuous speech, defaulting to 30 seconds. Adjacent same-speaker segments may be combined when the gap is no larger than `MaximumSampleSegmentGap`, but a different speaker resets the pending span. `SpeakerIdentification:PyannoteValidation` is an optional secondary confidence layer. When enabled, pyannote rejects multi-speaker samples and must confirm an Azure-confirmed identity match before Meeting Assistant accepts it. It uses the same Docker-based pyannote runtime shape as local Whisper finalization, reads its Hugging Face token from the nested diarization options, warms that runtime on application start, and defaults the validation command timeout to 1 hour because the local model can need substantial setup time.
ASR backends are exposed downstream as speech recognition pipelines. A pipeline can initialize, wait for readiness, accept audio chunks, emit live transcript segments, and produce a finished transcript after capture completes. The configured implementation is selected from `Recording:TranscriptionProvider` and registered through DI so recording and diagnostic endpoints do not need to know whether the backend uses FunASR, Whisper.NET, Azure Speech, pyannote, or a later provider.
@@ -0,0 +1,13 @@
# Change: Warm up pyannote on startup
## Why
Local pyannote validation can spend significant time building the Docker image and downloading Hugging Face model artifacts. If that work happens on the first speaker-validation request, the request can time out before validation starts.
## What Changes
- Increase the local pyannote validation command timeout default.
- Add a startup warm-up path that prepares enabled pyannote diarization runtimes and downloads the configured model into the persistent cache.
- Keep startup non-blocking and keep diarization requests bounded by configured timeouts.
## Impact
- First speaker validation is less likely to fail due to one-time model setup.
- Startup logs can surface pyannote setup failures before the first meeting needs validation.
@@ -0,0 +1,94 @@
## MODIFIED Requirements
### Requirement: Speech recognition pipelines are streamable and configurable
Meeting Assistant SHALL route audio through a provider-independent speech recognition pipeline contract.
The configured pipeline SHALL be selected through DI from the ASR backend configuration and SHALL expose operations to initialize, wait for readiness, write captured audio chunks, read live transcript segments, and read the finished transcript after capture completes.
Reading the finished transcript SHALL accept optional pipeline options, including a requested speaker count hint.
For real meeting recordings, Meeting Assistant SHALL derive the requested speaker count hint from the meeting note `attendees` frontmatter when at least two attendees are listed.
The configured pipeline SHALL receive audio chunks as they are captured rather than requiring the recording coordinator to finish a complete meeting audio file first.
On application startup, Meeting Assistant SHALL start non-blocking warm-up for the default launch profile's speech recognition pipeline and for any named launch profile that selects the managed FunASR provider.
#### Scenario: Streaming provider receives live audio
- **WHEN** recording mode is active and the combined audio source emits a chunk
- **THEN** Meeting Assistant can pass that chunk to the configured speech recognition pipeline before the audio source completes
#### Scenario: Pipeline readiness is separated from recording capture
- **WHEN** recording mode starts before the configured speech recognition backend is ready
- **THEN** Meeting Assistant starts audio capture immediately and lets the pipeline finish readiness before it emits live transcript output
#### Scenario: Pipeline is changed by configuration
- **WHEN** the configured ASR backend setting is changed
- **THEN** Meeting Assistant selects the configured speech recognition pipeline without changing recording control or transcript persistence code
#### Scenario: ASR backend is tested with a WAV file
- **WHEN** the user submits a local PCM WAV file to the ASR diagnostic endpoint
- **THEN** Meeting Assistant streams that file through the configured speech recognition pipeline and returns the emitted live transcript segments
#### Scenario: ASR diagnostic backend is unavailable
- **WHEN** the configured speech recognition pipeline fails while processing the diagnostic WAV file
- **THEN** Meeting Assistant returns a diagnostic backend failure response instead of hiding the pipeline error
#### Scenario: Final diarization backend is tested with a WAV file
- **WHEN** the user submits a local PCM WAV file to the final diarization diagnostic endpoint
- **THEN** Meeting Assistant streams the file through the configured speech recognition pipeline and returns the finished transcript segments
#### Scenario: Final diarization receives a speaker count hint
- **WHEN** the user submits a local PCM WAV file and `numSpeakers` to the final diarization diagnostic endpoint
- **THEN** Meeting Assistant passes the requested speaker count to the configured speech recognition pipeline as a finished-transcript option
#### Scenario: Meeting attendee count provides final speaker hint
- **WHEN** a meeting note has two or more entries in `attendees`
- **THEN** Meeting Assistant passes that attendee count to the configured speech recognition pipeline as the finished-transcript speaker count hint
#### Scenario: Empty or single-attendee meeting note has no final speaker hint
- **WHEN** a meeting note has zero or one entry in `attendees`
- **THEN** Meeting Assistant leaves the finished-transcript speaker count hint unset
#### Scenario: Launch profile FunASR pipeline warms on startup
- **GIVEN** a named launch profile selects `funasr`
- **WHEN** Meeting Assistant starts
- **THEN** it begins warming that profile's speech recognition pipeline without blocking startup
#### Scenario: Non-default non-FunASR profile is not warmed
- **GIVEN** a named launch profile selects a provider other than `funasr`
- **WHEN** Meeting Assistant starts
- **THEN** it does not create a startup warm-up pipeline for that named profile
### Requirement: Speaker identity matching can use pyannote secondary validation
Meeting Assistant SHALL support an optional configurable pyannote secondary validation layer for speaker identity matching.
When pyannote secondary validation is enabled, Meeting Assistant SHALL verify candidate speaker samples before retaining them for identity matching. Samples that pyannote reports as containing multiple speakers SHALL be rejected.
When pyannote secondary validation is enabled and the primary identity matcher confirms a speaker, Meeting Assistant SHALL run a second validation pass through pyannote before accepting the match.
If pyannote secondary validation cannot confirm that the unknown live sample and matched identity samples belong to one speaker, Meeting Assistant SHALL reject the match.
When pyannote secondary validation is disabled, Meeting Assistant SHALL preserve the primary identity matching behavior.
When pyannote secondary validation is enabled, Meeting Assistant SHALL start a non-blocking startup warm-up that builds or verifies the configured pyannote runtime image and downloads the configured model into the persistent model cache before the first validation request when possible.
#### Scenario: Multi-speaker sample is rejected
- **GIVEN** pyannote secondary validation is enabled
- **WHEN** pyannote reports multiple speakers in a candidate sample
- **THEN** Meeting Assistant does not retain that sample for identity matching
#### Scenario: Pyannote rejects primary match
- **GIVEN** pyannote secondary validation is enabled
- **AND** the primary identity matcher confirms `Guest03` as `Chris`
- **WHEN** pyannote reports that the unknown `Guest03` sample and known `Chris` samples contain different speakers
- **THEN** Meeting Assistant rejects the match
#### Scenario: Disabled pyannote validation preserves primary match
- **GIVEN** pyannote secondary validation is disabled
- **WHEN** the primary identity matcher confirms `Guest03` as `Chris`
- **THEN** Meeting Assistant accepts the match without running pyannote secondary validation
#### Scenario: Pyannote validation warms up on startup
- **GIVEN** pyannote secondary validation is enabled
- **WHEN** Meeting Assistant starts
- **THEN** it begins preparing the configured pyannote runtime image and model cache without waiting for the first validation request
- **AND** application startup is not blocked by the warm-up task
@@ -0,0 +1,9 @@
## Implementation
- [x] Add behavior tests for pyannote warm-up command generation.
- [x] Add behavior tests for non-blocking startup warm-up of enabled pyannote validation.
- [x] Implement pyannote warm-up in the finalizer/runtime.
- [x] Register a startup hosted service for enabled pyannote runtime warm-up.
- [x] Warm named launch-profile FunASR pipelines on startup.
- [x] Increase local pyannote validation timeout in code/config/docs.
- [x] Run focused tests.
- [x] Validate OpenSpec change.
@@ -27,6 +27,8 @@ For real meeting recordings, Meeting Assistant SHALL derive the requested speake
The configured pipeline SHALL receive audio chunks as they are captured rather than requiring the recording coordinator to finish a complete meeting audio file first.
On application startup, Meeting Assistant SHALL start non-blocking warm-up for the default launch profile's speech recognition pipeline and for any named launch profile that selects the managed FunASR provider.
#### Scenario: Streaming provider receives live audio
- **WHEN** recording mode is active and the combined audio source emits a chunk
- **THEN** Meeting Assistant can pass that chunk to the configured speech recognition pipeline before the audio source completes
@@ -63,6 +65,16 @@ The configured pipeline SHALL receive audio chunks as they are captured rather t
- **WHEN** a meeting note has zero or one entry in `attendees`
- **THEN** Meeting Assistant leaves the finished-transcript speaker count hint unset
#### Scenario: Launch profile FunASR pipeline warms on startup
- **GIVEN** a named launch profile selects `funasr`
- **WHEN** Meeting Assistant starts
- **THEN** it begins warming that profile's speech recognition pipeline without blocking startup
#### Scenario: Non-default non-FunASR profile is not warmed
- **GIVEN** a named launch profile selects a provider other than `funasr`
- **WHEN** Meeting Assistant starts
- **THEN** it does not create a startup warm-up pipeline for that named profile
### Requirement: Version 1 keeps local Whisper transcription available
Meeting Assistant SHALL provide a local Whisper speech recognition pipeline as a fallback ASR backend for version 1.
@@ -471,6 +483,8 @@ If pyannote secondary validation cannot confirm that the unknown live sample and
When pyannote secondary validation is disabled, Meeting Assistant SHALL preserve the primary identity matching behavior.
When pyannote secondary validation is enabled, Meeting Assistant SHALL start a non-blocking startup warm-up that builds or verifies the configured pyannote runtime image and downloads the configured model into the persistent model cache before the first validation request when possible.
#### Scenario: Multi-speaker sample is rejected
- **GIVEN** pyannote secondary validation is enabled
- **WHEN** pyannote reports multiple speakers in a candidate sample
@@ -486,3 +500,9 @@ When pyannote secondary validation is disabled, Meeting Assistant SHALL preserve
- **GIVEN** pyannote secondary validation is disabled
- **WHEN** the primary identity matcher confirms `Guest03` as `Chris`
- **THEN** Meeting Assistant accepts the match without running pyannote secondary validation
#### Scenario: Pyannote validation warms up on startup
- **GIVEN** pyannote secondary validation is enabled
- **WHEN** Meeting Assistant starts
- **THEN** it begins preparing the configured pyannote runtime image and model cache without waiting for the first validation request
- **AND** application startup is not blocked by the warm-up task