본문으로 건너뛰기

Voice Interface

Voice Interface는 Web HUD 와 데스크톱 앱에서 JARVIS 에게 말로 묻고 응답을 소리로 듣게 하는 VUI(Voice User Interface)다. 부르기, 듣기, 되묻기, 승인, 화면 밖으로 전하기까지 대화의 각 단계에 소리로 드나드는 입구를 붙여, 화면을 보지 않고 손을 쓰지 않는 상황에서도 묻고, 듣고, 승인까지 말로 끝낼 수 있게 하는 것을 지향한다.

Voice Interface말로 묻고 소리로 들으며, 승인까지 손을 쓰지 않고 말로 끝낸다웨이크워드Jarvis 로 깨움STT 와 TTS브라우저, 서버기호 지우기금액은 한글로영어 음성소리로만 영어Full-voice읽고 다시 듣기음성 승인애매하면 거부되묻기못 들었을 때처리 흐름듣기 시작부르거나 구체를누름받아 적기브라우저나whisper잘 알아들었나?예아니오보내지 않고되묻는다대화 요청인가?예승인 창승인 답으로해석한다응답 만들기대화로 보낸다기호 지우기기호 빼고 금액은한글로영어 번역speak_english소리로 읽기Full-voice 면다시 듣는다

Voice Interface가 주목한 점​

요리하다가 "Jarvis, 오늘 회의 몇 시야?" 라고 묻는 상황을 떠올려 보자. 기존의 Chatbot은 답을 화면에만 띄우므로 사용자는 손을 씻고 와서 읽어야 한다. 음성 읽기를 덧붙인 경우에도 답에 **, 표의 |, 70,000,000원 같은 글이 섞여 있으면 그대로 읽어 줘서 알아듣기 어렵고, 작업 도중 "명령 X 를 실행할까요" 같은 승인 질문이 오면 결국 화면 앞으로 돌아와야 한다. 텍스트 응답에 TTS를 붙이는 것만으로는 말로 하는 소통이 완성되지 않는다. Jarvis-Agent는 부르기, 듣기, 되묻기, 승인까지 대화의 모든 단계를 말로 이어 갈 수 있도록 하고, 사람에 가까운 JARVIS 목소리로 응답하도록 설계했다.

어떤 기능을 가지고 있나요?​

기호를 지우는 곳은 서버 한 곳​

  • 마크다운 기호를 읽지 않게 하는 정리는 화면이 아니라 서버의 speech_text 한 함수가 한다. 함수는 두 번 거쳐도 결과가 같게 만들었다.
  • 대안으로는 화면이 읽기 직전에 기호를 지우는 방식이 있었다.
  • 음성은 서버 합성(Supertonic, Piper)과 브라우저 합성 두 길로 나가고, 디스코드 음성 채널로도 나간다. 화면에서 지우면 서버 합성과 디스코드 음성이 빠진다.

멈춤은 "지금 재생" 과 "오는 중" 을 함께 끊는다​

  • 읽기 요청마다 id 를 붙이고 서버가 결과에 그대로 돌려주게 했다. Esc 를 누른 시점까지의 요청은 결과가 와도 버린다.
  • 대안으로는 Esc 를 눌렀을 때 재생 중인 소리만 끊는 방식이 있었다.
  • 그렇게 하면 합성을 기다리던 요청의 오디오가 몇 초 뒤 도착해 다시 말하기 시작한다.

승인은 "들을 때까지" 듣는다​

  • 승인 창이 열려 있는 동안 읽기가 끝날 때와 인식이 끝날 때마다 다시 듣는다(최대 20번). 읽는 동안에는 듣지 않는다.
  • 대안으로는 처음 만들 때처럼 질문을 읽은 뒤 한 번만 듣는 방식이 있었다.
  • 실제로는 인식이 몇 초 만에 말 없이 끝나거나, 다른 읽기가 끼어들어 "다 읽으면 듣기" 설정을 지워서, 사용자의 "승인해" 가 어디에도 닿지 않고 120초 뒤 거부됐다. 읽는 동안 듣지 않는 것은 질문 자체("허용, 항상, 거부")를 답으로 알아듣기 때문이다.

영어 합성은 조각으로 받아 시계로 잇는다​

  • Web HUD 는 소리를 0.32초 조각으로 받아 첫 조각부터 읽는다. 조각은 앞 조각이 끝나는 시각(Web Audio 시계)에 예약해 잇는다.
  • 대안으로는 문장을 통째로 합성하는 방식과, 조각이 끝나는 때(onended)에 다음 조각을 시작하는 방식이 있었다.
  • 통째로 합성하면 답이 길수록 첫 소리가 늦고, onended 에 다음을 시작하면 경계마다 틈이 생긴다. 근거는 첫 소리까지 실험이다.

