├── .env.example ├── .eslintrc.json ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── README_CN.md ├── app ├── [lang] │ ├── layout.tsx │ ├── overlay-scrollbar.tsx │ ├── page.tsx │ ├── providers.tsx │ └── ui │ │ ├── components │ │ ├── config-slider.tsx │ │ ├── export-import-setting-button.tsx │ │ ├── icon-button.tsx │ │ ├── import-text-button.tsx │ │ ├── language-select.tsx │ │ ├── stop-time-button.tsx │ │ └── theme-toggle.tsx │ │ ├── content.tsx │ │ └── nav.tsx ├── api │ ├── audio │ │ └── route.ts │ ├── list │ │ └── fetch-list.ts │ └── token │ │ └── fetch-token.ts ├── icons │ ├── github.svg │ ├── language.svg │ └── logo.png └── lib │ ├── constants.ts │ ├── hooks │ └── use-theme.ts │ ├── i18n │ ├── get-locale.ts │ └── i18n-config.ts │ ├── tools.ts │ └── types.ts ├── commitlint.config.ts ├── locales ├── cn.json └── en.json ├── middleware.ts ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── mstile-150x150.png ├── safari-pinned-tab.svg └── site.webmanifest ├── styles ├── globals.css └── theme-button.css ├── tailwind.config.ts ├── tsconfig.json ├── typings.d.ts └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | # Your Azure key 2 | SPEECH_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxx 3 | # Your Azure region 4 | SPEECH_REGION=southeastasia 5 | # Maximum input length limit (optional) 6 | NEXT_PUBLIC_MAX_INPUT_LENGTH=4000 -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "plugin:@typescript-eslint/recommended", 5 | "plugin:prettier/recommended", 6 | "eslint-config-prettier" 7 | ], 8 | "plugins": ["prettier"], 9 | "rules": { 10 | "@typescript-eslint/no-explicit-any": "off", // 允许使用any 11 | "@typescript-eslint/ban-ts-comment": "off", // 允许使用@ts-ignore 12 | "@typescript-eslint/no-non-null-assertion": "off", // 允许使用非空断言 13 | "@typescript-eslint/no-var-requires": "off", // 允许使用CommonJS的写法 14 | "@typescript-eslint/no-unused-vars": "warn", // 允许未使用的变量 15 | "no-debugger": "warn", 16 | "import/order": [ 17 | "error", 18 | { 19 | // 按照分组顺序进行排序 20 | "groups": ["builtin", "external", "parent", "sibling", "index", "internal", "object", "type"], 21 | // 通过路径自定义分组 22 | "pathGroups": [ 23 | { 24 | "pattern": "react*", 25 | "group": "builtin", 26 | "position": "before" 27 | }, 28 | { 29 | "pattern": "@/components/**", 30 | "group": "parent", 31 | "position": "before" 32 | }, 33 | { 34 | "pattern": "@/utils/**", 35 | "group": "parent", 36 | "position": "after" 37 | }, 38 | { 39 | "pattern": "@/apis/**", 40 | "group": "parent", 41 | "position": "after" 42 | } 43 | ], 44 | "pathGroupsExcludedImportTypes": ["react"], 45 | "newlines-between": "never", // 每个分组之间换行 46 | // 根据字母顺序对每个组内的顺序进行排序 47 | "alphabetize": { 48 | "order": "asc", 49 | "caseInsensitive": true 50 | } 51 | } 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=*@nextui-org/* -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /.next/ 2 | /node_modules 3 | .env*.local -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "auto", 3 | "printWidth": 120, 4 | "semi": false, 5 | "singleQuote": true, 6 | "tabWidth": 2, 7 | "trailingComma": "all", 8 | "bracketSpacing": true, 9 | "arrowParens": "avoid" 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "bradlc.vscode-tailwindcss"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["Apim", "cognitiveservices", "overlayscrollbars", "sonner", "SSML"] 3 | } 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine AS base 2 | 3 | # Install dependencies only when needed 4 | FROM base AS deps 5 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 6 | RUN apk add --no-cache libc6-compat 7 | WORKDIR /app 8 | 9 | # Install dependencies based on the preferred package manager 10 | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ 11 | RUN \ 12 | if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ 13 | elif [ -f package-lock.json ]; then npm ci; \ 14 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ 15 | else echo "Lockfile not found." && exit 1; \ 16 | fi 17 | 18 | 19 | # Rebuild the source code only when needed 20 | FROM base AS builder 21 | WORKDIR /app 22 | COPY --from=deps /app/node_modules ./node_modules 23 | COPY . . 24 | 25 | # Next.js collects completely anonymous telemetry data about general usage. 26 | # Learn more here: https://nextjs.org/telemetry 27 | # Uncomment the following line in case you want to disable telemetry during the build. 28 | # ENV NEXT_TELEMETRY_DISABLED 1 29 | 30 | RUN \ 31 | if [ -f yarn.lock ]; then yarn run build; \ 32 | elif [ -f package-lock.json ]; then npm run build; \ 33 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ 34 | else echo "Lockfile not found." && exit 1; \ 35 | fi 36 | 37 | # Production image, copy all the files and run next 38 | FROM base AS runner 39 | WORKDIR /app 40 | 41 | ENV NODE_ENV production 42 | # Uncomment the following line in case you want to disable telemetry during runtime. 43 | # ENV NEXT_TELEMETRY_DISABLED 1 44 | 45 | RUN addgroup --system --gid 1001 nodejs 46 | RUN adduser --system --uid 1001 nextjs 47 | 48 | COPY --from=builder /app/public ./public 49 | 50 | # Set the correct permission for prerender cache 51 | RUN mkdir .next 52 | RUN chown nextjs:nodejs .next 53 | 54 | # Automatically leverage output traces to reduce image size 55 | # https://nextjs.org/docs/advanced-features/output-file-tracing 56 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 57 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 58 | 59 | USER nextjs 60 | 61 | EXPOSE 3000 62 | 63 | ENV PORT 3000 64 | 65 | # server.js is created by next build from the standalone output 66 | # https://nextjs.org/docs/pages/api-reference/next-config-js/output 67 | CMD HOSTNAME="0.0.0.0" node server.js 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Femoon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TTS Azure Web 2 | 3 | English / [简体中文](./README_CN.md) 4 | 5 | TTS Azure Web is an Azure Text-to-Speech (TTS) web application. Fine-tune the output speech results using Speech Synthesis Markup Language (SSML). It allows you to run it locally or deploy it with a single click using your Azure Key. 6 | 7 | Key Features: 8 | 9 | - Supports selection of voice, language, style, and character. 10 | - Supports adjustments of speech speed, intonation, and volume. 11 | - Supports audio output download. 12 | - One-click deployment for both local and cloud environments. 13 | - Supports SSML config import and export. 14 | 15 | This application is ideal for those looking to minimize setup while experiencing the full capabilities of Azure TTS. 16 | 17 | Live demo: https://tts.femoon.top 18 | 19 | ## Getting Started 20 | 21 | Get your API Key 22 | 23 | - Go to [Microsoft Azure Text to Speech](https://azure.microsoft.com/en-us/products/ai-services/text-to-speech/) and click "Try Text to Speech Free" 24 | - Go to [Azure AI services](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices) 25 | - In the "Speech services" block, click "Add" 26 | - A region and two subscription keys will be listed beside Speech Services. You only need one key and its corresponding region. 27 | 28 | ## Deploy on Vercel 29 | 30 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FFemoon%2Ftts-azure-web&env=SPEECH_KEY&env=SPEECH_REGION&project-name=tts-azure-web&repository-name=tts-azure-web) 31 | 32 | ## Deploy locally 33 | 34 | ```bash 35 | # install yarn 36 | npm i -g yarn 37 | # install dependencies 38 | yarn 39 | # building the production environment 40 | yarn build 41 | # run production environment serve 42 | yarn start 43 | ``` 44 | 45 | ## Development 46 | 47 | Before starting development, you must create a new `.env.local` file at project root, and place your azure key and region into it: 48 | 49 | ```bash 50 | # your azure key (required) 51 | SPEECH_KEY=your_azure_key 52 | # your azure region (required) 53 | SPEECH_REGION=your_azure_region 54 | # Maximum input length limit (optional) 55 | NEXT_PUBLIC_MAX_INPUT_LENGTH=4000 56 | ``` 57 | 58 | Run the development server: 59 | 60 | ```bash 61 | # install yarn 62 | npm i -g yarn 63 | # install dependencies 64 | yarn 65 | # run serve 66 | yarn dev 67 | ``` 68 | 69 | Open [http://localhost:3000](http://localhost:3000/) with your browser to see the result. 70 | 71 | ## Git commit specification reference 72 | 73 | - `feat` add new functions 74 | - `fix` Fix issues/bugs 75 | - `perf` Optimize performance 76 | - `style` Change the code style without affecting the running result 77 | - `refactor` Re-factor code 78 | - `revert` Undo changes 79 | - `test` Test related, does not involve changes to business code 80 | - `docs` Documentation and Annotation 81 | - `chore` Updating dependencies/modifying scaffolding configuration, etc. 82 | - `ci` CI/CD 83 | -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 | # TTS Azure Web 2 | 3 | [English](./README.md) / 简体中文 4 | 5 | TTS Azure Web 是一个 Azure 文本转语音(TTS)网页应用。通过语音合成标记语言 (SSML) 对输出语音结果微调,可以在本地运行或使用你的 Azure Key 一键部署。 6 | 7 | 主要特性: 8 | 9 | - 支持选择语音、语言、风格和角色 10 | - 支持语速、语调、音量的调节 11 | - 支持输出音频下载 12 | - 本地和云端一键部署。 13 | - 支持导入/导出 SSML 配置 14 | 15 | 该项目适合那些希望在体验 Azure TTS 全功能的同时最小化设置工作的用户。 16 | 17 | 在线演示: [https://tts.femoon.top/cn](https://tts.femoon.top/cn) 18 | 19 | ## 入门指南 20 | 21 | 获取你的 API 密钥 22 | 23 | - 需要一张 VISA 卡 24 | - 访问 [Microsoft Azure 文本转语音](https://azure.microsoft.com/zh-cn/products/ai-services/text-to-speech) 并点击“免费试用文本转语音” 25 | - 访问 [Azure AI services](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices) 26 | - 在“语音服务”块中,点击“创建” 27 | - 创建成功后,在语音服务旁边将列出一个区域和两个订阅 Key 。你只需一个 Key 及其对应的区域 28 | 29 | 具体可以参考 [Bob](https://github.com/ripperhe/Bob) 官方申请 Azure TTS 的[图文教程](https://bobtranslate.com/service/tts/microsoft.html),流程只需要到**获取完密钥**就可以了。 30 | 31 | ## 在 Vercel 上一键部署 32 | 33 | [![使用 Vercel 部署](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FFemoon%2Ftts-azure-web&env=SPEECH_KEY&env=SPEECH_REGION&project-name=tts-azure-web&repository-name=tts-azure-web) 34 | 35 | ## 在本地一键部署 36 | 37 | ```bash 38 | # 安装 yarn 39 | npm i -g yarn 40 | # 安装依赖 41 | yarn 42 | # 构建生产环境 43 | yarn build 44 | # 运行生产环境服务 45 | yarn start 46 | ``` 47 | 48 | ## 开发 49 | 50 | 在开始开发之前,必须在项目根目录创建一个新的 `.env.local` 文件,并输入你的 Azure Key 和对应的地区: 51 | 52 | ```bash 53 | # 你的 Azure Key (必填) 54 | SPEECH_KEY=your_azure_key 55 | # 你的 Azure 地区 (必填) 56 | SPEECH_REGION=your_azure_region 57 | # 输入框最大长度限制 (可选) 58 | NEXT_PUBLIC_MAX_INPUT_LENGTH=4000 59 | ``` 60 | 61 | 本地运行开发服务器: 62 | 63 | ```bash 64 | # 安装 yarn 65 | npm i -g yarn 66 | # 安装依赖 67 | yarn 68 | # 运行服务器 69 | yarn dev 70 | ``` 71 | 72 | 使用浏览器打开 [http://localhost:3000](http://localhost:3000/) 查看结果。 73 | 74 | ## Git 提交规范参考 75 | 76 | - `feat` 增加新的业务功能 77 | - `fix` 修复业务问题/BUG 78 | - `perf` 优化性能 79 | - `style` 更改代码风格, 不影响运行结果 80 | - `refactor` 重构代码 81 | - `revert` 撤销更改 82 | - `test` 测试相关, 不涉及业务代码的更改 83 | - `docs` 文档和注释相关 84 | - `chore` 更新依赖/修改脚手架配置等琐事 85 | - `ci` 持续集成相关 86 | -------------------------------------------------------------------------------- /app/[lang]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Analytics } from '@vercel/analytics/react' 2 | import { SpeedInsights } from '@vercel/speed-insights/next' 3 | import { Metadata } from 'next' 4 | import { OverlayScrollbar } from './overlay-scrollbar' 5 | import { Providers } from './providers' 6 | import '@/styles/globals.css' 7 | import { i18n, type Locale } from '@/app/lib/i18n/i18n-config' 8 | 9 | export async function generateStaticParams() { 10 | return i18n.locales.map(locale => ({ lang: locale })) 11 | } 12 | 13 | export const metadata: Metadata = { 14 | title: 'Azure Text To Speech(TTS)', 15 | description: 'Free Azure Text To Speech(TTS)', 16 | } 17 | 18 | export default function RootLayout({ children, params }: { children: React.ReactNode; params: { lang: Locale } }) { 19 | const lang = params.lang === 'cn' ? 'zh-CN' : 'en' 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {/* Apple */} 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | {children} 41 | 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /app/[lang]/overlay-scrollbar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { useEffect } from 'react' 3 | import { OverlayScrollbars } from 'overlayscrollbars' 4 | import useTheme from '../lib/hooks/use-theme' 5 | import 'overlayscrollbars/overlayscrollbars.css' 6 | 7 | export function OverlayScrollbar() { 8 | const [theme] = useTheme() 9 | const scrollbarTheme = theme === 'light' ? 'os-theme-dark' : 'os-theme-light' 10 | useEffect(() => { 11 | if (typeof window === 'undefined') return 12 | 13 | const osInstance = OverlayScrollbars( 14 | { 15 | target: document.body, 16 | cancel: { 17 | nativeScrollbarsOverlaid: true, 18 | body: null, 19 | }, 20 | }, 21 | { 22 | scrollbars: { 23 | theme: scrollbarTheme, 24 | }, 25 | }, 26 | ) 27 | 28 | return () => { 29 | if (osInstance) { 30 | osInstance.destroy() 31 | } 32 | } 33 | }, [scrollbarTheme]) 34 | return null 35 | } 36 | -------------------------------------------------------------------------------- /app/[lang]/page.tsx: -------------------------------------------------------------------------------- 1 | import { fetchList } from '../api/list/fetch-list' 2 | import { ListItem } from '../lib/types' 3 | import Content from './ui/content' 4 | import Nav from './ui/nav' 5 | import { getLocale } from '@/app/lib/i18n/get-locale' 6 | import type { Locale } from '@/app/lib/i18n/i18n-config' 7 | 8 | export default async function Home({ params: { lang } }: { params: { lang: Locale } }) { 9 | const t = await getLocale(lang) 10 | let list: ListItem[] = [] 11 | 12 | try { 13 | const res = await fetchList() 14 | if (res.ok) { 15 | list = await res.json() 16 | } else { 17 | console.error(`Failed to fetch list: ${res.status} ${res.statusText}`) 18 | } 19 | } catch (err) { 20 | console.error('Failed to fetch list:', err) 21 | } 22 | 23 | return ( 24 |
25 |
28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /app/[lang]/providers.tsx: -------------------------------------------------------------------------------- 1 | import { NextUIProvider } from '@nextui-org/react' 2 | 3 | export function Providers({ children }: { children: React.ReactNode }) { 4 | return {children} 5 | } 6 | -------------------------------------------------------------------------------- /app/[lang]/ui/components/config-slider.tsx: -------------------------------------------------------------------------------- 1 | import { faRotateRight } from '@fortawesome/free-solid-svg-icons' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import { Slider, SliderValue } from '@nextui-org/slider' 4 | const ConfigSlider = ({ 5 | label, 6 | value, 7 | minValue, 8 | maxValue, 9 | onChange, 10 | reset, 11 | suffix = '%', 12 | }: { 13 | label: string 14 | value: number 15 | minValue: number 16 | maxValue: number 17 | onChange: (value: SliderValue) => void 18 | reset: () => void 19 | suffix?: string 20 | }) => ( 21 |
22 |
23 |
24 |

{label}

25 | 26 |
27 |

28 | {value >= 0 && '+'} 29 | {value} 30 | {suffix} 31 |

32 |
33 | 46 |
47 | ) 48 | 49 | export default ConfigSlider 50 | -------------------------------------------------------------------------------- /app/[lang]/ui/components/export-import-setting-button.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react' 2 | import { Button } from '@nextui-org/button' 3 | import { Popover, PopoverTrigger, PopoverContent } from '@nextui-org/popover' 4 | import { toast } from 'sonner' 5 | import { getFormatDate, saveAs } from '@/app/lib/tools' 6 | import { Tran } from '@/app/lib/types' 7 | 8 | export const ExportImportSettingsButton = ({ 9 | t, 10 | getExportData, 11 | importSSMLSettings, 12 | buttonIcon, 13 | }: { 14 | t: Tran 15 | getExportData: () => string 16 | importSSMLSettings: (ssml: string) => void 17 | buttonIcon: JSX.Element 18 | }) => { 19 | const [isPopoverOpen, setIsPopoverOpen] = useState(false) 20 | 21 | const handleExport = () => { 22 | const data = getExportData() 23 | const blob = new Blob([data], { type: 'text/plain' }) 24 | 25 | const fileName = `Azure-SSML-${getFormatDate(new Date())}.txt` 26 | 27 | saveAs(blob, fileName) 28 | setIsPopoverOpen(false) 29 | toast.success(t['export-ssml-settings-success']) 30 | } 31 | 32 | const handleImport = useCallback(() => { 33 | const input = document.createElement('input') 34 | input.type = 'file' 35 | input.accept = '.txt' 36 | input.onchange = event => { 37 | const file = (event.target as HTMLInputElement).files?.[0] 38 | if (file) { 39 | if (!file.name.endsWith('.txt')) { 40 | toast.error(t['import-ssml-settings-error-file-type']) 41 | return 42 | } 43 | 44 | const reader = new FileReader() 45 | reader.onload = e => { 46 | const content = e.target?.result as string 47 | importSSMLSettings(content) 48 | toast.success(t['import-ssml-settings-success']) 49 | } 50 | reader.onerror = error => { 51 | console.error('Error reading file:', error) 52 | toast.error(t['import-ssml-settings-error']) 53 | } 54 | reader.readAsText(file) 55 | } 56 | } 57 | input.click() 58 | setIsPopoverOpen(false) 59 | }, [importSSMLSettings, t]) 60 | 61 | return ( 62 | setIsPopoverOpen(open)}> 63 | {buttonIcon} 64 | 65 |
{t['export-import-settings']}
66 |
67 | 70 | 73 |
74 |
75 |
76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /app/[lang]/ui/components/icon-button.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react' 2 | import { useButton } from '@nextui-org/button' 3 | 4 | const IconButton = forwardRef< 5 | HTMLButtonElement, 6 | { 7 | onClick?: () => void 8 | icon?: JSX.Element 9 | className?: string 10 | title?: string 11 | } 12 | >((props, ref) => { 13 | const { domRef, getButtonProps } = useButton({ ref, ...props }) 14 | 15 | return ( 16 | 32 | ) 33 | }) 34 | IconButton.displayName = 'IconButton' 35 | 36 | export default IconButton 37 | -------------------------------------------------------------------------------- /app/[lang]/ui/components/import-text-button.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Popover, PopoverTrigger, PopoverContent } from '@nextui-org/popover' 3 | import { toast } from 'sonner' 4 | import { DEFAULT_TEXT } from '@/app/lib/constants' 5 | import { Tran } from '@/app/lib/types' 6 | 7 | export const ImportTextButton = ({ 8 | t, 9 | setInput, 10 | buttonIcon, 11 | }: { 12 | t: Tran 13 | setInput: (text: string) => void 14 | buttonIcon: JSX.Element 15 | }) => { 16 | const [isPopoverOpen, setIsPopoverOpen] = useState(false) 17 | const defaultTextKeys = Object.keys(DEFAULT_TEXT) as Array 18 | 19 | return ( 20 | setIsPopoverOpen(open)}> 21 | {buttonIcon} 22 | 23 |
{t['import-example-text']}
24 |
    25 | {defaultTextKeys.map(item => { 26 | return ( 27 |
  • { 31 | setInput(DEFAULT_TEXT[item]) 32 | setIsPopoverOpen(false) 33 | toast.success(t['import-example-text-success']) 34 | }} 35 | > 36 | {item === 'CN' ? '中文' : 'English'} 37 |
  • 38 | ) 39 | })} 40 |
