2026-09-26 20:30:24 +02:00
|
|
|
#if DEBUG
|
|
|
|
|
using System.Buffers.Binary;
|
|
|
|
|
using System.Runtime.InteropServices;
|
|
|
|
|
using Foundation;
|
|
|
|
|
using UIKit;
|
|
|
|
|
using VoiceCat.Core;
|
|
|
|
|
|
|
|
|
|
namespace VoiceCat.iOS;
|
|
|
|
|
|
|
|
|
|
// An opt-in simulator gate for the real UIKit model and audio graph. Local development only.
|
|
|
|
|
internal static class SimulatorSmoke
|
|
|
|
|
{
|
|
|
|
|
internal static void RunIfRequested()
|
|
|
|
|
{
|
|
|
|
|
string? endpoint = Environment.GetEnvironmentVariable("VOICECAT_SIM_SMOKE");
|
|
|
|
|
if (endpoint is null || !RuntimeInformation.RuntimeIdentifier.StartsWith("iossimulator", StringComparison.Ordinal)) return;
|
|
|
|
|
UIApplication.SharedApplication.BeginInvokeOnMainThread(async () =>
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
string[] parts = endpoint.Split(':', 3);
|
|
|
|
|
Guid id = Guid.Parse("959da92f-f088-452d-a870-4c15007a6fd3");
|
|
|
|
|
ServerProfile guest = ServerProfile.Create(parts[0], ushort.Parse(parts[1]), ServerAuthentication.Guest, nickname: "smoke", id: id);
|
|
|
|
|
ServerProfile profile = ServerProfile.Create(parts[0], ushort.Parse(parts[1]), ServerAuthentication.Account, "smoke", id: id);
|
|
|
|
|
AppModel model = AppModel.Shared;
|
|
|
|
|
bool share = Environment.GetEnvironmentVariable("VOICECAT_SIM_SMOKE_MODE") == "share";
|
|
|
|
|
string? ring = share ? PrepareRing() : null;
|
|
|
|
|
model.IdentityRequested += _ => model.ResolveIdentity(true);
|
|
|
|
|
if (Environment.GetEnvironmentVariable("VOICECAT_SIM_SMOKE_MODE") != "resume")
|
|
|
|
|
{
|
|
|
|
|
model.UpsertProfile(guest, null);
|
|
|
|
|
model.UpsertProfile(profile, parts[2]);
|
|
|
|
|
}
|
|
|
|
|
model.Load();
|
2026-09-26 21:02:54 +02:00
|
|
|
if (Environment.GetEnvironmentVariable("VOICECAT_SIM_SMOKE_MODE") == "stereo")
|
|
|
|
|
IosAudioRouter.Shared.SelectPreset(IosAudioPreset.StereoMicrophone);
|
2026-09-26 20:30:24 +02:00
|
|
|
profile = model.Profiles.Single(value => value.Id == id);
|
|
|
|
|
Console.Error.WriteLine("VC_SIM phase=connect");
|
|
|
|
|
await model.ConnectAsync(profile);
|
|
|
|
|
Console.Error.WriteLine($"VC_SIM phase=connected audioRunning={IosAudioEngine.Shared.IsRunning}");
|
|
|
|
|
Require(model.IsConnected, "Account login did not connect.");
|
|
|
|
|
if (ring is not null)
|
|
|
|
|
{
|
|
|
|
|
// Start a synthetic producer while the microphone is off. This exercises
|
|
|
|
|
// screen-only subscription and the real BroadcastChanged audio recovery.
|
|
|
|
|
await Task.Delay(300);
|
|
|
|
|
Require(!model.ScreenRingMapped && !model.ScreenSharing,
|
|
|
|
|
"An unchanged ring from a previous broadcast remained mapped.");
|
2026-09-27 17:56:18 +02:00
|
|
|
for (int frame = 0; frame < 6; frame++) WriteScreenPcm(ring);
|
2026-09-26 20:30:24 +02:00
|
|
|
await WaitUntil(() => model.ScreenSharing, "Screen stream did not start.");
|
|
|
|
|
Require(IosAudioEngine.Shared.IsRunning, "Screen sharing stopped the audio graph.");
|
2026-09-27 17:56:18 +02:00
|
|
|
await WaitUntil(() => model.ScreenEncodedPackets > 0,
|
|
|
|
|
"Screen PCM captured before stream negotiation was discarded.");
|
|
|
|
|
long encodedBefore = model.ScreenEncodedPackets;
|
|
|
|
|
for (int frame = 0; frame < 12; frame++)
|
|
|
|
|
{
|
|
|
|
|
WriteScreenPcm(ring);
|
|
|
|
|
await Task.Delay(20);
|
|
|
|
|
}
|
|
|
|
|
await WaitUntil(() => model.ScreenEncodedPackets > encodedBefore,
|
|
|
|
|
"Screen PCM did not reach the encoder while the microphone was off.");
|
|
|
|
|
WriteRing(ring, active: false);
|
2026-09-26 20:30:24 +02:00
|
|
|
await WaitUntil(() => !model.ScreenSharing, "Screen stream did not stop.");
|
|
|
|
|
Require(!model.ScreenRingMapped, "The stopped screen ring remained mapped.");
|
|
|
|
|
Console.Error.WriteLine("VC_SIM phase=screenSharePassed");
|
|
|
|
|
}
|
|
|
|
|
await model.ToggleVoiceAsync();
|
|
|
|
|
Console.Error.WriteLine($"VC_SIM phase=joined audioRunning={IosAudioEngine.Shared.IsRunning}");
|
|
|
|
|
await Task.Delay(2500);
|
|
|
|
|
Require(model.VoiceJoined && IosAudioEngine.Shared.IsRunning, "The first voice graph stopped.");
|
|
|
|
|
long beforeShare = IosAudioEngine.Shared.RenderCallbacks;
|
|
|
|
|
IosAudioRouter.Shared.Recover("simulated screen sharing start", force: false);
|
|
|
|
|
await Task.Delay(1000);
|
|
|
|
|
Require(IosAudioEngine.Shared.IsRunning && IosAudioEngine.Shared.RenderCallbacks > beforeShare,
|
|
|
|
|
"Screen sharing recovery stopped the live audio graph.");
|
|
|
|
|
if (ring is not null)
|
|
|
|
|
{
|
2026-09-27 17:56:18 +02:00
|
|
|
WriteRing(ring, active: true);
|
2026-09-26 20:30:24 +02:00
|
|
|
await Task.Delay(300);
|
2026-09-27 17:56:18 +02:00
|
|
|
WriteScreenPcm(ring);
|
2026-09-26 20:30:24 +02:00
|
|
|
await WaitUntil(() => model.ScreenSharing, "Screen stream did not start during a voice call.");
|
|
|
|
|
Require(IosAudioEngine.Shared.IsRunning, "Starting screen sharing stopped the voice graph.");
|
|
|
|
|
}
|
|
|
|
|
await model.ToggleVoiceAsync();
|
|
|
|
|
if (ring is not null)
|
|
|
|
|
{
|
|
|
|
|
Require(model.ScreenSharing, "Leaving the microphone call unsubscribed active screen audio.");
|
2026-09-27 17:56:18 +02:00
|
|
|
WriteRing(ring, active: false);
|
2026-09-26 20:30:24 +02:00
|
|
|
await WaitUntil(() => !model.ScreenSharing, "The second screen stream did not stop.");
|
|
|
|
|
}
|
|
|
|
|
await model.DisconnectAsync();
|
|
|
|
|
await Task.Delay(3000);
|
|
|
|
|
model.Load();
|
|
|
|
|
profile = model.Profiles.Single(value => value.Id == id);
|
|
|
|
|
Console.Error.WriteLine("VC_SIM phase=reconnect");
|
|
|
|
|
await model.ConnectAsync(profile);
|
|
|
|
|
Console.Error.WriteLine($"VC_SIM phase=reconnected audioRunning={IosAudioEngine.Shared.IsRunning}");
|
|
|
|
|
Require(model.IsConnected, "Saved account login did not reconnect.");
|
|
|
|
|
await Task.Delay(2500);
|
|
|
|
|
Console.Error.WriteLine($"VC_SIM phase=reconnectedSettled audioRunning={IosAudioEngine.Shared.IsRunning}");
|
|
|
|
|
Require(IosAudioEngine.Shared.IsRunning, "The reconnect audio graph stopped.");
|
|
|
|
|
await model.ToggleVoiceAsync();
|
|
|
|
|
Console.Error.WriteLine($"VC_SIM phase=rejoined audioRunning={IosAudioEngine.Shared.IsRunning}");
|
|
|
|
|
await Task.Delay(2500);
|
|
|
|
|
Console.Error.WriteLine($"VC_SIM phase=rejoinedSettled audioRunning={IosAudioEngine.Shared.IsRunning}");
|
|
|
|
|
Require(model.VoiceJoined && IosAudioEngine.Shared.IsRunning, "The rejoined voice graph stopped.");
|
|
|
|
|
await model.DisconnectAsync();
|
|
|
|
|
Console.Error.WriteLine("VC_SIM phase=passed");
|
|
|
|
|
}
|
|
|
|
|
catch (Exception exception) { Console.Error.WriteLine($"VC_SIM phase=failed exception={exception}"); }
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void Require(bool condition, string message)
|
|
|
|
|
{
|
|
|
|
|
if (!condition) throw new InvalidOperationException(message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string PrepareRing()
|
|
|
|
|
{
|
|
|
|
|
string root = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup)?.Path
|
|
|
|
|
?? throw new InvalidOperationException("Simulator App Group is unavailable.");
|
|
|
|
|
string path = Path.Combine(root, "voicecat", "broadcast_audio.ring");
|
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
|
|
|
|
using var file = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.ReadWrite);
|
|
|
|
|
file.SetLength(64 + 96_000 * sizeof(short));
|
|
|
|
|
byte[] header = new byte[20];
|
|
|
|
|
BinaryPrimitives.WriteUInt32LittleEndian(header, 0x56434252);
|
|
|
|
|
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(4), 1);
|
|
|
|
|
file.Write(header);
|
|
|
|
|
file.Dispose();
|
|
|
|
|
WriteRing(path, active: true, write: 1920);
|
|
|
|
|
return path;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-27 17:56:18 +02:00
|
|
|
private static void WriteRing(string path, bool active, ulong? write = null)
|
2026-09-26 20:30:24 +02:00
|
|
|
{
|
|
|
|
|
using var file = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.ReadWrite);
|
|
|
|
|
byte[] value = new byte[8];
|
2026-09-27 17:56:18 +02:00
|
|
|
if (write is { } position)
|
|
|
|
|
{
|
|
|
|
|
BinaryPrimitives.WriteUInt64LittleEndian(value, position);
|
|
|
|
|
file.Position = 24; file.Write(value);
|
|
|
|
|
}
|
2026-09-26 20:30:24 +02:00
|
|
|
BinaryPrimitives.WriteUInt32LittleEndian(value, active ? 1u : 0u);
|
|
|
|
|
file.Position = 16; file.Write(value, 0, 4);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-27 17:56:18 +02:00
|
|
|
private static void WriteScreenPcm(string path)
|
|
|
|
|
{
|
|
|
|
|
using var file = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);
|
|
|
|
|
Span<byte> index = stackalloc byte[8];
|
|
|
|
|
file.Position = 24; file.ReadExactly(index);
|
|
|
|
|
ulong write = BinaryPrimitives.ReadUInt64LittleEndian(index);
|
|
|
|
|
byte[] pcm = new byte[960 * 2 * sizeof(short)];
|
|
|
|
|
for (int sample = 0; sample < pcm.Length / sizeof(short); sample++)
|
|
|
|
|
BinaryPrimitives.WriteInt16LittleEndian(pcm.AsSpan(sample * sizeof(short)),
|
|
|
|
|
(short)(sample % 64 < 32 ? 6000 : -6000));
|
|
|
|
|
int start = (int)(write % 96_000), first = Math.Min(pcm.Length / sizeof(short), 96_000 - start);
|
|
|
|
|
file.Position = 64 + start * sizeof(short);
|
|
|
|
|
file.Write(pcm.AsSpan(0, first * sizeof(short)));
|
|
|
|
|
if (first < pcm.Length / sizeof(short))
|
|
|
|
|
{
|
|
|
|
|
file.Position = 64;
|
|
|
|
|
file.Write(pcm.AsSpan(first * sizeof(short)));
|
|
|
|
|
}
|
|
|
|
|
BinaryPrimitives.WriteUInt64LittleEndian(index, write + (ulong)(pcm.Length / sizeof(short)));
|
|
|
|
|
file.Position = 24; file.Write(index);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 20:30:24 +02:00
|
|
|
private static async Task WaitUntil(Func<bool> condition, string error)
|
|
|
|
|
{
|
|
|
|
|
for (int attempt = 0; attempt < 30; attempt++)
|
|
|
|
|
{
|
|
|
|
|
if (condition()) return;
|
|
|
|
|
await Task.Delay(100);
|
|
|
|
|
}
|
|
|
|
|
throw new InvalidOperationException(error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#endif
|