Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ An interactive demo app for exploring Deepgram's real-time speech-to-text API. S
## Features

- **Mic streaming** — Real-time transcription from your browser microphone using Deepgram's WebSocket API
- **Flux (`/v2/listen`)** — Pick `flux-general-en` or `flux-general-multi` and the app switches endpoints, swaps the params panel for the small set v2 accepts, and renders the turn events (`StartOfTurn`, `EagerEndOfTurn`, `TurnResumed`, `EndOfTurn`) with their end-of-turn confidence
- **File streaming** — Upload an audio file and stream it to Deepgram in real-time, with transcript synced to playback
- **Batch transcription** — Submit a file or URL for single-shot transcription via the REST API
- **TTS Test mode** — Type any text, generate speech via Deepgram TTS, and transcribe it back through the STT pipeline. Ideal for testing redaction, formatting, and model behavior without a microphone
Expand Down Expand Up @@ -239,6 +240,14 @@ Non-obvious things discovered during the Flask/gevent → FastAPI async migratio
- **Never join a caller-supplied filename onto a directory** — Python's `Path` join *replaces* the base when the right side is absolute, so `TEMP_DIR / "/etc/passwd"` is `/etc/passwd`. Take `Path(name).name` and assert the resolved parent.
- **Never interpolate a caller-supplied host into a URL that carries your API key** — it forwards the credential to whatever host they named.

Flux (`/v2/listen`) specifically, all measured against production:

- **v2 is strict where v1 is permissive** — v1 drops query params it does not recognise and transcribes anyway; v2 refuses the whole handshake for a single unknown one, with a body that reads only `Unexpected error when initializing websocket connection`. It names no parameter. That is why Flux params go through an allowlist (`FLUX_PARAMS`) rather than the mode denylist v1 uses, and why the app range-checks the end-of-turn knobs itself.
- **Every Flux message arrives as a plain `dict`** — the SDK's `V2SocketClientResponse` union contains a bare `typing.Any`, which `construct_type` matches first, so `isinstance(msg, ListenV2TurnInfo)` is never true. The v1 union has no `Any` and does yield models. A handler copied from the v1 one drops every turn in silence.
- **Flux has no `KeepAlive`** — only `Configure` and `CloseStream`. The v2 socket client has no `send_keep_alive`, so a keep-alive loop copied from v1 kills the stream with an `AttributeError` eight seconds in.
- **A turn ends on silence in the AUDIO, not on wall clock** — and `CloseStream` flushes turns that already ended rather than forcing the open one shut. Cut a clip tight to the last syllable and the final turn never arrives: same 19-word clip, 8 runs each, 8/8 complete with a second of trailing silence and 0/8 without. The app detects this and says so instead of showing a short transcript with no explanation.
- **The docs are wrong about two things** — `/docs/flux/configuration` gives `eot_timeout_ms` a max of 10000 (the real ceiling is 60000, per the quickstart), and the Flux feature-overview lists `version` as supported (it is a 400). `language_hint` is in the SDK's `connect()` signature unconditionally but is a 400 on `flux-general-en`.

---

## Deploying to Fly.io
Expand Down
628 changes: 471 additions & 157 deletions app.py

Large diffs are not rendered by default.

74 changes: 62 additions & 12 deletions scripts/probe_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
uv run python scripts/probe_ui.py --expect-removed Keywords --expect-present Keyterms
uv run python scripts/probe_ui.py --mode streaming \
--expect-absent "Filler Words" Utterances Paragraphs
uv run python scripts/probe_ui.py --model flux-general-en \
--expect-present eot_threshold --expect-absent "Smart Format" \
--expect-url-contains /v2/listen

