my-sea voice Phase C: WebRTC mesh signaling app + TURN endpoint + voice-btn wiring + coturn infra — TDD

Phase C (final) of the my-sea invite → spectator → voice blueprint. Self-
hosted WebRTC mesh voice, built room-general but wired for my-sea only; epic
6-seat rooms reuse the same consumer later (key on Room.id). Media never
touches the server — only signaling is relayed. Built from the blueprint's
distilled spec (disco-voice-mesh.pdf unreadable in-env: no poppler/pypdf).

- C1: new apps/voice/ — RoomVoiceConsumer (AsyncJsonWebsocketConsumer):
  signaling-only relay (room group voice.<room_id> + per-peer peer.<uuid>;
  hello→present handshake, offer/answer/ice routed by target/source, left on
  disconnect). room_id is a STRING kwarg (mysea-<owner_id> now). _can_join
  gates: mysea → owner OR present invitee (token deposited, not left); epic
  UUID → seated gamer (later). routing.py ws/voice/<str:room_id>/; asgi.py
  aggregates epic + voice urlpatterns under AuthMiddlewareStack.
  voice-mesh.js: VoiceRoom client (getUserMedia AEC/NS/AGC, mesh
  RTCPeerConnection, newcomer-offers handshake, tuneOpus SDP munge =
  inbandfec+dtx+40kbps cap, mute via getAudioTracks().enabled), lazy-loaded.
- C2: apps/api VoiceTURNCredentialsAPI at /api/voice/turn-credentials/ —
  coturn use-auth-secret REST scheme: username=<expiry>:<user_id>,
  credential=base64(HMAC-SHA1(username, COTURN_SHARED_SECRET)) + stun/turn
  iceServers + ttl. Authenticated-only. 4 ITs (HMAC shape, auth gate).
- C3: settings COTURN_SHARED_SECRET / COTURN_TURN_HOST / COTURN_REALM /
  COTURN_TTL env block.
- C4: #id_voice_btn wiring — _burger.html renders .active + data-room-id when
  voice_active; burger-btn.js bindVoiceBtn (active click → lazy-load
  voice-mesh.js → join / toggle-mute; inactive → existing 2-pulse flash).
  my_sea (owner) + my_sea_visit (spectator) views compute voice_active
  (open 24h window) + voice_room_id=mysea-<owner_id>; spectator page now
  includes the burger. 4 voice-context ITs.
- C5: infra/coturn.conf.j2 (use-auth-secret, the external-ip footgun, relay
  port range, TLS 5349, peer-IP lockdown) + infra/coturn-playbook.yaml
  (dedicated droplet, PySwiss-style split: install coturn, template conf, ufw
  3478/5349/49152-65535, systemd enable) + [coturn] inventory placeholder.
  *** Manual ops step: provision the droplet + fill inventory before voice
  works on staging/prod; CI/local need none of it. ***
- C6: 8 channels ITs (@tag channels) — connect/auth/_can_join gate (owner,
  present invitee, stranger, not-present, anon) + hello/present handshake +
  offer routing + left-on-disconnect. Scope-injected; TransactionTestCase.
- JS: VoiceMeshSpec.js (tuneOpus) + voice-mesh.js registered in SpecRunner.

1440 IT/UT green; voice channels IT + full Jasmine + voice-btn FT green.
Voice infra is code-complete — provision the coturn droplet to go live.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-27 13:57:09 -04:00
parent d0c39b51b6
commit 41217d5438
26 changed files with 957 additions and 2 deletions

View File

5
src/apps/voice/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class VoiceConfig(AppConfig):
name = "apps.voice"

129
src/apps/voice/consumers.py Normal file
View File

