GenealogyPost 직접 만드는 녹화·녹음 프로그램
개발일지 2회

소리가 0.15초 앞서 있었다 · v1.0.0.1

시험이 「전부 통과」인데도 결함은 있었다. 통과한 것은 길이였지 맞춤이 아니었다. 스스로 점검해 찾은 여섯 가지와 그 고침.

목적

1판 코드를 처음부터 다시 읽어 시험이 못 잡은 결함을 찾아 고친다. 그리고 exe 를 클라우드에서 만들어 판마다 함께 낸다.

계획
  1. 값이 어디서 와서 어디로 가는지 따라가며 코드 점검
  2. 결함은 수치로 재현한 뒤 고치고, 그 수치를 재는 시험을 붙임
  3. 보완(단축키·영역·출력 크기·커서)은 고친 뒤 시험
  4. wine 에 Windows Python 을 올려 PyInstaller 빌드
  5. 빌드된 exe 로 실제 녹화
체크리스트8/9
  • ☑결함 1: 소리가 영상보다 0.15초 앞섬 → atrim·adelay, 섬광·삐 소리 시험으로 1.000초 일치 확인
  • ☑결함 2: 표식 시각이 파일 시각과 다름 → 첫 프레임 기준으로 보정
  • ☑결함 3: 합치는 중 재시작하면 세션을 잃음 → finalizing 상태로 차단
  • ☑단축키 Ctrl+Alt 로 변경, settings.json 에서 수정 가능
  • ☑영역 화면 밖 잘라내기 + 미리보기
  • ☑출력 크기 50·75%, 마우스 커서 덧그리기
  • ☑exe 189MB 클라우드 빌드(icuuc.dll 대역, numpy 2.0.2, ffmpeg 한 벌)
  • ☑Windows Python 으로 enginetest·realtest 전부 통과, exe 로 5.93초 녹화
  • ☐실제 Windows 에서 loopback 장치·배율 화면 확인

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% 에서 좌표가 어긋나면 이 그림이 어긋난 자리를 보여 줄 것이다. 배율 문제를 클라우드에서는 재현할 수 없으니, 사용자가 눈으로 확인할 자리를 만든 것이다.

영역 미리보기
realtest 가 (−50,−20) 500×300 을 넣었을 때. (0,0) 450×280 으로 잘리고 미리보기가 찍혔다.

5-3. 출력 크기와 커서

4K 화면은 프레임 하나가 33MB 다. 30fps 면 초당 1GB 를 파이프로 넘기고 libx264 가 그것을 따라가야 한다. 「출력 크기 50·75%」를 두어 ffmpeg 의 scale 로 줄인다. 커서는 mss 가 찍지 않으므로 Windows GetCursorPos 위치에 화살표 하나를 numpy 로 덧그린다. 실제 커서 그림을 뽑는 대신 흰 테두리·검은 속의 화살표 하나로 통일했다.

v1.0.0.1 녹화 모드
v1.0.0.1 녹화 모드. 원천 옆에 미리보기, 화질에 출력 크기와 커서.

6. 그 밖에 고친 것

7. 시험 결과

enginetest  [1] 파일 이름 · [2] 녹화 · [3] 녹음 · [4] 맞춤 · [5] 커서·크기 · [6] 표식   전부 통과
realtest    화면 밖 영역 잘라냄 · 미리보기 · 시작 · 표식 · 일시정지 · 재개 · 합치는 중 재시작 차단
            · 길이 = 경과 시간 · 정지 화면 서로 다름 · 세션 정리                       전부 통과
realtest 정지 화면
v1.0.0.1 realtest 의 1·3·5초 정지 화면.

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 와 표식 파일이 나왔고, 정지 화면에 덧그린 커서 화살표가 찍혀 있다.

exe 로 녹화한 정지 화면
GenealogyRecording_v1.0.0.1.exe 가 찍은 프레임(2초 지점). 「마우스 커서 그리기」 글자 옆에 덧그린 화살표가 보인다. wine 이라 한글 파일 이름은 ASCII 로 물러섰다.

9. 남은 일