diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs
index d83e271f..b8edd51c 100644
--- a/packages/core/controls.mjs
+++ b/packages/core/controls.mjs
@@ -109,6 +109,32 @@ const generic_params = [
*/
['attack', 'att'],
+ /**
+ * Sets the Frequency Modulation Harmonicity Ratio.
+ * Controls the timbre of the sound.
+ * Whole numbers and simple ratios sound more natural,
+ * while decimal numbers and complex ratios sound metallic.
+ *
+ * @name fmh
+ * @param {number | Pattern} harmonicity
+ * @example
+ * note("c e g b").fm(4).fmh("<1 2 1.5 1.61>")
+ *
+ */
+ [['fmh', 'fmi'], 'fmh'],
+ /**
+ * Sets the Frequency Modulation of the synth.
+ * Controls the modulation index, which defines the brightness of the sound.
+ *
+ * @name fm
+ * @param {number | Pattern} brightness modulation index
+ * @synonyms fmi
+ * @example
+ * note("c e g b").fm("<0 1 2 8 32>")
+ *
+ */
+ [['fmi', 'fmh'], 'fm'],
+
/**
* Select the sound bank to use. To be used together with `s`. The bank name (+ "_") will be prepended to the value of `s`.
*
diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs
index 29abf21b..fa86f629 100644
--- a/packages/midi/midi.mjs
+++ b/packages/midi/midi.mjs
@@ -6,9 +6,8 @@ This program is free software: you can redistribute it and/or modify it under th
import * as _WebMidi from 'webmidi';
import { Pattern, isPattern, logger } from '@strudel.cycles/core';
-import { getAudioContext } from '@strudel.cycles/webaudio';
import { noteToMidi } from '@strudel.cycles/core';
-
+import { Note } from 'webmidi';
// if you use WebMidi from outside of this package, make sure to import that instance:
export const { WebMidi } = _WebMidi;
@@ -16,12 +15,28 @@ function supportsMidi() {
return typeof navigator.requestMIDIAccess === 'function';
}
-export function enableWebMidi(options = {}) {
- const { onReady, onConnected, onDisconnected } = options;
+function getMidiDeviceNamesString(outputs) {
+ return outputs.map((o) => `'${o.name}'`).join(' | ');
+}
+export function enableWebMidi(options = {}) {
+ const { onReady, onConnected, onDisconnected, onEnabled } = options;
+ if (WebMidi.enabled) {
+ return;
+ }
if (!supportsMidi()) {
throw new Error('Your Browser does not support WebMIDI.');
}
+ WebMidi.addListener('connected', () => {
+ onConnected?.(WebMidi);
+ });
+ WebMidi.addListener('enabled', () => {
+ onEnabled?.(WebMidi);
+ });
+ // Reacting when a device becomes unavailable
+ WebMidi.addListener('disconnected', (e) => {
+ onDisconnected?.(WebMidi, e);
+ });
return new Promise((resolve, reject) => {
if (WebMidi.enabled) {
// if already enabled, just resolve WebMidi
@@ -32,13 +47,6 @@ export function enableWebMidi(options = {}) {
if (err) {
reject(err);
}
- WebMidi.addListener('connected', (e) => {
- onConnected?.(WebMidi);
- });
- // Reacting when a device becomes unavailable
- WebMidi.addListener('disconnected', (e) => {
- onDisconnected?.(WebMidi, e);
- });
onReady?.(WebMidi);
resolve(WebMidi);
});
@@ -47,8 +55,6 @@ export function enableWebMidi(options = {}) {
// const outputByName = (name: string) => WebMidi.getOutputByName(name);
const outputByName = (name) => WebMidi.getOutputByName(name);
-let midiReady;
-
// output?: string | number, outputs: typeof WebMidi.outputs
function getDevice(output, outputs) {
if (!outputs.length) {
@@ -60,29 +66,20 @@ function getDevice(output, outputs) {
if (typeof output === 'string') {
return outputByName(output);
}
- return outputs[0];
+ // attempt to default to first IAC device if none is specified
+ const IACOutput = outputs.find((output) => output.name.includes('IAC'));
+ const device = IACOutput ?? outputs[0];
+ if (!device) {
+ throw new Error(
+ `🔌 MIDI device '${output ? output : ''}' not found. Use one of ${getMidiDeviceNamesString(WebMidi.outputs)}`,
+ );
+ }
+
+ return IACOutput ?? outputs[0];
}
// Pattern.prototype.midi = function (output: string | number, channel = 1) {
Pattern.prototype.midi = function (output) {
- if (!supportsMidi()) {
- throw new Error(`🎹 WebMidi is not enabled. Supported Browsers: https://caniuse.com/?search=webmidi`);
- }
- /* await */ enableWebMidi({
- onConnected: ({ outputs }) =>
- logger(`Midi device connected! Available: ${outputs.map((o) => `'${o.name}'`).join(', ')}`),
- onDisconnected: ({ outputs }) =>
- logger(`Midi device disconnected! Available: ${outputs.map((o) => `'${o.name}'`).join(', ')}`),
- onReady: ({ outputs }) => {
- const device = getDevice(output, outputs);
- const otherOutputs = outputs
- .filter((o) => o.name !== device.name)
- .map((o) => `'${o.name}'`)
- .join(' | ');
- midiReady = true;
- logger(`Midi connected! Using "${device.name}". ${otherOutputs ? `Also available: ${otherOutputs}` : ''}`);
- },
- });
if (isPattern(output)) {
throw new Error(
`.midi does not accept Pattern input. Make sure to pass device name with single quotes. Example: .midi('${
@@ -90,35 +87,43 @@ Pattern.prototype.midi = function (output) {
}')`,
);
}
- return this.onTrigger((time, hap) => {
- if (!midiReady) {
+
+ enableWebMidi({
+ onEnabled: ({ outputs }) => {
+ const device = getDevice(output, outputs);
+ const otherOutputs = outputs.filter((o) => o.name !== device.name);
+ logger(
+ `Midi enabled! Using "${device.name}". ${
+ otherOutputs?.length ? `Also available: ${getMidiDeviceNamesString(otherOutputs)}` : ''
+ }`,
+ );
+ },
+ onDisconnected: ({ outputs }) =>
+ logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
+ });
+
+ return this.onTrigger((time, hap, currentTime, cps) => {
+ if (!WebMidi.enabled) {
return;
}
const device = getDevice(output, WebMidi.outputs);
- if (!device) {
- throw new Error(
- `🔌 MIDI device '${output ? output : ''}' not found. Use one of ${WebMidi.outputs
- .map((o) => `'${o.name}'`)
- .join(' | ')}`,
- );
- }
hap.ensureObjectValue();
- // calculate time
- const timingOffset = WebMidi.time - getAudioContext().getOutputTimestamp().contextTime * 1000;
- time = time * 1000 + timingOffset;
+ const offset = (time - currentTime) * 1000;
+ // passing a string with a +num into the webmidi api adds an offset to the current time https://webmidijs.org/api/classes/Output
+ const timeOffsetString = `+${offset}`;
// destructure value
const { note, nrpnn, nrpv, ccn, ccv, midichan = 1 } = hap.value;
const velocity = hap.context?.velocity ?? 0.9; // TODO: refactor velocity
- const duration = hap.duration.valueOf() * 1000 - 5;
+ // note off messages will often a few ms arrive late, try to prevent glitching by subtracting from the duration length
+ const duration = Math.floor(hap.duration.valueOf() * 1000 - 10);
if (note != null) {
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
- device.playNote(midiNumber, midichan, {
- time,
- duration,
- attack: velocity,
+ const midiNote = new Note(midiNumber, { attack: velocity, duration });
+ device.playNote(midiNote, midichan, {
+ time: timeOffsetString,
});
}
if (ccv && ccn) {
@@ -129,7 +134,7 @@ Pattern.prototype.midi = function (output) {
throw new Error('expected ccn to be a number or a string');
}
const scaled = Math.round(ccv * 127);
- device.sendControlChange(ccn, scaled, midichan, { time });
+ device.sendControlChange(ccn, scaled, midichan, { time: timeOffsetString });
}
});
};
diff --git a/packages/superdough/package.json b/packages/superdough/package.json
index e0ab452a..d82448d6 100644
--- a/packages/superdough/package.json
+++ b/packages/superdough/package.json
@@ -1,6 +1,6 @@
{
"name": "superdough",
- "version": "0.9.4",
+ "version": "0.9.5",
"description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.",
"main": "index.mjs",
"type": "module",
diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs
index 73174f65..57317133 100644
--- a/packages/superdough/synth.mjs
+++ b/packages/superdough/synth.mjs
@@ -1,14 +1,39 @@
import { midiToFreq, noteToMidi } from './util.mjs';
-import { registerSound } from './superdough.mjs';
+import { registerSound, getAudioContext } from './superdough.mjs';
import { getOscillator, gainNode, getEnvelope } from './helpers.mjs';
+const mod = (freq, range = 1, type = 'sine') => {
+ const ctx = getAudioContext();
+ const osc = ctx.createOscillator();
+ osc.type = type;
+ osc.frequency.value = freq;
+ osc.start();
+ const g = new GainNode(ctx, { gain: range });
+ osc.connect(g); // -range, range
+ return { node: g, stop: (t) => osc.stop(t) };
+};
+
+const fm = (osc, harmonicityRatio, modulationIndex, wave = 'sine') => {
+ const carrfreq = osc.frequency.value;
+ const modfreq = carrfreq * harmonicityRatio;
+ const modgain = modfreq * modulationIndex;
+ return mod(modfreq, modgain, wave);
+};
+
export function registerSynthSounds() {
['sine', 'square', 'triangle', 'sawtooth'].forEach((wave) => {
registerSound(
wave,
(t, value, onended) => {
// destructure adsr here, because the default should be different for synths and samples
- const { attack = 0.001, decay = 0.05, sustain = 0.6, release = 0.01 } = value;
+ const {
+ attack = 0.001,
+ decay = 0.05,
+ sustain = 0.6,
+ release = 0.01,
+ fmh: fmHarmonicity = 1,
+ fmi: fmModulationIndex,
+ } = value;
let { n, note, freq } = value;
// with synths, n and note are the same thing
n = note || n || 36;
@@ -22,6 +47,13 @@ export function registerSynthSounds() {
// maybe pull out the above frequency resolution?? (there is also getFrequency but it has no default)
// make oscillator
const { node: o, stop } = getOscillator({ t, s: wave, freq });
+
+ let stopFm;
+ if (fmModulationIndex) {
+ const { node: modulator, stop } = fm(o, fmHarmonicity, fmModulationIndex);
+ modulator.connect(o.frequency);
+ stopFm = stop;
+ }
const g = gainNode(0.3);
// envelope
const { node: envelope, stop: releaseEnvelope } = getEnvelope(attack, decay, sustain, release, 1, t);
@@ -34,7 +66,9 @@ export function registerSynthSounds() {
node: o.connect(g).connect(envelope),
stop: (releaseTime) => {
releaseEnvelope(releaseTime);
- stop(releaseTime + release);
+ let end = releaseTime + release;
+ stop(end);
+ stopFm?.(end);
},
};
},
diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap
index 967d6b5d..ee937e2b 100644
--- a/test/__snapshots__/examples.test.mjs.snap
+++ b/test/__snapshots__/examples.test.mjs.snap
@@ -1804,6 +1804,48 @@ exports[`runs examples > example "floor" example index 0 1`] = `
]
`;
+exports[`runs examples > example "fm" example index 0 1`] = `
+[
+ "[ 0/1 → 1/4 | note:c fmi:0 ]",
+ "[ 1/4 → 1/2 | note:e fmi:0 ]",
+ "[ 1/2 → 3/4 | note:g fmi:0 ]",
+ "[ 3/4 → 1/1 | note:b fmi:0 ]",
+ "[ 1/1 → 5/4 | note:c fmi:1 ]",
+ "[ 5/4 → 3/2 | note:e fmi:1 ]",
+ "[ 3/2 → 7/4 | note:g fmi:1 ]",
+ "[ 7/4 → 2/1 | note:b fmi:1 ]",
+ "[ 2/1 → 9/4 | note:c fmi:2 ]",
+ "[ 9/4 → 5/2 | note:e fmi:2 ]",
+ "[ 5/2 → 11/4 | note:g fmi:2 ]",
+ "[ 11/4 → 3/1 | note:b fmi:2 ]",
+ "[ 3/1 → 13/4 | note:c fmi:8 ]",
+ "[ 13/4 → 7/2 | note:e fmi:8 ]",
+ "[ 7/2 → 15/4 | note:g fmi:8 ]",
+ "[ 15/4 → 4/1 | note:b fmi:8 ]",
+]
+`;
+
+exports[`runs examples > example "fmh" example index 0 1`] = `
+[
+ "[ 0/1 → 1/4 | note:c fmi:4 fmh:1 ]",
+ "[ 1/4 → 1/2 | note:e fmi:4 fmh:1 ]",
+ "[ 1/2 → 3/4 | note:g fmi:4 fmh:1 ]",
+ "[ 3/4 → 1/1 | note:b fmi:4 fmh:1 ]",
+ "[ 1/1 → 5/4 | note:c fmi:4 fmh:2 ]",
+ "[ 5/4 → 3/2 | note:e fmi:4 fmh:2 ]",
+ "[ 3/2 → 7/4 | note:g fmi:4 fmh:2 ]",
+ "[ 7/4 → 2/1 | note:b fmi:4 fmh:2 ]",
+ "[ 2/1 → 9/4 | note:c fmi:4 fmh:1.5 ]",
+ "[ 9/4 → 5/2 | note:e fmi:4 fmh:1.5 ]",
+ "[ 5/2 → 11/4 | note:g fmi:4 fmh:1.5 ]",
+ "[ 11/4 → 3/1 | note:b fmi:4 fmh:1.5 ]",
+ "[ 3/1 → 13/4 | note:c fmi:4 fmh:1.61 ]",
+ "[ 13/4 → 7/2 | note:e fmi:4 fmh:1.61 ]",
+ "[ 7/2 → 15/4 | note:g fmi:4 fmh:1.61 ]",
+ "[ 15/4 → 4/1 | note:b fmi:4 fmh:1.61 ]",
+]
+`;
+
exports[`runs examples > example "focus" example index 0 1`] = `
[
"[ 0/1 → 1/8 | s:sd ]",
diff --git a/website/src/pages/learn/synths.mdx b/website/src/pages/learn/synths.mdx
index 3f808e66..6ebf2613 100644
--- a/website/src/pages/learn/synths.mdx
+++ b/website/src/pages/learn/synths.mdx
@@ -28,4 +28,14 @@ The power of patterns allows us to sequence any _param_ independently:
Now we not only pattern the notes, but the sound as well!
`sawtooth` `square` and `triangle` are the basic waveforms available in `s`.
+## FM Synthesis
+
+### fm
+
+
+
+### fmh
+
+
+
Next up: [Audio Effects](/learn/effects)...