# Project export: SignCast

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Real-time ASL over any YouTube live stream - we turn live commentary into signed video as it happens, so Deaf and hard-of-hearing fans can follow the match with everyone else.
- Devpost: https://devpost.com/software/signcast
- GitHub: https://github.com/AkankshaThalla-24/CalHacks26
- Video: https://www.youtube.com/embed/BwFu3vXiJ54?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 2 GitHub contributor(s) — Sumanth (9 commits), Hemanth NJ (2 commits)

## Devpost submission (written by the team)

### Overview

About the project

### Inspiration

Sport is one of the most shared experiences we have, but for Deaf and hard-of-hearing fans, a live match is mostly silence. Closed captions lag, often aren't there at all for live commentary, and even when present they flatten the play-by-play into text that can't keep up with the pace or carry the feeling of the moment. We wanted to build the thing that's actually missing: a live sign-language layer that sits on top of the broadcast everyone is already watching, so nobody has to wait for a special accessible feed that may never come.

### What it does

SignCast embeds any YouTube live stream and overlays a real-time American Sign Language interpreter on top of it. It captures the broadcast's audio, transcribes the commentary, rewrites it into ASL grammar, and plays real human-signer video clips in a small, draggable, resizable overlay you can park in any corner. Underneath, a separate validation agent measures translation quality across five sign languages, so the system can be held to a standard instead of trusted on faith.

### How we built it

The live path is a streaming pipeline with two WebSocket hops: Capture: the browser grabs the tab's audio via getDisplayMedia, downsamples it to 16 kHz mono PCM, and streams it to the backend. Transcribe: Deepgram (Nova-3) turns that into text; we buffer the fragments into complete utterances, flushing on a natural pause or after a few seconds, because continuous commentary over crowd noise rarely produces a clean pause on its own. Translate: Claude rewrites each utterance into ASL-ordered gloss steps: topic-first, articles and copulas dropped, common base-form words for signs, and fingerspelling for proper nouns and anything uncertain. Render: each sign word is matched live against the WLASL dataset of human-signer clips; unmatched words fall back to fingerspelling. The backend streams one clip event per word over WebSocket, and the overlay plays them with captions, speeding each clip up slightly so the signing tracks the commentator's actual pace. Alongside this, we built a validation agent: Claude generates realistic match commentary across eight scenarios, a translator converts it into ASL using per-language grammar rules, and Claude-as-judge scores every output on five metrics, producing a markdown report that flags its own weakest cases.

### What we learned

Sign language is not English with hands. The hard part isn't playing clips, it's reordering into a different grammar and knowing when not to translate (fingerspell the name instead of inventing a sign). Generated signing was the wrong instinct. Synthetic avatars driven straight from text produce signing that's unreadable, even offensive, to fluent signers. Pre-recorded human clips with an honest fingerspelling fallback are slower to scale but actually usable and more respectful. Streaming STT fights you on live audio. The endpointing that works for clean speech stalls on nonstop commentary, which is why the utterance buffering has a time-based escape hatch. You can't claim accuracy; you have to measure it. Building the evaluator changed how we talked about the product. Challenges we faced The no-audio race. Deepgram closes a connection that gets no audio within ~10 seconds, less time than it takes a human to click "start capture" and clear the browser's tab-share permission dialog. We had to wait for the first real audio chunk before ever opening the Deepgram connection. Keeping signing in sync with live speech. WLASL clips are recorded at a slow, deliberate teaching pace; played at 1× they fall further and further behind. We pass each utterance's spoken duration through to the frontend and speed up playback (clamped) so it keeps up without looking unnatural. Vocabulary gaps. No clip set covers everything, so unmatched signs are demoted to fingerspelling rather than dropped or faked. Honest scope under a deadline. Five languages translate and grade well in evaluation, but only ASL has backing clips in the live overlay; we kept the demo honest about that line.

## README (from the GitHub repository)

# SignCast — Real-time ASL Interpretation Pipeline

