hunt commited on
Commit
e4d5e6c
·
1 Parent(s): 7dd5742

WO-4: KinematicTransformFrame, DataChannelHub, Axum telemetry, StatsSource wiring

Browse files

T2: 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 CHANGED
@@ -277,7 +277,7 @@ pub struct BackpressureError {
277
  pub capacity: usize,
278
  }
279
 
280
- /// Identifies which of the three rings on the bus a `BackpressureError`
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
+ }
miranda-ipc/src/lib.rs CHANGED
@@ -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, and 9-coefficient spherical harmonic vectors.
 
5
  //!
6
  //! # What this is
7
  //!
8
- //! Three independent single-producer / single-consumer (SPSC) ring buffers
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::{AudioChunk, BackpressureError, BlendshapeFrame, RingId, SphericalHarmonics};
 
 
 
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(SH_DATA_OFF + SH_DATA_BYTES, CACHE_LINE);
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 <= BUS_TOTAL_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
- /// Capacity in slots of each ring, as `(audio, blendshape, sh)`.
502
- /// Exposed for telemetry — THE VANITY shows ring occupancy per node.
503
- pub const fn capacities() -> (usize, usize, usize) {
504
- (AUDIO_SLOTS, BLEND_SLOTS, SH_SLOTS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
miranda-transport/Cargo.toml CHANGED
@@ -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"
miranda-transport/src/frame.rs ADDED
@@ -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
+ }
miranda-transport/src/hub.rs ADDED
@@ -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
+ }
miranda-transport/src/lib.rs CHANGED
@@ -1,3 +1,80 @@
1
- //! miranda-transport — Work Order 4: webrtc-rs DataChannels for blendshape
2
- //! matrices/gaze/lighting at 60 FPS, plus the Axum WebSocket telemetry hub.
3
- //! Empty skeleton — see `nobility-posh-framework`'s work-orders.md.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;
miranda-transport/src/server.rs ADDED
@@ -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
+ }
miranda-transport/src/telemetry.rs ADDED
@@ -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
+ }
miranda-transport/src/wiring.rs ADDED
@@ -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
+ }