THE WORLD IS NOT STANDING STILL.RSS
BIG CHANGE.
BIG CHANGE, one tap away
BIG CHANGE, one tap away

Make room for the big picture.

Add BIG CHANGE to your home screen or desktop. It opens in its own window, with the latest stories when you’re online.

iPhone & iPad
Open bigchange.ai in Safari. Tap Share, then Add to Home Screen. Turn on Open as Web App if shown, then tap Add.
Android
Open bigchange.ai in Chrome. In the browser menu, choose Install app or Add to Home screen.
Desktop
In Chrome or Edge, use the install icon in the address bar or the browser’s app menu. In Safari on Mac, choose File → Add to Dock.

Markdown edition

# Turn a source-checked audio brief into a WAV file

> Run a cited OpenAI web search, check its sources, approve two or three spoken sentences, then generate and listen through a Gemini TTS WAV with the complete Python script.

By BIGCHANGE.AI Editorial

Published: 2026-10-26T20:18:38.485Z
Updated: 2026-10-26T20:18:38.485Z
Canonical: https://bigchange.ai/blog/source-checked-audio-brief-wav-openai-gemini-guide

![Conceptual charcoal illustration of one headphone listener holding a short spoken-copy sheet beside an unbranded laptop.](https://bigchange.ai/api/media/file/source-checked-audio-listening-hero-v1.png)
AI-generated conceptual illustration by BIG CHANGE; no actual OpenAI or Gemini output, checked source, recording, listening test or result is depicted.

This guide takes one public topic through a cited web search, your own fact check, an approved short script and a local speech file. You need access to two separate API projects. The code makes the research and speech requests; only you can decide whether the sources support the words you will publish.

## The big change

- **What changed:** OpenAI's Responses web search can return an inspectable search action and URL citations; Gemini 3.8 Flash TTS can return WAV audio from approved text. [OpenAI search guide](https://developers.openai.com/api/docs/guides/tools-web-search); [Gemini TTS guide](https://ai.google.dev/gemini-api/docs/speech-generation).
- **Why it matters:** You can keep the source trail, the exact spoken copy and the resulting file together, then check each boundary yourself.
- **What to watch:** A clickable citation is a lead to inspect. The WAV format checks cannot tell you whether the voice spoke a name or number correctly.

## 1. Prepare a permitted topic and two projects

Use a specific public topic that you have permission to send to OpenAI for search. Keep personal or confidential material out of this example; if your work requires it, choose authorized projects and review each provider's data terms first. An OpenAI API project needs a key, billing and access to the [Responses API](https://developers.openai.com/api/docs/quickstart), [`gpt-6-luna`](https://developers.openai.com/api/docs/models/gpt-6-luna) and its native `web_search` tool. A separate Gemini API project needs access to [`gemini-3.8-flash-tts`](https://ai.google.dev/gemini-api/docs/speech-generation) in an [available region](https://ai.google.dev/gemini-api/docs/available-regions). [Google's terms](https://ai.google.dev/gemini-api/terms) require the operator to be at least 18 and building for professional or business purposes; check the correct service tier for your project and use.

On Windows, install Python 3.11 with its `py` launcher and use a currently supported PowerShell release of at least 7.3. Run the commands in a new working folder. The direct virtual-environment Python path avoids a PowerShell activation-policy change. Google's TTS guide specifies `google-genai>=2.25.0`; the commands pin the currently checked 2.33.0 release. Its [tag](https://github.com/googleapis/python-genai/releases/tag/v2.33.0) establishes release existence.

```powershell
py -3.11 -m venv .venv
.\.venv\Scripts\python.exe -m pip install "google-genai==2.33.0"
.\.venv\Scripts\python.exe -c "import importlib.metadata as m; print(m.version('google-genai'))"
$env:OPENAI_API_KEY = Read-Host 'OpenAI API key' -MaskInput
$env:GEMINI_API_KEY = Read-Host 'Gemini API key' -MaskInput
```

Keep the keys out of files, terminal output and source control. The script reads `OPENAI_API_KEY` for the OpenAI HTTPS request and passes `GEMINI_API_KEY` explicitly to `genai.Client(api_key=...)`. This matters because [Google's key guide](https://ai.google.dev/gemini-api/docs/api-key) says an automatically detected `GOOGLE_API_KEY` takes precedence if both Google variables are set and no key is passed explicitly. Check each project's current access, rate limits and spend controls before a paid call.

## 2. Save the complete program

Save the following file as `brief_audio.py` in that folder. It uses Python's standard library for the OpenAI request and the pinned Google SDK for TTS. The research path requires a completed response with an actual completed search action and a URL citation. It saves the returned text and block-relative citation spans to JSON, and renders that text literally in escaped HTML with real clickable links after the cited spans. Code fences, inline backticks, indentation and non-ASCII text remain visible as text. If character-offset interpretation becomes ambiguous for astral or surrogate text, it stops for manual resolution.

```python
"""Documented searched-topic-to-human-checked-brief-to-WAV example.

Python 3.11+; google-genai >=2.25.0 for the speak command. Import makes no calls.
The research command uses OpenAI Responses REST; speak uses Gemini TTS.
"""

import argparse
import base64
from datetime import datetime, timezone
from html import escape
from io import BytesIO
import json
import os
from pathlib import Path
from urllib.request import Request, urlopen
from uuid import uuid4
import wave


SEARCH_MODEL = "gpt-6-luna"
SPEECH_MODEL = "gemini-3.8-flash-tts"


def request_openai(topic):
    key = os.environ.get("OPENAI_API_KEY", "").strip()
    if not key:
        raise SystemExit("Set OPENAI_API_KEY for the selected project first.")
    payload = {
        "model": SEARCH_MODEL,
        "reasoning": {"effort": "low"},
        "tools": [{"type": "web_search", "external_web_access": True,
                   "search_context_size": "medium"}],
        "tool_choice": "required",
        "include": ["web_search_call.action.sources"],
        "store": False,
        "max_output_tokens": 1200,
        "input": (
            "Research the following public topic for a concise factual audio brief. "
            "Cite material factual statements using the web search tool. State uncertainty, "
            "source dates and disagreements; do not invent a person, event or result. "
            "Give a research draft for human checking, not final approved copy. Topic: " + topic
        ),
    }
    req = Request("https://api.openai.com/v1/responses",
                  data=json.dumps(payload).encode("utf-8"), method="POST",
                  headers={"Authorization": "Bearer " + key,
                           "Content-Type": "application/json"})
    with urlopen(req, timeout=90) as result:
        return json.load(result)


def checked_url(value):
    if not isinstance(value, str) or not value.startswith(("https://", "http://")):
        raise ValueError("Citation URL is not HTTP(S).")
    return value


def render_html_block(body, citations):
    """Escape original model text; place real anchors at original citation ends."""
    parts = []
    cursor = 0
    for number, item in sorted(enumerate(citations, 1),
                               key=lambda pair: pair[1]["endIndex"]):
        end = item["endIndex"]
        parts.append(escape(body[cursor:end]))
        parts.append(' <a href="' + escape(item["url"], quote=True)
                     + '" rel="noopener noreferrer" target="_blank">source '
                     + str(number) + '</a>')
        cursor = end
    parts.append(escape(body[cursor:]))
    return '<section class="research-block"><pre>' + ''.join(parts) + '</pre></section>'


def render_html_document(blocks):
    return ('<!doctype html>\n<html lang="en"><head><meta charset="utf-8">'
            '<meta name="viewport" content="width=device-width,initial-scale=1">'
            '<title>Research draft for fact-checking</title>'
            '<style>body{font:1rem/1.55 system-ui,sans-serif;max-width:52rem;'
            'margin:2rem auto;padding:0 1rem}pre{font:inherit;white-space:pre-wrap;'
            'overflow-wrap:anywhere}a{color:#0645ad;text-decoration:underline}'
            '.research-block{border-top:1px solid #ccc}</style></head><body>'
            '<h1>Research draft for fact-checking</h1>'
            '<p>Open each source link and check the associated claim before approving spoken copy.</p>'
            + ''.join(render_html_block(block["text"], block["citations"])
                      for block in blocks) + '</body></html>\n')


def inspect_search_response(response):
    if response.get("status") != "completed" or not response.get("id"):
        raise ValueError("Search response is incomplete or lacks an ID.")
    output = response.get("output")
    if not isinstance(output, list):
        raise ValueError("No inspectable response output was returned.")
    calls = [item for item in output if isinstance(item, dict)
             and item.get("type") == "web_search_call"
             and isinstance(item.get("action"), dict)
             and item["action"].get("type") == "search"]
    if not calls or any(item.get("status") != "completed" for item in calls):
        raise ValueError("No completed actual web search was returned.")
    searches = []
    for call in calls:
        action = call["action"]
        sources = action.get("sources")
        searches.append({
            "id": call.get("id"), "action": "search",
            "query": action.get("query"),
            "queries": action.get("queries"),
            "consultedSources": [{"url": s.get("url"), "title": s.get("title")}
                                 for s in sources if isinstance(s, dict)
                                 and isinstance(s.get("url"), str)]
                                if isinstance(sources, list) else None,
        })
    blocks = []
    for item in output:
        if not isinstance(item, dict) or item.get("type") != "message" or item.get("role") != "assistant":
            continue
        for content in item.get("content") or []:
            if not isinstance(content, dict) or content.get("type") != "output_text":
                continue
            body = content.get("text")
            if not isinstance(body, str) or not body.strip():
                continue
            # Official docs call these character offsets. Astral characters make
            # UTF-16/code-point interpretation ambiguous; hold rather than mislink.
            if any(ord(char) > 0xFFFF or 0xD800 <= ord(char) <= 0xDFFF for char in body):
                raise ValueError("Astral/surrogate text needs manual citation-offset resolution.")
            citations = []
            for annotation in content.get("annotations") or []:
                if not isinstance(annotation, dict) or annotation.get("type") != "url_citation":
                    continue
                start = annotation.get("start_index")
                end = annotation.get("end_index")
                if (type(start) is not int or type(end) is not int
                        or not 0 <= start < end <= len(body)):
                    raise ValueError("Citation has invalid original text-block character offsets.")
                citations.append({"url": checked_url(annotation.get("url")),
                                  "title": annotation.get("title"),
                                  "startIndex": start, "endIndex": end,
                                  "citedText": body[start:end]})
            blocks.append({"blockIndex": len(blocks), "text": body,
                           "citations": citations})
    if not blocks or not any(block["citations"] for block in blocks):
        raise ValueError("No text block with a URL citation was returned.")
    return {"responseId": response["id"], "model": response.get("model"),
            "usage": response.get("usage"), "searches": searches,
            "textBlocks": blocks, "readableResearchHtml": render_html_document(blocks)}


def new_directory(path):
    try:
        path.mkdir(parents=False, exist_ok=False)
        return path
    except FileExistsError:
        recovery = path.with_name(path.name + ".recovered-" + uuid4().hex)
        recovery.mkdir(parents=False, exist_ok=False)
        return recovery


def research(topic, output_dir, requester=request_openai):
    topic = topic.strip()
    if not topic or len(topic) > 300:
        raise SystemExit("Supply one specific public topic of 1–300 characters.")
    if not output_dir.parent.is_dir() or output_dir.exists():
        raise SystemExit("Choose a new output directory inside an existing parent.")
    response = requester(topic)
    checked = inspect_search_response(response)
    target = new_directory(output_dir)
    checked.update({"topic": topic, "createdAtUtc": datetime.now(timezone.utc).isoformat(),
                    "ownerChecked": False})
    (target / "research.json").write_text(json.dumps(checked, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    (target / "research.html").write_text(checked["readableResearchHtml"], encoding="utf-8")
    print(f"Saved {target}. Open every linked source and check each claim before approving spoken copy.")
    return target


def gemini_client():
    key = os.environ.get("GEMINI_API_KEY", "").strip()
    if not key:
        raise SystemExit("Set GEMINI_API_KEY for the chosen Gemini project first.")
    from google import genai
    return genai.Client(api_key=key)


def validate_wav(raw):
    try:
        with wave.open(BytesIO(raw), "rb") as wav:
            channels, width, rate = wav.getnchannels(), wav.getsampwidth(), wav.getframerate()
            frames = wav.getnframes()
            if (channels, width, rate, wav.getcomptype()) != (1, 2, 24000, "NONE"):
                raise ValueError("Expected mono 16-bit 24 kHz uncompressed PCM WAV.")
            if frames < 1 or len(wav.readframes(frames)) != frames * channels * width:
                raise ValueError("WAV payload is absent or truncated.")
    except (EOFError, wave.Error) as exc:
        raise ValueError("Invalid WAV container.") from exc


def exclusive_file(path, raw):
    try:
        with path.open("xb") as stream:
            stream.write(raw)
        return path
    except FileExistsError:
        recovery = path.with_name(path.name + ".recovered-" + uuid4().hex + ".wav")
        with recovery.open("xb") as stream:
            stream.write(raw)
        return recovery


def speak(brief_file, wav_file, client_factory=gemini_client):
    if not wav_file.parent.is_dir() or wav_file.exists():
        raise SystemExit("Choose a new WAV filename in an existing directory.")
    brief = brief_file.read_text(encoding="utf-8").strip()
    if not brief or len(brief) > 1500:
        raise SystemExit("Human-approved spoken brief must contain 1–1500 characters.")
    response = client_factory().interactions.create(
        model=SPEECH_MODEL,
        input=[{"type": "user_input", "content": [{"type": "text", "text": brief}]}],
        response_format={"type": "audio"},
        generation_config={"speech_config": [{"voice": "Kore"}]},
        store=False,
    )
    if getattr(response, "status", None) != "completed":
        raise ValueError("TTS interaction was not completed.")
    audio = getattr(response, "output_audio", None)
    if audio is None or not getattr(audio, "data", None):
        raise ValueError("No audio block returned.")
    try:
        raw = base64.b64decode(audio.data, validate=True)
    except (ValueError, base64.binascii.Error) as exc:
        raise ValueError("Invalid base64 audio block.") from exc
    validate_wav(raw)
    saved = exclusive_file(wav_file, raw)
    print(f"Saved {saved}. Listen through while following {brief_file}.")
    return saved


def main():
    parser = argparse.ArgumentParser()
    subs = parser.add_subparsers(dest="command", required=True)
    p = subs.add_parser("research")
    p.add_argument("--topic", required=True)
    p.add_argument("--output-dir", required=True, type=Path)
    p = subs.add_parser("speak")
    p.add_argument("--approved-brief", required=True, type=Path)
    p.add_argument("--wav", required=True, type=Path)
    args = parser.parse_args()
    if args.command == "research":
        research(args.topic, args.output_dir)
    else:
        speak(args.approved_brief, args.wav)


if __name__ == "__main__":
    main()
```

## 3. Run the search and inspect the saved evidence

Replace the placeholder with **your** specific permitted public topic. Choose a new output folder name for each attempt:

```powershell
.\.venv\Scripts\python.exe brief_audio.py research --topic "YOUR SPECIFIC PUBLIC TOPIC" --output-dir research-1
```

Open `research-1\research.html` locally in Chrome. Read the entire draft and follow every material inline citation. Open the consulted-source URLs in `research-1\research.json` as needed; that list can contain more pages than the inline citations. Check the source's identity, date, current scope, exact support for the claim and contrary primary evidence. The saved `ownerChecked:false` marker deliberately leaves approval with you. If a claim fails, remove it or research again and keep your notes. An OpenAI citation does not certify the claim. [OpenAI's search documentation](https://developers.openai.com/api/docs/guides/tools-web-search) requires visible clickable inline citations when displaying search-derived material; this HTML contains actual anchors without interpreting the model's Markdown.

## 4. Approve only the words you want spoken

Write two or three short fact-checked sentences in `approved-brief.txt`. Keep a separate local note listing each material statement, the page you opened, its date and your decision. Keep attribution in the spoken copy when needed. The program imposes a 1,500-character local limit to keep this a brief; it does not verify your factual judgment. Use the editor of your choice, for example:

```powershell
notepad approved-brief.txt
```

The checkpoint is concrete: retain `research.json`, `research.html`, your checked notes and the final `approved-brief.txt`. If a source is unavailable or a material claim remains uncertain, hold the speech step until you can rewrite the line accurately.

## 5. Make the WAV and listen to every word

Choose a new WAV filename, then run:

```powershell
.\.venv\Scripts\python.exe brief_audio.py speak --approved-brief approved-brief.txt --wav brief-1.wav
```

The script sends only the approved text to Google's [Gemini 3.8 Flash TTS](https://ai.google.dev/gemini-api/docs/speech-generation), requests Kore, checks a completed audio response, decodes its base64 and validates a nonempty mono, 16-bit, 24 kHz PCM WAV. Open the saved file in a local player and listen **all the way through** while reading `approved-brief.txt`. Check names, numbers, attribution, missing words and the ending. If anything is wrong or unclear, edit the text if needed and generate a new filename, then listen again. When sharing the file, label the audio as AI-generated. This guide's script and offline fixtures were checked synthetically; no provider call, actual audio or speaker accuracy was tested for this article.

Keep a copy of this blank record with the files. Repeat the claim/source/date/decision lines for each material claim. Mark unresolved factual or audio checks as held. Record usage not shown by a project dashboard as unknown, and fill the hold reason only when a check is held.

```plaintext
Topic: ______________________
Research dir: _______________
JSON file: __________________
HTML file: __________________
Claim: ______________________
Source URL: _________________
Source date: ________________
Decision: supported / hold
Checked notes: ______________
Approved text: ______________
WAV file: ___________________
Full listen: yes / no
Words: pass / hold
Numbers: pass / hold
Names: pass / hold
Attribution: pass / hold
Ending: pass / hold
OpenAI usage: ____ / unknown
Gemini usage: ____ / unknown
Hold reason: ________________
```

## 6. Handle failure and review usage

A missing search action, incomplete response, absent citation, unsafe URL, ambiguous offset, incomplete TTS result or invalid WAV stops the script. A failed or timed-out paid call may still have incurred usage: inspect the applicable project usage before a deliberate retry. If a target path appears during a paid request, the code preserves that path and writes to a unique `.recovered-...` name. Inspect the printed path. Other file-system failures can leave a partial file; inspect it before you try again.

Check [OpenAI pricing](https://developers.openai.com/api/docs/pricing) for search-tool calls and model tokens, and [Gemini pricing](https://ai.google.dev/gemini-api/docs/pricing) for speech text input and audio output. Search can involve multiple billable tool calls. Your account's actual usage is the cost record; this documentation-only guide cannot establish a per-run bill. `store:false` limits ordinary stored response state in the OpenAI request, and `store=False` opts the Gemini interaction out of its ordinary server-side state. These settings do not eliminate processing or all safety and abuse retention. Read [OpenAI data controls](https://developers.openai.com/api/docs/guides/your-data), [Gemini Interactions retention](https://ai.google.dev/gemini-api/docs/interactions-overview) and [Google's tier terms](https://ai.google.dev/gemini-api/terms) for your project. The Google call here is speech only; the research call uses OpenAI native web search.

For related context, [BIG CHANGE's Gemini TTS pricing report](https://bigchange.ai/blog/gemini-3-8-tts-pricing-migration) covers the speech model's price change. Check today's provider price and availability before you run this workflow.

## Sources

- [OpenAI: Web search guide (checked October 26, 2026)](https://developers.openai.com/api/docs/guides/tools-web-search) — Documents native Responses web_search, external access, source inclusion, search action and inline URL citations. The reader must inspect the linked pages; citation placement does not certify claims.
- [OpenAI: Responses create reference (checked October 26, 2026)](https://developers.openai.com/api/reference/resources/responses/methods/create) — Supports request and output field shapes used in the REST path, including status, output items and URL annotation offsets. The script checks returned fields and refuses missing completed search evidence.
- [OpenAI: GPT-6 Luna model (checked October 26, 2026)](https://developers.openai.com/api/docs/models/gpt-6-luna) — Identifies the research model and supported low reasoning and web-search tool path. Account entitlement and rate limits remain project-specific.
- [OpenAI: API quickstart (checked October 26, 2026)](https://developers.openai.com/api/docs/quickstart) — Covers API project and key setup; an account's billing and model access must be checked by the reader.
- [OpenAI: API pricing (checked October 26, 2026)](https://developers.openai.com/api/docs/pricing) — Search tool calls and model token use are separate cost categories. No actual request or bill was observed for this guide.
- [OpenAI: Platform data controls (checked October 26, 2026)](https://developers.openai.com/api/docs/guides/your-data) — Separates store:false response state from abuse-monitoring data handling. The code makes no zero-retention promise.
- [Google: Gemini text-to-speech (checked October 26, 2026)](https://ai.google.dev/gemini-api/docs/speech-generation) — Documents Gemini 3.8 Flash TTS, Python SDK minimum 2.25.0, Kore request fields, base64 output and default WAV. The writer did not run a paid generation or hear output.
- [Google: Interactions API (checked October 26, 2026)](https://ai.google.dev/gemini-api/docs/interactions-overview) — Documents completed interaction status and store:false stateless option, distinct from all safety or tier-specific retention.
- [Google: Gemini API keys (checked October 26, 2026)](https://ai.google.dev/gemini-api/docs/api-key) — Explains project keys and GOOGLE_API_KEY precedence in automatic discovery. The script selects GEMINI_API_KEY explicitly.
- [Google: Available regions (checked October 26, 2026)](https://ai.google.dev/gemini-api/docs/available-regions) — The reader checks regional access for the selected project; this guide makes no regional entitlement claim.
- [Google: Gemini API pricing (checked October 26, 2026)](https://ai.google.dev/gemini-api/docs/pricing) — Lists Gemini speech input and output charges by model and tier. No per-run amount is inferred.
- [Google: Gemini API additional terms (checked October 26, 2026)](https://ai.google.dev/gemini-api/terms) — Requires an operator at least 18 and developer use for professional or business purposes. Service-tier terms inform the project choice, especially for confidential inputs and product-improvement treatment.
- [Google: Python GenAI SDK v2.33.0 release (October 22, 2026)](https://github.com/googleapis/python-genai/releases/tag/v2.33.0) — Confirms the pinned SDK release exists. TTS guide and Interactions documentation substantiate the call contract; the tag alone does not.
- [BIG CHANGE: Gemini 3.8 TTS pricing report](https://bigchange.ai/blog/gemini-3-8-tts-pricing-migration) — Related pricing context only. This guide's end-to-end task is distinct and has no dated In this story continuation.