commit 521ebd1077b11fa4cbdda569662f4d90f98941b1 equwal <truex@equwal.com> 2026-09-22 17:16:01 -0700 Use DeepInfra for cloud captions, and name the key after its job The paid tier of Groq is not open to this account, and its free tier allows 20 requests a minute for all users together. DeepInfra serves whisper-large-v3-turbo for $0.0002 a minute ($0.012 an hour, against about $0.08 at Groq), with pay as you go. - The defaults are now the OpenAI-compatible route of DeepInfra and the model openai/whisper-large-v3-turbo. Any OpenAI-compatible transcription endpoint works with SUBPLZ_WEB_CAPTION_API_URL and SUBPLZ_WEB_CAPTION_MODEL. - SUBPLZ_WEB_GROQ_API_KEY is now SUBPLZ_WEB_CAPTION_API_KEY. No server had the old name. Tests: 17 caption tests pass; the full suite has 140 passed and the one failure that main also has (test_vendor_files_may_be_kept).
backend/captions.py | 28 ++++++++++++++++------------ backend/settings.py | 8 ++++---- tests/test_captions.py | 38 +++++++++++++++++++------------------- 3 files changed, 39 insertions(+), 35 deletions(-)
diff --git a/backend/captions.py b/backend/captions.py index 697d5b3..da43c0d 100644 --- a/backend/captions.py +++ b/backend/captions.py @@ -1,21 +1,25 @@ """Cloud captions for Subrep: hours of speech recognition, bought in packs. Subrep makes live captions on the phone for free. With cloud captions, the app -sends each piece of speech here, and this server gives it to Groq -(whisper-large-v3-turbo), a larger model than a phone can run. The account +sends each piece of speech here, and this server gives it to a hosted +Whisper large-v3 turbo (DeepInfra), a larger model than a phone can run. The account pays with hours from a pack. 20 hours $4.99 80 hours $16.99 200 hours $39.00 -Groq bills about $0.04 for each audio hour, and at least 10 seconds for each -request. Pieces of speech are 1 to 11 seconds long, so one hour costs about -$0.08. The packs keep more than half of the price after that cost and the -Stripe fee (owner decision, 2026-09-22). +DeepInfra bills $0.0002 for each minute of audio: $0.012 for each hour. The +packs keep about 90 % of the price after that cost and the Stripe fee. Prices +and packs are an owner decision of 2026-09-22. Groq was the first choice, but +its paid tier was not open to this account. -The server takes the seconds of a piece before it calls Groq, and gives them -back if Groq fails. So two requests at the same time cannot spend one second +The speech service is any OpenAI-compatible transcription endpoint: +SUBPLZ_WEB_CAPTION_API_URL, SUBPLZ_WEB_CAPTION_MODEL and +SUBPLZ_WEB_CAPTION_API_KEY choose it. + +The server takes the seconds of a piece before it calls the speech service, +and gives them back if the service fails. So two requests at the same time cannot spend one second twice. The server does not keep the sound or the text. A pack is a pricing.Plan, so payments.py sells it the same way as a book pack. @@ -145,7 +149,7 @@ class TranscribeError(RuntimeError): def transcribe(pcm: bytes, language: str) -> str: - """Send one piece of speech to Groq. Returns its text.""" + """Send one piece of speech to the speech service. Returns its text.""" boundary = secrets.token_hex(16) fields = [("model", settings.caption_model), ("response_format", "json"), ("temperature", "0")] @@ -161,7 +165,7 @@ def transcribe(pcm: bytes, language: str) -> str: body.write(f"\r\n--{boundary}--\r\n".encode()) request = urllib.request.Request( settings.caption_api_url, data=body.getvalue(), method="POST", - headers={"Authorization": f"Bearer {settings.groq_api_key}", + headers={"Authorization": f"Bearer {settings.caption_api_key}", "Content-Type": f"multipart/form-data; boundary={boundary}"}, ) try: @@ -188,7 +192,7 @@ def _state(session: Session, account: Account) -> dict: return { "account_id": account.id, "seconds_left": seconds_left(session, account.id), - "available": bool(settings.groq_api_key) and settings.payments_configured, + "available": bool(settings.caption_api_key) and settings.payments_configured, "packs": [ {"id": p.id, "name": p.name, "hours": HOURS[p.id], "price_display": p.price_display} @@ -232,7 +236,7 @@ async def caption_transcribe( lang: Annotated[str, Query(max_length=8)] = "auto", ): """The body is one piece of speech: 16 kHz mono 16-bit little-endian PCM.""" - if not settings.groq_api_key: + if not settings.caption_api_key: raise HTTPException(503, "Cloud captions are not set up on this server.") pcm = await request.body() if not pcm or len(pcm) % 2: diff --git a/backend/settings.py b/backend/settings.py index a4f2758..217a270 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -134,10 +134,10 @@ class Settings(BaseSettings): # --- cloud captions (Subrep) ------------------------------------------ # Without a key, /api/captions/transcribe answers 503. Set it with - # tools/set-secret.sh SUBPLZ_WEB_GROQ_API_KEY on the server. - groq_api_key: str = "" - caption_api_url: str = "https://api.groq.com/openai/v1/audio/transcriptions" - caption_model: str = "whisper-large-v3-turbo" + # tools/set-secret.sh SUBPLZ_WEB_CAPTION_API_KEY on the server. + caption_api_key: str = "" + caption_api_url: str = "https://api.deepinfra.com/v1/openai/audio/transcriptions" + caption_model: str = "openai/whisper-large-v3-turbo" # The longest piece of speech in one request. The app sends 11 s at most. caption_max_seconds: int = 30 diff --git a/tests/test_captions.py b/tests/test_captions.py index 4ef29d8..75cd38f 100644 --- a/tests/test_captions.py +++ b/tests/test_captions.py @@ -22,9 +22,9 @@ SECOND = captions.BYTES_PER_SECOND @pytest.fixture -def groq(monkeypatch): - """A server with a Groq key, and Groq faked: it answers with a fixed text.""" - monkeypatch.setattr(settings, "groq_api_key", "gsk_test") +def speech(monkeypatch): + """A server with a key, and the speech service faked: it answers with a fixed text.""" + monkeypatch.setattr(settings, "caption_api_key", "gsk_test") calls = [] def fake(pcm, language): @@ -102,33 +102,33 @@ def test_a_merge_moves_the_hours(client, second_client): # --- transcribe -------------------------------------------------------------- def test_no_key_on_the_server_is_503(client, monkeypatch): - monkeypatch.setattr(settings, "groq_api_key", "") + monkeypatch.setattr(settings, "caption_api_key", "") give(client, HOUR) assert post_piece(client, 3 * SECOND).status_code == 503 assert left(client) == HOUR -def test_no_hours_is_402_and_groq_is_not_called(client, groq): +def test_no_hours_is_402_and_the_service_is_not_called(client, speech): assert post_piece(client, 3 * SECOND).status_code == 402 - assert groq == [] + assert speech == [] -def test_a_piece_costs_its_length_rounded_up(client, groq): +def test_a_piece_costs_its_length_rounded_up(client, speech): give(client, 60) r = post_piece(client, int(2.5 * SECOND)) assert r.status_code == 200 assert r.json() == {"text": "こんにちは", "seconds": 3, "seconds_left": 57} - assert groq == [(int(2.5 * SECOND), "ja")] + assert speech == [(int(2.5 * SECOND), "ja")] -def test_the_last_seconds_do_not_pay_for_a_longer_piece(client, groq): +def test_the_last_seconds_do_not_pay_for_a_longer_piece(client, speech): give(client, 2) assert post_piece(client, 3 * SECOND).status_code == 402 assert left(client) == 2 -def test_a_failure_of_groq_gives_the_seconds_back(client, monkeypatch): - monkeypatch.setattr(settings, "groq_api_key", "gsk_test") +def test_a_failure_of_the_service_gives_the_seconds_back(client, monkeypatch): + monkeypatch.setattr(settings, "caption_api_key", "gsk_test") def down(pcm, language): raise captions.TranscribeError("speech service answered 500") @@ -140,10 +140,10 @@ def test_a_failure_of_groq_gives_the_seconds_back(client, monkeypatch): @pytest.mark.parametrize("n_bytes, status", [(0, 400), (3, 400), (31 * SECOND, 413)]) -def test_a_bad_piece_is_refused_without_cost(client, groq, n_bytes, status): +def test_a_bad_piece_is_refused_without_cost(client, speech, n_bytes, status): give(client, HOUR) assert post_piece(client, n_bytes).status_code == status - assert left(client) == HOUR and groq == [] + assert left(client) == HOUR and speech == [] # --- properties -------------------------------------------------------------- @@ -182,8 +182,8 @@ def test_the_billed_seconds_cover_the_piece(n_bytes): -def test_the_request_to_groq_is_the_openai_form(monkeypatch): - """transcribe() against a local server that reads the form as Groq does.""" +def test_the_request_is_the_openai_form(monkeypatch): + """transcribe() against a local server that reads the form as the service does.""" import json import threading from email.parser import BytesParser @@ -192,7 +192,7 @@ def test_the_request_to_groq_is_the_openai_form(monkeypatch): seen = {} - class Groq(BaseHTTPRequestHandler): + class Service(BaseHTTPRequestHandler): def do_POST(self): body = self.rfile.read(int(self.headers["Content-Length"])) head = f"Content-Type: {self.headers['Content-Type']}\r\n\r\n".encode() @@ -209,15 +209,15 @@ def test_the_request_to_groq_is_the_openai_form(monkeypatch): def log_message(self, *args): pass - server = HTTPServer(("127.0.0.1", 0), Groq) + server = HTTPServer(("127.0.0.1", 0), Service) threading.Thread(target=server.handle_request, daemon=True).start() monkeypatch.setattr(settings, "caption_api_url", f"http://127.0.0.1:{server.server_port}/") - monkeypatch.setattr(settings, "groq_api_key", "gsk_test") + monkeypatch.setattr(settings, "caption_api_key", "gsk_test") pcm = bytes(range(256)) * 10 assert captions.transcribe(pcm, "ja") == "今日は" server.server_close() assert seen["auth"] == "Bearer gsk_test" - assert seen["model"] == b"whisper-large-v3-turbo" + assert seen["model"] == b"openai/whisper-large-v3-turbo" assert seen["language"] == b"ja" assert seen["file"] == captions.wav(pcm)