diff --git a/src/chat.rs b/src/chat.rs new file mode 100644 index 0000000..10beca6 --- /dev/null +++ b/src/chat.rs @@ -0,0 +1,118 @@ +use crate::AppState; +use axum::extract::ws::{Message, WebSocket}; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use tokio::sync::{RwLock, mpsc}; +use uuid::Uuid; + +#[derive(Default)] +pub struct ChatHub { + // user_id -> sender + clients: RwLock>>, +} + +#[derive(Serialize, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ServerEvent { + MessageCreated { + channel_id: Uuid, + message: serde_json::Value, + }, + DmCreated { + other_user_id: Uuid, + message: serde_json::Value, + }, + UserPresence { + user_id: Uuid, + online: bool, + }, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ClientEvent { + // Currently no interactive client events for the general chat WS + Ping, +} + +impl ChatHub { + pub async fn add_client(&self, user_id: Uuid, tx: mpsc::UnboundedSender) { + let mut clients = self.clients.write().await; + clients.insert(user_id, tx); + } + + pub async fn remove_client(&self, user_id: Uuid) { + let mut clients = self.clients.write().await; + clients.remove(&user_id); + } + + pub async fn get_online_users(&self) -> Vec { + let clients = self.clients.read().await; + clients.keys().cloned().collect() + } + + pub async fn broadcast_all(&self, event: ServerEvent) { + let clients = self.clients.read().await; + for tx in clients.values() { + let _ = tx.send(event.clone()); + } + } + + pub async fn broadcast_to_user(&self, user_id: Uuid, event: ServerEvent) { + let clients = self.clients.read().await; + if let Some(tx) = clients.get(&user_id) { + let _ = tx.send(event); + } + } + + pub async fn broadcast_to_many(&self, user_ids: Vec, event: ServerEvent) { + let clients = self.clients.read().await; + for user_id in user_ids { + if let Some(tx) = clients.get(&user_id) { + let _ = tx.send(event.clone()); + } + } + } +} + +pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) { + let (mut ws_sender, mut ws_receiver) = socket.split(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + + state.chat.add_client(user_id, tx).await; + state + .chat + .broadcast_all(ServerEvent::UserPresence { + user_id, + online: true, + }) + .await; + + let send_task = tokio::spawn(async move { + while let Some(event) = rx.recv().await { + let Ok(payload) = serde_json::to_string(&event) else { + continue; + }; + if ws_sender.send(Message::Text(payload.into())).await.is_err() { + break; + } + } + }); + + while let Some(Ok(msg)) = ws_receiver.next().await { + if let Message::Close(_) = msg { + break; + } + } + + send_task.abort(); + state.chat.remove_client(user_id).await; + state + .chat + .broadcast_all(ServerEvent::UserPresence { + user_id, + online: false, + }) + .await; +} diff --git a/src/db.rs b/src/db.rs index df877ed..cd6a234 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,15 +1,18 @@ use anyhow::{Result, anyhow}; use chrono::{Duration, Utc}; use sea_orm::{ - ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, - Condition, DatabaseTransaction, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, + ActiveModelTrait, ActiveValue::Set, ColumnTrait, Condition, DatabaseConnection, + DatabaseTransaction, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, TransactionTrait, sea_query::OnConflict, }; use uuid::Uuid; use crate::{ entity::{channels, direct_messages, guild_members, guilds, invites, messages, users}, - models::{BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, MessageWithAuthor, User}, + models::{ + BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, MessageWithAuthor, + User, + }, }; pub const CHANNEL_KIND_TEXT: &str = "text"; @@ -42,7 +45,10 @@ pub async fn upsert_user_from_oidc( users::Column::DisplayName, users::Column::AvatarUrl, ]) - .value(users::Column::UpdatedAt, sea_orm::sea_query::Expr::current_timestamp()) + .value( + users::Column::UpdatedAt, + sea_orm::sea_query::Expr::current_timestamp(), + ) .to_owned(), ) .exec_with_returning(db) @@ -75,6 +81,15 @@ pub async fn list_guild_members(db: &DatabaseConnection, guild_id: Uuid) -> Resu .collect()) } +pub async fn list_guild_member_ids(db: &DatabaseConnection, guild_id: Uuid) -> Result> { + let rows = guild_members::Entity::find() + .filter(guild_members::Column::GuildId.eq(guild_id)) + .all(db) + .await?; + + Ok(rows.into_iter().map(|m| m.user_id).collect()) +} + pub async fn list_guilds_for_user(db: &DatabaseConnection, user_id: Uuid) -> Result> { let rows = guild_members::Entity::find() .filter(guild_members::Column::UserId.eq(user_id)) @@ -88,7 +103,11 @@ pub async fn list_guilds_for_user(db: &DatabaseConnection, user_id: Uuid) -> Res .collect()) } -pub async fn create_guild(db: &DatabaseConnection, owner_user_id: Uuid, name: &str) -> Result { +pub async fn create_guild( + db: &DatabaseConnection, + owner_user_id: Uuid, + name: &str, +) -> Result { let guild = guilds::Entity::insert(guilds::ActiveModel { id: Set(Uuid::new_v4()), name: Set(name.to_string()), @@ -104,9 +123,12 @@ pub async fn create_guild(db: &DatabaseConnection, owner_user_id: Uuid, name: &s ..Default::default() }) .on_conflict( - OnConflict::columns([guild_members::Column::GuildId, guild_members::Column::UserId]) - .do_nothing() - .to_owned(), + OnConflict::columns([ + guild_members::Column::GuildId, + guild_members::Column::UserId, + ]) + .do_nothing() + .to_owned(), ) .exec(db) .await?; @@ -176,9 +198,12 @@ pub async fn join_invite(db: &DatabaseConnection, code: &str, user_id: Uuid) -> ..Default::default() }) .on_conflict( - OnConflict::columns([guild_members::Column::GuildId, guild_members::Column::UserId]) - .do_nothing() - .to_owned(), + OnConflict::columns([ + guild_members::Column::GuildId, + guild_members::Column::UserId, + ]) + .do_nothing() + .to_owned(), ) .exec(&txn) .await?; @@ -215,12 +240,18 @@ pub async fn create_channel( Ok(map_channel(channel)) } -pub async fn get_channel_by_id(db: &DatabaseConnection, channel_id: Uuid) -> Result> { +pub async fn get_channel_by_id( + db: &DatabaseConnection, + channel_id: Uuid, +) -> Result> { let row = channels::Entity::find_by_id(channel_id).one(db).await?; Ok(row.map(map_channel)) } -pub async fn list_channels_for_guild(db: &DatabaseConnection, guild_id: Uuid) -> Result> { +pub async fn list_channels_for_guild( + db: &DatabaseConnection, + guild_id: Uuid, +) -> Result> { let channels = channels::Entity::find() .filter(channels::Column::GuildId.eq(guild_id)) .order_by_asc(channels::Column::CreatedAt) @@ -230,7 +261,10 @@ pub async fn list_channels_for_guild(db: &DatabaseConnection, guild_id: Uuid) -> Ok(channels.into_iter().map(map_channel).collect()) } -pub async fn list_voice_channels_for_guild(db: &DatabaseConnection, guild_id: Uuid) -> Result> { +pub async fn list_voice_channels_for_guild( + db: &DatabaseConnection, + guild_id: Uuid, +) -> Result> { let channels = channels::Entity::find() .filter(channels::Column::GuildId.eq(guild_id)) .filter(channels::Column::Kind.eq(CHANNEL_KIND_VOICE)) @@ -241,7 +275,11 @@ pub async fn list_voice_channels_for_guild(db: &DatabaseConnection, guild_id: Uu Ok(channels.into_iter().map(map_channel).collect()) } -pub async fn is_member_of_guild(db: &DatabaseConnection, guild_id: Uuid, user_id: Uuid) -> Result { +pub async fn is_member_of_guild( + db: &DatabaseConnection, + guild_id: Uuid, + user_id: Uuid, +) -> Result { let count = guild_members::Entity::find() .filter(guild_members::Column::GuildId.eq(guild_id)) .filter(guild_members::Column::UserId.eq(user_id)) @@ -251,7 +289,10 @@ pub async fn is_member_of_guild(db: &DatabaseConnection, guild_id: Uuid, user_id Ok(count > 0) } -pub async fn guild_id_for_channel(db: &DatabaseConnection, channel_id: Uuid) -> Result> { +pub async fn guild_id_for_channel( + db: &DatabaseConnection, + channel_id: Uuid, +) -> Result> { let guild_id = channels::Entity::find_by_id(channel_id) .select_only() .column(channels::Column::GuildId) @@ -267,18 +308,30 @@ pub async fn create_message( channel_id: Uuid, author_user_id: Uuid, body: &str, -) -> Result<()> { - messages::Entity::insert(messages::ActiveModel { +) -> Result { + let model = messages::Entity::insert(messages::ActiveModel { id: Set(Uuid::new_v4()), channel_id: Set(channel_id), author_user_id: Set(author_user_id), body: Set(body.to_string()), ..Default::default() }) - .exec(db) + .exec_with_returning(db) .await?; - Ok(()) + let user = users::Entity::find_by_id(author_user_id) + .one(db) + .await? + .ok_or_else(|| anyhow!("author not found"))?; + + Ok(MessageWithAuthor { + id: model.id, + channel_id: model.channel_id, + author_user_id: model.author_user_id, + author_display_name: user.display_name, + body: model.body, + created_at: model.created_at, + }) } pub async fn list_messages( @@ -317,18 +370,30 @@ pub async fn create_direct_message( sender_user_id: Uuid, recipient_user_id: Uuid, body: &str, -) -> Result<()> { - direct_messages::Entity::insert(direct_messages::ActiveModel { +) -> Result { + let model = direct_messages::Entity::insert(direct_messages::ActiveModel { id: Set(Uuid::new_v4()), sender_user_id: Set(sender_user_id), recipient_user_id: Set(recipient_user_id), body: Set(body.to_string()), ..Default::default() }) - .exec(db) + .exec_with_returning(db) .await?; - Ok(()) + let user = users::Entity::find_by_id(sender_user_id) + .one(db) + .await? + .ok_or_else(|| anyhow!("sender not found"))?; + + Ok(DmMessageWithAuthor { + id: model.id, + author_user_id: model.sender_user_id, + recipient_user_id: model.recipient_user_id, + author_display_name: user.display_name, + body: model.body, + created_at: model.created_at, + }) } pub async fn list_direct_messages( @@ -385,7 +450,10 @@ pub async fn list_direct_messages( .collect()) } -pub async fn list_dm_conversations(db: &DatabaseConnection, current_user_id: Uuid) -> Result> { +pub async fn list_dm_conversations( + db: &DatabaseConnection, + current_user_id: Uuid, +) -> Result> { let rows = direct_messages::Entity::find() .filter( Condition::any() @@ -396,7 +464,8 @@ pub async fn list_dm_conversations(db: &DatabaseConnection, current_user_id: Uui .all(db) .await?; - let mut latest_by_peer = std::collections::HashMap::>::new(); + let mut latest_by_peer = + std::collections::HashMap::>::new(); for row in rows { let peer_id = if row.sender_user_id == current_user_id { row.recipient_user_id @@ -496,7 +565,10 @@ fn validate_invite(invite: &invites::Model) -> Result<()> { Ok(()) } -async fn increment_invite_use_count(txn: &DatabaseTransaction, invite: invites::Model) -> Result<()> { +async fn increment_invite_use_count( + txn: &DatabaseTransaction, + invite: invites::Model, +) -> Result<()> { let next_count = invite.use_count + 1; let mut active: invites::ActiveModel = invite.into(); active.use_count = Set(next_count); diff --git a/src/handlers.rs b/src/handlers.rs index d86deec..ffa363b 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use crate::{ AppState, auth::{self, ApiError, AuthUser}, - db, voice, + chat, db, voice, }; pub fn routes() -> Router { @@ -22,17 +22,28 @@ pub fn routes() -> Router { .route("/auth/logout", post(auth_logout)) .route("/me", get(me)) .route("/dms", get(list_dm_conversations)) - .route("/dms/{other_user_id}/messages", get(list_dm_messages).post(send_dm_message)) + .route( + "/dms/{other_user_id}/messages", + get(list_dm_messages).post(send_dm_message), + ) + .route("/presence", get(presence_list)) .route("/rtc-config", get(rtc_config)) .route("/guilds", get(list_guilds).post(create_guild)) .route("/guilds/{guild_id}/members", get(list_guild_members)) .route("/guilds/{guild_id}/channels", get(list_channels)) - .route("/guilds/{guild_id}/voice-presence", get(guild_voice_presence)) + .route( + "/guilds/{guild_id}/voice-presence", + get(guild_voice_presence), + ) .route("/guilds/{guild_id}/invites", post(create_invite)) .route("/invites/{code}/join", post(join_invite)) .route("/channels", post(create_channel)) - .route("/channels/{channel_id}/messages", get(list_messages).post(send_message)) + .route( + "/channels/{channel_id}/messages", + get(list_messages).post(send_message), + ) .route("/channels/{channel_id}/voice/ws", get(voice_ws)) + .route("/ws", get(chat_ws)) } pub async fn health() -> &'static str { @@ -142,7 +153,10 @@ async fn auth_callback( .map_err(|e| ApiError::bad_request(&format!("token exchange failed: {e}")))?; if !token_res.status().is_success() { - let text = token_res.text().await.unwrap_or_else(|_| "".to_string()); + let text = token_res + .text() + .await + .unwrap_or_else(|_| "".to_string()); return Err(ApiError::bad_request(&format!( "token exchange returned non-success: {text}" ))); @@ -162,7 +176,10 @@ async fn auth_callback( .map_err(|e| ApiError::bad_request(&format!("userinfo request failed: {e}")))?; if !userinfo_res.status().is_success() { - let text = userinfo_res.text().await.unwrap_or_else(|_| "".to_string()); + let text = userinfo_res + .text() + .await + .unwrap_or_else(|_| "".to_string()); return Err(ApiError::bad_request(&format!( "userinfo returned non-success: {text}" ))); @@ -185,9 +202,12 @@ async fn auth_callback( .await .map_err(|e| ApiError::internal(&format!("failed to persist user: {e}")))?; - let session_cookie = - auth::new_session_cookie(user.id, &state.settings.session_secret, state.settings.cookie_secure) - .map_err(|e| ApiError::internal(&e.to_string()))?; + let session_cookie = auth::new_session_cookie( + user.id, + &state.settings.session_secret, + state.settings.cookie_secure, + ) + .map_err(|e| ApiError::internal(&e.to_string()))?; let mut headers = HeaderMap::new(); headers.append( @@ -232,7 +252,10 @@ struct RtcIceServer { credential: Option, } -async fn rtc_config(State(state): State, _user: AuthUser) -> Result { +async fn rtc_config( + State(state): State, + _user: AuthUser, +) -> Result { let mut ice_servers = Vec::new(); if !state.settings.stun_urls.is_empty() { @@ -271,6 +294,14 @@ async fn me(State(state): State, user: AuthUser) -> Result, + _user: AuthUser, +) -> Result { + let users = state.chat.get_online_users().await; + Ok(Json(users)) +} + async fn list_dm_conversations( State(state): State, user: AuthUser, @@ -441,9 +472,13 @@ async fn create_channel( return Err(ApiError::bad_request("channel name must be 1..64 chars")); } - let kind = body.kind.unwrap_or_else(|| db::CHANNEL_KIND_TEXT.to_string()); + let kind = body + .kind + .unwrap_or_else(|| db::CHANNEL_KIND_TEXT.to_string()); if kind != db::CHANNEL_KIND_TEXT && kind != db::CHANNEL_KIND_VOICE { - return Err(ApiError::bad_request("channel kind must be 'text' or 'voice'")); + return Err(ApiError::bad_request( + "channel kind must be 'text' or 'voice'", + )); } let channel = db::create_channel(&state.db, body.guild_id, trimmed, &kind) @@ -497,10 +532,31 @@ async fn send_message( return Err(ApiError::bad_request("message body must be 1..4000 chars")); } - db::create_message(&state.db, channel_id, user.id, content) + let message = db::create_message(&state.db, channel_id, user.id, content) .await .map_err(|e| ApiError::internal(&format!("failed to create message: {e}")))?; + // Broadcast to all guild members + let guild_id = db::guild_id_for_channel(&state.db, channel_id) + .await + .map_err(|e| ApiError::internal(&format!("channel lookup failed: {e}")))? + .ok_or_else(|| ApiError::internal("channel not found after creation"))?; + + let members = db::list_guild_member_ids(&state.db, guild_id) + .await + .map_err(|e| ApiError::internal(&format!("failed to list guild members: {e}")))?; + + state + .chat + .broadcast_to_many( + members, + chat::ServerEvent::MessageCreated { + channel_id, + message: serde_json::to_value(&message).unwrap_or_default(), + }, + ) + .await; + Ok((StatusCode::CREATED, Json(SendMessageResponse { ok: true }))) } @@ -557,10 +613,32 @@ async fn send_dm_message( return Err(ApiError::bad_request("message body must be 1..4000 chars")); } - db::create_direct_message(&state.db, user.id, other_user_id, content) + let message = db::create_direct_message(&state.db, user.id, other_user_id, content) .await .map_err(|e| ApiError::internal(&format!("failed to create dm message: {e}")))?; + // Broadcast to both users + state + .chat + .broadcast_to_user( + user.id, + chat::ServerEvent::DmCreated { + other_user_id, + message: serde_json::to_value(&message).unwrap_or_default(), + }, + ) + .await; + state + .chat + .broadcast_to_user( + other_user_id, + chat::ServerEvent::DmCreated { + other_user_id: user.id, + message: serde_json::to_value(&message).unwrap_or_default(), + }, + ) + .await; + Ok((StatusCode::CREATED, Json(SendMessageResponse { ok: true }))) } @@ -581,15 +659,27 @@ async fn voice_ws( })?; if channel.kind != db::CHANNEL_KIND_VOICE { - return Err(ApiError::bad_request("voice websocket requires a voice channel")); + return Err(ApiError::bad_request( + "voice websocket requires a voice channel", + )); } - Ok(ws.on_upgrade(move |socket| { - voice::handle_socket(state, socket, channel_id, user.id) - })) + Ok(ws.on_upgrade(move |socket| voice::handle_socket(state, socket, channel_id, user.id))) } -async fn ensure_guild_member(state: &AppState, guild_id: Uuid, user_id: Uuid) -> Result<(), ApiError> { +async fn chat_ws( + ws: WebSocketUpgrade, + State(state): State, + user: AuthUser, +) -> Result { + Ok(ws.on_upgrade(move |socket| chat::handle_socket(state, socket, user.id))) +} + +async fn ensure_guild_member( + state: &AppState, + guild_id: Uuid, + user_id: Uuid, +) -> Result<(), ApiError> { let is_member = db::is_member_of_guild(&state.db, guild_id, user_id) .await .map_err(|e| ApiError::internal(&format!("membership check failed: {e}")))?; @@ -604,7 +694,11 @@ async fn ensure_guild_member(state: &AppState, guild_id: Uuid, user_id: Uuid) -> Ok(()) } -async fn ensure_channel_member(state: &AppState, channel_id: Uuid, user_id: Uuid) -> Result<(), ApiError> { +async fn ensure_channel_member( + state: &AppState, + channel_id: Uuid, + user_id: Uuid, +) -> Result<(), ApiError> { let guild_id = db::guild_id_for_channel(&state.db, channel_id) .await .map_err(|e| ApiError::internal(&format!("channel lookup failed: {e}")))? diff --git a/src/main.rs b/src/main.rs index fdeaeb9..53ea6f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod auth; +mod chat; mod config; mod db; mod entity; @@ -24,6 +25,7 @@ pub struct AppState { pub settings: Arc, pub http: reqwest::Client, pub voice: Arc, + pub chat: Arc, } #[tokio::main] @@ -46,6 +48,7 @@ async fn main() -> anyhow::Result<()> { settings, http: reqwest::Client::new(), voice: Arc::new(VoiceHub::default()), + chat: Arc::new(chat::ChatHub::default()), }; let port = state.settings.port; diff --git a/static/app.js b/static/app.js index 6bbe240..8f4347f 100644 --- a/static/app.js +++ b/static/app.js @@ -16,13 +16,14 @@ 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, + onlineUsers: new Set(), }; const el = { @@ -140,10 +141,55 @@ function formatDate(isoString) { return d.toLocaleDateString(); } -const DEEPFILTERNET_LIB_PATH = "/static/vendor/deepfilternet3/index.esm.js"; -const NSNET2_COMPAT_SUPPRESSION = 56; +let audioCtx = null; -let deepFilterLibPromise = null; +// Global interaction listener to unlock AudioContext (autoplay policy) +window.addEventListener('click', () => { + if (audioCtx && audioCtx.state === 'suspended') { + audioCtx.resume(); + } +}, { once: true }); + +function playSound(type) { + try { + if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + if (audioCtx.state === 'suspended') audioCtx.resume(); + + 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 +229,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'; @@ -237,9 +283,16 @@ function renderChannels() { function renderDMs() { el.dmList.innerHTML = ""; for (const dm of state.dmConversations) { + const isOnline = state.onlineUsers.has(dm.user_id); const row = document.createElement("button"); row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`; - row.innerHTML = ` ${escapeHtml(dm.display_name)}`; + row.innerHTML = ` +
+ +
+
+ ${escapeHtml(dm.display_name)} + `; row.onclick = async () => { state.selectedDmUserId = dm.user_id; state.selectedDmDisplayName = dm.display_name; @@ -291,16 +344,24 @@ function renderMessages(messages) { lastTime = mDate; } + if (messages.length > 0) { + state.lastMessageId = messages[0].id; + } + el.messageList.scrollTop = el.messageList.scrollHeight; } function renderMembers() { el.memberList.innerHTML = ""; for (const m of state.members) { + const isOnline = state.onlineUsers.has(m.id); const row = document.createElement("div"); row.className = "member-row"; row.innerHTML = ` -
${shortName(m.display_name)}
+
+ ${shortName(m.display_name)} +
+
${escapeHtml(m.display_name)}
`; row.style.cursor = "pointer"; @@ -422,6 +483,49 @@ 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); + const authorId = msg.message?.author_user_id; + const isFromMe = authorId && state.me && authorId === state.me.id; + + if (msg.type === "message_created") { + if (!isFromMe) playSound('message'); + if (state.selectedTextChannelId === msg.channel_id) { + api(`/channels/${state.selectedTextChannelId}/messages?limit=100`).then(renderMessages); + } + } else if (msg.type === "dm_created") { + if (!isFromMe) playSound('dm'); + if (state.selectedDmUserId === msg.other_user_id) { + api(`/dms/${state.selectedDmUserId}/messages?limit=100`).then(renderMessages); + playSound('dm'); + loadDMConversations().then(renderDMs); + } else { + loadDMConversations().then(renderDMs); + } + } else if (msg.type === "user_presence") { + if (msg.online) { + state.onlineUsers.add(msg.user_id); + } else { + state.onlineUsers.delete(msg.user_id); + } + renderMembers(); + renderDMs(); + } + }; + + ws.onclose = () => { + console.log("Chat WS closed, reconnecting..."); + setTimeout(initChatWs, 3000); + }; +} + // --- Voice --- function getVoiceWsUrl(channelId) { @@ -434,40 +538,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 +551,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 +561,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 +684,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 +695,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 +718,7 @@ async function joinVoice() { stopAndClearAudioPipeline(); state.voice.joinedChannelId = null; state.voice.ws = null; + playSound('leave'); refreshVoicePresence().catch(() => { }); }; } @@ -877,6 +944,11 @@ async function init() { await loadGuilds(); await loadDMConversations(); + try { + const online = await api("/presence"); + state.onlineUsers = new Set(online); + } catch (err) { console.warn("presence sync failed", err); } + if (state.guilds.length > 0) { const lastGuildId = localStorage.getItem(LAST_GUILD_STORAGE_KEY); const guild = state.guilds.find((g) => g.id === lastGuildId) || state.guilds[0]; @@ -893,6 +965,7 @@ async function init() { updateHeaderLabels(); } + initChatWs(); startVoicePresencePolling(); lucide.createIcons(); } catch (err) { diff --git a/static/styles.css b/static/styles.css index 012ceea..c1b9d6f 100644 --- a/static/styles.css +++ b/static/styles.css @@ -7,22 +7,24 @@ --bg-modifier-selected: rgba(78, 80, 88, 0.6); --bg-modifier-hover: rgba(78, 80, 88, 0.3); --bg-input: #383a40; - + --text-normal: #dbdee1; --text-muted: #949ba4; --text-strong: #f2f3f5; --text-link: #00a8fc; - + --brand: #5865f2; --brand-hover: #4752c4; --green: #23a559; --danger: #f23f43; --yellow: #f0b232; - + --font-main: "gg sans", "Inter", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; } -* { box-sizing: border-box; } +* { + box-sizing: border-box; +} body { margin: 0; @@ -34,15 +36,46 @@ body { } /* Scrollbars */ -::-webkit-scrollbar { width: 8px; height: 8px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border-radius: 4px; } -::-webkit-scrollbar-thumb:hover { background: #242529; } +::-webkit-scrollbar { + width: 8px; + height: 8px; +} -button { border: 0; cursor: pointer; transition: all 150ms ease; background: none; color: inherit; font: inherit; padding: 0; } -input, select { border: 0; outline: none; background: var(--bg-input); color: var(--text-normal); font: inherit; } +::-webkit-scrollbar-track { + background: transparent; +} -.hidden { display: none !important; } +::-webkit-scrollbar-thumb { + background: var(--bg-tertiary); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #242529; +} + +button { + border: 0; + cursor: pointer; + transition: all 150ms ease; + background: none; + color: inherit; + font: inherit; + padding: 0; +} + +input, +select { + border: 0; + outline: none; + background: var(--bg-input); + color: var(--text-normal); + font: inherit; +} + +.hidden { + display: none !important; +} /* Auth Screen */ .auth-screen { @@ -52,14 +85,16 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va background-image: url("https://discord.com/assets/f9a15998e94589d343f7.png"); background-size: cover; } + .auth-card { width: min(480px, 92vw); background: var(--bg-sidebar); border-radius: 8px; padding: 32px; - box-shadow: 0 2px 10px rgba(0,0,0,0.2); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); text-align: center; } + .brand-large { width: 80px; height: 80px; @@ -72,8 +107,17 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va font-weight: 800; margin: 0 auto 20px; } -.auth-card h1 { margin: 0 0 8px; color: var(--text-strong); } -.auth-card p { margin: 0 0 24px; color: var(--text-muted); } + +.auth-card h1 { + margin: 0 0 8px; + color: var(--text-strong); +} + +.auth-card p { + margin: 0 0 24px; + color: var(--text-muted); +} + #login-btn { width: 100%; background: var(--brand); @@ -83,7 +127,10 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va font-weight: 600; font-size: 16px; } -#login-btn:hover { background: var(--brand-hover); } + +#login-btn:hover { + background: var(--brand-hover); +} /* Main Layout */ .shell { @@ -104,7 +151,11 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va overflow-y: auto; scrollbar-width: none; } -.server-rail::-webkit-scrollbar { display: none; } + +.server-rail::-webkit-scrollbar { + display: none; +} + .guild-list { display: flex; flex-direction: column; @@ -112,7 +163,8 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va padding: 6px 0; } -.brand, .guild-pill { +.brand, +.guild-pill { width: 48px; height: 48px; border-radius: 50%; @@ -124,8 +176,16 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va background: var(--bg-sidebar); } -.brand { background: var(--brand); color: #fff; border-radius: 16px; margin-bottom: 2px; } -.brand:hover { border-radius: 16px; } +.brand { + background: var(--brand); + color: #fff; + border-radius: 16px; + margin-bottom: 2px; +} + +.brand:hover { + border-radius: 16px; +} .separator { width: 32px; @@ -135,7 +195,8 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va border-radius: 1px; } -.guild-pill:hover, .guild-pill.active { +.guild-pill:hover, +.guild-pill.active { border-radius: 16px; background: var(--brand); color: #fff; @@ -151,11 +212,23 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va border-radius: 0 4px 4px 0; transition: all 0.2s ease; } -.guild-pill:hover::before { height: 20px; } -.guild-pill.active::before { height: 40px; } -.action-pill { color: var(--green); } -.action-pill:hover { background: var(--green); color: #fff; } +.guild-pill:hover::before { + height: 20px; +} + +.guild-pill.active::before { + height: 40px; +} + +.action-pill { + color: var(--green); +} + +.action-pill:hover { + background: var(--green); + color: #fff; +} /* Channel Sidebar */ .channel-sidebar { @@ -171,11 +244,15 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va align-items: center; justify-content: space-between; border-bottom: 1px solid var(--bg-darker); - box-shadow: 0 1px 0 rgba(0,0,0,0.1); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.1); cursor: pointer; transition: background-color 0.1s; } -.sidebar-header:hover { background: var(--bg-modifier-hover); } + +.sidebar-header:hover { + background: var(--bg-modifier-hover); +} + .sidebar-header h2 { margin: 0; font-size: 15px; @@ -185,11 +262,13 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va overflow: hidden; text-overflow: ellipsis; } + .sidebar-header-actions { display: flex; align-items: center; gap: 8px; } + .header-action-btn { width: 24px; height: 24px; @@ -198,18 +277,27 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va place-items: center; color: var(--text-muted); } + .header-action-btn:hover { background: var(--bg-modifier-hover); color: var(--text-normal); } + .header-action-btn i { width: 16px; height: 16px; } -.sidebar-scroll { flex: 1; overflow-y: auto; padding-top: 12px; } +.sidebar-scroll { + flex: 1; + overflow-y: auto; + padding-top: 12px; +} + +.sidebar-group { + margin-bottom: 20px; +} -.sidebar-group { margin-bottom: 20px; } .group-title { padding: 0 8px 0 2px; display: flex; @@ -221,9 +309,21 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va letter-spacing: 0.24px; cursor: pointer; } -.group-title:hover { color: var(--text-normal); } -.group-toggle { width: 12px; height: 12px; margin-right: 2px; } -.group-title span { flex: 1; } + +.group-title:hover { + color: var(--text-normal); +} + +.group-toggle { + width: 12px; + height: 12px; + margin-right: 2px; +} + +.group-title span { + flex: 1; +} + .add-btn { width: 20px; height: 20px; @@ -233,13 +333,24 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va display: grid; place-items: center; } + .add-btn:hover { opacity: 1; background: var(--bg-modifier-hover); } -.add-btn i { width: 16px; height: 16px; } -.channel-list { padding: 0 8px; display: flex; flex-direction: column; gap: 2px; } +.add-btn i { + width: 16px; + height: 16px; +} + +.channel-list { + padding: 0 8px; + display: flex; + flex-direction: column; + gap: 2px; +} + .channel-row { display: flex; align-items: center; @@ -249,12 +360,28 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va font-weight: 500; gap: 6px; } -.channel-row:hover { background: var(--bg-modifier-hover); color: var(--text-normal); } -.channel-row.active { background: var(--bg-modifier-selected); color: var(--text-strong); } -.channel-row i { width: 20px; height: 20px; opacity: 0.6; } + +.channel-row:hover { + background: var(--bg-modifier-hover); + color: var(--text-normal); +} + +.channel-row.active { + background: var(--bg-modifier-selected); + color: var(--text-strong); +} + +.channel-row i { + width: 20px; + height: 20px; + opacity: 0.6; +} /* Sidebar Footer */ -.sidebar-footer { background: var(--bg-secondary); padding: 0; } +.sidebar-footer { + background: var(--bg-secondary); + padding: 0; +} .user-panel { padding: 8px; @@ -263,9 +390,15 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va gap: 8px; height: 52px; } -.user-panel:hover { background: var(--bg-modifier-hover); } -.avatar-wrapper { position: relative; } +.user-panel:hover { + background: var(--bg-modifier-hover); +} + +.avatar-wrapper { + position: relative; +} + .avatar { width: 32px; height: 32px; @@ -277,6 +410,7 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va font-weight: 700; font-size: 14px; } + .status-dot { position: absolute; bottom: -2px; @@ -286,9 +420,16 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va border-radius: 50%; border: 3px solid var(--bg-secondary); } -.status-dot.online { background: var(--green); } -.user-info { flex: 1; min-width: 0; } +.status-dot.online { + background: var(--green); +} + +.user-info { + flex: 1; + min-width: 0; +} + .display-name { font-size: 14px; font-weight: 600; @@ -297,9 +438,17 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va overflow: hidden; text-overflow: ellipsis; } -.user-status { font-size: 12px; color: var(--text-muted); } -.user-actions { display: flex; gap: 2px; } +.user-status { + font-size: 12px; + color: var(--text-muted); +} + +.user-actions { + display: flex; + gap: 2px; +} + .user-actions button { width: 32px; height: 32px; @@ -308,21 +457,57 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va place-items: center; color: var(--text-muted); } -.user-actions button:hover { background: var(--bg-modifier-hover); color: var(--text-normal); } -.user-actions i { width: 20px; height: 20px; } + +.user-actions button:hover { + background: var(--bg-modifier-hover); + color: var(--text-normal); +} + +.user-actions i { + width: 20px; + height: 20px; +} /* Voice Connection */ .voice-connection { padding: 8px; background: var(--bg-secondary); - border-bottom: 1px solid rgba(255,255,255,0.05); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); } -.vc-info { display: flex; align-items: center; gap: 8px; } -.vc-icon { color: var(--green); width: 20px; } -.vc-text { flex: 1; display: flex; flex-direction: column; } -.vc-status { color: var(--green); font-size: 14px; font-weight: 700; } -.vc-name { color: var(--text-muted); font-size: 12px; } -.vc-actions { display: flex; gap: 4px; } + +.vc-info { + display: flex; + align-items: center; + gap: 8px; +} + +.vc-icon { + color: var(--green); + width: 20px; +} + +.vc-text { + flex: 1; + display: flex; + flex-direction: column; +} + +.vc-status { + color: var(--green); + font-size: 14px; + font-weight: 700; +} + +.vc-name { + color: var(--text-muted); + font-size: 12px; +} + +.vc-actions { + display: flex; + gap: 4px; +} + .vc-actions button { width: 32px; height: 32px; @@ -331,7 +516,11 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va display: grid; place-items: center; } -.vc-actions button:hover { background: var(--bg-modifier-hover); color: var(--text-normal); } + +.vc-actions button:hover { + background: var(--bg-modifier-hover); + color: var(--text-normal); +} /* Chat Pane */ .chat-pane { @@ -347,11 +536,21 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va display: flex; align-items: center; gap: 8px; - border-bottom: 1px solid rgba(0,0,0,0.2); - box-shadow: 0 1px 0 rgba(0,0,0,0.1); + border-bottom: 1px solid rgba(0, 0, 0, 0.2); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.1); +} + +.header-icon { + color: var(--text-muted); + width: 24px; +} + +.chat-header h3 { + margin: 0; + font-size: 16px; + font-weight: 700; + color: var(--text-strong); } -.header-icon { color: var(--text-muted); width: 24px; } -.chat-header h3 { margin: 0; font-size: 16px; font-weight: 700; color: var(--text-strong); } .message-list { flex: 1; @@ -365,7 +564,10 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va gap: 16px; margin-top: 1.0625rem; } -.msg:hover { background: rgba(0,0,0,0.05); } + +.msg:hover { + background: rgba(0, 0, 0, 0.05); +} .msg-avatar { width: 40px; @@ -379,36 +581,77 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va font-weight: 600; } -.msg-content { flex: 1; min-width: 0; } -.msg-header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; } +.msg-content { + flex: 1; + min-width: 0; +} + +.msg-header { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 4px; +} + .msg-author { font-weight: 600; color: var(--text-strong); cursor: pointer; font-size: 1rem; } -.msg-author:hover { text-decoration: underline; } -.msg-time { font-size: 12px; color: var(--text-muted); } -.msg-body { color: var(--text-normal); line-height: 1.375rem; white-space: pre-wrap; word-wrap: break-word; } -.msg-grouped { margin-top: 0; padding-top: 0; padding-bottom: 0; } -.msg-grouped .msg-avatar, .msg-grouped .msg-header { display: none; } -.msg-grouped .msg-content { padding-left: 56px; } +.msg-author:hover { + text-decoration: underline; +} + +.msg-time { + font-size: 12px; + color: var(--text-muted); +} + +.msg-body { + color: var(--text-normal); + line-height: 1.375rem; + white-space: pre-wrap; + word-wrap: break-word; +} + +.msg-grouped { + margin-top: 0; + padding-top: 0; + padding-bottom: 0; +} + +.msg-grouped .msg-avatar, +.msg-grouped .msg-header { + display: none; +} + +.msg-grouped .msg-content { + padding-left: 56px; +} /* Chat Input */ -.chat-input-wrapper { padding: 0 16px 24px; } +.chat-input-wrapper { + padding: 0 16px 24px; +} + .message-form { background: var(--bg-input); border-radius: 8px; padding: 11px 16px; } + .message-form input { width: 100%; background: transparent; color: var(--text-normal); font-size: 16px; } -.message-form input::placeholder { color: var(--text-muted); } + +.message-form input::placeholder { + color: var(--text-muted); +} /* Member List */ .utility-sidebar { @@ -416,7 +659,13 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va display: flex; flex-direction: column; } -.member-list-wrapper { flex: 1; overflow-y: auto; padding: 12px 8px; } + +.member-list-wrapper { + flex: 1; + overflow-y: auto; + padding: 12px 8px; +} + .member-row { display: flex; align-items: center; @@ -426,7 +675,12 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va color: var(--text-muted); cursor: pointer; } -.member-row:hover { background: var(--bg-modifier-hover); color: var(--text-normal); } + +.member-row:hover { + background: var(--bg-modifier-hover); + color: var(--text-normal); +} + .member-avatar { width: 32px; height: 32px; @@ -439,23 +693,67 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va font-weight: 600; flex-shrink: 0; } + .member-name { - font-weight: 500; - font-size: 15px; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +/* Presence Badges */ +.avatar-wrapper { + position: relative; + display: inline-block; +} + +.status-badge { + position: absolute; + bottom: -2px; + right: -2px; + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid var(--bg-sidebar); + background: #747f8d; + /* Gray for offline */ +} + +.status-badge.online { + background: var(--green); +} + +.member-avatar, +.msg-avatar { + position: relative; +} + +.status-dot { + position: absolute; + bottom: 0; + right: 0; + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid var(--bg-sidebar); + background: #747f8d; +} + +.status-dot.online { + background: var(--green); +} + /* Modals */ .modal-container { position: fixed; - top: 0; left: 0; right: 0; bottom: 0; - background: rgba(0,0,0,0.85); + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.85); display: grid; place-items: center; z-index: 1000; } + .modal { background: var(--bg-sidebar); width: min(440px, 95vw); @@ -463,8 +761,17 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va padding: 24px; color: var(--text-normal); } -.modal h2 { margin: 0 0 16px; text-align: center; color: var(--text-strong); } -.form-item { margin-bottom: 20px; } + +.modal h2 { + margin: 0 0 16px; + text-align: center; + color: var(--text-strong); +} + +.form-item { + margin-bottom: 20px; +} + .form-item label { display: block; font-size: 12px; @@ -472,6 +779,7 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va color: var(--text-muted); margin-bottom: 8px; } + .form-item input { width: 100%; padding: 10px; @@ -485,8 +793,17 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va justify-content: flex-end; gap: 16px; } -.cancel-btn { padding: 10px 20px; color: var(--text-strong); font-weight: 500; } -.cancel-btn:hover { text-decoration: underline; } + +.cancel-btn { + padding: 10px 20px; + color: var(--text-strong); + font-weight: 500; +} + +.cancel-btn:hover { + text-decoration: underline; +} + .submit-btn { background: var(--brand); color: #fff; @@ -494,12 +811,28 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va border-radius: 3px; font-weight: 600; } -.submit-btn:hover { background: var(--brand-hover); } + +.submit-btn:hover { + background: var(--brand-hover); +} /* Radio Group for Channel Type */ -.radio-group { display: flex; flex-direction: column; gap: 8px; } -.radio-item { cursor: pointer; position: relative; } -.radio-item input { position: absolute; opacity: 0; } +.radio-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.radio-item { + cursor: pointer; + position: relative; +} + +.radio-item input { + position: absolute; + opacity: 0; +} + .radio-box { display: flex; align-items: center; @@ -509,21 +842,45 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va border-radius: 4px; transition: all 0.1s; } -.radio-item input:checked + .radio-box { background: var(--bg-modifier-selected); color: var(--text-strong); } -.radio-box i { width: 24px; height: 24px; color: var(--text-muted); } -.radio-text { display: flex; flex-direction: column; } -.radio-text strong { font-size: 16px; } -.radio-text span { font-size: 12px; color: var(--text-muted); } + +.radio-item input:checked+.radio-box { + background: var(--bg-modifier-selected); + color: var(--text-strong); +} + +.radio-box i { + width: 24px; + height: 24px; + color: var(--text-muted); +} + +.radio-text { + display: flex; + flex-direction: column; +} + +.radio-text strong { + font-size: 16px; +} + +.radio-text span { + font-size: 12px; + color: var(--text-muted); +} /* Mobile Overlay */ .overlay { position: fixed; - top: 0; left: 0; right: 0; bottom: 0; + top: 0; + left: 0; + right: 0; + bottom: 0; background: rgba(0, 0, 0, 0.7); z-index: 90; opacity: 1; transition: opacity 0.2s ease; } + .overlay.hidden { opacity: 0; pointer-events: none; @@ -541,39 +898,50 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va background: transparent; margin-right: 8px; } + .mobile-toggle-btn:hover { background: var(--bg-modifier-hover); border-radius: 4px; } @media (max-width: 1100px) { - .shell { grid-template-columns: 72px 240px 1fr; } + .shell { + grid-template-columns: 72px 240px 1fr; + } + .utility-sidebar { position: fixed; - top: 0; bottom: 0; right: 0; + top: 0; + bottom: 0; + right: 0; width: 240px; z-index: 100; - box-shadow: -2px 0 10px rgba(0,0,0,0.5); + box-shadow: -2px 0 10px rgba(0, 0, 0, 0.5); transform: translateX(100%); transition: transform 0.2s ease; } + .utility-sidebar.active { transform: translateX(0); display: flex; } - - .chat-header .mobile-toggle-btn { display: flex; } + + .chat-header .mobile-toggle-btn { + display: flex; + } } @media (max-width: 768px) { .shell { grid-template-columns: 1fr; } - + /* Hide sidebars by default on mobile */ - .server-rail, .channel-sidebar { + .server-rail, + .channel-sidebar { position: fixed; - top: 0; bottom: 0; + top: 0; + bottom: 0; z-index: 100; transition: transform 0.2s ease; } @@ -583,25 +951,28 @@ input, select { border: 0; outline: none; background: var(--bg-input); color: va width: 72px; transform: translateX(-100%); } - + .channel-sidebar { left: 72px; width: 240px; - transform: translateX(-312px); /* 72 + 240 */ - box-shadow: 2px 0 10px rgba(0,0,0,0.5); + transform: translateX(-312px); + /* 72 + 240 */ + box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5); } /* When menu is active */ .shell.menu-open .server-rail { transform: translateX(0); } + .shell.menu-open .channel-sidebar { transform: translateX(0); - display: flex; /* Override display: none from previous rule */ + display: flex; + /* Override display: none from previous rule */ } /* Adjust chat header for mobile */ .chat-header { padding: 0 8px; } -} +} \ No newline at end of file diff --git a/static/vendor/deepfilternet3/index.esm.js b/static/vendor/deepfilternet3/index.esm.js deleted file mode 100644 index 7ad3ee9..0000000 --- a/static/vendor/deepfilternet3/index.esm.js +++ /dev/null @@ -1,249 +0,0 @@ -class AssetLoader { - constructor(config = {}) { - this.cdnUrl = config.cdnUrl ?? 'https://cdn.mezon.ai/AI/models/datas/noise_suppression/deepfilternet3'; - } - getCdnUrl(relativePath) { - return `${this.cdnUrl}/${relativePath}`; - } - getAssetUrls() { - return { - wasm: this.getCdnUrl('v2/pkg/df_bg.wasm'), - model: this.getCdnUrl('v2/models/DeepFilterNet3_onnx.tar.gz') - }; - } - async fetchAsset(url) { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch asset: ${response.statusText}`); - } - return response.arrayBuffer(); - } -} -let defaultLoader = null; -function getAssetLoader(config) { - if (!defaultLoader || config) { - defaultLoader = new AssetLoader(config); - } - return defaultLoader; -} - -/** - * Creates a worklet module URL from inline code string - * This approach works with all bundlers without special configuration - */ -async function createWorkletModule(audioContext, workletCode) { - const blob = new Blob([workletCode], { type: 'application/javascript' }); - const blobUrl = URL.createObjectURL(blob); - await audioContext.audioWorklet.addModule(blobUrl); -} - -const WorkletMessageTypes = { - SET_SUPPRESSION_LEVEL: 'SET_SUPPRESSION_LEVEL', - SET_BYPASS: 'SET_BYPASS' -}; - -var workletCode = "(function () {\n 'use strict';\n\n let wasm;\n\n const heap = new Array(128).fill(undefined);\n\n heap.push(undefined, null, true, false);\n\n function getObject(idx) { return heap[idx]; }\n\n let heap_next = heap.length;\n\n function dropObject(idx) {\n if (idx < 132) return;\n heap[idx] = heap_next;\n heap_next = idx;\n }\n\n function takeObject(idx) {\n const ret = getObject(idx);\n dropObject(idx);\n return ret;\n }\n\n const cachedTextDecoder = (typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) : { decode: () => { throw Error('TextDecoder not available') } } );\n\n if (typeof TextDecoder !== 'undefined') { cachedTextDecoder.decode(); }\n let cachedUint8Memory0 = null;\n\n function getUint8Memory0() {\n if (cachedUint8Memory0 === null || cachedUint8Memory0.byteLength === 0) {\n cachedUint8Memory0 = new Uint8Array(wasm.memory.buffer);\n }\n return cachedUint8Memory0;\n }\n\n function getStringFromWasm0(ptr, len) {\n ptr = ptr >>> 0;\n return cachedTextDecoder.decode(getUint8Memory0().subarray(ptr, ptr + len));\n }\n\n function addHeapObject(obj) {\n if (heap_next === heap.length) heap.push(heap.length + 1);\n const idx = heap_next;\n heap_next = heap[idx];\n\n heap[idx] = obj;\n return idx;\n }\n /**\n * Set DeepFilterNet attenuation limit.\n *\n * Args:\n * - lim_db: New attenuation limit in dB.\n * @param {number} st\n * @param {number} lim_db\n */\n function df_set_atten_lim(st, lim_db) {\n wasm.df_set_atten_lim(st, lim_db);\n }\n\n /**\n * Get DeepFilterNet frame size in samples.\n * @param {number} st\n * @returns {number}\n */\n function df_get_frame_length(st) {\n const ret = wasm.df_get_frame_length(st);\n return ret >>> 0;\n }\n\n let WASM_VECTOR_LEN = 0;\n\n function passArray8ToWasm0(arg, malloc) {\n const ptr = malloc(arg.length * 1, 1) >>> 0;\n getUint8Memory0().set(arg, ptr / 1);\n WASM_VECTOR_LEN = arg.length;\n return ptr;\n }\n /**\n * Create a DeepFilterNet Model\n *\n * Args:\n * - path: File path to a DeepFilterNet tar.gz onnx model\n * - atten_lim: Attenuation limit in dB.\n *\n * Returns:\n * - DF state doing the full processing: stft, DNN noise reduction, istft.\n * @param {Uint8Array} model_bytes\n * @param {number} atten_lim\n * @returns {number}\n */\n function df_create(model_bytes, atten_lim) {\n const ptr0 = passArray8ToWasm0(model_bytes, wasm.__wbindgen_malloc);\n const len0 = WASM_VECTOR_LEN;\n const ret = wasm.df_create(ptr0, len0, atten_lim);\n return ret >>> 0;\n }\n\n let cachedFloat32Memory0 = null;\n\n function getFloat32Memory0() {\n if (cachedFloat32Memory0 === null || cachedFloat32Memory0.byteLength === 0) {\n cachedFloat32Memory0 = new Float32Array(wasm.memory.buffer);\n }\n return cachedFloat32Memory0;\n }\n\n function passArrayF32ToWasm0(arg, malloc) {\n const ptr = malloc(arg.length * 4, 4) >>> 0;\n getFloat32Memory0().set(arg, ptr / 4);\n WASM_VECTOR_LEN = arg.length;\n return ptr;\n }\n /**\n * Processes a chunk of samples.\n *\n * Args:\n * - df_state: Created via df_create()\n * - input: Input buffer of length df_get_frame_length()\n * - output: Output buffer of length df_get_frame_length()\n *\n * Returns:\n * - Local SNR of the current frame.\n * @param {number} st\n * @param {Float32Array} input\n * @returns {Float32Array}\n */\n function df_process_frame(st, input) {\n const ptr0 = passArrayF32ToWasm0(input, wasm.__wbindgen_malloc);\n const len0 = WASM_VECTOR_LEN;\n const ret = wasm.df_process_frame(st, ptr0, len0);\n return takeObject(ret);\n }\n\n function handleError(f, args) {\n try {\n return f.apply(this, args);\n } catch (e) {\n wasm.__wbindgen_exn_store(addHeapObject(e));\n }\n }\n\n (typeof FinalizationRegistry === 'undefined')\n ? { }\n : new FinalizationRegistry(ptr => wasm.__wbg_dfstate_free(ptr >>> 0));\n\n function __wbg_get_imports() {\n const imports = {};\n imports.wbg = {};\n imports.wbg.__wbindgen_object_drop_ref = function(arg0) {\n takeObject(arg0);\n };\n imports.wbg.__wbg_crypto_566d7465cdbb6b7a = function(arg0) {\n const ret = getObject(arg0).crypto;\n return addHeapObject(ret);\n };\n imports.wbg.__wbindgen_is_object = function(arg0) {\n const val = getObject(arg0);\n const ret = typeof(val) === 'object' && val !== null;\n return ret;\n };\n imports.wbg.__wbg_process_dc09a8c7d59982f6 = function(arg0) {\n const ret = getObject(arg0).process;\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_versions_d98c6400c6ca2bd8 = function(arg0) {\n const ret = getObject(arg0).versions;\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_node_caaf83d002149bd5 = function(arg0) {\n const ret = getObject(arg0).node;\n return addHeapObject(ret);\n };\n imports.wbg.__wbindgen_is_string = function(arg0) {\n const ret = typeof(getObject(arg0)) === 'string';\n return ret;\n };\n imports.wbg.__wbg_require_94a9da52636aacbf = function() { return handleError(function () {\n const ret = module.require;\n return addHeapObject(ret);\n }, arguments) };\n imports.wbg.__wbindgen_is_function = function(arg0) {\n const ret = typeof(getObject(arg0)) === 'function';\n return ret;\n };\n imports.wbg.__wbindgen_string_new = function(arg0, arg1) {\n const ret = getStringFromWasm0(arg0, arg1);\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_msCrypto_0b84745e9245cdf6 = function(arg0) {\n const ret = getObject(arg0).msCrypto;\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_randomFillSync_290977693942bf03 = function() { return handleError(function (arg0, arg1) {\n getObject(arg0).randomFillSync(takeObject(arg1));\n }, arguments) };\n imports.wbg.__wbg_getRandomValues_260cc23a41afad9a = function() { return handleError(function (arg0, arg1) {\n getObject(arg0).getRandomValues(getObject(arg1));\n }, arguments) };\n imports.wbg.__wbg_newnoargs_e258087cd0daa0ea = function(arg0, arg1) {\n const ret = new Function(getStringFromWasm0(arg0, arg1));\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_new_63b92bc8671ed464 = function(arg0) {\n const ret = new Uint8Array(getObject(arg0));\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_new_9efabd6b6d2ce46d = function(arg0) {\n const ret = new Float32Array(getObject(arg0));\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_buffer_12d079cc21e14bdb = function(arg0) {\n const ret = getObject(arg0).buffer;\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_newwithbyteoffsetandlength_aa4a17c33a06e5cb = function(arg0, arg1, arg2) {\n const ret = new Uint8Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0);\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_newwithlength_e9b4878cebadb3d3 = function(arg0) {\n const ret = new Uint8Array(arg0 >>> 0);\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_set_a47bac70306a19a7 = function(arg0, arg1, arg2) {\n getObject(arg0).set(getObject(arg1), arg2 >>> 0);\n };\n imports.wbg.__wbg_subarray_a1f73cd4b5b42fe1 = function(arg0, arg1, arg2) {\n const ret = getObject(arg0).subarray(arg1 >>> 0, arg2 >>> 0);\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_newwithbyteoffsetandlength_4a659d079a1650e0 = function(arg0, arg1, arg2) {\n const ret = new Float32Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0);\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_self_ce0dbfc45cf2f5be = function() { return handleError(function () {\n const ret = self.self;\n return addHeapObject(ret);\n }, arguments) };\n imports.wbg.__wbg_window_c6fb939a7f436783 = function() { return handleError(function () {\n const ret = window.window;\n return addHeapObject(ret);\n }, arguments) };\n imports.wbg.__wbg_globalThis_d1e6af4856ba331b = function() { return handleError(function () {\n const ret = globalThis.globalThis;\n return addHeapObject(ret);\n }, arguments) };\n imports.wbg.__wbg_global_207b558942527489 = function() { return handleError(function () {\n const ret = global.global;\n return addHeapObject(ret);\n }, arguments) };\n imports.wbg.__wbindgen_is_undefined = function(arg0) {\n const ret = getObject(arg0) === undefined;\n return ret;\n };\n imports.wbg.__wbg_call_27c0f87801dedf93 = function() { return handleError(function (arg0, arg1) {\n const ret = getObject(arg0).call(getObject(arg1));\n return addHeapObject(ret);\n }, arguments) };\n imports.wbg.__wbindgen_object_clone_ref = function(arg0) {\n const ret = getObject(arg0);\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_call_b3ca7c6051f9bec1 = function() { return handleError(function (arg0, arg1, arg2) {\n const ret = getObject(arg0).call(getObject(arg1), getObject(arg2));\n return addHeapObject(ret);\n }, arguments) };\n imports.wbg.__wbindgen_memory = function() {\n const ret = wasm.memory;\n return addHeapObject(ret);\n };\n imports.wbg.__wbindgen_throw = function(arg0, arg1) {\n throw new Error(getStringFromWasm0(arg0, arg1));\n };\n\n return imports;\n }\n\n function __wbg_finalize_init(instance, module) {\n wasm = instance.exports;\n cachedFloat32Memory0 = null;\n cachedUint8Memory0 = null;\n\n\n return wasm;\n }\n\n function initSync(module) {\n if (wasm !== undefined) return wasm;\n\n const imports = __wbg_get_imports();\n\n if (!(module instanceof WebAssembly.Module)) {\n module = new WebAssembly.Module(module);\n }\n\n const instance = new WebAssembly.Instance(module, imports);\n\n return __wbg_finalize_init(instance);\n }\n\n const WorkletMessageTypes = {\n SET_SUPPRESSION_LEVEL: 'SET_SUPPRESSION_LEVEL',\n SET_BYPASS: 'SET_BYPASS'\n };\n\n class DeepFilterAudioProcessor extends AudioWorkletProcessor {\n constructor(options) {\n super();\n this.dfModel = null;\n this.inputWritePos = 0;\n this.inputReadPos = 0;\n this.outputWritePos = 0;\n this.outputReadPos = 0;\n this.bypass = false;\n this.isInitialized = false;\n this.tempFrame = null;\n this.bufferSize = 8192;\n this.inputBuffer = new Float32Array(this.bufferSize);\n this.outputBuffer = new Float32Array(this.bufferSize);\n try {\n // Initialize WASM from pre-compiled module\n initSync(options.processorOptions.wasmModule);\n const modelBytes = new Uint8Array(options.processorOptions.modelBytes);\n const handle = df_create(modelBytes, options.processorOptions.suppressionLevel ?? 50);\n const frameLength = df_get_frame_length(handle);\n this.dfModel = { handle, frameLength };\n this.bufferSize = frameLength * 4;\n this.inputBuffer = new Float32Array(this.bufferSize);\n this.outputBuffer = new Float32Array(this.bufferSize);\n // Pre-allocate temp frame buffer for processing\n this.tempFrame = new Float32Array(frameLength);\n this.isInitialized = true;\n this.port.onmessage = (event) => {\n this.handleMessage(event.data);\n };\n }\n catch (error) {\n console.error('Failed to initialize DeepFilter in AudioWorklet:', error);\n this.isInitialized = false;\n }\n }\n handleMessage(data) {\n switch (data.type) {\n case WorkletMessageTypes.SET_SUPPRESSION_LEVEL:\n if (this.dfModel && typeof data.value === 'number') {\n const level = Math.max(0, Math.min(100, Math.floor(data.value)));\n df_set_atten_lim(this.dfModel.handle, level);\n }\n break;\n case WorkletMessageTypes.SET_BYPASS:\n this.bypass = Boolean(data.value);\n break;\n }\n }\n getInputAvailable() {\n return (this.inputWritePos - this.inputReadPos + this.bufferSize) % this.bufferSize;\n }\n getOutputAvailable() {\n return (this.outputWritePos - this.outputReadPos + this.bufferSize) % this.bufferSize;\n }\n process(inputList, outputList) {\n const sourceLimit = Math.min(inputList.length, outputList.length);\n const input = inputList[0]?.[0];\n if (!input) {\n return true;\n }\n // Passthrough mode - copy input to all output channels\n if (!this.isInitialized || !this.dfModel || this.bypass || !this.tempFrame) {\n for (let inputNum = 0; inputNum < sourceLimit; inputNum++) {\n const output = outputList[inputNum];\n const channelCount = output.length;\n for (let channelNum = 0; channelNum < channelCount; channelNum++) {\n output[channelNum].set(input);\n }\n }\n return true;\n }\n // Write input to ring buffer\n for (let i = 0; i < input.length; i++) {\n this.inputBuffer[this.inputWritePos] = input[i];\n this.inputWritePos = (this.inputWritePos + 1) % this.bufferSize;\n }\n const frameLength = this.dfModel.frameLength;\n while (this.getInputAvailable() >= frameLength) {\n // Extract frame from ring buffer\n for (let i = 0; i < frameLength; i++) {\n this.tempFrame[i] = this.inputBuffer[this.inputReadPos];\n this.inputReadPos = (this.inputReadPos + 1) % this.bufferSize;\n }\n const processed = df_process_frame(this.dfModel.handle, this.tempFrame);\n // Write to output ring buffer\n for (let i = 0; i < processed.length; i++) {\n this.outputBuffer[this.outputWritePos] = processed[i];\n this.outputWritePos = (this.outputWritePos + 1) % this.bufferSize;\n }\n }\n const outputAvailable = this.getOutputAvailable();\n if (outputAvailable >= 128) {\n for (let inputNum = 0; inputNum < sourceLimit; inputNum++) {\n const output = outputList[inputNum];\n const channelCount = output.length;\n for (let channelNum = 0; channelNum < channelCount; channelNum++) {\n const outputChannel = output[channelNum];\n let readPos = this.outputReadPos;\n for (let i = 0; i < 128; i++) {\n outputChannel[i] = this.outputBuffer[readPos];\n readPos = (readPos + 1) % this.bufferSize;\n }\n }\n }\n this.outputReadPos = (this.outputReadPos + 128) % this.bufferSize;\n }\n return true;\n }\n }\n registerProcessor('deepfilter-audio-processor', DeepFilterAudioProcessor);\n\n})();\n"; - -class DeepFilterNet3Core { - constructor(config = {}) { - this.assets = null; - this.workletNode = null; - this.isInitialized = false; - this.bypassEnabled = false; - this.config = { - sampleRate: config.sampleRate ?? 48000, - noiseReductionLevel: config.noiseReductionLevel ?? 50, - assetConfig: config.assetConfig - }; - this.assetLoader = getAssetLoader(config.assetConfig); - } - async initialize() { - if (this.isInitialized) - return; - // Fetch and compile WASM on main thread - const assetUrls = this.assetLoader.getAssetUrls(); - const [wasmBytes, modelBytes] = await Promise.all([ - this.assetLoader.fetchAsset(assetUrls.wasm), - this.assetLoader.fetchAsset(assetUrls.model) - ]); - // Compile WASM module - const wasmModule = await WebAssembly.compile(wasmBytes); - this.assets = { wasmModule, modelBytes }; - this.isInitialized = true; - } - async createAudioWorkletNode(audioContext) { - this.ensureInitialized(); - if (!this.assets) { - throw new Error('Assets not loaded'); - } - await createWorkletModule(audioContext, workletCode); - this.workletNode = new AudioWorkletNode(audioContext, 'deepfilter-audio-processor', { - processorOptions: { - wasmModule: this.assets.wasmModule, - modelBytes: this.assets.modelBytes, - suppressionLevel: this.config.noiseReductionLevel - } - }); - return this.workletNode; - } - setSuppressionLevel(level) { - if (!this.workletNode || typeof level !== 'number' || isNaN(level)) - return; - const clampedLevel = Math.max(0, Math.min(100, Math.floor(level))); - this.workletNode.port.postMessage({ - type: WorkletMessageTypes.SET_SUPPRESSION_LEVEL, - value: clampedLevel - }); - } - destroy() { - if (!this.isInitialized) - return; - if (this.workletNode) { - this.workletNode.disconnect(); - this.workletNode = null; - } - this.assets = null; - this.isInitialized = false; - } - isReady() { - return this.isInitialized && this.workletNode !== null; - } - setNoiseSuppressionEnabled(enabled) { - if (!this.workletNode) - return; - this.bypassEnabled = !enabled; - this.workletNode.port.postMessage({ - type: WorkletMessageTypes.SET_BYPASS, - value: !enabled - }); - } - isNoiseSuppressionEnabled() { - return !this.bypassEnabled; - } - ensureInitialized() { - if (!this.isInitialized) { - throw new Error('Processor not initialized. Call initialize() first.'); - } - } -} - -class DeepFilterNoiseFilterProcessor { - constructor(options = {}) { - this.name = 'deepfilternet3-noise-filter'; - this.audioContext = null; - this.sourceNode = null; - this.workletNode = null; - this.destination = null; - this.enabled = true; - this.init = async (opts) => { - const track = opts.track ?? opts.mediaStreamTrack; - if (!track) { - throw new Error('DeepFilterNoiseFilterProcessor.init: missing MediaStreamTrack'); - } - this.originalTrack = track; - await this.ensureGraph(); - }; - this.restart = async (opts) => { - const track = opts.track ?? opts.mediaStreamTrack; - if (track) { - this.originalTrack = track; - } - await this.ensureGraph(); - }; - this.setEnabled = async (enable) => { - this.enabled = enable; - this.processor.setNoiseSuppressionEnabled(enable); - return this.enabled; - }; - this.suspend = async () => { - if (this.audioContext && this.audioContext.state === 'running') { - await this.audioContext.suspend(); - } - }; - this.resume = async () => { - if (this.audioContext && this.audioContext.state === 'suspended') { - await this.audioContext.resume(); - } - }; - this.destroy = async () => { - await this.teardownGraph(); - this.processor.destroy(); - }; - const cfg = { - sampleRate: options.sampleRate ?? 48000, - noiseReductionLevel: options.noiseReductionLevel ?? 80, - assetConfig: options.assetConfig - }; - this.enabled = options.enabled ?? true; - this.processor = new DeepFilterNet3Core(cfg); - } - static isSupported() { - return typeof AudioContext !== 'undefined' && typeof WebAssembly !== 'undefined'; - } - setSuppressionLevel(level) { - this.processor.setSuppressionLevel(level); - } - isEnabled() { - return this.enabled; - } - isNoiseSuppressionEnabled() { - return this.processor.isNoiseSuppressionEnabled(); - } - async ensureGraph() { - if (!this.originalTrack) { - throw new Error('No source track'); - } - this.audioContext ?? (this.audioContext = new AudioContext({ sampleRate: 48000 })); - if (this.audioContext.state !== 'running') { - try { - await this.audioContext.resume(); - } - catch { - // Ignore resume errors - } - } - await this.processor.initialize(); - if (!this.workletNode) { - const node = await this.processor.createAudioWorkletNode(this.audioContext); - this.workletNode = node; - } - if (!this.destination) { - this.destination = this.audioContext.createMediaStreamDestination(); - this.processedTrack = this.destination.stream.getAudioTracks()[0]; - } - if (this.sourceNode) { - this.sourceNode.disconnect(); - } - this.sourceNode = this.audioContext.createMediaStreamSource(new MediaStream([this.originalTrack])); - this.sourceNode.connect(this.workletNode).connect(this.destination); - await this.setEnabled(this.enabled); - } - async teardownGraph() { - try { - if (this.workletNode) { - this.workletNode.disconnect(); - this.workletNode = null; - } - if (this.sourceNode) { - this.sourceNode.disconnect(); - this.sourceNode = null; - } - if (this.destination) { - this.destination.disconnect(); - this.destination = null; - } - if (this.audioContext) { - void this.audioContext.close(); - this.audioContext = null; - } - } - catch { - // Ignore disconnect errors - } - } -} -function DeepFilterNoiseFilter(options) { - return new DeepFilterNoiseFilterProcessor(options); -} - -export { AssetLoader, DeepFilterNet3Core, DeepFilterNoiseFilter, DeepFilterNoiseFilterProcessor, getAssetLoader }; diff --git a/static/vendor/deepfilternet3/v2/models/DeepFilterNet3_onnx.tar.gz b/static/vendor/deepfilternet3/v2/models/DeepFilterNet3_onnx.tar.gz deleted file mode 100644 index 1c4f4ff..0000000 Binary files a/static/vendor/deepfilternet3/v2/models/DeepFilterNet3_onnx.tar.gz and /dev/null differ diff --git a/static/vendor/deepfilternet3/v2/pkg/df_bg.wasm b/static/vendor/deepfilternet3/v2/pkg/df_bg.wasm deleted file mode 100644 index fac0f9e..0000000 Binary files a/static/vendor/deepfilternet3/v2/pkg/df_bg.wasm and /dev/null differ