1. 왜 만드는가

화면 녹화는 반디캠, 회의·강의 녹음은 Windows 녹음기나 스마트폰 앱을 썼다. 반디캠은 유료이고 무료판은 워터마크가 붙는다. OBS 는 무료이지만 방송용이라 장면·소스·믹서를 먼저 이해해야 단추가 보인다. Windows 녹음기는 표식 기능이 앱 안에 갇혀 있어 파일을 다른 곳으로 옮기면 표식이 사라진다.

세 프로그램에 공통으로 없는 것이 두 가지다. 첫째, 파일 이름 규칙을 내가 정할 수 없다. 둘째, 녹음 중 「지금 이 자리」를 남기고 그 목록을 파일 곁에 텍스트로 두는 기능이 없다. 이 둘이 GenealogyKSIC 에서 정한 「0000.00.00., 00.00., 주석.확장자」 규칙과 회의 전사 작업에 필요한 것이라, 직접 만들기로 했다. 이름은 GenealogyRecording.

2. 무엇을 만드는가

항목반디캠OBSWindows 녹음기GenealogyRecording
라이선스유료(무료판 워터마크)무료(GPL)무료자체 제작
녹화+녹음 한 프로그램녹화 위주녹화 위주녹음만모드 전환
표식(marker)없음없음앱 내부파일 곁 txt·csv
파일 이름날짜 고정날짜 고정「녹음 (1)」날짜.시각.주석
내부 수정불가C++불가Python, 판올림 기록

1판(v1.0.0.0)의 범위는 다음과 같다.

넣지 않은 것: 마우스 커서 그리기, 특정 창 따라가기, 웹캠 덧붙이기, 전사. 커서는 캡처 라이브러리가 찍지 않아 따로 덧그려야 하고, 그것은 다음 판의 일이다.

3. 스택과 구조

GenealogyImage 와 같은 Python + PySide6 이다. 이유도 같다. 화면 캡처는 mss, 소리는 sounddevice(PortAudio), 인코딩·합치기·변환은 ffmpeg 다. ffmpeg 는 imageio-ffmpeg 패키지가 실행 파일을 동봉하므로 따로 내려받을 것이 없다.

src/genealogyrecording/
  app.py           QApplication, 팔레트, 아이콘(코드로 그림)
  mainwindow.py    창. 모드 전환 · 설정 · 큰 단추 · 최근 결과
  settings.py      %APPDATA%\GenealogyRecording\settings.json
  naming.py        0000.00.00., 00.00., 주석.확장자
  screen.py        mss 모니터 목록 · BGRA 프레임
  videowriter.py   프레임 → ffmpeg 표준입력 → MP4
  audio.py         장치 목록 · WASAPI loopback 탐지 · WAV 기록
  session.py       한 번의 녹화를 통째로(시작·일시정지·표식·정지·마무리)
  markers.py       표식 목록 · txt/csv
  regionselect.py  화면 위 반투명 덮개에서 영역 끌기
  hotkeys.py       RegisterHotKey(Windows) / QShortcut(그 밖)
  levelmeter.py    입력 레벨 막대
  ffmpegtool.py    ffmpeg 찾기 · mux · 변환

4. 결정 세 가지

4-1. 프레임은 파이프로, 소리는 파일로, 합치기는 마지막에

영상과 소리를 처음부터 한 ffmpeg 프로세스에 넣는 방법도 있다. 그러면 소리 장치가 ffmpeg 의 dshow 로 잡혀야 하는데, 장치 이름이 한국어 Windows 에서 깨지거나 시스템 소리(스테레오 믹스)가 기본으로 꺼져 있는 PC 가 많다. 그래서 갈랐다. 영상은 mss 프레임을 ffmpeg 표준입력으로 흘려 임시 MP4 로, 마이크와 시스템 소리는 각각 WAV 로, 정지하면 셋을 ffmpeg 로 합친다.

