← Build log

Ditching SoX for Swift: How We Built Our Own Audio Capture Tool (And Why You Should Too)

Published: December 2024

You know that moment when you’re deep in a project and suddenly realize you’re completely dependent on a tool that’s older than your nephew’s TikTok account? That was me last week, staring at our SoX dependency like it was a flip phone at a Tesla convention.

SoX (Sound eXchange) has been the Swiss Army knife of audio processing since the Clinton administration. It’s reliable, it’s powerful, and it’s absolutely everywhere in audio toolchains. But here’s the thing about Swiss Army knives - they’re great until you need something specific, and then you’re trying to perform surgery with a corkscrew.

TLDR: What You’ll Gain

  • Complete control over your audio pipeline without external dependencies
  • Native performance using Swift and AVFoundation on macOS
  • Flexible integration that works with any Node.js/Electron project
  • Implementation in under 4 hours (including tests and documentation)
  • Bottom Line Value: Replace a 20MB+ dependency with a 2MB Swift binary that does exactly what you need, nothing more.

The Problem: When Dependencies Become Liabilities

Let me paint you a picture. VoiceCatcher needs to capture audio for transcription - specifically mono, 16-bit, 16kHz PCM audio that’s perfect for speech recognition. SoX can absolutely do this. It can also convert your audio to formats you’ve never heard of, apply effects that would make Pink Floyd jealous, and probably make you a sandwich if you ask nicely.

But we’re shipping an Electron app to beta users. Every dependency is a potential support nightmare waiting to happen. SoX requires system-level installation, has platform-specific quirks, and brings along enough baggage to fill a cargo plane.

The breaking point? A beta user on macOS Ventura couldn’t get SoX to recognize their USB microphone. Not our bug, not our code, but definitely our problem.

The Approach: Going Native

Instead of debugging someone else’s 30-year-old C codebase, I decided to build exactly what we needed. Nothing more, nothing less. Think of it like ordering a burger - we don’t need the 47-page menu, we need a really good cheeseburger.

The requirements were beautifully simple:

  • Record from system microphone
  • Output mono, 16-bit, 16kHz PCM audio
  • Save to file OR stream to stdout
  • Optional duration parameter
  • Generate valid WAV files

That’s it. No effects, no format conversions, no kitchen sink.

The Implementation: Swift Package Manager + AVFoundation

Here’s where it gets fun. Instead of wrestling with cross-platform audio libraries or Node.js native modules, I went straight to the source: macOS’s AVFoundation framework.

The beauty of this approach:

  1. Zero external dependencies - it’s all built into macOS
  2. Native performance - no JavaScript bridge overhead
  3. Bulletproof reliability - Apple’s own audio stack
  4. Easy deployment - single binary, no installation

The Core Architecture

// Main command-line interface
@main
struct VoiceCatcherCapture: AsyncParsableCommand {
    @Option(name: .shortAndLong, help: "Output file path")
    var file: String?

    @Flag(name: .long, help: "Output to stdout")
    var stdout: Bool = false

    @Option(name: .shortAndLong, help: "Recording duration in seconds")
    var duration: Double?
}

// The actual recording happens here
class AudioRecorder: NSObject, AVAudioRecorderDelegate {
    // AVFoundation magic - records exactly what we need
}

The Swift Package Manager setup was refreshingly straightforward:

let package = Package(
    name: "voicecatcher-capture",
    platforms: [.macOS(.v10_15)],
    products: [
        .executable(name: "voicecatcher-capture", targets: ["VoiceCatcherCapture"])
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser", from: "1.2.0")
    ]
)

The WAV File Generation

Here’s where I got nerdy. Instead of relying on AVFoundation’s built-in WAV export (which adds metadata we don’t need), I hand-crafted the WAV headers:

func createWAVFile(from audioData: Data) throws -> Data {
    var wavData = Data()

    // RIFF header - the magic that makes it a WAV file
    wavData.append("RIFF".data(using: .ascii)!)
    wavData.append(withUnsafeBytes(of: UInt32(36 + audioData.count).littleEndian) { Data($0) })
    wavData.append("WAVE".data(using: .ascii)!)

    // fmt chunk - tells the world it's 16kHz mono PCM
    // ... (the rest is just byte manipulation)
}

Why hand-craft WAV headers in 2024? Because when you control the entire pipeline, you can guarantee compatibility. No mysterious metadata, no unexpected format variations, just pure, predictable audio data.

