← Build log

The Great Swift CLI Integration Adventure: A Journey Through Silence Detection, Debugging, and Determination

December 12, 2024

Sometimes the most seemingly straightforward technical integrations turn into epic adventures that test every debugging skill you’ve developed over years of software development. This is the story of one such journey: integrating VoiceCatcher’s Swift CLI silence detection with our Node.js Electron app’s settings system.

What started as “just make the Swift CLI read our app settings” became a multi-day odyssey through environment variables, config files, process spawning mysteries, timer callbacks, RunLoop intricacies, and the subtle art of inter-process communication. By the end, we’d learned more about macOS process architecture than we ever expected—and built something pretty remarkable.

The Mission: Replacing Sox with Swift

VoiceCatcher had been using Sox for audio recording, but we wanted to replace it with our custom Swift CLI that had working silence detection. The Swift CLI was already functional when run directly—it could detect speech, monitor for pauses, and automatically stop recording after a configurable silence timeout. Perfect!

The challenge? Our Swift CLI was hardcoded to use environment variables like VOICECATCHER_SILENCE_TIMEOUT=1.2 and VOICECATCHER_SILENCE_THRESHOLD=0.01, while VoiceCatcher’s settings lived in a JSON config file with values like silenceTimeoutMs: 1200 and silenceThreshold: 0.01.

“How hard could it be to make them talk to each other?” we thought.

Narrator: It was harder than they thought.

Act I: The Environment Variable Odyssey

The First Attempt: “Just Pass the Environment Variables”

Our initial approach seemed logical: modify the Node.js spawn call to pass environment variables to the Swift process.

// This should work, right?
const swiftProcess = spawn(executablePath, args, {
  stdio: ['ignore', options.stream ? 'pipe' : 'ignore', 'pipe'],
  env: {
    ...process.env,
    VOICECATCHER_SILENCE_TIMEOUT: (settings.silenceTimeoutMs / 1000).toString(),
    VOICECATCHER_SILENCE_THRESHOLD: settings.silenceThreshold.toString(),
  },
});

We added extensive logging to verify the environment variables were being set correctly. Node.js confirmed it was passing them. But when we checked the Swift CLI logs… nothing. Zero VOICECATCHER environment variables were reaching the Swift process.

The Debugging Deep Dive

This is where things got interesting. We added debug logging everywhere:

print("🔇 [SWIFT-ENV-DEBUG] Found \(voiceCatcherEnvVars.count) VOICECATCHER environment variables:")
for (key, value) in voiceCatcherEnvVars {
    print("🔇 [SWIFT-ENV-DEBUG]   \(key)=\(value)")
}

When run directly: “Found 2 VOICECATCHER environment variables” When spawned from Node.js: “Found 0 VOICECATCHER environment variables”

The environment variables were vanishing somewhere between Node.js setting them and Swift receiving them. We tried different spawn configurations, checked process inheritance, even manually printed process.env in Node.js to confirm the variables were there.

Nothing worked. The environment variables were being lost in translation.

Act II: The Config File Gambit

”What If We Use a Config File?”

After banging our heads against the environment variable wall, we pivoted to a config file approach. The idea: Node.js creates a temporary JSON config file with the silence settings, passes the file path to Swift CLI via --config argument, and Swift reads the settings from the file.

// Create temporary config file
const tempConfigPath = path.join(os.tmpdir(), `voicecatcher-config-${Date.now()}.json`);
const configData = {
  silenceTimeoutMs: settings.silenceTimeoutMs,
  silenceThreshold: settings.silenceThreshold,
};
await fs.writeFile(tempConfigPath, JSON.stringify(configData, null, 2));

// Pass to Swift CLI
const args = ['--file', outputPath, '--config', tempConfigPath, '--verbose'];

On the Swift side, we added config file parsing:

if let configPath = configPath {
    print("🔍 DEBUG: Loading config from: \(configPath)")
    if let configData = FileManager.default.contents(atPath: configPath),
       let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any] {

        if let timeoutMs = config["silenceTimeoutMs"] as? Int {
            setenv("VOICECATCHER_SILENCE_TIMEOUT", String(Double(timeoutMs) / 1000.0), 1)
        }
        if let threshold = config["silenceThreshold"] as? Double {
            setenv("VOICECATCHER_SILENCE_THRESHOLD", String(threshold), 1)
        }
    }
}

We tested it extensively. When run directly, it worked perfectly—Swift CLI read the config file, set the environment variables, and silence detection worked flawlessly.

When spawned from Node.js? The config files were created correctly, passed correctly, but somehow Swift CLI wasn’t processing them.

The Great Logging Mystery

This led us to another discovery: we weren’t seeing Swift CLI debug output in VoiceCatcher’s logs. The Swift CLI was using print() statements for debug logging, which go to stdout, but Node.js was only capturing stderr.

Quick fix:

// Before: only capturing stderr
stdio: ['ignore', options.stream ? 'pipe' : 'ignore', 'pipe'];

