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.
70 lines
3.3 KiB
C#
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);
|
|
}
|