녹음 파일만 넣으면 전사되는 로컬 파이프라인 만들기

Whisper

강의나 회의 내용을 다시 확인하기 위해 녹음을 시작했다.

처음에는 녹음 내용을 GPT에 넘겨 정리하는 방식으로 사용했다. 이미 텍스트로 만들어진 강의 내용을 요약하거나 질문하는 용도로는 괜찮았지만, 녹음 원본부터 계속 처리하기에는 불편했다. 긴 강의 내용을 반복해서 넣으면 토큰도 계속 사용하게 된다.

전사 서비스로는 네이버 클로바노트 같은 선택지도 있었지만 일부 기능이 유료였다. 앞으로 강의와 회의를 계속 녹음한다면 파일이 쌓일 때마다 비용을 신경 쓰고 싶지는 않았다.

마침 Whisper는 오픈소스로 공개되어 있고 Mac에서 직접 실행할 수 있다.

그러면 전사 단계는 아예 로컬에서 끝내고, 만들어진 전사본만 내가 사용하고 있는 Obsidian Second Brain에 쌓으면 되지 않을까 생각했다.

여기서 transcribe-inbox를 만들기 시작했다.

목표는 단순했다.

녹음 파일을 특정 폴더에 넣으면 나머지는 아무것도 하지 않아도 된다.


전사 프로그램보다 전사 파이프라인이 필요했다

Whisper를 한 번 실행하는 것만 생각하면 별도의 프로그램을 만들 필요까지는 없었다.

CLI에서 파일 하나를 지정하고 전사하면 된다.

문제는 이 작업을 강의가 끝날 때마다 계속 반복해야 한다는 것이었다.

녹음 파일 찾기
→ Whisper 실행
→ 끝날 때까지 기다리기
→ 결과 파일 확인
→ Obsidian으로 옮기기
→ 원본 녹음 보관

이 작업을 계속하고 싶지는 않았다.

그렇다고 개인용 도구를 위해 별도의 웹 화면이나 GUI를 만드는 것도 일이 커진다. CLI조차 매번 명령어를 입력해야 한다는 점에서는 내가 원하는 사용 방식과 달랐다.

그래서 파일 시스템 자체를 UI로 사용하기로 했다.

현재 실제 사용 흐름은 대략 이렇다.

Galaxy에서 녹음
    ↓
카카오톡으로 Mac에 전달
    ↓
Finder에서 ~/Transcribe/inbox/ 아래로 이동
    ↓
끝

전체 구조

현재 파이프라인은 다음과 같이 동작한다.

flowchart TD
    A[Galaxy 녹음 / 온라인 강의 영상] --> B[Mac으로 파일 전달]
    B --> C["~/Transcribe/inbox/<category>/..."]

    C --> D[watchdog 파일 감지]
    D --> E{입력 모드}

    E -->|asr| F[파일 안정화 확인]
    E -->|diarize| F
    E -->|asr-multitrack| G[세션 Quiet Period 확인]

    F --> H[Content Hash 계산]
    G --> H

    H --> I[(PostgreSQL Job 등록)]
    I --> J[Worker가 PENDING Job 순차 처리]

    J --> K[ffmpeg<br/>16kHz Mono WAV]

    K --> L{Processing Mode}

    L -->|asr| M[whisper.cpp]
    L -->|asr-multitrack| N[트랙별 whisper.cpp]
    L -->|diarize| O[whispermlx<br/>ASR + Alignment + Diarization]

    N --> P[화자 라벨 + Timeline Merge]

    M --> Q[TranscriptDocument]
    O --> Q
    P --> Q

    Q --> R["transcript.json / md / txt / srt"]
    R --> S[Obsidian Second Brain]

    R --> T[Job COMPLETED]
    T --> U[macOS 알림]
    U --> V["~/Transcribe/archive/"]

백그라운드 프로세스는 macOS launchd로 상주시켰다.

전사 내용 자체를 PostgreSQL에 저장하지는 않는다. PostgreSQL은 어떤 파일이 대기 중인지, 처리 중인지, 성공했는지, 실패했는지 같은 Job 상태와 메타데이터를 관리하는 용도로만 사용한다.

전사 결과와 원본 파일은 계속 파일 시스템에 남는다.


폴더 구조로 카테고리와 처리 방식을 결정했다

별도의 설정 파일에서

컴퓨터네트워크 = 강의
캡스톤 = 회의

같은 값을 관리하고 싶지는 않았다.

