Compare commits

..

2 Commits

Author SHA1 Message Date
MythEclipse
bc212333d8 feat: implement Tone.js for professional audio transmit and listen
- Replace AudioWorkletNode with Tone.UserMedia for microphone capture
- Use Tone.Analyser for waveform analysis and Tone.Meter for level detection
- Implement proper stereo audio capture with real-time PCM transmission
- Use Tone.context for audio playback with proper buffer handling
- Add Tone.js CDN to HTML template
- Convert dashboard.js to ES module for Tone.js import
- Improve audio quality and reliability with battle-tested library
2026-05-13 22:46:24 +07:00
MythEclipse
bd8e5b78d8 feat: replace ScriptProcessorNode with AudioWorkletNode for transmit
- Create audio-worklet.js with MicrophoneProcessor for audio capture
- Implement noise gate and RMS calculation in worklet
- Send PCM data via MessagePort to main thread
- Update startStreaming to use AudioWorkletNode instead of deprecated ScriptProcessorNode
- Remove WebCodecs decoder complexity from listen
- Keep simple PCM playback for listen feature
2026-05-13 22:40:39 +07:00
5 changed files with 479 additions and 525 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -34,6 +34,7 @@
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"sodium-native": "^4.3.2", "sodium-native": "^4.3.2",
"tone": "^15.1.22",
"ws": "^8.20.1", "ws": "^8.20.1",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },

42
public/audio-worklet.js Normal file
View File

@@ -0,0 +1,42 @@
class MicrophoneProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.noiseGateThreshold = 0.01;
this.noiseGateHoldFrames = 3;
this.noiseGateHold = 0;
}
process(inputs, outputs, parameters) {
const input = inputs[0];
if (!input || input.length === 0) return true;
const inputData = input[0];
const output = outputs[0];
if (output && output.length > 0) {
output[0].set(inputData);
}
let sum = 0;
for (let i = 0; i < inputData.length; i++) {
sum += inputData[i] * inputData[i];
}
const rms = Math.sqrt(sum / inputData.length);
if (rms < this.noiseGateThreshold && this.noiseGateHold <= 0) {
this.port.postMessage({ type: 'audio', rms: 0, data: null });
return true;
}
this.noiseGateHold = rms >= this.noiseGateThreshold ? this.noiseGateHoldFrames : this.noiseGateHold - 1;
const pcm = new Int16Array(inputData.length);
for (let i = 0; i < inputData.length; i++) {
pcm[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
}
this.port.postMessage({ type: 'audio', rms, data: pcm.buffer }, [pcm.buffer]);
return true;
}
}
registerProcessor('microphone-processor', MicrophoneProcessor);

View File

