Skip to main content

sim_lib_view_spatial/
voice_site.rs

1//! Consent-gated glasses voice capture through a placed ASR site.
2//!
3//! The Halo edge records microphone audio by reference as `xr/mic-chunk` data.
4//! Recognition is an eval-fabric placement: local, phone-relay, or fabric. The
5//! site returns the already-formed Intent; this module only enforces consent,
6//! calls the site, and validates the Intent shape.
7
8use sim_kernel::{Consistency, Cx, Error, EvalFabric, EvalMode, EvalRequest, Expr, Result, Symbol};
9use sim_lib_intent::validate_intent;
10use sim_lib_view_device::{ConsentReceipt, EdgeId, require_with_consent};
11use sim_value::{access, build};
12
13use crate::{CAP_GLASSES_MIC, glasses_mic_capability};
14
15/// Namespace for glasses microphone chunk references.
16pub const XR_MIC_CHUNK_NAMESPACE: &str = "xr";
17
18/// Kind tag for glasses microphone chunk references.
19pub const XR_MIC_CHUNK_KIND: &str = "mic-chunk";
20
21/// Where a glasses ASR site is placed.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum AsrSitePlacement {
24    /// ASR runs in the local host process.
25    Local,
26    /// ASR is relayed through the paired phone.
27    PhoneRelay,
28    /// ASR is placed on a fabric site.
29    Fabric,
30}
31
32impl AsrSitePlacement {
33    /// Stable placement label.
34    pub fn as_str(self) -> &'static str {
35        match self {
36            Self::Local => "local",
37            Self::PhoneRelay => "phone-relay",
38            Self::Fabric => "fabric",
39        }
40    }
41
42    /// Expression symbol naming this placement.
43    pub fn symbol(self) -> Symbol {
44        Symbol::qualified("asr/site-placement", self.as_str())
45    }
46}
47
48/// A placed glasses ASR site.
49pub struct AsrSite<'a> {
50    placement: AsrSitePlacement,
51    fabric: &'a dyn EvalFabric,
52}
53
54impl<'a> AsrSite<'a> {
55    /// Creates a placed ASR site over an eval fabric.
56    pub fn new(placement: AsrSitePlacement, fabric: &'a dyn EvalFabric) -> Self {
57        Self { placement, fabric }
58    }
59
60    /// Creates a local ASR site.
61    pub fn local(fabric: &'a dyn EvalFabric) -> Self {
62        Self::new(AsrSitePlacement::Local, fabric)
63    }
64
65    /// Creates a phone-relay ASR site.
66    pub fn phone_relay(fabric: &'a dyn EvalFabric) -> Self {
67        Self::new(AsrSitePlacement::PhoneRelay, fabric)
68    }
69
70    /// Creates a fabric-placed ASR site.
71    pub fn fabric(fabric: &'a dyn EvalFabric) -> Self {
72        Self::new(AsrSitePlacement::Fabric, fabric)
73    }
74
75    /// Returns this site's placement.
76    pub fn placement(&self) -> AsrSitePlacement {
77        self.placement
78    }
79
80    fn realize(&self, cx: &mut Cx, chunk: &XrMicChunkRef) -> Result<Expr> {
81        let reply = self.fabric.realize(
82            cx,
83            EvalRequest {
84                expr: chunk.to_expr(),
85                result_shape: None,
86                required_capabilities: vec![glasses_mic_capability()],
87                deadline: None,
88                consistency: Consistency::LocalFirst,
89                mode: EvalMode::Eval,
90                answer_limit: None,
91                stream_buffer: None,
92                stream: false,
93                trace: false,
94            },
95        )?;
96        reply.value.object().as_expr(cx)
97    }
98}
99
100/// A by-reference glasses microphone chunk.
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct XrMicChunkRef {
103    /// Store key or stream key for the referenced audio chunk.
104    pub ref_id: Symbol,
105    /// Monotonic capture sequence.
106    pub seq: u64,
107    /// PCM sample rate.
108    pub sample_rate_hz: u32,
109    /// Number of PCM channels.
110    pub channels: u8,
111    /// Referenced audio byte length.
112    pub byte_len: u64,
113}
114
115impl XrMicChunkRef {
116    /// Builds a microphone chunk reference.
117    pub fn new(
118        ref_id: Symbol,
119        seq: u64,
120        sample_rate_hz: u32,
121        channels: u8,
122        byte_len: u64,
123    ) -> Result<Self> {
124        if sample_rate_hz == 0 || channels == 0 {
125            return Err(Error::HostError(
126                "xr mic chunk requires a nonzero PCM format".to_owned(),
127            ));
128        }
129        if byte_len == 0 {
130            return Err(Error::HostError(
131                "xr mic chunk requires referenced audio bytes".to_owned(),
132            ));
133        }
134        Ok(Self {
135            ref_id,
136            seq,
137            sample_rate_hz,
138            channels,
139            byte_len,
140        })
141    }
142
143    /// Encodes this reference as expression data.
144    pub fn to_expr(&self) -> Expr {
145        build::map(vec![
146            (
147                "kind",
148                build::qsym(XR_MIC_CHUNK_NAMESPACE, XR_MIC_CHUNK_KIND),
149            ),
150            ("ref", Expr::Symbol(self.ref_id.clone())),
151            ("seq", build::uint(self.seq)),
152            (
153                "sample-rate-hz",
154                build::uint(u64::from(self.sample_rate_hz)),
155            ),
156            ("channels", build::uint(u64::from(self.channels))),
157            ("bytes", build::uint(self.byte_len)),
158        ])
159    }
160
161    /// Decodes a microphone chunk reference, rejecting embedded audio or text.
162    pub fn from_expr(expr: &Expr) -> Result<Self> {
163        ensure_kind(expr)?;
164        ensure_no_extra(
165            expr,
166            &["kind", "ref", "seq", "sample-rate-hz", "channels", "bytes"],
167            "xr mic chunk",
168        )?;
169        let ref_id = match access::required(expr, "ref", "xr mic chunk")? {
170            Expr::Symbol(symbol) => symbol.clone(),
171            _ => {
172                return Err(Error::TypeMismatch {
173                    expected: "audio chunk reference symbol",
174                    found: "non-symbol",
175                });
176            }
177        };
178        Self::new(
179            ref_id,
180            uint_field(expr, "seq", "xr mic chunk")?,
181            u32_field(expr, "sample-rate-hz", "xr mic chunk")?,
182            u8_field(expr, "channels", "xr mic chunk")?,
183            uint_field(expr, "bytes", "xr mic chunk")?,
184        )
185    }
186}
187
188/// Produces a voice Intent through a placed ASR site.
189///
190/// This function first enforces `glasses/mic` through kernel capability state
191/// and the session-bound visible consent receipt. It then realizes the placed
192/// site with an `xr/mic-chunk` reference. The site output must already validate
193/// as a standard `intent/*` value.
194pub fn voice_intent_via_site(
195    cx: &mut Cx,
196    chunk_ref: &XrMicChunkRef,
197    site: Option<&AsrSite<'_>>,
198    receipt: &ConsentReceipt,
199    session: &EdgeId,
200) -> Result<Expr> {
201    require_with_consent(cx, CAP_GLASSES_MIC, receipt, session)?;
202    let site = site.ok_or_else(|| {
203        Error::HostError("glasses voice unavailable: no ASR site placed".to_owned())
204    })?;
205    let intent = site.realize(cx, chunk_ref)?;
206    validate_intent(&intent)
207        .map_err(|err| Error::HostError(format!("ASR site output is not a voice Intent: {err}")))?;
208    Ok(intent)
209}
210
211fn ensure_kind(expr: &Expr) -> Result<()> {
212    match access::field_sym(expr, "kind") {
213        Some(symbol)
214            if symbol.namespace.as_deref() == Some(XR_MIC_CHUNK_NAMESPACE)
215                && symbol.name.as_ref() == XR_MIC_CHUNK_KIND =>
216        {
217            Ok(())
218        }
219        _ => Err(Error::HostError("expected xr mic chunk".to_owned())),
220    }
221}
222
223fn ensure_no_extra(expr: &Expr, allowed: &[&str], context: &str) -> Result<()> {
224    let Expr::Map(entries) = expr else {
225        return Err(Error::HostError(format!("expected {context}")));
226    };
227    for (key, _) in entries {
228        let Expr::Symbol(symbol) = key else {
229            return Err(Error::HostError(format!(
230                "{context} has a non-symbol field"
231            )));
232        };
233        if symbol.namespace.is_some() || !allowed.contains(&symbol.name.as_ref()) {
234            return Err(Error::HostError(format!(
235                "{context} has unexpected field {}",
236                symbol.as_qualified_str()
237            )));
238        }
239    }
240    Ok(())
241}
242
243fn uint_field(expr: &Expr, name: &str, context: &str) -> Result<u64> {
244    match access::required(expr, name, context)? {
245        Expr::Number(number) if number.domain.namespace.is_none() => number
246            .canonical
247            .parse()
248            .map_err(|_| Error::Eval(format!("{context} field {name} is not u64"))),
249        _ => Err(Error::Eval(format!("{context} field {name} is not u64"))),
250    }
251}
252
253fn u32_field(expr: &Expr, name: &str, context: &str) -> Result<u32> {
254    uint_field(expr, name, context).and_then(|value| {
255        u32::try_from(value).map_err(|_| Error::Eval(format!("{context} field {name} exceeds u32")))
256    })
257}
258
259fn u8_field(expr: &Expr, name: &str, context: &str) -> Result<u8> {
260    uint_field(expr, name, context).and_then(|value| {
261        u8::try_from(value).map_err(|_| Error::Eval(format!("{context} field {name} exceeds u8")))
262    })
263}