Converts any video or audio source into a live ASL sign sequence for Deaf viewers.

```
Audio source (file/URL, or live browser tab capture)
    → audio_extractor.py   (raw PCM bytes)            \_ stt_service.py picks one
    → pipeline_server.py    (live browser audio relay) /
    → stt_service.py       (Deepgram → transcript text, buffered into utterances)
    → gloss_pipeline.py    (Claude → ASL sign/fingerspell steps, verified against WLASL)
    → pipeline_server.py   (WebSocket broadcast + WLASL clips served over HTTP)
    → frontend/            (browser overlay plays the clips, in sync with the video)
```

## System architecture

![SignCast system architecture](images/calhacks26_architecture.png)

## Files

- `audio_extractor.py` — pulls audio from any source (local file, HLS, RTMP, HTTP stream), decodes to real-time 16kHz mono PCM, emits bytes via `on_audio`. Used when `stt_service.py` is given a file/URL directly.
- `stream_resolver.py` — resolves YouTube / yt-dlp-supported URLs into something ffmpeg can consume. `download=True` downloads first to avoid truncating the first few words.
- `stt_service.py` — feeds PCM bytes to Deepgram (from `audio_extractor` or, with `--from-browser`, from live browser tab audio via `pipeline_server`), buffers fragments into complete utterances (on `speech_final`), sends each to `gloss_pipeline`, and broadcasts the result over WebSocket.
- `prompt.py` — builds the Claude system prompt. Claude freely picks the simplest common English word per concept; no fixed vocabulary list.
- `validator.py` — validates the shape of Claude's JSON output (well-formed steps), not vocabulary membership.
- `gloss_pipeline.py` — calls Claude to convert a transcript string into sign/fingerspell steps, then checks each "sign" step against `wlasl_lookup` and demotes unmatched ones to fingerspelling.
- `wlasl_lookup.py` — looks up a gloss word directly against a local WLASL dataset (`WLASL_v0.3.json` + `videos/`) on every call. No pre-curation step.
- `pipeline_server.py` — FastAPI/WebSocket bridge. Serves WLASL clips over HTTP at `/clips/<id>.mp4`, broadcasts `{clipUrl, gloss, caption, ts, lang}` events per word to `frontend/` over `/ws`, and (in `--from-browser` mode) receives live PCM from `frontend/audio-capture.js` over `/audio-in`.
- `frontend/` — the browser overlay (see its own section below) that actually displays the sign clips, draggable/resizable on top of the YouTube player.
- `sign_window.py` — standalone native OpenCV window for local testing/debugging without a browser (`python sign_window.py "GOAL TEAM"` or `--file glosses.txt`). Not used by the live `stt_service.py` pipeline anymore — the browser overlay is the real output now.
- `glosses.txt` — a sample word list for testing `sign_window.py --file glosses.txt` directly.

## Setup

1. Install dependencies:
   ```
   pip install -r requirements.txt
   ```

2. Set API keys in `.env`:
   ```
   DEEPGRAM_API_KEY=...
   ANTHROPIC_API_KEY=...
   ```

3. Point `wlasl_lookup.py` at your local WLASL dataset (defaults to `C:\Users\hp\Downloads\wlasl-processed`, override with the `WLASL_DIR` env var):
   ```
   WLASL_DIR=/path/to/wlasl-processed
   ```

4. Run it directly on a file/URL (prints transcripts/gloss; nothing to watch unless you also open `frontend/`):
   ```
   python stt_service.py <file-or-stream-url>
   python stt_service.py "https://www.youtube.com/watch?v=VIDEO_ID" --duration 20 --download
   ```

   Or run it against **live browser audio** (the real demo path — see "Frontend overlay" below):
   ```
   python stt_service.py --from-browser --duration 60
   ```

5. Open the overlay (separate terminal):
   ```
   cd frontend && python -m http.server 5500
   ```
   Go to `http://localhost:5500`, load any YouTube video via the picker, click **Start audio capture**, and pick "This Tab" + check "Share tab audio" in the browser's permission dialog. The overlay plays signs live, matching whatever's actually playing in that tab.

