WO-4: KinematicTransformFrame, DataChannelHub, Axum telemetry, StatsSource wiring
Browse filesT2: KinematicTransformFrame (88 bytes, repr(C)+Pod) in miranda-core closes
the WO-3 breath.rs gap where head_pitch_deg/clavicle_rise were computed but
could not be routed — ARKit-52 is face-only. from_breath() maps to joint
quaternions: head 100% X-rotation, neck 40%, shoulders +/-Z 4deg max.
Quaternion: 16 bytes, Pod, normalised/degenerate handling. RingId::Kinematic
added. miranda-ipc gains a 4th ring (KINEMATIC_SLOTS=128) with push/pop.
T3: DataChannelHub broadcasts 312-byte binary packets (MRD1 magic + two LE
u16 size fields + 216B BlendshapeFrame + 88B KinematicTransformFrame) to
all connected subscribers via bounded mpsc. Full channel drops frame and
increments counter, never stalls. Closed receivers pruned on next broadcast.
encode/decode in frame.rs is allocation-free in the hot path via scratch
BytesMut. webrtc 0.8.0 declared optional behind --features webrtc-native;
wire format is identical whether transport layer is axum WS or DTLS channel.
T4: TelemetryHub broadcasts camelCase JSON snapshots at configurable interval.
Three-state circuit breaker: Closed -> HalfOpen (any drops) -> Open (>=500ms
of drops); resets to Closed after 1000ms clean. DispatchSource trait
decouples the hub from miranda-nodes (testable with a mock). Axum server:
/health (JSON), /data (binary WS), /telemetry (text WS). Integration tests
use OS-assigned ports and a real tokio_tungstenite client.
T5: StatsSource wraps Arc<DispatchStats>, implements DispatchSource with
Relaxed atomics -- same ordering DispatchStats uses internally; telemetry
snapshots are samples, not synchronization points.
198 tests workspace-wide, zero warnings.
- miranda-core/src/lib.rs +378 -1
- miranda-ipc/src/lib.rs +53 -9
- miranda-transport/Cargo.toml +31 -0
- miranda-transport/src/frame.rs +242 -0
- miranda-transport/src/hub.rs +317 -0
- miranda-transport/src/lib.rs +80 -3
- miranda-transport/src/server.rs +379 -0
- miranda-transport/src/telemetry.rs +391 -0
- miranda-transport/src/wiring.rs +136 -0
|
@@ -277,7 +277,7 @@ pub struct BackpressureError {
|
|
| 277 |
pub capacity: usize,
|
| 278 |
}
|
| 279 |
|
| 280 |
-
/// Identifies which of the
|
| 281 |
/// came from.
|
| 282 |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 283 |
pub enum RingId {
|
|
@@ -287,6 +287,8 @@ pub enum RingId {
|
|
| 287 |
Blendshape,
|
| 288 |
/// The `SphericalHarmonics` ring.
|
| 289 |
SphericalHarmonics,
|
|
|
|
|
|
|
| 290 |
}
|
| 291 |
|
| 292 |
impl core::fmt::Display for BackpressureError {
|
|
@@ -430,3 +432,378 @@ mod tests {
|
|
| 430 |
assert_eq!(arkit::index_of("jawopen"), None);
|
| 431 |
}
|
| 432 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
pub capacity: usize,
|
| 278 |
}
|
| 279 |
|
| 280 |
+
/// Identifies which of the four rings on the bus a `BackpressureError`
|
| 281 |
/// came from.
|
| 282 |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 283 |
pub enum RingId {
|
|
|
|
| 287 |
Blendshape,
|
| 288 |
/// The `SphericalHarmonics` ring.
|
| 289 |
SphericalHarmonics,
|
| 290 |
+
/// The `KinematicTransformFrame` ring (added WO-4).
|
| 291 |
+
Kinematic,
|
| 292 |
}
|
| 293 |
|
| 294 |
impl core::fmt::Display for BackpressureError {
|
|
|
|
| 432 |
assert_eq!(arkit::index_of("jawopen"), None);
|
| 433 |
}
|
| 434 |
}
|
| 435 |
+
|
| 436 |
+
// ── KinematicTransformFrame ──────────────────────────────────────────────────
|
| 437 |
+
|
| 438 |
+
/// Number of skeletal joints carried in [`KinematicTransformFrame`].
|
| 439 |
+
///
|
| 440 |
+
/// HEAD, NECK, SHOULDER_LEFT, SHOULDER_RIGHT — the four joints the
|
| 441 |
+
/// respiratory oscillator (WO-3 T5) and future full-body kinematics need
|
| 442 |
+
/// to route. ARKit-52 is face-only and structurally cannot carry these, so
|
| 443 |
+
/// they travel on a parallel frame type rather than being smuggled into an
|
| 444 |
+
/// unrelated face channel.
|
| 445 |
+
pub const KINEMATIC_JOINT_COUNT: usize = 4;
|
| 446 |
+
|
| 447 |
+
/// Index constants for the joints in [`KinematicTransformFrame`].
|
| 448 |
+
///
|
| 449 |
+
/// Matches the order the renderer (WO-5) expects: head before neck so the
|
| 450 |
+
/// dependency chain is top-down.
|
| 451 |
+
pub mod kinematic_joints {
|
| 452 |
+
pub const HEAD: usize = 0;
|
| 453 |
+
pub const NECK: usize = 1;
|
| 454 |
+
pub const SHOULDER_LEFT: usize = 2;
|
| 455 |
+
pub const SHOULDER_RIGHT: usize = 3;
|
| 456 |
+
|
| 457 |
+
/// Human-readable names in index order, for telemetry and debug.
|
| 458 |
+
pub const JOINT_NAMES: [&str; super::KINEMATIC_JOINT_COUNT] =
|
| 459 |
+
["head", "neck", "shoulder_left", "shoulder_right"];
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
/// A unit quaternion representing a joint's local rotation, stored as
|
| 463 |
+
/// `[x, y, z, w]` in the standard conventions used by every game-engine
|
| 464 |
+
/// and animation tool this project targets (GLM, Three.js, Unity, Omniverse).
|
| 465 |
+
///
|
| 466 |
+
/// - Identity: `[0.0, 0.0, 0.0, 1.0]`.
|
| 467 |
+
/// - `w = 1.0` → no rotation. `w = 0.0, xyz = axis` → 180° rotation.
|
| 468 |
+
/// - **Must be unit-length.** Consumers normalize defensively, but a
|
| 469 |
+
/// non-unit quaternion is a producer bug — it will produce scale artefacts
|
| 470 |
+
/// on the rendered mesh.
|
| 471 |
+
///
|
| 472 |
+
/// Size: 4 × f32 = 16 bytes.
|
| 473 |
+
#[repr(C)]
|
| 474 |
+
#[derive(Debug, Clone, Copy, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
| 475 |
+
pub struct Quaternion {
|
| 476 |
+
pub x: f32,
|
| 477 |
+
pub y: f32,
|
| 478 |
+
pub z: f32,
|
| 479 |
+
pub w: f32,
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
impl Quaternion {
|
| 483 |
+
/// The identity rotation — no change from the bind pose.
|
| 484 |
+
pub const IDENTITY: Self = Self { x: 0.0, y: 0.0, z: 0.0, w: 1.0 };
|
| 485 |
+
|
| 486 |
+
/// Builds a quaternion from a rotation around the X axis (pitch), in
|
| 487 |
+
/// radians. Positive angle tilts the joint forward (head nods down).
|
| 488 |
+
#[inline]
|
| 489 |
+
pub fn from_angle_x(radians: f32) -> Self {
|
| 490 |
+
let (s, c) = (radians * 0.5).sin_cos();
|
| 491 |
+
Self { x: s, y: 0.0, z: 0.0, w: c }
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
/// Builds a quaternion from a rotation around the Y axis (yaw), in
|
| 495 |
+
/// radians.
|
| 496 |
+
#[inline]
|
| 497 |
+
pub fn from_angle_y(radians: f32) -> Self {
|
| 498 |
+
let (s, c) = (radians * 0.5).sin_cos();
|
| 499 |
+
Self { x: 0.0, y: s, z: 0.0, w: c }
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
/// Builds a quaternion from a rotation around the Z axis (roll), in
|
| 503 |
+
/// radians.
|
| 504 |
+
#[inline]
|
| 505 |
+
pub fn from_angle_z(radians: f32) -> Self {
|
| 506 |
+
let (s, c) = (radians * 0.5).sin_cos();
|
| 507 |
+
Self { x: 0.0, y: 0.0, z: s, w: c }
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
/// Squared magnitude — should be 1.0 for a valid rotation quaternion.
|
| 511 |
+
#[inline]
|
| 512 |
+
pub fn norm_sq(self) -> f32 {
|
| 513 |
+
self.x * self.x + self.y * self.y + self.z * self.z + self.w * self.w
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
/// Normalises to unit length. Returns `IDENTITY` if the quaternion is
|
| 517 |
+
/// degenerate (zero or near-zero magnitude) rather than propagating NaN.
|
| 518 |
+
#[inline]
|
| 519 |
+
pub fn normalised(self) -> Self {
|
| 520 |
+
let n = self.norm_sq().sqrt();
|
| 521 |
+
if n < f32::EPSILON {
|
| 522 |
+
return Self::IDENTITY;
|
| 523 |
+
}
|
| 524 |
+
let inv = 1.0 / n;
|
| 525 |
+
Self {
|
| 526 |
+
x: self.x * inv,
|
| 527 |
+
y: self.y * inv,
|
| 528 |
+
z: self.z * inv,
|
| 529 |
+
w: self.w * inv,
|
| 530 |
+
}
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
/// Returns `true` if this quaternion is within `tolerance` of unit
|
| 534 |
+
/// length. Useful in tests and assertions.
|
| 535 |
+
#[inline]
|
| 536 |
+
pub fn is_unit(&self, tolerance: f32) -> bool {
|
| 537 |
+
(self.norm_sq() - 1.0).abs() < tolerance
|
| 538 |
+
}
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
impl Default for Quaternion {
|
| 542 |
+
fn default() -> Self {
|
| 543 |
+
Self::IDENTITY
|
| 544 |
+
}
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
/// Body-rig kinematic transform frame — joint quaternions for the skeletal
|
| 548 |
+
/// joints that ARKit-52's face-only blendshape frame cannot carry.
|
| 549 |
+
///
|
| 550 |
+
/// This is the companion to [`BlendshapeFrame`], travelling in lockstep
|
| 551 |
+
/// through the transport layer. Together they carry the complete per-frame
|
| 552 |
+
/// face + body state that EVE's renderer needs.
|
| 553 |
+
///
|
| 554 |
+
/// # Layout (WO-4 defined — do not add fields without updating the IPC ring)
|
| 555 |
+
///
|
| 556 |
+
/// ```text
|
| 557 |
+
/// offset 0 : timestamp_us : u64 → 8 bytes
|
| 558 |
+
/// offset 8 : joints : [Quaternion; 4] → 64 bytes (4 × 16)
|
| 559 |
+
/// offset 72 : head_pitch_deg : f32 → 4 bytes
|
| 560 |
+
/// offset 76 : clavicle_rise : f32 → 4 bytes
|
| 561 |
+
/// offset 80 : _reserved : [u8; 8] → 8 bytes (explicit pad)
|
| 562 |
+
/// ─────────────────────────────────────────────────────
|
| 563 |
+
/// total 88 bytes
|
| 564 |
+
/// ```
|
| 565 |
+
///
|
| 566 |
+
/// The `_reserved` field is zero on write and must be ignored on read.
|
| 567 |
+
/// It provides expansion room (e.g. for a spine joint, shoulder elevation
|
| 568 |
+
/// scalar, or protocol version byte) without breaking the mmap layout or
|
| 569 |
+
/// existing consumers.
|
| 570 |
+
///
|
| 571 |
+
/// # The WO-3 gap this closes
|
| 572 |
+
///
|
| 573 |
+
/// `miranda-nodes/src/breath.rs` computes `head_pitch_deg` (head nodding
|
| 574 |
+
/// with the respiratory cycle) and `clavicle_rise` (chest rising on
|
| 575 |
+
/// inhalation). Both are physiologically correct values, but `BlendshapeFrame`
|
| 576 |
+
/// — ARKit-52 face weights only — has nowhere to put them. Rather than
|
| 577 |
+
/// silently smuggling them into an unrelated face channel, WO-3 documented
|
| 578 |
+
/// the gap and wrote a test asserting no smuggling occurred. This struct is
|
| 579 |
+
/// the promised fix: `BreathGenerator::build_transform_frame()` (WO-3
|
| 580 |
+
/// `breath.rs`) can now populate the right fields.
|
| 581 |
+
#[repr(C)]
|
| 582 |
+
#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
| 583 |
+
pub struct KinematicTransformFrame {
|
| 584 |
+
/// Microseconds since harness start — matches [`BlendshapeFrame::timestamp_us`]
|
| 585 |
+
/// so the renderer can pair the two frames by timestamp.
|
| 586 |
+
pub timestamp_us: u64,
|
| 587 |
+
/// Local-space rotation quaternions for each joint, in
|
| 588 |
+
/// [`kinematic_joints`] index order. Identity quaternion (`w=1`) means
|
| 589 |
+
/// no deviation from the bind pose.
|
| 590 |
+
pub joints: [Quaternion; KINEMATIC_JOINT_COUNT],
|
| 591 |
+
/// Head nodding amplitude from the respiratory cycle, in degrees.
|
| 592 |
+
/// Positive = forward nod (chin toward chest). Signed — the head nods
|
| 593 |
+
/// slightly backward on the exhale.
|
| 594 |
+
///
|
| 595 |
+
/// This is a convenience scalar: the full rotation is also encoded in
|
| 596 |
+
/// `joints[HEAD]`, but consumers that only need the scalar can read
|
| 597 |
+
/// this field without decomposing a quaternion.
|
| 598 |
+
pub head_pitch_deg: f32,
|
| 599 |
+
/// Fractional clavicle rise from inhalation, in `[0, 1]`. Zero is the
|
| 600 |
+
/// resting position; 1.0 would be full elevation (never reached in
|
| 601 |
+
/// practice — normal tidal breathing drives this to roughly 0.3).
|
| 602 |
+
///
|
| 603 |
+
/// Also a convenience scalar mirroring `joints[SHOULDER_LEFT/RIGHT]`'s
|
| 604 |
+
/// vertical component.
|
| 605 |
+
pub clavicle_rise: f32,
|
| 606 |
+
/// Reserved for future fields. Always zero on write.
|
| 607 |
+
pub _reserved: [u8; 8],
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
impl KinematicTransformFrame {
|
| 611 |
+
/// Rest-pose frame — all joints at identity, scalar outputs zero.
|
| 612 |
+
pub const REST: Self = Self {
|
| 613 |
+
timestamp_us: 0,
|
| 614 |
+
joints: [Quaternion::IDENTITY; KINEMATIC_JOINT_COUNT],
|
| 615 |
+
head_pitch_deg: 0.0,
|
| 616 |
+
clavicle_rise: 0.0,
|
| 617 |
+
_reserved: [0; 8],
|
| 618 |
+
};
|
| 619 |
+
|
| 620 |
+
/// Builds a frame from the outputs of `BreathGenerator::tick()`.
|
| 621 |
+
///
|
| 622 |
+
/// Converts `head_pitch_deg` into a proper X-axis rotation quaternion
|
| 623 |
+
/// for the head joint and a matching smaller rotation for the neck
|
| 624 |
+
/// joint (neck typically participates at ≈40% of head travel). Shoulders
|
| 625 |
+
/// rise symmetrically by `clavicle_rise`.
|
| 626 |
+
pub fn from_breath(
|
| 627 |
+
timestamp_us: u64,
|
| 628 |
+
head_pitch_deg: f32,
|
| 629 |
+
clavicle_rise: f32,
|
| 630 |
+
) -> Self {
|
| 631 |
+
let head_rad = head_pitch_deg.to_radians();
|
| 632 |
+
// Neck participates at 40% of head pitch — anatomically, head motion
|
| 633 |
+
// distributes across cervical vertebrae, not just the atlanto-occipital
|
| 634 |
+
// joint. A 60/40 split between head and neck joints is the standard
|
| 635 |
+
// authoring convention used by major rigs (Metahuman, UE5 mannequin).
|
| 636 |
+
let neck_rad = head_rad * 0.4;
|
| 637 |
+
// Shoulder elevation: clavicle_rise in [0,1] maps to a Z-axis
|
| 638 |
+
// rotation of up to 4° per shoulder (typical quiet breathing range).
|
| 639 |
+
let shoulder_elevation_rad = clavicle_rise * 4.0f32.to_radians();
|
| 640 |
+
|
| 641 |
+
let mut joints = [Quaternion::IDENTITY; KINEMATIC_JOINT_COUNT];
|
| 642 |
+
joints[kinematic_joints::HEAD] = Quaternion::from_angle_x(head_rad).normalised();
|
| 643 |
+
joints[kinematic_joints::NECK] = Quaternion::from_angle_x(neck_rad).normalised();
|
| 644 |
+
// Shoulders: left rotates positive Z, right negative Z (symmetric
|
| 645 |
+
// elevation). The sign convention matches the Y-up, right-hand
|
| 646 |
+
// coordinate system used by Three.js / GLB rigs.
|
| 647 |
+
joints[kinematic_joints::SHOULDER_LEFT] =
|
| 648 |
+
Quaternion::from_angle_z(shoulder_elevation_rad).normalised();
|
| 649 |
+
joints[kinematic_joints::SHOULDER_RIGHT] =
|
| 650 |
+
Quaternion::from_angle_z(-shoulder_elevation_rad).normalised();
|
| 651 |
+
|
| 652 |
+
Self {
|
| 653 |
+
timestamp_us,
|
| 654 |
+
joints,
|
| 655 |
+
head_pitch_deg,
|
| 656 |
+
clavicle_rise,
|
| 657 |
+
_reserved: [0; 8],
|
| 658 |
+
}
|
| 659 |
+
}
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
impl Default for KinematicTransformFrame {
|
| 663 |
+
fn default() -> Self {
|
| 664 |
+
Self::REST
|
| 665 |
+
}
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
#[cfg(test)]
|
| 669 |
+
mod kinematic_tests {
|
| 670 |
+
use super::*;
|
| 671 |
+
|
| 672 |
+
#[test]
|
| 673 |
+
fn struct_size_is_exactly_88_bytes() {
|
| 674 |
+
assert_eq!(std::mem::size_of::<Quaternion>(), 16);
|
| 675 |
+
assert_eq!(
|
| 676 |
+
std::mem::size_of::<KinematicTransformFrame>(),
|
| 677 |
+
88,
|
| 678 |
+
"KinematicTransformFrame layout changed — update the IPC ring \
|
| 679 |
+
capacity and the transport framing"
|
| 680 |
+
);
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
#[test]
|
| 684 |
+
fn rest_frame_is_all_identity_and_zero() {
|
| 685 |
+
let f = KinematicTransformFrame::REST;
|
| 686 |
+
assert_eq!(f.timestamp_us, 0);
|
| 687 |
+
assert_eq!(f.head_pitch_deg, 0.0);
|
| 688 |
+
assert_eq!(f.clavicle_rise, 0.0);
|
| 689 |
+
assert_eq!(f._reserved, [0u8; 8]);
|
| 690 |
+
for j in f.joints.iter() {
|
| 691 |
+
assert_eq!(*j, Quaternion::IDENTITY);
|
| 692 |
+
}
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
#[test]
|
| 696 |
+
fn identity_quaternion_is_unit_length() {
|
| 697 |
+
assert!(Quaternion::IDENTITY.is_unit(1e-6));
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
#[test]
|
| 701 |
+
fn quaternion_from_angle_x_is_unit() {
|
| 702 |
+
for deg in [-90.0f32, -45.0, -1.0, 0.0, 1.0, 15.0, 45.0, 90.0] {
|
| 703 |
+
let q = Quaternion::from_angle_x(deg.to_radians());
|
| 704 |
+
assert!(
|
| 705 |
+
q.is_unit(1e-5),
|
| 706 |
+
"from_angle_x({deg}) norm² = {} (not 1)",
|
| 707 |
+
q.norm_sq()
|
| 708 |
+
);
|
| 709 |
+
}
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
#[test]
|
| 713 |
+
fn from_breath_produces_valid_quaternions() {
|
| 714 |
+
let f = KinematicTransformFrame::from_breath(1_000_000, 0.6, 0.3);
|
| 715 |
+
for (i, j) in f.joints.iter().enumerate() {
|
| 716 |
+
assert!(
|
| 717 |
+
j.is_unit(1e-5),
|
| 718 |
+
"joint {} quaternion is not unit: {:?}", i, j
|
| 719 |
+
);
|
| 720 |
+
}
|
| 721 |
+
assert_eq!(f.head_pitch_deg, 0.6);
|
| 722 |
+
assert_eq!(f.clavicle_rise, 0.3);
|
| 723 |
+
assert_eq!(f._reserved, [0u8; 8]);
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
#[test]
|
| 727 |
+
fn head_leads_neck() {
|
| 728 |
+
// Head joint must rotate more than neck — neck participates at 40%.
|
| 729 |
+
let f = KinematicTransformFrame::from_breath(0, 5.0, 0.0);
|
| 730 |
+
// The X component of a quaternion is sin(θ/2); larger pitch ⇒ larger |x|.
|
| 731 |
+
let head_x = f.joints[kinematic_joints::HEAD].x.abs();
|
| 732 |
+
let neck_x = f.joints[kinematic_joints::NECK].x.abs();
|
| 733 |
+
assert!(
|
| 734 |
+
head_x > neck_x,
|
| 735 |
+
"head x={head_x} should lead neck x={neck_x}"
|
| 736 |
+
);
|
| 737 |
+
assert!(
|
| 738 |
+
(head_x / neck_x - 1.0 / 0.4).abs() < 0.01,
|
| 739 |
+
"neck should be ~40% of head pitch, got ratio {}",
|
| 740 |
+
head_x / neck_x
|
| 741 |
+
);
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
#[test]
|
| 745 |
+
fn shoulders_are_symmetric_and_scale_with_clavicle_rise() {
|
| 746 |
+
let f_half = KinematicTransformFrame::from_breath(0, 0.0, 0.5);
|
| 747 |
+
let f_full = KinematicTransformFrame::from_breath(0, 0.0, 1.0);
|
| 748 |
+
let sl = f_half.joints[kinematic_joints::SHOULDER_LEFT];
|
| 749 |
+
let sr = f_half.joints[kinematic_joints::SHOULDER_RIGHT];
|
| 750 |
+
// Symmetric: Z components should be equal in magnitude, opposite in sign.
|
| 751 |
+
assert!(
|
| 752 |
+
(sl.z + sr.z).abs() < 1e-6,
|
| 753 |
+
"shoulders are not symmetric: L.z={} R.z={}", sl.z, sr.z
|
| 754 |
+
);
|
| 755 |
+
// Scale: full rise should rotate more than half rise.
|
| 756 |
+
let sl_full = f_full.joints[kinematic_joints::SHOULDER_LEFT];
|
| 757 |
+
assert!(
|
| 758 |
+
sl_full.z.abs() > sl.z.abs(),
|
| 759 |
+
"shoulder at rise=1.0 should exceed rise=0.5"
|
| 760 |
+
);
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
#[test]
|
| 764 |
+
fn zero_breath_produces_rest_pose_joints() {
|
| 765 |
+
let f = KinematicTransformFrame::from_breath(0, 0.0, 0.0);
|
| 766 |
+
for (i, j) in f.joints.iter().enumerate() {
|
| 767 |
+
assert!(
|
| 768 |
+
j.is_unit(1e-6) && (j.x.abs() < 1e-7) && (j.y.abs() < 1e-7) && (j.z.abs() < 1e-7),
|
| 769 |
+
"joint {i} should be identity at zero breath: {j:?}"
|
| 770 |
+
);
|
| 771 |
+
}
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
#[test]
|
| 775 |
+
fn bytemuck_pod_round_trip() {
|
| 776 |
+
// Proves the Pod derive is sound: casting to bytes and back must
|
| 777 |
+
// give an identical value, with no padding bytes that could be
|
| 778 |
+
// non-deterministic.
|
| 779 |
+
let original = KinematicTransformFrame::from_breath(42_000, 0.8, 0.25);
|
| 780 |
+
let bytes: &[u8] = bytemuck::bytes_of(&original);
|
| 781 |
+
assert_eq!(bytes.len(), 88);
|
| 782 |
+
let recovered: KinematicTransformFrame = *bytemuck::from_bytes(bytes);
|
| 783 |
+
// Compare field-by-field since f32 doesn't impl Eq.
|
| 784 |
+
assert_eq!(recovered.timestamp_us, original.timestamp_us);
|
| 785 |
+
assert_eq!(recovered.head_pitch_deg, original.head_pitch_deg);
|
| 786 |
+
assert_eq!(recovered.clavicle_rise, original.clavicle_rise);
|
| 787 |
+
for i in 0..KINEMATIC_JOINT_COUNT {
|
| 788 |
+
assert_eq!(recovered.joints[i], original.joints[i]);
|
| 789 |
+
}
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
/// Joint index names must agree with `JOINT_NAMES`.
|
| 793 |
+
#[test]
|
| 794 |
+
fn joint_constants_match_names_array() {
|
| 795 |
+
use kinematic_joints::*;
|
| 796 |
+
assert_eq!(JOINT_NAMES[HEAD], "head");
|
| 797 |
+
assert_eq!(JOINT_NAMES[NECK], "neck");
|
| 798 |
+
assert_eq!(JOINT_NAMES[SHOULDER_LEFT], "shoulder_left");
|
| 799 |
+
assert_eq!(JOINT_NAMES[SHOULDER_RIGHT], "shoulder_right");
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
/// Normalise must not panic or produce NaN on a zero quaternion.
|
| 803 |
+
#[test]
|
| 804 |
+
fn normalise_handles_degenerate_input() {
|
| 805 |
+
let q = Quaternion { x: 0.0, y: 0.0, z: 0.0, w: 0.0 };
|
| 806 |
+
let n = q.normalised();
|
| 807 |
+
assert_eq!(n, Quaternion::IDENTITY, "degenerate quaternion must return IDENTITY");
|
| 808 |
+
}
|
| 809 |
+
}
|
|
@@ -1,11 +1,12 @@
|
|
| 1 |
//! miranda-ipc — Work Order 1: the POSIX shared-memory ring buffer at
|
| 2 |
//! `/dev/shm/miranda_bus`, lock-free atomic head/tail pointers, and C-ABI
|
| 3 |
//! aligned payload structs for audio chunks, 52-channel ARKit blendshape
|
| 4 |
-
//! frames,
|
|
|
|
| 5 |
//!
|
| 6 |
//! # What this is
|
| 7 |
//!
|
| 8 |
-
//!
|
| 9 |
//! sharing one memory mapping. Each ring is lock-free: coordination is a pair
|
| 10 |
//! of `AtomicUsize` counters (`head` = read position, `tail` = write
|
| 11 |
//! position), never a mutex. The bus is on the critical path of a 60 FPS
|
|
@@ -50,7 +51,10 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
|
| 50 |
|
| 51 |
use bytemuck::Pod;
|
| 52 |
use memmap2::MmapMut;
|
| 53 |
-
use miranda_core::{
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
/// Canonical path for the production bus. `/dev/shm` is a tmpfs mount
|
| 56 |
/// (RAM-backed), which is what makes the ≤50 μs round-trip target
|
|
@@ -71,6 +75,9 @@ const CTRL_BYTES: usize = 2 * CACHE_LINE;
|
|
| 71 |
const AUDIO_SLOTS: usize = 64;
|
| 72 |
const BLEND_SLOTS: usize = 128;
|
| 73 |
const SH_SLOTS: usize = 128;
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
/// Rounds `value` up to the next multiple of `align`. `align` must be a
|
| 76 |
/// power of two.
|
|
@@ -94,8 +101,13 @@ const SH_CTRL_OFF: usize = align_up(BLEND_DATA_OFF + BLEND_DATA_BYTES, CACHE_LIN
|
|
| 94 |
const SH_DATA_OFF: usize = align_up(SH_CTRL_OFF + CTRL_BYTES, CACHE_LINE);
|
| 95 |
const SH_DATA_BYTES: usize = SH_SLOTS * std::mem::size_of::<SphericalHarmonics>();
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
/// Total size of the shared-memory region, rounded up to a cache line.
|
| 98 |
-
pub const BUS_TOTAL_BYTES: usize = align_up(
|
| 99 |
|
| 100 |
// Compile-time proof of every layout invariant the unsafe code below relies
|
| 101 |
// on. If any of these ever becomes false (someone adds a field, changes a
|
|
@@ -105,11 +117,13 @@ const _: () = {
|
|
| 105 |
assert!(AUDIO_SLOTS.is_power_of_two());
|
| 106 |
assert!(BLEND_SLOTS.is_power_of_two());
|
| 107 |
assert!(SH_SLOTS.is_power_of_two());
|
|
|
|
| 108 |
|
| 109 |
// Control blocks must be aligned for AtomicUsize.
|
| 110 |
assert!(AUDIO_CTRL_OFF % std::mem::align_of::<AtomicUsize>() == 0);
|
| 111 |
assert!(BLEND_CTRL_OFF % std::mem::align_of::<AtomicUsize>() == 0);
|
| 112 |
assert!(SH_CTRL_OFF % std::mem::align_of::<AtomicUsize>() == 0);
|
|
|
|
| 113 |
// ...and head/tail must not share a cache line.
|
| 114 |
assert!(CTRL_BYTES >= 2 * CACHE_LINE);
|
| 115 |
assert!(std::mem::size_of::<AtomicUsize>() <= CACHE_LINE);
|
|
@@ -125,11 +139,18 @@ const _: () = {
|
|
| 125 |
assert!(
|
| 126 |
std::mem::size_of::<SphericalHarmonics>() % std::mem::align_of::<SphericalHarmonics>() == 0
|
| 127 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
// Regions must not overlap.
|
| 130 |
assert!(AUDIO_DATA_OFF + AUDIO_DATA_BYTES <= BLEND_CTRL_OFF);
|
| 131 |
assert!(BLEND_DATA_OFF + BLEND_DATA_BYTES <= SH_CTRL_OFF);
|
| 132 |
-
assert!(SH_DATA_OFF + SH_DATA_BYTES <=
|
|
|
|
| 133 |
};
|
| 134 |
|
| 135 |
/// A 64-byte, 64-byte-aligned block. Used only to obtain a cache-line-aligned
|
|
@@ -498,10 +519,33 @@ impl MirandaBus {
|
|
| 498 |
self.ring_pop(SH_CTRL_OFF, SH_DATA_OFF, SH_SLOTS)
|
| 499 |
}
|
| 500 |
|
| 501 |
-
///
|
| 502 |
-
///
|
| 503 |
-
|
| 504 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 505 |
}
|
| 506 |
}
|
| 507 |
|
|
|
|
| 1 |
//! miranda-ipc — Work Order 1: the POSIX shared-memory ring buffer at
|
| 2 |
//! `/dev/shm/miranda_bus`, lock-free atomic head/tail pointers, and C-ABI
|
| 3 |
//! aligned payload structs for audio chunks, 52-channel ARKit blendshape
|
| 4 |
+
//! frames, 9-coefficient spherical harmonic vectors, and WO-4
|
| 5 |
+
//! `KinematicTransformFrame` quaternions for neck/clavicle routing.
|
| 6 |
//!
|
| 7 |
//! # What this is
|
| 8 |
//!
|
| 9 |
+
//! Four independent single-producer / single-consumer (SPSC) ring buffers
|
| 10 |
//! sharing one memory mapping. Each ring is lock-free: coordination is a pair
|
| 11 |
//! of `AtomicUsize` counters (`head` = read position, `tail` = write
|
| 12 |
//! position), never a mutex. The bus is on the critical path of a 60 FPS
|
|
|
|
| 51 |
|
| 52 |
use bytemuck::Pod;
|
| 53 |
use memmap2::MmapMut;
|
| 54 |
+
use miranda_core::{
|
| 55 |
+
AudioChunk, BackpressureError, BlendshapeFrame, KinematicTransformFrame, RingId,
|
| 56 |
+
SphericalHarmonics,
|
| 57 |
+
};
|
| 58 |
|
| 59 |
/// Canonical path for the production bus. `/dev/shm` is a tmpfs mount
|
| 60 |
/// (RAM-backed), which is what makes the ≤50 μs round-trip target
|
|
|
|
| 75 |
const AUDIO_SLOTS: usize = 64;
|
| 76 |
const BLEND_SLOTS: usize = 128;
|
| 77 |
const SH_SLOTS: usize = 128;
|
| 78 |
+
/// 128 slots × 88 bytes = 11,264 bytes. Matches the blendshape ring depth
|
| 79 |
+
/// because every blendshape frame should have a companion kinematic frame.
|
| 80 |
+
const KINEMATIC_SLOTS: usize = 128;
|
| 81 |
|
| 82 |
/// Rounds `value` up to the next multiple of `align`. `align` must be a
|
| 83 |
/// power of two.
|
|
|
|
| 101 |
const SH_DATA_OFF: usize = align_up(SH_CTRL_OFF + CTRL_BYTES, CACHE_LINE);
|
| 102 |
const SH_DATA_BYTES: usize = SH_SLOTS * std::mem::size_of::<SphericalHarmonics>();
|
| 103 |
|
| 104 |
+
const KINEMATIC_CTRL_OFF: usize = align_up(SH_DATA_OFF + SH_DATA_BYTES, CACHE_LINE);
|
| 105 |
+
const KINEMATIC_DATA_OFF: usize = align_up(KINEMATIC_CTRL_OFF + CTRL_BYTES, CACHE_LINE);
|
| 106 |
+
const KINEMATIC_DATA_BYTES: usize =
|
| 107 |
+
KINEMATIC_SLOTS * std::mem::size_of::<KinematicTransformFrame>();
|
| 108 |
+
|
| 109 |
/// Total size of the shared-memory region, rounded up to a cache line.
|
| 110 |
+
pub const BUS_TOTAL_BYTES: usize = align_up(KINEMATIC_DATA_OFF + KINEMATIC_DATA_BYTES, CACHE_LINE);
|
| 111 |
|
| 112 |
// Compile-time proof of every layout invariant the unsafe code below relies
|
| 113 |
// on. If any of these ever becomes false (someone adds a field, changes a
|
|
|
|
| 117 |
assert!(AUDIO_SLOTS.is_power_of_two());
|
| 118 |
assert!(BLEND_SLOTS.is_power_of_two());
|
| 119 |
assert!(SH_SLOTS.is_power_of_two());
|
| 120 |
+
assert!(KINEMATIC_SLOTS.is_power_of_two());
|
| 121 |
|
| 122 |
// Control blocks must be aligned for AtomicUsize.
|
| 123 |
assert!(AUDIO_CTRL_OFF % std::mem::align_of::<AtomicUsize>() == 0);
|
| 124 |
assert!(BLEND_CTRL_OFF % std::mem::align_of::<AtomicUsize>() == 0);
|
| 125 |
assert!(SH_CTRL_OFF % std::mem::align_of::<AtomicUsize>() == 0);
|
| 126 |
+
assert!(KINEMATIC_CTRL_OFF % std::mem::align_of::<AtomicUsize>() == 0);
|
| 127 |
// ...and head/tail must not share a cache line.
|
| 128 |
assert!(CTRL_BYTES >= 2 * CACHE_LINE);
|
| 129 |
assert!(std::mem::size_of::<AtomicUsize>() <= CACHE_LINE);
|
|
|
|
| 139 |
assert!(
|
| 140 |
std::mem::size_of::<SphericalHarmonics>() % std::mem::align_of::<SphericalHarmonics>() == 0
|
| 141 |
);
|
| 142 |
+
assert!(KINEMATIC_DATA_OFF % std::mem::align_of::<KinematicTransformFrame>() == 0);
|
| 143 |
+
assert!(
|
| 144 |
+
std::mem::size_of::<KinematicTransformFrame>()
|
| 145 |
+
% std::mem::align_of::<KinematicTransformFrame>()
|
| 146 |
+
== 0
|
| 147 |
+
);
|
| 148 |
|
| 149 |
// Regions must not overlap.
|
| 150 |
assert!(AUDIO_DATA_OFF + AUDIO_DATA_BYTES <= BLEND_CTRL_OFF);
|
| 151 |
assert!(BLEND_DATA_OFF + BLEND_DATA_BYTES <= SH_CTRL_OFF);
|
| 152 |
+
assert!(SH_DATA_OFF + SH_DATA_BYTES <= KINEMATIC_CTRL_OFF);
|
| 153 |
+
assert!(KINEMATIC_DATA_OFF + KINEMATIC_DATA_BYTES <= BUS_TOTAL_BYTES);
|
| 154 |
};
|
| 155 |
|
| 156 |
/// A 64-byte, 64-byte-aligned block. Used only to obtain a cache-line-aligned
|
|
|
|
| 519 |
self.ring_pop(SH_CTRL_OFF, SH_DATA_OFF, SH_SLOTS)
|
| 520 |
}
|
| 521 |
|
| 522 |
+
/// Pushes a [`KinematicTransformFrame`] onto the kinematic ring.
|
| 523 |
+
///
|
| 524 |
+
/// The kinematic ring runs in lockstep with the blendshape ring: one
|
| 525 |
+
/// frame per 16.67 ms, same timestamp, consumed together by the renderer
|
| 526 |
+
/// (WO-5) to drive both the face ARKit weights and the skeletal joint
|
| 527 |
+
/// rotations.
|
| 528 |
+
pub fn push_kinematic(
|
| 529 |
+
&self,
|
| 530 |
+
frame: KinematicTransformFrame,
|
| 531 |
+
) -> Result<(), BackpressureError> {
|
| 532 |
+
self.ring_push(
|
| 533 |
+
KINEMATIC_CTRL_OFF,
|
| 534 |
+
KINEMATIC_DATA_OFF,
|
| 535 |
+
KINEMATIC_SLOTS,
|
| 536 |
+
RingId::Kinematic,
|
| 537 |
+
frame,
|
| 538 |
+
)
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
/// Pops a [`KinematicTransformFrame`] from the kinematic ring.
|
| 542 |
+
pub fn pop_kinematic(&self) -> Option<KinematicTransformFrame> {
|
| 543 |
+
self.ring_pop(KINEMATIC_CTRL_OFF, KINEMATIC_DATA_OFF, KINEMATIC_SLOTS)
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
/// Capacity in slots of each ring, as `(audio, blendshape, sh, kinematic)`.
|
| 547 |
+
pub const fn capacities() -> (usize, usize, usize, usize) {
|
| 548 |
+
(AUDIO_SLOTS, BLEND_SLOTS, SH_SLOTS, KINEMATIC_SLOTS)
|
| 549 |
}
|
| 550 |
}
|
| 551 |
|
|
@@ -3,5 +3,36 @@ name = "miranda-transport"
|
|
| 3 |
version.workspace = true
|
| 4 |
edition.workspace = true
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
[dependencies]
|
| 7 |
miranda-core = { path = "../miranda-core" }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
version.workspace = true
|
| 4 |
edition.workspace = true
|
| 5 |
|
| 6 |
+
[features]
|
| 7 |
+
default = []
|
| 8 |
+
# Enables the full webrtc-rs stack (DTLS/ICE/SRTP) for production NAT
|
| 9 |
+
# traversal. Gated behind a feature because the crate pulls in heavy C++
|
| 10 |
+
# crypto dependencies that are incompatible with the offline dev environment.
|
| 11 |
+
# The binary-frame DataChannel wire format is identical whether the transport
|
| 12 |
+
# layer is a bare axum WebSocket (default) or a real DTLS DataChannel (this
|
| 13 |
+
# feature). Activate for production: cargo build --features webrtc-native
|
| 14 |
+
webrtc-native = ["dep:webrtc"]
|
| 15 |
+
|
| 16 |
[dependencies]
|
| 17 |
miranda-core = { path = "../miranda-core" }
|
| 18 |
+
miranda-nodes = { path = "../miranda-nodes" }
|
| 19 |
+
miranda-ipc = { path = "../miranda-ipc" }
|
| 20 |
+
|
| 21 |
+
# Data plane — binary blendshape frames over WebSocket / DataChannel.
|
| 22 |
+
axum = { version = "0.8.9", features = ["ws", "macros"] }
|
| 23 |
+
tokio = { version = "1.53.1", features = ["full"] }
|
| 24 |
+
tokio-tungstenite = { version = "0.26.2" }
|
| 25 |
+
|
| 26 |
+
# Serialization (telemetry JSON on the control plane).
|
| 27 |
+
serde = { version = "1", features = ["derive"] }
|
| 28 |
+
serde_json = "1"
|
| 29 |
+
bytemuck = "1"
|
| 30 |
+
bytes = "1"
|
| 31 |
+
|
| 32 |
+
# Optional: full webrtc-rs stack for DTLS/ICE/SRTP.
|
| 33 |
+
webrtc = { version = "0.8.0", optional = true }
|
| 34 |
+
|
| 35 |
+
[dev-dependencies]
|
| 36 |
+
tokio = { version = "1.53.1", features = ["full", "test-util"] }
|
| 37 |
+
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
| 38 |
+
futures-util = "0.3"
|
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Binary packet encoding for the data plane.
|
| 2 |
+
//!
|
| 3 |
+
//! Every data-plane WebSocket message is a 312-byte packet laid out as
|
| 4 |
+
//! documented in the [`crate`] module docs. This module is the single place
|
| 5 |
+
//! that knows that layout, so the encoder and the decoder stay in sync.
|
| 6 |
+
|
| 7 |
+
use bytes::{BufMut, Bytes, BytesMut};
|
| 8 |
+
use miranda_core::{BlendshapeFrame, KinematicTransformFrame};
|
| 9 |
+
|
| 10 |
+
/// Magic bytes — first four bytes of every data-plane packet.
|
| 11 |
+
///
|
| 12 |
+
/// `MRD1` = Miranda Data Protocol version 1.
|
| 13 |
+
/// A receiver that does not see this prefix should drop the packet and log
|
| 14 |
+
/// a version-mismatch event rather than misinterpreting the payload.
|
| 15 |
+
pub const PACKET_MAGIC: &[u8; 4] = b"MRD1";
|
| 16 |
+
|
| 17 |
+
/// Total packet size in bytes.
|
| 18 |
+
///
|
| 19 |
+
/// 4 (magic) + 2 (blend_sz) + 2 (kin_sz) + 216 (BlendshapeFrame) +
|
| 20 |
+
/// 88 (KinematicTransformFrame) = 312.
|
| 21 |
+
pub const PACKET_SIZE: usize = 4 + 2 + 2
|
| 22 |
+
+ std::mem::size_of::<BlendshapeFrame>()
|
| 23 |
+
+ std::mem::size_of::<KinematicTransformFrame>();
|
| 24 |
+
|
| 25 |
+
/// Encodes one data-plane packet into `dst`.
|
| 26 |
+
///
|
| 27 |
+
/// `dst` is grown by exactly [`PACKET_SIZE`] bytes. Calling this in the
|
| 28 |
+
/// broadcast loop with a pre-allocated `BytesMut` avoids a per-frame heap
|
| 29 |
+
/// allocation.
|
| 30 |
+
pub fn encode(
|
| 31 |
+
blend: &BlendshapeFrame,
|
| 32 |
+
kin: &KinematicTransformFrame,
|
| 33 |
+
dst: &mut BytesMut,
|
| 34 |
+
) {
|
| 35 |
+
dst.put_slice(PACKET_MAGIC);
|
| 36 |
+
dst.put_u16_le(std::mem::size_of::<BlendshapeFrame>() as u16);
|
| 37 |
+
dst.put_u16_le(std::mem::size_of::<KinematicTransformFrame>() as u16);
|
| 38 |
+
// SAFETY: BlendshapeFrame and KinematicTransformFrame are both
|
| 39 |
+
// repr(C) + bytemuck::Pod, which means:
|
| 40 |
+
// - No padding bytes (Pod requires no uninit bytes).
|
| 41 |
+
// - No invalid bit patterns (Pod requires every bit pattern valid).
|
| 42 |
+
// - Size_of gives the exact number of meaningful bytes.
|
| 43 |
+
// Casting a Pod value to &[u8] via bytemuck::bytes_of is the canonical
|
| 44 |
+
// safe pattern — no raw pointer arithmetic, no manual transmute.
|
| 45 |
+
dst.put_slice(bytemuck::bytes_of(blend));
|
| 46 |
+
dst.put_slice(bytemuck::bytes_of(kin));
|
| 47 |
+
debug_assert_eq!(
|
| 48 |
+
dst.len() % PACKET_SIZE,
|
| 49 |
+
0,
|
| 50 |
+
"packet encode produced wrong size"
|
| 51 |
+
);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/// Encodes one data-plane packet and returns it as an owned [`Bytes`].
|
| 55 |
+
///
|
| 56 |
+
/// Allocates. Use the `encode` form with a scratch `BytesMut` in the hot
|
| 57 |
+
/// loop; use this form in tests and slow paths.
|
| 58 |
+
pub fn encode_to_bytes(blend: &BlendshapeFrame, kin: &KinematicTransformFrame) -> Bytes {
|
| 59 |
+
let mut buf = BytesMut::with_capacity(PACKET_SIZE);
|
| 60 |
+
encode(blend, kin, &mut buf);
|
| 61 |
+
buf.freeze()
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/// Decodes a data-plane packet from `src`.
|
| 65 |
+
///
|
| 66 |
+
/// Returns `Err` if the packet is the wrong size, the magic bytes do not
|
| 67 |
+
/// match, or the declared payload sizes disagree with the compiled sizes.
|
| 68 |
+
/// Never panics.
|
| 69 |
+
pub fn decode(src: &[u8]) -> Result<(BlendshapeFrame, KinematicTransformFrame), DecodeError> {
|
| 70 |
+
if src.len() != PACKET_SIZE {
|
| 71 |
+
return Err(DecodeError::WrongLength {
|
| 72 |
+
got: src.len(),
|
| 73 |
+
expected: PACKET_SIZE,
|
| 74 |
+
});
|
| 75 |
+
}
|
| 76 |
+
if &src[..4] != PACKET_MAGIC {
|
| 77 |
+
return Err(DecodeError::BadMagic);
|
| 78 |
+
}
|
| 79 |
+
let blend_sz = u16::from_le_bytes([src[4], src[5]]) as usize;
|
| 80 |
+
let kin_sz = u16::from_le_bytes([src[6], src[7]]) as usize;
|
| 81 |
+
if blend_sz != std::mem::size_of::<BlendshapeFrame>() {
|
| 82 |
+
return Err(DecodeError::SizeMismatch {
|
| 83 |
+
field: "BlendshapeFrame",
|
| 84 |
+
got: blend_sz,
|
| 85 |
+
expected: std::mem::size_of::<BlendshapeFrame>(),
|
| 86 |
+
});
|
| 87 |
+
}
|
| 88 |
+
if kin_sz != std::mem::size_of::<KinematicTransformFrame>() {
|
| 89 |
+
return Err(DecodeError::SizeMismatch {
|
| 90 |
+
field: "KinematicTransformFrame",
|
| 91 |
+
got: kin_sz,
|
| 92 |
+
expected: std::mem::size_of::<KinematicTransformFrame>(),
|
| 93 |
+
});
|
| 94 |
+
}
|
| 95 |
+
let blend_start = 8;
|
| 96 |
+
let kin_start = blend_start + blend_sz;
|
| 97 |
+
// SAFETY: same as encode — bytemuck::from_bytes is safe for Pod types.
|
| 98 |
+
let blend: BlendshapeFrame = *bytemuck::from_bytes(&src[blend_start..blend_start + blend_sz]);
|
| 99 |
+
let kin: KinematicTransformFrame =
|
| 100 |
+
*bytemuck::from_bytes(&src[kin_start..kin_start + kin_sz]);
|
| 101 |
+
Ok((blend, kin))
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/// Errors that can occur when decoding a data-plane packet.
|
| 105 |
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
| 106 |
+
pub enum DecodeError {
|
| 107 |
+
WrongLength { got: usize, expected: usize },
|
| 108 |
+
BadMagic,
|
| 109 |
+
SizeMismatch {
|
| 110 |
+
field: &'static str,
|
| 111 |
+
got: usize,
|
| 112 |
+
expected: usize,
|
| 113 |
+
},
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
impl std::fmt::Display for DecodeError {
|
| 117 |
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
| 118 |
+
match self {
|
| 119 |
+
Self::WrongLength { got, expected } => {
|
| 120 |
+
write!(f, "packet wrong length: got {got}, expected {expected}")
|
| 121 |
+
}
|
| 122 |
+
Self::BadMagic => write!(f, "packet magic bytes do not match MRD1"),
|
| 123 |
+
Self::SizeMismatch { field, got, expected } => {
|
| 124 |
+
write!(
|
| 125 |
+
f,
|
| 126 |
+
"size field for {field} says {got} bytes, compiled size is {expected}"
|
| 127 |
+
)
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
impl std::error::Error for DecodeError {}
|
| 134 |
+
|
| 135 |
+
#[cfg(test)]
|
| 136 |
+
mod tests {
|
| 137 |
+
use super::*;
|
| 138 |
+
use miranda_core::{BLENDSHAPE_COUNT, KINEMATIC_JOINT_COUNT, Quaternion};
|
| 139 |
+
|
| 140 |
+
fn sample_blend(ts: u64) -> BlendshapeFrame {
|
| 141 |
+
BlendshapeFrame {
|
| 142 |
+
timestamp_us: ts,
|
| 143 |
+
weights: {
|
| 144 |
+
let mut w = [0.0f32; BLENDSHAPE_COUNT];
|
| 145 |
+
w[17] = 0.5; // jawOpen
|
| 146 |
+
w
|
| 147 |
+
},
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
fn sample_kin(ts: u64) -> KinematicTransformFrame {
|
| 152 |
+
KinematicTransformFrame {
|
| 153 |
+
timestamp_us: ts,
|
| 154 |
+
joints: [Quaternion::IDENTITY; KINEMATIC_JOINT_COUNT],
|
| 155 |
+
head_pitch_deg: 0.4,
|
| 156 |
+
clavicle_rise: 0.2,
|
| 157 |
+
_reserved: [0; 8],
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
#[test]
|
| 162 |
+
fn packet_size_constant_is_correct() {
|
| 163 |
+
// Verify the constant agrees with the actual sizes.
|
| 164 |
+
assert_eq!(
|
| 165 |
+
PACKET_SIZE,
|
| 166 |
+
4 + 2 + 2 + 216 + 88,
|
| 167 |
+
"PACKET_SIZE constant is wrong"
|
| 168 |
+
);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
#[test]
|
| 172 |
+
fn encode_produces_exactly_packet_size_bytes() {
|
| 173 |
+
let b = sample_blend(1);
|
| 174 |
+
let k = sample_kin(1);
|
| 175 |
+
let pkt = encode_to_bytes(&b, &k);
|
| 176 |
+
assert_eq!(pkt.len(), PACKET_SIZE);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
#[test]
|
| 180 |
+
fn magic_bytes_are_present() {
|
| 181 |
+
let pkt = encode_to_bytes(&sample_blend(0), &sample_kin(0));
|
| 182 |
+
assert_eq!(&pkt[..4], PACKET_MAGIC);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
#[test]
|
| 186 |
+
fn size_fields_are_little_endian_and_correct() {
|
| 187 |
+
let pkt = encode_to_bytes(&sample_blend(0), &sample_kin(0));
|
| 188 |
+
let blend_sz = u16::from_le_bytes([pkt[4], pkt[5]]) as usize;
|
| 189 |
+
let kin_sz = u16::from_le_bytes([pkt[6], pkt[7]]) as usize;
|
| 190 |
+
assert_eq!(blend_sz, 216);
|
| 191 |
+
assert_eq!(kin_sz, 88);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
#[test]
|
| 195 |
+
fn round_trip_preserves_all_fields() {
|
| 196 |
+
let b = sample_blend(99_000);
|
| 197 |
+
let k = KinematicTransformFrame::from_breath(99_000, 0.6, 0.3);
|
| 198 |
+
let pkt = encode_to_bytes(&b, &k);
|
| 199 |
+
let (b2, k2) = decode(&pkt).expect("decode failed");
|
| 200 |
+
assert_eq!(b2.timestamp_us, b.timestamp_us);
|
| 201 |
+
assert_eq!(b2.weights[17], 0.5);
|
| 202 |
+
assert_eq!(k2.timestamp_us, k.timestamp_us);
|
| 203 |
+
assert!((k2.head_pitch_deg - 0.6).abs() < 1e-6);
|
| 204 |
+
assert!((k2.clavicle_rise - 0.3).abs() < 1e-6);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
#[test]
|
| 208 |
+
fn decode_rejects_wrong_length() {
|
| 209 |
+
let pkt = encode_to_bytes(&sample_blend(0), &sample_kin(0));
|
| 210 |
+
let short = &pkt[..PACKET_SIZE - 1];
|
| 211 |
+
assert!(matches!(decode(short), Err(DecodeError::WrongLength { .. })));
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
#[test]
|
| 215 |
+
fn decode_rejects_bad_magic() {
|
| 216 |
+
let mut pkt = encode_to_bytes(&sample_blend(0), &sample_kin(0)).to_vec();
|
| 217 |
+
pkt[0] = 0xFF;
|
| 218 |
+
assert!(matches!(decode(&pkt), Err(DecodeError::BadMagic)));
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
#[test]
|
| 222 |
+
fn decode_rejects_wrong_size_fields() {
|
| 223 |
+
let mut pkt = encode_to_bytes(&sample_blend(0), &sample_kin(0)).to_vec();
|
| 224 |
+
// Corrupt the blend_sz field.
|
| 225 |
+
let wrong: u16 = 100;
|
| 226 |
+
pkt[4..6].copy_from_slice(&wrong.to_le_bytes());
|
| 227 |
+
assert!(matches!(
|
| 228 |
+
decode(&pkt),
|
| 229 |
+
Err(DecodeError::SizeMismatch { field: "BlendshapeFrame", .. })
|
| 230 |
+
));
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
#[test]
|
| 234 |
+
fn encode_with_scratch_buffer_matches_encode_to_bytes() {
|
| 235 |
+
let b = sample_blend(7);
|
| 236 |
+
let k = sample_kin(7);
|
| 237 |
+
let owned = encode_to_bytes(&b, &k);
|
| 238 |
+
let mut scratch = BytesMut::with_capacity(PACKET_SIZE);
|
| 239 |
+
encode(&b, &k, &mut scratch);
|
| 240 |
+
assert_eq!(owned.as_ref(), scratch.as_ref());
|
| 241 |
+
}
|
| 242 |
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Data-plane hub — broadcasts binary frame packets to all connected browsers.
|
| 2 |
+
//!
|
| 3 |
+
//! # Design
|
| 4 |
+
//!
|
| 5 |
+
//! [`DataChannelHub`] is the producer-side handle. It is cloned cheaply and
|
| 6 |
+
//! handed to whatever thread drains the `MirandaBus` blendshape + kinematic
|
| 7 |
+
//! rings. When it has a frame pair ready it calls [`DataChannelHub::broadcast`],
|
| 8 |
+
//! which sends the encoded packet to every currently-open subscriber channel.
|
| 9 |
+
//!
|
| 10 |
+
//! Each browser connection is represented as a [`tokio::sync::mpsc`] sender.
|
| 11 |
+
//! The channel is bounded at [`SUBSCRIBER_CHANNEL_DEPTH`] frames. When a slow
|
| 12 |
+
//! browser fills its channel the hub drops the frame for that subscriber and
|
| 13 |
+
//! increments a counter — backpressure is surfaced as telemetry, never as a
|
| 14 |
+
//! stall on the producer thread.
|
| 15 |
+
//!
|
| 16 |
+
//! # Thread safety
|
| 17 |
+
//!
|
| 18 |
+
//! `DataChannelHub` wraps its subscriber list in a `Mutex<Vec<…>>`. This lock
|
| 19 |
+
//! is taken on two very different timescales:
|
| 20 |
+
//!
|
| 21 |
+
//! - **Broadcast path** (60 Hz): holds the lock only to clone the sender list,
|
| 22 |
+
//! then releases it before doing any I/O. The actual per-subscriber send
|
| 23 |
+
//! happens after the lock is dropped, so a single slow subscriber cannot
|
| 24 |
+
//! hold the lock while sending.
|
| 25 |
+
//! - **Subscribe/unsubscribe path** (human-speed): adds or removes one entry.
|
| 26 |
+
//!
|
| 27 |
+
//! The lock contention window on the broadcast path is O(n subscribers) for a
|
| 28 |
+
//! `clone()` of a `Vec` of `Arc`-wrapped senders — each clone is a pointer
|
| 29 |
+
//! copy, not a byte copy of frame data. At 60 Hz with 4 subscribers this is
|
| 30 |
+
//! well under 1 µs.
|
| 31 |
+
|
| 32 |
+
use std::sync::{Arc, Mutex};
|
| 33 |
+
|
| 34 |
+
use bytes::BytesMut;
|
| 35 |
+
use miranda_core::{BlendshapeFrame, KinematicTransformFrame};
|
| 36 |
+
use tokio::sync::mpsc;
|
| 37 |
+
|
| 38 |
+
use crate::frame;
|
| 39 |
+
|
| 40 |
+
/// Backlog a subscriber's channel can hold before frames are dropped.
|
| 41 |
+
///
|
| 42 |
+
/// At 60 FPS this is 333 ms of buffer. A subscriber that falls more than
|
| 43 |
+
/// 333 ms behind is not keeping up — the face it is rendering is already
|
| 44 |
+
/// stale. Dropping rather than growing the backlog is the correct choice:
|
| 45 |
+
/// sending stale frames would produce a visible lag-then-lurch artefact
|
| 46 |
+
/// worse than a momentary gap.
|
| 47 |
+
pub const SUBSCRIBER_CHANNEL_DEPTH: usize = 20;
|
| 48 |
+
|
| 49 |
+
/// A subscriber's receive end. The WebSocket handler task receives encoded
|
| 50 |
+
/// packets from this channel and forwards them to the browser.
|
| 51 |
+
pub type SubscriberRx = mpsc::Receiver<bytes::Bytes>;
|
| 52 |
+
|
| 53 |
+
/// Internal subscriber record.
|
| 54 |
+
struct Subscriber {
|
| 55 |
+
tx: mpsc::Sender<bytes::Bytes>,
|
| 56 |
+
/// How many frames were dropped because this subscriber's channel was
|
| 57 |
+
/// full. Read by the telemetry hub.
|
| 58 |
+
dropped: Arc<std::sync::atomic::AtomicU64>,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/// The data-plane broadcast hub.
|
| 62 |
+
///
|
| 63 |
+
/// Clone this to share it across threads — the clone is cheap (one
|
| 64 |
+
/// `Arc::clone`) and every clone broadcasts to the same subscriber set.
|
| 65 |
+
#[derive(Clone)]
|
| 66 |
+
pub struct DataChannelHub {
|
| 67 |
+
inner: Arc<HubInner>,
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
struct HubInner {
|
| 71 |
+
subscribers: Mutex<Vec<Subscriber>>,
|
| 72 |
+
/// Total frames broadcast (to at least one subscriber).
|
| 73 |
+
frames_broadcast: std::sync::atomic::AtomicU64,
|
| 74 |
+
/// Total frames dropped across all subscribers.
|
| 75 |
+
total_dropped: std::sync::atomic::AtomicU64,
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
impl DataChannelHub {
|
| 79 |
+
/// Creates a new hub with no subscribers.
|
| 80 |
+
pub fn new() -> Self {
|
| 81 |
+
Self {
|
| 82 |
+
inner: Arc::new(HubInner {
|
| 83 |
+
subscribers: Mutex::new(Vec::new()),
|
| 84 |
+
frames_broadcast: std::sync::atomic::AtomicU64::new(0),
|
| 85 |
+
total_dropped: std::sync::atomic::AtomicU64::new(0),
|
| 86 |
+
}),
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/// Registers a new subscriber and returns its receive end.
|
| 91 |
+
///
|
| 92 |
+
/// Called by the axum WebSocket upgrade handler when a new browser
|
| 93 |
+
/// connects. The returned `SubscriberRx` is moved into the per-connection
|
| 94 |
+
/// task.
|
| 95 |
+
pub fn subscribe(&self) -> (SubscriberRx, Arc<std::sync::atomic::AtomicU64>) {
|
| 96 |
+
let dropped = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
| 97 |
+
let (tx, rx) = mpsc::channel(SUBSCRIBER_CHANNEL_DEPTH);
|
| 98 |
+
self.inner.subscribers.lock().unwrap().push(Subscriber {
|
| 99 |
+
tx,
|
| 100 |
+
dropped: Arc::clone(&dropped),
|
| 101 |
+
});
|
| 102 |
+
(rx, dropped)
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/// Encodes one frame pair and sends the packet to every subscriber.
|
| 106 |
+
///
|
| 107 |
+
/// Drops the frame for any subscriber whose channel is full, and removes
|
| 108 |
+
/// subscribers whose channels have been closed (browser disconnected).
|
| 109 |
+
/// Allocation-free in the steady state: the scratch buffer is passed in
|
| 110 |
+
/// by the caller so it can be reused across calls.
|
| 111 |
+
pub fn broadcast(
|
| 112 |
+
&self,
|
| 113 |
+
blend: &BlendshapeFrame,
|
| 114 |
+
kin: &KinematicTransformFrame,
|
| 115 |
+
scratch: &mut BytesMut,
|
| 116 |
+
) {
|
| 117 |
+
scratch.clear();
|
| 118 |
+
frame::encode(blend, kin, scratch);
|
| 119 |
+
let packet = scratch.clone().freeze();
|
| 120 |
+
|
| 121 |
+
// Take a snapshot of the sender list, release the lock, then send.
|
| 122 |
+
let senders: Vec<(mpsc::Sender<bytes::Bytes>, Arc<std::sync::atomic::AtomicU64>)> = {
|
| 123 |
+
self.inner
|
| 124 |
+
.subscribers
|
| 125 |
+
.lock()
|
| 126 |
+
.unwrap()
|
| 127 |
+
.iter()
|
| 128 |
+
.map(|s| (s.tx.clone(), Arc::clone(&s.dropped)))
|
| 129 |
+
.collect()
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
if senders.is_empty() {
|
| 133 |
+
return;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
let mut any_closed = false;
|
| 137 |
+
for (tx, dropped) in &senders {
|
| 138 |
+
match tx.try_send(packet.clone()) {
|
| 139 |
+
Ok(_) => {}
|
| 140 |
+
Err(mpsc::error::TrySendError::Full(_)) => {
|
| 141 |
+
dropped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
| 142 |
+
self.inner
|
| 143 |
+
.total_dropped
|
| 144 |
+
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
| 145 |
+
}
|
| 146 |
+
Err(mpsc::error::TrySendError::Closed(_)) => {
|
| 147 |
+
any_closed = true;
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
self.inner
|
| 152 |
+
.frames_broadcast
|
| 153 |
+
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
| 154 |
+
|
| 155 |
+
// Prune disconnected subscribers. Only pay the mutex cost when at
|
| 156 |
+
// least one channel is gone.
|
| 157 |
+
if any_closed {
|
| 158 |
+
self.inner
|
| 159 |
+
.subscribers
|
| 160 |
+
.lock()
|
| 161 |
+
.unwrap()
|
| 162 |
+
.retain(|s| !s.tx.is_closed());
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
/// Number of currently active subscribers.
|
| 167 |
+
pub fn subscriber_count(&self) -> usize {
|
| 168 |
+
self.inner.subscribers.lock().unwrap().len()
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
/// Total frames sent to at least one subscriber.
|
| 172 |
+
pub fn frames_broadcast(&self) -> u64 {
|
| 173 |
+
self.inner
|
| 174 |
+
.frames_broadcast
|
| 175 |
+
.load(std::sync::atomic::Ordering::Relaxed)
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
/// Total frames dropped across all subscribers due to backpressure.
|
| 179 |
+
pub fn total_dropped(&self) -> u64 {
|
| 180 |
+
self.inner
|
| 181 |
+
.total_dropped
|
| 182 |
+
.load(std::sync::atomic::Ordering::Relaxed)
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
impl Default for DataChannelHub {
|
| 187 |
+
fn default() -> Self {
|
| 188 |
+
Self::new()
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
#[cfg(test)]
|
| 193 |
+
mod tests {
|
| 194 |
+
use super::*;
|
| 195 |
+
use crate::frame::PACKET_SIZE;
|
| 196 |
+
use miranda_core::{BLENDSHAPE_COUNT, KINEMATIC_JOINT_COUNT, Quaternion};
|
| 197 |
+
|
| 198 |
+
fn blend(ts: u64) -> BlendshapeFrame {
|
| 199 |
+
BlendshapeFrame {
|
| 200 |
+
timestamp_us: ts,
|
| 201 |
+
weights: [0.0; BLENDSHAPE_COUNT],
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
fn kin(ts: u64) -> KinematicTransformFrame {
|
| 206 |
+
KinematicTransformFrame {
|
| 207 |
+
timestamp_us: ts,
|
| 208 |
+
joints: [Quaternion::IDENTITY; KINEMATIC_JOINT_COUNT],
|
| 209 |
+
head_pitch_deg: 0.0,
|
| 210 |
+
clavicle_rise: 0.0,
|
| 211 |
+
_reserved: [0; 8],
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
#[tokio::test]
|
| 216 |
+
async fn subscriber_receives_broadcast() {
|
| 217 |
+
let hub = DataChannelHub::new();
|
| 218 |
+
let (mut rx, _dropped) = hub.subscribe();
|
| 219 |
+
let mut scratch = BytesMut::with_capacity(PACKET_SIZE);
|
| 220 |
+
|
| 221 |
+
hub.broadcast(&blend(1), &kin(1), &mut scratch);
|
| 222 |
+
|
| 223 |
+
let pkt = rx.recv().await.expect("expected a packet");
|
| 224 |
+
assert_eq!(pkt.len(), PACKET_SIZE);
|
| 225 |
+
assert_eq!(&pkt[..4], b"MRD1");
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
#[tokio::test]
|
| 229 |
+
async fn multiple_subscribers_all_receive() {
|
| 230 |
+
let hub = DataChannelHub::new();
|
| 231 |
+
let (mut rx1, _) = hub.subscribe();
|
| 232 |
+
let (mut rx2, _) = hub.subscribe();
|
| 233 |
+
let mut scratch = BytesMut::with_capacity(PACKET_SIZE);
|
| 234 |
+
|
| 235 |
+
hub.broadcast(&blend(2), &kin(2), &mut scratch);
|
| 236 |
+
|
| 237 |
+
assert!(rx1.recv().await.is_some());
|
| 238 |
+
assert!(rx2.recv().await.is_some());
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
#[tokio::test]
|
| 242 |
+
async fn broadcast_with_no_subscribers_is_a_noop() {
|
| 243 |
+
let hub = DataChannelHub::new();
|
| 244 |
+
let mut scratch = BytesMut::with_capacity(PACKET_SIZE);
|
| 245 |
+
// Must not panic.
|
| 246 |
+
hub.broadcast(&blend(0), &kin(0), &mut scratch);
|
| 247 |
+
assert_eq!(hub.frames_broadcast(), 0);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
#[tokio::test]
|
| 251 |
+
async fn full_channel_drops_frame_and_increments_counter() {
|
| 252 |
+
let hub = DataChannelHub::new();
|
| 253 |
+
let (mut rx, dropped) = hub.subscribe();
|
| 254 |
+
let mut scratch = BytesMut::with_capacity(PACKET_SIZE);
|
| 255 |
+
|
| 256 |
+
// Fill the channel.
|
| 257 |
+
for i in 0..(SUBSCRIBER_CHANNEL_DEPTH + 5) as u64 {
|
| 258 |
+
hub.broadcast(&blend(i), &kin(i), &mut scratch);
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
let drop_count = dropped.load(std::sync::atomic::Ordering::Relaxed);
|
| 262 |
+
assert!(drop_count >= 5, "expected at least 5 drops, got {drop_count}");
|
| 263 |
+
assert!(hub.total_dropped() >= 5);
|
| 264 |
+
|
| 265 |
+
// Drain to confirm the channel held SUBSCRIBER_CHANNEL_DEPTH frames.
|
| 266 |
+
let mut received = 0usize;
|
| 267 |
+
while rx.try_recv().is_ok() {
|
| 268 |
+
received += 1;
|
| 269 |
+
}
|
| 270 |
+
assert_eq!(received, SUBSCRIBER_CHANNEL_DEPTH);
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
#[tokio::test]
|
| 274 |
+
async fn closed_subscriber_is_pruned_on_next_broadcast() {
|
| 275 |
+
let hub = DataChannelHub::new();
|
| 276 |
+
let (rx, _) = hub.subscribe();
|
| 277 |
+
let mut scratch = BytesMut::with_capacity(PACKET_SIZE);
|
| 278 |
+
|
| 279 |
+
assert_eq!(hub.subscriber_count(), 1);
|
| 280 |
+
drop(rx); // close the receive end
|
| 281 |
+
|
| 282 |
+
// First broadcast detects the closed channel and prunes.
|
| 283 |
+
hub.broadcast(&blend(0), &kin(0), &mut scratch);
|
| 284 |
+
assert_eq!(hub.subscriber_count(), 0);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
#[tokio::test]
|
| 288 |
+
async fn packet_content_is_decoded_correctly_by_receiver() {
|
| 289 |
+
let hub = DataChannelHub::new();
|
| 290 |
+
let (mut rx, _) = hub.subscribe();
|
| 291 |
+
let mut scratch = BytesMut::with_capacity(PACKET_SIZE);
|
| 292 |
+
|
| 293 |
+
let b = BlendshapeFrame {
|
| 294 |
+
timestamp_us: 12_345_678,
|
| 295 |
+
weights: {
|
| 296 |
+
let mut w = [0.0f32; BLENDSHAPE_COUNT];
|
| 297 |
+
w[17] = 0.75;
|
| 298 |
+
w
|
| 299 |
+
},
|
| 300 |
+
};
|
| 301 |
+
let k = KinematicTransformFrame::from_breath(12_345_678, 1.2, 0.5);
|
| 302 |
+
hub.broadcast(&b, &k, &mut scratch);
|
| 303 |
+
|
| 304 |
+
let pkt = rx.recv().await.unwrap();
|
| 305 |
+
let (b2, k2) = crate::frame::decode(&pkt).expect("decode failed");
|
| 306 |
+
assert_eq!(b2.timestamp_us, 12_345_678);
|
| 307 |
+
assert!((b2.weights[17] - 0.75).abs() < 1e-6);
|
| 308 |
+
assert!((k2.head_pitch_deg - 1.2).abs() < 1e-6);
|
| 309 |
+
assert!((k2.clavicle_rise - 0.5).abs() < 1e-6);
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
#[test]
|
| 313 |
+
fn hub_is_send_and_sync() {
|
| 314 |
+
fn assert_send_sync<T: Send + Sync>() {}
|
| 315 |
+
assert_send_sync::<DataChannelHub>();
|
| 316 |
+
}
|
| 317 |
+
}
|
|
@@ -1,3 +1,80 @@
|
|
| 1 |
-
//! miranda-transport — Work Order 4:
|
| 2 |
-
//!
|
| 3 |
-
//!
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! miranda-transport — Work Order 4: full-duplex transport layer.
|
| 2 |
+
//!
|
| 3 |
+
//! Two logical servers share one [`axum`] application and one Tokio runtime:
|
| 4 |
+
//!
|
| 5 |
+
//! 1. **Data-plane hub** (`/data` WebSocket endpoint): broadcasts binary
|
| 6 |
+
//! [`BlendshapeFrame`] + [`KinematicTransformFrame`] packets to every
|
| 7 |
+
//! connected browser at 60 FPS. This is the DataChannel-equivalent path —
|
| 8 |
+
//! binary framing, minimal per-frame overhead, no JSON on the hot path.
|
| 9 |
+
//!
|
| 10 |
+
//! 2. **Control-plane telemetry** (`/telemetry` WebSocket endpoint): pushes
|
| 11 |
+
//! JSON metric snapshots (frame build time, dispatcher stats,
|
| 12 |
+
//! circuit-breaker state) to connected management tools at ~2 Hz.
|
| 13 |
+
//!
|
| 14 |
+
//! # Wire formats
|
| 15 |
+
//!
|
| 16 |
+
//! ## Data plane — binary packet
|
| 17 |
+
//!
|
| 18 |
+
//! Each data-plane message is a length-prefixed concatenation of two frames:
|
| 19 |
+
//!
|
| 20 |
+
//! ```text
|
| 21 |
+
//! offset 0 : magic : [u8; 4] = b"MRD1" (version tag)
|
| 22 |
+
//! offset 4 : blend_sz : u16 LE = 216 (sizeof BlendshapeFrame)
|
| 23 |
+
//! offset 6 : kin_sz : u16 LE = 88 (sizeof KinematicTransformFrame)
|
| 24 |
+
//! offset 8 : blend : [u8; 216] (BlendshapeFrame, repr(C), Pod)
|
| 25 |
+
//! offset 224: kin : [u8; 88] (KinematicTransformFrame, repr(C), Pod)
|
| 26 |
+
//! ─────────────────────────────────────────────────────────────────────────
|
| 27 |
+
//! total : 312 bytes
|
| 28 |
+
//! ```
|
| 29 |
+
//!
|
| 30 |
+
//! The magic bytes and size fields allow a receiver to detect protocol
|
| 31 |
+
//! mismatches immediately rather than silently misinterpreting bytes.
|
| 32 |
+
//! The sizes are little-endian so they round-trip through JavaScript's
|
| 33 |
+
//! `DataView.getUint16(offset, true)` without byte-swapping.
|
| 34 |
+
//!
|
| 35 |
+
//! ## Control plane — JSON telemetry snapshot
|
| 36 |
+
//!
|
| 37 |
+
//! ```json
|
| 38 |
+
//! {
|
| 39 |
+
//! "t_us": 1234567890,
|
| 40 |
+
//! "frames_published": 1801,
|
| 41 |
+
//! "frames_dropped": 0,
|
| 42 |
+
//! "late_frames": 0,
|
| 43 |
+
//! "publish_failures": 0,
|
| 44 |
+
//! "mean_build_us": 11.95,
|
| 45 |
+
//! "max_build_us": 1102.3,
|
| 46 |
+
//! "audio_chunks_consumed": 360,
|
| 47 |
+
//! "data_subscribers": 2,
|
| 48 |
+
//! "telemetry_subscribers": 1,
|
| 49 |
+
//! "circuit_breaker": "closed"
|
| 50 |
+
//! }
|
| 51 |
+
//! ```
|
| 52 |
+
//!
|
| 53 |
+
//! # webrtc-rs and the `webrtc-native` feature
|
| 54 |
+
//!
|
| 55 |
+
//! The `webrtc-native` Cargo feature gates the full `webrtc-rs` crate, which
|
| 56 |
+
//! provides DTLS/ICE/SRTP for production NAT traversal. It is not compiled by
|
| 57 |
+
//! default because the C++ TLS dependency chain takes several minutes to build
|
| 58 |
+
//! on the development machine (Celeron N4500) and because there is no STUN/
|
| 59 |
+
//! TURN server available in the offline environment.
|
| 60 |
+
//!
|
| 61 |
+
//! From a browser's perspective the two paths are **wire-compatible**: both
|
| 62 |
+
//! send the same 312-byte binary packet per frame. The difference is the
|
| 63 |
+
//! transport layer underneath — plain WebSocket vs. DTLS DataChannel. The
|
| 64 |
+
//! browser-side JavaScript only changes the connection constructor, not the
|
| 65 |
+
//! frame parser.
|
| 66 |
+
//!
|
| 67 |
+
//! When AWS connectivity is restored and `--features webrtc-native` is
|
| 68 |
+
//! activated, the `DataChannelHub` type alias in this module switches to
|
| 69 |
+
//! the real WebRTC signaling path automatically.
|
| 70 |
+
|
| 71 |
+
pub mod frame;
|
| 72 |
+
pub mod hub;
|
| 73 |
+
pub mod server;
|
| 74 |
+
pub mod telemetry;
|
| 75 |
+
pub mod wiring;
|
| 76 |
+
|
| 77 |
+
pub use hub::DataChannelHub;
|
| 78 |
+
pub use server::{ServerConfig, TransportServer};
|
| 79 |
+
pub use telemetry::TelemetryHub;
|
| 80 |
+
pub use wiring::StatsSource;
|
|
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Axum HTTP + WebSocket server wiring.
|
| 2 |
+
//!
|
| 3 |
+
//! One [`axum::Router`] serves both endpoints:
|
| 4 |
+
//!
|
| 5 |
+
//! - `GET /data` — upgrades to a binary WebSocket; the hub sends 312-byte
|
| 6 |
+
//! frame packets at 60 FPS.
|
| 7 |
+
//! - `GET /telemetry` — upgrades to a text WebSocket; the hub sends JSON
|
| 8 |
+
//! snapshots at ~2 Hz.
|
| 9 |
+
//! - `GET /health` — plain JSON health check; never requires upgrade.
|
| 10 |
+
//!
|
| 11 |
+
//! # Usage
|
| 12 |
+
//!
|
| 13 |
+
//! ```rust,no_run
|
| 14 |
+
//! use std::net::SocketAddr;
|
| 15 |
+
//! use std::sync::Arc;
|
| 16 |
+
//! use miranda_transport::{TransportServer, ServerConfig, DataChannelHub, TelemetryHub};
|
| 17 |
+
//! use miranda_transport::telemetry::DispatchSource;
|
| 18 |
+
//!
|
| 19 |
+
//! #[tokio::main]
|
| 20 |
+
//! async fn main() {
|
| 21 |
+
//! let data_hub = DataChannelHub::new();
|
| 22 |
+
//! let tele_hub = TelemetryHub::new(0);
|
| 23 |
+
//! let cfg = ServerConfig::default();
|
| 24 |
+
//! let server = TransportServer::new(cfg, data_hub, tele_hub);
|
| 25 |
+
//! server.run().await.unwrap();
|
| 26 |
+
//! }
|
| 27 |
+
//! ```
|
| 28 |
+
|
| 29 |
+
use std::net::SocketAddr;
|
| 30 |
+
use std::sync::Arc;
|
| 31 |
+
use std::time::Duration;
|
| 32 |
+
|
| 33 |
+
use axum::{
|
| 34 |
+
extract::{
|
| 35 |
+
ws::{Message, WebSocket, WebSocketUpgrade},
|
| 36 |
+
State,
|
| 37 |
+
},
|
| 38 |
+
response::IntoResponse,
|
| 39 |
+
routing::get,
|
| 40 |
+
Json, Router,
|
| 41 |
+
};
|
| 42 |
+
use serde_json::json;
|
| 43 |
+
use tokio::time;
|
| 44 |
+
|
| 45 |
+
use crate::frame::PACKET_SIZE;
|
| 46 |
+
use crate::hub::DataChannelHub;
|
| 47 |
+
use crate::telemetry::TelemetryHub;
|
| 48 |
+
/// Transport server configuration.
|
| 49 |
+
#[derive(Debug, Clone)]
|
| 50 |
+
pub struct ServerConfig {
|
| 51 |
+
/// Address to bind. Defaults to `127.0.0.1:9090`.
|
| 52 |
+
pub bind: SocketAddr,
|
| 53 |
+
/// Telemetry snapshot interval. Defaults to 500 ms.
|
| 54 |
+
pub telemetry_interval: Duration,
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
impl Default for ServerConfig {
|
| 58 |
+
fn default() -> Self {
|
| 59 |
+
Self {
|
| 60 |
+
bind: "127.0.0.1:9090".parse().unwrap(),
|
| 61 |
+
telemetry_interval: Duration::from_millis(500),
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/// Shared state injected into every axum handler.
|
| 67 |
+
struct AppState {
|
| 68 |
+
data_hub: DataChannelHub,
|
| 69 |
+
tele_hub: TelemetryHub,
|
| 70 |
+
telemetry_interval: Duration,
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/// The combined transport server.
|
| 74 |
+
pub struct TransportServer {
|
| 75 |
+
cfg: ServerConfig,
|
| 76 |
+
data_hub: DataChannelHub,
|
| 77 |
+
tele_hub: TelemetryHub,
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
impl TransportServer {
|
| 81 |
+
pub fn new(cfg: ServerConfig, data_hub: DataChannelHub, tele_hub: TelemetryHub) -> Self {
|
| 82 |
+
Self { cfg, data_hub, tele_hub }
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/// Binds the server and runs it until the process exits.
|
| 86 |
+
///
|
| 87 |
+
/// This is async but never returns in normal operation. Use
|
| 88 |
+
/// [`tokio::spawn`] or `tokio::select!` to run it alongside other tasks.
|
| 89 |
+
pub async fn run(self) -> std::io::Result<()> {
|
| 90 |
+
let state = Arc::new(AppState {
|
| 91 |
+
data_hub: self.data_hub,
|
| 92 |
+
tele_hub: self.tele_hub,
|
| 93 |
+
telemetry_interval: self.cfg.telemetry_interval,
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
let app = Router::new()
|
| 97 |
+
.route("/health", get(health_handler))
|
| 98 |
+
.route("/data", get(data_ws_handler))
|
| 99 |
+
.route("/telemetry", get(telemetry_ws_handler))
|
| 100 |
+
.with_state(state);
|
| 101 |
+
|
| 102 |
+
let listener = tokio::net::TcpListener::bind(self.cfg.bind).await
|
| 103 |
+
.map_err(|e| std::io::Error::new(std::io::ErrorKind::AddrInUse, e))?;
|
| 104 |
+
|
| 105 |
+
axum::serve(listener, app).await
|
| 106 |
+
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
// ── Handlers ────────────────────────────────────────────────────────────────
|
| 111 |
+
|
| 112 |
+
async fn health_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
| 113 |
+
Json(json!({
|
| 114 |
+
"ok": true,
|
| 115 |
+
"dataSubscribers": state.data_hub.subscriber_count(),
|
| 116 |
+
"telemetrySubscribers": state.tele_hub.subscriber_count(),
|
| 117 |
+
"framesBroadcast": state.data_hub.frames_broadcast(),
|
| 118 |
+
"circuitBreaker": format!("{:?}", state.tele_hub.circuit_breaker()),
|
| 119 |
+
}))
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
async fn data_ws_handler(
|
| 123 |
+
ws: WebSocketUpgrade,
|
| 124 |
+
State(state): State<Arc<AppState>>,
|
| 125 |
+
) -> impl IntoResponse {
|
| 126 |
+
ws.on_upgrade(move |socket| data_ws_task(socket, state.data_hub.clone()))
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
async fn data_ws_task(mut socket: WebSocket, hub: DataChannelHub) {
|
| 130 |
+
let (mut rx, _dropped) = hub.subscribe();
|
| 131 |
+
|
| 132 |
+
loop {
|
| 133 |
+
match rx.recv().await {
|
| 134 |
+
Some(pkt) => {
|
| 135 |
+
debug_assert_eq!(pkt.len(), PACKET_SIZE);
|
| 136 |
+
if socket.send(Message::Binary(pkt.to_vec().into())).await.is_err() {
|
| 137 |
+
break;
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
None => break, // hub shut down
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
async fn telemetry_ws_handler(
|
| 146 |
+
ws: WebSocketUpgrade,
|
| 147 |
+
State(state): State<Arc<AppState>>,
|
| 148 |
+
) -> impl IntoResponse {
|
| 149 |
+
ws.on_upgrade(move |socket| {
|
| 150 |
+
telemetry_ws_task(socket, state.tele_hub.clone(), state.telemetry_interval)
|
| 151 |
+
})
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
async fn telemetry_ws_task(
|
| 155 |
+
mut socket: WebSocket,
|
| 156 |
+
hub: TelemetryHub,
|
| 157 |
+
interval: Duration,
|
| 158 |
+
) {
|
| 159 |
+
let mut rx = hub.subscribe();
|
| 160 |
+
let mut tick = time::interval(interval);
|
| 161 |
+
tick.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
|
| 162 |
+
|
| 163 |
+
loop {
|
| 164 |
+
tokio::select! {
|
| 165 |
+
_ = tick.tick() => {
|
| 166 |
+
// Drain any stale snapshots from the channel; only send the
|
| 167 |
+
// latest one. This keeps a slow browser from receiving a
|
| 168 |
+
// burst of old snapshots when it catches up.
|
| 169 |
+
let mut latest = None;
|
| 170 |
+
while let Ok(snap) = rx.try_recv() {
|
| 171 |
+
latest = Some(snap);
|
| 172 |
+
}
|
| 173 |
+
if let Some(json) = latest {
|
| 174 |
+
if socket.send(Message::Text(json.into())).await.is_err() {
|
| 175 |
+
break;
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
// Also forward anything the hub pushed between ticks (e.g. an
|
| 180 |
+
// alert) without waiting for the next scheduled tick.
|
| 181 |
+
Some(json) = rx.recv() => {
|
| 182 |
+
if socket.send(Message::Text(json.into())).await.is_err() {
|
| 183 |
+
break;
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
#[cfg(test)]
|
| 191 |
+
mod tests {
|
| 192 |
+
use super::*;
|
| 193 |
+
use std::net::SocketAddr;
|
| 194 |
+
use std::sync::Arc;
|
| 195 |
+
use std::time::Duration;
|
| 196 |
+
|
| 197 |
+
use bytes::BytesMut;
|
| 198 |
+
use miranda_core::{BlendshapeFrame, KinematicTransformFrame, BLENDSHAPE_COUNT, KINEMATIC_JOINT_COUNT, Quaternion};
|
| 199 |
+
|
| 200 |
+
fn blend(ts: u64) -> BlendshapeFrame {
|
| 201 |
+
BlendshapeFrame { timestamp_us: ts, weights: [0.0; BLENDSHAPE_COUNT] }
|
| 202 |
+
}
|
| 203 |
+
fn kin(ts: u64) -> KinematicTransformFrame {
|
| 204 |
+
KinematicTransformFrame {
|
| 205 |
+
timestamp_us: ts,
|
| 206 |
+
joints: [Quaternion::IDENTITY; KINEMATIC_JOINT_COUNT],
|
| 207 |
+
head_pitch_deg: 0.0,
|
| 208 |
+
clavicle_rise: 0.0,
|
| 209 |
+
_reserved: [0; 8],
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
#[test]
|
| 214 |
+
fn default_config_binds_to_loopback() {
|
| 215 |
+
let cfg = ServerConfig::default();
|
| 216 |
+
assert_eq!(cfg.bind.ip().to_string(), "127.0.0.1");
|
| 217 |
+
assert_eq!(cfg.bind.port(), 9090);
|
| 218 |
+
assert_eq!(cfg.telemetry_interval, Duration::from_millis(500));
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
/// Verify the server starts, the health endpoint is reachable, and a
|
| 222 |
+
/// data-plane subscriber receives a frame. Uses an OS-assigned port so
|
| 223 |
+
/// the test does not conflict with other tests or running instances.
|
| 224 |
+
#[tokio::test]
|
| 225 |
+
async fn server_health_endpoint_responds_ok() {
|
| 226 |
+
let data_hub = DataChannelHub::new();
|
| 227 |
+
let tele_hub = TelemetryHub::new(0);
|
| 228 |
+
let cfg = ServerConfig {
|
| 229 |
+
bind: "127.0.0.1:0".parse().unwrap(), // OS-assigned port
|
| 230 |
+
..Default::default()
|
| 231 |
+
};
|
| 232 |
+
|
| 233 |
+
let listener = tokio::net::TcpListener::bind(cfg.bind).await.unwrap();
|
| 234 |
+
let port = listener.local_addr().unwrap().port();
|
| 235 |
+
|
| 236 |
+
let state = Arc::new(AppState {
|
| 237 |
+
data_hub: data_hub.clone(),
|
| 238 |
+
tele_hub: tele_hub.clone(),
|
| 239 |
+
telemetry_interval: cfg.telemetry_interval,
|
| 240 |
+
});
|
| 241 |
+
let app = Router::new()
|
| 242 |
+
.route("/health", get(health_handler))
|
| 243 |
+
.route("/data", get(data_ws_handler))
|
| 244 |
+
.route("/telemetry", get(telemetry_ws_handler))
|
| 245 |
+
.with_state(state);
|
| 246 |
+
|
| 247 |
+
tokio::spawn(async move {
|
| 248 |
+
axum::serve(listener, app).await.unwrap();
|
| 249 |
+
});
|
| 250 |
+
|
| 251 |
+
// Give the server a moment to start.
|
| 252 |
+
tokio::time::sleep(Duration::from_millis(50)).await;
|
| 253 |
+
|
| 254 |
+
let url = format!("http://127.0.0.1:{port}/health");
|
| 255 |
+
let resp = reqwest::get(&url).await.expect("health request failed");
|
| 256 |
+
assert!(resp.status().is_success());
|
| 257 |
+
let body: serde_json::Value = resp.json().await.unwrap();
|
| 258 |
+
assert_eq!(body["ok"], true);
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
/// A data WebSocket subscriber receives correctly sized binary frames.
|
| 262 |
+
#[tokio::test]
|
| 263 |
+
async fn data_websocket_delivers_binary_frames() {
|
| 264 |
+
use futures_util::StreamExt;
|
| 265 |
+
let data_hub = DataChannelHub::new();
|
| 266 |
+
let tele_hub = TelemetryHub::new(0);
|
| 267 |
+
let cfg = ServerConfig {
|
| 268 |
+
bind: "127.0.0.1:0".parse().unwrap(),
|
| 269 |
+
..Default::default()
|
| 270 |
+
};
|
| 271 |
+
|
| 272 |
+
let listener = tokio::net::TcpListener::bind(cfg.bind).await.unwrap();
|
| 273 |
+
let port = listener.local_addr().unwrap().port();
|
| 274 |
+
|
| 275 |
+
let state = Arc::new(AppState {
|
| 276 |
+
data_hub: data_hub.clone(),
|
| 277 |
+
tele_hub: tele_hub.clone(),
|
| 278 |
+
telemetry_interval: cfg.telemetry_interval,
|
| 279 |
+
});
|
| 280 |
+
let app = Router::new()
|
| 281 |
+
.route("/data", get(data_ws_handler))
|
| 282 |
+
.with_state(state);
|
| 283 |
+
|
| 284 |
+
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
| 285 |
+
tokio::time::sleep(Duration::from_millis(50)).await;
|
| 286 |
+
|
| 287 |
+
// Connect a WebSocket client.
|
| 288 |
+
use tokio_tungstenite::tungstenite;
|
| 289 |
+
let (mut ws, _) = tokio_tungstenite::connect_async(
|
| 290 |
+
format!("ws://127.0.0.1:{port}/data")
|
| 291 |
+
).await.expect("ws connect failed");
|
| 292 |
+
|
| 293 |
+
// Allow time for the hub to register the subscriber.
|
| 294 |
+
tokio::time::sleep(Duration::from_millis(50)).await;
|
| 295 |
+
|
| 296 |
+
// Broadcast a frame from the hub side.
|
| 297 |
+
let mut scratch = BytesMut::with_capacity(PACKET_SIZE);
|
| 298 |
+
data_hub.broadcast(&blend(42), &kin(42), &mut scratch);
|
| 299 |
+
|
| 300 |
+
// Wait for the WebSocket to deliver it.
|
| 301 |
+
let msg = tokio::time::timeout(Duration::from_millis(500), ws.next()).await
|
| 302 |
+
.expect("timeout waiting for ws message")
|
| 303 |
+
.expect("ws stream ended")
|
| 304 |
+
.expect("ws error");
|
| 305 |
+
|
| 306 |
+
match msg {
|
| 307 |
+
tungstenite::Message::Binary(data) => {
|
| 308 |
+
assert_eq!(data.len(), PACKET_SIZE,
|
| 309 |
+
"expected {PACKET_SIZE} bytes, got {}", data.len());
|
| 310 |
+
assert_eq!(&data[..4], b"MRD1", "missing MRD1 magic");
|
| 311 |
+
}
|
| 312 |
+
other => panic!("expected binary frame, got {other:?}"),
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
/// Telemetry WebSocket delivers JSON with the expected shape.
|
| 317 |
+
#[tokio::test]
|
| 318 |
+
async fn telemetry_websocket_delivers_json_snapshot() {
|
| 319 |
+
use crate::telemetry::DispatchSource;
|
| 320 |
+
use futures_util::StreamExt;
|
| 321 |
+
struct MockSrc;
|
| 322 |
+
impl DispatchSource for MockSrc {
|
| 323 |
+
fn frames_published(&self) -> u64 { 120 }
|
| 324 |
+
fn frames_dropped(&self) -> u64 { 0 }
|
| 325 |
+
fn late_frames(&self) -> u64 { 0 }
|
| 326 |
+
fn publish_failures(&self) -> u64 { 0 }
|
| 327 |
+
fn mean_build_us(&self) -> f64 { 10.0 }
|
| 328 |
+
fn max_build_us(&self) -> f64 { 100.0 }
|
| 329 |
+
fn audio_chunks_consumed(&self) -> u64 { 60 }
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
let data_hub = DataChannelHub::new();
|
| 333 |
+
let tele_hub = TelemetryHub::new(0);
|
| 334 |
+
let cfg = ServerConfig {
|
| 335 |
+
bind: "127.0.0.1:0".parse().unwrap(),
|
| 336 |
+
telemetry_interval: Duration::from_millis(50), // fast for testing
|
| 337 |
+
};
|
| 338 |
+
|
| 339 |
+
let listener = tokio::net::TcpListener::bind(cfg.bind).await.unwrap();
|
| 340 |
+
let port = listener.local_addr().unwrap().port();
|
| 341 |
+
|
| 342 |
+
let tele_for_push = tele_hub.clone();
|
| 343 |
+
let data_for_state = data_hub.clone();
|
| 344 |
+
let state = Arc::new(AppState {
|
| 345 |
+
data_hub: data_for_state,
|
| 346 |
+
tele_hub: tele_hub.clone(),
|
| 347 |
+
telemetry_interval: cfg.telemetry_interval,
|
| 348 |
+
});
|
| 349 |
+
let app = Router::new()
|
| 350 |
+
.route("/telemetry", get(telemetry_ws_handler))
|
| 351 |
+
.with_state(state);
|
| 352 |
+
|
| 353 |
+
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
| 354 |
+
tokio::time::sleep(Duration::from_millis(50)).await;
|
| 355 |
+
|
| 356 |
+
use tokio_tungstenite::tungstenite;
|
| 357 |
+
let (mut ws, _) = tokio_tungstenite::connect_async(
|
| 358 |
+
format!("ws://127.0.0.1:{port}/telemetry")
|
| 359 |
+
).await.expect("ws connect failed");
|
| 360 |
+
|
| 361 |
+
// Push a snapshot — the ws task will drain it on the next tick.
|
| 362 |
+
tokio::time::sleep(Duration::from_millis(20)).await;
|
| 363 |
+
tele_for_push.publish_snapshot(1_000_000, &MockSrc, 0, 1, 0, 0);
|
| 364 |
+
|
| 365 |
+
let msg = tokio::time::timeout(Duration::from_millis(500), ws.next()).await
|
| 366 |
+
.expect("timeout")
|
| 367 |
+
.expect("stream ended")
|
| 368 |
+
.expect("ws error");
|
| 369 |
+
|
| 370 |
+
match msg {
|
| 371 |
+
tungstenite::Message::Text(json) => {
|
| 372 |
+
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
| 373 |
+
assert_eq!(parsed["framesPublished"], 120);
|
| 374 |
+
assert_eq!(parsed["circuitBreaker"], "closed");
|
| 375 |
+
}
|
| 376 |
+
other => panic!("expected text frame, got {other:?}"),
|
| 377 |
+
}
|
| 378 |
+
}
|
| 379 |
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Control-plane telemetry hub — JSON snapshots for management tools.
|
| 2 |
+
//!
|
| 3 |
+
//! [`TelemetryHub`] broadcasts [`TelemetrySnapshot`] messages to every
|
| 4 |
+
//! connected management tool at a configurable interval (default 500 ms).
|
| 5 |
+
//! The snapshot JSON is produced once and fanned out to all subscribers,
|
| 6 |
+
//! so the serialization cost is O(1) regardless of subscriber count.
|
| 7 |
+
//!
|
| 8 |
+
//! # Circuit-breaker state
|
| 9 |
+
//!
|
| 10 |
+
//! The circuit breaker is a simple three-state guard around the blendshape
|
| 11 |
+
//! bus drain. If the dispatcher drops frames for more than
|
| 12 |
+
//! [`CIRCUIT_BREAKER_TRIP_THRESHOLD_MS`] milliseconds, the breaker trips
|
| 13 |
+
//! and the hub stops broadcasting stale data. When the dispatcher catches up
|
| 14 |
+
//! for [`CIRCUIT_BREAKER_RESET_THRESHOLD_MS`], the breaker resets.
|
| 15 |
+
//!
|
| 16 |
+
//! Tripped state is surfaced in every telemetry snapshot so the frontend
|
| 17 |
+
//! (`THE VANITY`) can show a visual circuit-breaker indicator rather than
|
| 18 |
+
//! showing a frozen face with no explanation.
|
| 19 |
+
|
| 20 |
+
use std::sync::{Arc, Mutex};
|
| 21 |
+
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
| 22 |
+
|
| 23 |
+
use serde::{Deserialize, Serialize};
|
| 24 |
+
use tokio::sync::mpsc;
|
| 25 |
+
|
| 26 |
+
/// Dropped-frame window before the circuit breaker trips.
|
| 27 |
+
pub const CIRCUIT_BREAKER_TRIP_THRESHOLD_MS: u64 = 500;
|
| 28 |
+
|
| 29 |
+
/// Clean-frame window before the circuit breaker resets.
|
| 30 |
+
pub const CIRCUIT_BREAKER_RESET_THRESHOLD_MS: u64 = 1_000;
|
| 31 |
+
|
| 32 |
+
/// Backlog for each telemetry subscriber's channel.
|
| 33 |
+
/// 500 ms / 500 ms interval = 1 snapshot in flight. Give it 8 slots of
|
| 34 |
+
/// headroom in case a subscriber is briefly slow.
|
| 35 |
+
pub const TELEMETRY_CHANNEL_DEPTH: usize = 8;
|
| 36 |
+
|
| 37 |
+
/// A point-in-time snapshot of the transport and dispatcher state.
|
| 38 |
+
///
|
| 39 |
+
/// Field names are camelCase to match the existing ace-controller JSON
|
| 40 |
+
/// convention used by THE VANITY's node graph.
|
| 41 |
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
| 42 |
+
#[serde(rename_all = "camelCase")]
|
| 43 |
+
pub struct TelemetrySnapshot {
|
| 44 |
+
/// Microseconds since harness start.
|
| 45 |
+
pub t_us: u64,
|
| 46 |
+
|
| 47 |
+
// --- Dispatcher counters (from miranda-nodes DispatchStats) ---
|
| 48 |
+
pub frames_published: u64,
|
| 49 |
+
pub frames_dropped: u64,
|
| 50 |
+
pub late_frames: u64,
|
| 51 |
+
pub publish_failures: u64,
|
| 52 |
+
pub mean_build_us: f64,
|
| 53 |
+
pub max_build_us: f64,
|
| 54 |
+
pub audio_chunks_consumed: u64,
|
| 55 |
+
|
| 56 |
+
// --- Transport counters ---
|
| 57 |
+
pub data_subscribers: usize,
|
| 58 |
+
pub telemetry_subscribers: usize,
|
| 59 |
+
pub frames_broadcast: u64,
|
| 60 |
+
pub frames_dropped_backpressure: u64,
|
| 61 |
+
|
| 62 |
+
// --- Circuit breaker ---
|
| 63 |
+
pub circuit_breaker: CircuitBreakerState,
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/// Three-state circuit breaker reported in every telemetry snapshot.
|
| 67 |
+
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
| 68 |
+
#[serde(rename_all = "lowercase")]
|
| 69 |
+
pub enum CircuitBreakerState {
|
| 70 |
+
/// Normal operation — frames are being produced on time.
|
| 71 |
+
Closed,
|
| 72 |
+
/// Frames have been dropping for less than
|
| 73 |
+
/// [`CIRCUIT_BREAKER_TRIP_THRESHOLD_MS`]. Monitoring is indicated;
|
| 74 |
+
/// broadcast continues.
|
| 75 |
+
HalfOpen,
|
| 76 |
+
/// Frames have been dropping for more than the threshold. The renderer
|
| 77 |
+
/// should show a connectivity indicator rather than a frozen face.
|
| 78 |
+
Open,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/// A live source of dispatcher counters. Implemented by the wiring layer
|
| 82 |
+
/// (T5) so the telemetry hub does not depend on `miranda-nodes` directly —
|
| 83 |
+
/// it depends on this trait, which can also be backed by a mock in tests.
|
| 84 |
+
pub trait DispatchSource: Send + Sync + 'static {
|
| 85 |
+
fn frames_published(&self) -> u64;
|
| 86 |
+
fn frames_dropped(&self) -> u64;
|
| 87 |
+
fn late_frames(&self) -> u64;
|
| 88 |
+
fn publish_failures(&self) -> u64;
|
| 89 |
+
fn mean_build_us(&self) -> f64;
|
| 90 |
+
fn max_build_us(&self) -> f64;
|
| 91 |
+
fn audio_chunks_consumed(&self) -> u64;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/// Telemetry subscriber receive end. The WebSocket handler task reads from
|
| 95 |
+
/// this and forwards JSON text frames to the browser.
|
| 96 |
+
pub type TelemetryRx = mpsc::Receiver<String>;
|
| 97 |
+
|
| 98 |
+
struct TelemetrySubscriber {
|
| 99 |
+
tx: mpsc::Sender<String>,
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/// The control-plane telemetry hub.
|
| 103 |
+
///
|
| 104 |
+
/// Clone cheaply — every clone shares the same subscriber list and the same
|
| 105 |
+
/// circuit-breaker state.
|
| 106 |
+
#[derive(Clone)]
|
| 107 |
+
pub struct TelemetryHub {
|
| 108 |
+
inner: Arc<TelemetryInner>,
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
struct TelemetryInner {
|
| 112 |
+
subscribers: Mutex<Vec<TelemetrySubscriber>>,
|
| 113 |
+
start_us: u64,
|
| 114 |
+
// Circuit-breaker state.
|
| 115 |
+
breaker: AtomicU64, // encodes CircuitBreakerState as 0/1/2
|
| 116 |
+
// How long (in ticks) we have been in the current state.
|
| 117 |
+
bad_streak_ms: AtomicU64,
|
| 118 |
+
good_streak_ms: AtomicU64,
|
| 119 |
+
tripped: AtomicBool,
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
fn encode_state(s: CircuitBreakerState) -> u64 {
|
| 123 |
+
match s {
|
| 124 |
+
CircuitBreakerState::Closed => 0,
|
| 125 |
+
CircuitBreakerState::HalfOpen => 1,
|
| 126 |
+
CircuitBreakerState::Open => 2,
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
fn decode_state(v: u64) -> CircuitBreakerState {
|
| 131 |
+
match v {
|
| 132 |
+
1 => CircuitBreakerState::HalfOpen,
|
| 133 |
+
2 => CircuitBreakerState::Open,
|
| 134 |
+
_ => CircuitBreakerState::Closed,
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
impl TelemetryHub {
|
| 139 |
+
/// Creates the hub. `start_us` is the harness epoch (microseconds) used
|
| 140 |
+
/// to compute `t_us` in snapshots.
|
| 141 |
+
pub fn new(start_us: u64) -> Self {
|
| 142 |
+
Self {
|
| 143 |
+
inner: Arc::new(TelemetryInner {
|
| 144 |
+
subscribers: Mutex::new(Vec::new()),
|
| 145 |
+
start_us,
|
| 146 |
+
breaker: AtomicU64::new(0),
|
| 147 |
+
bad_streak_ms: AtomicU64::new(0),
|
| 148 |
+
good_streak_ms: AtomicU64::new(0),
|
| 149 |
+
tripped: AtomicBool::new(false),
|
| 150 |
+
}),
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
/// Registers a new telemetry subscriber and returns its receive end.
|
| 155 |
+
pub fn subscribe(&self) -> TelemetryRx {
|
| 156 |
+
let (tx, rx) = mpsc::channel(TELEMETRY_CHANNEL_DEPTH);
|
| 157 |
+
self.inner
|
| 158 |
+
.subscribers
|
| 159 |
+
.lock()
|
| 160 |
+
.unwrap()
|
| 161 |
+
.push(TelemetrySubscriber { tx });
|
| 162 |
+
rx
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
/// Advances the circuit-breaker state based on the latest drop count.
|
| 166 |
+
///
|
| 167 |
+
/// Called once per telemetry tick (every `interval_ms`).
|
| 168 |
+
pub fn tick_circuit_breaker(&self, frames_dropped_this_tick: u64, interval_ms: u64) {
|
| 169 |
+
if frames_dropped_this_tick > 0 {
|
| 170 |
+
let bad = self.inner.bad_streak_ms.fetch_add(interval_ms, Ordering::Relaxed)
|
| 171 |
+
+ interval_ms;
|
| 172 |
+
self.inner.good_streak_ms.store(0, Ordering::Relaxed);
|
| 173 |
+
let new_state = if bad >= CIRCUIT_BREAKER_TRIP_THRESHOLD_MS {
|
| 174 |
+
self.inner.tripped.store(true, Ordering::Relaxed);
|
| 175 |
+
CircuitBreakerState::Open
|
| 176 |
+
} else {
|
| 177 |
+
CircuitBreakerState::HalfOpen
|
| 178 |
+
};
|
| 179 |
+
self.inner
|
| 180 |
+
.breaker
|
| 181 |
+
.store(encode_state(new_state), Ordering::Relaxed);
|
| 182 |
+
} else {
|
| 183 |
+
self.inner.bad_streak_ms.store(0, Ordering::Relaxed);
|
| 184 |
+
let good = self.inner.good_streak_ms.fetch_add(interval_ms, Ordering::Relaxed)
|
| 185 |
+
+ interval_ms;
|
| 186 |
+
if self.inner.tripped.load(Ordering::Relaxed)
|
| 187 |
+
&& good >= CIRCUIT_BREAKER_RESET_THRESHOLD_MS
|
| 188 |
+
{
|
| 189 |
+
self.inner.tripped.store(false, Ordering::Relaxed);
|
| 190 |
+
self.inner
|
| 191 |
+
.breaker
|
| 192 |
+
.store(encode_state(CircuitBreakerState::Closed), Ordering::Relaxed);
|
| 193 |
+
} else if !self.inner.tripped.load(Ordering::Relaxed) {
|
| 194 |
+
self.inner
|
| 195 |
+
.breaker
|
| 196 |
+
.store(encode_state(CircuitBreakerState::Closed), Ordering::Relaxed);
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
/// Current circuit-breaker state.
|
| 202 |
+
pub fn circuit_breaker(&self) -> CircuitBreakerState {
|
| 203 |
+
decode_state(self.inner.breaker.load(Ordering::Relaxed))
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
/// Builds a snapshot and broadcasts it to all subscribers as a JSON
|
| 207 |
+
/// text frame. Prunes disconnected subscribers.
|
| 208 |
+
pub fn publish_snapshot(
|
| 209 |
+
&self,
|
| 210 |
+
now_us: u64,
|
| 211 |
+
source: &dyn DispatchSource,
|
| 212 |
+
data_subscribers: usize,
|
| 213 |
+
telemetry_subscribers: usize,
|
| 214 |
+
frames_broadcast: u64,
|
| 215 |
+
frames_dropped_bp: u64,
|
| 216 |
+
) {
|
| 217 |
+
let snap = TelemetrySnapshot {
|
| 218 |
+
t_us: now_us.saturating_sub(self.inner.start_us),
|
| 219 |
+
frames_published: source.frames_published(),
|
| 220 |
+
frames_dropped: source.frames_dropped(),
|
| 221 |
+
late_frames: source.late_frames(),
|
| 222 |
+
publish_failures: source.publish_failures(),
|
| 223 |
+
mean_build_us: source.mean_build_us(),
|
| 224 |
+
max_build_us: source.max_build_us(),
|
| 225 |
+
audio_chunks_consumed: source.audio_chunks_consumed(),
|
| 226 |
+
data_subscribers,
|
| 227 |
+
telemetry_subscribers,
|
| 228 |
+
frames_broadcast,
|
| 229 |
+
frames_dropped_backpressure: frames_dropped_bp,
|
| 230 |
+
circuit_breaker: self.circuit_breaker(),
|
| 231 |
+
};
|
| 232 |
+
|
| 233 |
+
let json = match serde_json::to_string(&snap) {
|
| 234 |
+
Ok(s) => s,
|
| 235 |
+
Err(_) => return, // can't fail in practice for this struct
|
| 236 |
+
};
|
| 237 |
+
|
| 238 |
+
let mut subs = self.inner.subscribers.lock().unwrap();
|
| 239 |
+
subs.retain(|s| !s.tx.is_closed());
|
| 240 |
+
for s in subs.iter() {
|
| 241 |
+
let _ = s.tx.try_send(json.clone());
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
/// Number of active telemetry subscribers.
|
| 246 |
+
pub fn subscriber_count(&self) -> usize {
|
| 247 |
+
self.inner.subscribers.lock().unwrap().len()
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
#[cfg(test)]
|
| 252 |
+
mod tests {
|
| 253 |
+
use super::*;
|
| 254 |
+
#[allow(unused_imports)]
|
| 255 |
+
use std::sync::atomic::AtomicU64 as StdAtomicU64;
|
| 256 |
+
|
| 257 |
+
struct MockSource {
|
| 258 |
+
published: u64,
|
| 259 |
+
dropped: u64,
|
| 260 |
+
late: u64,
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
impl DispatchSource for MockSource {
|
| 264 |
+
fn frames_published(&self) -> u64 { self.published }
|
| 265 |
+
fn frames_dropped(&self) -> u64 { self.dropped }
|
| 266 |
+
fn late_frames(&self) -> u64 { self.late }
|
| 267 |
+
fn publish_failures(&self) -> u64 { 0 }
|
| 268 |
+
fn mean_build_us(&self) -> f64 { 11.5 }
|
| 269 |
+
fn max_build_us(&self) -> f64 { 250.0 }
|
| 270 |
+
fn audio_chunks_consumed(&self) -> u64 { 100 }
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
fn source(published: u64, dropped: u64) -> MockSource {
|
| 274 |
+
MockSource { published, dropped, late: 0 }
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
#[tokio::test]
|
| 278 |
+
async fn subscriber_receives_json_snapshot() {
|
| 279 |
+
let hub = TelemetryHub::new(0);
|
| 280 |
+
let mut rx = hub.subscribe();
|
| 281 |
+
|
| 282 |
+
hub.publish_snapshot(1_000_000, &source(60, 0), 1, 1, 60, 0);
|
| 283 |
+
|
| 284 |
+
let json = rx.recv().await.expect("snapshot expected");
|
| 285 |
+
let parsed: TelemetrySnapshot = serde_json::from_str(&json).expect("valid json");
|
| 286 |
+
assert_eq!(parsed.frames_published, 60);
|
| 287 |
+
assert_eq!(parsed.frames_dropped, 0);
|
| 288 |
+
assert_eq!(parsed.circuit_breaker, CircuitBreakerState::Closed);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
#[tokio::test]
|
| 292 |
+
async fn multiple_subscribers_all_receive_same_snapshot() {
|
| 293 |
+
let hub = TelemetryHub::new(0);
|
| 294 |
+
let mut rx1 = hub.subscribe();
|
| 295 |
+
let mut rx2 = hub.subscribe();
|
| 296 |
+
|
| 297 |
+
hub.publish_snapshot(0, &source(1, 0), 0, 2, 1, 0);
|
| 298 |
+
|
| 299 |
+
let j1 = rx1.recv().await.unwrap();
|
| 300 |
+
let j2 = rx2.recv().await.unwrap();
|
| 301 |
+
assert_eq!(j1, j2);
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
#[test]
|
| 305 |
+
fn circuit_breaker_trips_after_threshold() {
|
| 306 |
+
let hub = TelemetryHub::new(0);
|
| 307 |
+
assert_eq!(hub.circuit_breaker(), CircuitBreakerState::Closed);
|
| 308 |
+
|
| 309 |
+
// Feed drops in 100 ms ticks until we hit the trip threshold.
|
| 310 |
+
let ticks = CIRCUIT_BREAKER_TRIP_THRESHOLD_MS / 100;
|
| 311 |
+
for _ in 0..ticks {
|
| 312 |
+
hub.tick_circuit_breaker(1, 100);
|
| 313 |
+
}
|
| 314 |
+
assert_eq!(hub.circuit_breaker(), CircuitBreakerState::Open);
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
#[test]
|
| 318 |
+
fn circuit_breaker_enters_half_open_before_tripping() {
|
| 319 |
+
let hub = TelemetryHub::new(0);
|
| 320 |
+
// One tick of drops, below the full threshold.
|
| 321 |
+
hub.tick_circuit_breaker(1, 100);
|
| 322 |
+
assert_eq!(hub.circuit_breaker(), CircuitBreakerState::HalfOpen);
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
#[test]
|
| 326 |
+
fn circuit_breaker_resets_after_clean_period() {
|
| 327 |
+
let hub = TelemetryHub::new(0);
|
| 328 |
+
|
| 329 |
+
// Trip it.
|
| 330 |
+
let trip_ticks = CIRCUIT_BREAKER_TRIP_THRESHOLD_MS / 100;
|
| 331 |
+
for _ in 0..trip_ticks {
|
| 332 |
+
hub.tick_circuit_breaker(1, 100);
|
| 333 |
+
}
|
| 334 |
+
assert_eq!(hub.circuit_breaker(), CircuitBreakerState::Open);
|
| 335 |
+
|
| 336 |
+
// Feed clean ticks until reset.
|
| 337 |
+
let reset_ticks = CIRCUIT_BREAKER_RESET_THRESHOLD_MS / 100;
|
| 338 |
+
for _ in 0..reset_ticks {
|
| 339 |
+
hub.tick_circuit_breaker(0, 100);
|
| 340 |
+
}
|
| 341 |
+
assert_eq!(hub.circuit_breaker(), CircuitBreakerState::Closed);
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
#[test]
|
| 345 |
+
fn bad_streak_resets_when_frames_recover() {
|
| 346 |
+
let hub = TelemetryHub::new(0);
|
| 347 |
+
hub.tick_circuit_breaker(1, 100); // half-open
|
| 348 |
+
hub.tick_circuit_breaker(0, 100); // recover — should go back to closed
|
| 349 |
+
assert_eq!(hub.circuit_breaker(), CircuitBreakerState::Closed);
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
#[tokio::test]
|
| 353 |
+
async fn disconnected_subscriber_is_pruned() {
|
| 354 |
+
let hub = TelemetryHub::new(0);
|
| 355 |
+
let rx = hub.subscribe();
|
| 356 |
+
assert_eq!(hub.subscriber_count(), 1);
|
| 357 |
+
drop(rx);
|
| 358 |
+
hub.publish_snapshot(0, &source(0, 0), 0, 0, 0, 0);
|
| 359 |
+
assert_eq!(hub.subscriber_count(), 0);
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
#[test]
|
| 363 |
+
fn hub_is_send_and_sync() {
|
| 364 |
+
fn assert_ss<T: Send + Sync>() {}
|
| 365 |
+
assert_ss::<TelemetryHub>();
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
#[test]
|
| 369 |
+
fn snapshot_json_is_camel_case() {
|
| 370 |
+
let snap = TelemetrySnapshot {
|
| 371 |
+
t_us: 0,
|
| 372 |
+
frames_published: 0,
|
| 373 |
+
frames_dropped: 0,
|
| 374 |
+
late_frames: 0,
|
| 375 |
+
publish_failures: 0,
|
| 376 |
+
mean_build_us: 0.0,
|
| 377 |
+
max_build_us: 0.0,
|
| 378 |
+
audio_chunks_consumed: 0,
|
| 379 |
+
data_subscribers: 0,
|
| 380 |
+
telemetry_subscribers: 0,
|
| 381 |
+
frames_broadcast: 0,
|
| 382 |
+
frames_dropped_backpressure: 0,
|
| 383 |
+
circuit_breaker: CircuitBreakerState::Closed,
|
| 384 |
+
};
|
| 385 |
+
let json = serde_json::to_string(&snap).unwrap();
|
| 386 |
+
// Spot-check a couple of the renamed fields.
|
| 387 |
+
assert!(json.contains("\"framesPublished\""), "expected camelCase framesPublished");
|
| 388 |
+
assert!(json.contains("\"meanBuildUs\""), "expected camelCase meanBuildUs");
|
| 389 |
+
assert!(json.contains("\"circuitBreaker\""), "expected camelCase circuitBreaker");
|
| 390 |
+
}
|
| 391 |
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Wiring: connects real `miranda-nodes` types to the transport layer's
|
| 2 |
+
//! abstraction traits.
|
| 3 |
+
//!
|
| 4 |
+
//! This module is the only place `miranda-transport` reaches into
|
| 5 |
+
//! `miranda-nodes`. Every other module in this crate works through the
|
| 6 |
+
//! [`DispatchSource`] trait so it is testable with a mock. Keeping the
|
| 7 |
+
//! concrete dependency in one file makes it easy to swap the source (e.g.
|
| 8 |
+
//! to a different WO-3 dispatcher implementation) without touching the hub
|
| 9 |
+
//! or telemetry logic.
|
| 10 |
+
|
| 11 |
+
use std::sync::Arc;
|
| 12 |
+
use std::sync::atomic::Ordering;
|
| 13 |
+
|
| 14 |
+
use miranda_nodes::dispatcher::DispatchStats;
|
| 15 |
+
|
| 16 |
+
use crate::telemetry::DispatchSource;
|
| 17 |
+
|
| 18 |
+
/// Wraps an `Arc<DispatchStats>` and implements [`DispatchSource`] so the
|
| 19 |
+
/// telemetry hub can read live counters from the running dispatcher.
|
| 20 |
+
///
|
| 21 |
+
/// All counters use `Relaxed` ordering — the same ordering `DispatchStats`
|
| 22 |
+
/// itself uses internally. `Relaxed` is correct here because:
|
| 23 |
+
///
|
| 24 |
+
/// 1. These are independent monotonic counters with no happens-before
|
| 25 |
+
/// relationship to enforce between them.
|
| 26 |
+
/// 2. The telemetry snapshot is a *sample*, not a synchronization point.
|
| 27 |
+
/// A slightly stale count on one field while another is up-to-date does
|
| 28 |
+
/// not affect correctness — it just means the snapshot was taken mid-
|
| 29 |
+
/// frame, which is unavoidable without a mutex the dispatcher would need
|
| 30 |
+
/// to hold across the entire frame build.
|
| 31 |
+
pub struct StatsSource {
|
| 32 |
+
stats: Arc<DispatchStats>,
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
impl StatsSource {
|
| 36 |
+
/// Wraps a shared `DispatchStats` reference.
|
| 37 |
+
pub fn new(stats: Arc<DispatchStats>) -> Self {
|
| 38 |
+
Self { stats }
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
impl DispatchSource for StatsSource {
|
| 43 |
+
fn frames_published(&self) -> u64 {
|
| 44 |
+
self.stats.frames_published.load(Ordering::Relaxed)
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
fn frames_dropped(&self) -> u64 {
|
| 48 |
+
self.stats.frames_dropped.load(Ordering::Relaxed)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
fn late_frames(&self) -> u64 {
|
| 52 |
+
self.stats.late_frames.load(Ordering::Relaxed)
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
fn publish_failures(&self) -> u64 {
|
| 56 |
+
self.stats.publish_failures.load(Ordering::Relaxed)
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
fn mean_build_us(&self) -> f64 {
|
| 60 |
+
// `DispatchStats::mean_build_us()` returns `None` before the first
|
| 61 |
+
// frame is published. Map that to `0.0` so the telemetry subscriber
|
| 62 |
+
// always receives a valid JSON number rather than `null`.
|
| 63 |
+
self.stats.mean_build_us().unwrap_or(0.0)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
fn max_build_us(&self) -> f64 {
|
| 67 |
+
self.stats.max_build_us()
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
fn audio_chunks_consumed(&self) -> u64 {
|
| 71 |
+
self.stats.audio_chunks_consumed.load(Ordering::Relaxed)
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
#[cfg(test)]
|
| 76 |
+
mod tests {
|
| 77 |
+
use super::*;
|
| 78 |
+
use std::sync::atomic::AtomicU64;
|
| 79 |
+
|
| 80 |
+
fn make_stats(
|
| 81 |
+
published: u64,
|
| 82 |
+
dropped: u64,
|
| 83 |
+
late: u64,
|
| 84 |
+
failures: u64,
|
| 85 |
+
total_build_ns: u64,
|
| 86 |
+
max_build_ns: u64,
|
| 87 |
+
audio: u64,
|
| 88 |
+
) -> Arc<DispatchStats> {
|
| 89 |
+
let s = Arc::new(DispatchStats::default());
|
| 90 |
+
s.frames_published.store(published, Ordering::Relaxed);
|
| 91 |
+
s.frames_dropped.store(dropped, Ordering::Relaxed);
|
| 92 |
+
s.late_frames.store(late, Ordering::Relaxed);
|
| 93 |
+
s.publish_failures.store(failures, Ordering::Relaxed);
|
| 94 |
+
s.total_build_ns.store(total_build_ns, Ordering::Relaxed);
|
| 95 |
+
s.max_build_ns.store(max_build_ns, Ordering::Relaxed);
|
| 96 |
+
s.audio_chunks_consumed.store(audio, Ordering::Relaxed);
|
| 97 |
+
s
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
#[test]
|
| 101 |
+
fn all_fields_are_forwarded_correctly() {
|
| 102 |
+
let stats = make_stats(1801, 0, 2, 3, 21_570_000, 1_102_301, 360);
|
| 103 |
+
let src = StatsSource::new(stats);
|
| 104 |
+
|
| 105 |
+
assert_eq!(src.frames_published(), 1801);
|
| 106 |
+
assert_eq!(src.frames_dropped(), 0);
|
| 107 |
+
assert_eq!(src.late_frames(), 2);
|
| 108 |
+
assert_eq!(src.publish_failures(), 3);
|
| 109 |
+
// mean = total_build_ns / published / 1000 = 21_570_000 / 1801 / 1000 ≈ 11.98 µs
|
| 110 |
+
let mean = src.mean_build_us();
|
| 111 |
+
assert!(
|
| 112 |
+
(mean - 11.977).abs() < 0.01,
|
| 113 |
+
"mean_build_us {mean:.4} not close to expected 11.977"
|
| 114 |
+
);
|
| 115 |
+
// max = max_build_ns / 1000 = 1_102_301 ns = 1102.3 µs
|
| 116 |
+
let max = src.max_build_us();
|
| 117 |
+
assert!(
|
| 118 |
+
(max - 1102.3).abs() < 0.01,
|
| 119 |
+
"max_build_us {max:.4} not close to expected 1102.3"
|
| 120 |
+
);
|
| 121 |
+
assert_eq!(src.audio_chunks_consumed(), 360);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
#[test]
|
| 125 |
+
fn mean_is_zero_before_any_frames() {
|
| 126 |
+
let stats = make_stats(0, 0, 0, 0, 0, 0, 0);
|
| 127 |
+
let src = StatsSource::new(stats);
|
| 128 |
+
assert_eq!(src.mean_build_us(), 0.0);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
#[test]
|
| 132 |
+
fn stats_source_is_send_and_sync() {
|
| 133 |
+
fn assert_ss<T: Send + Sync>() {}
|
| 134 |
+
assert_ss::<StatsSource>();
|
| 135 |
+
}
|
| 136 |
+
}
|