그래서 inbox의 경로 자체가 설정이 되도록 했다.

대표적으로 다음과 같이 사용할 수 있다.

~/Transcribe/inbox/
├── 컴퓨터네트워크/
│   └── 2주차.m4a
│
├── 캡스톤/
│   └── diarize/
│       └── 회의.m4a
│
└── 프로젝트회의/
    └── asr-multitrack/
        └── 2026-09-14/
            ├── user-a.m4a
            └── user-b.m4a

현재 처리 모드는 세 가지다.

모드 사용 대상 엔진 화자 구분
asr 강의처럼 화자 구분이 필요 없는 녹음 whisper.cpp 없음
asr-multitrack 화자별 파일이 이미 분리되어 있는 녹음 whisper.cpp 파일명 사용
diarize 하나의 녹음 안에 여러 화자가 섞여 있는 회의 whispermlx 모델이 화자 분리

기본값은 asr이다.

따라서 일반적인 강의 파일은 그냥 다음 위치에 넣으면 된다.

~/Transcribe/inbox/컴퓨터네트워크/2주차.m4a

asr 폴더를 생략해도 된다.

파일 이름 규칙도 따로 없다. 카테고리를 추가하기 위해 코드를 수정할 필요도 없다. 새로운 폴더를 만들면 그 이름이 그대로 카테고리가 된다.


모든 녹음을 같은 방식으로 처리하지 않았다

처음에는 Whisper 엔진 하나로 모두 처리하는 구조도 생각할 수 있었다.

하지만 입력 형태가 이미 다르다.

강의는 화자 분리가 필요 없다

교수 한 명의 강의나 온라인 강의 녹화본처럼 화자를 굳이 구분할 필요가 없는 파일이 가장 많다.

이 경우 기본 엔진으로 whisper.cpp를 사용한다.

현재는 정확도를 우선해서 large-v3 모델을 사용하고 있다.

asr
  ↓
ffmpeg
  ↓
whisper.cpp
  ↓
TranscriptDocument

Apple Silicon에서 로컬로 돌리는 것이 목적이었고, 별도의 클라우드 API 호출은 없다.


이미 화자가 분리되어 있다면 다시 화자 분리를 할 필요가 없다

Discord 등에서 녹음하면 화자별 오디오 트랙을 따로 얻을 수 있는 경우가 있다.

예를 들어 다음과 같다.

회의/
├── user-a.m4a
├── user-b.m4a
└── user-c.m4a

이미 user-a.m4a가 누구의 음성인지 알고 있는데, 하나의 파일로 합친 다음 화자 분리 모델에게 다시 누가 말했는지 추론하게 할 이유는 없다.

그래서 asr-multitrack에서는 각 파일을 따로 whisper.cpp로 전사한다.

파일명은 그대로 화자 라벨로 사용한다.

user-a.m4a
    ↓
speaker = user-a

각 트랙에서 나온 Segment를 시간순으로 다시 합치면 하나의 회의록이 된다.

Track A ────────●──────●──────────●
Track B ───●────────●────────●─────

                    ↓

         timestamp 기준 정렬

                    ↓

A: ...
B: ...
A: ...
B: ...

트랙별 offset_seconds도 데이터 구조에는 포함했다.

서로 다른 시점에 녹음이 시작된 파일까지 나중에 하나의 절대 시간축으로 맞출 수 있도록 고려한 구조다.

다만 현재 실제 watcher가 등록하는 일반적인 multitrack 입력은 모두 offset_seconds = 0이다. 시작 시각을 자동으로 계산하는 기능까지 구현한 것은 아니다.


하나의 파일에 여러 사람이 섞여 있을 때만 diarization을 사용했다

하나의 마이크로 여러 사람이 대화한 회의는 상황이 다르다.

이 경우에는 누가 말했는지 추가 추론이 필요하다.

diarize 모드에서는 whispermlx를 사용한다.

처리 과정도 asr보다 많다.

Audio
 ↓
ASR
 ↓
Word Alignment
 ↓
Speaker Diarization
 ↓
Speaker Assignment
 ↓
Re-segmentation

결과적으로 각 Segment에 익명 화자 라벨이 붙는다.

[00:01:20] SPEAKER_00: ...
[00:01:25] SPEAKER_01: ...

화자 이름 자체를 식별하는 것은 아니다.

누가 SPEAKER_00인지는 전사본을 보고 사람이 판단해야 한다.

