1use 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
15pub const XR_MIC_CHUNK_NAMESPACE: &str = "xr";
17
18pub const XR_MIC_CHUNK_KIND: &str = "mic-chunk";
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum AsrSitePlacement {
24 Local,
26 PhoneRelay,
28 Fabric,
30}
31
32impl AsrSitePlacement {
33 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 pub fn symbol(self) -> Symbol {
44 Symbol::qualified("asr/site-placement", self.as_str())
45 }
46}
47
48pub struct AsrSite<'a> {
50 placement: AsrSitePlacement,
51 fabric: &'a dyn EvalFabric,
52}
53
54impl<'a> AsrSite<'a> {
55 pub fn new(placement: AsrSitePlacement, fabric: &'a dyn EvalFabric) -> Self {
57 Self { placement, fabric }
58 }
59
60 pub fn local(fabric: &'a dyn EvalFabric) -> Self {
62 Self::new(AsrSitePlacement::Local, fabric)
63 }
64
65 pub fn phone_relay(fabric: &'a dyn EvalFabric) -> Self {
67 Self::new(AsrSitePlacement::PhoneRelay, fabric)
68 }
69
70 pub fn fabric(fabric: &'a dyn EvalFabric) -> Self {
72 Self::new(AsrSitePlacement::Fabric, fabric)
73 }
74
75 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#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct XrMicChunkRef {
103 pub ref_id: Symbol,
105 pub seq: u64,
107 pub sample_rate_hz: u32,
109 pub channels: u8,
111 pub byte_len: u64,
113}
114
115impl XrMicChunkRef {
116 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 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 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
188pub 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}