Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/api/generate/scene-content/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@/lib/generation/generation-pipeline';
import type { AgentInfo } from '@/lib/generation/generation-pipeline';
import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation';
import type { Locale } from '@/lib/i18n/types';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { resolveModelFromHeaders } from '@/lib/server/resolve-model';
Expand Down Expand Up @@ -67,7 +68,7 @@ export async function POST(req: NextRequest) {
// Ensure outline has language from stageInfo (fallback for older outlines)
const outline: SceneOutline = {
...rawOutline,
language: rawOutline.language || (stageInfo?.language as 'zh-CN' | 'en-US') || 'zh-CN',
language: rawOutline.language || (stageInfo?.language as Locale) || 'zh-CN',
};

// ── Model resolution from request headers ──
Expand Down
8 changes: 5 additions & 3 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@ const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled';
const LANGUAGE_STORAGE_KEY = 'generationLanguage';
const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen';

import type { Locale } from '@/lib/i18n/types';

interface FormState {
pdfFile: File | null;
requirement: string;
language: 'zh-CN' | 'en-US';
language: Locale;
webSearch: boolean;
}

Expand Down Expand Up @@ -99,8 +101,8 @@ function HomePage() {
const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY);
const updates: Partial<FormState> = {};
if (savedWebSearch === 'true') updates.webSearch = true;
if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US') {
updates.language = savedLanguage;
if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US' || savedLanguage === 'vi-VN') {
updates.language = savedLanguage as Locale;
} else {
const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US';
updates.language = detected;
Expand Down
9 changes: 5 additions & 4 deletions components/generation/generation-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,16 @@ import type { WebSearchProviderId } from '@/lib/web-search/types';
import type { ProviderId } from '@/lib/ai/providers';
import type { SettingsSection } from '@/lib/types/settings';
import { MediaPopover } from '@/components/generation/media-popover';
import type { Locale } from '@/lib/i18n/types';

// ─── Constants ───────────────────────────────────────────────
const MAX_PDF_SIZE_MB = 50;
const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024;

// ─── Types ───────────────────────────────────────────────────
export interface GenerationToolbarProps {
language: 'zh-CN' | 'en-US';
onLanguageChange: (lang: 'zh-CN' | 'en-US') => void;
language: Locale;
onLanguageChange: (lang: Locale) => void;
webSearch: boolean;
onWebSearchChange: (v: boolean) => void;
onSettingsOpen: (section?: SettingsSection) => void;
Expand Down Expand Up @@ -361,11 +362,11 @@ export function GenerationToolbar({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onLanguageChange(language === 'zh-CN' ? 'en-US' : 'zh-CN')}
onClick={() => onLanguageChange(language === 'zh-CN' ? 'en-US' : language === 'en-US' ? 'vi-VN' : 'zh-CN')}
className={pillMuted}
>
<Globe className="size-3.5" />
<span>{language === 'zh-CN' ? '中文' : 'EN'}</span>
<span>{language === 'zh-CN' ? '中文' : language === 'vi-VN' ? 'Tiếng Việt' : 'EN'}</span>
</button>
</TooltipTrigger>
<TooltipContent>{t('toolbar.languageHint')}</TooltipContent>
Expand Down
1 change: 1 addition & 0 deletions components/generation/media-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => strin
'doubao-tts': t('settings.providerDoubaoTTS'),
'elevenlabs-tts': t('settings.providerElevenLabsTTS'),
'minimax-tts': t('settings.providerMiniMaxTTS'),
'vieneu-tts': t('settings.providerVieNeuTTS'),
'browser-native-tts': t('settings.providerBrowserNativeTTS'),
};
return names[providerId] || providerId;
Expand Down
15 changes: 14 additions & 1 deletion components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export function Header({ currentSceneTitle }: HeaderProps) {
}}
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-bold text-gray-500 dark:text-gray-400 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all"
>
{locale === 'zh-CN' ? 'CN' : 'EN'}
{locale === 'zh-CN' ? 'CN' : locale === 'vi-VN' ? 'VN' : 'EN'}
</button>
{languageOpen && (
<div className="absolute top-full mt-2 right-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50 min-w-[120px]">
Expand Down Expand Up @@ -138,6 +138,19 @@ export function Header({ currentSceneTitle }: HeaderProps) {
>
English
</button>
<button
onClick={() => {
setLocale('vi-VN');
setLanguageOpen(false);
}}
className={cn(
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
locale === 'vi-VN' &&
'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
)}
>
Tiếng Việt
</button>
</div>
)}
</div>
Expand Down
1 change: 1 addition & 0 deletions components/settings/audio-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => strin
'doubao-tts': t('settings.providerDoubaoTTS'),
'elevenlabs-tts': t('settings.providerElevenLabsTTS'),
'minimax-tts': t('settings.providerMiniMaxTTS'),
'vieneu-tts': t('settings.providerVieNeuTTS'),
'browser-native-tts': t('settings.providerBrowserNativeTTS'),
};
return names[providerId];
Expand Down
1 change: 1 addition & 0 deletions components/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => strin
'doubao-tts': t('settings.providerDoubaoTTS'),
'elevenlabs-tts': t('settings.providerElevenLabsTTS'),
'minimax-tts': t('settings.providerMiniMaxTTS'),
'vieneu-tts': t('settings.providerVieNeuTTS'),
'browser-native-tts': t('settings.providerBrowserNativeTTS'),
};
return names[providerId];
Expand Down
55 changes: 53 additions & 2 deletions components/settings/tts-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,6 +33,7 @@ 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
Expand All @@ -46,6 +54,7 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) {

// Doubao TTS uses compound "appId:accessKey" — split for separate UI fields
const isDoubao = selectedProviderId === 'doubao-tts';
const isVieNeu = selectedProviderId === 'vieneu-tts';
const rawApiKey = ttsProvidersConfig[selectedProviderId]?.apiKey || '';
const doubaoColonIdx = rawApiKey.indexOf(':');
const doubaoAppId = isDoubao && doubaoColonIdx > 0 ? rawApiKey.slice(0, doubaoColonIdx) : '';
Expand Down Expand Up @@ -112,8 +121,50 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) {
</div>
)}

