@@ -5,6 +5,7 @@ import { apiClient } from "@/lib/api/client";
55import { showApiError } from "@/components/feedback/ApiErrorToast" ;
66import { useAuth } from "@/hooks/auth/AuthProvider" ;
77import { useRealtimeChannel } from "@/hooks/realtime/useRealtimeChannel" ;
8+ import { float32ToInt16LE , int16LEToFloat32 } from "@/lib/wacalls/pcm" ;
89
910export type VoiceCallStatus = "starting" | "ringing" | "connected" | "ended" ;
1011
@@ -47,8 +48,11 @@ export function useVoiceCallSession(remoteAudioRef: RefObject<HTMLAudioElement |
4748 const [ connectingMedia , setConnectingMedia ] = useState ( false ) ;
4849
4950 const pcRef = useRef < RTCPeerConnection | null > ( null ) ;
51+ const dcRef = useRef < RTCDataChannel | null > ( null ) ;
52+ const audioCtxRef = useRef < AudioContext | null > ( null ) ;
5053 const localStreamRef = useRef < MediaStream | null > ( null ) ;
5154 const callRef = useRef < VoiceCallRow | null > ( null ) ;
55+ const isAcceptingRef = useRef ( false ) ;
5256 // Sincronizado em efeito, não durante o render: `callRef` só serve pra
5357 // closures de callback (accept/reject/hangUp) lerem o valor mais recente
5458 // sem entrar nas dependências — nunca é lido durante a renderização em si.
@@ -57,10 +61,26 @@ export function useVoiceCallSession(remoteAudioRef: RefObject<HTMLAudioElement |
5761 } , [ call ] ) ;
5862
5963 const teardownMedia = useCallback ( ( ) => {
60- pcRef . current ?. close ( ) ;
64+ try {
65+ dcRef . current ?. close ( ) ;
66+ } catch { }
67+ dcRef . current = null ;
68+
69+ try {
70+ pcRef . current ?. close ( ) ;
71+ } catch { }
6172 pcRef . current = null ;
62- localStreamRef . current ?. getTracks ( ) . forEach ( ( t ) => t . stop ( ) ) ;
73+
74+ try {
75+ localStreamRef . current ?. getTracks ( ) . forEach ( ( t ) => t . stop ( ) ) ;
76+ } catch { }
6377 localStreamRef . current = null ;
78+
79+ try {
80+ void audioCtxRef . current ?. close ( ) ;
81+ } catch { }
82+ audioCtxRef . current = null ;
83+
6484 if ( remoteAudioRef . current ) remoteAudioRef . current . srcObject = null ;
6585 setMuted ( false ) ;
6686 setConnectingMedia ( false ) ;
@@ -112,29 +132,77 @@ export function useVoiceCallSession(remoteAudioRef: RefObject<HTMLAudioElement |
112132 enabled : ! ! orgId ,
113133 } ) ;
114134
115- /** Abre a RTCPeerConnection, captura o microfone e troca o SDP com o backend . */
135+ /** Abre a RTCPeerConnection, conecta o DataChannel "pcm" e troca o áudio via AudioWorklets . */
116136 const conectarMidia = useCallback ( async ( callId : string ) => {
117137 setConnectingMedia ( true ) ;
118138 try {
119139 const stream = await navigator . mediaDevices . getUserMedia ( { audio : true } ) ;
120140 localStreamRef . current = stream ;
121141
122- const pc = new RTCPeerConnection ( ) ;
142+ const pc = new RTCPeerConnection ( { iceServers : [ ] } ) ;
123143 pcRef . current = pc ;
124- stream . getTracks ( ) . forEach ( ( track ) => pc . addTrack ( track , stream ) ) ;
125- pc . ontrack = ( ev ) => {
126- if ( remoteAudioRef . current ) {
127- remoteAudioRef . current . srcObject = ev . streams [ 0 ] ?? null ;
128- void remoteAudioRef . current . play ( ) . catch ( ( ) => { } ) ;
144+
145+ // O WaCalls opera áudio via DataChannel rotulado "pcm" com PCM 16kHz mono (Int16 LE)
146+ const dc = pc . createDataChannel ( "pcm" , { ordered : true } ) ;
147+ dc . binaryType = "arraybuffer" ;
148+ dcRef . current = dc ;
149+
150+ const AudioContextClass =
151+ window . AudioContext ||
152+ ( window as unknown as { webkitAudioContext : typeof AudioContext } ) . webkitAudioContext ;
153+ const ctx = new AudioContextClass ( { sampleRate : 16000 } ) ;
154+ audioCtxRef . current = ctx ;
155+
156+ await ctx . audioWorklet . addModule ( "/worklets/capture-processor.js" ) ;
157+ await ctx . audioWorklet . addModule ( "/worklets/playback-processor.js" ) ;
158+ await ctx . resume ( ) ;
159+
160+ // Microfone -> capture-processor -> DataChannel (PCM 16-bit LE)
161+ const micSource = ctx . createMediaStreamSource ( stream ) ;
162+ const captureNode = new AudioWorkletNode ( ctx , "capture-processor" ) ;
163+ captureNode . port . onmessage = ( e : MessageEvent < Float32Array > ) => {
164+ if ( dc . readyState === "open" ) {
165+ dc . send ( float32ToInt16LE ( e . data ) ) ;
129166 }
130167 } ;
168+ micSource . connect ( captureNode ) ;
169+ // Conectar ao destination mantém o AudioWorkletNode ativo no Chromium
170+ captureNode . connect ( ctx . destination ) ;
171+
172+ // DataChannel (PCM 16-bit LE) -> playback-processor -> MediaStreamDestination -> tag <audio>
173+ const playbackNode = new AudioWorkletNode ( ctx , "playback-processor" ) ;
174+ const streamDest = ctx . createMediaStreamDestination ( ) ;
175+ playbackNode . connect ( streamDest ) ;
176+ dc . onmessage = ( e : MessageEvent < ArrayBuffer > ) => {
177+ playbackNode . port . postMessage ( int16LEToFloat32 ( e . data ) ) ;
178+ } ;
179+
180+ if ( remoteAudioRef . current ) {
181+ remoteAudioRef . current . srcObject = streamDest . stream ;
182+ void remoteAudioRef . current . play ( ) . catch ( ( ) => { } ) ;
183+ }
131184
132- const offer = await pc . createOffer ( { offerToReceiveAudio : true } ) ;
185+ const offer = await pc . createOffer ( ) ;
133186 await pc . setLocalDescription ( offer ) ;
134187
188+ // Aguarda a coleta de candidatos ICE completar para enviar a oferta com todos os candidatos
189+ await new Promise < void > ( ( resolve ) => {
190+ if ( pc . iceGatheringState === "complete" ) {
191+ resolve ( ) ;
192+ } else {
193+ const checkState = ( ) => {
194+ if ( pc . iceGatheringState === "complete" ) {
195+ pc . removeEventListener ( "icegatheringstatechange" , checkState ) ;
196+ resolve ( ) ;
197+ }
198+ } ;
199+ pc . addEventListener ( "icegatheringstatechange" , checkState ) ;
200+ }
201+ } ) ;
202+
135203 const res = await apiClient . post < { data : { sdpAnswer : string } } > (
136204 `/api/v1/voice/calls/${ callId } /webrtc` ,
137- { sdpOffer : offer . sdp } ,
205+ { sdpOffer : pc . localDescription ! . sdp } ,
138206 ) ;
139207 await pc . setRemoteDescription ( { type : "answer" , sdp : res . data . sdpAnswer } ) ;
140208 } catch ( err ) {
@@ -143,7 +211,7 @@ export function useVoiceCallSession(remoteAudioRef: RefObject<HTMLAudioElement |
143211 } finally {
144212 setConnectingMedia ( false ) ;
145213 }
146- } , [ teardownMedia , remoteAudioRef ] ) ;
214+ } , [ remoteAudioRef , teardownMedia ] ) ;
147215
148216 // Assim que o Realtime confirma `connected`, abre o áudio — não antes: o
149217 // WaCalls só aceita a troca de SDP depois que o `<call>` foi realmente
@@ -168,11 +236,14 @@ export function useVoiceCallSession(remoteAudioRef: RefObject<HTMLAudioElement |
168236
169237 const acceptCall = useCallback ( async ( ) => {
170238 const atual = callRef . current ;
171- if ( ! atual ) return ;
239+ if ( ! atual || isAcceptingRef . current ) return ;
240+ isAcceptingRef . current = true ;
172241 try {
173242 await apiClient . post ( `/api/v1/voice/calls/${ atual . id } /accept` , { } ) ;
174243 } catch ( err ) {
175244 showApiError ( err ) ;
245+ } finally {
246+ isAcceptingRef . current = false ;
176247 }
177248 } , [ ] ) ;
178249
0 commit comments