Qwen 합성 서버는 Docker 밖의 Mac 에 둔다​

  • Ollama 와 같은 구조로 Qwen 합성 서버를 Mac 에서 따로 띄우고, 백엔드가 그 서버를 부른다.
  • 대안으로는 합성 모델을 다른 서비스처럼 Docker 안에서 돌리는 방식이 있었다.
  • Docker 는 Mac 위의 리눅스 VM 이라 Apple GPU 를 못 쓴다. Docker 안에서는 두 단어에 3분이 걸렸다(합성 속도). 대신 Apple silicon Mac 에서만 된다.

되묻는 말도 직전 목소리로​

  • whisper 가 잘 못 들었을 때 JARVIS 는 영어 TTS(JARVIS 목소리)로 되묻는다.
  • 대안으로는 다른 에이전트처럼 한국어 TTS 로 되묻는 방식이 있었다.
  • 직전 발화가 JARVIS 목소리였는데 되묻는 말만 다른 목소리로 나오지 않게 하려는 것이다.

세부 동작​

한 눈에 보기​

(a) 듣고 말하는 길마이크STTWeb Speech, faster-whisperuser_messageJARVIS스피커TTS브라우저, supertonic, 영어는 Piper, qwen영어로 옮기기speak_english읽을 글로기호 지우기, 금액은 한글응답, 승인 질문, 🔔(b) Full-voice 루프listeningprocessingthinkingspeaking전사user_message응답, 승인 질문다 읽음(c) 음성 승인 답은 애매하면 거부부정어거부, 아니, 하지 마, 취소거부1항상 키워드패턴 제안이 있을 때항상 허용2허용 키워드네, 응 단독 포함허용3그 밖사고 모델이 분류그 답, 애매하면 거부4주 경로흐름거부, 실패강조
Figure 1. (a) STT 와 TTS 는 브라우저가 기본이고 서버(faster-whisper, supertonic)로 바꿀 수 있다. 읽기 전에 서버가 마크다운 기호를 지우고 금액 숫자를 한글로 바꾼다. 영어 문장은 Piper 나 qwen 합성 서버로 따로 읽게 할 수 있고, speak_english 를 켜면 JARVIS 가 내는 한국어 글을 소리로만 영어로 옮겨 문장마다 읽는다. (b) 말로 시작한 대화는 듣기, 처리, 생각, 말하기를 돈다. (c) 음성 승인 답은 번호 순서로 해석하고, 애매하면 거부한다.

Web HUD 의 기본 상태는 아래와 같다. 말로 시작한 뒤의 상태 기계는 말로 이어 가는 대화 에 있다.

  1. 기다리는 동안 웨이크워드("Jarvis"), 구체 클릭, 데스크톱 전역 단축키로 듣기를 연다
  2. 브라우저(Web Speech API)나 서버(whisper)가 받아 적는다. 서버 인식은 잘 못 들었으면 되묻고 멈춘다
  3. 받아 적은 말을 user_message 로 보낸다. 승인 창이 열려 있으면 대화가 아니라 승인 답으로 쓴다
  4. 응답이 오면 voice_tts_request 로 읽기를 요청한다. 서버가 기호를 지우고, speak_english 면 영어로 옮긴다
  5. 서버 TTS 가 소리를 voice_tts_audio 로 돌려준다. data 가 비어 오면 브라우저가 직접 읽는다

Web HUD​

  • 기다리는 동안 "Jarvis" 라고 부르면 "At your service, sir." 로 답하고 바로 듣는다(웨이크워드)
  • 구체를 클릭하면 듣기를 시작하고, 듣는 중에 다시 누르면 취소한다. 읽는 중에 클릭하면 읽기만 멈춘다. 말로 시작한 요청은 응답을 읽어 주고, 대화 중에는 먼저 건네는 말도 읽는다. 스케줄 알림은 늘 읽고, 놓친 알림은 읽지 않는다
  • 서버 TTS 는 voice_tts_request 로 요청하고 voice_tts_audio 로 받는다. data 가 비어 오면 브라우저가 직접 읽는다
  • 읽기 전에 서버가 마크다운 기호를 지운다. **, #, 목록 기호, 표의 | 는 읽지 않고, 코드 블록과 URL 은 건너뛰고, 링크는 글자만 읽는다. 원, 만, 억 앞의 금액 숫자는 한글로 바꿔 70,000,000원 을 "칠천만원" 으로 읽는다(단위 없는 숫자는 그대로)
  • 읽는 중에 Esc 를 누르거나 구체를 클릭하면 멈춘다. 합성을 기다리는 중이어도 멈추고, 늦게 도착한 오디오는 버린다. 멈추면 대화도 끝나 다시 듣지 않는다
  • 포커스 모드 <작업>, 포커스 종료 는 /focus 명령으로 보낸다. 인식이 키워드를 틀리거나 앞을 잘라도 받는 규칙은 Focus Mode 에 있다
