초대하고 적립

초대 보상 안내

초대 링크를 공유하세요. 친구가 링크로 가입하고 충전하면 이후 충전마다 표시된 보상을 받을 수 있습니다.

Gemini 3.5 Transcribe를 활용한 회의 전사 실무: 화자 분리, 타임스탬프, 커스텀 어휘 적용 가이드

Gemini 3.5 Transcribe를 활용해 전문 기술 용어가 포함된 45분 분량의 2인 비즈니스 회의 녹음본을 검증된 회의록 및 자막용 타임라인으로 변환하는 실무 가이드입니다. API 설정 플래그(smart 모드, diarization_mode, timestamp_granularities, custom_vocabulary)의 호환성 및 제약 사항을 분석하고, 30분 오디오 길이 제한을 극복하기 위한 오디오 분할 전략을 제시합니다. 공식 Python SDK를 이용해 2개 파트로 분할된 음원을 처리하고, 타임스탬프 오프셋 보정 및 화자 매핑을 거쳐 예비 데이터 구조를 생성하는 완전한 파이프라인을 다룹니다. 또한 자동 생성된 초안 파일(meeting_transcript.txt, word_timestamps.json)을 실무에 전달하기 전 수행해야 하는 청취 기반의 3단계 수동 품질 검증(화자 식별, 용어·수치 검증, 구간 연속성) 및 프로세스 중단 조건(Stop Conditions)을 상세히 설명합니다.

목차
Gemini 3.5 Transcribe를 활용한 회의 전사 실무: 화자 분리, 타임스탬프, 커스텀 어휘 적용 가이드

비즈니스 회의 녹음본을 신뢰할 수 있는 업무 문서로 변환하려면 음성 인식 파라미터를 설정할 때 신중한 아키텍처적 선택이 필요합니다. Gemini 3.5 Transcribe에는 모든 기능을 한 번에 해결해 주는 만능 ‘올인원(all-in-one)’ 모드가 존재하지 않습니다. 즉, 심층적인 문맥 정규화 편집(smart), 발화자 분리(diarization_mode), 단어 수준의 세밀한 시간 그리드(timestamp_granularities), 도메인 특화 어휘집(custom_vocabulary)은 기능적으로 서로 격리된 독립적인 API 기능입니다.

모든 전사 파라미터 파이프라인은 최종 결과물의 명확한 목표를 정의하는 것에서 출발해야 합니다. 빠르게 훑어볼 수 있는 간결하고 정제된 텍스트가 필요한지, 후속 분석을 위한 구조화된 데이터 기반이 필요한지, 아니면 영상 편집을 위한 정밀한 타임스탬프 배열이 필요한지 결정해야 합니다. 상호 배타적인 파라미터를 함께 조합해 요청을 전송하면 실제 처리가 시작되기도 전에 스키마 유효성 검증 오류(schema validation error)가 발생합니다.

다음은 전문 기술 용어가 오가는 2인 참가자의 45분 회의 녹음본을 준비하고, 공식 문서에 명시된 API 제약 사항을 해결하며, 공식 Python SDK로 두 개 파트를 처리하고, 예비 초안 데이터 구조를 조립한 뒤, 실제 오디오와 대조하여 검증·수정하여 회의록 담당자나 자막 편집자에게 인계하는 전체 실무 워크플로입니다.


아키텍처 제약 사항 및 파라미터 충돌

실무 파이프라인을 구체적으로 살펴보기 위해 일반적인 회의 시나리오를 가정해 보겠습니다. 테크 리드 알렉세이(Alexey)와 프로덕트 매니저 미하일(Mikhail)이 사내 프로젝트명인 DataPulseCloudForge 서비스의 마이그레이션 아키텍처 및 배포 계획을 45분 동안 논의합니다. 대화에는 전문 기술 용어, 외래어, 빠른 말 끊김, 말문 막힘과 말실수(false starts)가 빈번하게 등장합니다.

