diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..031c7b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +## .NET build output +bin/ +obj/ + +## NuGet +*.nupkg +*.snupkg +.nuget/ + +## User-specific files +*.user +*.suo +.vs/ +.idea/ + +## Test project (scratch) +Robovoice.Test/ + +## Native binaries (built from piper1-gpl, not source) +Robovoice.Tts.LibPiper/runtimes/ +Robovoice.Tts.LibPiper/piper.h + +## Voice models (downloaded at runtime) +*.onnx +*.onnx.json + +## Temp files +*.wav +*.raw diff --git a/Robovoice.App/AudioOutput.cs b/Robovoice.App/AudioOutput.cs new file mode 100644 index 0000000..b9cb923 --- /dev/null +++ b/Robovoice.App/AudioOutput.cs @@ -0,0 +1,112 @@ +using NAudio.Wave; + +namespace Robovoice.App; + +internal sealed class AudioOutput : IDisposable +{ + private WaveOutEvent? _waveOut; + private BufferedWaveProvider? _bufferProvider; + private int _sampleRate; + private string _deviceName = string.Empty; + private bool _playing; + private bool _disposed; + + public Action? Log { get; set; } + + public void Start(int sampleRate, string? deviceName = null) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + _sampleRate = sampleRate; + _deviceName = deviceName ?? string.Empty; + + Stop(); + + int deviceNumber = FindDevice(_deviceName); + _waveOut = new WaveOutEvent { DeviceNumber = deviceNumber, DesiredLatency = 200 }; + + _bufferProvider = new BufferedWaveProvider( + WaveFormat.CreateIeeeFloatWaveFormat(sampleRate, 1)) + { + BufferDuration = TimeSpan.FromSeconds(60), + DiscardOnBufferOverflow = true, + ReadFully = true, + }; + + _waveOut.Init(_bufferProvider); + _waveOut.PlaybackStopped += OnPlaybackStopped; + _playing = true; + _waveOut.Play(); + } + + public void WriteSamples(float[] samples) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_bufferProvider is null) return; + + byte[] bytes = new byte[samples.Length * sizeof(float)]; + Buffer.BlockCopy(samples, 0, bytes, 0, bytes.Length); + _bufferProvider.AddSamples(bytes, 0, bytes.Length); + } + + public void Flush() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_bufferProvider is null || _sampleRate == 0) return; + + int padSamples = (int)(_sampleRate * 0.5); + WriteSamples(new float[padSamples]); + } + + private void OnPlaybackStopped(object? sender, StoppedEventArgs e) + { + _playing = false; + } + + public void Stop() + { + if (_waveOut is not null) + { + _waveOut.PlaybackStopped -= OnPlaybackStopped; + _waveOut.Stop(); + _waveOut.Dispose(); + _waveOut = null; + } + _bufferProvider?.ClearBuffer(); + _bufferProvider = null; + _playing = false; + } + + private static int FindDevice(string? deviceName) + { + if (string.IsNullOrEmpty(deviceName)) + return -1; + + for (int i = 0; i < WaveOut.DeviceCount; i++) + { + var caps = WaveOut.GetCapabilities(i); + if (caps.ProductName.Contains(deviceName, StringComparison.OrdinalIgnoreCase)) + return i; + } + + return -1; + } + + public static IReadOnlyList<(int Index, string Name)> GetDevices() + { + var devices = new List<(int, string)>(); + for (int i = 0; i < WaveOut.DeviceCount; i++) + { + var caps = WaveOut.GetCapabilities(i); + devices.Add((i, caps.ProductName)); + } + return devices; + } + + public void Dispose() + { + if (_disposed) return; + Stop(); + _disposed = true; + } +} diff --git a/Robovoice.App/MainForm.Designer.cs b/Robovoice.App/MainForm.Designer.cs new file mode 100644 index 0000000..ae078d5 --- /dev/null +++ b/Robovoice.App/MainForm.Designer.cs @@ -0,0 +1,193 @@ +namespace Robovoice.App; + +partial class MainForm +{ + private System.ComponentModel.IContainer? components = null; + + private Label lblPttKey = null!; + private ComboBox cmbPttKey = null!; + private Label lblVoice = null!; + private ComboBox cmbVoice = null!; + private Button btnTestVoice = null!; + private Button btnManageVoices = null!; + private Label lblOutput = null!; + private ComboBox cmbOutput = null!; + private Label lblFile = null!; + private Button btnBrowseFile = null!; + private Label lblFileName = null!; + private Label lblLineStatus = null!; + private RichTextBox txtLog = null!; + private CheckBox chkMinimizeToTray = null!; + private Button btnClearLog = null!; + private Label lblTextInput = null!; + private TextBox txtTextInput = null!; + private Button btnSpeakInput = null!; + + protected override void Dispose(bool disposing) + { + if (disposing && components != null) + components.Dispose(); + base.Dispose(disposing); + } + + private void InitializeComponent() + { + components = new System.ComponentModel.Container(); + + lblPttKey = new Label(); + cmbPttKey = new ComboBox(); + lblVoice = new Label(); + cmbVoice = new ComboBox(); + btnTestVoice = new Button(); + btnManageVoices = new Button(); + lblOutput = new Label(); + cmbOutput = new ComboBox(); + lblFile = new Label(); + btnBrowseFile = new Button(); + lblFileName = new Label(); + lblLineStatus = new Label(); + txtLog = new RichTextBox(); + chkMinimizeToTray = new CheckBox(); + btnClearLog = new Button(); + lblTextInput = new Label(); + txtTextInput = new TextBox(); + btnSpeakInput = new Button(); + + SuspendLayout(); + + // lblPttKey + lblPttKey.Text = "PTT Key:"; + lblPttKey.Location = new Point(12, 15); + lblPttKey.Size = new Size(60, 23); + lblPttKey.TextAlign = ContentAlignment.MiddleLeft; + + // cmbPttKey + cmbPttKey.Location = new Point(75, 12); + cmbPttKey.Size = new Size(80, 23); + cmbPttKey.DropDownStyle = ComboBoxStyle.DropDownList; + + // lblVoice + lblVoice.Text = "Voice:"; + lblVoice.Location = new Point(170, 15); + lblVoice.Size = new Size(45, 23); + lblVoice.TextAlign = ContentAlignment.MiddleLeft; + + // cmbVoice + cmbVoice.Location = new Point(218, 12); + cmbVoice.Size = new Size(200, 23); + cmbVoice.DropDownStyle = ComboBoxStyle.DropDownList; + + // btnTestVoice + btnTestVoice.Text = "Test"; + btnTestVoice.Location = new Point(423, 11); + btnTestVoice.Size = new Size(45, 25); + btnTestVoice.UseVisualStyleBackColor = true; + + // btnManageVoices + btnManageVoices.Text = "Add/Remove..."; + btnManageVoices.Location = new Point(474, 11); + btnManageVoices.Size = new Size(95, 25); + btnManageVoices.UseVisualStyleBackColor = true; + + // lblOutput + lblOutput.Text = "Output:"; + lblOutput.Location = new Point(580, 15); + lblOutput.Size = new Size(50, 23); + lblOutput.TextAlign = ContentAlignment.MiddleLeft; + + // cmbOutput + cmbOutput.Location = new Point(633, 12); + cmbOutput.Size = new Size(180, 23); + cmbOutput.DropDownStyle = ComboBoxStyle.DropDownList; + + // lblFile + lblFile.Text = "Text File:"; + lblFile.Location = new Point(12, 48); + lblFile.Size = new Size(60, 23); + lblFile.TextAlign = ContentAlignment.MiddleLeft; + + // btnBrowseFile + btnBrowseFile.Text = "Browse..."; + btnBrowseFile.Location = new Point(75, 45); + btnBrowseFile.Size = new Size(75, 25); + btnBrowseFile.UseVisualStyleBackColor = true; + + // lblFileName + lblFileName.Text = "(none)"; + lblFileName.Location = new Point(155, 48); + lblFileName.Size = new Size(490, 23); + lblFileName.TextAlign = ContentAlignment.MiddleLeft; + lblFileName.ForeColor = Color.Gray; + + // lblLineStatus + lblLineStatus.Text = ""; + lblLineStatus.Location = new Point(650, 48); + lblLineStatus.Size = new Size(160, 23); + lblLineStatus.TextAlign = ContentAlignment.MiddleRight; + lblLineStatus.ForeColor = Color.DarkBlue; + + // txtLog + txtLog.Location = new Point(12, 112); + txtLog.Size = new Size(800, 310); + + // lblTextInput + lblTextInput.Text = "Text:"; + lblTextInput.Location = new Point(12, 84); + lblTextInput.Size = new Size(35, 23); + lblTextInput.TextAlign = ContentAlignment.MiddleLeft; + + // txtTextInput + txtTextInput.Location = new Point(50, 81); + txtTextInput.Size = new Size(680, 23); + + // btnSpeakInput + btnSpeakInput.Text = "Speak"; + btnSpeakInput.Location = new Point(737, 80); + btnSpeakInput.Size = new Size(75, 25); + btnSpeakInput.UseVisualStyleBackColor = true; + txtLog.ReadOnly = true; + txtLog.Font = new Font("Consolas", 9F); + txtLog.BackColor = Color.FromArgb(30, 30, 30); + txtLog.ForeColor = Color.FromArgb(220, 220, 220); + + // chkMinimizeToTray + chkMinimizeToTray.Text = "Minimize to tray on close"; + chkMinimizeToTray.Location = new Point(12, 440); + chkMinimizeToTray.Size = new Size(180, 24); + chkMinimizeToTray.UseVisualStyleBackColor = true; + + // btnClearLog + btnClearLog.Text = "Clear Log"; + btnClearLog.Location = new Point(737, 438); + btnClearLog.Size = new Size(75, 25); + btnClearLog.UseVisualStyleBackColor = true; + + // MainForm + AutoScaleDimensions = new SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(824, 472); + Controls.Add(lblPttKey); + Controls.Add(cmbPttKey); + Controls.Add(lblVoice); + Controls.Add(cmbVoice); + Controls.Add(btnTestVoice); + Controls.Add(btnManageVoices); + Controls.Add(lblOutput); + Controls.Add(cmbOutput); + Controls.Add(lblFile); + Controls.Add(btnBrowseFile); + Controls.Add(lblFileName); + Controls.Add(lblLineStatus); + Controls.Add(lblTextInput); + Controls.Add(txtTextInput); + Controls.Add(btnSpeakInput); + Controls.Add(txtLog); + Controls.Add(chkMinimizeToTray); + Controls.Add(btnClearLog); + MinimumSize = new Size(840, 510); + Text = "Robovoice"; + FormBorderStyle = FormBorderStyle.FixedSingle; + MaximizeBox = false; + ResumeLayout(false); + } +} diff --git a/Robovoice.App/MainForm.cs b/Robovoice.App/MainForm.cs new file mode 100644 index 0000000..930e15e --- /dev/null +++ b/Robovoice.App/MainForm.cs @@ -0,0 +1,355 @@ +using NAudio.Wave; +using Robovoice.App; +using Robovoice.Core; +using Robovoice.Core.Voices; +using Robovoice.Stt.File; +using Robovoice.Tts.LibPiper; +using System.Diagnostics; + +namespace Robovoice.App; + +internal sealed partial class MainForm : Form +{ + private readonly string _voicesDir = @"C:\data\opencode\robovoice-voices"; + private readonly string _espeakDataPath; + + private LibPiperTtsEngine? _tts; + private AudioOutput? _audioOutput; + private FileSttSource? _sttSource; + private Orchestrator? _orchestrator; + private PttHotkey? _pttHotkey; + private NotifyIcon? _trayIcon; + private bool _trayInit; + + public MainForm() + { + InitializeComponent(); + _espeakDataPath = Path.Combine(AppContext.BaseDirectory, "espeak-ng-data"); + Load += OnLoad; + FormClosing += OnFormClosing; + } + + private async void OnLoad(object? sender, EventArgs e) + { + PopulatePttKeys(); + PopulateVoices(); + PopulateOutputDevices(); + + cmbPttKey.SelectedItem = Keys.F8; + if (cmbVoice.Items.Count > 0) + cmbVoice.SelectedIndex = 0; + + AutoSelectCableOutput(); + + btnBrowseFile.Click += OnBrowseFile; + btnTestVoice.Click += OnTestVoice; + btnManageVoices.Click += OnManageVoices; + btnClearLog.Click += (_, _) => txtLog.Clear(); + cmbPttKey.SelectedIndexChanged += OnPttKeyChanged; + cmbOutput.SelectedIndexChanged += OnOutputChanged; + cmbVoice.SelectedIndexChanged += OnVoiceChanged; + + SetupTray(); + + await InitializeEngineAsync(); + } + + private void PopulatePttKeys() + { + var keys = new[] + { + Keys.F1, Keys.F2, Keys.F3, Keys.F4, Keys.F5, Keys.F6, + Keys.F7, Keys.F8, Keys.F9, Keys.F10, Keys.F11, Keys.F12, + }; + cmbPttKey.Items.AddRange(keys.Cast().ToArray()); + } + + private void PopulateVoices() + { + cmbVoice.Items.Clear(); + if (!Directory.Exists(_voicesDir)) return; + + foreach (var onnx in Directory.GetFiles(_voicesDir, "*.onnx")) + { + string name = Path.GetFileNameWithoutExtension(onnx); + cmbVoice.Items.Add(name); + } + } + + private void PopulateOutputDevices() + { + cmbOutput.Items.Clear(); + foreach (var (index, name) in AudioOutput.GetDevices()) + { + cmbOutput.Items.Add(name); + } + } + + private void AutoSelectCableOutput() + { + for (int i = 0; i < cmbOutput.Items.Count; i++) + { + if (cmbOutput.Items[i] is string s && s.Contains("CABLE", StringComparison.OrdinalIgnoreCase)) + { + cmbOutput.SelectedIndex = i; + return; + } + } + if (cmbOutput.Items.Count > 0) + cmbOutput.SelectedIndex = 0; + } + + private async Task InitializeEngineAsync() + { + if (cmbVoice.SelectedItem is not string voiceName) + { + Log("No voice selected."); + return; + } + + string modelPath = Path.Combine(_voicesDir, voiceName + ".onnx"); + if (!File.Exists(modelPath)) + { + Log($"Model not found: {modelPath}"); + return; + } + + if (!Directory.Exists(_espeakDataPath)) + { + Log($"espeak-ng-data not found at: {_espeakDataPath}"); + return; + } + + Log($"Loading voice: {voiceName}..."); + _audioOutput?.Dispose(); + _tts?.DisposeAsync().AsTask().Wait(); + + _tts = new LibPiperTtsEngine(modelPath, _espeakDataPath); + _audioOutput = new AudioOutput(); + _sttSource = new FileSttSource(); + _orchestrator?.DisposeAsync().AsTask().Wait(); + _orchestrator = new Orchestrator(_tts, _audioOutput, _sttSource, Log) + { + OutputDeviceName = cmbOutput.SelectedItem as string ?? string.Empty, + }; + + try + { + await _orchestrator.InitializeTtsAsync(); + Log("Engine ready. Select a text file and press PTT key."); + SetupHotkey(); + } + catch (Exception ex) + { + Log($"Init failed: {ex.Message}"); + } + } + + private void SetupHotkey() + { + _pttHotkey?.Dispose(); + _pttHotkey = new PttHotkey { Key = (Keys)(cmbPttKey.SelectedItem ?? Keys.F8) }; + _pttHotkey.Pressed += OnPttPressed; + _pttHotkey.Released += OnPttReleased; + _pttHotkey.Install(); + Log($"PTT hotkey installed: {_pttHotkey.Key}"); + } + + private void OnPttPressed(object? sender, EventArgs e) + { + Log("PTT pressed"); + } + + private void OnPttReleased(object? sender, EventArgs e) + { + Log("PTT released"); + if (_orchestrator is null || _sttSource is null) return; + + if (_sttSource.LineCount == 0) + { + Log("No text file loaded."); + return; + } + + UpdateLineStatus(); + _orchestrator.TriggerNextLine(); + UpdateLineStatus(); + } + + private void OnBrowseFile(object? sender, EventArgs e) + { + using var dlg = new OpenFileDialog + { + Filter = "Text files (*.txt)|*.txt|All files (*.*)|*.*", + Title = "Select a text file to read sequentially", + }; + + if (dlg.ShowDialog() == DialogResult.OK) + { + _sttSource?.LoadFile(dlg.FileName); + lblFileName.Text = Path.GetFileName(dlg.FileName); + lblFileName.ForeColor = Color.Black; + UpdateLineStatus(); + Log($"Loaded: {dlg.FileName} ({_sttSource?.LineCount} lines)"); + } + } + + 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; + } + } + + private void OnManageVoices(object? sender, EventArgs e) + { + string currentVoice = cmbVoice.SelectedItem as string ?? string.Empty; + using var dlg = new VoiceManagerForm(_voicesDir, currentVoice); + dlg.ShowDialog(this); + + PopulateVoices(); + + if (!string.IsNullOrEmpty(dlg.SelectedVoiceKey) && + cmbVoice.Items.Contains(dlg.SelectedVoiceKey)) + { + cmbVoice.SelectedItem = dlg.SelectedVoiceKey; + } + else if (cmbVoice.Items.Count > 0) + { + cmbVoice.SelectedIndex = 0; + } + + _ = 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) + { + _ = ReinitializeEngineAsync(name); + } + } + + private async Task ReinitializeEngineAsync(string voiceName) + { + if (!VoiceCatalogue.IsVoiceInstalled(_voicesDir, voiceName)) + { + btnTestVoice.Enabled = false; + return; + } + + await InitializeEngineAsync(); + } + + private void OnPttKeyChanged(object? sender, EventArgs e) + { + SetupHotkey(); + } + + 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}"); + } + + private void UpdateLineStatus() + { + if (_sttSource is null || _sttSource.LineCount == 0) + { + lblLineStatus.Text = ""; + return; + } + int displayIdx = (_sttSource.CurrentIndex % _sttSource.LineCount) + 1; + lblLineStatus.Text = $"Line {displayIdx}/{_sttSource.LineCount}"; + } + + private void SetupTray() + { + if (_trayInit) return; + _trayInit = true; + + _trayIcon = new NotifyIcon + { + Icon = SystemIcons.Application, + Text = "Robovoice", + Visible = true, + }; + + var menu = new ContextMenuStrip(); + menu.Items.Add("Show", null, (_, _) => ShowWindow()); + menu.Items.Add("Exit", null, (_, _) => + { + _trayIcon.Visible = false; + Application.Exit(); + }); + _trayIcon.ContextMenuStrip = menu; + _trayIcon.DoubleClick += (_, _) => ShowWindow(); + } + + private void ShowWindow() + { + Show(); + WindowState = FormWindowState.Normal; + Activate(); + } + + private void Log(string message) + { + if (IsDisposed) return; + if (InvokeRequired) + { + BeginInvoke(() => Log(message)); + return; + } + + string timestamp = DateTime.Now.ToString("HH:mm:ss"); + txtLog.AppendText($"[{timestamp}] {message}\n"); + txtLog.ScrollToCaret(); + } + + private void OnFormClosing(object? sender, FormClosingEventArgs e) + { + if (e.CloseReason == CloseReason.UserClosing && chkMinimizeToTray.Checked && _trayIcon?.Visible == true) + { + e.Cancel = true; + Hide(); + return; + } + + _pttHotkey?.Dispose(); + _trayIcon!.Visible = false; + _orchestrator?.DisposeAsync().AsTask().Wait(); + } +} diff --git a/Robovoice.App/Orchestrator.cs b/Robovoice.App/Orchestrator.cs new file mode 100644 index 0000000..c1d9756 --- /dev/null +++ b/Robovoice.App/Orchestrator.cs @@ -0,0 +1,120 @@ +using Robovoice.Core; +using Robovoice.Stt.File; +using Robovoice.Tts.LibPiper; + +namespace Robovoice.App; + +internal sealed class Orchestrator : IAsyncDisposable +{ + private readonly LibPiperTtsEngine _tts; + private readonly AudioOutput _audioOutput; + private readonly FileSttSource _sttSource; + private readonly Action _log; + private CancellationTokenSource? _currentCts; + private bool _disposed; + + public string OutputDeviceName { get; set; } = string.Empty; + + public Orchestrator( + LibPiperTtsEngine tts, + AudioOutput audioOutput, + FileSttSource sttSource, + Action log) + { + _tts = tts; + _audioOutput = audioOutput; + _audioOutput.Log = log; + _sttSource = sttSource; + _log = log; + _sttSource.TranscriptReceived += OnTranscript; + } + + private void OnTranscript(object? sender, TranscriptEventArgs e) + { + if (e.Message.Type == TranscriptType.Final) + { + _ = SynthesizeAsync(e.Message.Text); + } + } + + 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($"FINAL: \"{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 void TriggerNextLine() + { + _sttSource.EmitNext(); + } + + public string? GetPendingLine() => _sttSource.GetPendingLine(); + public int GetCurrentLineIndex() => _sttSource.CurrentIndex; + public int GetLineCount() => _sttSource.LineCount; + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _currentCts?.Cancel(); + _currentCts?.Dispose(); + _sttSource.TranscriptReceived -= OnTranscript; + await _sttSource.DisposeAsync(); + await _tts.DisposeAsync(); + _audioOutput.Dispose(); + _disposed = true; + } +} diff --git a/Robovoice.App/Program.cs b/Robovoice.App/Program.cs new file mode 100644 index 0000000..bc56ff6 --- /dev/null +++ b/Robovoice.App/Program.cs @@ -0,0 +1,11 @@ +namespace Robovoice.App; + +static class Program +{ + [STAThread] + static void Main() + { + ApplicationConfiguration.Initialize(); + Application.Run(new MainForm()); + } +} diff --git a/Robovoice.App/PttHotkey.cs b/Robovoice.App/PttHotkey.cs new file mode 100644 index 0000000..3e71e52 --- /dev/null +++ b/Robovoice.App/PttHotkey.cs @@ -0,0 +1,94 @@ +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace Robovoice.App; + +internal sealed class PttHotkey : IDisposable +{ + private const int WH_KEYBOARD_LL = 13; + private const int WM_KEYDOWN = 0x0100; + private const int WM_KEYUP = 0x0101; + private const int WM_SYSKEYDOWN = 0x0104; + private const int WM_SYSKEYUP = 0x0105; + + private readonly LowLevelKeyboardProc _proc; + private IntPtr _hook = IntPtr.Zero; + private bool _isDown; + private bool _disposed; + + public Keys Key { get; set; } = Keys.F8; + + public event EventHandler? Pressed; + public event EventHandler? Released; + + public PttHotkey() + { + _proc = HookCallback; + } + + public void Install() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_hook != IntPtr.Zero) return; + + IntPtr hModule = GetModuleHandle(null); + _hook = SetWindowsHookEx(WH_KEYBOARD_LL, _proc, hModule, 0); + } + + public void Uninstall() + { + if (_hook != IntPtr.Zero) + { + UnhookWindowsHookEx(_hook); + _hook = IntPtr.Zero; + _isDown = false; + } + } + + private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) + { + if (nCode >= 0) + { + int vkCode = Marshal.ReadInt32(lParam); + Keys key = (Keys)vkCode; + + bool isDown = wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN; + bool isUp = wParam == WM_KEYUP || wParam == WM_SYSKEYUP; + + if (key == Key && isDown && !_isDown) + { + _isDown = true; + Pressed?.Invoke(this, EventArgs.Empty); + } + else if (key == Key && isUp && _isDown) + { + _isDown = false; + Released?.Invoke(this, EventArgs.Empty); + } + } + + return CallNextHookEx(_hook, nCode, wParam, lParam); + } + + public void Dispose() + { + if (_disposed) return; + Uninstall(); + _disposed = true; + } + + private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool UnhookWindowsHookEx(IntPtr hhk); + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern IntPtr GetModuleHandle(string? lpModuleName); +} diff --git a/Robovoice.App/Robovoice.App.csproj b/Robovoice.App/Robovoice.App.csproj new file mode 100644 index 0000000..9602244 --- /dev/null +++ b/Robovoice.App/Robovoice.App.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + WinExe + net10.0-windows + enable + true + enable + + + \ No newline at end of file diff --git a/Robovoice.App/VoiceManagerForm.Designer.cs b/Robovoice.App/VoiceManagerForm.Designer.cs new file mode 100644 index 0000000..0e9fab8 --- /dev/null +++ b/Robovoice.App/VoiceManagerForm.Designer.cs @@ -0,0 +1,116 @@ +namespace Robovoice.App; + +partial class VoiceManagerForm +{ + private System.ComponentModel.IContainer? components = null; + + private Label lblFilter = null!; + private TextBox txtFilter = null!; + private DataGridView gridVoices = null!; + private Button btnDownload = null!; + private Button btnRemove = null!; + private Button btnClose = null!; + private Label lblStatus = null!; + private ProgressBar progressBar = null!; + + protected override void Dispose(bool disposing) + { + if (disposing && components != null) + components.Dispose(); + base.Dispose(disposing); + } + + private void InitializeComponent() + { + components = new System.ComponentModel.Container(); + + lblFilter = new Label(); + txtFilter = new TextBox(); + gridVoices = new DataGridView(); + btnDownload = new Button(); + btnRemove = new Button(); + btnClose = new Button(); + lblStatus = new Label(); + progressBar = new ProgressBar(); + + ((System.ComponentModel.ISupportInitialize)gridVoices).BeginInit(); + SuspendLayout(); + + // lblFilter + lblFilter.Text = "Filter:"; + lblFilter.Location = new Point(12, 15); + lblFilter.Size = new Size(45, 23); + lblFilter.TextAlign = ContentAlignment.MiddleLeft; + + // txtFilter + txtFilter.Location = new Point(60, 12); + txtFilter.Size = new Size(300, 23); + + // gridVoices + gridVoices.Location = new Point(12, 42); + gridVoices.Size = new Size(660, 380); + gridVoices.AllowUserToAddRows = false; + gridVoices.AllowUserToDeleteRows = false; + gridVoices.AllowUserToResizeRows = false; + gridVoices.ReadOnly = true; + gridVoices.SelectionMode = DataGridViewSelectionMode.FullRowSelect; + gridVoices.MultiSelect = false; + gridVoices.RowHeadersVisible = false; + gridVoices.BackgroundColor = SystemColors.Window; + gridVoices.BorderStyle = BorderStyle.FixedSingle; + gridVoices.CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal; + gridVoices.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize; + gridVoices.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill; + + // btnDownload + btnDownload.Text = "Download"; + btnDownload.Location = new Point(12, 430); + btnDownload.Size = new Size(90, 28); + btnDownload.UseVisualStyleBackColor = true; + + // btnRemove + btnRemove.Text = "Remove"; + btnRemove.Location = new Point(108, 430); + btnRemove.Size = new Size(90, 28); + btnRemove.UseVisualStyleBackColor = true; + + // btnClose + btnClose.Text = "Close"; + btnClose.Location = new Point(582, 430); + btnClose.Size = new Size(90, 28); + btnClose.UseVisualStyleBackColor = true; + + // lblStatus + lblStatus.Text = ""; + lblStatus.Location = new Point(12, 465); + lblStatus.Size = new Size(660, 20); + lblStatus.TextAlign = ContentAlignment.MiddleLeft; + lblStatus.ForeColor = Color.DarkBlue; + + // progressBar + progressBar.Location = new Point(208, 433); + progressBar.Size = new Size(360, 23); + progressBar.Visible = false; + + // VoiceManagerForm + AutoScaleDimensions = new SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(684, 495); + Controls.Add(lblFilter); + Controls.Add(txtFilter); + Controls.Add(gridVoices); + Controls.Add(btnDownload); + Controls.Add(btnRemove); + Controls.Add(btnClose); + Controls.Add(lblStatus); + Controls.Add(progressBar); + MinimumSize = new Size(700, 535); + Text = "Voice Manager"; + FormBorderStyle = FormBorderStyle.FixedSingle; + MaximizeBox = false; + StartPosition = FormStartPosition.CenterParent; + + ((System.ComponentModel.ISupportInitialize)gridVoices).EndInit(); + ResumeLayout(false); + } +} diff --git a/Robovoice.App/VoiceManagerForm.cs b/Robovoice.App/VoiceManagerForm.cs new file mode 100644 index 0000000..0e39936 --- /dev/null +++ b/Robovoice.App/VoiceManagerForm.cs @@ -0,0 +1,285 @@ +using Robovoice.Core.Voices; +using System.ComponentModel; + +namespace Robovoice.App; + +internal sealed partial class VoiceManagerForm : Form +{ + private readonly string _voicesDir; + private readonly VoiceCatalogue _catalogue; + private IReadOnlyList _allVoices = Array.Empty(); + private HashSet _installedKeys = new(); + private bool _catalogueLoaded; + + public string? SelectedVoiceKey { get; private set; } + + public VoiceManagerForm(string voicesDir, string? currentVoiceKey) + { + InitializeComponent(); + _voicesDir = voicesDir; + _catalogue = new VoiceCatalogue(); + SelectedVoiceKey = currentVoiceKey; + + txtFilter.TextChanged += (_, _) => ApplyFilter(); + btnDownload.Click += OnDownload; + btnRemove.Click += OnRemove; + btnClose.Click += (_, _) => Close(); + gridVoices.SelectionChanged += OnSelectionChanged; + Load += OnLoad; + FormClosing += OnFormClosing; + } + + private async void OnLoad(object? sender, EventArgs e) + { + btnDownload.Enabled = false; + btnRemove.Enabled = false; + + RefreshInstalled(); + lblStatus.Text = "Fetching voice catalogue..."; + progressBar.Visible = true; + progressBar.Style = ProgressBarStyle.Marquee; + + try + { + _allVoices = await _catalogue.GetCatalogueAsync(); + _catalogueLoaded = true; + PopulateGrid(); + lblStatus.Text = $"{_allVoices.Count} voices available, {_installedKeys.Count} installed"; + } + catch (Exception ex) + { + lblStatus.Text = $"Failed to fetch catalogue: {ex.Message}"; + } + finally + { + progressBar.Visible = false; + } + } + + private void RefreshInstalled() + { + _installedKeys = VoiceCatalogue + .GetInstalledVoices(_voicesDir) + .ToHashSet(); + } + + private void PopulateGrid() + { + gridVoices.Rows.Clear(); + gridVoices.Columns.Clear(); + + gridVoices.Columns.Add(new DataGridViewTextBoxColumn + { + Name = "Key", + HeaderText = "Key", + FillWeight = 40, + }); + gridVoices.Columns.Add(new DataGridViewTextBoxColumn + { + Name = "Language", + HeaderText = "Language", + FillWeight = 25, + }); + gridVoices.Columns.Add(new DataGridViewTextBoxColumn + { + Name = "Voice", + HeaderText = "Voice", + FillWeight = 20, + }); + gridVoices.Columns.Add(new DataGridViewTextBoxColumn + { + Name = "Quality", + HeaderText = "Quality", + FillWeight = 15, + }); + gridVoices.Columns.Add(new DataGridViewTextBoxColumn + { + Name = "Size", + HeaderText = "Size", + FillWeight = 15, + }); + gridVoices.Columns.Add(new DataGridViewTextBoxColumn + { + Name = "Status", + HeaderText = "Status", + FillWeight = 15, + }); + + foreach (var voice in _allVoices) + { + bool installed = _installedKeys.Contains(voice.Key); + int rowIdx = gridVoices.Rows.Add( + voice.Key, + voice.Language.NameEnglish, + voice.Name, + voice.Quality, + voice.SizeDisplay, + installed ? "Installed" : "Available"); + + var row = gridVoices.Rows[rowIdx]; + row.Tag = voice.Key; + if (installed) + { + row.DefaultCellStyle.BackColor = Color.FromArgb(235, 245, 235); + if (voice.Key == SelectedVoiceKey) + { + row.DefaultCellStyle.Font = new Font(gridVoices.Font, FontStyle.Bold); + row.Selected = true; + } + } + else + { + row.DefaultCellStyle.ForeColor = Color.Gray; + } + } + + if (gridVoices.SelectedRows.Count == 0 && gridVoices.Rows.Count > 0) + gridVoices.Rows[0].Selected = true; + } + + private void ApplyFilter() + { + string filter = txtFilter.Text.Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(filter)) + { + foreach (DataGridViewRow row in gridVoices.Rows) + row.Visible = true; + return; + } + + foreach (DataGridViewRow row in gridVoices.Rows) + { + if (row.Tag is not string key) + { + row.Visible = false; + continue; + } + + var voice = _allVoices.FirstOrDefault(v => v.Key == key); + if (voice is null) + { + row.Visible = false; + continue; + } + + string haystack = $"{key} {voice.Name} {voice.Language.NameEnglish} {voice.Language.Code} {voice.Language.Family} {voice.Quality}".ToLowerInvariant(); + row.Visible = haystack.Contains(filter); + } + } + + private void OnSelectionChanged(object? sender, EventArgs e) + { + if (gridVoices.SelectedRows.Count == 0) return; + var row = gridVoices.SelectedRows[0]; + if (row.Tag is not string key) return; + + bool installed = _installedKeys.Contains(key); + btnDownload.Enabled = !installed && _catalogueLoaded; + btnRemove.Enabled = installed; + } + + private async void OnDownload(object? sender, EventArgs e) + { + if (gridVoices.SelectedRows.Count == 0) return; + var row = gridVoices.SelectedRows[0]; + if (row.Tag is not string key) return; + + var voice = _allVoices.FirstOrDefault(v => v.Key == key); + if (voice is null) return; + + btnDownload.Enabled = false; + btnRemove.Enabled = false; + progressBar.Visible = true; + progressBar.Style = ProgressBarStyle.Continuous; + progressBar.Value = 0; + lblStatus.Text = $"Downloading {key} ({voice.SizeDisplay})..."; + + try + { + var progress = new Progress<(long downloaded, long total)>(p => + { + if (p.total > 0) + { + progressBar.Value = (int)(p.downloaded * 100 / p.total); + lblStatus.Text = $"Downloading {key}... {p.downloaded / (1024 * 1024)} / {p.total / (1024 * 1024)} MB"; + } + }); + + await _catalogue.DownloadVoiceAsync(voice, _voicesDir, progress); + RefreshInstalled(); + UpdateRowStatus(key, installed: true); + lblStatus.Text = $"Downloaded {key} successfully."; + } + catch (Exception ex) + { + lblStatus.Text = $"Download failed: {ex.Message}"; + } + finally + { + progressBar.Visible = false; + OnSelectionChanged(null, EventArgs.Empty); + } + } + + private void OnRemove(object? sender, EventArgs e) + { + if (gridVoices.SelectedRows.Count == 0) return; + var row = gridVoices.SelectedRows[0]; + if (row.Tag is not string key) return; + + var dlgResult = MessageBox.Show( + $"Remove voice '{key}'?\nThis will delete the .onnx and .onnx.json files.", + "Confirm Remove", + MessageBoxButtons.YesNo, + MessageBoxIcon.Question); + + if (dlgResult != DialogResult.Yes) return; + + try + { + VoiceCatalogue.RemoveVoice(_voicesDir, key); + RefreshInstalled(); + UpdateRowStatus(key, installed: false); + if (SelectedVoiceKey == key) + SelectedVoiceKey = null; + lblStatus.Text = $"Removed {key}."; + } + catch (Exception ex) + { + lblStatus.Text = $"Remove failed: {ex.Message}"; + } + + OnSelectionChanged(null, EventArgs.Empty); + } + + private void UpdateRowStatus(string key, bool installed) + { + foreach (DataGridViewRow row in gridVoices.Rows) + { + if (row.Tag is string rowKey && rowKey == key) + { + row.Cells["Status"].Value = installed ? "Installed" : "Available"; + row.DefaultCellStyle.BackColor = installed + ? Color.FromArgb(235, 245, 235) + : Color.White; + row.DefaultCellStyle.ForeColor = installed + ? Color.Black + : Color.Gray; + break; + } + } + + lblStatus.Text = $"{_allVoices.Count} voices available, {_installedKeys.Count} installed"; + } + + private void OnFormClosing(object? sender, FormClosingEventArgs e) + { + if (gridVoices.SelectedRows.Count > 0 && gridVoices.SelectedRows[0].Tag is string key) + { + if (_installedKeys.Contains(key)) + SelectedVoiceKey = key; + } + + _catalogue.DisposeAsync().AsTask().Wait(); + } +} diff --git a/Robovoice.Core/AudioChunk.cs b/Robovoice.Core/AudioChunk.cs new file mode 100644 index 0000000..44fefb1 --- /dev/null +++ b/Robovoice.Core/AudioChunk.cs @@ -0,0 +1,3 @@ +namespace Robovoice.Core; + +public sealed record AudioChunk(float[] Samples, int SampleRate); diff --git a/Robovoice.Core/ISttSource.cs b/Robovoice.Core/ISttSource.cs new file mode 100644 index 0000000..f91c362 --- /dev/null +++ b/Robovoice.Core/ISttSource.cs @@ -0,0 +1,15 @@ +namespace Robovoice.Core; + +public interface ISttSource : IAsyncDisposable +{ + Task StartAsync(CancellationToken ct = default); + + Task StopAsync(CancellationToken ct = default); +} + +public sealed class TranscriptEventArgs : EventArgs +{ + public TranscriptMessage Message { get; init; } = new(TranscriptType.Final, string.Empty); +} + +public delegate void TranscriptEventHandler(object? sender, TranscriptEventArgs e); diff --git a/Robovoice.Core/ITtsEngine.cs b/Robovoice.Core/ITtsEngine.cs new file mode 100644 index 0000000..520df38 --- /dev/null +++ b/Robovoice.Core/ITtsEngine.cs @@ -0,0 +1,27 @@ +using System.Runtime.CompilerServices; + +namespace Robovoice.Core; + +public interface ITtsEngine : IAsyncDisposable +{ + string Name { get; } + + int SampleRate { get; } + + Task InitializeAsync(CancellationToken ct = default); + + IAsyncEnumerable SynthesizeAsync( + string text, + CancellationToken ct = default); +} + +public static class TtsEngineExtensions +{ + public static ConfiguredCancelableAsyncEnumerable SynthesizeAsync( + this ITtsEngine engine, + string text, + CancellationToken ct = default) + { + return engine.SynthesizeAsync(text, ct).ConfigureAwait(false).WithCancellation(ct); + } +} diff --git a/Robovoice.Core/Robovoice.Core.csproj b/Robovoice.Core/Robovoice.Core.csproj new file mode 100644 index 0000000..6d36c6d --- /dev/null +++ b/Robovoice.Core/Robovoice.Core.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/Robovoice.Core/TranscriptMessage.cs b/Robovoice.Core/TranscriptMessage.cs new file mode 100644 index 0000000..4e6b4db --- /dev/null +++ b/Robovoice.Core/TranscriptMessage.cs @@ -0,0 +1,9 @@ +namespace Robovoice.Core; + +public enum TranscriptType +{ + Partial, + Final, +} + +public sealed record TranscriptMessage(TranscriptType Type, string Text); diff --git a/Robovoice.Core/Voices/VoiceCatalogue.cs b/Robovoice.Core/Voices/VoiceCatalogue.cs new file mode 100644 index 0000000..10085d2 --- /dev/null +++ b/Robovoice.Core/Voices/VoiceCatalogue.cs @@ -0,0 +1,131 @@ +using System.Collections.Concurrent; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text.Json; + +namespace Robovoice.Core.Voices; + +public sealed class VoiceCatalogue : IAsyncDisposable +{ + private const string CatalogueUrl = + "https://huggingface.co/rhasspy/piper-voices/resolve/main/voices.json"; + private const string DownloadBaseUrl = + "https://huggingface.co/rhasspy/piper-voices/resolve/main/"; + + private readonly HttpClient _http; + private List? _catalogue; + private bool _disposed; + + public VoiceCatalogue() + { + _http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + } + + public async Task> GetCatalogueAsync( + CancellationToken ct = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_catalogue is not null) + return _catalogue; + + var dict = await _http.GetFromJsonAsync>( + CatalogueUrl, ct) + ?? throw new InvalidOperationException("Failed to fetch voice catalogue."); + + _catalogue = dict.Values + .OrderBy(v => v.Language.NameEnglish) + .ThenBy(v => v.Name) + .ThenBy(v => v.Quality) + .ToList(); + + return _catalogue; + } + + public static IReadOnlyList GetInstalledVoices(string voicesDir) + { + if (!Directory.Exists(voicesDir)) + return Array.Empty(); + + return Directory.GetFiles(voicesDir, "*.onnx") + .Select(f => Path.GetFileNameWithoutExtension(f)!) + .OrderBy(n => n) + .ToList(); + } + + public static bool IsVoiceInstalled(string voicesDir, string voiceKey) + { + string onnxPath = Path.Combine(voicesDir, voiceKey + ".onnx"); + string jsonPath = Path.Combine(voicesDir, voiceKey + ".onnx.json"); + return File.Exists(onnxPath) && File.Exists(jsonPath); + } + + public async Task DownloadVoiceAsync( + VoiceInfo voice, + string voicesDir, + IProgress<(long downloaded, long total)>? progress = null, + CancellationToken ct = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + Directory.CreateDirectory(voicesDir); + + var onnxFile = voice.Files.GetValueOrDefault(voice.OnnxFileKey) + ?? throw new InvalidOperationException($"No .onnx file for {voice.Key}"); + var jsonFile = voice.Files.GetValueOrDefault(voice.JsonFileKey) + ?? throw new InvalidOperationException($"No .onnx.json file for {voice.Key}"); + + string onnxPath = Path.Combine(voicesDir, voice.Key + ".onnx"); + string jsonPath = Path.Combine(voicesDir, voice.Key + ".onnx.json"); + + await DownloadFileAsync( + DownloadBaseUrl + voice.OnnxFileKey, + onnxPath, onnxFile.SizeBytes, onnxFile.Md5Digest, progress, ct); + + await DownloadFileAsync( + DownloadBaseUrl + voice.JsonFileKey, + jsonPath, jsonFile.SizeBytes, jsonFile.Md5Digest, null, ct); + } + + private async Task DownloadFileAsync( + string url, + string destPath, + long expectedSize, + string expectedMd5, + IProgress<(long, long)>? progress, + CancellationToken ct) + { + using var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); + resp.EnsureSuccessStatusCode(); + + long total = resp.Content.Headers.ContentLength ?? expectedSize; + long downloaded = 0; + + await using var contentStream = await resp.Content.ReadAsStreamAsync(ct); + await using var fileStream = File.Create(destPath); + + byte[] buffer = new byte[81920]; + int read; + while ((read = await contentStream.ReadAsync(buffer, ct)) > 0) + { + await fileStream.WriteAsync(buffer.AsMemory(0, read), ct); + downloaded += read; + progress?.Report((downloaded, total)); + } + } + + public static void RemoveVoice(string voicesDir, string voiceKey) + { + string onnxPath = Path.Combine(voicesDir, voiceKey + ".onnx"); + string jsonPath = Path.Combine(voicesDir, voiceKey + ".onnx.json"); + + if (File.Exists(onnxPath)) File.Delete(onnxPath); + if (File.Exists(jsonPath)) File.Delete(jsonPath); + } + + public ValueTask DisposeAsync() + { + if (_disposed) return ValueTask.CompletedTask; + _http.Dispose(); + _disposed = true; + return ValueTask.CompletedTask; + } +} diff --git a/Robovoice.Core/Voices/VoiceInfo.cs b/Robovoice.Core/Voices/VoiceInfo.cs new file mode 100644 index 0000000..f18b5af --- /dev/null +++ b/Robovoice.Core/Voices/VoiceInfo.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Serialization; + +namespace Robovoice.Core.Voices; + +public sealed class VoiceInfo +{ + public string Key { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public VoiceLanguage Language { get; set; } = new(); + public string Quality { get; set; } = string.Empty; + public int NumSpeakers { get; set; } + public VoiceFiles Files { get; set; } = new(); + public List Aliases { get; set; } = new(); + + [JsonIgnore] + public string DisplayName => + $"{Language.NameEnglish} ({Language.Code}) — {Name} [{Quality}]"; + + [JsonIgnore] + public long SizeBytes => + Files.FirstOrDefault(f => f.Key.EndsWith(".onnx")).Value?.SizeBytes ?? 0; + + [JsonIgnore] + public string SizeDisplay + { + get + { + double mb = SizeBytes / (1024.0 * 1024.0); + return mb >= 1024 ? $"{mb / 1024:F1} GB" : $"{mb:F0} MB"; + } + } + + [JsonIgnore] + public string OnnxFileKey => Files.Keys.FirstOrDefault(k => k.EndsWith(".onnx")) ?? ""; + + [JsonIgnore] + public string JsonFileKey => Files.Keys.FirstOrDefault(k => k.EndsWith(".onnx.json")) ?? ""; +} + +public sealed class VoiceLanguage +{ + public string Code { get; set; } = string.Empty; + public string Family { get; set; } = string.Empty; + public string Region { get; set; } = string.Empty; + + [JsonPropertyName("name_native")] + public string NameNative { get; set; } = string.Empty; + + [JsonPropertyName("name_english")] + public string NameEnglish { get; set; } = string.Empty; + + [JsonPropertyName("country_english")] + public string CountryEnglish { get; set; } = string.Empty; +} + +public sealed class VoiceFiles : Dictionary { } + +public sealed class VoiceFile +{ + [JsonPropertyName("size_bytes")] + public long SizeBytes { get; set; } + + [JsonPropertyName("md5_digest")] + public string Md5Digest { get; set; } = string.Empty; +} diff --git a/Robovoice.Stt.File/FileSttSource.cs b/Robovoice.Stt.File/FileSttSource.cs new file mode 100644 index 0000000..0f7151c --- /dev/null +++ b/Robovoice.Stt.File/FileSttSource.cs @@ -0,0 +1,83 @@ +using Robovoice.Core; + +namespace Robovoice.Stt.File; + +public sealed class FileSttSource : ISttSource +{ + private readonly object _lock = new(); + private string[] _lines = Array.Empty(); + private int _currentIndex; + private bool _disposed; + + public event TranscriptEventHandler? TranscriptReceived; + + public string FilePath { get; set; } = string.Empty; + public int LineCount { get; private set; } + public int CurrentIndex => _currentIndex; + + public void LoadFile(string path) + { + ObjectDisposedException.ThrowIf(_disposed, this); + FilePath = path; + _lines = System.IO.File.ReadAllLines(path); + LineCount = _lines.Length; + _currentIndex = 0; + } + + public string? GetPendingLine() + { + lock (_lock) + { + if (_lines.Length == 0) return null; + int idx = _currentIndex % _lines.Length; + return _lines[idx]; + } + } + + public void EmitNext() + { + ObjectDisposedException.ThrowIf(_disposed, this); + string? line = null; + lock (_lock) + { + if (_lines.Length > 0) + { + int idx = _currentIndex % _lines.Length; + line = _lines[idx]; + _currentIndex++; + } + } + + if (!string.IsNullOrWhiteSpace(line)) + { + TranscriptReceived?.Invoke(this, new TranscriptEventArgs + { + Message = new TranscriptMessage(TranscriptType.Final, line!), + }); + } + } + + public void Reset() + { + lock (_lock) + { + _currentIndex = 0; + } + } + + public Task StartAsync(CancellationToken ct = default) + { + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken ct = default) + { + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + _disposed = true; + return ValueTask.CompletedTask; + } +} diff --git a/Robovoice.Stt.File/Robovoice.Stt.File.csproj b/Robovoice.Stt.File/Robovoice.Stt.File.csproj new file mode 100644 index 0000000..a946c55 --- /dev/null +++ b/Robovoice.Stt.File/Robovoice.Stt.File.csproj @@ -0,0 +1,13 @@ + + + + + + + + net10.0 + enable + enable + + + diff --git a/Robovoice.Stt.Udp/Robovoice.Stt.Udp.csproj b/Robovoice.Stt.Udp/Robovoice.Stt.Udp.csproj new file mode 100644 index 0000000..a946c55 --- /dev/null +++ b/Robovoice.Stt.Udp/Robovoice.Stt.Udp.csproj @@ -0,0 +1,13 @@ + + + + + + + + net10.0 + enable + enable + + + diff --git a/Robovoice.Tts.LibPiper/LibPiperTtsEngine.cs b/Robovoice.Tts.LibPiper/LibPiperTtsEngine.cs new file mode 100644 index 0000000..c4db517 --- /dev/null +++ b/Robovoice.Tts.LibPiper/LibPiperTtsEngine.cs @@ -0,0 +1,179 @@ +using System.Runtime.InteropServices; +using Robovoice.Core; + +namespace Robovoice.Tts.LibPiper; + +public sealed class LibPiperTtsEngine : ITtsEngine +{ + private readonly string _modelPath; + private readonly string _espeakDataPath; + private readonly float? _noiseScaleOverride; + private readonly float? _lengthScaleOverride; + private readonly float? _noiseWScaleOverride; + private float _noiseScale; + private float _lengthScale; + private float _noiseWScale; + + private IntPtr _synth; + private int _sampleRate; + private bool _initialized; + private bool _disposed; + + public string Name => "libpiper"; + + public int SampleRate => _sampleRate; + + public LibPiperTtsEngine( + string modelPath, + string espeakDataPath, + float? noiseScale = null, + float? lengthScale = null, + float? noiseWScale = null) + { + _modelPath = modelPath; + _espeakDataPath = espeakDataPath; + _noiseScaleOverride = noiseScale; + _lengthScaleOverride = lengthScale; + _noiseWScaleOverride = noiseWScale; + _noiseScale = noiseScale ?? 0.667f; + _lengthScale = lengthScale ?? 1.0f; + _noiseWScale = noiseWScale ?? 0.8f; + } + + public Task InitializeAsync(CancellationToken ct = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_initialized) + return Task.CompletedTask; + + NativeDependencyLoader.EnsureLoaded(); + + _synth = PiperCreateUtf8(_modelPath, _modelPath + ".json", _espeakDataPath); + if (_synth == IntPtr.Zero) + throw new InvalidOperationException( + $"piper_create failed for model: {_modelPath}"); + + var defaults = PiperNative.piper_default_synthesize_options(_synth); + _noiseScale = _noiseScaleOverride ?? defaults.NoiseScale; + _lengthScale = _lengthScaleOverride ?? defaults.LengthScale; + _noiseWScale = _noiseWScaleOverride ?? defaults.NoiseWScale; + + _sampleRate = 22050; + _initialized = true; + return Task.CompletedTask; + } + + public async IAsyncEnumerable SynthesizeAsync( + string text, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_initialized) + throw new InvalidOperationException("Engine not initialized."); + + var options = new PiperSynthesizeOptions + { + SpeakerId = 0, + LengthScale = _lengthScale, + NoiseScale = _noiseScale, + NoiseWScale = _noiseWScale, + }; + + byte[] textBytes = System.Text.Encoding.UTF8.GetBytes(EnsureTerminator(text) + "\0"); + GCHandle textPin = GCHandle.Alloc(textBytes, GCHandleType.Pinned); + try + { + int startResult = PiperNative.piper_synthesize_start( + _synth, textPin.AddrOfPinnedObject(), in options); + if (startResult != PiperNative.PiperOk) + throw new InvalidOperationException($"piper_synthesize_start failed: {startResult}"); + + while (true) + { + ct.ThrowIfCancellationRequested(); + + PiperAudioChunk chunk = default; + int result = await Task.Run(() => PiperNative.piper_synthesize_next(_synth, out chunk), ct); + + if (chunk.NumSamples > 0 && chunk.Samples != IntPtr.Zero) + { + int numSamples = (int)chunk.NumSamples; + float[] samples = new float[numSamples]; + Marshal.Copy(chunk.Samples, samples, 0, numSamples); + + if (chunk.SampleRate > 0) + _sampleRate = chunk.SampleRate; + + yield return new AudioChunk(samples, _sampleRate); + } + + if (result == PiperNative.PiperDone || chunk.IsLast) + break; + + if (result < 0) + throw new InvalidOperationException($"piper_synthesize_next failed: {result}"); + } + } + finally + { + textPin.Free(); + } + } + + private static string EnsureTerminator(string text) + { + string trimmed = text.TrimEnd(); + if (trimmed.Length == 0) + return text; + char last = trimmed[^1]; + if (last is '.' or '!' or '?' or ',' or ';' or ':' or ')' or ']' or '}' or '"' or '\'' or '。' or '!' or '?') + return text; + return trimmed + "."; + } + + private static IntPtr PiperCreateUtf8(string modelPath, string? configPath, string espeakDataPath) + { + byte[] modelBytes = System.Text.Encoding.UTF8.GetBytes(modelPath + "\0"); + byte[] espeakBytes = System.Text.Encoding.UTF8.GetBytes(espeakDataPath + "\0"); + + GCHandle modelPin = GCHandle.Alloc(modelBytes, GCHandleType.Pinned); + GCHandle espeakPin = GCHandle.Alloc(espeakBytes, GCHandleType.Pinned); + GCHandle? configPin = null; + byte[]? configBytes = null; + + if (configPath is not null) + { + configBytes = System.Text.Encoding.UTF8.GetBytes(configPath + "\0"); + configPin = GCHandle.Alloc(configBytes, GCHandleType.Pinned); + } + + try + { + return PiperNative.piper_create( + modelPin.AddrOfPinnedObject(), + configPin?.AddrOfPinnedObject() ?? IntPtr.Zero, + espeakPin.AddrOfPinnedObject()); + } + finally + { + modelPin.Free(); + espeakPin.Free(); + configPin?.Free(); + } + } + + public ValueTask DisposeAsync() + { + if (_disposed) + return ValueTask.CompletedTask; + + if (_synth != IntPtr.Zero) + { + PiperNative.piper_free(_synth); + _synth = IntPtr.Zero; + } + + _disposed = true; + return ValueTask.CompletedTask; + } +} diff --git a/Robovoice.Tts.LibPiper/NativeDependencyLoader.cs b/Robovoice.Tts.LibPiper/NativeDependencyLoader.cs new file mode 100644 index 0000000..3ff5143 --- /dev/null +++ b/Robovoice.Tts.LibPiper/NativeDependencyLoader.cs @@ -0,0 +1,28 @@ +using System.Runtime.InteropServices; + +namespace Robovoice.Tts.LibPiper; + +internal static class NativeDependencyLoader +{ + private static int _loaded; + + public static void EnsureLoaded() + { + if (Interlocked.CompareExchange(ref _loaded, 1, 0) != 0) + return; + + string baseDir = AppContext.BaseDirectory; + string nativeDir = Path.Combine(baseDir, "runtimes", "win-x64", "native"); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && Directory.Exists(nativeDir)) + { + if (!SetDllDirectory(nativeDir)) + throw new InvalidOperationException( + $"SetDllDirectory failed for: {nativeDir}"); + } + } + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetDllDirectory(string lpPathName); +} diff --git a/Robovoice.Tts.LibPiper/PiperNative.cs b/Robovoice.Tts.LibPiper/PiperNative.cs new file mode 100644 index 0000000..dd617f9 --- /dev/null +++ b/Robovoice.Tts.LibPiper/PiperNative.cs @@ -0,0 +1,65 @@ +using System.Runtime.InteropServices; + +namespace Robovoice.Tts.LibPiper; + +[StructLayout(LayoutKind.Sequential)] +internal struct PiperSynthesizeOptions +{ + public int SpeakerId; + public float LengthScale; + public float NoiseScale; + public float NoiseWScale; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct PiperAudioChunk +{ + public IntPtr Samples; + public nuint NumSamples; + public int SampleRate; + + [MarshalAs(UnmanagedType.U1)] + public bool IsLast; + + public IntPtr Phonemes; + public nuint NumPhonemes; + public IntPtr PhonemeIds; + public nuint NumPhonemeIds; + public IntPtr Alignments; + public nuint NumAlignments; +} + +internal static class PiperNative +{ + private const string LibName = "piper"; + + public const int PiperOk = 0; + public const int PiperDone = 1; + public const int PiperErrGeneric = -1; + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr piper_create( + IntPtr modelPath, + IntPtr configPath, + IntPtr espeakDataPath); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern void piper_free(IntPtr synth); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern PiperSynthesizeOptions piper_default_synthesize_options(IntPtr synth); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern int piper_synthesize_start( + IntPtr synth, + IntPtr text, + in PiperSynthesizeOptions options); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern int piper_synthesize_next( + IntPtr synth, + out PiperAudioChunk chunk); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr piper_version(); +} diff --git a/Robovoice.Tts.LibPiper/Robovoice.Tts.LibPiper.csproj b/Robovoice.Tts.LibPiper/Robovoice.Tts.LibPiper.csproj new file mode 100644 index 0000000..3746410 --- /dev/null +++ b/Robovoice.Tts.LibPiper/Robovoice.Tts.LibPiper.csproj @@ -0,0 +1,21 @@ + + + + + + + + net10.0 + enable + enable + x64 + + + + + + + + + + diff --git a/Robovoice.slnx b/Robovoice.slnx new file mode 100644 index 0000000..7206f29 --- /dev/null +++ b/Robovoice.slnx @@ -0,0 +1,7 @@ + + + + + + +