@@ -0,0 +1,129 @@
"""Signaling-only relay for a self-hosted WebRTC mesh — Phase C of
[[my-sea-invite-voice-blueprint]] (per disco-voice-mesh.pdf).
Media (the actual audio) never touches the server — only SDP offers/answers
and ICE candidates are relayed. The server is room-agnostic: `room_id` is a
STRING url kwarg — `mysea-<owner_id>` for my-sea today, an epic room UUID
later (the `_can_join` gate is the only room-type-aware piece, so epic rooms
reuse this consumer unchanged).
Mesh handshake (newcomer offers to each existing peer):
client → hello (after `welcome`)
server → peer.hello broadcast → each existing peer replies `present`
newcomer ← present (per existing peer) → newcomer sends `offer` to each
offer / answer / ice are relayed point-to-point by `target` peer id,
tagged with the `source` peer id.
On disconnect the server broadcasts `left` so peers tear down their
RTCPeerConnection.
"""
import uuid as uuidlib
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
class RoomVoiceConsumer(AsyncJsonWebsocketConsumer):
async def connect(self):
self.room_id = self.scope["url_route"]["kwargs"]["room_id"]
self.user = self.scope.get("user")
self.room_group = None
self.peer_group = None
self.peer_id = None
if not (self.user and getattr(self.user, "is_authenticated", False)):
await self.close()
return
if not await self._can_join(self.user, self.room_id):
await self.close()
return
self.peer_id = uuidlib.uuid4().hex
self.room_group = f"voice.{self.room_id}"
self.peer_group = f"peer.{self.peer_id}"
await self.channel_layer.group_add(self.room_group, self.channel_name)
await self.channel_layer.group_add(self.peer_group, self.channel_name)
await self.accept()
# Hand the client its own peer id; it sends `hello` next.
await self.send_json({"type": "welcome", "peer_id": self.peer_id})
async def disconnect(self, code):
if self.room_group:
await self.channel_layer.group_send(
self.room_group, {"type": "peer.left", "source": self.peer_id},
)
await self.channel_layer.group_discard(self.room_group, self.channel_name)
if self.peer_group:
await self.channel_layer.group_discard(self.peer_group, self.channel_name)
async def receive_json(self, content):
mtype = content.get("type")
if mtype == "hello":
await self.channel_layer.group_send(
self.room_group, {"type": "peer.hello", "source": self.peer_id},
)
elif mtype in ("offer", "answer", "ice"):
target = content.get("target")
if target:
await self.channel_layer.group_send(
f"peer.{target}",
{
"type": f"relay.{mtype}",
"kind": mtype,
"source": self.peer_id,
"payload": content.get("payload"),
},
)
# ── room-group fan-out handlers ─────────────────────────────────────
async def peer_hello(self, event):
src = event.get("source")
if src == self.peer_id:
return # don't echo our own hello
# Tell the newcomer we're already present (they'll offer to us)…
await self.channel_layer.group_send(
f"peer.{src}", {"type": "peer.present", "source": self.peer_id},
)
# …and note the newcomer locally so we're ready to answer their offer.
await self.send_json({"type": "hello", "source": src})
async def peer_present(self, event):
await self.send_json({"type": "present", "source": event["source"]})
async def peer_left(self, event):
if event.get("source") == self.peer_id:
return
await self.send_json({"type": "left", "source": event["source"]})
# ── point-to-point signaling relay ──────────────────────────────────
async def relay_offer(self, event):
await self._relay(event)
async def relay_answer(self, event):
await self._relay(event)
async def relay_ice(self, event):
await self._relay(event)
async def _relay(self, event):
await self.send_json({
"type": event["kind"],
"source": event["source"],
"payload": event["payload"],
})
# ── membership gate ─────────────────────────────────────────────────
@database_sync_to_async
def _can_join(self, user, room_id):
"""`mysea-<owner_id>` → the owner OR a present invitee (token
deposited, not left). An epic room UUID → a seated gamer (wired in a
later sprint; the consumer itself needs no change)."""
if room_id.startswith("mysea-"):
owner_id = room_id[len("mysea-"):]
if str(user.id) == owner_id:
return True
from apps.gameboard.models import SeaInvite
return SeaInvite.objects.filter(
owner_id=owner_id, invitee=user, status=SeaInvite.ACCEPTED,
token_deposited_at__isnull=False, left_at__isnull=True,
).exists()
from apps.epic.models import TableSeat
return TableSeat.objects.filter(room_id=room_id, gamer=user).exists()

