v0.9: VoicePipeline refactor — queue-based pipeline, lock/release model, thread-based TCP
This commit is contained in:
@@ -9,10 +9,10 @@ public sealed class TcpSttSource : ISttSource
|
||||
{
|
||||
private TcpClient? _tcp;
|
||||
private NetworkStream? _stream;
|
||||
private StreamReader? _reader;
|
||||
private StreamWriter? _writer;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _runTask;
|
||||
private Thread? _recvThread;
|
||||
private Thread? _connectThread;
|
||||
private volatile bool _running;
|
||||
private readonly object _sendLock = new();
|
||||
private uint _session;
|
||||
private bool _disposed;
|
||||
@@ -26,105 +26,156 @@ public sealed class TcpSttSource : ISttSource
|
||||
public Task StartAsync(CancellationToken ct = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_cts is not null)
|
||||
if (_running)
|
||||
return Task.CompletedTask;
|
||||
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
_runTask = RunAsync(_cts.Token);
|
||||
_running = true;
|
||||
_connectThread = new Thread(ConnectLoop) { IsBackground = true, Name = "TcpSttSource-Connect" };
|
||||
_connectThread.Start();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken ct = default)
|
||||
public Task StopAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_cts is not null)
|
||||
_cts.Cancel();
|
||||
_running = false;
|
||||
|
||||
CleanupConnection();
|
||||
|
||||
if (_runTask is not null)
|
||||
lock (_sendLock)
|
||||
{
|
||||
try { await _runTask.WaitAsync(ct); }
|
||||
catch { }
|
||||
_runTask = null;
|
||||
_writer?.Dispose();
|
||||
_stream?.Dispose();
|
||||
_tcp?.Close();
|
||||
_writer = null;
|
||||
_stream = null;
|
||||
_tcp = null;
|
||||
}
|
||||
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
// Threads are background — they'll die when the process exits.
|
||||
// Closing the socket unblocks any pending Read.
|
||||
_connectThread?.Join(1000);
|
||||
_recvThread?.Join(1000);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
private void ConnectLoop()
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
while (_running)
|
||||
{
|
||||
IPEndPoint? endpoint = ParseEndpoint(Endpoint);
|
||||
if (endpoint is null)
|
||||
{
|
||||
Log?.Invoke($"STT: invalid endpoint '{Endpoint}'");
|
||||
try { await Task.Delay(3000, ct); } catch { break; }
|
||||
SleepInterruptible(3000);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_tcp = new TcpClient();
|
||||
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
connectCts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
await _tcp.ConnectAsync(endpoint.Address, endpoint.Port, connectCts.Token);
|
||||
var tcp = new TcpClient();
|
||||
tcp.Connect(endpoint.Address, endpoint.Port);
|
||||
tcp.NoDelay = true;
|
||||
|
||||
_stream = _tcp.GetStream();
|
||||
_reader = new StreamReader(_stream, Encoding.UTF8);
|
||||
_writer = new StreamWriter(_stream, Encoding.UTF8) { AutoFlush = true };
|
||||
lock (_sendLock)
|
||||
{
|
||||
_tcp = tcp;
|
||||
_stream = tcp.GetStream();
|
||||
_writer = new StreamWriter(_stream, new UTF8Encoding(false)) { AutoFlush = true };
|
||||
}
|
||||
|
||||
Log?.Invoke($"STT: connected to {Endpoint}");
|
||||
|
||||
await ReceiveLoopAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
// Blocking receive loop — runs until disconnected or stopped.
|
||||
ReceiveLoop();
|
||||
|
||||
Log?.Invoke("STT: disconnected");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.Invoke($"STT: connection failed ({ex.Message}), retrying...");
|
||||
if (_running)
|
||||
Log?.Invoke($"STT: connection failed ({ex.Message}), retrying...");
|
||||
}
|
||||
finally
|
||||
{
|
||||
CleanupConnection();
|
||||
lock (_sendLock)
|
||||
{
|
||||
_writer?.Dispose();
|
||||
_stream?.Dispose();
|
||||
_tcp?.Close();
|
||||
_writer = null;
|
||||
_stream = null;
|
||||
_tcp = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(3000, ct); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
if (_running)
|
||||
SleepInterruptible(3000);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(CancellationToken ct)
|
||||
private void ReceiveLoop()
|
||||
{
|
||||
while (!ct.IsCancellationRequested && _reader is not null)
|
||||
byte[] buffer = new byte[4096];
|
||||
StringBuilder lineBuf = new();
|
||||
|
||||
while (_running)
|
||||
{
|
||||
string? line;
|
||||
NetworkStream? stream;
|
||||
lock (_sendLock)
|
||||
{
|
||||
stream = _stream;
|
||||
}
|
||||
|
||||
if (stream is null)
|
||||
break;
|
||||
|
||||
int bytesRead;
|
||||
try
|
||||
{
|
||||
line = await _reader.ReadLineAsync(ct);
|
||||
bytesRead = stream.Read(buffer, 0, buffer.Length);
|
||||
}
|
||||
catch
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (line is null)
|
||||
if (bytesRead == 0)
|
||||
break;
|
||||
|
||||
TranscriptMessage? message = ParseReply(line);
|
||||
if (message is null)
|
||||
continue;
|
||||
|
||||
TranscriptReceived?.Invoke(this, new TranscriptEventArgs
|
||||
for (int i = 0; i < bytesRead; i++)
|
||||
{
|
||||
Message = message,
|
||||
});
|
||||
byte b = buffer[i];
|
||||
if (b == '\n')
|
||||
{
|
||||
string line = lineBuf.ToString().TrimEnd('\r');
|
||||
lineBuf.Clear();
|
||||
|
||||
TranscriptMessage? message = ParseReply(line);
|
||||
if (message is not null)
|
||||
{
|
||||
TranscriptReceived?.Invoke(this, new TranscriptEventArgs
|
||||
{
|
||||
Message = message,
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
lineBuf.Append((char)b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SleepInterruptible(int ms)
|
||||
{
|
||||
int slice = 100;
|
||||
int waited = 0;
|
||||
while (_running && waited < ms)
|
||||
{
|
||||
int chunk = Math.Min(slice, ms - waited);
|
||||
Thread.Sleep(chunk);
|
||||
waited += chunk;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,21 +208,6 @@ public sealed class TcpSttSource : ISttSource
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupConnection()
|
||||
{
|
||||
lock (_sendLock)
|
||||
{
|
||||
_writer?.Dispose();
|
||||
_reader?.Dispose();
|
||||
_stream?.Dispose();
|
||||
_tcp?.Dispose();
|
||||
_writer = null;
|
||||
_reader = null;
|
||||
_stream = null;
|
||||
_tcp = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IPEndPoint? ParseEndpoint(string endpoint)
|
||||
{
|
||||
int colon = endpoint.LastIndexOf(':');
|
||||
@@ -224,33 +260,34 @@ public sealed class TcpSttSource : ISttSource
|
||||
{
|
||||
string rest = line["F ".Length..];
|
||||
int space = rest.IndexOf(' ');
|
||||
|
||||
if (space < 0)
|
||||
return null;
|
||||
|
||||
if (!uint.TryParse(rest[..space], out uint session))
|
||||
return null;
|
||||
|
||||
if (session != _session)
|
||||
{
|
||||
Log?.Invoke($"STT: dropping stale reply (session {session} != current {_session})");
|
||||
if (uint.TryParse(rest, out uint session) && session == _session)
|
||||
return new TranscriptMessage(TranscriptType.Final, string.Empty);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!uint.TryParse(rest[..space], out uint ses))
|
||||
return null;
|
||||
|
||||
if (ses != _session)
|
||||
{
|
||||
Log?.Invoke($"STT: dropping stale reply (session {ses} != current {_session})");
|
||||
return null;
|
||||
}
|
||||
|
||||
return new TranscriptMessage(TranscriptType.Final, rest[(space + 1)..]);
|
||||
}
|
||||
|
||||
if (line == "F")
|
||||
{
|
||||
return new TranscriptMessage(TranscriptType.Final, string.Empty);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
await StopAsync();
|
||||
if (_disposed) return ValueTask.CompletedTask;
|
||||
_disposed = true;
|
||||
StopAsync();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user