Metadata-Version: 2.4
Name: stapel-recordings
Version: 0.22.0
Summary: Recording lifecycle and transcription for the Stapel framework
License: MIT
Project-URL: Homepage, https://github.com/usestapel/stapel-recordings
Project-URL: Repository, https://github.com/usestapel/stapel-recordings
Project-URL: Documentation, https://github.com/usestapel/stapel-recordings#readme
Project-URL: Changelog, https://github.com/usestapel/stapel-recordings/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/usestapel/stapel-recordings/issues
Keywords: django,stapel,recordings
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Django
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: stapel-core<1.0,>=0.26.0
Provides-Extra: s3
Requires-Dist: boto3>=1.34; extra == "s3"
Provides-Extra: vector
Requires-Dist: pgvector>=0.3; extra == "vector"
Provides-Extra: qa
Requires-Dist: stapel-recordings[vector]; extra == "qa"
Requires-Dist: stapel-agent>=0.12.0; extra == "qa"
Provides-Extra: all
Requires-Dist: stapel-recordings[qa,s3,vector]; extra == "all"
Dynamic: license-file

<!-- Generated by stapel-readme from docs/readme.md + docs/*.json. Do not edit this file; edit docs/readme.md and re-run `make readme`. -->

# stapel-recordings

[![CI](https://img.shields.io/github/actions/workflow/status/usestapel/stapel-recordings/ci.yml?branch=main&logo=github&label=CI)](https://github.com/usestapel/stapel-recordings/actions/workflows/ci.yml?query=branch%3Amain)
[![coverage](https://img.shields.io/codecov/c/github/usestapel/stapel-recordings?branch=main&logo=codecov&label=coverage)](https://app.codecov.io/gh/usestapel/stapel-recordings)
[![pypi](https://img.shields.io/pypi/v/stapel-recordings?logo=pypi&logoColor=white&label=pypi)](https://pypi.org/project/stapel-recordings/)
[![downloads](https://static.pepy.tech/badge/stapel-recordings/month)](https://pepy.tech/project/stapel-recordings)
[![python](https://img.shields.io/pypi/pyversions/stapel-recordings?logo=python&logoColor=white)](https://pypi.org/project/stapel-recordings/)
[![license](https://img.shields.io/github/license/usestapel/stapel-recordings)](https://github.com/usestapel/stapel-recordings/blob/main/LICENSE)
[![llms.txt](https://img.shields.io/badge/llms.txt-blue)](https://github.com/usestapel/stapel-recordings/blob/main/docs/llms.txt)

> An audio service, not a video host. Recording lifecycle: upload of any media container (presigned single-PUT and multipart), from which the audio track is extracted, downmixed to mono and stored — the container itself is never kept and never served back — then a configurable processing pipeline (convert, transcribe, diarize, merge) producing a unified speaker-attributed transcript, optional automatic summaries, and a watchdog that recovers stuck or abandoned recordings. Storage therefore scales with hours of speech (~10.8 MB/hour at the default Opus profile), not with what the recording was made on.

Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps that deploy as a monolith or as microservices without changing module code.

## Install

```bash
pip install stapel-recordings
```

## At a glance

| Fact | Value |
|---|---|
| Version | `0.22.0` |
| Python | `>=3.11` (3.11, 3.12, 3.13, 3.14) |
| HTTP operations | 12 |
| Config axes | 1 |
| Usage surface | 42 |
| Extension points | 7 |
| Error codes | 61 |
| Fleet dependencies | [`stapel-agent`](https://github.com/usestapel/stapel-agent) (optional) · [`stapel-auth`](https://github.com/usestapel/stapel-auth) (optional) · [`stapel-core`](https://github.com/usestapel/stapel-core) |

## Documentation

[OpenAPI](https://github.com/usestapel/stapel-recordings/blob/main/docs/schema.json) · [capabilities.json](https://github.com/usestapel/stapel-recordings/blob/main/docs/capabilities.json) · [llms.txt (for agents)](https://github.com/usestapel/stapel-recordings/blob/main/docs/llms.txt)

## What this is

**An audio service, not a video host.** Upload whatever your users record
on — a screen capture, a phone video, a voice memo — and this module keeps
the audio track and only the audio track: extracted, downmixed to mono,
stored once. The container is transport. It is deleted as soon as its audio
is out, there is no field and no URL that hands it back, and that holds for
a 4 GB screen recording and a 3 MB voice memo alike. A host's storage bill
therefore scales with **hours of speech** (~10.8 MB/hour at the default
Opus profile), not with what the recording happened to be made on.

Owns the lifecycle **capture/upload → storage → transcribe → summarize**:
`Recording` + `Speaker` + `Segment` (the unified transcript), presigned /
multipart upload sessions, and a data-driven, outbox-backed processing
pipeline with retry, DLQ and reconcile.

Speech-to-text and summarization are **delegated to
[stapel-agent](https://github.com/usestapel/stapel-agent)** via the
`llm.transcribe` / `llm.summarize` comm Functions — this module does not
implement STT or LLM calls. Object storage goes through a swappable seam.

## Uploads: two ceilings, because two different things

Because the container is discarded, "what we accept" and "what we keep" are
different questions and one number cannot answer both:

| Setting | Default | Bounds |
| --- | --- | --- |
| `MAX_CONTAINER_UPLOAD_BYTES` | 16 GiB | what we are willing to **receive** and run through extraction — bandwidth, temp disk and ffmpeg time |
| `MAX_STORED_BYTES` | 512 MiB | what we are willing to **keep**, enforced on the extracted audio (≈47 h at the Opus profile) |
| `MAX_UPLOAD_BYTES` | 2 GiB | the ceiling when nothing is extracted (`AUDIO_ONLY_INGEST = False`), where received and stored are the same object |

`services.accepted_upload_limit()` picks between the first and the last, and
it is that number the `413` quotes. It fails safe: drop `convert` from the
`PIPELINE`, or point `NORMALIZER` at `passthrough_normalize`, and the
accepted ceiling falls back to the storage-shaped one on its own — the
raised limit cannot outlive the promise that made it large. System check
`stapel_recordings.E007` tells the operator they are in that state, and
`E006` refuses a deploy whose ffmpeg is missing or has no `libopus`.

**Read the limits before uploading**, so a client refuses an oversized file
locally and names the real number:

```
GET /recordings/api/v1/recordings/upload-limits
{"max_upload_bytes": 17179869184, "max_stored_bytes": 536870912,
 "audio_only_ingest": true, "stored_audio_codec": "opus",
 "stored_audio_channels": 1, "stored_audio_sample_rate": 16000,
 "stored_bytes_per_hour": 10800000, "multipart_part_size": 10485760,
 "max_multipart_parts": 10000, "allowed_extensions": ["3gp", "aac", …]}
```

A refusal is an **answer**, not a 500: every upload error this module raises
is a DRF-aware `StapelServiceError`, so a host view that calls
`services.start_multipart_upload` directly answers `413`
`error.413.recording_too_large` with `{size, limit}` in the standard
envelope, with no `try/except` of its own (`400` for a size that is not a
size or a malformed part list, `415` for the file type, `409` for nothing
stored, `503` when the content gate could not run).

### The stored profile

Mono, 16 kHz, Ogg/Opus at 24 kbps — `AUDIO_CHANNELS`, `AUDIO_SAMPLE_RATE`,
`AUDIO_CODEC`, `AUDIO_BITRATE_BPS`. Mono is the default because every
downstream consumer here reads a single mixed track: diarization is the ASR
provider separating speakers within it, not channel separation, and this
module has downmixed since its first release. A host whose provider *does*
separate by channel sets `AUDIO_CHANNELS = 2` and pays for it in bytes.
`AUDIO_CODEC = "wav"` restores 16-bit PCM (~115 MB/hour) for a provider that
will not take Opus.

Every upload is re-encoded to the profile, including one that arrives as
audio already: a "this one is fine as it is" branch would have to be right
about container, codec, channel layout and sample rate at once, and it would
make the stored bytes depend on what the client happened to send.

To keep originals anyway — a documented exception, off by default — set
`AUDIO_ONLY_INGEST = False`; the accepted ceiling drops to `MAX_UPLOAD_BYTES`
in the same move.

`python manage.py recordings_audio_census` reports, read-only, how many
recordings still hold an uploaded container, what they weigh, and what the
same recordings would occupy as mono audio.

## Quick start

The base install uses the Django-storage backend; add the `s3` extra for the
boto3 S3/MinIO backend:

```bash
pip install "stapel-recordings[s3]"
```

```python
INSTALLED_APPS = [
    # ...
    "stapel_core.django.outbox",   # transactional outbox (pipeline reliability)
    "stapel_recordings",
]

# urls.py
path("recordings/", include("stapel_recordings.urls"))
```

The `transcribe` / `merge` stages call stapel-agent by comm name — install
and configure stapel-agent (or provide `llm.transcribe` / `llm.summarize`
providers) for the pipeline to complete. The default `convert` stage needs
`ffmpeg`/`ffprobe` on PATH (or set `NORMALIZER` to `passthrough_normalize`).

## The pipeline is data you can edit

```python
STAPEL_RECORDINGS = {
    # Reorder / subset / insert stages — no fork:
    "PIPELINE": ["convert", "transcribe", "redact_pii", "merge"],
    # Replace or add stage handlers (merge-over-builtins; None removes):
    "STAGES": {"diarize": "myproject.stages.PyannoteDiarizer"},
    # Or source the list at runtime (DB / per-workspace / per-recording):
    "PIPELINE_RESOLVER": "myproject.pipelines.resolve",
    # Swap the object store:
    "STORAGE": "stapel_recordings.storage.S3Backend",
}
```

A generic driver runs the resolved stage list, advancing the status machine
and emitting the next stage through the outbox. See
[MODULE.md](https://github.com/usestapel/stapel-recordings/blob/main/MODULE.md)
for the stage contract and worked examples.

## Settings

All configuration lives in the `STAPEL_RECORDINGS` namespace (dict setting,
flat setting, or env var — resolved lazily). See the full table in
[MODULE.md](https://github.com/usestapel/stapel-recordings/blob/main/MODULE.md).
Highlights: `PIPELINE`, `STAGES`, `PIPELINE_RESOLVER`, `STORAGE`,
`NORMALIZER`, `SUMMARIZE_ENABLED`, `MAX_STAGE_RETRIES`.

## comm surface

| Kind | Name | Contract |
|---|---|---|
| Action (emit) | `recording.uploaded`, `recording.stage_completed`, `recording.completed`, `recording.failed` | pipeline lifecycle (public); the run events carry `run_id` + `attempt` — a reprocess is a new run, so meter on `recording_id` + `run_id` |
| Action (consume) | `recording.uploaded`, `recording.stage`, `user.deleted` | driver + GDPR erase |
| Function (call) | `llm.transcribe`, `llm.summarize` | provided by stapel-agent |

## Operations

```bash
python manage.py recordings_reconcile --once   # re-drive stuck recordings
```

## Development

```bash
pip install -e . && pip install pytest pytest-django ruff jsonschema djangorestframework
./setup-hooks.sh
pytest tests/
```

## License

MIT — see [LICENSE](https://github.com/usestapel/stapel-recordings/blob/main/LICENSE).

---

<sub>This page is assembled by `stapel-readme` from `docs/readme.md` plus the contract artifacts in `docs/`. Edit the prose in `docs/readme.md`; the badges, facts and links above and below it are generated — do not hand-edit `README.md`.</sub>
