Files
VolcanicAgenticCore/tests/Volcanic.AgenticCore.Telegram.Tests/ElevenLabsTextToSpeechTests.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

139 lines
7.3 KiB
C#

using System.Net;
using System.Text;
using System.Text.Json;
using Volcanic.AgenticCore.Telegram.Speech;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class ElevenLabsTextToSpeechTests
{
private const string Key = "sk_eleven-test-secret-123";
private const string VoiceId = "21m00Tcm4TlvDq8ikWAM";
private static readonly byte[] Ogg = [0x4F, 0x67, 0x67, 0x53, 9];
private sealed class StubHandler(params (HttpStatusCode Status, byte[] Body)[] answers) : HttpMessageHandler
{
public List<(HttpRequestMessage Request, JsonElement? Body)> Requests { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
JsonElement? body = request.Content is null
? null
: JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)).RootElement.Clone();
Requests.Add((request, body));
var (status, bytes) = answers[Math.Min(Requests.Count, answers.Length) - 1];
return new HttpResponseMessage(status) { Content = new ByteArrayContent(bytes) };
}
}
private static (ElevenLabsTextToSpeech Tts, StubHandler Handler) Create(string? model = null, params (HttpStatusCode, byte[])[] answers)
{
var handler = new StubHandler(answers.Length > 0 ? answers : [(HttpStatusCode.OK, Ogg)]);
var options = new SpeechOptions { RetryDelay = TimeSpan.Zero };
options.ElevenLabs = new ElevenLabsOptions { ApiKey = Key, VoiceId = VoiceId, Model = model ?? "", BaseUrl = "https://el.example/" };
return (new ElevenLabsTextToSpeech(new HttpClient(handler), options), handler);
}
private static byte[] Utf8(string text) => Encoding.UTF8.GetBytes(text);
[Fact]
public async Task Posts_text_and_model_to_the_voice_with_an_opus_format_and_the_key_header()
{
var (tts, handler) = Create();
Assert.Equal(Ogg, await tts.SynthesizeAsync("Привіт", null, "cheerful", CancellationToken.None));
var (request, body) = Assert.Single(handler.Requests);
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal($"https://el.example/v1/text-to-speech/{VoiceId}?output_format=opus_48000_64", request.RequestUri!.ToString());
Assert.Equal(Key, Assert.Single(request.Headers.GetValues("xi-api-key")));
Assert.Null(request.Headers.Authorization);
Assert.Equal("Привіт", body!.Value.GetProperty("text").GetString()); // multilingual v2 ignores instructions
Assert.Equal("eleven_multilingual_v2", body.Value.GetProperty("model_id").GetString());
}
[Fact]
public async Task V3_turns_instructions_into_audio_tags()
{
var (tts, handler) = Create("eleven_v3");
await tts.SynthesizeAsync("Привет", null, "warm, calm", CancellationToken.None);
await tts.SynthesizeAsync("Привет", null, "[whispers] [sighs]", CancellationToken.None);
await tts.SynthesizeAsync("Привет", null, null, CancellationToken.None);
Assert.Equal(["[warm, calm] Привет", "[whispers] [sighs] Привет", "Привет"],
handler.Requests.Select(r => r.Body!.Value.GetProperty("text").GetString()));
Assert.Contains("audio tags", tts.ArgumentGuide, StringComparison.Ordinal);
Assert.Contains("ignored", Create().Tts.ArgumentGuide, StringComparison.Ordinal);
}
[Fact]
public async Task A_voice_id_argument_is_used_as_is()
{
var (tts, handler) = Create();
await tts.SynthesizeAsync("hi", "AZnzlk1XvdvUeBnXmlld", null, CancellationToken.None);
Assert.EndsWith("/v1/text-to-speech/AZnzlk1XvdvUeBnXmlld?output_format=opus_48000_64", handler.Requests[0].Request.RequestUri!.ToString());
}
[Fact]
public async Task A_voice_name_is_looked_up_once_and_cached()
{
var voices = Utf8("""{"voices":[{"voice_id":"aaaaaaaaaaaaaaaaaaa1","name":"Anna Pro"},{"voice_id":"bbbbbbbbbbbbbbbbbbb2","name":"Anna - calm narrator"}]}""");
var (tts, handler) = Create(null, (HttpStatusCode.OK, voices), (HttpStatusCode.OK, Ogg));
await tts.SynthesizeAsync("hi", "anna", null, CancellationToken.None);
await tts.SynthesizeAsync("hi", "Anna", null, CancellationToken.None);
Assert.Equal(3, handler.Requests.Count);
Assert.Equal(HttpMethod.Get, handler.Requests[0].Request.Method);
Assert.Equal("https://el.example/v2/voices?page_size=100&search=anna", handler.Requests[0].Request.RequestUri!.ToString());
Assert.All(handler.Requests.Skip(1), r => Assert.Contains("/v1/text-to-speech/bbbbbbbbbbbbbbbbbbb2?", r.Request.RequestUri!.ToString(), StringComparison.Ordinal));
}
[Fact]
public async Task An_unknown_voice_name_is_an_error()
{
var (tts, handler) = Create(null, (HttpStatusCode.OK, Utf8("""{"voices":[]}""")));
var ex = await Assert.ThrowsAsync<TextToSpeechException>(() => tts.SynthesizeAsync("hi", "Nobody", null, CancellationToken.None));
Assert.Contains("no voice named \"Nobody\"", ex.Message, StringComparison.Ordinal);
Assert.Single(handler.Requests);
}
[Fact]
public async Task Quota_errors_show_status_and_message_with_the_key_hidden_and_are_not_retried()
{
var error = Utf8($$$"""{"detail":{"status":"quota_exceeded","message":"This request exceeds your quota of 10000. Key {{{Key}}}"}}""");
var (tts, handler) = Create(null, (HttpStatusCode.Unauthorized, error));
var ex = await Assert.ThrowsAsync<TextToSpeechException>(() => tts.SynthesizeAsync("hi", null, null, CancellationToken.None));
Assert.Single(handler.Requests);
Assert.StartsWith("ElevenLabs speech synthesis failed: HTTP 401 quota_exceeded: This request exceeds your quota", ex.Message, StringComparison.Ordinal);
Assert.DoesNotContain(Key, ex.Message, StringComparison.Ordinal);
Assert.Contains("[REDACTED]", ex.Message, StringComparison.Ordinal);
}
[Fact]
public async Task Rate_limits_are_retried()
{
var busy = Utf8("""{"detail":{"status":"too_many_concurrent_requests","message":"busy"}}""");
var (tts, handler) = Create(null, (HttpStatusCode.TooManyRequests, busy), (HttpStatusCode.OK, Ogg));
Assert.Equal(Ogg, await tts.SynthesizeAsync("hi", null, null, CancellationToken.None));
Assert.Equal(2, handler.Requests.Count);
}
[Fact]
public async Task Rate_limits_that_persist_are_reported()
{
var busy = Utf8("""{"detail":{"status":"too_many_concurrent_requests","message":"busy"}}""");
var (tts, handler) = Create(null, (HttpStatusCode.TooManyRequests, busy));
var ex = await Assert.ThrowsAsync<TextToSpeechException>(() => tts.SynthesizeAsync("hi", null, null, CancellationToken.None));
Assert.Equal(SpeechOptions.MaxAttempts, handler.Requests.Count);
Assert.Equal("ElevenLabs speech synthesis failed: HTTP 429 too_many_concurrent_requests: busy", ex.Message);
}
[Fact]
public async Task Audio_that_is_not_ogg_is_refused()
{
var (tts, _) = Create(null, (HttpStatusCode.OK, Utf8("ID3 mp3 bytes")));
var ex = await Assert.ThrowsAsync<TextToSpeechException>(() => tts.SynthesizeAsync("hi", null, null, CancellationToken.None));
Assert.Contains("not OGG/Opus", ex.Message, StringComparison.Ordinal);
}
}