노트

서버가 voice_tts_request 에 응답하지 않으면 화면이 "음성 처리 중" 에 멈춘다. 그래서 서버는 빈 응답이라도 돌려준다.

말로 이어 가는 대화​

음성에는 모드 전환이 없다. 타이핑한 요청은 화면으로만 답한다. 구체 클릭, "Jarvis" 웨이크워드, 데스크톱 전역 단축키처럼 말로 시작한 요청은 대화를 연다. 대화 중에는 응답을 읽어 준 뒤 다시 듣고, 먼저 건네는 말(🔔)도 읽은 뒤 답을 듣는다. 대화 중이 아니면 🔔 는 서버가 speak 로 표시한 것만 읽는다.

대화는 세 경우에 끝난다. 들었는데 말이 없을 때(서버 녹음은 약 8초 동안 말소리가 없을 때, 브라우저 인식은 말 없이 끝났을 때), 입력창에 적어 보냈을 때, 읽는 중에 Esc 나 구체 클릭으로 멈췄을 때다. 듣기 사이에 마이크를 잡아 두지 않으므로 블루투스 헤드셋은 실제로 듣는 동안에만 통화 음질로 바뀐다.

대화 중이거나 말로 시작한 요청에서 승인이 필요하면 되묻고 듣는다. 음성으로 묻는 동안은 화면을 가리는 모달 대신 아래쪽에 작은 띠로 명령과 버튼을 보여 준다. 승인 창이 열려 있을 때 들은 말은 대화로 보내지 않고 승인 답으로 쓴다. 브라우저 인식은 몇 초 말이 없으면 끝나기 때문에, 답을 들을 때까지 다시 듣는다(최대 20번). 승인을 기다리는 동안에는 말 없이 끝난 듣기가 대화를 끝내지 않는다. 읽는 동안에는 듣지 않는다.

음성으로 받은 승인 답은 애매하면 거부 쪽으로 해석한다. 부정어가 있으면 거부하고, 분명한 긍정이면 허용한다. 그 밖의 말("그거 돌려")은 사고 모델에 물어 allow/always 일 때만 허용하고 나머지는 거부한다. gemma4:e2b 로 실측했을 때 승인 답 11문장 중 허용하면 안 되는 말을 허용한 경우는 0건이었다. 분류기 실험은 Tools and Approval 에 있다.

잘 못 알아들으면 다시 묻는다​

whisper 로 인식할 때 결과가 비었거나, 말소리가 없다는 확률(no_speech_prob)이 0.6 을 넘거나, 평균 로그 확률이 -1.0 보다 낮으면 잘 못 들은 것으로 본다. 이때는 받아 적은 말을 에이전트에 넘기지 않고 voice_stt_result 에 unclear: true 를 실어 보낸 뒤 되묻는 말을 읽는다.

에이전트되묻는 말목소리
JARVISI'm sorry, sir. I didn't catch that.영어 TTS(JARVIS 목소리)
그 밖잘 못 들었어요. 한 번 더 말씀해 주세요.한국어 TTS

판정 값은 voice/voice_service.py 의 NO_SPEECH_MAX, AVG_LOGPROB_MIN 이다. 백엔드 로그의 [Voice] stt 줄에 두 값과 판정이 남는다.

브라우저 인식이 없을 때​

JARVIS_VOICE__STT_PROVIDER=browser 인데 브라우저에 Web Speech API 가 없거나 인식이 실패하면, JARVIS_VOICE__STT_FALLBACK(기본 whisper)에 따라 HUD 가 직접 녹음하고 서버가 whisper 로 받아 적는다. 데스크톱 앱(Electron)은 Web Speech API 가 network 오류로 실패하므로 늘 이 길로 간다. whisper 모델은 처음 쓸 때 읽고, voice extra 가 있어야 한다.

화면 밖으로​

화면이 소리 내어 읽은 말은 spoken 알림으로 Slack, Discord, Web Push 에도 간다(JARVIS_NOTIFY__KINDS 에 spoken 이 있을 때). 화면이 한 답을 문장 조각으로 나눠 읽으면 첫 조각에서 원문 전체로 한 번만 보낸다. 녹음 문구는 화면이 재생한 뒤 voice_spoken 으로 알려 오면 그 문장으로 보낸다. 🔔, 뒤로 넘긴 작업의 답, 스케줄 알림은 서버가 이미 따로 보내므로 읽어도 다시 보내지 않는다.

