Files
voice-cat/clients/apple/dotnet/VoiceCat.iOS/BroadcastAudioPump.cs
T
Talon 9fc5598e8e
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
Bring managed iOS client to feature parity
2026-09-19 19:33:10 +02:00

77 lines
3.5 KiB
C#

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();
private Task worker = Task.CompletedTask;
private VoiceCatClient? client;
private uint streamId;
private bool active;
private readonly short[] scratch = new short[Frame * 2];
internal event Action? Changed;
internal bool IsActive => active;
internal void Start(VoiceCatClient owner) { client = owner; worker = RunAsync(stop.Token); }
private async Task RunAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try { await DrainAsync(token); }
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException) { }
await Task.Delay(10, token).ConfigureAwait(false);
}
}
private async Task DrainAsync(CancellationToken token)
{
NSUrl? root = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup);
if (root?.Path is null) return;
string path = Path.Combine(root.Path, "voicecat", "broadcast_audio.ring"); if (!File.Exists(path)) return;
using MemoryMappedFile map = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite);
using MemoryMappedViewAccessor view = map.CreateViewAccessor(0, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite);
if (view.ReadUInt32(0) != Magic || view.ReadUInt32(4) != Version) throw new InvalidDataException("Unsupported broadcast ring.");
bool active = view.ReadUInt32(16) != 0;
if (!active) { StopStream(); SetActive(false); return; }
VoiceCatClient owner = client ?? throw new IOException("Client disconnected.");
if (streamId == 0)
{
StreamInfo stream = await owner.StartStreamAsync(StreamKind.StreamScreenAudio, "Screen audio", 2, token).ConfigureAwait(false);
streamId = stream.StreamId; view.Write(32, view.ReadUInt64(24)); SetActive(true);
}
ulong write = view.ReadUInt64(24), read = view.ReadUInt64(32);
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;
scratch[sample] = view.ReadInt16(Header + checked((long)index * sizeof(short)));
}
if (!owner.Audio.FeedPcm(streamId, scratch, 2)) break;
read += (ulong)scratch.Length;
view.Write(32, read);
}
}
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) { }
}
private void SetActive(bool value) { if (active == value) return; active = value; Changed?.Invoke(); }
public async ValueTask DisposeAsync()
{
stop.Cancel(); try { await worker.ConfigureAwait(false); } catch (OperationCanceledException) { } StopStream(); SetActive(false); stop.Dispose();
}
}