This commit is contained in:
pavel 2026-02-13 19:52:42 +01:00
commit d1a68c635c
5 changed files with 384 additions and 94 deletions

View file

@ -16,13 +16,13 @@ const state = {
localStream: null,
rawStream: null,
audioContext: null,
denoiserNode: null,
deepFilterCore: null,
peerConnections: new Map(),
muted: false,
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
},
voicePresencePollId: null,
chatWs: null,
lastMessageId: null,
};
const el = {
@ -140,10 +140,47 @@ function formatDate(isoString) {
return d.toLocaleDateString();
}
const DEEPFILTERNET_LIB_PATH = "/static/vendor/deepfilternet3/index.esm.js";
const NSNET2_COMPAT_SUPPRESSION = 56;
let audioCtx = null;
function playSound(type) {
try {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume();
let deepFilterLibPromise = null;
const sounds = {
message: { freq: 880, type: 'sine', duration: 0.1, volume: 0.1 },
dm: { freq: [660, 880], type: 'sine', duration: 0.15, volume: 0.1 },
join: { freq: [440, 880], type: 'sine', duration: 0.2, volume: 0.1 },
leave: { freq: [880, 440], type: 'sine', duration: 0.2, volume: 0.1 },
'peer-join': { freq: [660, 990], type: 'sine', duration: 0.15, volume: 0.05 },
'peer-leave': { freq: [990, 660], type: 'sine', duration: 0.15, volume: 0.05 },
};
const s = sounds[type];
if (!s) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = s.type;
if (Array.isArray(s.freq)) {
osc.frequency.setValueAtTime(s.freq[0], audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(s.freq[1], audioCtx.currentTime + s.duration);
} else {
osc.frequency.setValueAtTime(s.freq, audioCtx.currentTime);
}
gain.gain.setValueAtTime(s.volume, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + s.duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + s.duration);
} catch (err) {
console.warn("playSound failed", err);
}
}
const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId";
// --- Renderers ---
@ -183,7 +220,7 @@ function renderChannels() {
for (const channel of state.channels) {
const row = document.createElement("button");
row.className = `channel-row ${(channel.kind === 'text' && state.selectedTextChannelId === channel.id) ||
(channel.kind === 'voice' && state.selectedVoiceChannelId === channel.id) ? "active" : ""
(channel.kind === 'voice' && state.selectedVoiceChannelId === channel.id) ? "active" : ""
}`;
const iconName = channel.kind === 'text' ? 'hash' : 'volume-2';
@ -291,6 +328,14 @@ function renderMessages(messages) {
lastTime = mDate;
}
if (messages.length > 0) {
const latest = messages[0];
if (state.lastMessageId && latest.id !== state.lastMessageId && latest.author_user_id !== state.me?.id) {
playSound(state.selectedDmUserId ? 'dm' : 'message');
}
state.lastMessageId = latest.id;
}
el.messageList.scrollTop = el.messageList.scrollHeight;
}
@ -422,6 +467,39 @@ function startVoicePresencePolling() {
}, 3000);
}
function initChatWs() {
if (state.chatWs) state.chatWs.close();
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${location.host}/ws`;
const ws = new WebSocket(wsUrl);
state.chatWs = ws;
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "message_created") {
if (state.selectedTextChannelId === msg.channel_id) {
// Optimization: we could just push the message, but refresh for now
api(`/channels/${state.selectedTextChannelId}/messages?limit=100`).then(renderMessages);
} else {
// Even if not active, play sound
playSound('message');
}
} else if (msg.type === "dm_created") {
if (state.selectedDmUserId === msg.other_user_id) {
api(`/dms/${state.selectedDmUserId}/messages?limit=100`).then(renderMessages);
} else {
playSound('dm');
loadDMConversations().then(renderDMs);
}
}
};
ws.onclose = () => {
console.log("Chat WS closed, reconnecting...");
setTimeout(initChatWs, 3000);
};
}
// --- Voice ---
function getVoiceWsUrl(channelId) {
@ -434,40 +512,7 @@ function shouldInitiateOffer(peerId) {
return state.me.id > peerId;
}
async function loadDeepFilterLib() {
if (!deepFilterLibPromise) {
deepFilterLibPromise = import(DEEPFILTERNET_LIB_PATH);
}
return deepFilterLibPromise;
}
async function buildDenoiserNode(audioContext) {
if (!audioContext.audioWorklet) {
throw new Error("AudioWorklet is not supported in this browser");
}
const df = await loadDeepFilterLib();
const core = new df.DeepFilterNet3Core({
sampleRate: 48000,
noiseReductionLevel: NSNET2_COMPAT_SUPPRESSION,
assetConfig: {
cdnUrl: "/static/vendor/deepfilternet3",
},
});
await core.initialize();
const workletNode = await core.createAudioWorkletNode(audioContext);
state.voice.deepFilterCore = core;
return workletNode;
}
function stopAndClearAudioPipeline() {
if (state.voice.deepFilterCore) {
state.voice.deepFilterCore.destroy();
state.voice.deepFilterCore = null;
}
if (state.voice.denoiserNode && typeof state.voice.denoiserNode.destroy === "function") {
state.voice.denoiserNode.destroy();
}
if (state.voice.localStream) {
for (const track of state.voice.localStream.getTracks()) track.stop();
}
@ -480,8 +525,6 @@ function stopAndClearAudioPipeline() {
state.voice.localStream = null;
state.voice.rawStream = null;
state.voice.audioContext = null;
state.voice.denoiserNode = null;
state.voice.deepFilterCore = null;
}
async function buildAudioPipeline(rawStream) {
@ -492,14 +535,8 @@ async function buildAudioPipeline(rawStream) {
let head = source;
const denoiserNode = await buildDenoiserNode(audioContext);
if (denoiserNode) {
head.connect(denoiserNode);
head = denoiserNode;
}
head.connect(destination);
state.voice.denoiserNode = denoiserNode;
return destination.stream;
}
@ -621,6 +658,7 @@ async function joinVoice() {
const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId);
el.vcChannelName.textContent = channel ? channel.name : "Voice";
el.voiceConnection.classList.remove("hidden");
playSound('join');
refreshVoicePresence().catch(() => { });
};
@ -631,8 +669,10 @@ async function joinVoice() {
if (shouldInitiateOffer(peer.user_id)) await sendOffer(peer.user_id);
}
} else if (msg.type === "peer_joined") {
playSound('peer-join');
if (shouldInitiateOffer(msg.user_id)) await sendOffer(msg.user_id);
} else if (msg.type === "peer_left") {
playSound('peer-leave');
const pc = state.voice.peerConnections.get(msg.user_id);
if (pc) {
pc.close();
@ -652,6 +692,7 @@ async function joinVoice() {
stopAndClearAudioPipeline();
state.voice.joinedChannelId = null;
state.voice.ws = null;
playSound('leave');
refreshVoicePresence().catch(() => { });
};
}
@ -893,6 +934,7 @@ async function init() {
updateHeaderLabels();
}
initChatWs();
startVoicePresencePolling();
lucide.createIcons();
} catch (err) {