시리즈: AgentZero Lite — Part 8. 직전 글(Part 7 — Step 2 Landed: Akka.NET Streams Now Carry the Whole Voice Path)은 음성 서브시스템에 스트리밍 substrate 를 깔았다. 이번 글은 그 위에 측정 도구 를 얹는다 — 커밋 사이에 좋아지는지 나빠지는지 알 수 있도록.
TL;DR
- 5-case
xUnit스위트가 TTS (Windows SAPI 기본 음성) → WAV → resample → STT (Whisper.netmedium, CPU) 라운드트립을 돌리고 transcript 를 입력과 비교한다.
- Pure-model, 파일 기반, Akka 없음, 스트림 없음. 결정적(deterministic) 베이스라인이고, 스트리밍 변종은 이 베이스라인 대비 delta 로 측정 가능해진다.
- 비교 단계에서 case + 구두점 + 공백을 접어 동치 비교하기 때문에 토크나이저의 화장(예:
"hello"→"Hello.") 이 content drift 로 잡히지 않는다.
- 5개 중 4개 통과. 떨어진 한 개가 흥미로운 케이스 — Whisper 가 사용자 입력의 typo
모래(sand) 를 문맥상 맞는모레(day after tomorrow) 로 자동 교정. Edit distance 1, similarity 96.8 %. 테스트는 그 drift 를 정직하게 보고했고, 글에선 그래야 한다 고 주장한다.
- 다음 사람이 다시 도출하지 않도록 적어둘 만한 6개 교훈. 가운데 4개는 기법, 양 끝 2개는 철학.
구현:
Project/AgentTest/Voice/TtsSttRoundTripTests.cs, commit b1a0ec8.왜 지금 이게 필요했나
Part 7 → Part 8 갭은 전형적인 리팩터 후 계측(refactor-then-instrument) 흐름이다.
Part 7 끝 시점에 음성 파이프라인은 streaming substrate, barge-in detector, sentence chunker, opt-in feature flag 까지 갖췄다. 갖추지 못한 것은 "새 버전이 더 나은가?" 에 답하는 방법이었다 — 모든 품질 체크가 사람이 스피커를 듣고 transcript 를 째려보는 식이었다.
한 번의 변화면 그것도 OK. 작은 튜닝 패스가 연속될 때(SAPI 속도 15 % 늦추기, WAV 에 1초 룸 노이즈 패딩, resampler 교체, 모델 swap)는 잘못된 루프다.
안녕하세요 가 그대로 나왔는지, 아니면 Thank you for watching, please subscribe and like 로 바뀌었는지 기억 에 의존해 토론하고 있었다. 측정할 시간.들어가면서 잡은 제약:
- 마이크 없음, 스피커 없음. 음향 경로는 자체 문제(Windows mic enhancements, room noise, 하드웨어 편차)를 가진다. raw 모델 쌍을 검증하려면 파일 기반 라운드트립 — WAV 로 합성, WAV 를 STT 에 넘기기 — 가 답.
- Actor 없음, stream 없음. 그건 운용 관심사. 여기서 묻는 건 "중간에 다른 움직이는 부품 없이, TTS 와 STT 가 문자열에 무슨 짓을 하는가?"
- 실제 provider. 어느 쪽이든 mock 하면 의미 없음 — 알고 싶은 건 실제 배포 파이프라인의 실제 hallucination rate 이지 mock 의 rate 가 아님.
이게 다음 토폴로지로 떨어졌다:
flowchart LR A["입력 문자열"] --> B["WindowsTts.SynthesizeAsync"] B --> C["WAV 바이트"] C --> D["File.WriteAllBytes<br/>*-tts.wav"] C --> E["WavToPcm.To16kMono<br/>(NAudio resample)"] E --> F["PCM 16k mono"] F --> G["File.WriteAllBytes<br/>*-stt-input-16k.wav"] F --> H["WhisperLocalStt.TranscribeAsync<br/>(medium, CPU)"] H --> I["transcript 문자열"] A --> J["Normalize<br/>(case + 구두점 + 공백)"] I --> J J --> K{"== ?"} K --> L["VERDICT + diff + 타이밍"] classDef io fill:#1f2937,stroke:#0ea5e9,color:#e5e7eb classDef pure fill:#0b3d2e,stroke:#10b981,color:#d1fae5 class B,H,E pure class D,G io
5개 케이스 — production UI 의 quick-phrase 버튼이 쓰는 픽스처와 동일:
[Fact] public Task Korean_short_안녕하세요() => RunRoundTrip("안녕하세요", language: "ko", caseId: "ko-short"); [Fact] public Task Korean_question_today_weather() => RunRoundTrip("오늘의 날씨는 어때?", language: "ko", caseId: "ko-question"); [Fact] public Task Korean_long_multi_clause() => RunRoundTrip( "내일의 날씨는 말고 모래의날씨는 흐리고 그리고 주간내내 비올예정입니다.", language: "ko", caseId: "ko-long"); [Fact] public Task English_short_hello() => RunRoundTrip("hello", language: "en", caseId: "en-short"); [Fact] public Task English_question_how_are_you() => RunRoundTrip("how are you?", language: "en", caseId: "en-question");
production UI 의 픽스처를 그대로 mirror 한 건 의도적 — long fixture 의 typo 까지 포함. 그건 아래 별도 섹션에서 다룬다.
교훈 1 — 비교: == 는 너무 엄격하고, Contains 는 너무 느슨하다
Whisper 는 입력 형식과 무관하게 proper-case + 구두점이 붙은 출력을 낸다. 그건 토크나이저가 일을 하는 거지 인식기 가 실패하는 게 아니다.
입력 | Whisper 출력 | Levenshtein | content drift? |
--- | --- | ---: | --- |
hello | Hello. | 2 | 아니오 — 토크나이저 화장 |
how are you? | How are you? | 1 | 아니오 — 대문자 H 만 |
안녕하세요 | 안녕하세요 | 0 | 아니오 |
순진한
== 는 잘못 인식한 게 없는데도 앞 두 개를 떨어뜨린다. 느슨한 임계값(similarity > 0.9 패스)은 모델이 실제로 단어를 치환 하거나 떨어뜨린 케이스를 숨긴다.우리가 "모델이 알아들었다" 고 부를 수준은 case-folded, 구두점 제거, 공백 제거 동치 비교 다:
private static string Normalize(string s) { if (string.IsNullOrEmpty(s)) return string.Empty; var sb = new StringBuilder(s.Length); foreach (var c in s) { if (char.IsWhiteSpace(c)) continue; if (char.IsPunctuation(c)) continue; // .,?!;:'"() 및 유니코드 친척까지 커버 sb.Append(char.ToLowerInvariant(c)); // 한글에선 no-op } return sb.ToString(); }
작지만 중요한 두 디테일:
char.IsPunctuation을 hard-coded 리스트 대신 사용. smart quote, em-dash, 그리고 Whisper 가 한국어 텍스트에 즐겨 붙이는 전각 구두점까지 잡아낸다.
ToLowerInvariant는 무조건 적용. 한글은 case 가 없으니 비용 0이고, 같은Normalize가 두 언어 모두에 동작한다. trivially right 한 branchless 코드가 언어별 cleverness 보다 가치 있다.
공백은 collapse 가 아니라 통째로 제거. round-trip 동치 검증에선 모델이
오늘의 와 날씨 사이에 공백을 추가하든 말든 상관없음 — 토크나이저마다 split 이 다르고 의미 변화는 없으니.normalize 후 content drift 가 읽힌다. 정확히 우리가 교훈 2 를 얘기하기 전에 원했던 상태다.
교훈 2 — Whisper 가 픽스처의 typo 를 자동 교정한다. 테스트는 여전히 떨어진다. 그래서 좋다.
긴 한국어 픽스처는
"내일의 날씨는 말고 모래의날씨는 흐리고 그리고 주간내내 비올예정입니다."모래 는 sand. 모레 는 day after tomorrow. 한글 모음 하나 차이고, 주변 문장이 명백히 일기예보라 한국어 독자라면 누구나 모레 를 의도했다고 읽는다. 사용자의 quick-phrase 픽스처에는 모래 가 들어있다. typo 였을 수도, 동음이의어를 의도했을 수도 있다. 우리는 알 수 없다.Whisper 는
모레 — 문맥상 맞는 단어 — 로 transcribe 했다. 모델이 입력을 교정 했다.테스트 결과:
─── COMPARISON ──────────────────────────────────────────────────────── normalised input : "내일의날씨는말고모래의날씨는흐리고그리고주간내내비올예정입니다" normalised output : "내일의날씨는말고모레의날씨는흐리고그리고주간내내비올예정입니다" exact match : False edit distance : 1 similarity : 96.8% VERDICT : ✗ FAIL — see comparison
이 케이스는 앞으로 자꾸 나올 거고, 유혹은 이렇다:
"한 글자 차이일 뿐. 임계값을>= 0.95 PASS로 올리고 넘어가자."
나는 그게 잘못된 판단이라 본다. 세 가지 이유:
- 그 1글자 drift 가 정확히 당신이 원했던 신호다. 스위트의 존재 이유는 TTS+STT 가 content 를 보존하는지 알기 위함이다. Whisper 가 content 를 바꿨다. 그게 질문에 대한 답이다. 변화가 작고 주장하기에 정확 하다는 이유로 숨기는 건 답을 보존하는 게 아니라 버리는 거다.
- 다음 변화는 양성(benign)이 아닐 거다. 오늘은 누구도 반대하지 않을 동음이의어 교정. 내일은 모델이 사용자가 소유격을 의도했다고 생각해서
송파구→송파의 구 (Songpa's borough)— 같은 1-substitution, 같은 "똑똑함", 같은 1-edit-distance, 그러나 정확한 구 이름을 찾는 downstream 코드를 깨뜨림. fuzzy 임계값은 어느 쪽이 일어나는지 알려주지 않는다.
- 콘솔 dump 가 운영자에게 모든 걸 알려준다.
normalised input과normalised output이 옆에 나란히 출력 — diff 가모래→모레면 리뷰어는 "아, 모델이 내 typo 고쳤네" 하고 넘어간다.송파구→송파의 구면 리뷰어는 진짜 문제를 본다. 둘 다 1-edit-distance fail 이지만 같은 종류의 문제는 아니다.
그래서 규칙은: drift 를 정직하게 보고하고, 사람이 diff 를 읽게 하라. 그게 "콘솔 dump 가 곧 리포트" 의 구체적 형태다 — 테스트는 green/red gate 가 아니라, 측정값을 출력하는 측정 도구.
이 글에서 가장 적어두고 싶었던 교훈이다. 같은 방식으로 4번째 음성 파이프라인이 떨어지는 걸 직접 보기 전엔 아무도 안 믿는 그 교훈.
교훈 3 — Dual output channel: ITestOutputHelper 그리고 Console.WriteLine
xUnit 의 정통(canonical) 캡처는
ITestOutputHelper. 테스트 익스플로러에서 pass/fail 바로 옆에 붙어 있고, verbosity 와 무관하게 dotnet test 가 캡처한다.안 되는 것:
"console;verbosity=detailed" 처럼 표준 출력을 타깃하는 CI 로거에는 안 잡힘. 그쪽은 Console.WriteLine 을 원한다.해결: 두 곳에 다 쓴다.
private void Log(string line) { try { _output.WriteLine(line); } catch { /* 실패한 assert teardown 중 helper 가 dispose 됐을 수 있음 */ } Console.WriteLine(line); }
try/catch 는 paranoia 가 아니다. Assert.Fail 이 돌면, 테스트 helper 의 라이프사이클이 후속 cleanup 이 쓰려는 시점 전에 finalize 될 수 있다. 그건 원래 실패를 가리는 secondary failure 다. catch 하고 넘어가라.happy-path 로컬 실행만 보면 두 출력은 overkill. CI 에서 처음 떨어지고 어느 로거가 뭘 잡았는지 찾아 헤맬 때 본전 뽑는다.
교훈 4 — 오디오는 항상 증거로 저장하라
비교가 떨어지면 다음 질문은: "오디오가 제대로 들리는가?"
두 중간 WAV 가 디스크에 있으면 즉시 답할 수 있다:
%TEMP%\agentzero-tts-stt-tests\ 20260429-180920-162-ko-long-tts.wav ← SAPI 가 만든 것 20260429-180920-162-ko-long-stt-input-16k.wav ← Whisper 가 실제로 들은 것
순서대로 듣는 건 결정 트리:
TTS WAV 들리기 | STT-input WAV 들리기 | 결론 |
정상 | 정상 | 모델 hallucination — 진짜 STT 버그 |
정상 | 이상 | resample regression — WavToPcm 버그 |
이상 | (irrelevant) | TTS-side regression 또는 잘못된 음성 선택 |
각 가지가 다른 fix 를 가리킨다. 아티팩트가 없으면 파이프라인을 다시 계측해 다시 돌려야 한다.
비용은 디스크 공간 반올림 오차. 이득은 파일 탐색기를 안 떠나고도 어떤 실패든 진단 가능.
교훈 5 — 성능 베이스라인. 지금 기록하고, 나중에 regress 잡기
maintainer 의 dev 머신에서 측정 (
Whisper.net medium model, CPU only):입력 길이 | TTS ms | decode ms | STT ms | xRT | total ms |
--- | ---: | ---: | ---: | ---: | ---: |
5 chars (한국어) | 30 | 1 | 3000 | 3–5× | ~3,000 |
11 chars (한국어) | 33 | 1 | 8915 | 4.09× | 8,949 |
42 chars (한국어) | 66 | 5 | 9948 | 1.68× | 10,019 |
5 chars (영어) | 25 | 1 | 6000 | 3–5× | ~6,000 |
12 chars (영어) | 43 | 11 | 9478 | 6.30× | 9,532 |
다음 토론에서 머리에 둘 만한 관찰 세 가지:
- xRT 는 긴 오디오에서 좋아진다. Whisper 의 호출당 오버헤드 — 모델 로드, prompt setup, beam-search 초기화 — 가 짧은 클립을 지배한다. 5.93초 utterance 의 1.68× 가 medium 모델 CPU 정상 상태(steady state) 에 더 가깝다. 0.5초 클립의 ~5× xRT 로 결론 내지 마라.
- TTS 는 STT 보다 두 자릿수 빠르다. 합성은 거의 병목이 아니다. 라운드트립이 느리면 STT 부터 봐라.
- 첫 번째 테스트가 모델 로드 비용을 낸다 (~9초 prep). 같은 스위트의 후속 테스트는 캐시 hit (
prep: 0 ms). 5-test 스위트의 wall-clock end-to-end ~50초 — 로컬 반복 OK, CI OK.
이 숫자들이 regression baseline 이다. 우리가 swap 할 때:
- 다른 STT (cloud Whisper, Webnori Gemma, on-device Gemma)
- 다른 모델 크기 (tiny / small / large)
- GPU 가속 (Vulkan / CUDA)
…같은 스위트가 같은 단위로 직접 비교 가능한 숫자를 낸다. swap 했더니 xRT 가 >1.5× 늘었는데 품질 향상 없으면 merge 전에 따져볼 만한 regression. swap 비용이 1.2× xRT 더 들지만 long fixture 의 similarity 가 96.8 % → 100 % 로 올라가면, 그건 다른 토론.
핵심은 절대값이 아니다. 그 숫자를 가지고 있다 는 사실이 다음 변화를 측정 가능하게 만든다.
교훈 6 — InternalsVisibleTo 는 state 용이지 helper 용이 아니다
WavToPcm.To16kMono 는 internal 이었다. 테스트가 InternalsVisibleTo 배관 없이 닿을 수 없었다 — 테스트가 production state (private 필드, internal 예외 타입, lock object) 를 들여다봐야 할 때는 합리적이지만, 단지 production 이 호출하는 같은 변환을 호출하고 싶은 거면 heavyweight.이 helper 의 encapsulation footprint 는 정확히 0.
internal static class WavToPcm 을 public static class WavToPcm 으로 뒤집은 게 옳은 수. internal 이 보호하던 비밀이 없었다.일반 규칙:
InternalsVisibleTo 는 state 용이지 helper 용이 아님. static 메서드가 어떤 변환의 정전(canonical) 구현이고 테스트가 그걸 직접 운동시킨다면, 그냥 public 으로. InternalsVisibleTo 는 테스트가 진짜 production internals 를 inspect 해야 하는 경우 — 그땐 인정해야 할 진짜 coupling 이 있는 거 — 만 위해 남겨둬라.콘솔이 실제로 어떻게 보이는가
매 케이스가 이런 걸 출력 — 매 실행이 같은 데이터 구조라, 두 실행을
diff 하면 정확히 뭐가 바뀌었는지 보인다:═══════════════════════════════════════════════════════════════════════ Round-trip case: ko-question ═══════════════════════════════════════════════════════════════════════ input : "오늘의 날씨는 어때?" language : ko input chars : 11 TTS provider : WindowsTTS (SAPI default voice) STT provider : WhisperLocal (model=medium) OS : Microsoft Windows NT 10.0.26200.0 .NET : 10.0.7 TTS voices available: Microsoft Heami Desktop, Microsoft Zira Desktop, ... [STAGE 1/3] TTS : 33 ms · 96150 bytes WAV saved : C:\...\agentzero-tts-stt-tests\20260429-180759-870-ko-question-tts.wav [STAGE 2/3] decode : 1 ms · 69736 pcm bytes (~2.18s audio) PCM level : peak=-5.2 dBFS · rms=-21.3 dBFS STT-input WAV saved: C:\...\agentzero-tts-stt-tests\20260429-180759-870-ko-question-stt-input-16k.wav STT prep : 0 ms · ready=True [STAGE 3/3] STT : 8915 ms · 4.09x realtime transcript : "오늘의 날씨는 어때?" transcript chars : 11 ─── COMPARISON ──────────────────────────────────────────────────────── normalised input : "오늘의날씨는어때" normalised output : "오늘의날씨는어때" exact match : True · edit distance: 0 · similarity: 100.0% TIMING TOTAL : 8949 ms (33 synth + 1 decode + 8915 STT) VERDICT : ✓ PASS ═══════════════════════════════════════════════════════════════════════
매 파라미터, 매 바이트 카운트, 매 밀리세컨드, 매 오디오 레벨, 매 저장된 아티팩트 경로. 통과든 실패든 같은 리포트 모양. 어느 하나도 떼지 마라 — 다음에 출력을 읽는 사람은 십오 개의 변수 중 어느 게 바뀌었는지 찾는 중일 거다.
이게 다음에 무엇을 가능하게 하나
스트리밍 음성 파이프라인 (Part 7) 도 regress 하는지 알려면 같은 측정이 필요하다. 이제 베이스라인이 있다.
측정 도구가 존재하기 때문에 모양이 분명한 후속 작업들:
- Cloud Whisper swap — 같은 스위트를
WhisperLocalStt대신OpenAiWhisperStt로. 직접적인 latency / 품질 / 비용 trade-off 표를 얻는다.
- Whisper small vs medium — 현재 실행 중인 앱은 비용/속도 위해 small 기본; 테스트 스위트는 품질 측정에 medium 사용. 픽스처마다 둘 다 돌리는
[Theory]row 를 추가하면 합성 벤치마크가 아닌 실제 배포 픽스처 위에서의 small-vs-medium 정확도 갭을 본다.
- Streaming-graph regression —
Akka.Streams출력 그래프가 sentence 별로 chunk 하고 progressive TTS 를 돌리면, 같은 5개 픽스처의 streaming-flavor 변종이 여기 파일 기반 reference 대비 regression 을 잡는다.
- GPU 가속 — 베이스라인의
xRT컬럼이 Vulkan / CUDAWhisper.netruntime 을 wire 할 때 "이게 더 빠른가" 체크가 된다.
각 변화가 예전엔 "써봐야 알겠지" 처럼 느껴졌다면, 이제 위 베이스라인 숫자에 대한 5-row delta 와 함께 떨어진다.
닫는 말
여기서 다룬 기법 은 작다. 글로 적을 만하게 만든 건 그 뒤의 철학 이었다.
테스트되는 모델이 픽스처보다 더 똑똑할 때 — Whisper 가
모래 를 모레 로 고치고, LLM 이 더 매끄러운 paraphrase 를 주고, OCR 이 mojibake 를 normalize 하는 — 테스트엔 선택지가 있다: fuzzy 임계값으로 통과시키거나, 실패시키고 diff 를 표면화하거나. 나는 두 번째가 옳다고 보고, 그 주장을 실제 케이스를 동봉해 적어두고 싶었다. 다음에 이 상황을 마주할 사람이 보러 갈 곳 이 있도록.다음 글은 cloud Whisper swap 이 이 숫자들에 형제를 만들어주면 그게 가르쳐주는 것이 될 것이다.
- psmon
TECH LINKS
- 𝕏 @webnori