Chrome, Ears Only

Recording audio with a headless browser and a Raspberry PI

Published 9/23/2021 by Colby Rabideau

I've been working on a program to turn on my lights when I whistle a tune. Now that I have a working prototype, I wanted to get it running on an old Raspberry Pi and start testing it around the apartment.

So far I've done most of my work with Web Audio. It was really helpful to work in a familiar language like JS so I could focus on learning about audio. Alas, Web Audio is really only available in browser. There's an incomplete node implementation and plenty of other audio libraries in JS and languages, but I didn't really want to rebuild what I have. At least not right now. So I got to thinking, maybe I could run the prototype in a "headless" browser environment.

Headless Chromium and Puppeteer

Nowadays Chromium (and Chrome and Firefox) can run in "headless" mode. The browser runs like normal, it just doesn't show any UI on the screen. Instead, you can interact with the browser programatically through the DevTools protocol.

I decided to try using puppeteer to automate a headless instance of Chromium with JS. It's pretty fun.

// puppeteer-test.js const puppeteer = require("puppeteer") ;(async () => { const browser = await puppeteer.launch({ // If you disable the `headless` option, you can watch your script control the browser. headless: false, }) const [page] = await browser.pages() await page.goto("https://pptr.dev/") })()

👀 Check it out.

A Puppeteer script controlling a browser window

I disabled headless to create that GIF. It makes for easier debugging, but beware! Some APIs behave differently in headless mode.

Headless recording

My whistle detector uses getUserMedia to access the computer's microphone and listen for whistles.

const micStream = await navigator.mediaDevices.getUserMedia({ audio: true })

With the UI on, you see a permissions dialog when a new site requests access to your mic.

Microphone permission dialog

Unforunately you can't interact with those dialogs in headless mode, so getUserMedia fails with an error like this.

Uncaught (in promise) NotSupportedError: Not supported

Luckily, we can override those permission dialogs by passing the --use-fake-ui-for-media-stream flag to puppeteer's instance of Chromium (this list of Chromium flags came in real handy).

const browser = await puppeteer.launch({ args: ["--use-fake-ui-for-media-stream"], headless: true, })

With that flag enabled, microphone access works without user interaction. Probably don't pass that flag to your everyday browser. 🤪

Running on the Raspberry PI

At that point, everything seemed to be working on my laptop, so it was time to try it out on the PI! Alas, it failed immediately.

(node:906) UnhandledPromiseRejectionWarning: Error: Failed to launch the browser process! /home/pi/rc-playground/whistlee-headless/node_modules/puppeteer/.local-chromium/linux-901912/chrome-linux/chrome: 1: /home/pi/rc-playground/whistlee-headless/node_modules/puppeteer/.local-chromium/linux-901912/chrome-linux/chrome: Syntax error: ")" unexpected

It turns out puppeteer comes with a version of Chromium built for x86 architectures, but the Pi has an ARM processor. To run puppeteer on the Pi, you need to tell it where to find an ARM compatible version of Chromium.

Nowadays, the Raspberry Pi OS comes bundled with already Chromium installed. You can find it's path using the which command in the Pi's terminal.

~ ➜ which chromium-browser /usr/bin/chromium-browser

Then pass that path into puppeteer's executablePath option.

const browser = await puppeteer.launch({ args: ["--use-fake-ui-for-media-stream"], executablePath: "/usr/bin/chromium-browser", headless: true, })

It's alive!

That's it! With those options, you should be able to run a webpage headlessly on a Raspberry Pi with access to the mic.

If you want to try the whistle detector:

  • clone my rc-playground project
  • run yarn at the root
  • run yarn in whistlee-headless run ./bin/start-headless.sh from the root