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
@@ -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)) {