{/* VieNeu TTS: Base URL + Voice (no API key required) */}
{isVieNeu && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm">{t('settings.ttsBaseUrl')}</Label>
<Input
name={`tts-base-url-${selectedProviderId}`}
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={ttsProvider.defaultBaseUrl || 'http://127.0.0.1:8001'}
value={ttsProvidersConfig[selectedProviderId]?.baseUrl || ''}
onChange={(e) =>
setTTSProviderConfig(selectedProviderId, {
baseUrl: e.target.value,
})
}
className="text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-sm">{t('settings.ttsVoice')}</Label>
<Select
value={effectiveVoice}
onValueChange={(v) => setTTSVoice(v)}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{getTTSVoices('vieneu-tts').map((voice) => (
<SelectItem key={voice.id} value={voice.id} className="text-sm">
{voice.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}

{/* API Key & Base URL */}
{(ttsProvider.requiresApiKey || isServerConfigured) && (
{!isVieNeu && (ttsProvider.requiresApiKey || isServerConfigured) && (
<>
<div className={cn('grid gap-4', isDoubao ? 'grid-cols-3' : 'grid-cols-2')}>
{isDoubao ? (
Expand Down
46 changes: 46 additions & 0 deletions lib/audio/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,50 @@ export const TTS_PROVIDERS: Record<TTSProviderId, TTSProviderConfig> = {
speedRange: { min: 0.7, max: 1.2, default: 1.0 },
},

'vieneu-tts': {
id: 'vieneu-tts',
name: 'VieNeu TTS (Tiếng Việt)',
requiresApiKey: false,
defaultBaseUrl: 'http://127.0.0.1:8001',
icon: '/logos/vieneu.svg',
models: [],
defaultModelId: '',
voices: [
// Miền Nam (Southern accent)
{
id: 'Xuân Vĩnh (Nam - Miền Nam)',
name: 'Xuân Vĩnh (Nam - Miền Nam)',
language: 'vi-VN',
gender: 'male',
description: 'vieneuVoiceXuanVinh',
},
{
id: 'Thục Đoan (Nữ - Miền Nam)',
name: 'Thục Đoan (Nữ - Miền Nam)',
language: 'vi-VN',
gender: 'female',
description: 'vieneuVoiceThucDoan',
},
// Miền Bắc (Northern accent)
{
id: 'Đoan Trang (Nữ - Miền Bắc)',
name: 'Đoan Trang (Nữ - Miền Bắc)',
language: 'vi-VN',
gender: 'female',
description: 'vieneuVoiceDoanTrang',
},
{
id: 'Phạm Tuyên (Nam - Miền Bắc)',
name: 'Phạm Tuyên (Nam - Miền Bắc)',
language: 'vi-VN',
gender: 'male',
description: 'vieneuVoicePhamTuyen',
},
],
supportedFormats: ['wav'],
speedRange: { min: 0.5, max: 2.0, default: 1.0 },
},

'browser-native-tts': {
id: 'browser-native-tts',
name: '浏览器原生 (Web Speech API)',
Expand Down Expand Up @@ -1125,6 +1169,7 @@ export const DEFAULT_TTS_VOICES: Record<TTSProviderId, string> = {
'doubao-tts': 'zh_female_vv_uranus_bigtts',
'elevenlabs-tts': 'EXAVITQu4vr4xnSDxMaL',
'minimax-tts': 'female-yujie',
'vieneu-tts': 'Xuân Vĩnh (Nam - Miền Nam)',
'browser-native-tts': 'default',
};

Expand All @@ -1136,6 +1181,7 @@ export const DEFAULT_TTS_MODELS: Record<TTSProviderId, string> = {
'doubao-tts': '',
'elevenlabs-tts': 'eleven_multilingual_v2',
'minimax-tts': 'speech-2.8-hd',
'vieneu-tts': '',
'browser-native-tts': '',
};

Expand Down
38 changes: 38 additions & 0 deletions lib/audio/tts-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ export async function generateTTS(
case 'elevenlabs-tts':
return await generateElevenLabsTTS(config, text);

case 'vieneu-tts':
return await generateVieNeuTTS(config, text);

case 'browser-native-tts':
throw new Error(
'Browser Native TTS must be handled client-side using Web Speech API. This provider cannot be used on the server.',
Expand Down Expand Up @@ -581,6 +584,41 @@ async function generateDoubaoTTS(
return { audio: combined, format: 'mp3' };
}

/**
* VieNeu TTS implementation (local Vietnamese TTS server)
* API: GET /stream?text=<text>&voice_id=<voice_id>
* Returns: WAV audio
*/
async function generateVieNeuTTS(
config: TTSModelConfig,
text: string,
): Promise<TTSGenerationResult> {
const baseUrl = (config.baseUrl || TTS_PROVIDERS['vieneu-tts'].defaultBaseUrl || '').replace(
/\/$/,
'',
);

const params = new URLSearchParams({
text,
voice_id: config.voice,
});

const response = await fetch(`${baseUrl}/stream?${params.toString()}`, {
method: 'GET',
});

if (!response.ok) {
const errorText = await response.text().catch(() => response.statusText);
throw new Error(`VieNeu TTS API error (${response.status}): ${errorText}`);
}

const arrayBuffer = await response.arrayBuffer();
return {
audio: new Uint8Array(arrayBuffer),
format: 'wav',
};
}

/**
* Escape XML special characters for SSML
*/
Expand Down
1 change: 1 addition & 0 deletions lib/audio/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export type TTSProviderId =
| 'doubao-tts'
| 'elevenlabs-tts'
| 'minimax-tts'
| 'vieneu-tts'
| 'browser-native-tts';
// Add new TTS providers below (uncomment and modify):
// | 'fish-audio-tts'
Expand Down
3 changes: 2 additions & 1 deletion lib/generation/scene-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
PdfImage,
ImageMapping,
} from '@/lib/types/generation';
import type { Locale } from '@/lib/i18n/types';
import type { LanguageModel } from 'ai';
import type { StageStore } from '@/lib/api/stage-api';
import { createStageAPI } from '@/lib/api/stage-api';
Expand Down Expand Up @@ -735,7 +736,7 @@ function normalizeQuizAnswer(question: Record<string, unknown>): string[] | unde
async function generateInteractiveContent(
outline: SceneOutline,
aiCall: AICallFn,
language: 'zh-CN' | 'en-US' = 'zh-CN',
language: Locale = 'zh-CN',
): Promise<GeneratedInteractiveContent | null> {
const config = outline.interactiveConfig!;

Expand Down
4 changes: 2 additions & 2 deletions lib/hooks/use-i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ type I18nContextType = {
};

const LOCALE_STORAGE_KEY = 'locale';
const VALID_LOCALES: Locale[] = ['zh-CN', 'en-US'];
const VALID_LOCALES: Locale[] = ['zh-CN', 'en-US', 'vi-VN'];

const I18nContext = createContext<I18nContextType | undefined>(undefined);

Expand All @@ -26,7 +26,7 @@ export function I18nProvider({ children }: { children: ReactNode }) {
setLocaleState(stored as Locale);
return;
}
const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US';
const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : navigator.language?.startsWith('vi') ? 'vi-VN' : 'en-US';
localStorage.setItem(LOCALE_STORAGE_KEY, detected);
setLocaleState(detected);
} catch {
Expand Down
Loading
Loading