10
src/apps/voice/routing.py Normal file
View File

@@ -0,0 +1,10 @@
from django.urls import path
from . import consumers
# room_id is a free-form string (`mysea-<owner_id>` now, room UUID later), so
# a <str:…> converter — NOT <uuid:…> — keeps both keying schemes on one route.
websocket_urlpatterns = [
path("ws/voice/<str:room_id>/", consumers.RoomVoiceConsumer.as_asgi()),
]

View File

@@ -0,0 +1,190 @@
// voice-mesh.js — self-hosted WebRTC mesh voice client (Phase C of the my-sea
// invite/voice sprint, per disco-voice-mesh.pdf). Full-mesh: every peer holds
// a direct RTCPeerConnection to every other peer; the Django Channels
// `RoomVoiceConsumer` only relays signaling (offer/answer/ice). TURN creds
// come from /api/voice/turn-credentials/.
//
// Exposes `window.VoiceRoom` { join(roomId), leave(), toggleMute() } and the
// pure `window.tuneOpus(sdp)` helper (Jasmine-tested).
(function () {
'use strict';
// Munge an SDP offer/answer so the Opus payload runs in-band FEC (packet-
// loss resilience), DTX (silence suppression), and a ~40 kbps cap — voice,
// not music. Pure string transform so it's unit-testable without WebRTC.
function tuneOpus(sdp) {
if (!sdp || sdp.indexOf('opus') === -1) return sdp;
var m = sdp.match(/a=rtpmap:(\d+)\s+opus\/48000/i);
if (!m) return sdp;
var pt = m[1];
var fmtpRe = new RegExp('a=fmtp:' + pt + ' ([^\\r\\n]*)');
var extra = 'useinbandfec=1;usedtx=1;maxaveragebitrate=40000';
if (fmtpRe.test(sdp)) {
return sdp.replace(fmtpRe, function (line, params) {
return 'a=fmtp:' + pt + ' ' + params + ';' + extra;
});
}
// No existing fmtp line for opus — append one after its rtpmap.
return sdp.replace(
m[0], m[0] + '\r\na=fmtp:' + pt + ' ' + extra
);
}
window.tuneOpus = tuneOpus;
var ICE_FALLBACK = [{ urls: 'stun:stun.l.google.com:19302' }];
function VoiceRoom() {
this.ws = null;
this.localStream = null;
this.peers = {}; // peerId → RTCPeerConnection
this.iceServers = ICE_FALLBACK;
this.selfId = null;
this.muted = false;
}
VoiceRoom.prototype._fetchTurn = function () {
return fetch('/api/voice/turn-credentials/', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
}).then(function (r) {
return r.ok ? r.json() : null;
}).catch(function () { return null; });
};
VoiceRoom.prototype._mkPeer = function (peerId) {
var self = this;
var pc = new RTCPeerConnection({ iceServers: this.iceServers });
this.localStream.getTracks().forEach(function (t) {
pc.addTrack(t, self.localStream);
});
pc.onicecandidate = function (e) {
if (e.candidate) {
self._send({ type: 'ice', target: peerId, payload: e.candidate });
}
};
pc.ontrack = function (e) {
var el = document.getElementById('voice-audio-' + peerId);
if (!el) {
el = document.createElement('audio');
el.id = 'voice-audio-' + peerId;
el.autoplay = true;
document.body.appendChild(el);
}
el.srcObject = e.streams[0];
};
this.peers[peerId] = pc;
return pc;
};
VoiceRoom.prototype._offerTo = function (peerId) {
var self = this;
var pc = this._mkPeer(peerId);
return pc.createOffer().then(function (offer) {
offer.sdp = tuneOpus(offer.sdp);
return pc.setLocalDescription(offer);
}).then(function () {
self._send({ type: 'offer', target: peerId, payload: pc.localDescription });
});
};
VoiceRoom.prototype._onOffer = function (src, payload) {
var self = this;
var pc = this.peers[src] || this._mkPeer(src);
return pc.setRemoteDescription(payload).then(function () {
return pc.createAnswer();
}).then(function (answer) {
answer.sdp = tuneOpus(answer.sdp);
return pc.setLocalDescription(answer);
}).then(function () {
self._send({ type: 'answer', target: src, payload: pc.localDescription });
});
};
VoiceRoom.prototype._dropPeer = function (peerId) {
var pc = this.peers[peerId];
if (pc) { try { pc.close(); } catch (e) {} delete this.peers[peerId]; }
var el = document.getElementById('voice-audio-' + peerId);
if (el && el.parentNode) el.parentNode.removeChild(el);
};
VoiceRoom.prototype._send = function (obj) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(obj));
}
};
VoiceRoom.prototype._onMessage = function (msg) {
var self = this;
switch (msg.type) {
case 'welcome':
this.selfId = msg.peer_id;
this._send({ type: 'hello' });
break;
case 'present': // an existing peer → we offer to them
this._offerTo(msg.source);
break;
case 'hello': // a newcomer arrived → await their offer
break;
case 'offer':
this._onOffer(msg.source, msg.payload);
break;
case 'answer':
var pc = this.peers[msg.source];
if (pc) pc.setRemoteDescription(msg.payload);
break;
case 'ice':
var p = this.peers[msg.source];
if (p && msg.payload) {
p.addIceCandidate(msg.payload).catch(function () {});
}
break;
case 'left':
this._dropPeer(msg.source);
break;
}
};
VoiceRoom.prototype.join = function (roomId) {
var self = this;
return this._fetchTurn().then(function (creds) {
if (creds && creds.iceServers) self.iceServers = creds.iceServers;
return navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
video: false,
});
}).then(function (stream) {
self.localStream = stream;
var scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
self.ws = new WebSocket(scheme + '://' + window.location.host + '/ws/voice/' + roomId + '/');
self.ws.onmessage = function (e) { self._onMessage(JSON.parse(e.data)); };
self.ws.onclose = function () { self._teardown(); };
return self;
});
};
VoiceRoom.prototype.toggleMute = function () {
this.muted = !this.muted;
if (this.localStream) {
this.localStream.getAudioTracks().forEach(function (t) {
t.enabled = !this.muted;
}, this);
}
return this.muted;
};
VoiceRoom.prototype._teardown = function () {
Object.keys(this.peers).forEach(this._dropPeer.bind(this));
if (this.localStream) {
this.localStream.getTracks().forEach(function (t) { t.stop(); });
this.localStream = null;
}
};
VoiceRoom.prototype.leave = function () {
if (this.ws) { try { this.ws.close(); } catch (e) {} this.ws = null; }
this._teardown();
};
// Singleton for the page — burger-btn.js drives join/toggle/leave.
window.VoiceRoom = window.VoiceRoom || new VoiceRoom();
}());

