diff options
| author | julian laplace <julescarbon@gmail.com> | 2023-05-08 23:49:29 +0200 |
|---|---|---|
| committer | julian laplace <julescarbon@gmail.com> | 2023-05-08 23:49:29 +0200 |
| commit | b9dc2f677e7c3021aeeea6b1ab609a9b40806b48 (patch) | |
| tree | c49c179515b2dce0784205e1d707f99d9ba6d0ee /src | |
| parent | cee4e2e53e1d7df114960daa78e7fd7b38e165b9 (diff) | |
v1 of relabi generator
Diffstat (limited to 'src')
| -rw-r--r-- | src/index.js | 1 | ||||
| -rw-r--r-- | src/index.jsx | 16 | ||||
| -rw-r--r-- | src/lib/kalimba.js | 49 | ||||
| -rw-r--r-- | src/lib/output.js | 8 | ||||
| -rw-r--r-- | src/lib/startAudioContext.js | 180 | ||||
| -rw-r--r-- | src/lib/util.js | 63 | ||||
| -rw-r--r-- | src/relabi/index.js | 121 |
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; |
