Freestyle Docs

Freestyle / Guides

How to Stream Audio Alongside noVNC

Add browser audio to a noVNC desktop with a PulseAudio null sink and a sidecar WebM/Opus stream served from the same VM.

VNC and noVNC do not carry audio. They stream pixels and input events. To hear a browser or desktop app, add a sidecar audio stream: send application audio into a PulseAudio null sink, capture that sink’s monitor with ffmpeg, and serve a live WebM/ Opus stream on a second Freestyle domain.

This guide layers audio onto the headed browser from Run Chromium and Chrome for Computer Use. Keep audio separate from the base VNC snapshot: ffmpeg pulls a large dependency set, and the audio path has its own failure modes.

Install the Audio Packages

Install PulseAudio and ffmpeg in their own setup stage. Use the background setup helper from the browser and desktop guides so the package install can run longer than a single buffered vm.exec().

await runSetup(
  builder,
  "audio",
  `apt-get update -qq
apt-get install -y -qq --no-install-recommends \
  pulseaudio pulseaudio-utils ffmpeg curl file`,
);

pulseaudio-utils gives you pactl, which is the simplest way to create and inspect the sink. ffmpeg captures the monitor and serves the stream.

Run PulseAudio as Root User Mode

Use a root user-mode PulseAudio daemon, not PulseAudio system mode. System mode can start, but pactl may get Access denied unless you also manage cookies and groups. Root user mode keeps Chromium, pactl, and ffmpeg on the same Unix socket.

await builder.exec("mkdir -p /run/user/0 && chmod 700 /run/user/0");

await builder.fs.writeTextFile(
  "/etc/systemd/system/pulseaudio-guide.service",
  `[Unit]
Description=PulseAudio daemon for root browser audio

[Service]
Environment=HOME=/root
Environment=XDG_RUNTIME_DIR=/run/user/0
ExecStart=/usr/bin/pulseaudio --daemonize=no --exit-idle-time=-1 --disallow-exit
Restart=always

[Install]
WantedBy=multi-user.target
`,
);

await builder.exec("systemctl daemon-reload && systemctl enable --now pulseaudio-guide");

Wait for the socket, then create a null sink named browser_sink and make it the default. The sink’s .monitor source is what ffmpeg records.

await builder.exec(
  `for i in $(seq 1 30); do
  PULSE_SERVER=unix:/run/user/0/pulse/native pactl info >/tmp/pulse.info 2>&1 && exit 0
  sleep 1
done
cat /tmp/pulse.info
exit 1`,
);

await builder.exec(
  "PULSE_SERVER=unix:/run/user/0/pulse/native " +
    "pactl load-module module-null-sink sink_name=browser_sink " +
    "sink_properties=device.description=BrowserSink || true",
);
await builder.exec(
  "PULSE_SERVER=unix:/run/user/0/pulse/native pactl set-default-sink browser_sink",
);

Send Chromium Audio to the Sink

Add PULSE_SERVER to the headed Chromium unit. If the page should play without a manual click, add --autoplay-policy=no-user-gesture-required; otherwise Chrome will enforce its normal autoplay rules.

[Service]
Environment=DISPLAY=:0
Environment=HOME=/root
Environment=PULSE_SERVER=unix:/run/user/0/pulse/native
ExecStart=/usr/bin/chromium --no-sandbox --disable-dev-shm-usage --autoplay-policy=no-user-gesture-required --user-data-dir=/root/.config/chromium-headed --window-size=1440,900 --window-position=0,0 https://example.com
Restart=always

Reload the unit after editing it:

await builder.exec("systemctl daemon-reload && systemctl restart chromium-headed");

Any app that honors PulseAudio can use the same socket. For command-line smoke tests, ffmpeg can generate a sine tone directly into the sink.

Serve a Live WebM Audio Stream

Run ffmpeg as a service. It records browser_sink.monitor, encodes Opus, and serves audio.webm on port 8090.

