> ## Documentation Index
> Fetch the complete documentation index at: https://docs.venice.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Créer un carnet de recherche audio

> Transformez vos sources en réponses citées et en aperçu audio à deux voix avec Venice.

export const AuthorByline = ({name, date}) => {
  return <p style={{
    marginTop: "-1rem",
    marginBottom: "1.5rem"
  }}>
      <small>
        Originally written by {name} - {date}
      </small>
    </p>;
};

<AuthorByline name="Sabrina Aquino" date="20 August 2026" />

Des outils comme NotebookLM ont changé ce que les gens attendent d'un tas de documents de recherche. Vous ajoutez des sources, vous posez des questions et vous obtenez des réponses qui renvoient au matériel, puis vous générez une conversation entre deux animateurs que vous pouvez écouter en marchant.

Ce guide construit tout cela, en environ deux cents lignes de Python, sur cinq endpoints Venice. Rien n'est stocké en dehors de votre machine, à part les requêtes elles-mêmes, et Venice ne les conserve pas.

<Card title="Exécuter ce carnet dans Google Colab" icon="notebook" href="https://colab.research.google.com/github/veniceai/api-docs/blob/main/notebooks/audio-research-notebook.ipynb">
  Toutes les étapes ci-dessous sous forme de carnet exécutable, avec l'aperçu qui se lit en ligne. Rien à installer.
</Card>

## Fonctionnement

Cinq endpoints, chacun avec un seul rôle :

| Étape                      | Endpoint               | Pourquoi                                             |
| -------------------------- | ---------------------- | ---------------------------------------------------- |
| Lire une page web          | `/augment/scrape`      | Renvoie du Markdown, pas du HTML à nettoyer          |
| Lire un PDF ou un document | `/augment/text-parser` | Un seul upload multipart, du texte en retour         |
| Indexer le texte           | `/embeddings`          | Permet de rechercher par sens plutôt que par mot-clé |
| Répondre aux questions     | `/chat/completions`    | Ancré dans les passages récupérés, avec citations    |
| Prononcer l'aperçu         | `/audio/speech`        | Deux voix, une par animateur                         |

La récupération est ici délibérément simple : des vecteurs dans une liste Python, une similarité cosinus dans une boucle. C'est la juste dose de mécanique pour quelques dizaines de sources et cela garde les rouages visibles. Quand vous en aurez besoin de plus, [Créer un bot RAG privé](/fr/learn/private-rag-bot) couvre le même pipeline avec une véritable base vectorielle et une passe de re-classement.

## Mise en place

Une seule dépendance, et une clé depuis [la page des paramètres API](/fr/guides/getting-started/generating-api-key).

```bash theme={"system"}
pip install requests
export VENICE_API_KEY="your-key-here"
```

Créez `notebook.py` et commencez par les imports et la configuration. Les deux listes en bas constituent tout l'état du carnet : `sources` enregistre ce que vous avez ajouté, et `chunks` contient les morceaux consultables.

```python theme={"system"}
import io
import json
import os
import re
import wave
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

import requests

BASE_URL = "https://api.venice.ai/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VENICE_API_KEY']}"}

EMBED_MODEL = "text-embedding-bge-m3"
TTS_MODEL = "tts-xai-v1"
HOSTS = {"Ana": "luna", "Marco": "orion"}

sources = []
chunks = []
```

`HOSTS` associe un nom d'animateur à une voix. Les deux voix proviennent de `tts-xai-v1`, et cela compte : les voix appartiennent aux modèles, et envoyer une voix d'une famille à un modèle d'une autre est l'erreur la plus courante lorsqu'on débute avec l'endpoint de synthèse vocale.

## Choisir un modèle qui ne vieillira pas

Coder en dur un modèle de chat dans un projet garantit son vieillissement. Venice publie via `/models/traits` quel modèle occupe actuellement chaque rôle, ce qui vous permet de demander le modèle par défaut du moment plutôt que d'en nommer un.

