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
13 changes: 13 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,19 @@ const log = createLogger('Home');

const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled';
const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen';
const LANGUAGE_STORAGE_KEY = 'courseLanguage';

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

const initialFormState: FormState = {
pdfFile: null,
requirement: '',
language: 'zh-CN',
webSearch: false,
};

Expand Down Expand Up @@ -97,8 +100,15 @@ function HomePage() {
}
try {
const savedWebSearch = localStorage.getItem(WEB_SEARCH_STORAGE_KEY);
const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY);
const updates: Partial<FormState> = {};
if (savedWebSearch === 'true') updates.webSearch = true;
if (savedLanguage === 'zh-CN' || savedLanguage === 'zh-TW' || savedLanguage === 'en-US') {
updates.language = savedLanguage;
} else {
const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US';
updates.language = detected;
}
if (Object.keys(updates).length > 0) {
setForm((prev) => ({ ...prev, ...updates }));
}
Expand Down Expand Up @@ -257,6 +267,7 @@ function HomePage() {
const userProfile = useUserProfileStore.getState();
const requirements: UserRequirements = {
requirement: form.requirement,
language: form.language,
userNickname: userProfile.nickname || undefined,
userBio: userProfile.bio || undefined,
webSearch: form.webSearch || undefined,
Expand Down Expand Up @@ -503,6 +514,8 @@ function HomePage() {
<div className="px-3 pb-3 flex items-end gap-2">
<div className="flex-1 min-w-0">
<GenerationToolbar
language={form.language}
onLanguageChange={(lang) => updateForm('language', lang)}
webSearch={form.webSearch}
onWebSearchChange={(v) => updateForm('webSearch', v)}
onSettingsOpen={(section) => {
Expand Down
27 changes: 26 additions & 1 deletion components/generation/generation-toolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { useState, useRef, useMemo } from 'react';
import { Bot, Check, ChevronLeft, Paperclip, FileText, X, Globe2 } from 'lucide-react';
import { Bot, Check, ChevronLeft, Paperclip, FileText, X, Globe, Globe2 } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Select,
Expand All @@ -28,7 +28,11 @@ const MAX_PDF_SIZE_MB = 50;
const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024;

// ─── Types ───────────────────────────────────────────────────
export type CourseLanguage = 'zh-CN' | 'zh-TW' | 'en-US';

export interface GenerationToolbarProps {
language: CourseLanguage;
onLanguageChange: (lang: CourseLanguage) => void;
webSearch: boolean;
onWebSearchChange: (v: boolean) => void;
onSettingsOpen: (section?: SettingsSection) => void;
Expand All @@ -40,6 +44,8 @@ export interface GenerationToolbarProps {

// ─── Component ───────────────────────────────────────────────
export function GenerationToolbar({
language,
onLanguageChange,
webSearch,
onWebSearchChange,
onSettingsOpen,
Expand Down Expand Up @@ -356,6 +362,25 @@ export function GenerationToolbar({
</Tooltip>
)}

{/* ── Language pill ── */}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
const languages: CourseLanguage[] = ['zh-CN', 'zh-TW', 'en-US'];
const currentIndex = languages.indexOf(language);
const nextIndex = (currentIndex + 1) % languages.length;
onLanguageChange(languages[nextIndex]);
}}
className={pillMuted}
>
<Globe className="size-3.5" />
<span>{language === 'zh-CN' ? '中文' : language === 'zh-TW' ? '繁中' : 'EN'}</span>
</button>
</TooltipTrigger>
<TooltipContent>{t('toolbar.languageHint')}</TooltipContent>
</Tooltip>

{/* ── Separator ── */}
<div className="w-px h-4 bg-border/60 mx-1" />

Expand Down
3 changes: 2 additions & 1 deletion components/settings/tts-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ interface TTSSettingsProps {
}

export function TTSSettings({ selectedProviderId }: TTSSettingsProps) {
const { t } = useI18n();
const { t, locale } = useI18n();

const ttsVoice = useSettingsStore((state) => state.ttsVoice);
const ttsSpeed = useSettingsStore((state) => state.ttsSpeed);
Expand Down Expand Up @@ -116,6 +116,7 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) {
ttsProvidersConfig[selectedProviderId]?.baseUrl ||
providerConfig?.customDefaultBaseUrl ||
'',
locale, // Pass UI locale for browser TTS language selection
});
setTestStatus('success');
setTestMessage(t('settings.ttsTestSuccess'));
Expand Down
17 changes: 17 additions & 0 deletions components/stage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,23 @@ export function Stage({
return ids.includes(agentId);
},
getPlaybackSpeed: () => useSettingsStore.getState().playbackSpeed || 1,
getCourseLanguage: () => {
// Get course language from stage (set during generation)
// Use languageDirective field or fallback to zh-CN
const stage = useStageStore.getState().stage;
const lang = stage?.languageDirective;
if (lang === 'zh-TW' || lang === 'zh-CN' || lang === 'en-US') {
return lang;
}
// Infer from languageDirective if it's a language note
if (lang?.includes('繁體') || lang?.includes('台灣') || lang?.includes('香港')) {
return 'zh-TW';
}
if (lang?.includes('中文') || lang?.includes('简体')) {
return 'zh-CN';
}
return 'zh-CN'; // Default
},
onComplete: () => {
// lectureSpeech intentionally NOT cleared — last sentence stays visible
// until scene transition (auto-play) or user restarts. Scene change
Expand Down
40 changes: 36 additions & 4 deletions lib/audio/browser-tts-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,28 @@ const VOICES_LOAD_TIMEOUT_MS = 2000;
const PREVIEW_TIMEOUT_MS = 30000;
const CJK_LANG_THRESHOLD = 0.3;

/**
* Map UI locale to browser TTS language code.
* zh-TW (Traditional Chinese) → zh-HK (Cantonese - better voice quality)
* zh-CN (Simplified Chinese) → zh-CN
* en-US → en-US
*/
function localeToBrowserTTSLang(locale: string): string {
if (locale === 'zh-TW') {
return 'zh-HK';
}
if (locale === 'zh-CN') {
return 'zh-CN';
}
return 'en-US';
}

type PlayBrowserTTSPreviewOptions = {
text: string;
voice?: string;
rate?: number;
voices?: SpeechSynthesisVoice[];
locale?: string; // UI locale (zh-CN, zh-TW, en-US) for language selection
};

function createAbortError(): Error {
Expand Down Expand Up @@ -68,11 +85,12 @@ export async function ensureVoicesLoaded(): Promise<SpeechSynthesisVoice[]> {
});
}

/** Resolve a browser voice by voiceURI, name, or lang, with language fallback by text. */
/** Resolve a browser voice by voiceURI, name, or lang, with language fallback by locale or text. */
export function resolveBrowserVoice(
voices: SpeechSynthesisVoice[],
voiceNameOrLang: string,
text: string,
locale?: string,
): { voice: SpeechSynthesisVoice | null; lang: string } {
const target = voiceNameOrLang.trim();
const matchedVoice =
Expand All @@ -82,9 +100,18 @@ export function resolveBrowserVoice(
) || null
: null;

if (matchedVoice) {
return {
voice: matchedVoice,
lang: matchedVoice.lang,
};
}

// No voice matched — use locale if provided, otherwise infer from text
const lang = locale ? localeToBrowserTTSLang(locale) : inferPreviewLang(text);
return {
voice: matchedVoice,
lang: matchedVoice?.lang || inferPreviewLang(text),
voice: null,
lang,
};
}

Expand Down Expand Up @@ -153,7 +180,12 @@ export function playBrowserTTSPreview(options: PlayBrowserTTSPreviewOptions): {
const utterance = new SpeechSynthesisUtterance(options.text);
utterance.rate = options.rate ?? 1;

const { voice, lang } = resolveBrowserVoice(voices, options.voice ?? '', options.text);
const { voice, lang } = resolveBrowserVoice(
voices,
options.voice ?? '',
options.text,
options.locale,
);
if (voice) {
utterance.voice = voice;
}
Expand Down
68 changes: 68 additions & 0 deletions lib/audio/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,74 @@ export const DEFAULT_TTS_MODELS: Record<BuiltInTTSProviderId, string> = {
'browser-native-tts': '',
};

/**
* Map course generation language to appropriate TTS voice for each provider.
* When generating course in Traditional Chinese (zh-TW), use Cantonese (zh-HK) voice.
*/
export const LANGUAGE_TO_TTS_VOICE: Record<string, Record<string, string>> = {
'zh-CN': {
'openai-tts': 'alloy',
'azure-tts': 'zh-CN-XiaoxiaoNeural',
'glm-tts': 'tongtong',
'qwen-tts': 'Cherry',
'doubao-tts': 'zh_female_vv_uranus_bigtts',
'elevenlabs-tts': 'EXAVITQu4vr4xnSDxMaL',
'minimax-tts': 'female-yujie',
'browser-native-tts': 'zh-CN',
},
'zh-TW': {
'openai-tts': 'alloy', // OpenAI TTS doesn't support Chinese, will fallback
'azure-tts': 'zh-HK-HiuGaaiNeural', // Cantonese voice for Traditional Chinese content
'glm-tts': 'tongtong',
'qwen-tts': 'Tingting',
'doubao-tts': 'zh_female_vv_uranus_bigtts',
'elevenlabs-tts': 'EXAVITQu4vr4xnSDxMaL',
'minimax-tts': 'female-yujie',
'browser-native-tts': 'zh-HK',
},
'en-US': {
'openai-tts': 'alloy',
'azure-tts': 'en-US-JennyNeural',
'glm-tts': 'tongtong',
'qwen-tts': 'Amy',
'doubao-tts': 'en_male_tim_uranus_bigtts',
'elevenlabs-tts': 'EXAVITQu4vr4xnSDxMaL',
'minimax-tts': 'English_Trustworthy_Man',
'browser-native-tts': 'en-US',
},
};

/**
* Get the appropriate TTS voice for a given course language and provider.
*/
export function getVoiceForLanguage(courseLanguage: string, providerId: string): string {
const languageMap = LANGUAGE_TO_TTS_VOICE[courseLanguage];
if (languageMap && languageMap[providerId]) {
return languageMap[providerId];
}
// Fallback to default voice
return DEFAULT_TTS_VOICES[providerId as keyof typeof DEFAULT_TTS_VOICES] || 'alloy';
}

/**
* Map UI locale (zh-CN / zh-TW / en-US) to browser-native-tts voice ID.
* zh-TW → zh-HK (Cantonese voice for Traditional Chinese UI)
* zh-CN → zh-CN (Mandarin voice for Simplified Chinese UI)
* en-US → en-US (English voice)
*/
export const LOCALE_TO_BROWSER_VOICE: Record<string, string> = {
'zh-CN': 'zh-CN',
'zh-TW': 'zh-HK',
'en-US': 'en-US',
};

/**
* Get the browser-native-tts voice ID for a given UI locale.
*/
export function getBrowserVoiceForLocale(locale: string): string {
return LOCALE_TO_BROWSER_VOICE[locale] || 'zh-CN';
}

/**
* Get all available TTS providers (built-in + custom)
*/
Expand Down
2 changes: 2 additions & 0 deletions lib/audio/use-tts-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface TTSPreviewOptions {
speed: number;
apiKey?: string;
baseUrl?: string;
locale?: string; // UI locale (zh-CN, zh-TW, en-US) for browser TTS language selection
}

/**
Expand Down Expand Up @@ -81,6 +82,7 @@ export function useTTSPreview() {
voice: options.voice,
rate: options.speed,
voices,
locale: options.locale,
});
cancelRef.current = controller.cancel;
await controller.promise;
Expand Down
24 changes: 22 additions & 2 deletions lib/hooks/use-browser-tts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export interface UseBrowserTTSOptions {
pitch?: number; // 0 to 2
volume?: number; // 0 to 1
lang?: string; // e.g., 'zh-CN', 'en-US'
/**
* Locale code (zh-CN, zh-TW, en-US) for language selection.
* zh-TW maps to zh-HK (Cantonese) for better voice quality.
* If provided, overrides the static lang option when no specific voice is set.
*/
locale?: string;
}

export function useBrowserTTS(options: UseBrowserTTSOptions = {}) {
Expand All @@ -27,6 +33,7 @@ export function useBrowserTTS(options: UseBrowserTTSOptions = {}) {
pitch = 1.0,
volume = 1.0,
lang = 'zh-CN',
locale,
} = options;

const [isSpeaking, setIsSpeaking] = useState(false);
Expand Down Expand Up @@ -73,13 +80,26 @@ export function useBrowserTTS(options: UseBrowserTTSOptions = {}) {
utterance.rate = rate;
utterance.pitch = pitch;
utterance.volume = volume;
utterance.lang = lang;

// Determine language: use locale if provided, otherwise fall back to lang option
let finalLang = lang;
if (locale) {
if (locale === 'zh-TW') {
finalLang = 'zh-HK'; // Cantonese for Traditional Chinese
} else if (locale === 'zh-CN') {
finalLang = 'zh-CN';
} else {
finalLang = locale; // en-US or others
}
}
utterance.lang = finalLang;

// Set voice if specified
if (voiceURI) {
const voice = availableVoices.find((v) => v.voiceURI === voiceURI);
if (voice) {
utterance.voice = voice;
utterance.lang = voice.lang; // Use voice's language if available
}
}

Expand Down Expand Up @@ -114,7 +134,7 @@ export function useBrowserTTS(options: UseBrowserTTSOptions = {}) {
utteranceRef.current = utterance;
window.speechSynthesis.speak(utterance);
},
[rate, pitch, volume, lang, availableVoices, onStart, onEnd, onError],
[rate, pitch, volume, lang, locale, availableVoices, onStart, onEnd, onError],
);

const pause = useCallback(() => {
Expand Down
Loading
Loading