geedback

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit 85771e7c460d8f3507872cd05242b131ef908f9c
parent 54e3b09628623abebe5dcd243822c44ec37b3995
Author: minerva-jupiter <ryouturn@gmail.com>
Date:   Mon,  4 May 2026 13:49:10 +0900

feat(dsp): implement multi-oscillator engine and Kaoss Pad

- Add 3-oscillator architecture with FM modulation and selectable waveforms
- Implement global Biquad LPF with Kaoss Pad UI touch controls
- Add stateful `GeedbackProcessor` with parameter smoothing and Nyquist safety clamping
- Update frontend to support real-time interaction and oscillator configuration
- Include project documentation in README.md

Diffstat:
AREADME.md | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbun.lock | 1+
Mgeedback-dsp/pkg/geedback_dsp.d.ts | 21+++++++++++++++++----
Mgeedback-dsp/pkg/geedback_dsp.js | 40++++++++++++++++++++++++++++++----------
Mgeedback-dsp/pkg/geedback_dsp_bg.wasm | 0
Mgeedback-dsp/pkg/geedback_dsp_bg.wasm.d.ts | 5+++--
Mgeedback-dsp/src/lib.rs | 263+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Msrc/main.ts | 324+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Msrc/style.css | 20++++++++++++++++++++
9 files changed, 602 insertions(+), 145 deletions(-)

