Skip to content
Open
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
17 changes: 17 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
EXTENSION_DIR := dna-chrome-extension
EXTENSION_VERSION := $(shell python3 -c "import json; print(json.load(open('$(EXTENSION_DIR)/manifest.json'))['version'])")
EXTENSION_ZIP := dist/dna-chrome-extension-$(EXTENSION_VERSION).zip

.PHONY: package-extension

# Zip the Chrome extension for load-unpacked testing or Chrome Web Store upload.
# manifest.json ends up at the archive root (required by Chrome).
package-extension:
mkdir -p dist
rm -f $(EXTENSION_ZIP)
cd $(EXTENSION_DIR) && zip -qr ../$(EXTENSION_ZIP) . \
-x 'README.md' \
-x 'package-lock.json' \
-x '.DS_Store' \
-x '__MACOSX/*'
@echo "Packaged $(EXTENSION_ZIP)"
32 changes: 32 additions & 0 deletions backend/docker-compose.whisperlive.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Optional WhisperLive service for the browser-extension transcription route.
#
# Included automatically by `make start-local-extension` (not the Vexa stack).
#
# Builds a CPU-only Collabora WhisperLive image locally so Apple Silicon Macs
# work without pulling the amd64-only ghcr.io/collabora/whisperlive-cpu image
# or the broken hwdsl2 arm64 CUDA bundle.
#
# To use the pre-built Collabora image on x86_64 Linux instead:
# WHISPERLIVE_IMAGE=ghcr.io/collabora/whisperlive-cpu:latest docker compose ...
#
# To start WhisperLive alone:
#
# docker compose -f docker-compose.yml -f docker-compose.whisperlive.yml up whisperlive
#
# The DNA browser extension connects to WhisperLive directly from the browser
# (ws://localhost:9090), so only the port needs to be reachable from the host.
services:
whisperlive:
image: ${WHISPERLIVE_IMAGE:-dna-whisperlive:local}
build:
context: ./docker/whisperlive
dockerfile: Dockerfile
container_name: dna-whisperlive
ports:
- "9090:9090"
volumes:
- whisperlive-cache:/root/.cache/whisper-live
restart: unless-stopped

volumes:
whisperlive-cache:
31 changes: 31 additions & 0 deletions backend/docker/whisperlive/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# CPU-only WhisperLive for browser-extension transcription.
#
# ghcr.io/collabora/whisperlive-cpu is amd64-only; hwdsl2/whisper-live-server
# arm64 images crash loading CUDA libraries on Apple Silicon. This follows
# Collabora's docker/Dockerfile.cpu and builds natively on arm64 and amd64.

FROM python:3.10-bookworm

ARG DEBIAN_FRONTEND=noninteractive

