v0.1: Robovoice — PTT re-voicing app

WinForms tray app that captures text (from file or direct input) and
synthesizes speech via libpiper (Piper TTS), outputting to any audio
device (VB-CABLE for virtual mic routing).

Features:
- Global PTT hotkey (configurable F1-F12) via WH_KEYBOARD_LL
- Text file sequential reader (line-by-line on each PTT cycle)
- Direct text input with Speak button
- Voice manager: browse 147-voice Piper catalogue, download, remove
- libpiper P/Invoke wrapper with UTF-8 marshaling, streaming chunks
- BufferedWaveProvider streaming playback with trailing silence flush
- Sentence terminator auto-append (fixes espeak-ng final-word drop)
- Tray icon with minimize-to-tray

Architecture:
- Robovoice.Core: ITtsEngine, ISttSource interfaces, voice catalogue
- Robovoice.Tts.LibPiper: P/Invoke wrapper for libpiper.dll
- Robovoice.Stt.File: text file STT source (testing without mic server)
- Robovoice.Stt.Udp: UDP client stub (for future Linux mic server)
- Robovoice.App: WinForms UI, orchestrator, PTT hotkey, audio output
This commit is contained in:
2026-08-10 11:27:28 +00:00
parent 03e6ccf388
commit 8585972a1e
25 changed files with 2005 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
namespace Robovoice.Core;
public sealed record AudioChunk(float[] Samples, int SampleRate);
+15
View File
@@ -0,0 +1,15 @@
namespace Robovoice.Core;
public interface ISttSource : IAsyncDisposable
{
Task StartAsync(CancellationToken ct = default);
Task StopAsync(CancellationToken ct = default);
}
public sealed class TranscriptEventArgs : EventArgs
{
public TranscriptMessage Message { get; init; } = new(TranscriptType.Final, string.Empty);
}
public delegate void TranscriptEventHandler(object? sender, TranscriptEventArgs e);
+27
View File
@@ -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);
}
}
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+9
View File
@@ -0,0 +1,9 @@
namespace Robovoice.Core;
public enum TranscriptType
{
Partial,
Final,
}
public sealed record TranscriptMessage(TranscriptType Type, string Text);
+131
View File
@@ -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;
}
}
+65
View File
@@ -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;
}