From b91973865840833ee0eead56d2f5d0ef4ba50802 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 19 Sep 2026 14:30:40 +0000 Subject: [PATCH 01/17] refactor(plugins): drop unused sherpa-onnx wave FFI bindings Finding: sfind-7a1e043a6e8549dabb24dab4a8c7ced9 (cherry picked from commit 53f57fa201373f19302c0987326a688b848dada9) Signed-off-by: streamkit-devin --- plugins/native/sensevoice/src/ffi.rs | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/plugins/native/sensevoice/src/ffi.rs b/plugins/native/sensevoice/src/ffi.rs index 628532918..83580098d 100644 --- a/plugins/native/sensevoice/src/ffi.rs +++ b/plugins/native/sensevoice/src/ffi.rs @@ -190,15 +190,6 @@ pub struct SherpaOnnxFeatureConfig { pub feature_dim: c_int, } -/// Wave data structure (currently unused but kept for future use) -#[allow(dead_code)] -#[repr(C)] -pub struct SherpaOnnxWave { - pub samples: *const c_float, - pub sample_rate: c_int, - pub num_samples: c_int, -} - extern "C" { /// Create offline recognizer pub fn SherpaOnnxCreateOfflineRecognizer( @@ -239,12 +230,4 @@ extern "C" { pub fn SherpaOnnxDestroyOfflineRecognizerResult( result: *const SherpaOnnxOfflineRecognizerResult, ); - - /// Read wave file (utility function, currently unused but kept for future use) - #[allow(dead_code)] - pub fn SherpaOnnxReadWave(filename: *const c_char) -> *const SherpaOnnxWave; - - /// Free wave data (currently unused but kept for future use) - #[allow(dead_code)] - pub fn SherpaOnnxFreeWave(wave: *const SherpaOnnxWave); } From ee4a6192b9662671c57bff6a092c460e55440c02 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 19 Sep 2026 14:30:47 +0000 Subject: [PATCH 02/17] refactor(client): drop unread pipeline_path from DynamicSession Finding: sfind-9e70b738d4e5489fa91bbdf6964dde56 (cherry picked from commit 65d08923a11aff78183c5864af0d44495d491dad) Signed-off-by: streamkit-devin --- apps/skit-cli/src/load_test/workers.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/skit-cli/src/load_test/workers.rs b/apps/skit-cli/src/load_test/workers.rs index 483b9bec4..56e3b9bf4 100644 --- a/apps/skit-cli/src/load_test/workers.rs +++ b/apps/skit-cli/src/load_test/workers.rs @@ -85,8 +85,6 @@ pub async fn oneshot_worker( pub struct DynamicSession { pub session_id: String, - #[allow(dead_code)] - pub pipeline_path: String, pub tunable_node_ids: Vec, } @@ -448,7 +446,6 @@ pub async fn session_creator_worker( let _ = session_tx .send(DynamicSession { session_id, - pipeline_path: pipeline_path.clone(), tunable_node_ids, }) .await; From 644f617ed5d45831e58783103da9086460b18a6c Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 19 Sep 2026 14:30:54 +0000 Subject: [PATCH 03/17] refactor(client): drop unread total_success snapshot field Finding: sfind-ee00315bd4674501a4c8a8b382a07a91 (cherry picked from commit 261d1d53de26c84ef830b7b7b48be3293c9cf321) Signed-off-by: streamkit-devin --- apps/skit-cli/src/load_test/metrics.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/skit-cli/src/load_test/metrics.rs b/apps/skit-cli/src/load_test/metrics.rs index bf78d4318..c25eefcca 100644 --- a/apps/skit-cli/src/load_test/metrics.rs +++ b/apps/skit-cli/src/load_test/metrics.rs @@ -192,7 +192,6 @@ impl MetricsCollector { MetricsSnapshot { elapsed, total_ops, - total_success, total_failures, throughput, success_rate, @@ -255,8 +254,6 @@ impl MetricsCollector { pub struct MetricsSnapshot { pub elapsed: Duration, pub total_ops: usize, - #[allow(dead_code)] - pub total_success: usize, pub total_failures: usize, pub throughput: f64, pub success_rate: f64, From 314c68d706af24cc7c3951e555f3d8f293aa76e9 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 19 Sep 2026 14:31:39 +0000 Subject: [PATCH 04/17] refactor(server): drop unused permission helpers get_default had no callers and can_accept_oneshot was never consulted; the oneshot limit is enforced by the tower ConcurrencyLimitLayer. Finding: sfind-7c55087087d64d6981515c6b2da97a39 (cherry picked from commit fd1ec6a1eaa6dd47940cd255c221efd4793f6e55) Signed-off-by: streamkit-devin --- apps/skit/src/permissions.rs | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/apps/skit/src/permissions.rs b/apps/skit/src/permissions.rs index 40dfef449..8d495ba0b 100644 --- a/apps/skit/src/permissions.rs +++ b/apps/skit/src/permissions.rs @@ -436,12 +436,6 @@ impl PermissionsConfig { ) } - /// Get the default role permissions - #[allow(dead_code)] - pub fn get_default(&self) -> Permissions { - self.get_role(&self.default_role) - } - /// Check if we can accept a new session (global limit check) pub const fn can_accept_session(&self, current_count: usize) -> bool { match self.max_concurrent_sessions { @@ -449,15 +443,6 @@ impl PermissionsConfig { Some(max) => current_count < max, } } - - /// Check if we can accept a new oneshot pipeline (global limit check) - #[allow(dead_code)] - pub const fn can_accept_oneshot(&self, current_count: usize) -> bool { - match self.max_concurrent_oneshots { - None => true, - Some(max) => current_count < max, - } - } } #[cfg(test)] @@ -544,15 +529,6 @@ mod tests { assert!(!config.can_accept_session(11)); } - #[test] - fn test_global_oneshot_limits() { - let config = PermissionsConfig { max_concurrent_oneshots: Some(5), ..Default::default() }; - - assert!(config.can_accept_oneshot(0)); - assert!(config.can_accept_oneshot(4)); - assert!(!config.can_accept_oneshot(5)); - } - #[test] fn test_user_role_defaults() { let user = Permissions::user(); From aa3d8fed2e2c21f2f27e6603b398daa31bd0b6b4 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 19 Sep 2026 14:31:45 +0000 Subject: [PATCH 05/17] refactor(nodes): drop stale allow(unused_variables) in blit Finding: sfind-019e0e6339164e9781ca104bc0332f9b (cherry picked from commit e6dc2b5e8a50abb4a2ae51a42d14c8d9228b4286) Signed-off-by: streamkit-devin --- crates/nodes/src/video/pixel_ops/blit.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/nodes/src/video/pixel_ops/blit.rs b/crates/nodes/src/video/pixel_ops/blit.rs index 841cb15e7..aec866b18 100644 --- a/crates/nodes/src/video/pixel_ops/blit.rs +++ b/crates/nodes/src/video/pixel_ops/blit.rs @@ -103,7 +103,7 @@ pub fn scale_blit_rgba( src_height: u32, dst_rect: &BlitRect, opacity: f32, - #[allow(unused_variables)] src_opaque: bool, + src_opaque: bool, mirror_h: bool, mirror_v: bool, src_region: Option<(u32, u32, u32, u32)>, From ee9b363b9d0dfae1f369eca713d8355f0012e493 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 19 Sep 2026 14:31:51 +0000 Subject: [PATCH 06/17] refactor(server): use MoQ route session_id in gateway logs Drops the rationale-free #[allow(dead_code)] by emitting the owning session id in connection routing and route unregistration traces. Finding: sfind-5181780016d34e709f62754881c66c71 (cherry picked from commit 9a5d0538f048a512c1f149ad4addb1edb1d3ab4a) Signed-off-by: streamkit-devin --- apps/skit/src/moq_gateway.rs | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/skit/src/moq_gateway.rs b/apps/skit/src/moq_gateway.rs index abe7c753c..736428cc2 100644 --- a/apps/skit/src/moq_gateway.rs +++ b/apps/skit/src/moq_gateway.rs @@ -18,7 +18,6 @@ use tracing::{debug, error, info, warn}; /// A route registration from a path pattern to a connection receiver struct Route { /// The session ID that owns this route - #[allow(dead_code)] session_id: String, /// Channel to send accepted connections to the node @@ -94,17 +93,17 @@ impl MoqGateway { // The UI currently connects WebTransport before creating the dynamic session; when the // session starts, it registers its routes. Without waiting here, the server would drop // the connection before the route exists. - let connection_tx: Option> = { + let route: Option<(String, mpsc::UnboundedSender)> = { const MAX_WAIT: Duration = Duration::from_secs(30); const POLL_INTERVAL: Duration = Duration::from_millis(200); let mut waited = Duration::from_secs(0); loop { - if let Some(tx) = { + if let Some(entry) = { let routes = self.routes.read().await; - routes.get(&path).map(|r| r.connection_tx.clone()) + routes.get(&path).map(|r| (r.session_id.clone(), r.connection_tx.clone())) } { - break Some(tx); + break Some(entry); } if waited >= MAX_WAIT { @@ -119,7 +118,7 @@ impl MoqGateway { } }; - if let Some(connection_tx) = connection_tx { + if let Some((session_id, connection_tx)) = route { let (response_tx, response_rx) = oneshot::channel(); // Type-erase the moq-native Request @@ -129,22 +128,22 @@ impl MoqGateway { MoqConnection { path: path.clone(), session: session_boxed, response_tx, auth }; if connection_tx.send(conn).is_err() { - error!(path = %path, "Failed to send connection to node (channel closed)"); + error!(path = %path, session_id = %session_id, "Failed to send connection to node (channel closed)"); return Err("Node disconnected".to_string()); } // Wait for node to accept or reject match response_rx.await { Ok(MoqConnectionResult::Accepted) => { - info!(path = %path, "Connection accepted by node"); + info!(path = %path, session_id = %session_id, "Connection accepted by node"); Ok(()) }, Ok(MoqConnectionResult::Rejected(reason)) => { - warn!(path = %path, reason = %reason, "Connection rejected by node"); + warn!(path = %path, session_id = %session_id, reason = %reason, "Connection rejected by node"); Err(reason) }, Err(_) => { - error!(path = %path, "Node dropped connection without responding"); + error!(path = %path, session_id = %session_id, "Node dropped connection without responding"); Err("Node did not respond".to_string()) }, } @@ -209,9 +208,13 @@ impl MoqGatewayTrait for MoqGateway { async fn unregister_route(&self, path_pattern: &str) { let mut routes = self.routes.write().await; - if routes.remove(path_pattern).is_some() { + if let Some(route) = routes.remove(path_pattern) { self.route_notify.notify_waiters(); - info!(path_pattern = %path_pattern, "Unregistered MoQ route"); + info!( + path_pattern = %path_pattern, + session_id = %route.session_id, + "Unregistered MoQ route" + ); } } } From 0777cb87070b827a670a1316186d2d069b6d40ba Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 19 Sep 2026 14:36:48 +0000 Subject: [PATCH 07/17] refactor(server): gate CreateMoqTokenRequest behind moq feature The request DTO is only used by the moq-gated token handler and CLI arm; replace the stale #[allow(dead_code)] with the real compilation condition. Finding: sfind-617012a3955b47ef907efbfe01c97e56 Signed-off-by: streamkit-devin --- apps/skit/src/auth/handlers.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/skit/src/auth/handlers.rs b/apps/skit/src/auth/handlers.rs index 28ea0c704..5b2dd2803 100644 --- a/apps/skit/src/auth/handlers.rs +++ b/apps/skit/src/auth/handlers.rs @@ -49,8 +49,8 @@ pub struct CreateApiTokenRequest { #[serde(default)] pub ttl_secs: Option, } +#[cfg(feature = "moq")] #[derive(Debug, Deserialize, Serialize)] -#[allow(dead_code)] pub struct CreateMoqTokenRequest { pub root: String, #[serde(default)] From 026638aa10f9db4309bcdd45769b209b0882ac38 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 19 Sep 2026 14:38:22 +0000 Subject: [PATCH 08/17] refactor(server): cfg-gate MoQ claim types instead of dead_code AUD_MOQ, MoqClaims, MoqClaims::validate, and the MissingRoot error variant are only reachable under the moq feature; gate them like the rest of the MoQ surface in auth/mod.rs and gate test_moq_claims_validation to match. Finding: sfind-b043e0c55e5c4904a6f42d3d19fad606 Signed-off-by: streamkit-devin --- apps/skit/src/auth/claims.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/skit/src/auth/claims.rs b/apps/skit/src/auth/claims.rs index 192ecbb7f..8aa3d8d98 100644 --- a/apps/skit/src/auth/claims.rs +++ b/apps/skit/src/auth/claims.rs @@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize}; pub const AUD_API: &str = "skit-api"; /// Audience value for MoQ tokens. -#[allow(dead_code)] +#[cfg(feature = "moq")] pub const AUD_MOQ: &str = "skit-moq"; /// JWT claims for API tokens (HTTP API and WebSocket control plane). @@ -79,8 +79,8 @@ impl ApiClaims { /// - `[""]` (empty string in array) = all broadcasts allowed /// - `[]` (empty array) = no broadcasts allowed /// - `["foo", "bar"]` = broadcasts starting with "foo" or "bar" allowed +#[cfg(feature = "moq")] #[derive(Debug, Clone, Serialize, Deserialize)] -#[allow(dead_code)] pub struct MoqClaims { /// Must be [`AUD_MOQ`]. pub aud: String, @@ -105,7 +105,7 @@ pub struct MoqClaims { pub jti: String, } -#[allow(dead_code)] +#[cfg(feature = "moq")] impl MoqClaims { /// Validate claims structure (not cryptographic verification). /// @@ -141,8 +141,8 @@ pub enum ClaimsValidationError { #[error("Missing role claim")] MissingRole, + #[cfg(feature = "moq")] #[error("Missing root claim")] - #[allow(dead_code)] MissingRoot, } @@ -163,7 +163,7 @@ mod tests { assert!(valid.validate().is_ok()); // Wrong audience - let wrong_aud = ApiClaims { aud: AUD_MOQ.to_string(), ..valid.clone() }; + let wrong_aud = ApiClaims { aud: "skit-moq".to_string(), ..valid.clone() }; assert!(matches!(wrong_aud.validate(), Err(ClaimsValidationError::InvalidAudience { .. }))); // Missing jti @@ -175,6 +175,7 @@ mod tests { assert!(matches!(no_role.validate(), Err(ClaimsValidationError::MissingRole))); } + #[cfg(feature = "moq")] #[test] fn test_moq_claims_validation() { let valid = MoqClaims { From b8b8c8fb88ae1dc5ae42fc7fe3f44fbf3681b925 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 19 Sep 2026 14:39:44 +0000 Subject: [PATCH 09/17] refactor(server): tighten auth-core dead_code suppressions - Drop the blanket allow on AuthError and remove the never-constructed Revoked/Expired/InvalidAudience variants (those states already surface through the Jwt/Claims wrappers or the extractor's status tuples). - is_revoked is only called from moq-gated code, so gate it with #[cfg(feature = "moq")] instead of an allow. - key_provider is used by the JWKS handler; the allow was stale. - Wire should_enable into start_server's auth decision instead of duplicating the match there, which also drops its dead-code allow. Finding: sfind-e25bbcea436842b288fe795e4c3d4d02 Signed-off-by: streamkit-devin --- apps/skit/src/auth/mod.rs | 14 +------------- apps/skit/src/server/mod.rs | 6 +----- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/apps/skit/src/auth/mod.rs b/apps/skit/src/auth/mod.rs index 9459b62d6..c7cfb7385 100644 --- a/apps/skit/src/auth/mod.rs +++ b/apps/skit/src/auth/mod.rs @@ -58,7 +58,6 @@ use tracing::{debug, info}; /// Errors that can occur during authentication. #[derive(Debug, thiserror::Error)] -#[allow(dead_code)] pub enum AuthError { #[error("Authentication is disabled")] Disabled, @@ -78,15 +77,6 @@ pub enum AuthError { #[error("Token not found in metadata store (not minted by this server)")] UnknownToken, - #[error("Token has been revoked")] - Revoked, - - #[error("Token expired")] - Expired, - - #[error("Invalid audience: expected {expected}, got {actual}")] - InvalidAudience { expected: String, actual: String }, - #[error("TTL exceeds maximum allowed ({max} seconds)")] TtlExceedsMax { max: u64 }, @@ -215,7 +205,7 @@ impl AuthState { } /// Returns false if auth is disabled or the revocation store is unavailable. - #[allow(dead_code)] + #[cfg(feature = "moq")] pub fn is_revoked(&self, token_hash: &str) -> bool { self.revocation_store.as_ref().is_some_and(|store| store.is_revoked(token_hash)) } @@ -224,7 +214,6 @@ impl AuthState { self.token_metadata_store.as_ref() } - #[allow(dead_code)] pub fn key_provider(&self) -> Option<&Arc> { self.key_provider.as_ref() } @@ -472,7 +461,6 @@ impl AuthState { } /// Whether auth should be enabled based on config and bind address. - #[allow(dead_code)] pub const fn should_enable(config: &AuthConfig, bind_addr: &std::net::SocketAddr) -> bool { match config.mode { AuthMode::Auto => !bind_addr.ip().is_loopback(), diff --git a/apps/skit/src/server/mod.rs b/apps/skit/src/server/mod.rs index bb14d7f38..5af9d209a 100644 --- a/apps/skit/src/server/mod.rs +++ b/apps/skit/src/server/mod.rs @@ -2041,11 +2041,7 @@ pub fn create_app( pub async fn start_server(config: &Config) -> Result<(), Box> { let addr: SocketAddr = config.server.address.parse()?; - let auth_enabled = match config.auth.mode { - crate::config::AuthMode::Auto => !addr.ip().is_loopback(), - crate::config::AuthMode::Enabled => true, - crate::config::AuthMode::Disabled => false, - }; + let auth_enabled = crate::auth::AuthState::should_enable(&config.auth, &addr); // Deployment footgun: cookie-based auth without TLS. // From dc6f9264353dabff8a9755416d69edf854e29fe4 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 19 Sep 2026 14:41:31 +0000 Subject: [PATCH 10/17] refactor(server): drop dead MaybeAuth scaffolding in auth extractor AuthContext.permissions is read by the websocket auth path, so its allow was stale. MaybeAuth and AuthContext::jti/sub had no production caller and existed only for their own unit test; delete them. Finding: sfind-1ef5a2836ffa4bd1b5b0f20ac21ed4fe Signed-off-by: streamkit-devin --- apps/skit/src/auth/extractor.rs | 58 --------------------------------- 1 file changed, 58 deletions(-) diff --git a/apps/skit/src/auth/extractor.rs b/apps/skit/src/auth/extractor.rs index 85492dd31..55bb47e26 100644 --- a/apps/skit/src/auth/extractor.rs +++ b/apps/skit/src/auth/extractor.rs @@ -15,53 +15,9 @@ use axum::http::{HeaderMap, StatusCode}; pub struct AuthContext { pub claims: ApiClaims, pub role: String, - #[allow(dead_code)] pub permissions: Permissions, } -#[allow(dead_code)] -impl AuthContext { - /// JWT ID (used for revocation tracking). - pub fn jti(&self) -> &str { - &self.claims.jti - } - - /// Subject (token holder identifier). - pub fn sub(&self) -> &str { - &self.claims.sub - } -} - -/// Optional auth context — never fails; contains `None` when unauthenticated. -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct MaybeAuth(pub Option); - -#[allow(dead_code)] -impl MaybeAuth { - pub const fn context(&self) -> Option<&AuthContext> { - self.0.as_ref() - } - - pub const fn is_authenticated(&self) -> bool { - self.0.is_some() - } - - #[allow(clippy::ref_option)] - pub const fn as_option(&self) -> &Option { - &self.0 - } - - /// Unwrap or return an unauthorized error. - /// - /// # Errors - /// - /// Returns `(StatusCode::UNAUTHORIZED, ...)` if not authenticated. - pub fn require(self) -> Result { - self.0.ok_or_else(|| (StatusCode::UNAUTHORIZED, "Authentication required".to_string())) - } -} - /// Extract token from Authorization header or cookie. /// /// Checks the Authorization header first (Bearer token format), @@ -214,18 +170,4 @@ mod tests { let token = extract_token(&headers, &config); assert!(token.is_none()); } - - #[test] - fn test_maybe_auth_require() { - let auth = MaybeAuth(None); - assert!(auth.require().is_err()); - - let ctx = AuthContext { - claims: ApiClaims::anonymous("admin"), - role: "admin".to_string(), - permissions: crate::permissions::Permissions::admin(), - }; - let auth = MaybeAuth(Some(ctx)); - assert!(auth.require().is_ok()); - } } From 1bd3b2e246356fef513cec0a26f9a22e98a2376b Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 19 Sep 2026 14:41:37 +0000 Subject: [PATCH 11/17] refactor(server): drop never-constructed KeyNotFound store error Finding: sfind-8e7f1fc36e6243489e51ccf99bf6a0bc Signed-off-by: streamkit-devin --- apps/skit/src/auth/stores/mod.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/skit/src/auth/stores/mod.rs b/apps/skit/src/auth/stores/mod.rs index 93dedad2b..161e20499 100644 --- a/apps/skit/src/auth/stores/mod.rs +++ b/apps/skit/src/auth/stores/mod.rs @@ -44,10 +44,6 @@ pub enum AuthStoreError { #[error("Base64 decode error: {0}")] Base64(#[from] base64::DecodeError), - #[error("Key not found: {0}")] - #[allow(dead_code)] - KeyNotFound(String), - #[error("Invalid file permissions on {path}: expected 0600, got {actual:o}")] InsecurePermissions { path: String, actual: u32 }, From c8c35d904e93c3a87c34ca9d070f56a65cbdce17 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 19 Sep 2026 14:41:44 +0000 Subject: [PATCH 12/17] refactor(server): drop unread root field from MoqAuthContext Finding: sfind-d13d8c628fda46b68b380cf234a6da25 Signed-off-by: streamkit-devin --- apps/skit/src/auth/moq.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/skit/src/auth/moq.rs b/apps/skit/src/auth/moq.rs index 34efefc0c..6a7871750 100644 --- a/apps/skit/src/auth/moq.rs +++ b/apps/skit/src/auth/moq.rs @@ -23,9 +23,6 @@ use streamkit_core::moq_gateway::MoqAuthChecker; /// Verified MoQ auth context with permissions reduced by connection path depth. #[derive(Debug, Clone)] pub struct MoqAuthContext { - /// The actual connection path (after root validation) - #[allow(dead_code)] - pub root: PathOwned, /// Reduced subscribe permissions (broadcast paths relative to connection) pub subscribe: Vec, /// Reduced publish permissions (broadcast paths relative to connection) @@ -86,7 +83,7 @@ pub fn verify_moq_token( let subscribe = claims.subscribe.iter().filter_map(|p| reduce_permission(p, &suffix)).collect(); let publish = claims.publish.iter().filter_map(|p| reduce_permission(p, &suffix)).collect(); - Ok(MoqAuthContext { root: url_path.to_owned(), subscribe, publish }) + Ok(MoqAuthContext { subscribe, publish }) } /// Reduce a permission path based on connection suffix. From 6c38b88f6d1ea16314e1274701c5d0b6155ef869 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 19 Sep 2026 14:42:46 +0000 Subject: [PATCH 13/17] docs(nodes): add rationale for js_to_packet lint suppression Finding: sfind-b7c3ad500f7347f9b139c5011b9b4c43 Signed-off-by: streamkit-devin --- crates/nodes/src/core/script.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/nodes/src/core/script.rs b/crates/nodes/src/core/script.rs index 55fc35571..c2dc06434 100644 --- a/crates/nodes/src/core/script.rs +++ b/crates/nodes/src/core/script.rs @@ -679,6 +679,8 @@ impl ScriptNode { /// Returns: /// - Some(Packet) - Continue processing with this packet /// - None - Drop the packet + // Allow: `&self` is retained for method-call symmetry with `packet_to_js`, + // and `rquickjs::Value` is a cheap GC handle passed by value. #[allow(clippy::needless_pass_by_value, clippy::unused_self)] fn js_to_packet( &self, From d1feb1f304cef9764cc4a3bf6ea43c5efb061274 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 19 Sep 2026 14:55:44 +0000 Subject: [PATCH 14/17] refactor(plugins): share SentenceSplitter via streamkit_core::text Five TTS plugins carried copies of the same incremental sentence splitter (piper/kokoro/matcha/supertonic identical; pocket-tts had drifted to English-only boundaries), and TextChunkerNode duplicated the same scan logic again for sentence and clause modes. - streamkit_core::text gains a generic extract_chunk primitive plus a SentenceSplitter wrapper with the shared English+CJK boundary tables; plugins reach it through the plugin SDK's streamkit_core re-export. - TextChunkerNode delegates both modes to extract_chunk with its own clause boundary tables; pocket-tts picks up CJK boundaries. - The dead flush() helper and per-plugin copies are removed. Finding: sfind-91ebd1390a7d4d9284c7f904df0c480f Signed-off-by: streamkit-devin --- crates/core/src/lib.rs | 1 + crates/core/src/text.rs | 110 ++++++++++++++++++ crates/nodes/src/core/text_chunker.rs | 64 ++-------- plugins/native/kokoro/src/kokoro_node.rs | 2 +- plugins/native/kokoro/src/lib.rs | 1 - .../native/kokoro/src/sentence_splitter.rs | 95 --------------- plugins/native/matcha/src/lib.rs | 1 - plugins/native/matcha/src/matcha_node.rs | 2 +- .../native/matcha/src/sentence_splitter.rs | 95 --------------- plugins/native/piper/src/lib.rs | 1 - plugins/native/piper/src/piper_node.rs | 2 +- plugins/native/piper/src/sentence_splitter.rs | 96 --------------- plugins/native/pocket-tts/src/lib.rs | 1 - .../native/pocket-tts/src/pocket_tts_node.rs | 2 +- .../pocket-tts/src/sentence_splitter.rs | 74 ------------ plugins/native/supertonic/src/lib.rs | 1 - .../supertonic/src/sentence_splitter.rs | 94 --------------- .../native/supertonic/src/supertonic_node.rs | 2 +- 18 files changed, 126 insertions(+), 518 deletions(-) create mode 100644 crates/core/src/text.rs delete mode 100644 plugins/native/kokoro/src/sentence_splitter.rs delete mode 100644 plugins/native/matcha/src/sentence_splitter.rs delete mode 100644 plugins/native/piper/src/sentence_splitter.rs delete mode 100644 plugins/native/pocket-tts/src/sentence_splitter.rs delete mode 100644 plugins/native/supertonic/src/sentence_splitter.rs diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 8f3e66d23..62be3b30f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -65,6 +65,7 @@ pub mod resource_manager; pub mod state; pub mod stats; pub mod telemetry; +pub mod text; pub mod timing; pub mod types; pub mod view_data; diff --git a/crates/core/src/text.rs b/crates/core/src/text.rs new file mode 100644 index 000000000..b6c9984db --- /dev/null +++ b/crates/core/src/text.rs @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +//! Incremental text segmentation shared by the text-chunker node and the +//! TTS plugins that buffer streamed text into whole sentences. + +/// Boundaries ending a complete sentence: English punctuation followed by +/// whitespace, or CJK punctuation (which needs no trailing whitespace). +pub const SENTENCE_BOUNDARIES: &[&str] = &[". ", ".\n", "! ", "!\n", "? ", "?\n", "。", "!", "?"]; + +/// Punctuation ending a sentence even without trailing whitespace. +pub const SENTENCE_TRAILING: &[char] = &['.', '!', '?', '。', '!', '?']; + +/// Drain the first complete chunk from the front of `buffer`. +/// +/// `boundaries` are tried in table order (not buffer position); on the first +/// match the buffer up to and including the boundary is drained, trimmed, and +/// returned. With no boundary match, a `trailing` punctuation char at the end +/// of `buffer` completes the chunk. `None` is returned while `buffer` is +/// shorter than `min_length` or contains no completed chunk. +pub fn extract_chunk( + buffer: &mut String, + min_length: usize, + boundaries: &[&str], + trailing: &[char], +) -> Option { + if buffer.len() < min_length { + return None; + } + + for boundary in boundaries { + if let Some(pos) = buffer.find(boundary) { + let end_pos = pos + boundary.len(); + let chunk: String = buffer.drain(..end_pos).collect(); + return Some(chunk.trim().to_string()); + } + } + + if trailing.iter().any(|&p| buffer.ends_with(p)) { + return Some(std::mem::take(buffer)); + } + + None +} + +/// Splits a growing text buffer into complete sentences on English or CJK +/// punctuation boundaries. +pub struct SentenceSplitter { + min_length: usize, +} + +impl SentenceSplitter { + pub const fn new(min_length: usize) -> Self { + Self { min_length } + } + + /// Drain and return the first complete sentence in `buffer`, if it is at + /// least `min_length` long and reaches a sentence boundary. + pub fn extract_sentence(&self, buffer: &mut String) -> Option { + extract_chunk(buffer, self.min_length, SENTENCE_BOUNDARIES, SENTENCE_TRAILING) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_sentence_english_boundaries() { + let splitter = SentenceSplitter::new(5); + let mut buffer = "Hello world. How are you?".to_string(); + + assert_eq!(splitter.extract_sentence(&mut buffer), Some("Hello world.".to_string())); + assert_eq!(buffer, "How are you?"); + + assert_eq!(splitter.extract_sentence(&mut buffer), Some("How are you?".to_string())); + assert!(buffer.is_empty()); + } + + #[test] + fn extract_sentence_cjk_boundaries() { + let splitter = SentenceSplitter::new(1); + let mut buffer = "こんにちは。元気ですか?".to_string(); + + assert_eq!(splitter.extract_sentence(&mut buffer), Some("こんにちは。".to_string())); + assert_eq!(splitter.extract_sentence(&mut buffer), Some("元気ですか?".to_string())); + assert!(buffer.is_empty()); + } + + #[test] + fn extract_sentence_respects_min_length() { + let splitter = SentenceSplitter::new(20); + let mut buffer = "Hi.".to_string(); + + assert_eq!(splitter.extract_sentence(&mut buffer), None); + assert_eq!(buffer, "Hi."); + } + + #[test] + fn extract_chunk_custom_boundaries() { + let mut buffer = "first clause, rest".to_string(); + + assert_eq!( + extract_chunk(&mut buffer, 1, &[", "], &[',']), + Some("first clause,".to_string()) + ); + assert_eq!(buffer, "rest"); + } +} diff --git a/crates/nodes/src/core/text_chunker.rs b/crates/nodes/src/core/text_chunker.rs index c3fd3cab2..b5c5bae06 100644 --- a/crates/nodes/src/core/text_chunker.rs +++ b/crates/nodes/src/core/text_chunker.rs @@ -5,6 +5,7 @@ use async_trait::async_trait; use schemars::JsonSchema; use serde::Deserialize; +use streamkit_core::text::{extract_chunk, SENTENCE_BOUNDARIES, SENTENCE_TRAILING}; use streamkit_core::types::{Packet, PacketType}; use streamkit_core::{ config_helpers, state_helpers, InputPin, NodeContext, OutputPin, PinCardinality, ProcessorNode, @@ -58,32 +59,12 @@ impl TextChunkerNode { } fn extract_sentence(&mut self) -> Option { - if self.buffer.len() < self.config.min_length { - return None; - } - - let boundaries = [". ", ".\n", "! ", "!\n", "? ", "?\n", "。", "!", "?"]; - - for boundary in &boundaries { - if let Some(pos) = self.buffer.find(boundary) { - let end_pos = pos + boundary.len(); - let sentence: String = self.buffer.drain(..end_pos).collect(); - return Some(sentence.trim().to_string()); - } - } - - if self.buffer.ends_with('.') - || self.buffer.ends_with('!') - || self.buffer.ends_with('?') - || self.buffer.ends_with('。') - || self.buffer.ends_with('!') - || self.buffer.ends_with('?') - { - let sentence = self.buffer.drain(..).collect(); - return Some(sentence); - } - - None + extract_chunk( + &mut self.buffer, + self.config.min_length, + SENTENCE_BOUNDARIES, + SENTENCE_TRAILING, + ) } fn extract_word_chunk(&mut self) -> Option { @@ -115,11 +96,7 @@ impl TextChunkerNode { } fn extract_clause(&mut self) -> Option { - if self.buffer.len() < self.config.min_length { - return None; - } - - let boundaries = [ + const CLAUSE_BOUNDARIES: &[&str] = &[ ". ", ".\n", "! ", "!\n", "? ", "?\n", // Sentence endings (English) "。", "!", "?", // Sentence endings (Chinese) ", ", ",\n", // Commas (natural pauses) @@ -127,30 +104,9 @@ impl TextChunkerNode { " - ", " – ", " — ", // Dashes (with spaces) ": ", ":\n", // Colons (list introductions) ]; + const CLAUSE_TRAILING: &[char] = &['.', '!', '?', '。', '!', '?', ',', ';', ':']; - for boundary in &boundaries { - if let Some(pos) = self.buffer.find(boundary) { - let end_pos = pos + boundary.len(); - let clause: String = self.buffer.drain(..end_pos).collect(); - return Some(clause.trim().to_string()); - } - } - - if self.buffer.ends_with('.') - || self.buffer.ends_with('!') - || self.buffer.ends_with('?') - || self.buffer.ends_with('。') - || self.buffer.ends_with('!') - || self.buffer.ends_with('?') - || self.buffer.ends_with(',') - || self.buffer.ends_with(';') - || self.buffer.ends_with(':') - { - let clause = self.buffer.drain(..).collect(); - return Some(clause); - } - - None + extract_chunk(&mut self.buffer, self.config.min_length, CLAUSE_BOUNDARIES, CLAUSE_TRAILING) } fn extract_chunk(&mut self) -> Option { diff --git a/plugins/native/kokoro/src/kokoro_node.rs b/plugins/native/kokoro/src/kokoro_node.rs index b16b27a9b..222e93ab2 100644 --- a/plugins/native/kokoro/src/kokoro_node.rs +++ b/plugins/native/kokoro/src/kokoro_node.rs @@ -14,7 +14,7 @@ use streamkit_plugin_sdk_native::streamkit_core::types::{AudioFormat, SampleForm use crate::config::KokoroTtsConfig; use crate::ffi; -use crate::sentence_splitter::SentenceSplitter; +use streamkit_plugin_sdk_native::streamkit_core::text::SentenceSplitter; /// GPU availability status /// 0 = not checked, 1 = available, 2 = not available diff --git a/plugins/native/kokoro/src/lib.rs b/plugins/native/kokoro/src/lib.rs index 340f73a76..10a8758be 100644 --- a/plugins/native/kokoro/src/lib.rs +++ b/plugins/native/kokoro/src/lib.rs @@ -5,7 +5,6 @@ mod config; mod ffi; mod kokoro_node; -mod sentence_splitter; use kokoro_node::KokoroTtsNode; use streamkit_plugin_sdk_native::{native_plugin_entry, NativeProcessorNode}; diff --git a/plugins/native/kokoro/src/sentence_splitter.rs b/plugins/native/kokoro/src/sentence_splitter.rs deleted file mode 100644 index 1078cd26c..000000000 --- a/plugins/native/kokoro/src/sentence_splitter.rs +++ /dev/null @@ -1,95 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 StreamKit Contributors -// -// SPDX-License-Identifier: MPL-2.0 - -pub struct SentenceSplitter { - min_length: usize, -} - -impl SentenceSplitter { - pub const fn new(min_length: usize) -> Self { - Self { min_length } - } - - /// Extract complete sentence from buffer if available - /// Supports both English and Chinese punctuation - pub fn extract_sentence(&self, buffer: &mut String) -> Option { - if buffer.len() < self.min_length { - return None; - } - - // Find sentence boundaries: English and Chinese punctuation - let boundaries = [ - ". ", ".\n", "! ", "!\n", "? ", "?\n", // English - "。", "!", "?", // Chinese (no space needed) - ]; - - for boundary in &boundaries { - if let Some(pos) = buffer.find(boundary) { - let end_pos = pos + boundary.len(); - let sentence: String = buffer.drain(..end_pos).collect(); - return Some(sentence.trim().to_string()); - } - } - - // Also check for final punctuation at end - if buffer.ends_with('.') - || buffer.ends_with('!') - || buffer.ends_with('?') - || buffer.ends_with('。') - || buffer.ends_with('!') - || buffer.ends_with('?') - { - return Some(std::mem::take(buffer)); - } - - None - } - - /// Force flush remaining buffer (called on cleanup) - // Allow: Function reserved for future use (manual flush on shutdown) - #[allow(dead_code)] - pub fn flush(buffer: &mut String) -> Option { - if buffer.is_empty() { - None - } else { - Some(std::mem::take(buffer)) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_sentence_extraction() { - let splitter = SentenceSplitter::new(5); - let mut buffer = "Hello world. How are you?".to_string(); - - assert_eq!(splitter.extract_sentence(&mut buffer), Some("Hello world.".to_string())); - assert_eq!(buffer, "How are you?"); - - assert_eq!(splitter.extract_sentence(&mut buffer), Some("How are you?".to_string())); - assert_eq!(buffer, ""); - } - - #[test] - fn test_min_length() { - let splitter = SentenceSplitter::new(20); - let mut buffer = "Hi.".to_string(); - - // Too short, should not extract - assert_eq!(splitter.extract_sentence(&mut buffer), None); - assert_eq!(buffer, "Hi."); - } - - #[test] - fn test_flush() { - let _splitter = SentenceSplitter::new(10); - let mut buffer = "Incomplete sentence".to_string(); - - assert_eq!(SentenceSplitter::flush(&mut buffer), Some("Incomplete sentence".to_string())); - assert_eq!(buffer, ""); - } -} diff --git a/plugins/native/matcha/src/lib.rs b/plugins/native/matcha/src/lib.rs index 6186668d6..30cb565fd 100644 --- a/plugins/native/matcha/src/lib.rs +++ b/plugins/native/matcha/src/lib.rs @@ -5,7 +5,6 @@ mod config; mod ffi; mod matcha_node; -mod sentence_splitter; use matcha_node::MatchaTtsNode; use streamkit_plugin_sdk_native::{native_plugin_entry, NativeProcessorNode}; diff --git a/plugins/native/matcha/src/matcha_node.rs b/plugins/native/matcha/src/matcha_node.rs index eeb639d58..545139572 100644 --- a/plugins/native/matcha/src/matcha_node.rs +++ b/plugins/native/matcha/src/matcha_node.rs @@ -13,7 +13,7 @@ use streamkit_plugin_sdk_native::streamkit_core::types::{AudioFormat, SampleForm use crate::config::MatchaTtsConfig; use crate::ffi; -use crate::sentence_splitter::SentenceSplitter; +use streamkit_plugin_sdk_native::streamkit_core::text::SentenceSplitter; /// GPU availability status /// 0 = not checked, 1 = available, 2 = not available diff --git a/plugins/native/matcha/src/sentence_splitter.rs b/plugins/native/matcha/src/sentence_splitter.rs deleted file mode 100644 index 1078cd26c..000000000 --- a/plugins/native/matcha/src/sentence_splitter.rs +++ /dev/null @@ -1,95 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 StreamKit Contributors -// -// SPDX-License-Identifier: MPL-2.0 - -pub struct SentenceSplitter { - min_length: usize, -} - -impl SentenceSplitter { - pub const fn new(min_length: usize) -> Self { - Self { min_length } - } - - /// Extract complete sentence from buffer if available - /// Supports both English and Chinese punctuation - pub fn extract_sentence(&self, buffer: &mut String) -> Option { - if buffer.len() < self.min_length { - return None; - } - - // Find sentence boundaries: English and Chinese punctuation - let boundaries = [ - ". ", ".\n", "! ", "!\n", "? ", "?\n", // English - "。", "!", "?", // Chinese (no space needed) - ]; - - for boundary in &boundaries { - if let Some(pos) = buffer.find(boundary) { - let end_pos = pos + boundary.len(); - let sentence: String = buffer.drain(..end_pos).collect(); - return Some(sentence.trim().to_string()); - } - } - - // Also check for final punctuation at end - if buffer.ends_with('.') - || buffer.ends_with('!') - || buffer.ends_with('?') - || buffer.ends_with('。') - || buffer.ends_with('!') - || buffer.ends_with('?') - { - return Some(std::mem::take(buffer)); - } - - None - } - - /// Force flush remaining buffer (called on cleanup) - // Allow: Function reserved for future use (manual flush on shutdown) - #[allow(dead_code)] - pub fn flush(buffer: &mut String) -> Option { - if buffer.is_empty() { - None - } else { - Some(std::mem::take(buffer)) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_sentence_extraction() { - let splitter = SentenceSplitter::new(5); - let mut buffer = "Hello world. How are you?".to_string(); - - assert_eq!(splitter.extract_sentence(&mut buffer), Some("Hello world.".to_string())); - assert_eq!(buffer, "How are you?"); - - assert_eq!(splitter.extract_sentence(&mut buffer), Some("How are you?".to_string())); - assert_eq!(buffer, ""); - } - - #[test] - fn test_min_length() { - let splitter = SentenceSplitter::new(20); - let mut buffer = "Hi.".to_string(); - - // Too short, should not extract - assert_eq!(splitter.extract_sentence(&mut buffer), None); - assert_eq!(buffer, "Hi."); - } - - #[test] - fn test_flush() { - let _splitter = SentenceSplitter::new(10); - let mut buffer = "Incomplete sentence".to_string(); - - assert_eq!(SentenceSplitter::flush(&mut buffer), Some("Incomplete sentence".to_string())); - assert_eq!(buffer, ""); - } -} diff --git a/plugins/native/piper/src/lib.rs b/plugins/native/piper/src/lib.rs index 52d66624e..f57339ab4 100644 --- a/plugins/native/piper/src/lib.rs +++ b/plugins/native/piper/src/lib.rs @@ -7,7 +7,6 @@ mod config; mod ffi; mod piper_node; -mod sentence_splitter; use piper_node::PiperTtsNode; use streamkit_plugin_sdk_native::{native_plugin_entry, NativeProcessorNode}; diff --git a/plugins/native/piper/src/piper_node.rs b/plugins/native/piper/src/piper_node.rs index 97932e20c..3686fce8e 100644 --- a/plugins/native/piper/src/piper_node.rs +++ b/plugins/native/piper/src/piper_node.rs @@ -12,7 +12,7 @@ use streamkit_plugin_sdk_native::streamkit_core::types::{AudioFormat, SampleForm use crate::config::PiperTtsConfig; use crate::ffi; -use crate::sentence_splitter::SentenceSplitter; +use streamkit_plugin_sdk_native::streamkit_core::text::SentenceSplitter; /// Wrapper for TTS engine pointer that implements Send/Sync /// SAFETY: We ensure thread-safe access through Mutex diff --git a/plugins/native/piper/src/sentence_splitter.rs b/plugins/native/piper/src/sentence_splitter.rs deleted file mode 100644 index 29be215b8..000000000 --- a/plugins/native/piper/src/sentence_splitter.rs +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 StreamKit Contributors -// -// SPDX-License-Identifier: MPL-2.0 - -pub struct SentenceSplitter { - min_length: usize, -} - -impl SentenceSplitter { - pub const fn new(min_length: usize) -> Self { - Self { min_length } - } - - /// Extract complete sentence from buffer if available - /// Supports both English and Chinese punctuation - pub fn extract_sentence(&self, buffer: &mut String) -> Option { - if buffer.len() < self.min_length { - return None; - } - - // Find sentence boundaries: English and Chinese punctuation - let boundaries = [ - ". ", ".\n", "! ", "!\n", "? ", "?\n", // English - "。", "!", "?", // Chinese (no space needed) - ]; - - for boundary in &boundaries { - if let Some(pos) = buffer.find(boundary) { - let end_pos = pos + boundary.len(); - let sentence: String = buffer.drain(..end_pos).collect(); - return Some(sentence.trim().to_string()); - } - } - - // Also check for final punctuation at end - if buffer.ends_with('.') - || buffer.ends_with('!') - || buffer.ends_with('?') - || buffer.ends_with('。') - || buffer.ends_with('!') - || buffer.ends_with('?') - { - let sentence = std::mem::take(buffer); - return Some(sentence); - } - - None - } - - /// Force flush remaining buffer (called on cleanup) - // Allow: Function reserved for future use (manual flush on shutdown) - #[allow(dead_code)] - pub fn flush(buffer: &mut String) -> Option { - if buffer.is_empty() { - None - } else { - Some(std::mem::take(buffer)) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_sentence_extraction() { - let splitter = SentenceSplitter::new(5); - let mut buffer = "Hello world. How are you?".to_string(); - - assert_eq!(splitter.extract_sentence(&mut buffer), Some("Hello world.".to_string())); - assert_eq!(buffer, "How are you?"); - - assert_eq!(splitter.extract_sentence(&mut buffer), Some("How are you?".to_string())); - assert_eq!(buffer, ""); - } - - #[test] - fn test_min_length() { - let splitter = SentenceSplitter::new(20); - let mut buffer = "Hi.".to_string(); - - // Too short, should not extract - assert_eq!(splitter.extract_sentence(&mut buffer), None); - assert_eq!(buffer, "Hi."); - } - - #[test] - fn test_flush() { - let _splitter = SentenceSplitter::new(10); - let mut buffer = "Incomplete sentence".to_string(); - - assert_eq!(SentenceSplitter::flush(&mut buffer), Some("Incomplete sentence".to_string())); - assert_eq!(buffer, ""); - } -} diff --git a/plugins/native/pocket-tts/src/lib.rs b/plugins/native/pocket-tts/src/lib.rs index ffd3be803..8e43bef24 100644 --- a/plugins/native/pocket-tts/src/lib.rs +++ b/plugins/native/pocket-tts/src/lib.rs @@ -4,7 +4,6 @@ mod config; mod model; -mod sentence_splitter; mod voice; mod pocket_tts_node; diff --git a/plugins/native/pocket-tts/src/pocket_tts_node.rs b/plugins/native/pocket-tts/src/pocket_tts_node.rs index f2cabdbc9..4300135f9 100644 --- a/plugins/native/pocket-tts/src/pocket_tts_node.rs +++ b/plugins/native/pocket-tts/src/pocket_tts_node.rs @@ -12,11 +12,11 @@ use streamkit_plugin_sdk_native::{plugin_debug, plugin_error, plugin_info, plugi use crate::config::PocketTtsConfig; use crate::model::{configure_model, get_or_load_model, ModelCacheKey}; -use crate::sentence_splitter::SentenceSplitter; use crate::voice::{ get_or_load_voice_state, normalize_voice_spec, voice_state_from_base64, voice_state_from_wav_bytes, VoiceCacheKey, }; +use streamkit_plugin_sdk_native::streamkit_core::text::SentenceSplitter; pub struct PocketTtsNode { model: pocket_tts::TTSModel, diff --git a/plugins/native/pocket-tts/src/sentence_splitter.rs b/plugins/native/pocket-tts/src/sentence_splitter.rs deleted file mode 100644 index c0fdd6b28..000000000 --- a/plugins/native/pocket-tts/src/sentence_splitter.rs +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 StreamKit Contributors -// -// SPDX-License-Identifier: MPL-2.0 - -pub struct SentenceSplitter { - min_length: usize, -} - -impl SentenceSplitter { - pub const fn new(min_length: usize) -> Self { - Self { min_length } - } - - /// Extract complete sentence from buffer if available. - pub fn extract_sentence(&self, buffer: &mut String) -> Option { - if buffer.len() < self.min_length { - return None; - } - - let boundaries = [ - ". ", ".\n", "! ", "!\n", "? ", "?\n", // English - ]; - - for boundary in &boundaries { - if let Some(pos) = buffer.find(boundary) { - let end_pos = pos + boundary.len(); - let sentence: String = buffer.drain(..end_pos).collect(); - return Some(sentence.trim().to_string()); - } - } - - if buffer.ends_with('.') || buffer.ends_with('!') || buffer.ends_with('?') { - let sentence = std::mem::take(buffer); - return Some(sentence); - } - - None - } - - #[allow(dead_code)] - pub fn flush(buffer: &mut String) -> Option { - if buffer.is_empty() { - None - } else { - Some(std::mem::take(buffer)) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_sentence_extraction() { - let splitter = SentenceSplitter::new(5); - let mut buffer = "Hello world. How are you?".to_string(); - - assert_eq!(splitter.extract_sentence(&mut buffer), Some("Hello world.".to_string())); - assert_eq!(buffer, "How are you?"); - - assert_eq!(splitter.extract_sentence(&mut buffer), Some("How are you?".to_string())); - assert_eq!(buffer, ""); - } - - #[test] - fn test_min_length() { - let splitter = SentenceSplitter::new(20); - let mut buffer = "Hi.".to_string(); - - assert_eq!(splitter.extract_sentence(&mut buffer), None); - assert_eq!(buffer, "Hi."); - } -} diff --git a/plugins/native/supertonic/src/lib.rs b/plugins/native/supertonic/src/lib.rs index 7377c41fc..410865fed 100644 --- a/plugins/native/supertonic/src/lib.rs +++ b/plugins/native/supertonic/src/lib.rs @@ -4,7 +4,6 @@ mod config; mod model; -mod sentence_splitter; mod supertonic_node; mod voice; diff --git a/plugins/native/supertonic/src/sentence_splitter.rs b/plugins/native/supertonic/src/sentence_splitter.rs deleted file mode 100644 index fa3ca31c8..000000000 --- a/plugins/native/supertonic/src/sentence_splitter.rs +++ /dev/null @@ -1,94 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 StreamKit Contributors -// -// SPDX-License-Identifier: MPL-2.0 - -pub struct SentenceSplitter { - min_length: usize, -} - -impl SentenceSplitter { - pub const fn new(min_length: usize) -> Self { - Self { min_length } - } - - /// Extract complete sentence from buffer if available. - /// Supports English, Chinese, and Korean punctuation. - pub fn extract_sentence(&self, buffer: &mut String) -> Option { - if buffer.len() < self.min_length { - return None; - } - - // Sentence boundaries: English, Chinese, and Korean punctuation - let boundaries = [ - ". ", ".\n", "! ", "!\n", "? ", "?\n", // English - "。", "!", "?", // CJK (no space needed) - ]; - - for boundary in &boundaries { - if let Some(pos) = buffer.find(boundary) { - let end_pos = pos + boundary.len(); - let sentence: String = buffer.drain(..end_pos).collect(); - return Some(sentence.trim().to_string()); - } - } - - // Also check for final punctuation at end - if buffer.ends_with('.') - || buffer.ends_with('!') - || buffer.ends_with('?') - || buffer.ends_with('。') - || buffer.ends_with('!') - || buffer.ends_with('?') - { - return Some(std::mem::take(buffer)); - } - - None - } - - /// Force flush remaining buffer (called on cleanup) - #[allow(dead_code)] - pub fn flush(buffer: &mut String) -> Option { - if buffer.is_empty() { - None - } else { - Some(std::mem::take(buffer)) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_sentence_extraction() { - let splitter = SentenceSplitter::new(5); - let mut buffer = "Hello world. How are you?".to_string(); - - assert_eq!(splitter.extract_sentence(&mut buffer), Some("Hello world.".to_string())); - assert_eq!(buffer, "How are you?"); - - assert_eq!(splitter.extract_sentence(&mut buffer), Some("How are you?".to_string())); - assert_eq!(buffer, ""); - } - - #[test] - fn test_min_length() { - let splitter = SentenceSplitter::new(20); - let mut buffer = "Hi.".to_string(); - - // Too short, should not extract - assert_eq!(splitter.extract_sentence(&mut buffer), None); - assert_eq!(buffer, "Hi."); - } - - #[test] - fn test_flush() { - let _splitter = SentenceSplitter::new(10); - let mut buffer = "Incomplete sentence".to_string(); - - assert_eq!(SentenceSplitter::flush(&mut buffer), Some("Incomplete sentence".to_string())); - assert_eq!(buffer, ""); - } -} diff --git a/plugins/native/supertonic/src/supertonic_node.rs b/plugins/native/supertonic/src/supertonic_node.rs index ebdb5148a..c7f1ccc21 100644 --- a/plugins/native/supertonic/src/supertonic_node.rs +++ b/plugins/native/supertonic/src/supertonic_node.rs @@ -10,8 +10,8 @@ use streamkit_plugin_sdk_native::streamkit_core::types::{AudioFormat, SampleForm use crate::config::SupertonicConfig; use crate::model::{self, TtsModelWrapper}; -use crate::sentence_splitter::SentenceSplitter; use crate::voice::{self, StyleWrapper}; +use streamkit_plugin_sdk_native::streamkit_core::text::SentenceSplitter; pub struct SupertonicNode { tts_model: Arc, From 2826eadd581be5d15ad5076c2df2f79b9f5444f8 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 19 Sep 2026 15:01:02 +0000 Subject: [PATCH 15/17] refactor(plugins): dedup sherpa-onnx FFI bindings into shared crate piper, kokoro, and matcha each carried ~100 lines of identical sherpa-onnx C-API struct bindings plus ~90 lines of near-identical OfflineTtsConfig assembly (the three plugins differ only in which model-family sub-config they populate and in debug/provider flags). - New plugins/native/common crate (streamkit-plugin-native-common) hosts the repr(C) bindings, a path_to_cstring helper, and an owning OfflineTtsConfig builder that fills unused model families with the null placeholders the C API expects; the unsafe create call moves inside the crate so the node code is safe. - Removes the never-used SherpaOnnxGeneratedAudioCallbackWithArg alias and piper's module-wide #![allow(dead_code)]. - The 'common' dir is skipped in marketplace plugin enumeration and linted via just lint-plugins. Finding: sfind-a95f7f7cb1474fc2ac6f00113d750f59 Signed-off-by: streamkit-devin --- justfile | 2 + plugins/native/common/Cargo.lock | 762 ++++++++++++++++++ plugins/native/common/Cargo.toml | 39 + plugins/native/common/src/lib.rs | 14 + plugins/native/common/src/sherpa_onnx.rs | 417 ++++++++++ plugins/native/kokoro/Cargo.lock | 5 + plugins/native/kokoro/Cargo.toml | 1 + plugins/native/kokoro/src/ffi.rs | 138 ---- plugins/native/kokoro/src/kokoro_node.rs | 118 +-- plugins/native/kokoro/src/lib.rs | 1 - plugins/native/matcha/Cargo.lock | 5 + plugins/native/matcha/Cargo.toml | 1 + plugins/native/matcha/src/ffi.rs | 138 ---- plugins/native/matcha/src/lib.rs | 1 - plugins/native/matcha/src/matcha_node.rs | 118 +-- plugins/native/piper/Cargo.lock | 5 + plugins/native/piper/Cargo.toml | 1 + plugins/native/piper/src/ffi.rs | 139 ---- plugins/native/piper/src/lib.rs | 1 - plugins/native/piper/src/piper_node.rs | 121 +-- scripts/marketplace/build_official_plugins.sh | 7 +- .../marketplace/generate_official_plugins.py | 3 + 22 files changed, 1342 insertions(+), 695 deletions(-) create mode 100644 plugins/native/common/Cargo.lock create mode 100644 plugins/native/common/Cargo.toml create mode 100644 plugins/native/common/src/lib.rs create mode 100644 plugins/native/common/src/sherpa_onnx.rs delete mode 100644 plugins/native/kokoro/src/ffi.rs delete mode 100644 plugins/native/matcha/src/ffi.rs delete mode 100644 plugins/native/piper/src/ffi.rs diff --git a/justfile b/justfile index 9bdb0f218..237854620 100644 --- a/justfile +++ b/justfile @@ -413,6 +413,7 @@ lint-plugins: @cd plugins/native/slint && cargo fmt -- --check && CARGO_TARGET_DIR={{plugins_target_dir}} cargo clippy -- -D warnings @cd plugins/native/aac-encoder && cargo fmt -- --check && CARGO_TARGET_DIR={{plugins_target_dir}} cargo clippy -- -D warnings @cd plugins/native/servo && cargo fmt -- --check && CARGO_TARGET_DIR={{plugins_target_dir}} cargo clippy -- -D warnings + @cd plugins/native/common && cargo fmt -- --check && CARGO_TARGET_DIR={{plugins_target_dir}} cargo clippy --all-targets --all-features -- -D warnings @echo "✓ All native plugins passed linting" # Auto-fix formatting and linting issues in native plugins @@ -430,6 +431,7 @@ fix-plugins: @cd plugins/native/slint && cargo fmt && CARGO_TARGET_DIR={{plugins_target_dir}} cargo clippy --fix --allow-dirty --allow-staged -- -D warnings @cd plugins/native/aac-encoder && cargo fmt && CARGO_TARGET_DIR={{plugins_target_dir}} cargo clippy --fix --allow-dirty --allow-staged -- -D warnings @cd plugins/native/servo && cargo fmt && CARGO_TARGET_DIR={{plugins_target_dir}} cargo clippy --fix --allow-dirty --allow-staged -- -D warnings + @cd plugins/native/common && cargo fmt && CARGO_TARGET_DIR={{plugins_target_dir}} cargo clippy --fix --allow-dirty --allow-staged --all-targets --all-features -- -D warnings @echo "✓ All native plugins fixed" # --- Profiling --- diff --git a/plugins/native/common/Cargo.lock b/plugins/native/common/Cargo.lock new file mode 100644 index 000000000..ba502ec6f --- /dev/null +++ b/plugins/native/common/Cargo.lock @@ -0,0 +1,762 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ort" +version = "2.0.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa7e49bd669d32d7bc2a15ec540a527e7764aec722a45467814005725bcd721" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2aba9f5c7c479925205799216e7e5d07cc1d4fa76ea8058c60a9a30f6a4e890" +dependencies = [ + "flate2", + "pkg-config", + "sha2", + "tar", + "ureq", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smallvec" +version = "2.0.0-alpha.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d44cfb396c3caf6fbfd0ab422af02631b69ddd96d2eff0b0f0724f9024051b" + +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + +[[package]] +name = "streamkit-plugin-native-common" +version = "0.1.0" +dependencies = [ + "ndarray", + "ort", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64", + "der", + "log", + "native-tls", + "percent-encoding", + "rustls-pki-types", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-root-certs", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" diff --git a/plugins/native/common/Cargo.toml b/plugins/native/common/Cargo.toml new file mode 100644 index 000000000..93faaf867 --- /dev/null +++ b/plugins/native/common/Cargo.toml @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: © 2025 StreamKit Contributors +# +# SPDX-License-Identifier: MPL-2.0 + +[package] +name = "streamkit-plugin-native-common" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" +description = "Shared FFI bindings and model wrappers for StreamKit native plugins" +publish = false + +[features] +default = [] +# Silero VAD wrapper (ONNX Runtime); kept optional so TTS plugins that only +# need the sherpa-onnx FFI don't pay for the ort/ndarray dependency tree. +silero-vad = ["dep:ort", "dep:ndarray"] + +[dependencies] +ort = { version = "=2.0.0-rc.10", optional = true } +ndarray = { version = "0.16", optional = true } + +[lints.clippy] +# Categories +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +# Safety +unwrap_used = "warn" +expect_used = "warn" +# Complexity +cognitive_complexity = "warn" +# Math +cast_possible_truncation = "warn" +cast_precision_loss = "warn" +cast_sign_loss = "warn" +# Allow-list (Noise reduction) +module_name_repetitions = "allow" +must_use_candidate = "allow" +doc_markdown = "allow" diff --git a/plugins/native/common/src/lib.rs b/plugins/native/common/src/lib.rs new file mode 100644 index 000000000..bdcc8da15 --- /dev/null +++ b/plugins/native/common/src/lib.rs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +//! Support crate shared by the official native plugins: FFI bindings to +//! external model libraries and thin Rust wrappers around them. +//! +//! Not a plugin itself — this crate only exists so each plugin does not +//! carry its own copy of the same bindings. It is deliberately excluded +//! from the marketplace plugin enumeration in +//! `scripts/marketplace/generate_official_plugins.py` and +//! `scripts/marketplace/build_official_plugins.sh`. + +pub mod sherpa_onnx; diff --git a/plugins/native/common/src/sherpa_onnx.rs b/plugins/native/common/src/sherpa_onnx.rs new file mode 100644 index 000000000..f0ccc128c --- /dev/null +++ b/plugins/native/common/src/sherpa_onnx.rs @@ -0,0 +1,417 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +//! Manual FFI bindings to the sherpa-onnx C API. +//! +//! Also provides an owning builder for offline TTS engine configs, shared by +//! the sherpa-onnx TTS plugins. Struct layouts mirror +//! https://github.com/k2-fsa/sherpa-onnx/blob/master/sherpa-onnx/c-api/c-api.h + +use std::ffi::CString; +use std::os::raw::{c_char, c_float, c_int}; +use std::path::Path; +use std::ptr; + +/// Opaque TTS engine handle +#[repr(C)] +pub struct SherpaOnnxOfflineTts { + _private: [u8; 0], +} + +/// Generated audio data +#[repr(C)] +pub struct SherpaOnnxOfflineTtsGeneratedAudio { + pub samples: *const c_float, + pub n: c_int, + pub sample_rate: c_int, +} + +/// Kokoro model configuration +#[repr(C)] +pub struct SherpaOnnxOfflineTtsKokoroModelConfig { + pub model: *const c_char, + pub voices: *const c_char, + pub tokens: *const c_char, + pub data_dir: *const c_char, + pub length_scale: c_float, + pub dict_dir: *const c_char, + pub lexicon: *const c_char, + pub lang: *const c_char, +} + +impl Default for SherpaOnnxOfflineTtsKokoroModelConfig { + /// All-null placeholder for when the Kokoro family is not in use. + fn default() -> Self { + Self { + model: ptr::null(), + voices: ptr::null(), + tokens: ptr::null(), + data_dir: ptr::null(), + length_scale: 1.0, + dict_dir: ptr::null(), + lexicon: ptr::null(), + lang: ptr::null(), + } + } +} + +/// VITS model configuration +#[repr(C)] +pub struct SherpaOnnxOfflineTtsVitsModelConfig { + pub model: *const c_char, + pub lexicon: *const c_char, + pub tokens: *const c_char, + pub data_dir: *const c_char, + pub noise_scale: c_float, + pub noise_scale_w: c_float, + pub length_scale: c_float, + pub dict_dir: *const c_char, +} + +impl Default for SherpaOnnxOfflineTtsVitsModelConfig { + /// All-null placeholder for when the VITS family is not in use. + fn default() -> Self { + Self { + model: ptr::null(), + lexicon: ptr::null(), + tokens: ptr::null(), + data_dir: ptr::null(), + noise_scale: 0.0, + noise_scale_w: 0.0, + length_scale: 1.0, + dict_dir: ptr::null(), + } + } +} + +/// Matcha model configuration +#[repr(C)] +pub struct SherpaOnnxOfflineTtsMatchaModelConfig { + pub acoustic_model: *const c_char, + pub vocoder: *const c_char, + pub lexicon: *const c_char, + pub tokens: *const c_char, + pub data_dir: *const c_char, + pub noise_scale: c_float, + pub length_scale: c_float, + pub dict_dir: *const c_char, +} + +impl Default for SherpaOnnxOfflineTtsMatchaModelConfig { + /// All-null placeholder for when the Matcha family is not in use. + fn default() -> Self { + Self { + acoustic_model: ptr::null(), + vocoder: ptr::null(), + lexicon: ptr::null(), + tokens: ptr::null(), + data_dir: ptr::null(), + noise_scale: 0.0, + length_scale: 1.0, + dict_dir: ptr::null(), + } + } +} + +/// Kitten model configuration (not used, but needed for struct layout) +#[repr(C)] +pub struct SherpaOnnxOfflineTtsKittenModelConfig { + pub model: *const c_char, + pub voices: *const c_char, + pub tokens: *const c_char, + pub data_dir: *const c_char, + pub length_scale: c_float, +} + +impl Default for SherpaOnnxOfflineTtsKittenModelConfig { + /// All-null placeholder for when the Kitten family is not in use. + fn default() -> Self { + Self { + model: ptr::null(), + voices: ptr::null(), + tokens: ptr::null(), + data_dir: ptr::null(), + length_scale: 1.0, + } + } +} + +/// Zipvoice model configuration (not used, but needed for struct layout) +#[repr(C)] +pub struct SherpaOnnxOfflineTtsZipvoiceModelConfig { + pub tokens: *const c_char, + pub text_model: *const c_char, + pub flow_matching_model: *const c_char, + pub vocoder: *const c_char, + pub data_dir: *const c_char, + pub pinyin_dict: *const c_char, + pub feat_scale: c_float, + pub t_shift: c_float, + pub target_rms: c_float, + pub guidance_scale: c_float, +} + +impl Default for SherpaOnnxOfflineTtsZipvoiceModelConfig { + /// All-null placeholder for when the Zipvoice family is not in use. + fn default() -> Self { + Self { + tokens: ptr::null(), + text_model: ptr::null(), + flow_matching_model: ptr::null(), + vocoder: ptr::null(), + data_dir: ptr::null(), + pinyin_dict: ptr::null(), + feat_scale: 0.0, + t_shift: 0.0, + target_rms: 0.0, + guidance_scale: 0.0, + } + } +} + +/// TTS model configuration +#[repr(C)] +pub struct SherpaOnnxOfflineTtsModelConfig { + pub vits: SherpaOnnxOfflineTtsVitsModelConfig, + pub num_threads: c_int, + pub debug: c_int, + pub provider: *const c_char, + pub matcha: SherpaOnnxOfflineTtsMatchaModelConfig, + pub kokoro: SherpaOnnxOfflineTtsKokoroModelConfig, + pub kitten: SherpaOnnxOfflineTtsKittenModelConfig, + pub zipvoice: SherpaOnnxOfflineTtsZipvoiceModelConfig, +} + +/// TTS configuration +#[repr(C)] +pub struct SherpaOnnxOfflineTtsConfig { + pub model: SherpaOnnxOfflineTtsModelConfig, + pub rule_fsts: *const c_char, + pub max_num_sentences: c_int, + pub rule_fars: *const c_char, + pub silence_scale: c_float, +} + +extern "C" { + /// Create TTS engine + pub fn SherpaOnnxCreateOfflineTts( + config: *const SherpaOnnxOfflineTtsConfig, + ) -> *mut SherpaOnnxOfflineTts; + + /// Destroy TTS engine + pub fn SherpaOnnxDestroyOfflineTts(tts: *mut SherpaOnnxOfflineTts); + + /// Generate audio (non-callback, faster) + pub fn SherpaOnnxOfflineTtsGenerate( + tts: *const SherpaOnnxOfflineTts, + text: *const c_char, + sid: c_int, + speed: c_float, + ) -> *const SherpaOnnxOfflineTtsGeneratedAudio; + + /// Destroy generated audio + pub fn SherpaOnnxDestroyOfflineTtsGeneratedAudio( + audio: *const SherpaOnnxOfflineTtsGeneratedAudio, + ); +} + +/// # Errors +/// Returns `Err` if the path contains an interior NUL byte. +pub fn path_to_cstring(path: &Path) -> Result { + CString::new(path.to_string_lossy().as_bytes()).map_err(|e| format!("Invalid path: {e}")) +} + +fn cstr_or_null(s: Option<&CString>) -> *const c_char { + s.map_or(ptr::null(), |s| s.as_ptr()) +} + +/// Owned parameters for a VITS (Piper) model. +/// +/// `Option` fields map to a null pointer when `None`. +pub struct VitsParams { + pub model: CString, + pub lexicon: Option, + pub tokens: CString, + pub data_dir: CString, + pub noise_scale: c_float, + pub noise_scale_w: c_float, + pub length_scale: c_float, + pub dict_dir: Option, +} + +impl VitsParams { + fn as_raw(&self) -> SherpaOnnxOfflineTtsVitsModelConfig { + SherpaOnnxOfflineTtsVitsModelConfig { + model: self.model.as_ptr(), + lexicon: cstr_or_null(self.lexicon.as_ref()), + tokens: self.tokens.as_ptr(), + data_dir: self.data_dir.as_ptr(), + noise_scale: self.noise_scale, + noise_scale_w: self.noise_scale_w, + length_scale: self.length_scale, + dict_dir: cstr_or_null(self.dict_dir.as_ref()), + } + } +} + +/// Owned parameters for a Matcha model. +pub struct MatchaParams { + pub acoustic_model: CString, + pub vocoder: CString, + pub lexicon: CString, + pub tokens: CString, + pub data_dir: CString, + pub noise_scale: c_float, + pub length_scale: c_float, + pub dict_dir: CString, +} + +impl MatchaParams { + fn as_raw(&self) -> SherpaOnnxOfflineTtsMatchaModelConfig { + SherpaOnnxOfflineTtsMatchaModelConfig { + acoustic_model: self.acoustic_model.as_ptr(), + vocoder: self.vocoder.as_ptr(), + lexicon: self.lexicon.as_ptr(), + tokens: self.tokens.as_ptr(), + data_dir: self.data_dir.as_ptr(), + noise_scale: self.noise_scale, + length_scale: self.length_scale, + dict_dir: self.dict_dir.as_ptr(), + } + } +} + +/// Owned parameters for a Kokoro model. +pub struct KokoroParams { + pub model: CString, + pub voices: CString, + pub tokens: CString, + pub data_dir: CString, + pub length_scale: c_float, + pub dict_dir: CString, + pub lexicon: CString, + pub lang: CString, +} + +impl KokoroParams { + fn as_raw(&self) -> SherpaOnnxOfflineTtsKokoroModelConfig { + SherpaOnnxOfflineTtsKokoroModelConfig { + model: self.model.as_ptr(), + voices: self.voices.as_ptr(), + tokens: self.tokens.as_ptr(), + data_dir: self.data_dir.as_ptr(), + length_scale: self.length_scale, + dict_dir: self.dict_dir.as_ptr(), + lexicon: self.lexicon.as_ptr(), + lang: self.lang.as_ptr(), + } + } +} + +enum ModelParams { + Vits(VitsParams), + Matcha(MatchaParams), + Kokoro(KokoroParams), +} + +/// Owned offline-TTS configuration. +/// +/// Keeps every `CString` the C config points at alive for the duration of +/// [`Self::create`], and fills the model families that are not in use with the +/// null placeholders the C API expects. `rule_fsts`/`rule_fars`, +/// `max_num_sentences = 1` and `silence_scale = 1.0` are fixed to the values +/// all plugins use. +pub struct OfflineTtsConfig { + provider: CString, + num_threads: c_int, + debug: c_int, + rule_fsts: CString, + rule_fars: CString, + max_num_sentences: c_int, + silence_scale: c_float, + model: ModelParams, +} + +impl OfflineTtsConfig { + fn base(provider: CString, num_threads: c_int, debug: bool, model: ModelParams) -> Self { + Self { + provider, + num_threads, + debug: c_int::from(debug), + rule_fsts: CString::default(), + rule_fars: CString::default(), + max_num_sentences: 1, + silence_scale: 1.0, + model, + } + } + + /// `provider` is the ONNX execution provider ("cpu", "cuda", ...). + pub fn vits(provider: CString, num_threads: c_int, debug: bool, params: VitsParams) -> Self { + Self::base(provider, num_threads, debug, ModelParams::Vits(params)) + } + + /// `provider` is the ONNX execution provider ("cpu", "cuda", ...). + pub fn matcha( + provider: CString, + num_threads: c_int, + debug: bool, + params: MatchaParams, + ) -> Self { + Self::base(provider, num_threads, debug, ModelParams::Matcha(params)) + } + + /// `provider` is the ONNX execution provider ("cpu", "cuda", ...). + pub fn kokoro( + provider: CString, + num_threads: c_int, + debug: bool, + params: KokoroParams, + ) -> Self { + Self::base(provider, num_threads, debug, ModelParams::Kokoro(params)) + } + + /// Create the TTS engine. The returned pointer must eventually be freed + /// with [`SherpaOnnxDestroyOfflineTts`]. + /// + /// # Errors + /// Returns `Err` if `SherpaOnnxCreateOfflineTts` returns a null engine. + pub fn create(&self) -> Result<*mut SherpaOnnxOfflineTts, String> { + let model = SherpaOnnxOfflineTtsModelConfig { + vits: match &self.model { + ModelParams::Vits(p) => p.as_raw(), + _ => SherpaOnnxOfflineTtsVitsModelConfig::default(), + }, + num_threads: self.num_threads, + debug: self.debug, + provider: self.provider.as_ptr(), + matcha: match &self.model { + ModelParams::Matcha(p) => p.as_raw(), + _ => SherpaOnnxOfflineTtsMatchaModelConfig::default(), + }, + kokoro: match &self.model { + ModelParams::Kokoro(p) => p.as_raw(), + _ => SherpaOnnxOfflineTtsKokoroModelConfig::default(), + }, + kitten: SherpaOnnxOfflineTtsKittenModelConfig::default(), + zipvoice: SherpaOnnxOfflineTtsZipvoiceModelConfig::default(), + }; + let config = SherpaOnnxOfflineTtsConfig { + model, + rule_fsts: self.rule_fsts.as_ptr(), + max_num_sentences: self.max_num_sentences, + rule_fars: self.rule_fars.as_ptr(), + silence_scale: self.silence_scale, + }; + + // SAFETY: `config` only points at `CString`s owned by `self`, which are + // alive for the whole call; sherpa-onnx copies them while constructing + // the engine. + let tts = unsafe { SherpaOnnxCreateOfflineTts(&raw const config) }; + if tts.is_null() { + return Err("Failed to create TTS engine".to_string()); + } + Ok(tts) + } +} diff --git a/plugins/native/kokoro/Cargo.lock b/plugins/native/kokoro/Cargo.lock index f4f26b048..a0886ccb2 100644 --- a/plugins/native/kokoro/Cargo.lock +++ b/plugins/native/kokoro/Cargo.lock @@ -73,6 +73,7 @@ dependencies = [ "once_cell", "serde", "serde_json", + "streamkit-plugin-native-common", "streamkit-plugin-sdk-native", "tracing", "unicode-segmentation", @@ -243,6 +244,10 @@ dependencies = [ "ts-rs", ] +[[package]] +name = "streamkit-plugin-native-common" +version = "0.1.0" + [[package]] name = "streamkit-plugin-sdk-native" version = "0.2.0" diff --git a/plugins/native/kokoro/Cargo.toml b/plugins/native/kokoro/Cargo.toml index 289c7c84a..8b4b2812a 100644 --- a/plugins/native/kokoro/Cargo.toml +++ b/plugins/native/kokoro/Cargo.toml @@ -14,6 +14,7 @@ crate-type = ["cdylib"] [dependencies] streamkit-plugin-sdk-native = { path = "../../../sdks/plugin-sdk/native" } +streamkit-plugin-native-common = { path = "../common" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" unicode-segmentation = "1.10" diff --git a/plugins/native/kokoro/src/ffi.rs b/plugins/native/kokoro/src/ffi.rs deleted file mode 100644 index 6d1be8548..000000000 --- a/plugins/native/kokoro/src/ffi.rs +++ /dev/null @@ -1,138 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 StreamKit Contributors -// -// SPDX-License-Identifier: MPL-2.0 - -//! Manual FFI bindings to Sherpa-ONNX C API -//! Based on https://github.com/k2-fsa/sherpa-onnx/blob/master/sherpa-onnx/c-api/c-api.h - -use std::os::raw::{c_char, c_float, c_int, c_void}; - -/// Opaque TTS engine handle -#[repr(C)] -pub struct SherpaOnnxOfflineTts { - _private: [u8; 0], -} - -/// Generated audio data -#[repr(C)] -pub struct SherpaOnnxOfflineTtsGeneratedAudio { - pub samples: *const c_float, - pub n: c_int, - pub sample_rate: c_int, -} - -/// Kokoro model configuration -#[repr(C)] -pub struct SherpaOnnxOfflineTtsKokoroModelConfig { - pub model: *const c_char, - pub voices: *const c_char, - pub tokens: *const c_char, - pub data_dir: *const c_char, - pub length_scale: c_float, - pub dict_dir: *const c_char, - pub lexicon: *const c_char, - pub lang: *const c_char, -} - -/// VITS model configuration (unused but needed for struct layout) -#[repr(C)] -pub struct SherpaOnnxOfflineTtsVitsModelConfig { - pub model: *const c_char, - pub lexicon: *const c_char, - pub tokens: *const c_char, - pub data_dir: *const c_char, - pub noise_scale: c_float, - pub noise_scale_w: c_float, - pub length_scale: c_float, - pub dict_dir: *const c_char, -} - -/// Placeholder for Matcha model config (not used, but needed for struct layout) -#[repr(C)] -pub struct SherpaOnnxOfflineTtsMatchaModelConfig { - pub acoustic_model: *const c_char, - pub vocoder: *const c_char, - pub lexicon: *const c_char, - pub tokens: *const c_char, - pub data_dir: *const c_char, - pub noise_scale: c_float, - pub length_scale: c_float, - pub dict_dir: *const c_char, -} - -/// Placeholder for Kitten model config (not used, but needed for struct layout) -#[repr(C)] -pub struct SherpaOnnxOfflineTtsKittenModelConfig { - pub model: *const c_char, - pub voices: *const c_char, - pub tokens: *const c_char, - pub data_dir: *const c_char, - pub length_scale: c_float, -} - -/// Placeholder for Zipvoice model config (not used, but needed for struct layout) -#[repr(C)] -pub struct SherpaOnnxOfflineTtsZipvoiceModelConfig { - pub tokens: *const c_char, - pub text_model: *const c_char, - pub flow_matching_model: *const c_char, - pub vocoder: *const c_char, - pub data_dir: *const c_char, - pub pinyin_dict: *const c_char, - pub feat_scale: c_float, - pub t_shift: c_float, - pub target_rms: c_float, - pub guidance_scale: c_float, -} - -/// TTS model configuration -#[repr(C)] -pub struct SherpaOnnxOfflineTtsModelConfig { - pub vits: SherpaOnnxOfflineTtsVitsModelConfig, - pub num_threads: c_int, - pub debug: c_int, - pub provider: *const c_char, - pub matcha: SherpaOnnxOfflineTtsMatchaModelConfig, - pub kokoro: SherpaOnnxOfflineTtsKokoroModelConfig, - pub kitten: SherpaOnnxOfflineTtsKittenModelConfig, - pub zipvoice: SherpaOnnxOfflineTtsZipvoiceModelConfig, -} - -/// TTS configuration -#[repr(C)] -pub struct SherpaOnnxOfflineTtsConfig { - pub model: SherpaOnnxOfflineTtsModelConfig, - pub rule_fsts: *const c_char, - pub max_num_sentences: c_int, - pub rule_fars: *const c_char, - pub silence_scale: c_float, -} - -/// Callback function type: (samples, count, arg) -> continue (1) or stop (0) -// Allow: FFI type alias for future use (streaming audio generation) -#[allow(dead_code)] -pub type SherpaOnnxGeneratedAudioCallbackWithArg = - Option c_int>; - -extern "C" { - /// Create TTS engine - pub fn SherpaOnnxCreateOfflineTts( - config: *const SherpaOnnxOfflineTtsConfig, - ) -> *mut SherpaOnnxOfflineTts; - - /// Destroy TTS engine - pub fn SherpaOnnxDestroyOfflineTts(tts: *mut SherpaOnnxOfflineTts); - - /// Generate audio (non-callback, faster) - pub fn SherpaOnnxOfflineTtsGenerate( - tts: *const SherpaOnnxOfflineTts, - text: *const c_char, - sid: c_int, - speed: c_float, - ) -> *const SherpaOnnxOfflineTtsGeneratedAudio; - - /// Destroy generated audio - pub fn SherpaOnnxDestroyOfflineTtsGeneratedAudio( - audio: *const SherpaOnnxOfflineTtsGeneratedAudio, - ); -} diff --git a/plugins/native/kokoro/src/kokoro_node.rs b/plugins/native/kokoro/src/kokoro_node.rs index 222e93ab2..9f6118e3f 100644 --- a/plugins/native/kokoro/src/kokoro_node.rs +++ b/plugins/native/kokoro/src/kokoro_node.rs @@ -5,7 +5,6 @@ use std::collections::HashMap; use std::ffi::CString; use std::path::{Path, PathBuf}; -use std::ptr; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Instant; @@ -13,7 +12,7 @@ use streamkit_plugin_sdk_native::prelude::*; use streamkit_plugin_sdk_native::streamkit_core::types::{AudioFormat, SampleFormat}; use crate::config::KokoroTtsConfig; -use crate::ffi; +use streamkit_plugin_native_common::sherpa_onnx as ffi; use streamkit_plugin_sdk_native::streamkit_core::text::SentenceSplitter; /// GPU availability status @@ -361,7 +360,7 @@ impl NativeProcessorNode for KokoroTtsNode { plugin_info!(logger, "❌ CACHE MISS - loading model (5 sec)"); // Try to create the engine with the requested execution provider - let engine_result = unsafe { create_tts_engine(&logger, &model_dir, &config) }; + let engine_result = create_tts_engine(&logger, &model_dir, &config); let engine_ptr = match engine_result { Ok(e) => e, @@ -380,7 +379,7 @@ impl NativeProcessorNode for KokoroTtsNode { let mut cpu_config = config.clone(); cpu_config.execution_provider = "cpu".to_string(); - match unsafe { create_tts_engine(&logger, &model_dir, &cpu_config) } { + match create_tts_engine(&logger, &model_dir, &cpu_config) { Ok(e) => { plugin_info!( logger, @@ -695,7 +694,7 @@ impl KokoroTtsNode { } /// Create TTS engine using Sherpa-ONNX C API -unsafe fn create_tts_engine( +fn create_tts_engine( logger: &Logger, model_dir: &Path, config: &KokoroTtsConfig, @@ -721,13 +720,13 @@ unsafe fn create_tts_engine( plugin_info!(logger, file = %path.display(), "File exists: {}", name); } - // Create C strings - keep them alive until after SherpaOnnxCreateOfflineTts call + // Create C strings - kept alive inside OfflineTtsConfig through create() plugin_info!(logger, "Creating CStrings for paths"); - let model_cstr = path_to_cstring(&model_path)?; - let voices_cstr = path_to_cstring(&voices_path)?; - let tokens_cstr = path_to_cstring(&tokens_path)?; - let data_dir_cstr = path_to_cstring(&data_dir)?; - let dict_dir_cstr = path_to_cstring(&dict_dir)?; + let model_cstr = ffi::path_to_cstring(&model_path)?; + let voices_cstr = ffi::path_to_cstring(&voices_path)?; + let tokens_cstr = ffi::path_to_cstring(&tokens_path)?; + let data_dir_cstr = ffi::path_to_cstring(&data_dir)?; + let dict_dir_cstr = ffi::path_to_cstring(&dict_dir)?; let lexicon = format!("{},{}", lexicon_us.to_string_lossy(), lexicon_zh.to_string_lossy()); plugin_info!(logger, lexicon = %lexicon, "Built lexicon string"); @@ -740,76 +739,7 @@ unsafe fn create_tts_engine( // Language field for Kokoro (empty = auto-detect) let lang_cstr = CString::new("").map_err(|e| format!("Invalid lang string: {e}"))?; - plugin_info!(logger, "All CStrings created, building config struct"); - - // Build config - match exact C API struct layout! - let tts_config = ffi::SherpaOnnxOfflineTtsConfig { - model: ffi::SherpaOnnxOfflineTtsModelConfig { - // VITS comes first in C API - vits: ffi::SherpaOnnxOfflineTtsVitsModelConfig { - model: ptr::null(), - lexicon: ptr::null(), - tokens: ptr::null(), - data_dir: ptr::null(), - noise_scale: 0.0, - noise_scale_w: 0.0, - length_scale: 1.0, - dict_dir: ptr::null(), - }, - // Common model config fields - num_threads: config.num_threads, - debug: 0, - provider: provider_cstr.as_ptr(), - // Matcha placeholder (unused) - matcha: ffi::SherpaOnnxOfflineTtsMatchaModelConfig { - acoustic_model: ptr::null(), - vocoder: ptr::null(), - lexicon: ptr::null(), - tokens: ptr::null(), - data_dir: ptr::null(), - noise_scale: 0.0, - length_scale: 1.0, - dict_dir: ptr::null(), - }, - // Kokoro config (what we actually use) - kokoro: ffi::SherpaOnnxOfflineTtsKokoroModelConfig { - model: model_cstr.as_ptr(), - voices: voices_cstr.as_ptr(), - tokens: tokens_cstr.as_ptr(), - data_dir: data_dir_cstr.as_ptr(), - length_scale: 1.0, - dict_dir: dict_dir_cstr.as_ptr(), - lexicon: lexicon_cstr.as_ptr(), - lang: lang_cstr.as_ptr(), - }, - // Kitten placeholder (unused) - kitten: ffi::SherpaOnnxOfflineTtsKittenModelConfig { - model: ptr::null(), - voices: ptr::null(), - tokens: ptr::null(), - data_dir: ptr::null(), - length_scale: 1.0, - }, - // Zipvoice placeholder (unused) - zipvoice: ffi::SherpaOnnxOfflineTtsZipvoiceModelConfig { - tokens: ptr::null(), - text_model: ptr::null(), - flow_matching_model: ptr::null(), - vocoder: ptr::null(), - data_dir: ptr::null(), - pinyin_dict: ptr::null(), - feat_scale: 0.0, - t_shift: 0.0, - target_rms: 0.0, - guidance_scale: 0.0, - }, - }, - // Use empty string for rules - rule_fsts: lang_cstr.as_ptr(), - max_num_sentences: 1, - rule_fars: lang_cstr.as_ptr(), - silence_scale: 1.0, - }; + plugin_info!(logger, "All CStrings created, building config"); plugin_info!(logger, model = %model_path.display(), @@ -826,22 +756,28 @@ unsafe fn create_tts_engine( plugin_warn!(logger, "2. Likely cause: CUDA provider not available in this ONNX Runtime build"); plugin_warn!(logger, "3. Or: CUDA version mismatch"); - let tts = ffi::SherpaOnnxCreateOfflineTts(&raw const tts_config); + let tts = ffi::OfflineTtsConfig::kokoro( + provider_cstr, + config.num_threads, + false, + ffi::KokoroParams { + model: model_cstr, + voices: voices_cstr, + tokens: tokens_cstr, + data_dir: data_dir_cstr, + length_scale: 1.0, + dict_dir: dict_dir_cstr, + lexicon: lexicon_cstr, + lang: lang_cstr, + }, + ) + .create()?; plugin_info!(logger, "✓ SherpaOnnxCreateOfflineTts succeeded: ptr={:p}", tts); - - if tts.is_null() { - return Err("Failed to create TTS engine".to_string()); - } - plugin_info!(logger, "TTS engine created successfully"); Ok(tts) } -fn path_to_cstring(path: &Path) -> Result { - CString::new(path.to_string_lossy().as_bytes()).map_err(|e| format!("Invalid path: {e}")) -} - impl Drop for KokoroTtsNode { fn drop(&mut self) { // Arc reference will be dropped automatically diff --git a/plugins/native/kokoro/src/lib.rs b/plugins/native/kokoro/src/lib.rs index 10a8758be..7109af2f7 100644 --- a/plugins/native/kokoro/src/lib.rs +++ b/plugins/native/kokoro/src/lib.rs @@ -3,7 +3,6 @@ // SPDX-License-Identifier: MPL-2.0 mod config; -mod ffi; mod kokoro_node; use kokoro_node::KokoroTtsNode; diff --git a/plugins/native/matcha/Cargo.lock b/plugins/native/matcha/Cargo.lock index 8ada496ae..808dd3efa 100644 --- a/plugins/native/matcha/Cargo.lock +++ b/plugins/native/matcha/Cargo.lock @@ -73,6 +73,7 @@ dependencies = [ "once_cell", "serde", "serde_json", + "streamkit-plugin-native-common", "streamkit-plugin-sdk-native", "tracing", "unicode-segmentation", @@ -243,6 +244,10 @@ dependencies = [ "ts-rs", ] +[[package]] +name = "streamkit-plugin-native-common" +version = "0.1.0" + [[package]] name = "streamkit-plugin-sdk-native" version = "0.2.0" diff --git a/plugins/native/matcha/Cargo.toml b/plugins/native/matcha/Cargo.toml index 9c7229a92..382f256f5 100644 --- a/plugins/native/matcha/Cargo.toml +++ b/plugins/native/matcha/Cargo.toml @@ -14,6 +14,7 @@ crate-type = ["cdylib"] [dependencies] streamkit-plugin-sdk-native = { path = "../../../sdks/plugin-sdk/native" } +streamkit-plugin-native-common = { path = "../common" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" unicode-segmentation = "1.10" diff --git a/plugins/native/matcha/src/ffi.rs b/plugins/native/matcha/src/ffi.rs deleted file mode 100644 index 6d1be8548..000000000 --- a/plugins/native/matcha/src/ffi.rs +++ /dev/null @@ -1,138 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 StreamKit Contributors -// -// SPDX-License-Identifier: MPL-2.0 - -//! Manual FFI bindings to Sherpa-ONNX C API -//! Based on https://github.com/k2-fsa/sherpa-onnx/blob/master/sherpa-onnx/c-api/c-api.h - -use std::os::raw::{c_char, c_float, c_int, c_void}; - -/// Opaque TTS engine handle -#[repr(C)] -pub struct SherpaOnnxOfflineTts { - _private: [u8; 0], -} - -/// Generated audio data -#[repr(C)] -pub struct SherpaOnnxOfflineTtsGeneratedAudio { - pub samples: *const c_float, - pub n: c_int, - pub sample_rate: c_int, -} - -/// Kokoro model configuration -#[repr(C)] -pub struct SherpaOnnxOfflineTtsKokoroModelConfig { - pub model: *const c_char, - pub voices: *const c_char, - pub tokens: *const c_char, - pub data_dir: *const c_char, - pub length_scale: c_float, - pub dict_dir: *const c_char, - pub lexicon: *const c_char, - pub lang: *const c_char, -} - -/// VITS model configuration (unused but needed for struct layout) -#[repr(C)] -pub struct SherpaOnnxOfflineTtsVitsModelConfig { - pub model: *const c_char, - pub lexicon: *const c_char, - pub tokens: *const c_char, - pub data_dir: *const c_char, - pub noise_scale: c_float, - pub noise_scale_w: c_float, - pub length_scale: c_float, - pub dict_dir: *const c_char, -} - -/// Placeholder for Matcha model config (not used, but needed for struct layout) -#[repr(C)] -pub struct SherpaOnnxOfflineTtsMatchaModelConfig { - pub acoustic_model: *const c_char, - pub vocoder: *const c_char, - pub lexicon: *const c_char, - pub tokens: *const c_char, - pub data_dir: *const c_char, - pub noise_scale: c_float, - pub length_scale: c_float, - pub dict_dir: *const c_char, -} - -/// Placeholder for Kitten model config (not used, but needed for struct layout) -#[repr(C)] -pub struct SherpaOnnxOfflineTtsKittenModelConfig { - pub model: *const c_char, - pub voices: *const c_char, - pub tokens: *const c_char, - pub data_dir: *const c_char, - pub length_scale: c_float, -} - -/// Placeholder for Zipvoice model config (not used, but needed for struct layout) -#[repr(C)] -pub struct SherpaOnnxOfflineTtsZipvoiceModelConfig { - pub tokens: *const c_char, - pub text_model: *const c_char, - pub flow_matching_model: *const c_char, - pub vocoder: *const c_char, - pub data_dir: *const c_char, - pub pinyin_dict: *const c_char, - pub feat_scale: c_float, - pub t_shift: c_float, - pub target_rms: c_float, - pub guidance_scale: c_float, -} - -/// TTS model configuration -#[repr(C)] -pub struct SherpaOnnxOfflineTtsModelConfig { - pub vits: SherpaOnnxOfflineTtsVitsModelConfig, - pub num_threads: c_int, - pub debug: c_int, - pub provider: *const c_char, - pub matcha: SherpaOnnxOfflineTtsMatchaModelConfig, - pub kokoro: SherpaOnnxOfflineTtsKokoroModelConfig, - pub kitten: SherpaOnnxOfflineTtsKittenModelConfig, - pub zipvoice: SherpaOnnxOfflineTtsZipvoiceModelConfig, -} - -/// TTS configuration -#[repr(C)] -pub struct SherpaOnnxOfflineTtsConfig { - pub model: SherpaOnnxOfflineTtsModelConfig, - pub rule_fsts: *const c_char, - pub max_num_sentences: c_int, - pub rule_fars: *const c_char, - pub silence_scale: c_float, -} - -/// Callback function type: (samples, count, arg) -> continue (1) or stop (0) -// Allow: FFI type alias for future use (streaming audio generation) -#[allow(dead_code)] -pub type SherpaOnnxGeneratedAudioCallbackWithArg = - Option c_int>; - -extern "C" { - /// Create TTS engine - pub fn SherpaOnnxCreateOfflineTts( - config: *const SherpaOnnxOfflineTtsConfig, - ) -> *mut SherpaOnnxOfflineTts; - - /// Destroy TTS engine - pub fn SherpaOnnxDestroyOfflineTts(tts: *mut SherpaOnnxOfflineTts); - - /// Generate audio (non-callback, faster) - pub fn SherpaOnnxOfflineTtsGenerate( - tts: *const SherpaOnnxOfflineTts, - text: *const c_char, - sid: c_int, - speed: c_float, - ) -> *const SherpaOnnxOfflineTtsGeneratedAudio; - - /// Destroy generated audio - pub fn SherpaOnnxDestroyOfflineTtsGeneratedAudio( - audio: *const SherpaOnnxOfflineTtsGeneratedAudio, - ); -} diff --git a/plugins/native/matcha/src/lib.rs b/plugins/native/matcha/src/lib.rs index 30cb565fd..cdac3066e 100644 --- a/plugins/native/matcha/src/lib.rs +++ b/plugins/native/matcha/src/lib.rs @@ -3,7 +3,6 @@ // SPDX-License-Identifier: MPL-2.0 mod config; -mod ffi; mod matcha_node; use matcha_node::MatchaTtsNode; diff --git a/plugins/native/matcha/src/matcha_node.rs b/plugins/native/matcha/src/matcha_node.rs index 545139572..cf9c7207c 100644 --- a/plugins/native/matcha/src/matcha_node.rs +++ b/plugins/native/matcha/src/matcha_node.rs @@ -5,14 +5,13 @@ use std::collections::HashMap; use std::ffi::CString; use std::path::{Path, PathBuf}; -use std::ptr; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, Mutex}; use streamkit_plugin_sdk_native::prelude::*; use streamkit_plugin_sdk_native::streamkit_core::types::{AudioFormat, SampleFormat}; use crate::config::MatchaTtsConfig; -use crate::ffi; +use streamkit_plugin_native_common::sherpa_onnx as ffi; use streamkit_plugin_sdk_native::streamkit_core::text::SentenceSplitter; /// GPU availability status @@ -372,7 +371,7 @@ impl NativeProcessorNode for MatchaTtsNode { plugin_info!(logger, "❌ CACHE MISS - loading model (5 sec)"); // Try to create the engine with the requested execution provider - let engine_result = unsafe { create_tts_engine(&logger, &model_dir, &config) }; + let engine_result = create_tts_engine(&logger, &model_dir, &config); let engine_ptr = match engine_result { Ok(e) => e, @@ -391,7 +390,7 @@ impl NativeProcessorNode for MatchaTtsNode { let mut cpu_config = config.clone(); cpu_config.execution_provider = "cpu".to_string(); - match unsafe { create_tts_engine(&logger, &model_dir, &cpu_config) } { + match create_tts_engine(&logger, &model_dir, &cpu_config) { Ok(e) => { plugin_info!( logger, @@ -643,7 +642,7 @@ impl MatchaTtsNode { } /// Create TTS engine using Sherpa-ONNX C API -unsafe fn create_tts_engine( +fn create_tts_engine( logger: &Logger, model_dir: &Path, config: &MatchaTtsConfig, @@ -669,17 +668,17 @@ unsafe fn create_tts_engine( plugin_info!(logger, file = %path.display(), "File exists: {}", name); } - // Create C strings - keep them alive until after SherpaOnnxCreateOfflineTts call + // Create C strings - kept alive inside OfflineTtsConfig through create() plugin_info!(logger, "Creating CStrings for paths"); - let acoustic_model_cstr = path_to_cstring(&acoustic_model)?; - let vocoder_cstr = path_to_cstring(&vocoder)?; - let tokens_cstr = path_to_cstring(&tokens_path)?; - let data_dir_cstr = path_to_cstring(&data_dir)?; + let acoustic_model_cstr = ffi::path_to_cstring(&acoustic_model)?; + let vocoder_cstr = ffi::path_to_cstring(&vocoder)?; + let tokens_cstr = ffi::path_to_cstring(&tokens_path)?; + let data_dir_cstr = ffi::path_to_cstring(&data_dir)?; // Lexicon is optional but recommended let lexicon_cstr = if lexicon_path.exists() { plugin_info!(logger, "Lexicon file found, using it"); - path_to_cstring(&lexicon_path)? + ffi::path_to_cstring(&lexicon_path)? } else { plugin_info!(logger, "Lexicon file not found, using empty string"); CString::new("").map_err(|e| format!("Invalid lexicon string: {e}"))? @@ -692,76 +691,7 @@ unsafe fn create_tts_engine( // Empty dict_dir for Matcha (used for Chinese models) let dict_dir_cstr = CString::new("").map_err(|e| format!("Invalid dict_dir string: {e}"))?; - plugin_info!(logger, "All CStrings created, building config struct"); - - // Build config - match exact C API struct layout! - let tts_config = ffi::SherpaOnnxOfflineTtsConfig { - model: ffi::SherpaOnnxOfflineTtsModelConfig { - // VITS comes first in C API (unused for Matcha) - vits: ffi::SherpaOnnxOfflineTtsVitsModelConfig { - model: ptr::null(), - lexicon: ptr::null(), - tokens: ptr::null(), - data_dir: ptr::null(), - noise_scale: 0.0, - noise_scale_w: 0.0, - length_scale: 1.0, - dict_dir: ptr::null(), - }, - // Common model config fields - num_threads: config.num_threads, - debug: 0, - provider: provider_cstr.as_ptr(), - // Matcha config (what we actually use) - matcha: ffi::SherpaOnnxOfflineTtsMatchaModelConfig { - acoustic_model: acoustic_model_cstr.as_ptr(), - vocoder: vocoder_cstr.as_ptr(), - lexicon: lexicon_cstr.as_ptr(), - tokens: tokens_cstr.as_ptr(), - data_dir: data_dir_cstr.as_ptr(), - noise_scale: config.noise_scale, - length_scale: config.length_scale, - dict_dir: dict_dir_cstr.as_ptr(), - }, - // Kokoro placeholder (unused) - kokoro: ffi::SherpaOnnxOfflineTtsKokoroModelConfig { - model: ptr::null(), - voices: ptr::null(), - tokens: ptr::null(), - data_dir: ptr::null(), - length_scale: 1.0, - dict_dir: ptr::null(), - lexicon: ptr::null(), - lang: ptr::null(), - }, - // Kitten placeholder (unused) - kitten: ffi::SherpaOnnxOfflineTtsKittenModelConfig { - model: ptr::null(), - voices: ptr::null(), - tokens: ptr::null(), - data_dir: ptr::null(), - length_scale: 1.0, - }, - // Zipvoice placeholder (unused) - zipvoice: ffi::SherpaOnnxOfflineTtsZipvoiceModelConfig { - tokens: ptr::null(), - text_model: ptr::null(), - flow_matching_model: ptr::null(), - vocoder: ptr::null(), - data_dir: ptr::null(), - pinyin_dict: ptr::null(), - feat_scale: 0.0, - t_shift: 0.0, - target_rms: 0.0, - guidance_scale: 0.0, - }, - }, - // Use empty string for rules - rule_fsts: dict_dir_cstr.as_ptr(), - max_num_sentences: 1, - rule_fars: dict_dir_cstr.as_ptr(), - silence_scale: 1.0, - }; + plugin_info!(logger, "All CStrings created, building config"); plugin_info!(logger, acoustic_model = %acoustic_model.display(), @@ -785,22 +715,28 @@ unsafe fn create_tts_engine( plugin_warn!(logger, "3. Or: CUDA version mismatch"); } - let tts = ffi::SherpaOnnxCreateOfflineTts(&raw const tts_config); + let tts = ffi::OfflineTtsConfig::matcha( + provider_cstr, + config.num_threads, + false, + ffi::MatchaParams { + acoustic_model: acoustic_model_cstr, + vocoder: vocoder_cstr, + lexicon: lexicon_cstr, + tokens: tokens_cstr, + data_dir: data_dir_cstr, + noise_scale: config.noise_scale, + length_scale: config.length_scale, + dict_dir: dict_dir_cstr, + }, + ) + .create()?; plugin_info!(logger, "✓ SherpaOnnxCreateOfflineTts succeeded: ptr={:p}", tts); - - if tts.is_null() { - return Err("Failed to create TTS engine".to_string()); - } - plugin_info!(logger, "TTS engine created successfully"); Ok(tts) } -fn path_to_cstring(path: &Path) -> Result { - CString::new(path.to_string_lossy().as_bytes()).map_err(|e| format!("Invalid path: {e}")) -} - impl Drop for MatchaTtsNode { fn drop(&mut self) { // Arc reference will be dropped automatically diff --git a/plugins/native/piper/Cargo.lock b/plugins/native/piper/Cargo.lock index 106f79cee..c689cb090 100644 --- a/plugins/native/piper/Cargo.lock +++ b/plugins/native/piper/Cargo.lock @@ -91,6 +91,7 @@ dependencies = [ "once_cell", "serde", "serde_json", + "streamkit-plugin-native-common", "streamkit-plugin-sdk-native", "tracing", "unicode-segmentation", @@ -243,6 +244,10 @@ dependencies = [ "ts-rs", ] +[[package]] +name = "streamkit-plugin-native-common" +version = "0.1.0" + [[package]] name = "streamkit-plugin-sdk-native" version = "0.2.0" diff --git a/plugins/native/piper/Cargo.toml b/plugins/native/piper/Cargo.toml index 618188993..cbce7560b 100644 --- a/plugins/native/piper/Cargo.toml +++ b/plugins/native/piper/Cargo.toml @@ -14,6 +14,7 @@ crate-type = ["cdylib"] [dependencies] streamkit-plugin-sdk-native = { path = "../../../sdks/plugin-sdk/native" } +streamkit-plugin-native-common = { path = "../common" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" unicode-segmentation = "1.10" diff --git a/plugins/native/piper/src/ffi.rs b/plugins/native/piper/src/ffi.rs deleted file mode 100644 index aa8478775..000000000 --- a/plugins/native/piper/src/ffi.rs +++ /dev/null @@ -1,139 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 StreamKit Contributors -// -// SPDX-License-Identifier: MPL-2.0 - -//! Manual FFI bindings to Sherpa-ONNX C API for VITS/Piper models -//! Based on https://github.com/k2-fsa/sherpa-onnx/blob/master/sherpa-onnx/c-api/c-api.h - -// Allow: FFI types/functions reserved for future use -#![allow(dead_code)] - -use std::os::raw::{c_char, c_float, c_int, c_void}; - -/// Opaque TTS engine handle -#[repr(C)] -pub struct SherpaOnnxOfflineTts { - _private: [u8; 0], -} - -/// Generated audio data -#[repr(C)] -pub struct SherpaOnnxOfflineTtsGeneratedAudio { - pub samples: *const c_float, - pub n: c_int, - pub sample_rate: c_int, -} - -/// VITS model configuration (what we use for Piper) -#[repr(C)] -pub struct SherpaOnnxOfflineTtsVitsModelConfig { - pub model: *const c_char, - pub lexicon: *const c_char, - pub tokens: *const c_char, - pub data_dir: *const c_char, - pub noise_scale: c_float, - pub noise_scale_w: c_float, - pub length_scale: c_float, - pub dict_dir: *const c_char, -} - -/// Placeholder for Matcha model config (not used, but needed for struct layout) -#[repr(C)] -pub struct SherpaOnnxOfflineTtsMatchaModelConfig { - pub acoustic_model: *const c_char, - pub vocoder: *const c_char, - pub lexicon: *const c_char, - pub tokens: *const c_char, - pub data_dir: *const c_char, - pub noise_scale: c_float, - pub length_scale: c_float, - pub dict_dir: *const c_char, -} - -/// Placeholder for Kokoro model config (not used, but needed for struct layout) -#[repr(C)] -pub struct SherpaOnnxOfflineTtsKokoroModelConfig { - pub model: *const c_char, - pub voices: *const c_char, - pub tokens: *const c_char, - pub data_dir: *const c_char, - pub length_scale: c_float, - pub dict_dir: *const c_char, - pub lexicon: *const c_char, - pub lang: *const c_char, -} - -/// Placeholder for Kitten model config (not used, but needed for struct layout) -#[repr(C)] -pub struct SherpaOnnxOfflineTtsKittenModelConfig { - pub model: *const c_char, - pub voices: *const c_char, - pub tokens: *const c_char, - pub data_dir: *const c_char, - pub length_scale: c_float, -} - -/// Placeholder for Zipvoice model config (not used, but needed for struct layout) -#[repr(C)] -pub struct SherpaOnnxOfflineTtsZipvoiceModelConfig { - pub tokens: *const c_char, - pub text_model: *const c_char, - pub flow_matching_model: *const c_char, - pub vocoder: *const c_char, - pub data_dir: *const c_char, - pub pinyin_dict: *const c_char, - pub feat_scale: c_float, - pub t_shift: c_float, - pub target_rms: c_float, - pub guidance_scale: c_float, -} - -/// TTS model configuration -#[repr(C)] -pub struct SherpaOnnxOfflineTtsModelConfig { - pub vits: SherpaOnnxOfflineTtsVitsModelConfig, - pub num_threads: c_int, - pub debug: c_int, - pub provider: *const c_char, - pub matcha: SherpaOnnxOfflineTtsMatchaModelConfig, - pub kokoro: SherpaOnnxOfflineTtsKokoroModelConfig, - pub kitten: SherpaOnnxOfflineTtsKittenModelConfig, - pub zipvoice: SherpaOnnxOfflineTtsZipvoiceModelConfig, -} - -/// TTS configuration -#[repr(C)] -pub struct SherpaOnnxOfflineTtsConfig { - pub model: SherpaOnnxOfflineTtsModelConfig, - pub rule_fsts: *const c_char, - pub max_num_sentences: c_int, - pub rule_fars: *const c_char, - pub silence_scale: c_float, -} - -/// Callback function type: (samples, count, arg) -> continue (1) or stop (0) -pub type SherpaOnnxGeneratedAudioCallbackWithArg = - Option c_int>; - -extern "C" { - /// Create TTS engine - pub fn SherpaOnnxCreateOfflineTts( - config: *const SherpaOnnxOfflineTtsConfig, - ) -> *mut SherpaOnnxOfflineTts; - - /// Destroy TTS engine - pub fn SherpaOnnxDestroyOfflineTts(tts: *mut SherpaOnnxOfflineTts); - - /// Generate audio (non-callback, faster) - pub fn SherpaOnnxOfflineTtsGenerate( - tts: *const SherpaOnnxOfflineTts, - text: *const c_char, - sid: c_int, - speed: c_float, - ) -> *const SherpaOnnxOfflineTtsGeneratedAudio; - - /// Destroy generated audio - pub fn SherpaOnnxDestroyOfflineTtsGeneratedAudio( - audio: *const SherpaOnnxOfflineTtsGeneratedAudio, - ); -} diff --git a/plugins/native/piper/src/lib.rs b/plugins/native/piper/src/lib.rs index f57339ab4..4a03979a1 100644 --- a/plugins/native/piper/src/lib.rs +++ b/plugins/native/piper/src/lib.rs @@ -5,7 +5,6 @@ #![allow(clippy::cognitive_complexity)] // Complex TTS initialization mod config; -mod ffi; mod piper_node; use piper_node::PiperTtsNode; diff --git a/plugins/native/piper/src/piper_node.rs b/plugins/native/piper/src/piper_node.rs index 3686fce8e..469f79b72 100644 --- a/plugins/native/piper/src/piper_node.rs +++ b/plugins/native/piper/src/piper_node.rs @@ -5,13 +5,12 @@ use std::collections::HashMap; use std::ffi::CString; use std::path::{Path, PathBuf}; -use std::ptr; use std::sync::Mutex; use streamkit_plugin_sdk_native::prelude::*; use streamkit_plugin_sdk_native::streamkit_core::types::{AudioFormat, SampleFormat}; use crate::config::PiperTtsConfig; -use crate::ffi; +use streamkit_plugin_native_common::sherpa_onnx as ffi; use streamkit_plugin_sdk_native::streamkit_core::text::SentenceSplitter; /// Wrapper for TTS engine pointer that implements Send/Sync @@ -39,7 +38,7 @@ static TTS_ENGINE_CACHE: std::sync::LazyLock { let cache_key = (model_dir.to_string_lossy().to_string(), default_config.num_threads); @@ -207,7 +206,7 @@ impl NativeProcessorNode for PiperTtsNode { "Creating new TTS engine" ); - let engine = unsafe { create_tts_engine(&model_dir, &config)? }; + let engine = create_tts_engine(&model_dir, &config)?; cache.insert(cache_key, TtsEnginePtr(engine)); engine } @@ -366,7 +365,7 @@ impl PiperTtsNode { } /// Create TTS engine using Sherpa-ONNX C API -unsafe fn create_tts_engine( +fn create_tts_engine( model_dir: &Path, config: &PiperTtsConfig, ) -> Result<*mut ffi::SherpaOnnxOfflineTts, String> { @@ -415,89 +414,17 @@ unsafe fn create_tts_engine( tracing::info!(file = %path.display(), "File exists: {}", name); } - // Create C strings - keep them alive until after SherpaOnnxCreateOfflineTts call + // Create C strings - kept alive inside OfflineTtsConfig through create() tracing::info!("Creating CStrings for paths"); - let model_cstr = path_to_cstring(&model_path)?; - let tokens_cstr = path_to_cstring(&tokens_path)?; - let data_dir_cstr = path_to_cstring(&data_dir)?; + let model_cstr = ffi::path_to_cstring(&model_path)?; + let tokens_cstr = ffi::path_to_cstring(&tokens_path)?; + let data_dir_cstr = ffi::path_to_cstring(&data_dir)?; - // IMPORTANT: Keep provider CStrings alive - // Allow: Hard-coded string literals are known to be valid C strings (no null bytes) + // Allow: Hard-coded string literal is a valid C string (no null bytes) #[allow(clippy::unwrap_used)] let provider_cpu_cstr = CString::new("cpu").unwrap(); - #[allow(clippy::unwrap_used)] - let empty_cstr = CString::new("").unwrap(); - - tracing::info!("All CStrings created, building config struct"); - - // Build config - match exact C API struct layout! - let tts_config = ffi::SherpaOnnxOfflineTtsConfig { - model: ffi::SherpaOnnxOfflineTtsModelConfig { - // VITS config (what we actually use for Piper) - vits: ffi::SherpaOnnxOfflineTtsVitsModelConfig { - model: model_cstr.as_ptr(), - lexicon: ptr::null(), - tokens: tokens_cstr.as_ptr(), - data_dir: data_dir_cstr.as_ptr(), - noise_scale: config.noise_scale, - noise_scale_w: config.noise_scale_w, - length_scale: config.length_scale, - dict_dir: ptr::null(), - }, - // Common model config fields - num_threads: config.num_threads, - debug: 1, - provider: provider_cpu_cstr.as_ptr(), - // Matcha placeholder (unused) - matcha: ffi::SherpaOnnxOfflineTtsMatchaModelConfig { - acoustic_model: ptr::null(), - vocoder: ptr::null(), - lexicon: ptr::null(), - tokens: ptr::null(), - data_dir: ptr::null(), - noise_scale: 0.0, - length_scale: 1.0, - dict_dir: ptr::null(), - }, - // Kokoro placeholder (unused) - kokoro: ffi::SherpaOnnxOfflineTtsKokoroModelConfig { - model: ptr::null(), - voices: ptr::null(), - tokens: ptr::null(), - data_dir: ptr::null(), - length_scale: 1.0, - dict_dir: ptr::null(), - lexicon: ptr::null(), - lang: ptr::null(), - }, - // Kitten placeholder (unused) - kitten: ffi::SherpaOnnxOfflineTtsKittenModelConfig { - model: ptr::null(), - voices: ptr::null(), - tokens: ptr::null(), - data_dir: ptr::null(), - length_scale: 1.0, - }, - // Zipvoice placeholder (unused) - zipvoice: ffi::SherpaOnnxOfflineTtsZipvoiceModelConfig { - tokens: ptr::null(), - text_model: ptr::null(), - flow_matching_model: ptr::null(), - vocoder: ptr::null(), - data_dir: ptr::null(), - pinyin_dict: ptr::null(), - feat_scale: 0.0, - t_shift: 0.0, - target_rms: 0.0, - guidance_scale: 0.0, - }, - }, - // Use empty string for rules - rule_fsts: empty_cstr.as_ptr(), - max_num_sentences: 1, - rule_fars: empty_cstr.as_ptr(), - silence_scale: 1.0, - }; + + tracing::info!("All CStrings created, building config"); tracing::info!( model = %model_path.display(), @@ -506,22 +433,28 @@ unsafe fn create_tts_engine( "About to call SherpaOnnxCreateOfflineTts" ); - let tts = ffi::SherpaOnnxCreateOfflineTts(&raw const tts_config); + let tts = ffi::OfflineTtsConfig::vits( + provider_cpu_cstr, + config.num_threads, + true, + ffi::VitsParams { + model: model_cstr, + lexicon: None, + tokens: tokens_cstr, + data_dir: data_dir_cstr, + noise_scale: config.noise_scale, + noise_scale_w: config.noise_scale_w, + length_scale: config.length_scale, + dict_dir: None, + }, + ) + .create()?; tracing::info!("SherpaOnnxCreateOfflineTts returned: ptr={:p}", tts); - - if tts.is_null() { - return Err("Failed to create TTS engine".to_string()); - } - tracing::info!("TTS engine created successfully"); Ok(tts) } -fn path_to_cstring(path: &Path) -> Result { - CString::new(path.to_string_lossy().as_bytes()).map_err(|e| format!("Invalid path: {e}")) -} - impl Drop for PiperTtsNode { fn drop(&mut self) { // Note: We don't destroy the TTS engine here because it's cached diff --git a/scripts/marketplace/build_official_plugins.sh b/scripts/marketplace/build_official_plugins.sh index 74eb2d3ef..5a802d541 100755 --- a/scripts/marketplace/build_official_plugins.sh +++ b/scripts/marketplace/build_official_plugins.sh @@ -16,7 +16,12 @@ metadata = json.loads(plugins_path.read_text()) plugin_ids = [plugin["id"] for plugin in metadata.get("plugins", [])] native_root = pathlib.Path("plugins/native") -native_dirs = [path.name for path in native_root.iterdir() if path.is_dir()] +# Skip shared support crates that are not plugins +native_dirs = [ + path.name + for path in native_root.iterdir() + if path.is_dir() and path.name != "common" +] missing = sorted(set(native_dirs) - set(plugin_ids)) if missing: diff --git a/scripts/marketplace/generate_official_plugins.py b/scripts/marketplace/generate_official_plugins.py index 2715df802..90994faba 100644 --- a/scripts/marketplace/generate_official_plugins.py +++ b/scripts/marketplace/generate_official_plugins.py @@ -93,6 +93,9 @@ def main() -> int: for plugin_dir in sorted(plugins_root.iterdir()): if not plugin_dir.is_dir(): continue + if plugin_dir.name == "common": + # Shared support crate, not a plugin + continue # Search order: plugin.yml, plugin.yaml, then deprecated marketplace.yml/yaml metadata_path = None is_deprecated = False From fb5f9ef50e4ca1b8d85fa12566d27ed42380f90f Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 19 Sep 2026 15:03:44 +0000 Subject: [PATCH 16/17] refactor(plugins): dedup SileroVAD into shared crate whisper, sensevoice, and parakeet each carried a copy of the same SileroVAD ort wrapper (new + process_chunk byte-identical; sensevoice and parakeet had drifted on dead helpers and constness). - plugins/native/common gains a silero_vad module behind the silero-vad feature so the heavy ort/ndarray deps stay optional. - process_chunk now returns (probability, is_speech) so callers stop re-implementing the threshold comparison inline; whisper keeps the probability for telemetry. - Dead helpers is_speech, reset, and threshold() are dropped; parakeet's non-const set_threshold (and its missing_const_for_fn allow) converge on whisper's const fn. - The plugins drop their own ort/ndarray/once_cell deps and their vad.rs copies; whisper's VAD tests move with the module. Finding: sfind-96154dc806254f089ee9bf7bbde0b0e3 Signed-off-by: streamkit-devin --- plugins/native/common/src/lib.rs | 2 + .../src/vad.rs => common/src/silero_vad.rs} | 54 +++---- plugins/native/parakeet/Cargo.lock | 11 +- plugins/native/parakeet/Cargo.toml | 6 +- plugins/native/parakeet/src/lib.rs | 1 - plugins/native/parakeet/src/parakeet_node.rs | 9 +- plugins/native/parakeet/src/vad.rs | 153 ------------------ plugins/native/sensevoice/Cargo.lock | 12 +- plugins/native/sensevoice/Cargo.toml | 6 +- plugins/native/sensevoice/src/lib.rs | 1 - .../native/sensevoice/src/sensevoice_node.rs | 9 +- plugins/native/sensevoice/src/vad.rs | 151 ----------------- plugins/native/whisper/Cargo.lock | 12 +- plugins/native/whisper/Cargo.toml | 4 +- plugins/native/whisper/src/lib.rs | 7 +- 15 files changed, 60 insertions(+), 378 deletions(-) rename plugins/native/{whisper/src/vad.rs => common/src/silero_vad.rs} (88%) delete mode 100644 plugins/native/parakeet/src/vad.rs delete mode 100644 plugins/native/sensevoice/src/vad.rs diff --git a/plugins/native/common/src/lib.rs b/plugins/native/common/src/lib.rs index bdcc8da15..f602015bc 100644 --- a/plugins/native/common/src/lib.rs +++ b/plugins/native/common/src/lib.rs @@ -12,3 +12,5 @@ //! `scripts/marketplace/build_official_plugins.sh`. pub mod sherpa_onnx; +#[cfg(feature = "silero-vad")] +pub mod silero_vad; diff --git a/plugins/native/whisper/src/vad.rs b/plugins/native/common/src/silero_vad.rs similarity index 88% rename from plugins/native/whisper/src/vad.rs rename to plugins/native/common/src/silero_vad.rs index 7dada3a60..1ffc57d96 100644 --- a/plugins/native/whisper/src/vad.rs +++ b/plugins/native/common/src/silero_vad.rs @@ -4,8 +4,8 @@ //! Silero VAD v6 wrapper for voice activity detection //! -//! This module provides a lightweight Rust wrapper around the Silero VAD v6 ONNX model -//! for detecting speech vs. silence in audio streams. +//! A lightweight Rust wrapper around the Silero VAD v6 ONNX model for +//! detecting speech vs. silence in audio streams, shared by the STT plugins. use ndarray::{Array1, Array2, Array3}; use ort::session::{builder::GraphOptimizationLevel, Session}; @@ -31,6 +31,9 @@ impl SileroVAD { /// * `model_path` - Path to the silero_vad.onnx model file /// * `sample_rate` - Audio sample rate (8000 or 16000) /// * `threshold` - Speech probability threshold (0.0-1.0, default 0.5) + /// + /// # Errors + /// Returns `Err` if the sample rate is unsupported or the model fails to load. pub fn new(model_path: &str, sample_rate: u32, threshold: f32) -> Result { // Validate sample rate if sample_rate != 8000 && sample_rate != 16000 { @@ -53,7 +56,7 @@ impl SileroVAD { Ok(Self { session, sample_rate, state, context, threshold }) } - /// Process a 512-sample audio chunk and return speech probability + /// Process a 512-sample audio chunk /// /// Silero VAD v6 requires context from the previous frame for temporal continuity. /// The model expects [context_samples + window_samples] = [64 + 512] = 576 samples. @@ -62,8 +65,12 @@ impl SileroVAD { /// * `audio` - Audio samples (exactly 512 samples) /// /// # Returns - /// Speech probability (0.0-1.0) - pub fn process_chunk(&mut self, audio: &[f32]) -> Result { + /// `(speech_probability, is_speech)` where `is_speech` applies the + /// configured threshold to the probability. + /// + /// # Errors + /// Returns `Err` if the chunk is not exactly 512 samples or inference fails. + pub fn process_chunk(&mut self, audio: &[f32]) -> Result<(f32, bool), String> { if audio.len() != 512 { return Err(format!("Silero VAD expects exactly 512 samples, got {}", audio.len())); } @@ -114,39 +121,13 @@ impl SileroVAD { // Update context: save last 64 samples of current audio for next frame self.context.copy_from_slice(&audio[audio.len() - 64..]); - Ok(probability) - } - - /// Check if audio chunk contains speech - /// - /// # Arguments - /// * `audio` - Audio samples (exactly 512 samples) - /// - /// # Returns - /// `true` if speech detected, `false` if silence - #[allow(dead_code)] - pub fn is_speech(&mut self, audio: &[f32]) -> Result { - let probability = self.process_chunk(audio)?; - Ok(probability >= self.threshold) - } - - /// Reset VAD state (clears RNN state and context buffer) - #[allow(dead_code)] - pub fn reset(&mut self) { - self.state.fill(0.0); - self.context.fill(0.0); + Ok((probability, probability >= self.threshold)) } /// Update speech threshold pub const fn set_threshold(&mut self, threshold: f32) { self.threshold = threshold.clamp(0.0, 1.0); } - - /// Get current threshold - #[allow(dead_code)] - pub const fn threshold(&self) -> f32 { - self.threshold - } } #[cfg(test)] @@ -175,11 +156,12 @@ mod tests { // Test with silence (zeros) let silence = vec![0.0f32; 512]; - let probability = vad.process_chunk(&silence).unwrap(); + let (probability, is_speech) = vad.process_chunk(&silence).unwrap(); println!("Silence probability: {}", probability); // Silence should have low probability assert!(probability < 0.5); + assert!(!is_speech); } #[test] @@ -197,7 +179,7 @@ mod tests { + 0.1 * (2.0 * std::f32::consts::PI * 2000.0 * t).sin(); } - let probability = vad.process_chunk(&audio).unwrap(); + let (probability, _) = vad.process_chunk(&audio).unwrap(); println!("Synthetic speech probability: {}", probability); } @@ -257,10 +239,10 @@ mod tests { for (i, chunk) in samples.chunks(512).enumerate() { if chunk.len() == 512 { - let probability = vad.process_chunk(chunk).unwrap(); + let (probability, is_speech) = vad.process_chunk(chunk).unwrap(); probabilities.push(probability); - if probability >= 0.5 { + if is_speech { speech_chunks += 1; } else { silence_chunks += 1; diff --git a/plugins/native/parakeet/Cargo.lock b/plugins/native/parakeet/Cargo.lock index 61f6537d7..83646a26b 100644 --- a/plugins/native/parakeet/Cargo.lock +++ b/plugins/native/parakeet/Cargo.lock @@ -449,10 +449,9 @@ dependencies = [ name = "parakeet-plugin-native" version = "0.3.1" dependencies = [ - "ndarray", - "ort", "serde", "serde_json", + "streamkit-plugin-native-common", "streamkit-plugin-sdk-native", "tracing", ] @@ -746,6 +745,14 @@ dependencies = [ "ts-rs", ] +[[package]] +name = "streamkit-plugin-native-common" +version = "0.1.0" +dependencies = [ + "ndarray", + "ort", +] + [[package]] name = "streamkit-plugin-sdk-native" version = "0.2.0" diff --git a/plugins/native/parakeet/Cargo.toml b/plugins/native/parakeet/Cargo.toml index 1560f654c..c9b9bc5a4 100644 --- a/plugins/native/parakeet/Cargo.toml +++ b/plugins/native/parakeet/Cargo.toml @@ -14,15 +14,11 @@ crate-type = ["cdylib"] [dependencies] streamkit-plugin-sdk-native = { path = "../../../sdks/plugin-sdk/native" } +streamkit-plugin-native-common = { path = "../common", features = ["silero-vad"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tracing = "0.1" -# For VAD support -ort = "=2.0.0-rc.10" -ndarray = "0.16" - - [lints.clippy] # Categories pedantic = { level = "warn", priority = -1 } diff --git a/plugins/native/parakeet/src/lib.rs b/plugins/native/parakeet/src/lib.rs index 1799a313a..1b7bcc123 100644 --- a/plugins/native/parakeet/src/lib.rs +++ b/plugins/native/parakeet/src/lib.rs @@ -11,7 +11,6 @@ mod config; mod ffi; mod parakeet_node; -mod vad; use parakeet_node::ParakeetNode; use streamkit_plugin_sdk_native::prelude::*; diff --git a/plugins/native/parakeet/src/parakeet_node.rs b/plugins/native/parakeet/src/parakeet_node.rs index d18a2a0e8..5df3ab962 100644 --- a/plugins/native/parakeet/src/parakeet_node.rs +++ b/plugins/native/parakeet/src/parakeet_node.rs @@ -14,7 +14,7 @@ use streamkit_plugin_sdk_native::streamkit_core::types::{ use crate::config::ParakeetConfig; use crate::ffi; -use crate::vad::SileroVAD; +use streamkit_plugin_native_common::silero_vad::SileroVAD; /// Wrapper for recognizer pointer with proper cleanup struct RecognizerWrapper { @@ -280,10 +280,9 @@ impl NativeProcessorNode for ParakeetNode { let vad = self.vad.as_mut().ok_or_else(|| { "VAD not initialized but use_vad is true".to_string() })?; - let probability = vad - .process_chunk(&vad_frame) - .map_err(|e| format!("VAD processing failed: {e}"))?; - probability >= self.config.vad_threshold + vad.process_chunk(&vad_frame) + .map_err(|e| format!("VAD processing failed: {e}"))? + .1 }; // vad borrow ends here let should_transcribe = if is_speech { diff --git a/plugins/native/parakeet/src/vad.rs b/plugins/native/parakeet/src/vad.rs deleted file mode 100644 index 082ff4e34..000000000 --- a/plugins/native/parakeet/src/vad.rs +++ /dev/null @@ -1,153 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 StreamKit Contributors -// -// SPDX-License-Identifier: MPL-2.0 - -//! Silero VAD v6 wrapper for voice activity detection -//! -//! This module provides a lightweight Rust wrapper around the Silero VAD v6 ONNX model -//! for detecting speech vs. silence in audio streams. - -use ndarray::{Array1, Array2, Array3}; -use ort::session::{builder::GraphOptimizationLevel, Session}; -use ort::value::Value; - -/// Silero VAD v6 for voice activity detection -/// -/// Processes audio in 512-sample chunks (32ms @ 16kHz) and maintains RNN state -/// and context between frames for temporal continuity. -#[derive(Debug)] -pub struct SileroVAD { - session: Session, - sample_rate: u32, - state: Array3, // RNN state [2, batch_size, 128] where batch_size=1 - context: Vec, // Context samples from previous frame (64 samples for v6) - threshold: f32, -} - -impl SileroVAD { - /// Create a new Silero VAD instance - /// - /// # Arguments - /// * `model_path` - Path to the silero_vad.onnx model file - /// * `sample_rate` - Audio sample rate (8000 or 16000) - /// * `threshold` - Speech probability threshold (0.0-1.0, default 0.5) - pub fn new(model_path: &str, sample_rate: u32, threshold: f32) -> Result { - // Validate sample rate - if sample_rate != 8000 && sample_rate != 16000 { - return Err(format!("Silero VAD only supports 8kHz or 16kHz, got {sample_rate}Hz")); - } - - let session = Session::builder() - .map_err(|e| format!("Failed to create session builder: {e}"))? - .with_optimization_level(GraphOptimizationLevel::Level3) - .map_err(|e| format!("Failed to set optimization level: {e}"))? - .commit_from_file(model_path) - .map_err(|e| format!("Failed to load VAD model from '{model_path}': {e}"))?; - - // Initialize RNN state [2, batch_size, 128] where batch_size=1 - let state = Array3::::zeros((2, 1, 128)); - - // Initialize context buffer with 64 zeros (window_size / 8 = 512 / 8 = 64) - let context = vec![0.0f32; 64]; - - Ok(Self { session, sample_rate, state, context, threshold }) - } - - /// Process a 512-sample audio chunk and return speech probability - /// - /// Silero VAD v6 requires context from the previous frame for temporal continuity. - /// The model expects [context_samples + window_samples] = [64 + 512] = 576 samples. - /// - /// # Arguments - /// * `audio` - Audio samples (exactly 512 samples) - /// - /// # Returns - /// Speech probability (0.0-1.0) - pub fn process_chunk(&mut self, audio: &[f32]) -> Result { - if audio.len() != 512 { - return Err(format!("Silero VAD expects exactly 512 samples, got {}", audio.len())); - } - - // Prepend context samples (64) to current audio (512) for effective window of 576 - let mut input_with_context = Vec::with_capacity(576); - input_with_context.extend_from_slice(&self.context); - input_with_context.extend_from_slice(audio); - - // Prepare input tensor with batch dimension: [batch_size, num_samples] = [1, 576] - let audio_input = Array2::from_shape_vec((1, 576), input_with_context) - .map_err(|e| format!("Failed to create audio input tensor: {e}"))?; - - // Sample rate as int64 scalar array - let sr_input = Array1::from_vec(vec![i64::from(self.sample_rate)]); - - let input_value = Value::from_array(audio_input) - .map_err(|e| format!("Failed to convert audio to Value: {e}"))?; - - let state_value = Value::from_array(self.state.clone()) - .map_err(|e| format!("Failed to convert state to Value: {e}"))?; - - let sr_value = Value::from_array(sr_input) - .map_err(|e| format!("Failed to convert sample rate to Value: {e}"))?; - - // Run inference with inputs: input, state, sr - let outputs = self - .session - .run(ort::inputs![input_value, state_value, sr_value]) - .map_err(|e| format!("VAD inference failed: {e}"))?; - - // Extract probability (first output) - let prob_view = outputs[0] - .try_extract_tensor::() - .map_err(|e| format!("Failed to extract probability: {e}"))?; - let probability = prob_view.1[0]; // Extract first element - - // Extract updated state (second output) - let state_view = outputs[1] - .try_extract_tensor::() - .map_err(|e| format!("Failed to extract state: {e}"))?; - let state_new = Array3::from_shape_vec((2, 1, 128), state_view.1.to_vec()) - .map_err(|e| format!("Failed to reshape state: {e}"))?; - - // Update state for next iteration - self.state = state_new; - - // Update context: save last 64 samples of current audio for next frame - self.context.copy_from_slice(&audio[audio.len() - 64..]); - - Ok(probability) - } - - /// Check if audio chunk contains speech - /// - /// # Arguments - /// * `audio` - Audio samples (exactly 512 samples) - /// - /// # Returns - /// `true` if speech detected, `false` if silence - #[allow(dead_code)] - pub fn is_speech(&mut self, audio: &[f32]) -> Result { - let probability = self.process_chunk(audio)?; - Ok(probability >= self.threshold) - } - - /// Reset VAD state (clears RNN state and context buffer) - #[allow(dead_code)] - pub fn reset(&mut self) { - self.state.fill(0.0); - self.context.fill(0.0); - } - - /// Update speech threshold - #[allow(dead_code)] - // Allow: f32::clamp is not const-stable, so this cannot be const fn. - #[allow(clippy::missing_const_for_fn)] - pub fn set_threshold(&mut self, threshold: f32) { - self.threshold = threshold.clamp(0.0, 1.0); - } - - /// Get current threshold - #[allow(dead_code)] - pub const fn threshold(&self) -> f32 { - self.threshold - } -} diff --git a/plugins/native/sensevoice/Cargo.lock b/plugins/native/sensevoice/Cargo.lock index 06098be7c..e365bc33b 100644 --- a/plugins/native/sensevoice/Cargo.lock +++ b/plugins/native/sensevoice/Cargo.lock @@ -621,11 +621,9 @@ name = "sensevoice-plugin-native" version = "0.3.0" dependencies = [ "cc", - "ndarray", - "once_cell", - "ort", "serde", "serde_json", + "streamkit-plugin-native-common", "streamkit-plugin-sdk-native", "tracing", ] @@ -748,6 +746,14 @@ dependencies = [ "ts-rs", ] +[[package]] +name = "streamkit-plugin-native-common" +version = "0.1.0" +dependencies = [ + "ndarray", + "ort", +] + [[package]] name = "streamkit-plugin-sdk-native" version = "0.2.0" diff --git a/plugins/native/sensevoice/Cargo.toml b/plugins/native/sensevoice/Cargo.toml index 4b74ba7ea..cbb9a8123 100644 --- a/plugins/native/sensevoice/Cargo.toml +++ b/plugins/native/sensevoice/Cargo.toml @@ -14,15 +14,11 @@ crate-type = ["cdylib"] [dependencies] streamkit-plugin-sdk-native = { path = "../../../sdks/plugin-sdk/native" } +streamkit-plugin-native-common = { path = "../common", features = ["silero-vad"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tracing = "0.1" -# For VAD support -ort = "2.0.0-rc.10" -ndarray = "0.16" -once_cell = "1.20" - [build-dependencies] cc = "1.0" diff --git a/plugins/native/sensevoice/src/lib.rs b/plugins/native/sensevoice/src/lib.rs index 11f88efa5..63fd06a16 100644 --- a/plugins/native/sensevoice/src/lib.rs +++ b/plugins/native/sensevoice/src/lib.rs @@ -10,7 +10,6 @@ mod config; mod ffi; mod sensevoice_node; -mod vad; use sensevoice_node::SenseVoiceNode; use streamkit_plugin_sdk_native::prelude::*; diff --git a/plugins/native/sensevoice/src/sensevoice_node.rs b/plugins/native/sensevoice/src/sensevoice_node.rs index 6eafb569f..f928ca21d 100644 --- a/plugins/native/sensevoice/src/sensevoice_node.rs +++ b/plugins/native/sensevoice/src/sensevoice_node.rs @@ -13,7 +13,7 @@ use streamkit_plugin_sdk_native::streamkit_core::types::{ use crate::config::SenseVoiceConfig; use crate::ffi; -use crate::vad::SileroVAD; +use streamkit_plugin_native_common::silero_vad::SileroVAD; /// Wrapper for recognizer pointer with proper cleanup struct RecognizerWrapper { @@ -321,10 +321,9 @@ impl NativeProcessorNode for SenseVoiceNode { let vad = self.vad.as_mut().ok_or_else(|| { "VAD not initialized but use_vad is true".to_string() })?; - let probability = vad - .process_chunk(&vad_frame) - .map_err(|e| format!("VAD processing failed: {e}"))?; - probability >= self.config.vad_threshold + vad.process_chunk(&vad_frame) + .map_err(|e| format!("VAD processing failed: {e}"))? + .1 }; // vad borrow ends here let should_transcribe = if is_speech { diff --git a/plugins/native/sensevoice/src/vad.rs b/plugins/native/sensevoice/src/vad.rs deleted file mode 100644 index 8b8a3b65d..000000000 --- a/plugins/native/sensevoice/src/vad.rs +++ /dev/null @@ -1,151 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 StreamKit Contributors -// -// SPDX-License-Identifier: MPL-2.0 - -//! Silero VAD v6 wrapper for voice activity detection -//! -//! This module provides a lightweight Rust wrapper around the Silero VAD v6 ONNX model -//! for detecting speech vs. silence in audio streams. - -use ndarray::{Array1, Array2, Array3}; -use ort::session::{builder::GraphOptimizationLevel, Session}; -use ort::value::Value; - -/// Silero VAD v6 for voice activity detection -/// -/// Processes audio in 512-sample chunks (32ms @ 16kHz) and maintains RNN state -/// and context between frames for temporal continuity. -#[derive(Debug)] -pub struct SileroVAD { - session: Session, - sample_rate: u32, - state: Array3, // RNN state [2, batch_size, 128] where batch_size=1 - context: Vec, // Context samples from previous frame (64 samples for v6) - threshold: f32, -} - -impl SileroVAD { - /// Create a new Silero VAD instance - /// - /// # Arguments - /// * `model_path` - Path to the silero_vad.onnx model file - /// * `sample_rate` - Audio sample rate (8000 or 16000) - /// * `threshold` - Speech probability threshold (0.0-1.0, default 0.5) - pub fn new(model_path: &str, sample_rate: u32, threshold: f32) -> Result { - // Validate sample rate - if sample_rate != 8000 && sample_rate != 16000 { - return Err(format!("Silero VAD only supports 8kHz or 16kHz, got {sample_rate}Hz")); - } - - let session = Session::builder() - .map_err(|e| format!("Failed to create session builder: {e}"))? - .with_optimization_level(GraphOptimizationLevel::Level3) - .map_err(|e| format!("Failed to set optimization level: {e}"))? - .commit_from_file(model_path) - .map_err(|e| format!("Failed to load VAD model from '{model_path}': {e}"))?; - - // Initialize RNN state [2, batch_size, 128] where batch_size=1 - let state = Array3::::zeros((2, 1, 128)); - - // Initialize context buffer with 64 zeros (window_size / 8 = 512 / 8 = 64) - let context = vec![0.0f32; 64]; - - Ok(Self { session, sample_rate, state, context, threshold }) - } - - /// Process a 512-sample audio chunk and return speech probability - /// - /// Silero VAD v6 requires context from the previous frame for temporal continuity. - /// The model expects [context_samples + window_samples] = [64 + 512] = 576 samples. - /// - /// # Arguments - /// * `audio` - Audio samples (exactly 512 samples) - /// - /// # Returns - /// Speech probability (0.0-1.0) - pub fn process_chunk(&mut self, audio: &[f32]) -> Result { - if audio.len() != 512 { - return Err(format!("Silero VAD expects exactly 512 samples, got {}", audio.len())); - } - - // Prepend context samples (64) to current audio (512) for effective window of 576 - let mut input_with_context = Vec::with_capacity(576); - input_with_context.extend_from_slice(&self.context); - input_with_context.extend_from_slice(audio); - - // Prepare input tensor with batch dimension: [batch_size, num_samples] = [1, 576] - let audio_input = Array2::from_shape_vec((1, 576), input_with_context) - .map_err(|e| format!("Failed to create audio input tensor: {e}"))?; - - // Sample rate as int64 scalar array - let sr_input = Array1::from_vec(vec![i64::from(self.sample_rate)]); - - let input_value = Value::from_array(audio_input) - .map_err(|e| format!("Failed to convert audio to Value: {e}"))?; - - let state_value = Value::from_array(self.state.clone()) - .map_err(|e| format!("Failed to convert state to Value: {e}"))?; - - let sr_value = Value::from_array(sr_input) - .map_err(|e| format!("Failed to convert sample rate to Value: {e}"))?; - - // Run inference with inputs: input, state, sr - let outputs = self - .session - .run(ort::inputs![input_value, state_value, sr_value]) - .map_err(|e| format!("VAD inference failed: {e}"))?; - - // Extract probability (first output) - let prob_view = outputs[0] - .try_extract_tensor::() - .map_err(|e| format!("Failed to extract probability: {e}"))?; - let probability = prob_view.1[0]; // Extract first element - - // Extract updated state (second output) - let state_view = outputs[1] - .try_extract_tensor::() - .map_err(|e| format!("Failed to extract state: {e}"))?; - let state_new = Array3::from_shape_vec((2, 1, 128), state_view.1.to_vec()) - .map_err(|e| format!("Failed to reshape state: {e}"))?; - - // Update state for next iteration - self.state = state_new; - - // Update context: save last 64 samples of current audio for next frame - self.context.copy_from_slice(&audio[audio.len() - 64..]); - - Ok(probability) - } - - /// Check if audio chunk contains speech - /// - /// # Arguments - /// * `audio` - Audio samples (exactly 512 samples) - /// - /// # Returns - /// `true` if speech detected, `false` if silence - #[allow(dead_code)] - pub fn is_speech(&mut self, audio: &[f32]) -> Result { - let probability = self.process_chunk(audio)?; - Ok(probability >= self.threshold) - } - - /// Reset VAD state (clears RNN state and context buffer) - #[allow(dead_code)] - pub fn reset(&mut self) { - self.state.fill(0.0); - self.context.fill(0.0); - } - - /// Update speech threshold - #[allow(dead_code)] - pub const fn set_threshold(&mut self, threshold: f32) { - self.threshold = threshold.clamp(0.0, 1.0); - } - - /// Get current threshold - #[allow(dead_code)] - pub const fn threshold(&self) -> f32 { - self.threshold - } -} diff --git a/plugins/native/whisper/Cargo.lock b/plugins/native/whisper/Cargo.lock index 9308280a8..297ebd837 100644 --- a/plugins/native/whisper/Cargo.lock +++ b/plugins/native/whisper/Cargo.lock @@ -896,6 +896,14 @@ dependencies = [ "ts-rs", ] +[[package]] +name = "streamkit-plugin-native-common" +version = "0.1.0" +dependencies = [ + "ndarray", + "ort", +] + [[package]] name = "streamkit-plugin-sdk-native" version = "0.2.0" @@ -1144,11 +1152,9 @@ dependencies = [ name = "whisper-plugin-native" version = "0.3.0" dependencies = [ - "ndarray", - "once_cell", - "ort", "serde", "serde_json", + "streamkit-plugin-native-common", "streamkit-plugin-sdk-native", "tracing", "whisper-rs", diff --git a/plugins/native/whisper/Cargo.toml b/plugins/native/whisper/Cargo.toml index e7ee973a9..edae25df6 100644 --- a/plugins/native/whisper/Cargo.toml +++ b/plugins/native/whisper/Cargo.toml @@ -14,13 +14,11 @@ crate-type = ["cdylib"] [dependencies] streamkit-plugin-sdk-native = { path = "../../../sdks/plugin-sdk/native" } +streamkit-plugin-native-common = { path = "../common", features = ["silero-vad"] } whisper-rs = "0.15" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tracing = "0.1" -ort = "2.0.0-rc.10" -ndarray = "0.16" -once_cell = "1.20" [features] default = [] diff --git a/plugins/native/whisper/src/lib.rs b/plugins/native/whisper/src/lib.rs index 713d1c8e3..62f0f54c9 100644 --- a/plugins/native/whisper/src/lib.rs +++ b/plugins/native/whisper/src/lib.rs @@ -7,17 +7,15 @@ //! This plugin provides high-performance CPU-based transcription with VAD-based //! segmentation for natural speech boundaries and zero chunking artifacts. -mod vad; - use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex}; +use streamkit_plugin_native_common::silero_vad::SileroVAD; use streamkit_plugin_sdk_native::prelude::*; use streamkit_plugin_sdk_native::streamkit_core::types::{ AudioFormat, SampleFormat, TranscriptionData, TranscriptionSegment, }; -use vad::SileroVAD; use whisper_rs::{ FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters, WhisperState, }; @@ -414,11 +412,10 @@ impl NativeProcessorNode for WhisperPlugin { while self.frame_buffer.len() >= 512 { let vad_frame: Vec = self.frame_buffer.drain(..512).collect(); - let probability = self + let (probability, is_speech) = self .vad .process_chunk(&vad_frame) .map_err(|e| format!("VAD processing failed: {e}"))?; - let is_speech = probability >= self.config.vad_threshold; if is_speech { // Speech detected From 5298909b9c9d01cfeccd5bc21844dbce671ac954 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 19 Sep 2026 15:46:17 +0000 Subject: [PATCH 17/17] chore(deps): bump rustls to 0.23.45 for RUSTSEC-2026-0285 cargo deny advisories fails on rustls 0.23.43 (TLS 1.3 handshake messages accepted across encryption level boundaries). Patch bump, no code changes; lockfile-only update via cargo update -p rustls. Signed-off-by: streamkit-devin --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a8d46bcea..7a2c6722d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5904,9 +5904,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -7135,7 +7135,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2",