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
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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(/</g, "\\u003c");
|
||||
}
|
||||
|
||||
function MessageCard({ message }: { message: MessageRecord }) {
|
||||
const metadata = parseMetadata(message.metadata);
|
||||
const content = message.edited_content || message.content || "(empty message)";
|
||||
|
||||
return (
|
||||
<article className="event-card" data-message-id={message.id}>
|
||||
<div className="event-head">
|
||||
<div className="author">
|
||||
<div className="avatar">
|
||||
{message.avatar_url ? <img src={message.avatar_url} alt="" /> : null}
|
||||
</div>
|
||||
<div className="name">{message.username || message.user_id}</div>
|
||||
</div>
|
||||
<div className="time">{new Date(message.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
|
||||
<div className="message-text">{content}</div>
|
||||
|
||||
{metadata.stickers?.length ? (
|
||||
<div className="sticker-strip">
|
||||
{metadata.stickers.map((sticker: any) => (
|
||||
<img key={sticker.id} className="sticker-img" src={sticker.url} alt={sticker.name} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{metadata.embeds?.length ? (
|
||||
<div className="feed">
|
||||
{metadata.embeds.map((embed: any, index: number) => (
|
||||
<div key={index} className="embed-card">
|
||||
{embed.title ? (
|
||||
embed.url ? (
|
||||
<a className="embed-title" href={embed.url} target="_blank" rel="noreferrer">{embed.title}</a>
|
||||
) : (
|
||||
<div className="embed-title">{embed.title}</div>
|
||||
)
|
||||
) : null}
|
||||
{embed.description ? <div className="embed-description">{embed.description}</div> : null}
|
||||
{embed.fields?.map((field: any, fieldIndex: number) => (
|
||||
<div key={fieldIndex} className="embed-description">{field.name}: {field.value}</div>
|
||||
))}
|
||||
{embed.image || embed.thumbnail ? (
|
||||
<img className="embed-image" src={embed.image || embed.thumbnail} alt={embed.title || "embed image"} />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{metadata.attachments?.length ? (
|
||||
<div className="attachment-strip">
|
||||
{metadata.attachments.map((attachment: any) => (
|
||||
<a key={attachment.id} className="attachment-chip" href={attachment.url} target="_blank" rel="noreferrer">
|
||||
{attachment.name} ({(attachment.size / 1024).toFixed(1)}KB)
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="badges">
|
||||
{metadata.reference?.messageId ? <span className="badge">reply</span> : null}
|
||||
{message.thread_id ? (
|
||||
<span className="badge">{metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : "thread"}</span>
|
||||
) : null}
|
||||
{message.edited_at ? <span className="badge edit">edited</span> : null}
|
||||
{message.deleted_at ? <span className="badge delete">deleted</span> : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardPage(props: DashboardProps) {
|
||||
return (
|
||||
<main className="shell">
|
||||
<section className="hero">
|
||||
<div className="brand-card">
|
||||
<div className="eyebrow"><span className="pulse" /> Discord moderation command center</div>
|
||||
<h1>Voice. Text. One Watch Floor.</h1>
|
||||
<p className="subtitle">Single-page watcher for live voice bridge and captured Discord messages, including stickers, embeds, replies, and uploaded image evidence inline.</p>
|
||||
</div>
|
||||
<div className="status-card">
|
||||
<div className="status-row"><span className="status-label">WebSocket</span><span className="status-value"><span id="wsDot" className="dot" /><span id="wsStatusText">Connecting</span></span></div>
|
||||
<div className="status-row"><span className="status-label">Voice Link</span><span id="voiceStatusText" className="status-value">{props.status.connected ? props.status.activeChannelName || "Connected" : "Not connected"}</span></div>
|
||||
<div className="status-row"><span className="status-label">Active Tab</span><span id="activeTabLabel" className="status-value">Voice</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav className="tab-panel">
|
||||
<div className="tabs">
|
||||
<button className="tab-btn active" data-tab="voice">Voice</button>
|
||||
<button className="tab-btn" data-tab="text">Text</button>
|
||||
</div>
|
||||
<div className="filter-row">
|
||||
<span>Channel / Thread</span>
|
||||
<select id="channelFilter" defaultValue={props.selectedChannelId}>
|
||||
<option value="">Select channel</option>
|
||||
{props.watchChannels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>{channel.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div id="errorBox" className="error" />
|
||||
|
||||
<section id="voice" className="tab-content active">
|
||||
<div className="voice-layout">
|
||||
<div className="content-card">
|
||||
<div className="card-title"><h2>Voice Control</h2><span className="mini">bridge</span></div>
|
||||
<div className="field-group">
|
||||
<label htmlFor="guildSelect">Guild</label>
|
||||
<select id="guildSelect" defaultValue={props.selectedGuildId}>
|
||||
<option value="">Select guild</option>
|
||||
{props.guilds.map((guild) => <option key={guild.id} value={guild.id}>{guild.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field-group">
|
||||
<label htmlFor="channelSelect">Voice Channel</label>
|
||||
<select id="channelSelect">
|
||||
<option value="">Select voice channel</option>
|
||||
{props.voiceChannels.map((channel) => <option key={channel.id} value={channel.id}>{channel.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button id="joinVoiceBtn" className="btn btn-success">Join</button>
|
||||
<button id="disconnectVoiceBtn" className="btn btn-danger">Disconnect</button>
|
||||
</div>
|
||||
<div className="voice-status" id="voiceStatusNote">{props.status.connected ? `Connected to ${props.status.activeChannelName}` : "Idle"}</div>
|
||||
</div>
|
||||
|
||||
<div className="content-card">
|
||||
<div className="card-title"><h2>Live Audio</h2><span className="mini" id="listenStatus">speaker off</span></div>
|
||||
<div style={{ display: "grid", gap: 12, gridTemplateColumns: "1fr 1fr", marginBottom: 14 }}>
|
||||
<button id="toggleBtn" className="btn btn-primary">Start Transmitting</button>
|
||||
<button id="listenBtn" className="btn btn-success">Join Listen Channel</button>
|
||||
</div>
|
||||
<div className="visualizer" id="visualizer" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content-card" style={{ marginTop: 18 }}>
|
||||
<div className="card-title"><h2>Participants</h2><span className="mini">speaking now</span></div>
|
||||
<div id="userList" className="participants" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="text" className="tab-content">
|
||||
<div className="content-card">
|
||||
<div className="card-title"><h2>Text Watch</h2><span className="mini">create / edit / delete</span></div>
|
||||
<div id="textList" className="feed">
|
||||
{!props.selectedChannelId ? <div className="empty">Select channel to view text captures</div> : null}
|
||||
{props.selectedChannelId && props.messages.length === 0 ? <div className="empty">No text captures yet</div> : null}
|
||||
{props.messages.map((message) => <MessageCard key={message.id} message={message} />)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderDashboardPage(props: DashboardProps): string {
|
||||
const app = renderToString(<DashboardPage {...props} />);
|
||||
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 `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Discord Moderation Watcher</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=JetBrains+Mono:wght@400;600;700&family=Manrope:wght@500;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/dashboard.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">${app}</div>
|
||||
<script id="__DASHBOARD_DATA__" type="application/json">${bootstrap}</script>
|
||||
<script src="/dashboard.js" defer></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user