디스코드 봇 토큰, 서버 id, 사용자 id(JARVIS_NOTIFY__DISCORD_BOT_TOKEN, DISCORD_GUILD_ID, DISCORD_USER_ID)가 모두 있으면, 사용자가 음성 채널에 있을 때 봇이 그 채널에 들어가 알림을 읽고 나간다(core/notify/discord_voice.py). 읽는 종류는 JARVIS_NOTIFY__DISCORD_VOICE_KINDS(기본 reminder, proactive)다. spoken 은 화면이 이미 읽은 말이라 글로만 보낸다.

  • 보낸 에이전트 이름, 제목, 본문 순으로 읽는다. 본문은 화면과 같은 speech_text 로 기호를 지운다
  • 300자를 넘으면 앞부분만 읽고 "나머지는 디스코드 글로 남겼어요." 를 붙인 뒤 전문을 글로도 보낸다
  • 한국어는 JARVIS_NOTIFY__DISCORD_TTS_PROVIDER(기본 supertonic)가, 영어 문장은 ENGLISH_TTS_PROVIDER 가 읽는다. speak_english 의 영어 옮기기는 하지 않는다
  • 사용자가 음성 채널에 없거나, 합성이나 재생이 실패하면 webhook 글로 보낸다. 알림은 한 번에 하나씩 읽는다
  • uv sync --extra discord 와 ffmpeg 가 필요하다. Docker 의 web 이미지에는 둘 다 들어 있다. discord.py 가 없으면 로그에 경고를 남기고 글로만 보낸다

STT 와 TTS 프로바이더​

음성 인식(STT)과 음성 합성(TTS)을 브라우저에서 할지 서버에서 할지는 설정으로 정한다.

browser(기본)서버
STTWeb Speech API 가 브라우저에서 인식whisper: 브라우저가 MediaRecorder 로 녹음해 voice_stt_submit 으로 보내고 faster-whisper 가 전사
TTSWeb Speech API 가 합성supertonic: ONNX 합성 결과를 WAV 로 돌려준다
영어 TTSTTS 와 같은 프로바이더piper: Piper 음성 모델 하나로 영어 문장만 합성한다. qwen: Mac 호스트의 Qwen3-TTS 합성 서버에 맡긴다

whisper 와 supertonic 은 uv sync --extra voice, piper 는 --extra piper 로 설치한다. Docker 의 web 이미지에는 둘 다 들어 있다.

JARVIS_VOICE__WHISPER_MODEL_SIZE(기본 base)는 whisper 모델 크기로, SUPERTONIC_MODEL(기본 supertonic-3)은 supertonic 모델 이름으로 넘어간다. whisper 언어는 한국어로 고정돼 있다. STT_LANG 은 접속 때 화면에 보내지만 브라우저 인식은 이 값을 읽지 않고 ko-KR 로 듣는다.

영어 문장은 Piper 음성으로​

JARVIS_VOICE__ENGLISH_TTS_PROVIDER=piper 를 두면 영어로만 된 문장은 Piper 음성 모델이 읽고, 나머지는 TTS_PROVIDER 가 읽는다. 한글, 가나, 한자가 한 글자라도 섞이면 영어로 보지 않는다. Piper 모델이 영어 전용이라 한국어 문장을 넣으면 발음이 무너지기 때문이다.

JARVIS_VOICE__ENGLISH_TTS_PROVIDER=piper
JARVIS_VOICE__PIPER_MODEL=~/.jarvis/voices/en-voice.onnx

Piper 음성 모델(.onnx, .onnx.json)을 받아 ~/.jarvis/voices/ 에 같은 이름으로 나란히 둔다. 모델은 레포에 넣지 않는다.

Docker 에서는 ~/.jarvis 가 jarvis_data 볼륨이다. 호스트에 받은 파일을 docker compose cp 로 넣고 백엔드를 다시 띄운다.

docker compose exec jarvis-backend mkdir -p /root/.jarvis/voices
docker compose cp en-voice.onnx jarvis-backend:/root/.jarvis/voices/
docker compose cp en-voice.onnx.json jarvis-backend:/root/.jarvis/voices/
docker compose restart jarvis-backend
경고

실존 인물의 목소리를 흉내 낸 모델은 쓰기 전에 그 모델의 라이선스와 용도 제한을 확인한다.

모델 파일이 없거나 piper 가 설치되지 않았으면 서버 음성이 통째로 꺼지고 브라우저가 모두 읽는다. 백엔드 로그에 [Voice] 서버 음성 비활성화 가 남는다.

Qwen3-TTS 로 JARVIS 목소리 복제​

JARVIS_VOICE__ENGLISH_TTS_PROVIDER=qwen 이면 영어 문장을 Qwen3-TTS 1.7B 가 JARVIS 의 녹음 한 클립(reference.wav, 4~12초)을 따라 합성한다. 음성 품질 비교는 복제 음성 품질 실험에 있다.