RUN apt-get update && apt-get install -y --no-install-recommends \
portaudio19-dev \
git \
&& apt-get clean && rm -rf /var/lib/apt/lists/*

RUN pip install --no-cache-dir -U "pip>=24"

WORKDIR /app

RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu

ARG WHISPERLIVE_REF=main
RUN git clone --depth 1 --branch "${WHISPERLIVE_REF}" https://github.com/collabora/WhisperLive.git /tmp/whisperlive \
&& cp -r /tmp/whisperlive/whisper_live /app/whisper_live \
&& cp /tmp/whisperlive/run_server.py /app/run_server.py \
&& pip install --no-cache-dir -r /tmp/whisperlive/requirements/server.txt \
&& rm -rf /tmp/whisperlive

EXPOSE 9090

CMD ["python", "run_server.py", "--port", "9090", "--backend", "faster_whisper"]
89 changes: 89 additions & 0 deletions backend/docs/TRANSCRIPTION_PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1432,3 +1432,92 @@ endpoint, not in the ingest pipeline.
become authoritative). Keeping the builder isolated means that change
is one file here rather than a re-ingest.
- The builder is pure and trivially testable, unlike the ingest loop.

---

## Browser Extension Transcription Route (Alternative to Vexa)

An alternative to the Vexa bot: instead of dispatching a server-side bot, the
**DNA browser extension** captures Google Meet **tab audio** in the user's own
browser, transcribes it via a **WhisperLive** service, and streams segments
directly into DNA. This avoids bot authentication and the Vexa infrastructure.

Both routes coexist and reuse the same storage + `/ws` broadcast, so the
frontend `TranscriptManager` consumes identical `{type:"transcript", ...}`
frames regardless of source.

### Data flow

```mermaid
sequenceDiagram
participant FE as DNA Frontend
participant EXT as Extension (SW + offscreen + content)
participant MEET as Google Meet Tab
participant WL as WhisperLive
participant BE as DNA Backend
FE->>EXT: PING_TRANSCRIPTION + ACTIVATE_TRANSCRIPTION {dnaApiUrl, dnaIngestWsUrl, whisperLiveUrl, playlistId, token}
EXT-->>FE: ACK + status
EXT->>MEET: tabCapture audio + scrape active speaker (DOM)
EXT->>WL: audio chunks (WS), receive partial/final results
EXT->>BE: WS ingest {type:"transcript", confirmed, pending, speaker, playlist_id, ts} + token
BE->>BE: ingest_extension_transcript -> upsert confirmed by in_review version (resolved server-side)
BE-->>FE: /ws broadcast {type:"transcript", ...}
```

### Handshake points (each observable in the extension Debug log)

1. **FE ↔ EXT**: `PING_TRANSCRIPTION`/pong, `ACTIVATE_TRANSCRIPTION`/ack.
2. **EXT ↔ Meet tab**: `getUserMedia` tab-capture permission granted.
3. **EXT ↔ WhisperLive**: WS open + `SERVER_READY`.
4. **EXT ↔ DNA**: WS connect + `connected` ack + per-frame `ack`.

The toolbar status dot derives from the weakest link: **red** (no server info),
**orange/blinking** (connecting or needs Meet permission), **green** (all hops
healthy and streaming).

### Backend components

| Component | File | Role |
|-----------|------|------|
| `ExtensionTranscriptionProvider` | `transcription_providers/extension.py` | No-op bot lifecycle; selected via `TRANSCRIPTION_PROVIDER=browser_extension` (alias `extension`). |
| `ingest_extension_transcript()` | `transcription_service.py` | Keys by `playlist_id` directly (extension already knows it), then shares `_store_and_broadcast` with the Vexa path. |
| `GET /transcription/extension/health` | `main.py` | Handshake/availability probe. 404 when disabled. |
| `WS /transcription/extension/ingest` | `main.py` | Inbound transcript stream. Auth via `?token=` (bearer-equivalent), acks every frame. |

### Configuration

**Backend**

| Variable | Default | Description |
|----------|---------|-------------|
| `DNA_ENABLE_EXTENSION_TRANSCRIPTION` | `false` | Gates the ingest WS + health endpoint. |
| `TRANSCRIPTION_PROVIDER` | `vexa` | Set to `browser_extension` to use the extension provider for bot endpoints. |

**Frontend**

| Variable | Description |
|----------|-------------|
| `VITE_TRANSCRIPTION_EXTENSION_ID` | Chrome extension ID (enables the extension UI). |
| `VITE_TRANSCRIPTION_EXTENSION_INSTALL_URL` | Install link shown when the extension is not detected. |
| `VITE_WHISPERLIVE_URL` | WhisperLive WS URL the extension streams audio to (e.g. `ws://localhost:9090`). |
| `VITE_FEATURE_EXTENSION_TRANSCRIPTION` | Optional lock for the extension UI. |

**WhisperLive** runs as an optional overlay compose service (not in the base
stack). DNA builds a CPU-only Collabora WhisperLive image locally
(`backend/docker/whisperlive/Dockerfile`) so Apple Silicon Macs work; the
official Collabora registry image is amd64-only.

```bash
docker compose -f docker-compose.yml -f docker-compose.whisperlive.yml up whisperlive
```

### Verification

1. Enable `DNA_ENABLE_EXTENSION_TRANSCRIPTION=true`; confirm `GET
/transcription/extension/health` returns `{"status":"ok"}` (404 when off).
2. Push a frame with a WS client to `/transcription/extension/ingest?token=...`:
`{"type":"transcript","playlist_id":<id>,"confirmed":[<segment>],"pending":[]}`
→ segment appears in Mongo and on `/ws`.
3. Load the extension, click **Transcribe via Extension** in DNA, grant the Meet
tab permission from the popup, and watch the four handshakes turn the dot
green while live transcripts appear in the DNA `TranscriptPanel`.
5 changes: 5 additions & 0 deletions backend/example.docker-compose.local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ services:
# for the ShotGrid site-setup checklist the custom entity depends on.
- DNA_ENABLE_TRANSCRIPT_PUBLISH=false
- SHOTGRID_TRANSCRIPT_ENTITY=CustomEntity01
# Browser-extension transcription route (opt-in). Choosing option 2 under
# "Transcription route" in ./bootstrap.sh enables it and appends a randomly
# generated DNA_EXTENSION_KEY here. That key must match
# VITE_TRANSCRIPTION_EXTENSION_KEY in the frontend and the key entered once
# in the extension popup; leave it unset to disable the key gate.
87 changes: 69 additions & 18 deletions backend/makefile
Original file line number Diff line number Diff line change
@@ -1,32 +1,83 @@
DOCKER_COMPOSE := $(shell command -v docker-compose 2>/dev/null || echo "docker compose")

COMPOSE_LOCAL := -f docker-compose.yml -f docker-compose.vexa.yml -f docker-compose.debug.yml -f docker-compose.local.yml -f docker-compose.local.vexa.yml
# Vexa stack — server-side bot transcription (pulls Vexa images).
COMPOSE_LOCAL_VEXA := -f docker-compose.yml -f docker-compose.vexa.yml -f docker-compose.debug.yml -f docker-compose.local.yml -f docker-compose.local.vexa.yml

# If start-local fails with: blob sha256:... input/output error — Docker’s local
# Extension stack — browser extension + WhisperLive (no Vexa images).
COMPOSE_LOCAL_EXTENSION := -f docker-compose.yml -f docker-compose.debug.yml -f docker-compose.local.yml -f docker-compose.whisperlive.yml

# Backward-compatible alias for the Vexa stack.
COMPOSE_LOCAL := $(COMPOSE_LOCAL_VEXA)

# If start-local fails with: blob sha256:... input/output error — Docker's local
# image store is damaged. Run: make docker-fix-api-image, restart Docker Desktop,
# then make start-local. If it still fails: Docker → Troubleshoot → Clean/Purge
# then make start-local-vexa. If it still fails: Docker → Troubleshoot → Clean/Purge
# data, or check disk space.

docker-fix-api-image:
-$(DOCKER_COMPOSE) $(COMPOSE_LOCAL) down
-docker rmi backend-api 2>/dev/null || true
@echo "Removed backend-api if present. Restart Docker Desktop, then: make start-local"
.PHONY: start-local-vexa start-local-vexa-detached stop-local-vexa logs-local-vexa restart-local-vexa build-vexa \
start-local-extension start-local-extension-detached stop-local-extension logs-local-extension restart-local-extension build-extension \
start-local stop-local logs-local restart-local build docker-fix-api-image shell test test-cov test-cov-html venv-lint format-python seed-mock-db inject-transcript

# ── Vexa stack ────────────────────────────────────────────────────────────────

start-local-vexa:
$(DOCKER_COMPOSE) $(COMPOSE_LOCAL_VEXA) up --build

build:
start-local-vexa-detached:
$(DOCKER_COMPOSE) $(COMPOSE_LOCAL_VEXA) up --build -d --force-recreate --remove-orphans

stop-local-vexa:
$(DOCKER_COMPOSE) $(COMPOSE_LOCAL_VEXA) down

logs-local-vexa:
$(DOCKER_COMPOSE) $(COMPOSE_LOCAL_VEXA) logs -f

restart-local-vexa:
$(MAKE) stop-local-vexa
$(MAKE) start-local-vexa

build-vexa:
$(DOCKER_COMPOSE) -f docker-compose.yml -f docker-compose.vexa.yml -f docker-compose.local.yml -f docker-compose.local.vexa.yml build --no-cache

start-local:
$(DOCKER_COMPOSE) $(COMPOSE_LOCAL) up --build
# ── Extension stack (no Vexa) ─────────────────────────────────────────────────

start-local-extension:
$(DOCKER_COMPOSE) $(COMPOSE_LOCAL_EXTENSION) up --build

start-local-extension-detached:
$(DOCKER_COMPOSE) $(COMPOSE_LOCAL_EXTENSION) up --build -d --force-recreate --remove-orphans

stop-local:
$(DOCKER_COMPOSE) $(COMPOSE_LOCAL) down
stop-local-extension:
$(DOCKER_COMPOSE) $(COMPOSE_LOCAL_EXTENSION) down

logs-local:
$(DOCKER_COMPOSE) $(COMPOSE_LOCAL) logs -f
logs-local-extension:
$(DOCKER_COMPOSE) $(COMPOSE_LOCAL_EXTENSION) logs -f

restart-local:
make stop-local
make start-local
restart-local-extension:
$(MAKE) stop-local-extension
$(MAKE) start-local-extension

build-extension:
$(DOCKER_COMPOSE) -f docker-compose.yml -f docker-compose.local.yml -f docker-compose.whisperlive.yml build --no-cache

# ── Backward-compatible aliases (Vexa stack) ──────────────────────────────────

start-local: start-local-vexa

stop-local: stop-local-vexa

logs-local: logs-local-vexa

restart-local: restart-local-vexa

build: build-vexa

# ── Shared / utility targets ──────────────────────────────────────────────────

docker-fix-api-image:
-$(DOCKER_COMPOSE) $(COMPOSE_LOCAL_VEXA) down
-docker rmi backend-api 2>/dev/null || true
@echo "Removed backend-api if present. Restart Docker Desktop, then: make start-local-vexa"

shell:
$(DOCKER_COMPOSE) -f docker-compose.yml -f docker-compose.local.yml run --rm api bash
Expand Down Expand Up @@ -62,4 +113,4 @@ inject-transcript:
$(DOCKER_COMPOSE) -f docker-compose.yml -f docker-compose.local.yml run --rm \
-v "$(abspath $(FILE)):/tmp/inject_input.json:ro" \
-v "$(abspath ../scripts/inject_transcript.py):/tmp/inject_transcript.py:ro" \
api python /tmp/inject_transcript.py /tmp/inject_input.json $(if $(DRYRUN),--dry-run,)
api python /tmp/inject_transcript.py /tmp/inject_input.json $(if $(DRYRUN),--dry-run,)
2 changes: 2 additions & 0 deletions backend/src/dna/transcription_providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
Provides abstraction for transcription services.
"""

from dna.transcription_providers.extension import ExtensionTranscriptionProvider
from dna.transcription_providers.transcription_provider_base import (
TranscriptionProviderBase,
get_transcription_provider,
)
from dna.transcription_providers.vexa import VexaTranscriptionProvider

__all__ = [
"ExtensionTranscriptionProvider",
"TranscriptionProviderBase",
"VexaTranscriptionProvider",
"get_transcription_provider",
Expand Down
Loading
Loading