Files
Bohdan Triapitsyn 391f938334 feat(voice): match local and macOS voices to the language of the text
Text-to-speech picked one voice regardless of what language a reply was in.
A dependency-free language detector (script, marker letters, function words)
now decides the language of the whole message once; with the new
"Match the voice to the language of the text" setting the local provider
switches to a catalog model for that language (Kokoro zh/en and Piper models
for 12 languages, downloaded on first use like the existing model) and macOS
say switches to an installed voice whose locale matches. The local voice
picker lists voices of every installed model, and the settings show which
language models are on disk.

The Ukrainian Piper medium build is a character-level model that sherpa-onnx
turns into noise, so the espeak-based Lada build is used instead.

Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98
2026-08-30 02:24:22 +03:00

169 lines
6.0 KiB
JavaScript

import { describe, expect, it, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { registerTtsRoutes } from './routes.js';
import { normalizeCustomOpenAIBaseURL } from './base-url.js';
const createApp = (sayTTSCapability = null) => {
const app = express();
app.use(express.json());
registerTtsRoutes(app, {
resolveZenModel: async () => 'gpt-5-nano',
sayTTSCapability,
});
return app;
};
describe('tts routes', () => {
it('waits for the authoritative macOS say capability', async () => {
let resolveCapability;
const capability = new Promise((resolve) => {
resolveCapability = resolve;
});
const pending = request(createApp(capability)).get('/api/tts/say/status');
resolveCapability({ available: true, voices: [{ name: 'Samantha', locale: 'en_US' }] });
const response = await pending;
expect(response.status).toBe(200);
expect(response.body).toEqual({
available: true,
voices: [{ name: 'Samantha', locale: 'en_US' }],
});
});
it('switches the say voice to the language of the text when asked to', async () => {
const capability = Promise.resolve({
available: true,
voices: [
{ name: 'Samantha', locale: 'en_US' },
{ name: 'Lesya', locale: 'uk_UA' },
{ name: 'Lesya (Enhanced)', locale: 'uk_UA' },
],
});
const app = createApp(capability);
const response = await request(app)
.post('/api/tts/say/speak')
.send({ text: 'Привіт! Це відповідь українською мовою, і вона досить довга.', voice: 'Samantha', language: 'auto' });
// On macOS the route synthesizes; elsewhere it refuses before running say.
// Either way the chosen voice must be the Ukrainian one when the platform
// allows the request to proceed.
if (process.platform === 'darwin') {
expect(response.status).toBe(200);
expect(response.headers['x-speech-voice']).toBe('Lesya (Enhanced)');
expect(response.headers['x-speech-language']).toBe('uk');
} else {
expect(response.status).toBe(503);
}
});
it('returns local note fallback while model summarization is retired', async () => {
const response = await request(createApp())
.post('/api/text/summarize')
.send({
text: 'First sentence. Second sentence with the useful insight.',
threshold: 0,
maxLength: 100,
mode: 'note',
});
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
summary: 'First sentence.',
summarized: false,
reason: 'Model summarization provider unavailable',
});
});
it('keeps notification fallback behavior without calling zen', async () => {
const response = await request(createApp())
.post('/api/text/summarize')
.send({
text: 'Notification text that should fall back cleanly.',
threshold: 0,
maxLength: 100,
mode: 'notification',
});
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
summary: 'Notification text that should fall back cleanly.',
summarized: false,
reason: 'Model summarization provider unavailable',
});
});
});
describe('normalizeCustomOpenAIBaseURL', () => {
const originalRuntime = process.env.OPENCHAMBER_RUNTIME;
const originalAllowRemote = process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS;
afterEach(() => {
// Restore env vars after each test
if (originalRuntime === undefined) {
delete process.env.OPENCHAMBER_RUNTIME;
} else {
process.env.OPENCHAMBER_RUNTIME = originalRuntime;
}
if (originalAllowRemote === undefined) {
delete process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS;
} else {
process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS = originalAllowRemote;
}
});
it('rejects remote URLs when OPENCHAMBER_RUNTIME is not set (web)', () => {
delete process.env.OPENCHAMBER_RUNTIME;
delete process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS;
const result = normalizeCustomOpenAIBaseURL('https://my-tts-server.example.com/v1');
expect(result.error).toMatch(/Remote custom server URLs are disabled/);
expect(result.value).toBeUndefined();
});
it('allows remote URLs when OPENCHAMBER_RUNTIME is desktop', () => {
process.env.OPENCHAMBER_RUNTIME = 'desktop';
delete process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS;
const result = normalizeCustomOpenAIBaseURL('https://my-tts-server.example.com/v1');
expect(result.error).toBeUndefined();
expect(result.value).toBe('https://my-tts-server.example.com/v1');
});
it('allows remote URLs when OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS is true', () => {
delete process.env.OPENCHAMBER_RUNTIME;
process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS = 'true';
const result = normalizeCustomOpenAIBaseURL('https://my-tts-server.example.com/v1');
expect(result.error).toBeUndefined();
expect(result.value).toBe('https://my-tts-server.example.com/v1');
});
it('allows localhost URLs regardless of runtime', () => {
delete process.env.OPENCHAMBER_RUNTIME;
delete process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS;
const result = normalizeCustomOpenAIBaseURL('http://localhost:8880/v1');
expect(result.error).toBeUndefined();
expect(result.value).toBe('http://localhost:8880/v1');
});
it('strips query strings and trailing slashes', () => {
process.env.OPENCHAMBER_RUNTIME = 'desktop';
const result = normalizeCustomOpenAIBaseURL('https://my-server.com/v1/?key=123');
expect(result.value).toBe('https://my-server.com/v1');
});
it('denies remote URLs on desktop when env var is explicitly false', () => {
process.env.OPENCHAMBER_RUNTIME = 'desktop';
process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS = 'false';
const result = normalizeCustomOpenAIBaseURL('https://my-tts-server.example.com/v1');
expect(result.error).toMatch(/Remote custom server URLs are disabled/);
expect(result.value).toBeUndefined();
});
});