모델은 MLX 로 Apple GPU(Metal)에서 돈다. 합성 서버(services/qwen_tts)를 Mac 에서 따로 띄우고 백엔드가 host.docker.internal:8770 으로 부른다. 서버에 닿지 않거나 실패하면 PIPER_MODEL 로 읽어 소리가 끊기지 않는다(로그 [Voice] qwen 합성 서버에 닿지 않아 Piper 로 읽는다).

services/qwen_tts/setup.sh # 잠긴 버전(mlx-audio 0.6.2, mlx 0.32.4)으로 환경을 맞춘다. 다시 실행해도 같다
mkdir -p ~/.jarvis/voices/qwen # voice.json(모델, 버전, 기준 녹음 대본)과 reference.wav 를 둔다
services/qwen_tts/run.sh # 127.0.0.1:8770. GET /health, POST /tts {"text"} -> WAV(24kHz), POST /tts/stream -> 조각
services/qwen_tts/install-launchd.sh # ~/.jarvis/qwen-tts 로 복사해 로그인할 때 띄우고 죽으면 다시 띄운다(로그 ~/.jarvis/logs/qwen-tts.log)
  • 모델(bf16 4.2GB)은 서버가 뜰 때 한 번 읽고 첫 문장으로 데운다. 메모리를 그만큼 계속 쓴다
  • /tts/stream 은 조각마다 4바이트 길이(LE)와 PCM s16le 를 보내고 길이 0 으로 끝을 알린다. 끝 표시 없이 닫히면 백엔드는 중간에 끊긴 것으로 보고 그 문장의 나머지는 읽지 않는다(로그 [Voice] qwen 조각 합성이 중간에 끊겨). 이미 읽은 소리 뒤에 같은 문장을 Piper 로 처음부터 다시 읽으면 더 어색하다. 첫 조각 전에 닿지 않으면 그 문장을 Piper 로 통째로 읽는다
  • 요청은 한 번에 하나씩 합성한다(MLX 모델은 동시에 두 번 생성하지 못한다). 매 요청 같은 seed 라 같은 문장은 같은 소리다
  • 서버는 127.0.0.1 에만 연다. Docker Desktop 의 host.docker.internal 은 호스트의 루프백에 닿는다
  • 기준 녹음은 손대지 않는다. 잡음 제거를 걸면 오히려 나빠졌다(UTMOS 4.07 에서 3.90)
  • launchd 로 띄운 프로그램은 macOS 개인정보 보호 때문에 Desktop, Documents 아래 파일을 열지 못한다(Operation not permitted, 종료 코드 126). 그래서 등록 스크립트는 서버를 ~/.jarvis/qwen-tts 로 복사해 그 자리에서 띄운다. 서버를 고치면 등록 스크립트를 다시 실행한다

JARVIS 가 하는 말은 모두 JARVIS 목소리로​

JARVIS 의 답은 한국어라 그대로 두면 한국어 TTS(supertonic)가 읽는다. JARVIS_VOICE__SPEAK_ENGLISH=true 를 두면 JARVIS 가 소리 내는 한국어 글(답, 🔔, filler, 앞으로 생길 종류 모두)을 소리로만 짧은 영어 구어체로 옮겨 영어 TTS(JARVIS 목소리, piper 또는 qwen)로 읽는다. 화면에 쓰는 글은 한국어 그대로다.

  • 옮기기는 메인 모델에 한 번 묻는다. 두 문장, 45단어 안으로 줄이고 이름, 숫자, 날짜는 그대로 둔다
  • Web HUD 는 소리를 나오는 대로 0.32초 조각으로 받아(stream) 첫 조각부터 읽는다
  • 8초 안에 못 옮기거나 영어가 아닌 답이 오면 한국어 원문을 다른 목소리로 읽지 않고 "Sir, I've put it on your screen." 을 읽는다
  • KAREN 등 다른 에이전트의 말은 옮기지 않고 원문 그대로 읽는다
  • 영어 TTS(ENGLISH_TTS_PROVIDER)가 없으면 켜도 아무 일도 하지 않는다

JARVIS 음성 모델​

JARVIS 전용으로 학습한 모델 세 가지를 레포의 models/ 폴더에 두고 쓴다. 이 폴더는 .gitignore 에 있어 GitHub 에 올라가지 않는다. 모델이 비상업 라이선스이기 때문이다.