```python theme={"system"}
def default_text_model():
    response = requests.get(
        f"{BASE_URL}/models/traits", headers=HEADERS, params={"type": "text"}, timeout=60
    )
    response.raise_for_status()
    return response.json()["data"]["default"]


CHAT_MODEL = default_text_model()


def chat(messages, **options):
    response = requests.post(
        f"{BASE_URL}/chat/completions",
        headers=HEADERS,
        json={"model": CHAT_MODEL, "messages": messages, **options},
        timeout=300,
    )
    response.raise_for_status()
    return response.json()["choices"][0]["message"]["content"]
```

D'autres traits sont disponibles si ce carnet n'a pas la forme que vous voulez. `most_intelligent` vous fournit un modèle plus puissant pour la synthèse qui demande beaucoup de raisonnement, et `default_reasoning` vous en donne un qui raisonne à voix ouverte. Voir [Modèles](/fr/models/overview) pour la liste complète.

## Ajouter des sources

Une source est soit une URL soit un fichier sur le disque, et Venice a un endpoint pour chacun. Tous deux renvoient du texte brut, ce qui est le but : le reste du carnet ne se soucie pas de la provenance d'une source.

```python theme={"system"}
def read_url(url):
    response = requests.post(
        f"{BASE_URL}/augment/scrape", headers=HEADERS, json={"url": url}, timeout=180
    )
    response.raise_for_status()
    return response.json()["content"]


def read_file(path):
    with open(path, "rb") as handle:
        response = requests.post(
            f"{BASE_URL}/augment/text-parser",
            headers=HEADERS,
            files={"file": (Path(path).name, handle)},
            timeout=180,
        )
    response.raise_for_status()
    return response.json()["text"]
```

`/augment/scrape` renvoie du Markdown plutôt que du HTML brut, il n'y a donc pas de code de nettoyage à écrire. `/augment/text-parser` accepte les PDF, Word, Excel et texte brut jusqu'à 25 Mo, et retourne un décompte de tokens à côté du texte. [Traitement de documents](/fr/guides/tools/document-processing) couvre toutes ses options.

## Découpage et embeddings

Embedder un document entier produit un unique vecteur qui est une moyenne de tout ce qui y est dit, ce qui est trop grossier pour récupérer une affirmation précise. Le découper produit des vecteurs qui veulent chacun dire quelque chose.

Découpez sur les frontières de paragraphes plutôt que sur un nombre fixe de caractères. Un chunk qui s'arrête au milieu d'une phrase est mal récupéré, parce que son embedding est celui d'un fragment.

```python theme={"system"}
def split(text, limit=1200):
    """Regroupe les paragraphes en chunks sans en couper un en deux."""
    packed, current = [], ""
    for para in re.split(r"\n\s*\n", text):
        para = para.strip()
        if not para:
            continue
        if current and len(current) + len(para) + 2 > limit:
            packed.append(current)
            current = para
        else:
            current = f"{current}\n\n{para}" if current else para
    if current:
        packed.append(current)
    return packed


def embed(texts):
    vectors = []
    for start in range(0, len(texts), 64):
        response = requests.post(
            f"{BASE_URL}/embeddings",
            headers=HEADERS,
            json={"model": EMBED_MODEL, "input": texts[start : start + 64]},
            timeout=180,
        )
        response.raise_for_status()
        vectors.extend(row["embedding"] for row in response.json()["data"])
    return vectors
```

`embed` traite par lots parce que l'endpoint prend une liste, et une seule requête pour soixante-quatre chunks est bien plus rapide en temps réel que soixante-quatre requêtes. `text-embedding-bge-m3` renvoie 1024 dimensions et gère bien les sources multilingues.

Ajouter une source, c'est désormais lire, découper, embedder et enregistrer. La magnitude de chaque vecteur est stockée à côté de lui, parce qu'elle ne change jamais et que la recalculer à l'intérieur de la boucle de similarité serait du travail perdu.

