docs · API v1 Guide .md
Détection de répondeur · temps réel

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 wss://167-233-100-203.sslip.io/v1/stream
Essayer la démo live
0 msajoutée à la conversation : l'analyse tourne en parallèle
5,5 spremière suspicion de répondeur (médiane)
9 sraccrochage après le décroché (médiane)

En bref

  1. Vous streamez le correspondant

    L'audio du correspondant uniquement, jamais la voix de votre IA, à partir du décroché.

  2. Sonar renvoie des verdicts au fil de l'eau

    Humain, messagerie, message opérateur, standard ou serveur vocal.

  3. 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é

Déposez un enregistrement ou cliquez pour choisir WAV, MP3, M4A… 120 s analysées au maximum

Accès #

Environnement de dev.

WebSocket temps réelwss://167-233-100-203.sslip.io/v1/stream
API RESThttps://167-233-100-203.sslip.io
CléTransmise à part. À mettre dans SONAR_API_KEY, jamais dans le code.
Quota20 000 appels analysés par mois
Flux simultanés10 au maximum sur cet environnement
Durée analysée120 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

json
{"type": "start", "api_key": "<SONAR_API_KEY>", "call_id": "CA1234...",
 "sample_rate": 8000, "format": "mulaw"}
ChampValeurs
call_idVotre identifiant d'appel (CallSid Twilio, UUID FreeSWITCH), 128 caractères max.
sample_rate8000 recommandé ; les autres fréquences sont rééchantillonnées.
formatmulaw (Twilio), alaw, s16le (PCM 16 bits little-endian), f32le.
wrapOptionnel. "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.

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.

ChampValeurs
verdictmachine ou human (no_decision possible dans final).
labelhuman ; voicemail (annonce personnelle) ; operator_msg (messagerie ou message de l'opérateur) ; robot_ivr (standard, serveur vocal, file d'attente).
eventprovisional : 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.
actionhangup : raccrocher. wait : standard ou serveur vocal, un humain peut encore arriver. continue : humain, laisser l'IA parler.
t_decisionSecondes d'audio écoulées depuis le décroché au moment du verdict.

Quand raccrocher #

À appliquer tel quel.

RaccrocherVerdict confirmé

action == "hangup" et event parmi confirmed, final, update.

Raccrocher au bipAprès un verdict d'attente

Un beep reçu après un verdict avec action == "wait" ou event == "revoked".

JamaisSur un verdict provisoire seul

Un provisional peut encore être annulé : ne raccrochez jamais dessus.

règle
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" seul

Fail-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 #

CodeCause
4001Clé absente ou invalide (error.code 401), compte désactivé (403), quota atteint (429).
4002format inconnu.
1013Capacité 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.

ÉtapeDélai après le décroché
Première suspicion de répondeur5,5 s en médiane
Raccrochage9 s en médiane, 90 % sous 14 s
Verdict humain définitifvers 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 piste inbound, 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.
sonar_twilio.py
"""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.

  • 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 : notez AnsweredBy à 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 :

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, pas api. 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_fork ne relaie que les messages de type transcription.

Recevoir les verdicts

Abonnez l'ESL aux événements suivants :

ESL
events plain CHANNEL_ANSWER CHANNEL_HANGUP CUSTOM mod_audio_fork::transcription
  • Chaque verdict arrive en événement CUSTOM, sous-classe mod_audio_fork::transcription.
  • Le JSON est dans le corps de l'événement, et Unique-ID donne l'uuid du canal.
  • Selon la version du module, le corps contient l'enveloppe complète {"type":"transcription","data":{…}} ou directement {…} : déballez data s'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.

sonar_replay.py
"""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.

bash
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

bash
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.

  1. Branchez Sonar avec SONAR_SHADOW=1

    Il donne son verdict, mais ne coupe rien.

  2. 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, AnsweredBy de Twilio, issue de la conversation.

  3. 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.

  4. Passez SONAR_SHADOW=0

    Quand les chiffres sont validés.