Run on Cloud · backend

Audio transcription

Upload audio or podcasts, transcribe with Whisper, and search sentences semantically.

Scaffold locally

uvx pixeltable-new --template audio-transcription my-audio-transcription

Same starter-kit files Cloud uses. Local UI (static HTML in some templates) is for uvx, not for Cloud. Cloud deploys schema + insert routes via pxt serve.

Cloud routes

  • insert/insertaudiointel/audio

schema.py

"""Audio & Podcast Intelligence -- self-hosted transcription, summarization, and semantic search."""

import os

import pixeltable as pxt
from pixeltable.functions.audio import audio_splitter
from pixeltable.functions.huggingface import sentence_transformer
from pixeltable.functions.string import string_splitter
from pixeltable.functions.uuid import uuid7

HAVE_OPENAI = bool(os.environ.get("OPENAI_API_KEY"))

if HAVE_OPENAI:
    from pixeltable.functions import openai

EMBED_FN = sentence_transformer.using(model_id="all-MiniLM-L6-v2")

# ---------------------------------------------------------------------------
# Namespace
# ---------------------------------------------------------------------------
pxt.create_dir("audiointel", if_exists="ignore")

# ---------------------------------------------------------------------------
# Base table -- one row per audio file
# ---------------------------------------------------------------------------
audio_files = pxt.create_table(
    "audiointel.audio_files",
    {
        "audio": pxt.Audio,
        "title": pxt.String,
        "source": pxt.String,
        "uuid": uuid7(),
        "timestamp": pxt.Timestamp,
    },
    primary_key=["uuid"],
    if_exists="ignore",
)

# ---------------------------------------------------------------------------
# Audio chunking -- 30-second segments with 5s overlap
# ---------------------------------------------------------------------------
chunks = pxt.create_view(
    "audiointel.chunks",
    audio_files,
    iterator=audio_splitter(audio_files.audio, duration=30.0, overlap=5.0),
    if_exists="ignore",
)

# ---------------------------------------------------------------------------
# Transcription (OpenAI Whisper API or local whisper)
# ---------------------------------------------------------------------------
if HAVE_OPENAI:
    chunks.add_computed_column(
        transcription=openai.transcriptions(chunks.audio_segment, model="whisper-1"),
        if_exists="ignore",
    )
    chunks.add_computed_column(
        transcript_text=chunks.transcription.text.astype(pxt.String),
        if_exists="ignore",
    )
# Local alternative:
# from pixeltable.functions.whisper import transcribe as whisper_transcribe
# chunks.add_computed_column(
#     transcription=whisper_transcribe(chunks.audio_segment, model='base.en'),
#     if_exists='ignore',
# )
# chunks.add_computed_column(transcript_text=chunks.transcription['text'].astype(pxt.String), if_exists='ignore')

# ---------------------------------------------------------------------------
# Sentence splitting on transcript text
# ---------------------------------------------------------------------------
if HAVE_OPENAI:
    sentences = pxt.create_view(
        "audiointel.sentences",
        chunks,
        iterator=string_splitter(chunks.transcript_text, separators="sentence"),
        if_exists="ignore",
    )

    sentences.add_embedding_index(
        "text",
        idx_name="sentences_text_idx",
        string_embed=EMBED_FN,
        if_exists="ignore",
    )

# ---------------------------------------------------------------------------
# Per-chunk summary (LLM)
# ---------------------------------------------------------------------------
if HAVE_OPENAI:
    chunks.add_computed_column(
        summary=openai.chat_completions(
            messages=[
                {
                    "role": "system",
                    "content": (
                        "You are a concise summarizer. Summarize the following audio transcript chunk "
                        "in 2-3 sentences. Focus on key points, decisions, and action items."
                    ),
                },
                {"role": "user", "content": chunks.transcript_text},
            ],
            model="gpt-4.1-mini",
        ),
        if_exists="ignore",
    )
    chunks.add_computed_column(
        summary_text=chunks.summary.choices[0].message.content.astype(pxt.String),
        if_exists="ignore",
    )


# ---------------------------------------------------------------------------
# Query functions
# ---------------------------------------------------------------------------
@pxt.query
def list_recordings():
    """List all audio files with metadata."""
    return audio_files.select(audio_files.title, audio_files.source, audio_files.timestamp, audio_files.uuid)