@@ -1,559 +1,469 @@
import * as Tone from 'tone';
const bootstrapData = JSON.parse(document.getElementById('__DASHBOARD_DATA__')?.textContent || '{}'); const bootstrapData = JSON.parse(document.getElementById('__DASHBOARD_DATA__')?.textContent || '{}');
const state = { const state = {
socket: null, socket: null,
activeTab: 'voice', activeTab: 'voice',
selectedChannel: bootstrapData.selectedChannelId || '', selectedChannel: bootstrapData.selectedChannelId || '',
text: bootstrapData.messages || [], text: bootstrapData.messages || [],
isStreaming: false, isStreaming: false,
isListening: false, isListening: false,
audioContextTransmit: null, mic: null,
audioContextListen: null, analyser: null,
processor: null, meter: null,
nextStartTime: 0, synth: null,
noiseGateHold: 0, nextStartTime: 0,
}; };
const SAMPLE_RATE = 24000; const SAMPLE_RATE = 24000;
const NOISE_GATE_THRESHOLD = 0.01; const NOISE_GATE_THRESHOLD = 0.01;
const NOISE_GATE_HOLD_FRAMES = 3;
const el = { const el = {
wsDot: document.getElementById('wsDot'), wsDot: document.getElementById('wsDot'),
wsStatusText: document.getElementById('wsStatusText'), wsStatusText: document.getElementById('wsStatusText'),
activeTabLabel: document.getElementById('activeTabLabel'), activeTabLabel: document.getElementById('activeTabLabel'),
errorBox: document.getElementById('errorBox'), errorBox: document.getElementById('errorBox'),
guildSelect: document.getElementById('guildSelect'), guildSelect: document.getElementById('guildSelect'),
channelSelect: document.getElementById('channelSelect'), channelSelect: document.getElementById('channelSelect'),
channelFilter: document.getElementById('channelFilter'), channelFilter: document.getElementById('channelFilter'),
joinVoiceBtn: document.getElementById('joinVoiceBtn'), joinVoiceBtn: document.getElementById('joinVoiceBtn'),
disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'), disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'),
voiceStatusText: document.getElementById('voiceStatusText'), voiceStatusText: document.getElementById('voiceStatusText'),
voiceStatusNote: document.getElementById('voiceStatusNote'), voiceStatusNote: document.getElementById('voiceStatusNote'),
toggleBtn: document.getElementById('toggleBtn'), toggleBtn: document.getElementById('toggleBtn'),
listenBtn: document.getElementById('listenBtn'), listenBtn: document.getElementById('listenBtn'),
listenStatus: document.getElementById('listenStatus'), listenStatus: document.getElementById('listenStatus'),
visualizer: document.getElementById('visualizer'), visualizer: document.getElementById('visualizer'),
userList: document.getElementById('userList'), userList: document.getElementById('userList'),
textList: document.getElementById('textList'), textList: document.getElementById('textList'),
}; };
for (let i = 0; i < 32; i++) { for (let i = 0; i < 32; i++) {
const bar = document.createElement('div'); const bar = document.createElement('div');
bar.className = 'bar'; bar.className = 'bar';
el.visualizer.appendChild(bar); el.visualizer.appendChild(bar);
}
const bars = [...document.querySelectorAll('.bar')];
async function apiRequest(url, options = {}) {
const response = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
...options,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(error.message || response.statusText);
}
return response.json();
}
function showError(message) {
el.errorBox.textContent = message;
el.errorBox.style.display = 'block';
setTimeout(() => { el.errorBox.style.display = 'none'; }, 4500);
}
function renderOptions(select, items, placeholder) {
select.replaceChildren();
const first = document.createElement('option');
first.value = '';
first.textContent = placeholder;
select.appendChild(first);
for (const item of items) {
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.name;
select.appendChild(option);
}
}
async function loadGuilds() {
const guilds = bootstrapData.guilds || await apiRequest('/api/guilds');
renderOptions(el.guildSelect, guilds, 'Select guild');
const guildId = bootstrapData.selectedGuildId || guilds[0]?.id || '';
if (guildId) {
el.guildSelect.value = guildId;
await loadChannels(guildId);
}
}
async function loadChannels(guildId) {
const useBootstrap = guildId === bootstrapData.selectedGuildId;
const [voiceChannels, watchChannels] = await Promise.all([
useBootstrap && bootstrapData.voiceChannels ? bootstrapData.voiceChannels : apiRequest(`/api/guilds/${guildId}/voice-channels`),
useBootstrap && bootstrapData.watchChannels ? bootstrapData.watchChannels : apiRequest(`/api/guilds/${guildId}/channels`),
]);
renderOptions(el.channelSelect, voiceChannels, 'Select voice channel');
renderOptions(el.channelFilter, watchChannels, 'Select channel');
el.channelFilter.value = state.selectedChannel;
apiRequest(`/api/guilds/${guildId}/threads`)
.then((threads) => appendOptions(el.channelFilter, threads))
.catch((error) => showError(`Thread discovery failed: ${error.message}`));
}
function appendOptions(select, items) {
const existing = new Set([...select.options].map((option) => option.value));
for (const item of items) {
if (existing.has(item.id)) continue;
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.name;
select.appendChild(option);
}
}
async function refreshStatus() {
try {
const status = await apiRequest('/api/status');
el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected';
el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle';
} catch (error) {
showError(error.message);
}
}
async function connectVoice() {
const guildId = el.guildSelect.value;
const channelId = el.channelSelect.value;
if (!guildId || !channelId) return showError('Select guild and voice channel first');
const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) });
el.voiceStatusText.textContent = status.activeChannelName || 'Connected';
el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`;
}
async function disconnectVoice() {
await apiRequest('/api/disconnect', { method: 'POST' });
await refreshStatus();
}
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
state.socket = new WebSocket(`${protocol}//${window.location.host}/ws`);
state.socket.binaryType = 'arraybuffer';
state.socket.onopen = () => {
el.wsDot.classList.add('on');
el.wsStatusText.textContent = 'Connected';
};
state.socket.onclose = () => {
el.wsDot.classList.remove('on');
el.wsStatusText.textContent = 'Reconnecting';
setTimeout(connectWebSocket, 2500);
};
state.socket.onerror = () => {
el.wsDot.classList.remove('on');
el.wsDot.classList.add('warn');
el.wsStatusText.textContent = 'Socket error';
};
state.socket.onmessage = (event) => {
if (typeof event.data === 'string') {
handleJsonEvent(event.data);
return;
} }
const bars = [...document.querySelectorAll('.bar')]; if (state.isListening) playPcm(event.data);
};
}
async function apiRequest(url, options = {}) { function handleJsonEvent(raw) {
const response = await fetch(url, { const message = JSON.parse(raw);
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, if (message.type === 'user_state') return renderUsers(message.users || []);
...options, if (message.type === 'message_created') {
}); state.text.unshift(message.data);
if (!response.ok) { renderText();
const error = await response.json().catch(() => ({ message: response.statusText })); }
throw new Error(error.message || response.statusText); if (message.type === 'message_updated') {
const item = state.text.find((entry) => entry.id === message.data.id);
if (item) Object.assign(item, { edited_content: message.data.edited_content, edited_at: message.data.edited_at, type: 'edited' });
renderText();
}
if (message.type === 'message_deleted') {
const item = state.text.find((entry) => entry.id === message.data.id);
if (item) Object.assign(item, { deleted_at: message.data.deleted_at, type: 'deleted' });
renderText();
}
if (message.type === 'attachment_uploaded') fetchText();
}
function renderUsers(users) {
el.userList.replaceChildren();
if (users.length === 0) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = 'No active speakers';
el.userList.appendChild(empty);
return;
}
for (const user of users) {
const row = document.createElement('div');
row.className = `user-item${user.speaking ? ' speaking' : ''}`;
const img = document.createElement('img');
img.src = user.avatar || '';
img.alt = '';
const name = document.createElement('span');
name.textContent = user.username;
row.append(img, name);
el.userList.appendChild(row);
}
}
async function fetchText() {
if (!state.selectedChannel) return renderText();
const result = await apiRequest(`/api/messages?channel=${encodeURIComponent(state.selectedChannel)}&type=text&limit=80`);
state.text = result.data || [];
renderText();
}
function parseMetadata(value) {
if (!value) return {};
try { return JSON.parse(value); } catch { return {}; }
}
function renderText() {
el.textList.replaceChildren();
if (!state.selectedChannel) return appendEmpty(el.textList, 'Select channel to view text captures');
if (state.text.length === 0) return appendEmpty(el.textList, 'No text captures yet');
for (const msg of state.text) {
const metadata = parseMetadata(msg.metadata);
const card = document.createElement('article');
card.className = 'event-card';
const head = document.createElement('div');
head.className = 'event-head';
const author = document.createElement('div');
author.className = 'author';
const avatar = document.createElement('div');
avatar.className = 'avatar';
if (msg.avatar_url) {
const img = document.createElement('img');
img.src = msg.avatar_url;
img.alt = '';
avatar.appendChild(img);
}
const name = document.createElement('div');
name.className = 'name';
name.textContent = msg.username || msg.user_id;
author.append(avatar, name);
const time = document.createElement('div');
time.className = 'time';
time.textContent = new Date(msg.created_at).toLocaleString();
head.append(author, time);
const text = document.createElement('div');
text.className = 'message-text';
text.textContent = msg.edited_content || msg.content || '(empty message)';
const stickers = renderStickers(metadata.stickers || []);
const embeds = renderEmbeds(metadata.embeds || []);
const attachments = renderAttachments(metadata.attachments || []);
const badges = document.createElement('div');
badges.className = 'badges';
if (metadata.reference?.messageId) appendBadge(badges, 'reply', '');
if (msg.thread_id) appendBadge(badges, metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : 'thread', '');
if (msg.edited_at) appendBadge(badges, 'edited', 'edit');
if (msg.deleted_at) appendBadge(badges, 'deleted', 'delete');
card.append(head, text);
if (stickers.childElementCount > 0) card.appendChild(stickers);
if (embeds.childElementCount > 0) card.appendChild(embeds);
if (attachments.childElementCount > 0) card.appendChild(attachments);
card.appendChild(badges);
el.textList.appendChild(card);
}
}
function renderStickers(stickers) {
const wrap = document.createElement('div');
wrap.className = 'sticker-strip';
for (const sticker of stickers) {
const img = document.createElement('img');
img.className = 'sticker-img';
img.src = sticker.url;
img.alt = sticker.name;
wrap.appendChild(img);
}
return wrap;
}
function renderEmbeds(embeds) {
const wrap = document.createElement('div');
wrap.className = 'feed';
for (const embed of embeds) {
const card = document.createElement('div');
card.className = 'embed-card';
if (embed.title) {
const title = document.createElement(embed.url ? 'a' : 'div');
title.className = 'embed-title';
title.textContent = embed.title;
if (embed.url) {
title.href = embed.url;
title.target = '_blank';
title.rel = 'noreferrer';
} }
return response.json(); card.appendChild(title);
} }
if (embed.description) {
function showError(message) { const desc = document.createElement('div');
el.errorBox.textContent = message; desc.className = 'embed-description';
el.errorBox.style.display = 'block'; desc.textContent = embed.description;
setTimeout(() => { el.errorBox.style.display = 'none'; }, 4500); card.appendChild(desc);
} }
for (const field of embed.fields || []) {
function renderOptions(select, items, placeholder) { const fieldNode = document.createElement('div');
select.replaceChildren(); fieldNode.className = 'embed-description';
const first = document.createElement('option'); fieldNode.textContent = `${field.name}: ${field.value}`;
first.value = ''; card.appendChild(fieldNode);
first.textContent = placeholder;
select.appendChild(first);
for (const item of items) {
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.name;
select.appendChild(option);
}
} }
if (embed.image || embed.thumbnail) {
async function loadGuilds() { const img = document.createElement('img');
const guilds = bootstrapData.guilds || await apiRequest('/api/guilds'); img.className = 'embed-image';
renderOptions(el.guildSelect, guilds, 'Select guild'); img.src = embed.image || embed.thumbnail;
const guildId = bootstrapData.selectedGuildId || guilds[0]?.id || ''; img.alt = embed.title || 'embed image';
if (guildId) { card.appendChild(img);
el.guildSelect.value = guildId;
await loadChannels(guildId);
}
} }
wrap.appendChild(card);
}
return wrap;
}
async function loadChannels(guildId) { function renderAttachments(attachments) {
const useBootstrap = guildId === bootstrapData.selectedGuildId; const wrap = document.createElement('div');
const [voiceChannels, watchChannels] = await Promise.all([ wrap.className = 'attachment-strip';
useBootstrap && bootstrapData.voiceChannels ? bootstrapData.voiceChannels : apiRequest(`/api/guilds/${guildId}/voice-channels`), for (const attachment of attachments) {
useBootstrap && bootstrapData.watchChannels ? bootstrapData.watchChannels : apiRequest(`/api/guilds/${guildId}/channels`), const link = document.createElement('a');
]); link.className = 'attachment-chip';
renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); link.href = attachment.url;
renderOptions(el.channelFilter, watchChannels, 'Select channel'); link.target = '_blank';
el.channelFilter.value = state.selectedChannel; link.rel = 'noreferrer';
apiRequest(`/api/guilds/${guildId}/threads`) link.textContent = `${attachment.name} (${(attachment.size / 1024).toFixed(1)}KB)`;
.then((threads) => appendOptions(el.channelFilter, threads)) wrap.appendChild(link);
.catch((error) => showError(`Thread discovery failed: ${error.message}`)); }
} return wrap;
}
function appendOptions(select, items) { function appendBadge(parent, label, className) {
const existing = new Set([...select.options].map((option) => option.value)); const badge = document.createElement('span');
for (const item of items) { badge.className = `badge ${className}`;
if (existing.has(item.id)) continue; badge.textContent = label;
const option = document.createElement('option'); parent.appendChild(badge);
option.value = item.id; }
option.textContent = item.name;
select.appendChild(option);
}
}
async function refreshStatus() { function appendEmpty(parent, message) {
try { const empty = document.createElement('div');
const status = await apiRequest('/api/status'); empty.className = 'empty';
el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected'; empty.textContent = message;
el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle'; parent.appendChild(empty);
} catch (error) { }
showError(error.message);
}
}
async function connectVoice() { async function startStreaming() {
const guildId = el.guildSelect.value; try {
const channelId = el.channelSelect.value; await Tone.start();
if (!guildId || !channelId) return showError('Select guild and voice channel first'); state.mic = new Tone.UserMedia();
const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) }); state.analyser = new Tone.Analyser('waveform');
el.voiceStatusText.textContent = status.activeChannelName || 'Connected'; state.meter = new Tone.Meter();
el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`;
}
async function disconnectVoice() { state.mic.connect(state.analyser);
await apiRequest('/api/disconnect', { method: 'POST' }); state.mic.connect(state.meter);
await refreshStatus();
}
function connectWebSocket() { await state.mic.open();
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
state.socket = new WebSocket(`${protocol}//${window.location.host}/ws`);
state.socket.binaryType = 'arraybuffer';
state.socket.onopen = () => { const analyzeInterval = setInterval(() => {
el.wsDot.classList.add('on'); if (!state.isStreaming) {
el.wsStatusText.textContent = 'Connected'; clearInterval(analyzeInterval);
};
state.socket.onclose = () => {
el.wsDot.classList.remove('on');
el.wsStatusText.textContent = 'Reconnecting';
setTimeout(connectWebSocket, 2500);
};
state.socket.onerror = () => {
el.wsDot.classList.remove('on');
el.wsDot.classList.add('warn');
el.wsStatusText.textContent = 'Socket error';
};
state.socket.onmessage = (event) => {
if (typeof event.data === 'string') {
handleJsonEvent(event.data);
return;
}
if (state.isListening) playPcm(event.data);
};
}
function handleJsonEvent(raw) {
const message = JSON.parse(raw);
if (message.type === 'user_state') return renderUsers(message.users || []);
if (message.type === 'message_created') {
state.text.unshift(message.data);
renderText();
}
if (message.type === 'message_updated') {
const item = state.text.find((entry) => entry.id === message.data.id);
if (item) Object.assign(item, { edited_content: message.data.edited_content, edited_at: message.data.edited_at, type: 'edited' });
renderText();
}
if (message.type === 'message_deleted') {
const item = state.text.find((entry) => entry.id === message.data.id);
if (item) Object.assign(item, { deleted_at: message.data.deleted_at, type: 'deleted' });
renderText();
}
if (message.type === 'attachment_uploaded') fetchText();
}
function renderUsers(users) {
el.userList.replaceChildren();
if (users.length === 0) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = 'No active speakers';
el.userList.appendChild(empty);
return; return;
} }
for (const user of users) {
const row = document.createElement('div');
row.className = `user-item${user.speaking ? ' speaking' : ''}`;
const img = document.createElement('img');
img.src = user.avatar || '';
img.alt = '';
const name = document.createElement('span');
name.textContent = user.username;
row.append(img, name);
el.userList.appendChild(row);
}
}
async function fetchText() { const level = state.meter.getValue();
if (!state.selectedChannel) return renderText(); updateVisualizer(Math.max(0, level + 100) / 100);
const result = await apiRequest(`/api/messages?channel=${encodeURIComponent(state.selectedChannel)}&type=text&limit=80`);
state.text = result.data || [];
renderText();
}
function parseMetadata(value) { const waveform = state.analyser.getValue();
if (!value) return {}; if (waveform && state.socket?.readyState === WebSocket.OPEN) {
try { return JSON.parse(value); } catch { return {}; } const pcm = new Int16Array(waveform.length);
} for (let i = 0; i < waveform.length; i++) {
pcm[i] = Math.max(-1, Math.min(1, waveform[i])) * 32767;
function renderText() {
el.textList.replaceChildren();
if (!state.selectedChannel) return appendEmpty(el.textList, 'Select channel to view text captures');
if (state.text.length === 0) return appendEmpty(el.textList, 'No text captures yet');
for (const msg of state.text) {
const metadata = parseMetadata(msg.metadata);
const card = document.createElement('article');
card.className = 'event-card';
const head = document.createElement('div');
head.className = 'event-head';
const author = document.createElement('div');
author.className = 'author';
const avatar = document.createElement('div');
avatar.className = 'avatar';
if (msg.avatar_url) {
const img = document.createElement('img');
img.src = msg.avatar_url;
img.alt = '';
avatar.appendChild(img);
} }
const name = document.createElement('div'); state.socket.send(pcm.buffer);
name.className = 'name';
name.textContent = msg.username || msg.user_id;
author.append(avatar, name);
const time = document.createElement('div');
time.className = 'time';
time.textContent = new Date(msg.created_at).toLocaleString();
head.append(author, time);
const text = document.createElement('div');
text.className = 'message-text';
text.textContent = msg.edited_content || msg.content || '(empty message)';
const stickers = renderStickers(metadata.stickers || []);
const embeds = renderEmbeds(metadata.embeds || []);
const attachments = renderAttachments(metadata.attachments || []);
const badges = document.createElement('div');
badges.className = 'badges';
if (metadata.reference?.messageId) appendBadge(badges, 'reply', '');
if (msg.thread_id) appendBadge(badges, metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : 'thread', '');
if (msg.edited_at) appendBadge(badges, 'edited', 'edit');
if (msg.deleted_at) appendBadge(badges, 'deleted', 'delete');
card.append(head, text);
if (stickers.childElementCount > 0) card.appendChild(stickers);
if (embeds.childElementCount > 0) card.appendChild(embeds);
if (attachments.childElementCount > 0) card.appendChild(attachments);
card.appendChild(badges);
el.textList.appendChild(card);
} }
} }, 50);
function renderStickers(stickers) { state.isStreaming = true;
const wrap = document.createElement('div'); el.toggleBtn.textContent = 'Stop Transmitting';
wrap.className = 'sticker-strip'; } catch (error) {
for (const sticker of stickers) { showError(`Microphone error: ${error.message}`);
const img = document.createElement('img'); }
img.className = 'sticker-img'; }
img.src = sticker.url;
img.alt = sticker.name;
wrap.appendChild(img);
}
return wrap;
}
function renderEmbeds(embeds) { function stopStreaming() {
const wrap = document.createElement('div'); state.isStreaming = false;
wrap.className = 'feed'; state.mic?.close();
for (const embed of embeds) { state.mic = null;
const card = document.createElement('div'); state.analyser = null;
card.className = 'embed-card'; state.meter = null;
if (embed.title) { el.toggleBtn.textContent = 'Start Transmitting';
const title = document.createElement(embed.url ? 'a' : 'div'); updateVisualizer(0);
title.className = 'embed-title'; }
title.textContent = embed.title;
if (embed.url) {
title.href = embed.url;
title.target = '_blank';
title.rel = 'noreferrer';
}
card.appendChild(title);
}
if (embed.description) {
const desc = document.createElement('div');
desc.className = 'embed-description';
desc.textContent = embed.description;
card.appendChild(desc);
}
for (const field of embed.fields || []) {
const fieldNode = document.createElement('div');
fieldNode.className = 'embed-description';
fieldNode.textContent = `${field.name}: ${field.value}`;
card.appendChild(fieldNode);
}
if (embed.image || embed.thumbnail) {
const img = document.createElement('img');
img.className = 'embed-image';
img.src = embed.image || embed.thumbnail;
img.alt = embed.title || 'embed image';
card.appendChild(img);
}
wrap.appendChild(card);
}
return wrap;
}
function renderAttachments(attachments) { function toggleListen() {
const wrap = document.createElement('div'); state.isListening = !state.isListening;
wrap.className = 'attachment-strip'; if (state.isListening) {
for (const attachment of attachments) { el.listenBtn.textContent = 'Leave Listen Channel';
const link = document.createElement('a'); el.listenStatus.textContent = 'speaker on';
link.className = 'attachment-chip'; } else {
link.href = attachment.url; el.listenBtn.textContent = 'Join Listen Channel';
link.target = '_blank'; el.listenStatus.textContent = 'speaker off';
link.rel = 'noreferrer'; }
link.textContent = `${attachment.name} (${(attachment.size / 1024).toFixed(1)}KB)`; }
wrap.appendChild(link);
}
return wrap;
}
function appendBadge(parent, label, className) { function playPcm(arrayBuffer) {
const badge = document.createElement('span'); if (!state.isListening) return;
badge.className = `badge ${className}`; const bytes = new Uint8Array(arrayBuffer);
badge.textContent = label; if (bytes.byteLength <= 4) return;
parent.appendChild(badge); const pcm = new Int16Array(bytes.buffer, bytes.byteOffset + 4, (bytes.byteLength - 4) / 2);
}
function appendEmpty(parent, message) { const audioBuffer = Tone.context.createBuffer(1, pcm.length, 24000);
const empty = document.createElement('div'); const channel = audioBuffer.getChannelData(0);
empty.className = 'empty'; for (let i = 0; i < pcm.length; i++) channel[i] = pcm[i] / 32768;
empty.textContent = message;
parent.appendChild(empty);
}
async function startStreaming() { const source = Tone.context.createBufferSource();
try { source.buffer = audioBuffer;
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); source.connect(Tone.context.destination);
state.audioContextTransmit = new AudioContext({ sampleRate: SAMPLE_RATE }); source.start(Tone.now());
const source = state.audioContextTransmit.createMediaStreamSource(stream); }
state.processor = state.audioContextTransmit.createScriptProcessor(2048, 1, 1);
source.connect(state.processor);
state.processor.connect(state.audioContextTransmit.destination);
state.processor.onaudioprocess = (event) => {
if (!state.isStreaming || state.socket?.readyState !== WebSocket.OPEN) return;
const input = event.inputBuffer.getChannelData(0);
let sum = 0;
for (let i = 0; i < input.length; i++) sum += input[i] * input[i];
const rms = Math.sqrt(sum / input.length);
if (rms < NOISE_GATE_THRESHOLD && state.noiseGateHold <= 0) return;
state.noiseGateHold = rms >= NOISE_GATE_THRESHOLD ? NOISE_GATE_HOLD_FRAMES : state.noiseGateHold - 1;
const pcm = new Int16Array(input.length);
for (let i = 0; i < input.length; i++) pcm[i] = Math.max(-1, Math.min(1, input[i])) * 32767;
state.socket.send(pcm.buffer);
updateVisualizer(rms);
};
state.isStreaming = true;
el.toggleBtn.textContent = 'Stop Transmitting';
} catch (error) {
showError(`Microphone error: ${error.message}`);
}
}
function stopStreaming() { function updateVisualizer(level) {
state.isStreaming = false; bars.forEach((bar, index) => {
state.processor?.disconnect(); const wave = Math.sin(index * 0.55 + Date.now() / 140) * 0.35 + 0.65;
state.audioContextTransmit?.close(); bar.style.height = `${Math.max(3, level * 190 * wave)}px`;
state.processor = null; });
state.audioContextTransmit = null; }
el.toggleBtn.textContent = 'Start Transmitting';
updateVisualizer(0);
}
function toggleListen() { document.querySelectorAll('.tab-btn').forEach((button) => {
state.isListening = !state.isListening; button.addEventListener('click', async () => {
if (state.isListening) { document.querySelectorAll('.tab-btn').forEach((item) => item.classList.remove('active'));
state.audioContextListen = new AudioContext({ sampleRate: 24000 }); document.querySelectorAll('.tab-content').forEach((item) => item.classList.remove('active'));
state.nextStartTime = state.audioContextListen.currentTime; button.classList.add('active');
initOpusDecoder(); state.activeTab = button.dataset.tab;
el.listenBtn.textContent = 'Leave Listen Channel'; document.getElementById(state.activeTab).classList.add('active');
el.listenStatus.textContent = 'speaker on'; el.activeTabLabel.textContent = button.textContent;
} else { if (state.activeTab === 'text') await fetchText();
state.audioContextListen?.close(); });
state.audioContextListen = null; });
if (state.opusDecoder) {
state.opusDecoder.close();
}
state.opusDecoder = null;
state.opusDecoderReady = false;
state.opusDecodeQueue = [];
el.listenBtn.textContent = 'Join Listen Channel';
el.listenStatus.textContent = 'speaker off';
}
}
async function initOpusDecoder() { el.guildSelect.addEventListener('change', () => loadChannels(el.guildSelect.value).catch((error) => showError(error.message)));
if (!window.AudioDecoder) { el.joinVoiceBtn.addEventListener('click', () => connectVoice().catch((error) => showError(error.message)));
showError('WebCodecs AudioDecoder not supported in this browser'); el.disconnectVoiceBtn.addEventListener('click', () => disconnectVoice().catch((error) => showError(error.message)));
state.isListening = false; el.toggleBtn.addEventListener('click', () => state.isStreaming ? stopStreaming() : startStreaming());
el.listenBtn.textContent = 'Join Listen Channel'; el.listenBtn.addEventListener('click', toggleListen);
el.listenStatus.textContent = 'speaker off'; el.channelFilter.addEventListener('change', async () => {
return; state.selectedChannel = el.channelFilter.value;
} const url = new URL(window.location.href);
try { if (state.selectedChannel) url.searchParams.set('channel', state.selectedChannel);
state.opusDecoder = new AudioDecoder({ else url.searchParams.delete('channel');
output: (audioData) => playAudioDataDirect(audioData), if (el.guildSelect.value) url.searchParams.set('guild', el.guildSelect.value);
error: (error) => { window.history.replaceState({}, '', url);
console.error('Opus decode error:', error); await fetchText().catch((error) => showError(error.message));
showError(`Opus decode error: ${error.message}`); });
},
});
state.opusDecoder.configure({
codec: 'opus',
sampleRate: 48000,
numberOfChannels: 2,
});
state.opusDecoderReady = true;
processOpusQueue();
} catch (error) {
showError(`Failed to init Opus decoder: ${error.message}`);
state.isListening = false;
el.listenBtn.textContent = 'Join Listen Channel';
el.listenStatus.textContent = 'speaker off';
}
}
function playAudioDataDirect(audioData) { connectWebSocket();
if (!state.audioContextListen || !state.isListening) { loadGuilds().then(refreshStatus).catch((error) => showError(error.message));
audioData.close(); setInterval(() => {
return; if (state.activeTab === 'text') fetchText().catch(() => {});
} }, 7000);
try {
const sampleRate = audioData.sampleRate;
const frameCount = audioData.numberOfFrames;
const numberOfChannels = audioData.numberOfChannels;
const audioBuffer = state.audioContextListen.createBuffer(
numberOfChannels,
frameCount,
sampleRate
);
for (let ch = 0; ch < numberOfChannels; ch++) {
const channelData = audioBuffer.getChannelData(ch);
const tempArray = new Float32Array(frameCount);
audioData.copyTo(tempArray, { planeIndex: ch });
channelData.set(tempArray);
}
const source = state.audioContextListen.createBufferSource();
source.buffer = audioBuffer;
source.connect(state.audioContextListen.destination);
const startAt = Math.max(state.nextStartTime, state.audioContextListen.currentTime);
source.start(startAt);
state.nextStartTime = startAt + audioBuffer.duration;
} catch (error) {
console.error('Play audio error:', error);
} finally {
audioData.close();
}
}
function decodeOpus(opusBuffer) {
if (!state.isListening || !state.opusDecoderReady) {
if (state.isListening) {
state.opusDecodeQueue.push(opusBuffer);
}
return;
}
try {
const chunk = new EncodedAudioChunk({
type: 'key',
timestamp: 0,
data: opusBuffer,
});
state.opusDecoder.decode(chunk);
} catch (error) {
console.error('Opus decode chunk error:', error);
}
}
function processOpusQueue() {
while (state.opusDecodeQueue.length > 0 && state.opusDecoderReady) {
const buffer = state.opusDecodeQueue.shift();
decodeOpus(buffer);
}
}
function playPcm(arrayBuffer) {
if (!state.audioContextListen) return;
const bytes = new Uint8Array(arrayBuffer);
if (bytes.byteLength <= 4) return;
const pcm = new Int16Array(bytes.buffer, bytes.byteOffset + 4, (bytes.byteLength - 4) / 2);
const audioBuffer = state.audioContextListen.createBuffer(1, pcm.length, 24000);
const channel = audioBuffer.getChannelData(0);
for (let i = 0; i < pcm.length; i++) channel[i] = pcm[i] / 32768;
const source = state.audioContextListen.createBufferSource();
source.buffer = audioBuffer;
source.connect(state.audioContextListen.destination);
const startAt = Math.max(state.nextStartTime, state.audioContextListen.currentTime);
source.start(startAt);
state.nextStartTime = startAt + audioBuffer.duration;
}
function updateVisualizer(level) {
bars.forEach((bar, index) => {
const wave = Math.sin(index * 0.55 + Date.now() / 140) * 0.35 + 0.65;
bar.style.height = `${Math.max(3, level * 190 * wave)}px`;
});
}
document.querySelectorAll('.tab-btn').forEach((button) => {
button.addEventListener('click', async () => {
document.querySelectorAll('.tab-btn').forEach((item) => item.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach((item) => item.classList.remove('active'));
button.classList.add('active');
state.activeTab = button.dataset.tab;
document.getElementById(state.activeTab).classList.add('active');
el.activeTabLabel.textContent = button.textContent;
if (state.activeTab === 'text') await fetchText();
});
});
el.guildSelect.addEventListener('change', () => loadChannels(el.guildSelect.value).catch((error) => showError(error.message)));
el.joinVoiceBtn.addEventListener('click', () => connectVoice().catch((error) => showError(error.message)));
el.disconnectVoiceBtn.addEventListener('click', () => disconnectVoice().catch((error) => showError(error.message)));
el.toggleBtn.addEventListener('click', () => state.isStreaming ? stopStreaming() : startStreaming());
el.listenBtn.addEventListener('click', toggleListen);
el.channelFilter.addEventListener('change', async () => {
state.selectedChannel = el.channelFilter.value;
const url = new URL(window.location.href);
if (state.selectedChannel) url.searchParams.set('channel', state.selectedChannel);
else url.searchParams.delete('channel');
if (el.guildSelect.value) url.searchParams.set('guild', el.guildSelect.value);
window.history.replaceState({}, '', url);
await fetchText().catch((error) => showError(error.message));
});
connectWebSocket();
loadGuilds().then(refreshStatus).catch((error) => showError(error.message));
setInterval(() => {
if (state.activeTab === 'text') fetchText().catch(() => {});
}, 7000);

View File

@@ -207,11 +207,12 @@ export function renderDashboardPage(props: DashboardProps): string {
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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 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"> <link rel="stylesheet" href="/dashboard.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/15.1.22/Tone.js"></script>
</head> </head>
<body> <body>
<div id="root">${app}</div> <div id="root">${app}</div>
<script id="__DASHBOARD_DATA__" type="application/json">${bootstrap}</script> <script id="__DASHBOARD_DATA__" type="application/json">${bootstrap}</script>
<script src="/dashboard.js" defer></script> <script type="module" src="/dashboard.js"></script>
</body> </body>
</html>`; </html>`;
} }