diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 26f0a0d..0f1e80c 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,8 +1,6 @@ -import fs from "node:fs"; -import type { IncomingMessage, ServerResponse } from "node:http"; -import path from "node:path"; -import type { Connect, Plugin, ViteDevServer } from "vite"; import { defineConfig, type MarkdownOptions } from "vitepress"; +import { mp3DownloaderPlugin } from "./vite-plugins/mp3-downloader"; +import { gttsPlugin } from "./vite-plugins/gtts"; const host = process.env.TAURI_DEV_HOST; @@ -69,7 +67,12 @@ export default defineConfig({ }, }, vite: { - plugins: [audioProxyPlugin() as any], + plugins: [ + mp3DownloaderPlugin() as any, + // (Python) pip install gtts + // gtts will intercept /api/tts away from opentts:ko container + gttsPlugin(), + ], envDir: process.cwd(), ssr: { noExternal: [], @@ -98,6 +101,10 @@ export default defineConfig({ port: 5174, } : undefined, + proxy: { + // docker run -it -p 5500:5500 synesthesiam/opentts:ko + "/api/tts": "http://localhost:5500", + }, }, // Env variables starting with the item of `envPrefix` will be exposed in tauri's source code through `import.meta.env`. envPrefix: ["VITE_", "TAURI_ENV_*"], @@ -112,56 +119,3 @@ export default defineConfig({ }, }, }); - -function audioProxyPlugin(): Plugin { - return { - name: "audio-proxy", - configureServer(server: ViteDevServer) { - // Vite dev server middlewares are connect-style: (req, res, next) - server.middlewares.use(async (req, res, next) => { - const reqUrl = req.url; - if (!reqUrl || !reqUrl.startsWith("/uploads/")) return next(); - if (!reqUrl.endsWith(".mp3")) return next(); - - try { - await proxyAudio(req, res, next); - } catch (e) { - console.error(e, reqUrl); - next(); - } - }); - }, - }; -} - -async function proxyAudio( - req: Connect.IncomingMessage, - res: ServerResponse, - next: Connect.NextFunction, -) { - const url = new URL(`https://www.howtostudykorean.com/wp-content${req.url}`); - - const segs = req.url!.slice(1).split("/"); - const filename = segs.pop()!; - const saveroot = "docs/public"; - const saveto = path.join(saveroot, ...segs, filename); - - if (fs.existsSync(saveto)) { - return next(); - } - - fs.mkdirSync(path.join(saveroot, ...segs), { recursive: true }); - - const r = await fetch(url, { redirect: "follow" }); - if (!r.ok) { - console.error(r); - return next(); - } - - fs.writeFileSync(saveto, Buffer.from(await r.arrayBuffer())); - // console.log(fs.statSync(saveto)); - console.log("saved to", saveto); - - res.setHeader("Content-Type", "audio/mpeg"); - fs.createReadStream(saveto).pipe(res); -} diff --git a/docs/.vitepress/theme/components/TTS.vue b/docs/.vitepress/theme/components/TTS.vue index 1276811..637e78e 100644 --- a/docs/.vitepress/theme/components/TTS.vue +++ b/docs/.vitepress/theme/components/TTS.vue @@ -46,7 +46,7 @@ async function playAudio() { if (navigator.platform === "Win32" || !(await playAudioTTSPlugin())) { if (!audioEl.value) return; - const url = new URL("http://localhost:5500/api/tts"); + const url = new URL("/api/tts", location.origin); url.searchParams.set("voice", "glow-speak:ko_kss"); url.searchParams.set("text", getMainText()); diff --git a/docs/.vitepress/vite-plugins/gtts.ts b/docs/.vitepress/vite-plugins/gtts.ts new file mode 100644 index 0000000..3183606 --- /dev/null +++ b/docs/.vitepress/vite-plugins/gtts.ts @@ -0,0 +1,82 @@ +import fs from "node:fs"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import path from "node:path"; +import type { Connect, Plugin, ViteDevServer } from "vite"; +import { spawn } from "child_process"; + +export function gttsPlugin(): Plugin { + return { + name: "gtts", + configureServer(server: ViteDevServer) { + // Vite dev server middlewares are connect-style: (req, res, next) + server.middlewares.use(async (req, res, next) => { + const reqUrl = req.url; + if (!reqUrl) return next(); + + const url = new URL(reqUrl, "http://localhost"); + if (url.pathname !== "/api/tts") return next(); + + const text = url.searchParams.get("text"); + if (!text) return next(); + + try { + await gtts(text, req, res, next); + } catch (e) { + console.error(e, reqUrl); + next(); + } + }); + }, + }; +} + +let fileList: Record = {}; + +async function gtts( + text: string, + req: Connect.IncomingMessage, + res: ServerResponse, + next: Connect.NextFunction, +) { + text = text.replace(/["]/g, " "); + + const saveroot = "docs/.vitepress/cache/gtts"; + fs.mkdirSync(saveroot, { recursive: true }); + + const fileListJSON = path.join(saveroot, "gtts.json"); + + if (Object.keys(fileList).length === 0 && fs.existsSync(fileListJSON)) { + fileList = JSON.parse(fs.readFileSync(fileListJSON, "utf-8")); + } + + const fileNo = fileList[text] || Object.keys(fileList).length; + const filename = `${fileNo}.mp3`; + const saveto = path.join(saveroot, filename); + + if (!fs.existsSync(saveto)) { + await new Promise((resolve, reject) => { + const p = spawn( + "gtts-cli", + [ + `"${text}"`, + ...["--lang", "ko"], + ...["--output", path.join(process.cwd(), saveto)], + ], + { + stdio: "inherit", + shell: true, + }, + ); + p.on("error", reject).on("close", resolve); + }); + fileList[text] = fileNo; + console.log(`gtts: ${text} saved to ${saveto}`); + + fs.writeFileSync(fileListJSON, JSON.stringify(fileList, null, 2)); + } else { + // console.log(`gtts: ${text} loaded from ${saveto}`); + } + + res.setHeader("Content-Type", "audio/mpeg"); + fs.createReadStream(saveto).pipe(res); +} diff --git a/docs/.vitepress/vite-plugins/mp3-downloader.ts b/docs/.vitepress/vite-plugins/mp3-downloader.ts new file mode 100644 index 0000000..b7f1491 --- /dev/null +++ b/docs/.vitepress/vite-plugins/mp3-downloader.ts @@ -0,0 +1,57 @@ +import fs from "node:fs"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import path from "node:path"; +import type { Connect, Plugin, ViteDevServer } from "vite"; + +export function mp3DownloaderPlugin(): Plugin { + return { + name: "mp3-downloader", + configureServer(server: ViteDevServer) { + // Vite dev server middlewares are connect-style: (req, res, next) + server.middlewares.use(async (req, res, next) => { + const reqUrl = req.url; + if (!reqUrl || !reqUrl.startsWith("/uploads/")) return next(); + if (!reqUrl.endsWith(".mp3")) return next(); + + try { + await mp3Downloader(req, res, next); + } catch (e) { + console.error(e, reqUrl); + next(); + } + }); + }, + }; +} + +async function mp3Downloader( + req: Connect.IncomingMessage, + res: ServerResponse, + next: Connect.NextFunction, +) { + const url = new URL(`https://www.howtostudykorean.com/wp-content${req.url}`); + + const segs = req.url!.slice(1).split("/"); + const filename = segs.pop()!; + const saveroot = "docs/public"; + const saveto = path.join(saveroot, ...segs, filename); + + if (fs.existsSync(saveto)) { + return next(); + } + + fs.mkdirSync(path.join(saveroot, ...segs), { recursive: true }); + + const r = await fetch(url, { redirect: "follow" }); + if (!r.ok) { + console.error(r); + return next(); + } + + fs.writeFileSync(saveto, Buffer.from(await r.arrayBuffer())); + // console.log(fs.statSync(saveto)); + console.log("saved to", saveto); + + res.setHeader("Content-Type", "audio/mpeg"); + fs.createReadStream(saveto).pipe(res); +} diff --git a/package.json b/package.json index a11d62f..d7a6c31 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@types/node": "^24.13.3", "markdown-it": "^14.3.0", "pdfjs-dist": "^5.7.284", + "shell-quote": "^1.10.0", "tauri-plugin-tts-api": "^0.1.11", "vite": "^8.1.5", "yaml": "^2.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36969ec..a3911ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: pdfjs-dist: specifier: ^5.7.284 version: 5.7.284 + shell-quote: + specifier: ^1.10.0 + version: 1.10.0 tauri-plugin-tts-api: specifier: ^0.1.11 version: 0.1.11 @@ -1088,6 +1091,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} + engines: {node: '>= 0.4'} + shiki@3.23.0: resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} @@ -2109,6 +2116,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 + shell-quote@1.10.0: {} + shiki@3.23.0: dependencies: '@shikijs/core': 3.23.0