Skip to content

Repository files navigation

local-stt

오른쪽 Option 키로 음성을 로컬에서 텍스트로 변환하는 LocalSTT

오른쪽 Option 키를 누른 채로 말하고 떼면, Qwen3-ASR이 이 맥 안에서 받아쓴 뒤 지금 커서가 있는 곳에 그대로 붙여넣습니다. Slack이든 터미널이든 IDE든 상관없습니다. (다른 키가 편하면 메뉴의 녹음 키에서 바꿀 수 있습니다.)

모든 발화는 ~/Library/Application Support/LocalSTT/recordings/YYYYmmdd-HHMMSS.wav와 같은 이름의 .txt로 남습니다.


왜 이렇게 만들었나

받아쓰기 앱은 이미 많습니다. 이 앱은 한국어와 영어를 섞어 쓰는 개발자의 말을 받아쓰는 것 하나만 보고 만들어졌고, 아래 선택들은 전부 거기서 나왔습니다.

왜 로컬인가

받아쓰는 내용이 대체로 회의 내용, 사내 용어, 코드에 대한 설명입니다. 이런 걸 클라우드 STT API로 보내려면 매번 "이거 보내도 되나"를 판단해야 하는데, 말하기 전에 그 판단을 하게 되는 순간 도구를 안 쓰게 됩니다. 그래서 네트워크로 나가는 오디오가 0바이트인 쪽을 택했습니다.

부수적으로 따라오는 것들:

  • 오프라인에서 그대로 동작합니다. 비행기, 지하철, 사내망 어디든.
  • 요금이 없으니 길이나 횟수를 아낄 이유가 없습니다.
  • 네트워크 왕복이 없으니 지연이 모델 추론 시간 그 자체입니다.

대가는 첫 실행 때 받는 4.7 GB의 가중치와, Apple Silicon이 필요하다는 점입니다.

왜 Qwen3-ASR인가

한국어 개발자의 실제 말투는 이렇습니다.

"이 endpointlatency가 튀어서 rollback 먼저 하고 hotfix 올릴게요"

여기서 필요한 건 "한국어 인식률"이나 "영어 인식률"이 아니라, 한 문장 안에서 두 언어가 섞였을 때 영어를 영어 철자로 적어주는 능력입니다. 음차되어 "이 엔드포인트의 레이턴시가"로 나오면 붙여넣은 뒤 손으로 고쳐야 하고, 그럴 거면 그냥 타이핑하는 게 빠릅니다.

Qwen3-ASR을 고른 실질적인 이유는 세 가지입니다.

  1. 한/영 혼용 발화에 강합니다. 다국어로 학습된 모델이라 코드 스위칭이 잦은 문장에서 영어 토큰을 알파벳으로 유지하는 경향이 뚜렷합니다.
  2. context 인자가 있습니다. 발화마다 어휘 힌트를 문자열로 넘길 수 있고, 이게 아래 vocab.txt의 근거입니다. 이 기능 하나가 사실상 선택을 결정했습니다.
  3. MLX 구현체가 있습니다. mlx-qwen3-asr로 Apple Silicon의 Metal에서 바로 돌아갑니다. 별도 변환·양자화 작업 없이 uv run 한 줄로 끝나고, 이게 앱을 자체 완결형으로 만들 수 있게 해줬습니다.

왜 0.6B가 아니라 1.7B인가

Qwen/Qwen3-ASR-0.6B약 3배 빠릅니다. 대신 한/영이 섞인 문장에서 품질이 눈에 띄게 떨어집니다 — 정확히 이 앱이 존재하는 이유인 그 지점에서요.

누르고 말하는 방식이라 어차피 말이 끝난 뒤에 추론이 시작되고, 한두 문장 길이에서는 1.7B도 붙여넣기까지 체감상 잠깐입니다. 그 잠깐을 줄이는 대가로 손으로 고칠 단어가 생기면 남는 장사가 아닙니다.

빠른 쪽이 필요하면 asr_server.pyMODEL 한 줄만 바꾸면 됩니다.

MODEL = "Qwen/Qwen3-ASR-1.7B"  # 0.6B는 ~3배 빠르지만 ko/en 혼용이 눈에 띄게 나쁨

