Skip to main content

ling_py/
lib.rs

1//! ling-py — PyO3 bindings for ling-audio, ling-physics, and WebSocket netplay.
2//!
3//! Python classes exposed:
4//!   AudioEngine    — 4D spatial audio synthesis (wraps ling_audio::AudioEngine)
5//!   HyperbolicWorld — Poincaré-ball hyperbolic sphere world (wraps ling_physics::hyperbolic)
6//!   NetClient      — async WebSocket client for netplay
7
8use pyo3::prelude::*;
9use std::collections::VecDeque;
10use std::sync::{Arc, Mutex};
11
12// ── AudioEngine ───────────────────────────────────────────────────────────────
13
14#[pyclass(name = "AudioEngine", unsendable)]
15struct PyAudioEngine {
16    inner: ling_audio::AudioEngine,
17}
18
19#[pymethods]
20impl PyAudioEngine {
21    #[new]
22    fn new() -> PyResult<Self> {
23        ling_audio::AudioEngine::new()
24            .map(|e| PyAudioEngine { inner: e })
25            .map_err(|e| {
26                pyo3::exceptions::PyRuntimeError::new_err(format!("audio init: {e}"))
27            })
28    }
29
30    /// set_tone(idx, x, y, z, w, freq, amp, lfo_rate, lfo_depth)
31    ///
32    /// Places a synthesised tone at 4D world position (x,y,z,w).
33    /// W drives a sub-oscillator cross-modulator for a hyperdimensional shimmer.
34    #[pyo3(signature = (
35        idx,
36        x=0.0, y=0.0, z=0.0, w=1.0,
37        freq=220.0, amp=0.15,
38        lfo_rate=0.5, lfo_depth=0.02
39    ))]
40    fn set_tone(
41        &self,
42        idx: usize,
43        x: f32, y: f32, z: f32, w: f32,
44        freq: f32, amp: f32,
45        lfo_rate: f32, lfo_depth: f32,
46    ) {
47        self.inner.set_tone(idx, ling_audio::ToneParams {
48            x, y, z, w, freq, amp, lfo_rate, lfo_depth,
49        });
50    }
51
52    fn clear_tone(&self, idx: usize) {
53        self.inner.clear_tone(idx);
54    }
55
56    /// Update listener orientation to match the camera (yaw + pitch trig values).
57    fn set_listener(&self, cry: f32, sry: f32, crx: f32, srx: f32) {
58        self.inner.set_listener(cry, sry, crx, srx);
59    }
60
61    /// Load a WAV file as looping background music.
62    fn load_bgm(&self, path: &str, vol: f32) {
63        self.inner.load_bgm(path, vol);
64    }
65
66    fn set_bgm_volume(&self, vol: f32) {
67        self.inner.set_bgm_volume(vol);
68    }
69
70    fn set_master_volume(&self, vol: f32) {
71        self.inner.set_master_volume(vol);
72    }
73}
74
75// ── HyperbolicWorld ───────────────────────────────────────────────────────────
76
77use ling_physics::hyperbolic::HyperbolicSphereWorld;
78use glam::Vec3;
79
80#[pyclass(name = "HyperbolicWorld")]
81struct PyHyperbolicWorld {
82    inner: HyperbolicSphereWorld,
83}
84
85#[pymethods]
86impl PyHyperbolicWorld {
87    #[new]
88    #[pyo3(signature = (radius=100.0, curvature=-1.0, gravity=9.81))]
89    fn new(radius: f32, curvature: f32, gravity: f32) -> Self {
90        PyHyperbolicWorld {
91            inner: HyperbolicSphereWorld { radius, curvature, gravity },
92        }
93    }
94
95    /// Returns the outward gravity direction at world-space pos (x,y,z).
96    fn gravity_dir(&self, x: f32, y: f32, z: f32) -> (f32, f32, f32) {
97        let v = self.inner.gravity_dir(Vec3::new(x, y, z));
98        (v.x, v.y, v.z)
99    }
100
101    /// Returns the gravity force vector (outward, magnitude = gravity * mass).
102    fn gravity_force(&self, x: f32, y: f32, z: f32, mass: f32) -> (f32, f32, f32) {
103        let v = self.inner.gravity_force(Vec3::new(x, y, z), mass);
104        (v.x, v.y, v.z)
105    }
106
107    /// Returns the "up" direction at pos — points inward toward sphere centre.
108    fn up_at(&self, x: f32, y: f32, z: f32) -> (f32, f32, f32) {
109        let v = self.inner.up_at(Vec3::new(x, y, z));
110        (v.x, v.y, v.z)
111    }
112
113    /// Hyperbolic distance between two world-space points.
114    fn world_distance(
115        &self,
116        ax: f32, ay: f32, az: f32,
117        bx: f32, by: f32, bz: f32,
118    ) -> f32 {
119        self.inner.world_distance(Vec3::new(ax, ay, az), Vec3::new(bx, by, bz))
120    }
121
122    /// Convert world-space position to Poincaré ball coordinate.
123    fn to_poincare(&self, x: f32, y: f32, z: f32) -> (f32, f32, f32) {
124        let v = self.inner.to_poincare(Vec3::new(x, y, z));
125        (v.x, v.y, v.z)
126    }
127
128    /// Convert Poincaré coordinate back to world space.
129    fn from_poincare(&self, x: f32, y: f32, z: f32) -> (f32, f32, f32) {
130        let v = self.inner.from_poincare(Vec3::new(x, y, z));
131        (v.x, v.y, v.z)
132    }
133}
134
135// Stand-alone hyperbolic math functions exposed to Python.
136#[pyfunction]
137fn hyp_distance(ax: f32, ay: f32, az: f32, bx: f32, by: f32, bz: f32) -> f32 {
138    ling_physics::hyperbolic::distance(Vec3::new(ax, ay, az), Vec3::new(bx, by, bz))
139}
140
141#[pyfunction]
142fn exp_map(
143    bx: f32, by: f32, bz: f32,
144    vx: f32, vy: f32, vz: f32,
145) -> (f32, f32, f32) {
146    let v = ling_physics::hyperbolic::exp_map(Vec3::new(bx, by, bz), Vec3::new(vx, vy, vz));
147    (v.x, v.y, v.z)
148}
149
150#[pyfunction]
151fn log_map(
152    bx: f32, by: f32, bz: f32,
153    tx: f32, ty: f32, tz: f32,
154) -> (f32, f32, f32) {
155    let v = ling_physics::hyperbolic::log_map(Vec3::new(bx, by, bz), Vec3::new(tx, ty, tz));
156    (v.x, v.y, v.z)
157}
158
159// ── NetClient ─────────────────────────────────────────────────────────────────
160
161/// Asynchronous WebSocket client for netplay.
162///
163/// Usage from Python:
164///   net = ling_py.NetClient()
165///   net.connect("ws://host:8765")   # non-blocking, spawns background thread
166///   net.send('{"type":"state",...}')
167///   msg = net.try_recv()            # returns None or str
168#[pyclass(name = "NetClient")]
169struct PyNetClient {
170    outbox:    Arc<Mutex<Vec<String>>>,
171    inbox:     Arc<Mutex<VecDeque<String>>>,
172    connected: Arc<std::sync::atomic::AtomicBool>,
173}
174
175#[pymethods]
176impl PyNetClient {
177    #[new]
178    fn new() -> Self {
179        PyNetClient {
180            outbox:    Arc::new(Mutex::new(Vec::new())),
181            inbox:     Arc::new(Mutex::new(VecDeque::new())),
182            connected: Arc::new(std::sync::atomic::AtomicBool::new(false)),
183        }
184    }
185
186    /// Connect to a WebSocket server (non-blocking; returns immediately).
187    fn connect(&self, url: String) {
188        let outbox    = Arc::clone(&self.outbox);
189        let inbox     = Arc::clone(&self.inbox);
190        let connected = Arc::clone(&self.connected);
191        std::thread::spawn(move || {
192            let rt = tokio::runtime::Builder::new_current_thread()
193                .enable_all()
194                .build()
195                .expect("tokio runtime build");
196            rt.block_on(ws_run(url, outbox, inbox, connected));
197        });
198    }
199
200    /// Queue a message to be sent to the server.
201    fn send(&self, msg: String) {
202        if let Ok(mut ob) = self.outbox.lock() {
203            ob.push(msg);
204        }
205    }
206
207    /// Pop one incoming message, or return None.
208    fn try_recv(&self) -> Option<String> {
209        self.inbox.lock().ok()?.pop_front()
210    }
211
212    fn is_connected(&self) -> bool {
213        self.connected.load(std::sync::atomic::Ordering::Relaxed)
214    }
215}
216
217async fn ws_run(
218    url:       String,
219    outbox:    Arc<Mutex<Vec<String>>>,
220    inbox:     Arc<Mutex<VecDeque<String>>>,
221    connected: Arc<std::sync::atomic::AtomicBool>,
222) {
223    use futures_util::{SinkExt, StreamExt};
224    use tokio::time::{sleep, Duration};
225    use tokio_tungstenite::{connect_async, tungstenite::Message};
226
227    let ws = match connect_async(&url).await {
228        Ok((ws, _)) => ws,
229        Err(e) => { eprintln!("[ling-py netplay] connect failed ({url}): {e}"); return; }
230    };
231    connected.store(true, std::sync::atomic::Ordering::Relaxed);
232    eprintln!("[ling-py netplay] connected to {url}");
233
234    let (mut write, mut read) = ws.split();
235
236    loop {
237        // Drain outbox → WebSocket
238        let msgs: Vec<String> = if let Ok(mut ob) = outbox.lock() {
239            std::mem::take(&mut *ob)
240        } else {
241            vec![]
242        };
243        for m in msgs {
244            if write.send(Message::Text(m)).await.is_err() { break; }
245        }
246
247        // Read one incoming frame or yield after 1 ms
248        tokio::select! {
249            frame = read.next() => {
250                match frame {
251                    Some(Ok(Message::Text(s))) => {
252                        if let Ok(mut ib) = inbox.lock() {
253                            ib.push_back(s.to_string());
254                            while ib.len() > 128 { ib.pop_front(); }
255                        }
256                    }
257                    Some(Ok(_)) => {} // binary, ping, pong — ignore
258                    _           => break, // error or close frame
259                }
260            }
261            _ = sleep(Duration::from_millis(1)) => {}
262        }
263    }
264
265    connected.store(false, std::sync::atomic::Ordering::Relaxed);
266    eprintln!("[ling-py netplay] disconnected from {url}");
267}
268
269// ── Module root ───────────────────────────────────────────────────────────────
270
271#[pymodule]
272fn ling_py(m: &Bound<'_, PyModule>) -> PyResult<()> {
273    m.add_class::<PyAudioEngine>()?;
274    m.add_class::<PyHyperbolicWorld>()?;
275    m.add_class::<PyNetClient>()?;
276
277    m.add_function(wrap_pyfunction!(hyp_distance, m)?)?;
278    m.add_function(wrap_pyfunction!(exp_map, m)?)?;
279    m.add_function(wrap_pyfunction!(log_map, m)?)?;
280
281    Ok(())
282}