Files
VolcanicAgenticCore/hosts/Volcanic.AgenticCore.Telegram/Speech/OpenAiTextToSpeech.cs
EugeneTes 87240238cc feat(telegram): voice tool sends spoken replies as Telegram voice notes
OpenAI gpt-4o-mini-tts (female voice nova by default; TTS_MODEL/TTS_VOICE) renders OGG/Opus,
sent with sendVoice in order with rendered output. Up to 4500 characters per call, split
into 1500-character parts at paragraph/sentence/word boundaries; all parts are synthesized
before any is sent, and API errors come back to the agent as text with the key hidden.
On whenever OPENAI_API_KEY is set.
2026-10-09 10:09:46 +00:00

70 lines
3.3 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 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);
}