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.
123 lines
5.3 KiB
C#
123 lines
5.3 KiB
C#
using Telegram.Bot.Exceptions;
|
||
using Telegram.Bot.Requests;
|
||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||
using Volcanic.AgenticCore.Telegram.Chats;
|
||
using Volcanic.AgenticCore.Telegram.Speech;
|
||
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
||
using Volcanic.AgenticCore.Testing;
|
||
|
||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||
|
||
public sealed class VoiceToolChatTests
|
||
{
|
||
private static async Task<ToolResultMessage> ResultAsync(ChatHost host) =>
|
||
(await host.ChatAsync()).Session.Messages.OfType<ToolResultMessage>().Single();
|
||
|
||
[Fact]
|
||
public async Task Voice_text_is_spoken_and_sent_as_a_voice_note_before_the_answer()
|
||
{
|
||
var tts = new FakeTextToSpeech();
|
||
await using var host = new ChatHost(tts: tts);
|
||
host.Faux.Enqueue(
|
||
FauxTurn.Tool("voice", new { text = "Привет! Встреча в три.", voice = "coral", instructions = "warm" }),
|
||
FauxTurn.Say("Sent."));
|
||
|
||
await host.SendAsync("скажи голосом");
|
||
await host.SettleAsync();
|
||
|
||
Assert.Equal(("Привет! Встреча в три.", "coral", "warm"), Assert.Single(tts.Calls));
|
||
var upload = Assert.Single(host.Bot.Uploads);
|
||
Assert.Equal(("sendVoice", "voice-1.ogg"), (upload.Method, upload.FileName));
|
||
Assert.Equal(new byte[] { 0x4F, 0x67, 0x67, 0x53, 1 }, upload.Bytes);
|
||
var requests = host.Bot.Requests.ToList();
|
||
Assert.InRange(requests.FindIndex(r => r is SendVoiceRequest), 0,
|
||
requests.FindIndex(r => r is SendMessageRequest m && m.Text.Contains("Sent.", StringComparison.Ordinal)) - 1);
|
||
var result = await ResultAsync(host);
|
||
Assert.False(result.IsError);
|
||
Assert.Equal("Sent 1 voice message.", result.Text);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Long_text_goes_out_as_several_voice_notes_in_order()
|
||
{
|
||
var tts = new FakeTextToSpeech();
|
||
await using var host = new ChatHost(tts: tts);
|
||
var sentence = new string('а', 99) + ". ";
|
||
var text = string.Concat(Enumerable.Repeat(sentence, 20)).Trim(); // about 2000 characters
|
||
host.Faux.Enqueue(FauxTurn.Tool("voice", new { text }), FauxTurn.Say("Done."));
|
||
|
||
await host.SendAsync("long voice");
|
||
await host.SettleAsync();
|
||
|
||
Assert.Equal(2, tts.Calls.Count);
|
||
Assert.All(tts.Calls, c => Assert.True(c.Text.Length <= VoiceExtension.MaxPartLength));
|
||
Assert.Equal(["voice-1.ogg", "voice-2.ogg"], host.Bot.Uploads.Select(u => u.FileName));
|
||
Assert.Equal("Sent 2 voice messages.", (await ResultAsync(host)).Text);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Too_long_text_is_refused_without_calling_the_api()
|
||
{
|
||
var tts = new FakeTextToSpeech();
|
||
await using var host = new ChatHost(tts: tts);
|
||
host.Faux.Enqueue(FauxTurn.Tool("voice", new { text = new string('x', VoiceExtension.MaxTextLength + 1) }), FauxTurn.Say("Too long."));
|
||
|
||
await host.SendAsync("very long voice");
|
||
await host.SettleAsync();
|
||
|
||
Assert.Empty(tts.Calls);
|
||
Assert.Empty(host.Bot.Uploads);
|
||
var result = await ResultAsync(host);
|
||
Assert.True(result.IsError);
|
||
Assert.Contains("at most 4500", result.Text, StringComparison.Ordinal);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Api_errors_come_back_as_text_and_nothing_is_sent()
|
||
{
|
||
var tts = new FakeTextToSpeech { Failure = new TextToSpeechException("OpenAI speech synthesis failed: HTTP 401 bad key") };
|
||
await using var host = new ChatHost(tts: tts);
|
||
host.Faux.Enqueue(FauxTurn.Tool("voice", new { text = "hello" }), FauxTurn.Say("It failed."));
|
||
|
||
await host.SendAsync("voice");
|
||
await host.SettleAsync();
|
||
|
||
Assert.Empty(host.Bot.Uploads);
|
||
var result = await ResultAsync(host);
|
||
Assert.True(result.IsError);
|
||
Assert.Equal("OpenAI speech synthesis failed: HTTP 401 bad key Nothing was sent.", result.Text);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task A_refused_voice_note_is_reported()
|
||
{
|
||
await using var host = new ChatHost(tts: new FakeTextToSpeech());
|
||
host.Bot.OnSend = r => { if (r is SendVoiceRequest) throw new ApiRequestException("Forbidden: VOICE_MESSAGES_FORBIDDEN", 400); };
|
||
host.Faux.Enqueue(FauxTurn.Tool("voice", new { text = "hello" }), FauxTurn.Say("It failed."));
|
||
|
||
await host.SendAsync("voice");
|
||
await host.SettleAsync();
|
||
|
||
var result = await ResultAsync(host);
|
||
Assert.True(result.IsError);
|
||
Assert.Contains("VOICE_MESSAGES_FORBIDDEN", result.Text, StringComparison.Ordinal);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Without_text_to_speech_there_is_no_voice_tool()
|
||
{
|
||
await using var host = new ChatHost();
|
||
var chat = await host.ChatAsync();
|
||
Assert.DoesNotContain(chat.Session.Tools, t => t.Name == "voice");
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("One. Two. Three.", 100, new[] { "One. Two. Three." })]
|
||
[InlineData("Aaaa bbbb. Cccc dddd.", 12, new[] { "Aaaa bbbb.", "Cccc dddd." })]
|
||
[InlineData("aaaa bbbb cccc", 9, new[] { "aaaa bbbb", "cccc" })]
|
||
[InlineData("abcdefghij", 4, new[] { "abcd", "efgh", "ij" })]
|
||
[InlineData("First para\n\nSecond para", 15, new[] { "First para", "Second para" })]
|
||
public void Splits_at_paragraphs_sentences_spaces_then_hard(string text, int max, string[] expected) =>
|
||
Assert.Equal(expected, VoiceExtension.SplitForSpeech(text, max));
|
||
}
|