feat(tts): add multi-provider TTS support with Deepinfra and custom OpenAI-compatible endpoints
Add comprehensive multi-provider TTS support enabling users to choose between OpenAI, Deepinfra, and custom OpenAI-compatible endpoints. Implement provider-specific voice management with automatic voice restoration per provider-model combination, and migrate package manager to pnpm for improved dependency handling. Key changes: - Add TTS provider selection (OpenAI, Deepinfra, custom-openai) in settings UI - Implement provider-specific model and voice lists with dynamic fetching - Add voice persistence per provider-model combination in savedVoices - Support Deepinfra models: Kokoro-82M, Orpheus-3B, Sesame-1B with their voice libraries - Migrate to pnpm with frozen lockfile for reproducible builds - Update Docker configuration to use pnpm and Deepinfra API defaults - Add migration logic for existing users to infer provider from stored baseUrl - Update test helpers and Playwright configuration for Deepinfra API - Add example docker-compose.yml with Kokoro-FastAPI integration BREAKING CHANGE: Voice selection is now provider-model specific. Previously saved voices will be migrated to the new savedVoices structure, but users may need to reselect voices if switching providers.
This commit is contained in:
parent
e56736fda8
commit
b21bdb3a21
19 changed files with 6815 additions and 9879 deletions
4
.github/workflows/playwright.yml
vendored
4
.github/workflows/playwright.yml
vendored
|
|
@ -24,8 +24,8 @@ jobs:
|
|||
- name: Run Playwright tests
|
||||
env:
|
||||
NEXT_PUBLIC_NODE_ENV: test
|
||||
API_BASE: https://tts.richardr.dev/v1
|
||||
API_KEY: not-needed
|
||||
API_BASE: https://api.deepinfra.com/v1/openai
|
||||
API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
run: npx playwright test --reporter=list,github,html
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
|
|
|
|||
3
.npmrc
Normal file
3
.npmrc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# pnpm configuration
|
||||
auto-install-peers=true
|
||||
strict-peer-dependencies=false
|
||||
11
Dockerfile
11
Dockerfile
|
|
@ -4,23 +4,26 @@ FROM node:current-alpine
|
|||
# Add ffmpeg and libreoffice using Alpine package manager
|
||||
RUN apk add --no-cache ffmpeg libreoffice-writer
|
||||
|
||||
# Install pnpm globally
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy project files
|
||||
COPY . .
|
||||
|
||||
# Build the Next.js application
|
||||
RUN npm run build
|
||||
RUN pnpm run build
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 3003
|
||||
|
||||
# Start the application
|
||||
CMD ["npm", "start"]
|
||||
CMD ["pnpm", "start"]
|
||||
109
README.md
109
README.md
|
|
@ -9,75 +9,83 @@
|
|||
|
||||
# OpenReader WebUI 📄🔊
|
||||
|
||||
OpenReader WebUI is a document reader with Text-to-Speech capabilities, offering a TTS read along experience with narration for EPUB, PDF, TXT, MD, and DOCX documents. It can use any OpenAI compatible TTS endpoint, including [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) and [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI)
|
||||
OpenReader WebUI is a document reader with Text-to-Speech capabilities, offering a TTS read along experience with narration for EPUB, PDF, TXT, MD, and DOCX documents. It supports multiple TTS providers including OpenAI, Deepinfra, and custom OpenAI-compatible endpoints like [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) and [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI)
|
||||
|
||||
- 🎯 **TTS API Integration**:
|
||||
- Compatible with OpenAI text to speech API and GPT-4o Mini TTS, Kokoro-FastAPI TTS, Orpheus FastAPI or any other compatible service
|
||||
- Support for TTS models (tts-1, tts-1-hd, gpt-4o-mini-tts, kokoro, and custom)
|
||||
- 🎯 **Multi-Provider TTS Support**:
|
||||
- **OpenAI**: tts-1, tts-1-hd, gpt-4o-mini-tts models with voices (alloy, echo, fable, onyx, nova, shimmer)
|
||||
- **Deepinfra**: Kokoro-82M, Orpheus-3B, Sesame-1B models with extensive voice libraries
|
||||
- **Custom OpenAI-Compatible**: Any OpenAI-compatible endpoint with custom voice sets
|
||||
- Provider-specific voice management with automatic voice restoration per provider-model combination
|
||||
- 💾 **Local-First Architecture**: Uses IndexedDB browser storage for documents
|
||||
- 🛜 **Optional Server-side documents**: Manually upload documents to the next backend for all users to download
|
||||
- 📖 **Read Along Experience**: Follow along with highlighted text as the TTS narrates
|
||||
- 📄 **Document formats**: EPUB, PDF, TXT, MD, DOCX (with libreoffice installed)
|
||||
- 🎧 **Audiobook Creation**: Create and export audiobooks from PDF and ePub files **(in m4b format with ffmpeg and aac TTS output)**
|
||||
- 📲 **Mobile Support**: Works on mobile devices, and can be added as a PWA web app
|
||||
- 🎨 **Customizable Experience**:
|
||||
- 🔑 Set TTS API base URL (and optional API key)
|
||||
- 🎯 Set model-specific instructions for GPT-4o Mini TTS
|
||||
- 🏎️ Adjustable playback speed
|
||||
- 📐 Customize PDF text extraction margins
|
||||
- 🗣️ Multiple voice options (checks `/v1/audio/voices` endpoint)
|
||||
- 🔑 Select TTS provider (OpenAI, Deepinfra, or Custom OpenAI-compatible)
|
||||
- 🔐 Set TTS API base URL and optional API key
|
||||
- 🎨 Multiple app theme options
|
||||
- And more...
|
||||
|
||||
### 🛠️ Work in progress
|
||||
- [ ] **Native .docx support** (currently requires libreoffice)
|
||||
- [ ] **Support non-OpenAI TTS APIs**: ElevenLabs, etc.
|
||||
- [ ] **Accessibility Improvements**
|
||||
|
||||
## 🐳 Docker Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Recent version of Docker installed on your machine
|
||||
- A TTS API server (Kokoro-FastAPI, Orpheus-FastAPI, or OpenAI API)
|
||||
- A TTS API server (Kokoro-FastAPI, Orpheus-FastAPI, Deepinfra, OpenAI, etc.) running and accessible
|
||||
|
||||
```bash
|
||||
docker run --name openreader-webui \
|
||||
-p 3003:3003 \
|
||||
-v openreader_docstore:/app/docstore \
|
||||
ghcr.io/richardr1126/openreader-webui:latest
|
||||
```
|
||||
### 1. 🐳 Start the Docker container:
|
||||
```bash
|
||||
docker run --name openreader-webui \
|
||||
-p 3003:3003 \
|
||||
-v openreader_docstore:/app/docstore \
|
||||
ghcr.io/richardr1126/openreader-webui:latest
|
||||
```
|
||||
|
||||
(Optionally): Set the TTS `API_BASE` URL and/or `API_KEY` to be default for all devices
|
||||
```bash
|
||||
docker run --name openreader-webui \
|
||||
-e API_BASE=http://host.docker.internal:8880/v1 \
|
||||
-p 3003:3003 \
|
||||
-v openreader_docstore:/app/docstore \
|
||||
ghcr.io/richardr1126/openreader-webui:latest
|
||||
```
|
||||
(Optionally): Set the TTS `API_BASE` URL and/or `API_KEY` to be default for all devices
|
||||
```bash
|
||||
docker run --name openreader-webui \
|
||||
-e API_KEY=none \
|
||||
-e API_BASE=http://host.docker.internal:8880/v1 \
|
||||
-p 3003:3003 \
|
||||
-v openreader_docstore:/app/docstore \
|
||||
ghcr.io/richardr1126/openreader-webui:latest
|
||||
```
|
||||
|
||||
> Requesting audio from the TTS API happens on the Next.js server not the client. So the base URL for the TTS API should be accessible and relative to the Next.js server. If it is in a Docker you may need to use `host.docker.internal` to access the host machine, instead of `localhost`.
|
||||
> **Note:** Requesting audio from the TTS API happens on the Next.js server not the client. So the base URL for the TTS API should be accessible and relative to the Next.js server. If it is in a Docker you may need to use `host.docker.internal` to access the host machine, instead of `localhost`.
|
||||
|
||||
Visit [http://localhost:3003](http://localhost:3003) to run the app and set your settings.
|
||||
Visit [http://localhost:3003](http://localhost:3003) to run the app and set your settings.
|
||||
|
||||
> **Note:** The `openreader_docstore` volume is used to store server-side documents. You can mount a local directory instead. Or remove it if you don't need server-side documents.
|
||||
> **Note:** The `openreader_docstore` volume is used to store server-side documents. You can mount a local directory instead. Or remove it if you don't need server-side documents.
|
||||
|
||||
### ⬆️ Update Docker Image
|
||||
### 2. ⚙️ Configure the app settings in the UI:
|
||||
- Set the TTS Provider and Model in the Settings modal
|
||||
- Set the TTS API Base URL and API Key if needed (more secure to set in env vars)
|
||||
- Select your model's voice from the dropdown (voices try to be fetched from TTS Provider API)
|
||||
|
||||
### 3. ⬆️ Updating Docker Image
|
||||
```bash
|
||||
docker stop openreader-webui && \
|
||||
docker rm openreader-webui && \
|
||||
docker pull ghcr.io/richardr1126/openreader-webui:latest
|
||||
```
|
||||
|
||||
### Adding to a Docker Compose (i.e. with open-webui or Kokoro-FastAPI)
|
||||
### (Alternate) 🐳 Configuration with Docker Compose and Kokoro-FastAPI
|
||||
|
||||
> Note: This is an example of how to add OpenReader WebUI to a docker-compose file. You can add it to your existing docker-compose file or create a new one in this directory. Then run `docker-compose up --build` to start the services.
|
||||
A complete example docker-compose file with Kokoro-FastAPI and OpenReader WebUI is available in [`examples/docker-compose.yml`](examples/docker-compose.yml). You can download and use it:
|
||||
|
||||
```bash
|
||||
mkdir -p openreader-compose
|
||||
cd openreader-compose
|
||||
curl -O https://raw.githubusercontent.com/richardr1126/OpenReader-WebUI/main/examples/docker-compose.yml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Create or add to a `docker-compose.yml`:
|
||||
Or add OpenReader WebUI to your existing `docker-compose.yml`:
|
||||
```yaml
|
||||
volumes:
|
||||
docstore:
|
||||
|
||||
services:
|
||||
openreader-webui:
|
||||
container_name: openreader-webui
|
||||
|
|
@ -89,12 +97,15 @@ services:
|
|||
volumes:
|
||||
- docstore:/app/docstore
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
docstore:
|
||||
```
|
||||
|
||||
## Dev Installation
|
||||
|
||||
### Prerequisites
|
||||
- Node.js & npm (recommended: use [nvm](https://github.com/nvm-sh/nvm))
|
||||
- Node.js & npm or pnpm (recommended: use [nvm](https://github.com/nvm-sh/nvm) for Node.js)
|
||||
Optionally required for different features:
|
||||
- [FFmpeg](https://ffmpeg.org) (required for audiobook m4b creation only)
|
||||
- On Linux: `sudo apt install ffmpeg`
|
||||
|
|
@ -112,6 +123,13 @@ Optionally required for different features:
|
|||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
With pnpm (recommended):
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Or with npm:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
|
@ -124,11 +142,26 @@ Optionally required for different features:
|
|||
> Note: The base URL for the TTS API should be accessible and relative to the Next.js server
|
||||
|
||||
4. Start the development server:
|
||||
|
||||
With pnpm (recommended):
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Or with npm:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
or build and run the production server:
|
||||
|
||||
With pnpm:
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm start
|
||||
```
|
||||
|
||||
Or with npm:
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
|
|
@ -183,9 +216,9 @@ This project would not be possible without standing on the shoulders of these gi
|
|||
- [Headless UI](https://headlessui.com)
|
||||
- [@tailwindcss/typography](https://tailwindcss.com/docs/typography-plugin)
|
||||
- **TTS:** (tested on)
|
||||
- [Deepinfra API](https://deepinfra.com) (Kokoro-82M, Orpheus-3B, Sesame-1B)
|
||||
- [Kokoro FastAPI TTS](https://github.com/remsky/Kokoro-FastAPI/tree/v0.0.5post1-stable)
|
||||
- [Orpheus FastAPI TTS](https://github.com/Lex-au/Orpheus-FastAPI)
|
||||
- [OpenAI API](https://platform.openai.com/docs/api-reference/text-to-speech)
|
||||
- **NLP:** [compromise](https://github.com/spencermountain/compromise) NLP library for sentence splitting
|
||||
|
||||
## License
|
||||
|
|
|
|||
30
examples/docker-compose.yml
Normal file
30
examples/docker-compose.yml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
services:
|
||||
kokoro-tts:
|
||||
container_name: kokoro-tts
|
||||
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4
|
||||
ports:
|
||||
- "8880:8880"
|
||||
environment:
|
||||
# ONNX Optimization Settings for vectorized operations
|
||||
- ONNX_NUM_THREADS=8 # Maximize core usage for vectorized ops
|
||||
- ONNX_INTER_OP_THREADS=4 # Higher inter-op for parallel matrix operations
|
||||
- ONNX_EXECUTION_MODE=parallel
|
||||
- ONNX_OPTIMIZATION_LEVEL=all
|
||||
- ONNX_MEMORY_PATTERN=true
|
||||
- ONNX_ARENA_EXTEND_STRATEGY=kNextPowerOfTwo
|
||||
- API_LOG_LEVEL=DEBUG
|
||||
restart: unless-stopped
|
||||
|
||||
openreader-webui:
|
||||
container_name: openreader-webui
|
||||
image: ghcr.io/richardr1126/openreader-webui:latest
|
||||
environment:
|
||||
- API_BASE=http://host.docker.internal:8880/v1
|
||||
ports:
|
||||
- "3003:3003"
|
||||
volumes:
|
||||
- docstore:/app/docstore
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
docstore:
|
||||
9579
package-lock.json
generated
9579
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,8 @@
|
|||
"dev": "next dev --turbopack -p 3003",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3003",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"test": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.0",
|
||||
|
|
@ -16,6 +17,7 @@
|
|||
"@vercel/analytics": "^1.5.0",
|
||||
"compromise": "^14.14.4",
|
||||
"core-js": "^3.41.0",
|
||||
"epubjs": "^0.3.93",
|
||||
"howler": "^2.2.4",
|
||||
"lru-cache": "^11.0.2",
|
||||
"next": "^15.2.1",
|
||||
|
|
|
|||
6035
pnpm-lock.yaml
Normal file
6035
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -13,6 +13,7 @@ export async function POST(req: NextRequest) {
|
|||
// Get API credentials from headers or fall back to environment variables
|
||||
const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
|
||||
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
|
||||
const provider = req.headers.get('x-tts-provider') || 'openai';
|
||||
const { text, voice, speed, format, model, instructions } = await req.json();
|
||||
console.log('Received TTS request:', text, voice, speed, format, model);
|
||||
|
||||
|
|
@ -24,6 +25,10 @@ export async function POST(req: NextRequest) {
|
|||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Apply Deepinfra defaults if provider is deepinfra
|
||||
const finalModel = provider === 'deepinfra' && !model ? 'hexgrad/Kokoro-82M' : model;
|
||||
const finalVoice = provider === 'deepinfra' && !voice ? 'af_bella' : voice;
|
||||
|
||||
// Initialize OpenAI client with abort signal
|
||||
const openai = new OpenAI({
|
||||
apiKey: openApiKey,
|
||||
|
|
@ -32,15 +37,15 @@ export async function POST(req: NextRequest) {
|
|||
|
||||
// Request audio from OpenAI and pass along the abort signal
|
||||
const createParams: ExtendedSpeechParams = {
|
||||
model: model || 'tts-1',
|
||||
voice: voice as "alloy",
|
||||
model: finalModel || 'tts-1',
|
||||
voice: finalVoice as "alloy",
|
||||
input: text,
|
||||
speed: speed,
|
||||
response_format: format === 'aac' ? 'aac' : 'mp3',
|
||||
};
|
||||
|
||||
// Only add instructions if model is gpt-4o-mini-tts and instructions are provided
|
||||
if (model === 'gpt-4o-mini-tts' && instructions) {
|
||||
if (finalModel === 'gpt-4o-mini-tts' && instructions) {
|
||||
createParams.instructions = instructions;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +1,152 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const DEFAULT_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'];
|
||||
const OPENAI_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'];
|
||||
const GPT4O_MINI_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'];
|
||||
const CUSTOM_OPENAI_VOICES = ['af_sarah', 'af_bella', 'af_nicole', 'am_adam', 'am_michael', 'bf_emma', 'bf_isabella', 'bm_george', 'bm_lewis'];
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const KOKORO_VOICES = [
|
||||
'af_alloy', 'af_aoede', 'af_bella', 'af_heart', 'af_jessica', 'af_kore', 'af_nicole', 'af_nova',
|
||||
'af_river', 'af_sarah', 'af_sky', 'am_adam', 'am_echo', 'am_eric', 'am_fenrir', 'am_liam',
|
||||
'am_michael', 'am_onyx', 'am_puck', 'am_santa', 'bf_alice', 'bf_emma', 'bf_isabella', 'bf_lily',
|
||||
'bm_daniel', 'bm_fable', 'bm_george', 'bm_lewis', 'ef_dora', 'em_alex', 'em_santa', 'ff_siwis',
|
||||
'hf_alpha', 'hf_beta', 'hm_omega', 'hm_psi', 'if_sara', 'im_nicola', 'jf_alpha', 'jf_gongitsune',
|
||||
'jf_nezumi', 'jf_tebukuro', 'jm_kumo', 'pf_dora', 'pm_alex', 'pm_santa', 'zf_xiaobei', 'zf_xiaoni',
|
||||
'zf_xiaoxiao', 'zf_xiaoyi', 'zm_yunjian', 'zm_yunxi', 'zm_yunxia', 'zm_yunyang'
|
||||
];
|
||||
|
||||
const ORPHEUS_VOICES = ['tara', 'leah', 'jess', 'leo', 'dan', 'mia', 'zac'];
|
||||
|
||||
const SESAME_VOICES = ['conversational_a', 'conversational_b', 'read_speech_a', 'read_speech_b', 'read_speech_c', 'read_speech_d', 'none'];
|
||||
|
||||
function getDefaultVoices(provider: string, model: string): string[] {
|
||||
// For OpenAI provider
|
||||
if (provider === 'openai') {
|
||||
if (model === 'gpt-4o-mini-tts') {
|
||||
return GPT4O_MINI_VOICES;
|
||||
}
|
||||
return OPENAI_VOICES;
|
||||
}
|
||||
|
||||
// For Custom OpenAI-Like provider
|
||||
if (provider === 'custom-openai') {
|
||||
return CUSTOM_OPENAI_VOICES;
|
||||
}
|
||||
|
||||
// For Deepinfra provider - model-specific voices
|
||||
if (provider === 'deepinfra') {
|
||||
if (model === 'hexgrad/Kokoro-82M') {
|
||||
return KOKORO_VOICES;
|
||||
}
|
||||
if (model === 'canopylabs/orpheus-3b-0.1-ft') {
|
||||
return ORPHEUS_VOICES;
|
||||
}
|
||||
if (model === 'sesame/csm-1b') {
|
||||
return SESAME_VOICES;
|
||||
}
|
||||
// For ResembleAI/chatterbox and Zyphra models, return special values
|
||||
if (model === 'ResembleAI/chatterbox') {
|
||||
return ['None'];
|
||||
}
|
||||
if (model === 'Zyphra/Zonos-v0.1-hybrid' || model === 'Zyphra/Zonos-v0.1-transformer') {
|
||||
return ['random'];
|
||||
}
|
||||
// Default Deepinfra voices
|
||||
return CUSTOM_OPENAI_VOICES;
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return OPENAI_VOICES;
|
||||
}
|
||||
|
||||
async function fetchDeepinfraVoices(apiKey: string): Promise<string[]> {
|
||||
try {
|
||||
// Get API credentials from headers or fall back to environment variables
|
||||
const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
|
||||
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
|
||||
|
||||
// Request voices from OpenAI
|
||||
const response = await fetch(`${openApiBaseUrl}/audio/voices`, {
|
||||
const response = await fetch('https://api.deepinfra.com/v1/voices', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${openApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch voices');
|
||||
throw new Error('Failed to fetch Deepinfra voices');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json({ voices: data.voices || DEFAULT_VOICES });
|
||||
//console.log('Deepinfra voices response:', data);
|
||||
// Extract voice names from the response, excluding preset voices
|
||||
if (data.voices && Array.isArray(data.voices)) {
|
||||
return data.voices
|
||||
.filter((voice: { user_id?: string }) => voice.user_id !== 'preset')
|
||||
.map((voice: { name: string }) => voice.name);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching voices:', error);
|
||||
// Return default voices on error
|
||||
return NextResponse.json({ voices: DEFAULT_VOICES });
|
||||
console.error('Error fetching Deepinfra voices:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
|
||||
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
|
||||
const provider = req.headers.get('x-tts-provider') || 'openai';
|
||||
const model = req.headers.get('x-tts-model') || 'tts-1';
|
||||
|
||||
// For OpenAI provider, use default voices (no API call needed)
|
||||
if (provider === 'openai') {
|
||||
return NextResponse.json({ voices: getDefaultVoices(provider, model) });
|
||||
}
|
||||
|
||||
// For Deepinfra provider with specific models that need API fetching
|
||||
if (provider === 'deepinfra') {
|
||||
const needsApiFetch = model === 'ResembleAI/chatterbox' ||
|
||||
model === 'Zyphra/Zonos-v0.1-hybrid' ||
|
||||
model === 'Zyphra/Zonos-v0.1-transformer';
|
||||
|
||||
if (needsApiFetch) {
|
||||
const apiVoices = await fetchDeepinfraVoices(openApiKey);
|
||||
// Combine default voice with fetched voices
|
||||
const defaultVoice = getDefaultVoices(provider, model);
|
||||
if (apiVoices.length > 0) {
|
||||
return NextResponse.json({ voices: [...defaultVoice, ...apiVoices] });
|
||||
}
|
||||
}
|
||||
|
||||
// For other Deepinfra models, return static defaults
|
||||
return NextResponse.json({ voices: getDefaultVoices(provider, model) });
|
||||
}
|
||||
|
||||
// For Custom OpenAI-Like provider, try to fetch voices from custom endpoint
|
||||
if (provider === 'custom-openai') {
|
||||
try {
|
||||
const response = await fetch(`${openApiBaseUrl}/audio/voices`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${openApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.voices) {
|
||||
return NextResponse.json({ voices: data.voices });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.log('Custom endpoint does not support voices, using defaults');
|
||||
}
|
||||
|
||||
// Fallback to default voices if API call fails
|
||||
return NextResponse.json({ voices: getDefaultVoices(provider, model) });
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return NextResponse.json({ voices: getDefaultVoices(provider, model) });
|
||||
} catch (error) {
|
||||
console.error('Error in voices endpoint:', error);
|
||||
const provider = req.headers.get('x-tts-provider') || 'openai';
|
||||
const model = req.headers.get('x-tts-model') || 'tts-1';
|
||||
return NextResponse.json({ voices: getDefaultVoices(provider, model) });
|
||||
}
|
||||
}
|
||||
|
|
@ -41,12 +41,13 @@ export function SettingsModal() {
|
|||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { apiKey, baseUrl, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig();
|
||||
const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig();
|
||||
const { refreshPDFs, refreshEPUBs, clearPDFs, clearEPUBs } = useDocuments();
|
||||
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
||||
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
|
||||
const [localTTSModel, setLocalTTSModel] = useState(ttsModel);
|
||||
const [customModel, setCustomModel] = useState('');
|
||||
const [localTTSProvider, setLocalTTSProvider] = useState(ttsProvider);
|
||||
const [modelValue, setModelValue] = useState(ttsModel);
|
||||
const [customModelInput, setCustomModelInput] = useState('');
|
||||
const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -59,14 +60,59 @@ export function SettingsModal() {
|
|||
const [showClearServerConfirm, setShowClearServerConfirm] = useState(false);
|
||||
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
|
||||
|
||||
const ttsModels = useMemo(() => [
|
||||
{ id: 'tts-1', name: 'TTS-1' },
|
||||
{ id: 'tts-1-hd', name: '($$) TTS-1-HD' },
|
||||
{ id: 'gpt-4o-mini-tts', name: '($$$) GPT-4o Mini TTS' },
|
||||
{ id: 'kokoro', name: 'Kokoro' },
|
||||
{ id: 'custom', name: 'Custom Model' }
|
||||
const ttsProviders = useMemo(() => [
|
||||
{ id: 'custom-openai', name: 'Custom OpenAI-Like' },
|
||||
{ id: 'deepinfra', name: 'Deepinfra' },
|
||||
{ id: 'openai', name: 'OpenAI' }
|
||||
], []);
|
||||
|
||||
const ttsModels = useMemo(() => {
|
||||
switch (localTTSProvider) {
|
||||
case 'openai':
|
||||
return [
|
||||
{ id: 'tts-1', name: 'TTS-1' },
|
||||
{ id: 'tts-1-hd', name: 'TTS-1 HD' },
|
||||
{ id: 'gpt-4o-mini-tts', name: 'GPT-4o Mini TTS' }
|
||||
];
|
||||
case 'custom-openai':
|
||||
return [
|
||||
{ id: 'kokoro', name: 'Kokoro' },
|
||||
{ id: 'orpheus', name: 'Orpheus' },
|
||||
{ id: 'custom', name: 'Other' }
|
||||
];
|
||||
case 'deepinfra':
|
||||
return [
|
||||
{ id: 'hexgrad/Kokoro-82M', name: 'hexgrad/Kokoro-82M' },
|
||||
{ id: 'canopylabs/orpheus-3b-0.1-ft', name: 'canopylabs/orpheus-3b-0.1-ft' },
|
||||
{ id: 'sesame/csm-1b', name: 'sesame/csm-1b' },
|
||||
{ id: 'ResembleAI/chatterbox', name: 'ResembleAI/chatterbox' },
|
||||
{ id: 'Zyphra/Zonos-v0.1-hybrid', name: 'Zyphra/Zonos-v0.1-hybrid' },
|
||||
{ id: 'Zyphra/Zonos-v0.1-transformer', name: 'Zyphra/Zonos-v0.1-transformer' },
|
||||
{ id: 'custom', name: 'Other' }
|
||||
];
|
||||
default:
|
||||
return [
|
||||
{ id: 'tts-1', name: 'TTS-1' }
|
||||
];
|
||||
}
|
||||
}, [localTTSProvider]);
|
||||
|
||||
const supportsCustom = useMemo(() => localTTSProvider !== 'openai', [localTTSProvider]);
|
||||
|
||||
const selectedModelId = useMemo(
|
||||
() => {
|
||||
const isPreset = ttsModels.some(m => m.id === modelValue);
|
||||
if (isPreset) return modelValue;
|
||||
return supportsCustom ? 'custom' : (ttsModels[0]?.id ?? '');
|
||||
},
|
||||
[ttsModels, modelValue, supportsCustom]
|
||||
);
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() => selectedModelId !== 'custom' || (supportsCustom && customModelInput.trim().length > 0),
|
||||
[selectedModelId, supportsCustom, customModelInput]
|
||||
);
|
||||
|
||||
// set firstVisit on initial load
|
||||
const checkFirstVist = useCallback(async () => {
|
||||
if (!isDev) return;
|
||||
|
|
@ -81,14 +127,19 @@ export function SettingsModal() {
|
|||
checkFirstVist();
|
||||
setLocalApiKey(apiKey);
|
||||
setLocalBaseUrl(baseUrl);
|
||||
setLocalTTSModel(ttsModel);
|
||||
setLocalTTSProvider(ttsProvider);
|
||||
setModelValue(ttsModel);
|
||||
setLocalTTSInstructions(ttsInstructions);
|
||||
// Set custom model if current model is not in predefined list
|
||||
if (!ttsModels.some(m => m.id === ttsModel) && ttsModel !== '') {
|
||||
setCustomModel(ttsModel);
|
||||
setLocalTTSModel('custom');
|
||||
}, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, checkFirstVist]);
|
||||
|
||||
// Detect if current model is custom (not in presets) and mirror it in the input field
|
||||
useEffect(() => {
|
||||
if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') {
|
||||
setCustomModelInput(modelValue);
|
||||
} else {
|
||||
setCustomModelInput('');
|
||||
}
|
||||
}, [apiKey, baseUrl, ttsModel, ttsModels, ttsInstructions, checkFirstVist]);
|
||||
}, [modelValue, ttsModels]);
|
||||
|
||||
const handleSync = async () => {
|
||||
const controller = new AbortController();
|
||||
|
|
@ -177,13 +228,11 @@ export function SettingsModal() {
|
|||
setShowClearServerConfirm(false);
|
||||
};
|
||||
|
||||
const handleInputChange = (type: 'apiKey' | 'baseUrl' | 'ttsModel', value: string) => {
|
||||
const handleInputChange = (type: 'apiKey' | 'baseUrl', value: string) => {
|
||||
if (type === 'apiKey') {
|
||||
setLocalApiKey(value === '' ? '' : value);
|
||||
} else if (type === 'baseUrl') {
|
||||
setLocalBaseUrl(value === '' ? '' : value);
|
||||
} else if (type === 'ttsModel') {
|
||||
setLocalTTSModel(value === '' ? 'tts-1' : value);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -191,17 +240,19 @@ export function SettingsModal() {
|
|||
setIsOpen(false);
|
||||
setLocalApiKey(apiKey);
|
||||
setLocalBaseUrl(baseUrl);
|
||||
setLocalTTSModel(ttsModel);
|
||||
setLocalTTSProvider(ttsProvider);
|
||||
setModelValue(ttsModel);
|
||||
setLocalTTSInstructions(ttsInstructions);
|
||||
if (!ttsModels.some(m => m.id === ttsModel) && ttsModel !== '') {
|
||||
setCustomModel(ttsModel);
|
||||
setLocalTTSModel('custom');
|
||||
setCustomModelInput(ttsModel);
|
||||
} else {
|
||||
setCustomModelInput('');
|
||||
}
|
||||
}, [apiKey, baseUrl, ttsModel, ttsInstructions, ttsModels]);
|
||||
}, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, ttsModels]);
|
||||
|
||||
const tabs = [
|
||||
{ name: 'Appearance', icon: '✨' },
|
||||
{ name: 'API', icon: '🔑' },
|
||||
{ name: 'Appearance', icon: '✨' },
|
||||
{ name: 'Documents', icon: '📄' }
|
||||
];
|
||||
|
||||
|
|
@ -271,6 +322,233 @@ export function SettingsModal() {
|
|||
))}
|
||||
</TabList>
|
||||
<TabPanels className="mt-2">
|
||||
<TabPanel className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">TTS Provider</label>
|
||||
<Listbox
|
||||
value={ttsProviders.find(p => p.id === localTTSProvider) || ttsProviders[0]}
|
||||
onChange={(provider) => {
|
||||
setLocalTTSProvider(provider.id);
|
||||
// Set default model and base_url for each provider
|
||||
if (provider.id === 'openai') {
|
||||
setModelValue('tts-1');
|
||||
setLocalBaseUrl('https://api.openai.com/v1');
|
||||
} else if (provider.id === 'custom-openai') {
|
||||
setModelValue('kokoro');
|
||||
// Clear baseUrl for custom provider
|
||||
setLocalBaseUrl('');
|
||||
} else if (provider.id === 'deepinfra') {
|
||||
setModelValue('hexgrad/Kokoro-82M');
|
||||
setLocalBaseUrl('https://api.deepinfra.com/v1/openai');
|
||||
}
|
||||
setCustomModelInput('');
|
||||
}}
|
||||
>
|
||||
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent">
|
||||
<span className="block truncate">
|
||||
{ttsProviders.find(p => p.id === localTTSProvider)?.name || 'Select Provider'}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions className="absolute mt-1 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-50">
|
||||
{ttsProviders.map((provider) => (
|
||||
<ListboxOption
|
||||
key={provider.id}
|
||||
className={({ active }) =>
|
||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
|
||||
active ? 'bg-accent/10 text-accent' : 'text-foreground'
|
||||
}`
|
||||
}
|
||||
value={provider}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
{provider.name}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
|
||||
<CheckIcon className="h-5 w-5" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
API Key
|
||||
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={localApiKey}
|
||||
onChange={(e) => handleInputChange('apiKey', e.target.value)}
|
||||
placeholder="Using environment variable"
|
||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">TTS Model</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Listbox
|
||||
value={ttsModels.find(m => m.id === selectedModelId) || ttsModels[0]}
|
||||
onChange={(model) => {
|
||||
if (model.id === 'custom') {
|
||||
// Switch to custom: keep the current custom input (or empty)
|
||||
setModelValue(customModelInput);
|
||||
} else {
|
||||
setModelValue(model.id);
|
||||
setCustomModelInput('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent">
|
||||
<span className="block truncate">
|
||||
{ttsModels.find(m => m.id === selectedModelId)?.name || 'Select Model'}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions className="absolute mt-1 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-50">
|
||||
{ttsModels.map((model) => (
|
||||
<ListboxOption
|
||||
key={model.id}
|
||||
className={({ active }) =>
|
||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
|
||||
active ? 'bg-accent/10 text-accent' : 'text-foreground'
|
||||
}`
|
||||
}
|
||||
value={model}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
{model.name}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
|
||||
<CheckIcon className="h-5 w-5" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
|
||||
{supportsCustom && selectedModelId === 'custom' && (
|
||||
<Input
|
||||
type="text"
|
||||
value={customModelInput}
|
||||
onChange={(e) => {
|
||||
setCustomModelInput(e.target.value);
|
||||
setModelValue(e.target.value);
|
||||
}}
|
||||
placeholder="Enter custom model name"
|
||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modelValue === 'gpt-4o-mini-tts' && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">TTS Instructions</label>
|
||||
<textarea
|
||||
value={localTTSInstructions}
|
||||
onChange={(e) => setLocalTTSInstructions(e.target.value)}
|
||||
placeholder="Enter instructions for the TTS model"
|
||||
className="w-full h-24 rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
API Base URL
|
||||
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={localBaseUrl}
|
||||
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
|
||||
placeholder="Using environment variable"
|
||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||
font-medium text-foreground hover:bg-background/90 focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||
onClick={async () => {
|
||||
setLocalApiKey('');
|
||||
setLocalBaseUrl('');
|
||||
setLocalTTSProvider('custom-openai');
|
||||
setModelValue('kokoro');
|
||||
setCustomModelInput('');
|
||||
setLocalTTSInstructions('');
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
||||
font-medium text-white hover:bg-accent/90 focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
|
||||
disabled={!canSubmit}
|
||||
onClick={async () => {
|
||||
await updateConfig({
|
||||
apiKey: localApiKey || '',
|
||||
baseUrl: localBaseUrl || '',
|
||||
});
|
||||
await updateConfigKey('ttsProvider', localTTSProvider);
|
||||
const finalModel = selectedModelId === 'custom' ? customModelInput.trim() : modelValue;
|
||||
await updateConfigKey('ttsModel', finalModel);
|
||||
await updateConfigKey('ttsInstructions', localTTSInstructions);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel className="space-y-4 pb-3">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">Theme</label>
|
||||
|
|
@ -317,157 +595,6 @@ export function SettingsModal() {
|
|||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
OpenAI API Key
|
||||
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={localApiKey}
|
||||
onChange={(e) => handleInputChange('apiKey', e.target.value)}
|
||||
placeholder="Using environment variable"
|
||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
OpenAI API Base URL
|
||||
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={localBaseUrl}
|
||||
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
|
||||
placeholder="Using environment variable"
|
||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">TTS Model</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Listbox
|
||||
value={ttsModels.find(m => m.id === localTTSModel) || ttsModels[0]}
|
||||
onChange={(model) => {
|
||||
setLocalTTSModel(model.id);
|
||||
if (model.id !== 'custom') {
|
||||
setCustomModel('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent">
|
||||
<span className="block truncate">
|
||||
{ttsModels.find(m => m.id === localTTSModel)?.name || 'Select Model'}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions className="absolute mt-1 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-50">
|
||||
{ttsModels.map((model) => (
|
||||
<ListboxOption
|
||||
key={model.id}
|
||||
className={({ active }) =>
|
||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
|
||||
active ? 'bg-accent/10 text-accent' : 'text-foreground'
|
||||
}`
|
||||
}
|
||||
value={model}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
{model.name}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
|
||||
<CheckIcon className="h-5 w-5" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
|
||||
{localTTSModel === 'custom' && (
|
||||
<Input
|
||||
type="text"
|
||||
value={customModel}
|
||||
onChange={(e) => setCustomModel(e.target.value)}
|
||||
placeholder="Enter custom model name"
|
||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localTTSModel === 'gpt-4o-mini-tts' && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">TTS Instructions</label>
|
||||
<textarea
|
||||
value={localTTSInstructions}
|
||||
onChange={(e) => setLocalTTSInstructions(e.target.value)}
|
||||
placeholder="Enter instructions for the TTS model"
|
||||
className="w-full h-24 rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||
font-medium text-foreground hover:bg-background/90 focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||
onClick={async () => {
|
||||
setLocalApiKey('');
|
||||
setLocalBaseUrl('');
|
||||
setLocalTTSModel('tts-1');
|
||||
setCustomModel('');
|
||||
setLocalTTSInstructions('');
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
||||
font-medium text-white hover:bg-accent/90 focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
|
||||
onClick={async () => {
|
||||
await updateConfig({
|
||||
apiKey: localApiKey || '',
|
||||
baseUrl: localBaseUrl || '',
|
||||
});
|
||||
const finalModel = localTTSModel === 'custom' ? customModel : localTTSModel;
|
||||
await updateConfigKey('ttsModel', finalModel);
|
||||
await updateConfigKey('ttsInstructions', localTTSInstructions);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel className="space-y-4">
|
||||
{isDev && <div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">Document Sync</label>
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@ export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
|
|||
}) => {
|
||||
const { voice: configVoice } = useConfig();
|
||||
|
||||
// Use configVoice as the source of truth
|
||||
const currentVoice = configVoice;
|
||||
// If the saved voice is not in the available list, use the first available voice
|
||||
const currentVoice = (configVoice && availableVoices.includes(configVoice))
|
||||
? configVoice
|
||||
: availableVoices[0] || '';
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import { getItem, indexedDBService, setItem, removeItem } from '@/utils/indexedD
|
|||
/** Represents the possible view types for document display */
|
||||
export type ViewType = 'single' | 'dual' | 'scroll';
|
||||
|
||||
/** Saved voice configurations per provider-model */
|
||||
type SavedVoices = Record<string, string>;
|
||||
|
||||
/** Configuration values for the application */
|
||||
type ConfigValues = {
|
||||
apiKey: string;
|
||||
|
|
@ -20,8 +23,10 @@ type ConfigValues = {
|
|||
footerMargin: number;
|
||||
leftMargin: number;
|
||||
rightMargin: number;
|
||||
ttsProvider: string;
|
||||
ttsModel: string;
|
||||
ttsInstructions: string;
|
||||
savedVoices: SavedVoices;
|
||||
};
|
||||
|
||||
/** Interface defining the configuration context shape and functionality */
|
||||
|
|
@ -38,8 +43,10 @@ interface ConfigContextType {
|
|||
footerMargin: number;
|
||||
leftMargin: number;
|
||||
rightMargin: number;
|
||||
ttsProvider: string;
|
||||
ttsModel: string;
|
||||
ttsInstructions: string;
|
||||
savedVoices: SavedVoices;
|
||||
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise<void>;
|
||||
updateConfigKey: <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
|
|
@ -68,12 +75,17 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
const [footerMargin, setFooterMargin] = useState<number>(0.07);
|
||||
const [leftMargin, setLeftMargin] = useState<number>(0.07);
|
||||
const [rightMargin, setRightMargin] = useState<number>(0.07);
|
||||
const [ttsModel, setTTSModel] = useState<string>('tts-1');
|
||||
const [ttsProvider, setTTSProvider] = useState<string>('custom-openai');
|
||||
const [ttsModel, setTTSModel] = useState<string>('kokoro');
|
||||
const [ttsInstructions, setTTSInstructions] = useState<string>('');
|
||||
const [savedVoices, setSavedVoices] = useState<SavedVoices>({});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isDBReady, setIsDBReady] = useState(false);
|
||||
|
||||
// Helper function to generate provider-model key
|
||||
const getVoiceKey = (provider: string, model: string) => `${provider}:${model}`;
|
||||
|
||||
useEffect(() => {
|
||||
const initializeDB = async () => {
|
||||
try {
|
||||
|
|
@ -87,15 +99,56 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
const cachedViewType = await getItem('viewType');
|
||||
const cachedVoiceSpeed = await getItem('voiceSpeed');
|
||||
const cachedAudioPlayerSpeed = await getItem('audioPlayerSpeed');
|
||||
const cachedVoice = await getItem('voice');
|
||||
const cachedSkipBlank = await getItem('skipBlank');
|
||||
const cachedEpubTheme = await getItem('epubTheme');
|
||||
const cachedHeaderMargin = await getItem('headerMargin');
|
||||
const cachedFooterMargin = await getItem('footerMargin');
|
||||
const cachedLeftMargin = await getItem('leftMargin');
|
||||
const cachedRightMargin = await getItem('rightMargin');
|
||||
const cachedTTSProvider = await getItem('ttsProvider');
|
||||
const cachedTTSModel = await getItem('ttsModel');
|
||||
const cachedTTSInstructions = await getItem('ttsInstructions');
|
||||
const cachedSavedVoices = await getItem('savedVoices');
|
||||
|
||||
// Migration logic: infer provider and baseUrl for returning users
|
||||
let inferredProvider = cachedTTSProvider || '';
|
||||
let inferredBaseUrl = cachedBaseUrl || '';
|
||||
|
||||
if (!inferredProvider) {
|
||||
if (cachedBaseUrl) {
|
||||
const baseUrlLower = cachedBaseUrl.toLowerCase();
|
||||
if (baseUrlLower.includes('deepinfra.com')) {
|
||||
inferredProvider = 'deepinfra';
|
||||
} else if (baseUrlLower.includes('openai.com')) {
|
||||
inferredProvider = 'openai';
|
||||
} else if (
|
||||
baseUrlLower.includes('localhost') ||
|
||||
baseUrlLower.includes('127.0.0.1') ||
|
||||
baseUrlLower.includes('internal')
|
||||
) {
|
||||
inferredProvider = 'custom-openai';
|
||||
} else {
|
||||
// Unknown host: fall back based on presence of API key
|
||||
inferredProvider = cachedApiKey ? 'openai' : 'custom-openai';
|
||||
}
|
||||
} else {
|
||||
// No provider stored and no baseUrl stored
|
||||
// If there is an API key and no base URL -> assume OpenAI
|
||||
// If empty with no API key -> default to custom
|
||||
inferredProvider = cachedApiKey ? 'openai' : 'custom-openai';
|
||||
}
|
||||
}
|
||||
|
||||
// If baseUrl is missing, set a safe default based on the inferred provider
|
||||
if (!inferredBaseUrl) {
|
||||
if (inferredProvider === 'openai') {
|
||||
inferredBaseUrl = 'https://api.openai.com/v1';
|
||||
} else if (inferredProvider === 'deepinfra') {
|
||||
inferredBaseUrl = 'https://api.deepinfra.com/v1/openai';
|
||||
} else {
|
||||
inferredBaseUrl = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Only set API key and base URL if they were explicitly saved by the user
|
||||
if (cachedApiKey) {
|
||||
|
|
@ -105,22 +158,44 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
if (cachedBaseUrl) {
|
||||
console.log('Using cached base URL');
|
||||
setBaseUrl(cachedBaseUrl);
|
||||
} else if (inferredBaseUrl) {
|
||||
// Migration: no stored baseUrl, pick a safe default from provider inference
|
||||
console.log('Setting default base URL from inferred provider', inferredBaseUrl);
|
||||
setBaseUrl(inferredBaseUrl);
|
||||
await setItem('baseUrl', inferredBaseUrl);
|
||||
}
|
||||
|
||||
// Parse savedVoices
|
||||
let parsedSavedVoices: SavedVoices = {};
|
||||
if (cachedSavedVoices) {
|
||||
try {
|
||||
parsedSavedVoices = JSON.parse(cachedSavedVoices);
|
||||
} catch (error) {
|
||||
console.error('Error parsing savedVoices:', error);
|
||||
}
|
||||
}
|
||||
setSavedVoices(parsedSavedVoices);
|
||||
|
||||
// Set the other values with defaults
|
||||
setViewType((cachedViewType || 'single') as ViewType);
|
||||
setVoiceSpeed(parseFloat(cachedVoiceSpeed || '1'));
|
||||
setAudioPlayerSpeed(parseFloat(cachedAudioPlayerSpeed || '1'));
|
||||
setVoice(cachedVoice || 'af_sarah');
|
||||
setSkipBlank(cachedSkipBlank === 'false' ? false : true);
|
||||
setEpubTheme(cachedEpubTheme === 'true');
|
||||
setHeaderMargin(parseFloat(cachedHeaderMargin || '0.07'));
|
||||
setFooterMargin(parseFloat(cachedFooterMargin || '0.07'));
|
||||
setLeftMargin(parseFloat(cachedLeftMargin || '0.07'));
|
||||
setRightMargin(parseFloat(cachedRightMargin || '0.07'));
|
||||
setTTSModel(cachedTTSModel || 'tts-1');
|
||||
setTTSProvider(inferredProvider || 'custom-openai');
|
||||
const finalModel = cachedTTSModel || (inferredProvider === 'openai' ? 'tts-1' : inferredProvider === 'deepinfra' ? 'hexgrad/Kokoro-82M' : 'kokoro');
|
||||
setTTSModel(finalModel);
|
||||
setTTSInstructions(cachedTTSInstructions || '');
|
||||
|
||||
// Restore voice for current provider-model if available in savedVoices
|
||||
const voiceKey = getVoiceKey(inferredProvider || 'custom-openai', finalModel);
|
||||
const restoredVoice = parsedSavedVoices[voiceKey] || '';
|
||||
setVoice(restoredVoice);
|
||||
|
||||
// Only save non-sensitive settings by default
|
||||
if (!cachedViewType) {
|
||||
await setItem('viewType', 'single');
|
||||
|
|
@ -135,8 +210,14 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
if (cachedFooterMargin === null) await setItem('footerMargin', '0.07');
|
||||
if (cachedLeftMargin === null) await setItem('leftMargin', '0.0');
|
||||
if (cachedRightMargin === null) await setItem('rightMargin', '0.0');
|
||||
if (cachedTTSProvider === null && inferredProvider) {
|
||||
await setItem('ttsProvider', inferredProvider);
|
||||
} else if (cachedTTSProvider === null) {
|
||||
await setItem('ttsProvider', 'custom-openai');
|
||||
}
|
||||
if (cachedTTSModel === null) {
|
||||
await setItem('ttsModel', 'tts-1');
|
||||
const defaultModel = inferredProvider === 'openai' ? 'tts-1' : inferredProvider === 'deepinfra' ? 'hexgrad/Kokoro-82M' : 'kokoro';
|
||||
await setItem('ttsModel', defaultModel);
|
||||
}
|
||||
if (cachedTTSInstructions === null) {
|
||||
await setItem('ttsInstructions', '');
|
||||
|
|
@ -147,6 +228,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
if (!cachedAudioPlayerSpeed) {
|
||||
await setItem('audioPlayerSpeed', '1');
|
||||
}
|
||||
if (!cachedSavedVoices) {
|
||||
await setItem('savedVoices', JSON.stringify({}));
|
||||
}
|
||||
// Always ensure voice is not stored standalone - only in savedVoices
|
||||
await removeItem('voice');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing:', error);
|
||||
|
|
@ -165,12 +251,12 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
if (newConfig.apiKey !== undefined || newConfig.apiKey !== '') {
|
||||
if (newConfig.apiKey !== undefined && newConfig.apiKey !== '') {
|
||||
// Only save API key to IndexedDB if it's different from env default
|
||||
await setItem('apiKey', newConfig.apiKey!);
|
||||
setApiKey(newConfig.apiKey!);
|
||||
}
|
||||
if (newConfig.baseUrl !== undefined || newConfig.baseUrl !== '') {
|
||||
if (newConfig.baseUrl !== undefined && newConfig.baseUrl !== '') {
|
||||
// Only save base URL to IndexedDB if it's different from env default
|
||||
await setItem('baseUrl', newConfig.baseUrl!);
|
||||
setBaseUrl(newConfig.baseUrl!);
|
||||
|
|
@ -202,50 +288,82 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
const updateConfigKey = async <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await setItem(key, value.toString());
|
||||
switch (key) {
|
||||
case 'apiKey':
|
||||
setApiKey(value as string);
|
||||
break;
|
||||
case 'baseUrl':
|
||||
setBaseUrl(value as string);
|
||||
break;
|
||||
case 'viewType':
|
||||
setViewType(value as ViewType);
|
||||
break;
|
||||
case 'voiceSpeed':
|
||||
setVoiceSpeed(value as number);
|
||||
break;
|
||||
case 'audioPlayerSpeed':
|
||||
setAudioPlayerSpeed(value as number);
|
||||
break;
|
||||
case 'voice':
|
||||
setVoice(value as string);
|
||||
break;
|
||||
case 'skipBlank':
|
||||
setSkipBlank(value as boolean);
|
||||
break;
|
||||
case 'epubTheme':
|
||||
setEpubTheme(value as boolean);
|
||||
break;
|
||||
case 'headerMargin':
|
||||
setHeaderMargin(value as number);
|
||||
break;
|
||||
case 'footerMargin':
|
||||
setFooterMargin(value as number);
|
||||
break;
|
||||
case 'leftMargin':
|
||||
setLeftMargin(value as number);
|
||||
break;
|
||||
case 'rightMargin':
|
||||
setRightMargin(value as number);
|
||||
break;
|
||||
case 'ttsModel':
|
||||
|
||||
// Special handling for voice - only update savedVoices
|
||||
if (key === 'voice') {
|
||||
const voiceKey = getVoiceKey(ttsProvider, ttsModel);
|
||||
const updatedSavedVoices = { ...savedVoices, [voiceKey]: value as string };
|
||||
setSavedVoices(updatedSavedVoices);
|
||||
await setItem('savedVoices', JSON.stringify(updatedSavedVoices));
|
||||
setVoice(value as string);
|
||||
}
|
||||
// Special handling for provider/model changes - restore saved voice if available
|
||||
else if (key === 'ttsProvider' || key === 'ttsModel') {
|
||||
const newProvider = key === 'ttsProvider' ? (value as string) : ttsProvider;
|
||||
const newModel = key === 'ttsModel' ? (value as string) : ttsModel;
|
||||
const voiceKey = getVoiceKey(newProvider, newModel);
|
||||
|
||||
// Update provider or model
|
||||
await setItem(key, value.toString());
|
||||
if (key === 'ttsProvider') {
|
||||
setTTSProvider(value as string);
|
||||
} else {
|
||||
setTTSModel(value as string);
|
||||
break;
|
||||
case 'ttsInstructions':
|
||||
setTTSInstructions(value as string);
|
||||
break;
|
||||
}
|
||||
|
||||
// Restore voice for this provider-model combination if it exists
|
||||
const restoredVoice = savedVoices[voiceKey];
|
||||
if (restoredVoice) {
|
||||
setVoice(restoredVoice);
|
||||
} else {
|
||||
// Clear voice so TTSContext will use first available
|
||||
setVoice('');
|
||||
}
|
||||
}
|
||||
else if (key === 'savedVoices') {
|
||||
setSavedVoices(value as SavedVoices);
|
||||
await setItem('savedVoices', JSON.stringify(value));
|
||||
}
|
||||
else {
|
||||
await setItem(key, value.toString());
|
||||
switch (key) {
|
||||
case 'apiKey':
|
||||
setApiKey(value as string);
|
||||
break;
|
||||
case 'baseUrl':
|
||||
setBaseUrl(value as string);
|
||||
break;
|
||||
case 'viewType':
|
||||
setViewType(value as ViewType);
|
||||
break;
|
||||
case 'voiceSpeed':
|
||||
setVoiceSpeed(value as number);
|
||||
break;
|
||||
case 'audioPlayerSpeed':
|
||||
setAudioPlayerSpeed(value as number);
|
||||
break;
|
||||
case 'skipBlank':
|
||||
setSkipBlank(value as boolean);
|
||||
break;
|
||||
case 'epubTheme':
|
||||
setEpubTheme(value as boolean);
|
||||
break;
|
||||
case 'headerMargin':
|
||||
setHeaderMargin(value as number);
|
||||
break;
|
||||
case 'footerMargin':
|
||||
setFooterMargin(value as number);
|
||||
break;
|
||||
case 'leftMargin':
|
||||
setLeftMargin(value as number);
|
||||
break;
|
||||
case 'rightMargin':
|
||||
setRightMargin(value as number);
|
||||
break;
|
||||
case 'ttsInstructions':
|
||||
setTTSInstructions(value as string);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error updating config key ${key}:`, error);
|
||||
|
|
@ -256,10 +374,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
};
|
||||
|
||||
return (
|
||||
<ConfigContext.Provider value={{
|
||||
apiKey,
|
||||
baseUrl,
|
||||
viewType,
|
||||
<ConfigContext.Provider value={{
|
||||
apiKey,
|
||||
baseUrl,
|
||||
viewType,
|
||||
voiceSpeed,
|
||||
audioPlayerSpeed,
|
||||
voice,
|
||||
|
|
@ -269,12 +387,14 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
footerMargin,
|
||||
leftMargin,
|
||||
rightMargin,
|
||||
ttsProvider,
|
||||
ttsModel,
|
||||
ttsInstructions,
|
||||
updateConfig,
|
||||
savedVoices,
|
||||
updateConfig,
|
||||
updateConfigKey,
|
||||
isLoading,
|
||||
isDBReady
|
||||
isLoading,
|
||||
isDBReady
|
||||
}}>
|
||||
{children}
|
||||
</ConfigContext.Provider>
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
baseUrl,
|
||||
voiceSpeed,
|
||||
voice,
|
||||
ttsProvider,
|
||||
} = useConfig();
|
||||
// Current document state
|
||||
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
|
||||
|
|
@ -254,6 +255,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
headers: {
|
||||
'x-openai-key': apiKey,
|
||||
'x-openai-base-url': baseUrl,
|
||||
'x-tts-provider': ttsProvider,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: trimmedText,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const HTMLContext = createContext<HTMLContextType | undefined>(undefined);
|
|||
*/
|
||||
export function HTMLProvider({ children }: { children: ReactNode }) {
|
||||
const { setText: setTTSText, stop } = useTTS();
|
||||
const { apiKey, baseUrl, voiceSpeed, voice } = useConfig();
|
||||
const { apiKey, baseUrl, voiceSpeed, voice, ttsProvider } = useConfig();
|
||||
|
||||
// Current document state
|
||||
const [currDocData, setCurrDocData] = useState<string>();
|
||||
|
|
@ -97,6 +97,7 @@ export function HTMLProvider({ children }: { children: ReactNode }) {
|
|||
headers: {
|
||||
'x-openai-key': apiKey,
|
||||
'x-openai-base-url': baseUrl,
|
||||
'x-tts-provider': ttsProvider,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: currDocText,
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
baseUrl,
|
||||
voiceSpeed,
|
||||
voice,
|
||||
ttsProvider,
|
||||
} = useConfig();
|
||||
|
||||
// Current document state
|
||||
|
|
@ -243,6 +244,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
headers: {
|
||||
'x-openai-key': apiKey,
|
||||
'x-openai-base-url': baseUrl,
|
||||
'x-tts-provider': ttsProvider,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
voiceSpeed,
|
||||
audioPlayerSpeed,
|
||||
voice: configVoice,
|
||||
ttsProvider: configTTSProvider,
|
||||
ttsModel: configTTSModel,
|
||||
ttsInstructions: configTTSInstructions,
|
||||
updateConfigKey,
|
||||
|
|
@ -110,7 +111,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
// Remove OpenAI client reference as it's no longer needed
|
||||
const audioContext = useAudioContext();
|
||||
const audioCache = useAudioCache(25);
|
||||
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl);
|
||||
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel);
|
||||
|
||||
// Add ref for location change handler
|
||||
const locationChangeHandlerRef = useRef<((location: string | number) => void) | null>(null);
|
||||
|
|
@ -405,6 +406,20 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
}
|
||||
}, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed, fetchVoices, configTTSModel, configTTSInstructions]);
|
||||
|
||||
/**
|
||||
* Validates that the current voice is in the available voices list
|
||||
* If voice is empty or invalid, use the first available voice (only in local state, don't save)
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (availableVoices.length > 0) {
|
||||
if (!voice || !availableVoices.includes(voice)) {
|
||||
console.log(`Voice "${voice || '(empty)'}" not found in available voices. Using "${availableVoices[0]}"`);
|
||||
setVoice(availableVoices[0]);
|
||||
// Don't save to config - just use it temporarily until user explicitly selects one
|
||||
}
|
||||
}
|
||||
}, [availableVoices, voice]);
|
||||
|
||||
/**
|
||||
* Generates and plays audio for the current sentence
|
||||
*
|
||||
|
|
@ -434,6 +449,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
'Content-Type': 'application/json',
|
||||
'x-openai-key': openApiKey || '',
|
||||
'x-openai-base-url': openApiBaseUrl || '',
|
||||
'x-tts-provider': configTTSProvider,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: sentence,
|
||||
|
|
|
|||
|
|
@ -8,9 +8,16 @@ const DEFAULT_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova'
|
|||
* Custom hook for managing TTS voices
|
||||
* @param apiKey OpenAI API key
|
||||
* @param baseUrl OpenAI API base URL
|
||||
* @param ttsProvider TTS provider (openai, custom-openai, deepinfra)
|
||||
* @param ttsModel TTS model name
|
||||
* @returns Object containing available voices and fetch function
|
||||
*/
|
||||
export function useVoiceManagement(apiKey: string | undefined, baseUrl: string | undefined) {
|
||||
export function useVoiceManagement(
|
||||
apiKey: string | undefined,
|
||||
baseUrl: string | undefined,
|
||||
ttsProvider: string | undefined,
|
||||
ttsModel: string | undefined
|
||||
) {
|
||||
const [availableVoices, setAvailableVoices] = useState<string[]>([]);
|
||||
|
||||
const fetchVoices = useCallback(async () => {
|
||||
|
|
@ -20,6 +27,8 @@ export function useVoiceManagement(apiKey: string | undefined, baseUrl: string |
|
|||
headers: {
|
||||
'x-openai-key': apiKey || '',
|
||||
'x-openai-base-url': baseUrl || '',
|
||||
'x-tts-provider': ttsProvider || 'openai',
|
||||
'x-tts-model': ttsModel || 'tts-1',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
|
@ -32,7 +41,7 @@ export function useVoiceManagement(apiKey: string | undefined, baseUrl: string |
|
|||
// Set available voices to default openai voices
|
||||
setAvailableVoices(DEFAULT_VOICES);
|
||||
}
|
||||
}, [apiKey, baseUrl]);
|
||||
}, [apiKey, baseUrl, ttsProvider, ttsModel]);
|
||||
|
||||
return { availableVoices, fetchVoices };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,10 @@ export async function setupTest(page: Page) {
|
|||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByRole('button', { name: 'Custom OpenAI-Like' }).click();
|
||||
await page.getByText('Deepinfra').click();
|
||||
|
||||
// Click the "done" button to dismiss the welcome message
|
||||
await page.getByRole('tab', { name: '🔑 API' }).click();
|
||||
await page.getByRole('button', { name: 'Done' }).click();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
Loading…
Reference in a new issue