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; Gemini TTS guide.
- 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, gpt-6-luna and its native web_search tool. A separate Gemini API project needs access to gemini-3.8-flash-tts in an available region. Google's 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 establishes release existence.
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' -MaskInputKeep 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 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.
"""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:
.\.venv\Scripts\python.exe brief_audio.py research --topic "YOUR SPECIFIC PUBLIC TOPIC" --output-dir research-1Open 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 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:
notepad approved-brief.txtThe 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:
.\.venv\Scripts\python.exe brief_audio.py speak --approved-brief approved-brief.txt --wav brief-1.wavThe script sends only the approved text to Google's Gemini 3.8 Flash TTS, 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.
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 for search-tool calls and model tokens, and Gemini 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, Gemini Interactions retention and Google's tier 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 covers the speech model's price change. Check today's provider price and availability before you run this workflow.



