소리가 0.15초 앞서 있었다 — v1.0.0.1
시험이 「전부 통과」인데도 결함은 있었다. 통과한 것은 길이였지 맞춤이 아니었다. 스스로 점검해 찾은 여섯 가지와 그 고침.
1. 점검 방법
1판의 시험은 「결과 파일이 나오는가, 길이가 맞는가」를 봤다. 이번에는 코드를 처음부터 다시 읽으면서 「이 값이 어디서 와서 어디로 가는가」를 따라갔다. 찾은 것은 결함 셋, 보완 넷이다. 결함은 수치로 재현한 뒤 고쳤고, 보완은 고친 뒤 시험을 붙였다.
| # | 구분 | 내용 | 재현·확인 |
|---|---|---|---|
| 1 | 결함 | 소리가 영상보다 0.15초 앞서 있음 | enginetest [4] 섬광·삐 소리 시각 비교 |
| 2 | 결함 | 표식 시각이 파일 시각과 다름 | enginetest [6] |
| 3 | 결함 | 합치는 중 단축키로 시작하면 새 세션이 화면에서 사라짐 | realtest「합치는 중엔 새로 시작 안 됨」 |
| 4 | 보완 | Ctrl+Shift+R 이 브라우저 강력 새로고침과 겹침 | Ctrl+Alt+R/P/M, 설정으로 변경 |
| 5 | 보완 | 영역이 화면 밖으로 나가면 mss 예외. 배율 어긋남을 알 길 없음 | 잘라내기 + 미리보기, realtest |
| 6 | 보완 | 4K 화면을 그대로 넣으면 인코더가 못 따라감 | 출력 크기 50·75%, enginetest [5] |
| 7 | 보완 | 커서가 안 찍힘 | 화살표 덧그리기, enginetest [5] |
2. 결함 1. 소리가 0.15초 앞서 있었다
1판의 _audio_parts 는 소리 시작이 영상보다 늦은 경우(양수)만 -itsoffset 으로 처리하고, 이른 경우(음수)는 0으로 버렸다. 그런데 세션은 소리 장치를 먼저 열고 그 다음에 ffmpeg 를 띄운다. 소리가 늘 먼저다. 재 보니 이렇다.
t_start->video 0.175
t_start->mic 0.025
mic - video -0.150 (음수 = 소리가 먼저. 1판은 이 값을 버렸다)
결과 파일에서 소리는 영상보다 0.15초 앞서 재생된다. 1판의 시험이 이것을 못 잡은 이유는 「길이」만 쟀기 때문이다. 길이는 -shortest 가 맞춰 주니 늘 통과한다.
고침
세 가지를 바꿨다. 첫째, 음수면 소리 앞머리를 그만큼 잘라낸다(atrim). 둘째, 양수면 -itsoffset 대신 adelay 를 쓴다. amix 는 늦게 시작하는 입력 앞을 무음으로 채워 주지 않으므로 -itsoffset 은 섞을 때 어긋난다. 셋째, 결과 길이는 영상 길이로 못 박는다. 영상 프레임 수 ÷ FPS 를 알고 있으니 apad=whole_dur 로 채우고 atrim=end 로 자른다.
def _audio_chain(idx, off, tag):
chain = f"[{idx}:a]aresample=48000"
if off > 0.0005: chain += f",adelay={int(round(off * 1000))}:all=1"
elif off < -0.0005: chain += f",atrim=start={-off:.3f},asetpts=PTS-STARTPTS"
return chain + f"[{tag}]"
fit = f",apad=whole_dur={duration:.3f},atrim=end={duration:.3f}"
중간에 겪은 것: ffmpeg 가 끝나지 않는다
처음엔 apad(무한) + -shortest 로 했다. 영상을 다시 인코딩할 때는 되는 조합인데 -c:v copy 와 함께 쓰니 ffmpeg 가 끝나지 않았다. 2분 기다리다 죽였다. 영상 길이를 숫자로 알고 있으니 whole_dur 로 끊는 쪽이 애초에 맞다.
시험
길이가 아니라 「맞춤」을 재는 시험을 넣었다. 1.0초에 흰 프레임 3장이 있는 영상과, 소리 시계로 1.15초에 삐 소리가 있는 WAV(0.15초 일찍 켜진 상황)를 만들어 합친 뒤, 결과에서 삐 소리의 시작과 섬광의 시작을 따로 잰다.
[4] 영상·소리 맞춤 (소리가 0.15초 먼저 시작)
삐 시작 1.000s / 섬광 시작 1.00s / 길이 2.00s
ok 삐 소리가 1.00초에
ok 섬광이 1.00초에
ok 길이 = 영상 길이 2.0
3. 결함 2. 표식 시각
표식은 세션 시계(_t_start)로 쟀다. 파일의 0초는 첫 프레임을 보낸 순간이라 그보다 0.02~0.5초 뒤다. 플레이어에서 표식 시각으로 가면 그만큼 앞이다. 마무리할 때 media_start() − _t_start 만큼 빼서 적는다. 녹음 모드는 가장 먼저 블록이 온 시각이 기준이다. 두 소리 원천의 시작 차이도 같은 기준으로 adelay 한다.
4. 결함 3. 합치는 중에 다시 시작
정지를 누르면 화면의 단추는 잠기지만 단축키는 살아 있다. 그 사이에 Ctrl+Shift+R 을 누르면 session.running 이 이미 False 라 새 세션이 만들어지고, 조금 뒤 앞 세션의 마무리 신호가 self.session = None 을 해 버린다. 새 세션은 돌고 있는데 화면은 그것을 잊는다. 정지도 못 한다.
finalizing 을 따로 두고, 그것이 남아 있으면 시작을 거부하고 상태줄에 이유를 쓴다. 마무리 신호는 자기 세션이 아닌 것을 지우지 않는다. realtest 에서 정지 직후 start() 를 다시 불러 확인한다.
5. 보완
5-1. 단축키
Ctrl+Shift+R 은 Chrome·Edge 의 강력 새로고침이다. 전역 단축키는 다른 프로그램의 것을 빼앗으므로, 브라우저에서 새로고침을 누를 때마다 녹화가 켜진다. Ctrl+Alt+R/P/M 으로 바꾸고 settings.json 에서 고칠 수 있게 했다. "F9", "Ctrl+Shift+F12" 같은 글을 읽어 RegisterHotKey 의 modifier·vk 로 바꾸는 parse() 를 넣었다.
5-2. 영역: 잘라내기와 미리보기
영역을 화면 밖까지 끌면 mss 가 예외를 낸다. 가상 데스크톱 사각형으로 잘라낸다. 그리고 고른 직후 그 자리를 한 번 찍어 창 안에 작은 그림으로 보여 준다. Windows 배율 125%·150% 에서 좌표가 어긋나면 이 그림이 어긋난 자리를 보여 줄 것이다. 배율 문제를 클라우드에서는 재현할 수 없으니, 사용자가 눈으로 확인할 자리를 만든 것이다.
5-3. 출력 크기와 커서
4K 화면은 프레임 하나가 33MB 다. 30fps 면 초당 1GB 를 파이프로 넘기고 libx264 가 그것을 따라가야 한다. 「출력 크기 50·75%」를 두어 ffmpeg 의 scale 로 줄인다. 커서는 mss 가 찍지 않으므로 Windows GetCursorPos 위치에 화살표 하나를 numpy 로 덧그린다. 실제 커서 그림을 뽑는 대신 흰 테두리·검은 속의 화살표 하나로 통일했다.
6. 그 밖에 고친 것
- 일시정지 표시의 ⏸ 글리프가 Consolas 에 없어 네모로 나옴 → 숫자 색을 회색으로
- 품질 콤보가 잘려 「보통 (CRF」까지만 보임 → 화질 묶음을 두 줄로
- 녹화 모드와 녹음 모드가 장치 선택을 한 자리에 저장해 모드를 바꾸면 잃음 → 따로 저장
- 창을 닫으면 트레이로 내려가는데 녹화가 계속되는지 알 길 없음 → 트레이 알림 한 번
- 시험 스크립트가 사용자의 설정 파일(저장 폴더)을 덮어씀 → 시험은 임시 설정 파일로
- capture.py 의
processEvents()가 다음 단계 타이머를 먼저 돌려 한 단계 앞선 화면이 찍힘 → 제거
7. 시험 결과
enginetest [1] 파일 이름 · [2] 녹화 · [3] 녹음 · [4] 맞춤 · [5] 커서·크기 · [6] 표식 전부 통과
realtest 화면 밖 영역 잘라냄 · 미리보기 · 시작 · 표식 · 일시정지 · 재개 · 합치는 중 재시작 차단
· 길이 = 경과 시간 · 정지 화면 서로 다름 · 세션 정리 전부 통과
8. exe 를 클라우드에서 만들다
1판은 exe 를 사용자 PC 의 run0.bat 에 맡겼다. 이번에는 클라우드에서 만들어 넣는다. wine 9.0 에 64비트 Windows Python 3.11.9(NuGet 배포본)를 올리고 PySide6·mss·sounddevice·numpy·imageio-ffmpeg·PyInstaller 를 Windows 휠로 설치했다. 막힌 곳이 둘이었다.
8-1. icuuc.dll 이 없다
Qt6Core.dll 이 icuuc.dll 을 정적으로 끌어온다. Windows 10 부터 System32 에 들어 있는 시스템 ICU 라 실제 PC 에서는 문제가 없지만 wine 에는 없다. PyInstaller 의 PySide6 훅은 QtCore 를 실제로 import 해 경로를 묻기 때문에 이것이 안 뜨면 빌드가 깨진다. Qt6Core 가 쓰는 함수를 objdump 로 세어 보니 ucnv_* 스무 개다. 그 이름만 내보내고 전부 「없음」을 돌려주는 대역 DLL 을 mingw 로 만들어 wine 의 system32 에 넣었다. Qt 는 ICU 코덱이 없으면 자체 코덱으로 간다.
API void* ucnv_open(const char* n, UErrorCode* e){ if(e) *e = 4; return NULL; }
API int ucnv_countAvailable(void){ return 0; }
... /* 20개, 빌드 전용. 배포본에는 안 들어간다 */
8-2. numpy 2.4 가 wine 에서 안 뜬다
ucrtbase.dll.crealf 가 wine 9.0 에 구현돼 있지 않다. 빌드 환경만 numpy 2.0.2 로 내렸다. 프로그램의 요구는 1.26 이상이라 상관없다.
8-3. 두 벌 들어간 ffmpeg
첫 빌드가 273MB 였다. PyInstaller 훅이 imageio-ffmpeg 의 바이너리(84MB)를 넣고, build.bat 이 exe 옆에 또 복사했다. 스펙에서 훅이 넣는 것을 빼고 _internal\ffmpeg.exe 한 벌만 두었다. 안 쓰는 Qt 덩어리(소프트웨어 OpenGL 20MB, 번역 6.7MB)도 뺐다. 189MB.
8-4. 한글 파일 이름
Windows Python 으로 enginetest 를 돌리니 회의.mic.tmp.wav 를 못 열었다. wine 의 win32 파일계통이 한글 경로를 못 받는 것이라 실제 Windows 에서는 없는 일이지만, 덕분에 구조를 고쳤다. 임시 파일은 gr_시각_pid.* 같은 ASCII 이름으로 쓰고 마지막에 os.replace 로 최종 이름을 준다. 그것마저 안 되면 주석을 뺀 이름으로 물러선다. ffmpeg 와 wave 에 한글 경로를 직접 주는 자리가 없어졌다.
8-5. exe 로 실제 녹화
Windows Python 으로 enginetest 6묶음과 realtest 20항목이 전부 통과했고, 빌드된 exe 를 가상 화면에 띄워 xdotool 로 시작·커서 이동·표식·정지를 눌렀다. 5.93초 MP4 와 표식 파일이 나왔고, 정지 화면에 덧그린 커서 화살표가 찍혀 있다.
9. 남은 일
- Windows 실기: exe 를 바로 실행. WASAPI loopback 장치가 잡히는지, 배율 화면의 영역 미리보기가 맞는지. 다시 빌드하려면 run1.bat
- 커서 위치는 Windows GetCursorPos 다. wine 에서는 잡혔고, 실제 PC 의 배율 화면에서 자리가 맞는지 본다