Using ElevenLabs in Electron Desktop Apps
Why ElevenLabs is a Game‑Changer for Desktop Voice Apps If you’ve ever tried to add text‑to‑speech (TTS) to an Electron app, you know the pain points: low‑quality voices, clunky latency, and a steep learning curve for API authentication. ElevenLabs cuts through that noise with ultra‑rea

If you’ve ever tried to add text‑to‑speech (TTS) to an Electron app, you know the pain points: low‑quality voices, clunky latency, and a steep learning curve for API authentication. ElevenLabs cuts through that noise with ultra‑realistic, low‑latency speech synthesis and a simple REST API that fits right into the Node.js ecosystem. The result? Your desktop app can talk back to users with a natural voice in just a few lines of code.
Pro tip: The affiliate link below gives you a free trial and a little boost for the ElevenLabs team, so you can start experimenting right away: https://try.elevenlabs.io/kr07zfuqn1bp
If you already have an Electron project, great—just follow the steps below. If you’re starting from scratch:
npx create-electron-app my-voice-app cd my-voice-app npm install Enter fullscreen mode Exit fullscreen mode Getting an ElevenLabs API Key Sign up at https://try.elevenlabs.io/kr07zfuqn1bp Navigate to API Keys in the dashboard. Copy the generated key – you’ll need it in both the main and renderer processes. Security tip: Store the key in an environment variable ( ELEVANLABS_API_KEY ) and never commit it to source control.
Before diving into code, verify the API works with a simple curl command:
curl -X POST "https://api.elevenlabs.io/v1/text-to-speech/your_voice_id" \ -H "xi-api-key: $ELEVANLABS_API_KEY " \ -H "Content-Type: application/json" \ -d '{ "text": "Hello from ElevenLabs!", "model_id": "eleven_monolingual_v1", "voice_settings": { "stability": 0.75, "similarity_boost": 0.85 } }' \ --output hello.wav Enter fullscreen mode Exit fullscreen mode If hello.wav plays back clearly, you’re ready to integrate.
We’ll keep the ElevenLabs calls in the main process for two reasons:
We’ll expose a tiny IPC bridge ( window.elevenLabs ) that the renderer can call.
npm install node-fetch@2 # fetch API for Node 14‑16 npm install fs-extra # convenient file utilities Enter fullscreen mode Exit fullscreen mode Note: Electron bundles a recent version of fetch, but using node-fetch gives us consistent behavior across Node versions.
// src/elevenLabsService.js const fetch = require ( ' node-fetch ' ); const fs = require ( ' fs-extra ' ); const path = require ( ' path ' ); const ELEVEN_API = ' https://api.elevenlabs.io/v1 '; const API_KEY = process. env. ELEVANLABS_API_KEY; // set in.env or launch script if (! API_KEY ) { throw new Error ( ' ElevenLabs API key not set!
Use ELEVANLABS_API_KEY env var. ' ); } /** * Synthesize speech and write to a temporary file. * @param {string} text - Text to speak. * @param {string} voiceId - ElevenLabs voice ID. * @returns {Promise<string>} - Path to the generated.wav file. */ async function synthesize ( text, voiceId = ' EXAVITQu4vr4xnSDxMaL ' ) { const url = ` ${ ELEVEN_API } /text-to-speech/ ${ voiceId } `; const body = { text, model_id: ' eleven_monolingual_v1 ', voice_settings: { stability: 0.75, similarity_boost: 0.85 } }; const response = await fetch ( url, { method: ' POST ', headers: { ' xi-api-key ': API_KEY, ' Content-Type ': ' application/json ', Accept: ' audio/mpeg ' // can also request 'audio/wav' }, body: JSON. stringify ( body ) }); if (! response. ok ) { const err = await response. text (); throw new Error ( `ElevenLabs error: ${ response. status } ${ err } ` ); } // Stream the binary audio directly to a file const outPath = path. join ( require ( ' os ' ). tmpdir (), `eleven- ${ Date. now ()}.mp3` ); const fileStream = fs. createWriteStream ( outPath ); await new Promise (( resolve, reject ) => { response. body. pipe ( fileStream ); response. body. on ( ' error ', reject ); fileStream. on ( ' finish ', resolve ); }); return outPath; } module. exports = { synthesize }; Enter fullscreen mode Exit fullscreen mode Step 3 – Wire Up IPC with Context Bridge Edit src/main.js (or wherever you create the BrowserWindow):
// src/main.js const { app, BrowserWindow, ipcMain, contextBridge } = require ( ' electron ' ); const path = require ( ' path ' ); const { synthesize } = require ( './elevenLabsService ' ); function createWindow () { const win = new BrowserWindow ({ width: 800, height: 600, webPreferences: { preload: path. join ( __dirname, ' preload.js ' ), // see next step contextIsolation: true, nodeIntegration: false } }); win. loadFile ( ' index.html ' ); } app. whenReady (). then ( createWindow ); // IPC handler ipcMain. handle ( ' elevenlabs:speak ', async ( event, { text, voiceId }) => { try { const audioPath = await synthesize ( text, voiceId ); return { success: true, path: audioPath }; } catch ( err ) { console. error ( err ); return { success: false, error: err. message }; } }); Enter fullscreen mode Exit fullscreen mode Create src/preload.js to expose a safe API:
// src/preload.js const { contextBridge, ipcRenderer } = require ( ' electron ' ); contextBridge. exposeInMainWorld ( ' elevenLabs ', { /** * Request speech synthesis. * @param {string} text * @param {string} [voiceId] * @returns {Promise<{success:boolean, path?:string, error?:string}>} */ speak: ( text, voiceId ) => ipcRenderer. invoke ( ' elevenlabs:speak ', { text, voiceId }) }); Enter fullscreen mode Exit fullscreen mode Now the renderer can call window.elevenLabs.speak without ever seeing the API key.
Assuming you’re using plain HTML/JS for the demo:
<!-- index.html --> <!DOCTYPE html> <html lang= "en" > <head> <meta charset= "UTF-8" > <title> ElevenLabs Voice Demo </title> </head> <body> <h1> ElevenLabs in Electron </h1> <textarea id= "txt" rows= "4" cols= "50" placeholder= "Enter text..." ></textarea><br> <button id= "speakBtn" > Speak </button> <audio id= "player" controls ></audio> <script> const txt = document. getElementById ( ' txt ' ); const btn = document. getElementById ( ' speakBtn ' ); const player = document. getElementById ( ' player ' ); btn. addEventListener ( ' click ', async () => { const text = txt. value. trim (); if (! text ) return; btn. disabled = true; btn. textContent = ' Synthesizing... '; const result = await window. elevenLabs. speak ( text ); if ( result. success ) { // Convert file:// path to a URL the < audio > tag can use player. src = `file:// ${ result. path } `; player. play (); } else { alert ( ' Error: ' + result. error ); } btn. disabled = false; btn. textContent = ' Speak '; }); </script> </body> </html> Enter fullscreen mode Exit fullscreen mode Why Not Stream Directly to the <audio> Element? Electron’s security model isolates the renderer from the filesystem. By writing to a temporary file and then feeding the file:// URL to the <audio> tag, we avoid extra dependencies like WebAudio streaming buffers.
For real‑time streaming, you could pipe the response into a MediaSource object, but the file‑based approach is simpler and works well for most desktop use‑cases.
ElevenLabs also offers voice cloning. After you upload a short sample (≈30 seconds), you’ll receive a new voice_id. Use that ID in the speak call:
await window. elevenLabs. speak ( ' Hello, this is my cloned voice! ', ' my_custom_voice_id ' ); Enter fullscreen mode Exit fullscreen mode The cloning workflow lives entirely on ElevenLabs’ dashboard, so your Electron code stays unchanged—just swap the ID.
Ready to give your app a voice that sounds human? Grab a free trial and start experimenting with the same API you just wired up: https://try.elevenlabs.io/kr07zfuqn1bp
Happy coding, and may your apps speak as clearly as you think!