Create Kokoro TTS JavaScript library (#3)
* Set up JS project * Finalise JS library * Update README * Fix package.json repository url * Rename package -> `kokoro-js` * Fix samples in README * Cleanup README * Bump `phonemizer` version * Create web demo * Run prettier * Link to model used in demo * Enable multithreading in HF space demo (~40% faster) * Add link to demo in README * Bump to v1.0.1
This commit is contained in:
24
kokoro.js/demo/.gitignore
vendored
Normal file
24
kokoro.js/demo/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
59
kokoro.js/demo/README.md
Normal file
59
kokoro.js/demo/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: Kokoro Text-to-Speech
|
||||
emoji: 🗣️
|
||||
colorFrom: indigo
|
||||
colorTo: purple
|
||||
sdk: static
|
||||
pinned: false
|
||||
license: apache-2.0
|
||||
short_description: High-quality speech synthesis powered by Kokoro TTS
|
||||
header: mini
|
||||
models:
|
||||
- onnx-community/Kokoro-82M-ONNX
|
||||
custom_headers:
|
||||
cross-origin-embedder-policy: require-corp
|
||||
cross-origin-opener-policy: same-origin
|
||||
cross-origin-resource-policy: cross-origin
|
||||
---
|
||||
|
||||
# Kokoro Text-to-Speech
|
||||
|
||||
A simple React + Vite application for running [Kokoro](https://github.com/hexgrad/kokoro), a frontier text-to-speech model for its size. The model runs 100% locally in the browser using [kokoro-js](https://www.npmjs.com/package/kokoro-js) and [🤗 Transformers.js](https://www.npmjs.com/package/@huggingface/transformers)!
|
||||
|
||||
## Getting Started
|
||||
|
||||
Follow the steps below to set up and run the application.
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
Clone the examples repository from GitHub:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/hexgrad/kokoro.git
|
||||
```
|
||||
|
||||
### 2. Navigate to the Project Directory
|
||||
|
||||
Change your working directory to the `demo` folder:
|
||||
|
||||
```sh
|
||||
cd kokoro/kokoro.js/demo
|
||||
```
|
||||
|
||||
### 3. Install Dependencies
|
||||
|
||||
Install the necessary dependencies using npm:
|
||||
|
||||
```sh
|
||||
npm i
|
||||
```
|
||||
|
||||
### 4. Run the Development Server
|
||||
|
||||
Start the development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The application should now be running locally. Open your browser and go to `http://localhost:5173` to see it in action.
|
||||
35
kokoro.js/demo/eslint.config.js
Normal file
35
kokoro.js/demo/eslint.config.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import react from "eslint-plugin-react";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
|
||||
export default [
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
files: ["**/*.{js,jsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
settings: { react: { version: "18.3" } },
|
||||
plugins: {
|
||||
react,
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs["jsx-runtime"].rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react/jsx-no-target-blank": "off",
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
},
|
||||
},
|
||||
];
|
||||
13
kokoro.js/demo/index.html
Normal file
13
kokoro.js/demo/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/hf-logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kokoro Text-to-Speech</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4680
kokoro.js/demo/package-lock.json
generated
Normal file
4680
kokoro.js/demo/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
kokoro.js/demo/package.json
Normal file
33
kokoro.js/demo/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "kokoro-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"kokoro-js": "file:..",
|
||||
"motion": "^11.12.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.15.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"globals": "^15.12.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"vite": "^6.0.1"
|
||||
}
|
||||
}
|
||||
6
kokoro.js/demo/postcss.config.js
Normal file
6
kokoro.js/demo/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
8
kokoro.js/demo/public/hf-logo.svg
Normal file
8
kokoro.js/demo/public/hf-logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 34 KiB |
9
kokoro.js/demo/public/wave.svg
Normal file
9
kokoro.js/demo/public/wave.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="198">
|
||||
<defs>
|
||||
<linearGradient id="a" x1="50%" x2="50%" y1="-10.959%" y2="100%">
|
||||
<stop stop-color="#57BBC1" stop-opacity=".25" offset="0%"/>
|
||||
<stop stop-color="#015871" offset="100%"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#a)" fill-rule="evenodd" d="M.005 121C311 121 409.898-.25 811 0c400 0 500 121 789 121v77H0s.005-48 .005-77z" transform="matrix(-1 0 0 1 1600 0)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 465 B |
144
kokoro.js/demo/src/App.jsx
Normal file
144
kokoro.js/demo/src/App.jsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
export default function App() {
|
||||
// Create a reference to the worker object.
|
||||
const worker = useRef(null);
|
||||
|
||||
const [inputText, setInputText] = useState("Life is like a box of chocolates. You never know what you're gonna get.");
|
||||
const [selectedSpeaker, setSelectedSpeaker] = useState("af");
|
||||
|
||||
const [status, setStatus] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loadingMessage, setLoadingMessage] = useState("Loading model (only downloaded once)...");
|
||||
|
||||
const [results, setResults] = useState([]);
|
||||
|
||||
// We use the `useEffect` hook to setup the worker as soon as the `App` component is mounted.
|
||||
useEffect(() => {
|
||||
// Create the worker if it does not yet exist.
|
||||
worker.current ??= new Worker(new URL("./worker.js", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
|
||||
// Create a callback function for messages from the worker thread.
|
||||
const onMessageReceived = (e) => {
|
||||
switch (e.data.status) {
|
||||
// TODO: WebGPU feature checking
|
||||
// case "feature-success":
|
||||
// break;
|
||||
|
||||
// case "feature-error":
|
||||
// setError(e.data.data);
|
||||
// break;
|
||||
|
||||
case "ready":
|
||||
setStatus("ready");
|
||||
break;
|
||||
|
||||
case "complete":
|
||||
const { audio, text } = e.data;
|
||||
// Generation complete: re-enable the "Generate" button
|
||||
setResults((prev) => [{ text, src: audio }, ...prev]);
|
||||
setStatus("ready");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const onErrorReceived = (e) => {
|
||||
console.error("Worker error:", e);
|
||||
};
|
||||
|
||||
// Attach the callback function as an event listener.
|
||||
worker.current.addEventListener("message", onMessageReceived);
|
||||
worker.current.addEventListener("error", onErrorReceived);
|
||||
|
||||
// Define a cleanup function for when the component is unmounted.
|
||||
return () => {
|
||||
worker.current.removeEventListener("message", onMessageReceived);
|
||||
worker.current.removeEventListener("error", onErrorReceived);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
setStatus("running");
|
||||
|
||||
worker.current.postMessage({
|
||||
type: "generate",
|
||||
text: inputText.trim(),
|
||||
voice: selectedSpeaker,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full min-h-screen bg-gradient-to-br from-gray-900 to-gray-700 flex flex-col items-center justify-center p-4 relative overflow-hidden font-sans">
|
||||
<motion.div initial={{ opacity: 1 }} animate={{ opacity: status === null ? 1 : 0 }} transition={{ duration: 0.5 }} className="absolute w-screen h-screen justify-center flex flex-col items-center z-10 bg-gray-800/95 backdrop-blur-md" style={{ pointerEvents: status === null ? "auto" : "none" }}>
|
||||
<div className="w-[250px] h-[250px] border-4 border-white shadow-[0_0_0_5px_#4973ff] rounded-full overflow-hidden">
|
||||
<div className="loading-wave"></div>
|
||||
</div>
|
||||
<p className={`text-3xl my-5 text-center ${error ? "text-red-500" : "text-white"}`}>{error ?? loadingMessage}</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="max-w-3xl w-full space-y-8 relative z-[2]">
|
||||
<div className="text-center">
|
||||
<h1 className="text-5xl font-extrabold text-gray-100 mb-2 drop-shadow-lg font-heading">Kokoro Text-to-Speech</h1>
|
||||
<p className="text-2xl text-gray-300 font-semibold font-subheading">
|
||||
Powered by
|
||||
<a href="https://github.com/hexgrad/kokoro" target="_blank" rel="noreferrer" className="underline">
|
||||
Kokoro
|
||||
</a>
|
||||
and
|
||||
<a href="https://huggingface.co/docs/transformers.js" target="_blank" rel="noreferrer" className="underline">
|
||||
<img width="40" src="hf-logo.svg" className="inline translate-y-[-2px] me-1"></img>Transformers.js
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700 rounded-lg p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<textarea placeholder="Enter text..." value={inputText} onChange={(e) => setInputText(e.target.value)} className="w-full min-h-[100px] max-h-[300px] bg-gray-700/50 backdrop-blur-sm border-2 border-gray-600 rounded-xl resize-y text-gray-100 placeholder-gray-400 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" rows={Math.min(8, inputText.split("\n").length)} />
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<select value={selectedSpeaker} onChange={(e) => setSelectedSpeaker(e.target.value)} className="w-full bg-gray-700/50 backdrop-blur-sm border-2 border-gray-600 rounded-xl text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="af">Default (American Female)</option>
|
||||
<option value="af_bella">Bella (American Female)</option>
|
||||
<option value="af_nicole">Nicole (American Female)</option>
|
||||
<option value="af_sarah">Sarah (American Female)</option>
|
||||
<option value="af_sky">Sky (American Female)</option>
|
||||
<option value="am_adam">Adam (American Male)</option>
|
||||
<option value="am_michael">Michael (American Male)</option>
|
||||
<option value="bf_emma">Emma (British Female)</option>
|
||||
<option value="bf_isabella">Isabella (British Female)</option>
|
||||
<option value="bm_george">George (British Male)</option>
|
||||
<option value="bm_lewis">Lewis (British Male)</option>
|
||||
</select>
|
||||
<button type="submit" className="inline-flex justify-center items-center px-6 py-2 text-lg font-semibold bg-gradient-to-t from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 transition-colors duration-300 rounded-xl text-white disabled:opacity-50" disabled={status === "running" || inputText.trim() === ""}>
|
||||
{status === "running" ? "Generating..." : "Generate"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
<motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ duration: 0.5 }} className="max-h-[250px] overflow-y-auto px-2 mt-4 space-y-6 relative z-[2]">
|
||||
{results.map((result, i) => (
|
||||
<div key={i}>
|
||||
<div className="text-white bg-gray-800/70 backdrop-blur-sm border border-gray-700 rounded-lg p-4 z-10">
|
||||
<span className="absolute right-5 font-bold">#{results.length - i}</span>
|
||||
<p className="mb-3 max-w-[95%]">{result.text}</p>
|
||||
<audio controls src={result.src} className="w-full">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-[#015871] pointer-events-none absolute left-0 w-full h-[5%] bottom-[-50px]">
|
||||
<div className="wave"></div>
|
||||
<div className="wave"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
kokoro.js/demo/src/index.css
Normal file
100
kokoro.js/demo/src/index.css
Normal file
@@ -0,0 +1,100 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/*
|
||||
* Wave animations adapted from the following two demos:
|
||||
* - https://codepen.io/upasanaasopa/pen/poObEWZ
|
||||
* - https://codepen.io/breakstorm00/pen/qBJZQNB
|
||||
*/
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.loading-wave {
|
||||
position: relative;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #2c74b3;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 50px 0 rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.loading-wave:before,
|
||||
.loading-wave:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: black;
|
||||
transform: translate(-50%, -75%);
|
||||
}
|
||||
|
||||
.loading-wave:before {
|
||||
border-radius: 45%;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
animation: animate 5s linear infinite;
|
||||
}
|
||||
|
||||
.loading-wave:after {
|
||||
border-radius: 40%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
animation: animate 10s linear infinite;
|
||||
}
|
||||
|
||||
.wave {
|
||||
background: url(/wave.svg) repeat-x;
|
||||
position: absolute;
|
||||
top: -198px;
|
||||
width: 6400px;
|
||||
height: 198px;
|
||||
animation: wave 7s cubic-bezier(0.36, 0.45, 0.63, 0.53) infinite;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.wave:nth-of-type(2) {
|
||||
top: -175px;
|
||||
animation:
|
||||
wave 7s cubic-bezier(0.36, 0.45, 0.63, 0.53) -0.125s infinite,
|
||||
swell 7s ease -1.25s infinite;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0% {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
margin-left: -1600px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes swell {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate3d(0, -25px, 0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate3d(0, 5px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes animate {
|
||||
0% {
|
||||
transform: translate(-50%, -75%) rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-50%, -75%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
10
kokoro.js/demo/src/main.jsx
Normal file
10
kokoro.js/demo/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.jsx";
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
20
kokoro.js/demo/src/worker.js
Normal file
20
kokoro.js/demo/src/worker.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { KokoroTTS } from "kokoro-js";
|
||||
|
||||
const model_id = "onnx-community/Kokoro-82M-ONNX";
|
||||
const tts = await KokoroTTS.from_pretrained(model_id, {
|
||||
dtype: "q8", // Options: "fp32", "fp16", "q8", "q4", "q4f16"
|
||||
});
|
||||
|
||||
self.postMessage({ status: "ready" });
|
||||
|
||||
// Listen for messages from the main thread
|
||||
self.addEventListener("message", async (e) => {
|
||||
const { text, voice } = e.data;
|
||||
|
||||
// Generate speech
|
||||
const audio = await tts.generate(text, { voice });
|
||||
|
||||
// Send the audio file back to the main thread
|
||||
const blob = audio.toBlob();
|
||||
self.postMessage({ status: "complete", audio: URL.createObjectURL(blob), text });
|
||||
});
|
||||
8
kokoro.js/demo/tailwind.config.js
Normal file
8
kokoro.js/demo/tailwind.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
12
kokoro.js/demo/vite.config.js
Normal file
12
kokoro.js/demo/vite.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
worker: { format: "es" },
|
||||
build: {
|
||||
target: "esnext",
|
||||
},
|
||||
logLevel: process.env.NODE_ENV === "development" ? "error" : "info",
|
||||
});
|
||||
Reference in New Issue
Block a user