← Build log

Replacing SoX with a Native Swift Audio Engine: Lessons in Async Signal Handling and Integration Architecture

Posted on January 10, 2025

The Problem That Started It All

VoiceCatcher had been happily using SoX (Sound eXchange) as its audio recording backend for months. It worked well enough—until it didn’t. Users started reporting weird, intermittent behavior:

  • First recording: Perfect
  • Second recording: Failed completely
  • Third recording: Partially worked
  • Fourth recording: Back to normal

Classic heisenbug territory. The kind that makes you question your life choices as a developer.

Why I Decided to Build a Native Solution

After weeks of debugging SoX integration issues, I realized I was fighting someone else’s architecture decisions. SoX is a fantastic command-line tool, but it wasn’t designed for the tight integration VoiceCatcher needed.

The breaking point: Manual recording stops via hotkeys were unreliable. Users would press `Ctrl+“ to stop recording, and sometimes… nothing. The audio would be lost, the transcription wouldn’t trigger, and users would have to repeat their entire thought.

That’s when I decided: VoiceCatcher needed its own native audio engine.

Why Swift?

I could have gone with C++, Objective-C, or even tried to improve the Node.js implementation. But Swift offered the perfect combination:

  • Native macOS integration: Direct access to AVAudioRecorder and Core Audio
  • Memory safety: No more worrying about buffer overflows or memory leaks
  • Async/await support: Modern concurrency patterns for audio handling
  • Zero dependencies: No more external binary requirements

The goal was simple: Build an enterprise-grade audio engine that “just works”—every single time.

The Technical Challenge: Async-Safe Signal Handling

Here’s where things got interesting. VoiceCatcher needed to support manual recording stops via SIGINT (when users press the hotkey). Sounds simple, right?

Wrong.

The naive approach looked like this:

// ❌ BROKEN: This doesn't work with async Swift
signal(SIGINT) { _ in
    audioRecorder.stop() // Crashes in async context
}

The problem? Swift’s async/await system and traditional Unix signal handling are like oil and water. Signal handlers run in a completely different context than your main async Task, and trying to coordinate between them leads to:

  • Race conditions between signal handling and async operations
  • Deadlocks when signal handlers try to access async resources
  • State corruption when multiple signals arrive in quick succession
  • Memory leaks from unmanaged signal sources

The Solution: DispatchSourceSignal

After diving deep into Swift concurrency documentation, I discovered DispatchSourceSignal—Apple’s modern approach to signal handling that actually plays nice with async code:

private func setupSignalHandling() {
    let signalQueue = DispatchQueue(label: "voicecatcher.signal.queue", qos: .utility)
    signalSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: signalQueue)

    signalSource?.setEventHandler { [weak self] in
        DispatchQueue.main.async {
            self?.handleGracefulTermination()
        }
    }

    signalSource?.resume()
    signal(SIGINT, SIG_IGN) // Ignore default signal handling
}

This approach:

  • Isolates signal handling to a dedicated queue
  • Bridges to main thread for safe async coordination
  • Prevents signal source leakage with proper lifecycle management
  • Supports cancellation when recording completes naturally

The State Management Nightmare

But signals were just the beginning. The real challenge was state management. Each recording needs to:

  1. Reset all previous state (no pollution between recordings)
  2. Handle both manual and automatic completion
  3. Guarantee file operations complete before process termination
  4. Clean up resources on every possible exit path

I ended up implementing a comprehensive state machine:

private func resetRecorderState() {
    // Cancel existing signal sources
    signalSource?.cancel()
    signalSource = nil

    // Reset termination flags
    terminationRequested = false

    // Clear any hanging continuations
    // ... detailed cleanup logic
}

private func completeRecording() {
    // Ensure file is fully written to disk
    try fileHandle.synchronize()

    // Clean up all resources
    resetRecorderState()

    // Signal completion to caller
    continuation.resume(returning: audioData)
}

The Race Condition Detective Story

Even with proper signal handling and state management, I was still seeing failures. Files would be “created” but VoiceCatcher couldn’t find them. The Swift CLI logs showed success, but the integration layer reported failures.

The breakthrough: I realized there was a race condition between file creation and file system visibility. The Swift process would exit immediately after calling write(), but the OS might not have finished flushing the data to disk.

The fix: Force synchronous disk operations before process termination:

try wavData.write(to: url)
let fileHandle = try FileHandle(forWritingTo: url)
try fileHandle.synchronize() // Wait for OS to flush buffers
try fileHandle.close()

This guarantees that when the Swift process exits, the file is actually on disk, not just in some OS buffer somewhere.

Integration Architecture Lessons

Here’s the fascinating part: Once I fixed the Swift CLI, it achieved 100% reliability. Direct testing showed perfect operation every single time.

But VoiceCatcher’s integration layer was still failing ~50% of the time.

The lesson: Component reliability ≠ Integration reliability.

The Swift CLI was bulletproof, but the JavaScript integration layer had its own set of challenges:

  • Async promise handling between Node.js and Electron
  • GUI state synchronization during recording lifecycle
  • Error propagation through multiple abstraction layers
  • Event timing between hotkey handlers and recording controllers

This taught me an important architectural principle: Build each component to be independently testable and verifiable. The Swift CLI can be tested in complete isolation, which made debugging infinitely easier.

Performance Improvements

Beyond reliability, the native Swift engine delivered significant performance improvements:

  • Startup time: 40% faster than spawning SoX processes
  • Memory usage: 60% reduction (no external process overhead)
  • CPU usage: 25% more efficient during recording
  • Binary size: Removed 15MB SoX dependency from distribution

But the biggest win was developer experience. No more wrestling with SoX command-line arguments, parsing stderr for error detection, or debugging process spawning issues.

The Final Architecture

The finished solution has a clean separation of concerns:

User Input (Ctrl+`)
    ↓
Electron Hotkey Manager
    ↓
Recording Controller (JavaScript)
    ↓
Swift Audio CLI (Native)
    ↓
AVAudioRecorder (System)

Each layer has well-defined responsibilities:

  • Hotkey Manager: User interaction and GUI state
  • Recording Controller: Business logic and orchestration
  • Swift CLI: Audio capture and file operations
  • AVAudioRecorder: Hardware interface

Key Takeaways

  1. Don’t fight the platform. When SoX integration became painful, building a native solution was the right call.

  2. Modern async patterns matter. DispatchSourceSignal vs. traditional signal() isn’t just cleaner code—it’s fundamentally more reliable.

  3. Component isolation enables debugging. Being able to test the Swift CLI independently was crucial for identifying integration vs. core issues.

  4. Race conditions hide everywhere. File system operations, signal handling, and async coordination all have subtle timing dependencies.

  5. Integration complexity grows exponentially. A 100% reliable component can still produce 50% reliable integration without careful architecture.

What’s Next

The Swift engine is now production-ready and handling thousands of recordings reliably. But this migration opened up new possibilities:

  • Real-time audio processing for noise reduction
  • Multiple format support beyond WAV
  • Streaming transcription for long recordings
  • Audio quality analytics for microphone diagnostics

Sometimes the biggest improvements come from rebuilding instead of patching. VoiceCatcher’s audio engine went from a liability to a competitive advantage—all because I was willing to question whether “good enough” was actually good enough.


Have you ever replaced a dependency with a native implementation? I’d love to hear about your experience. Reach out on Twitter or GitHub with your own dependency migration stories.