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)}