diff --git a/examples/220-django-channels-live-stt-python/.env.example b/examples/220-django-channels-live-stt-python/.env.example new file mode 100644 index 0000000..99314a3 --- /dev/null +++ b/examples/220-django-channels-live-stt-python/.env.example @@ -0,0 +1,2 @@ +# Deepgram — https://console.deepgram.com/ +DEEPGRAM_API_KEY= diff --git a/examples/220-django-channels-live-stt-python/README.md b/examples/220-django-channels-live-stt-python/README.md new file mode 100644 index 0000000..58d1554 --- /dev/null +++ b/examples/220-django-channels-live-stt-python/README.md @@ -0,0 +1,86 @@ +# Django Channels Real-Time Transcription with Deepgram Live STT + +Build a Django 5 application that captures browser microphone audio and streams it through Django Channels WebSockets to Deepgram's Live STT API, displaying transcription results on the page in real-time. The Deepgram API key stays server-side — the browser never sees it. + +## What you'll build + +A Django web application that uses Django Channels to handle WebSocket connections from the browser. When a user clicks "Start Listening", the page captures microphone audio, streams it over a WebSocket to a Django Channels consumer, which forwards it to Deepgram's Live STT API (Nova-3). Transcription results flow back through the same WebSocket and appear on the page instantly. + +## Prerequisites + +- Python 3.11+ +- Deepgram account — [get a free API key](https://console.deepgram.com/) + +## Environment variables + +| Variable | Where to find it | +|----------|-----------------| +| `DEEPGRAM_API_KEY` | [Deepgram console](https://console.deepgram.com/) | + +Copy `.env.example` to `.env` and fill in your values. + +## Install and run + +```bash +cd examples/220-django-channels-live-stt-python + +pip install -r requirements.txt + +cp .env.example .env +# Edit .env and add your DEEPGRAM_API_KEY + +python src/manage.py runserver +``` + +Then open http://127.0.0.1:8000 in your browser and click **Start Listening**. + +## Key parameters + +| Parameter | Value | Description | +|-----------|-------|-------------| +| `model` | `nova-3` | Deepgram's latest and most accurate speech recognition model | +| `smart_format` | `True` | Adds punctuation, capitalization, and number formatting | +| `interim_results` | `True` | Returns partial transcripts while you're still speaking | +| `encoding` | `linear16` | Raw 16-bit PCM audio format from the browser | +| `sample_rate` | `16000` | 16 kHz sample rate — good balance of quality and bandwidth | + +## How it works + +1. **Browser** — the HTML page uses `getUserMedia` to capture microphone audio, resamples it to 16 kHz linear16 PCM via a `ScriptProcessorNode`, and sends binary frames over a WebSocket to `/ws/transcribe/` +2. **Django Channels consumer** (`consumer.py`) — an `AsyncWebsocketConsumer` that on connect opens a Deepgram live STT WebSocket using the official Python SDK (`client.listen.v1.connect()`) +3. **Audio forwarding** — each binary WebSocket frame from the browser is forwarded to Deepgram via `connection.send_media()` +4. **Transcript delivery** — Deepgram fires `EventType.MESSAGE` callbacks with `ListenV1Results`; the consumer sends each transcript back to the browser as JSON with `is_final` indicating whether the result is finalized +5. **Browser display** — interim results appear greyed out and get replaced; final results are appended permanently + +## Architecture + +``` +Browser Microphone + | + | WebSocket (binary PCM audio) + v +Django Channels Consumer + | + | Deepgram Python SDK (WebSocket) + v +Deepgram Live STT (nova-3) + | + | transcript JSON + v +Django Channels Consumer + | + | WebSocket (JSON) + v +Browser Display +``` + +## Related + +- [Deepgram Live STT docs](https://developers.deepgram.com/docs/getting-started-with-live-streaming-audio) +- [Deepgram Python SDK](https://github.com/deepgram/deepgram-python-sdk) +- [Django Channels documentation](https://channels.readthedocs.io/) +- [Daphne ASGI server](https://github.com/django/daphne) + +## Starter templates + +If you want a ready-to-run base for your own project, check the [deepgram-starters](https://github.com/orgs/deepgram-starters/repositories) org — there are starter repos for every language and every Deepgram product. diff --git a/examples/220-django-channels-live-stt-python/requirements.txt b/examples/220-django-channels-live-stt-python/requirements.txt new file mode 100644 index 0000000..a47930e --- /dev/null +++ b/examples/220-django-channels-live-stt-python/requirements.txt @@ -0,0 +1,5 @@ +django>=5.0,<6.0 +channels>=4.0,<5.0 +daphne>=4.0,<5.0 +deepgram-sdk>=4.0.0 +python-dotenv>=1.0.0 diff --git a/examples/220-django-channels-live-stt-python/src/asgi.py b/examples/220-django-channels-live-stt-python/src/asgi.py new file mode 100644 index 0000000..8e93ab8 --- /dev/null +++ b/examples/220-django-channels-live-stt-python/src/asgi.py @@ -0,0 +1,16 @@ +import os + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") + +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter +from django.core.asgi import get_asgi_application + +import urls + +application = ProtocolTypeRouter( + { + "http": get_asgi_application(), + "websocket": AuthMiddlewareStack(URLRouter(urls.websocket_urlpatterns)), + } +) diff --git a/examples/220-django-channels-live-stt-python/src/consumer.py b/examples/220-django-channels-live-stt-python/src/consumer.py new file mode 100644 index 0000000..6bdfa7a --- /dev/null +++ b/examples/220-django-channels-live-stt-python/src/consumer.py @@ -0,0 +1,75 @@ +"""Django Channels WebSocket consumer that bridges browser audio to Deepgram Live STT. + +Audio flows: browser microphone -> Django Channels WebSocket -> Deepgram Live STT -> transcript back to browser. +The DEEPGRAM_API_KEY stays server-side — the browser never sees it. +""" + +import asyncio +import json +import os + +from channels.generic.websocket import AsyncWebsocketConsumer +from deepgram import AsyncDeepgramClient +from deepgram.core.events import EventType +from deepgram.listen.v1.types import ListenV1Results + + +class TranscriptionConsumer(AsyncWebsocketConsumer): + """Receives raw audio bytes from the browser, streams them to Deepgram, and + sends transcription results back as JSON messages.""" + + async def connect(self): + await self.accept() + self._dg_client = AsyncDeepgramClient( + api_key=os.environ["DEEPGRAM_API_KEY"] + ) + # ← connect() returns a live WebSocket connection to Deepgram's STT API + self._dg_connection = await self._dg_client.listen.v1.connect( + model="nova-3", + smart_format=True, + interim_results=True, + encoding="linear16", + sample_rate=16000, + channels=1, + ) + + async def on_message(message) -> None: + if isinstance(message, ListenV1Results): + # message.channel.alternatives[0].transcript — the transcribed text + transcript = message.channel.alternatives[0].transcript + if transcript.strip(): + await self.send( + text_data=json.dumps( + { + "transcript": transcript, + "is_final": message.is_final, + } + ) + ) + + async def on_error(error) -> None: + await self.send( + text_data=json.dumps({"error": str(error)}) + ) + + self._dg_connection.on(EventType.MESSAGE, on_message) + self._dg_connection.on(EventType.ERROR, on_error) + + # Runs the Deepgram receive loop in the background so events dispatch + self._listener_task = asyncio.create_task( + self._dg_connection.start_listening() + ) + + async def disconnect(self, close_code): + if hasattr(self, "_dg_connection"): + try: + await self._dg_connection.send_close_stream() + except Exception: + pass + if hasattr(self, "_listener_task"): + self._listener_task.cancel() + + async def receive(self, text_data=None, bytes_data=None): + # Browser sends raw PCM audio as binary WebSocket frames + if bytes_data: + await self._dg_connection.send_media(bytes_data) diff --git a/examples/220-django-channels-live-stt-python/src/manage.py b/examples/220-django-channels-live-stt-python/src/manage.py new file mode 100644 index 0000000..9a584c9 --- /dev/null +++ b/examples/220-django-channels-live-stt-python/src/manage.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +import os +import sys + +from dotenv import load_dotenv + +load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env")) + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/examples/220-django-channels-live-stt-python/src/settings.py b/examples/220-django-channels-live-stt-python/src/settings.py new file mode 100644 index 0000000..fa32e36 --- /dev/null +++ b/examples/220-django-channels-live-stt-python/src/settings.py @@ -0,0 +1,41 @@ +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent + +SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-insecure-key") + +DEBUG = True + +ALLOWED_HOSTS = ["*"] + +INSTALLED_APPS = [ + "daphne", + "django.contrib.staticfiles", +] + +ROOT_URLCONF = "urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": False, + "OPTIONS": { + "context_processors": [], + }, + }, +] + +ASGI_APPLICATION = "asgi.application" + +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels.layers.InMemoryChannelLayer", + }, +} + +STATIC_URL = "static/" +STATICFILES_DIRS = [] + +DATABASES = {} diff --git a/examples/220-django-channels-live-stt-python/src/templates/index.html b/examples/220-django-channels-live-stt-python/src/templates/index.html new file mode 100644 index 0000000..b95c50f --- /dev/null +++ b/examples/220-django-channels-live-stt-python/src/templates/index.html @@ -0,0 +1,133 @@ + + + + + + Deepgram Live Transcription — Django Channels + + + +