모델파일하는 일
TTSmodels/tts/voice.onnx, voice.onnx.json영어 문장을 JARVIS 목소리로 읽는다. Piper 음성이라 위의 PIPER_MODEL 로 지정한다. qwen 을 쓰면 서버에 닿지 않을 때만 읽는다
읽기 문구models/tts/phrases/phrases.json, wav 9개정해진 순간에 합성 대신 녹음한 문구를 읽는다
웨이크워드models/wakeword/jarvis.onnx, melspectrogram.onnx, embedding_model.onnx기다리는 동안 "Jarvis" 를 들으면 듣기를 연다

학습 결과 폴더에서 체크포인트(model.ckpt, 846MB)만 빼고 복사한다. rsync -a 라 다시 실행해도 같은 상태가 된다.

rsync -a --exclude model.ckpt --exclude archive ~/Desktop/jarvis-agent-models/ models/

Docker 는 ./models 를 /app/models 로 읽기 전용 마운트한다. .env 에 경로를 두고 백엔드를 다시 띄운다. 웨이크워드는 wake extra 가 필요하고 web 이미지에 들어 있다.

JARVIS_VOICE__ENGLISH_TTS_PROVIDER=piper
JARVIS_VOICE__PIPER_MODEL=/app/models/tts/voice.onnx
JARVIS_VOICE__PHRASES_DIR=/app/models/tts/phrases
JARVIS_VOICE__WAKE_MODEL_DIR=/app/models/wakeword
JARVIS_VOICE__WAKE_THRESHOLD=0.5

경로를 비우거나 파일이 없으면 그 기능만 꺼진다. 웨이크워드가 꺼지면 백엔드 로그에 [Voice] 웨이크워드 비활성화 와 빠진 파일이 남는다.

읽기 문구​

말로 이어 가는 대화 중이거나 음성으로 시작한 요청에서만 읽는다. 웨이크워드를 들었을 때의 문구는 늘 읽는다.

순간문구
웨이크워드를 들음At your service, sir.
web_search 도구 시작Searching Reference.
remember_task_pattern 도구 시작Checking the Pattern.
💬 filler(오래 걸리는 요청을 뒤로 넘김)Processing Long Task. filler 문장은 화면에만 쓰고 읽지 않는다
/focus 로 Focus 를 켜고 끔Activate Focus mode. / Deactivate Focus mode.
Focus 분석 갱신 시작Target. Analysis.
구체 옆 화살표나 밀기로 KAREN, JARVIS 전환Change to KAREN. / Change to JARVIS.

접속할 때 서버가 되살리는 Focus 는 문구를 읽지 않는다. 사용자가 보낸 /focus 로 바뀐 경우만 읽는다. wav 는 GET /api/voice/phrases/{name} 으로 처음 읽을 때 한 번 받는다.

웨이크워드​

기다리는 동안 브라우저가 마이크 소리를 16kHz PCM 으로 WebSocket 바이너리 프레임에 실어 보낸다. 서버는 연결마다 openWakeWord 감지기를 하나 두고 80ms 마다 점수를 매긴다. 점수가 임계치를 넘으면 voice_wake 를 보내고, 화면은 "At your service, sir." 를 읽은 뒤 바로 듣는다. 듣기, 읽기, 응답, 승인 중에는 보내지 않는다. 깨워 보낸 요청은 말로 시작한 요청이라 대화가 열리고, 응답을 다 읽은 뒤 다시 듣는다.

이름 끝 무렵 여러 프레임이 연달아 임계치를 넘으므로, 첫 프레임에서 한 번 깨고 그 뒤 1초는 무시한다.

노트

openwakeword 는 리눅스에서 tflite-runtime 을 요구하지만 arm64 휠이 없어 Apple Silicon 의 Docker 에서 설치가 깨진다. onnx 로만 돌리므로 pyproject.toml 의 override-dependencies 로 뺐다.

경고

TTS 와 읽기 문구는 영화 속 인물의 목소리를 흉내 낸 모델이라 개인, 비상업 용도로만 쓴다. 웨이크워드의 특징 모델(openWakeWord)도 CC BY-NC-SA 4.0 이다.

한계​

  • Qwen 합성 서버는 Apple silicon Mac 에서만 된다. 리눅스 서버로 옮기면 이 방식은 쓸 수 없다
  • 재시작 직후 첫 번역은 모델 연결을 여느라 8초를 넘길 수 있다. 그때는 "Sir, I've put it on your screen." 이 나온다
  • 웨이크워드가 켜져 있으면 기다리는 동안에도 마이크가 계속 열려 있어 브라우저의 녹음 표시가 꺼지지 않는다. 닫으려면 JARVIS_VOICE__WAKE_MODEL_DIR 를 비운다
  • 웨이크워드는 영어 발음 "Jarvis" 만 알아듣는다. 한국어로 "자비스" 라고 부르면 깨지 않는다
  • 웨이크워드 모델은 합성 음성으로만 학습했다. "Jars", "Garbage" 처럼 비슷한 소리에 깨어날 수 있다
  • 브라우저 STT 는 Chrome 계열에서 동작한다. 지원하지 않는 브라우저에서는 SYS 에러가 한 줄 뜬다
  • 음성으로 Focus 를 켜는 말은 인식이 키워드를 잘라 버리면 되살리지 못한다(Focus 키워드 해석)