이미 화자별 파일이 있는 경우 asr-multitrack을 우선하는 이유도 여기 있다. 화자 분리는 추론이지만, 트랙별 녹음에서는 파일 자체가 이미 화자를 구분하고 있다.


입력 포맷을 엔진에 맡기지 않았다

녹음 파일은 .m4a만 들어오는 것이 아니다.

온라인 강의를 녹화하면 .mp4나 .mov 같은 비디오 파일이 들어올 수도 있다.

각 엔진에서 파일 포맷을 직접 처리하도록 두는 대신, 전사 전에 모든 입력을 한 번 정규화하기로 했다.

args = [
    FFMPEG_BINARY,
    "-y",
    "-i",
    str(source_path),
    "-ar",
    "16000",
    "-ac",
    "1",
    str(output_path),
]

입력은 모두 ffmpeg를 거쳐 16kHz mono WAV가 된다.

m4a ──┐
mp4 ──┤
mov ──┼→ ffmpeg → 16kHz mono WAV → ASR
... ──┘

여기서 무음 구간을 제거하지는 않는다.

당장 모든 전사본에서 원본 타임라인이 반드시 필요한 것은 아니었지만, 나중에

전사본의 42분 10초 부분을 원본에서 다시 듣고 싶다.

같은 사용이 필요해질 수 있다고 봤다.

그래서 전처리 과정에서 시간축 자체를 바꾸지 않는 방향으로 구현했다.


파일이 생겼다고 바로 전사하면 안 됐다

폴더 감지를 붙이면서 가장 먼저 고려해야 했던 것은 파일 복사 완료 여부였다.

Galaxy에서 가져온 녹음이나 온라인 강의 영상은 파일 크기가 꽤 클 수 있다.

문제는 watchdog이 파일 생성 이벤트를 받는 시점과 파일 복사가 끝나는 시점이 같지 않다는 것이다.

파일 생성 이벤트
      ↓
[=====>             ] 복사 중
      ↓
전사 시작?

이 상태에서 바로 ffmpeg를 실행하면 아직 완성되지 않은 파일을 처리할 수 있다.

그래서 asr과 diarize 파일은 크기가 일정 시간 동안 변하지 않는지 확인한 뒤 Job으로 등록한다.

현재 기준은 5초다.

size = 100MB
5초 대기
size = 100MB

→ stable

이 값은 벤치마크를 통해 계산한 최적값은 아니다.

개인용 파이프라인에서 사용하기 위해 정한 운영값이다.


멀티트랙은 파일 하나가 안정됐다고 끝난 게 아니었다

asr-multitrack은 더 복잡했다.

예를 들어 회의 세션에 두 개의 트랙을 넣는다고 해보자.

meeting/
├── user-a.m4a
└── user-b.m4a

user-a.m4a의 복사가 끝났다고 해서 세션 전체가 완성된 것은 아니다.

그 직후 user-b.m4a가 들어올 수 있다.

따라서 개별 파일 안정화가 아니라 세션 폴더에 일정 시간 새로운 활동이 없었는가를 기준으로 판단했다.

현재는 60초다.

user-a.m4a 도착
      ↓
30초 뒤
      ↓
user-b.m4a 도착
      ↓
60초 동안 새 이벤트 없음
      ↓
세션 완료로 판단

60초 역시 정량적으로 최적화한 값은 아니다.

트랙을 순서대로 넣는 중간에 세션이 먼저 등록되는 상황을 막기 위해 여유 있게 잡은 값이다.


mtime으로 60초를 계산하면 안 됐다

멀티트랙의 quiet period를 구현하면서 처음에는 파일의 mtime을 다시 확인하는 방법도 가능했다.

예를 들어 가장 최근 파일의 수정 시간이 60초 이상 지났다면 세션이 조용하다고 보는 방식이다.

그런데 Finder에서 같은 볼륨 안의 파일을 드래그해 이동하면 파일이 새로 복사되는 것이 아니라 mv되는 경우가 있다.

이때 기존 파일의 mtime이 그대로 유지된다.

예를 들어 다른 폴더에서 10분 전에 만들어진 파일을 지금 세션 폴더에 옮겼다고 해보자.

실제 이동 시각
10:10

파일 mtime
10:00

mtime만 보면 시스템은 이 파일이 10분 동안 아무 변화가 없었다고 판단할 수 있다.

파일 들어옴
  ↓
mtime 확인
  ↓
이미 60초보다 오래됨
  ↓
세션 완료?

그러면 아직 다른 트랙을 넣기도 전에 불완전한 세션이 등록될 수 있다.

