본문 바로가기
C.W.K.
Stream
Lesson 06 of 07 · published

테이크 하나, 호출 하나, 페이지 하나

~15 min · tts, measurement, provider-behavior, evolution

Level 0불 꺼진 작업장
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

호출 아흔 번, 목소리 아흔 개

앞의 두 강의에서 오디오 파이프라인은 작은 단위 위에 지어져 있었어. 대개 한두 문장짜리인, 언어가 하나뿐인 구간을 하나씩 따로 호출해서 합성하고, 구간 사이 틈은 손으로 적어 넣는 식이지. 그 모양은 섞인 언어가 엉뚱한 억양으로 읽히는 실패 하나를 막는 방패였고, 제 일을 했어. 대신 뭘 치르고 있었는지는 대화 시리즈가 내레이션을 한국어로 바꾼 뒤에야 귀에 들렸어. 두 번째 에피소드 대본이 문장 크기 호출 아흔 개쯤으로 나갔는데, 톤이 조금씩 다른 아흔 개로 돌아왔거든. 음성 모형은 생성 호출마다 톤을 새로 잡아. 그래서 잘게 썬 대본은 조각 하나하나가 아무리 깨끗해도 기워 붙인 말투로 들려.

잘게 써는 걸 정당화하던 전제는 이미 소리 없이 만료돼 있었어. 내레이션이 한 언어고 외국어 용어는 목소리용 사본에서 전부 음차하니까, 섞인 언어라는 도박 자체가 더는 없었지. 그래서 규칙이 뒤집혔어. 공급자가 허락하는 한 제일 적은 호출로 합성해. 직접 시험해봤더니, 그 에피소드 대본 전체를 호출 하나에 넣은 게 시도해본 것 중에서 제일 자연스럽게 읽혔어.

받아주는 한도는 믿을 수 있는 한도가 아니야

공급자의 호출당 한도는 5,000 자였어. 그래서 다음 에피소드는 긴 호출을 썼는데, 4,625 자짜리 호출이 1,900 자쯤을 소리 없이 빼먹고 돌아왔어. 목소리가 대본 중간에서 마지막 문단으로 훌쩍 건너뛴 거야. 3,202 자짜리 호출은 끝 무렵에서 단어 중간에 멈췄고. 둘 다 종료 코드는 깨끗했고 길이도 그럴듯했어. 두 번째는 마지막 시청에서야 잡혔어. 요청 한도는 공급자가 받아주는 크기지, 믿고 맡길 수 있는 크기가 아니야.

그래서 자리 잡은 관행은 한도 숫자가 아니라 측정에서 나왔어. 테이크는 2,600 자 아래로 두고, 대본에서 제일 큰 비트 경계에서 잘라. 톤이 제일 고르던 에피소드들은 2,000 자 근처였는데, 34 분에 걸친 테이크 여섯 개의 음량 차이가 0.5 dB 안에 들어왔어. 그리고 테이크는 전부 커버리지 관문을 통과해야 해. 테이크가 실제로 읽은 말을 대본과 맞춰보는 관문이지. 마지막 큐가 반드시 있어야 하고, 큐가 세 개 연달아 빠지면 실패고, 전체 비율에도 하한선이 걸려. 테이크 사이 이음매에선 톤이 조금 바뀌는 게 들려. 그래서 규칙은 이래. 테이크 수는 한도가 허락하는 한 제일 적게, 이음매는 톤 변화가 의도로 읽힐 수 있는 전환점에 둬. 그리고 옆 테이크들 사이에서 혼자 톤이 튀는 섬이 될 만큼 짧은 테이크는 안 만들어.

언어를 밝힌 한 페이지

테이크의 모양을 정한 실패가 둘 더 있어. 음성 엔진의 분할기는 태그 없는 텍스트를 한글 없는 문장마다 잘랐어. 그래서 한국어 내레이션 안의 영어 이름 하나가 따로 떨어져 예열 안 된 호출이 됐고, 그 뒤 한국어는 예열 없이 새로 시작했지. 게다가 줄바꿈은 아예 공급자한테 닿지 않았어. 분할기가 모든 문장을 공백 하나로 이어 붙였거든. 쉼을 달라고 대본에 넣은 빈 줄은 한 번도 전달된 적이 없었던 거야. 둘 다 음성 엔진 안에서 고쳐졌고, 둘 다 이제 테이크 모양의 일부야. 테이크는 언어를 밝히고 한 덩어리로 가고, 한 페이지로 나가. 한 장면 안의 큐 사이엔 줄바꿈 하나, 장면 사이엔 빈 줄 하나를 넣어. 공급자는 그걸 서로 다른 숨으로 읽어. 그 밑에 깔린 지시는 단순해. 기계한테 문자열을 욱여넣듯 대하지 말고, 사람이 쓴 문서를 사람이 소리 내 읽는다고 생각하라는 거야.

