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.
119 lines
5.8 KiB
C#
119 lines
5.8 KiB
C#
using System.ComponentModel;
|
|
using System.Globalization;
|
|
using Volcanic.AgenticCore.Abstractions.Extensions;
|
|
using Volcanic.AgenticCore.Abstractions.Tools;
|
|
using Volcanic.AgenticCore.Abstractions.Ui;
|
|
using Volcanic.AgenticCore.Telegram.Speech;
|
|
|
|
namespace Volcanic.AgenticCore.Telegram.Chats;
|
|
|
|
/// <summary>Arguments of <c>voice</c>.</summary>
|
|
public sealed record VoiceArgs(
|
|
[property: Description("What to say, as plain spoken text in any language (no Markdown, links or emoji)")] string Text,
|
|
[property: Description("Optional OpenAI voice: nova (default, female), marin, coral, shimmer, cedar, alloy, ash, ballad, echo, fable, onyx, sage, verse")] string? Voice = null,
|
|
[property: Description("Optional delivery instructions in English, e.g. \"warm and calm, unhurried\" or \"cheerful, upbeat\"")] string? Instructions = null);
|
|
|
|
/// <summary>Host extension per chat: the <c>voice</c> tool, which speaks text with OpenAI and sends it as Telegram voice notes.</summary>
|
|
public sealed class VoiceExtension(ITextToSpeech speech, TelegramChatChannel channel) : IExtension
|
|
{
|
|
public const string ExtensionName = "telegram-voice";
|
|
public const string ToolName = "voice";
|
|
|
|
/// <summary>The most text one call may speak; more is refused, not cut.</summary>
|
|
public const int MaxTextLength = 4500;
|
|
|
|
/// <summary>The longest text per voice note (the speech API takes 4096 characters and about 2000 tokens).</summary>
|
|
public const int MaxPartLength = 1500;
|
|
|
|
public string Name => ExtensionName;
|
|
|
|
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(api);
|
|
api.RegisterTool(new DelegateTool<VoiceArgs>(
|
|
ToolName,
|
|
$"Speak text aloud and send it to the user in this chat as a Telegram voice message. Up to {MaxTextLength} characters per call; " +
|
|
$"text over {MaxPartLength} characters is split at sentence boundaries into several voice messages, sent in order. " +
|
|
"Longer text is refused: shorten it, or send it as text. Errors come back as text, so you can tell the user.",
|
|
(args, _, ct) => SpeakAsync(args, ct),
|
|
promptSnippet: "voice: send text to the user as a spoken voice message",
|
|
promptGuidelines:
|
|
[
|
|
"Use voice only when the user asks for a voice message or audio; otherwise answer in text.",
|
|
"Write the voice text as natural speech: no Markdown, lists, links or emoji. Don't repeat the voice text in your reply.",
|
|
],
|
|
executionMode: ToolExecutionMode.Sequential));
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
private async Task<ToolResult> SpeakAsync(VoiceArgs args, CancellationToken cancellationToken)
|
|
{
|
|
var text = args.Text?.Trim() ?? "";
|
|
if (text.Length == 0) throw new ToolExecutionException(ToolResult.Text("Nothing to say: text is empty."));
|
|
if (text.Length > MaxTextLength)
|
|
throw new ToolExecutionException(ToolResult.Text(
|
|
$"The text is {text.Length} characters; one voice call takes at most {MaxTextLength}. Shorten it, or send it as text."));
|
|
|
|
var parts = SplitForSpeech(text, MaxPartLength);
|
|
// Synthesize every part before sending any, so a speech failure never leaves half a message in the chat.
|
|
var audio = new List<byte[]>(parts.Count);
|
|
try
|
|
{
|
|
foreach (var part in parts) audio.Add(await speech.SynthesizeAsync(part, args.Voice, args.Instructions, cancellationToken).ConfigureAwait(false));
|
|
}
|
|
catch (TextToSpeechException ex)
|
|
{
|
|
throw new ToolExecutionException(ToolResult.Text($"{ex.Message} Nothing was sent."));
|
|
}
|
|
|
|
for (var i = 0; i < audio.Count; i++)
|
|
{
|
|
try
|
|
{
|
|
await channel.SendVoiceAsync(audio[i], $"voice-{i + 1}.ogg", cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (ChatDeliveryException ex)
|
|
{
|
|
var sent = i == 0 ? "Nothing was sent." : $"{i} of {audio.Count} voice messages were sent before it.";
|
|
throw new ToolExecutionException(ToolResult.Text($"{ex.Message} {sent}"));
|
|
}
|
|
}
|
|
return ToolResult.Text(audio.Count == 1
|
|
? "Sent 1 voice message."
|
|
: string.Create(CultureInfo.InvariantCulture, $"Sent {audio.Count} voice messages."));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cuts <paramref name="text"/> into parts of at most <paramref name="max"/> characters, preferring paragraph breaks,
|
|
/// then sentence ends, then spaces; a run without any of them is cut hard.
|
|
/// </summary>
|
|
public static IReadOnlyList<string> SplitForSpeech(string text, int max)
|
|
{
|
|
ArgumentOutOfRangeException.ThrowIfLessThan(max, 1);
|
|
var parts = new List<string>();
|
|
var rest = text.Trim();
|
|
while (rest.Length > max)
|
|
{
|
|
var cut = LastBreak(rest, max);
|
|
parts.Add(rest[..cut].Trim());
|
|
rest = rest[cut..].TrimStart();
|
|
}
|
|
if (rest.Length > 0) parts.Add(rest);
|
|
return parts;
|
|
}
|
|
|
|
// The length of the best first part within max: after the last paragraph break past half of max, else the last sentence
|
|
// end, else the last whitespace, else max.
|
|
private static int LastBreak(string text, int max)
|
|
{
|
|
var window = text[..(max + 1)]; // includes the character just past max, so a break right at max counts
|
|
var paragraph = window.LastIndexOf("\n\n", StringComparison.Ordinal);
|
|
if (paragraph >= max / 2) return paragraph;
|
|
for (var i = max - 1; i >= max / 2; i--)
|
|
if (text[i] is '.' or '!' or '?' or '…' or ';' && char.IsWhiteSpace(window[i + 1])) return i + 1;
|
|
for (var i = max; i > 0; i--)
|
|
if (char.IsWhiteSpace(window[i])) return i;
|
|
return max;
|
|
}
|
|
}
|