macOS 앱에서 온디바이스 비디오 노트 구축하기: SpeechAnalyzer, 파운데이션 모델(Foundation Models), 그리고
요약
macOS 앱 Reel에서 구현된 온디바이스 비디오 노트 구축 과정을 설명합니다. libmpv를 활용한 오디오 추출부터 SpeechAnalyzer를 이용한 전사 및 요약까지, 실제 프로덕션 환경에서의 구현 방법과 주의사항을 다룹니다.
핵심 포인트
- libmpv의 PCM 출력을 활용해 다양한 비디오 컨테이너에서 오디오 추출
- Apple Intelligence 없이도 SpeechAnalyzer로 빠른 온디바이스 전사 가능
- 실제 구현 시 데드라인 설정 및 파일 무결성 검증의 중요성 강조
- 타임스탬프 옵션을 활용한 플레이어 탐색 기능 구현 방법
온디바이스 비디오 노트 구축하기: SpeechAnalyzer, 파운데이션 모델 (Foundation Models), 그리고 출시된 macOS 앱에서의 libmpv
저의 macOS 비디오 플레이어인 Reel에는 '비디오 노트(Video Notes)'라는 기능이 있습니다. 로컬 비디오를 선택하고 'Generate'를 클릭하면 타임스탬프가 찍힌 전사(transcript), 선택적인 번역, 그리고 구조화된 요약을 얻을 수 있습니다. 이 모든 과정은 **전적으로 온디바이스(on-device)**에서 이루어집니다. API 키도 필요 없고, 업로드도 필요 없으며, 2시간 분량의 강의가 담긴 mkv 파일에서도 작동합니다.
이 파이프라인은 네 단계로 구성되며, 각 단계는 서로 다른 기술을 사용합니다:
비디오 파일 (mp4/mov/mkv/webm/…)
│ libmpv, ao=pcm → 16 kHz mono WAV (Apple Intelligence가 필요하지 않음)
▼
...
Apple의 2025년 API들은 멋지게 데모를 보여줍니다. 하지만 이를 실제로 출시(shipping)하는 것은 다른 이야기입니다. 흥미로운 버그들은 모두 마지막 단계에서 발생합니다. 여기 각 단계가 실제 프로덕션에서 어떻게 구현되었는지, 그리고 제가 필요했던 모든 해결책(workaround)을 소개합니다.
1단계: 헤드리스 libmpv (ao=pcm)를 이용한 오디오 추출
왜 AVFoundation을 사용하지 않았을까요? 로컬 비디오 라이브러리용으로는 충분하지 않기 때문입니다. 실제 환경에서 흔히 쓰이는 mkv와 webm은 아예 열리지 않습니다. Reel은 이미 재생을 위해 libmpv를 포함하고 있으며, libmpv에는 어떠한 인코더도 없이 범용 오디오 추출기로 만들 수 있는 트릭이 있습니다. 바로 PCM 오디오 출력입니다.
guard let h = mpv_create() else { return nil }
func opt(_ k: String, _ v: String) { _ = mpv_set_option_string(h, k, v) }
opt("terminal", "no"); opt("config", "no"); opt("msg-level", "all=no")
...
비디오를 비활성화하고 오디오 장치를 WAV 라이터로 교체한 상태에서 파일을 "재생"합니다. 디코딩은 실시간보다 훨씬 빠르게 실행됩니다. mpv가 디멀티플렉싱(demux)할 수 있는 모든 것—mkv, webm, avi 및 AVFoundation이 거부하는 다른 컨테이너들—은 16 kHz 모노 WAV가 됩니다. 프로덕션을 위한 두 가지 참고 사항은 다음과 같습니다:
MPV_EVENT_END_FILE이 나올 때까지mpv_wait_event를 호출하되, 반드시 **데드라인(deadline)**을 설정하세요. 그렇지 않으면END_FILE을 내보내지 않는 병적인(pathological) 입력값이 파이프라인을 영원히 중단시킬 수 있습니다.- 성공을 선언하기 전에 출력 파일이 존재하는지, 크기가 1 KB를 초과하는지 확인(sanity-check)하세요. 오디오 트랙이 없는 비디오는 빈 파일로 "성공" 처리될 수 있습니다.
2단계: SpeechAnalyzer를 이용한 전사 (macOS 26)
SpeechAnalyzer / SpeechTranscriber는 새로운 음성 스택입니다. 온디바이스(on-device) 방식이며 빠르고(제 테스트 결과 긴 파일도 실시간보다 훨씬 빠르게 처리했습니다), 특히 Apple Intelligence를 필요로 하지 않으며 모델 다운로드만 있으면 됩니다. 샘플 코드에서 강조하지 않는 세 가지 사항은 다음과 같습니다.
1. attributeOptions를 통해 타임스탬프(timestamps)를 요청하세요. 저는 줄을 클릭했을 때 *플레이어를 탐색(seek)*할 수 있는 전사(transcript)를 원하며, 이는 세그먼트별 타이밍(per-segment timing)을 의미합니다.
let transcriber = SpeechTranscriber(
locale: locale,
transcriptionOptions: [],
...
타이밍 정보는 속성 문자열(attributed-string) 런(runs)으로 반환됩니다. audioTimeRange를 포함하는 첫 번째 런을 읽어 세그먼트의 시작 시간을 가져오세요.
2. 모델 다운로드는 사용자에게 노출해야 하는 작업입니다. 특정 로케일(locale)을 처음 사용할 때는 에셋(asset)이 필요합니다.
if let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
try await request.downloadAndInstall()
}
첫 실행 시 시간이 다소 걸릴 수 있으므로, 단순히 로딩 스피너만 보여주지 말고 진행 상황(progress)을 표시하세요.
3. 파일을 입력하는 것과 동시에 결과를 병렬로 소비(consume)하세요. transcriber.results는 비동기 시퀀스(async sequence)입니다. analyzer.analyzeSequence(from:)가 오디오 파일을 소비하는 동안 Task 내에서 이를 수집하고, 마지막 샘플에 대해 finalizeAndFinish(through:)를 호출하세요. 작업이 끝난 후 수집하려고 하면 데드락(deadlock)이 발생합니다. 수집기(collector)가 이미 데이터를 비워내고(draining) 있어야 합니다.
3단계: 번역(translation) — SwiftUI 내부에서만 존재하는 프레임워크
Translation 프레임워크는 Apple Intelligence 요구 사항 없이 온디바이스로 영어↔일본어(및 기타 언어) 번역을 수행합니다. 이 프레임워크의 한 가지 구조적 특징은 서비스 레이어에서 번역 세션을 단순히 인스턴스화할 수 없다는 점입니다. TranslationSession은 오직 SwiftUI의 .translationTask 수정자(modifier)를 통해서만 제공되므로, 번역 단계는 파이프라인의 나머지 부분과 동일한 상태 비저장(stateless) 서비스가 아닌 뷰/모델 레이어에서 구동됩니다. 이러한 비대칭성을 고려하여 아키텍처를 설계하세요. 저의 파이프라인 서비스는 추출/전사/요약을 수행하며, SwiftUI 인터페이스를 소유한 모델이 번역을 구동합니다.
Stage 4: 파운데이션 모델 (Foundation Models)을 이용한 요약 — 실제 운영 이슈가 발생한 지점
이 단계는 "데모에서는 잘 작동하지만", 실제 콘텐츠를 다룰 때는 네 가지 다른 방식으로 실패하는 단계입니다. 아래의 모든 해결책은 실제 배포되는 코드에 적용되어 있습니다.
4a. 기본 가드레일 (Guardrails)이 일반적인 콘텐츠를 거부함
저의 첫 번째 엔드투엔드 (end-to-end) 테스트에서는 관광 클립은 잘 요약했지만, 평범한 인터뷰 영상은 거부했습니다. 기본 안전 가드레일 (safety guardrails)이 지극히 정상적인 인간의 대화에 대해 오탐 (false-positive)을 일으킨 것입니다. 그리고 _사용자 자신의 파일_을 요약하는 것은 바로 Apple이 가드레일을 완화하여 제공하는 정확한 유스케이스 (use case)입니다:
private static let summaryModel =
SystemLanguageModel(guardrails: .permissiveContentTransformations)
.permissiveContentTransformations (사용자가 제공한 자료에 대한 콘텐츠 변환 작업을 위해 의도된 모드)로 전환함으로써 오탐 차단을 극적으로 줄일 수 있었습니다.
4b. 가드레일 거부와 실제 실패를 구분하기
모델이 실제로 거부할 때, 사용자에게는 단순히 "생성 실패 (generation failed)\
긴 전사 데이터(transcript)를 포함한 단일 respond(to:) 호출은 단순히 '컨텍스트 윈도우 초과 (context window exceeded)' 오류를 발생시키며, 정작 요약이 가장 절실한 비디오들에 대해서는 요약을 제공하지 못합니다. 해결책은 전형적인 맵-리듀스 (map-reduce) 방식입니다. 즉, 전사 데이터를 청크(chunk) 단위로 나누고(요청당 약 6,000자), 중립적인 프롬프트(prompt)로 각 청크를 요약한 다음, 결합된 부분 요약본들을 사용자의 선호도를 적용하여 최종적으로 다시 요약하는 것입니다. 청커(chunker)는 공백을 기준으로 분할하며, 이는 공백으로 단어가 구분되는 언어에서의 단어 경계를 의미합니다. 일본어는 내부 공백이 없지만, 전사 데이터는 타임스탬프가 찍힌 세그먼트(segment)들을 공백으로 연결하여 조립되므로, 세그먼트 경계에서 여전히 올바르게 청킹됩니다. 만약 결합된 부분 요약본들조차 예산(budget)을 초과한다면, 실패하기보다는 결합된 결과물을 그대로 전달하십시오.
4d. 온디바이스 모델이 반복 루프에 빠지는 경우
가끔 모델이 동일한 문장을 계속해서 반복하는 경우가 발생하는데, 이는 전형적인 탐욕적 디코딩 (greedy-decoding) 실패 사례입니다. 이를 방지하기 위해 다음 세 가지 방어 기제가 모두 필요합니다:
- 약간의 온도 (temperature) 조절.
GenerationOptions(temperature: 0.6)를 사용하면 대부분의 루프를 즉시 방지할 수 있습니다. - 여전히 반복되는 내용 압축. 후처리(Post-process) 단계에서 동일한 인접 라인(line)을 제거하고, 라인 내에서 동일한 인접 문장을 제거합니다.
- 퇴보한 출력(degenerate output) 감지 및 1회 재시도. 압축 후에도 문장의 절반 이상이 중복된다면 모델이 루프에 빠진 것입니다. 이 경우 쓰레기 값을 보여주는 대신 한 번 더 재생성(regenerate)을 시도하고, 그래도 안 되면 포기하십시오:
private static func isDegenerate(_ text: String) -> Bool {
let units = text.split(whereSeparator: { $0 == "\n" || $0 == "。" || $0 == "." })
.map { $0.trimmingCharacters(in: .whitespaces) }
...
4e. 동일한 단계에서 얻은 작은 교훈들
- 이 작업에서는 Plain text가 Guided Generation을 이겼습니다. 구조화된 (guided) 출력은 가끔 형식이 잘못되어 돌아오는 경우가 있었습니다. 반면 엄격하게 지정된 plain-text 형식("1–2문장 개요, 빈 줄, 3–6개의 불렛 포인트")은 매우 쉽게 파싱되었으며 결코 깨지지 않았습니다.
- 요약할 수 없는 것은 요약하지 마세요. 전사(transcript) 내용이 약 140자 미만일 경우, "요약"은 단순히 입력을 다시 말하는 것에 불과합니다. 이 단계는 건너뛰고 사용자에게 전사 내용을 직접 보여주세요.
- 교차 언어 출력에는 강력한 지시가 필요합니다. 영어 전사를 일본어 요약으로 만드는 것은 가능하지만, 지침에 사실상 _"중요: 제목을 포함한 요약 전체를 일본어로 작성할 것"_이라고 명시해야만 합니다. 정중한 요청만으로는 혼용된 언어의 출력을 얻게 됩니다.
- 전사는 노이즈가 있는 입력임을 명시하세요. 한 줄의 지침("전사는 자동 생성되었습니다. 명백한 고유명사 오인식은 조용히 수정하세요")이 기술 콘텐츠의 요약 품질을 눈에 띄게 향상시킵니다.
우아한 성능 저하 (Degrade gracefully)
SystemLanguageModel.default.availability를 통해 Apple Intelligence가 활성화되어 있는지 확인할 수 있습니다. 활성화되어 있지 않더라도 요약 단계만 작동하지 않을 뿐, 전사(transcription), 번역(translation), 프레임 캡처(frame captures)는 여전히 작동하므로 Apple Intelligence가 없는 기기에서도 기능은 유용하게 유지됩니다. 가용성을 사전에 확인하고 기능 전체가 아닌 정확히 하나의 단계만 비활성화하세요.
조립 노트 (Assembly notes)
- 한 번 생성하여 유지하고, 즉시 다시 불러옵니다 (Generate once, persist, reload instantly). 파이프라인 실행에는 수 초에서 수 분이 소요됩니다. 결과물은 경로에서 유도된 안정적인 비디오 ID (stable video ID)를 키로 하여 Application Support 폴더에 JSON 형식(및 캡처된 프레임)으로 저장됩니다. 재생성은 명시적인 동작을 통해서만 이루어집니다.
- 모든 단계는 독립적입니다. 추출(Extraction) 및 전사(Transcription) 단계는 Apple Intelligence의 도움 없이도 작동합니다. 번역은 선택 사항이며 필요할 때만 수행됩니다. 요약(Summary)만이 유일하게 제한된(gated) 단계입니다. 파이프라인을 이와 같이 설계하면 각 기능이 개별적으로 저하(degrade)될 수 있습니다.
- 프레임 캡처는 플레이어의 엔진을 재사용합니다. 타임스탬프가 찍힌 캡처는 플레이어가 썸네일 생성에 사용하는 것과 동일한 headless-libmpv 스크린샷 경로를 사용하므로, "이 순간을 캡처"하는 데 추가 비용이 들지 않습니다. 최종 목표는 요약, 이미지가 포함된 하이라이트, 전사 내용이 포함되어 Obsidian이나 GitHub에서 열 수 있는 휴대 가능한 Markdown 내보내기(export)를 만드는 것입니다.
시사점 (The takeaway)
2026년의 Mac에서 온디바이스 AI (On-device AI)는 진정으로 출시 가능한 수준이지만, 그 난이도는 예상과는 반대입니다. AI 부분(전사 품질, 요약 품질)은 대부분 그냥 잘 작동하는 반면, 프로덕션 엔지니어링 — 가드레일의 오탐(false positives), 컨텍스트 예산 관리(context budgeting), 반복 루프(repetition loops), 프레임워크 생명주기 특이사항(framework lifecycle quirks), 우아한 기능 저하(graceful degradation) — 이 부분이 실제 작업이 집중되는 곳입니다. 이 문제들이 닥칠 것임을 미리 알고 있다면 그중 어느 것도 어렵지 않습니다. 이제 당신은 알고 있습니다.
저는 이 앱을 Mac App Store에서 이용 가능한 macOS용 로컬 비디오 플레이어인 Reel을 위해 구축했습니다. 관련 내용인 libmpv 자체를 App Store 심사를 통해 통과시키는 방법은 여기에서 확인할 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기