diff --git a/bun.lockb b/bun.lockb index 0fed084..c3fd6b9 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 7c2e09a..989a972 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "sodium-native": "^4.3.2", + "tone": "^15.1.22", "ws": "^8.20.1", "zod": "^4.4.3" }, diff --git a/public/dashboard.js b/public/dashboard.js index f3b07e8..c6a1df3 100644 --- a/public/dashboard.js +++ b/public/dashboard.js @@ -1,488 +1,469 @@ +import * as Tone from 'tone'; + const bootstrapData = JSON.parse(document.getElementById('__DASHBOARD_DATA__')?.textContent || '{}'); const state = { - socket: null, - activeTab: 'voice', - selectedChannel: bootstrapData.selectedChannelId || '', - text: bootstrapData.messages || [], - isStreaming: false, - isListening: false, - audioContextTransmit: null, - audioContextListen: null, - processor: null, - nextStartTime: 0, - noiseGateHold: 0, - }; + socket: null, + activeTab: 'voice', + selectedChannel: bootstrapData.selectedChannelId || '', + text: bootstrapData.messages || [], + isStreaming: false, + isListening: false, + mic: null, + analyser: null, + meter: null, + synth: null, + nextStartTime: 0, +}; - const SAMPLE_RATE = 24000; - const NOISE_GATE_THRESHOLD = 0.01; - const NOISE_GATE_HOLD_FRAMES = 3; +const SAMPLE_RATE = 24000; +const NOISE_GATE_THRESHOLD = 0.01; - const el = { - wsDot: document.getElementById('wsDot'), - wsStatusText: document.getElementById('wsStatusText'), - activeTabLabel: document.getElementById('activeTabLabel'), - errorBox: document.getElementById('errorBox'), - guildSelect: document.getElementById('guildSelect'), - channelSelect: document.getElementById('channelSelect'), - channelFilter: document.getElementById('channelFilter'), - joinVoiceBtn: document.getElementById('joinVoiceBtn'), - disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'), - voiceStatusText: document.getElementById('voiceStatusText'), - voiceStatusNote: document.getElementById('voiceStatusNote'), - toggleBtn: document.getElementById('toggleBtn'), - listenBtn: document.getElementById('listenBtn'), - listenStatus: document.getElementById('listenStatus'), - visualizer: document.getElementById('visualizer'), - userList: document.getElementById('userList'), - textList: document.getElementById('textList'), - }; +const el = { + wsDot: document.getElementById('wsDot'), + wsStatusText: document.getElementById('wsStatusText'), + activeTabLabel: document.getElementById('activeTabLabel'), + errorBox: document.getElementById('errorBox'), + guildSelect: document.getElementById('guildSelect'), + channelSelect: document.getElementById('channelSelect'), + channelFilter: document.getElementById('channelFilter'), + joinVoiceBtn: document.getElementById('joinVoiceBtn'), + disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'), + voiceStatusText: document.getElementById('voiceStatusText'), + voiceStatusNote: document.getElementById('voiceStatusNote'), + toggleBtn: document.getElementById('toggleBtn'), + listenBtn: document.getElementById('listenBtn'), + listenStatus: document.getElementById('listenStatus'), + visualizer: document.getElementById('visualizer'), + userList: document.getElementById('userList'), + textList: document.getElementById('textList'), +}; - for (let i = 0; i < 32; i++) { - const bar = document.createElement('div'); - bar.className = 'bar'; - el.visualizer.appendChild(bar); +for (let i = 0; i < 32; i++) { + const bar = document.createElement('div'); + bar.className = '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 = {}) { - 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}
- + `; }