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

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

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

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

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

View File

@@ -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) -->

View 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('');
});
});