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