Spaces:
Runtime error
Runtime error
| # T-002: IPC Command Registry β All Typed Serde Structs & TypeScript Mirror Types | |
| **Type:** Task | |
| **Phase:** 0 β Foundation | |
| **Autonomy:** `agent:review-required` β Agent writes all types and stub handlers. Human reviews PR before merge. A type error here cascades to all 148 remaining tasks. | |
| **Stack:** `stack:rust` `stack:typescript` | |
| **Version:** v0.1 | |
| **Iteration:** iter-1 | |
| **Effort:** M (1β2 days) | |
| --- | |
| > β οΈ **Agent Scope:** Write the **type system and stub command registry only**. Every command handler returns `IpcError::NotImplemented`. No audio, no models, no cameras, no controllers. Logic arrives in T-003 through T-150. Your deliverable is a compile-clean, fully-typed contract. | |
| > β οΈ **Contract Rule:** Once merged, **no type may be renamed, removed, or have a field removed** without a new migration sub-issue. Adding `Option<T>` fields to existing structs is safe without a migration. | |
| > β οΈ **Naming:** The Tauri backend directory is `kansas/` (not `src-tauri/`). All paths below reflect this. See AMENDMENT-001. | |
| --- | |
| ## Context | |
| Tauri IPC is the nervous system of Synesthesia. Every subsystem communicates with the Rust backend through typed `invoke()` calls and typed `emit()` events. If Rust and TypeScript types diverge, the mismatch surfaces as a silent runtime `undefined` or a JSON parse panic β not a compile error. | |
| This task eliminates that class of bug by defining the complete type surface in one place on both sides, enforcing `serde(rename_all = "camelCase")` so the JSON wire format matches TypeScript conventions, and providing typed wrappers for every command and event in the full v1 system. Many commands will have stub handlers for months. That is correct. The stubs are compile-time documentation. | |
| --- | |
| ## Prerequisites | |
| - [ ] T-001 merged β `kansas/`, `src/`, workspace `Cargo.toml` with `edition = "2024"` and wildcard deps exist | |
| - [ ] `kansas/src/commands/mod.rs` contains only `ping()` from T-001 | |
| - [ ] `cargo check --workspace` passes on T-001 output before starting | |
| --- | |
| ## Acceptance Criteria | |
| ### Rust | |
| - [ ] `cargo check --workspace` β zero errors | |
| - [ ] `cargo clippy --workspace -- -D warnings` β zero warnings | |
| - [ ] Every public type derives `Debug`, `Clone`, `Serialize`, `Deserialize` | |
| - [ ] Every struct/enum has `// [T-NNN]` doc comment for the implementing task | |
| - [ ] All serde types use `#[serde(rename_all = "camelCase")]` | |
| - [ ] `Option<T>` fields use `#[serde(skip_serializing_if = "Option::is_none")]` | |
| - [ ] All stubs return `Result<_, IpcError>` β no `unwrap!`, `panic!`, `todo!()` | |
| - [ ] `ipc` module declared in `kansas/src/lib.rs` | |
| ### TypeScript | |
| - [ ] `pnpm typecheck` β zero errors | |
| - [ ] `src/ipc/types.ts` β all interfaces match Rust counterparts field-for-field | |
| - [ ] `src/ipc/commands.ts` β typed `invoke()` wrapper for every command | |
| - [ ] `src/ipc/events.ts` β typed `listen()` wrapper for every event | |
| - [ ] No `any` types in the IPC layer | |
| ### Registration | |
| - [ ] All commands in the Commands Catalog registered in `tauri::generate_handler![]` | |
| - [ ] All events in the Events Catalog have a TypeScript `listen()` wrapper | |
| - [ ] `ping` from T-001 preserved and moved into the new module structure | |
| ### Capability | |
| - [ ] `kansas/capabilities/default.json` lists every new command | |
| - [ ] No command callable without an explicit capability entry | |
| --- | |
| ## Serialization Contract | |
| **Rule: Rust is `snake_case` internally. JSON wire format and TypeScript are `camelCase`.** | |
| ```rust | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct ExamplePayload { | |
| pub some_field: f32, // β "someField" | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub optional_field: Option<u32>, | |
| } | |
| // Unit enum β tagged string | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase", tag = "kind")] | |
| pub enum TransportState { Playing, Paused, Stopped } | |
| // β { "kind": "playing" } | |
| // Data-carrying enum β adjacent tagged | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase", tag = "type", content = "data")] | |
| pub enum HapticKind { | |
| BeatPulse { intensity: f32 }, | |
| ChunkBoundary, | |
| } | |
| // β { "type": "beatPulse", "data": { "intensity": 0.8 } } | |
| ``` | |
| --- | |
| ## File Structure After This Task | |
| ``` | |
| kansas/src/ | |
| βββ main.rs β registers all commands | |
| βββ lib.rs β declares ipc + commands modules | |
| βββ ipc/ | |
| β βββ mod.rs β re-exports types, error | |
| β βββ types.rs β ALL serde structs and enums | |
| β βββ error.rs β IpcError enum | |
| βββ commands/ | |
| βββ mod.rs β re-exports all submodules | |
| βββ ping.rs β moved from T-001 | |
| βββ transport.rs β stub β T-003 | |
| βββ audio.rs β stub β T-004 | |
| βββ models.rs β stub β T-046+ | |
| βββ camera.rs β stub β T-031+ | |
| βββ controllers.rs β stub β T-016+ | |
| βββ spatial.rs β stub β T-096+ | |
| βββ devices.rs β stub β T-013 | |
| βββ app_state.rs β stub β T-048 | |
| src/ipc/ | |
| βββ types.ts β ALL TypeScript mirror interfaces | |
| βββ commands.ts β typed invoke() wrappers | |
| βββ events.ts β typed listen() wrappers | |
| ``` | |
| --- | |
| ## IPC Error Type | |
| ```rust | |
| // kansas/src/ipc/error.rs | |
| use serde::Serialize; | |
| use thiserror::Error; | |
| #[derive(Debug, Error, Serialize)] | |
| #[serde(rename_all = "camelCase", tag = "kind", content = "message")] | |
| pub enum IpcError { | |
| #[error("Not yet implemented: {0}")] | |
| NotImplemented(String), | |
| #[error("Invalid parameter: {0}")] | |
| InvalidParam(String), | |
| #[error("Device not found: {0}")] | |
| DeviceNotFound(String), | |
| #[error("Model not loaded: {0}")] | |
| ModelNotLoaded(String), | |
| #[error("Audio error: {0}")] | |
| AudioError(String), | |
| #[error("Internal error: {0}")] | |
| Internal(String), | |
| } | |
| impl From<IpcError> for String { | |
| fn from(e: IpcError) -> Self { | |
| serde_json::to_string(&e).unwrap_or_else(|_| e.to_string()) | |
| } | |
| } | |
| ``` | |
| ```typescript | |
| // src/ipc/types.ts (add at top) | |
| export type IpcErrorKind = | |
| | 'notImplemented' | 'invalidParam' | 'deviceNotFound' | |
| | 'modelNotLoaded' | 'audioError' | 'internal'; | |
| export interface IpcError { kind: IpcErrorKind; message: string; } | |
| export function parseIpcError(raw: unknown): IpcError { | |
| if (typeof raw === 'string') { | |
| try { return JSON.parse(raw) as IpcError; } catch { /* fall through */ } | |
| } | |
| return { kind: 'internal', message: String(raw) }; | |
| } | |
| ``` | |
| --- | |
| ## Complete Types β Rust (`kansas/src/ipc/types.rs`) | |
| ```rust | |
| use serde::{Deserialize, Serialize}; | |
| // βββ Primitives βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// 3D vector; units depend on context (cm for head pos, meters for spatial audio) | |
| #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct Vec3 { pub x: f32, pub y: f32, pub z: f32 } | |
| /// Unit quaternion for orientation | |
| #[derive(Debug, Clone, Copy, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct Quaternion { pub x: f32, pub y: f32, pub z: f32, pub w: f32 } | |
| impl Default for Quaternion { | |
| fn default() -> Self { Self { x: 0.0, y: 0.0, z: 0.0, w: 1.0 } } | |
| } | |
| /// RGB color 0β255 | |
| #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct Rgb { pub r: u8, pub g: u8, pub b: u8 } | |
| // βββ Transport [T-003] ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// [T-003] Partial update β only set fields that should change | |
| #[derive(Debug, Clone, Default, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct TransportSetCmd { | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub state: Option<TransportState>, | |
| /// BPM 20.0β300.0 | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub bpm: Option<f32>, | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub time_sig_numerator: Option<u8>, | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub time_sig_denominator: Option<u8>, | |
| } | |
| /// [T-003] | |
| #[derive(Debug, Clone, Default, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase", tag = "kind")] | |
| pub enum TransportState { #[default] Stopped, Playing, Paused } | |
| /// [T-003] Full snapshot returned by transport_get | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct TransportStatus { | |
| pub state: TransportState, | |
| pub bpm: f32, | |
| pub time_sig_numerator: u8, | |
| pub time_sig_denominator: u8, | |
| pub bar: u32, | |
| pub beat: u8, // 1-indexed, 1..=time_sig_numerator | |
| pub tick: u32, // 0..=95 (96 PPQN) | |
| pub elapsed_ms: u64, | |
| } | |
| /// [T-003] Emitted every tick by transport thread | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct TransportTickEvent { | |
| pub bar: u32, | |
| pub beat: u8, | |
| pub tick: u32, | |
| pub bpm: f32, | |
| pub elapsed_ms: u64, | |
| pub is_downbeat: bool, | |
| } | |
| // βββ Parameter Bus [T-024] ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// [T-024] Set any named parameter. value always 0.0β1.0 normalized. | |
| /// param_id dot-notation: "perfrnn.lead.temperature", "magenta_rt.style_weight.0" | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct ParamSetCmd { | |
| pub param_id: String, | |
| pub value: f32, | |
| pub source: ParamSource, | |
| } | |
| /// [T-024] | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase", tag = "kind")] | |
| pub enum ParamSource { | |
| Ui, | |
| Controller { id: ControllerId }, | |
| Gesture, | |
| HeadTracking, | |
| Internal, | |
| } | |
| /// [T-024] Broadcast when any parameter changes | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct ParamChangedEvent { | |
| pub param_id: String, | |
| pub value: f32, | |
| pub source: ParamSource, | |
| pub timestamp_ms: u64, | |
| } | |
| // βββ Model Inference [T-046+] βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// [T-046] | |
| #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase", tag = "kind")] | |
| pub enum ModelId { | |
| MusicVaeEncoder, MusicVaeDecoder, | |
| PerfRnnStep, | |
| SpectroStreamEncode, SpectroStreamDecode, | |
| MusicCocaText, MusicCocaAudio, | |
| MagnetaRtGenerate, | |
| DdspEncode, DdspDecode, | |
| GemmaInference, | |
| } | |
| /// [T-046] input is model-specific JSON; handler validates + deserializes | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct ModelInferCmd { | |
| pub model: ModelId, | |
| pub input: serde_json::Value, | |
| } | |
| /// [T-046] | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct ModelInferResult { | |
| pub model: ModelId, | |
| pub output: serde_json::Value, | |
| pub latency_ms: f32, | |
| } | |
| /// [T-127] Hot-swap ONNX file without restart | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct ModelReloadCmd { | |
| pub model: ModelId, | |
| pub path: String, | |
| } | |
| /// [T-046] Emitted after inference completes (Rerun logging hook) | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct ModelInferenceCompleteEvent { | |
| pub model: ModelId, | |
| pub latency_ms: f32, | |
| pub timestamp_ms: u64, | |
| } | |
| // βββ Devices [T-013] ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// [T-013] | |
| #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase", tag = "kind")] | |
| pub enum DeviceKind { AudioInput, AudioOutput, Camera } | |
| /// [T-013] | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct DeviceInfo { | |
| pub id: u32, | |
| pub name: String, | |
| pub kind: DeviceKind, | |
| pub is_default: bool, | |
| } | |
| /// [T-013] | |
| #[derive(Debug, Clone, Default, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct DeviceListResult { pub devices: Vec<DeviceInfo> } | |
| // βββ Camera [T-031βT-034] βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// [T-034] | |
| #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase", tag = "kind")] | |
| pub enum CameraRole { | |
| Pose, // Primary: 30 FPS, MediaPipe + Gemma-3N | |
| Head, // Secondary: 120 FPS stereo, head tracking only | |
| } | |
| /// [T-034] | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct CameraAssignCmd { pub role: CameraRole, pub device_id: u32 } | |
| /// [T-031] | |
| #[derive(Debug, Clone, Default, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct CameraAssignment { | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub pose_device_id: Option<u32>, | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub head_device_id: Option<u32>, | |
| } | |
| // βββ Skeleton Pose [T-035βT-038] βββββββββββββββββββββββββββββββββββββββββββββ | |
| /// [T-038] Single MediaPipe BlazePose landmark (full topology: developers.google.com/mediapipe/solutions/vision/pose_landmarker) | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct PoseLandmark { | |
| pub x: f32, // normalized 0.0 (left) β 1.0 (right) | |
| pub y: f32, // normalized 0.0 (top) β 1.0 (bottom) | |
| pub z: f32, // depth relative to hips, normalized | |
| pub visibility: f32, // confidence 0.0β1.0 | |
| } | |
| /// [T-038] 33-landmark skeleton; landmarks[0]=nose, [11]=L shoulder, [16]=R wrist | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct SkeletonPoseEvent { | |
| pub landmarks: Vec<PoseLandmark>, // always 33 elements | |
| pub timestamp_ms: u64, | |
| pub detected: bool, | |
| } | |
| // βββ Head Tracking [T-039βT-041] βββββββββββββββββββββββββββββββββββββββββββββ | |
| /// [T-041] 6-DOF head pose from secondary 120 FPS stereo camera, broadcast at 120 Hz | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct HeadPoseEvent { | |
| pub position: Vec3, // cm from camera origin | |
| pub orientation: Quaternion, | |
| pub yaw_deg: f32, | |
| pub pitch_deg: f32, | |
| pub roll_deg: f32, | |
| pub timestamp_ms: u64, | |
| pub confidence: f32, // 0.0β1.0; below 0.5 treat as unreliable | |
| } | |
| // βββ Controllers [T-016βT-029] ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// [T-016] | |
| #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase", tag = "kind")] | |
| pub enum ControllerId { DualSense, JoyConLeft, JoyConRight } | |
| /// [T-022] State snapshot broadcast at 60 Hz per connected controller | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct ControllerStateEvent { | |
| pub controller: ControllerId, | |
| pub axes: Vec<f32>, // β1.0β1.0, layout controller-specific (T-017, T-018) | |
| pub buttons: Vec<bool>, // layout controller-specific | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub gyro: Option<Vec3>, // rad/s | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub accel: Option<Vec3>, // m/sΒ² | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub left_trigger: Option<f32>, // DualSense only, 0.0β1.0 | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub right_trigger: Option<f32>, // DualSense only, 0.0β1.0 | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub touchpad: Option<[f32; 2]>, // DualSense only, normalized | |
| pub timestamp_ms: u64, | |
| } | |
| /// [T-019βT-021] Send haptic or LED command to a controller | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct HapticCmd { pub controller: ControllerId, pub kind: HapticKind } | |
| /// [T-019βT-021] | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase", tag = "type", content = "data")] | |
| pub enum HapticKind { | |
| BeatPulse { intensity: f32 }, | |
| NoteOnset { note: u8, velocity: u8 }, | |
| ChunkBoundary, | |
| MoodChange, | |
| TriggerResistance { trigger: TriggerSide, force: f32, start_pos: f32 }, | |
| LedColor { r: u8, g: u8, b: u8 }, | |
| RumbleLf { intensity: f32, duration_ms: u32 }, | |
| } | |
| /// [T-019] | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase", tag = "kind")] | |
| pub enum TriggerSide { Left, Right } | |
| /// [T-016] Emitted on connect/disconnect | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct ControllerConnectionEvent { | |
| pub controller: ControllerId, | |
| pub connected: bool, | |
| pub timestamp_ms: u64, | |
| } | |
| // βββ Spatial Audio [T-096βT-102] βββββββββββββββββββββββββββββββββββββββββββββ | |
| /// [T-099] Position a named audio source for Steam Audio. | |
| /// source_id: "perfrnn.lead" | "perfrnn.pad" | "perfrnn.bass" | | |
| /// "magenta_rt" | "ddsp" | "pitch_class.{0..11}" | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct SourceSetPositionCmd { | |
| pub source_id: String, | |
| pub position: Vec3, // meters, right-hand, origin at listener default | |
| pub gain: f32, // 0.0β1.0 | |
| } | |
| /// [T-101] Initialize/update Steam Audio room model | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct RoomModelCmd { | |
| pub width: f32, | |
| pub height: f32, | |
| pub depth: f32, | |
| pub absorption: f32, | |
| } | |
| /// [T-133] Load SOFA HRTF personalization file | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct HrtfLoadCmd { pub path: String } | |
| // βββ Gemma-3N / Mood [T-049βT-052] βββββββββββββββββββββββββββββββββββββββββββ | |
| /// [T-052] Output of Gemma-3N visual analysis, broadcast at 0.5 Hz | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct MoodState { | |
| pub mood: String, // e.g. "mysterious" | |
| pub energy: f32, // 0.0β1.0 | |
| pub texture: String, // e.g. "dense" | |
| pub timestamp_ms: u64, | |
| } | |
| // βββ Magenta RT Style [T-083βT-086] ββββββββββββββββββββββββββββββββββββββββββ | |
| /// [T-084] Macro knobs 1β6 β MusicCoCa embedding blend weights. 0.0β1.0 each. | |
| /// Need not sum to 1.0; backend normalizes before applying. | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct StyleBlendSetCmd { pub weights: [f32; 6] } | |
| /// [T-088] Emitted after each 2-second Magenta RT generation chunk | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct GenerationChunkEvent { | |
| pub source: GenerationSource, | |
| pub duration_ms: u32, | |
| pub timestamp_ms: u64, | |
| } | |
| /// [T-088] | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase", tag = "kind", content = "data")] | |
| pub enum GenerationSource { | |
| MagnetaRt, | |
| PerfRnn { channel: RnnChannel }, | |
| Ddsp, | |
| } | |
| /// [T-070] | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase", tag = "kind")] | |
| pub enum RnnChannel { Lead, Pad, Bass } | |
| // βββ Application State [T-048+] βββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// [T-048] Full snapshot returned by get_app_state | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct AppState { | |
| pub transport: TransportStatus, | |
| pub webgpu_active: bool, | |
| pub controllers_connected: Vec<ControllerId>, | |
| pub camera_assignment: CameraAssignment, | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub current_mood: Option<MoodState>, | |
| pub style_weights: [f32; 6], | |
| } | |
| ``` | |
| --- | |
| ## Stub Command Pattern | |
| Every unimplemented handler follows this exactly: | |
| ```rust | |
| // kansas/src/commands/transport.rs | |
| use crate::ipc::{error::IpcError, types::*}; | |
| /// [T-003] Set transport state and/or BPM | |
| #[tauri::command] | |
| pub fn transport_set(cmd: TransportSetCmd) -> Result<TransportStatus, IpcError> { | |
| Err(IpcError::NotImplemented("T-003".into())) | |
| } | |
| /// [T-003] Get current transport status | |
| #[tauri::command] | |
| pub fn transport_get() -> Result<TransportStatus, IpcError> { | |
| Err(IpcError::NotImplemented("T-003".into())) | |
| } | |
| ``` | |
| Repeat this pattern for every command below. The `T-NNN` string in `NotImplemented` must match the task that implements it. | |
| --- | |
| ## Commands Catalog | |
| Register ALL in `tauri::generate_handler![]` in `kansas/src/main.rs`. | |
| | Command | Input | Return | Implements | | |
| |---------|-------|--------|-----------| | |
| | `ping` | β | `&'static str` | T-001 β | | |
| | `transport_set` | `TransportSetCmd` | `TransportStatus` | T-003 | | |
| | `transport_get` | β | `TransportStatus` | T-003 | | |
| | `model_infer` | `ModelInferCmd` | `ModelInferResult` | T-046 | | |
| | `model_reload` | `ModelReloadCmd` | `()` | T-127 | | |
| | `camera_assign` | `CameraAssignCmd` | `CameraAssignment` | T-034 | | |
| | `param_set` | `ParamSetCmd` | `()` | T-024 | | |
| | `haptic_send` | `HapticCmd` | `()` | T-019 | | |
| | `source_set_position` | `SourceSetPositionCmd` | `()` | T-099 | | |
| | `room_model_set` | `RoomModelCmd` | `()` | T-101 | | |
| | `hrtf_load` | `HrtfLoadCmd` | `()` | T-133 | | |
| | `style_blend_set` | `StyleBlendSetCmd` | `()` | T-084 | | |
| | `device_list` | β | `DeviceListResult` | T-013 | | |
| | `get_app_state` | β | `AppState` | T-048 | | |
| --- | |
| ## Events Catalog | |
| Fire-and-forget from Rust backend via `app_handle.emit(name, payload)`. | |
| | Event | Payload | Emitted by | | |
| |-------|---------|-----------| | |
| | `transport_tick` | `TransportTickEvent` | T-003 | | |
| | `param_changed` | `ParamChangedEvent` | T-024 | | |
| | `skeleton_pose` | `SkeletonPoseEvent` | T-038 | | |
| | `head_pose` | `HeadPoseEvent` | T-041 | | |
| | `controller_state` | `ControllerStateEvent` | T-022 | | |
| | `controller_connection` | `ControllerConnectionEvent` | T-016 | | |
| | `mood_changed` | `MoodState` | T-052 | | |
| | `generation_chunk` | `GenerationChunkEvent` | T-088 | | |
| | `model_inference_complete` | `ModelInferenceCompleteEvent` | T-046 | | |
| | `device_list_updated` | `DeviceListResult` | T-013 | | |
| --- | |
| ## TypeScript Mirror Types (`src/ipc/types.ts`) | |
| ```typescript | |
| // βββ Primitives βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export interface Vec3 { x: number; y: number; z: number } | |
| export interface Quaternion { x: number; y: number; z: number; w: number } | |
| export interface Rgb { r: number; g: number; b: number } | |
| export type IpcErrorKind = 'notImplemented'|'invalidParam'|'deviceNotFound'|'modelNotLoaded'|'audioError'|'internal' | |
| export interface IpcError { kind: IpcErrorKind; message: string } | |
| export function parseIpcError(raw: unknown): IpcError { | |
| if (typeof raw === 'string') { try { return JSON.parse(raw) as IpcError } catch {} } | |
| return { kind: 'internal', message: String(raw) } | |
| } | |
| // βββ Transport ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export type TransportStateKind = 'stopped' | 'playing' | 'paused' | |
| export interface TransportState { kind: TransportStateKind } | |
| export interface TransportSetCmd { | |
| state?: TransportState; bpm?: number | |
| timeSigNumerator?: number; timeSigDenominator?: number | |
| } | |
| export interface TransportStatus { | |
| state: TransportState; bpm: number | |
| timeSigNumerator: number; timeSigDenominator: number | |
| bar: number; beat: number; tick: number; elapsedMs: number | |
| } | |
| export interface TransportTickEvent { | |
| bar: number; beat: number; tick: number | |
| bpm: number; elapsedMs: number; isDownbeat: boolean | |
| } | |
| // βββ Parameter Bus ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export type ParamSourceKind = 'ui' | 'controller' | 'gesture' | 'headTracking' | 'internal' | |
| export interface ParamSource { kind: ParamSourceKind; id?: ControllerId } | |
| export interface ParamSetCmd { paramId: string; value: number; source: ParamSource } | |
| export interface ParamChangedEvent { paramId: string; value: number; source: ParamSource; timestampMs: number } | |
| // βββ Models βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export type ModelIdKind = | |
| | 'musicVaeEncoder' | 'musicVaeDecoder' | 'perfRnnStep' | |
| | 'spectroStreamEncode' | 'spectroStreamDecode' | |
| | 'musicCocaText' | 'musicCocaAudio' | 'magnetaRtGenerate' | |
| | 'ddspEncode' | 'ddspDecode' | 'gemmaInference' | |
| export interface ModelId { kind: ModelIdKind } | |
| export interface ModelInferCmd { model: ModelId; input: unknown } | |
| export interface ModelInferResult { model: ModelId; output: unknown; latencyMs: number } | |
| export interface ModelReloadCmd { model: ModelId; path: string } | |
| export interface ModelInferenceCompleteEvent { model: ModelId; latencyMs: number; timestampMs: number } | |
| // βββ Devices ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export type DeviceKind = 'audioInput' | 'audioOutput' | 'camera' | |
| export interface DeviceInfo { id: number; name: string; kind: DeviceKind; isDefault: boolean } | |
| export interface DeviceListResult { devices: DeviceInfo[] } | |
| // βββ Camera βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export type CameraRoleKind = 'pose' | 'head' | |
| export interface CameraRole { kind: CameraRoleKind } | |
| export interface CameraAssignCmd { role: CameraRole; deviceId: number } | |
| export interface CameraAssignment { poseDeviceId?: number; headDeviceId?: number } | |
| // βββ Skeleton Pose ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export interface PoseLandmark { x: number; y: number; z: number; visibility: number } | |
| export interface SkeletonPoseEvent { | |
| landmarks: PoseLandmark[] // always 33 | |
| timestampMs: number; detected: boolean | |
| } | |
| // βββ Head Tracking ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export interface HeadPoseEvent { | |
| position: Vec3; orientation: Quaternion | |
| yawDeg: number; pitchDeg: number; rollDeg: number | |
| timestampMs: number; confidence: number | |
| } | |
| // βββ Controllers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export type ControllerIdKind = 'dualSense' | 'joyConLeft' | 'joyConRight' | |
| export interface ControllerId { kind: ControllerIdKind } | |
| export interface ControllerStateEvent { | |
| controller: ControllerId; axes: number[]; buttons: boolean[] | |
| gyro?: Vec3; accel?: Vec3 | |
| leftTrigger?: number; rightTrigger?: number | |
| touchpad?: [number, number]; timestampMs: number | |
| } | |
| export type TriggerSideKind = 'left' | 'right' | |
| export type HapticKindType = | |
| | { type: 'beatPulse'; data: { intensity: number } } | |
| | { type: 'noteOnset'; data: { note: number; velocity: number } } | |
| | { type: 'chunkBoundary'; data: Record<string, never> } | |
| | { type: 'moodChange'; data: Record<string, never> } | |
| | { type: 'triggerResistance'; data: { trigger: { kind: TriggerSideKind }; force: number; startPos: number } } | |
| | { type: 'ledColor'; data: { r: number; g: number; b: number } } | |
| | { type: 'rumbleLf'; data: { intensity: number; durationMs: number } } | |
| export interface HapticCmd { controller: ControllerId; kind: HapticKindType } | |
| export interface ControllerConnectionEvent { controller: ControllerId; connected: boolean; timestampMs: number } | |
| // βββ Spatial Audio ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export interface SourceSetPositionCmd { sourceId: string; position: Vec3; gain: number } | |
| export interface RoomModelCmd { width: number; height: number; depth: number; absorption: number } | |
| export interface HrtfLoadCmd { path: string } | |
| // βββ Mood βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export interface MoodState { mood: string; energy: number; texture: string; timestampMs: number } | |
| // βββ Magenta RT βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export interface StyleBlendSetCmd { weights: [number,number,number,number,number,number] } | |
| export type RnnChannelKind = 'lead' | 'pad' | 'bass' | |
| export interface RnnChannel { kind: RnnChannelKind } | |
| export type GenerationSource = | |
| | { kind: 'magnetaRt' } | |
| | { kind: 'perfRnn'; data: { channel: RnnChannel } } | |
| | { kind: 'ddsp' } | |
| export interface GenerationChunkEvent { source: GenerationSource; durationMs: number; timestampMs: number } | |
| // βββ App State ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export interface AppState { | |
| transport: TransportStatus; webgpuActive: boolean | |
| controllersConnected: ControllerId[]; cameraAssignment: CameraAssignment | |
| currentMood?: MoodState | |
| styleWeights: [number,number,number,number,number,number] | |
| } | |
| ``` | |
| --- | |
| ## TypeScript Wrappers | |
| ### `src/ipc/commands.ts` | |
| ```typescript | |
| import { invoke } from '@tauri-apps/api/core' | |
| import type { | |
| TransportSetCmd, TransportStatus, | |
| ModelInferCmd, ModelInferResult, ModelReloadCmd, | |
| CameraAssignCmd, CameraAssignment, | |
| ParamSetCmd, HapticCmd, | |
| SourceSetPositionCmd, RoomModelCmd, HrtfLoadCmd, | |
| StyleBlendSetCmd, DeviceListResult, AppState, | |
| } from './types' | |
| export const ping = (): Promise<string> => invoke('ping') | |
| export const transportSet = (cmd: TransportSetCmd): Promise<TransportStatus> => invoke('transport_set', { cmd }) | |
| export const transportGet = (): Promise<TransportStatus> => invoke('transport_get') | |
| export const modelInfer = (cmd: ModelInferCmd): Promise<ModelInferResult>=> invoke('model_infer', { cmd }) | |
| export const modelReload = (cmd: ModelReloadCmd): Promise<void> => invoke('model_reload', { cmd }) | |
| export const cameraAssign = (cmd: CameraAssignCmd): Promise<CameraAssignment>=> invoke('camera_assign', { cmd }) | |
| export const paramSet = (cmd: ParamSetCmd): Promise<void> => invoke('param_set', { cmd }) | |
| export const hapticSend = (cmd: HapticCmd): Promise<void> => invoke('haptic_send', { cmd }) | |
| export const sourceSetPosition = (cmd: SourceSetPositionCmd): Promise<void> => invoke('source_set_position', { cmd }) | |
| export const roomModelSet = (cmd: RoomModelCmd): Promise<void> => invoke('room_model_set', { cmd }) | |
| export const hrtfLoad = (cmd: HrtfLoadCmd): Promise<void> => invoke('hrtf_load', { cmd }) | |
| export const styleBlendSet = (cmd: StyleBlendSetCmd): Promise<void> => invoke('style_blend_set', { cmd }) | |
| export const deviceList = (): Promise<DeviceListResult>=> invoke('device_list') | |
| export const getAppState = (): Promise<AppState> => invoke('get_app_state') | |
| ``` | |
| ### `src/ipc/events.ts` | |
| ```typescript | |
| import { listen, type UnlistenFn } from '@tauri-apps/api/event' | |
| import type { | |
| TransportTickEvent, ParamChangedEvent, | |
| SkeletonPoseEvent, HeadPoseEvent, | |
| ControllerStateEvent, ControllerConnectionEvent, | |
| MoodState, GenerationChunkEvent, | |
| ModelInferenceCompleteEvent, DeviceListResult, | |
| } from './types' | |
| // IMPORTANT: Every returned UnlistenFn MUST be called on component unmount. | |
| // Dangling listeners accumulate and will OOM the WebView over long sessions. | |
| const on = <T>(event: string, cb: (p: T) => void): Promise<UnlistenFn> => | |
| listen<T>(event, (e) => cb(e.payload)) | |
| export const onTransportTick = (cb: (e: TransportTickEvent) => void) => on<TransportTickEvent> ('transport_tick', cb) | |
| export const onParamChanged = (cb: (e: ParamChangedEvent) => void) => on<ParamChangedEvent> ('param_changed', cb) | |
| export const onSkeletonPose = (cb: (e: SkeletonPoseEvent) => void) => on<SkeletonPoseEvent> ('skeleton_pose', cb) | |
| export const onHeadPose = (cb: (e: HeadPoseEvent) => void) => on<HeadPoseEvent> ('head_pose', cb) | |
| export const onControllerState = (cb: (e: ControllerStateEvent) => void) => on<ControllerStateEvent> ('controller_state', cb) | |
| export const onControllerConnection = (cb: (e: ControllerConnectionEvent) => void) => on<ControllerConnectionEvent> ('controller_connection', cb) | |
| export const onMoodChanged = (cb: (e: MoodState) => void) => on<MoodState> ('mood_changed', cb) | |
| export const onGenerationChunk = (cb: (e: GenerationChunkEvent) => void) => on<GenerationChunkEvent> ('generation_chunk', cb) | |
| export const onModelInferenceComplete = (cb: (e: ModelInferenceCompleteEvent) => void) => on<ModelInferenceCompleteEvent>('model_inference_complete', cb) | |
| export const onDeviceListUpdated = (cb: (e: DeviceListResult) => void) => on<DeviceListResult> ('device_list_updated', cb) | |
| ``` | |
| --- | |
| ## Capability Configuration (`kansas/capabilities/default.json`) | |
| ```json | |
| { | |
| "$schema": "../gen/schemas/desktop-schema.json", | |
| "identifier": "default", | |
| "description": "Default capabilities for Synesthesia", | |
| "windows": ["main"], | |
| "permissions": [ | |
| "core:default", | |
| "core:window:allow-start-dragging", | |
| "core:window:allow-close", | |
| "core:window:allow-minimize", | |
| "core:window:allow-toggle-maximize", | |
| "synesthesia:allow-ping", | |
| "synesthesia:allow-transport-set", | |
| "synesthesia:allow-transport-get", | |
| "synesthesia:allow-model-infer", | |
| "synesthesia:allow-model-reload", | |
| "synesthesia:allow-camera-assign", | |
| "synesthesia:allow-param-set", | |
| "synesthesia:allow-haptic-send", | |
| "synesthesia:allow-source-set-position", | |
| "synesthesia:allow-room-model-set", | |
| "synesthesia:allow-hrtf-load", | |
| "synesthesia:allow-style-blend-set", | |
| "synesthesia:allow-device-list", | |
| "synesthesia:allow-get-app-state" | |
| ] | |
| } | |
| ``` | |
| --- | |
| ## Testing | |
| ### Automated | |
| - [ ] `cargo check --workspace` β zero errors | |
| - [ ] `cargo clippy --workspace -- -D warnings` β zero warnings | |
| - [ ] `pnpm typecheck` β zero errors | |
| ### Manual | |
| 1. `cargo tauri dev` β DevTools console: | |
| ```js | |
| await window.__TAURI__.core.invoke('transport_set', { cmd: { bpm: 140.0 } }) | |
| // Expected rejection: { "kind": "notImplemented", "message": "T-003" } | |
| ``` | |
| 2. Call every command from the catalog β each must reject with structured `IpcError`, not crash. | |
| 3. `await window.__TAURI__.core.invoke('ping')` β `"pong"` β | |
| --- | |
| ## Do Not | |
| - Implement any command logic β `NotImplemented` only except `ping` | |
| - Add `AppHandle` state β T-003+ own Tauri managed state | |
| - Use `serde_json::Value` for any input except `ModelInferCmd` | |
| - Use `#[allow(dead_code)]` β fix the registration if warnings appear | |
| - Use TypeScript `class` β `interface` and `type` only | |
| - Use `as unknown as T` casts in event listeners | |
| --- | |
| ## Iteration Protocol | |
| PR required before closing. Human reviews before merge. | |
| ```markdown | |
| ## Review Blocker β [date] | |
| **Type affected:** [TypeName] | |
| **Problem:** [mismatch or design flaw] | |
| **Downstream impact:** [which T-NNN tasks already use this] | |
| **Proposed fix:** [recommendation] | |
| ``` | |
| --- | |
| ## GitHub CLI | |
| ```bash | |
| gh issue create \ | |
| --title "T-002: IPC command registry β typed serde structs & TypeScript mirror types" \ | |
| --label "type:task,stack:rust,stack:typescript,agent:review-required,priority:critical,status:ready,day:1" \ | |
| --body-file T-002.md | |
| ``` | |
| --- | |
| **Parent:** `#GENESIS` | |
| **Blocks:** T-003βT-005, T-010, T-013βT-014, T-016βT-029, T-031βT-041, T-046βT-052, T-062βT-090, T-096βT-102, T-121βT-135 | |
| **Blocked By:** T-001 | |
| **Version:** v0.1 Β· **Iteration:** iter-1 Β· **Effort:** M (1β2 days) | |