---
title: "How do I transcribe audio uploads and make them searchable?"
description: "Transcribe audio uploads in Python, split them into timestamped segments, embed the text, and search by meaning so a hit is a file plus seconds."
url: "https://www.pixeltable.com/learn/how-to/transcribe-audio-and-search-it"
updated: "2026-10-23"
doc: "https://docs.pixeltable.com/howto/cookbooks/audio/audio-transcribe"
---

# How do I transcribe audio uploads and make them searchable?

Accept the upload, split it into timestamped slices, run speech-to-text (Whisper or a hosted API) on each slice, embed the text, and query by meaning; each hit points back to the file and the slice where it was said. When a new file arrives, only that file is transcribed and indexed.

Updated: 2026-10-23
Part of [How do I ... in Python?](https://www.pixeltable.com/learn/how-to).

## Before you start

About 30 min.

- Python 3.11+
- pip install -U 'pixeltable[serve]'
- pxt init in the project root
- ffmpeg
- openai-whisper
- sentence-transformers

## On this page

- [Before you start](https://www.pixeltable.com/learn/how-to/transcribe-audio-and-search-it#before-you-start)
- [Store uploads as a typed audio column](https://www.pixeltable.com/learn/how-to/transcribe-audio-and-search-it#step-1)
- [Split long files into timestamped segments](https://www.pixeltable.com/learn/how-to/transcribe-audio-and-search-it#step-2)
- [Transcribe each segment](https://www.pixeltable.com/learn/how-to/transcribe-audio-and-search-it#step-3)
- [Embed the text and rank by meaning](https://www.pixeltable.com/learn/how-to/transcribe-audio-and-search-it#step-4)
- [Serve upload and search over HTTP](https://www.pixeltable.com/learn/how-to/transcribe-audio-and-search-it#step-5)
- [Comparison](https://www.pixeltable.com/learn/how-to/transcribe-audio-and-search-it#comparison)
- [Where Pixeltable fits](https://www.pixeltable.com/learn/how-to/transcribe-audio-and-search-it#where-pixeltable-fits)
- [Questions](https://www.pixeltable.com/learn/how-to/transcribe-audio-and-search-it#questions)

## 1. Store uploads as a typed audio column {#step-1}

Put each file in a table as audio, plus any metadata you will filter on. A path string is not enough: splitting and transcription need a typed column.

```python
import pixeltable as pxt
from pixeltable.functions.audio import audio_splitter
from pixeltable.functions.huggingface import sentence_transformer
from pixeltable.functions.whisper import transcribe

TableModel = pxt.model_base()
text_embed = sentence_transformer.using(model_id='all-MiniLM-L6-v2')

class Recordings(TableModel, name='recordings'):
    title: pxt.String
    audio: pxt.Audio
```

## 2. Split long files into timestamped segments {#step-2}

audio_splitter emits one row per slice with segment_start, segment_end, and audio_segment. Pass exactly one of duration or max_size. duration=30.0 is a starting slice for local Whisper.

```python
class Segments(
    TableModel,
    name='segments',
    base=Recordings,
    iterator=audio_splitter(audio=Recordings.audio, duration=30.0),
):
    pass
```

## 3. Transcribe each segment {#step-3}

Whisper on audio_segment writes the text for that slice. A hosted API is an alternative model choice on the same column (openai.transcriptions). The catalog still holds the file and the timestamps.

```python
class Segments(
    TableModel,
    name='segments',
    base=Recordings,
    iterator=audio_splitter(audio=Recordings.audio, duration=30.0),
):
    transcript = transcribe(audio_segment, model='base').text.astype(pxt.String)
```

## 4. Embed the text and rank by meaning {#step-4}

Index the transcript with a sentence embedding model. Return the parent title, segment_start, text, and score. The start time is the second to seek to. Sentence splitting with string_splitter needs spaCy; segment rows are enough for a first index.

```python
class Segments(
    TableModel,
    name='segments',
    base=Recordings,
    iterator=audio_splitter(audio=Recordings.audio, duration=30.0),
):
    transcript = transcribe(audio_segment, model='base').text.astype(pxt.String)
    __indexes__ = [
        pxt.EmbeddingIndex(transcript, string_embed=text_embed),
    ]

@pxt.query
def search_audio(query: str, limit: int = 5):
    sim = Segments.transcript.similarity(string=query)
    return (
        Segments.order_by(sim, asc=False)
        .limit(limit)
        .select(
            title=Recordings.title,
            segment_start=Segments.segment_start,
            segment_end=Segments.segment_end,
            text=Segments.transcript,
            score=sim,
        )
    )
```

## 5. Serve upload and search over HTTP {#step-5}

Expose an upload route for new files and a search route for the query. background=True on insert returns a job id and a job_url to poll, which fits a long transcription. Apply the schema, then start the service. A new file transcribes only that file.

```python
from pixeltable.serving import FastAPIRouter

api = FastAPIRouter(name='audio_lib')
api.add_insert_route(
    Recordings,
    path='/recordings',
    inputs=[Recordings.title],
    uploadfile_inputs=[Recordings.audio],
    background=True,
)
api.add_query_route(path='/search', query=search_audio)

# pxt init
# pxt schema update app.py audio_lib -f
# pxt service update app.py audio_lib -f
```

## The stack coding agents usually pick vs one schema {#comparison}

| Job | Glue stack | One schema |
| --- | --- | --- |
| Upload and files | FastAPI plus MinIO or S3 | An Audio column; media under PIXELTABLE_HOME |
| Workers | Celery plus Redis between upload and Whisper | Computed columns and a splitter view on insert |
| Transcription | Whisper or a hosted STT API in the worker | whisper.transcribe on the segment, or openai.transcriptions |
| Index | Postgres plus pgvector | EmbeddingIndex on the transcript column |
| Timestamps | You store offsets next to the vector by hand | segment_start and segment_end on the view row |
| New file | Enqueue a task; retry the DAG | Insert a row; only that file is transcribed and indexed |
| Failed call | Celery retry policy you write | insert(..., on_error='ignore') stores None plus errortype; default on_error='abort' |

## Where Pixeltable fits {#where-pixeltable-fits}

Pixeltable is where the transcription call runs and the results live, not a speech-to-text model. Deepgram, AssemblyAI, Gladia, and other hosted STT APIs are fine model choices on a computed column; this page does not rank them or quote their prices. The glue stack is FastAPI upload, MinIO or S3, Celery plus Redis, Whisper, and Postgres plus pgvector. Pick that stack when those services are already the contract. Pick this schema when the file, the timestamped segments, the index, and HTTP should live in one app.py. Pixeltable Cloud is Limited Beta. Speakers are a WhisperX cookbook, not this first index.

```python
import pixeltable as pxt
from pixeltable.functions.audio import audio_splitter
from pixeltable.functions.huggingface import sentence_transformer
from pixeltable.functions.whisper import transcribe
from pixeltable.serving import FastAPIRouter

TableModel = pxt.model_base()
text_embed = sentence_transformer.using(model_id='all-MiniLM-L6-v2')

class Recordings(TableModel, name='recordings'):
    title: pxt.String
    audio: pxt.Audio

class Segments(
    TableModel,
    name='segments',
    base=Recordings,
    iterator=audio_splitter(audio=Recordings.audio, duration=30.0),
):
    transcript = transcribe(audio_segment, model='base').text.astype(pxt.String)
    __indexes__ = [
        pxt.EmbeddingIndex(transcript, string_embed=text_embed),
    ]

@pxt.query
def search_audio(query: str, limit: int = 5):
    sim = Segments.transcript.similarity(string=query)
    return (
        Segments.order_by(sim, asc=False)
        .limit(limit)
        .select(
            title=Recordings.title,
            segment_start=Segments.segment_start,
            segment_end=Segments.segment_end,
            text=Segments.transcript,
            score=sim,
        )
    )

api = FastAPIRouter(name='audio_lib')
api.add_insert_route(
    Recordings,
    path='/recordings',
    inputs=[Recordings.title],
    uploadfile_inputs=[Recordings.audio],
    background=True,
)
api.add_query_route(path='/search', query=search_audio)

# pxt init
# pxt schema update app.py audio_lib -f
# pxt service update app.py audio_lib -f
```

## Concepts

- [What is speech-to-text?](https://www.pixeltable.com/learn/what-is-speech-to-text)
- [What is Whisper?](https://www.pixeltable.com/learn/what-is-whisper)
- [What is semantic search?](https://www.pixeltable.com/learn/what-is-semantic-search)

## Use case

- [Audio Transcription Pipeline with OpenAI Whisper](https://www.pixeltable.com/use-cases/audio-transcription-pipeline)

## Questions {#questions}

### Do I need Celery and Redis? {#faq-1}

You need something that runs transcription when a file arrives. Computed columns and the splitter view do that on insert. Celery plus Redis is optional when that queue is already the contract. background=True on the insert route returns a job id so the HTTP caller does not wait on Whisper.

### Local Whisper or a transcription API? {#faq-2}

Local whisper.transcribe(model=base) needs no API key and runs on CPU or CUDA. openai.transcriptions is the hosted OpenAI API on the same column shape. Other STT vendors are also model choices; this page does not rank them. Pick local when the file must stay on the box; pick an API when you already pay for that model.

### How do I get timestamps for a search hit? {#faq-3}

Each splitter row keeps segment_start and segment_end in seconds. Return them from the query and seek the parent file to segment_start. A filename alone is not a timestamp.

### What about speakers? {#faq-4}

This first index is one transcript string per segment, not a speaker label. WhisperX diarization is a separate cookbook: https://docs.pixeltable.com/howto/cookbooks/audio/whisperx-transcription

### What happens if a transcription call fails? {#faq-5}

Python insert defaults to on_error='abort': the operation fails and the rows are not inserted. on_error='ignore' stores the row with None in the failed cell and records errortype and errormsg (operations docs). Service insert routes do not take on_error; they abort on a computed-column failure.

## Related

- [What is speech to text?](https://www.pixeltable.com/learn/what-is-speech-to-text)
- [What is Whisper?](https://www.pixeltable.com/learn/what-is-whisper)
- [What is semantic search?](https://www.pixeltable.com/learn/what-is-semantic-search)
- [Audio transcription pipeline](https://www.pixeltable.com/use-cases/audio-transcription-pipeline)
- [How do I index a video library and search it by text in Python?](https://www.pixeltable.com/learn/how-to/search-a-video-library-by-text)
- [How do I do semantic search in Python?](https://www.pixeltable.com/learn/how-to/semantic-search-in-python)
- [Pixeltable vs Mixpeek](https://www.pixeltable.com/compare/pixeltable-vs-mixpeek)
- [Transcribe audio files](https://docs.pixeltable.com/howto/cookbooks/audio/audio-transcribe)
- [Audio transcriptions use case](https://docs.pixeltable.com/howto/use-cases/audio-transcriptions)
- [WhisperX transcription and speakers](https://docs.pixeltable.com/howto/cookbooks/audio/whisperx-transcription)
- [HTTP serving and background jobs](https://docs.pixeltable.com/howto/deployment/serving)
