2 Commits
Author SHA1 Message Date
Talon f348574bc1 Fix iOS background audio pacing
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
Sleep-paced threads stall for hundreds of milliseconds when iOS coalesces a
backgrounded app's wakeups, and the deadline resets that discarded the deficit
left the capture backlog queued until its ring overflowed: regular dropouts
that worsen the longer the app stays backgrounded. Pace both 20 ms hands-offs
from the AVAudioSourceNode render callback instead (mix via Audio.RunCycle
with deviceClockedAudio, capture handoff from the ring), wake the UDP sender
on a queue signal instead of a 1 ms poll, and let stalled consumers drop their
backlog to the buffer target instead of ratcheting it.
2026-09-23 21:37:24 +02:00
Talon 1487f2673b Fix iOS app icon and extension bundle version validation
Declare XSAppIconAssets in the app manifest so actool receives --app-icon
and the bundle carries CFBundleIconName, and always pass both Xcode version
placeholders to the ReplayKit extension so an empty build number cannot
drop CFBundleVersion and fail installd.
2026-09-23 21:36:34 +02:00
13 changed files with 367 additions and 57 deletions
+5 -3
View File
@@ -42,9 +42,11 @@ cap the applied Opus hint at 30%, and feed it back over TLS.
- 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 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.
ScreenCaptureKit paths on devices. The background/lock gate keeps a call active for 15+ minutes
backgrounded and screen-locked with no periodic glitches and flat `VC_AUDIO` `feedDrops`/`starved`
counters (the render callback now paces the mix and the 20 ms capture handoff). 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.
+4 -1
View File
@@ -99,7 +99,10 @@ internal sealed class AppModel
explicitDisconnect = false; IsConnecting = true; connectedProfile = profile;
Status = restoring ? "Reconnecting…" : "Connecting…"; Notify();
lifetime?.Cancel(); lifetime?.Dispose(); lifetime = new();
VoiceCatClient next = new("VoiceCat-iOS", "0.0.1", storage.TofuPath);
// The AVAudioSourceNode render callback drives Audio.RunCycle and the 20 ms capture
// handoff; a sleep-paced audio worker stalls when iOS coalesces backgrounded wakeups and
// the call glitches after several minutes in the background.
VoiceCatClient next = new("VoiceCat-iOS", "0.0.1", storage.TofuPath, deviceClockedAudio: true);
next.ConnectionStateChanged += state =>
{
if (state == ClientConnectionState.Disconnected && next.ConnectionFailure is { } failure)
+1
View File
@@ -3,6 +3,7 @@
<plist version="1.0"><dict>
<key>CFBundleDisplayName</key><string>VoiceCat</string>
<key>CFBundleIdentifier</key><string>me.iamtalon.voicecat</string>
<key>XSAppIconAssets</key><string>Assets.xcassets/AppIcon.appiconset</string>
<key>LSRequiresIPhoneOS</key><true/>
<key>NSMicrophoneUsageDescription</key><string>VoiceCat needs microphone access to transmit your voice in channels.</string>
<key>UIBackgroundModes</key><array><string>audio</string><string>screen-capture</string></array>
+21 -26
View File
@@ -31,7 +31,7 @@ internal sealed class IosAudioEngine
}
private MicrophoneRoute? microphone;
private readonly short[] microphoneFrame = new short[960 * 2];
private readonly Thread microphonePump;
private int microphoneCredit;
private AVAudioFormat? microphoneFormat;
private AVAudioConverter? microphoneConverter;
private AVAudioPcmBuffer? convertedMicrophone;
@@ -43,12 +43,6 @@ internal sealed class IosAudioEngine
internal bool IsConnected { get; private set; }
internal int BufferMilliseconds { get => playbackRing.BufferMilliseconds; set => playbackRing.BufferMilliseconds = value; }
private IosAudioEngine()
{
microphonePump = new Thread(PumpMicrophone) { IsBackground = true, Name = "VoiceCat iOS microphone pacer", Priority = ThreadPriority.Highest };
microphonePump.Start();
}
internal void StartListening(VoiceCatClient owner)
{
Stop(); client = owner; IsConnected = true; owner.Audio.MixedPcm += ReceiveMixedPcm; Rebuild();
@@ -183,33 +177,20 @@ internal sealed class IosAudioEngine
status = AVAudioConverterInputStatus.NoDataNow; return null!;
}
private void PumpMicrophone()
{
long deadline = System.Diagnostics.Stopwatch.GetTimestamp();
while (true)
// One paced 20 ms handoff from the capture ring into the encoder. The render callback calls
// this at its demand rate: the ring absorbs RemoteIO's burst pattern and the small capture vs
// output clock difference, so the sender sees a steady 20 ms feed without a sleep-paced pacer
// thread to stall when iOS coalesces a backgrounded app's wakeups.
private void PumpMicrophoneChunk(VoiceCatClient owner)
{
MicrophoneRoute? route = Volatile.Read(ref microphone);
VoiceCatClient? owner = client;
if (route is not null && owner is not null)
{
if (route is null) return;
int required = 960 * route.Channels;
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)
{
long remaining = deadline - System.Diagnostics.Stopwatch.GetTimestamp();
if (remaining <= 0) break;
double milliseconds = remaining * 1000.0 / System.Diagnostics.Stopwatch.Frequency;
if (milliseconds > 2) Thread.Sleep(Math.Max(1, (int)milliseconds - 1)); else Thread.SpinWait(64);
}
if ((deadline - System.Diagnostics.Stopwatch.GetTimestamp()) * 1000.0 / System.Diagnostics.Stopwatch.Frequency < -100)
deadline = System.Diagnostics.Stopwatch.GetTimestamp();
}
}
private void ReceiveMixedPcm(ReadOnlySpan<short> pcm) => playbackRing.TryWrite(pcm);
@@ -217,6 +198,20 @@ internal sealed class IosAudioEngine
{
int frames = checked((int)frameCount), requested = checked(frames * 2);
if (requested > renderScratch.Length) return -1;
// This callback is the cadence iOS keeps exact while the app is backgrounded or the device
// is locked, so it owns both managed 20 ms hands-offs: capture into the sender and one mix
// cycle per 20 ms of render demand. Both run allocation-free and without locks or I/O.
VoiceCatClient? owner = Volatile.Read(ref client);
if (owner is null || !IsConnected) microphoneCredit = 0;
else
{
microphoneCredit += frames;
while (microphoneCredit >= 960) { microphoneCredit -= 960; PumpMicrophoneChunk(owner); }
// Top the mix ring up to cover this callback plus its configured target so the read
// below never starves and the buffer keeps its chosen buffering latency.
int deficit = frames + playbackRing.TargetFrames - playbackRing.CountFrames;
for (int produced = 0; produced < deficit; produced += 960) owner.Audio.RunCycle();
}
Span<short> input = renderScratch.AsSpan(0, requested);
int read = playbackRing.Read(input); input[read..].Clear();
int count = Marshal.ReadInt32(outputData), first = IntPtr.Size == 8 ? 8 : 4, stride = IntPtr.Size == 8 ? 16 : 12;
@@ -10,10 +10,14 @@ project="$script_dir/../../../native/apple/broadcast/VoiceCatBroadcast.xcodeproj
mkdir -p "$output"
signing=()
versioning=(MARKETING_VERSION="${VOICECAT_DISPLAY_VERSION:-0.0.1}")
if [[ -n "${VOICECAT_BUILD_NUMBER:-}" ]]; then
versioning+=(CURRENT_PROJECT_VERSION="$VOICECAT_BUILD_NUMBER")
fi
# Both keys must always be set. The extension manifest expands these placeholders, and an
# empty CURRENT_PROJECT_VERSION drops CFBundleVersion, which installd rejects. The defaults
# match ApplicationDisplayVersion/ApplicationVersion in VoiceCat.iOS.csproj so that the host
# and extension versions agree when no release build number is supplied.
versioning=(
MARKETING_VERSION="${VOICECAT_DISPLAY_VERSION:-0.0.1}"
CURRENT_PROJECT_VERSION="${VOICECAT_BUILD_NUMBER:-1}"
)
if [[ "$sdk" == "iphonesimulator" ]]; then
signing+=(CODE_SIGNING_ALLOWED=NO)
elif [[ -n "${VOICECAT_BROADCAST_CODESIGN_KEY:-}" && -n "${VOICECAT_BROADCAST_CODESIGN_PROVISION:-}" ]]; then
+36 -5
View File
@@ -162,8 +162,10 @@ Before packaging, confirm:
- both entitlements contain the shared App Group;
- both have `get-task-allow=false` and `beta-reports-active=true`;
- the bundle identifiers are the stable identifiers above;
- both version pairs match; and
- both executables contain `arm64`.
- both version pairs match;
- both executables contain `arm64`; and
- the host `Info.plist` contains `CFBundleIconName` and the bundle contains both
`AppIcon60x60@2x.png` (120x120) and `AppIcon76x76@2x~ipad.png` (152x152).
Also decode each embedded profile and verify its name, UUID, application identifier, and
`get-task-allow` value:
@@ -232,6 +234,34 @@ Xcode command-line builds can report a missing `Xcode-Token` even when the GUI d
account. Manual App Store Connect profiles avoid that dependency and make the two-target signing
inputs explicit.
### Missing app icon or `CFBundleIconName`
App Store Connect rejects uploads with errors 90022, 90023, and 90713 when the host bundle has
no `CFBundleIconName`. Local `codesign` cannot catch this; only upload validation does.
The asset catalog alone is not enough. `Info.plist` must name it:
```xml
<key>XSAppIconAssets</key><string>Assets.xcassets/AppIcon.appiconset</string>
```
This is an **app-manifest key**, not an MSBuild property. The `ReadAppManifest` task reads
`XSAppIconAssets` out of `Info.plist` and passes `--app-icon` to `actool`; setting a csproj
property of the same name has no effect. The build strips the key from the shipped manifest.
Without it, `actool` still compiles the catalog but emits an empty
`obj/<config>/<tfm>/<rid>/actool/partial-info.plist`, so no icon keys reach the app. Verify the
build inputs rather than the bundle, since stale intermediates can leave icon PNGs from an
earlier build in place while the manifest has no icon keys:
```bash
cat clients/apple/VoiceCat.iOS/obj/Release/net10.0-ios27.0/ios-arm64/actool/partial-info.plist
```
A correct build writes `CFBundleIcons` and `CFBundleIcons~ipad` there. A single 1024x1024
universal entry in `AppIcon.appiconset` is sufficient; `actool` derives the 120x120 and 152x152
variants.
### App and extension versions differ
Apple validates nested bundle metadata. Confirm the host does not hard-code version keys, the
@@ -240,8 +270,9 @@ clean build regenerated the app manifests.
## Last verified release build
On 2026-09-22, version `0.0.1`, build `2026092202` was built with .NET 10.0.401 and Xcode 27.0.
On 2026-09-22, version `0.0.1`, build `2026092203` 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
validation remains a separate gate.
`get-task-allow=false`. The host carries `CFBundleIconName` with the 120x120 and 152x152 icons.
The resulting IPA was packaged locally; Apple server-side upload validation remains a separate
gate.
+27 -5
View File
@@ -101,6 +101,28 @@ be a shell sandbox or keychain-access artifact. Run the identity check from an o
session before changing certificates. A successful build prints the selected Apple Development
identity, provisioning profile, bundle ID, and app ID before linking.
### Install fails with `MissingBundleVersion`
CoreDevice error 3002 with `does not have a CFBundleVersion key with a non-zero length string
value` means the ReplayKit extension was built without `CURRENT_PROJECT_VERSION`. The extension
manifest expands that placeholder, and an empty value drops `CFBundleVersion` entirely, which
installd rejects.
`build-broadcast-extension.sh` now always passes both version placeholders, defaulting to `1`
and `0.0.1` to match `ApplicationVersion` and `ApplicationDisplayVersion` in the csproj, so a
plain Debug deployment needs no release environment variables. If this reappears, confirm the
script is not making `CURRENT_PROJECT_VERSION` conditional again, and check the staged bundle:
```bash
/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' \
dist/ios-managed-device/VoiceCat.iOS.app/PlugIns/VoiceCatBroadcast.appex/Info.plist
```
### Install fails on a dropped connection
CoreDevice error 4000 with `Connection reset by peer` is a transport failure, not a bundle or
signing problem. Rerun the deployment with `--no-build`.
### Developer disk image cannot be mounted
CoreDevice errors 10003 or 12040 mean the phone locked. Unlock it, keep the display awake, and
@@ -113,8 +135,8 @@ listing devices or processes do not imply that an already-confirmed install or l
## Verified hardware result
On 2026-09-21, the Debug build completed with .NET 10.0.401 and Xcode 27.0. The host and
ReplayKit extension were signed under team `FJV8L966W4`, installed wirelessly on Talon's iPhone,
and launched as `me.iamtalon.voicecat`; the running UI was confirmed on the device. Audio,
background/lock behavior, Bluetooth, ReplayKit, ScreenCaptureKit, and VoiceOver remain separate
manual hardware gates.
On 2026-09-22, the Debug build completed with .NET 10.0.401 and Xcode 27.0. The host and
ReplayKit extension were signed under team `FJV8L966W4` with matching versions, installed on
Talon's iPhone (iPhone 16 Pro Max), and launched as `me.iamtalon.voicecat`; the process was
confirmed running on the device. Audio, background/lock behavior, Bluetooth, ReplayKit,
ScreenCaptureKit, and VoiceOver remain separate manual hardware gates.
+14
View File
@@ -23,6 +23,7 @@ public sealed class AdaptivePcmBuffer
public int Channels => channels;
public int CountFrames => unchecked(Volatile.Read(ref writtenFrame) - Volatile.Read(ref readFrame));
public int TargetFrames => Volatile.Read(ref targetFrames);
public int BufferMilliseconds
{
get => Volatile.Read(ref targetFrames) * 1000 / SampleRate;
@@ -50,6 +51,19 @@ public sealed class AdaptivePcmBuffer
finally { Volatile.Write(ref producer, 0); }
}
// A consumer that stalls leaves its producer backlog queued at a fixed offset forever: feed
// and drain rates are equal in steady state, so the correction above only sheds roughly half
// a percent at a time and the queue ratchets toward its hard edge one stall at a time. The
// consumer calls this after a detected stall so the backlog becomes one bounded gap instead
// of accumulating; queued audio beyond the target is dropped and the next read re-primes.
public void Resynchronize()
{
int written = Volatile.Read(ref writtenFrame), available = unchecked(written - Volatile.Read(ref readFrame));
int target = Volatile.Read(ref targetFrames);
if (available > target) Volatile.Write(ref readFrame, unchecked(written - target));
primed = false; phase = 0;
}
// Returns interleaved samples written. A zero return means the caller should treat the
// already-cleared destination as silence. Once primed, short scheduling stalls re-prime
// instead of repeatedly clicking at the ring edge.
+18
View File
@@ -166,6 +166,17 @@ public sealed class AudioEngine : IDisposable
Volatile.Write(ref completedEpoch, current.Epoch);
}
// Device-clocked pull entry for platforms whose audio callback is the only reliable cadence:
// iOS keeps render callbacks running in the background while sleep-paced threads coalesce
// there. The device callback owns this call, so failures are contained instead of thrown and
// later cycles stay inert. Constructed with startWorker: false, the caller alone paces it.
public void RunCycle()
{
if (Failure is not null || Volatile.Read(ref disposed) != 0) return;
try { ProcessCycle(); }
catch (Exception exception) { Failure = exception; stop.Cancel(); }
}
private void Work()
{
long deadline = Stopwatch.GetTimestamp();
@@ -177,7 +188,14 @@ public sealed class AudioEngine : IDisposable
deadline += Stopwatch.Frequency / 50;
WaitUntil(deadline);
if ((deadline - Stopwatch.GetTimestamp()) * 1000.0 / Stopwatch.Frequency < -100)
{
// A stall this long cannot be caught up in place. Discarding only the schedule
// deficit would leave the producer backlog queued at a fixed offset forever and
// eventually overflow its ring, so drop back to the buffer targets instead: one
// bounded gap per stall rather than growing latency and feed drops.
deadline = Stopwatch.GetTimestamp();
foreach (LocalStream stream in Volatile.Read(ref routes).Local) stream.Input.Resynchronize();
}
}
}
catch (Exception exception) { Failure = exception; stop.Cancel(); }
+26 -4
View File
@@ -19,6 +19,8 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
private readonly Thread sending;
private readonly Task receiving;
private readonly TaskCompletionSource bound = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly AutoResetEvent sendReady = new(false);
private int senderNapping;
internal event EncodedVoiceHandler? Received;
internal Task Bound => bound.Task;
@@ -38,7 +40,14 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
sending.Start();
}
internal bool TrySend(VoiceFrameHeader header, ReadOnlySpan<byte> payload) => packets.TryWrite(header, payload);
internal bool TrySend(VoiceFrameHeader header, ReadOnlySpan<byte> payload)
{
if (!packets.TryWrite(header, payload)) return false;
// Wake the sender only when it is actually waiting. The flag is raised before its final
// queue check, so a write can never slip between that check and the wait unnoticed.
if (Volatile.Read(ref senderNapping) != 0) sendReady.Set();
return true;
}
private void Send()
{
@@ -48,6 +57,7 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
{
while (!stop.IsCancellationRequested)
{
bool drained = false;
try
{
if (Environment.TickCount64 >= nextKeepalive)
@@ -58,6 +68,7 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
}
while (packets.TryRead(plain, out VoiceFrameHeader header, out int length))
{
drained = true;
int size = crypto.Encryptor.Encrypt(header, plain.AsSpan(0, length), packet);
socket.Send(packet.AsSpan(0, size), SocketFlags.None);
}
@@ -69,7 +80,17 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
nextKeepalive = 0;
Thread.Sleep(100);
}
Thread.Sleep(1);
if (drained) continue;
// Wait on the queue's signal instead of polling every millisecond. A backgrounded
// iOS app coalesces timed sleeps, and a polling sender then flushes queued voice in
// bursts; a signalled wake is immediate. The keepalive deadline bounds the wait.
Volatile.Write(ref senderNapping, 1);
try
{
if (packets.IsEmpty && Environment.TickCount64 < nextKeepalive)
sendReady.WaitOne(checked((int)Math.Clamp(nextKeepalive - Environment.TickCount64, 1, 5000)));
}
finally { Volatile.Write(ref senderNapping, 0); }
}
}
catch (Exception exception) when (exception is SocketException or ObjectDisposedException)
@@ -107,9 +128,9 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
public async ValueTask DisposeAsync()
{
stop.Cancel(); socket.Dispose();
stop.Cancel(); socket.Dispose(); sendReady.Set();
try { sending.Join(); await receiving.ConfigureAwait(false); }
finally { System.Security.Cryptography.CryptographicOperations.ZeroMemory(binding); stop.Dispose(); }
finally { System.Security.Cryptography.CryptographicOperations.ZeroMemory(binding); stop.Dispose(); sendReady.Dispose(); }
}
// A bounded, allocation-free packet handoff. A contending producer drops instead of
@@ -120,6 +141,7 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
private readonly VoiceFrameHeader[] headers = new VoiceFrameHeader[64];
private readonly int[] lengths = new int[64];
private int read, written, producer;
internal bool IsEmpty => read == Volatile.Read(ref written);
internal bool TryWrite(VoiceFrameHeader header, ReadOnlySpan<byte> payload)
{
if (payload.Length is < 1 or > 1275 || Interlocked.CompareExchange(ref producer, 1, 0) != 0) return false;
+5 -2
View File
@@ -55,12 +55,15 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
public bool TryReadEvent(out Envelope? envelope) => events.Reader.TryRead(out envelope);
public IAsyncEnumerable<Envelope> ReadEventsAsync(CancellationToken cancellationToken = default) => events.Reader.ReadAllAsync(cancellationToken);
public VoiceCatClient(string clientName = "VoiceCat .NET", string clientVersion = "0.1.0", string? tofuStorePath = null)
// deviceClockedAudio: the platform's audio callback drives Audio.RunCycle() itself (iOS
// keeps render callbacks running in the background while sleep-paced threads coalesce there),
// so the audio engine must not start its own pacing worker.
public VoiceCatClient(string clientName = "VoiceCat .NET", string clientVersion = "0.1.0", string? tofuStorePath = null, bool deviceClockedAudio = false)
{
this.clientName = clientName;
this.clientVersion = clientVersion;
pins = new(tofuStorePath ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "VoiceCat", "tofu.txt"));
Audio = new(TrySendEncodedVoice);
Audio = new(TrySendEncodedVoice, startWorker: !deviceClockedAudio);
VoiceReceived += Audio.Receive;
}
+164
View File
@@ -1,6 +1,11 @@
using System.Net;
using System.Net.Sockets;
using VoiceCat.Audio;
using VoiceCat.Codec;
using VoiceCat.Core;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
using VoiceCat.Transport;
using Voicecat.V1;
namespace VoiceCat.Tests;
@@ -28,6 +33,63 @@ public class AudioEngineTests
Assert.DoesNotContain("SendAsync(packet.AsMemory", source);
}
[Fact]
public void MediaSenderWakesOnQueueSignalInsteadOfTimedPolling()
{
string source = File.ReadAllText(Path.Combine(FindRoot(), "src", "VoiceCat.Core", "ClientMediaTransport.cs"));
Assert.Contains("sendReady.WaitOne(", source);
Assert.Contains("senderNapping", source);
Assert.DoesNotContain("Thread.Sleep(1);", source);
}
[Fact]
public async Task MediaSenderDeliversQueuedVoicePromptlyWithoutTimedPolling()
{
// A loopback "relay" answers the binding so the sender's own keepalive pass falls back to
// its five second schedule: only the queue signal can then deliver queued voice promptly,
// so a lost wake or a polling loop shows up here as a multi-second delay.
using var relay = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
relay.Bind(new IPEndPoint(IPAddress.Loopback, 0));
byte[] key = new byte[32]; Random.Shared.NextBytes(key);
using var crypto = new MediaSessionCrypto(new MediaEncryptor(key), new MediaDecryptor(key));
await using var transport = new ClientMediaTransport((IPEndPoint)relay.LocalEndPoint!, new byte[16], crypto, CancellationToken.None);
byte[] datagram = new byte[512];
using (var startup = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
{
SocketReceiveFromResult probe = await relay.ReceiveFromAsync(datagram, SocketFlags.None, new IPEndPoint(IPAddress.Any, 0), startup.Token);
Assert.Equal(VoiceFrameHeader.Size + 16, probe.ReceivedBytes);
byte[] keepalive = new byte[VoiceFrameHeader.Size];
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
await relay.SendToAsync(keepalive, SocketFlags.None, probe.RemoteEndPoint);
}
await transport.Bound.WaitAsync(TimeSpan.FromSeconds(5));
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
Assert.True(transport.TrySend(new(MediaFrameType.Voice, VoiceFrameFlags.None, 0, 7, 0, 0), new byte[80]));
await ReceiveVoiceAsync(relay, datagram, 1, TimeSpan.FromSeconds(2));
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(2));
// A burst queued while the sender naps must drain in one wake, not one keepalive pass.
stopwatch.Restart();
for (uint i = 1; i <= 30; i++)
Assert.True(transport.TrySend(new(MediaFrameType.Voice, VoiceFrameFlags.None, 0, 7, 0, i * 960), new byte[80]));
await ReceiveVoiceAsync(relay, datagram, 30, TimeSpan.FromSeconds(2));
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(2));
}
private static async Task ReceiveVoiceAsync(Socket relay, byte[] datagram, int count, TimeSpan timeout)
{
int voice = VoiceFrameHeader.Size + 80 + MediaEncryptor.TagSize;
using var deadline = new CancellationTokenSource(timeout);
while (count > 0)
{
SocketReceiveFromResult packet = await relay.ReceiveFromAsync(datagram, SocketFlags.None, new IPEndPoint(IPAddress.Any, 0), deadline.Token);
if (packet.ReceivedBytes == voice) count--;
}
}
[Theory]
[InlineData(-1000)]
[InlineData(1000)]
@@ -124,6 +186,31 @@ public class AudioEngineTests
Assert.Equal(0, lateStarvation);
}
[Theory]
[InlineData(20)]
[InlineData(40)]
[InlineData(120)]
public void AdaptivePcmBufferResynchronizeDropsStallBacklogToTheTarget(int bufferMilliseconds)
{
var buffer = new AdaptivePcmBuffer(1, bufferMilliseconds, 65_536);
short[] chunk = new short[960], output = new short[960];
chunk.AsSpan().Fill(1234);
// A stalled consumer leaves every chunk queued: feed and drain rates are equal in steady
// state, so this backlog would otherwise sit at a fixed offset until overflow drops begin.
for (int i = 0; i < 50; i++) Assert.True(buffer.TryWrite(chunk));
Assert.Equal(50 * 960, buffer.CountFrames);
buffer.Resynchronize();
Assert.Equal(bufferMilliseconds * 48, buffer.CountFrames);
Assert.Equal(960, buffer.Read(output));
Assert.All(output, value => Assert.Equal(1234, value));
// At or below the target the resynchronization drops nothing.
buffer.Resynchronize();
Assert.Equal(bufferMilliseconds * 48 - 960, buffer.CountFrames);
}
[Fact]
public void OneCaptureMissDoesNotRestartTalkspurtButSustainedStarvationDoes()
{
@@ -282,6 +369,83 @@ public class AudioEngineTests
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
}
[Fact]
public void DeviceClockedRunCyclePacesMixAndCaptureWithNoWorkerThread()
{
var sent = new List<(uint Timestamp, VoiceFrameFlags Flags)>(512);
StreamInfo stream = Stream();
using var receive = new AudioEngine((_, _, _, _) => true, false);
receive.SetRemoteStreams([new() { Id = 2, ChannelId = 1, Streams = { stream } }], 1, 1);
using var send = new AudioEngine((ssrc, timestamp, payload, flags) =>
{
sent.Add((timestamp, flags));
receive.Receive(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload);
return true;
}, false) { InputMode = AudioInputMode.AlwaysOn, DeviceBufferMilliseconds = 20 };
send.AddLocalStream(stream);
short[] tone = Tone(); long energy = 0;
receive.MixedPcm += pcm => { foreach (short sample in pcm) energy += Math.Abs((int)sample); };
// Each iteration is one 20 ms render demand quantum: feed the capture handoff, then pull
// both mixes, exactly as the iOS render callback drives its device-clocked engine.
for (int i = 0; i < 30; i++) { Assert.True(send.FeedPcm(1, tone, 1)); send.RunCycle(); receive.RunCycle(); }
Assert.Equal(30, send.GetLocalDiagnostics(1).Cycles);
Assert.Equal(0, send.GetLocalDiagnostics(1).StarvedCycles);
Assert.Equal(30, sent.Count);
Assert.True(energy > 100000);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 100; i++) { send.FeedPcm(1, tone, 1); send.RunCycle(); receive.RunCycle(); }
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
}
[Fact]
public void RunCycleContainsFailuresInsteadOfThrowingIntoRealtimeCallbacks()
{
using var engine = new AudioEngine((_, _, _, _) => throw new InvalidOperationException("boom"), false)
{ InputMode = AudioInputMode.AlwaysOn, DeviceBufferMilliseconds = 20 };
engine.AddLocalStream(Stream());
Assert.True(engine.FeedPcm(1, Tone(), 1));
engine.RunCycle();
Assert.IsType<InvalidOperationException>(engine.Failure);
engine.RunCycle();
Assert.Equal(1, engine.GetLocalDiagnostics(1).Cycles);
}
[Fact]
public async Task DeviceClockedClientLeavesMixCadenceToTheAudioCallback()
{
string directory = Path.Combine(Path.GetTempPath(), $"voicecat-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
await using var paced = new VoiceCatClient(tofuStorePath: Path.Combine(directory, "paced.txt"));
await using var pulled = new VoiceCatClient(tofuStorePath: Path.Combine(directory, "pulled.txt"), deviceClockedAudio: true);
paced.Audio.AddLocalStream(Stream());
pulled.Audio.AddLocalStream(Stream());
await Task.Delay(250);
Assert.True(paced.Audio.GetLocalDiagnostics(1).Cycles > 0);
Assert.Equal(0, pulled.Audio.GetLocalDiagnostics(1).Cycles);
pulled.Audio.RunCycle();
Assert.Equal(1, pulled.Audio.GetLocalDiagnostics(1).Cycles);
}
finally
{
try { Directory.Delete(directory, true); }
catch (IOException) { } catch (UnauthorizedAccessException) { }
}
}
[Fact]
public void ManagedAudioWorkerResynchronizesProducerBacklogAfterStalls()
{
string source = File.ReadAllText(Path.Combine(FindRoot(), "src", "VoiceCat.Audio", "AudioEngine.cs"));
string buffer = File.ReadAllText(Path.Combine(FindRoot(), "src", "VoiceCat.Audio", "AdaptivePcmBuffer.cs"));
Assert.Contains("stream.Input.Resynchronize()", source);
Assert.Contains("public void Resynchronize()", buffer);
}
[Fact]
public void RemotePlaybackSettingsAreIndependentForEachUserStream()
{
@@ -66,8 +66,11 @@ public class PublishServerScriptTests
Assert.Contains("group.me.iamtalon.voicecat", entitlements);
Assert.Contains("CODE_SIGN_STYLE=Manual", script);
Assert.Contains("PROVISIONING_PROFILE_SPECIFIER=\"$VOICECAT_BROADCAST_CODESIGN_PROVISION\"", script);
Assert.Contains("CURRENT_PROJECT_VERSION=\"$VOICECAT_BUILD_NUMBER\"", script);
// Both version placeholders must always be passed to xcodebuild. A conditional
// CURRENT_PROJECT_VERSION expands to an empty CFBundleVersion, which installd rejects.
Assert.Contains("CURRENT_PROJECT_VERSION=\"${VOICECAT_BUILD_NUMBER:-1}\"", script);
Assert.Contains("MARKETING_VERSION=\"${VOICECAT_DISPLAY_VERSION:-0.0.1}\"", script);
Assert.DoesNotContain("if [[ -n \"${VOICECAT_BUILD_NUMBER:-}\" ]]; then", script);
string deviceScript = await File.ReadAllTextAsync(Path.Combine(
root, "clients", "apple", "build-ios-device.sh"));
@@ -81,6 +84,24 @@ public class PublishServerScriptTests
Assert.Contains("<string>$(CURRENT_PROJECT_VERSION)</string>", extensionManifest);
}
[Fact]
public async Task IosHostDeclaresItsAppIconAssetForAppStoreValidation()
{
string root = FindRoot();
string manifest = await File.ReadAllTextAsync(Path.Combine(
root, "clients", "apple", "VoiceCat.iOS", "Info.plist"));
string iconSet = await File.ReadAllTextAsync(Path.Combine(
root, "clients", "apple", "VoiceCat.iOS",
"Assets.xcassets", "AppIcon.appiconset", "Contents.json"));
// XSAppIconAssets is an app-manifest key, not an MSBuild property. Without it actool
// never receives --app-icon, so the bundle ships without CFBundleIconName and App Store
// Connect rejects the upload with errors 90022, 90023, and 90713.
Assert.Contains("<key>XSAppIconAssets</key>", manifest);
Assert.Contains("<string>Assets.xcassets/AppIcon.appiconset</string>", manifest);
Assert.Contains("AppIcon-1024.png", iconSet);
}
[Fact]
public async Task IosScreenSharingDeclaresBackgroundCaptureAndKeepsReplayKitFallback()
{
@@ -111,6 +132,16 @@ public class PublishServerScriptTests
Assert.DoesNotContain("Task.Delay(10", screen);
Assert.Contains("MaximumCaptureCallbackFrames = 16_384", microphone);
Assert.DoesNotContain("Math.Ceiling(4_096 * 48_000", microphone);
// Both 20 ms hands-offs run from the AVAudioSourceNode render callback: sleep-paced
// threads coalesce when the app is backgrounded and glitch the call after several minutes.
string model = await File.ReadAllTextAsync(Path.Combine(
root, "clients", "apple", "VoiceCat.iOS", "AppModel.cs"));
Assert.DoesNotContain("new Thread(", microphone);
Assert.DoesNotContain("Thread.Sleep", microphone);
Assert.Contains("owner.Audio.RunCycle()", microphone);
Assert.Contains("while (microphoneCredit >= 960)", microphone);
Assert.Contains("deviceClockedAudio: true", model);
}
[Fact]