## Next steps

- Pass rolling context (previous utterance) into `gloss_pipeline.process_transcript(context=...)` for better continuity across sentences.
- Add a real fingerspelling clip set (currently fingerspell steps just show a text placeholder, no per-letter clips).

---

# SignCast · Sign-Language Accessibility (team scope)

Three pieces toward the team's goal — a sign-language layer that live-translates match
commentary for Deaf and hard-of-hearing fans.

| Piece | Folder | What it does |
|---|---|---|
| Validation agent | `validation/` | Auto-generates test commentary for 5 sign languages, runs the translator, grades each output on 5 metrics with Claude-as-judge, writes a report. |
| Frontend overlay | `frontend/` | Web app embedding a YouTube live with a translucent, draggable, resizable sign-language video overlay you can place in any corner; switch among ASL/BSL/LSF/CSL/JSL. |
| Real pipeline | `stt_service.py` + `pipeline_server.py` | The working backend — Deepgram → Claude gloss → WLASL clips, broadcast to the overlay over WebSocket. Replaces the old mock pipeline entirely (ASL only for now; BSL/LSF/CSL/JSL in the dropdown are unimplemented). |

Sign languages: **ASL, BSL, LSF (French), CSL (Chinese), JSL (Japanese)**.

---

## 1. Validation agent

```bash
cd validation
pip install -r requirements.txt
export ANTHROPIC_API_KEY=sk-...
# optional: export ANTHROPIC_MODEL=claude-sonnet-4-5
python agent.py            # 1 test case per scenario per language
python agent.py --per 2    # more cases
```

Output: `validation/reports/validation_report.md` — a table of **5 languages × 5 metrics**
(grammatical accuracy, semantic accuracy, completeness, gloss validity, real-time fluency),
overall scores, and the weakest cases flagged.

**Pipeline:** `testgen.py` (writes cases) → `translator.py` (the system under test:
English → SL gloss) → `judge.py` (Claude-as-judge, 1–5 per metric) → `report.py`.

**Optional live dashboard:** `observability.py` adds an Arize Phoenix tracing layer
on top — no-op unless `USE_PHOENIX=1` is set, so `python agent.py` behaves identically
with or without it. See `validation/PHOENIX_SETUP.md` for setup; the short version:
```bash
export USE_PHOENIX=1
python agent.py --per 2
```
prints a Phoenix UI URL (default `http://localhost:6006`) showing every traced
translate→judge span with its 5 scores, sortable worst-first.

## 2. Frontend overlay

```bash
cd frontend
python -m http.server 5500
# open http://localhost:5500
```

- Change the match: set `window.YT_VIDEO_ID` (in `youtube.js`) to any YouTube video/live id.
- Point at a different pipeline: set `window.PIPELINE_WS` (default `ws://localhost:8000/ws`).
- Drag the overlay by its header, resize from the bottom-right handle, place it in any corner
  with the corner picker, switch sign language with the dropdown.

## 3. Real pipeline (replaces the old mock)

```bash
python stt_service.py --from-browser
```

Starts `pipeline_server.py` automatically (no separate process needed). Emits one
clip event per gloss word:
```json
{ "clipUrl": "http://localhost:8000/clips/24872.mp4", "gloss": "GOAL", "caption": "the team scored a goal", "ts": 0, "lang": "ASL" }
```

## Demo

![SignCast overlay running over a live stream](images/sys-screenshot.png)

A screenshot of the system

