Intégrer Sonar dans vos appels sortants
Envoyez l'audio du correspondant dès le décroché, recevez un verdict en quelques secondes : humain, messagerie, message opérateur ou standard. Vous raccrochez sur les machines, votre IA parle aux humains.
wss://167-233-100-203.sslip.io/v1/stream
En bref
- Vous streamez le correspondant
L'audio du correspondant uniquement, jamais la voix de votre IA, à partir du décroché.
- Sonar renvoie des verdicts au fil de l'eau
Humain, messagerie, message opérateur, standard ou serveur vocal.
- Vous raccrochez
En appliquant la règle de la section Quand raccrocher. L'IA parle dès le décroché, sans attendre Sonar.
Aucun audio n'est stocké. Sonar garde une ligne par appel : identifiant, durée et verdict.
Démo live #
L'audio part en temps réel sur l'API, exactement comme pendant un appel, et la page raccroche quand la règle de production le dit.
Aucun audio stocké · chaque test compte comme un appel sur votre clé
Vous jouez le correspondant. Répondez comme à un appel (« Allô ? Oui ? ») : Sonar doit conclure à un humain vers 11 s et laisser l'appel continuer. Faites jouer une messagerie près du micro : il doit raccrocher.
Accès #
Environnement de dev.
| WebSocket temps réel | wss://167-233-100-203.sslip.io/v1/stream |
| API REST | https://167-233-100-203.sslip.io |
| Clé | Transmise à part. À mettre dans SONAR_API_KEY, jamais dans le code. |
| Quota | 20 000 appels analysés par mois |
| Flux simultanés | 10 au maximum sur cet environnement |
| Durée analysée | 120 s d'audio au maximum par appel |
Un appel est compté dès qu'il a envoyé au moins 1 s d'audio. Cette adresse est un endpoint de dev : elle changera pour la prod.
Connexion WebSocket #
Une connexion WebSocket par appel, sur /v1/stream.
1. Premier message, en texte : start
{"type": "start", "api_key": "<SONAR_API_KEY>", "call_id": "CA1234...",
"sample_rate": 8000, "format": "mulaw"}| Champ | Valeurs |
|---|---|
call_id | Votre identifiant d'appel (CallSid Twilio, UUID FreeSWITCH), 128 caractères max. |
sample_rate | 8000 recommandé ; les autres fréquences sont rééchantillonnées. |
format | mulaw (Twilio), alaw, s16le (PCM 16 bits little-endian), f32le. |
wrap | Optionnel. "transcription" emballe chaque réponse en {"type":"transcription","data":{…}}, le format attendu par mod_audio_fork de FreeSWITCH. |
2. Ensuite, l'audio en messages binaires
Audio mono du correspondant, au format annoncé, en morceaux de 20 à 200 ms, au fil de l'appel.
Commencez au décroché (answer), pas pendant la sonnerie ni pendant l'early media.
3. Fin (optionnel)
Envoyez {"type": "end"}, ou fermez simplement la connexion.
Messages reçus #
Sonar envoie quatre types de messages, en JSON.
{"type": "verdict", "call_id": "CA1234...", "verdict": "machine", "label": "operator_msg",
"event": "confirmed", "action": "hangup", "t_decision": 8.4}Un verdict à chaque étape de la décision, au fil de l'appel.
{"type": "beep", "call_id": "CA1234...", "t": 15.04}Signale le bip d'une messagerie.
{"type": "final", "call_id": "CA1234...", "audio_seconds": 20.4, "verdict": "machine",
"label": "operator_msg", "action": "hangup", "beep": true}Arrive toujours en dernier, à la fermeture.
{"type": "error", "code": 401, "message": "clé API invalide ou révoquée"}Suivi de la fermeture de la connexion. Voir Erreurs.
| Champ | Valeurs |
|---|---|
verdict | machine ou human (no_decision possible dans final). |
label | human ; voicemail (annonce personnelle) ; operator_msg (messagerie ou message de l'opérateur) ; robot_ivr (standard, serveur vocal, file d'attente). |
event | provisional : première suspicion, peut être annulée. confirmed : machine confirmée. final : décision définitive. update : décision tardive après surveillance. revoked : suspicion annulée. |
action | hangup : raccrocher. wait : standard ou serveur vocal, un humain peut encore arriver. continue : humain, laisser l'IA parler. |
t_decision | Secondes d'audio écoulées depuis le décroché au moment du verdict. |
Quand raccrocher #
À appliquer tel quel.
action == "hangup" et event parmi confirmed, final, update.
Un beep reçu après un verdict avec action == "wait" ou event == "revoked".
Un provisional peut encore être annulé : ne raccrochez jamais dessus.
raccrocher si verdict.action == "hangup" ET verdict.event in {"confirmed", "final", "update"}
raccrocher si "beep" reçu APRÈS un verdict avec action == "wait" ou event == "revoked"
ne JAMAIS raccrocher sur un verdict "provisional" seulFail-open. Ne bloquez jamais l'appel sur Sonar. Si la connexion échoue, se ferme ou ne donne pas de verdict, l'appel continue normalement.
Erreurs et codes de fermeture #
| Code | Cause |
|---|---|
4001 | Clé absente ou invalide (error.code 401), compte désactivé (403), quota atteint (429). |
4002 | format inconnu. |
1013 | Capacité maximale atteinte. L'appel continue sans AMD ; réessayez sur l'appel suivant. |
Délais observés en production #
Mesurés sur ~400 000 appels, en octobre 2026.
| Étape | Délai après le décroché |
|---|---|
| Première suspicion de répondeur | 5,5 s en médiane |
| Raccrochage | 9 s en médiane, 90 % sous 14 s |
| Verdict humain définitif | vers 11 s |
Sonar attend volontairement une confirmation pour ne jamais couper un humain. L'IA parle dès le décroché, sans attendre le verdict humain.
Recette 1 : Twilio Media Streams #
Twilio envoie l'audio en μ-law 8 kHz, encodé en base64, par trames de 20 ms. Décodez le base64 et transmettez les octets tels quels, avec format: "mulaw".
- Vous avez déjà un serveur WebSocket pour
<Connect><Stream>(bidirectionnel) : ajoutez une session Sonar par appel, comme ci-dessous. Ce flux ne contient que la pisteinbound, donc le correspondant : c'est exactement ce qu'il faut. - Pour brancher Sonar à côté de votre pipeline actuel, sans le modifier : ajoutez
<Start><Stream url="wss://votre-serveur/sonar" track="inbound_track"/></Start>dans le TwiML. Twilio envoie alors une copie de l'audio du correspondant vers un second WebSocket, sur lequel vous appliquez le même code.
"""Session Sonar par appel Twilio. Fail-open : toute erreur laisse l'appel continuer."""
import asyncio, base64, json, os
import websockets
from twilio.rest import Client
SONAR_URL = "wss://167-233-100-203.sslip.io/v1/stream"
SONAR_KEY = os.environ["SONAR_API_KEY"]
SHADOW = os.environ.get("SONAR_SHADOW", "1") == "1" # 1 = on logge sans raccrocher
twilio = Client(os.environ["TWILIO_ACCOUNT_SID"], os.environ["TWILIO_AUTH_TOKEN"])
class SonarSession:
def __init__(self, call_sid: str):
self.call_sid = call_sid
self.ws = None
self.waiting_beep = False
self.done = False
async def start(self):
try:
self.ws = await websockets.connect(SONAR_URL, open_timeout=3)
await self.ws.send(json.dumps({
"type": "start", "api_key": SONAR_KEY, "call_id": self.call_sid,
"sample_rate": 8000, "format": "mulaw"}))
asyncio.create_task(self._read())
except Exception:
self.ws = None
async def feed(self, payload_b64: str):
if self.ws and not self.done:
try:
await self.ws.send(base64.b64decode(payload_b64))
except Exception:
self.ws = None
async def stop(self):
if self.ws:
try:
await self.ws.send(json.dumps({"type": "end"}))
await self.ws.close()
except Exception:
pass
async def _read(self):
try:
async for raw in self.ws:
m = json.loads(raw)
if m["type"] == "verdict":
if m["action"] == "hangup" and m["event"] in ("confirmed", "final", "update"):
await self._hangup(f'{m["event"]}:{m["label"]}')
elif m["action"] == "wait" or m["event"] == "revoked":
self.waiting_beep = True
elif m["type"] == "beep" and self.waiting_beep:
await self._hangup("beep")
elif m["type"] == "final":
print("sonar final", self.call_sid, m) # à journaliser chez vous
except Exception:
pass
async def _hangup(self, reason: str):
if self.done:
return
self.done = True
print("sonar hangup", self.call_sid, reason, "(shadow)" if SHADOW else "")
if not SHADOW:
await asyncio.to_thread(twilio.calls(self.call_sid).update, status="completed")
# Dans votre handler WebSocket Twilio :
# event "start" → sonar = SonarSession(msg["start"]["callSid"]); await sonar.start()
# event "media" → if msg["media"].get("track", "inbound") == "inbound":
# await sonar.feed(msg["media"]["payload"])
# event "stop" → await sonar.stop()Dépendances : pip install websockets twilio.
/** Session Sonar par appel Twilio. Fail-open : toute erreur laisse l'appel continuer. */
import WebSocket from "ws";
import twilio from "twilio";
const SONAR_URL = "wss://167-233-100-203.sslip.io/v1/stream";
const SONAR_KEY = process.env.SONAR_API_KEY ?? "";
const SHADOW = (process.env.SONAR_SHADOW ?? "1") === "1"; // 1 = on logge sans raccrocher
const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
type SonarMessage =
| { type: "verdict"; call_id: string; verdict: "machine" | "human"; label: string;
event: "provisional" | "confirmed" | "final" | "update" | "revoked";
action: "hangup" | "wait" | "continue"; t_decision: number }
| { type: "beep"; call_id: string; t: number }
| { type: "final"; call_id: string; audio_seconds: number; verdict: "machine" | "human" | "no_decision";
label: string | null; action: string | null; beep: boolean }
| { type: "error"; code: number; message: string };
export class SonarSession {
private ws: WebSocket | null = null;
private pending: Buffer[] = []; // audio reçu pendant la connexion à Sonar
private waitingBeep = false;
private done = false;
constructor(private readonly callSid: string) {}
start(): void {
try {
const ws = new WebSocket(SONAR_URL, { handshakeTimeout: 3000 });
ws.on("open", () => {
ws.send(JSON.stringify({ type: "start", api_key: SONAR_KEY, call_id: this.callSid,
sample_rate: 8000, format: "mulaw" }));
for (const chunk of this.pending) ws.send(chunk);
this.pending = [];
});
ws.on("message", (raw) => {
try {
void this.onMessage(JSON.parse(raw.toString()) as SonarMessage);
} catch {
// message illisible : ignoré
}
});
ws.on("error", () => { this.ws = null; });
ws.on("close", () => { this.ws = null; });
this.ws = ws;
} catch {
this.ws = null;
}
}
feed(payloadB64: string): void {
const ws = this.ws;
if (!ws || this.done) return;
const chunk = Buffer.from(payloadB64, "base64");
if (ws.readyState === WebSocket.OPEN) ws.send(chunk);
else if (ws.readyState === WebSocket.CONNECTING && this.pending.length < 250) this.pending.push(chunk);
}
stop(): void {
const ws = this.ws;
if (!ws) return;
try {
// Sonar répond par « final » puis ferme lui-même la connexion
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "end" }));
setTimeout(() => ws.close(), 5000).unref();
} catch {
// fail-open
}
}
private async onMessage(m: SonarMessage): Promise<void> {
if (m.type === "verdict") {
if (m.action === "hangup" && (m.event === "confirmed" || m.event === "final" || m.event === "update")) {
await this.hangup(`${m.event}:${m.label}`);
} else if (m.action === "wait" || m.event === "revoked") {
this.waitingBeep = true;
}
} else if (m.type === "beep" && this.waitingBeep) {
await this.hangup("beep");
} else if (m.type === "final") {
console.log("sonar final", this.callSid, m); // à journaliser chez vous
}
}
private async hangup(reason: string): Promise<void> {
if (this.done) return;
this.done = true;
console.log("sonar hangup", this.callSid, reason, SHADOW ? "(shadow)" : "");
if (!SHADOW) {
try {
await client.calls(this.callSid).update({ status: "completed" });
} catch {
// l'appel est peut-être déjà terminé
}
}
}
}
// Dans votre handler WebSocket Twilio :
// event "start" → const sonar = new SonarSession(msg.start.callSid); sonar.start();
// event "media" → if ((msg.media.track ?? "inbound") === "inbound") sonar.feed(msg.media.payload);
// event "stop" → sonar.stop();Dépendances (Node 18+) : npm i ws twilio et npm i -D @types/ws. L'audio reçu pendant la connexion à Sonar est mis en attente, puis envoyé.
- Le flux Twilio démarre au décroché, quand le TwiML s'exécute : rien à filtrer.
- Si la détection de répondeur de Twilio (
machineDetection) est active, ne la laissez pas raccrocher en même temps que Sonar. La garder en mode shadow est en revanche le meilleur moyen de comparer les deux : notezAnsweredByà côté du verdict Sonar. - Pour raccrocher, vous pouvez aussi arrêter votre agent IA au lieu de couper l'appel Twilio, selon votre architecture.
Recette 2 : FreeSWITCH #
Avec mod_audio_fork. C'est l'intégration qui tourne en production depuis juillet 2026, plus d'1,5 million d'appels analysés.
Au décroché de la jambe du correspondant (événement CHANNEL_ANSWER, jamais pendant l'early media), lancez le fork via ESL :
bgapi uuid_audio_fork <uuid> start wss://167-233-100-203.sslip.io/v1/stream mono 8000 {"type":"start","api_key":"<SONAR_API_KEY>","call_id":"<uuid>","sample_rate":8000,"format":"s16le","wrap":"transcription"}mono: seul l'audio reçu sur ce canal part, donc le correspondant. La voix de votre IA n'est jamais envoyée.- Le dernier argument est le message
start. Il doit être en JSON compact, sans espace, car c'est un seul argument ESL. - Utilisez
bgapi, pasapi. Le handshake WebSocket bloquerait sinon la connexion ESL ; sous charge, les événements s'accumulent et FreeSWITCH finit par fermer l'ESL. "wrap":"transcription"est indispensable :mod_audio_forkne relaie que les messages de typetranscription.
Recevoir les verdicts
Abonnez l'ESL aux événements suivants :
events plain CHANNEL_ANSWER CHANNEL_HANGUP CUSTOM mod_audio_fork::transcription- Chaque verdict arrive en événement
CUSTOM, sous-classemod_audio_fork::transcription. - Le JSON est dans le corps de l'événement, et
Unique-IDdonne l'uuid du canal. - Selon la version du module, le corps contient l'enveloppe complète
{"type":"transcription","data":{…}}ou directement{…}: déballezdatas'il est présent.
Raccrocher
Appliquez la règle Quand raccrocher, puis bgapi uuid_kill <uuid> NORMAL_CLEARING. Si votre IA est sur une autre jambe bridgée, tuez la jambe qui la porte.
Avec mod_audio_stream à la place
Le principe est le même (uuid_audio_stream <uuid> start <url> mono 8k <metadata>). Les messages de Sonar arrivent en événements mod_audio_stream::json : n'utilisez pas wrap. Vérifiez la syntaxe exacte de votre version du module, et mettez sample_rate à 16000 si vous streamez en 16k.
Tester sans appel réel #
Rejouer un enregistrement comme un appel en direct
Il faut Python 3.10+, pip install websockets et ffmpeg.
"""python sonar_replay.py appel.wav [--format mulaw] [--channel 0] [--realtime]"""
import argparse, asyncio, json, os, subprocess
import websockets
ap = argparse.ArgumentParser()
ap.add_argument("wav")
ap.add_argument("--format", default="mulaw", choices=["mulaw", "alaw", "s16le"])
ap.add_argument("--channel", type=int, default=0, help="canal du correspondant si stéréo")
ap.add_argument("--realtime", action="store_true")
a = ap.parse_args()
width = 2 if a.format == "s16le" else 1
audio = subprocess.run(["ffmpeg", "-v", "quiet", "-i", a.wav, "-af", f"pan=mono|c0=c{a.channel}",
"-ar", "8000", "-f", a.format, "-"], capture_output=True).stdout
async def main():
async with websockets.connect("wss://167-233-100-203.sslip.io/v1/stream") as ws:
await ws.send(json.dumps({"type": "start", "api_key": os.environ["SONAR_API_KEY"],
"call_id": a.wav, "sample_rate": 8000, "format": a.format}))
async def reader():
async for raw in ws:
m = json.loads(raw)
print(m)
if m["type"] in ("final", "error"):
return
r = asyncio.create_task(reader())
step = 1600 * width # 200 ms
for i in range(0, len(audio), step):
await ws.send(audio[i:i + step])
if a.realtime:
await asyncio.sleep(0.2)
await ws.send(json.dumps({"type": "end"}))
await asyncio.wait_for(r, 15)
asyncio.run(main())Analyser un fichier d'un coup
WAV ou MP3 ; channel = canal du correspondant.
curl -H "x-api-key: $SONAR_API_KEY" -F file=@appel.wav \
"https://167-233-100-203.sslip.io/v1/verify?channel=0"Consommation et derniers appels
curl -H "x-api-key: $SONAR_API_KEY" https://167-233-100-203.sslip.io/v1/usage
curl -H "x-api-key: $SONAR_API_KEY" "https://167-233-100-203.sslip.io/v1/calls?limit=50"Mesurer le gain (mode shadow) #
Pour chiffrer le retour sur investissement avant de laisser Sonar raccrocher.
- Branchez Sonar avec
SONAR_SHADOW=1Il donne son verdict, mais ne coupe rien.
- Journalisez chaque appel
La décision Sonar et le moment où il aurait raccroché, à côté de ce qui s'est réellement passé : durée,
AnsweredByde Twilio, issue de la conversation. - Comparez
Les secondes d'IA et de téléphonie économisées sur les répondeurs, et les humains que l'autre détection a coupés à tort.
- Passez
SONAR_SHADOW=0Quand les chiffres sont validés.