구현 위치​

  • 구현: services/qwen_tts/(Mac 의 Qwen3-TTS 합성 서버와 launchd 등록), ui/web/ws_tasks.py 의 run_tts(옮기기, 문장 나누기, 조각 중계), voice/(서버 프로바이더, voice_service.py 의 영어 판정, english.py 의 영어로 옮기기, speech_text.py 의 기호 지우기, wake.py 의 웨이크워드 감지, phrases.py 의 읽기 문구), ui/web/router/voice_router.py(문구 wav), ui/web/static/voice.js(브라우저 상태 기계, 조각 예약 재생), app/wake.js 와 wake-worklet.js(마이크 캡처), app/phrases.js(문구 받기)

왼쪽이 사용자와 화면, 가운데가 코드 모듈, 오른쪽이 저장소다. 화살표는 호출이나 데이터의 방향이다.

  • 핵심 모듈: ui/web/static/voice.js, voice/
  • 저장: 없음. 모델은 models/(git 밖)에서 읽기만 한다

Performance Experiments​

음성 합성 속도, 첫 소리까지 걸리는 시간, 음성 명령의 키워드 해석, 복제 음성의 품질을 차례로 쟀다.

Piper 영어 음성 합성 속도​

  • 질문: CPU 만으로 Piper 영어 합성이 대화에 쓸 만큼 빠른가
  • 설정: 백엔드 컨테이너(aarch64, 14코어, CPU 만)에서 Piper 모델(high 품질, noise_scale 0.4)로 길이가 다른 영어 문장 넷을 합성했다
  • 결과:
Piper(high) 합성, 컨테이너 CPU 14코어: 합성 시간은 음성 길이의 약 1/5음성 길이합성 시간13자 문장0.65s0.14s58자 문장3.02s0.58s89자 문장4.71s0.98s110자 문장6.12s1.32s01234567시간 (초)
Figure 2. 합성 시간은 음성 길이의 0.19~0.22배다. 110자 문장(6.1초 분량)도 1.3초에 합성된다.
  • 해석: 문장 하나를 통째로 합성한 뒤 보내므로, 듣기 시작까지는 합성 시간만큼 기다린다. 긴 답이면 1초 남짓이다. qwen 을 쓸 때 서버에 닿지 않으면 이 Piper 음성이 대신 읽는다
  • 한계: 문장 넷, CPU 한 환경의 측정이다. 소리 품질은 재지 않았다
  • 재현: scripts/experiments/piper_speed.py 를 컨테이너에서 실행한다

Qwen3-TTS 합성 속도: Mac GPU 와 Docker CPU​

  • 질문: Qwen3-TTS 를 Docker 안에서 돌릴 수 있나, 아니면 Mac GPU 가 필요한가
  • 설정: 같은 모델(1.7B bf16), 같은 기준 녹음, 같은 seed 로 영어 문장 다섯을 합성했다(첫 호출은 빼고 잰다). M4 Pro, Docker 는 14코어 23GB
  • 결과:
문장(소리 길이)Mac GPUDocker CPU
모델 로드3.0초18.8초
Yes, sir? (0.6초)0.48초185.7초
Sir, I've put it on your screen. (2.3초)1.22초재지 않음
Sir, your meeting with the team starts in ten minutes. (3.4초)1.69초재지 않음
16단어 문장(5.7초)2.65초재지 않음
  • 해석: Mac GPU 는 합성 시간이 소리 길이의 0.5배 안팎이라 대화에 쓸 수 있다. Piper(0.2배)보다는 2배쯤 느리다. Docker 안에서는 mlx 의 CPU 빌드가 설치되고 돌지만 두 단어에 3분이 걸렸고, 14코어 중 하나만 썼다. 그래서 합성 서버를 Docker 밖에 두었다
  • 한계: Docker CPU 는 가장 짧은 문장 하나만 쟀다. 문장 다섯, 한 기기의 측정이다
  • 재현: services/qwen_tts/bench.py <voice 디렉터리> <출력 디렉터리>

