---
title: "Add music recognition to an Electron desktop app"
description: "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."
slug: "/resources/integrations/electron-desktop"
section: "integrations"
keywords: [audd, electron, desktop music recognition, web audio, tray app]
---

# Add music recognition to an Electron desktop app

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 `MediaRecorder` and
  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 whole `ipcRenderer`.

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](https://dashboard.audd.io).
- Node.js 20 or newer (the version `@audd/sdk` requires) and Electron.
- Familiarity with Electron's main/renderer/preload split. If you're new to
  it, the [Electron process model docs](https://www.electronjs.org/docs/latest/tutorial/process-model)
  are the prerequisite reading.

```bash
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 `AudD` in the renderer.** If you `new AudD("...")`
> anywhere that runs in the Chromium context, the token is in your shipped
> bundle. Keep `@audd/sdk` imported 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.

```javascript
// 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).

```javascript
// 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`.

```javascript
// 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):

```bash
AUDD_API_TOKEN=test npx electron .
```

Get your own token at [dashboard.audd.io](https://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.

```javascript
// 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:

```javascript
// 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.

```javascript
// 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 `getUserMedia` from 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:

```json
{
  "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 `test` token 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.

```javascript
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](/resources/recipes/shazam-clone).
- 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](/resources/integrations/react-native).

---

**Related**

- [Build a Shazam-style music identification app](/resources/recipes/shazam-clone)
- [Add music recognition to a React Native app](/resources/integrations/react-native)
- [Node.js SDK docs](https://docs.audd.io/sdks/node)
- [API reference](https://docs.audd.io)