실시간 영어·일본어·중국어를 한국어로 통역합니다.
소리는 에어팟·갤럭시버즈·MFi 보청기로 출력하고
화면에는 입력 언어를 자동 감지한 자막이 송출됩니다.
말하는 도중에도 실시간으로 통역을 제공해 발화가 끝날 때까지 기다릴 필요가 없습니다.
발화가 끝나야 번역이 시작되는 방식으로는 대화의 흐름을 따라가기 어려웠습니다.
청각장애 당사자로서 직접 겪은 문제를 해결하기 위해, 말하는 동안 번역도 함께 이어지는 StarLink를 만들었습니다. 번역 음성은 무선 이어폰과 보청기로 바로 전달되며, iOS 앱부터 Python 백엔드와 인프라까지 혼자 구현했습니다.
핸즈프리로 외국어 대화에 자연스럽게 참여하도록, 통역·자막·기록을 하나의 흐름으로 묶었습니다.
에어팟, 갤럭시버즈, 일반 블루투스 이어버드, MFi 보청기를 동일하게 처리합니다. 기기별 설정 없이 연결된 무선 기기로 통역 음성이 자동 라우팅됩니다.
음성 출력과 동시에 번역 자막이 화면에 표시됩니다. 입력 언어를 자동 감지해 [영어]·[일본어] 글래스모피즘 배지로 함께 보여줍니다.
통역 모드와 별개로, 한국어 포함 모든 언어를 0ms 지연으로 받아쓰는 STT 전용 모드. 대화 히스토리를 .txt로 내보내 공유할 수 있습니다.
톱니바퀴 버튼으로 LiveKit URL·토큰을 직접 입력해 UserDefaults에 저장. 서버 비용 없이 개인 키로 동작하는 앱스토어 배포 시나리오를 지원합니다.
aiohttp 기반 토큰 서버가 1시간 TTL 단기 JWT를 발급. iOS는 실행 시 동적 토큰을 받아오고, 없으면 안전하게 폴백합니다.
LiveKit Cloud의 미디어 라우팅 엔진으로 양방향 오디오·데이터를 스트리밍. 마이크 입력부터 번역 음성 출력까지 하나의 연속 스트림으로 연결합니다.
실시간 오디오 + WebRTC + 음성 AI가 충돌하는 지점들을 하나씩 깨면서 만든 프로젝트입니다.
LiveKit의 자동 오디오 구성이 AVAudioSession 카테고리를 재설정해, 연결된 이어폰 대신 아이폰 내장 리시버로 오디오가 라우팅. 직접 세션을 조작하면 WebRTC 세션 관리와 충돌해 비동기 오디오 스레드 데드락 발생.
출력 경로 제어를 raw 오버라이드 대신 LiveKit 공식 API(isSpeakerOutputPreferred)로 통제. 보청기 연결 시 스피커 우선을 끄고 짧은 블루투스 정착 지연을 넣어 AUIOClient_StartIO (-3001) 데드락을 회피.
이어폰으로 재생되는 통역 음성이 마이크로 다시 새어 들어가, AI가 자기 통역을 듣고 재통역하는 무한 에코 루프(then, and then… 도배) 발생. 마이크 트랙을 물리적으로 뮤트하면 WebRTC 오디오 디바이스 락 위험.
마이크 트랙을 물리적으로 건드리지 않고, Silero·VAD·turn-taking을 전부 제거. Gemini의 자동 발화 감지를 끄고(AutomaticActivityDetection(disabled=True)) 오디오를 연속 스트림으로 처리하며, generate_reply를 가로채 ActivityEnd 전송을 막아 모델이 자기 출력을 재통역하지 않게 함.
음성-대-음성 모델은 기본값에서 전사(transcription) 스트림을 보내지 않아 자막이 번역 생성 중… 상태에 멈춤. SDK가 같은 비동기 제네레이터를 이중 소비하며 스트림이 프리징되는 문제도 동반.
세션 초기화 시 입·출력에 AudioTranscriptionConfig()를 주입해 전사 토큰을 강제 방출. emit 몽키패치와 스트림 래퍼로 부작용 없이 자막 텍스트만 복제 전송해 오디오·자막을 동시 복구.
다국어 대화의 원문 언어를 표시하려면 언어 감지가 필요한데, 외부 번역 API를 쓰면 허용 불가한 네트워크 지연이 추가됨.
유니코드 블록(한글·가나·CJK 한자·라틴)을 분석하는 0ms 경량 파서 detect_language를 백엔드에 구현. 언어 코드를 LiveKit 데이터 채널로 전송해 실시간 언어 배지를 렌더링.
def detect_language(text: str) -> str: if not text: return "unknown" text = text.strip() # Hangul (Korean) if any('가' <= c <= '힣' for c in text): return "ko" # Hiragana / Katakana (Japanese) if any('' <= c <= 'ヿ' for c in text): return "ja" # Chinese Hanzi if any('一' <= c <= '鿿' for c in text): return "zh" # English / Latin latin = sum(1 for c in text if 'a' <= c.lower() <= 'z') return "en" if latin > len(text) * 0.3 else "unknown"
아이폰 마이크에서 시작해 LiveKit Cloud를 거쳐 Python 에이전트의 Gemini 통역까지, 양방향 WebRTC 스트림으로 연결됩니다.
마이크가 주변 외국어 음성을 포착하고, LiveKit SDK가 로컬 오디오 트랙을 publish. 오디오 세션 관리자가 출력을 무선 기기로 라우팅합니다.
양방향 오디오·데이터 스트림을 룸 단위로 중계. 자막은 caption 토픽의 데이터 채널로 전달됩니다.
livekit-agents가 오디오를 구독해 Gemini Live로 EN·JA·ZH → KO 통역. 언어 감지·전사 스트림을 파싱해 다시 publish합니다.
WebSocket 세션 충돌과 CoreAudio 데드락을 로그·프로토콜 동작과 대조해 원인을 찾고 수정했습니다.
음성-대-음성 Gemini Live에 SDK의 generate_reply 호출, 그리고 모드 전환 시 update_instructions를 모델이 거부해 1007 (invalid argument)로 WebSocket이 종료.
generate_reply 몽키패치 + 세션 중 update_instructions 미호출(모드는 상태만 추적)BT 기기 연결 직후 AUIOClient_StartIO를 너무 빨리 호출하면 오디오 엔진이 -3001 (kAudioEngineErr)로 멈춤.
표준 overrideOutputAudioPort(.speaker)를 직접 호출하면 LiveKit 자동 세션 관리에 덮어써지며 OSStatus -50 (invalid param) 발생.
isSpeakerOutputPreferred로 제어실기기에서 에어팟으로 통역을 들으며 자막이 실시간으로 쌓이는 흐름입니다.
무선 기기를 페어링하고 토글 버튼 한 번이면 주변 외국어가 한국어로 귀에 들어옵니다.
입력 언어가 감지돼 배지로 표시되고, 통역 음성과 자막이 동시에 쌓입니다.
회의록 모드로 받아쓴 대화를 .txt로 공유 시트에 바로 전달합니다.
유료 멤버십 없이 개인 Apple ID로 실기기에 7일 서명 빌드를 올려 데모를 녹화할 수 있습니다.
실제 개발 로그(progress.md)에 남긴, 막힌 지점과 그걸 뚫은 방법의 기록입니다. 결과보다 과정이 더 많은 걸 말해줍니다.
then, and then… 무한 도배초기엔 VAD로 마이크를 즉시 여닫자 통역 잔향이 재유입돼 번역이 정지. 백엔드 상태 메시지 기준으로 바꿨더니 조기 언뮤트로 또 막힘.
→ 물리적 mute·VAD를 전부 버리고, 자동 발화 감지 비활성화 +generate_reply 차단으로 스트리밍 전용 전환
자동 구성·커스텀 콜백 모두 WebRTC가 세션을 수화기 모드로 덮어써 무음 발생. overrideOutputAudioPort 직접 호출은 OSStatus -50 에러.
isAutomaticConfigurationEnabled=true)에 위임하고 isSpeakerOutputPreferred로 출력 경로 제어
백엔드와 SDK가 같은 message_stream 제네레이터를 동시에 소비하며 태스크 프리징.
초기엔 서버 사이드 발화 감지 + allow_interruptions=False로 가로채기를 막으려다 SDK 내부 ValueError·오디오 폐기 크래시.
allow_interruptions=True 부여 + Gemini 자동 발화 감지 비활성화(로컬 VAD 없이)로 안정화
StarLink는 실시간 오디오·WebRTC·음성 AI의 까다로운 충돌 지점을 직접 부딪혀 해결한 iOS 프로젝트입니다.