Compare commits
2
Commits
ea76d5157a
...
f348574bc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f348574bc1 | ||
|
|
1487f2673b |
+5
-3
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user