Skip to content

Commit 218875c

Browse files
fix(voice): transporte de áudio WebRTC via DataChannel pcm, resolução por wa_lid e avatar na UI
1 parent 39fd61b commit 218875c

9 files changed

Lines changed: 217 additions & 16 deletions

File tree

app/api/v1/voice/calls/[id]/accept/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,18 @@ export async function POST(
2929
const call = await resolveVoiceCall(supabase, activeOrg.orgId, id);
3030
if (!call) return fail("not_found", "Chamada não encontrada.", 404, { requestId });
3131

32+
if (call.status === "connected") {
33+
return ok({ id, status: "connected" }, { requestId });
34+
}
35+
3236
try {
3337
await wacalls.acceptCall(call.wacallsSessionId, call.wacallsCallId, user.id);
3438
return ok({ id, status: "connected" }, { requestId });
3539
} catch (err) {
40+
const msg = err instanceof Error ? err.message : String(err);
41+
if (msg.includes("409") || msg.includes("already")) {
42+
return ok({ id, status: "connected" }, { requestId });
43+
}
3644
return fail("wacalls_error", wacallsFriendlyError(err), 502, { requestId });
3745
}
3846
}

components/voice/ActiveCallPanel.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useVoiceCall } from "@/components/voice/VoiceCallContext";
55
import { useContact } from "@/hooks/contacts/useContact";
66
import { rotuloDoContato } from "@/lib/contacts/rotulo-do-contato";
77
import { phoneForDisplay } from "@/lib/channels/phone-variants";
8-
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
8+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
99
import { Button } from "@/components/ui/button";
1010
import { CircleNotch, Microphone, MicrophoneSlash, PhoneX } from "@/lib/ui/icons";
1111
import { useT } from "@/hooks/i18n/useT";
@@ -63,6 +63,13 @@ export function ActiveCallPanel() {
6363
className="fixed bottom-4 right-4 z-50 flex w-[min(320px,calc(100%-2rem))] items-center gap-3 rounded-xl border border-border bg-popover p-3 shadow-2xl animate-in fade-in slide-in-from-bottom-4"
6464
>
6565
<Avatar className="h-10 w-10 shrink-0">
66+
{contact?.id ? (
67+
<AvatarImage
68+
src={`/api/v1/contacts/${contact.id}/avatar`}
69+
alt=""
70+
className="object-cover"
71+
/>
72+
) : null}
6673
<AvatarFallback className="bg-primary/15 text-sm font-semibold text-primary">
6774
{inicial}
6875
</AvatarFallback>

components/voice/IncomingCallBanner.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useVoiceCall } from "@/components/voice/VoiceCallContext";
55
import { useContact } from "@/hooks/contacts/useContact";
66
import { rotuloDoContato } from "@/lib/contacts/rotulo-do-contato";
77
import { phoneForDisplay } from "@/lib/channels/phone-variants";
8-
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
8+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
99
import { Button } from "@/components/ui/button";
1010
import { Phone, PhoneX } from "@/lib/ui/icons";
1111
import { useT } from "@/hooks/i18n/useT";
@@ -69,6 +69,13 @@ export function IncomingCallBanner() {
6969
className="fixed inset-x-0 top-4 z-50 mx-auto flex w-[min(420px,calc(100%-2rem))] items-center gap-4 rounded-xl border border-border bg-popover p-4 shadow-2xl animate-in fade-in slide-in-from-top-4"
7070
>
7171
<Avatar className="h-12 w-12 shrink-0">
72+
{contact?.id ? (
73+
<AvatarImage
74+
src={`/api/v1/contacts/${contact.id}/avatar`}
75+
alt=""
76+
className="object-cover"
77+
/>
78+
) : null}
7279
<AvatarFallback className="bg-primary/15 text-base font-semibold text-primary">
7380
{inicial}
7481
</AvatarFallback>

hooks/voice/useVoiceCallSession.ts

Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { apiClient } from "@/lib/api/client";
55
import { showApiError } from "@/components/feedback/ApiErrorToast";
66
import { useAuth } from "@/hooks/auth/AuthProvider";
77
import { useRealtimeChannel } from "@/hooks/realtime/useRealtimeChannel";
8+
import { float32ToInt16LE, int16LEToFloat32 } from "@/lib/wacalls/pcm";
89

910
export 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

lib/wacalls/events-bridge.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,11 @@ async function handleCallStatus(
9191
(organization_id, channel_session_id, contact_id, wacalls_call_id, direction,
9292
peer_phone, status, started_at)
9393
values ($1, $2,
94-
(select id from contacts where organization_id = $1 and phone_number = $5 limit 1),
94+
(select id from contacts where organization_id = $1 and (phone_number = $5 or wa_lid = $5) and is_merged_into is null limit 1),
9595
$3, $4, $5, $6, to_timestamp($7 / 1000.0))
9696
on conflict (organization_id, wacalls_call_id) do update
9797
set status = excluded.status,
98+
contact_id = coalesce(voice_calls.contact_id, excluded.contact_id),
9899
answered_at = case
99100
when voice_calls.answered_at is null and excluded.status = 'connected'
100101
then now()

lib/wacalls/pcm.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from "vitest";
2+
import { float32ToInt16LE, int16LEToFloat32 } from "./pcm";
3+
4+
describe("PCM 16-bit LE audio converters", () => {
5+
it("converte Float32Array para ArrayBuffer Int16LE e de volta para Float32Array", () => {
6+
const input = new Float32Array([0, 0.5, -0.5, 1, -1]);
7+
const buffer = float32ToInt16LE(input);
8+
9+
expect(buffer.byteLength).toBe(input.length * 2);
10+
11+
const output = int16LEToFloat32(buffer);
12+
expect(output.length).toBe(input.length);
13+
14+
// Precisão aproximada devido à quantização 16-bit
15+
for (let i = 0; i < input.length; i++) {
16+
expect(output[i]).toBeCloseTo(input[i]!, 2);
17+
}
18+
});
19+
20+
it("limita amplitudes fora da faixa [-1, 1]", () => {
21+
const input = new Float32Array([1.5, -1.5, NaN]);
22+
const buffer = float32ToInt16LE(input);
23+
const output = int16LEToFloat32(buffer);
24+
25+
expect(output[0]).toBeCloseTo(1, 2);
26+
expect(output[1]).toBeCloseTo(-1, 2);
27+
expect(output[2]).toBe(0);
28+
});
29+
});

lib/wacalls/pcm.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Converte Float32Array (áudio capturado do microfone) para PCM 16-bit Little Endian ArrayBuffer.
3+
*/
4+
export function float32ToInt16LE(pcm: Float32Array): ArrayBuffer {
5+
const view = new DataView(new ArrayBuffer(pcm.length * 2));
6+
for (let i = 0; i < pcm.length; i += 1) {
7+
let s = pcm[i] ?? 0;
8+
if (Number.isNaN(s)) s = 0;
9+
else if (s > 1) s = 1;
10+
else if (s < -1) s = -1;
11+
view.setInt16(i * 2, s < 0 ? Math.round(s * 32768) : Math.round(s * 32767), true);
12+
}
13+
return view.buffer;
14+
}
15+
16+
/**
17+
* Converte PCM 16-bit Little Endian ArrayBuffer recebido do DataChannel para Float32Array (playback).
18+
*/
19+
export function int16LEToFloat32(buf: ArrayBuffer): Float32Array {
20+
const view = new DataView(buf);
21+
const n = Math.floor(buf.byteLength / 2);
22+
const out = new Float32Array(n);
23+
for (let i = 0; i < n; i += 1) {
24+
out[i] = view.getInt16(i * 2, true) / 32768;
25+
}
26+
return out;
27+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
class CaptureProcessor extends AudioWorkletProcessor {
2+
process(inputs) {
3+
const channel = inputs[0] && inputs[0][0];
4+
if (channel && channel.length) {
5+
this.port.postMessage(channel.slice(0));
6+
}
7+
return true;
8+
}
9+
}
10+
11+
registerProcessor("capture-processor", CaptureProcessor);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
const RING_SIZE = 16000 * 2;
2+
3+
class PlaybackProcessor extends AudioWorkletProcessor {
4+
constructor() {
5+
super();
6+
this.ring = new Float32Array(RING_SIZE);
7+
this.read = 0;
8+
this.write = 0;
9+
this.available = 0;
10+
this.port.onmessage = (e) => {
11+
const data = e.data;
12+
for (let i = 0; i < data.length; i += 1) {
13+
this.ring[this.write] = data[i];
14+
this.write = (this.write + 1) % RING_SIZE;
15+
if (this.available < RING_SIZE) {
16+
this.available += 1;
17+
} else {
18+
this.read = (this.read + 1) % RING_SIZE;
19+
}
20+
}
21+
};
22+
}
23+
24+
process(_inputs, outputs) {
25+
const out = outputs[0] && outputs[0][0];
26+
if (!out) return true;
27+
for (let i = 0; i < out.length; i += 1) {
28+
if (this.available > 0) {
29+
out[i] = this.ring[this.read];
30+
this.read = (this.read + 1) % RING_SIZE;
31+
this.available -= 1;
32+
} else {
33+
out[i] = 0;
34+
}
35+
}
36+
return true;
37+
}
38+
}
39+
40+
registerProcessor("playback-processor", PlaybackProcessor);

0 commit comments

Comments
 (0)