MicroCast is a single-process macOS app. Audio comes in from one Core Audio device, fans out to a handful of
encoders, and leaves through a tiny HTTP server. Everything runs in the app; the only external processes are
lame (MP3) and the optional tunnel CLI.
flowchart LR
Dev[(Audio input<br/>e.g. Wave Link Stream)] --> Cap[AudioCapture<br/>AVCaptureSession → 48 kHz s16le]
Apps[(Running apps)] --> Tap[TapCapture<br/>process tap + aggregate device → 48 kHz s16le]
Tap --> Mix
Cap --> Mix[AudioMixer<br/>jingles over ducked audio, CMSampleBuffers]
Mix --> HLS[HLSVariant ×4<br/>AVAssetWriter fMP4 parts] --> PL[LivePlaylist ×4]
Mix --> AAC[AACStream ×4<br/>AudioConverter + ADTS]
Mix --> MP3[MP3Stream ×4<br/>lame process]
Mix --> FLAC[FLACStream<br/>AudioConverter]
Mix --> PCM[PCMStream<br/>20 ms chunks]
AAC & MP3 & FLAC & PCM --> B[Broadcaster<br/>per stream]
B --> Rec[Recorder<br/>file on disk]
PL & B --> R[Router] --> S[HTTPServer<br/>Network.framework]
S --> Clients[Browsers, VLC, ffmpeg…]
S -.-> T[Tunnel<br/>cloudflared / ngrok / …]
| Type | File | Job |
|---|---|---|
Streamer |
Streamer.swift |
Owns the pipeline, drives the SwiftUI menu (@Observable, main actor), reads Settings from UserDefaults. |
AudioCapture |
AudioCapture.swift |
AVCaptureSession on the chosen device; AVCaptureAudioDataOutput.audioSettings converts to 48 kHz stereo 16-bit. Hands each buffer to a sink as both CMSampleBuffer (for AVAssetWriter) and raw Data. |
TapCapture |
TapCapture.swift |
The other AudioSource: a Core Audio process tap (CATapDescription) on the chosen apps or on everything, inside a private aggregate device that also holds the optional input device, so Core Audio compensates the clocks. Channels are summed to stereo, resampled with AVAudioConverter, wrapped in CMSampleBuffers. AudioApp lists running apps by their responsible process; SystemAudioPermission asks for System Audio Recording. |
HLSVariant |
HLSVariant.swift |
One AAC rendition (one per configured bitrate when HLS is enabled): AVAssetWriter with outputFileTypeProfile = .mpeg4AppleHLS and preferredOutputSegmentInterval = one part. Segments arrive through AVAssetWriterDelegate. Injects the stream title into the init segment. |
LivePlaylist |
LivePlaylist.swift |
The media playlist of a rendition: parts, segments, sliding window, blocking-reload waiters, rendering. Pure Swift, unit tested. |
PacketEncoder |
PacketEncoder.swift |
Core Audio's AAC and FLAC encoders in streaming mode. AACStream frames packets in ADTS, FLACStream prepends the fLaC header. |
MP3Stream |
MP3Stream.swift |
A lame process per bitrate, PCM on stdin, MP3 on stdout, ID3 preamble for new listeners. |
PCMStream |
PCMStream.swift |
Raw PCM coalesced into 20 ms chunks for the page's ultra-low-latency mode (uncompressed, 1.5 Mbit/s). |
Broadcaster |
Broadcaster.swift |
Fan-out of a byte stream to any number of consumers, each an AsyncStream with a bounded buffer. |
Recorder |
Recorder.swift |
A consumer that writes to a file. |
ListenerHistory |
ListenerHistory.swift |
Listener counts sampled every 5 s for an hour, with the peak; feeds the dashboard chart (Swift Charts) and the page sparkline through /status.json. |
HTTPServer |
HTTPServer.swift |
HTTP/1.1 on NWListener: GET/HEAD, keep-alive, endless bodies, Bonjour registration, optional TLS from a SecIdentity. |
Router |
Router.swift |
URL → response. Basic auth, listener counting, /status.json, playlists, segments, streams. |
Tunnel |
Tunnel.swift |
Spawns the tunnel CLI, parses its output for the public URL (TunnelOutputParser, unit tested). |
DuckDNSPublisher |
DuckDNS.swift |
Port-forwarding modes: keeps a DuckDNS name pointed here (IPv4 only) or trusts the router's DynDNS, obtains and renews the certificate (DNS-01 through DuckDNS, HTTP-01 otherwise), hands the identity to an HTTPS listener. |
ACMEClient |
ACME.swift |
RFC 8555 client on CryptoKit ES256 JWS with DNS-01 and HTTP-01; CSR through the system openssl; ACMEChallengeStore backs /.well-known/acme-challenge/. Unit tested; verified against Let's Encrypt staging. |
TLSIdentity |
TLS.swift |
PEM → PKCS#12 → SecIdentity for Network.framework, expiry parsing. |
AppDelegate |
AppDelegate.swift |
Owns the status item and the window. The window is built in AppKit and hosts SwiftUI, because a scene's lifetime belongs to SwiftUI and a menu bar app needs a window it can hide and bring back; closing hides it, and the app is a regular one while the window is up and an accessory when it is not. The traffic lights are hidden but the window stays .closable, so ⌘W still routes through windowShouldClose. |
RootView |
RootView.swift |
The window's shell: its own title bar, and the slide between the dashboard and settings. |
DashboardView |
DashboardView.swift |
The main screen, and two of them really: on air it shows the stereo trace, vitals, current track with its cover, listeners chart and addresses; off air the meters have nothing to show, so it becomes a pre-flight panel — source, formats, address and jingles, each row saying whether it looks usable and opening the settings tab that changes it. |
SettingsView |
SettingsView.swift |
The Settings window (⌘,): General, Stream, Internet, Recording, About. |
Screenshot |
Screenshot.swift |
Draws the window into a PNG on a distributed notification, for Tools/shoot.sh. Renders the view rather than capturing the display, so documentation images need no Screen Recording permission. |
Components |
Components.swift |
VUMeters (a pair of analogue VU meters driven by Core Animation ballistics), SignalTrace (the alternative: a glowing stereo waveform gliding right to left, meterStyle = "wave"), BroadcastButton, AmbientBackground, Card, PreflightRow, CopyButton, Banner, QR generation. |
- Capture callbacks arrive on a serial
userInteractivequeue and only copy bytes and dispatch. - Every encoder has its own serial queue; encoding one 5 ms buffer costs well under a millisecond.
LivePlaylistandBroadcasterare lock-protected and callable from anywhere.- The HTTP server bridges Network.framework callbacks to Swift concurrency; each connection is a
Task. Streamerlives on the main actor; a 20 Hz timer refreshes the levels (with a decaying peak hold) and a 1 Hz one the counters and live settings, since only a needle needs twenty updates a second. The counters, and compares the live settings with the snapshot taken at start to offer a restart.
- Part duration is a setting (200 ms to 1 s, default 334 ms). AVAssetWriter cuts on AAC frame boundaries
(1024 samples = 21.3 ms), so parts come out at 320 or 341 ms for a 334 ms request.
PART-TARGETis therefore advertised as the requested duration plus one frame. - A segment is
partsPerSegmentparts (segment duration ÷ part duration, rounded).EXT-X-TARGETDURATIONispartsPerSegment × PART-TARGETrounded up. PART-HOLD-BACKis three part targets: hls.js and Safari settle about one second above that.- Blocking reloads:
_HLS_msn/_HLS_partregister a waiter that is resumed when the part arrives, or after 3 × target duration. Requests that can never be satisfied (part index past the end of a complete segment, sequence more than two ahead, sequence already dropped) return immediately. - The window keeps six complete segments plus the one in progress. Old parts are dropped from memory with them.
- Every
EXTINFcarries the stream name as its title; the master playlist addsEXT-X-SESSION-DATA. - The AAC encoder needs a few hundred milliseconds to warm up, during which
AVAssetWriterInputrefuses samples.HLSVariantkeeps a backlog instead of dropping audio.
PacketEncoder wraps AudioConverterFillComplexBuffer in the usual streaming pattern: the input callback hands
over whatever PCM is pending and returns a private "out of input" status when there is none, which makes the
converter return the packets it could complete while keeping the leftover frames internally. AAC packets are
1024 frames, FLAC packets 4608 frames. FLAC's STREAMINFO is recovered from the converter's magic cookie (a dfLa
box whose last 34 bytes are the block); the stream header adds a VORBIS_COMMENT with the title.
macOS has no MP3 encoder, hence lame --flush per bitrate. Its stdout is read on a readabilityHandler and
published as-is; MP3 decoders resync on frame headers, so joining mid-stream is fine.
Broadcaster.publish never blocks: each subscriber is an AsyncStream with bufferingNewest(64), so a client
that stops reading loses the oldest chunks instead of adding delay for everyone. HTTP writes for streaming bodies
use Connection: close and no length, the same shape as Icecast.
Capturing any input device, virtual ones included, requires the microphone permission
(NSMicrophoneUsageDescription). Capturing applications requires System Audio Recording Only
(NSAudioCaptureUsageDescription). Process taps never prompt by themselves and just deliver silence without the
grant; there is no public API to ask, so SystemAudioPermission calls the TCC framework's preflight and request
functions through dlsym, the same approach as AudioCap, and simply proceeds if those symbols ever disappear. The app is ad-hoc signed by build.sh, so the grant is keyed to the build's
code hash and macOS may ask again after a rebuild. dist.sh can sign with a Developer ID and notarize; that path
enables the hardened runtime, which is why entitlements.plist declares com.apple.security.device.audio-input.
All in UserDefaults under local.microcast:
| Key | Default | Meaning |
|---|---|---|
deviceUID |
Wave Link Stream if present | Core Audio device UID |
sourceMode |
device | device (the input) or apps (a system audio tap) |
allApps, selectedApps, mixInput |
false, empty, false | which apps to tap (bundle identifiers, comma-separated) and whether to add the input |
port |
8080 | HTTP port |
partDuration |
0.334 | HLS part length in seconds |
bitrates |
empty = 64, 128, 256, 320 | comma-separated kbps, clamped to 64–320, at most eight |
enableHLS, enableAAC, enableMP3, enableFLAC, enablePCM |
true | which outputs to produce |
segmentDuration |
2 | HLS segment length in seconds |
streamName |
MicroCast | title everywhere |
password |
empty | HTTP Basic password when set |
tunnelProvider |
off | cloudflare, cloudflareNamed, ngrok, tailscale, custom, duckdns, ownHost |
ownHostname |
the DynDNS name for ownHost |
|
duckSubdomain, duckToken, duckHostname, duckPublicPort, httpsEnabled, httpsPort, acmeEmail |
DuckDNS + HTTPS; acmeStaging (hidden) targets Let's Encrypt's staging CA |
|
cloudflareToken, cloudflareHostname |
named tunnel | |
customTunnelCommand |
{port} placeholder |
|
recordFormat |
off | flac, aac, mp3 |
recordFolder |
~/Music/MicroCast | |
autoStart |
false | start streaming when the app launches |
keepOnline |
true | keep the address up between streams and serve the off-air page (also goes online at launch) |
titlePattern |
%name% — %artist% - %title% | now-playing title for ICY metadata, the page tab and status.json |
nowPlayingEnabled |
true | in input mode, show the Music/Spotify track; in app-capture mode it follows the captured apps |
lastLive |
when the last stream ended, for the off-air page | |
screenEnabled, screenDisplayID, screenX/Y/Width/Height, screenFPS, screenMaxWidth, screenQuality |
off, main display, whole display, 12 fps, 1280 px, 0.7 | screen region streaming as MJPEG |
showInDock, menuBarIcon |
true, radio | the Dock icon, and what the status item shows: five SF Symbol pairs (one glyph at rest, a louder one on air) plus onOff, a lettered badge drawn in MenuBarIcon.badge because SF Symbols has none — outlined around the word off air, filled with the word knocked out on air, and sized on the longer word so the item does not change width. The activation policy is set once from showInDock rather than following the window: flipping it orders the windows out and rebuilds the main menu. Because the window is an AppKit one, the delegate owns its keyboard shortcuts through a local event monitor instead of the menu. |
jinglesEnabled, jingleFolder, jingleDuckDecibels, jingleVolume, jingleLeadSeconds, jingleEveryTracks |
false, ~/Music/MicroCast/Jingles, −12, 1.0, 2, 1 | jingles at track changes; with a lead time the player's position and duration schedule the jingle before the declared end, the change itself is the fallback; jingleEveryTracks rests the rotation on the boundaries in between, its verdict memoised per track so both paths agree; changing it restarts the count; polling of Music/Spotify drops to 1 s when enabled |
| Path | Typical |
|---|---|
| Capture + conversion | ~10 ms |
| PCM mode on the page (uncompressed) | 0.15 s scheduling headroom + network ≈ 0.2 s |
| Direct streams in VLC/mpv | 1–2 s (player buffer) |
| LL-HLS in hls.js, same network | 1.3 s |
| LL-HLS through Cloudflare | +0.3–1 s |