diff --git a/changelog.md b/changelog.md index ed7e489..3ff52f3 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-04-14 - 1.25.0 - feat(proxy-engine) +add live TTS streaming interactions and incoming number range support + +- add a new start_tts_interaction command and bridge API to begin IVR or leg interactions before full TTS rendering completes +- stream synthesized TTS chunks into the mixer with cancellation handling so prompts can stop cleanly on digit match, leg removal, or shutdown +- extract PCM-to-mixer frame conversion for reusable live prompt processing +- extend routing pattern matching to support numeric number ranges like start..end, including + prefixed values +- add incomingNumbers config typing and frontend config update support for single, range, and regex number modes + ## 2026-04-14 - 1.24.0 - feat(routing) require explicit inbound DID routes and normalize SIP identities for provider-based number matching diff --git a/rust/crates/proxy-engine/src/audio_player.rs b/rust/crates/proxy-engine/src/audio_player.rs index 6e08a53..4145b14 100644 --- a/rust/crates/proxy-engine/src/audio_player.rs +++ b/rust/crates/proxy-engine/src/audio_player.rs @@ -208,14 +208,23 @@ pub fn load_prompt_pcm_frames(wav_path: &str) -> Result>, String> { return Ok(vec![]); } + pcm_to_mix_frames(&samples, wav_rate) +} + +/// Convert PCM samples at an arbitrary rate into 48kHz 20ms mixer frames. +pub fn pcm_to_mix_frames(samples: &[f32], sample_rate: u32) -> Result>, String> { + if samples.is_empty() { + return Ok(vec![]); + } + // Resample to MIX_RATE (48kHz) if needed. - let resampled = if wav_rate != MIX_RATE { + let resampled = if sample_rate != MIX_RATE { let mut transcoder = TranscodeState::new().map_err(|e| format!("codec init: {e}"))?; transcoder - .resample_f32(&samples, wav_rate, MIX_RATE) + .resample_f32(samples, sample_rate, MIX_RATE) .map_err(|e| format!("resample: {e}"))? } else { - samples + samples.to_vec() }; // Split into MIX_FRAME_SIZE (960) sample frames. diff --git a/rust/crates/proxy-engine/src/call_manager.rs b/rust/crates/proxy-engine/src/call_manager.rs index 29dbed1..00b0322 100644 --- a/rust/crates/proxy-engine/src/call_manager.rs +++ b/rust/crates/proxy-engine/src/call_manager.rs @@ -1942,24 +1942,23 @@ impl CallManager { } } - // Generate IVR prompt on-demand via TTS (cached). + // Generate the IVR prompt as a live chunked TTS stream so playback can + // start after the first chunk instead of waiting for a full WAV render. let voice = menu.prompt_voice.as_deref().unwrap_or("af_bella"); - let prompt_output = format!(".nogit/tts/ivr-menu-{}.wav", menu.id); - let prompt_params = serde_json::json!({ - "model": ".nogit/tts/kokoro-v1.0.onnx", - "voices": ".nogit/tts/voices.bin", - "voice": voice, - "text": &menu.prompt_text, - "output": &prompt_output, - "cacheable": true, - }); - - let prompt_wav = { + let live_prompt = { let mut tts = tts_engine.lock().await; - match tts.generate(&prompt_params).await { - Ok(_) => Some(prompt_output), + match tts + .start_live_prompt(crate::tts::TtsPromptRequest { + model_path: crate::tts::DEFAULT_MODEL_PATH.to_string(), + voices_path: crate::tts::DEFAULT_VOICES_PATH.to_string(), + voice_name: voice.to_string(), + text: menu.prompt_text.clone(), + }) + .await + { + Ok(prompt) => Some(prompt), Err(e) => { - eprintln!("[ivr] TTS generation failed: {e}"); + eprintln!("[ivr] live TTS setup failed: {e}"); None } } @@ -1976,17 +1975,14 @@ impl CallManager { let timeout_ms = menu.timeout_sec.unwrap_or(5) * 1000; tokio::spawn(async move { - // Load prompt PCM frames if available. - let prompt_frames = prompt_wav - .as_ref() - .and_then(|wav| crate::audio_player::load_prompt_pcm_frames(wav).ok()); - - if let Some(frames) = prompt_frames { + if let Some(prompt) = live_prompt { let (result_tx, result_rx) = tokio::sync::oneshot::channel(); let _ = mixer_cmd_tx .send(crate::mixer::MixerCommand::StartInteraction { leg_id: provider_leg_id.clone(), - prompt_pcm_frames: frames, + prompt_pcm_frames: prompt.initial_frames, + prompt_stream_rx: Some(prompt.stream_rx), + prompt_cancel_tx: Some(prompt.cancel_tx), expected_digits: expected_digits.clone(), timeout_ms, result_tx, diff --git a/rust/crates/proxy-engine/src/config.rs b/rust/crates/proxy-engine/src/config.rs index 06fa2e4..ee53b17 100644 --- a/rust/crates/proxy-engine/src/config.rs +++ b/rust/crates/proxy-engine/src/config.rs @@ -273,8 +273,56 @@ pub fn normalize_routing_identity(value: &str) -> String { digits } +fn parse_numeric_range_value(value: &str) -> Option<(bool, &str)> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + + let (has_plus, digits) = if let Some(rest) = trimmed.strip_prefix('+') { + (true, rest) + } else { + (false, trimmed) + }; + + if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) { + return None; + } + + Some((has_plus, digits)) +} + +fn matches_numeric_range_pattern(pattern: &str, value: &str) -> bool { + let Some((start, end)) = pattern.split_once("..") else { + return false; + }; + + let Some((start_plus, start_digits)) = parse_numeric_range_value(start) else { + return false; + }; + let Some((end_plus, end_digits)) = parse_numeric_range_value(end) else { + return false; + }; + let Some((value_plus, value_digits)) = parse_numeric_range_value(value) else { + return false; + }; + + if start_plus != end_plus || value_plus != start_plus { + return false; + } + if start_digits.len() != end_digits.len() || value_digits.len() != start_digits.len() { + return false; + } + if start_digits > end_digits { + return false; + } + + value_digits >= start_digits && value_digits <= end_digits +} + /// Test a value against a pattern string. /// - None/empty: matches everything (wildcard) +/// - `start..end`: numeric range match /// - Trailing '*': prefix match /// - Starts with '/': regex match /// - Otherwise: exact match @@ -290,6 +338,10 @@ pub fn matches_pattern(pattern: Option<&str>, value: &str) -> bool { return value.starts_with(&pattern[..pattern.len() - 1]); } + if matches_numeric_range_pattern(pattern, value) { + return true; + } + // Regex match: "/^\\+49/" or "/pattern/i" if pattern.starts_with('/') { if let Some(last_slash) = pattern[1..].rfind('/') { @@ -579,4 +631,24 @@ mod tests { assert_eq!(support.no_answer_timeout, Some(20)); assert!(!support.ring_browsers); } + + #[test] + fn matches_pattern_supports_numeric_ranges() { + assert!(matches_pattern( + Some("042116767546..042116767548"), + "042116767547" + )); + assert!(!matches_pattern( + Some("042116767546..042116767548"), + "042116767549" + )); + assert!(matches_pattern( + Some("+4942116767546..+4942116767548"), + "+4942116767547" + )); + assert!(!matches_pattern( + Some("+4942116767546..+4942116767548"), + "042116767547" + )); + } } diff --git a/rust/crates/proxy-engine/src/main.rs b/rust/crates/proxy-engine/src/main.rs index 7f7d57b..3e63177 100644 --- a/rust/crates/proxy-engine/src/main.rs +++ b/rust/crates/proxy-engine/src/main.rs @@ -152,6 +152,7 @@ async fn handle_command( "replace_leg" => handle_replace_leg(engine, out_tx, &cmd).await, // Leg interaction and tool leg commands. "start_interaction" => handle_start_interaction(engine, out_tx, &cmd).await, + "start_tts_interaction" => handle_start_tts_interaction(engine, out_tx, &cmd).await, "add_tool_leg" => handle_add_tool_leg(engine, out_tx, &cmd).await, "remove_tool_leg" => handle_remove_tool_leg(engine, out_tx, &cmd).await, "set_leg_metadata" => handle_set_leg_metadata(engine, out_tx, &cmd).await, @@ -1138,6 +1139,79 @@ async fn handle_webrtc_close(webrtc: Arc>, out_tx: &OutTx, c // Leg interaction & tool leg commands // --------------------------------------------------------------------------- +async fn run_interaction_command( + engine: Arc>, + out_tx: &OutTx, + cmd: &Command, + call_id: String, + leg_id: String, + prompt_pcm_frames: Vec>, + prompt_stream_rx: Option>, + prompt_cancel_tx: Option>, + expected_digits: Vec, + timeout_ms: u32, +) { + let (result_tx, result_rx) = tokio::sync::oneshot::channel(); + + { + let eng = engine.lock().await; + let call = match eng.call_mgr.calls.get(&call_id) { + Some(c) => c, + None => { + respond_err(out_tx, &cmd.id, &format!("call {call_id} not found")); + return; + } + }; + let _ = call + .mixer_cmd_tx + .send(crate::mixer::MixerCommand::StartInteraction { + leg_id: leg_id.clone(), + prompt_pcm_frames, + prompt_stream_rx, + prompt_cancel_tx, + expected_digits: expected_digits.clone(), + timeout_ms, + result_tx, + }) + .await; + } + + let safety_timeout = tokio::time::Duration::from_millis(timeout_ms as u64 + 30000); + let result = match tokio::time::timeout(safety_timeout, result_rx).await { + Ok(Ok(r)) => r, + Ok(Err(_)) => crate::mixer::InteractionResult::Cancelled, + Err(_) => crate::mixer::InteractionResult::Timeout, + }; + + let (result_str, digit_str) = match &result { + crate::mixer::InteractionResult::Digit(d) => ("digit", Some(d.to_string())), + crate::mixer::InteractionResult::Timeout => ("timeout", None), + crate::mixer::InteractionResult::Cancelled => ("cancelled", None), + }; + + { + let mut eng = engine.lock().await; + if let Some(call) = eng.call_mgr.calls.get_mut(&call_id) { + if let Some(leg) = call.legs.get_mut(&leg_id) { + leg.metadata.insert( + "last_interaction_result".to_string(), + serde_json::json!(result_str), + ); + if let Some(ref d) = digit_str { + leg.metadata + .insert("last_interaction_digit".to_string(), serde_json::json!(d)); + } + } + } + } + + let mut resp = serde_json::json!({ "result": result_str }); + if let Some(d) = digit_str { + resp["digit"] = serde_json::json!(d); + } + respond_ok(out_tx, &cmd.id, resp); +} + /// Handle `start_interaction` — isolate a leg, play a prompt, collect DTMF. /// This command blocks until the interaction completes (digit, timeout, or cancel). async fn handle_start_interaction(engine: Arc>, out_tx: &OutTx, cmd: &Command) { @@ -1175,7 +1249,6 @@ async fn handle_start_interaction(engine: Arc>, out_tx: &OutT .and_then(|v| v.as_u64()) .unwrap_or(15000) as u32; - // Load prompt audio from WAV file. let prompt_frames = match crate::audio_player::load_prompt_pcm_frames(&prompt_wav) { Ok(f) => f, Err(e) => { @@ -1184,67 +1257,113 @@ async fn handle_start_interaction(engine: Arc>, out_tx: &OutT } }; - // Create oneshot channel for the result. - let (result_tx, result_rx) = tokio::sync::oneshot::channel(); + run_interaction_command( + engine, + out_tx, + cmd, + call_id, + leg_id, + prompt_frames, + None, + None, + expected_digits, + timeout_ms, + ) + .await; +} - // Send StartInteraction to the mixer. - { - let eng = engine.lock().await; - let call = match eng.call_mgr.calls.get(&call_id) { - Some(c) => c, - None => { - respond_err(out_tx, &cmd.id, &format!("call {call_id} not found")); +/// Handle `start_tts_interaction` — isolate a leg, stream chunked TTS, and +/// start playback before the full prompt has rendered. +async fn handle_start_tts_interaction( + engine: Arc>, + out_tx: &OutTx, + cmd: &Command, +) { + let call_id = match cmd.params.get("call_id").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => { + respond_err(out_tx, &cmd.id, "missing call_id"); + return; + } + }; + let leg_id = match cmd.params.get("leg_id").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => { + respond_err(out_tx, &cmd.id, "missing leg_id"); + return; + } + }; + let text = match cmd.params.get("text").and_then(|v| v.as_str()) { + Some(s) if !s.trim().is_empty() => s.to_string(), + _ => { + respond_err(out_tx, &cmd.id, "missing text"); + return; + } + }; + let voice = cmd + .params + .get("voice") + .and_then(|v| v.as_str()) + .unwrap_or("af_bella") + .to_string(); + let model = cmd + .params + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or(tts::DEFAULT_MODEL_PATH) + .to_string(); + let voices = cmd + .params + .get("voices") + .and_then(|v| v.as_str()) + .unwrap_or(tts::DEFAULT_VOICES_PATH) + .to_string(); + let expected_digits: Vec = cmd + .params + .get("expected_digits") + .and_then(|v| v.as_str()) + .unwrap_or("12") + .chars() + .collect(); + let timeout_ms = cmd + .params + .get("timeout_ms") + .and_then(|v| v.as_u64()) + .unwrap_or(15000) as u32; + + let tts_engine = engine.lock().await.tts_engine.clone(); + let live_prompt = { + let mut tts = tts_engine.lock().await; + match tts + .start_live_prompt(tts::TtsPromptRequest { + model_path: model, + voices_path: voices, + voice_name: voice, + text, + }) + .await + { + Ok(prompt) => prompt, + Err(e) => { + respond_err(out_tx, &cmd.id, &e); return; } - }; - let _ = call - .mixer_cmd_tx - .send(crate::mixer::MixerCommand::StartInteraction { - leg_id: leg_id.clone(), - prompt_pcm_frames: prompt_frames, - expected_digits: expected_digits.clone(), - timeout_ms, - result_tx, - }) - .await; - } // engine lock released — we block on the oneshot, not the lock. - - // Await the interaction result (blocks this task until complete). - let safety_timeout = tokio::time::Duration::from_millis(timeout_ms as u64 + 30000); - let result = match tokio::time::timeout(safety_timeout, result_rx).await { - Ok(Ok(r)) => r, - Ok(Err(_)) => crate::mixer::InteractionResult::Cancelled, // oneshot dropped - Err(_) => crate::mixer::InteractionResult::Timeout, // safety timeout - }; - - // Store consent result in leg metadata. - let (result_str, digit_str) = match &result { - crate::mixer::InteractionResult::Digit(d) => ("digit", Some(d.to_string())), - crate::mixer::InteractionResult::Timeout => ("timeout", None), - crate::mixer::InteractionResult::Cancelled => ("cancelled", None), - }; - - { - let mut eng = engine.lock().await; - if let Some(call) = eng.call_mgr.calls.get_mut(&call_id) { - if let Some(leg) = call.legs.get_mut(&leg_id) { - leg.metadata.insert( - "last_interaction_result".to_string(), - serde_json::json!(result_str), - ); - if let Some(ref d) = digit_str { - leg.metadata - .insert("last_interaction_digit".to_string(), serde_json::json!(d)); - } - } } - } + }; - let mut resp = serde_json::json!({ "result": result_str }); - if let Some(d) = digit_str { - resp["digit"] = serde_json::json!(d); - } - respond_ok(out_tx, &cmd.id, resp); + run_interaction_command( + engine, + out_tx, + cmd, + call_id, + leg_id, + live_prompt.initial_frames, + Some(live_prompt.stream_rx), + Some(live_prompt.cancel_tx), + expected_digits, + timeout_ms, + ) + .await; } /// Handle `add_tool_leg` — add a recording or transcription tool leg to a call. diff --git a/rust/crates/proxy-engine/src/mixer.rs b/rust/crates/proxy-engine/src/mixer.rs index 0b168cc..d5421b2 100644 --- a/rust/crates/proxy-engine/src/mixer.rs +++ b/rust/crates/proxy-engine/src/mixer.rs @@ -18,10 +18,11 @@ use crate::ipc::{emit_event, OutTx}; use crate::jitter_buffer::{JitterBuffer, JitterResult}; use crate::rtp::{build_rtp_header, rtp_clock_increment, rtp_clock_rate}; +use crate::tts::TtsStreamMessage; use codec_lib::{codec_sample_rate, new_denoiser, TranscodeState}; use nnnoiseless::DenoiseState; use std::collections::{HashMap, VecDeque}; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::{mpsc, oneshot, watch}; use tokio::task::JoinHandle; use tokio::time::{self, Duration, MissedTickBehavior}; @@ -64,6 +65,12 @@ enum LegRole { struct IsolationState { /// PCM frames at MIX_RATE (960 samples each, 48kHz f32) queued for playback. prompt_frames: VecDeque>, + /// Live TTS frames arrive here while playback is already in progress. + prompt_stream_rx: Option>, + /// Cancels the background TTS producer when the interaction ends early. + prompt_cancel_tx: Option>, + /// Whether the live prompt stream has ended. + prompt_stream_finished: bool, /// Digits that complete the interaction (e.g., ['1', '2']). expected_digits: Vec, /// Ticks remaining before timeout (decremented each tick after prompt ends). @@ -140,6 +147,10 @@ pub enum MixerCommand { leg_id: String, /// PCM frames at MIX_RATE (48kHz f32), each 960 samples. prompt_pcm_frames: Vec>, + /// Optional live prompt stream. Frames are appended as they are synthesized. + prompt_stream_rx: Option>, + /// Optional cancellation handle for the live prompt stream. + prompt_cancel_tx: Option>, expected_digits: Vec, timeout_ms: u32, result_tx: oneshot::Sender, @@ -329,9 +340,11 @@ fn fill_leg_playout_buffer(slot: &mut MixerLegSlot) { match slot.jitter.consume() { JitterResult::Packet(pkt) => queue_inbound_packet(slot, pkt), JitterResult::Missing => { - let conceal_ts = slot.estimated_packet_ts.max(rtp_clock_increment(slot.codec_pt)); - let conceal_samples = rtp_ts_to_mix_samples(slot.codec_pt, conceal_ts) - .clamp(1, MAX_GAP_FILL_SAMPLES); + let conceal_ts = slot + .estimated_packet_ts + .max(rtp_clock_increment(slot.codec_pt)); + let conceal_samples = + rtp_ts_to_mix_samples(slot.codec_pt, conceal_ts).clamp(1, MAX_GAP_FILL_SAMPLES); append_packet_loss_concealment(slot, conceal_samples); if let Some(expected_ts) = slot.expected_rtp_timestamp { slot.expected_rtp_timestamp = Some(expected_ts.wrapping_add(conceal_ts)); @@ -418,6 +431,64 @@ fn try_send_tool_output( ); } +fn cancel_prompt_producer(state: &mut IsolationState) { + if let Some(cancel_tx) = state.prompt_cancel_tx.take() { + let _ = cancel_tx.send(true); + } +} + +fn cancel_isolated_interaction(state: &mut IsolationState) { + cancel_prompt_producer(state); + if let Some(tx) = state.result_tx.take() { + let _ = tx.send(InteractionResult::Cancelled); + } +} + +fn drain_prompt_stream( + out_tx: &OutTx, + call_id: &str, + leg_id: &str, + state: &mut IsolationState, +) { + loop { + let Some(mut stream_rx) = state.prompt_stream_rx.take() else { + return; + }; + + match stream_rx.try_recv() { + Ok(TtsStreamMessage::Frames(frames)) => { + state.prompt_frames.extend(frames); + state.prompt_stream_rx = Some(stream_rx); + } + Ok(TtsStreamMessage::Finished) => { + state.prompt_stream_finished = true; + return; + } + Ok(TtsStreamMessage::Failed(error)) => { + emit_event( + out_tx, + "mixer_error", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "error": format!("tts stream failed: {error}"), + }), + ); + state.prompt_stream_finished = true; + return; + } + Err(mpsc::error::TryRecvError::Empty) => { + state.prompt_stream_rx = Some(stream_rx); + return; + } + Err(mpsc::error::TryRecvError::Disconnected) => { + state.prompt_stream_finished = true; + return; + } + } + } +} + /// Spawn the mixer task for a call. Returns the command sender and task handle. pub fn spawn_mixer(call_id: String, out_tx: OutTx) -> (mpsc::Sender, JoinHandle<()>) { let (cmd_tx, cmd_rx) = mpsc::channel::(32); @@ -489,9 +560,7 @@ async fn mixer_loop(call_id: String, mut cmd_rx: mpsc::Receiver, o // If the leg is isolated, send Cancelled before dropping. if let Some(slot) = legs.get_mut(&leg_id) { if let LegRole::Isolated(ref mut state) = slot.role { - if let Some(tx) = state.result_tx.take() { - let _ = tx.send(InteractionResult::Cancelled); - } + cancel_isolated_interaction(state); } } legs.remove(&leg_id); @@ -501,9 +570,7 @@ async fn mixer_loop(call_id: String, mut cmd_rx: mpsc::Receiver, o // Cancel all outstanding interactions before shutting down. for slot in legs.values_mut() { if let LegRole::Isolated(ref mut state) = slot.role { - if let Some(tx) = state.result_tx.take() { - let _ = tx.send(InteractionResult::Cancelled); - } + cancel_isolated_interaction(state); } } return; @@ -511,6 +578,8 @@ async fn mixer_loop(call_id: String, mut cmd_rx: mpsc::Receiver, o Ok(MixerCommand::StartInteraction { leg_id, prompt_pcm_frames, + prompt_stream_rx, + prompt_cancel_tx, expected_digits, timeout_ms, result_tx, @@ -518,13 +587,14 @@ async fn mixer_loop(call_id: String, mut cmd_rx: mpsc::Receiver, o if let Some(slot) = legs.get_mut(&leg_id) { // Cancel any existing interaction first. if let LegRole::Isolated(ref mut old_state) = slot.role { - if let Some(tx) = old_state.result_tx.take() { - let _ = tx.send(InteractionResult::Cancelled); - } + cancel_isolated_interaction(old_state); } let timeout_ticks = timeout_ms / 20; slot.role = LegRole::Isolated(IsolationState { prompt_frames: VecDeque::from(prompt_pcm_frames), + prompt_stream_rx, + prompt_cancel_tx, + prompt_stream_finished: false, expected_digits, timeout_ticks_remaining: timeout_ticks, prompt_done: false, @@ -532,6 +602,9 @@ async fn mixer_loop(call_id: String, mut cmd_rx: mpsc::Receiver, o }); } else { // Leg not found — immediately cancel. + if let Some(cancel_tx) = prompt_cancel_tx { + let _ = cancel_tx.send(true); + } let _ = result_tx.send(InteractionResult::Cancelled); } } @@ -667,6 +740,8 @@ async fn mixer_loop(call_id: String, mut cmd_rx: mpsc::Receiver, o try_send_leg_output(&out_tx, &call_id, lid, slot, rtp, "participant-audio"); } LegRole::Isolated(state) => { + drain_prompt_stream(&out_tx, &call_id, lid, state); + // Check for DTMF digit from this leg. let mut matched_digit: Option = None; for (src_lid, dtmf_pkt) in &dtmf_forward { @@ -692,9 +767,12 @@ async fn mixer_loop(call_id: String, mut cmd_rx: mpsc::Receiver, o // Interaction complete — digit matched. completed_interactions.push((lid.clone(), InteractionResult::Digit(digit))); } else { - // Play prompt frame or silence. + // Play prompt frame, wait for live TTS, or move to timeout once the + // prompt stream has fully drained. let pcm_frame = if let Some(frame) = state.prompt_frames.pop_front() { frame + } else if !state.prompt_stream_finished { + vec![0.0f32; MIX_FRAME_SIZE] } else { state.prompt_done = true; vec![0.0f32; MIX_FRAME_SIZE] @@ -759,6 +837,7 @@ async fn mixer_loop(call_id: String, mut cmd_rx: mpsc::Receiver, o for (lid, result) in completed_interactions { if let Some(slot) = legs.get_mut(&lid) { if let LegRole::Isolated(ref mut state) = slot.role { + cancel_prompt_producer(state); if let Some(tx) = state.result_tx.take() { let _ = tx.send(result); } @@ -822,14 +901,7 @@ async fn mixer_loop(call_id: String, mut cmd_rx: mpsc::Receiver, o rtp_out.extend_from_slice(&dtmf_pkt.payload); target_slot.rtp_seq = target_slot.rtp_seq.wrapping_add(1); // Don't increment rtp_ts for DTMF — it shares timestamp context with audio. - try_send_leg_output( - &out_tx, - &call_id, - target_lid, - target_slot, - rtp_out, - "dtmf", - ); + try_send_leg_output(&out_tx, &call_id, target_lid, target_slot, rtp_out, "dtmf"); } } } diff --git a/rust/crates/proxy-engine/src/tts.rs b/rust/crates/proxy-engine/src/tts.rs index da0836a..990fcff 100644 --- a/rust/crates/proxy-engine/src/tts.rs +++ b/rust/crates/proxy-engine/src/tts.rs @@ -9,12 +9,41 @@ //! Callers never need to check for cached files — that is entirely this module's //! responsibility. +use crate::audio_player::pcm_to_mix_frames; use kokoro_tts::{KokoroTts, Voice}; use std::path::Path; +use std::sync::Arc; +use tokio::sync::{mpsc, watch}; + +pub const DEFAULT_MODEL_PATH: &str = ".nogit/tts/kokoro-v1.0.onnx"; +pub const DEFAULT_VOICES_PATH: &str = ".nogit/tts/voices.bin"; +const TTS_OUTPUT_RATE: u32 = 24000; +const MAX_CHUNK_CHARS: usize = 220; +const MIN_CHUNK_CHARS: usize = 80; + +pub enum TtsStreamMessage { + Frames(Vec>), + Finished, + Failed(String), +} + +pub struct TtsLivePrompt { + pub initial_frames: Vec>, + pub stream_rx: mpsc::Receiver, + pub cancel_tx: watch::Sender, +} + +#[derive(Clone)] +pub struct TtsPromptRequest { + pub model_path: String, + pub voices_path: String, + pub voice_name: String, + pub text: String, +} /// Wraps the Kokoro TTS engine with lazy model loading. pub struct TtsEngine { - tts: Option, + tts: Option>, /// Path that was used to load the current model (for cache invalidation). loaded_model_path: String, loaded_voices_path: String, @@ -29,6 +58,69 @@ impl TtsEngine { } } + async fn ensure_loaded( + &mut self, + model_path: &str, + voices_path: &str, + ) -> Result, String> { + if !Path::new(model_path).exists() { + return Err(format!("model not found: {model_path}")); + } + if !Path::new(voices_path).exists() { + return Err(format!("voices not found: {voices_path}")); + } + + if self.tts.is_none() + || self.loaded_model_path != model_path + || self.loaded_voices_path != voices_path + { + eprintln!("[tts] loading model: {model_path}"); + let tts = Arc::new( + KokoroTts::new(model_path, voices_path) + .await + .map_err(|e| format!("model load failed: {e:?}"))?, + ); + self.tts = Some(tts); + self.loaded_model_path = model_path.to_string(); + self.loaded_voices_path = voices_path.to_string(); + } + + Ok(self.tts.as_ref().unwrap().clone()) + } + + pub async fn start_live_prompt( + &mut self, + request: TtsPromptRequest, + ) -> Result { + if request.text.trim().is_empty() { + return Err("empty text".into()); + } + + let tts = self + .ensure_loaded(&request.model_path, &request.voices_path) + .await?; + let voice = select_voice(&request.voice_name); + let chunks = chunk_text(&request.text); + if chunks.is_empty() { + return Err("empty text".into()); + } + + let initial_frames = synth_text_to_mix_frames(&tts, chunks[0].as_str(), voice).await?; + let remaining_chunks: Vec = chunks.into_iter().skip(1).collect(); + let (stream_tx, stream_rx) = mpsc::channel(8); + let (cancel_tx, cancel_rx) = watch::channel(false); + + tokio::spawn(async move { + stream_live_prompt_chunks(tts, voice, remaining_chunks, stream_tx, cancel_rx).await; + }); + + Ok(TtsLivePrompt { + initial_frames, + stream_rx, + cancel_tx, + }) + } + /// Generate a WAV file from text. /// /// Params (from IPC JSON): @@ -78,37 +170,15 @@ impl TtsEngine { return Ok(serde_json::json!({ "output": output_path })); } - // Check that model/voices files exist. - if !Path::new(model_path).exists() { - return Err(format!("model not found: {model_path}")); - } - if !Path::new(voices_path).exists() { - return Err(format!("voices not found: {voices_path}")); - } - // Ensure parent directory exists. if let Some(parent) = Path::new(output_path).parent() { let _ = std::fs::create_dir_all(parent); } - // Lazy-load or reload if paths changed. - if self.tts.is_none() - || self.loaded_model_path != model_path - || self.loaded_voices_path != voices_path - { - eprintln!("[tts] loading model: {model_path}"); - let tts = KokoroTts::new(model_path, voices_path) - .await - .map_err(|e| format!("model load failed: {e:?}"))?; - self.tts = Some(tts); - self.loaded_model_path = model_path.to_string(); - self.loaded_voices_path = voices_path.to_string(); - } - - let tts = self.tts.as_ref().unwrap(); + let tts = self.ensure_loaded(model_path, voices_path).await?; let voice = select_voice(voice_name); - eprintln!("[tts] synthesizing voice '{voice_name}': \"{text}\""); + eprintln!("[tts] synthesizing WAV voice '{voice_name}' to {output_path}"); let (samples, duration) = tts .synth(text, voice) .await @@ -175,6 +245,106 @@ impl TtsEngine { } } +async fn synth_text_to_mix_frames( + tts: &Arc, + text: &str, + voice: Voice, +) -> Result>, String> { + let (samples, duration) = tts + .synth(text, voice) + .await + .map_err(|e| format!("synthesis failed: {e:?}"))?; + eprintln!( + "[tts] synthesized chunk ({} chars, {} samples) in {duration:?}", + text.chars().count(), + samples.len() + ); + pcm_to_mix_frames(&samples, TTS_OUTPUT_RATE) +} + +async fn stream_live_prompt_chunks( + tts: Arc, + voice: Voice, + chunks: Vec, + stream_tx: mpsc::Sender, + mut cancel_rx: watch::Receiver, +) { + for chunk in chunks { + if *cancel_rx.borrow() { + break; + } + + match synth_text_to_mix_frames(&tts, &chunk, voice).await { + Ok(frames) => { + if *cancel_rx.borrow() { + break; + } + if stream_tx.send(TtsStreamMessage::Frames(frames)).await.is_err() { + return; + } + } + Err(error) => { + let _ = stream_tx.send(TtsStreamMessage::Failed(error)).await; + return; + } + } + + if cancel_rx.has_changed().unwrap_or(false) && *cancel_rx.borrow_and_update() { + break; + } + } + + let _ = stream_tx.send(TtsStreamMessage::Finished).await; +} + +fn chunk_text(text: &str) -> Vec { + let mut chunks = Vec::new(); + let mut current = String::new(); + + for ch in text.chars() { + current.push(ch); + + let len = current.chars().count(); + let hard_split = len >= MAX_CHUNK_CHARS && (ch.is_whitespace() || is_soft_boundary(ch)); + let natural_split = len >= MIN_CHUNK_CHARS && is_sentence_boundary(ch); + + if natural_split || hard_split { + push_chunk(&mut chunks, &mut current); + } + } + + push_chunk(&mut chunks, &mut current); + + if chunks.len() >= 2 { + let last_len = chunks.last().unwrap().chars().count(); + if last_len < (MIN_CHUNK_CHARS / 2) { + let tail = chunks.pop().unwrap(); + if let Some(prev) = chunks.last_mut() { + prev.push(' '); + prev.push_str(tail.trim()); + } + } + } + + chunks +} + +fn push_chunk(chunks: &mut Vec, current: &mut String) { + let trimmed = current.trim(); + if !trimmed.is_empty() { + chunks.push(trimmed.to_string()); + } + current.clear(); +} + +fn is_sentence_boundary(ch: char) -> bool { + matches!(ch, '.' | '!' | '?' | '\n' | ';' | ':') +} + +fn is_soft_boundary(ch: char) -> bool { + matches!(ch, ',' | ';' | ':' | ')' | ']' | '\n') +} + /// Map voice name string to Kokoro Voice enum variant. fn select_voice(name: &str) -> Voice { match name { diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 0f3c109..fa01293 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: 'siprouter', - version: '1.24.0', + version: '1.25.0', description: 'undefined' } diff --git a/ts/config.ts b/ts/config.ts index 98921cb..4d77d5e 100644 --- a/ts/config.ts +++ b/ts/config.ts @@ -48,6 +48,24 @@ export interface IDeviceConfig { extension: string; } +export type TIncomingNumberMode = 'single' | 'range' | 'regex'; + +export interface IIncomingNumberConfig { + id: string; + label: string; + providerId?: string; + mode: TIncomingNumberMode; + countryCode?: string; + areaCode?: string; + localNumber?: string; + rangeEnd?: string; + pattern?: string; + + // Legacy persisted fields kept for migration compatibility. + number?: string; + rangeStart?: string; +} + // --------------------------------------------------------------------------- // Match/Action routing model // --------------------------------------------------------------------------- @@ -66,7 +84,7 @@ export interface ISipRouteMatch { * * Inbound: matches the provider-delivered DID / Request-URI user part. * Outbound: matches the normalized dialed digits. - * Supports: exact string, prefix with trailing '*' (e.g. "+4930*"), or regex ("/^\\+49/"). + * Supports: exact string, numeric range `start..end`, prefix with trailing '*' (e.g. "+4930*"), or regex ("/^\\+49/"). */ numberPattern?: string; @@ -234,6 +252,7 @@ export interface IAppConfig { proxy: IProxyConfig; providers: IProviderConfig[]; devices: IDeviceConfig[]; + incomingNumbers?: IIncomingNumberConfig[]; routing: IRoutingConfig; contacts: IContact[]; voiceboxes?: IVoiceboxConfig[]; @@ -288,6 +307,14 @@ export function loadConfig(): IAppConfig { d.extension ??= '100'; } + cfg.incomingNumbers ??= []; + for (const incoming of cfg.incomingNumbers) { + if (!incoming.id) incoming.id = `incoming-${Date.now()}`; + incoming.label ??= incoming.id; + incoming.mode ??= incoming.pattern ? 'regex' : incoming.rangeStart || incoming.rangeEnd ? 'range' : 'single'; + incoming.countryCode ??= incoming.mode === 'regex' ? undefined : '+49'; + } + cfg.routing ??= { routes: [] }; cfg.routing.routes ??= []; diff --git a/ts/frontend.ts b/ts/frontend.ts index ec7c38f..69a1f40 100644 --- a/ts/frontend.ts +++ b/ts/frontend.ts @@ -266,6 +266,7 @@ async function handleRequest( if (existing && ud.displayName !== undefined) existing.displayName = ud.displayName; } } + if (updates.incomingNumbers !== undefined) cfg.incomingNumbers = updates.incomingNumbers; if (updates.routing) { if (updates.routing.routes) { cfg.routing.routes = updates.routing.routes; diff --git a/ts/proxybridge.ts b/ts/proxybridge.ts index 91187b9..31c1a58 100644 --- a/ts/proxybridge.ts +++ b/ts/proxybridge.ts @@ -82,6 +82,19 @@ type TProxyCommands = { }; result: { result: 'digit' | 'timeout' | 'cancelled'; digit?: string }; }; + start_tts_interaction: { + params: { + call_id: string; + leg_id: string; + text: string; + voice?: string; + model?: string; + voices?: string; + expected_digits: string; + timeout_ms: number; + }; + result: { result: 'digit' | 'timeout' | 'cancelled'; digit?: string }; + }; add_tool_leg: { params: { call_id: string; @@ -446,6 +459,40 @@ export async function startInteraction( } } +/** + * Start a live TTS interaction on a specific leg. The first chunk is rendered + * up front and the rest streams into the mixer while playback is already live. + */ +export async function startTtsInteraction( + callId: string, + legId: string, + text: string, + expectedDigits: string, + timeoutMs: number, + options?: { + voice?: string; + model?: string; + voices?: string; + }, +): Promise<{ result: 'digit' | 'timeout' | 'cancelled'; digit?: string } | null> { + if (!bridge || !initialized) return null; + try { + return await sendProxyCommand('start_tts_interaction', { + call_id: callId, + leg_id: legId, + text, + expected_digits: expectedDigits, + timeout_ms: timeoutMs, + voice: options?.voice, + model: options?.model, + voices: options?.voices, + }); + } catch (error: unknown) { + logFn?.(`[proxy-engine] start_tts_interaction error: ${errorMessage(error)}`); + return null; + } +} + /** * Add a tool leg (recording or transcription) to a call. * Tool legs receive per-source unmerged audio from all participants. diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 0f3c109..fa01293 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: 'siprouter', - version: '1.24.0', + version: '1.25.0', description: 'undefined' } diff --git a/ts_web/elements/sipproxy-view-routes.ts b/ts_web/elements/sipproxy-view-routes.ts index fc21fc8..58b476f 100644 --- a/ts_web/elements/sipproxy-view-routes.ts +++ b/ts_web/elements/sipproxy-view-routes.ts @@ -2,34 +2,203 @@ import { DeesElement, customElement, html, css, cssManager, state, type Template import { deesCatalog } from '../plugins.js'; import { appState, type IAppState } from '../state/appstate.js'; import { viewHostCss } from './shared/index.js'; +import type { IIncomingNumberConfig, ISipRoute } from '../../ts/config.ts'; const { DeesModal, DeesToast } = deesCatalog; -interface ISipRoute { - id: string; - name: string; - priority: number; - enabled: boolean; - match: { - direction: 'inbound' | 'outbound'; - numberPattern?: string; - callerPattern?: string; - sourceProvider?: string; - sourceDevice?: string; +const CUSTOM_REGEX_KEY = '__custom_regex__'; +const CUSTOM_COUNTRY_CODE_KEY = '__custom_country_code__'; + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)); +} + +function createRoute(): ISipRoute { + return { + id: `route-${Date.now()}`, + name: '', + priority: 0, + enabled: true, + match: { direction: 'outbound' }, + action: {}, }; - action: { - targets?: string[]; - ringBrowsers?: boolean; - voicemailBox?: string; - ivrMenuId?: string; - noAnswerTimeout?: number; - provider?: string; - failoverProviders?: string[]; - stripPrefix?: string; - prependPrefix?: string; +} + +function createIncomingNumber(): IIncomingNumberConfig { + return { + id: `incoming-${Date.now()}`, + label: '', + mode: 'single', + countryCode: '+49', + areaCode: '', + localNumber: '', }; } +function normalizeCountryCode(value?: string): string { + const trimmed = (value || '').trim(); + if (!trimmed) return ''; + const digits = trimmed.replace(/\D/g, ''); + return digits ? `+${digits}` : ''; +} + +function normalizeNumberPart(value?: string): string { + return (value || '').replace(/\D/g, ''); +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function anyDigits(length: number): string { + if (length <= 0) return ''; + if (length === 1) return '\\d'; + return `\\d{${length}}`; +} + +function digitClass(start: number, end: number): string { + if (start === end) return String(start); + if (end === start + 1) return `[${start}${end}]`; + return `[${start}-${end}]`; +} + +function unique(values: string[]): string[] { + return Array.from(new Set(values)); +} + +function buildRangeAlternatives(min: string, max: string): string[] { + if (min.length !== max.length || !/^\d*$/.test(min) || !/^\d*$/.test(max)) { + return []; + } + if (min.length === 0) { + return ['']; + } + if (min === max) { + return [min]; + } + if (/^0+$/.test(min) && /^9+$/.test(max)) { + return [anyDigits(min.length)]; + } + + let index = 0; + while (index < min.length && min[index] === max[index]) { + index += 1; + } + + const prefix = min.slice(0, index); + const lowDigit = Number(min[index]); + const highDigit = Number(max[index]); + const restLength = min.length - index - 1; + const values: string[] = []; + + values.push( + ...buildRangeAlternatives(min.slice(index + 1), '9'.repeat(restLength)).map( + (suffix) => `${prefix}${lowDigit}${suffix}`, + ), + ); + + if (highDigit - lowDigit > 1) { + values.push(`${prefix}${digitClass(lowDigit + 1, highDigit - 1)}${anyDigits(restLength)}`); + } + + values.push( + ...buildRangeAlternatives('0'.repeat(restLength), max.slice(index + 1)).map( + (suffix) => `${prefix}${highDigit}${suffix}`, + ), + ); + + return unique(values); +} + +function buildRangePattern(min: string, max: string): string { + const alternatives = buildRangeAlternatives(min, max).filter(Boolean); + if (!alternatives.length) return ''; + if (alternatives.length === 1) return alternatives[0]; + return `(?:${alternatives.join('|')})`; +} + +function validateLocalRange(start?: string, end?: string): string | null { + const normalizedStart = normalizeNumberPart(start); + const normalizedEnd = normalizeNumberPart(end); + if (!normalizedStart || !normalizedEnd) { + return 'Range start and end are required'; + } + if (normalizedStart.length !== normalizedEnd.length) { + return 'Range start and end must have the same length'; + } + if (normalizedStart > normalizedEnd) { + return 'Range start must be less than or equal to range end'; + } + return null; +} + +function getIncomingNumberPattern(entry: IIncomingNumberConfig): string { + if (entry.mode === 'regex') { + return (entry.pattern || '').trim(); + } + + const countryCode = normalizeCountryCode(entry.countryCode); + const areaCode = normalizeNumberPart(entry.areaCode); + const localNumber = normalizeNumberPart(entry.localNumber); + if (!countryCode || !areaCode || !localNumber) { + return ''; + } + + let localPattern = escapeRegex(localNumber); + if (entry.mode === 'range') { + const rangeEnd = normalizeNumberPart(entry.rangeEnd); + if (!rangeEnd || validateLocalRange(localNumber, rangeEnd)) { + return ''; + } + localPattern = buildRangePattern(localNumber, rangeEnd); + } + + const countryDigits = countryCode.slice(1); + const nationalArea = areaCode.startsWith('0') ? areaCode : `0${areaCode}`; + const internationalArea = areaCode.replace(/^0+/, '') || areaCode; + return `/^(?:\\+${countryDigits}${internationalArea}|${nationalArea})${localPattern}$/`; +} + +function describeIncomingNumber(entry: IIncomingNumberConfig): string { + if (entry.mode === 'regex') { + return (entry.pattern || '').trim() || '(regex missing)'; + } + + const countryCode = normalizeCountryCode(entry.countryCode) || '+?'; + const areaCode = normalizeNumberPart(entry.areaCode) || '?'; + const localNumber = normalizeNumberPart(entry.localNumber) || '?'; + if (entry.mode === 'range') { + const rangeEnd = normalizeNumberPart(entry.rangeEnd) || '?'; + return `${countryCode} / ${areaCode} / ${localNumber}-${rangeEnd}`; + } + return `${countryCode} / ${areaCode} / ${localNumber}`; +} + +function describeRouteAction(route: ISipRoute): string { + const action = route.action; + if (route.match.direction === 'outbound') { + const parts: string[] = []; + if (action.provider) parts.push(`-> ${action.provider}`); + if (action.failoverProviders?.length) parts.push(`(failover: ${action.failoverProviders.join(', ')})`); + if (action.stripPrefix) parts.push(`strip: ${action.stripPrefix}`); + if (action.prependPrefix) parts.push(`prepend: ${action.prependPrefix}`); + return parts.join(' '); + } + + const parts: string[] = []; + if (action.ivrMenuId) { + parts.push(`ivr: ${action.ivrMenuId}`); + } else if (action.targets?.length) { + parts.push(`ring: ${action.targets.join(', ')}`); + } else { + parts.push('ring: all devices'); + } + if (action.ringBrowsers) parts.push('+ browsers'); + if (action.voicemailBox) parts.push(`vm: ${action.voicemailBox}`); + if (action.noAnswerTimeout) parts.push(`timeout: ${action.noAnswerTimeout}s`); + return parts.join(' '); +} + @customElement('sipproxy-view-routes') export class SipproxyViewRoutes extends DeesElement { @state() accessor appData: IAppState = appState.getState(); @@ -45,47 +214,127 @@ export class SipproxyViewRoutes extends DeesElement { async connectedCallback(): Promise { await super.connectedCallback(); - appState.subscribe((s) => { this.appData = s; }); + appState.subscribe((state) => { this.appData = state; }); await this.loadConfig(); } - private async loadConfig() { + private async loadConfig(): Promise { try { this.config = await appState.apiGetConfig(); } catch { - // Will show empty table. + // Show empty state. } } + private getRoutes(): ISipRoute[] { + return this.config?.routing?.routes || []; + } + + private getIncomingNumbers(): IIncomingNumberConfig[] { + return this.config?.incomingNumbers || []; + } + + private getCountryCodeOptions(extraCode?: string): Array<{ option: string; key: string }> { + const codes = new Set(['+49']); + for (const entry of this.getIncomingNumbers()) { + const countryCode = normalizeCountryCode(entry.countryCode); + if (countryCode) codes.add(countryCode); + } + const normalizedExtra = normalizeCountryCode(extraCode); + if (normalizedExtra) codes.add(normalizedExtra); + + return [ + ...Array.from(codes).sort((a, b) => a.localeCompare(b)).map((code) => ({ option: code, key: code })), + { option: 'Custom', key: CUSTOM_COUNTRY_CODE_KEY }, + ]; + } + + private getProviderLabel(providerId?: string): string { + if (!providerId) return '(any)'; + const provider = (this.config?.providers || []).find((item: any) => item.id === providerId); + return provider?.displayName || providerId; + } + + private findIncomingNumberForRoute(route: ISipRoute): IIncomingNumberConfig | undefined { + if (route.match.direction !== 'inbound' || !route.match.numberPattern) { + return undefined; + } + + return this.getIncomingNumbers().find((entry) => { + if (getIncomingNumberPattern(entry) !== route.match.numberPattern) { + return false; + } + if (entry.providerId && route.match.sourceProvider && entry.providerId !== route.match.sourceProvider) { + return false; + } + return true; + }); + } + + private countRoutesUsingIncomingNumber(entry: IIncomingNumberConfig): number { + const pattern = getIncomingNumberPattern(entry); + return this.getRoutes().filter((route) => { + if (route.match.direction !== 'inbound' || route.match.numberPattern !== pattern) { + return false; + } + if (entry.providerId && route.match.sourceProvider && entry.providerId !== route.match.sourceProvider) { + return false; + } + return true; + }).length; + } + + private async saveRoutes(routes: ISipRoute[]): Promise { + const result = await appState.apiSaveConfig({ routing: { routes } }); + if (!result.ok) return false; + await this.loadConfig(); + return true; + } + + private async saveIncomingNumbers(incomingNumbers: IIncomingNumberConfig[]): Promise { + const result = await appState.apiSaveConfig({ incomingNumbers }); + if (!result.ok) return false; + await this.loadConfig(); + return true; + } + public render(): TemplateResult { - const cfg = this.config; - const routes: ISipRoute[] = cfg?.routing?.routes || []; - const sorted = [...routes].sort((a, b) => b.priority - a.priority); + const routes = this.getRoutes(); + const incomingNumbers = [...this.getIncomingNumbers()].sort((a, b) => a.label.localeCompare(b.label)); + const sortedRoutes = [...routes].sort((a, b) => b.priority - a.priority); const tiles: any[] = [ { - id: 'total', + id: 'incoming-numbers', + title: 'Incoming Numbers', + value: incomingNumbers.length, + type: 'number', + icon: 'lucide:phoneIncoming', + description: 'Managed DIDs and regexes', + }, + { + id: 'total-routes', title: 'Total Routes', value: routes.length, type: 'number', icon: 'lucide:route', - description: `${routes.filter((r) => r.enabled).length} active`, + description: `${routes.filter((route) => route.enabled).length} active`, }, { - id: 'inbound', - title: 'Inbound', - value: routes.filter((r) => r.match.direction === 'inbound').length, + id: 'inbound-routes', + title: 'Inbound Routes', + value: routes.filter((route) => route.match.direction === 'inbound').length, type: 'number', - icon: 'lucide:phoneIncoming', - description: 'Incoming call routes', + icon: 'lucide:phoneCall', + description: 'Incoming call routing rules', }, { - id: 'outbound', - title: 'Outbound', - value: routes.filter((r) => r.match.direction === 'outbound').length, + id: 'outbound-routes', + title: 'Outbound Routes', + value: routes.filter((route) => route.match.direction === 'outbound').length, type: 'number', icon: 'lucide:phoneOutgoing', - description: 'Outgoing call routes', + description: 'Outgoing call routing rules', }, ]; @@ -94,28 +343,119 @@ export class SipproxyViewRoutes extends DeesElement { +
+ +
+
`; } - private getColumns() { + private getIncomingNumberColumns() { + return [ + { + key: 'label', + header: 'Label', + sortable: true, + }, + { + key: 'providerId', + header: 'Provider', + renderer: (_value: string | undefined, row: IIncomingNumberConfig) => + html`${this.getProviderLabel(row.providerId)}`, + }, + { + key: 'mode', + header: 'Type', + renderer: (value: string) => { + const label = value === 'regex' ? 'regex' : value === 'range' ? 'range' : 'number'; + const color = value === 'regex' ? '#f59e0b' : value === 'range' ? '#60a5fa' : '#4ade80'; + const bg = value === 'regex' ? '#422006' : value === 'range' ? '#1e3a5f' : '#1a3c2a'; + return html`${label}`; + }, + }, + { + key: 'match', + header: 'Definition', + renderer: (_value: unknown, row: IIncomingNumberConfig) => + html`${describeIncomingNumber(row)}`, + }, + { + key: 'pattern', + header: 'Generated Pattern', + renderer: (_value: unknown, row: IIncomingNumberConfig) => + html`${getIncomingNumberPattern(row) || '(incomplete)'}`, + }, + { + key: 'usage', + header: 'Used By', + renderer: (_value: unknown, row: IIncomingNumberConfig) => { + const count = this.countRoutesUsingIncomingNumber(row); + return html`${count} route${count === 1 ? '' : 's'}`; + }, + }, + ]; + } + + private getIncomingNumberActions() { + return [ + { + name: 'Add Number', + iconName: 'lucide:plus' as any, + type: ['header'] as any, + actionFunc: async () => { + await this.openIncomingNumberEditor(null); + }, + }, + { + name: 'Edit', + iconName: 'lucide:pencil' as any, + type: ['inRow'] as any, + actionFunc: async ({ item }: { item: IIncomingNumberConfig }) => { + await this.openIncomingNumberEditor(item); + }, + }, + { + name: 'Delete', + iconName: 'lucide:trash2' as any, + type: ['inRow'] as any, + actionFunc: async ({ item }: { item: IIncomingNumberConfig }) => { + const incomingNumbers = this.getIncomingNumbers().filter((entry) => entry.id !== item.id); + if (await this.saveIncomingNumbers(incomingNumbers)) { + DeesToast.success('Incoming number deleted'); + } else { + DeesToast.error('Failed to delete incoming number'); + } + }, + }, + ]; + } + + private getRouteColumns() { return [ { key: 'priority', header: 'Priority', sortable: true, - renderer: (val: number) => - html`${val}`, + renderer: (value: number) => html`${value}`, }, { key: 'name', @@ -125,23 +465,26 @@ export class SipproxyViewRoutes extends DeesElement { { key: 'match', header: 'Direction', - renderer: (_val: any, row: ISipRoute) => { - const dir = row.match.direction; - const color = dir === 'inbound' ? '#60a5fa' : '#4ade80'; - const bg = dir === 'inbound' ? '#1e3a5f' : '#1a3c2a'; - return html`${dir}`; + renderer: (_value: unknown, row: ISipRoute) => { + const direction = row.match.direction; + const color = direction === 'inbound' ? '#60a5fa' : '#4ade80'; + const bg = direction === 'inbound' ? '#1e3a5f' : '#1a3c2a'; + return html`${direction}`; }, }, { key: 'match', header: 'Match', - renderer: (_val: any, row: ISipRoute) => { - const m = row.match; + renderer: (_value: unknown, row: ISipRoute) => { const parts: string[] = []; - if (m.sourceProvider) parts.push(`provider: ${m.sourceProvider}`); - if (m.sourceDevice) parts.push(`device: ${m.sourceDevice}`); - if (m.numberPattern) parts.push(`number: ${m.numberPattern}`); - if (m.callerPattern) parts.push(`caller: ${m.callerPattern}`); + if (row.match.sourceProvider) parts.push(`provider: ${row.match.sourceProvider}`); + const incomingNumber = this.findIncomingNumberForRoute(row); + if (incomingNumber) { + parts.push(`did: ${incomingNumber.label}`); + } else if (row.match.numberPattern) { + parts.push(`number: ${row.match.numberPattern}`); + } + if (row.match.callerPattern) parts.push(`caller: ${row.match.callerPattern}`); if (!parts.length) return html`catch-all`; return html`${parts.join(', ')}`; }, @@ -149,46 +492,25 @@ export class SipproxyViewRoutes extends DeesElement { { key: 'action', header: 'Action', - renderer: (_val: any, row: ISipRoute) => { - const a = row.action; - if (row.match.direction === 'outbound') { - const parts: string[] = []; - if (a.provider) parts.push(`\u2192 ${a.provider}`); - if (a.failoverProviders?.length) parts.push(`(failover: ${a.failoverProviders.join(', ')})`); - if (a.stripPrefix) parts.push(`strip: ${a.stripPrefix}`); - if (a.prependPrefix) parts.push(`prepend: ${a.prependPrefix}`); - return html`${parts.join(' ')}`; - } else { - const parts: string[] = []; - if (a.ivrMenuId) { - parts.push(`ivr: ${a.ivrMenuId}`); - } else { - if (a.targets?.length) parts.push(`ring: ${a.targets.join(', ')}`); - else parts.push('ring: all devices'); - if (a.ringBrowsers) parts.push('+ browsers'); - } - if (a.voicemailBox) parts.push(`vm: ${a.voicemailBox}`); - if (a.noAnswerTimeout) parts.push(`timeout: ${a.noAnswerTimeout}s`); - return html`${parts.join(' ')}`; - } - }, + renderer: (_value: unknown, row: ISipRoute) => + html`${describeRouteAction(row)}`, }, { key: 'enabled', header: 'Status', - renderer: (val: boolean) => { - const color = val ? '#4ade80' : '#71717a'; - const bg = val ? '#1a3c2a' : '#3f3f46'; - return html`${val ? 'Active' : 'Disabled'}`; + renderer: (value: boolean) => { + const color = value ? '#4ade80' : '#71717a'; + const bg = value ? '#1a3c2a' : '#3f3f46'; + return html`${value ? 'Active' : 'Disabled'}`; }, }, ]; } - private getDataActions() { + private getRouteActions() { return [ { - name: 'Add', + name: 'Add Route', iconName: 'lucide:plus' as any, type: ['header'] as any, actionFunc: async () => { @@ -208,14 +530,13 @@ export class SipproxyViewRoutes extends DeesElement { iconName: 'lucide:toggleLeft' as any, type: ['inRow'] as any, actionFunc: async ({ item }: { item: ISipRoute }) => { - const cfg = this.config; - const routes = (cfg?.routing?.routes || []).map((r: ISipRoute) => - r.id === item.id ? { ...r, enabled: !r.enabled } : r, + const routes = this.getRoutes().map((route) => + route.id === item.id ? { ...route, enabled: !route.enabled } : route, ); - const result = await appState.apiSaveConfig({ routing: { routes } }); - if (result.ok) { + if (await this.saveRoutes(routes)) { DeesToast.success(item.enabled ? 'Route disabled' : 'Route enabled'); - await this.loadConfig(); + } else { + DeesToast.error('Failed to update route'); } }, }, @@ -224,41 +545,262 @@ export class SipproxyViewRoutes extends DeesElement { iconName: 'lucide:trash2' as any, type: ['inRow'] as any, actionFunc: async ({ item }: { item: ISipRoute }) => { - const cfg = this.config; - const routes = (cfg?.routing?.routes || []).filter((r: ISipRoute) => r.id !== item.id); - const result = await appState.apiSaveConfig({ routing: { routes } }); - if (result.ok) { + const routes = this.getRoutes().filter((route) => route.id !== item.id); + if (await this.saveRoutes(routes)) { DeesToast.success('Route deleted'); - await this.loadConfig(); + } else { + DeesToast.error('Failed to delete route'); } }, }, ]; } - private async openRouteEditor(existing: ISipRoute | null) { - const cfg = this.config; - const providers = cfg?.providers || []; - const devices = cfg?.devices || []; - const voiceboxes = cfg?.voiceboxes || []; - const ivrMenus = cfg?.ivr?.menus || []; + private async openIncomingNumberEditor(existing: IIncomingNumberConfig | null): Promise { + const providers = this.config?.providers || []; + const formData = existing ? clone(existing) : createIncomingNumber(); + const countryCodeOptions = this.getCountryCodeOptions(formData.countryCode); + let definitionType: 'number' | 'regex' = formData.mode === 'regex' ? 'regex' : 'number'; + let selectedCountryCode = countryCodeOptions.some((option) => option.key === normalizeCountryCode(formData.countryCode)) + ? normalizeCountryCode(formData.countryCode) + : CUSTOM_COUNTRY_CODE_KEY; + let customCountryCode = selectedCountryCode === CUSTOM_COUNTRY_CODE_KEY + ? normalizeCountryCode(formData.countryCode) + : ''; + let modalRef: any; - const formData: ISipRoute = existing - ? JSON.parse(JSON.stringify(existing)) - : { - id: `route-${Date.now()}`, - name: '', - priority: 0, - enabled: true, - match: { direction: 'outbound' as const }, - action: {}, - }; + const applySelectedCountryCode = () => { + formData.countryCode = selectedCountryCode === CUSTOM_COUNTRY_CODE_KEY + ? normalizeCountryCode(customCountryCode) + : selectedCountryCode; + }; - await DeesModal.createAndShow({ - heading: existing ? `Edit Route: ${existing.name}` : 'New Route', + const rerender = () => { + if (!modalRef) return; + modalRef.content = renderContent(); + modalRef.requestUpdate(); + }; + + const renderContent = () => { + applySelectedCountryCode(); + const generatedPattern = getIncomingNumberPattern(formData); + + return html` +
+ { formData.label = (e.target as any).value; }} + > + + ({ option: provider.displayName || provider.id, key: provider.id })), + ]} + @selectedOption=${(e: CustomEvent) => { formData.providerId = e.detail.key || undefined; }} + > + + { + definitionType = e.detail.key; + formData.mode = definitionType === 'regex' ? 'regex' : formData.mode === 'range' ? 'range' : 'single'; + if (definitionType === 'number' && !formData.countryCode) { + formData.countryCode = '+49'; + selectedCountryCode = '+49'; + } + rerender(); + }} + > + + ${definitionType === 'regex' ? html` + { formData.pattern = (e.target as any).value || undefined; }} + > + ` : html` + option.key === selectedCountryCode) || countryCodeOptions[0]} + .options=${countryCodeOptions} + @selectedOption=${(e: CustomEvent) => { + selectedCountryCode = e.detail.key || '+49'; + rerender(); + }} + > + + ${selectedCountryCode === CUSTOM_COUNTRY_CODE_KEY ? html` + { + customCountryCode = (e.target as any).value || ''; + formData.countryCode = normalizeCountryCode(customCountryCode); + }} + > + ` : ''} + + { formData.areaCode = (e.target as any).value || undefined; }} + > + + { formData.localNumber = (e.target as any).value || undefined; }} + > + + { + formData.mode = e.detail ? 'range' : 'single'; + rerender(); + }} + > + + ${formData.mode === 'range' ? html` + { formData.rangeEnd = (e.target as any).value || undefined; }} + > + ` : ''} + +
+
Generated Regex
+
${generatedPattern || '(complete the fields to generate a pattern)'}
+
+ `} +
+ `; + }; + + modalRef = await DeesModal.createAndShow({ + heading: existing ? `Edit Incoming Number: ${existing.label}` : 'New Incoming Number', width: 'small', showCloseButton: true, - content: html` + content: renderContent(), + menuOptions: [ + { + name: 'Cancel', + iconName: 'lucide:x', + action: async (modal: any) => { modal.destroy(); }, + }, + { + name: 'Save', + iconName: 'lucide:check', + action: async (modal: any) => { + const next = clone(formData); + next.label = next.label.trim(); + if (!next.label) { + DeesToast.error('Label is required'); + return; + } + + if (definitionType === 'regex') { + next.mode = 'regex'; + next.pattern = (next.pattern || '').trim(); + if (!next.pattern) { + DeesToast.error('Number pattern is required for custom regex'); + return; + } + delete next.countryCode; + delete next.areaCode; + delete next.localNumber; + delete next.rangeEnd; + delete next.number; + delete next.rangeStart; + } else { + next.countryCode = selectedCountryCode === CUSTOM_COUNTRY_CODE_KEY + ? normalizeCountryCode(customCountryCode) + : selectedCountryCode; + next.areaCode = normalizeNumberPart(next.areaCode); + next.localNumber = normalizeNumberPart(next.localNumber); + next.rangeEnd = normalizeNumberPart(next.rangeEnd); + next.mode = next.mode === 'range' ? 'range' : 'single'; + + if (!next.countryCode || !next.areaCode || !next.localNumber) { + DeesToast.error('Country code, area code, and number are required'); + return; + } + if (next.mode === 'range') { + const validationError = validateLocalRange(next.localNumber, next.rangeEnd); + if (validationError) { + DeesToast.error(validationError); + return; + } + } else { + delete next.rangeEnd; + } + delete next.pattern; + delete next.number; + delete next.rangeStart; + } + + if (!next.providerId) delete next.providerId; + const incomingNumbers = [...this.getIncomingNumbers()]; + const index = incomingNumbers.findIndex((entry) => entry.id === next.id); + if (index >= 0) incomingNumbers[index] = next; + else incomingNumbers.push(next); + + if (await this.saveIncomingNumbers(incomingNumbers)) { + modal.destroy(); + DeesToast.success(existing ? 'Incoming number updated' : 'Incoming number created'); + } else { + DeesToast.error('Failed to save incoming number'); + } + }, + }, + ], + }); + } + + private async openRouteEditor(existing: ISipRoute | null): Promise { + const providers = this.config?.providers || []; + const devices = this.config?.devices || []; + const voiceboxes = this.config?.voiceboxes || []; + const ivrMenus = this.config?.ivr?.menus || []; + const incomingNumbers = this.getIncomingNumbers(); + const formData = existing ? clone(existing) : createRoute(); + let selectedIncomingNumberId = this.findIncomingNumberForRoute(formData)?.id || CUSTOM_REGEX_KEY; + let modalRef: any; + + const rerender = () => { + if (!modalRef) return; + modalRef.content = renderContent(); + modalRef.requestUpdate(); + }; + + const renderContent = () => { + const incomingNumberOptions = [ + { option: 'Custom regex', key: CUSTOM_REGEX_KEY }, + ...incomingNumbers.map((entry) => ({ + option: `${entry.label} | ${describeIncomingNumber(entry)}`, + key: entry.id, + })), + ]; + + return html`
{ formData.match.direction = e.detail.key; }} + @selectedOption=${(e: CustomEvent) => { + formData.match.direction = e.detail.key; + rerender(); + }} > Match Criteria
- { formData.match.numberPattern = (e.target as any).value || undefined; }} - > + ${formData.match.direction === 'inbound' ? html` + ({ option: provider.displayName || provider.id, key: provider.id })), + ]} + @selectedOption=${(e: CustomEvent) => { formData.match.sourceProvider = e.detail.key || undefined; }} + > - { formData.match.callerPattern = (e.target as any).value || undefined; }} - > + option.key === selectedIncomingNumberId) || incomingNumberOptions[0]} + .options=${incomingNumberOptions} + @selectedOption=${(e: CustomEvent) => { + selectedIncomingNumberId = e.detail.key || CUSTOM_REGEX_KEY; + const selectedIncomingNumber = incomingNumbers.find((entry) => entry.id === selectedIncomingNumberId); + if (selectedIncomingNumber) { + formData.match.numberPattern = getIncomingNumberPattern(selectedIncomingNumber) || undefined; + if (selectedIncomingNumber.providerId) { + formData.match.sourceProvider = selectedIncomingNumber.providerId; + } + } + rerender(); + }} + > - ({ option: p.displayName || p.id, key: p.id })), - ]} - @selectedOption=${(e: CustomEvent) => { formData.match.sourceProvider = e.detail.key || undefined; }} - > + ${selectedIncomingNumberId === CUSTOM_REGEX_KEY ? html` + { formData.match.numberPattern = (e.target as any).value || undefined; }} + > + ` : html` +
+
Selected Pattern
+
${formData.match.numberPattern || '(not set)'}
+
+ `} + + { formData.match.callerPattern = (e.target as any).value || undefined; }} + > + ` : html` + { formData.match.numberPattern = (e.target as any).value || undefined; }} + > + `}
Action
- ({ option: p.displayName || p.id, key: p.id })), - ]} - @selectedOption=${(e: CustomEvent) => { formData.action.provider = e.detail.key || undefined; }} - > + ${formData.match.direction === 'inbound' ? html` + { + const value = (e.target as any).value.trim(); + formData.action.targets = value ? value.split(',').map((item: string) => item.trim()).filter(Boolean) : undefined; + }} + > - { - const v = (e.target as any).value.trim(); - formData.action.targets = v ? v.split(',').map((s: string) => s.trim()) : undefined; - }} - > + { formData.action.ringBrowsers = e.detail; }} + > - { formData.action.ringBrowsers = e.detail; }} - > + ({ option: voicebox.id, key: voicebox.id })), + ]} + @selectedOption=${(e: CustomEvent) => { formData.action.voicemailBox = e.detail.key || undefined; }} + > - ({ option: vb.id, key: vb.id })), - ]} - @selectedOption=${(e: CustomEvent) => { formData.action.voicemailBox = e.detail.key || undefined; }} - > + ({ option: menu.name || menu.id, key: menu.id })), + ]} + @selectedOption=${(e: CustomEvent) => { formData.action.ivrMenuId = e.detail.key || undefined; }} + > + ` : html` + ({ option: provider.displayName || provider.id, key: provider.id })), + ]} + @selectedOption=${(e: CustomEvent) => { formData.action.provider = e.detail.key || undefined; }} + > - ({ option: menu.name || menu.id, key: menu.id })), - ]} - @selectedOption=${(e: CustomEvent) => { formData.action.ivrMenuId = e.detail.key || undefined; }} - > + { formData.action.stripPrefix = (e.target as any).value || undefined; }} + > - { formData.action.stripPrefix = (e.target as any).value || undefined; }} - > + { formData.action.prependPrefix = (e.target as any).value || undefined; }} + > + `} - { formData.action.prependPrefix = (e.target as any).value || undefined; }} - > + ${formData.match.direction === 'inbound' && devices.length ? html` +
Known devices: ${devices.map((device: any) => device.id).join(', ')}
+ ` : ''} - `, + `; + }; + + modalRef = await DeesModal.createAndShow({ + heading: existing ? `Edit Route: ${existing.name}` : 'New Route', + width: 'small', + showCloseButton: true, + content: renderContent(), menuOptions: [ { name: 'Cancel', iconName: 'lucide:x', - action: async (modalRef: any) => { modalRef.destroy(); }, + action: async (modal: any) => { modal.destroy(); }, }, { name: 'Save', iconName: 'lucide:check', - action: async (modalRef: any) => { - if (!formData.name.trim()) { + action: async (modal: any) => { + const next = clone(formData); + next.name = next.name.trim(); + if (!next.name) { DeesToast.error('Route name is required'); return; } - // Clean up empty optional fields. - if (!formData.match.numberPattern) delete formData.match.numberPattern; - if (!formData.match.callerPattern) delete formData.match.callerPattern; - if (!formData.match.sourceProvider) delete formData.match.sourceProvider; - if (!formData.match.sourceDevice) delete formData.match.sourceDevice; - if (!formData.action.provider) delete formData.action.provider; - if (!formData.action.stripPrefix) delete formData.action.stripPrefix; - if (!formData.action.prependPrefix) delete formData.action.prependPrefix; - if (!formData.action.targets?.length) delete formData.action.targets; - if (!formData.action.ringBrowsers) delete formData.action.ringBrowsers; - if (!formData.action.voicemailBox) delete formData.action.voicemailBox; - if (!formData.action.ivrMenuId) delete formData.action.ivrMenuId; - if (!formData.action.noAnswerTimeout) delete formData.action.noAnswerTimeout; - - const currentRoutes = [...(cfg?.routing?.routes || [])]; - const idx = currentRoutes.findIndex((r: any) => r.id === formData.id); - if (idx >= 0) { - currentRoutes[idx] = formData; - } else { - currentRoutes.push(formData); + const selectedIncomingNumber = incomingNumbers.find((entry) => entry.id === selectedIncomingNumberId); + if (next.match.direction === 'inbound' && selectedIncomingNumber) { + next.match.numberPattern = getIncomingNumberPattern(selectedIncomingNumber) || undefined; + if (selectedIncomingNumber.providerId) { + next.match.sourceProvider = selectedIncomingNumber.providerId; + } } - const result = await appState.apiSaveConfig({ routing: { routes: currentRoutes } }); - if (result.ok) { - modalRef.destroy(); + if (!next.match.numberPattern) delete next.match.numberPattern; + if (!next.match.callerPattern) delete next.match.callerPattern; + if (!next.match.sourceProvider) delete next.match.sourceProvider; + if (!next.match.sourceDevice) delete next.match.sourceDevice; + if (!next.action.provider) delete next.action.provider; + if (!next.action.stripPrefix) delete next.action.stripPrefix; + if (!next.action.prependPrefix) delete next.action.prependPrefix; + if (!next.action.targets?.length) delete next.action.targets; + if (!next.action.ringBrowsers) delete next.action.ringBrowsers; + if (!next.action.voicemailBox) delete next.action.voicemailBox; + if (!next.action.ivrMenuId) delete next.action.ivrMenuId; + if (!next.action.noAnswerTimeout) delete next.action.noAnswerTimeout; + + const routes = [...this.getRoutes()]; + const index = routes.findIndex((route) => route.id === next.id); + if (index >= 0) routes[index] = next; + else routes.push(next); + + if (await this.saveRoutes(routes)) { + modal.destroy(); DeesToast.success(existing ? 'Route updated' : 'Route created'); - await this.loadConfig(); } else { DeesToast.error('Failed to save route'); }