From 25dbd8413bf090fbaf76f9d2944035f4872b0777 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Wed, 13 May 2026 22:13:03 +0700 Subject: [PATCH] feat: implement WebCodecs Opus decode for browser listen - Add broadcastOpusToWeb to PcmBroadcaster interface for raw Opus packets - Server broadcasts Opus frames with mode byte (1) + user hash + packet data - Browser detects packet mode: mode=1 for Opus, mode=0 for legacy PCM - Implement WebCodecs AudioDecoder for Opus decoding in browser - Keep existing PCM playback as fallback for compatibility - Show error if WebCodecs unsupported - Fixes listen feature under Bun where native Opus decode unavailable --- bun.lockb | Bin 159658 -> 161508 bytes .../plans/2026-05-13-react-ssr-dashboard.md | 60 ++ package.json | 4 + public/dashboard.css | 417 ++++++++ public/dashboard.js | 555 ++++++++++ public/index.html | 958 +----------------- src/recorder.ts | 1 + src/types.ts | 1 + src/web/dashboardPage.tsx | 217 ++++ src/webserver.ts | 50 + tsconfig.json | 3 +- 11 files changed, 1308 insertions(+), 958 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-13-react-ssr-dashboard.md create mode 100644 public/dashboard.css create mode 100644 public/dashboard.js create mode 100644 src/web/dashboardPage.tsx diff --git a/bun.lockb b/bun.lockb index 04ee2b725d496918e61b9a331fdf2a7c6072247e..aece00d4b517fb744f20fca1db36c5c7e0ed49f2 100755 GIT binary patch delta 29463 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%0A$!7=m*3DeStne zZy*k!c+wLO04QG94@8*aNT$NbhVo7Zk^odPU)in6%Wz;QKrJu?$OL`|N`P~~S>OzC z1egI#0SbU|KrS!}$N{o}5x~2^NMI~52FTN7vL(CGKrZz^DaHfT6%&E?fC<0{z$D;( zfVQd0z;s|LFb((!m=DYZ=)nFlcn&Zd_ym{*kYilqufc_Y06qmi1LjeoCg5{mt``0h z`~~n8un<@P90n-UgFrFxGq4U=3M>J>0e%D)1K$ISfFFQwf$sp)5h%P27zV5b@~QBZ zzzSeFunJfMtOm%4x?vNr5m*lt0UH3S@E%|Xup8I~>;$#~TY${~*;4#=U>oogK=FHl zeE=FQ|Lp+;_5+82)4(a*zHKfGEA86r+;NO7z0EHg|4}eFSOuF)V^#j}6x6h$}20R67 zF?BPq4yL0CZC@1uBftRaQsuz)7W@Wy4ZH%r0%!xJ=9j>^wbWZx`qSfA8I1LUxBZKyRKwa>qJ6BmIOL)#RG9-@C3;38}`JaZs z3{5#5{E-$WqnTQm4ooByz5qT4<^ps``V63>;HSVGVBmWE!L|LFc)E%;7A~tIdIFx$XO)AEMr>Kl0`S$@qt(oTEfiJO zFb79k9x)UP8rE22+v1m)9EtV!MZNrd4LgJh27%OXwlI)y_PkaHKR+o3fhb8?jH)9h zdE%;pH)?#9sV1eU2V(jf)=4rlabXSfmufOmV=e2*+(o~&D3XS26{K6Sr1)kd&yyV_ zMu$TiXxfWeFbLG246Hx43>hme5~O@OQoPe1Y*rT2OZ>Q&b#mN+oGK%y)a+$%UsnGy ztv2i5!UqmvX%>giFbBy|5) zXf2h3^oJDdPf$~Q^=}FL|Ez3{(#j@TiEHavM<@Lm#{F7fy7SS5ZvIM9N}a4LijGBS zpt==BK@qc;JSz&P^{PsPiygT1M<|Lh}`hK_q})6oC;Aqf-Po{wQzw&kp76{HlDxK zy7$A&`dVJF(4U@MD=&GshkuiCi1AbFqt@JC(()+vL@#}!wHWsIrR!~=`Ci&KdcFVJ zLuYH@z7egdl_jz^vU*Z+74gwV^tb+e=2IWfwSF(8?y#~d-zeI)ksW8!sA`{XMf(&~ z6U#Qkd;P(|8M_vaH(fdtiv<4gu_ew$m@hPbz4o2FxAaJ@_DT%qD^5Yx#4Dtg7FQE9 zc7WGb6TVx(+pCHGKcN!(LzRd0Y2JIwaVvKvC0dTY&WhcJsc{Wi(^~NPSTE!CozJdn zS;OF=Sh)qm*HB#?K#orOGomj>MqIcU`*INswI0yi(5fQ*x3bQO>v*pooWiV?W|+D{1kUJYq5DdTjvy2N1cJi z%iC-m{7$F~GDoYCkAKlBwZB9Pa{7gh+uAzeUq6)C^g81H4%RB@k9mzR%}ecrPNgo4 zr`|yyXl?DJKbd+^ukR5rdY^XVd|3&#e$uUYzI53^Gj z=0BFw$X>jmJhajzEzkdryoB#A*5Yp*#81QpyWpVycx~%1W*ula@y3F(#;W5Wy6#5h z+#JLqSO)12=Ki$N-lUN=yYDQs40ljhRQq{j3a9_@PPt{Sn}cxK^S2GHl@j#Vjif#B zyI|Gwiu{D1d79R+=y25RE2^PdN*yCq`F7&iZj4vG@>&Ig^rw&CTOKyQ_Szmzl}wbL z{_BF(Ua1@0!k|Aoe9Z5sLh^si!rJPKZ5jjHpg&w3_vv*XwyLnU#Qc0hXfWbw?8<5) zZEzN+_F(1&)EDkQqh?|CMf}ffu9K6CIx0NpE}QvHUiuiNM*cn+FO6J8wY|)qwGs`k zFbAjRFr-QHO`WJ&VM9BNfMEdkX_VgDMf8QCQ

