v0.9: VoicePipeline refactor — queue-based pipeline, lock/release model, thread-based TCP
This commit is contained in:
+163
-141
@@ -17,10 +17,11 @@ internal sealed partial class MainForm : Form
|
||||
private LibPiperTtsEngine? _tts;
|
||||
private AudioOutput? _audioOutput;
|
||||
private TcpSttSource? _sttSource;
|
||||
private Orchestrator? _orchestrator;
|
||||
private VoicePipeline? _pipeline;
|
||||
private PttHotkey? _pttHotkey;
|
||||
private NotifyIcon? _trayIcon;
|
||||
private bool _trayInit;
|
||||
private bool _locked;
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
@@ -65,14 +66,11 @@ internal sealed partial class MainForm : Form
|
||||
chkMinimizeToTray.Checked = _config.MinimizeToTray;
|
||||
txtSttEndpoint.Text = _config.SttEndpoint;
|
||||
|
||||
btnBrowseFile.Click += OnBrowseFile;
|
||||
btnTestVoice.Click += OnTestVoice;
|
||||
btnManageVoices.Click += OnManageVoices;
|
||||
btnClearLog.Click += (_, _) => txtLog.Clear();
|
||||
btnLock.Click += OnLockToggle;
|
||||
txtPttKey.Enter += OnPttKeyFocus;
|
||||
txtPttKey.KeyDown += OnPttKeyDown;
|
||||
cmbOutput.SelectedIndexChanged += OnOutputChanged;
|
||||
cmbVoice.SelectedIndexChanged += OnVoiceChanged;
|
||||
txtSttEndpoint.Leave += OnSttEndpointChanged;
|
||||
|
||||
trkNoise.Scroll += OnSliderScroll;
|
||||
@@ -90,10 +88,8 @@ internal sealed partial class MainForm : Form
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"OnLoad failed: {ex}");
|
||||
Debug.WriteLine($"OnLoad failed: {ex}");
|
||||
}
|
||||
|
||||
BeginInvoke(async () => await InitializeEngineAsync());
|
||||
}
|
||||
|
||||
private Keys _pttKey = Keys.F8;
|
||||
@@ -216,7 +212,21 @@ internal sealed partial class MainForm : Form
|
||||
cmbOutput.SelectedIndex = 0;
|
||||
}
|
||||
|
||||
private async Task InitializeEngineAsync()
|
||||
// ─── Lock / Release ────────────────────────────────────────────────────
|
||||
|
||||
private async void OnLockToggle(object? sender, EventArgs e)
|
||||
{
|
||||
if (_locked)
|
||||
{
|
||||
ReleaseModel();
|
||||
}
|
||||
else
|
||||
{
|
||||
await LockModelAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LockModelAsync()
|
||||
{
|
||||
if (cmbVoice.SelectedItem is not string voiceName)
|
||||
{
|
||||
@@ -237,44 +247,98 @@ internal sealed partial class MainForm : Form
|
||||
return;
|
||||
}
|
||||
|
||||
btnLock.Enabled = false;
|
||||
Log($"Loading voice: {voiceName}...");
|
||||
_audioOutput?.Dispose();
|
||||
if (_tts is not null)
|
||||
await _tts.DisposeAsync();
|
||||
|
||||
_tts = new LibPiperTtsEngine(
|
||||
modelPath,
|
||||
_espeakDataPath,
|
||||
noiseScale: trkNoise.Value / 1000.0f,
|
||||
lengthScale: trkSpeed.Value / 100.0f,
|
||||
noiseWScale: trkNoiseW.Value / 1000.0f);
|
||||
_audioOutput = new AudioOutput();
|
||||
|
||||
try
|
||||
{
|
||||
if (_sttSource is null)
|
||||
{
|
||||
_sttSource = new TcpSttSource { Endpoint = txtSttEndpoint.Text, Log = Log };
|
||||
await _sttSource.StartAsync();
|
||||
}
|
||||
_tts = new LibPiperTtsEngine(
|
||||
modelPath,
|
||||
_espeakDataPath,
|
||||
noiseScale: trkNoise.Value / 1000.0f,
|
||||
lengthScale: trkSpeed.Value / 100.0f,
|
||||
noiseWScale: trkNoiseW.Value / 1000.0f);
|
||||
|
||||
if (_orchestrator is not null)
|
||||
await _orchestrator.DisposeAsync();
|
||||
_orchestrator = new Orchestrator(_tts, _audioOutput, _sttSource, Log)
|
||||
await Task.Run(() => _tts.InitializeAsync());
|
||||
Log($"TTS ready (sample rate: {_tts.SampleRate} Hz)");
|
||||
|
||||
_audioOutput = new AudioOutput();
|
||||
|
||||
_sttSource = new TcpSttSource { Endpoint = txtSttEndpoint.Text, Log = Log };
|
||||
_sttSource.TranscriptReceived += OnTranscript;
|
||||
await _sttSource.StartAsync();
|
||||
|
||||
_pipeline = new VoicePipeline(_tts, _audioOutput, Log)
|
||||
{
|
||||
OutputDeviceName = cmbOutput.SelectedItem as string ?? string.Empty,
|
||||
};
|
||||
_pipeline.Start();
|
||||
|
||||
_locked = true;
|
||||
SetControlsLocked(true);
|
||||
btnLock.Text = "Release Model";
|
||||
btnLock.Enabled = true;
|
||||
|
||||
await _orchestrator.InitializeTtsAsync();
|
||||
Log("Engine ready. Press PTT to send to STT server.");
|
||||
SetupHotkey();
|
||||
Log("Model locked. Press PTT to talk.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Init failed: {ex.Message}");
|
||||
Log($"Lock failed: {ex.Message}");
|
||||
btnLock.Enabled = true;
|
||||
_tts?.DisposeAsync().AsTask().Wait();
|
||||
_tts = null;
|
||||
_audioOutput?.Dispose();
|
||||
_audioOutput = null;
|
||||
if (_sttSource is not null)
|
||||
{
|
||||
_sttSource.TranscriptReceived -= OnTranscript;
|
||||
_sttSource.DisposeAsync().AsTask().Wait();
|
||||
_sttSource = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ReleaseModel()
|
||||
{
|
||||
if (!_locked) return;
|
||||
|
||||
_pttHotkey?.Dispose();
|
||||
_pttHotkey = null;
|
||||
|
||||
if (_sttSource is not null)
|
||||
{
|
||||
_sttSource.TranscriptReceived -= OnTranscript;
|
||||
_sttSource.DisposeAsync().AsTask().Wait();
|
||||
_sttSource = null;
|
||||
}
|
||||
|
||||
_pipeline?.Dispose();
|
||||
_pipeline = null;
|
||||
|
||||
// VoicePipeline.Dispose calls _tts.DisposeAsync and _audioOutput.Dispose
|
||||
_tts = null;
|
||||
_audioOutput = null;
|
||||
|
||||
_locked = false;
|
||||
SetControlsLocked(false);
|
||||
btnLock.Text = "Lock Model";
|
||||
Log("Model released.");
|
||||
}
|
||||
|
||||
private void SetControlsLocked(bool locked)
|
||||
{
|
||||
cmbVoice.Enabled = !locked;
|
||||
cmbOutput.Enabled = !locked;
|
||||
trkNoise.Enabled = !locked;
|
||||
trkSpeed.Enabled = !locked;
|
||||
trkNoiseW.Enabled = !locked;
|
||||
txtSttEndpoint.Enabled = !locked;
|
||||
btnManageVoices.Enabled = !locked;
|
||||
}
|
||||
|
||||
// ─── PTT ────────────────────────────────────────────────────────────────
|
||||
|
||||
private void SetupHotkey()
|
||||
{
|
||||
_pttHotkey?.Dispose();
|
||||
@@ -287,86 +351,56 @@ internal sealed partial class MainForm : Form
|
||||
|
||||
private void OnPttPressed(object? sender, EventArgs e)
|
||||
{
|
||||
Log("PTT pressed");
|
||||
_orchestrator?.NotifyPttPressed();
|
||||
try
|
||||
{
|
||||
_sttSource?.SendOn();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"SendOn failed: {ex.Message}");
|
||||
}
|
||||
_pipeline?.OnPttPressed();
|
||||
_sttSource?.SendOn();
|
||||
}
|
||||
|
||||
private void OnPttReleased(object? sender, EventArgs e)
|
||||
{
|
||||
Log("PTT released");
|
||||
try
|
||||
{
|
||||
_sttSource?.SendOff();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"SendOff failed: {ex.Message}");
|
||||
}
|
||||
_sttSource?.SendOff();
|
||||
_pipeline?.OnPttReleased();
|
||||
}
|
||||
|
||||
private void OnBrowseFile(object? sender, EventArgs e)
|
||||
// ─── STT transcript → pipeline ──────────────────────────────────────────
|
||||
|
||||
private void OnTranscript(object? sender, TranscriptEventArgs e)
|
||||
{
|
||||
using var dlg = new OpenFileDialog
|
||||
{
|
||||
Filter = "Text files (*.txt)|*.txt|All files (*.*)|*.*",
|
||||
Title = "Select a text file to speak",
|
||||
};
|
||||
if (_pipeline is null || !_locked) return;
|
||||
|
||||
if (dlg.ShowDialog() == DialogResult.OK)
|
||||
var msg = e.Message;
|
||||
if (msg.Type == TranscriptType.Partial)
|
||||
{
|
||||
string text = File.ReadAllText(dlg.FileName);
|
||||
lblFileName.Text = Path.GetFileName(dlg.FileName);
|
||||
lblFileName.ForeColor = Color.Black;
|
||||
Log($"Loaded: {dlg.FileName} ({text.Length} chars)");
|
||||
_ = _orchestrator?.SynthesizeAsync(text);
|
||||
if (!string.IsNullOrWhiteSpace(msg.Text))
|
||||
{
|
||||
Log($"SEGMENT: \"{msg.Text}\"");
|
||||
_pipeline.EnqueueSegment(msg.Text);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(msg.Text))
|
||||
{
|
||||
Log($"FINAL: \"{msg.Text}\"");
|
||||
_pipeline.EnqueueFinal(msg.Text);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("FINAL: (empty)");
|
||||
_pipeline.EnqueueFinal("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTestVoice(object? sender, EventArgs e)
|
||||
{
|
||||
if (_orchestrator is null)
|
||||
{
|
||||
Log("Engine not initialized.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmbVoice.SelectedItem is not string voiceName)
|
||||
{
|
||||
Log("No voice selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!VoiceCatalogue.IsVoiceInstalled(_voicesDir, voiceName))
|
||||
{
|
||||
Log($"Voice '{voiceName}' is not installed. Use Add/Remove to download it.");
|
||||
return;
|
||||
}
|
||||
|
||||
string text = txtTextInput.Text.Trim();
|
||||
if (string.IsNullOrEmpty(text))
|
||||
text = "Hello, this is a voice test.";
|
||||
|
||||
btnTestVoice.Enabled = false;
|
||||
try
|
||||
{
|
||||
_ = _orchestrator.SynthesizeAsync(text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
btnTestVoice.Enabled = true;
|
||||
}
|
||||
}
|
||||
// ─── Voice manager ─────────────────────────────────────────────────────
|
||||
|
||||
private void OnManageVoices(object? sender, EventArgs e)
|
||||
{
|
||||
if (_locked)
|
||||
{
|
||||
Log("Release the model before managing voices.");
|
||||
return;
|
||||
}
|
||||
|
||||
string currentVoice = cmbVoice.SelectedItem as string ?? string.Empty;
|
||||
using var dlg = new VoiceManagerForm(_voicesDir, currentVoice);
|
||||
dlg.ShowDialog(this);
|
||||
@@ -384,31 +418,9 @@ internal sealed partial class MainForm : Form
|
||||
}
|
||||
|
||||
SaveConfig();
|
||||
_ = InitializeEngineAsync();
|
||||
}
|
||||
|
||||
private void OnVoiceChanged(object? sender, EventArgs e)
|
||||
{
|
||||
btnTestVoice.Enabled = cmbVoice.SelectedItem is string voiceName
|
||||
&& VoiceCatalogue.IsVoiceInstalled(_voicesDir, voiceName);
|
||||
|
||||
if (cmbVoice.SelectedItem is string name)
|
||||
{
|
||||
SaveConfig();
|
||||
_ = ReinitializeEngineAsync(name);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReinitializeEngineAsync(string voiceName)
|
||||
{
|
||||
if (!VoiceCatalogue.IsVoiceInstalled(_voicesDir, voiceName))
|
||||
{
|
||||
btnTestVoice.Enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await InitializeEngineAsync();
|
||||
}
|
||||
// ─── Slider / endpoint ─────────────────────────────────────────────────
|
||||
|
||||
private void OnSliderScroll(object? sender, EventArgs e)
|
||||
{
|
||||
@@ -420,33 +432,16 @@ internal sealed partial class MainForm : Form
|
||||
private void OnSliderReleased(object? sender, MouseEventArgs e)
|
||||
{
|
||||
SaveConfig();
|
||||
if (cmbVoice.SelectedItem is string name && VoiceCatalogue.IsVoiceInstalled(_voicesDir, name))
|
||||
{
|
||||
_ = InitializeEngineAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOutputChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_orchestrator is not null)
|
||||
_orchestrator.OutputDeviceName = cmbOutput.SelectedItem as string ?? string.Empty;
|
||||
Log($"Output device: {_orchestrator?.OutputDeviceName}");
|
||||
SaveConfig();
|
||||
}
|
||||
|
||||
private async void OnSttEndpointChanged(object? sender, EventArgs e)
|
||||
private void OnSttEndpointChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_config.SttEndpoint = txtSttEndpoint.Text;
|
||||
SaveConfig();
|
||||
|
||||
if (_sttSource is not null)
|
||||
{
|
||||
await _sttSource.DisposeAsync();
|
||||
_sttSource = null;
|
||||
await InitializeEngineAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tray ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void SetupTray()
|
||||
{
|
||||
if (_trayInit) return;
|
||||
@@ -503,6 +498,8 @@ internal sealed partial class MainForm : Form
|
||||
Activate();
|
||||
}
|
||||
|
||||
// ─── Config ────────────────────────────────────────────────────────────
|
||||
|
||||
private void SaveConfig()
|
||||
{
|
||||
_config.Voice = cmbVoice.SelectedItem as string ?? string.Empty;
|
||||
@@ -516,6 +513,8 @@ internal sealed partial class MainForm : Form
|
||||
_config.Save();
|
||||
}
|
||||
|
||||
// ─── Logging ───────────────────────────────────────────────────────────
|
||||
|
||||
private void Log(string message)
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
@@ -530,12 +529,35 @@ internal sealed partial class MainForm : Form
|
||||
txtLog.ScrollToCaret();
|
||||
}
|
||||
|
||||
// ─── Shutdown ──────────────────────────────────────────────────────────
|
||||
|
||||
private static string DisposeLogPath => Path.Combine(AppConfig.AppDataDir, "dispose.log");
|
||||
|
||||
private static void LogDispose(string label, long elapsedMs)
|
||||
{
|
||||
string line = $"[{DateTime.Now:HH:mm:ss.fff}] {label}: {elapsedMs}ms";
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(AppConfig.AppDataDir);
|
||||
File.AppendAllText(DisposeLogPath, line + "\n");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private void OnFormClosing(object? sender, FormClosingEventArgs e)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
_pttHotkey?.Dispose();
|
||||
LogDispose("pttHotkey.Dispose", sw.ElapsedMilliseconds);
|
||||
|
||||
_trayIcon!.Visible = false;
|
||||
_orchestrator?.DisposeAsync().AsTask().Wait(2000);
|
||||
_sttSource?.DisposeAsync().AsTask().Wait(2000);
|
||||
|
||||
sw.Restart();
|
||||
ReleaseModel();
|
||||
LogDispose("ReleaseModel", sw.ElapsedMilliseconds);
|
||||
|
||||
sw.Restart();
|
||||
SaveConfig();
|
||||
LogDispose("SaveConfig", sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user