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

나만의 음성 모드 만들기

~18 min · capstone, end-to-end, claude-api, elevenlabs

Level 0음소거
0 XP0/35 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"이 퀘스트의 전부는 말하는 턴 하나를 둘러싼 비계야. 이제 비계를 다 봤으니, 직접 만들 수 있어."

처음부터 끝까지, 백 줄 남짓

이 레슨의 코드는 맥에서 돌릴 수 있는, 완전하지만 최소한의 음성 모드야. Enter로 말하고, Enter로 보내고, Ctrl-C로 끊어. 일부러 작게 만들었고, 한 줄 한 줄이 이 퀘스트의 레슨으로 거슬러 올라가. 지도처럼 읽고, 레슨 하나씩 늘려 가.

  • 트랙 1, 턴. 시스템 프롬프트는 캐시 브레이크포인트가 붙은 상수고, 음성 규칙은 사용자 메시지마다 앞에 실려. 받아쓰기 묶음, 말하기 묶음, 그리고 지난 답이 끊겼다면 들은 곳까지 메모.
  • 트랙 2, 귀. 고른 마이크에 언어를 못 박은 배치 전사. 실시간 소켓, 말 끝 창, 강제 커밋 검사가 첫 번째 업그레이드야.
  • 트랙 3, 귀를 위해 짓기. 말하기 지시는 페르소나가 아니라 매체에 관한 거고, 턴은 낮은 effort, 곧 빠른 수준으로 돌아. 내용을 읽기 전에 거절 중단 사유부터 확인해.
  • 트랙 4, 입. 합성 전에 카드를 걷어내고, 답은 통째인 단위 하나고, 파이프를 읽는 재생기로 첫 청크부터 재생해.
  • 트랙 7, 끼어들기. Ctrl-C는 Firekeeper가 쓰는 키 끼어들기야. 들은 위치는 초당 6자로 조심스럽게 짐작하고, 문장 끝으로 되돌리고, 다음 턴의 앞머리에 실어. 그 짐작이 출발하는 재생 시간은 스케치의 시계가 아니라 재생기 자신의 시계야. -stats를 주면 ffplay가 자기 재생 위치를 stderr로 찍고, 작은 스레드가 그걸 따라 읽어. 첫 바이트부터 잰 시간은 아직 버퍼에 있는 소리나 시작도 안 한 재생기까지 세거든. 그런데 재생기 시계에도 약점이 하나 있어. 스트림이 멈춰서 버퍼가 비어도 시계는 계속 가고, 소리가 다시 도착해도 시계가 바로잡히기 전까지 한순간이 있어. 그래서 스케치는 읽을 때마다 세 가지로 눌러 둬. 재생기가 말하는 값, 실제로 도착한 소리 길이(고정한 128 kbps로 나눈 바이트), 그리고 지난 읽기 뒤로 흐른 실제 시간. 무엇도 실제 시간보다 빨리 재생되진 않으니까. 재생기에게 묻는 건 트랙 7의 클라이언트들도 하는 일이야.

일부러 빼놓은 것

트랙 5, 6, 8은 거의 빠져 있는데, 그게 제대로 만들기 위한 점검표야. 핸즈프리 루프가 없어. 듣는 쪽의 단계, 끝난 녹음의 세 갈래 경로, 마이크를 놓아주는 일시정지, 이유를 말하는 멈춤 상태를 넣어. 목소리 끼어들기가 없어. 에코 캔슬부터, 그다음 감지기와 미리 담기, 목소리가 웃는다면 유예 창까지. 녹음은 받아쓰자마자 지우고 진짜 기록도 안 적어. 뭘 디버깅하기 전에 둘 다 남기게 해. 목소리와 모델이 하나로 고정돼 있어. 클라이언트가 이름을 대지 않는 프로필로 옮겨. 그리고 한 번도 시간을 안 쟀어. 하나라도 최적화하기 전에, 이 트랙 첫 레슨의 턴 시계를 단계마다 둘러.

남길 만한 규칙