41 |
42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /app/[lang]/ui/components/language-select.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react' 2 | import { Autocomplete, AutocompleteItem } from '@nextui-org/autocomplete' 3 | import { LanguageSelectProps } from '../../../lib/types' 4 | 5 | const LanguageSelect = ({ t, langs, selectedLang, handleSelectLang }: LanguageSelectProps) => { 6 | return ( 7 | 13 | {langs!.map(item => ( 14 | 15 | {item.label} 16 | 17 | ))} 18 | 19 | ) 20 | } 21 | 22 | export default memo(LanguageSelect) 23 | -------------------------------------------------------------------------------- /app/[lang]/ui/components/stop-time-button.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Popover, PopoverTrigger, PopoverContent } from '@nextui-org/popover' 3 | import { TIMES } from '@/app/lib/constants' 4 | import { Tran } from '@/app/lib/types' 5 | 6 | export const StopTimeButton = ({ 7 | t, 8 | insertTextAtCursor, 9 | buttonIcon, 10 | }: { 11 | t: Tran 12 | insertTextAtCursor: (text: string) => void 13 | buttonIcon: JSX.Element 14 | }) => { 15 | const [isPopoverOpen, setIsPopoverOpen] = useState(false) 16 | return ( 17 | setIsPopoverOpen(open)}> 18 | {buttonIcon} 19 | 20 |
{t['insert-pause']}
21 |
    22 | {TIMES.map(time => { 23 | return ( 24 |
  • { 28 | insertTextAtCursor(`{{⏱️=${time}}}`) 29 | setIsPopoverOpen(false) 30 | }} 31 | > 32 | {time} {' ms'} 33 |
  • 34 | ) 35 | })} 36 |