이 문제는 실제 운영 장애를 겪고 고친 것은 아니고, 구현을 검증하는 과정에서 발견했다.

현재 라이브 watcher에서는 파일의 mtime 대신

watchdog이 해당 이벤트를 실제로 관찰한 시각

을 저장한다.

class PendingSession(NamedTuple):
    last_event_at: float
    category: str

트랙이 들어올 때마다 last_event_at이 갱신되고, 현재 시각과 이 값의 차이가 60초 이상일 때만 등록한다.

파일 자체가 언제 만들어졌는지가 아니라 우리 프로세스가 언제 그 변화를 봤는지가 기준이 된 것이다.

다만 프로세스가 재시작되면 이전 이벤트 시각은 메모리에서 사라진다. 이때는 실제 이벤트 기록을 알 수 없으므로 startup scan에서는 파일 mtime을 fallback으로 사용한다.

라이브 처리와 재시작 복구가 서로 다른 기준을 사용하는 이유다.


같은 녹음을 두 번 처리하지 않기 위해 PostgreSQL을 넣었다

단순한 개인용 watcher라면 데이터베이스 없이도 만들 수 있다.

파일 발견
→ 전사
→ 이동

처음에는 이것만으로 충분해 보인다.

하지만 백그라운드에서 계속 동작시키려니 상태가 필요해졌다.

예를 들어 다음 질문들이다.

  • 이 파일은 이미 처리했는가?
  • 지금 처리 중인 파일인가?
  • 실패한 작업인가?
  • 프로세스가 중간에 죽은 작업인가?
  • 전사는 끝났는데 원본 이동만 실패했는가?

파일 위치만 보고 이런 상태를 추론하고 싶지는 않았다.

그래서 PostgreSQL에 Job을 두었다.

현재 상태는 네 가지다.

stateDiagram-v2
    [*] --> PENDING
    PENDING --> PROCESSING
    PROCESSING --> COMPLETED
    PROCESSING --> FAILED
    FAILED --> PENDING: retry

DB에는 전사 내용 자체를 저장하지 않는다.

대략 다음 정보를 관리한다.

source_path
source_hash
category
processing_mode
status
engine
model
metrics
error_message
retry_count
...

중복 여부는 파일명이 아니라 콘텐츠 해시를 기준으로 판단한다.

같은 녹음 파일의 이름만 바꿔 다시 넣어도 동일한 Job으로 판단할 수 있다.


SELECT하고 INSERT하면 된다고 생각했다

register_job의 초기 아이디어는 단순했다.

source_hash로 기존 Job 조회

없음
→ INSERT

있음
→ 기존 상태에 따라 처리

일반적인 check-then-act 구조다.

문제는 watcher와 worker가 동시에 움직이고 PostgreSQL connection도 나뉘기 시작하면서 나타났다.

예를 들어 두 connection이 거의 동시에 같은 파일을 등록한다고 해보자.

Connection A                    Connection B

SELECT hash
→ 없음

                                SELECT hash
                                → 없음

INSERT
→ 성공

                                INSERT
                                → UNIQUE VIOLATION

각각의 SELECT 시점에서는 둘 다 데이터가 없었다.

조회와 쓰기가 하나의 원자적 연산이 아니기 때문에 중간에 다른 connection이 끼어들 수 있다.

처음에는 connection을 나누거나 transaction 처리를 조정하면서 문제를 해결하려 했다.

하지만 결국 SELECT → 판단 → INSERT 구조 자체를 없앴다.

PostgreSQL의 ON CONFLICT를 사용했다.

INSERT INTO transcription_job (
    id,
    source_path,
    source_hash,
    category,
    processing_mode,
    tracks,
    status
)
VALUES (
    %s,
    %s,
    %s,
    %s,
    %s,
    %s,
    'PENDING'
)
ON CONFLICT (source_hash) DO UPDATE
SET
    status = 'PENDING',
    source_path = EXCLUDED.source_path,
    category = EXCLUDED.category,
    processing_mode = EXCLUDED.processing_mode,
    tracks = EXCLUDED.tracks
WHERE transcription_job.status = 'FAILED'
RETURNING id;

실제 현재 구현에는 PENDING 상태에서 파일 경로만 이동된 경우를 갱신하는 조건과 retry 관련 필드 처리도 추가되어 있다.

중요했던 변화는 조건 자체보다 이것이었다.

Before

SELECT
 ↓
Python에서 판단
 ↓
