kitlangton / kitlangton/Hex

coreaudiod persistent 10% CPU usage when running hex

Open
#137 2 comments 3 reactions 0 assignees View on GitHub
Dominant language
Swift
Stars
2.9k
Forks
226
PR merge metrics
No merged PRs in 30d

Description

## Bug Report: coreaudiod persistent 10% CPU usage after transcriptions

### Description
After performing a transcription, `coreaudiod` persistently uses ~10% CPU even when Hex is idle. The CPU usage only drops to 0% when the Hex app is completely terminated.

### Environment
- **macOS Version**: Tahoe (macOS 26.2)
- **Hardware**: Apple Silicon (M-series)
- **Hex Version**: Debug build from main branch
- **Transcription Engine**: Parakeet TDT v3 (FluidAudio)

### Steps to Reproduce
1. Launch Hex Debug
2. Start a recording (press hotkey)
3. Complete transcription (or cancel with ESC)
4. Check `coreaudiod` CPU usage:
```bash
ps aux | grep "coreaudiod$" | grep -v grep
```
5. Observe `coreaudiod` at ~10-12% CPU
6. Kill Hex app:
```bash
pkill -f "Hex Debug"
sleep 3
ps aux | grep "coreaudiod$" | grep -v grep
```
7. Observe `coreaudiod` drops to 0.0% CPU

### Expected Behavior
After transcription completes and the app is idle, `coreaudiod` should return to 0% CPU without requiring app termination.

### Actual Behavior
`coreaudiod` maintains 10-12% CPU usage until Hex is killed, suggesting the app is holding audio sessions or resources that aren't being released.

### Investigation Summary

#### Attempted Fixes (Unsuccessful)
1. **Setting model references to `nil`**:
- `whisperKit = nil`
- `parakeet.asr = nil`, `parakeet.models = nil`
- Added 1-3 second wait times after cleanup
- **Result**: No change, CPU stays at 10%

2. **Recording cleanup**:
- Properly calling `recorder.stop()` and `recorder.deleteRecording()`
- Setting `recorder = nil`
- Adding cleanup delays
- **Result**: No change

3. **Preventing Parakeet load in main app**:
- Modified `downloadAndLoadModel()` to skip `ensureLoaded()` for Parakeet
- Only helper process loads models
- **Result**: No change

#### Successful Workarounds
1. **Helper Process Isolation (Partial Success)**:
- Created `ParakeetHelper` binary that loads Parakeet models in separate process
- Helper is killed after transcription completes
- **Result**: Prevents Parakeet-related CPU leak, but ~10% leak remains
- **Location**: `Hex/Resources/ParakeetHelper`, source at `/ParakeetHelper/`

2. **Watchdog Script (Temporary)**:
- Auto-restarts Hex when `coreaudiod` stays above 8% for 20+ seconds
- **Location**: `tools/coreaudiod-watchdog.sh`
- **Usage**: `nohup tools/coreaudiod-watchdog.sh &`

### Root Cause Analysis

**Key Observation**: Killing the Hex app immediately drops `coreaudiod` to 0%, proving the leak is within the main app process, not the system daemon itself.

**Suspected Components**:
1. **AVAudioRecorder** (Primary Suspect):
- Recording uses `AVAudioRecorder` in `RecordingClient.swift`
- Even after calling `.stop()`, `.deleteRecording()`, and setting reference to `nil`, audio sessions may persist
- Similar to known CoreML audio session bug
- macOS doesn't provide `AVAudioSession.setActive(false)` like iOS (API unavailable)

2. **Audio Queue Services**:
- `AVAudioRecorder` uses Audio Queue Services internally
- These may not be properly released when recorder is destroyed
- No explicit API to force-close audio queues on macOS

### Logs

Console logs show proper cleanup sequence:
```
[com.kitlangton.Hex:Recording] Recording started
[com.kitlangton.Hex:Recording] Recorder fully destroyed and cleaned up
[com.kitlangton.Hex:Transcription] Parakeet helper completed in 2.6s, exit code: 0
[com.kitlangton.Hex:Transcription] ✅ Parakeet helper SIGKILL sent (PID 76887)
```