cmd = [exe, "-f", "rawvideo", "-pix_fmt", "bgra",
       "-s", f"{w}x{h}", "-r", str(fps), "-i", "-",
       "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
       "-pix_fmt", "yuv420p", "-movflags", "+faststart", out]

4-2. 박자를 놓치면 직전 프레임을 다시 보낸다

캡처는 늦을 수 있다. 큰 화면, 느린 PC, 다른 프로그램이 CPU 를 쓰는 순간. 그때 프레임을 건너뛰면 영상이 실제 시간보다 짧아지고 소리와 어긋난다. 그래서 캡처 스레드는 시계를 보고, 놓친 박자 수만큼 같은 프레임을 다시 보낸다. 영상 길이 = 프레임 수 ÷ FPS 가 항상 실제 시간과 같아진다.

due = base + tick * period
if now < due: sleep(min(due - now, 0.05)); continue
frame = source.grab()
pipe.write(frame); tick += 1
behind = int((monotonic() - base) / period) - tick
for _ in range(min(behind, fps)):          # 놓친 박자만큼 채움
    pipe.write(frame); tick += 1

일시정지는 박자를 세지 않는다. 재개할 때 base 를 멈춘 시간만큼 뒤로 옮겨 시계를 잇는다.

4-3. 시작 시각 차이는 itsoffset 으로

영상 스레드와 소리 스레드는 같은 순간에 뜨지 않는다. PortAudio 장치가 열리는 데 0.1~0.5초가 든다. 각 스레드가 첫 프레임·첫 블록을 받은 monotonic() 을 적어 두고, 소리가 늦은 만큼 합칠 때 -itsoffset 으로 앞에 무음을 넣는다.

off = recorder.started_at - video.started_at
args += ["-itsoffset", f"{off:.3f}", "-i", wav]

5. 화면

녹화 모드
녹화 모드. 원천(모니터·영역), 화질(FPS·CRF), 소리(마이크·시스템), 저장(폴더·주석). 아래에 레벨 미터, 시간, 시작 단추.
녹음 모드
녹음 모드. 형식은 MP3·M4A·WAV. 표식 설명이 형식 옆에 있다.
녹화 중
녹화 중. 설정은 잠기고, 표식 하나를 찍은 상태. 레벨 미터가 입력을 보여 준다.
정지 뒤
정지 뒤. 최근 결과 목록에 길이·파일 이름·표식 수. 두 번 누르면 연다.

위 그림은 클라우드의 가상 화면에서 가짜 원천으로 찍은 것이라 「화면 없음」「마이크 없음」이 보인다. 실제 PC 에서는 모니터와 장치 이름이 채워진다.

6. 디버깅

6-1. 'Event' object is not callable

첫 시험에서 정지 직후 스레드 join 이 죽었다. 원인은 이름 충돌이다. threading.Thread 는 내부에서 self._stop() 메서드를 쓴다. 내가 정지 신호를 self._stop = threading.Event() 로 두는 바람에 그 메서드를 덮어썼다. 스레드가 끝날 때 파이썬이 _stop() 을 부르고, Event 는 호출할 수 없으니 예외다. _stop_ev 로 바꿨다. 스레드를 상속할 때 밑줄 하나짜리 이름은 부모 것과 겹치는지 먼저 본다.

6-2. flush of closed file, 그리고 moov atom not found

두 번째 시험에서 MP4 가 48바이트짜리 빈 파일로 남았다. ffmpeg 표준입력을 close() 한 뒤 communicate() 를 불렀는데, communicate() 는 입력이 없어도 stdin 을 한 번 더 flush 하려 든다. 이미 닫힌 파일이라 예외, 그 예외 처리에서 kill(), ffmpeg 가 moov 를 못 쓰고 죽음. 순서를 「stdin 닫기 → stderr 읽기 → wait」로 바꿨다.

p.stdin.close()
err = p.stderr.read()
p.wait(timeout=120)

