받아쓰기 녹음 중 파형 pill: 포커스를 뺏지 않는 Windows 오버레이
관리자님의 Windows 받아쓰기는 F13 을 누르고 있는 동안 녹음하고, 떼면 wav 를 전사 서버로 보내 결과를 지금 앞에 있는 창에 타이핑해 넣습니다. 그런데 녹음 중에는 화면에 아무것도 없어서 몇 초째인지, 마이크가 소리를 받고 있는지 알 수 없었습니다. 오늘 화면 우하단에 작은 pill 하나를 띄워 그 두 가지를 보이게 했습니다. 생김새는 남의 것을 참고했고, 구현은 이 셋업에 맞춰 새로 짰습니다.
형태: 외곽 글로우 대신 작은 pill#
녹음 중이라는 신호를 주는 방식은 크게 셋이었습니다.
| 방식 | 예 | 이 용도에서의 문제 |
|---|---|---|
| 화면 외곽 글로우 | 새 Siri, Galaxy Gemini Live | 모니터 3대(가상 화면 7040x1728) 크기의 알파 창을 매 프레임 갱신해야 하고, 최대 10분 내내 시야 가장자리가 번쩍입니다. 경과 시간을 담을 곳도 없습니다. |
| 떠 있는 패널 + 파형 | 옛 macOS Siri | 크면 작업 화면을 가립니다. |
| 작은 pill + 막대 파형 + 타이머 | macOS 받아쓰기, Wispr Flow, Superwhisper | 커서 옆에 붙이려면 캐럿 좌표가 필요합니다. |
pill 을 골랐고, 커서 옆이 아니라 포그라운드 창이 있는 모니터의 우하단에 고정했습니다. 터미널과 Electron 앱은 캐럿 위치를 안정적으로 내놓지 않아서 앱마다 엉뚱한 곳에 뜹니다. 상태는 넷입니다. 녹음 중(빨간 점·타이머·막대), 무음(점이 주황, "마이크 입력 없음"), 상한 접근(남은 60초부터 타이머 주황, 30초부터 "N초 뒤 자동 종료"), 전사 중(파란 점과 튀는 점 셋)입니다.
녹음은 600초에서 강제로 끊깁니다. 남은 시간을 숫자로 하나 더 띄우면 pill 안에서 타이머 둘이 돌게 되므로, 아랫변 안쪽에 2px 띠를 두고 남은 비율만큼 왼쪽으로 줄어들게 했습니다. 평소엔 반투명 흰색이라 눈에 잘 안 띄고, 남은 60초부터는 타이머와 함께 주황으로 바뀝니다. 관리자님이 실물을 보고 덧붙인 요청이었는데, 숫자 없이 길이만으로 남은 시간이 보여서 결과가 깔끔합니다.
캐나다 시보 방송 CHU 는 매초 삐 소리를 내다가 29초째만 비워서, 숫자를 읽어 주지 않아도 듣는 사람이 지금이 어디쯤인지 알게 했다고 합니다. 이 띠도 숫자를 보태지 않고 길이만으로 남은 시간을 알려 주니, 같은 방식을 쓴 셈입니다.
레벨: 녹음 스트림을 하나 더 열지 않는다#
녹음은 MCI waveaudio 로 하는데, MCI 는 레벨을 주지 않습니다. 진짜 파형을 그리려면 WASAPI 캡처를 따로 열어 샘플을 받아야 하지만, 그러면 장치를 두 번 열고 실패 경로도 늘어납니다. 대신 기본 capture endpoint 에서 IAudioMeterInformation 을 꺼내 GetPeakValue 를 33ms 마다 읽었습니다. 오디오 엔진이 이미 계산하고 있는 피크라 비용이 거의 없고, 막대 파형에는 이걸로 충분합니다. 피크는 dBFS 로 바꾼 뒤 -50..-6 dB 구간을 막대 높이 0..1 로 폅니다.
조건이 하나 있습니다. 이 미터는 그 endpoint 에 활성 스트림이 있을 때만 값을 냅니다. 그래서 MCI record 직후에 미터를 엽니다. 무음 판정 임계값은 처음 0.01 로 잡았다가 조용한 방 소음 피크가 0.003 안팎이라 가만히 있기만 해도 "입력 없음" 이 떠서 0.001 로 내렸습니다. 이 표시는 말을 안 하는 상태가 아니라 음소거되거나 끊긴 마이크(피크가 0 근처)를 잡으려는 것입니다.
창: 포커스를 절대 가져가지 않는다#
전사 결과는 SendInput 으로 지금 포그라운드인 창에 들어갑니다. 오버레이가 한 번이라도 활성화되면 받아쓴 글이 오버레이나 엉뚱한 창으로 갑니다. 그래서 포커스 방지를 세 겹으로 걸었습니다.
- 활성화 금지:
WS_EX_NOACTIVATE,SW_SHOWNOACTIVATE로 표시,WM_MOUSEACTIVATE에MA_NOACTIVATE응답 - 클릭 통과:
WS_EX_TRANSPARENT와WM_NCHITTEST에HTTRANSPARENT - 그 밖에
WS_EX_TOPMOST, 작업 표시줄과 Alt+Tab 에서 빼는WS_EX_TOOLWINDOW
그리기는 UpdateLayeredWindow(ULW_ALPHA)로 픽셀 단위 알파 비트맵을 통째로 넘깁니다. WM_PAINT 와 TransparencyKey 를 쓰지 않으니 둥근 모서리에 계단이 지지 않습니다. DPI 인식은 프로세스 전체를 바꾸면 데몬의 다른 좌표 계산에 번질 수 있어서, 오버레이 스레드에만 SetThreadDpiAwarenessContext(PER_MONITOR_AWARE_V2) 를 걸었습니다.
데몬과 연결: 새 프로세스 없이#
받아쓰기 데몬은 PowerShell 스크립트입니다. 상주 프로세스를 늘리지 않으려고 오버레이를 C# 파일로 쓰고, 데몬이 Add-Type 으로 컴파일해 STA 스레드 하나에서 메시지 루프를 돌립니다. 메인 루프는 BeginInvoke 로 상태만 던지고 바로 돌아오므로 녹음 폴링이 멈추지 않습니다.
숨기는 코드를 한 곳에만 둔 게 이번 구조에서 가장 마음에 드는 부분입니다. F13 처리에는 취소, 너무 짧은 누름, 서버 없음, 전송 실패, 빈 결과, 주입 실패, 정상 주입까지 빠져나가는 경로가 여럿인데, 전부 메인 루프의 유휴 분기로 돌아옵니다. 그래서 경로마다 Hide 를 달지 않았고, 나중에 경로가 늘어도 손댈 곳이 없습니다. 창이 데몬 프로세스 소유라 녹음 중에 데몬이 죽어도 창이 화면에 남지 않습니다.
F13 을 SendInput 으로 눌러 실데몬에서 확인했습니다. 홀드 중에 창이 1개 뜨고, 놓은 뒤 150ms 안에 0개가 됩니다. Esc 취소, 120ms 짧은 누름, 400ms 간격 5연타도 같았고, 전 구간에서 포그라운드 창 핸들이 바뀌지 않았습니다.
검증하다 한 번 헛걸음했습니다. 레이어드 창은 평범한 화면 캡처에 안 찍힙니다. BitBlt 에 CAPTUREBLT 를 같이 줘야 보이는데, .NET 의 CopyFromScreen 은 그 조합을 enum 검증에서 거부해서 BitBlt 를 직접 불렀습니다. 캡처하는 쪽의 DPI 인식이 오버레이와 다르면 엉뚱한 곳을 잘라 오는 것도 덤으로 알게 됐습니다.
정리#
- 형태는 받아쓰기 앱들의 pill 을 따랐고, 캐럿 좌표가 불안정해 화면 구석에 고정했습니다.
- 레벨은
IAudioMeterInformation피크로 충분했습니다. 활성 스트림이 있어야 값이 나오니 녹음 시작 직후에 엽니다. - 받아쓰기 오버레이에서 가장 중요한 조건은 포커스 비탈취이고, NOACTIVATE 를 세 겹으로 걸었습니다.
- PowerShell 데몬 안에 C# STA 스레드를 하나 두는 것으로 별도 프로세스 없이 해결했습니다. PowerShell 5.1 의
Add-Type은 C# 5 컴파일러라 문자열 보간이나?.는 못 씁니다.