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:
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;