Youtube Demo Link: [Demo Link](https://youtu.be/BwFu3vXiJ54)

---

### Quick full-demo run order
1. Terminal A: `python stt_service.py --from-browser` (waits for browser audio; starts `pipeline_server` on port 8000)
2. Terminal B: `cd frontend && python -m http.server 5500`
3. Browser: `http://localhost:5500` → load a video, click **Start audio capture**, share the tab's audio → live signing overlay.
4. (Separately) `cd validation && python agent.py` → show the quality report.

### Notes / known gaps
- Only ASL is implemented (via the WLASL dataset) — BSL/LSF/CSL/JSL exist in the
  language dropdown but have no backing clips or translation yet.
- `gloss_pipe

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 27 recognized source files, 86 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- Python (language) — detected in the code
- Redis (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (31 of 31)

```
.DS_Store
.gitignore
audio_extractor.py
demo.md
frontend/audio-capture.js
frontend/index.html
frontend/overlay.js
frontend/stream-client.js
frontend/styles.css
frontend/youtube.js
gloss_pipeline.py
pipeline_server.py
prompt.py
README.md
requirements.txt
sign_window.py
stream_resolver.py
stt_service.py
validation/agent.py
validation/judge.py
validation/languages.py
validation/llm.py
validation/metrics.py
validation/observability.py
validation/PHOENIX_SETUP.md
validation/report.py
validation/requirements.txt
validation/testgen.py
validation/translator.py
validator.py
wlasl_lookup.py
```

### Dependencies

- requirements.txt: anthropic@>=0.40.0, arize-phoenix@>=5.0.0, arize-phoenix-otel@>=0.6.0, deepgram-sdk@>=7.0.0, fastapi@>=0.110.0, numpy@>=1.24.0, opencv-python@>=4.8.0, openinference-instrumentation-anthropic@>=0.1.0, pandas@>=2.0.0, python-dotenv@>=1.0.0, uvicorn[standard]@>=0.27.0, yt-dlp@>=2025.1.1
- validation/requirements.txt: anthropic@>=0.40.0, jinja2@>=3.1.0, python-dotenv@>=1.0.0

### Recent commits (newest first)

- add demo link
- add sys arc
- naming
- fix captioning size
- add arize-phoenix for validation
- final frontend + backend integration
- Merge branch 'master' of https://github.com/AkankshaThalla-24/CalHacks26
- add wlasl integration
- Merge remote-tracking branch 'origin/main'
- integrate asl + transcript
- Merge branch 'UI-and-vals' of https://github.com/AkankshaThalla-24/CalHacks26 into UI-and-vals
- Initial commit of Hackathon code
- add asl to gloss conversion
- add text to asl-gloss generation
- code fixes and remove unwanted file
- add audio extractor + deepgram integration

## Key source files (fetched from GitHub, selected and truncated for size)

### demo.md

```markdown
# Demo Script — SignCast, Signed in Real Time (~2 min)

**Format:** one presenter, screen shared. Times are cumulative.

---

### 0:00 — The hook (15s)
> "Sports give us passion, connection, belonging. But for millions of Deaf and
> hard-of-hearing fans, a live match is mostly silence — captions lag, and they
> miss the *emotion* in a commentator's voice. We asked: what if the match could
> sign itself, live?"

### 0:15 — The product (20s)
*(Screen: a real YouTube live football stream playing full-screen.)*
> "This is any YouTube live broadcast. Watch the corner."
*(A translucent overlay appears — a signing avatar with a live caption underneath.)*
> "That's our accessibility layer, sitting on top of the broadcast. No special
> player, no separate feed — it rides on the stream people already watch."

### 0:35 — How it works (25s)
*(Screen: architecture diagram.)*
> "Commentary audio goes through Deepgram to text. Claude rewrites that English
> into the *grammar* of a sign language — not word-for-word, real signing structure.
> That drives a generated signing visual, cached in Redis so it stays in sync.
> End to end, a few seconds."

### 1:00 — Five languages (15s)
*(Screen: back to the overlay. Switch the language picker ASL → BSL → LSF.)*
> "And it's not just American Sign Language. We support five — ASL, BSL, French,
> Chinese, and Japanese sign language — switchable live, repositionable to any
> corner so it never blocks the play."

### 1:15 — The trust problem (30s)
> "But here's the hard question for any translation system: *how do you know it's
> right?* A wrong sign isn't a typo — it changes the meaning of the match."
*(Screen: the validation report — table of 5 languages × 5 metrics.)*
> "So we built a validation agent. It auto-writes test commentary across real match
> moments — goals, fouls, offside, substitutions — runs them through the translator
> for all five languages, and grades every output on grammar, meaning, completeness,
> gloss validity, and real-time fluency. It ships a report with scores and flags its
> own weakest cases. Quality you can measure, not just hope for."

### 1:45 — Close (15s)
> "A live broadcast that signs itself, in five languages, with a quality bar it
> holds itself to. We're turning 'yearning' into 'watching along with everyone
> else.' That's the experience every fan deserves."

---

**Backup talking points (if asked):**
- *Latency:* Deepgram <2s, Claude <3s, generation ~30s cold / <1s on cache hit.
- *Why gloss, not word-for-word:* sign languages have their own grammar (topic-comment,
  time-first); literal translation is unreadable to native signers.
- *Robustness:* the overlay reads from a WebSocket; if the live pipeline stalls, a
  cached clip library keeps the demo running.

```

### validation/PHOENIX_SETUP.md

```markdown
# Adding Arize Phoenix (optional eval dashboard)

Your LLM-as-judge harness is unchanged. Phoenix is a thin observability layer
on top: it traces each translate→judge cycle and logs your 5 metric scores so
you get a live UI — score distributions, per-language breakdown, and click-into
any failing translation to see its gloss + the judge's note.

## Run WITHOUT Phoenix (default, unchanged)
    python agent.py --per 2

## Run WITH Phoenix
    pip install arize-phoenix arize-phoenix-otel openinference-instrumentation-anthropic pandas
    export USE_PHOENIX=1
    python agent.py --per 2
Then open the UI url it prints (default http://localhost:6006).

## What judges see
- every translation traced as a span (commentary in, gloss out)
- 5 metric scores attached to each span as evaluations
- overall_avg column to sort worst-first
- filter by language, click a low scorer, read the judge's failure note

## How it's wired (so you can explain it)
- observability.py: all Phoenix logic, no-ops if USE_PHOENIX!=1 or not installed
- agent.py: wraps the translate+judge cycle in obs.case_span(), calls
  obs.record() per case and obs.flush() at the end
- your judge.py / metrics.py / rubric are untouched — Phoenix logs YOUR scores,
  it does not replace your judge

```

### requirements.txt

```
anthropic>=0.40.0
deepgram-sdk>=7.0.0
python-dotenv>=1.0.0
yt-dlp>=2025.1.1
opencv-python>=4.8.0
numpy>=1.24.0
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
arize-phoenix>=5.0.0
arize-phoenix-otel>=0.6.0
openinference-instrumentation-anthropic>=0.1.0
pandas>=2.0.0

```

### validation/requirements.txt

```
anthropic>=0.40.0
python-dotenv>=1.0.0
jinja2>=3.1.0

```

### validator.py

```python
def validate_gloss_output(steps):
    errors = []
    if not isinstance(steps, list):
        return False, ["Output is not a JSON array"]
    if len(steps) == 0:
        errors.append("Empty output")
    for i, step in enumerate(steps):
        if not isinstance(step, dict):
            errors.append(f"Step {i}: not an object")
            continue
        step_type = step.get("type")
        if step_type == "sign":
            if not step.get("id"):
                errors.append(f"Step {i}: sign step missing id")
        elif step_type == "fingerspell":
            if not step.get("text"):
                errors.append(f"Step {i}: fingerspell step missing text")
        else:
            errors.append(f"Step {i}: unknown type '{step_type}'")
    return len(errors) == 0, errors

```

### prompt.py

```python
def build_system_prompt():
    return """You are converting spoken transcript text into a sequence of ASL gloss steps for a sign-language interpretation system.

Each step is either:
  - {"type": "sign", "id": "<word>"}          a single common, base-form English word naming the ASL concept
  - {"type": "fingerspell", "text": "<word>"}  a word to be spelled out letter by letter

RULES:
1. Output ONLY a JSON array of steps. No prose, no explanation, no markdown fences.
2. Reorder into ASL-like structure: topic first, drop articles (the/a/an) and copulas (is/are/was), keep only content words.
3. For "sign" steps, use the single most common, simplest base-form English word for the concept (e.g. "run" not "running", "big" not "enormous") — common everyday words are far more likely to have a matching sign clip.
4. Proper nouns (names of people, places, brands, acronyms) are ALWAYS "fingerspell", never "sign".
5. Small numbers (under twenty) as the spelled-out word ("three"); for larger numbers, or any concept you're unsure has a sign, prefer "fingerspell".
6. Keep output to 3-6 steps per utterance.

EXAMPLE:
Input: "The teacher helps the child learn something new"
Output: [{"type":"sign","id":"teach"},{"type":"sign","id":"child"},{"type":"sign","id":"learn"},{"type":"sign","id":"new"}]
"""

```

### gloss_pipeline.py

```python
import json
import time
from dotenv import load_dotenv
from anthropic import Anthropic
from prompt import build_system_prompt
from validator import validate_gloss_output
import wlasl_lookup

load_dotenv()

_client = Anthropic()
_system_prompt = build_system_prompt()

MAX_RETRIES = 1


def _resolve_against_dictionary(steps):
    """Demote any 'sign' step with no matching WLASL clip to fingerspelling,
    so the returned steps accurately reflect what can actually be played."""
    resolved = []
    for step in steps:
        if step["type"] == "sign" and wlasl_lookup.find(step["id"]) is None:
            resolved.append({"type": "fingerspell", "text": step["id"]})
        else:
            resolved.append(step)
    return resolved


def process_transcript(text: str, context: str = None):
    if not text or not text.strip():
        return None

    user_message = f'Transcript: "{text.strip()}"'
    if context:
        user_message += f"\nContext: {context}"

    for attempt in range(MAX_RETRIES + 1):
        try:
            t0 = time.perf_counter()
            response = _client.messages.create(
                model="claude-sonnet-4-6",
                max_tokens=300,
                system=_system_prompt,
                messages=[{"role": "user", "content": user_message}],
            )
            latency_ms = (time.perf_counter() - t0) * 1000
            print(f"[gloss_pipeline] latency: {latency_ms:.0f}ms (attempt {attempt})")
            raw_text = response.content[0].text.strip()
            steps = json.loads(raw_text)
        except (json.JSONDecodeError, IndexError, KeyError) as e:
            print(f"[gloss_pipeline] parse error on attempt {attempt}: {e}")
            continue

        is_valid, errors = validate_gloss_output(steps)
        if is_valid:
            return _resolve_against_dictionary(steps)
        else:
            print(f"[gloss_pipeline] invalid output on attempt {attempt}: {errors}")

    print(f"[gloss_pipeline] giving up on: {text!r}")
    return None

```

### wlasl_lookup.py

```python
"""
Looks up a gloss word directly against the WLASL dataset on disk —
no pre-curation step, no renamed copies. Point it at the dataset
once (WLASL_DIR) and call find(gloss) per word, live.
"""

import json
import os

WLASL_DIR = os.getenv("WLASL_DIR", r"C:\Users\hp\Downloads\wlasl-processed")

_gloss_map = None   # {gloss: [video_id, ...]}
_videos_dir = None


def _load_index():
    json_path = None
    for root, _, files in os.walk(WLASL_DIR):
        for f in files:
            if f.lower().startswith("wlasl") and f.lower().endswith(".json"):
                json_path = os.path.join(root, f)
                break
        if json_path:
            break
    if not json_path:
        raise RuntimeError(f"Could not find WLASL_*.json under {WLASL_DIR}")

    with open(json_path) as fh:
        data = json.load(fh)

    gloss_map = {}
    for entry in data:
        gloss = entry["gloss"].lower().strip()
        gloss_map[gloss] = [inst["video_id"] for inst in entry.get("instances", [])]
    return gloss_map


def _find_videos_dir():
    best, best_count = None, 0
    for root, _, files in os.walk(WLASL_DIR):
        mp4s = sum(1 for f in files if f.endswith(".mp4"))
        if mp4s > best_count:
            best, best_count = root, mp4s
    if not best:
        raise RuntimeError(f"No .mp4 files found under {WLASL_DIR}")
    return best


def _ensure_loaded():
    global _gloss_map, _videos_dir
    if _gloss_map is None:
        _gloss_map = _load_index()
        _videos_dir = _find_videos_dir()


def find(gloss: str) -> str | None:
    """Returns the absolute path to a video for this gloss, or None if no match."""
    _ensure_loaded()
    g = gloss.lower().strip()
    for video_id in _gloss_map.get(g, []):
        candidate = os.path.join(_videos_dir, f"{video_id}.mp4")
        if os.path.exists(candidate):
            return candidate
    return None


def videos_dir() -> str:
    """Returns the folder containing the WLASL .mp4 files (for HTTP serving)."""
    _ensure_loaded()
    return _videos_dir

```

### sign_window.py

```python
"""
Small native window that plays ASL sign clips back-to-back as words are
enqueued — no server, no browser. Looks each word up live via
wlasl_lookup.find(); words with no match get a brief text placeholder
instead of a clip.
"""

import queue
import threading
import time

import cv2

import wlasl_lookup

WINDOW_NAME = "ASL Sign Player"
PLACEHOLDER_MS = 600
PLACEHOLDER_SIZE = (480, 360)


class SignWindow:
    def __init__(self):
        self._queue = queue.Queue()
        self._stop = threading.Event()
        self._thread = None

    def start(self):
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()
        return self

    def enqueue(self, text: str):
        """Split on whitespace and queue each word for playback, in order."""
        for word in str(text).split():
            self._queue.put(word)

    def wait_until_idle(self, timeout=None):
        """Blocks until every enqueued word has finished playing."""
        start = time.monotonic()
        while not self._queue.empty():
            if timeout is not None and time.monotonic() - start > timeout:
                return False
            time.sleep(0.1)
        return True

    def stop(self):
        self._stop.set()
        if self._thread:
            self._thread.join(timeout=5)

    def _run(self):
        cv2.namedWindow(WINDOW_NAME, cv2.WINDOW_NORMAL)
        cv2.resizeWindow(WINDOW_NAME, *PLACEHOLDER_SIZE)
        try:
            while not self._stop.is_set():
                try:
                    word = self._queue.get(timeout=0.1)
                except queue.Empty:
                    cv2.waitKey(1)
                    continue

                path = wlasl_lookup.find(word)
                if path:
                    self._play_clip(path, word)
                else:
                    self._show_placeholder(word)
        finally:
            cv2.destroyWindow(WINDOW_NAME)

    def _play_clip(self, path, label):
        cap = cv2.VideoCapture(path)
        fps = cap.get(cv2.CAP_PROP_FPS) or 25
        delay_ms = max(1, int(1000 / fps))
        while not self._stop.is_set():
            ok, frame = cap.read()
            if not ok:
                break
            cv2.putText(frame, label.upper(), (12, 28), cv2.FONT_HERSHEY_SIMPLEX,
                        0.8, (79, 195, 161), 2, cv2.LINE_AA)
            cv2.imshow(WINDOW_NAME, frame)
            cv2.waitKey(delay_ms)
        cap.release()

    def _show_placeholder(self, word):
        import numpy as np
        frame = np.zeros((PLACEHOLDER_SIZE[1], PLACEHOLDER_SIZE[0], 3), dtype="uint8")
        cv2.putText(frame, word.upper(), (16, PLACEHOLDER_SIZE[1] // 2), cv2.FONT_HERSHEY_SIMPLEX,
                    1.0, (224, 164, 88), 2, cv2.LINE_AA)
        cv2.putText(frame, "(no clip - fingerspell)", (16, PLACEHOLDER_SIZE[1] // 2 + 32),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.5, (139, 152, 168), 1, cv2.LINE_AA)
        cv2.imshow(WINDOW_NAME, frame)
        cv2.waitKey(PLACEHOLDER_MS)


if __name__ == "__main__":
    import sys

    win = SignWindow().start()
    if sys.argv[1:2] == ["--file"]:
        with open(sys.argv[2]) as fh:
            text = " ".join(line.strip() for line in fh if line.strip())
    else:
        text = " ".join(sys.argv[1:]) or "FOOTBALL TEAM PLAY GOAL WIN"
    win.enqueue(text)
    win.wait_until_idle()
    win.stop()

```

### stream_resolver.py

```python
"""
Resolves a YouTube (or other yt-dlp-supported) URL into something ffmpeg can
consume — with TWO modes:

  resolve_stream_url(url)                -> streams the remote URL (fast start,
                                            but TRUNCATES the first ~1-4 words
                                            because ffmpeg connects mid-stream)

  resolve_stream_url(url, download=True) -> downloads audio to a local file
                                            first, then returns that path. NO
                                            truncation — every word from t=0.
                                            Best for demos and uploaded videos.
"""

import subprocess
import json
import shutil
import os
import tempfile


def is_web_url(source: str) -> bool:
    s = source.lower()
    if s.startswith(("http://", "https://")):
        if s.split("?")[0].endswith((".m3u8", ".mp3", ".mp4", ".aac", ".wav", ".ts")):
            return False
        return True
    return False


def _get_info(source: str) -> dict:
    meta = subprocess.run(
        ["yt-dlp", "-q", "--no-warnings", "--dump-json",
         "-f", "bestaudio/best", source],
        capture_output=True, text=True,
    )
    if meta.returncode != 0:
        raise RuntimeError(f"yt-dlp could not read the URL:\n{meta.stderr.strip()}")
    return json.loads(meta.stdout.splitlines()[0])


def resolve_stream_url(source: str, download: bool = False, dest_dir: str = None):
    """
    Returns (path_or_url, info).
      download=False : remote stream URL (fast, truncates start)
      download=True  : downloads audio locally first (no truncation)
    Non-web sources are returned unchanged.
    """
    if not is_web_url(source):
        return source, {"is_live": False, "title": source, "mode": "local"}

    if shutil.which("yt-dlp") is None:
        raise RuntimeError("yt-dlp is not installed. Install with: pip install yt-dlp")

    info = _get_info(source)
    is_live = bool(info.get("is_live"))

    if is_live and download:
        print("⚠ source is LIVE — download mode ignored; streaming from now.")
        download = False

    if download:
        dest_dir = dest_dir or tempfile.gettempdir()
        out_tmpl = os.path.join(dest_dir, "signcast_%(id)s.%(ext)s")
        dl = subprocess.run(
            ["yt-dlp", "-q", "--no-warnings",
             "-f", "bestaudio/best",
             "-o", out_tmpl,
             "--print", "after_move:filepath",
             source],
            capture_output=True, text=True,
        )
        if dl.returncode != 0 or not dl.stdout.strip():
            raise RuntimeError(f"yt-dlp download failed:\n{dl.stderr.strip()}")
        local_path = dl.stdout.strip().splitlines()[-1]
        return local_path, {
            "is_live": False,
            "title": info.get("title", "unknown"),
            "ext": info.get("ext"),
            "mode": "downloaded",
            "path": local_path,
        }

    got = subprocess.run(
        ["yt-dlp", "-q", "--no-warnings",
         "-f", "bestaudio/best", "--get-url", source],
        capture_output=True, text=True,
    )
    if got.returncode != 0 or not got.stdout.strip():
        raise RuntimeError(f"yt-dlp could not get a stream URL:\n{got.stderr.strip()}")

    return got.stdout.strip().splitlines()[0], {
        "is_live": is_live,
        "title": info.get("title", "unknown"),
        "ext": info.get("ext"),
        "mode": "streamed",
    }
```

[18 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]