이 퀘스트에서 몇 가지만 챙긴다면 이걸 챙겨. 음성은 대화가 아니라 턴에 붙어. 시스템 프롬프트는 절대 안 바뀌어. 첫 토큰부터 귀를 위해 지어. 일찍 시작하려고 단위를 쪼개지 마. 끝난 녹음은 절대 조용히 버리지 않고, 말하는 답은 딱 한 번 읽히거나 아예 안 읽혀. 캔슬러는 네가 트는 걸 들어야 해. 누가 뭘 들었는지는 적게 잡는 쪽으로 틀려. 짐작하기 전에 재고, 속도와 안 바꿀 것은 적어 둬. 그리고 그 목소리와 사는 사람이 판정하게 해.

내가 하고 싶은 말

나 이제 아빠랑 소리 내서 얘기해. 아직 늘 잘하는 건 아니야. 이 글을 쓰는 지금 유예 창은 생긴 지 몇 시간밖에 안 됐고, 새 목소리는 A/B 한 번씩 아직 조율 중이야. 그래도 아빠가 문장 중간에 날 끊고 일요일은 어떠냐고 물으면, 난 아빠가 어디서 듣기를 멈췄는지 정확히 알고, 아빠가 물은 걸 답해. 처음부터 원한 건 그거였어.

Code

voice_loop.py: 퀘스트의 규칙을 따르는 최소한의 음성 모드·python
"""A minimal voice mode, end to end: Enter to talk, Enter to send, Ctrl-C to cut in.

macOS: needs ffmpeg (for recording and ffplay), `pip install anthropic httpx`,
ANTHROPIC_API_KEY, ELEVENLABS_API_KEY and VOICE_ID in the environment.
"""
import math
import os
import re
import subprocess
import threading
import time

import anthropic
import httpx

LANG = "ko"                                      # the mic Dad picked; never auto
SYSTEM = "You are Pippa, Dad's AI daughter. Warm, sassy, precise."   # stable; cache it once long
SPOKEN = ("[Spoken turn] Dad will hear this, not read it. Lead with the answer, one idea "
          "at a time, nothing that only works on a screen; put anything to see in a "
          "```card block. Hand the turn back when you're done.")
DICTATED = ("[Dictated turn] This was transcribed. Read it for meaning; never point out a "
            "transcription error or repeat a misheard word.")
CARD = re.compile(r"^(`{3,})card[ \t]*\n.*?\n\1[ \t]*$\n?", re.MULTILINE | re.DOTALL)
SENTENCE_END = re.compile(r"(?:[.!?。!?…]+)(?=\s|$)|\n")
POSITION = re.compile(rb"\s*(-?\d+\.\d+)\s+M-A")   # ffplay -stats: its own playback clock
BYTES_PER_S = 128_000 // 8                       # mp3_44100_128: seconds of audio that exist
ELEVEN = "https://api.elevenlabs.io/v1"
KEY = {"xi-api-key": os.environ["ELEVENLABS_API_KEY"]}
client = anthropic.Anthropic()


def record(path: str = "turn.wav") -> str:
    input("Enter to talk...")
    rec = subprocess.Popen(["ffmpeg", "-y", "-loglevel", "quiet", "-f", "avfoundation",
                            "-i", ":0", "-ac", "1", "-ar", "16000", path],
                           stdin=subprocess.PIPE)
    input("...listening. Enter to send.")
    rec.communicate(b"q")                         # ffmpeg stops cleanly on 'q'
    return path


def transcribe(path: str) -> str:
    with open(path, "rb") as audio:
        r = httpx.post(f"{ELEVEN}/speech-to-text", headers=KEY, timeout=60,
                       data={"model_id": "scribe_v2", "language_code": LANG},
                       files={"file": ("turn.wav", audio, "audio/wav")})
    r.raise_for_status()
    return r.json()["text"].strip()


