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:
81
infra/coturn-playbook.yaml
Normal file
81
infra/coturn-playbook.yaml
Normal file
@@ -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
|
||||
49
infra/coturn.conf.j2
Normal file
49
infra/coturn.conf.j2
Normal file
@@ -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(<expiry>:<user_id>, 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
|
||||
@@ -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
|
||||
|
||||
55
src/apps/api/tests/integrated/test_turn_credentials.py
Normal file
55
src/apps/api/tests/integrated/test_turn_credentials.py
Normal file
@@ -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)
|
||||
@@ -8,4 +8,6 @@ urlpatterns = [
|
||||
path('posts/<uuid:post_id>/', views.PostDetailAPI.as_view(), name='api_post_detail'),
|
||||
path('posts/<uuid:post_id>/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'),
|
||||
]
|
||||
|
||||
@@ -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 = `<expiry>:<user_id>`, 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,
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}());
|
||||
|
||||
@@ -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-<owner_id>."""
|
||||
|
||||
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"])
|
||||
|
||||
@@ -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-<owner_id>.
|
||||
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),
|
||||
|
||||
0
src/apps/voice/__init__.py
Normal file
0
src/apps/voice/__init__.py
Normal file
5
src/apps/voice/apps.py
Normal file
5
src/apps/voice/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class VoiceConfig(AppConfig):
|
||||
name = "apps.voice"
|
||||
129
src/apps/voice/consumers.py
Normal file
129
src/apps/voice/consumers.py
Normal 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
10
src/apps/voice/routing.py
Normal 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()),
|
||||
]
|
||||
190
src/apps/voice/static/apps/voice/voice-mesh.js
Normal file
190
src/apps/voice/static/apps/voice/voice-mesh.js
Normal 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();
|
||||
}());
|
||||
0
src/apps/voice/tests/__init__.py
Normal file
0
src/apps/voice/tests/__init__.py
Normal file
0
src/apps/voice/tests/integrated/__init__.py
Normal file
0
src/apps/voice/tests/integrated/__init__.py
Normal file
135
src/apps/voice/tests/integrated/test_consumers.py
Normal file
135
src/apps/voice/tests/integrated/test_consumers.py
Normal 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()
|
||||
@@ -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
|
||||
)
|
||||
),
|
||||
})
|
||||
|
||||
@@ -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 `<expiry>:<user_id>`). 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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<script src="WalletShopSpec.js"></script>
|
||||
<script src="BurgerSpec.js"></script>
|
||||
<script src="MySeaSeatsSpec.js"></script>
|
||||
<script src="VoiceMeshSpec.js"></script>
|
||||
<!-- src files -->
|
||||
<script src="/static/apps/applets/row-lock.js"></script>
|
||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||
@@ -47,6 +48,7 @@
|
||||
<script src="/static/apps/epic/burger-btn.js"></script>
|
||||
<script src="/static/apps/gameboard/game-kit.js"></script>
|
||||
<script src="/static/apps/gameboard/my-sea-seats.js"></script>
|
||||
<script src="/static/apps/voice/voice-mesh.js"></script>
|
||||
<script src="/static/apps/gameboard/d3.min.js"></script>
|
||||
<script src="/static/apps/gameboard/sky-wheel.js"></script>
|
||||
<!-- Jasmine env config (optional) -->
|
||||
|
||||
42
src/static/tests/VoiceMeshSpec.js
Normal file
42
src/static/tests/VoiceMeshSpec.js
Normal file
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@
|
||||
<script src="WalletShopSpec.js"></script>
|
||||
<script src="BurgerSpec.js"></script>
|
||||
<script src="MySeaSeatsSpec.js"></script>
|
||||
<script src="VoiceMeshSpec.js"></script>
|
||||
<!-- src files -->
|
||||
<script src="/static/apps/applets/row-lock.js"></script>
|
||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||
@@ -47,6 +48,7 @@
|
||||
<script src="/static/apps/epic/burger-btn.js"></script>
|
||||
<script src="/static/apps/gameboard/game-kit.js"></script>
|
||||
<script src="/static/apps/gameboard/my-sea-seats.js"></script>
|
||||
<script src="/static/apps/voice/voice-mesh.js"></script>
|
||||
<script src="/static/apps/gameboard/d3.min.js"></script>
|
||||
<script src="/static/apps/gameboard/sky-wheel.js"></script>
|
||||
<!-- Jasmine env config (optional) -->
|
||||
|
||||
42
src/static_src/tests/VoiceMeshSpec.js
Normal file
42
src/static_src/tests/VoiceMeshSpec.js
Normal file
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,11 @@
|
||||
<i class="fa-solid fa-burger"></i>
|
||||
</button>
|
||||
<div id="id_burger_fan" aria-hidden="true">
|
||||
<button id="id_voice_btn" type="button" class="burger-fan-btn" aria-label="Voice">
|
||||
{# Voice sub-btn (Phase C). .active when a 24h voice window is open #}
|
||||
{# (owner w. a present invitee, or the present invitee). data-room-id #}
|
||||
{# keys the WebRTC mesh group: mysea-<owner_id>. burger-btn.js binds the #}
|
||||
{# active-click → lazy-load voice-mesh.js → join / toggle-mute. #}
|
||||
<button id="id_voice_btn" type="button" class="burger-fan-btn{% if voice_active %} active{% endif %}" aria-label="Voice"{% if voice_room_id %} data-room-id="{{ voice_room_id }}"{% endif %}>
|
||||
<i class="fa-solid fa-headset burger-fan-icon--on"></i>
|
||||
<i class="fa-solid fa-ban burger-fan-icon--off"></i>
|
||||
</button>
|
||||
|
||||
@@ -76,11 +76,14 @@
|
||||
{# Gear menu — NVM (back to /gameboard/) + BYE (drop presence, free 2C). #}
|
||||
{% url 'my_sea_visit_leave' owner.id as leave_url %}
|
||||
{% include "apps/gameboard/_partials/_my_sea_gear.html" with leave_url=leave_url %}
|
||||
{# Burger fan — carries the voice sub-btn (active while voice_active). #}
|
||||
{% include "apps/gameboard/_partials/_burger.html" %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{% static 'apps/gameboard/my-sea-seats.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/burger-btn.js' %}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
// VIEW DRAW toggles the read-only draw against the table hex.
|
||||
|
||||
Reference in New Issue
Block a user