Audio from Linux apps in an OrbStack VM routes over TCP to PulseAudio on macOS, which outputs via CoreAudio.
Linux app -> PulseAudio client -> TCP:4713 -> PulseAudio server (macOS) -> CoreAudio -> speakers
brew install pulseaudioCreate ~/.config/pulse/daemon.conf:
# Don't exit when idle — keep running for OrbStack VM connections
exit-idle-time = -1
# Match CoreAudio sink's native rate (48000Hz) to avoid unnecessary resampling
default-sample-rate = 48000
# High-quality resampler for 22050Hz WAVs -> 48000Hz sink
# (system default is 'trivial' which drops/duplicates samples and causes artifacts)
resample-method = soxr-mq
default-sample-rate = 48000 — The CoreAudio sink runs at 48000Hz natively. The
Homebrew default is 44100Hz, which forces every stream through an extra resampling step.
Setting this to match the sink avoids that overhead. To check your sink's native rate:
pactl list sinks | grep 'Sample Specification'
# e.g. float32le 2ch 48000Hzresample-method = soxr-mq — The Homebrew daemon.conf ships with
resample-method = trivial, which is the lowest-quality resampler — it just drops or
duplicates samples. Any audio that doesn't match the sink's sample rate (like 22050Hz WAV
files) will sound rough. soxr-mq (SoX Resampler, medium quality) is a good balance of
quality and CPU. To list available resamplers:
pulseaudio --dump-resample-methodsCreate ~/.config/pulse/default.pa — see Full default.pa at the end
of this document for the complete file. The key differences from the Homebrew default are:
module-coreaudio-detect ioproc_frames=2048— increases the CoreAudio callback buffer from the default 512 frames (~10ms) to 2048 frames (~42ms).module-suspend-on-idleomitted — prevents pops/clicks when the sink resumes after idle.- Standalone file — does not
.includethe systemdefault.pa, to avoid loading CoreAudio devices twice (which causes.2name suffixes on sinks).
PulseAudio's CoreAudio module registers a callback (IOProc) that CoreAudio invokes every N frames to pull audio data. The default is 512 frames, which at 48000Hz gives only ~10.67ms per callback:
buffer_duration = ioproc_frames / sample_rate
512 / 48000 = 0.01067s = ~10.67ms
2048 / 48000 = 0.04267s = ~42.67ms
On macOS, PulseAudio cannot acquire real-time scheduling priority (the log will show "Unable to read CPU frequency, acquisition of real-time scheduling failed"). Without RT priority, the OS can preempt PulseAudio's audio thread, and if it doesn't wake up within that ~10ms window, the buffer underruns and you hear a pop or crackle. Increasing to 2048 frames gives ~42ms of headroom, which is enough to absorb normal scheduling jitter. The tradeoff is ~32ms of additional latency, which is imperceptible for sound effects and notifications.
When loaded, this module suspends the CoreAudio sink after a few seconds of silence. When new audio arrives, the sink must resume, which involves reinitializing the CoreAudio output stream — producing an audible pop/click. For intermittent sounds (like notifications from a VM), every single sound would pop. Omitting the module keeps the sink in IDLE state instead, ready to play immediately.
brew services start pulseaudioThis auto-starts PulseAudio on login. To restart after config changes:
brew services restart pulseaudiopactl info # should show server info
lsof -nP -iTCP:4713 # should show LISTEN on both IPv4 and IPv6sudo apt install pulseaudio-utils libpulse0echo "PULSE_SERVER=tcp:host.orb.internal:4713" | sudo tee -a /etc/environmentThis takes effect on next login. For the current shell:
export PULSE_SERVER=tcp:host.orb.internal:4713pactl info # should show Server Name: pulseaudio, Default Sink: 1__2# Play white noise for 1 second
paplay --raw /dev/urandom &
sleep 1
kill %1OrbStack sets SSH_CONNECTION in the environment, which causes peon to assume audio should not be played locally. Override this with:
peon ssh-audio local# Server info (sample rate, default sink)
pactl info
# List sinks with state, sample spec, and buffer size
pactl list sinks
# Short sink listing (state at a glance: IDLE, RUNNING, SUSPENDED)
pactl list sinks short
# List loaded modules (check for suspend-on-idle, TCP, etc.)
pactl list modules short
# Check what's listening on the TCP port
lsof -nP -iTCP:4713
# List available resamplers
pulseaudio --dump-resample-methods
# Tail the PulseAudio log
tail -f /opt/homebrew/var/log/pulseaudio.logTo diagnose crackling or underruns, restart with debug logging:
brew services stop pulseaudio
/opt/homebrew/opt/pulseaudio/bin/pulseaudio \
--exit-idle-time=-1 \
--log-level=debug \
--log-target=file:/opt/homebrew/var/log/pulseaudio-debug.log \
--daemonizeThen play audio and search the log for underruns:
grep -i "underrun\|overrun\|xrun" /opt/homebrew/var/log/pulseaudio-debug.logWhen done, restart normally:
pulseaudio --kill
brew services start pulseaudioIf you change audio hardware or sample rate, recalculate:
buffer_duration_ms = (ioproc_frames / sample_rate) * 1000
Check your sink's sample rate with pactl list sinks | grep 'Sample Specification'.
For example, at 44100Hz: 2048 / 44100 * 1000 = ~46.4ms.
If you still get crackling at 2048, try 4096 (~85ms at 48kHz). If 1024 works cleanly, use that for lower latency.
If pactl list sinks short shows SUSPENDED, module-suspend-on-idle is loaded. Check
with pactl list modules short | grep suspend and verify your default.pa doesn't
include it (or doesn't .include the system default.pa, which loads it).
auth-anonymous=1 means any process that can reach port 4713 can play audio. This is fine for a local OrbStack setup but not suitable for shared networks. For stricter auth, use cookie-based authentication instead (copy ~/.config/pulse/cookie to the Linux VM and remove auth-anonymous=1).
~/.config/pulse/default.pa — standalone (does not include the system default):
# User PulseAudio configuration for OrbStack VM audio
# (standalone — does NOT include the system default.pa, to control CoreAudio
# module loading order and avoid device name collisions)
.fail
### Automatically restore the volume of streams and devices
load-module module-device-restore
load-module module-stream-restore
load-module module-card-restore
### Automatically augment property information from .desktop files
load-module module-augment-properties
### Should be after module-*-restore but before module-*-detect
load-module module-switch-on-port-available
### Load CoreAudio devices with larger IOProc buffer
### (default 512 frames = ~10ms is too tight without real-time scheduling;
### 2048 frames = ~42ms gives headroom to absorb scheduling jitter)
.ifexists module-coreaudio-detect.so
load-module module-coreaudio-detect ioproc_frames=2048
.else
load-module module-detect
.endif
### Load protocols
.ifexists module-esound-protocol-unix.so
load-module module-esound-protocol-unix
.endif
load-module module-native-protocol-unix
### Automatically restore the default sink/source
load-module module-default-device-restore
### Make sure we always have a sink around, even if it is a null sink.
load-module module-always-sink
### Honour intended role device property
load-module module-intended-roles
### NOTE: module-suspend-on-idle intentionally NOT loaded — it causes pops/clicks
### when the CoreAudio sink resumes after idle, which is common with short
### intermittent sound effects over TCP from OrbStack VMs.
### If autoexit on idle is enabled we want to make sure we only quit
### when no local session needs us anymore.
.ifexists module-console-kit.so
load-module module-console-kit
.endif
.ifexists module-systemd-login.so
load-module module-systemd-login
.endif
### Enable positioned event sounds
load-module module-position-event-sounds
### Cork music/video streams when a phone stream is active
load-module module-role-cork
### Modules to allow autoloading of filters (such as echo cancellation)
### on demand.
load-module module-filter-heuristics
load-module module-filter-apply
### Allow TCP connections from OrbStack VMs (anonymous auth for local use)
load-module module-native-protocol-tcp auth-anonymous=1 listen=0.0.0.0 port=4713
load-module module-native-protocol-tcp auth-anonymous=1 listen=:: port=4713