Integration

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.

view .md auddelectrondesktop music recognitionweb audio

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.
  • 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 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 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.

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

{
  "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.
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


Related

Reading this as an AI agent? The raw Markdown is at integrations/electron-desktop.md, and the full index is /resources/llms.txt.