mirror of
https://github.com/coral/sipcord-bridge.git
synced 2026-04-12 20:42:33 -06:00
big one
This commit is contained in:
commit
2cdf0b3883
12
.env.example
Normal file
12
.env.example
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Required
|
||||||
|
SIP_PUBLIC_HOST=0.0.0.0
|
||||||
|
DISCORD_BOT_TOKEN=your_bot_token_here
|
||||||
|
|
||||||
|
# Optional (defaults shown)
|
||||||
|
# DATA_DIR=/var/lib/sipcord
|
||||||
|
# CONFIG_PATH=./config.toml
|
||||||
|
# SOUNDS_DIR=./wav
|
||||||
|
# SIP_PORT=5060
|
||||||
|
# RTP_PORT_START=10000
|
||||||
|
# RTP_PORT_END=15000
|
||||||
|
# DEV_MODE=false
|
||||||
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Cargo build output
|
||||||
|
target/
|
||||||
|
debug/
|
||||||
|
|
||||||
|
# Rustfmt backup files
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# MSVC debug info
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Temporary fax files
|
||||||
|
tmp/
|
||||||
5572
Cargo.lock
generated
Normal file
5572
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
3
Cargo.toml
Normal file
3
Cargo.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[workspace]
|
||||||
|
members = ["sipcord-bridge", "pjsua"]
|
||||||
|
resolver = "2"
|
||||||
115
Dockerfile
Normal file
115
Dockerfile
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# Stage 0: Shared base with build dependencies
|
||||||
|
FROM debian:trixie AS build-base
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
cmake \
|
||||||
|
pkg-config \
|
||||||
|
build-essential \
|
||||||
|
libssl-dev \
|
||||||
|
libasound2-dev \
|
||||||
|
uuid-dev \
|
||||||
|
libclang-dev \
|
||||||
|
curl \
|
||||||
|
libopencore-amrnb-dev \
|
||||||
|
libopencore-amrwb-dev \
|
||||||
|
libopus-dev \
|
||||||
|
libtiff-dev \
|
||||||
|
libjpeg-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Stage 1: Build pjproject C library (slow, cached unless pjsua/pjproject changes)
|
||||||
|
FROM build-base AS pjproject-builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY pjsua/pjproject/ pjproject-src/
|
||||||
|
|
||||||
|
RUN mkdir -p pjproject-build pjproject-install && \
|
||||||
|
cd pjproject-build && \
|
||||||
|
cmake \
|
||||||
|
-G "Unix Makefiles" \
|
||||||
|
-DCMAKE_INSTALL_PREFIX=/build/pjproject-install \
|
||||||
|
-DCMAKE_BUILD_TYPE=Release \
|
||||||
|
-DBUILD_SHARED_LIBS=OFF \
|
||||||
|
-DPJ_SKIP_EXPERIMENTAL_NOTICE=ON \
|
||||||
|
-DPJ_ENABLE_TESTS=OFF \
|
||||||
|
-DBUILD_TESTING=OFF \
|
||||||
|
-DPJMEDIA_WITH_VIDEO=OFF \
|
||||||
|
-DPJMEDIA_WITH_FFMPEG=OFF \
|
||||||
|
-DPJMEDIA_WITH_LIBYUV=OFF \
|
||||||
|
-DPJMEDIA_WITH_OPENCORE_AMRNB_CODEC=ON \
|
||||||
|
-DPJMEDIA_WITH_OPENCORE_AMRWB_CODEC=ON \
|
||||||
|
-DPJMEDIA_WITH_OPUS_CODEC=ON \
|
||||||
|
-DPJLIB_WITH_SSL=openssl \
|
||||||
|
"-DCMAKE_C_FLAGS=-DPJSUA_MAX_CALLS=128" \
|
||||||
|
"-DCMAKE_CXX_FLAGS=-DPJSUA_MAX_CALLS=128" \
|
||||||
|
../pjproject-src && \
|
||||||
|
cmake --build . -j$(nproc) \
|
||||||
|
--target pjlib pjlib-util pjnath pjmedia pjmedia-audiodev \
|
||||||
|
pjmedia-codec pjsip pjsip-simple pjsip-ua pjsua-lib pjsua2 \
|
||||||
|
resample srtp speex g7221 gsm ilbc && \
|
||||||
|
cmake --install . || true
|
||||||
|
|
||||||
|
# Collect all .a files into a single flat lib directory
|
||||||
|
RUN mkdir -p /build/pjproject-install/lib && \
|
||||||
|
find /build/pjproject-build /build/pjproject-install -name '*.a' -exec cp -n {} /build/pjproject-install/lib/ \; && \
|
||||||
|
echo "Libraries collected:" && ls /build/pjproject-install/lib/
|
||||||
|
|
||||||
|
# Stage 2: Build Rust dependencies (cached unless Cargo.toml/lock changes)
|
||||||
|
FROM build-base AS deps-builder
|
||||||
|
|
||||||
|
# Install Rust nightly (required for portable_simd)
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain nightly
|
||||||
|
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy pre-built pjproject from stage 1
|
||||||
|
COPY --from=pjproject-builder /build/pjproject-install /pjproject
|
||||||
|
ENV PJPROJECT_DIR=/pjproject
|
||||||
|
|
||||||
|
# Copy only what cargo needs for dependency resolution
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY pjsua/ pjsua/
|
||||||
|
COPY sipcord-bridge/Cargo.toml sipcord-bridge/Cargo.toml
|
||||||
|
|
||||||
|
# Create dummy source files to build dependencies only
|
||||||
|
RUN mkdir -p sipcord-bridge/src && \
|
||||||
|
echo '#![feature(portable_simd)] fn main() {}' > sipcord-bridge/src/main.rs && \
|
||||||
|
echo '#![feature(portable_simd)]' > sipcord-bridge/src/lib.rs
|
||||||
|
|
||||||
|
RUN cargo build --release -p sipcord-bridge
|
||||||
|
|
||||||
|
# Stage 3: Build application (fast, only rebuilds when src/ changes)
|
||||||
|
FROM deps-builder AS builder
|
||||||
|
|
||||||
|
RUN rm -rf sipcord-bridge/src
|
||||||
|
COPY sipcord-bridge/src/ sipcord-bridge/src/
|
||||||
|
COPY wav/ wav/
|
||||||
|
COPY config.toml config.toml
|
||||||
|
|
||||||
|
RUN touch sipcord-bridge/src/main.rs sipcord-bridge/src/lib.rs
|
||||||
|
RUN cargo build --release -p sipcord-bridge
|
||||||
|
|
||||||
|
# Stage 4: Minimal runtime image
|
||||||
|
FROM debian:trixie-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ca-certificates \
|
||||||
|
libasound2 \
|
||||||
|
libssl3 \
|
||||||
|
libuuid1 \
|
||||||
|
libopencore-amrnb0 \
|
||||||
|
libopencore-amrwb0 \
|
||||||
|
libopus0 \
|
||||||
|
libtiff6 \
|
||||||
|
libjpeg62-turbo \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /build/target/release/sipcord-bridge /app/sipcord-bridge
|
||||||
|
COPY --from=builder /build/config.toml /app/config.toml
|
||||||
|
COPY --from=builder /build/wav/ /app/wav/
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/sipcord-bridge"]
|
||||||
20
README.md
Normal file
20
README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# SIPcord Bridge
|
||||||
|
|
||||||
|
This is a slice of the code that powers [SIPcord](https://sipcord.net/) that you can use to self host something similar. It's not the full SIPcord package but rather the core functionality used in SIPcord with ways to build your own backend adapter. SIPcord itself uses this as a component of the full build so the code is the same that runs on the public bridges.
|
||||||
|
|
||||||
|
## Help!
|
||||||
|
|
||||||
|
I am providing 0 support for this, my goal is to run [sipcord.net](https://sipcord.net/), not support self hosting. If you want to run this self hosted, feel free to use this code but do not ask me for support.
|
||||||
|
|
||||||
|
## I have a feature request!
|
||||||
|
|
||||||
|
**PR's welcome**. No really, feel free to implement it and contribute.
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
- Thanks to [dusthillguy](https://www.youtube.com/watch?v=IK1ydvw3xkU) for letting me use the song *"Joona Kouvolalainen buttermilk"* as hold music and distribute it.
|
||||||
|
- Thanks to chrischrome for hosting bridge-use1
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GPLv3
|
||||||
43
config.toml
Normal file
43
config.toml
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Sipcord Bridge — sample configuration
|
||||||
|
#
|
||||||
|
# Copy this file to your working directory and adjust as needed.
|
||||||
|
# All sections except [sounds] are optional (defaults shown).
|
||||||
|
|
||||||
|
[sounds]
|
||||||
|
# System sounds (preloaded into memory at startup)
|
||||||
|
discord_join = { src = "discord_join.wav", preload = true }
|
||||||
|
connecting = { src = "connecting.wav", preload = true }
|
||||||
|
unknown_error = { src = "unknown.wav", preload = true }
|
||||||
|
no_channel_mapping = { src = "no_channel_mapping.wav", preload = true }
|
||||||
|
no_permissions = { src = "no_permissions.wav", preload = true }
|
||||||
|
server_is_busy = { src = "serverisbusy.wav", preload = true }
|
||||||
|
|
||||||
|
# Easter eggs (streamed from disk on demand)
|
||||||
|
easteregg = { src = "nokia.flac", preload = false, extension = 11111 }
|
||||||
|
hold = { src = "JoonaKouvolalainen.flac", preload = false, extension = 10000 }
|
||||||
|
|
||||||
|
# Test tone — 440Hz sine wave, generated dynamically (no file needed)
|
||||||
|
test_tone = { extension = 0 }
|
||||||
|
|
||||||
|
[bridge]
|
||||||
|
# rtp_inactivity_timeout_secs = 60
|
||||||
|
# no_audio_timeout_secs = 10
|
||||||
|
# empty_bridge_grace_period_secs = 30
|
||||||
|
# max_channel_buffer_samples = 32000
|
||||||
|
# api_timeout_secs = 10
|
||||||
|
# health_check_interval_secs = 5
|
||||||
|
# voice_join_max_retries = 2
|
||||||
|
# voice_join_retry_delay_secs = 5
|
||||||
|
# pjsip_log_level = 4
|
||||||
|
|
||||||
|
[audio]
|
||||||
|
# ring_buffer_samples = 96000
|
||||||
|
# pre_buffer_samples = 14400
|
||||||
|
# vad_silence_threshold = 200
|
||||||
|
# vad_mute_threshold = 50
|
||||||
|
# vad_silence_frames_before_stop = 15
|
||||||
|
|
||||||
|
[fax]
|
||||||
|
# tmp_folder = "/tmp/sipcord-fax"
|
||||||
|
# prefix = "fax_"
|
||||||
|
# output_format = "png"
|
||||||
12
pjsua/Cargo.toml
Normal file
12
pjsua/Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[package]
|
||||||
|
name = "pjsua"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Rust bindings for pjsua (pjproject SIP library)"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
bindgen = "0.71"
|
||||||
|
pkg-config = "0.3"
|
||||||
|
cmake = "0.1"
|
||||||
535
pjsua/build.rs
Normal file
535
pjsua/build.rs
Normal file
|
|
@ -0,0 +1,535 @@
|
||||||
|
//! Build script for pjsua bindings
|
||||||
|
//!
|
||||||
|
//! This script builds pjproject from source if not found, then generates
|
||||||
|
//! Rust bindings using bindgen.
|
||||||
|
//!
|
||||||
|
//! Set PJPROJECT_DIR to a pre-built pjproject install prefix to skip the
|
||||||
|
//! cmake build (used in Docker to separate the slow C build into its own layer).
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
|
println!("cargo:rerun-if-env-changed=PJPROJECT_DIR");
|
||||||
|
|
||||||
|
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||||
|
|
||||||
|
// If PJPROJECT_DIR is set, use pre-built pjproject (e.g. from a separate Docker stage).
|
||||||
|
// Otherwise build from source via cmake.
|
||||||
|
let include_paths = if let Ok(prefix) = env::var("PJPROJECT_DIR") {
|
||||||
|
let prefix = PathBuf::from(&prefix);
|
||||||
|
println!("cargo:warning=Using pre-built pjproject from: {}", prefix.display());
|
||||||
|
|
||||||
|
let lib_dir = prefix.join("lib");
|
||||||
|
println!("cargo:rustc-link-search=native={}", lib_dir.display());
|
||||||
|
|
||||||
|
// Link libraries in the correct dependency order (same as build-from-source path)
|
||||||
|
let pj_libs = [
|
||||||
|
"pjsua-lib", "pjsua2", "pjsip-ua", "pjsip-simple", "pjsip",
|
||||||
|
"pjmedia-codec", "pjmedia", "pjmedia-audiodev", "pjnath",
|
||||||
|
"pjlib-util", "pjlib",
|
||||||
|
"srtp", "resample", "speex", "g7221", "gsm", "ilbc",
|
||||||
|
];
|
||||||
|
for lib in &pj_libs {
|
||||||
|
println!("cargo:rustc-link-lib=static={}", lib);
|
||||||
|
}
|
||||||
|
|
||||||
|
vec![prefix.join("include")]
|
||||||
|
} else {
|
||||||
|
build_from_source(&out_dir)
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- System libraries (common to both paths) ----
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
println!("cargo:rustc-link-lib=framework=AudioToolbox");
|
||||||
|
println!("cargo:rustc-link-lib=framework=AudioUnit");
|
||||||
|
println!("cargo:rustc-link-lib=framework=CoreAudio");
|
||||||
|
println!("cargo:rustc-link-lib=framework=CoreServices");
|
||||||
|
println!("cargo:rustc-link-lib=framework=Foundation");
|
||||||
|
println!("cargo:rustc-link-lib=framework=AVFoundation");
|
||||||
|
println!("cargo:rustc-link-lib=framework=CoreMedia");
|
||||||
|
println!("cargo:rustc-link-lib=framework=CoreVideo");
|
||||||
|
println!("cargo:rustc-link-lib=framework=VideoToolbox");
|
||||||
|
println!("cargo:rustc-link-lib=framework=Security");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
println!("cargo:rustc-link-lib=asound");
|
||||||
|
println!("cargo:rustc-link-lib=pthread");
|
||||||
|
println!("cargo:rustc-link-lib=m");
|
||||||
|
println!("cargo:rustc-link-lib=rt");
|
||||||
|
println!("cargo:rustc-link-lib=uuid");
|
||||||
|
println!("cargo:rustc-link-lib=opencore-amrnb");
|
||||||
|
println!("cargo:rustc-link-lib=opencore-amrwb");
|
||||||
|
println!("cargo:rustc-link-lib=opus");
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenSSL
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let openssl_paths = [
|
||||||
|
"/opt/homebrew/opt/openssl@3/lib",
|
||||||
|
"/opt/homebrew/opt/openssl/lib",
|
||||||
|
"/usr/local/opt/openssl@3/lib",
|
||||||
|
"/usr/local/opt/openssl/lib",
|
||||||
|
];
|
||||||
|
for path in &openssl_paths {
|
||||||
|
if std::path::Path::new(path).exists() {
|
||||||
|
println!("cargo:rustc-link-search=native={}", path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let amr_paths = [
|
||||||
|
"/opt/homebrew/opt/opencore-amr/lib",
|
||||||
|
"/usr/local/opt/opencore-amr/lib",
|
||||||
|
];
|
||||||
|
for path in &amr_paths {
|
||||||
|
if std::path::Path::new(path).exists() {
|
||||||
|
println!("cargo:rustc-link-search=native={}", path);
|
||||||
|
println!("cargo:rustc-link-lib=opencore-amrnb");
|
||||||
|
println!("cargo:rustc-link-lib=opencore-amrwb");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let opus_paths = [
|
||||||
|
"/opt/homebrew/opt/opus/lib",
|
||||||
|
"/usr/local/opt/opus/lib",
|
||||||
|
];
|
||||||
|
for path in &opus_paths {
|
||||||
|
if std::path::Path::new(path).exists() {
|
||||||
|
println!("cargo:rustc-link-search=native={}", path);
|
||||||
|
println!("cargo:rustc-link-lib=opus");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("cargo:rustc-link-lib=ssl");
|
||||||
|
println!("cargo:rustc-link-lib=crypto");
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
println!("cargo:rustc-link-lib=c++");
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
println!("cargo:rustc-link-lib=stdc++");
|
||||||
|
|
||||||
|
// ---- Generate bindings ----
|
||||||
|
|
||||||
|
let mut clang_args = Vec::new();
|
||||||
|
|
||||||
|
for path in &include_paths {
|
||||||
|
clang_args.push(format!("-I{}", path.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_endian = "little")]
|
||||||
|
{
|
||||||
|
clang_args.push("-DPJ_IS_LITTLE_ENDIAN=1".to_string());
|
||||||
|
clang_args.push("-DPJ_IS_BIG_ENDIAN=0".to_string());
|
||||||
|
}
|
||||||
|
#[cfg(target_endian = "big")]
|
||||||
|
{
|
||||||
|
clang_args.push("-DPJ_IS_LITTLE_ENDIAN=0".to_string());
|
||||||
|
clang_args.push("-DPJ_IS_BIG_ENDIAN=1".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
clang_args.push("-DPJ_DARWINOS=1".to_string());
|
||||||
|
clang_args.push("-DPJ_HAS_LIMITS_H=1".to_string());
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
clang_args.push("-DPJ_LINUX=1".to_string());
|
||||||
|
clang_args.push("-DPJ_HAS_LIMITS_H=1".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_pointer_width = "64")]
|
||||||
|
clang_args.push("-DPJ_HAS_INT64=1".to_string());
|
||||||
|
|
||||||
|
clang_args.push("-DPJ_AUTOCONF=1".to_string());
|
||||||
|
|
||||||
|
let pjsua_header = include_paths.iter()
|
||||||
|
.find_map(|p| {
|
||||||
|
let header = p.join("pjsua-lib/pjsua.h");
|
||||||
|
if header.exists() {
|
||||||
|
return Some(header);
|
||||||
|
}
|
||||||
|
let header = p.join("pjsua.h");
|
||||||
|
if header.exists() { Some(header) } else { None }
|
||||||
|
})
|
||||||
|
.expect("Could not find pjsua.h header in installed location");
|
||||||
|
|
||||||
|
println!("cargo:warning=Using pjsua.h from: {}", pjsua_header.display());
|
||||||
|
println!("cargo:warning=Include paths: {:?}", include_paths);
|
||||||
|
|
||||||
|
let bindings = bindgen::Builder::default()
|
||||||
|
.header(pjsua_header.to_str().unwrap())
|
||||||
|
.clang_args(&clang_args)
|
||||||
|
.generate_comments(false)
|
||||||
|
.allowlist_type(r"pj.*")
|
||||||
|
.allowlist_type(r"PJ.*")
|
||||||
|
.allowlist_var(r"pj.*")
|
||||||
|
.allowlist_var(r"PJ.*")
|
||||||
|
.allowlist_function(r"pj.*")
|
||||||
|
.allowlist_function(r"PJ.*")
|
||||||
|
.generate()
|
||||||
|
.expect("Unable to generate bindings");
|
||||||
|
|
||||||
|
let bindings_path = out_dir.join("bindings.rs");
|
||||||
|
bindings
|
||||||
|
.write_to_file(&bindings_path)
|
||||||
|
.expect("Couldn't write bindings!");
|
||||||
|
|
||||||
|
println!("cargo:warning=Bindings written to: {}", bindings_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build pjproject from source and return include paths.
|
||||||
|
fn build_from_source(out_dir: &PathBuf) -> Vec<PathBuf> {
|
||||||
|
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||||
|
let pjproject_src = manifest_dir.join("pjproject");
|
||||||
|
|
||||||
|
let pjproject_build = out_dir.join("pjproject-build");
|
||||||
|
let pjproject_install = out_dir.join("pjproject-install");
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&pjproject_build).expect("Failed to create build directory");
|
||||||
|
std::fs::create_dir_all(&pjproject_install).expect("Failed to create install directory");
|
||||||
|
|
||||||
|
let include_dir = pjproject_install.join("include");
|
||||||
|
let lib_dir = pjproject_install.join("lib");
|
||||||
|
|
||||||
|
build_pjproject(&pjproject_src, &pjproject_build, &pjproject_install);
|
||||||
|
|
||||||
|
let include_paths = vec![include_dir.clone()];
|
||||||
|
let lib_paths = vec![lib_dir.clone()];
|
||||||
|
|
||||||
|
// Set up library paths
|
||||||
|
for path in &lib_paths {
|
||||||
|
println!("cargo:rustc-link-search=native={}", path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
// For built-from-source pjproject, libraries are in the build directory subdirs
|
||||||
|
let pjproject_build_for_libs = out_dir.join("pjproject-build");
|
||||||
|
if pjproject_build_for_libs.exists() {
|
||||||
|
let lib_subdirs = [
|
||||||
|
"pjlib",
|
||||||
|
"pjlib-util",
|
||||||
|
"pjmedia",
|
||||||
|
"pjnath",
|
||||||
|
"pjsip",
|
||||||
|
"third_party/resample",
|
||||||
|
"third_party/speex",
|
||||||
|
"third_party/g7221",
|
||||||
|
"third_party/yuv",
|
||||||
|
"third_party/gsm",
|
||||||
|
"third_party/srtp",
|
||||||
|
"third_party/ilbc",
|
||||||
|
];
|
||||||
|
|
||||||
|
for subdir in &lib_subdirs {
|
||||||
|
let lib_path = pjproject_build_for_libs.join(subdir);
|
||||||
|
if lib_path.exists() {
|
||||||
|
println!("cargo:rustc-link-search=native={}", lib_path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link libraries in the correct order (dependencies matter!)
|
||||||
|
let pj_libs = [
|
||||||
|
"pjsua-lib", // main pjsua library
|
||||||
|
"pjsua2", // C++ wrapper (may be needed)
|
||||||
|
"pjsip-ua", // SIP user agent
|
||||||
|
"pjsip-simple", // SIP SIMPLE presence
|
||||||
|
"pjsip", // Core SIP
|
||||||
|
"pjmedia-codec",// Media codecs
|
||||||
|
"pjmedia", // Media framework
|
||||||
|
"pjmedia-audiodev", // Audio device
|
||||||
|
"pjnath", // NAT traversal
|
||||||
|
"pjlib-util", // Utility functions
|
||||||
|
"pjlib", // Core library
|
||||||
|
// Third party
|
||||||
|
"srtp",
|
||||||
|
"resample",
|
||||||
|
"speex",
|
||||||
|
"g7221",
|
||||||
|
"gsm",
|
||||||
|
"ilbc",
|
||||||
|
];
|
||||||
|
|
||||||
|
for lib in &pj_libs {
|
||||||
|
println!("cargo:rustc-link-lib=static={}", lib);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Link against pjproject libraries from install directory (static)
|
||||||
|
for lib_path in &lib_paths {
|
||||||
|
if let Ok(entries) = std::fs::read_dir(lib_path) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if let Some(ext) = path.extension() {
|
||||||
|
if ext == "a" {
|
||||||
|
if let Some(name) = path.file_stem() {
|
||||||
|
let name = name.to_string_lossy();
|
||||||
|
if name.starts_with("lib") {
|
||||||
|
let lib_name = name.strip_prefix("lib").unwrap();
|
||||||
|
println!("cargo:rustc-link-lib=static={}", lib_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
include_paths
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_pjproject(pjproject_src: &std::path::Path, pjproject_build: &std::path::Path, pjproject_install: &std::path::Path) {
|
||||||
|
// Check for .pc file in build dir (CMake install doesn't always copy it to install dir)
|
||||||
|
let pc_file = pjproject_build.join("libpjproject.pc");
|
||||||
|
|
||||||
|
if !pc_file.exists() {
|
||||||
|
println!("cargo:warning=Building pjproject from source (this may take several minutes)...");
|
||||||
|
|
||||||
|
// Detect cross-compilation target
|
||||||
|
let target = env::var("TARGET").unwrap_or_default();
|
||||||
|
let host = env::var("HOST").unwrap_or_default();
|
||||||
|
let is_cross = target != host;
|
||||||
|
|
||||||
|
// Collect C/CXX flags — merged at the end into CMAKE_C_FLAGS/CMAKE_CXX_FLAGS.
|
||||||
|
// pjsua.h guards PJSUA_MAX_CALLS with #ifndef, so -D on the command line wins.
|
||||||
|
let mut c_flags: Vec<&str> = vec!["-DPJSUA_MAX_CALLS=128"];
|
||||||
|
|
||||||
|
let mut cmake_args = vec![
|
||||||
|
"-G".to_string(), "Unix Makefiles".to_string(),
|
||||||
|
format!("-DCMAKE_INSTALL_PREFIX={}", pjproject_install.display()),
|
||||||
|
"-DCMAKE_BUILD_TYPE=Release".to_string(),
|
||||||
|
"-DBUILD_SHARED_LIBS=OFF".to_string(),
|
||||||
|
"-DPJ_SKIP_EXPERIMENTAL_NOTICE=ON".to_string(),
|
||||||
|
// Disable tests to avoid linking issues with cross-compilation
|
||||||
|
"-DPJ_ENABLE_TESTS=OFF".to_string(),
|
||||||
|
"-DBUILD_TESTING=OFF".to_string(),
|
||||||
|
// Disable video support
|
||||||
|
"-DPJMEDIA_WITH_VIDEO=OFF".to_string(),
|
||||||
|
"-DPJMEDIA_WITH_FFMPEG=OFF".to_string(),
|
||||||
|
"-DPJMEDIA_WITH_LIBYUV=OFF".to_string(),
|
||||||
|
// Enable AMR codecs (IMS/MR-NB support)
|
||||||
|
"-DPJMEDIA_WITH_OPENCORE_AMRNB_CODEC=ON".to_string(),
|
||||||
|
"-DPJMEDIA_WITH_OPENCORE_AMRWB_CODEC=ON".to_string(),
|
||||||
|
// Enable Opus codec
|
||||||
|
"-DPJMEDIA_WITH_OPUS_CODEC=ON".to_string(),
|
||||||
|
// Enable TLS/SSL support with OpenSSL
|
||||||
|
"-DPJLIB_WITH_SSL=openssl".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Configure cross-compilation toolchain
|
||||||
|
if is_cross {
|
||||||
|
println!("cargo:warning=Cross-compiling for {} from {}", target, host);
|
||||||
|
|
||||||
|
// Map Rust target to cross-compiler prefix
|
||||||
|
let cross_prefix = match target.as_str() {
|
||||||
|
"aarch64-unknown-linux-gnu" => "aarch64-linux-gnu",
|
||||||
|
"x86_64-unknown-linux-gnu" => "x86_64-linux-gnu",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if !cross_prefix.is_empty() {
|
||||||
|
let cc = format!("{}-gcc", cross_prefix);
|
||||||
|
let cxx = format!("{}-g++", cross_prefix);
|
||||||
|
|
||||||
|
// Check if cross-compiler exists
|
||||||
|
if std::process::Command::new("which").arg(&cc).output().map(|o| o.status.success()).unwrap_or(false) {
|
||||||
|
cmake_args.push(format!("-DCMAKE_C_COMPILER={}", cc));
|
||||||
|
cmake_args.push(format!("-DCMAKE_CXX_COMPILER={}", cxx));
|
||||||
|
println!("cargo:warning=Using cross-compiler: {}", cc);
|
||||||
|
|
||||||
|
// ARM64: Fix atomic alignment issues
|
||||||
|
// 1. -mno-outline-atomics: Use inline atomics instead of helper functions
|
||||||
|
// 2. -DPJ_POOL_ALIGNMENT=8: Force pjlib pool to use 8-byte alignment (C define)
|
||||||
|
if target.contains("aarch64") {
|
||||||
|
c_flags.push("-mno-outline-atomics");
|
||||||
|
c_flags.push("-DPJ_POOL_ALIGNMENT=8");
|
||||||
|
println!("cargo:warning=ARM64: Using inline atomics with 8-byte pool alignment");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cross-compiler (from crossbuild-essential-arm64) has --sysroot=/usr/aarch64-linux-gnu
|
||||||
|
// baked into its specs, but the actual libraries are in /usr/lib/aarch64-linux-gnu/
|
||||||
|
// via Debian's multiarch. We must override the sysroot to "/" so the linker
|
||||||
|
// finds libc at /lib/aarch64-linux-gnu/ instead of the non-existent
|
||||||
|
// /usr/aarch64-linux-gnu/lib/libc.so.6
|
||||||
|
cmake_args.push("-DCMAKE_SYSROOT=/".to_string());
|
||||||
|
println!("cargo:warning=Overriding sysroot to / for multiarch compatibility");
|
||||||
|
|
||||||
|
// Help CMake find cross-compiled libraries in multiarch paths
|
||||||
|
let multiarch_lib = format!("/usr/lib/{}", cross_prefix);
|
||||||
|
if std::path::Path::new(&multiarch_lib).exists() {
|
||||||
|
cmake_args.push(format!("-DCMAKE_FIND_ROOT_PATH=/usr;{}", multiarch_lib));
|
||||||
|
cmake_args.push("-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH".to_string());
|
||||||
|
cmake_args.push("-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH".to_string());
|
||||||
|
cmake_args.push("-DCMAKE_FIND_ROOT_PATH_MODE_PROGRAM=NEVER".to_string());
|
||||||
|
println!("cargo:warning=Using multiarch library path: {}", multiarch_lib);
|
||||||
|
|
||||||
|
// Explicitly set OpenSSL paths for cross-compilation
|
||||||
|
let openssl_ssl = format!("{}/libssl.so", multiarch_lib);
|
||||||
|
let openssl_crypto = format!("{}/libcrypto.so", multiarch_lib);
|
||||||
|
if std::path::Path::new(&openssl_ssl).exists() {
|
||||||
|
cmake_args.push("-DOPENSSL_ROOT_DIR=/usr".to_string());
|
||||||
|
cmake_args.push("-DOPENSSL_INCLUDE_DIR=/usr/include".to_string());
|
||||||
|
cmake_args.push(format!("-DOPENSSL_SSL_LIBRARY={}", openssl_ssl));
|
||||||
|
cmake_args.push(format!("-DOPENSSL_CRYPTO_LIBRARY={}", openssl_crypto));
|
||||||
|
println!("cargo:warning=Using cross-compiled OpenSSL from {}", multiarch_lib);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly set Opus paths for cross-compilation
|
||||||
|
let opus_lib = format!("{}/libopus.so", multiarch_lib);
|
||||||
|
if std::path::Path::new(&opus_lib).exists() {
|
||||||
|
cmake_args.push("-DOPUS_INCLUDE_DIR=/usr/include".to_string());
|
||||||
|
cmake_args.push(format!("-DOPUS_LIBRARY={}", opus_lib));
|
||||||
|
println!("cargo:warning=Using cross-compiled Opus from {}", multiarch_lib);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Native build - find OpenSSL in standard locations
|
||||||
|
let openssl_prefixes = if cfg!(target_os = "macos") {
|
||||||
|
vec![
|
||||||
|
"/opt/homebrew/opt/openssl@3",
|
||||||
|
"/opt/homebrew/opt/openssl",
|
||||||
|
"/usr/local/opt/openssl@3",
|
||||||
|
"/usr/local/opt/openssl",
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec!["/usr", "/usr/local"]
|
||||||
|
};
|
||||||
|
|
||||||
|
for prefix in &openssl_prefixes {
|
||||||
|
let include_path = format!("{}/include", prefix);
|
||||||
|
if std::path::Path::new(&include_path).join("openssl/ssl.h").exists() {
|
||||||
|
println!("cargo:warning=Found OpenSSL at: {}", prefix);
|
||||||
|
cmake_args.push(format!("-DOPENSSL_ROOT_DIR={}", prefix));
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
cmake_args.push(format!("-DOPENSSL_INCLUDE_DIR={}", include_path));
|
||||||
|
let lib_path = format!("{}/lib", prefix);
|
||||||
|
let static_crypto = format!("{}/libcrypto.a", lib_path);
|
||||||
|
let static_ssl = format!("{}/libssl.a", lib_path);
|
||||||
|
if std::path::Path::new(&static_crypto).exists() {
|
||||||
|
cmake_args.push(format!("-DOPENSSL_CRYPTO_LIBRARY={}", static_crypto));
|
||||||
|
cmake_args.push(format!("-DOPENSSL_SSL_LIBRARY={}", static_ssl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native build - find Opus codec library
|
||||||
|
let opus_prefixes = if cfg!(target_os = "macos") {
|
||||||
|
vec![
|
||||||
|
"/opt/homebrew/opt/opus",
|
||||||
|
"/usr/local/opt/opus",
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec!["/usr", "/usr/local"]
|
||||||
|
};
|
||||||
|
|
||||||
|
for prefix in &opus_prefixes {
|
||||||
|
let include_path = format!("{}/include", prefix);
|
||||||
|
if std::path::Path::new(&include_path).join("opus/opus.h").exists() {
|
||||||
|
println!("cargo:warning=Found Opus at: {}", prefix);
|
||||||
|
cmake_args.push(format!("-DOPUS_INCLUDE_DIR={}", include_path));
|
||||||
|
let lib_path = format!("{}/lib", prefix);
|
||||||
|
let opus_lib = if cfg!(target_os = "macos") {
|
||||||
|
format!("{}/libopus.a", lib_path)
|
||||||
|
} else {
|
||||||
|
format!("{}/libopus.so", lib_path)
|
||||||
|
};
|
||||||
|
if std::path::Path::new(&opus_lib).exists() {
|
||||||
|
cmake_args.push(format!("-DOPUS_LIBRARY={}", opus_lib));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge all collected C/CXX flags into cmake args
|
||||||
|
if !c_flags.is_empty() {
|
||||||
|
let flags = c_flags.join(" ");
|
||||||
|
println!("cargo:warning=C flags: {}", flags);
|
||||||
|
cmake_args.push(format!("-DCMAKE_C_FLAGS={}", flags));
|
||||||
|
cmake_args.push(format!("-DCMAKE_CXX_FLAGS={}", flags));
|
||||||
|
}
|
||||||
|
|
||||||
|
cmake_args.push(pjproject_src.to_str().unwrap().to_string());
|
||||||
|
|
||||||
|
// Run CMake configure
|
||||||
|
let cmake_result = Command::new("cmake")
|
||||||
|
.current_dir(pjproject_build)
|
||||||
|
.args(&cmake_args)
|
||||||
|
.output()
|
||||||
|
.expect("Failed to run cmake configure");
|
||||||
|
|
||||||
|
if !cmake_result.status.success() {
|
||||||
|
eprintln!("CMake configure stdout: {}", String::from_utf8_lossy(&cmake_result.stdout));
|
||||||
|
eprintln!("CMake configure stderr: {}", String::from_utf8_lossy(&cmake_result.stderr));
|
||||||
|
panic!("CMake configure failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get number of CPUs for parallel build
|
||||||
|
let num_cpus = std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(4);
|
||||||
|
|
||||||
|
// Run CMake build - only build the libraries we need, not sample apps
|
||||||
|
println!("cargo:warning=Compiling pjproject with {} threads...", num_cpus);
|
||||||
|
let mut build_args = vec![
|
||||||
|
"--build".to_string(), ".".to_string(),
|
||||||
|
"--config".to_string(), "Release".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Specify only the library targets we need
|
||||||
|
let targets = [
|
||||||
|
"pjlib", "pjlib-util", "pjnath", "pjmedia", "pjmedia-audiodev",
|
||||||
|
"pjmedia-codec", "pjsip", "pjsip-simple", "pjsip-ua", "pjsua-lib", "pjsua2",
|
||||||
|
"resample", "srtp", "speex", "g7221", "gsm", "ilbc",
|
||||||
|
];
|
||||||
|
for target in &targets {
|
||||||
|
build_args.push("--target".to_string());
|
||||||
|
build_args.push(target.to_string());
|
||||||
|
}
|
||||||
|
build_args.push("-j".to_string());
|
||||||
|
build_args.push(num_cpus.to_string());
|
||||||
|
|
||||||
|
let build_result = Command::new("cmake")
|
||||||
|
.current_dir(pjproject_build)
|
||||||
|
.args(&build_args)
|
||||||
|
.output()
|
||||||
|
.expect("Failed to run cmake build");
|
||||||
|
|
||||||
|
if !build_result.status.success() {
|
||||||
|
eprintln!("CMake build stdout: {}", String::from_utf8_lossy(&build_result.stdout));
|
||||||
|
eprintln!("CMake build stderr: {}", String::from_utf8_lossy(&build_result.stderr));
|
||||||
|
panic!("CMake build failed");
|
||||||
|
}
|
||||||
|
println!("cargo:warning=Library builds complete");
|
||||||
|
|
||||||
|
// Run CMake install - may fail for sample apps but that's OK
|
||||||
|
let install_result = Command::new("cmake")
|
||||||
|
.current_dir(pjproject_build)
|
||||||
|
.args(["--install", "."])
|
||||||
|
.output()
|
||||||
|
.expect("Failed to run cmake install");
|
||||||
|
|
||||||
|
if !install_result.status.success() {
|
||||||
|
// Install might fail for sample apps we didn't build, but libraries are installed
|
||||||
|
println!("cargo:warning=CMake install had errors (OK if only sample apps failed)");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("cargo:warning=pjproject build complete!");
|
||||||
|
} else {
|
||||||
|
println!("cargo:warning=Using cached pjproject build");
|
||||||
|
}
|
||||||
|
}
|
||||||
1
pjsua/pjproject
Submodule
1
pjsua/pjproject
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 9caa8d4ef5374650cb4d6e06080e854ea5ea339b
|
||||||
17
pjsua/src/lib.rs
Normal file
17
pjsua/src/lib.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
//! Rust bindings for pjsua (pjproject SIP library)
|
||||||
|
//!
|
||||||
|
//! This crate provides low-level FFI bindings to pjsua, generated via bindgen.
|
||||||
|
//! The pjproject library is built from source automatically if not found on the system.
|
||||||
|
|
||||||
|
#![allow(non_upper_case_globals)]
|
||||||
|
#![allow(non_camel_case_types)]
|
||||||
|
#![allow(non_snake_case)]
|
||||||
|
#![allow(improper_ctypes)]
|
||||||
|
#![allow(clippy::all)]
|
||||||
|
|
||||||
|
mod pjsua {
|
||||||
|
#![allow(unnecessary_transmutes)]
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub use pjsua::*;
|
||||||
113
sipcord-bridge/Cargo.toml
Normal file
113
sipcord-bridge/Cargo.toml
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
[package]
|
||||||
|
name = "sipcord-bridge"
|
||||||
|
version = "1.5.5"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "sipcord-bridge"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "sipcord_bridge"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1.49.0", features = ["full"] }
|
||||||
|
tokio-util = "0.7"
|
||||||
|
|
||||||
|
# Discord
|
||||||
|
serenity = { git = "https://github.com/serenity-rs/serenity", default-features = false, features = [
|
||||||
|
"default_no_backend",
|
||||||
|
"rustls_backend",
|
||||||
|
"voice",
|
||||||
|
"model",
|
||||||
|
"framework",
|
||||||
|
] , branch = "next"}
|
||||||
|
songbird = { version = "0.5.0", git = "https://github.com/jtscuba/songbird", features = ["driver", "gateway", "receive", "tungstenite"] , branch = "davey" }
|
||||||
|
poise = { version = "0.6.1", git = "https://github.com/serenity-rs/poise", branch = "serenity-next"}
|
||||||
|
|
||||||
|
# HTTP client for API calls
|
||||||
|
reqwest = { version = "0.13.1", default-features = false, features = [
|
||||||
|
"json",
|
||||||
|
"multipart",
|
||||||
|
"rustls",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.149"
|
||||||
|
|
||||||
|
# Audio processing
|
||||||
|
audiopus = "0.3.0-rc.0"
|
||||||
|
rubato = "1.0.1"
|
||||||
|
audioadapter = "2.0.0"
|
||||||
|
audioadapter-buffers = "2.0.0"
|
||||||
|
|
||||||
|
# Symphonia with PCM codec for RawAdapter support in Songbird
|
||||||
|
symphonia = { version = "0.5.5", default-features = false, features = [
|
||||||
|
"pcm",
|
||||||
|
"flac",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# FLAC decoder
|
||||||
|
claxon = "0.4.3"
|
||||||
|
|
||||||
|
# SIP - using pjsua bindings (builds pjproject from pjsua/pjproject submodule)
|
||||||
|
pjsua = { path = "../pjsua" }
|
||||||
|
|
||||||
|
# SpanDSP - fax demodulation
|
||||||
|
spandsp = "0.1.5"
|
||||||
|
|
||||||
|
# UDPTL transport for T.38 fax
|
||||||
|
udptl = "0.1.0"
|
||||||
|
|
||||||
|
# Image conversion (fax TIFF -> PNG for Discord)
|
||||||
|
image = { version = "0.25", default-features = false, features = [
|
||||||
|
"png",
|
||||||
|
"jpeg",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# Networking
|
||||||
|
ipnet = "2.11.0"
|
||||||
|
|
||||||
|
# Lock-free ring buffer for real-time audio
|
||||||
|
rtrb = "0.3.2"
|
||||||
|
|
||||||
|
# Async trait for dyn Backend
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
anyhow = "1.0.100"
|
||||||
|
thiserror = "2.0.18"
|
||||||
|
tracing = "0.1.44"
|
||||||
|
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
|
||||||
|
parking_lot = "0.12.5"
|
||||||
|
crossbeam-channel = "0.5.15"
|
||||||
|
bytes = "1.11.1"
|
||||||
|
byteorder = "1.5.0"
|
||||||
|
symphonia-core = "0.5.5"
|
||||||
|
dashmap = "6.1.0"
|
||||||
|
moka = { version = "0.12.13", features = ["sync"] }
|
||||||
|
|
||||||
|
# Crypto (MD5 for SIP digest auth cache verification, rand for nonces)
|
||||||
|
md-5 = "0.10"
|
||||||
|
rand = "0.9"
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
dotenvy = "0.15.7"
|
||||||
|
envy = "0.4"
|
||||||
|
base64 = "0.22.1"
|
||||||
|
toml = "0.9.11"
|
||||||
|
|
||||||
|
# Date/time handling
|
||||||
|
chrono = { version = "0.4.43", features = ["serde"] }
|
||||||
|
|
||||||
|
# TLS - explicit crypto provider for rustls 0.23+
|
||||||
|
rustls = { version = "0.23", default-features = false, features = [
|
||||||
|
"ring",
|
||||||
|
"std",
|
||||||
|
"tls12",
|
||||||
|
] }
|
||||||
|
crossbeam-queue = "0.3.12"
|
||||||
73
sipcord-bridge/src/audio/flac.rs
Normal file
73
sipcord-bridge/src/audio/flac.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
//! FLAC file parsing
|
||||||
|
//!
|
||||||
|
//! Parses FLAC file bytes to extract raw PCM i16 samples.
|
||||||
|
|
||||||
|
use anyhow::{bail, Context};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// Parse a FLAC file and return the raw PCM i16 samples (mono).
|
||||||
|
///
|
||||||
|
/// Handles:
|
||||||
|
/// - Standard FLAC files
|
||||||
|
/// - Stereo to mono conversion (if needed)
|
||||||
|
/// - Various bit depths (converted to 16-bit)
|
||||||
|
pub fn parse_flac(data: &[u8]) -> anyhow::Result<(Vec<i16>, u32)> {
|
||||||
|
let cursor = std::io::Cursor::new(data);
|
||||||
|
let mut reader = claxon::FlacReader::new(cursor).context("Failed to create FLAC reader")?;
|
||||||
|
|
||||||
|
let info = reader.streaminfo();
|
||||||
|
let sample_rate = info.sample_rate;
|
||||||
|
let num_channels = info.channels as usize;
|
||||||
|
let bits_per_sample = info.bits_per_sample;
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"FLAC format: {}Hz, {} channels, {} bits per sample",
|
||||||
|
sample_rate, num_channels, bits_per_sample
|
||||||
|
);
|
||||||
|
|
||||||
|
// Read all samples
|
||||||
|
let mut raw_samples: Vec<i32> = Vec::new();
|
||||||
|
for sample in reader.samples() {
|
||||||
|
raw_samples.push(sample.context("Failed to read FLAC sample")?);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to i16 based on bit depth
|
||||||
|
let samples_i16: Vec<i16> = match bits_per_sample {
|
||||||
|
8 => raw_samples.iter().map(|&s| (s << 8) as i16).collect(),
|
||||||
|
16 => raw_samples.iter().map(|&s| s as i16).collect(),
|
||||||
|
24 => raw_samples.iter().map(|&s| (s >> 8) as i16).collect(),
|
||||||
|
32 => raw_samples.iter().map(|&s| (s >> 16) as i16).collect(),
|
||||||
|
_ => bail!("Unsupported FLAC bit depth: {}", bits_per_sample),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert to mono if stereo (samples are interleaved)
|
||||||
|
let mono_samples = if num_channels == 2 {
|
||||||
|
samples_i16
|
||||||
|
.chunks(2)
|
||||||
|
.map(|chunk| {
|
||||||
|
if chunk.len() == 2 {
|
||||||
|
((chunk[0] as i32 + chunk[1] as i32) / 2) as i16
|
||||||
|
} else {
|
||||||
|
chunk[0]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else if num_channels > 2 {
|
||||||
|
// For more than 2 channels, take first channel only
|
||||||
|
samples_i16
|
||||||
|
.chunks(num_channels)
|
||||||
|
.map(|chunk| chunk[0])
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
samples_i16
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"FLAC data: {} samples ({}Hz, {} channels -> mono)",
|
||||||
|
mono_samples.len(),
|
||||||
|
sample_rate,
|
||||||
|
num_channels
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((mono_samples, sample_rate))
|
||||||
|
}
|
||||||
8
sipcord-bridge/src/audio/mod.rs
Normal file
8
sipcord-bridge/src/audio/mod.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
//! Audio parsing utilities
|
||||||
|
//!
|
||||||
|
//! This module provides audio file parsing for WAV and FLAC formats.
|
||||||
|
//! Used by the `sound` module for loading audio files from disk.
|
||||||
|
|
||||||
|
pub mod flac;
|
||||||
|
pub mod simd;
|
||||||
|
pub mod wav;
|
||||||
260
sipcord-bridge/src/audio/simd.rs
Normal file
260
sipcord-bridge/src/audio/simd.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
//! SIMD-accelerated audio processing utilities
|
||||||
|
//!
|
||||||
|
//! Uses portable_simd for cross-platform support (x86_64 SSE/AVX, aarch64 NEON).
|
||||||
|
//! Falls back to scalar code for unsupported platforms.
|
||||||
|
|
||||||
|
use std::simd::{cmp::SimdOrd, i16x8, i32x8, num::SimdInt};
|
||||||
|
|
||||||
|
/// SIMD-accelerated max absolute value for i16 samples.
|
||||||
|
///
|
||||||
|
/// Processes 8 samples at a time using SIMD, with scalar fallback for remainder.
|
||||||
|
/// This is the hot path for Voice Activity Detection (VAD).
|
||||||
|
///
|
||||||
|
/// # Performance
|
||||||
|
/// - x86_64: Uses SSE2/AVX2 instructions (vpabsw, pmaxsw)
|
||||||
|
/// - aarch64: Uses NEON instructions
|
||||||
|
/// - Expected speedup: 4-8x vs scalar
|
||||||
|
#[inline]
|
||||||
|
pub fn max_abs_i16(samples: &[i16]) -> i16 {
|
||||||
|
if samples.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunks = samples.chunks_exact(8);
|
||||||
|
let remainder = chunks.remainder();
|
||||||
|
|
||||||
|
let mut max_vec = i16x8::splat(0);
|
||||||
|
for chunk in chunks {
|
||||||
|
let v = i16x8::from_slice(chunk);
|
||||||
|
// Handle i16::MIN specially since abs(i16::MIN) overflows
|
||||||
|
// For audio samples this is rare, but we handle it correctly
|
||||||
|
let abs_v = v.abs();
|
||||||
|
max_vec = max_vec.simd_max(abs_v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal max reduction
|
||||||
|
let mut result = max_vec.reduce_max();
|
||||||
|
|
||||||
|
// Process remainder with scalar code
|
||||||
|
for &s in remainder {
|
||||||
|
result = result.max(s.saturating_abs());
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SIMD-accelerated widen i16 to i32 (first speaker — overwrites dst).
|
||||||
|
///
|
||||||
|
/// Processes 8 samples at a time. Used for the first speaker in mixing.
|
||||||
|
#[inline]
|
||||||
|
pub fn widen_i16_to_i32(src: &[i16], dst: &mut [i32]) {
|
||||||
|
let len = src.len().min(dst.len());
|
||||||
|
let chunks_src = src[..len].chunks_exact(8);
|
||||||
|
let chunks_dst = dst[..len].chunks_exact_mut(8);
|
||||||
|
let remainder_start = chunks_src.remainder().len();
|
||||||
|
|
||||||
|
for (src_chunk, dst_chunk) in chunks_src.zip(chunks_dst) {
|
||||||
|
let v = i16x8::from_slice(src_chunk);
|
||||||
|
// Widen i16 -> i32 by casting each lane
|
||||||
|
let wide: [i32; 8] = [
|
||||||
|
v[0] as i32,
|
||||||
|
v[1] as i32,
|
||||||
|
v[2] as i32,
|
||||||
|
v[3] as i32,
|
||||||
|
v[4] as i32,
|
||||||
|
v[5] as i32,
|
||||||
|
v[6] as i32,
|
||||||
|
v[7] as i32,
|
||||||
|
];
|
||||||
|
dst_chunk.copy_from_slice(&wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scalar remainder
|
||||||
|
let start = len - remainder_start;
|
||||||
|
for i in start..len {
|
||||||
|
dst[i] = src[i] as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SIMD-accelerated accumulate i16 into i32 (mix additional speakers — adds to dst).
|
||||||
|
///
|
||||||
|
/// Processes 8 samples at a time. Used for mixing additional speakers.
|
||||||
|
#[inline]
|
||||||
|
pub fn accumulate_i16_to_i32(src: &[i16], dst: &mut [i32]) {
|
||||||
|
let len = src.len().min(dst.len());
|
||||||
|
let chunks_src = src[..len].chunks_exact(8);
|
||||||
|
let chunks_dst = dst[..len].chunks_exact_mut(8);
|
||||||
|
let remainder_start = chunks_src.remainder().len();
|
||||||
|
|
||||||
|
for (src_chunk, dst_chunk) in chunks_src.zip(chunks_dst) {
|
||||||
|
let v = i16x8::from_slice(src_chunk);
|
||||||
|
let dst_v = i32x8::from_slice(dst_chunk);
|
||||||
|
let wide = i32x8::from_array([
|
||||||
|
v[0] as i32,
|
||||||
|
v[1] as i32,
|
||||||
|
v[2] as i32,
|
||||||
|
v[3] as i32,
|
||||||
|
v[4] as i32,
|
||||||
|
v[5] as i32,
|
||||||
|
v[6] as i32,
|
||||||
|
v[7] as i32,
|
||||||
|
]);
|
||||||
|
let sum = dst_v + wide;
|
||||||
|
dst_chunk.copy_from_slice(sum.as_array());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scalar remainder
|
||||||
|
let start = len - remainder_start;
|
||||||
|
for i in start..len {
|
||||||
|
dst[i] += src[i] as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SIMD-accelerated clamp i32 to i16 with saturation.
|
||||||
|
///
|
||||||
|
/// Processes 8 samples at a time. Clamps values to i16 range [-32768, 32767].
|
||||||
|
#[inline]
|
||||||
|
pub fn clamp_i32_to_i16(src: &[i32], dst: &mut [i16]) {
|
||||||
|
let len = src.len().min(dst.len());
|
||||||
|
let chunks_src = src[..len].chunks_exact(8);
|
||||||
|
let chunks_dst = dst[..len].chunks_exact_mut(8);
|
||||||
|
let remainder_start = chunks_src.remainder().len();
|
||||||
|
|
||||||
|
let min_val = i32x8::splat(-32768);
|
||||||
|
let max_val = i32x8::splat(32767);
|
||||||
|
|
||||||
|
for (src_chunk, dst_chunk) in chunks_src.zip(chunks_dst) {
|
||||||
|
let v = i32x8::from_slice(src_chunk);
|
||||||
|
let clamped = v.simd_max(min_val).simd_min(max_val);
|
||||||
|
let narrow: [i16; 8] = [
|
||||||
|
clamped[0] as i16,
|
||||||
|
clamped[1] as i16,
|
||||||
|
clamped[2] as i16,
|
||||||
|
clamped[3] as i16,
|
||||||
|
clamped[4] as i16,
|
||||||
|
clamped[5] as i16,
|
||||||
|
clamped[6] as i16,
|
||||||
|
clamped[7] as i16,
|
||||||
|
];
|
||||||
|
dst_chunk.copy_from_slice(&narrow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scalar remainder
|
||||||
|
let start = len - remainder_start;
|
||||||
|
for i in start..len {
|
||||||
|
dst[i] = src[i].clamp(-32768, 32767) as i16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SIMD-accelerated stereo to mono conversion.
|
||||||
|
///
|
||||||
|
/// Averages adjacent sample pairs (L, R) -> (L+R)/2.
|
||||||
|
/// `stereo` length must be even. `mono` must be at least `stereo.len() / 2`.
|
||||||
|
#[inline]
|
||||||
|
pub fn stereo_to_mono_i16(stereo: &[i16], mono: &mut [i16]) {
|
||||||
|
let mono_len = (stereo.len() / 2).min(mono.len());
|
||||||
|
|
||||||
|
// Process 8 mono samples at a time (16 stereo samples)
|
||||||
|
let mut i = 0;
|
||||||
|
while i + 8 <= mono_len {
|
||||||
|
let si = i * 2;
|
||||||
|
// Load 16 stereo samples as two i16x8 vectors
|
||||||
|
let v0 = i16x8::from_slice(&stereo[si..si + 8]);
|
||||||
|
let v1 = i16x8::from_slice(&stereo[si + 8..si + 16]);
|
||||||
|
|
||||||
|
// Deinterleave: extract even (left) and odd (right) samples
|
||||||
|
let left = i16x8::from_array([v0[0], v0[2], v0[4], v0[6], v1[0], v1[2], v1[4], v1[6]]);
|
||||||
|
let right = i16x8::from_array([v0[1], v0[3], v0[5], v0[7], v1[1], v1[3], v1[5], v1[7]]);
|
||||||
|
|
||||||
|
// Average: (l + r) / 2 — use arithmetic shift to avoid overflow
|
||||||
|
// (l >> 1) + (r >> 1) + ((l & r) & 1) for exact rounding
|
||||||
|
let avg = (left >> i16x8::splat(1)) + (right >> i16x8::splat(1));
|
||||||
|
mono[i..i + 8].copy_from_slice(avg.as_array());
|
||||||
|
i += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scalar remainder
|
||||||
|
while i < mono_len {
|
||||||
|
let l = stereo[i * 2] as i32;
|
||||||
|
let r = stereo[i * 2 + 1] as i32;
|
||||||
|
mono[i] = ((l + r) / 2) as i16;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_max_abs_i16_basic() {
|
||||||
|
let samples = [1, -5, 3, -2, 4, -10, 7, -8, 100];
|
||||||
|
assert_eq!(max_abs_i16(&samples), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_max_abs_i16_negative_max() {
|
||||||
|
let samples = [1, -500, 3, -2];
|
||||||
|
assert_eq!(max_abs_i16(&samples), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_max_abs_i16_empty() {
|
||||||
|
let samples: [i16; 0] = [];
|
||||||
|
assert_eq!(max_abs_i16(&samples), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_max_abs_i16_aligned() {
|
||||||
|
// Exactly 8 samples (one SIMD vector)
|
||||||
|
let samples = [100, -200, 300, -400, 500, -600, 700, -800];
|
||||||
|
assert_eq!(max_abs_i16(&samples), 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_widen_i16_to_i32() {
|
||||||
|
let src: Vec<i16> = (0..20).map(|i| (i * 100 - 1000) as i16).collect();
|
||||||
|
let mut dst = vec![0i32; 20];
|
||||||
|
widen_i16_to_i32(&src, &mut dst);
|
||||||
|
for i in 0..20 {
|
||||||
|
assert_eq!(dst[i], src[i] as i32, "mismatch at index {}", i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_accumulate_i16_to_i32() {
|
||||||
|
let src = [100i16, -200, 300, -400, 500, -600, 700, -800, 900];
|
||||||
|
let mut dst = [1i32, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||||
|
accumulate_i16_to_i32(&src, &mut dst);
|
||||||
|
assert_eq!(dst, [101, -198, 303, -396, 505, -594, 707, -792, 909]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clamp_i32_to_i16() {
|
||||||
|
let src = [0i32, 32767, -32768, 40000, -40000, 100, -100, 0, 12345];
|
||||||
|
let mut dst = [0i16; 9];
|
||||||
|
clamp_i32_to_i16(&src, &mut dst);
|
||||||
|
assert_eq!(dst, [0, 32767, -32768, 32767, -32768, 100, -100, 0, 12345]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stereo_to_mono() {
|
||||||
|
// 20 stereo samples -> 10 mono
|
||||||
|
let stereo: Vec<i16> = (0..20).map(|i| (i * 100) as i16).collect();
|
||||||
|
let mut mono = vec![0i16; 10];
|
||||||
|
stereo_to_mono_i16(&stereo, &mut mono);
|
||||||
|
for i in 0..10 {
|
||||||
|
let l = stereo[i * 2] as i32;
|
||||||
|
let r = stereo[i * 2 + 1] as i32;
|
||||||
|
let expected = ((l + r) / 2) as i16;
|
||||||
|
// Allow +-1 for rounding differences between SIMD and scalar
|
||||||
|
assert!(
|
||||||
|
(mono[i] as i32 - expected as i32).abs() <= 1,
|
||||||
|
"mismatch at {}: got {} expected {}",
|
||||||
|
i,
|
||||||
|
mono[i],
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
206
sipcord-bridge/src/audio/wav.rs
Normal file
206
sipcord-bridge/src/audio/wav.rs
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
//! WAV file parsing
|
||||||
|
//!
|
||||||
|
//! Parses WAV file bytes to extract raw PCM i16 samples.
|
||||||
|
//! Supports standard PCM WAV files (format code 1).
|
||||||
|
|
||||||
|
use anyhow::ensure;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// WAV format chunk data
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct WavFormat {
|
||||||
|
/// Audio format (1 = PCM)
|
||||||
|
audio_format: u16,
|
||||||
|
/// Number of channels
|
||||||
|
num_channels: u16,
|
||||||
|
/// Sample rate in Hz
|
||||||
|
sample_rate: u32,
|
||||||
|
/// Bits per sample (typically 16)
|
||||||
|
bits_per_sample: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a WAV file and return the raw PCM i16 samples (mono).
|
||||||
|
///
|
||||||
|
/// Handles:
|
||||||
|
/// - Standard PCM WAV files (format code 1)
|
||||||
|
/// - Stereo to mono conversion (if needed)
|
||||||
|
/// - 16-bit samples
|
||||||
|
pub fn parse_wav(data: &[u8]) -> anyhow::Result<(Vec<i16>, u32)> {
|
||||||
|
// Validate RIFF header
|
||||||
|
ensure!(data.len() >= 12, "WAV file too short for header");
|
||||||
|
ensure!(&data[0..4] == b"RIFF", "Missing RIFF header");
|
||||||
|
ensure!(&data[8..12] == b"WAVE", "Missing WAVE format");
|
||||||
|
|
||||||
|
let mut pos = 12;
|
||||||
|
let mut format: Option<WavFormat> = None;
|
||||||
|
let mut samples: Vec<i16> = Vec::new();
|
||||||
|
|
||||||
|
// Parse chunks
|
||||||
|
while pos + 8 <= data.len() {
|
||||||
|
let chunk_id = &data[pos..pos + 4];
|
||||||
|
let chunk_size =
|
||||||
|
u32::from_le_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
|
||||||
|
as usize;
|
||||||
|
pos += 8;
|
||||||
|
|
||||||
|
match chunk_id {
|
||||||
|
b"fmt " => {
|
||||||
|
ensure!(chunk_size >= 16, "fmt chunk too small");
|
||||||
|
format = Some(WavFormat {
|
||||||
|
audio_format: u16::from_le_bytes([data[pos], data[pos + 1]]),
|
||||||
|
num_channels: u16::from_le_bytes([data[pos + 2], data[pos + 3]]),
|
||||||
|
sample_rate: u32::from_le_bytes([
|
||||||
|
data[pos + 4],
|
||||||
|
data[pos + 5],
|
||||||
|
data[pos + 6],
|
||||||
|
data[pos + 7],
|
||||||
|
]),
|
||||||
|
// Skip byte rate (4 bytes) and block align (2 bytes)
|
||||||
|
bits_per_sample: u16::from_le_bytes([data[pos + 14], data[pos + 15]]),
|
||||||
|
});
|
||||||
|
debug!("WAV format: {:?}", format);
|
||||||
|
}
|
||||||
|
b"data" => {
|
||||||
|
let fmt = format.as_ref().ok_or_else(|| anyhow::anyhow!("data chunk before fmt chunk"))?;
|
||||||
|
ensure!(fmt.audio_format == 1, "Only PCM format supported");
|
||||||
|
ensure!(fmt.bits_per_sample == 16, "Only 16-bit samples supported");
|
||||||
|
|
||||||
|
let data_end = (pos + chunk_size).min(data.len());
|
||||||
|
let sample_data = &data[pos..data_end];
|
||||||
|
|
||||||
|
// Parse i16 samples
|
||||||
|
let raw_samples: Vec<i16> = sample_data
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Convert to mono if stereo
|
||||||
|
samples = if fmt.num_channels == 2 {
|
||||||
|
raw_samples
|
||||||
|
.chunks(2)
|
||||||
|
.map(|chunk| {
|
||||||
|
if chunk.len() == 2 {
|
||||||
|
((chunk[0] as i32 + chunk[1] as i32) / 2) as i16
|
||||||
|
} else {
|
||||||
|
chunk[0]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
raw_samples
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"WAV data: {} samples ({}Hz, {} channels -> mono)",
|
||||||
|
samples.len(),
|
||||||
|
fmt.sample_rate,
|
||||||
|
fmt.num_channels
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Skip unknown chunks
|
||||||
|
debug!("Skipping WAV chunk: {:?}", std::str::from_utf8(chunk_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next chunk (chunks are word-aligned)
|
||||||
|
pos += chunk_size;
|
||||||
|
if !chunk_size.is_multiple_of(2) {
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sample_rate = format
|
||||||
|
.as_ref()
|
||||||
|
.map(|f| f.sample_rate)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No fmt chunk found"))?;
|
||||||
|
|
||||||
|
Ok((samples, sample_rate))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_simple_wav() {
|
||||||
|
// Minimal valid WAV with 4 samples of silence
|
||||||
|
let wav = [
|
||||||
|
// RIFF header
|
||||||
|
b'R', b'I', b'F', b'F', // "RIFF"
|
||||||
|
0x2C, 0x00, 0x00, 0x00, // File size - 8 = 44
|
||||||
|
b'W', b'A', b'V', b'E', // "WAVE"
|
||||||
|
// fmt chunk
|
||||||
|
b'f', b'm', b't', b' ', // "fmt "
|
||||||
|
0x10, 0x00, 0x00, 0x00, // Chunk size = 16
|
||||||
|
0x01, 0x00, // Audio format = 1 (PCM)
|
||||||
|
0x01, 0x00, // Num channels = 1 (mono)
|
||||||
|
0x80, 0x3E, 0x00, 0x00, // Sample rate = 16000
|
||||||
|
0x00, 0x7D, 0x00, 0x00, // Byte rate = 32000
|
||||||
|
0x02, 0x00, // Block align = 2
|
||||||
|
0x10, 0x00, // Bits per sample = 16
|
||||||
|
// data chunk
|
||||||
|
b'd', b'a', b't', b'a', // "data"
|
||||||
|
0x08, 0x00, 0x00, 0x00, // Chunk size = 8 bytes = 4 samples
|
||||||
|
0x00, 0x00, // Sample 0 = 0
|
||||||
|
0x00, 0x10, // Sample 1 = 4096
|
||||||
|
0x00, 0x20, // Sample 2 = 8192
|
||||||
|
0x00, 0x30, // Sample 3 = 12288
|
||||||
|
];
|
||||||
|
|
||||||
|
let (samples, rate) = parse_wav(&wav).unwrap();
|
||||||
|
assert_eq!(rate, 16000);
|
||||||
|
assert_eq!(samples.len(), 4);
|
||||||
|
assert_eq!(samples[0], 0);
|
||||||
|
assert_eq!(samples[1], 4096);
|
||||||
|
assert_eq!(samples[2], 8192);
|
||||||
|
assert_eq!(samples[3], 12288);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_wav_too_short() {
|
||||||
|
let result = parse_wav(&[0u8; 4]);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("too short"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_wav_wrong_magic() {
|
||||||
|
let mut data = [0u8; 44];
|
||||||
|
data[0..4].copy_from_slice(b"NOPE");
|
||||||
|
let result = parse_wav(&data);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("RIFF"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_stereo_wav() {
|
||||||
|
// Stereo WAV: 2 stereo sample frames = 4 raw samples -> 2 mono samples
|
||||||
|
let wav = [
|
||||||
|
// RIFF header
|
||||||
|
b'R', b'I', b'F', b'F', 0x2C, 0x00, 0x00, 0x00, // File size - 8
|
||||||
|
b'W', b'A', b'V', b'E', // fmt chunk
|
||||||
|
b'f', b'm', b't', b' ', 0x10, 0x00, 0x00, 0x00, // Chunk size = 16
|
||||||
|
0x01, 0x00, // PCM
|
||||||
|
0x02, 0x00, // 2 channels (stereo)
|
||||||
|
0x80, 0x3E, 0x00, 0x00, // 16000 Hz
|
||||||
|
0x00, 0xFA, 0x00, 0x00, // Byte rate = 64000
|
||||||
|
0x04, 0x00, // Block align = 4
|
||||||
|
0x10, 0x00, // 16 bits
|
||||||
|
// data chunk
|
||||||
|
b'd', b'a', b't', b'a', 0x08, 0x00, 0x00, 0x00, // 8 bytes = 2 stereo frames
|
||||||
|
// Frame 1: L=1000, R=3000 -> mono = 2000
|
||||||
|
0xE8, 0x03, // 1000 LE
|
||||||
|
0xB8, 0x0B, // 3000 LE
|
||||||
|
// Frame 2: L=-100, R=100 -> mono = 0
|
||||||
|
0x9C, 0xFF, // -100 LE
|
||||||
|
0x64, 0x00, // 100 LE
|
||||||
|
];
|
||||||
|
|
||||||
|
let (samples, rate) = parse_wav(&wav).unwrap();
|
||||||
|
assert_eq!(rate, 16000);
|
||||||
|
assert_eq!(samples.len(), 2);
|
||||||
|
assert_eq!(samples[0], 2000);
|
||||||
|
assert_eq!(samples[1], 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
2078
sipcord-bridge/src/call/mod.rs
Normal file
2078
sipcord-bridge/src/call/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
535
sipcord-bridge/src/config.rs
Normal file
535
sipcord-bridge/src/config.rs
Normal file
|
|
@ -0,0 +1,535 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// Global application config (loaded from config.toml)
|
||||||
|
pub static APP_CONFIG: OnceLock<AppConfig> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Global environment config (parsed once at startup via `envy`)
|
||||||
|
static ENV_CONFIG: OnceLock<EnvConfig> = OnceLock::new();
|
||||||
|
|
||||||
|
fn default_data_dir() -> String {
|
||||||
|
"/var/lib/sipcord".to_string()
|
||||||
|
}
|
||||||
|
fn default_config_path() -> String {
|
||||||
|
"./config.toml".to_string()
|
||||||
|
}
|
||||||
|
fn default_bridge_id() -> String {
|
||||||
|
"br_unknown".to_string()
|
||||||
|
}
|
||||||
|
fn default_sounds_dir() -> String {
|
||||||
|
"./wav".to_string()
|
||||||
|
}
|
||||||
|
fn default_sip_port() -> u16 {
|
||||||
|
5060
|
||||||
|
}
|
||||||
|
fn default_rtp_port_start() -> u16 {
|
||||||
|
10000
|
||||||
|
}
|
||||||
|
fn default_rtp_port_end() -> u16 {
|
||||||
|
15000
|
||||||
|
}
|
||||||
|
fn default_tls_port() -> u16 {
|
||||||
|
5061
|
||||||
|
}
|
||||||
|
fn default_tls_refresh() -> u64 {
|
||||||
|
3600
|
||||||
|
}
|
||||||
|
fn default_dialplan_path() -> String {
|
||||||
|
"./dialplan.toml".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All environment variables consumed by the bridge, deserialized once at startup.
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
pub struct EnvConfig {
|
||||||
|
// Paths & Identity
|
||||||
|
#[serde(default = "default_data_dir")]
|
||||||
|
pub data_dir: String,
|
||||||
|
#[serde(default = "default_config_path")]
|
||||||
|
pub config_path: String,
|
||||||
|
#[serde(default = "default_bridge_id")]
|
||||||
|
pub bridge_id: String,
|
||||||
|
#[serde(default = "default_sounds_dir")]
|
||||||
|
pub sounds_dir: String,
|
||||||
|
|
||||||
|
// Mode
|
||||||
|
#[serde(default)]
|
||||||
|
pub dev_mode: bool,
|
||||||
|
|
||||||
|
// SIP
|
||||||
|
pub sip_public_host: Option<String>,
|
||||||
|
#[serde(default = "default_sip_port")]
|
||||||
|
pub sip_port: u16,
|
||||||
|
#[serde(default = "default_rtp_port_start")]
|
||||||
|
pub rtp_port_start: u16,
|
||||||
|
#[serde(default = "default_rtp_port_end")]
|
||||||
|
pub rtp_port_end: u16,
|
||||||
|
pub rtp_public_ip: Option<String>,
|
||||||
|
pub sip_local_host: Option<String>,
|
||||||
|
pub sip_local_cidr: Option<String>,
|
||||||
|
|
||||||
|
// TLS
|
||||||
|
pub tls_cert_dir: Option<String>,
|
||||||
|
#[serde(default = "default_tls_port")]
|
||||||
|
pub tls_port: u16,
|
||||||
|
#[serde(default = "default_tls_refresh")]
|
||||||
|
pub tls_refresh_interval: u64,
|
||||||
|
|
||||||
|
// Static router
|
||||||
|
pub discord_bot_token: Option<String>,
|
||||||
|
#[serde(default = "default_dialplan_path")]
|
||||||
|
pub dialplan_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnvConfig {
|
||||||
|
/// Parse environment variables (via `envy`) and store in the global `OnceLock`.
|
||||||
|
/// Call once at the top of `main()`.
|
||||||
|
pub fn init() -> Result<()> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let cfg: EnvConfig =
|
||||||
|
envy::from_env().context("Failed to parse environment variables into EnvConfig")?;
|
||||||
|
ENV_CONFIG
|
||||||
|
.set(cfg)
|
||||||
|
.ok()
|
||||||
|
.context("EnvConfig already initialized")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access the global `EnvConfig` (panics if `init()` was not called).
|
||||||
|
pub fn global() -> &'static EnvConfig {
|
||||||
|
ENV_CONFIG
|
||||||
|
.get()
|
||||||
|
.expect("EnvConfig not initialized — call EnvConfig::init() first")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `SipConfig` from the parsed environment.
|
||||||
|
pub fn to_sip_config(&self) -> Result<SipConfig> {
|
||||||
|
let public_host = self
|
||||||
|
.sip_public_host
|
||||||
|
.clone()
|
||||||
|
.context("SIP_PUBLIC_HOST required")?;
|
||||||
|
|
||||||
|
let local_net = match (&self.sip_local_host, &self.sip_local_cidr) {
|
||||||
|
(Some(host), Some(cidr)) => Some(LocalNetConfig {
|
||||||
|
host: host.clone(),
|
||||||
|
cidr: cidr.clone(),
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(SipConfig {
|
||||||
|
public_host,
|
||||||
|
port: self.sip_port,
|
||||||
|
rtp_port_start: self.rtp_port_start,
|
||||||
|
rtp_port_end: self.rtp_port_end,
|
||||||
|
rtp_public_ip: self.rtp_public_ip.clone(),
|
||||||
|
local_net,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `TlsConfig` from the parsed environment.
|
||||||
|
pub fn to_tls_config(&self) -> TlsConfig {
|
||||||
|
let cert_dir = self
|
||||||
|
.tls_cert_dir
|
||||||
|
.as_ref()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from(&self.data_dir).join("certs"));
|
||||||
|
|
||||||
|
TlsConfig {
|
||||||
|
cert_dir,
|
||||||
|
port: self.tls_port,
|
||||||
|
refresh_interval_secs: self.tls_refresh_interval,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the SIP public host, falling back to `"0.0.0.0"` when unset.
|
||||||
|
pub fn sip_public_host_or_default(&self) -> &str {
|
||||||
|
self.sip_public_host.as_deref().unwrap_or("0.0.0.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the resolved DATA_DIR path, applying the smart fallback:
|
||||||
|
/// if the default `/var/lib/sipcord` doesn't exist on disk, fall back to `.`.
|
||||||
|
pub fn resolved_data_dir(&self) -> String {
|
||||||
|
if self.data_dir == "/var/lib/sipcord" && !Path::new(&self.data_dir).exists() {
|
||||||
|
".".to_string()
|
||||||
|
} else {
|
||||||
|
self.data_dir.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application-level configuration from config.toml
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub sounds: SoundsConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bridge: BridgeConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub audio: AudioConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub fax: FaxConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge operational settings
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct BridgeConfig {
|
||||||
|
/// Seconds without RTP before a call is considered dead
|
||||||
|
pub rtp_inactivity_timeout_secs: u64,
|
||||||
|
/// Seconds to wait for the first RTP packet before declaring no audio
|
||||||
|
/// (faster than rtp_inactivity_timeout for calls that never receive any audio)
|
||||||
|
pub no_audio_timeout_secs: u64,
|
||||||
|
/// Seconds before destroying a bridge with no SIP calls
|
||||||
|
pub empty_bridge_grace_period_secs: u64,
|
||||||
|
/// Maximum samples buffered per channel (Discord->SIP direction)
|
||||||
|
pub max_channel_buffer_samples: usize,
|
||||||
|
/// API request timeout in seconds
|
||||||
|
pub api_timeout_secs: u64,
|
||||||
|
/// Health check interval in seconds
|
||||||
|
pub health_check_interval_secs: u64,
|
||||||
|
/// Maximum voice join retry attempts
|
||||||
|
pub voice_join_max_retries: u32,
|
||||||
|
/// Delay between voice join retries in seconds
|
||||||
|
pub voice_join_retry_delay_secs: u64,
|
||||||
|
/// PJSIP internal log level (0-6, filtered via tracing)
|
||||||
|
pub pjsip_log_level: u32,
|
||||||
|
/// Maximum reconnection attempts before tearing down the bridge
|
||||||
|
pub reconnect_max_attempts: u32,
|
||||||
|
/// Base delay (seconds) for exponential backoff between reconnections
|
||||||
|
pub reconnect_base_delay_secs: u64,
|
||||||
|
/// Maximum backoff delay cap (seconds)
|
||||||
|
pub reconnect_max_delay_secs: u64,
|
||||||
|
/// Minimum bridge age (seconds) before it can be reconnected (cooldown)
|
||||||
|
pub reconnect_min_age_secs: u64,
|
||||||
|
/// Maximum reconnections allowed per health check cycle
|
||||||
|
pub reconnect_max_per_cycle: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BridgeConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
rtp_inactivity_timeout_secs: 60,
|
||||||
|
no_audio_timeout_secs: 10,
|
||||||
|
empty_bridge_grace_period_secs: 30,
|
||||||
|
max_channel_buffer_samples: 32000,
|
||||||
|
api_timeout_secs: 10,
|
||||||
|
health_check_interval_secs: 5,
|
||||||
|
voice_join_max_retries: 2,
|
||||||
|
voice_join_retry_delay_secs: 5,
|
||||||
|
pjsip_log_level: 4,
|
||||||
|
reconnect_max_attempts: 5,
|
||||||
|
reconnect_base_delay_secs: 5,
|
||||||
|
reconnect_max_delay_secs: 300,
|
||||||
|
reconnect_min_age_secs: 30,
|
||||||
|
reconnect_max_per_cycle: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audio pipeline settings
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct AudioConfig {
|
||||||
|
/// Ring buffer size in samples for Discord audio streaming
|
||||||
|
pub ring_buffer_samples: usize,
|
||||||
|
/// Pre-buffer samples before starting Discord audio playback
|
||||||
|
pub pre_buffer_samples: usize,
|
||||||
|
/// Amplitude threshold above which audio is considered speech
|
||||||
|
pub vad_silence_threshold: i16,
|
||||||
|
/// Amplitude threshold below which audio is considered muted
|
||||||
|
pub vad_mute_threshold: i16,
|
||||||
|
/// Consecutive silence frames before stopping speaking state
|
||||||
|
pub vad_silence_frames_before_stop: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AudioConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
ring_buffer_samples: 96000,
|
||||||
|
pre_buffer_samples: 14400,
|
||||||
|
vad_silence_threshold: 200,
|
||||||
|
vad_mute_threshold: 50,
|
||||||
|
vad_silence_frames_before_stop: 15,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fax reception settings
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct FaxConfig {
|
||||||
|
/// Directory for temporary fax files. Defaults to system temp dir.
|
||||||
|
pub tmp_folder: Option<PathBuf>,
|
||||||
|
/// Filename prefix for fax TIFF/output files (e.g. "fax_")
|
||||||
|
pub prefix: String,
|
||||||
|
/// Output image format: "png" or "jpg"
|
||||||
|
pub output_format: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FaxConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
tmp_folder: None,
|
||||||
|
prefix: "fax_".to_string(),
|
||||||
|
output_format: "png".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sound configuration section
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
pub struct SoundsConfig {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub entries: HashMap<String, SoundEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Individual sound entry configuration
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
pub struct SoundEntry {
|
||||||
|
/// Source file path (relative to sounds directory). None for generated tones.
|
||||||
|
pub src: Option<String>,
|
||||||
|
/// Whether to preload into memory (true) or stream from disk (false)
|
||||||
|
#[serde(default)]
|
||||||
|
pub preload: bool,
|
||||||
|
/// Optional extension that triggers this sound (for easter eggs)
|
||||||
|
#[serde(default)]
|
||||||
|
pub extension: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
/// Load configuration from a TOML file
|
||||||
|
pub fn load(path: &Path) -> Result<Self> {
|
||||||
|
let contents = std::fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
|
||||||
|
toml::from_str(&contents)
|
||||||
|
.with_context(|| format!("Failed to parse config file: {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the global application config (panics if not initialized)
|
||||||
|
pub fn global() -> &'static AppConfig {
|
||||||
|
APP_CONFIG
|
||||||
|
.get()
|
||||||
|
.expect("AppConfig not initialized - call AppConfig::load() first")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get bridge config (with defaults if not loaded yet)
|
||||||
|
pub fn bridge() -> &'static BridgeConfig {
|
||||||
|
APP_CONFIG.get().map(|c| &c.bridge).unwrap_or_else(|| {
|
||||||
|
static DEFAULT: OnceLock<BridgeConfig> = OnceLock::new();
|
||||||
|
DEFAULT.get_or_init(BridgeConfig::default)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get audio config (with defaults if not loaded yet)
|
||||||
|
pub fn audio() -> &'static AudioConfig {
|
||||||
|
APP_CONFIG.get().map(|c| &c.audio).unwrap_or_else(|| {
|
||||||
|
static DEFAULT: OnceLock<AudioConfig> = OnceLock::new();
|
||||||
|
DEFAULT.get_or_init(AudioConfig::default)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get fax config (with defaults if not loaded yet)
|
||||||
|
pub fn fax() -> &'static FaxConfig {
|
||||||
|
APP_CONFIG.get().map(|c| &c.fax).unwrap_or_else(|| {
|
||||||
|
static DEFAULT: OnceLock<FaxConfig> = OnceLock::new();
|
||||||
|
DEFAULT.get_or_init(FaxConfig::default)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TlsConfig {
|
||||||
|
pub cert_dir: PathBuf,
|
||||||
|
pub port: u16,
|
||||||
|
pub refresh_interval_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SipConfig {
|
||||||
|
pub public_host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub rtp_port_start: u16,
|
||||||
|
pub rtp_port_end: u16,
|
||||||
|
/// Public IP address to advertise in SDP for RTP media (c= line)
|
||||||
|
/// If not set, pjsua will use the local interface IP which won't work for NAT
|
||||||
|
pub rtp_public_ip: Option<String>,
|
||||||
|
/// Local network support: rewrite Contact headers for clients in local_network to use local_host
|
||||||
|
/// This allows the bridge to serve both public and local clients simultaneously
|
||||||
|
pub local_net: Option<LocalNetConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LocalNetConfig {
|
||||||
|
/// Local host IP to use in Contact headers for local clients (e.g., 192.168.10.1)
|
||||||
|
pub host: String,
|
||||||
|
/// Local network CIDR - clients in this range get local_host in Contact (e.g., 192.168.10.0/24)
|
||||||
|
pub cidr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SipConfig {
|
||||||
|
/// Load SIP configuration from environment variables.
|
||||||
|
/// Standalone method for backends that don't need the full Config.
|
||||||
|
pub fn from_env() -> Result<Self> {
|
||||||
|
EnvConfig::global().to_sip_config()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TlsConfig {
|
||||||
|
pub fn cert_path(&self) -> PathBuf {
|
||||||
|
self.cert_dir.join("bridge.crt")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn key_path(&self) -> PathBuf {
|
||||||
|
self.cert_dir.join("bridge.key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bridge_config_default() {
|
||||||
|
let c = BridgeConfig::default();
|
||||||
|
assert_eq!(c.rtp_inactivity_timeout_secs, 60);
|
||||||
|
assert_eq!(c.no_audio_timeout_secs, 10);
|
||||||
|
assert_eq!(c.empty_bridge_grace_period_secs, 30);
|
||||||
|
assert_eq!(c.max_channel_buffer_samples, 32000);
|
||||||
|
assert_eq!(c.api_timeout_secs, 10);
|
||||||
|
assert_eq!(c.pjsip_log_level, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_audio_config_default() {
|
||||||
|
let c = AudioConfig::default();
|
||||||
|
assert_eq!(c.ring_buffer_samples, 96000);
|
||||||
|
assert_eq!(c.pre_buffer_samples, 14400);
|
||||||
|
assert_eq!(c.vad_silence_threshold, 200);
|
||||||
|
assert_eq!(c.vad_mute_threshold, 50);
|
||||||
|
assert_eq!(c.vad_silence_frames_before_stop, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fax_config_default() {
|
||||||
|
let c = FaxConfig::default();
|
||||||
|
assert!(c.tmp_folder.is_none());
|
||||||
|
assert_eq!(c.prefix, "fax_");
|
||||||
|
assert_eq!(c.output_format, "png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolved_data_dir_default_missing() {
|
||||||
|
let env = EnvConfig {
|
||||||
|
data_dir: "/var/lib/sipcord".to_string(),
|
||||||
|
config_path: "./config.toml".to_string(),
|
||||||
|
bridge_id: "br_test".to_string(),
|
||||||
|
sounds_dir: "./wav".to_string(),
|
||||||
|
dev_mode: false,
|
||||||
|
sip_public_host: None,
|
||||||
|
sip_port: 5060,
|
||||||
|
rtp_port_start: 10000,
|
||||||
|
rtp_port_end: 15000,
|
||||||
|
rtp_public_ip: None,
|
||||||
|
sip_local_host: None,
|
||||||
|
sip_local_cidr: None,
|
||||||
|
tls_cert_dir: None,
|
||||||
|
tls_port: 5061,
|
||||||
|
tls_refresh_interval: 3600,
|
||||||
|
discord_bot_token: None,
|
||||||
|
dialplan_path: "./dialplan.toml".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(env.resolved_data_dir(), ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolved_data_dir_custom() {
|
||||||
|
let env = EnvConfig {
|
||||||
|
data_dir: "/tmp".to_string(),
|
||||||
|
config_path: "./config.toml".to_string(),
|
||||||
|
bridge_id: "br_test".to_string(),
|
||||||
|
sounds_dir: "./wav".to_string(),
|
||||||
|
dev_mode: false,
|
||||||
|
sip_public_host: None,
|
||||||
|
sip_port: 5060,
|
||||||
|
rtp_port_start: 10000,
|
||||||
|
rtp_port_end: 15000,
|
||||||
|
rtp_public_ip: None,
|
||||||
|
sip_local_host: None,
|
||||||
|
sip_local_cidr: None,
|
||||||
|
tls_cert_dir: None,
|
||||||
|
tls_port: 5061,
|
||||||
|
tls_refresh_interval: 3600,
|
||||||
|
discord_bot_token: None,
|
||||||
|
dialplan_path: "./dialplan.toml".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(env.resolved_data_dir(), "/tmp");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_to_tls_config_cert_dir_fallback() {
|
||||||
|
let env = EnvConfig {
|
||||||
|
data_dir: "/data".to_string(),
|
||||||
|
config_path: "./config.toml".to_string(),
|
||||||
|
bridge_id: "br_test".to_string(),
|
||||||
|
sounds_dir: "./wav".to_string(),
|
||||||
|
dev_mode: false,
|
||||||
|
sip_public_host: None,
|
||||||
|
sip_port: 5060,
|
||||||
|
rtp_port_start: 10000,
|
||||||
|
rtp_port_end: 15000,
|
||||||
|
rtp_public_ip: None,
|
||||||
|
sip_local_host: None,
|
||||||
|
sip_local_cidr: None,
|
||||||
|
tls_cert_dir: None,
|
||||||
|
tls_port: 5061,
|
||||||
|
tls_refresh_interval: 3600,
|
||||||
|
discord_bot_token: None,
|
||||||
|
dialplan_path: "./dialplan.toml".to_string(),
|
||||||
|
};
|
||||||
|
let tls = env.to_tls_config();
|
||||||
|
assert_eq!(tls.cert_dir, PathBuf::from("/data/certs"));
|
||||||
|
assert_eq!(tls.port, 5061);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tls_config_paths() {
|
||||||
|
let tls = TlsConfig {
|
||||||
|
cert_dir: PathBuf::from("/etc/ssl/sipcord"),
|
||||||
|
port: 5061,
|
||||||
|
refresh_interval_secs: 3600,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
tls.cert_path(),
|
||||||
|
PathBuf::from("/etc/ssl/sipcord/bridge.crt")
|
||||||
|
);
|
||||||
|
assert_eq!(tls.key_path(), PathBuf::from("/etc/ssl/sipcord/bridge.key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_app_config_load_valid_toml() {
|
||||||
|
let toml_content = r#"
|
||||||
|
[sounds]
|
||||||
|
join = { src = "join.wav", preload = true }
|
||||||
|
|
||||||
|
[bridge]
|
||||||
|
rtp_inactivity_timeout_secs = 120
|
||||||
|
|
||||||
|
[audio]
|
||||||
|
ring_buffer_samples = 48000
|
||||||
|
|
||||||
|
[fax]
|
||||||
|
prefix = "test_"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir().join("sipcord_test_config");
|
||||||
|
std::fs::create_dir_all(&dir).ok();
|
||||||
|
let path = dir.join("test_config.toml");
|
||||||
|
std::fs::write(&path, toml_content).unwrap();
|
||||||
|
|
||||||
|
let config = AppConfig::load(&path).unwrap();
|
||||||
|
assert_eq!(config.bridge.rtp_inactivity_timeout_secs, 120);
|
||||||
|
assert_eq!(config.audio.ring_buffer_samples, 48000);
|
||||||
|
assert_eq!(config.fax.prefix, "test_");
|
||||||
|
assert!(config.sounds.entries.contains_key("join"));
|
||||||
|
}
|
||||||
|
}
|
||||||
344
sipcord-bridge/src/fax/audio_port.rs
Normal file
344
sipcord-bridge/src/fax/audio_port.rs
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
//! Fax audio port — bidirectional audio between SIP and SpanDSP.
|
||||||
|
//!
|
||||||
|
//! For each fax call, we create a custom conference port that:
|
||||||
|
//! - Receives audio from the SIP call via `put_frame` → RX ring buffer → fax processing task
|
||||||
|
//! - Sends SpanDSP transmit audio (CED, T.30) via TX ring buffer → `get_frame` → SIP call
|
||||||
|
//!
|
||||||
|
//! This is analogous to the channel_audio.rs ports used for Discord↔SIP audio.
|
||||||
|
|
||||||
|
use crate::transport::sip::ffi::types::{
|
||||||
|
ConfPort, SendablePool, SendablePort, CALL_CONF_PORTS, CONF_CHANNELS, CONF_SAMPLE_RATE,
|
||||||
|
SAMPLES_PER_FRAME,
|
||||||
|
};
|
||||||
|
use crate::transport::sip::CallId;
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use pjsua::*;
|
||||||
|
use rtrb::{Consumer, Producer};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use tracing::{debug, error, warn};
|
||||||
|
|
||||||
|
/// Ring buffer capacity for fax audio (i16 mono @ 16kHz).
|
||||||
|
/// 16000 samples = 1 second of audio, generous buffer for fax processing.
|
||||||
|
const FAX_AUDIO_RING_BUFFER_SIZE: usize = 16000;
|
||||||
|
|
||||||
|
/// Ring buffer capacity for fax TX audio (SpanDSP → SIP).
|
||||||
|
/// 3200 samples = 200ms — enough for timing jitter.
|
||||||
|
const FAX_TX_RING_BUFFER_SIZE: usize = 3200;
|
||||||
|
|
||||||
|
/// Map from CallId → RX ring buffer producer (SIP audio → fax processing task).
|
||||||
|
/// The put_frame callback pushes audio samples here.
|
||||||
|
static FAX_RX_PRODUCERS: OnceLock<DashMap<i64, Mutex<Producer<i16>>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn get_fax_rx_producers() -> &'static DashMap<i64, Mutex<Producer<i16>>> {
|
||||||
|
FAX_RX_PRODUCERS.get_or_init(DashMap::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map from CallId → TX ring buffer consumer (fax processing task → SIP caller).
|
||||||
|
/// The get_frame callback reads SpanDSP transmit audio from here.
|
||||||
|
static FAX_TX_CONSUMERS: OnceLock<DashMap<i64, Mutex<Consumer<i16>>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn get_fax_tx_consumers() -> &'static DashMap<i64, Mutex<Consumer<i16>>> {
|
||||||
|
FAX_TX_CONSUMERS.get_or_init(DashMap::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map from CallId → RX frame drop count (incremented in put_frame when buffer is full).
|
||||||
|
static FAX_RX_DROP_COUNTS: OnceLock<DashMap<i64, AtomicU64>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn get_fax_rx_drop_counts() -> &'static DashMap<i64, AtomicU64> {
|
||||||
|
FAX_RX_DROP_COUNTS.get_or_init(DashMap::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of RX audio frames dropped for a call (buffer full).
|
||||||
|
/// Returns 0 if no drops have been recorded.
|
||||||
|
pub fn get_rx_drop_count(call_id: CallId) -> u64 {
|
||||||
|
get_fax_rx_drop_counts()
|
||||||
|
.get(&(*call_id as i64))
|
||||||
|
.map(|c| c.load(Ordering::Relaxed))
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map from CallId → conference slot (for cleanup).
|
||||||
|
static FAX_CONF_SLOTS: OnceLock<Mutex<HashMap<CallId, (SendablePort, ConfPort)>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn get_fax_slots() -> &'static Mutex<HashMap<CallId, (SendablePort, ConfPort)>> {
|
||||||
|
FAX_CONF_SLOTS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Memory pool for fax ports
|
||||||
|
static FAX_PORT_POOL: OnceLock<Mutex<SendablePool>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Bidirectional ring buffer handles for a fax audio port.
|
||||||
|
pub struct FaxAudioPorts {
|
||||||
|
/// RX: SIP audio from caller → fax processing task (feeds SpanDSP fax_rx)
|
||||||
|
pub rx_consumer: Consumer<i16>,
|
||||||
|
/// TX: SpanDSP transmit audio → SIP caller (CED tones, T.30 signaling)
|
||||||
|
pub tx_producer: Producer<i16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a bidirectional fax audio port for a call and connect it to the call's conference slot.
|
||||||
|
///
|
||||||
|
/// Port creation and conference addition happen on the calling thread.
|
||||||
|
/// The bidirectional `pjmedia_conf_connect_port` calls are queued to the audio thread
|
||||||
|
/// to avoid racing with `pjmedia_port_get_frame`.
|
||||||
|
///
|
||||||
|
/// Returns `FaxAudioPorts` with:
|
||||||
|
/// - `rx_consumer`: reads SIP audio (16kHz mono, 320 samples/20ms frames)
|
||||||
|
/// - `tx_producer`: writes SpanDSP transmit audio back to the caller
|
||||||
|
pub async fn create_fax_audio_port(call_id: CallId) -> Option<FaxAudioPorts> {
|
||||||
|
// Get the call's conference port
|
||||||
|
let call_conf_port = {
|
||||||
|
let ports = CALL_CONF_PORTS.get_or_init(DashMap::new);
|
||||||
|
ports.get(&call_id).map(|r| *r)
|
||||||
|
};
|
||||||
|
|
||||||
|
let call_conf_port: ConfPort = match call_conf_port {
|
||||||
|
Some(p) if p.is_valid() => p,
|
||||||
|
_ => {
|
||||||
|
warn!(
|
||||||
|
"Cannot create fax audio port for call {} — no valid conference port",
|
||||||
|
call_id
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create RX ring buffer (SIP → fax processing)
|
||||||
|
let (rx_producer, rx_consumer) = rtrb::RingBuffer::new(FAX_AUDIO_RING_BUFFER_SIZE);
|
||||||
|
|
||||||
|
// Create TX ring buffer (fax processing → SIP)
|
||||||
|
let (tx_producer, tx_consumer) = rtrb::RingBuffer::new(FAX_TX_RING_BUFFER_SIZE);
|
||||||
|
|
||||||
|
let conf_slot = unsafe {
|
||||||
|
// Get or create the memory pool for fax ports
|
||||||
|
let pool = FAX_PORT_POOL.get_or_init(|| {
|
||||||
|
let pool = pjsua_pool_create(c"fax_ports".as_ptr() as *const _, 4096, 4096);
|
||||||
|
Mutex::new(SendablePool(pool))
|
||||||
|
});
|
||||||
|
let pool_ptr = pool.lock().0;
|
||||||
|
|
||||||
|
// Allocate pjmedia_port structure
|
||||||
|
let port_size = std::mem::size_of::<pjmedia_port>();
|
||||||
|
let port = pj_pool_alloc(pool_ptr, port_size) as *mut pjmedia_port;
|
||||||
|
if port.is_null() {
|
||||||
|
error!("Failed to allocate fax audio port for call {}", call_id);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
std::ptr::write_bytes(port as *mut u8, 0, port_size);
|
||||||
|
|
||||||
|
// Initialize port info
|
||||||
|
let port_name = format!("fax{}", *call_id);
|
||||||
|
let port_name_cstr = std::ffi::CString::new(port_name).ok()?;
|
||||||
|
let signature = 0x4641_5852; // "FAXR" in hex
|
||||||
|
|
||||||
|
pjmedia_port_info_init(
|
||||||
|
&mut (*port).info,
|
||||||
|
&pj_str(port_name_cstr.as_ptr() as *mut _),
|
||||||
|
signature,
|
||||||
|
CONF_SAMPLE_RATE,
|
||||||
|
CONF_CHANNELS,
|
||||||
|
16, // bits per sample
|
||||||
|
SAMPLES_PER_FRAME as u32,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set callbacks
|
||||||
|
(*port).get_frame = Some(fax_port_get_frame); // Sends SpanDSP TX audio back to caller
|
||||||
|
(*port).put_frame = Some(fax_port_put_frame); // Captures SIP audio for SpanDSP
|
||||||
|
(*port).on_destroy = Some(fax_port_on_destroy);
|
||||||
|
|
||||||
|
// Store call_id in port_data.ldata for O(1) lookup in callbacks
|
||||||
|
(*port).port_data.ldata = *call_id as i64;
|
||||||
|
|
||||||
|
// Add to conference bridge
|
||||||
|
let mut slot: i32 = 0;
|
||||||
|
let status = pjsua_conf_add_port(pool_ptr, port, &mut slot);
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
error!(
|
||||||
|
"Failed to add fax port to conference for call {}: {}",
|
||||||
|
call_id, status
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let conf_slot = ConfPort::new(slot);
|
||||||
|
|
||||||
|
// Store ring buffer handles for callbacks
|
||||||
|
get_fax_rx_producers().insert(*call_id as i64, Mutex::new(rx_producer));
|
||||||
|
get_fax_tx_consumers().insert(*call_id as i64, Mutex::new(tx_consumer));
|
||||||
|
get_fax_rx_drop_counts().insert(*call_id as i64, AtomicU64::new(0));
|
||||||
|
|
||||||
|
// Store slot for cleanup
|
||||||
|
get_fax_slots()
|
||||||
|
.lock()
|
||||||
|
.insert(call_id, (SendablePort(port), conf_slot));
|
||||||
|
|
||||||
|
conf_slot
|
||||||
|
};
|
||||||
|
|
||||||
|
// Queue the bidirectional conference connection to the audio thread
|
||||||
|
// This avoids racing with pjmedia_port_get_frame
|
||||||
|
let (done_tx, done_rx) = tokio::sync::oneshot::channel();
|
||||||
|
use crate::transport::sip::ffi::types::{queue_pjsua_op, PendingPjsuaOp};
|
||||||
|
queue_pjsua_op(PendingPjsuaOp::ConnectFaxPort {
|
||||||
|
call_id,
|
||||||
|
fax_slot: conf_slot,
|
||||||
|
call_conf_port,
|
||||||
|
done_tx,
|
||||||
|
});
|
||||||
|
|
||||||
|
match done_rx.await {
|
||||||
|
Ok(true) => {
|
||||||
|
debug!(
|
||||||
|
"Created fax audio port for call {} at slot {} (bidirectional with call conf_port {})",
|
||||||
|
call_id, conf_slot, call_conf_port
|
||||||
|
);
|
||||||
|
Some(FaxAudioPorts {
|
||||||
|
rx_consumer,
|
||||||
|
tx_producer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
error!(
|
||||||
|
"Audio thread failed to connect fax port for call {} — cleaning up",
|
||||||
|
call_id
|
||||||
|
);
|
||||||
|
remove_fax_audio_port(call_id);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
error!(
|
||||||
|
"Audio thread dropped fax port connection signal for call {} — cleaning up",
|
||||||
|
call_id
|
||||||
|
);
|
||||||
|
remove_fax_audio_port(call_id);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove and clean up the fax audio port for a call.
|
||||||
|
pub fn remove_fax_audio_port(call_id: CallId) {
|
||||||
|
// Remove ring buffer handles first (stops callbacks from reading/writing)
|
||||||
|
get_fax_rx_producers().remove(&(*call_id as i64));
|
||||||
|
get_fax_tx_consumers().remove(&(*call_id as i64));
|
||||||
|
get_fax_rx_drop_counts().remove(&(*call_id as i64));
|
||||||
|
|
||||||
|
// Remove and clean up the conference port
|
||||||
|
let removed = get_fax_slots().lock().remove(&call_id);
|
||||||
|
if let Some((port, slot)) = removed {
|
||||||
|
unsafe {
|
||||||
|
// Disconnect from conference
|
||||||
|
pjsua_conf_remove_port(*slot);
|
||||||
|
|
||||||
|
// Destroy the port
|
||||||
|
if !port.0.is_null() {
|
||||||
|
pjmedia_port_destroy(port.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug!(
|
||||||
|
"Removed fax audio port for call {} (slot {})",
|
||||||
|
call_id, slot
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// get_frame callback — sends SpanDSP transmit audio (CED, T.30) back to the SIP caller.
|
||||||
|
///
|
||||||
|
/// Reads from the TX ring buffer filled by the fax processing task.
|
||||||
|
/// Returns silence if no TX audio is available.
|
||||||
|
unsafe extern "C" fn fax_port_get_frame(
|
||||||
|
this_port: *mut pjmedia_port,
|
||||||
|
frame: *mut pjmedia_frame,
|
||||||
|
) -> pj_status_t {
|
||||||
|
if this_port.is_null() || frame.is_null() {
|
||||||
|
return pj_constants__PJ_SUCCESS as pj_status_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
let call_id_ldata = (*this_port).port_data.ldata;
|
||||||
|
|
||||||
|
if let Some(consumer_entry) = get_fax_tx_consumers().get(&call_id_ldata) {
|
||||||
|
if let Some(mut consumer) = consumer_entry.try_lock() {
|
||||||
|
let available = consumer.slots();
|
||||||
|
if available >= SAMPLES_PER_FRAME {
|
||||||
|
if let Ok(chunk) = consumer.read_chunk(SAMPLES_PER_FRAME) {
|
||||||
|
let (first, second) = chunk.as_slices();
|
||||||
|
let buf = (*frame).buf as *mut i16;
|
||||||
|
let out = std::slice::from_raw_parts_mut(buf, SAMPLES_PER_FRAME);
|
||||||
|
out[..first.len()].copy_from_slice(first);
|
||||||
|
if !second.is_empty() {
|
||||||
|
out[first.len()..first.len() + second.len()].copy_from_slice(second);
|
||||||
|
}
|
||||||
|
chunk.commit_all();
|
||||||
|
(*frame).type_ = pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO;
|
||||||
|
(*frame).size = SAMPLES_PER_FRAME * 2;
|
||||||
|
return pj_constants__PJ_SUCCESS as pj_status_t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No TX audio available — return silence audio frame (not NONE).
|
||||||
|
// Returning FRAME_TYPE_NONE can cause PJSIP's conference bridge to
|
||||||
|
// exclude this port from the audio mix, breaking the TX path.
|
||||||
|
let buf = (*frame).buf as *mut i16;
|
||||||
|
let out = std::slice::from_raw_parts_mut(buf, SAMPLES_PER_FRAME);
|
||||||
|
out.fill(0);
|
||||||
|
(*frame).type_ = pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO;
|
||||||
|
(*frame).size = SAMPLES_PER_FRAME * 2;
|
||||||
|
pj_constants__PJ_SUCCESS as pj_status_t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// on_destroy callback — no-op since cleanup is done in remove_fax_audio_port().
|
||||||
|
/// Required by PJSIP to avoid "on_destroy() not found" warning.
|
||||||
|
unsafe extern "C" fn fax_port_on_destroy(_this_port: *mut pjmedia_port) -> pj_status_t {
|
||||||
|
pj_constants__PJ_SUCCESS as pj_status_t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// put_frame callback — captures SIP audio and pushes to RX ring buffer for SpanDSP.
|
||||||
|
unsafe extern "C" fn fax_port_put_frame(
|
||||||
|
this_port: *mut pjmedia_port,
|
||||||
|
frame: *mut pjmedia_frame,
|
||||||
|
) -> pj_status_t {
|
||||||
|
if this_port.is_null() || frame.is_null() {
|
||||||
|
return pj_constants__PJ_SUCCESS as pj_status_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process audio frames with data
|
||||||
|
if (*frame).type_ != pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO || (*frame).size == 0 {
|
||||||
|
return pj_constants__PJ_SUCCESS as pj_status_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
let call_id_ldata = (*this_port).port_data.ldata;
|
||||||
|
|
||||||
|
// View frame buffer as i16 slice
|
||||||
|
let num_samples = (*frame).size / 2;
|
||||||
|
let frame_buf = (*frame).buf as *const i16;
|
||||||
|
let samples = std::slice::from_raw_parts(frame_buf, num_samples);
|
||||||
|
|
||||||
|
// Push to RX ring buffer
|
||||||
|
if let Some(producer_entry) = get_fax_rx_producers().get(&call_id_ldata) {
|
||||||
|
if let Some(mut producer) = producer_entry.try_lock() {
|
||||||
|
let available = producer.slots();
|
||||||
|
if available >= samples.len() {
|
||||||
|
if let Ok(mut chunk) = producer.write_chunk(samples.len()) {
|
||||||
|
let (first, second) = chunk.as_mut_slices();
|
||||||
|
let first_len = first.len().min(samples.len());
|
||||||
|
first[..first_len].copy_from_slice(&samples[..first_len]);
|
||||||
|
if first_len < samples.len() {
|
||||||
|
second[..samples.len() - first_len].copy_from_slice(&samples[first_len..]);
|
||||||
|
}
|
||||||
|
chunk.commit_all();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Buffer full — fax processing is falling behind. Track the drop.
|
||||||
|
if let Some(counter) = get_fax_rx_drop_counts().get(&call_id_ldata) {
|
||||||
|
counter.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pj_constants__PJ_SUCCESS as pj_status_t
|
||||||
|
}
|
||||||
224
sipcord-bridge/src/fax/discord_poster.rs
Normal file
224
sipcord-bridge/src/fax/discord_poster.rs
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
//! Discord message poster for fax sessions using serenity's HTTP client.
|
||||||
|
//!
|
||||||
|
//! Posts embed messages through the fax lifecycle:
|
||||||
|
//! - "Receiving fax..." (blurple) when negotiation starts
|
||||||
|
//! - Replaced with "Fax Received" (green) with page image gallery on success
|
||||||
|
//! - Edited to "Fax Failed" (red) with reason on failure
|
||||||
|
|
||||||
|
use crate::services::snowflake::Snowflake;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serenity::all::{ChannelId, MessageId, UserId};
|
||||||
|
use serenity::builder::{
|
||||||
|
CreateAttachment, CreateEmbed, CreateEmbedFooter, CreateMessage, EditMessage,
|
||||||
|
};
|
||||||
|
use serenity::http::Http;
|
||||||
|
use serenity::secrets::Token;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tracing::{debug, error, warn};
|
||||||
|
|
||||||
|
const COLOR_RECEIVING: u32 = 0x5865F2; // Discord blurple
|
||||||
|
const COLOR_COMPLETE: u32 = 0x57F287; // Green
|
||||||
|
const COLOR_FAILED: u32 = 0xED4245; // Red
|
||||||
|
const GALLERY_URL: &str = "https://sipcord.net/fax";
|
||||||
|
|
||||||
|
pub struct DiscordPoster {
|
||||||
|
http: Arc<Http>,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
user_id: String,
|
||||||
|
/// Cached display name, resolved on first use
|
||||||
|
display_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiscordPoster {
|
||||||
|
pub fn new(bot_token: String, channel_id: Snowflake, user_id: String) -> Self {
|
||||||
|
let token: Token = bot_token.parse().expect("invalid Discord bot token");
|
||||||
|
Self {
|
||||||
|
http: Arc::new(Http::new(token)),
|
||||||
|
channel_id: ChannelId::new(*channel_id),
|
||||||
|
user_id,
|
||||||
|
display_name: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve and cache the Discord display name for the user.
|
||||||
|
async fn resolve_display_name(&mut self) {
|
||||||
|
if self.display_name.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let name = match self.user_id.parse::<u64>() {
|
||||||
|
Ok(id) => match UserId::new(id).to_user(&self.http).await {
|
||||||
|
Ok(user) => user
|
||||||
|
.global_name
|
||||||
|
.map(|n| n.to_string())
|
||||||
|
.unwrap_or_else(|| user.name.to_string()),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to resolve Discord user {}: {}", self.user_id, e);
|
||||||
|
self.user_id.clone()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(_) => self.user_id.clone(),
|
||||||
|
};
|
||||||
|
self.display_name = Some(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn footer(&self) -> CreateEmbedFooter<'_> {
|
||||||
|
let name = self
|
||||||
|
.display_name
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(self.user_id.as_str());
|
||||||
|
CreateEmbedFooter::new(format!("From: @{}", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Post a "Receiving fax..." status message. Returns the message ID for future edits.
|
||||||
|
pub async fn post_fax_receiving(&mut self) -> Result<u64> {
|
||||||
|
self.resolve_display_name().await;
|
||||||
|
|
||||||
|
let embed = CreateEmbed::new()
|
||||||
|
.title("Incoming Fax")
|
||||||
|
.description("Receiving fax...")
|
||||||
|
.color(COLOR_RECEIVING)
|
||||||
|
.footer(self.footer());
|
||||||
|
|
||||||
|
let msg = self
|
||||||
|
.channel_id
|
||||||
|
.widen()
|
||||||
|
.send_message(&self.http, CreateMessage::new().embed(embed))
|
||||||
|
.await
|
||||||
|
.context("Failed to post fax receiving message")?;
|
||||||
|
|
||||||
|
debug!("Posted fax receiving message: {}", msg.id);
|
||||||
|
Ok(msg.id.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the "Receiving fax..." message with the completed fax and image attachments.
|
||||||
|
///
|
||||||
|
/// Deletes the original status message and posts a new one with embeds + images.
|
||||||
|
/// Uses one embed per page with a shared URL so Discord renders them as a gallery.
|
||||||
|
/// `file_ext` is the file extension without dot (e.g. "png" or "jpg").
|
||||||
|
///
|
||||||
|
/// Discord limits messages to 10 embeds. For faxes with >10 pages, the first 10
|
||||||
|
/// pages are shown in the embed gallery, and remaining pages are attached as files.
|
||||||
|
pub async fn edit_fax_complete(
|
||||||
|
&self,
|
||||||
|
message_id: u64,
|
||||||
|
image_pages: Vec<Vec<u8>>,
|
||||||
|
page_count: u32,
|
||||||
|
file_ext: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
/// Discord's maximum number of embeds per message.
|
||||||
|
const MAX_EMBEDS: u32 = 10;
|
||||||
|
|
||||||
|
let embed_count = page_count.min(MAX_EMBEDS);
|
||||||
|
let has_overflow = page_count > MAX_EMBEDS;
|
||||||
|
|
||||||
|
let description = if page_count == 1 {
|
||||||
|
"Fax received — 1 page".to_string()
|
||||||
|
} else if has_overflow {
|
||||||
|
format!(
|
||||||
|
"Fax received — {} pages (showing first {})",
|
||||||
|
page_count, MAX_EMBEDS
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("Fax received — {} pages", page_count)
|
||||||
|
};
|
||||||
|
|
||||||
|
// One embed per page (up to MAX_EMBEDS) with a shared URL for gallery rendering
|
||||||
|
let mut embeds = Vec::with_capacity(embed_count as usize);
|
||||||
|
for i in 0..embed_count {
|
||||||
|
let filename = format!("fax_page_{}.{}", i + 1, file_ext);
|
||||||
|
let image_url = format!("attachment://{}", filename);
|
||||||
|
|
||||||
|
let embed = if i == 0 {
|
||||||
|
CreateEmbed::new()
|
||||||
|
.title("Fax Received")
|
||||||
|
.description(description.clone())
|
||||||
|
.color(COLOR_COMPLETE)
|
||||||
|
.url(GALLERY_URL)
|
||||||
|
.image(image_url)
|
||||||
|
.footer(self.footer())
|
||||||
|
} else {
|
||||||
|
CreateEmbed::new()
|
||||||
|
.color(COLOR_COMPLETE)
|
||||||
|
.url(GALLERY_URL)
|
||||||
|
.image(image_url)
|
||||||
|
};
|
||||||
|
embeds.push(embed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All pages are attached as files (embed pages get rendered in gallery,
|
||||||
|
// overflow pages appear as plain file attachments)
|
||||||
|
let attachments: Vec<CreateAttachment> = image_pages
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, data)| {
|
||||||
|
CreateAttachment::bytes(data, format!("fax_page_{}.{}", i + 1, file_ext))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut edit = EditMessage::new().embeds(embeds);
|
||||||
|
for attachment in attachments {
|
||||||
|
edit = edit.new_attachment(attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = self
|
||||||
|
.channel_id
|
||||||
|
.widen()
|
||||||
|
.edit_message(&self.http, MessageId::new(message_id), edit)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
error!(
|
||||||
|
"Discord API error editing fax complete (msg={}, {} pages): {}",
|
||||||
|
message_id, page_count, e
|
||||||
|
);
|
||||||
|
anyhow::bail!("Failed to edit fax complete message: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Edit the status message to show a failure reason.
|
||||||
|
pub async fn edit_fax_failed(&self, message_id: u64, reason: &str) -> Result<()> {
|
||||||
|
let embed = CreateEmbed::new()
|
||||||
|
.title("Fax Failed")
|
||||||
|
.description(reason)
|
||||||
|
.color(COLOR_FAILED)
|
||||||
|
.footer(self.footer());
|
||||||
|
|
||||||
|
if let Err(e) = self
|
||||||
|
.channel_id
|
||||||
|
.widen()
|
||||||
|
.edit_message(
|
||||||
|
&self.http,
|
||||||
|
MessageId::new(message_id),
|
||||||
|
EditMessage::new().embed(embed),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
error!("Discord API error editing fax failed: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Post a standalone failure message (when no "receiving" message was posted).
|
||||||
|
pub async fn post_fax_failed(&mut self, reason: &str) -> Result<()> {
|
||||||
|
self.resolve_display_name().await;
|
||||||
|
|
||||||
|
let embed = CreateEmbed::new()
|
||||||
|
.title("Fax Failed")
|
||||||
|
.description(reason)
|
||||||
|
.color(COLOR_FAILED)
|
||||||
|
.footer(self.footer());
|
||||||
|
|
||||||
|
if let Err(e) = self
|
||||||
|
.channel_id
|
||||||
|
.widen()
|
||||||
|
.send_message(&self.http, CreateMessage::new().embed(embed))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
error!("Discord API error posting fax failed: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
sipcord-bridge/src/fax/example.tiff
Normal file
BIN
sipcord-bridge/src/fax/example.tiff
Normal file
Binary file not shown.
18
sipcord-bridge/src/fax/mod.rs
Normal file
18
sipcord-bridge/src/fax/mod.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
//! Incoming fax support — receives faxes over SIP and posts images to Discord.
|
||||||
|
//!
|
||||||
|
//! Supports two transport modes:
|
||||||
|
//! - **G.711 passthrough**: Demodulates fax tones from audio samples (SpanDSP FaxState)
|
||||||
|
//! - **T.38 native**: Receives IFP packets via UDPTL (SpanDSP T38Terminal)
|
||||||
|
//!
|
||||||
|
//! Architecture:
|
||||||
|
//! - FaxSession: State machine managing a single fax reception (audio or T.38)
|
||||||
|
//! - DiscordPoster: Posts/edits messages in Discord text channels with fax images
|
||||||
|
//! - SpanDSP wrapper: FFI to SpanDSP for fax demodulation (FaxReceiver + FaxT38Receiver)
|
||||||
|
//! - audio_port: Conference bridge port for capturing SIP audio (G.711 mode)
|
||||||
|
//! - UDPTL: UDP transport for T.38 IFP packets
|
||||||
|
|
||||||
|
pub mod audio_port;
|
||||||
|
pub mod discord_poster;
|
||||||
|
pub mod session;
|
||||||
|
pub mod spandsp;
|
||||||
|
pub mod tiff_decoder;
|
||||||
649
sipcord-bridge/src/fax/session.rs
Normal file
649
sipcord-bridge/src/fax/session.rs
Normal file
|
|
@ -0,0 +1,649 @@
|
||||||
|
//! FaxSession state machine — manages a single incoming fax reception.
|
||||||
|
//!
|
||||||
|
//! Lifecycle:
|
||||||
|
//! 1. Created when a fax call is answered (ConnectFax route decision)
|
||||||
|
//! 2. Audio frames are fed via `feed_audio()`
|
||||||
|
//! 3. SpanDSP demodulates the fax tones into a TIFF file
|
||||||
|
//! 4. On completion, TIFF is converted to PNG and posted to Discord
|
||||||
|
//! 5. On failure or timeout, an error message is posted to Discord
|
||||||
|
|
||||||
|
use crate::fax::discord_poster::DiscordPoster;
|
||||||
|
use crate::fax::spandsp::{FaxReceiver, FaxRxStatus, FaxT38Receiver};
|
||||||
|
use crate::fax::tiff_decoder;
|
||||||
|
use crate::services::snowflake::Snowflake;
|
||||||
|
use crate::transport::sip::CallId;
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::io::Cursor;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
/// Maximum duration for a fax session before timeout (5 minutes)
|
||||||
|
const FAX_TIMEOUT_SECS: u64 = 300;
|
||||||
|
|
||||||
|
/// How the fax audio is being received
|
||||||
|
pub enum FaxSource {
|
||||||
|
/// G.711 audio passthrough
|
||||||
|
G711Audio,
|
||||||
|
/// T.38 UDPTL
|
||||||
|
T38Udptl,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active receiver — either audio-based or T.38 IFP-based.
|
||||||
|
enum FaxReceiverKind {
|
||||||
|
/// G.711 audio passthrough (demodulates fax tones from audio samples)
|
||||||
|
Audio(FaxReceiver),
|
||||||
|
/// T.38 UDPTL (receives IFP packets directly)
|
||||||
|
T38(FaxT38Receiver),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current state of the fax reception
|
||||||
|
pub enum FaxState {
|
||||||
|
/// Answered, feeding audio to SpanDSP, waiting for fax negotiation
|
||||||
|
WaitingForData,
|
||||||
|
/// SpanDSP confirmed fax negotiation started
|
||||||
|
Receiving {
|
||||||
|
/// Number of pages received so far
|
||||||
|
pages_received: u32,
|
||||||
|
},
|
||||||
|
/// SpanDSP signaled fax complete, awaiting conversion and Discord posting
|
||||||
|
Received,
|
||||||
|
/// Fax posted to Discord successfully
|
||||||
|
Complete,
|
||||||
|
/// Fax reception failed
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single fax reception session
|
||||||
|
pub struct FaxSession {
|
||||||
|
/// SIP call ID for this fax
|
||||||
|
pub call_id: CallId,
|
||||||
|
/// Discord text channel to post the fax to
|
||||||
|
pub text_channel_id: Snowflake,
|
||||||
|
/// Guild ID (for logging)
|
||||||
|
pub guild_id: Snowflake,
|
||||||
|
/// User ID who owns this mapping
|
||||||
|
pub user_id: String,
|
||||||
|
/// Current state
|
||||||
|
pub state: FaxState,
|
||||||
|
/// How we're receiving the fax
|
||||||
|
pub source: FaxSource,
|
||||||
|
/// When this session was created
|
||||||
|
pub created_at: Instant,
|
||||||
|
/// Discord poster for this session
|
||||||
|
pub poster: DiscordPoster,
|
||||||
|
/// SpanDSP fax receiver (audio or T.38 mode)
|
||||||
|
receiver: FaxReceiverKind,
|
||||||
|
/// Temp directory for this fax session's TIFF output
|
||||||
|
pub tiff_dir: PathBuf,
|
||||||
|
/// Discord message ID for the "Receiving fax..." status message.
|
||||||
|
/// Stored separately so it survives state transitions to Complete/Failed.
|
||||||
|
receiving_message_id: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FaxSession {
|
||||||
|
/// Create a new fax session. Initializes SpanDSP in receive mode.
|
||||||
|
pub fn new(
|
||||||
|
call_id: CallId,
|
||||||
|
text_channel_id: Snowflake,
|
||||||
|
guild_id: Snowflake,
|
||||||
|
user_id: String,
|
||||||
|
bot_token: String,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let fax_config = crate::config::AppConfig::fax();
|
||||||
|
|
||||||
|
// Use configured tmp_folder or system temp dir
|
||||||
|
let base_dir = fax_config
|
||||||
|
.tmp_folder
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(std::env::temp_dir);
|
||||||
|
|
||||||
|
// Generate a unique session ID for the filename
|
||||||
|
let session_id = format!("{:016x}", {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let t = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
// Mix timestamp with call_id using a prime constant for a unique session ID
|
||||||
|
t.as_nanos() as u64 ^ (*call_id as u64).wrapping_mul(0x517cc1b727220a95)
|
||||||
|
});
|
||||||
|
|
||||||
|
let tiff_dir = base_dir.join(format!("{}{}", fax_config.prefix, session_id));
|
||||||
|
std::fs::create_dir_all(&tiff_dir)?;
|
||||||
|
let tiff_path = tiff_dir.join(format!("{}{}.tiff", fax_config.prefix, session_id));
|
||||||
|
|
||||||
|
let receiver = FaxReceiver::new_audio_receiver(&tiff_path)?;
|
||||||
|
|
||||||
|
let poster = DiscordPoster::new(bot_token, text_channel_id, user_id.clone());
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
call_id,
|
||||||
|
text_channel_id,
|
||||||
|
guild_id,
|
||||||
|
user_id,
|
||||||
|
state: FaxState::WaitingForData,
|
||||||
|
source: FaxSource::G711Audio,
|
||||||
|
created_at: Instant::now(),
|
||||||
|
poster,
|
||||||
|
receiver: FaxReceiverKind::Audio(receiver),
|
||||||
|
tiff_dir,
|
||||||
|
receiving_message_id: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed audio samples from the SIP call (16kHz mono i16).
|
||||||
|
/// Downsamples to 8kHz and feeds to SpanDSP's fax_rx().
|
||||||
|
/// Returns true if the fax is complete and ready for post-processing.
|
||||||
|
/// Only works in Audio mode — logs a warning and returns false if called in T.38 mode.
|
||||||
|
pub fn feed_audio(&mut self, samples: &[i16]) -> bool {
|
||||||
|
// Check for timeout
|
||||||
|
if self.created_at.elapsed().as_secs() > FAX_TIMEOUT_SECS {
|
||||||
|
warn!(
|
||||||
|
"Fax session {} timed out after {}s",
|
||||||
|
self.call_id,
|
||||||
|
self.created_at.elapsed().as_secs()
|
||||||
|
);
|
||||||
|
self.state = FaxState::Failed("Fax reception timed out".to_string());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.is_finished() {
|
||||||
|
return matches!(self.state, FaxState::Received | FaxState::Complete);
|
||||||
|
}
|
||||||
|
|
||||||
|
let receiver = match &mut self.receiver {
|
||||||
|
FaxReceiverKind::Audio(r) => r,
|
||||||
|
FaxReceiverKind::T38(_) => {
|
||||||
|
warn!("feed_audio called on T.38 session {}", self.call_id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = receiver.feed_samples_16k(samples);
|
||||||
|
self.handle_rx_status(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed a T.38 IFP packet from the UDPTL socket to SpanDSP.
|
||||||
|
/// Returns true if the fax is complete and ready for post-processing.
|
||||||
|
/// Only works in T.38 mode.
|
||||||
|
pub fn feed_t38_ifp(&mut self, data: &[u8], seq: u16) -> bool {
|
||||||
|
if self.is_finished() {
|
||||||
|
return matches!(self.state, FaxState::Received | FaxState::Complete);
|
||||||
|
}
|
||||||
|
|
||||||
|
let receiver = match &mut self.receiver {
|
||||||
|
FaxReceiverKind::T38(r) => r,
|
||||||
|
FaxReceiverKind::Audio(_) => {
|
||||||
|
warn!("feed_t38_ifp called on audio session {}", self.call_id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = receiver.feed_ifp_packet(data, seq);
|
||||||
|
self.handle_rx_status(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive the T.38 terminal timer (call every 20ms).
|
||||||
|
/// Returns true if the fax is complete and ready for post-processing.
|
||||||
|
pub fn drive_t38_timer(&mut self) -> bool {
|
||||||
|
if self.is_finished() {
|
||||||
|
return matches!(self.state, FaxState::Received | FaxState::Complete);
|
||||||
|
}
|
||||||
|
|
||||||
|
let receiver = match &mut self.receiver {
|
||||||
|
FaxReceiverKind::T38(r) => r,
|
||||||
|
FaxReceiverKind::Audio(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = receiver.drive_timer();
|
||||||
|
self.handle_rx_status(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common handler for FaxRxStatus from either audio or T.38 receiver.
|
||||||
|
fn handle_rx_status(&mut self, status: FaxRxStatus) -> bool {
|
||||||
|
// Log stats on completion/error before delegating to pure state transition
|
||||||
|
match &status {
|
||||||
|
FaxRxStatus::Complete => {
|
||||||
|
if let Some(stats) = self.get_stats() {
|
||||||
|
info!(
|
||||||
|
"Fax {} complete: {} pages, {}bps, {}x{}, ECM={}, bad_rows={}",
|
||||||
|
self.call_id,
|
||||||
|
stats.pages_rx,
|
||||||
|
stats.bit_rate,
|
||||||
|
stats.image_width,
|
||||||
|
stats.image_length,
|
||||||
|
stats.ecm,
|
||||||
|
stats.bad_rows
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FaxRxStatus::Error(msg) => {
|
||||||
|
if let Some(stats) = self.get_stats() {
|
||||||
|
warn!(
|
||||||
|
"Fax {} failed: {} ({}bps, {}x{}, ECM={}, pages_rx={}, bad_rows={}, audio={:.1}s)",
|
||||||
|
self.call_id,
|
||||||
|
msg,
|
||||||
|
stats.bit_rate,
|
||||||
|
stats.image_width,
|
||||||
|
stats.image_length,
|
||||||
|
stats.ecm,
|
||||||
|
stats.pages_rx,
|
||||||
|
stats.bad_rows,
|
||||||
|
self.audio_duration_secs()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
"Fax {} failed: {} (no stats, audio={:.1}s)",
|
||||||
|
self.call_id,
|
||||||
|
msg,
|
||||||
|
self.audio_duration_secs()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FaxRxStatus::InProgress => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let page_count = self.pages_received();
|
||||||
|
apply_rx_status(&mut self.state, status, page_count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of pages received so far.
|
||||||
|
pub fn pages_received(&self) -> u32 {
|
||||||
|
match &self.receiver {
|
||||||
|
FaxReceiverKind::Audio(r) => r.pages_received(),
|
||||||
|
FaxReceiverKind::T38(r) => r.pages_received(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get transfer statistics from SpanDSP.
|
||||||
|
fn get_stats(&self) -> Option<crate::fax::spandsp::FaxStats> {
|
||||||
|
match &self.receiver {
|
||||||
|
FaxReceiverKind::Audio(r) => r.get_stats(),
|
||||||
|
FaxReceiverKind::T38(r) => r.get_stats(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this session has timed out
|
||||||
|
pub fn is_timed_out(&self) -> bool {
|
||||||
|
self.created_at.elapsed().as_secs() > FAX_TIMEOUT_SECS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the session is in a terminal state
|
||||||
|
pub fn is_finished(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self.state,
|
||||||
|
FaxState::Received | FaxState::Complete | FaxState::Failed(_)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Post the initial "Receiving fax..." message to Discord.
|
||||||
|
/// Called when fax negotiation is detected.
|
||||||
|
pub async fn post_receiving_message(&mut self) -> Result<()> {
|
||||||
|
match self.poster.post_fax_receiving().await {
|
||||||
|
Ok(msg_id) => {
|
||||||
|
debug!(
|
||||||
|
"Posted 'Receiving fax...' message {} to channel {} (call {})",
|
||||||
|
msg_id, self.text_channel_id, self.call_id
|
||||||
|
);
|
||||||
|
self.receiving_message_id = Some(msg_id);
|
||||||
|
self.state = FaxState::Receiving { pages_received: 0 };
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(
|
||||||
|
"Failed to post receiving message to channel {}: {}",
|
||||||
|
self.text_channel_id, e
|
||||||
|
);
|
||||||
|
self.state = FaxState::Failed(format!("Discord error: {}", e));
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Post a failure message to Discord
|
||||||
|
pub async fn post_failure(&mut self, reason: &str) {
|
||||||
|
if let Some(discord_msg_id) = self.receiving_message_id {
|
||||||
|
if let Err(e) = self.poster.edit_fax_failed(discord_msg_id, reason).await {
|
||||||
|
error!("Failed to edit fax failure message: {}", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No receiving message was posted — post a standalone failure
|
||||||
|
if let Err(e) = self.poster.post_fax_failed(reason).await {
|
||||||
|
error!("Failed to post fax failure message: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.state = FaxState::Failed(reason.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert the received TIFF to images and post to Discord.
|
||||||
|
/// Called after fax reception is complete.
|
||||||
|
pub async fn convert_and_post(&mut self) -> Result<()> {
|
||||||
|
// Guard against double-processing: if we've already posted (Complete) or failed,
|
||||||
|
// another caller (e.g., CallEnded racing with T.38 completion) already handled it.
|
||||||
|
// Note: FaxState::Received is NOT skipped — that's the normal entry state.
|
||||||
|
if matches!(self.state, FaxState::Complete | FaxState::Failed(_)) {
|
||||||
|
debug!(
|
||||||
|
"convert_and_post called on already-finished session {} — skipping",
|
||||||
|
self.call_id
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tiff_path, pages) = match &self.receiver {
|
||||||
|
FaxReceiverKind::Audio(r) => (
|
||||||
|
r.tiff_output_path().to_path_buf(),
|
||||||
|
r.pages_received().max(1),
|
||||||
|
),
|
||||||
|
FaxReceiverKind::T38(r) => (
|
||||||
|
r.tiff_output_path().to_path_buf(),
|
||||||
|
r.pages_received().max(1),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let tiff_path = &tiff_path;
|
||||||
|
|
||||||
|
let fax_config = crate::config::AppConfig::fax();
|
||||||
|
let (output_format, file_ext) = match fax_config.output_format.as_str() {
|
||||||
|
"jpg" | "jpeg" => (OutputFormat::Jpeg, "jpg"),
|
||||||
|
_ => (OutputFormat::Png, "png"),
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Converting TIFF to {} for call {}: {} ({} pages)",
|
||||||
|
output_format.label(),
|
||||||
|
self.call_id,
|
||||||
|
tiff_path.display(),
|
||||||
|
pages
|
||||||
|
);
|
||||||
|
|
||||||
|
let gray_images = tiff_decoder::decode_fax_tiff(tiff_path)?;
|
||||||
|
let image_pages: Vec<Vec<u8>> = gray_images
|
||||||
|
.into_iter()
|
||||||
|
.map(|img| {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
image::DynamicImage::ImageLuma8(img)
|
||||||
|
.write_to(&mut Cursor::new(&mut buf), output_format.image_format())
|
||||||
|
.map(|_| buf)
|
||||||
|
})
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
if image_pages.is_empty() {
|
||||||
|
self.post_failure("No pages in received fax").await;
|
||||||
|
anyhow::bail!("No pages in received fax");
|
||||||
|
}
|
||||||
|
|
||||||
|
let page_count = image_pages.len() as u32;
|
||||||
|
|
||||||
|
if let Some(discord_msg_id) = self.receiving_message_id {
|
||||||
|
match self
|
||||||
|
.poster
|
||||||
|
.edit_fax_complete(discord_msg_id, image_pages, page_count, file_ext)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
info!(
|
||||||
|
"Fax complete: {} pages posted to channel {} (call {})",
|
||||||
|
page_count, self.text_channel_id, self.call_id
|
||||||
|
);
|
||||||
|
self.state = FaxState::Complete;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to post completed fax: {}", e);
|
||||||
|
self.state = FaxState::Failed(format!("Discord upload error: {}", e));
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If we never posted a "receiving" message (e.g., fast fax), post directly
|
||||||
|
// This shouldn't normally happen since we post receiving message early
|
||||||
|
warn!("Fax completed without a receiving message — posting directly");
|
||||||
|
match self.poster.post_fax_receiving().await {
|
||||||
|
Ok(msg_id) => {
|
||||||
|
self.receiving_message_id = Some(msg_id);
|
||||||
|
self.poster
|
||||||
|
.edit_fax_complete(msg_id, image_pages, page_count, file_ext)
|
||||||
|
.await?;
|
||||||
|
self.state = FaxState::Complete;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to post fax: {}", e);
|
||||||
|
self.state = FaxState::Failed(format!("Discord error: {}", e));
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch from G.711 audio mode to T.38 UDPTL mode.
|
||||||
|
///
|
||||||
|
/// Replaces the audio receiver with a T.38 receiver. The caller must:
|
||||||
|
/// 1. Stop feeding audio samples (remove fax audio port)
|
||||||
|
/// 2. Start the UDPTL processing tasks (rx, tx, timer)
|
||||||
|
pub fn switch_to_t38(&mut self, t38_receiver: FaxT38Receiver) {
|
||||||
|
debug!("Fax session {} switching from G.711 to T.38", self.call_id);
|
||||||
|
self.source = FaxSource::T38Udptl;
|
||||||
|
self.receiver = FaxReceiverKind::T38(t38_receiver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate transmit audio from SpanDSP (CED tones, T.30 signaling).
|
||||||
|
///
|
||||||
|
/// Only works in Audio mode — T.38 uses IFP packets, not audio.
|
||||||
|
/// `out_buf` should be 320 samples (20ms at 16kHz).
|
||||||
|
/// Returns the number of 16kHz samples written.
|
||||||
|
pub fn generate_tx_16k(&mut self, out_buf: &mut [i16]) -> usize {
|
||||||
|
match &mut self.receiver {
|
||||||
|
FaxReceiverKind::Audio(r) => r.generate_tx_16k(out_buf),
|
||||||
|
FaxReceiverKind::T38(_) => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the total audio duration received so far (for debugging).
|
||||||
|
/// Returns 0 in T.38 mode (no audio samples).
|
||||||
|
pub fn audio_duration_secs(&self) -> f64 {
|
||||||
|
match &self.receiver {
|
||||||
|
FaxReceiverKind::Audio(r) => r.audio_duration_secs(),
|
||||||
|
FaxReceiverKind::T38(_) => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for FaxSession {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let status = match &self.state {
|
||||||
|
FaxState::WaitingForData => "waiting_for_data",
|
||||||
|
FaxState::Receiving { .. } => "receiving",
|
||||||
|
FaxState::Received => "received",
|
||||||
|
FaxState::Complete => "complete",
|
||||||
|
FaxState::Failed(reason) => {
|
||||||
|
debug!("Fax failure reason: {}", reason);
|
||||||
|
"failed"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
debug!(
|
||||||
|
"FaxSession dropped: call={}, channel={}, guild={}, user={}, status={}, duration={:.1}s, audio={:.1}s",
|
||||||
|
self.call_id,
|
||||||
|
self.text_channel_id,
|
||||||
|
self.guild_id,
|
||||||
|
self.user_id,
|
||||||
|
status,
|
||||||
|
self.created_at.elapsed().as_secs_f64(),
|
||||||
|
self.audio_duration_secs()
|
||||||
|
);
|
||||||
|
if let Err(e) = std::fs::remove_dir_all(&self.tiff_dir) {
|
||||||
|
debug!(
|
||||||
|
"Failed to clean up fax temp dir {}: {}",
|
||||||
|
self.tiff_dir.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
debug!("Cleaned up fax temp dir: {}", self.tiff_dir.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure state transition logic (extracted for testability)
|
||||||
|
|
||||||
|
/// Apply a FaxRxStatus to a FaxState, returning whether the fax is complete.
|
||||||
|
/// This is the core state transition logic used by `FaxSession::handle_rx_status`.
|
||||||
|
fn apply_rx_status(state: &mut FaxState, status: FaxRxStatus, page_count: u32) -> bool {
|
||||||
|
match status {
|
||||||
|
FaxRxStatus::InProgress => {
|
||||||
|
if let FaxState::Receiving { pages_received, .. } = state {
|
||||||
|
*pages_received = page_count;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
FaxRxStatus::Complete => {
|
||||||
|
*state = FaxState::Received;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
FaxRxStatus::Error(msg) => {
|
||||||
|
*state = FaxState::Failed(msg);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output format
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum OutputFormat {
|
||||||
|
Png,
|
||||||
|
Jpeg,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutputFormat {
|
||||||
|
fn image_format(self) -> image::ImageFormat {
|
||||||
|
match self {
|
||||||
|
OutputFormat::Png => image::ImageFormat::Png,
|
||||||
|
OutputFormat::Jpeg => image::ImageFormat::Jpeg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
OutputFormat::Png => "PNG",
|
||||||
|
OutputFormat::Jpeg => "JPEG",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// Helper: check if a FaxState is_finished (mirrors FaxSession::is_finished logic)
|
||||||
|
fn state_is_finished(state: &FaxState) -> bool {
|
||||||
|
matches!(
|
||||||
|
state,
|
||||||
|
FaxState::Received | FaxState::Complete | FaxState::Failed(_)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// is_finished tests
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_finished_waiting_for_data() {
|
||||||
|
assert!(!state_is_finished(&FaxState::WaitingForData));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_finished_receiving() {
|
||||||
|
assert!(!state_is_finished(&FaxState::Receiving {
|
||||||
|
pages_received: 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_finished_received() {
|
||||||
|
assert!(state_is_finished(&FaxState::Received));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_finished_complete() {
|
||||||
|
assert!(state_is_finished(&FaxState::Complete));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_finished_failed() {
|
||||||
|
assert!(state_is_finished(&FaxState::Failed("err".to_string())));
|
||||||
|
}
|
||||||
|
|
||||||
|
// is_timed_out tests
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_timed_out_fresh() {
|
||||||
|
// A fresh Instant should not be timed out
|
||||||
|
let created_at = Instant::now();
|
||||||
|
let elapsed = created_at.elapsed().as_secs();
|
||||||
|
assert!(elapsed <= FAX_TIMEOUT_SECS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_timed_out_old() {
|
||||||
|
// An instant created FAX_TIMEOUT_SECS+1 ago should be timed out
|
||||||
|
let created_at = Instant::now() - std::time::Duration::from_secs(FAX_TIMEOUT_SECS + 1);
|
||||||
|
assert!(created_at.elapsed().as_secs() > FAX_TIMEOUT_SECS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply_rx_status tests
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_rx_status_in_progress_on_waiting() {
|
||||||
|
let mut state = FaxState::WaitingForData;
|
||||||
|
let result = apply_rx_status(&mut state, FaxRxStatus::InProgress, 0);
|
||||||
|
assert!(!result);
|
||||||
|
assert!(matches!(state, FaxState::WaitingForData));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_rx_status_in_progress_on_receiving_updates_pages() {
|
||||||
|
let mut state = FaxState::Receiving { pages_received: 0 };
|
||||||
|
let result = apply_rx_status(&mut state, FaxRxStatus::InProgress, 3);
|
||||||
|
assert!(!result);
|
||||||
|
match state {
|
||||||
|
FaxState::Receiving { pages_received } => assert_eq!(pages_received, 3),
|
||||||
|
_ => panic!("Expected Receiving state"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_rx_status_complete_transitions_to_received() {
|
||||||
|
let mut state = FaxState::Receiving { pages_received: 1 };
|
||||||
|
let result = apply_rx_status(&mut state, FaxRxStatus::Complete, 1);
|
||||||
|
assert!(result);
|
||||||
|
assert!(matches!(state, FaxState::Received));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_rx_status_error_transitions_to_failed() {
|
||||||
|
let mut state = FaxState::WaitingForData;
|
||||||
|
let result = apply_rx_status(&mut state, FaxRxStatus::Error("timeout".to_string()), 0);
|
||||||
|
assert!(!result);
|
||||||
|
match state {
|
||||||
|
FaxState::Failed(msg) => assert_eq!(msg, "timeout"),
|
||||||
|
_ => panic!("Expected Failed state"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_rx_status_idempotent_on_terminal_complete() {
|
||||||
|
// Once in Received, InProgress should not change the state
|
||||||
|
let mut state = FaxState::Received;
|
||||||
|
let result = apply_rx_status(&mut state, FaxRxStatus::InProgress, 0);
|
||||||
|
assert!(!result);
|
||||||
|
assert!(matches!(state, FaxState::Received));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_rx_status_idempotent_on_terminal_failed() {
|
||||||
|
let mut state = FaxState::Failed("original".to_string());
|
||||||
|
let result = apply_rx_status(&mut state, FaxRxStatus::InProgress, 0);
|
||||||
|
assert!(!result);
|
||||||
|
match state {
|
||||||
|
FaxState::Failed(msg) => assert_eq!(msg, "original"),
|
||||||
|
_ => panic!("Expected Failed state"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
672
sipcord-bridge/src/fax/spandsp.rs
Normal file
672
sipcord-bridge/src/fax/spandsp.rs
Normal file
|
|
@ -0,0 +1,672 @@
|
||||||
|
//! SpanDSP wrapper for fax demodulation.
|
||||||
|
//!
|
||||||
|
//! Uses the `spandsp` safe wrapper crate to decode G.711 audio into TIFF images.
|
||||||
|
//! Audio arrives at 16kHz from PJSUA conference bridge; we downsample to 8kHz for SpanDSP.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use spandsp::fax::FaxState;
|
||||||
|
use spandsp::logging::{LogLevel, LogShowFlags};
|
||||||
|
use spandsp::spandsp_sys;
|
||||||
|
use spandsp::t30::T30ModemSupport;
|
||||||
|
use spandsp::t38_terminal::T38Terminal;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{debug, error, info, trace, warn};
|
||||||
|
|
||||||
|
// T.4 image compression types (bitmask for t30_set_supported_compressions)
|
||||||
|
const T4_COMPRESSION_T4_1D: i32 = spandsp_sys::t4_image_compression_t_T4_COMPRESSION_T4_1D as i32;
|
||||||
|
const T4_COMPRESSION_T4_2D: i32 = spandsp_sys::t4_image_compression_t_T4_COMPRESSION_T4_2D as i32;
|
||||||
|
const T4_COMPRESSION_T6: i32 = spandsp_sys::t4_image_compression_t_T4_COMPRESSION_T6 as i32;
|
||||||
|
|
||||||
|
// T.4 supported image widths (bitmask for t30_set_supported_image_sizes)
|
||||||
|
// These are #defines in the C header that bindgen doesn't capture as constants.
|
||||||
|
// Values from spandsp/t4_rx.h: T4_SUPPORT_WIDTH_215MM=0x01, 255MM=0x02, 303MM=0x04
|
||||||
|
const T4_SUPPORT_WIDTH_215MM: i32 = 0x01;
|
||||||
|
const T4_SUPPORT_WIDTH_255MM: i32 = 0x02;
|
||||||
|
const T4_SUPPORT_WIDTH_303MM: i32 = 0x04;
|
||||||
|
|
||||||
|
// T.4 supported resolutions (bitmask, OR'd into the same sizes parameter)
|
||||||
|
// Values from spandsp/t4_rx.h
|
||||||
|
const T4_RESOLUTION_R8_STANDARD: i32 = 0x01; // 204×98 DPI
|
||||||
|
const T4_RESOLUTION_R8_FINE: i32 = 0x02; // 204×196 DPI
|
||||||
|
const T4_RESOLUTION_R8_SUPERFINE: i32 = 0x04; // 204×391 DPI
|
||||||
|
const T4_RESOLUTION_200_200: i32 = 0x40; // 200×200 DPI
|
||||||
|
|
||||||
|
/// Status returned after processing audio
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum FaxRxStatus {
|
||||||
|
/// Still processing, no state change
|
||||||
|
InProgress,
|
||||||
|
/// Fax reception completed successfully
|
||||||
|
Complete,
|
||||||
|
/// Error during reception
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callbacks from SpanDSP via the T.30 phase handlers.
|
||||||
|
/// These track progress but don't drive control flow — FaxSession checks
|
||||||
|
/// the receiver's state after each feed_samples() call.
|
||||||
|
struct FaxCallbackState {
|
||||||
|
/// Whether phase B (negotiation) has been entered
|
||||||
|
negotiation_started: bool,
|
||||||
|
/// Number of pages received (phase D count)
|
||||||
|
pages_received: u32,
|
||||||
|
/// Final completion code from phase E (-1 = not yet completed)
|
||||||
|
completion_code: i32,
|
||||||
|
/// Whether phase E (completion) has fired
|
||||||
|
completed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summary statistics from a fax reception.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct FaxStats {
|
||||||
|
pub bit_rate: i32,
|
||||||
|
pub pages_rx: i32,
|
||||||
|
pub image_width: i32,
|
||||||
|
pub image_length: i32,
|
||||||
|
pub bad_rows: i32,
|
||||||
|
pub ecm: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared helpers
|
||||||
|
|
||||||
|
/// Configure T.30 session parameters, set output TIFF, and register phase handlers.
|
||||||
|
fn configure_t30(
|
||||||
|
t30: &spandsp::t30::T30State,
|
||||||
|
tiff_path: &str,
|
||||||
|
callback_state: &mut FaxCallbackState,
|
||||||
|
) -> Result<()> {
|
||||||
|
t30.set_rx_file(tiff_path, -1)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to set rx file: {}", e))?;
|
||||||
|
|
||||||
|
t30.set_supported_modems(T30ModemSupport::default())
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to set supported modems: {}", e))?;
|
||||||
|
|
||||||
|
t30.set_ecm_capability(true)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to set ECM: {}", e))?;
|
||||||
|
|
||||||
|
let compressions = T4_COMPRESSION_T4_1D | T4_COMPRESSION_T4_2D | T4_COMPRESSION_T6;
|
||||||
|
t30.set_supported_compressions(compressions)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to set compressions: {}", e))?;
|
||||||
|
|
||||||
|
let sizes = T4_SUPPORT_WIDTH_215MM | T4_SUPPORT_WIDTH_255MM | T4_SUPPORT_WIDTH_303MM
|
||||||
|
| T4_RESOLUTION_R8_STANDARD | T4_RESOLUTION_R8_FINE | T4_RESOLUTION_R8_SUPERFINE
|
||||||
|
| T4_RESOLUTION_200_200;
|
||||||
|
t30.set_supported_image_sizes(sizes)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to set image sizes: {}", e))?;
|
||||||
|
|
||||||
|
let user_data = callback_state as *mut FaxCallbackState as *mut std::ffi::c_void;
|
||||||
|
unsafe {
|
||||||
|
t30.set_phase_b_handler_raw(Some(phase_b_handler), user_data);
|
||||||
|
t30.set_phase_d_handler_raw(Some(phase_d_handler), user_data);
|
||||||
|
t30.set_phase_e_handler_raw(Some(phase_e_handler), user_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure a SpanDSP logging state to route messages to tracing.
|
||||||
|
unsafe fn configure_log_state(log_state: *mut spandsp_sys::logging_state_t) {
|
||||||
|
if log_state.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let log_level = LogLevel::Flow as i32 | LogShowFlags::TAG.bits();
|
||||||
|
spandsp_sys::span_log_set_level(log_state, log_level);
|
||||||
|
spandsp_sys::span_log_set_message_handler(
|
||||||
|
log_state,
|
||||||
|
Some(spandsp_log_handler),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check fax reception completion status from callback state.
|
||||||
|
fn check_completion(state: &FaxCallbackState) -> FaxRxStatus {
|
||||||
|
if state.completed {
|
||||||
|
match spandsp::t30::T30State::completion_code(state.completion_code) {
|
||||||
|
Some(err) if err.is_ok() => FaxRxStatus::Complete,
|
||||||
|
Some(err) => FaxRxStatus::Error(format!("Fax failed: {}", err)),
|
||||||
|
None => FaxRxStatus::Error(format!(
|
||||||
|
"Fax failed with unknown T.30 error code {}",
|
||||||
|
state.completion_code
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
FaxRxStatus::InProgress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract transfer statistics from a T.30 state.
|
||||||
|
fn get_fax_stats(t30: &spandsp::t30::T30State) -> FaxStats {
|
||||||
|
let stats = t30.get_transfer_statistics();
|
||||||
|
FaxStats {
|
||||||
|
bit_rate: stats.bit_rate,
|
||||||
|
pages_rx: stats.pages_rx,
|
||||||
|
image_width: stats.image_width,
|
||||||
|
image_length: stats.image_length,
|
||||||
|
bad_rows: stats.bad_rows,
|
||||||
|
ecm: stats.error_correcting_mode != 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure resampling helpers (extracted for testability)
|
||||||
|
|
||||||
|
/// Downsample 16kHz→8kHz by averaging consecutive pairs.
|
||||||
|
/// Appends `samples` to `buf` (accumulator), drains pairs, returns 8kHz samples.
|
||||||
|
/// Leftover odd samples remain in `buf` for the next call.
|
||||||
|
fn downsample_16k_to_8k(buf: &mut Vec<i16>, samples: &[i16]) -> Vec<i16> {
|
||||||
|
buf.extend_from_slice(samples);
|
||||||
|
let pairs = buf.len() / 2;
|
||||||
|
if pairs == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(pairs);
|
||||||
|
for i in 0..pairs {
|
||||||
|
let a = buf[i * 2] as i32;
|
||||||
|
let b = buf[i * 2 + 1] as i32;
|
||||||
|
out.push(((a + b) / 2) as i16);
|
||||||
|
}
|
||||||
|
let consumed = pairs * 2;
|
||||||
|
buf.drain(..consumed);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upsample 8kHz→16kHz by duplicating each sample.
|
||||||
|
/// Writes to `out`, returns number of 16kHz samples written (= input_len * 2).
|
||||||
|
fn upsample_8k_to_16k(samples_8k: &[i16], out: &mut [i16]) -> usize {
|
||||||
|
for (i, &s) in samples_8k.iter().enumerate() {
|
||||||
|
out[i * 2] = s;
|
||||||
|
out[i * 2 + 1] = s;
|
||||||
|
}
|
||||||
|
samples_8k.len() * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio-based fax receiver
|
||||||
|
|
||||||
|
/// SpanDSP fax receiver — wraps `FaxState` for receiving faxes from audio.
|
||||||
|
pub struct FaxReceiver {
|
||||||
|
fax: FaxState,
|
||||||
|
tiff_path: PathBuf,
|
||||||
|
callback_state: Box<FaxCallbackState>,
|
||||||
|
/// Downsampling buffer: accumulates 16kHz samples, emits 8kHz
|
||||||
|
downsample_buf: Vec<i16>,
|
||||||
|
/// Total 8kHz samples fed to SpanDSP
|
||||||
|
samples_fed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// FaxState is Send (via unsafe impl in the spandsp crate).
|
||||||
|
// Box<FaxCallbackState> and Vec<i16> are Send.
|
||||||
|
// We ensure exclusive access via tokio::sync::Mutex in FaxSession.
|
||||||
|
unsafe impl Send for FaxReceiver {}
|
||||||
|
|
||||||
|
impl FaxReceiver {
|
||||||
|
/// Create a new fax receiver in audio mode.
|
||||||
|
///
|
||||||
|
/// Initializes SpanDSP in receive mode and sets the output TIFF path.
|
||||||
|
pub fn new_audio_receiver(tiff_path: &Path) -> Result<Self> {
|
||||||
|
let tiff_path_str = tiff_path.to_str().context("Invalid TIFF path")?;
|
||||||
|
|
||||||
|
let fax = FaxState::new(false)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to initialize SpanDSP fax state: {}", e))?;
|
||||||
|
|
||||||
|
let t30 = fax
|
||||||
|
.get_t30_state()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to get T.30 state: {}", e))?;
|
||||||
|
|
||||||
|
let mut callback_state = Box::new(FaxCallbackState {
|
||||||
|
negotiation_started: false,
|
||||||
|
pages_received: 0,
|
||||||
|
completion_code: -1,
|
||||||
|
completed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
configure_t30(&t30, tiff_path_str, &mut callback_state)?;
|
||||||
|
|
||||||
|
// Route SpanDSP log messages to tracing.
|
||||||
|
// We use raw spandsp_sys functions since LoggingState doesn't
|
||||||
|
// support borrowed pointers from parent objects safely yet.
|
||||||
|
unsafe {
|
||||||
|
configure_log_state(spandsp_sys::fax_get_logging_state(fax.as_ptr()));
|
||||||
|
configure_log_state(spandsp_sys::t30_get_logging_state(t30.as_ptr()));
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"SpanDSP fax receiver initialized, output: {}",
|
||||||
|
tiff_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
fax,
|
||||||
|
tiff_path: tiff_path.to_path_buf(),
|
||||||
|
callback_state,
|
||||||
|
downsample_buf: Vec::with_capacity(640), // 2 frames worth
|
||||||
|
samples_fed: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed 16kHz mono i16 audio samples (from PJSUA conference bridge).
|
||||||
|
///
|
||||||
|
/// Downsamples to 8kHz and passes to SpanDSP's `fax_rx()`.
|
||||||
|
/// Returns the current reception status.
|
||||||
|
pub fn feed_samples_16k(&mut self, samples: &[i16]) -> FaxRxStatus {
|
||||||
|
let mut downsampled = downsample_16k_to_8k(&mut self.downsample_buf, samples);
|
||||||
|
if downsampled.is_empty() {
|
||||||
|
return self.current_status();
|
||||||
|
}
|
||||||
|
self.feed_samples_8k(&mut downsampled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed 8kHz mono i16 audio samples directly to SpanDSP.
|
||||||
|
fn feed_samples_8k(&mut self, samples: &mut [i16]) -> FaxRxStatus {
|
||||||
|
if samples.is_empty() {
|
||||||
|
return self.current_status();
|
||||||
|
}
|
||||||
|
|
||||||
|
let _result = self.fax.rx(samples);
|
||||||
|
self.samples_fed += samples.len();
|
||||||
|
|
||||||
|
if self.samples_fed.is_multiple_of(80000) {
|
||||||
|
// Log every 10 seconds of audio
|
||||||
|
trace!("SpanDSP fed {}s of audio", self.samples_fed as f64 / 8000.0,);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.current_status()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check the current status based on callback state.
|
||||||
|
fn current_status(&self) -> FaxRxStatus {
|
||||||
|
check_completion(&self.callback_state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of pages received so far.
|
||||||
|
pub fn pages_received(&self) -> u32 {
|
||||||
|
self.callback_state.pages_received
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the output TIFF file path.
|
||||||
|
pub fn tiff_output_path(&self) -> &Path {
|
||||||
|
&self.tiff_path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate transmit audio from SpanDSP (CED tones, T.30 signaling).
|
||||||
|
///
|
||||||
|
/// SpanDSP generates at 8kHz; we upsample to 16kHz for the conference bridge.
|
||||||
|
/// `out_buf` must be large enough for 16kHz samples (e.g., 320 for 20ms).
|
||||||
|
/// Returns the number of 16kHz samples written.
|
||||||
|
pub fn generate_tx_16k(&mut self, out_buf: &mut [i16]) -> usize {
|
||||||
|
let max_8k_samples = out_buf.len() / 2;
|
||||||
|
let mut buf_8k = vec![0i16; max_8k_samples];
|
||||||
|
let generated = self.fax.tx(&mut buf_8k);
|
||||||
|
if generated == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
upsample_8k_to_16k(&buf_8k[..generated], out_buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total seconds of audio fed (at 8kHz).
|
||||||
|
pub fn audio_duration_secs(&self) -> f64 {
|
||||||
|
self.samples_fed as f64 / 8000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get transfer statistics from SpanDSP (for logging).
|
||||||
|
pub fn get_stats(&self) -> Option<FaxStats> {
|
||||||
|
let t30 = self.fax.get_t30_state().ok()?;
|
||||||
|
Some(get_fax_stats(&t30))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// T.38 IFP-based receiver (UDPTL mode)
|
||||||
|
|
||||||
|
/// State passed to the T.38 TX packet handler callback.
|
||||||
|
/// When SpanDSP wants to send an IFP packet, we push it into the mpsc channel.
|
||||||
|
struct TxCallbackState {
|
||||||
|
sender: mpsc::UnboundedSender<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SpanDSP fax receiver using T.38 IFP packets (via T38Terminal).
|
||||||
|
///
|
||||||
|
/// Instead of demodulating audio, this receives IFP packets from the UDPTL
|
||||||
|
/// socket and feeds them to SpanDSP's T38Terminal, which handles the T.30
|
||||||
|
/// protocol directly over T.38.
|
||||||
|
pub struct FaxT38Receiver {
|
||||||
|
terminal: T38Terminal,
|
||||||
|
tiff_path: PathBuf,
|
||||||
|
callback_state: Box<FaxCallbackState>,
|
||||||
|
_tx_callback_state: Box<TxCallbackState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// T38Terminal is Send (via unsafe impl in spandsp-rs crate).
|
||||||
|
// Box<FaxCallbackState> and Box<TxCallbackState> are Send.
|
||||||
|
// We ensure exclusive access via tokio::sync::Mutex in FaxSession.
|
||||||
|
unsafe impl Send for FaxT38Receiver {}
|
||||||
|
|
||||||
|
impl FaxT38Receiver {
|
||||||
|
/// Create a new T.38 fax receiver.
|
||||||
|
///
|
||||||
|
/// `tiff_path`: Where to write the received fax TIFF file.
|
||||||
|
/// `tx_ifp_sender`: Channel for outgoing IFP packets (sent to UDPTL socket).
|
||||||
|
pub fn new(tiff_path: &Path, tx_ifp_sender: mpsc::UnboundedSender<Vec<u8>>) -> Result<Self> {
|
||||||
|
let tiff_path_str = tiff_path.to_str().context("Invalid TIFF path")?;
|
||||||
|
|
||||||
|
let tx_callback_state = Box::new(TxCallbackState {
|
||||||
|
sender: tx_ifp_sender,
|
||||||
|
});
|
||||||
|
let tx_user_data = &*tx_callback_state as *const TxCallbackState as *mut std::ffi::c_void;
|
||||||
|
|
||||||
|
let terminal = unsafe {
|
||||||
|
T38Terminal::new_raw(false, Some(tx_packet_handler), tx_user_data)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to initialize T38Terminal: {}", e))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let t30 = terminal
|
||||||
|
.get_t30_state()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to get T.30 state from T38Terminal: {}", e))?;
|
||||||
|
|
||||||
|
let mut callback_state = Box::new(FaxCallbackState {
|
||||||
|
negotiation_started: false,
|
||||||
|
pages_received: 0,
|
||||||
|
completion_code: -1,
|
||||||
|
completed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
configure_t30(&t30, tiff_path_str, &mut callback_state)?;
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
configure_log_state(spandsp_sys::t38_terminal_get_logging_state(
|
||||||
|
terminal.as_ptr(),
|
||||||
|
));
|
||||||
|
configure_log_state(spandsp_sys::t30_get_logging_state(t30.as_ptr()));
|
||||||
|
|
||||||
|
let t38_core = terminal
|
||||||
|
.get_t38_core_state()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to get T38Core: {}", e))?;
|
||||||
|
configure_log_state(spandsp_sys::t38_core_get_logging_state(t38_core.as_ptr()));
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"T.38 fax receiver initialized, output: {}",
|
||||||
|
tiff_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
terminal,
|
||||||
|
tiff_path: tiff_path.to_path_buf(),
|
||||||
|
callback_state,
|
||||||
|
_tx_callback_state: tx_callback_state,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed a received IFP packet from the UDPTL socket to SpanDSP.
|
||||||
|
pub fn feed_ifp_packet(&self, data: &[u8], seq: u16) -> FaxRxStatus {
|
||||||
|
let t38_core = match self.terminal.get_t38_core_state() {
|
||||||
|
Ok(core) => core,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to get T38Core for rx: {}", e);
|
||||||
|
return FaxRxStatus::Error(format!("T38Core error: {}", e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = t38_core.rx_ifp_packet(data, seq) {
|
||||||
|
warn!("T38Core rx_ifp_packet error: {} (seq={})", e, seq);
|
||||||
|
// Don't return error — packet loss is expected in UDPTL
|
||||||
|
}
|
||||||
|
|
||||||
|
self.current_status()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive the T.38 terminal's timer. Call every 20ms (160 samples at 8kHz).
|
||||||
|
///
|
||||||
|
/// This advances the T.30 state machine. Returns the current reception status.
|
||||||
|
pub fn drive_timer(&self) -> FaxRxStatus {
|
||||||
|
// 160 samples = 20ms at 8kHz
|
||||||
|
let _result = self.terminal.send_timeout(160);
|
||||||
|
self.current_status()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check current status based on T.30 callback state.
|
||||||
|
fn current_status(&self) -> FaxRxStatus {
|
||||||
|
check_completion(&self.callback_state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of pages received so far.
|
||||||
|
pub fn pages_received(&self) -> u32 {
|
||||||
|
self.callback_state.pages_received
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the output TIFF file path.
|
||||||
|
pub fn tiff_output_path(&self) -> &Path {
|
||||||
|
&self.tiff_path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get transfer statistics from SpanDSP.
|
||||||
|
pub fn get_stats(&self) -> Option<FaxStats> {
|
||||||
|
let t30 = self.terminal.get_t30_state().ok()?;
|
||||||
|
Some(get_fax_stats(&t30))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpanDSP C callbacks
|
||||||
|
|
||||||
|
/// T.38 TX packet handler callback.
|
||||||
|
///
|
||||||
|
/// Called by SpanDSP when it wants to send an IFP packet to the remote endpoint.
|
||||||
|
/// We push the packet into an mpsc channel, which the UDPTL socket task reads from.
|
||||||
|
///
|
||||||
|
/// Signature matches `t38_tx_packet_handler_t`:
|
||||||
|
/// `fn(s: *mut t38_core_state_t, user_data: *mut c_void, buf: *const u8, len: i32, count: i32) -> i32`
|
||||||
|
unsafe extern "C" fn tx_packet_handler(
|
||||||
|
_s: *mut spandsp_sys::t38_core_state_t,
|
||||||
|
user_data: *mut std::ffi::c_void,
|
||||||
|
buf: *const u8,
|
||||||
|
len: i32,
|
||||||
|
count: i32,
|
||||||
|
) -> i32 {
|
||||||
|
if user_data.is_null() || buf.is_null() || len <= 0 {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
let state = &*(user_data as *const TxCallbackState);
|
||||||
|
let data = std::slice::from_raw_parts(buf, len as usize);
|
||||||
|
debug!("SpanDSP TX IFP: {}B (count={})", len, count);
|
||||||
|
// Send the packet `count` times as SpanDSP requests.
|
||||||
|
// For indicator packets (CNG, CED, DIS), count is typically 3 — these
|
||||||
|
// must be sent multiple times because early packets have no UDPTL
|
||||||
|
// redundancy history for error recovery.
|
||||||
|
let send_count = count.max(1) as usize;
|
||||||
|
for _ in 0..send_count {
|
||||||
|
match state.sender.send(data.to_vec()) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(_) => {
|
||||||
|
// Channel closed — UDPTL socket task has ended
|
||||||
|
warn!("SpanDSP TX IFP channel closed");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase B handler: called when T.30 negotiation starts.
|
||||||
|
unsafe extern "C" fn phase_b_handler(user_data: *mut std::ffi::c_void, result: i32) -> i32 {
|
||||||
|
if !user_data.is_null() {
|
||||||
|
let state = &mut *(user_data as *mut FaxCallbackState);
|
||||||
|
state.negotiation_started = true;
|
||||||
|
info!(
|
||||||
|
"SpanDSP phase B: fax negotiation started (result={})",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
0 // T30_ERR_OK
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase D handler: called when a page is received.
|
||||||
|
unsafe extern "C" fn phase_d_handler(user_data: *mut std::ffi::c_void, result: i32) -> i32 {
|
||||||
|
if !user_data.is_null() {
|
||||||
|
let state = &mut *(user_data as *mut FaxCallbackState);
|
||||||
|
state.pages_received += 1;
|
||||||
|
info!(
|
||||||
|
"SpanDSP phase D: page {} received (result={})",
|
||||||
|
state.pages_received, result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
0 // T30_ERR_OK
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase E handler: called when fax reception completes (success or failure).
|
||||||
|
unsafe extern "C" fn phase_e_handler(user_data: *mut std::ffi::c_void, completion_code: i32) {
|
||||||
|
if !user_data.is_null() {
|
||||||
|
let state = &mut *(user_data as *mut FaxCallbackState);
|
||||||
|
state.completion_code = completion_code;
|
||||||
|
state.completed = true;
|
||||||
|
|
||||||
|
let reason = match spandsp::t30::T30State::completion_code(completion_code) {
|
||||||
|
Some(err) if err.is_ok() => "OK".to_string(),
|
||||||
|
Some(err) => format!("{}", err),
|
||||||
|
None => format!("unknown code {}", completion_code),
|
||||||
|
};
|
||||||
|
|
||||||
|
if completion_code == 0 {
|
||||||
|
info!(
|
||||||
|
"SpanDSP phase E: fax complete, {} pages received",
|
||||||
|
state.pages_received
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
"SpanDSP phase E: fax failed after {} pages — T.30 error {}: {}",
|
||||||
|
state.pages_received, completion_code, reason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SpanDSP log handler: routes SpanDSP log messages to tracing.
|
||||||
|
unsafe extern "C" fn spandsp_log_handler(
|
||||||
|
_user_data: *mut std::ffi::c_void,
|
||||||
|
level: i32,
|
||||||
|
text: *const std::ffi::c_char,
|
||||||
|
) {
|
||||||
|
if text.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let msg = std::ffi::CStr::from_ptr(text).to_string_lossy();
|
||||||
|
let msg = msg.trim_end(); // SpanDSP messages often have trailing newlines
|
||||||
|
|
||||||
|
match level {
|
||||||
|
l if l <= LogLevel::Error as i32 => error!(target: "spandsp", "{}", msg),
|
||||||
|
l if l <= LogLevel::Warning as i32 => warn!(target: "spandsp", "{}", msg),
|
||||||
|
l if l <= LogLevel::Flow as i32 => debug!(target: "spandsp", "{}", msg),
|
||||||
|
_ => trace!(target: "spandsp", "{}", msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// check_completion tests
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_completion_not_completed_returns_in_progress() {
|
||||||
|
let state = FaxCallbackState {
|
||||||
|
negotiation_started: false,
|
||||||
|
pages_received: 0,
|
||||||
|
completion_code: -1,
|
||||||
|
completed: false,
|
||||||
|
};
|
||||||
|
assert_eq!(check_completion(&state), FaxRxStatus::InProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_completion_completed_code_0_returns_complete() {
|
||||||
|
let state = FaxCallbackState {
|
||||||
|
negotiation_started: true,
|
||||||
|
pages_received: 1,
|
||||||
|
completion_code: 0,
|
||||||
|
completed: true,
|
||||||
|
};
|
||||||
|
assert_eq!(check_completion(&state), FaxRxStatus::Complete);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_completion_completed_bad_code_returns_error() {
|
||||||
|
let state = FaxCallbackState {
|
||||||
|
negotiation_started: true,
|
||||||
|
pages_received: 0,
|
||||||
|
completion_code: 42,
|
||||||
|
completed: true,
|
||||||
|
};
|
||||||
|
match check_completion(&state) {
|
||||||
|
FaxRxStatus::Error(msg) => assert!(
|
||||||
|
msg.contains("42") || msg.contains("failed") || msg.contains("Fax"),
|
||||||
|
"Error message should reference the code: {}",
|
||||||
|
msg
|
||||||
|
),
|
||||||
|
other => panic!("Expected Error, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// downsample_16k_to_8k tests
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downsample_even_count() {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let samples: Vec<i16> = vec![100, 200, 300, 400, 500, 600, 700, 800, 900, 1000];
|
||||||
|
let out = downsample_16k_to_8k(&mut buf, &samples);
|
||||||
|
assert_eq!(out.len(), 5);
|
||||||
|
assert_eq!(out[0], 150); // (100+200)/2
|
||||||
|
assert_eq!(out[1], 350); // (300+400)/2
|
||||||
|
assert_eq!(out[2], 550);
|
||||||
|
assert_eq!(out[3], 750);
|
||||||
|
assert_eq!(out[4], 950);
|
||||||
|
assert!(buf.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downsample_odd_count_preserves_leftover() {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let samples: Vec<i16> = vec![100, 200, 300];
|
||||||
|
let out = downsample_16k_to_8k(&mut buf, &samples);
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0], 150);
|
||||||
|
assert_eq!(buf.len(), 1);
|
||||||
|
assert_eq!(buf[0], 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downsample_sequential_calls_bridge_accumulator() {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
// First call: 3 samples → 1 output, 1 leftover
|
||||||
|
let out1 = downsample_16k_to_8k(&mut buf, &[10, 20, 30]);
|
||||||
|
assert_eq!(out1, vec![15]);
|
||||||
|
assert_eq!(buf, vec![30]);
|
||||||
|
|
||||||
|
// Second call: leftover 30 + new [40, 50] = [30, 40, 50] → 1 output, 1 leftover
|
||||||
|
let out2 = downsample_16k_to_8k(&mut buf, &[40, 50]);
|
||||||
|
assert_eq!(out2, vec![35]); // (30+40)/2
|
||||||
|
assert_eq!(buf, vec![50]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downsample_single_sample_returns_empty() {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let out = downsample_16k_to_8k(&mut buf, &[42]);
|
||||||
|
assert!(out.is_empty());
|
||||||
|
assert_eq!(buf, vec![42]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsample_8k_to_16k tests
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsample_basic() {
|
||||||
|
let input: Vec<i16> = vec![100, 200, 300, 400];
|
||||||
|
let mut out = vec![0i16; 8];
|
||||||
|
let written = upsample_8k_to_16k(&input, &mut out);
|
||||||
|
assert_eq!(written, 8);
|
||||||
|
assert_eq!(out, vec![100, 100, 200, 200, 300, 300, 400, 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsample_empty_input() {
|
||||||
|
let mut out = vec![0i16; 8];
|
||||||
|
let written = upsample_8k_to_16k(&[], &mut out);
|
||||||
|
assert_eq!(written, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
1300
sipcord-bridge/src/fax/tiff_decoder.rs
Normal file
1300
sipcord-bridge/src/fax/tiff_decoder.rs
Normal file
File diff suppressed because it is too large
Load diff
18
sipcord-bridge/src/lib.rs
Normal file
18
sipcord-bridge/src/lib.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
//! Sipcord Bridge - SIP to Discord Voice Bridge
|
||||||
|
//!
|
||||||
|
//! A generic SIP-to-Discord voice bridge library. Provides all the core
|
||||||
|
//! functionality for bridging SIP phone calls to Discord voice channels,
|
||||||
|
//! including fax (G.711 and T.38) support.
|
||||||
|
//!
|
||||||
|
//! Backends implement the `routing::Backend` trait to control call routing
|
||||||
|
//! and authentication. A built-in `StaticBackend` (TOML dialplan) is included.
|
||||||
|
|
||||||
|
#![feature(portable_simd)]
|
||||||
|
|
||||||
|
pub mod audio;
|
||||||
|
pub mod call;
|
||||||
|
pub mod config;
|
||||||
|
pub mod fax;
|
||||||
|
pub mod routing;
|
||||||
|
pub mod services;
|
||||||
|
pub mod transport;
|
||||||
126
sipcord-bridge/src/main.rs
Normal file
126
sipcord-bridge/src/main.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
//! Sipcord Bridge - Static Router Binary
|
||||||
|
//!
|
||||||
|
//! Standalone SIP-to-Discord voice bridge using a TOML dialplan.
|
||||||
|
|
||||||
|
#![feature(portable_simd)]
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use tracing::{error, info};
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
use sipcord_bridge::call::BridgeCoordinator;
|
||||||
|
use sipcord_bridge::config::{AppConfig, EnvConfig, SipConfig, APP_CONFIG};
|
||||||
|
use sipcord_bridge::routing::static_router::StaticBackend;
|
||||||
|
use sipcord_bridge::transport::discord::SharedDiscordClient;
|
||||||
|
use sipcord_bridge::transport::sip::SipTransport;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
rustls::crypto::ring::default_provider()
|
||||||
|
.install_default()
|
||||||
|
.expect("Failed to install rustls crypto provider");
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "sipcord_bridge=info,pjsip=warn".into()),
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
info!("Starting Sipcord Bridge v{}", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
EnvConfig::init()?;
|
||||||
|
|
||||||
|
let config_path = PathBuf::from(&EnvConfig::global().config_path);
|
||||||
|
let app_config = AppConfig::load(&config_path)?;
|
||||||
|
APP_CONFIG
|
||||||
|
.set(app_config)
|
||||||
|
.expect("AppConfig already initialized");
|
||||||
|
info!("Loaded config from {}", config_path.display());
|
||||||
|
|
||||||
|
run_static_router().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_static_router() -> Result<()> {
|
||||||
|
let bot_token = EnvConfig::global()
|
||||||
|
.discord_bot_token
|
||||||
|
.clone()
|
||||||
|
.context("DISCORD_BOT_TOKEN required")?;
|
||||||
|
let sip_config = SipConfig::from_env()?;
|
||||||
|
|
||||||
|
// Load dialplan
|
||||||
|
let dialplan_path = PathBuf::from(&EnvConfig::global().dialplan_path);
|
||||||
|
let backend = Arc::new(StaticBackend::load(&dialplan_path, bot_token.clone())?);
|
||||||
|
|
||||||
|
// Create SIP transport (no TLS for static router)
|
||||||
|
let sip_transport = SipTransport::new(sip_config.clone(), None);
|
||||||
|
let sip_event_tx = sip_transport.event_sender();
|
||||||
|
|
||||||
|
// Create channel for outbound call events (SIP callbacks still emit these)
|
||||||
|
let (outbound_event_tx, mut outbound_event_rx) = tokio::sync::mpsc::channel(100);
|
||||||
|
sipcord_bridge::transport::sip::set_outbound_event_sender(outbound_event_tx);
|
||||||
|
|
||||||
|
// Forward outbound call events to the main SIP event channel
|
||||||
|
let sip_event_tx_for_outbound = sip_event_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(event) = outbound_event_rx.recv().await {
|
||||||
|
let _ = sip_event_tx_for_outbound.send(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create shared Discord client
|
||||||
|
let shared_discord = SharedDiscordClient::new(&bot_token)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create shared Discord client");
|
||||||
|
info!("Shared Discord client initialized");
|
||||||
|
|
||||||
|
let bridge = BridgeCoordinator::new(
|
||||||
|
backend,
|
||||||
|
sip_transport.commands(),
|
||||||
|
sip_transport.events(),
|
||||||
|
shared_discord,
|
||||||
|
);
|
||||||
|
|
||||||
|
info!("Starting components...");
|
||||||
|
|
||||||
|
let mut sip_handle = tokio::spawn(async move {
|
||||||
|
if let Err(e) = sip_transport.run().await {
|
||||||
|
error!("SIP server error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut bridge_handle = tokio::spawn(async move {
|
||||||
|
if let Err(e) = bridge.run().await {
|
||||||
|
error!("Bridge coordinator error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Static router running on {}:{}",
|
||||||
|
sip_config.public_host, sip_config.port
|
||||||
|
);
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::signal::ctrl_c() => info!("Shutdown signal received"),
|
||||||
|
sip_res = &mut sip_handle => { if let Err(e) = sip_res { error!("SIP task failed: {}", e); } },
|
||||||
|
bridge_res = &mut bridge_handle => { if let Err(e) = bridge_res { error!("Bridge task failed: {}", e); } },
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Shutting down...");
|
||||||
|
|
||||||
|
std::thread::spawn(|| {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||||
|
std::process::exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
sip_handle.abort();
|
||||||
|
bridge_handle.abort();
|
||||||
|
sipcord_bridge::transport::sip::shutdown_pjsua();
|
||||||
|
|
||||||
|
info!("Shutdown complete");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
101
sipcord-bridge/src/routing/mod.rs
Normal file
101
sipcord-bridge/src/routing/mod.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
pub mod static_router;
|
||||||
|
|
||||||
|
use crate::services::snowflake::Snowflake;
|
||||||
|
use crate::transport::sip::DigestAuthParams;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
/// Outbound call request from the backend (e.g., Discord /call command)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OutboundCallRequest {
|
||||||
|
pub call_id: String,
|
||||||
|
pub discord_username: String,
|
||||||
|
pub guild_id: String,
|
||||||
|
pub channel_id: String,
|
||||||
|
pub bot_token: String,
|
||||||
|
pub caller_username: String,
|
||||||
|
pub created_at: std::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of routing an incoming SIP call
|
||||||
|
pub enum RouteDecision {
|
||||||
|
/// Connect to this Discord voice channel
|
||||||
|
Connect {
|
||||||
|
channel_id: Snowflake,
|
||||||
|
guild_id: Snowflake,
|
||||||
|
user_id: String,
|
||||||
|
bot_token: String,
|
||||||
|
},
|
||||||
|
/// Handle as incoming fax — post to a Discord text channel
|
||||||
|
ConnectFax {
|
||||||
|
text_channel_id: Snowflake,
|
||||||
|
guild_id: Snowflake,
|
||||||
|
user_id: String,
|
||||||
|
bot_token: String,
|
||||||
|
},
|
||||||
|
/// Redirect to another bridge server
|
||||||
|
Redirect { domain: String, extension: String },
|
||||||
|
/// Reject with invalid credentials (no error sound, just hangup)
|
||||||
|
RejectInvalidCredentials,
|
||||||
|
/// Play an error sound and hangup
|
||||||
|
RejectWithError { error: CallError },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors that trigger audio playback before hangup
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum CallError {
|
||||||
|
NoChannelMapping,
|
||||||
|
NoPermissions,
|
||||||
|
DiscordApiError,
|
||||||
|
ServerBusy,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CallError {
|
||||||
|
/// Get the sound name for this error type
|
||||||
|
pub fn sound_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
CallError::NoChannelMapping => "no_channel_mapping",
|
||||||
|
CallError::NoPermissions => "no_permissions",
|
||||||
|
CallError::DiscordApiError => "server_is_busy",
|
||||||
|
CallError::ServerBusy => "server_is_busy",
|
||||||
|
CallError::Unknown => "unknown_error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Info about a call that just started (for backend tracking)
|
||||||
|
pub struct CallStartedInfo {
|
||||||
|
pub sip_call_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub guild_id: String,
|
||||||
|
pub channel_id: String,
|
||||||
|
pub extension: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The routing backend — tells the bridge who to connect and when.
|
||||||
|
///
|
||||||
|
/// This is the open-source boundary: the core bridge knows how to connect
|
||||||
|
/// SIP <-> Discord audio. The Backend tells it *who* to connect and *when*.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Backend: Send + Sync {
|
||||||
|
/// Get the Discord bot token
|
||||||
|
fn bot_token(&self) -> &str;
|
||||||
|
|
||||||
|
/// Route an incoming SIP call (authenticate + get destination)
|
||||||
|
async fn route_call(&self, digest_auth: &DigestAuthParams, extension: &str) -> RouteDecision;
|
||||||
|
|
||||||
|
/// Notify that a call has started
|
||||||
|
async fn on_call_started(&self, info: &CallStartedInfo);
|
||||||
|
|
||||||
|
/// Notify that a call has ended
|
||||||
|
async fn on_call_ended(&self, sip_call_id: &str);
|
||||||
|
|
||||||
|
/// Send heartbeat for active channels
|
||||||
|
async fn heartbeat(&self, active_channel_ids: &[String]);
|
||||||
|
|
||||||
|
/// Report outbound call status back to the backend
|
||||||
|
fn report_call_status(&self, call_id: &str, status: &str);
|
||||||
|
|
||||||
|
/// Get the next outbound call request (None if backend doesn't support outbound)
|
||||||
|
async fn next_outbound_request(&self) -> Option<OutboundCallRequest>;
|
||||||
|
}
|
||||||
208
sipcord-bridge/src/routing/static_router.rs
Normal file
208
sipcord-bridge/src/routing/static_router.rs
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
//! Static dialplan router — routes calls based on a TOML file.
|
||||||
|
//!
|
||||||
|
//! This is the open-source-friendly backend that doesn't require the SIPcord API.
|
||||||
|
//! It reads a `dialplan.toml` file mapping extensions to Discord voice channels.
|
||||||
|
//!
|
||||||
|
//! Required env var: `DISCORD_BOT_TOKEN`
|
||||||
|
//!
|
||||||
|
//! Example `dialplan.toml`:
|
||||||
|
//! ```toml
|
||||||
|
//! [extensions]
|
||||||
|
//! 1000 = { guild = 123456789012345678, channel = 987654321012345678 }
|
||||||
|
//! 2000 = { guild = 123456789012345678, channel = 111222333444555666 }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::routing::{Backend, CallError, CallStartedInfo, OutboundCallRequest, RouteDecision};
|
||||||
|
use crate::services::snowflake::Snowflake;
|
||||||
|
use crate::transport::sip::DigestAuthParams;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
struct ExtensionTarget {
|
||||||
|
guild: Snowflake,
|
||||||
|
channel: Snowflake,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Dialplan {
|
||||||
|
extensions: HashMap<String, ExtensionTarget>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Static file-based routing backend.
|
||||||
|
///
|
||||||
|
/// Routes calls by looking up the dialed extension in a TOML dialplan file.
|
||||||
|
/// No authentication is performed — any caller dialing a known extension is connected.
|
||||||
|
/// Outbound calls are not supported.
|
||||||
|
pub struct StaticBackend {
|
||||||
|
bot_token: String,
|
||||||
|
extensions: HashMap<String, ExtensionTarget>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StaticBackend {
|
||||||
|
/// Load the dialplan from a TOML file. `bot_token` comes from the environment.
|
||||||
|
pub fn load(path: &Path, bot_token: String) -> anyhow::Result<Self> {
|
||||||
|
let content = std::fs::read_to_string(path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to read {}: {}", path.display(), e))?;
|
||||||
|
let dialplan: Dialplan = toml::from_str(&content)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", path.display(), e))?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Loaded dialplan from {} ({} extensions)",
|
||||||
|
path.display(),
|
||||||
|
dialplan.extensions.len(),
|
||||||
|
);
|
||||||
|
for (ext, target) in &dialplan.extensions {
|
||||||
|
info!(
|
||||||
|
" ext {} -> guild {} channel {}",
|
||||||
|
ext, target.guild, target.channel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
bot_token,
|
||||||
|
extensions: dialplan.extensions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Backend for StaticBackend {
|
||||||
|
fn bot_token(&self) -> &str {
|
||||||
|
&self.bot_token
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn route_call(&self, _digest_auth: &DigestAuthParams, extension: &str) -> RouteDecision {
|
||||||
|
match self.extensions.get(extension) {
|
||||||
|
Some(target) => RouteDecision::Connect {
|
||||||
|
channel_id: target.channel,
|
||||||
|
guild_id: target.guild,
|
||||||
|
user_id: "static".to_string(),
|
||||||
|
bot_token: self.bot_token.clone(),
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
tracing::warn!("Extension {} not found in dialplan", extension);
|
||||||
|
RouteDecision::RejectWithError {
|
||||||
|
error: CallError::NoChannelMapping,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_call_started(&self, info: &CallStartedInfo) {
|
||||||
|
info!(
|
||||||
|
"Call started: {} -> channel {} (ext {})",
|
||||||
|
info.sip_call_id, info.channel_id, info.extension
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_call_ended(&self, sip_call_id: &str) {
|
||||||
|
info!("Call ended: {}", sip_call_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn heartbeat(&self, _active_channel_ids: &[String]) {}
|
||||||
|
|
||||||
|
fn report_call_status(&self, _call_id: &str, _status: &str) {}
|
||||||
|
|
||||||
|
async fn next_outbound_request(&self) -> Option<OutboundCallRequest> {
|
||||||
|
// Static router doesn't support outbound calls — block forever
|
||||||
|
std::future::pending().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_valid_dialplan() {
|
||||||
|
let toml_content = r#"
|
||||||
|
[extensions]
|
||||||
|
1000 = { guild = 123456789012345678, channel = 987654321012345678 }
|
||||||
|
2000 = { guild = 123456789012345678, channel = 111222333444555666 }
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir().join("sipcord_test_dialplan");
|
||||||
|
std::fs::create_dir_all(&dir).ok();
|
||||||
|
let path = dir.join("test_dialplan.toml");
|
||||||
|
std::fs::write(&path, toml_content).unwrap();
|
||||||
|
|
||||||
|
let backend = StaticBackend::load(&path, "test_token".to_string()).unwrap();
|
||||||
|
assert_eq!(backend.extensions.len(), 2);
|
||||||
|
assert!(backend.extensions.contains_key("1000"));
|
||||||
|
assert!(backend.extensions.contains_key("2000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_route_known_extension() {
|
||||||
|
let toml_content = r#"
|
||||||
|
[extensions]
|
||||||
|
1000 = { guild = 111, channel = 222 }
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir().join("sipcord_test_dialplan");
|
||||||
|
std::fs::create_dir_all(&dir).ok();
|
||||||
|
let path = dir.join("test_route.toml");
|
||||||
|
std::fs::write(&path, toml_content).unwrap();
|
||||||
|
|
||||||
|
let backend = StaticBackend::load(&path, "tok".to_string()).unwrap();
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
rt.block_on(async {
|
||||||
|
let decision = backend
|
||||||
|
.route_call(&DigestAuthParams::default(), "1000")
|
||||||
|
.await;
|
||||||
|
match decision {
|
||||||
|
RouteDecision::Connect { channel_id, .. } => {
|
||||||
|
assert_eq!(channel_id, Snowflake::new(222));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Connect"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_route_unknown_extension() {
|
||||||
|
let toml_content = r#"
|
||||||
|
[extensions]
|
||||||
|
1000 = { guild = 111, channel = 222 }
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir().join("sipcord_test_dialplan");
|
||||||
|
std::fs::create_dir_all(&dir).ok();
|
||||||
|
let path = dir.join("test_route_unknown.toml");
|
||||||
|
std::fs::write(&path, toml_content).unwrap();
|
||||||
|
|
||||||
|
let backend = StaticBackend::load(&path, "tok".to_string()).unwrap();
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
rt.block_on(async {
|
||||||
|
let decision = backend
|
||||||
|
.route_call(&DigestAuthParams::default(), "9999")
|
||||||
|
.await;
|
||||||
|
match decision {
|
||||||
|
RouteDecision::RejectWithError { error } => {
|
||||||
|
assert!(matches!(error, CallError::NoChannelMapping));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected RejectWithError"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_malformed_toml() {
|
||||||
|
let dir = std::env::temp_dir().join("sipcord_test_dialplan");
|
||||||
|
std::fs::create_dir_all(&dir).ok();
|
||||||
|
let path = dir.join("test_bad.toml");
|
||||||
|
std::fs::write(&path, "this is not valid toml [[[").unwrap();
|
||||||
|
|
||||||
|
let result = StaticBackend::load(&path, "tok".to_string());
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
274
sipcord-bridge/src/services/auth_cache.rs
Normal file
274
sipcord-bridge/src/services/auth_cache.rs
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
//! SIP credential cache for local digest auth verification
|
||||||
|
//!
|
||||||
|
//! Caches HA1 hashes returned by the API so that repeat REGISTER requests
|
||||||
|
//! can be verified locally without an API round-trip. On cache miss or
|
||||||
|
//! verification failure, falls through to the API.
|
||||||
|
//!
|
||||||
|
//! Also tracks consecutive auth failures per username to rate-limit
|
||||||
|
//! users with bad credentials (429 cooldown after N failures).
|
||||||
|
|
||||||
|
use md5::{Digest, Md5};
|
||||||
|
use moka::sync::Cache;
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::transport::sip::DigestAuthParams;
|
||||||
|
|
||||||
|
/// Global auth cache instance accessible from C callbacks
|
||||||
|
static AUTH_CACHE: OnceLock<Arc<AuthCache>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Result of checking digest auth against the cache
|
||||||
|
pub enum VerifyResult {
|
||||||
|
/// Cache hit and credentials verified successfully
|
||||||
|
Verified,
|
||||||
|
/// Cache had an entry but credentials didn't match (wrong password or stale cache)
|
||||||
|
Mismatch,
|
||||||
|
/// No cache entry for this username
|
||||||
|
Miss,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Data returned from a successful REGISTER authentication
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RegisterData {
|
||||||
|
pub sip_username: String,
|
||||||
|
/// None if user has allow_inbound_calls disabled
|
||||||
|
pub discord_username: Option<String>,
|
||||||
|
/// Pre-computed HA1 hash for caching
|
||||||
|
pub ha1: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached credential entry for a SIP user
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct CachedAuth {
|
||||||
|
/// Pre-computed MD5(username:sipcord:password)
|
||||||
|
pub ha1: String,
|
||||||
|
/// Cached registration data
|
||||||
|
pub register_data: RegisterData,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory credential cache with TTL
|
||||||
|
pub struct AuthCache {
|
||||||
|
cache: Cache<String, CachedAuth>,
|
||||||
|
/// Consecutive auth failure count per username (TTL = cooldown period)
|
||||||
|
failures: Cache<String, u32>,
|
||||||
|
/// Number of failures before cooldown kicks in
|
||||||
|
max_failures: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthCache {
|
||||||
|
/// Create a new cache with the given TTL for entries
|
||||||
|
pub fn new(ttl: Duration, failure_cooldown: Duration, max_failures: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
cache: Cache::builder()
|
||||||
|
.time_to_live(ttl)
|
||||||
|
.max_capacity(10_000)
|
||||||
|
.build(),
|
||||||
|
failures: Cache::builder()
|
||||||
|
.time_to_live(failure_cooldown)
|
||||||
|
.max_capacity(10_000)
|
||||||
|
.build(),
|
||||||
|
max_failures,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set this cache as the global instance
|
||||||
|
pub fn set_global(cache: Arc<AuthCache>) {
|
||||||
|
let _ = AUTH_CACHE.set(cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the global auth cache instance
|
||||||
|
pub fn global() -> Option<&'static Arc<AuthCache>> {
|
||||||
|
AUTH_CACHE.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a failed auth attempt, returns the new failure count
|
||||||
|
pub fn record_failure(&self, username: &str) -> u32 {
|
||||||
|
let count = self.failures.get(username).unwrap_or(0) + 1;
|
||||||
|
self.failures.insert(username.to_string(), count);
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear failure count on successful auth
|
||||||
|
pub fn clear_failures(&self, username: &str) {
|
||||||
|
self.failures.invalidate(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a username is in auth cooldown (too many failures)
|
||||||
|
pub fn is_in_cooldown(&self, username: &str) -> bool {
|
||||||
|
self.failures.get(username).unwrap_or(0) >= self.max_failures
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to verify digest auth locally using cached HA1.
|
||||||
|
/// Returns Some(cached_data) on success, None on miss or mismatch.
|
||||||
|
pub fn verify(&self, digest: &DigestAuthParams) -> Option<CachedAuth> {
|
||||||
|
let cached = self.cache.get(&digest.username)?;
|
||||||
|
|
||||||
|
if verify_digest_with_ha1(&cached.ha1, digest) {
|
||||||
|
Some(cached)
|
||||||
|
} else {
|
||||||
|
// Mismatch - password may have changed, evict stale entry
|
||||||
|
self.cache.invalidate(&digest.username);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check digest auth against the cache, distinguishing miss from mismatch.
|
||||||
|
pub fn check(&self, digest: &DigestAuthParams) -> VerifyResult {
|
||||||
|
match self.cache.get(&digest.username) {
|
||||||
|
Some(cached) => {
|
||||||
|
if verify_digest_with_ha1(&cached.ha1, digest) {
|
||||||
|
VerifyResult::Verified
|
||||||
|
} else {
|
||||||
|
self.cache.invalidate(&digest.username);
|
||||||
|
VerifyResult::Mismatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => VerifyResult::Miss,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a successful auth result in the cache
|
||||||
|
pub fn insert(&self, username: &str, ha1: &str, register_data: RegisterData) {
|
||||||
|
self.cache.insert(
|
||||||
|
username.to_string(),
|
||||||
|
CachedAuth {
|
||||||
|
ha1: ha1.to_string(),
|
||||||
|
register_data,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute MD5 hex digest of a string
|
||||||
|
fn md5_hex(input: &str) -> String {
|
||||||
|
let mut hasher = Md5::new();
|
||||||
|
hasher.update(input.as_bytes());
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify SIP digest auth using a pre-computed HA1 hash
|
||||||
|
fn verify_digest_with_ha1(ha1: &str, params: &DigestAuthParams) -> bool {
|
||||||
|
let ha2 = md5_hex(&format!("{}:{}", params.method, params.uri));
|
||||||
|
|
||||||
|
let expected = match (¶ms.qop, ¶ms.nc, ¶ms.cnonce) {
|
||||||
|
(Some(qop), Some(nc), Some(cnonce)) if qop == "auth" => md5_hex(&format!(
|
||||||
|
"{}:{}:{}:{}:{}:{}",
|
||||||
|
ha1, params.nonce, nc, cnonce, qop, ha2
|
||||||
|
)),
|
||||||
|
_ => md5_hex(&format!("{}:{}:{}", ha1, params.nonce, ha2)),
|
||||||
|
};
|
||||||
|
|
||||||
|
params.response.eq_ignore_ascii_case(&expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_md5_hex_empty() {
|
||||||
|
assert_eq!(md5_hex(""), "d41d8cd98f00b204e9800998ecf8427e");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_md5_hex_hello() {
|
||||||
|
assert_eq!(md5_hex("hello"), "5d41402abc4b2a76b9719d911017c592");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_digest_without_qop() {
|
||||||
|
// Compute expected values manually
|
||||||
|
let ha1 = md5_hex("alice:sipcord:password123");
|
||||||
|
let ha2 = md5_hex("REGISTER:sip:sipcord");
|
||||||
|
let nonce = "dcd98b7102dd2f0e8b11d0f600bfb0c093";
|
||||||
|
let response = md5_hex(&format!("{}:{}:{}", ha1, nonce, ha2));
|
||||||
|
|
||||||
|
let params = DigestAuthParams {
|
||||||
|
username: "alice".to_string(),
|
||||||
|
realm: "sipcord".to_string(),
|
||||||
|
nonce: nonce.to_string(),
|
||||||
|
uri: "sip:sipcord".to_string(),
|
||||||
|
response,
|
||||||
|
method: "REGISTER".to_string(),
|
||||||
|
qop: None,
|
||||||
|
nc: None,
|
||||||
|
cnonce: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(verify_digest_with_ha1(&ha1, ¶ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_digest_with_qop_auth() {
|
||||||
|
let ha1 = md5_hex("bob:sipcord:secret");
|
||||||
|
let ha2 = md5_hex("REGISTER:sip:sipcord");
|
||||||
|
let nonce = "abc123";
|
||||||
|
let nc = "00000001";
|
||||||
|
let cnonce = "0a4f113b";
|
||||||
|
let response = md5_hex(&format!("{}:{}:{}:{}:auth:{}", ha1, nonce, nc, cnonce, ha2));
|
||||||
|
|
||||||
|
let params = DigestAuthParams {
|
||||||
|
username: "bob".to_string(),
|
||||||
|
realm: "sipcord".to_string(),
|
||||||
|
nonce: nonce.to_string(),
|
||||||
|
uri: "sip:sipcord".to_string(),
|
||||||
|
response,
|
||||||
|
method: "REGISTER".to_string(),
|
||||||
|
qop: Some("auth".to_string()),
|
||||||
|
nc: Some(nc.to_string()),
|
||||||
|
cnonce: Some(cnonce.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(verify_digest_with_ha1(&ha1, ¶ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_digest_wrong_response() {
|
||||||
|
let ha1 = md5_hex("alice:sipcord:password123");
|
||||||
|
let params = DigestAuthParams {
|
||||||
|
username: "alice".to_string(),
|
||||||
|
realm: "sipcord".to_string(),
|
||||||
|
nonce: "nonce".to_string(),
|
||||||
|
uri: "sip:sipcord".to_string(),
|
||||||
|
response: "0000000000000000000000000000dead".to_string(),
|
||||||
|
method: "REGISTER".to_string(),
|
||||||
|
qop: None,
|
||||||
|
nc: None,
|
||||||
|
cnonce: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!verify_digest_with_ha1(&ha1, ¶ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_cache_record_failure() {
|
||||||
|
let cache = AuthCache::new(Duration::from_secs(300), Duration::from_secs(60), 3);
|
||||||
|
assert_eq!(cache.record_failure("user1"), 1);
|
||||||
|
assert_eq!(cache.record_failure("user1"), 2);
|
||||||
|
assert_eq!(cache.record_failure("user1"), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_cache_clear_failures() {
|
||||||
|
let cache = AuthCache::new(Duration::from_secs(300), Duration::from_secs(60), 3);
|
||||||
|
cache.record_failure("user1");
|
||||||
|
cache.record_failure("user1");
|
||||||
|
cache.clear_failures("user1");
|
||||||
|
assert!(!cache.is_in_cooldown("user1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_cache_cooldown_threshold() {
|
||||||
|
let cache = AuthCache::new(Duration::from_secs(300), Duration::from_secs(60), 3);
|
||||||
|
assert!(!cache.is_in_cooldown("user1"));
|
||||||
|
cache.record_failure("user1");
|
||||||
|
cache.record_failure("user1");
|
||||||
|
assert!(!cache.is_in_cooldown("user1"));
|
||||||
|
cache.record_failure("user1");
|
||||||
|
assert!(cache.is_in_cooldown("user1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
58
sipcord-bridge/src/services/ban.rs
Normal file
58
sipcord-bridge/src/services/ban.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
//! Ban system trait definition
|
||||||
|
//!
|
||||||
|
//! The trait is defined here so FFI callbacks in the library can call ban checks.
|
||||||
|
//! When no implementation is registered (e.g. standalone/static-router mode),
|
||||||
|
//! ban checks are simply skipped.
|
||||||
|
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
|
|
||||||
|
/// Result of checking/recording a ban
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct BanCheckResult {
|
||||||
|
/// Current offense level for this IP (progressive timeout key)
|
||||||
|
pub offense_level: u32,
|
||||||
|
/// Whether the IP is currently timed out or banned
|
||||||
|
pub is_banned: bool,
|
||||||
|
/// Whether this is a permanent ban (vs progressive timeout)
|
||||||
|
pub is_permanent: bool,
|
||||||
|
/// Timeout duration in seconds (0 if not timed out)
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
/// Whether we should log this attempt
|
||||||
|
pub should_log: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of clearing all ban data
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ClearResult {
|
||||||
|
pub bans_cleared: u64,
|
||||||
|
pub registers_cleared: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trait for ban checking — implemented by the adapter, consumed by FFI callbacks
|
||||||
|
pub trait BanCheck: Send + Sync {
|
||||||
|
fn is_enabled(&self) -> bool;
|
||||||
|
fn is_whitelisted(&self, ip: &IpAddr) -> bool;
|
||||||
|
fn check_banned(&self, ip: &IpAddr) -> BanCheckResult;
|
||||||
|
fn record_offense(&self, ip: IpAddr, reason: &str) -> BanCheckResult;
|
||||||
|
fn record_permanent_ban(&self, ip: IpAddr, reason: &str) -> BanCheckResult;
|
||||||
|
/// Record a REGISTER request from an IP. Returns true if rate limited.
|
||||||
|
fn record_register(&self, ip: IpAddr) -> bool;
|
||||||
|
fn clear_all(&self) -> Result<ClearResult, Box<dyn std::error::Error + Send + Sync>>;
|
||||||
|
/// Config accessors for extension-length checks in callbacks
|
||||||
|
fn suspicious_extension_min_length(&self) -> usize;
|
||||||
|
fn suspicious_extension_max_length(&self) -> usize;
|
||||||
|
fn permaban_extension_min_length(&self) -> usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
static GLOBAL_BAN_CHECK: OnceLock<Arc<dyn BanCheck>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Register a global ban checker (called by the adapter at init time)
|
||||||
|
pub fn set_global(checker: Arc<dyn BanCheck>) {
|
||||||
|
let _ = GLOBAL_BAN_CHECK.set(checker);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the global ban checker (None if not registered)
|
||||||
|
pub fn global() -> Option<&'static Arc<dyn BanCheck>> {
|
||||||
|
GLOBAL_BAN_CHECK.get()
|
||||||
|
}
|
||||||
5
sipcord-bridge/src/services/mod.rs
Normal file
5
sipcord-bridge/src/services/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod auth_cache;
|
||||||
|
pub mod ban;
|
||||||
|
pub mod registrar;
|
||||||
|
pub mod snowflake;
|
||||||
|
pub mod sound;
|
||||||
321
sipcord-bridge/src/services/registrar.rs
Normal file
321
sipcord-bridge/src/services/registrar.rs
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
//! SIP Registration Storage
|
||||||
|
//!
|
||||||
|
//! Tracks SIP REGISTER'ed users so we know which phones are online
|
||||||
|
//! and can route inbound calls (Discord -> SIP) to them.
|
||||||
|
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// Global registrar instance (set during initialization)
|
||||||
|
pub static GLOBAL_REGISTRAR: OnceLock<Arc<Registrar>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Transport protocol used for a SIP registration
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum SipTransport {
|
||||||
|
Udp,
|
||||||
|
Tcp,
|
||||||
|
Tls,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single SIP registration (one phone/device)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Registration {
|
||||||
|
pub sip_username: String,
|
||||||
|
/// None if user has allow_inbound_calls disabled
|
||||||
|
pub discord_username: Option<String>,
|
||||||
|
/// From Contact header (client-advertised URI)
|
||||||
|
pub contact_uri: String,
|
||||||
|
/// Actual transport source (for NAT traversal)
|
||||||
|
pub source_addr: SocketAddr,
|
||||||
|
/// Transport protocol used to register
|
||||||
|
pub transport: SipTransport,
|
||||||
|
/// When this registration expires
|
||||||
|
pub expires_at: Instant,
|
||||||
|
/// When this registration was created/refreshed
|
||||||
|
pub registered_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages SIP registrations for all users
|
||||||
|
pub struct Registrar {
|
||||||
|
/// SIP username -> list of registrations (multiple phones per user)
|
||||||
|
registrations: DashMap<String, Vec<Registration>>,
|
||||||
|
/// Discord username -> SIP username (reverse lookup for inbound calls)
|
||||||
|
discord_to_sip: DashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Registrar {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
registrations: DashMap::new(),
|
||||||
|
discord_to_sip: DashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add or update a registration.
|
||||||
|
pub fn add_registration(&self, reg: Registration) {
|
||||||
|
let sip_username = reg.sip_username.clone();
|
||||||
|
let discord_username = reg.discord_username.clone();
|
||||||
|
|
||||||
|
// Update or insert into registrations
|
||||||
|
let mut regs = self.registrations.entry(sip_username.clone()).or_default();
|
||||||
|
|
||||||
|
// Check if this source_addr already has a registration - update it
|
||||||
|
if let Some(existing) = regs
|
||||||
|
.iter_mut()
|
||||||
|
.find(|r| r.source_addr == reg.source_addr && r.contact_uri == reg.contact_uri)
|
||||||
|
{
|
||||||
|
// If discord_username changed, remove the old reverse mapping
|
||||||
|
if existing.discord_username != reg.discord_username {
|
||||||
|
if let Some(ref old_du) = existing.discord_username {
|
||||||
|
self.discord_to_sip.remove(old_du);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.expires_at = reg.expires_at;
|
||||||
|
existing.registered_at = reg.registered_at;
|
||||||
|
existing.contact_uri = reg.contact_uri.clone();
|
||||||
|
existing.discord_username = reg.discord_username.clone();
|
||||||
|
|
||||||
|
// Update reverse lookup if discord_username is set
|
||||||
|
if let Some(ref du) = discord_username {
|
||||||
|
self.discord_to_sip.insert(du.clone(), sip_username.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
regs.push(reg);
|
||||||
|
drop(regs);
|
||||||
|
|
||||||
|
// Update reverse lookup
|
||||||
|
if let Some(ref du) = discord_username {
|
||||||
|
self.discord_to_sip.insert(du.clone(), sip_username.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove expired registrations.
|
||||||
|
pub fn remove_expired(&self) {
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
let mut to_clean = Vec::new();
|
||||||
|
for entry in self.registrations.iter() {
|
||||||
|
let sip_username = entry.key().clone();
|
||||||
|
let has_expired = entry.value().iter().any(|r| r.expires_at <= now);
|
||||||
|
if has_expired {
|
||||||
|
to_clean.push(sip_username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for sip_username in to_clean {
|
||||||
|
if let Some(mut regs) = self.registrations.get_mut(&sip_username) {
|
||||||
|
let discord_username_before = regs.iter().find_map(|r| r.discord_username.clone());
|
||||||
|
|
||||||
|
regs.retain(|r| r.expires_at > now);
|
||||||
|
|
||||||
|
if regs.is_empty() {
|
||||||
|
drop(regs);
|
||||||
|
self.registrations.remove(&sip_username);
|
||||||
|
|
||||||
|
// Clean up reverse lookup
|
||||||
|
if let Some(du) = discord_username_before {
|
||||||
|
self.discord_to_sip.remove(&du);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get source addresses for a SIP user (for debug capture)
|
||||||
|
pub fn get_source_addrs_for_sip_user(&self, sip_username: &str) -> Vec<SocketAddr> {
|
||||||
|
let now = Instant::now();
|
||||||
|
match self.registrations.get(sip_username) {
|
||||||
|
Some(regs) => regs
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.expires_at > now)
|
||||||
|
.map(|r| r.source_addr)
|
||||||
|
.collect(),
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get contacts for a Discord user (for inbound calling)
|
||||||
|
pub fn get_contacts_for_discord_user(
|
||||||
|
&self,
|
||||||
|
discord_username: &str,
|
||||||
|
) -> Vec<(String, SocketAddr, SipTransport)> {
|
||||||
|
let sip_username = match self.discord_to_sip.get(discord_username) {
|
||||||
|
Some(entry) => entry.value().clone(),
|
||||||
|
None => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
match self.registrations.get(&sip_username) {
|
||||||
|
Some(regs) => regs
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.expires_at > now)
|
||||||
|
.map(|r| (r.contact_uri.clone(), r.source_addr, r.transport))
|
||||||
|
.collect(),
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
fn make_reg(
|
||||||
|
sip_user: &str,
|
||||||
|
discord_user: Option<&str>,
|
||||||
|
addr: &str,
|
||||||
|
contact: &str,
|
||||||
|
expires_secs: u64,
|
||||||
|
) -> Registration {
|
||||||
|
Registration {
|
||||||
|
sip_username: sip_user.to_string(),
|
||||||
|
discord_username: discord_user.map(|s| s.to_string()),
|
||||||
|
contact_uri: contact.to_string(),
|
||||||
|
source_addr: addr.parse::<SocketAddr>().unwrap(),
|
||||||
|
transport: SipTransport::Udp,
|
||||||
|
expires_at: Instant::now() + Duration::from_secs(expires_secs),
|
||||||
|
registered_at: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_and_lookup() {
|
||||||
|
let reg = Registrar::new();
|
||||||
|
reg.add_registration(make_reg(
|
||||||
|
"alice",
|
||||||
|
None,
|
||||||
|
"1.2.3.4:5060",
|
||||||
|
"sip:alice@1.2.3.4",
|
||||||
|
300,
|
||||||
|
));
|
||||||
|
let addrs = reg.get_source_addrs_for_sip_user("alice");
|
||||||
|
assert_eq!(addrs.len(), 1);
|
||||||
|
assert_eq!(addrs[0], "1.2.3.4:5060".parse::<SocketAddr>().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_discord_reverse_lookup() {
|
||||||
|
let reg = Registrar::new();
|
||||||
|
reg.add_registration(make_reg(
|
||||||
|
"bob",
|
||||||
|
Some("bob#1234"),
|
||||||
|
"5.6.7.8:5060",
|
||||||
|
"sip:bob@5.6.7.8",
|
||||||
|
300,
|
||||||
|
));
|
||||||
|
let contacts = reg.get_contacts_for_discord_user("bob#1234");
|
||||||
|
assert_eq!(contacts.len(), 1);
|
||||||
|
assert_eq!(contacts[0].0, "sip:bob@5.6.7.8");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_existing_registration() {
|
||||||
|
let reg = Registrar::new();
|
||||||
|
reg.add_registration(make_reg(
|
||||||
|
"alice",
|
||||||
|
None,
|
||||||
|
"1.2.3.4:5060",
|
||||||
|
"sip:alice@1.2.3.4",
|
||||||
|
300,
|
||||||
|
));
|
||||||
|
// Same source_addr + contact_uri -> update in place
|
||||||
|
reg.add_registration(make_reg(
|
||||||
|
"alice",
|
||||||
|
None,
|
||||||
|
"1.2.3.4:5060",
|
||||||
|
"sip:alice@1.2.3.4",
|
||||||
|
600,
|
||||||
|
));
|
||||||
|
let addrs = reg.get_source_addrs_for_sip_user("alice");
|
||||||
|
assert_eq!(addrs.len(), 1); // Should not duplicate
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_registrations_per_user() {
|
||||||
|
let reg = Registrar::new();
|
||||||
|
reg.add_registration(make_reg(
|
||||||
|
"alice",
|
||||||
|
None,
|
||||||
|
"1.2.3.4:5060",
|
||||||
|
"sip:alice@1.2.3.4",
|
||||||
|
300,
|
||||||
|
));
|
||||||
|
reg.add_registration(make_reg(
|
||||||
|
"alice",
|
||||||
|
None,
|
||||||
|
"5.6.7.8:5060",
|
||||||
|
"sip:alice@5.6.7.8",
|
||||||
|
300,
|
||||||
|
));
|
||||||
|
let addrs = reg.get_source_addrs_for_sip_user("alice");
|
||||||
|
assert_eq!(addrs.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remove_expired() {
|
||||||
|
let reg = Registrar::new();
|
||||||
|
// Add one that expires immediately
|
||||||
|
let mut expired_reg = make_reg("alice", None, "1.2.3.4:5060", "sip:alice@1.2.3.4", 0);
|
||||||
|
expired_reg.expires_at = Instant::now() - Duration::from_secs(1);
|
||||||
|
reg.add_registration(expired_reg);
|
||||||
|
// Add one that's still valid
|
||||||
|
reg.add_registration(make_reg(
|
||||||
|
"alice",
|
||||||
|
None,
|
||||||
|
"5.6.7.8:5060",
|
||||||
|
"sip:alice@5.6.7.8",
|
||||||
|
300,
|
||||||
|
));
|
||||||
|
|
||||||
|
reg.remove_expired();
|
||||||
|
let addrs = reg.get_source_addrs_for_sip_user("alice");
|
||||||
|
assert_eq!(addrs.len(), 1);
|
||||||
|
assert_eq!(addrs[0], "5.6.7.8:5060".parse::<SocketAddr>().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_contacts_for_discord_user_expired_filtered() {
|
||||||
|
let reg = Registrar::new();
|
||||||
|
let mut expired_reg = make_reg(
|
||||||
|
"charlie",
|
||||||
|
Some("charlie#0001"),
|
||||||
|
"1.2.3.4:5060",
|
||||||
|
"sip:charlie@1.2.3.4",
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expired_reg.expires_at = Instant::now() - Duration::from_secs(1);
|
||||||
|
reg.add_registration(expired_reg);
|
||||||
|
|
||||||
|
reg.add_registration(make_reg(
|
||||||
|
"charlie",
|
||||||
|
Some("charlie#0001"),
|
||||||
|
"5.6.7.8:5060",
|
||||||
|
"sip:charlie@5.6.7.8",
|
||||||
|
300,
|
||||||
|
));
|
||||||
|
|
||||||
|
let contacts = reg.get_contacts_for_discord_user("charlie#0001");
|
||||||
|
assert_eq!(contacts.len(), 1);
|
||||||
|
assert_eq!(contacts[0].0, "sip:charlie@5.6.7.8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the periodic cleanup task
|
||||||
|
pub fn spawn_cleanup_task(registrar: Arc<Registrar>) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
registrar.remove_expired();
|
||||||
|
debug!("Registrar cleanup complete");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
181
sipcord-bridge/src/services/snowflake.rs
Normal file
181
sipcord-bridge/src/services/snowflake.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
//! Discord Snowflake ID — type-safe wrapper around u64.
|
||||||
|
//!
|
||||||
|
//! Snowflakes encode a millisecond timestamp (bits 22+) relative to the
|
||||||
|
//! Discord epoch (2015-01-01T00:00:00.000Z), plus worker/process/sequence
|
||||||
|
//! metadata in the lower 22 bits.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::ops::Deref;
|
||||||
|
|
||||||
|
/// Discord epoch: 2015-01-01T00:00:00.000Z in Unix millis
|
||||||
|
const DISCORD_EPOCH_MS: u64 = 1_420_070_400_000;
|
||||||
|
|
||||||
|
/// Smallest plausible snowflake (~17 digits).
|
||||||
|
/// Corresponds to roughly mid-2015, shortly after Discord launched.
|
||||||
|
/// 21_154_535_154_122_752 = (1433289600000 - DISCORD_EPOCH_MS) << 22 (2015-06-03)
|
||||||
|
const MIN_SNOWFLAKE: u64 = 21_154_535_154_122_752;
|
||||||
|
|
||||||
|
/// Largest plausible snowflake — year 2100 relative to Discord epoch.
|
||||||
|
/// (2_682_288_000_000 << 22) ≈ 11.2e18, still well within u64.
|
||||||
|
const MAX_SNOWFLAKE: u64 = 2_682_288_000_000 << 22;
|
||||||
|
|
||||||
|
/// A Discord Snowflake ID.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
|
||||||
|
pub struct Snowflake(u64);
|
||||||
|
|
||||||
|
impl Snowflake {
|
||||||
|
/// Wrap a raw u64 as a Snowflake (no validation).
|
||||||
|
pub const fn new(value: u64) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The raw u64 value.
|
||||||
|
pub const fn get(self) -> u64 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this is a plausible Discord snowflake.
|
||||||
|
///
|
||||||
|
/// Checks that the value is at least 17 digits (all real Discord IDs are)
|
||||||
|
/// and that the embedded timestamp falls between Discord's launch (~mid 2015)
|
||||||
|
/// and the year 2100.
|
||||||
|
pub const fn is_valid(self) -> bool {
|
||||||
|
self.0 >= MIN_SNOWFLAKE && self.0 <= MAX_SNOWFLAKE
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Milliseconds since Unix epoch encoded in this snowflake.
|
||||||
|
pub const fn timestamp_ms(self) -> u64 {
|
||||||
|
(self.0 >> 22) + DISCORD_EPOCH_MS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transparent access as u64
|
||||||
|
|
||||||
|
impl Deref for Snowflake {
|
||||||
|
type Target = u64;
|
||||||
|
fn deref(&self) -> &u64 {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<u64> for Snowflake {
|
||||||
|
fn from(v: u64) -> Self {
|
||||||
|
Self(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Snowflake> for u64 {
|
||||||
|
fn from(s: Snowflake) -> u64 {
|
||||||
|
s.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for Snowflake {
|
||||||
|
type Err = std::num::ParseIntError;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
s.parse::<u64>().map(Self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display / Debug
|
||||||
|
|
||||||
|
impl fmt::Display for Snowflake {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Snowflake {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "Snowflake({})", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serde — deserialise from integer OR string (Discord uses both)
|
||||||
|
|
||||||
|
impl serde::Serialize for Snowflake {
|
||||||
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
self.0.serialize(serializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> serde::Deserialize<'de> for Snowflake {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
struct Visitor;
|
||||||
|
|
||||||
|
impl<'de> serde::de::Visitor<'de> for Visitor {
|
||||||
|
type Value = Snowflake;
|
||||||
|
|
||||||
|
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str("a snowflake as integer or string")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Snowflake, E> {
|
||||||
|
Ok(Snowflake(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Snowflake, E> {
|
||||||
|
v.parse::<u64>().map(Snowflake).map_err(E::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_any(Visitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_is_invalid() {
|
||||||
|
assert!(!Snowflake::new(0).is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn too_short_is_invalid() {
|
||||||
|
// 9 digits — way too small to be a Discord snowflake
|
||||||
|
assert!(!Snowflake::new(123_456_789).is_valid());
|
||||||
|
// 16 digits — still too short
|
||||||
|
assert!(!Snowflake::new(1_000_000_000_000_000).is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn real_snowflakes_are_valid() {
|
||||||
|
// Discord's own system messages channel
|
||||||
|
assert!(Snowflake::new(80_351_110_224_678_912).is_valid());
|
||||||
|
// A typical modern channel ID
|
||||||
|
assert!(Snowflake::new(1_098_765_432_101_234_567).is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timestamp_decodes() {
|
||||||
|
let s = Snowflake::new(80_351_110_224_678_912);
|
||||||
|
assert!(s.timestamp_ms() > DISCORD_EPOCH_MS);
|
||||||
|
// Should be sometime in 2015
|
||||||
|
let year_2016 = 1_451_606_400_000u64; // 2016-01-01 Unix ms
|
||||||
|
assert!(s.timestamp_ms() < year_2016);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deref_to_u64() {
|
||||||
|
let s = Snowflake::new(80_351_110_224_678_912);
|
||||||
|
let v: u64 = *s;
|
||||||
|
assert_eq!(v, 80_351_110_224_678_912);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_roundtrip_integer() {
|
||||||
|
let s = Snowflake::new(80_351_110_224_678_912);
|
||||||
|
let json = serde_json::to_string(&s).unwrap();
|
||||||
|
assert_eq!(json, "80351110224678912");
|
||||||
|
let back: Snowflake = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_from_string() {
|
||||||
|
let back: Snowflake = serde_json::from_str("\"80351110224678912\"").unwrap();
|
||||||
|
assert_eq!(back.get(), 80_351_110_224_678_912);
|
||||||
|
}
|
||||||
|
}
|
||||||
236
sipcord-bridge/src/services/sound/mod.rs
Normal file
236
sipcord-bridge/src/services/sound/mod.rs
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
//! Sound management for SIP call audio
|
||||||
|
//!
|
||||||
|
//! Provides a SoundManager that loads sounds from config.toml with two modes:
|
||||||
|
//! - Preloaded: Loaded into memory at startup for fast playback (system sounds)
|
||||||
|
//! - Streaming: Loaded on-demand from disk for large files (easter eggs)
|
||||||
|
//!
|
||||||
|
//! All audio files must be pre-resampled to 16kHz mono - no runtime resampling.
|
||||||
|
|
||||||
|
mod streaming;
|
||||||
|
|
||||||
|
use crate::audio::{flac, wav};
|
||||||
|
use crate::config::{AppConfig, SoundEntry};
|
||||||
|
use crate::transport::sip::CONF_SAMPLE_RATE;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
pub use streaming::StreamingPlayer;
|
||||||
|
|
||||||
|
/// A preloaded sound ready for immediate playback
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PreloadedSound {
|
||||||
|
/// PCM samples at 16kHz mono - NO RESAMPLING at runtime
|
||||||
|
pub samples: Arc<Vec<i16>>,
|
||||||
|
/// Duration in milliseconds
|
||||||
|
pub duration_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for a streaming sound (loaded on-demand)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct StreamingConfig {
|
||||||
|
/// Full path to the audio file
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sound manager for loading and playing audio files
|
||||||
|
pub struct SoundManager {
|
||||||
|
/// Preloaded sounds (preload=true) - in memory, ready for playback
|
||||||
|
preloaded: HashMap<String, PreloadedSound>,
|
||||||
|
/// Streaming configs (preload=false) - path only, loaded on demand
|
||||||
|
streaming: HashMap<String, StreamingConfig>,
|
||||||
|
/// Extension -> sound name mapping for easter eggs
|
||||||
|
pub extension_map: HashMap<u32, String>,
|
||||||
|
/// Base directory for sound files
|
||||||
|
sounds_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SoundManager {
|
||||||
|
/// Create a new SoundManager and load sounds from config
|
||||||
|
pub fn new(sounds_dir: PathBuf) -> Result<Self> {
|
||||||
|
let config = AppConfig::global();
|
||||||
|
let mut manager = Self {
|
||||||
|
preloaded: HashMap::new(),
|
||||||
|
streaming: HashMap::new(),
|
||||||
|
extension_map: HashMap::new(),
|
||||||
|
sounds_dir,
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.load_sounds(&config.sounds.entries)?;
|
||||||
|
Ok(manager)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load all sounds from config entries
|
||||||
|
fn load_sounds(&mut self, entries: &HashMap<String, SoundEntry>) -> Result<()> {
|
||||||
|
let mut preloaded_count = 0;
|
||||||
|
let mut streaming_count = 0;
|
||||||
|
let mut virtual_count = 0;
|
||||||
|
|
||||||
|
for (name, entry) in entries {
|
||||||
|
// Build extension map for easter eggs and test tones
|
||||||
|
if let Some(ext) = entry.extension {
|
||||||
|
self.extension_map.insert(ext, name.clone());
|
||||||
|
debug!("Registered extension {} -> sound '{}'", ext, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle virtual sounds (no src file - generated dynamically)
|
||||||
|
let Some(ref src) = entry.src else {
|
||||||
|
virtual_count += 1;
|
||||||
|
info!("Registered virtual sound '{}' (no file, generated)", name);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_path = self.sounds_dir.join(src);
|
||||||
|
|
||||||
|
if entry.preload {
|
||||||
|
// Load and store in memory
|
||||||
|
match self.load_preloaded_sound(&file_path, name) {
|
||||||
|
Ok(sound) => {
|
||||||
|
info!(
|
||||||
|
"Preloaded sound '{}': {} samples ({} ms) from {}",
|
||||||
|
name,
|
||||||
|
sound.samples.len(),
|
||||||
|
sound.duration_ms,
|
||||||
|
src
|
||||||
|
);
|
||||||
|
self.preloaded.insert(name.clone(), sound);
|
||||||
|
preloaded_count += 1;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to preload sound '{}' from {}: {}", name, src, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Just store path for streaming
|
||||||
|
if file_path.exists() {
|
||||||
|
self.streaming.insert(
|
||||||
|
name.clone(),
|
||||||
|
StreamingConfig {
|
||||||
|
path: file_path.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
streaming_count += 1;
|
||||||
|
info!("Registered streaming sound '{}' from {}", name, src);
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
"Streaming sound '{}' file not found: {}",
|
||||||
|
name,
|
||||||
|
file_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"SoundManager loaded {} preloaded, {} streaming, {} virtual sounds, {} extensions",
|
||||||
|
preloaded_count,
|
||||||
|
streaming_count,
|
||||||
|
virtual_count,
|
||||||
|
self.extension_map.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a preloaded sound from a file
|
||||||
|
fn load_preloaded_sound(&self, path: &Path, name: &str) -> Result<PreloadedSound> {
|
||||||
|
let data = std::fs::read(path)
|
||||||
|
.with_context(|| format!("Failed to read sound file: {}", path.display()))?;
|
||||||
|
|
||||||
|
let samples = self.parse_audio(&data, name)?;
|
||||||
|
|
||||||
|
let duration_ms = (samples.len() as u64 * 1000) / CONF_SAMPLE_RATE as u64;
|
||||||
|
|
||||||
|
Ok(PreloadedSound {
|
||||||
|
samples: Arc::new(samples),
|
||||||
|
duration_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse audio data (auto-detect WAV or FLAC format)
|
||||||
|
/// Expects 16kHz mono - panics if wrong sample rate
|
||||||
|
fn parse_audio(&self, data: &[u8], name: &str) -> Result<Vec<i16>> {
|
||||||
|
// Check for FLAC magic number: "fLaC"
|
||||||
|
if data.len() >= 4 && &data[0..4] == b"fLaC" {
|
||||||
|
debug!("Detected FLAC format for '{}'", name);
|
||||||
|
let (samples, rate) = flac::parse_flac(data)
|
||||||
|
.with_context(|| format!("Failed to parse FLAC for sound '{}'", name))?;
|
||||||
|
if rate != CONF_SAMPLE_RATE {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Sound '{}' has wrong sample rate: {} Hz (expected {} Hz). Pre-resample the file.",
|
||||||
|
name, rate, CONF_SAMPLE_RATE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Ok(samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for WAV magic number: "RIFF"
|
||||||
|
if data.len() >= 4 && &data[0..4] == b"RIFF" {
|
||||||
|
debug!("Detected WAV format for '{}'", name);
|
||||||
|
let (samples, rate) = wav::parse_wav(data)
|
||||||
|
.with_context(|| format!("Failed to parse WAV for sound '{}'", name))?;
|
||||||
|
if rate != CONF_SAMPLE_RATE {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Sound '{}' has wrong sample rate: {} Hz (expected {} Hz). Pre-resample the file.",
|
||||||
|
name, rate, CONF_SAMPLE_RATE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Ok(samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
anyhow::bail!(
|
||||||
|
"Unknown audio format for '{}': header bytes {:?}",
|
||||||
|
name,
|
||||||
|
&data[..4.min(data.len())]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a preloaded sound by name
|
||||||
|
pub fn get_preloaded(&self, name: &str) -> Option<&PreloadedSound> {
|
||||||
|
self.preloaded.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a streaming config by name
|
||||||
|
pub fn get_streaming(&self, name: &str) -> Option<&StreamingConfig> {
|
||||||
|
self.streaming.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a sound is configured for streaming
|
||||||
|
pub fn is_streaming(&self, name: &str) -> bool {
|
||||||
|
self.streaming.contains_key(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a sound is a virtual sound (test tone)
|
||||||
|
pub fn is_test_tone(&self, name: &str) -> bool {
|
||||||
|
name == "test_tone"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the sound name for an extension (if configured)
|
||||||
|
pub fn get_extension_sound(&self, extension: u32) -> Option<&str> {
|
||||||
|
self.extension_map.get(&extension).map(|s| s.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the connecting sound samples (used for early media loop)
|
||||||
|
pub fn get_connecting_samples(&self) -> Option<Arc<Vec<i16>>> {
|
||||||
|
self.preloaded.get("connecting").map(|s| s.samples.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the discord_join sound samples
|
||||||
|
pub fn get_discord_join_samples(&self) -> Option<Arc<Vec<i16>>> {
|
||||||
|
self.preloaded
|
||||||
|
.get("discord_join")
|
||||||
|
.map(|s| s.samples.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get error sound samples by error type
|
||||||
|
pub fn get_error_samples(&self, error_type: &str) -> Option<Arc<Vec<i16>>> {
|
||||||
|
self.preloaded.get(error_type).map(|s| s.samples.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an Arc-wrapped SoundManager for sharing across async tasks
|
||||||
|
pub fn create_sound_manager(sounds_dir: PathBuf) -> Result<Arc<SoundManager>> {
|
||||||
|
Ok(Arc::new(SoundManager::new(sounds_dir)?))
|
||||||
|
}
|
||||||
273
sipcord-bridge/src/services/sound/streaming.rs
Normal file
273
sipcord-bridge/src/services/sound/streaming.rs
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
//! Streaming audio player for large files
|
||||||
|
//!
|
||||||
|
//! Provides a file-backed streaming player that reads audio from disk
|
||||||
|
//! on-demand rather than loading the entire file into memory.
|
||||||
|
//!
|
||||||
|
//! Uses Symphonia for FLAC decoding (pure Rust).
|
||||||
|
|
||||||
|
use crate::transport::sip::CONF_SAMPLE_RATE;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::path::Path;
|
||||||
|
use symphonia::core::audio::{AudioBufferRef, Signal};
|
||||||
|
use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL};
|
||||||
|
use symphonia::core::formats::FormatOptions;
|
||||||
|
use symphonia::core::io::MediaSourceStream;
|
||||||
|
use symphonia::core::meta::MetadataOptions;
|
||||||
|
use symphonia::core::probe::Hint;
|
||||||
|
|
||||||
|
/// Streaming player for large audio files
|
||||||
|
///
|
||||||
|
/// Reads FLAC frames on-demand to avoid loading entire file into memory.
|
||||||
|
pub struct StreamingPlayer {
|
||||||
|
/// Symphonia format reader
|
||||||
|
format: Box<dyn symphonia::core::formats::FormatReader>,
|
||||||
|
/// Symphonia decoder
|
||||||
|
decoder: Box<dyn symphonia::core::codecs::Decoder>,
|
||||||
|
/// Track ID we're decoding
|
||||||
|
track_id: u32,
|
||||||
|
/// Buffer of decoded samples ready for playback
|
||||||
|
samples_buffer: VecDeque<i16>,
|
||||||
|
/// Whether we've reached end of file
|
||||||
|
eof: bool,
|
||||||
|
/// Total samples read from file (for debugging)
|
||||||
|
total_samples_read: u64,
|
||||||
|
/// Total samples delivered via get_frame (for debugging)
|
||||||
|
total_samples_delivered: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamingPlayer {
|
||||||
|
/// Create a new streaming player for a FLAC file
|
||||||
|
pub fn new(path: &Path) -> Result<Self> {
|
||||||
|
let file = File::open(path)
|
||||||
|
.with_context(|| format!("Failed to open streaming file: {}", path.display()))?;
|
||||||
|
|
||||||
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||||
|
|
||||||
|
let mut hint = Hint::new();
|
||||||
|
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||||
|
hint.with_extension(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
let probed = symphonia::default::get_probe()
|
||||||
|
.format(
|
||||||
|
&hint,
|
||||||
|
mss,
|
||||||
|
&FormatOptions::default(),
|
||||||
|
&MetadataOptions::default(),
|
||||||
|
)
|
||||||
|
.with_context(|| format!("Failed to probe format: {}", path.display()))?;
|
||||||
|
|
||||||
|
let format = probed.format;
|
||||||
|
|
||||||
|
// Find the first audio track
|
||||||
|
let track = format
|
||||||
|
.tracks()
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No audio track found in {}", path.display()))?;
|
||||||
|
|
||||||
|
let track_id = track.id;
|
||||||
|
|
||||||
|
// Verify sample rate
|
||||||
|
let sample_rate = track
|
||||||
|
.codec_params
|
||||||
|
.sample_rate
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No sample rate in track"))?;
|
||||||
|
|
||||||
|
if sample_rate != CONF_SAMPLE_RATE {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Streaming file {} has wrong sample rate: {} Hz (expected {} Hz)",
|
||||||
|
path.display(),
|
||||||
|
sample_rate,
|
||||||
|
CONF_SAMPLE_RATE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let channels = track.codec_params.channels.map(|c| c.count()).unwrap_or(1);
|
||||||
|
|
||||||
|
let n_frames = track.codec_params.n_frames;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Created Symphonia streaming player for {}: {}Hz, {} channels, n_frames={:?}",
|
||||||
|
path.display(),
|
||||||
|
sample_rate,
|
||||||
|
channels,
|
||||||
|
n_frames
|
||||||
|
);
|
||||||
|
|
||||||
|
let decoder = symphonia::default::get_codecs()
|
||||||
|
.make(&track.codec_params, &DecoderOptions::default())
|
||||||
|
.with_context(|| "Failed to create decoder")?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
format,
|
||||||
|
decoder,
|
||||||
|
track_id,
|
||||||
|
samples_buffer: VecDeque::with_capacity(4096),
|
||||||
|
eof: false,
|
||||||
|
total_samples_read: 0,
|
||||||
|
total_samples_delivered: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the next frame of samples (320 samples for 20ms at 16kHz)
|
||||||
|
///
|
||||||
|
/// Returns None when the file is finished.
|
||||||
|
pub fn get_frame(&mut self, frame_size: usize) -> Option<Vec<i16>> {
|
||||||
|
// Fill buffer if needed
|
||||||
|
while self.samples_buffer.len() < frame_size && !self.eof {
|
||||||
|
if !self.read_more_samples() {
|
||||||
|
self.eof = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return None if no samples available
|
||||||
|
if self.samples_buffer.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain requested samples (or all remaining)
|
||||||
|
let count = frame_size.min(self.samples_buffer.len());
|
||||||
|
let samples: Vec<i16> = self.samples_buffer.drain(..count).collect();
|
||||||
|
self.total_samples_delivered += samples.len() as u64;
|
||||||
|
|
||||||
|
// Pad with silence if we got fewer than requested
|
||||||
|
if samples.len() < frame_size {
|
||||||
|
let mut padded = samples;
|
||||||
|
padded.resize(frame_size, 0);
|
||||||
|
return Some(padded);
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(samples)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if playback is complete
|
||||||
|
pub fn is_finished(&self) -> bool {
|
||||||
|
let finished = self.eof && self.samples_buffer.is_empty();
|
||||||
|
if finished {
|
||||||
|
tracing::info!(
|
||||||
|
"StreamingPlayer finished: read {} samples, delivered {} samples",
|
||||||
|
self.total_samples_read,
|
||||||
|
self.total_samples_delivered,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
finished
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read more samples from the file into the buffer
|
||||||
|
/// Returns false when EOF is reached
|
||||||
|
fn read_more_samples(&mut self) -> bool {
|
||||||
|
loop {
|
||||||
|
let packet = match self.format.next_packet() {
|
||||||
|
Ok(packet) => packet,
|
||||||
|
Err(symphonia::core::errors::Error::IoError(e))
|
||||||
|
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("Error reading packet: {}", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skip packets from other tracks
|
||||||
|
if packet.track_id() != self.track_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.decoder.decode(&packet) {
|
||||||
|
Ok(decoded) => {
|
||||||
|
// Convert to i16 samples
|
||||||
|
let samples_added = convert_audio_buffer(&decoded, &mut self.samples_buffer);
|
||||||
|
self.total_samples_read += samples_added as u64;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Err(symphonia::core::errors::Error::DecodeError(e)) => {
|
||||||
|
tracing::debug!("Decode error: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("Fatal decode error: {}", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert Symphonia audio buffer to i16 samples and add to buffer
|
||||||
|
fn convert_audio_buffer(audio: &AudioBufferRef, samples_buffer: &mut VecDeque<i16>) -> usize {
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
match audio {
|
||||||
|
AudioBufferRef::S16(buf) => {
|
||||||
|
let channels = buf.spec().channels.count();
|
||||||
|
let frames = buf.frames();
|
||||||
|
|
||||||
|
for frame_idx in 0..frames {
|
||||||
|
if channels == 1 {
|
||||||
|
let sample = buf.chan(0)[frame_idx];
|
||||||
|
samples_buffer.push_back(sample);
|
||||||
|
count += 1;
|
||||||
|
} else {
|
||||||
|
// Stereo to mono: average channels
|
||||||
|
let mut sum: i32 = 0;
|
||||||
|
for ch in 0..channels {
|
||||||
|
sum += buf.chan(ch)[frame_idx] as i32;
|
||||||
|
}
|
||||||
|
let mono = (sum / channels as i32) as i16;
|
||||||
|
samples_buffer.push_back(mono);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AudioBufferRef::S32(buf) => {
|
||||||
|
let channels = buf.spec().channels.count();
|
||||||
|
let frames = buf.frames();
|
||||||
|
|
||||||
|
for frame_idx in 0..frames {
|
||||||
|
if channels == 1 {
|
||||||
|
let sample = (buf.chan(0)[frame_idx] >> 16) as i16;
|
||||||
|
samples_buffer.push_back(sample);
|
||||||
|
count += 1;
|
||||||
|
} else {
|
||||||
|
let mut sum: i64 = 0;
|
||||||
|
for ch in 0..channels {
|
||||||
|
sum += buf.chan(ch)[frame_idx] as i64;
|
||||||
|
}
|
||||||
|
let mono = ((sum / channels as i64) >> 16) as i16;
|
||||||
|
samples_buffer.push_back(mono);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AudioBufferRef::F32(buf) => {
|
||||||
|
let channels = buf.spec().channels.count();
|
||||||
|
let frames = buf.frames();
|
||||||
|
|
||||||
|
for frame_idx in 0..frames {
|
||||||
|
if channels == 1 {
|
||||||
|
let sample = (buf.chan(0)[frame_idx] * 32767.0) as i16;
|
||||||
|
samples_buffer.push_back(sample);
|
||||||
|
count += 1;
|
||||||
|
} else {
|
||||||
|
let mut sum: f32 = 0.0;
|
||||||
|
for ch in 0..channels {
|
||||||
|
sum += buf.chan(ch)[frame_idx];
|
||||||
|
}
|
||||||
|
let mono = ((sum / channels as f32) * 32767.0) as i16;
|
||||||
|
samples_buffer.push_back(mono);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tracing::warn!("Unsupported audio buffer format");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
count
|
||||||
|
}
|
||||||
1264
sipcord-bridge/src/transport/discord/mod.rs
Normal file
1264
sipcord-bridge/src/transport/discord/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
239
sipcord-bridge/src/transport/discord/voice.rs
Normal file
239
sipcord-bridge/src/transport/discord/voice.rs
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
//! Voice/audio utilities for Discord
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use rtrb::Consumer;
|
||||||
|
use songbird::input::{Input, RawAdapter};
|
||||||
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use symphonia_core::io::MediaSource;
|
||||||
|
|
||||||
|
/// Discord expects 48kHz stereo audio
|
||||||
|
pub const DISCORD_SAMPLE_RATE: u32 = 48000;
|
||||||
|
const DISCORD_CHANNELS: u16 = 2;
|
||||||
|
|
||||||
|
/// Ring buffer capacity in samples (f32 stereo pairs)
|
||||||
|
pub fn ring_buffer_samples() -> usize {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static CACHED: OnceLock<usize> = OnceLock::new();
|
||||||
|
*CACHED.get_or_init(|| crate::config::AppConfig::audio().ring_buffer_samples)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-buffer threshold in samples before we start outputting
|
||||||
|
fn pre_buffer_samples() -> usize {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static CACHED: OnceLock<usize> = OnceLock::new();
|
||||||
|
*CACHED.get_or_init(|| crate::config::AppConfig::audio().pre_buffer_samples)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resample audio from one sample rate to another using linear interpolation.
|
||||||
|
/// This is a fallback - prefer using the rubato sinc resampler for quality.
|
||||||
|
pub fn resample_audio(samples: &[i16], from_rate: u32, to_rate: u32) -> Vec<i16> {
|
||||||
|
let mut output = Vec::new();
|
||||||
|
resample_audio_into(samples, from_rate, to_rate, &mut output);
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resample audio into a pre-allocated buffer (avoids per-call Vec allocation).
|
||||||
|
/// Clears `output` and fills it with resampled data.
|
||||||
|
pub fn resample_audio_into(samples: &[i16], from_rate: u32, to_rate: u32, output: &mut Vec<i16>) {
|
||||||
|
output.clear();
|
||||||
|
|
||||||
|
if from_rate == to_rate {
|
||||||
|
output.extend_from_slice(samples);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if samples.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ratio = from_rate as f64 / to_rate as f64;
|
||||||
|
let output_len = ((samples.len() as f64) / ratio).ceil() as usize;
|
||||||
|
output.reserve(output_len.saturating_sub(output.capacity()));
|
||||||
|
|
||||||
|
for i in 0..output_len {
|
||||||
|
let src_pos = i as f64 * ratio;
|
||||||
|
let src_idx = src_pos as usize;
|
||||||
|
let frac = src_pos - src_idx as f64;
|
||||||
|
|
||||||
|
let sample = if src_idx + 1 < samples.len() {
|
||||||
|
let s0 = samples[src_idx] as f64;
|
||||||
|
let s1 = samples[src_idx + 1] as f64;
|
||||||
|
(s0 + (s1 - s0) * frac) as i16
|
||||||
|
} else if src_idx < samples.len() {
|
||||||
|
samples[src_idx]
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
output.push(sample);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streaming audio source using a lock-free ring buffer.
|
||||||
|
///
|
||||||
|
/// This implements Symphonia's MediaSource trait and provides raw f32 PCM data.
|
||||||
|
/// Uses rtrb for lock-free, wait-free audio streaming - no spinning or blocking.
|
||||||
|
///
|
||||||
|
/// The Mutex around Consumer is required to satisfy MediaSource's Sync bound,
|
||||||
|
/// but since only one thread (Songbird's audio thread) ever accesses it,
|
||||||
|
/// there's never any contention - it's essentially just satisfying the type system.
|
||||||
|
pub struct StreamingAudioSource {
|
||||||
|
/// Ring buffer consumer for audio samples (f32)
|
||||||
|
/// Wrapped in Mutex to satisfy Sync bound (no actual contention)
|
||||||
|
consumer: Mutex<Consumer<f32>>,
|
||||||
|
/// Read count for logging (atomic — single reader, no contention)
|
||||||
|
read_count: AtomicU64,
|
||||||
|
/// Whether we've pre-buffered enough to start output (atomic — single reader)
|
||||||
|
pre_buffered: AtomicBool,
|
||||||
|
/// Underrun count for diagnostics (atomic — single reader)
|
||||||
|
underrun_count: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamingAudioSource {
|
||||||
|
/// Create a new streaming audio source with ring buffer.
|
||||||
|
///
|
||||||
|
/// Returns the source and the rtrb Producer to push audio samples.
|
||||||
|
/// Samples should be f32 interleaved stereo at 48kHz, normalized to [-1.0, 1.0].
|
||||||
|
pub fn new() -> (Self, rtrb::Producer<f32>) {
|
||||||
|
let (producer, consumer) = rtrb::RingBuffer::new(ring_buffer_samples());
|
||||||
|
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
consumer: Mutex::new(consumer),
|
||||||
|
read_count: AtomicU64::new(0),
|
||||||
|
pre_buffered: AtomicBool::new(false),
|
||||||
|
underrun_count: AtomicU64::new(0),
|
||||||
|
},
|
||||||
|
producer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a Songbird Input from this streaming source
|
||||||
|
pub fn into_input(self) -> Input {
|
||||||
|
RawAdapter::new(self, DISCORD_SAMPLE_RATE, DISCORD_CHANNELS as u32).into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Read for StreamingAudioSource {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
let count = self.read_count.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
|
|
||||||
|
// How many f32 samples can we fit in the output buffer?
|
||||||
|
let samples_requested = buf.len() / 4; // 4 bytes per f32
|
||||||
|
|
||||||
|
let mut consumer = self.consumer.lock();
|
||||||
|
let samples_available = consumer.slots();
|
||||||
|
|
||||||
|
// Pre-buffering: wait until ring buffer has enough before starting
|
||||||
|
if !self.pre_buffered.load(Ordering::Relaxed) {
|
||||||
|
if samples_available >= pre_buffer_samples() {
|
||||||
|
self.pre_buffered.store(true, Ordering::Relaxed);
|
||||||
|
let ms_buffered = samples_available as f64 / 48000.0 / 2.0 * 1000.0;
|
||||||
|
tracing::info!(
|
||||||
|
"StreamingAudioSource: Pre-buffer complete ({} samples, {:.0}ms), starting output",
|
||||||
|
samples_available, ms_buffered
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Still pre-buffering - return silence
|
||||||
|
if count.is_multiple_of(50) {
|
||||||
|
let ms_buffered = samples_available as f64 / 48000.0 / 2.0 * 1000.0;
|
||||||
|
let ms_target = pre_buffer_samples() as f64 / 48000.0 / 2.0 * 1000.0;
|
||||||
|
tracing::debug!(
|
||||||
|
"StreamingAudioSource: Pre-buffering {}/{} samples ({:.0}ms / {:.0}ms)",
|
||||||
|
samples_available,
|
||||||
|
pre_buffer_samples(),
|
||||||
|
ms_buffered,
|
||||||
|
ms_target
|
||||||
|
);
|
||||||
|
}
|
||||||
|
buf.fill(0);
|
||||||
|
return Ok(buf.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log buffer status periodically
|
||||||
|
if count.is_multiple_of(50) {
|
||||||
|
let ms_buffered = samples_available as f64 / 48000.0 / 2.0 * 1000.0;
|
||||||
|
tracing::debug!(
|
||||||
|
"StreamingAudioSource #{}: ring buffer has {} samples ({:.1}ms), Songbird wants {} samples",
|
||||||
|
count, samples_available, ms_buffered, samples_requested
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read as many samples as we can from ring buffer
|
||||||
|
let samples_to_read = samples_requested.min(samples_available);
|
||||||
|
|
||||||
|
if samples_to_read == 0 {
|
||||||
|
// Buffer empty - underrun
|
||||||
|
let underruns = self.underrun_count.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
|
if underruns <= 5 || underruns.is_multiple_of(100) {
|
||||||
|
tracing::warn!(
|
||||||
|
"StreamingAudioSource: Ring buffer empty, filling with silence (underruns: {})",
|
||||||
|
underruns
|
||||||
|
);
|
||||||
|
}
|
||||||
|
buf.fill(0);
|
||||||
|
return Ok(buf.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read samples from ring buffer directly into output buffer
|
||||||
|
let chunk = consumer.read_chunk(samples_to_read).unwrap();
|
||||||
|
let (first, second) = chunk.as_slices();
|
||||||
|
|
||||||
|
// Bulk copy f32 samples as raw bytes (memcpy instead of per-sample loop)
|
||||||
|
let first_bytes =
|
||||||
|
unsafe { std::slice::from_raw_parts(first.as_ptr() as *const u8, first.len() * 4) };
|
||||||
|
buf[..first_bytes.len()].copy_from_slice(first_bytes);
|
||||||
|
if !second.is_empty() {
|
||||||
|
let second_bytes = unsafe {
|
||||||
|
std::slice::from_raw_parts(second.as_ptr() as *const u8, second.len() * 4)
|
||||||
|
};
|
||||||
|
buf[first_bytes.len()..first_bytes.len() + second_bytes.len()]
|
||||||
|
.copy_from_slice(second_bytes);
|
||||||
|
}
|
||||||
|
chunk.commit_all();
|
||||||
|
|
||||||
|
// Fill remainder with silence if we didn't have enough
|
||||||
|
let bytes_written = samples_to_read * 4;
|
||||||
|
if bytes_written < buf.len() {
|
||||||
|
buf[bytes_written..].fill(0);
|
||||||
|
if count.is_multiple_of(50) || count < 10 {
|
||||||
|
let silence_samples = (buf.len() - bytes_written) / 4;
|
||||||
|
tracing::debug!(
|
||||||
|
"StreamingAudioSource #{}: Partial read, filled {} samples with silence",
|
||||||
|
count,
|
||||||
|
silence_samples
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(buf.len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Seek for StreamingAudioSource {
|
||||||
|
fn seek(&mut self, _pos: SeekFrom) -> std::io::Result<u64> {
|
||||||
|
// Live streams are not seekable
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Unsupported,
|
||||||
|
"Live streams are not seekable",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaSource for StreamingAudioSource {
|
||||||
|
fn is_seekable(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byte_len(&self) -> Option<u64> {
|
||||||
|
None // Unknown length for live streams
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for StreamingAudioSource {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
tracing::debug!("StreamingAudioSource dropped (call ending)");
|
||||||
|
}
|
||||||
|
}
|
||||||
2
sipcord-bridge/src/transport/mod.rs
Normal file
2
sipcord-bridge/src/transport/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod discord;
|
||||||
|
pub mod sip;
|
||||||
863
sipcord-bridge/src/transport/sip/audio_thread.rs
Normal file
863
sipcord-bridge/src/transport/sip/audio_thread.rs
Normal file
|
|
@ -0,0 +1,863 @@
|
||||||
|
//! Audio processing thread and RTP activity tracking
|
||||||
|
//!
|
||||||
|
//! This module handles:
|
||||||
|
//! - Audio thread lifecycle (start/stop)
|
||||||
|
//! - Per-frame audio processing for SIP <-> Discord
|
||||||
|
//! - RTP inactivity timeout detection
|
||||||
|
|
||||||
|
use super::channel_audio::{complete_pending_channel_registration, get_active_channels_into};
|
||||||
|
use super::ffi::types::*;
|
||||||
|
use crate::audio::simd;
|
||||||
|
use crate::services::snowflake::Snowflake;
|
||||||
|
use crossbeam_channel::Sender;
|
||||||
|
use crossbeam_queue::SegQueue;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use pjsua::*;
|
||||||
|
use std::mem::MaybeUninit;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// Frame counter for when we first see active channels (for debug logging)
|
||||||
|
/// This is reset when the audio thread starts to prevent subtraction overflow
|
||||||
|
static FIRST_ACTIVE_CHANNEL_FRAME: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
fn drain_queue<T>(queue: &SegQueue<T>, name: &str) {
|
||||||
|
let mut count = 0;
|
||||||
|
while queue.pop().is_some() {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
tracing::warn!("Drained {} stale {} from previous audio thread", count, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the audio processing thread
|
||||||
|
///
|
||||||
|
/// This thread periodically:
|
||||||
|
/// - Gets audio frames from the conference (SIP -> callback)
|
||||||
|
/// - Puts audio frames to the conference (from AUDIO_OUT_BUFFERS -> SIP)
|
||||||
|
pub fn start_audio_thread() {
|
||||||
|
if AUDIO_THREAD_RUNNING.swap(true, Ordering::SeqCst) {
|
||||||
|
tracing::warn!("Audio thread already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the "ready" flag - we'll set it after processing the first frame
|
||||||
|
AUDIO_THREAD_READY.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
|
// Reset the first-active-channel frame counter to prevent subtraction overflow
|
||||||
|
// when the audio thread restarts with a new frame_count
|
||||||
|
FIRST_ACTIVE_CHANNEL_FRAME.store(0, Ordering::SeqCst);
|
||||||
|
|
||||||
|
let handle = std::thread::spawn(|| {
|
||||||
|
// Catch any panics in the audio thread
|
||||||
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
tracing::info!(
|
||||||
|
"Audio processing thread started [thread: {:?}]",
|
||||||
|
std::thread::current().id()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drain stale ops from previous audio thread lifecycle
|
||||||
|
drain_queue(&PENDING_PJSUA_OPS, "PENDING_PJSUA_OPS");
|
||||||
|
drain_queue(&PENDING_CONF_CONNECTIONS, "PENDING_CONF_CONNECTIONS");
|
||||||
|
drain_queue(&PENDING_CHANNEL_COMPLETIONS, "PENDING_CHANNEL_COMPLETIONS");
|
||||||
|
|
||||||
|
// Register this thread with PJLIB so we can call PJSUA functions
|
||||||
|
// The thread descriptor must remain valid for the thread's lifetime
|
||||||
|
let mut thread_desc: pj_thread_desc = [0; 64];
|
||||||
|
let mut thread_ptr: *mut pj_thread_t = std::ptr::null_mut();
|
||||||
|
let thread_name = c"audio_thread";
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let is_registered = pj_thread_is_registered();
|
||||||
|
if is_registered == 0 {
|
||||||
|
let status = pj_thread_register(
|
||||||
|
thread_name.as_ptr(),
|
||||||
|
thread_desc.as_mut_ptr(),
|
||||||
|
&mut thread_ptr,
|
||||||
|
);
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
tracing::error!("Failed to register audio thread with PJLIB: {}", status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::debug!("Audio thread registered with PJLIB successfully");
|
||||||
|
} else {
|
||||||
|
tracing::debug!("Audio thread already registered with PJLIB");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate frame buffer (16-bit samples)
|
||||||
|
let frame_size_bytes = SAMPLES_PER_FRAME * 2; // 2 bytes per i16 sample
|
||||||
|
let mut frame_buffer: Vec<u8> = vec![0u8; frame_size_bytes];
|
||||||
|
let mut timestamp: u64 = 0;
|
||||||
|
let mut frame_count: u64 = 0;
|
||||||
|
|
||||||
|
let mut active_channels: Vec<Snowflake> = Vec::with_capacity(32);
|
||||||
|
let mut drain_buf: Vec<i16> = vec![0i16; SAMPLES_PER_FRAME];
|
||||||
|
let silence: Vec<i16> = vec![0i16; SAMPLES_PER_FRAME];
|
||||||
|
|
||||||
|
// Use deadline-based timing instead of duration-based timing.
|
||||||
|
// This prevents sleep overrun from accumulating frame after frame.
|
||||||
|
let frame_duration = std::time::Duration::from_millis(FRAME_PTIME_MS as u64);
|
||||||
|
let mut next_frame_deadline = Instant::now() + frame_duration;
|
||||||
|
|
||||||
|
while AUDIO_THREAD_RUNNING.load(Ordering::SeqCst) {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
// Process one frame
|
||||||
|
unsafe {
|
||||||
|
process_audio_frame(
|
||||||
|
&mut frame_buffer,
|
||||||
|
&mut timestamp,
|
||||||
|
&mut frame_count,
|
||||||
|
&mut active_channels,
|
||||||
|
&mut drain_buf,
|
||||||
|
&silence,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the first frame, mark audio thread as ready and process any pending
|
||||||
|
// channel registrations. This ensures the conference bridge is actively being
|
||||||
|
// clocked when we make connections via pjsua_conf_connect.
|
||||||
|
if frame_count == 1 {
|
||||||
|
AUDIO_THREAD_READY.store(true, Ordering::SeqCst);
|
||||||
|
tracing::debug!("Audio thread ready after first frame, processing pending channel completions");
|
||||||
|
process_pending_channel_completions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process any pending conference connections (must be done in audio thread
|
||||||
|
// to avoid conflicts with pjmedia_port_get_frame)
|
||||||
|
process_pending_conf_connections(frame_count);
|
||||||
|
|
||||||
|
// Process any pending PJSUA operations (answer, hangup, play)
|
||||||
|
// These must run in the audio thread to avoid deadlocks with conf_connect/disconnect
|
||||||
|
process_pending_pjsua_ops();
|
||||||
|
|
||||||
|
// Track frame processing time for latency diagnostics
|
||||||
|
let processing_elapsed = start.elapsed();
|
||||||
|
let processing_ms = processing_elapsed.as_secs_f64() * 1000.0;
|
||||||
|
|
||||||
|
// Warn if processing took longer than frame time (20ms) - this causes audio crunch
|
||||||
|
if processing_ms > FRAME_PTIME_MS as f64 {
|
||||||
|
tracing::warn!(
|
||||||
|
"AUDIO OVERRUN: Frame #{} processing took {:.2}ms (>{}ms), audio will crunch!",
|
||||||
|
frame_count, processing_ms, FRAME_PTIME_MS
|
||||||
|
);
|
||||||
|
} else if processing_ms > (FRAME_PTIME_MS as f64 * 0.8) {
|
||||||
|
// Warn if approaching the limit (>80% of frame time)
|
||||||
|
tracing::debug!(
|
||||||
|
"Audio frame #{} processing took {:.2}ms (approaching {}ms limit)",
|
||||||
|
frame_count,
|
||||||
|
processing_ms,
|
||||||
|
FRAME_PTIME_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log every 5 seconds (250 frames at 20ms each) that we're still alive
|
||||||
|
if frame_count.is_multiple_of(250) {
|
||||||
|
let call_ids: Vec<CallId> = COUNTED_CALL_IDS
|
||||||
|
.get()
|
||||||
|
.map(|ids| ids.lock().iter().copied().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"Audio thread: frame #{}, active_calls={}, call_ids={:?}",
|
||||||
|
frame_count,
|
||||||
|
call_ids.len(),
|
||||||
|
call_ids
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deadline-based sleep: sleep until the next frame deadline, not for a duration.
|
||||||
|
// This compensates for any sleep overrun on the next frame.
|
||||||
|
let now = Instant::now();
|
||||||
|
if next_frame_deadline > now {
|
||||||
|
std::thread::sleep(next_frame_deadline - now);
|
||||||
|
}
|
||||||
|
// Advance deadline for next frame (even if we're behind, keep the cadence)
|
||||||
|
next_frame_deadline += frame_duration;
|
||||||
|
|
||||||
|
// If we've fallen more than 5 frames behind (100ms), reset the deadline
|
||||||
|
// to avoid a burst of catch-up frames that would cause audio glitches
|
||||||
|
if next_frame_deadline + std::time::Duration::from_millis(100) < Instant::now() {
|
||||||
|
tracing::warn!(
|
||||||
|
"Audio thread fell behind by >100ms, resetting deadline (frame #{})",
|
||||||
|
frame_count
|
||||||
|
);
|
||||||
|
next_frame_deadline = Instant::now() + frame_duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"Audio processing thread exiting - AUDIO_THREAD_RUNNING is false, frame_count={}",
|
||||||
|
frame_count
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
tracing::error!("AUDIO THREAD PANICKED: {:?}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store the handle for joining later
|
||||||
|
let handle_storage = AUDIO_THREAD_HANDLE.get_or_init(|| Mutex::new(None));
|
||||||
|
*handle_storage.lock() = Some(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the audio processing thread
|
||||||
|
pub fn stop_audio_thread() {
|
||||||
|
let active_calls = COUNTED_CALL_IDS
|
||||||
|
.get()
|
||||||
|
.map(|ids| ids.lock().len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
tracing::debug!(
|
||||||
|
"Stopping audio thread (active_media_calls={}, was_running={})",
|
||||||
|
active_calls,
|
||||||
|
AUDIO_THREAD_RUNNING.load(Ordering::SeqCst)
|
||||||
|
);
|
||||||
|
AUDIO_THREAD_RUNNING.store(false, Ordering::SeqCst);
|
||||||
|
AUDIO_THREAD_READY.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
|
// Wait for the thread to stop with a bounded timeout.
|
||||||
|
// If the thread is blocked on a conference bridge lock, we don't want
|
||||||
|
// shutdown to hang indefinitely. The 2s force-exit timer in main.rs
|
||||||
|
// is a final backstop, but this avoids relying on a hard process exit.
|
||||||
|
if let Some(handle_storage) = AUDIO_THREAD_HANDLE.get() {
|
||||||
|
if let Some(handle) = handle_storage.lock().take() {
|
||||||
|
tracing::debug!("Joining audio thread (2s timeout)...");
|
||||||
|
let (done_tx, done_rx) = std::sync::mpsc::channel();
|
||||||
|
let join_thread = std::thread::spawn(move || {
|
||||||
|
let result = handle.join();
|
||||||
|
let _ = done_tx.send(result);
|
||||||
|
});
|
||||||
|
match done_rx.recv_timeout(std::time::Duration::from_secs(2)) {
|
||||||
|
Ok(Ok(())) => {
|
||||||
|
tracing::debug!("Audio thread joined successfully");
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
tracing::error!("Audio thread panicked: {:?}", e);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!("Audio thread join timed out after 2s, detaching");
|
||||||
|
// Detach the join thread — the audio thread will be
|
||||||
|
// cleaned up by process exit
|
||||||
|
drop(join_thread);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process any pending channel registration completions
|
||||||
|
/// Called from the audio thread after it has processed its first frame
|
||||||
|
fn process_pending_channel_completions() {
|
||||||
|
let mut count = 0;
|
||||||
|
while let Some((call_id, conf_port)) = PENDING_CHANNEL_COMPLETIONS.pop() {
|
||||||
|
tracing::debug!(
|
||||||
|
"Completing deferred channel registration: call {} -> conf_port {}",
|
||||||
|
call_id,
|
||||||
|
conf_port
|
||||||
|
);
|
||||||
|
complete_pending_channel_registration(call_id, conf_port);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
tracing::debug!("Processed {} pending channel completions", count);
|
||||||
|
} else {
|
||||||
|
tracing::debug!("No pending channel completions to process");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process any pending conference connections
|
||||||
|
/// Called from the audio thread every frame to handle newly registered calls
|
||||||
|
fn process_pending_conf_connections(_frame_count: u64) {
|
||||||
|
use super::channel_audio::complete_conf_connections;
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
while let Some((call_id, channel_id)) = PENDING_CONF_CONNECTIONS.pop() {
|
||||||
|
tracing::debug!(
|
||||||
|
"Audio thread making conference connections: call {} -> channel {}",
|
||||||
|
call_id,
|
||||||
|
channel_id
|
||||||
|
);
|
||||||
|
complete_conf_connections(call_id, channel_id);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
tracing::debug!(
|
||||||
|
"Audio thread processed {} pending conference connections",
|
||||||
|
count
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process any pending PJSUA operations
|
||||||
|
/// Called from the audio thread every frame to handle queued operations
|
||||||
|
/// that would deadlock if called from other threads during audio processing
|
||||||
|
fn is_call_valid(call_id: CallId) -> bool {
|
||||||
|
unsafe {
|
||||||
|
let mut ci = MaybeUninit::<pjsua_call_info>::uninit();
|
||||||
|
let status = pjsua_call_get_info(*call_id, ci.as_mut_ptr());
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let ci = ci.assume_init();
|
||||||
|
ci.state != pjsip_inv_state_PJSIP_INV_STATE_DISCONNECTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_pending_pjsua_ops() {
|
||||||
|
use super::ffi::direct_player::play_audio_to_call_direct_internal;
|
||||||
|
use super::ffi::streaming_player::start_streaming_to_call;
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
while let Some(op) = PENDING_PJSUA_OPS.pop() {
|
||||||
|
// Validate that the call still exists before processing the op
|
||||||
|
let call_id = match &op {
|
||||||
|
PendingPjsuaOp::PlayDirect { call_id, .. } => Some(*call_id),
|
||||||
|
PendingPjsuaOp::StartLoop { call_id, .. } => Some(*call_id),
|
||||||
|
PendingPjsuaOp::StartStreaming { call_id, .. } => Some(*call_id),
|
||||||
|
PendingPjsuaOp::StartTestTone { call_id } => Some(*call_id),
|
||||||
|
PendingPjsuaOp::Hangup { call_id } => Some(*call_id),
|
||||||
|
PendingPjsuaOp::ConnectFaxPort { call_id, .. } => Some(*call_id),
|
||||||
|
};
|
||||||
|
if let Some(cid) = call_id {
|
||||||
|
if !is_call_valid(cid) {
|
||||||
|
tracing::warn!("Skipping stale op for dead call {}: {:?}", cid, op);
|
||||||
|
// For ConnectFaxPort, signal failure so the caller doesn't hang
|
||||||
|
if let PendingPjsuaOp::ConnectFaxPort { done_tx, .. } = op {
|
||||||
|
let _ = done_tx.send(false);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
match op {
|
||||||
|
PendingPjsuaOp::PlayDirect { call_id, samples } => {
|
||||||
|
tracing::debug!(
|
||||||
|
"Audio thread: executing PlayDirect for call {} ({} samples)",
|
||||||
|
call_id,
|
||||||
|
samples.len()
|
||||||
|
);
|
||||||
|
// Stop any active looping player for this call first
|
||||||
|
// This ensures a seamless transition from connecting sound to join sound
|
||||||
|
super::ffi::looping_player::stop_loop(call_id);
|
||||||
|
|
||||||
|
if let Err(e) = play_audio_to_call_direct_internal(call_id, &samples) {
|
||||||
|
tracing::warn!("Failed to play direct audio to call {}: {}", call_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PendingPjsuaOp::StartStreaming {
|
||||||
|
call_id,
|
||||||
|
path,
|
||||||
|
hangup_on_complete,
|
||||||
|
} => {
|
||||||
|
tracing::debug!(
|
||||||
|
"Audio thread: executing StartStreaming for call {} ({})",
|
||||||
|
call_id,
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
// Stop any active looping player for this call first
|
||||||
|
super::ffi::looping_player::stop_loop(call_id);
|
||||||
|
|
||||||
|
if let Err(e) = start_streaming_to_call(call_id, &path, hangup_on_complete) {
|
||||||
|
tracing::warn!("Failed to start streaming for call {}: {}", call_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PendingPjsuaOp::StartTestTone { call_id } => {
|
||||||
|
tracing::debug!("Audio thread: executing StartTestTone for call {}", call_id);
|
||||||
|
// Stop any active looping player for this call first
|
||||||
|
super::ffi::looping_player::stop_loop(call_id);
|
||||||
|
|
||||||
|
if let Err(e) = super::ffi::test_tone::start_test_tone_to_call(call_id) {
|
||||||
|
tracing::warn!("Failed to start test tone for call {}: {}", call_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PendingPjsuaOp::Hangup { call_id } => {
|
||||||
|
tracing::debug!("Audio thread: executing Hangup for call {}", call_id);
|
||||||
|
// Stop any active looping player for this call first
|
||||||
|
super::ffi::looping_player::stop_loop(call_id);
|
||||||
|
// Hangup the call
|
||||||
|
unsafe {
|
||||||
|
pjsua::pjsua_call_hangup(*call_id, 200, std::ptr::null(), std::ptr::null());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PendingPjsuaOp::StartLoop { call_id, samples } => {
|
||||||
|
tracing::debug!("Audio thread: executing StartLoop for call {}", call_id);
|
||||||
|
if let Err(e) = super::ffi::looping_player::start_loop(call_id, samples) {
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to start connecting loop for call {}: {}",
|
||||||
|
call_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PendingPjsuaOp::ConnectFaxPort {
|
||||||
|
call_id,
|
||||||
|
fax_slot,
|
||||||
|
call_conf_port,
|
||||||
|
done_tx,
|
||||||
|
} => {
|
||||||
|
tracing::debug!(
|
||||||
|
"Audio thread: connecting fax port for call {} (fax_slot={}, call_port={})",
|
||||||
|
call_id,
|
||||||
|
fax_slot,
|
||||||
|
call_conf_port
|
||||||
|
);
|
||||||
|
let success = unsafe {
|
||||||
|
let conf = super::ffi::frame_utils::get_conference_bridge();
|
||||||
|
if let Some(conf) = conf {
|
||||||
|
let s1 = pjmedia_conf_connect_port(
|
||||||
|
conf,
|
||||||
|
*call_conf_port as u32,
|
||||||
|
*fax_slot as u32,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
let s2 = pjmedia_conf_connect_port(
|
||||||
|
conf,
|
||||||
|
*fax_slot as u32,
|
||||||
|
*call_conf_port as u32,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if s1 != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to connect call {} -> fax slot {}: {}",
|
||||||
|
call_id,
|
||||||
|
fax_slot,
|
||||||
|
s1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if s2 != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to connect fax slot {} -> call {}: {}",
|
||||||
|
fax_slot,
|
||||||
|
call_id,
|
||||||
|
s2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
s1 == pj_constants__PJ_SUCCESS as i32
|
||||||
|
&& s2 == pj_constants__PJ_SUCCESS as i32
|
||||||
|
} else {
|
||||||
|
tracing::error!("Cannot get conference bridge for fax port connection");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = done_tx.send(success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
tracing::debug!("Audio thread processed {} pending PJSUA operations", count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue a channel registration completion for when the audio thread is ready
|
||||||
|
/// Returns true if queued, false if audio thread is ready (caller should complete immediately)
|
||||||
|
pub fn queue_pending_channel_completion(call_id: CallId, conf_port: ConfPort) -> bool {
|
||||||
|
if AUDIO_THREAD_READY.load(Ordering::SeqCst) {
|
||||||
|
// Audio thread is ready, caller should complete immediately
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue for later processing
|
||||||
|
PENDING_CHANNEL_COMPLETIONS.push((call_id, conf_port));
|
||||||
|
tracing::debug!(
|
||||||
|
"Queued pending channel completion: call {} -> conf_port {} (audio thread not ready yet)",
|
||||||
|
call_id,
|
||||||
|
conf_port
|
||||||
|
);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process one audio frame (called from audio thread)
|
||||||
|
///
|
||||||
|
/// This function handles per-channel audio isolation using a SINGLE clock tick:
|
||||||
|
/// 1. Clock the conference ONCE via pjmedia_port_get_frame (runs all codecs, jitter buffers, etc.)
|
||||||
|
/// 2. During that tick, channel_port_put_frame callbacks receive audio from connected calls
|
||||||
|
/// 3. Drain the per-channel SIP->Discord buffers and send to Discord
|
||||||
|
///
|
||||||
|
/// This architecture ensures the conference only advances once per 20ms frame, regardless of
|
||||||
|
/// how many channels are active. Previously, we clocked once PER CHANNEL which caused audio
|
||||||
|
/// to run at N*speed (stuttering, delays) when N channels were active.
|
||||||
|
unsafe fn process_audio_frame(
|
||||||
|
frame_buffer: &mut [u8],
|
||||||
|
timestamp: &mut u64,
|
||||||
|
frame_count: &mut u64,
|
||||||
|
active_channels: &mut Vec<Snowflake>,
|
||||||
|
drain_buf: &mut [i16],
|
||||||
|
silence: &[i16],
|
||||||
|
) {
|
||||||
|
use super::channel_audio::drain_sip_to_discord_audio;
|
||||||
|
|
||||||
|
*frame_count += 1;
|
||||||
|
|
||||||
|
// Increment global frame counter for channel port caching
|
||||||
|
// This ensures channel_port_get_frame only drains buffers once per tick
|
||||||
|
AUDIO_FRAME_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
|
||||||
|
let port_guard = match CONF_MASTER_PORT.get() {
|
||||||
|
Some(guard) => guard,
|
||||||
|
None => {
|
||||||
|
if (*frame_count).is_multiple_of(500) {
|
||||||
|
tracing::warn!("Audio thread: No master port configured");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let master_port = port_guard.lock().0;
|
||||||
|
if master_port.is_null() {
|
||||||
|
if (*frame_count).is_multiple_of(500) {
|
||||||
|
tracing::warn!("Audio thread: Master port is null");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log every 5 seconds (250 frames at 20ms each)
|
||||||
|
let should_log = (*frame_count).is_multiple_of(250);
|
||||||
|
|
||||||
|
// Get snapshots of channel mappings (reuses allocation)
|
||||||
|
get_active_channels_into(active_channels);
|
||||||
|
|
||||||
|
if should_log {
|
||||||
|
tracing::trace!("Audio thread: {} active channels", active_channels.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log when we first start processing active channels
|
||||||
|
let first_active = FIRST_ACTIVE_CHANNEL_FRAME.load(Ordering::Relaxed);
|
||||||
|
if first_active == 0 && !active_channels.is_empty() {
|
||||||
|
FIRST_ACTIVE_CHANNEL_FRAME.store(*frame_count, Ordering::Relaxed);
|
||||||
|
tracing::info!(
|
||||||
|
"Audio thread frame #{}: FIRST frame with active channels: {:?}",
|
||||||
|
*frame_count,
|
||||||
|
active_channels
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Clock the conference EXACTLY ONCE per frame
|
||||||
|
// This runs ALL the internal processing:
|
||||||
|
// - Jitter buffers for all calls
|
||||||
|
// - Codec decode/encode for all calls
|
||||||
|
// - Mixing for all connected ports
|
||||||
|
// - Calls channel_port_get_frame for Discord->SIP (provides audio TO calls)
|
||||||
|
// - Calls channel_port_put_frame for SIP->Discord (receives audio FROM calls)
|
||||||
|
let mut clock_frame = pjmedia_frame {
|
||||||
|
type_: pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO,
|
||||||
|
buf: frame_buffer.as_mut_ptr() as *mut _,
|
||||||
|
size: frame_buffer.len() as pj_size_t,
|
||||||
|
timestamp: pj_timestamp { u64_: *timestamp },
|
||||||
|
bit_info: 0,
|
||||||
|
};
|
||||||
|
pjmedia_port_get_frame(master_port, &mut clock_frame);
|
||||||
|
|
||||||
|
// Now drain the SIP->Discord buffers that were filled by channel_port_put_frame callbacks
|
||||||
|
// during the conference tick above.
|
||||||
|
// Lock callbacks ONCE per frame (not per channel) to avoid N Mutex acquisitions.
|
||||||
|
if !active_channels.is_empty() {
|
||||||
|
let callbacks_guard = CALLBACKS.get().map(|c| c.lock());
|
||||||
|
let on_audio_frame = callbacks_guard
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|g| g.as_ref())
|
||||||
|
.map(|h| &h.on_audio_frame);
|
||||||
|
|
||||||
|
for &channel_id in active_channels.iter() {
|
||||||
|
// Drain one frame's worth of audio into pre-allocated buffer
|
||||||
|
let n = drain_sip_to_discord_audio(channel_id, drain_buf);
|
||||||
|
|
||||||
|
// ALWAYS send something to keep Discord stream alive (even if just silence)
|
||||||
|
let samples: &[i16] = if n > 0 { &drain_buf[..n] } else { silence };
|
||||||
|
|
||||||
|
// Log periodically
|
||||||
|
if should_log {
|
||||||
|
let max_sample = simd::max_abs_i16(samples);
|
||||||
|
tracing::trace!(
|
||||||
|
"SIP->Discord: {} samples from channel {}, max_amp={}",
|
||||||
|
samples.len(),
|
||||||
|
channel_id,
|
||||||
|
max_sample
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit audio for THIS channel specifically
|
||||||
|
if let Some(on_audio_frame) = on_audio_frame {
|
||||||
|
on_audio_frame(channel_id, samples, CONF_SAMPLE_RATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment timestamp
|
||||||
|
*timestamp += SAMPLES_PER_FRAME as u64;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RTP activity tracking
|
||||||
|
|
||||||
|
/// Get the total RTP packets received for a call
|
||||||
|
/// Returns None if call doesn't exist or stats unavailable
|
||||||
|
fn get_call_rtp_rx_count(call_id: CallId) -> Option<u64> {
|
||||||
|
unsafe {
|
||||||
|
let mut stat = MaybeUninit::<pjsua_stream_stat>::uninit();
|
||||||
|
let status = pjsua_call_get_stream_stat(*call_id, 0, stat.as_mut_ptr());
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let stat = stat.assume_init();
|
||||||
|
// rtcp.rx.pkt contains total RTP packets received
|
||||||
|
Some(stat.rtcp.rx.pkt as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the event sender for timeout events
|
||||||
|
pub fn set_timeout_event_sender(tx: Sender<super::SipEvent>) {
|
||||||
|
let sender = TIMEOUT_EVENT_TX.get_or_init(|| Mutex::new(None));
|
||||||
|
*sender.lock() = Some(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize RTP activity tracking for a call
|
||||||
|
pub fn init_call_rtp_tracking(call_id: CallId) {
|
||||||
|
let activity_map =
|
||||||
|
CALL_RTP_ACTIVITY.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
|
||||||
|
// Start with count 0 - the periodic check will update with actual values
|
||||||
|
activity_map.lock().insert(call_id, (0, Instant::now()));
|
||||||
|
tracing::debug!("Initialized RTP tracking for call {}", call_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove RTP activity tracking for a call
|
||||||
|
pub fn remove_call_rtp_tracking(call_id: CallId) {
|
||||||
|
if let Some(activity_map) = CALL_RTP_ACTIVITY.get() {
|
||||||
|
activity_map.lock().remove(&call_id);
|
||||||
|
tracing::debug!("Removed RTP tracking for call {}", call_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check all tracked calls for RTP inactivity and emit timeout events
|
||||||
|
///
|
||||||
|
/// This must be called from the PJSUA thread context, not from the audio thread,
|
||||||
|
/// because it calls pjsua_call_get_stream_stat() which requires PJSUA thread synchronization.
|
||||||
|
pub fn check_rtp_inactivity() {
|
||||||
|
let Some(activity_map) = CALL_RTP_ACTIVITY.get() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect all tracked calls first, then release the lock before calling PJSUA
|
||||||
|
let tracked_calls: Vec<(CallId, u64, Instant)> = {
|
||||||
|
let map = activity_map.lock();
|
||||||
|
map.iter()
|
||||||
|
.map(|(&call_id, &(rx_count, last_activity))| (call_id, rx_count, last_activity))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut timed_out_calls: Vec<(CallId, u64)> = Vec::new();
|
||||||
|
let mut updates = Vec::new();
|
||||||
|
|
||||||
|
// Now iterate without holding the lock
|
||||||
|
for (call_id, last_rx_count, last_activity) in tracked_calls {
|
||||||
|
let current_rx = match get_call_rtp_rx_count(call_id) {
|
||||||
|
Some(count) => count,
|
||||||
|
None => {
|
||||||
|
// Call stats unavailable - likely dead call
|
||||||
|
// Don't wait for on_call_state_cb which may never fire
|
||||||
|
tracing::warn!(
|
||||||
|
"Call {} RTP stats unavailable, treating as timed out",
|
||||||
|
call_id
|
||||||
|
);
|
||||||
|
timed_out_calls.push((call_id, 0));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if current_rx > last_rx_count {
|
||||||
|
// Activity detected - queue update
|
||||||
|
updates.push((call_id, current_rx));
|
||||||
|
} else {
|
||||||
|
// No new packets — use a shorter timeout if we never received any audio
|
||||||
|
let timeout = if current_rx == 0 {
|
||||||
|
no_audio_timeout_secs()
|
||||||
|
} else {
|
||||||
|
rtp_inactivity_timeout_secs()
|
||||||
|
};
|
||||||
|
let elapsed = last_activity.elapsed().as_secs();
|
||||||
|
if elapsed > timeout {
|
||||||
|
tracing::warn!(
|
||||||
|
"Call {} timed out: no RTP activity for {}s (rx_count={}, timeout={}s)",
|
||||||
|
call_id,
|
||||||
|
elapsed,
|
||||||
|
current_rx,
|
||||||
|
timeout
|
||||||
|
);
|
||||||
|
timed_out_calls.push((call_id, current_rx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply updates
|
||||||
|
if !updates.is_empty() {
|
||||||
|
let mut map = activity_map.lock();
|
||||||
|
for (call_id, rx_count) in updates {
|
||||||
|
map.insert(call_id, (rx_count, Instant::now()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit timeout events for dead calls
|
||||||
|
if !timed_out_calls.is_empty() {
|
||||||
|
// Remove timed out calls from tracking
|
||||||
|
{
|
||||||
|
let mut map = activity_map.lock();
|
||||||
|
for &(call_id, _) in &timed_out_calls {
|
||||||
|
map.remove(&call_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(sender_lock) = TIMEOUT_EVENT_TX.get() {
|
||||||
|
if let Some(ref tx) = *sender_lock.lock() {
|
||||||
|
for (call_id, rx_count) in timed_out_calls {
|
||||||
|
let _ = tx.send(super::SipEvent::CallTimeout { call_id, rx_count });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate all entries in COUNTED_CALL_IDS are still valid PJSUA calls
|
||||||
|
/// Removes stale entries and returns the number removed.
|
||||||
|
/// This should be called periodically from the SIP event loop.
|
||||||
|
pub fn validate_counted_calls() -> usize {
|
||||||
|
let Some(counted_ids) = COUNTED_CALL_IDS.get() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
let call_ids: Vec<CallId> = counted_ids.lock().iter().copied().collect();
|
||||||
|
let mut removed = 0;
|
||||||
|
|
||||||
|
// Get RTP tracking info for cross-reference
|
||||||
|
let rtp_tracked_calls: std::collections::HashSet<CallId> = CALL_RTP_ACTIVITY
|
||||||
|
.get()
|
||||||
|
.map(|m| m.lock().keys().copied().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
for call_id in call_ids {
|
||||||
|
unsafe {
|
||||||
|
let mut ci = MaybeUninit::<pjsua_call_info>::uninit();
|
||||||
|
let status = pjsua_call_get_info(*call_id, ci.as_mut_ptr());
|
||||||
|
|
||||||
|
let should_remove = if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
tracing::warn!(
|
||||||
|
"Stale call {} in COUNTED_CALL_IDS: pjsua_call_get_info failed (status={})",
|
||||||
|
call_id,
|
||||||
|
status
|
||||||
|
);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
let ci = ci.assume_init();
|
||||||
|
if ci.state == pjsip_inv_state_PJSIP_INV_STATE_DISCONNECTED {
|
||||||
|
tracing::warn!(
|
||||||
|
"Stale call {} in COUNTED_CALL_IDS: already DISCONNECTED",
|
||||||
|
call_id
|
||||||
|
);
|
||||||
|
true
|
||||||
|
} else if !rtp_tracked_calls.contains(&call_id) {
|
||||||
|
// Call is in COUNTED but NOT being tracked for RTP activity.
|
||||||
|
// However, REMOTE_HOLD intentionally removes RTP tracking
|
||||||
|
// (phones send no RTP during hold), so don't treat those as stale.
|
||||||
|
if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_REMOTE_HOLD {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"Stale call {} in COUNTED_CALL_IDS: not in RTP tracking (state={}, media={})",
|
||||||
|
call_id,
|
||||||
|
ci.state,
|
||||||
|
ci.media_status
|
||||||
|
);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if should_remove {
|
||||||
|
counted_ids.lock().remove(&call_id);
|
||||||
|
remove_call_rtp_tracking(call_id);
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if removed > 0 {
|
||||||
|
let remaining = counted_ids.lock().len();
|
||||||
|
tracing::warn!(
|
||||||
|
"Removed {} stale calls from COUNTED_CALL_IDS, {} remaining",
|
||||||
|
removed,
|
||||||
|
remaining
|
||||||
|
);
|
||||||
|
if remaining == 0 {
|
||||||
|
stop_audio_thread();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan all pjsua call slots and force-hangup zombie calls.
|
||||||
|
///
|
||||||
|
/// Unlike `validate_counted_calls()` which only checks COUNTED_CALL_IDS (authenticated calls),
|
||||||
|
/// this scans the raw pjsua call array for slots that are stuck — e.g. calls rejected early
|
||||||
|
/// (banned IPs, 401 challenges, spam) where the SIP transaction never completed and the slot
|
||||||
|
/// was never freed.
|
||||||
|
///
|
||||||
|
/// A call is considered a zombie if:
|
||||||
|
/// - It's been in a non-CONFIRMED state (NULL, CALLING, INCOMING, EARLY, CONNECTING) for
|
||||||
|
/// more than 2 minutes (SIP transaction timeout is 32s, so 2min is very generous)
|
||||||
|
/// - It's in DISCONNECTED state but the slot hasn't been freed (shouldn't happen, but safety net)
|
||||||
|
pub fn cleanup_zombie_pjsua_calls() -> usize {
|
||||||
|
let max_calls: u32 = 128; // Must match cfg_ptr.max_calls in init.rs
|
||||||
|
let mut cleaned = 0;
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
for i in 0..max_calls {
|
||||||
|
let call_id = i as pjsua_call_id;
|
||||||
|
let mut ci = MaybeUninit::<pjsua_call_info>::uninit();
|
||||||
|
let status = pjsua_call_get_info(call_id, ci.as_mut_ptr());
|
||||||
|
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
// Slot is free (no inv), this is fine
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ci = ci.assume_init();
|
||||||
|
|
||||||
|
// Skip calls that are actively connected (CONFIRMED state) — those are real calls
|
||||||
|
if ci.state == pjsip_inv_state_PJSIP_INV_STATE_CONFIRMED {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-CONFIRMED calls, check how long they've been alive.
|
||||||
|
// total_duration is time since call->start_time for non-CONFIRMED/DISCONNECTED calls.
|
||||||
|
let age = ci.total_duration.sec as u64;
|
||||||
|
|
||||||
|
// 2 minutes is very generous — SIP transaction timeout (Timer B) is 32 seconds,
|
||||||
|
// and even slow auth flows should complete within 30 seconds
|
||||||
|
if age > 120 {
|
||||||
|
let state_name = super::ffi::init::InvState::from(ci.state);
|
||||||
|
|
||||||
|
tracing::warn!(
|
||||||
|
"Zombie pjsua call slot {}: state={}, age={}s — force hanging up",
|
||||||
|
call_id,
|
||||||
|
state_name,
|
||||||
|
age
|
||||||
|
);
|
||||||
|
|
||||||
|
pjsua_call_hangup(call_id, 500, std::ptr::null(), std::ptr::null());
|
||||||
|
cleaned += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cleaned > 0 {
|
||||||
|
tracing::warn!("Force-cleaned {} zombie pjsua call slots", cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned
|
||||||
|
}
|
||||||
1509
sipcord-bridge/src/transport/sip/callbacks.rs
Normal file
1509
sipcord-bridge/src/transport/sip/callbacks.rs
Normal file
File diff suppressed because it is too large
Load diff
1044
sipcord-bridge/src/transport/sip/channel_audio.rs
Normal file
1044
sipcord-bridge/src/transport/sip/channel_audio.rs
Normal file
File diff suppressed because it is too large
Load diff
161
sipcord-bridge/src/transport/sip/ffi/direct_player.rs
Normal file
161
sipcord-bridge/src/transport/sip/ffi/direct_player.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
//! Direct player port for playing audio to a single call
|
||||||
|
//!
|
||||||
|
//! This module provides one-shot audio playback (e.g., join sounds) that
|
||||||
|
//! bypasses the channel buffer and plays directly to a specific call.
|
||||||
|
|
||||||
|
use super::types::*;
|
||||||
|
use anyhow::Result;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use pjsua::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Custom get_frame callback for direct player ports
|
||||||
|
/// Returns samples from the player's buffer, advancing position each call
|
||||||
|
pub unsafe extern "C" fn direct_player_get_frame(
|
||||||
|
this_port: *mut pjmedia_port,
|
||||||
|
frame: *mut pjmedia_frame,
|
||||||
|
) -> pj_status_t {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static GET_FRAME_CALL_COUNT: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let call_count = GET_FRAME_CALL_COUNT.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
|
// Log first 10 calls to confirm this callback is being invoked
|
||||||
|
if call_count < 10 {
|
||||||
|
tracing::trace!(
|
||||||
|
"direct_player_get_frame called (call #{}, port={:p})",
|
||||||
|
call_count,
|
||||||
|
this_port
|
||||||
|
);
|
||||||
|
} else if call_count == 10 {
|
||||||
|
tracing::trace!("direct_player_get_frame: suppressing further per-call logs");
|
||||||
|
}
|
||||||
|
|
||||||
|
if this_port.is_null() || frame.is_null() {
|
||||||
|
return -1; // PJ_EINVAL
|
||||||
|
}
|
||||||
|
|
||||||
|
let port_key = this_port as usize;
|
||||||
|
|
||||||
|
// Get samples from the player's buffer and fill frame directly (no intermediate Vec)
|
||||||
|
{
|
||||||
|
let state = DIRECT_PLAYER_STATE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
let mut state = state.lock();
|
||||||
|
|
||||||
|
if let Some((buffer, pos)) = state.get_mut(&port_key) {
|
||||||
|
if *pos < buffer.len() {
|
||||||
|
let end = (*pos + SAMPLES_PER_FRAME).min(buffer.len());
|
||||||
|
super::frame_utils::fill_audio_frame(frame, &buffer[*pos..end]);
|
||||||
|
*pos = end;
|
||||||
|
} else {
|
||||||
|
super::frame_utils::fill_silence_frame(frame); // Playback complete
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
super::frame_utils::fill_silence_frame(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pj_constants__PJ_SUCCESS as pj_status_t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom on_destroy callback for direct player ports
|
||||||
|
pub unsafe extern "C" fn direct_player_on_destroy(this_port: *mut pjmedia_port) -> pj_status_t {
|
||||||
|
if !this_port.is_null() {
|
||||||
|
let port_key = this_port as usize;
|
||||||
|
if let Some(state) = DIRECT_PLAYER_STATE.get() {
|
||||||
|
state.lock().remove(&port_key);
|
||||||
|
}
|
||||||
|
tracing::debug!("Direct player port destroyed: {:p}", this_port);
|
||||||
|
}
|
||||||
|
pj_constants__PJ_SUCCESS as pj_status_t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play audio directly to a specific call's conference port using a custom player port.
|
||||||
|
/// This bypasses the channel buffer - used for join sounds to avoid overflow.
|
||||||
|
///
|
||||||
|
/// The player port connects directly to the call's conf_port, so only that caller
|
||||||
|
/// hears the audio. Other callers and Discord users don't hear it.
|
||||||
|
///
|
||||||
|
/// This queues the operation to be executed by the audio thread to avoid
|
||||||
|
/// deadlocks with the audio thread's pjsua_conf_connect/disconnect calls.
|
||||||
|
pub fn play_audio_to_call_direct(call_id: CallId, samples: &[i16]) -> Result<()> {
|
||||||
|
use super::types::{queue_pjsua_op, PendingPjsuaOp};
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"Queueing PlayDirect for call {} ({} samples)",
|
||||||
|
call_id,
|
||||||
|
samples.len()
|
||||||
|
);
|
||||||
|
queue_pjsua_op(PendingPjsuaOp::PlayDirect {
|
||||||
|
call_id,
|
||||||
|
samples: samples.to_vec(),
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal implementation of play_audio_to_call_direct
|
||||||
|
/// Called from the audio thread to actually create and connect the player
|
||||||
|
pub fn play_audio_to_call_direct_internal(call_id: CallId, samples: &[i16]) -> Result<()> {
|
||||||
|
use super::frame_utils::{create_and_connect_port, PortCallbacks};
|
||||||
|
|
||||||
|
// Get call's conference port
|
||||||
|
let call_conf_port = CALL_CONF_PORTS
|
||||||
|
.get()
|
||||||
|
.and_then(|p| p.get(&call_id).map(|r| *r))
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No conf_port for call {}", call_id))?;
|
||||||
|
|
||||||
|
// Store samples in the player state BEFORE creating port (get_frame needs them)
|
||||||
|
// We'll clean up if port creation fails
|
||||||
|
let guard = unsafe {
|
||||||
|
let callbacks = PortCallbacks {
|
||||||
|
get_frame: direct_player_get_frame,
|
||||||
|
put_frame: super::frame_utils::noop_put_frame,
|
||||||
|
on_destroy: Some(direct_player_on_destroy),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pre-store samples so get_frame can find them even during pjsua_conf_add_port
|
||||||
|
// We'll use a temporary key (0) and fix it after we get the actual port pointer
|
||||||
|
let guard = create_and_connect_port(
|
||||||
|
&DIRECT_PLAYER_POOL,
|
||||||
|
b"direct_players\0",
|
||||||
|
"dplay",
|
||||||
|
call_id,
|
||||||
|
0x4450_4C59, // "DPLY"
|
||||||
|
callbacks,
|
||||||
|
call_conf_port,
|
||||||
|
);
|
||||||
|
|
||||||
|
match guard {
|
||||||
|
Ok(guard) => {
|
||||||
|
// Now store samples with the actual port key
|
||||||
|
let state = DIRECT_PLAYER_STATE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
state.lock().insert(guard.port_key, (samples.to_vec(), 0));
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"Playing {} samples directly to call {} (player_slot={}, call_port={})",
|
||||||
|
samples.len(),
|
||||||
|
call_id,
|
||||||
|
guard.slot,
|
||||||
|
call_conf_port
|
||||||
|
);
|
||||||
|
|
||||||
|
guard
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Schedule cleanup after playback duration
|
||||||
|
// The ConfPortGuard handles pjsua_conf_remove_port when dropped
|
||||||
|
let sample_count = samples.len();
|
||||||
|
let duration_ms = (sample_count as u64 * 1000) / CONF_SAMPLE_RATE as u64 + 100;
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(duration_ms));
|
||||||
|
// Drop the guard to remove from conference
|
||||||
|
// on_destroy callback will clean up DIRECT_PLAYER_STATE
|
||||||
|
drop(guard);
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
184
sipcord-bridge/src/transport/sip/ffi/frame_utils.rs
Normal file
184
sipcord-bridge/src/transport/sip/ffi/frame_utils.rs
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
//! Shared frame utilities for pjmedia ports
|
||||||
|
//!
|
||||||
|
//! Provides common helpers for filling audio frames and a shared no-op
|
||||||
|
//! put_frame callback used by ports that only produce audio.
|
||||||
|
|
||||||
|
use super::types::{
|
||||||
|
CallId, ConfPort, SendablePool, CONF_CHANNELS, CONF_MASTER_PORT, CONF_SAMPLE_RATE,
|
||||||
|
SAMPLES_PER_FRAME,
|
||||||
|
};
|
||||||
|
use anyhow::Result;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use pjsua::*;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// Get the pjmedia_conf pointer from the master port
|
||||||
|
/// The conference bridge pointer is stored in master_port->port_data.pdata
|
||||||
|
/// Returns None if master port is not initialized
|
||||||
|
///
|
||||||
|
/// This is public so other modules (direct_player, looping_player) can use it
|
||||||
|
/// to bypass PJSUA_LOCK when connecting/disconnecting ports.
|
||||||
|
pub unsafe fn get_conference_bridge() -> Option<*mut pjmedia_conf> {
|
||||||
|
let port_guard = CONF_MASTER_PORT.get()?;
|
||||||
|
let master_port = port_guard.lock().0;
|
||||||
|
if master_port.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let conf = (*master_port).port_data.pdata as *mut pjmedia_conf;
|
||||||
|
if conf.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(conf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write audio samples into a pjmedia_frame, padding with silence if fewer
|
||||||
|
/// than SAMPLES_PER_FRAME samples are provided.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `frame` must be a valid, non-null pointer to a pjmedia_frame with a buffer
|
||||||
|
/// large enough for SAMPLES_PER_FRAME i16 samples.
|
||||||
|
pub unsafe fn fill_audio_frame(frame: *mut pjmedia_frame, samples: &[i16]) {
|
||||||
|
let frame_buf = (*frame).buf as *mut i16;
|
||||||
|
std::ptr::copy_nonoverlapping(samples.as_ptr(), frame_buf, samples.len());
|
||||||
|
// Pad with silence if we got fewer samples than a full frame
|
||||||
|
if samples.len() < SAMPLES_PER_FRAME {
|
||||||
|
std::ptr::write_bytes(
|
||||||
|
frame_buf.add(samples.len()),
|
||||||
|
0,
|
||||||
|
SAMPLES_PER_FRAME - samples.len(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(*frame).size = (SAMPLES_PER_FRAME * 2) as pj_size_t;
|
||||||
|
(*frame).type_ = pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill a pjmedia_frame with silence.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `frame` must be a valid, non-null pointer to a pjmedia_frame with a buffer
|
||||||
|
/// large enough for SAMPLES_PER_FRAME i16 samples.
|
||||||
|
pub unsafe fn fill_silence_frame(frame: *mut pjmedia_frame) {
|
||||||
|
let frame_buf = (*frame).buf as *mut u8;
|
||||||
|
std::ptr::write_bytes(frame_buf, 0, SAMPLES_PER_FRAME * 2);
|
||||||
|
(*frame).size = (SAMPLES_PER_FRAME * 2) as pj_size_t;
|
||||||
|
(*frame).type_ = pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No-op put_frame callback for ports that only produce audio.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called by the pjmedia conference bridge.
|
||||||
|
pub unsafe extern "C" fn noop_put_frame(
|
||||||
|
_this_port: *mut pjmedia_port,
|
||||||
|
_frame: *mut pjmedia_frame,
|
||||||
|
) -> pj_status_t {
|
||||||
|
pj_constants__PJ_SUCCESS as pj_status_t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conference port guard and creation helper
|
||||||
|
|
||||||
|
/// Callbacks for a custom pjmedia port.
|
||||||
|
pub struct PortCallbacks {
|
||||||
|
pub get_frame: unsafe extern "C" fn(*mut pjmedia_port, *mut pjmedia_frame) -> pj_status_t,
|
||||||
|
pub put_frame: unsafe extern "C" fn(*mut pjmedia_port, *mut pjmedia_frame) -> pj_status_t,
|
||||||
|
pub on_destroy: Option<unsafe extern "C" fn(*mut pjmedia_port) -> pj_status_t>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RAII guard for a conference port. Removes port from conference on drop.
|
||||||
|
pub struct ConfPortGuard {
|
||||||
|
pub slot: ConfPort,
|
||||||
|
pub port_key: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ConfPortGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
pjsua_conf_remove_port(*self.slot);
|
||||||
|
}
|
||||||
|
tracing::debug!(
|
||||||
|
"ConfPortGuard: removed conf port slot={} (port={:p})",
|
||||||
|
self.slot,
|
||||||
|
self.port_key as *const ()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocate a pjmedia port, init it, add to conference, and connect to a call's conf port.
|
||||||
|
/// Returns a `ConfPortGuard` that auto-cleans-up on drop.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Must be called from the audio thread or while holding appropriate locks.
|
||||||
|
pub unsafe fn create_and_connect_port(
|
||||||
|
pool: &OnceLock<Mutex<SendablePool>>,
|
||||||
|
pool_name: &[u8],
|
||||||
|
name_prefix: &str,
|
||||||
|
call_id: CallId,
|
||||||
|
signature: u32,
|
||||||
|
callbacks: PortCallbacks,
|
||||||
|
call_conf_port: ConfPort,
|
||||||
|
) -> Result<ConfPortGuard> {
|
||||||
|
// Get or create the memory pool
|
||||||
|
let pool = pool.get_or_init(|| {
|
||||||
|
let p = pjsua_pool_create(pool_name.as_ptr() as *const _, 4096, 4096);
|
||||||
|
Mutex::new(SendablePool(p))
|
||||||
|
});
|
||||||
|
let pool_ptr = pool.lock().0;
|
||||||
|
|
||||||
|
// Allocate pjmedia_port structure
|
||||||
|
let port_size = std::mem::size_of::<pjmedia_port>();
|
||||||
|
let port = pj_pool_alloc(pool_ptr, port_size) as *mut pjmedia_port;
|
||||||
|
if port.is_null() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Failed to allocate {} port for call {}",
|
||||||
|
name_prefix,
|
||||||
|
call_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::ptr::write_bytes(port as *mut u8, 0, port_size);
|
||||||
|
|
||||||
|
// Create port name
|
||||||
|
let port_name = format!("{}{}", name_prefix, call_id);
|
||||||
|
let port_name_cstr = std::ffi::CString::new(port_name)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Invalid port name: {}", e))?;
|
||||||
|
|
||||||
|
// Initialize port info
|
||||||
|
pjmedia_port_info_init(
|
||||||
|
&mut (*port).info,
|
||||||
|
&pj_str(port_name_cstr.as_ptr() as *mut _),
|
||||||
|
signature,
|
||||||
|
CONF_SAMPLE_RATE,
|
||||||
|
CONF_CHANNELS,
|
||||||
|
16,
|
||||||
|
SAMPLES_PER_FRAME as u32,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set callbacks
|
||||||
|
(*port).get_frame = Some(callbacks.get_frame);
|
||||||
|
(*port).put_frame = Some(callbacks.put_frame);
|
||||||
|
(*port).on_destroy = callbacks.on_destroy;
|
||||||
|
|
||||||
|
// Add to conference
|
||||||
|
let mut player_slot: i32 = 0;
|
||||||
|
let status = pjsua_conf_add_port(pool_ptr, port, &mut player_slot);
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
anyhow::bail!("Failed to add {} port to conf: {}", name_prefix, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect player port to the target call's port
|
||||||
|
let conf = get_conference_bridge();
|
||||||
|
let Some(conf) = conf else {
|
||||||
|
pjsua_conf_remove_port(player_slot);
|
||||||
|
anyhow::bail!("Failed to get conference bridge for {} port", name_prefix);
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = pjmedia_conf_connect_port(conf, player_slot as u32, *call_conf_port as u32, 0);
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
pjsua_conf_remove_port(player_slot);
|
||||||
|
anyhow::bail!("Failed to connect {} port to call: {}", name_prefix, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ConfPortGuard {
|
||||||
|
slot: ConfPort::new(player_slot),
|
||||||
|
port_key: port as usize,
|
||||||
|
})
|
||||||
|
}
|
||||||
918
sipcord-bridge/src/transport/sip/ffi/init.rs
Normal file
918
sipcord-bridge/src/transport/sip/ffi/init.rs
Normal file
|
|
@ -0,0 +1,918 @@
|
||||||
|
//! PJSUA initialization and core control functions
|
||||||
|
//!
|
||||||
|
//! This module handles:
|
||||||
|
//! - PJSUA initialization and configuration
|
||||||
|
//! - TLS transport creation and hot-reload
|
||||||
|
//! - Shutdown and thread registration
|
||||||
|
|
||||||
|
use super::super::audio_thread::stop_audio_thread;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// SIP invite session state (Rust wrapper for pjsip_inv_state)
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum InvState {
|
||||||
|
Null,
|
||||||
|
Calling,
|
||||||
|
Incoming,
|
||||||
|
Early,
|
||||||
|
Connecting,
|
||||||
|
Confirmed,
|
||||||
|
Disconnected,
|
||||||
|
Unknown(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<u32> for InvState {
|
||||||
|
fn from(state: u32) -> Self {
|
||||||
|
match state {
|
||||||
|
x if x == pjsip_inv_state_PJSIP_INV_STATE_NULL => InvState::Null,
|
||||||
|
x if x == pjsip_inv_state_PJSIP_INV_STATE_CALLING => InvState::Calling,
|
||||||
|
x if x == pjsip_inv_state_PJSIP_INV_STATE_INCOMING => InvState::Incoming,
|
||||||
|
x if x == pjsip_inv_state_PJSIP_INV_STATE_EARLY => InvState::Early,
|
||||||
|
x if x == pjsip_inv_state_PJSIP_INV_STATE_CONNECTING => InvState::Connecting,
|
||||||
|
x if x == pjsip_inv_state_PJSIP_INV_STATE_CONFIRMED => InvState::Confirmed,
|
||||||
|
x if x == pjsip_inv_state_PJSIP_INV_STATE_DISCONNECTED => InvState::Disconnected,
|
||||||
|
x => InvState::Unknown(x),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for InvState {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
InvState::Null => write!(f, "NULL"),
|
||||||
|
InvState::Calling => write!(f, "CALLING"),
|
||||||
|
InvState::Incoming => write!(f, "INCOMING"),
|
||||||
|
InvState::Early => write!(f, "EARLY"),
|
||||||
|
InvState::Connecting => write!(f, "CONNECTING"),
|
||||||
|
InvState::Confirmed => write!(f, "CONFIRMED"),
|
||||||
|
InvState::Disconnected => write!(f, "DISCONNECTED"),
|
||||||
|
InvState::Unknown(x) => write!(f, "UNKNOWN({})", x),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
use super::super::callbacks::{
|
||||||
|
on_call_media_state_cb, on_call_rx_reinvite_cb, on_call_state_cb, on_dtmf_digit_cb,
|
||||||
|
on_incoming_call_cb,
|
||||||
|
};
|
||||||
|
use super::super::nat::{
|
||||||
|
on_rx_request_nat_fixup_cb, on_rx_response_nat_fixup_cb, on_tx_request_cb, on_tx_response_cb,
|
||||||
|
};
|
||||||
|
use super::super::register_handler::on_rx_request_cb;
|
||||||
|
use super::types::*;
|
||||||
|
use crate::config::{SipConfig, TlsConfig};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use ipnet::Ipv4Net;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use pjsua::*;
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::mem::MaybeUninit;
|
||||||
|
use std::os::raw::{c_char, c_int};
|
||||||
|
use std::ptr;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
/// Known PJSIP error conditions detected from log messages.
|
||||||
|
///
|
||||||
|
/// PJSIP's log callback only provides (level, string) — no structured error codes.
|
||||||
|
/// We pattern-match known messages to classify them into actionable variants.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PjsipEvent {
|
||||||
|
/// All call slots exhausted — new INVITEs are rejected with 486 Busy Here
|
||||||
|
TooManyCalls,
|
||||||
|
/// SSL/TLS handshake failed with a remote peer
|
||||||
|
SslHandshakeError,
|
||||||
|
/// Failed to send a SIP response
|
||||||
|
SendResponseFailed,
|
||||||
|
/// ICE negotiation failed
|
||||||
|
IceNegotiationFailed,
|
||||||
|
/// Transport error (TCP/UDP)
|
||||||
|
TransportError,
|
||||||
|
/// No matching codec for call
|
||||||
|
NoMatchingCodec,
|
||||||
|
/// SIP SUBSCRIBE for an unsupported event package (e.g. presence, dialog)
|
||||||
|
/// — pjsip responds 489 Bad Event, which is correct; just noisy at ERROR level
|
||||||
|
BadEventSubscription,
|
||||||
|
/// Unclassified message — logged at pjsip's original level
|
||||||
|
Unclassified,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PjsipEvent {
|
||||||
|
/// Try to classify a pjsip log message into a known event.
|
||||||
|
/// Returns the event variant and optionally an upgraded log level
|
||||||
|
/// (None = use pjsip's original level).
|
||||||
|
fn classify(msg: &str) -> (Self, Option<u8>) {
|
||||||
|
// Level overrides: 0=error, 1=error, 2=warn, 3=info
|
||||||
|
if msg.contains("too many calls") {
|
||||||
|
(Self::TooManyCalls, Some(0))
|
||||||
|
} else if msg.contains("SSL_ERROR_SSL") || msg.contains("SSL_ERROR_SYSCALL") {
|
||||||
|
(Self::SslHandshakeError, None)
|
||||||
|
} else if msg.contains("Unable to send") && msg.contains("response") {
|
||||||
|
(Self::SendResponseFailed, Some(1))
|
||||||
|
} else if msg.contains("ICE") && msg.contains("failed") {
|
||||||
|
(Self::IceNegotiationFailed, None)
|
||||||
|
} else if msg.contains("Transport") && msg.contains("error") {
|
||||||
|
(Self::TransportError, Some(1))
|
||||||
|
} else if msg.contains("No matching codec") {
|
||||||
|
(Self::NoMatchingCodec, None)
|
||||||
|
} else if msg.contains("Unable to create server subscription") {
|
||||||
|
// SIP clients SUBSCRIBE to presence/dialog after REGISTER — expected and harmless
|
||||||
|
(Self::BadEventSubscription, Some(4))
|
||||||
|
} else {
|
||||||
|
(Self::Unclassified, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short tag for structured logging
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::TooManyCalls => "TOO_MANY_CALLS",
|
||||||
|
Self::SslHandshakeError => "SSL_HANDSHAKE_ERROR",
|
||||||
|
Self::SendResponseFailed => "SEND_RESPONSE_FAILED",
|
||||||
|
Self::IceNegotiationFailed => "ICE_NEGOTIATION_FAILED",
|
||||||
|
Self::TransportError => "TRANSPORT_ERROR",
|
||||||
|
Self::NoMatchingCodec => "NO_MATCHING_CODEC",
|
||||||
|
Self::BadEventSubscription => "BAD_EVENT_SUBSCRIBE",
|
||||||
|
Self::Unclassified => "UNCLASSIFIED",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract "IP:PORT" from a PJSIP SSL error message.
|
||||||
|
///
|
||||||
|
/// PJSIP ssl_sock logs include `peer: IP:PORT` at the end of the message.
|
||||||
|
/// Returns the "IP:PORT" substring, or None if not found.
|
||||||
|
fn extract_ssl_peer(msg: &str) -> Option<&str> {
|
||||||
|
let idx = msg.find("peer: ")?;
|
||||||
|
let rest = &msg[idx + 6..];
|
||||||
|
let trimmed = rest.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PJSIP log callback - redirects logs to Rust tracing
|
||||||
|
///
|
||||||
|
/// This function is called by PJSIP for each log message instead of printing to stdout.
|
||||||
|
/// We map PJSIP log levels to tracing levels, with overrides for known critical messages
|
||||||
|
/// that pjsip under-reports (e.g. "too many calls" logged at level 2/warn → upgraded to error).
|
||||||
|
unsafe extern "C" fn pjsip_log_callback(level: c_int, data: *const c_char, _len: c_int) {
|
||||||
|
if data.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let c_str = std::ffi::CStr::from_ptr(data);
|
||||||
|
let msg = c_str.to_string_lossy();
|
||||||
|
let msg = msg.trim_end();
|
||||||
|
|
||||||
|
let (event, level_override) = PjsipEvent::classify(msg);
|
||||||
|
let effective_level = level_override.unwrap_or(level as u8);
|
||||||
|
|
||||||
|
if event == PjsipEvent::SslHandshakeError {
|
||||||
|
// Extract peer IP for structured logging context
|
||||||
|
let peer = extract_ssl_peer(msg).unwrap_or("unknown");
|
||||||
|
tracing::warn!(target: "pjsip", event = "SSL_HANDSHAKE_ERROR", peer = peer, "{}", msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if event != PjsipEvent::Unclassified {
|
||||||
|
let tag = event.as_str();
|
||||||
|
match effective_level {
|
||||||
|
0 | 1 => tracing::error!(target: "pjsip", event = tag, "{}", msg),
|
||||||
|
2 => tracing::warn!(target: "pjsip", event = tag, "{}", msg),
|
||||||
|
3 => tracing::info!(target: "pjsip", event = tag, "{}", msg),
|
||||||
|
4 => tracing::debug!(target: "pjsip", event = tag, "{}", msg),
|
||||||
|
_ => tracing::trace!(target: "pjsip", event = tag, "{}", msg),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match effective_level {
|
||||||
|
0 | 1 => tracing::error!(target: "pjsip", "{}", msg),
|
||||||
|
2 => tracing::warn!(target: "pjsip", "{}", msg),
|
||||||
|
3 => tracing::info!(target: "pjsip", "{}", msg),
|
||||||
|
4 => tracing::debug!(target: "pjsip", "{}", msg),
|
||||||
|
_ => tracing::trace!(target: "pjsip", "{}", msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the global callback handlers
|
||||||
|
pub fn set_callbacks(handlers: CallbackHandlers) {
|
||||||
|
let callbacks = CALLBACKS.get_or_init(|| Mutex::new(None));
|
||||||
|
*callbacks.lock() = Some(handlers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize pjsua with optional TLS support
|
||||||
|
pub fn init_pjsua(config: &SipConfig, tls_config: Option<&TlsConfig>) -> Result<()> {
|
||||||
|
// Initialize public host config for Contact header rewriting on outgoing responses.
|
||||||
|
// pjsua derives Contact from the TCP connection's local address (private IP), but
|
||||||
|
// external clients need the public hostname to route BYE back to us.
|
||||||
|
PUBLIC_HOST_CONFIG.get_or_init(|| {
|
||||||
|
if !config.public_host.is_empty() {
|
||||||
|
tracing::info!(
|
||||||
|
"Public host Contact rewriting enabled: {}:{}",
|
||||||
|
config.public_host,
|
||||||
|
config.port
|
||||||
|
);
|
||||||
|
Some((config.public_host.clone(), config.port))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize local network config for Contact header and SDP rewriting
|
||||||
|
LOCAL_NET_CONFIG.get_or_init(|| {
|
||||||
|
config.local_net.as_ref().and_then(|ln| {
|
||||||
|
match ln.cidr.parse::<Ipv4Net>() {
|
||||||
|
Ok(net) => {
|
||||||
|
tracing::info!(
|
||||||
|
"Local network rewriting enabled: {} -> {} for CIDR {}, RTP public IP: {:?}",
|
||||||
|
config.public_host, ln.host, ln.cidr, config.rtp_public_ip
|
||||||
|
);
|
||||||
|
Some((ln.host.clone(), net, config.port, config.rtp_public_ip.clone()))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Invalid SIP_LOCAL_CIDR '{}': {}", ln.cidr, e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
// Create pjsua instance
|
||||||
|
let status = pjsua_create();
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
anyhow::bail!("Failed to create pjsua: {}", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable automatic UDP->TCP switch for large SIP messages.
|
||||||
|
// pjsip switches to TCP when a request exceeds 1300 bytes, but for
|
||||||
|
// outbound calls to NATted clients, the client's UDP NAT mapping
|
||||||
|
// won't accept TCP connections. We must respect the transport the
|
||||||
|
// client registered with.
|
||||||
|
{
|
||||||
|
extern "C" {
|
||||||
|
static mut pjsip_sip_cfg_var: pjsip_cfg_t;
|
||||||
|
}
|
||||||
|
pjsip_sip_cfg_var.endpt.disable_tcp_switch = pj_constants__PJ_TRUE as _;
|
||||||
|
tracing::info!("Disabled automatic UDP->TCP switch for large SIP messages");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure pjsua
|
||||||
|
let mut cfg = MaybeUninit::<pjsua_config>::uninit();
|
||||||
|
pjsua_config_default(cfg.as_mut_ptr());
|
||||||
|
let cfg_ptr = cfg.assume_init_mut();
|
||||||
|
|
||||||
|
// Allow enough concurrent call slots for real calls + spam that's being rejected.
|
||||||
|
// Compile-time PJSUA_MAX_CALLS is set to 128 in config_site.h.
|
||||||
|
cfg_ptr.max_calls = 128;
|
||||||
|
|
||||||
|
// Set callbacks
|
||||||
|
cfg_ptr.cb.on_incoming_call = Some(on_incoming_call_cb);
|
||||||
|
cfg_ptr.cb.on_call_state = Some(on_call_state_cb);
|
||||||
|
cfg_ptr.cb.on_call_media_state = Some(on_call_media_state_cb);
|
||||||
|
cfg_ptr.cb.on_dtmf_digit = Some(on_dtmf_digit_cb);
|
||||||
|
cfg_ptr.cb.on_call_rx_reinvite = Some(on_call_rx_reinvite_cb);
|
||||||
|
|
||||||
|
// Logging config - redirect PJSIP logs to Rust tracing
|
||||||
|
let mut log_cfg = MaybeUninit::<pjsua_logging_config>::uninit();
|
||||||
|
pjsua_logging_config_default(log_cfg.as_mut_ptr());
|
||||||
|
let log_cfg_ptr = log_cfg.assume_init_mut();
|
||||||
|
let configured_level = crate::config::AppConfig::bridge().pjsip_log_level;
|
||||||
|
tracing::info!("PJSIP log level from config: {}", configured_level);
|
||||||
|
log_cfg_ptr.level = configured_level as _;
|
||||||
|
log_cfg_ptr.console_level = configured_level as _; // Must match level — cb is gated by console_level
|
||||||
|
log_cfg_ptr.cb = Some(pjsip_log_callback); // Our callback replaces default console output
|
||||||
|
|
||||||
|
// Media config
|
||||||
|
let mut media_cfg = MaybeUninit::<pjsua_media_config>::uninit();
|
||||||
|
pjsua_media_config_default(media_cfg.as_mut_ptr());
|
||||||
|
let media_cfg_ptr = media_cfg.assume_init_mut();
|
||||||
|
|
||||||
|
// Configure conference bridge for 16kHz mono
|
||||||
|
// This is the internal sample rate - pjsua will resample from codecs as needed
|
||||||
|
media_cfg_ptr.clock_rate = CONF_SAMPLE_RATE;
|
||||||
|
media_cfg_ptr.snd_clock_rate = CONF_SAMPLE_RATE;
|
||||||
|
media_cfg_ptr.channel_count = CONF_CHANNELS;
|
||||||
|
media_cfg_ptr.audio_frame_ptime = FRAME_PTIME_MS;
|
||||||
|
// Set default SDP ptime to match internal frame ptime
|
||||||
|
// If these differ, there can be timing mismatches
|
||||||
|
media_cfg_ptr.ptime = FRAME_PTIME_MS;
|
||||||
|
|
||||||
|
// Log the media config
|
||||||
|
tracing::info!(
|
||||||
|
"Media config: clock_rate={}, snd_clock_rate={}, audio_frame_ptime={}, ptime={}",
|
||||||
|
media_cfg_ptr.clock_rate,
|
||||||
|
media_cfg_ptr.snd_clock_rate,
|
||||||
|
media_cfg_ptr.audio_frame_ptime,
|
||||||
|
media_cfg_ptr.ptime
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize pjsua
|
||||||
|
let status = pjsua_init(cfg_ptr, log_cfg_ptr, media_cfg_ptr);
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
anyhow::bail!("Failed to init pjsua: {}", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create UDP transport
|
||||||
|
let mut t_cfg = MaybeUninit::<pjsua_transport_config>::uninit();
|
||||||
|
pjsua_transport_config_default(t_cfg.as_mut_ptr());
|
||||||
|
let t_cfg_ptr = t_cfg.assume_init_mut();
|
||||||
|
t_cfg_ptr.port = config.port as u32;
|
||||||
|
|
||||||
|
// Set public address if specified - keep CString alive until transport is created
|
||||||
|
let public_host_cstring = if !config.public_host.is_empty() {
|
||||||
|
let host = CString::new(config.public_host.as_str()).context("Invalid public host")?;
|
||||||
|
t_cfg_ptr.public_addr = pj_str(host.as_ptr() as *mut c_char);
|
||||||
|
Some(host)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut transport_id: c_int = 0;
|
||||||
|
let status = pjsua_transport_create(
|
||||||
|
pjsip_transport_type_e_PJSIP_TRANSPORT_UDP,
|
||||||
|
t_cfg_ptr,
|
||||||
|
&mut transport_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// CString can be dropped now
|
||||||
|
drop(public_host_cstring);
|
||||||
|
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
anyhow::bail!("Failed to create UDP transport: {}", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create TCP transport on the same port
|
||||||
|
let mut tcp_cfg = MaybeUninit::<pjsua_transport_config>::uninit();
|
||||||
|
pjsua_transport_config_default(tcp_cfg.as_mut_ptr());
|
||||||
|
let tcp_cfg_ptr = tcp_cfg.assume_init_mut();
|
||||||
|
tcp_cfg_ptr.port = config.port as u32;
|
||||||
|
|
||||||
|
// Set public address for TCP - keep CString alive
|
||||||
|
let tcp_public_host_cstring = if !config.public_host.is_empty() {
|
||||||
|
let host =
|
||||||
|
CString::new(config.public_host.as_str()).context("Invalid public host for TCP")?;
|
||||||
|
tcp_cfg_ptr.public_addr = pj_str(host.as_ptr() as *mut c_char);
|
||||||
|
Some(host)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tcp_transport_id: c_int = 0;
|
||||||
|
let status = pjsua_transport_create(
|
||||||
|
pjsip_transport_type_e_PJSIP_TRANSPORT_TCP,
|
||||||
|
tcp_cfg_ptr,
|
||||||
|
&mut tcp_transport_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(tcp_public_host_cstring);
|
||||||
|
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
anyhow::bail!("Failed to create TCP transport: {}", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("TCP transport created on port {}", config.port);
|
||||||
|
|
||||||
|
// Create TLS transport if configured (skip gracefully if certs missing)
|
||||||
|
if let Some(tls) = tls_config {
|
||||||
|
if !create_tls_transport(tls, &config.public_host)? {
|
||||||
|
tracing::warn!("TLS transport not created - running without TLS");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start pjsua
|
||||||
|
let status = pjsua_start();
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
anyhow::bail!("Failed to start pjsua: {}", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure codec priorities to keep INVITE SDP small.
|
||||||
|
// Without this, PJSUA offers every compiled codec (~16 entries) plus a
|
||||||
|
// T.140 text stream, producing an INVITE of ~1750 bytes. UDP packets
|
||||||
|
// over ~1300 bytes get IP-fragmented and are silently dropped by many
|
||||||
|
// NAT routers, which completely breaks outbound calls.
|
||||||
|
//
|
||||||
|
// Strategy: disable everything, then re-enable only what we need,
|
||||||
|
// ordered by quality (highest priority = preferred in SDP negotiation).
|
||||||
|
{
|
||||||
|
// Disable all audio codecs first
|
||||||
|
let all = CString::new("*").unwrap();
|
||||||
|
pjsua_codec_set_priority(&pj_str(all.as_ptr() as *mut c_char), 0);
|
||||||
|
|
||||||
|
// Re-enable desired codecs (highest priority = preferred in negotiation).
|
||||||
|
// NOTE: G722 is registered internally at 16000Hz in PJSIP despite the
|
||||||
|
// RFC 3551 SDP convention of advertising clock_rate=8000.
|
||||||
|
let codecs: &[(&str, u8)] = &[
|
||||||
|
("opus/48000", 255), // Best quality: adaptive, wideband/fullband
|
||||||
|
("G722/16000", 254), // Wideband 16kHz, widely supported
|
||||||
|
("AMR/8000", 252), // Adaptive narrowband
|
||||||
|
("PCMU/8000", 200), // G.711 mu-law, ubiquitous fallback
|
||||||
|
("PCMA/8000", 199), // G.711 A-law, ubiquitous fallback
|
||||||
|
("telephone-event", 200), // DTMF support (all sample rates)
|
||||||
|
];
|
||||||
|
|
||||||
|
for (name, priority) in codecs {
|
||||||
|
let codec_id = CString::new(*name).unwrap();
|
||||||
|
let status =
|
||||||
|
pjsua_codec_set_priority(&pj_str(codec_id.as_ptr() as *mut c_char), *priority);
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
tracing::warn!("Failed to set codec priority for {}: {}", name, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Codec priorities configured: {}",
|
||||||
|
codecs
|
||||||
|
.iter()
|
||||||
|
.map(|(n, p)| format!("{}={}", n, p))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register custom module to handle REGISTER requests and Contact header rewriting
|
||||||
|
// pjsua's high-level API only handles call-related events, but SIP clients
|
||||||
|
// send REGISTER to register with the server. We intercept these at the PJSIP level.
|
||||||
|
// We also intercept outgoing responses to rewrite Contact headers for local clients.
|
||||||
|
static mut REGISTER_MODULE: pjsip_module = pjsip_module {
|
||||||
|
prev: ptr::null_mut(),
|
||||||
|
next: ptr::null_mut(),
|
||||||
|
name: pj_str_t {
|
||||||
|
ptr: ptr::null_mut(),
|
||||||
|
slen: 0,
|
||||||
|
},
|
||||||
|
id: -1,
|
||||||
|
priority: pjsip_module_priority_PJSIP_MOD_PRIORITY_APPLICATION as i32,
|
||||||
|
load: None,
|
||||||
|
start: None,
|
||||||
|
stop: None,
|
||||||
|
unload: None,
|
||||||
|
on_rx_request: Some(on_rx_request_cb),
|
||||||
|
on_rx_response: None,
|
||||||
|
on_tx_request: Some(on_tx_request_cb),
|
||||||
|
on_tx_response: Some(on_tx_response_cb),
|
||||||
|
on_tsx_state: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set module name (must be done at runtime since pj_str needs mutable ptr)
|
||||||
|
static MOD_NAME: &[u8] = b"mod-sipcord\0";
|
||||||
|
REGISTER_MODULE.name = pj_str(MOD_NAME.as_ptr() as *mut c_char);
|
||||||
|
|
||||||
|
// Get endpoint and register module
|
||||||
|
let endpt = pjsua_get_pjsip_endpt();
|
||||||
|
if !endpt.is_null() {
|
||||||
|
let status = pjsip_endpt_register_module(endpt, &raw mut REGISTER_MODULE);
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
tracing::warn!("Failed to register REGISTER handler module: {}", status);
|
||||||
|
} else {
|
||||||
|
tracing::info!("Registered REGISTER handler module");
|
||||||
|
// Store the module pointer so register_handler can create
|
||||||
|
// UAS transactions for deferred REGISTER responses.
|
||||||
|
super::super::register_handler::set_register_module_ptr(&raw mut REGISTER_MODULE);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("Could not get PJSIP endpoint for module registration");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register NAT fixup module for far-end NAT traversal
|
||||||
|
// This rewrites private IPs in Contact headers and SDP bodies of incoming
|
||||||
|
// requests (INVITEs from NATted phones) and responses (for outbound calls)
|
||||||
|
// to the actual public source IP, fixing RTP delivery for phones behind NAT.
|
||||||
|
//
|
||||||
|
// Priority 15 = runs BEFORE TSX_LAYER(16). This is critical because the
|
||||||
|
// TSX layer's on_rx_response matches responses to transactions and then
|
||||||
|
// synchronously triggers the full dialog + invite session processing chain
|
||||||
|
// (updating remote target from Contact, SDP negotiation, ACK sending).
|
||||||
|
// If NAT fixup ran after the TSX layer (as it did at priority 28), the
|
||||||
|
// dialog would see the original private IPs, causing ACK and RTP to be
|
||||||
|
// sent to unreachable private addresses.
|
||||||
|
static mut NAT_FIXUP_MODULE: pjsip_module = pjsip_module {
|
||||||
|
prev: ptr::null_mut(),
|
||||||
|
next: ptr::null_mut(),
|
||||||
|
name: pj_str_t {
|
||||||
|
ptr: ptr::null_mut(),
|
||||||
|
slen: 0,
|
||||||
|
},
|
||||||
|
id: -1,
|
||||||
|
priority: 15, // Just before TSX_LAYER(16), after TRANSPORT_LAYER(8)
|
||||||
|
load: None,
|
||||||
|
start: None,
|
||||||
|
stop: None,
|
||||||
|
unload: None,
|
||||||
|
on_rx_request: Some(on_rx_request_nat_fixup_cb),
|
||||||
|
on_rx_response: Some(on_rx_response_nat_fixup_cb),
|
||||||
|
on_tx_request: None,
|
||||||
|
on_tx_response: None,
|
||||||
|
on_tsx_state: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
static NAT_FIXUP_MOD_NAME: &[u8] = b"mod-nat-fixup\0";
|
||||||
|
NAT_FIXUP_MODULE.name = pj_str(NAT_FIXUP_MOD_NAME.as_ptr() as *mut c_char);
|
||||||
|
|
||||||
|
if !endpt.is_null() {
|
||||||
|
let status = pjsip_endpt_register_module(endpt, &raw mut NAT_FIXUP_MODULE);
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
tracing::warn!("Failed to register NAT fixup module: {}", status);
|
||||||
|
} else {
|
||||||
|
tracing::info!("Registered NAT fixup module (priority 15, before TSX layer)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable sound device and get the conference master port
|
||||||
|
// This allows us to manually control audio I/O
|
||||||
|
let master_port = pjsua_set_no_snd_dev();
|
||||||
|
if master_port.is_null() {
|
||||||
|
anyhow::bail!("Failed to set null sound device");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the master port's actual sample rate
|
||||||
|
let master_port_info = &(*master_port).info;
|
||||||
|
let aud_fmt = &master_port_info.fmt.det.aud;
|
||||||
|
let actual_clock_rate = aud_fmt.clock_rate;
|
||||||
|
let actual_channel_count = aud_fmt.channel_count;
|
||||||
|
let actual_frame_time_usec = aud_fmt.frame_time_usec;
|
||||||
|
let actual_bits_per_sample = aud_fmt.bits_per_sample;
|
||||||
|
// Calculate samples per frame from frame time
|
||||||
|
let actual_samples_per_frame = (actual_clock_rate * actual_frame_time_usec) / 1_000_000;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Master port ACTUAL config: clock_rate={}, channels={}, frame_time={}us, bits={}, samples_per_frame={}",
|
||||||
|
actual_clock_rate, actual_channel_count, actual_frame_time_usec, actual_bits_per_sample, actual_samples_per_frame
|
||||||
|
);
|
||||||
|
|
||||||
|
// CRITICAL: Verify the conference bridge is actually at our configured rate
|
||||||
|
if actual_clock_rate != CONF_SAMPLE_RATE {
|
||||||
|
tracing::error!(
|
||||||
|
"SAMPLE RATE MISMATCH! Requested {}Hz but got {}Hz - audio will play at wrong speed!",
|
||||||
|
CONF_SAMPLE_RATE, actual_clock_rate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the master port for audio thread access
|
||||||
|
let conf_port = CONF_MASTER_PORT.get_or_init(|| Mutex::new(SendablePort(ptr::null_mut())));
|
||||||
|
conf_port.lock().0 = master_port;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Conference bridge configured: {}Hz, {} channel(s), {}ms frames ({} samples/frame)",
|
||||||
|
CONF_SAMPLE_RATE,
|
||||||
|
CONF_CHANNELS,
|
||||||
|
FRAME_PTIME_MS,
|
||||||
|
SAMPLES_PER_FRAME
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a local account for receiving calls
|
||||||
|
let mut acc_cfg = MaybeUninit::<pjsua_acc_config>::uninit();
|
||||||
|
pjsua_acc_config_default(acc_cfg.as_mut_ptr());
|
||||||
|
let acc_cfg_ptr = acc_cfg.assume_init_mut();
|
||||||
|
|
||||||
|
// Local account ID - keep CString alive until account is added
|
||||||
|
let local_uri = CString::new(format!("sip:sipcord@{}", config.public_host))
|
||||||
|
.context("Invalid local URI")?;
|
||||||
|
acc_cfg_ptr.id = pj_str(local_uri.as_ptr() as *mut c_char);
|
||||||
|
|
||||||
|
// Enable incoming calls without registration
|
||||||
|
acc_cfg_ptr.register_on_acc_add = pj_constants__PJ_FALSE as i32;
|
||||||
|
|
||||||
|
// Disable SIP session timers (RFC 4028). The bridge has its own RTP
|
||||||
|
// inactivity timeouts, and session timer UPDATEs break when the remote
|
||||||
|
// side is behind NAT (the UPDATE targets the Contact URI which may be
|
||||||
|
// unreachable, causing retransmit storms and eventual 408 disconnect).
|
||||||
|
acc_cfg_ptr.use_timer = pjsua_sip_timer_use_PJSUA_SIP_TIMER_INACTIVE;
|
||||||
|
|
||||||
|
// Configure RTP port range for media
|
||||||
|
// port is the starting port, port_range is how many consecutive ports to try
|
||||||
|
acc_cfg_ptr.rtp_cfg.port = config.rtp_port_start as u32;
|
||||||
|
acc_cfg_ptr.rtp_cfg.port_range = (config.rtp_port_end - config.rtp_port_start) as u32;
|
||||||
|
|
||||||
|
// Set public IP for RTP if configured - this is advertised in SDP c= line
|
||||||
|
// Without this, pjsua uses the local interface IP which won't work for NAT
|
||||||
|
let rtp_public_ip_cstring = if let Some(ref public_ip) = config.rtp_public_ip {
|
||||||
|
let ip_cstr = CString::new(public_ip.as_str()).context("Invalid RTP public IP")?;
|
||||||
|
acc_cfg_ptr.rtp_cfg.public_addr = pj_str(ip_cstr.as_ptr() as *mut c_char);
|
||||||
|
tracing::info!(
|
||||||
|
"Account RTP config: port={}, port_range={} (ports {}-{}), public_addr={}",
|
||||||
|
acc_cfg_ptr.rtp_cfg.port,
|
||||||
|
acc_cfg_ptr.rtp_cfg.port_range,
|
||||||
|
config.rtp_port_start,
|
||||||
|
config.rtp_port_end,
|
||||||
|
public_ip
|
||||||
|
);
|
||||||
|
Some(ip_cstr)
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"RTP_PUBLIC_IP not set - SDP will advertise local IP, external calls won't work!"
|
||||||
|
);
|
||||||
|
tracing::info!(
|
||||||
|
"Account RTP config: port={}, port_range={} (ports {}-{})",
|
||||||
|
acc_cfg_ptr.rtp_cfg.port,
|
||||||
|
acc_cfg_ptr.rtp_cfg.port_range,
|
||||||
|
config.rtp_port_start,
|
||||||
|
config.rtp_port_end
|
||||||
|
);
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut acc_id: pjsua_acc_id = 0;
|
||||||
|
let status = pjsua_acc_add(acc_cfg_ptr, pj_constants__PJ_TRUE as i32, &mut acc_id);
|
||||||
|
|
||||||
|
// CStrings can be dropped now
|
||||||
|
drop(local_uri);
|
||||||
|
drop(rtp_public_ip_cstring);
|
||||||
|
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
anyhow::bail!("Failed to add account: {}", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create TLS transport for SIP-over-TLS
|
||||||
|
/// Returns Ok(true) if created, Ok(false) if skipped due to missing certs
|
||||||
|
fn create_tls_transport(tls_config: &TlsConfig, public_host: &str) -> Result<bool> {
|
||||||
|
// Check cert files exist before doing anything
|
||||||
|
let cert_path = tls_config.cert_path();
|
||||||
|
let key_path = tls_config.key_path();
|
||||||
|
|
||||||
|
if !cert_path.exists() {
|
||||||
|
tracing::warn!(
|
||||||
|
"TLS certificate not found: {} - TLS disabled until cert is obtained",
|
||||||
|
cert_path.display()
|
||||||
|
);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
if !key_path.exists() {
|
||||||
|
tracing::warn!(
|
||||||
|
"TLS private key not found: {} - TLS disabled until cert is obtained",
|
||||||
|
key_path.display()
|
||||||
|
);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("TLS cert path: {}", cert_path.display());
|
||||||
|
tracing::info!("TLS key path: {}", key_path.display());
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let mut t_cfg = MaybeUninit::<pjsua_transport_config>::uninit();
|
||||||
|
pjsua_transport_config_default(t_cfg.as_mut_ptr());
|
||||||
|
let t_cfg_ptr = t_cfg.assume_init_mut();
|
||||||
|
|
||||||
|
// Set TLS port
|
||||||
|
t_cfg_ptr.port = tls_config.port as u32;
|
||||||
|
|
||||||
|
// Set public address
|
||||||
|
let public_host_cstring = CString::new(public_host).context("Invalid public host")?;
|
||||||
|
t_cfg_ptr.public_addr = pj_str(public_host_cstring.as_ptr() as *mut c_char);
|
||||||
|
|
||||||
|
let cert_path_cstring =
|
||||||
|
CString::new(cert_path.to_str().unwrap()).context("Invalid cert path")?;
|
||||||
|
let key_path_cstring =
|
||||||
|
CString::new(key_path.to_str().unwrap()).context("Invalid key path")?;
|
||||||
|
|
||||||
|
// Set certificate and key
|
||||||
|
t_cfg_ptr.tls_setting.cert_file = pj_str(cert_path_cstring.as_ptr() as *mut c_char);
|
||||||
|
t_cfg_ptr.tls_setting.privkey_file = pj_str(key_path_cstring.as_ptr() as *mut c_char);
|
||||||
|
|
||||||
|
// Also set CA list to the cert file (contains the chain) so pjsip sends full chain
|
||||||
|
t_cfg_ptr.tls_setting.ca_list_file = pj_str(cert_path_cstring.as_ptr() as *mut c_char);
|
||||||
|
|
||||||
|
// Create TLS transport
|
||||||
|
let mut transport_id: c_int = 0;
|
||||||
|
let status = pjsua_transport_create(
|
||||||
|
pjsip_transport_type_e_PJSIP_TRANSPORT_TLS,
|
||||||
|
t_cfg_ptr,
|
||||||
|
&mut transport_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// CStrings can be dropped now
|
||||||
|
drop(public_host_cstring);
|
||||||
|
drop(cert_path_cstring);
|
||||||
|
drop(key_path_cstring);
|
||||||
|
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
anyhow::bail!("Failed to create TLS transport: {}", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store transport ID for potential reload
|
||||||
|
let tls_id = TLS_TRANSPORT_ID.get_or_init(|| Mutex::new(None));
|
||||||
|
*tls_id.lock() = Some(transport_id);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"TLS transport created on port {} (transport_id={})",
|
||||||
|
tls_config.port,
|
||||||
|
transport_id
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reload TLS transport with updated certificates, or create it if it didn't exist
|
||||||
|
///
|
||||||
|
/// This should only be called when there are no active calls.
|
||||||
|
/// Returns Ok(true) if reload/create was successful, Ok(false) if skipped (certs missing or calls active).
|
||||||
|
pub fn reload_tls_transport(tls_config: &TlsConfig, public_host: &str) -> Result<bool> {
|
||||||
|
// Check active calls - don't reload if calls are active
|
||||||
|
let active_calls = COUNTED_CALL_IDS
|
||||||
|
.get()
|
||||||
|
.map(|ids| ids.lock().len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
if active_calls > 0 {
|
||||||
|
tracing::info!("Skipping TLS reload: {} active calls", active_calls);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have an existing TLS transport to close first
|
||||||
|
let tls_id_lock = TLS_TRANSPORT_ID.get_or_init(|| Mutex::new(None));
|
||||||
|
let old_transport_id = {
|
||||||
|
let guard = tls_id_lock.lock();
|
||||||
|
*guard
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(old_id) = old_transport_id {
|
||||||
|
tracing::info!("Closing existing TLS transport (id={})", old_id);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
// Close old transport
|
||||||
|
let status = pjsua_transport_close(old_id, pj_constants__PJ_FALSE as i32);
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
tracing::warn!("Failed to close old TLS transport: {}", status);
|
||||||
|
// Continue anyway - we'll try to create a new one
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the stored transport ID
|
||||||
|
{
|
||||||
|
let mut guard = tls_id_lock.lock();
|
||||||
|
*guard = None;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::info!("No existing TLS transport - creating new one");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new TLS transport (returns false if certs missing)
|
||||||
|
let created = create_tls_transport(tls_config, public_host)?;
|
||||||
|
|
||||||
|
if created {
|
||||||
|
// Clear reload pending flag
|
||||||
|
TLS_RELOAD_PENDING.store(false, Ordering::SeqCst);
|
||||||
|
tracing::info!("TLS transport created/reloaded successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(created)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set TLS reload pending flag
|
||||||
|
pub fn set_tls_reload_pending(pending: bool) {
|
||||||
|
TLS_RELOAD_PENDING.store(pending, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the count of active media calls
|
||||||
|
pub fn active_media_call_count() -> usize {
|
||||||
|
COUNTED_CALL_IDS
|
||||||
|
.get()
|
||||||
|
.map(|ids| ids.lock().len())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process pjsua events (call from event loop)
|
||||||
|
pub fn process_pjsua_events(timeout_ms: u32) -> Result<()> {
|
||||||
|
unsafe {
|
||||||
|
pj_thread_sleep(timeout_ms);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer an incoming call with 200 OK
|
||||||
|
///
|
||||||
|
/// This calls pjsua_call_answer directly. We previously queued this to the audio
|
||||||
|
/// thread to avoid deadlocks, but the actual deadlock was with pjsua_conf_connect
|
||||||
|
/// (now fixed by using pjmedia_conf_connect_port). Calling answer from the SIP
|
||||||
|
/// command thread is safe and avoids blocking the audio thread.
|
||||||
|
pub fn answer_call(call_id: CallId) {
|
||||||
|
unsafe {
|
||||||
|
// Get call info to check state before answering
|
||||||
|
let mut ci = MaybeUninit::<pjsua_call_info>::uninit();
|
||||||
|
if pjsua_call_get_info(*call_id, ci.as_mut_ptr()) == pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
let ci = ci.assume_init();
|
||||||
|
let state = InvState::from(ci.state);
|
||||||
|
tracing::info!(
|
||||||
|
"Answering call {} with 200 OK (current_state={}, media_status={})",
|
||||||
|
call_id,
|
||||||
|
state,
|
||||||
|
ci.media_status
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
"Answering call {} with 200 OK (couldn't get call info)",
|
||||||
|
call_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call directly - this is safe now that we use pjmedia_conf_connect_port
|
||||||
|
// instead of pjsua_conf_connect in the audio thread
|
||||||
|
let status = pjsua_call_answer(*call_id, 200, ptr::null(), ptr::null());
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
tracing::warn!("Failed to answer call {}: status={}", call_id, status);
|
||||||
|
} else {
|
||||||
|
tracing::info!("Call {} answered with 200 OK successfully", call_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send 183 Session Progress (establishes early media for connecting sound)
|
||||||
|
///
|
||||||
|
/// This sends SDP to the caller, allowing them to hear audio before the call is
|
||||||
|
/// fully answered with 200 OK. Used to play the "connecting" sound while we
|
||||||
|
/// wait for Discord to connect.
|
||||||
|
pub fn send_183_session_progress(call_id: CallId) {
|
||||||
|
unsafe {
|
||||||
|
// Get call info to check state before sending 183
|
||||||
|
let mut ci = MaybeUninit::<pjsua_call_info>::uninit();
|
||||||
|
if pjsua_call_get_info(*call_id, ci.as_mut_ptr()) == pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
let ci = ci.assume_init();
|
||||||
|
let state = InvState::from(ci.state);
|
||||||
|
tracing::info!(
|
||||||
|
"Sending 183 Session Progress for call {} (current_state={}, media_status={})",
|
||||||
|
call_id,
|
||||||
|
state,
|
||||||
|
ci.media_status
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
"Sending 183 Session Progress for call {} (couldn't get call info)",
|
||||||
|
call_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create reason string
|
||||||
|
let reason = CString::new("Session Progress").unwrap();
|
||||||
|
let reason_pj = pj_str(reason.as_ptr() as *mut c_char);
|
||||||
|
|
||||||
|
let status = pjsua_call_answer(*call_id, 183, &reason_pj, ptr::null());
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
tracing::warn!("Failed to send 183 for call {}: status={}", call_id, status);
|
||||||
|
} else {
|
||||||
|
tracing::info!("Call {} sent 183 Session Progress successfully", call_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hangup a call
|
||||||
|
pub fn hangup_call(call_id: CallId) {
|
||||||
|
unsafe {
|
||||||
|
pjsua_call_hangup(*call_id, 0, ptr::null(), ptr::null());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shutdown pjsua and clean up resources
|
||||||
|
pub fn shutdown_pjsua() {
|
||||||
|
tracing::info!("Shutting down pjsua...");
|
||||||
|
|
||||||
|
// Stop and join audio thread first (must complete before pjsua_destroy)
|
||||||
|
stop_audio_thread();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
// Destroy pjsua
|
||||||
|
tracing::info!("Calling pjsua_destroy...");
|
||||||
|
pjsua_destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("pjsua shutdown complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register the current thread with PJLIB so it can safely call PJSUA functions.
|
||||||
|
///
|
||||||
|
/// Must be called once per thread before any PJSUA calls (except from the main thread
|
||||||
|
/// that called pjsua_create, which is already registered).
|
||||||
|
///
|
||||||
|
/// Returns true if registration succeeded (or thread was already registered).
|
||||||
|
pub fn register_thread_with_pjlib(thread_name: &str) -> bool {
|
||||||
|
unsafe {
|
||||||
|
// Check if already registered
|
||||||
|
if pj_thread_is_registered() == pj_constants__PJ_TRUE as i32 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread descriptor must live for the lifetime of the thread.
|
||||||
|
// Using a thread-local static to ensure it stays alive.
|
||||||
|
thread_local! {
|
||||||
|
static THREAD_DESC: std::cell::UnsafeCell<pj_thread_desc> =
|
||||||
|
const { std::cell::UnsafeCell::new([0; 64]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
THREAD_DESC.with(|desc| {
|
||||||
|
let name = CString::new(thread_name).unwrap_or_default();
|
||||||
|
let mut thread_handle: *mut pj_thread_t = std::ptr::null_mut();
|
||||||
|
|
||||||
|
let status = pj_thread_register(
|
||||||
|
name.as_ptr() as *mut c_char,
|
||||||
|
(*desc.get()).as_mut_ptr(),
|
||||||
|
&mut thread_handle,
|
||||||
|
);
|
||||||
|
|
||||||
|
status == pj_constants__PJ_SUCCESS as i32
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
227
sipcord-bridge/src/transport/sip/ffi/looping_player.rs
Normal file
227
sipcord-bridge/src/transport/sip/ffi/looping_player.rs
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
//! Looping audio player for early media
|
||||||
|
//!
|
||||||
|
//! Provides a looping player that plays audio repeatedly until stopped.
|
||||||
|
//! Used for the "connecting" sound during call setup (183 Session Progress).
|
||||||
|
|
||||||
|
use super::types::*;
|
||||||
|
use anyhow::Result;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use pjsua::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// Global state for looping players: call_id -> LoopingPlayerState
|
||||||
|
pub static LOOPING_PLAYERS: OnceLock<Mutex<HashMap<CallId, LoopingPlayerState>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Memory pool for looping player ports
|
||||||
|
pub static LOOPING_PLAYER_POOL: OnceLock<Mutex<SendablePool>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Port key -> (samples, position, is_active) mapping for get_frame callback
|
||||||
|
pub static LOOPING_PLAYER_DATA: OnceLock<Mutex<HashMap<usize, LoopingPlayerData>>> =
|
||||||
|
OnceLock::new();
|
||||||
|
|
||||||
|
/// Data needed by the get_frame callback
|
||||||
|
pub struct LoopingPlayerData {
|
||||||
|
pub samples: Vec<i16>,
|
||||||
|
pub position: usize,
|
||||||
|
pub is_active: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State for a looping player
|
||||||
|
pub struct LoopingPlayerState {
|
||||||
|
/// Conference slot for this player
|
||||||
|
pub conf_slot: ConfPort,
|
||||||
|
/// Port pointer (for cleanup)
|
||||||
|
pub port_key: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom get_frame callback for looping player ports
|
||||||
|
/// Returns samples from the player's buffer, looping back to start when reaching end
|
||||||
|
pub unsafe extern "C" fn looping_player_get_frame(
|
||||||
|
this_port: *mut pjmedia_port,
|
||||||
|
frame: *mut pjmedia_frame,
|
||||||
|
) -> pj_status_t {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
|
||||||
|
|
||||||
|
static GET_FRAME_CALL_COUNT: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let call_count = GET_FRAME_CALL_COUNT.fetch_add(1, AtomicOrdering::Relaxed);
|
||||||
|
|
||||||
|
// Log first 10 calls to confirm this callback is being invoked
|
||||||
|
if call_count < 10 {
|
||||||
|
tracing::trace!(
|
||||||
|
"looping_player_get_frame called (call #{}, port={:p})",
|
||||||
|
call_count,
|
||||||
|
this_port
|
||||||
|
);
|
||||||
|
} else if call_count == 10 {
|
||||||
|
tracing::trace!("looping_player_get_frame: suppressing further per-call logs");
|
||||||
|
}
|
||||||
|
|
||||||
|
if this_port.is_null() || frame.is_null() {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let port_key = this_port as usize;
|
||||||
|
|
||||||
|
// Get samples from the player's buffer and fill frame directly (no intermediate Vec)
|
||||||
|
{
|
||||||
|
let data = LOOPING_PLAYER_DATA.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
let mut data = data.lock();
|
||||||
|
|
||||||
|
if let Some(player_data) = data.get_mut(&port_key) {
|
||||||
|
if player_data.is_active.load(Ordering::SeqCst) && !player_data.samples.is_empty() {
|
||||||
|
let pos = player_data.position;
|
||||||
|
let end = (pos + SAMPLES_PER_FRAME).min(player_data.samples.len());
|
||||||
|
super::frame_utils::fill_audio_frame(frame, &player_data.samples[pos..end]);
|
||||||
|
|
||||||
|
// Advance position, loop back if at end
|
||||||
|
player_data.position = if end >= player_data.samples.len() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
end
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
super::frame_utils::fill_silence_frame(frame);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
super::frame_utils::fill_silence_frame(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pj_constants__PJ_SUCCESS as pj_status_t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom on_destroy callback for looping player ports
|
||||||
|
pub unsafe extern "C" fn looping_player_on_destroy(this_port: *mut pjmedia_port) -> pj_status_t {
|
||||||
|
if !this_port.is_null() {
|
||||||
|
let port_key = this_port as usize;
|
||||||
|
if let Some(data) = LOOPING_PLAYER_DATA.get() {
|
||||||
|
data.lock().remove(&port_key);
|
||||||
|
}
|
||||||
|
tracing::debug!("Looping player port destroyed: {:p}", this_port);
|
||||||
|
}
|
||||||
|
pj_constants__PJ_SUCCESS as pj_status_t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a looping player for a call
|
||||||
|
///
|
||||||
|
/// Creates a pjmedia_port that loops the given samples and connects it to the call.
|
||||||
|
/// The loop continues until stop_loop is called.
|
||||||
|
pub fn start_loop(call_id: CallId, samples: Vec<i16>) -> Result<()> {
|
||||||
|
use super::frame_utils::{create_and_connect_port, PortCallbacks};
|
||||||
|
|
||||||
|
// Check if already looping for this call
|
||||||
|
{
|
||||||
|
let players = LOOPING_PLAYERS.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
if players.lock().contains_key(&call_id) {
|
||||||
|
tracing::warn!("Looping player already exists for call {}", call_id);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get call's conference port
|
||||||
|
let call_conf_port = CALL_CONF_PORTS
|
||||||
|
.get()
|
||||||
|
.and_then(|p| p.get(&call_id).map(|r| *r))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("No conf_port for call {} - media not ready yet", call_id)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let guard = unsafe {
|
||||||
|
let callbacks = PortCallbacks {
|
||||||
|
get_frame: looping_player_get_frame,
|
||||||
|
put_frame: super::frame_utils::noop_put_frame,
|
||||||
|
on_destroy: Some(looping_player_on_destroy),
|
||||||
|
};
|
||||||
|
|
||||||
|
let guard = create_and_connect_port(
|
||||||
|
&LOOPING_PLAYER_POOL,
|
||||||
|
b"looping_players\0",
|
||||||
|
"loop",
|
||||||
|
call_id,
|
||||||
|
0x4C4F_4F50, // "LOOP"
|
||||||
|
callbacks,
|
||||||
|
call_conf_port,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Store samples in the player data with the actual port key
|
||||||
|
{
|
||||||
|
let data = LOOPING_PLAYER_DATA.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
data.lock().insert(
|
||||||
|
guard.port_key,
|
||||||
|
LoopingPlayerData {
|
||||||
|
samples,
|
||||||
|
position: 0,
|
||||||
|
is_active: AtomicBool::new(true),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"Started looping player for call {} (player_slot={}, call_port={})",
|
||||||
|
call_id,
|
||||||
|
guard.slot,
|
||||||
|
call_conf_port
|
||||||
|
);
|
||||||
|
|
||||||
|
guard
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store player state (we manually manage the guard via stop_loop)
|
||||||
|
let players = LOOPING_PLAYERS.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
players.lock().insert(
|
||||||
|
call_id,
|
||||||
|
LoopingPlayerState {
|
||||||
|
conf_slot: guard.slot,
|
||||||
|
port_key: guard.port_key,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Forget the guard - stop_loop will handle cleanup manually
|
||||||
|
// (looping player needs explicit stop, not drop-based cleanup)
|
||||||
|
std::mem::forget(guard);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop and clean up looping player for a call
|
||||||
|
pub fn stop_loop(call_id: CallId) {
|
||||||
|
let state = {
|
||||||
|
let players = LOOPING_PLAYERS.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
players.lock().remove(&call_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(state) = state {
|
||||||
|
// Mark as inactive (get_frame will return silence)
|
||||||
|
if let Some(data) = LOOPING_PLAYER_DATA.get() {
|
||||||
|
if let Some(player_data) = data.lock().get(&state.port_key) {
|
||||||
|
player_data.is_active.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from conference
|
||||||
|
tracing::trace!(
|
||||||
|
"stop_loop: BEFORE pjsua_conf_remove_port({}) for call {} [thread: {:?}]",
|
||||||
|
state.conf_slot,
|
||||||
|
call_id,
|
||||||
|
std::thread::current().id()
|
||||||
|
);
|
||||||
|
unsafe {
|
||||||
|
pjsua_conf_remove_port(*state.conf_slot);
|
||||||
|
}
|
||||||
|
tracing::trace!(
|
||||||
|
"stop_loop: AFTER pjsua_conf_remove_port({}) for call {}",
|
||||||
|
state.conf_slot,
|
||||||
|
call_id
|
||||||
|
);
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"Stopped looping player for call {} (slot={})",
|
||||||
|
call_id,
|
||||||
|
state.conf_slot
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::debug!("No looping player to stop for call {}", call_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
sipcord-bridge/src/transport/sip/ffi/mod.rs
Normal file
31
sipcord-bridge/src/transport/sip/ffi/mod.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
//! Low-level pjsua FFI wrapper
|
||||||
|
//!
|
||||||
|
//! This module provides safe(r) Rust wrappers around the pjsua C library.
|
||||||
|
//! Pure FFI code only — application-level logic lives in the parent `sip` module.
|
||||||
|
//!
|
||||||
|
//! ## Module Structure
|
||||||
|
//!
|
||||||
|
//! - `types` - Constants, statics, wrapper types, DigestAuthParams, CallbackHandlers
|
||||||
|
//! - `utils` - String conversion utilities
|
||||||
|
//! - `init` - PJSUA initialization, TLS transport, shutdown
|
||||||
|
//! - `direct_player` - Direct player port for join sounds
|
||||||
|
//! - `streaming_player` - Streaming player for large files
|
||||||
|
//! - `looping_player` - Looping player for early media
|
||||||
|
//! - `test_tone` - Test tone generator (440Hz sine wave)
|
||||||
|
//! - `frame_utils` - Shared frame helpers and conference port guard
|
||||||
|
|
||||||
|
// pub(super) so parent sip/ modules can access internal submodules directly
|
||||||
|
pub(super) mod direct_player;
|
||||||
|
pub(crate) mod frame_utils;
|
||||||
|
pub(super) mod init;
|
||||||
|
pub(super) mod looping_player;
|
||||||
|
pub(super) mod streaming_player;
|
||||||
|
pub(super) mod test_tone;
|
||||||
|
pub mod types;
|
||||||
|
pub(super) mod utils;
|
||||||
|
|
||||||
|
// Re-export public API for external consumers (crate::transport::sip::*)
|
||||||
|
pub use direct_player::*;
|
||||||
|
pub use init::*;
|
||||||
|
pub use looping_player::*;
|
||||||
|
pub use types::*;
|
||||||
261
sipcord-bridge/src/transport/sip/ffi/streaming_player.rs
Normal file
261
sipcord-bridge/src/transport/sip/ffi/streaming_player.rs
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
//! Streaming audio player port for large files
|
||||||
|
//!
|
||||||
|
//! This module provides a PJSUA conference port that streams audio from a FLAC file
|
||||||
|
//! to a specific call. Unlike direct_player (which buffers all samples in memory),
|
||||||
|
//! this reads from disk on-demand for large files (e.g., easter egg audio).
|
||||||
|
//!
|
||||||
|
//! ## Design: Pull Model
|
||||||
|
//!
|
||||||
|
//! The streaming player uses a "pull" model where PJSUA's conference bridge calls
|
||||||
|
//! `streaming_get_frame` when it needs audio samples. This ensures precise timing
|
||||||
|
//! controlled by the audio thread's deadline-based scheduler, avoiding the timing
|
||||||
|
//! drift issues of tokio::sleep-based "push" models.
|
||||||
|
//!
|
||||||
|
//! ## Hangup Detection
|
||||||
|
//!
|
||||||
|
//! The `streaming_get_frame` callback checks if the call still exists in
|
||||||
|
//! `CALL_CONF_PORTS`. If the call has ended, it marks the player as finished
|
||||||
|
//! and returns silence. This handles mid-stream hangups cleanly.
|
||||||
|
|
||||||
|
use super::types::*;
|
||||||
|
use crate::services::sound::StreamingPlayer;
|
||||||
|
use anyhow::Result;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use pjsua::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// Global state for streaming players: port_ptr -> StreamingPlayerState
|
||||||
|
pub static STREAMING_PLAYER_STATE: OnceLock<Mutex<HashMap<usize, StreamingPlayerState>>> =
|
||||||
|
OnceLock::new();
|
||||||
|
|
||||||
|
/// Memory pool for streaming player ports
|
||||||
|
pub static STREAMING_PLAYER_POOL: OnceLock<Mutex<SendablePool>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// State for a streaming player port
|
||||||
|
pub struct StreamingPlayerState {
|
||||||
|
/// The file-backed streaming player
|
||||||
|
pub player: StreamingPlayer,
|
||||||
|
/// Call ID (for hangup detection)
|
||||||
|
pub call_id: CallId,
|
||||||
|
/// Whether playback is finished (EOF or call ended)
|
||||||
|
pub finished: bool,
|
||||||
|
/// Whether to hangup when playback completes
|
||||||
|
pub hangup_on_complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom get_frame callback for streaming player ports
|
||||||
|
///
|
||||||
|
/// This is called by the PJSUA conference bridge when it needs audio samples.
|
||||||
|
/// The timing is controlled by the audio thread's deadline-based scheduler,
|
||||||
|
/// ensuring precise 20ms frame intervals.
|
||||||
|
pub unsafe extern "C" fn streaming_get_frame(
|
||||||
|
this_port: *mut pjmedia_port,
|
||||||
|
frame: *mut pjmedia_frame,
|
||||||
|
) -> pj_status_t {
|
||||||
|
if this_port.is_null() || frame.is_null() {
|
||||||
|
return -1; // PJ_EINVAL
|
||||||
|
}
|
||||||
|
|
||||||
|
let port_key = this_port as usize;
|
||||||
|
|
||||||
|
// Get samples from the streaming player
|
||||||
|
let samples = {
|
||||||
|
let state = STREAMING_PLAYER_STATE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
let mut state = state.lock();
|
||||||
|
|
||||||
|
if let Some(player_state) = state.get_mut(&port_key) {
|
||||||
|
// Check if call still exists (hangup detection)
|
||||||
|
if !player_state.finished {
|
||||||
|
let call_exists = CALL_CONF_PORTS
|
||||||
|
.get()
|
||||||
|
.map(|p| p.contains_key(&player_state.call_id))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !call_exists {
|
||||||
|
tracing::debug!(
|
||||||
|
"Call {} ended, stopping streaming (port {:p})",
|
||||||
|
player_state.call_id,
|
||||||
|
this_port
|
||||||
|
);
|
||||||
|
player_state.finished = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if player_state.finished {
|
||||||
|
// Already finished - return silence
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
// Try to get the next frame from the streaming player
|
||||||
|
match player_state.player.get_frame(SAMPLES_PER_FRAME) {
|
||||||
|
Some(samples) => {
|
||||||
|
// Check if this was the last frame
|
||||||
|
if player_state.player.is_finished() {
|
||||||
|
player_state.finished = true;
|
||||||
|
tracing::debug!(
|
||||||
|
"Streaming playback finished for call {} (EOF)",
|
||||||
|
player_state.call_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
samples
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// No more samples - mark finished
|
||||||
|
player_state.finished = true;
|
||||||
|
tracing::debug!(
|
||||||
|
"Streaming playback finished for call {} (no more samples)",
|
||||||
|
player_state.call_id
|
||||||
|
);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fill frame buffer
|
||||||
|
if !samples.is_empty() {
|
||||||
|
super::frame_utils::fill_audio_frame(frame, &samples);
|
||||||
|
} else {
|
||||||
|
super::frame_utils::fill_silence_frame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
pj_constants__PJ_SUCCESS as pj_status_t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom on_destroy callback for streaming player ports
|
||||||
|
pub unsafe extern "C" fn streaming_on_destroy(this_port: *mut pjmedia_port) -> pj_status_t {
|
||||||
|
if !this_port.is_null() {
|
||||||
|
let port_key = this_port as usize;
|
||||||
|
if let Some(state) = STREAMING_PLAYER_STATE.get() {
|
||||||
|
state.lock().remove(&port_key);
|
||||||
|
}
|
||||||
|
tracing::debug!("Streaming player port destroyed: {:p}", this_port);
|
||||||
|
}
|
||||||
|
pj_constants__PJ_SUCCESS as pj_status_t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start streaming audio from a file to a call
|
||||||
|
///
|
||||||
|
/// This creates a PJSUA conference port backed by a StreamingPlayer and connects
|
||||||
|
/// it to the specified call. The audio thread's conference bridge will call
|
||||||
|
/// `streaming_get_frame` every 20ms to pull samples.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `call_id` - The call to stream audio to
|
||||||
|
/// * `path` - Path to the FLAC file
|
||||||
|
/// * `hangup_on_complete` - Whether to hangup the call when playback finishes
|
||||||
|
pub fn start_streaming_to_call(
|
||||||
|
call_id: CallId,
|
||||||
|
path: &Path,
|
||||||
|
hangup_on_complete: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
use super::frame_utils::{create_and_connect_port, PortCallbacks};
|
||||||
|
|
||||||
|
// Create the streaming player
|
||||||
|
let player = StreamingPlayer::new(path)?;
|
||||||
|
|
||||||
|
// Get call's conference port
|
||||||
|
let call_conf_port = CALL_CONF_PORTS
|
||||||
|
.get()
|
||||||
|
.and_then(|p| p.get(&call_id).map(|r| *r))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("No conf_port for call {} - media not ready yet", call_id)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let guard = unsafe {
|
||||||
|
let callbacks = PortCallbacks {
|
||||||
|
get_frame: streaming_get_frame,
|
||||||
|
put_frame: super::frame_utils::noop_put_frame,
|
||||||
|
on_destroy: Some(streaming_on_destroy),
|
||||||
|
};
|
||||||
|
|
||||||
|
let guard = create_and_connect_port(
|
||||||
|
&STREAMING_PLAYER_POOL,
|
||||||
|
b"streaming_players\0",
|
||||||
|
"strm",
|
||||||
|
call_id,
|
||||||
|
0x5354_524D, // "STRM"
|
||||||
|
callbacks,
|
||||||
|
call_conf_port,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Store player state with the actual port key
|
||||||
|
{
|
||||||
|
let state = STREAMING_PLAYER_STATE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
state.lock().insert(
|
||||||
|
guard.port_key,
|
||||||
|
StreamingPlayerState {
|
||||||
|
player,
|
||||||
|
call_id,
|
||||||
|
finished: false,
|
||||||
|
hangup_on_complete,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Started streaming {} to call {} (player_slot={}, call_port={})",
|
||||||
|
path.display(),
|
||||||
|
call_id,
|
||||||
|
guard.slot,
|
||||||
|
call_conf_port
|
||||||
|
);
|
||||||
|
|
||||||
|
guard
|
||||||
|
};
|
||||||
|
|
||||||
|
let port_key = guard.port_key;
|
||||||
|
|
||||||
|
// Spawn a cleanup thread that watches for completion
|
||||||
|
// The ConfPortGuard handles pjsua_conf_remove_port when dropped
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
|
||||||
|
let (finished, hangup, call_id) = {
|
||||||
|
let state = STREAMING_PLAYER_STATE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
let state = state.lock();
|
||||||
|
|
||||||
|
if let Some(player_state) = state.get(&port_key) {
|
||||||
|
(
|
||||||
|
player_state.finished,
|
||||||
|
player_state.hangup_on_complete,
|
||||||
|
player_state.call_id,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// State already removed - we're done
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if finished {
|
||||||
|
// Small delay to ensure last frame is sent
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||||
|
|
||||||
|
// Drop guard to remove from conference
|
||||||
|
// on_destroy callback will clean up STREAMING_PLAYER_STATE
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"Cleaned up streaming player (port={:p})",
|
||||||
|
port_key as *const ()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hangup if requested
|
||||||
|
if hangup {
|
||||||
|
tracing::info!("Hanging up call {} after streaming playback", call_id);
|
||||||
|
use super::types::queue_pjsua_op;
|
||||||
|
queue_pjsua_op(PendingPjsuaOp::Hangup { call_id });
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
220
sipcord-bridge/src/transport/sip/ffi/test_tone.rs
Normal file
220
sipcord-bridge/src/transport/sip/ffi/test_tone.rs
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
//! Test tone player for diagnostic audio
|
||||||
|
//!
|
||||||
|
//! Provides a 440Hz sine wave generator that plays to a specific call
|
||||||
|
//! until the caller hangs up. Used for audio pipeline testing.
|
||||||
|
|
||||||
|
use super::streaming_player::STREAMING_PLAYER_POOL;
|
||||||
|
use super::types::*;
|
||||||
|
use anyhow::Result;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use pjsua::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// Precomputed 440Hz tone lookup table (one exact period = 400 samples at 16kHz)
|
||||||
|
/// gcd(16000, 440) = 40, so period = 16000/40 = 400 samples
|
||||||
|
static TONE_LUT: OnceLock<Vec<i16>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn tone_lut() -> &'static [i16] {
|
||||||
|
TONE_LUT.get_or_init(|| {
|
||||||
|
(0..400)
|
||||||
|
.map(|i| {
|
||||||
|
let t = i as f64 / CONF_SAMPLE_RATE as f64;
|
||||||
|
(f64::sin(2.0 * std::f64::consts::PI * 440.0 * t) * 16000.0) as i16
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global state for test tone players: port_ptr -> TestToneState
|
||||||
|
pub static TEST_TONE_STATE: OnceLock<Mutex<HashMap<usize, TestToneState>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// State for a test tone player port
|
||||||
|
pub struct TestToneState {
|
||||||
|
/// Call ID (for hangup detection)
|
||||||
|
pub call_id: CallId,
|
||||||
|
/// Current phase of the sine wave (in samples)
|
||||||
|
pub phase: u64,
|
||||||
|
/// Whether playback is finished (call ended)
|
||||||
|
pub finished: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom get_frame callback for test tone player ports
|
||||||
|
///
|
||||||
|
/// Generates a 440Hz sine wave until the call ends.
|
||||||
|
pub unsafe extern "C" fn test_tone_get_frame(
|
||||||
|
this_port: *mut pjmedia_port,
|
||||||
|
frame: *mut pjmedia_frame,
|
||||||
|
) -> pj_status_t {
|
||||||
|
if this_port.is_null() || frame.is_null() {
|
||||||
|
return -1; // PJ_EINVAL
|
||||||
|
}
|
||||||
|
|
||||||
|
let port_key = this_port as usize;
|
||||||
|
|
||||||
|
// Get samples from precomputed LUT and fill frame directly
|
||||||
|
{
|
||||||
|
let state = TEST_TONE_STATE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
let mut state = state.lock();
|
||||||
|
|
||||||
|
if let Some(tone_state) = state.get_mut(&port_key) {
|
||||||
|
// Check if call still exists (hangup detection)
|
||||||
|
if !tone_state.finished {
|
||||||
|
let call_exists = CALL_CONF_PORTS
|
||||||
|
.get()
|
||||||
|
.map(|p| p.contains_key(&tone_state.call_id))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !call_exists {
|
||||||
|
tracing::debug!(
|
||||||
|
"Call {} ended, stopping test tone (port {:p})",
|
||||||
|
tone_state.call_id,
|
||||||
|
this_port
|
||||||
|
);
|
||||||
|
tone_state.finished = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tone_state.finished {
|
||||||
|
super::frame_utils::fill_silence_frame(frame);
|
||||||
|
} else {
|
||||||
|
// Copy from precomputed LUT with wraparound (two memcpy calls max)
|
||||||
|
let lut = tone_lut();
|
||||||
|
let lut_len = lut.len();
|
||||||
|
let phase = (tone_state.phase as usize) % lut_len;
|
||||||
|
tone_state.phase += SAMPLES_PER_FRAME as u64;
|
||||||
|
|
||||||
|
let first_chunk = (lut_len - phase).min(SAMPLES_PER_FRAME);
|
||||||
|
let frame_buf = (*frame).buf as *mut i16;
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
lut[phase..phase + first_chunk].as_ptr(),
|
||||||
|
frame_buf,
|
||||||
|
first_chunk,
|
||||||
|
);
|
||||||
|
|
||||||
|
if first_chunk < SAMPLES_PER_FRAME {
|
||||||
|
let remaining = SAMPLES_PER_FRAME - first_chunk;
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
lut.as_ptr(),
|
||||||
|
frame_buf.add(first_chunk),
|
||||||
|
remaining,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
(*frame).size = (SAMPLES_PER_FRAME * 2) as pj_size_t;
|
||||||
|
(*frame).type_ = pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
super::frame_utils::fill_silence_frame(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pj_constants__PJ_SUCCESS as pj_status_t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom on_destroy callback for test tone player ports
|
||||||
|
pub unsafe extern "C" fn test_tone_on_destroy(this_port: *mut pjmedia_port) -> pj_status_t {
|
||||||
|
if !this_port.is_null() {
|
||||||
|
let port_key = this_port as usize;
|
||||||
|
if let Some(state) = TEST_TONE_STATE.get() {
|
||||||
|
state.lock().remove(&port_key);
|
||||||
|
}
|
||||||
|
tracing::debug!("Test tone player port destroyed: {:p}", this_port);
|
||||||
|
}
|
||||||
|
pj_constants__PJ_SUCCESS as pj_status_t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start playing a 440Hz test tone to a call
|
||||||
|
///
|
||||||
|
/// The tone plays indefinitely until the caller hangs up. No automatic hangup.
|
||||||
|
pub fn start_test_tone_to_call(call_id: CallId) -> Result<()> {
|
||||||
|
use super::frame_utils::{create_and_connect_port, PortCallbacks};
|
||||||
|
|
||||||
|
// Get call's conference port
|
||||||
|
let call_conf_port = CALL_CONF_PORTS
|
||||||
|
.get()
|
||||||
|
.and_then(|p| p.get(&call_id).map(|r| *r))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("No conf_port for call {} - media not ready yet", call_id)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let guard = unsafe {
|
||||||
|
let callbacks = PortCallbacks {
|
||||||
|
get_frame: test_tone_get_frame,
|
||||||
|
put_frame: super::frame_utils::noop_put_frame,
|
||||||
|
on_destroy: Some(test_tone_on_destroy),
|
||||||
|
};
|
||||||
|
|
||||||
|
let guard = create_and_connect_port(
|
||||||
|
&STREAMING_PLAYER_POOL,
|
||||||
|
b"streaming_players\0",
|
||||||
|
"tone",
|
||||||
|
call_id,
|
||||||
|
0x544F_4E45, // "TONE"
|
||||||
|
callbacks,
|
||||||
|
call_conf_port,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Store player state with the actual port key
|
||||||
|
{
|
||||||
|
let state = TEST_TONE_STATE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
state.lock().insert(
|
||||||
|
guard.port_key,
|
||||||
|
TestToneState {
|
||||||
|
call_id,
|
||||||
|
phase: 0,
|
||||||
|
finished: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Started 440Hz test tone for call {} (player_slot={}, call_port={})",
|
||||||
|
call_id,
|
||||||
|
guard.slot,
|
||||||
|
call_conf_port
|
||||||
|
);
|
||||||
|
|
||||||
|
guard
|
||||||
|
};
|
||||||
|
|
||||||
|
let port_key = guard.port_key;
|
||||||
|
|
||||||
|
// Spawn a cleanup thread that watches for when the call ends
|
||||||
|
// The ConfPortGuard handles pjsua_conf_remove_port when dropped
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
|
||||||
|
let finished = {
|
||||||
|
let state = TEST_TONE_STATE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
let state = state.lock();
|
||||||
|
|
||||||
|
if let Some(tone_state) = state.get(&port_key) {
|
||||||
|
tone_state.finished
|
||||||
|
} else {
|
||||||
|
// State already removed - we're done
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if finished {
|
||||||
|
// Small delay to ensure last frame is sent
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||||
|
|
||||||
|
// Drop guard to remove from conference
|
||||||
|
// on_destroy callback will clean up TEST_TONE_STATE
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"Cleaned up test tone player (port={:p})",
|
||||||
|
port_key as *const ()
|
||||||
|
);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
477
sipcord-bridge/src/transport/sip/ffi/types.rs
Normal file
477
sipcord-bridge/src/transport/sip/ffi/types.rs
Normal file
|
|
@ -0,0 +1,477 @@
|
||||||
|
//! Low-level pjsua wrapper types and constants
|
||||||
|
//!
|
||||||
|
//! This module provides safe(r) Rust wrappers around the pjsua C library.
|
||||||
|
//!
|
||||||
|
//! ## Audio Architecture
|
||||||
|
//!
|
||||||
|
//! When using `pjsua_set_no_snd_dev()`, we take control of audio I/O:
|
||||||
|
//! - pjsua's conference bridge handles codec negotiation and mixing
|
||||||
|
//! - We periodically call `get_frame`/`put_frame` to exchange audio with the conference
|
||||||
|
//! - The conference outputs 16kHz mono PCM regardless of call codec (G.711, Opus, etc.)
|
||||||
|
//! - We resample to/from Discord's 48kHz stereo
|
||||||
|
|
||||||
|
use crate::services::snowflake::Snowflake;
|
||||||
|
use crossbeam_channel::Sender;
|
||||||
|
use crossbeam_queue::SegQueue;
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use ipnet::Ipv4Net;
|
||||||
|
use parking_lot::{Mutex, RwLock};
|
||||||
|
use pjsua::*;
|
||||||
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::ops::Deref;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
// CallId newtype
|
||||||
|
|
||||||
|
/// Type-safe wrapper around `pjsua_call_id` (i32).
|
||||||
|
///
|
||||||
|
/// Prevents accidental confusion with conference port IDs, account IDs,
|
||||||
|
/// and other bare `i32` values in the pjsua API.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub struct CallId(i32);
|
||||||
|
|
||||||
|
impl CallId {
|
||||||
|
/// Sentinel for "no call" / invalid call ID.
|
||||||
|
pub const INVALID: CallId = CallId(-1);
|
||||||
|
|
||||||
|
pub const fn new(value: i32) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn get(self) -> i32 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn is_valid(self) -> bool {
|
||||||
|
self.0 >= 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for CallId {
|
||||||
|
type Target = i32;
|
||||||
|
fn deref(&self) -> &i32 {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<i32> for CallId {
|
||||||
|
fn from(v: i32) -> Self {
|
||||||
|
Self(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CallId> for i32 {
|
||||||
|
fn from(c: CallId) -> i32 {
|
||||||
|
c.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for CallId {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for CallId {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "CallId({})", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfPort newtype
|
||||||
|
|
||||||
|
/// Type-safe wrapper around conference port slot IDs (`i32`).
|
||||||
|
///
|
||||||
|
/// Prevents accidental confusion with `CallId`, account IDs,
|
||||||
|
/// and other bare `i32` values in the pjsua API.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub struct ConfPort(i32);
|
||||||
|
|
||||||
|
impl ConfPort {
|
||||||
|
/// Sentinel for "no port" / invalid conf port.
|
||||||
|
pub const INVALID: ConfPort = ConfPort(-1);
|
||||||
|
|
||||||
|
pub const fn new(value: i32) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn get(self) -> i32 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn is_valid(self) -> bool {
|
||||||
|
self.0 >= 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for ConfPort {
|
||||||
|
type Target = i32;
|
||||||
|
fn deref(&self) -> &i32 {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<i32> for ConfPort {
|
||||||
|
fn from(v: i32) -> Self {
|
||||||
|
Self(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ConfPort> for i32 {
|
||||||
|
fn from(c: ConfPort) -> i32 {
|
||||||
|
c.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ConfPort {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ConfPort {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "ConfPort({})", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SIP Digest auth parameters extracted from Authorization header
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct DigestAuthParams {
|
||||||
|
pub username: String,
|
||||||
|
pub realm: String,
|
||||||
|
pub nonce: String,
|
||||||
|
pub uri: String,
|
||||||
|
pub response: String,
|
||||||
|
pub method: String,
|
||||||
|
pub qop: Option<String>,
|
||||||
|
pub nc: Option<String>,
|
||||||
|
pub cnonce: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callback handlers for SIP events
|
||||||
|
pub struct CallbackHandlers {
|
||||||
|
pub on_incoming_call: Box<dyn Fn(CallId, String, String, Option<IpAddr>) + Send + Sync>,
|
||||||
|
pub on_call_authenticated:
|
||||||
|
Box<dyn Fn(CallId, DigestAuthParams, String, Option<IpAddr>) + Send + Sync>,
|
||||||
|
pub on_dtmf: Box<dyn Fn(CallId, char) + Send + Sync>,
|
||||||
|
pub on_call_ended: Box<dyn Fn(CallId) + Send + Sync>,
|
||||||
|
/// Audio frame callback: (channel_id, samples, sample_rate)
|
||||||
|
/// channel_id is the Discord channel ID (Snowflake) for per-channel routing
|
||||||
|
pub on_audio_frame: AudioFrameCallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callback type for audio frame delivery: (channel_id, samples, sample_rate)
|
||||||
|
pub type AudioFrameCallback = Box<dyn Fn(Snowflake, &[i16], u32) + Send + Sync>;
|
||||||
|
|
||||||
|
/// Realm for our SIP server
|
||||||
|
pub const SIP_REALM: &str = "sipcord";
|
||||||
|
|
||||||
|
/// Conference bridge sample rate (16kHz)
|
||||||
|
pub const CONF_SAMPLE_RATE: u32 = 16000;
|
||||||
|
|
||||||
|
/// Conference bridge channels (mono)
|
||||||
|
pub const CONF_CHANNELS: u32 = 1;
|
||||||
|
|
||||||
|
/// Audio frame duration in ms
|
||||||
|
pub const FRAME_PTIME_MS: u32 = 20;
|
||||||
|
|
||||||
|
/// Samples per frame = sample_rate * ptime / 1000
|
||||||
|
pub const SAMPLES_PER_FRAME: usize = (CONF_SAMPLE_RATE * FRAME_PTIME_MS / 1000) as usize;
|
||||||
|
|
||||||
|
// Config accessors — cached on first call via OnceLock (config is immutable at runtime).
|
||||||
|
|
||||||
|
pub fn rtp_inactivity_timeout_secs() -> u64 {
|
||||||
|
static CACHED: OnceLock<u64> = OnceLock::new();
|
||||||
|
*CACHED.get_or_init(|| crate::config::AppConfig::bridge().rtp_inactivity_timeout_secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shorter timeout for calls that never receive any RTP at all
|
||||||
|
pub fn no_audio_timeout_secs() -> u64 {
|
||||||
|
static CACHED: OnceLock<u64> = OnceLock::new();
|
||||||
|
*CACHED.get_or_init(|| crate::config::AppConfig::bridge().no_audio_timeout_secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn empty_bridge_grace_period_secs() -> u64 {
|
||||||
|
static CACHED: OnceLock<u64> = OnceLock::new();
|
||||||
|
*CACHED.get_or_init(|| crate::config::AppConfig::bridge().empty_bridge_grace_period_secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn max_channel_buffer_samples() -> usize {
|
||||||
|
static CACHED: OnceLock<usize> = OnceLock::new();
|
||||||
|
*CACHED.get_or_init(|| crate::config::AppConfig::bridge().max_channel_buffer_samples)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper for pjmedia_port pointer that is Send
|
||||||
|
/// Safety: pjsua is single-threaded and we only access this from the audio thread
|
||||||
|
pub struct SendablePort(pub *mut pjmedia_port);
|
||||||
|
unsafe impl Send for SendablePort {}
|
||||||
|
unsafe impl Sync for SendablePort {}
|
||||||
|
|
||||||
|
/// Wrapper for pj_pool_t pointer
|
||||||
|
pub struct SendablePool(pub *mut pj_pool_t);
|
||||||
|
unsafe impl Send for SendablePool {}
|
||||||
|
unsafe impl Sync for SendablePool {}
|
||||||
|
|
||||||
|
/// Type alias for local network config: (local_host, parsed_cidr, port, rtp_public_ip)
|
||||||
|
pub type LocalNetConfig = (String, Ipv4Net, u16, Option<String>);
|
||||||
|
|
||||||
|
/// Type alias for drain cache entry: (last_drain_time, cached_samples, sample_count)
|
||||||
|
/// Using Arc<[i16]> for single allocation (no separate Vec header).
|
||||||
|
/// Cache hit becomes Arc::clone() (zero-copy).
|
||||||
|
pub type DrainCacheEntry = (Instant, Arc<[i16]>, usize);
|
||||||
|
|
||||||
|
/// Type alias for direct player entry: (samples buffer, current read position)
|
||||||
|
pub type DirectPlayerEntry = (Vec<i16>, usize);
|
||||||
|
|
||||||
|
// Global statics
|
||||||
|
|
||||||
|
/// Global callback handlers (pjsua uses global callbacks)
|
||||||
|
pub static CALLBACKS: OnceLock<Mutex<Option<CallbackHandlers>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Audio output buffers per call (Discord -> SIP)
|
||||||
|
/// Using DashMap for lock-free concurrent access on audio hot path
|
||||||
|
pub static AUDIO_OUT_BUFFERS: OnceLock<DashMap<CallId, VecDeque<i16>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Master conference port (returned by pjsua_set_no_snd_dev)
|
||||||
|
pub static CONF_MASTER_PORT: OnceLock<Mutex<SendablePort>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Local network config for Contact header and SDP rewriting
|
||||||
|
/// Stored as (local_host, parsed_cidr, port, rtp_public_ip) for efficient lookup in the callback
|
||||||
|
/// rtp_public_ip is the IP that pjsua advertises in SDP - we replace it with local_host for local clients
|
||||||
|
pub static LOCAL_NET_CONFIG: OnceLock<Option<LocalNetConfig>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Public host config for rewriting private IPs in Contact headers sent to external clients.
|
||||||
|
/// pjsua derives Contact from the TCP connection's local address (e.g. 10.0.1.7), but external
|
||||||
|
/// clients need the public hostname to route in-dialog requests (BYE) back to us.
|
||||||
|
/// Stored as (public_host, sip_port).
|
||||||
|
pub static PUBLIC_HOST_CONFIG: OnceLock<Option<(String, u16)>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Flag to indicate audio thread should stop
|
||||||
|
pub static AUDIO_THREAD_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Audio thread handle for joining on shutdown
|
||||||
|
pub static AUDIO_THREAD_HANDLE: OnceLock<Mutex<Option<std::thread::JoinHandle<()>>>> =
|
||||||
|
OnceLock::new();
|
||||||
|
|
||||||
|
/// Flag indicating the audio thread has processed at least one frame
|
||||||
|
/// This is used to defer channel registration completions until the conference
|
||||||
|
/// bridge is actively being clocked.
|
||||||
|
pub static AUDIO_THREAD_READY: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Queue of pending channel registrations to complete once audio thread is ready
|
||||||
|
/// Stores (call_id, conf_port) pairs that need complete_pending_channel_registration called
|
||||||
|
/// Uses lock-free SegQueue for zero-contention push/pop on the 50Hz audio thread
|
||||||
|
pub static PENDING_CHANNEL_COMPLETIONS: SegQueue<(CallId, ConfPort)> = SegQueue::new();
|
||||||
|
|
||||||
|
/// Queue of pending conference connections to be made by the audio thread
|
||||||
|
/// Stores (call_id, channel_id) pairs that need their conference connections made
|
||||||
|
/// This is used because pjsua_conf_connect conflicts with the audio thread's
|
||||||
|
/// pjmedia_port_get_frame calls if made from a different thread
|
||||||
|
/// Uses lock-free SegQueue for zero-contention push/pop on the 50Hz audio thread
|
||||||
|
pub static PENDING_CONF_CONNECTIONS: SegQueue<(CallId, Snowflake)> = SegQueue::new();
|
||||||
|
|
||||||
|
/// Pending PJSUA operations that must be executed by the audio thread
|
||||||
|
/// These operations modify the conference bridge and must be synchronized with get_frame
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum PendingPjsuaOp {
|
||||||
|
/// Play samples directly to a call (for join sounds)
|
||||||
|
/// Note: This also stops any active looping player for the call first
|
||||||
|
PlayDirect { call_id: CallId, samples: Vec<i16> },
|
||||||
|
/// Start streaming audio from a file to a call (for large easter egg files)
|
||||||
|
/// Uses pull model for precise timing - audio thread pulls frames as needed
|
||||||
|
StartStreaming {
|
||||||
|
call_id: CallId,
|
||||||
|
path: PathBuf,
|
||||||
|
hangup_on_complete: bool,
|
||||||
|
},
|
||||||
|
/// Start playing a 440Hz test tone to a call (plays until caller hangs up)
|
||||||
|
StartTestTone { call_id: CallId },
|
||||||
|
/// Hangup a call (used internally for cleanup after streaming)
|
||||||
|
Hangup { call_id: CallId },
|
||||||
|
/// Start a looping audio player for early media (connecting sound)
|
||||||
|
/// Must run on audio thread to avoid race with pjmedia_port_get_frame
|
||||||
|
StartLoop { call_id: CallId, samples: Vec<i16> },
|
||||||
|
/// Connect a fax audio port bidirectionally in the conference bridge.
|
||||||
|
/// Must run on the audio thread to avoid racing with pjmedia_port_get_frame.
|
||||||
|
/// The oneshot sender signals completion back to the async caller.
|
||||||
|
ConnectFaxPort {
|
||||||
|
call_id: CallId,
|
||||||
|
fax_slot: ConfPort,
|
||||||
|
call_conf_port: ConfPort,
|
||||||
|
done_tx: tokio::sync::oneshot::Sender<bool>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue of pending PJSUA operations to be executed by the audio thread
|
||||||
|
/// Uses lock-free SegQueue for zero-contention push/pop on the 50Hz audio thread
|
||||||
|
pub static PENDING_PJSUA_OPS: SegQueue<PendingPjsuaOp> = SegQueue::new();
|
||||||
|
|
||||||
|
/// Set of call_ids with active media (used to start/stop audio thread)
|
||||||
|
/// This prevents double-counting or decrementing calls that were never counted
|
||||||
|
pub static COUNTED_CALL_IDS: OnceLock<Mutex<HashSet<CallId>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// TLS transport ID (for reload support)
|
||||||
|
pub static TLS_TRANSPORT_ID: OnceLock<Mutex<Option<std::os::raw::c_int>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Flag indicating TLS reload is pending
|
||||||
|
pub static TLS_RELOAD_PENDING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Per-call RTP activity tracking: call_id -> (last_rx_packet_count, last_activity_time)
|
||||||
|
/// Used to detect dead calls when SIP BYE is not received
|
||||||
|
pub static CALL_RTP_ACTIVITY: OnceLock<Mutex<HashMap<CallId, (u64, Instant)>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Event sender for timeout events (set during callback setup)
|
||||||
|
pub static TIMEOUT_EVENT_TX: OnceLock<Mutex<Option<Sender<super::super::SipEvent>>>> =
|
||||||
|
OnceLock::new();
|
||||||
|
|
||||||
|
// Per-channel audio isolation statics
|
||||||
|
|
||||||
|
/// call_id -> conf_port mapping (for connecting/disconnecting calls)
|
||||||
|
/// Using DashMap for lock-free concurrent access on audio hot path
|
||||||
|
pub static CALL_CONF_PORTS: OnceLock<DashMap<CallId, ConfPort>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// call_id -> channel_id mapping (which Discord channel each call belongs to)
|
||||||
|
/// Using DashMap for lock-free concurrent access on audio hot path
|
||||||
|
pub static CALL_CHANNELS: OnceLock<DashMap<CallId, Snowflake>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// channel_id -> set of call_ids (all calls in each channel)
|
||||||
|
/// Uses RwLock: audio thread takes .read() (non-exclusive, 50Hz), call lifecycle takes .write()
|
||||||
|
pub static CHANNEL_CALLS: OnceLock<RwLock<HashMap<Snowflake, HashSet<CallId>>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// channel_id -> audio INPUT buffer (SIP -> Discord, per-channel)
|
||||||
|
/// Filled by channel_port_put_frame callback, drained by audio thread for Discord
|
||||||
|
/// Using DashMap for lock-free concurrent access on audio hot path
|
||||||
|
pub static CHANNEL_AUDIO_IN: OnceLock<DashMap<Snowflake, VecDeque<i16>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Per-channel conference ports: channel_id -> (pjmedia_port*, conf_slot)
|
||||||
|
/// Each channel gets its own CUSTOM BUFFER port for isolated Discord->SIP audio routing
|
||||||
|
/// Unlike null ports, these actually provide audio to the conference via get_frame callback
|
||||||
|
pub static CHANNEL_CONF_PORTS: OnceLock<Mutex<HashMap<Snowflake, (SendablePort, ConfPort)>>> =
|
||||||
|
OnceLock::new();
|
||||||
|
|
||||||
|
/// Reverse mapping: port_ptr -> channel_id (for get_frame callback to find the right buffer)
|
||||||
|
pub static PORT_TO_CHANNEL: OnceLock<Mutex<HashMap<usize, Snowflake>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Memory pool for creating channel ports
|
||||||
|
pub static CHANNEL_PORT_POOL: OnceLock<Mutex<SendablePool>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Global audio frame counter (incremented once per audio thread tick)
|
||||||
|
/// Used to prevent channel ports from being drained multiple times per frame
|
||||||
|
pub static AUDIO_FRAME_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
|
|
||||||
|
/// Per-channel time-based cache: channel_id -> (last_drain_time, cached_samples)
|
||||||
|
/// If get_frame is called multiple times within 15ms (same PJSUA tick), we return the cached samples.
|
||||||
|
/// This prevents N callers from draining N*320 samples when they should all share the same frame.
|
||||||
|
/// Using DashMap for lock-free concurrent access on audio hot path
|
||||||
|
pub static CHANNEL_DRAIN_CACHE: OnceLock<DashMap<Snowflake, DrainCacheEntry>> = OnceLock::new();
|
||||||
|
|
||||||
|
// Direct player statics
|
||||||
|
|
||||||
|
/// Direct player state: port_ptr -> (samples buffer, current read position)
|
||||||
|
/// Used for playing audio directly to a single call without going through channel buffer
|
||||||
|
pub static DIRECT_PLAYER_STATE: OnceLock<Mutex<HashMap<usize, DirectPlayerEntry>>> =
|
||||||
|
OnceLock::new();
|
||||||
|
|
||||||
|
/// Memory pool for direct player ports
|
||||||
|
pub static DIRECT_PLAYER_POOL: OnceLock<Mutex<SendablePool>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Queue a PJSUA operation to be executed by the audio thread
|
||||||
|
pub fn queue_pjsua_op(op: PendingPjsuaOp) {
|
||||||
|
PENDING_PJSUA_OPS.push(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_call_id_invalid() {
|
||||||
|
assert_eq!(CallId::INVALID.get(), -1);
|
||||||
|
assert!(!CallId::INVALID.is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_call_id_valid() {
|
||||||
|
assert!(CallId::new(0).is_valid());
|
||||||
|
assert!(CallId::new(5).is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_call_id_deref() {
|
||||||
|
let id = CallId::new(42);
|
||||||
|
let val: &i32 = &id;
|
||||||
|
assert_eq!(*val, 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_call_id_from_into() {
|
||||||
|
let id: CallId = 7.into();
|
||||||
|
assert_eq!(id.get(), 7);
|
||||||
|
let raw: i32 = id.into();
|
||||||
|
assert_eq!(raw, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_call_id_display_debug() {
|
||||||
|
let id = CallId::new(3);
|
||||||
|
assert_eq!(format!("{}", id), "3");
|
||||||
|
assert_eq!(format!("{:?}", id), "CallId(3)");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_conf_port_invalid() {
|
||||||
|
assert_eq!(ConfPort::INVALID.get(), -1);
|
||||||
|
assert!(!ConfPort::INVALID.is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_conf_port_valid() {
|
||||||
|
assert!(ConfPort::new(0).is_valid());
|
||||||
|
assert!(ConfPort::new(5).is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_conf_port_deref() {
|
||||||
|
let port = ConfPort::new(10);
|
||||||
|
let val: &i32 = &port;
|
||||||
|
assert_eq!(*val, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_conf_port_from_into() {
|
||||||
|
let port: ConfPort = 9.into();
|
||||||
|
assert_eq!(port.get(), 9);
|
||||||
|
let raw: i32 = port.into();
|
||||||
|
assert_eq!(raw, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_conf_port_display_debug() {
|
||||||
|
let port = ConfPort::new(4);
|
||||||
|
assert_eq!(format!("{}", port), "4");
|
||||||
|
assert_eq!(format!("{:?}", port), "ConfPort(4)");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_digest_auth_params_default() {
|
||||||
|
let params = DigestAuthParams::default();
|
||||||
|
assert!(params.username.is_empty());
|
||||||
|
assert!(params.realm.is_empty());
|
||||||
|
assert!(params.nonce.is_empty());
|
||||||
|
assert!(params.uri.is_empty());
|
||||||
|
assert!(params.response.is_empty());
|
||||||
|
assert!(params.method.is_empty());
|
||||||
|
assert!(params.qop.is_none());
|
||||||
|
assert!(params.nc.is_none());
|
||||||
|
assert!(params.cnonce.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
67
sipcord-bridge/src/transport/sip/ffi/utils.rs
Normal file
67
sipcord-bridge/src/transport/sip/ffi/utils.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
//! Utility functions for pjsua wrapper
|
||||||
|
|
||||||
|
use pjsua::pj_str_t;
|
||||||
|
|
||||||
|
/// Convert a pj_str_t to a Rust String
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// The pj_str_t must point to valid memory for `slen` bytes.
|
||||||
|
pub unsafe fn pj_str_to_string(s: &pj_str_t) -> String {
|
||||||
|
if s.ptr.is_null() || s.slen <= 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let slice = std::slice::from_raw_parts(s.ptr as *const u8, s.slen as usize);
|
||||||
|
String::from_utf8_lossy(slice).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract username from SIP URI (e.g., "<sip:username@domain>" -> "username")
|
||||||
|
pub fn extract_sip_username(uri: &str) -> String {
|
||||||
|
// Remove angle brackets if present
|
||||||
|
let uri = uri.trim_start_matches('<').trim_end_matches('>');
|
||||||
|
|
||||||
|
// Remove "sip:" prefix
|
||||||
|
let uri = uri.strip_prefix("sip:").unwrap_or(uri);
|
||||||
|
|
||||||
|
// Take everything before @ as username
|
||||||
|
if let Some(at_pos) = uri.find('@') {
|
||||||
|
uri[..at_pos].to_string()
|
||||||
|
} else {
|
||||||
|
uri.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_sip_username_full_uri() {
|
||||||
|
assert_eq!(extract_sip_username("<sip:alice@example.com>"), "alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_sip_username_no_brackets() {
|
||||||
|
assert_eq!(extract_sip_username("sip:bob@domain"), "bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_sip_username_no_sip_prefix() {
|
||||||
|
assert_eq!(extract_sip_username("charlie@host"), "charlie");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_sip_username_no_at() {
|
||||||
|
assert_eq!(extract_sip_username("sip:dave"), "dave");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_sip_username_with_port() {
|
||||||
|
assert_eq!(extract_sip_username("<sip:eve@host:5060>"), "eve");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_sip_username_empty() {
|
||||||
|
assert_eq!(extract_sip_username(""), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
278
sipcord-bridge/src/transport/sip/fork_group.rs
Normal file
278
sipcord-bridge/src/transport/sip/fork_group.rs
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
//! Fork group tracking for multi-contact outbound call forking.
|
||||||
|
//!
|
||||||
|
//! When a user has multiple SIP phones registered, the bridge rings all of them
|
||||||
|
//! simultaneously. A "fork group" tracks the set of forked call legs for a single
|
||||||
|
//! logical call (identified by tracking_id). When one leg answers, the siblings
|
||||||
|
//! are cancelled. When all legs fail, the failure is reported.
|
||||||
|
|
||||||
|
use super::CallId;
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
/// Global fork group registry, keyed by tracking_id.
|
||||||
|
static FORK_GROUPS: OnceLock<DashMap<String, ForkGroup>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn groups() -> &'static DashMap<String, ForkGroup> {
|
||||||
|
FORK_GROUPS.get_or_init(DashMap::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ForkGroup {
|
||||||
|
/// Call IDs that were successfully started (active legs)
|
||||||
|
sibling_call_ids: HashSet<CallId>,
|
||||||
|
/// Call IDs that have failed (answered-then-disconnected, or never-answered)
|
||||||
|
failed_call_ids: HashSet<CallId>,
|
||||||
|
/// Number of calls that failed to even start (MakeOutboundCall returned error)
|
||||||
|
initial_failures: usize,
|
||||||
|
/// The call_id that answered first (if any)
|
||||||
|
answered_call_id: Option<CallId>,
|
||||||
|
/// Total number of fork attempts (successful starts + initial failures)
|
||||||
|
expected_total: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a successfully started call leg in a fork group.
|
||||||
|
///
|
||||||
|
/// Called from `process_sip_command` after `make_outbound_call` succeeds.
|
||||||
|
pub fn add_member(tracking_id: &str, call_id: CallId, fork_total: usize) {
|
||||||
|
let mut entry = groups()
|
||||||
|
.entry(tracking_id.to_string())
|
||||||
|
.or_insert_with(|| ForkGroup {
|
||||||
|
sibling_call_ids: HashSet::new(),
|
||||||
|
failed_call_ids: HashSet::new(),
|
||||||
|
initial_failures: 0,
|
||||||
|
answered_call_id: None,
|
||||||
|
expected_total: fork_total,
|
||||||
|
});
|
||||||
|
entry.sibling_call_ids.insert(call_id);
|
||||||
|
debug!(
|
||||||
|
"Fork group {}: added call_id={}, members={}/{}",
|
||||||
|
tracking_id,
|
||||||
|
call_id,
|
||||||
|
entry.sibling_call_ids.len() + entry.initial_failures,
|
||||||
|
fork_total
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track a call that failed to start (make_outbound_call returned error).
|
||||||
|
///
|
||||||
|
/// Called from `process_sip_command` when `make_outbound_call` fails.
|
||||||
|
pub fn add_initial_failure(tracking_id: &str, fork_total: usize) {
|
||||||
|
let mut entry = groups()
|
||||||
|
.entry(tracking_id.to_string())
|
||||||
|
.or_insert_with(|| ForkGroup {
|
||||||
|
sibling_call_ids: HashSet::new(),
|
||||||
|
failed_call_ids: HashSet::new(),
|
||||||
|
initial_failures: 0,
|
||||||
|
answered_call_id: None,
|
||||||
|
expected_total: fork_total,
|
||||||
|
});
|
||||||
|
entry.initial_failures += 1;
|
||||||
|
debug!(
|
||||||
|
"Fork group {}: initial failure, failures={}/{}",
|
||||||
|
tracking_id,
|
||||||
|
entry.initial_failures + entry.failed_call_ids.len(),
|
||||||
|
fork_total
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a fork leg as answered. Returns the sibling call_ids to cancel (if this
|
||||||
|
/// is the first answer), or `None` if another leg already answered.
|
||||||
|
///
|
||||||
|
/// The fork group is removed after this call since the logical call is resolved.
|
||||||
|
pub fn mark_answered(tracking_id: &str, call_id: CallId) -> Option<Vec<CallId>> {
|
||||||
|
// Use remove to get exclusive ownership - prevents races between two simultaneous answers
|
||||||
|
let (_, mut group) = groups().remove(tracking_id)?;
|
||||||
|
|
||||||
|
if group.answered_call_id.is_some() {
|
||||||
|
// Another leg already answered - this shouldn't happen with DashMap remove,
|
||||||
|
// but handle it gracefully
|
||||||
|
info!(
|
||||||
|
"Fork group {}: call_id={} answered but already resolved",
|
||||||
|
tracking_id, call_id
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
group.answered_call_id = Some(call_id);
|
||||||
|
|
||||||
|
// Collect siblings to cancel (all members except the one that answered, minus already-failed)
|
||||||
|
let siblings: Vec<CallId> = group
|
||||||
|
.sibling_call_ids
|
||||||
|
.iter()
|
||||||
|
.filter(|&&id| id != call_id && !group.failed_call_ids.contains(&id))
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Fork group {}: call_id={} answered, cancelling {} siblings",
|
||||||
|
tracking_id,
|
||||||
|
call_id,
|
||||||
|
siblings.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
Some(siblings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a fork leg as failed. Returns `true` if ALL legs have now failed
|
||||||
|
/// (meaning the logical call should be reported as failed to the DO).
|
||||||
|
///
|
||||||
|
/// The fork group is removed when all legs have failed.
|
||||||
|
pub fn mark_failed(tracking_id: &str, call_id: CallId) -> bool {
|
||||||
|
let mut entry = match groups().get_mut(tracking_id) {
|
||||||
|
Some(e) => e,
|
||||||
|
None => {
|
||||||
|
// Group already removed (answered or all-failed) — this is a late failure
|
||||||
|
// from a leg that was being cancelled. Not an error.
|
||||||
|
debug!(
|
||||||
|
"Fork group {}: call_id={} failed but group already resolved",
|
||||||
|
tracking_id, call_id
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// If this group was already answered, don't count this failure
|
||||||
|
if entry.answered_call_id.is_some() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.sibling_call_ids.remove(&call_id);
|
||||||
|
entry.failed_call_ids.insert(call_id);
|
||||||
|
|
||||||
|
let total_resolved = entry.failed_call_ids.len() + entry.initial_failures;
|
||||||
|
let all_failed = total_resolved >= entry.expected_total;
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Fork group {}: call_id={} failed, resolved={}/{}, all_failed={}",
|
||||||
|
tracking_id, call_id, total_resolved, entry.expected_total, all_failed
|
||||||
|
);
|
||||||
|
|
||||||
|
if all_failed {
|
||||||
|
// Drop the mutable ref before removing
|
||||||
|
drop(entry);
|
||||||
|
groups().remove(tracking_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
all_failed
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Generate a unique tracking ID per test to avoid interference with the global DashMap
|
||||||
|
fn unique_id(base: &str) -> String {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
format!("{}_{}", base, COUNTER.fetch_add(1, Ordering::Relaxed))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_members_and_answer() {
|
||||||
|
let tid = unique_id("answer");
|
||||||
|
let c1 = CallId::new(100);
|
||||||
|
let c2 = CallId::new(101);
|
||||||
|
let c3 = CallId::new(102);
|
||||||
|
|
||||||
|
add_member(&tid, c1, 3);
|
||||||
|
add_member(&tid, c2, 3);
|
||||||
|
add_member(&tid, c3, 3);
|
||||||
|
|
||||||
|
// c1 answers -> siblings c2, c3 returned for cancellation
|
||||||
|
let siblings = mark_answered(&tid, c1).unwrap();
|
||||||
|
assert_eq!(siblings.len(), 2);
|
||||||
|
assert!(siblings.contains(&c2));
|
||||||
|
assert!(siblings.contains(&c3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_all_failed() {
|
||||||
|
let tid = unique_id("allfail");
|
||||||
|
let c1 = CallId::new(200);
|
||||||
|
let c2 = CallId::new(201);
|
||||||
|
|
||||||
|
add_member(&tid, c1, 2);
|
||||||
|
add_member(&tid, c2, 2);
|
||||||
|
|
||||||
|
assert!(!mark_failed(&tid, c1)); // 1/2 failed
|
||||||
|
assert!(mark_failed(&tid, c2)); // 2/2 failed -> all_failed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_answer_on_already_resolved() {
|
||||||
|
let tid = unique_id("double_answer");
|
||||||
|
let c1 = CallId::new(300);
|
||||||
|
let c2 = CallId::new(301);
|
||||||
|
|
||||||
|
add_member(&tid, c1, 2);
|
||||||
|
add_member(&tid, c2, 2);
|
||||||
|
|
||||||
|
// First answer removes the group
|
||||||
|
mark_answered(&tid, c1);
|
||||||
|
|
||||||
|
// Second answer -> group gone, returns None
|
||||||
|
assert!(mark_answered(&tid, c2).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_failed_on_already_resolved() {
|
||||||
|
let tid = unique_id("fail_after_answer");
|
||||||
|
let c1 = CallId::new(400);
|
||||||
|
let c2 = CallId::new(401);
|
||||||
|
|
||||||
|
add_member(&tid, c1, 2);
|
||||||
|
add_member(&tid, c2, 2);
|
||||||
|
|
||||||
|
mark_answered(&tid, c1);
|
||||||
|
|
||||||
|
// Late failure after answer -> returns false
|
||||||
|
assert!(!mark_failed(&tid, c2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_initial_failures_plus_call_failures() {
|
||||||
|
let tid = unique_id("mixed_fail");
|
||||||
|
let c1 = CallId::new(500);
|
||||||
|
|
||||||
|
// 3 total forks: 1 started, 2 failed to start
|
||||||
|
add_member(&tid, c1, 3);
|
||||||
|
add_initial_failure(&tid, 3);
|
||||||
|
add_initial_failure(&tid, 3);
|
||||||
|
|
||||||
|
// The one started leg also fails -> all_failed
|
||||||
|
assert!(mark_failed(&tid, c1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_single_member_fork_group() {
|
||||||
|
let tid = unique_id("single");
|
||||||
|
let c1 = CallId::new(600);
|
||||||
|
|
||||||
|
add_member(&tid, c1, 1);
|
||||||
|
|
||||||
|
// Single member answers -> empty siblings list
|
||||||
|
let siblings = mark_answered(&tid, c1).unwrap();
|
||||||
|
assert!(siblings.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_some_fail_then_answer() {
|
||||||
|
let tid = unique_id("partial_fail_answer");
|
||||||
|
let c1 = CallId::new(700);
|
||||||
|
let c2 = CallId::new(701);
|
||||||
|
let c3 = CallId::new(702);
|
||||||
|
|
||||||
|
add_member(&tid, c1, 3);
|
||||||
|
add_member(&tid, c2, 3);
|
||||||
|
add_member(&tid, c3, 3);
|
||||||
|
|
||||||
|
// c1 fails first
|
||||||
|
assert!(!mark_failed(&tid, c1));
|
||||||
|
|
||||||
|
// c2 answers -> should only cancel c3 (c1 already failed)
|
||||||
|
let siblings = mark_answered(&tid, c2).unwrap();
|
||||||
|
assert_eq!(siblings.len(), 1);
|
||||||
|
assert!(siblings.contains(&c3));
|
||||||
|
}
|
||||||
|
}
|
||||||
579
sipcord-bridge/src/transport/sip/mod.rs
Normal file
579
sipcord-bridge/src/transport/sip/mod.rs
Normal file
|
|
@ -0,0 +1,579 @@
|
||||||
|
pub mod ffi;
|
||||||
|
|
||||||
|
mod audio_thread;
|
||||||
|
mod callbacks;
|
||||||
|
mod channel_audio;
|
||||||
|
pub mod fork_group;
|
||||||
|
mod nat;
|
||||||
|
mod register_handler;
|
||||||
|
|
||||||
|
// Re-export everything from the pjsua FFI module
|
||||||
|
pub use self::ffi::*;
|
||||||
|
|
||||||
|
// Re-export from mixed/application-level modules
|
||||||
|
pub use audio_thread::{
|
||||||
|
check_rtp_inactivity, cleanup_zombie_pjsua_calls, set_timeout_event_sender,
|
||||||
|
validate_counted_calls,
|
||||||
|
};
|
||||||
|
pub use callbacks::{set_outbound_event_sender, T38_PRESOCKETS};
|
||||||
|
pub use channel_audio::{
|
||||||
|
cleanup_channel_port, clear_channel_stale_audio, register_call_channel,
|
||||||
|
register_discord_to_sip, unregister_call_channel, unregister_discord_to_sip,
|
||||||
|
};
|
||||||
|
pub use register_handler::{set_register_event_sender, set_sip_command_sender, PendingRegisterTsx};
|
||||||
|
|
||||||
|
use crate::config::{SipConfig, TlsConfig};
|
||||||
|
use crate::transport::discord::send_audio_to_discord_direct;
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossbeam_channel::{bounded, Receiver, Sender};
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tracing::{debug, error, info, trace};
|
||||||
|
|
||||||
|
/// Events emitted by the SIP module
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SipEvent {
|
||||||
|
/// Incoming call received with SIP Digest auth params and extension
|
||||||
|
IncomingCall {
|
||||||
|
call_id: CallId,
|
||||||
|
/// SIP Digest auth parameters (boxed to reduce enum size)
|
||||||
|
digest_auth: Box<DigestAuthParams>,
|
||||||
|
/// Extension being called (from To header)
|
||||||
|
extension: String,
|
||||||
|
/// Source IP address of the caller
|
||||||
|
source_ip: Option<IpAddr>,
|
||||||
|
},
|
||||||
|
/// Call ended
|
||||||
|
CallEnded { call_id: CallId },
|
||||||
|
/// Call timed out due to RTP inactivity (no audio received for extended period)
|
||||||
|
/// rx_count is the total RTP packets received before timeout (0 = never got any audio)
|
||||||
|
CallTimeout { call_id: CallId, rx_count: u64 },
|
||||||
|
/// Outbound call was answered
|
||||||
|
OutboundCallAnswered {
|
||||||
|
tracking_id: String,
|
||||||
|
call_id: CallId,
|
||||||
|
},
|
||||||
|
/// Outbound call failed (rejected, timeout, error)
|
||||||
|
OutboundCallFailed {
|
||||||
|
tracking_id: String,
|
||||||
|
call_id: Option<CallId>,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// Remote sent a T.38 re-INVITE (fax switching from G.711 to T.38 UDPTL)
|
||||||
|
/// The re-INVITE has already been answered synchronously with a 200 OK;
|
||||||
|
/// the pre-bound UDPTL socket is stored in T38_PRESOCKETS.
|
||||||
|
T38Offered {
|
||||||
|
call_id: CallId,
|
||||||
|
/// Remote IP for UDPTL packets
|
||||||
|
remote_ip: String,
|
||||||
|
/// Remote UDPTL port
|
||||||
|
remote_port: u16,
|
||||||
|
/// T.38 version from SDP (typically 0)
|
||||||
|
t38_version: u8,
|
||||||
|
/// Max bit rate from SDP (typically 14400)
|
||||||
|
max_bit_rate: u32,
|
||||||
|
/// Rate management method ("transferredTCF" or "localTCF")
|
||||||
|
rate_management: String,
|
||||||
|
/// UDP error correction ("t38UDPRedundancy" or "t38UDPFEC")
|
||||||
|
udp_ec: String,
|
||||||
|
/// Our local UDPTL port (pre-bound in callback)
|
||||||
|
local_port: u16,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commands that can be sent to the SIP module
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SipCommand {
|
||||||
|
/// Play audio directly to a call (bypasses channel buffer)
|
||||||
|
/// Used for join sounds to avoid buffer overflow with Discord audio
|
||||||
|
PlayDirectToCall { call_id: CallId, samples: Vec<i16> },
|
||||||
|
/// Start a looping audio player for early media (183 Session Progress)
|
||||||
|
StartConnectingLoop { call_id: CallId, samples: Vec<i16> },
|
||||||
|
/// Hangup a call
|
||||||
|
Hangup { call_id: CallId },
|
||||||
|
/// Answer a call with 200 OK (after Discord connects successfully)
|
||||||
|
Answer { call_id: CallId },
|
||||||
|
/// Send 183 Session Progress (establishes early media for connecting sound)
|
||||||
|
Send183 { call_id: CallId },
|
||||||
|
/// Start streaming audio from a file to a call (for large files like easter eggs)
|
||||||
|
/// Uses pull model for precise timing - hangs up automatically when done
|
||||||
|
StartStreaming { call_id: CallId, path: PathBuf },
|
||||||
|
/// Start playing a 440Hz test tone to a call
|
||||||
|
/// Plays until the caller hangs up
|
||||||
|
StartTestTone { call_id: CallId },
|
||||||
|
/// Send 302 redirect to another bridge server
|
||||||
|
/// Must be processed in the PJSUA thread to avoid deadlocking with internal PJSIP state
|
||||||
|
Redirect {
|
||||||
|
call_id: CallId,
|
||||||
|
domain: String,
|
||||||
|
extension: String,
|
||||||
|
},
|
||||||
|
/// Make an outbound call to a SIP URI (for inbound Discord->SIP calls)
|
||||||
|
MakeOutboundCall {
|
||||||
|
tracking_id: String,
|
||||||
|
sip_uri: String,
|
||||||
|
caller_display_name: Option<String>,
|
||||||
|
/// Total number of fork legs for this tracking_id (for multi-contact forking)
|
||||||
|
fork_total: usize,
|
||||||
|
},
|
||||||
|
/// Complete a deferred REGISTER response via a UAS transaction.
|
||||||
|
/// Sent by the async auth handler after API verification.
|
||||||
|
RespondRegister {
|
||||||
|
pending: PendingRegisterTsx,
|
||||||
|
auth_ok: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Active call state (tracked by SIP module before authentication completes)
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CallState;
|
||||||
|
|
||||||
|
/// SIP transport — owns the pjsua event loop and all SIP state.
|
||||||
|
///
|
||||||
|
/// Creates its own event/command channels internally. Use `events()` and `commands()`
|
||||||
|
/// to get handles for communication with the bridge coordinator.
|
||||||
|
pub struct SipTransport {
|
||||||
|
config: SipConfig,
|
||||||
|
tls_config: Option<TlsConfig>,
|
||||||
|
event_tx: Sender<SipEvent>,
|
||||||
|
event_rx: Receiver<SipEvent>,
|
||||||
|
command_tx: Sender<SipCommand>,
|
||||||
|
command_rx: Receiver<SipCommand>,
|
||||||
|
calls: Arc<DashMap<CallId, CallState>>,
|
||||||
|
pjsua_initialized: Arc<RwLock<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SipTransport {
|
||||||
|
pub fn new(config: SipConfig, tls_config: Option<TlsConfig>) -> Self {
|
||||||
|
let (event_tx, event_rx) = bounded(1000);
|
||||||
|
let (command_tx, command_rx) = bounded(1000);
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
tls_config,
|
||||||
|
event_tx,
|
||||||
|
event_rx,
|
||||||
|
command_tx,
|
||||||
|
command_rx,
|
||||||
|
calls: Arc::new(DashMap::new()),
|
||||||
|
pjsua_initialized: Arc::new(RwLock::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a receiver for SIP events (incoming calls, call ended, etc.)
|
||||||
|
pub fn events(&self) -> Receiver<SipEvent> {
|
||||||
|
self.event_rx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a sender for SIP commands (hangup, answer, send audio, etc.)
|
||||||
|
pub fn commands(&self) -> Sender<SipCommand> {
|
||||||
|
self.command_tx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a sender for SIP events (used to inject outbound call events)
|
||||||
|
pub fn event_sender(&self) -> Sender<SipEvent> {
|
||||||
|
self.event_tx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the SIP transport
|
||||||
|
pub async fn run(&self) -> Result<()> {
|
||||||
|
info!(
|
||||||
|
"Starting SIP server on {}:{}",
|
||||||
|
self.config.public_host, self.config.port
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(ref tls) = self.tls_config {
|
||||||
|
info!("TLS enabled on port {}", tls.port);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize pjsua in a blocking task since it's not async-safe
|
||||||
|
let config = self.config.clone();
|
||||||
|
let tls_config = self.tls_config.clone();
|
||||||
|
let calls = self.calls.clone();
|
||||||
|
let event_tx = self.event_tx.clone();
|
||||||
|
let initialized = self.pjsua_initialized.clone();
|
||||||
|
let command_rx = self.command_rx.clone();
|
||||||
|
|
||||||
|
// Spawn pjsua event loop in a blocking thread
|
||||||
|
// IMPORTANT: All PJSUA calls must be made from this thread to avoid deadlocks
|
||||||
|
let pjsua_handle = tokio::task::spawn_blocking(move || {
|
||||||
|
if let Err(e) =
|
||||||
|
run_pjsua_loop(config, tls_config, calls, event_tx, initialized, command_rx)
|
||||||
|
{
|
||||||
|
error!("pjsua loop error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pjsua_handle.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the pjsua event loop (blocking)
|
||||||
|
///
|
||||||
|
/// IMPORTANT: All PJSUA calls (answer, hangup, etc.) must be made from this thread
|
||||||
|
/// to avoid deadlocks with PJSIP's internal worker threads.
|
||||||
|
fn run_pjsua_loop(
|
||||||
|
config: SipConfig,
|
||||||
|
tls_config: Option<TlsConfig>,
|
||||||
|
calls: Arc<DashMap<CallId, CallState>>,
|
||||||
|
event_tx: Sender<SipEvent>,
|
||||||
|
initialized: Arc<RwLock<bool>>,
|
||||||
|
command_rx: Receiver<SipCommand>,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Initialize pjsua with optional TLS
|
||||||
|
init_pjsua(&config, tls_config.as_ref())?;
|
||||||
|
*initialized.write() = true;
|
||||||
|
|
||||||
|
// Register this thread with PJLIB so we can safely call PJSUA functions.
|
||||||
|
// This is required because tokio::task::spawn_blocking creates a new thread
|
||||||
|
// that isn't automatically registered with PJLIB.
|
||||||
|
if !register_thread_with_pjlib("pjsua_event_loop") {
|
||||||
|
tracing::warn!("Failed to register event loop thread with PJLIB");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Audio thread is started on-demand when first call becomes active
|
||||||
|
// (see on_call_media_state_cb in callbacks.rs)
|
||||||
|
|
||||||
|
info!("pjsua initialized, waiting for calls...");
|
||||||
|
|
||||||
|
// Set up timeout event sender for RTP inactivity detection
|
||||||
|
set_timeout_event_sender(event_tx.clone());
|
||||||
|
|
||||||
|
// Set up callbacks
|
||||||
|
set_callbacks(CallbackHandlers {
|
||||||
|
on_incoming_call: Box::new({
|
||||||
|
let calls = calls.clone();
|
||||||
|
move |call_id, sip_username, extension, source_ip| {
|
||||||
|
debug!(
|
||||||
|
"Incoming call {} from {} to extension {} (IP: {:?})",
|
||||||
|
call_id, sip_username, extension, source_ip
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track call (actual state is managed via events after authentication)
|
||||||
|
calls.insert(call_id, CallState);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
on_call_authenticated: Box::new({
|
||||||
|
let event_tx = event_tx.clone();
|
||||||
|
move |call_id, digest_auth, extension, source_ip| {
|
||||||
|
info!(
|
||||||
|
"Call {} authenticated: user={}",
|
||||||
|
call_id, digest_auth.username
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = event_tx.send(SipEvent::IncomingCall {
|
||||||
|
call_id,
|
||||||
|
digest_auth: Box::new(digest_auth),
|
||||||
|
extension,
|
||||||
|
source_ip,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
on_dtmf: Box::new({
|
||||||
|
move |call_id, digit| {
|
||||||
|
debug!(
|
||||||
|
"DTMF {} on call {} (ignored - using dialed number)",
|
||||||
|
digit, call_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
on_call_ended: Box::new({
|
||||||
|
let calls = calls.clone();
|
||||||
|
let event_tx = event_tx.clone();
|
||||||
|
move |call_id| {
|
||||||
|
calls.remove(&call_id);
|
||||||
|
let _ = event_tx.send(SipEvent::CallEnded { call_id });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
on_audio_frame: Box::new({
|
||||||
|
move |channel_id, samples, sample_rate| {
|
||||||
|
// DIRECT PATH: Send audio directly to Discord, bypassing tokio entirely.
|
||||||
|
// This is called from the pjsua audio thread and sends directly to the
|
||||||
|
// crossbeam channel that feeds Songbird's StreamingAudioSource.
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
static DIRECT_AUDIO_COUNT: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let count = DIRECT_AUDIO_COUNT.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
|
let sent = send_audio_to_discord_direct(channel_id, samples, sample_rate);
|
||||||
|
|
||||||
|
if !sent && count.is_multiple_of(250) {
|
||||||
|
// No sender registered for this channel - bridge might not be ready yet
|
||||||
|
trace!(
|
||||||
|
"No Discord sender for channel {} (direct audio dropped, count={})",
|
||||||
|
channel_id,
|
||||||
|
count
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run pjsua event loop
|
||||||
|
let mut loop_count: u64 = 0;
|
||||||
|
loop {
|
||||||
|
// Process any pending SIP commands (non-blocking)
|
||||||
|
// These must be processed in the PJSUA thread to avoid deadlocks
|
||||||
|
while let Ok(cmd) = command_rx.try_recv() {
|
||||||
|
process_sip_command(cmd, &calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sleep briefly to allow PJSIP worker threads to process events
|
||||||
|
// Note: PJSIP has its own internal worker threads that handle the ioqueue
|
||||||
|
process_pjsua_events(10)?;
|
||||||
|
|
||||||
|
loop_count += 1;
|
||||||
|
|
||||||
|
// Every ~5 seconds (500 iterations at 10ms each), check for RTP inactivity
|
||||||
|
// This must be done from the PJSUA thread, not the audio thread
|
||||||
|
if loop_count.is_multiple_of(500) {
|
||||||
|
check_rtp_inactivity();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every ~30 seconds (3000 iterations at 10ms each), validate COUNTED_CALL_IDS
|
||||||
|
// This catches stale calls that weren't properly cleaned up by on_call_state_cb
|
||||||
|
if loop_count.is_multiple_of(3000) {
|
||||||
|
validate_counted_calls();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every ~60 seconds (6000 iterations at 10ms each), scan ALL pjsua call slots
|
||||||
|
// for zombie calls that are stuck (rejected calls where the SIP transaction
|
||||||
|
// never completed, or calls where handle_incoming_call panicked/hung)
|
||||||
|
if loop_count.is_multiple_of(6000) {
|
||||||
|
cleanup_zombie_pjsua_calls();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process a SIP command in the PJSUA thread
|
||||||
|
///
|
||||||
|
/// This must be called from the PJSUA event loop thread to avoid deadlocks.
|
||||||
|
fn process_sip_command(cmd: SipCommand, calls: &Arc<DashMap<CallId, CallState>>) {
|
||||||
|
match cmd {
|
||||||
|
SipCommand::PlayDirectToCall { call_id, samples } => {
|
||||||
|
// Play audio directly to a call (bypasses channel buffer)
|
||||||
|
if let Err(e) = play_audio_to_call_direct(call_id, &samples) {
|
||||||
|
tracing::error!("Failed to play direct audio to call {}: {}", call_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SipCommand::StartConnectingLoop { call_id, samples } => {
|
||||||
|
// Queue to audio thread to avoid race with pjmedia_port_get_frame
|
||||||
|
queue_pjsua_op(PendingPjsuaOp::StartLoop { call_id, samples });
|
||||||
|
}
|
||||||
|
SipCommand::Hangup { call_id } => {
|
||||||
|
// Stop any looping audio first
|
||||||
|
stop_loop(call_id);
|
||||||
|
// Always try to hangup - PJSUA will handle if call doesn't exist
|
||||||
|
// Remove from our tracking if present
|
||||||
|
calls.remove(&call_id);
|
||||||
|
hangup_call(call_id);
|
||||||
|
}
|
||||||
|
SipCommand::Answer { call_id } => {
|
||||||
|
answer_call(call_id);
|
||||||
|
}
|
||||||
|
SipCommand::Send183 { call_id } => {
|
||||||
|
send_183_session_progress(call_id);
|
||||||
|
}
|
||||||
|
SipCommand::StartStreaming { call_id, path } => {
|
||||||
|
// Queue streaming to audio thread (handles timing and hangup detection)
|
||||||
|
queue_pjsua_op(PendingPjsuaOp::StartStreaming {
|
||||||
|
call_id,
|
||||||
|
path,
|
||||||
|
hangup_on_complete: true, // Easter egg calls hangup when done
|
||||||
|
});
|
||||||
|
}
|
||||||
|
SipCommand::StartTestTone { call_id } => {
|
||||||
|
// Queue test tone to audio thread
|
||||||
|
queue_pjsua_op(PendingPjsuaOp::StartTestTone { call_id });
|
||||||
|
}
|
||||||
|
SipCommand::Redirect {
|
||||||
|
call_id,
|
||||||
|
domain,
|
||||||
|
extension,
|
||||||
|
} => {
|
||||||
|
// Stop any connecting loop first
|
||||||
|
stop_loop(call_id);
|
||||||
|
// Send 302 from the PJSUA thread (safe - no deadlock with PJSIP internals)
|
||||||
|
unsafe {
|
||||||
|
callbacks::send_302_redirect(call_id, &domain, &extension);
|
||||||
|
}
|
||||||
|
calls.remove(&call_id);
|
||||||
|
}
|
||||||
|
SipCommand::MakeOutboundCall {
|
||||||
|
tracking_id,
|
||||||
|
sip_uri,
|
||||||
|
caller_display_name,
|
||||||
|
fork_total,
|
||||||
|
} => {
|
||||||
|
info!(
|
||||||
|
"Making outbound call: tracking_id={}, uri={}, caller={:?}, fork={}/{}",
|
||||||
|
tracking_id, sip_uri, caller_display_name, fork_total, fork_total
|
||||||
|
);
|
||||||
|
match make_outbound_call(&sip_uri, caller_display_name.as_deref()) {
|
||||||
|
Ok(call_id) => {
|
||||||
|
// Store tracking_id -> call_id mapping
|
||||||
|
let outbound_calls = OUTBOUND_CALL_TRACKING.get_or_init(DashMap::new);
|
||||||
|
outbound_calls.insert(call_id, tracking_id.clone());
|
||||||
|
// Register in fork group
|
||||||
|
fork_group::add_member(&tracking_id, call_id, fork_total);
|
||||||
|
info!(
|
||||||
|
"Outbound call started: tracking_id={}, call_id={}",
|
||||||
|
tracking_id, call_id
|
||||||
|
);
|
||||||
|
calls.insert(call_id, CallState);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(
|
||||||
|
"Failed to make outbound call (tracking_id={}): {}",
|
||||||
|
tracking_id, e
|
||||||
|
);
|
||||||
|
// Track the initial failure in fork group
|
||||||
|
fork_group::add_initial_failure(&tracking_id, fork_total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SipCommand::RespondRegister { pending, auth_ok } => {
|
||||||
|
// Complete a deferred REGISTER response. Must run on the pjsua
|
||||||
|
// thread because pjsip_tsx_send_msg is not thread-safe.
|
||||||
|
unsafe {
|
||||||
|
use pjsua::*;
|
||||||
|
use std::os::raw::c_char;
|
||||||
|
|
||||||
|
let tsx = pending.tsx.0;
|
||||||
|
let tdata = pending.tdata.0;
|
||||||
|
|
||||||
|
if tsx.is_null() || tdata.is_null() {
|
||||||
|
tracing::warn!("RespondRegister: null tsx or tdata");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth_ok {
|
||||||
|
// Add Expires header to the pre-built 200 OK
|
||||||
|
let expires_str = format!("{}", pending.expires);
|
||||||
|
let hdr_name = std::ffi::CString::new("Expires").unwrap();
|
||||||
|
let hdr_value = std::ffi::CString::new(expires_str).unwrap();
|
||||||
|
|
||||||
|
let name = pj_str(hdr_name.as_ptr() as *mut c_char);
|
||||||
|
let value = pj_str(hdr_value.as_ptr() as *mut c_char);
|
||||||
|
let hdr = pjsip_generic_string_hdr_create((*tdata).pool, &name, &value);
|
||||||
|
if !hdr.is_null() {
|
||||||
|
pj_list_insert_before(
|
||||||
|
&mut (*(*tdata).msg).hdr as *mut pjsip_hdr as *mut pj_list_type,
|
||||||
|
hdr as *mut pj_list_type,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Rewrite the pre-built 200 to a 403 Forbidden
|
||||||
|
(*(*tdata).msg).line.status.code = 403;
|
||||||
|
let reason = b"Forbidden\0";
|
||||||
|
let ptr = pj_pool_alloc((*tdata).pool, reason.len()) as *mut u8;
|
||||||
|
std::ptr::copy_nonoverlapping(reason.as_ptr(), ptr, reason.len());
|
||||||
|
(*(*tdata).msg).line.status.reason.ptr = ptr as *mut c_char;
|
||||||
|
(*(*tdata).msg).line.status.reason.slen =
|
||||||
|
(reason.len() - 1) as std::os::raw::c_long;
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = pjsip_tsx_send_msg(tsx, tdata);
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to send deferred REGISTER response ({}): {}",
|
||||||
|
if auth_ok { 200 } else { 403 },
|
||||||
|
status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracking map for outbound calls: pjsua call_id -> tracking_id
|
||||||
|
static OUTBOUND_CALL_TRACKING: std::sync::OnceLock<DashMap<CallId, String>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
/// Get the tracking ID for an outbound call (if any)
|
||||||
|
pub fn get_outbound_tracking_id(call_id: CallId) -> Option<String> {
|
||||||
|
OUTBOUND_CALL_TRACKING
|
||||||
|
.get()
|
||||||
|
.and_then(|m| m.get(&call_id).map(|v| v.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove and return the tracking ID for an outbound call
|
||||||
|
pub fn remove_outbound_tracking(call_id: CallId) -> Option<String> {
|
||||||
|
OUTBOUND_CALL_TRACKING
|
||||||
|
.get()
|
||||||
|
.and_then(|m| m.remove(&call_id).map(|(_, v)| v))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make an outbound SIP call using pjsua
|
||||||
|
///
|
||||||
|
/// If `caller_display_name` is provided, it sets the From header display name
|
||||||
|
/// to show who initiated the call from Discord (e.g., "Discord: username").
|
||||||
|
fn make_outbound_call(sip_uri: &str, caller_display_name: Option<&str>) -> Result<CallId, String> {
|
||||||
|
unsafe {
|
||||||
|
let uri = std::ffi::CString::new(sip_uri).map_err(|e| e.to_string())?;
|
||||||
|
let mut call_id: ::pjsua::pjsua_call_id = -1;
|
||||||
|
|
||||||
|
// Explicit call settings: audio only, no video, no T.140 text.
|
||||||
|
// The default txt_cnt=1 adds an m=text stream to the SDP, bloating
|
||||||
|
// the INVITE beyond the ~1300-byte UDP fragmentation threshold.
|
||||||
|
let mut opt = std::mem::MaybeUninit::<::pjsua::pjsua_call_setting>::uninit();
|
||||||
|
::pjsua::pjsua_call_setting_default(opt.as_mut_ptr());
|
||||||
|
let opt_ptr = opt.assume_init_mut();
|
||||||
|
opt_ptr.aud_cnt = 1;
|
||||||
|
opt_ptr.vid_cnt = 0;
|
||||||
|
opt_ptr.txt_cnt = 0;
|
||||||
|
|
||||||
|
// Set up msg_data with custom From header if caller display name provided
|
||||||
|
let mut msg_data = std::mem::MaybeUninit::<::pjsua::pjsua_msg_data>::uninit();
|
||||||
|
::pjsua::pjsua_msg_data_init(msg_data.as_mut_ptr());
|
||||||
|
let msg_data_ptr = msg_data.assume_init_mut();
|
||||||
|
|
||||||
|
// Build the From URI with display name: "name" <sip:sipcord@host>
|
||||||
|
// The local_uri field overrides the From header in the outgoing INVITE
|
||||||
|
let from_uri_cstring;
|
||||||
|
if let Some(name) = caller_display_name {
|
||||||
|
// Get the account's SIP URI to use as the address part
|
||||||
|
let mut acc_info = std::mem::MaybeUninit::<::pjsua::pjsua_acc_info>::uninit();
|
||||||
|
let acc_uri = if ::pjsua::pjsua_acc_get_info(0, acc_info.as_mut_ptr())
|
||||||
|
== ::pjsua::pj_constants__PJ_SUCCESS as i32
|
||||||
|
{
|
||||||
|
let ai = acc_info.assume_init();
|
||||||
|
let uri_str = std::ffi::CStr::from_ptr(ai.acc_uri.ptr)
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
uri_str
|
||||||
|
} else {
|
||||||
|
"sip:sipcord@localhost".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sanitize display name: whitelist printable ASCII, strip control chars
|
||||||
|
// and characters that could break SIP header parsing or enable injection
|
||||||
|
let sanitized: String = name
|
||||||
|
.chars()
|
||||||
|
.filter(|c| *c >= ' ' && *c != '"' && *c != '<' && *c != '>' && *c != '\\')
|
||||||
|
.take(64)
|
||||||
|
.collect();
|
||||||
|
let from_uri = format!("\"{}\" <{}>", sanitized, acc_uri);
|
||||||
|
from_uri_cstring = std::ffi::CString::new(from_uri).map_err(|e| e.to_string())?;
|
||||||
|
msg_data_ptr.local_uri =
|
||||||
|
::pjsua::pj_str(from_uri_cstring.as_ptr() as *mut std::os::raw::c_char);
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = ::pjsua::pjsua_call_make_call(
|
||||||
|
0, // default account
|
||||||
|
&::pjsua::pj_str(uri.as_ptr() as *mut std::os::raw::c_char),
|
||||||
|
opt_ptr, // call settings (no text stream)
|
||||||
|
std::ptr::null_mut(), // user data
|
||||||
|
msg_data_ptr, // msg_data with custom From header
|
||||||
|
&mut call_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if status != ::pjsua::pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
return Err(format!("pjsua_call_make_call failed: {}", status));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CallId::new(call_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
909
sipcord-bridge/src/transport/sip/nat.rs
Normal file
909
sipcord-bridge/src/transport/sip/nat.rs
Normal file
|
|
@ -0,0 +1,909 @@
|
||||||
|
//! NAT-related SIP rewriting for Contact headers and SDP bodies
|
||||||
|
//!
|
||||||
|
//! This module consolidates all NAT rewriting logic:
|
||||||
|
//! - Local-network rewriting (tx path): rewrites Contact + SDP in outgoing
|
||||||
|
//! requests/responses so that local-network clients use the local IP
|
||||||
|
//! - Far-end NAT fixup (rx path): rewrites private IPs in incoming responses
|
||||||
|
//! to the actual public source IP
|
||||||
|
|
||||||
|
use super::ffi::types::*;
|
||||||
|
use super::ffi::utils::pj_str_to_string;
|
||||||
|
use pjsua::*;
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
use std::os::raw::c_char;
|
||||||
|
use std::ptr;
|
||||||
|
|
||||||
|
// Private helpers
|
||||||
|
|
||||||
|
/// Remove dynamic payload types (96+) from `m=` lines when they lack a corresponding
|
||||||
|
/// `a=rtpmap:<PT>` attribute in that media section. This prevents PJSIP's SDP validator
|
||||||
|
/// from rejecting the SDP with PJMEDIA_SDP_EMISSINGRTPMAP.
|
||||||
|
///
|
||||||
|
/// Returns `Some(sanitized)` if any orphan dynamic PTs were stripped, `None` if no changes.
|
||||||
|
fn sanitize_sdp_missing_rtpmap(sdp: &str) -> Option<String> {
|
||||||
|
// Split SDP into lines, grouping by media sections.
|
||||||
|
// Session-level lines come before the first m= line.
|
||||||
|
// Each m= line starts a new media section that includes all following a=/b=/c= lines
|
||||||
|
// until the next m= line or end of SDP.
|
||||||
|
|
||||||
|
let lines: Vec<&str> = sdp.lines().collect();
|
||||||
|
let mut result_lines: Vec<String> = Vec::with_capacity(lines.len());
|
||||||
|
let mut changed = false;
|
||||||
|
|
||||||
|
// Find media section boundaries (indices of m= lines)
|
||||||
|
let mut section_starts: Vec<usize> = Vec::new();
|
||||||
|
for (i, line) in lines.iter().enumerate() {
|
||||||
|
if line.starts_with("m=") {
|
||||||
|
section_starts.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session-level lines (before first m= line) pass through unchanged
|
||||||
|
let first_m = section_starts.first().copied().unwrap_or(lines.len());
|
||||||
|
for line in &lines[..first_m] {
|
||||||
|
result_lines.push(line.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each media section
|
||||||
|
for (sec_idx, &start) in section_starts.iter().enumerate() {
|
||||||
|
let end = section_starts
|
||||||
|
.get(sec_idx + 1)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(lines.len());
|
||||||
|
|
||||||
|
let m_line = lines[start];
|
||||||
|
let section_lines = &lines[start + 1..end];
|
||||||
|
|
||||||
|
// Parse m= line: m=<media> <port> <transport> <fmt1> <fmt2> ...
|
||||||
|
let parts: Vec<&str> = m_line.split_whitespace().collect();
|
||||||
|
if parts.len() < 4 {
|
||||||
|
// Malformed m= line, pass through
|
||||||
|
for line in &lines[start..end] {
|
||||||
|
result_lines.push(line.to_string());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let transport = parts[2];
|
||||||
|
|
||||||
|
// Only sanitize RTP-based transports (not UDPTL for T.38, etc.)
|
||||||
|
if !transport.starts_with("RTP/") {
|
||||||
|
for line in &lines[start..end] {
|
||||||
|
result_lines.push(line.to_string());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect rtpmap PTs declared in this section
|
||||||
|
let mut rtpmap_pts: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||||
|
for line in section_lines {
|
||||||
|
// a=rtpmap:96 opus/48000/2
|
||||||
|
if let Some(rest) = line.strip_prefix("a=rtpmap:") {
|
||||||
|
if let Some(pt_str) = rest.split_whitespace().next() {
|
||||||
|
if let Ok(pt) = pt_str.parse::<u32>() {
|
||||||
|
rtpmap_pts.insert(pt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check which PTs in the m= line need stripping
|
||||||
|
let payload_types = &parts[3..];
|
||||||
|
let mut kept_pts: Vec<&str> = Vec::new();
|
||||||
|
let mut stripped_pts: Vec<u32> = Vec::new();
|
||||||
|
|
||||||
|
for pt_str in payload_types {
|
||||||
|
if let Ok(pt) = pt_str.parse::<u32>() {
|
||||||
|
if pt >= 96 && !rtpmap_pts.contains(&pt) {
|
||||||
|
stripped_pts.push(pt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kept_pts.push(pt_str);
|
||||||
|
}
|
||||||
|
|
||||||
|
if stripped_pts.is_empty() {
|
||||||
|
// No changes needed for this section
|
||||||
|
for line in &lines[start..end] {
|
||||||
|
result_lines.push(line.to_string());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If stripping all PTs would leave none, leave the m= line unchanged
|
||||||
|
if kept_pts.is_empty() {
|
||||||
|
for line in &lines[start..end] {
|
||||||
|
result_lines.push(line.to_string());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = true;
|
||||||
|
|
||||||
|
// Rebuild m= line with kept PTs only
|
||||||
|
let new_m_line = format!(
|
||||||
|
"{} {} {} {}",
|
||||||
|
parts[0],
|
||||||
|
parts[1],
|
||||||
|
parts[2],
|
||||||
|
kept_pts.join(" ")
|
||||||
|
);
|
||||||
|
result_lines.push(new_m_line);
|
||||||
|
|
||||||
|
// Copy section attribute lines, stripping a=fmtp: for removed PTs
|
||||||
|
let stripped_set: std::collections::HashSet<u32> = stripped_pts.into_iter().collect();
|
||||||
|
for line in section_lines {
|
||||||
|
if let Some(rest) = line.strip_prefix("a=fmtp:") {
|
||||||
|
if let Some(pt_str) = rest.split_whitespace().next() {
|
||||||
|
if let Ok(pt) = pt_str.parse::<u32>() {
|
||||||
|
if stripped_set.contains(&pt) {
|
||||||
|
continue; // skip fmtp for stripped PT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result_lines.push(line.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
Some(result_lines.join("\r\n") + "\r\n")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an IPv4 address is in RFC 1918 private space
|
||||||
|
fn is_rfc1918(ip: Ipv4Addr) -> bool {
|
||||||
|
let o = ip.octets();
|
||||||
|
(o[0] == 10) || (o[0] == 172 && (16..=31).contains(&o[1])) || (o[0] == 192 && o[1] == 168)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the destination IPv4 address from `pjsip_tx_data` transport info.
|
||||||
|
///
|
||||||
|
/// Returns `None` if transport info is invalid or the address is not IPv4.
|
||||||
|
unsafe fn extract_dst_ipv4(tdata: *const pjsip_tx_data) -> Option<Ipv4Addr> {
|
||||||
|
if (*tdata).tp_info.transport.is_null() || (*tdata).tp_info.dst_addr_len <= 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dst_addr = &(*tdata).tp_info.dst_addr;
|
||||||
|
// PJ_AF_INET is typically 2 (same as AF_INET on most systems)
|
||||||
|
if dst_addr.addr.sa_family == 2 {
|
||||||
|
let addr_in = &dst_addr.ipv4;
|
||||||
|
let ip_bytes = addr_in.sin_addr.s_addr.to_ne_bytes();
|
||||||
|
Some(Ipv4Addr::new(
|
||||||
|
ip_bytes[0],
|
||||||
|
ip_bytes[1],
|
||||||
|
ip_bytes[2],
|
||||||
|
ip_bytes[3],
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite the Contact header's host and port via pool allocation.
|
||||||
|
///
|
||||||
|
/// Uses vtable-based URI unwrapping (`p_get_uri`) to safely handle both
|
||||||
|
/// bare `pjsip_sip_uri` and `pjsip_name_addr`-wrapped URIs.
|
||||||
|
/// Returns `true` if the rewrite succeeded.
|
||||||
|
unsafe fn rewrite_contact_host(
|
||||||
|
pool: *mut pj_pool_t,
|
||||||
|
msg: *mut pjsip_msg,
|
||||||
|
new_host: &str,
|
||||||
|
new_port: u16,
|
||||||
|
) -> bool {
|
||||||
|
let contact_hdr = pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_CONTACT, ptr::null_mut())
|
||||||
|
as *mut pjsip_contact_hdr;
|
||||||
|
if contact_hdr.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uri = (*contact_hdr).uri;
|
||||||
|
if uri.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap via vtable to handle pjsip_name_addr wrapping
|
||||||
|
let uri_vptr = (*(uri as *const pjsip_uri)).vptr;
|
||||||
|
if uri_vptr.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let get_uri_fn = match (*uri_vptr).p_get_uri {
|
||||||
|
Some(f) => f,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
let sip_uri_raw = get_uri_fn(uri as *mut std::os::raw::c_void);
|
||||||
|
if sip_uri_raw.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let sip_uri = sip_uri_raw as *mut pjsip_sip_uri;
|
||||||
|
if (*sip_uri).host.ptr.is_null() || (*sip_uri).host.slen <= 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(host_cstr) = CString::new(new_host) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let host_len = new_host.len();
|
||||||
|
let pool_str = pj_pool_alloc(pool, host_len + 1) as *mut c_char;
|
||||||
|
if pool_str.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ptr::copy_nonoverlapping(host_cstr.as_ptr(), pool_str, host_len + 1);
|
||||||
|
(*sip_uri).host.ptr = pool_str;
|
||||||
|
(*sip_uri).host.slen = host_len as i64;
|
||||||
|
(*sip_uri).port = new_port as i32;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace `old_ip` with `new_ip` inside the SDP body of `msg`, allocating
|
||||||
|
/// the replacement string from `pool`. Only rewrites `c=` (connection) and
|
||||||
|
/// `o=` (origin) lines to avoid corrupting attribute values that may
|
||||||
|
/// coincidentally contain the same IP string. Returns `true` if a
|
||||||
|
/// substitution was made.
|
||||||
|
unsafe fn rewrite_sdp_body(
|
||||||
|
pool: *mut pj_pool_t,
|
||||||
|
msg: *mut pjsip_msg,
|
||||||
|
old_ip: &str,
|
||||||
|
new_ip: &str,
|
||||||
|
) -> bool {
|
||||||
|
let body = (*msg).body;
|
||||||
|
if body.is_null() || (*body).len == 0 || (*body).data.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body_slice = std::slice::from_raw_parts((*body).data as *const u8, (*body).len as usize);
|
||||||
|
let Ok(sdp_str) = std::str::from_utf8(body_slice) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Line-by-line replacement: only rewrite c= and o= lines
|
||||||
|
let mut changed = false;
|
||||||
|
let new_sdp: String = sdp_str
|
||||||
|
.lines()
|
||||||
|
.map(|line| {
|
||||||
|
if (line.starts_with("c=") || line.starts_with("o=")) && line.contains(old_ip) {
|
||||||
|
changed = true;
|
||||||
|
line.replace(old_ip, new_ip)
|
||||||
|
} else {
|
||||||
|
line.to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\r\n");
|
||||||
|
|
||||||
|
// Preserve trailing CRLF
|
||||||
|
let new_sdp = new_sdp + "\r\n";
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_len = new_sdp.len();
|
||||||
|
let new_body_ptr = pj_pool_alloc(pool, new_len) as *mut u8;
|
||||||
|
if new_body_ptr.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ptr::copy_nonoverlapping(new_sdp.as_ptr(), new_body_ptr, new_len);
|
||||||
|
(*body).data = new_body_ptr as *mut _;
|
||||||
|
(*body).len = new_len as u32;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified local-network rewriting for outgoing tx data.
|
||||||
|
///
|
||||||
|
/// Checks `LOCAL_NET_CONFIG`, verifies the destination is in the configured
|
||||||
|
/// CIDR, and rewrites the Contact header + SDP body.
|
||||||
|
///
|
||||||
|
/// `direction` is used only for log messages ("request" or "response").
|
||||||
|
unsafe fn rewrite_local_network_tdata(tdata: *mut pjsip_tx_data, direction: &str) -> bool {
|
||||||
|
let Some(Some((local_host, local_cidr, port, rtp_public_ip))) = LOCAL_NET_CONFIG.get() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if tdata.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(dst_ip) = extract_dst_ipv4(tdata) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !local_cidr.contains(&dst_ip) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = (*tdata).msg;
|
||||||
|
if msg.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut changed = false;
|
||||||
|
|
||||||
|
// Rewrite Contact header
|
||||||
|
if rewrite_contact_host((*tdata).pool, msg, local_host, *port) {
|
||||||
|
tracing::debug!(
|
||||||
|
"Rewrote {} Contact header for local client {}: host -> {}:{}",
|
||||||
|
direction,
|
||||||
|
dst_ip,
|
||||||
|
local_host,
|
||||||
|
port
|
||||||
|
);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite SDP body if we have an RTP public IP to replace
|
||||||
|
if let Some(public_ip) = rtp_public_ip {
|
||||||
|
if rewrite_sdp_body((*tdata).pool, msg, public_ip, local_host) {
|
||||||
|
tracing::debug!(
|
||||||
|
"Rewrote {} SDP for local client {}: {} -> {}",
|
||||||
|
direction,
|
||||||
|
dst_ip,
|
||||||
|
public_ip,
|
||||||
|
local_host
|
||||||
|
);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite private IPs in Contact headers for external (non-local) clients.
|
||||||
|
///
|
||||||
|
/// pjsua derives the Contact URI from the TCP/UDP connection's local address,
|
||||||
|
/// which is the bridge's private IP (e.g. 10.0.1.7) when running behind NAT.
|
||||||
|
/// External clients need the public hostname (e.g. bridge-usw1.sipcord.net) so
|
||||||
|
/// they can route in-dialog requests like BYE back to us. Without this fix,
|
||||||
|
/// phones that try to send BYE to the private IP will silently fail.
|
||||||
|
unsafe fn rewrite_private_contact_for_external(tdata: *mut pjsip_tx_data, direction: &str) -> bool {
|
||||||
|
let Some(Some((public_host, port))) = PUBLIC_HOST_CONFIG.get() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if tdata.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = (*tdata).msg;
|
||||||
|
if msg.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find Contact header
|
||||||
|
let contact_hdr = pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_CONTACT, ptr::null_mut())
|
||||||
|
as *mut pjsip_contact_hdr;
|
||||||
|
if contact_hdr.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uri = (*contact_hdr).uri;
|
||||||
|
if uri.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap via vtable to handle pjsip_name_addr wrapping
|
||||||
|
let uri_vptr = (*(uri as *const pjsip_uri)).vptr;
|
||||||
|
if uri_vptr.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let get_uri_fn = match (*uri_vptr).p_get_uri {
|
||||||
|
Some(f) => f,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
let sip_uri_raw = get_uri_fn(uri as *mut std::os::raw::c_void);
|
||||||
|
if sip_uri_raw.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let sip_uri = sip_uri_raw as *mut pjsip_sip_uri;
|
||||||
|
if (*sip_uri).host.ptr.is_null() || (*sip_uri).host.slen <= 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = pj_str_to_string(&(*sip_uri).host);
|
||||||
|
|
||||||
|
// Only rewrite if Contact host is a private (RFC 1918) IP
|
||||||
|
let contact_ip: Ipv4Addr = match host.parse() {
|
||||||
|
Ok(ip) => ip,
|
||||||
|
Err(_) => return false, // Already a hostname, no rewrite needed
|
||||||
|
};
|
||||||
|
|
||||||
|
if !is_rfc1918(contact_ip) {
|
||||||
|
return false; // Public IP, no rewrite needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if destination is also private (local-network rewrite handles that)
|
||||||
|
if let Some(dst_ip) = extract_dst_ipv4(tdata) {
|
||||||
|
if is_rfc1918(dst_ip) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite Contact to public host
|
||||||
|
if rewrite_contact_host((*tdata).pool, msg, public_host, *port) {
|
||||||
|
tracing::debug!(
|
||||||
|
"Rewrote {} Contact for external client: {} -> {}:{}",
|
||||||
|
direction,
|
||||||
|
host,
|
||||||
|
public_host,
|
||||||
|
port
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public callbacks
|
||||||
|
|
||||||
|
/// Callback to rewrite Contact header and SDP body in outgoing responses.
|
||||||
|
///
|
||||||
|
/// Two rewrites are applied in order:
|
||||||
|
/// 1. Local-network rewrite: for clients on the local CIDR, use the local IP
|
||||||
|
/// 2. Public-host rewrite: for external clients, replace private Contact IPs
|
||||||
|
/// with the public hostname so they can route BYE back to us
|
||||||
|
pub unsafe extern "C" fn on_tx_response_cb(tdata: *mut pjsip_tx_data) -> pj_status_t {
|
||||||
|
let local_rewrite = rewrite_local_network_tdata(tdata, "response");
|
||||||
|
let public_rewrite = rewrite_private_contact_for_external(tdata, "response");
|
||||||
|
|
||||||
|
// If we modified headers, the buffer was already serialized by mod-msg-print
|
||||||
|
// (priority 8, before our module at priority 32). Invalidate and re-encode
|
||||||
|
// so the changes actually reach the wire.
|
||||||
|
if local_rewrite || public_rewrite {
|
||||||
|
pjsip_tx_data_invalidate_msg(tdata);
|
||||||
|
pjsip_tx_data_encode(tdata);
|
||||||
|
}
|
||||||
|
|
||||||
|
pj_constants__PJ_SUCCESS as pj_status_t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callback to rewrite Contact header and SDP body in outgoing requests.
|
||||||
|
/// Same dual-rewrite logic as the response path.
|
||||||
|
pub unsafe extern "C" fn on_tx_request_cb(tdata: *mut pjsip_tx_data) -> pj_status_t {
|
||||||
|
let local_rewrite = rewrite_local_network_tdata(tdata, "request");
|
||||||
|
let public_rewrite = rewrite_private_contact_for_external(tdata, "request");
|
||||||
|
|
||||||
|
// If we modified headers, the buffer was already serialized by mod-msg-print
|
||||||
|
// (priority 8, before our module at priority 32). Invalidate and re-encode
|
||||||
|
// so the changes actually reach the wire.
|
||||||
|
if local_rewrite || public_rewrite {
|
||||||
|
pjsip_tx_data_invalidate_msg(tdata);
|
||||||
|
pjsip_tx_data_encode(tdata);
|
||||||
|
}
|
||||||
|
|
||||||
|
pj_constants__PJ_SUCCESS as pj_status_t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callback to fix far-end NAT traversal in incoming SIP requests (INVITEs).
|
||||||
|
///
|
||||||
|
/// When a phone behind NAT sends an INVITE, its SDP body contains private IPs:
|
||||||
|
/// - SDP `c=IN IP4 192.168.x.x` -> We'd send RTP to an unreachable private IP
|
||||||
|
///
|
||||||
|
/// This callback detects the NAT condition (private SDP IP != packet source IP)
|
||||||
|
/// and rewrites the SDP before PJSIP's invite/dialog layer processes it,
|
||||||
|
/// so the media session uses the correct public address.
|
||||||
|
pub unsafe extern "C" fn on_rx_request_nat_fixup_cb(rdata: *mut pjsip_rx_data) -> pj_bool_t {
|
||||||
|
if rdata.is_null() {
|
||||||
|
return pj_constants__PJ_FALSE as pj_bool_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = (*rdata).msg_info.msg;
|
||||||
|
if msg.is_null() {
|
||||||
|
return pj_constants__PJ_FALSE as pj_bool_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process requests (safety check)
|
||||||
|
if (*msg).type_ != pjsip_msg_type_e_PJSIP_REQUEST_MSG {
|
||||||
|
return pj_constants__PJ_FALSE as pj_bool_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process INVITE and re-INVITE (they carry SDP with media addresses)
|
||||||
|
let method_id = (*msg).line.req.method.id;
|
||||||
|
if method_id != pjsip_method_e_PJSIP_INVITE_METHOD {
|
||||||
|
return pj_constants__PJ_FALSE as pj_bool_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's a body (SDP)
|
||||||
|
let body = (*msg).body;
|
||||||
|
if body.is_null() || (*body).len == 0 || (*body).data.is_null() {
|
||||||
|
return pj_constants__PJ_FALSE as pj_bool_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract source IP from packet info
|
||||||
|
let src_name = &(*rdata).pkt_info.src_name;
|
||||||
|
let name_len = src_name
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == 0)
|
||||||
|
.unwrap_or(src_name.len());
|
||||||
|
let src_ip_str = match std::str::from_utf8(std::slice::from_raw_parts(
|
||||||
|
src_name.as_ptr() as *const u8,
|
||||||
|
name_len,
|
||||||
|
)) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return pj_constants__PJ_FALSE as pj_bool_t,
|
||||||
|
};
|
||||||
|
let src_ip: Ipv4Addr = match src_ip_str.parse() {
|
||||||
|
Ok(ip) => ip,
|
||||||
|
Err(_) => return pj_constants__PJ_FALSE as pj_bool_t,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse SDP to find c= line IP and check if it's a private address
|
||||||
|
let body_slice = std::slice::from_raw_parts((*body).data as *const u8, (*body).len as usize);
|
||||||
|
let sdp_str = match std::str::from_utf8(body_slice) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return pj_constants__PJ_FALSE as pj_bool_t,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find any connection address in the SDP that needs NAT fixup.
|
||||||
|
// Check ALL c= lines (session-level and per-media) — if any contain a
|
||||||
|
// private IP different from the packet source, rewrite the SDP.
|
||||||
|
let mut needs_rewrite = false;
|
||||||
|
let mut private_ip_str: Option<&str> = None;
|
||||||
|
for line in sdp_str.lines() {
|
||||||
|
if let Some(addr_str) = line.strip_prefix("c=IN IP4 ") {
|
||||||
|
let addr_str = addr_str.trim();
|
||||||
|
if let Ok(sdp_ip) = addr_str.parse::<Ipv4Addr>() {
|
||||||
|
if is_rfc1918(sdp_ip) && sdp_ip != src_ip {
|
||||||
|
needs_rewrite = true;
|
||||||
|
private_ip_str = Some(addr_str);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if needs_rewrite {
|
||||||
|
if let Some(private_ip) = private_ip_str {
|
||||||
|
let pool = (*rdata).tp_info.pool;
|
||||||
|
if !pool.is_null() {
|
||||||
|
if rewrite_sdp_body(pool, msg, private_ip, src_ip_str) {
|
||||||
|
tracing::debug!(
|
||||||
|
"NAT fixup (INVITE): SDP rewritten {} -> {} (from {}:{})",
|
||||||
|
private_ip,
|
||||||
|
src_ip_str,
|
||||||
|
src_ip_str,
|
||||||
|
(*rdata).pkt_info.src_port
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also rewrite Contact header if present and has private IP
|
||||||
|
let contact_hdr = pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_CONTACT, ptr::null_mut())
|
||||||
|
as *mut pjsip_contact_hdr;
|
||||||
|
if !contact_hdr.is_null() {
|
||||||
|
let uri = (*contact_hdr).uri;
|
||||||
|
if !uri.is_null() {
|
||||||
|
let uri_vptr = (*(uri as *const pjsip_uri)).vptr;
|
||||||
|
if !uri_vptr.is_null() {
|
||||||
|
if let Some(get_uri_fn) = (*uri_vptr).p_get_uri {
|
||||||
|
let sip_uri_raw = get_uri_fn(uri as *mut std::os::raw::c_void);
|
||||||
|
if !sip_uri_raw.is_null() {
|
||||||
|
let sip_uri = sip_uri_raw as *mut pjsip_sip_uri;
|
||||||
|
let contact_host = pj_str_to_string(&(*sip_uri).host);
|
||||||
|
if let Ok(contact_ip) = contact_host.parse::<Ipv4Addr>() {
|
||||||
|
if is_rfc1918(contact_ip) && contact_ip != src_ip {
|
||||||
|
let src_port = (*rdata).pkt_info.src_port as u16;
|
||||||
|
let pool = (*rdata).tp_info.pool;
|
||||||
|
if !pool.is_null() {
|
||||||
|
if let Ok(new_host_cstr) = CString::new(src_ip_str) {
|
||||||
|
let host_len = src_ip_str.len();
|
||||||
|
let pool_str =
|
||||||
|
pj_pool_alloc(pool, host_len + 1) as *mut c_char;
|
||||||
|
if !pool_str.is_null() {
|
||||||
|
ptr::copy_nonoverlapping(
|
||||||
|
new_host_cstr.as_ptr(),
|
||||||
|
pool_str,
|
||||||
|
host_len + 1,
|
||||||
|
);
|
||||||
|
(*sip_uri).host.ptr = pool_str;
|
||||||
|
(*sip_uri).host.slen = host_len as i64;
|
||||||
|
(*sip_uri).port = src_port as i32;
|
||||||
|
tracing::debug!(
|
||||||
|
"NAT fixup (INVITE): Contact rewritten {} -> {}:{}",
|
||||||
|
contact_host,
|
||||||
|
src_ip_str,
|
||||||
|
src_port
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize SDP: strip dynamic payload types (96+) that lack a=rtpmap attributes.
|
||||||
|
// Without this, PJSIP's SDP validator rejects these INVITEs with EMISSINGRTPMAP.
|
||||||
|
let body = (*msg).body;
|
||||||
|
if !body.is_null() && (*body).len > 0 && !(*body).data.is_null() {
|
||||||
|
let body_slice =
|
||||||
|
std::slice::from_raw_parts((*body).data as *const u8, (*body).len as usize);
|
||||||
|
if let Ok(sdp_str) = std::str::from_utf8(body_slice) {
|
||||||
|
if let Some(sanitized) = sanitize_sdp_missing_rtpmap(sdp_str) {
|
||||||
|
let pool = (*rdata).tp_info.pool;
|
||||||
|
if !pool.is_null() {
|
||||||
|
let new_len = sanitized.len();
|
||||||
|
let new_body_ptr = pj_pool_alloc(pool, new_len) as *mut u8;
|
||||||
|
if !new_body_ptr.is_null() {
|
||||||
|
ptr::copy_nonoverlapping(sanitized.as_ptr(), new_body_ptr, new_len);
|
||||||
|
(*body).data = new_body_ptr as *mut _;
|
||||||
|
(*body).len = new_len as u32;
|
||||||
|
tracing::debug!(
|
||||||
|
"SDP sanitized: stripped orphan dynamic payload types (from {}:{})",
|
||||||
|
src_ip_str,
|
||||||
|
(*rdata).pkt_info.src_port
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pj_constants__PJ_FALSE as pj_bool_t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callback to fix far-end NAT traversal in incoming SIP responses.
|
||||||
|
///
|
||||||
|
/// When the remote party (phone) is behind NAT, their responses contain
|
||||||
|
/// private IPs in the Contact header and SDP body:
|
||||||
|
/// - Contact: `<sip:user@192.168.x.x>` -> PRACK/ACK routed to unreachable private IP
|
||||||
|
/// - SDP `c=IN IP4 192.168.x.x` -> RTP sent to unreachable private IP
|
||||||
|
///
|
||||||
|
/// This callback detects NAT (private Contact IP != packet source IP) and
|
||||||
|
/// rewrites both to the actual public source IP before PJSIP processes the
|
||||||
|
/// response, so the dialog target and media address are correct.
|
||||||
|
///
|
||||||
|
/// Registered at priority 28 (before dialog/invite layer at 32) to ensure
|
||||||
|
/// the dialog's remote target uses the corrected address.
|
||||||
|
pub unsafe extern "C" fn on_rx_response_nat_fixup_cb(rdata: *mut pjsip_rx_data) -> pj_bool_t {
|
||||||
|
if rdata.is_null() {
|
||||||
|
return pj_constants__PJ_FALSE as pj_bool_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = (*rdata).msg_info.msg;
|
||||||
|
if msg.is_null() {
|
||||||
|
return pj_constants__PJ_FALSE as pj_bool_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process 1xx/2xx responses (provisional and success)
|
||||||
|
let status_code = (*msg).line.status.code;
|
||||||
|
if !(100..300).contains(&status_code) {
|
||||||
|
return pj_constants__PJ_FALSE as pj_bool_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract source IP from pkt_info.src_name (null-terminated char array)
|
||||||
|
let src_name = &(*rdata).pkt_info.src_name;
|
||||||
|
let name_len = src_name
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == 0)
|
||||||
|
.unwrap_or(src_name.len());
|
||||||
|
let src_ip_str = match std::str::from_utf8(std::slice::from_raw_parts(
|
||||||
|
src_name.as_ptr() as *const u8,
|
||||||
|
name_len,
|
||||||
|
)) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return pj_constants__PJ_FALSE as pj_bool_t,
|
||||||
|
};
|
||||||
|
let src_ip: Ipv4Addr = match src_ip_str.parse() {
|
||||||
|
Ok(ip) => ip,
|
||||||
|
Err(_) => return pj_constants__PJ_FALSE as pj_bool_t, // IPv6 or invalid
|
||||||
|
};
|
||||||
|
let src_port = (*rdata).pkt_info.src_port as u16;
|
||||||
|
|
||||||
|
// Find Contact header in the response
|
||||||
|
let contact_hdr = pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_CONTACT, ptr::null_mut())
|
||||||
|
as *mut pjsip_contact_hdr;
|
||||||
|
if contact_hdr.is_null() {
|
||||||
|
return pj_constants__PJ_FALSE as pj_bool_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the SIP URI from the Contact (unwrap name_addr via vtable).
|
||||||
|
// The rx path requires vtable-based URI unwrapping (p_get_uri) because
|
||||||
|
// the Contact URI may be wrapped in a pjsip_name_addr, unlike the tx
|
||||||
|
// path where we can cast directly.
|
||||||
|
let uri = (*contact_hdr).uri;
|
||||||
|
if uri.is_null() {
|
||||||
|
return pj_constants__PJ_FALSE as pj_bool_t;
|
||||||
|
}
|
||||||
|
let uri_vptr = (*(uri as *const pjsip_uri)).vptr;
|
||||||
|
if uri_vptr.is_null() {
|
||||||
|
return pj_constants__PJ_FALSE as pj_bool_t;
|
||||||
|
}
|
||||||
|
let get_uri_fn = match (*uri_vptr).p_get_uri {
|
||||||
|
Some(f) => f,
|
||||||
|
None => return pj_constants__PJ_FALSE as pj_bool_t,
|
||||||
|
};
|
||||||
|
let sip_uri_raw = get_uri_fn(uri as *mut std::os::raw::c_void);
|
||||||
|
if sip_uri_raw.is_null() {
|
||||||
|
return pj_constants__PJ_FALSE as pj_bool_t;
|
||||||
|
}
|
||||||
|
let sip_uri = sip_uri_raw as *mut pjsip_sip_uri;
|
||||||
|
|
||||||
|
// Parse Contact host as IPv4
|
||||||
|
let contact_host = pj_str_to_string(&(*sip_uri).host);
|
||||||
|
let contact_ip: Ipv4Addr = match contact_host.parse() {
|
||||||
|
Ok(ip) => ip,
|
||||||
|
Err(_) => return pj_constants__PJ_FALSE as pj_bool_t, // Hostname, skip
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only rewrite if Contact has a private IP that differs from the source
|
||||||
|
if !is_rfc1918(contact_ip) || contact_ip == src_ip {
|
||||||
|
return pj_constants__PJ_FALSE as pj_bool_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NAT detected: Contact has private IP, packet came from different (public) IP
|
||||||
|
tracing::debug!(
|
||||||
|
"NAT fixup: rewriting Contact {} -> {}:{} (response {} from {}:{})",
|
||||||
|
contact_host,
|
||||||
|
src_ip,
|
||||||
|
src_port,
|
||||||
|
status_code,
|
||||||
|
src_ip,
|
||||||
|
src_port
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rewrite Contact URI host to the public source IP
|
||||||
|
let pool = (*rdata).tp_info.pool;
|
||||||
|
if !pool.is_null() {
|
||||||
|
if let Ok(new_host_cstr) = CString::new(src_ip_str) {
|
||||||
|
let host_len = src_ip_str.len();
|
||||||
|
let pool_str = pj_pool_alloc(pool, host_len + 1) as *mut c_char;
|
||||||
|
if !pool_str.is_null() {
|
||||||
|
ptr::copy_nonoverlapping(new_host_cstr.as_ptr(), pool_str, host_len + 1);
|
||||||
|
(*sip_uri).host.ptr = pool_str;
|
||||||
|
(*sip_uri).host.slen = host_len as i64;
|
||||||
|
(*sip_uri).port = src_port as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite SDP body: replace private IP with public source IP.
|
||||||
|
// Parse the SDP c= line directly to get the actual media IP — it may differ
|
||||||
|
// from the Contact header IP (e.g., dual-homed phone or double NAT).
|
||||||
|
let body = (*msg).body;
|
||||||
|
if !body.is_null() && (*body).len > 0 && !(*body).data.is_null() {
|
||||||
|
let body_slice =
|
||||||
|
std::slice::from_raw_parts((*body).data as *const u8, (*body).len as usize);
|
||||||
|
if let Ok(sdp_str) = std::str::from_utf8(body_slice) {
|
||||||
|
for line in sdp_str.lines() {
|
||||||
|
if let Some(addr_str) = line.strip_prefix("c=IN IP4 ") {
|
||||||
|
let addr_str = addr_str.trim();
|
||||||
|
if let Ok(sdp_ip) = addr_str.parse::<Ipv4Addr>() {
|
||||||
|
if is_rfc1918(sdp_ip) && sdp_ip != src_ip {
|
||||||
|
if rewrite_sdp_body(pool, msg, addr_str, src_ip_str) {
|
||||||
|
tracing::debug!(
|
||||||
|
"NAT fixup: SDP rewritten {} -> {}",
|
||||||
|
addr_str,
|
||||||
|
src_ip_str
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return FALSE to let other modules also process this response
|
||||||
|
pj_constants__PJ_FALSE as pj_bool_t
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_rfc1918_10_network() {
|
||||||
|
assert!(is_rfc1918(Ipv4Addr::new(10, 0, 0, 1)));
|
||||||
|
assert!(is_rfc1918(Ipv4Addr::new(10, 255, 255, 255)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_rfc1918_172_16_network() {
|
||||||
|
assert!(is_rfc1918(Ipv4Addr::new(172, 16, 0, 1)));
|
||||||
|
assert!(is_rfc1918(Ipv4Addr::new(172, 31, 255, 255)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_rfc1918_192_168_network() {
|
||||||
|
assert!(is_rfc1918(Ipv4Addr::new(192, 168, 1, 1)));
|
||||||
|
assert!(is_rfc1918(Ipv4Addr::new(192, 168, 0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_rfc1918_public_addresses() {
|
||||||
|
assert!(!is_rfc1918(Ipv4Addr::new(8, 8, 8, 8)));
|
||||||
|
assert!(!is_rfc1918(Ipv4Addr::new(172, 15, 0, 1)));
|
||||||
|
assert!(!is_rfc1918(Ipv4Addr::new(172, 32, 0, 1)));
|
||||||
|
assert!(!is_rfc1918(Ipv4Addr::new(192, 167, 1, 1)));
|
||||||
|
assert!(!is_rfc1918(Ipv4Addr::new(1, 1, 1, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_sdp_orphan_dynamic_pt_stripped() {
|
||||||
|
let sdp = "v=0\r\n\
|
||||||
|
o=- 0 0 IN IP4 0.0.0.0\r\n\
|
||||||
|
s=-\r\n\
|
||||||
|
c=IN IP4 10.0.0.1\r\n\
|
||||||
|
m=audio 5000 RTP/AVP 0 8 96\r\n\
|
||||||
|
a=rtpmap:0 PCMU/8000\r\n\
|
||||||
|
a=rtpmap:8 PCMA/8000\r\n";
|
||||||
|
// PT 96 has no rtpmap -> should be stripped
|
||||||
|
let result = sanitize_sdp_missing_rtpmap(sdp).unwrap();
|
||||||
|
assert!(result.contains("m=audio 5000 RTP/AVP 0 8\r\n"));
|
||||||
|
assert!(!result.contains("96"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_sdp_all_valid_pts_unchanged() {
|
||||||
|
let sdp = "v=0\r\n\
|
||||||
|
o=- 0 0 IN IP4 0.0.0.0\r\n\
|
||||||
|
s=-\r\n\
|
||||||
|
m=audio 5000 RTP/AVP 0 96\r\n\
|
||||||
|
a=rtpmap:0 PCMU/8000\r\n\
|
||||||
|
a=rtpmap:96 opus/48000/2\r\n";
|
||||||
|
assert!(sanitize_sdp_missing_rtpmap(sdp).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_sdp_non_rtp_transport_skipped() {
|
||||||
|
let sdp = "v=0\r\n\
|
||||||
|
o=- 0 0 IN IP4 0.0.0.0\r\n\
|
||||||
|
s=-\r\n\
|
||||||
|
m=image 5000 udptl t38\r\n\
|
||||||
|
a=T38FaxVersion:0\r\n";
|
||||||
|
assert!(sanitize_sdp_missing_rtpmap(sdp).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_sdp_all_pts_orphaned_unchanged() {
|
||||||
|
// If stripping all dynamic PTs would leave none, m= line stays unchanged
|
||||||
|
let sdp = "v=0\r\n\
|
||||||
|
o=- 0 0 IN IP4 0.0.0.0\r\n\
|
||||||
|
s=-\r\n\
|
||||||
|
m=audio 5000 RTP/AVP 96 97\r\n";
|
||||||
|
// Both 96 and 97 are dynamic with no rtpmap, but stripping both would leave no PTs
|
||||||
|
assert!(sanitize_sdp_missing_rtpmap(sdp).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_sdp_mixed_valid_and_orphan() {
|
||||||
|
let sdp = "v=0\r\n\
|
||||||
|
o=- 0 0 IN IP4 0.0.0.0\r\n\
|
||||||
|
s=-\r\n\
|
||||||
|
m=audio 5000 RTP/AVP 0 96 97\r\n\
|
||||||
|
a=rtpmap:0 PCMU/8000\r\n\
|
||||||
|
a=rtpmap:96 opus/48000/2\r\n\
|
||||||
|
a=fmtp:97 mode=20\r\n";
|
||||||
|
// PT 97 has no rtpmap -> stripped, its fmtp line also removed
|
||||||
|
let result = sanitize_sdp_missing_rtpmap(sdp).unwrap();
|
||||||
|
assert!(result.contains("m=audio 5000 RTP/AVP 0 96\r\n"));
|
||||||
|
assert!(!result.contains("97"));
|
||||||
|
assert!(!result.contains("fmtp:97"));
|
||||||
|
// fmtp for 96 would be kept if it existed; rtpmap:96 should still be there
|
||||||
|
assert!(result.contains("a=rtpmap:96 opus/48000/2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_sdp_malformed_m_line() {
|
||||||
|
let sdp = "v=0\r\n\
|
||||||
|
o=- 0 0 IN IP4 0.0.0.0\r\n\
|
||||||
|
s=-\r\n\
|
||||||
|
m=audio 5000\r\n";
|
||||||
|
// Malformed m= line (< 4 parts) -> passes through unchanged
|
||||||
|
assert!(sanitize_sdp_missing_rtpmap(sdp).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
585
sipcord-bridge/src/transport/sip/register_handler.rs
Normal file
585
sipcord-bridge/src/transport/sip/register_handler.rs
Normal file
|
|
@ -0,0 +1,585 @@
|
||||||
|
//! PJSIP module for REGISTER request handling
|
||||||
|
//!
|
||||||
|
//! This module handles:
|
||||||
|
//! - REGISTER requests with 401 challenge / Digest auth verification
|
||||||
|
//! - Storing registrations in the Registrar for inbound call routing
|
||||||
|
|
||||||
|
use super::callbacks::{
|
||||||
|
extract_digest_auth_from_rdata, extract_source_ip, extract_user_agent, is_sipvicious_scanner,
|
||||||
|
};
|
||||||
|
use super::ffi::types::*;
|
||||||
|
use super::ffi::utils::pj_str_to_string;
|
||||||
|
use pjsua::*;
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::os::raw::c_char;
|
||||||
|
use std::ptr;
|
||||||
|
use std::sync::atomic::{AtomicPtr, Ordering};
|
||||||
|
|
||||||
|
// Sendable pointer wrappers for pjsip types (used to move tsx/tdata across
|
||||||
|
// threads via the SipCommand channel). These MUST only be dereferenced from
|
||||||
|
// the pjsua event-loop thread.
|
||||||
|
|
||||||
|
pub struct SendableTsx(pub *mut pjsip_transaction);
|
||||||
|
unsafe impl Send for SendableTsx {}
|
||||||
|
|
||||||
|
pub struct SendableTdata(pub *mut pjsip_tx_data);
|
||||||
|
unsafe impl Send for SendableTdata {}
|
||||||
|
|
||||||
|
/// A REGISTER transaction awaiting async auth verification.
|
||||||
|
/// Created in the pjsip callback, consumed in `process_sip_command`.
|
||||||
|
pub struct PendingRegisterTsx {
|
||||||
|
pub tsx: SendableTsx,
|
||||||
|
pub tdata: SendableTdata,
|
||||||
|
pub expires: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for PendingRegisterTsx {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("PendingRegisterTsx")
|
||||||
|
.field("expires", &self.expires)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Globals
|
||||||
|
|
||||||
|
/// Channel for sending register events to the async verification task.
|
||||||
|
static REGISTER_EVENT_TX: std::sync::OnceLock<tokio::sync::mpsc::Sender<RegisterRequest>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
/// Sender half of the SIP command channel (for deferred REGISTER responses).
|
||||||
|
static SIP_COMMAND_TX: std::sync::OnceLock<crossbeam_channel::Sender<super::SipCommand>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
/// Pointer to the registered pjsip_module, needed for `pjsip_tsx_create_uas2`.
|
||||||
|
static REGISTER_MODULE_PTR: AtomicPtr<pjsip_module> = AtomicPtr::new(ptr::null_mut());
|
||||||
|
|
||||||
|
pub fn set_register_event_sender(tx: tokio::sync::mpsc::Sender<RegisterRequest>) {
|
||||||
|
let _ = REGISTER_EVENT_TX.set(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_sip_command_sender(tx: crossbeam_channel::Sender<super::SipCommand>) {
|
||||||
|
let _ = SIP_COMMAND_TX.set(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_register_module_ptr(ptr: *mut pjsip_module) {
|
||||||
|
REGISTER_MODULE_PTR.store(ptr, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
|
||||||
|
/// Initialize a pjsip_hdr as a list head (equivalent to pj_list_init C macro).
|
||||||
|
#[inline]
|
||||||
|
unsafe fn pj_list_init_hdr(hdr: *mut pjsip_hdr) {
|
||||||
|
(*hdr).next = hdr as *mut _;
|
||||||
|
(*hdr).prev = hdr as *mut _;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a simple stateless SIP response (no custom headers).
|
||||||
|
unsafe fn send_simple_response(rdata: *mut pjsip_rx_data, status_code: u16, reason: &str) {
|
||||||
|
let endpt = pjsua_get_pjsip_endpt();
|
||||||
|
if !endpt.is_null() {
|
||||||
|
let reason_cstr = CString::new(reason).unwrap();
|
||||||
|
let reason_pj = pj_str(reason_cstr.as_ptr() as *mut c_char);
|
||||||
|
pjsip_endpt_respond_stateless(
|
||||||
|
endpt,
|
||||||
|
rdata,
|
||||||
|
status_code.into(),
|
||||||
|
&reason_pj,
|
||||||
|
ptr::null(),
|
||||||
|
ptr::null(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a stateless 200 OK with an Expires header.
|
||||||
|
unsafe fn send_register_ok(rdata: *mut pjsip_rx_data, expires: u32) {
|
||||||
|
let endpt = pjsua_get_pjsip_endpt();
|
||||||
|
if endpt.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let expires_str = format!("{}", expires);
|
||||||
|
let hdr_name = CString::new("Expires").unwrap();
|
||||||
|
let hdr_value = CString::new(expires_str).unwrap();
|
||||||
|
|
||||||
|
let pool = pjsua_pool_create(c"register_ok".as_ptr(), 512, 512);
|
||||||
|
if !pool.is_null() {
|
||||||
|
let name = pj_str(hdr_name.as_ptr() as *mut c_char);
|
||||||
|
let value = pj_str(hdr_value.as_ptr() as *mut c_char);
|
||||||
|
let hdr = pjsip_generic_string_hdr_create(pool, &name, &value);
|
||||||
|
|
||||||
|
if !hdr.is_null() {
|
||||||
|
let hdr_list = pj_pool_alloc(pool, std::mem::size_of::<pjsip_hdr>()) as *mut pjsip_hdr;
|
||||||
|
if !hdr_list.is_null() {
|
||||||
|
pj_list_init_hdr(hdr_list);
|
||||||
|
pj_list_insert_before(hdr_list as *mut pj_list_type, hdr as *mut pj_list_type);
|
||||||
|
|
||||||
|
let status = pjsip_endpt_respond_stateless(
|
||||||
|
endpt,
|
||||||
|
rdata,
|
||||||
|
200,
|
||||||
|
ptr::null(),
|
||||||
|
hdr_list,
|
||||||
|
ptr::null(),
|
||||||
|
);
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
tracing::warn!("Failed to respond 200 OK to REGISTER: {}", status);
|
||||||
|
}
|
||||||
|
// Release pool — pjsip_endpt_respond_stateless clones what it
|
||||||
|
// needs into rdata's pool, so our header pool can be freed now.
|
||||||
|
pj_pool_release(pool);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Header creation failed — release the pool before falling through
|
||||||
|
pj_pool_release(pool);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: respond without Expires header
|
||||||
|
let status =
|
||||||
|
pjsip_endpt_respond_stateless(endpt, rdata, 200, ptr::null(), ptr::null(), ptr::null());
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
tracing::warn!("Failed to respond 200 OK to REGISTER: {}", status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect transport type (UDP/TCP/TLS) from the incoming request.
|
||||||
|
unsafe fn detect_transport(rdata: *mut pjsip_rx_data) -> crate::services::registrar::SipTransport {
|
||||||
|
if !(*rdata).tp_info.transport.is_null() {
|
||||||
|
let tp_type = (*(*rdata).tp_info.transport).key.type_ as u32;
|
||||||
|
if tp_type == pjsip_transport_type_e_PJSIP_TRANSPORT_TLS {
|
||||||
|
crate::services::registrar::SipTransport::Tls
|
||||||
|
} else if tp_type == pjsip_transport_type_e_PJSIP_TRANSPORT_TCP {
|
||||||
|
crate::services::registrar::SipTransport::Tcp
|
||||||
|
} else {
|
||||||
|
crate::services::registrar::SipTransport::Udp
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
crate::services::registrar::SipTransport::Udp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a UAS transaction + pre-built response tdata for deferred REGISTER
|
||||||
|
/// responses. Returns `None` if transaction creation fails (caller should fall
|
||||||
|
/// back to stateless response).
|
||||||
|
unsafe fn create_register_tsx(
|
||||||
|
rdata: *mut pjsip_rx_data,
|
||||||
|
expires: u32,
|
||||||
|
) -> Option<PendingRegisterTsx> {
|
||||||
|
let endpt = pjsua_get_pjsip_endpt();
|
||||||
|
let module_ptr = REGISTER_MODULE_PTR.load(Ordering::Acquire);
|
||||||
|
|
||||||
|
if endpt.is_null() || module_ptr.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create UAS transaction
|
||||||
|
let mut tsx: *mut pjsip_transaction = ptr::null_mut();
|
||||||
|
let status = pjsip_tsx_create_uas2(module_ptr, rdata, ptr::null_mut(), &mut tsx);
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 || tsx.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed the request to the transaction (starts Timer F, stores headers)
|
||||||
|
pjsip_tsx_recv_msg(tsx, rdata);
|
||||||
|
|
||||||
|
// Pre-build a 200 OK response while rdata is still valid.
|
||||||
|
// The status code / reason will be modified before sending if auth fails.
|
||||||
|
let mut tdata: *mut pjsip_tx_data = ptr::null_mut();
|
||||||
|
let status = pjsip_endpt_create_response(endpt, rdata, 200, ptr::null(), &mut tdata);
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 || tdata.is_null() {
|
||||||
|
pjsip_tsx_terminate(tsx, 500);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(PendingRegisterTsx {
|
||||||
|
tsx: SendableTsx(tsx),
|
||||||
|
tdata: SendableTdata(tdata),
|
||||||
|
expires,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main callback
|
||||||
|
|
||||||
|
/// Callback to handle incoming SIP requests (for REGISTER support)
|
||||||
|
///
|
||||||
|
/// SIP clients send REGISTER requests to register with the server. pjsua's high-level
|
||||||
|
/// API doesn't handle REGISTER since it's designed as a client library. We intercept
|
||||||
|
/// REGISTER requests here.
|
||||||
|
///
|
||||||
|
/// Flow:
|
||||||
|
/// 1. REGISTER without Authorization header -> 401 with WWW-Authenticate challenge
|
||||||
|
/// 2. REGISTER with Authorization header:
|
||||||
|
/// a. Cache hit + verified -> immediate 200 OK (stateless)
|
||||||
|
/// b. Cache hit + mismatch -> immediate 403 Forbidden (stateless)
|
||||||
|
/// c. Cache miss -> defer via UAS transaction, verify via API, respond later
|
||||||
|
pub unsafe extern "C" fn on_rx_request_cb(rdata: *mut pjsip_rx_data) -> pj_bool_t {
|
||||||
|
if rdata.is_null() {
|
||||||
|
return pj_constants__PJ_FALSE as pj_bool_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = (*rdata).msg_info.msg;
|
||||||
|
if msg.is_null() {
|
||||||
|
return pj_constants__PJ_FALSE as pj_bool_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a REGISTER request
|
||||||
|
let method_id = (*msg).line.req.method.id;
|
||||||
|
if method_id != pjsip_method_e_PJSIP_REGISTER_METHOD {
|
||||||
|
// Not REGISTER, let other modules handle it
|
||||||
|
return pj_constants__PJ_FALSE as pj_bool_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract source IP for logging and ban checking
|
||||||
|
let source_ip = extract_source_ip(rdata);
|
||||||
|
let ip_str = source_ip
|
||||||
|
.map(|ip| ip.to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
// Extract source port
|
||||||
|
let source_port = (*rdata).pkt_info.src_port as u16;
|
||||||
|
|
||||||
|
// Ban checks: skip if banning disabled or IP is whitelisted
|
||||||
|
if let Some(ip) = source_ip {
|
||||||
|
if let Some(ban_mgr) = crate::services::ban::global() {
|
||||||
|
if ban_mgr.is_enabled() && !ban_mgr.is_whitelisted(&ip) {
|
||||||
|
// Check if IP is banned
|
||||||
|
let result = ban_mgr.check_banned(&ip);
|
||||||
|
if result.is_banned {
|
||||||
|
tracing::debug!("Rejecting REGISTER from banned IP {}", ip);
|
||||||
|
send_simple_response(rdata, 403, "Forbidden");
|
||||||
|
return pj_constants__PJ_TRUE as pj_bool_t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check User-Agent for SIPVicious scanners - instant permaban
|
||||||
|
if let Some(user_agent) = extract_user_agent(rdata) {
|
||||||
|
if is_sipvicious_scanner(&user_agent) {
|
||||||
|
if let Some(ip) = source_ip {
|
||||||
|
if let Some(ban_mgr) = crate::services::ban::global() {
|
||||||
|
if ban_mgr.is_enabled() && !ban_mgr.is_whitelisted(&ip) {
|
||||||
|
let result =
|
||||||
|
ban_mgr.record_permanent_ban(ip, "sipvicious_scanner_register");
|
||||||
|
if result.should_log {
|
||||||
|
tracing::warn!(
|
||||||
|
"PERMABAN IP {} - SIPVicious scanner detected in REGISTER: User-Agent='{}'",
|
||||||
|
ip, user_agent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"SIPVicious scanner detected in REGISTER but no IP available: User-Agent='{}'",
|
||||||
|
user_agent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
send_simple_response(rdata, 403, "Forbidden");
|
||||||
|
return pj_constants__PJ_TRUE as pj_bool_t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit REGISTER requests
|
||||||
|
if let Some(ip) = source_ip {
|
||||||
|
if let Some(ban_mgr) = crate::services::ban::global() {
|
||||||
|
if ban_mgr.is_enabled() && !ban_mgr.is_whitelisted(&ip) && ban_mgr.record_register(ip) {
|
||||||
|
tracing::debug!("Rejecting REGISTER from {} - rate limit exceeded", ip);
|
||||||
|
send_simple_response(rdata, 429, "Too Many Requests");
|
||||||
|
return pj_constants__PJ_TRUE as pj_bool_t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract Digest auth params from Authorization header
|
||||||
|
let digest_params = extract_digest_auth_from_rdata(rdata);
|
||||||
|
|
||||||
|
if let Some(mut params) = digest_params {
|
||||||
|
// Has auth - fill in REGISTER method
|
||||||
|
params.method = "REGISTER".to_string();
|
||||||
|
|
||||||
|
// Check auth failure cooldown before processing
|
||||||
|
if let Some(cache) = crate::services::auth_cache::AuthCache::global() {
|
||||||
|
if cache.is_in_cooldown(¶ms.username) {
|
||||||
|
tracing::debug!(
|
||||||
|
"Rejecting REGISTER from {} (user={}) - auth cooldown active",
|
||||||
|
ip_str,
|
||||||
|
params.username
|
||||||
|
);
|
||||||
|
send_simple_response(rdata, 429, "Too Many Requests");
|
||||||
|
return pj_constants__PJ_TRUE as pj_bool_t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract fields needed for all code paths
|
||||||
|
let contact_uri = extract_contact_uri(rdata);
|
||||||
|
let expires = extract_expires(rdata);
|
||||||
|
let source_addr = source_ip.map(|ip| SocketAddr::new(ip, source_port));
|
||||||
|
let transport = detect_transport(rdata);
|
||||||
|
|
||||||
|
// Auth cache verification
|
||||||
|
if let Some(cache) = crate::services::auth_cache::AuthCache::global() {
|
||||||
|
use crate::services::auth_cache::VerifyResult;
|
||||||
|
match cache.check(¶ms) {
|
||||||
|
VerifyResult::Verified => {
|
||||||
|
// Cache hit, auth OK — fast-path 200 OK
|
||||||
|
tracing::debug!(
|
||||||
|
"REGISTER auth OK (cached): user={} from {}",
|
||||||
|
params.username,
|
||||||
|
ip_str
|
||||||
|
);
|
||||||
|
send_register_ok(rdata, expires);
|
||||||
|
// Send to async handler for registrar update
|
||||||
|
if let Some(tx) = REGISTER_EVENT_TX.get() {
|
||||||
|
let _ = tx.try_send(RegisterRequest {
|
||||||
|
digest_auth: params,
|
||||||
|
contact_uri: contact_uri.unwrap_or_default(),
|
||||||
|
source_addr,
|
||||||
|
transport,
|
||||||
|
expires,
|
||||||
|
pending_tsx: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pj_constants__PJ_TRUE as pj_bool_t;
|
||||||
|
}
|
||||||
|
VerifyResult::Mismatch => {
|
||||||
|
// Wrong password (cached HA1 didn't match) — 403
|
||||||
|
tracing::debug!(
|
||||||
|
"REGISTER auth mismatch (cached): user={} from {}",
|
||||||
|
params.username,
|
||||||
|
ip_str
|
||||||
|
);
|
||||||
|
send_simple_response(rdata, 403, "Forbidden");
|
||||||
|
// Send to async so API can re-verify (cache may be stale
|
||||||
|
// after a password change) and update failure counts
|
||||||
|
if let Some(tx) = REGISTER_EVENT_TX.get() {
|
||||||
|
let _ = tx.try_send(RegisterRequest {
|
||||||
|
digest_auth: params,
|
||||||
|
contact_uri: contact_uri.unwrap_or_default(),
|
||||||
|
source_addr,
|
||||||
|
transport,
|
||||||
|
expires,
|
||||||
|
pending_tsx: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pj_constants__PJ_TRUE as pj_bool_t;
|
||||||
|
}
|
||||||
|
VerifyResult::Miss => {
|
||||||
|
// No cached HA1 — need API round-trip.
|
||||||
|
// Create a UAS transaction so we can respond after the
|
||||||
|
// async handler completes, without blocking pjsip.
|
||||||
|
tracing::debug!(
|
||||||
|
"REGISTER cache miss: user={} from {}, deferring to API",
|
||||||
|
params.username,
|
||||||
|
ip_str
|
||||||
|
);
|
||||||
|
if let Some(pending) = create_register_tsx(rdata, expires) {
|
||||||
|
if let Some(tx) = REGISTER_EVENT_TX.get() {
|
||||||
|
let _ = tx.try_send(RegisterRequest {
|
||||||
|
digest_auth: params,
|
||||||
|
contact_uri: contact_uri.unwrap_or_default(),
|
||||||
|
source_addr,
|
||||||
|
transport,
|
||||||
|
expires,
|
||||||
|
pending_tsx: Some(pending),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pj_constants__PJ_TRUE as pj_bool_t;
|
||||||
|
}
|
||||||
|
// Transaction creation failed — fall through to stateless
|
||||||
|
// 200 OK below (same behaviour as before this change).
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to create tsx for deferred REGISTER, falling back to stateless 200"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default path: stateless 200 OK + async verification
|
||||||
|
// (non-sipcord builds, auth cache unavailable, or tsx creation failed)
|
||||||
|
tracing::debug!(
|
||||||
|
"REGISTER with auth from {} (user={}), responding 200 OK (stateless)",
|
||||||
|
ip_str,
|
||||||
|
params.username
|
||||||
|
);
|
||||||
|
if let Some(tx) = REGISTER_EVENT_TX.get() {
|
||||||
|
let _ = tx.try_send(RegisterRequest {
|
||||||
|
digest_auth: params,
|
||||||
|
contact_uri: contact_uri.unwrap_or_default(),
|
||||||
|
source_addr,
|
||||||
|
transport,
|
||||||
|
expires,
|
||||||
|
pending_tsx: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
send_register_ok(rdata, expires);
|
||||||
|
} else {
|
||||||
|
// No Authorization header - send 401 challenge
|
||||||
|
tracing::debug!(
|
||||||
|
"REGISTER without auth from {}, sending 401 challenge",
|
||||||
|
ip_str
|
||||||
|
);
|
||||||
|
|
||||||
|
let endpt = pjsua_get_pjsip_endpt();
|
||||||
|
if endpt.is_null() {
|
||||||
|
tracing::error!("Failed to get PJSIP endpoint for REGISTER 401 response");
|
||||||
|
return pj_constants__PJ_TRUE as pj_bool_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a cryptographically random nonce
|
||||||
|
let nonce = {
|
||||||
|
let bytes: [u8; 16] = rand::random();
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{:02x}", b))
|
||||||
|
.collect::<String>()
|
||||||
|
};
|
||||||
|
|
||||||
|
let www_auth = format!(
|
||||||
|
"Digest realm=\"{}\", nonce=\"{}\", algorithm=MD5, qop=\"auth\"",
|
||||||
|
SIP_REALM, nonce
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create WWW-Authenticate header
|
||||||
|
let hdr_name = CString::new("WWW-Authenticate").unwrap();
|
||||||
|
let hdr_value = CString::new(www_auth).unwrap();
|
||||||
|
|
||||||
|
let pool = pjsua_pool_create(c"register_401".as_ptr(), 512, 512);
|
||||||
|
if pool.is_null() {
|
||||||
|
tracing::error!("Failed to create pool for REGISTER 401 response");
|
||||||
|
return pj_constants__PJ_TRUE as pj_bool_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = pj_str(hdr_name.as_ptr() as *mut c_char);
|
||||||
|
let value = pj_str(hdr_value.as_ptr() as *mut c_char);
|
||||||
|
let hdr = pjsip_generic_string_hdr_create(pool, &name, &value);
|
||||||
|
|
||||||
|
if !hdr.is_null() {
|
||||||
|
let hdr_list = pj_pool_alloc(pool, std::mem::size_of::<pjsip_hdr>()) as *mut pjsip_hdr;
|
||||||
|
if !hdr_list.is_null() {
|
||||||
|
pj_list_init_hdr(hdr_list);
|
||||||
|
pj_list_insert_before(hdr_list as *mut pj_list_type, hdr as *mut pj_list_type);
|
||||||
|
|
||||||
|
let status = pjsip_endpt_respond_stateless(
|
||||||
|
endpt,
|
||||||
|
rdata,
|
||||||
|
401,
|
||||||
|
ptr::null(),
|
||||||
|
hdr_list,
|
||||||
|
ptr::null(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if status != pj_constants__PJ_SUCCESS as i32 {
|
||||||
|
tracing::warn!("Failed to respond 401 to REGISTER: {}", status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Release pool — pjsip_endpt_respond_stateless clones headers internally
|
||||||
|
pj_pool_release(pool);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return TRUE to indicate we handled this request
|
||||||
|
pj_constants__PJ_TRUE as pj_bool_t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraction helpers
|
||||||
|
|
||||||
|
/// Extract Contact URI from REGISTER request
|
||||||
|
unsafe fn extract_contact_uri(rdata: *mut pjsip_rx_data) -> Option<String> {
|
||||||
|
if rdata.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = (*rdata).msg_info.msg;
|
||||||
|
if msg.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contact_hdr = pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_CONTACT, ptr::null_mut())
|
||||||
|
as *const pjsip_contact_hdr;
|
||||||
|
|
||||||
|
if contact_hdr.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uri = (*contact_hdr).uri;
|
||||||
|
if uri.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Contact header URI is typically a pjsip_name_addr wrapping a pjsip_sip_uri.
|
||||||
|
// We must unwrap it via the vtable's p_get_uri (equivalent to pjsip_uri_get_uri()
|
||||||
|
// which is an inline C function not available through FFI).
|
||||||
|
let uri_vptr = (*(uri as *const pjsip_uri)).vptr;
|
||||||
|
if uri_vptr.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let get_uri_fn = (*uri_vptr).p_get_uri?;
|
||||||
|
let sip_uri_raw = get_uri_fn(uri as *mut std::os::raw::c_void);
|
||||||
|
if sip_uri_raw.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let sip_uri = sip_uri_raw as *const pjsip_sip_uri;
|
||||||
|
if (*sip_uri).host.ptr.is_null() || (*sip_uri).host.slen <= 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = pj_str_to_string(&(*sip_uri).host);
|
||||||
|
let port = (*sip_uri).port;
|
||||||
|
let user = if !(*sip_uri).user.ptr.is_null() && (*sip_uri).user.slen > 0 {
|
||||||
|
Some(pj_str_to_string(&(*sip_uri).user))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let uri_str = match (user, port) {
|
||||||
|
(Some(u), p) if p > 0 => format!("sip:{}@{}:{}", u, host, p),
|
||||||
|
(Some(u), _) => format!("sip:{}@{}", u, host),
|
||||||
|
(None, p) if p > 0 => format!("sip:{}:{}", host, p),
|
||||||
|
(None, _) => format!("sip:{}", host),
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(uri_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract Expires value from REGISTER request (header or Contact param)
|
||||||
|
unsafe fn extract_expires(rdata: *mut pjsip_rx_data) -> u32 {
|
||||||
|
if rdata.is_null() {
|
||||||
|
return 3600;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = (*rdata).msg_info.msg;
|
||||||
|
if msg.is_null() {
|
||||||
|
return 3600;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Expires header first
|
||||||
|
let expires_hdr = pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_EXPIRES, ptr::null_mut())
|
||||||
|
as *const pjsip_expires_hdr;
|
||||||
|
|
||||||
|
if !expires_hdr.is_null() {
|
||||||
|
return (*expires_hdr).ivalue as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default
|
||||||
|
3600
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types
|
||||||
|
|
||||||
|
/// Data passed to the async register verification task
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RegisterRequest {
|
||||||
|
pub digest_auth: DigestAuthParams,
|
||||||
|
pub contact_uri: String,
|
||||||
|
pub source_addr: Option<SocketAddr>,
|
||||||
|
pub transport: crate::services::registrar::SipTransport,
|
||||||
|
pub expires: u32,
|
||||||
|
/// When set, the async handler must send the auth result back via
|
||||||
|
/// `SipCommand::RespondRegister` so the pjsip thread can complete
|
||||||
|
/// the UAS transaction.
|
||||||
|
pub pending_tsx: Option<PendingRegisterTsx>,
|
||||||
|
}
|
||||||
BIN
wav/JoonaKouvolalainen.flac
Normal file
BIN
wav/JoonaKouvolalainen.flac
Normal file
Binary file not shown.
BIN
wav/connecting.wav
Normal file
BIN
wav/connecting.wav
Normal file
Binary file not shown.
BIN
wav/discord_join.wav
Normal file
BIN
wav/discord_join.wav
Normal file
Binary file not shown.
BIN
wav/hold.flac
Normal file
BIN
wav/hold.flac
Normal file
Binary file not shown.
BIN
wav/no_channel_map.mp3
Normal file
BIN
wav/no_channel_map.mp3
Normal file
Binary file not shown.
BIN
wav/no_channel_mapping.wav
Normal file
BIN
wav/no_channel_mapping.wav
Normal file
Binary file not shown.
BIN
wav/no_permissions.wav
Normal file
BIN
wav/no_permissions.wav
Normal file
Binary file not shown.
BIN
wav/no_perms.mp3
Normal file
BIN
wav/no_perms.mp3
Normal file
Binary file not shown.
BIN
wav/nokia.flac
Normal file
BIN
wav/nokia.flac
Normal file
Binary file not shown.
BIN
wav/serverisbusy.wav
Normal file
BIN
wav/serverisbusy.wav
Normal file
Binary file not shown.
BIN
wav/unknown.wav
Normal file
BIN
wav/unknown.wav
Normal file
Binary file not shown.
BIN
wav/unknown_error.mp3
Normal file
BIN
wav/unknown_error.mp3
Normal file
Binary file not shown.
Loading…
Reference in a new issue