Files
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

74 lines
3.6 KiB
C#

using System.Net.Http.Headers;
using System.Net.Http.Json;
namespace Volcanic.AgenticCore.Telegram.Speech;
/// <summary>OpenAI's <c>/audio/speech</c> with <c>response_format=opus</c>, which Telegram shows as a voice note.</summary>
public sealed class OpenAiTextToSpeech(HttpClient http, SpeechOptions options) : ITextToSpeech
{
private const string Prefix = "OpenAI speech synthesis failed: ";
private const int MaxExcerpt = 200;
public string ArgumentGuide =>
$"voice: an OpenAI voice, {options.ResolvedTtsVoice} by default; nova (female), marin, coral, shimmer, cedar, alloy, ash, ballad, echo, fable, onyx, sage, verse. " +
"instructions: delivery in English, e.g. \"warm and calm, unhurried\" or \"cheerful, upbeat\".";
public async Task<byte[]> SynthesizeAsync(string text, string? voice, string? instructions, CancellationToken cancellationToken)
{
for (var attempt = 1; ; attempt++)
{
try
{
return await SynthesizeOnceAsync(text, voice, instructions, cancellationToken).ConfigureAwait(false);
}
catch (TextToSpeechException ex) when (ex.IsTransient && attempt < SpeechOptions.MaxAttempts)
{
await Task.Delay(options.RetryDelay * (1 << (attempt - 1)), cancellationToken).ConfigureAwait(false);
}
}
}
private async Task<byte[]> SynthesizeOnceAsync(string text, string? voice, string? instructions, CancellationToken cancellationToken)
{
var body = new Dictionary<string, string>
{
["model"] = options.ResolvedTtsModel,
["input"] = text,
["voice"] = string.IsNullOrWhiteSpace(voice) ? options.ResolvedTtsVoice : voice.Trim(),
["response_format"] = "opus",
};
var tone = string.IsNullOrWhiteSpace(instructions) ? options.TtsInstructions : instructions.Trim();
if (!string.IsNullOrWhiteSpace(tone)) body["instructions"] = tone.Trim();
using var request = new HttpRequestMessage(HttpMethod.Post, options.BaseUrl.TrimEnd('/') + "/audio/speech")
{
Content = JsonContent.Create(body),
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey);
try
{
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
var audio = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
return audio.Length > 0 ? audio : throw new TextToSpeechException(Prefix + "the response is empty.");
}
var error = Hide(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 };
}
}
private string Hide(string text) =>
string.IsNullOrEmpty(options.ApiKey) ? text : text.Replace(options.ApiKey, "[REDACTED]", StringComparison.Ordinal);
}