Gemini 3.5 Transcribe 연동을 설계할 때는 다음 세 가지 엄격한 규칙을 반드시 고려해야 합니다:

  • 커스텀 어휘와 구조적 메타데이터 간의 비호환성: 최대 1,000개 단어(실무 권장치는 최대 100개)를 지원하는 custom_vocabulary 파라미터는 diarization_modetimestamp_granularities와 동시에 전달할 수 없습니다. 따라서 설계 단계에서 명확한 트레이드오프를 결정해야 합니다. 즉, 모델에 특화된 고유 브랜드명의 정확한 전사를 맡기고 자동 화자 분리 및 타임스탬프 생성을 포기하거나, 정밀한 화자 구분과 시간 정렬을 요청하는 대신 후처리 단계에서 전문 용어를 직접 수동으로 검증해야 합니다.
  • Smart 모드와 타임라인 정렬 간의 비호환성: smart 모드는 발화 정규화(speech normalization)를 적용합니다. 이 과정에서 불필요한 추임새(filler words), 말더듬, 잘못 시작된 문장을 제거하고 문법 구조를 정돈합니다. 텍스트 토큰이 삭제되거나 병합되고 재배치되기 때문에, 모델은 정제된 최종 텍스트를 원본 오디오 스트림의 시간 축에 다시 매핑할 수 없습니다. 그 결과 smart 모드에서는 화자 구분 정보와 단어 수준 타임스탬프를 전혀 사용할 수 없습니다. 또한 이 모드는 주석 없는 연속 텍스트(output_text)만을 반환하며 조치 항목(action items)을 자동으로 추출해 주지 않습니다.
  • 고급 메타데이터 사용 시 30분 오디오 길이 제한: 상세 주석 메타데이터를 요청하지 않는 일반 전사 요청은 최대 60분 길이의 오디오 파일을 처리할 수 있습니다. 그러나 diarization_mode 또는 timestamp_granularities를 활성화하는 순간, 문서에 명시된 최대 파일 허용 길이는 30분으로 단축됩니다. 이 제한을 초과하는 파일을 전송하면 공식 API 사양을 벗어나므로 전송을 시도해서는 안 되며, 긴 오디오는 API 호출 전에 클라이언트 측에서 미리 분할해야 합니다.

45분 녹음본의 준비 및 분할

45분 분량의 녹음 파일에서 화자 분리와 단어 수준 타임스탬프를 모두 얻으려면 원본 파일을 30분 미만의 청크로 반드시 분할해야 합니다. 본 시나리오에서는 녹음본을 다음과 같이 두 개의 세그먼트로 나눕니다:

  • 파트 1: 00:00–25:00 (계산의 편의를 위해 정확히 1500.0초로 가정)
  • 파트 2: 25:00–45:00 (남은 20분, 즉 1200.0초)

1500.0초 분할 경계는 예시 계산을 단순화하기 위해 설정한 수치입니다. 실제 프로덕션 환경에서는 발화 턴 사이의 자연스러운 묵음 구간을 분할 지점으로 지정해야 하며, 첫 번째 세그먼트의 실제 물리적 재생 길이는 ffprobe와 같은 미디어 파일 검사 도구를 사용해 메타데이터에서 직접 추출해야 합니다.

오디오를 분할하면 데이터 연속성과 관련해 두 가지 중요한 문제가 발생합니다:

  1. 두 번째 세그먼트의 타임스탬프 리셋: API는 두 번째 세그먼트를 완전히 독립적인 파일로 인식하므로 내부 단어 오프셋이 다시 0.000s부터 시작됩니다. 전체 회의의 통합된 연속 타임라인을 재구성하려면 첫 번째 세그먼트의 실제 재생 시간을 두 번째 세그먼트에서 추출된 모든 단어 타임스탬프에 프로그래밍 방식으로 더해주어야 합니다.
  2. 화자 라벨의 로컬 종속성: 모델은 각 API 호출 단위마다 spk_1, spk_2와 같은 화자 식별자를 독립적으로 부여합니다. 첫 번째 청크에서 spk_1으로 지정된 참가자가 두 번째 청크에서는 spk_2로 바뀔 수 있습니다. 개별 요청 간의 동일한 기술 라벨을 검증 없이 그대로 합치면 화자 귀속이 완전히 왜곡됩니다. 따라서 각 청크마다 실제 오디오 청취 샘플링을 통해 실제 참가자와 매핑하는 독립적인 룩업 테이블(lookup table)을 작성해야 합니다.

