From 2194d93fe067f6b31694953463500737f5a70aab Mon Sep 17 00:00:00 2001 From: goddonebianu Date: Wed, 8 Oct 2025 10:42:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=BF=AB=E4=BA=86TTS=E7=9A=84?= =?UTF-8?q?=E9=80=9F=E5=BA=A6=EF=BC=8C=E5=B0=86IPA=E7=94=9F=E6=88=90?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E4=B8=BA=E5=8F=AF=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/changelog.txt | 1 + src/app/api/ipa/route.ts | 64 +++++++++++ src/app/api/locale/route.ts | 62 ++++++++++ src/app/api/translate/route.ts | 22 +--- src/app/text-speaker/page.tsx | 29 +++-- src/app/translator/page.tsx | 204 +++++++++++++++++++++------------ 6 files changed, 284 insertions(+), 98 deletions(-) create mode 100644 src/app/api/ipa/route.ts create mode 100644 src/app/api/locale/route.ts diff --git a/public/changelog.txt b/public/changelog.txt index 2e4ee83..e9dfa72 100644 --- a/public/changelog.txt +++ b/public/changelog.txt @@ -1,3 +1,4 @@ +2025.10.08 加快了TTS的速度,将IPA生成设置为可选项 2025.10.07 新增文本朗读器,优化了视频播放器UI 2025.10.06 更新了主页面UI,移除IPA生成与文本朗读功能,新增全语言翻译器 2025.10.05 新增IPA生成与文本朗读功能 diff --git a/src/app/api/ipa/route.ts b/src/app/api/ipa/route.ts new file mode 100644 index 0000000..74bc7ed --- /dev/null +++ b/src/app/api/ipa/route.ts @@ -0,0 +1,64 @@ +import { callZhipuAPI } from "@/utils"; +import { NextRequest, NextResponse } from "next/server"; + +async function getIPA(text: string) { + console.log(`get ipa of ${text}`); + const messages = [ + { + role: 'user', content: ` +请推断以下文本的语言,生成对应的宽式国际音标(IPA)以及locale,以JSON格式返回 +[${text}] +结果如: +{ + "ipa": "[ni˨˩˦ xɑʊ˨˩˦]", + "locale": "zh-CN" +} +注意: +直接返回json文本, +ipa一定要加[], +locale如果可能有多个,选取最可能的一个,其中使用符号"-", +locale如果推断失败,就返回{"locale": "en-US"} +` + }]; + try { + const response = await callZhipuAPI(messages); + let to_parse = response.choices[0].message.content.trim() as string; + if (to_parse.startsWith('`')) to_parse = to_parse.slice(7, to_parse.length - 3); + if (to_parse.length === 0) throw Error('ai啥也每说'); + return JSON.parse(to_parse); + } catch (error) { + console.error(error); + return null; + } +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const text = searchParams.get('text'); + + if (!text) { + return NextResponse.json( + { error: "查询参数错误", message: "text参数是必需的" }, + { status: 400 } + ); + } + + const textInfo = await getIPA(text); + if (!textInfo) { + return NextResponse.json( + { error: "服务暂时不可用", message: "LLM API 请求失败" }, + { status: 503 } + ); + } + + return NextResponse.json(textInfo, { status: 200 }); + + } catch (error) { + console.error('API 错误:', error); + return NextResponse.json( + { error: "服务器内部错误", message: "请稍后重试" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/locale/route.ts b/src/app/api/locale/route.ts new file mode 100644 index 0000000..756e684 --- /dev/null +++ b/src/app/api/locale/route.ts @@ -0,0 +1,62 @@ +import { callZhipuAPI } from "@/utils"; +import { NextRequest, NextResponse } from "next/server"; + +async function getLocale(text: string) { + console.log(`get locale of ${text}`); + const messages = [ + { + role: 'user', content: ` +请推断以下文本的的locale,以JSON格式返回 +[${text}] +结果如: +{ + "locale": "zh-CN" +} +注意: +直接返回json文本, +locale如果可能有多个,选取最可能的一个,其中使用符号"-", +locale如果推断失败,就返回{"locale": "en-US"} +` + }]; + try { + const response = await callZhipuAPI(messages); + let to_parse = response.choices[0].message.content.trim() as string; + if (to_parse.startsWith('`')) to_parse = to_parse.slice(7, to_parse.length - 3); + if (to_parse.length === 0) throw Error('ai啥也每说'); + return JSON.parse(to_parse); + } catch (error) { + console.error(error); + return null; + } +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const text = searchParams.get('text'); + + if (!text) { + return NextResponse.json( + { error: "查询参数错误", message: "text参数是必需的" }, + { status: 400 } + ); + } + + const textInfo = await getLocale(text.slice(0, 30)); + if (!textInfo) { + return NextResponse.json( + { error: "服务暂时不可用", message: "LLM API 请求失败" }, + { status: 503 } + ); + } + + return NextResponse.json(textInfo, { status: 200 }); + + } catch (error) { + console.error('API 错误:', error); + return NextResponse.json( + { error: "服务器内部错误", message: "请稍后重试" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/translate/route.ts b/src/app/api/translate/route.ts index bc9e2d0..ef19cc3 100644 --- a/src/app/api/translate/route.ts +++ b/src/app/api/translate/route.ts @@ -6,28 +6,18 @@ async function translate(text: string, target_lang: string) { const messages = [ { role: 'user', content: ` -请推断以下文本的语言、locale,生成宽式国际音标(IPA),并翻译到${target_lang},同样需要语言、locale、IPA信息,以JSON格式返回 +请推断以下文本的语言、locale,并翻译到目标语言[${target_lang}],同样需要locale信息,以JSON格式返回 [${text}] 结果如: { - "source": { - "text": "你好。", - "lang": "mandarin", - "ipa": "[ni˨˩˦ xɑʊ˨˩˦]", - "locale": "zh-CN" - }, - "target": { - "text": "Hallo.", - "lang": "german", - "ipa": " [haˈloː]", - "locale": "de-DE" - } + "source_locale": "zh-CN", + "target_locale": "de-DE", + "target_text": "Halo" } 注意: 直接返回json文本, -ipa一定要加[], -lang的值是小写字母的英文的语言名称, -locale如果可能有多个,选取最可能的一个,其中使用符号"-" +locale如果可能有多个,选取最可能的一个,其中使用符号"-", +locale如果推断失败,就当作是en-US ` }]; try { diff --git a/src/app/text-speaker/page.tsx b/src/app/text-speaker/page.tsx index f8e5c52..a6968db 100644 --- a/src/app/text-speaker/page.tsx +++ b/src/app/text-speaker/page.tsx @@ -1,5 +1,6 @@ "use client"; +import Button from "@/components/Button"; import IconClick from "@/components/IconClick"; import IMAGES from "@/config/images"; import { useAudioPlayer } from "@/hooks/useAudioPlayer"; @@ -7,12 +8,13 @@ import { getTTSAudioUrl } from "@/utils"; import { ChangeEvent, useEffect, useRef, useState } from "react"; export default function Home() { + const [ipaEnabled, setIPAEnabled] = useState(false); const [speed, setSpeed] = useState(1); const [pause, setPause] = useState(true); const [autopause, setAutopause] = useState(true); const textRef = useRef(''); const localeRef = useRef(null); - const [ipa, setIPA] = useState(null); + const [ipa, setIPA] = useState(''); const objurlRef = useRef(null); const [processing, setProcessing] = useState(false); @@ -44,7 +46,7 @@ export default function Home() { return () => { audio.removeEventListener('ended', handleEnded); }; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [audioRef, autopause]); @@ -56,6 +58,20 @@ export default function Home() { if (processing) return; setProcessing(true); + if (ipa.length === 0 && ipaEnabled && textRef.current.length !== 0) { + const params = new URLSearchParams({ + text: textRef.current + }); + fetch(`/api/ipa?${params}`) + .then(res => res.json()) + .then(data => { + setIPA(data.ipa); + }).catch(e => { + console.error(e); + setIPA(''); + }) + } + if (pause) { // 如果没在读 if (textRef.current.length === 0) { @@ -70,12 +86,11 @@ export default function Home() { // 第一次播放 console.log('downloading text info'); const params = new URLSearchParams({ - text: textRef.current + text: textRef.current.slice(0, 30) }); try { - const textinfo = await (await fetch(`/api/textinfo?${params}`)).json(); + const textinfo = await (await fetch(`/api/locale?${params}`)).json(); localeRef.current = textinfo.locale; - setIPA(textinfo.ipa); const voice = voicesData.find(v => v.locale.startsWith(localeRef.current!)); if (!voice) throw 'Voice not found.'; @@ -98,7 +113,6 @@ export default function Home() { setPause(true); localeRef.current = null; - setIPA(null); setProcessing(false); } @@ -116,7 +130,7 @@ export default function Home() { const handleInputChange = (e: ChangeEvent) => { textRef.current = e.target.value.trim(); localeRef.current = null; - setIPA(null); + setIPA(''); if (objurlRef.current) URL.revokeObjectURL(objurlRef.current); objurlRef.current = null; stopAudio(); @@ -142,6 +156,7 @@ export default function Home() { {ipa}
+ { setAutopause(!autopause); if (objurlRef) { stopAudio(); } setPause(true); }} src={ diff --git a/src/app/translator/page.tsx b/src/app/translator/page.tsx index 210eeba..5c3d854 100644 --- a/src/app/translator/page.tsx +++ b/src/app/translator/page.tsx @@ -8,40 +8,21 @@ import IMAGES from "@/config/images"; import { getTTSAudioUrl } from "@/utils"; export default function Home() { + const [ipaEnabled, setIPAEnabled] = useState(true); const [voicesData, setVoicesData] = useState<{ locale: string, short_name: string }[] | null>(null); const [loading, setLoading] = useState(true); const [targetLang, setTargetLang] = useState('Italian'); - const nullTextInfo = { - source: { - text: null, - language: null, - ipa: null, - locale: null - }, - target: { - text: null, - language: null, - ipa: null, - locale: null - } - }; - const [textInfo, setTextInfo] = useState<{ - source: { - text: string | null, - language: string | null, - ipa: string | null, - locale: string | null - }, - target: { - text: string | null, - language: string | null, - ipa: string | null, - locale: string | null - } - }>(nullTextInfo); + + const [sourceText, setSourceText] = useState(''); + const [targetText, setTargetText] = useState(''); + const [sourceIPA, setSourceIPA] = useState(''); + const [targetIPA, setTargetIPA] = useState(''); + const [sourceLocale, setSourceLocale] = useState(null); + const [targetLocale, setTargetLocale] = useState(null); + const [translating, setTranslating] = useState(false); const { playAudio } = useAudioPlayer(); @@ -66,68 +47,140 @@ export default function Home() { const translate = () => { if (translating) return; - if (!textInfo.source.text || textInfo.source.text.length === 0) return; + if (sourceText.length === 0) return; setTranslating(true); + setTargetText(''); + setSourceLocale(null); + setTargetLocale(null); + setSourceIPA(''); + setTargetIPA(''); + const params = new URLSearchParams({ - text: textInfo.source.text, + text: sourceText, target: targetLang }) fetch(`/api/translate?${params}`) .then(res => res.json()) - .then(setTextInfo) - .finally(() => setTranslating(false)); + .then(obj => { + setSourceLocale(obj.source_locale); + setTargetLocale(obj.target_locale); + setTargetText(obj.target_text); + + if (ipaEnabled) { + const params = new URLSearchParams({ + text: sourceText + }); + fetch(`/api/ipa?${params}`) + .then(res => res.json()) + .then(data => { + setSourceIPA(data.ipa); + }).catch(e => { + console.error(e); + setSourceIPA(''); + }) + const params2 = new URLSearchParams({ + text: obj.target_text + }); + fetch(`/api/ipa?${params2}`) + .then(res => res.json()) + .then(data => { + setTargetIPA(data.ipa); + }).catch(e => { + console.error(e); + setTargetIPA(''); + }) + } + }).catch(r => { + console.error(r); + setSourceLocale(''); + setTargetLocale(''); + setTargetText(''); + }).finally(() => setTranslating(false)); } const handleInputChange = (e: ChangeEvent) => { - setTextInfo({ - source: { - text: e.target.value.trim(), - language: null, - ipa: null, - locale: null - }, - target: { - text: null, - language: null, - ipa: null, - locale: null - } - }); + setSourceText(e.target.value.trim()); + setTargetText(''); + setSourceLocale(null); + setTargetLocale(null); + setSourceIPA(''); + setTargetIPA(''); } const readSource = async () => { - if (!textInfo.source.text || textInfo.source.text.length === 0) return; + if (sourceText.length === 0) return; - if (!textInfo.source.locale) { - const params = new URLSearchParams({ text: textInfo.source.text }); - const res = await fetch(`/api/textinfo?${params}`); - const info = await res.json(); - setTextInfo( - { - source: info, - target: { ...textInfo.target } + if (sourceIPA.length === 0 && ipaEnabled) { + const params = new URLSearchParams({ + text: sourceText + }); + fetch(`/api/ipa?${params}`) + .then(res => res.json()) + .then(data => { + setSourceIPA(data.ipa); + }).catch(e => { + console.error(e); + setSourceIPA(''); + }) + } + + if (!sourceLocale) { + try { + const params = new URLSearchParams({ + text: sourceText.slice(0, 30) + }); + const res = await fetch(`/api/locale?${params}`); + const info = await res.json(); + setSourceLocale(info.locale); + + const voice = voicesData.find(v => v.locale.startsWith(info.locale)); + if (!voice) { + return; } - ); - } - const voice = voicesData.find(v => v.locale.startsWith(textInfo.source.locale!)); - if (!voice) { - return; - } - const url = await getTTSAudioUrl(textInfo.source.text, voice.short_name); - await playAudio(url); - URL.revokeObjectURL(url); + const url = await getTTSAudioUrl(sourceText, voice.short_name); + await playAudio(url); + URL.revokeObjectURL(url); + } catch (e) { + console.error(e); + setSourceLocale(null); + return; + } + } else { + const voice = voicesData.find(v => v.locale.startsWith(sourceLocale!)); + if (!voice) { + return; + } + + const url = await getTTSAudioUrl(sourceText, voice.short_name); + await playAudio(url); + URL.revokeObjectURL(url); + } } const readTarget = async () => { - if (!textInfo.target.text || textInfo.target.text.length === 0) return; + if (targetText.length === 0) return; - const voice = voicesData.find(v => v.locale.startsWith(textInfo.target.locale!)); + if (targetIPA.length === 0 && ipaEnabled) { + const params = new URLSearchParams({ + text: targetText + }); + fetch(`/api/ipa?${params}`) + .then(res => res.json()) + .then(data => { + setTargetIPA(data.ipa); + }).catch(e => { + console.error(e); + setTargetIPA(''); + }) + } + + const voice = voicesData.find(v => v.locale.startsWith(targetLocale!)); if (!voice) return; - const url = await getTTSAudioUrl(textInfo.target.text, voice.short_name); + const url = await getTTSAudioUrl(targetText, voice.short_name); await playAudio(url); URL.revokeObjectURL(url); } @@ -139,32 +192,33 @@ export default function Home() {
- {textInfo.source.ipa || ''} + {sourceIPA}
{ - if (textInfo.source.text && textInfo.source.text.length !== 0) - await navigator.clipboard.writeText(textInfo.source.text); + if (sourceText.length !== 0) + await navigator.clipboard.writeText(sourceText); }} src={IMAGES.copy_all} alt="copy">
-
+
detect language +
{ - textInfo.target.text || '' + targetText }
- {textInfo.target.ipa || ''} + {targetIPA}
{ - if (textInfo.target.text && textInfo.target.text.length !== 0) - await navigator.clipboard.writeText(textInfo.target.text); + if (targetText.length !== 0) + await navigator.clipboard.writeText(targetText); }} src={IMAGES.copy_all} alt="copy">