2026-08-10 11:27:28 +00:00
|
|
|
using Robovoice.Core;
|
|
|
|
|
using Robovoice.Tts.LibPiper;
|
|
|
|
|
|
|
|
|
|
namespace Robovoice.App;
|
|
|
|
|
|
|
|
|
|
internal sealed class Orchestrator : IAsyncDisposable
|
|
|
|
|
{
|
|
|
|
|
private readonly LibPiperTtsEngine _tts;
|
|
|
|
|
private readonly AudioOutput _audioOutput;
|
2026-08-11 09:03:50 +00:00
|
|
|
private readonly ISttSource _sttSource;
|
2026-08-10 11:27:28 +00:00
|
|
|
private readonly Action<string> _log;
|
|
|
|
|
private CancellationTokenSource? _currentCts;
|
|
|
|
|
private bool _disposed;
|
|
|
|
|
|
2026-08-12 10:30:11 +00:00
|
|
|
private readonly object _stateLock = new();
|
|
|
|
|
private readonly Queue<string> _pendingTexts = new();
|
|
|
|
|
private readonly List<float[]> _audioBuffer = new();
|
|
|
|
|
private int _bufferSampleRate;
|
|
|
|
|
private bool _playing;
|
|
|
|
|
private Task? _synthTask;
|
|
|
|
|
private CancellationTokenSource? _synthCts;
|
|
|
|
|
|
2026-08-10 11:27:28 +00:00
|
|
|
public string OutputDeviceName { get; set; } = string.Empty;
|
|
|
|
|
|
|
|
|
|
public Orchestrator(
|
|
|
|
|
LibPiperTtsEngine tts,
|
|
|
|
|
AudioOutput audioOutput,
|
2026-08-11 09:03:50 +00:00
|
|
|
ISttSource sttSource,
|
2026-08-10 11:27:28 +00:00
|
|
|
Action<string> log)
|
|
|
|
|
{
|
|
|
|
|
_tts = tts;
|
|
|
|
|
_audioOutput = audioOutput;
|
|
|
|
|
_audioOutput.Log = log;
|
|
|
|
|
_sttSource = sttSource;
|
|
|
|
|
_log = log;
|
|
|
|
|
_sttSource.TranscriptReceived += OnTranscript;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OnTranscript(object? sender, TranscriptEventArgs e)
|
|
|
|
|
{
|
2026-08-12 10:30:11 +00:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(msg.Text))
|
|
|
|
|
{
|
|
|
|
|
_log($"FINAL: \"{msg.Text}\" ({msg.Text.Length} chars)");
|
|
|
|
|
_pendingTexts.Enqueue(msg.Text);
|
|
|
|
|
EnsureSynthTask();
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
_log("FINAL: (empty)");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TransitionToPlaying();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void TransitionToPlaying()
|
|
|
|
|
{
|
|
|
|
|
if (_playing)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
if (_audioBuffer.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
if (_synthTask is null || _synthTask.IsCompleted)
|
|
|
|
|
{
|
|
|
|
|
_log("TTS: nothing to play");
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_playing = true;
|
|
|
|
|
|
|
|
|
|
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)
|
2026-08-10 11:27:28 +00:00
|
|
|
{
|
2026-08-12 10:30:11 +00:00
|
|
|
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)
|
|
|
|
|
{
|
|
|
|
|
_synthCts?.Cancel();
|
|
|
|
|
_synthCts?.Dispose();
|
|
|
|
|
_synthTask = null;
|
|
|
|
|
|
|
|
|
|
if (_playing)
|
|
|
|
|
{
|
|
|
|
|
_audioOutput.Stop();
|
|
|
|
|
_playing = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_pendingTexts.Clear();
|
|
|
|
|
_audioBuffer.Clear();
|
|
|
|
|
_bufferSampleRate = 0;
|
2026-08-10 11:27:28 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task InitializeTtsAsync()
|
|
|
|
|
{
|
|
|
|
|
_log("Initializing TTS engine...");
|
2026-08-13 09:58:33 +00:00
|
|
|
await Task.Run(() => _tts.InitializeAsync());
|
2026-08-10 11:27:28 +00:00
|
|
|
_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();
|
2026-08-12 10:30:11 +00:00
|
|
|
_log($"Speak: \"{text}\" ({text.Length} chars)");
|
2026-08-10 11:27:28 +00:00
|
|
|
|
|
|
|
|
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;
|
2026-08-13 09:58:33 +00:00
|
|
|
_disposed = true;
|
|
|
|
|
|
|
|
|
|
try { _currentCts?.Cancel(); } catch { }
|
2026-08-10 11:27:28 +00:00
|
|
|
_currentCts?.Dispose();
|
2026-08-13 09:58:33 +00:00
|
|
|
|
|
|
|
|
try { _synthCts?.Cancel(); } catch { }
|
2026-08-12 10:30:11 +00:00
|
|
|
_synthCts?.Dispose();
|
2026-08-13 09:58:33 +00:00
|
|
|
|
2026-08-10 11:27:28 +00:00
|
|
|
_sttSource.TranscriptReceived -= OnTranscript;
|
|
|
|
|
await _sttSource.DisposeAsync();
|
|
|
|
|
await _tts.DisposeAsync();
|
|
|
|
|
_audioOutput.Dispose();
|
|
|
|
|
}
|
|
|
|
|
}
|