Exits non-zero when an expectation is unmet, so it works as a gate rather than
just a screenshot printer.
Expand Down Expand Up @@ -46,6 +49,9 @@ async def probe(
expect_absent: list[str],
expect_removed: list[str],
mode: str | None,
model: str | None,
expect_url_contains: list[str],
expect_url_absent: list[str],
) -> int:
ARTIFACTS.mkdir(exist_ok=True)
failures: list[str] = []
Expand Down Expand Up @@ -78,23 +84,34 @@ async def probe(
# mode-gated (batch-only features are hidden while streaming), so --mode
# picks which surface is being asserted.
await page.evaluate(
"""(m) => {
"""({m, model}) => {
const d = Alpine.$data(document.querySelector('[x-data]'));
if (m) d.mode = m;
// Flux gating is driven by the MODEL, not the mode, so a probe that
// could only set the mode could not see the Flux surface at all.
if (model) d.params.model = model;
Object.keys(d.sections || {}).forEach(k => d.sections[k] = true);
}""",
mode,
{"m": mode, "model": model},
)
await page.wait_for_timeout(700)
await page.wait_for_timeout(900)
if mode:
print(f"mode: {mode}")

# VISIBLE labels only, and both label flavours (field rows use
# .field-label, checkboxes use <label for>). A mode-gated control is
# still in the DOM with x-show false, so presence is not the question —
# whether the user can see it is.
if model:
print(f"model: {model}")

# VISIBLE labels only, and every label flavour the panel uses: field rows
# (.field-label), checkboxes (<label for>) and switches (.toggle-label).
# A mode-gated control is still in the DOM with x-show false, so presence
# is not the question — whether the user can see it is.
#
# DO NOT drop .toggle-label. It was missing, which made every switch —
# Interim Results, VAD Events, No Delay, Multichannel, MIP Opt Out —
# invisible to this harness, so --expect-absent on any of them passed
# without checking anything. A gate that cannot fail is not a gate.
labels = await page.evaluate("""() => {
const seen = document.querySelectorAll('.field-label, .checkbox-item label');
const seen = document.querySelectorAll(
'.field-label, .checkbox-item label, .toggle-label');
return [...seen]
.filter(e => e.offsetParent !== null)
.map(e => e.textContent.trim());
Expand All @@ -109,6 +126,23 @@ async def probe(
"Alpine.$data(document.querySelector('[x-data]')).params))"
)

# The URL bar is a headline feature — people copy it into curl — so what
# it displays is part of the contract, not decoration.
url_display = await page.evaluate(
"() => Alpine.$data(document.querySelector('[x-data]')).urlDisplay"
)
print(f"url bar: {url_display}")
for fragment in expect_url_contains:
if fragment not in (url_display or ""):
failures.append(f"url bar {url_display!r} does not contain {fragment!r}")
# Asserting on the URL rather than on a label is the unambiguous test for
# a stripped param: several controls share label text (the Core language
# field and the TTS voice-language picker are both "Language"), but only
# one thing can put language= on the wire.
for fragment in expect_url_absent:
if fragment in (url_display or ""):
failures.append(f"url bar {url_display!r} still contains {fragment!r}")

print(f"fields rendered: {len(labels)}")
print(json.dumps(sorted(labels), indent=1))
print(f"\nconsole errors: {console_errors or 'none'}")
Expand Down Expand Up @@ -143,8 +177,11 @@ async def probe(
if key in params:
failures.append(f"{key!r} is still in the live Alpine params state")

# Mode in the filename so a streaming run does not overwrite a batch one.
shot = ARTIFACTS / f"ui_{mode or 'default'}.png"
# Mode AND model in the filename so no run overwrites another: the two
# axes are independent (nova-3 renders differently in streaming vs
# batch, and flux differently again), and an overwritten artifact is a
# PR that shows the wrong evidence.
shot = ARTIFACTS / f"ui_{model or 'default'}_{mode or 'default'}.png"
await page.screenshot(path=str(shot), full_page=True)
print(f"\nartifact: {shot}")
await browser.close()
Expand Down Expand Up @@ -174,9 +211,22 @@ def main() -> int:
"--mode", default=None,
help="set the app mode first (streaming|batch); some controls are mode-gated",
)
ap.add_argument(
"--model", default=None,
help="set params.model first; Flux gating is model-driven, not mode-driven",
)
ap.add_argument(
"--expect-url-contains", nargs="*", default=[],
help="fragments the URL bar must display, e.g. /v2/listen",
)
ap.add_argument(
"--expect-url-absent", nargs="*", default=[],
help="fragments the URL bar must NOT display, e.g. smart_format",
)
a = ap.parse_args()
return asyncio.run(
probe(a.url, a.expect_present, a.expect_absent, a.expect_removed, a.mode)
probe(a.url, a.expect_present, a.expect_absent, a.expect_removed,
a.mode, a.model, a.expect_url_contains, a.expect_url_absent)
)


Expand Down
Loading