diff --git a/infra/coturn-playbook.yaml b/infra/coturn-playbook.yaml new file mode 100644 index 0000000..7018383 --- /dev/null +++ b/infra/coturn-playbook.yaml @@ -0,0 +1,81 @@ +# Provision the dedicated coturn (TURN/STUN) droplet for WebRTC mesh voice — +# Phase C of the my-sea invite/voice sprint. Mirrors the PySwiss split: its own +# DigitalOcean droplet, NOT the app box. CI needs none of this (signaling tests +# use the in-memory channel layer; the TURN endpoint is unit-tested w. a fake +# secret) — this runs only when you actually stand voice up on staging/prod. +# +# Prereqs (manual, one-time): +# 1. Create a DO droplet + a reserved/static public IP; point +# turn.earthmanrpg.me at it. +# 2. Add it to inventory.ini under [coturn] with host_vars: +# coturn_secret, coturn_realm, coturn_public_ip[, coturn_private_ip, +# coturn_tls_cert, coturn_tls_key] +# 3. Put the SAME coturn_secret into the APP droplet's env as +# COTURN_SHARED_SECRET (+ COTURN_TURN_HOST=turn.earthmanrpg.me, +# COTURN_REALM) so the /api/voice/turn-credentials/ HMAC matches. +# +# Run: ansible-playbook -i inventory.ini coturn-playbook.yaml +# +# nginx already proxy-upgrades WebSocket on the APP droplet (nginx.conf.j2), so +# ws/voice/ rides the existing proxy — no nginx change here. +- hosts: coturn + become: true + + tasks: + - name: Install coturn + ansible.builtin.apt: + name: coturn + state: latest + update_cache: true + + - name: Enable the coturn daemon + ansible.builtin.lineinfile: + path: /etc/default/coturn + regexp: '^#?TURNSERVER_ENABLED=' + line: 'TURNSERVER_ENABLED=1' + + - name: Ensure turn log dir exists + ansible.builtin.file: + path: /var/log/turnserver + state: directory + owner: turnserver + group: turnserver + mode: '0755' + + - name: Deploy turnserver.conf + ansible.builtin.template: + src: coturn.conf.j2 + dest: /etc/turnserver.conf + mode: '0640' + notify: Restart coturn + + - name: Open STUN/TURN signaling ports (3478 udp+tcp) + community.general.ufw: + rule: allow + port: '3478' + proto: "{{ item }}" + loop: [udp, tcp] + + - name: Open TURN-over-TLS port (5349 tcp) + community.general.ufw: + rule: allow + port: '5349' + proto: tcp + + - name: Open the relay UDP port range (49152-65535) + community.general.ufw: + rule: allow + port: '49152:65535' + proto: udp + + - name: Enable + start coturn + ansible.builtin.systemd: + name: coturn + enabled: true + state: started + + handlers: + - name: Restart coturn + ansible.builtin.systemd: + name: coturn + state: restarted diff --git a/infra/coturn.conf.j2 b/infra/coturn.conf.j2 new file mode 100644 index 0000000..bee44ab --- /dev/null +++ b/infra/coturn.conf.j2 @@ -0,0 +1,49 @@ +# coturn (TURN/STUN) config for the EarthmanRPG WebRTC mesh voice feature — +# Phase C of the my-sea invite/voice sprint. Rendered by coturn-playbook.yaml +# onto a DEDICATED droplet (PySwiss-style split), NOT the app droplet. +# +# The app's /api/voice/turn-credentials/ endpoint signs ephemeral credentials +# with HMAC-SHA1(:, secret); `use-auth-secret` + +# `static-auth-secret` here must use the SAME secret (COTURN_SHARED_SECRET in +# the app env). + +listening-port=3478 +tls-listening-port=5349 + +fingerprint +lt-cred-mech +use-auth-secret +static-auth-secret={{ coturn_secret }} +realm={{ coturn_realm }} + +# ── THE #1 FOOTGUN ────────────────────────────────────────────────────────── +# Without external-ip, coturn hands out its PRIVATE address as the relay +# candidate and every relayed connection silently fails. On a DigitalOcean +# droplet with a single public IP set it to that IP; if the droplet also has a +# private/anchor IP, use PUBLIC/PRIVATE so coturn maps between them. +external-ip={{ coturn_public_ip }}{% if coturn_private_ip is defined and coturn_private_ip %}/{{ coturn_private_ip }}{% endif %} + +# Relay port range — open this exact UDP range in the firewall (playbook does). +min-port=49152 +max-port=65535 + +# ── TLS (turns: on 5349) — prod hardening ────────────────────────────────── +{% if coturn_tls_cert is defined and coturn_tls_cert %} +cert={{ coturn_tls_cert }} +pkey={{ coturn_tls_key }} +{% endif %} +no-tlsv1 +no-tlsv1_1 + +# ── Lockdown: relay only, no SSRF via the TURN server ─────────────────────── +no-multicast-peers +no-cli +no-software-attribute +# Block relaying to private ranges so the box can't be used to probe internals. +denied-peer-ip=10.0.0.0-10.255.255.255 +denied-peer-ip=172.16.0.0-172.31.255.255 +denied-peer-ip=192.168.0.0-192.168.255.255 +denied-peer-ip=127.0.0.0-127.255.255.255 + +log-file=/var/log/turnserver/turn.log +simple-log diff --git a/infra/inventory.ini b/infra/inventory.ini index 483ec0f..28d4e54 100644 --- a/infra/inventory.ini +++ b/infra/inventory.ini @@ -8,3 +8,10 @@ dashboard.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.s [cicd] gitea.earthmanrpg.me ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd + +# Dedicated coturn (TURN/STUN) droplet for WebRTC mesh voice — provisioned by +# coturn-playbook.yaml. UNCOMMENT + fill once the droplet + static IP exist +# (see the playbook header). coturn_secret must equal the app's +# COTURN_SHARED_SECRET. coturn_private_ip / coturn_tls_* are optional. +# [coturn] +# turn.earthmanrpg.me ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd coturn_secret=CHANGEME coturn_realm=earthmanrpg.me coturn_public_ip=CHANGEME diff --git a/src/apps/api/tests/integrated/test_turn_credentials.py b/src/apps/api/tests/integrated/test_turn_credentials.py new file mode 100644 index 0000000..8fd0451 --- /dev/null +++ b/src/apps/api/tests/integrated/test_turn_credentials.py @@ -0,0 +1,55 @@ +"""ITs for the WebRTC mesh TURN-credentials endpoint — Phase C of +[[my-sea-invite-voice-blueprint]]. Verifies the coturn `use-auth-secret` +REST scheme (HMAC-SHA1 username/credential) + auth gating. +""" + +import base64 +import hashlib +import hmac + +from django.test import TestCase, override_settings +from django.urls import reverse + +from apps.lyric.models import User + + +@override_settings( + COTURN_SHARED_SECRET="testsecret", + COTURN_TURN_HOST="turn.test", + COTURN_TTL=86400, +) +class TURNCredentialsTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="turn@test.io", username="turner") + self.url = reverse("api_turn_credentials") + + def test_requires_authentication(self): + resp = self.client.get(self.url) + self.assertIn(resp.status_code, (401, 403)) + + def test_returns_ice_servers_and_ttl(self): + self.client.force_login(self.user) + data = self.client.get(self.url).json() + self.assertEqual(data["ttl"], 86400) + urls = [s for srv in data["iceServers"] for s in srv["urls"]] + self.assertTrue(any(u.startswith("stun:turn.test") for u in urls)) + self.assertTrue(any("turn:turn.test" in u and "transport=udp" in u for u in urls)) + self.assertTrue(any("turn:turn.test" in u and "transport=tcp" in u for u in urls)) + + def test_username_is_expiry_colon_user_id(self): + self.client.force_login(self.user) + data = self.client.get(self.url).json() + expiry_str, _, uid = data["username"].partition(":") + self.assertTrue(expiry_str.isdigit()) + self.assertEqual(uid, str(self.user.id)) + + def test_credential_is_hmac_sha1_of_username(self): + self.client.force_login(self.user) + data = self.client.get(self.url).json() + expected = base64.b64encode( + hmac.new(b"testsecret", data["username"].encode(), hashlib.sha1).digest() + ).decode() + self.assertEqual(data["credential"], expected) + # The TURN iceServer entry carries the same credential. + turn = [s for s in data["iceServers"] if "credential" in s][0] + self.assertEqual(turn["credential"], expected) diff --git a/src/apps/api/urls.py b/src/apps/api/urls.py index a5fcac5..8828e9f 100644 --- a/src/apps/api/urls.py +++ b/src/apps/api/urls.py @@ -8,4 +8,6 @@ urlpatterns = [ path('posts//', views.PostDetailAPI.as_view(), name='api_post_detail'), path('posts//lines/', views.PostLinesAPI.as_view(), name='api_post_lines'), path('users/', views.UserSearchAPI.as_view(), name='api_users'), + path('voice/turn-credentials/', views.VoiceTURNCredentialsAPI.as_view(), + name='api_turn_credentials'), ] diff --git a/src/apps/api/views.py b/src/apps/api/views.py index ebc7f7f..02bfd4b 100644 --- a/src/apps/api/views.py +++ b/src/apps/api/views.py @@ -1,4 +1,11 @@ +import base64 +import hashlib +import hmac +import time + +from django.conf import settings from django.shortcuts import get_object_or_404 +from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView from rest_framework.response import Response @@ -44,3 +51,44 @@ class UserSearchAPI(APIView): ) serializer = UserSerializer(users, many=True) return Response(serializer.data) + + +class VoiceTURNCredentialsAPI(APIView): + """Time-limited TURN/STUN credentials for the WebRTC mesh voice client + (Phase C of [[my-sea-invite-voice-blueprint]]). Implements the coturn + `use-auth-secret` REST scheme: username = `:`, credential + = base64(HMAC-SHA1(username, COTURN_SHARED_SECRET)) — the coturn droplet + validates it with the same shared secret, so no per-user state is stored + on either side. Authenticated-only.""" + + permission_classes = [IsAuthenticated] + + def get(self, request): + host = settings.COTURN_TURN_HOST + ttl = settings.COTURN_TTL + expiry = int(time.time()) + ttl + username = f"{expiry}:{request.user.id}" + digest = hmac.new( + settings.COTURN_SHARED_SECRET.encode(), + username.encode(), + hashlib.sha1, + ).digest() + credential = base64.b64encode(digest).decode() + + ice_servers = [] + if host: + ice_servers.append({"urls": [f"stun:{host}:3478"]}) + ice_servers.append({ + "urls": [ + f"turn:{host}:3478?transport=udp", + f"turn:{host}:3478?transport=tcp", + ], + "username": username, + "credential": credential, + }) + return Response({ + "iceServers": ice_servers, + "username": username, + "credential": credential, + "ttl": ttl, + }) diff --git a/src/apps/epic/static/apps/epic/burger-btn.js b/src/apps/epic/static/apps/epic/burger-btn.js index 252e8df..b0aaab7 100644 --- a/src/apps/epic/static/apps/epic/burger-btn.js +++ b/src/apps/epic/static/apps/epic/burger-btn.js @@ -114,9 +114,49 @@ window.bindBurger = bindBurger; + // Voice sub-btn (Phase C of the my-sea invite/voice sprint). Direct + // listener on #id_voice_btn: an ACTIVE click lazy-loads voice-mesh.js + // then joins the mesh (first click) or toggles mute (subsequent). An + // INACTIVE click is left to the delegated fan handler's 2-pulse flash. + // No stopPropagation on active — the delegated handler then closes the + // fan (its existing .active behaviour). + function bindVoiceBtn() { + var vbtn = document.getElementById('id_voice_btn'); + if (!vbtn) return; + vbtn.addEventListener('click', function () { + if (!vbtn.classList.contains('active')) return; // → delegated flash + var roomId = vbtn.getAttribute('data-room-id'); + if (!roomId) return; + function act() { + if (!window.VoiceRoom) return; + if (!vbtn.dataset.inCall) { + vbtn.dataset.inCall = '1'; + vbtn.classList.add('in-call'); + window.VoiceRoom.join(roomId); + } else { + var muted = window.VoiceRoom.toggleMute(); + vbtn.classList.toggle('muted', muted); + } + } + if (window.VoiceRoom) { + act(); + } else { + var s = document.createElement('script'); + s.src = '/static/apps/voice/voice-mesh.js'; + s.onload = act; + document.head.appendChild(s); + } + }); + } + window.bindVoiceBtn = bindVoiceBtn; + if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', bindBurger); + document.addEventListener('DOMContentLoaded', function () { + bindBurger(); + bindVoiceBtn(); + }); } else { bindBurger(); + bindVoiceBtn(); } }()); diff --git a/src/apps/gameboard/tests/integrated/test_sea_visit.py b/src/apps/gameboard/tests/integrated/test_sea_visit.py index acb6f9c..49fdc8a 100644 --- a/src/apps/gameboard/tests/integrated/test_sea_visit.py +++ b/src/apps/gameboard/tests/integrated/test_sea_visit.py @@ -173,3 +173,51 @@ class MySeaVisitLeaveTest(TestCase): stranger = User.objects.create(email="x@test.io", username="x") self.client.force_login(stranger) self.assertEqual(self.client.post(self.url).status_code, 403) + + +class MySeaVoiceContextTest(TestCase): + """Phase C — the #id_voice_btn lights up (voice_active) for both the + owner and the present invitee while the 24h voice window is open, keyed on + mysea-.""" + + def setUp(self): + self.owner = _owner_with_sig() + self.bud = User.objects.create(email="bud@test.io", username="budster") + self.room_id = f"mysea-{self.owner.id}" + + def _present_invite(self, voice_future=True): + 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(), + voice_until=timezone.now() + ( + timedelta(hours=24) if voice_future else timedelta(hours=-1) + ), + ) + + def test_spectator_voice_active_in_context(self): + self._present_invite() + self.client.force_login(self.bud) + ctx = self.client.get( + reverse("my_sea_visit", args=[self.owner.id]) + ).context + self.assertTrue(ctx["voice_active"]) + self.assertEqual(ctx["voice_room_id"], self.room_id) + + def test_owner_voice_active_when_present_invitee_has_window(self): + self._present_invite() + self.client.force_login(self.owner) + ctx = self.client.get(reverse("my_sea")).context + self.assertTrue(ctx["voice_active"]) + self.assertEqual(ctx["voice_room_id"], self.room_id) + + def test_owner_voice_inactive_without_present_invitee(self): + self.client.force_login(self.owner) + ctx = self.client.get(reverse("my_sea")).context + self.assertFalse(ctx["voice_active"]) + + def test_owner_voice_inactive_when_window_lapsed(self): + self._present_invite(voice_future=False) + self.client.force_login(self.owner) + ctx = self.client.get(reverse("my_sea")).context + self.assertFalse(ctx["voice_active"]) diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 136edb4..23bb55c 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -340,8 +340,19 @@ def my_sea(request): reverse("my_sea_dismiss_paid_draw_brief"), ) + # Phase C — the owner's voice btn lights up while any present invitee + # has an open 24h voice window. Room key is mysea-. + from .models import SeaInvite + voice_active = SeaInvite.objects.filter( + owner=request.user, status=SeaInvite.ACCEPTED, + token_deposited_at__isnull=False, left_at__isnull=True, + voice_until__gt=timezone.now(), + ).exists() + return render(request, "apps/gameboard/my_sea.html", { "user_has_sig": user_has_sig, + "voice_active": voice_active, + "voice_room_id": f"mysea-{request.user.id}", "no_equipped_deck": no_equipped_deck, "show_backup_intro_banner": ( user_has_sig and no_equipped_deck and active_draw is None @@ -906,6 +917,7 @@ def my_sea_visit(request, owner_id): "seat2_present": invite.is_present, "owner_draw_id": owner_draw.id if owner_draw is not None else "", "voice_active": invite.voice_active, + "voice_room_id": f"mysea-{owner.id}", "significator": sig_card, "significator_reversed": sig_reversed, "my_sea_slots": latest_draw_slots(owner), diff --git a/src/apps/voice/__init__.py b/src/apps/voice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/voice/apps.py b/src/apps/voice/apps.py new file mode 100644 index 0000000..8df2f56 --- /dev/null +++ b/src/apps/voice/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class VoiceConfig(AppConfig): + name = "apps.voice" diff --git a/src/apps/voice/consumers.py b/src/apps/voice/consumers.py new file mode 100644 index 0000000..e781d16 --- /dev/null +++ b/src/apps/voice/consumers.py @@ -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-` 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-` → 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() diff --git a/src/apps/voice/routing.py b/src/apps/voice/routing.py new file mode 100644 index 0000000..9e2ddfc --- /dev/null +++ b/src/apps/voice/routing.py @@ -0,0 +1,10 @@ +from django.urls import path + +from . import consumers + + +# room_id is a free-form string (`mysea-` now, room UUID later), so +# a converter — NOT — keeps both keying schemes on one route. +websocket_urlpatterns = [ + path("ws/voice//", consumers.RoomVoiceConsumer.as_asgi()), +] diff --git a/src/apps/voice/static/apps/voice/voice-mesh.js b/src/apps/voice/static/apps/voice/voice-mesh.js new file mode 100644 index 0000000..ae563c5 --- /dev/null +++ b/src/apps/voice/static/apps/voice/voice-mesh.js @@ -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(); +}()); diff --git a/src/apps/voice/tests/__init__.py b/src/apps/voice/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/voice/tests/integrated/__init__.py b/src/apps/voice/tests/integrated/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/voice/tests/integrated/test_consumers.py b/src/apps/voice/tests/integrated/test_consumers.py new file mode 100644 index 0000000..f562f73 --- /dev/null +++ b/src/apps/voice/tests/integrated/test_consumers.py @@ -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() diff --git a/src/core/asgi.py b/src/core/asgi.py index 7efe506..dd49130 100644 --- a/src/core/asgi.py +++ b/src/core/asgi.py @@ -5,6 +5,7 @@ from channels.routing import ProtocolTypeRouter, URLRouter from channels.auth import AuthMiddlewareStack import apps.epic.routing +import apps.voice.routing os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') @@ -14,6 +15,7 @@ application = ProtocolTypeRouter({ 'websocket': AuthMiddlewareStack( URLRouter( apps.epic.routing.websocket_urlpatterns + + apps.voice.routing.websocket_urlpatterns ) ), }) diff --git a/src/core/settings.py b/src/core/settings.py index ef20196..4cf3e16 100644 --- a/src/core/settings.py +++ b/src/core/settings.py @@ -62,6 +62,7 @@ INSTALLED_APPS = [ 'apps.lyric', 'apps.epic', 'apps.drama', + 'apps.voice', # Custom apps 'apps.tooltips', 'apps.ap', @@ -226,6 +227,16 @@ STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "") # PySwiss ephemeris microservice PYSWISS_URL = os.environ.get("PYSWISS_URL", "http://127.0.0.1:8001") +# coturn TURN/STUN server (self-hosted WebRTC mesh voice — my-sea now, epic +# rooms later). `COTURN_SHARED_SECRET` must match the coturn droplet's +# `static-auth-secret`; /api/voice/turn-credentials/ signs time-limited +# credentials with it (HMAC-SHA1 of `:`). coturn runs on its +# own droplet (PySwiss-style split). See [[my-sea-invite-voice-blueprint]] C. +COTURN_SHARED_SECRET = os.environ.get("COTURN_SHARED_SECRET", "") +COTURN_TURN_HOST = os.environ.get("COTURN_TURN_HOST", "") # e.g. turn.earthmanrpg.me +COTURN_REALM = os.environ.get("COTURN_REALM", "earthmanrpg.me") +COTURN_TTL = int(os.environ.get("COTURN_TTL", "86400")) # TURN credential lifetime (s) + if 'test' in sys.argv: import shutil _cache_dir = BASE_DIR / 'static' / 'CACHE' diff --git a/src/functional_tests/test_game_my_sea.py b/src/functional_tests/test_game_my_sea.py index 9979887..f72d318 100644 --- a/src/functional_tests/test_game_my_sea.py +++ b/src/functional_tests/test_game_my_sea.py @@ -6,6 +6,8 @@ Look!-formatted Brief-style line w. FYI (→ /billboard/my-sign/) + NVM (→ /gameboard/) instead of the draw UX. The My Sea applet on /gameboard/ mirrors the gate hint in its empty-state slot. """ +from datetime import timedelta + from django.utils import timezone from selenium.webdriver.common.by import By @@ -1947,6 +1949,40 @@ class MySeaSpectatorFlowTest(FunctionalTest): self.assertIn("seated", seat2.get_attribute("class")) +class MySeaVoiceBtnTest(FunctionalTest): + """Phase C — with a 24h voice window open, the burger fan's #id_voice_btn + renders .active + data-room-id on the present invitee's visit page.""" + + def setUp(self): + super().setUp() + self.browser.set_window_size(800, 1200) + _seed_gameboard_applets() + from apps.gameboard.models import SeaInvite + self.owner = User.objects.create(email="owner@test.io", username="discoman") + self.email = "bud@test.io" + self.bud = User.objects.create(email=self.email, username="budster") + SeaInvite.objects.create( + owner=self.owner, invitee=self.bud, invitee_email=self.email, + status=SeaInvite.ACCEPTED, accepted_at=timezone.now(), + token_deposited_at=timezone.now(), + voice_until=timezone.now() + timedelta(hours=24), + ) + + def test_voice_btn_active_for_present_invitee(self): + from django.urls import reverse + self.create_pre_authenticated_session(self.email) + self.browser.get( + self.live_server_url + reverse("my_sea_visit", args=[self.owner.id]) + ) + voice = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_voice_btn") + ) + self.assertIn("active", voice.get_attribute("class")) + self.assertEqual( + voice.get_attribute("data-room-id"), f"mysea-{self.owner.id}" + ) + + class MySeaGearBtnTest(FunctionalTest): """Sprint 6 iter 6c — `.gear-btn` on every my-sea page state (landing / picker / gatekeeper). Opens a NVM-only menu (DEL/BYE diff --git a/src/static/tests/SpecRunner.html b/src/static/tests/SpecRunner.html index 8f8cc97..93e91d7 100644 --- a/src/static/tests/SpecRunner.html +++ b/src/static/tests/SpecRunner.html @@ -32,6 +32,7 @@ + @@ -47,6 +48,7 @@ + diff --git a/src/static/tests/VoiceMeshSpec.js b/src/static/tests/VoiceMeshSpec.js new file mode 100644 index 0000000..de8a3c2 --- /dev/null +++ b/src/static/tests/VoiceMeshSpec.js @@ -0,0 +1,42 @@ +// Jasmine spec for voice-mesh.js's `tuneOpus` — the pure SDP-munging helper +// that turns on Opus in-band FEC + DTX and caps the bitrate for voice +// (Phase C of the my-sea invite/voice sprint). The RTCPeerConnection mesh +// itself is verified by hand; this pins the one deterministic, testable part. +describe('voice-mesh tuneOpus', function () { + var SDP_WITH_FMTP = + 'v=0\r\n' + + 'm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n' + + 'a=rtpmap:111 opus/48000/2\r\n' + + 'a=fmtp:111 minptime=10\r\n'; + + var SDP_NO_FMTP = + 'v=0\r\n' + + 'm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n' + + 'a=rtpmap:111 opus/48000/2\r\n'; + + it('exposes tuneOpus globally', function () { + expect(typeof window.tuneOpus).toBe('function'); + }); + + it('appends FEC/DTX/bitrate to an existing opus fmtp line', function () { + var out = window.tuneOpus(SDP_WITH_FMTP); + expect(out).toContain('a=fmtp:111 minptime=10;'); + expect(out).toContain('useinbandfec=1'); + expect(out).toContain('usedtx=1'); + expect(out).toContain('maxaveragebitrate=40000'); + }); + + it('adds an fmtp line when opus has none', function () { + var out = window.tuneOpus(SDP_NO_FMTP); + expect(out).toMatch(/a=fmtp:111 [^\r\n]*useinbandfec=1/); + }); + + it('leaves SDP without opus untouched', function () { + var pcmu = 'v=0\r\nm=audio 9 RTP/AVP 0\r\na=rtpmap:0 PCMU/8000\r\n'; + expect(window.tuneOpus(pcmu)).toBe(pcmu); + }); + + it('is null-safe', function () { + expect(window.tuneOpus('')).toBe(''); + }); +}); diff --git a/src/static_src/tests/SpecRunner.html b/src/static_src/tests/SpecRunner.html index 8f8cc97..93e91d7 100644 --- a/src/static_src/tests/SpecRunner.html +++ b/src/static_src/tests/SpecRunner.html @@ -32,6 +32,7 @@ + @@ -47,6 +48,7 @@ + diff --git a/src/static_src/tests/VoiceMeshSpec.js b/src/static_src/tests/VoiceMeshSpec.js new file mode 100644 index 0000000..de8a3c2 --- /dev/null +++ b/src/static_src/tests/VoiceMeshSpec.js @@ -0,0 +1,42 @@ +// Jasmine spec for voice-mesh.js's `tuneOpus` — the pure SDP-munging helper +// that turns on Opus in-band FEC + DTX and caps the bitrate for voice +// (Phase C of the my-sea invite/voice sprint). The RTCPeerConnection mesh +// itself is verified by hand; this pins the one deterministic, testable part. +describe('voice-mesh tuneOpus', function () { + var SDP_WITH_FMTP = + 'v=0\r\n' + + 'm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n' + + 'a=rtpmap:111 opus/48000/2\r\n' + + 'a=fmtp:111 minptime=10\r\n'; + + var SDP_NO_FMTP = + 'v=0\r\n' + + 'm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n' + + 'a=rtpmap:111 opus/48000/2\r\n'; + + it('exposes tuneOpus globally', function () { + expect(typeof window.tuneOpus).toBe('function'); + }); + + it('appends FEC/DTX/bitrate to an existing opus fmtp line', function () { + var out = window.tuneOpus(SDP_WITH_FMTP); + expect(out).toContain('a=fmtp:111 minptime=10;'); + expect(out).toContain('useinbandfec=1'); + expect(out).toContain('usedtx=1'); + expect(out).toContain('maxaveragebitrate=40000'); + }); + + it('adds an fmtp line when opus has none', function () { + var out = window.tuneOpus(SDP_NO_FMTP); + expect(out).toMatch(/a=fmtp:111 [^\r\n]*useinbandfec=1/); + }); + + it('leaves SDP without opus untouched', function () { + var pcmu = 'v=0\r\nm=audio 9 RTP/AVP 0\r\na=rtpmap:0 PCMU/8000\r\n'; + expect(window.tuneOpus(pcmu)).toBe(pcmu); + }); + + it('is null-safe', function () { + expect(window.tuneOpus('')).toBe(''); + }); +}); diff --git a/src/templates/apps/gameboard/_partials/_burger.html b/src/templates/apps/gameboard/_partials/_burger.html index 6af34e6..c95030c 100644 --- a/src/templates/apps/gameboard/_partials/_burger.html +++ b/src/templates/apps/gameboard/_partials/_burger.html @@ -12,7 +12,11 @@ {% endblock content %} {% block scripts %} +