Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a58b94f35 | |||
| 08d6eeb46e | |||
| fbbb52df90 | |||
| 9b0dc11ee6 | |||
| f7ea07b78e | |||
| 8585972a1e |
+29
@@ -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
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Text.Json;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace Robovoice.App;
|
||||
|
||||
public sealed class AppConfig
|
||||
{
|
||||
public string Voice { get; set; } = string.Empty;
|
||||
public int PttKey { get; set; } = (int)Keys.F8;
|
||||
public string OutputDevice { get; set; } = string.Empty;
|
||||
public int NoiseScale { get; set; } = 667;
|
||||
public int LengthScale { get; set; } = 100;
|
||||
public int NoiseWScale { get; set; } = 800;
|
||||
public bool MinimizeToTray { get; set; } = true;
|
||||
public string ServerEndpoint { get; set; } = "127.0.0.1:5210";
|
||||
|
||||
public static string AppDataDir => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Hackmaster", "Robovoice");
|
||||
|
||||
public static string ConfigPath => Path.Combine(AppDataDir, "config.json");
|
||||
public static string VoicesDir => Path.Combine(AppDataDir, "Piper-voices");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
public static AppConfig Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(ConfigPath))
|
||||
{
|
||||
using var stream = File.OpenRead(ConfigPath);
|
||||
return JsonSerializer.Deserialize<AppConfig>(stream) ?? new AppConfig();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
return new AppConfig();
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(AppDataDir);
|
||||
using var stream = File.Create(ConfigPath);
|
||||
JsonSerializer.Serialize(stream, this, JsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
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 _disposed;
|
||||
|
||||
public Action<string>? 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;
|
||||
_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)
|
||||
{
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_waveOut is not null)
|
||||
{
|
||||
_waveOut.PlaybackStopped -= OnPlaybackStopped;
|
||||
_waveOut.Stop();
|
||||
_waveOut.Dispose();
|
||||
_waveOut = null;
|
||||
}
|
||||
_bufferProvider?.ClearBuffer();
|
||||
_bufferProvider = null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Robovoice.App;
|
||||
|
||||
internal static class IconExtractor
|
||||
{
|
||||
private const string Source = @"%SystemRoot%\System32\mmres.dll";
|
||||
private static readonly int[] CandidateIndices = { 5, 12 };
|
||||
|
||||
public static Icon? TryExtractMicrophone(int size = 32)
|
||||
{
|
||||
string path = Environment.ExpandEnvironmentVariables(Source);
|
||||
if (!File.Exists(path)) return null;
|
||||
|
||||
foreach (int index in CandidateIndices)
|
||||
{
|
||||
if (ExtractIconAt(path, index, size, size) is { } icon)
|
||||
return icon;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Icon? ExtractIconAt(string path, int index, int width, int height)
|
||||
{
|
||||
IntPtr[] hicons = new IntPtr[1];
|
||||
IntPtr[] ids = new IntPtr[1];
|
||||
|
||||
int count = PrivateExtractIcons(path, index, width, height, hicons, ids, 1, 0);
|
||||
if (count <= 0 || hicons[0] == IntPtr.Zero) return null;
|
||||
|
||||
try
|
||||
{
|
||||
return Icon.FromHandle(hicons[0]);
|
||||
}
|
||||
catch
|
||||
{
|
||||
DestroyIcon(hicons[0]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern int PrivateExtractIcons(
|
||||
string lpszFile,
|
||||
int nIconIndex,
|
||||
int cxIcon,
|
||||
int cyIcon,
|
||||
IntPtr[] phicon,
|
||||
IntPtr[] phiconId,
|
||||
int nIcons,
|
||||
int flags);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DestroyIcon(IntPtr hIcon);
|
||||
}
|
||||
Generated
+303
@@ -0,0 +1,303 @@
|
||||
#nullable enable
|
||||
namespace Robovoice.App;
|
||||
|
||||
partial class MainForm
|
||||
{
|
||||
private System.ComponentModel.IContainer? components = null;
|
||||
|
||||
private Label lblPttKey = null!;
|
||||
private TextBox txtPttKey = 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 lblServer = null!;
|
||||
private TextBox txtServer = null!;
|
||||
private Button btnConnect = 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 Label lblNoise = null!;
|
||||
private TrackBar trkNoise = null!;
|
||||
private Label lblNoiseVal = null!;
|
||||
private Label lblSpeed = null!;
|
||||
private TrackBar trkSpeed = null!;
|
||||
private Label lblSpeedVal = null!;
|
||||
private Label lblNoiseW = null!;
|
||||
private TrackBar trkNoiseW = null!;
|
||||
private Label lblNoiseWVal = 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();
|
||||
txtPttKey = new TextBox();
|
||||
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();
|
||||
lblServer = new Label();
|
||||
txtServer = new TextBox();
|
||||
btnConnect = new Button();
|
||||
lblLineStatus = new Label();
|
||||
txtLog = new RichTextBox();
|
||||
chkMinimizeToTray = new CheckBox();
|
||||
btnClearLog = new Button();
|
||||
lblTextInput = new Label();
|
||||
txtTextInput = new TextBox();
|
||||
lblNoise = new Label();
|
||||
trkNoise = new TrackBar();
|
||||
lblNoiseVal = new Label();
|
||||
lblSpeed = new Label();
|
||||
trkSpeed = new TrackBar();
|
||||
lblSpeedVal = new Label();
|
||||
lblNoiseW = new Label();
|
||||
trkNoiseW = new TrackBar();
|
||||
lblNoiseWVal = new Label();
|
||||
|
||||
SuspendLayout();
|
||||
|
||||
// lblPttKey
|
||||
lblPttKey.Text = "PTT Key:";
|
||||
lblPttKey.Location = new Point(12, 15);
|
||||
lblPttKey.Size = new Size(60, 23);
|
||||
lblPttKey.TextAlign = ContentAlignment.MiddleLeft;
|
||||
|
||||
// txtPttKey
|
||||
txtPttKey.Location = new Point(75, 12);
|
||||
txtPttKey.Size = new Size(80, 23);
|
||||
txtPttKey.ReadOnly = true;
|
||||
txtPttKey.TextAlign = HorizontalAlignment.Center;
|
||||
txtPttKey.TabStop = false;
|
||||
|
||||
// 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(280, 23);
|
||||
lblFileName.TextAlign = ContentAlignment.MiddleLeft;
|
||||
lblFileName.ForeColor = Color.Gray;
|
||||
|
||||
// lblServer
|
||||
lblServer.Text = "Server:";
|
||||
lblServer.Location = new Point(440, 48);
|
||||
lblServer.Size = new Size(45, 23);
|
||||
lblServer.TextAlign = ContentAlignment.MiddleLeft;
|
||||
|
||||
// txtServer
|
||||
txtServer.Location = new Point(488, 45);
|
||||
txtServer.Size = new Size(110, 23);
|
||||
|
||||
// btnConnect
|
||||
btnConnect.Text = "Connect";
|
||||
btnConnect.Location = new Point(603, 44);
|
||||
btnConnect.Size = new Size(60, 25);
|
||||
btnConnect.UseVisualStyleBackColor = true;
|
||||
|
||||
// lblLineStatus
|
||||
lblLineStatus.Text = "";
|
||||
lblLineStatus.Location = new Point(645, 48);
|
||||
lblLineStatus.Size = new Size(160, 23);
|
||||
lblLineStatus.TextAlign = ContentAlignment.MiddleRight;
|
||||
lblLineStatus.ForeColor = Color.DarkBlue;
|
||||
|
||||
// lblNoise
|
||||
lblNoise.Text = "Noise:";
|
||||
lblNoise.Location = new Point(12, 82);
|
||||
lblNoise.Size = new Size(40, 23);
|
||||
lblNoise.TextAlign = ContentAlignment.MiddleLeft;
|
||||
|
||||
// trkNoise (0-1000 → 0.0-1.0, default 667)
|
||||
trkNoise.Location = new Point(52, 78);
|
||||
trkNoise.Size = new Size(120, 45);
|
||||
trkNoise.Minimum = 0;
|
||||
trkNoise.Maximum = 1000;
|
||||
trkNoise.Value = 667;
|
||||
trkNoise.TickFrequency = 200;
|
||||
trkNoise.Orientation = Orientation.Horizontal;
|
||||
|
||||
// lblNoiseVal
|
||||
lblNoiseVal.Text = "0.667";
|
||||
lblNoiseVal.Location = new Point(175, 82);
|
||||
lblNoiseVal.Size = new Size(35, 23);
|
||||
lblNoiseVal.TextAlign = ContentAlignment.MiddleLeft;
|
||||
|
||||
// lblSpeed
|
||||
lblSpeed.Text = "Speed:";
|
||||
lblSpeed.Location = new Point(220, 82);
|
||||
lblSpeed.Size = new Size(40, 23);
|
||||
lblSpeed.TextAlign = ContentAlignment.MiddleLeft;
|
||||
|
||||
// trkSpeed (50-300 → 0.5-3.0, default 100)
|
||||
trkSpeed.Location = new Point(260, 78);
|
||||
trkSpeed.Size = new Size(120, 45);
|
||||
trkSpeed.Minimum = 50;
|
||||
trkSpeed.Maximum = 300;
|
||||
trkSpeed.Value = 100;
|
||||
trkSpeed.TickFrequency = 50;
|
||||
trkSpeed.Orientation = Orientation.Horizontal;
|
||||
|
||||
// lblSpeedVal
|
||||
lblSpeedVal.Text = "1.00";
|
||||
lblSpeedVal.Location = new Point(383, 82);
|
||||
lblSpeedVal.Size = new Size(35, 23);
|
||||
lblSpeedVal.TextAlign = ContentAlignment.MiddleLeft;
|
||||
|
||||
// lblNoiseW
|
||||
lblNoiseW.Text = "NoiseW:";
|
||||
lblNoiseW.Location = new Point(428, 82);
|
||||
lblNoiseW.Size = new Size(45, 23);
|
||||
lblNoiseW.TextAlign = ContentAlignment.MiddleLeft;
|
||||
|
||||
// trkNoiseW (0-1000 → 0.0-1.0, default 800)
|
||||
trkNoiseW.Location = new Point(475, 78);
|
||||
trkNoiseW.Size = new Size(120, 45);
|
||||
trkNoiseW.Minimum = 0;
|
||||
trkNoiseW.Maximum = 1000;
|
||||
trkNoiseW.Value = 800;
|
||||
trkNoiseW.TickFrequency = 200;
|
||||
trkNoiseW.Orientation = Orientation.Horizontal;
|
||||
|
||||
// lblNoiseWVal
|
||||
lblNoiseWVal.Text = "0.800";
|
||||
lblNoiseWVal.Location = new Point(598, 82);
|
||||
lblNoiseWVal.Size = new Size(45, 23);
|
||||
lblNoiseWVal.TextAlign = ContentAlignment.MiddleLeft;
|
||||
|
||||
// txtLog
|
||||
txtLog.Location = new Point(12, 149);
|
||||
txtLog.Size = new Size(800, 310);
|
||||
|
||||
// lblTextInput
|
||||
lblTextInput.Text = "Text:";
|
||||
lblTextInput.Location = new Point(12, 121);
|
||||
lblTextInput.Size = new Size(35, 23);
|
||||
lblTextInput.TextAlign = ContentAlignment.MiddleLeft;
|
||||
|
||||
// txtTextInput
|
||||
txtTextInput.Location = new Point(50, 118);
|
||||
txtTextInput.Size = new Size(762, 23);
|
||||
|
||||
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, 477);
|
||||
chkMinimizeToTray.Size = new Size(180, 24);
|
||||
chkMinimizeToTray.UseVisualStyleBackColor = true;
|
||||
|
||||
// btnClearLog
|
||||
btnClearLog.Text = "Clear Log";
|
||||
btnClearLog.Location = new Point(737, 475);
|
||||
btnClearLog.Size = new Size(75, 25);
|
||||
btnClearLog.UseVisualStyleBackColor = true;
|
||||
|
||||
// MainForm
|
||||
AutoScaleDimensions = new SizeF(7F, 15F);
|
||||
AutoScaleMode = AutoScaleMode.Font;
|
||||
ClientSize = new Size(824, 509);
|
||||
Controls.Add(lblPttKey);
|
||||
Controls.Add(txtPttKey);
|
||||
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(lblServer);
|
||||
Controls.Add(txtServer);
|
||||
Controls.Add(btnConnect);
|
||||
Controls.Add(lblLineStatus);
|
||||
Controls.Add(lblNoise);
|
||||
Controls.Add(trkNoise);
|
||||
Controls.Add(lblNoiseVal);
|
||||
Controls.Add(lblSpeed);
|
||||
Controls.Add(trkSpeed);
|
||||
Controls.Add(lblSpeedVal);
|
||||
Controls.Add(lblNoiseW);
|
||||
Controls.Add(trkNoiseW);
|
||||
Controls.Add(lblNoiseWVal);
|
||||
Controls.Add(lblTextInput);
|
||||
Controls.Add(txtTextInput);
|
||||
Controls.Add(txtLog);
|
||||
Controls.Add(chkMinimizeToTray);
|
||||
Controls.Add(btnClearLog);
|
||||
MinimumSize = new Size(840, 547);
|
||||
Text = "Robovoice";
|
||||
FormBorderStyle = FormBorderStyle.FixedSingle;
|
||||
MaximizeBox = false;
|
||||
ResumeLayout(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
using NAudio.Wave;
|
||||
using Robovoice.App;
|
||||
using Robovoice.Core;
|
||||
using Robovoice.Core.Voices;
|
||||
using Robovoice.Stt.Tcp;
|
||||
using Robovoice.Tts.LibPiper;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Robovoice.App;
|
||||
|
||||
internal sealed partial class MainForm : Form
|
||||
{
|
||||
private readonly string _voicesDir = AppConfig.VoicesDir;
|
||||
private readonly string _espeakDataPath;
|
||||
private readonly AppConfig _config;
|
||||
|
||||
private LibPiperTtsEngine? _tts;
|
||||
private AudioOutput? _audioOutput;
|
||||
private TcpSttSource? _sttSource;
|
||||
private Orchestrator? _orchestrator;
|
||||
private PttHotkey? _pttHotkey;
|
||||
private NotifyIcon? _trayIcon;
|
||||
private bool _trayInit;
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
InitializeComponent();
|
||||
_espeakDataPath = Path.Combine(AppContext.BaseDirectory, "espeak-ng-data");
|
||||
_config = AppConfig.Load();
|
||||
Directory.CreateDirectory(AppConfig.AppDataDir);
|
||||
Directory.CreateDirectory(_voicesDir);
|
||||
Text = $"Robovoice {AppVersion}";
|
||||
Load += OnLoad;
|
||||
FormClosing += OnFormClosing;
|
||||
}
|
||||
|
||||
private async void OnLoad(object? sender, EventArgs e)
|
||||
{
|
||||
ActiveControl = txtLog;
|
||||
PopulateVoices();
|
||||
PopulateOutputDevices();
|
||||
|
||||
_pttKey = (Keys)_config.PttKey;
|
||||
if (_pttKey == Keys.None)
|
||||
_pttKey = Keys.F8;
|
||||
txtPttKey.Text = KeyToDisplayString(_pttKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(_config.Voice) && cmbVoice.Items.Contains(_config.Voice))
|
||||
cmbVoice.SelectedItem = _config.Voice;
|
||||
else if (cmbVoice.Items.Count > 0)
|
||||
cmbVoice.SelectedIndex = 0;
|
||||
|
||||
if (!string.IsNullOrEmpty(_config.OutputDevice) && cmbOutput.Items.Contains(_config.OutputDevice))
|
||||
cmbOutput.SelectedItem = _config.OutputDevice;
|
||||
else
|
||||
AutoSelectCableOutput();
|
||||
|
||||
trkNoise.Value = _config.NoiseScale;
|
||||
trkSpeed.Value = _config.LengthScale;
|
||||
trkNoiseW.Value = _config.NoiseWScale;
|
||||
OnSliderScroll(null, EventArgs.Empty);
|
||||
|
||||
chkMinimizeToTray.Checked = _config.MinimizeToTray;
|
||||
txtServer.Text = _config.ServerEndpoint;
|
||||
|
||||
btnBrowseFile.Click += OnBrowseFile;
|
||||
btnTestVoice.Click += OnTestVoice;
|
||||
btnManageVoices.Click += OnManageVoices;
|
||||
btnClearLog.Click += (_, _) => txtLog.Clear();
|
||||
txtPttKey.Enter += OnPttKeyFocus;
|
||||
txtPttKey.KeyDown += OnPttKeyDown;
|
||||
cmbOutput.SelectedIndexChanged += OnOutputChanged;
|
||||
cmbVoice.SelectedIndexChanged += OnVoiceChanged;
|
||||
txtServer.Leave += OnServerChanged;
|
||||
btnConnect.Click += OnConnect;
|
||||
|
||||
trkNoise.Scroll += OnSliderScroll;
|
||||
trkSpeed.Scroll += OnSliderScroll;
|
||||
trkNoiseW.Scroll += OnSliderScroll;
|
||||
trkNoise.MouseUp += OnSliderReleased;
|
||||
trkSpeed.MouseUp += OnSliderReleased;
|
||||
trkNoiseW.MouseUp += OnSliderReleased;
|
||||
|
||||
chkMinimizeToTray.CheckedChanged += (_, _) => SaveConfig();
|
||||
|
||||
Resize += OnResize;
|
||||
|
||||
SetupTray();
|
||||
|
||||
await InitializeEngineAsync();
|
||||
}
|
||||
|
||||
private Keys _pttKey = Keys.F8;
|
||||
private bool _capturingPttKey;
|
||||
private PttHotkey? _captureHook;
|
||||
|
||||
private void OnPttKeyFocus(object? sender, EventArgs e)
|
||||
{
|
||||
_capturingPttKey = true;
|
||||
txtPttKey.Text = "Press a key...";
|
||||
txtPttKey.BackColor = Color.LightYellow;
|
||||
|
||||
_captureHook?.Dispose();
|
||||
_captureHook = new PttHotkey();
|
||||
_captureHook.CaptureKeyPressed += OnCaptureKey;
|
||||
_captureHook.InstallCaptureHook();
|
||||
}
|
||||
|
||||
private void OnCaptureKey(Keys key)
|
||||
{
|
||||
if (!_capturingPttKey) return;
|
||||
|
||||
if (key == Keys.Escape)
|
||||
{
|
||||
CancelCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key is Keys.ShiftKey or Keys.Menu or Keys.LWin or Keys.RWin)
|
||||
return;
|
||||
|
||||
_pttKey = key;
|
||||
_capturingPttKey = false;
|
||||
txtPttKey.Text = KeyToDisplayString(_pttKey);
|
||||
txtPttKey.BackColor = SystemColors.Window;
|
||||
_captureHook?.Dispose();
|
||||
_captureHook = null;
|
||||
SetupHotkey();
|
||||
SaveConfig();
|
||||
}
|
||||
|
||||
private void CancelCapture()
|
||||
{
|
||||
_capturingPttKey = false;
|
||||
txtPttKey.Text = KeyToDisplayString(_pttKey);
|
||||
txtPttKey.BackColor = SystemColors.Window;
|
||||
_captureHook?.Dispose();
|
||||
_captureHook = null;
|
||||
}
|
||||
|
||||
private void OnPttKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (_capturingPttKey && e.KeyCode == Keys.Escape)
|
||||
{
|
||||
CancelCapture();
|
||||
e.SuppressKeyPress = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string KeyToDisplayString(Keys key)
|
||||
{
|
||||
return key switch
|
||||
{
|
||||
>= Keys.F1 and <= Keys.F12 => key.ToString(),
|
||||
Keys.RControlKey => "Right Ctrl",
|
||||
Keys.LControlKey => "Left Ctrl",
|
||||
Keys.Space => "Space",
|
||||
Keys.LButton => "Mouse Left",
|
||||
Keys.RButton => "Mouse Right",
|
||||
Keys.MButton => "Mouse Middle",
|
||||
Keys.XButton1 => "Mouse X1",
|
||||
Keys.XButton2 => "Mouse X2",
|
||||
Keys.Oemtilde => "`",
|
||||
Keys.CapsLock => "CapsLock",
|
||||
Keys.NumLock => "NumLock",
|
||||
Keys.Scroll => "ScrollLock",
|
||||
Keys.Pause => "Pause",
|
||||
Keys.Insert => "Insert",
|
||||
Keys.Delete => "Delete",
|
||||
Keys.Home => "Home",
|
||||
Keys.End => "End",
|
||||
Keys.PageUp => "PageUp",
|
||||
Keys.PageDown => "PageDown",
|
||||
_ => key.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
noiseScale: trkNoise.Value / 1000.0f,
|
||||
lengthScale: trkSpeed.Value / 100.0f,
|
||||
noiseWScale: trkNoiseW.Value / 1000.0f);
|
||||
_audioOutput = new AudioOutput();
|
||||
|
||||
if (_sttSource is null)
|
||||
{
|
||||
_sttSource = new TcpSttSource { ServerEndpoint = txtServer.Text, Log = Log };
|
||||
Log($"STT endpoint: {_sttSource.ServerEndpoint} (press Connect)");
|
||||
}
|
||||
|
||||
_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. Press PTT to send to STT server.");
|
||||
SetupHotkey();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Init failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupHotkey()
|
||||
{
|
||||
_pttHotkey?.Dispose();
|
||||
_pttHotkey = new PttHotkey { Key = _pttKey };
|
||||
_pttHotkey.Pressed += OnPttPressed;
|
||||
_pttHotkey.Released += OnPttReleased;
|
||||
_pttHotkey.Install();
|
||||
Log($"PTT hotkey installed: {_pttHotkey.Key}");
|
||||
}
|
||||
|
||||
private void OnPttPressed(object? sender, EventArgs e)
|
||||
{
|
||||
Log("PTT pressed");
|
||||
try
|
||||
{
|
||||
_sttSource?.SendOn();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"SendOn failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPttReleased(object? sender, EventArgs e)
|
||||
{
|
||||
Log("PTT released");
|
||||
try
|
||||
{
|
||||
_sttSource?.SendOff();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"SendOff failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
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 speak",
|
||||
};
|
||||
|
||||
if (dlg.ShowDialog() == DialogResult.OK)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
private void OnSliderScroll(object? sender, EventArgs e)
|
||||
{
|
||||
lblNoiseVal.Text = $"{trkNoise.Value / 1000.0:F3}";
|
||||
lblSpeedVal.Text = $"{trkSpeed.Value / 100.0:F2}";
|
||||
lblNoiseWVal.Text = $"{trkNoiseW.Value / 1000.0:F3}";
|
||||
}
|
||||
|
||||
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 void OnServerChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_sttSource is not null)
|
||||
{
|
||||
_sttSource.ServerEndpoint = txtServer.Text;
|
||||
Log($"Server endpoint: {txtServer.Text}");
|
||||
}
|
||||
SaveConfig();
|
||||
}
|
||||
|
||||
private async void OnConnect(object? sender, EventArgs e)
|
||||
{
|
||||
if (_sttSource is null)
|
||||
{
|
||||
Log("STT source not initialized.");
|
||||
return;
|
||||
}
|
||||
|
||||
_sttSource.ServerEndpoint = txtServer.Text;
|
||||
SaveConfig();
|
||||
btnConnect.Enabled = false;
|
||||
try
|
||||
{
|
||||
if (_sttSource.IsRunning)
|
||||
await _sttSource.ReconnectAsync();
|
||||
else
|
||||
await _sttSource.StartAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
btnConnect.Enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupTray()
|
||||
{
|
||||
if (_trayInit) return;
|
||||
_trayInit = true;
|
||||
|
||||
var micIcon = IconExtractor.TryExtractMicrophone();
|
||||
if (micIcon is not null)
|
||||
{
|
||||
_trayIcon = new NotifyIcon
|
||||
{
|
||||
Icon = micIcon,
|
||||
Text = $"Robovoice {AppVersion}",
|
||||
Visible = true,
|
||||
};
|
||||
Icon = micIcon;
|
||||
}
|
||||
else
|
||||
{
|
||||
_trayIcon = new NotifyIcon
|
||||
{
|
||||
Icon = SystemIcons.Application,
|
||||
Text = $"Robovoice {AppVersion}",
|
||||
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 static string AppVersion =>
|
||||
typeof(MainForm).Assembly.GetName().Version?.ToString() ?? "0.0";
|
||||
|
||||
private void OnResize(object? sender, EventArgs e)
|
||||
{
|
||||
if (WindowState == FormWindowState.Minimized && chkMinimizeToTray.Checked)
|
||||
{
|
||||
Hide();
|
||||
WindowState = FormWindowState.Normal;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowWindow()
|
||||
{
|
||||
Show();
|
||||
WindowState = FormWindowState.Normal;
|
||||
Activate();
|
||||
}
|
||||
|
||||
private void SaveConfig()
|
||||
{
|
||||
_config.Voice = cmbVoice.SelectedItem as string ?? string.Empty;
|
||||
_config.PttKey = (int)_pttKey;
|
||||
_config.OutputDevice = cmbOutput.SelectedItem as string ?? string.Empty;
|
||||
_config.NoiseScale = trkNoise.Value;
|
||||
_config.LengthScale = trkSpeed.Value;
|
||||
_config.NoiseWScale = trkNoiseW.Value;
|
||||
_config.MinimizeToTray = chkMinimizeToTray.Checked;
|
||||
_config.ServerEndpoint = txtServer.Text;
|
||||
_config.Save();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
_pttHotkey?.Dispose();
|
||||
_trayIcon!.Visible = false;
|
||||
_orchestrator?.DisposeAsync().AsTask().Wait(2000);
|
||||
_sttSource?.DisposeAsync().AsTask().Wait(2000);
|
||||
SaveConfig();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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<string> _log;
|
||||
private CancellationTokenSource? _currentCts;
|
||||
private bool _disposed;
|
||||
|
||||
public string OutputDeviceName { get; set; } = string.Empty;
|
||||
|
||||
public Orchestrator(
|
||||
LibPiperTtsEngine tts,
|
||||
AudioOutput audioOutput,
|
||||
ISttSource sttSource,
|
||||
Action<string> 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 async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_currentCts?.Cancel();
|
||||
_currentCts?.Dispose();
|
||||
_sttSource.TranscriptReceived -= OnTranscript;
|
||||
await _sttSource.DisposeAsync();
|
||||
await _tts.DisposeAsync();
|
||||
_audioOutput.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Robovoice.App;
|
||||
|
||||
static class Program
|
||||
{
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
ApplicationConfiguration.Initialize();
|
||||
Application.Run(new MainForm());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
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 WH_MOUSE_LL = 14;
|
||||
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 const int WM_LBUTTONDOWN = 0x0201;
|
||||
private const int WM_LBUTTONUP = 0x0202;
|
||||
private const int WM_RBUTTONDOWN = 0x0204;
|
||||
private const int WM_RBUTTONUP = 0x0205;
|
||||
private const int WM_MBUTTONDOWN = 0x0207;
|
||||
private const int WM_MBUTTONUP = 0x0208;
|
||||
private const int WM_XBUTTONDOWN = 0x020B;
|
||||
private const int WM_XBUTTONUP = 0x020C;
|
||||
|
||||
private readonly LowLevelKeyboardProc _kbProc;
|
||||
private readonly LowLevelMouseProc _msProc;
|
||||
private IntPtr _kbHook = IntPtr.Zero;
|
||||
private IntPtr _msHook = IntPtr.Zero;
|
||||
private bool _isDown;
|
||||
private bool _disposed;
|
||||
|
||||
public Keys Key { get; set; } = Keys.F8;
|
||||
|
||||
public static bool IsMouseButton(Keys key) =>
|
||||
key is Keys.LButton or Keys.RButton or Keys.MButton or Keys.XButton1 or Keys.XButton2;
|
||||
|
||||
public event EventHandler? Pressed;
|
||||
public event EventHandler? Released;
|
||||
|
||||
public event Action<Keys>? CaptureKeyPressed;
|
||||
|
||||
public void InstallCaptureHook()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_kbHook != IntPtr.Zero) return;
|
||||
|
||||
IntPtr hModule = GetModuleHandle(null);
|
||||
_kbHook = SetWindowsHookEx(WH_KEYBOARD_LL, _kbProc, hModule, 0);
|
||||
}
|
||||
|
||||
public PttHotkey()
|
||||
{
|
||||
_kbProc = KeyboardCallback;
|
||||
_msProc = MouseCallback;
|
||||
}
|
||||
|
||||
public void Install()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_kbHook != IntPtr.Zero || _msHook != IntPtr.Zero) return;
|
||||
|
||||
IntPtr hModule = GetModuleHandle(null);
|
||||
|
||||
if (IsMouseButton(Key))
|
||||
_msHook = SetWindowsHookEx(WH_MOUSE_LL, _msProc, hModule, 0);
|
||||
else
|
||||
_kbHook = SetWindowsHookEx(WH_KEYBOARD_LL, _kbProc, hModule, 0);
|
||||
}
|
||||
|
||||
public void Uninstall()
|
||||
{
|
||||
if (_kbHook != IntPtr.Zero)
|
||||
{
|
||||
UnhookWindowsHookEx(_kbHook);
|
||||
_kbHook = IntPtr.Zero;
|
||||
}
|
||||
if (_msHook != IntPtr.Zero)
|
||||
{
|
||||
UnhookWindowsHookEx(_msHook);
|
||||
_msHook = IntPtr.Zero;
|
||||
}
|
||||
_isDown = false;
|
||||
}
|
||||
|
||||
private IntPtr KeyboardCallback(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 (CaptureKeyPressed is not null && isDown)
|
||||
{
|
||||
CaptureKeyPressed.Invoke(key);
|
||||
}
|
||||
else if (key == Key)
|
||||
{
|
||||
if (isDown && !_isDown)
|
||||
{
|
||||
_isDown = true;
|
||||
Pressed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
else if (isUp && _isDown)
|
||||
{
|
||||
_isDown = false;
|
||||
Released?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CallNextHookEx(_kbHook, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
private IntPtr MouseCallback(int nCode, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
if (nCode >= 0)
|
||||
{
|
||||
Keys? button = null;
|
||||
|
||||
switch ((int)wParam)
|
||||
{
|
||||
case WM_LBUTTONDOWN: button = Keys.LButton; break;
|
||||
case WM_LBUTTONUP: button = Keys.LButton; break;
|
||||
case WM_RBUTTONDOWN: button = Keys.RButton; break;
|
||||
case WM_RBUTTONUP: button = Keys.RButton; break;
|
||||
case WM_MBUTTONDOWN: button = Keys.MButton; break;
|
||||
case WM_MBUTTONUP: button = Keys.MButton; break;
|
||||
case WM_XBUTTONDOWN:
|
||||
case WM_XBUTTONUP:
|
||||
{
|
||||
int xButton = Marshal.ReadInt32(lParam + 8) >> 16;
|
||||
button = xButton == 1 ? Keys.XButton1 : Keys.XButton2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (button is Keys btn && btn == Key)
|
||||
{
|
||||
bool isDown = wParam == WM_LBUTTONDOWN || wParam == WM_RBUTTONDOWN
|
||||
|| wParam == WM_MBUTTONDOWN || wParam == WM_XBUTTONDOWN;
|
||||
bool isUp = wParam == WM_LBUTTONUP || wParam == WM_RBUTTONUP
|
||||
|| wParam == WM_MBUTTONUP || wParam == WM_XBUTTONUP;
|
||||
|
||||
if (isDown && !_isDown)
|
||||
{
|
||||
_isDown = true;
|
||||
Pressed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
else if (isUp && _isDown)
|
||||
{
|
||||
_isDown = false;
|
||||
Released?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CallNextHookEx(_msHook, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
Uninstall();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
|
||||
private delegate IntPtr LowLevelMouseProc(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)]
|
||||
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelMouseProc 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);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Robovoice.Core\Robovoice.Core.csproj" />
|
||||
<ProjectReference Include="..\Robovoice.Tts.LibPiper\Robovoice.Tts.LibPiper.csproj" />
|
||||
<ProjectReference Include="..\Robovoice.Stt.Tcp\Robovoice.Stt.Tcp.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NAudio" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>0.4.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
#nullable enable
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<VoiceInfo> _allVoices = Array.Empty<VoiceInfo>();
|
||||
private HashSet<string> _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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Robovoice.Core;
|
||||
|
||||
public sealed record AudioChunk(float[] Samples, int SampleRate);
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Robovoice.Core;
|
||||
|
||||
public interface ISttSource : IAsyncDisposable
|
||||
{
|
||||
event TranscriptEventHandler? TranscriptReceived;
|
||||
|
||||
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);
|
||||
@@ -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<AudioChunk> SynthesizeAsync(
|
||||
string text,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public static class TtsEngineExtensions
|
||||
{
|
||||
public static ConfiguredCancelableAsyncEnumerable<AudioChunk> SynthesizeAsync(
|
||||
this ITtsEngine engine,
|
||||
string text,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return engine.SynthesizeAsync(text, ct).ConfigureAwait(false).WithCancellation(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Robovoice.Core;
|
||||
|
||||
public enum TranscriptType
|
||||
{
|
||||
Partial,
|
||||
Final,
|
||||
}
|
||||
|
||||
public sealed record TranscriptMessage(TranscriptType Type, string Text);
|
||||
@@ -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<VoiceInfo>? _catalogue;
|
||||
private bool _disposed;
|
||||
|
||||
public VoiceCatalogue()
|
||||
{
|
||||
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<VoiceInfo>> GetCatalogueAsync(
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_catalogue is not null)
|
||||
return _catalogue;
|
||||
|
||||
var dict = await _http.GetFromJsonAsync<Dictionary<string, VoiceInfo>>(
|
||||
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<string> GetInstalledVoices(string voicesDir)
|
||||
{
|
||||
if (!Directory.Exists(voicesDir))
|
||||
return Array.Empty<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string> 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<string, VoiceFile> { }
|
||||
|
||||
public sealed class VoiceFile
|
||||
{
|
||||
[JsonPropertyName("size_bytes")]
|
||||
public long SizeBytes { get; set; }
|
||||
|
||||
[JsonPropertyName("md5_digest")]
|
||||
public string Md5Digest { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -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<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Robovoice.Core\Robovoice.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Robovoice.Core\Robovoice.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,253 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Robovoice.Core;
|
||||
|
||||
namespace Robovoice.Stt.Tcp;
|
||||
|
||||
public sealed class TcpSttSource : ISttSource
|
||||
{
|
||||
private TcpClient? _tcp;
|
||||
private NetworkStream? _stream;
|
||||
private StreamReader? _reader;
|
||||
private StreamWriter? _writer;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _runTask;
|
||||
private readonly object _sendLock = new();
|
||||
private bool _disposed;
|
||||
|
||||
public string ServerEndpoint { get; set; } = "127.0.0.1:5210";
|
||||
|
||||
public bool IsRunning => _cts is not null;
|
||||
|
||||
public Action<string>? Log { get; set; }
|
||||
|
||||
public event TranscriptEventHandler? TranscriptReceived;
|
||||
|
||||
public Task StartAsync(CancellationToken ct = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_cts is not null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
_runTask = RunAsync(_cts.Token);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_cts is not null)
|
||||
_cts.Cancel();
|
||||
|
||||
CleanupConnection();
|
||||
|
||||
if (_runTask is not null)
|
||||
{
|
||||
try { await _runTask.WaitAsync(ct); }
|
||||
catch { }
|
||||
_runTask = null;
|
||||
}
|
||||
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
IPEndPoint? endpoint = ParseEndpoint(ServerEndpoint);
|
||||
if (endpoint is null)
|
||||
{
|
||||
Log?.Invoke($"STT: invalid endpoint '{ServerEndpoint}'");
|
||||
try { await Task.Delay(3000, ct); } catch { break; }
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_tcp = new TcpClient();
|
||||
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
connectCts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
await _tcp.ConnectAsync(endpoint.Address, endpoint.Port, connectCts.Token);
|
||||
|
||||
_stream = _tcp.GetStream();
|
||||
_reader = new StreamReader(_stream, System.Text.Encoding.UTF8);
|
||||
_writer = new StreamWriter(_stream, System.Text.Encoding.UTF8) { AutoFlush = true };
|
||||
|
||||
Log?.Invoke($"STT: connected to {ServerEndpoint}");
|
||||
|
||||
await ReceiveLoopAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.Invoke($"STT: connection failed ({ex.Message}), retrying...");
|
||||
}
|
||||
finally
|
||||
{
|
||||
CleanupConnection();
|
||||
}
|
||||
|
||||
if (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(3000, ct); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested && _reader is not null)
|
||||
{
|
||||
string? line;
|
||||
try
|
||||
{
|
||||
line = await _reader.ReadLineAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (line is null)
|
||||
break;
|
||||
|
||||
TranscriptMessage? message = ParseTranscriptLine(line);
|
||||
if (message is null)
|
||||
continue;
|
||||
|
||||
TranscriptReceived?.Invoke(this, new TranscriptEventArgs
|
||||
{
|
||||
Message = message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void SendOn()
|
||||
{
|
||||
SendControl("on");
|
||||
}
|
||||
|
||||
public void SendOff()
|
||||
{
|
||||
SendControl("off");
|
||||
}
|
||||
|
||||
public async Task ReconnectAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_cts is null)
|
||||
return;
|
||||
|
||||
Log?.Invoke("STT: reconnecting...");
|
||||
CleanupConnection();
|
||||
|
||||
try { await Task.Delay(500, ct); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
}
|
||||
|
||||
private void SendControl(string evt)
|
||||
{
|
||||
lock (_sendLock)
|
||||
{
|
||||
if (_writer is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_writer.WriteLine(JsonSerializer.Serialize(new ControlDto { Event = evt }));
|
||||
}
|
||||
catch
|
||||
{
|
||||
Log?.Invoke($"STT: failed to send '{evt}' (not connected?)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupConnection()
|
||||
{
|
||||
lock (_sendLock)
|
||||
{
|
||||
_writer?.Dispose();
|
||||
_reader?.Dispose();
|
||||
_stream?.Dispose();
|
||||
_tcp?.Dispose();
|
||||
_writer = null;
|
||||
_reader = null;
|
||||
_stream = null;
|
||||
_tcp = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IPEndPoint? ParseEndpoint(string endpoint)
|
||||
{
|
||||
int colon = endpoint.LastIndexOf(':');
|
||||
if (colon <= 0)
|
||||
return null;
|
||||
|
||||
string host = endpoint[..colon];
|
||||
if (!int.TryParse(endpoint[(colon + 1)..], out int port))
|
||||
return null;
|
||||
|
||||
if (IPAddress.TryParse(host, out var addr))
|
||||
return new IPEndPoint(addr, port);
|
||||
|
||||
try
|
||||
{
|
||||
var addresses = Dns.GetHostAddresses(host);
|
||||
addr = addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork);
|
||||
if (addr is null)
|
||||
return null;
|
||||
return new IPEndPoint(addr, port);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static TranscriptMessage? ParseTranscriptLine(string line)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<TransmitDto>(line);
|
||||
if (dto is null)
|
||||
return null;
|
||||
|
||||
var type = dto.Final ? TranscriptType.Final : TranscriptType.Partial;
|
||||
return new TranscriptMessage(type, dto.Text ?? string.Empty);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
await StopAsync();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ControlDto
|
||||
{
|
||||
[JsonPropertyName("event")]
|
||||
public string Event { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class TransmitDto
|
||||
{
|
||||
public bool Final { get; set; }
|
||||
public string Text { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -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<AudioChunk> 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Robovoice.Core\Robovoice.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="runtimes\win-x64\native\piper.dll" CopyToOutputDirectory="PreserveNewest" Link="piper.dll" />
|
||||
<None Include="runtimes\win-x64\native\onnxruntime.dll" CopyToOutputDirectory="PreserveNewest" Link="onnxruntime.dll" />
|
||||
<None Include="runtimes\win-x64\native\onnxruntime_providers_shared.dll" CopyToOutputDirectory="PreserveNewest" Link="onnxruntime_providers_shared.dll" />
|
||||
<None Include="runtimes\win-x64\native\espeak-ng-data\**\*" CopyToOutputDirectory="PreserveNewest" Link="espeak-ng-data\%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
<Solution>
|
||||
<Project Path="Robovoice.App/Robovoice.App.csproj" />
|
||||
<Project Path="Robovoice.Core/Robovoice.Core.csproj" />
|
||||
<Project Path="Robovoice.Stt.File/Robovoice.Stt.File.csproj" />
|
||||
<Project Path="Robovoice.Stt.Tcp/Robovoice.Stt.Tcp.csproj" />
|
||||
<Project Path="Robovoice.Tts.LibPiper/Robovoice.Tts.LibPiper.csproj" />
|
||||
</Solution>
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
# Server implementation notes
|
||||
|
||||
The STT server listens for TCP connections from Robovoice, captures audio
|
||||
from a microphone when `on` is received, runs speech recognition (Moonshine),
|
||||
and sends transcript messages back over the same connection.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌── on/off (TCP, newline-delimited JSON)
|
||||
Robovoice ──────────────►│
|
||||
│ STT Server
|
||||
Robovoice ◄──────────────┤
|
||||
└── partial/final (TCP, newline-delimited JSON)
|
||||
```
|
||||
|
||||
The server:
|
||||
1. Listens on a TCP port (e.g. 5210)
|
||||
2. Accepts a connection from Robovoice
|
||||
3. Reads lines: waits for `{"event":"on"}`
|
||||
4. Records audio from the microphone
|
||||
5. Waits for `{"event":"off"}` (or a timeout)
|
||||
6. Runs STT on the captured audio
|
||||
7. Sends `{"final":true,"text":"..."}`\n back over the connection
|
||||
|
||||
## Framing
|
||||
|
||||
Every message is a single JSON object on one line, terminated by `\n`. No
|
||||
length prefix, no binary framing. Use `readline()` / `StreamReader.ReadLineAsync()`.
|
||||
|
||||
## Python server with Moonshine
|
||||
|
||||
[Moonshine](https://github.com/usefulsensors/moonshine) is a lightweight ASR
|
||||
model by Useful Sensors. Install with `pip install moonshine`.
|
||||
|
||||
```python
|
||||
import socket
|
||||
import json
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
import moonshine
|
||||
|
||||
LISTEN_PORT = 5210
|
||||
SAMPLE_RATE = 16000
|
||||
|
||||
model = moonshine.MoonshineModel(model="moonshine/base")
|
||||
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.bind(("0.0.0.0", LISTEN_PORT))
|
||||
server.listen(1)
|
||||
|
||||
print(f"STT server listening on :{LISTEN_PORT}")
|
||||
|
||||
while True:
|
||||
conn, addr = server.accept()
|
||||
print(f"Client connected: {addr}")
|
||||
|
||||
buf = ""
|
||||
with conn:
|
||||
while True:
|
||||
data = conn.recv(4096).decode("utf-8")
|
||||
if not data:
|
||||
break
|
||||
buf += data
|
||||
|
||||
while "\n" in buf:
|
||||
line, buf = buf.split("\n", 1)
|
||||
msg = json.loads(line)
|
||||
|
||||
if msg.get("event") == "on":
|
||||
print("PTT on — recording")
|
||||
audio_chunks = []
|
||||
|
||||
# Record until "off" or timeout
|
||||
conn.settimeout(0.1)
|
||||
while True:
|
||||
try:
|
||||
data2 = conn.recv(4096).decode("utf-8")
|
||||
if not data2:
|
||||
break
|
||||
buf += data2
|
||||
while "\n" in buf:
|
||||
line2, buf = buf.split("\n", 1)
|
||||
msg2 = json.loads(line2)
|
||||
if msg2.get("event") == "off":
|
||||
break
|
||||
except socket.timeout:
|
||||
pass
|
||||
|
||||
chunk = sd.rec(int(SAMPLE_RATE * 0.1),
|
||||
samplerate=SAMPLE_RATE,
|
||||
channels=1, dtype="float32")
|
||||
sd.wait()
|
||||
audio_chunks.append(chunk.flatten())
|
||||
|
||||
conn.settimeout(None)
|
||||
|
||||
if not audio_chunks:
|
||||
continue
|
||||
|
||||
audio = np.concatenate(audio_chunks)
|
||||
print(f"Captured {len(audio)/SAMPLE_RATE:.1f}s")
|
||||
|
||||
text = moonshine.transcribe(model, audio).strip()
|
||||
|
||||
if text:
|
||||
print(f"Transcript: {text}")
|
||||
reply = json.dumps({"final": True, "text": text})
|
||||
conn.sendall((reply + "\n").encode("utf-8"))
|
||||
else:
|
||||
print("Empty transcript")
|
||||
```
|
||||
|
||||
## Python server with streaming partials
|
||||
|
||||
For lower latency, send partial results while still recording:
|
||||
|
||||
```python
|
||||
import socket
|
||||
import json
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
import moonshine
|
||||
|
||||
LISTEN_PORT = 5210
|
||||
SAMPLE_RATE = 16000
|
||||
CHUNK_DURATION = 0.5
|
||||
|
||||
model = moonshine.MoonshineModel(model="moonshine/base")
|
||||
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.bind(("0.0.0.0", LISTEN_PORT))
|
||||
server.listen(1)
|
||||
|
||||
print(f"STT server listening on :{LISTEN_PORT}")
|
||||
|
||||
while True:
|
||||
conn, addr = server.accept()
|
||||
print(f"Client connected: {addr}")
|
||||
buf = ""
|
||||
|
||||
with conn:
|
||||
while True:
|
||||
data = conn.recv(4096).decode("utf-8")
|
||||
if not data:
|
||||
break
|
||||
buf += data
|
||||
|
||||
while "\n" in buf:
|
||||
line, buf = buf.split("\n", 1)
|
||||
msg = json.loads(line)
|
||||
|
||||
if msg.get("event") != "on":
|
||||
continue
|
||||
|
||||
print("PTT on — recording")
|
||||
audio_chunks = []
|
||||
|
||||
while True:
|
||||
try:
|
||||
conn.settimeout(CHUNK_DURATION)
|
||||
data2 = conn.recv(4096).decode("utf-8")
|
||||
if not data2:
|
||||
break
|
||||
buf += data2
|
||||
while "\n" in buf:
|
||||
line2, buf = buf.split("\n", 1)
|
||||
msg2 = json.loads(line2)
|
||||
if msg2.get("event") == "off":
|
||||
break
|
||||
except socket.timeout:
|
||||
pass
|
||||
|
||||
chunk = sd.rec(int(SAMPLE_RATE * CHUNK_DURATION),
|
||||
samplerate=SAMPLE_RATE,
|
||||
channels=1, dtype="float32")
|
||||
sd.wait()
|
||||
audio_chunks.append(chunk.flatten())
|
||||
|
||||
# Send partial every few chunks
|
||||
if len(audio_chunks) % 4 == 0:
|
||||
partial_audio = np.concatenate(audio_chunks)
|
||||
partial_text = moonshine.transcribe(model, partial_audio).strip()
|
||||
if partial_text:
|
||||
reply = json.dumps({"final": False, "text": partial_text})
|
||||
conn.sendall((reply + "\n").encode("utf-8"))
|
||||
|
||||
conn.settimeout(None)
|
||||
|
||||
if not audio_chunks:
|
||||
continue
|
||||
|
||||
audio = np.concatenate(audio_chunks)
|
||||
text = moonshine.transcribe(model, audio).strip()
|
||||
|
||||
if text:
|
||||
print(f"Final: {text}")
|
||||
reply = json.dumps({"final": True, "text": text})
|
||||
conn.sendall((reply + "\n").encode("utf-8"))
|
||||
```
|
||||
|
||||
## C# server skeleton
|
||||
|
||||
```csharp
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
|
||||
var listener = new TcpListener(IPAddress.Any, 5210);
|
||||
listener.Start();
|
||||
|
||||
Console.WriteLine("STT server listening on :5210");
|
||||
|
||||
while (true)
|
||||
{
|
||||
var client = listener.AcceptTcpClient();
|
||||
Console.WriteLine($"Client connected: {client.Client.RemoteEndPoint}");
|
||||
|
||||
using var stream = client.GetStream();
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
using var writer = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = true };
|
||||
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
var msg = JsonSerializer.Deserialize<Dictionary<string, string>>(line);
|
||||
if (msg?["event"] != "on")
|
||||
continue;
|
||||
|
||||
Console.WriteLine("PTT on — recording");
|
||||
// Capture audio...
|
||||
|
||||
// Read until "off"
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
msg = JsonSerializer.Deserialize<Dictionary<string, string>>(line);
|
||||
if (msg?["event"] == "off")
|
||||
break;
|
||||
}
|
||||
|
||||
// Run STT...
|
||||
string text = "recognized text here";
|
||||
|
||||
var reply = JsonSerializer.Serialize(new { final = true, text });
|
||||
writer.WriteLine(reply);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- **One connection per client:** Robovoice maintains a single persistent TCP
|
||||
connection. The server should handle one client at a time (or track
|
||||
multiple if needed).
|
||||
- **Timeout:** implement a recording timeout in case the `off` message is
|
||||
delayed or the client disconnects. 10–30 seconds is reasonable.
|
||||
- **Partials:** optional but improve UX — Robovoice logs them so the user
|
||||
sees live feedback. Only `final` triggers TTS.
|
||||
- **Encoding:** always UTF-8. Every line is a UTF-8 JSON object terminated
|
||||
by `\n`.
|
||||
- **Reconnection:** Robovoice auto-reconnects every 3 seconds if the
|
||||
connection drops. The server just needs to accept new connections.
|
||||
- **Moonshine models:** `moonshine/base` (faster, less accurate) or
|
||||
`moonshine/tiny` (fastest). Choose based on your hardware.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Robovoice TCP STT Protocol
|
||||
|
||||
## Overview
|
||||
|
||||
Robovoice acts as a **client**: it connects to a remote STT server over TCP,
|
||||
sends control messages when the user presses/releases the PTT key, and
|
||||
receives transcript messages back. The STT server captures audio from a
|
||||
microphone, runs speech recognition (Moonshine), and sends transcripts back
|
||||
over the same connection.
|
||||
|
||||
```
|
||||
[Robovoice client] --TCP--> [STT server :5210]
|
||||
│ │
|
||||
├── {"event":"on"}\n ──────►│
|
||||
│ ├── capture audio
|
||||
├── {"event":"off"}\n ──────►│
|
||||
│ ├── run STT
|
||||
│◄── {"final":true,...}\n ──┤
|
||||
```
|
||||
|
||||
## Transport
|
||||
|
||||
- **Protocol:** TCP (reliable, ordered, connection-oriented)
|
||||
- **Server endpoint:** configurable in Robovoice UI (default `127.0.0.1:5210`)
|
||||
- **Framing:** newline-delimited JSON (NDJSON) — each message is a single
|
||||
UTF-8 JSON object terminated by `\n`
|
||||
- **Auto-reconnect:** if the connection drops, Robovoice retries every 3
|
||||
seconds until the server is available
|
||||
|
||||
## Control messages (client → server)
|
||||
|
||||
Sent by Robovoice when the user presses/releases the PTT key.
|
||||
|
||||
```json
|
||||
{"event": "on"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"event": "off"}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------|--------|------------------------------------|
|
||||
| `event` | string | `"on"` (PTT pressed) or `"off"` (PTT released) |
|
||||
|
||||
## Transcript messages (server → client)
|
||||
|
||||
Sent by the server back to Robovoice over the same TCP connection.
|
||||
|
||||
```json
|
||||
{"final": false, "text": "hello world"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"final": true, "text": "hello world how are you"}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---------|---------|----------|--------------------------------------------------|
|
||||
| `final` | bool | yes | `true` = final result, `false` = partial |
|
||||
| `text` | string | yes | The transcript text (may be empty for partials) |
|
||||
|
||||
### Semantics
|
||||
|
||||
- **`final: false`** — intermediate recognition result (partial). Robovoice
|
||||
logs these but does not act on them (only `final` triggers TTS).
|
||||
- **`final: true`** — complete utterance. Robovoice feeds this to the TTS
|
||||
engine and speaks it.
|
||||
|
||||
Malformed JSON or unknown field values are silently dropped by the client.
|
||||
Reference in New Issue
Block a user