// After: capturing both stdout and stderr
stdio: ['ignore', options.stream ? 'pipe' : 'pipe', 'pipe'];

Now we could see Swift CLI’s debug output! But the config file approach still wasn’t working when spawned from Node.js.

Act III: The Eureka Moment

”Why Can’t the Swift App Check There Too?”

After multiple failed attempts with environment variables and config files, we had a breakthrough moment. Instead of trying to pass data from Node.js to Swift, why not have Swift read directly from VoiceCatcher’s config file?

VoiceCatcher already stores its settings in ~/Library/Application Support/voicecatcher/config.json. The Swift CLI could read from the same location!

func loadVoiceCatcherSettings() {
    let homeDir = FileManager.default.homeDirectoryForCurrentUser
    let configPath = homeDir.appendingPathComponent("Library/Application Support/voicecatcher/config.json")

    print("🔍 DEBUG: VoiceCatcher config path: \(configPath.path)")

    guard let configData = try? Data(contentsOf: configPath),
          let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any] else {
        print("❌ ERROR: Could not read VoiceCatcher config")
        return
    }

    if let timeoutMs = config["silenceTimeoutMs"] as? Int {
        let timeoutSeconds = Double(timeoutMs) / 1000.0
        setenv("VOICECATCHER_SILENCE_TIMEOUT", String(timeoutSeconds), 1)
        print("🔍 DEBUG: Found silenceTimeoutMs=\(timeoutMs), converting to \(timeoutSeconds)s")
    }

    if let threshold = config["silenceThreshold"] as? Double {
        setenv("VOICECATCHER_SILENCE_THRESHOLD", String(threshold), 1)
        print("🔍 DEBUG: Found silenceThreshold=\(threshold)")
    }

    print("✅ SUCCESS: VoiceCatcher settings loaded - silence detection configured")
}

This approach was elegant: no inter-process communication needed, no temporary files, no environment variable inheritance issues. Swift CLI reads directly from the source of truth.

And it worked! We could see in the logs:

✅ SUCCESS: VoiceCatcher settings loaded
🔇 SILENCE DETECTION: Enabled - timeout=1.2s, threshold=0.01

Act IV: The Timer Mystery

When Success Reveals New Problems

With the config integration working, we discovered our next challenge: the timer callbacks weren’t executing. We could see:

⏰ Starting silence detection timer (checking every 0.1s)
✅ Audio engine started - real-time monitoring active

But no amplitude readings, no silence detection logic running. The timer was being created but never firing.

The RunLoop Revelation

The issue was subtle but critical. Timer.scheduledTimer creates a timer on the current thread’s RunLoop, but our startSilenceDetectionTimer() was being called from the audio engine’s context, which might not have a RunLoop running.

The fix required ensuring timer creation on the main thread and explicitly adding it to the main RunLoop:

DispatchQueue.main.async { [weak self] in
    let timer = Timer(timeInterval: 0.1, repeats: true) { [weak self] _ in
        self?.checkForSilence()
    }
    RunLoop.main.add(timer, forMode: .common)
    self.amplitudeMonitoringTimer = timer
    print("🔍 DEBUG: [SILENCE] ⏰ Timer created and added to main RunLoop")
}

Suddenly, the timer callbacks started executing and we could see amplitude readings!

Act V: The Logic Labyrinth

Understanding the Requirements

With the timer working, we discovered our silence detection logic was fundamentally wrong. We were measuring total recording time instead of silence duration after speech detection.

Through collaborative discussion, we clarified the correct behavior:

  1. Recording starts → No timer, just record everything
  2. Initial silence → Ignore completely, keep recording
  3. First speech detected → Now start monitoring for pauses
  4. Speech continues → Keep recording, no timer
  5. Pause detected → Start the 1.2s timer
  6. Speech resumes before 1.2s → Reset timer, continue recording
  7. Another pause → Start timer again from 0
  8. Pause lasts full 1.2s → Stop recording

This required a complete rewrite of the SilenceTracker with proper state management:

class SilenceTracker {
    private var speechDetected = false      // Has any speech been heard yet?
    private var currentlyInPause = false    // Currently in a pause?
    private var pauseStartTime: Date?       // When did current pause start?

    func updateAmplitude(_ amplitude: Float) {
        let isSpeech = amplitude >= threshold

        if isSpeech {
            if !speechDetected {
                speechDetected = true
                print("🎤 [SILENCE] First speech detected - now monitoring for pauses")
            }

            if currentlyInPause {
                currentlyInPause = false
                pauseStartTime = nil
                print("🎤 [SILENCE] Speech resumed - pause timer reset")
            }
        } else if speechDetected && !currentlyInPause {
            // Start new pause
            currentlyInPause = true
            pauseStartTime = Date()
            print("🔇 [SILENCE] Pause started - timer begins")
        }

        // Check if pause has exceeded timeout
        if let pauseStart = pauseStartTime {
            let pauseDuration = Date().timeIntervalSince(pauseStart)
            if pauseDuration >= timeoutSeconds {
                print("🔇 [SILENCE] Silence timeout reached - stopping recording")
                stopRecording()
            }
        }
    }
}

