Imported from geongyu09/meeting-stt (
.claude/skills/meeting-stt-dev/SKILL.md). Install upstream withnpx skills add geongyu09/meeting-stt --skill meeting-stt-dev. Copyright stays with the author.
meeting-stt 개발 방향성
클로바 노트/젠스파크 회의록과 유사한 기능을 서버 없이 데스크탑 앱 하나로 구현한다.
근거 문서는 plan.md(2026-08 기준 조사 자료). 이 스킬은 그 문서에서 결정된 사항과
작업 규칙만 추려낸 것이며, 배경 설명이 필요하면 plan.md의 해당 섹션을 읽는다.
0. 문서 우선 원칙 (SSOT)
이 스킬 문서(SKILL.md + references/*.md)가 프로젝트의 단일 진실 공급원(SSOT)이고, 코드는 그 부산물이다.
코드와 문서가 다르면 문서가 옳다고 보고 코드를 문서에 맞춘다. 문서를 고쳐야 할 이유가 생겼다면 코드보다 문서를 먼저 고친다.
- 기술 결정(1절)·파이프라인(2절)·로드맵(3절)·구조 규칙(4절)과 어긋나는 구현·리팩터링·라이브러리 추가·모델 변경은 문서를 먼저 수정하고 사용자에게 변경 내용을 알린 뒤 코드를 작성한다. "일단 코드 먼저, 문서는 나중에"는 금지.
- 작업 중 문서에 없는 결정을 내려야 하면(예: 새 IPC 채널 규약, 스키마 컬럼 추가, 상수 값 선택) 코드를 쓰기 전에
해당
references/*.md에 결정과 근거를 추가한다. 사소한 구현 세부는 코드에 두되, 다른 코드가 의존하게 되는 계약은 문서에 둔다. - Phase 완료·진입 등 상태 변화는 3절의 "현재 위치"와
references/roadmap.md체크리스트를 갱신해 기록한다. - 문서에 오래된 내용(예: 마이그레이션 전 도구 이름)이 발견되면 즉시 문서를 고친다. 낡은 문서는 잘못된 SSOT다.
1. 확정된 기술 결정 (변경 시 사용자 확인 필수)
| 영역 | 결정 | 비고 |
|---|---|---|
| 대상 플랫폼 | macOS 14+ / Apple Silicon 전용 | Windows는 지원하지 않는다 (2026-08-26 결정). 새 코드에 win32 분기·자산을 만들지 않는다. 이미 있는 win32-x64 바이너리·build:win·setupBin --platform=win32-x64는 유지·검증 대상이 아니다 |
| 데스크탑 프레임워크 | Electron (electron-vite + React 19 + TS) | 이미 스캐폴드됨. Tauri로 전환하지 않는다 |
| 패키지 매니저 / 스크립트 러너 | pnpm 10 (pnpm install, pnpm dev, pnpm test, pnpm --filter meeting-stt exec tsx scripts/*.ts) |
런타임은 Node 22+. 단위 테스트는 vitest, TS 스크립트 실행은 tsx. bun/npm/yarn 명령을 문서·스크립트에 섞지 않는다 |
| 리포지토리 구조 | pnpm 워크스페이스 모노레포 — apps/desktop(제품 Electron 앱) · apps/web(브라우저 프로토타입) · packages/core(순수 공용 로직) · packages/models(모델 카탈로그) |
두 앱이 같은 파이프라인을 다른 런타임에서 돌리므로 병합·포맷·정규화 공식·참석자 수 규칙·모델 카탈로그는 packages/*에 한 번만 정의한다. 의존 방향은 apps/* → packages/* 한 방향이고 패키지는 electron·fs·DOM을 import하지 않는다. 워크스페이스 경계·스크립트 규약·새 패키지 추가 절차는 references/monorepo.md |
| STT 엔진 | whisper.cpp whisper-cli 바이너리를 child_process로 spawn (방식 A) |
기본 모델 ggml-large-v3-turbo-q5_0.bin, 고품질 옵션 large-v3-q5_0, 저사양 옵션 small-q5_1. -l ko --output-json-full + 토큰 타임스탬프. -dtw는 끔 (Phase 1에서 이득 없음, --no-flash-attn을 강제해 느려짐) |
| 화자 분리 | sherpa-onnx (pyannote segmentation-3.0 ONNX + 3D-Speaker ERes2Net 임베딩) | sherpa-onnx-offline-speaker-diarization CLI spawn (v1.13.6), CPU 프로바이더 (coreml은 CPU보다 훨씬 느려 사용 안 함). 참석자 수를 받아 --clustering.num-clusters로 돌리는 것이 기본 경로(녹음 정지 시 입력, meetings.speaker_count). sherpa-onnx의 군집은 complete-linkage + 코사인 거리 고정 임계값이라 임계값 방식은 클러스터 수가 참석자 수가 아니라 녹음 길이·발화 교대에 비례해 늘어난다 (71분 발표: 0.9에서 115개·10초 이상 38명, 앱 26분 회의: 129개·23명). 참석자 수가 없을 때만 --clustering.cluster-threshold=0.8 + 군소 화자 흡수(10초 미만, assignSpeakers)로 폴백하고 과분할될 수 있음을 UI에 안내한다. 참석자 수가 있으면 군소 화자 흡수를 하지 않는다 (docs/phase1-results.md 6·7절) |
| 가속 · 스레드 | whisper.cpp·llama.cpp는 Metal GPU(전 레이어 offload), sherpa-onnx는 CPU. spawn에 넘기는 스레드 수는 성능 코어(P) 수 기준으로 프로세스마다 따로 정한다 | os.cpus().length - 2처럼 효율 코어까지 세면 더 느려지면서 발열만 는다 (M3 Pro 실측: 화자 분리 -t 10 18.2초·CPU 915% → -t 6 10.8초·CPU 593%). 설정 '조용히 처리'(pipeline.quiet, 기본 꺼짐) 를 켜면 화자 분리 스레드를 성능 코어의 절반으로 줄이고 STT와 순차로 돌린다 (화자 분리 +44%, CPU 부하 절반). 화자 분리를 CoreML로 옮기지 않는 이유(임베딩 입력 길이가 호출마다 달라 CPU보다 느림)도 같은 문서에 있다. 정책과 실측표는 references/architecture.md |
| 음량 정규화 | STT·화자 분리 전에 WAV 전체에 순수 TS RMS 게인 정규화 (src/main/pipeline/normalize.ts) |
ffmpeg를 동봉하지 않는다. 50ms 프레임 RMS의 90퍼센타일을 −20 dBFS로 맞추고 게인은 최대 +30 dB, 초과 샘플은 하드 클립. 원거리 마이크 녹음(발화 −44 dBFS)에서 Whisper가 수십 초를 통째로 놓치던 것을 복구한다 (글자수 +36%, ffmpeg loudnorm과 동등). 화자 분리에는 효과 없음. docs/phase1-results.md |
| VAD | whisper.cpp 내장 VAD (--vad --vad-model ggml-silero-v5.1.2.bin) |
환각 억제뿐 아니라 타임스탬프 정확도에도 필수. 끄면 화자 경계 단어가 앞 화자에게 붙는다 (docs/phase1-results.md) |
| 녹음 | getUserMedia + AudioWorklet으로 16kHz mono Float32 PCM 직접 수집 → WAV |
MediaRecorder/ffmpeg 경로 사용 안 함. 주기적으로 디스크에 append |
| 저장소 | SQLite via better-sqlite3 (main 프로세스 전용) |
스키마는 references/data-model.md |
| 라우팅 | react-router v8의 createHashRouter + RouterProvider |
URL 공유가 없고 file://에서도 동작한다. BrowserRouter 금지. 경로 상수는 @renderer/shared/routes에서만 정의 |
| 스타일 | CSS Modules (index.module.css 코로케이션) + base.css의 CSS 변수 토큰 |
UI 라이브러리·CSS-in-JS 도입 안 함 |
| 편집 | 발화 단위 인라인 편집(contentEditable/textarea, blur 시 UPDATE) | 에디터 라이브러리 도입 금지 (필요 생기면 그때 TipTap 검토) |
| 모델 배포 | 설치 파일에 미동봉, 첫 실행 온보딩에서 다운로드 (Range 이어받기 + 체크섬) | 저장 위치 app.getPath('userData')/models |
| 시스템 오디오 캡처 | 1차 범위 제외 (마이크만) | Phase 5의 두 번째 항목. 로컬 요약을 끝낸 뒤 착수한다 |
| 로컬 요약 | llama.cpp llama-cli 를 child_process로 spawn. 모델 Qwen3-4B-Instruct-2507-Q4_K_M.gguf (Apache-2.0, 비사고형 instruct) |
llama-server(HTTP)는 쓰지 않는다 — 단발 요약에 상주 서버·포트 관리가 필요 없다. 프롬프트·시스템 프롬프트·출력은 전부 파일로 주고받고(-f/-sysf/-o), 회의록이 길면 map-reduce 청킹. 자동 실행이 아니라 사용자가 버튼으로 요청한다 (references/architecture.md) |
| 녹음본 재생 | 요구사항 아님 → 파이프라인 완료 후 원본 WAV 삭제가 기본, 보관은 설정 옵션(audio.keep, /settings) |
실패한 잡은 재시도용으로 원본을 남긴다 |
| 클립보드 | 복사는 main의 electron.clipboard 경유(clipboard:writeText) |
file:// 문서와 권한 핸들러에 걸릴 여지를 없앤다. 텍스트 조립은 renderer가 @meeting-stt/core/format으로 |
| 녹음 위젯 | Electron 플로팅 패널 창(화면 우측, type: 'panel', alwaysOnTop) + 메뉴바 Tray 시간 + 전역 단축키(기본 ⌥⌘R/⌥⌘W, 설정에서 변경) |
macOS WidgetKit 위젯(SwiftUI 앱 확장)은 만들지 않는다 — 서명·공증 대상이 늘고 상태를 프로세스 밖으로 복제해야 하는데 얻는 건 외형뿐이다. 오디오 그래프의 소유자는 위젯 창 하나이고 메인 창은 명령 전송·상태 구독만 한다. 진행 중 녹음의 단일 출처는 main의 녹음 세션이며 recording:state로 두 창에 push한다. 설계는 references/architecture.md의 "녹음 위젯 패널" 절 |
| 확인 UI | 되돌릴 수 없는 동작(회의 삭제, 화자 병합)은 2단계 인라인 확인. window.confirm·네이티브 대화상자 금지 |
renderer를 멈추지 않고 통합 테스트로 검증할 수 있다 |
2. 처리 파이프라인 (불변)
[마이크 녹음 (AudioWorklet, 16kHz mono)]
→ [WAV 확정]
→ [음량 정규화 (RMS 게인, 순수 TS)]
→ [VAD 무음 제거]
→ [whisper-cli → JSON 세그먼트(+단어 타임스탬프)] ┐ 코어 수에 따라
→ [sherpa-onnx diarization → 화자 구간] ┘ 병렬/순차 분기
→ [병합: 군소 화자 흡수 → 타임스탬프 겹침 최대 화자 배정 → 동일 화자 연속 발화 문단화]
→ [SQLite INSERT, status='done']
→ [(설정) 원본 WAV 삭제]
→ [홈 리스트 / 디테일 페이지: 조회·인라인 수정·화자 이름 지정·복사]
무거운 작업(spawn, 파일 IO, DB)은 전부 main 프로세스(또는 utilityProcess) 에서 수행하고, renderer는 IPC로 요청·진행률 수신만 한다. 렌더러 내 추론(transformers.js/WebGPU)은 하지 않는다.
3. 단계별 로드맵 — 순서를 건너뛰지 않는다
상세 체크리스트(완료 기준 포함)는 references/roadmap.md.
- Phase 1 파이프라인 검증 (스크립트) — 앱 UI를 만들기 전에
apps/desktop/scripts/에서 tsx로whisper-cli → sherpa-onnx → 병합을 실제 한국어 회의 WAV로 돌려 품질·속도를 확인하고 모델 크기/양자화/cluster_threshold를 튜닝한다. 여기서 만든 병합 로직은 이후src/main/pipeline/으로 그대로 옮긴다. - Phase 2 앱 골격 — 녹음 → WAV → 파이프라인 → SQLite → 홈/디테일 관통.
- Phase 3 편집·복사·화자 관리 — 인라인 편집, 화자 이름, 전체/부분 복사, 진행률.
- Phase 4 배포 품질 — 온보딩 모델 다운로드, 코드 사이닝/notarization, electron-updater, 저사양 폴백.
- Phase 5 확장 — 로컬 LLM 요약(llama.cpp), 시스템 오디오 캡처.
현재 위치: Phase 1~4 종료, Phase 5-1(로컬 요약) 종료 (2026-09-18).
Phase 2·3·4와 5-1의 완료 기준이 2026-09-18 사용자 수동 확인을 모두 통과했다.
진행 중인 작업은 Phase 5-3(녹음 위젯 패널) 이다 — 사용자 요청으로 5-2보다 먼저 착수했고, 5-1처럼 순서를 건너뛴 예외다.
그 뒤 작업 후보는 Phase 5-2(시스템 오디오 캡처) 와 미착수로 남은 배포·품질 항목(다음 릴리스의 업데이트용 zip, 회의별 용어 사전, 저사양 요약 폴백 모델)이다. Phase 1은 합성 픽스처에 이어 실제 한국어 발표·Q&A 녹음(71분, 음성 메모 m4a → 16kHz WAV) 으로 재측정까지 마쳤고,
그 결과 음량 정규화 단계 추가·cluster-threshold 0.8·군소 화자 흡수를 확정했다(docs/phase1-results.md). Phase 2의 run.ts가 이 세 가지를 반영했고,
녹음 → 파이프라인 → SQLite → 홈/디테일 관통이 붙었다. Phase 3(편집·복사·화자 관리·설정)과 Phase 4(온보딩 모델 다운로드, 서명·업데이터 설정,
CI, 단일 인스턴스)도 코드가 붙었고, 세 Phase의 완료 기준(실제 녹음·실제 회의록·빈 userData로 온보딩)을 모두 통과했다.
Phase 5-1(로컬 LLM 요약)은 사용자 지시로 Phase 3·4보다 먼저 착수했다 — 로드맵 순서를 건너뛴 예외이므로 여기 기록해 둔다.
5-1에 남은 항목(회의별 용어 사전, 저사양 폴백 모델)은 완료 기준이 아니라 후속 과제다 (references/roadmap.md).
Phase 4 배포 결정(모델 레지스트리·온보딩·바이너리·서명·업데이트·CI)은 references/distribution.md에 있다.
Phase 5-2(시스템 오디오 캡처)는 아직 시작하지 않았다.
Phase 5-3(녹음 위젯 패널) 은 2026-09-18에 문서를 먼저 확정했다. 녹음 제어가 메인 창 밖으로 나가면서
오디오 그래프 소유자·참석자 수의 단일 출처·창 참조 관리가 함께 바뀌므로, 체크리스트의 "계약 변경"을 먼저 끝내고 구현한다.
Phase 5-4(LLM 회의록 교정) 는 2026-09-24 사용자 요청으로 검증 단계만 마쳤다 — 발화를 LLM이 다시 쓰는 방식은 폐기하고,
용어 사전 + 발음 유사도 후보 + LLM O/X 판정으로 수정 제안을 만드는 방식을 채택했다 (docs/phase5-refine-results.md). 앱 통합은 사용자 확인 뒤 착수한다.
작업 시작 시 git log/디렉터리 상태로 현재 Phase를 먼저 재확인한다.
4. 코드 구조와 규칙
워크스페이스 경계·패키지 형태·스크립트 규약은 references/monorepo.md, 데스크탑 앱 내부의 디렉터리 배치·IPC 규약·프로세스 경계는 references/architecture.md, 배포(모델 다운로드·바이너리·서명·업데이트·CI)는 references/distribution.md를 따른다. 코드 컨벤션·renderer React 레이어(추상화 레벨·콜로케이션·세그먼트·훅 위치)·IPC/API 작성·테스트 배치 규칙은 .claude/rules/*.md에 있으며, 해당 경로의 파일을 만들거나 수정할 때 자동으로 적용된다. 핵심 규칙:
- 워크스페이스 경계: 두 앱이 같은 값·같은 알고리즘을 써야 하면
packages/core(순수 로직)나packages/models(모델 카탈로그)에 올리고, 런타임 API(fs,electron,AudioContext, 워커, spawn)를 만지는 코드는 앱에 남긴다. 아래src/…경로는 모두apps/desktop/기준이다 (references/monorepo.md). - 프로세스 경계:
src/main(Node) /src/preload(contextBridge) /src/renderer(브라우저) /src/shared(순수 TS 타입·유틸, 런타임 의존 없음). 병합 알고리즘·포맷터 같은 순수 로직은src/shared또는src/main/pipeline에 두고pnpm test(vitest)로 단위 테스트한다. - IPC: 채널 이름과 payload 타입은
src/shared/ipc.ts에 단일 정의. 요청-응답은ipcMain.handle/ipcRenderer.invoke, 진행률 등 push는webContents.send. preload는window.api에 타입이 붙은 함수만 노출하고ipcRenderer를 직접 노출하지 않는다. - 외부 바이너리:
resources/bin/<platform>-<arch>/에 두고asarUnpack대상으로 유지. 실행 전 존재·실행권한 확인, stdout JSON 파싱 실패/비정상 종료는status='error'로 기록하고 사용자에게 안내한다. - 데이터: 화자 이름은
utterances에 쓰지 않고speakers(meeting_id, label) → display_name매핑으로 관리한다(한 번 바꾸면 전체 반영). 모든 시간 값은 초(sec, REAL), 생성 시각은 epoch ms. - 한국어 우선: 기본 언어
ko, UI 문구·문서·커밋 메시지는 한국어. 코드 식별자는 영어. - pnpm 주의: pnpm 10은 의존성의 install/postinstall 스크립트를 기본 차단한다. 네이티브 애드온·바이너리 다운로드 패키지(
electron,esbuild,electron-winstaller,better-sqlite3)는 워크스페이스 루트package.json의pnpm.onlyBuiltDependencies에 등록해야 한다 (앱package.json에 적으면 무시된다). 새 네이티브 의존성을 추가하면 이 목록도 갱신한다..npmrc의node-linker=hoisted는 electron-builder 패키징을 위한 설정이므로 지우지 않는다.package.jsonscripts 내부 호출은pnpm run <script>로 통일한다. - 범위 절제: plan.md에 없는 기능(클라우드 동기화, 실시간 스트리밍 STT, 계정 등)은 제안만 하고 구현하지 않는다.
5. 알려진 함정 (구현 전 확인)
전체 목록은 references/pitfalls.md. 자주 걸리는 것:
- Whisper는 무음에서 환각 텍스트를 만든다 → VAD 없이 추론 금지.
- 음량이 작은 녹음(원거리 마이크)에서는 Whisper가 수십 초 구간을 한두 단어로 뭉갠다 → STT 전에 RMS 정규화 필수.
- 임계값 군집(
cluster-threshold)은 긴 녹음에서 화자가 무한정 늘어나고 같은 사람도 여러 클러스터로 갈라진다 → 참석자 수를 받아num-clusters로 돌리는 것이 기본. 임계값 경로는 폴백이며 군소 화자 흡수로도 못 막는다. - Whisper 세그먼트 안에서 화자가 바뀔 수 있다 → 단어 단위 타임스탬프로 배정 후 재문장화.
- 장시간 녹음 PCM을 메모리에 전부 들고 있지 않는다 → 청크 단위 디스크 append.
- 저사양 CPU에서 STT+화자분리 병렬 실행은 오히려 느리다 →
os.cpus().length기준 분기. - 바이너리에 코어 수만큼 스레드를 주면 효율 코어까지 잡아 느려지고 팬만 돈다 → 성능 코어 수 기준으로, GPU가 일하는 whisper·llama는 그보다 더 낮게.
- 성능 코어 수로 돌려도 긴 회의의 화자 분리는 몇 분간 코어를 전부 쓴다 → 소음이 싫은 사용자는 '조용히 처리' 설정으로 속도를 내준다. GPU를 쓴다고 발열이 없는 게 아니다(CPU·GPU가 방열판 하나를 공유).
- macOS 마이크 권한:
NSMicrophoneUsageDescription(electron-builder.yml, 한국어 문구로 교체) +systemPreferences.askForMediaAccess('microphone'). - 동봉 바이너리도 macOS notarization 시 함께 서명해야 한다.
6. 작업 시작 시 절차
- 이 스킬과 필요한
references/*.md를 읽는다. 결정 사항과 어긋나는 요청이면 plan.md 근거를 들어 한 번 확인하고, 사용자가 재확인하면 그대로 진행한다. - 현재 Phase와 완료 기준을
references/roadmap.md에서 확인하고, 해당 Phase 항목만 구현한다. - 순수 로직은
pnpm test로 검증, 앱 동작은pnpm dev로 확인한 뒤 결과를 있는 그대로 보고한다. - 새로운 기술 결정(모델 변경, 라이브러리 추가, 규약 변경 등)이 생기면 코드를 쓰기 전에 이 SKILL.md의 결정 표나
해당
references/*.md를 먼저 갱신한다(0절). 문서 갱신 → 사용자 확인 → 코드 순서를 지킨다.