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.
200 lines
6.3 KiB
TypeScript
200 lines
6.3 KiB
TypeScript
/**
|
|
* Audio transcoding bridge — uses smartrust to communicate with the Rust
|
|
* opus-codec binary, which handles Opus ↔ G.722 ↔ PCMU/PCMA transcoding.
|
|
*
|
|
* All codec conversion happens in Rust (libopus + SpanDSP G.722 port).
|
|
* The TypeScript side just passes raw payloads back and forth.
|
|
*/
|
|
|
|
import path from 'node:path';
|
|
import { RustBridge } from '@push.rocks/smartrust';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Command type map for smartrust
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type TCodecCommands = {
|
|
init: {
|
|
params: Record<string, never>;
|
|
result: Record<string, never>;
|
|
};
|
|
create_session: {
|
|
params: { session_id: string };
|
|
result: Record<string, never>;
|
|
};
|
|
destroy_session: {
|
|
params: { session_id: string };
|
|
result: Record<string, never>;
|
|
};
|
|
transcode: {
|
|
params: { data_b64: string; from_pt: number; to_pt: number; session_id?: string; direction?: string };
|
|
result: { data_b64: string };
|
|
};
|
|
encode_pcm: {
|
|
params: { data_b64: string; sample_rate: number; to_pt: number; session_id?: string };
|
|
result: { data_b64: string };
|
|
};
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bridge singleton
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let bridge: RustBridge<TCodecCommands> | null = null;
|
|
let initialized = false;
|
|
|
|
function buildLocalPaths(): string[] {
|
|
const root = process.cwd();
|
|
return [
|
|
path.join(root, 'dist_rust', 'opus-codec'),
|
|
path.join(root, 'rust', 'target', 'release', 'opus-codec'),
|
|
path.join(root, 'rust', 'target', 'debug', 'opus-codec'),
|
|
];
|
|
}
|
|
|
|
let logFn: ((msg: string) => void) | undefined;
|
|
|
|
/**
|
|
* Initialize the audio transcoding bridge. Spawns the Rust binary.
|
|
*/
|
|
export async function initCodecBridge(log?: (msg: string) => void): Promise<boolean> {
|
|
if (initialized && bridge) return true;
|
|
logFn = log;
|
|
|
|
try {
|
|
bridge = new RustBridge<TCodecCommands>({
|
|
binaryName: 'opus-codec',
|
|
localPaths: buildLocalPaths(),
|
|
});
|
|
|
|
const spawned = await bridge.spawn();
|
|
if (!spawned) {
|
|
log?.('[codec] failed to spawn opus-codec binary');
|
|
bridge = null;
|
|
return false;
|
|
}
|
|
|
|
// Auto-restart: reset state when the Rust process exits so the next
|
|
// transcode attempt triggers re-initialization instead of silent failure.
|
|
bridge.on('exit', () => {
|
|
logFn?.('[codec] Rust audio transcoder process exited — will re-init on next use');
|
|
bridge = null;
|
|
initialized = false;
|
|
});
|
|
|
|
await bridge.sendCommand('init', {} as any);
|
|
initialized = true;
|
|
log?.('[codec] Rust audio transcoder initialized (Opus + G.722 + PCMU/PCMA)');
|
|
return true;
|
|
} catch (e: any) {
|
|
log?.(`[codec] init error: ${e.message}`);
|
|
bridge = null;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Session management — per-call codec isolation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create an isolated codec session. Each session gets its own Opus/G.722
|
|
* encoder/decoder state, preventing concurrent calls from corrupting each
|
|
* other's stateful codec predictions.
|
|
*/
|
|
export async function createSession(sessionId: string): Promise<boolean> {
|
|
if (!bridge || !initialized) {
|
|
// Attempt auto-reinit if bridge died.
|
|
const ok = await initCodecBridge(logFn);
|
|
if (!ok) return false;
|
|
}
|
|
try {
|
|
await bridge!.sendCommand('create_session', { session_id: sessionId });
|
|
return true;
|
|
} catch (e: any) {
|
|
logFn?.(`[codec] create_session error: ${e?.message || e}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Destroy a codec session, freeing its encoder/decoder state.
|
|
*/
|
|
export async function destroySession(sessionId: string): Promise<void> {
|
|
if (!bridge || !initialized) return;
|
|
try {
|
|
await bridge.sendCommand('destroy_session', { session_id: sessionId });
|
|
} catch {
|
|
// Best-effort cleanup.
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Transcoding
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Transcode an RTP payload between two codecs.
|
|
* All codec work (Opus, G.722, PCMU, PCMA) + resampling happens in Rust.
|
|
*
|
|
* @param data - raw RTP payload (no header)
|
|
* @param fromPT - source payload type (0=PCMU, 8=PCMA, 9=G.722, 111=Opus)
|
|
* @param toPT - target payload type
|
|
* @param sessionId - optional session for isolated codec state
|
|
* @returns transcoded payload, or null on failure
|
|
*/
|
|
export async function transcode(data: Buffer, fromPT: number, toPT: number, sessionId?: string, direction?: string): Promise<Buffer | null> {
|
|
if (!bridge || !initialized) return null;
|
|
try {
|
|
const params: any = {
|
|
data_b64: data.toString('base64'),
|
|
from_pt: fromPT,
|
|
to_pt: toPT,
|
|
};
|
|
if (sessionId) params.session_id = sessionId;
|
|
if (direction) params.direction = direction;
|
|
const result = await bridge.sendCommand('transcode', params);
|
|
return Buffer.from(result.data_b64, 'base64');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Encode raw 16-bit PCM to a target codec.
|
|
* @param pcmData - raw 16-bit LE PCM bytes
|
|
* @param sampleRate - input sample rate (e.g. 22050 for Piper TTS)
|
|
* @param toPT - target payload type (9=G.722, 111=Opus, 0=PCMU, 8=PCMA)
|
|
* @param sessionId - optional session for isolated codec state
|
|
*/
|
|
export async function encodePcm(pcmData: Buffer, sampleRate: number, toPT: number, sessionId?: string): Promise<Buffer | null> {
|
|
if (!bridge || !initialized) return null;
|
|
try {
|
|
const params: any = {
|
|
data_b64: pcmData.toString('base64'),
|
|
sample_rate: sampleRate,
|
|
to_pt: toPT,
|
|
};
|
|
if (sessionId) params.session_id = sessionId;
|
|
const result = await bridge.sendCommand('encode_pcm', params);
|
|
return Buffer.from(result.data_b64, 'base64');
|
|
} catch (e: any) {
|
|
console.error('[encodePcm] error:', e?.message || e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Check if the codec bridge is ready. */
|
|
export function isCodecReady(): boolean {
|
|
return initialized && bridge !== null;
|
|
}
|
|
|
|
/** Shut down the codec bridge. */
|
|
export function shutdownCodecBridge(): void {
|
|
if (bridge) {
|
|
try { bridge.kill(); } catch { /* ignore */ }
|
|
bridge = null;
|
|
initialized = false;
|
|
}
|
|
}
|