using Robovoice.Core; using Robovoice.Tts.LibPiper; namespace Robovoice.App; internal sealed class Orchestrator : IAsyncDisposable { private readonly LibPiperTtsEngine _tts; private readonly AudioOutput _audioOutput; private readonly ISttSource _sttSource; private readonly Action _log; private CancellationTokenSource? _currentCts; private bool _disposed; private readonly object _stateLock = new(); private readonly Queue _pendingTexts = new(); private readonly List _audioBuffer = new(); private int _bufferSampleRate; private bool _playing; private Task? _synthTask; private CancellationTokenSource? _synthCts; private System.Threading.Timer? _segmentTimer; private static readonly TimeSpan SegmentTimeout = TimeSpan.FromMilliseconds(250); public string OutputDeviceName { get; set; } = string.Empty; public Orchestrator( LibPiperTtsEngine tts, AudioOutput audioOutput, ISttSource sttSource, Action log) { _tts = tts; _audioOutput = audioOutput; _audioOutput.Log = log; _sttSource = sttSource; _log = log; _sttSource.TranscriptReceived += OnTranscript; } private void OnTranscript(object? sender, TranscriptEventArgs e) { var msg = e.Message; lock (_stateLock) { if (msg.Type == TranscriptType.Partial) { if (!string.IsNullOrWhiteSpace(msg.Text)) { _log($"SEGMENT: \"{msg.Text}\" ({msg.Text.Length} chars)"); _pendingTexts.Enqueue(msg.Text); EnsureSynthTask(); ResetSegmentTimer(); } } else { if (!string.IsNullOrWhiteSpace(msg.Text)) { _log($"FINAL: \"{msg.Text}\" ({msg.Text.Length} chars)"); _pendingTexts.Enqueue(msg.Text); EnsureSynthTask(); } else { _log("FINAL: (empty)"); } CancelSegmentTimer(); TransitionToPlaying(); } } } private void ResetSegmentTimer() { _segmentTimer?.Dispose(); _segmentTimer = new System.Threading.Timer(_ => OnSegmentTimeout(), null, SegmentTimeout, Timeout.InfiniteTimeSpan); } private void CancelSegmentTimer() { _segmentTimer?.Dispose(); _segmentTimer = null; } private void OnSegmentTimeout() { _log("STT: segment timeout, starting playback early"); lock (_stateLock) { TransitionToPlaying(); } } private void TransitionToPlaying() { if (_playing) return; if (_audioBuffer.Count == 0) { if (_synthTask is null || _synthTask.IsCompleted) { _log("TTS: nothing to play"); CancelSegmentTimer(); } return; } _playing = true; CancelSegmentTimer(); int sampleRate = _bufferSampleRate; var chunks = _audioBuffer.ToList(); _audioBuffer.Clear(); _log($"TTS: playing {chunks.Count} buffered chunks ({sampleRate} Hz)"); _audioOutput.Start(sampleRate, OutputDeviceName); foreach (var samples in chunks) { _audioOutput.WriteSamples(samples); } } private void EnsureSynthTask() { if (_synthTask is not null && !_synthTask.IsCompleted) return; _synthCts?.Cancel(); _synthCts = new CancellationTokenSource(); _synthTask = SynthLoopAsync(_synthCts.Token); } private async Task SynthLoopAsync(CancellationToken ct) { while (true) { string text; lock (_stateLock) { if (_pendingTexts.Count == 0) break; text = _pendingTexts.Dequeue(); } try { await foreach (var chunk in _tts.SynthesizeAsync(text, ct)) { lock (_stateLock) { if (_playing) { _audioOutput.WriteSamples(chunk.Samples); } else { _bufferSampleRate = chunk.SampleRate; _audioBuffer.Add(chunk.Samples); } } } } catch (OperationCanceledException) { break; } catch (Exception ex) { _log($"TTS error: {ex.Message}"); } } lock (_stateLock) { if (_playing) { _audioOutput.Flush(); _log("TTS: synthesis complete, flushed"); } } } public void NotifyPttPressed() { lock (_stateLock) { CancelSegmentTimer(); _synthCts?.Cancel(); _synthCts?.Dispose(); _synthTask = null; if (_playing) { _audioOutput.Stop(); _playing = false; } _pendingTexts.Clear(); _audioBuffer.Clear(); _bufferSampleRate = 0; } } public async Task InitializeTtsAsync() { _log("Initializing TTS engine..."); await _tts.InitializeAsync(); _log($"TTS ready (sample rate: {_tts.SampleRate} Hz)"); } public async Task SynthesizeAsync(string text) { _currentCts?.Cancel(); _currentCts = new CancellationTokenSource(); var ct = _currentCts.Token; var sw = System.Diagnostics.Stopwatch.StartNew(); _log($"Speak: \"{text}\" ({text.Length} chars)"); try { bool started = false; int chunkCount = 0; int totalSamples = 0; await foreach (var chunk in _tts.SynthesizeAsync(text, ct)) { if (!started) { started = true; _audioOutput.Start(chunk.SampleRate, OutputDeviceName); _log($"TTS: first chunk ({sw.ElapsedMilliseconds}ms)"); } _audioOutput.WriteSamples(chunk.Samples); chunkCount++; totalSamples += chunk.Samples.Length; } if (!started) { _log("TTS: no audio produced"); } else { _audioOutput.Flush(); double durationSec = (double)totalSamples / _tts.SampleRate; _log($"TTS: done ({chunkCount} chunks, {durationSec:F2}s audio, {sw.ElapsedMilliseconds}ms)"); } } catch (OperationCanceledException) { _log("TTS: cancelled"); _audioOutput.Stop(); } catch (Exception ex) { _log($"TTS error: {ex.Message}"); } sw.Stop(); } public async ValueTask DisposeAsync() { if (_disposed) return; _currentCts?.Cancel(); _currentCts?.Dispose(); _synthCts?.Cancel(); _synthCts?.Dispose(); CancelSegmentTimer(); _sttSource.TranscriptReceived -= OnTranscript; await _sttSource.DisposeAsync(); await _tts.DisposeAsync(); _audioOutput.Dispose(); _disposed = true; } }