vocab.txt인가

모델이 아무리 좋아도 처음 보는 고유명사는 못 맞힙니다. 사내 서비스 이름, 팀에서만 쓰는 축약어, 특정 라이브러리 이름 같은 것들이요.

vocab.txt는 한 줄에 한 단어씩 적어두면 매 발화마다 읽혀서 모델의 context로 들어갑니다. "deployment"가 "디플로이먼트"로 나오는 걸 막는 게 정확히 이 파일입니다.

# 한 줄에 하나. #으로 시작하면 주석.
deployment
onboarding
pull request

재시작이 필요 없습니다. 저장하면 다음 발화부터 바로 반영됩니다. 받아쓰기가 자꾸 틀리는 단어가 보이면 그때그때 한 줄 추가하는 용도로 만들었습니다. 메뉴의 Edit vocab으로 열거나 ~/Library/Application Support/LocalSTT/vocab.txt를 직접 고치면 됩니다.

왜 Swift 앱 + python 사이드카인가

전부 python으로 짤 수도 있었고, 실제로 그 버전이 stt.py로 남아 있습니다. 문제는 macOS 권한 대화상자에 뜨는 이름이었습니다.

python이 마이크와 키보드를 만지면 시스템 설정의 접근성 목록에 뜨는 게 Python 또는 uv입니다. 뭔지 모를 항목에 키보드 입력 권한을 주라는 요구가 되고, 심지어 python을 다시 설치하면 권한이 날아갑니다.

그래서 마이크·단축키·붙여넣기·UI는 네이티브 Swift가 가지고, python은 오디오 파일을 받아 텍스트를 돌려주는 일만 합니다. 권한 대화상자에는 LocalSTT라고 뜹니다.

왜 uv를 앱 안에 넣나

"python 3.12 설치하고 pip install 하세요"로 시작하는 도구는 결국 저만 씁니다.

uv는 의존성 없는 단일 실행 파일이라(45 MB) 앱 번들 안에 그냥 넣을 수 있고, 자기가 쓸 인터프리터까지 직접 받아옵니다. 그래서 설치 요구사항이 없습니다 — python도, uv도, homebrew도 필요 없습니다. DMG를 열고 드래그하면 끝입니다.

asr_server.py.lock이 함께 실려 있고 사이드카는 --locked로 돌기 때문에, 사용자가 받는 패키지 버전은 이 릴리스가 테스트된 그 버전 그대로입니다.


설치

LocalSTT-<버전>.dmg를 열고 LocalSTT를 응용 프로그램 폴더로 드래그하세요. DMG나 다운로드 폴더에서 바로 실행하지 말고 실제로 옮겨야 합니다. macOS는 격리된 앱을 매번 다른 임시 경로에서 실행하는데(App Translocation), 경로가 계속 바뀌니 접근성 권한이 유지되지 않습니다. 앱이 이 상황을 감지해서 옮겨달라고 안내합니다.

공증(notarize)된 앱이라 첫 실행 때 "인터넷에서 다운로드한 앱인데 여시겠습니까" 확인이 한 번 뜹니다. 차단이 아니라 확인입니다. Apple Silicon + macOS 13 이상 전용입니다 (ASR이 Metal 위에서 MLX로 돕니다).

첫 실행은 시간이 좀 걸리고, 진행 상황이 메뉴 바에 표시됩니다. uv가 전용 python 3.12와 ASR 패키지(약 230 MB)를 받고, 그다음 모델(약 4.7 GB)을 받습니다. 이후 실행부터는 네트워크가 필요 없습니다. 전부 ~/Library/Application Support/LocalSTT 아래에 들어갑니다.

macOS가 요구하는 권한 두 개를 허용해 주세요. 마이크는 녹음에, **손쉬운 사용(접근성)**은 전역 단축키와 Cmd+V에 필요합니다. 앱이 접근성 권한을 지켜보다가 허용되면 재시작을 제안하는데, 재시작해야 단축키가 실제로 붙습니다. 메뉴 바에 🔒가 보이면 아직 권한이 없다는 뜻이고, 메뉴의 권한 설정 열기를 누르면 해당 설정 화면으로 바로 갑니다.

