Files
VolcanicAgenticCore/hosts/Volcanic.AgenticCore.Telegram/Speech/ElevenLabsTextToSpeech.cs
EugeneTes 7fef3ce94e feat(telegram): ElevenLabs text to speech for the voice tool
ElevenLabs speaks for the voice tool when ELEVENLABS_API_KEY and ELEVENLABS_VOICE_ID
are set (TTS_PROVIDER=elevenlabs|openai picks, the other is the fallback). Opus output
(opus_48000_64) goes to Telegram as is; voice names resolve through /v2/voices;
instructions become audio tags on eleven_v3 and are ignored on other models. The key
is redacted from tool output and never reaches agent processes.
2026-10-14 13:07:36 +00:00

153 lines
8.2 KiB
C#

using System.Collections.Concurrent;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Volcanic.AgenticCore.Telegram.Speech;
/// <summary>
/// ElevenLabs' <c>/v1/text-to-speech/{voice_id}</c> with an <c>opus_48000_*</c> output format, which is OGG/Opus and so
/// shows in Telegram as a voice note. A voice name is looked up once in the account's voices (<c>/v2/voices</c>).
/// </summary>
public sealed partial class ElevenLabsTextToSpeech(HttpClient http, SpeechOptions options) : ITextToSpeech
{
private const string Prefix = "ElevenLabs speech synthesis failed: ";
private const int MaxExcerpt = 200;
private readonly ConcurrentDictionary<string, string> _voiceIds = new(StringComparer.OrdinalIgnoreCase);
private ElevenLabsOptions Settings => options.ElevenLabs;
public string ArgumentGuide =>
"voice: an ElevenLabs voice id (20 letters and digits) or the name of a voice in the account's library; the configured voice by default. " +
(Settings.SupportsAudioTags
? "instructions: delivery as ElevenLabs v3 audio tags in English, e.g. \"[warm] [calm]\", \"[excited]\", \"[whispers]\"; plain words become one tag."
: $"instructions: ignored by the current model ({Settings.ResolvedModel}); the voice's own style applies.");
public async Task<byte[]> SynthesizeAsync(string text, string? voice, string? instructions, CancellationToken cancellationToken)
{
var voiceId = await ResolveVoiceAsync(string.IsNullOrWhiteSpace(voice) ? Settings.VoiceId : voice, cancellationToken).ConfigureAwait(false);
var input = Tags(instructions) is { } tags ? $"{tags} {text}" : text;
for (var attempt = 1; ; attempt++)
{
try
{
return await SynthesizeOnceAsync(input, voiceId, cancellationToken).ConfigureAwait(false);
}
catch (TextToSpeechException ex) when (ex.IsTransient && attempt < SpeechOptions.MaxAttempts)
{
await Task.Delay(options.RetryDelay * (1 << (attempt - 1)), cancellationToken).ConfigureAwait(false);
}
}
}
// v3 reads "[tag]" as delivery; older models would speak it, so they get nothing.
private string? Tags(string? instructions)
{
if (!Settings.SupportsAudioTags || string.IsNullOrWhiteSpace(instructions)) return null;
var tone = instructions.Trim();
return tone.Contains('[', StringComparison.Ordinal) ? tone : $"[{tone}]";
}
private async Task<byte[]> SynthesizeOnceAsync(string text, string voiceId, CancellationToken cancellationToken)
{
var url = $"{BaseUrl}/v1/text-to-speech/{Uri.EscapeDataString(voiceId)}?output_format={Uri.EscapeDataString(Settings.OutputFormat.Trim())}";
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(new Dictionary<string, string> { ["text"] = text, ["model_id"] = Settings.ResolvedModel }),
};
var audio = await SendAsync(request, cancellationToken).ConfigureAwait(false);
if (audio.Length == 0) throw new TextToSpeechException(Prefix + "the response is empty.");
if (audio.Length < 4 || audio[0] != 'O' || audio[1] != 'g' || audio[2] != 'g' || audio[3] != 'S')
throw new TextToSpeechException(Prefix + $"the audio is not OGG/Opus (output format {Settings.OutputFormat}); use an opus_48000_* format.");
return audio;
}
private async Task<string> ResolveVoiceAsync(string voice, CancellationToken cancellationToken)
{
voice = voice.Trim();
if (VoiceIdShape().IsMatch(voice)) return voice;
if (_voiceIds.TryGetValue(voice, out var cached)) return cached;
using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/v2/voices?page_size=100&search={Uri.EscapeDataString(voice)}");
var body = await SendAsync(request, cancellationToken).ConfigureAwait(false);
var voices = new List<(string Id, string Name)>();
try
{
using var json = JsonDocument.Parse(body);
if (json.RootElement.TryGetProperty("voices", out var list) && list.ValueKind == JsonValueKind.Array)
foreach (var item in list.EnumerateArray())
if (item.TryGetProperty("voice_id", out var id) && item.TryGetProperty("name", out var name)
&& id.GetString() is { Length: > 0 } idText && name.GetString() is { } nameText)
voices.Add((idText, nameText.Trim()));
}
catch (JsonException ex)
{
throw new TextToSpeechException(Prefix + "the voice list is not valid JSON.", ex);
}
// An exact name first, then "Name - description" style names, then a single search hit.
var match = voices.Where(v => string.Equals(v.Name, voice, StringComparison.OrdinalIgnoreCase)).Select(v => v.Id).FirstOrDefault()
?? voices.Where(v => v.Name.StartsWith(voice, StringComparison.OrdinalIgnoreCase)
&& v.Name[voice.Length..].TrimStart() is [ '-' or '(' or ',' or '|', ..])
.Select(v => v.Id).FirstOrDefault()
?? (voices.Count == 1 ? voices[0].Id : null);
if (match is null)
throw new TextToSpeechException(voices.Count == 0
? $"{Prefix}no voice named \"{voice}\" in the ElevenLabs account. Use a voice id or a name from the account's voices."
: $"{Prefix}\"{voice}\" matches several voices: {string.Join(", ", voices.Take(10).Select(v => $"{v.Name} ({v.Id})"))}. Use the voice id.");
_voiceIds[voice] = match;
return match;
}
private string BaseUrl => Settings.BaseUrl.TrimEnd('/');
private async Task<byte[]> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Add("xi-api-key", Settings.ApiKey);
try
{
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode) return await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
var error = Hide(Explain(await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false))); // hide before cutting
var excerpt = error.Length > MaxExcerpt ? error[..MaxExcerpt] : error;
var status = (int)response.StatusCode;
throw new TextToSpeechException($"{Prefix}HTTP {status} {excerpt}".TrimEnd()) { IsTransient = status is 408 or 429 or >= 500 };
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) // HttpClient.Timeout
{
throw new TextToSpeechException(Prefix + "the request timed out.", ex) { IsTransient = true };
}
catch (HttpRequestException ex)
{
throw new TextToSpeechException(Prefix + Hide(ex.Message), ex) { IsTransient = true };
}
}
// ElevenLabs errors look like {"detail":{"status":"quota_exceeded","message":"..."}}; show "status: message".
private static string Explain(string body)
{
try
{
using var json = JsonDocument.Parse(body);
if (json.RootElement.ValueKind == JsonValueKind.Object && json.RootElement.TryGetProperty("detail", out var detail)
&& detail.ValueKind == JsonValueKind.Object)
{
var status = detail.TryGetProperty("status", out var s) && s.ValueKind == JsonValueKind.String ? s.GetString() : null;
var message = detail.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String ? m.GetString() : null;
if (status is not null || message is not null) return string.Join(": ", new[] { status, message }.Where(p => !string.IsNullOrWhiteSpace(p)));
}
}
catch (JsonException)
{
}
return body;
}
private string Hide(string text) =>
string.IsNullOrEmpty(Settings.ApiKey) ? text : text.Replace(Settings.ApiKey, "[REDACTED]", StringComparison.Ordinal);
[GeneratedRegex("^[A-Za-z0-9]{20}$")]
private static partial Regex VoiceIdShape();
}