summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/index.js1
-rw-r--r--src/index.jsx16
-rw-r--r--src/lib/kalimba.js49
-rw-r--r--src/lib/output.js8
-rw-r--r--src/lib/startAudioContext.js180
-rw-r--r--src/lib/util.js63
-rw-r--r--src/relabi/index.js121
7 files changed, 437 insertions, 1 deletions
diff --git a/src/index.js b/src/index.js
deleted file mode 100644
index 702f428..0000000
--- a/src/index.js
+++ /dev/null
@@ -1 +0,0 @@
-console.log("hello");
diff --git a/src/index.jsx b/src/index.jsx
new file mode 100644
index 0000000..e752c5a
--- /dev/null
+++ b/src/index.jsx
@@ -0,0 +1,16 @@
+import * as React from "react";
+import { createRoot } from "react-dom/client";
+import { requestAudioContext } from "./lib/util";
+import Relabi from "./relabi";
+
+document.body.style.backgroundColor = "#111";
+document.body.style.color = "#fff";
+
+requestAudioContext(() => {
+ document.body.innerHTML = '<div id="app"></div>';
+ const relabi = new Relabi();
+ relabi.start();
+
+ const root = createRoot(document.getElementById("app"));
+ root.render(<h1>Relabi generator</h1>);
+});
diff --git a/src/lib/kalimba.js b/src/lib/kalimba.js
new file mode 100644
index 0000000..a8e0c0e
--- /dev/null
+++ b/src/lib/kalimba.js
@@ -0,0 +1,49 @@
+import * as Tone from "tone";
+import { choice } from "./util";
+import output from "./output";
+
+const player_count = 4;
+let player_index = 0;
+
+const samples = [
+ { root: 226, fn: "samples/380737__cabled-mess__sansula-01-a-raw.mp3" },
+ { root: 267, fn: "samples/380736__cabled-mess__sansula-02-c-raw.mp3" },
+ { root: 340, fn: "samples/380735__cabled-mess__sansula-03-e-raw.mp3" },
+ { root: 452, fn: "samples/380733__cabled-mess__sansula-06-a-02-raw.mp3" },
+ // { root: 507, fn: 'samples/380734__cabled-mess__sansula-07-b-h-raw.wav', },
+ // { root: 535, fn: 'samples/380731__cabled-mess__sansula-08-c-raw.wav', },
+ // { root: 671, fn: 'samples/380732__cabled-mess__sansula-09-e-raw.wav', },
+];
+
+samples.forEach((sample) => {
+ sample.players = [];
+ sample.index = -1;
+ for (let i = 0; i < player_count; i++) {
+ let fn = sample.fn;
+ if (window.location.href.match(/asdf.us/)) {
+ fn = "//asdf.us/kalimba/" + fn;
+ }
+ let player = new Tone.Player({
+ url: fn,
+ retrigger: true,
+ playbackRate: 1,
+ });
+ player.connect(output);
+ sample.players.push(player);
+ }
+});
+
+function play(freq, time) {
+ const best = choice(samples);
+ best.index = (best.index + 1) % player_count;
+
+ const player = best.players[best.index];
+ player.playbackRate = freq / best.root;
+ player.start(time || 0);
+}
+
+function pause() {
+ // no-op
+}
+
+export default { play, pause };
diff --git a/src/lib/output.js b/src/lib/output.js
new file mode 100644
index 0000000..88bee5d
--- /dev/null
+++ b/src/lib/output.js
@@ -0,0 +1,8 @@
+import * as Tone from "tone";
+
+const compressor = new Tone.Compressor(-30, 3);
+const gain = new Tone.Gain(0.3);
+compressor.connect(gain);
+gain.toDestination();
+
+export default compressor;
diff --git a/src/lib/startAudioContext.js b/src/lib/startAudioContext.js
new file mode 100644
index 0000000..5a339a2
--- /dev/null
+++ b/src/lib/startAudioContext.js
@@ -0,0 +1,180 @@
+/**
+ * StartAudioContext.js
+ * @author Yotam Mann
+ * @license http://opensource.org/licenses/MIT MIT License
+ * @copyright 2016 Yotam Mann
+ */
+
+(function (root, factory) {
+ if (typeof define === "function" && define.amd) {
+ define([], factory);
+ } else if (typeof module === "object" && module.exports) {
+ module.exports = factory();
+ } else {
+ root.StartAudioContext = factory();
+ }
+})(this, function () {
+ /**
+ * The StartAudioContext object
+ */
+ var StartAudioContext = {
+ /**
+ * The audio context passed in by the user
+ * @type {AudioContext}
+ */
+ context: null,
+ /**
+ * The TapListeners bound to the elements
+ * @type {Array}
+ * @private
+ */
+ _tapListeners: [],
+ /**
+ * Callbacks to invoke when the audio context is started
+ * @type {Array}
+ * @private
+ */
+ _onStarted: [],
+ };
+
+ /**
+ * Set the context
+ * @param {AudioContext} ctx
+ * @returns {StartAudioContext}
+ */
+ StartAudioContext.setContext = function (ctx) {
+ StartAudioContext.context = ctx;
+ return StartAudioContext;
+ };
+
+ /**
+ * Add a tap listener to the audio context
+ * @param {Array|Element|String|jQuery} element
+ * @returns {StartAudioContext}
+ */
+ StartAudioContext.on = function (element) {
+ if (Array.isArray(element) || (NodeList && element instanceof NodeList)) {
+ for (var i = 0; i < element.length; i++) {
+ StartAudioContext.on(element[i]);
+ }
+ } else if (typeof element === "string") {
+ StartAudioContext.on(document.querySelectorAll(element));
+ } else if (element.jquery && typeof element.toArray === "function") {
+ StartAudioContext.on(element.toArray());
+ } else if (Element && element instanceof Element) {
+ //if it's an element, create a TapListener
+ var tap = new TapListener(element, onTap);
+ StartAudioContext._tapListeners.push(tap);
+ }
+ return StartAudioContext;
+ };
+
+ /**
+ * Bind a callback to when the audio context is started.
+ * @param {Function} cb
+ * @return {StartAudioContext}
+ */
+ StartAudioContext.onStarted = function (cb) {
+ //if it's already started, invoke the callback
+ if (StartAudioContext.isStarted()) {
+ cb();
+ } else {
+ StartAudioContext._onStarted.push(cb);
+ }
+ return StartAudioContext;
+ };
+
+ /**
+ * returns true if the context is started
+ * @return {Boolean}
+ */
+ StartAudioContext.isStarted = function () {
+ return (
+ StartAudioContext.context !== null &&
+ StartAudioContext.context.state === "running"
+ );
+ };
+
+ /**
+ * @class Listens for non-dragging tap ends on the given element
+ * @param {Element} element
+ * @internal
+ */
+ var TapListener = function (element) {
+ this._dragged = false;
+
+ this._element = element;
+
+ this._bindedMove = this._moved.bind(this);
+ this._bindedEnd = this._ended.bind(this);
+
+ element.addEventListener("touchmove", this._bindedMove);
+ element.addEventListener("touchend", this._bindedEnd);
+ element.addEventListener("mouseup", this._bindedEnd);
+ };
+
+ /**
+ * drag move event
+ */
+ TapListener.prototype._moved = function (e) {
+ this._dragged = true;
+ };
+
+ /**
+ * tap ended listener
+ */
+ TapListener.prototype._ended = function (e) {
+ if (!this._dragged) {
+ onTap();
+ }
+ this._dragged = false;
+ };
+
+ /**
+ * remove all the bound events
+ */
+ TapListener.prototype.dispose = function () {
+ this._element.removeEventListener("touchmove", this._bindedMove);
+ this._element.removeEventListener("touchend", this._bindedEnd);
+ this._element.removeEventListener("mouseup", this._bindedEnd);
+ this._bindedMove = null;
+ this._bindedEnd = null;
+ this._element = null;
+ };
+
+ /**
+ * Invoked the first time of the elements is tapped.
+ * Creates a silent oscillator when a non-dragging touchend
+ * event has been triggered.
+ */
+ function onTap() {
+ //start the audio context with a silent oscillator
+ if (StartAudioContext.context && !StartAudioContext.isStarted()) {
+ var osc = StartAudioContext.context.createOscillator();
+ var silent = StartAudioContext.context.createGain();
+ silent.gain.value = 0;
+ osc.connect(silent);
+ silent.connect(StartAudioContext.context.destination);
+ var now = StartAudioContext.context.currentTime;
+ osc.start(now);
+ osc.stop(now + 0.5);
+ }
+
+ //dispose all the tap listeners
+ if (StartAudioContext._tapListeners) {
+ for (var i = 0; i < StartAudioContext._tapListeners.length; i++) {
+ StartAudioContext._tapListeners[i].dispose();
+ }
+ StartAudioContext._tapListeners = null;
+ }
+ //the onstarted callbacks
+ if (StartAudioContext._onStarted) {
+ for (var j = 0; j < StartAudioContext._onStarted.length; j++) {
+ StartAudioContext._onStarted[j]();
+ }
+ StartAudioContext._onStarted = null;
+ }
+ }
+
+ return StartAudioContext;
+});
diff --git a/src/lib/util.js b/src/lib/util.js
new file mode 100644
index 0000000..750c5b7
--- /dev/null
+++ b/src/lib/util.js
@@ -0,0 +1,63 @@
+import * as Tone from "tone";
+import StartAudioContext from "./startAudioContext";
+
+const isIphone =
+ navigator.userAgent.match(/iPhone/i) || navigator.userAgent.match(/iPod/i);
+const isIpad = navigator.userAgent.match(/iPad/i);
+const isAndroid = navigator.userAgent.match(/Android/i);
+const isMobile = isIphone || isIpad || isAndroid;
+const isDesktop = !isMobile;
+
+document.body.classList.add(isMobile ? "mobile" : "desktop");
+
+export const browser = { isIphone, isIpad, isMobile, isDesktop };
+export const choice = (a) => a[Math.floor(Math.random() * a.length)];
+export const mod = (n, m) => n - m * Math.floor(n / m);
+export const random = () => Math.random();
+export const rand = (n) => Math.random() * n;
+export const randint = (n) => rand(n) | 0;
+export const randrange = (a, b) => a + rand(b - a);
+export const randsign = () => (random() >= 0.5 ? -1 : 1);
+export const randnullsign = () => {
+ var r = random();
+ return r < 0.333 ? -1 : r < 0.666 ? 0 : 1;
+};
+
+export function requestAudioContext(fn) {
+ const container = document.createElement("div");
+ const button = document.createElement("div");
+ button.innerHTML = "Tap to start - please unmute your phone";
+ Object.assign(container.style, {
+ display: "block",
+ position: "absolute",
+ width: "100%",
+ height: "100%",
+ zIndex: "10000",
+ top: "0px",
+ left: "0px",
+ backgroundColor: "rgba(0, 0, 0, 0.8)",
+ });
+ Object.assign(button.style, {
+ display: "block",
+ position: "absolute",
+ left: "50%",
+ top: "50%",
+ padding: "20px",
+ backgroundColor: "#7F33ED",
+ color: "white",
+ fontFamily: "monospace",
+ borderRadius: "3px",
+ transform: "translate3D(-50%,-50%,0)",
+ textAlign: "center",
+ lineHeight: "1.5",
+ width: "150px",
+ });
+ container.appendChild(button);
+ document.body.appendChild(container);
+ StartAudioContext.setContext(Tone.context);
+ StartAudioContext.on(button);
+ StartAudioContext.onStarted((_) => {
+ container.remove();
+ fn();
+ });
+}
diff --git a/src/relabi/index.js b/src/relabi/index.js
new file mode 100644
index 0000000..867bc0d
--- /dev/null
+++ b/src/relabi/index.js
@@ -0,0 +1,121 @@
+import * as Tone from "tone";
+import kalimba from "../lib/kalimba";
+import { randrange } from "../lib/util";
+
+const TWO_PI = 2 * Math.PI;
+
+/**
+ * Wave functions
+ */
+const WAVE_FUNCTIONS = {
+ sine: Math.cos,
+ triangle: (time) =>
+ (4 / TWO_PI) *
+ Math.abs(
+ ((((time - TWO_PI / 4) % TWO_PI) + TWO_PI) % TWO_PI) - TWO_PI / 2
+ ) -
+ 1,
+ square: (time) => (time % TWO_PI < Math.PI ? 1 : -1),
+ saw: (time) => ((time % TWO_PI) - Math.PI) / Math.PI,
+};
+
+class Relabi {
+ /**
+ * Initialize relabi generator
+ */
+ constructor() {
+ this.updateTime = 1.0;
+ this.steps = 100;
+ this.waves = [
+ { type: "sine", frequency: randrange(0.5, 2) },
+ { type: "sine", frequency: randrange(0.5, 2) },
+ { type: "sine", frequency: randrange(1, 10) },
+ { type: "sine", frequency: randrange(5, 10) },
+ ];
+ this.bounds = [-0.5, 0.5];
+ this.frequencies = [220, (220 * 3) / 2, 440, (440 * 3) / 2];
+ }
+
+ /**
+ * Start the generator
+ */
+ start() {
+ console.log("Start Relabi");
+ this.stop();
+ this.clock = new Tone.Clock((time) => this.step(time), this.updateTime);
+ this.clock.start();
+ }
+
+ /**
+ * Stop the generator and reset it
+ */
+ stop() {
+ if (this.clock) {
+ this.clock.stop();
+ this.clock.dispose();
+ }
+ }
+
+ /**
+ * Generate relabi events
+ */
+ step(time) {
+ const waveCount = this.waves.length;
+ const boundsCount = this.bounds.length;
+ let previousValue = this.previousValue;
+ let index;
+ let step;
+ let value;
+ let noteCount = 0;
+
+ // Generate several events per second
+ for (step = 0; step < this.steps; step += 1) {
+ // Time offset for this event
+ const offset = time + (step * this.updateTime) / this.steps;
+
+ // Initialize value
+ value = 0;
+
+ // Compute the wave functions for this event
+ for (index = 0; index < waveCount; index += 1) {
+ const wave = this.waves[index];
+ value += WAVE_FUNCTIONS[wave.type](offset * wave.frequency);
+ }
+
+ // Scale to [-1, 1]
+ value /= waveCount / Math.PI;
+
+ // Compute whether we crossed a boundary, and which direction
+ for (index = 0; index < boundsCount; index += 1) {
+ const bound = this.bounds[index];
+ if (value < bound && bound < previousValue) {
+ // Going down
+ this.trigger(offset, index * 2);
+ noteCount += 1;
+ } else if (value > bound && bound > previousValue) {
+ // Going up
+ this.trigger(offset, index * 2 + 1);
+ noteCount += 1;
+ }
+ }
+
+ // Update the previous value
+ previousValue = value;
+ }
+
+ // Store the latest value
+ this.previousValue = value;
+
+ console.log(`Tick ${Math.floor(time)}, played ${noteCount} notes`);
+ }
+
+ /**
+ * Trigger an event
+ */
+ trigger(time, index) {
+ // console.log("trigger index", index, time);
+ kalimba.play(this.frequencies[index], time);
+ }
+}
+
+export default Relabi;