The Amplitude Threshold Discovery

Even with correct logic, silence detection wasn’t triggering. The issue? Our threshold was set to 0.5, but RMS amplitude values for normal speech typically range from 0.01 to 0.3. Speech was never being detected because the threshold was too high!

We changed silenceThreshold from 0.5 to 0.01 in VoiceCatcher’s config, and suddenly everything started working:

🎤 [SILENCE] Amplitude: 0.023 (speech detected)
🔇 [SILENCE] Pause started - timer begins
🎤 [SILENCE] Speech resumed - pause timer reset
🔇 [SILENCE] Pause started - timer begins
🔇 [SILENCE] Silence timeout reached - stopping recording

Act VI: The Final Integration

Putting It All Together

After all the debugging adventures, we had a working system:

  1. VoiceCatcher settings: silenceTimeoutMs: 1200, silenceThreshold: 0.01
  2. Swift CLI: Reads directly from VoiceCatcher’s config file
  3. Environment variables: Swift sets VOICECATCHER_SILENCE_TIMEOUT=1.2, VOICECATCHER_SILENCE_THRESHOLD=0.01
  4. Audio engine: 48kHz, 1 channel, real-time amplitude monitoring
  5. Timer: Scheduled every 0.1s on main RunLoop to check for silence
  6. Silence logic: Wait for speech → Monitor for pauses → Start timer on pause → Reset timer if speech resumes → Stop recording if pause exceeds timeout

The Evidence of Success

The logs told the story of our victory:

✅ SUCCESS: VoiceCatcher settings loaded
🔇 SILENCE DETECTION: Enabled - timeout=1.2s, threshold=0.01
✅ Audio engine started - real-time monitoring active
⏰ Starting silence detection timer (checking every 0.1s)
🎤 [SILENCE] First speech detected - now monitoring for pauses
🎤 [SILENCE] Amplitude: 0.023 (speech detected)
🔇 [SILENCE] Pause started - timer begins
🔇 [SILENCE] Silence timeout reached - stopping recording
ℹ️  INFO: [SILENCE] 🛑 SILENCE TIMEOUT: 1.3s silence detected - auto-stopping

Lessons Learned: The Technical and Human Elements

Technical Insights

  1. Environment Variable Inheritance: Process spawning on macOS can be more complex than expected. Environment variables don’t always inherit as cleanly as you’d hope.

  2. RunLoop Fundamentals: Timer creation context matters enormously. Understanding which thread has a RunLoop and ensuring proper timer scheduling is crucial for real-time applications.

  3. Audio Processing Reality: RMS amplitude values are much smaller than intuition suggests. Always validate your thresholds with real audio data.

  4. State Machine Clarity: Complex behavior like “silence after speech detection” requires careful state management. Drawing out the state transitions helps enormously.

  5. Logging Strategy: Comprehensive logging at every step is invaluable for debugging complex integrations. Don’t assume anything is working until you can see it in the logs.

Collaborative Problem-Solving

This project highlighted the power of collaborative debugging. When one approach failed, we’d brainstorm alternatives. When requirements were unclear, we’d work through examples together. The breakthrough moment—“Why can’t the Swift app check there too?”—came from stepping back and questioning our assumptions.

The iterative process of:

  • Try an approach
  • Debug thoroughly
  • Identify the real problem
  • Brainstorm alternatives
  • Implement and test

…proved more valuable than trying to architect the perfect solution upfront.

The Road Ahead: Almost Back to Baseline

We’re tantalizingly close to fully replacing Sox with our Swift CLI. The silence detection integration is working, settings are properly synchronized, and the audio quality is excellent. We have “a little ways to go, but nearly there.”

The remaining work involves:

  • Final testing across different audio scenarios
  • Performance optimization for longer recordings
  • Edge case handling for unusual audio patterns
  • Integration testing with the full VoiceCatcher workflow

But the hardest part—getting two different processes to communicate effectively about silence detection—is solved. The Swift CLI now seamlessly integrates with VoiceCatcher’s settings system, providing the automatic recording termination that makes the app feel magical to use.

Conclusion: The Journey Was the Destination

What started as a “simple” integration became a masterclass in debugging, process communication, audio processing, and collaborative problem-solving. We learned about macOS process architecture, RunLoop intricacies, audio amplitude characteristics, and the importance of clear requirements.

More importantly, we built something robust and elegant. The Swift CLI now reads directly from VoiceCatcher’s authoritative config source, eliminating the complexity of inter-process communication while providing the silence detection functionality our users need.

Sometimes the most valuable technical work isn’t the code you write—it’s the problems you solve, the debugging skills you develop, and the collaborative relationships you build along the way. This Swift CLI integration adventure was all three.

Next up: Final testing and the complete Sox replacement. The finish line is in sight.


This post is part of the VoiceCatcher development blog series, documenting the technical challenges and solutions in building a modern voice transcription application for macOS.