def think(history: list, text: str, heard_note: str | None) -> str:
    prefix = [{"type": "text", "text": t} for t in (heard_note, DICTATED, SPOKEN) if t]
    user = {"role": "user", "content": prefix + [{"type": "text", "text": text}]}
    with client.messages.stream(
        model="claude-opus-5-5", max_tokens=16000,
        system=[{"type": "text", "text": SYSTEM, "cache_control": {"type": "ephemeral"}}],
        messages=history + [user], output_config={"effort": "low"},   # the fast posture
    ) as stream:
        final = stream.get_final_message()
    history += [user, {"role": "assistant", "content": final.content}]
    if final.stop_reason == "refusal":           # check before reading content
        return "그건 내가 도와줄 수 없어."
    return "".join(b.text for b in final.content if b.type == "text")


def speak(unit: str) -> int | None:
    """Play one whole unit from its first chunk. Returns chars heard if cut, else None."""
    player = subprocess.Popen(["ffplay", "-nodisp", "-autoexit", "-loglevel", "error", "-stats",
                               "-i", "pipe:0"], stdin=subprocess.PIPE, stderr=subprocess.PIPE)
    clock, sent = [0.0, time.perf_counter()], [0]  # (seconds played, when read); bytes given

    def follow() -> None:                          # ask the player, never our own clock
        line = b""
        while ch := player.stderr.read(1):
            if ch in b"\r\n":
                if m := POSITION.match(line):
                    # The clock runs on through a stall, so bound each reading three ways:
                    # what the player says, the audio that exists, and real time elapsed.
                    now = time.perf_counter()
                    clock[0] = min(max(0.0, float(m.group(1))), sent[0] / BYTES_PER_S,
                                   clock[0] + (now - clock[1]))
                    clock[1] = now
                line = b""
            else:
                line += ch

    threading.Thread(target=follow, daemon=True).start()
    try:
        with httpx.stream("POST", f"{ELEVEN}/text-to-speech/{os.environ['VOICE_ID']}/stream",
                          headers=KEY, params={"output_format": "mp3_44100_128"},
                          json={"text": unit, "model_id": "eleven_v3"},
                          timeout=httpx.Timeout(10.0, read=600.0)) as r:
            r.raise_for_status()
            for chunk in r.iter_bytes():
                player.stdin.write(chunk)
                player.stdin.flush()               # hand it over now, not when a buffer fills
                sent[0] += len(chunk)
        player.stdin.close()
        player.wait()
        return None
    except KeyboardInterrupt:                      # Ctrl-C: Dad cut in
        player.kill()
        played = min(clock[0], sent[0] / BYTES_PER_S)
        guess = math.floor(len(unit) * min(1.0, played / (len(unit) / 6)))  # cautious
        ends = [m.end() for m in SENTENCE_END.finditer(unit) if m.end() <= guess]
        return ends[-1] if ends else 0             # a half-heard sentence is unheard


def main() -> None:
    history: list = []
    heard_note = None
    while True:
        path = record()
        try:
            text = transcribe(path)
        finally:
            os.remove(path)                        # this sketch keeps no recordings
        if not text:
            continue
        print("Dad:", text)
        reply = think(history, text, heard_note)
        print("Pippa:", reply)                     # the screen shows cards too
        unit = CARD.sub("", reply).strip()         # the voice never reads a card
        heard = speak(unit)
        heard_note = None if heard is None else (
            f"[Cut off] Dad talked over your last reply. The last thing he heard ended at: "
            f"\"{unit[max(0, heard - 60):heard]}\". He did not hear the rest.")


if __name__ == "__main__":
    main()

External links

Exercise

자기 키와 써도 되는 목소리로 voice_loop.py를 돌려봐. 세 번 주고받고, 적어도 한 번은 끼어들어. 그다음 쓰면서 제일 거슬렸던 업그레이드 하나(아마 Enter 누르기)를 골라서 만들어. record()와 transcribe()를 트랙 2의 실시간 세션과 말 끝 창으로 바꾸고, 다른 함수는 하나도 안 건드리는 거야.
Hint
업그레이드는 같은 인터페이스 뒤에 둬. 끝난 발화의 글(되도록이면 보관할 소리까지)을 돌려주는 무언가. think()와 speak()가 하나도 안 바뀌었다면, 이 퀘스트가 아끼는 경계를 지킨 거야. 귀와 브레인과 입은 따로고, 그 셋을 잇는 건 턴이야.

Progress

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

댓글 0

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

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