Skip to content

Commit c57ccb2

Browse files
authored
Merge pull request #192 from Team-StackUp/fix/rt3-forward-content-type
fix(realtime): 오디오 스트림의 contentType 을 AI 에 전달
2 parents ef4c54c + 6a78a2e commit c57ccb2

3 files changed

Lines changed: 89 additions & 1 deletion

File tree

realtime/CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ config는 cmd, internal/* 모두에서 import 가능
9999
> 모든 `/realtime/stream/*` (SSE) 및 `/realtime/sessions/{id}` (WS) 는 인증 필요 — `?access_token=<stream-token>` 쿼리로 Core 발급 토큰 검증 (EventSource/WS 헤더 한계 우회). `internal/auth` 미들웨어가 처리.
100100
> WS는 SSE(`/realtime/stream/*`)와 **다른 path**라 Upgrade 헤더 분기가 필요 없다. WS 핸들러(`coder/websocket`)는 session 채널 fan-out을 그대로 구독(서버→클라)하고, 수신한 답변(`{type:"answer",content,idempotencyKey?}`)을 `internal/core` 클라이언트로 Core 내부 REST(`POST /api/internal/sessions/{id}/messages`)에 프록시한다.
101101
> RT3(`/realtime/sessions/{id}/audio`)는 `WSAudioHandler`(`transport/ws_audio.go`)가 브라우저 WS를 AI WS(`REALTIME_AI_WS_URL` + `?sessionId=&messageId=`, `X-Internal-API-Key` 헤더)로 **양방향 프록시**한다. `messageId`는 사전에 Core `POST /api/sessions/{id}/messages/voice/stream-begin`이 만든 placeholder. 오디오 내용은 해석하지 않고 프레임 타입을 보존하며 복사만(`copyWS`).
102+
> 브라우저의 `?contentType=` 을 AI 로 전달한다 — AI 가 이 값으로 STT 세션의 디코더를 고르므로, 넘기지 않으면 무엇을 보내든 `audio/webm` 으로 가정되어 Safari(mp4) 같은 경우 틀어진다. 사용자 제어 값이라 화이트리스트(webm/ogg/mp4/mpeg/wav)를 통과한 base MIME 만 붙이고, 그 외에는 기존 기본값으로 떨어진다.
102103
103104
---
104105

realtime/internal/transport/ws_audio.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"fmt"
66
"log/slog"
77
"net/http"
8+
"net/url"
89
"strconv"
10+
"strings"
911
"time"
1012

1113
"github.com/Team-StackUp/stackup/realtime/internal/auth"
@@ -48,7 +50,10 @@ func (h *WSAudioHandler) ServeAudioWS(w http.ResponseWriter, r *http.Request) {
4850
defer client.CloseNow()
4951

5052
ctx := r.Context()
51-
aiURL := fmt.Sprintf("%s?sessionId=%d&messageId=%d", h.AIBaseURL, sid, mid)
53+
// 브라우저가 실제로 만드는 코덱을 AI 에 알려야 STT 세션이 맞는 디코더로 열린다.
54+
// 지금까지는 넘기지 않아 AI 가 항상 audio/webm 으로 가정했다 — Safari(mp4)에서는 틀린다.
55+
contentType := resolveAudioContentType(r.URL.Query().Get("contentType"))
56+
aiURL := buildAIStreamURL(h.AIBaseURL, sid, mid, contentType)
5257
upstream, _, err := websocket.Dial(ctx, aiURL, &websocket.DialOptions{
5358
HTTPHeader: http.Header{"X-Internal-API-Key": {h.InternalKey}},
5459
})
@@ -70,6 +75,31 @@ func (h *WSAudioHandler) ServeAudioWS(w http.ResponseWriter, r *http.Request) {
7075
slog.Info("ws_audio.proxy.end", "session_id", sid, "message_id", mid)
7176
}
7277

78+
// AI 가 다룰 수 있고 Core 가 저장을 허용하는 오디오 타입만 통과시킨다.
79+
// 값은 사용자(브라우저)가 정하므로 그대로 업스트림 URL 에 붙이지 않는다.
80+
var allowedAudioContentTypes = map[string]struct{}{
81+
"audio/webm": {},
82+
"audio/ogg": {},
83+
"audio/mp4": {},
84+
"audio/mpeg": {},
85+
"audio/wav": {},
86+
}
87+
88+
// resolveAudioContentType 은 "audio/webm;codecs=opus" 같은 값에서 base MIME 만 뽑고,
89+
// 허용 목록에 없으면 기존 동작과 같은 기본값(audio/webm)으로 떨어진다.
90+
func resolveAudioContentType(raw string) string {
91+
base := strings.ToLower(strings.TrimSpace(strings.SplitN(raw, ";", 2)[0]))
92+
if _, ok := allowedAudioContentTypes[base]; ok {
93+
return base
94+
}
95+
return "audio/webm"
96+
}
97+
98+
func buildAIStreamURL(base string, sessionID, messageID int64, contentType string) string {
99+
return fmt.Sprintf("%s?sessionId=%d&messageId=%d&contentType=%s",
100+
base, sessionID, messageID, url.QueryEscape(contentType))
101+
}
102+
73103
// copyWS 는 src 에서 받은 프레임을 dst 로 그대로 전달한다 (타입 보존).
74104
func copyWS(ctx context.Context, src, dst *websocket.Conn) error {
75105
for {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package transport
2+
3+
import "testing"
4+
5+
// AI 는 contentType 으로 STT 세션의 디코더를 고른다. RealTime 이 넘기지 않으면
6+
// 무엇을 보내든 audio/webm 으로 가정되어 webm 이 아닌 브라우저(Safari=mp4)에서 틀어진다.
7+
func TestResolveAudioContentType(t *testing.T) {
8+
cases := []struct {
9+
name string
10+
in string
11+
want string
12+
}{
13+
{"코덱 파라미터를 떼고 base MIME 만", "audio/webm;codecs=opus", "audio/webm"},
14+
{"대문자도 정규화", "AUDIO/MP4", "audio/mp4"},
15+
{"앞뒤 공백 허용", " audio/ogg ", "audio/ogg"},
16+
{"허용 목록의 다른 타입", "audio/mpeg", "audio/mpeg"},
17+
{"빈 값이면 기존 동작과 같은 기본값", "", "audio/webm"},
18+
{"허용 목록 밖은 기본값", "audio/flac", "audio/webm"},
19+
// 사용자 제어 값이므로 임의 문자열이 업스트림 URL 로 새어 나가면 안 된다.
20+
{"주입 시도도 기본값", "audio/webm&apiKey=leak", "audio/webm"},
21+
{"완전히 다른 값", "text/html", "audio/webm"},
22+
}
23+
for _, c := range cases {
24+
t.Run(c.name, func(t *testing.T) {
25+
if got := resolveAudioContentType(c.in); got != c.want {
26+
t.Fatalf("resolveAudioContentType(%q) = %q, want %q", c.in, got, c.want)
27+
}
28+
})
29+
}
30+
}
31+
32+
func TestBuildAIStreamURL(t *testing.T) {
33+
got := buildAIStreamURL("ws://ai:8000/internal/voice/stream", 7, 42, "audio/mp4")
34+
want := "ws://ai:8000/internal/voice/stream?sessionId=7&messageId=42&contentType=audio%2Fmp4"
35+
if got != want {
36+
t.Fatalf("buildAIStreamURL = %q, want %q", got, want)
37+
}
38+
}
39+
40+
// 슬래시가 이스케이프되지 않으면 AI 쪽 쿼리 파싱이 어긋난다.
41+
func TestBuildAIStreamURLEscapesContentType(t *testing.T) {
42+
got := buildAIStreamURL("ws://ai:8000/s", 1, 2, "audio/webm")
43+
if want := "contentType=audio%2Fwebm"; !contains(got, want) {
44+
t.Fatalf("expected %q in %q", want, got)
45+
}
46+
}
47+
48+
func contains(s, sub string) bool {
49+
return len(s) >= len(sub) && (func() bool {
50+
for i := 0; i+len(sub) <= len(s); i++ {
51+
if s[i:i+len(sub)] == sub {
52+
return true
53+
}
54+
}
55+
return false
56+
})()
57+
}

0 commit comments

Comments
 (0)