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.
153 lines
8.2 KiB
C#
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();
|
|
}
|