initial commit — SIP B2BUA + WebRTC bridge with Rust codec engine

Full-featured SIP router with multi-provider trunking, browser softphone
via WebRTC, real-time Opus/G.722/PCM transcoding in Rust, RNNoise ML
noise suppression, Kokoro neural TTS announcements, and a Lit-based
web dashboard with live call monitoring and REST API.
This commit is contained in:
2026-04-09 23:03:55 +00:00
commit f3e1c96872
59 changed files with 18377 additions and 0 deletions

253
ts_web/state/appstate.ts Normal file
View File

@@ -0,0 +1,253 @@
/**
* Application state — receives live updates from the proxy via WebSocket.
*/
export interface IProviderStatus {
id: string;
displayName: string;
registered: boolean;
publicIp: string | null;
}
export interface IDeviceStatus {
id: string;
displayName: string;
contact: { address: string; port: number } | null;
aor: string;
connected: boolean;
isBrowser: boolean;
}
export interface ILegStatus {
id: string;
type: 'sip-device' | 'sip-provider' | 'webrtc';
state: string;
remoteMedia: { address: string; port: number } | null;
rtpPort: number | null;
pktSent: number;
pktReceived: number;
codec: string | null;
transcoding: boolean;
}
export interface ICallStatus {
id: string;
state: string;
direction: 'inbound' | 'outbound' | 'internal';
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
createdAt: number;
duration: number;
legs: ILegStatus[];
}
export interface ICallHistoryEntry {
id: string;
direction: 'inbound' | 'outbound' | 'internal';
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
startedAt: number;
duration: number;
}
export interface IContact {
id: string;
name: string;
number: string;
company?: string;
notes?: string;
starred?: boolean;
}
export interface IAppState {
connected: boolean;
browserDeviceId: string | null;
uptime: number;
providers: IProviderStatus[];
devices: IDeviceStatus[];
calls: ICallStatus[];
callHistory: ICallHistoryEntry[];
contacts: IContact[];
selectedContact: IContact | null;
logLines: string[];
}
const MAX_LOG = 200;
let knownInstanceId: string | null = null;
class AppStateManager {
private state: IAppState = {
connected: false,
browserDeviceId: null,
uptime: 0,
providers: [],
devices: [],
calls: [],
callHistory: [],
contacts: [],
selectedContact: null,
logLines: [],
};
private listeners = new Set<(state: IAppState) => void>();
private ws: WebSocket | null = null;
getState(): IAppState {
return this.state;
}
subscribe(listener: (state: IAppState) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private update(partial: Partial<IAppState>): void {
this.state = { ...this.state, ...partial };
for (const fn of this.listeners) fn(this.state);
}
addLog(msg: string): void {
const lines = [...this.state.logLines, msg];
if (lines.length > MAX_LOG) lines.splice(0, lines.length - MAX_LOG);
this.update({ logLines: lines });
}
connect(): void {
// Guard against duplicate connections.
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${location.host}/ws`);
ws.onopen = () => {
this.ws = ws;
(window as any).__sipRouterWs = ws;
this.update({ connected: true });
};
ws.onclose = () => {
this.ws = null;
this.update({ connected: false });
setTimeout(() => this.connect(), 2000);
};
ws.onerror = () => ws.close();
ws.onmessage = (ev) => {
try {
const m = JSON.parse(ev.data);
if (m.type === 'status') {
// Auto-reload if backend restarted (different instance).
if (m.data.instanceId) {
if (knownInstanceId && knownInstanceId !== m.data.instanceId) {
location.reload();
return;
}
knownInstanceId = m.data.instanceId;
}
this.update({
uptime: m.data.uptime,
providers: m.data.providers || [],
devices: m.data.devices || [],
calls: m.data.calls || [],
callHistory: m.data.callHistory || [],
contacts: m.data.contacts || [],
});
} else if (m.type === 'log') {
this.addLog(`${m.ts} ${m.data.message}`);
} else if (m.type === 'call-update') {
// Real-time call state update — will be picked up by next status snapshot.
} else if (m.type?.startsWith('webrtc-')) {
// Route to any registered WebRTC signaling handler.
const handler = (window as any).__sipRouterWebRtcHandler;
if (handler) handler(m);
}
} catch { /* ignore */ }
};
}
setBrowserDeviceId(deviceId: string): void {
this.update({ browserDeviceId: deviceId });
}
selectContact(contact: IContact | null): void {
this.update({ selectedContact: contact });
}
clearSelectedContact(): void {
this.update({ selectedContact: null });
}
async apiCall(number: string, deviceId?: string, providerId?: string): Promise<{ ok: boolean; callId?: string; error?: string }> {
const res = await fetch('/api/call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ number, deviceId, providerId }),
});
return res.json();
}
async apiHangup(callId: string): Promise<{ ok: boolean }> {
const res = await fetch('/api/hangup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ callId }),
});
return res.json();
}
async apiAddLeg(callId: string, deviceId: string): Promise<{ ok: boolean }> {
const res = await fetch(`/api/call/${callId}/addleg`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId }),
});
return res.json();
}
async apiAddExternal(callId: string, number: string, providerId?: string): Promise<{ ok: boolean }> {
const res = await fetch(`/api/call/${callId}/addexternal`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ number, providerId }),
});
return res.json();
}
async apiRemoveLeg(callId: string, legId: string): Promise<{ ok: boolean }> {
const res = await fetch(`/api/call/${callId}/removeleg`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ legId }),
});
return res.json();
}
async apiTransfer(sourceCallId: string, legId: string, targetCallId: string): Promise<{ ok: boolean }> {
const res = await fetch('/api/transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceCallId, legId, targetCallId }),
});
return res.json();
}
async apiGetConfig(): Promise<any> {
const res = await fetch('/api/config');
return res.json();
}
async apiSaveConfig(updates: any): Promise<{ ok: boolean }> {
const res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
return res.json();
}
}
export const appState = new AppStateManager();

View File

@@ -0,0 +1,215 @@
/**
* Notification manager — drives global banners and toasts based on app state changes.
*/
import { deesCatalog } from '../plugins.js';
import { appState, type IAppState } from './appstate.js';
type BannerType = 'info' | 'success' | 'warning' | 'error';
type ToastType = 'info' | 'success' | 'warning' | 'error';
export class NotificationManager {
private appdash: InstanceType<typeof deesCatalog.DeesSimpleAppDash> | null = null;
private prevState: IAppState | null = null;
private unsubscribe: (() => void) | null = null;
private activeBannerIds = new Set<string>();
private userDismissedIds = new Set<string>();
private suppressToastsUntil = 0;
private wsDisconnectTimer: ReturnType<typeof setTimeout> | null = null;
private wsWasConnected = false;
init(appdash: InstanceType<typeof deesCatalog.DeesSimpleAppDash>): void {
this.appdash = appdash;
this.suppressToastsUntil = Date.now() + 3000;
appdash.addEventListener('message-dismiss', ((e: CustomEvent) => {
const id = e.detail?.id;
if (id) {
this.userDismissedIds.add(id);
this.activeBannerIds.delete(id);
}
}) as EventListener);
this.unsubscribe = appState.subscribe((state) => this.onStateChange(state));
}
destroy(): void {
this.unsubscribe?.();
this.unsubscribe = null;
if (this.wsDisconnectTimer) {
clearTimeout(this.wsDisconnectTimer);
this.wsDisconnectTimer = null;
}
}
// ---------------------------------------------------------------------------
// Change detection
// ---------------------------------------------------------------------------
private onStateChange(newState: IAppState): void {
const prev = this.prevState;
// First state: snapshot and exit without notifications.
if (!prev) {
this.prevState = newState;
// Exception: if we start disconnected, show the banner immediately.
if (!newState.connected) {
this.addBanner('ws-disconnected', 'error', 'Connection to server lost. Reconnecting...', { dismissible: false });
}
return;
}
this.checkWebSocketConnectivity(prev, newState);
this.checkProviders(prev, newState);
this.checkDevices(prev, newState);
this.checkOriginatedCalls(prev, newState);
this.prevState = newState;
}
// ---------------------------------------------------------------------------
// WebSocket connectivity
// ---------------------------------------------------------------------------
private checkWebSocketConnectivity(prev: IAppState, next: IAppState): void {
if (prev.connected && !next.connected) {
// Disconnected.
this.addBanner('ws-disconnected', 'error', 'Connection to server lost. Reconnecting...', { dismissible: false });
this.wsWasConnected = true;
// Debounce: set timer so we can suppress toast on rapid reconnect.
if (this.wsDisconnectTimer) clearTimeout(this.wsDisconnectTimer);
this.wsDisconnectTimer = setTimeout(() => { this.wsDisconnectTimer = null; }, 500);
} else if (!prev.connected && next.connected) {
// Reconnected.
this.removeBanner('ws-disconnected');
if (this.wsWasConnected && !this.wsDisconnectTimer) {
this.showToast('Reconnected to server', 'success');
}
if (this.wsDisconnectTimer) {
clearTimeout(this.wsDisconnectTimer);
this.wsDisconnectTimer = null;
}
}
}
// ---------------------------------------------------------------------------
// Provider registration
// ---------------------------------------------------------------------------
private checkProviders(prev: IAppState, next: IAppState): void {
const prevMap = new Map(prev.providers.map((p) => [p.id, p]));
const nextMap = new Map(next.providers.map((p) => [p.id, p]));
for (const [id, np] of nextMap) {
const pp = prevMap.get(id);
if (!pp) continue; // New provider appeared — no transition to report.
const bannerId = `provider-unreg-${id}`;
if (pp.registered && !np.registered) {
// Registration lost.
this.addBanner(bannerId, 'warning', `${np.displayName}: registration lost`);
} else if (!pp.registered && np.registered) {
// Registration restored.
this.removeBanner(bannerId);
this.showToast(`${np.displayName} registered`, 'success');
}
}
}
// ---------------------------------------------------------------------------
// Device connectivity
// ---------------------------------------------------------------------------
private checkDevices(prev: IAppState, next: IAppState): void {
const prevMap = new Map(prev.devices.map((d) => [d.id, d]));
const nextMap = new Map(next.devices.map((d) => [d.id, d]));
for (const [id, nd] of nextMap) {
// Skip browser devices for banners — connections are transient.
if (nd.isBrowser) continue;
const pd = prevMap.get(id);
if (!pd) continue; // New device appeared — no transition.
const bannerId = `device-offline-${id}`;
if (pd.connected && !nd.connected) {
this.addBanner(bannerId, 'warning', `${nd.displayName} disconnected`);
} else if (!pd.connected && nd.connected) {
this.removeBanner(bannerId);
this.showToast(`${nd.displayName} connected`, 'success');
}
}
// Handle devices that disappeared entirely (browser device left).
for (const [id, pd] of prevMap) {
if (pd.isBrowser) continue;
if (!nextMap.has(id) && pd.connected) {
this.addBanner(`device-offline-${id}`, 'warning', `${pd.displayName} disconnected`);
}
}
}
// ---------------------------------------------------------------------------
// Originated call state transitions
// ---------------------------------------------------------------------------
private checkOriginatedCalls(prev: IAppState, next: IAppState): void {
const prevMap = new Map(prev.calls.map((c) => [c.id, c]));
for (const nc of next.calls) {
const pc = prevMap.get(nc.id);
if (!pc) continue;
const label = nc.calleeNumber || nc.callerNumber || nc.id;
if (pc.state !== 'connected' && nc.state === 'connected') {
this.showToast(`Call ${label} connected`, 'success');
} else if (pc.state !== 'terminated' && nc.state === 'terminated') {
this.showToast(`Call ${label} ended`, 'info');
}
}
}
// ---------------------------------------------------------------------------
// Banner helpers
// ---------------------------------------------------------------------------
private addBanner(id: string, type: BannerType, message: string, opts?: { dismissible?: boolean }): void {
if (!this.appdash) return;
if (this.activeBannerIds.has(id)) return;
if (this.userDismissedIds.has(id)) return;
this.appdash.addMessage({
id,
type,
message,
dismissible: opts?.dismissible ?? true,
});
this.activeBannerIds.add(id);
}
private removeBanner(id: string): void {
if (!this.appdash) return;
if (!this.activeBannerIds.has(id)) {
// Also clear user-dismissed tracking so the banner can appear again if the problem recurs.
this.userDismissedIds.delete(id);
return;
}
this.appdash.removeMessage(id);
this.activeBannerIds.delete(id);
this.userDismissedIds.delete(id);
}
// ---------------------------------------------------------------------------
// Toast helper
// ---------------------------------------------------------------------------
private showToast(message: string, type: ToastType = 'info', duration = 3000): void {
if (Date.now() < this.suppressToastsUntil) return;
deesCatalog.DeesToast.show({ message, type, duration, position: 'top-right' });
}
}

View File

@@ -0,0 +1,248 @@
/**
* Browser-side WebRTC client — manages audio capture, playback, and peer connection.
*/
export interface IAudioDevices {
inputs: MediaDeviceInfo[];
outputs: MediaDeviceInfo[];
}
export class WebRtcClient {
private pc: RTCPeerConnection | null = null;
private localStream: MediaStream | null = null;
private remoteAudio: HTMLAudioElement | null = null;
private ws: WebSocket | null = null;
private sessionId: string;
private onStateChange: (state: string) => void;
// Audio analysis for level meters.
private localAnalyser: AnalyserNode | null = null;
private remoteAnalyser: AnalyserNode | null = null;
private audioCtx: AudioContext | null = null;
// Device selection.
private selectedInputId: string = '';
private selectedOutputId: string = '';
state: 'idle' | 'requesting-mic' | 'connecting' | 'connected' | 'error' = 'idle';
constructor(onStateChange: (state: string) => void) {
this.sessionId = `web-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`;
this.onStateChange = onStateChange;
}
setWebSocket(ws: WebSocket): void {
this.ws = ws;
}
setInputDevice(deviceId: string): void {
this.selectedInputId = deviceId;
}
setOutputDevice(deviceId: string): void {
this.selectedOutputId = deviceId;
if (this.remoteAudio && 'setSinkId' in this.remoteAudio) {
(this.remoteAudio as any).setSinkId(deviceId).catch(() => {});
}
}
handleSignaling(msg: { type: string; sessionId?: string; sdp?: string; candidate?: any; error?: string }): void {
if (msg.sessionId !== this.sessionId) return;
if (msg.type === 'webrtc-answer' && msg.sdp) {
this.handleAnswer(msg.sdp);
} else if (msg.type === 'webrtc-ice' && msg.candidate) {
this.handleRemoteIce(msg.candidate);
} else if (msg.type === 'webrtc-error') {
this.setState('error');
console.error('[webrtc] server error:', msg.error);
}
}
async startCall(): Promise<void> {
this.setState('requesting-mic');
const audioConstraints: MediaTrackConstraints = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
};
if (this.selectedInputId) {
audioConstraints.deviceId = { exact: this.selectedInputId };
}
try {
this.localStream = await navigator.mediaDevices.getUserMedia({
audio: audioConstraints,
video: false,
});
} catch (err) {
console.error('[webrtc] mic access denied:', err);
this.setState('error');
return;
}
this.setState('connecting');
// Set up AudioContext for level meters.
this.audioCtx = new AudioContext();
const localSource = this.audioCtx.createMediaStreamSource(this.localStream);
this.localAnalyser = this.audioCtx.createAnalyser();
this.localAnalyser.fftSize = 256;
localSource.connect(this.localAnalyser);
this.pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
// Add local audio track.
for (const track of this.localStream.getTracks()) {
this.pc.addTrack(track, this.localStream);
}
// Handle remote audio (incoming from SIP provider via proxy).
this.pc.ontrack = (event) => {
console.log('[webrtc] ontrack fired, streams:', event.streams.length);
this.remoteAudio = new Audio();
this.remoteAudio.autoplay = true;
this.remoteAudio.srcObject = event.streams[0] || new MediaStream([event.track]);
// Route to selected output device.
if (this.selectedOutputId && 'setSinkId' in this.remoteAudio) {
(this.remoteAudio as any).setSinkId(this.selectedOutputId).catch(() => {});
}
this.remoteAudio.play().catch((e) => console.warn('[webrtc] autoplay blocked:', e));
// Set up remote audio analyser for level meter.
if (this.audioCtx && event.streams[0]) {
const remoteSource = this.audioCtx.createMediaStreamSource(event.streams[0]);
this.remoteAnalyser = this.audioCtx.createAnalyser();
this.remoteAnalyser.fftSize = 256;
remoteSource.connect(this.remoteAnalyser);
}
};
// Send ICE candidates to server.
this.pc.onicecandidate = (event) => {
if (event.candidate) {
this.wsSend({
type: 'webrtc-ice',
sessionId: this.sessionId,
candidate: event.candidate.toJSON(),
});
}
};
this.pc.onconnectionstatechange = () => {
if (this.pc?.connectionState === 'connected') {
this.setState('connected');
} else if (this.pc?.connectionState === 'failed') {
this.setState('error');
}
};
// Create offer and send to server.
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
this.wsSend({
type: 'webrtc-offer',
sessionId: this.sessionId,
sdp: offer.sdp,
});
}
/** Get current mic input level (0-1). */
getLocalLevel(): number {
return this.getLevel(this.localAnalyser);
}
/** Get current remote audio level (0-1). */
getRemoteLevel(): number {
return this.getLevel(this.remoteAnalyser);
}
private getLevel(analyser: AnalyserNode | null): number {
if (!analyser) return 0;
const data = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteTimeDomainData(data);
let sum = 0;
for (let i = 0; i < data.length; i++) {
const v = (data[i] - 128) / 128;
sum += v * v;
}
return Math.sqrt(sum / data.length);
}
hangup(): void {
this.wsSend({
type: 'webrtc-hangup',
sessionId: this.sessionId,
});
this.cleanup();
this.setState('idle');
}
private async handleAnswer(sdp: string): Promise<void> {
if (!this.pc) return;
await this.pc.setRemoteDescription({ type: 'answer', sdp });
}
private async handleRemoteIce(candidate: RTCIceCandidateInit): Promise<void> {
if (!this.pc) return;
try {
await this.pc.addIceCandidate(candidate);
} catch (err) {
console.error('[webrtc] ice error:', err);
}
}
private setState(state: typeof this.state): void {
this.state = state;
this.onStateChange(state);
}
private cleanup(): void {
this.localStream?.getTracks().forEach((t) => t.stop());
this.localStream = null;
this.localAnalyser = null;
this.remoteAnalyser = null;
if (this.remoteAudio) {
this.remoteAudio.pause();
this.remoteAudio.srcObject = null;
this.remoteAudio = null;
}
this.audioCtx?.close().catch(() => {});
this.audioCtx = null;
this.pc?.close();
this.pc = null;
}
private wsSend(data: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
get id(): string {
return this.sessionId;
}
}
/** Enumerate audio input/output devices. */
export async function getAudioDevices(): Promise<IAudioDevices> {
try {
// Need to request mic first to get labels.
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach((t) => t.stop());
const devices = await navigator.mediaDevices.enumerateDevices();
return {
inputs: devices.filter((d) => d.kind === 'audioinput'),
outputs: devices.filter((d) => d.kind === 'audiooutput'),
};
} catch {
return { inputs: [], outputs: [] };
}
}