```python theme={"system"}
def add_source(title, ref):
    text = read_url(ref) if ref.startswith("http") else read_file(ref)
    number = len(sources) + 1
    sources.append({"number": number, "title": title, "ref": ref})

    pieces = split(text)
    for piece, vector in zip(pieces, embed(pieces)):
        magnitude = sum(x * x for x in vector) ** 0.5
        chunks.append(
            {"source": number, "title": title, "text": piece,
             "vector": vector, "magnitude": magnitude}
        )
    print(f"[{number}] {title}: {len(text)} characters, {len(pieces)} chunks")
```

Le `number` est ce qui rend la citation possible par la suite. Chaque chunk se souvient de la source dont il provient, de sorte qu'une réponse peut y renvoyer.

## Récupérer les bons passages

Similarité cosinus entre le vecteur de la question et chaque vecteur de chunk, tri, top k. Pour quelques milliers de chunks, cela s'exécute plus vite que l'appel réseau qui a produit le vecteur de la question.

```python theme={"system"}
def retrieve(question, k=6):
    query = embed([question])[0]
    query_magnitude = sum(x * x for x in query) ** 0.5

    def similarity(chunk):
        dot = sum(a * b for a, b in zip(query, chunk["vector"]))
        return dot / (query_magnitude * chunk["magnitude"])

    return sorted(chunks, key=similarity, reverse=True)[:k]
```

## Répondre avec des citations

La différence entre une réponse ancrée et une supposition assurée tient entièrement au prompt. Deux instructions font le travail : ne répondre qu'à partir des notes, et le dire lorsque les notes ne suffisent pas. Sans la seconde, un modèle comblera discrètement la lacune avec sa mémoire, ce qui est exactement le mode de défaillance que vous cherchez à éliminer par conception.

Numéroter les notes dans le prompt donne au modèle un vocabulaire de citation. Il écrit `[2]`, et vous pouvez remonter à la source correspondante.

```python theme={"system"}
def ask(question, k=6):
    hits = retrieve(question, k)
    notes = "\n\n".join(f"[{h['source']}] {h['title']}\n{h['text']}" for h in hits)
    answer = chat(
        [
            {"role": "system", "content": (
                "Answer only from the numbered notes. Cite every claim with the bracket number "
                "of the note it came from. If the notes do not answer the question, say so "
                "instead of filling the gap.")},
            {"role": "user", "content": f"Notes:\n\n{notes}\n\nQuestion: {question}"},
        ],
        temperature=0.2,
    )
    cited = sorted({int(n) for n in re.findall(r"\[(\d+)\]", answer)})
    return answer, [s for s in sources if s["number"] in cited]
```

Extraire les crochets à nouveau vaut bien la ligne unique. Cela vous dit quelles sources ont réellement porté la réponse, et c'est ainsi que vous remarquez qu'une source que vous pensiez centrale n'est jamais citée.

## Rédiger le script de l'aperçu

C'est là que le carnet cesse d'être une boîte de recherche. Un résumé, c'est quelque chose qu'on lit ; un aperçu, c'est quelque chose qu'on écoute, et les deux appellent une prose différente. Le dialogue fonctionne mieux à l'oral parce que le tour de parole fait la mise en rythme pour vous, et une question posée par un animateur est une manière naturelle d'introduire l'idée suivante.

Trois contraintes comptent, et toutes trois viennent de l'audio et non du texte :

* **Pas de markdown, pas d'URL.** Un modèle de synthèse vocale lit `https://docs.venice.ai` un caractère à la fois.
* **Développez les abréviations.** *T E E* la première fois, pas *tee*.
* **Variez la longueur des tours.** Des tours de longueur égale donnent l'impression de deux personnes qui se lisent une liste à haute voix.

Demander du JSON avec un schéma est ce qui rend le résultat exploitable. Du texte libre demanderait un parsing, et les étiquettes de locuteur sont précisément le genre de chose sur laquelle un modèle s'autorise à être créatif. L'`enum` sur `speaker` garantit que chaque tour correspond à une voix dont vous disposez.

