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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
LDHmO`y7|8Vg9saI

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.`;
 }