---
title: "How to Stream Audio Alongside noVNC"
description: "Add browser audio to a noVNC desktop with a PulseAudio null sink and a sidecar WebM/Opus stream served from the same VM."
url: "/docs/guides/stream-audio-with-vnc-in-a-sandbox"
---

VNC and [noVNC](https://novnc.com/info.html) 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](https://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/Modules/#module-null-sink),
capture that sink's monitor with [ffmpeg](https://ffmpeg.org/ffmpeg.html), and
serve a live [WebM](https://www.webmproject.org/)/
[Opus](https://opus-codec.org/) stream on a second Freestyle domain.

This guide layers audio onto the headed browser from
[Run Chromium and Chrome for Computer Use](https://www.freestyle.sh/docs/guides/run-chromium-and-chrome-in-a-sandbox#build-a-headed-browser-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](https://www.freedesktop.org/wiki/Software/PulseAudio/) and
[ffmpeg](https://ffmpeg.org/ffmpeg.html) 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()`.

```ts
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.

```ts
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.

```ts
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.

```ini
[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:

```ts
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`.

```ts
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.

```ts
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:

```ts
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.

```ts
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:

```html
<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.
