Improve iOS voice stability and user audio controls
Build and test / test (macos-latest) (push) Waiting to run
Build and test / test (ubuntu-24.04) (push) Waiting to run
Build and test / test (windows-latest) (push) Waiting to run
Build and test / apple-client (push) Waiting to run

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
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.
+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`.
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
@@ -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.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<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)
{
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<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
{
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); }
}
+53 -15
View File
@@ -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(); }
}
}
+9 -27
View File
@@ -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)
+61 -1
View File
@@ -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<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
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
+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;
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);
}
}
+23 -7
View File
@@ -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();
+37
View File
@@ -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()
{
@@ -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);