gw$r^~pIX15O#*u{W!2q9%!EbI zU|7^b9;4S?IC8*L7LK0&lOj6w{ZPJXe8>ij6`qne1JZ3DSaa}k%2R{GjSJl~6o zPjwYu#aKi12b#|vv~ZW$96d|POKA<&!d_BKts5K=@YlKPk4gXWY|5RC=hGS>7hlw< zw9a*{0s^#rgx5aiB>B6Gn0=_mNp~?G>@=~Fx(;-1{?h`7eLK1h1oBOQL9cU@xRamw*&fpc9G^?sii_yH zpZRyyAIk1ip;6yPKQyCG!h+`GAE_jA+dt59CBI?{T1 zi24W68-qMV@By5to{0Akuue|;Gu99G3y^+Ya2U=hdlkB`*i+m`f*}3r>^moKcg>ic z`#UUXzrYaHALl%)dGG0s*M`wKz>n$?V5s9ILJy(^UA#orK@{5DOH3tslb6^EIY@tk z`+|T|o#%b~qGeg0`m@|!&o}Siq`&J;GE1ckjQmcKm>j!?lVbbNylPy|&#qesQTpT>$)OQ@Cb{sIo5pLwfAjDA|u*mkWobp94zPsAAg_UVv_%uTS^g1{WrA`hmNBDXti5MnP=s! zK=lw`5GZ_4vpNk>PyNa7HvOJ9%^7@jkd_#ues-YfdJN5@KP!G~x3rzoo8vQKp&j@Z z2a1^|Ssf!LEq)>X3dTV@Q34IMZc|Pzs~;az8OAgRP|%;&QmKEr+RP48r_jB@y?a~? z-QEt#gHfq5HeqZo9ovp}nYj;j!lY&L^apoe8 z4iS@&W5L@RB2FDgH~P00l}|v9ZY|DqG+uL^oPxdr5qVN_QN4`mZA?pSUYg4zJ#oTl;_f;U9Me>{W-0~ zxxbQDX&3#;_4ZeP*mh&vkF@M)xwbV4uT$tftvXW6a50eNw&5c06iascJVITsdmO*# zo|jpp7gA!mq7k9CD9(OL2jLv4^fyk?f3`v?eelP|)7P$%ZN>Q0@L)n)bxZY~G4fXC zxjxUxN2;iwfg`3a3XKWazx;TQKJ&_Aw6yH0m_VO*sziQz1_Mfei2l5`y$5>E-%E=e z&3_Cz&0hKs{`d6%<>#ts{b~E_US|E;yzw3ygnpQofj$BH^Y}e$_h^z>(lu5o+K>7{ ze`5cLFI?-ly)`LDVvg;F?^%qVDQ3~_ESt-&ijuQ%LR%BqUEz8Tlk}Nr4RX?-*YEV~ z{bPOQS9!>o8V56@c9h6EhxXGS@qhOHHY=7l8S*jh*hOh2;_|roF77;99*;tS^1?U38g&O&3vc2^G^{ zZy>U7E< z7bzk7@vE*?3%?o7#K4EBnEvVnIWu(6o!GgX(3L10tF9qDP8}QJU#Ay4JWqduVyKhR zQ(fZ3<}0XJ?>OOmjoFL)S6B-vIZn7=#U_)9Wb~0VFHXc?#p*gSPJDfX)wK#VOpp8Y zI;^)|Wi{J;2CI6o(x2Mz)VSXAaYF;~feVEw$6);x3a^{LXUU(~7;}|&5^NBL<8h+e zFZc+nzieUMk~6`R#=7h(vv?{Ze!+yXiWhxhDAkS^<55q^HD1hw+|?^y-3N!y+Bu@J zS4wVe=80_S(%<>iXGNv>q_j!tI0(`489x8#*`ziLMK1Wpub_9lc>W6_zhc&n zgWeAMw9zm2gdW*CzIJKFXUB_yun77d7LKrRvayfsaboPCGK-%OL*E%ywER48$+-t( z%3==0i)EC%{^p9ix8~06-h5}rvR+Y}SJGNX(nPh7{7cnZTLkHExOickH1ti2gN4Y) z&nM6a`?vlsi-iX|>}@F)(Y}I#N^P*Tk2r7*o@!ZZ%~{&K4f=}C=wQjGuSmTPGp#5m z{Y?!&<#)cCd3h6UesqGs;;0QEy<>!QgL%2@Lt|Gz^<&2P^&_+Q2A}j->Jfk;XuweG zb^{Ya9a{d5f9#thNQ;m11=mH%4b}{2Q-_bG$W ztT#~k9lGFI`sdH8n3qSD8MH zC6`%zg%~%){5b9Uw8;;wx|YRkA1Ia}t>a}_IKyJ&&z3X7uB3XI#Vc`{D(0M^R_sZG zyhX9n<0oYXf3ftTwS&3}pF0>ZSm>hfV8CF38+(U!4AS2>6Iml8^vbP?>PInr67WL` zbxi1sC4Iw*Z*%8OQZ2HV;wGA=a;U6c6%}A?%FU)R=2PJ3@ z{pBZV9h)v)ek+f@h^OBc(ENWqGaxG^249x8xsM9#S$;o4d~_d&6a95un~vQW^h5TV z50RJlz4*{KaZ)iu}eIP8}E2TLdYWi5+($4;@m{^RgJSV(%F>+{4bZ0%0^ zYgvv4t!}&R(BuAAjJ{0p!xDcZPxwAU$LlY0*|H?s>-}}PBGxFmiW;3I>pF_jzZHjH*F=P~z>% delta 28201 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 diff --git a/docs/superpowers/plans/2026-05-13-react-ssr-dashboard.md b/docs/superpowers/plans/2026-05-13-react-ssr-dashboard.md new file mode 100644 index 0000000..0a5bda3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-react-ssr-dashboard.md @@ -0,0 +1,60 @@ +# React SSR Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace static client-rendered homepage with React server-side rendering while keeping live WebSocket/voice behavior as progressive enhancement. + +**Architecture:** Express `GET /` builds dashboard data, renders React component to HTML with `react-dom/server`, injects bootstrap JSON for client script. CSS/JS move to static assets; React owns initial markup only, lightweight browser JS handles tab switching, voice bridge, WebSocket updates, and async thread discovery. + +**Tech Stack:** React, ReactDOM server, Bun, Express, TypeScript, vanilla browser JS for progressive enhancement. + +--- + +### Task 1: Add React dependencies + +**Files:** +- Modify: `package.json` +- Modify: `bun.lockb` + +- [ ] Run `bun add react react-dom`. +- [ ] Run `bun add -d @types/react @types/react-dom`. +- [ ] Verify `bun run typecheck`. + +### Task 2: Extract dashboard assets + +**Files:** +- Create: `public/dashboard.css` +- Create: `public/dashboard.js` +- Modify: `public/index.html` + +- [ ] Move current ` - - -

