Files

137 lines
5.5 KiB
C#
Raw Permalink Normal View History

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-19 15:43:37 +02:00
2026-09-19 19:33:10 +02:00
internal event Action? Changed;
internal bool IsActive => active;
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(); }
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or OperationCanceledException) { CloseMapping(); }
if (!token.IsCancellationRequested) Thread.Sleep(5);
}
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-19 19:33:10 +02:00
if (!active) { StopStream(); SetActive(false); return; }
2026-09-19 15:43:37 +02:00
VoiceCatClient owner = client ?? throw new IOException("Client disconnected.");
if (streamId == 0)
{
2026-09-19 20:09:00 +02:00
int startGeneration = Volatile.Read(ref generation);
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-23 21:47:42 +02:00
streamId = stream.StreamId; pages.Write(32, pages.ReadUInt64(24)); 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-23 21:47:42 +02:00
// The producer opens the ring with O_CREAT and never replaces it, so one mapping covers the
// pump's whole life. Creating and disposing a mapping (plus its container lookup and path
// strings) every 5 ms tick allocated steadily at 200 Hz and churned the GC under long calls;
// the mapping is now built on the ring's transitions and reused until the file disappears or
// a drain fails.
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;
}
if (!present) return false;
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.");
}
map = candidate; view = pages; return true;
}
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-19 15:43:37 +02:00
private void StopStream()
{
uint id = streamId; streamId = 0; if (id == 0 || client?.State != ClientConnectionState.Connected) return;
try { client.StopStream(id); } catch (Exception exception) when (exception is IOException or InvalidOperationException) { }
}
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
}
}