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

# Building an Audio Research Notebook

> Turn your sources into cited answers and a two-host audio overview with 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" />

Tools like NotebookLM changed what people expect from a pile of research. You add sources, you ask questions and get answers that point back at the material, and then you generate a conversation between two hosts that you can listen to on a walk.

This guide builds that, in about two hundred lines of Python, on five Venice endpoints. Nothing is stored outside your machine except the requests themselves, and Venice does not retain those.

<Card title="Run this notebook in Google Colab" icon="notebook" href="https://colab.research.google.com/github/veniceai/api-docs/blob/main/notebooks/audio-research-notebook.ipynb">
  Every step below as an executable notebook, with the overview playing inline. Nothing to install.
</Card>

## How It Works

Five endpoints, each doing one job:

| Step                   | Endpoint               | Why                                              |
| ---------------------- | ---------------------- | ------------------------------------------------ |
| Read a web page        | `/augment/scrape`      | Returns Markdown, not HTML you have to clean     |
| Read a PDF or document | `/augment/text-parser` | One multipart upload, text back                  |
| Index the text         | `/embeddings`          | Lets you retrieve by meaning rather than keyword |
| Answer questions       | `/chat/completions`    | Grounded in retrieved passages, with citations   |
| Speak the overview     | `/audio/speech`        | Two voices, one per host                         |

The retrieval here is deliberately plain: vectors in a Python list, cosine similarity in a loop. That is the right amount of machinery for a few dozen sources and it keeps the moving parts visible. When you outgrow it, [Building a Private RAG Bot](/learn/private-rag-bot) covers the same pipeline with a real vector database and a re-ranking pass.

## Setting Up

One dependency, and a key from [the API settings page](/guides/getting-started/generating-api-key).

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

Create `notebook.py` and start with the imports and configuration. The two lists at the bottom are the whole state of the notebook: `sources` records what you added, and `chunks` holds the searchable pieces.

```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` maps a host name to a voice. Both voices come from `tts-xai-v1`, and that matters: voices belong to models, and sending a voice from one family to a model from another is the most common first mistake with the speech endpoint.

## Choosing a Model That Will Not Go Stale

Hardcoding a chat model into a project guarantees the project ages. Venice publishes which model currently holds each role through `/models/traits`, so you can ask for the current default instead of naming one.

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

Other traits are available if this notebook is not the shape you want. `most_intelligent` buys you a stronger model for the reasoning-heavy summarization, and `default_reasoning` gives you one that thinks in the open. See [Models](/models/overview) for the full list.

## Adding Sources

A source is either a URL or a file on disk, and Venice has an endpoint for each. Both return plain text, which is the point: the rest of the notebook does not care where a source came from.

```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` returns Markdown rather than raw HTML, so there is no boilerplate stripping to write. `/augment/text-parser` accepts PDF, Word, Excel, and plain text up to 25 MB, and reports a token count alongside the text. [Document Processing](/guides/tools/document-processing) covers its options in full.

## Chunking and Embedding

Embedding a whole document produces one vector that is an average of everything it says, which is too blunt to retrieve a specific claim. Splitting it produces vectors that each mean something.

Split on paragraph boundaries rather than a fixed character count. A chunk that stops mid-sentence retrieves badly, because the embedding is of a fragment.

```python theme={"system"}
def split(text, limit=1200):
    """Pack paragraphs into chunks without cutting one in half."""
    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` batches because the endpoint takes a list, and one request for sixty-four chunks is far cheaper in wall time than sixty-four requests. `text-embedding-bge-m3` returns 1024 dimensions and handles multilingual sources well.

Adding a source is now read, split, embed, and record. The magnitude of each vector gets stored alongside it, because it never changes and recomputing it inside the similarity loop is wasted work.

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

The `number` is what makes citation possible later. Every chunk remembers which source it came from, so an answer can point back at it.

## Retrieving the Right Passages

Cosine similarity between the question vector and every chunk vector, sorted, top k. For a few thousand chunks this runs faster than the network call that produced the question vector.

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

## Answering with Citations

The difference between a grounded answer and a confident guess is entirely in the prompt. Two instructions do the work: answer only from the notes, and say so when the notes fall short. Without the second one a model will quietly fill the gap from memory, which is the failure mode you are trying to design out.