-
-
-
Discord moderation command center
-

Voice. Text. One Watch Floor.

-

Single-page watcher for live voice bridge and captured Discord messages, including stickers, embeds, replies, and uploaded image evidence inline.

-
-
-
WebSocketConnecting
-
Voice LinkNot connected
-
Active TabVoice
-
-
- - - -
- -
-
-
-

Voice Control

bridge
-
- - -
-
- - -
-
- - -
-
Idle
-
- -
-

Live Audio

speaker off
-
- - -
-
-
-
- -
-

Participants

speaking now
-
-
-
- -
-
-

Text Watch

create / edit / delete
-
-
-
-
- - - - + diff --git a/src/recorder.ts b/src/recorder.ts index 8c9b0ef..68aa0d0 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -176,6 +176,7 @@ export async function startRecording( onPacket: (chunk) => { if (chunk.length < 8) return; segmentManager.rotateIfNeeded(oggPacketStream); + broadcaster.broadcastOpusToWeb?.(chunk, userId); if (!broadcaster.broadcastPcmToWeb) return; decoder.rotateIfNeeded(); decoder.write(chunk); diff --git a/src/types.ts b/src/types.ts index 4c8ff95..0358ad2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,7 @@ export interface SegmentMetadata extends UserMetadata { export interface PcmBroadcaster { broadcastPcmToWeb?: (chunk: Buffer, userId: string) => void; + broadcastOpusToWeb?: (chunk: Buffer, userId: string) => void; updateActiveUser?: ( userId: string, data: { username: string; avatar: string; speaking: boolean }, diff --git a/src/web/dashboardPage.tsx b/src/web/dashboardPage.tsx new file mode 100644 index 0000000..6bd5d11 --- /dev/null +++ b/src/web/dashboardPage.tsx @@ -0,0 +1,217 @@ +import { renderToString } from "react-dom/server"; +import type { MessageRecord } from "../moderation/types"; +import type { ChannelSummary, GuildSummary, VoiceChannelSummary, VoiceStatus } from "../voiceController"; + +interface DashboardProps { + guilds: GuildSummary[]; + voiceChannels: VoiceChannelSummary[]; + watchChannels: ChannelSummary[]; + selectedGuildId: string; + selectedChannelId: string; + messages: MessageRecord[]; + status: VoiceStatus; +} + +function parseMetadata(value: string | null): any { + if (!value) return {}; + try { + return JSON.parse(value); + } catch { + return {}; + } +} + +function safeJson(value: unknown): string { + return JSON.stringify(value).replace(/ +
+
+
+ {message.avatar_url ? : null} +
+
{message.username || message.user_id}
+
+
{new Date(message.created_at).toLocaleString()}
+
+ +
{content}
+ + {metadata.stickers?.length ? ( +
+ {metadata.stickers.map((sticker: any) => ( + {sticker.name} + ))} +
+ ) : null} + + {metadata.embeds?.length ? ( +
+ {metadata.embeds.map((embed: any, index: number) => ( +
+ {embed.title ? ( + embed.url ? ( + {embed.title} + ) : ( +
{embed.title}
+ ) + ) : null} + {embed.description ?
{embed.description}
: null} + {embed.fields?.map((field: any, fieldIndex: number) => ( +
{field.name}: {field.value}
+ ))} + {embed.image || embed.thumbnail ? ( + {embed.title + ) : null} +
+ ))} +
+ ) : null} + + {metadata.attachments?.length ? ( +
+ {metadata.attachments.map((attachment: any) => ( + + {attachment.name} ({(attachment.size / 1024).toFixed(1)}KB) + + ))} +
+ ) : null} + +
+ {metadata.reference?.messageId ? reply : null} + {message.thread_id ? ( + {metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : "thread"} + ) : null} + {message.edited_at ? edited : null} + {message.deleted_at ? deleted : null} +
+ + ); +} + +function DashboardPage(props: DashboardProps) { + return ( +
+
+
+
Discord moderation command center
+

Voice. Text. One Watch Floor.

+

Single-page watcher for live voice bridge and captured Discord messages, including stickers, embeds, replies, and uploaded image evidence inline.

+
+
+
WebSocketConnecting
+
Voice Link{props.status.connected ? props.status.activeChannelName || "Connected" : "Not connected"}
+
Active TabVoice
+
+
+ + + +
+ +
+
+
+

Voice Control

bridge
+
+ + +
+
+ + +
+
+ + +
+
{props.status.connected ? `Connected to ${props.status.activeChannelName}` : "Idle"}
+
+ +
+

Live Audio

speaker off
+
+ + +
+
+
+
+ +
+

Participants

speaking now
+
+
+
+ +
+
+

Text Watch

create / edit / delete
+
+ {!props.selectedChannelId ?
Select channel to view text captures
: null} + {props.selectedChannelId && props.messages.length === 0 ?
No text captures yet
: null} + {props.messages.map((message) => )} +
+
+
+
+ ); +} + +export function renderDashboardPage(props: DashboardProps): string { + const app = renderToString(); + const bootstrap = safeJson({ + guilds: props.guilds, + voiceChannels: props.voiceChannels, + watchChannels: props.watchChannels, + selectedGuildId: props.selectedGuildId, + selectedChannelId: props.selectedChannelId, + messages: props.messages, + status: props.status, + }); + + return ` + + + + + Discord Moderation Watcher + + + + + + +
${app}
+ + + +`; +} diff --git a/src/webserver.ts b/src/webserver.ts index 19565c0..47fd47d 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -10,6 +10,7 @@ import { AppError } from "./errors"; import { createChildLogger, logger } from "./logger"; import { getMetrics, uptimeGauge } from "./metrics"; import { discordPlayer } from "./player"; +import { renderDashboardPage } from "./web/dashboardPage"; import type { VoiceController } from "./voiceController"; import { getDatabase } from "./muxer-queue"; import { getMessagesByChannel, getAttachmentsByChannel } from "./moderation/messageStore"; @@ -70,6 +71,39 @@ export function startWebserver( app.use(pinoHttp({ logger })); app.use(express.json()); + app.get("/", async (req, res, next) => { + try { + const guilds = voiceController.listGuilds(); + const selectedGuildId = + typeof req.query.guild === "string" ? req.query.guild : guilds[0]?.id || ""; + const selectedChannelId = + typeof req.query.channel === "string" ? req.query.channel : ""; + const [voiceChannels, watchChannels] = selectedGuildId + ? await Promise.all([ + voiceController.listVoiceChannels(selectedGuildId), + voiceController.listWatchableChannels(selectedGuildId), + ]) + : [[], []]; + const messages = selectedChannelId + ? getMessagesByChannel(getDatabase(), selectedChannelId, 80, 0) + : []; + + res.type("html").send( + renderDashboardPage({ + guilds, + voiceChannels, + watchChannels, + selectedGuildId, + selectedChannelId, + messages, + status: voiceController.getStatus(), + }), + ); + } catch (error) { + next(error); + } + }); + app.use(express.static(path.join(__dirname, "../public"))); // Health check endpoint @@ -204,6 +238,22 @@ export function startWebserver( }); }; + // Inbound: Discord Opus → tagged chunks → browser (WebCodecs decode) + (global as any).broadcastOpusToWeb = (chunk: Buffer, userId: string) => { + let hash = 0; + for (let i = 0; i < userId.length; i++) { + hash = (hash << 5) - hash + userId.charCodeAt(i); + hash |= 0; + } + const header = Buffer.alloc(5); + header.writeUInt8(1, 0); // mode: 1 = Opus + header.writeInt32LE(hash, 1); + const packet = Buffer.concat([header, chunk]); + wsClients.forEach((client) => { + if (client.readyState === 1) client.send(packet); + }); + }; + (global as any).updateActiveUser = ( userId: string, data: { username: string; avatar: string; speaking: boolean }, diff --git a/tsconfig.json b/tsconfig.json index b648b77..74fc610 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,8 @@ "outDir": "dist", "rootDir": "src", "experimentalDecorators": true, - "emitDecoratorMetadata": true + "emitDecoratorMetadata": true, + "jsx": "react-jsx" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]