Improve iOS voice stability and user audio controls
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s

This commit is contained in:
2026-09-22 22:07:00 +02:00
parent 7b0c003ad4
commit ea76d5157a
15 changed files with 460 additions and 68 deletions
+9 -2
View File
@@ -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 samples; the managed Apple binding requires native use of its otherwise-unmapped stereo polar
pattern constant. 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; 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, 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. 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 20/40/60 ms buffering, duration-aware DRED/FEC, automatic packet-loss feedback, and mismatched
input/output endpoints. input/output endpoints.
- Complete NVDA and VoiceOver navigation/announcement passes. - 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 - Exercise iOS background/lock, interruption, Bluetooth, route-change, ReplayKit, and iOS 27
ScreenCaptureKit paths on devices. Complete extended mono/stereo/voice-chat switching while ScreenCaptureKit paths on devices. Complete a 30-minute iOS call and Wi-Fi/cellular switching
joined, and verify Windows desktop/per-app stereo sharing. 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 - 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. 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. - Run the published Linux container and a 30-minute-or-longer server soak.
+9
View File
@@ -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`. ReplayKit extension require signing profiles with App Group `group.me.iamtalon.voicecat`.
Hardware validation must cover VoiceOver, background and lock behavior, interruptions, route Hardware validation must cover VoiceOver, background and lock behavior, interruptions, route
changes, Bluetooth, ReplayKit, and iOS 27 ScreenCaptureKit audio. 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 App Store builds use the same device builder with `--configuration Release`. Set
`VOICECAT_BUILD_NUMBER`, `VOICECAT_DISPLAY_VERSION`, `VOICECAT_DEVELOPMENT_TEAM`, the host `VOICECAT_BUILD_NUMBER`, `VOICECAT_DISPLAY_VERSION`, `VOICECAT_DEVELOPMENT_TEAM`, the host
@@ -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": {}
}
}
@@ -16,19 +16,32 @@ internal sealed class UsersController : UITableViewController
content.Text = user.Nickname + (user.Id == model.SelfUserId ? " (you)" : ""); 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.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}"; 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<UIAccessibilityCustomAction, bool>)(_ => { 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<UIAccessibilityCustomAction, bool>)(_ => { Open(user.Id); return true; })),
new("Private message", (Func<UIAccessibilityCustomAction, bool>)(_ => { NavigationController?.PushViewController(new PrivateConversationController(model, user.Id, user.Nickname), true); return true; }))
];
return cell;
} }
public override void RowSelected(UITableView tableView, NSIndexPath indexPath) public override void RowSelected(UITableView tableView, NSIndexPath indexPath)
{ {
User user = Visible[indexPath.Row]; tableView.DeselectRow(indexPath, true); if (user.Id == model.SelfUserId) return; User user = Visible[indexPath.Row]; tableView.DeselectRow(indexPath, true); if (user.Id == model.SelfUserId) return;
UIAlertController menu = UIAlertController.Create(user.Nickname, null, UIAlertControllerStyle.ActionSheet); Open(user.Id);
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);
} }
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)> get
{ ("Private message", () => PromptPrivate(user), false), ("Volume and noise reduction", () => NavigationController?.PushViewController(new PerUserTuningController(model, user), true), false) }; {
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.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.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)); if (permissions.CanMoveUsers || permissions.IsAdmin) result.Add(("Move to channel", () => NavigationController?.PushViewController(new MoveUserController(model, user), true), false));
@@ -40,7 +53,47 @@ internal sealed class UsersController : UITableViewController
} }
return result; 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 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<Task<GenericResult>> command) { try { GenericResult result = await model.RunAdminAsync(_ => command()); if (!result.Ok) throw new InvalidOperationException(result.Message); } catch (Exception exception) { UiHelpers.ShowError(this, exception); } } private async void Run(Func<Task<GenericResult>> 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 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; private bool muted, noise;
internal PerUserTuningController(AppModel model, User user) : base(user.Nickname) { this.model = model; this.user = user; } private User? User => model.Users.FirstOrDefault(value => value.Id == userId);
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; } } private StreamInfo? Stream => User?.Streams.FirstOrDefault(value => value.StreamId == streamId);
public override nint RowsInSection(UITableView tableView, nint section) => 3; 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; } 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); }
} }
+52 -14
View File
@@ -6,7 +6,7 @@ using Channel = Voicecat.V1.Channel;
namespace VoiceCat.iOS; 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 record ActivityEntry(DateTime Timestamp, string Text);
internal sealed class AppModel internal sealed class AppModel
@@ -104,6 +104,12 @@ internal sealed class AppModel
{ {
if (state == ClientConnectionState.Disconnected && next.ConnectionFailure is { } failure) if (state == ClientConnectionState.Disconnected && next.ConnectionFailure is { } failure)
Console.Error.WriteLine($"VoiceCat control connection failed: {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; client = next;
try try
@@ -118,7 +124,8 @@ internal sealed class AppModel
IosAudioEngine.Shared.StartListening(next); IosAudioEngine.Shared.StartListening(next);
broadcast = new(); broadcast.Changed += BroadcastChanged; broadcast.Start(next); broadcast = new(); broadcast.Changed += BroadcastChanged; broadcast.Start(next);
_ = PumpEventsAsync(next, lifetime.Token); _ = 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"); feedback.Play(SoundEvent.Login); feedback.Speak(restoring ? "Reconnected" : "Connected");
if (restoring && restoreChannel != 0) await RestoreSessionAsync(next); if (restoring && restoreChannel != 0) await RestoreSessionAsync(next);
Notify(); Notify();
@@ -127,7 +134,8 @@ internal sealed class AppModel
{ {
System.Diagnostics.Debug.WriteLine($"Connection failed: {exception}"); System.Diagnostics.Debug.WriteLine($"Connection failed: {exception}");
IsConnecting = false; Status = exception.Message; 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(); Notify();
if (restoring && !explicitDisconnect) ScheduleReconnect(); if (restoring && !explicitDisconnect) ScheduleReconnect();
else throw; else throw;
@@ -155,7 +163,10 @@ internal sealed class AppModel
if (envelope.TextMessage is { } text) if (envelope.TextMessage is { } text)
{ {
User? sender = owner.Users.FirstOrDefault(user => user.Id == text.SenderId); 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); if (messages.Count > 500) messages.RemoveAt(0);
bool self = text.SenderId == SelfUserId; bool self = text.SenderId == SelfUserId;
feedback.Play(text.Scope == TextScope.TextPrivate feedback.Play(text.Scope == TextScope.TextPrivate
@@ -174,14 +185,24 @@ internal sealed class AppModel
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
finally finally
{ {
if (ReferenceEquals(client, owner) && !explicitDisconnect) await HandleConnectionLostAsync(owner);
}
}
private async Task HandleConnectionLostAsync(VoiceCatClient owner)
{ {
CaptureRestoreState(owner); await StopSessionResourcesAsync(); client = null; Status = "Connection lost"; if (!ReferenceEquals(client, owner) || IsConnecting || explicitDisconnect) return;
feedback.Play(SoundEvent.ConnectionLost); feedback.Speak("Connection lost, reconnecting"); UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify); // 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(); ScheduleReconnect();
} }
}
}
internal async Task JoinChannelAsync(uint channelId, string password = "") internal async Task JoinChannelAsync(uint channelId, string password = "")
{ {
@@ -258,7 +279,18 @@ internal sealed class AppModel
{ {
ServerProfile? profile = connectedProfile; if (profile is null || explicitDisconnect) return; ServerProfile? profile = connectedProfile; if (profile is null || explicitDisconnect) return;
int delay = Math.Min(1 << Math.Min(reconnectAttempt++, 5), 30); 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(); private void Notify() => Changed?.Invoke();
@@ -274,7 +306,8 @@ internal sealed class AppModel
private void PollAudio() 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; (float level, bool talking) = owner.Audio.GetLocalLevel(stream); MicrophoneLevel = level;
if (++diagnosticPolls >= 20) 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} " + Console.Error.WriteLine($"VC_AUDIO {IosAudioEngine.Shared.CaptureDiagnostics()} cycles={audio.Cycles} starved={audio.StarvedCycles} " +
$"encoded={audio.EncodedPackets} packetDrops={audio.RejectedPackets} queued={audio.BufferedFrames}"); $"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); } if (talking != lastTalking)
UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify); {
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) private void HandleUserEvent(VoiceCatClient owner, UserEvent value)
@@ -328,7 +366,7 @@ internal sealed class AppModel
private async Task StopSessionResourcesAsync() 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(); } if (broadcast is { } pump) { broadcast = null; pump.Changed -= BroadcastChanged; await pump.DisposeAsync(); }
} }
} }
+6 -24
View File
@@ -24,9 +24,10 @@ internal sealed class IosAudioEngine
{ {
internal readonly uint StreamId = streamId; internal readonly uint StreamId = streamId;
internal readonly int Channels = channels; internal readonly int Channels = channels;
internal readonly PcmRing Ring = new(131_072); // Capture hardware and the managed 20 ms sender have independent clocks. Correct
internal bool Primed; // their small rate difference before the queue eventually reaches its hard edge.
internal int TargetFrames = 3; // 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 MicrophoneRoute? microphone;
private readonly short[] microphoneFrame = new short[960 * 2]; private readonly short[] microphoneFrame = new short[960 * 2];
@@ -77,13 +78,7 @@ internal sealed class IosAudioEngine
private static MicrophoneRoute CreateMicrophoneRoute(uint streamId, int channels) private static MicrophoneRoute CreateMicrophoneRoute(uint streamId, int channels)
{ {
var route = new MicrophoneRoute(streamId, Math.Clamp(channels, 1, 2)); return 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;
} }
internal bool EnsureRunning() internal bool EnsureRunning()
{ {
@@ -198,24 +193,11 @@ internal sealed class IosAudioEngine
if (route is not null && owner is not null) if (route is not null && owner is not null)
{ {
int required = 960 * route.Channels; int required = 960 * route.Channels;
int completeFrames = route.Ring.Count / required; if (route.Ring.Read(microphoneFrame.AsSpan(0, required)) == 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)) && ReferenceEquals(route, Volatile.Read(ref microphone)) &&
!owner.Audio.FeedPcm(route.StreamId, microphoneFrame.AsSpan(0, required), route.Channels)) !owner.Audio.FeedPcm(route.StreamId, microphoneFrame.AsSpan(0, required), route.Channels))
Interlocked.Increment(ref rejectedFeeds); Interlocked.Increment(ref rejectedFeeds);
} }
}
deadline += System.Diagnostics.Stopwatch.Frequency / 50; deadline += System.Diagnostics.Stopwatch.Frequency / 50;
while (true) while (true)
{ {
+61 -1
View File
@@ -116,14 +116,74 @@ internal sealed class ChatController : UIViewController
public override void ViewDidLoad() 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; 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 = ""; }; 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); 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(); 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() 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}")); 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)); 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<ChatEntry> 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;
}
}
@@ -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": {}
}
}
@@ -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": {}
}
}
+1 -1
View File
@@ -240,7 +240,7 @@ clean build regenerated the app manifests.
## Last verified release build ## 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 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 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 `get-task-allow=false`. The resulting IPA was packaged locally; Apple server-side upload
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"powershell": {
"version": "7.6.6",
"commands": [
"pwsh"
],
"rollForward": false
}
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ public sealed class AdaptivePcmBuffer
get => Volatile.Read(ref targetFrames) * 1000 / SampleRate; get => Volatile.Read(ref targetFrames) * 1000 / SampleRate;
set 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); Volatile.Write(ref targetFrames, value * SampleRate / 1000);
} }
} }
+16
View File
@@ -47,6 +47,8 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
try try
{ {
while (!stop.IsCancellationRequested) while (!stop.IsCancellationRequested)
{
try
{ {
if (Environment.TickCount64 >= nextKeepalive) if (Environment.TickCount64 >= nextKeepalive)
{ {
@@ -59,6 +61,14 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
int size = crypto.Encryptor.Encrypt(header, plain.AsSpan(0, length), packet); int size = crypto.Encryptor.Encrypt(header, plain.AsSpan(0, length), packet);
socket.Send(packet.AsSpan(0, size), SocketFlags.None); socket.Send(packet.AsSpan(0, size), SocketFlags.None);
} }
}
catch (SocketException exception) when (IsTransientNetworkError(exception))
{
// 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); Thread.Sleep(1);
} }
} }
@@ -77,6 +87,8 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
int length; int length;
try { length = await socket.ReceiveAsync(packet, SocketFlags.None, stop.Token).ConfigureAwait(false); } 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 (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 (!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.Keepalive && length == VoiceFrameHeader.Size) { bound.TrySetResult(); continue; }
if (candidate.Type != MediaFrameType.Voice || candidate.Codec != 0 || if (candidate.Type != MediaFrameType.Voice || candidate.Codec != 0 ||
@@ -89,6 +101,10 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
finally { bound.TrySetCanceled(); stop.Cancel(); } 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() public async ValueTask DisposeAsync()
{ {
stop.Cancel(); socket.Dispose(); stop.Cancel(); socket.Dispose();
+37
View File
@@ -102,6 +102,28 @@ public class AudioEngineTests
Assert.True(fullReads > 5_000); 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] [Fact]
public void OneCaptureMissDoesNotRestartTalkspurtButSustainedStarvationDoes() public void OneCaptureMissDoesNotRestartTalkspurtButSustainedStarvationDoes()
{ {
@@ -260,6 +282,21 @@ public class AudioEngineTests
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before); 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] [Fact]
public void JitterBacklogIsBoundedAndPlcEventuallyBecomesSilence() public void JitterBacklogIsBoundedAndPlcEventuallyBecomesSilence()
{ {
@@ -128,7 +128,7 @@ public class PublishServerScriptTests
Assert.Contains("SetPreferredInputOrientation(AVAudioStereoOrientation.None", router); Assert.Contains("SetPreferredInputOrientation(AVAudioStereoOrientation.None", router);
Assert.Contains("AVAudioSession.PolarPatternStereo", router); Assert.Contains("AVAudioSession.PolarPatternStereo", router);
Assert.Contains("SupportsStereoPolarPattern", 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("SelectedDataSourceId = null; SelectedPolarPattern = AVAudioDataSourcePolarPattern.Unknown", router);
Assert.Contains("ClearStereoPolarPattern(session)", router); Assert.Contains("ClearStereoPolarPattern(session)", router);
Assert.Contains("CaptureChannels == 2\n ? port.DataSources?.FirstOrDefault", router); Assert.Contains("CaptureChannels == 2\n ? port.DataSources?.FirstOrDefault", router);