forked from Manuel/meeting-assistant
Compare commits
5
Commits
723298e720
...
bf6e1e7560
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf6e1e7560 | ||
|
|
d1d4b53ee8 | ||
|
|
0e2aa3830f | ||
|
|
d77187e9ec | ||
|
|
6adf6f726b |
@@ -90,3 +90,61 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
find MeetingAssistant.Tests -type d -name TestResults -print || true
|
find MeetingAssistant.Tests -type d -name TestResults -print || true
|
||||||
find MeetingAssistant.Tests -type f -path "*/TestResults/*" -maxdepth 5 -print || true
|
find MeetingAssistant.Tests -type f -path "*/TestResults/*" -maxdepth 5 -print || true
|
||||||
|
|
||||||
|
macos-build-and-test:
|
||||||
|
runs-on: macos
|
||||||
|
env:
|
||||||
|
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
|
||||||
|
DOTNET_NOLOGO: "1"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Verify native macOS runner
|
||||||
|
run: |
|
||||||
|
if [ "$(uname -s)" != "Darwin" ]; then
|
||||||
|
echo "The macos job requires a native Gitea runner registered with the macos:host label."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v6
|
||||||
|
with:
|
||||||
|
dotnet-version: "10.0.x"
|
||||||
|
|
||||||
|
- name: Restore (portable target)
|
||||||
|
run: |
|
||||||
|
dotnet restore MeetingAssistant.Tests/MeetingAssistant.Tests.csproj \
|
||||||
|
-p:EnableWindowsTargeting=true
|
||||||
|
|
||||||
|
- name: Build (portable target and macOS helpers)
|
||||||
|
run: |
|
||||||
|
dotnet build MeetingAssistant.Tests/MeetingAssistant.Tests.csproj \
|
||||||
|
-c Release \
|
||||||
|
-f net10.0 \
|
||||||
|
--no-restore \
|
||||||
|
-p:EnableWindowsTargeting=true \
|
||||||
|
--nologo
|
||||||
|
|
||||||
|
- name: Verify macOS native helpers
|
||||||
|
run: |
|
||||||
|
test -x MeetingAssistant/bin/Release/net10.0/Native/MeetingAssistantAudioCapture.app/Contents/MacOS/macos-meeting-audio-capture
|
||||||
|
test -x MeetingAssistant/bin/Release/net10.0/Native/macos-desktop-controls
|
||||||
|
test -x MeetingAssistant/bin/Release/net10.0/Native/macos-meeting-integrations
|
||||||
|
test -x MeetingAssistant/bin/Release/net10.0/Native/macos-meeting-assistant-launcher
|
||||||
|
|
||||||
|
- name: Run tests on macOS
|
||||||
|
run: |
|
||||||
|
dotnet test MeetingAssistant.Tests/MeetingAssistant.Tests.csproj \
|
||||||
|
-c Release \
|
||||||
|
-f net10.0 \
|
||||||
|
--no-build \
|
||||||
|
-p:EnableWindowsTargeting=true \
|
||||||
|
--nologo
|
||||||
|
|
||||||
|
- name: Show test output folder on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
find MeetingAssistant.Tests -type d -name TestResults -print || true
|
||||||
|
find MeetingAssistant.Tests -type f -path "*/TestResults/*" -maxdepth 5 -print || true
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ public sealed class AzureSpeechStreamingTranscriptionProviderTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ResolveKeyFallsBackToUserEnvironment()
|
public void ResolveKeyFallsBackToUserEnvironment()
|
||||||
{
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var keyEnv = $"MEETING_ASSISTANT_AZURE_KEY_{Guid.NewGuid():N}";
|
var keyEnv = $"MEETING_ASSISTANT_AZURE_KEY_{Guid.NewGuid():N}";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,7 +17,45 @@ namespace MeetingAssistant.Tests;
|
|||||||
public sealed class MacOsMeetingAudioSourceTests
|
public sealed class MacOsMeetingAudioSourceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NativeMicrophoneHelperDeclaresMacOsPrivacyMetadata()
|
public void MacOsServiceLauncherDefinesStablePrivacyIdentity()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsMacOS())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectDirectory = Path.GetFullPath(Path.Combine(
|
||||||
|
AppContext.BaseDirectory,
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"MeetingAssistant"));
|
||||||
|
var launcherDirectory = Path.Combine(projectDirectory, "Native", "MacOsLauncher");
|
||||||
|
var infoPlistPath = Path.Combine(launcherDirectory, "Info.plist");
|
||||||
|
var launcherSourcePath = Path.Combine(launcherDirectory, "main.swift");
|
||||||
|
|
||||||
|
Assert.True(File.Exists(infoPlistPath), $"Expected launcher Info.plist at '{infoPlistPath}'.");
|
||||||
|
Assert.True(File.Exists(launcherSourcePath), $"Expected launcher source at '{launcherSourcePath}'.");
|
||||||
|
|
||||||
|
var infoPlist = File.ReadAllText(infoPlistPath);
|
||||||
|
Assert.Contains("cloud.schweigert.meeting-assistant", infoPlist, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("NSMicrophoneUsageDescription", infoPlist, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("NSCalendarsFullAccessUsageDescription", infoPlist, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
var launcherSource = File.ReadAllText(launcherSourcePath);
|
||||||
|
Assert.Contains("set -a", launcherSource, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("Contents/Resources/app", launcherSource, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
var projectFile = File.ReadAllText(Path.Combine(projectDirectory, "MeetingAssistant.csproj"));
|
||||||
|
Assert.Contains(
|
||||||
|
"designated => identifier "cloud.schweigert.meeting-assistant"",
|
||||||
|
projectFile,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NativeAudioCaptureIsPackagedAsSignedMacOsAppForPersistentPrivacyGrant()
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsMacOS())
|
if (!OperatingSystem.IsMacOS())
|
||||||
{
|
{
|
||||||
@@ -26,7 +64,7 @@ public sealed class MacOsMeetingAudioSourceTests
|
|||||||
|
|
||||||
var configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
var configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||||
.Parent?.Name ?? "Debug";
|
.Parent?.Name ?? "Debug";
|
||||||
var helperPath = Path.GetFullPath(Path.Combine(
|
var appPath = Path.GetFullPath(Path.Combine(
|
||||||
AppContext.BaseDirectory,
|
AppContext.BaseDirectory,
|
||||||
"..",
|
"..",
|
||||||
"..",
|
"..",
|
||||||
@@ -37,11 +75,30 @@ public sealed class MacOsMeetingAudioSourceTests
|
|||||||
configuration,
|
configuration,
|
||||||
"net10.0",
|
"net10.0",
|
||||||
"Native",
|
"Native",
|
||||||
"macos-meeting-audio-capture"));
|
"MeetingAssistantAudioCapture.app"));
|
||||||
var helperImage = System.Text.Encoding.UTF8.GetString(File.ReadAllBytes(helperPath));
|
var infoPlistPath = Path.Combine(appPath, "Contents", "Info.plist");
|
||||||
|
var helperPath = Path.Combine(appPath, "Contents", "MacOS", "macos-meeting-audio-capture");
|
||||||
|
|
||||||
Assert.Contains("cloud.schweigert.meeting-assistant.audio-capture", helperImage, StringComparison.Ordinal);
|
Assert.True(Directory.Exists(appPath), $"Expected macOS audio capture app at '{appPath}'.");
|
||||||
Assert.Contains("NSMicrophoneUsageDescription", helperImage, StringComparison.Ordinal);
|
Assert.True(File.Exists(infoPlistPath), $"Expected Info.plist at '{infoPlistPath}'.");
|
||||||
|
Assert.True(File.Exists(helperPath), $"Expected native helper at '{helperPath}'.");
|
||||||
|
|
||||||
|
var infoPlist = File.ReadAllText(infoPlistPath);
|
||||||
|
Assert.Contains("cloud.schweigert.meeting-assistant.audio-capture", infoPlist, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("NSMicrophoneUsageDescription", infoPlist, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
using var verification = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "/usr/bin/codesign",
|
||||||
|
ArgumentList = { "--verify", "--deep", "--strict", appPath },
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false
|
||||||
|
});
|
||||||
|
Assert.NotNull(verification);
|
||||||
|
verification.WaitForExit();
|
||||||
|
Assert.True(
|
||||||
|
verification.ExitCode == 0,
|
||||||
|
$"Expected a valid app-bundle signature: {verification.StandardError.ReadToEnd()}");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ public sealed class MeetingNoteStoreTests
|
|||||||
{
|
{
|
||||||
var link = MeetingNoteActionLinks.CreateSummaryRetryLink(
|
var link = MeetingNoteActionLinks.CreateSummaryRetryLink(
|
||||||
"http://localhost:5090/",
|
"http://localhost:5090/",
|
||||||
"C:\\Vault\\Meetings\\Summaries\\summary with spaces.md");
|
Path.Combine(Path.GetTempPath(), "summary with spaces.md"));
|
||||||
|
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
"[Retry summary generation](http://localhost:5090/meetings/summary/retry?summaryPath=summary%20with%20spaces.md)",
|
"[Retry summary generation](http://localhost:5090/meetings/summary/retry?summaryPath=summary%20with%20spaces.md)",
|
||||||
|
|||||||
@@ -448,9 +448,14 @@ public sealed class RecordingCoordinatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task StartCreatesMeetingNoteLinkedToTranscriptAndOpensIt()
|
public async Task StartCreatesMeetingNoteLinkedToTranscriptAndOpensIt()
|
||||||
{
|
{
|
||||||
|
var vaultRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-vault");
|
||||||
|
var transcriptPath = Path.Combine(vaultRoot, "Meetings", "Transcripts", "20260519-transcript.md");
|
||||||
|
var meetingNotePath = Path.Combine(vaultRoot, "Meetings", "Notes", "20260519-meeting.md");
|
||||||
|
var assistantContextFolder = Path.Combine(vaultRoot, "Meetings", "Assistant Context");
|
||||||
|
var summariesFolder = Path.Combine(vaultRoot, "Meetings", "Summaries");
|
||||||
var audioSource = new ControlledAudioSource();
|
var audioSource = new ControlledAudioSource();
|
||||||
var transcriptStore = new InMemoryTranscriptStore("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md");
|
var transcriptStore = new InMemoryTranscriptStore(transcriptPath);
|
||||||
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md");
|
var noteStore = new InMemoryMeetingNoteStore(meetingNotePath);
|
||||||
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-05-19T10:03:42+02:00"));
|
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-05-19T10:03:42+02:00"));
|
||||||
var noteOpener = new CapturingMeetingNoteOpener();
|
var noteOpener = new CapturingMeetingNoteOpener();
|
||||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||||
@@ -468,25 +473,27 @@ public sealed class RecordingCoordinatorTests
|
|||||||
{
|
{
|
||||||
Vault = new VaultOptions
|
Vault = new VaultOptions
|
||||||
{
|
{
|
||||||
AssistantContextFolder = "C:\\Vault\\Meetings\\Assistant Context",
|
AssistantContextFolder = assistantContextFolder,
|
||||||
SummariesFolder = "C:\\Vault\\Meetings\\Summaries"
|
SummariesFolder = summariesFolder
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||||
inactivityClock: clock);
|
inactivityClock: clock);
|
||||||
|
|
||||||
var status = await coordinator.StartAsync(CancellationToken.None);
|
var status = await coordinator.StartAsync(CancellationToken.None);
|
||||||
|
var assistantContextPath = Path.Combine(assistantContextFolder, "20260519-1003-context.md");
|
||||||
|
var summaryPath = Path.Combine(summariesFolder, "20260519-1003-summary.md");
|
||||||
|
|
||||||
Assert.True(status.IsRecording);
|
Assert.True(status.IsRecording);
|
||||||
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", status.MeetingNotePath);
|
Assert.Equal(meetingNotePath, status.MeetingNotePath);
|
||||||
Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", noteStore.SavedNote?.Frontmatter.Transcript);
|
Assert.Equal(transcriptPath, noteStore.SavedNote?.Frontmatter.Transcript);
|
||||||
Assert.Equal("C:\\Vault\\Meetings\\Assistant Context\\20260519-1003-context.md", noteStore.SavedNote?.Frontmatter.AssistantContext);
|
Assert.Equal(assistantContextPath, noteStore.SavedNote?.Frontmatter.AssistantContext);
|
||||||
Assert.Equal("C:\\Vault\\Meetings\\Summaries\\20260519-1003-summary.md", noteStore.SavedNote?.Frontmatter.Summary);
|
Assert.Equal(summaryPath, noteStore.SavedNote?.Frontmatter.Summary);
|
||||||
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", noteOpener.OpenedPath);
|
Assert.Equal(meetingNotePath, noteOpener.OpenedPath);
|
||||||
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", artifactStore.CreatedArtifacts?.MeetingNotePath);
|
Assert.Equal(meetingNotePath, artifactStore.CreatedArtifacts?.MeetingNotePath);
|
||||||
Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", artifactStore.CreatedArtifacts?.TranscriptPath);
|
Assert.Equal(transcriptPath, artifactStore.CreatedArtifacts?.TranscriptPath);
|
||||||
Assert.Equal("C:\\Vault\\Meetings\\Assistant Context\\20260519-1003-context.md", artifactStore.CreatedArtifacts?.AssistantContextPath);
|
Assert.Equal(assistantContextPath, artifactStore.CreatedArtifacts?.AssistantContextPath);
|
||||||
Assert.Equal("C:\\Vault\\Meetings\\Summaries\\20260519-1003-summary.md", artifactStore.CreatedArtifacts?.SummaryPath);
|
Assert.Equal(summaryPath, artifactStore.CreatedArtifacts?.SummaryPath);
|
||||||
Assert.Equal(noteStore.SavedNote?.Frontmatter.Title, artifactStore.ContextMeetingNote?.Frontmatter.Title);
|
Assert.Equal(noteStore.SavedNote?.Frontmatter.Title, artifactStore.ContextMeetingNote?.Frontmatter.Title);
|
||||||
Assert.Equal(noteStore.SavedNote?.Frontmatter.StartTime, artifactStore.ContextMeetingNote?.Frontmatter.StartTime);
|
Assert.Equal(noteStore.SavedNote?.Frontmatter.StartTime, artifactStore.ContextMeetingNote?.Frontmatter.StartTime);
|
||||||
|
|
||||||
|
|||||||
@@ -181,6 +181,11 @@ public sealed class TaskbarIconTests
|
|||||||
[SupportedOSPlatform("windows")]
|
[SupportedOSPlatform("windows")]
|
||||||
public void TaskbarIconGlyphsAreVisuallyCentered()
|
public void TaskbarIconGlyphsAreVisuallyCentered()
|
||||||
{
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var state in new[]
|
foreach (var state in new[]
|
||||||
{
|
{
|
||||||
RecordingProcessState.Idle,
|
RecordingProcessState.Idle,
|
||||||
|
|||||||
@@ -7,16 +7,16 @@ public sealed class VaultPathTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ResolveUsesVaultBaseFolderForRelativePaths()
|
public void ResolveUsesVaultBaseFolderForRelativePaths()
|
||||||
{
|
{
|
||||||
|
var baseFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-vault");
|
||||||
var vault = new VaultOptions
|
var vault = new VaultOptions
|
||||||
{
|
{
|
||||||
BaseFolder = @"C:\Users\masc3\OpenCloud\Persönlich\Vault\Exxeta"
|
BaseFolder = baseFolder
|
||||||
};
|
};
|
||||||
|
|
||||||
var resolved = VaultPath.Resolve(vault, @"Meetings\Transcripts");
|
var relativePath = Path.Combine("Meetings", "Transcripts");
|
||||||
|
var resolved = VaultPath.Resolve(vault, relativePath);
|
||||||
|
|
||||||
Assert.Equal(
|
Assert.Equal(Path.Combine(baseFolder, relativePath), resolved);
|
||||||
@"C:\Users\masc3\OpenCloud\Persönlich\Vault\Exxeta\Meetings\Transcripts",
|
|
||||||
resolved);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -24,11 +24,12 @@ public sealed class VaultPathTests
|
|||||||
{
|
{
|
||||||
var vault = new VaultOptions
|
var vault = new VaultOptions
|
||||||
{
|
{
|
||||||
BaseFolder = @"C:\Users\masc3\OpenCloud\Persönlich\Vault\Exxeta"
|
BaseFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-vault")
|
||||||
};
|
};
|
||||||
|
var absolutePath = Path.Combine(Path.GetTempPath(), "meeting-assistant-other");
|
||||||
|
|
||||||
var resolved = VaultPath.Resolve(vault, @"C:\Other\Path");
|
var resolved = VaultPath.Resolve(vault, absolutePath);
|
||||||
|
|
||||||
Assert.Equal(@"C:\Other\Path", resolved);
|
Assert.Equal(absolutePath, resolved);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ public sealed class VenvResemblyzerVoiceEncoderTests
|
|||||||
command => command.Arguments.Any(
|
command => command.Arguments.Any(
|
||||||
argument => argument.EndsWith("encode.py", StringComparison.Ordinal)));
|
argument => argument.EndsWith("encode.py", StringComparison.Ordinal)));
|
||||||
Assert.EndsWith(
|
Assert.EndsWith(
|
||||||
Path.Combine("Scripts", "python.exe"),
|
OperatingSystem.IsWindows()
|
||||||
|
? Path.Combine("Scripts", "python.exe")
|
||||||
|
: Path.Combine("bin", "python"),
|
||||||
encodingCommand.FileName,
|
encodingCommand.FileName,
|
||||||
StringComparison.OrdinalIgnoreCase);
|
StringComparison.OrdinalIgnoreCase);
|
||||||
Assert.Equal(2, encodingCommand.Arguments.Count);
|
Assert.Equal(2, encodingCommand.Arguments.Count);
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
<MacOsNativeHelpersEnabled>true</MacOsNativeHelpersEnabled>
|
<MacOsNativeHelpersEnabled>true</MacOsNativeHelpersEnabled>
|
||||||
<MacOsAudioCaptureSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/main.swift</MacOsAudioCaptureSource>
|
<MacOsAudioCaptureSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/main.swift</MacOsAudioCaptureSource>
|
||||||
<MacOsAudioCaptureInfoPlist>$(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/Info.plist</MacOsAudioCaptureInfoPlist>
|
<MacOsAudioCaptureInfoPlist>$(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/Info.plist</MacOsAudioCaptureInfoPlist>
|
||||||
<MacOsAudioCaptureOutputPath>Native/macos-meeting-audio-capture</MacOsAudioCaptureOutputPath>
|
<MacOsAudioCaptureBundlePath>Native/MeetingAssistantAudioCapture.app</MacOsAudioCaptureBundlePath>
|
||||||
|
<MacOsAudioCaptureOutputPath>$(MacOsAudioCaptureBundlePath)/Contents/MacOS/macos-meeting-audio-capture</MacOsAudioCaptureOutputPath>
|
||||||
<MacOsAudioCaptureRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</MacOsAudioCaptureRid>
|
<MacOsAudioCaptureRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</MacOsAudioCaptureRid>
|
||||||
<MacOsAudioCaptureRid Condition="'$(MacOsAudioCaptureRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</MacOsAudioCaptureRid>
|
<MacOsAudioCaptureRid Condition="'$(MacOsAudioCaptureRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</MacOsAudioCaptureRid>
|
||||||
<MacOsAudioCaptureArchitecture Condition="'$(MacOsAudioCaptureRid)' == 'osx-x64'">x86_64</MacOsAudioCaptureArchitecture>
|
<MacOsAudioCaptureArchitecture Condition="'$(MacOsAudioCaptureRid)' == 'osx-x64'">x86_64</MacOsAudioCaptureArchitecture>
|
||||||
@@ -29,6 +30,10 @@
|
|||||||
<MacOsMeetingIntegrationsSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingIntegrations/main.swift</MacOsMeetingIntegrationsSource>
|
<MacOsMeetingIntegrationsSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingIntegrations/main.swift</MacOsMeetingIntegrationsSource>
|
||||||
<MacOsMeetingIntegrationsInfoPlist>$(MSBuildProjectDirectory)/Native/MacOsMeetingIntegrations/Info.plist</MacOsMeetingIntegrationsInfoPlist>
|
<MacOsMeetingIntegrationsInfoPlist>$(MSBuildProjectDirectory)/Native/MacOsMeetingIntegrations/Info.plist</MacOsMeetingIntegrationsInfoPlist>
|
||||||
<MacOsMeetingIntegrationsOutputPath>Native/macos-meeting-integrations</MacOsMeetingIntegrationsOutputPath>
|
<MacOsMeetingIntegrationsOutputPath>Native/macos-meeting-integrations</MacOsMeetingIntegrationsOutputPath>
|
||||||
|
<MacOsLauncherSource>$(MSBuildProjectDirectory)/Native/MacOsLauncher/main.swift</MacOsLauncherSource>
|
||||||
|
<MacOsLauncherInfoPlist>$(MSBuildProjectDirectory)/Native/MacOsLauncher/Info.plist</MacOsLauncherInfoPlist>
|
||||||
|
<MacOsLauncherOutputPath>Native/macos-meeting-assistant-launcher</MacOsLauncherOutputPath>
|
||||||
|
<MacOsApplicationBundlePath>MeetingAssistant.app</MacOsApplicationBundlePath>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -83,9 +88,32 @@
|
|||||||
AfterTargets="Build"
|
AfterTargets="Build"
|
||||||
Condition="'$(MacOsNativeHelpersEnabled)' == 'true'">
|
Condition="'$(MacOsNativeHelpersEnabled)' == 'true'">
|
||||||
<MakeDir Directories="$(TargetDir)Native" />
|
<MakeDir Directories="$(TargetDir)Native" />
|
||||||
|
<MakeDir Directories="$(TargetDir)$(MacOsAudioCaptureBundlePath)/Contents/MacOS" />
|
||||||
|
<Copy SourceFiles="$(MacOsAudioCaptureInfoPlist)" DestinationFiles="$(TargetDir)$(MacOsAudioCaptureBundlePath)/Contents/Info.plist" />
|
||||||
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AVFoundation -framework CoreMedia -framework ScreenCaptureKit -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker "$(MacOsAudioCaptureInfoPlist)" "$(MacOsAudioCaptureSource)" -o "$(TargetDir)$(MacOsAudioCaptureOutputPath)"" />
|
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AVFoundation -framework CoreMedia -framework ScreenCaptureKit -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker "$(MacOsAudioCaptureInfoPlist)" "$(MacOsAudioCaptureSource)" -o "$(TargetDir)$(MacOsAudioCaptureOutputPath)"" />
|
||||||
|
<Exec Command="/usr/bin/codesign --force --deep --sign - "$(TargetDir)$(MacOsAudioCaptureBundlePath)"" />
|
||||||
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AppKit -framework Carbon -framework WebKit "$(MacOsDesktopControlsSource)" -o "$(TargetDir)$(MacOsDesktopControlsOutputPath)"" />
|
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AppKit -framework Carbon -framework WebKit "$(MacOsDesktopControlsSource)" -o "$(TargetDir)$(MacOsDesktopControlsOutputPath)"" />
|
||||||
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AppKit -framework CoreGraphics -framework EventKit -framework ImageIO -framework UniformTypeIdentifiers -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker "$(MacOsMeetingIntegrationsInfoPlist)" "$(MacOsMeetingIntegrationsSource)" -o "$(TargetDir)$(MacOsMeetingIntegrationsOutputPath)"" />
|
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AppKit -framework CoreGraphics -framework EventKit -framework ImageIO -framework UniformTypeIdentifiers -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker "$(MacOsMeetingIntegrationsInfoPlist)" "$(MacOsMeetingIntegrationsSource)" -o "$(TargetDir)$(MacOsMeetingIntegrationsOutputPath)"" />
|
||||||
|
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 "$(MacOsLauncherSource)" -o "$(TargetDir)$(MacOsLauncherOutputPath)"" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<Target
|
||||||
|
Name="PackageMacOsApplication"
|
||||||
|
AfterTargets="Publish"
|
||||||
|
DependsOnTargets="CopyMeetingAssistantConfigToPublishRoot;CopyMacOsNativeHelpersToPublish"
|
||||||
|
Condition="'$(MacOsNativeHelpersEnabled)' == 'true'">
|
||||||
|
<RemoveDir Directories="$(PublishDir)$(MacOsApplicationBundlePath)" />
|
||||||
|
<MakeDir Directories="$(PublishDir)$(MacOsApplicationBundlePath)/Contents/MacOS" />
|
||||||
|
<MakeDir Directories="$(PublishDir)$(MacOsApplicationBundlePath)/Contents/Resources/app" />
|
||||||
|
<ItemGroup>
|
||||||
|
<MacOsPublishedRuntimeFiles Include="$(PublishDir)**/*" Exclude="$(PublishDir)$(MacOsApplicationBundlePath)/**/*" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Copy
|
||||||
|
SourceFiles="@(MacOsPublishedRuntimeFiles)"
|
||||||
|
DestinationFiles="@(MacOsPublishedRuntimeFiles->'$(PublishDir)$(MacOsApplicationBundlePath)/Contents/Resources/app/%(RecursiveDir)%(Filename)%(Extension)')" />
|
||||||
|
<Copy SourceFiles="$(TargetDir)$(MacOsLauncherOutputPath)" DestinationFiles="$(PublishDir)$(MacOsApplicationBundlePath)/Contents/MacOS/MeetingAssistant" />
|
||||||
|
<Copy SourceFiles="$(MacOsLauncherInfoPlist)" DestinationFiles="$(PublishDir)$(MacOsApplicationBundlePath)/Contents/Info.plist" />
|
||||||
|
<Exec Command="/usr/bin/codesign --force --deep --sign - --requirements '=designated => identifier "cloud.schweigert.meeting-assistant"' "$(PublishDir)$(MacOsApplicationBundlePath)"" />
|
||||||
</Target>
|
</Target>
|
||||||
|
|
||||||
<Target
|
<Target
|
||||||
@@ -93,7 +121,10 @@
|
|||||||
AfterTargets="Publish"
|
AfterTargets="Publish"
|
||||||
Condition="'$(MacOsNativeHelpersEnabled)' == 'true'">
|
Condition="'$(MacOsNativeHelpersEnabled)' == 'true'">
|
||||||
<MakeDir Directories="$(PublishDir)Native" />
|
<MakeDir Directories="$(PublishDir)Native" />
|
||||||
<Copy SourceFiles="$(TargetDir)$(MacOsAudioCaptureOutputPath)" DestinationFolder="$(PublishDir)Native" />
|
<MakeDir Directories="$(PublishDir)$(MacOsAudioCaptureBundlePath)/Contents/MacOS" />
|
||||||
|
<Copy SourceFiles="$(TargetDir)$(MacOsAudioCaptureOutputPath)" DestinationFiles="$(PublishDir)$(MacOsAudioCaptureOutputPath)" />
|
||||||
|
<Copy SourceFiles="$(MacOsAudioCaptureInfoPlist)" DestinationFiles="$(PublishDir)$(MacOsAudioCaptureBundlePath)/Contents/Info.plist" />
|
||||||
|
<Exec Command="/usr/bin/codesign --force --deep --sign - "$(PublishDir)$(MacOsAudioCaptureBundlePath)"" />
|
||||||
<Copy SourceFiles="$(TargetDir)$(MacOsDesktopControlsOutputPath)" DestinationFolder="$(PublishDir)Native" />
|
<Copy SourceFiles="$(TargetDir)$(MacOsDesktopControlsOutputPath)" DestinationFolder="$(PublishDir)Native" />
|
||||||
<Copy SourceFiles="$(TargetDir)$(MacOsMeetingIntegrationsOutputPath)" DestinationFolder="$(PublishDir)Native" />
|
<Copy SourceFiles="$(TargetDir)$(MacOsMeetingIntegrationsOutputPath)" DestinationFolder="$(PublishDir)Native" />
|
||||||
</Target>
|
</Target>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>MeetingAssistant</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>cloud.schweigert.meeting-assistant</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>Meeting Assistant</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||||
|
<string>Meeting Assistant reads calendar metadata for meeting notes and recording prompts.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Meeting Assistant records microphone audio for live meeting transcription.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import Darwin
|
||||||
|
import Dispatch
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@main
|
||||||
|
private struct MeetingAssistantLauncher {
|
||||||
|
private static let serviceDirectoryName = "MeetingAssistant"
|
||||||
|
|
||||||
|
static func main() {
|
||||||
|
let runtimeDirectory = Bundle.main.bundleURL
|
||||||
|
.appendingPathComponent("Contents/Resources/app", isDirectory: true)
|
||||||
|
let assemblyPath = runtimeDirectory.appendingPathComponent("MeetingAssistant.dll").path
|
||||||
|
let environmentFile = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Library/Application Support")
|
||||||
|
.appendingPathComponent(serviceDirectoryName)
|
||||||
|
.appendingPathComponent("config/meeting-assistant.env")
|
||||||
|
|
||||||
|
guard FileManager.default.fileExists(atPath: assemblyPath) else {
|
||||||
|
fail("Meeting Assistant runtime not found at \(assemblyPath)")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard FileManager.default.fileExists(atPath: environmentFile.path) else {
|
||||||
|
fail("Meeting Assistant environment file not found at \(environmentFile.path)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||||
|
process.arguments = [
|
||||||
|
"-c",
|
||||||
|
"set -a; source \"$1\"; set +a; exec /usr/local/share/dotnet/dotnet \"$2\" --contentRoot \"$3\"",
|
||||||
|
"meeting-assistant-launcher",
|
||||||
|
environmentFile.path,
|
||||||
|
assemblyPath,
|
||||||
|
runtimeDirectory.path
|
||||||
|
]
|
||||||
|
process.currentDirectoryURL = runtimeDirectory
|
||||||
|
|
||||||
|
signal(SIGTERM, SIG_IGN)
|
||||||
|
signal(SIGINT, SIG_IGN)
|
||||||
|
|
||||||
|
let terminationSignal = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .global())
|
||||||
|
terminationSignal.setEventHandler {
|
||||||
|
if process.isRunning {
|
||||||
|
process.terminate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
terminationSignal.resume()
|
||||||
|
|
||||||
|
let interruptSignal = DispatchSource.makeSignalSource(signal: SIGINT, queue: .global())
|
||||||
|
interruptSignal.setEventHandler {
|
||||||
|
if process.isRunning {
|
||||||
|
kill(process.processIdentifier, SIGINT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interruptSignal.resume()
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
Darwin.exit(process.terminationStatus)
|
||||||
|
} catch {
|
||||||
|
fail("Meeting Assistant launcher failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fail(_ message: String) -> Never {
|
||||||
|
FileHandle.standardError.write(Data("\(message)\n".utf8))
|
||||||
|
Darwin.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,21 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>cloud.schweigert.meeting-assistant.audio-capture</string>
|
<string>macos-meeting-audio-capture</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>Meeting Assistant Audio Capture</string>
|
<string>cloud.schweigert.meeting-assistant.audio-capture</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>CFBundleName</key>
|
||||||
<string>Meeting Assistant records microphone audio for live meeting transcription.</string>
|
<string>Meeting Assistant Audio Capture</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Meeting Assistant records microphone audio for live meeting transcription.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -199,7 +199,13 @@ internal sealed class MacOsAudioCaptureProcessFactory : IMacOsAudioCaptureProces
|
|||||||
throw new PlatformNotSupportedException("The macOS audio capture helper can only run on macOS.");
|
throw new PlatformNotSupportedException("The macOS audio capture helper can only run on macOS.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var helperPath = Path.Combine(AppContext.BaseDirectory, "Native", "macos-meeting-audio-capture");
|
var helperPath = Path.Combine(
|
||||||
|
AppContext.BaseDirectory,
|
||||||
|
"Native",
|
||||||
|
"MeetingAssistantAudioCapture.app",
|
||||||
|
"Contents",
|
||||||
|
"MacOS",
|
||||||
|
"macos-meeting-audio-capture");
|
||||||
if (!File.Exists(helperPath))
|
if (!File.Exists(helperPath))
|
||||||
{
|
{
|
||||||
throw new FileNotFoundException(
|
throw new FileNotFoundException(
|
||||||
|
|||||||
@@ -157,13 +157,15 @@ The Windows tray and macOS menu-bar menus expose `Open agent`, which opens the `
|
|||||||
|
|
||||||
At startup, Meeting Assistant detects whether the host is Windows or macOS and includes that immutable runtime context in the interactive settings/logs assistant instructions. On Windows the assistant uses Windows commands and concepts such as PowerShell, Windows paths, services, and Task Manager. On macOS it uses zsh, POSIX paths, launchd/LaunchAgents, and Activity Monitor. This guidance is also appended when a custom interactive-agent prompt is configured. macOS audio capture, menu-bar controls, global hotkeys, EventKit calendar enrichment/prompts, active-window screenshots, screenshot OCR, speaker identification, FunASR, local diarization, and the AppKit/WebKit workflow editor are available in the portable build. Outlook Classic COM and Windows toast notifications remain Windows-specific implementations.
|
At startup, Meeting Assistant detects whether the host is Windows or macOS and includes that immutable runtime context in the interactive settings/logs assistant instructions. On Windows the assistant uses Windows commands and concepts such as PowerShell, Windows paths, services, and Task Manager. On macOS it uses zsh, POSIX paths, launchd/LaunchAgents, and Activity Monitor. This guidance is also appended when a custom interactive-agent prompt is configured. macOS audio capture, menu-bar controls, global hotkeys, EventKit calendar enrichment/prompts, active-window screenshots, screenshot OCR, speaker identification, FunASR, local diarization, and the AppKit/WebKit workflow editor are available in the portable build. Outlook Classic COM and Windows toast notifications remain Windows-specific implementations.
|
||||||
|
|
||||||
|
The macOS publish output includes a signed `MeetingAssistant.app` bundle. Install that bundle in `/Applications` and launch the background service through its native executable so macOS microphone, calendar, and Screen/System Audio privacy grants are attributed to the stable Meeting Assistant application identity.
|
||||||
|
|
||||||
Detailed workflow syntax and extension guidance live in `docs/meeting-workflow-engine.md`.
|
Detailed workflow syntax and extension guidance live in `docs/meeting-workflow-engine.md`.
|
||||||
|
|
||||||
## Development And CI
|
## Development And CI
|
||||||
|
|
||||||
Behavior changes are OpenSpec-driven and test-first: update the relevant requirement/scenario, add a failing public behavior test, implement the smallest passing change, run focused tests and then the justified broader suite, and validate the active change with `openspec validate <change-id> --strict`. Documentation-only maintenance does not need a new OpenSpec change.
|
Behavior changes are OpenSpec-driven and test-first: update the relevant requirement/scenario, add a failing public behavior test, implement the smallest passing change, run focused tests and then the justified broader suite, and validate the active change with `openspec validate <change-id> --strict`. Documentation-only maintenance does not need a new OpenSpec change.
|
||||||
|
|
||||||
The Gitea workflow runs for pull requests, pushes, and manual dispatch. It builds the Windows target on an Ubuntu runner, installs Wine plus a matching Windows .NET SDK, and runs the test project through the Windows host under Wine. It validates source; it does not publish or deploy the workstation application.
|
The Gitea workflow runs for pull requests, pushes, and manual dispatch. Its Linux job builds the Windows target, installs Wine plus a matching Windows .NET SDK, and runs the test project through the Windows host under Wine. A separate `runs-on: macos` job builds the portable target and bundled Swift helpers, verifies the native helper executables, and runs the complete portable unit-test suite on macOS. The Gitea runner serving that job must run natively on a Mac and expose the `macos:host` label; Docker-OSX is not used because it requires nested KVM that is normally unavailable on CI runners. CI validates source; it does not publish or deploy the workstation application.
|
||||||
|
|
||||||
## Operations And Limitations
|
## Operations And Limitations
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ On Windows, Meeting Assistant SHALL retain the existing NAudio microphone and WA
|
|||||||
|
|
||||||
On macOS, Meeting Assistant SHALL capture the default microphone through AVFoundation and computer output through ScreenCaptureKit without requiring a virtual audio device.
|
On macOS, Meeting Assistant SHALL capture the default microphone through AVFoundation and computer output through ScreenCaptureKit without requiring a virtual audio device.
|
||||||
|
|
||||||
|
The macOS native audio helper SHALL be packaged and launched from a signed application bundle with the stable bundle identifier `cloud.schweigert.meeting-assistant.audio-capture` and a microphone usage description so macOS privacy grants apply to background LaunchAgent capture and persist across deployments.
|
||||||
|
|
||||||
|
The macOS background service SHALL be launched through a signed application bundle with the stable bundle identifier `cloud.schweigert.meeting-assistant` plus microphone and calendar usage descriptions, with the managed runtime nested under that bundle so macOS attributes privacy-sensitive child processes to the Meeting Assistant application identity. The bundle SHALL use a stable designated code requirement for that identifier so routine deployments remain compatible with its macOS privacy grant.
|
||||||
|
|
||||||
The macOS capture adapter SHALL convert both native sources to signed 16-bit PCM using the active run's configured sample rate and channel count before passing chunks to the existing managed mixing pipeline.
|
The macOS capture adapter SHALL convert both native sources to signed 16-bit PCM using the active run's configured sample rate and channel count before passing chunks to the existing managed mixing pipeline.
|
||||||
|
|
||||||
The macOS capture adapter SHALL stop its native capture process when the recording capture token is cancelled.
|
The macOS capture adapter SHALL stop its native capture process when the recording capture token is cancelled.
|
||||||
@@ -154,6 +158,12 @@ When no runtime microphone override is selected, the checked microphone SHALL be
|
|||||||
- **AND** captures computer output through ScreenCaptureKit
|
- **AND** captures computer output through ScreenCaptureKit
|
||||||
- **AND** passes both signed 16-bit PCM streams through the existing mixer
|
- **AND** passes both signed 16-bit PCM streams through the existing mixer
|
||||||
|
|
||||||
|
#### Scenario: macOS background capture uses a stable privacy identity
|
||||||
|
- **GIVEN** Meeting Assistant runs as a macOS LaunchAgent
|
||||||
|
- **WHEN** it starts the native audio helper
|
||||||
|
- **THEN** it launches the executable from the signed Meeting Assistant audio-capture application bundle
|
||||||
|
- **AND** macOS evaluates Microphone and Screen Recording/System Audio access against the bundle identifier `cloud.schweigert.meeting-assistant.audio-capture`
|
||||||
|
|
||||||
#### Scenario: macOS capture uses run audio format
|
#### Scenario: macOS capture uses run audio format
|
||||||
- **GIVEN** an active macOS recording configures a sample rate and channel count
|
- **GIVEN** an active macOS recording configures a sample rate and channel count
|
||||||
- **WHEN** the native capture helpers start
|
- **WHEN** the native capture helpers start
|
||||||
|
|||||||
Reference in New Issue
Block a user