Add music recognition to an Electron desktop app
Capture a few seconds of audio in an Electron app and identify the song with AudD, keeping your API token in the main process and off the client.
This page shows how to add “what’s this song?” to an Electron desktop app.
The renderer captures a few seconds of audio, hands the bytes to the main
process over IPC, the main process calls AudD with @audd/sdk, and the
result comes back to the renderer to display. It’s for anyone building a
desktop utility — a tray app, a global-hotkey identifier, a companion to a
media player.
The single most important architectural point: the API token lives in the main process and never reaches the renderer. Everything else follows from that.
What you’ll build
An Electron app with three pieces:
- A renderer (the web page) that records audio with
MediaRecorderand sends the captured bytes to the main process. No AudD code, no token. - The main process (Node.js) that holds the AudD client, receives bytes
over IPC, calls
recognize, and returns the match. This is the only place the token exists. - A preload script that exposes a single, narrow IPC bridge to the
renderer via
contextBridge— not the wholeipcRenderer.
The renderer captures audio because microphone and Web Audio APIs live in the Chromium side. The main process calls AudD because Node modules and your secret token live there. IPC is the seam between them.
Prerequisites
- An API token from dashboard.audd.io.
- Node.js 20 or newer (the version
@audd/sdkrequires) and Electron. - Familiarity with Electron’s main/renderer/preload split. If you’re new to it, the Electron process model docs are the prerequisite reading.
npm install @audd/sdk electron
Why the token must stay in the main process
An Electron renderer is a web page. Anything you put in renderer code —
including a hard-coded API token, or one fetched into renderer memory —
ships inside your app bundle and is readable by anyone who unpacks the
app.asar, opens DevTools, or inspects network traffic. Treat the renderer
as fully untrusted, exactly like a browser tab you don’t control.
So the token, the AudD client, and the network call all live in the main
process. The renderer’s only job is to capture audio and ask the main
process to identify it. This is the same boundary you’d keep between a
browser front end and your server — the main process is your “server,”
co-located in the app.
Never construct
AudDin the renderer. If younew AudD("...")anywhere that runs in the Chromium context, the token is in your shipped bundle. Keep@audd/sdkimported only in the main process, and pass audio to it over IPC rather than passing the token out.
Walkthrough
Step 1: Capture a few seconds of audio in the renderer
In the renderer, record a short clip with MediaRecorder. For a
microphone-based identifier (the song is playing out loud in the room), this
is getUserMedia({ audio: true }). Recording five to ten seconds is plenty
for the standard endpoint.
// renderer.js — runs in the Chromium context. No token here.
async function recordClip(seconds = 6) {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
const chunks = [];
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.start();
await new Promise((r) => setTimeout(r, seconds * 1000));
recorder.stop();
stream.getTracks().forEach((t) => t.stop());
await new Promise((r) => (recorder.onstop = r));
return new Blob(chunks, { type: "audio/webm" });
}
MediaRecorder in Chromium produces WebM/Opus by default, which AudD
accepts. Keep the clip short — the standard endpoint caps files at 10 MB,
and a few seconds of audio is well under that.
Step 2: Send the bytes to the main process over IPC
The renderer can’t send a Blob directly over IPC, but it can send an
ArrayBuffer. Convert, then call across the bridge the preload script
exposes (set up in Step 4).
// renderer.js, continued
async function identify() {
const blob = await recordClip();
const buffer = await blob.arrayBuffer();
// window.audd is the narrow bridge from the preload script.
const result = await window.audd.recognize(buffer);
if (result.match) {
show(`${result.match.artist} — ${result.match.title}`);
} else {
show("No match");
}
}
The renderer never sees a token, only the audio it captured and the result it gets back.
Step 3: Call AudD in the main process
The main process holds the client and does the recognition. It receives the
ArrayBuffer over the IPC channel, wraps it in a Buffer (which the SDK
accepts as bytes), and calls recognize.
// main.js — Node.js context. The token lives here only.
import { app, BrowserWindow, ipcMain } from "electron";
import { AudD } from "@audd/sdk";
import path from "node:path";
// Read from the environment or your OS keychain — not a literal in source.
const audd = new AudD(process.env.AUDD_API_TOKEN);
ipcMain.handle("audd:recognize", async (_event, arrayBuffer) => {
const bytes = Buffer.from(arrayBuffer);
const song = await audd.recognize(bytes, {
returnMetadata: ["apple_music", "spotify"],
});
if (!song) return { match: null };
return {
match: {
artist: song.artist,
title: song.title,
album: song.album,
songLink: song.songLink,
appleMusic: song.streamingUrl("apple_music"),
spotify: song.streamingUrl("spotify"),
},
};
});
recognize returns null on a successful call with no match; that’s not
an error, so return { match: null } and let the renderer show “No match.”
Pass only plain, serializable data back over IPC (a null-or-object shape
works); don’t try to return the SDK’s result object directly.
For local development you can run with the test token (capped at 10
requests/day, standard endpoint only):
AUDD_API_TOKEN=test npx electron .
Get your own token at dashboard.audd.io. For a
shipped app, read the token from the OS keychain (for example via
keytar) or from a config the user supplies — not from a literal compiled
into the bundle.
Step 4: Expose a narrow IPC bridge in the preload script
The preload script runs with access to Node and to the renderer’s window.
Use contextBridge to expose exactly one method — recognize — and nothing
else. Don’t expose ipcRenderer wholesale.
// preload.js
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("audd", {
recognize: (arrayBuffer) =>
ipcRenderer.invoke("audd:recognize", arrayBuffer),
});
Wire the preload into the window with context isolation on and node integration off in the renderer — the secure defaults:
// main.js, continued
function createWindow() {
const win = new BrowserWindow({
width: 420,
height: 320,
webPreferences: {
preload: path.join(import.meta.dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
},
});
win.loadFile("index.html");
}
app.whenReady().then(createWindow);
With contextIsolation: true and nodeIntegration: false, the renderer
can’t reach Node or the token even if a page it loads is compromised. The
only thing it can do is call window.audd.recognize with audio bytes.
Step 5: A tray app with a global hotkey
A common shape for this kind of utility is a menu-bar / tray app: no main window, a tray icon, and a global hotkey that triggers “identify what’s playing” from anywhere. The main process owns the hotkey and the tray; it asks a hidden renderer to capture audio, then recognizes the result.
// main.js — tray + hotkey variant
import { app, Tray, globalShortcut, BrowserWindow, ipcMain } from "electron";
let tray;
let captureWin;
app.whenReady().then(() => {
tray = new Tray("iconTemplate.png");
tray.setToolTip("Identify the song that's playing");
// Hidden window that owns the MediaRecorder capture.
captureWin = new BrowserWindow({
show: false,
webPreferences: {
preload: path.join(import.meta.dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
},
});
captureWin.loadFile("capture.html");
// Ctrl/Cmd+Shift+I from anywhere kicks off a capture.
globalShortcut.register("CommandOrControl+Shift+I", () => {
captureWin.webContents.send("audd:capture-now");
});
});
app.on("will-quit", () => globalShortcut.unregisterAll());
The hidden capture.html renderer listens for audd:capture-now, runs the
same recordClip + window.audd.recognize flow from Steps 1–2, and shows
the result in a tray notification. The recognition path is unchanged — only
the trigger differs.
Step 6: Capturing system audio (the song playing on the machine)
Microphone capture works when the music is playing out loud in the room. To identify audio playing inside the machine — a track in a browser tab, a streaming app — you need to capture system output, and that is OS-specific.
Be honest with yourself about the platform cost here:
- macOS has no built-in system-audio capture API. Users typically
install a virtual loopback device (BlackHole, Loopback, or similar) that
appears as an input device; you then
getUserMediafrom that device. - Windows can capture loopback audio via WASAPI, and some Chromium capture paths expose desktop audio, but behavior varies by version and setup.
- Linux routes system audio through PulseAudio/PipeWire monitor sources, which show up as input devices you can select.
There is no single cross-platform “capture the speakers” call. Detect the
available input devices with navigator.mediaDevices.enumerateDevices() and
let the user pick the loopback/monitor device, or document the loopback
setup your app expects per platform. Once you have a MediaStream from
whatever source, the capture-and-recognize flow from Steps 1–3 is identical
— AudD only sees audio bytes and doesn’t care where they came from.
System-audio capture needs OS-level plumbing you can’t fully provide from Electron. Don’t promise one-click “identify what’s playing on this computer” across all platforms. Ship microphone capture as the reliable baseline, and treat loopback/system capture as an opt-in that depends on a device the user configures.
What you get back
The main process returns a plain object to the renderer. A match looks like:
{
"match": {
"artist": "Imagine Dragons",
"title": "Warriors",
"album": "Smoke + Mirrors (Deluxe)",
"songLink": "https://lis.tn/Warriors",
"appleMusic": "https://music.apple.com/...",
"spotify": "https://open.spotify.com/track/..."
}
}
A clean call that found nothing returns { "match": null }. The renderer
branches on result.match to show the song or “No match.”
The provider links (appleMusic, spotify) are populated because the main
process requested them with returnMetadata. Each provider you request adds
a little latency; request only the ones you’ll show. The songLink is a
universal lis.tn URL that resolves to the track on whatever service the
user has — a good default when you don’t want provider-specific buttons.
Handling errors
The recognition call runs in the main process, so handle errors there and return a serializable shape the renderer can display.
- Authentication errors — bad or missing token. On a desktop app this usually means the user hasn’t entered their token yet, or it’s wrong. Prompt them to set it; don’t crash the app.
- Quota errors — the request limit is hit (the
testtoken allows 10 per day). Show a clear message pointing at the dashboard. - Invalid-audio errors — the captured clip wasn’t decodable. This can happen if recording started before the device was ready; tell the user to try again.
- Connection errors — the machine is offline or the network blipped. Transient; let the user retry.
ipcMain.handle("audd:recognize", async (_event, arrayBuffer) => {
try {
const song = await audd.recognize(Buffer.from(arrayBuffer));
return { match: song ?? null };
} catch (err) {
// err.errorCode is useful in your logs; keep the user-facing text plain.
return { error: "recognition_failed" };
}
});
Going further
- For the recording and recognition UX in depth — clip length, retries, showing streaming links — see Build a Shazam-style music identification app.
- For a mobile app with the same main/renderer-style token boundary (capture on device, recognize behind a trusted layer), see the React Native integration.
Related
Reading this as an AI agent? The raw Markdown is at integrations/electron-desktop.md, and the full index is /resources/llms.txt.
