Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


backend/captions.py (10127 bytes)

1 """Cloud captions for Subrep: hours of speech recognition, bought in packs.
2 
3 Subrep makes live captions on the phone for free. With cloud captions, the app
4 sends each piece of speech here, and this server gives it to a hosted
5 Whisper large-v3 turbo (DeepInfra), a larger model than a phone can run. The account
6 pays with hours from a pack.
7 
8   20 hours    $4.99
9   80 hours    $16.99
10   200 hours   $39.00
11 
12 DeepInfra bills $0.0002 for each minute of audio: $0.012 for each hour. The
13 packs keep about 90 % of the price after that cost and the Stripe fee. Prices
14 and packs are an owner decision of 2026-09-22. Groq was the first choice, but
15 its paid tier was not open to this account.
16 
17 The speech service is any OpenAI-compatible transcription endpoint:
18 SUBPLZ_WEB_CAPTION_API_URL, SUBPLZ_WEB_CAPTION_MODEL and
19 SUBPLZ_WEB_CAPTION_API_KEY choose it.
20 
21 The server takes the seconds of a piece before it calls the speech service,
22 and gives them back if the service fails. So two requests at the same time cannot spend one second
23 twice. The server does not keep the sound or the text.
24 
25 A pack is a pricing.Plan, so payments.py sells it the same way as a book pack.
26 The packs are not in pricing.plans(): the book page does not show them.
27 """
28 from __future__ import annotations
29 
30 import io
31 import json
32 import logging
33 import math
34 import secrets
35 import urllib.error
36 import urllib.request
37 import wave
38 from typing import Annotated
39 
40 from fastapi import APIRouter, Depends, HTTPException, Query, Request
41 from fastapi.concurrency import run_in_threadpool
42 from pydantic import BaseModel
43 from sqlalchemy import ForeignKey, Integer, String, update
44 from sqlalchemy.dialects import postgresql, sqlite
45 from sqlalchemy.orm import Mapped, Session, mapped_column
46 
47 from .api import get_account, get_session
48 from .db import Account, Base
49 from .pricing import Plan
50 from .settings import settings
51 
52 log = logging.getLogger(__name__)
53 
54 SAMPLE_RATE = 16_000
55 BYTES_PER_SECOND = SAMPLE_RATE * 2  # 16-bit mono
56 
57 PACKS: list[Plan] = [
58     Plan(id="captions20", name="20 hours of cloud captions", credits=0, price_cents=499),
59     Plan(id="captions80", name="80 hours of cloud captions", credits=0, price_cents=1699),
60     Plan(id="captions200", name="200 hours of cloud captions", credits=0, price_cents=3900),
61 ]
62 HOURS = {"captions20": 20, "captions80": 80, "captions200": 200}
63 
64 # INSERT ... ON CONFLICT is not standard SQL, so SQLAlchemy has one for each database.
65 _INSERT = {"postgresql": postgresql.insert, "sqlite": sqlite.insert}
66 
67 
68 class CaptionBalance(Base):
69     """The seconds of cloud captions that an account has."""
70 
71     __tablename__ = "caption_balances"
72 
73     account_id: Mapped[str] = mapped_column(
74         String(64), ForeignKey("accounts.id"), primary_key=True
75     )
76     seconds_left: Mapped[int] = mapped_column(Integer, default=0)
77     seconds_used: Mapped[int] = mapped_column(Integer, default=0)
78 
79 
80 def pack(plan_id: str) -> Plan | None:
81     return next((p for p in PACKS if p.id == plan_id), None)
82 
83 
84 def seconds_left(session: Session, account_id: str) -> int:
85     row = session.get(CaptionBalance, account_id)
86     return row.seconds_left if row else 0
87 
88 
89 def add(session: Session, account_id: str, seconds: int) -> None:
90     """Give `seconds` to the account. The caller commits.
91 
92     The database does each step in one statement, as in spend: the INSERT
93     makes a missing row, and the UPDATE adds to the row. A sum made in Python
94     could lose a credit or a spend of another transaction on Postgres.
95     """
96     insert = _INSERT[session.get_bind().dialect.name]
97     session.execute(
98         insert(CaptionBalance)
99         .values(account_id=account_id, seconds_left=0, seconds_used=0)
100         .on_conflict_do_nothing(index_elements=[CaptionBalance.account_id])
101     )
102     session.execute(
103         update(CaptionBalance)
104         .where(CaptionBalance.account_id == account_id)
105         .values(seconds_left=CaptionBalance.seconds_left + seconds)
106     )
107 
108 
109 def spend(session: Session, account_id: str, seconds: int) -> bool:
110     """Take `seconds` from the account, and commit. False if it has fewer."""
111     done = session.execute(
112         update(CaptionBalance)
113         .where(CaptionBalance.account_id == account_id,
114                CaptionBalance.seconds_left >= seconds)
115         .values(seconds_left=CaptionBalance.seconds_left - seconds,
116                 seconds_used=CaptionBalance.seconds_used + seconds)
117     ).rowcount
118     session.commit()
119     return done == 1
120 
121 
122 def refund(session: Session, account_id: str, seconds: int) -> None:
123     session.execute(
124         update(CaptionBalance)
125         .where(CaptionBalance.account_id == account_id)
126         .values(seconds_left=CaptionBalance.seconds_left + seconds,
127                 seconds_used=CaptionBalance.seconds_used - seconds)
128     )
129     session.commit()
130 
131 
132 def fulfil(session: Session, account: Account, plan: Plan) -> None:
133     """Called by payments.fulfil for a paid pack. The caller commits."""
134     if plan.id in HOURS:
135         add(session, account.id, HOURS[plan.id] * 3600)
136 
137 
138 def merge(session: Session, src: Account, dst: Account) -> None:
139     """Called by accounts.merge: the hours of `src` go to `dst`."""
140     row = session.get(CaptionBalance, src.id)
141     if row is None or row.seconds_left == 0:
142         return
143     add(session, dst.id, row.seconds_left)
144     row.seconds_left = 0
145 
146 
147 def billed_seconds(pcm_bytes: int) -> int:
148     """The seconds that a piece of `pcm_bytes` costs: its length, rounded up."""
149     return max(1, math.ceil(pcm_bytes / BYTES_PER_SECOND))
150 
151 
152 def wav(pcm: bytes) -> bytes:
153     out = io.BytesIO()
154     with wave.open(out, "wb") as w:
155         w.setnchannels(1)
156         w.setsampwidth(2)
157         w.setframerate(SAMPLE_RATE)
158         w.writeframes(pcm)
159     return out.getvalue()
160 
161 
162 class TranscribeError(RuntimeError):
163     pass
164 
165 
166 def transcribe(pcm: bytes, language: str) -> str:
167     """Send one piece of speech to the speech service. Returns its text."""
168     boundary = secrets.token_hex(16)
169     fields = [("model", settings.caption_model), ("response_format", "json"),
170               ("temperature", "0")]
171     if language and language != "auto":
172         fields.append(("language", language))
173     body = io.BytesIO()
174     for name, value in fields:
175         body.write(f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"'
176                    f"\r\n\r\n{value}\r\n".encode())
177     body.write(f'--{boundary}\r\nContent-Disposition: form-data; name="file"; '
178                f'filename="piece.wav"\r\nContent-Type: audio/wav\r\n\r\n'.encode())
179     body.write(wav(pcm))
180     body.write(f"\r\n--{boundary}--\r\n".encode())
181     request = urllib.request.Request(
182         settings.caption_api_url, data=body.getvalue(), method="POST",
183         headers={"Authorization": f"Bearer {settings.caption_api_key}",
184                  "Content-Type": f"multipart/form-data; boundary={boundary}"},
185     )
186     try:
187         with urllib.request.urlopen(request, timeout=30) as response:
188             return json.load(response).get("text", "").strip()
189     except urllib.error.HTTPError as exc:
190         raise TranscribeError(f"speech service answered {exc.code}") from exc
191     except (urllib.error.URLError, TimeoutError, ValueError) as exc:
192         raise TranscribeError(f"speech service unreachable: {exc}") from exc
193 
194 
195 # ---------------------------------------------------------------------------
196 # routes
197 # ---------------------------------------------------------------------------
198 
199 router = APIRouter(prefix="/api/captions")
200 
201 
202 class PackIn(BaseModel):
203     pack_id: str
204 
205 
206 def _state(session: Session, account: Account) -> dict:
207     return {
208         "account_id": account.id,
209         "seconds_left": seconds_left(session, account.id),
210         "available": bool(settings.caption_api_key) and settings.payments_configured,
211         # The Play build of Subrep sells with Google Play Billing, not with Stripe.
212         "play_available": bool(settings.caption_api_key) and settings.play_configured,
213         "packs": [
214             {"id": p.id, "name": p.name, "hours": HOURS[p.id],
215              "price_display": p.price_display}
216             for p in PACKS
217         ],
218     }
219 
220 
221 @router.get("")
222 def caption_state(
223     account: Annotated[Account, Depends(get_account)],
224     session: Annotated[Session, Depends(get_session)],
225 ):
226     return _state(session, account)
227 
228 
229 @router.post("/checkout")
230 def caption_checkout(
231     body: PackIn,
232     account: Annotated[Account, Depends(get_account)],
233     session: Annotated[Session, Depends(get_session)],
234 ):
235     from . import payments
236 
237     plan = pack(body.pack_id)
238     if plan is None:
239         raise HTTPException(404, "No such pack.")
240     try:
241         return {"url": payments.start_checkout(session, account, plan)}
242     except payments.PaymentsUnavailable as exc:
243         raise HTTPException(503, str(exc)) from exc
244     except payments.PaymentError as exc:
245         raise HTTPException(400, str(exc)) from exc
246 
247 
248 @router.post("/transcribe")
249 async def caption_transcribe(
250     request: Request,
251     account: Annotated[Account, Depends(get_account)],
252     session: Annotated[Session, Depends(get_session)],
253     lang: Annotated[str, Query(max_length=8)] = "auto",
254 ):
255     """The body is one piece of speech: 16 kHz mono 16-bit little-endian PCM."""
256     if not settings.caption_api_key:
257         raise HTTPException(503, "Cloud captions are not set up on this server.")
258     pcm = await request.body()
259     if not pcm or len(pcm) % 2:
260         raise HTTPException(400, "The body must be 16-bit PCM.")
261     if len(pcm) > settings.caption_max_seconds * BYTES_PER_SECOND:
262         raise HTTPException(413, f"A piece is at most {settings.caption_max_seconds} s.")
263 
264     cost = billed_seconds(len(pcm))
265     if not spend(session, account.id, cost):
266         raise HTTPException(402, "No cloud caption hours left.")
267     try:
268         text = await run_in_threadpool(transcribe, pcm, lang)
269     except TranscribeError as exc:
270         refund(session, account.id, cost)
271         log.warning("caption piece for %s failed: %s", account.id, exc)
272         raise HTTPException(502, str(exc)) from exc
273     return {"text": text, "seconds": cost,
274             "seconds_left": seconds_left(session, account.id)}