# Sonar : intégrer la détection de répondeur (AMD) en temps réel

Ce document est écrit pour être donné tel quel à un agent de code (Claude Code) ou à un
développeur. Il contient tout ce qu'il faut pour brancher Sonar sur un appel sortant,
depuis **Twilio Media Streams** ou depuis **FreeSWITCH**.

## En bref

- Pendant l'appel, vous envoyez à Sonar l'audio **du correspondant uniquement** (pas la
  voix de votre IA), à partir du décroché.
- Sonar vous renvoie des verdicts au fil de l'eau : humain, messagerie, message opérateur,
  standard ou serveur vocal.
- C'est **vous** qui raccrochez, en appliquant la règle de la section « Quand raccrocher ».
- L'analyse tourne en parallèle : elle ne retarde jamais votre IA, qui parle dès le décroché.
- Aucun audio n'est stocké. Sonar garde une ligne par appel : identifiant, durée, verdict.

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

## Protocole WebSocket `/v1/stream`

Une connexion WebSocket par appel.

**1. Premier message, en texte : `start`**

```json
{"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)** : `{"type": "end"}`, ou fermez simplement la connexion.

### Messages reçus

```json
{"type": "verdict", "call_id": "CA1234...", "verdict": "machine", "label": "operator_msg",
 "event": "confirmed", "action": "hangup", "t_decision": 8.4}
{"type": "beep", "call_id": "CA1234...", "t": 15.04}
{"type": "final", "call_id": "CA1234...", "audio_seconds": 20.4, "verdict": "machine",
 "label": "operator_msg", "action": "hangup", "beep": true}
{"type": "error", "code": 401, "message": "clé API invalide ou révoquée"}
```

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

`beep` signale le bip d'une messagerie. `final` arrive toujours en dernier, à la fermeture.

### Quand raccrocher (à appliquer tel quel)

```
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
```

Ne bloquez jamais l'appel sur Sonar. Si la connexion échoue, se ferme ou ne donne pas de
verdict, l'appel continue normalement : c'est le principe « fail-open ».

### 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 (~400 000 appels, octobre 2026)

- Première suspicion de répondeur : 5,5 s en médiane après le décroché.
- Raccrochage : 9 s en médiane, 90 % sous 14 s. Sonar attend volontairement une
  confirmation pour ne jamais couper un humain.
- Verdict humain définitif : vers 11 s. L'IA parle dès le décroché, sans attendre ce verdict.

## Recette 1 : Twilio Media Streams

Twilio envoie l'audio en μ-law 8 kHz, encodé en base64, par trames de 20 ms. Il suffit de
décoder le base64 et de transmettre les octets tels quels (`format: "mulaw"`).

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

```python
"""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`.

**La même session en TypeScript** (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é.

```typescript
/** 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();
```

- 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. En revanche, la garder en mode shadow est 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 (`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`, 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 à
`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

**Démo live dans le navigateur** : https://sonar-docs.pages.dev/#demo (fichier audio ou
micro, avec votre clé).

**Rejouer un enregistrement comme un appel en direct.** Il faut Python 3.10+,
`pip install websockets` et `ffmpeg`.

```python
"""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, 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 pour chaque appel la décision Sonar, avec 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.