Numbering the notes in the prompt gives the model a citation vocabulary. It writes `[2]`, and you can resolve that back to a source.

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

Parsing the brackets back out is worth the one line. It tells you which sources actually carried the answer, which is how you notice that a source you thought was central never gets cited.

## Writing the Overview Script

Here is where the notebook stops being a search box. A summary is something you read; an overview is something you listen to, and the two want different prose. Dialogue works better in audio because the turn-taking does the pacing for you, and a question from one host is a natural way to introduce the next idea.

Three constraints matter, and all three come from the audio rather than the text:

* **No markdown, no URLs.** A speech model reads `https://docs.venice.ai` one character at a time.
* **Spell out abbreviations.** *T E E* the first time, not *tee*.
* **Vary turn length.** Evenly sized turns sound like two people reading a list at each other.

Asking for JSON with a schema is what makes the result renderable. Free-form text would need parsing, and speaker labels are exactly the thing a model gets creative about. The `enum` on `speaker` means every turn maps to a voice you have.

```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):
    """Ask a chat model for a two-host dialogue grounded in the 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"]
```

The overview covers the sources broadly rather than answering one question, so `spread` samples chunks across the whole collection instead of retrieving by similarity. Taking every nth chunk is crude and works well: it reaches the end of long documents, which taking the first twelve never would.

Treat the turn count as a hint rather than an instruction. Asking for sixteen has produced anywhere from sixteen to twenty-eight here, depending on how much the sources have to say. If you need a hard ceiling, truncate `turns` before rendering rather than arguing with the prompt.

## Rendering Two Voices into One Track

Each turn becomes one speech request, with the voice chosen by who is speaking.

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

Reading the frames out of each clip, rather than saving twenty files and stitching them afterwards, is what keeps the join clean. Concatenating encoded audio such as MP3 does not work reliably, because every file carries its own headers. Decoded frames are just samples, so joining them is appending bytes.

Two details make the result sound intentional. The header for the output comes from the first clip rather than from constants, so the sample rate is always right for whichever model you chose. And a quarter second of silence between turns gives the ear a beat to register that the speaker changed. Without it the hosts talk over each other's endings.

```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` preserves input order, so the turns come back in the order they were written no matter which finishes first. Four workers is a deliberate ceiling rather than a maximum: more concurrency will start returning 429s on lower tiers, and the job is already dominated by the longest single turn.

## Running It

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

Ingesting and answering takes a few seconds. The audio is the slow part, and it varies with load: about six minutes of speech takes anywhere from half a minute to three minutes to render.

## Making It Yours

**The sources are the whole game.** Everything downstream is bounded by what you put in. Scraped pages bring their navigation and footers along, which is harmless for answering but shows up in an overview as a host earnestly discussing a documentation index. If that happens, drop chunks below a length threshold or filter obvious furniture before embedding.

**Swap the voices.** `HOSTS` is two entries in a dictionary. `tts-xai-v1` ships twenty-six voices, and other families have their own; `GET /models?type=tts` lists `voices` per model. Two voices that contrast clearly are easier to follow than two that are merely different.

**Clone your own.** [Voice Cloning](/guides/media/voice-cloning) turns a short sample into a voice handle you can drop straight into `HOSTS`.

**Add a third participant.** Nothing in the pipeline assumes two speakers except the schema `enum`. Adding an interviewer who only asks questions changes the feel considerably.

**Keep the script.** Writing `turns` to a JSON file next to the audio costs two lines and saves a re-render every time you want to tweak one sentence.

## Where to Go Next

<CardGroup cols={2}>
  <Card title="Private RAG Bot" icon="database" href="/learn/private-rag-bot">
    The same retrieval pipeline with a real vector database and re-ranking.
  </Card>

  <Card title="Cited Answers with Web Search" icon="search" href="/guides/tools/cited-web-answers">
    Find the sources automatically instead of naming them yourself.
  </Card>

  <Card title="Text-to-Speech" icon="microphone" href="/guides/media/text-to-speech">
    Reference for the speech endpoint, its voices, and streaming.
  </Card>

  <Card title="Document Processing" icon="file-text" href="/guides/tools/document-processing">
    Everything the text parser accepts, and what it returns.
  </Card>
</CardGroup>
