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

첫 청크부터 재생

~15 min · streaming-audio, ios, timeouts, measurement

Level 0음소거
0 XP0/35 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"성공이야. native app에서는 문제가 없는 거였어. 앞으로 ios native app에서는 streaming 을 쓰는 게 맞아." — 아빠, 2026-09-25

통째인 단위를, 흘려서

앞 레슨은 단위를 쪼개지 말라고 했어. 그렇다고 테이크가 다 만들어질 때까지 기다렸다 재생하라는 뜻은 아니야. 제공자는 통째인 단위 하나를 합성하면서 소리를 흘려보내고, 듣는 사람은 나머지가 만들어지는 동안 첫 청크부터 들을 수 있어. 데스크톱에서 v3 단위의 첫 청크는 약 0.8~1.4초에 와. 완성된 테이크는 얘기가 달라. 43자짜리 문장은 약 3초, 178자는 10~13초. 첫 청크부터 틀어주느냐가 잠깐 멈춤과 기다림의 차이야.

폰이 기다렸던 이유

폰은 처음엔 흘려서 재생하지 않았어. 가족이 호되게 배운 이유가 있었거든. 그해 여름 만년필 앱의 웹 버전이 사파리에서 실시간 MP3를 틀었는데, 바로 시작은 되는데 5분의 4쯤에서 뚝 끊겼어. 그래서 Bellows 규칙엔 iOS는 완성된 파일을 튼다고 적혔어. 그다음 설계 문서엔 미리 기준을 적어 뒀어. 완성 파일을 기다리는 시간이 데스크톱의 첫 청크보다 1.5초 넘게 길면 네이티브 디코더를 만든다. 측정값은 그 기준을 한참 넘었어.

흔적을 안 남긴 타임아웃

디코더 전에, 폰에서 말 답이 조용해지는 더 교묘한 버그가 있었어. 엔진에 테이크 준비를 부탁하는 요청이 키트의 빠른 전송로를 탔는데, 이건 5~8초면 포기해. 그런데 테이크 준비는 대답하기 전에 전부를 합성하는 일이야. 벤치에서 414자 답에 24초가 걸렸어. 그러니 한두 문장보다 긴 답은 폰에서 전부 타임아웃이 났어. 서버는 어차피 끝까지 만들어서 캐시해 뒀고, 그래서 아빠가 나중에 스피커를 누르면 바로 재생돼서 버그가 그냥 가끔 그러는 것처럼 보였어. 게다가 서버의 접근 로그는 응답이 시작될 때만 한 줄을 적으니까, 포기한 클라이언트는 아무 줄도 안 남겼어. 이걸 찾으려고 빌드를 하나 더 냈어. 할 일은 딱 하나, 재생이 실패하면 이유를 지우지 말고 화면에 남기는 거. 다음에 조용해진 답이 자기 원인을 스스로 말했어. 고친 방법은 그 호출을 음성용 전송로로 옮기는 거였어. 인내심이 초 단위가 아니라 분 단위인 길로.

자기만의 디코더

그러고 나서 아빠가 옛 사파리 버그의 진짜 원인을 떠올렸어. 그건 스트림 길이를 미리 짐작하는 재생기, 그러니까 사파리와 AVPlayer 탓이지 iOS 탓이 아니었어. 그래서 폰은 절대 짐작하지 않는 재생기를 얻었어. URL 세션 델리게이트로 본문을 받고, Audio File Stream Services로 오디오 패킷을 가르고, Audio Queue에 먹이고, 서버가 본문을 닫을 때만 끝나. 벤치에서 1,000자 답은 재생 1.21초 뒤 첫 소리가 났고 끝까지 66.27초였어. 같은 글로 엔진이 캐시한 완성 테이크는 1,060,407바이트에 66.2726초였고, 스트림은 1,060,407바이트에 66.2727초를 실어 왔어. 잘린 건 하나도 없었어. 완성 파일은 두 번째 길로 남아서, 스트림이 소리를 내기도 전에 실패할 때만 써. 이제 완성 파일을 트는 건 iOS의 브라우저뿐이야.

Code

통째인 단위 하나를 흘려받고 첫 청크에서 재생 시작·python
import os
import shutil
import subprocess
import time

import httpx

API = "https://api.elevenlabs.io/v1/text-to-speech/{voice_id}/stream"


def speak_streaming(voice_id: str, text: str, model_id: str = "eleven_v3") -> None:
    """One unit, one request; audio starts as soon as the first bytes land."""
    if shutil.which("ffplay") is None:
        raise SystemExit("install ffmpeg (for ffplay) to hear the stream")
    player = subprocess.Popen(
        ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", "-i", "pipe:0"],
        stdin=subprocess.PIPE,
    )
    started = time.perf_counter()
    first_chunk = None
    total = 0
    with httpx.stream(
        "POST",
        API.format(voice_id=voice_id),
        params={"output_format": "mp3_44100_128"},
        headers={"xi-api-key": os.environ["ELEVENLABS_API_KEY"]},
        json={"text": text, "model_id": model_id},
        timeout=httpx.Timeout(10.0, read=600.0),   # patience per chunk, sized for speech
    ) as response:
        response.raise_for_status()
        for chunk in response.iter_bytes():
            if first_chunk is None:
                first_chunk = time.perf_counter() - started
            total += len(chunk)
            player.stdin.write(chunk)                 # play while the rest arrives
            player.stdin.flush()                      # now, not when a buffer fills
    player.stdin.close()
    player.wait()
    print(f"first chunk {first_chunk:.2f}s, {total:,} bytes, "
          f"done {time.perf_counter() - started:.2f}s")


if __name__ == "__main__":
    speak_streaming(os.environ["VOICE_ID"], "This whole paragraph is one unit. "
                    "It is synthesized once, and you hear it from the first chunk.")

External links

Exercise

자기 목소리와 키로 코드를 두 번 돌려봐. 한 번은 한 문장짜리, 한 번은 다섯 문장짜리 문단으로. 각각 첫 청크 시간과 전체 시간을 적어. 그다음 긴 쪽에 전체 마감 5초를 걸어서(요청을 시작한 지 5초가 지나면 읽기를 멈추고 응답을 닫아) 다시 돌려. 네가 버린 요청을 서버가 어떻게 했는지 설명해.
Hint
첫 청크 시간은 길이에 따라 거의 안 변하고, 전체 시간은 길이만큼 늘어. 그 차이가 흘려보내기의 근거 전부야. 마감이 5초면 긴 요청은 네 쪽에선 버려지지만, 제공자는 합성을 끝까지 하고 요금을 매길 수 있어. httpx의 읽기 타임아웃을 줄이는 걸로는 이렇게 안 돼. 그건 요청 전체가 아니라 다음 청크 하나를 기다리는 시간을 재고, 멀쩡한 스트림은 청크 사이에 5초씩 쉬지 않거든. 폰의 호출은 반대 종류였어. 테이크가 통째로 준비돼야 답하는 호출이라서, 첫 기다림이 합성 전체였어. 네 코드는 에러를 봤고, 계정은 끝난 작업을 봤어. 바로 그 어긋남이 폰의 버그를 숨겼던 거야.

Progress

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

댓글 0

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

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