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