Public Access
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
497390c2ec | ||
|
|
b1365b202d | ||
|
|
70ffaa7357 |
@@ -25,4 +25,16 @@ public sealed class NotificationActivationArgumentsTests
|
|||||||
Assert.Equal("abc123", arguments["promptId"]);
|
Assert.Equal("abc123", arguments["promptId"]);
|
||||||
Assert.Equal("continue", arguments["response"]);
|
Assert.Equal("continue", arguments["response"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InactivityPromptActionsIncludePauseAndParseItsResponse()
|
||||||
|
{
|
||||||
|
Assert.Contains(
|
||||||
|
MeetingInactivityPromptActions.All,
|
||||||
|
action => action.Content == "Pause transcription" &&
|
||||||
|
action.ResponseArgument == "pause");
|
||||||
|
Assert.Equal(
|
||||||
|
MeetingInactivityPromptResponse.Pause,
|
||||||
|
MeetingInactivityPromptActions.ParseResponse("pause"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,31 +18,8 @@ public sealed class PyannoteDiarizationWarmupHostedServiceTests
|
|||||||
NullLogger<PyannoteTranscriptFinalizer>.Instance);
|
NullLogger<PyannoteTranscriptFinalizer>.Instance);
|
||||||
var service = new PyannoteDiarizationWarmupHostedService(
|
var service = new PyannoteDiarizationWarmupHostedService(
|
||||||
finalizer,
|
finalizer,
|
||||||
new FakeLaunchProfileOptionsProvider(new MeetingAssistantOptions
|
new FakeLaunchProfileOptionsProvider(
|
||||||
{
|
CreateValidationOptions("meeting-assistant-pyannote-validation:local")),
|
||||||
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);
|
NullLogger<PyannoteDiarizationWarmupHostedService>.Instance);
|
||||||
|
|
||||||
await service.StartAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(1));
|
await service.StartAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(1));
|
||||||
@@ -54,23 +31,87 @@ public sealed class PyannoteDiarizationWarmupHostedServiceTests
|
|||||||
Assert.True(commandRunner.RunCancellationWasObserved);
|
Assert.True(commandRunner.RunCancellationWasObserved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HostedServiceUsesApplicationValidationRuntimeInsteadOfNamedProfileOverride()
|
||||||
|
{
|
||||||
|
var commandRunner = new BlockingCommandRunner();
|
||||||
|
var finalizer = new PyannoteTranscriptFinalizer(
|
||||||
|
commandRunner,
|
||||||
|
Options.Create(new MeetingAssistantOptions()),
|
||||||
|
NullLogger<PyannoteTranscriptFinalizer>.Instance);
|
||||||
|
var defaultOptions = CreateValidationOptions("meeting-assistant-pyannote-default:local");
|
||||||
|
var namedOptions = CreateValidationOptions("meeting-assistant-pyannote-named:local");
|
||||||
|
var service = new PyannoteDiarizationWarmupHostedService(
|
||||||
|
finalizer,
|
||||||
|
new FakeLaunchProfileOptionsProvider(
|
||||||
|
[
|
||||||
|
new LaunchProfile("english", namedOptions),
|
||||||
|
new LaunchProfile(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, defaultOptions)
|
||||||
|
]),
|
||||||
|
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-default:local"));
|
||||||
|
Assert.DoesNotContain(commandRunner.Commands, command => command.Arguments.Contains("meeting-assistant-pyannote-named:local"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MeetingAssistantOptions CreateValidationOptions(string image)
|
||||||
|
{
|
||||||
|
return new MeetingAssistantOptions
|
||||||
|
{
|
||||||
|
Recording = { TranscriptionProvider = "azure-speech" },
|
||||||
|
SpeakerIdentification =
|
||||||
|
{
|
||||||
|
PyannoteValidation =
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
Diarization =
|
||||||
|
{
|
||||||
|
DockerCommand = "docker",
|
||||||
|
Image = image,
|
||||||
|
ModelsFolder = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"meeting-assistant-tests",
|
||||||
|
Guid.NewGuid().ToString("N"),
|
||||||
|
"models"),
|
||||||
|
Token = "hf_test",
|
||||||
|
TokenEnv = "",
|
||||||
|
CommandTimeout = TimeSpan.FromMinutes(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeLaunchProfileOptionsProvider : ILaunchProfileOptionsProvider
|
private sealed class FakeLaunchProfileOptionsProvider : ILaunchProfileOptionsProvider
|
||||||
{
|
{
|
||||||
private readonly MeetingAssistantOptions options;
|
private readonly IReadOnlyList<LaunchProfile> profiles;
|
||||||
|
|
||||||
public FakeLaunchProfileOptionsProvider(MeetingAssistantOptions options)
|
public FakeLaunchProfileOptionsProvider(MeetingAssistantOptions options)
|
||||||
|
: this([new LaunchProfile(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, options)])
|
||||||
{
|
{
|
||||||
this.options = options;
|
}
|
||||||
|
|
||||||
|
public FakeLaunchProfileOptionsProvider(IReadOnlyList<LaunchProfile> profiles)
|
||||||
|
{
|
||||||
|
this.profiles = profiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LaunchProfile GetRequiredProfile(string? name)
|
public LaunchProfile GetRequiredProfile(string? name)
|
||||||
{
|
{
|
||||||
return new LaunchProfile(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, options);
|
var profileName = string.IsNullOrWhiteSpace(name)
|
||||||
|
? ConfigurationLaunchProfileOptionsProvider.DefaultProfileName
|
||||||
|
: name;
|
||||||
|
return profiles.Single(profile => profile.Name.Equals(profileName, StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<LaunchProfile> GetProfiles()
|
public IReadOnlyList<LaunchProfile> GetProfiles()
|
||||||
{
|
{
|
||||||
return [GetRequiredProfile(null)];
|
return profiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<LaunchProfileHotkey> GetHotkeys()
|
public IReadOnlyList<LaunchProfileHotkey> GetHotkeys()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using MeetingAssistant;
|
|||||||
using MeetingAssistant.Recording;
|
using MeetingAssistant.Recording;
|
||||||
using MeetingAssistant.Speakers;
|
using MeetingAssistant.Speakers;
|
||||||
using MeetingAssistant.Transcription;
|
using MeetingAssistant.Transcription;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NAudio.Wave;
|
using NAudio.Wave;
|
||||||
@@ -57,17 +58,49 @@ public sealed class PyannoteSpeakerIdentityMatchValidatorTests
|
|||||||
Assert.Empty(commandRunner.Commands);
|
Assert.Empty(commandRunner.Commands);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OuterToggleControlsValidationWhenLegacyNestedToggleIsFalse()
|
||||||
|
{
|
||||||
|
var commandRunner = new CapturingCommandRunner(
|
||||||
|
"""
|
||||||
|
__MEETING_ASSISTANT_PYANNOTE_JSON_START__
|
||||||
|
[{"start":0.0,"end":20.0,"speaker":"SPEAKER_00"}]
|
||||||
|
__MEETING_ASSISTANT_PYANNOTE_JSON_END__
|
||||||
|
""");
|
||||||
|
var modelsFolder = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"meeting-assistant-tests",
|
||||||
|
Guid.NewGuid().ToString("N"),
|
||||||
|
"models");
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["SpeakerIdentification:PyannoteValidation:Enabled"] = "true",
|
||||||
|
["SpeakerIdentification:PyannoteValidation:Diarization:Enabled"] = "false",
|
||||||
|
["SpeakerIdentification:PyannoteValidation:Diarization:BuildImage"] = "false",
|
||||||
|
["SpeakerIdentification:PyannoteValidation:Diarization:ModelsFolder"] = modelsFolder,
|
||||||
|
["SpeakerIdentification:PyannoteValidation:Diarization:Token"] = "hf_test",
|
||||||
|
["SpeakerIdentification:PyannoteValidation:Diarization:TokenEnv"] = ""
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
var configuredOptions = configuration.Get<MeetingAssistantOptions>()!;
|
||||||
|
var validator = CreateValidator(commandRunner, configuredOptions);
|
||||||
|
|
||||||
|
var valid = await validator.ValidateSampleAsync(
|
||||||
|
CreateWav(TimeSpan.FromSeconds(20)),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(valid);
|
||||||
|
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("run"));
|
||||||
|
}
|
||||||
|
|
||||||
private static PyannoteSpeakerIdentityMatchValidator CreateValidator(
|
private static PyannoteSpeakerIdentityMatchValidator CreateValidator(
|
||||||
CapturingCommandRunner commandRunner,
|
CapturingCommandRunner commandRunner,
|
||||||
bool enabled = true)
|
bool enabled = true)
|
||||||
{
|
{
|
||||||
var finalizer = new PyannoteTranscriptFinalizer(
|
return CreateValidator(
|
||||||
commandRunner,
|
commandRunner,
|
||||||
Options.Create(new MeetingAssistantOptions()),
|
new MeetingAssistantOptions
|
||||||
NullLogger<PyannoteTranscriptFinalizer>.Instance);
|
|
||||||
return new PyannoteSpeakerIdentityMatchValidator(
|
|
||||||
finalizer,
|
|
||||||
Options.Create(new MeetingAssistantOptions
|
|
||||||
{
|
{
|
||||||
SpeakerIdentification = new SpeakerIdentificationOptions
|
SpeakerIdentification = new SpeakerIdentificationOptions
|
||||||
{
|
{
|
||||||
@@ -76,9 +109,8 @@ public sealed class PyannoteSpeakerIdentityMatchValidatorTests
|
|||||||
Enabled = enabled,
|
Enabled = enabled,
|
||||||
MinimumSingleSpeakerCoverage = 0.90,
|
MinimumSingleSpeakerCoverage = 0.90,
|
||||||
MinimumMatchingKnownSnippetRatio = 1,
|
MinimumMatchingKnownSnippetRatio = 1,
|
||||||
Diarization = new PyannoteDiarizationOptions
|
Diarization = new PyannoteRuntimeOptions
|
||||||
{
|
{
|
||||||
Enabled = true,
|
|
||||||
BuildImage = false,
|
BuildImage = false,
|
||||||
DockerCommand = "docker",
|
DockerCommand = "docker",
|
||||||
Image = "meeting-assistant-pyannote:local",
|
Image = "meeting-assistant-pyannote:local",
|
||||||
@@ -94,7 +126,20 @@ public sealed class PyannoteSpeakerIdentityMatchValidatorTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PyannoteSpeakerIdentityMatchValidator CreateValidator(
|
||||||
|
CapturingCommandRunner commandRunner,
|
||||||
|
MeetingAssistantOptions configuredOptions)
|
||||||
|
{
|
||||||
|
var finalizer = new PyannoteTranscriptFinalizer(
|
||||||
|
commandRunner,
|
||||||
|
Options.Create(new MeetingAssistantOptions()),
|
||||||
|
NullLogger<PyannoteTranscriptFinalizer>.Instance);
|
||||||
|
return new PyannoteSpeakerIdentityMatchValidator(
|
||||||
|
finalizer,
|
||||||
|
Options.Create(configuredOptions),
|
||||||
NullLogger<PyannoteSpeakerIdentityMatchValidator>.Instance);
|
NullLogger<PyannoteSpeakerIdentityMatchValidator>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -156,9 +156,8 @@ public sealed class PyannoteTranscriptFinalizerTests
|
|||||||
commandRunner,
|
commandRunner,
|
||||||
Options.Create(new MeetingAssistantOptions()),
|
Options.Create(new MeetingAssistantOptions()),
|
||||||
NullLogger<PyannoteTranscriptFinalizer>.Instance);
|
NullLogger<PyannoteTranscriptFinalizer>.Instance);
|
||||||
var explicitDiarization = new PyannoteDiarizationOptions
|
var explicitDiarization = new PyannoteRuntimeOptions
|
||||||
{
|
{
|
||||||
Enabled = true,
|
|
||||||
DockerCommand = "docker",
|
DockerCommand = "docker",
|
||||||
BaseImage = "python:3.11-slim",
|
BaseImage = "python:3.11-slim",
|
||||||
Image = "meeting-assistant-pyannote-azure:local",
|
Image = "meeting-assistant-pyannote-azure:local",
|
||||||
@@ -169,7 +168,7 @@ public sealed class PyannoteTranscriptFinalizerTests
|
|||||||
CommandTimeout = TimeSpan.FromMinutes(1)
|
CommandTimeout = TimeSpan.FromMinutes(1)
|
||||||
};
|
};
|
||||||
|
|
||||||
await finalizer.FinalizeAsync(
|
await finalizer.FinalizeEnabledAsync(
|
||||||
audioPath,
|
audioPath,
|
||||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Unknown", "hello")],
|
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Unknown", "hello")],
|
||||||
explicitDiarization,
|
explicitDiarization,
|
||||||
@@ -186,9 +185,8 @@ public sealed class PyannoteTranscriptFinalizerTests
|
|||||||
{
|
{
|
||||||
var commandRunner = new CapturingCommandRunner("");
|
var commandRunner = new CapturingCommandRunner("");
|
||||||
var finalizer = CreateFinalizer(commandRunner, token: "hf_test");
|
var finalizer = CreateFinalizer(commandRunner, token: "hf_test");
|
||||||
var diarization = new PyannoteDiarizationOptions
|
var diarization = new PyannoteRuntimeOptions
|
||||||
{
|
{
|
||||||
Enabled = true,
|
|
||||||
DockerCommand = "docker",
|
DockerCommand = "docker",
|
||||||
BaseImage = "python:3.11-slim",
|
BaseImage = "python:3.11-slim",
|
||||||
Image = "meeting-assistant-pyannote-warmup:local",
|
Image = "meeting-assistant-pyannote-warmup:local",
|
||||||
@@ -218,9 +216,8 @@ public sealed class PyannoteTranscriptFinalizerTests
|
|||||||
var commandRunner = new CapturingCommandRunner("");
|
var commandRunner = new CapturingCommandRunner("");
|
||||||
var finalizer = CreateFinalizer(commandRunner, token: null);
|
var finalizer = CreateFinalizer(commandRunner, token: null);
|
||||||
|
|
||||||
await finalizer.WarmUpAsync(new PyannoteDiarizationOptions
|
await finalizer.WarmUpAsync(new PyannoteRuntimeOptions
|
||||||
{
|
{
|
||||||
Enabled = true,
|
|
||||||
Token = "",
|
Token = "",
|
||||||
TokenEnv = "",
|
TokenEnv = "",
|
||||||
ModelsFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "models")
|
ModelsFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "models")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -74,7 +74,8 @@ public sealed class TaskbarIconTests
|
|||||||
menu,
|
menu,
|
||||||
("Open agent", MeetingTaskbarAction.EditRules, false),
|
("Open agent", MeetingTaskbarAction.EditRules, false),
|
||||||
("Finish meeting", MeetingTaskbarAction.StopRecording, true),
|
("Finish meeting", MeetingTaskbarAction.StopRecording, true),
|
||||||
("Microphone", MeetingTaskbarAction.OpenSubmenu, true),
|
("Pause transcription", MeetingTaskbarAction.PauseTranscription, true),
|
||||||
|
("Microphone", MeetingTaskbarAction.OpenSubmenu, false),
|
||||||
("Cancel meeting recording and discard", MeetingTaskbarAction.AbortRecording, false),
|
("Cancel meeting recording and discard", MeetingTaskbarAction.AbortRecording, false),
|
||||||
("Switch to english\tCtrl+Alt+L", MeetingTaskbarAction.SwitchProfile, false),
|
("Switch to english\tCtrl+Alt+L", MeetingTaskbarAction.SwitchProfile, false),
|
||||||
("Exit", MeetingTaskbarAction.Exit, true));
|
("Exit", MeetingTaskbarAction.Exit, true));
|
||||||
@@ -94,10 +95,32 @@ public sealed class TaskbarIconTests
|
|||||||
menu,
|
menu,
|
||||||
("Open agent", MeetingTaskbarAction.EditRules, false),
|
("Open agent", MeetingTaskbarAction.EditRules, false),
|
||||||
("Finish meeting", MeetingTaskbarAction.StopRecording, true),
|
("Finish meeting", MeetingTaskbarAction.StopRecording, true),
|
||||||
("Cancel meeting recording and discard", MeetingTaskbarAction.AbortRecording, true),
|
("Pause transcription", MeetingTaskbarAction.PauseTranscription, true),
|
||||||
|
("Cancel meeting recording and discard", MeetingTaskbarAction.AbortRecording, false),
|
||||||
("Exit", MeetingTaskbarAction.Exit, true));
|
("Exit", MeetingTaskbarAction.Exit, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TranscriptionPauseActionTracksActivePauseStateWithoutHidingFinish()
|
||||||
|
{
|
||||||
|
var runningMenu = MeetingTaskbarMenuBuilder.Build(
|
||||||
|
Status(isRecording: true, state: RecordingProcessState.Recording, profile: "default"),
|
||||||
|
[Profile("default")]);
|
||||||
|
var pausedMenu = MeetingTaskbarMenuBuilder.Build(
|
||||||
|
Status(isRecording: true, state: RecordingProcessState.Recording, profile: "default", isPaused: true),
|
||||||
|
[Profile("default")]);
|
||||||
|
|
||||||
|
Assert.Contains(runningMenu.Items, item =>
|
||||||
|
item.Action == MeetingTaskbarAction.PauseTranscription &&
|
||||||
|
item.Text == "Pause transcription");
|
||||||
|
Assert.Contains(pausedMenu.Items, item =>
|
||||||
|
item.Action == MeetingTaskbarAction.UnpauseTranscription &&
|
||||||
|
item.Text == "Unpause transcription");
|
||||||
|
Assert.Contains(pausedMenu.Items, item =>
|
||||||
|
item.Action == MeetingTaskbarAction.StopRecording &&
|
||||||
|
item.Text == "Finish meeting");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ProcessingMenuShowsSummarizingButAllowsStartingNewRecordings()
|
public void ProcessingMenuShowsSummarizingButAllowsStartingNewRecordings()
|
||||||
{
|
{
|
||||||
@@ -200,7 +223,8 @@ public sealed class TaskbarIconTests
|
|||||||
private static RecordingStatus Status(
|
private static RecordingStatus Status(
|
||||||
bool isRecording = false,
|
bool isRecording = false,
|
||||||
RecordingProcessState state = RecordingProcessState.Idle,
|
RecordingProcessState state = RecordingProcessState.Idle,
|
||||||
string? profile = null)
|
string? profile = null,
|
||||||
|
bool isPaused = false)
|
||||||
{
|
{
|
||||||
return new RecordingStatus(
|
return new RecordingStatus(
|
||||||
isRecording,
|
isRecording,
|
||||||
@@ -209,7 +233,8 @@ public sealed class TaskbarIconTests
|
|||||||
state == RecordingProcessState.Idle ? null : "context.md",
|
state == RecordingProcessState.Idle ? null : "context.md",
|
||||||
state == RecordingProcessState.Idle ? null : "summary.md",
|
state == RecordingProcessState.Idle ? null : "summary.md",
|
||||||
state,
|
state,
|
||||||
profile);
|
profile,
|
||||||
|
isPaused);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SupportedOSPlatform("windows")]
|
[SupportedOSPlatform("windows")]
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ public sealed class RecordingInactivitySafeguardOptions
|
|||||||
|
|
||||||
public TimeSpan AutoStopAfter { get; set; } = TimeSpan.FromMinutes(30);
|
public TimeSpan AutoStopAfter { get; set; } = TimeSpan.FromMinutes(30);
|
||||||
|
|
||||||
|
public TimeSpan MaximumPauseDuration { get; set; } = TimeSpan.FromHours(4);
|
||||||
|
|
||||||
public TimeSpan InferredEndPadding { get; set; } = TimeSpan.FromMinutes(1);
|
public TimeSpan InferredEndPadding { get; set; } = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromSeconds(15);
|
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromSeconds(15);
|
||||||
@@ -181,10 +183,8 @@ public sealed class WhisperLocalOptions
|
|||||||
public PyannoteDiarizationOptions Diarization { get; set; } = new();
|
public PyannoteDiarizationOptions Diarization { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class PyannoteDiarizationOptions
|
public class PyannoteRuntimeOptions
|
||||||
{
|
{
|
||||||
public bool Enabled { get; set; } = true;
|
|
||||||
|
|
||||||
public string DockerCommand { get; set; } = "docker";
|
public string DockerCommand { get; set; } = "docker";
|
||||||
|
|
||||||
public string BaseImage { get; set; } = "python:3.11-slim";
|
public string BaseImage { get; set; } = "python:3.11-slim";
|
||||||
@@ -208,6 +208,11 @@ public sealed class PyannoteDiarizationOptions
|
|||||||
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromMinutes(30);
|
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromMinutes(30);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class PyannoteDiarizationOptions : PyannoteRuntimeOptions
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class AzureSpeechOptions
|
public sealed class AzureSpeechOptions
|
||||||
{
|
{
|
||||||
public string Endpoint { get; set; } = "";
|
public string Endpoint { get; set; } = "";
|
||||||
@@ -365,9 +370,8 @@ public sealed class SpeakerIdentityPyannoteValidationOptions
|
|||||||
|
|
||||||
public double MinimumMatchingKnownSnippetRatio { get; set; } = 0.50;
|
public double MinimumMatchingKnownSnippetRatio { get; set; } = 0.50;
|
||||||
|
|
||||||
public PyannoteDiarizationOptions Diarization { get; set; } = new()
|
public PyannoteRuntimeOptions Diarization { get; set; } = new()
|
||||||
{
|
{
|
||||||
Enabled = true,
|
|
||||||
CommandTimeout = TimeSpan.FromHours(1),
|
CommandTimeout = TimeSpan.FromHours(1),
|
||||||
AlignmentMode = PyannoteAlignmentMode.PyannoteTurns
|
AlignmentMode = PyannoteAlignmentMode.PyannoteTurns
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ public interface IMeetingInactivityPromptService
|
|||||||
MeetingInactivityPromptRequest request,
|
MeetingInactivityPromptRequest request,
|
||||||
Func<MeetingInactivityPromptResponse, CancellationToken, Task> handleResponseAsync,
|
Func<MeetingInactivityPromptResponse, CancellationToken, Task> handleResponseAsync,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task DismissAllAsync(CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record MeetingInactivityPromptRequest(
|
public sealed record MeetingInactivityPromptRequest(
|
||||||
@@ -18,9 +20,32 @@ public enum MeetingInactivityPromptResponse
|
|||||||
{
|
{
|
||||||
Dismissed,
|
Dismissed,
|
||||||
Continue,
|
Continue,
|
||||||
|
Pause,
|
||||||
Stop
|
Stop
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed record MeetingInactivityPromptAction(
|
||||||
|
string Content,
|
||||||
|
string ResponseArgument,
|
||||||
|
MeetingInactivityPromptResponse Response);
|
||||||
|
|
||||||
|
internal static class MeetingInactivityPromptActions
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<MeetingInactivityPromptAction> All { get; } =
|
||||||
|
[
|
||||||
|
new("Yes", "stop", MeetingInactivityPromptResponse.Stop),
|
||||||
|
new("No", "continue", MeetingInactivityPromptResponse.Continue),
|
||||||
|
new("Pause transcription", "pause", MeetingInactivityPromptResponse.Pause)
|
||||||
|
];
|
||||||
|
|
||||||
|
public static MeetingInactivityPromptResponse ParseResponse(string? value)
|
||||||
|
{
|
||||||
|
return All.FirstOrDefault(action =>
|
||||||
|
action.ResponseArgument.Equals(value, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?.Response ?? MeetingInactivityPromptResponse.Continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class NoopMeetingInactivityPromptService : IMeetingInactivityPromptService
|
public sealed class NoopMeetingInactivityPromptService : IMeetingInactivityPromptService
|
||||||
{
|
{
|
||||||
public Task ShowStopPromptAsync(
|
public Task ShowStopPromptAsync(
|
||||||
@@ -30,4 +55,9 @@ public sealed class NoopMeetingInactivityPromptService : IMeetingInactivityPromp
|
|||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task DismissAllAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
currentArtifacts?.AssistantContextPath,
|
currentArtifacts?.AssistantContextPath,
|
||||||
currentArtifacts?.SummaryPath,
|
currentArtifacts?.SummaryPath,
|
||||||
GetProcessState(currentRun),
|
GetProcessState(currentRun),
|
||||||
currentRun?.LaunchProfileName);
|
currentRun?.LaunchProfileName,
|
||||||
|
IsRecording && currentRun?.IsTranscriptionPaused == true);
|
||||||
|
|
||||||
public MeetingSessionArtifacts? CurrentArtifacts => currentArtifacts;
|
public MeetingSessionArtifacts? CurrentArtifacts => currentArtifacts;
|
||||||
|
|
||||||
@@ -292,7 +293,7 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
runOptions.SpeakerIdentification.MaxSnippetsPerSpeaker,
|
runOptions.SpeakerIdentification.MaxSnippetsPerSpeaker,
|
||||||
logger);
|
logger);
|
||||||
run.Task = Task.Run(() => RecordAsync(run), CancellationToken.None);
|
run.Task = Task.Run(() => RecordAsync(run), CancellationToken.None);
|
||||||
if (runOptions.Recording.InactivitySafeguard.Enabled)
|
if (ShouldRunInactivitySafeguard(runOptions.Recording.InactivitySafeguard))
|
||||||
{
|
{
|
||||||
_ = Task.Run(
|
_ = Task.Run(
|
||||||
() => RunInactivitySafeguardAsync(run),
|
() => RunInactivitySafeguardAsync(run),
|
||||||
@@ -334,11 +335,14 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
|
|
||||||
public async Task<RecordingStatus> StopAsync(CancellationToken cancellationToken)
|
public async Task<RecordingStatus> StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await StopAsync(null, cancellationToken);
|
return await StopAsync(null, null, null, null, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<RecordingStatus> StopAsync(
|
private async Task<RecordingStatus> StopAsync(
|
||||||
DateTimeOffset? inferredEndTime,
|
DateTimeOffset? inferredEndTime,
|
||||||
|
RecordingRun? expectedRun,
|
||||||
|
long? expectedActivityVersion,
|
||||||
|
DateTimeOffset? expectedPauseStartedAt,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
RecordingRun run;
|
RecordingRun run;
|
||||||
@@ -347,13 +351,30 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
await gate.WaitAsync(cancellationToken);
|
await gate.WaitAsync(cancellationToken);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (currentRun is null || currentRun.IsCaptureStopping)
|
if (currentRun is null ||
|
||||||
|
currentRun.IsCaptureStopping ||
|
||||||
|
expectedRun is not null && !ReferenceEquals(currentRun, expectedRun))
|
||||||
{
|
{
|
||||||
return CurrentStatus;
|
return CurrentStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
run = currentRun;
|
run = currentRun;
|
||||||
if (inferredEndTime is not null)
|
var inactivityStopClaimed = expectedActivityVersion is not null;
|
||||||
|
if (expectedActivityVersion is not null &&
|
||||||
|
(inferredEndTime is null ||
|
||||||
|
!run.TryClaimInactivityStop(expectedActivityVersion.Value, inferredEndTime.Value)))
|
||||||
|
{
|
||||||
|
return CurrentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
var maximumPauseStopClaimed = expectedPauseStartedAt is not null;
|
||||||
|
if (expectedPauseStartedAt is not null &&
|
||||||
|
!run.TryClaimMaximumPauseStop(expectedPauseStartedAt.Value))
|
||||||
|
{
|
||||||
|
return CurrentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inactivityStopClaimed && !maximumPauseStopClaimed && inferredEndTime is not null)
|
||||||
{
|
{
|
||||||
run.SetInferredEndTime(inferredEndTime.Value);
|
run.SetInferredEndTime(inferredEndTime.Value);
|
||||||
}
|
}
|
||||||
@@ -363,7 +384,7 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
{
|
{
|
||||||
run.Abort();
|
run.Abort();
|
||||||
}
|
}
|
||||||
else
|
else if (!inactivityStopClaimed && !maximumPauseStopClaimed)
|
||||||
{
|
{
|
||||||
run.StopCapture();
|
run.StopCapture();
|
||||||
}
|
}
|
||||||
@@ -409,6 +430,65 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
return CurrentStatus;
|
return CurrentStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<RecordingStatus> SetTranscriptionPausedAsync(
|
||||||
|
bool isPaused,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await SetTranscriptionPausedAsync(isPaused, null, null, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<RecordingStatus> SetTranscriptionPausedAsync(
|
||||||
|
bool isPaused,
|
||||||
|
RecordingRun? expectedRun,
|
||||||
|
long? expectedActivityVersion,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await gate.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var run = currentRun;
|
||||||
|
if (run is null ||
|
||||||
|
run.IsCaptureStopping ||
|
||||||
|
expectedRun is not null && !ReferenceEquals(run, expectedRun))
|
||||||
|
{
|
||||||
|
return CurrentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
var changed = false;
|
||||||
|
await run.PipelineGate.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var changedAt = inactivityClock.Now;
|
||||||
|
changed = run.TrySetTranscriptionPaused(
|
||||||
|
isPaused,
|
||||||
|
changedAt,
|
||||||
|
expectedActivityVersion);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
run.PipelineGate.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed && isPaused)
|
||||||
|
{
|
||||||
|
await DismissInactivityPromptsAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Meeting transcription {PauseState}",
|
||||||
|
isPaused ? "paused" : "unpaused");
|
||||||
|
}
|
||||||
|
|
||||||
|
return CurrentStatus;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<RecordingStatus> AbortAsync(CancellationToken cancellationToken)
|
public async Task<RecordingStatus> AbortAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
RecordingRun run;
|
RecordingRun run;
|
||||||
@@ -661,8 +741,7 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
chunk.Channels);
|
chunk.Channels);
|
||||||
}
|
}
|
||||||
|
|
||||||
await run.RecordedAudio.AppendAsync(chunk, run.CaptureCancellation);
|
await run.RouteCapturedAudioAsync(chunk, run.CaptureCancellation);
|
||||||
await run.WriteAudioAsync(chunk, run.CaptureCancellation);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
@@ -701,7 +780,6 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
run.ResetSpeakerIdentification();
|
run.ResetSpeakerIdentification();
|
||||||
}
|
}
|
||||||
|
|
||||||
run.RecordTranscriptActivity(segment, inactivityClock.Now);
|
|
||||||
var sample = run.TryAddSpeakerSample(segment);
|
var sample = run.TryAddSpeakerSample(segment);
|
||||||
if (sample is not null)
|
if (sample is not null)
|
||||||
{
|
{
|
||||||
@@ -718,6 +796,47 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
var relabeledSegment = run.Relabel(segment);
|
var relabeledSegment = run.Relabel(segment);
|
||||||
run.AddLiveSegment(relabeledSegment);
|
run.AddLiveSegment(relabeledSegment);
|
||||||
await AppendTranscriptSegmentAsync(run, relabeledSegment, cancellationToken);
|
await AppendTranscriptSegmentAsync(run, relabeledSegment, cancellationToken);
|
||||||
|
run.RecordTranscriptActivity(segment, inactivityClock.Now);
|
||||||
|
if (!string.IsNullOrWhiteSpace(segment.Text))
|
||||||
|
{
|
||||||
|
await DismissInactivityPromptsForRunAsync(run, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DismissInactivityPromptsForRunAsync(
|
||||||
|
RecordingRun run,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await gate.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(currentRun, run) || run.IsCaptureStopping)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await DismissInactivityPromptsAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DismissInactivityPromptsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await inactivityPromptService.DismissAllAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(exception, "Failed to dismiss transcript inactivity prompts");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,7 +964,10 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
private async Task RunInactivitySafeguardAsync(RecordingRun run)
|
private async Task RunInactivitySafeguardAsync(RecordingRun run)
|
||||||
{
|
{
|
||||||
var safeguardOptions = run.Options.Recording.InactivitySafeguard;
|
var safeguardOptions = run.Options.Recording.InactivitySafeguard;
|
||||||
var promptThresholds = GetInactivityPromptThresholds(safeguardOptions);
|
var promptThresholds = safeguardOptions.Enabled
|
||||||
|
? GetInactivityPromptThresholds(safeguardOptions)
|
||||||
|
: [];
|
||||||
|
var checkInterval = GetInactivitySafeguardCheckInterval(safeguardOptions);
|
||||||
var promptedThresholds = new HashSet<TimeSpan>();
|
var promptedThresholds = new HashSet<TimeSpan>();
|
||||||
var lastActivityVersion = run.GetTranscriptActivitySnapshot().ActivityVersion;
|
var lastActivityVersion = run.GetTranscriptActivitySnapshot().ActivityVersion;
|
||||||
|
|
||||||
@@ -853,7 +975,7 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
"Recording inactivity safeguard started with prompt thresholds {PromptThresholds}, auto-stop {AutoStopAfter}, check interval {CheckInterval}",
|
"Recording inactivity safeguard started with prompt thresholds {PromptThresholds}, auto-stop {AutoStopAfter}, check interval {CheckInterval}",
|
||||||
string.Join(", ", promptThresholds),
|
string.Join(", ", promptThresholds),
|
||||||
safeguardOptions.AutoStopAfter,
|
safeguardOptions.AutoStopAfter,
|
||||||
safeguardOptions.CheckInterval);
|
checkInterval);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -869,9 +991,38 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
snapshot.LastTranscriptActivityAt);
|
snapshot.LastTranscriptActivityAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var pauseStartedAt = snapshot.TranscriptionPausedAt;
|
||||||
|
if (pauseStartedAt is not null)
|
||||||
|
{
|
||||||
|
var maximumPauseDuration = safeguardOptions.MaximumPauseDuration;
|
||||||
|
if (maximumPauseDuration > TimeSpan.Zero &&
|
||||||
|
inactivityClock.Now - pauseStartedAt.Value >= maximumPauseDuration)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Recording inactivity safeguard stopping meeting after transcription remained paused for {PauseDuration}",
|
||||||
|
maximumPauseDuration);
|
||||||
|
var stopStatus = await StopAsync(
|
||||||
|
null,
|
||||||
|
run,
|
||||||
|
null,
|
||||||
|
pauseStartedAt,
|
||||||
|
CancellationToken.None);
|
||||||
|
if (!stopStatus.IsRecording)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await inactivityClock.DelayAsync(
|
||||||
|
checkInterval,
|
||||||
|
run.CaptureCancellation);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var now = inactivityClock.Now;
|
var now = inactivityClock.Now;
|
||||||
var inactivityDuration = now - snapshot.LastTranscriptActivityAt;
|
var inactivityDuration = now - snapshot.LastTranscriptActivityAt;
|
||||||
if (safeguardOptions.AutoStopAfter > TimeSpan.Zero &&
|
if (safeguardOptions.Enabled &&
|
||||||
|
safeguardOptions.AutoStopAfter > TimeSpan.Zero &&
|
||||||
inactivityDuration >= safeguardOptions.AutoStopAfter)
|
inactivityDuration >= safeguardOptions.AutoStopAfter)
|
||||||
{
|
{
|
||||||
var inferredEndTime = run.GetInactivitySafeguardEndTime(safeguardOptions.InferredEndPadding);
|
var inferredEndTime = run.GetInactivitySafeguardEndTime(safeguardOptions.InferredEndPadding);
|
||||||
@@ -879,8 +1030,16 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
"Recording inactivity safeguard auto-stopping meeting after {InactivityDuration} without transcript text; inferred end time {InferredEndTime}",
|
"Recording inactivity safeguard auto-stopping meeting after {InactivityDuration} without transcript text; inferred end time {InferredEndTime}",
|
||||||
inactivityDuration,
|
inactivityDuration,
|
||||||
inferredEndTime);
|
inferredEndTime);
|
||||||
await StopAsync(inferredEndTime, CancellationToken.None);
|
var stopStatus = await StopAsync(
|
||||||
return;
|
inferredEndTime,
|
||||||
|
run,
|
||||||
|
snapshot.ActivityVersion,
|
||||||
|
null,
|
||||||
|
CancellationToken.None);
|
||||||
|
if (!stopStatus.IsRecording)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var threshold in promptThresholds)
|
foreach (var threshold in promptThresholds)
|
||||||
@@ -897,6 +1056,7 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
inactivityDuration,
|
inactivityDuration,
|
||||||
threshold,
|
threshold,
|
||||||
inferredEndTime);
|
inferredEndTime);
|
||||||
|
var promptActivityVersion = snapshot.ActivityVersion;
|
||||||
await inactivityPromptService.ShowStopPromptAsync(
|
await inactivityPromptService.ShowStopPromptAsync(
|
||||||
new MeetingInactivityPromptRequest(
|
new MeetingInactivityPromptRequest(
|
||||||
inactivityDuration,
|
inactivityDuration,
|
||||||
@@ -912,16 +1072,36 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
|
|
||||||
if (response == MeetingInactivityPromptResponse.Stop)
|
if (response == MeetingInactivityPromptResponse.Stop)
|
||||||
{
|
{
|
||||||
await StopAsync(inferredEndTime, CancellationToken.None);
|
await StopAsync(
|
||||||
|
inferredEndTime,
|
||||||
|
run,
|
||||||
|
promptActivityVersion,
|
||||||
|
null,
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
else if (response == MeetingInactivityPromptResponse.Pause)
|
||||||
|
{
|
||||||
|
await SetTranscriptionPausedAsync(
|
||||||
|
true,
|
||||||
|
run,
|
||||||
|
promptActivityVersion,
|
||||||
|
CancellationToken.None);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
run.CaptureCancellation);
|
run.CaptureCancellation);
|
||||||
|
|
||||||
|
var currentActivity = run.GetTranscriptActivitySnapshot();
|
||||||
|
if (currentActivity.IsTranscriptionPaused ||
|
||||||
|
currentActivity.ActivityVersion != promptActivityVersion)
|
||||||
|
{
|
||||||
|
await DismissInactivityPromptsAsync(run.CaptureCancellation);
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
await inactivityClock.DelayAsync(
|
await inactivityClock.DelayAsync(
|
||||||
GetInactivitySafeguardCheckInterval(safeguardOptions),
|
checkInterval,
|
||||||
run.CaptureCancellation);
|
run.CaptureCancellation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1821,6 +2001,11 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool ShouldRunInactivitySafeguard(RecordingInactivitySafeguardOptions options)
|
||||||
|
{
|
||||||
|
return options.Enabled || options.MaximumPauseDuration > TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
private static TimeSpan GetInactivitySafeguardCheckInterval(
|
private static TimeSpan GetInactivitySafeguardCheckInterval(
|
||||||
RecordingInactivitySafeguardOptions options)
|
RecordingInactivitySafeguardOptions options)
|
||||||
{
|
{
|
||||||
@@ -1877,6 +2062,8 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
private TimeSpan? lastTranscriptSegmentEnd;
|
private TimeSpan? lastTranscriptSegmentEnd;
|
||||||
private long transcriptActivityVersion;
|
private long transcriptActivityVersion;
|
||||||
private DateTimeOffset? inferredEndTime;
|
private DateTimeOffset? inferredEndTime;
|
||||||
|
private bool isTranscriptionPaused;
|
||||||
|
private DateTimeOffset? transcriptionPausedAt;
|
||||||
|
|
||||||
public RecordingRun(
|
public RecordingRun(
|
||||||
CancellationTokenSource captureCancellation,
|
CancellationTokenSource captureCancellation,
|
||||||
@@ -1960,6 +2147,17 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
|
|
||||||
public bool HasAttachedPromptMetadata { get; private set; }
|
public bool HasAttachedPromptMetadata { get; private set; }
|
||||||
|
|
||||||
|
public bool IsTranscriptionPaused
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (transcriptActivityGate)
|
||||||
|
{
|
||||||
|
return isTranscriptionPaused;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public AssistantContextState ContextState { get; private set; } = AssistantContextState.CollectingMetadata;
|
public AssistantContextState ContextState { get; private set; } = AssistantContextState.CollectingMetadata;
|
||||||
|
|
||||||
public DateTimeOffset? InferredEndTime
|
public DateTimeOffset? InferredEndTime
|
||||||
@@ -1983,6 +2181,37 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
HasAttachedPromptMetadata = true;
|
HasAttachedPromptMetadata = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool TrySetTranscriptionPaused(
|
||||||
|
bool isPaused,
|
||||||
|
DateTimeOffset changedAt,
|
||||||
|
long? expectedActivityVersion)
|
||||||
|
{
|
||||||
|
lock (transcriptActivityGate)
|
||||||
|
{
|
||||||
|
if (expectedActivityVersion is not null &&
|
||||||
|
transcriptActivityVersion != expectedActivityVersion.Value)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTranscriptionPaused == isPaused)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isTranscriptionPaused = isPaused;
|
||||||
|
transcriptionPausedAt = isPaused ? changedAt : null;
|
||||||
|
|
||||||
|
if (!isPaused)
|
||||||
|
{
|
||||||
|
lastTranscriptActivityAt = changedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
transcriptActivityVersion++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Abort()
|
public void Abort()
|
||||||
{
|
{
|
||||||
IsAborted = true;
|
IsAborted = true;
|
||||||
@@ -2011,13 +2240,17 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
return string.Equals(LaunchProfileName, launchProfileName, StringComparison.OrdinalIgnoreCase);
|
return string.Equals(LaunchProfileName, launchProfileName, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask WriteAudioAsync(AudioChunk chunk, CancellationToken cancellationToken)
|
public async ValueTask RouteCapturedAudioAsync(AudioChunk chunk, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await PipelineGate.WaitAsync(cancellationToken);
|
await PipelineGate.WaitAsync(cancellationToken);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
AppendAudio(chunk);
|
var routedChunk = IsTranscriptionPaused
|
||||||
await Pipeline.WriteAsync(chunk, cancellationToken);
|
? chunk with { Pcm = new byte[chunk.Pcm.Length] }
|
||||||
|
: chunk;
|
||||||
|
await RecordedAudio.AppendAsync(routedChunk, cancellationToken);
|
||||||
|
AppendAudio(routedChunk);
|
||||||
|
await Pipeline.WriteAsync(routedChunk, cancellationToken);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -2201,7 +2434,9 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
return new TranscriptActivitySnapshot(
|
return new TranscriptActivitySnapshot(
|
||||||
lastTranscriptActivityAt,
|
lastTranscriptActivityAt,
|
||||||
lastTranscriptSegmentEnd,
|
lastTranscriptSegmentEnd,
|
||||||
transcriptActivityVersion);
|
transcriptActivityVersion,
|
||||||
|
isTranscriptionPaused,
|
||||||
|
transcriptionPausedAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2221,6 +2456,35 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool TryClaimInactivityStop(long expectedActivityVersion, DateTimeOffset endTime)
|
||||||
|
{
|
||||||
|
lock (transcriptActivityGate)
|
||||||
|
{
|
||||||
|
if (isTranscriptionPaused || transcriptActivityVersion != expectedActivityVersion)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
inferredEndTime = endTime;
|
||||||
|
StopCapture();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryClaimMaximumPauseStop(DateTimeOffset expectedPauseStartedAt)
|
||||||
|
{
|
||||||
|
lock (transcriptActivityGate)
|
||||||
|
{
|
||||||
|
if (!isTranscriptionPaused || transcriptionPausedAt != expectedPauseStartedAt)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
StopCapture();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void AppendAudio(AudioChunk chunk)
|
public void AppendAudio(AudioChunk chunk)
|
||||||
{
|
{
|
||||||
speakerSampleCollector.AppendAudio(chunk);
|
speakerSampleCollector.AppendAudio(chunk);
|
||||||
@@ -2350,7 +2614,9 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
public sealed record TranscriptActivitySnapshot(
|
public sealed record TranscriptActivitySnapshot(
|
||||||
DateTimeOffset LastTranscriptActivityAt,
|
DateTimeOffset LastTranscriptActivityAt,
|
||||||
TimeSpan? LastTranscriptSegmentEnd,
|
TimeSpan? LastTranscriptSegmentEnd,
|
||||||
long ActivityVersion);
|
long ActivityVersion,
|
||||||
|
bool IsTranscriptionPaused,
|
||||||
|
DateTimeOffset? TranscriptionPausedAt);
|
||||||
|
|
||||||
private static int StateRank(AssistantContextState state)
|
private static int StateRank(AssistantContextState state)
|
||||||
{
|
{
|
||||||
@@ -2405,7 +2671,8 @@ public sealed record RecordingStatus(
|
|||||||
string? AssistantContextPath,
|
string? AssistantContextPath,
|
||||||
string? SummaryPath,
|
string? SummaryPath,
|
||||||
RecordingProcessState State = RecordingProcessState.Idle,
|
RecordingProcessState State = RecordingProcessState.Idle,
|
||||||
string? LaunchProfile = null);
|
string? LaunchProfile = null,
|
||||||
|
bool IsPaused = false);
|
||||||
|
|
||||||
public enum RecordingProcessState
|
public enum RecordingProcessState
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
|||||||
private const string NotificationGroup = "meeting-inactivity";
|
private const string NotificationGroup = "meeting-inactivity";
|
||||||
private readonly ConcurrentDictionary<string, PendingPrompt> pendingPrompts = new(StringComparer.OrdinalIgnoreCase);
|
private readonly ConcurrentDictionary<string, PendingPrompt> pendingPrompts = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly ILogger<WindowsMeetingInactivityPromptService> logger;
|
private readonly ILogger<WindowsMeetingInactivityPromptService> logger;
|
||||||
private readonly object registrationGate = new();
|
private readonly object notificationGate = new();
|
||||||
private bool registered;
|
private bool registered;
|
||||||
|
|
||||||
public WindowsMeetingInactivityPromptService(ILogger<WindowsMeetingInactivityPromptService> logger)
|
public WindowsMeetingInactivityPromptService(ILogger<WindowsMeetingInactivityPromptService> logger)
|
||||||
@@ -37,24 +37,27 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
EnsureRegistered();
|
lock (notificationGate)
|
||||||
var promptId = Guid.NewGuid().ToString("N");
|
|
||||||
pendingPrompts[promptId] = new PendingPrompt(handleResponseAsync);
|
|
||||||
logger.LogInformation(
|
|
||||||
"Registered native Windows inactivity notification {PromptId} response callback at threshold {Threshold}",
|
|
||||||
promptId,
|
|
||||||
request.Threshold);
|
|
||||||
var notification = BuildNotification(promptId, request);
|
|
||||||
notification.Show(toast =>
|
|
||||||
{
|
{
|
||||||
toast.Group = NotificationGroup;
|
EnsureRegistered();
|
||||||
toast.Tag = promptId;
|
var promptId = Guid.NewGuid().ToString("N");
|
||||||
toast.ExpirationTime = MeetingToastExpirationPolicy.StopReminderExpiration(DateTimeOffset.Now);
|
pendingPrompts[promptId] = new PendingPrompt(handleResponseAsync);
|
||||||
});
|
logger.LogInformation(
|
||||||
logger.LogInformation(
|
"Registered native Windows inactivity notification {PromptId} response callback at threshold {Threshold}",
|
||||||
"Displayed native Windows inactivity notification {PromptId} at threshold {Threshold}",
|
promptId,
|
||||||
promptId,
|
request.Threshold);
|
||||||
request.Threshold);
|
var notification = BuildNotification(promptId, request);
|
||||||
|
notification.Show(toast =>
|
||||||
|
{
|
||||||
|
toast.Group = NotificationGroup;
|
||||||
|
toast.Tag = promptId;
|
||||||
|
toast.ExpirationTime = MeetingToastExpirationPolicy.StopReminderExpiration(DateTimeOffset.Now);
|
||||||
|
});
|
||||||
|
logger.LogInformation(
|
||||||
|
"Displayed native Windows inactivity notification {PromptId} at threshold {Threshold}",
|
||||||
|
promptId,
|
||||||
|
request.Threshold);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
@@ -76,6 +79,36 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task DismissAllAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
lock (notificationGate)
|
||||||
|
{
|
||||||
|
if (pendingPrompts.IsEmpty)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingPrompts.Clear();
|
||||||
|
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763))
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ToastNotificationManagerCompat.History.RemoveGroup(NotificationGroup);
|
||||||
|
logger.LogInformation("Dismissed all native Windows inactivity notifications");
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(exception, "Failed to dismiss native Windows inactivity notifications");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken)
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@@ -83,29 +116,28 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (!registered)
|
lock (notificationGate)
|
||||||
{
|
{
|
||||||
return;
|
if (!registered)
|
||||||
}
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ToastNotificationManagerCompat.OnActivated -= OnNotificationInvoked;
|
ToastNotificationManagerCompat.OnActivated -= OnNotificationInvoked;
|
||||||
}
|
registered = false;
|
||||||
catch (Exception exception)
|
}
|
||||||
{
|
catch (Exception exception)
|
||||||
logger.LogWarning(exception, "Failed to detach native Windows toast notification activation handler");
|
{
|
||||||
|
logger.LogWarning(exception, "Failed to detach native Windows toast notification activation handler");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureRegistered()
|
private void EnsureRegistered()
|
||||||
{
|
{
|
||||||
if (registered)
|
lock (notificationGate)
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (registrationGate)
|
|
||||||
{
|
{
|
||||||
if (registered)
|
if (registered)
|
||||||
{
|
{
|
||||||
@@ -122,29 +154,25 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
|||||||
string promptId,
|
string promptId,
|
||||||
MeetingInactivityPromptRequest request)
|
MeetingInactivityPromptRequest request)
|
||||||
{
|
{
|
||||||
var yesButton = new ToastButton()
|
var builder = new ToastContentBuilder()
|
||||||
.SetContent("Yes")
|
|
||||||
.AddArgument("source", NotificationSource)
|
|
||||||
.AddArgument("promptId", promptId)
|
|
||||||
.AddArgument("response", "stop")
|
|
||||||
.SetBackgroundActivation();
|
|
||||||
|
|
||||||
var noButton = new ToastButton()
|
|
||||||
.SetContent("No")
|
|
||||||
.AddArgument("source", NotificationSource)
|
|
||||||
.AddArgument("promptId", promptId)
|
|
||||||
.AddArgument("response", "continue")
|
|
||||||
.SetBackgroundActivation();
|
|
||||||
|
|
||||||
return new ToastContentBuilder()
|
|
||||||
.AddArgument("source", NotificationSource)
|
.AddArgument("source", NotificationSource)
|
||||||
.AddArgument("promptId", promptId)
|
.AddArgument("promptId", promptId)
|
||||||
.SetToastScenario(ToastScenario.Reminder)
|
.SetToastScenario(ToastScenario.Reminder)
|
||||||
.SetToastDuration(ToastDuration.Long)
|
.SetToastDuration(ToastDuration.Long)
|
||||||
.AddText("Stop meeting?")
|
.AddText("Stop meeting?")
|
||||||
.AddText($"No transcript text has arrived for {FormatDuration(request.InactivityDuration)}.")
|
.AddText($"No transcript text has arrived for {FormatDuration(request.InactivityDuration)}.");
|
||||||
.AddButton(yesButton)
|
|
||||||
.AddButton(noButton);
|
foreach (var action in MeetingInactivityPromptActions.All)
|
||||||
|
{
|
||||||
|
builder.AddButton(new ToastButton()
|
||||||
|
.SetContent(action.Content)
|
||||||
|
.AddArgument("source", NotificationSource)
|
||||||
|
.AddArgument("promptId", promptId)
|
||||||
|
.AddArgument("response", action.ResponseArgument)
|
||||||
|
.SetBackgroundActivation());
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnNotificationInvoked(ToastNotificationActivatedEventArgsCompat args)
|
private void OnNotificationInvoked(ToastNotificationActivatedEventArgsCompat args)
|
||||||
@@ -177,10 +205,9 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var response = TryGetArgument(arguments, "response", out var responseValue) &&
|
var response = TryGetArgument(arguments, "response", out var responseValue)
|
||||||
string.Equals(responseValue, "stop", StringComparison.OrdinalIgnoreCase)
|
? MeetingInactivityPromptActions.ParseResponse(responseValue)
|
||||||
? MeetingInactivityPromptResponse.Stop
|
: MeetingInactivityPromptResponse.Continue;
|
||||||
: MeetingInactivityPromptResponse.Continue;
|
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ public sealed class PyannoteSpeakerIdentityMatchValidator : ISpeakerIdentityMatc
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return await finalizer.FinalizeAsync(
|
return await finalizer.FinalizeEnabledAsync(
|
||||||
wavPath,
|
wavPath,
|
||||||
[new TranscriptionSegment(TimeSpan.Zero, duration, "Unknown", "speaker identity validation sample")],
|
[new TranscriptionSegment(TimeSpan.Zero, duration, "Unknown", "speaker identity validation sample")],
|
||||||
options.Diarization,
|
options.Diarization,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ public enum MeetingTaskbarAction
|
|||||||
OpenSubmenu,
|
OpenSubmenu,
|
||||||
StartRecording,
|
StartRecording,
|
||||||
StopRecording,
|
StopRecording,
|
||||||
|
PauseTranscription,
|
||||||
|
UnpauseTranscription,
|
||||||
AbortRecording,
|
AbortRecording,
|
||||||
SwitchProfile,
|
SwitchProfile,
|
||||||
SelectMicrophone,
|
SelectMicrophone,
|
||||||
@@ -51,6 +53,15 @@ public static class MeetingTaskbarMenuBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
var secondaryControls = new List<MeetingTaskbarMenuItem>();
|
var secondaryControls = new List<MeetingTaskbarMenuItem>();
|
||||||
|
if (status.IsRecording)
|
||||||
|
{
|
||||||
|
secondaryControls.Add(new MeetingTaskbarMenuItem(
|
||||||
|
status.IsPaused ? "Unpause transcription" : "Pause transcription",
|
||||||
|
status.IsPaused
|
||||||
|
? MeetingTaskbarAction.UnpauseTranscription
|
||||||
|
: MeetingTaskbarAction.PauseTranscription));
|
||||||
|
}
|
||||||
|
|
||||||
if (microphones is { Count: > 0 })
|
if (microphones is { Count: > 0 })
|
||||||
{
|
{
|
||||||
secondaryControls.Add(BuildMicrophoneMenu(microphones, currentMicrophoneDeviceId));
|
secondaryControls.Add(BuildMicrophoneMenu(microphones, currentMicrophoneDeviceId));
|
||||||
|
|||||||
@@ -249,6 +249,12 @@ public sealed class UnoTaskbarIconService : IHostedService, IDisposable
|
|||||||
case MeetingTaskbarAction.StopRecording:
|
case MeetingTaskbarAction.StopRecording:
|
||||||
await coordinator.StopAsync(CancellationToken.None);
|
await coordinator.StopAsync(CancellationToken.None);
|
||||||
break;
|
break;
|
||||||
|
case MeetingTaskbarAction.PauseTranscription:
|
||||||
|
await coordinator.SetTranscriptionPausedAsync(true, CancellationToken.None);
|
||||||
|
break;
|
||||||
|
case MeetingTaskbarAction.UnpauseTranscription:
|
||||||
|
await coordinator.SetTranscriptionPausedAsync(false, CancellationToken.None);
|
||||||
|
break;
|
||||||
case MeetingTaskbarAction.AbortRecording:
|
case MeetingTaskbarAction.AbortRecording:
|
||||||
await coordinator.AbortAsync(CancellationToken.None);
|
await coordinator.AbortAsync(CancellationToken.None);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -96,14 +96,18 @@ public sealed class PyannoteDiarizationWarmupHostedService : IHostedService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private IEnumerable<PyannoteDiarizationOptions> GetEnabledDiarizationOptions()
|
private IEnumerable<PyannoteRuntimeOptions> GetEnabledDiarizationOptions()
|
||||||
{
|
{
|
||||||
return launchProfiles.GetProfiles()
|
var transcriptionRuntimes = launchProfiles.GetProfiles()
|
||||||
.SelectMany(profile => GetEnabledDiarizationOptions(profile.Options))
|
.SelectMany(profile => GetEnabledTranscriptionDiarizationOptions(profile.Options));
|
||||||
|
var speakerValidationRuntimes = GetEnabledSpeakerValidationDiarizationOptions(
|
||||||
|
launchProfiles.GetRequiredProfile(null).Options);
|
||||||
|
return transcriptionRuntimes
|
||||||
|
.Concat(speakerValidationRuntimes)
|
||||||
.DistinctBy(CreateWarmUpKey);
|
.DistinctBy(CreateWarmUpKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<PyannoteDiarizationOptions> GetEnabledDiarizationOptions(
|
private static IEnumerable<PyannoteRuntimeOptions> GetEnabledTranscriptionDiarizationOptions(
|
||||||
MeetingAssistantOptions options)
|
MeetingAssistantOptions options)
|
||||||
{
|
{
|
||||||
if (options.Recording.TranscriptionProvider.Equals("whisper-local", StringComparison.OrdinalIgnoreCase) &&
|
if (options.Recording.TranscriptionProvider.Equals("whisper-local", StringComparison.OrdinalIgnoreCase) &&
|
||||||
@@ -111,15 +115,18 @@ public sealed class PyannoteDiarizationWarmupHostedService : IHostedService
|
|||||||
{
|
{
|
||||||
yield return options.WhisperLocal.Diarization;
|
yield return options.WhisperLocal.Diarization;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (options.SpeakerIdentification.PyannoteValidation.Enabled &&
|
private static IEnumerable<PyannoteRuntimeOptions> GetEnabledSpeakerValidationDiarizationOptions(
|
||||||
options.SpeakerIdentification.PyannoteValidation.Diarization.Enabled)
|
MeetingAssistantOptions options)
|
||||||
|
{
|
||||||
|
if (options.SpeakerIdentification.PyannoteValidation.Enabled)
|
||||||
{
|
{
|
||||||
yield return options.SpeakerIdentification.PyannoteValidation.Diarization;
|
yield return options.SpeakerIdentification.PyannoteValidation.Diarization;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string CreateWarmUpKey(PyannoteDiarizationOptions diarization)
|
private static string CreateWarmUpKey(PyannoteRuntimeOptions diarization)
|
||||||
{
|
{
|
||||||
return string.Join(
|
return string.Join(
|
||||||
'\u001f',
|
'\u001f',
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ public sealed class PyannoteTranscriptFinalizer
|
|||||||
SpeechRecognitionPipelineOptions pipelineOptions,
|
SpeechRecognitionPipelineOptions pipelineOptions,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await FinalizeAsync(
|
if (!options.WhisperLocal.Diarization.Enabled)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await FinalizeEnabledAsync(
|
||||||
audioPath,
|
audioPath,
|
||||||
liveSegments,
|
liveSegments,
|
||||||
options.WhisperLocal.Diarization,
|
options.WhisperLocal.Diarization,
|
||||||
@@ -36,14 +41,14 @@ public sealed class PyannoteTranscriptFinalizer
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<TranscriptionSegment>> FinalizeAsync(
|
internal async Task<IReadOnlyList<TranscriptionSegment>> FinalizeEnabledAsync(
|
||||||
string audioPath,
|
string audioPath,
|
||||||
IReadOnlyList<TranscriptionSegment> liveSegments,
|
IReadOnlyList<TranscriptionSegment> liveSegments,
|
||||||
PyannoteDiarizationOptions diarization,
|
PyannoteRuntimeOptions diarization,
|
||||||
SpeechRecognitionPipelineOptions pipelineOptions,
|
SpeechRecognitionPipelineOptions pipelineOptions,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!diarization.Enabled || liveSegments.Count == 0)
|
if (liveSegments.Count == 0)
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -83,14 +88,9 @@ public sealed class PyannoteTranscriptFinalizer
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task WarmUpAsync(
|
public async Task WarmUpAsync(
|
||||||
PyannoteDiarizationOptions diarization,
|
PyannoteRuntimeOptions diarization,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!diarization.Enabled)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var token = ResolveToken(diarization);
|
var token = ResolveToken(diarization);
|
||||||
if (string.IsNullOrWhiteSpace(token))
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
{
|
{
|
||||||
@@ -137,7 +137,7 @@ public sealed class PyannoteTranscriptFinalizer
|
|||||||
private async Task<CommandResult> RunDiarizationAsync(
|
private async Task<CommandResult> RunDiarizationAsync(
|
||||||
string fullAudioPath,
|
string fullAudioPath,
|
||||||
string token,
|
string token,
|
||||||
PyannoteDiarizationOptions diarization,
|
PyannoteRuntimeOptions diarization,
|
||||||
SpeechRecognitionPipelineOptions pipelineOptions,
|
SpeechRecognitionPipelineOptions pipelineOptions,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -171,7 +171,7 @@ public sealed class PyannoteTranscriptFinalizer
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task EnsureDockerImageAsync(
|
private async Task EnsureDockerImageAsync(
|
||||||
PyannoteDiarizationOptions diarization,
|
PyannoteRuntimeOptions diarization,
|
||||||
string modelsFolder,
|
string modelsFolder,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -198,7 +198,7 @@ public sealed class PyannoteTranscriptFinalizer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildDockerfile(PyannoteDiarizationOptions diarization)
|
private static string BuildDockerfile(PyannoteRuntimeOptions diarization)
|
||||||
{
|
{
|
||||||
return
|
return
|
||||||
$"FROM {diarization.BaseImage}\n"
|
$"FROM {diarization.BaseImage}\n"
|
||||||
@@ -211,7 +211,7 @@ public sealed class PyannoteTranscriptFinalizer
|
|||||||
}
|
}
|
||||||
|
|
||||||
private string[] BuildDockerArguments(
|
private string[] BuildDockerArguments(
|
||||||
PyannoteDiarizationOptions diarization,
|
PyannoteRuntimeOptions diarization,
|
||||||
string fullAudioPath,
|
string fullAudioPath,
|
||||||
string modelsFolder,
|
string modelsFolder,
|
||||||
SpeechRecognitionPipelineOptions pipelineOptions)
|
SpeechRecognitionPipelineOptions pipelineOptions)
|
||||||
@@ -242,7 +242,7 @@ public sealed class PyannoteTranscriptFinalizer
|
|||||||
}
|
}
|
||||||
|
|
||||||
private string[] BuildWarmUpDockerArguments(
|
private string[] BuildWarmUpDockerArguments(
|
||||||
PyannoteDiarizationOptions diarization,
|
PyannoteRuntimeOptions diarization,
|
||||||
string modelsFolder)
|
string modelsFolder)
|
||||||
{
|
{
|
||||||
return
|
return
|
||||||
@@ -269,7 +269,7 @@ public sealed class PyannoteTranscriptFinalizer
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildPythonCommand(
|
private static string BuildPythonCommand(
|
||||||
PyannoteDiarizationOptions diarization,
|
PyannoteRuntimeOptions diarization,
|
||||||
SpeechRecognitionPipelineOptions pipelineOptions)
|
SpeechRecognitionPipelineOptions pipelineOptions)
|
||||||
{
|
{
|
||||||
var model = diarization.Model;
|
var model = diarization.Model;
|
||||||
@@ -296,7 +296,7 @@ public sealed class PyannoteTranscriptFinalizer
|
|||||||
+ "python /tmp/meeting_assistant_pyannote.py";
|
+ "python /tmp/meeting_assistant_pyannote.py";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildWarmUpPythonCommand(PyannoteDiarizationOptions diarization)
|
private static string BuildWarmUpPythonCommand(PyannoteRuntimeOptions diarization)
|
||||||
{
|
{
|
||||||
var model = diarization.Model;
|
var model = diarization.Model;
|
||||||
return
|
return
|
||||||
@@ -475,7 +475,7 @@ public sealed class PyannoteTranscriptFinalizer
|
|||||||
return turns;
|
return turns;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ResolveToken(PyannoteDiarizationOptions options)
|
private static string ResolveToken(PyannoteRuntimeOptions options)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(options.Token))
|
if (!string.IsNullOrWhiteSpace(options.Token))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"00:10:00"
|
"00:10:00"
|
||||||
],
|
],
|
||||||
"AutoStopAfter": "00:30:00",
|
"AutoStopAfter": "00:30:00",
|
||||||
|
"MaximumPauseDuration": "04:00:00",
|
||||||
"InferredEndPadding": "00:01:00",
|
"InferredEndPadding": "00:01:00",
|
||||||
"CheckInterval": "00:00:15"
|
"CheckInterval": "00:00:15"
|
||||||
}
|
}
|
||||||
@@ -126,11 +127,10 @@
|
|||||||
"MergeRecentIdentityAge": "14.00:00:00",
|
"MergeRecentIdentityAge": "14.00:00:00",
|
||||||
"MatchTimeout": "00:03:00",
|
"MatchTimeout": "00:03:00",
|
||||||
"PyannoteValidation": {
|
"PyannoteValidation": {
|
||||||
"Enabled": true,
|
"Enabled": false,
|
||||||
"MinimumSingleSpeakerCoverage": 0.9,
|
"MinimumSingleSpeakerCoverage": 0.9,
|
||||||
"MinimumMatchingKnownSnippetRatio": 0.5,
|
"MinimumMatchingKnownSnippetRatio": 0.5,
|
||||||
"Diarization": {
|
"Diarization": {
|
||||||
"Enabled": false,
|
|
||||||
"DockerCommand": "docker",
|
"DockerCommand": "docker",
|
||||||
"BaseImage": "python:3.11-slim",
|
"BaseImage": "python:3.11-slim",
|
||||||
"Image": "meeting-assistant-pyannote:local",
|
"Image": "meeting-assistant-pyannote:local",
|
||||||
|
|||||||
@@ -50,12 +50,14 @@ The default global controls are:
|
|||||||
- `Ctrl+Alt+Z`: abort the active run and delete its artifacts.
|
- `Ctrl+Alt+Z`: abort the active run and delete its artifacts.
|
||||||
- `Ctrl+Alt+S`: capture the active window into the meeting context.
|
- `Ctrl+Alt+S`: capture the active window into the meeting context.
|
||||||
|
|
||||||
The tray presents `Finish meeting` as the primary action during capture; microphone selection, cancel/discard, and profile switching remain separate controls. `Exit` is always available and requires confirmation while any meeting is recording or processing.
|
The tray presents `Finish meeting` as the primary action during capture; pause/unpause transcription, microphone selection, cancel/discard, and profile switching remain separate controls. A paused meeting stays active and can still be finished or canceled normally. `Exit` is always available and requires confirmation while any meeting is recording or processing.
|
||||||
|
|
||||||
The loopback HTTP surface exposes health and recording status, recording start/stop/toggle/abort operations, profile-specific equivalents, and diagnostics/retry operations. It has no application authentication. Some generated retry links use `GET` while starting work, so port `5090` must remain a trusted loopback-only control surface.
|
The loopback HTTP surface exposes health and recording status, recording start/stop/toggle/abort operations, profile-specific equivalents, and diagnostics/retry operations. It has no application authentication. Some generated retry links use `GET` while starting work, so port `5090` must remain a trusted loopback-only control surface.
|
||||||
|
|
||||||
Stopping capture lets buffered transcription, speaker work, meeting-note image OCR, screenshot OCR, and summary generation finish. Another meeting can start while an older stopped run finalizes; each run retains isolated options and artifact paths.
|
Stopping capture lets buffered transcription, speaker work, meeting-note image OCR, screenshot OCR, and summary generation finish. Another meeting can start while an older stopped run finalizes; each run retains isolated options and artifact paths.
|
||||||
|
|
||||||
|
Pausing transcription keeps the active recognition pipeline and meeting session alive, including Azure conversation transcription and the run's speaker context. Captured audio is replaced with equal-length silence before it reaches the temporary WAV or transcription backend, so real audio from the paused interval is discarded while provider continuity and meeting-relative timing are preserved. Normal transcript-inactivity notifications and auto-stop are suppressed during pause, while a separate four-hour maximum continuous pause prevents a forgotten paused meeting from running indefinitely. The recording status response exposes pause state as `isPaused`.
|
||||||
|
|
||||||
During an active run, microphone creation failures and disconnects are retried every second with fresh endpoint selection. The meeting and system-loopback capture stay active, with microphone silence mixed in until capture resumes. This recovery does not cover a failed system-loopback source.
|
During an active run, microphone creation failures and disconnects are retried every second with fresh endpoint selection. The meeting and system-loopback capture stay active, with microphone silence mixed in until capture resumes. This recovery does not cover a failed system-loopback source.
|
||||||
|
|
||||||
Outlook enrichment selects an unambiguous current or imminent appointment. A scheduled prompt shown during an active recording can apply that exact appointment's title, eligible attendees, agenda, and scheduled end without interrupting capture; explicit prompt metadata wins over a slower background lookup.
|
Outlook enrichment selects an unambiguous current or imminent appointment. A scheduled prompt shown during an active recording can apply that exact appointment's title, eligible attendees, agenda, and scheduled end without interrupting capture; explicit prompt metadata wins over a slower background lookup.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ This example is abbreviated so the most common shape is readable. The checked-in
|
|||||||
"FirstPromptAfter": "00:02:00",
|
"FirstPromptAfter": "00:02:00",
|
||||||
"ReminderPromptAfter": [ "00:05:00", "00:10:00" ],
|
"ReminderPromptAfter": [ "00:05:00", "00:10:00" ],
|
||||||
"AutoStopAfter": "00:30:00",
|
"AutoStopAfter": "00:30:00",
|
||||||
|
"MaximumPauseDuration": "04:00:00",
|
||||||
"InferredEndPadding": "00:01:00",
|
"InferredEndPadding": "00:01:00",
|
||||||
"CheckInterval": "00:00:15"
|
"CheckInterval": "00:00:15"
|
||||||
}
|
}
|
||||||
@@ -131,20 +132,23 @@ The default profile is always named `default`. Non-default profile hotkeys are r
|
|||||||
|
|
||||||
During recording, Meeting Assistant captures microphone and system loopback separately, buffers both streams to align samples, cleans the microphone stream through a local adaptive echo canceller using loopback as the far-end reference, then mixes the cleaned microphone and system streams into the normal 16 kHz mono PCM chunks. If one source is quiet beyond the alignment timeout, the available source is mixed with synthetic silence so microphone-only speech and system-only playback keep flowing to transcription.
|
During recording, Meeting Assistant captures microphone and system loopback separately, buffers both streams to align samples, cleans the microphone stream through a local adaptive echo canceller using loopback as the far-end reference, then mixes the cleaned microphone and system streams into the normal 16 kHz mono PCM chunks. If one source is quiet beyond the alignment timeout, the available source is mixed with synthetic silence so microphone-only speech and system-only playback keep flowing to transcription.
|
||||||
|
|
||||||
|
The tray's fine-grained controls expose `Pause transcription` while a meeting is active and `Unpause transcription` while it is paused. Pause does not stop audio devices, finish the meeting, replace the speech-recognition pipeline, or reset run-local speaker mappings and collected samples. Instead, each captured mixed chunk is replaced with equal-length PCM silence before it reaches the temporary WAV, live speaker buffer, or configured transcription provider. This discards real audio from the paused interval while preserving the provider session and meeting-relative timing. In particular, Azure keeps the same active `ConversationTranscriber` and push stream; pause therefore does not suspend Azure connection time or billing. `Finish meeting` and cancel/discard remain available while paused, and `/recording/status` reports the state in `isPaused`.
|
||||||
|
|
||||||
On Windows, `Recording:MicrophoneDeviceId` can pin capture to a specific active microphone endpoint id. Leave it blank to follow the Windows default capture endpoint. The tray icon menu also exposes `Microphone`, listing active microphone endpoints with the effective endpoint checked. Selecting a microphone there overrides the configured/default microphone for later recording starts until another microphone is selected or the process exits.
|
On Windows, `Recording:MicrophoneDeviceId` can pin capture to a specific active microphone endpoint id. Leave it blank to follow the Windows default capture endpoint. The tray icon menu also exposes `Microphone`, listing active microphone endpoints with the effective endpoint checked. Selecting a microphone there overrides the configured/default microphone for later recording starts until another microphone is selected or the process exits.
|
||||||
|
|
||||||
`Recording:MicrophoneMixGain` and `Recording:SystemAudioMixGain` are applied during the final mix and default to `1`. `Recording:TemporaryRecordingsFolder` controls where the temporary mixed WAV is written while the run is active. Temporary WAV files are deleted after the run completes, and stale temporary recordings from interrupted runs are deleted when the application starts. If an Azure Speech meeting cannot drain transcription before `Recording:StopProcessingTimeout`, Meeting Assistant keeps the WAV and writes a durable backlog item under `TemporaryRecordingsFolder\offline-transcription-backlog`. The background backlog worker retries those queued meetings, replays each WAV through a fresh speech pipeline, rewrites the original transcript, completes meeting metadata and summary generation, then removes the backlog item and WAV.
|
`Recording:MicrophoneMixGain` and `Recording:SystemAudioMixGain` are applied during the final mix and default to `1`. `Recording:TemporaryRecordingsFolder` controls where the temporary mixed WAV is written while the run is active. Temporary WAV files are deleted after the run completes, and stale temporary recordings from interrupted runs are deleted when the application starts. If an Azure Speech meeting cannot drain transcription before `Recording:StopProcessingTimeout`, Meeting Assistant keeps the WAV and writes a durable backlog item under `TemporaryRecordingsFolder\offline-transcription-backlog`. The background backlog worker retries those queued meetings, replays each WAV through a fresh speech pipeline, rewrites the original transcript, completes meeting metadata and summary generation, then removes the backlog item and WAV.
|
||||||
|
|
||||||
`Recording:MaxMetadataAttendeeImportCount` limits how many attendees Outlook metadata enrichment imports into meeting-note frontmatter. The default is `30`; when an appointment has more attendees than that, Meeting Assistant still imports title, agenda, and scheduled end time, but leaves attendees empty because large invites are usually presentation-style meetings.
|
`Recording:MaxMetadataAttendeeImportCount` limits how many attendees Outlook metadata enrichment imports into meeting-note frontmatter. The default is `30`; when an appointment has more attendees than that, Meeting Assistant still imports title, agenda, and scheduled end time, but leaves attendees empty because large invites are usually presentation-style meetings.
|
||||||
|
|
||||||
`Recording:InactivitySafeguard` watches active recordings for long periods without transcript text. The timer starts at meeting start and resets whenever a live transcript segment with text arrives. By default the app asks whether to stop after 2, 5, and 10 minutes of inactivity through native Windows app notifications with action buttons, requests reminder-style toast behavior, keeps each stop reminder actionable for 1 minute, and automatically stops normally after 30 minutes. Ignoring a notification does not block later checks or auto-stop. Safeguard stops are not aborts: transcription drain, speaker processing, screenshots, and summary generation continue through the normal stop flow. When the safeguard stops a run, the meeting end time is inferred as the last transcript segment timestamp plus `InferredEndPadding`; if no transcript text arrived, it uses meeting start plus the same padding.
|
`Recording:InactivitySafeguard` watches active recordings for long periods without transcript text. The timer starts at meeting start and resets whenever a live transcript segment with text is written. Writing new transcript text also dismisses every outstanding inactivity notification and invalidates its actions. By default the app asks whether to stop after 2, 5, and 10 minutes of inactivity through native Windows app notifications with Yes, No, and `Pause transcription` actions, requests reminder-style toast behavior, keeps each stop reminder actionable for 1 minute, and automatically stops normally after 30 minutes. While transcription is intentionally paused, those prompts and the ordinary transcript-inactivity stop are fully suspended. A separate `MaximumPauseDuration`, defaulting to 4 hours, normally stops a meeting that remains continuously paused without showing an inactivity notification; it remains active when the ordinary inactivity safeguard is disabled, while a non-positive value disables the paused-session cutoff. Unpausing clears the continuous-pause timer and restarts transcript-inactivity timing from that moment. Ignoring a notification does not block later checks or auto-stop. Safeguard stops are not aborts: transcription drain, speaker processing, screenshots, and summary generation continue through the normal stop flow. When transcript inactivity stops a run, the meeting end time is inferred as the last transcript segment timestamp plus `InferredEndPadding`; if no transcript text arrived, it uses meeting start plus the same padding.
|
||||||
|
|
||||||
| Setting | Purpose |
|
| Setting | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `Enabled` | Enables the inactivity safeguard for active recordings. |
|
| `Enabled` | Enables transcript-inactivity prompts and `AutoStopAfter`; `MaximumPauseDuration` remains independent. |
|
||||||
| `FirstPromptAfter` | First transcript-inactivity duration before showing the stop prompt. |
|
| `FirstPromptAfter` | First transcript-inactivity duration before showing the stop prompt. |
|
||||||
| `ReminderPromptAfter` | Additional transcript-inactivity durations before showing another stop prompt. |
|
| `ReminderPromptAfter` | Additional transcript-inactivity durations before showing another stop prompt. |
|
||||||
| `AutoStopAfter` | Transcript-inactivity duration after which Meeting Assistant stops the recording normally without prompting again. |
|
| `AutoStopAfter` | Transcript-inactivity duration after which Meeting Assistant stops the recording normally without prompting again. |
|
||||||
|
| `MaximumPauseDuration` | Maximum continuous transcription pause before Meeting Assistant stops the meeting normally without an inactivity notification; defaults to 4 hours, and a non-positive value disables it. |
|
||||||
| `InferredEndPadding` | Padding added to the last transcript timestamp, or meeting start when no transcript arrived, for safeguard-triggered end times. |
|
| `InferredEndPadding` | Padding added to the last transcript timestamp, or meeting start when no transcript arrived, for safeguard-triggered end times. |
|
||||||
| `CheckInterval` | Polling interval for checking the active recording inactivity state. |
|
| `CheckInterval` | Polling interval for checking the active recording inactivity state. |
|
||||||
|
|
||||||
@@ -219,11 +223,11 @@ When `WhisperLocal:Diarization:Enabled` is true, the final post-processing pass
|
|||||||
|
|
||||||
Active pyannote runtimes are warmed up on application start so image setup and model download do not wait for the first diarization request.
|
Active pyannote runtimes are warmed up on application start so image setup and model download do not wait for the first diarization request.
|
||||||
|
|
||||||
Pyannote diarization settings are shared by local Whisper finalization and speaker-identification validation:
|
Pyannote runtime settings are shared by local Whisper finalization and speaker-identification validation. `WhisperLocal:Diarization:Enabled` controls the optional Whisper finalization pass; speaker-identification validation instead uses its single outer `SpeakerIdentification:PyannoteValidation:Enabled` switch.
|
||||||
|
|
||||||
| Setting | Purpose |
|
| Setting | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `Enabled` | Enables the pyannote-backed pass. |
|
| `Enabled` | Available under `WhisperLocal:Diarization` to enable the pyannote-backed Whisper finalization pass. It is not part of the nested speaker-validation runtime settings. |
|
||||||
| `DockerCommand` | Docker executable name or path. |
|
| `DockerCommand` | Docker executable name or path. |
|
||||||
| `BaseImage` | Python base image used when building the local pyannote image. |
|
| `BaseImage` | Python base image used when building the local pyannote image. |
|
||||||
| `Image` | Local pyannote Docker image tag. |
|
| `Image` | Local pyannote Docker image tag. |
|
||||||
@@ -285,10 +289,11 @@ Speaker identity matching keeps candidate samples only after a diarized speaker
|
|||||||
|
|
||||||
`SpeakerIdentification:AzureSpeech` is an advanced nested override for speaker identity matching. It uses the same shape as `AzureSpeech` and lets identity matching use different Azure language, endpoint, or key settings than live transcription when needed. If it is left unset, the normal Azure Speech settings remain the practical default.
|
`SpeakerIdentification:AzureSpeech` is an advanced nested override for speaker identity matching. It uses the same shape as `AzureSpeech` and lets identity matching use different Azure language, endpoint, or key settings than live transcription when needed. If it is left unset, the normal Azure Speech settings remain the practical default.
|
||||||
|
|
||||||
`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 defaults the validation command timeout to 1 hour because local model setup can take substantial time.
|
`SpeakerIdentification:PyannoteValidation` is an optional application-level secondary confidence layer and defaults to disabled. Its outer `Enabled` setting is the only validation toggle; the nested `Diarization` block contains runtime settings but no second enable switch. Launch profiles do not override speaker validation or its runtime settings. 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 defaults the validation command timeout to 1 hour because local model setup can take substantial time.
|
||||||
|
|
||||||
| Setting | Purpose |
|
| Setting | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
| `Enabled` | Sole switch for speaker-identity pyannote validation and its startup warm-up. |
|
||||||
| `MinimumSingleSpeakerCoverage` | Required fraction of the tested sample that pyannote must attribute to a single speaker. |
|
| `MinimumSingleSpeakerCoverage` | Required fraction of the tested sample that pyannote must attribute to a single speaker. |
|
||||||
| `MinimumMatchingKnownSnippetRatio` | Required pyannote agreement ratio between the new sample and known snippets for an accepted identity. |
|
| `MinimumMatchingKnownSnippetRatio` | Required pyannote agreement ratio between the new sample and known snippets for an accepted identity. |
|
||||||
| `Diarization` | Nested pyannote settings used for this validation pass. |
|
| `Diarization` | Nested pyannote settings used for this validation pass. |
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-09-02
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Meeting Assistant currently has one active `RecordingRun` that owns its speech-recognition pipeline, transcript session, temporary WAV, speaker mappings, and live speaker samples. Mixed audio chunks are written to both the WAV and the active pipeline. Transcript inactivity is tracked separately, and Windows inactivity toasts share a notification group but expose only stop/continue callbacks.
|
||||||
|
|
||||||
|
The Azure Speech SDK `ConversationTranscriber` has start and stop operations but no pause operation. Stopping ends the ongoing real-time recognition session, and recreating or restarting recognition can reset backend speaker IDs. Continuous recognition does support an open push stream containing silence, so the provider session can remain alive without receiving the meeting's actual audio during a pause.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Pause the transcription of an active meeting without finishing its recording run.
|
||||||
|
- Prevent actual paused audio from reaching either durable temporary audio or a remote/local transcription backend.
|
||||||
|
- Preserve the same recognition pipeline, Azure conversation transcriber, transcript session, speaker mappings, collected speaker samples, and artifacts.
|
||||||
|
- Keep normal finish, cancel/discard, and profile-switch controls usable while paused.
|
||||||
|
- Treat intentional pause as distinct from inactivity and dismiss notifications that become obsolete when transcript text resumes.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Suspend microphone or loopback device capture at the operating-system layer.
|
||||||
|
- Disconnect or stop the configured speech-recognition backend while paused.
|
||||||
|
- Reduce Azure connection time or billing during a pause.
|
||||||
|
- Persist pause state across process restarts or stopped meeting backlog replay.
|
||||||
|
- Add a pause hotkey.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Preserve the pipeline by substituting silence at the recording-run boundary
|
||||||
|
|
||||||
|
For each mixed audio chunk captured while paused, the coordinator will create an equal-length zeroed PCM chunk and route that chunk to the temporary WAV, live speaker audio buffer, and current speech-recognition pipeline. Real captured samples are discarded at that boundary.
|
||||||
|
|
||||||
|
This keeps audio duration and transcript timestamps aligned while keeping the Azure push stream active. Dropping chunks entirely was rejected because a sufficiently long input gap can stop or reconnect a provider session. Calling `StopTranscribingAsync` was rejected because it terminates the ongoing Azure operation and cannot guarantee stable backend speaker IDs when started again.
|
||||||
|
|
||||||
|
### Keep pause state on the active recording run
|
||||||
|
|
||||||
|
The run will expose a thread-safe paused flag and coordinator pause/unpause operations. `RecordingStatus` will expose the flag so tray rendering and the loopback status endpoint observe the same state. Pause requests outside an active capture will be harmless, and normal stop/abort paths will remain authoritative.
|
||||||
|
|
||||||
|
The paused flag will not become another post-recording process state: a paused run is still an active recording and continues to offer Finish and Cancel.
|
||||||
|
|
||||||
|
### Separate transcript inactivity from a maximum continuous pause
|
||||||
|
|
||||||
|
The inactivity safeguard will skip transcript-inactivity prompting and its ordinary auto-stop while the run is paused. Pausing will dismiss all outstanding inactivity notifications. A separate `MaximumPauseDuration`, defaulting to four hours, will normally stop a run that remains continuously paused for that duration without showing inactivity notifications. Unpausing will clear the paused-duration timer, set a new transcript-inactivity baseline, and advance the activity version so prompt thresholds start over rather than firing immediately.
|
||||||
|
|
||||||
|
### Make notification lifecycle part of the prompt-service contract
|
||||||
|
|
||||||
|
`IMeetingInactivityPromptService` will gain an operation to dismiss all active inactivity prompts. The Windows implementation will remove the notification group from Action Center and clear its pending callback registry. The no-op and test implementations will implement the same public contract.
|
||||||
|
|
||||||
|
The coordinator will call dismissal only after a non-empty live segment has been durably appended, matching the user's observable meaning of a new transcription being written. Notification callbacks will verify that their originating run is still current before changing recording state.
|
||||||
|
|
||||||
|
### Model pause as one toggling tray action
|
||||||
|
|
||||||
|
The active-recording tray menu will show `Pause transcription` while running and `Unpause transcription` while paused. It will stay in the fine-grained controls section below the dedicated `Finish meeting` section. The inactivity toast will add `Pause transcription` alongside the existing Yes/No stop controls.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Azure may still reconnect for unrelated transport failures during a long pause** → Reuse the existing Azure reconnect behavior; the application preserves meeting artifacts and resets backend speaker assumptions only if Azure actually creates a new SDK session.
|
||||||
|
- **Silence consumes provider connection time and temporary WAV space** → Accept this to preserve provider/session continuity and timestamp alignment; document that pause is not a cost-suspension mechanism.
|
||||||
|
- **A forgotten pause could otherwise keep capture and provider resources alive indefinitely** → Apply the separate four-hour maximum continuous pause while keeping the shorter transcript-inactivity prompts fully suppressed.
|
||||||
|
- **A transcript result already buffered by the provider can arrive just after pause** → Keep and write it because it represents audio submitted before the pause boundary.
|
||||||
|
- **A stale notification action could affect a later meeting** → Clear pending callbacks on dismissal and verify the originating run before applying stop or pause.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
No data or configuration migration is required. Deploy the updated executable normally. Rollback restores the previous controls; existing meeting artifacts and speaker identities remain compatible.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Transcript-inactivity notifications remain visible after transcription has resumed, and there is no way to intentionally suspend transcription during a break without ending the meeting and losing the active recognition session. Meeting Assistant should distinguish intentional pauses from accidental inactivity while preserving the meeting run and speaker context.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Dismiss every outstanding transcript-inactivity notification as soon as a new non-empty transcript segment is written.
|
||||||
|
- Allow an active meeting transcription to be paused and unpaused without ending the recording run, replacing captured audio with silence so paused audio is neither persisted nor sent to the transcription backend.
|
||||||
|
- Keep the active speech-recognition pipeline, including the Azure conversation transcriber session, run-local speaker mappings, artifacts, and normal finish/cancel controls intact across a pause.
|
||||||
|
- Suspend transcript-inactivity prompts and the ordinary inactivity stop while paused, but normally stop a meeting that remains continuously paused for four hours by default; restart inactivity timing when transcription is unpaused.
|
||||||
|
- Add `Pause transcription` / `Unpause transcription` to the active-recording tray menu and add `Pause transcription` to transcript-inactivity notifications.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `meeting-recording`: Add pause state and controls, intentional-pause inactivity behavior, and automatic dismissal of obsolete inactivity notifications.
|
||||||
|
- `meeting-transcription`: Preserve one streaming recognition session and speaker context while paused audio is replaced with silence.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affects recording status and coordinator behavior, inactivity prompt abstractions and the Windows toast implementation, tray menu modeling/rendering, and loopback status output.
|
||||||
|
- Affects the audio routed to temporary recordings, live speaker sampling, and configured streaming speech-recognition providers during an intentional pause.
|
||||||
|
- Adds one paused-session safety-timeout setting under the existing inactivity safeguard, adds no external dependency, and does not change normal finish, cancel/discard, profile-switch, or post-recording processing behavior.
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Active transcription can be paused without ending the meeting
|
||||||
|
Meeting Assistant SHALL allow transcription for an active meeting recording to be paused and unpaused without stopping the meeting run.
|
||||||
|
|
||||||
|
While transcription is paused, Meeting Assistant SHALL keep the recording status active, SHALL preserve the meeting artifacts and run-local speaker context, and SHALL keep the normal finish and cancel/discard controls available.
|
||||||
|
|
||||||
|
When transcription is unpaused, Meeting Assistant SHALL resume transcribing newly captured audio through the existing meeting run.
|
||||||
|
|
||||||
|
Pausing or unpausing when no active recording exists SHALL leave recording state unchanged.
|
||||||
|
|
||||||
|
#### Scenario: Active transcription is paused and unpaused
|
||||||
|
- **GIVEN** a meeting is actively recording
|
||||||
|
- **WHEN** the user pauses transcription
|
||||||
|
- **THEN** the meeting remains active and reports that transcription is paused
|
||||||
|
- **AND** keeps its existing artifacts and speaker context
|
||||||
|
- **WHEN** the user unpauses transcription
|
||||||
|
- **THEN** newly captured audio is transcribed in the same meeting run
|
||||||
|
|
||||||
|
#### Scenario: Paused meeting can still be finished
|
||||||
|
- **GIVEN** an active meeting transcription is paused
|
||||||
|
- **WHEN** the user finishes the meeting
|
||||||
|
- **THEN** Meeting Assistant follows the normal stop, transcription drain, speaker processing, and summary flow
|
||||||
|
|
||||||
|
#### Scenario: Pause request while idle is harmless
|
||||||
|
- **GIVEN** no meeting recording is active
|
||||||
|
- **WHEN** transcription pause is requested
|
||||||
|
- **THEN** Meeting Assistant remains idle
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Windows taskbar icon controls recording
|
||||||
|
Meeting Assistant SHALL show a Windows taskbar notification icon when running on Windows.
|
||||||
|
|
||||||
|
The taskbar icon SHALL indicate whether the newest meeting process is idle, actively recording, or post-recording processing/summarizing.
|
||||||
|
|
||||||
|
When a new meeting is actively recording while an older stopped meeting is still transcribing, recognizing speakers, or summarizing, the taskbar icon SHALL show the new active recording state.
|
||||||
|
|
||||||
|
The taskbar icon right-click menu SHALL expose recording controls based on the current state and configured launch profiles.
|
||||||
|
|
||||||
|
The taskbar icon right-click menu SHALL expose an Exit action in every recording state.
|
||||||
|
|
||||||
|
When Meeting Assistant is idle or only processing older stopped meetings, the menu SHALL allow starting a meeting recording for each configured launch profile.
|
||||||
|
|
||||||
|
When a meeting is actively recording, the menu SHALL allow stopping the recording and continuing transcription/summary generation.
|
||||||
|
|
||||||
|
During an active recording, the normal stop action SHALL be labeled `Finish meeting` and SHALL be the only action in a dedicated menu section immediately below the `Open agent` section.
|
||||||
|
|
||||||
|
During an active recording, pause/unpause, microphone selection, cancel/discard, and profile-switch actions SHALL appear in a separate fine-grained controls section below `Finish meeting`.
|
||||||
|
|
||||||
|
When a meeting is actively recording and transcription is running, the menu SHALL expose `Pause transcription`.
|
||||||
|
|
||||||
|
When a meeting is actively recording and transcription is paused, the menu SHALL expose `Unpause transcription` and SHALL continue exposing `Finish meeting`.
|
||||||
|
|
||||||
|
When a meeting is actively recording, the menu SHALL allow canceling the recording and discarding that run's artifacts.
|
||||||
|
|
||||||
|
When a meeting is actively recording, the menu SHALL allow switching to each configured launch profile other than the current active profile.
|
||||||
|
|
||||||
|
Selecting Exit while Meeting Assistant is idle SHALL stop the application without an additional confirmation prompt.
|
||||||
|
|
||||||
|
Selecting Exit while Meeting Assistant is recording, transcribing, recognizing speakers, or summarizing SHALL show a confirmation dialog before stopping the application.
|
||||||
|
|
||||||
|
#### Scenario: Idle tray menu can start configured profiles
|
||||||
|
- **GIVEN** launch profiles `default` and `english` are configured
|
||||||
|
- **AND** no meeting recording is active
|
||||||
|
- **WHEN** the taskbar menu is opened
|
||||||
|
- **THEN** it offers start recording actions for `default` and `english`
|
||||||
|
|
||||||
|
#### Scenario: Recording tray menu prioritizes finishing the meeting
|
||||||
|
- **GIVEN** launch profiles `default` and `english` are configured
|
||||||
|
- **AND** a meeting is actively recording with profile `default`
|
||||||
|
- **WHEN** the taskbar menu is opened
|
||||||
|
- **THEN** `Finish meeting` is the only action in the section immediately below `Open agent`
|
||||||
|
- **AND** pause, microphone selection, cancel/discard, and switching to `english` appear in a separate following section
|
||||||
|
- **AND** the menu does not offer switching to `default`
|
||||||
|
|
||||||
|
#### Scenario: Tray pause action changes to unpause
|
||||||
|
- **GIVEN** a meeting is actively recording with transcription running
|
||||||
|
- **WHEN** the taskbar menu is opened
|
||||||
|
- **THEN** it offers `Pause transcription`
|
||||||
|
- **WHEN** transcription is paused and the taskbar menu is opened again
|
||||||
|
- **THEN** it offers `Unpause transcription`
|
||||||
|
- **AND** still offers `Finish meeting`
|
||||||
|
|
||||||
|
#### Scenario: Active recording has priority over older summarizing runs
|
||||||
|
- **GIVEN** an older meeting is still summarizing
|
||||||
|
- **WHEN** a newer meeting is actively recording
|
||||||
|
- **THEN** the taskbar icon indicates recording
|
||||||
|
|
||||||
|
#### Scenario: Tray menu always exposes Exit
|
||||||
|
- **GIVEN** Meeting Assistant is running
|
||||||
|
- **WHEN** the taskbar menu is opened
|
||||||
|
- **THEN** it offers an Exit action
|
||||||
|
|
||||||
|
#### Scenario: Idle Exit stops immediately
|
||||||
|
- **GIVEN** no recording, transcription, speaker recognition, or summary work is running
|
||||||
|
- **WHEN** the user selects Exit from the taskbar menu
|
||||||
|
- **THEN** Meeting Assistant stops the application without an additional confirmation prompt
|
||||||
|
|
||||||
|
#### Scenario: In-progress Exit asks for confirmation
|
||||||
|
- **GIVEN** Meeting Assistant is recording, transcribing, recognizing speakers, or summarizing
|
||||||
|
- **WHEN** the user selects Exit from the taskbar menu
|
||||||
|
- **THEN** Meeting Assistant asks for confirmation before stopping the application
|
||||||
|
|
||||||
|
### Requirement: Recording inactivity safeguard stops forgotten meetings
|
||||||
|
Meeting Assistant SHALL track transcript inactivity during an active recording from the later of meeting start, the most recent unpause, or the most recent live transcript segment that contains text.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL show a stop prompt when transcript inactivity reaches configured prompt thresholds. The default thresholds SHALL be 2 minutes, 5 minutes, and 10 minutes.
|
||||||
|
|
||||||
|
On Windows, the stop prompt SHALL use a native Windows app notification with stop, continue, and pause-transcription action buttons.
|
||||||
|
|
||||||
|
On Windows, the stop prompt notification SHALL request reminder-style toast behavior and remain actionable for 1 minute.
|
||||||
|
|
||||||
|
The stop prompt SHALL ask whether to stop the meeting and SHALL provide affirmative, negative, and pause-transcription actions.
|
||||||
|
|
||||||
|
Showing or ignoring the stop prompt SHALL NOT block later inactivity checks, reminder prompts, or automatic stop.
|
||||||
|
|
||||||
|
If the user accepts the stop prompt, Meeting Assistant SHALL stop the recording normally, allowing transcription, speaker recognition, and summary generation to continue as for a normal stop.
|
||||||
|
|
||||||
|
If the user selects pause from the stop prompt, Meeting Assistant SHALL pause transcription for that same active meeting without finishing it.
|
||||||
|
|
||||||
|
When a new non-empty live transcript segment is written, Meeting Assistant SHALL dismiss all outstanding transcript-inactivity notifications and invalidate their pending actions.
|
||||||
|
|
||||||
|
While transcription is intentionally paused, Meeting Assistant SHALL NOT show transcript-inactivity prompts and SHALL NOT apply the ordinary transcript-inactivity auto-stop threshold.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL normally stop a meeting that remains continuously paused for the configured maximum pause duration, defaulting to 4 hours, without first showing a transcript-inactivity notification. This maximum continuous-pause cutoff SHALL remain active when ordinary transcript-inactivity prompting and auto-stop are disabled.
|
||||||
|
|
||||||
|
When transcription is unpaused, Meeting Assistant SHALL clear the continuous-pause timer and restart transcript-inactivity timing from the unpause time.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL automatically stop the recording normally when transcript inactivity reaches the configured auto-stop threshold, defaulting to 30 minutes.
|
||||||
|
|
||||||
|
When the inactivity safeguard stops a recording, Meeting Assistant SHALL infer the meeting end time from the most recent transcript segment timestamp plus configured padding, defaulting to 1 minute. If no transcript segment has arrived, Meeting Assistant SHALL infer the end time from the meeting start time plus the same padding.
|
||||||
|
|
||||||
|
When a recording stops normally and its meeting note, transcript, and assistant context contain no user-authored or captured content beyond generated default headings and metadata, Meeting Assistant SHALL delete the run artifacts instead of running summary generation.
|
||||||
|
|
||||||
|
#### Scenario: Inactive recording prompts the user
|
||||||
|
- **GIVEN** a recording is active
|
||||||
|
- **AND** no transcript text has arrived for the first configured inactivity prompt threshold
|
||||||
|
- **WHEN** the inactivity safeguard checks the active recording
|
||||||
|
- **THEN** Meeting Assistant prompts the user whether to stop the meeting with a native Windows app notification when running on Windows
|
||||||
|
- **AND** the notification offers pause transcription
|
||||||
|
- **AND** the notification remains actionable for 1 minute
|
||||||
|
- **AND** does not abort or discard meeting artifacts
|
||||||
|
|
||||||
|
#### Scenario: Ignored inactivity prompt does not block auto-stop
|
||||||
|
- **GIVEN** a recording is active
|
||||||
|
- **AND** the inactivity safeguard prompt was shown
|
||||||
|
- **WHEN** the user ignores the prompt until the automatic stop threshold is reached
|
||||||
|
- **THEN** Meeting Assistant stops the recording normally without waiting for a prompt response
|
||||||
|
|
||||||
|
#### Scenario: User accepts inactivity stop prompt
|
||||||
|
- **GIVEN** a recording is active
|
||||||
|
- **AND** the inactivity safeguard prompt is shown
|
||||||
|
- **WHEN** the user chooses to stop the meeting
|
||||||
|
- **THEN** Meeting Assistant stops the recording normally
|
||||||
|
- **AND** continues normal transcription and summary processing
|
||||||
|
- **AND** writes the inferred meeting end time to meeting artifacts
|
||||||
|
|
||||||
|
#### Scenario: User pauses from inactivity prompt
|
||||||
|
- **GIVEN** a recording is active
|
||||||
|
- **AND** the inactivity safeguard prompt is shown
|
||||||
|
- **WHEN** the user chooses to pause transcription
|
||||||
|
- **THEN** Meeting Assistant dismisses the outstanding inactivity notifications
|
||||||
|
- **AND** pauses transcription without finishing the meeting
|
||||||
|
|
||||||
|
#### Scenario: Inactive recording automatically stops
|
||||||
|
- **GIVEN** a recording is active
|
||||||
|
- **AND** no transcript text has arrived through the configured automatic stop threshold
|
||||||
|
- **WHEN** the inactivity safeguard checks the active recording
|
||||||
|
- **THEN** Meeting Assistant stops the recording normally without aborting artifacts
|
||||||
|
- **AND** writes the inferred meeting end time to meeting artifacts
|
||||||
|
|
||||||
|
#### Scenario: New transcript text resets inactivity prompts
|
||||||
|
- **GIVEN** a recording is active
|
||||||
|
- **AND** one or more inactivity prompts were shown
|
||||||
|
- **WHEN** a new transcript segment with text is written
|
||||||
|
- **AND** the segment belongs to that active recording
|
||||||
|
- **THEN** Meeting Assistant dismisses every outstanding inactivity notification
|
||||||
|
- **AND** invalidates their pending actions
|
||||||
|
- **AND** resets the inactivity prompt schedule from that transcript arrival
|
||||||
|
|
||||||
|
#### Scenario: Paused transcription suppresses inactivity safeguard
|
||||||
|
- **GIVEN** an active meeting transcription is paused
|
||||||
|
- **WHEN** configured transcript-inactivity prompt or automatic-stop thresholds pass before the maximum pause duration
|
||||||
|
- **THEN** Meeting Assistant does not show an inactivity prompt
|
||||||
|
- **AND** does not stop the meeting for transcript inactivity
|
||||||
|
- **WHEN** transcription is unpaused
|
||||||
|
- **THEN** inactivity timing restarts from the unpause time
|
||||||
|
|
||||||
|
#### Scenario: Continuously paused meeting is stopped after four hours
|
||||||
|
- **GIVEN** an active meeting transcription is paused continuously
|
||||||
|
- **WHEN** the configured maximum pause duration of 4 hours is reached
|
||||||
|
- **THEN** Meeting Assistant does not show a transcript-inactivity notification
|
||||||
|
- **AND** stops the meeting normally
|
||||||
|
|
||||||
|
#### Scenario: Maximum pause remains active when transcript-inactivity handling is disabled
|
||||||
|
- **GIVEN** ordinary transcript-inactivity prompting and auto-stop are disabled
|
||||||
|
- **AND** an active meeting transcription is paused continuously
|
||||||
|
- **WHEN** the configured maximum pause duration is reached
|
||||||
|
- **THEN** Meeting Assistant stops the meeting normally without an inactivity notification
|
||||||
|
|
||||||
|
#### Scenario: An older run cannot dismiss a current run's notification
|
||||||
|
- **GIVEN** an older stopped meeting is still draining transcription
|
||||||
|
- **AND** a newer active meeting has an outstanding transcript-inactivity notification
|
||||||
|
- **WHEN** the older meeting writes a late transcript segment
|
||||||
|
- **THEN** the newer meeting's notification and pending actions remain active
|
||||||
|
|
||||||
|
#### Scenario: Empty stopped recording is cleaned up
|
||||||
|
- **GIVEN** a recording is active
|
||||||
|
- **AND** the meeting note, transcript, and assistant context only contain generated default content
|
||||||
|
- **WHEN** the recording stops normally
|
||||||
|
- **THEN** Meeting Assistant deletes the run artifacts
|
||||||
|
- **AND** does not run summary generation
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Streaming recognition sessions survive intentional transcription pauses
|
||||||
|
Meeting Assistant SHALL preserve the active streaming speech-recognition pipeline and transcript session while transcription is intentionally paused.
|
||||||
|
|
||||||
|
For every mixed audio chunk captured while paused, Meeting Assistant SHALL discard the captured sample values and SHALL send an equal-duration PCM silence chunk to the temporary recording, live speaker audio buffer, and configured speech-recognition pipeline.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL NOT clear run-local speaker mappings or previously collected speaker samples when transcription is paused or unpaused.
|
||||||
|
|
||||||
|
When Azure Speech is the configured provider, Meeting Assistant SHALL keep the same active `ConversationTranscriber` operation and push audio stream across the pause rather than stopping and recreating the Azure recognition session.
|
||||||
|
|
||||||
|
#### Scenario: Paused audio is replaced with silence
|
||||||
|
- **GIVEN** an active meeting transcription is paused
|
||||||
|
- **WHEN** the audio source captures a non-silent mixed audio chunk
|
||||||
|
- **THEN** the temporary recording and speech-recognition pipeline receive an equal-length silent chunk
|
||||||
|
- **AND** neither receives the captured sample values
|
||||||
|
|
||||||
|
#### Scenario: Azure session remains active across pause
|
||||||
|
- **GIVEN** Azure Speech is transcribing an active meeting with speaker attribution
|
||||||
|
- **WHEN** transcription is paused and later unpaused
|
||||||
|
- **THEN** Meeting Assistant keeps the same conversation transcriber and push stream active
|
||||||
|
- **AND** preserves run-local speaker mappings and collected speaker samples
|
||||||
|
- **AND** newly captured audio after unpause continues through that session
|
||||||
|
|
||||||
|
#### Scenario: Buffered pre-pause result is retained
|
||||||
|
- **GIVEN** the transcription backend accepted audio before transcription was paused
|
||||||
|
- **WHEN** the corresponding transcript result arrives after the pause begins
|
||||||
|
- **THEN** Meeting Assistant writes that transcript result to the same meeting transcript
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
## 1. Inactivity notification lifecycle
|
||||||
|
|
||||||
|
- [x] 1.1 Add a failing coordinator behavior test proving that a newly written non-empty transcript segment dismisses every outstanding inactivity prompt.
|
||||||
|
- [x] 1.2 Extend the inactivity prompt service contract and Windows implementation to invalidate callbacks and remove the complete inactivity notification group.
|
||||||
|
|
||||||
|
## 2. Pause and unpause behavior
|
||||||
|
|
||||||
|
- [x] 2.1 Add a failing coordinator behavior test proving that pause keeps the run active, routes equal-duration silence instead of captured audio, and unpause resumes real audio through the same pipeline.
|
||||||
|
- [x] 2.2 Implement thread-safe pause state, coordinator pause/unpause controls, status reporting, and silence substitution without resetting speaker state or the active pipeline.
|
||||||
|
- [x] 2.3 Add a failing behavior test proving that inactivity prompts and auto-stop are suspended while paused and restart from the unpause time.
|
||||||
|
- [x] 2.4 Implement pause-aware inactivity timing and guard prompt callbacks so stale notifications cannot control another run.
|
||||||
|
|
||||||
|
## 3. User controls
|
||||||
|
|
||||||
|
- [x] 3.1 Add a failing tray-menu behavior test for `Pause transcription` / `Unpause transcription` while `Finish meeting` remains available.
|
||||||
|
- [x] 3.2 Add the tray pause toggle action in the fine-grained controls section and wire it to the coordinator.
|
||||||
|
- [x] 3.3 Add a failing inactivity-prompt behavior test proving the pause response pauses the originating active meeting.
|
||||||
|
- [x] 3.4 Add the `Pause transcription` Windows notification action and route its response through the guarded coordinator pause path.
|
||||||
|
|
||||||
|
## 4. Documentation and verification
|
||||||
|
|
||||||
|
- [x] 4.1 Update the operational documentation with pause behavior, Azure session continuity, silence substitution, and the tray/notification controls.
|
||||||
|
- [x] 4.2 Refactor the touched paths for DRYness, SOLID boundaries, and simplicity while preserving behavior, including pause/inactivity race handling found during independent review.
|
||||||
|
- [x] 4.3 Run focused recording/taskbar tests, the Windows application build, the full solution tests, and `openspec validate add-transcription-pause-controls --strict`.
|
||||||
|
- [x] 4.4 Inspect the local health and recording-status surfaces without restarting or interrupting an active meeting run.
|
||||||
|
|
||||||
|
## 5. Maximum continuous pause
|
||||||
|
|
||||||
|
- [x] 5.1 Add a failing coordinator behavior test proving that ordinary inactivity prompts remain suppressed while paused and the meeting stops normally after four continuous paused hours.
|
||||||
|
- [x] 5.2 Track the continuous pause start and implement the configurable four-hour paused-session safety stop without allowing an unpause race to stop the meeting.
|
||||||
|
- [x] 5.3 Update configuration documentation and rerun focused tests, the Windows build, the full suite, and strict OpenSpec validation.
|
||||||
|
|
||||||
|
## 6. Review follow-up
|
||||||
|
|
||||||
|
- [x] 6.1 Keep the maximum continuous-pause cutoff active when ordinary inactivity handling is disabled, with a failing public behavior test.
|
||||||
|
- [x] 6.2 Scope notification dismissal to the active run and move activity invalidation after durable transcript append, with overlapping-run and blocked-write regression tests.
|
||||||
|
- [x] 6.3 Make pause-notification activity validation atomic and make tray pause/unpause actions intent-specific, with race and stale-menu tests.
|
||||||
|
- [x] 6.4 Complete an independent simplification review and rerun all required validation before commit.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-09-02
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Speaker identity validation reuses the general pyannote diarization option type. That type includes an `Enabled` property for the optional Whisper finalization feature, while speaker validation already has its own outer `Enabled` property. The checked-in and deployed configuration currently sets those properties to opposite values. The validator observes the outer value, calls the finalizer, and receives an empty result because the finalizer observes the nested value, causing every sample to be rejected before Azure matching.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Expose one authoritative enable switch for speaker identity pyannote validation.
|
||||||
|
- Keep Whisper diarization independently configurable.
|
||||||
|
- Make validation, matching, and startup warm-up observe the same speaker-validation state.
|
||||||
|
- Preserve the existing pyannote runtime settings and validation thresholds.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Change speaker sample duration or gap thresholds.
|
||||||
|
- Change Azure Speech matching semantics.
|
||||||
|
- Enable pyannote validation without the required Docker runtime and Hugging Face token.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Give speaker validation a runtime-options type without an enable flag
|
||||||
|
|
||||||
|
Extract the shared pyannote runtime properties into `PyannoteRuntimeOptions`. Keep `PyannoteDiarizationOptions` as the Whisper-facing derived type that adds `Enabled`, and type `SpeakerIdentityPyannoteValidationOptions.Diarization` as `PyannoteRuntimeOptions`.
|
||||||
|
|
||||||
|
This makes contradictory speaker-validation state unrepresentable through the typed configuration model. The alternative—retaining the nested flag and overriding it at runtime—would leave a misleading configuration surface and permit the same mistake to recur.
|
||||||
|
|
||||||
|
### Gate once at the feature boundary
|
||||||
|
|
||||||
|
`PyannoteSpeakerIdentityMatchValidator` bypasses pyannote when the application-level outer validation switch is off. When it is on, the validator calls an enabled-runtime finalization path that does not evaluate another toggle. The warm-up service selects the same application-level speaker-validation runtime instead of resolving speaker validation independently for each launch profile.
|
||||||
|
|
||||||
|
Whisper finalization continues checking `WhisperLocal:Diarization:Enabled` before it invokes the shared runtime path.
|
||||||
|
|
||||||
|
### Migrate configuration by removing the nested key
|
||||||
|
|
||||||
|
Remove `SpeakerIdentification:PyannoteValidation:Diarization:Enabled` from the canonical configuration and document `SpeakerIdentification:PyannoteValidation:Enabled` as the sole switch. Existing unknown nested keys are ignored by .NET configuration binding after the typed property is removed; deployments should republish from the canonical configuration to remove the stale key.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Enabling the outer switch now really invokes Docker/pyannote] → Preserve the existing token/runtime error reporting and document that disabling the outer switch is the supported bypass.
|
||||||
|
- [The shared options refactor touches Whisper code] → Preserve its independent `Enabled` property on the derived type and run focused Whisper/pyannote tests plus the full suite.
|
||||||
|
- [Old deployed appsettings may retain the removed nested key] → The binder ignores it, so runtime behavior remains controlled by the outer switch; republishing removes it from the canonical deployed file.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Publish the updated application configuration with the nested key removed.
|
||||||
|
2. Keep `SpeakerIdentification:PyannoteValidation:Enabled` on only where Docker, the pyannote model, and `HF_TOKEN` are available.
|
||||||
|
3. Roll back by deploying the prior build and configuration together if necessary.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Speaker identity matching currently exposes two independent pyannote validation switches. Enabling the outer validation switch while disabling the nested diarization switch silently rejects every otherwise usable speaker sample, so the default configuration can prevent all automatic identity matches.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Make `SpeakerIdentification:PyannoteValidation:Enabled` the only switch controlling secondary pyannote validation.
|
||||||
|
- Remove the nested `SpeakerIdentification:PyannoteValidation:Diarization:Enabled` configuration setting.
|
||||||
|
- Ensure enabling validation also enables its pyannote runtime and startup warm-up, while disabling validation preserves primary Azure identity matching without invoking pyannote.
|
||||||
|
- Add regression coverage and configuration documentation for both toggle states.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `meeting-transcription`: Clarify that secondary pyannote validation has one authoritative enable setting and cannot be partially enabled.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Speaker identification options and pyannote runtime invocation.
|
||||||
|
- Pyannote startup warm-up selection.
|
||||||
|
- Checked-in application configuration and configuration reference.
|
||||||
|
- Speaker validation and warm-up behavior tests.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Speaker identity matching can use pyannote secondary validation
|
||||||
|
Meeting Assistant SHALL support an optional configurable pyannote secondary validation layer for speaker identity matching.
|
||||||
|
|
||||||
|
`SpeakerIdentification:PyannoteValidation:Enabled` SHALL be the only enable setting for speaker identity pyannote validation. The nested pyannote runtime settings SHALL NOT expose or honor a second enable setting.
|
||||||
|
|
||||||
|
Speaker identity pyannote validation SHALL use the application-level setting consistently for matching and startup warm-up. Launch profiles SHALL NOT override this validation setting or its runtime configuration.
|
||||||
|
|
||||||
|
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, Meeting Assistant SHALL verify speaker-override samples before retaining them on speaker identities. Speaker overrides whose source samples are rejected SHALL NOT create a new speaker identity from that rejected sample.
|
||||||
|
|
||||||
|
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: Multi-speaker speaker-override sample is rejected
|
||||||
|
- **GIVEN** pyannote secondary validation is enabled
|
||||||
|
- **WHEN** a summary speaker override resolves a source sample that pyannote reports as containing multiple speakers
|
||||||
|
- **THEN** Meeting Assistant does not retain that sample on a speaker identity
|
||||||
|
- **AND** does not create a new speaker identity from that rejected sample
|
||||||
|
|
||||||
|
#### 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: Enabled pyannote validation invokes its runtime
|
||||||
|
- **GIVEN** speaker identity pyannote validation is enabled
|
||||||
|
- **WHEN** Meeting Assistant validates a readable speaker sample
|
||||||
|
- **THEN** it invokes the configured pyannote runtime without requiring another enable setting
|
||||||
|
|
||||||
|
#### Scenario: Launch profile cannot override speaker validation
|
||||||
|
- **GIVEN** application-level speaker identity pyannote validation is disabled
|
||||||
|
- **AND** a named launch profile contains different speaker-validation settings
|
||||||
|
- **WHEN** Meeting Assistant starts or matches a speaker for that profile
|
||||||
|
- **THEN** it keeps application-level validation disabled
|
||||||
|
- **AND** does not warm or invoke the named profile's speaker-validation runtime
|
||||||
|
|
||||||
|
#### 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,15 @@
|
|||||||
|
## 1. Single-toggle behavior
|
||||||
|
|
||||||
|
- [x] 1.1 Add a failing behavior test proving enabled speaker validation invokes pyannote without a nested enable setting
|
||||||
|
- [x] 1.2 Introduce toggle-free speaker-validation runtime options and make the validator use them
|
||||||
|
- [x] 1.3 Add or update behavior coverage proving disabled validation bypasses pyannote and enabled validation warms the runtime
|
||||||
|
- [x] 1.4 Keep application-level validation and warm-up consistent when named launch profiles contain speaker-validation overrides
|
||||||
|
|
||||||
|
## 2. Configuration migration
|
||||||
|
|
||||||
|
- [x] 2.1 Remove the nested speaker-validation diarization toggle from canonical configuration and document the outer toggle as authoritative
|
||||||
|
|
||||||
|
## 3. Verification
|
||||||
|
|
||||||
|
- [x] 3.1 Run focused speaker validation, warm-up, and pyannote finalizer tests
|
||||||
|
- [x] 3.2 Run the full solution test suite and validate the OpenSpec change strictly
|
||||||
Reference in New Issue
Block a user