JARVIS 답의 첫 소리까지: 한 번에, 문장마다, 조각마다​

  • 질문: speak_english 에서 영어로 옮긴 답을 언제부터 들을 수 있나, 합성 단위를 바꾸면 얼마나 줄어드나
  • 설정: JARVIS 한국어 답 세 개(짧은 확인, 회의와 날씨, 주택 공고 세 건)를 Web HUD 와 같은 voice_tts_request 로 읽혔다. 요청을 보내고 소리가 처음 올 때까지 잰다. 번역은 gpt-4o-mini, 합성은 Qwen3-TTS 1.7B(Mac GPU). 한 번에 합성, 문장마다 합성, 조각마다 받기(지금) 셋을 비교했다
  • 결과:
답번역한 번에 합성문장마다 합성조각마다(지금)
짧은 확인(6단어)0.9~1.9초2.6초2.0초1.2초
회의와 날씨(28단어)1.0~1.9초5.0~5.9초3.1~3.9초1.8~2.2초
주택 공고 세 건(33~40단어)1.3~1.8초7.0~7.8초3.0~3.3초1.9~2.1초
  • 해석: 번역은 1~2초로 일정하고, 합성은 단어당 0.11초 안팎이라 한 번에 합성하면 길수록 늦다. 조각마다 받으면 첫 소리가 번역 끝에서 0.3초 뒤로, 답 길이와 상관없다. 재시작 직후 첫 번역은 2.8초까지 걸렸다. 지금 Web HUD 는 조각마다 받는다
  • 이어 붙이기: 조각은 소리 길이의 절반쯤 걸려 만들어진다(12.4초 소리를 5.7초에). 도착 시각으로 재생 시각표를 짜 보면 앞 조각이 끝나기 전에 다음 조각이 늘 와 있었다(빈틈 0ms, 문장이 바뀌는 곳 포함). 이어 붙인 경계의 표본 차이(최대 0.14)는 조각 안 표본 차이의 99.9 백분위(0.21~0.32)보다 작아 경계에서 튀는 소리는 없다
  • 한계: 경계 확인은 표본 값으로 본 것이고, 귀로 들어 본 확인은 아니다. 화면은 앞 조각이 끝난 뒤에 조각이 오면 콘솔에 [voice] underrun 을 남긴다
  • 재현: 백엔드 로그 [Voice] tts translate=... first_chunk=... 에 같은 값이 남는다

실제 전사 기록에서 Focus 키워드 해석​

  • 질문: 음성으로 Focus 를 켜고 끄는 말이 인식 오류에도 명령으로 잡히나
  • 설정: Chrome 음성 인식(ko-KR)으로 Focus 를 켜고 끄려 한 실제 시도 다섯 번의 전사문을, 키워드 해석을 고치기 전과 후로 다시 돌렸다
  • 결과:
의도실제 전사문고치기 전고친 뒤
시작버스 모드대화로 감/focus(현황 보기)
시작다음 주 회의 아젠다 만들어 줘대화로 감대화로 감
시작Please make the meeting timer for next week대화로 감대화로 감
종료동요대화로 감/focus stop
종료종료대화로 감/focus stop
  • 해석: 종료는 두 번 모두 잡게 됐다. Focus 중일 때만 "종료" 한마디를 받으므로, Focus 가 아닐 때의 같은 말은 대화로 간다. 시작은 여전히 어렵다. 인식이 키워드를 통째로 잘라 버리면(두 번째) 문장에서 되살릴 정보가 없고, 영어로 인식된 경우(세 번째)도 마찬가지다. 그래서 키워드를 문장 끝에 두는 방법("…, 포커스 모드로")을 안내한다
  • 한계: 다섯 번은 작은 표본이다. 고친 규칙이 잘못 켜는 경우("버스 시간 알려줘", "종료 시간 알려줘")는 따로 단위 검사로 확인했다
  • 재현: 규칙은 ui/web/static/app/focus.js 에 있다

Qwen3-TTS 복제 음성 품질​

  • 질문: JARVIS 목소리를 어떤 방식으로 만들어야 사람 목소리에 가까운가
  • 설정: 같은 48문장을 Piper 파인튜닝 음성과 Qwen3-TTS 복제(기준 녹음 한 클립)로 합성해 UTMOS, WER, 화자 유사도를 비교했다
  • 결과: Piper 파인튜닝 음성은 기계음이 심했다(UTMOS 2.6~2.9, 사람 녹음 4.1). Qwen 복제는 UTMOS 4.07, WER 0.017, 화자 유사도 0.965 다. 기준 녹음에 잡음 제거를 걸면 UTMOS 4.07 에서 3.90 으로 나빠졌다
  • 해석: 목소리는 그대로이고 소리가 사람에 가깝다. 그래서 ENGLISH_TTS_PROVIDER=qwen 을 두고, Piper 는 서버에 닿지 않을 때만 읽는다. 기준 녹음은 손대지 않는다
  • 한계: 자동 지표이고, 이 레포 밖에서 잰 값이다
  • 재현: llmclick 레시피 tts_qwen 측정