Keep managed iOS audio active in background
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s

This commit is contained in:
2026-09-19 20:09:00 +02:00
parent 9fc5598e8e
commit 42e3bbe14c
10 changed files with 112 additions and 15 deletions
+6 -2
View File
@@ -23,8 +23,12 @@ up instantly. Newest status at the top.
administration, persistent VAD/PTT/always-on voice controls, event feedback, advanced iOS
audio routing, reconnect restoration, and an always-visible voice bar. iOS 27 uses a small
dynamically loaded ScreenCaptureKit audio bridge; iOS 1826 retain ReplayKit, with both producers
feeding the frozen ring ABI. Added physical-device build/deploy wrappers and expanded the
managed administration round-trip test. Corrected the historical App Group and extension
feeding the frozen ring ABI. Scene lifecycle handling now preserves the active PlayAndRecord
graph in the background and recovers it on foreground activation, interruption, hardware-route
changes and media-service resets. The iOS 27 Settings action now switches ScreenCaptureKit audio
on and off, including cancellation of stale stream negotiation after rapid switching; the native
bridge also tears down its picker observer and active state. Added physical-device build/deploy
wrappers and expanded the managed administration round-trip test. Corrected the historical App Group and extension
bundle IDs. CI installs the iOS workload and builds both managed Apple clients. **Next:** run
the physical-device VoiceOver, route-change, background/lock and real multi-human call matrix;
retain the Swift app as release oracle until those observable gates pass.
+10 -2
View File
@@ -40,8 +40,16 @@ clients/apple/dotnet/deploy-ios-device.sh --device "My iPhone" --configuration D
The build stages the verified app at `dist/ios-managed-device/VoiceCat.iOS.app`. Pass
`--no-build` to the deployment script for quick reinstall cycles. On iOS 1826, screen audio
uses the retained ReplayKit extension. On iOS 27 and newer, the host uses a small dynamically
loaded ScreenCaptureKit bridge and writes the same versioned ring; this keeps one managed consumer and
allows the app to remain launchable on older supported systems.
loaded ScreenCaptureKit bridge and writes the same versioned ring; the Settings row toggles
that capture on and off. This keeps one managed consumer and allows the app to remain launchable
on older supported systems.
While joined to voice, the active `PlayAndRecord` session and audio engine remain running when
the scene backgrounds or the device locks, which keeps microphone capture, peer playback and the
screen-audio pump eligible for the declared `audio` background mode. Foreground activation,
hardware route changes, audio interruptions and media-service resets revalidate or rebuild the
audio graph. Validate this behavior on hardware: iOS simulator lifecycle transitions do not prove
background execution, lock-screen routing or Bluetooth recovery.
Profiles, TOFU state, passwords and the ReplayKit ring use the signed App Group `group.me.iamtalon.voicecat`; the managed client migrates the old app-private profile files on first use. The shared ring ABI is frozen in [`docs/broadcast-ring-format.md`](../../../docs/broadcast-ring-format.md).
@@ -32,6 +32,7 @@ internal sealed class AppModel
private uint restoreChannel;
private bool restoreMuted;
private bool restoreDeafened;
private bool backgrounded;
internal event Action? Changed;
internal event Action<ServerIdentityChallenge>? IdentityRequested;
@@ -44,6 +45,7 @@ internal sealed class AppModel
internal bool IsConnecting { get; private set; }
internal bool VoiceJoined => microphoneStream != 0;
internal bool ScreenSharing => broadcast?.IsActive == true;
internal bool IsBackgrounded => backgrounded;
internal float MicrophoneLevel { get; private set; }
internal string Status { get; private set; } = "Not connected";
internal uint CurrentChannelId { get; private set; }
@@ -55,6 +57,27 @@ internal sealed class AppModel
internal void Load() { profiles.Clear(); profiles.AddRange(storage.LoadProfiles()); settings.Load(); IosAudioRouter.Shared.Load(); Notify(); }
internal void Save() { storage.SaveProfiles(profiles); settings.Save(); }
internal void DidEnterBackground()
{
backgrounded = true; Save();
// Do not stop or rebuild AVAudioEngine here. With the `audio` background mode and an
// active PlayAndRecord session, capture, playback, media UDP, and screen-ring draining
// remain live while the scene is backgrounded or the device is locked.
}
internal void WillEnterForeground()
{
backgrounded = false;
IosAudioRouter.Shared.Recover("foreground");
}
internal void DidBecomeActive()
{
backgrounded = false;
IosAudioRouter.Shared.EnsureAudio("active scene");
Notify();
}
internal void UpsertProfile(ServerProfile profile, string? password)
{
int index = profiles.FindIndex(item => item.Id == profile.Id);
@@ -186,6 +209,17 @@ internal sealed class AppModel
microphoneStream = stream.StreamId; IosAudioEngine.Shared.StartMicrophone(stream.StreamId, channels); feedback.Play(SoundEvent.VoiceOn); Notify();
}
internal void ToggleScreenAudio()
{
if (!OperatingSystem.IsIOSVersionAtLeast(27)) throw new PlatformNotSupportedException("Use the ReplayKit broadcast picker on this iOS version.");
if (ScreenSharing) { IosScreenCapture.Stop(); broadcast?.RequestStop(); }
else
{
if (!IsConnected || CurrentChannelId == 0) throw new InvalidOperationException("Connect and join a channel before sharing screen audio.");
IosScreenCapture.Present();
}
}
internal void SetSelfAudio(bool muted, bool deafened) { client?.SetSelfAudioState(muted, deafened); Notify(); }
internal void SetPushToTalk(bool active)
{
@@ -13,6 +13,7 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
private VoiceCatClient? client;
private uint streamId;
private bool active;
private int generation;
private readonly short[] scratch = new short[Frame * 2];
internal event Action? Changed;
@@ -43,7 +44,13 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
VoiceCatClient owner = client ?? throw new IOException("Client disconnected.");
if (streamId == 0)
{
int startGeneration = Volatile.Read(ref generation);
StreamInfo stream = await owner.StartStreamAsync(StreamKind.StreamScreenAudio, "Screen audio", 2, token).ConfigureAwait(false);
if (startGeneration != Volatile.Read(ref generation) || view.ReadUInt32(16) == 0 || !ReferenceEquals(client, owner))
{
try { owner.StopStream(stream.StreamId); } catch (Exception exception) when (exception is IOException or InvalidOperationException) { }
return;
}
streamId = stream.StreamId; view.Write(32, view.ReadUInt64(24)); SetActive(true);
}
ulong write = view.ReadUInt64(24), read = view.ReadUInt64(32);
@@ -67,10 +74,12 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
try { client.StopStream(id); } catch (Exception exception) when (exception is IOException or InvalidOperationException) { }
}
internal void RequestStop() { Interlocked.Increment(ref generation); SetActive(false); }
private void SetActive(bool value) { if (active == value) return; active = value; Changed?.Invoke(); }
public async ValueTask DisposeAsync()
{
stop.Cancel(); try { await worker.ConfigureAwait(false); } catch (OperationCanceledException) { } StopStream(); SetActive(false); stop.Dispose();
stop.Cancel(); Interlocked.Increment(ref generation); try { await worker.ConfigureAwait(false); } catch (OperationCanceledException) { } StopStream(); SetActive(false); stop.Dispose();
}
}
@@ -43,6 +43,11 @@ internal sealed class IosAudioEngine
internal void StopMicrophone() { microphoneStream = 0; Rebuild(); }
internal void Reconfigure() { if (IsConnected) Rebuild(); }
internal bool EnsureRunning()
{
if (!IsConnected || engine?.Running == true) return true;
Rebuild(); return engine?.Running == true;
}
private void Rebuild()
{
@@ -33,9 +33,9 @@ internal sealed class IosAudioRouter
private IosAudioRouter()
{
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.RouteChangeNotification, _ => Recover());
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.RouteChangeNotification, HandleRouteChange);
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.InterruptionNotification, HandleInterruption);
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.MediaServicesWereResetNotification, _ => Recover());
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.MediaServicesWereResetNotification, _ => Recover("media services reset"));
}
internal void Load()
@@ -131,10 +131,38 @@ internal sealed class IosAudioRouter
private void Set(string key, string? value) { if (value is null) defaults.RemoveObject(key); else defaults.SetString(value, key); }
internal void Deactivate() => AVAudioSession.SharedInstance().SetActive(false, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out _);
private void Recover() { RefreshRoutes(); if (IosAudioEngine.Shared.IsConnected) UIApplication.SharedApplication.BeginInvokeOnMainThread(IosAudioEngine.Shared.Reconfigure); }
internal void EnsureAudio(string reason)
{
if (!IosAudioEngine.Shared.IsConnected) return;
try { IosAudioEngine.Shared.EnsureRunning(); }
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio recovery ({reason}) failed: {exception}"); }
}
internal void Recover(string reason)
{
RefreshRoutes();
if (!IosAudioEngine.Shared.IsConnected) return;
UIApplication.SharedApplication.BeginInvokeOnMainThread(() =>
{
try { IosAudioEngine.Shared.Reconfigure(); }
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio recovery ({reason}) failed: {exception}"); }
});
}
private void HandleRouteChange(NSNotification note)
{
NSNumber? value = note.UserInfo?[new NSString("AVAudioSessionRouteChangeReasonKey")] as NSNumber;
AVAudioSessionRouteChangeReason reason = (AVAudioSessionRouteChangeReason)(value?.UInt32Value ?? 0);
RefreshRoutes();
if (reason is AVAudioSessionRouteChangeReason.CategoryChange
or AVAudioSessionRouteChangeReason.Override
or AVAudioSessionRouteChangeReason.RouteConfigurationChange)
return;
Recover($"route change ({reason})");
}
private void HandleInterruption(NSNotification note)
{
NSNumber? type = note.UserInfo?[new NSString("AVAudioSessionInterruptionTypeKey")] as NSNumber;
if ((AVAudioSessionInterruptionType)(type?.UInt32Value ?? 0) == AVAudioSessionInterruptionType.Ended) Recover();
AVAudioSessionInterruptionType interruption = (AVAudioSessionInterruptionType)(type?.UInt32Value ?? 0);
if (interruption == AVAudioSessionInterruptionType.Ended) Recover("interruption ended");
}
}
@@ -11,9 +11,11 @@ internal static partial class IosScreenCapture
NSUrl? root = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup);
if (root?.Path is null) throw new InvalidOperationException("The VoiceCat App Group is unavailable.");
string path = Path.Combine(root.Path, "voicecat", "broadcast_audio.ring"); Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if (Available() == 0) throw new InvalidOperationException("Screen audio sharing is unavailable on this device."); PresentNative(path);
if (!IsAvailable) throw new InvalidOperationException("Screen audio sharing is unavailable on this device."); PresentNative(path);
}
internal static bool IsAvailable => OperatingSystem.IsIOSVersionAtLeast(27) && Available() != 0;
[LibraryImport("__Internal", EntryPoint = "vc_ios_screen_capture_available")]
private static partial int Available();
[LibraryImport("__Internal", EntryPoint = "vc_ios_screen_capture_present", StringMarshalling = StringMarshalling.Utf8)]
@@ -18,5 +18,11 @@ internal sealed class SceneDelegate : UIResponder, IUIWindowSceneDelegate
}
[Export("sceneDidEnterBackground:")]
public void DidEnterBackground(UIScene scene) => AppModel.Shared.Save();
public void DidEnterBackground(UIScene scene) => AppModel.Shared.DidEnterBackground();
[Export("sceneWillEnterForeground:")]
public void WillEnterForeground(UIScene scene) => AppModel.Shared.WillEnterForeground();
[Export("sceneDidBecomeActive:")]
public void DidBecomeActive(UIScene scene) => AppModel.Shared.DidBecomeActive();
}
@@ -21,7 +21,7 @@ internal sealed class SettingsController : UITableViewController
UITableViewCell cell = tableView.DequeueReusableCell("setting", path); cell.AccessoryView = null; cell.Accessory = UITableViewCellAccessory.None;
string title = path.Section switch
{
0 => path.Row switch { 0 => "Audio preset", 1 => "Speaker output", 2 => "Advanced audio", _ => "Share screen audio" },
0 => path.Row switch { 0 => "Audio preset", 1 => "Speaker output", 2 => "Advanced audio", _ => model.ScreenSharing ? "Stop sharing screen audio" : "Share screen audio" },
1 => path.Row switch { 0 => "Input mode", 1 => $"VAD threshold: {model.Settings.VadThreshold:0.000}", 2 => $"Microphone volume: {model.Settings.InputGain:P0}", _ => "Microphone noise reduction" },
2 => path.Row switch { 0 => "Event sounds", 1 => $"Sound volume: {model.Settings.EventVolume:P0}", 2 => "Speak events", 3 => "Own voice activity sounds", _ => "Push-to-talk cue" },
3 => "Manage accounts", _ => path.Row == 0 ? "Disconnect" : "VoiceCat 0.0.1"
@@ -54,7 +54,7 @@ internal sealed class SettingsController : UITableViewController
private void Slider(string title, float minimum, float maximum, float current, Action<float> changed) { var slider = new UISlider(new CoreGraphics.CGRect(16, 48, 238, 28)) { MinValue = minimum, MaxValue = maximum, Value = current, AccessibilityLabel = title }; UIAlertController alert = UIAlertController.Create(title, null, UIAlertControllerStyle.Alert); alert.View!.AddSubview(slider); alert.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); alert.AddAction(UIAlertAction.Create("Apply", UIAlertActionStyle.Default, _ => changed(slider.Value))); PresentViewController(alert, true, null); }
private void ShowScreenSharing()
{
if (OperatingSystem.IsIOSVersionAtLeast(27)) { IosScreenCapture.Present(); return; }
if (OperatingSystem.IsIOSVersionAtLeast(27)) { model.ToggleScreenAudio(); return; }
#pragma warning disable CA1422
var picker = new RPSystemBroadcastPickerView(new CoreGraphics.CGRect(0, 0, 60, 60)) { PreferredExtension = IosConstants.BroadcastExtension, ShowsMicrophoneButton = false };
#pragma warning restore CA1422
@@ -70,11 +70,12 @@ static SCContentSharingPicker *VCSharedPicker(void) {
SCContentSharingPickerConfiguration *configuration = [configurationClass new];
if (!picker || !configuration) return;
configuration.showsMicrophoneControl = NO; configuration.showsCameraControl = NO; picker.defaultConfiguration = configuration;
[picker addObserver:self]; picker.active = YES; [picker presentPickerUsingContentStyle:SCShareableContentStyleDisplay];
[picker removeObserver:self]; [picker addObserver:self]; picker.active = YES; [picker presentPickerUsingContentStyle:SCShareableContentStyleDisplay];
}
- (void)stop {
[self.stream stopCaptureWithCompletionHandler:^(__unused NSError *error) {}]; self.stream = nil; [self setRingActive:NO];
[self.stream stopCaptureWithCompletionHandler:^(__unused NSError *error) {}]; self.stream = nil;
SCContentSharingPicker *picker = VCSharedPicker(); [picker removeObserver:self]; picker.active = NO; [self setRingActive:NO];
}
- (void)contentSharingPicker:(SCContentSharingPicker *)picker didUpdateWithFilter:(SCContentFilter *)filter forStream:(SCStream *)stream API_AVAILABLE(ios(27.0)) {