INSERT / UPDATE
After

INSERT ... ON CONFLICT
          ↓
PostgreSQL이 충돌 판단

애플리케이션이 여러 SQL 사이에서 상태를 판단하지 않고, DB가 가지고 있는 unique constraint와 원자적 연산에 판단을 맡겼다.

이 문제를 테스트하면서 mock만으로 DB 로직을 검증하는 데도 한계가 있었다.

실제 PostgreSQL의 transaction과 unique constraint가 어떻게 동작하는지를 확인해야 했기 때문에 로컬 Docker PostgreSQL에 테스트 전용 DB를 따로 만들어 통합 테스트를 실행했다.


watcher와 worker가 PostgreSQL connection 하나를 공유하면 안 됐다

또 다른 문제는 connection 공유였다.

처음에는 하나의 psycopg connection을 watcher와 worker가 같이 사용하는 구조가 있었다.

watchdog thread ─┐
                 ├── PostgreSQL Connection
worker thread ───┘

이 구조에서는 한쪽에서 실행한 rollback()이 다른 쪽의 아직 commit되지 않은 transaction에 영향을 줄 수 있다.

현재는 기본적으로 worker와 watcher가 서로 다른 connection을 사용한다.

worker_conn = connect_with_backoff(dsn)
watcher_conn = connect_with_backoff(dsn)

watcher 내부에서도 동일 connection을 여러 경로에서 사용할 수 있기 때문에 connection 단위 mutex를 따로 두었다.

이 문제 역시 사용 중 실제 데이터가 날아간 장애를 겪고 발견한 것은 아니다.

전체 흐름을 통합해서 검증하는 과정에서 확인한 동시성 문제였다.


프로세스가 죽어도 다시 이어갈 수 있어야 했다

launchd로 계속 실행하는 프로그램이라면 정상 종료만 생각할 수 없다.

Mac이 재부팅될 수도 있고 프로세스가 죽을 수도 있다.

가장 애매한 상황은 이런 경우다.

전사 완료
 ↓
Obsidian 발행 완료
 ↓
DB COMPLETED
 ↓
프로세스 종료
 ↓
원본 archive 이동은 실행되지 않음

결과물은 정상적으로 만들어졌는데 원본이 여전히 inbox에 남는다.

이걸 단순히 다시 신규 파일로 처리하면 전사를 한 번 더 돌릴 수도 있다.

그래서 프로그램 시작 시 reconciliation을 실행한다.

Daemon 시작
   ↓
기존 Job 상태 확인
   ↓
PROCESSING인데 멈춰 있음?
   ↓
재처리 또는 실패 정리

COMPLETED인데 source가 inbox에 남아 있음?
   ↓
내용 hash 재확인
   ↓
archive만 재시도

daemon이 꺼진 동안 새 파일이 들어왔음?
   ↓
startup scan에서 등록

특히 COMPLETED인데 원본이 남아 있는 경우에는 경로만 믿고 옮기지 않는다.

현재 해당 경로에 존재하는 파일의 content hash를 다시 계산하고, 기존 Job의 source_hash와 같을 때만 archive한다.

같은 경로에 이미 다른 녹음 파일이 들어온 상태라면 예전 Job의 원본이라고 착각하고 이동시키면 안 되기 때문이다.


결과 파일도 중간 상태를 보여주고 싶지 않았다

전사가 끝나면 결과 파일 네 개를 만든다.

transcript.json
transcript.md
transcript.txt
transcript.srt

transcript.json을 정본으로 두고, 나머지 형식은 사람이 읽거나 다른 프로그램에서 사용할 수 있도록 만든다.

Obsidian에서는 주로 Markdown을 보게 된다.

[00:00:00] 아 그럼 완전 똑같은 건데?

[00:00:01] 그니까

[00:00:02] 이제야 22일?

[00:00:06] 언제까지?

여기서도 파일을 하나씩 최종 디렉터리에 쓰지 않았다.

예를 들어 세 번째 파일을 쓰다가 프로세스가 죽으면 다음과 같은 결과가 생길 수 있다.

transcript.json  ✓
transcript.md    ✓
transcript.txt   쓰는 중
transcript.srt   없음

Obsidian에서는 이미 해당 디렉터리가 보이기 때문에 사용자는 완성된 결과인지 실패한 결과인지 구분하기 어렵다.

그래서 staging directory에 모든 파일을 먼저 만든다.

staging/
├── transcript.json
├── transcript.md
├── transcript.txt
└── transcript.srt