Deepgram Live Transcription

+

Powered by Django Channels + Deepgram Nova-3

+ +
+ + + +
+ +
+ + + + diff --git a/examples/220-django-channels-live-stt-python/src/urls.py b/examples/220-django-channels-live-stt-python/src/urls.py new file mode 100644 index 0000000..616c445 --- /dev/null +++ b/examples/220-django-channels-live-stt-python/src/urls.py @@ -0,0 +1,12 @@ +from django.urls import path, re_path + +from views import index +from consumer import TranscriptionConsumer + +urlpatterns = [ + path("", index), +] + +websocket_urlpatterns = [ + re_path(r"ws/transcribe/$", TranscriptionConsumer.as_asgi()), +] diff --git a/examples/220-django-channels-live-stt-python/src/views.py b/examples/220-django-channels-live-stt-python/src/views.py new file mode 100644 index 0000000..33e346c --- /dev/null +++ b/examples/220-django-channels-live-stt-python/src/views.py @@ -0,0 +1,5 @@ +from django.shortcuts import render + + +def index(request): + return render(request, "index.html") diff --git a/examples/220-django-channels-live-stt-python/tests/test_example.py b/examples/220-django-channels-live-stt-python/tests/test_example.py new file mode 100644 index 0000000..4ce40fa --- /dev/null +++ b/examples/220-django-channels-live-stt-python/tests/test_example.py @@ -0,0 +1,97 @@ +import os +import sys +from pathlib import Path + +# ── Credential check ──────────────────────────────────────────────────────── +# Exit code convention across all examples in this repo: +# 0 = all tests passed +# 1 = real test failure (code bug, assertion error, unexpected API response) +# 2 = missing credentials (expected in CI until secrets are configured) +env_example = Path(__file__).parent.parent / ".env.example" +required = [ + line.split("=")[0].strip() + for line in env_example.read_text().splitlines() + if line and not line.startswith("#") and "=" in line and line[0].isupper() +] +missing = [k for k in required if not os.environ.get(k)] +if missing: + print(f"MISSING_CREDENTIALS: {','.join(missing)}", file=sys.stderr) + sys.exit(2) +# ──────────────────────────────────────────────────────────────────────────── + +from deepgram import DeepgramClient + + +def test_deepgram_stt(): + """Verify the Deepgram API key works and nova-3 returns a transcript.""" + client = DeepgramClient() + response = client.listen.v1.media.transcribe_url( + url="https://dpgr.am/spacewalk.wav", + model="nova-3", + smart_format=True, + ) + transcript = response.results.channels[0].alternatives[0].transcript + assert len(transcript) > 10, "Transcript too short" + + lower = transcript.lower() + expected = ["spacewalk", "astronaut", "nasa"] + found = [w for w in expected if w in lower] + assert len(found) > 0, f"Expected keywords not found in: {transcript[:200]}" + + print(" Deepgram STT integration working") + print(f" Transcript preview: '{transcript[:80]}...'") + + +def test_django_imports(): + """Verify Django and Channels are importable and configured.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") + sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + + import django + + django.setup() + + from django.conf import settings + + assert settings.ASGI_APPLICATION == "asgi.application" + assert "daphne" in settings.INSTALLED_APPS + + print(" Django settings configured correctly") + + +def test_consumer_imports(): + """Verify the transcription consumer is importable.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") + sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + + import django + + django.setup() + + from consumer import TranscriptionConsumer + + assert TranscriptionConsumer is not None + assert hasattr(TranscriptionConsumer, "connect") + assert hasattr(TranscriptionConsumer, "disconnect") + assert hasattr(TranscriptionConsumer, "receive") + + print(" TranscriptionConsumer imports correctly") + + +def test_template_exists(): + """Verify the HTML template is present.""" + template = Path(__file__).parent.parent / "src" / "templates" / "index.html" + assert template.exists(), "index.html template missing" + content = template.read_text() + assert "getUserMedia" in content, "Template should use getUserMedia for microphone" + assert "ws/transcribe" in content, "Template should connect to ws/transcribe endpoint" + + print(" Template exists and contains expected content") + + +if __name__ == "__main__": + test_deepgram_stt() + test_django_imports() + test_consumer_imports() + test_template_exists() + print("\nAll tests passed")