├── .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 | [](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 | [](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 |
26 |
27 |
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 |
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 |
380 | {/* select language */}
381 |
382 |
383 |
384 | {genders.map(item => (
385 |
392 | ))}
393 |
394 |
395 |
401 | {/* voice */}
402 |
407 |
408 |
409 | {t.voice}
410 |
411 | }
412 | >
413 |
414 | {voiceNames.map(item => {
415 | return (
416 |
440 | )
441 | })}
442 |
443 |
444 |
445 | {/* style */}
446 |
451 |
452 | {t.style}
453 |
454 | }
455 | >
456 |
457 |
458 |
463 |
477 |
{config.styleDegree}
478 |
479 |
480 |
481 |
489 | {styles.map(item => {
490 | return (
491 |
499 | )
500 | })}
501 |
502 |
503 |
504 | {/* role */}
505 |
510 |
511 | {t.role}
512 |
513 | }
514 | >
515 |
516 |
524 | {roles.map(item => {
525 | return (
526 |
534 | )
535 | })}
536 |
537 |
538 |
539 | {/* Advanced settings */}
540 |
546 |
547 | {t.advancedSettings}
548 |
549 | }
550 | >
551 | {/* rate */}
552 |
560 | {/* pitch */}
561 |
569 | {/* volume */}
570 |
578 |
579 |
580 |
581 |
582 | )
583 | }
584 |
--------------------------------------------------------------------------------
/app/[lang]/ui/nav.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useEffect, useState } from 'react'
3 | import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Selection } from '@nextui-org/react'
4 | import Image from 'next/image'
5 | import { usePathname, useRouter } from 'next/navigation'
6 | import Github from '../../icons/github.svg'
7 | import Language from '../../icons/language.svg'
8 | import Logo from '../../icons/logo.png'
9 | import { GITHUB_URL, LANGS } from '../../lib/constants'
10 | import IconButton from './components/icon-button'
11 | import { ThemeToggle } from './components/theme-toggle'
12 | import { Locale } from '@/app/lib/i18n/i18n-config'
13 | import { Tran } from '@/app/lib/types'
14 |
15 | export default function Nav({ t }: { t: Tran }) {
16 | const [selectedKeys, setSelectedKeys] = useState(new Set(['cn']))
17 |
18 | const router = useRouter()
19 | const pathname = usePathname()
20 |
21 | useEffect(() => {
22 | const locale = pathname.split('/')[1]
23 | window.localStorage.setItem('browserLang', locale)
24 | setSelectedKeys(new Set([locale]))
25 | }, [pathname])
26 |
27 | const handleSelectionChange = (key: Selection) => {
28 | setSelectedKeys(key)
29 | const redirectedPathname = (locale: Locale) => {
30 | if (!pathname) return
31 | const segments = pathname.split('/')
32 | if (segments[1] !== locale) {
33 | segments[1] = locale
34 | }
35 | return segments.join('/')
36 | }
37 | const locale = Array.from(key)[0] as Locale
38 | const newPath = redirectedPathname(locale)
39 | if (newPath && newPath !== pathname) {
40 | window.location.href = newPath
41 | }
42 | }
43 |
44 | const handleClickTitle = () => {
45 | router.refresh()
46 | }
47 |
48 | return (
49 |
50 |
51 |
52 |
Azure TTS Web
53 |
54 |
90 |
91 | )
92 | }
93 |
--------------------------------------------------------------------------------
/app/api/audio/route.ts:
--------------------------------------------------------------------------------
1 | import { Buffer } from 'buffer'
2 | import { NextRequest, NextResponse } from 'next/server'
3 | import { fetchToken } from '../token/fetch-token'
4 | import { AZURE_COGNITIVE_ENDPOINT, MAX_INPUT_LENGTH } from '@/app/lib/constants'
5 | import { generateSSML } from '@/app/lib/tools'
6 |
7 | async function fetchAudio(token: string, SSML: string): Promise {
8 | const res = await fetch(AZURE_COGNITIVE_ENDPOINT, {
9 | method: 'POST',
10 | headers: {
11 | Authorization: `Bearer ${token}`,
12 | 'Content-Type': 'application/ssml+xml',
13 | 'X-MICROSOFT-OutputFormat': 'audio-16khz-32kbitrate-mono-mp3',
14 | },
15 | body: SSML,
16 | })
17 |
18 | return res
19 | }
20 |
21 | export const maxDuration = 20
22 |
23 | export async function POST(req: NextRequest) {
24 | try {
25 | const payload = await req.json()
26 | if (payload.input.length > MAX_INPUT_LENGTH) {
27 | return NextResponse.json(
28 | { error: 'Request body too large. Maximum allowed size is ' + MAX_INPUT_LENGTH + ' characters.' },
29 | { status: 413 },
30 | )
31 | }
32 |
33 | const token = await fetchToken()
34 | const audioResponse = await fetchAudio(token, generateSSML({ input: payload.input, config: payload.config }))
35 |
36 | if (!audioResponse.ok) {
37 | return NextResponse.json(
38 | { error: 'Error fetching audio. Error code: ' + audioResponse.status },
39 | { status: audioResponse.status },
40 | )
41 | }
42 |
43 | const arrayBuffer = await audioResponse.arrayBuffer()
44 | const base64Audio = Buffer.from(arrayBuffer).toString('base64')
45 | return NextResponse.json({ base64Audio })
46 | } catch (error) {
47 | console.error('Error in audio POST handler:', error)
48 | return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/app/api/list/fetch-list.ts:
--------------------------------------------------------------------------------
1 | import { AZURE_LIST_ENDPOINT } from '@/app/lib/constants'
2 |
3 | import 'server-only'
4 |
5 | export async function fetchList() {
6 | const res = await fetch(AZURE_LIST_ENDPOINT, {
7 | headers: {
8 | 'Ocp-Apim-Subscription-Key': process.env.SPEECH_KEY!,
9 | },
10 | next: {
11 | revalidate: 60 * 60 * 24, // cache 24 hours
12 | },
13 | })
14 |
15 | return res
16 | }
17 |
--------------------------------------------------------------------------------
/app/api/token/fetch-token.ts:
--------------------------------------------------------------------------------
1 | import { AZURE_TOKEN_ENDPOINT } from '@/app/lib/constants'
2 |
3 | import 'server-only'
4 |
5 | let cachedToken: string | null = null
6 | let tokenExpiration: Date | null = null
7 |
8 | export async function fetchToken(): Promise {
9 | if (!cachedToken || !tokenExpiration || tokenExpiration <= new Date()) {
10 | const res = await fetch(AZURE_TOKEN_ENDPOINT, {
11 | method: 'POST',
12 | headers: {
13 | 'Content-Type': 'application/json',
14 | 'Ocp-Apim-Subscription-Key': process.env.SPEECH_KEY!,
15 | },
16 | })
17 |
18 | if (!res.ok) {
19 | throw new Error(`Error fetching token. Error code: ${res.status}`)
20 | }
21 |
22 | cachedToken = await res.text()
23 | tokenExpiration = new Date(new Date().getTime() + 20 * 1000) // 20s
24 | }
25 |
26 | return cachedToken
27 | }
28 |
--------------------------------------------------------------------------------
/app/icons/github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/language.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Femoon/tts-azure-web/fb307235e0665830fa494ea68273b81f5c891633/app/icons/logo.png
--------------------------------------------------------------------------------
/app/lib/constants.ts:
--------------------------------------------------------------------------------
1 | const REGION = process.env.SPEECH_REGION
2 |
3 | export const AZURE_TOKEN_ENDPOINT = `https://${REGION}.api.cognitive.microsoft.com/sts/v1.0/issuetoken`
4 | export const AZURE_LIST_ENDPOINT = `https://${REGION}.tts.speech.microsoft.com/cognitiveservices/voices/list`
5 | export const AZURE_COGNITIVE_ENDPOINT = `https://${REGION}.tts.speech.microsoft.com/cognitiveservices/v1`
6 |
7 | export const GITHUB_URL = 'https://github.com/Femoon/tts-azure-web'
8 |
9 | export const MAX_INPUT_LENGTH = process.env.NEXT_PUBLIC_MAX_INPUT_LENGTH
10 | ? parseInt(process.env.NEXT_PUBLIC_MAX_INPUT_LENGTH, 10)
11 | : 4000
12 |
13 | export const DEFAULT_TEXT = {
14 | CN: '我们需要加的是生抽、老抽、料酒、白糖还有一点点的醋、盐,然后把它翻炒均匀就可以了。接下来就是收汁的阶段了哈,我们加入适量的水淀粉翻炒到这个鸡丁上色,而且汤汁呢,稍稍已经比较浓稠,啊不会轻易的滑落。',
15 | EN: "Hmm, I'm not sure what to wear to the party tonight. I want to look nice, but I also want to be comfortable. Maybe I’ll wear my new dress and heels. Oh no, but what if my feet start hurting after a while? Maybe I should bring a pair of flats just in case.",
16 | }
17 |
18 | export const LANGS = [
19 | {
20 | label: '中文',
21 | value: 'cn',
22 | },
23 | {
24 | label: 'English',
25 | value: 'en',
26 | },
27 | ]
28 |
29 | export const TIMES = ['200', '300', '500', '1000', '2000', '5000']
30 |
--------------------------------------------------------------------------------
/app/lib/hooks/use-theme.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useCallback } from 'react'
2 | import { OverlayScrollbars } from 'overlayscrollbars'
3 |
4 | type ThemeType = 'light' | 'dark'
5 |
6 | let isInitialized = false
7 |
8 | function useTheme(): [ThemeType, (newTheme: ThemeType) => void] {
9 | const [theme, setThemeState] = useState(() => {
10 | if (typeof window !== 'undefined') {
11 | const storedTheme = window.localStorage.getItem('theme') as ThemeType | null
12 | const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
13 | return storedTheme || (mediaQuery.matches ? 'dark' : 'light')
14 | }
15 | return 'light'
16 | })
17 |
18 | const setTheme = useCallback((newTheme: ThemeType) => {
19 | setThemeState(newTheme)
20 | if (typeof window !== 'undefined') {
21 | window.localStorage.setItem('theme', newTheme)
22 | const root = document.documentElement
23 | const isDark = newTheme === 'dark'
24 | root.classList.remove(isDark ? 'light' : 'dark')
25 | root.classList.add(isDark ? 'dark' : 'light')
26 | updateOverlayScrollbarTheme(newTheme)
27 | }
28 | }, [])
29 |
30 | const updateOverlayScrollbarTheme = (newTheme: ThemeType) => {
31 | const osInstance = OverlayScrollbars(document.body)
32 | const scrollbarTheme = newTheme === 'dark' ? 'os-theme-light' : 'os-theme-dark'
33 | osInstance?.options({ scrollbars: { theme: scrollbarTheme } })
34 | }
35 |
36 | useEffect(() => {
37 | if (typeof window === 'undefined') return
38 | if (isInitialized) return
39 | isInitialized = true
40 |
41 | const storedTheme = window.localStorage.getItem('theme') as ThemeType | null
42 | const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
43 | const initialTheme = storedTheme || (mediaQuery.matches ? 'dark' : 'light')
44 | setTheme(initialTheme)
45 |
46 | const handleChange = () => {
47 | setThemeState(mediaQuery.matches ? 'dark' : 'light')
48 | }
49 |
50 | mediaQuery.addEventListener('change', handleChange)
51 |
52 | return () => mediaQuery.removeEventListener('change', handleChange)
53 | }, [setTheme])
54 |
55 | return [theme, setTheme]
56 | }
57 |
58 | export default useTheme
59 |
--------------------------------------------------------------------------------
/app/lib/i18n/get-locale.ts:
--------------------------------------------------------------------------------
1 | import 'server-only'
2 | import type { Locale } from './i18n-config'
3 | const locales: any = {
4 | en: () => import('@/locales/en.json').then(module => module.default),
5 | cn: () => import('@/locales/cn.json').then(module => module.default),
6 | }
7 |
8 | export const getLocale = async (locale: Locale) => locales[locale]?.() ?? locales.en()
9 |
--------------------------------------------------------------------------------
/app/lib/i18n/i18n-config.ts:
--------------------------------------------------------------------------------
1 | export const i18n = {
2 | defaultLocale: 'en',
3 | locales: ['en', 'cn'],
4 | } as const
5 |
6 | export type Locale = (typeof i18n)['locales'][number]
7 |
--------------------------------------------------------------------------------
/app/lib/tools.ts:
--------------------------------------------------------------------------------
1 | import { Config, GenderItem, ListItem } from './types'
2 |
3 | export function saveAs(blob: Blob, name: string) {
4 | const a = document.createElement('a')
5 | document.body.appendChild(a)
6 | a.setAttribute('style', 'display: none')
7 | const url = window.URL.createObjectURL(blob)
8 | a.href = url
9 | a.download = name
10 | a.click()
11 | window.URL.revokeObjectURL(url)
12 | }
13 |
14 | export function getGenders(data: ListItem[]): GenderItem[] {
15 | const allGenders = data.map(item => item.Gender)
16 | const genderList = [...new Set(allGenders)]
17 | const genders = genderList.map(item => ({
18 | label: item.toLowerCase(),
19 | value: item.toLowerCase(),
20 | }))
21 |
22 | return genders
23 | }
24 |
25 | export function base64AudioToBlobUrl(base64Audio: string) {
26 | const binaryString = atob(base64Audio)
27 | const len = binaryString.length
28 | const bytes = new Uint8Array(len)
29 | for (let i = 0; i < len; i++) {
30 | bytes[i] = binaryString.charCodeAt(i)
31 | }
32 |
33 | const blob = new Blob([bytes], { type: 'audio/mp3' })
34 | return URL.createObjectURL(blob)
35 | }
36 |
37 | interface VoiceName {
38 | label: string
39 | value: string
40 | hasStyle: boolean
41 | hasRole: boolean
42 | }
43 |
44 | export function processVoiceName(voiceNames: VoiceName[], gender: string, lang: string) {
45 | sortWithMultilingual(voiceNames)
46 | if (lang === 'zh-CN') {
47 | sortWithSimplifiedMandarin(voiceNames)
48 | if (gender === 'male') {
49 | supplementaryTranslateForMale(voiceNames)
50 | }
51 | if (gender === 'female') {
52 | supplementaryTranslateForFemale(voiceNames)
53 | }
54 | }
55 | }
56 |
57 | function sortWithMultilingual(voiceNames: VoiceName[]) {
58 | voiceNames.sort((a: VoiceName, b: VoiceName) => {
59 | const aContainsMultilingual = a.value.toLowerCase().includes('multilingual')
60 | const bContainsMultilingual = b.value.toLowerCase().includes('multilingual')
61 |
62 | if (aContainsMultilingual && !bContainsMultilingual) {
63 | return -1
64 | }
65 | if (!aContainsMultilingual && bContainsMultilingual) {
66 | return 1
67 | }
68 | return 0
69 | })
70 | }
71 |
72 | function sortWithSimplifiedMandarin(voiceNames: VoiceName[]) {
73 | voiceNames.sort((a, b) => {
74 | if (a.value.includes('XiaoxiaoMultilingualNeural')) return -1
75 | if (b.value.includes('XiaoxiaoMultilingualNeural')) return 1
76 | return 0
77 | })
78 | }
79 |
80 | function supplementaryTranslateForMale(voiceNames: VoiceName[]) {
81 | voiceNames.forEach(item => {
82 | if (item.label === 'Yunxiao Multilingual') {
83 | item.label = '云霄 多语言'
84 | }
85 | if (item.label === 'Yunfan Multilingual') {
86 | item.label = '云帆 多语言'
87 | }
88 | })
89 | }
90 |
91 | function supplementaryTranslateForFemale(voiceNames: VoiceName[]) {
92 | voiceNames.forEach(item => {
93 | if (item.label === 'Xiaochen Dragon HD Latest') {
94 | item.label = '晓辰 Dragon HD'
95 | }
96 | })
97 | }
98 |
99 | export function generateSSML(data: { input: string; config: Config }, compression: boolean = true): string {
100 | const { input, config } = data
101 | const { lang, voiceName, style, styleDegree, role, volume, rate, pitch, gender } = config
102 | const styleProperty = style ? ` style="${style}"` : ''
103 | const styleDegreeProperty = styleDegree ? ` styleDegree="${styleDegree}"` : ''
104 | const roleProperty = role ? ` role="${role}"` : ''
105 | const volumeProperty = ` volume="${volume}%"`
106 | const rateProperty = ` rate="${rate}%"`
107 | const pitchProperty = ` pitch="${pitch}%"`
108 | const inputWithStop = input.replace(/{{⏱️=(\d+)}}/g, '')
109 | const genderAttribute = compression ? '' : ` data-gender="${gender}"`
110 | let SSML = `
111 |
112 |
113 | ${inputWithStop}
114 |
115 |
116 | `
117 |
118 | if (compression) {
119 | SSML = SSML.replace(/\>\s+\<')
120 | }
121 |
122 | return SSML
123 | }
124 |
125 | export function parseSSML(ssml: string): { config: Partial; input: string } {
126 | const parser = new DOMParser()
127 | const xmlDoc = parser.parseFromString(ssml, 'text/xml')
128 |
129 | const config: Partial = {}
130 |
131 | // Extract language and gender
132 | const speakElement = xmlDoc.getElementsByTagName('speak')[0]
133 | config.lang = speakElement.getAttribute('xml:lang') || ''
134 | config.gender = speakElement.getAttribute('data-gender') || ''
135 |
136 | // Extract voice name
137 | const voiceElement = xmlDoc.getElementsByTagName('voice')[0]
138 | config.voiceName = voiceElement.getAttribute('name') || ''
139 |
140 | // Extract express-as attributes
141 | const expressAsElement = xmlDoc.getElementsByTagName('mstts:express-as')[0]
142 | if (expressAsElement) {
143 | config.role = expressAsElement.getAttribute('role') || ''
144 | config.style = expressAsElement.getAttribute('style') || ''
145 | config.styleDegree = parseFloat(expressAsElement.getAttribute('styleDegree') || '1')
146 | }
147 |
148 | // Extract prosody attributes
149 | const prosodyElement = xmlDoc.getElementsByTagName('prosody')[0]
150 | if (prosodyElement) {
151 | config.volume = parseInt(prosodyElement.getAttribute('volume') || '0')
152 | config.rate = parseInt(prosodyElement.getAttribute('rate') || '0')
153 | config.pitch = parseInt(prosodyElement.getAttribute('pitch') || '0')
154 | }
155 |
156 | // Extract input text and handle break tags
157 | const prosodyContent = prosodyElement?.innerHTML || ''
158 | const input = prosodyContent
159 | .replace(//g, '{{⏱️=$1}}')
160 | .replace(/</g, '<')
161 | .replace(/>/g, '>')
162 |
163 | return { config, input }
164 | }
165 |
166 | export function getFormatDate(date: Date): string {
167 | const year = date.getFullYear()
168 | const month = String(date.getMonth() + 1).padStart(2, '0')
169 | const day = String(date.getDate()).padStart(2, '0')
170 | const hours = String(date.getHours()).padStart(2, '0')
171 | const minutes = String(date.getMinutes()).padStart(2, '0')
172 | const seconds = String(date.getSeconds()).padStart(2, '0')
173 |
174 | return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`
175 | }
176 |
--------------------------------------------------------------------------------
/app/lib/types.ts:
--------------------------------------------------------------------------------
1 | import { Key } from 'react'
2 | import { getLocale } from '@/app/lib/i18n/get-locale'
3 |
4 | export interface ListItem {
5 | Name: string
6 | DisplayName: string
7 | LocalName: string
8 | ShortName: string
9 | Gender: string
10 | Locale: string
11 | LocaleName: string
12 | StyleList?: string[]
13 | SampleRateHertz: string
14 | VoiceType: string
15 | Status: string
16 | ExtendedPropertyMap?: ExtendedPropertyMap
17 | WordsPerMinute: string
18 | SecondaryLocaleList?: string[]
19 | RolePlayList?: string[]
20 | }
21 |
22 | interface ExtendedPropertyMap {
23 | IsHighQuality48K: string
24 | }
25 |
26 | export interface Config {
27 | gender: string
28 | voiceName: string
29 | lang: string
30 | style: string
31 | styleDegree: number
32 | role: string
33 | rate: number
34 | volume: number
35 | pitch: number
36 | }
37 |
38 | interface KeyValue {
39 | label: string
40 | value: string
41 | }
42 |
43 | export interface GenderItem extends KeyValue {}
44 |
45 | export interface LangsItem extends KeyValue {}
46 |
47 | export type Tran = Awaited>
48 |
49 | export interface LanguageSelectProps {
50 | t: Tran
51 | langs: LangsItem[]
52 | selectedLang: string
53 | handleSelectLang: (value: Key | null) => void
54 | }
55 |
--------------------------------------------------------------------------------
/commitlint.config.ts:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['@commitlint/config-conventional'],
3 | rules: {
4 | // @see https://commitlint.js.org/#/reference-rules
5 |
6 | // 提交类型枚举,git提交type必须是以下类型
7 | 'type-enum': [
8 | 2,
9 | 'always',
10 | [
11 | 'feat', // 新增功能
12 | 'fix', // 修复缺陷
13 | 'docs', // 文档变更
14 | 'style', // 代码格式(不影响功能,例如空格、分号等格式修正)
15 | 'refactor', // 代码重构(不包括 bug 修复、功能新增)
16 | 'perf', // 性能优化
17 | 'test', // 添加疏漏测试或已有测试改动
18 | 'build', // 构建流程、外部依赖变更(如升级 npm 包、修改 webpack 配置等)
19 | 'ci', // 修改 CI 配置、脚本
20 | 'revert', // 回滚 commit
21 | 'chore', // 对构建过程或辅助工具和库的更改(不影响源文件、测试用例)
22 | ],
23 | ],
24 | },
25 | }
26 |
--------------------------------------------------------------------------------
/locales/cn.json:
--------------------------------------------------------------------------------
1 | {
2 | "select-language-btn": "选择语言",
3 | "toggle-theme": "切换浅色/深色模式",
4 | "download": "下载",
5 | "download-success": "下载成功",
6 | "download-fail": "需要先点击播放按钮才能下载",
7 | "import": "导入",
8 | "import-success": "导入成功",
9 | "import-example-text": "导入示例文本",
10 | "import-example-text-success": "导入示例文本成功",
11 | "insert-pause": "在光标处插入停顿",
12 | "insert-pause-success": "在光标处插入停顿成功",
13 | "insert-pause-fail": "在光标处插入停顿失败",
14 | "export-import-settings": "导出/导入 SSML 参数",
15 | "import-ssml-settings": "导入 SSML 参数",
16 | "export-ssml-settings": "导出 SSML 参数",
17 | "import-ssml-settings-success": "导入 SSML 参数成功",
18 | "export-ssml-settings-success": "导出 SSML 参数成功",
19 | "play": "播放",
20 | "pause": "暂停",
21 | "input-text": "请输入文本",
22 | "select-language": "请选择语言",
23 | "voice": "语音",
24 | "male": "男性",
25 | "female": "女性",
26 | "neutral": "中性",
27 | "advancedSettings": "高级设置",
28 | "rate": "语速",
29 | "volume": "音量",
30 | "pitch": "语调",
31 | "style": "风格",
32 | "role": "角色",
33 | "styleIntensity": "风格强度",
34 | "default": "默认",
35 | "styles": {
36 | "affectionate": "亲切",
37 | "angry": "愤怒",
38 | "assistant": "助理",
39 | "calm": "平静",
40 | "chat": "聊天",
41 | "chat-casual": "聊天 - 休闲",
42 | "cheerful": "愉悦",
43 | "conversation": "交谈",
44 | "customerservice": "客户服务",
45 | "disgruntled": "不满",
46 | "fearful": "害怕",
47 | "friendly": "友好",
48 | "gentle": "温柔",
49 | "lyrical": "抒情",
50 | "newscast": "新闻播报",
51 | "poetry-reading": "诗歌朗诵",
52 | "sad": "悲伤",
53 | "serious": "严肃",
54 | "sorry": "抱歉",
55 | "whisper": "低语",
56 | "depressed": "沮丧",
57 | "embarrassed": "尴尬",
58 | "envious": "嫉妒",
59 | "narration-relaxed": "旁白 - 轻松",
60 | "narration-professional": "旁白 - 专业",
61 | "newscast-casual": "新闻播报 - 休闲",
62 | "newscast-formal": "新闻播报 - 正式",
63 | "empathetic": "有同情心",
64 | "excited": "兴奋",
65 | "hopeful": "充满希望",
66 | "shouting": "喊叫",
67 | "terrified": "恐惧",
68 | "unfriendly": "不友好",
69 | "whispering": "低声说话",
70 | "story": "叙事",
71 | "sports-commentary": "体育解说",
72 | "sports-commentary-excited": "体育解说 - 兴奋",
73 | "documentary-narration": "纪录片 - 旁白",
74 | "livecommercial": "现场广告",
75 | "advertisement-upbeat": "广告 - 欢快"
76 | },
77 | "roles": {
78 | "Boy": "男孩",
79 | "Girl": "女孩",
80 | "OlderAdultFemale": "年长女性",
81 | "OlderAdultMale": "年长男性",
82 | "SeniorFemale": "老年女性",
83 | "SeniorMale": "老年男性",
84 | "YoungAdultFemale": "年轻女性",
85 | "YoungAdultMale": "年轻男性",
86 | "Narrator": "旁白者"
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/locales/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "select-language-btn": "Select language",
3 | "toggle-theme": "Toggles light & dark",
4 | "download": "Download",
5 | "download-success": "Download successfully",
6 | "download-fail": "You need to click on the play button first to download",
7 | "import": "Import",
8 | "import-success": "Import successfully",
9 | "import-example-text": "Import example text",
10 | "import-example-text-success": "Import example text successfully",
11 | "insert-pause": "Insert a pause at the cursor",
12 | "insert-pause-success": "Insert a pause at the cursor successfully",
13 | "insert-pause-fail": "Failed to insert pause at the cursor",
14 | "export-import-settings": "Export/Import SSML settings",
15 | "import-ssml-settings": "Import SSML settings",
16 | "export-ssml-settings": "Export SSML settings",
17 | "import-ssml-settings-success": "Import SSML settings successfully",
18 | "export-ssml-settings-success": "Export SSML settings successfully",
19 | "play": "Play",
20 | "pause": "Pause",
21 | "input-text": "Please enter text",
22 | "select-language": "Please select language",
23 | "voice": "Voice",
24 | "male": "Man",
25 | "female": "Woman",
26 | "neutral": "Neutral",
27 | "rate": "Rate",
28 | "advancedSettings": "Advanced Settings",
29 | "volume": "Volume",
30 | "pitch": "Pitch",
31 | "style": "Style",
32 | "styleIntensity": "Style intensity",
33 | "role": "Role",
34 | "default": "Default",
35 | "styles": {
36 | "affectionate": "Affectionate",
37 | "angry": "Angry",
38 | "assistant": "Assistant",
39 | "calm": "Calm",
40 | "chat": "Chat",
41 | "chat-casual": "Chat-casual",
42 | "cheerful": "Cheerful",
43 | "conversation": "Conversation",
44 | "customerservice": "Customerservice",
45 | "disgruntled": "Disgruntled",
46 | "fearful": "Fearful",
47 | "friendly": "Friendly",
48 | "gentle": "Gentle",
49 | "lyrical": "Lyrical",
50 | "newscast": "Newscast",
51 | "poetry-reading": "Poetry-reading",
52 | "sad": "Sad",
53 | "serious": "Serious",
54 | "sorry": "Sorry",
55 | "whisper": "Whisper",
56 | "depressed": "Depressed",
57 | "embarrassed": "Embarrassed",
58 | "envious": "Envious",
59 | "narration-relaxed": "Narration-relaxed",
60 | "narration-professional": "Narration-professional",
61 | "newscast-casual": "Newscast-casual",
62 | "newscast-formal": "Newscast-formal",
63 | "empathetic": "Empathetic",
64 | "excited": "Excited",
65 | "hopeful": "Hopeful",
66 | "shouting": "Shouting",
67 | "terrified": "Terrified",
68 | "unfriendly": "Unfriendly",
69 | "whispering": "Whispering",
70 | "story": "Story",
71 | "sports-commentary": "Sports-commentary",
72 | "sports-commentary-excited": "Sports-commentary-excited",
73 | "documentary-narration": "Documentary-narration",
74 | "livecommercial": "Livecommercial",
75 | "advertisement-upbeat": "Advertisement-upbeat"
76 | },
77 | "roles": {
78 | "Boy": "Boy",
79 | "Girl": "Girl",
80 | "OlderAdultFemale": "OlderAdultFemale",
81 | "OlderAdultMale": "OlderAdultMale",
82 | "SeniorFemale": "SeniorFemale",
83 | "SeniorMale": "SeniorMale",
84 | "YoungAdultFemale": "YoungAdultFemale",
85 | "YoungAdultMale": "YoungAdultMale",
86 | "Narrator": "Narrator"
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { match as matchLocale } from '@formatjs/intl-localematcher'
2 | import Negotiator from 'negotiator'
3 | import { NextResponse } from 'next/server'
4 | import { i18n } from './app/lib/i18n/i18n-config'
5 | import type { NextRequest } from 'next/server'
6 |
7 | function getLocale(request: NextRequest): string | undefined {
8 | // Negotiator expects plain object so we need to transform headers
9 | const negotiatorHeaders: Record = {}
10 | request.headers.forEach((value, key) => (negotiatorHeaders[key] = value))
11 |
12 | // @ts-ignore locales are readonly
13 | const locales: string[] = i18n.locales
14 |
15 | // Use negotiator and intl-localematcher to get best locale
16 | const languages = new Negotiator({ headers: negotiatorHeaders }).languages(locales)
17 |
18 | const locale = matchLocale(languages, locales, i18n.defaultLocale)
19 |
20 | return locale
21 | }
22 |
23 | function getCookie(name: string, cookies: string): string | undefined {
24 | const value = `; ${cookies}`
25 | const parts = value.split(`; ${name}=`)
26 | if (parts.length === 2) return parts.pop()?.split(';').shift()
27 | }
28 |
29 | export function middleware(request: NextRequest) {
30 | const pathname = request.nextUrl.pathname
31 | const excludedPaths = [
32 | '/manifest.json',
33 | '/favicon.ico',
34 | '/site.webmanifest',
35 | '/apple-touch-icon.png',
36 | '/android-chrome-192x192.png',
37 | '/android-chrome-512x512.png',
38 | '/favicon-16x16.png',
39 | '/favicon-32x32.png',
40 | '/mstile-150x150.png',
41 | '/safari-pinned-tab.svg',
42 | '/browserconfig.xml',
43 | ]
44 | if (excludedPaths.includes(pathname)) return
45 |
46 | const pathnameIsMissingLocale = i18n.locales.every(
47 | locale => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`,
48 | )
49 |
50 | const setSecurityHeaders = (response: NextResponse) => {
51 | response.headers.set('X-Frame-Options', 'DENY')
52 | response.headers.set('Content-Security-Policy', "frame-ancestors 'none';")
53 | return response
54 | }
55 |
56 | if (pathnameIsMissingLocale) {
57 | const cookies = request.headers.get('cookie') || ''
58 | const cookieLang = getCookie('user-language', cookies)
59 | const cookieLocale = cookieLang && (cookieLang.startsWith('zh') ? 'cn' : 'en')
60 | const locale = cookieLocale || getLocale(request)
61 | const redirectUrl = new URL(`/${locale}${pathname.startsWith('/') ? '' : '/'}${pathname}`, request.url)
62 |
63 | const response = NextResponse.redirect(redirectUrl)
64 | setSecurityHeaders(response)
65 |
66 | return response
67 | }
68 |
69 | const response = NextResponse.next()
70 | setSecurityHeaders(response)
71 |
72 | return response
73 | }
74 |
75 | export const config = {
76 | // Matcher ignoring `/_next/` and `/api/`
77 | matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
78 | }
79 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | output: 'standalone',
4 | webpack(config) {
5 | config.module.rules.push({
6 | test: /\.svg$/,
7 | use: ['@svgr/webpack'],
8 | })
9 | return config
10 | },
11 | }
12 |
13 | export default nextConfig
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tts-azure-web",
3 | "version": "1.1.0",
4 | "homepage": "https://github.com/Femoon/tts-azure-web.git",
5 | "description": "TTS Azure Web is an Azure Text-to-Speech (TTS) web application. It allows you to run it locally or deploy it with a single click using your Azure Key.",
6 | "license": "MIT",
7 | "author": "Femoon",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/Femoon/tts-azure-web.git"
11 | },
12 | "bugs": {
13 | "url": "https://github.com/Femoon/tts-azure-web/issues"
14 | },
15 | "keywords": [
16 | "TTS",
17 | "Text to speech",
18 | "Microsoft Azure"
19 | ],
20 | "scripts": {
21 | "dev": "next dev",
22 | "lan-dev": "next dev -H 0.0.0.0",
23 | "build": "next build",
24 | "start": "next start",
25 | "lint": "eslint app --fix --ext .ts,.tsx,.js,.jsx --max-warnings 0",
26 | "prepare": "husky install"
27 | },
28 | "dependencies": {
29 | "@formatjs/intl-localematcher": "^0.5.4",
30 | "@fortawesome/fontawesome-svg-core": "^6.5.2",
31 | "@fortawesome/free-regular-svg-icons": "^6.5.2",
32 | "@fortawesome/free-solid-svg-icons": "^6.5.2",
33 | "@fortawesome/react-fontawesome": "^0.2.0",
34 | "@nextui-org/react": "^2.3.6",
35 | "@svgr/webpack": "^8.1.0",
36 | "@types/negotiator": "^0.6.3",
37 | "@vercel/analytics": "^1.2.2",
38 | "@vercel/speed-insights": "^1.0.10",
39 | "framer-motion": "^11.1.9",
40 | "microsoft-cognitiveservices-speech-sdk": "^1.36.0",
41 | "negotiator": "^0.6.3",
42 | "next": "14.2.21",
43 | "overlayscrollbars": "^2.8.3",
44 | "react": "^18",
45 | "react-dom": "^18",
46 | "sharp": "^0.33.5",
47 | "sonner": "^1.5.0"
48 | },
49 | "devDependencies": {
50 | "@commitlint/cli": "^19.0.3",
51 | "@commitlint/config-conventional": "^19.0.3",
52 | "@types/node": "^20",
53 | "@types/react": "^18",
54 | "@types/react-dom": "^18",
55 | "@typescript-eslint/eslint-plugin": "^7.8.0",
56 | "eslint": "^8",
57 | "eslint-config-next": "14.2.3",
58 | "eslint-config-prettier": "^9.1.0",
59 | "eslint-plugin-import": "^2.29.1",
60 | "eslint-plugin-prettier": "^5.1.3",
61 | "husky": "8.0.3",
62 | "lint-staged": "^15.2.2",
63 | "postcss": "^8",
64 | "prettier": "^3.2.5",
65 | "tailwindcss": "^3.4.1",
66 | "typescript": "^5"
67 | },
68 | "lint-staged": {
69 | "app/**/*.{js,jsx,ts,tsx,json}": [
70 | "npm run lint",
71 | "prettier --write"
72 | ]
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | }
7 |
8 | export default config
9 |
--------------------------------------------------------------------------------
/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Femoon/tts-azure-web/fb307235e0665830fa494ea68273b81f5c891633/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Femoon/tts-azure-web/fb307235e0665830fa494ea68273b81f5c891633/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Femoon/tts-azure-web/fb307235e0665830fa494ea68273b81f5c891633/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #603cba
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Femoon/tts-azure-web/fb307235e0665830fa494ea68273b81f5c891633/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Femoon/tts-azure-web/fb307235e0665830fa494ea68273b81f5c891633/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Femoon/tts-azure-web/fb307235e0665830fa494ea68273b81f5c891633/public/favicon.ico
--------------------------------------------------------------------------------
/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Femoon/tts-azure-web/fb307235e0665830fa494ea68273b81f5c891633/public/mstile-150x150.png
--------------------------------------------------------------------------------
/public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
29 |
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 0, 0, 0;
7 | --background-start-rgb: 214, 219, 220;
8 | --background-end-rgb: 255, 255, 255;
9 | }
10 |
11 | .dark {
12 | --foreground-rgb: 255, 255, 255;
13 | --background-start-rgb: 28, 33, 40;
14 | --background-end-rgb: 28, 33, 40;
15 | }
16 |
17 | body {
18 | color: rgb(var(--foreground-rgb));
19 | background: linear-gradient(to bottom, transparent, rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb));
20 | }
21 |
22 | @layer utilities {
23 | .text-balance {
24 | text-wrap: balance;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/styles/theme-button.css:
--------------------------------------------------------------------------------
1 |
2 | .sun-and-moon > :is(.moon, .sun, .sun-beams) {
3 | transform-origin: center center;
4 | }
5 |
6 | .sun-and-moon > :is(.moon, .sun) {
7 | fill: var(--icon-fill);
8 | }
9 |
10 | .theme-toggle:is(:hover, :focus-visible) > .sun-and-moon > :is(.moon, .sun) {
11 | fill: var(--icon-fill-hover);
12 | }
13 |
14 | .sun-and-moon > .sun-beams {
15 | stroke: var(--icon-fill);
16 | stroke-width: 2px;
17 | }
18 |
19 | .theme-toggle:is(:hover, :focus-visible) .sun-and-moon > .sun-beams {
20 | stroke: var(--icon-fill-hover);
21 | }
22 |
23 | .dark .sun-and-moon > .sun {
24 | transform: scale(1.75);
25 | }
26 |
27 | .dark .sun-and-moon > .sun-beams {
28 | opacity: 0;
29 | }
30 |
31 | .dark .sun-and-moon > .moon > circle {
32 | transform: translate(-7px);
33 | }
34 |
35 | @supports (cx: 1) {
36 | .dark .sun-and-moon > .moon > circle {
37 | transform: translate(0);
38 | cx: 17;
39 | }
40 | }
41 |
42 | @media (prefers-reduced-motion: no-preference) {
43 | .sun-and-moon > .sun {
44 | transition: transform 0.5s ease-out;
45 | }
46 |
47 | .sun-and-moon > .sun-beams {
48 | transition:
49 | transform 0.5s ease-in-out,
50 | opacity 0.5s ease;
51 | }
52 |
53 | .sun-and-moon .moon > circle {
54 | transition: transform 0.25s ease-out;
55 | }
56 |
57 | @supports (cx: 1) {
58 | .sun-and-moon .moon > circle {
59 | transition: cx 0.25s ease-out;
60 | }
61 | }
62 |
63 | .dark .sun-and-moon > .sun {
64 | transform: scale(1.75);
65 | transition: transform 0.25s ease;
66 | }
67 |
68 | .dark .sun-and-moon > .sun-beams {
69 | transform: rotate(-25deg);
70 | transition: transform 0.15s ease;
71 | }
72 |
73 | .dark .sun-and-moon > .moon > circle {
74 | transition: cx 0.5s ease-out 0.25s;
75 | }
76 | }
77 |
78 | .theme-toggle {
79 | width: 1.25rem;
80 | height: 1.25rem;
81 | padding: .75rem 0 .75rem .75rem;
82 | box-sizing: content-box;
83 | --icon-fill: #404040;
84 | --icon-fill-hover: #d97706;
85 | background: none;
86 | border: none;
87 | aspect-ratio: 1;
88 | border-radius: 50%;
89 | cursor: pointer;
90 | touch-action: manipulation;
91 | -webkit-tap-highlight-color: transparent;
92 | outline-offset: 5px;
93 | }
94 |
95 | .theme-toggle > svg {
96 | inline-size: 100%;
97 | block-size: 100%;
98 | stroke-linecap: round;
99 | }
100 |
101 | .dark .theme-toggle {
102 | --icon-fill: #e5e5e5;
103 | --icon-fill-hover: #3b82f6;
104 | }
105 |
106 | ::view-transition-old(root),
107 | ::view-transition-new(root) {
108 | animation: none;
109 | mix-blend-mode: normal;
110 | }
111 |
112 | .dark::view-transition-old(root) {
113 | z-index: 1;
114 | }
115 | .dark::view-transition-new(root) {
116 | z-index: 999;
117 | }
118 |
119 | ::view-transition-old(root) {
120 | z-index: 999;
121 | }
122 | ::view-transition-new(root) {
123 | z-index: 1;
124 | }
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import { nextui } from '@nextui-org/theme'
2 | import type { Config } from 'tailwindcss'
3 |
4 | const config: Config = {
5 | content: [
6 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
7 | './components/**/*.{js,ts,jsx,tsx,mdx}',
8 | './app/**/*.{js,ts,jsx,tsx,mdx}',
9 | './node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}',
10 | ],
11 | theme: {
12 | extend: {
13 | backgroundImage: {
14 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
15 | 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
16 | },
17 | backgroundColor: {
18 | 'nav-light': '#fff',
19 | 'nav-dark': 'rgb(28, 33, 40)',
20 | },
21 | borderColor: {
22 | 'nav-light': '#fff',
23 | 'nav-dark': 'rgb(68, 76, 86)',
24 | },
25 | },
26 | },
27 | darkMode: ['class'],
28 | plugins: [nextui()],
29 | }
30 | export default config
31 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "target": "ESNext",
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["svgr.d.ts", "next-env.d.ts", "typings.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/typings.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace NodeJS {
2 | interface ProcessEnv {}
3 | }
4 |
--------------------------------------------------------------------------------