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:
@@ -0,0 +1,3 @@
|
||||
namespace Robovoice.Core;
|
||||
|
||||
public sealed record AudioChunk(float[] Samples, int SampleRate);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user