작업 목적별 설정 변형

전사 파라미터는 generation_config 딕셔너리의 transcription_config 필드 내에서 구성합니다. 다음은 다양한 업무 목적에 맞춘 기본 구성 형태입니다.

화자 라벨이나 타임스탬프 없이 자연스럽게 정규화된 회의 노트를 작성할 때(60분 미만의 전체 파일 단일 처리에 적합):

generation_config = {
    "transcription_config": {
        "mode": "smart"
    }
}

사내 전용 용어나 특수한 브랜드명의 정확한 철자 인식이 최우선 과제인 기술 회의를 전사할 때:

generation_config = {
    "transcription_config": {
        "custom_vocabulary": ["DataPulse", "CloudForge", "ClickHouse", "gRPC"]
    }
}

화자별 발화록과 자막용 타이밍 그리드를 구성할 때(30분 오디오 제한 적용):

generation_config = {
    "transcription_config": {
        "mode": {
            "type": "verbatim",
            "diarization_mode": "speaker",
            "timestamp_granularities": ["word"],
        }
    }
}

Python SDK를 활용한 전체 작업 파이프라인: 2개 파트 처리

아래 스크립트는 실제 오디오 청취나 수동 검증을 수행하기 전에 먼저 실행하는 파이프라인입니다. Files API를 통해 두 세그먼트를 업로드하고, Interactions API로 전사 요청을 전송하며, 단어 수준 주석을 추출하고, 타임스탬프 및 화자 식별자를 검증한 뒤, 두 번째 파트의 타임라인을 1500.0초만큼 이동시켜 다음과 같은 1차 예비 초안 파일을 생성합니다:

  • meeting_transcript.txt — 화자 턴별로 정렬된 UTF-8 인코딩의 초안 대화 녹취록
  • word_timestamps.json — 자막 구간 분할을 위한 연속된 단어 수준 타임스탬프 초안 배열

이 스크립트로 생성된 파일은 명백히 검증되지 않은 초안(draft)이며, 최종 문서가 아닙니다. 작업자는 반드시 오디오 녹음본을 직접 청취하면서 시스템 화자 토큰을 실제 목소리에 매핑(speaker mapping)하고, 전문 기술 용어(terms)를 확인하며, 시간 오프셋(time offsets)을 감사해야 합니다. 이 감사를 마친 후 코드 내 파라미터를 수정하여 파이프라인을 재실행하거나 생성된 텍스트 파일을 직접 편집해야 합니다. 이와 같은 검증 프로토콜을 통과한 후에만 최종 복사본을 회의록 담당자나 자막 편집자에게 전달해야 합니다.

본 스크립트는 분석가와 영상 편집자를 위한 중간 데이터 구조를 생성하는 것이며, 액션 아이템 목록이나 완제품 .srt 자막 파일을 자동으로 만들어 주지 않습니다. 화자 이름과 1500.0초 경계값은 본 가상 시나리오의 시연 목적으로 명시된 것입니다.

import json
from google import genai

client = genai.Client()

audio_part1 = client.files.upload(file="meeting_part1.mp3")
audio_part2 = client.files.upload(file="meeting_part2.mp3")

transcription_mode_config = {
    "transcription_config": {
        "mode": {
            "type": "verbatim",
            "diarization_mode": "speaker",
            "timestamp_granularities": ["word"],
        }
    }
}

interaction_part1 = client.interactions.create(
    model="gemini-3.5-transcribe",
    input=[
        {
            "type": "audio",
            "uri": audio_part1.uri,
            "mime_type": audio_part1.mime_type,
        }
    ],
    generation_config=transcription_mode_config,
)

interaction_part2 = client.interactions.create(
    model="gemini-3.5-transcribe",
    input=[
        {
            "type": "audio",
            "uri": audio_part2.uri,
            "mime_type": audio_part2.mime_type,
        }
    ],
    generation_config=transcription_mode_config,
)

