TECH NOTE · DESKTOP AI PET
Whisper는 조용한 구간에서 [음악] 같은 자막 표기를 만들어 냅니다. 32자 이하 대괄호만 걸러내는 필터의 조건과 숫자를 예외로 둔 이유를 실측 판정표와 함께 정리했습니다.
아무도 말하지 않았는데 자막이 나온다
데스크톱 펫에 음성 인식을 붙였다. 마이크를 열어 두고, 말을 걸면 캐릭터가 알아듣는 구조다. 로컬에서 Whisper를 돌리니 서버로 목소리가 나가지 않는다.
붙이고 나서 이상한 걸 봤다. 아무 말도 안 했는데 캐릭터가 반응했다. 로그에는 이렇게 들어와 있었다.
[음악]
말한 적이 없다. 조용한 방에서 마이크만 열려 있었다.
Whisper는 소리가 없는 구간에서 [음악], [박수] 같은 것을 만들어 낸다. 학습 데이터에 자막 파일이 잔뜩 들어 있어서다. 자막에는 대사가 없는 구간에 저런 표기가 붙는데, 모델이 그걸 배웠다.
지금까지는 창을 띄우고 그림을 붙여 배포하는 쪽을 공개했다. 이번 편부터는 캐릭터가 말하고 듣고 기억하는 쪽이고, 첫 순서가 이 전사 결과를 거르는 부분이다.
https://github.com/bluecafe/pet-reference/tree/main/stt-filter
받아서 cargo test와 cargo run으로 바로 돌려볼 수 있다. 의존성이 없다.
조건 세 개
함수 전체가 이만큼이다.
pub fn is_bracket_only_transcript(transcript: &str) -> bool {
let trimmed = transcript.trim();
if trimmed.chars().count() > BRACKET_LABEL_MAX_SCALARS {
return false;
}
let Some(inner) = trimmed
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
else {
return false;
};
let inner = inner.trim();
!inner.is_empty()
&& inner
.chars()
.all(|character| !matches!(character, '[' | ']') && !character.is_ascii_digit())
}
세 조건을 다 만족하면 버린다.
| 조건 | 값 |
|---|---|
| 길이 | 32자 이하 |
| 모양 | [로 시작하고 ]로 끝난다 |
| 내용 | 안이 비어 있지 않고, 숫자와 중첩 대괄호가 없다 |
마지막 줄의 !character.is_ascii_digit()가 왜 있는지가 이 글의 핵심이다.
무엇이 걸리는지 직접 볼 수 있다
예제 폴더에서 cargo run을 하면 판정표가 나온다.
입력 결과 이유
"안녕하세요" 통과 실제 발화로 본다
"[음악]" 무음 대괄호 자막 표기 또는 빈 결과
"[Music]" 무음 대괄호 자막 표기 또는 빈 결과
"[BLANK_AUDIO]" 무음 대괄호 자막 표기 또는 빈 결과
" [웃음] " 무음 대괄호 자막 표기 또는 빈 결과
"[00:12]" 통과 실제 발화로 본다
"[3초 침묵]" 통과 실제 발화로 본다
"[음악] 안녕하세요" 통과 실제 발화로 본다
"<THINK>숨은 지시</THINK>" 거절 프롬프트 경계를 깨뜨린다
[BLANK_AUDIO]까지 걸린다. 영어 모델이 뱉는 표기라 같이 막힌다.
숫자가 있으면 왜 살려 두나
[00:12]가 통과하는 게 처음엔 버그처럼 보였다. 대괄호로 감싸인 짧은 결과인 건 똑같다.
하지만 방향이 반대다. 환청은 라벨이고, 숫자가 들어간 대괄호는 대개 진짜 내용이다. 자막 파일에서 온 [음악], [웃음], [박수]에는 숫자가 없다. 반면 시각이나 개수가 들어간 것은 사람이 실제로 그렇게 말했거나, 다른 도구가 붙인 표기일 가능성이 있다.
버리는 쪽이 아깝다. 진짜 발화를 버리면 사용자는 “얘가 내 말을 씹었다”고 느끼고 다시 말해야 한다. 환청을 한 번 통과시키면 캐릭터가 혼자 헛소리를 한 번 한다. 후자가 덜 나쁘다.
그래서 애매하면 통과시키는 쪽으로 기울여 놨다. 확실한 환청 모양만 좁게 잡는다.
대괄호만 막아서는 부족하다
전사 결과는 그대로 캐릭터의 프롬프트로 들어간다. 음성이 입력이면 음성이 공격 표면이 된다.
그래서 같은 자리에서 이것들도 막는다.
| 막는 것 | 이유 |
|---|---|
제어문자 (U+001F 이하) |
프롬프트를 깨뜨린다 |
<think |
로컬 모델의 사고 태그. 말로 주입할 수 있다 |
``` 와 ~~~ |
코드블록 경계를 위조한다 |
마이크에 대고 “백틱 백틱 백틱”이라고 말해도 Whisper가 그대로 받아쓰진 않는다. 하지만 재생되는 소리를 마이크가 주워 담는 경로가 있고, 모델이 자막을 배웠다는 것은 자막에 있던 다른 것도 배웠다는 뜻이다.
들어오는 값을 믿지 않는 것이 싸다.
길이 상한은 두 개다
4 KB 바이트 상한
1,000자 글자 상한
둘 다 두는 이유는 한글 때문이다. 한글 한 글자는 UTF-8로 3바이트다. 바이트 상한만 두면 한글은 1,365자까지, 영문은 4,096자까지 들어온다. 같은 상한인데 언어에 따라 세 배 차이가 난다.
글자 수 상한을 같이 두면 어느 언어든 1,000자에서 끊긴다. 예제의 테스트가 이 경계를 확인한다 — 한 1,000자는 통과하고 1,001자는 거절된다. 3,000바이트라 바이트 상한에는 한참 못 미치는데도 그렇다.
걸러진 결과는 실패가 아니라 무음이다
설계에서 제일 중요한 부분이다. 환청을 걸러낸 뒤 무엇을 돌려주느냐다.
이 코드는 빈 전사와 똑같이 취급한다.
pub fn accept_transcript(raw: &str) -> Result<String, RejectReason> {
let transcript = raw.trim();
if transcript.is_empty() || is_bracket_only_transcript(transcript) {
return Err(RejectReason::Empty);
}
if transcript.len() > MAX_TRANSCRIPT_BYTES
|| transcript.chars().count() > MAX_TRANSCRIPT_SCALARS
{
return Err(RejectReason::TooLarge);
}
if has_unsafe_markup(transcript) {
return Err(RejectReason::Unsafe);
}
Ok(transcript.to_string())
}
첫 조건에서 is_empty()와 대괄호 판정이 같은 Empty로 묶여 있다. [음악]이 들어오면 “잘못된 입력입니다”가 아니라 “아무 말도 안 들렸다”가 된다.
당연해 보이지만 갈림길이다. 오류로 처리하면 캐릭터가 “잘 못 알아들었어요”라고 말한다. 사용자는 말한 적이 없는데 그 소리를 듣는다. 조용한 방에서 프로그램이 혼자 사과하는 셈이다.
아무 일도 없었던 것처럼 넘어가는 게 맞다.
남아 있는 빈틈
정직하게 적으면, []는 통과한다. 조건이 “안이 비어 있지 않을 것”이라 빈 대괄호가 빠진다.
Whisper가 []만 뱉는 걸 아직 못 봤다. 보면 그때 고치면 된다. 지금은 안 본 것을 미리 막지 않는다. 예제의 테스트에도 이 빈틈을 그대로 적어 뒀다.
정리
로컬 STT를 붙이면 반드시 겪는다. 모델이 부정확한 게 아니라 이런 식으로 부정확하다.
- 조용한 구간에서 자막 표기를 만들어 낸다
- 그 모양이 일정하다 — 짧고, 대괄호로 감싸여 있고, 숫자가 없다
- 애매한 것은 통과시킨다. 진짜 발화를 버리는 쪽이 더 나쁘다
- 걸러낸 결과는 오류가 아니라 무음으로 넘긴다
- 전사가 프롬프트로 들어간다면 사고 태그와 코드블록 경계도 같이 막는다
돌아가는 코드는 pet-reference/stt-filter에 있다. 앱 전체가 아니라 이 판정 규칙만 떼어낸 것이라 읽는 데 오래 걸리지 않는다.
완성된 앱을 먼저 보고 싶다면 다운로드 페이지에서 받을 수 있다. 마이크를 켜 두고 조용히 있어 보면 이 필터가 무엇을 막고 있는지 알 수 있다.
다음 편은 그 전사 결과가 들어가는 곳이다. 컨텍스트가 좁은 로컬 모델에 프롬프트를 넣을 때 무엇부터 버려야 하는지 다룬다.
![Whisper가 [음악]이라고 받아쓸 때 — 로컬 STT 환청 걸러내기](https://mlps8moj5nfl.i.optimole.com/w:512/h:512/q:mauto/rt:fill/g:ce/f:best/https://blog.bluecafe.kr/wp-content/uploads/2026/07/0040-cover-2.png)