1 Commits

Author SHA1 Message Date
sam 0c1479a3ee Upgrade to doxus 0.7.2
Build Mumble Web 2 / linux_build (push) Failing after 58s
Build Mumble Web 2 / windows_build (push) Has been cancelled
2025-12-04 22:22:35 -07:00
29 changed files with 881 additions and 1339 deletions
@@ -1,27 +0,0 @@
name: Build android container
on:
workflow_dispatch:
schedule:
- cron: "0 4 * * *"
jobs:
android-release-builder-container-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: git.ohea.xyz
username: ${{ secrets.CI_REGISTRY_USER }}
password: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Build Android builder image
shell: bash
run: |
docker pull "$(grep -m1 '^FROM' ./docker/android-release-builder.Dockerfile | awk '{print $2}')"
docker build -t git.ohea.xyz/mumble/mumble-web2/android-release-builder:latest -f ./docker/android-release-builder.Dockerfile .
docker push git.ohea.xyz/mumble/mumble-web2/android-release-builder:latest
+1 -24
View File
@@ -20,7 +20,7 @@ jobs:
- name: Install dioxus-cli - name: Install dioxus-cli
run: cargo binstall dioxus-cli --version 0.7.2 run: cargo binstall dioxus-cli --version 0.7.2
#- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- name: Build dioxus project - name: Build dioxus project
run: dx build --platform web --release -p mumble-web2-gui run: dx build --platform web --release -p mumble-web2-gui
@@ -83,26 +83,3 @@ jobs:
name: mumble-web2-gui-windows name: mumble-web2-gui-windows
path: gui/dist path: gui/dist
retention-days: 5 retention-days: 5
android_build:
runs-on: ubuntu-latest
container:
image: git.ohea.xyz/mumble/mumble-web2/android-release-builder:latest
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: Swatinem/rust-cache@v2
- name: Build dioxus project (x86_64-linux-android)
run: dx build --platform android --target x86_64-linux-android --release -p mumble-web2-gui
- name: Build dioxus project (aarch64-linux-android)
run: dx build --platform android --target aarch64-linux-android --release -p mumble-web2-gui
- name: Upload mumble-web2-gui Android Artifact
uses: https://gitea.com/actions/gitea-upload-artifact@v4
with:
name: mumble-web2-android
path: target/dx/mumble-web2-gui/release/android/app/app/build/outputs/apk/debug/app-debug.apk
retention-days: 5
@@ -22,6 +22,6 @@ jobs:
- name: Build Windows image - name: Build Windows image
shell: bash shell: bash
run: | run: |
docker pull "$(grep -m1 '^FROM' ./docker/windows-release-builder.Dockerfile | awk '{print $2}')" docker pull "$(grep -m1 '^FROM' Dockerfile | awk '{print $2}')"
docker build -t git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest -f ./docker/windows-release-builder.Dockerfile . docker build -t git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest -f ./docker/windows-release-builder.Dockerfile .
docker push git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest docker push git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest
-4
View File
@@ -1,4 +0,0 @@
{
"rust-analyzer.cargo.features": ["desktop","web"],
"rust-analyzer.cargo.noDefaultFeatures": false
}
Generated
+390 -173
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -2,12 +2,12 @@
## Running Desktop ## Running Desktop
1. `cargo install dioxus-cli --version 0.7.1` 1. `cargo install dioxus-cli --version 0.7.2`
2. `dx run -p mumble-web2-gui --platform desktop --release` 2. `dx run -p mumble-web2-gui --platform desktop --release`
## Running Web (development) ## Running Web (development)
1. `cargo install dioxus-cli --version 0.7.1` 1. `cargo install dioxus-cli --version 0.7.2`
3. `dx serve -p mumble-web2-gui --platform web` 3. `dx serve -p mumble-web2-gui --platform web`
2. `cd docker && docker compose up` 2. `cd docker && docker compose up`
4. connect to `https://localhost:64444` 4. connect to `https://localhost:64444`
@@ -15,7 +15,7 @@
## Running Web (with `proxy` only) ## Running Web (with `proxy` only)
1. `cargo install dioxus-cli --version 0.7.1` 1. `cargo install dioxus-cli --version 0.7.2`
2. `dx build -p mumble-web2-gui --platform web --release` 2. `dx build -p mumble-web2-gui --platform web --release`
3. `cp config.toml.example config.toml` 3. `cp config.toml.example config.toml`
4. `cargo run -p mumble-web2-proxy` in the background 4. `cargo run -p mumble-web2-proxy` in the background
+1
View File
@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize, Default)] #[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ClientConfig { pub struct ClientConfig {
pub proxy_url: Option<String>, pub proxy_url: Option<String>,
pub status_url: Option<String>,
pub cert_hash: Option<Vec<u8>>, pub cert_hash: Option<Vec<u8>>,
pub any_server: bool, pub any_server: bool,
} }
+1 -1
View File
@@ -1,4 +1,4 @@
proxy_url = "https://127.0.0.1:4433/proxy" public_url = "https://127.0.0.1:4433"
https_listen_address = "127.0.0.1:4433" https_listen_address = "127.0.0.1:4433"
http_listen_address = "127.0.0.1:8080" http_listen_address = "127.0.0.1:8080"
mumble_server_url = "[SERVER_URL_HERE]" mumble_server_url = "[SERVER_URL_HERE]"
-43
View File
@@ -1,43 +0,0 @@
FROM rust:trixie
ARG ANDROID_CLI_TOOLS_VERSION=13114758
# Install android rust toolchains
RUN rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
# Install debian dependencies
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
nodejs \
ca-certificates \
curl \
unzip \
default-jdk
# Install android commandline tools (required to install the sdk)
RUN cd /tmp && \
curl -o commandlinetools-linux.zip "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CLI_TOOLS_VERSION}_latest.zip" && \
unzip commandlinetools-linux.zip && \
mkdir -p /opt/android-tools/cmdline-tools && \
cp -r cmdline-tools /opt/android-tools/cmdline-tools/latest
# Install required android tools
RUN yes | /opt/android-tools/cmdline-tools/latest/bin/sdkmanager --install "platform-tools" "platforms;android-36.1" "build-tools;36.1.0" "ndk;29.0.14206865" "cmake;3.31.6"
# Install cargo binstall
RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
# Install dioxus-cli
RUN cargo binstall dioxus-cli@0.7.2
# Install bindgen-cli
RUN cargo binstall bindgen-cli
# Set required env vars
ENV ANDROID_HOME="/opt/android-tools/"
ENV NDK_HOME="$ANDROID_HOME/ndk/29.0.14206865"
ENV PATH="$PATH:$ANDROID_HOME/platform-tools"
ENV PATH="$PATH:/opt/android-tools/cmake/3.31.6/bin/"
ENV LLVM_CONFIG_PATH="/opt/android-tools/ndk/29.0.14206865/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-config"
+3 -3
View File
@@ -5,7 +5,7 @@ services:
- "64444:64444/tcp" - "64444:64444/tcp"
- "64444:64444/udp" - "64444:64444/udp"
volumes: volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:z - ./Caddyfile:/etc/caddy/Caddyfile
#- caddy_data:/data #- caddy_data:/data
#- caddy_config:/config #- caddy_config:/config
depends_on: depends_on:
@@ -35,8 +35,8 @@ services:
image: rust:latest image: rust:latest
working_dir: /app working_dir: /app
volumes: volumes:
- ..:/app:z - ..:/app
- ./proxy-config.toml:/app/config.toml:z - ./proxy-config.toml:/app/config.toml
ports: ports:
- "4433:4433/tcp" - "4433:4433/tcp"
- "4433:4433/udp" - "4433:4433/udp"
+1
View File
@@ -1,3 +1,4 @@
public_url = "https://localhost:64444"
proxy_url = "https://127.0.0.1:4433/proxy" proxy_url = "https://127.0.0.1:4433/proxy"
https_listen_address = "127.0.0.1:4433" https_listen_address = "127.0.0.1:4433"
http_listen_address = "127.0.0.1:4400" http_listen_address = "127.0.0.1:4400"
+3 -6
View File
@@ -44,14 +44,11 @@ RUN choco install rustup.install -y --no-progress
RUN rustup toolchain install stable-x86_64-pc-windows-msvc RUN rustup toolchain install stable-x86_64-pc-windows-msvc
RUN rustup default stable-x86_64-pc-windows-msvc RUN rustup default stable-x86_64-pc-windows-msvc
# Install carog binstall
RUN Set-ExecutionPolicy Unrestricted -Scope Process; ` RUN Set-ExecutionPolicy Unrestricted -Scope Process; `
iex (Invoke-WebRequest "https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.ps1" -UseBasicParsing).Content iex (iwr "https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.ps1").Content
SHELL ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"] SHELL ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
# Install dioxus-cli from git HEAD with cargo
# This is to work around a bug in the windows builder upstream. RUN cargo binstall dioxus-cli --version 0.7.2
# Dioxus has released 0.7.2, but it seems to be broken for now.
RUN cargo binstall dioxus-cli
ENTRYPOINT ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"] ENTRYPOINT ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
+6 -31
View File
@@ -6,7 +6,7 @@ edition = "2021"
[dependencies] [dependencies]
# Web Dependencies # Web Dependencies
# ================ # ================
dioxus-web = { version = "0.7.1", optional = true } dioxus-web = { version = "0.7.2", optional = true }
wasm-bindgen = { version = "^0.2.92", optional = true } wasm-bindgen = { version = "^0.2.92", optional = true }
wasm-bindgen-futures = { version = "^0.4.42", optional = true } wasm-bindgen-futures = { version = "^0.4.42", optional = true }
wasm-streams = { version = "^0.4.0", optional = true } wasm-streams = { version = "^0.4.0", optional = true }
@@ -30,6 +30,8 @@ web-sys = { version = "^0.3.72", features = [
"EncodedAudioChunkInit", "EncodedAudioChunkInit",
"EncodedAudioChunkType", "EncodedAudioChunkType",
"CodecState", "CodecState",
"MediaStreamTrackGenerator",
"MediaStreamTrackGeneratorInit",
"AudioContext", "AudioContext",
"AudioContextOptions", "AudioContextOptions",
"MediaStream", "MediaStream",
@@ -40,7 +42,6 @@ web-sys = { version = "^0.3.72", features = [
"AudioWorkletNode", "AudioWorkletNode",
"AudioWorklet", "AudioWorklet",
"AudioWorkletProcessor", "AudioWorkletProcessor",
"MessagePort",
"MediaStreamConstraints", "MediaStreamConstraints",
"WorkletOptions", "WorkletOptions",
"AudioEncoder", "AudioEncoder",
@@ -63,7 +64,6 @@ tokio-rustls = { version = "^0.26.0", optional = true }
opus = { version = "0.3.0", optional = true } opus = { version = "0.3.0", optional = true }
cpal = { version = "0.15.3", optional = true } cpal = { version = "0.15.3", optional = true }
dasp_ring_buffer = { version = "0.11.0", optional = true } dasp_ring_buffer = { version = "0.11.0", optional = true }
etcetera = { version = "0.10.0", optional = true }
# Base Dependencies # Base Dependencies
# ================ # ================
@@ -88,32 +88,18 @@ tracing = "^0.1.40"
color-eyre = "^0.6.3" color-eyre = "^0.6.3"
crossbeam-queue = "^0.3.11" crossbeam-queue = "^0.3.11"
lol_html = "^2.2.0" lol_html = "^2.2.0"
rfd = { git = "https://github.com/samsartor/rfd.git", version = "^0.16.0", default-features = false }
base64 = "^0.22" base64 = "^0.22"
mime_guess = "^2.0.5" mime_guess = "^2.0.5"
async_cell = "^0.2.3" async_cell = "^0.2.3"
reqwest = { version = "^0.12.22", features = ["json"] } reqwest = { version = "^0.12.22", features = ["json"] }
dioxus-asset-resolver = "0.7.2" dioxus-asset-resolver = "0.7.2"
# Denoising # Denoising
# ========= # =========
deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = [ deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = ["tract"] }
"tract",
] }
crossbeam = "0.8.4" crossbeam = "0.8.4"
# Platform Integration
# ====================
# rfd only supports windows, macos, linux, and wasm32. No support for Android or iOS
[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos", target_arch = "wasm32"))'.dependencies]
rfd = { git = "https://github.com/PolyMeilex/rfd.git", version = "^0.16.0", default-features = false, optional = true }
# Android dependencies for requesting permissions
[target.'cfg(target_os = "android")'.dependencies]
android-permissions = "0.1.2"
jni = "0.21.1"
ndk-context = "0.1.1"
[patch.crates-io] [patch.crates-io]
tract-hir = "=0.12.4" tract-hir = "=0.12.4"
tract-core = "=0.12.4" tract-core = "=0.12.4"
@@ -133,7 +119,6 @@ web = [
"gloo-timers", "gloo-timers",
"tracing-web", "tracing-web",
"deep_filter/wasm", "deep_filter/wasm",
"rfd",
] ]
desktop = [ desktop = [
"dioxus/desktop", "dioxus/desktop",
@@ -144,15 +129,5 @@ desktop = [
"cpal", "cpal",
"dasp_ring_buffer", "dasp_ring_buffer",
"rfd/xdg-portal", "rfd/xdg-portal",
"etcetera", "rfd/tokio",
]
mobile = [
"dioxus/mobile",
"tokio",
"tokio-rustls",
"tracing-subscriber/env-filter",
"opus",
"cpal",
"dasp_ring_buffer",
] ]
+2 -4
View File
@@ -8,8 +8,6 @@ out_dir = "dist"
# resource (public) file folder # resource (public) file folder
asset_dir = "public" asset_dir = "public"
android_manifest = "build/AndroidManifest.xml"
[web.app] [web.app]
# HTML title tag content # HTML title tag content
title = "Mumble Web 2" title = "Mumble Web 2"
@@ -25,7 +23,7 @@ watch_path = ["src", "assets"]
# CSS style file # CSS style file
style = [] style = []
# Javascript code file # Javascript code file
script = ["loader.js"] script = []
[web.resource.dev] [web.resource.dev]
# serve: [dev-server] only # serve: [dev-server] only
@@ -35,7 +33,7 @@ style = []
script = [] script = []
[bundle] [bundle]
identifier = "xyz.ohea.mumble_web_2" identifier = "xyz.ohea.mumble-web-2"
publisher = "OheaCorp" publisher = "OheaCorp"
icon = [ icon = [
"icons/32x32.png", "icons/32x32.png",
+10 -100
View File
@@ -168,68 +168,26 @@ a:visited {
background-color: oklch(0.53 0.1431 264.18); background-color: oklch(0.53 0.1431 264.18);
border-radius: 50%; border-radius: 50%;
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
flex-shrink: 0;
.material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
}
} }
.button_row { .button_row {
display: flex; display: flex;
gap: clamp(4px, 1vw, 10px); gap: 10px;
align-items: center;
flex-wrap: nowrap;
min-height: 0;
.spacer { .spacer {
flex-grow: 1; flex-grow: 1;
flex-shrink: 1;
min-width: 0;
}
.connection_status {
display: flex;
flex-direction: column;
min-width: 0;
flex-shrink: 1;
.material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
vertical-align: middle;
}
}
.user_info {
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
flex-shrink: 1;
.user_name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user_data {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
} }
} }
.toggle_button { .toggle_button {
padding: clamp(4px, 0.5vw, 8px); padding: 8px;
height: 100%;
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
flex-shrink: 0;
background-color: unset; background-color: unset;
border: solid rgb(255 255 255 / 0.1) clamp(1px, 0.3vw, 3px); border: solid rgb(255 255 255 / 0.1) 3px;
border-radius: clamp(4px, 0.8vw, 10px); border-radius: 10px;
color: rgb(255 255 255 / 50%); color: rgb(255 255 255 / 50%);
transition: all 0.5s ease-in-out; transition: all 0.5s ease-in-out;
@@ -242,6 +200,7 @@ a:visited {
.material-symbols-outlined { .material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
vertical-align: middle; vertical-align: middle;
font-size: 35px;
} }
} }
@@ -286,60 +245,16 @@ a:visited {
} }
&_control_box { &_control_box {
padding: clamp(6px, 0.8vw, 12px); padding: 16px;
margin: clamp(6px, 0.8vw, 12px); margin: 16px;
background-color: var(--light-bg-color); background-color: var(--light-bg-color);
border-radius: clamp(6px, 1vw, 10px); border-radius: 10px;
overflow: hidden; overflow: hidden;
grid-area: control; grid-area: control;
display: flex; display: flex;
gap: clamp(4px, 0.8vw, 8px); gap: 10px;
flex-direction: column; flex-direction: column;
// Dynamic font sizing for control elements
--control-icon-size: clamp(16px, 2.5vw, 30px);
--control-text-size: clamp(12px, 2vw, 25px);
--control-small-text-size: clamp(10px, 1.5vw, 20px);
--user-icon-size: clamp(24px, 4vw, 45px);
--toggle-icon-size: clamp(18px, 3vw, 35px);
.connection_status {
.material-symbols-outlined {
font-size: var(--control-icon-size);
}
.status_text {
font-size: var(--control-text-size);
}
.channel_text {
font-size: var(--control-small-text-size);
}
}
.user_edit_button {
.material-symbols-outlined {
font-size: var(--user-icon-size);
}
}
.user_info {
.user_name {
font-size: var(--control-text-size);
}
.user_data {
font-size: var(--control-small-text-size);
}
}
.toggle_button {
.material-symbols-outlined {
font-size: var(--toggle-icon-size);
}
}
hr {
margin: 0;
}
} }
} }
@@ -364,11 +279,6 @@ a:visited {
color: #b3c6b4; color: #b3c6b4;
} }
&_version {
color: var(--txt-color);
font-weight: normal;
}
&_bttn { &_bttn {
font-weight: bold; font-weight: bold;
font-size: large; font-size: large;
@@ -1,7 +1,7 @@
const SAMPLE_RATE = 48000; const SAMPLE_RATE = 48000;
const PACKET_SAMPLES = 960; const PACKET_SAMPLES = 960;
class RustMicWorklet extends AudioWorkletProcessor { class RustWorklet extends AudioWorkletProcessor {
constructor(options) { constructor(options) {
super(); super();
this.module = options.processorOptions; this.module = options.processorOptions;
@@ -31,7 +31,7 @@ class RustMicWorklet extends AudioWorkletProcessor {
} }
this.buffer_offset -= PACKET_SAMPLES; this.buffer_offset -= PACKET_SAMPLES;
this.timestamp = null; this.timestamp = null;
} }
process(inputs) { process(inputs) {
//console.log(inputs); //console.log(inputs);
@@ -60,44 +60,4 @@ class RustMicWorklet extends AudioWorkletProcessor {
} }
}; };
registerProcessor("rust_mic_worklet", RustWorklet);
class RustSpeakerWorklet extends AudioWorkletProcessor {
constructor() {
super();
this.queue = [];
this.readIndex = 0;
this.port.onmessage = (event) => {
this.queue.push(event.data)
};
}
process(inputs, outputs) {
if (this.queue.length) {
console.log(this.queue[0].samples.length, outputs[0][0].length);
}
const output = outputs[0];
for (let i = 0; i < output[0].length; i++) {
if (!this.queue.length) {
return true;
}
const current = this.queue[0];
for (let ch = 0; ch < output.length; ch++) {
output[ch][i] = current.samples[this.readIndex];
}
this.readIndex++;
if (this.readIndex >= current.samples.length) {
this.queue.shift();
this.readIndex = 0;
}
}
return true;
}
};
registerProcessor("rust_mic_worklet", RustMicWorklet);
registerProcessor("rust_speaker_worklet", RustSpeakerWorklet);
+8 -56
View File
@@ -1,39 +1,7 @@
use std::env;
use std::path::Path; use std::path::Path;
use std::process::Command; use std::process::Command;
fn version_env() -> Option<()> { fn main() {
if env::var("MUMBLE_WEB2_VERSION").is_ok() {
return Some(());
}
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()?;
let git_hash = String::from_utf8(output.stdout).ok()?;
let git_hash = git_hash.trim(); // drop trailing newline
let status = Command::new("git")
.args(["status", "--porcelain"])
.output()
.ok()?;
let dirty = match status.stdout.is_empty() {
true => "",
false => "-dirty",
};
// Expose it as a compile-time env var
println!("cargo::rustc-env=MUMBLE_WEB2_VERSION=git-{git_hash}{dirty}");
// Optional: rebuild when HEAD changes
println!("cargo::rerun-if-changed=.git/HEAD");
Some(())
}
fn download_deepfilternet() {
// Define the target directory and file // Define the target directory and file
let assets_dir = "assets"; let assets_dir = "assets";
let target_file = format!("{}/DeepFilterNet3_ll_onnx.tar.gz", assets_dir); let target_file = format!("{}/DeepFilterNet3_ll_onnx.tar.gz", assets_dir);
@@ -41,46 +9,30 @@ fn download_deepfilternet() {
// Check if the file already exists // Check if the file already exists
if target_path.exists() { if target_path.exists() {
println!( println!("cargo:warning=DeepFilterNet model already exists at {}", target_file);
"cargo::warning=DeepFilterNet model already exists at {}",
target_file
);
return; return;
} }
println!( println!("cargo:warning=Downloading DeepFilterNet model to {}...", target_file);
"cargo::warning=Downloading DeepFilterNet model to {}...",
target_file
);
// Download the file using curl // Download the file using curl
let url = "https://github.com/Rikorose/DeepFilterNet/raw/refs/heads/main/models/DeepFilterNet3_ll_onnx.tar.gz"; let url = "https://github.com/Rikorose/DeepFilterNet/raw/refs/heads/main/models/DeepFilterNet3_ll_onnx.tar.gz";
let status = Command::new("curl") let status = Command::new("curl")
.args([ .args([
"-L", // Follow redirects "-L", // Follow redirects
"-o", "-o", &target_file, // Output file
&target_file, // Output file
url, url,
]) ])
.status() .status()
.expect("Failed to execute curl command. Make sure curl is installed."); .expect("Failed to execute curl command. Make sure curl is installed.");
if !status.success() { if !status.success() {
println!("cargo::error=Failed to download DeepFilterNet model from {url}"); panic!("Failed to download DeepFilterNet model from {}", url);
return;
} }
println!( println!("cargo:warning=Successfully downloaded DeepFilterNet model to {}", target_file);
"cargo::warning=Successfully downloaded DeepFilterNet model to {}",
target_file
);
// Rerun this build script if the target file is deleted // Rerun this build script if the target file is deleted
println!("cargo::rerun-if-changed={}", target_file); println!("cargo:rerun-if-changed={}", target_file);
}
fn main() {
version_env();
download_deepfilternet();
} }
-32
View File
@@ -1,32 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Borrowed from https://github.com/irh/audio-app/blob/main/apps/dioxus/AndroidManifest.xml
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature android:name="android.hardware.audio.low_latency" android:required="false" />
<uses-feature android:name="android.hardware.audio.output" android:required="false" />
<uses-feature android:name="android.hardware.audio.pro" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<application android:hasCode="true" android:supportsRtl="true" android:icon="@mipmap/ic_launcher"
android:extractNativeLibs="true"
android:allowNativeHeapPointerTagging="false"
android:label="@string/app_name"
android:theme="@style/AppTheme"
android:networkSecurityConfig="@xml/network_security_config">
<activity android:configChanges="orientation|screenLayout|screenSize|keyboardHidden" android:exported="true"
android:label="@string/app_name" android:name="dev.dioxus.main.MainActivity">
<meta-data android:name="android.app.lib_name" android:value="dioxusmain" />
<meta-data android:name="android.app.func_name" android:value="ANativeActivity_onCreate" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
-68
View File
@@ -1,68 +0,0 @@
// Loading screen that displays while WASM loads
(function() {
// Create and inject loader styles immediately (head exists)
var style = document.createElement('style');
style.textContent =
'.wasm-loader {' +
'position: fixed;' +
'top: 0;' +
'left: 0;' +
'width: 100%;' +
'height: 100%;' +
'background-color: oklch(0.15 0.01 338.64);' +
'display: flex;' +
'align-items: center;' +
'justify-content: center;' +
'z-index: 9999;' +
'transition: opacity 0.3s ease-out;' +
'}' +
'.wasm-loader.hidden {' +
'opacity: 0;' +
'pointer-events: none;' +
'}' +
'.wasm-spinner {' +
'width: 48px;' +
'height: 48px;' +
'border: 4px solid rgba(123, 173, 159, 0.2);' +
'border-top-color: #7bad9f;' +
'border-radius: 50%;' +
'animation: wasm-spin 1s linear infinite;' +
'}' +
'@keyframes wasm-spin {' +
'to { transform: rotate(360deg); }' +
'}' +
'#main {' +
'background-color: oklch(0.15 0.01 338.64);' +
'}';
document.head.appendChild(style);
function init() {
// Create loader element
var loader = document.createElement('div');
loader.className = 'wasm-loader';
loader.innerHTML = '<div class="wasm-spinner"></div>';
document.body.appendChild(loader);
// Watch for Dioxus to mount content in #main
var observer = new MutationObserver(function(mutations, obs) {
var main = document.getElementById('main');
if (main && main.children.length > 0) {
loader.classList.add('hidden');
setTimeout(function() { loader.remove(); }, 300);
obs.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// Wait for body to exist
if (document.body) {
init();
} else {
document.addEventListener('DOMContentLoaded', init);
}
})();
+81 -74
View File
@@ -68,21 +68,16 @@ pub struct UserState {
pub channel: ChannelId, pub channel: ChannelId,
pub deaf: bool, pub deaf: bool,
pub mute: bool, pub mute: bool,
pub suppress: bool,
pub self_deaf: bool, pub self_deaf: bool,
pub self_mute: bool, pub self_mute: bool,
} }
impl UserState { impl UserState {
pub fn icon(&self) -> UserIcon { pub fn icon(&self) -> UserIcon {
if self.deaf || self.self_deaf { match (self.mute || self.self_mute, self.deaf || self.self_deaf) {
UserIcon::Deafened (false, false) => UserIcon::Normal,
} else if self.mute || self.self_mute { (true, false) => UserIcon::Muted,
UserIcon::Muted (_, true) => UserIcon::Deafened,
} else if self.suppress {
UserIcon::Suppressed
} else {
UserIcon::Normal
} }
} }
} }
@@ -122,7 +117,6 @@ pub enum UserIcon {
Normal, Normal,
Muted, Muted,
Deafened, Deafened,
Suppressed,
None, None,
} }
@@ -134,7 +128,7 @@ impl UserIcon {
use UserIcon::*; use UserIcon::*;
Some(match self { Some(match self {
Normal => asset!("assets/mic-svgrepo-com.svg"), Normal => asset!("assets/mic-svgrepo-com.svg"),
Muted | Suppressed => asset!("assets/mic-off-svgrepo-com.svg"), Muted => asset!("assets/mic-off-svgrepo-com.svg"),
Deafened => asset!("assets/speaker-muted-svgrepo-com.svg"), Deafened => asset!("assets/speaker-muted-svgrepo-com.svg"),
None => return Option::None, None => return Option::None,
}) })
@@ -146,7 +140,7 @@ pub fn UserPill(name: String, icon: UserIcon, isself: bool) -> Element {
let color = match icon { let color = match icon {
UserIcon::Normal => "var(--accent-normal)", UserIcon::Normal => "var(--accent-normal)",
UserIcon::Muted => "var(--accent-muted)", UserIcon::Muted => "var(--accent-muted)",
UserIcon::Suppressed | UserIcon::Deafened => "var(--accent-deafened)", UserIcon::Deafened => "var(--accent-deafened)",
UserIcon::None => "var(--accent-normal)", UserIcon::None => "var(--accent-normal)",
}; };
@@ -216,7 +210,6 @@ pub fn Channel(id: ChannelId) -> Element {
) )
} }
#[cfg(any(feature = "desktop", feature = "web"))]
pub fn pick_and_send_file(net: &Coroutine<Command>) { pub fn pick_and_send_file(net: &Coroutine<Command>) {
let channels = if let Some(user) = STATE.server.read().this_user() { let channels = if let Some(user) = STATE.server.read().this_user() {
vec![user.channel] vec![user.channel]
@@ -238,8 +231,6 @@ pub fn pick_and_send_file(net: &Coroutine<Command>) {
}); });
}); });
} }
#[cfg(not(any(feature = "desktop", feature = "web")))]
pub fn pick_and_send_file(net: &Coroutine<Command>) {}
#[component] #[component]
pub fn ChatView() -> Element { pub fn ChatView() -> Element {
@@ -326,7 +317,6 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
deaf, deaf,
self_deaf, self_deaf,
mute, mute,
suppress,
self_mute, self_mute,
ref name, ref name,
channel, channel,
@@ -351,69 +341,79 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
let connection_status = match &*status.read() { let connection_status = match &*status.read() {
Connecting => rsx! { Connecting => rsx! {
div { div {
class: "connection_status", style: "color: \"{connecting_color}\";",
style: "color: {connecting_color};", span {
div { class: "material-symbols-outlined",
span { style: "vertical-align: middle; font-size: 30px;",
class: "material-symbols-outlined", "signal_cellular_alt_2_bar"
"signal_cellular_alt_2_bar" }
} span {
span { style: "width: 5px; display: inline-block;"
class: "status_text", }
" Connecting" span {
} style: "vertical-align: middle; font-size: 30px;",
"Connecting"
} }
} }
}, },
Connected => rsx! { Connected => rsx! {
div { div {
class: "connection_status",
div { div {
style: "color: {connected_color};", style: "color: \"{connected_color}\";",
span { span {
class: "material-symbols-outlined", class: "material-symbols-outlined",
style: "vertical-align: middle; font-size: 30px;",
"signal_cellular_alt" "signal_cellular_alt"
} }
span { span {
class: "status_text", style: "width: 5px; display: inline-block;"
" Connected" }
span {
style: "vertical-align: middle; font-size: 25px;",
"Connected"
} }
} }
div { div {
class: "channel_text", span { style: "width: 3px; display: inline-block;"}
span { "{current_channel_name}" } span { "{current_channel_name}" }
if let Some(proxy_url) = proxy_url {
span { "" }
span { "{proxy_url}" }
}
} }
} }
}, },
Disconnected => rsx! { Disconnected => rsx! {
div { div {
class: "connection_status", style: "color: \"{disconnected_color}\";",
style: "color: {disconnected_color};", span {
div { class: "material-symbols-outlined",
span { style: "vertical-align: middle;",
class: "material-symbols-outlined", "signal_disconnected"
"signal_disconnected" }
} span {
span { style: "width: 5px; display: inline-block;"
class: "status_text", }
" Disconnected" span {
} style: "vertical-align: middle;",
"Disconnected"
} }
} }
}, },
Failed(_) => rsx! { Failed(_) => rsx! {
div { div {
class: "connection_status", style: "color: \"{failed_color}\";",
style: "color: {failed_color};", span {
div { class: "material-symbols-outlined",
span { style: "vertical-align: middle;",
class: "material-symbols-outlined", "error"
"error" }
} span {
span { style: "width: 5px; display: inline-block;"
class: "status_text", }
" Failed" span {
} style: "vertical-align: middle;",
"Failed"
} }
} }
}, },
@@ -445,17 +445,16 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
class: "user_edit_button", class: "user_edit_button",
span { span {
class: "material-symbols-outlined", class: "material-symbols-outlined",
style: "color: oklch(0.65 0.2245 28.06);", style: "color: oklch(0.65 0.2245 28.06); font-size: 45px; font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;",
"person_edit" "person_edit"
} }
} }
div { div {
class: "user_info",
div { div {
span { class: "user_name", "{name}" } span { style: "font-size: 25px;", "{name}" }
} }
div { div {
span { class: "user_data", "some data" } span { style: "font-size: 20px; color: gray;", "some data" }
} }
} }
span { class: "spacer" } span { class: "spacer" }
@@ -477,15 +476,15 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
} }
} }
button { button {
class: match mute || suppress || self_mute { class: match mute || self_mute {
true => "toggle_button is_on", true => "toggle_button is_on",
false => "toggle_button", false => "toggle_button",
}, },
role: "switch", role: "switch",
aria_checked: mute || suppress || self_mute, aria_checked: mute || self_mute,
disabled: mute || suppress, disabled: mute,
onclick: move |_| net.send(SetMute { mute: !self_mute }), onclick: move |_| net.send(SetMute { mute: !self_mute }),
match mute || suppress || self_mute { match mute || self_mute {
true => rsx!(span { class: "material-symbols-outlined", "mic_off"}), true => rsx!(span { class: "material-symbols-outlined", "mic_off"}),
false => rsx!(span { class: "material-symbols-outlined", "mic"}), false => rsx!(span { class: "material-symbols-outlined", "mic"}),
} }
@@ -546,20 +545,38 @@ pub fn ServerView(config: Resource<ClientConfig>) -> Element {
) )
} }
async fn get_status(
client: &reqwest::Client,
status_url: &str,
) -> color_eyre::Result<ServerStatus> {
Ok(client
.get(status_url)
.send()
.await?
.json::<ServerStatus>()
.await?)
}
#[component] #[component]
pub fn LoginView(config: Resource<ClientConfig>) -> Element { pub fn LoginView(config: Resource<ClientConfig>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle(); let net: Coroutine<Command> = use_coroutine_handle();
let last_status = use_signal(|| None::<color_eyre::Result<ServerStatus>>); let last_status = use_signal(|| None::<color_eyre::Result<ServerStatus>>);
use_resource(move || async move { use_resource(move || async move {
let Some(config) = config.read().clone() else {
return;
};
let Some(status_url) = config.status_url else {
return;
};
let client = reqwest::Client::new(); let client = reqwest::Client::new();
loop { loop {
*last_status.write_unchecked() = Some(imp::get_status(&client).await); *last_status.write_unchecked() = Some(get_status(&client, &status_url).await);
imp::sleep(std::time::Duration::from_secs_f32(1.0)).await; imp::sleep(std::time::Duration::from_secs_f32(1.0)).await;
} }
}); });
let mut address_input = use_signal(|| imp::load_server_url()); let mut address_input = use_signal(|| None::<String>);
let address = use_memo(move || { let address = use_memo(move || {
if let Some(addr) = address_input() { if let Some(addr) = address_input() {
addr.clone() addr.clone()
@@ -576,9 +593,6 @@ pub fn LoginView(config: Resource<ClientConfig>) -> Element {
let do_connect = move |_| { let do_connect = move |_| {
//let _ = set_default_username(&username.read()); //let _ = set_default_username(&username.read());
let _ = imp::set_default_username(&username.read()); let _ = imp::set_default_username(&username.read());
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
imp::set_default_server(&address.read());
}
net.send(Connect { net.send(Connect {
address: address.read().clone(), address: address.read().clone(),
username: username.read().clone(), username: username.read().clone(),
@@ -616,16 +630,11 @@ pub fn LoginView(config: Resource<ClientConfig>) -> Element {
), ),
Connected => unreachable!(), Connected => unreachable!(),
}; };
let version = option_env!("MUMBLE_WEB2_VERSION");
rsx!( rsx!(
div { div {
class: "login", class: "login",
h1 { h1 {
"Mumble Web" "Mumble Web"
match version {
Some(v) => rsx!(" " span { class: "login_version", "({v})" }),
None => rsx!(),
}
} }
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) { if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
div { div {
@@ -730,8 +739,6 @@ pub fn app() -> Element {
} }
}); });
imp::request_permissions();
rsx!( rsx!(
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" } document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" }
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" } document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" }
+3 -61
View File
@@ -10,21 +10,6 @@ use tracing::{error, info};
use crate::imp; use crate::imp;
static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz"); static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz");
// TODO: make this user configurable.
static DEFAULT_NOISE_FLOOR: f32 = 0.001;
// 200ms hold at 48kHz sample rate
static HOLD_SAMPLES_MAX: usize = 48000 / 5; // 9600 samples = 200ms
/// Indicates the transmission state after processing audio.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransmitState {
/// Audio is above threshold, or below but within hold period - transmit normally
Transmitting,
/// Hold period expired - send this frame as terminator (end_bit = true)
Terminator,
/// Silent and not transmitting - don't send anything
Silent,
}
enum DenoisingModelState { enum DenoisingModelState {
Nothing, Nothing,
@@ -91,11 +76,6 @@ pub struct AudioProcessor {
denoise: bool, denoise: bool,
spawn: imp::SpawnHandle, spawn: imp::SpawnHandle,
buffer: Vec<f32>, buffer: Vec<f32>,
noise_floor: f32,
/// Whether we were transmitting in the previous frame
was_transmitting: bool,
/// Number of samples we've been below threshold (for hold period)
hold_samples: usize,
} }
impl AudioProcessor { impl AudioProcessor {
@@ -104,9 +84,6 @@ impl AudioProcessor {
denoise: false, denoise: false,
spawn: imp::SpawnHandle::current(), spawn: imp::SpawnHandle::current(),
buffer: Vec::new(), buffer: Vec::new(),
noise_floor: DEFAULT_NOISE_FLOOR,
was_transmitting: false,
hold_samples: 0,
} }
} }
@@ -115,21 +92,18 @@ impl AudioProcessor {
denoise: true, denoise: true,
spawn: imp::SpawnHandle::current(), spawn: imp::SpawnHandle::current(),
buffer: Vec::new(), buffer: Vec::new(),
noise_floor: DEFAULT_NOISE_FLOOR,
was_transmitting: false,
hold_samples: 0,
} }
} }
} }
impl AudioProcessor { impl AudioProcessor {
pub fn process(&mut self, audio: &[f32], channels: usize, output: &mut Vec<f32>) -> TransmitState { pub fn process(&mut self, audio: &[f32], output: &mut Vec<f32>) {
let mut include_raw = true; let mut include_raw = true;
if self.denoise { if self.denoise {
with_denoising_model(&self.spawn, |df| { with_denoising_model(&self.spawn, |df| {
include_raw = false; include_raw = false;
self.buffer.extend(audio.iter().step_by(channels).copied()); self.buffer.extend_from_slice(audio);
output.reserve(audio.len()); output.reserve(audio.len());
let hop = df.hop_size; let hop = df.hop_size;
@@ -156,40 +130,8 @@ impl AudioProcessor {
} }
if include_raw { if include_raw {
output.extend(audio.iter().step_by(channels).copied()); output.extend_from_slice(audio);
} }
// Calculate average amplitude for VAD
let avg: f32 = if output.is_empty() {
0.0
} else {
output.iter().map(|x| x.abs()).sum::<f32>() / output.len() as f32
};
let above_threshold = avg >= self.noise_floor;
let samples_in_frame = output.len();
let state = if above_threshold {
// Above threshold - reset hold counter and transmit
self.hold_samples = 0;
self.was_transmitting = true;
TransmitState::Transmitting
} else if self.was_transmitting && self.hold_samples < HOLD_SAMPLES_MAX {
// Below threshold but in hold period - keep transmitting
self.hold_samples += samples_in_frame;
TransmitState::Transmitting
} else if self.was_transmitting {
// Hold period expired - send terminator
self.was_transmitting = false;
self.hold_samples = 0;
TransmitState::Terminator
} else {
// Not transmitting and below threshold - stay silent
output.clear(); // Don't accumulate stale audio during silence
TransmitState::Silent
};
state
} }
} }
-110
View File
@@ -1,110 +0,0 @@
use crate::app::Command;
use color_eyre::eyre::{bail, Error};
use dioxus::hooks::UnboundedReceiver;
use mumble_protocol::control::ClientControlCodec;
use std::net::ToSocketAddrs;
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio_rustls::rustls;
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio_rustls::rustls::ClientConfig as RlsClientConfig;
use tokio_rustls::rustls::DigitallySignedStruct;
use tokio_rustls::TlsConnector;
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
use tracing::{info, instrument};
use mumble_web2_common::{ClientConfig, ServerStatus};
#[derive(Debug)]
struct NoCertificateVerification;
impl ServerCertVerifier for NoCertificateVerification {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp: &[u8],
_now: UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::RSA_PKCS1_SHA1,
rustls::SignatureScheme::ECDSA_SHA1_Legacy,
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::ED448,
]
}
}
#[instrument]
pub async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
) -> Result<(), Error> {
info!("connecting");
let config = RlsClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let addr = format!("{}:{}", address, 64738)
.to_socket_addrs()?
.next()
.unwrap();
let server_tcp = TcpStream::connect(addr).await?;
let server_stream = connector
//.connect("127.0.0.1".try_into()?, server_tcp)
.connect(address.try_into()?, server_tcp)
.await?;
let (read_server, write_server) = tokio::io::split(server_stream);
let read_codec = ClientControlCodec::new();
let write_codec = ClientControlCodec::new();
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
crate::network_loop(username, event_rx, reader, writer).await
}
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
bail!("status not supported on desktop yet")
}
+271 -40
View File
@@ -1,66 +1,297 @@
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; use crate::app::Command;
use crate::effects::{AudioProcessor, AudioProcessorSender};
use color_eyre::eyre::{eyre, Context, Error};
use cpal::traits::{DeviceTrait, HostTrait};
use dioxus::hooks::UnboundedReceiver;
use futures::io::{AsyncRead, AsyncWrite};
use mumble_protocol::control::ClientControlCodec;
use mumble_web2_common::ClientConfig; use mumble_web2_common::ClientConfig;
use std::collections::HashMap; use std::mem::replace;
use std::net::ToSocketAddrs;
use std::sync::Arc;
use std::sync::Mutex;
use tokio::net::TcpStream;
use tokio_rustls::rustls;
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio_rustls::rustls::ClientConfig as RlsClientConfig;
use tokio_rustls::rustls::DigitallySignedStruct;
use tokio_rustls::TlsConnector;
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
use tracing::{error, info, instrument, warn};
pub use tokio::runtime::Handle as SpawnHandle; pub use tokio::runtime::Handle as SpawnHandle;
pub use tokio::task::spawn; pub use tokio::task::spawn;
pub use tokio::time::sleep; pub use tokio::time::sleep;
pub use super::connect::*; pub trait ImpRead: AsyncRead + Unpin + Send + 'static {}
pub use super::native_audio::*; impl<T: AsyncRead + Unpin + Send + 'static> ImpRead for T {}
fn get_config_path() -> std::path::PathBuf { pub trait ImpWrite: AsyncWrite + Unpin + Send + 'static {}
let strategy = choose_app_strategy(AppStrategyArgs { impl<T: AsyncWrite + Unpin + Send + 'static> ImpWrite for T {}
top_level_domain: "com".to_string(),
author: "Ohea Corp".to_string(), pub struct AudioSystem {
app_name: "Mumble Web2".to_string(), output: cpal::Device,
}) input: cpal::Device,
.expect("failed to choose app strategy"); processors: AudioProcessorSender,
strategy.config_dir().join("config.json") recording_stream: Option<cpal::Stream>,
} }
fn load_config_map() -> HashMap<String, String> { const SAMPLE_RATE: u32 = 48_000;
let config_path = get_config_path(); const PACKET_SAMPLES: u32 = 960;
match std::fs::read_to_string(&config_path) {
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(), type Buffer = Arc<Mutex<dasp_ring_buffer::Bounded<Vec<i16>>>>;
Err(_) => HashMap::new(),
impl AudioSystem {
pub fn new() -> Result<Self, Error> {
// TODO
let host = cpal::default_host();
let name = host.id();
let processors = AudioProcessorSender::default();
Ok(AudioSystem {
output: host
.default_output_device()
.ok_or(eyre!("no output devices from {name:?}"))?,
input: host
.default_input_device()
.ok_or(eyre!("no input devices from {name:?}"))?,
processors,
recording_stream: None,
})
}
pub fn set_processor(&self, processor: AudioProcessor) {
self.processors.store(Some(processor))
}
pub fn start_recording(
&mut self,
mut each: impl FnMut(Vec<u8>) + Send + 'static,
) -> Result<(), Error> {
let mut encoder =
opus::Encoder::new(SAMPLE_RATE, opus::Channels::Mono, opus::Application::Voip)?;
let mut current_processor = AudioProcessor::new_plain();
let mut output_buffer = Vec::new();
let processors = self.processors.clone();
let error_callback = move |e: cpal::StreamError| error!("error recording: {e:?}");
let data_callback = move |frame: &[f32], _: &cpal::InputCallbackInfo| {
if let Some(new_processor) = processors.take() {
current_processor = new_processor;
}
current_processor.process(frame, &mut output_buffer);
if output_buffer.len() < PACKET_SAMPLES as usize {
return;
}
let remainder = output_buffer.split_off(PACKET_SAMPLES as usize);
let frame = replace(&mut output_buffer, remainder);
match encoder.encode_vec_float(&frame, frame.len() * 2) {
Ok(buf) => {
each(buf);
}
Err(e) => {
error!("error encoding {} samples: {e:?}", frame.len());
}
}
};
match self.input.build_input_stream(
&cpal::StreamConfig {
channels: 1,
sample_rate: cpal::SampleRate(SAMPLE_RATE),
buffer_size: cpal::BufferSize::Fixed(PACKET_SAMPLES),
},
data_callback,
error_callback,
None,
) {
Ok(stream) => {
self.recording_stream = Some(stream);
Ok(())
}
Err(err) => {
self.recording_stream = None;
Err(err.into())
}
}
}
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
let buffer = Arc::new(Mutex::new(dasp_ring_buffer::Bounded::from_raw_parts(
0,
0,
vec![
0;
SAMPLE_RATE as usize/4 // 250ms of buffer
],
)));
let decoder = opus::Decoder::new(SAMPLE_RATE, opus::Channels::Mono)?;
let stream = {
let buffer = buffer.clone();
self.output.build_output_stream(
&cpal::StreamConfig {
channels: 1,
sample_rate: cpal::SampleRate(SAMPLE_RATE),
buffer_size: cpal::BufferSize::Fixed(480), // 10ms playback delay
},
move |frame, info| {
let mut buffer = buffer.lock().unwrap();
for x in frame.iter_mut() {
match buffer.pop() {
Some(y) => {
*x = y;
}
None => {
*x = 0;
}
}
}
},
move |err| error!("could not create output stream {err:?}"),
None,
)?
};
Ok(AudioPlayer {
decoder,
stream,
buffer,
tmp: vec![0; 2400],
})
} }
} }
fn save_config_map(config: &HashMap<String, String>) -> color_eyre::Result<()> { pub struct AudioPlayer {
let config_path = get_config_path(); decoder: opus::Decoder,
if let Some(parent) = config_path.parent() { stream: cpal::Stream,
std::fs::create_dir_all(parent)?; buffer: Buffer,
tmp: Vec<i16>,
}
impl AudioPlayer {
pub fn play_opus(&mut self, payload: &[u8]) {
let len = loop {
match self.decoder.decode(payload, &mut self.tmp, false) {
Ok(l) => break l,
Err(e) => {
error!("opus decode error {e:?}");
return;
}
}
};
let mut buffer = self.buffer.lock().unwrap();
let mut overrun = 0;
for x in &self.tmp[..len] {
if let Some(_) = buffer.push(*x) {
overrun += 1;
}
}
if overrun > 0 {
warn!("playback overrun by {overrun} samples");
}
} }
let contents = serde_json::to_string_pretty(config)?; }
std::fs::write(&config_path, contents)?;
Ok(()) #[derive(Debug)]
struct NoCertificateVerification;
impl ServerCertVerifier for NoCertificateVerification {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp: &[u8],
_now: UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::RSA_PKCS1_SHA1,
rustls::SignatureScheme::ECDSA_SHA1_Legacy,
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::ED448,
]
}
}
#[instrument]
pub async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
) -> Result<(), Error> {
info!("connecting");
let config = RlsClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let addr = format!("{}:{}", address, 64738)
.to_socket_addrs()?
.next()
.unwrap();
let server_tcp = TcpStream::connect(addr).await?;
let server_stream = connector
//.connect("127.0.0.1".try_into()?, server_tcp)
.connect(address.try_into()?, server_tcp)
.await?;
let (read_server, write_server) = tokio::io::split(server_stream);
let read_codec = ClientControlCodec::new();
let write_codec = ClientControlCodec::new();
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
crate::network_loop(username, event_rx, reader, writer).await
} }
pub fn set_default_username(username: &str) -> Option<()> { pub fn set_default_username(username: &str) -> Option<()> {
let mut config = load_config_map(); None
config.insert("username".to_string(), username.to_string());
save_config_map(&config).ok()
}
pub fn set_default_server(server: &str) -> Option<()> {
let mut config = load_config_map();
config.insert("server".to_string(), server.to_string());
save_config_map(&config).ok()
} }
pub fn load_username() -> Option<String> { pub fn load_username() -> Option<String> {
let config = load_config_map(); return None;
config.get("username").cloned()
}
pub fn load_server_url() -> Option<String> {
let config = load_config_map();
config.get("server").cloned()
} }
pub async fn load_config() -> color_eyre::Result<ClientConfig> { pub async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig { Ok(ClientConfig {
proxy_url: None, proxy_url: None,
status_url: None,
cert_hash: None, cert_hash: None,
any_server: true, any_server: true,
}) })
-67
View File
@@ -1,67 +0,0 @@
use android_permissions::{PermissionManager, RECORD_AUDIO};
use jni::{objects::JObject, JavaVM};
use mumble_web2_common::ClientConfig;
use std::collections::HashMap;
pub use tokio::runtime::Handle as SpawnHandle;
pub use tokio::task::spawn;
pub use tokio::time::sleep;
pub use super::connect::*;
pub use super::native_audio::*;
pub fn set_default_username(username: &str) -> Option<()> {
None
}
pub fn set_default_server(server: &str) -> Option<()> {
None
}
pub fn load_username() -> Option<String> {
None
}
pub fn load_server_url() -> Option<String> {
None
}
pub async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
pub fn init_logging() {
use tracing::level_filters::LevelFilter;
use tracing_subscriber::filter::EnvFilter;
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::fmt()
.with_target(true)
.with_level(true)
.with_env_filter(env_filter)
.init();
}
#[cfg(feature = "mobile")]
pub fn request_permissions() {
request_recording_permission();
}
#[cfg(target_os = "android")]
pub fn request_recording_permission() {
let ctx = ndk_context::android_context();
let vm = unsafe { JavaVM::from_raw(ctx.vm().cast()).unwrap() };
let activity = unsafe { JObject::from_raw(ctx.context().cast()) };
let manager = PermissionManager::create(vm, activity).unwrap();
if !manager.check(&RECORD_AUDIO).unwrap() {
manager.request(&[&RECORD_AUDIO]).unwrap();
}
}
+3 -21
View File
@@ -1,29 +1,11 @@
#[cfg(feature = "web")] #[cfg(feature = "web")]
mod web; mod web;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod connect;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod native_audio;
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
mod desktop; mod desktop;
#[cfg(feature = "mobile")]
mod mobile; #[cfg(all(feature = "web", not(feature = "desktop")))]
pub use web::*;
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
pub use desktop::*; pub use desktop::*;
#[cfg(feature = "mobile")]
pub use mobile::*;
#[cfg(feature = "mobile")]
pub use mobile::request_permissions;
#[cfg(any(feature = "desktop", feature = "web"))]
pub fn request_permissions() {}
#[cfg(all(feature = "web", not(any(feature = "desktop", feature = "mobile"))))]
pub use web::*;
#[cfg(any(feature = "desktop"))]
pub use desktop::*;
-222
View File
@@ -1,222 +0,0 @@
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
use color_eyre::eyre::{eyre, Error};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _};
use futures::io::{AsyncRead, AsyncWrite};
use std::mem::replace;
use std::sync::Arc;
use std::sync::Mutex;
use tracing::{error, info, warn};
pub trait ImpRead: AsyncRead + Unpin + Send + 'static {}
impl<T: AsyncRead + Unpin + Send + 'static> ImpRead for T {}
pub trait ImpWrite: AsyncWrite + Unpin + Send + 'static {}
impl<T: AsyncWrite + Unpin + Send + 'static> ImpWrite for T {}
pub struct AudioSystem {
output: cpal::Device,
input: cpal::Device,
processors: AudioProcessorSender,
recording_stream: Option<cpal::Stream>,
}
const SAMPLE_RATE: u32 = 48_000;
const PACKET_SAMPLES: u32 = 960;
fn encode_and_send(
state: TransmitState,
output_buffer: &mut Vec<f32>,
encoder: &mut opus::Encoder,
each: &mut impl FnMut(Vec<u8>, bool),
) {
let (is_terminator, should_encode) = match state {
TransmitState::Silent => return,
TransmitState::Transmitting => (false, output_buffer.len() >= PACKET_SAMPLES as usize),
TransmitState::Terminator => {
output_buffer.resize(PACKET_SAMPLES as usize, 0.0);
(true, true)
}
};
if should_encode {
let remainder = output_buffer.split_off(PACKET_SAMPLES as usize);
let frame = replace(output_buffer, remainder);
match encoder.encode_vec_float(&frame, frame.len() * 2) {
Ok(encoded) => each(encoded, is_terminator),
Err(e) => error!("error encoding {} samples: {e:?}", frame.len()),
}
}
}
type Buffer = Arc<Mutex<dasp_ring_buffer::Bounded<Vec<i16>>>>;
impl AudioSystem {
pub async fn new() -> Result<Self, Error> {
// TODO
let host = cpal::default_host();
let name = host.id();
let processors = AudioProcessorSender::default();
Ok(AudioSystem {
output: host
.default_output_device()
.ok_or(eyre!("no output devices from {name:?}"))?,
input: host
.default_input_device()
.ok_or(eyre!("no input devices from {name:?}"))?,
processors,
recording_stream: None,
})
}
pub fn set_processor(&self, processor: AudioProcessor) {
self.processors.store(Some(processor))
}
fn choose_config(
&self,
configs: impl Iterator<Item = cpal::SupportedStreamConfigRange>,
) -> Result<cpal::StreamConfig, Error> {
let mut supported_configs: Vec<_> = configs
.filter_map(|cfg| cfg.try_with_sample_rate(cpal::SampleRate(SAMPLE_RATE)))
.filter(|cfg| cfg.sample_format() == cpal::SampleFormat::I16)
.map(|cfg| cpal::StreamConfig {
buffer_size: cpal::BufferSize::Fixed(match *cfg.buffer_size() {
cpal::SupportedBufferSize::Range { min, max } => 480.clamp(min, max),
cpal::SupportedBufferSize::Unknown => 480,
}),
..cfg.config()
})
.collect();
supported_configs.sort_by(|a, b| {
let cpal::BufferSize::Fixed(a_buf) = a.buffer_size else {
unreachable!()
};
let cpal::BufferSize::Fixed(b_buf) = b.buffer_size else {
unreachable!()
};
Ord::cmp(&a.channels, &b.channels).then(Ord::cmp(&a_buf, &b_buf))
});
supported_configs
.get(0)
.cloned()
.ok_or(eyre!("no supported stream configs"))
}
pub fn start_recording(
&mut self,
mut each: impl FnMut(Vec<u8>, bool) + Send + 'static,
) -> Result<(), Error> {
let config = self.choose_config(self.input.supported_input_configs()?)?;
info!(
"creating recording on {:?} with {:#?}",
self.input.name()?,
config
);
let mut encoder =
opus::Encoder::new(SAMPLE_RATE, opus::Channels::Mono, opus::Application::Voip)?;
let mut current_processor = AudioProcessor::new_plain();
let mut output_buffer = Vec::new();
let processors = self.processors.clone();
let error_callback = move |e: cpal::StreamError| error!("error recording: {e:?}");
let data_callback = move |frame: &[f32], _: &cpal::InputCallbackInfo| {
if let Some(new_processor) = processors.take() {
current_processor = new_processor;
}
let state = current_processor.process(frame, config.channels as usize, &mut output_buffer);
encode_and_send(state, &mut output_buffer, &mut encoder, &mut each);
};
match self
.input
.build_input_stream(&config, data_callback, error_callback, None)
{
Ok(stream) => {
stream.play()?;
self.recording_stream = Some(stream);
Ok(())
}
Err(err) => {
self.recording_stream = None;
Err(err.into())
}
}
}
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
let config = self.choose_config(self.output.supported_output_configs()?)?;
info!(
"creating player on {:?} with {:#?}",
self.output.name().ok(),
&config
);
let buffer = Arc::new(Mutex::new(dasp_ring_buffer::Bounded::from_raw_parts(
0,
0,
vec![
0;
SAMPLE_RATE as usize/4 // 250ms of buffer
],
)));
let decoder = opus::Decoder::new(SAMPLE_RATE, opus::Channels::Mono)?;
let stream = {
let buffer = buffer.clone();
self.output.build_output_stream(
&config,
move |frame, _info| {
let mut buffer = buffer.lock().unwrap();
for x in frame.chunks_mut(config.channels as usize) {
match buffer.pop() {
Some(y) => {
x.fill(y);
}
None => {
x.fill(0);
}
}
}
},
move |err| error!("could not create output stream {err:?}"),
None,
)?
};
stream.play()?;
Ok(AudioPlayer {
decoder,
stream,
buffer,
tmp: vec![0; 2400],
})
}
}
pub struct AudioPlayer {
decoder: opus::Decoder,
stream: cpal::Stream,
buffer: Buffer,
tmp: Vec<i16>,
}
impl AudioPlayer {
pub fn play_opus(&mut self, payload: &[u8]) {
let len = loop {
match self.decoder.decode(payload, &mut self.tmp, false) {
Ok(l) => break l,
Err(e) => {
error!("opus decode error {e:?}");
return;
}
}
};
let mut buffer = self.buffer.lock().unwrap();
let mut overrun = 0;
for x in &self.tmp[..len] {
if let Some(_) = buffer.push(*x) {
overrun += 1;
}
}
if overrun > 0 {
warn!("playback overrun by {overrun} samples");
}
}
}
+65 -112
View File
@@ -1,22 +1,21 @@
use crate::app::Command; use crate::app::Command;
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState}; use crate::effects::{AudioProcessor, AudioProcessorSender};
use color_eyre::eyre::{bail, eyre, Error}; use color_eyre::eyre::{bail, eyre, Error};
use crossbeam::atomic::AtomicCell;
use dioxus::prelude::*; use dioxus::prelude::*;
use futures::{AsyncRead, AsyncWrite}; use futures::{AsyncRead, AsyncWrite};
use gloo_timers::future::TimeoutFuture; use gloo_timers::future::TimeoutFuture;
use js_sys::Float32Array; use js_sys::Float32Array;
use mumble_protocol::control::ClientControlCodec; use mumble_protocol::control::ClientControlCodec;
use mumble_web2_common::{ClientConfig, ServerStatus}; use mumble_web2_common::ClientConfig;
use reqwest::Url; use reqwest::Url;
use std::future::Future; use std::future::Future;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tracing::level_filters::LevelFilter; use tracing::level_filters::LevelFilter;
use tracing::{debug, error, info, instrument}; use tracing::{debug, error, info, instrument};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::JsFuture;
use web_sys::js_sys::{Promise, Reflect, Uint8Array}; use web_sys::js_sys::{Promise, Reflect, Uint8Array};
use web_sys::AudioContext;
use web_sys::AudioContextOptions; use web_sys::AudioContextOptions;
use web_sys::AudioData; use web_sys::AudioData;
use web_sys::AudioDecoder; use web_sys::AudioDecoder;
@@ -31,13 +30,14 @@ use web_sys::EncodedAudioChunkInit;
use web_sys::EncodedAudioChunkType; use web_sys::EncodedAudioChunkType;
use web_sys::MediaStream; use web_sys::MediaStream;
use web_sys::MediaStreamConstraints; use web_sys::MediaStreamConstraints;
use web_sys::MediaStreamTrackGenerator;
use web_sys::MediaStreamTrackGeneratorInit;
use web_sys::MessageEvent; use web_sys::MessageEvent;
use web_sys::WebTransport; use web_sys::WebTransport;
use web_sys::WebTransportBidirectionalStream; use web_sys::WebTransportBidirectionalStream;
use web_sys::WebTransportOptions; use web_sys::WebTransportOptions;
use web_sys::WorkletOptions; use web_sys::WorkletOptions;
use web_sys::{console, window}; use web_sys::{console, window};
use web_sys::{AudioContext, AudioDataCopyToOptions};
pub use wasm_bindgen_futures::spawn_local as spawn; pub use wasm_bindgen_futures::spawn_local as spawn;
@@ -78,41 +78,12 @@ pub struct AudioSystem {
processors: AudioProcessorSender, processors: AudioProcessorSender,
} }
async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> {
// Create worklets to process mic and speaker audio
// Speaker audio processing worklet only required on
// browsers that don't support MediaStreamTrackGenerator
let options = WorkletOptions::new();
Reflect::set(
&options,
&"processorOptions".into(),
&wasm_bindgen::module(),
)
.ey()?;
let module = asset!("assets/rust_audio_worklet.js").to_string();
info!("loading mic worklet from {module:?}");
audio_context
.audio_worklet()
.ey()?
.add_module_with_options(&module, &options)
.ey()?
.into_future()
.await
.ey()?;
Ok(())
}
impl AudioSystem { impl AudioSystem {
pub async fn new() -> Result<Self, Error> { pub fn new() -> Result<Self, Error> {
// Create MediaStreams to playback decoded audio // Create MediaStreams to playback decoded audio
// The audio context is used to reproduce audio. // The audio context is used to reproduce audio.
let webctx = configure_audio_context(); let webctx = configure_audio_context();
attach_worklet(&webctx).await?;
let processors = AudioProcessorSender::default(); let processors = AudioProcessorSender::default();
Ok(AudioSystem { webctx, processors }) Ok(AudioSystem { webctx, processors })
} }
@@ -120,10 +91,7 @@ impl AudioSystem {
self.processors.store(Some(processor)) self.processors.store(Some(processor))
} }
pub fn start_recording( pub fn start_recording(&mut self, each: impl FnMut(Vec<u8>) + 'static) -> Result<(), Error> {
&mut self,
each: impl FnMut(Vec<u8>, bool) + 'static,
) -> Result<(), Error> {
let audio_context_worklet = self.webctx.clone(); let audio_context_worklet = self.webctx.clone();
let processors = self.processors.clone(); let processors = self.processors.clone();
spawn(async move { spawn(async move {
@@ -136,10 +104,18 @@ impl AudioSystem {
} }
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> { pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
let sink_node = AudioWorkletNode::new(&self.webctx, "rust_speaker_worklet").ey()?; let audio_stream_generator =
MediaStreamTrackGenerator::new(&MediaStreamTrackGeneratorInit::new("audio")).ey()?;
// Connect worklet to destination // Create MediaStream from MediaStreamTrackGenerator
sink_node let js_tracks = web_sys::js_sys::Array::new();
js_tracks.push(&audio_stream_generator);
let media_stream = MediaStream::new_with_tracks(&js_tracks).ey()?;
// Create MediaStreamAudioSourceNode
let audio_source = self.webctx.create_media_stream_source(&media_stream).ey()?;
// Connect output of audio_source to audio_context (browser audio)
audio_source
.connect_with_audio_node(&self.webctx.destination()) .connect_with_audio_node(&self.webctx.destination())
.ey()?; .ey()?;
@@ -148,31 +124,28 @@ impl AudioSystem {
error!("error decoding audio {:?}", e); error!("error decoding audio {:?}", e);
}) as Box<dyn FnMut(JsValue)>); }) as Box<dyn FnMut(JsValue)>);
let sink_port = sink_node.port().ey()?; // This knows what MediaStreamTrackGenerator to use as it closes around it
let output = Closure::wrap(Box::new(move |audio_data: AudioData| { let output = Closure::wrap(Box::new(move |audio_data: AudioData| {
// Extract planar PCM from AudioData into an ArrayBuffer or Float32Array let writable = audio_stream_generator.writable();
// Here we assume f32 samples, 1 channel for brevity. if writable.locked() {
let number_of_frames = audio_data.number_of_frames(); return;
}
let js_buffer = Float32Array::new_with_length(number_of_frames); if let Err(e) = writable.get_writer().map(|writer| {
spawn(async move {
let audio_data_copy_to_options = &AudioDataCopyToOptions::new(0); if let Err(e) = JsFuture::from(writer.ready()).await.ey() {
audio_data_copy_to_options.set_format(web_sys::AudioSampleFormat::F32); error!("write chunk ready error {:?}", e);
}
if let Err(e) = audio_data if let Err(e) = JsFuture::from(writer.write_with_chunk(&audio_data))
.copy_to_with_buffer_source(&js_buffer.buffer(), &audio_data_copy_to_options) .await
{ .ey()
error!("could not copy audio data to array {:?}", e); {
error!("write chunk error {:?}", e);
};
writer.release_lock();
});
}) {
error!("error writing audio data {:?}", e);
} }
// Post to the worklet; include sampleRate and channel count if needed.
let msg = js_sys::Object::new();
js_sys::Reflect::set(&msg, &"samples".into(), &js_buffer).unwrap();
sink_port.post_message(&msg).unwrap();
audio_data.close();
}) as Box<dyn FnMut(AudioData)>); }) as Box<dyn FnMut(AudioData)>);
let audio_decoder = AudioDecoder::new(&AudioDecoderInit::new( let audio_decoder = AudioDecoder::new(&AudioDecoderInit::new(
@@ -227,26 +200,22 @@ impl PromiseExt for Promise {
} }
} }
fn process_audio(frame: &JsValue, processor: &mut AudioProcessor) -> TransmitState { fn process_audio(frame: &JsValue, processor: &mut AudioProcessor) {
let Ok(samples) = Reflect::get(&frame, &"data".into()) else { let Ok(samples) = Reflect::get(&frame, &"data".into()) else {
return TransmitState::Silent; return;
}; };
let Ok(samples) = samples.dyn_into::<Float32Array>() else { let Ok(samples) = samples.dyn_into::<Float32Array>() else {
return TransmitState::Silent; return;
}; };
let input = samples.to_vec(); let input = samples.to_vec();
let mut output = Vec::with_capacity(input.len()); let mut output = Vec::with_capacity(input.len());
let state = processor.process(&input, 1, &mut output); processor.process(&input, &mut output);
if !output.is_empty() { samples.copy_from(&output);
samples.copy_from(&output);
}
state
} }
async fn run_encoder_worklet( async fn run_encoder_worklet(
audio_context: &AudioContext, audio_context: &AudioContext,
mut each: impl FnMut(Vec<u8>, bool) + 'static, mut each: impl FnMut(Vec<u8>) + 'static,
processors: AudioProcessorSender, processors: AudioProcessorSender,
) -> Result<AudioWorkletNode, Error> { ) -> Result<AudioWorkletNode, Error> {
let constraints = MediaStreamConstraints::new(); let constraints = MediaStreamConstraints::new();
@@ -265,25 +234,37 @@ async fn run_encoder_worklet(
.map_err(|e| JsError::new(&format!("not a stream: {e:?}"))) .map_err(|e| JsError::new(&format!("not a stream: {e:?}")))
.ey()?; .ey()?;
let options = WorkletOptions::new();
Reflect::set(
&options,
&"processorOptions".into(),
&wasm_bindgen::module(),
)
.ey()?;
let module = asset!("assets/rust_mic_worklet.js").to_string();
info!("loading mic worklet from {module:?}");
audio_context
.audio_worklet()
.ey()?
.add_module_with_options(&module, &options)
.ey()?
.into_future()
.await
.ey()?;
let source = audio_context.create_media_stream_source(&stream).ey()?; let source = audio_context.create_media_stream_source(&stream).ey()?;
let worklet_node = AudioWorkletNode::new(audio_context, "rust_mic_worklet").ey()?; let worklet_node = AudioWorkletNode::new(audio_context, "rust_mic_worklet").ey()?;
let encoder_error: Closure<dyn FnMut(JsValue)> = let encoder_error: Closure<dyn FnMut(JsValue)> =
Closure::new(|e| error!("error encoding audio {:?}", e)); Closure::new(|e| error!("error encoding audio {:?}", e));
// Shared state to signal terminator between onmessage and output closures
// The output closure runs asynchronously after encoding completes
let pending_terminator = Arc::new(AtomicCell::new(false));
let pending_terminator_output = pending_terminator.clone();
// This knows what MediaStreamTrackGenerator to use as it closes around it // This knows what MediaStreamTrackGenerator to use as it closes around it
let output: Closure<dyn FnMut(EncodedAudioChunk)> = let output: Closure<dyn FnMut(EncodedAudioChunk)> =
Closure::new(move |audio_data: EncodedAudioChunk| { Closure::new(move |audio_data: EncodedAudioChunk| {
let mut array = vec![0u8; audio_data.byte_length() as usize]; let mut array = vec![0u8; audio_data.byte_length() as usize];
audio_data.copy_to_with_u8_slice(&mut array); audio_data.copy_to_with_u8_slice(&mut array);
// Check if this frame was marked as a terminator each(array);
let is_terminator = pending_terminator_output.swap(false);
each(array, is_terminator);
}); });
let audio_encoder = AudioEncoder::new(&AudioEncoderInit::new( let audio_encoder = AudioEncoder::new(&AudioEncoderInit::new(
@@ -310,19 +291,8 @@ async fn run_encoder_worklet(
} }
let frame = event.data(); let frame = event.data();
let state = process_audio(&frame, &mut current_processor); process_audio(&frame, &mut current_processor);
match state {
TransmitState::Silent => {
// Don't encode or send anything
return;
}
TransmitState::Transmitting => (), // Normal transmission
TransmitState::Terminator => {
// Mark this as a terminator before encoding
pending_terminator.store(true);
}
}
match AudioData::new(frame.unchecked_ref()) { match AudioData::new(frame.unchecked_ref()) {
Ok(data) => { Ok(data) => {
let _ = audio_encoder.encode(&data); let _ = audio_encoder.encode(&data);
@@ -426,10 +396,6 @@ pub fn set_default_username(username: &str) -> Option<()> {
.ok() .ok()
} }
pub fn set_default_server(username: &str) -> Option<()> {
None
}
pub fn load_username() -> Option<String> { pub fn load_username() -> Option<String> {
web_sys::window() web_sys::window()
.unwrap() .unwrap()
@@ -439,10 +405,6 @@ pub fn load_username() -> Option<String> {
.ok()? .ok()?
} }
pub fn load_server_url() -> Option<String> {
None
}
pub fn absolute_url(path: &str) -> Result<Url, Error> { pub fn absolute_url(path: &str) -> Result<Url, Error> {
let window: web_sys::Window = web_sys::window().expect("no global `window` exists"); let window: web_sys::Window = web_sys::window().expect("no global `window` exists");
let location = window.location(); let location = window.location();
@@ -464,15 +426,6 @@ pub async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(config) Ok(config)
} }
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
Ok(client
.get(absolute_url("status")?)
.send()
.await?
.json::<ServerStatus>()
.await?)
}
pub fn init_logging() { pub fn init_logging() {
// copied from tracing_web example usage // copied from tracing_web example usage
+6 -6
View File
@@ -20,9 +20,12 @@ use mumble_protocol::voice::VoicePacket;
use mumble_protocol::voice::VoicePacketPayload; use mumble_protocol::voice::VoicePacketPayload;
use mumble_protocol::Clientbound; use mumble_protocol::Clientbound;
use mumble_protocol::Serverbound; use mumble_protocol::Serverbound;
use mumble_web2_common::ClientConfig;
use once_cell::sync::Lazy;
use std::collections::hash_map::Entry; use std::collections::hash_map::Entry;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
use tracing::debug;
use tracing::error; use tracing::error;
use tracing::info; use tracing::info;
@@ -110,18 +113,18 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
}); });
} }
let mut audio = imp::AudioSystem::new().await?; let mut audio = imp::AudioSystem::new()?;
{ {
let send_chan = send_chan.clone(); let send_chan = send_chan.clone();
let mut sequence_num = 0; let mut sequence_num = 0;
audio.start_recording(move |opus_frame, is_terminator| { audio.start_recording(move |opus_frame| {
let _ = let _ =
send_chan.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio { send_chan.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio {
_dst: std::marker::PhantomData, _dst: std::marker::PhantomData,
target: 0, target: 0,
session_id: (), session_id: (),
seq_num: sequence_num, seq_num: sequence_num,
payload: VoicePacketPayload::Opus(opus_frame.into(), is_terminator), payload: VoicePacketPayload::Opus(opus_frame.into(), false),
position_info: None, position_info: None,
}))); })));
sequence_num = sequence_num.wrapping_add(2); sequence_num = sequence_num.wrapping_add(2);
@@ -404,9 +407,6 @@ fn accept_packet(
if u.has_deaf() { if u.has_deaf() {
state.deaf = u.get_deaf(); state.deaf = u.get_deaf();
} }
if u.has_suppress() {
state.suppress = u.get_suppress();
}
if u.has_self_mute() { if u.has_self_mute() {
state.self_mute = u.get_self_mute(); state.self_mute = u.get_self_mute();
} }
+19 -7
View File
@@ -1,6 +1,10 @@
use color_eyre::eyre::{anyhow, bail, Context, Result}; use color_eyre::eyre::{anyhow, bail, Context, Result};
use color_eyre::owo_colors::OwoColorize;
use mumble_web2_common::{ClientConfig, ServerStatus}; use mumble_web2_common::{ClientConfig, ServerStatus};
use once_cell::sync::OnceCell;
use rand::Rng; use rand::Rng;
use rcgen::date_time_ymd;
use rustls::server;
use salvo::conn::rustls::{Keycert, RustlsConfig}; use salvo::conn::rustls::{Keycert, RustlsConfig};
use salvo::cors::{AllowOrigin, Cors}; use salvo::cors::{AllowOrigin, Cors};
use salvo::logging::Logger; use salvo::logging::Logger;
@@ -34,6 +38,7 @@ fn default_cert_alt_names() -> Vec<String> {
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
struct Config { struct Config {
public_url: Url,
proxy_url: Option<Url>, proxy_url: Option<Url>,
https_listen_address: SocketAddr, https_listen_address: SocketAddr,
http_listen_address: Option<SocketAddr>, http_listen_address: Option<SocketAddr>,
@@ -80,8 +85,9 @@ async fn main() -> Result<()> {
let mut client_config = ClientConfig { let mut client_config = ClientConfig {
proxy_url: match &server_config.proxy_url { proxy_url: match &server_config.proxy_url {
Some(url) => Some(url.to_string()), Some(url) => Some(url.to_string()),
None => None, None => Some(server_config.public_url.join("proxy")?.to_string()),
}, },
status_url: Some(server_config.public_url.join("status")?.to_string()),
cert_hash: None, cert_hash: None,
any_server: false, any_server: false,
}; };
@@ -335,13 +341,19 @@ async fn connect_proxy_impl(
info!("connected to Mumble server"); info!("connected to Mumble server");
// Handle transmitting data between the WebTransport client and Mumble TCP Server // Spawn tasks to handle transmitting data between the WebTransport client and Mumble TCP Server
// When one direction completes/fails, the other is dropped and its streams are closed let c2s = tokio::spawn(
pass_bytes_loop(incoming, write_server)
.instrument(info_span!("Handler", "Client to server")),
);
let s2c = tokio::spawn(
pass_bytes_loop(read_server, outgoing)
.instrument(info_span!("Handler", "Server to client")),
);
tokio::select! { tokio::select! {
res = pass_bytes_loop(incoming, write_server) res = c2s => res??,
.instrument(info_span!("Handler", "Client to server")) => res?, res = s2c => res??,
res = pass_bytes_loop(read_server, outgoing)
.instrument(info_span!("Handler", "Server to client")) => res?,
}; };
Ok(()) Ok(())
} }