Realtime VoiceChat API¶
NemotronLabs VoiceChat(model_type: "nemotron_voicechat", architectures: ["NemotronVoiceChatForConditionalGeneration"])은 전이중(full-duplex) 음성 대 음성 모델입니다. 16 kHz 오디오를 80 ms 프레임 단위로 받고, 프레임마다 assistant 텍스트 토큰, function 채널 토큰, 사용자 전사 갱신, 22.05 kHz assistant 음성 80 ms를 하나의 공통 타임라인 위에 출력합니다. 침묵과 겹치는 발화도 타임라인에 그대로 포함되며 음성 구간 검출(VAD) 단계는 없습니다.
mlxcel은 이 모델을 mlxcel generate로 오프라인 실행하고, mlxcel serve와 mlxcel-server의 /v1/realtime WebSocket으로 실시간 서비스합니다. 프로토콜은 mlx-vlm 레퍼런스 서버(mlx_vlm/server/realtime.py)를 따릅니다.
체크포인트¶
| 체크포인트 | 비고 |
|---|---|
mlx-community/NemotronLabs-VoiceChat-11B-bf16 | 양자화하지 않음 |
mlx-community/NemotronLabs-VoiceChat-11B-8bit | LLM만 양자화(임베딩 테이블, 두 head, Nemotron-H mixer projection) |
mlx-community/NemotronLabs-VoiceChat-11B-4bit | 양자화 범위는 8-bit와 같음 |
양자화 변환본에서도 음성 인코더, RNNT 전사 분기, EAR-TTS 음성 생성기, codec은 bf16으로 남습니다. 변환된 MLX safetensors 레이아웃만 로드하며 원본 NeMo .nemo 체크포인트는 로드하지 않습니다.
프레임 생성 과정¶
| 네트워크 | 역할 |
|---|---|
| Log-mel 프론트엔드 | 25 ms / 10 ms STFT, mel bin 128개 |
| FastConformer 인코더 | 8배 causal subsampling(10 ms mel 프레임을 80 ms 프레임으로 변환)과 왼쪽 attention 문맥 70프레임을 쓰는 conformer 블록 24개 |
| RNNT 분기 | 사용자 오디오를 greedy 방식으로 전사 |
| Duplex LLM | 위치마다 하나로 합친 임베딩(직전 텍스트 토큰, 오디오, 직전 function 토큰)을 입력으로 받는 Nemotron-H(56 layer). 텍스트 head와 function head 모두 greedy |
| EAR-TTS | 현재 텍스트 토큰을 조건으로 받는 Gemma-3 계열 backbone. 프레임마다 residual codebook 코드 31개를 생성 |
| Codec | 코드 프레임 하나를 1764 샘플(22.05 kHz 기준 80 ms)로 디코딩 |
오프라인 CLI¶
mlxcel generate -m models/NemotronLabs-VoiceChat-11B-4bit \
--audio question.wav --output-audio response.wav \
-p "Be concise and answer in one sentence."
이 명령은 [user] <transcript>, assistant 텍스트, 그리고 function 채널 출력이 있으면 [function] <text>를 출력하고, 타임라인 전체를 담은 22050 Hz mono PCM16 response.wav를 씁니다.
| 옵션 | VoiceChat 체크포인트에서의 동작 |
|---|---|
--audio <PATH> | 필수. 전이중 타임라인의 사용자 쪽 입력이며 16 kHz mono로 리샘플링합니다. |
-p, --prompt | 선택 사항인 system prompt. 생략하면 system prompt 없이 실행합니다. |
--output-audio <PATH> | assistant 음성을 씁니다(22050 Hz mono PCM16). |
--extra-decoding-seconds <S> | 모델이 답변을 마칠 수 있도록 입력 뒤에 붙이는 침묵 길이(초). 기본값 3.0. |
--seed <N> | EAR-TTS 샘플링 노이즈의 시드. 기본값 0이며, 같은 시드로 두 번 실행하면 바이트 단위로 같은 오디오가 나옵니다. |
--stream | 오프라인 타임라인 대신 캐시를 유지하는 온라인 세션을 80 ms 프레임 단위로 실행하고, 프레임이 만든 assistant 텍스트를 바로 출력합니다. |
--max-streaming-seconds <S> | --stream과 함께 쓰며, 세션이 이 시간만큼 오디오를 소비하면 오류로 멈춥니다. |
--profile | --stream과 함께 쓰며, 초기 5프레임을 제외한 단계별 지연 요약과 real-time factor를 출력합니다. |
-n | 무시합니다. 출력 길이는 입력 타임라인과 같습니다. |
--image, --video | 거부합니다. |
--audio 없이 대화형 mlxcel generate를 실행하면 오프라인 명령을 안내하며 이 체크포인트를 거부합니다.
스트리밍 프로파일¶
--stream --profile은 단계별(perception, rnnt, language, tts, codec, total) 프레임 시간의 평균, p50, p95, 최댓값과 realtime_factor = mean total / 80 ms를 보고합니다. 이 값이 1.0을 넘으면 실시간보다 느립니다. 다른 작업이 없는 Apple M1 Ultra(128 GB)에서 짧은 질문에 추가 디코딩 3초를 붙여 측정한 결과는 다음과 같습니다.
| 체크포인트 | Real-time factor | 프레임 시간 p50 / p95 |
|---|---|---|
| 4-bit | 0.87 | 69.0 ms / 70.4 ms |
| 8-bit | 0.94 | 75.3 ms / 76.4 ms |
로더는 인코더, EAR-TTS backbone, EAR-TTS mixture-of-Gaussians head를 f32로 보관합니다(bf16에서 정확히 변환). 상주 메모리가 약 2.7 GB 늘어나는 대신 프레임마다 가중치를 캐스팅하지 않습니다.
진단용 변수 두 개로 프로파일을 더 세분할 수 있습니다.
| 변수 | 효과 |
|---|---|
MLXCEL_VOICECHAT_PROFILE_STAGES=1 | sub_stages 맵(예: perception.layers, tts.backbone, codec.decoder_istft)을 추가합니다. 추가 타이머가 경계마다 평가를 강제하므로 이 실행의 프레임 합계는 real-time factor로 쓸 수 없습니다. |
MLXCEL_VOICECHAT_PROFILE_FRAMES=<path> | 초기 프레임을 포함한 모든 프레임의 시간을 JSON으로 씁니다. |
서비스¶
mlxcel-server -m models/NemotronLabs-VoiceChat-11B-4bit --port 8080
# 또는
mlxcel serve --model models/NemotronLabs-VoiceChat-11B-4bit --port 8080
서버는 전용 realtime 엔진 스레드에서 체크포인트를 로드하고 GET /v1/realtime을 WebSocket 업그레이드 경로로 마운트합니다. 이 체크포인트에서는 chat worker, batch scheduler, 텍스트 warmup을 시작하지 않습니다. /v1/chat/completions, /v1/completions, /v1/responses, /v1/messages는 /v1/realtime을 안내하는 메시지와 함께 501 Not Implemented를 반환합니다.
| 서버 옵션 | /v1/realtime에 대한 효과 |
|---|---|
--api-key | 업그레이드 요청에 Authorization: Bearer <key>가 필요합니다. |
--api-prefix | 다른 route와 같은 방식으로 경로 앞에 붙습니다. |
--cors-origins, --allowed-origins | Origin 헤더로 업그레이드를 허용하거나 막습니다. 정책이 허용하지 않는 브라우저 origin은 403 Forbidden을 받고, Origin을 보내지 않는 클라이언트(브라우저가 아닌 클라이언트)는 허용합니다. |
--alias | session.updated에 보고하는 모델 id. 지정하지 않으면 모델 디렉터리 이름을 씁니다. |
제한¶
| 항목 | 값 |
|---|---|
| 동시 세션 | 1개. 세션이 진행 중일 때 두 번째 연결은 server_busy를 받고 close code 1013(잠시 후 재시도)으로 종료됩니다. |
| WebSocket 메시지 또는 프레임 크기 | 1 MiB(base64 PCM16 약 24초 분량. 클라이언트는 보통 80 ms 단위로 append를 보냅니다). |
| 세션당 오디오 | 600초. session.max_streaming_seconds로 낮출 수 있고 더 큰 값은 600초로 제한됩니다. 한도에 도달하면 inference_error로 스트림을 끝냅니다. |
session.update 전송 기한 | 연결 후 30초 |
| 유휴 제한 | 설정을 마친 세션에서 클라이언트 메시지가 120초 동안 없으면(WebSocket ping은 세지 않음) 서버가 invalid_request를 보내고 close code 1008로 종료합니다. |
| 음성 | 체크포인트에 내장된 Aria 음성만 지원하며 다른 speaker는 로드 시 거부합니다. |
| 배치 크기 | 1 |
세션 슬롯은 세션이 끝나면 반환됩니다. commit이나 cancel 뒤에 반환되고, 클라이언트가 연결을 끊으면 서버가 flush하지 않은 세션을 먼저 취소한 뒤 반환합니다.
프로토콜¶
모든 메시지는 JSON 텍스트 프레임입니다.
클라이언트 메시지¶
type | 필드 | 동작 |
|---|---|---|
session.update | session.system_prompt(문자열. 없으면 체크포인트 기본값인 빈 문자열), session.seed(음이 아닌 정수, 기본값 0), session.max_streaming_seconds(숫자, 선택), session.model(선택. 어떤 값이든 서비스 중인 체크포인트를 엽니다) | 스트리밍 세션을 열고(EAR-TTS warmup과 system prompt prefill) session.updated로 응답합니다. 설정을 마친 연결에서 session.update를 다시 보내면 invalid_request 오류입니다. |
input_audio_buffer.append | audio(base64 little-endian PCM16 mono), sample_rate(기본값 16000. 다른 값은 거부) | 1280 샘플(80 ms) 단위로 나눠 처리합니다. 각 조각의 이벤트를 모두 보낸 뒤 다음 조각을 실행합니다. |
input_audio_buffer.commit | pad_partial(기본값 true) | input_audio_buffer.committed로 응답합니다. 세션을 flush하고(pad_partial이 true면 남은 부분 프레임을 0으로 채움) 마지막 이벤트로 response.done을 보낸 뒤 close code 1000으로 소켓을 닫습니다. |
session.cancel, response.cancel | response.cancelled를 보내고 close code 1000으로 소켓을 닫습니다. | |
session.ping | session.pong으로 응답합니다. |
session.update 전에는 session.update와 session.ping을 제외한 모든 메시지에 invalid_request 오류("send session.update before audio")로 응답합니다. 설정 후에는 알 수 없는 type이 invalid_request 오류이고, JSON 객체가 아닌 프레임도 invalid_request 오류를 받지만 연결은 유지됩니다.
서버 이벤트¶
모든 이벤트에는 event_id(event_ 뒤에 16자리 16진수)가 붙습니다.
type | 필드 |
|---|---|
session.created | session.id(sess_ 뒤에 16자리 16진수), session.state: "configuring", session.input_audio_format({"type": "pcm16", "sample_rate": 16000}), session.output_audio_format({"type": "pcm16", "sample_rate": 22050}) |
session.updated | session.id, session.state: "ready", session.model, session.frame_samples: 1280, 두 오디오 형식 |
conversation.item.input_audio_transcription.delta | frame_index, delta, 누적 transcript |
response.text.delta | frame_index, token_id, delta, 누적 text |
response.function.delta | frame_index, token_id, delta, 누적 text |
response.audio.delta | frame_index, delta(round(clip(x, -1, 1) * 32767)을 base64 PCM16으로 인코딩한 1764 샘플), format: "pcm16", sample_rate: 22050, channels: 1, audio_codes(해당 프레임의 codec 코드 31개) |
input_audio_buffer.committed | |
response.done, response.cancelled | frame_index |
session.pong | |
error | error.code, error.message |
기준이 되는 값은 누적 필드인 text와 transcript입니다. delta는 누적 디코딩 결과가 이전 결과를 이어 늘린 경우 새로 붙은 부분입니다.
오류 코드¶
error.code | 발생 조건 |
|---|---|
invalid_request | 알 수 없는 type, JSON 객체가 아닌 프레임, session.update 전에 보낸 오디오, 두 번째 session.update, 유휴 제한 초과 |
server_busy | 다른 연결이 세션을 점유 중. 소켓은 close code 1013으로 닫힙니다. |
session_initialization_failed | session.update 실패(예: 음수 seed, 0 이하의 max_streaming_seconds). 연결은 유지되며 다시 시도할 수 있습니다. |
inference_error | audio가 올바른 base64가 아니거나 바이트 수가 홀수인 append, 정수가 아닌 sample_rate, 16 kHz가 아닌 샘플링 레이트, max_streaming_seconds를 넘은 스트림, 모델 실패. 연결은 유지되고, 실패한 append는 실패한 조각에서 처리를 멈춥니다. |
교환 예시¶
>로 시작하는 줄은 클라이언트 메시지이며 event_id 값과 오디오 payload는 생략했습니다.
{"type":"session.created","session":{"id":"sess_…","state":"configuring","input_audio_format":{"type":"pcm16","sample_rate":16000},"output_audio_format":{"type":"pcm16","sample_rate":22050}}}
> {"type":"session.update","session":{"system_prompt":"Be concise and answer in one sentence.","seed":0}}
{"type":"session.updated","session":{"id":"sess_…","state":"ready","model":"nemotronlabs-voicechat-11b-4bit","frame_samples":1280,"input_audio_format":{"type":"pcm16","sample_rate":16000},"output_audio_format":{"type":"pcm16","sample_rate":22050}}}
> {"type":"input_audio_buffer.append","audio":"…","sample_rate":16000}
{"type":"response.audio.delta","frame_index":0,"delta":"…","format":"pcm16","sample_rate":22050,"channels":1,"audio_codes":[…]}
{"type":"conversation.item.input_audio_transcription.delta","frame_index":5,"delta":"What","transcript":"What"}
{"type":"response.text.delta","frame_index":24,"token_id":…,"delta":"The","text":"The"}
> {"type":"input_audio_buffer.commit"}
{"type":"input_audio_buffer.committed"}
{"type":"response.done","frame_index":61}
입력, system prompt, seed가 같으면 세션은 mlxcel generate --stream과 같은 결과를 계산합니다. 다만 CLI의 WAV writer는 샘플에 32768을 곱하고 wire 형식은 32767을 곱하므로 오디오 파일이 바이트 단위로 같지는 않고, 샘플마다 양자화 한 단계만큼 차이가 날 수 있습니다.
모델은 말하는 동안에도 입력을 계속 받고 서버는 음향 반향 제거를 하지 않습니다. 마이크를 쓰는 실시간 클라이언트는 헤드폰으로 응답을 재생해야 하며, 스피커로 재생하면 모델이 자신의 답변을 다시 입력으로 받습니다.