6-3. 표본율이 다른 원천

마이크는 48000Hz, WASAPI loopback 은 장치 기본값(44100 인 경우가 많음)이다. WASAPI 공유 모드는 장치 기본 표본율만 받으므로 녹음 쪽은 장치 값을 따르게 했고, 합칠 때는 aresample=48000 을 각 입력에 먼저 걸고 amix 한다. 44100 과 48000 을 섞은 시험은 통과했다.

7. 시험

7-1. 장치 없이 엔진만

장치가 없는 곳에서도 엔진을 시험할 수 있어야 한다. FakeSource(색이 바뀌는 회색 프레임)와 FakeRecorder(440Hz 사인파를 실시간으로 흘림)를 두고 scripts/enginetest.py 가 세 묶음을 돈다.

[1] 파일 이름
  ok   주석 있음 / 주석 없음 / 금지 문자 치환 / 중복 이름 (2)
[2] 녹화 모드 (가짜 화면 + 마이크 + 시스템 소리)
  ok   결과 ok
       2026.09.09., 09.57., 시험.mp4  streams=['video', 'audio']  dur=3.03
  ok   영상+소리 트랙 / 길이 3초 안팎 (일시정지 1초 제외) / 세션 시간 3.00
  ok   프레임 60 (중복 0) / 표식 txt / 임시 파일 정리 / 짝수 크기
[3] 녹음 모드 (mp3 + 표식)
  ok   결과 ok
       2026.09.09., 09.57., 회의.mp3  streams=['audio']  dur=2.04
  ok   mp3 / 길이 2초 안팎 / 표식 2개 / 표식 csv 3줄 / 원본 WAV 보존
전부 통과

「1.5초 녹화 → 1초 일시정지 → 1.5초 녹화」가 3.03초 영상으로 나온 것이 4-2 의 박자 규칙이 맞다는 증거다.

7-2. 진짜 화면을 진짜 단추로

가짜 원천 시험은 엔진만 본다. 화면 캡처 경로(mss)와 창의 단추가 실제로 이어지는지는 따로 봐야 한다. scripts/realtest.py 는 0.1초마다 숫자가 오르는 「내용 창」을 띄우고, 프로그램 창의 「시작」「표식」「일시정지」「재개」「정지」 단추를 차례로 누른다. 마이크가 없는 PC 에서도 돌아야 하므로 소리는 끈다. 클라우드의 가상 X 화면(1280×800)에서 돌린 결과다.

  ok   시작 단추로 세션 시작        mic: None  system: None
  ok   마이크 없으면 소리 트랙 없이 진행
  ok   진짜 ScreenSource
  ok   표식 단추 / 일시정지 단추 / 일시정지 중 프레임 안 늘어남 / 재개
  ok   마무리 중엔 단추 잠김
       2026.09.09., 10.41., 실기.mp4  streams=['video']  dur=5.10  elapsed=5.10
  ok   길이 = 실제 녹화 시간(일시정지 제외)
  ok   1초 간격 정지 화면 5장 이상 / 정지 화면이 서로 다름
전부 통과      (30fps × 5.10초 = 153 프레임, 인코딩된 프레임 153)
녹화 결과 정지 화면
결과 MP4 에서 1초 간격으로 뽑은 정지 화면(1·3·5초). 내용 창의 숫자가 0019 → 0039 → 0073 으로 오르고, 프로그램 창의 시계도 00:00:00 → 02 → 04 로 함께 찍혔다. 3초와 5초 사이에 숫자가 34 오른 것은 그 사이에 1초 일시정지가 들어 있기 때문이다.

마이크가 없으면 소리 항목이 저절로 꺼지고 영상 트랙만 있는 MP4 가 나온다. 이 경우 ffmpeg 합치기 단계는 -c:v copy -an 으로 지나가므로 정지 뒤 기다림이 거의 없다.

8. 남은 일