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