* feat(tts/stt): add API key support for OpenAI-compatible custom providers ## Problem Custom (OpenAI-compatible) TTS/STT provider in Voice Settings has no way to pass an API key or bearer token. Many self-hosted or third-party compatible servers require authentication, making them unreachable from OpenChamber. The server-side TTS route already accepts an `apiKey` parameter, but the frontend never sends it. The STT route hardcodes `'not-required'`. ## Implementation - Add `openaiCompatibleApiKey` to Zustand config store, persisted to localStorage - Add API Key input field in VoiceSettings.tsx under the custom provider section - Wire `openaiCompatibleApiKey` through useServerTTS to the TTS backend - Add `apiKey` field to AudioStreamConfig for STT, forwarded as X-API-Key header - Update server STT route to accept and forward X-API-Key to transcribeAudio - Update stt.js to use client-provided apiKey before falling back to env var ## Files changed - packages/ui/src/stores/useConfigStore.ts - packages/ui/src/components/sections/openchamber/VoiceSettings.tsx - packages/ui/src/hooks/useServerTTS.ts - packages/ui/src/hooks/useBrowserVoice.ts - packages/ui/src/lib/voice/audioStreamService.ts - packages/web/server/lib/tts/routes.js - packages/web/server/lib/tts/stt.js * feat(tts/stt): add separate API key support for custom TTS and STT providers ## Problem Custom (OpenAI-compatible) TTS and STT providers in Voice Settings have no way to pass API keys. Many self-hosted or third-party compatible servers require authentication, making them unreachable from OpenChamber Desktop (Electron). ## Implementation - Add `openaiCompatibleApiKey` for TTS (persisted to localStorage, passed in JSON body) - Add `sttApiKey` for STT (persisted to localStorage, passed via Authorization: Bearer header) - Two independent keys: TTS and STT are configured separately - STT authentication follows OpenAI standard (Authorization: Bearer <token>) - TTS authentication follows existing pattern (apiKey in JSON body) - Backend STT route extracts bearer token from Authorization header - Backend STT service prefers client-provided key over OPENAI_API_KEY env var ## Fixes - Fixed P1: ConfigStore interface now declares setOpenaiCompatibleApiKey setter - STT API key is only forwarded when sttProvider === 'server' (not leaked to other providers) ## Files changed (7) - packages/ui/src/stores/useConfigStore.ts - packages/ui/src/components/sections/openchamber/VoiceSettings.tsx - packages/ui/src/hooks/useServerTTS.ts - packages/ui/src/hooks/useBrowserVoice.ts - packages/ui/src/lib/voice/audioStreamService.ts - packages/web/server/lib/tts/routes.js - packages/web/server/lib/tts/stt.js * fix: refresh server STT callback when API key changes --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
77 lines
2.4 KiB
JavaScript
77 lines
2.4 KiB
JavaScript
/**
|
|
* Server-side Speech-to-Text Service
|
|
*
|
|
* Proxies audio to any OpenAI-compatible transcription endpoint
|
|
* (e.g. faster-whisper, whisper.cpp) using the OpenAI Node SDK.
|
|
*/
|
|
|
|
import OpenAI, { toFile } from 'openai';
|
|
import { normalizeCustomOpenAIBaseURL } from './base-url.js';
|
|
|
|
/**
|
|
* Transcribe an audio buffer via an OpenAI-compatible /v1/audio/transcriptions endpoint.
|
|
*
|
|
* @param {object} opts
|
|
* @param {Buffer} opts.audioBuffer - Raw audio bytes
|
|
* @param {string} opts.mimeType - MIME type of the audio (e.g. 'audio/webm')
|
|
* @param {string} opts.model - Model name accepted by the remote server
|
|
* @param {string} [opts.baseURL] - Base URL of the compatible server (including /v1)
|
|
* @param {string} [opts.apiKey] - Optional API key for the compatible server
|
|
* @param {string} [opts.language] - Optional BCP-47 language hint (e.g. 'en')
|
|
* @returns {Promise<string>} Transcribed text
|
|
*/
|
|
export async function transcribeAudio({ audioBuffer, mimeType, model, baseURL, apiKey, language }) {
|
|
const normalizedBaseURLResult = normalizeCustomOpenAIBaseURL(baseURL);
|
|
if (normalizedBaseURLResult.error) {
|
|
throw new Error(normalizedBaseURLResult.error);
|
|
}
|
|
|
|
const normalizedBaseURL = normalizedBaseURLResult.value;
|
|
if (!normalizedBaseURL) {
|
|
throw new Error('Custom server URL is required');
|
|
}
|
|
|
|
const clientOpts = {
|
|
apiKey: apiKey || process.env.OPENAI_API_KEY || 'not-required',
|
|
};
|
|
clientOpts.baseURL = normalizedBaseURL;
|
|
|
|
const client = new OpenAI(clientOpts);
|
|
|
|
// Derive a sensible filename extension from the MIME type so the server
|
|
// can infer the codec when it isn't explicit in the stream header.
|
|
const ext = mimeTypeToExt(mimeType);
|
|
const filename = `audio.${ext}`;
|
|
|
|
const file = await toFile(audioBuffer, filename, { type: mimeType });
|
|
|
|
const result = await client.audio.transcriptions.create({
|
|
file,
|
|
model,
|
|
response_format: 'json',
|
|
...(language ? { language } : {}),
|
|
});
|
|
|
|
return result.text ?? '';
|
|
}
|
|
|
|
/**
|
|
* Map a MIME type to a file extension understood by Whisper servers.
|
|
* @param {string} mimeType
|
|
* @returns {string}
|
|
*/
|
|
function mimeTypeToExt(mimeType) {
|
|
const type = (mimeType || '').split(';')[0].trim().toLowerCase();
|
|
const map = {
|
|
'audio/webm': 'webm',
|
|
'audio/ogg': 'ogg',
|
|
'audio/wav': 'wav',
|
|
'audio/wave': 'wav',
|
|
'audio/mpeg': 'mp3',
|
|
'audio/mp4': 'mp4',
|
|
'audio/mp3': 'mp3',
|
|
'audio/flac': 'flac',
|
|
};
|
|
return map[type] ?? 'webm';
|
|
}
|