diff --git a/README.md b/README.md @@ -0,0 +1,73 @@ +# Geedback + +A real-time sensor-driven WASM DSP synthesizer. + +## Design Philosophy & Direction + +### 1. WASM-Centric DSP +All core signal processing is implemented in Rust/WASM for high performance and portability. The project follows a stateful processor pattern, designed for future integration with frameworks like **NIH-plug**. + +### 2. Physical State Modeling +Sensor data is interpreted as physical forces: +- **Orientation (Tilt)**: Static position in 3D space. +- **Linear Acceleration (Force)**: Dynamic impact and rapid movement. + +### 3. Seamless 2D Trigonometric Mapping +To ensure unique parameter states and perfectly smooth 360-degree rotation: +- **$\sin(\theta/2) \to$ Pitch**: Spread over a 360-degree period to prevent half-turn repetition. +- **$\cos(\theta/2) \to$ Waveform Morphing**: Ensures every angle has a unique "Timbre," even when pitches match. + +### 4. Organic Parameter Smoothing +Internal Low-Pass Filters (LPF) with a high coefficient (**0.9995**) eliminate "zipper noise" from 60Hz sensor updates, creating a creamy, instrument-like feel with physical inertia. + +--- + +## Detailed Specifications + +### 1. Synthesis Engine +- **3-Oscillator Architecture**: Each oscillator has independent phase and selectable waveforms. +- **Available Waveforms**: Sine, Triangle, Triangle-Sawtooth, Sawtooth, Reverse Sawtooth, Square, Wide Pulse, Narrow Pulse. +- **FM Modulation**: Each carrier oscillator is frequency-modulated by a dedicated modulator, driven by dynamic physical forces. + +### 2. Motion Mapping (Orientation & Acceleration) +| Input Axis | Target Oscillator | Static Control ($\sin/\cos$) | Dynamic Control (Linear Accel) | +| :--- | :--- | :--- | :--- | +| **Gamma (Y)** | Oscillator 1 | Pitch & Waveform Morph | FM Modulation Depth | +| **Alpha (Z)** | Oscillator 2 | Pitch & Waveform Morph | FM Modulation Depth | +| **Beta (X)** | Oscillator 3 | Pitch & Waveform Morph | FM Modulation Depth | + +- **Waveform Morphing Examples**: + - Pulse Waves: Modulates Pulse Width (2% to 50%). + - Triangle-Sawtooth: Modulates the blend ratio. + - Sine: Adds subtle saturation/harmonics. + +### 3. Kaoss Pad (Global Filter) +- **UI**: A red crosshair square canvas with scroll-safe touch handling. +- **Algorithm**: High-quality Global Biquad Low-Pass Filter (LPF). +- **Control Mapping**: + - **X-axis**: Cutoff Frequency (**100Hz to 18kHz**, exponential scale). + - **Y-axis**: Resonance / Q (**0.707 to 15.0**). +- **Stability**: Includes a safety clamp at **0.45 * Sample Rate** to prevent NaN/mathematical explosion at high cutoff frequencies. + +### 4. Technical Integration +- **Web Audio API**: Real-time streaming via `ScriptProcessorNode`. +- **Permission Handling**: Integrated iOS `DeviceMotionEvent.requestPermission` flow. +- **Build System**: Automated `wasm-pack workflow targeting standard browser environments. + +--- + +## 🤖 Message for Future Coding Agents + +### Technical Handoff Notes +When continuing development on Geedback, please adhere to the following architectural constraints: + +1. **Stateful Processor**: Keep `GeedbackProcessor` stateful. It is designed to mimic the `process()` loop of standard audio plugins. Avoid adding global static state; encapsulate everything within the struct to facilitate future **NIH-plug** porting. +2. **The "Zipper Noise" Battle**: Sensor updates from browsers are slow (~60Hz). Always use the internal LPF (**`0.9995` coefficient**) for any parameter driven by sensors. Never map raw sensor values directly to audio parameters. +3. **Trigonometric Periodicity**: Use `theta / 2.0` before calculating `sin/cos` for orientation. This is a deliberate choice to spread a full oscillator cycle across a **360-degree** physical turn, preventing the pitch from repeating every half-turn. +4. **Filter Stability**: The Biquad implementation is sensitive to the Nyquist frequency. **Always clamp** the cutoff frequency to below `0.45 * sample_rate`. If you implement other filter types (High-Pass, Band-Pass), apply similar safety guards to prevent NaN explosions. +5. **2D Mapping Integrity**: Maintain the `sin` (Pitch) and `cos` (Timbre) coupling. This is the primary solution to "data collapse" where multiple orientations would otherwise yield the same sound. + +### Future Roadmap Ideas +- Implement an ADSR envelope triggered by sharp Linear Acceleration spikes. +- Add a "Delay" or "Reverb" module, potentially controlled by the Z-axis (Alpha) or Magnetometer (if available). +- Port the current `GeedbackProcessor` logic into a Rust-native audio plugin using NIH-plug. diff --git a/bun.lock b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "geedback", diff --git a/geedback-dsp/pkg/geedback_dsp.d.ts b/geedback-dsp/pkg/geedback_dsp.d.ts @@ -7,10 +7,22 @@ export class GeedbackProcessor { get_latest_output(): number; constructor(); process(): number; - set_accel(x: number, y: number, z: number): void; - set_gyro(a: number, b: number, g: number): void; + set_linear_accel(x: number, y: number, z: number): void; set_orient(a: number, b: number, g: number): void; set_sample_rate(sample_rate: number): void; + set_touch(x: number, y: number): void; + set_waveform(index: number, wave: Waveform): void; +} + +export enum Waveform { + Sine = 0, + Triangle = 1, + TriangleSawtooth = 2, + Sawtooth = 3, + ReverseSawtooth = 4, + Square = 5, + WidePulse = 6, + NarrowPulse = 7, } export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; @@ -21,10 +33,11 @@ export interface InitOutput { readonly geedbackprocessor_get_latest_output: (a: number) => number; readonly geedbackprocessor_new: () => number; readonly geedbackprocessor_process: (a: number) => number; - readonly geedbackprocessor_set_accel: (a: number, b: number, c: number, d: number) => void; - readonly geedbackprocessor_set_gyro: (a: number, b: number, c: number, d: number) => void; + readonly geedbackprocessor_set_linear_accel: (a: number, b: number, c: number, d: number) => void; readonly geedbackprocessor_set_orient: (a: number, b: number, c: number, d: number) => void; readonly geedbackprocessor_set_sample_rate: (a: number, b: number) => void; + readonly geedbackprocessor_set_touch: (a: number, b: number, c: number) => void; + readonly geedbackprocessor_set_waveform: (a: number, b: number, c: number) => void; readonly __wbindgen_externrefs: WebAssembly.Table; readonly __wbindgen_start: () => void; } diff --git a/geedback-dsp/pkg/geedback_dsp.js b/geedback-dsp/pkg/geedback_dsp.js @@ -36,16 +36,8 @@ export class GeedbackProcessor { * @param {number} y * @param {number} z */ - set_accel(x, y, z) { - wasm.geedbackprocessor_set_accel(this.__wbg_ptr, x, y, z); - } - /** - * @param {number} a - * @param {number} b - * @param {number} g - */ - set_gyro(a, b, g) { - wasm.geedbackprocessor_set_gyro(this.__wbg_ptr, a, b, g); + set_linear_accel(x, y, z) { + wasm.geedbackprocessor_set_linear_accel(this.__wbg_ptr, x, y, z); } /** * @param {number} a @@ -61,8 +53,36 @@ export class GeedbackProcessor { set_sample_rate(sample_rate) { wasm.geedbackprocessor_set_sample_rate(this.__wbg_ptr, sample_rate); } + /** + * @param {number} x + * @param {number} y + */ + set_touch(x, y) { + wasm.geedbackprocessor_set_touch(this.__wbg_ptr, x, y); + } + /** + * @param {number} index + * @param {Waveform} wave + */ + set_waveform(index, wave) { + wasm.geedbackprocessor_set_waveform(this.__wbg_ptr, index, wave); + } } if (Symbol.dispose) GeedbackProcessor.prototype[Symbol.dispose] = GeedbackProcessor.prototype.free; + +/** + * @enum {0 | 1 | 2 | 3 | 4 | 5 | 6 | 7} + */ +export const Waveform = Object.freeze({ + Sine: 0, "0": "Sine", + Triangle: 1, "1": "Triangle", + TriangleSawtooth: 2, "2": "TriangleSawtooth", + Sawtooth: 3, "3": "Sawtooth", + ReverseSawtooth: 4, "4": "ReverseSawtooth", + Square: 5, "5": "Square", + WidePulse: 6, "6": "WidePulse", + NarrowPulse: 7, "7": "NarrowPulse", +}); function __wbg_get_imports() { const import0 = { __proto__: null, diff --git a/geedback-dsp/pkg/geedback_dsp_bg.wasm b/geedback-dsp/pkg/geedback_dsp_bg.wasm Binary files differ. diff --git a/geedback-dsp/pkg/geedback_dsp_bg.wasm.d.ts b/geedback-dsp/pkg/geedback_dsp_bg.wasm.d.ts @@ -5,9 +5,10 @@ export const __wbg_geedbackprocessor_free: (a: number, b: number) => void; export const geedbackprocessor_get_latest_output: (a: number) => number; export const geedbackprocessor_new: () => number; export const geedbackprocessor_process: (a: number) => number; -export const geedbackprocessor_set_accel: (a: number, b: number, c: number, d: number) => void; -export const geedbackprocessor_set_gyro: (a: number, b: number, c: number, d: number) => void; +export const geedbackprocessor_set_linear_accel: (a: number, b: number, c: number, d: number) => void; export const geedbackprocessor_set_orient: (a: number, b: number, c: number, d: number) => void; export const geedbackprocessor_set_sample_rate: (a: number, b: number) => void; +export const geedbackprocessor_set_touch: (a: number, b: number, c: number) => void; +export const geedbackprocessor_set_waveform: (a: number, b: number, c: number) => void; export const __wbindgen_externrefs: WebAssembly.Table; export const __wbindgen_start: () => void; diff --git a/geedback-dsp/src/lib.rs b/geedback-dsp/src/lib.rs @@ -1,24 +1,58 @@ use wasm_bindgen::prelude::*; #[wasm_bindgen] +#[derive(PartialEq, Clone, Copy)] +pub enum Waveform { + Sine, + Triangle, + TriangleSawtooth, + Sawtooth, + ReverseSawtooth, + Square, + WidePulse, + NarrowPulse, +} + +#[wasm_bindgen] pub struct GeedbackProcessor { - accel_x: f64, - accel_y: f64, - accel_z: f64, - gyro_a: f64, - gyro_b: f64, - gyro_g: f64, - orient_a: f64, - orient_b: f64, - orient_g: f64, - - // DSP state: 3 independent phases for oscillators - phase_x: f64, - phase_y: f64, - phase_z: f64, + // Primary inputs + orient_a: f64, // Z (Alpha) + orient_b: f64, // X (Beta) + orient_g: f64, // Y (Gamma) + linear_accel_x: f64, + linear_accel_y: f64, + linear_accel_z: f64, + + // Touch inputs (Kaoss Pad) + touch_x: f64, + touch_y: f64, + + // Smoothed parameters for all 3 axes (sin/cos pairs) + s_a_sin: f64, + s_a_cos: f64, + s_b_sin: f64, + s_b_cos: f64, + s_g_sin: f64, + s_g_cos: f64, + + // Smoothed linear acceleration per axis + s_la_x: f64, + s_la_y: f64, + s_la_z: f64, + + // Oscillator States + phases: [f64; 3], + phases_mod: [f64; 3], // Dedicated modulator phases + waveforms: [Waveform; 3], + mixes: [f64; 3], sample_rate: f64, latest_output: f64, + + // Filter state + s_filter_cutoff: f64, + s_filter_res: f64, + filter_mem: [f64; 4], // x1, x2, y1, y2 } #[wasm_bindgen] @@ -26,68 +60,181 @@ impl GeedbackProcessor { #[wasm_bindgen(constructor)] pub fn new() -> Self { Self { - accel_x: 0.0, - accel_y: 0.0, - accel_z: 0.0, - gyro_a: 0.0, - gyro_b: 0.0, - gyro_g: 0.0, orient_a: 0.0, orient_b: 0.0, orient_g: 0.0, - phase_x: 0.0, - phase_y: 0.0, - phase_z: 0.0, + linear_accel_x: 0.0, + linear_accel_y: 0.0, + linear_accel_z: 0.0, + touch_x: 0.5, + touch_y: 0.5, + s_a_sin: 0.0, + s_a_cos: 1.0, + s_b_sin: 0.0, + s_b_cos: 1.0, + s_g_sin: 0.0, + s_g_cos: 1.0, + s_la_x: 0.0, + s_la_y: 0.0, + s_la_z: 0.0, + s_filter_cutoff: 0.5, + s_filter_res: 0.1, + filter_mem: [0.0; 4], + phases: [0.0; 3], + phases_mod: [0.0; 3], + waveforms: [Waveform::Sine, Waveform::Sine, Waveform::Sine], + mixes: [0.6, 0.6, 0.6], sample_rate: 44100.0, latest_output: 0.0, } } - pub fn get_latest_output(&self) -> f64 { - self.latest_output + pub fn set_waveform(&mut self, index: usize, wave: Waveform) { + if index < 3 { + self.waveforms[index] = wave; + } } - pub fn set_sample_rate(&mut self, sample_rate: f64) { - self.sample_rate = sample_rate; + pub fn set_orient(&mut self, a: f64, b: f64, g: f64) { + self.orient_a = a; + self.orient_b = b; + self.orient_g = g; } - pub fn set_accel(&mut self, x: f64, y: f64, z: f64) { - self.accel_x = x; - self.accel_y = y; - self.accel_z = z; + pub fn set_linear_accel(&mut self, x: f64, y: f64, z: f64) { + self.linear_accel_x = x; + self.linear_accel_y = y; + self.linear_accel_z = z; } - pub fn set_gyro(&mut self, a: f64, b: f64, g: f64) { - self.gyro_a = a; - self.gyro_b = b; - self.gyro_g = g; + pub fn set_touch(&mut self, x: f64, y: f64) { + self.touch_x = x.clamp(0.0, 1.0); + self.touch_y = y.clamp(0.0, 1.0); } - pub fn set_orient(&mut self, a: f64, b: f64, g: f64) { - self.orient_a = a; - self.orient_b = b; - self.orient_g = g; + pub fn set_sample_rate(&mut self, sample_rate: f64) { + self.sample_rate = sample_rate; + } + + fn calculate_wave(&self, phase: f64, wave: Waveform, morph: f64) -> f64 { + let m = (morph + 1.0) * 0.5; + let p = phase.fract(); + + match wave { + Waveform::Sine => { + let raw = (p * 2.0 * std::f64::consts::PI).sin(); + if m > 0.5 { + let drive = 1.0 + (m - 0.5) * 2.0; + (raw * drive).tanh() + } else { + raw + } + } + Waveform::Triangle => 1.0 - 2.0 * (p + 0.75).fract().abs(), + Waveform::TriangleSawtooth => { + let tri = 1.0 - 2.0 * (p + 0.75).fract().abs(); + let saw = 2.0 * p - 1.0; + tri * (1.0 - m) + saw * m + } + Waveform::Sawtooth => 2.0 * p - 1.0, + Waveform::ReverseSawtooth => 1.0 - p * 2.0, + Waveform::Square | Waveform::WidePulse | Waveform::NarrowPulse => { + let width = 0.02 + m * 0.48; + if p < width { 1.0 } else { -1.0 } + } + } } pub fn process(&mut self) -> f64 { - // Map acceleration to audible frequencies (e.g., 220Hz to 880Hz) - let freq_x = 220.0 + self.accel_x.abs() * 20.0; - let freq_y = 330.0 + self.accel_y.abs() * 20.0; - let freq_z = 440.0 + self.accel_z.abs() * 20.0; - - // Update phases - self.phase_x = (self.phase_x + freq_x / self.sample_rate) % 1.0; - self.phase_y = (self.phase_y + freq_y / self.sample_rate) % 1.0; - self.phase_z = (self.phase_z + freq_z / self.sample_rate) % 1.0; - - // Calculate sine waves - let out_x = (self.phase_x * 2.0 * std::f64::consts::PI).sin(); - let out_y = (self.phase_y * 2.0 * std::f64::consts::PI).sin(); - let out_z = (self.phase_z * 2.0 * std::f64::consts::PI).sin(); - - // Mix output (sum of 3 oscillators) - let out = (out_x + out_y + out_z) / 3.0; - self.latest_output = out; - out + let rad_a = (self.orient_a / 2.0).to_radians(); + let rad_b = (self.orient_b / 2.0).to_radians(); + let rad_g = (self.orient_g / 2.0).to_radians(); + + let lpf_val = 0.9995; + let gain_val = 0.0005; + + // Smooth Orientation + self.s_a_sin = self.s_a_sin * lpf_val + rad_a.sin() * gain_val; + self.s_a_cos = self.s_a_cos * lpf_val + rad_a.cos() * gain_val; + self.s_b_sin = self.s_b_sin * lpf_val + rad_b.sin() * gain_val; + self.s_b_cos = self.s_b_cos * lpf_val + rad_b.cos() * gain_val; + self.s_g_sin = self.s_g_sin * lpf_val + rad_g.sin() * gain_val; + self.s_g_cos = self.s_g_cos * lpf_val + rad_g.cos() * gain_val; + + // Smooth Linear Accel + let la_lpf = 0.995; + let la_gain = 0.005; + self.s_la_x = self.s_la_x * la_lpf + self.linear_accel_x * la_gain; + self.s_la_y = self.s_la_y * la_lpf + self.linear_accel_y * la_gain; + self.s_la_z = self.s_la_z * la_lpf + self.linear_accel_z * la_gain; + + self.s_filter_cutoff = self.s_filter_cutoff * 0.99 + self.touch_x * 0.01; + self.s_filter_res = self.s_filter_res * 0.99 + (1.0 - self.touch_y) * 0.01; + + let freqs = [ + 110.0 * 2.0_f64.powf(self.s_g_sin * 2.5), // Osc 1: Y + 164.8 * 2.0_f64.powf(self.s_a_sin * 4.0), // Osc 2: Z + 220.0 * 2.0_f64.powf(self.s_b_sin * 2.5), // Osc 3: X + ]; + + let morphs = [self.s_g_cos, self.s_a_cos, self.s_b_cos]; + + let fm_depths = [ + self.s_la_y.abs() * 5.0, // Osc 1: Y + self.s_la_z.abs() * 5.0, // Osc 2: Z + self.s_la_x.abs() * 5.0, // Osc 3: X + ]; + + let mut mixed_output = 0.0; + + for i in 0..3 { + self.phases_mod[i] = (self.phases_mod[i] + (freqs[i] * 1.5) / self.sample_rate) % 1.0; + let modulation = + (self.phases_mod[i] * 2.0 * std::f64::consts::PI).sin() * (fm_depths[i] * 2.0); + let osc_out = + self.calculate_wave(self.phases[i] + modulation, self.waveforms[i], morphs[i]); + mixed_output += osc_out * self.mixes[i]; + self.phases[i] = (self.phases[i] + freqs[i] / self.sample_rate) % 1.0; + } + + let mut cutoff_hz = 200.0 * 180.0_f64.powf(self.s_filter_cutoff); + + // Safety: Clamp cutoff to slightly below Nyquist to prevent NaN + let max_cutoff = self.sample_rate * 0.45; + if cutoff_hz > max_cutoff { + cutoff_hz = max_cutoff; + } + + let q = 0.707 + self.s_filter_res * 14.3; + + let omega = 2.0 * std::f64::consts::PI * cutoff_hz / self.sample_rate; + let alpha = omega.sin() / (2.0 * q); + let cos_w = omega.cos(); + + let b0 = (1.0 - cos_w) / 2.0; + let b1 = 1.0 - cos_w; + let b2 = (1.0 - cos_w) / 2.0; + let a0 = 1.0 + alpha; + let a1 = -2.0 * cos_w; + let a2 = 1.0 - alpha; + + let filtered = (b0 / a0) * mixed_output + + (b1 / a0) * self.filter_mem[0] + + (b2 / a0) * self.filter_mem[1] + - (a1 / a0) * self.filter_mem[2] + - (a2 / a0) * self.filter_mem[3]; + + self.filter_mem[1] = self.filter_mem[0]; + self.filter_mem[0] = mixed_output; + self.filter_mem[3] = self.filter_mem[2]; + self.filter_mem[2] = filtered; + + self.latest_output = filtered; + let master_gain = 0.6; + (filtered * master_gain).tanh() + } + + pub fn get_latest_output(&self) -> f64 { + self.latest_output } } diff --git a/src/main.ts b/src/main.ts @@ -1,26 +1,54 @@ import "./style.css"; -import init, { GeedbackProcessor } from "../geedback-dsp/pkg/geedback_dsp.js"; +import init, { + GeedbackProcessor, + Waveform, +} from "../geedback-dsp/pkg/geedback_dsp.js"; const app = document.querySelector<HTMLDivElement>("#app")!; app.innerHTML = ` <div> - <h1>Sensor WASM Synth</h1> + <h1>Multi-Osc WASM Synth</h1> <button id="start-btn">Start Audio & Sensors</button> - <div style="margin-top: 20px; padding: 10px; border: 2px solid #646cff; border-radius: 8px;"> - <strong>WASM Output (DSP Value):</strong> <span id="wasm-output" style="font-family: monospace; font-size: 1.2em;">-</span> + + <canvas id="kaoss-pad"></canvas> + + <div style="margin-top: 20px; padding: 15px; border: 2px solid #ff4444; border-radius: 8px; background: #1a1a1a;"> + <div style="display: flex; justify-content: space-between; margin-bottom: 10px;"> + <strong>DSP Output:</strong> <span id="wasm-output" style="font-family: monospace;">-</span> + </div> + <div style="font-size: 0.8em; color: #888; margin-bottom: 10px;"> + Cutoff (X): <span id="touch-x">-</span> | Resonance (Y): <span id="touch-y">-</span> + </div> + + <div class="osc-controls"> + ${[0, 1, 2] + .map( + (i) => ` + <div style="margin-bottom: 8px;"> + <label>Osc ${i + 1} Wave:</label> + <select id="osc-${i}-wave" class="wave-select"> + <option value="Sine">Sine</option> + <option value="Triangle">Triangle</option> + <option value="TriangleSawtooth">Tri-Saw</option> + <option value="Sawtooth">Sawtooth</option> + <option value="ReverseSawtooth">Rev-Saw</option> + <option value="Square">Square</option> + <option value="WidePulse">Wide Pulse</option> + <option value="NarrowPulse">Narrow Pulse</option> + </select> + </div> + `, + ) + .join("")} + </div> </div> - <table border="1" style="margin-top: 20px; width: 100%; border-collapse: collapse;"> - <thead> - <tr><th>Category</th><th>Property</th><th>Value</th></tr> - </thead> - <tbody id="sensor-body"> - <tr><td rowspan="3">Acceleration</td><td>X</td><td id="acc-x">-</td></tr> - <tr><td>Y</td><td id="acc-y">-</td></tr> - <tr><td>Z</td><td id="acc-z">-</td></tr> - <tr><td rowspan="3">Orientation</td><td>Alpha</td><td id="ori-a">-</td></tr> - <tr><td>Beta</td><td id="ori-b">-</td></tr> - <tr><td>Gamma</td><td id="ori-g">-</td></tr> + + <table border="1" style="margin-top: 20px; width: 100%; border-collapse: collapse; font-size: 0.8em; opacity: 0.7;"> + <tbody id="sensor-table"> + <tr id="row-accel"><td>Accel</td><td class="x">-</td><td class="y">-</td><td class="z">-</td></tr> + <tr id="row-linear"><td>Linear</td><td class="x">-</td><td class="y">-</td><td class="z">-</td></tr> + <tr id="row-orient"><td>Orient</td><td class="x">-</td><td class="y">-</td><td class="z">-</td></tr> </tbody> </table> </div> @@ -28,82 +56,236 @@ app.innerHTML = ` const startBtn = document.querySelector<HTMLButtonElement>("#start-btn")!; const wasmOutput = document.querySelector<HTMLSpanElement>("#wasm-output")!; +const canvas = document.querySelector<HTMLCanvasElement>("#kaoss-pad")!; +const touchXEl = document.querySelector<HTMLSpanElement>("#touch-x")!; +const touchYEl = document.querySelector<HTMLSpanElement>("#touch-y")!; let processor: GeedbackProcessor | null = null; let audioCtx: AudioContext | null = null; +let isActive = false; +let wasmInitialized = false; -const updateValue = (id: string, value: number | null | undefined) => { - const el = document.getElementById(id); - if (el) el.textContent = value?.toFixed(2) ?? "-"; +const updateRow = ( + id: string, + x: number | null, + y?: number | null, + z?: number | null, +) => { + const row = document.getElementById(id); + if (!row) return; + if (x !== null) + (row.querySelector(".x") as HTMLElement).textContent = x.toFixed(2); + if (y !== undefined && y !== null) + (row.querySelector(".y") as HTMLElement).textContent = y.toFixed(2); + if (z !== undefined && z !== null) + (row.querySelector(".z") as HTMLElement).textContent = z.toFixed(2); }; -const startAudio = async () => { - // 1. Initialize WASM - try { - await init(); - processor = new GeedbackProcessor(); - } catch (e) { - console.error(e); - alert("WASM init failed"); - return; - } +const setupWaveformControls = () => { + [0, 1, 2].forEach((i) => { + const select = document.getElementById( + `osc-${i}-wave`, + ) as HTMLSelectElement; + select.addEventListener("change", (e) => { + if (processor) { + const val = (e.target as HTMLSelectElement).value; + processor.set_waveform(i, (Waveform as any)[val]); + } + }); + if (i === 0) select.value = "Sine"; + if (i === 1) select.value = "Sine"; + if (i === 2) select.value = "Sine"; + }); +}; - // 2. Request Permissions (iOS) - if (typeof (DeviceMotionEvent as any).requestPermission === "function") { - const res = await (DeviceMotionEvent as any).requestPermission(); - if (res !== "granted") return alert("Permission denied"); +const drawPad = (x: number, y: number) => { + const ctx = canvas.getContext("2d")!; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Grid + ctx.strokeStyle = "#222"; + ctx.lineWidth = 1; + for (let i = 1; i < 4; i++) { + ctx.beginPath(); + ctx.moveTo((i * canvas.width) / 4, 0); + ctx.lineTo((i * canvas.width) / 4, canvas.height); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, (i * canvas.height) / 4); + ctx.lineTo(canvas.width, (i * canvas.height) / 4); + ctx.stroke(); } - // 3. Setup Web Audio - audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); - processor.set_sample_rate(audioCtx.sampleRate); + // Touch point - RED + ctx.fillStyle = "#ff4444"; + ctx.beginPath(); + ctx.arc(x * canvas.width, y * canvas.height, 10, 0, Math.PI * 2); + ctx.fill(); + + // Crosshair - RED + ctx.strokeStyle = "#ff4444"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(x * canvas.width, 0); + ctx.lineTo(x * canvas.width, canvas.height); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, y * canvas.height); + ctx.lineTo(canvas.width, y * canvas.height); + ctx.stroke(); +}; + +const setupKaossPad = () => { + let isTouching = false; - // Using ScriptProcessor for simplicity in this test - const bufferSize = 4096; - const scriptNode = audioCtx.createScriptProcessor(bufferSize, 0, 1); + const updateTouch = (e: MouseEvent | TouchEvent) => { + if (!isActive) return; + const rect = canvas.getBoundingClientRect(); + let clientX, clientY; + + if ("touches" in e) { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + clientX = e.clientX; + clientY = e.clientY; + } + + const x = (clientX - rect.left) / rect.width; + const y = (clientY - rect.top) / rect.height; + const clampedX = Math.max(0, Math.min(1, x)); + const clampedY = Math.max(0, Math.min(1, y)); - scriptNode.onaudioprocess = (audioEvent) => { - const outputBuffer = audioEvent.outputBuffer.getChannelData(0); if (processor) { - for (let i = 0; i < bufferSize; i++) { - outputBuffer[i] = processor.process() * 0.2; // Volume control - } + processor.set_touch(clampedX, clampedY); } + + touchXEl.textContent = clampedX.toFixed(2); + touchYEl.textContent = clampedY.toFixed(2); + + drawPad(clampedX, clampedY); }; - scriptNode.connect(audioCtx.destination); - if (audioCtx.state === "suspended") await audioCtx.resume(); + canvas.width = canvas.offsetWidth; + canvas.height = canvas.offsetHeight; + drawPad(0.5, 0.5); - startBtn.disabled = true; - startBtn.textContent = "Audio Active"; - - // 4. Sensor Listeners - window.addEventListener("devicemotion", (e) => { - const acc = e.accelerationIncludingGravity; - if (acc && processor) { - updateValue("acc-x", acc.x); - updateValue("acc-y", acc.y); - updateValue("acc-z", acc.z); - processor.set_accel(acc.x || 0, acc.y || 0, acc.z || 0); - } + canvas.addEventListener("mousedown", (e) => { + isTouching = true; + updateTouch(e); + }); + window.addEventListener("mousemove", (e) => { + if (isTouching) updateTouch(e); + }); + window.addEventListener("mouseup", () => { + isTouching = false; }); - window.addEventListener("deviceorientation", (e) => { - if (processor) { - updateValue("ori-a", e.alpha); - updateValue("ori-b", e.beta); - updateValue("ori-g", e.gamma); - processor.set_orient(e.alpha || 0, e.beta || 0, e.gamma || 0); - } + canvas.addEventListener( + "touchstart", + (e) => { + if (e.target === canvas) { + isTouching = true; + updateTouch(e); + e.preventDefault(); + } + }, + { passive: false }, + ); + + window.addEventListener( + "touchmove", + (e) => { + if (isTouching) { + updateTouch(e); + e.preventDefault(); + } + }, + { passive: false }, + ); + window.addEventListener("touchend", () => { + isTouching = false; }); +}; - // UI Update Loop - const updateUI = () => { - if (processor) { - wasmOutput.textContent = processor.get_latest_output().toFixed(6); +const startSynth = async () => { + if (!wasmInitialized) { + try { + await init(); + wasmInitialized = true; + processor = new GeedbackProcessor(); + setupWaveformControls(); + setupKaossPad(); + } catch (e) { + alert("WASM failed"); + return; + } + } + + audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + if (processor) processor.set_sample_rate(audioCtx.sampleRate); + + const scriptNode = audioCtx.createScriptProcessor(4096, 0, 1); + scriptNode.onaudioprocess = (e) => { + const out = e.outputBuffer.getChannelData(0); + if (processor && isActive) { + for (let i = 0; i < out.length; i++) out[i] = processor.process(); + } else { + for (let i = 0; i < out.length; i++) out[i] = 0; } - requestAnimationFrame(updateUI); }; - updateUI(); + scriptNode.connect(audioCtx.destination); + if (audioCtx.state === "suspended") await audioCtx.resume(); + + // Sensor Permissions (iOS) + if (typeof (DeviceMotionEvent as any).requestPermission === "function") { + await (DeviceMotionEvent as any).requestPermission(); + } + + isActive = true; + startBtn.textContent = "Stop Audio & Sensors"; + startBtn.classList.add("active"); +}; + +const stopSynth = async () => { + isActive = false; + if (audioCtx) { + await audioCtx.close(); + audioCtx = null; + } + startBtn.textContent = "Start Audio & Sensors"; + startBtn.classList.remove("active"); +}; + +startBtn.addEventListener("click", () => { + if (!isActive) { + startSynth(); + } else { + stopSynth(); + } +}); + +// Setup global event listeners once +window.addEventListener("devicemotion", (e) => { + if (!isActive || !processor) return; + const la = e.acceleration; + if (la) { + processor.set_linear_accel(la.x || 0, la.y || 0, la.z || 0); + updateRow("row-linear", la.x, la.y, la.z); + } + const a = e.accelerationIncludingGravity; + if (a) updateRow("row-accel", a.x, a.y, a.z); +}); + +window.addEventListener("deviceorientation", (e) => { + if (!isActive || !processor) return; + processor.set_orient(e.alpha || 0, e.beta || 0, e.gamma || 0); + updateRow("row-orient", e.alpha, e.beta, e.gamma); +}); + +const uiLoop = () => { + if (processor && isActive) + wasmOutput.textContent = processor.get_latest_output().toFixed(6); + requestAnimationFrame(uiLoop); }; -startBtn.addEventListener("click", startAudio); +uiLoop(); diff --git a/src/style.css b/src/style.css @@ -58,6 +58,26 @@ button:hover { border-color: #646cff; } +button.active { + background-color: #ff4444; + color: white; + border-color: #ff4444; +} + +button.active:hover { + background-color: #cc0000; +} + +#kaoss-pad { + width: 100%; + aspect-ratio: 1 / 1; + background: #000; + border: 2px solid #333; + border-radius: 8px; + margin-top: 20px; + touch-action: none; /* Only disable scroll on the pad itself */ + cursor: crosshair; +} @media (prefers-color-scheme: light) { :root { color: #213547;