From ea76d5157ac49422ed213d2433871f07a9e6016b Mon Sep 17 00:00:00 2001 From: Talon Date: Tue, 22 Sep 2026 22:07:00 +0200 Subject: [PATCH] Improve iOS voice stability and user audio controls --- PROGRESS.md | 11 ++- clients/apple/README.md | 9 +++ clients/apple/VoiceCat.Mac/packages.lock.json | 58 +++++++++++++ .../VoiceCat.iOS/AdministrationControllers.cs | 81 ++++++++++++++++--- clients/apple/VoiceCat.iOS/AppModel.cs | 68 ++++++++++++---- clients/apple/VoiceCat.iOS/IosAudioEngine.cs | 36 +++------ clients/apple/VoiceCat.iOS/MainControllers.cs | 62 +++++++++++++- .../apple/VoiceCat.iOS/packages.ios.lock.json | 59 ++++++++++++++ clients/apple/VoiceCat.iOS/packages.lock.json | 58 +++++++++++++ docs/apple-ios-release.md | 2 +- dotnet-tools.json | 13 +++ src/VoiceCat.Audio/AdaptivePcmBuffer.cs | 2 +- src/VoiceCat.Core/ClientMediaTransport.cs | 30 +++++-- tests/VoiceCat.Tests/AudioEngineTests.cs | 37 +++++++++ .../PublishServerScriptTests.cs | 2 +- 15 files changed, 460 insertions(+), 68 deletions(-) create mode 100644 clients/apple/VoiceCat.Mac/packages.lock.json create mode 100644 clients/apple/VoiceCat.iOS/packages.ios.lock.json create mode 100644 clients/apple/VoiceCat.iOS/packages.lock.json create mode 100644 dotnet-tools.json diff --git a/PROGRESS.md b/PROGRESS.md index 38e2441..a1013d0 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -25,6 +25,10 @@ verified on an iPhone 16 Pro Max with a two-channel AVAudioEngine input and dist samples; the managed Apple binding requires native use of its otherwise-unmapped stereo polar pattern constant. +The iOS user list now opens a remote-user detail view with independent tuning for each active +audio stream. Private messages are grouped into per-user conversations with direct access to the +same user and audio controls. + SQLite schema v4 persists DRED and the channel packet-loss mode. Manual loss remains the default; automatic Fast/Balanced/Stable modes measure each sender's authenticated UDP uplink at the server, cap the applied Opus hint at 30%, and feed it back over TLS. @@ -35,9 +39,12 @@ cap the applied Opus hint at 30%, and feed it back over TLS. 20/40/60 ms buffering, duration-aware DRED/FEC, automatic packet-loss feedback, and mismatched input/output endpoints. - Complete NVDA and VoiceOver navigation/announcement passes. +- Verify iOS remote-user tuning and private-conversation navigation with VoiceOver, including + multiple streams, users without active streams, and users who disconnect while a view is open. - Exercise iOS background/lock, interruption, Bluetooth, route-change, ReplayKit, and iOS 27 - ScreenCaptureKit paths on devices. Complete extended mono/stereo/voice-chat switching while - joined, and verify Windows desktop/per-app stereo sharing. + ScreenCaptureKit paths on devices. Complete a 30-minute iOS call and Wi-Fi/cellular switching + with voice restoration, plus extended mono/stereo/voice-chat switching while joined. Verify + Windows desktop/per-app stereo sharing. - Complete Developer ID signing/notarization. The iOS host and ReplayKit extension have been distribution-signed and packaged locally; upload the IPA for Apple's server-side validation. - Run the published Linux container and a 30-minute-or-longer server soak. diff --git a/clients/apple/README.md b/clients/apple/README.md index 7f3b3a6..24db6ef 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -25,6 +25,15 @@ For a physical iOS device, use `build-ios-device.sh` and `deploy-ios-device.sh`. ReplayKit extension require signing profiles with App Group `group.me.iamtalon.voicecat`. Hardware validation must cover VoiceOver, background and lock behavior, interruptions, route changes, Bluetooth, ReplayKit, and iOS 27 ScreenCaptureKit audio. +For iOS voice stability, leave a call joined with the microphone active for at least 30 minutes +and confirm speech stays clear and `VC_AUDIO` reports no growing `feedDrops`. While still joined, +toggle Wi-Fi off and on, switch between Wi-Fi and cellular, and confirm the app stays open, +reconnects, and restores the voice session. Repeat with mono, stereo, and voice processing. + +The iOS remote-user manual gate must also cover Users → user detail → independent microphone and +screen-audio gain/mute controls, microphone receive noise reduction, the no-active-stream state, +and Private Chats → conversation → User/audio settings. Repeat the navigation with VoiceOver and +disconnect the remote user while its detail and conversation views are open. App Store builds use the same device builder with `--configuration Release`. Set `VOICECAT_BUILD_NUMBER`, `VOICECAT_DISPLAY_VERSION`, `VOICECAT_DEVELOPMENT_TEAM`, the host diff --git a/clients/apple/VoiceCat.Mac/packages.lock.json b/clients/apple/VoiceCat.Mac/packages.lock.json new file mode 100644 index 0000000..d6305db --- /dev/null +++ b/clients/apple/VoiceCat.Mac/packages.lock.json @@ -0,0 +1,58 @@ +{ + "version": 1, + "dependencies": { + "net10.0-macos27.0": { + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" + }, + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, + "Google.Protobuf": { + "type": "Transitive", + "resolved": "3.36.1", + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==" + }, + "voicecat.audio": { + "type": "Project", + "dependencies": { + "VoiceCat.Codec": "[1.0.0, )", + "VoiceCat.Dsp": "[1.0.0, )", + "VoiceCat.Protocol": "[1.0.0, )" + } + }, + "voicecat.codec": { + "type": "Project" + }, + "voicecat.core": { + "type": "Project", + "dependencies": { + "VoiceCat.Audio": "[1.0.0, )", + "VoiceCat.Crypto": "[1.0.0, )" + } + }, + "voicecat.crypto": { + "type": "Project", + "dependencies": { + "BouncyCastle.Cryptography": "[2.6.2, )", + "VoiceCat.Protocol": "[1.0.0, )" + } + }, + "voicecat.dsp": { + "type": "Project" + }, + "voicecat.protocol": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.36.1, )" + } + } + }, + "net10.0-macos27.0/osx-arm64": {} + } +} \ No newline at end of file diff --git a/clients/apple/VoiceCat.iOS/AdministrationControllers.cs b/clients/apple/VoiceCat.iOS/AdministrationControllers.cs index 6794e14..8ef8ce4 100644 --- a/clients/apple/VoiceCat.iOS/AdministrationControllers.cs +++ b/clients/apple/VoiceCat.iOS/AdministrationControllers.cs @@ -16,19 +16,32 @@ internal sealed class UsersController : UITableViewController content.Text = user.Nickname + (user.Id == model.SelfUserId ? " (you)" : ""); content.SecondaryText = user.ServerDeafened ? "server deafened" : user.ServerMuted ? "server muted" : user.SelfDeafened ? "deafened" : user.SelfMicMuted ? "muted" : user.IsGuest ? "guest" : "account"; content.Image = UIImage.GetSystemImage(user.ServerMuted || user.SelfMicMuted ? "mic.slash.fill" : "mic.fill"); cell.ContentConfiguration = content; cell.AccessibilityLabel = $"{content.Text}, {content.SecondaryText}"; - if (user.Id != model.SelfUserId) cell.AccessibilityCustomActions = Actions(user).Select(value => new UIAccessibilityCustomAction(value.Title, (Func)(_ => { value.Run(); return true; }))).ToArray(); return cell; + cell.Accessory = user.Id == model.SelfUserId ? UITableViewCellAccessory.None : UITableViewCellAccessory.DisclosureIndicator; + if (user.Id != model.SelfUserId) cell.AccessibilityCustomActions = + [ + new("Open user details", (Func)(_ => { Open(user.Id); return true; })), + new("Private message", (Func)(_ => { NavigationController?.PushViewController(new PrivateConversationController(model, user.Id, user.Nickname), true); return true; })) + ]; + return cell; } public override void RowSelected(UITableView tableView, NSIndexPath indexPath) { User user = Visible[indexPath.Row]; tableView.DeselectRow(indexPath, true); if (user.Id == model.SelfUserId) return; - UIAlertController menu = UIAlertController.Create(user.Nickname, null, UIAlertControllerStyle.ActionSheet); - foreach ((string title, Action run, bool destructive) in Actions(user)) menu.AddAction(UIAlertAction.Create(title, destructive ? UIAlertActionStyle.Destructive : UIAlertActionStyle.Default, _ => run())); - menu.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); menu.PopoverPresentationController!.SourceView = (UIView?)tableView.CellAt(indexPath) ?? tableView; PresentViewController(menu, true, null); + Open(user.Id); } - private IReadOnlyList<(string Title, Action Run, bool Destructive)> Actions(User user) + private void Open(uint userId) => NavigationController?.PushViewController(new UserDetailController(model, userId), true); +} + +internal sealed class UserDetailController : FormController +{ + private readonly AppModel model; private readonly uint userId; + private User? User => model.Users.FirstOrDefault(value => value.Id == userId); + private IReadOnlyList<(string Title, Action Run, bool Destructive)> AdminActions { - Permissions permissions = model.Client?.Permissions ?? new(); var result = new List<(string, Action, bool)> - { ("Private message", () => PromptPrivate(user), false), ("Volume and noise reduction", () => NavigationController?.PushViewController(new PerUserTuningController(model, user), true), false) }; + get + { + User? user = User; if (user is null) return []; + Permissions permissions = model.Client?.Permissions ?? new(); var result = new List<(string, Action, bool)>(); if (permissions.CanKick || permissions.IsAdmin) result.Add(("Kick", () => PromptReason(user, false), true)); if (permissions.CanBan || permissions.IsAdmin) result.Add(("Ban", () => NavigationController?.PushViewController(new BanUserController(model, user), true), true)); if (permissions.CanMoveUsers || permissions.IsAdmin) result.Add(("Move to channel", () => NavigationController?.PushViewController(new MoveUserController(model, user), true), false)); @@ -39,8 +52,48 @@ internal sealed class UsersController : UITableViewController result.Add(("Permissions", () => NavigationController?.PushViewController(new PermissionsController(model, user), true), false)); } return result; + } } - private void PromptPrivate(User user) { UIAlertController prompt = UIAlertController.Create($"Message {user.Nickname}", null, UIAlertControllerStyle.Alert); prompt.AddTextField(field => field.AccessibilityLabel = "Private message"); prompt.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); prompt.AddAction(UIAlertAction.Create("Send", UIAlertActionStyle.Default, _ => model.SendText(prompt.TextFields?[0].Text ?? "", user.Id))); PresentViewController(prompt, true, null); } + internal UserDetailController(AppModel model, uint userId) : base(model.Users.FirstOrDefault(value => value.Id == userId)?.Nickname ?? $"User {userId}") + { this.model = model; this.userId = userId; model.Changed += Reload; } + public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "detail"); Reload(); } + public override nint NumberOfSections(UITableView tableView) => AdminActions.Count == 0 ? 2 : 3; + public override nint RowsInSection(UITableView tableView, nint section) => section switch + { 0 => Math.Max(User?.Streams.Count ?? 0, 1), 1 => 1, _ => AdminActions.Count }; + public override string? TitleForHeader(UITableView tableView, nint section) => section switch { 0 => "Audio streams", 1 => "Conversation", _ => "Administration" }; + public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) + { + User? user = User; + if (indexPath.Section == 0) + { + if (user is null || user.Streams.Count == 0) + { + UITableViewCell empty = TextCell(tableView, indexPath, "detail", user is null ? "User is offline" : "No active audio streams"); + empty.SelectionStyle = UITableViewCellSelectionStyle.None; empty.AccessibilityTraits |= UIAccessibilityTrait.NotEnabled; return empty; + } + StreamInfo stream = user.Streams[indexPath.Row]; string kind = stream.Kind == StreamKind.StreamMic ? "Microphone" : "Screen audio"; + (float Gain, bool Muted, bool NoiseReduction)? state = model.Client?.Audio.GetRemotePlayback(userId, stream.StreamId); + string status = state is null ? kind : $"{kind} · {state.Value.Gain:P0}{(state.Value.Muted ? " · muted" : "")}{(state.Value.NoiseReduction && stream.Kind == StreamKind.StreamMic ? " · noise reduction" : "")}"; + string displayName = string.IsNullOrWhiteSpace(stream.Label) ? kind : stream.Label; UITableViewCell cell = TextCell(tableView, indexPath, "detail", displayName, status); + cell.Accessory = UITableViewCellAccessory.DisclosureIndicator; cell.AccessibilityLabel = $"{displayName}, {status}"; return cell; + } + if (indexPath.Section == 1) + { + UITableViewCell cell = TextCell(tableView, indexPath, "detail", "Private message", user is null ? "User is offline" : null); + cell.Accessory = UITableViewCellAccessory.DisclosureIndicator; cell.UserInteractionEnabled = user is not null; if (user is null) cell.AccessibilityTraits |= UIAccessibilityTrait.NotEnabled; return cell; + } + (string title, _, bool destructive) = AdminActions[indexPath.Row]; UITableViewCell action = TextCell(tableView, indexPath, "detail", title); + if (destructive) { UIListContentConfiguration content = action.DefaultContentConfiguration; content.TextProperties.Color = UIColor.SystemRed; action.ContentConfiguration = content; } return action; + } + public override void RowSelected(UITableView tableView, NSIndexPath indexPath) + { + tableView.DeselectRow(indexPath, true); User? user = User; if (user is null) return; + if (indexPath.Section == 0 && user.Streams.Count > indexPath.Row) + NavigationController?.PushViewController(new PerUserTuningController(model, user.Id, user.Streams[indexPath.Row].StreamId), true); + else if (indexPath.Section == 1) NavigationController?.PushViewController(new PrivateConversationController(model, user.Id, user.Nickname), true); + else if (indexPath.Section == 2) AdminActions[indexPath.Row].Run(); + } + private void Reload() { Title = User?.Nickname ?? $"User {userId}"; if (IsViewLoaded) TableView.ReloadData(); } private void PromptReason(User user, bool ban) { UIAlertController prompt = UIAlertController.Create(ban ? "Ban user" : "Kick user", "Reason (optional)", UIAlertControllerStyle.Alert); prompt.AddTextField(field => field.AccessibilityLabel = "Reason"); prompt.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); prompt.AddAction(UIAlertAction.Create(ban ? "Ban" : "Kick", UIAlertActionStyle.Destructive, _ => Run(() => model.Client!.KickUserAsync(user.Id, prompt.TextFields?[0].Text ?? "")))); PresentViewController(prompt, true, null); } private async void Run(Func> command) { try { GenericResult result = await model.RunAdminAsync(_ => command()); if (!result.Ok) throw new InvalidOperationException(result.Message); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } } } @@ -85,11 +138,13 @@ internal sealed class BanUserController : UIViewController internal sealed class PerUserTuningController : FormController { - private readonly AppModel model; private readonly User user; private readonly UISlider gain = new() { MinValue = 0, MaxValue = 4, Value = 1 }; + private readonly AppModel model; private readonly uint userId, streamId; private readonly UISlider gain = new() { MinValue = 0, MaxValue = 4, Value = 1 }; private bool muted, noise; - internal PerUserTuningController(AppModel model, User user) : base(user.Nickname) { this.model = model; this.user = user; } - public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "tuning"); gain.AccessibilityLabel = $"Volume gain for {user.Nickname}"; gain.ValueChanged += (_, _) => Apply(); if (user.Streams.FirstOrDefault() is { } stream && model.Client?.Audio.GetRemotePlayback(user.Id, stream.StreamId) is { } state) { gain.Value = state.Gain; muted = state.Muted; noise = state.NoiseReduction; } } - public override nint RowsInSection(UITableView tableView, nint section) => 3; + private User? User => model.Users.FirstOrDefault(value => value.Id == userId); + private StreamInfo? Stream => User?.Streams.FirstOrDefault(value => value.StreamId == streamId); + internal PerUserTuningController(AppModel model, uint userId, uint streamId) : base("Audio tuning") { this.model = model; this.userId = userId; this.streamId = streamId; } + public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "tuning"); StreamInfo? stream = Stream; Title = stream?.Label ?? "Audio tuning"; gain.AccessibilityLabel = $"Volume gain for {User?.Nickname ?? $"User {userId}"}, {stream?.Label ?? "stream"}"; gain.ValueChanged += (_, _) => Apply(); if (model.Client?.Audio.GetRemotePlayback(userId, streamId) is { } state) { gain.Value = state.Gain; muted = state.Muted; noise = state.NoiseReduction; } } + public override nint RowsInSection(UITableView tableView, nint section) => Stream?.Kind == StreamKind.StreamMic ? 3 : 2; public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) { string title = indexPath.Row switch { 0 => "Gain", 1 => "Mute", _ => "Noise reduction" }; UITableViewCell cell = TextCell(tableView, indexPath, "tuning", title); if (indexPath.Row == 0) cell.AccessoryView = gain; else { int row = indexPath.Row; cell.AccessoryView = Switch(row == 1 ? muted : noise, title, (_, _) => { bool value = ((UISwitch)cell.AccessoryView!).On; if (row == 1) muted = value; else noise = value; Apply(); }); } return cell; } - private void Apply() { if (model.Client is not { } client) return; foreach (StreamInfo stream in user.Streams) client.Audio.SetRemotePlayback(user.Id, stream.StreamId, gain.Value, muted, noise); } + private void Apply() { if (model.Client is not { } client || Stream is null) return; client.Audio.SetRemotePlayback(userId, streamId, gain.Value, muted, noise); } } diff --git a/clients/apple/VoiceCat.iOS/AppModel.cs b/clients/apple/VoiceCat.iOS/AppModel.cs index 8b3842a..ee96bb7 100644 --- a/clients/apple/VoiceCat.iOS/AppModel.cs +++ b/clients/apple/VoiceCat.iOS/AppModel.cs @@ -6,7 +6,7 @@ using Channel = Voicecat.V1.Channel; namespace VoiceCat.iOS; -internal sealed record ChatEntry(DateTime Timestamp, string Sender, string Text, bool Private); +internal sealed record ChatEntry(DateTime Timestamp, uint SenderId, uint PeerUserId, string Sender, string Peer, string Text, bool Private); internal sealed record ActivityEntry(DateTime Timestamp, string Text); internal sealed class AppModel @@ -104,6 +104,12 @@ internal sealed class AppModel { if (state == ClientConnectionState.Disconnected && next.ConnectionFailure is { } failure) Console.Error.WriteLine($"VoiceCat control connection failed: {failure}"); + if (state == ClientConnectionState.Disconnected) + UIApplication.SharedApplication.BeginInvokeOnMainThread(async () => + { + try { await HandleConnectionLostAsync(next); } + catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Reconnect cleanup failed: {exception}"); } + }); }; client = next; try @@ -118,7 +124,8 @@ internal sealed class AppModel IosAudioEngine.Shared.StartListening(next); broadcast = new(); broadcast.Changed += BroadcastChanged; broadcast.Start(next); _ = PumpEventsAsync(next, lifetime.Token); - levelTimer?.Dispose(); levelTimer = new(_ => PollAudio(), null, 50, 50); + levelTimer?.Dispose(); levelTimer = new(_ => + UIApplication.SharedApplication.BeginInvokeOnMainThread(PollAudio), null, 50, 50); feedback.Play(SoundEvent.Login); feedback.Speak(restoring ? "Reconnected" : "Connected"); if (restoring && restoreChannel != 0) await RestoreSessionAsync(next); Notify(); @@ -127,7 +134,8 @@ internal sealed class AppModel { System.Diagnostics.Debug.WriteLine($"Connection failed: {exception}"); IsConnecting = false; Status = exception.Message; - await next.DisposeAsync(); if (ReferenceEquals(client, next)) client = null; + if (ReferenceEquals(client, next)) { client = null; await StopSessionResourcesAsync(); } + await next.DisposeAsync(); Notify(); if (restoring && !explicitDisconnect) ScheduleReconnect(); else throw; @@ -155,7 +163,10 @@ internal sealed class AppModel if (envelope.TextMessage is { } text) { User? sender = owner.Users.FirstOrDefault(user => user.Id == text.SenderId); - messages.Add(new(DateTime.Now, sender?.Nickname ?? $"User {text.SenderId}", text.Body, text.Scope == TextScope.TextPrivate)); + bool privateMessage = text.Scope == TextScope.TextPrivate; + uint peerUserId = privateMessage ? text.SenderId == SelfUserId ? text.TargetId : text.SenderId : 0; + User? peer = privateMessage ? owner.Users.FirstOrDefault(user => user.Id == peerUserId) : null; + messages.Add(new(DateTime.Now, text.SenderId, peerUserId, sender?.Nickname ?? $"User {text.SenderId}", peer?.Nickname ?? $"User {peerUserId}", text.Body, privateMessage)); if (messages.Count > 500) messages.RemoveAt(0); bool self = text.SenderId == SelfUserId; feedback.Play(text.Scope == TextScope.TextPrivate @@ -174,15 +185,25 @@ internal sealed class AppModel catch (OperationCanceledException) { } finally { - if (ReferenceEquals(client, owner) && !explicitDisconnect) - { - CaptureRestoreState(owner); await StopSessionResourcesAsync(); client = null; Status = "Connection lost"; - feedback.Play(SoundEvent.ConnectionLost); feedback.Speak("Connection lost, reconnecting"); UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify); - ScheduleReconnect(); - } + await HandleConnectionLostAsync(owner); } } + private async Task HandleConnectionLostAsync(VoiceCatClient owner) + { + if (!ReferenceEquals(client, owner) || IsConnecting || explicitDisconnect) return; + // A failed TLS reader changes State but leaves the event channel open until disposal. + // Handle that state change directly so a Wi-Fi transition cannot strand this session. + CaptureRestoreState(owner); + client = null; Status = "Connection lost"; + try { await StopSessionResourcesAsync(); } + catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio cleanup failed: {exception}"); } + try { await owner.DisposeAsync(); } + catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Connection cleanup failed: {exception}"); } + feedback.Play(SoundEvent.ConnectionLost); feedback.Speak("Connection lost, reconnecting"); Notify(); + ScheduleReconnect(); + } + internal async Task JoinChannelAsync(uint channelId, string password = "") { VoiceCatClient active = client ?? throw new InvalidOperationException("Not connected."); @@ -258,7 +279,18 @@ internal sealed class AppModel { ServerProfile? profile = connectedProfile; if (profile is null || explicitDisconnect) return; int delay = Math.Min(1 << Math.Min(reconnectAttempt++, 5), 30); - _ = Task.Run(async () => { try { await Task.Delay(TimeSpan.FromSeconds(delay), lifetime?.Token ?? default); await ConnectAsync(profile, restoring: true); } catch { } }); + CancellationToken token = lifetime?.Token ?? default; + _ = Task.Run(async () => + { + try { await Task.Delay(TimeSpan.FromSeconds(delay), token); } + catch (OperationCanceledException) { return; } + UIApplication.SharedApplication.BeginInvokeOnMainThread(async () => + { + if (token.IsCancellationRequested || explicitDisconnect) return; + try { await ConnectAsync(profile, restoring: true); } + catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Reconnect failed: {exception}"); } + }); + }); } private void Notify() => Changed?.Invoke(); @@ -274,7 +306,8 @@ internal sealed class AppModel private void PollAudio() { - VoiceCatClient? owner = client; uint stream = microphoneStream; if (owner is null || stream == 0) return; + VoiceCatClient? owner = client; uint stream = microphoneStream; + if (owner is null || owner.State != ClientConnectionState.Connected || stream == 0) return; (float level, bool talking) = owner.Audio.GetLocalLevel(stream); MicrophoneLevel = level; if (++diagnosticPolls >= 20) { @@ -283,8 +316,13 @@ internal sealed class AppModel Console.Error.WriteLine($"VC_AUDIO {IosAudioEngine.Shared.CaptureDiagnostics()} cycles={audio.Cycles} starved={audio.StarvedCycles} " + $"encoded={audio.EncodedPackets} packetDrops={audio.RejectedPackets} queued={audio.BufferedFrames}"); } - if (talking != lastTalking) { lastTalking = talking; owner.PublishStreamState(stream, talking); feedback.Play(talking ? SoundEvent.VoiceStart : SoundEvent.VoiceStop); } - UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify); + if (talking != lastTalking) + { + try { owner.PublishStreamState(stream, talking); } + catch (Exception exception) when (exception is IOException or InvalidOperationException or ObjectDisposedException) { return; } + lastTalking = talking; feedback.Play(talking ? SoundEvent.VoiceStart : SoundEvent.VoiceStop); + } + Notify(); } private void HandleUserEvent(VoiceCatClient owner, UserEvent value) @@ -328,7 +366,7 @@ internal sealed class AppModel private async Task StopSessionResourcesAsync() { - levelTimer?.Dispose(); levelTimer = null; IosAudioEngine.Shared.Stop(); microphoneStream = 0; MicrophoneLevel = 0; + levelTimer?.Dispose(); levelTimer = null; IosAudioEngine.Shared.Stop(); microphoneStream = 0; MicrophoneLevel = 0; lastTalking = false; if (broadcast is { } pump) { broadcast = null; pump.Changed -= BroadcastChanged; await pump.DisposeAsync(); } } } diff --git a/clients/apple/VoiceCat.iOS/IosAudioEngine.cs b/clients/apple/VoiceCat.iOS/IosAudioEngine.cs index ba17072..cd73a49 100644 --- a/clients/apple/VoiceCat.iOS/IosAudioEngine.cs +++ b/clients/apple/VoiceCat.iOS/IosAudioEngine.cs @@ -24,9 +24,10 @@ internal sealed class IosAudioEngine { internal readonly uint StreamId = streamId; internal readonly int Channels = channels; - internal readonly PcmRing Ring = new(131_072); - internal bool Primed; - internal int TargetFrames = 3; + // Capture hardware and the managed 20 ms sender have independent clocks. Correct + // their small rate difference before the queue eventually reaches its hard edge. + // RemoteIO can deliver capture in 100 ms bursts, so retain one burst of headroom. + internal readonly AdaptivePcmBuffer Ring = new(channels, 120, 65_536); } private MicrophoneRoute? microphone; private readonly short[] microphoneFrame = new short[960 * 2]; @@ -77,13 +78,7 @@ internal sealed class IosAudioEngine private static MicrophoneRoute CreateMicrophoneRoute(uint streamId, int channels) { - var route = new MicrophoneRoute(streamId, Math.Clamp(channels, 1, 2)); - // Physical RemoteIO capture is commonly delivered in 100 ms bursts. Starting its 20 ms - // pacer with only the VPIO-oriented 60 ms cushion guarantees several underruns after every - // mono/stereo rebuild before the adaptive path catches up. Prime one full burst plus one - // frame for non-VPIO routes; VPIO remains at the proven low-latency three-frame cushion. - route.TargetFrames = IosAudioRouter.Shared.UsesVoiceProcessing ? 3 : 6; - return route; + return new MicrophoneRoute(streamId, Math.Clamp(channels, 1, 2)); } internal bool EnsureRunning() { @@ -198,23 +193,10 @@ internal sealed class IosAudioEngine if (route is not null && owner is not null) { int required = 960 * route.Channels; - int completeFrames = route.Ring.Count / required; - if (!route.Primed) - { - if (completeFrames >= route.TargetFrames) route.Primed = true; - } - if (route.Primed) - { - if (completeFrames == 0) - { - route.Primed = false; - route.TargetFrames = Math.Min(route.TargetFrames + 1, 6); - } - else if (route.Ring.Read(microphoneFrame.AsSpan(0, required)) == required && - ReferenceEquals(route, Volatile.Read(ref microphone)) && - !owner.Audio.FeedPcm(route.StreamId, microphoneFrame.AsSpan(0, required), route.Channels)) - Interlocked.Increment(ref rejectedFeeds); - } + if (route.Ring.Read(microphoneFrame.AsSpan(0, required)) == required && + ReferenceEquals(route, Volatile.Read(ref microphone)) && + !owner.Audio.FeedPcm(route.StreamId, microphoneFrame.AsSpan(0, required), route.Channels)) + Interlocked.Increment(ref rejectedFeeds); } deadline += System.Diagnostics.Stopwatch.Frequency / 50; while (true) diff --git a/clients/apple/VoiceCat.iOS/MainControllers.cs b/clients/apple/VoiceCat.iOS/MainControllers.cs index 30d7c31..fe09772 100644 --- a/clients/apple/VoiceCat.iOS/MainControllers.cs +++ b/clients/apple/VoiceCat.iOS/MainControllers.cs @@ -116,14 +116,74 @@ internal sealed class ChatController : UIViewController public override void ViewDidLoad() { base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemBackground; log.Editable = false; log.Font = UIFont.PreferredBody; log.AccessibilityLabel = "Chat and activity timeline"; log.TranslatesAutoresizingMaskIntoConstraints = false; + NavigationItem.RightBarButtonItem = new("Private Chats", UIBarButtonItemStyle.Plain, (_, _) => NavigationController?.PushViewController(new PrivateChatsController(model), true)); UIButton send = UIButton.FromType(UIButtonType.System); send.SetTitle("Send", UIControlState.Normal); send.AccessibilityLabel = "Send message"; send.TranslatesAutoresizingMaskIntoConstraints = false; send.TouchUpInside += (_, _) => { model.SendText(compose.Text ?? ""); compose.Text = ""; }; compose.TranslatesAutoresizingMaskIntoConstraints = false; View.AddSubviews(log, compose, send); NSLayoutConstraint.ActivateConstraints([log.TopAnchor.ConstraintEqualTo(View.SafeAreaLayoutGuide.TopAnchor), log.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor, 12), log.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor, -12), compose.TopAnchor.ConstraintEqualTo(log.BottomAnchor, 8), compose.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor, 12), compose.BottomAnchor.ConstraintEqualTo(View.KeyboardLayoutGuide.TopAnchor, -8), send.LeadingAnchor.ConstraintEqualTo(compose.TrailingAnchor, 8), send.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor, -12), send.CenterYAnchor.ConstraintEqualTo(compose.CenterYAnchor), compose.WidthAnchor.ConstraintGreaterThanOrEqualTo(120)]); Refresh(); } private void Refresh() { - IEnumerable<(DateTime Time, string Text)> chat = model.Messages.Select(message => (message.Timestamp, $"{(message.Private ? "[private] " : "")}{message.Sender}: {message.Text}")); + IEnumerable<(DateTime Time, string Text)> chat = model.Messages.Where(message => !message.Private).Select(message => (message.Timestamp, $"{message.Sender}: {message.Text}")); IEnumerable<(DateTime Time, string Text)> activity = model.Activity.Select(value => (value.Timestamp, $"• {value.Text}")); log.Text = string.Join("\n", chat.Concat(activity).OrderBy(value => value.Time).Select(value => $"[{value.Time:t}] {value.Text}")); if (log.Text.Length > 0) log.ScrollRangeToVisible(new(log.Text.Length - 1, 1)); } } + +internal sealed class PrivateChatsController : UITableViewController +{ + private readonly AppModel model; + private IReadOnlyList<(uint Id, string Name, ChatEntry? Latest, bool Online)> Peers + { + get + { + var online = model.Users.Where(user => user.Id != model.SelfUserId).ToDictionary(user => user.Id); + var history = model.Messages.Where(message => message.Private).GroupBy(message => message.PeerUserId).ToDictionary(group => group.Key, group => group.OrderByDescending(message => message.Timestamp).First()); + return online.Keys.Concat(history.Keys).Distinct().Select(id => + { + online.TryGetValue(id, out User? user); history.TryGetValue(id, out ChatEntry? latest); + string name = user?.Nickname ?? latest?.Peer ?? $"User {id}"; return (Id: id, Name: name, Latest: latest, Online: user is not null); + }).OrderByDescending(peer => peer.Latest?.Timestamp ?? DateTime.MinValue).ThenBy(peer => peer.Name).ToArray(); + } + } + internal PrivateChatsController(AppModel model) { this.model = model; Title = "Private Chats"; model.Changed += Reload; } + public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "private-peer"); Reload(); } + public override nint RowsInSection(UITableView tableView, nint section) => Peers.Count; + public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) + { + (uint _, string name, ChatEntry? latest, bool online) = Peers[indexPath.Row]; UITableViewCell cell = tableView.DequeueReusableCell("private-peer", indexPath); var content = cell.DefaultContentConfiguration; + content.Text = name; content.SecondaryText = latest?.Text ?? "Start a conversation"; content.Image = UIImage.GetSystemImage(online ? "person.crop.circle.fill" : "person.crop.circle.badge.xmark"); cell.ContentConfiguration = content; + cell.Accessory = UITableViewCellAccessory.DisclosureIndicator; cell.AccessibilityLabel = $"{name}, {(online ? "online" : "offline")}, {content.SecondaryText}"; return cell; + } + public override void RowSelected(UITableView tableView, NSIndexPath indexPath) + { + (uint id, string name, _, _) = Peers[indexPath.Row]; tableView.DeselectRow(indexPath, true); NavigationController?.PushViewController(new PrivateConversationController(model, id, name), true); + } + private void Reload() { if (IsViewLoaded) { TableView.ReloadData(); TableView.BackgroundView = Peers.Count == 0 ? new UILabel { Text = "No other users or private conversations", TextAlignment = UITextAlignment.Center, AccessibilityLabel = "No other users or private conversations" } : null; } } +} + +internal sealed class PrivateConversationController : UIViewController +{ + private readonly AppModel model; private readonly uint peerUserId; private readonly string fallbackName; + private readonly UITextView transcript = new(); private readonly UITextField compose = UiHelpers.Field("Private message"); private readonly UIButton send = UIButton.FromType(UIButtonType.System); + private User? Peer => model.Users.FirstOrDefault(user => user.Id == peerUserId); + internal PrivateConversationController(AppModel model, uint peerUserId, string fallbackName) + { this.model = model; this.peerUserId = peerUserId; this.fallbackName = fallbackName; Title = fallbackName; model.Changed += Refresh; } + public override void ViewDidLoad() + { + base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemBackground; transcript.Editable = false; transcript.Font = UIFont.PreferredBody; transcript.AccessibilityLabel = $"Private conversation with {fallbackName}"; transcript.TranslatesAutoresizingMaskIntoConstraints = false; + compose.AccessibilityLabel = $"Message to {fallbackName}"; compose.TranslatesAutoresizingMaskIntoConstraints = false; + send.SetTitle("Send", UIControlState.Normal); send.AccessibilityLabel = $"Send message to {fallbackName}"; send.TranslatesAutoresizingMaskIntoConstraints = false; send.TouchUpInside += (_, _) => Send(); + NavigationItem.RightBarButtonItem = new("User", UIBarButtonItemStyle.Plain, (_, _) => NavigationController?.PushViewController(new UserDetailController(model, peerUserId), true)) { AccessibilityLabel = $"Audio and user settings for {fallbackName}" }; + View.AddSubviews(transcript, compose, send); + NSLayoutConstraint.ActivateConstraints([transcript.TopAnchor.ConstraintEqualTo(View.SafeAreaLayoutGuide.TopAnchor), transcript.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor, 12), transcript.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor, -12), compose.TopAnchor.ConstraintEqualTo(transcript.BottomAnchor, 8), compose.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor, 12), compose.BottomAnchor.ConstraintEqualTo(View.KeyboardLayoutGuide.TopAnchor, -8), send.LeadingAnchor.ConstraintEqualTo(compose.TrailingAnchor, 8), send.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor, -12), send.CenterYAnchor.ConstraintEqualTo(compose.CenterYAnchor), compose.WidthAnchor.ConstraintGreaterThanOrEqualTo(120)]); Refresh(); + } + private void Send() { string text = compose.Text ?? ""; if (string.IsNullOrWhiteSpace(text) || Peer is null) return; model.SendText(text, peerUserId); compose.Text = ""; } + private void Refresh() + { + if (!IsViewLoaded) return; string name = Peer?.Nickname ?? fallbackName; Title = name; + IEnumerable messages = model.Messages.Where(message => message.Private && message.PeerUserId == peerUserId).OrderBy(message => message.Timestamp); + transcript.Text = string.Join("\n", messages.Select(message => $"[{message.Timestamp:t}] {(message.SenderId == model.SelfUserId ? "You" : message.Sender)}: {message.Text}")); + if (transcript.Text.Length > 0) transcript.ScrollRangeToVisible(new(transcript.Text.Length - 1, 1)); + bool online = Peer is not null; compose.Enabled = online; send.Enabled = online; NavigationItem.RightBarButtonItem!.Enabled = online; + } +} diff --git a/clients/apple/VoiceCat.iOS/packages.ios.lock.json b/clients/apple/VoiceCat.iOS/packages.ios.lock.json new file mode 100644 index 0000000..3cb77eb --- /dev/null +++ b/clients/apple/VoiceCat.iOS/packages.ios.lock.json @@ -0,0 +1,59 @@ +{ + "version": 1, + "dependencies": { + "net10.0-ios27.0": { + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" + }, + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, + "Google.Protobuf": { + "type": "Transitive", + "resolved": "3.36.1", + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==" + }, + "voicecat.audio": { + "type": "Project", + "dependencies": { + "VoiceCat.Codec": "[1.0.0, )", + "VoiceCat.Dsp": "[1.0.0, )", + "VoiceCat.Protocol": "[1.0.0, )" + } + }, + "voicecat.codec": { + "type": "Project" + }, + "voicecat.core": { + "type": "Project", + "dependencies": { + "VoiceCat.Audio": "[1.0.0, )", + "VoiceCat.Crypto": "[1.0.0, )" + } + }, + "voicecat.crypto": { + "type": "Project", + "dependencies": { + "BouncyCastle.Cryptography": "[2.6.2, )", + "VoiceCat.Protocol": "[1.0.0, )" + } + }, + "voicecat.dsp": { + "type": "Project" + }, + "voicecat.protocol": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.36.1, )" + } + } + }, + "net10.0-ios27.0/ios-arm64": {}, + "net10.0-ios27.0/iossimulator-arm64": {} + } +} \ No newline at end of file diff --git a/clients/apple/VoiceCat.iOS/packages.lock.json b/clients/apple/VoiceCat.iOS/packages.lock.json new file mode 100644 index 0000000..32a40ac --- /dev/null +++ b/clients/apple/VoiceCat.iOS/packages.lock.json @@ -0,0 +1,58 @@ +{ + "version": 1, + "dependencies": { + "net10.0-ios27.0": { + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" + }, + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, + "Google.Protobuf": { + "type": "Transitive", + "resolved": "3.36.1", + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==" + }, + "voicecat.audio": { + "type": "Project", + "dependencies": { + "VoiceCat.Codec": "[1.0.0, )", + "VoiceCat.Dsp": "[1.0.0, )", + "VoiceCat.Protocol": "[1.0.0, )" + } + }, + "voicecat.codec": { + "type": "Project" + }, + "voicecat.core": { + "type": "Project", + "dependencies": { + "VoiceCat.Audio": "[1.0.0, )", + "VoiceCat.Crypto": "[1.0.0, )" + } + }, + "voicecat.crypto": { + "type": "Project", + "dependencies": { + "BouncyCastle.Cryptography": "[2.6.2, )", + "VoiceCat.Protocol": "[1.0.0, )" + } + }, + "voicecat.dsp": { + "type": "Project" + }, + "voicecat.protocol": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.36.1, )" + } + } + }, + "net10.0-ios27.0/iossimulator-arm64": {} + } +} \ No newline at end of file diff --git a/docs/apple-ios-release.md b/docs/apple-ios-release.md index bc34b6f..ce25c4a 100644 --- a/docs/apple-ios-release.md +++ b/docs/apple-ios-release.md @@ -240,7 +240,7 @@ clean build regenerated the app manifests. ## Last verified release build -On 2026-09-21, version `0.0.1`, build `2026092101` was built with .NET 10.0.401 and Xcode 27.0. +On 2026-09-22, version `0.0.1`, build `2026092202` was built with .NET 10.0.401 and Xcode 27.0. The host and ReplayKit extension passed strict nested-signature validation with App Store Connect profiles, matching distribution identities, matching versions, the shared App Group, and `get-task-allow=false`. The resulting IPA was packaged locally; Apple server-side upload diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..9e5fb58 --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "powershell": { + "version": "7.6.6", + "commands": [ + "pwsh" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/src/VoiceCat.Audio/AdaptivePcmBuffer.cs b/src/VoiceCat.Audio/AdaptivePcmBuffer.cs index b3e1f6b..9328272 100644 --- a/src/VoiceCat.Audio/AdaptivePcmBuffer.cs +++ b/src/VoiceCat.Audio/AdaptivePcmBuffer.cs @@ -28,7 +28,7 @@ public sealed class AdaptivePcmBuffer get => Volatile.Read(ref targetFrames) * 1000 / SampleRate; set { - if (value is not (20 or 40 or 60)) throw new ArgumentOutOfRangeException(nameof(value)); + if (value is not (20 or 40 or 60 or 120)) throw new ArgumentOutOfRangeException(nameof(value)); Volatile.Write(ref targetFrames, value * SampleRate / 1000); } } diff --git a/src/VoiceCat.Core/ClientMediaTransport.cs b/src/VoiceCat.Core/ClientMediaTransport.cs index 70f309e..bd0cbb1 100644 --- a/src/VoiceCat.Core/ClientMediaTransport.cs +++ b/src/VoiceCat.Core/ClientMediaTransport.cs @@ -48,16 +48,26 @@ internal sealed class ClientMediaTransport : IAsyncDisposable { while (!stop.IsCancellationRequested) { - if (Environment.TickCount64 >= nextKeepalive) + try { - if (!bound.Task.IsCompleted) socket.Send(binding, SocketFlags.None); - socket.Send(keepalive, SocketFlags.None); - nextKeepalive = Environment.TickCount64 + (bound.Task.IsCompleted ? 5000 : 250); + if (Environment.TickCount64 >= nextKeepalive) + { + if (!bound.Task.IsCompleted) socket.Send(binding, SocketFlags.None); + socket.Send(keepalive, SocketFlags.None); + nextKeepalive = Environment.TickCount64 + (bound.Task.IsCompleted ? 5000 : 250); + } + while (packets.TryRead(plain, out VoiceFrameHeader header, out int length)) + { + int size = crypto.Encryptor.Encrypt(header, plain.AsSpan(0, length), packet); + socket.Send(packet.AsSpan(0, size), SocketFlags.None); + } } - while (packets.TryRead(plain, out VoiceFrameHeader header, out int length)) + catch (SocketException exception) when (IsTransientNetworkError(exception)) { - int size = crypto.Encryptor.Encrypt(header, plain.AsSpan(0, length), packet); - socket.Send(packet.AsSpan(0, size), SocketFlags.None); + // iOS can briefly lose its UDP route while Wi-Fi and cellular switch. + // Keep the sender and socket alive so the next route can carry media. + nextKeepalive = 0; + Thread.Sleep(100); } Thread.Sleep(1); } @@ -77,6 +87,8 @@ internal sealed class ClientMediaTransport : IAsyncDisposable int length; try { length = await socket.ReceiveAsync(packet, SocketFlags.None, stop.Token).ConfigureAwait(false); } catch (SocketException exception) when (exception.SocketErrorCode is SocketError.ConnectionReset or SocketError.MessageSize) { continue; } + catch (SocketException exception) when (IsTransientNetworkError(exception)) + { await Task.Delay(100, stop.Token).ConfigureAwait(false); continue; } if (!VoiceFrameHeader.TryRead(packet.AsSpan(0, length), out var candidate)) continue; if (candidate.Type == MediaFrameType.Keepalive && length == VoiceFrameHeader.Size) { bound.TrySetResult(); continue; } if (candidate.Type != MediaFrameType.Voice || candidate.Codec != 0 || @@ -89,6 +101,10 @@ internal sealed class ClientMediaTransport : IAsyncDisposable finally { bound.TrySetCanceled(); stop.Cancel(); } } + private static bool IsTransientNetworkError(SocketException exception) => exception.SocketErrorCode is + SocketError.NetworkDown or SocketError.NetworkUnreachable or SocketError.HostUnreachable or + SocketError.AddressNotAvailable or SocketError.NotConnected or SocketError.ConnectionReset; + public async ValueTask DisposeAsync() { stop.Cancel(); socket.Dispose(); diff --git a/tests/VoiceCat.Tests/AudioEngineTests.cs b/tests/VoiceCat.Tests/AudioEngineTests.cs index ed48676..696ac32 100644 --- a/tests/VoiceCat.Tests/AudioEngineTests.cs +++ b/tests/VoiceCat.Tests/AudioEngineTests.cs @@ -102,6 +102,28 @@ public class AudioEngineTests Assert.True(fullReads > 5_000); } + [Theory] + [InlineData(-1000)] + [InlineData(1000)] + public void AdaptivePcmBufferKeepsBurstingIosCaptureBoundedOverLongCalls(int partsPerMillion) + { + var buffer = new AdaptivePcmBuffer(1, 120, 65_536); + short[] callback = new short[4_800], encoded = new short[960]; + long produced = 0; + int lateStarvation = 0; + for (long consumerFrame = 0; consumerFrame < 48_000L * 600; consumerFrame += 960) + { + while (produced / (1 + partsPerMillion / 1_000_000.0) <= consumerFrame) + { + Assert.True(buffer.TryWrite(callback)); + produced += callback.Length; + } + if (buffer.Read(encoded) == 0 && consumerFrame >= 48_000) lateStarvation++; + Assert.InRange(buffer.CountFrames, 0, 20_000); + } + Assert.Equal(0, lateStarvation); + } + [Fact] public void OneCaptureMissDoesNotRestartTalkspurtButSustainedStarvationDoes() { @@ -260,6 +282,21 @@ public class AudioEngineTests Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before); } + [Fact] + public void RemotePlaybackSettingsAreIndependentForEachUserStream() + { + StreamInfo microphone = Stream(); + StreamInfo screen = Stream(); screen.StreamId = 2; screen.Ssrc = 43; screen.Kind = StreamKind.StreamScreenAudio; screen.Label = "Screen audio"; + using var receive = new AudioEngine((_, _, _, _) => true, false); + receive.SetRemoteStreams([new() { Id = 7, ChannelId = 1, Streams = { microphone, screen } }], 1, 1); + + receive.SetRemotePlayback(7, microphone.StreamId, 0.5f, true, true); + receive.SetRemotePlayback(7, screen.StreamId, 1.75f, false, false); + + Assert.Equal((0.5f, true, true), receive.GetRemotePlayback(7, microphone.StreamId)); + Assert.Equal((1.75f, false, false), receive.GetRemotePlayback(7, screen.StreamId)); + } + [Fact] public void JitterBacklogIsBoundedAndPlcEventuallyBecomesSilence() { diff --git a/tests/VoiceCat.Tests/PublishServerScriptTests.cs b/tests/VoiceCat.Tests/PublishServerScriptTests.cs index 08280b3..5746e97 100644 --- a/tests/VoiceCat.Tests/PublishServerScriptTests.cs +++ b/tests/VoiceCat.Tests/PublishServerScriptTests.cs @@ -128,7 +128,7 @@ public class PublishServerScriptTests Assert.Contains("SetPreferredInputOrientation(AVAudioStereoOrientation.None", router); Assert.Contains("AVAudioSession.PolarPatternStereo", router); Assert.Contains("SupportsStereoPolarPattern", router); - Assert.Contains("route.TargetFrames = IosAudioRouter.Shared.UsesVoiceProcessing ? 3 : 6", engine); + 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);