def extract_word_annotations(interaction):
    words = []
    for step in getattr(interaction, "steps", []) or []:
        for content in getattr(step, "content", []) or []:
            for annotation in getattr(content, "annotations", []) or []:
                if getattr(annotation, "type", None) == "word_info":
                    words.append(annotation)
    return words

def parse_offset_seconds(offset_val, word_text):
    if offset_val is None or offset_val == "":
        raise ValueError(f"Отсутствует таймкод для слова '{word_text}'. Требуется проверка аудиозаписи.")
    val_str = str(offset_val)
    if val_str.endswith("s"):
        val_str = val_str[:-1]
    try:
        return float(val_str)
    except ValueError:
        raise ValueError(f"Некорректный формат таймкода '{offset_val}' для слова '{word_text}'. Требуется проверка аудиозаписи.")

words_part1 = extract_word_annotations(interaction_part1)
words_part2 = extract_word_annotations(interaction_part2)

if not words_part1 or not words_part2:
    raise ValueError("Один из аудиосегментов не содержит пословных аннотаций. Пустой результат не может считаться успешным.")

part1_offset_seconds = 0.0
part2_offset_seconds = 1500.0

manual_mapping_part1 = {
    "spk_1": "Алексей",
    "spk_2": "Михаил",
}

manual_mapping_part2 = {
    "spk_1": "Михаил",
    "spk_2": "Алексей",
}

unified_word_stream = []

for word in words_part1:
    text = getattr(word, "text", "")
    raw_speaker = getattr(word, "speaker", None)
    if not raw_speaker or raw_speaker not in manual_mapping_part1:
        raise ValueError(f"Неизвестный спикер '{raw_speaker}' в части 1. Требуется ручная верификация по аудио.")
    resolved_speaker = manual_mapping_part1[raw_speaker]
    word_start = parse_offset_seconds(getattr(word, "start_offset", None), text) + part1_offset_seconds
    word_end = parse_offset_seconds(getattr(word, "end_offset", None), text) + part1_offset_seconds
    unified_word_stream.append({
        "text": text,
        "speaker": resolved_speaker,
        "start_seconds": word_start,
        "end_seconds": word_end,
        "part": 1,
    })

for word in words_part2:
    text = getattr(word, "text", "")
    raw_speaker = getattr(word, "speaker", None)
    if not raw_speaker or raw_speaker not in manual_mapping_part2:
        raise ValueError(f"Неизвестный спикер '{raw_speaker}' в части 2. Требуется ручная верификация по аудио.")
    resolved_speaker = manual_mapping_part2[raw_speaker]
    word_start = parse_offset_seconds(getattr(word, "start_offset", None), text) + part2_offset_seconds
    word_end = parse_offset_seconds(getattr(word, "end_offset", None), text) + part2_offset_seconds
    unified_word_stream.append({
        "text": text,
        "speaker": resolved_speaker,
        "start_seconds": word_start,
        "end_seconds": word_end,
        "part": 2,
    })

dialogue_turns = []
current_turn = None

for item in unified_word_stream:
    if current_turn is None or current_turn["speaker"] != item["speaker"] or current_turn["part"] != item["part"]:
        if current_turn is not None:
            dialogue_turns.append(current_turn)
        current_turn = {
            "part": item["part"],
            "speaker": item["speaker"],
            "start_seconds": item["start_seconds"],
            "end_seconds": item["end_seconds"],
            "words": [item["text"]],
        }
    else:
        current_turn["end_seconds"] = item["end_seconds"]
        current_turn["words"].append(item["text"])

if current_turn is not None:
    dialogue_turns.append(current_turn)

