Wixzel Voice로 WebSocket 기반 Flutter 음성 에이전트 추가하기
Flutter 앱에서 사용자가 직접 만든 음성 에이전트와 WebSocket으로 대화할 수 있게 됩니다. SIP 트렁크도 전화번호도 필요 없습니다. 서버가 일회용 세션을 발급하므로 API 키가 앱에 들어가지 않으며, 프로토콜은 wixzel_voice Dart SDK가 처리합니다. 약 1시간이 걸리며, 대부분은 마이크와 스피커를 연결하는 작업입니다. 세션 요금은 같은 엔진의 전화 통화와 같습니다. Gemini Live 엔진에서 연결된 1분당 $0.0491, 4분 세션에 약 $0.20이며, 선불 잔액에서 초 단위로 청구되고 전화 요금은 없습니다.
작성 Aqeel Shamsudheen · 게시 · 업데이트
- 소요 시간
- 약 1시간
- 엔진 사용 시간
- 연결된 1분당 $0.0491
시작하기 전에 무엇이 필요한가요?
- 크레딧이 있는 Wixzel Voice API 키(서버에 보관).
- 세션을 발급할, 직접 관리하는 서버. 예제는 Node.js와 Express를 쓰지만 HTTPS 요청을 보낼 수 있는 언어라면 무엇이든 됩니다.
- 16비트 PCM을 스트리밍하는 녹음 플러그인(가능하면 8 kHz 모노, 에코 제거 지원)과 raw PCM을 재생할 수 있는 플레이어를 갖춘 Flutter 앱.
Wixzel Voice에서 단계별로 어떻게 만드나요?
01에이전트를 한 번 만들기
이 에이전트는 듣고, 생각하고, 말하는 실시간 모델 하나인 Gemini Live를 사용합니다.
shellcurl https://api.voice.wixzel.com/v1/agents \ -H "Authorization: Bearer $WIXZEL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "In-app assistant", "system_prompt": "You are the voice assistant inside the Acme app. Answer in one or two short sentences.", "opening_message": "Hi, what can I help you with?", "voice": { "realtime": { "model": "google/gemini-live", "voice": "Kore" } } }'이 에이전트에는 앱 전용 설정이 없습니다. 같은 에이전트가 전화번호로 오는 전화도 받을 수 있습니다.
id는 서버에AGENT_ID로 보관합니다.02서버에서 세션 발급하기
앱은 API 키를 갖지 않습니다. 앱이 서버에 요청하면, 서버가
POST /v1/realtime/sessions로 API에 세션을 요청합니다. 여기서는 TypeScript SDK를 사용합니다.server.mjsimport express from 'express'; import { WixzelVoice } from 'wixzel-voice'; const wixzel = new WixzelVoice({ apiKey: process.env.WIXZEL_API_KEY }); const app = express(); // Replace with your own authentication. It must set req.userId. function requireUser(req, res, next) { req.userId = 'demo-user'; next(); } app.post('/voice/session', requireUser, async (req, res) => { const session = await wixzel.realtime.createSession({ agent_id: process.env.AGENT_ID, max_duration_seconds: 300, metadata: { user_id: req.userId }, }); res.json(session); }); app.listen(3000);응답에는 소켓
url, 이 에이전트에 대해 1분 동안 한 번만 쓸 수 있는client_secret, 그리고audio_format: "mulaw_8000"이 들어 있습니다. 세션 발급은 무료이며, 앱이 연결할 때부터 요금이 청구됩니다.max_duration_seconds는 대화 길이의 상한으로 10초에서 3600초까지 정할 수 있고 기본값은 600초입니다.metadata는 통화 기록에 그대로 담기며 모델에는 전달되지 않습니다.allowed_origins는 브라우저에서만 의미가 있으므로 네이티브 앱에서는 생략합니다.03앱에서 연결하기
flutter pub add wixzel_voice http로 SDK를 추가한 뒤, 서버가 반환한 세션을 엽니다.voice_call.dartimport 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:http/http.dart' as http; import 'package:wixzel_voice/wixzel_voice.dart'; /// Whatever your playback plugin offers, behind a small interface. abstract class PcmPlayer { void add(Int16List samples); void clear(); } class VoiceCall { VoiceCall({required this.player, required this.onLine}); final PcmPlayer player; final void Function(String role, String text) onLine; RealtimeConnection? _conn; StreamSubscription<Uint8List>? _mic; final List<int> _pending = []; /// [micBytes] is 16-bit little-endian PCM, 8 kHz mono, from your recording plugin. Future<void> start(Stream<Uint8List> micBytes) async { final res = await http.post(Uri.parse('https://api.example.com/voice/session')); final status = res.statusCode; if (status != 200) throw Exception('Could not start a voice session: HTTP $status'); final session = RealtimeSession.fromJson(jsonDecode(res.body) as Map<String, dynamic>); final conn = RealtimeConnection.forSession(session); _conn = conn; conn.events.listen((event) { switch (event) { case RealtimeAudio(:final mulaw): player.add(mulawToPcm16(mulaw)); case RealtimeAudioClear(): player.clear(); // the user interrupted: drop queued audio case RealtimeTranscript(:final role, :final text): onLine(role, text); case RealtimeError(:final code, :final message): onLine('error', '$code: $message'); case RealtimeEnded(:final reason): onLine('system', 'ended: $reason'); _mic?.cancel(); default: } }); await conn.ready; _mic = micBytes.listen(_send); } void _send(Uint8List bytes) { final data = ByteData.sublistView(bytes); for (var i = 0; i + 1 < bytes.length; i += 2) { _pending.add(data.getInt16(i, Endian.little)); } // 160 samples is 20 ms at 8 kHz, the frame size the server expects. while (_pending.length >= 160) { final frame = Int16List.fromList(_pending.sublist(0, 160)); _pending.removeRange(0, 160); _conn?.sendAudio(pcm16ToMulaw(frame)); } } Future<void> end() async { await _mic?.cancel(); await _conn?.end(); } }RealtimeConnection은 프로토콜만 처리합니다. 마이크나 스피커를 열지 않으므로 SDK에 플러그인 의존성이 없고, 이미 쓰고 있는 오디오 플러그인을 그대로 쓸 수 있습니다.pcm16ToMulaw와mulawToPcm16은 16비트 PCM과 전송 형식 사이를 변환합니다.먼저 마이크 권한을 요청합니다. Android에서는
RECORD_AUDIO, iOS에서는NSMicrophoneUsageDescription입니다.04에코 제거를 켜고, 전화 수준의 음질을 예상하기
녹음 플러그인에서 플랫폼의 에코 제거를 켭니다. 이것이 없으면 에이전트가 스피커로 나오는 자기 목소리를 듣고 스스로 말을 끊습니다.
오디오는 양방향 모두 8 kHz G.711 µ-law로, 전화 회선과 같습니다. 세션이 통화와 같은 미디어 경로를 거치기 때문입니다. 음성에는 충분하지만 광대역 오디오보다는 대역이 좁습니다. 플러그인이 허용하면 8 kHz로 녹음하고, 그렇지 않으면
pcm16ToMulaw전에 리샘플링하며, 에이전트 오디오는 8 kHz 모노로 재생합니다.05세션이 끝난 뒤 확인하기
모든 세션은
channel: "web"인 통화 기록입니다.shellcurl "https://api.voice.wixzel.com/v1/calls?channel=web&limit=5" \ -H "Authorization: Bearer $WIXZEL_API_KEY"웹 세션은 수신 통화로 기록되고,
from과to가 없으며, 길이,cost_micros, 넘겨준metadata가 담깁니다.GET /v1/calls/{id}로 조회하면 대화 기록도 포함됩니다. 세션이 끝나면 전화 통화와 같은callCompleted웹훅이 발생합니다. 웹 세션은 녹음되지 않습니다.
Wixzel Voice에서 이 빌드의 비용은 얼마인가요?
gemini-live 엔진에서 연결된 1분당 $0.0491로 4분 세션을 진행할 때의 엔진 사용 시간 비용: 세션당 약 $0.20. 전화 회선이 없으므로 전화 통신 비용이 없습니다.
| 월 세션 수 | 연결된 시간(분) | 월 엔진 비용 |
|---|---|---|
| 500 | 2,000 | $98.20 |
| 2,500 | 10,000 | $491 |
| 10,000 | 40,000 | $1,964 |
세션 비용은 같은 엔진의 전화 통화와 같으며, 같은 선불 잔액에서 초 단위로 청구됩니다. 세션 발급은 무료이므로, 화면을 연 뒤 마이크 권한을 거부한 사용자에게는 비용이 들지 않습니다.
선불이며 구독과 무료 등급이 없습니다. $5부터 크레딧을 충전하면 사용량만큼 차감됩니다. 내 사용량으로 추정해 보기.
이 빌드에는 Wixzel Voice의 어떤 한도가 적용되나요?
- 전화 수준의 음질: 양방향 모두 8 kHz G.711 µ-law이며 광대역이 아닙니다.
- Dart SDK는 오디오를 녹음하거나 재생하지 않습니다. 마이크와 스피커는 앱이 직접 고른 플러그인으로 처리합니다.
- 세션은 전화 통화와 함께 계정의 동시 통화 한도(기본 5건)에 포함됩니다. 요청하면 한도를 늘려 드립니다.
- 세션은 기본 10분, 최대 1시간까지 이어지며, 2분 동안 아무 소리가 없으면 종료됩니다.
- 웹 세션은 녹음되지 않으며, 사용자를 상담원에게 넘길 두 번째 회선이 없습니다.
자주 묻는 질문
Flutter용 음성 에이전트 API가 있나요?
wixzel_voice에 RealtimeConnection이 포함되어 있어 Flutter나 Dart 앱을 WebSocket으로 음성 에이전트에 연결합니다. 세션은 서버가 API 키로 발급하고, 앱은 1분짜리 일회용 시크릿만 갖습니다.앱 내 음성 에이전트에 전화번호나 SIP 트렁크가 필요한가요?
에이전트가 계속 스스로 말을 끊는 이유는 무엇인가요?
앱에서 다른 엔진을 쓸 수 있나요?
세션 도중 계정 크레딧이 떨어지면 어떻게 되나요?
low_balance와 대략적인 남은 시간(초)을 담은 session.warning을 받고, 이어서 세션이 insufficient_credits 사유로 종료됩니다. 처음부터 비용을 충당할 수 없는 세션은 종료 코드 4402로 거부됩니다.더 읽을거리
- 실시간: 웹과 모바일문서
세션 발급, 프로토콜, 거부 코드, 요금.
- SDK문서
브라우저 클라이언트를 포함한 Dart SDK와 TypeScript SDK.
- 음성 엔진문서
실시간 음성과 구성형 음성, 음성 목록.
- 웹훅문서
metadata가 포함된 웹 세션의 callCompleted.
Wixzel Voice 소개
Wixzel Voice는 자체 SIP 트렁크로 실제 전화를 걸고 받거나 웹·모바일 앱에서 사람들과 대화하는 AI 에이전트를 만들기 위한 음성 AI API입니다. API 키 하나와 선불 잔액 하나로 모든 음성 엔진을 사용하며, 실제 사용량에 따라 초 단위로 과금됩니다.
더 많은 가이드
Claude Code나 claude.ai의 Claude가 SIP 트렁크로 실제 전화를 걸고 대화 기록을 읽어 주는 상태. 전화를 걸기 전에는 항상 확인을 거칩니다.
Node.js로 만든 AI 접수원이 기존 Twilio 번호로 오는 전화를 받고 API에 예약을 잡는 상태.
Sarvam의 인도 언어 모델로 동작하며 SIP 트렁크로 전화를 받거나 거는 전화 에이전트 두 개(힌디어 하나, 말라얄람어 하나).
cURL 요청 네 번으로 SIP 트렁크를 통해 실제 전화를 거는 Gemini Live 에이전트, 그리고 대화 기록과 비용이 담긴 통화 기록.
에이전트가 SIP 트렁크로 리드 목록에 동시 통화 한도에 맞춘 배치 단위로 전화를 걸고, 모든 통화의 상태와 대화 기록을 읽어 오는 상태.
웹사이트 구석에서 방문자가 브라우저로 통화하는 말하는 마스코트. 지정한 도메인과 일일 한도 안에서만 동작하고, 모든 발신자가 목록에 남습니다.
실시간 통화 중 에이전트 음성에 맞춰 입을 움직이는 에이전트 마스코트가 있는 Flutter 화면. API 키는 서버에 둡니다.