```python theme={"system"}
DIALOGUE_SCHEMA = {
    "type": "json_schema",
    "json_schema": {
        "name": "dialogue",
        "strict": True,
        "schema": {
            "type": "object",
            "additionalProperties": False,
            "required": ["turns"],
            "properties": {
                "turns": {
                    "type": "array",
                    "items": {
                        "type": "object",
                        "additionalProperties": False,
                        "required": ["speaker", "text"],
                        "properties": {
                            "speaker": {"type": "string", "enum": list(HOSTS)},
                            "text": {"type": "string"},
                        },
                    },
                }
            },
        },
    },
}


def write_script(turns=16):
    """Demande à un modèle de chat un dialogue à deux animateurs, ancré dans les sources."""
    spread = chunks[:: max(1, len(chunks) // 12)][:12]
    notes = "\n\n".join(f"{c['title']}\n{c['text']}" for c in spread)
    hosts = " and ".join(HOSTS)
    raw = chat(
        [
            {"role": "system", "content": (
                f"You write podcast dialogue for two hosts, {hosts}. Ground every statement in "
                "the supplied notes. Write for the ear: no markdown, no URLs, no bracket "
                "citations, no stage directions. Spell out abbreviations the first time they "
                "appear. Vary the length of turns. Open with a hook and close with a takeaway.")},
            {"role": "user", "content": f"Notes:\n\n{notes}\n\nWrite about {turns} turns."},
        ],
        temperature=0.7,
        response_format=DIALOGUE_SCHEMA,
    )
    return json.loads(raw)["turns"]
```

L'aperçu couvre les sources en largeur plutôt que de répondre à une question, donc `spread` échantillonne des chunks sur l'ensemble de la collection au lieu de récupérer par similarité. Prendre un chunk sur n est grossier et fonctionne bien : cela atteint la fin des documents longs, ce que prendre les douze premiers ne ferait jamais.

Traitez le nombre de tours comme un indice plutôt que comme une instruction. Demander seize en a produit ici de seize à vingt-huit, selon ce que les sources avaient à dire. S'il vous faut un plafond dur, tronquez `turns` avant le rendu plutôt que de discuter avec le prompt.

## Assembler deux voix en une seule piste

Chaque tour devient une requête de synthèse vocale, avec la voix choisie selon qui parle.

```python theme={"system"}
def speak(turn):
    response = requests.post(
        f"{BASE_URL}/audio/speech",
        headers=HEADERS,
        json={"model": TTS_MODEL, "voice": HOSTS[turn["speaker"]],
              "input": turn["text"], "response_format": "wav"},
        timeout=300,
    )
    response.raise_for_status()
    with wave.open(io.BytesIO(response.content)) as clip:
        return clip.getparams(), clip.readframes(clip.getnframes())
```

Lire les frames de chaque clip, plutôt que d'enregistrer vingt fichiers et de les recoller ensuite, est ce qui garde la jointure propre. Concaténer de l'audio encodé comme du MP3 ne fonctionne pas de manière fiable, parce que chaque fichier porte ses propres en-têtes. Les frames décodées ne sont que des échantillons, et les assembler revient à ajouter des octets.

Deux détails font sonner le résultat comme intentionnel. L'en-tête de la sortie provient du premier clip plutôt que de constantes, si bien que la fréquence d'échantillonnage est toujours correcte pour le modèle que vous avez choisi. Et un quart de seconde de silence entre les tours donne à l'oreille un temps pour enregistrer que le locuteur a changé. Sans cela, les animateurs se marchent sur la fin des phrases de l'autre.

```python theme={"system"}
def audio_overview(turns, path="overview.wav", pause_seconds=0.25):
    with ThreadPoolExecutor(max_workers=4) as pool:
        rendered = list(pool.map(speak, turns))

    params = rendered[0][0]
    silence = b"\x00" * int(params.framerate * params.sampwidth * params.nchannels * pause_seconds)
    with wave.open(path, "wb") as out:
        out.setnchannels(params.nchannels)
        out.setsampwidth(params.sampwidth)
        out.setframerate(params.framerate)
        for position, (_, frames) in enumerate(rendered):
            if position:
                out.writeframes(silence)
            out.writeframes(frames)
    return path
```