View File

View File

@@ -0,0 +1,135 @@
"""Channels ITs for RoomVoiceConsumer — Phase C of
[[my-sea-invite-voice-blueprint]]. Covers the auth + `_can_join` membership
gate and the signaling relay (hello/present handshake + offer routing).
Scope is injected directly (user + url_route) rather than going through
AuthMiddlewareStack, so no session plumbing is needed. `_can_join` queries the
DB, so this is a TransactionTestCase (committed setUp rows are visible to the
consumer's threaded `database_sync_to_async` query) using the in-memory
channel layer. Tagged `channels` (excluded from the default run).
"""
from channels.db import database_sync_to_async
from channels.testing.websocket import WebsocketCommunicator
from django.contrib.auth.models import AnonymousUser
from django.test import TransactionTestCase, override_settings, tag
from django.utils import timezone
from apps.gameboard.models import SeaInvite
from apps.lyric.models import User
from apps.voice.consumers import RoomVoiceConsumer
TEST_CHANNEL_LAYERS = {
"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
}
def _comm(user, room_id):
comm = WebsocketCommunicator(RoomVoiceConsumer.as_asgi(), f"/ws/voice/{room_id}/")
comm.scope["user"] = user
comm.scope["url_route"] = {"kwargs": {"room_id": room_id}}
return comm
@tag("channels")
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
class RoomVoiceConsumerGateTest(TransactionTestCase):
def setUp(self):
self.owner = User.objects.create(email="owner@test.io", username="discoman")
self.bud = User.objects.create(email="bud@test.io", username="budster")
self.room = f"mysea-{self.owner.id}"
def _accepted_invite(self, present):
return SeaInvite.objects.create(
owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
token_deposited_at=timezone.now() if present else None,
)
async def test_owner_connects_and_receives_welcome(self):
comm = _comm(self.owner, self.room)
connected, _ = await comm.connect()
self.assertTrue(connected)
msg = await comm.receive_json_from()
self.assertEqual(msg["type"], "welcome")
self.assertIn("peer_id", msg)
await comm.disconnect()
async def test_unauthenticated_is_rejected(self):
comm = _comm(AnonymousUser(), self.room)
connected, _ = await comm.connect()
self.assertFalse(connected)
async def test_stranger_is_rejected(self):
comm = _comm(self.bud, self.room) # bud has no invite yet
connected, _ = await comm.connect()
self.assertFalse(connected)
async def test_present_invitee_connects(self):
# ORM from an async test must be offloaded off the event loop.
await database_sync_to_async(self._accepted_invite)(present=True)
comm = _comm(self.bud, self.room)
connected, _ = await comm.connect()
self.assertTrue(connected)
await comm.disconnect()
async def test_accepted_but_not_present_is_rejected(self):
await database_sync_to_async(self._accepted_invite)(present=False)
comm = _comm(self.bud, self.room)
connected, _ = await comm.connect()
self.assertFalse(connected)
@tag("channels")
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
class RoomVoiceConsumerSignalingTest(TransactionTestCase):
def setUp(self):
self.owner = User.objects.create(email="owner@test.io", username="discoman")
self.bud = User.objects.create(email="bud@test.io", username="budster")
SeaInvite.objects.create(
owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
token_deposited_at=timezone.now(),
)
self.room = f"mysea-{self.owner.id}"
async def _connect(self, user):
comm = _comm(user, self.room)
await comm.connect()
welcome = await comm.receive_json_from()
return comm, welcome["peer_id"]
async def test_hello_triggers_present_and_hello_handshake(self):
owner_comm, owner_pid = await self._connect(self.owner)
bud_comm, bud_pid = await self._connect(self.bud)
# Bud announces arrival.
await bud_comm.send_json_to({"type": "hello"})
# Owner is told a newcomer (bud) arrived; bud is told owner is present.
owner_msg = await owner_comm.receive_json_from()
self.assertEqual(owner_msg, {"type": "hello", "source": bud_pid})
bud_msg = await bud_comm.receive_json_from()
self.assertEqual(bud_msg, {"type": "present", "source": owner_pid})
await owner_comm.disconnect()
await bud_comm.disconnect()
async def test_offer_is_relayed_to_target_peer(self):
owner_comm, owner_pid = await self._connect(self.owner)
bud_comm, bud_pid = await self._connect(self.bud)
await bud_comm.send_json_to({
"type": "offer", "target": owner_pid, "payload": {"sdp": "X"},
})
msg = await owner_comm.receive_json_from()
self.assertEqual(msg["type"], "offer")
self.assertEqual(msg["source"], bud_pid)
self.assertEqual(msg["payload"], {"sdp": "X"})
await owner_comm.disconnect()
await bud_comm.disconnect()
async def test_disconnect_broadcasts_left(self):
owner_comm, owner_pid = await self._connect(self.owner)
bud_comm, bud_pid = await self._connect(self.bud)
await bud_comm.disconnect()
msg = await owner_comm.receive_json_from()
self.assertEqual(msg, {"type": "left", "source": bud_pid})
await owner_comm.disconnect()