Improve iOS voice stability and user audio controls
This commit is contained in:
+9
-2
@@ -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.
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"powershell": {
|
||||
"version": "7.6.6",
|
||||
"commands": [
|
||||
"pwsh"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user