patristic-be / src /api /dto /audio.py
Mario33333's picture
deploy: main@5d35a5b (batched narration read-path: clip offsets + clipRef headers so the reader can play ONE sentence out of a shared blob; + research agent)
3b86006 verified
Raw
History Blame Contribute Delete
9.46 kB
"""DTOs for the audiobook endpoints (BE_AUDIOBOOK_SPEC Β§5.5).
Field names match the FE ``contract.ts`` 1:1 (camelCase via :class:`ApiModel`).
``AudiobookStateEnum`` is a real ``str, Enum`` so OpenAPI codegen yields a TS
union (a ``Literal`` would collapse to ``string``).
"""
from __future__ import annotations
from enum import Enum
from .common import ApiModel
class AudiobookStateEnum(str, Enum):
"""Mirrors the FE ``AudiobookState`` union."""
idle = "idle"
generating = "generating"
ready = "ready"
partial = "partial"
error = "error"
class AudioManifestSpan(ApiModel):
"""One span entry in the manifest (matches a reader highlight span).
BATCHED NARRATION (additive, v10): when one TTS request voiced several
consecutive sentences, the returned audio is stored ONCE and never cut, and
each sentence carries a play window into that shared blob.
* ``clipStartMs`` / ``clipEndMs`` null β†’ this span owns its whole clip file
(one request per sentence β€” the original behaviour, and what every span
recorded before this feature reports). Clients that ignore these fields
keep working unchanged.
* both set β†’ play the fetched audio from ``clipStartMs`` and stop at
``clipEndMs``.
``durationMs`` is ALWAYS this sentence's own length (``clipEndMs -
clipStartMs`` when windowed), never the shared blob's, so every existing
consumer of it stays correct.
``clipRef`` is an opaque, stable id for the underlying audio object. Spans
that share a blob share a ``clipRef``, which is how a client fetches and
stores it ONCE instead of once per sentence. It is a hash, not a storage
path β€” it exposes no bucket layout.
"""
span_id: str
page: int
paragraph: int
duration_ms: int
has_word_marks: bool
clip_start_ms: int | None = None
clip_end_ms: int | None = None
clip_ref: str | None = None
class AudioManifest(ApiModel):
"""``GET /books/{id}/audio/manifest`` 200 body."""
engine: str
voice: str
lang: str
generated_at: str | None = None
spans: list[AudioManifestSpan]
total_spans: int
done_spans: int
state: AudiobookStateEnum
class AudioStatus(ApiModel):
"""``GET /books/{id}/audio/status`` 200 body (manifest numbers, no spans)."""
state: AudiobookStateEnum
done_spans: int
total_spans: int
engine: str
error: str | None = None
# Actual accumulated cost for this (book_id, engine, voice) run, summed from
# tts_usage_ledger. null when no usage rows exist (e.g. free MMS runs).
actual_cost_usd: float | None = None
class GenerateAudiobookRequest(ApiModel):
"""``POST /books/{id}/audio:generate`` body.
``engine`` is required (validated against the TTS registry). The rest are
optional selection params that default from ``config.yaml > audiobook`` when
omitted, so the wizard can pre-fill them transparently:
* ``model`` β€” engine model id (e.g. ``gemini-2.5-flash-preview-tts``).
* ``voice`` β€” engine voice id (e.g. ``Pulcherrima`` / ``ar-XA-Wavenet-D``).
* ``lang`` β€” BCP-47 narration language (defaults from the book's language).
* ``style`` β€” optional natural-language delivery instruction (Gemini only).
* ``tier`` β€” ``'free'`` (throttle hard, $0) or ``'paid'`` (full speed).
* ``force`` β€” re-generate spans already rendered for this (engine, voice).
"""
engine: str
model: str | None = None
voice: str | None = None
lang: str | None = None
style: str | None = None
tier: str | None = None
force: bool = False
class AudioRunSummaryDTO(ApiModel):
"""One (engine, voice) run summary for the admin overview."""
engine: str
voice: str
model_version: str | None = None
state: str
done_clips: int
skipped_clips: int
error_clips: int
# total_spans from the status row (planned at generation start).
total_spans: int
# Percentage of planned spans that are done (done_clips / total_spans * 100).
coverage_pct: float
# Sum of duration_ms over done clips (ms).
total_duration_ms: int
# ISO-8601 string of the last finished_at timestamp (may be null if still running).
last_run_at: str | None = None
# Actual accumulated cost for this (engine) for the current calendar month.
# null = free engine (MMS) or no ledger rows yet.
actual_cost_usd: float | None = None
class AudioBookAdminSummary(ApiModel):
"""One book entry in the admin overview."""
book_id: str
title: str
language: str | None = None
# Planned spans (from the most-recent status row, or 0 when no runs).
total_spans: int
runs: list[AudioRunSummaryDTO]
class AudioAdminOverview(ApiModel):
"""``GET /books/audio/admin/overview`` 200 body."""
books: list[AudioBookAdminSummary]
class AudioVoiceStateEnum(str, Enum):
"""Per-voice recording status for a book (drives the Reading Room picker).
Distinct from :class:`AudiobookStateEnum` (a generation-run lifecycle): this
is the READER-facing answer to "can I pick this voice for THIS book?".
* ``ready`` β€” every planned span is recorded (selectable, full playback).
* ``partial`` β€” some spans recorded, some missing (selectable; missing
spans fall through per-span on the client).
* ``none`` β€” no clips for this (engine, voice) on this book; the voice
COULD be generated (it's in the config catalog) but isn't
recorded yet β†’ shown but DISABLED ("Not recorded yet").
"""
ready = "ready"
partial = "partial"
none = "none"
class AudioVoice(ApiModel):
"""One selectable/visible voice in ``GET /books/{id}/audio/voices``.
Recorded voices (``ready``/``partial``) carry real ``spansDone``/``spansTotal``
and play their own clips when selected. ``none`` voices come from the config
``audiobook.providers`` catalog (merged in) so the picker can show every voice
that exists, disabled, with its Arabic rating.
"""
engine: str
voice: str
label: str
state: AudioVoiceStateEnum
spans_done: int = 0
spans_total: int = 0
# PAGE coverage β€” the human-meaningful count the picker shows ("180 / 400
# pages"). pages_done = DISTINCT book pages with β‰₯1 'done' clip for this voice;
# pages_total = the book's real page count (book metadata). The span counts
# above (sentence/chunk granularity, ~20Γ— larger) are kept for any internal
# use but are NOT what the reader sees.
pages_done: int = 0
pages_total: int = 0
# Arabic quality /10 from config (Mario's lived score), null when unrated.
arabic_rating: float | None = None
# One-line human insight (config ``insight``), shown as picker subtext.
insight: str | None = None
# Pinned model id for a recorded voice (transparency), null for catalog-only.
model_version: str | None = None
class AudioVoiceList(ApiModel):
"""``GET /books/{id}/audio/voices`` 200 body.
``voices`` is ordered most-complete-recorded first, then catalog-only
(``none``) voices. ``default`` is the engine/voice the FE should pre-select
(the most-complete recorded voice, or null when nothing is recorded yet).
"""
voices: list[AudioVoice]
default_engine: str | None = None
default_voice: str | None = None
class AudioCostEstimate(ApiModel):
"""``GET /books/{id}/audio/estimate`` 200 body β€” pre-flight consent step.
The legacy CostGuard fields (cost_usd .. is_free) are preserved for backward
compatibility. The richer fields below are ADDITIVE (Task 2 / Β§13 estimate).
"""
engine: str
chars: int
cost_usd: float
free_chars_remaining: int
budget_remaining_usd: float
within_budget: bool
warn: bool
would_exceed: bool
is_free: bool
# --- ADDITIVE: richer Β§13 estimate fields (may be null for simple engines) ---
# Number of narratable spans (non-empty speech_text; excludes page-number artifacts)
narratable_spans: int | None = None
# Estimated audio duration in minutes (chars / 10.5 ar-chars/sec)
est_audio_minutes: float | None = None
# Gemini token breakdown (null for char-billed engines)
est_tokens: dict | None = None
# More precise USD estimate from Β§13 method (supersedes cost_usd for Gemini)
est_usd: float | None = None
# ISO-4217 currency code
currency: str = "USD"
# Chars or tokens remaining in the free monthly quota for this provider
# (null when not applicable, e.g. MMS=unlimited, Gemini=rate-limited not char-limited)
free_quota_remaining: int | None = None
# True when the book's chars fit within this month's free quota
within_free_tier: bool | None = None
# Whether the price figures are approximate (always true for Gemini; false for MMS)
price_estimated: bool | None = None
# Human-readable one-liner (chars β†’ estimate β†’ cost)
breakdown: str | None = None
# Model id actually used for the estimate
model: str | None = None
# Voice id actually used for the estimate
voice: str | None = None