37 |
38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /app/[lang]/ui/components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | import useTheme from '@/app/lib/hooks/use-theme' 2 | import { Tran } from '@/app/lib/types' 3 | import '@/styles/theme-button.css' 4 | 5 | export function ThemeToggle({ t }: { t: Tran }) { 6 | const [theme, setTheme] = useTheme() 7 | 8 | const toggleTheme = (event: MouseEvent) => { 9 | const newTheme = theme === 'light' ? 'dark' : 'light' 10 | 11 | // document.startViewTransition fallback 12 | if (!(document as any).startViewTransition) { 13 | setTheme(newTheme) 14 | return 15 | } 16 | 17 | const x = event.clientX 18 | const y = event.clientY 19 | const endRadius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y)) 20 | 21 | const transition = (document as any).startViewTransition(() => { 22 | setTheme(newTheme) 23 | }) 24 | 25 | const isDark: boolean = theme === 'dark' 26 | 27 | transition.ready.then(() => { 28 | const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`] 29 | document.documentElement.animate( 30 | { 31 | clipPath: isDark ? clipPath.reverse() : clipPath, 32 | }, 33 | { 34 | duration: 500, 35 | easing: 'ease-in', 36 | pseudoElement: isDark ? '::view-transition-old(root)' : '::view-transition-new(root)', 37 | }, 38 | ) 39 | }) 40 | } 41 | 42 | return ( 43 | 70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /app/[lang]/ui/content.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { Key, useCallback, useEffect, useMemo, useRef, useState } from 'react' 3 | import { 4 | faCircleDown, 5 | faCirclePause, 6 | faCirclePlay, 7 | faRotateRight, 8 | faMicrophone, 9 | faFaceLaugh, 10 | faUserGroup, 11 | faSliders, 12 | faFileLines, 13 | faStopwatch, 14 | faFileCode, 15 | } from '@fortawesome/free-solid-svg-icons' 16 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 17 | import { Accordion, AccordionItem } from '@nextui-org/accordion' 18 | import { Button } from '@nextui-org/button' 19 | import { Textarea } from '@nextui-org/input' 20 | import { Slider, SliderValue } from '@nextui-org/slider' 21 | import { Spinner } from '@nextui-org/spinner' 22 | import { Toaster, toast } from 'sonner' 23 | import { 24 | base64AudioToBlobUrl, 25 | generateSSML, 26 | getFormatDate, 27 | getGenders, 28 | parseSSML, 29 | processVoiceName, 30 | saveAs, 31 | } from '../../lib/tools' 32 | import { Config, ListItem, Tran } from '../../lib/types' 33 | import ConfigSlider from './components/config-slider' 34 | import { ExportImportSettingsButton } from './components/export-import-setting-button' 35 | import { ImportTextButton } from './components/import-text-button' 36 | import LanguageSelect from './components/language-select' 37 | import { StopTimeButton } from './components/stop-time-button' 38 | import { DEFAULT_TEXT, MAX_INPUT_LENGTH } from '@/app/lib/constants' 39 | 40 | export default function Content({ t, list }: { t: Tran; list: ListItem[] }) { 41 | const [input, setInput] = useState('') 42 | const [isLoading, setLoading] = useState(false) 43 | const [isPlaying, setIsPlaying] = useState(false) 44 | const audioRef = useRef(null) 45 | const cacheConfigRef = useRef(null) 46 | const inputRef = useRef(null) 47 | const [config, setConfig] = useState({ 48 | gender: 'female', 49 | voiceName: '', 50 | lang: 'zh-CN', 51 | style: '', 52 | styleDegree: 1, 53 | role: '', 54 | rate: 0, 55 | volume: 0, 56 | pitch: 0, 57 | }) 58 | 59 | const langs = useMemo(() => { 60 | const map = new Map() 61 | list.forEach(item => { 62 | map.set(item.Locale, item.LocaleName) 63 | }) 64 | return [...map].map(([value, label]) => ({ label, value })) 65 | }, [list]) 66 | 67 | const selectedConfigs = useMemo(() => { 68 | return list.filter(item => item.Locale === config.lang) 69 | }, [list, config.lang]) 70 | 71 | const genders = useMemo(() => { 72 | return getGenders(selectedConfigs) 73 | }, [selectedConfigs]) 74 | 75 | const voiceNames = useMemo(() => { 76 | const dataForVoiceName = selectedConfigs.filter(item => item.Gender.toLowerCase() === config.gender) 77 | const _voiceNames = dataForVoiceName.map(item => { 78 | return { 79 | label: item.LocalName, 80 | value: item.ShortName, 81 | hasStyle: !!item.StyleList?.length, 82 | hasRole: !!item.RolePlayList?.length, 83 | } 84 | }) 85 | 86 | processVoiceName(_voiceNames, config.gender, config.lang) 87 | 88 | return _voiceNames 89 | }, [config.gender, config.lang, selectedConfigs]) 90 | 91 | const { styles, roles } = useMemo(() => { 92 | const data = selectedConfigs.find(item => item.ShortName === config.voiceName) 93 | const { StyleList = [], RolePlayList = [] } = data || {} 94 | return { styles: StyleList, roles: RolePlayList } 95 | }, [config.voiceName, selectedConfigs]) 96 | 97 | useEffect(() => { 98 | if (voiceNames.length && (!config.voiceName || !voiceNames.some(v => v.value === config.voiceName))) { 99 | handleSelectVoiceName(voiceNames[0].value) 100 | } 101 | }, [config.gender, voiceNames, config.voiceName]) 102 | 103 | const handleSelectGender = (e: React.MouseEvent, gender: string) => { 104 | setConfig(prevConfig => ({ ...prevConfig, gender })) 105 | } 106 | 107 | const handleSelectLang = (value: Key | null) => { 108 | if (!value) return 109 | const lang = value.toString() 110 | setConfig(prevConfig => ({ ...prevConfig, lang })) 111 | window.localStorage.setItem('lang', lang) 112 | } 113 | 114 | const handleSlideStyleDegree = (value: SliderValue) => { 115 | if (typeof value === 'number') { 116 | setConfig(prevConfig => ({ ...prevConfig, styleDegree: value })) 117 | } 118 | } 119 | 120 | const handleSlideRate = (value: SliderValue) => { 121 | if (typeof value === 'number') { 122 | setConfig(prevConfig => ({ ...prevConfig, rate: value })) 123 | } 124 | } 125 | 126 | const handleSlideVolume = (value: SliderValue) => { 127 | if (typeof value === 'number') { 128 | setConfig(prevConfig => ({ ...prevConfig, volume: value })) 129 | } 130 | } 131 | 132 | const handleSlidePitch = (value: SliderValue) => { 133 | if (typeof value === 'number') { 134 | setConfig(prevConfig => ({ ...prevConfig, pitch: value })) 135 | } 136 | } 137 | 138 | const handleSelectVoiceName = (voiceName: string) => { 139 | setConfig(prevConfig => ({ ...prevConfig, voiceName, style: '', role: '' })) 140 | } 141 | 142 | useEffect(() => { 143 | if (typeof window !== undefined) { 144 | const browserLang = window.localStorage.getItem('browserLang') === 'cn' ? 'zh-CN' : 'en-US' 145 | const lang = window.localStorage.getItem('lang') || browserLang || 'zh-CN' 146 | // Set the user's language to the cookie 147 | document.cookie = `user-language=${lang}; path=/` 148 | 149 | setConfig(prevConfig => ({ ...prevConfig, lang })) 150 | setInput(lang.startsWith('zh') ? DEFAULT_TEXT.CN : DEFAULT_TEXT.EN) 151 | } 152 | }, [list]) 153 | 154 | useEffect(() => { 155 | if (!genders.length || config.gender) return 156 | setConfig(prevConfig => ({ ...prevConfig, gender: genders[0].value })) 157 | }, [config.lang, genders, config.gender]) 158 | 159 | useEffect(() => { 160 | if (voiceNames.length && !config.voiceName) { 161 | handleSelectVoiceName(voiceNames[0].value) 162 | } 163 | }, [voiceNames, config.voiceName]) 164 | 165 | const fetchAudio = async () => { 166 | const res = await fetch('/api/audio', { 167 | method: 'POST', 168 | headers: { 'Content-Type': 'application/json' }, 169 | body: JSON.stringify({ input, config }), 170 | }) 171 | if (!res.ok) { 172 | toast.error('Error fetching audio. Error code: ' + res.status) 173 | } 174 | return res.json() 175 | } 176 | 177 | const play = async () => { 178 | if (!input.length || isLoading) return 179 | const cacheString = getCacheMark() 180 | if (cacheConfigRef.current === cacheString) { 181 | setIsPlaying(true) 182 | audioRef.current?.play() 183 | return 184 | } 185 | audioRef.current = null 186 | setLoading(true) 187 | 188 | try { 189 | const { base64Audio } = await fetchAudio() 190 | const url = base64AudioToBlobUrl(base64Audio) 191 | if (!audioRef.current) { 192 | audioRef.current = new Audio(url) 193 | audioRef.current.onended = () => { 194 | setIsPlaying(false) 195 | } 196 | } 197 | setIsPlaying(true) 198 | audioRef.current?.play() 199 | // save cache mark 200 | cacheConfigRef.current = cacheString 201 | } catch (err) { 202 | console.error('Error fetching audio:', err) 203 | } finally { 204 | setLoading(false) 205 | } 206 | } 207 | 208 | const pause = () => { 209 | if (audioRef.current) { 210 | audioRef.current.pause() 211 | audioRef.current.currentTime = 0 212 | } 213 | setIsPlaying(false) 214 | } 215 | 216 | const handleDownload = async () => { 217 | if (!audioRef.current || !audioRef.current.src) { 218 | toast.warning(t['download-fail']) 219 | return 220 | } 221 | const response = await fetch(audioRef.current.src) 222 | const blob = await response.blob() 223 | saveAs(blob, `Azure-TTS-${getFormatDate(new Date())}.mp3`) 224 | toast.success(t['download-success']) 225 | } 226 | 227 | const handleInsertPause = async (text: string) => { 228 | try { 229 | await insertTextAtCursor(text) 230 | toast.success(t['insert-pause-success']) 231 | } catch (error) { 232 | toast.success(t['insert-pause-fail']) 233 | } 234 | } 235 | 236 | const insertTextAtCursor = (text: string): Promise => { 237 | return new Promise((resolve, reject) => { 238 | const input = inputRef.current 239 | if (!input) { 240 | reject(new Error('Input element not found')) 241 | return 242 | } 243 | const start = input.selectionStart 244 | const end = input.selectionEnd 245 | const newValue = input.value.substring(0, start) + text + input.value.substring(end) 246 | setInput(newValue) 247 | 248 | setTimeout(() => { 249 | input.setSelectionRange(start + text.length, start + text.length) 250 | resolve() 251 | }, 0) 252 | }) 253 | } 254 | 255 | const getExportData = () => { 256 | return generateSSML({ input, config }, false) 257 | } 258 | 259 | const resetStyleDegree = () => { 260 | setConfig(prevConfig => ({ ...prevConfig, styleDegree: 1 })) 261 | } 262 | 263 | const resetRate = () => { 264 | setConfig(prevConfig => ({ ...prevConfig, rate: 0 })) 265 | } 266 | 267 | const resetVolume = () => { 268 | setConfig(prevConfig => ({ ...prevConfig, volume: 0 })) 269 | } 270 | 271 | const resetPitch = () => { 272 | setConfig(prevConfig => ({ ...prevConfig, pitch: 0 })) 273 | } 274 | 275 | const getCacheMark: () => string = () => { 276 | return input + Object.values(config).join('') 277 | } 278 | 279 | const importSSMLSettings = useCallback( 280 | (ssml: string) => { 281 | try { 282 | const { config: importedConfig, input: importedInput } = parseSSML(ssml) 283 | setConfig(prevConfig => ({ ...prevConfig, ...importedConfig })) 284 | setInput(importedInput || '') 285 | } catch (error) { 286 | console.error('Error parsing SSML:', error) 287 | toast.error(t['import-ssml-settings-error']) 288 | } 289 | }, 290 | [t], 291 | ) 292 | 293 | return ( 294 |
295 |
296 | 297 | {/* textarea */} 298 |