diff --git a/public/changelog.txt b/public/changelog.txt index c2f52f9..8b3b890 100644 --- a/public/changelog.txt +++ b/public/changelog.txt @@ -1,4 +1,5 @@ -2025.10.06 更新了主页面UI +2025.10.07 新增文本朗读器 +2025.10.06 更新了主页面UI,移除IPA生成与文本朗读功能,新增全语言翻译器 2025.10.05 新增IPA生成与文本朗读功能 2025.09.25 优化了主界面UI 2025.09.19 更新了单词板,单词不再会重叠。 diff --git a/public/images/1x_mobiledata_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg b/public/images/1x_mobiledata_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg new file mode 100644 index 0000000..31b5f83 --- /dev/null +++ b/public/images/1x_mobiledata_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/autopause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg b/public/images/autopause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg new file mode 100644 index 0000000..6013cb0 --- /dev/null +++ b/public/images/autopause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/autoplay_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg b/public/images/autoplay_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg new file mode 100644 index 0000000..c6cde8e --- /dev/null +++ b/public/images/autoplay_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg b/public/images/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg similarity index 100% rename from public/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg rename to public/images/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg diff --git a/public/images/pause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg b/public/images/pause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg new file mode 100644 index 0000000..19bf4e5 --- /dev/null +++ b/public/images/pause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/play_arrow_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg b/public/images/play_arrow_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg similarity index 100% rename from public/play_arrow_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg rename to public/images/play_arrow_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg diff --git a/public/images/speed_0_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg b/public/images/speed_0_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg new file mode 100644 index 0000000..4862ed9 --- /dev/null +++ b/public/images/speed_0_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/speed_0_7x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg b/public/images/speed_0_7x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg new file mode 100644 index 0000000..f96d563 --- /dev/null +++ b/public/images/speed_0_7x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/speed_1_2x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg b/public/images/speed_1_2x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg new file mode 100644 index 0000000..dbe8d12 --- /dev/null +++ b/public/images/speed_1_2x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/speed_1_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg b/public/images/speed_1_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg new file mode 100644 index 0000000..1281ee4 --- /dev/null +++ b/public/images/speed_1_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index dab4a2b..018f4fb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -47,6 +47,11 @@ function LinkGrid() { name="翻译器" description="翻译到任何语言,并标注国际音标(IPA)" color="#a56068"> + + color="#cab48a"> ) } diff --git a/src/app/text-speaker/page.tsx b/src/app/text-speaker/page.tsx new file mode 100644 index 0000000..f8e5c52 --- /dev/null +++ b/src/app/text-speaker/page.tsx @@ -0,0 +1,182 @@ +"use client"; + +import IconClick from "@/components/IconClick"; +import IMAGES from "@/config/images"; +import { useAudioPlayer } from "@/hooks/useAudioPlayer"; +import { getTTSAudioUrl } from "@/utils"; +import { ChangeEvent, useEffect, useRef, useState } from "react"; + +export default function Home() { + 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 objurlRef = useRef(null); + const [processing, setProcessing] = useState(false); + + const [voicesData, setVoicesData] = useState<{ + locale: string, + short_name: string + }[] | null>(null); + const [loading, setLoading] = useState(true); + const { playAudio, stopAudio, audioRef } = useAudioPlayer(); + useEffect(() => { + fetch('/list_of_voices.json') + .then(res => res.json()) + .then(setVoicesData) + .catch(() => setVoicesData(null)) + .finally(() => setLoading(false)); + }, []); + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + + const handleEnded = () => { + if (autopause) { + setPause(true); + } else { + playAudio(objurlRef.current!); + } + } + audio.addEventListener('ended', handleEnded); + return () => { + audio.removeEventListener('ended', handleEnded); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [audioRef, autopause]); + + + if (loading) return
加载中...
; + if (!voicesData) return
加载失败
; + + + const speak = async () => { + if (processing) return; + setProcessing(true); + + if (pause) { + // 如果没在读 + if (textRef.current.length === 0) { + // 没文本咋读 + } else { + setPause(false); + + if (objurlRef.current) { + // 之前有播放 + playAudio(objurlRef.current); + } else { + // 第一次播放 + console.log('downloading text info'); + const params = new URLSearchParams({ + text: textRef.current + }); + try { + const textinfo = await (await fetch(`/api/textinfo?${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.'; + + objurlRef.current = await getTTSAudioUrl( + textRef.current, + voice.short_name, + (() => { + if (speed === 1) return {}; + else if (speed < 1) return { + rate: `-${100 - speed * 100}%` + }; else return { + rate: `+${speed * 100 - 100}%` + }; + })() + ); + playAudio(objurlRef.current); + } catch (e) { + console.error(e); + + setPause(true); + localeRef.current = null; + setIPA(null); + + setProcessing(false); + } + } + } + } else { + // 如果在读就暂停 + setPause(true); + stopAudio(); + } + + setProcessing(false); + } + + const handleInputChange = (e: ChangeEvent) => { + textRef.current = e.target.value.trim(); + localeRef.current = null; + setIPA(null); + if (objurlRef.current) URL.revokeObjectURL(objurlRef.current); + objurlRef.current = null; + stopAudio(); + setPause(true); + } + + const letMeSetSpeed = (new_speed: number) => { + return () => { + setSpeed(new_speed); + if (objurlRef.current) URL.revokeObjectURL(objurlRef.current); + objurlRef.current = null; + stopAudio(); + setPause(true); + } + } + + return (<> +
+ +
+ {ipa} +
+
+ { + setAutopause(!autopause); if (objurlRef) { stopAudio(); } setPause(true); + }} src={ + autopause ? IMAGES.autoplay : IMAGES.autopause + } alt="autoplayorpause" + > + + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/translator/IPAForm.tsx b/src/app/translator/IPAForm.tsx deleted file mode 100644 index 08d8a95..0000000 --- a/src/app/translator/IPAForm.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import Button from "@/components/Button"; -import { EdgeTTS } from "edge-tts-universal"; -import { useRef, useState } from "react"; -import { useAudioPlayer } from "@/hooks/useAudioPlayer"; - -export default function IPAForm( - { voicesData }: { - voicesData: { - locale: string, - short_name: string - }[] - } -) { - const respref = useRef(null); - const inputref = useRef(null); - const [reqEnabled, setReqEnabled] = useState(true); - const [textInfo, setTextInfo] = useState<{ - lang: string, - ipa: string, - locale: string, - text: string - } | null>(null); - const { playAudio, pauseAudio, stopAudio } = useAudioPlayer(); - const readIPA = async () => { - if (!textInfo) { - respref.current!.innerText = '请先生成IPA。'; - return; - } - const voice = voicesData.find(v => v.locale.startsWith(textInfo.locale)); - if (!voice) { - respref.current!.innerText = '暂不支持朗读' + textInfo.lang; - return; - } - const tts = new EdgeTTS(textInfo.text, voice.short_name); - const result = await tts.synthesize(); - playAudio(URL.createObjectURL(result.audio)); - } - const generateIPA = () => { - if (!reqEnabled) return; - setReqEnabled(false); - - respref.current!.innerText = '生成国际音标中,请稍等~'; - let timer: NodeJS.Timeout; - (() => { - let count = 0; - timer = setInterval(() => { - respref.current!.innerText = '正在生成国际音标(IPA),请稍等~'; - respref.current!.innerText += `\n(waiting for ${++count}s)` - }, 1000); - })(); - - const text = inputref.current!.value.trim(); - if (text.length === 0) return; - - const params = new URLSearchParams({ text: text }); - fetch(`/api/ipa?${params}`) - .then(response => { - if (!response.ok) { - return response.json().then(resj => { - throw new Error(`HTTP ${response.status}: ${resj.error} ${resj.message}`); - }) - } - return response.json(); - }) - .then(data => { - setTextInfo({ ...data, text: text }); - respref.current!.innerText = `LANG: ${data.lang}\nIPA: ${data.ipa}`; - }) - .catch(error => { - respref.current!.innerText = `错误: ${error.message}`; - }) - .finally(() => { - setReqEnabled(true); - clearInterval(timer); - }); - } - - return (<> -
- -
-
- - -
-
- ) -}; \ No newline at end of file diff --git a/src/app/translator/page.tsx b/src/app/translator/page.tsx index 1546ace..210eeba 100644 --- a/src/app/translator/page.tsx +++ b/src/app/translator/page.tsx @@ -4,7 +4,8 @@ import { ChangeEvent, useEffect, useState } from "react"; import Button from "@/components/Button"; import IconClick from "@/components/IconClick"; import { useAudioPlayer } from "@/hooks/useAudioPlayer"; -import { EdgeTTS } from "edge-tts-universal/browser"; +import IMAGES from "@/config/images"; +import { getTTSAudioUrl } from "@/utils"; export default function Home() { const [voicesData, setVoicesData] = useState<{ @@ -82,7 +83,7 @@ export default function Home() { const handleInputChange = (e: ChangeEvent) => { setTextInfo({ source: { - text: e.target.value, + text: e.target.value.trim(), language: null, ipa: null, locale: null @@ -114,20 +115,21 @@ export default function Home() { if (!voice) { return; } - const tts = new EdgeTTS(textInfo.source.text, voice.short_name); - const result = await tts.synthesize(); - playAudio(URL.createObjectURL(result.audio)); + + const url = await getTTSAudioUrl(textInfo.source.text, voice.short_name); + await playAudio(url); + URL.revokeObjectURL(url); } + const readTarget = async () => { if (!textInfo.target.text || textInfo.target.text.length === 0) return; const voice = voicesData.find(v => v.locale.startsWith(textInfo.target.locale!)); - if (!voice) { - return; - } - const tts = new EdgeTTS(textInfo.target.text, voice.short_name); - const result = await tts.synthesize(); - playAudio(URL.createObjectURL(result.audio)); + if (!voice) return; + + const url = await getTTSAudioUrl(textInfo.target.text, voice.short_name); + await playAudio(url); + URL.revokeObjectURL(url); } return ( @@ -135,16 +137,16 @@ export default function Home() {
- -
+ +
{textInfo.source.ipa || ''}
{ if (textInfo.source.text && textInfo.source.text.length !== 0) await navigator.clipboard.writeText(textInfo.source.text); - }} src={'/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg'} alt="copy"> - + }} src={IMAGES.copy_all} alt="copy"> +
@@ -153,18 +155,18 @@ export default function Home() {
-
{ +
{ textInfo.target.text || '' }
-
+
{textInfo.target.ipa || ''}
{ if (textInfo.target.text && textInfo.target.text.length !== 0) await navigator.clipboard.writeText(textInfo.target.text); - }} src={'/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg'} alt="copy"> - + }} src={IMAGES.copy_all} alt="copy"> +
diff --git a/src/components/IconClick.tsx b/src/components/IconClick.tsx index 1a5b2d1..ebad2b6 100644 --- a/src/components/IconClick.tsx +++ b/src/components/IconClick.tsx @@ -2,20 +2,22 @@ import Image from "next/image"; interface IconClickProps { - src: string; - alt: string; - onClick?: () => void; + src: string; + alt: string; + onClick?: () => void; + className?: string; + size?: number } export default function IconClick( - { src, alt, onClick = () => { } }: IconClickProps) { - return (<> -
- {alt} -
- ); + { src, alt, onClick = () => { }, className = '', size = 32 }: IconClickProps) { + return (<> +
+ {alt} +
+ ); } diff --git a/src/config/images.ts b/src/config/images.ts new file mode 100644 index 0000000..18b7f5c --- /dev/null +++ b/src/config/images.ts @@ -0,0 +1,14 @@ +const IMAGES = { + speed_1_5x: '/images/speed_1_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', + speed_1_2_x: '/images/speed_1_2x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', + speed_0_7x: '/images/speed_0_7x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', + pause: '/images/pause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', + speed_0_5x: '/images/speed_0_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', + copy_all: '/images/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', + autoplay: '/images/autoplay_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', + autopause: '/images/autopause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', + speed_1x: '/images/1x_mobiledata_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', + play_arrow: '/images/play_arrow_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg' +} + +export default IMAGES; \ No newline at end of file diff --git a/src/hooks/useAudioPlayer.ts b/src/hooks/useAudioPlayer.ts index 29b0536..0fa89b7 100644 --- a/src/hooks/useAudioPlayer.ts +++ b/src/hooks/useAudioPlayer.ts @@ -5,36 +5,30 @@ export function useAudioPlayer() { const audioRef = useRef(null); useEffect(() => { audioRef.current = new Audio(); - return () => { - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current = null; - } + audioRef.current!.pause(); + audioRef.current = null; }; }, []); - const playAudio = (audioUrl: string) => { - if (audioRef.current) { - audioRef.current.src = audioUrl; - audioRef.current.play().catch(error => { - console.error('播放失败:', error); - }); + const playAudio = async (audioUrl: string) => { + audioRef.current!.src = audioUrl; + try { + await audioRef.current!.play(); + } catch (e) { + return e; } }; const pauseAudio = () => { - if (audioRef.current) { - audioRef.current.pause(); - } + audioRef.current!.pause(); }; const stopAudio = () => { - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current.currentTime = 0; - } + audioRef.current!.pause(); + audioRef.current!.currentTime = 0; }; return { playAudio, pauseAudio, - stopAudio + stopAudio, + audioRef }; } diff --git a/src/utils.ts b/src/utils.ts index 4c82ffe..1a068c3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,4 @@ +import { EdgeTTS, ProsodyOptions } from "edge-tts-universal/browser"; import { env } from "process"; export function inspect(word: string) { @@ -40,3 +41,12 @@ export async function callZhipuAPI(messages: { role: string; content: string; }[ return await response.json(); } +export async function getTTSAudioUrl(text: string, short_name: string, options: ProsodyOptions | undefined = undefined) { + const tts = new EdgeTTS(text, short_name, options); + try { + const result = await tts.synthesize(); + return URL.createObjectURL(result.audio); + } catch (e) { + throw e; + } +} \ No newline at end of file