Skip to main content

sim_lib_web_bridge/
placement.rs

1//! Browser-local wasm placement descriptors and headless execution harness.
2
3use sim_kernel::{Error, Expr, Result, Symbol};
4use sim_lib_stream_core::{
5    ClockDomain, LatencyClass, PlacedFragment, StreamEdge, StreamEnvelope, StreamMedia,
6};
7
8use crate::transport::TransportKind;
9
10/// Stable site name for a browser-local wasm placement.
11pub fn browser_wasm_site_symbol() -> Symbol {
12    Symbol::qualified("stream/site", "browser-wasm")
13}
14
15/// Stable entrypoint name for the browser wasm stream engine.
16pub fn browser_wasm_engine_entry_symbol() -> Symbol {
17    Symbol::qualified("stream/wasm-entry", "browser-wasm-engine")
18}
19
20/// Stable entrypoint name for the browser AudioWorklet bridge.
21pub fn browser_audio_worklet_entry_symbol() -> Symbol {
22    Symbol::qualified("stream/wasm-entry", "browser-audio-worklet")
23}
24
25/// Diagnostic emitted when a server-only node is offered to browser placement.
26pub fn browser_server_only_refusal_diagnostic() -> Symbol {
27    Symbol::qualified("stream/browser-diagnostic", "server-only-refused")
28}
29
30/// Lane a browser-local placement carries data on.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum BrowserBridgeLane {
33    /// UI-facing lane.
34    Ui,
35    /// Preview lane.
36    Preview,
37    /// Trace lane.
38    Trace,
39}
40
41impl BrowserBridgeLane {
42    /// Returns the stable symbol naming this lane.
43    pub fn symbol(self) -> Symbol {
44        match self {
45            Self::Ui => Symbol::qualified("stream/browser-bridge", "ui"),
46            Self::Preview => Symbol::qualified("stream/browser-bridge", "preview"),
47            Self::Trace => Symbol::qualified("stream/browser-bridge", "trace"),
48        }
49    }
50}
51
52/// Wasm entrypoint symbols for a browser-local stream engine.
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct BrowserWasmEntryPoints {
55    engine: Symbol,
56    audio_worklet: Symbol,
57}
58
59impl BrowserWasmEntryPoints {
60    /// Returns the default browser entrypoints (engine and AudioWorklet).
61    pub fn browser_defaults() -> Self {
62        Self {
63            engine: browser_wasm_engine_entry_symbol(),
64            audio_worklet: browser_audio_worklet_entry_symbol(),
65        }
66    }
67
68    /// Returns the stream engine entrypoint symbol.
69    pub fn engine(&self) -> &Symbol {
70        &self.engine
71    }
72
73    /// Returns the AudioWorklet bridge entrypoint symbol.
74    pub fn audio_worklet(&self) -> &Symbol {
75        &self.audio_worklet
76    }
77}
78
79/// A browser-local wasm stream engine and its capabilities.
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct BrowserWasmEngine {
82    id: Symbol,
83    entry_points: BrowserWasmEntryPoints,
84    audio_worklet_capable: bool,
85}
86
87impl BrowserWasmEngine {
88    /// Builds a browser-local engine with default entrypoints.
89    pub fn browser_local(id: Symbol) -> Self {
90        Self {
91            id,
92            entry_points: BrowserWasmEntryPoints::browser_defaults(),
93            audio_worklet_capable: true,
94        }
95    }
96
97    /// Returns the engine id.
98    pub fn id(&self) -> &Symbol {
99        &self.id
100    }
101
102    /// Returns the engine entrypoint symbols.
103    pub fn entry_points(&self) -> &BrowserWasmEntryPoints {
104        &self.entry_points
105    }
106
107    /// Returns whether the engine can drive an AudioWorklet.
108    pub fn audio_worklet_capable(&self) -> bool {
109        self.audio_worklet_capable
110    }
111
112    /// Returns the transport kind used to reach this engine.
113    pub fn transport_kind(&self) -> TransportKind {
114        TransportKind::Wasm
115    }
116
117    /// Returns whether this engine tunnels audio through the server.
118    pub fn uses_server_audio_tunnel(&self) -> bool {
119        false
120    }
121}
122
123/// A request to place a stream fragment on a browser-local engine.
124#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct BrowserPlacementRequest {
126    fragment: PlacedFragment,
127    engine: BrowserWasmEngine,
128    server_only: bool,
129}
130
131impl BrowserPlacementRequest {
132    /// Builds a placement request for a fragment and target engine.
133    pub fn new(fragment: PlacedFragment, engine: BrowserWasmEngine) -> Self {
134        Self {
135            fragment,
136            engine,
137            server_only: false,
138        }
139    }
140
141    /// Marks the fragment as server-only, returning the updated request.
142    pub fn with_server_only(mut self, server_only: bool) -> Self {
143        self.server_only = server_only;
144        self
145    }
146
147    /// Runs the placement headlessly, returning a report or a refusal error.
148    ///
149    /// # Errors
150    ///
151    /// Returns an error when the fragment is server-only and therefore cannot
152    /// run in browser-wasm placement.
153    pub fn run_headless(&self) -> Result<BrowserPlacementReport> {
154        if self.server_only {
155            let diagnostic = browser_server_only_refusal_diagnostic();
156            return Err(Error::Eval(format!(
157                "{}: server-only nodes cannot run in browser-wasm placement",
158                diagnostic.as_qualified_str()
159            )));
160        }
161
162        Ok(BrowserPlacementReport {
163            fragment_id: self.fragment.id().clone(),
164            site: browser_wasm_site_symbol(),
165            engine: self.engine.clone(),
166            lanes: carried_lanes(&self.fragment),
167            output_envelopes: self.fragment.output_envelopes(),
168            diagnostics: Vec::new(),
169        })
170    }
171}
172
173/// The result of running a browser-local placement.
174#[derive(Clone, Debug, PartialEq, Eq)]
175pub struct BrowserPlacementReport {
176    fragment_id: Symbol,
177    site: Symbol,
178    engine: BrowserWasmEngine,
179    lanes: Vec<BrowserBridgeLane>,
180    output_envelopes: Vec<StreamEnvelope>,
181    diagnostics: Vec<Symbol>,
182}
183
184impl BrowserPlacementReport {
185    /// Returns the placed fragment id.
186    pub fn fragment_id(&self) -> &Symbol {
187        &self.fragment_id
188    }
189
190    /// Returns the site the fragment was placed on.
191    pub fn site(&self) -> &Symbol {
192        &self.site
193    }
194
195    /// Returns the engine that ran the fragment.
196    pub fn engine(&self) -> &BrowserWasmEngine {
197        &self.engine
198    }
199
200    /// Returns the lanes the placement carries.
201    pub fn lanes(&self) -> &[BrowserBridgeLane] {
202        &self.lanes
203    }
204
205    /// Returns the output stream envelopes produced by the placement.
206    pub fn output_envelopes(&self) -> &[StreamEnvelope] {
207        &self.output_envelopes
208    }
209
210    /// Returns the diagnostics emitted during placement.
211    pub fn diagnostics(&self) -> &[Symbol] {
212        &self.diagnostics
213    }
214
215    /// Encodes the report as an `Expr` map.
216    pub fn to_expr(&self) -> Expr {
217        Expr::Map(vec![
218            (
219                Expr::Symbol(Symbol::new("fragment")),
220                Expr::Symbol(self.fragment_id.clone()),
221            ),
222            (
223                Expr::Symbol(Symbol::new("site")),
224                Expr::Symbol(self.site.clone()),
225            ),
226            (
227                Expr::Symbol(Symbol::new("transport")),
228                Expr::Symbol(Symbol::qualified("stream/transport", "wasm")),
229            ),
230            (
231                Expr::Symbol(Symbol::new("engine")),
232                Expr::Symbol(self.engine.id().clone()),
233            ),
234            (
235                Expr::Symbol(Symbol::new("entry-points")),
236                Expr::List(vec![
237                    Expr::Symbol(self.engine.entry_points().engine().clone()),
238                    Expr::Symbol(self.engine.entry_points().audio_worklet().clone()),
239                ]),
240            ),
241            (
242                Expr::Symbol(Symbol::new("lanes")),
243                Expr::List(
244                    self.lanes
245                        .iter()
246                        .map(|lane| Expr::Symbol(lane.symbol()))
247                        .collect(),
248                ),
249            ),
250            (
251                Expr::Symbol(Symbol::new("diagnostics")),
252                Expr::List(self.diagnostics.iter().cloned().map(Expr::Symbol).collect()),
253            ),
254        ])
255    }
256}
257
258fn carried_lanes(fragment: &PlacedFragment) -> Vec<BrowserBridgeLane> {
259    let mut lanes = Vec::new();
260    for lane in fragment
261        .input_edges()
262        .iter()
263        .chain(fragment.output_edges())
264        .filter_map(lane_for_edge)
265    {
266        if !lanes.contains(&lane) {
267            lanes.push(lane);
268        }
269    }
270    lanes
271}
272
273fn lane_for_edge(edge: &StreamEdge) -> Option<BrowserBridgeLane> {
274    let rate = edge.rate_contract();
275    if rate.clock_domain() == ClockDomain::TraceStep {
276        return Some(BrowserBridgeLane::Trace);
277    }
278    if edge.metadata().media() == StreamMedia::Pcm
279        && rate.latency_class() == LatencyClass::BufferedPreview
280    {
281        return Some(BrowserBridgeLane::Preview);
282    }
283    if rate.clock_domain() == ClockDomain::BrowserFrame
284        || edge.metadata().media() == StreamMedia::Data
285    {
286        return Some(BrowserBridgeLane::Ui);
287    }
288    None
289}