The Human Reality: What Actually Happened

Let me be honest - this wasn’t a smooth, linear process. The first version crashed immediately because I forgot that AVAudioSession doesn’t exist on macOS (it’s iOS-only). Spent about 20 minutes googling “AVAudioSession macOS equivalent” before realizing that macOS just… doesn’t need it.

Then there was the submodule dance. Git submodules are like that friend who’s incredibly helpful but also incredibly particular about how you ask for help. The workflow took some getting used to:

  1. Make changes in bin/audio-capture/
  2. Commit and push to the submodule repo
  3. Update the main repo to point to the new submodule commit
  4. Remember to push both repos

It’s not complicated, but it’s different enough that you’ll forget a step and wonder why your changes aren’t showing up.

The breakthrough moment came when I tested the audio output and heard crystal-clear recording at exactly the specs we needed. No noise, no artifacts, no mysterious format conversions. Just clean audio data, ready for transcription.

The Business Impact: Numbers Don’t Lie

Before (SoX dependency):

  • 23MB of additional binaries in our distribution
  • Platform-specific installation requirements
  • Support tickets for audio driver conflicts
  • Zero control over audio processing pipeline

After (Swift tool):

  • 2MB self-contained binary
  • Zero installation requirements (beyond macOS)
  • Complete control over audio capture logic
  • Native integration with our Electron app

Time investment: 4 hours total (development, testing, documentation) Ongoing maintenance: Practically zero (it’s using Apple’s own audio stack)

But here’s the real kicker - we can now iterate on audio capture without waiting for SoX updates or worrying about breaking changes. Need a different sample rate? Five-minute code change. Want to add real-time audio processing? We own the pipeline.

What This Means for Us

This isn’t just about replacing a dependency - it’s about owning our audio destiny. Every solo builder faces this choice: depend on external tools or build exactly what you need.

The Swift approach scales beautifully. Need Windows support? Build a C# version using the same architecture. Need Linux support? ALSA is your friend. The pattern is proven, the code is clean, and the approach is replicable.

What excites me most is the control. When a beta user reports an audio issue, we can debug our own code instead of filing GitHub issues with projects that haven’t been updated since Obama was president.

Your Action Items (Copy-Paste Ready)

1. Audit Your Dependencies (5 minutes)

# List all your project dependencies
npm list --depth=0
# Ask: Which of these could I build in 4 hours?

2. Identify Single-Purpose Dependencies Look for tools that do 100 things when you need 3. These are prime candidates for replacement.

3. Consider the Swift Option If you’re building for macOS, Swift + native frameworks often beats cross-platform solutions for single-purpose tools.

4. Set Up Git Submodules Properly

# Add a submodule for your custom tool
git submodule add <repo-url> path/to/tool
# Remember: two repos, two commit processes

5. Test Extensively

# Build and test your tool independently
cd path/to/tool && swift build -c release
# Test edge cases your dependency handled

The Framework: When to Build vs. Buy

Build when:

  • You need 3 features but the tool has 300
  • The dependency is causing support issues
  • You can implement core functionality in under a day
  • Platform-specific solutions outperform cross-platform ones

Keep the dependency when:

  • It’s actively maintained and well-documented
  • The complexity would take weeks to replicate
  • You’re using 80%+ of its features
  • Breaking changes are extremely rare

What’s Next?

We’re integrating this Swift tool into VoiceCatcher’s main audio pipeline this week. The plan is feature-flag rollout - start with internal testing, then beta users, then full release. If something breaks, we can fall back to SoX instantly.

But I’m confident this won’t be necessary. There’s something deeply satisfying about code that does exactly what you need, nothing more. It’s like finally getting that perfect cheeseburger instead of navigating a 47-page menu.

The broader lesson? Sometimes the best solution isn’t finding the right tool - it’s building the right tool. Especially when “right” means “exactly what we need, when we need it, with no surprises.”

Your dependencies should serve you, not the other way around. And sometimes that means rolling up your sleeves and writing some Swift.


Want to see the full Swift implementation? Check out the voicecatcher-capture repository for the complete source code, tests, and documentation. It’s MIT licensed and ready for your next audio project.

This is part of our ongoing journey building VoiceCatcher in public. Follow along as we make the hard choices between building and buying, and learn what it really takes to ship a product as a solo technical founder.