전부 정상적으로 작성된 뒤 최종 디렉터리로 rename한다.

staging 완성
   ↓
rename
   ↓
Obsidian에 한 번에 등장

전사 결과를 생성하는 과정도 하나의 publish 단위로 본 것이다.


원본 녹음은 전사본보다 더 중요했다

전사가 완료되면 원본은 inbox에서 archive로 이동한다.

예를 들어 실제 구조는 다음과 같은 형태다.

~/Transcribe/
├── inbox/
│   ├── 강의/
│   └── 프로젝트/
│
└── archive/
    ├── 강의/
    │   └── asr/
    │
    └── 프로젝트/
        └── diarize/

원본을 지우지 않는 이유는 명확하다.

전사 결과가 항상 완벽하다고 보장할 수 없기 때문이다.

나중에 Second Brain에서 어떤 내용을 찾았는데 문장이 이상하다면 원본 녹음의 해당 시점을 다시 들어볼 수 있어야 한다.

전사본은 다시 만들 수 있지만 원본은 한 번 잃어버리면 복구할 수 없다.

이 원칙 때문에 archive 로직에서도 문제가 하나 있었다.

초기에는 목적지에 같은 이름의 파일이 존재하면 기존 파일을 지우고 새로운 파일을 넣는 코드가 있었다.

archive/강의/asr/2주차.m4a 존재

새로운 2주차.m4a 도착
       ↓
기존 2주차.m4a 삭제
       ↓
새 파일 이동

같은 이름이라고 같은 녹음이라는 보장은 없다.

재녹음을 했을 수도 있고, 우연히 같은 이름을 사용했을 수도 있다.

이 상태에서는 예전 원본이 영구적으로 사라진다.

현재는 기존 파일을 절대 삭제하지 않는다.

같은 이름이 존재하면 새로운 파일의 content hash 일부를 붙인다.

2주차.m4a
2주차-1a2b3c4d.m4a

그 이름까지 이미 있다면 숫자를 하나 더 붙인다.

2주차-1a2b3c4d-1.m4a
2주차-1a2b3c4d-2.m4a

멀티트랙 세션 폴더도 같은 정책을 사용한다.

이 문제도 실제로 원본을 잃은 뒤 수정한 것은 아니다.

코드를 검증하는 과정에서 데이터 손실 가능성을 발견했고, 실제 사용 전에 정책을 바꿨다.


whispermlx에서는 파일 경로 대신 배열을 넘기게 됐다

launchd로 백그라운드 실행을 준비하면서 실행 환경 차이도 고려해야 했다.

터미널에서 직접 실행한 프로그램과 launchd가 실행한 프로그램은 환경이 같지 않다.

특히 로그인 셸에서 사용하는 PATH를 당연히 사용할 수 있다고 가정하면 안 된다.

whispermlx에는 오디오 파일 경로를 그대로 넘길 수 있지만, 사용 중인 라이브러리 코드를 확인해보니 경로 입력의 일부 처리에서 ffmpeg를 외부 프로세스로 실행하는 경로가 있었다.

launchd 환경에서 해당 실행 파일을 찾지 못하면 diarize 전체가 실패할 수 있다.

그래서 이미 앞 단계에서 16kHz mono WAV로 만들어둔 파일을 Python에서 직접 읽는다.

def _read_wav_as_float32(wav_path: Path) -> np.ndarray:
    with wave.open(str(wav_path), "rb") as wav_file:
        raw = wav_file.readframes(wav_file.getnframes())

    return (
        np.frombuffer(raw, dtype=np.int16)
        .astype(np.float32)
        / 32768.0
    )

그리고 whispermlx에 경로가 아니라 배열을 전달한다.

Before

normalized.wav
     ↓
whispermlx(path)
     ↓
라이브러리 내부에서 다시 파일 decode
After

normalized.wav
     ↓
numpy float32
     ↓
whispermlx(array)

ASR, alignment, diarization이 모두 같은 배열을 사용한다.

이미 파이프라인에서 한 번 정규화한 오디오를 라이브러리 내부에서 다시 디코딩할 필요도 줄었다.

이 역시 실제 운영에서 diarize가 계속 실패해서 발견한 장애는 아니었다.

백그라운드 실행 환경을 검증하는 과정에서 발견한 문제였다.


화자 분리 결과를 그대로 출력하지 않았다

diarize는 word 단위 화자 정보까지 만들어낸다.

하지만 그 단어를 그대로 한 줄씩 출력할 수는 없다.

