From 9961d3a26f5c685e54f6ef9780f6ea09553671bc Mon Sep 17 00:00:00 2001 From: canisminor1990 Date: Fri, 3 Nov 2023 00:08:09 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .commitlintrc.js | 1 + .eslintignore | 32 +++++++++ .eslintrc.js | 7 ++ .gitignore | 58 +++++++++++++++ .husky/commit-msg | 4 ++ .husky/pre-commit | 5 ++ .npmrc | 11 +++ .prettierignore | 63 +++++++++++++++++ .prettierrc.js | 1 + .releaserc.js | 1 + .remarkrc.js | 1 + README.md | 0 api/index.ts | 16 +++++ package.json | 65 +++++++++++++++++ src/cors.ts | 140 +++++++++++++++++++++++++++++++++++++ src/genSSML.ts | 29 ++++++++ src/postMicrosoftSpeech.ts | 44 ++++++++++++ tsconfig.json | 35 ++++++++++ 18 files changed, 513 insertions(+) create mode 100644 .commitlintrc.js create mode 100644 .eslintignore create mode 100644 .eslintrc.js create mode 100644 .gitignore create mode 100644 .husky/commit-msg create mode 100644 .husky/pre-commit create mode 100644 .npmrc create mode 100644 .prettierignore create mode 100644 .prettierrc.js create mode 100644 .releaserc.js create mode 100644 .remarkrc.js create mode 100644 README.md create mode 100644 api/index.ts create mode 100644 package.json create mode 100644 src/cors.ts create mode 100644 src/genSSML.ts create mode 100644 src/postMicrosoftSpeech.ts create mode 100644 tsconfig.json diff --git a/.commitlintrc.js b/.commitlintrc.js new file mode 100644 index 0000000..9b8c6ac --- /dev/null +++ b/.commitlintrc.js @@ -0,0 +1 @@ +module.exports = require('@lobehub/lint').commitlint; diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..b245203 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,32 @@ +# Eslintignore for LobeHub +################################################################ + +# dependencies +node_modules + +# ci +coverage +.coverage + +# test +jest* +_test_ +__test__ +*.test.ts + +# umi +.umi +.umi-production +.umi-test +.dumi/tmp* +!.dumirc.ts + +# production +dist +es +lib +logs + +# misc +# add other ignore file below +.next \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..d1ff44b --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,7 @@ +const config = require('@lobehub/lint').eslint; + +config.rules['no-param-reassign'] = 0; +config.rules['unicorn/no-array-callback-reference'] = 0; +config.rules['unicorn/no-array-for-each'] = 0; + +module.exports = config; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b5c105 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Gitignore for LobeHub +################################################################ + +# general +.DS_Store +.idea +.vscode +.history +.temp +.env.local +venv +temp +tmp + +# dependencies +node_modules +*.log +*.lock +package-lock.json + +# ci +coverage +.coverage +.eslintcache +.stylelintcache + +# production +dist +es +lib +logs +test-output + +# umi +.umi +.umi-production +.umi-test +.dumi/tmp* + +# husky +.husky/prepare-commit-msg + +# misc +# add other ignore file below + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +.next +.env +public/*.js +bun.lockb \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..c160a77 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no -- commitlint --edit ${1} diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..8da041a --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npm run type-check +npx --no-install lint-staged diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..d9ed3d3 --- /dev/null +++ b/.npmrc @@ -0,0 +1,11 @@ +lockfile=false +resolution-mode=highest +public-hoist-pattern[]=*@umijs/lint* +public-hoist-pattern[]=*changelog* +public-hoist-pattern[]=*commitlint* +public-hoist-pattern[]=*eslint* +public-hoist-pattern[]=*postcss* +public-hoist-pattern[]=*prettier* +public-hoist-pattern[]=*remark* +public-hoist-pattern[]=*semantic-release* +public-hoist-pattern[]=*stylelint* diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..3e459cb --- /dev/null +++ b/.prettierignore @@ -0,0 +1,63 @@ +# Prettierignore for LobeHub +################################################################ + +# general +.DS_Store +.editorconfig +.idea +.vscode +.history +.temp +.env.local +.husky +.npmrc +.gitkeep +venv +temp +tmp +LICENSE + +# dependencies +node_modules +*.log +*.lock +package-lock.json + +# ci +coverage +.coverage +.eslintcache +.stylelintcache +test-output +__snapshots__ +*.snap + +# production +dist +es +lib +logs + +# umi +.umi +.umi-production +.umi-test +.dumi/tmp* + +# ignore files +.*ignore + +# docker +docker +Dockerfile* + +# image +*.webp +*.gif +*.png +*.jpg +*.svg + +# misc +# add other ignore file below +.next \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..f0355a9 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1 @@ +module.exports = require('@lobehub/lint').prettier; diff --git a/.releaserc.js b/.releaserc.js new file mode 100644 index 0000000..3793001 --- /dev/null +++ b/.releaserc.js @@ -0,0 +1 @@ +module.exports = require('@lobehub/lint').semanticRelease; diff --git a/.remarkrc.js b/.remarkrc.js new file mode 100644 index 0000000..b673c10 --- /dev/null +++ b/.remarkrc.js @@ -0,0 +1 @@ +module.exports = require('@lobehub/lint').remarklint; diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/api/index.ts b/api/index.ts new file mode 100644 index 0000000..ccc8f2c --- /dev/null +++ b/api/index.ts @@ -0,0 +1,16 @@ +import qs from 'query-string'; + +import cors from '../src/cors'; +import { SsmlOptions } from '../src/genSSML'; +import { postMicrosoftSpeech } from '../src/postMicrosoftSpeech'; + +export const config = { + runtime: 'edge', +}; + +export default async (req: Request) => { + const { text, ...options }: SsmlOptions & { text: string } = qs.parseUrl(req.url).query as any; + + const res = await fetch(...postMicrosoftSpeech(text, options)); + return cors(req, res); +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..6dfa1de --- /dev/null +++ b/package.json @@ -0,0 +1,65 @@ +{ + "name": "lobe-tts", + "version": "0.0.1", + "homepage": "https://github.com/lobehub/lobe-tts", + "bugs": { + "url": "https://github.com/lobehub/lobe-tts/issues/new/choose" + }, + "repository": { + "type": "git", + "url": "https://github.com/lobehub/lobe-tts.git" + }, + "license": "MIT", + "author": "LobeHub ", + "sideEffects": false, + "scripts": { + "lint": "eslint \"{src,tests}/**/*.{js,jsx,ts,tsx}\" --fix", + "lint:md": "remark . --quiet --frail --output", + "lint:style": "stylelint \"{src,tests}/**/*.{js,jsx,ts,tsx}\" --fix", + "prepare": "husky install", + "prettier": "prettier -c --write \"**/**\"", + "release": "semantic-release", + "type-check": "tsc --noEmit" + }, + "lint-staged": { + "*.md": [ + "remark --quiet --output --", + "prettier --write --no-error-on-unmatched-pattern" + ], + "*.json": [ + "prettier --write --no-error-on-unmatched-pattern" + ], + "*.{js,jsx}": [ + "prettier --write", + "eslint --fix" + ], + "*.{ts,tsx}": [ + "prettier --parser=typescript --write", + "eslint --fix" + ] + }, + "dependencies": { + "query-string": "^8", + "ssml-document": "^1" + }, + "devDependencies": { + "@commitlint/cli": "^18", + "@lobehub/lint": "latest", + "@types/node": "^20", + "@types/query-string": "^6", + "@types/uuid": "^9", + "@vercel/node": "^3.0.7", + "commitlint": "^18", + "eslint": "^8", + "husky": "^8", + "lint-staged": "^15", + "prettier": "^3", + "remark-cli": "^11", + "semantic-release": "^21", + "typescript": "^5" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + } +} diff --git a/src/cors.ts b/src/cors.ts new file mode 100644 index 0000000..a8be6c5 --- /dev/null +++ b/src/cors.ts @@ -0,0 +1,140 @@ +/** + * Multi purpose CORS lib. + * Note: Based on the `cors` package in npm but using only + * web APIs. Feel free to use it in your own projects. + */ + +type StaticOrigin = boolean | string | RegExp | (boolean | string | RegExp)[]; + +type OriginFn = (origin: string | undefined, req: Request) => StaticOrigin | Promise; + +interface CorsOptions { + allowedHeaders?: string | string[]; + credentials?: boolean; + exposedHeaders?: string | string[]; + maxAge?: number; + methods?: string | string[]; + optionsSuccessStatus?: number; + origin?: StaticOrigin | OriginFn; + preflightContinue?: boolean; +} + +const defaultOptions: CorsOptions = { + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', + optionsSuccessStatus: 204, + origin: '*', + preflightContinue: false, +}; + +function isOriginAllowed(origin: string, allowed: StaticOrigin): boolean { + return Array.isArray(allowed) + ? allowed.some((o) => isOriginAllowed(origin, o)) + : typeof allowed === 'string' + ? origin === allowed + : allowed instanceof RegExp + ? allowed.test(origin) + : !!allowed; +} + +function getOriginHeaders(reqOrigin: string | undefined, origin: StaticOrigin) { + const headers = new Headers(); + + if (origin === '*') { + // Allow any origin + headers.set('Access-Control-Allow-Origin', '*'); + } else if (typeof origin === 'string') { + // Fixed origin + headers.set('Access-Control-Allow-Origin', origin); + headers.append('Vary', 'Origin'); + } else { + const allowed = isOriginAllowed(reqOrigin ?? '', origin); + + if (allowed && reqOrigin) { + headers.set('Access-Control-Allow-Origin', reqOrigin); + } + headers.append('Vary', 'Origin'); + } + + return headers; +} + +// originHeadersFromReq + +async function originHeadersFromReq(req: Request, origin: StaticOrigin | OriginFn) { + const reqOrigin = req.headers.get('Origin') || undefined; + const value = typeof origin === 'function' ? await origin(reqOrigin, req) : origin; + + if (!value) return; + return getOriginHeaders(reqOrigin, value); +} + +function getAllowedHeaders(req: Request, allowed?: string | string[]) { + const headers = new Headers(); + + if (!allowed) { + allowed = req.headers.get('Access-Control-Request-Headers')!; + headers.append('Vary', 'Access-Control-Request-Headers'); + } else if (Array.isArray(allowed)) { + // If the allowed headers is an array, turn it into a string + allowed = allowed.join(','); + } + if (allowed) { + headers.set('Access-Control-Allow-Headers', allowed); + } + + return headers; +} + +export default async function cors(req: Request, res: Response, options?: CorsOptions) { + const opts = { ...defaultOptions, ...options }; + const { headers } = res; + const originHeaders = await originHeadersFromReq(req, opts.origin ?? false); + const mergeHeaders = (v: string, k: string) => { + if (k === 'Vary') headers.append(k, v); + else headers.set(k, v); + }; + + // If there's no origin we won't touch the response + if (!originHeaders) return res; + + originHeaders.forEach(mergeHeaders); + + if (opts.credentials) { + headers.set('Access-Control-Allow-Credentials', 'true'); + } + + const exposed = Array.isArray(opts.exposedHeaders) + ? opts.exposedHeaders.join(',') + : opts.exposedHeaders; + + if (exposed) { + headers.set('Access-Control-Expose-Headers', exposed); + } + + // Handle the preflight request + if (req.method === 'OPTIONS') { + if (opts.methods) { + const methods = Array.isArray(opts.methods) ? opts.methods.join(',') : opts.methods; + + headers.set('Access-Control-Allow-Methods', methods); + } + + getAllowedHeaders(req, opts.allowedHeaders).forEach(mergeHeaders); + + if (typeof opts.maxAge === 'number') { + headers.set('Access-Control-Max-Age', String(opts.maxAge)); + } + + if (opts.preflightContinue) return res; + + headers.set('Content-Length', '0'); + return new Response(null, { headers, status: opts.optionsSuccessStatus }); + } + + // If we got here, it's a normal request + return res; +} + +export function initCors(options?: CorsOptions) { + return (req: Request, res: Response) => cors(req, res, options); +} diff --git a/src/genSSML.ts b/src/genSSML.ts new file mode 100644 index 0000000..1e0bc1e --- /dev/null +++ b/src/genSSML.ts @@ -0,0 +1,29 @@ +import { Document, ServiceProvider } from 'ssml-document'; + +export type StyleName = + | 'affectionate' + | 'angry' + | 'calm' + | 'cheerful' + | 'disgruntled' + | 'embarrassed' + | 'fearful' + | 'general' + | 'gentle' + | 'sad' + | 'serious'; + +export interface SsmlOptions { + name: string; + pitch?: number; + rate?: number; + style?: StyleName; +} + +export const genSSML = (text: string, options: SsmlOptions) => { + let ssml = new Document().voice(options.name); + if (options.style) ssml.expressAs({ style: options.style }); + if (options.pitch || options.rate) ssml.prosody({ pitch: options.pitch, rate: options.rate }); + const result = ssml.say(text).render({ provider: ServiceProvider.Microsoft }); + return `${result}`; +}; diff --git a/src/postMicrosoftSpeech.ts b/src/postMicrosoftSpeech.ts new file mode 100644 index 0000000..5088dd4 --- /dev/null +++ b/src/postMicrosoftSpeech.ts @@ -0,0 +1,44 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { type SsmlOptions, genSSML } from './genSSML'; + +const API = + 'https://southeastasia.api.speech.microsoft.com/accfreetrial/texttospeech/acc/v3.0-beta1/vcg/speak'; + +export const postMicrosoftSpeech = (text: string, options: SsmlOptions): [any, any] => { + const data = JSON.stringify({ + offsetInPlainText: 0, + properties: { + SpeakTriggerSource: 'AccTuningPagePlayButton', + }, + ssml: genSSML(text, options), + ttsAudioFormat: 'audio-24khz-160kbitrate-mono-mp3', + }); + + const DEFAULT_HEADERS = { + 'accept': '*/*', + 'accept-language': 'zh-CN,zh;q=0.9', + 'authority': 'southeastasia.api.speech.microsoft.com', + 'content-type': 'application/json', + 'customvoiceconnectionid': uuidv4(), + 'origin': 'https://speech.microsoft.com', + 'sec-ch-ua': '"Google Chrome";v="111", "Not(A:Brand";v="8", "Chromium";v="111"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Windows"', + 'sec-fetch-dest': 'empty', + 'sec-fetch-mode': 'cors', + 'sec-fetch-site': 'same-site', + 'user-agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36', + }; + + return [ + API, + { + body: data, + headers: DEFAULT_HEADERS, + method: 'POST', + responseType: 'arraybuffer', + }, + ]; +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b7e1b04 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "plugins": [ + { + "name": "next" + } + ] + }, + "exclude": ["node_modules"], + "include": ["api", "**/*.ts", "**/*.d.ts", "**/*.tsx"], + "ts-node": { + "compilerOptions": { + "module": "commonjs" + } + } +}