`pool.map` préserve l'ordre d'entrée, si bien que les tours reviennent dans l'ordre où ils ont été écrits, peu importe lequel finit en premier. Quatre workers sont un plafond délibéré plutôt qu'un maximum : davantage de concurrence commencera à renvoyer des 429 sur les paliers inférieurs, et le job est déjà dominé par le tour le plus long.

## Exécution

```python theme={"system"}
if __name__ == "__main__":
    add_source("Venice Privacy", "https://docs.venice.ai/overview/privacy")
    add_source("TEE and E2EE Models", "https://docs.venice.ai/guides/features/tee-e2ee-models")
    add_source("VVV and DIEM", "https://docs.venice.ai/overview/vvv-diem")

    answer, cited = ask("How does Venice keep my prompts private, and what do I give up?")
    print(answer)
    print("\nSources:", ", ".join(f"[{s['number']}] {s['title']}" for s in cited))

    turns = write_script()
    print(f"\nWriting {len(turns)} turns to overview.wav")
    audio_overview(turns)
```

```bash theme={"system"}
python notebook.py
```

```
[1] Venice Privacy: 7507 characters, 7 chunks
[2] TEE and E2EE Models: 43859 characters, 40 chunks
[3] VVV and DIEM: 9609 characters, 11 chunks

Venice's privacy architecture is built around a proxy foundation. All requests pass
through Venice over HTTPS and are relayed to the model provider without Venice storing
your prompt or response content [1]. On top of that proxy, each model offers one of four
progressively stronger privacy modes [1]...

Sources: [1] Venice Privacy

Writing 23 turns to overview.wav
```

L'ingestion et la réponse prennent quelques secondes. L'audio est la partie lente, et cela varie avec la charge : environ six minutes de parole prennent entre trente secondes et trois minutes à rendre.

## Le rendre vôtre

**Les sources sont tout l'enjeu.** Tout ce qui vient ensuite est borné par ce que vous mettez en entrée. Les pages scrapées entraînent avec elles leur navigation et leurs pieds de page, ce qui est sans conséquence pour les réponses mais se manifeste dans un aperçu par un animateur discutant sérieusement d'un index de documentation. Si cela arrive, écartez les chunks en dessous d'un seuil de longueur ou filtrez le mobilier évident avant l'embedding.

**Changez les voix.** `HOSTS` n'est qu'un dictionnaire à deux entrées. `tts-xai-v1` propose vingt-six voix, et d'autres familles ont les leurs ; `GET /models?type=tts` liste les `voices` par modèle. Deux voix qui contrastent nettement se suivent plus facilement que deux qui sont simplement différentes.

**Clonez la vôtre.** [Clonage de voix](/fr/guides/media/voice-cloning) transforme un court échantillon en un identifiant de voix que vous pouvez glisser directement dans `HOSTS`.

**Ajoutez un troisième participant.** Rien dans le pipeline ne suppose deux locuteurs, sauf l'`enum` du schéma. Ajouter un intervieweur qui ne pose que des questions change considérablement le rendu.

**Gardez le script.** Écrire `turns` dans un fichier JSON à côté de l'audio coûte deux lignes et vous épargne un re-rendu à chaque fois que vous voulez retoucher une phrase.

## Pour aller plus loin

<CardGroup cols={2}>
  <Card title="Bot RAG privé" icon="database" href="/fr/learn/private-rag-bot">
    Le même pipeline de récupération avec une véritable base vectorielle et un re-classement.
  </Card>

  <Card title="Réponses citées avec recherche web" icon="search" href="/fr/guides/tools/cited-web-answers">
    Trouver les sources automatiquement au lieu de les nommer soi-même.
  </Card>

  <Card title="Synthèse vocale" icon="microphone" href="/fr/guides/media/text-to-speech">
    Référence de l'endpoint de synthèse vocale, de ses voix et du streaming.
  </Card>

  <Card title="Traitement de documents" icon="file-text" href="/fr/guides/tools/document-processing">
    Tout ce que le parser de documents accepte, et ce qu'il retourne.
  </Card>
</CardGroup>
