diff --git a/components/settings/provider-config-panel.tsx b/components/settings/provider-config-panel.tsx
index c0b075aeb..351203d22 100644
--- a/components/settings/provider-config-panel.tsx
+++ b/components/settings/provider-config-panel.tsx
@@ -32,7 +32,11 @@ import {
Send,
} from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
-import { resolveProviderBaseUrl, type ProviderConfig } from '@/lib/ai/providers';
+import {
+ finalizeProviderRequestUrl,
+ resolveProviderBaseUrl,
+ type ProviderConfig,
+} from '@/lib/ai/providers';
import type { ProvidersConfig } from '@/lib/types/settings';
import { formatContextWindow } from './utils';
import { cn } from '@/lib/utils';
@@ -282,7 +286,7 @@ export function ProviderConfigPanel({
endpointPath = '';
}
- const fullUrl = effectiveBaseUrl + endpointPath;
+ const fullUrl = finalizeProviderRequestUrl(effectiveBaseUrl + endpointPath);
return (
diff --git a/lib/ai/providers.ts b/lib/ai/providers.ts
index f0603f92a..9d77eac8b 100644
--- a/lib/ai/providers.ts
+++ b/lib/ai/providers.ts
@@ -46,6 +46,30 @@ const log = createLogger('AIProviders');
export type { ProviderId, ProviderConfig, ModelInfo, ModelConfig };
const MODEL_BASE_URL_PLACEHOLDER = /\{\{\s*model\s*\}\}/gi;
+const AZURE_OPENAI_API_VERSION = '2024-05-01-preview';
+
+function shouldAppendAzureOpenAIApiVersion(url: URL): boolean {
+ const isAzureHost =
+ url.hostname.endsWith('.openai.azure.com') || url.hostname.endsWith('.cognitiveservices.azure.com');
+
+ return (
+ isAzureHost &&
+ url.pathname.includes('/openai/deployments/') &&
+ !url.searchParams.has('api-version')
+ );
+}
+
+export function finalizeProviderRequestUrl(url: string): string {
+ try {
+ const parsedUrl = new URL(url);
+ if (shouldAppendAzureOpenAIApiVersion(parsedUrl)) {
+ parsedUrl.searchParams.set('api-version', AZURE_OPENAI_API_VERSION);
+ }
+ return parsedUrl.toString();
+ } catch {
+ return url;
+ }
+}
/**
* Provider registry
@@ -1109,19 +1133,19 @@ export function getModel(config: ModelConfig): ModelWithInfo {
switch (providerType) {
case 'openai': {
+ const providerId = config.providerId;
const openaiOptions: Parameters[0] = {
apiKey: effectiveApiKey,
baseURL: effectiveBaseUrl,
};
- // For OpenAI-compatible providers (not native OpenAI), add a fetch
- // wrapper that injects vendor-specific thinking params into the HTTP
- // body. The thinking config is read from AsyncLocalStorage, set by
- // callLLM / streamLLM at call time.
- if (config.providerId !== 'openai') {
- const providerId = config.providerId;
- openaiOptions.fetch = async (url: RequestInfo | URL, init?: RequestInit) => {
- // Read thinking config from globalThis (set by thinking-context.ts)
+ openaiOptions.fetch = async (url: RequestInfo | URL, init?: RequestInit) => {
+ const rawUrl = typeof url === 'string' ? url : url.toString();
+ const requestUrl = finalizeProviderRequestUrl(rawUrl);
+
+ // For OpenAI-compatible providers (not native OpenAI), inject
+ // vendor-specific thinking params into the HTTP body.
+ if (providerId !== 'openai') {
const thinkingCtx = (globalThis as Record).__thinkingContext as
| { getStore?: () => unknown }
| undefined;
@@ -1138,9 +1162,10 @@ export function getModel(config: ModelConfig): ModelWithInfo {
}
}
}
- return globalThis.fetch(url, init);
- };
- }
+ }
+
+ return globalThis.fetch(requestUrl, init);
+ };
const openai = createOpenAI(openaiOptions);
model = openai.chat(config.modelId);
diff --git a/tests/ai/minimax-provider.test.ts b/tests/ai/minimax-provider.test.ts
index fd54ca338..ff938d74a 100644
--- a/tests/ai/minimax-provider.test.ts
+++ b/tests/ai/minimax-provider.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { getProvider, resolveProviderBaseUrl } from '@/lib/ai/providers';
+import { finalizeProviderRequestUrl, getProvider, resolveProviderBaseUrl } from '@/lib/ai/providers';
describe('MiniMax provider defaults', () => {
it('uses the Anthropic-compatible v1 endpoint by default', () => {
@@ -47,4 +47,24 @@ describe('OpenAI-compatible base URL templates', () => {
),
).toBe('https://gateway.example.com/deployments/publisher%2Fmodel-name');
});
+
+ it('appends the Azure OpenAI api-version to deployment endpoints', () => {
+ expect(
+ finalizeProviderRequestUrl(
+ 'https://resource.example.cognitiveservices.azure.com/openai/deployments/gpt-5.4/chat/completions',
+ ),
+ ).toBe(
+ 'https://resource.example.cognitiveservices.azure.com/openai/deployments/gpt-5.4/chat/completions?api-version=2024-05-01-preview',
+ );
+ });
+
+ it('preserves an explicit Azure OpenAI api-version if already present', () => {
+ expect(
+ finalizeProviderRequestUrl(
+ 'https://resource.example.openai.azure.com/openai/deployments/gpt-5.4/chat/completions?api-version=2024-10-21',
+ ),
+ ).toBe(
+ 'https://resource.example.openai.azure.com/openai/deployments/gpt-5.4/chat/completions?api-version=2024-10-21',
+ );
+ });
});
From 00bd41c1df0f94f70a71afa5d5d697e76f552dac Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miralles=20Sol=C3=A9?= <46744682B@gcb.intranet.gencat.cat>
Date: Sat, 4 Apr 2026 11:51:17 +0200
Subject: [PATCH 3/8] feat(tts): add Azure AI Foundry TTS provider with
DragonHD voice support
- Add 'azure-foundry-tts' to TTSProviderId union type
- Register azure-foundry-tts provider in constants.ts with standard Neural
and premium DragonHD voices (en-US, zh-CN, ca-ES, es-ES)
- Add getVoiceLanguage() helper to extract BCP-47 locale from Azure voice ID
- Fix xml:lang hardcoded 'zh-CN' bug in generateAzureTTS (now dynamic)
- Add generateAzureFoundryTTS() using multi-service Cognitive Services URL
- Wire new provider into generateTTS() switch, settings store, env map and i18n
Co-Authored-By: Claude Sonnet 4.6
---
lib/audio/constants.ts | 34 ++++++++++++++++++++
lib/audio/tts-providers.ts | 60 +++++++++++++++++++++++++++++++++--
lib/audio/types.ts | 1 +
lib/i18n/settings.ts | 2 ++
lib/server/provider-config.ts | 1 +
lib/store/settings.ts | 1 +
6 files changed, 97 insertions(+), 2 deletions(-)
diff --git a/lib/audio/constants.ts b/lib/audio/constants.ts
index 423f5b82c..e79d329cf 100644
--- a/lib/audio/constants.ts
+++ b/lib/audio/constants.ts
@@ -209,6 +209,38 @@ export const TTS_PROVIDERS: Record = {
speedRange: { min: 0.5, max: 2.0, default: 1.0 },
},
+ 'azure-foundry-tts': {
+ id: 'azure-foundry-tts',
+ name: 'Azure AI Foundry TTS',
+ requiresApiKey: true,
+ defaultBaseUrl: 'https://{resource}.cognitiveservices.azure.com',
+ icon: '/logos/azure.svg',
+ models: [],
+ defaultModelId: '',
+ voices: [
+ // Standard Neural voices
+ { id: 'en-US-AvaNeural', name: 'Ava', language: 'en-US', gender: 'female' as const },
+ { id: 'en-US-AndrewNeural', name: 'Andrew', language: 'en-US', gender: 'male' as const },
+ { id: 'en-US-EmmaNeural', name: 'Emma', language: 'en-US', gender: 'female' as const },
+ { id: 'en-US-BrianNeural', name: 'Brian', language: 'en-US', gender: 'male' as const },
+ { id: 'zh-CN-XiaoxiaoNeural', name: '晓晓 (女)', language: 'zh-CN', gender: 'female' as const },
+ { id: 'zh-CN-YunxiNeural', name: '云希 (男)', language: 'zh-CN', gender: 'male' as const },
+ { id: 'ca-ES-AlbaNeural', name: 'Alba (Català)', language: 'ca-ES', gender: 'female' as const },
+ { id: 'ca-ES-EnricNeural', name: 'Enric (Català)', language: 'ca-ES', gender: 'male' as const },
+ { id: 'es-ES-ElviraNeural', name: 'Elvira (Español)', language: 'es-ES', gender: 'female' as const },
+ { id: 'es-ES-AlvaroNeural', name: 'Álvaro (Español)', language: 'es-ES', gender: 'male' as const },
+ // HD Neural voices (Azure AI Foundry — premium DragonHD quality)
+ { id: 'en-US-Ava:DragonHDLatestNeural', name: 'Ava HD', language: 'en-US', gender: 'female' as const, description: 'DragonHD' },
+ { id: 'en-US-Andrew:DragonHDLatestNeural', name: 'Andrew HD', language: 'en-US', gender: 'male' as const, description: 'DragonHD' },
+ { id: 'en-US-Emma:DragonHDLatestNeural', name: 'Emma HD', language: 'en-US', gender: 'female' as const, description: 'DragonHD' },
+ { id: 'en-US-Brian:DragonHDLatestNeural', name: 'Brian HD', language: 'en-US', gender: 'male' as const, description: 'DragonHD' },
+ { id: 'ca-ES-Alba:DragonHDLatestNeural', name: 'Alba HD (Català)', language: 'ca-ES', gender: 'female' as const, description: 'DragonHD' },
+ { id: 'es-ES-Elvira:DragonHDLatestNeural', name: 'Elvira HD (Español)', language: 'es-ES', gender: 'female' as const, description: 'DragonHD' },
+ ],
+ supportedFormats: ['mp3', 'wav', 'ogg'],
+ speedRange: { min: 0.5, max: 2.0, default: 1.0 },
+ },
+
'glm-tts': {
id: 'glm-tts',
name: 'GLM TTS',
@@ -1120,6 +1152,7 @@ export function getTTSProvider(providerId: TTSProviderId): TTSProviderConfig | u
export const DEFAULT_TTS_VOICES: Record = {
'openai-tts': 'alloy',
'azure-tts': 'zh-CN-XiaoxiaoNeural',
+ 'azure-foundry-tts': 'en-US-AvaNeural',
'glm-tts': 'tongtong',
'qwen-tts': 'Cherry',
'doubao-tts': 'zh_female_vv_uranus_bigtts',
@@ -1131,6 +1164,7 @@ export const DEFAULT_TTS_VOICES: Record = {
export const DEFAULT_TTS_MODELS: Record = {
'openai-tts': 'gpt-4o-mini-tts',
'azure-tts': '',
+ 'azure-foundry-tts': '',
'glm-tts': 'glm-tts',
'qwen-tts': 'qwen3-tts-flash',
'doubao-tts': '',
diff --git a/lib/audio/tts-providers.ts b/lib/audio/tts-providers.ts
index 67f0e7cc0..25bbc88b8 100644
--- a/lib/audio/tts-providers.ts
+++ b/lib/audio/tts-providers.ts
@@ -144,6 +144,9 @@ export async function generateTTS(
case 'azure-tts':
return await generateAzureTTS(config, text);
+ case 'azure-foundry-tts':
+ return await generateAzureFoundryTTS(config, text);
+
case 'glm-tts':
return await generateGLMTTS(config, text);
@@ -203,6 +206,16 @@ async function generateOpenAITTS(
};
}
+/**
+ * Extracts the BCP-47 language code from an Azure voice name.
+ * Supports both standard ("en-US-AvaNeural") and HD ("en-US-Ava:DragonHDLatestNeural") formats.
+ * Falls back to 'en-US' if the format is unrecognised.
+ */
+function getVoiceLanguage(voice: string): string {
+ const match = voice.match(/^([a-z]{2,3}-[A-Z]{2,3})-/);
+ return match ? match[1] : 'en-US';
+}
+
/**
* Azure TTS implementation (direct API call with SSML)
*/
@@ -211,12 +224,13 @@ async function generateAzureTTS(
text: string,
): Promise {
const baseUrl = config.baseUrl || TTS_PROVIDERS['azure-tts'].defaultBaseUrl;
+ const lang = getVoiceLanguage(config.voice);
// Build SSML
const rate = config.speed ? `${((config.speed - 1) * 100).toFixed(0)}%` : '0%';
const ssml = `
-
-
+
+
${escapeXml(text)}
@@ -243,6 +257,48 @@ async function generateAzureTTS(
};
}
+/**
+ * Azure AI Foundry TTS — multi-service Cognitive Services endpoint.
+ * URL format: https://{resource}.cognitiveservices.azure.com/cognitiveservices/v1
+ * Supports standard Neural and premium DragonHD voices (e.g. "en-US-Ava:DragonHDLatestNeural").
+ */
+async function generateAzureFoundryTTS(
+ config: TTSModelConfig,
+ text: string,
+): Promise {
+ const baseUrl = config.baseUrl || TTS_PROVIDERS['azure-foundry-tts'].defaultBaseUrl;
+ const lang = getVoiceLanguage(config.voice);
+
+ const rate = config.speed ? `${((config.speed - 1) * 100).toFixed(0)}%` : '0%';
+ const ssml = `
+
+
+ ${escapeXml(text)}
+
+
+ `.trim();
+
+ const response = await fetch(`${baseUrl}/cognitiveservices/v1`, {
+ method: 'POST',
+ headers: {
+ 'Ocp-Apim-Subscription-Key': config.apiKey!,
+ 'Content-Type': 'application/ssml+xml; charset=utf-8',
+ 'X-Microsoft-OutputFormat': 'audio-16khz-128kbitrate-mono-mp3',
+ },
+ body: ssml,
+ });
+
+ if (!response.ok) {
+ throw new Error(`Azure AI Foundry TTS error: ${response.statusText}`);
+ }
+
+ const arrayBuffer = await response.arrayBuffer();
+ return {
+ audio: new Uint8Array(arrayBuffer),
+ format: 'mp3',
+ };
+}
+
/**
* GLM TTS implementation (GLM API)
*/
diff --git a/lib/audio/types.ts b/lib/audio/types.ts
index 0c3c91792..032d0fded 100644
--- a/lib/audio/types.ts
+++ b/lib/audio/types.ts
@@ -81,6 +81,7 @@
export type TTSProviderId =
| 'openai-tts'
| 'azure-tts'
+ | 'azure-foundry-tts'
| 'glm-tts'
| 'qwen-tts'
| 'doubao-tts'
diff --git a/lib/i18n/settings.ts b/lib/i18n/settings.ts
index 356fea554..0ce8efe5c 100644
--- a/lib/i18n/settings.ts
+++ b/lib/i18n/settings.ts
@@ -224,6 +224,7 @@ export const settingsZhCN = {
// Audio provider names
providerOpenAITTS: 'OpenAI TTS (gpt-4o-mini-tts)',
providerAzureTTS: 'Azure TTS',
+ providerAzureFoundryTTS: 'Azure AI Foundry TTS',
providerGLMTTS: 'GLM TTS',
providerQwenTTS: 'Qwen TTS(阿里云百炼)',
providerDoubaoTTS: '豆包 TTS 2.0(火山引擎)',
@@ -822,6 +823,7 @@ export const settingsEnUS = {
// Audio provider names
providerOpenAITTS: 'OpenAI TTS (gpt-4o-mini-tts)',
providerAzureTTS: 'Azure TTS',
+ providerAzureFoundryTTS: 'Azure AI Foundry TTS',
providerGLMTTS: 'GLM TTS',
providerQwenTTS: 'Qwen TTS (Alibaba Cloud Bailian)',
providerDoubaoTTS: 'Doubao TTS 2.0 (Volcengine)',
diff --git a/lib/server/provider-config.ts b/lib/server/provider-config.ts
index 27afa1411..8fc8ba2ad 100644
--- a/lib/server/provider-config.ts
+++ b/lib/server/provider-config.ts
@@ -54,6 +54,7 @@ const LLM_ENV_MAP: Record = {
const TTS_ENV_MAP: Record = {
TTS_OPENAI: 'openai-tts',
TTS_AZURE: 'azure-tts',
+ TTS_AZURE_FOUNDRY: 'azure-foundry-tts',
TTS_GLM: 'glm-tts',
TTS_QWEN: 'qwen-tts',
TTS_DOUBAO: 'doubao-tts',
diff --git a/lib/store/settings.ts b/lib/store/settings.ts
index 4b088bbc6..dcc918341 100644
--- a/lib/store/settings.ts
+++ b/lib/store/settings.ts
@@ -285,6 +285,7 @@ const getDefaultAudioConfig = () => ({
ttsProvidersConfig: {
'openai-tts': { apiKey: '', baseUrl: '', enabled: true },
'azure-tts': { apiKey: '', baseUrl: '', enabled: false },
+ 'azure-foundry-tts': { apiKey: '', baseUrl: '', enabled: false },
'glm-tts': { apiKey: '', baseUrl: '', enabled: false },
'qwen-tts': { apiKey: '', baseUrl: '', enabled: false },
'doubao-tts': { apiKey: '', baseUrl: '', enabled: false },
From d00bb96cf4513f76b2ef5e6368149a4ff8726b9f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miralles=20Sol=C3=A9?= <46744682B@gcb.intranet.gencat.cat>
Date: Sat, 4 Apr 2026 12:01:30 +0200
Subject: [PATCH 4/8] fix(tts): add azure-foundry-tts label to
getTTSProviderName in all components
The provider name mapping in audio-settings.tsx, settings/index.tsx and
media-popover.tsx was missing the 'azure-foundry-tts' entry, causing the
sidebar label to appear blank.
Co-Authored-By: Claude Sonnet 4.6
---
components/generation/media-popover.tsx | 1 +
components/settings/audio-settings.tsx | 1 +
components/settings/index.tsx | 1 +
3 files changed, 3 insertions(+)
diff --git a/components/generation/media-popover.tsx b/components/generation/media-popover.tsx
index a09a32432..9f4c98cb2 100644
--- a/components/generation/media-popover.tsx
+++ b/components/generation/media-popover.tsx
@@ -86,6 +86,7 @@ function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => strin
const names: Record = {
'openai-tts': t('settings.providerOpenAITTS'),
'azure-tts': t('settings.providerAzureTTS'),
+ 'azure-foundry-tts': t('settings.providerAzureFoundryTTS'),
'glm-tts': t('settings.providerGLMTTS'),
'qwen-tts': t('settings.providerQwenTTS'),
'doubao-tts': t('settings.providerDoubaoTTS'),
diff --git a/components/settings/audio-settings.tsx b/components/settings/audio-settings.tsx
index d88590ac0..e4490c4a7 100644
--- a/components/settings/audio-settings.tsx
+++ b/components/settings/audio-settings.tsx
@@ -35,6 +35,7 @@ function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => strin
const names: Record = {
'openai-tts': t('settings.providerOpenAITTS'),
'azure-tts': t('settings.providerAzureTTS'),
+ 'azure-foundry-tts': t('settings.providerAzureFoundryTTS'),
'glm-tts': t('settings.providerGLMTTS'),
'qwen-tts': t('settings.providerQwenTTS'),
'doubao-tts': t('settings.providerDoubaoTTS'),
diff --git a/components/settings/index.tsx b/components/settings/index.tsx
index 5f122e2e5..03db9a128 100644
--- a/components/settings/index.tsx
+++ b/components/settings/index.tsx
@@ -119,6 +119,7 @@ function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => strin
const names: Record = {
'openai-tts': t('settings.providerOpenAITTS'),
'azure-tts': t('settings.providerAzureTTS'),
+ 'azure-foundry-tts': t('settings.providerAzureFoundryTTS'),
'glm-tts': t('settings.providerGLMTTS'),
'qwen-tts': t('settings.providerQwenTTS'),
'doubao-tts': t('settings.providerDoubaoTTS'),
From e6989cac8be5565de5875035b227cb19d456270e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miralles=20Sol=C3=A9?= <46744682B@gcb.intranet.gencat.cat>
Date: Sat, 4 Apr 2026 12:09:35 +0200
Subject: [PATCH 5/8] feat(tts): add voice selector to settings panel + expand
azure-foundry-tts voices
- Add voice Select dropdown to audio-settings.tsx for all providers with
voices defined in constants (excludes azure-tts which uses its own
locale-filter + big JSON approach)
- Add Multilingual Neural voices: AvaMultilingual, AndrewMultilingual,
AdamMultilingual
- Add Adam:DragonHDLatestNeural HD voice
- Voices list now totals 20 entries covering en-US, zh-CN, ca-ES, es-ES
Co-Authored-By: Claude Sonnet 4.6
---
components/settings/audio-settings.tsx | 26 ++++++++++++++++++++++++++
lib/audio/constants.ts | 5 +++++
2 files changed, 31 insertions(+)
diff --git a/components/settings/audio-settings.tsx b/components/settings/audio-settings.tsx
index e4490c4a7..b91a8e2e9 100644
--- a/components/settings/audio-settings.tsx
+++ b/components/settings/audio-settings.tsx
@@ -500,6 +500,32 @@ export function AudioSettings({ onSave }: AudioSettingsProps = {}) {
>
)}
+
+ {/* Voice selector — shown for providers with voices defined in constants
+ (azure-tts uses the separate locale-filter + big JSON, so excluded) */}
+ {ttsProviderId !== 'azure-tts' && getTTSVoices(ttsProviderId).length > 0 && (
+
+
+
+
+ )}
diff --git a/lib/audio/constants.ts b/lib/audio/constants.ts
index e79d329cf..956e54ddc 100644
--- a/lib/audio/constants.ts
+++ b/lib/audio/constants.ts
@@ -223,6 +223,10 @@ export const TTS_PROVIDERS: Record = {
{ id: 'en-US-AndrewNeural', name: 'Andrew', language: 'en-US', gender: 'male' as const },
{ id: 'en-US-EmmaNeural', name: 'Emma', language: 'en-US', gender: 'female' as const },
{ id: 'en-US-BrianNeural', name: 'Brian', language: 'en-US', gender: 'male' as const },
+ // Multilingual Neural voices (support multiple languages dynamically)
+ { id: 'en-US-AvaMultilingualNeural', name: 'Ava Multilingual', language: 'en-US', gender: 'female' as const },
+ { id: 'en-US-AndrewMultilingualNeural', name: 'Andrew Multilingual', language: 'en-US', gender: 'male' as const },
+ { id: 'en-US-AdamMultilingualNeural', name: 'Adam Multilingual', language: 'en-US', gender: 'male' as const },
{ id: 'zh-CN-XiaoxiaoNeural', name: '晓晓 (女)', language: 'zh-CN', gender: 'female' as const },
{ id: 'zh-CN-YunxiNeural', name: '云希 (男)', language: 'zh-CN', gender: 'male' as const },
{ id: 'ca-ES-AlbaNeural', name: 'Alba (Català)', language: 'ca-ES', gender: 'female' as const },
@@ -232,6 +236,7 @@ export const TTS_PROVIDERS: Record = {
// HD Neural voices (Azure AI Foundry — premium DragonHD quality)
{ id: 'en-US-Ava:DragonHDLatestNeural', name: 'Ava HD', language: 'en-US', gender: 'female' as const, description: 'DragonHD' },
{ id: 'en-US-Andrew:DragonHDLatestNeural', name: 'Andrew HD', language: 'en-US', gender: 'male' as const, description: 'DragonHD' },
+ { id: 'en-US-Adam:DragonHDLatestNeural', name: 'Adam HD', language: 'en-US', gender: 'male' as const, description: 'DragonHD' },
{ id: 'en-US-Emma:DragonHDLatestNeural', name: 'Emma HD', language: 'en-US', gender: 'female' as const, description: 'DragonHD' },
{ id: 'en-US-Brian:DragonHDLatestNeural', name: 'Brian HD', language: 'en-US', gender: 'male' as const, description: 'DragonHD' },
{ id: 'ca-ES-Alba:DragonHDLatestNeural', name: 'Alba HD (Català)', language: 'ca-ES', gender: 'female' as const, description: 'DragonHD' },
From dd3acbb1d6e2904a2a8cac7cad64a5ba24fda4ac Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miralles=20Sol=C3=A9?= <46744682B@gcb.intranet.gencat.cat>
Date: Sat, 4 Apr 2026 12:14:17 +0200
Subject: [PATCH 6/8] feat(tts): add voice selector to TTSSettings panel +
azure-foundry-tts URL preview
- Add voice Select dropdown to tts-settings.tsx for all providers with
voices in constants (syncs to global store when provider is active,
uses local state when testing a non-active provider)
- Add 'azure-foundry-tts' to URL preview switch (/cognitiveservices/v1)
Co-Authored-By: Claude Sonnet 4.6
---
components/settings/tts-settings.tsx | 51 ++++++++++++++++++++++++++--
1 file changed, 49 insertions(+), 2 deletions(-)
diff --git a/components/settings/tts-settings.tsx b/components/settings/tts-settings.tsx
index 4a35a4f36..b8f16a6ba 100644
--- a/components/settings/tts-settings.tsx
+++ b/components/settings/tts-settings.tsx
@@ -4,9 +4,16 @@ import { useState, useEffect } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
-import { TTS_PROVIDERS, DEFAULT_TTS_VOICES } from '@/lib/audio/constants';
+import { TTS_PROVIDERS, DEFAULT_TTS_VOICES, getTTSVoices } from '@/lib/audio/constants';
import type { TTSProviderId } from '@/lib/audio/types';
import { Volume2, Loader2, CheckCircle2, XCircle, Eye, EyeOff } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -26,15 +33,29 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) {
const ttsSpeed = useSettingsStore((state) => state.ttsSpeed);
const ttsProvidersConfig = useSettingsStore((state) => state.ttsProvidersConfig);
const setTTSProviderConfig = useSettingsStore((state) => state.setTTSProviderConfig);
+ const setTTSVoice = useSettingsStore((state) => state.setTTSVoice);
const activeProviderId = useSettingsStore((state) => state.ttsProviderId);
// When testing a non-active provider, use that provider's default voice
// instead of the active provider's voice (which may be incompatible).
- const effectiveVoice =
+ const defaultEffectiveVoice =
selectedProviderId === activeProviderId
? ttsVoice
: DEFAULT_TTS_VOICES[selectedProviderId] || 'default';
+ // Local voice state for providers with voices defined in constants.
+ // Syncs back to the global store when this is the active provider.
+ const [selectedVoice, setSelectedVoice] = useState(defaultEffectiveVoice);
+
+ const effectiveVoice = getTTSVoices(selectedProviderId).length > 0 ? selectedVoice : defaultEffectiveVoice;
+
+ const handleVoiceChange = (voice: string) => {
+ setSelectedVoice(voice);
+ if (selectedProviderId === activeProviderId) {
+ setTTSVoice(voice);
+ }
+ };
+
const ttsProvider = TTS_PROVIDERS[selectedProviderId] ?? TTS_PROVIDERS['openai-tts'];
const isServerConfigured = !!ttsProvidersConfig[selectedProviderId]?.isServerConfigured;
@@ -72,6 +93,12 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) {
setShowApiKey(false);
setTestStatus('idle');
setTestMessage('');
+ setSelectedVoice(
+ selectedProviderId === activeProviderId
+ ? ttsVoice
+ : DEFAULT_TTS_VOICES[selectedProviderId] || 'default',
+ );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedProviderId, stopPreview]);
const handleTestTTS = async () => {
@@ -240,6 +267,7 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) {
endpointPath = '/audio/speech';
break;
case 'azure-tts':
+ case 'azure-foundry-tts':
endpointPath = '/cognitiveservices/v1';
break;
case 'qwen-tts':
@@ -262,6 +290,25 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) {
>
)}
+ {/* Voice selector — shown for providers with voices defined in constants */}
+ {getTTSVoices(selectedProviderId).length > 0 && (
+
+
+
+
+ )}
+
{/* Test TTS */}
From 3226b2ea5d74cbd0fc9d4771e9dd263f04d716a1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miralles=20Sol=C3=A9?= <46744682B@gcb.intranet.gencat.cat>
Date: Sat, 4 Apr 2026 12:16:57 +0200
Subject: [PATCH 7/8] fix(tts): improve azure-foundry-tts error messages and
URL validation
- Detect unconfigured {resource} placeholder and throw clear message
- Validate API key presence before making the request
- Include HTTP status code, response body and actual endpoint URL in errors
- Strip trailing slash from base URL before appending path
Co-Authored-By: Claude Sonnet 4.6
---
lib/audio/tts-providers.ts | 28 ++++++++++++++++++++++++----
1 file changed, 24 insertions(+), 4 deletions(-)
diff --git a/lib/audio/tts-providers.ts b/lib/audio/tts-providers.ts
index 25bbc88b8..9b688e57c 100644
--- a/lib/audio/tts-providers.ts
+++ b/lib/audio/tts-providers.ts
@@ -266,8 +266,23 @@ async function generateAzureFoundryTTS(
config: TTSModelConfig,
text: string,
): Promise {
- const baseUrl = config.baseUrl || TTS_PROVIDERS['azure-foundry-tts'].defaultBaseUrl;
+ const baseUrl = (config.baseUrl || '').trim() || TTS_PROVIDERS['azure-foundry-tts'].defaultBaseUrl!;
+
+ // Guard: detect unconfigured placeholder URL
+ if (baseUrl.includes('{resource}')) {
+ throw new Error(
+ 'Azure AI Foundry TTS: Base URL not configured. ' +
+ 'Replace {resource} with your actual Azure resource name, e.g. ' +
+ 'https://my-resource.cognitiveservices.azure.com',
+ );
+ }
+
+ if (!config.apiKey?.trim()) {
+ throw new Error('Azure AI Foundry TTS: API Key is required.');
+ }
+
const lang = getVoiceLanguage(config.voice);
+ const endpoint = `${baseUrl.replace(/\/$/, '')}/cognitiveservices/v1`;
const rate = config.speed ? `${((config.speed - 1) * 100).toFixed(0)}%` : '0%';
const ssml = `
@@ -278,10 +293,10 @@ async function generateAzureFoundryTTS(
`.trim();
- const response = await fetch(`${baseUrl}/cognitiveservices/v1`, {
+ const response = await fetch(endpoint, {
method: 'POST',
headers: {
- 'Ocp-Apim-Subscription-Key': config.apiKey!,
+ 'Ocp-Apim-Subscription-Key': config.apiKey,
'Content-Type': 'application/ssml+xml; charset=utf-8',
'X-Microsoft-OutputFormat': 'audio-16khz-128kbitrate-mono-mp3',
},
@@ -289,7 +304,12 @@ async function generateAzureFoundryTTS(
});
if (!response.ok) {
- throw new Error(`Azure AI Foundry TTS error: ${response.statusText}`);
+ const body = await response.text().catch(() => '');
+ throw new Error(
+ `Azure AI Foundry TTS error ${response.status} (${response.statusText})` +
+ (body ? `: ${body.slice(0, 200)}` : '') +
+ ` — endpoint: ${endpoint}`,
+ );
}
const arrayBuffer = await response.arrayBuffer();
From 5a2d5bf6c2e7f785965fdbc56203fe207f4be40e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miralles=20Sol=C3=A9?= <46744682B@gcb.intranet.gencat.cat>
Date: Sat, 4 Apr 2026 12:27:35 +0200
Subject: [PATCH 8/8] fix(tts): correct azure-foundry-tts endpoint path to
/tts/cognitiveservices/v1
Custom domain endpoints (*.cognitiveservices.azure.com) require the /tts/
prefix before /cognitiveservices/v1, unlike regional endpoints which use
just /cognitiveservices/v1 directly on the TTS subdomain.
Ref: https://learn.microsoft.com/azure/ai-services/speech-service/speech-services-private-link
Standard: {region}.tts.speech.microsoft.com/cognitiveservices/v1
Custom: {custom}.cognitiveservices.azure.com/tts/cognitiveservices/v1
Also update URL preview in tts-settings.tsx to show the correct path.
Co-Authored-By: Claude Sonnet 4.6
---
components/settings/tts-settings.tsx | 4 +++-
lib/audio/tts-providers.ts | 6 +++++-
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/components/settings/tts-settings.tsx b/components/settings/tts-settings.tsx
index b8f16a6ba..dc36e5a80 100644
--- a/components/settings/tts-settings.tsx
+++ b/components/settings/tts-settings.tsx
@@ -267,9 +267,11 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) {
endpointPath = '/audio/speech';
break;
case 'azure-tts':
- case 'azure-foundry-tts':
endpointPath = '/cognitiveservices/v1';
break;
+ case 'azure-foundry-tts':
+ endpointPath = '/tts/cognitiveservices/v1';
+ break;
case 'qwen-tts':
endpointPath = '/services/aigc/multimodal-generation/generation';
break;
diff --git a/lib/audio/tts-providers.ts b/lib/audio/tts-providers.ts
index 9b688e57c..066c8180f 100644
--- a/lib/audio/tts-providers.ts
+++ b/lib/audio/tts-providers.ts
@@ -282,7 +282,11 @@ async function generateAzureFoundryTTS(
}
const lang = getVoiceLanguage(config.voice);
- const endpoint = `${baseUrl.replace(/\/$/, '')}/cognitiveservices/v1`;
+ // Custom domain endpoints require the /tts/ prefix before /cognitiveservices/v1.
+ // Regional endpoints (azure-tts) use: {region}.tts.speech.microsoft.com/cognitiveservices/v1
+ // Custom domain endpoints (azure-foundry-tts) use: {custom}.cognitiveservices.azure.com/tts/cognitiveservices/v1
+ // Reference: https://learn.microsoft.com/azure/ai-services/speech-service/speech-services-private-link
+ const endpoint = `${baseUrl.replace(/\/$/, '')}/tts/cognitiveservices/v1`;
const rate = config.speed ? `${((config.speed - 1) * 100).toFixed(0)}%` : '0%';
const ssml = `