From d072875e43cb802266a353a9b08afd21e454de77 Mon Sep 17 00:00:00 2001 From: Timothy Carambat <rambat1010@gmail.com> Date: Wed, 7 Aug 2024 11:09:51 -0700 Subject: [PATCH] Add piperTTS in-browser text-to-speech (#2052) * Add piperTTS in-browser text-to-speech * update vite config * Add voice default + change prod public URL * uncheck file * Error handling bump package for better quality and voices * bump package * Remove pre-packed WASM - will not support offline first solution for docker * attach TTSProvider telem --- .github/workflows/dev-build.yaml | 2 +- README.md | 1 + frontend/package.json | 2 + .../TextToSpeech/PiperTTSOptions/index.jsx | 219 ++++++++++++++++++ .../Actions/TTSButton/index.jsx | 21 +- .../Actions/TTSButton/piperTTS.jsx | 90 +++++++ frontend/src/media/ttsproviders/piper.png | Bin 0 -> 11283 bytes .../GeneralSettings/AudioPreference/tts.jsx | 9 + frontend/src/utils/piperTTS/index.js | 138 +++++++++++ frontend/src/utils/piperTTS/worker.js | 94 ++++++++ frontend/vite.config.js | 11 +- frontend/yarn.lock | 125 ++++++++++ server/endpoints/api/openai/index.js | 2 + server/endpoints/api/workspace/index.js | 3 + server/endpoints/api/workspaceThread/index.js | 3 + server/endpoints/chat.js | 2 + server/endpoints/workspaceThreads.js | 1 + server/endpoints/workspaces.js | 1 + server/models/documents.js | 2 + server/models/systemSettings.js | 3 + server/utils/helpers/updateENV.js | 13 +- 21 files changed, 736 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/TextToSpeech/PiperTTSOptions/index.jsx create mode 100644 frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/piperTTS.jsx create mode 100644 frontend/src/media/ttsproviders/piper.png create mode 100644 frontend/src/utils/piperTTS/index.js create mode 100644 frontend/src/utils/piperTTS/worker.js diff --git a/.github/workflows/dev-build.yaml b/.github/workflows/dev-build.yaml index a7632dfd0..e3bb1d556 100644 --- a/.github/workflows/dev-build.yaml +++ b/.github/workflows/dev-build.yaml @@ -6,7 +6,7 @@ concurrency: on: push: - branches: ['558-multi-modal-support'] # put your current branch to create a build. Core team only. + branches: ['pipertts-support'] # put your current branch to create a build. Core team only. paths-ignore: - '**.md' - 'cloud-deployments/*' diff --git a/README.md b/README.md index d7812265d..178fef08e 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ AnythingLLM divides your documents into objects called `workspaces`. A Workspace **TTS (text-to-speech) support:** - Native Browser Built-in (default) +- [PiperTTSLocal - runs in browser](https://github.com/rhasspy/piper) - [OpenAI TTS](https://platform.openai.com/docs/guides/text-to-speech/voice-options) - [ElevenLabs](https://elevenlabs.io/) diff --git a/frontend/package.json b/frontend/package.json index 3640e9ee5..8a60c1109 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "dependencies": { "@metamask/jazzicon": "^2.0.0", "@microsoft/fetch-event-source": "^2.0.1", + "@mintplex-labs/piper-tts-web": "^1.0.4", "@phosphor-icons/react": "^2.1.7", "@tremor/react": "^3.15.1", "dompurify": "^3.0.8", @@ -26,6 +27,7 @@ "markdown-it": "^13.0.1", "markdown-it-katex": "^2.0.3", "moment": "^2.30.1", + "onnxruntime-web": "^1.18.0", "pluralize": "^8.0.0", "react": "^18.2.0", "react-device-detect": "^2.2.2", diff --git a/frontend/src/components/TextToSpeech/PiperTTSOptions/index.jsx b/frontend/src/components/TextToSpeech/PiperTTSOptions/index.jsx new file mode 100644 index 000000000..323bf3ad5 --- /dev/null +++ b/frontend/src/components/TextToSpeech/PiperTTSOptions/index.jsx @@ -0,0 +1,219 @@ +import { useState, useEffect, useRef } from "react"; +import PiperTTSClient from "@/utils/piperTTS"; +import { titleCase } from "text-case"; +import { humanFileSize } from "@/utils/numbers"; +import showToast from "@/utils/toast"; +import { CircleNotch, PauseCircle, PlayCircle } from "@phosphor-icons/react"; + +export default function PiperTTSOptions({ settings }) { + return ( + <> + <p className="text-sm font-base text-white text-opacity-60 mb-4"> + All PiperTTS models will run in your browser locally. This can be + resource intensive on lower-end devices. + </p> + <div className="flex gap-x-4 items-center"> + <PiperTTSModelSelection settings={settings} /> + </div> + </> + ); +} + +function voicesByLanguage(voices = []) { + const voicesByLanguage = voices.reduce((acc, voice) => { + const langName = voice?.language?.name_english ?? "Unlisted"; + acc[langName] = acc[langName] || []; + acc[langName].push(voice); + return acc; + }, {}); + return Object.entries(voicesByLanguage); +} + +function voiceDisplayName(voice) { + const { is_stored, name, quality, files } = voice; + const onnxFileKey = Object.keys(files).find((key) => key.endsWith(".onnx")); + const fileSize = files?.[onnxFileKey]?.size_bytes || 0; + return `${is_stored ? "✔ " : ""}${titleCase(name)}-${quality === "low" ? "Low" : "HQ"} (${humanFileSize(fileSize)})`; +} + +function PiperTTSModelSelection({ settings }) { + const [loading, setLoading] = useState(true); + const [voices, setVoices] = useState([]); + const [selectedVoice, setSelectedVoice] = useState( + settings?.TTSPiperTTSVoiceModel + ); + + function flushVoices() { + PiperTTSClient.flush() + .then(() => + showToast("All voices flushed from browser storage", "info", { + clear: true, + }) + ) + .catch((e) => console.error(e)); + } + + useEffect(() => { + PiperTTSClient.voices() + .then((voices) => { + if (voices?.length !== 0) return setVoices(voices); + throw new Error("Could not fetch voices from web worker."); + }) + .catch((e) => { + console.error(e); + }) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( + <div className="flex flex-col w-60"> + <label className="text-white text-sm font-semibold block mb-3"> + Voice Model Selection + </label> + <select + name="TTSPiperTTSVoiceModel" + value="" + disabled={true} + className="border-none bg-zinc-900 border-gray-500 text-white text-sm rounded-lg block w-full p-2.5" + > + <option value="" disabled={true}> + -- loading available models -- + </option> + </select> + </div> + ); + } + + return ( + <div className="flex flex-col w-fit"> + <div className="flex flex-col w-60"> + <label className="text-white text-sm font-semibold block mb-3"> + Voice Model Selection + </label> + <div className="flex items-center w-fit gap-x-4 mb-2"> + <select + name="TTSPiperTTSVoiceModel" + required={true} + onChange={(e) => setSelectedVoice(e.target.value)} + value={selectedVoice} + className="border-none flex-shrink-0 bg-zinc-900 border-gray-500 text-white text-sm rounded-lg block w-full p-2.5" + > + {voicesByLanguage(voices).map(([lang, voices]) => { + return ( + <optgroup key={lang} label={lang}> + {voices.map((voice) => ( + <option key={voice.key} value={voice.key}> + {voiceDisplayName(voice)} + </option> + ))} + </optgroup> + ); + })} + </select> + <DemoVoiceSample voiceId={selectedVoice} /> + </div> + <p className="text-xs text-white/40"> + The "✔" indicates this model is already stored locally and does not + need to be downloaded when run. + </p> + </div> + {!!voices.find((voice) => voice.is_stored) && ( + <button + type="button" + onClick={flushVoices} + className="w-fit border-none hover:text-white hover:underline text-white/40 text-sm my-4" + > + Flush voice cache + </button> + )} + </div> + ); +} + +function DemoVoiceSample({ voiceId }) { + const playerRef = useRef(null); + const [speaking, setSpeaking] = useState(false); + const [loading, setLoading] = useState(false); + const [audioSrc, setAudioSrc] = useState(null); + + async function speakMessage(e) { + e.preventDefault(); + if (speaking) { + playerRef?.current?.pause(); + return; + } + + try { + if (!audioSrc) { + setLoading(true); + const client = new PiperTTSClient({ voiceId }); + const blobUrl = await client.getAudioBlobForText( + "Hello, welcome to AnythingLLM!" + ); + setAudioSrc(blobUrl); + setLoading(false); + client.worker?.terminate(); + PiperTTSClient._instance = null; + } else { + playerRef.current.play(); + } + } catch (e) { + console.error(e); + setLoading(false); + setSpeaking(false); + } + } + + useEffect(() => { + function setupPlayer() { + if (!playerRef?.current) return; + playerRef.current.addEventListener("play", () => { + setSpeaking(true); + }); + + playerRef.current.addEventListener("pause", () => { + playerRef.current.currentTime = 0; + setSpeaking(false); + setAudioSrc(null); + }); + } + setupPlayer(); + }, []); + + return ( + <button + type="button" + onClick={speakMessage} + className="border-none text-zinc-300 flex items-center gap-x-1" + > + {speaking ? ( + <> + <PauseCircle size={20} className="flex-shrink-0" /> + <p className="text-sm flex-shrink-0">Stop demo</p> + </> + ) : ( + <> + {loading ? ( + <> + <CircleNotch size={20} className="animate-spin flex-shrink-0" /> + <p className="text-sm flex-shrink-0">Loading voice</p> + </> + ) : ( + <> + <PlayCircle size={20} className="flex-shrink-0" /> + <p className="text-sm flex-shrink-0">Play sample</p> + </> + )} + </> + )} + <audio + ref={playerRef} + hidden={true} + src={audioSrc} + autoPlay={true} + controls={false} + /> + </button> + ); +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/index.jsx index 56d32e847..88d063387 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/index.jsx @@ -1,9 +1,11 @@ import { useEffect, useState } from "react"; import NativeTTSMessage from "./native"; import AsyncTTSMessage from "./asyncTts"; +import PiperTTSMessage from "./piperTTS"; import System from "@/models/system"; export default function TTSMessage({ slug, chatId, message }) { + const [settings, setSettings] = useState({}); const [provider, setProvider] = useState("native"); const [loading, setLoading] = useState(true); @@ -11,13 +13,26 @@ export default function TTSMessage({ slug, chatId, message }) { async function getSettings() { const _settings = await System.keys(); setProvider(_settings?.TextToSpeechProvider ?? "native"); + setSettings(_settings); setLoading(false); } getSettings(); }, []); if (!chatId || loading) return null; - if (provider !== "native") - return <AsyncTTSMessage slug={slug} chatId={chatId} />; - return <NativeTTSMessage message={message} />; + + switch (provider) { + case "openai": + case "elevenlabs": + return <AsyncTTSMessage slug={slug} chatId={chatId} />; + case "piper_local": + return ( + <PiperTTSMessage + voiceId={settings?.TTSPiperTTSVoiceModel} + message={message} + /> + ); + default: + return <NativeTTSMessage message={message} />; + } } diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/piperTTS.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/piperTTS.jsx new file mode 100644 index 000000000..d384faf1e --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/piperTTS.jsx @@ -0,0 +1,90 @@ +import { useEffect, useState, useRef } from "react"; +import { SpeakerHigh, PauseCircle, CircleNotch } from "@phosphor-icons/react"; +import { Tooltip } from "react-tooltip"; +import PiperTTSClient from "@/utils/piperTTS"; + +export default function PiperTTS({ voiceId = null, message }) { + const playerRef = useRef(null); + const [speaking, setSpeaking] = useState(false); + const [loading, setLoading] = useState(false); + const [audioSrc, setAudioSrc] = useState(null); + + async function speakMessage(e) { + e.preventDefault(); + if (speaking) { + playerRef?.current?.pause(); + return; + } + + try { + if (!audioSrc) { + setLoading(true); + const client = new PiperTTSClient({ voiceId }); + const blobUrl = await client.getAudioBlobForText(message); + setAudioSrc(blobUrl); + setLoading(false); + } else { + playerRef.current.play(); + } + } catch (e) { + console.error(e); + setLoading(false); + setSpeaking(false); + } + } + + useEffect(() => { + function setupPlayer() { + if (!playerRef?.current) return; + playerRef.current.addEventListener("play", () => { + setSpeaking(true); + }); + + playerRef.current.addEventListener("pause", () => { + playerRef.current.currentTime = 0; + setSpeaking(false); + }); + } + setupPlayer(); + }, []); + + return ( + <div className="mt-3 relative"> + <button + type="button" + onClick={speakMessage} + data-tooltip-id="message-to-speech" + data-tooltip-content={ + speaking ? "Pause TTS speech of message" : "TTS Speak message" + } + className="border-none text-zinc-300" + aria-label={speaking ? "Pause speech" : "Speak message"} + > + {speaking ? ( + <PauseCircle size={18} className="mb-1" /> + ) : ( + <> + {loading ? ( + <CircleNotch size={18} className="mb-1 animate-spin" /> + ) : ( + <SpeakerHigh size={18} className="mb-1" /> + )} + </> + )} + <audio + ref={playerRef} + hidden={true} + src={audioSrc} + autoPlay={true} + controls={false} + /> + </button> + <Tooltip + id="message-to-speech" + place="bottom" + delayShow={300} + className="tooltip !text-xs" + /> + </div> + ); +} diff --git a/frontend/src/media/ttsproviders/piper.png b/frontend/src/media/ttsproviders/piper.png new file mode 100644 index 0000000000000000000000000000000000000000..32d3ec5a7b25ce21f82cc8a6b4259114228d1b94 GIT binary patch literal 11283 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 Ow(IHY=d#Wzp$Pzk2pekv literal 0 HcmV?d00001 diff --git a/frontend/src/pages/GeneralSettings/AudioPreference/tts.jsx b/frontend/src/pages/GeneralSettings/AudioPreference/tts.jsx index dee8a8444..0ebab72de 100644 --- a/frontend/src/pages/GeneralSettings/AudioPreference/tts.jsx +++ b/frontend/src/pages/GeneralSettings/AudioPreference/tts.jsx @@ -7,9 +7,11 @@ import CTAButton from "@/components/lib/CTAButton"; import OpenAiLogo from "@/media/llmprovider/openai.png"; import AnythingLLMIcon from "@/media/logo/anything-llm-icon.png"; import ElevenLabsIcon from "@/media/ttsproviders/elevenlabs.png"; +import PiperTTSIcon from "@/media/ttsproviders/piper.png"; import BrowserNative from "@/components/TextToSpeech/BrowserNative"; import OpenAiTTSOptions from "@/components/TextToSpeech/OpenAiOptions"; import ElevenLabsTTSOptions from "@/components/TextToSpeech/ElevenLabsOptions"; +import PiperTTSOptions from "@/components/TextToSpeech/PiperTTSOptions"; const PROVIDERS = [ { @@ -33,6 +35,13 @@ const PROVIDERS = [ options: (settings) => <ElevenLabsTTSOptions settings={settings} />, description: "Use ElevenLabs's text to speech voices and technology.", }, + { + name: "PiperTTS", + value: "piper_local", + logo: PiperTTSIcon, + options: (settings) => <PiperTTSOptions settings={settings} />, + description: "Run TTS models locally in your browser privately.", + }, ]; export default function TextToSpeechProvider({ settings }) { diff --git a/frontend/src/utils/piperTTS/index.js b/frontend/src/utils/piperTTS/index.js new file mode 100644 index 000000000..5016af79e --- /dev/null +++ b/frontend/src/utils/piperTTS/index.js @@ -0,0 +1,138 @@ +import showToast from "../toast"; + +export default class PiperTTSClient { + static _instance; + voiceId = "en_US-hfc_female-medium"; + worker = null; + + constructor({ voiceId } = { voiceId: null }) { + if (PiperTTSClient._instance) { + this.voiceId = voiceId !== null ? voiceId : this.voiceId; + return PiperTTSClient._instance; + } + + this.voiceId = voiceId !== null ? voiceId : this.voiceId; + PiperTTSClient._instance = this; + return this; + } + + #getWorker() { + if (!this.worker) + this.worker = new Worker(new URL("./worker.js", import.meta.url), { + type: "module", + }); + return this.worker; + } + + /** + * Get all available voices for a client + * @returns {Promise<import("@mintplex-labs/piper-tts-web/dist/types").Voice[]}>} + */ + static async voices() { + const tmpWorker = new Worker(new URL("./worker.js", import.meta.url), { + type: "module", + }); + tmpWorker.postMessage({ type: "voices" }); + return new Promise((resolve, reject) => { + let timeout = null; + const handleMessage = (event) => { + if (event.data.type !== "voices") { + console.log("PiperTTSWorker debug event:", event.data); + return; + } + resolve(event.data.voices); + tmpWorker.removeEventListener("message", handleMessage); + timeout && clearTimeout(timeout); + tmpWorker.terminate(); + }; + + timeout = setTimeout(() => { + reject("TTS Worker timed out."); + }, 30_000); + tmpWorker.addEventListener("message", handleMessage); + }); + } + + static async flush() { + const tmpWorker = new Worker(new URL("./worker.js", import.meta.url), { + type: "module", + }); + tmpWorker.postMessage({ type: "flush" }); + return new Promise((resolve, reject) => { + let timeout = null; + const handleMessage = (event) => { + if (event.data.type !== "flush") { + console.log("PiperTTSWorker debug event:", event.data); + return; + } + resolve(event.data.flushed); + tmpWorker.removeEventListener("message", handleMessage); + timeout && clearTimeout(timeout); + tmpWorker.terminate(); + }; + + timeout = setTimeout(() => { + reject("TTS Worker timed out."); + }, 30_000); + tmpWorker.addEventListener("message", handleMessage); + }); + } + + /** + * Runs prediction via webworker so we can get an audio blob back. + * @returns {Promise<{blobURL: string|null, error: string|null}>} objectURL blob: type. + */ + async waitForBlobResponse() { + return new Promise((resolve) => { + let timeout = null; + const handleMessage = (event) => { + if (event.data.type === "error") { + this.worker.removeEventListener("message", handleMessage); + timeout && clearTimeout(timeout); + return resolve({ blobURL: null, error: event.data.message }); + } + + if (event.data.type !== "result") { + console.log("PiperTTSWorker debug event:", event.data); + return; + } + resolve({ + blobURL: URL.createObjectURL(event.data.audio), + error: null, + }); + this.worker.removeEventListener("message", handleMessage); + timeout && clearTimeout(timeout); + }; + + timeout = setTimeout(() => { + resolve({ blobURL: null, error: "PiperTTSWorker Worker timed out." }); + }, 30_000); + this.worker.addEventListener("message", handleMessage); + }); + } + + async getAudioBlobForText(textToSpeak, voiceId = null) { + const primaryWorker = this.#getWorker(); + primaryWorker.postMessage({ + type: "init", + text: String(textToSpeak), + voiceId: voiceId ?? this.voiceId, + // Don't reference WASM because in the docker image + // the user will be connected to internet (mostly) + // and it bloats the app size on the frontend or app significantly + // and running the docker image fully offline is not an intended use-case unlike the app. + }); + + const { blobURL, error } = await this.waitForBlobResponse(); + if (!!error) { + showToast( + `Could not generate voice prediction. Error: ${error}`, + "error", + { clear: true } + ); + return; + } + + return blobURL; + } +} diff --git a/frontend/src/utils/piperTTS/worker.js b/frontend/src/utils/piperTTS/worker.js new file mode 100644 index 000000000..e0fa8aabb --- /dev/null +++ b/frontend/src/utils/piperTTS/worker.js @@ -0,0 +1,94 @@ +import * as TTS from "@mintplex-labs/piper-tts-web"; + +/** @type {import("@mintplexlabs/piper-web-tts").TtsSession | null} */ +let PIPER_SESSION = null; + +/** + * @typedef PredictionRequest + * @property {('init')} type + * @property {string} text - the text to inference on + * @property {import('@mintplexlabs/piper-web-tts').VoiceId} voiceId - the voiceID key to use. + * @property {string|null} baseUrl - the base URL to fetch WASMs from. + */ +/** + * @typedef PredictionRequestResponse + * @property {('result')} type + * @property {Blob} audio - the text to inference on + */ + +/** + * @typedef VoicesRequest + * @property {('voices')} type + * @property {string|null} baseUrl - the base URL to fetch WASMs from. + */ +/** + * @typedef VoicesRequestResponse + * @property {('voices')} type + * @property {[import("@mintplex-labs/piper-tts-web/dist/types")['Voice']]} voices - available voices in array + */ + +/** + * @typedef FlushRequest + * @property {('flush')} type + */ +/** + * @typedef FlushRequestResponse + * @property {('flush')} type + * @property {true} flushed + */ + +/** + * Web worker for generating client-side PiperTTS predictions + * @param {MessageEvent<PredictionRequest | VoicesRequest | FlushRequest>} event - The event object containing the prediction request + * @returns {Promise<PredictionRequestResponse|VoicesRequestResponse|FlushRequestResponse>} + */ +async function main(event) { + if (event.data.type === "voices") { + const stored = await TTS.stored(); + const voices = await TTS.voices(); + voices.forEach((voice) => (voice.is_stored = stored.includes(voice.key))); + + self.postMessage({ type: "voices", voices }); + return; + } + + if (event.data.type === "flush") { + await TTS.flush(); + self.postMessage({ type: "flush", flushed: true }); + return; + } + + if (event.data?.type !== "init") return; + if (!PIPER_SESSION) { + PIPER_SESSION = new TTS.TtsSession({ + voiceId: event.data.voiceId, + progress: (e) => self.postMessage(JSON.stringify(e)), + logger: (msg) => self.postMessage(msg), + ...(!!event.data.baseUrl + ? { + wasmPaths: { + onnxWasm: `${event.data.baseUrl}/piper/ort/`, + piperData: `${event.data.baseUrl}/piper/piper_phonemize.data`, + piperWasm: `${event.data.baseUrl}/piper/piper_phonemize.wasm`, + }, + } + : {}), + }); + } + + if (event.data.voiceId && PIPER_SESSION.voiceId !== event.data.voiceId) + PIPER_SESSION.voiceId = event.data.voiceId; + + PIPER_SESSION.predict(event.data.text) + .then((res) => { + if (res instanceof Blob) { + self.postMessage({ type: "result", audio: res }); + return; + } + }) + .catch((error) => { + self.postMessage({ type: "error", message: error.message, error }); // Will be an error. + }); +} + +self.addEventListener("message", main); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index b67e9ef7c..73b295be2 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -9,6 +9,14 @@ dns.setDefaultResultOrder("verbatim") // https://vitejs.dev/config/ export default defineConfig({ + assetsInclude: [ + './public/piper/ort-wasm-simd-threaded.wasm', + './public/piper/piper_phonemize.wasm', + './public/piper/piper_phonemize.data', + ], + worker: { + format: 'es' + }, server: { port: 3000, host: "localhost" @@ -60,7 +68,7 @@ export default defineConfig({ }, external: [ // Reduces transformation time by 50% and we don't even use this variant, so we can ignore. - /@phosphor-icons\/react\/dist\/ssr/ + /@phosphor-icons\/react\/dist\/ssr/, ] }, commonjsOptions: { @@ -68,6 +76,7 @@ export default defineConfig({ } }, optimizeDeps: { + include: ["@mintplex-labs/piper-tts-web"], esbuildOptions: { define: { global: "globalThis" diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 0f62957b1..4a56e4f92 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -496,6 +496,11 @@ resolved "https://registry.yarnpkg.com/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz#9ceecc94b49fbaa15666e38ae8587f64acce007d" integrity sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA== +"@mintplex-labs/piper-tts-web@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@mintplex-labs/piper-tts-web/-/piper-tts-web-1.0.4.tgz#016b196fa86dc8b616691dd381f3ca1939196444" + integrity sha512-Y24X+CJaGXoY5HFPSstHvJI6408OAtw3Pmq2OIYwpRpcwLLbgadWg8l1ODHNkgpB0Ps5fS9PAAQB60fHA3Bdag== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -532,6 +537,59 @@ resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31" integrity sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA== +"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" + integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== + +"@protobufjs/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" + integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== + +"@protobufjs/codegen@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb" + integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== + +"@protobufjs/eventemitter@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" + integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== + +"@protobufjs/fetch@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" + integrity sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ== + dependencies: + "@protobufjs/aspromise" "^1.1.1" + "@protobufjs/inquire" "^1.1.0" + +"@protobufjs/float@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" + integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== + +"@protobufjs/inquire@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089" + integrity sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q== + +"@protobufjs/path@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" + integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== + +"@protobufjs/pool@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" + integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== + +"@protobufjs/utf8@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" + integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== + "@remix-run/router@1.18.0": version "1.18.0" resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.18.0.tgz#20b033d1f542a100c1d57cfd18ecf442d1784732" @@ -652,6 +710,13 @@ resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.11.tgz#56588b17ae8f50c53983a524fc3cc47437969d64" integrity sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA== +"@types/node@>=13.7.0": + version "22.1.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.1.0.tgz#6d6adc648b5e03f0e83c78dc788c2b037d0ad94b" + integrity sha512-AOmuRF0R2/5j1knA3c6G3HOk523Ga+l+ZXltX8SF1+5oqcXijjfTd8fY3XRZqSihEu9XhtQnKYLmkFaoxgsJHw== + dependencies: + undici-types "~6.13.0" + "@types/prop-types@*": version "15.7.12" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6" @@ -1729,6 +1794,11 @@ flat-cache@^3.0.4: keyv "^4.5.3" rimraf "^3.0.2" +flatbuffers@^1.12.0: + version "1.12.0" + resolved "https://registry.yarnpkg.com/flatbuffers/-/flatbuffers-1.12.0.tgz#72e87d1726cb1b216e839ef02658aa87dcef68aa" + integrity sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ== + flatted@^3.2.9: version "3.3.1" resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a" @@ -1898,6 +1968,11 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== +guid-typescript@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/guid-typescript/-/guid-typescript-1.0.9.tgz#e35f77003535b0297ea08548f5ace6adb1480ddc" + integrity sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ== + has-bigints@^1.0.1, has-bigints@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" @@ -2413,6 +2488,11 @@ lodash@^4.17.21: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +long@^5.0.0, long@^5.2.3: + version "5.2.3" + resolved "https://registry.yarnpkg.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1" + integrity sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q== + loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -2611,6 +2691,23 @@ once@^1.3.0: dependencies: wrappy "1" +onnxruntime-common@1.18.0: + version "1.18.0" + resolved "https://registry.yarnpkg.com/onnxruntime-common/-/onnxruntime-common-1.18.0.tgz#b904dc6ff134e7f21a3eab702fac17538f59e116" + integrity sha512-lufrSzX6QdKrktAELG5x5VkBpapbCeS3dQwrXbN0eD9rHvU0yAWl7Ztju9FvgAKWvwd/teEKJNj3OwM6eTZh3Q== + +onnxruntime-web@^1.18.0: + version "1.18.0" + resolved "https://registry.yarnpkg.com/onnxruntime-web/-/onnxruntime-web-1.18.0.tgz#cd46268d9472f89697da0a3282f13129f0acbfa0" + integrity sha512-o1UKj4ABIj1gmG7ae0RKJ3/GT+3yoF0RRpfDfeoe0huzRW4FDRLfbkDETmdFAvnJEXuYDE0YT+hhkia0352StQ== + dependencies: + flatbuffers "^1.12.0" + guid-typescript "^1.0.9" + long "^5.2.3" + onnxruntime-common "1.18.0" + platform "^1.3.6" + protobufjs "^7.2.4" + open@^8.4.0: version "8.4.2" resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9" @@ -2713,6 +2810,11 @@ pirates@^4.0.1: resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg== +platform@^1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.6.tgz#48b4ce983164b209c2d45a107adb31f473a6e7a7" + integrity sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg== + pluralize@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" @@ -2802,6 +2904,24 @@ prop-types@^15.6.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +protobufjs@^7.2.4: + version "7.3.2" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.3.2.tgz#60f3b7624968868f6f739430cfbc8c9370e26df4" + integrity sha512-RXyHaACeqXeqAKGLDl68rQKbmObRsTIn4TYVUUug1KfS47YWCo5MacGITEryugIgZqORCvJWEk4l449POg5Txg== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.4" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.0" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.0" + "@types/node" ">=13.7.0" + long "^5.0.0" + punycode@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -3612,6 +3732,11 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +undici-types@~6.13.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.13.0.tgz#e3e79220ab8c81ed1496b5812471afd7cf075ea5" + integrity sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg== + update-browserslist-db@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz#7ca61c0d8650766090728046e416a8cde682859e" diff --git a/server/endpoints/api/openai/index.js b/server/endpoints/api/openai/index.js index 309575115..cd732f424 100644 --- a/server/endpoints/api/openai/index.js +++ b/server/endpoints/api/openai/index.js @@ -154,6 +154,7 @@ function apiOpenAICompatibleEndpoints(app) { workspace.chatProvider ?? process.env.LLM_PROVIDER ?? "openai", Embedder: process.env.EMBEDDING_ENGINE || "inherit", VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", }); await EventLogs.logEvent("api_sent_chat", { workspaceName: workspace?.name, @@ -180,6 +181,7 @@ function apiOpenAICompatibleEndpoints(app) { LLMSelection: process.env.LLM_PROVIDER || "openai", Embedder: process.env.EMBEDDING_ENGINE || "inherit", VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", }); await EventLogs.logEvent("api_sent_chat", { workspaceName: workspace?.name, diff --git a/server/endpoints/api/workspace/index.js b/server/endpoints/api/workspace/index.js index 719b73baf..3d4e90fb4 100644 --- a/server/endpoints/api/workspace/index.js +++ b/server/endpoints/api/workspace/index.js @@ -73,6 +73,7 @@ function apiWorkspaceEndpoints(app) { LLMSelection: process.env.LLM_PROVIDER || "openai", Embedder: process.env.EMBEDDING_ENGINE || "inherit", VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", }); await EventLogs.logEvent("api_workspace_created", { workspaceName: workspace?.name || "Unknown Workspace", @@ -622,6 +623,7 @@ function apiWorkspaceEndpoints(app) { LLMSelection: process.env.LLM_PROVIDER || "openai", Embedder: process.env.EMBEDDING_ENGINE || "inherit", VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", }); await EventLogs.logEvent("api_sent_chat", { workspaceName: workspace?.name, @@ -745,6 +747,7 @@ function apiWorkspaceEndpoints(app) { LLMSelection: process.env.LLM_PROVIDER || "openai", Embedder: process.env.EMBEDDING_ENGINE || "inherit", VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", }); await EventLogs.logEvent("api_sent_chat", { workspaceName: workspace?.name, diff --git a/server/endpoints/api/workspaceThread/index.js b/server/endpoints/api/workspaceThread/index.js index a636a85d2..e2c6af1c7 100644 --- a/server/endpoints/api/workspaceThread/index.js +++ b/server/endpoints/api/workspaceThread/index.js @@ -90,6 +90,7 @@ function apiWorkspaceThreadEndpoints(app) { LLMSelection: process.env.LLM_PROVIDER || "openai", Embedder: process.env.EMBEDDING_ENGINE || "inherit", VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", }); await EventLogs.logEvent("api_workspace_thread_created", { workspaceName: workspace?.name || "Unknown Workspace", @@ -416,6 +417,7 @@ function apiWorkspaceThreadEndpoints(app) { LLMSelection: process.env.LLM_PROVIDER || "openai", Embedder: process.env.EMBEDDING_ENGINE || "inherit", VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", }); await EventLogs.logEvent("api_sent_chat", { workspaceName: workspace?.name, @@ -567,6 +569,7 @@ function apiWorkspaceThreadEndpoints(app) { LLMSelection: process.env.LLM_PROVIDER || "openai", Embedder: process.env.EMBEDDING_ENGINE || "inherit", VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", }); await EventLogs.logEvent("api_sent_chat", { workspaceName: workspace?.name, diff --git a/server/endpoints/chat.js b/server/endpoints/chat.js index 787aba574..64beefeb6 100644 --- a/server/endpoints/chat.js +++ b/server/endpoints/chat.js @@ -98,6 +98,7 @@ function chatEndpoints(app) { Embedder: process.env.EMBEDDING_ENGINE || "inherit", VectorDbSelection: process.env.VECTOR_DB || "lancedb", multiModal: Array.isArray(attachments) && attachments?.length !== 0, + TTSSelection: process.env.TTS_PROVIDER || "native", }); await EventLogs.logEvent( @@ -226,6 +227,7 @@ function chatEndpoints(app) { Embedder: process.env.EMBEDDING_ENGINE || "inherit", VectorDbSelection: process.env.VECTOR_DB || "lancedb", multiModal: Array.isArray(attachments) && attachments?.length !== 0, + TTSSelection: process.env.TTS_PROVIDER || "native", }); await EventLogs.logEvent( diff --git a/server/endpoints/workspaceThreads.js b/server/endpoints/workspaceThreads.js index 42e502278..4e071992b 100644 --- a/server/endpoints/workspaceThreads.js +++ b/server/endpoints/workspaceThreads.js @@ -40,6 +40,7 @@ function workspaceThreadEndpoints(app) { LLMSelection: process.env.LLM_PROVIDER || "openai", Embedder: process.env.EMBEDDING_ENGINE || "inherit", VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", }, user?.id ); diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js index 4f523aaaf..43b093679 100644 --- a/server/endpoints/workspaces.js +++ b/server/endpoints/workspaces.js @@ -55,6 +55,7 @@ function workspaceEndpoints(app) { LLMSelection: process.env.LLM_PROVIDER || "openai", Embedder: process.env.EMBEDDING_ENGINE || "inherit", VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", }, user?.id ); diff --git a/server/models/documents.js b/server/models/documents.js index 80d4fd850..43ec5f9f4 100644 --- a/server/models/documents.js +++ b/server/models/documents.js @@ -142,6 +142,7 @@ const Document = { LLMSelection: process.env.LLM_PROVIDER || "openai", Embedder: process.env.EMBEDDING_ENGINE || "inherit", VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", }); await EventLogs.logEvent( "workspace_documents_added", @@ -185,6 +186,7 @@ const Document = { LLMSelection: process.env.LLM_PROVIDER || "openai", Embedder: process.env.EMBEDDING_ENGINE || "inherit", VectorDbSelection: process.env.VECTOR_DB || "lancedb", + TTSSelection: process.env.TTS_PROVIDER || "native", }); await EventLogs.logEvent( "workspace_documents_removed", diff --git a/server/models/systemSettings.js b/server/models/systemSettings.js index 216f63ad5..b85f3cb8c 100644 --- a/server/models/systemSettings.js +++ b/server/models/systemSettings.js @@ -209,6 +209,9 @@ const SystemSettings = { // Eleven Labs TTS TTSElevenLabsKey: !!process.env.TTS_ELEVEN_LABS_KEY, TTSElevenLabsVoiceModel: process.env.TTS_ELEVEN_LABS_VOICE_MODEL, + // Piper TTS + TTSPiperTTSVoiceModel: + process.env.TTS_PIPER_VOICE_MODEL ?? "en_US-hfc_female-medium", // -------------------------------------------------------- // Agent Settings & Configs diff --git a/server/utils/helpers/updateENV.js b/server/utils/helpers/updateENV.js index 85981994d..c579da188 100644 --- a/server/utils/helpers/updateENV.js +++ b/server/utils/helpers/updateENV.js @@ -477,6 +477,12 @@ const KEY_MAPPING = { envKey: "TTS_ELEVEN_LABS_VOICE_MODEL", checks: [], }, + + // PiperTTS Local + TTSPiperTTSVoiceModel: { + envKey: "TTS_PIPER_VOICE_MODEL", + checks: [], + }, }; function isNotEmpty(input = "") { @@ -536,7 +542,12 @@ function validOllamaLLMBasePath(input = "") { } function supportedTTSProvider(input = "") { - const validSelection = ["native", "openai", "elevenlabs"].includes(input); + const validSelection = [ + "native", + "openai", + "elevenlabs", + "piper_local", + ].includes(input); return validSelection ? null : `${input} is not a valid TTS provider.`; } -- GitLab