def format_timestamp(seconds):
    minutes = int(seconds // 60)
    remaining_seconds = seconds % 60
    return f"{minutes:02d}:{remaining_seconds:06.3f}"

with open("meeting_transcript.txt", "w", encoding="utf-8") as f_transcript:
    for turn in dialogue_turns:
        start_str = format_timestamp(turn["start_seconds"])
        end_str = format_timestamp(turn["end_seconds"])
        speech_text = " ".join(turn["words"])
        f_transcript.write(f"[{start_str} - {end_str}] {turn['speaker']}: {speech_text}\n")

with open("word_timestamps.json", "w", encoding="utf-8") as f_json:
    json.dump(unified_word_stream, f_json, ensure_ascii=False, indent=2)

단어 수준 구조의 예시 및 집중 검토 영역

다음은 화자 발화 전환 구간에서 추출된 데이터 스트림의 가상 구조를 나타낸 스니펫입니다.

참고: 이 스니펫은 반환되는 객체의 구조를 설명하기 위한 예시일 뿐이며, 실제 API 호출 로그가 아닙니다. 실제 전사 품질은 음향 환경, 마이크 특성 및 발음의 명확성에 따라 달라집니다.

[Строка 1] [spk_1] (0.100s -> 0.420s) Мы
[Строка 2] [spk_1] (0.450s -> 0.810s) переносим
[Строка 3] [spk_1] (0.830s -> 1.250s) ДатаПульс
[Строка 4] [spk_1] (1.300s -> 1.550s) на
[Строка 5] [spk_1] (1.600s -> 2.100s) CloudForge.
[Строка 6] [spk_1] (2.300s -> 2.600s) Да,
[Строка 7] [spk_2] (2.650s -> 2.900s) согласен,
[Строка 8] [spk_2] (2.950s -> 3.400s) э-э-э,
[Строка 9] [spk_2] (3.420s -> 3.900s) логично.

수동 검토 시 핵심 체크리스트:

  1. 실제 참가자와 라벨 매핑 (라인 1–5 및 7–9): 식별자 spk_1spk_2는 임의로 할당된 순서 기반의 음성 토큰에 불과합니다. 편집자는 각 세그먼트의 도입부를 직접 청취하여 실제 인물을 파악해야 합니다. 예를 들어 파트 1에서 spk_1이 알렉세이이고 spk_2가 미하일임을 확인하는 식입니다.
  2. 발화 전환과 짧은 추임새 반응 (라인 6): 단어 “Да,“는 spk_1의 발화 턴으로 묶여 있습니다. 그러나 빠른 대화에서는 이것이 실제로는 상대방(spk_2)이 건넨 긍정의 맞장구이거나 백채널(backchannel) 신호일 수 있습니다. 이처럼 화자가 전환되는 경계선은 오디오를 통한 표적 청취 대조가 필요합니다.
  3. 미등록 브랜드명의 음차 표기 (라인 3): custom_vocabulary를 사용하지 않으면 영문 기술 명칭이 키릴 문자로 발음대로 표기될 수 있습니다(예: ДатаПульс). 편집자는 이를 표준 형태인 DataPulse로 바로잡아야 합니다.
  4. 무분별한 전역 일괄 치환(Global Replace) 금지: 용어 수정은 반드시 문맥에 따라 선별적으로 적용해야 합니다. 문서 전체를 대상으로 무차별적인 ‘찾아 바꾸기’를 실행하면 음운적으로 유사한 일상 단어, 관용구 또는 실제 발화 인용구가 잘못 수정될 위험이 있습니다.
  5. 구어체 머뭇거림 표현 (라인 8): verbatim 모드에서는 “э-э-э”와 같은 간투사가 그대로 기록됩니다. 정돈된 회의록에서는 이를 삭제하지만, 영상 자막 트랙의 경우 화면 속 인물의 입 모양(조음)과 자막 표시 싱크를 일치시켜야 한다면 해당 타이밍을 그대로 보존해야 합니다.

자료 인계 전 샘플링 검증 프로토콜

프로그래밍 방식으로 병합한 원시 데이터는 다음 세 가지 검증 체크포인트를 거쳐 품질 감사를 완료해야 합니다:

  1. 화자 및 발화 경계 검증: 화자 전환이 발생하는 지점 2~3곳을 중심으로 15–20초 분량의 오디오를 샘플링 청취합니다. 서로 다른 인물의 목소리가 한 명으로 합쳐지지 않았는지, 반대로 한 사람의 연속된 독백이 가상의 라벨들로 쪼개지지 않았는지 확인합니다. 공식 문서에 따르면 3인 이상이 참여하는 회의의 화자 분리는 실험적(experimental) 기능으로 분류되므로 훨씬 더 면밀한 검토가 요구됩니다.
  2. 용어, 수치, 고유 명사 검증: 핵심 프로젝트명(DataPulse, CloudForge, 버전 번호, 예산 규모 등)의 점검 목록을 작성합니다. 텍스트 검색으로 해당 항목들을 찾고, 불확실한 구간은 녹음본과 직접 대조 확인하되 무분별한 전역 치환은 피합니다.
  3. 세그먼트 경계 지점의 타임라인 연속성 검증: 파트 1의 시작 1분과 파트 2 연결 지점(25:00 / 1500.0초 경계 직후)을 대조합니다. 두 번째 세그먼트의 타임스탬프가 0으로 초기화되지 않고 이전 파트의 타임라인을 자연스럽게 이어받는지 확인합니다.

다운스트림 전달 및 프로세스 중단 조건

스크립트로 생성된 meeting_transcript.txtword_timestamps.json 파일은 오디오 청취 검토 전에 작성된 것이므로 엄격히 예비 초안으로 다루어야 합니다. 가공되지 않은 초안 상태 그대로 후속 작업자에게 넘겨서는 절대 안 됩니다. 먼저 수동 검증 프로토콜을 수행하여 오디오를 기준으로 화자 식별, 전문 용어, 타임라인 오프셋을 조율하고, 결과물을 보정(코드 내 파라미터를 수정하여 재실행하거나 텍스트 파일을 직접 편집)한 뒤에만 검증된 최종본을 전달해야 합니다:

  • 회의록 담당자 또는 LLM 요약 모델 전달용 (Note Taker / LLM Summarization): 화자 귀속이 검증되고 전문 용어가 교정된 검증된 축어록 복사본을 제공합니다. 회의록 및 의사결정 사항은 다운스트림에서 작성되며, 이는 요약문 작성을 위한 기초 자료가 되지만, 그 자체로 자동 추출된 액션 아이템 목록이 되지는 않습니다.
  • 영상 편집자 또는 자막 전문가 전달용 (Caption Editor): 연속 타임스탬프가 확인된 단어별 타임스탬프 배열 복사본을 제공합니다. 화면당 노출 시간, 한 줄당 최대 글자 수, 가독 속도 등 구체적인 스타일링 규칙은 대상 비디오 플레이어와 언어 환경에 따라 달라지며, 이후 전용 포맷팅 도구를 거쳐 최종 .srt 또는 .vtt 자막 파일로 생성됩니다.

프로세스 중단 조건 (Process Stop Conditions)

다음 조건 중 하나라도 해당하면 워크플로를 즉시 멈추고 후속 팀에 파일을 전달해서는 안 됩니다:

  1. 30분을 초과하는 오디오 파일을 사전 분할 없이 화자 분리나 단어 타임스탬프 요청으로 전송한 경우(문서에 명시된 API 한도를 벗어나므로 전송해서는 안 됨).
  2. 스크립트 실행 직후 오디오 샘플 청취 및 수동 검토 없이 생성된 원시 초안 파일(meeting_transcript.txt 또는 word_timestamps.json)을 후속 작업자에게 전달한 경우.
  3. 오디오 청취 없이 파트 1의 라벨 번호만을 근거로 파트 2의 화자를 임의로 매핑했거나, 데이터 내에 확인되지 않은 화자 식별자가 남아 있는 경우.
  4. 첫 번째 세그먼트의 실제 재생 시간을 두 번째 세그먼트 타임라인에 합산하지 않았거나, 누락된 단어 타임스탬프가 발견된 경우.
  5. 문맥 확인 없이 무분별한 전역 찾아 바꾸기 규칙으로 용어를 수정했거나, 핵심 설정 파라미터 및 수치 데이터가 원본 녹음본과 대조 확인되지 않은 경우.

LLM 워크플로를 최적화할 준비가 되셨나요?

하나의 API로 모델을 연결하고 키와 AI 비용을 관리하세요.

무료로 시작하기