await builder.fs.writeTextFile(
  "/etc/systemd/system/audio-stream.service",
  `[Unit]
Description=Live WebM audio stream from PulseAudio monitor
After=pulseaudio-guide.service
Requires=pulseaudio-guide.service

[Service]
Environment=PULSE_SERVER=unix:/run/user/0/pulse/native
ExecStart=/usr/bin/ffmpeg -hide_banner -loglevel warning -f pulse -i browser_sink.monitor -c:a libopus -b:a 96k -f webm -content_type audio/webm -listen 1 http://0.0.0.0:8090/audio.webm
Restart=always
RestartSec=1

[Install]
WantedBy=multi-user.target
`,
);

await builder.exec("systemctl daemon-reload && systemctl enable --now audio-stream");

ffmpeg -listen 1 is a simple one-client HTTP server. When the client disconnects, the process exits and systemd restarts it. That is fine for one viewer. For multiple simultaneous listeners, put a real streaming service in front of PulseAudio instead of this minimal ffmpeg listener.

Smoke Test the Stream

Generate a short tone into browser_sink, fetch the live stream, and confirm the output is WebM. The sleep gives ffmpeg time to open PulseAudio before curl connects.

const smoke = await builder.exec(
  `sleep 4
(timeout 10 curl -fsS http://127.0.0.1:8090/audio.webm -o /tmp/live.webm >/tmp/curl.log 2>&1 || true) &
sleep 1
timeout 4 ffmpeg -hide_banner -loglevel error -re -f lavfi -i sine=frequency=660:duration=3 -f pulse -server unix:/run/user/0/pulse/native browser_sink >/tmp/tone.log 2>&1 || true
sleep 4
cat /tmp/curl.log || true
journalctl -u audio-stream --no-pager -n 40
file /tmp/live.webm
stat -c '%s bytes' /tmp/live.webm`,
);

console.log(smoke.stdout); // /tmp/live.webm: WebM

Snapshot the VM after PulseAudio, Chromium, noVNC, and the audio stream are all running:

const { snapshotId } = await builder.snapshot();
await builder.delete();

Map the Audio Domain

Boot the snapshot and map noVNC and audio separately. Use the noVNC URL for video/control and the audio URL in an <audio> element or directly in a browser tab.

const { vmId } = await freestyle.vms.create({
  slug: "desktop-vnc-audio",
  snapshotId,
  idleTimeoutSeconds: null,
});

const vncDomain = `desktop-${crypto.randomUUID().slice(0, 8)}.style.dev`;
const audioDomain = `desktop-audio-${crypto.randomUUID().slice(0, 8)}.style.dev`;

await freestyle.domains.mappings.create({ domain: vncDomain, vmId, vmPort: 6080 });
await freestyle.domains.mappings.create({ domain: audioDomain, vmId, vmPort: 8090 });

console.log(`screen: https://${vncDomain}/vnc.html?autoconnect=true&resize=scale`);
console.log(`audio:  https://${audioDomain}/audio.webm`);

Chrome and Firefox can play WebM/Opus:

<audio controls autoplay src="https://desktop-audio-xxxx.style.dev/audio.webm"></audio>

Autoplay can still be blocked by the viewer’s browser. Keep controls visible so the user can click play.

Troubleshooting

pactl: Access denied usually means PulseAudio was started in system mode. Use the root user-mode service above and point every process at PULSE_SERVER=unix:/run/user/0/pulse/native.

If curl reaches port 8090 before ffmpeg is listening, it fails quickly. Start audio-stream.service, wait a few seconds, then connect. systemd will restart the service after each client disconnects.

If the noVNC screen works but the audio is silent, check the Chromium unit for PULSE_SERVER, confirm pactl list short sinks includes browser_sink, and use the sine-tone smoke test to separate browser autoplay issues from PulseAudio or ffmpeg issues.

esc