회의마다 "네, 뭐라고요?"를 외치다가 직접 만든 Mac 메뉴 막대 앱 — Huh?
회의나 강의에서 방금 놓친 말을 되돌려 듣기 위해, 최근 30초~2분의 오디오를 RAM에 보관했다가 단축키로 즉시 재생하는 macOS 메뉴 막대 앱 Huh?를 만든 개발기입니다. CoreAudio 프로세스 탭, 무잠금 링 버퍼, 온디바이스 전사, ad-hoc 코드 서명이 권한 없이 무음을 반환하는 문제 등 실제 구현 과정에서 겪은 함정들이 상세히 공유됩니다.
본문
Zoom 통화에서 누군가 URL을 말했는데 메모하느라 놓쳤고, "링크 다시 말해줄 수 있나요?"라고 채팅을 치는 동안 상대가 어차피 붙여넣어 준 경험에서 출발한 프로젝트입니다. 저자가 필요했던 것은 화면 녹화도, 클라우드 전사 서비스도 아닌 "메모리에 남아 있는 지난 30초"였고, 그래서 ⌥⌘H를 누르면 직전 구간을 즉시 다시 재생하는 macOS 메뉴 막대 앱 Huh?를 직접 만들었습니다.
제품 구조와 대안 검토
Huh?는 특정 앱의 시스템 오디오, 전체 시스템 오디오, 또는 마이크 중 하나를 골라 롤링 버퍼를 가동합니다. 버퍼는 30초에서 2분까지 설정할 수 있고, ⌥⌘H를 누르면 HUD가 열리며 버퍼 끝부터 재생이 시작됩니다. AVAudioUnitTimePitch를 통해 0.75배·0.5배 재생 시에도 피치가 유지되고, 이름이나 URL을 놓쳤을 때를 대비해 온디바이스 전사 기능도 있습니다. ⌘S로 클립을 저장하기 전까지는 디스크에 아무것도 쓰지 않으며, 유일한 네트워크 요청은 설정에서 Whisper 모델을 다운로드할 때뿐입니다. 화면 녹화는 "사전에 켜야 하는" 도구라 이 용도와 맞지 않고, Otter 같은 서비스는 회의 오디오 업로드와 봇 참여가 싫어서 배제했다고 합니다. YouTube에는 되감기 버튼이 있지만 Zoom에는 없고, 메뉴 막대 유틸리티는 앱을 가로질러 따라다닌다는 것이 존재 이유입니다.
오디오 캡처와 링 버퍼
스택은 Swift와 NSPanel 안의 SwiftUI, CoreAudio 프로세스 탭(CATapDescription), 재생용 AVAudioEngine, 선택적 WhisperKit입니다. Xcode 프로젝트 없이 SwiftPM과 Command Line Tools로 빌드하고 build.sh가 .app 번들을 수동 조립합니다. 오디오는 모노 Float32 PCM으로 들어와 무잠금 링 버퍼에 기록되는데, write 경로는 CoreAudio의 실시간 IO 스레드에서 실행되므로 그 안에서는 잠금·할당·로깅·ARC retain이 모두 금지됩니다. "한 번의 할당이나 os_log 호출이 스타일 문제가 아니라 들리는 글리치"라는 주석이 코드에 남아 있습니다. 30초 버퍼는 약 5.5MB, 48kHz 기준 2분 버퍼는 약 22MB를 차지하며, 릴리스 빌드는 lipo로 arm64+x86_64 유니버설입니다.
일주일을 먹은 재생 디싱크 버그
재생 중 Bluetooth 헤드폰을 연결하는 등 시스템 오디오 장치가 바뀌면 UI는 0:18을 가리키는데 실제 오디오는 엉뚱한 위치에 있는 디싱크가 발생했습니다. 일시정지가 "계속"으로 바뀌어도 오디오가 계속 나오는 경우도 있었습니다. 해결책은 재생 위치를 벽시계가 아닌 플레이어의 샘플 클럭에서 구동하고, 오래된 completion handler가 현재 재생과 충돌하지 않도록 generation token을 쓰며, 종료 감지에는 노드 정지 가정 대신 .dataPlayedBack을 사용하는 것이었습니다.
SFSpeechRecognizer의 타임스탬프 거짓말
requiresOnDeviceRecognition = true를 켠 SFSpeechRecognizer는 긴 버퍼를 처리하는 것처럼 보이지만 실제로는 약 1분을 넘기면 앞부분만 반환하고 뒷부분 단어의 타임스탬프가 깨집니다. 107초 연속 음성을 측정했더니 0.00~30.03과 60.06~83.76만 보고되어 후반부가 전반부 위에 겹쳐 있었고, 끝부분 단어를 클릭하면 잘못된 지점으로 시크했습니다. 해결책은 약 40초 단위로 잘라 각 경계 근처에서 가장 조용한 지점에서 분할하고, 순차 전사 후 타임스탬프에 오프셋을 더하는 방식이었습니다. 또한 전사 전에 AudioPrep이 하이패스 필터와 게인 정규화를 수행하는데, 피크 0.02 수준의 조용한 강의 오디오가 전사 절반만 반환하던 원인이 "신비한 이유가 아니라 그냥 볼륨"이었다고 합니다. 실측 비교에서 Apple 엔진은 186초 음성을 427단어·약 25초에 처리했고, WhisperKit은 450단어·M-series에서 약 1.8초였습니다. 단 Whisper는 x86_64 슬라이스에서 모델 로드 즉시 segfault(종료 코드 139)가 났기 때문에, Intel Mac에서는 엔진 선택지 자체를 숨겼습니다.
ad-hoc 코드 서명과 TCC의 침묵
codesign -s -로 ad-hoc 서명하면 macOS는 오디오 탭 실행을 허용하면서도 모든 샘플이 0인 무음을 전달합니다. 프롬프트도 오류도 없습니다. TCC가 권한을 서명 identity에 묶는데 ad-hoc에는 identity가 없어서, macOS가 묻지도 기록하지도 않고 탭은 멀쩡해 보이면서 아무것도 주지 않는 것입니다. 개발용으로는 로컬 인증서를 만드는 스크립트를 쓰고, 배포용으로는 Developer ID와 공증(notarization)이 필요하며 이것이 현재 출시 블로커입니다.
상주 앱의 메모리와 라이선스 설계
메뉴 막대 앱은 하루 종일 상주하므로 --memory-report 플래그로 전 경로의 footprint를 출력해 검증했습니다. M1 Pro 16GB·120초 버퍼 기준으로 실행 직후 약 13MB, 링 버퍼 포화 시 +16MB, Apple 전사 후 약 46MB, Whisper Base 로드 시 약 200MB까지 올라갔다가 유휴 150초 후 파이프라인을 내려 약 90MB로 수렴합니다. 라이선스 키는 <base64 페이로드>.<base64 Ed25519 서명> 형식으로, 컴파일된 공개키로 오프라인 검증되며 활성화 서버가 없습니다. 트라이얼도 없고 키 입력 전에는 탭 자체를 가동하지 않는데, 게이트가 UI가 아니라 CaptureController.arm에 있어 미라이선스 사본이 마이크를 열 수 없습니다. 결제는 Dodo Payments가 webhook으로 키에 서명해 이메일로 보내는 방식이며, 앱에는 텔레메트리·업데이트 확인·분석 코드가 없습니다.
출시 전 남은 일과 가격 고민
체크리스트에는 Apple 공증, macOS 15 하드웨어 테스트, Sparkle 등 업데이트 수단, 실제 HUD와 일치하는 랜딩 데모가 남아 있습니다. "첫 유료 사용자가 Gatekeeper에 부딪혀 이탈하기 전에 미리 말하겠다"는 입장입니다. 가격은 얼리 어답터용 $5, 공증 후 $12~15, 전문가 대상이면 $24까지를 두고 고민 중이며, 독자에게 강의·회의·팟캐스트 중 무엇에 쓸지와 지불 의사를 묻고 있습니다.
원문: dev.to / 번역·요약: Trawling