로그인할 때 자동 실행하려면 시스템 설정 → 일반 → 로그인 항목에 LocalSTT를 추가하세요.

사용법

녹음 중에는 화면 아래에 🔴 녹음 중 알약이 뜹니다 (변환 중에는 💬 변환 중…). 메뉴 바 아이콘이 상태를 그대로 보여줍니다.

아이콘 상태
⚙️ 첫 실행 설치 중
⬇️ 모델 다운로드 중
모델 로딩 중
🎙 대기
🔴 녹음 중
💬 변환 중
🔒 접근성 권한 없음
⚠️ 사이드카 종료됨 (로그 보기)
설치 대기 중인 업데이트 있음

메뉴: Open recordings, Edit vocab(내장 편집기, 닫을 때 저장), 녹음 키, 권한 설정 열기, 로그 보기, 버전 …, 업데이트 확인…, 자동으로 업데이트 확인, Quit.

녹음 키 바꾸기

메뉴의 녹음 키에서 고를 수 있습니다 — 오른쪽 Option(기본), 오른쪽 Command, 오른쪽 Control, 오른쪽 Shift, 왼쪽 Option, Fn(🌐). 고르는 즉시 적용되고 다음 실행에도 남습니다.

후보가 전부 수식 키인 건 이유가 있습니다. 전역 이벤트 모니터는 키를 보기만 할 뿐 가로채지 못해서, 보통 키를 녹음 키로 쓰면 그 키가 지금 타이핑 중인 창에 그대로 찍힙니다. 왼쪽 수식 키 중에는 Option만 넣었습니다. 왼쪽 Shift·Command·Control은 평소 단축키와 대문자 입력에 쓰이니 녹음이 수시로 켜집니다.

0.3초보다 짧은 녹음은 그냥 버립니다 (실수로 눌렀을 때). 접근성 권한이 없으면 붙여넣기 대신 클립보드에 남겨둡니다 — 손으로 Cmd+V 하면 됩니다.

업데이트

GitHub 릴리스에서 Sparkle로 자체 업데이트합니다. 백그라운드에서 하루 한 번 확인하고, *업데이트 확인…*으로 직접 확인할 수도 있습니다. 자동으로 업데이트 확인을 끄면 백그라운드 확인이 멈춥니다.

백그라운드 확인이 뭔가 찾아도 끼어들지 않습니다. 이 앱은 남의 창에 타이핑하려고 존재하는데, 말하는 도중에 포커스를 뺏으면 곤란하니까요. 대신 메뉴 바 아이콘 옆에 **•**이 붙습니다. 편할 때 메뉴를 열어 *업데이트 확인…*을 누르면 평소의 설치·재시작 안내가 뜹니다. 직접 시작한 확인은 창을 바로 앞으로 가져옵니다.

업데이트는 앱 번들만 교체합니다. 가중치, python 런타임, 녹음 파일, vocab.txt는 Application Support에 있어서 그대로 남습니다 — 그래서 업데이트는 5 GB가 아니라 50 MB 정도입니다. 번들 경로와 서명 주체가 그대로라 마이크·접근성 권한도 유지됩니다.

모든 다운로드는 Gatekeeper의 공증 확인에 더해, 프로젝트의 EdDSA 키 서명을 통과해야만 Sparkle이 설치합니다.

삭제

LocalSTT를 휴지통에 넣고 ./clean.sh를 실행하세요 (또는 ~/Library/Application Support/LocalSTT~/Library/Logs/LocalSTT를 직접 삭제). ./clean.sh models는 가중치만 지우고 런타임은 남깁니다.

빌드

./make-app.sh              # LocalSTT.app, ad-hoc 서명, 로컬 테스트용
VERSION=0.0.2 ./release.sh # 공증된 DMG + zip + appcast, GitHub 릴리스로 발행

release.sh에는 키체인의 Developer ID Application 인증서와 App Store Connect API 키가 필요합니다. ~/.appstoreconnect/config.jsonkey_id/issuer_id/team_id를 넣고, 짝이 되는 .p8~/.appstoreconnect/private_keys/에 둡니다. 앱을 공증·스테이플하고, 그다음 DMG를 처리한 뒤 spctl/stapler로 확인합니다.