테이크는 예열된 채로 시작하기도 해. 첫 테이크 앞엔 담백한 큐 하나를 붙이고, 그 뒤 테이크들은 바로 앞 테이크의 마지막 큐 두 개를 자기 언어로 빌려 와. 예열 문장은 합성 텍스트와 캐시 키에만 들어가고, 완성된 오디오엔 절대 안 남아.

시계는 짐작이고, 진실은 오디오야

테이크마다 긴 호출 하나로 가면, 자막 시간을 맞출 문장별 길이가 따로 없어. 그래서 정렬은 공급자의 글자별 타임스탬프에서 와. 문장 안에선 10 밀리초 안팎으로 정확해. 그런데 문단이 바뀌는 곳에선 달라. 측정해본 테이크 하나에선 첫 단어가 시계가 말한 것보다 1 초 넘게 늦게 나왔어. 그래서 예열을 잘라낼 지점은 자르기 전 오디오를 받아쓰게 해서 진짜 첫 단어를 찾아 정해. 자른 뒤엔 첫머리를 다시 받아써서 예열이 안 남았는지 확인하고. 그것조차 증거가 아니라 어림이야. 기준 에피소드에선 짧은 받아쓰기 창이 오프닝이 깨끗하다고 보고했는데도 남은 단어가 여전히 들렸어. 그걸 잡아낸 건 직접 귀로 들은 사람뿐이었고.

마지막 수선은 숨이야. 어떤 테이크는 텍스트 자체 때문에 말이 급해진 채로 돌아와. 문장 사이 틈이 1 초쯤에서 0.5 초 아래로 반토막이 나. 다시 뽑아도 안 나아져. 그 텍스트는 몇 번을 뽑든 같은 상태로 떨어지거든. 그래서 결정론적인 처리 하나가 짧아진 문장 틈을, 틈 안에서 제일 조용한 지점을 골라 측정해둔 목표 길이까지 늘려. 실제로 조용하지 않은 지점은 건너뛰고, 끼워 넣을 때마다 시계도 같이 밀어. 목소리 자체엔 절대 손 안 대.

누가 전제를 확인하지 않는 한, 방패는 자기 전제보다 오래 살아. 잘게 나눈 합성은 섞인 언어 호출에 대한 옳은 방어였어. 그런데 그 위험이 사라지자 슬그머니 더 나쁜 결함의 원인이 됐지. 제약을 정당화하던 조건이 바뀌면 그 제약의 비용을 다시 매겨. 어느새 파이프라인에서 제일 비싼 게 돼 있을 수도 있어.

Code

테이크 파이프라인, 단계별로·text
author cues by SOUND      numbers spelled as spoken; foreign terms
                          transliterated in the voice's copy only
group cues into takes     <= ~2,600 sent chars, cut at the largest
                          beat boundary; no tone islands
one call per take         language declared -> one chunk
                          "\n" between cues, "\n\n" between scenes
                          warm-up prepended (text + cache key only)
character clock returns   exact inside a sentence, early across breaks
cut the warm-up           located by ASR on the untrimmed audio,
                          opening re-read after the cut
restore breaths           short gaps -> sentence 0.9s / cue 1.0s /
                          scene 1.2s at the quietest 20 ms; skipped
                          if louder than -38 dBFS; clock shifted
persist the final clock   alignment and subtitles computed from it
gates                     coverage (tail present, no 3-miss run),
                          window shape; pace flagged for the watch
커버리지: 테이크는 대본이 시킨 말을 해야 해·python
def coverage_gate(script_cues: list[str], heard: list[str]) -> list[str]:
    """Compare the cues a take was asked to speak with what it actually voiced.

    Exit codes and durations cannot see a skipped middle or a lost
    tail: the provider returns a plausible file either way.
    """
    found = [cue_heard(cue, heard) for cue in script_cues]
    problems = []
    if not found[-1]:
        problems.append("tail truncated: final cue not in the audio")
    run = 0
    for i, ok in enumerate(found):
        run = 0 if ok else run + 1
        if run >= 3:
            problems.append(f"mid-take skip ending at cue {i}")
            break
    if sum(found) / len(found) < 0.75:
        problems.append("coverage below floor")
    return problems  # any entry -> the take is deleted, not cached

External links

Exercise

외부 서비스한테 일을 잘게 쪼개서 여러 번 호출하는 파이프라인을 하나 찾아봐. 번역, 요약, 임베딩, 합성 같은 거. 호출 사이 맥락에 기대는 성질을 하나 재봐. 이웃한 조각들 사이의 톤이나 용어나 형식이 얼마나 한결같은지. 그다음 서비스 문서에 적힌 제일 큰 묶음을 시험하고, 넣은 게 실제로 전부 돌아왔는지 확인해. 믿을 만한 묶음 크기랑, 묶음마다 빠진 게 없다는 걸 증명하는 검사를 적어봐.
Hint
노려야 할 건 소리 없는 잘림이야. 형식도 멀쩡하고 모양도 맞는데 중간이나 끝이 빠진 응답. 상태 코드나 그럴듯한 길이를 믿지 말고, 보낸 것과 받은 걸 내용 수준에서 대조해. 항목 개수, 그리고 마지막 항목이 있는지.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.