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>
{
[ConfigurationLaunchProfileOptionsProvider.DefaultProfileName] = pipeline
})
{
this.pipeline = 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 [];
}
}