Chrome, Ears Only
Recording audio with a headless browser and a Raspberry PI
Published 9/23/2021 by Colby RabideauI'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.
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.
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
inwhistlee-headless
run./bin/start-headless.sh
from the root