From bc212333d8cbcb05a4d74afe8b8c987e12564dd7 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Wed, 13 May 2026 22:46:24 +0700 Subject: [PATCH] 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 --- bun.lockb | Bin 161872 -> 163506 bytes package.json | 1 + public/dashboard.js | 887 +++++++++++++++++++------------------- src/web/dashboardPage.tsx | 3 +- 4 files changed, 437 insertions(+), 454 deletions(-) diff --git a/bun.lockb b/bun.lockb index 0fed084682a919d111b43681f59d91e8397ade34..c3fd6b9c7e74ecad65a31dd41d7b6d4bc3dd4440 100755 GIT binary patch delta 29712 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 delta 28630 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 ({ 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 = {}) { - 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); +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; + } + 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); } - - function showError(message) { - el.errorBox.textContent = message; - el.errorBox.style.display = 'block'; - setTimeout(() => { el.errorBox.style.display = 'none'; }, 4500); + if (embed.description) { + const desc = document.createElement('div'); + desc.className = 'embed-description'; + desc.textContent = embed.description; + card.appendChild(desc); } - - 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); - } + for (const field of embed.fields || []) { + const fieldNode = document.createElement('div'); + fieldNode.className = 'embed-description'; + fieldNode.textContent = `${field.name}: ${field.value}`; + card.appendChild(fieldNode); } - - 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); - } + 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; +} - 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 renderAttachments(attachments) { + const wrap = document.createElement('div'); + wrap.className = 'attachment-strip'; + for (const attachment of attachments) { + const link = document.createElement('a'); + link.className = 'attachment-chip'; + link.href = attachment.url; + link.target = '_blank'; + link.rel = 'noreferrer'; + link.textContent = `${attachment.name} (${(attachment.size / 1024).toFixed(1)}KB)`; + wrap.appendChild(link); + } + return wrap; +} - 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); - } - } +function appendBadge(parent, label, className) { + const badge = document.createElement('span'); + badge.className = `badge ${className}`; + badge.textContent = label; + parent.appendChild(badge); +} - 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); - } - } +function appendEmpty(parent, message) { + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = message; + parent.appendChild(empty); +} - 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 startStreaming() { + try { + await Tone.start(); + state.mic = new Tone.UserMedia(); + state.analyser = new Tone.Analyser('waveform'); + state.meter = new Tone.Meter(); - async function disconnectVoice() { - await apiRequest('/api/disconnect', { method: 'POST' }); - await refreshStatus(); - } + state.mic.connect(state.analyser); + state.mic.connect(state.meter); - function connectWebSocket() { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - state.socket = new WebSocket(`${protocol}//${window.location.host}/ws`); - state.socket.binaryType = 'arraybuffer'; + await state.mic.open(); - 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; - } - 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); + const analyzeInterval = setInterval(() => { + if (!state.isStreaming) { + clearInterval(analyzeInterval); 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(); - } + const level = state.meter.getValue(); + updateVisualizer(Math.max(0, level + 100) / 100); - 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 waveform = state.analyser.getValue(); + if (waveform && state.socket?.readyState === WebSocket.OPEN) { + 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; } - 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); + state.socket.send(pcm.buffer); } - } + }, 50); - 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; - } + state.isStreaming = true; + el.toggleBtn.textContent = 'Stop Transmitting'; + } catch (error) { + showError(`Microphone error: ${error.message}`); + } +} - 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'; - } - 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 stopStreaming() { + state.isStreaming = false; + state.mic?.close(); + state.mic = null; + state.analyser = null; + state.meter = null; + el.toggleBtn.textContent = 'Start Transmitting'; + updateVisualizer(0); +} - function renderAttachments(attachments) { - const wrap = document.createElement('div'); - wrap.className = 'attachment-strip'; - for (const attachment of attachments) { - const link = document.createElement('a'); - link.className = 'attachment-chip'; - link.href = attachment.url; - link.target = '_blank'; - link.rel = 'noreferrer'; - link.textContent = `${attachment.name} (${(attachment.size / 1024).toFixed(1)}KB)`; - wrap.appendChild(link); - } - return wrap; - } +function toggleListen() { + state.isListening = !state.isListening; + if (state.isListening) { + el.listenBtn.textContent = 'Leave Listen Channel'; + el.listenStatus.textContent = 'speaker on'; + } else { + el.listenBtn.textContent = 'Join Listen Channel'; + el.listenStatus.textContent = 'speaker off'; + } +} - function appendBadge(parent, label, className) { - const badge = document.createElement('span'); - badge.className = `badge ${className}`; - badge.textContent = label; - parent.appendChild(badge); - } +function playPcm(arrayBuffer) { + if (!state.isListening) return; + const bytes = new Uint8Array(arrayBuffer); + if (bytes.byteLength <= 4) return; + const pcm = new Int16Array(bytes.buffer, bytes.byteOffset + 4, (bytes.byteLength - 4) / 2); - function appendEmpty(parent, message) { - const empty = document.createElement('div'); - empty.className = 'empty'; - empty.textContent = message; - parent.appendChild(empty); - } + const audioBuffer = Tone.context.createBuffer(1, pcm.length, 24000); + const channel = audioBuffer.getChannelData(0); + for (let i = 0; i < pcm.length; i++) channel[i] = pcm[i] / 32768; - async function startStreaming() { - try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - state.audioContextTransmit = new AudioContext({ sampleRate: SAMPLE_RATE }); + const source = Tone.context.createBufferSource(); + source.buffer = audioBuffer; + source.connect(Tone.context.destination); + source.start(Tone.now()); +} - await state.audioContextTransmit.audioWorklet.addModule('/audio-worklet.js'); +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`; + }); +} - const source = state.audioContextTransmit.createMediaStreamSource(stream); - state.processor = new AudioWorkletNode(state.audioContextTransmit, 'microphone-processor'); +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(); + }); +}); - state.processor.port.onmessage = (event) => { - if (!state.isStreaming || state.socket?.readyState !== WebSocket.OPEN) return; - const { type, rms, data } = event.data; - if (type === 'audio' && data) { - state.socket.send(data); - updateVisualizer(rms); - } - }; +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)); +}); - source.connect(state.processor); - state.processor.connect(state.audioContextTransmit.destination); - state.isStreaming = true; - el.toggleBtn.textContent = 'Stop Transmitting'; - } catch (error) { - showError(`Microphone error: ${error.message}`); - } - } - - function stopStreaming() { - state.isStreaming = false; - state.processor?.disconnect(); - state.audioContextTransmit?.close(); - state.processor = null; - state.audioContextTransmit = null; - el.toggleBtn.textContent = 'Start Transmitting'; - updateVisualizer(0); - } - - function toggleListen() { - state.isListening = !state.isListening; - if (state.isListening) { - state.audioContextListen = new AudioContext({ sampleRate: 24000 }); - state.nextStartTime = state.audioContextListen.currentTime; - el.listenBtn.textContent = 'Leave Listen Channel'; - el.listenStatus.textContent = 'speaker on'; - } else { - state.audioContextListen?.close(); - state.audioContextListen = null; - el.listenBtn.textContent = 'Join Listen Channel'; - el.listenStatus.textContent = 'speaker off'; - } - } - - 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); +connectWebSocket(); +loadGuilds().then(refreshStatus).catch((error) => showError(error.message)); +setInterval(() => { + if (state.activeTab === 'text') fetchText().catch(() => {}); +}, 7000); diff --git a/src/web/dashboardPage.tsx b/src/web/dashboardPage.tsx index 6bd5d11..413996a 100644 --- a/src/web/dashboardPage.tsx +++ b/src/web/dashboardPage.tsx @@ -207,11 +207,12 @@ export function renderDashboardPage(props: DashboardProps): string { +
${app}
- + `; }