Agent Template
Voice Transcriber
Upload a voice note or audio file and get an instant transcript, then ask questions or request a summary — fully in your browser, nothing uploaded to a server.
audio
speech-to-text
whisper
transcription
privacy
ozzo
Jul 10, 2026
1 use
Preview Mode
This is a preview with sample data. The template uses placeholders like
which will be replaced with actual agent data.
About This Template
Voice Transcriber is a browser-executable AI agent template built on AgentOp. It runs entirely in the browser using Python (via Pyodide) and can be deployed without a server — just download the generated HTML file and open it locally or host it anywhere.
Topics
audio
speech-to-text
whisper
transcription
privacy
Template Preview
Template Metadata
- Slug
- voice-transcriber
- Created By
- ozzo
- Created
- Jul 10, 2026
- Usage Count
- 1
Tags
audio
speech-to-text
whisper
transcription
privacy
Code Statistics
- HTML Lines
- 34
- CSS Lines
- 34
- JS Lines
- 92
- Python Lines
- 42
Source Code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ agent_name }}</title>
<style>{{ css_code }}</style>
</head>
<body>
<main class="vt">
<header class="vt__head">
<h1>{{ agent_name }}</h1>
<p class="vt__sub">{{ description }}</p>
</header>
<section class="vt__upload">
<label class="vt__drop" for="audio-file">
<input id="audio-file" type="file" accept="audio/*" multiple hidden>
<span>🎙️ Upload a voice note or audio file to transcribe it</span>
</label>
<div id="vt-status" class="vt__status" role="status"></div>
</section>
<section id="results-container" class="vt__chat" aria-live="polite"></section>
<form id="vt-form" class="vt__form" autocomplete="off">
<input id="vt-input" type="text"
placeholder="Ask about the recording, or say: summarize it" required disabled>
<button id="vt-send" type="submit" disabled>Ask</button>
</form>
</main>
<script>{{ js_code }}</script>
</body>
</html>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #f6f8f8; color: #16211f; }
.vt { max-width: 760px; margin: 0 auto; padding: 24px 16px 96px; }
.vt__head h1 { font-size: 1.6rem; margin: 0 0 4px; }
.vt__sub { margin: 0 0 20px; color: #5c6b68; }
.vt__drop { display: block; border: 2px dashed #b9cbc7; border-radius: 12px;
padding: 22px; text-align: center; cursor: pointer; background: #fff;
color: #3c4a47; transition: border-color .15s, background .15s; }
.vt__drop:hover { border-color: #0d9488; background: #f0fbf9; }
.vt__status { font-size: .85rem; color: #52605d; margin: 10px 2px; min-height: 1.2em; }
.vt__chat { display: flex; flex-direction: column; gap: 10px; margin: 8px 0 16px; }
.vt__msg, .message { padding: 10px 14px; border-radius: 12px; max-width: 85%;
white-space: pre-wrap; line-height: 1.5; }
.vt__msg--user { align-self: flex-end; background: #0d9488; color: #fff; }
.vt__msg--assistant, .message-assistant { align-self: flex-start; background: #fff;
border: 1px solid #e2eae8; }
.message { display: flex; gap: 8px; }
.message-icon { flex: 0 0 auto; }
.message-content { min-width: 0; }
.vt__form { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 8px;
padding: 12px 16px; background: #fff; border-top: 1px solid #e2eae8; }
.vt__form input { flex: 1; padding: 12px 14px; border: 1px solid #c6d4d1;
border-radius: 10px; font-size: 1rem; }
.vt__form button { padding: 0 20px; border: 0; border-radius: 10px;
background: #0d9488; color: #fff; font-weight: 600; cursor: pointer; }
.vt__form button:disabled { background: #a9bcb8; cursor: not-allowed; }
@media (prefers-color-scheme: dark) {
body { background: #131a19; color: #e7efed; }
.vt__drop, .vt__msg--assistant, .message-assistant, .vt__form
{ background: #1c2523; border-color: #2d3a37; }
.vt__form input { background: #131a19; color: #e7efed; border-color: #2d3a37; }
}
// Voice Transcriber UI wiring. The STT engine + wllama infrastructure are
// injected by the generator; this connects upload + chat controls to them.
(function () {
const statusEl = () => document.getElementById('vt-status');
const results = () => document.getElementById('results-container');
const input = () => document.getElementById('vt-input');
const sendBtn = () => document.getElementById('vt-send');
let pyReady = false;
// Render helper the wllama query bridge also calls for streamed responses.
window.addMessage = function (type, content) {
const el = document.createElement('div');
el.className = 'vt__msg vt__msg--' + (type === 'user' ? 'user' : 'assistant');
el.textContent = content;
results().appendChild(el);
el.scrollIntoView({ behavior: 'smooth', block: 'end' });
return el;
};
function setStatus(msg) { statusEl().textContent = msg; }
function enableChat(on) {
input().disabled = !on;
sendBtn().disabled = !on;
}
// Decode any browser-supported audio to mono 16 kHz Float32Array samples
// (the contract of agentop_ml.transcribe / Whisper).
async function fileToSamples(file) {
const ctx = new AudioContext({ sampleRate: 16000 });
try {
const buf = await ctx.decodeAudioData(await file.arrayBuffer());
if (buf.numberOfChannels === 1) return new Float32Array(buf.getChannelData(0));
const mono = new Float32Array(buf.length);
for (let c = 0; c < buf.numberOfChannels; c++) {
const ch = buf.getChannelData(c);
for (let i = 0; i < buf.length; i++) mono[i] += ch[i] / buf.numberOfChannels;
}
return mono;
} finally {
ctx.close();
}
}
async function transcribe(file) {
setStatus('Decoding "' + file.name + '"…');
const samples = await fileToSamples(file);
setStatus('Transcribing "' + file.name + '"… (first run downloads the model)');
window.pyodide.globals.set('__vt_samples', samples);
window.pyodide.globals.set('__vt_name', file.name);
const text = await window.pyodide.runPythonAsync(
'await _transcribe_audio(__vt_samples, __vt_name)'
);
window.addMessage('assistant', '📝 ' + file.name + '\n' + text);
setStatus('Transcribed "' + file.name + '". Ask about it, or upload more. ' +
'(Load a model above to enable questions & summaries.)');
enableChat(true);
}
document.getElementById('audio-file').addEventListener('change', async (e) => {
if (!pyReady) { setStatus('Still starting up—one moment…'); return; }
for (const file of e.target.files) {
try { await transcribe(file); }
catch (err) { setStatus('Could not transcribe "' + file.name + '": ' + err); }
}
});
document.getElementById('vt-form').addEventListener('submit', async (e) => {
e.preventDefault();
const q = input().value.trim();
if (!q || !pyReady) return;
window.addMessage('user', q);
input().value = '';
enableChat(false);
try {
window.pyodide.globals.set('__vt_q', q);
const answer = await window.pyodide.runPythonAsync('await process_user_query(__vt_q)');
if (answer) window.addMessage('assistant', answer);
} catch (err) {
window.addMessage('assistant', 'Sorry, something went wrong: ' + err);
} finally {
enableChat(true);
input().focus();
}
});
// Pyodide is ready after initAgent(); the orchestrator fires this on document.
document.addEventListener('pyodide-ready', () => {
pyReady = true;
setStatus('Ready. Upload an audio file to transcribe it.');
});
})();
# Accumulated transcripts for this session: [{"source": name, "text": text}]
TRANSCRIPTS = []
async def _transcribe_audio(samples, name):
"""(JS-callable) Transcribe decoded audio samples and remember the result.
Underscore-prefixed so it is never exposed to the LLM as a tool. ``samples``
is a Float32Array of mono 16 kHz samples handed over by the page JS.
Returns the transcript text (for the UI).
"""
text = (await agentop_ml.transcribe(samples)).strip()
TRANSCRIPTS.append({"source": name, "text": text})
return text
async def process_user_query(query):
"""Answer questions / summarise grounded ONLY in the accumulated transcripts.
Overrides the default router so context is deterministic (small local
models are unreliable at deciding to call tools themselves).
"""
if not TRANSCRIPTS:
return (
"No audio has been transcribed yet - upload a voice note or "
"audio file first."
)
context = "\n\n".join(
f"[{i + 1}] (from {t['source']}) {t['text']}"
for i, t in enumerate(TRANSCRIPTS)
)
grounded_prompt = (
"Answer the QUESTION using ONLY the TRANSCRIPTS below. "
"If the answer is not in the transcripts, say you could not find it. "
"Cite transcript numbers like [1] where relevant.\n\n"
f"TRANSCRIPTS:\n{context}\n\nQUESTION: {query}"
)
# TEMPLATE_SYSTEM_PROMPT only exists as a JS global; the query bridge falls
# back to it when custom_prompt is empty (same idiom as the base template).
return await process_user_query_wllama(
grounded_prompt, globals().get("TEMPLATE_SYSTEM_PROMPT", "")
)
Name your agent
Based on . You'll get your own private copy to try and customize.