Files
VolcanicAgenticCore/hosts/Volcanic.AgenticCore.Telegram/Chats/VoiceExtension.cs
EugeneTes 87240238cc feat(telegram): voice tool sends spoken replies as Telegram voice notes
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.
2026-10-09 10:09:46 +00:00

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;
}
}