No errors or warnings about audio sessions, yet `coreaudiod` remains at 10% CPU.

### Relevant Code Locations

**Recording Cleanup** (`Hex/Clients/RecordingClient.swift:820-850`):
```swift
func stopRecording() async -> URL {
// Stop and copy BEFORE cleanup/deletion
if let recorder = recorder {
recorder.stop()
recorder.isMeteringEnabled = false
}

// Copy recording
exportedURL = try duplicateCurrentRecording()

// Cleanup
if let recorder = recorder {
recorder.deleteRecording()
}

self.recorder = nil
stopMeterTask()
endRecordingSession()

try? await Task.sleep(for: .milliseconds(100))
}
```

**ParakeetHelper Integration** (`Hex/Clients/TranscriptionClient.swift:178-260`):
- Spawns helper process with `Process()`
- Passes `XDG_CACHE_HOME` environment variable
- Waits for completion, reads JSON output
- Forcefully kills helper with `SIGKILL`

### Questions for Investigation

1. **Audio Session Management**:
- Does `AVAudioRecorder` on macOS have a proper cleanup API?
- Is there a way to explicitly invalidate audio sessions?
- Should we move recording to a helper process like transcription?

2. **Alternative Recording Approaches**:
- Could we use `AVCaptureSession` instead of `AVAudioRecorder`?
- Would `Audio Queue Services` API give more control?
- Is there a lower-level API that properly releases resources?

3. **System-Level Debugging**:
- Can we use `sample` command to profile `coreaudiod` and see what's holding resources?
- Are there Instruments templates to trace audio session lifecycle?
- What does DTrace show about audio queue activity?

### Temporary Workaround

Users experiencing this issue can use the watchdog script:

```bash
# Start watchdog (checks every 10s, restarts after 20s high CPU)
nohup tools/coreaudiod-watchdog.sh > /dev/null 2>&1 &

# Monitor logs
tail -f ~/Library/Logs/hex-watchdog.log

# Stop watchdog
pkill -f coreaudiod-watchdog
```

### Potential Solutions (For Discussion)

1. **Move Recording to Helper Process**:
- Similar to `ParakeetHelper`, create `RecordingHelper`
- Isolate `AVAudioRecorder` in separate process
- **Pros**: Guaranteed resource cleanup on process exit
- **Cons**: Complex IPC, potential audio device conflicts

2. **Investigate Audio Unit API**:
- Use lower-level Audio Units instead of `AVAudioRecorder`
- More control over audio graph lifecycle
- **Pros**: Explicit cleanup, better resource management
- **Cons**: More complex code, harder to maintain

3. **Periodic App Restart**:
- Built-in auto-restart after N transcriptions
- Cleaner than watchdog script
- **Pros**: Simple, user-configurable
- **Cons**: Doesn't fix root cause, interrupts workflow

### Additional Context

This appears to be a macOS-level bug where audio resources aren't properly released when Swift objects are deallocated. Similar issues exist with:
- CoreML models keeping audio sessions active
- AVFoundation not cleaning up audio queues
- System audio daemon not detecting unused sessions

### References

- CoreML audio leak: https://developer.apple.com/forums/thread/699908
- AVAudioRecorder lifecycle: https://developer.apple.com/documentation/avfaudio/avaudiorecorder
- Audio Session management (iOS only): https://developer.apple.com/documentation/avfaudio/avaudiosession

---

**Labels**: bug, performance, audio, macos-specific, needs-investigation
**Priority**: Medium (workaround exists, but impacts battery life)

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with the cleanup path in Hex/Clients/RecordingClient.swift:820-850 and reproduce the issue using the listed recording steps and ps command. Review the ParakeetHelper integration in Hex/Clients/TranscriptionClient.swift:178-260 to separate transcription cleanup from recording cleanup. Done means coreaudiod returns to 0% after transcription while Hex remains running, without relying on tools/coreaudiod-watchdog.sh.

Written by the indexing model from the issue text.

Assessment

Tech stack
macos, swift
Domain
audio-video-rtc, desktop, performance
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
30/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.