Files
VolcanicAgenticCore/hosts/Volcanic.AgenticCore.Telegram/Speech/SpeechOptions.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

118 lines
5.8 KiB
C#

namespace Volcanic.AgenticCore.Telegram.Speech;
/// <summary>Which service speaks for the <c>voice</c> tool.</summary>
public enum TextToSpeechProvider
{
None,
OpenAi,
ElevenLabs,
}
/// <summary>
/// The <c>Telegram:Speech</c> section. Speech to text (voice notes in) is on exactly when <see cref="ApiKey"/> is set;
/// text to speech (the <c>voice</c> tool) uses <see cref="ElevenLabs"/> when it is configured, else OpenAI with the same key.
/// </summary>
public sealed class SpeechOptions
{
public const string DefaultModel = "gpt-4o-transcribe";
public const string DefaultBaseUrl = "https://api.openai.com/v1";
public const string DefaultTtsModel = "gpt-4o-mini-tts";
public const string DefaultTtsVoice = "nova";
/// <summary>Falls back to <c>OPENAI_API_KEY</c>. Empty turns speech to text off.</summary>
public string ApiKey { get; set; } = "";
/// <summary>Falls back to <c>STT_MODEL</c>, then <see cref="DefaultModel"/>.</summary>
public string Model { get; set; } = "";
/// <summary>Falls back to <c>TTS_MODEL</c>, then <see cref="DefaultTtsModel"/>.</summary>
public string TtsModel { get; set; } = "";
/// <summary>Falls back to <c>TTS_VOICE</c>, then <see cref="DefaultTtsVoice"/>. The tool's <c>voice</c> argument overrides it.</summary>
public string TtsVoice { get; set; } = "";
/// <summary>Default delivery instructions (tone, pace); the tool's <c>instructions</c> argument overrides them. Ignored by tts-1.</summary>
public string? TtsInstructions { get; set; }
/// <summary>
/// Falls back to <c>TTS_PROVIDER</c>: <c>elevenlabs</c> or <c>openai</c>. Empty prefers ElevenLabs. The preferred
/// provider is used when configured, else the other one.
/// </summary>
public string TtsProvider { get; set; } = "";
/// <summary>ElevenLabs text to speech (<c>Telegram:Speech:ElevenLabs</c>).</summary>
public ElevenLabsOptions ElevenLabs { get; set; } = new();
/// <summary>Any OpenAI-compatible audio endpoint (transcriptions and speech).</summary>
public string BaseUrl { get; set; } = DefaultBaseUrl;
/// <summary>Optional ISO-639-1 hint.</summary>
public string? Language { get; set; }
/// <summary>Tries per transcription; network errors, timeouts, 408, 429 and 5xx are retried.</summary>
public const int MaxAttempts = 3;
/// <summary>The wait before the second attempt, doubled before the third. Internal so tests can zero it.</summary>
internal TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(1);
public bool IsEnabled => !string.IsNullOrWhiteSpace(ApiKey);
public string ResolvedModel => string.IsNullOrWhiteSpace(Model) ? DefaultModel : Model.Trim();
public string ResolvedTtsModel => string.IsNullOrWhiteSpace(TtsModel) ? DefaultTtsModel : TtsModel.Trim();
public string ResolvedTtsVoice => string.IsNullOrWhiteSpace(TtsVoice) ? DefaultTtsVoice : TtsVoice.Trim();
/// <summary>The service behind the <c>voice</c> tool, or <see cref="TextToSpeechProvider.None"/> when neither is configured.</summary>
public TextToSpeechProvider ResolvedTtsProvider
{
get
{
var elevenLabs = ElevenLabs?.IsConfigured == true;
var preferOpenAi = string.Equals(TtsProvider?.Trim(), "openai", StringComparison.OrdinalIgnoreCase);
return (preferOpenAi, IsEnabled, elevenLabs) switch
{
(true, true, _) => TextToSpeechProvider.OpenAi,
(false, _, true) => TextToSpeechProvider.ElevenLabs,
(_, true, _) => TextToSpeechProvider.OpenAi,
(_, _, true) => TextToSpeechProvider.ElevenLabs,
_ => TextToSpeechProvider.None,
};
}
}
/// <summary>Settings problems that must stop the host, added to <paramref name="errors"/>.</summary>
public void Validate(List<string> errors)
{
ArgumentNullException.ThrowIfNull(errors);
// On Linux "/v1" parses as an absolute file:// URI; the scheme check rejects it.
if (!Uri.TryCreate(BaseUrl, UriKind.Absolute, out var sttUri) || sttUri.Scheme is not ("http" or "https"))
errors.Add("Telegram:Speech:BaseUrl must be an absolute http(s) URL.");
if (!string.IsNullOrWhiteSpace(TtsProvider) && TtsProvider.Trim().ToLowerInvariant() is not ("openai" or "elevenlabs"))
errors.Add("TTS_PROVIDER (Telegram:Speech:TtsProvider) must be 'elevenlabs' or 'openai'.");
if (!Uri.TryCreate(ElevenLabs?.BaseUrl, UriKind.Absolute, out var elevenUri) || elevenUri.Scheme is not ("http" or "https"))
errors.Add("Telegram:Speech:ElevenLabs:BaseUrl must be an absolute http(s) URL.");
}
/// <summary>The startup log line (never a key).</summary>
public string Describe()
{
var provider = ResolvedTtsProvider;
var stt = IsEnabled ? $"Speech to text: {ResolvedModel}" : "Speech to text: off (no OPENAI_API_KEY)";
var tts = provider switch
{
TextToSpeechProvider.OpenAi => $"text to speech: {ResolvedTtsModel}, voice {ResolvedTtsVoice}",
TextToSpeechProvider.ElevenLabs => $"text to speech: ElevenLabs {ElevenLabs.ResolvedModel}, voice {ElevenLabs.VoiceId.Trim()}",
_ => "text to speech: off",
};
if (!IsEnabled && provider == TextToSpeechProvider.None) tts = "text to speech: off (no OPENAI_API_KEY or ELEVENLABS_API_KEY)";
var line = $"{stt}; {tts}";
if (provider != TextToSpeechProvider.ElevenLabs && ElevenLabs is { } eleven
&& string.IsNullOrWhiteSpace(eleven.ApiKey) != string.IsNullOrWhiteSpace(eleven.VoiceId))
line += string.IsNullOrWhiteSpace(eleven.VoiceId)
? " (ElevenLabs off: ELEVENLABS_VOICE_ID is not set)"
: " (ElevenLabs off: ELEVENLABS_API_KEY is not set)";
return line;
}
}