SPEAKER_00: 오늘
SPEAKER_00: 회의는
SPEAKER_00: 여기까지

그래서 같은 화자의 연속 발화는 다시 Segment로 묶는다.

현재 규칙은 다음과 같다.

  • 화자가 바뀌면 새로운 Segment
  • 같은 화자라도 이전 단어와 2초를 초과해서 떨어져 있으면 새로운 Segment
MAX_PAUSE_SECONDS = 2.0

예를 들어:

A: 오늘
0.2초
A: 회의는
0.3초
A: 여기까지입니다

라면 하나로 합친다.

A: 오늘 회의는 여기까지입니다

반대로:

A: 오늘 회의는
3초
A: 여기까지입니다

라면 두 Segment가 된다.

2초도 정량 실험으로 도출한 값은 아니다.

현재 읽기 좋은 결과를 만들기 위해 사용 중인 threshold다.


터미널을 열지 않으려고 만든 프로그램이라 알림도 필요했다

백그라운드 도구를 만들어놓고 현재 상태를 확인하려고 매번 터미널을 열면 원래 목적과 맞지 않는다.

그래서 macOS notification을 사용한다.

처음에는 완료와 실패만 알렸는데, 지금은 전사 시작도 알린다.

전사 시작
컴퓨터네트워크 · 2주차.m4a
전사 완료
컴퓨터네트워크 · 2주차.m4a

또는

전사 실패
컴퓨터네트워크 · 2주차.m4a

현재 실제 사용에서도 이 알림은 꽤 유용하다.

큰 녹음 파일을 넣어놓고 다른 일을 하다가 시작됐는지, 끝났는지만 알림으로 보면 된다.


실제로 사용해봤다

구현 이후에는 실제 강의 녹음과 회의 녹음을 넣어 전체 파이프라인을 사용하고 있다.

파일 inbox에 이동
    ↓
Job 등록
    ↓
전사
    ↓
Obsidian 발행
    ↓
원본 archive

전체 흐름은 정상적으로 동작했다.

.m4a 음성 녹음뿐 아니라 오디오 트랙이 들어 있는 강의 영상도 ffmpeg에서 오디오만 추출해 같은 파이프라인으로 처리할 수 있다.

전사 정확도는 체감상 사용할 만한 수준이었다.

다만 정확도를 정량적으로 측정하지는 않았다.

따라서 이 글에서는

  • WER
  • 모델별 정확도 비교
  • 화자 분리 정확도

같은 수치는 다루지 않는다.

처리 속도도 별도로 벤치마크하지 않았다.

코드에는 처리 시간, Real Time Factor, 메모리 사용량 등을 기록하는 관측 기능이 있지만 아직 비교할 만큼 데이터를 모아 분석한 것은 아니다.


mock만으로는 확인하기 어려운 부분이 있었다

v1 구현을 마친 시점 기준으로 유닛 테스트는 70개, 실제 PostgreSQL을 사용하는 통합 테스트는 23개였다.

특히 DB 쪽은 mock만 사용하는 테스트로 끝내고 싶지 않았다.

이번에 문제가 됐던 것이 대부분 이런 종류였기 때문이다.

transaction
rollback
unique constraint
동시에 들어오는 connection

예를 들어 Python 코드에서

connection.execute(...)
connection.commit()

가 호출됐다는 것만 mock으로 확인해도 실제 PostgreSQL에서 concurrency 문제가 없는지는 알 수 없다.

그래서 기존에 Docker로 실행하고 있던 PostgreSQL에 프로젝트 전용 테스트 DB를 따로 만들고 실제 SQL을 실행했다.

Docker PostgreSQL
      ↓
transcribe_inbox_test
      ↓
register_job()
claim_next_pending_job()
reconciliation
watcher integration test

다른 프로젝트 DB와는 분리했다.

이번 프로젝트에서는 실제 DB 동작을 테스트하는 것이 단순히 테스트 커버리지를 늘리는 것보다 더 중요했다.


아직 해결되지 않은 것도 있다

현재 파이프라인이 실제 사용 가능한 상태라고 해서 모든 정책이 끝난 것은 아니다.

전사 결과 디렉터리 이름 충돌

원본 archive는 같은 파일명이 들어와도 content hash를 붙여 둘 다 보존한다.

하지만 Obsidian에 발행하는 전사 결과는 아직 같은 수준의 정책이 없다.

현재 출력 위치는 대략 다음처럼 파일명에 의존한다.

