2026-09-19 15:43:37 +02:00
|
|
|
using System.IO.MemoryMappedFiles;
|
|
|
|
|
using VoiceCat.Core;
|
|
|
|
|
using Voicecat.V1;
|
|
|
|
|
|
|
|
|
|
namespace VoiceCat.iOS;
|
|
|
|
|
|
|
|
|
|
internal sealed class BroadcastAudioPump : IAsyncDisposable
|
|
|
|
|
{
|
|
|
|
|
private const uint Magic = 0x56434252, Version = 1;
|
|
|
|
|
private const int Header = 64, Capacity = 96_000, Frame = 960;
|
|
|
|
|
private readonly CancellationTokenSource stop = new();
|
2026-09-23 21:47:42 +02:00
|
|
|
private readonly short[] scratch = new short[Frame * 2];
|
2026-09-21 14:14:15 +02:00
|
|
|
private Thread? worker;
|
2026-09-19 15:43:37 +02:00
|
|
|
private VoiceCatClient? client;
|
2026-09-23 21:47:42 +02:00
|
|
|
private MemoryMappedFile? map;
|
|
|
|
|
private MemoryMappedViewAccessor? view;
|
|
|
|
|
private string? path;
|
2026-09-19 15:43:37 +02:00
|
|
|
private uint streamId;
|
2026-09-19 19:33:10 +02:00
|
|
|
private bool active;
|
2026-09-19 20:09:00 +02:00
|
|
|
private int generation;
|
2026-09-26 20:30:24 +02:00
|
|
|
private ulong observedWrite;
|
|
|
|
|
private ulong idleWrite;
|
|
|
|
|
private bool idleWriteSeen;
|
2026-09-19 15:43:37 +02:00
|
|
|
|
2026-09-19 19:33:10 +02:00
|
|
|
internal event Action? Changed;
|
|
|
|
|
internal bool IsActive => active;
|
2026-09-26 20:30:24 +02:00
|
|
|
#if DEBUG
|
|
|
|
|
internal bool IsMappingOpen => view is not null;
|
2026-09-27 17:56:18 +02:00
|
|
|
internal long EncodedPackets => streamId == 0 ? 0 : client?.Audio.GetLocalDiagnostics(streamId).EncodedPackets ?? 0;
|
2026-09-26 20:30:24 +02:00
|
|
|
#endif
|
2026-09-19 19:33:10 +02:00
|
|
|
|
2026-09-21 14:14:15 +02:00
|
|
|
internal void Start(VoiceCatClient owner)
|
2026-09-19 15:43:37 +02:00
|
|
|
{
|
2026-09-21 14:14:15 +02:00
|
|
|
client = owner;
|
|
|
|
|
worker = new Thread(Run) { IsBackground = true, Name = "VoiceCat iOS screen audio", Priority = ThreadPriority.AboveNormal };
|
|
|
|
|
worker.Start();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void Run()
|
|
|
|
|
{
|
|
|
|
|
CancellationToken token = stop.Token;
|
2026-09-23 21:47:42 +02:00
|
|
|
try
|
2026-09-19 15:43:37 +02:00
|
|
|
{
|
2026-09-23 21:47:42 +02:00
|
|
|
while (!token.IsCancellationRequested)
|
|
|
|
|
{
|
|
|
|
|
try { DrainAsync(token).GetAwaiter().GetResult(); }
|
2026-09-26 20:30:24 +02:00
|
|
|
catch (OperationCanceledException) when (token.IsCancellationRequested) { break; }
|
|
|
|
|
catch (Exception exception)
|
|
|
|
|
{
|
|
|
|
|
// A rejected screen stream is a session error, not an unhandled exception on
|
|
|
|
|
// this background Thread. Keep the app and microphone call alive.
|
|
|
|
|
Console.Error.WriteLine($"VoiceCat screen audio pump failed: {exception}");
|
|
|
|
|
CloseMapping(); StopStream(); SetActive(false);
|
|
|
|
|
if (!token.IsCancellationRequested) Thread.Sleep(1000);
|
|
|
|
|
}
|
|
|
|
|
// The shared file must not remain mapped while the app is idle or suspending.
|
|
|
|
|
if (!token.IsCancellationRequested) Thread.Sleep(view is null ? 100 : 5);
|
2026-09-23 21:47:42 +02:00
|
|
|
}
|
2026-09-19 15:43:37 +02:00
|
|
|
}
|
2026-09-23 21:47:42 +02:00
|
|
|
finally { CloseMapping(); }
|
2026-09-19 15:43:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task DrainAsync(CancellationToken token)
|
|
|
|
|
{
|
2026-09-23 21:47:42 +02:00
|
|
|
if (!OpenMapping()) return;
|
|
|
|
|
MemoryMappedViewAccessor pages = view!;
|
|
|
|
|
bool active = pages.ReadUInt32(16) != 0;
|
2026-09-26 20:30:24 +02:00
|
|
|
if (!active) { StopStream(); SetActive(false); CloseMapping(); return; }
|
2026-09-19 15:43:37 +02:00
|
|
|
VoiceCatClient owner = client ?? throw new IOException("Client disconnected.");
|
|
|
|
|
if (streamId == 0)
|
|
|
|
|
{
|
2026-09-26 20:30:24 +02:00
|
|
|
// A ring left marked active by a killed extension must not start a phantom stream.
|
|
|
|
|
// Wait for a fresh producer write after this connection opened the mapping.
|
|
|
|
|
ulong currentWrite = pages.ReadUInt64(24);
|
|
|
|
|
if (currentWrite == observedWrite) return;
|
|
|
|
|
observedWrite = currentWrite;
|
2026-09-19 20:09:00 +02:00
|
|
|
int startGeneration = Volatile.Read(ref generation);
|
2026-09-26 20:30:24 +02:00
|
|
|
VoiceSubscriptionResult subscription = await owner.SubscribeVoiceAsync(cancellationToken: token).ConfigureAwait(false);
|
|
|
|
|
if (!subscription.Ok) throw new InvalidOperationException(subscription.Error);
|
2026-09-19 15:43:37 +02:00
|
|
|
StreamInfo stream = await owner.StartStreamAsync(StreamKind.StreamScreenAudio, "Screen audio", 2, token).ConfigureAwait(false);
|
2026-09-23 21:47:42 +02:00
|
|
|
if (startGeneration != Volatile.Read(ref generation) || pages.ReadUInt32(16) == 0 || !ReferenceEquals(client, owner))
|
2026-09-19 20:09:00 +02:00
|
|
|
{
|
|
|
|
|
try { owner.StopStream(stream.StreamId); } catch (Exception exception) when (exception is IOException or InvalidOperationException) { }
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-09-27 17:56:18 +02:00
|
|
|
// Keep the newest 120 ms captured during stream negotiation. This covers the
|
|
|
|
|
// largest codec frame plus the input buffer target; dropping through the latest
|
|
|
|
|
// write loses short sounds before the first drain can feed them.
|
|
|
|
|
ulong latestWrite = pages.ReadUInt64(24);
|
|
|
|
|
ulong earliest = latestWrite > (ulong)(Frame * 12) ? latestWrite - (ulong)(Frame * 12) : 0;
|
|
|
|
|
pages.Write(32, Math.Max(pages.ReadUInt64(32), earliest));
|
|
|
|
|
streamId = stream.StreamId; SetActive(true);
|
2026-09-19 15:43:37 +02:00
|
|
|
}
|
2026-09-23 21:47:42 +02:00
|
|
|
ulong write = pages.ReadUInt64(24), read = pages.ReadUInt64(32);
|
2026-09-19 15:43:37 +02:00
|
|
|
if (write - read > Capacity) read = write - Capacity;
|
|
|
|
|
while (write - read >= Frame * 2)
|
|
|
|
|
{
|
|
|
|
|
for (int sample = 0; sample < scratch.Length; sample++)
|
|
|
|
|
{
|
|
|
|
|
ulong index = (read + (ulong)sample) % Capacity;
|
2026-09-23 21:47:42 +02:00
|
|
|
scratch[sample] = pages.ReadInt16(Header + checked((long)index * sizeof(short)));
|
2026-09-19 15:43:37 +02:00
|
|
|
}
|
|
|
|
|
if (!owner.Audio.FeedPcm(streamId, scratch, 2)) break;
|
|
|
|
|
read += (ulong)scratch.Length;
|
2026-09-23 21:47:42 +02:00
|
|
|
pages.Write(32, read);
|
2026-09-19 15:43:37 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 20:30:24 +02:00
|
|
|
// Keep a mapping only while a broadcast is active. An inactive mapping holds an open handle
|
|
|
|
|
// into the shared App Group container through suspension, which iOS may terminate as a shared
|
|
|
|
|
// file lock (0xdead10cc). Poll the small header with a short-lived read when idle, then map
|
|
|
|
|
// once for the active broadcast's high-rate drain.
|
2026-09-23 21:47:42 +02:00
|
|
|
private bool OpenMapping()
|
|
|
|
|
{
|
|
|
|
|
path ??= ResolvePath();
|
|
|
|
|
if (path is null) return false;
|
|
|
|
|
bool present = File.Exists(path);
|
|
|
|
|
if (view is not null)
|
|
|
|
|
{
|
|
|
|
|
if (present) return true;
|
|
|
|
|
CloseMapping(); return false;
|
|
|
|
|
}
|
2026-09-26 20:30:24 +02:00
|
|
|
if (!present) { idleWriteSeen = false; return false; }
|
|
|
|
|
using (var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
|
|
|
|
|
{
|
|
|
|
|
if (file.Length < Header) { idleWriteSeen = false; return false; }
|
|
|
|
|
Span<byte> header = stackalloc byte[32];
|
|
|
|
|
file.ReadExactly(header);
|
|
|
|
|
if (System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(header) != Magic ||
|
|
|
|
|
System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(header[4..]) != Version ||
|
|
|
|
|
System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(header[16..]) == 0)
|
|
|
|
|
{
|
|
|
|
|
idleWriteSeen = false;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
ulong currentWrite = System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(header[24..]);
|
|
|
|
|
if (!idleWriteSeen)
|
|
|
|
|
{
|
|
|
|
|
idleWrite = currentWrite;
|
|
|
|
|
idleWriteSeen = true;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (currentWrite == idleWrite) return false;
|
|
|
|
|
}
|
2026-09-23 21:47:42 +02:00
|
|
|
MemoryMappedFile candidate = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
MemoryMappedViewAccessor pages = candidate.CreateViewAccessor(0, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite);
|
|
|
|
|
if (pages.ReadUInt32(0) != Magic || pages.ReadUInt32(4) != Version)
|
|
|
|
|
{
|
|
|
|
|
pages.Dispose(); throw new InvalidDataException("Unsupported broadcast ring.");
|
|
|
|
|
}
|
2026-09-26 20:30:24 +02:00
|
|
|
map = candidate; view = pages; observedWrite = idleWrite; return true;
|
2026-09-23 21:47:42 +02:00
|
|
|
}
|
|
|
|
|
catch { candidate.Dispose(); throw; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string? ResolvePath()
|
|
|
|
|
{
|
|
|
|
|
NSUrl? root = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup);
|
|
|
|
|
return root?.Path is null ? null : Path.Combine(root.Path, "voicecat", "broadcast_audio.ring");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void CloseMapping()
|
|
|
|
|
{
|
|
|
|
|
view?.Dispose(); view = null;
|
|
|
|
|
map?.Dispose(); map = null;
|
2026-09-26 20:30:24 +02:00
|
|
|
observedWrite = 0;
|
|
|
|
|
idleWriteSeen = false;
|
2026-09-23 21:47:42 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-19 15:43:37 +02:00
|
|
|
private void StopStream()
|
|
|
|
|
{
|
|
|
|
|
uint id = streamId; streamId = 0; if (id == 0 || client?.State != ClientConnectionState.Connected) return;
|
2026-09-26 20:30:24 +02:00
|
|
|
try { client.StopStream(id); } catch (Exception exception) when (exception is IOException or InvalidOperationException or ObjectDisposedException) { }
|
2026-09-19 15:43:37 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-19 20:09:00 +02:00
|
|
|
internal void RequestStop() { Interlocked.Increment(ref generation); SetActive(false); }
|
|
|
|
|
|
2026-09-19 19:33:10 +02:00
|
|
|
private void SetActive(bool value) { if (active == value) return; active = value; Changed?.Invoke(); }
|
|
|
|
|
|
2026-09-21 14:14:15 +02:00
|
|
|
public ValueTask DisposeAsync()
|
2026-09-19 15:43:37 +02:00
|
|
|
{
|
2026-09-21 14:14:15 +02:00
|
|
|
stop.Cancel(); Interlocked.Increment(ref generation); worker?.Join(); worker = null;
|
|
|
|
|
StopStream(); SetActive(false); stop.Dispose(); return ValueTask.CompletedTask;
|
2026-09-19 15:43:37 +02:00
|
|
|
}
|
|
|
|
|
}
|