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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user