Transcripts/
└── 강의/
    └── 2주차/
        ├── transcript.json
        ├── transcript.md
        ├── transcript.txt
        └── transcript.srt

다른 내용인데 파일명이 같은 2주차.m4a가 다시 들어오면 결과 디렉터리 역시 2주차가 된다.

문제는 정책이 단순하지 않다는 점이다.

같은 Job을 다시 실행했다면 기존 결과를 교체하는 것이 맞을 수 있다.

반면 전혀 다른 녹음이 우연히 같은 이름을 쓴 것이라면 두 결과를 모두 보존해야 한다.

아직 둘을 구분하는 출력 정책을 정하지 않았다.


whisper.cpp 버전 고정

ASR 모델은 large-v3를 사용하고 있지만 whisper.cpp 자체의 특정 git tag나 commit까지 프로젝트에서 고정한 상태는 아니다.

설치 시점의 binary에 따라 동작 차이가 생길 여지가 있다.

개인 프로젝트에서는 지금도 사용할 수 있지만, 재현 가능한 환경이라는 관점에서는 남아 있는 기술 부채다.


여러 시간 기준값은 경험적 값이다

현재 코드에는 몇 가지 threshold가 있다.

파일 안정화: 5초
multitrack quiet period: 60초
같은 화자 segment merge pause: 2초

세 값 모두 벤치마크나 실험으로 최적점을 찾은 값은 아니다.

현재 사용 상황에서 충분하다고 판단해 둔 운영값이다.

파일 크기나 사용 패턴이 달라지면 다시 조정할 필요가 있다.


Galaxy에서 Mac으로 가져오는 과정은 아직 수동이다

현재는 녹음 파일을 카카오톡을 통해 Mac으로 가져오고 있다.

즉 자동화 범위는

Galaxy
  ↓
[수동 전달]
  ↓
Mac inbox
  ↓
[자동]
  ↓
Obsidian + archive

까지다.

향후에는 이 부분도 별도의 동기화 방식을 붙일 수 있겠지만, 당장 필요한 문제는 아니어서 범위에서 제외했다.


diarize는 credential이 하나 더 필요하다

diarize 모드는 화자 분리 모델 때문에 Hugging Face token이 필요하다.

현재 프로그램은 이를 환경변수에서 읽는다.

토큰을 코드나 공개 저장소에 넣지 않는 것은 당연하지만, 장기적으로 로컬 macOS daemon에서 credential을 어떤 방식으로 안전하게 주입하고 관리할 것인지는 파이프라인 구현과 별도로 운영 정책을 더 정리할 필요가 있다.


만들고 보니 Whisper보다 주변 코드가 더 많았다

처음 생각했던 모습은 단순했다.

파일
 ↓
Whisper
 ↓
텍스트

실제로 만든 것은 이쪽에 가까웠다.

파일 감지
 ↓
복사가 정말 끝났는지 확인
 ↓
경로 해석
 ↓
중복 검사
 ↓
Job 등록
 ↓
실패 복구
 ↓
오디오 정규화
 ↓
입력에 맞는 엔진 선택
 ↓
전사
 ↓
화자 처리
 ↓
결과물 여러 형식으로 생성
 ↓
원자적 발행
 ↓
상태 저장
 ↓
알림
 ↓
원본 안전하게 보존

전사 모델을 호출하는 부분은 전체 파이프라인에서 일부에 불과했다.

오히려 계속 사용하기 위한 도구로 만들면서 시간을 더 많이 쓴 것은 파일이 아직 복사 중인지 확인하고, 같은 녹음을 두 번 처리하지 않고, 프로세스가 죽어도 복구하고, 원본을 실수로 덮어쓰지 않는 부분이었다.

지금은 녹음 파일을 적절한 폴더에 넣고 나면 신경 쓰지 않는다.

전사가 끝나면 Obsidian Second Brain에 원본 전사체가 쌓이고, 필요할 때 그 내용을 다시 찾아보거나 GPT에 일부를 넘겨 정리할 수 있다.

요약까지 파이프라인의 책임으로 넣지는 않았다.

transcribe-inbox가 하는 일은 녹음을 다시 활용할 수 있는 형태의 원본 데이터로 바꾸고 안전하게 보관하는 것까지다.

처음 필요했던 것도 결국 그것이었다.

118 ❤️ 0

댓글

댓글을 작성하려면 GitHub 로그인이 필요합니다.

첫 댓글을 남겨보세요.

AI 챗봇
...