# These require OPENAI_API_KEY (the views/columns they reference are created above)
if HAVE_OPENAI:

    @pxt.query
    def search_transcripts(query_text: str, limit: int = 10):
        """Semantic search across all transcripts."""
        sim = sentences.text.similarity(string=query_text)
        return sentences.order_by(sim, asc=False).limit(limit).select(sentences.text, score=sim)

    @pxt.query
    def search_in_recording(recording_title: str, query_text: str, limit: int = 10):
        """Semantic search within a specific recording."""
        sim = sentences.text.similarity(string=query_text)
        return (
            sentences.where(sentences.title == recording_title)
            .order_by(sim, asc=False)
            .limit(limit)
            .select(sentences.text, score=sim)
        )

    @pxt.query
    def get_transcript(recording_title: str):
        """Full transcript of a recording, ordered by segment start time."""
        return (
            chunks.where(chunks.title == recording_title)
            .order_by(chunks.segment_start)
            .select(chunks.transcript_text, chunks.segment_start, chunks.segment_end)
        )

    @pxt.query
    def get_summary(recording_title: str):
        """Per-chunk summaries for a recording, ordered by segment start time."""
        return (
            chunks.where(chunks.title == recording_title)
            .order_by(chunks.segment_start)
            .select(chunks.summary_text, chunks.segment_start, chunks.segment_end)
        )


if __name__ == "__main__":
    print("Schema initialized. Run: python app.py")

README

Audio & Podcast Intelligence

Ingest audio files, automatically transcribe, chunk, summarize, and search across recordings. Your own Otter.ai, self-hosted.

What it replaces

Service Cost
Otter.ai $20/mo per user
Descript $24/mo per user
AssemblyAI $0.37/min
audio-transcription Free + your compute

Use cases

  • Meeting intelligence -- transcribe and search across every meeting recording
  • Podcast analytics -- find every mention of a topic across hundreds of episodes
  • Call center QA -- search agent calls for compliance keywords in seconds
  • Compliance search -- full-text + semantic search over recorded conversations

Quickstart

uv sync                           # install deps
OPENAI_API_KEY=sk-... uv run python app.py
# Open http://localhost:8000

That's it. app.py initializes the schema and starts the server with the web UI.

To use local Whisper instead of the OpenAI API, install with uv sync --extra local and uncomment the local whisper block in schema.py.

API-only mode (no UI)

OPENAI_API_KEY=sk-... uv run python schema.py
OPENAI_API_KEY=sk-... uv run pxt serve audiointel

Do not run both pxt serve and app.py at the same time -- they bind to the same port.

What Pixeltable handles

All of the following run automatically when you insert a row -- zero glue code:

  1. Audio splitting -- 30-second segments with 5s overlap via audio_splitter
  2. Transcription -- OpenAI Whisper API (or local whisper.transcribe)
  3. Sentence chunking -- spaCy sentence segmentation via string_splitter
  4. Embedding -- all-MiniLM-L6-v2 sentence embeddings, computed on insert
  5. Indexing -- HNSW vector index for sub-second semantic search
  6. Summarization -- per-chunk LLM summaries via chat_completions

API routes

Method Path Description
POST /api/upload Upload and ingest an audio file (background job)
GET /api/recordings List all recordings
POST /api/search Semantic search across all transcripts (needs OPENAI_API_KEY)
POST /api/search-in Search within one recording (needs OPENAI_API_KEY)
GET /api/transcript Full transcript for a recording (needs OPENAI_API_KEY)
GET /api/summary Per-chunk summaries for a recording (needs OPENAI_API_KEY)

Architecture

audio file
  └─ audio_files table (audio, title, source, uuid, timestamp)
       └─ chunks view (30s segments via audio_splitter)
            ├─ transcription (openai.transcriptions)
            ├─ summary (chat_completions)
            └─ sentences view (string_splitter)
                 └─ embedding index (all-MiniLM-L6-v2)

Files

audio-transcription/
├── schema.py         Tables, views, indexes, computed columns, query functions
├── functions.py      UDFs (generate_full_summary)
├── app.py            FastAPI server — API + web UI
├── static/
│   └── index.html    Frontend (Tailwind CSS, vanilla JS)
├── pyproject.toml    Dependencies + pxt serve routes (API-only alternative)
└── README.md