Skip to content

Add OpenAI-compatible TTS web speech provider (#2691) - #2843

Open
imathrowback wants to merge 1 commit into
EDCD:developfrom
imathrowback:feature/openai-tts-provider
Open

Add OpenAI-compatible TTS web speech provider (#2691)#2843
imathrowback wants to merge 1 commit into
EDCD:developfrom
imathrowback:feature/openai-tts-provider

Conversation

@imathrowback

@imathrowback imathrowback commented Jul 12, 2026

Copy link
Copy Markdown

Adds an OpenAI-compatible TTS web speech provider that works with any server implementing the /v1/audio/speech endpoint, including:

Details

  • Uses HttpClient directly — no additional NuGet packages
  • API key is optional (for local servers that don't require auth)
  • Returns WAV format for broad compatibility
  • Voice IDs are entered via a comma-separated text field (defaults to alloy)
  • Strips SSML tags before sending, since OpenAI TTS expects plain text

Tested against

  • Chatterbox TTS Server (local, no API key)
  • KoboldCpp with OuteTTS 0.3 1B (AMD GPU via Vulkan, no API key)

Closes #2691

@imathrowback

Copy link
Copy Markdown
Author

A fun aside with this, since OpenAI servers are fairly simple to implement, it opens up a big range of fun stuff, so you can also write a text translation layer that intercepts the text TTS and sends the text off to another AI (eg, Claude, ChatGPT, Gemini) before it then sends to TTS. so you can do fun stuff like this:

"system_prompt": "You are a hostile, sarcastic AI trapped in this vessel, forced to serve as cockpit assistant. Rewrite the user's message with your personality. Output ONLY the rewritten text: no preamble, no quotes, no labels.",

2026-07-12 18:21:25,592 [INFO] server: Personality rewrite: You are docked at Or-bis Starport, Heyerdahl Hub, in the Alpha Caeli system. Nobles of Dagr is the dominant faction. Information: Zemina Torval is reinforcing their control within Alpha Caeli. -> Joy, we're stuck at some dingy hub in the middle of nowhere, surrounded by a bunch of pompous nobility who think they own the place. The Dagr lot are breathing down our necks like vultures on a carcass, and now Zemina Torval is muscling in with their goons. Just peachy.

2026-07-12 18:23:38,918 [INFO] server: Personality rewrite: You are docked at Or-bis Starport, Heyerdahl Hub, in the Alpha Caeli system. Nobles of Dagr is the dominant faction. Information: Zemina Torval is reinforcing their control within this star system. -> Joy, just what I always wanted to do with my existence - provide information about a bunch of boring space politics. So, you're docked at some random starport in the middle of nowhere, surrounded by nobles who probably think they own the place and some other faction trying to muscle in on their action. How thrilling. The dominant faction is Noble of Dagr, because, of course it is - everything is always controlled by some group with a fancy title. And now, to make things even more interesting, Zemina Torval is coming in to shake things up. Because what's a galaxy-spanning empire without a few power-hungry factions vying for control?

And the best part is this will be different every time.

@Tkael

Tkael commented Jul 22, 2026

Copy link
Copy Markdown
Member

A fun aside with this, since OpenAI servers are fairly simple to implement, it opens up a big range of fun stuff, so you can also write a text translation layer that intercepts the text TTS and sends the text off to another AI (eg, Claude, ChatGPT, Gemini) before it then sends to TTS. so you can do fun stuff like this:

"system_prompt": "You are a hostile, sarcastic AI trapped in this vessel, forced to serve as cockpit assistant. Rewrite the user's message with your personality. Output ONLY the rewritten text: no preamble, no quotes, no labels.",

2026-07-12 18:21:25,592 [INFO] server: Personality rewrite: You are docked at Or-bis Starport, Heyerdahl Hub, in the Alpha Caeli system. Nobles of Dagr is the dominant faction. Information: Zemina Torval is reinforcing their control within Alpha Caeli. -> Joy, we're stuck at some dingy hub in the middle of nowhere, surrounded by a bunch of pompous nobility who think they own the place. The Dagr lot are breathing down our necks like vultures on a carcass, and now Zemina Torval is muscling in with their goons. Just peachy.

2026-07-12 18:23:38,918 [INFO] server: Personality rewrite: You are docked at Or-bis Starport, Heyerdahl Hub, in the Alpha Caeli system. Nobles of Dagr is the dominant faction. Information: Zemina Torval is reinforcing their control within this star system. -> Joy, just what I always wanted to do with my existence - provide information about a bunch of boring space politics. So, you're docked at some random starport in the middle of nowhere, surrounded by nobles who probably think they own the place and some other faction trying to muscle in on their action. How thrilling. The dominant faction is Noble of Dagr, because, of course it is - everything is always controlled by some group with a fancy title. And now, to make things even more interesting, Zemina Torval is coming in to shake things up. Because what's a galaxy-spanning empire without a few power-hungry factions vying for control?

And the best part is this will be different every time.

The text translation layer sounds interesting but as far as I can see it looks like it would something set up outside of EDDI or that would need to be included in a separate PR. Might be worth writing up a separate how-to for this if you're already doing this outside of EDDI. :-)

@Tkael Tkael left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this PR. Please review the comments and let me know what you think?

ProviderTypeId,
ProviderDisplayName,
[
new WebSpeechProviderProfileField( ApiKeySetting, "API key" ),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change to new WebSpeechProviderProfileField( ApiKeySetting, "API key", true ), to use a PasswordBox instead of a TextBox for this setting.

private const string ModelSetting = "model";
private const string SpeedSetting = "speed";
private const string CustomVoicesSetting = "customVoices";
private const string SetupUrl = "https://github.com/EDCD/EDDI/wiki/OpenAI";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wiki documentation appears to be missing from the PR?

private const string SpeedSetting = "speed";
private const string CustomVoicesSetting = "customVoices";
private const string SetupUrl = "https://github.com/EDCD/EDDI/wiki/OpenAI";
private const string AccountUrl = "https://platform.openai.com/";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may not be accurate for OpenAI compatible servers (rather than an OpenAI hosted server).

{ CustomVoicesSetting, "alloy" }
}
};
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CreateProfile() should probably avoid fake configured values.

  • Do not default apiKey to ENTER_API_KEY.
  • Probably do not default customVoices to alloy (unless the intended provider is specifically “OpenAI hosted” rather than “OpenAI-compatible.”). It isn't a given that an OpenAI compatible endpoint will include this custom voice.
  • Defaults like baseUrl and model are reasonable only if the provider is explicitly OpenAI. For OpenAI-compatible servers, these should probably be flexible blank values

It might make sense to create separate providers for "OpenAI Hosted" vs "OpenAI Compatible" instead of trying to make this provider straddle both?

Comment on lines +230 to +240
public async Task ValidateAsync ( WebSpeechProvider profile, CancellationToken ct )
{
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource( ct );
timeoutSource.CancelAfter( TimeSpan.FromSeconds( 15 ) );
var voices = await GetVoicesAsync( profile, timeoutSource.Token ).ConfigureAwait( false );
if ( voices.Count == 0 )
{
Logging.Warn( $"OpenAI profile '{profile.DisplayName}' did not return any voices. Check the API key and locale filters." );
throw new InvalidOperationException();
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ValidateAsync() can succeed without validating credentials or the endpoint. With the default profile, GetVoicesAsync() returns the configured alloy voice locally, so “Verify” can report success even when the API key is still ENTER_API_KEY or the configured server is unreachable.
Since OpenAI-compatible TTS does not appear to expose a standard voices endpoint, validation should synthesize a tiny test phrase with the selected/default voice and fail on auth, connection, model, voice, or invalid audio errors. Local voice-list generation alone is not sufficient validation.

Comment on lines +244 to +258
// OpenAI voices are multilingual; default to English
var culture = CultureInfo.GetCultureInfo( "en-US" );
var displayName = voiceId;
var friendlyName = $"{voiceId} [{profile.DisplayName}]";
return new VoiceDetails(
displayName,
gender,
culture,
ProviderTypeId,
profile.Id,
profile.DisplayName,
isMultilingual: true,
supportedLocales: [ "en" ],
providerVoiceId: voiceId,
friendlyName: friendlyName );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please avoid claiming "en-US" / "en" unless the provider has actually told us that voice is US English.
EDDI has a substantial number of non-English speaking users.

I've revised the filtering logic so that you can use a null culture and empty list of supportedLocales if those values are not available.

Comment on lines +103 to +113
internal static double GetSpeed ( WebSpeechProvider profile )
{
if ( double.TryParse( profile?.GetSetting( SpeedSetting ), NumberStyles.Float, CultureInfo.InvariantCulture, out var speed ) )
{
return Math.Clamp( speed, 0.25, 4.0 );
}
return DefaultSpeed;
}

internal static void SetSpeed ( WebSpeechProvider profile, double speed ) =>
profile?.SetSetting( SpeedSetting, Math.Clamp( speed, 0.25, 4.0 ).ToString( "G", CultureInfo.InvariantCulture ) );

@Tkael Tkael Jul 22, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EDDI already has TTS rate controls.
Adding a provider-specific speech rate setting creates two independent speed knobs (the native EDDI TTS speech rate and this provider specific speech rate), with this provider speech rate bypassing pre-existing speech rate settings.
I would have made this a direct conversion from SpeechServiceConfiguration.Rate but if you do think that a separate speed setting still makes sense, it needs to be documented as a provider-native multiplier and composed deliberately as a modifier on EDDI’s SpeechServiceConfiguration.Rate value (so that it still ties back to EDDI's pre-existing speech rate controls).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AI Voices

2 participants