From Swift CLI to cpal: How to Replace Platform Subprocess Audio With In-Process Rust
The specific architecture move that eliminated 1,628 lines of Swift, removed subprocess fragility, and made cross-platform audio possible in 600 lines of Rust.
Published: May 2026 Tags: #SoloBuilder #Rust #Audio #cpal #CrossPlatform #Tauri #Refactoring
The “So What?” - Why You Should Care
Bottom Line Value: A concrete blueprint for replacing platform-specific subprocess audio code with cpal (Rust’s cross-platform audio crate), with the exact module structure, the pipeline pattern that worked, and the gotchas that bit us.
What You’ll Gain:
- The 6-module structure that breaks audio cleanly into testable pieces
- The “capture → silence-detect → checkpoint → encode” pipeline pattern
- Specific gotchas around USB, Bluetooth, and sample rates
- A test strategy for hardware-dependent code (you can test 80% without a microphone)
- ROI: ~3 weeks of work to replace a 1,628-line platform-specific subprocess with ~600 lines of cross-platform Rust that’s actually testable
Who This Helps: Solo builders carrying a Swift/Objective-C audio CLI (or any subprocess audio path) and wanting to ship Windows or Linux. Anyone whose audio capture currently lives outside their main process.
Why Bother: Subprocess audio is fragile (any crash takes down recording), platform-specific (you cannot port without rewriting), and impossible to test cleanly. In-process audio in Rust is none of those.
TLDR: The Move in 6 Steps
- Add cpal as a dependency (5 min)
- Split audio into 6 modules — capture, silence, checkpoint, encoder, resampler, pipeline (planning: 1 hour)
- Wire up capture — get raw samples flowing from the input device (4–6 hours)
- Add silence detection — RMS-based, with configurable threshold and timeout (2–3 hours)
- Add checkpointing — write WAV every N seconds for crash recovery (3–4 hours)
- Test with real hardware — USB, Bluetooth, internal mic (1 day across devices)
Result: ~3 weeks of solo work replaces a 1,628-line platform-specific Swift CLI with ~600 lines of cross-platform Rust. Bonus: 80% of the code is unit-testable without hardware.
The Moment You’re Probably In
Your app records audio. The audio capture code is a CLI written in Swift (or Objective-C, or some platform-specific thing). It works. Mostly. But:
- It only runs on macOS, and you want Windows next
- Every recording session spawns a subprocess and reads from stdout/files
- When the subprocess crashes, you have no idea why
- When recording fails silently, your only debugging tool is
tail -fon a log file - Cross-platform porting means writing the equivalent thing for Windows in C++ or Rust
- Testing requires actually running the subprocess against a real microphone
You’ve thought “I should just run this in-process.” But:
- You don’t know what library to use
- Audio code feels scary (sample rates, formats, buffer sizes, callbacks)
- The platform-specific subprocess “works”
The fix is cpal and a clean pipeline structure. Here’s how.
Step 1: Pick Your Crate (5 minutes)
There are a few options. We picked cpal. Why:
| Crate | Pros | Cons |
|---|---|---|
| cpal | Cross-platform (CoreAudio, WASAPI, ALSA, JACK), pure-Rust, ~maintained, broad device support | Low-level (you handle sample format conversion) |
rodio | Higher-level, easier for playback | Built on cpal; less control over capture |
nokhwa | Includes video | Overkill for audio-only |
| Direct bindings (CoreAudio FFI) | Lowest-level control | Platform-specific defeats the whole point |
For voice transcription specifically: cpal. Add to Cargo.toml:
[dependencies]
cpal = "0.15"
hound = "3" # WAV encoder
Step 2: The 6-Module Structure (1 hour to plan)
Don’t put everything in one file. Audio has natural seams. Use them:
src-tauri/src/audio/
├── mod.rs # public API + re-exports
├── types.rs # AudioConfig, AudioSample, RecordingHandle
├── error.rs # typed AudioError variants
├── capture.rs # cpal input stream + buffer drain
├── silence.rs # RMS-based silence detection
├── checkpoint.rs # periodic WAV writes for crash recovery
├── resampler.rs # convert from device rate to 16kHz
├── encoder.rs # 16-bit PCM WAV writer
└── pipeline.rs # orchestrates the above into a recording session
Each module has one job. Each is testable in isolation. The pipeline.rs is the only place where they interact — everything else is a pure transformation.
This matters because audio bugs are nasty. Without module boundaries, you’ll have a 1,500-line file where the silence detector and the encoder share a buffer and the whole thing breaks when you breathe wrong.
Step 3: Capture (4–6 hours)
The minimum useful capture loop:
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use std::sync::{Arc, Mutex};
pub fn start_capture(device_name: Option<&str>) -> Result<CaptureHandle, AudioError> {
let host = cpal::default_host();
let device = match device_name {
Some(name) => host.input_devices()?
.find(|d| d.name().map(|n| n == name).unwrap_or(false))
.ok_or(AudioError::DeviceNotFound)?,
None => host.default_input_device().ok_or(AudioError::NoDefaultDevice)?,
};
let config = device.default_input_config()?;
let sample_format = config.sample_format();
let sample_rate = config.sample_rate().0;
let channels = config.channels();
let buffer = Arc::new(Mutex::new(Vec::<f32>::with_capacity(sample_rate as usize * 60)));
let buffer_clone = buffer.clone();
let stream = device.build_input_stream(
&config.into(),
move |data: &[f32], _: &cpal::InputCallbackInfo| {
let mut buf = buffer_clone.lock().expect("buffer poisoned");
buf.extend_from_slice(data);
},
|err| tracing::error!(error = %err, "input stream error"),
None,
)?;
stream.play()?;
Ok(CaptureHandle {
stream,
buffer,
sample_rate,
channels,
})
}
Three things to know:
- The callback fires on a high-priority audio thread. Do not block. Do not allocate (in steady state). Push to a buffer and get out.
Sample format varies by device.Default config gives you whatever the device prefers — sometimes f32, sometimes i16. Use cpal’sSampleFormatenum to handle both.Sample rate varies by device.USB mics are often 48 kHz. Internal mics on Mac are often 44.1 kHz. Bluetooth can be 16 kHz or 32 kHz. You’ll need to resample (step 4).
Step 4: Silence Detection (2–3 hours)
A simple RMS-based detector. Configurable threshold + timeout:
pub struct SilenceDetector {
threshold_db: f32, // e.g., -40.0
silence_timeout: Duration, // e.g., 3 seconds
last_voice_at: Option<Instant>,
}
impl SilenceDetector {
pub fn analyze(&mut self, samples: &[f32]) -> SilenceVerdict {
let rms = (samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32).sqrt();
let db = 20.0 * rms.log10().max(-90.0);
if db > self.threshold_db {
self.last_voice_at = Some(Instant::now());
SilenceVerdict::Voice
} else if self.last_voice_at
.map(|t| t.elapsed() > self.silence_timeout)
.unwrap_or(false)
{
SilenceVerdict::SilenceTimeout
} else {
SilenceVerdict::Silence
}
}
}
The gotchas:
-40 dBis a reasonable default but USB mics often need-50 dB- Bluetooth mics have noise floors that mess with absolute thresholds
- Some users will want “never auto-stop” — make the timeout
Option<Duration>
This module is fully testable without hardware. Generate sine waves at known amplitudes, feed them in, assert verdicts.
Step 5: Checkpointing (3–4 hours)
Periodic WAV writes that survive a crash. The pattern:
pub struct Checkpointer {
interval: Duration,
last_checkpoint: Instant,
output_dir: PathBuf,
session_id: String,
}
impl Checkpointer {
pub fn maybe_checkpoint(&mut self, samples: &[f32], sample_rate: u32) -> Result<()> {
if self.last_checkpoint.elapsed() < self.interval {
return Ok(());
}
// Write to a temp file, then atomic rename — so we never have a partial WAV
let final_path = self.output_dir.join(format!("{}-checkpoint.wav", self.session_id));
let tmp_path = final_path.with_extension("wav.tmp");
encoder::write_wav(&tmp_path, samples, sample_rate, 16)?;
std::fs::rename(&tmp_path, &final_path)?;
self.last_checkpoint = Instant::now();
Ok(())
}
}
Two non-obvious decisions we made:
- Atomic rename, not direct write. If the app crashes mid-write, you’d otherwise have a partial WAV the recovery code interprets as the full recording.
- 30 second interval, not shorter. Disk I/O on the audio thread (or even a sister thread) at high frequency degrades capture. 30 sec balances recovery loss vs. throughput.
On startup, scan output_dir for orphaned *-checkpoint.wav files. Those are recoverable sessions.
Step 6: Test With Real Hardware (1 day)
The unit tests get you 80% there. The 20% that requires hardware:
Test matrix to actually run:
| Device | Test for |
|---|---|
| Internal MacBook mic | Baseline. Should “just work.” |
| AirPods (Bluetooth) | Sample-rate weirdness. Drops to 16 kHz in some modes. |
| USB condenser mic (e.g., Yeti) | Higher sample rates (48 kHz). Larger buffers. |
| External audio interface | Multi-channel — your code may get stereo when it expected mono. |
| Device hot-swap mid-recording | What happens when the user yanks their headphones? |
Each failure mode you find becomes a test case (use recorded audio fixtures for regression).
What This Doesn’t Fix
- You still own resampling. cpal gives you raw samples at the device’s native rate. Voice transcription typically wants 16 kHz mono. We use
rubatofor resampling. - cpal doesn’t do echo cancellation. If you’re recording playback + voice, you’ll need platform-specific echo cancellation, which is back to platform-specific code.
- Audio bugs are nasty to debug. Buffer underruns, sample format mismatches, channel count surprises. Plan for 2x your initial debugging estimate.
- Cross-platform doesn’t mean cross-platform-identical. Windows’ WASAPI behaves differently than macOS’s CoreAudio in edge cases. Your test matrix needs both.
- In-process audio still requires a permission grant on macOS (Microphone permission). Tauri handles the prompt, but you have to handle the deny case in your UI.
Try It This Week
Saturday afternoon (3 hours):
- Add cpal + hound to
Cargo.toml - Create the 6-module skeleton (empty files)
- Write
capture.rsand get raw samples flowing into a buffer - Print sample counts to confirm capture is working
Sunday morning (4 hours):
- Write
silence.rswith the RMS detector - Write unit tests against synthesized sine waves
- Wire silence detection into a basic capture session
Monday evening (3 hours):
- Write
checkpoint.rsandencoder.rs - Generate your first WAV and play it back
Tuesday–Friday (2 hours/day):
pipeline.rsto orchestrateresampler.rsfor the 16kHz target- Real-hardware testing across the matrix above
That’s ~2 weeks part-time to ship. Full-time, ~1 week.
The Takeaway
Subprocess audio is a category of code that solo builders carry for years because rewriting it feels scary. The scary part is the cross-platform audio code, not the in-process refactor.
cpal makes the cross-platform audio code small (~600 lines for a real capture pipeline). The pipeline pattern makes it testable. The 6-module split keeps the bugs localized.
If you’re carrying a Swift CLI, an Objective-C subprocess, or any platform-specific audio subprocess: this is the path off it. Three weeks of focused work, and you delete more code than you write while gaining cross-platform support and testability.
The 1,628-line Swift CLI we replaced wasn’t bad code. It was just code that had to exist when in-process cross-platform audio in Rust wasn’t a realistic option. It is now. The work to switch is finite and bounded — and most of it is structure, not algorithms.
Coming up next: post #138 — whisper-rs in production. Once your audio capture is in-process, the next subprocess to kill is the transcription engine. Same playbook, different crate.