Agent Template

Voice Transcriber

Upload a voice note or record straight from your microphone and get an instant transcript, then ask questions or request a summary — answers can be read aloud. Fully in your browser, nothing uploaded to a server.

audio voice 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 voice 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 voice speech-to-text whisper transcription privacy

Code Statistics

HTML Lines
35
CSS Lines
42
JS Lines
142
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>
      <button id="vt-mic" type="button" class="vt__micbtn" hidden>🔴 Record from microphone</button>
      <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__micbtn { margin-top: 10px; padding: 10px 16px; border: 1px solid #b9cbc7;
              border-radius: 10px; background: #fff; color: #3c4a47; font-size: .95rem;
              cursor: pointer; transition: border-color .15s, background .15s; }
.vt__micbtn:hover { border-color: #0d9488; background: #f0fbf9; }
.vt__micbtn:disabled { opacity: .6; cursor: not-allowed; }
.vt__micbtn--rec { border-color: #dc2626; color: #dc2626; }
.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__micbtn, .vt__msg--assistant, .message-assistant, .vt__form
    { background: #1c2523; border-color: #2d3a37; }
  .vt__micbtn { color: #e7efed; }
  .vt__micbtn--rec { border-color: #f87171; color: #f87171; }
  .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 transcribeSamples(samples, name) {
    setStatus('Transcribing "' + name + '"… (first run downloads the model)');
    window.pyodide.globals.set('__vt_samples', samples);
    window.pyodide.globals.set('__vt_name', name);
    const text = await window.pyodide.runPythonAsync(
      'await _transcribe_audio(__vt_samples, __vt_name)'
    );
    window.addMessage('assistant', '📝 ' + name + '\n' + text);
    setStatus('Transcribed "' + name + '". Ask about it, or add more audio. ' +
              '(Load a model above to enable questions & summaries.)');
    enableChat(true);
  }

  async function transcribe(file) {
    setStatus('Decoding "' + file.name + '"…');
    const samples = await fileToSamples(file);
    await transcribeSamples(samples, file.name);
  }

  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); }
    }
  });

  // Microphone recording via the injected mic-capture component (roadmap #15).
  // The button stays hidden unless window.AgentOpMic reports support, so the
  // upload flow is unaffected in browsers without a usable microphone.
  const micBtn = () => document.getElementById('vt-mic');
  let recordingNo = 0;

  function setMicIdle() {
    const btn = micBtn();
    btn.classList.remove('vt__micbtn--rec');
    btn.textContent = '🔴 Record from microphone';
  }

  micBtn().addEventListener('click', async () => {
    const mic = window.AgentOpMic;
    const btn = micBtn();
    if (!mic || !pyReady) { setStatus('Still starting up—one moment…'); return; }
    if (mic.isRecording) {
      btn.disabled = true;
      try {
        const samples = await mic.stop();
        setMicIdle();
        recordingNo += 1;
        await transcribeSamples(samples, 'recording ' + recordingNo);
      } catch (err) {
        setMicIdle();
        setStatus('Could not transcribe the recording: ' + err);
      } finally {
        btn.disabled = false;
      }
    } else {
      try {
        await mic.start();
        btn.classList.add('vt__micbtn--rec');
        btn.textContent = '⏹ Stop recording';
        setStatus('Recording… click Stop when you are done.');
      } catch (err) {
        setStatus('Microphone unavailable: ' + 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;
    if (window.AgentOpMic && window.AgentOpMic.isSupported) {
      micBtn().hidden = false;
      setStatus('Ready. Upload an audio file or record from your microphone.');
    } else {
      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", "")
    )