Joining voice on the voice-chat preset was unreliable: audio arrived after several seconds of the route flipping back and forth, sometimes not at all, and VoiceOver went quiet while it happened. Device logs show why. A graph with voice processing enabled reports a successful start and is then torn down within a second, roughly three times in four; every configuration without voice processing — both microphone presets, and voice chat with processing off — comes up first time and runs indefinitely. With voice processing the input and output are one IO unit, and it only stays up while the input is part of the render chain. The input node carried a tap and no connection, which leaves it out of that chain. Route the input through a silent mixer so it is genuinely rendered. The rest of this is the amplifier rather than the cause, and each part of it turned one failed start into a storm: The stall watchdog rebuilt on every missed tick, without bound. That converted a graph that could not start into endless session reconfiguration, which is what the user heard and what hid the reason from the log. It now backs off after each failed attempt and stops after four, logging VC_WATCHDOG exhausted, so a transient freeze still recovers and a graph that will not start fails visibly. Nothing waited for a graph to start before judging it dead. Enabling voice processing rebuilds both halves of the IO, which posts a configuration change and reads as not running for several hundred milliseconds, so the configuration-change handler and the watchdog both tore down graphs that were about to run. A settling window holds them off for two seconds. A route change forced a full rebuild, and every rebuild moves the route, so one notification produced the next. Route changes now take the non-forcing path, which rebuilds a stopped graph and leaves a healthy one alone; the hardware test it uses reads the input node's format, not AVAudioSession, whose reported rate and channel count do not settle until after the graph has started. The input side is built once per session instead of being added when voice is joined, so joining and leaving voice set a stream id rather than replacing the graph, and a mono voice-chat apply no longer clears a stereo capsule configuration it never applied. Every rebuild now logs its cause, and VC_START/VC_START_CHECK record whether the graph survived its start. The first-attempt failure is not fixed and is recorded in PROGRESS.md as a release gate: capture still comes up on a watchdog rebuild rather than immediately. The changed logic sits on AVAudioSession and AVAudioEngine, which the net10.0 test project cannot reference, so the behaviour is covered by the existing source assertions; verification is on device.
331 lines
19 KiB
C#
331 lines
19 KiB
C#
using System.Diagnostics;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace VoiceCat.Tests;
|
|
|
|
public class PublishServerScriptTests
|
|
{
|
|
[Theory]
|
|
[InlineData("clients/windows/publish-client.ps1")]
|
|
[InlineData("scripts/publish-server.ps1")]
|
|
public async Task PublishUsesRegeneratedIntermediateRuntimeLock(string relativePath)
|
|
{
|
|
string script = await File.ReadAllTextAsync(Path.Combine(FindRoot(), relativePath.Replace('/', Path.DirectorySeparatorChar)));
|
|
|
|
Assert.Contains("NuGetLockFilePath=obj/packages.publish.", script);
|
|
Assert.Contains("RestoreLockedMode=false", script);
|
|
Assert.DoesNotContain("NuGetLockFilePath=packages.publish.", script);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LicenseAuditIgnoresGeneratedBuildLocks()
|
|
{
|
|
string script = await File.ReadAllTextAsync(Path.Combine(FindRoot(), "scripts", "check-licenses.ps1"));
|
|
|
|
Assert.Contains("(?:bin|obj)", script);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MacPublishPreservesAppEntitlementsAndReplacesBrokenWorkloadBundle()
|
|
{
|
|
string root = FindRoot();
|
|
string script = await File.ReadAllTextAsync(Path.Combine(root, "clients", "apple", "publish-macos.sh"));
|
|
string entitlements = await File.ReadAllTextAsync(Path.Combine(
|
|
root, "clients", "apple", "VoiceCat.Mac", "VoiceCat.Mac.entitlements"));
|
|
|
|
Assert.Contains("sign_nested_code", script);
|
|
Assert.Contains("--entitlements \"$entitlements\"", script);
|
|
Assert.Contains("install_verified_primary_bundle", script);
|
|
Assert.DoesNotContain("codesign --force --deep", script);
|
|
Assert.Contains("com.apple.security.cs.allow-jit", entitlements);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IosDeviceBuildUsesAnIntermediateDeviceLockAndScopesKnownTrimWarnings()
|
|
{
|
|
string root = FindRoot();
|
|
string script = await File.ReadAllTextAsync(Path.Combine(root, "clients", "apple", "build-ios-device.sh"));
|
|
string project = await File.ReadAllTextAsync(Path.Combine(
|
|
root, "clients", "apple", "VoiceCat.iOS", "VoiceCat.iOS.csproj"));
|
|
|
|
Assert.Contains("NuGetLockFilePath=\"$device_lock\"", script);
|
|
Assert.Contains("RestoreLockedMode=false", script);
|
|
Assert.Contains("<WarningsNotAsErrors>$(WarningsNotAsErrors);IL2104</WarningsNotAsErrors>", project);
|
|
Assert.DoesNotContain("<TreatWarningsAsErrors>false</TreatWarningsAsErrors>", project);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IosBroadcastExtensionPreservesSharedAppGroupAndSupportsDistributionSigning()
|
|
{
|
|
string root = FindRoot();
|
|
string entitlements = await File.ReadAllTextAsync(Path.Combine(
|
|
root, "native", "apple", "broadcast", "VoiceCatBroadcast.entitlements"));
|
|
string script = await File.ReadAllTextAsync(Path.Combine(
|
|
root, "clients", "apple", "VoiceCat.iOS", "build-broadcast-extension.sh"));
|
|
|
|
Assert.Contains("com.apple.security.application-groups", entitlements);
|
|
Assert.Contains("group.me.iamtalon.voicecat", entitlements);
|
|
Assert.Contains("CODE_SIGN_STYLE=Manual", script);
|
|
Assert.Contains("PROVISIONING_PROFILE_SPECIFIER=\"$VOICECAT_BROADCAST_CODESIGN_PROVISION\"", script);
|
|
// Both version placeholders must always be passed to xcodebuild. A conditional
|
|
// CURRENT_PROJECT_VERSION expands to an empty CFBundleVersion, which installd rejects.
|
|
Assert.Contains("CURRENT_PROJECT_VERSION=\"${VOICECAT_BUILD_NUMBER:-1}\"", script);
|
|
Assert.Contains("MARKETING_VERSION=\"${VOICECAT_DISPLAY_VERSION:-0.0.1}\"", script);
|
|
Assert.DoesNotContain("if [[ -n \"${VOICECAT_BUILD_NUMBER:-}\" ]]; then", script);
|
|
|
|
string deviceScript = await File.ReadAllTextAsync(Path.Combine(
|
|
root, "clients", "apple", "build-ios-device.sh"));
|
|
Assert.Contains("-p:ApplicationVersion=\"$VOICECAT_BUILD_NUMBER\"", deviceScript);
|
|
|
|
string appManifest = await File.ReadAllTextAsync(Path.Combine(
|
|
root, "clients", "apple", "VoiceCat.iOS", "Info.plist"));
|
|
string extensionManifest = await File.ReadAllTextAsync(Path.Combine(
|
|
root, "native", "apple", "broadcast", "Info.plist"));
|
|
Assert.DoesNotContain("<key>CFBundleVersion</key>", appManifest);
|
|
Assert.Contains("<string>$(CURRENT_PROJECT_VERSION)</string>", extensionManifest);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IosHostDeclaresItsAppIconAssetForAppStoreValidation()
|
|
{
|
|
string root = FindRoot();
|
|
string manifest = await File.ReadAllTextAsync(Path.Combine(
|
|
root, "clients", "apple", "VoiceCat.iOS", "Info.plist"));
|
|
string iconSet = await File.ReadAllTextAsync(Path.Combine(
|
|
root, "clients", "apple", "VoiceCat.iOS",
|
|
"Assets.xcassets", "AppIcon.appiconset", "Contents.json"));
|
|
|
|
// XSAppIconAssets is an app-manifest key, not an MSBuild property. Without it actool
|
|
// never receives --app-icon, so the bundle ships without CFBundleIconName and App Store
|
|
// Connect rejects the upload with errors 90022, 90023, and 90713.
|
|
Assert.Contains("<key>XSAppIconAssets</key>", manifest);
|
|
Assert.Contains("<string>Assets.xcassets/AppIcon.appiconset</string>", manifest);
|
|
Assert.Contains("AppIcon-1024.png", iconSet);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IosScreenSharingDeclaresBackgroundCaptureAndKeepsReplayKitFallback()
|
|
{
|
|
string root = FindRoot();
|
|
string manifest = await File.ReadAllTextAsync(Path.Combine(
|
|
root, "clients", "apple", "VoiceCat.iOS", "Info.plist"));
|
|
string settings = await File.ReadAllTextAsync(Path.Combine(
|
|
root, "clients", "apple", "VoiceCat.iOS", "SettingsController.cs"));
|
|
|
|
Assert.Contains("<string>screen-capture</string>", manifest);
|
|
Assert.Contains("if (IosScreenCapture.IsAvailable)", settings);
|
|
Assert.Contains("new RPSystemBroadcastPickerView", settings);
|
|
Assert.DoesNotContain("if (OperatingSystem.IsIOSVersionAtLeast(27)) { model.ToggleScreenAudio();", settings);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IosBroadcastPumpKeepsOneRingMappingAcrossTicks()
|
|
{
|
|
string root = FindRoot();
|
|
string pump = await File.ReadAllTextAsync(Path.Combine(
|
|
root, "clients", "apple", "VoiceCat.iOS", "BroadcastAudioPump.cs"));
|
|
|
|
// A mapping pair plus its container lookup and path strings per 5 ms tick allocated
|
|
// steadily at 200 Hz and churned the GC under long calls. The pump opens one mapping on
|
|
// the ring's transitions and reuses it; the producer never replaces the ring file.
|
|
Assert.Single(Regex.Matches(pump, "MemoryMappedFile.CreateFromFile"));
|
|
Assert.Contains("private bool OpenMapping()", pump);
|
|
Assert.Contains("private void CloseMapping()", pump);
|
|
Assert.Contains("Thread.Sleep(5)", pump);
|
|
Assert.Contains("File.Exists(path)", pump);
|
|
Assert.DoesNotContain("using MemoryMappedFile", pump);
|
|
Assert.DoesNotContain("using MemoryMappedViewAccessor", pump);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IosCaptureDoesNotDependOnCoalescedManagedTimers()
|
|
{
|
|
string root = FindRoot();
|
|
string microphone = await File.ReadAllTextAsync(Path.Combine(
|
|
root, "clients", "apple", "VoiceCat.iOS", "IosAudioEngine.cs"));
|
|
string screen = await File.ReadAllTextAsync(Path.Combine(
|
|
root, "clients", "apple", "VoiceCat.iOS", "BroadcastAudioPump.cs"));
|
|
|
|
Assert.DoesNotContain("PeriodicTimer", microphone);
|
|
Assert.Contains("owner.Audio.FeedPcm(route.StreamId", microphone);
|
|
Assert.Contains("new Thread(Run)", screen);
|
|
Assert.DoesNotContain("Task.Delay(10", screen);
|
|
Assert.Contains("MaximumCaptureCallbackFrames = 16_384", microphone);
|
|
Assert.DoesNotContain("Math.Ceiling(4_096 * 48_000", microphone);
|
|
|
|
// Both 20 ms hands-offs run from the AVAudioSourceNode render callback: sleep-paced
|
|
// threads coalesce when the app is backgrounded and glitch the call after several minutes.
|
|
string model = await File.ReadAllTextAsync(Path.Combine(
|
|
root, "clients", "apple", "VoiceCat.iOS", "AppModel.cs"));
|
|
Assert.DoesNotContain("new Thread(", microphone);
|
|
Assert.DoesNotContain("Thread.Sleep", microphone);
|
|
Assert.Contains("owner.Audio.RunCycle()", microphone);
|
|
Assert.Contains("while (microphoneCredit >= 960)", microphone);
|
|
Assert.Contains("deviceClockedAudio: true", model);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IosRecoversBackgroundAudioWithoutForegrounding()
|
|
{
|
|
string root = FindRoot();
|
|
string microphone = await File.ReadAllTextAsync(Path.Combine(
|
|
root, "clients", "apple", "VoiceCat.iOS", "IosAudioEngine.cs"));
|
|
string router = await File.ReadAllTextAsync(Path.Combine(
|
|
root, "clients", "apple", "VoiceCat.iOS", "IosAudioRouter.cs"));
|
|
|
|
// The render callback is the only clock for the device-clocked pipeline, so a graph that
|
|
// stops while backgrounded froze capture, mix and send until the app was foregrounded.
|
|
Assert.Contains("internal long RenderCallbacks", microphone);
|
|
Assert.Contains("internal bool IsRunning", microphone);
|
|
Assert.Contains("private void TickWatchdog()", router);
|
|
Assert.Contains("IosAudioEngine.Shared.RenderCallbacks", router);
|
|
Assert.Contains("IosAudioEngine.Shared.IsRunning", router);
|
|
// Bounded: a rebuild that does not restore the callbacks backs off and then stops, so a
|
|
// graph that cannot start fails visibly instead of churning the session forever.
|
|
Assert.Contains("IosAudioEngine.Shared.Reconfigure(true, $\"stall watchdog {watchdogAttempts}\")", router);
|
|
Assert.Contains("watchdogAttempts >= MaximumWatchdogAttempts", router);
|
|
Assert.Contains("VC_WATCHDOG exhausted", router);
|
|
|
|
// SetActive fails for as long as an interruption is in force, so Began suppresses
|
|
// recovery and the watchdog retries the rebuild that Ended asks for.
|
|
Assert.Contains("AVAudioSessionInterruptionType.Began", router);
|
|
Assert.Contains("interrupted = true", router);
|
|
Assert.Contains("internal void ResumeForeground()", router);
|
|
|
|
// A stalled render callback resynchronizes its own rings, and a refill is bounded so it
|
|
// cannot overrun the callback deadline that it is recovering from.
|
|
Assert.Contains("owner.Audio.ResynchronizeInputs()", microphone);
|
|
Assert.Contains("playbackRing.Resynchronize()", microphone);
|
|
Assert.Contains("Ring.Resynchronize()", microphone);
|
|
Assert.Contains("Math.Min(frames + playbackRing.TargetFrames - playbackRing.CountFrames, frames + 960)", microphone);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IosAudioCommitsInputRouteBeforeChannelsAndVoiceProcessingBeforeConnections()
|
|
{
|
|
string root = FindRoot();
|
|
string router = await File.ReadAllTextAsync(Path.Combine(root, "clients", "apple", "VoiceCat.iOS", "IosAudioRouter.cs"));
|
|
string engine = await File.ReadAllTextAsync(Path.Combine(root, "clients", "apple", "VoiceCat.iOS", "IosAudioEngine.cs"));
|
|
|
|
Assert.True(router.IndexOf("ApplyInputSelection(session)", StringComparison.Ordinal) <
|
|
router.IndexOf("SetActive(true", StringComparison.Ordinal));
|
|
Assert.DoesNotContain("session.SetPreferredInputNumberOfChannels", router);
|
|
Assert.DoesNotContain("MaximumInputNumberOfChannels", router);
|
|
Assert.Contains("SetPreferredInputOrientation(AVAudioStereoOrientation.Portrait", router);
|
|
Assert.Contains("SetPreferredInputOrientation(AVAudioStereoOrientation.None", router);
|
|
Assert.Contains("AVAudioSession.PolarPatternStereo", router);
|
|
Assert.Contains("SupportsStereoPolarPattern", router);
|
|
Assert.Contains("AdaptivePcmBuffer Ring = new(channels, 120, 65_536)", engine);
|
|
Assert.Contains("SelectedDataSourceId = null; SelectedPolarPattern = AVAudioDataSourcePolarPattern.Unknown", router);
|
|
Assert.Contains("ClearStereoPolarPattern(session)", router);
|
|
Assert.Contains("CaptureChannels == 2\n ? port.DataSources?.FirstOrDefault", router);
|
|
Assert.True(engine.IndexOf("SetVoiceProcessingEnabled", StringComparison.Ordinal) <
|
|
engine.IndexOf("next.Connect(source", StringComparison.Ordinal));
|
|
Assert.Contains("AVAudioEngine.ConfigurationChangeNotification", engine);
|
|
Assert.Contains("if (!IsConnected || !ReferenceEquals(engine, next)) return;", engine);
|
|
Assert.Contains("if (!next.Running || HardwareChanged()) Rebuild(\"configuration change\");", engine);
|
|
// With voice processing the input and output are one IO unit, and it only stays up while the
|
|
// input is rendered. A tap alone leaves it out of the chain and the unit dies seconds later.
|
|
Assert.Contains("next.Connect(input, captureSink, inputFormat", engine);
|
|
Assert.Contains("captureSink.OutputVolume = 0f;", engine);
|
|
// Capture exists for the session, so joining voice names a stream instead of rebuilding.
|
|
Assert.Contains("internal void StopMicrophone() { Volatile.Write(ref microphone, null); }", engine);
|
|
Assert.Contains("bool captures = IsConnected;", engine);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IosSpeakerOutputIsAnOrthogonalOneShotChoice()
|
|
{
|
|
string router = await File.ReadAllTextAsync(Path.Combine(
|
|
FindRoot(), "clients", "apple", "VoiceCat.iOS", "IosAudioRouter.cs"));
|
|
|
|
// Speaker output decides where audio goes, not how it is captured, so it must not take the
|
|
// preset with it, and no preset may clear it back.
|
|
Assert.Contains("ForceSpeaker = value; speakerIsExplicit = overridePending = true", router);
|
|
Assert.DoesNotContain("ForceSpeaker = value; Preset = IosAudioPreset.Advanced", router);
|
|
Assert.DoesNotContain("IosAudioPreset.VoiceChat) ForceSpeaker = false", router);
|
|
// The stored flag is honoured only once the user has actually chosen, so the value older
|
|
// installs inherited from the voice-chat preset cannot pin a headset user to the speaker.
|
|
Assert.Contains("cat.voice.audio.speakerIsExplicit", router);
|
|
// The port override is the toggle's one-shot request. Re-asserting it on every rebuild
|
|
// means fighting the system for the route, which the user sees as audio flipping.
|
|
Assert.Contains("if (overridePending)", router);
|
|
Assert.Contains("overridePending = false;", router);
|
|
// Every reconfiguration moves the route and moving the route notifies the handler, so a
|
|
// route change must never force a rebuild: that is one rebuild per notification, without
|
|
// end. The graph is rebuilt only when it stopped or when the hardware it was built around
|
|
// moved, which is the one thing AVAudioEngine cannot absorb by itself.
|
|
Assert.Contains("if (applying) return;", router);
|
|
Assert.Contains("Recover($\"route change ({reason})\", force: false)", router);
|
|
Assert.DoesNotContain("Recover($\"route change ({reason})\");", router);
|
|
|
|
string engine = await File.ReadAllTextAsync(Path.Combine(
|
|
FindRoot(), "clients", "apple", "VoiceCat.iOS", "IosAudioEngine.cs"));
|
|
// Asked of the graph's own input, not of AVAudioSession and not remembered from a route:
|
|
// the session's reported rate and channel count do not settle until after the graph has
|
|
// started, and CurrentRoute still names the previous route for a while, so either one
|
|
// reports a change that has not happened and every rebuild reports it again.
|
|
Assert.Contains("live.InputNode.GetBusOutputFormat(0)", engine);
|
|
Assert.Contains("format.SampleRate != builtInputRate || format.ChannelCount != builtInputChannels", engine);
|
|
Assert.DoesNotContain("AVAudioSession.SharedInstance().SampleRate", engine);
|
|
// Every rebuild names what asked for it, so a storm can be read from a log.
|
|
Assert.Contains("VC_REBUILD cause=", engine);
|
|
// Building a graph is itself what posts most configuration changes, and the engine reads as
|
|
// stopped until voice processing has rebuilt the IO. Nothing may judge a graph dead inside
|
|
// that window, or it replaces the graph it just built with one that reports the same thing.
|
|
Assert.Contains("internal bool Settling =>", engine);
|
|
Assert.Contains("if (!force && Settling) return;", engine);
|
|
Assert.Contains("if (Settling) return;", engine);
|
|
Assert.Contains("if (Settling) return true;", engine);
|
|
Assert.Contains("|| IosAudioEngine.Shared.Settling) { ResetWatchdog(); return; }", router);
|
|
Assert.Contains("&& !HardwareChanged()) return;", engine);
|
|
Assert.Contains("(!next.Running || HardwareChanged())", engine);
|
|
Assert.Contains("if (Preset == IosAudioPreset.VoiceChat) { SelectedInputId = null; SelectedDataSourceId = null; }", router);
|
|
Assert.DoesNotContain("SelectedInputId ??= session.PreferredInput", router);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DefaultPublishTargetsWindowsAndLinuxWhileRuntimeCanSelectOne()
|
|
{
|
|
string script = Path.Combine(FindRoot(), "scripts", "publish-server.ps1");
|
|
|
|
string allTargets = await RunWhatIf(script);
|
|
Assert.Contains("win-x64", allTargets);
|
|
Assert.Contains("linux-x64", allTargets);
|
|
|
|
string linuxOnly = await RunWhatIf(script, "-Runtime", "linux-x64");
|
|
Assert.Contains("linux-x64", linuxOnly);
|
|
Assert.DoesNotContain("win-x64", linuxOnly);
|
|
}
|
|
|
|
private static async Task<string> RunWhatIf(string script, params string[] arguments)
|
|
{
|
|
string powerShell = OperatingSystem.IsWindows() ? "powershell.exe" : "pwsh";
|
|
var start = new ProcessStartInfo(powerShell)
|
|
{
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
};
|
|
foreach (string argument in new[] { "-NoProfile", "-NonInteractive", "-File", script, "-WhatIf" }.Concat(arguments))
|
|
start.ArgumentList.Add(argument);
|
|
|
|
using Process process = Process.Start(start) ?? throw new InvalidOperationException("Could not start PowerShell.");
|
|
Task<string> stdout = process.StandardOutput.ReadToEndAsync();
|
|
Task<string> stderr = process.StandardError.ReadToEndAsync();
|
|
await process.WaitForExitAsync();
|
|
string output = await stdout + await stderr;
|
|
Assert.True(process.ExitCode == 0, output);
|
|
return output;
|
|
}
|
|
|
|
private static string FindRoot()
|
|
{
|
|
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
|
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "VoiceCat.slnx")))
|
|
directory = directory.Parent;
|
|
return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found.");
|
|
}
|
|
}
|