이어서 업데이트 zip을 Sparkle 키로 서명하고 appcast.xml을 쓴 다음, 셋을 모두 첨부해 gh release create v<version>을 실행합니다. PUBLISH=0이면 업로드 직전에 멈추고, NOTES=…로 릴리스 노트를 덮어쓸 수 있습니다 (기본값은 마지막 태그 이후의 커밋 제목들).

모든 릴리스에는 반드시 appcast.xml이 들어가야 합니다. 앱에 박힌 피드 URL이 항상 최신 릴리스를 가리키기 때문에, appcast 없는 릴리스가 하나 나가면 모든 사용자의 업데이트가 멈춥니다.

업데이트 서명은 로그인 키체인에서 개인 키를 읽고, 처음엔 허용 프롬프트가 뜹니다 — 비대화형 셸에서는 불가능합니다. 키는 지금 백업하세요. 잃어버리면 이미 배포된 앱을 영원히 업데이트할 수 없습니다.

$(find .build/artifacts -name generate_keys -path '*Sparkle*') -x sparkle-private-key.txt

이 파일을 비밀번호 관리자에 넣고 삭제하세요 — 이걸 가진 사람은 설치된 모든 사본에 업데이트를 밀어넣을 수 있습니다. .gitignore가 파일명을 막아두긴 했지만 그건 안전장치이지 계획이 아닙니다. 공개 키 쪽은 make-app.shED_PUBLIC_KEY이고, 공개되라고 있는 값입니다.

동작 방식

네이티브 Swift 껍데기(마이크·단축키·UI)와 python 사이드카(asr_server.py)가 JSON 라인 파이프로 통신합니다. 마이크와 키보드를 만지는 쪽이 Swift 프로세스라 macOS 권한 프롬프트에 LocalSTT라고 뜹니다 (uv나 python이 아니라).

uv(45 MB, 시스템 프레임워크 외 의존성 없음)는 번들 안 Contents/MacOS/uv에 실려서 자기 인터프리터를 직접 관리합니다. 그래서 앱은 python을 품지 않고 사용자도 설치하지 않습니다. asr_server.py.lock이 함께 실리고 사이드카는 --locked로 돌기 때문에, 사용자는 릴리스가 테스트된 정확한 버전을 받습니다. requires-python은 3.12로 고정해서, MLX 휠이 없는 미래 인터프리터가 잡히는 일이 없게 했습니다.

/Applications에 들어간 번들은 읽기 전용이라 앱 옆에는 아무것도 쓰지 않습니다. 가중치·런타임·녹음·vocab은 Application Support로, 로그는 ~/Library/Logs/LocalSTT/stt.log로 갑니다. Hardened Runtime이 켜져 있고(공증 요구사항), 엔타이틀먼트는 com.apple.security.device.audio-input 하나뿐입니다 — uv와 python은 별도 프로세스로 돌기 때문입니다.

번들에 들어가는 프레임워크는 Sparkle 하나입니다. SwiftPM이 링크는 하지만 임베드는 안 해서, make-app.shSparkle.frameworkContents/Frameworks로 복사하고 중첩된 헬퍼들(Autoupdate, Updater.app, XPC 서비스 둘)을 안쪽부터 다시 서명합니다. Sparkle은 실행 중인 앱과 팀이 다른 업데이트를 설치하지 않기 때문입니다. 여기서 걸리는 게 하나 있는데, 라이브러리 검증은 Team ID를 비교하고 ad-hoc 빌드에는 팀이 없어서 그냥 ./make-app.sh 빌드는 자기 프레임워크를 로드하다 dyld에서 죽습니다. 그래서 ad-hoc 빌드에만 com.apple.security.cs.disable-library-validation을 즉석에서 붙이고, 실제 배포본은 검증을 유지합니다.

./stt.py는 순수 python 버전으로, 터미널에서 단독 실행하면 지금도 동작합니다. ./test_stt.py는 vocab 파싱과 WAV 쓰기를 확인합니다 (asr_server.py가 같은 포맷을 씁니다).

About

Hold-to-talk local STT for macOS — Qwen3-ASR on MLX, pastes at cursor

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages