v0.9: VoicePipeline refactor — queue-based pipeline, lock/release model, thread-based TCP
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Robovoice.Core;
|
||||
using Robovoice.Tts.LibPiper;
|
||||
|
||||
namespace Robovoice.App;
|
||||
|
||||
internal sealed record TextItem(uint Session, string Text);
|
||||
|
||||
internal sealed record AudioItem(uint Session, float[]? Samples);
|
||||
|
||||
internal sealed class VoicePipeline : IDisposable
|
||||
{
|
||||
private readonly LibPiperTtsEngine _tts;
|
||||
private readonly AudioOutput _audioOutput;
|
||||
private readonly Action<string> _log;
|
||||
|
||||
private BlockingCollection<TextItem> _textQueue = new();
|
||||
private BlockingCollection<AudioItem> _audioQueue = new();
|
||||
private ManualResetEventSlim _gate = new(false);
|
||||
private volatile uint _currentSession;
|
||||
private volatile bool _running;
|
||||
|
||||
private Thread? _synthThread;
|
||||
private Thread? _playerThread;
|
||||
private bool _disposed;
|
||||
|
||||
public string OutputDeviceName { get; set; } = string.Empty;
|
||||
|
||||
public VoicePipeline(
|
||||
LibPiperTtsEngine tts,
|
||||
AudioOutput audioOutput,
|
||||
Action<string> log)
|
||||
{
|
||||
_tts = tts;
|
||||
_audioOutput = audioOutput;
|
||||
_audioOutput.Log = log;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_running = true;
|
||||
_synthThread = new Thread(SynthLoop) { IsBackground = true, Name = "VoicePipeline-Synth" };
|
||||
_playerThread = new Thread(PlayerLoop) { IsBackground = true, Name = "VoicePipeline-Player" };
|
||||
_synthThread.Start();
|
||||
_playerThread.Start();
|
||||
}
|
||||
|
||||
public void EnqueueSegment(string text)
|
||||
{
|
||||
if (!_running) return;
|
||||
uint session = _currentSession;
|
||||
_textQueue.Add(new TextItem(session, text));
|
||||
}
|
||||
|
||||
public void EnqueueFinal(string text)
|
||||
{
|
||||
if (!_running) return;
|
||||
uint session = _currentSession;
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
_textQueue.Add(new TextItem(session, text));
|
||||
_textQueue.Add(new TextItem(session, ""));
|
||||
_gate.Reset();
|
||||
}
|
||||
|
||||
public void OnPttPressed()
|
||||
{
|
||||
_currentSession++;
|
||||
_gate.Reset();
|
||||
_audioOutput.Stop();
|
||||
|
||||
// Unblock player thread if it's waiting on audioQueue.Take()
|
||||
_audioQueue.Add(new AudioItem(_currentSession - 1, null));
|
||||
}
|
||||
|
||||
public void OnPttReleased()
|
||||
{
|
||||
_gate.Set();
|
||||
}
|
||||
|
||||
private void SynthLoop()
|
||||
{
|
||||
foreach (var item in _textQueue.GetConsumingEnumerable())
|
||||
{
|
||||
if (!_running) break;
|
||||
|
||||
if (item.Session != _currentSession)
|
||||
continue;
|
||||
|
||||
if (item.Text.Length == 0)
|
||||
{
|
||||
// :F sentinel — signal end of utterance to player
|
||||
_audioQueue.Add(new AudioItem(item.Session, null));
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var chunk in _tts.SynthesizeSync(item.Text))
|
||||
{
|
||||
if (item.Session != _currentSession)
|
||||
{
|
||||
// Session changed mid-synthesis — drain piper cleanly
|
||||
_tts.SynthesizeDrain();
|
||||
break;
|
||||
}
|
||||
|
||||
_audioQueue.Add(new AudioItem(item.Session, chunk.Samples));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log($"TTS error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void PlayerLoop()
|
||||
{
|
||||
while (_running)
|
||||
{
|
||||
_gate.Wait();
|
||||
if (!_running) break;
|
||||
|
||||
// Block until first audio item is available
|
||||
AudioItem firstItem;
|
||||
try
|
||||
{
|
||||
firstItem = _audioQueue.Take();
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!_running) break;
|
||||
if (firstItem.Session != _currentSession)
|
||||
continue;
|
||||
if (firstItem.Samples == null)
|
||||
{
|
||||
// :F with no audio — nothing to play
|
||||
_gate.Reset();
|
||||
continue;
|
||||
}
|
||||
|
||||
_audioOutput.Start(_tts.SampleRate, OutputDeviceName);
|
||||
_audioOutput.WriteSamples(firstItem.Samples);
|
||||
_log($"TTS: playback started (session {firstItem.Session})");
|
||||
|
||||
foreach (var item in _audioQueue.GetConsumingEnumerable())
|
||||
{
|
||||
if (!_running) break;
|
||||
|
||||
if (item.Session != _currentSession)
|
||||
break;
|
||||
|
||||
if (item.Samples == null)
|
||||
break;
|
||||
|
||||
_audioOutput.WriteSamples(item.Samples);
|
||||
}
|
||||
|
||||
_audioOutput.Flush();
|
||||
_audioOutput.Stop();
|
||||
_log("TTS: playback finished");
|
||||
_gate.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_running = false;
|
||||
|
||||
_gate.Set();
|
||||
_textQueue.CompleteAdding();
|
||||
_audioQueue.CompleteAdding();
|
||||
|
||||
_synthThread?.Join(5000);
|
||||
_playerThread?.Join(5000);
|
||||
|
||||
_textQueue.Dispose();
|
||||
_audioQueue.Dispose();
|
||||
_gate.Dispose();
|
||||
|
||||
_tts.DisposeAsync().AsTask().Wait();
|
||||
_audioOutput.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user