diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 92a40db4..044cfa96 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -114,7 +114,7 @@ export const { n } = registerControl('n'); * * - a letter (a-g or A-G) * - optional accidentals (b or #) - * - optional octave number (0-9). Defaults to 3 + * - optional (possibly negative) octave number (0-9). Defaults to 3 * * Examples of valid note names: `c`, `bb`, `Bb`, `f#`, `c3`, `A4`, `Eb2`, `c#5` * @@ -127,6 +127,8 @@ export const { n } = registerControl('n'); * note("c4 a4 f4 e4") * @example * note("60 69 65 64") + * @example + * note("fbb1 a#0 cbbb-1 e##-2").sound("saw") */ export const { note } = registerControl(['note', 'n']); @@ -142,8 +144,8 @@ export const { note } = registerControl(['note', 'n']); */ export const { accelerate } = registerControl('accelerate'); /** - * * Sets the velocity from 0 to 1. Is multiplied together with gain. + * * @name velocity * @example * s("hh*8") @@ -255,7 +257,7 @@ export const { fmenv } = registerControl('fmenv'); export const { fmattack } = registerControl('fmattack'); /** - * waveform of the fm modulator + * Waveform of the fm modulator * * @name fmwave * @param {number | Pattern} wave waveform @@ -389,7 +391,7 @@ export const { bandf, bpf, bp } = registerControl(['bandf', 'bandq', 'bpenv'], ' // ['bpq'], export const { bandq, bpq } = registerControl('bandq', 'bpq'); /** - * a pattern of numbers from 0 to 1. Skips the beginning of each sample, e.g. `0.25` to cut off the first quarter from each sample. + * A pattern of numbers from 0 to 1. Skips the beginning of each sample, e.g. `0.25` to cut off the first quarter from each sample. * * @memberof Pattern * @name begin @@ -450,7 +452,7 @@ export const { loopBegin, loopb } = registerControl('loopBegin', 'loopb'); */ export const { loopEnd, loope } = registerControl('loopEnd', 'loope'); /** - * bit crusher effect. + * Bit crusher effect. * * @name crush * @param {number | Pattern} depth between 1 (for drastic reduction in bit-depth) to 16 (for barely no reduction). @@ -461,7 +463,7 @@ export const { loopEnd, loope } = registerControl('loopEnd', 'loope'); // ['clhatdecay'], export const { crush } = registerControl('crush'); /** - * fake-resampling for lowering the sample rate. Caution: This effect seems to only work in chromium based browsers + * Fake-resampling for lowering the sample rate. Caution: This effect seems to only work in chromium based browsers * * @name coarse * @param {number | Pattern} factor 1 for original 2 for half, 3 for a third and so on. @@ -472,7 +474,7 @@ export const { crush } = registerControl('crush'); export const { coarse } = registerControl('coarse'); /** - * modulate the amplitude of a sound with a continuous waveform + * Modulate the amplitude of a sound with a continuous waveform * * @name tremolo * @synonyms trem @@ -484,7 +486,7 @@ export const { coarse } = registerControl('coarse'); export const { tremolo } = registerControl(['tremolo', 'tremolodepth', 'tremoloskew', 'tremolophase'], 'trem'); /** - * modulate the amplitude of a sound with a continuous waveform + * Modulate the amplitude of a sound with a continuous waveform * * @name tremolosync * @synonyms tremsync @@ -499,7 +501,7 @@ export const { tremolosync } = registerControl( ); /** - * depth of amplitude modulation + * Depth of amplitude modulation * * @name tremolodepth * @synonyms tremdepth @@ -510,7 +512,7 @@ export const { tremolosync } = registerControl( */ export const { tremolodepth } = registerControl('tremolodepth', 'tremdepth'); /** - * alter the shape of the modulation waveform + * Alter the shape of the modulation waveform * * @name tremoloskew * @synonyms tremskew @@ -522,7 +524,7 @@ export const { tremolodepth } = registerControl('tremolodepth', 'tremdepth'); export const { tremoloskew } = registerControl('tremoloskew', 'tremskew'); /** - * alter the phase of the modulation waveform + * Alter the phase of the modulation waveform * * @name tremolophase * @synonyms tremphase @@ -534,7 +536,7 @@ export const { tremoloskew } = registerControl('tremoloskew', 'tremskew'); export const { tremolophase } = registerControl('tremolophase', 'tremphase'); /** - * shape of amplitude modulation + * Shape of amplitude modulation * * @name tremoloshape * @synonyms tremshape @@ -545,7 +547,7 @@ export const { tremolophase } = registerControl('tremolophase', 'tremphase'); */ export const { tremoloshape } = registerControl('tremoloshape', 'tremshape'); /** - * filter overdrive for supported filter types + * Filter overdrive for supported filter types * * @name drive * @param {number | Pattern} amount @@ -556,7 +558,9 @@ export const { tremoloshape } = registerControl('tremoloshape', 'tremshape'); export const { drive } = registerControl('drive'); /** - * modulate the amplitude of an orbit to create a "sidechain" like effect + * Modulate the amplitude of an orbit to create a "sidechain" like effect. + * + * Can be applied to multiple orbits with the ':' mininotation, e.g. `duckorbit("2:3")` * * @name duckorbit * @synonyms duck @@ -564,30 +568,77 @@ export const { drive } = registerControl('drive'); * @example * $: n(run(16)).scale("c:minor:pentatonic").s("sawtooth").delay(.7).orbit(2) * $: s("bd:4!4").beat("0,4,8,11,14",16).duckorbit(2).duckattack(0.2).duckdepth(1) + * @example + * $: n(run(16)).scale("c:minor:pentatonic").s("sawtooth").delay(.7).orbit(2) + * $: s("hh*16").orbit(3) + * $: s("bd:4!4").beat("0,4,8,11,14",16).duckorbit("2:3").duckattack(0.2).duckdepth(1) * */ export const { duck } = registerControl('duckorbit', 'duck'); /** - * the amount of ducking applied to target orbit + * The amount of ducking applied to target orbit + * + * Can vary across orbits with the ':' mininotation, e.g. `duckdepth("0.3:0.1")`. + * Note: this requires first applying the effect to multiple orbits with e.g. `duckorbit("2:3")`. * * @name duckdepth * @param {number | Pattern} depth depth of modulation from 0 to 1 * @example * stack( n(run(8)).scale("c:minor").s("sawtooth").delay(.7).orbit(2), s("bd:4!4").beat("0,4,8,11,14",16).duckorbit(2).duckattack(0.2).duckdepth("<1 .9 .6 0>")) + * @example + * $: n(run(16)).scale("c:minor:pentatonic").s("sawtooth").delay(.7).orbit(2) + * $: s("hh*16").orbit(3) + * $: s("bd:4!4").beat("0,4,8,11,14",16).duckorbit("2:3").duckattack(0.2).duckdepth("1:0.5") * */ - export const { duckdepth } = registerControl('duckdepth'); /** - * the attack time of the duck effect + * The time required for the ducked signal(s) to reach their lowest volume. + * Can be used to prevent clicking or for creative rhythmic effects. + * + * Can vary across orbits with the ':' mininotation, e.g. `duckonset("0:0.003")`. + * Note: this requires first applying the effect to multiple orbits with e.g. `duckorbit("2:3")`. + * + * @name duckonset + * @synonyms duckons + * + * @param {number | Pattern} time The onset time in seconds + * @example + * // Clicks + * sound: freq("63.2388").s("sine").orbit(2).gain(4) + * duckerWithClick: s("bd*4").duckorbit(2).duckattack(0.3).duckonset(0).postgain(0) + * @example + * // No clicks + * sound: freq("63.2388").s("sine").orbit(2).gain(4) + * duckerWithoutClick: s("bd*4").duckorbit(2).duckattack(0.3).duckonset(0.01).postgain(0) + * @example + * // Rhythmic + * noise: s("pink").distort("2:1").orbit(4) // used rhythmically with 0.3 onset below + * hhat: s("hh*16").orbit(7) + * ducker: s("bd*4").bank("tr909").duckorbit("4:7").duckonset("0.3:0.003").duckattack(0.25) + * + */ +export const { duckonset } = registerControl('duckonset', 'duckons'); + +/** + * The time required for the ducked signal(s) to return to their normal volume. + * + * Can vary across orbits with the ':' mininotation, e.g. `duckonset("0:0.003")`. + * Note: this requires first applying the effect to multiple orbits with e.g. `duckorbit("2:3")`. * * @name duckattack * @synonyms duckatt - * @param {number | Pattern} time + * + * @param {number | Pattern} time The attack time in seconds * @example - * stack( n(run(8)).scale("c:minor").s("sawtooth").delay(.7).orbit(2), s("bd:4!4").beat("0,4,8,11,14",16).duckorbit(2).duckattack("<0.2 0 0.4>").duckdepth(1)) + * sound: n(run(8)).scale("c:minor").s("sawtooth").delay(.7).orbit(2) + * ducker: s("bd:4!4").beat("0,4,8,11,14",16).duckorbit(2).duckattack("<0.2 0 0.4>").duckdepth(1) + * @example + * moreduck: n(run(8)).scale("c:minor").s("sawtooth").delay(.7).orbit(2) + * lessduck: s("hh*16").orbit(5) + * ducker: s("bd:4!4").beat("0,4,8,11,14",16).duckorbit("2:5").duckattack("0.4:0.1") * */ export const { duckattack } = registerControl('duckattack', 'duckatt'); @@ -632,7 +683,7 @@ export const { byteBeatStartTime, bbst } = registerControl('byteBeatStartTime', export const { channels, ch } = registerControl('channels', 'ch'); /** - * controls the pulsewidth of the pulse oscillator + * Controls the pulsewidth of the pulse oscillator * * @name pw * @param {number | Pattern} pulsewidth @@ -644,7 +695,7 @@ export const { channels, ch } = registerControl('channels', 'ch'); export const { pw } = registerControl(['pw', 'pwrate', 'pwsweep']); /** - * controls the lfo rate for the pulsewidth of the pulse oscillator + * Controls the lfo rate for the pulsewidth of the pulse oscillator * * @name pwrate * @param {number | Pattern} rate @@ -656,7 +707,7 @@ export const { pw } = registerControl(['pw', 'pwrate', 'pwsweep']); export const { pwrate } = registerControl('pwrate'); /** - * controls the lfo sweep for the pulsewidth of the pulse oscillator + * Controls the lfo sweep for the pulsewidth of the pulse oscillator * * @name pwsweep * @param {number | Pattern} sweep @@ -697,7 +748,7 @@ export const { phaserrate, ph, phaser } = registerControl( export const { phasersweep, phs } = registerControl('phasersweep', 'phs'); /** - * The center frequency of the phaser in HZ. Defaults to 1000 + * The center frequency of the phaser in HZ. Defaults to 1000 * * @name phasercenter * @synonyms phc @@ -725,7 +776,7 @@ export const { phasercenter, phc } = registerControl('phasercenter', 'phc'); export const { phaserdepth, phd, phasdp } = registerControl('phaserdepth', 'phd', 'phasdp'); /** - * choose the channel the pattern is sent to in superdirt + * Choose the channel the pattern is sent to in superdirt * * @name channel * @param {number | Pattern} channel channel number diff --git a/packages/core/util.mjs b/packages/core/util.mjs index 2e7c6e02..ef3f1e96 100644 --- a/packages/core/util.mjs +++ b/packages/core/util.mjs @@ -8,12 +8,12 @@ import { logger } from './logger.mjs'; // returns true if the given string is a note export const isNoteWithOctave = (name) => /^[a-gA-G][#bs]*[0-9]$/.test(name); -export const isNote = (name) => /^[a-gA-G][#bsf]*[0-9]?$/.test(name); +export const isNote = (name) => /^[a-gA-G][#bsf]*-?[0-9]?$/.test(name); export const tokenizeNote = (note) => { if (typeof note !== 'string') { return []; } - const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)([0-9]*)$/)?.slice(1) || []; + const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)(-?[0-9]*)$/)?.slice(1) || []; if (!pc) { return []; } diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 6072b633..f1f308a7 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -349,18 +349,17 @@ function getDelay(orbit, delaytime, delayfeedback, t) { //logger(`delayfeedback was clamped to ${maxfeedback} to save your ears`); } delayfeedback = clamp(delayfeedback, 0, 0.98); - if (!orbits[orbit].delayNode) { + let delayNode = orbits[orbit].delayNode; + if (delayNode === undefined) { const ac = getAudioContext(); - const dly = ac.createFeedbackDelay(1, delaytime, delayfeedback); - dly.start?.(t); // for some reason, this throws when audion extension is installed.. - connectToOrbit(dly, orbit); - orbits[orbit].delayNode = dly; + delayNode = ac.createFeedbackDelay(1, delaytime, delayfeedback); + delayNode.start?.(t); // for some reason, this throws when audion extension is installed.. + connectToOrbit(delayNode, orbit); + orbits[orbit].delayNode = delayNode; } - orbits[orbit].delayNode.delayTime.value !== delaytime && - orbits[orbit].delayNode.delayTime.setValueAtTime(delaytime, t); - orbits[orbit].delayNode.feedback.value !== delayfeedback && - orbits[orbit].delayNode.feedback.setValueAtTime(delayfeedback, t); - return orbits[orbit].delayNode; + delayNode.delayTime.value !== delaytime && delayNode.delayTime.setValueAtTime(delaytime, t); + delayNode.feedback.value !== delayfeedback && delayNode.feedback.setValueAtTime(delayfeedback, t); + return delayNode; } export function getLfo(audioContext, begin, end, properties = {}) { @@ -416,42 +415,60 @@ function getFilterType(ftype) { return typeof ftype === 'number' ? filterTypes[Math.floor(_mod(ftype, filterTypes.length))] : ftype; } -//type orbit { -// gain: number, -// reverbNode: reverbNode -// delayNode: delayNode -//} +// type orbit { +// output: GainNode, +// reverbNode: ConvolverNode +// delayNode: FeedbackDelayNode +// } let orbits = {}; function connectToOrbit(node, orbit) { if (orbits[orbit] == null) { errorLogger(new Error('target orbit does not exist'), 'superdough'); } - node.connect(orbits[orbit].gain); + node.connect(orbits[orbit].output); } function setOrbit(audioContext, orbit, channels) { if (orbits[orbit] == null) { orbits[orbit] = { - gain: new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }), + // Setup output node through which all audio filters prior to hitting + // the destination (and thus allows for global volume automation) + output: new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }), }; - connectToDestination(orbits[orbit].gain, channels); + connectToDestination(orbits[orbit].output, channels); } } -function duckOrbit(audioContext, targetOrbit, t, attacktime = 0.1, duckdepth = 1) { - const targetArr = [targetOrbit].flat(); - targetArr.forEach((target) => { +function duckOrbit(audioContext, targetOrbit, t, onsettime = 0, attacktime = 0.1, duckdepth = 1) { + const targetArr = [targetOrbit].flat(); + const onsetArr = [onsettime].flat(); + const attackArr = [attacktime].flat(); + const depthArr = [duckdepth].flat(); + + targetArr.forEach((target, idx) => { if (orbits[target] == null) { errorLogger(new Error(`duck target orbit ${target} does not exist`), 'superdough'); return; } + const onset = onsetArr[idx] ?? onsetArr[0]; + const attack = Math.max(attackArr[idx] ?? attackArr[0], 0.002); + const depth = depthArr[idx] ?? depthArr[0]; + const gainParam = orbits[target].output.gain; webAudioTimeout( audioContext, () => { - orbits[target].gain.gain.cancelScheduledValues(t); - const currVal = orbits[target].gain.gain.value; - orbits[target].gain.gain.linearRampToValueAtTime(clamp(1 - Math.pow(duckdepth, 0.5), 0.01, currVal), t); - orbits[target].gain.gain.exponentialRampToValueAtTime(1, t + Math.max(0.002, attacktime)); + const now = audioContext.currentTime; + + // cancelScheduledValues and setValueAtTime together emulate cancelAndHoldAtTime + // on browsers which lack that method + const currVal = gainParam.value; + gainParam.cancelScheduledValues(now); + gainParam.setValueAtTime(currVal, now); + + const t0 = Math.max(t, now); // guard against now > t + const duckedVal = clamp(1 - Math.sqrt(depth), 0.01, currVal); + gainParam.exponentialRampToValueAtTime(duckedVal, t0 + onset); + gainParam.exponentialRampToValueAtTime(1, t0 + onset + attack); }, 0, t - 0.01, @@ -462,30 +479,31 @@ function duckOrbit(audioContext, targetOrbit, t, attacktime = 0.1, duckdepth = 1 let hasChanged = (now, before) => now !== undefined && now !== before; function getReverb(orbit, duration, fade, lp, dim, ir, irspeed, irbegin) { // If no reverb has been created for a given orbit, create one - if (!orbits[orbit].reverbNode) { + let reverbNode = orbits[orbit].reverbNode; + if (reverbNode === undefined) { const ac = getAudioContext(); - const reverb = ac.createReverb(duration, fade, lp, dim, ir, irspeed, irbegin); - connectToOrbit(reverb, orbit); - orbits[orbit].reverbNode = reverb; + reverbNode = ac.createReverb(duration, fade, lp, dim, ir, irspeed, irbegin); + connectToOrbit(reverbNode, orbit); + orbits[orbit].reverbNode = reverbNode; } if ( - hasChanged(duration, orbits[orbit].reverbNode.duration) || - hasChanged(fade, orbits[orbit].reverbNode.fade) || - hasChanged(lp, orbits[orbit].reverbNode.lp) || - hasChanged(dim, orbits[orbit].reverbNode.dim) || - hasChanged(irspeed, orbits[orbit].reverbNode.irspeed) || - hasChanged(irbegin, orbits[orbit].reverbNode.irbegin) || - orbits[orbit].reverbNode.ir !== ir + hasChanged(duration, reverbNode.duration) || + hasChanged(fade, reverbNode.fade) || + hasChanged(lp, reverbNode.lp) || + hasChanged(dim, reverbNode.dim) || + hasChanged(irspeed, reverbNode.irspeed) || + hasChanged(irbegin, reverbNode.irbegin) || + reverbNode.ir !== ir ) { // only regenerate when something has changed // avoids endless regeneration on things like // stack(s("a"), s("b").rsize(8)).room(.5) // this only works when args may stay undefined until here // setting default values breaks this - orbits[orbit].reverbNode.generate(duration, fade, lp, dim, ir, irspeed, irbegin); + reverbNode.generate(duration, fade, lp, dim, ir, irspeed, irbegin); } - return orbits[orbit].reverbNode; + return reverbNode; } export let analysers = {}, @@ -582,6 +600,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) postgain = getDefaultValue('postgain'), density = getDefaultValue('density'), duckorbit, + duckonset, duckattack, duckdepth, // filters @@ -663,7 +682,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) setOrbit(ac, orbit, channels, t, cycle, cps); if (duckorbit != null) { - duckOrbit(ac, duckorbit, t, duckattack, duckdepth); + duckOrbit(ac, duckorbit, t, duckonset, duckattack, duckdepth); } gain = applyGainCurve(nanFallback(gain, 1)); diff --git a/packages/superdough/util.mjs b/packages/superdough/util.mjs index f4d59024..764ebb43 100644 --- a/packages/superdough/util.mjs +++ b/packages/superdough/util.mjs @@ -7,7 +7,7 @@ export const tokenizeNote = (note) => { if (typeof note !== 'string') { return []; } - const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)([0-9]*)$/)?.slice(1) || []; + const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)(-?[0-9]*)$/)?.slice(1) || []; if (!pc) { return []; } diff --git a/packages/tonal/test/tonal.test.mjs b/packages/tonal/test/tonal.test.mjs index cd8b3c88..5f61b05a 100644 --- a/packages/tonal/test/tonal.test.mjs +++ b/packages/tonal/test/tonal.test.mjs @@ -7,80 +7,130 @@ This program is free software: you can redistribute it and/or modify it under th // import { strict as assert } from 'assert'; import '../tonal.mjs'; // need to import this to add prototypes -import { pure, n, seq, note } from '@strudel/core'; +import { pure, n, seq, note, noteToMidi } from '@strudel/core'; import { describe, it, expect } from 'vitest'; import { mini } from '../../mini/mini.mjs'; describe('tonal', () => { - it('Should run tonal functions ', () => { - expect(pure('c3').scale('C major').scaleTranspose(1).firstCycleValues).toEqual(['D3']); + describe('scaleTranspose', () => { + it('transposes notes by scale degrees', () => { + expect(pure('c3').scale('C major').scaleTranspose(1).firstCycleValues).toEqual(['D3']); + }); }); - it('scale with plain values', () => { - expect( - seq(0, 1, 2) - .scale('C major') - .note() - .firstCycleValues.map((h) => h.note), - ).toEqual(['C3', 'D3', 'E3']); + describe('scale', () => { + it('converts plain values', () => { + expect( + seq(0, 1, 2) + .scale('C major') + .note() + .firstCycleValues.map((h) => h.note), + ).toEqual(['C3', 'D3', 'E3']); + }); + it('converts n values', () => { + expect( + n(seq(0, 1, 2)) + .scale('C major') + .firstCycleValues.map((h) => h.note), + ).toEqual(['C3', 'D3', 'E3']); + }); + it('converts n values (mini notation)', () => { + expect( + n(seq(0, 1, 2)) + .scale('C:major') + .firstCycleValues.map((h) => h.note), + ).toEqual(['C3', 'D3', 'E3']); + }); + it('converts n values (no tonic)', () => { + expect( + n(seq(0, 1, 2)) + .scale('major') + .firstCycleValues.map((h) => h.note), + ).toEqual(['C3', 'D3', 'E3']); + }); + it('converts n values (explicit mini notation)', () => { + expect( + n(seq(0, 1, 2)) + .scale(mini('C:major')) + .firstCycleValues.map((h) => h.note), + ).toEqual(['C3', 'D3', 'E3']); + }); + it('converts decorated n values', () => { + expect( + n(seq('0b', '1#', '-2', '3##', '4bb')) + .scale('C major') + .firstCycleValues.map((h) => h.note), + ).toEqual(['B2', 'Eb3', 'A2', 'G3', 'F3']); + }); + it('produces silence for mixed sharps and flats', () => { + expect( + n(seq('0b#', '1#b', '2#b#')) + .scale('C major') + .firstCycleValues.map((h) => h.note), + ).toEqual([]); + }); + it('snaps notes (upwards) to scale', () => { + const inputNotes = ['Cb', 'Eb', 'G', 'A#', 'Bb']; + const expectedNotes = ['B2', 'E3', 'G3', 'B3', 'B3']; + + expect( + note(seq(inputNotes)) + .scale('C major') + .firstCycleValues.map((h) => h.note), + ).toEqual(expectedNotes); + }); + it('snaps notes to the correct octave', () => { + const inputNotes = ['Cb0', 'Eb4', 'G1', 'A#19', 'Bb8']; + const expectedNotes = ['B#-1', 'D#4', 'G#1', 'A#19', 'A#8']; + + expect( + note(seq(inputNotes)) + .scale('A# minor') // A#, B#, C#, D#, E#, F#, G# + .firstCycleValues.map((h) => h.note), + ).toEqual(expectedNotes); + }); + it('handles scale names provided with colons', () => { + const inputNotes = ['Cb', 'E', 'G', 'A#', 'Bb']; + const expectedNotes = ['A#2', 'D#3', 'G#3', 'A#3', 'A#3']; + + expect( + note(seq(inputNotes)) + .scale('F#:pentatonic') // F#, G#, A#, C#, and D# + .firstCycleValues.map((h) => h.note), + ).toEqual(expectedNotes); + }); }); - it('scale with n values', () => { - expect( - n(seq(0, 1, 2)) - .scale('C major') - .firstCycleValues.map((h) => h.note), - ).toEqual(['C3', 'D3', 'E3']); - }); - it('scale with colon', () => { - expect( - n(seq(0, 1, 2)) - .scale('C:major') - .firstCycleValues.map((h) => h.note), - ).toEqual(['C3', 'D3', 'E3']); - }); - it('scale without tonic', () => { - expect( - n(seq(0, 1, 2)) - .scale('major') - .firstCycleValues.map((h) => h.note), - ).toEqual(['C3', 'D3', 'E3']); - }); - it('scale with mininotation colon', () => { - expect( - n(seq(0, 1, 2)) - .scale(mini('C:major')) - .firstCycleValues.map((h) => h.note), - ).toEqual(['C3', 'D3', 'E3']); - }); - it('transposes note numbers with interval numbers', () => { - expect( - note(seq(40, 40, 40)) - .transpose(0, 1, 2) - .firstCycleValues.map((h) => h.note), - ).toEqual([40, 41, 42]); - expect(seq(40, 40, 40).transpose(0, 1, 2).firstCycleValues).toEqual([40, 41, 42]); - }); - it('transposes note numbers with interval strings', () => { - expect( - note(seq(40, 40, 40)) - .transpose('1P', '2M', '3m') - .firstCycleValues.map((h) => h.note), - ).toEqual([40, 42, 43]); - expect(seq(40, 40, 40).transpose('1P', '2M', '3m').firstCycleValues).toEqual([40, 42, 43]); - }); - it('transposes note strings with interval numbers', () => { - expect( - note(seq('c', 'c', 'c')) - .transpose(0, 1, 2) - .firstCycleValues.map((h) => h.note), - ).toEqual(['C', 'Db', 'D']); - expect(seq('c', 'c', 'c').transpose(0, 1, 2).firstCycleValues).toEqual(['C', 'Db', 'D']); - }); - it('transposes note strings with interval strings', () => { - expect( - note(seq('c', 'c', 'c')) - .transpose('1P', '2M', '3m') - .firstCycleValues.map((h) => h.note), - ).toEqual(['C', 'D', 'Eb']); - expect(seq('c', 'c', 'c').transpose('1P', '2M', '3m').firstCycleValues).toEqual(['C', 'D', 'Eb']); + describe('transpose', () => { + it('transposes note numbers with interval numbers', () => { + expect( + note(seq(40, 40, 40)) + .transpose(0, 1, 2) + .firstCycleValues.map((h) => h.note), + ).toEqual([40, 41, 42]); + expect(seq(40, 40, 40).transpose(0, 1, 2).firstCycleValues).toEqual([40, 41, 42]); + }); + it('transposes note numbers with interval strings', () => { + expect( + note(seq(40, 40, 40)) + .transpose('1P', '2M', '3m') + .firstCycleValues.map((h) => h.note), + ).toEqual([40, 42, 43]); + expect(seq(40, 40, 40).transpose('1P', '2M', '3m').firstCycleValues).toEqual([40, 42, 43]); + }); + it('transposes note strings with interval numbers', () => { + expect( + note(seq('c', 'c', 'c')) + .transpose(0, 1, 2) + .firstCycleValues.map((h) => h.note), + ).toEqual(['C', 'Db', 'D']); + expect(seq('c', 'c', 'c').transpose(0, 1, 2).firstCycleValues).toEqual(['C', 'Db', 'D']); + }); + it('transposes note strings with interval strings', () => { + expect( + note(seq('c', 'c', 'c')) + .transpose('1P', '2M', '3m') + .firstCycleValues.map((h) => h.note), + ).toEqual(['C', 'D', 'Eb']); + expect(seq('c', 'c', 'c').transpose('1P', '2M', '3m').firstCycleValues).toEqual(['C', 'D', 'Eb']); + }); }); }); diff --git a/packages/tonal/tonal.mjs b/packages/tonal/tonal.mjs index c4dd5488..ae75ab69 100644 --- a/packages/tonal/tonal.mjs +++ b/packages/tonal/tonal.mjs @@ -6,19 +6,28 @@ This program is free software: you can redistribute it and/or modify it under th import { Note, Interval, Scale } from '@tonaljs/tonal'; import { register, _mod, silence, logger, pure, isNote } from '@strudel/core'; -import { stepInNamedScale } from './tonleiter.mjs'; +import { stepInNamedScale, nearestNumberIndex } from './tonleiter.mjs'; +import { noteToMidi } from '../core/util.mjs'; const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P'; -function scaleStep(step, scale) { - scale = scale.replaceAll(':', ' '); - step = Math.ceil(step); - let { intervals, tonic, empty } = Scale.get(scale); - if ((empty && isNote(scale)) || (empty && !tonic)) { - throw new Error(`incomplete scale. Make sure to use ":" instead of spaces, example: .scale("C:major")`); +function getScale(scaleName) { + scaleName = scaleName.replaceAll(':', ' '); + const scale = Scale.get(scaleName); + const { tonic, empty } = scale; + if ((empty && isNote(scaleName)) || (empty && !tonic)) { + throw new Error( + `Scale name ${scaleName} is incomplete. Make sure to use ":" instead of spaces, example: .scale("C:major")`, + ); } else if (empty) { - throw new Error(`invalid scale "${scale}"`); + throw new Error(`Invalid scale name "${scaleName}"`); } + return scale; +} + +function scaleStep(step, scale) { + step = Math.ceil(step); + let { intervals, tonic } = getScale(scale); tonic = tonic || 'C'; const { pc, oct = 3 } = Note.get(tonic); const octaveOffset = Math.floor(step / intervals.length); @@ -30,8 +39,7 @@ function scaleStep(step, scale) { // transpose note inside scale by offset steps // function scaleOffset(scale: string, offset: number, note: string) { function scaleOffset(scale, offset, note) { - let [tonic, scaleName] = Scale.tokenize(scale); - let { notes } = Scale.get(`${tonic} ${scaleName}`); + let { notes } = getScale(scale); notes = notes.map((note) => Note.get(note).pc); // use only pc! offset = Number(offset); if (isNaN(offset)) { @@ -120,10 +128,7 @@ export const { transpose, trans } = register(['transpose', 'trans'], function tr const interval = !isNaN(Number(intervalOrSemitones)) ? Interval.fromSemitones(intervalOrSemitones) : String(intervalOrSemitones); - // TODO: move simplify to player to preserve enharmonics - // tone.js doesn't understand multiple sharps flats e.g. F##3 has to be turned into G3 - // TODO: check if this is still relevant.. - const targetNote = Note.simplify(Note.transpose(note, interval)); + const targetNote = Note.transpose(note, interval); if (typeof hap.value === 'object') { return hap.withValue(() => ({ ...hap.value, note: targetNote })); } @@ -171,8 +176,59 @@ export const { scaleTranspose, scaleTrans, strans } = register( }, ); +// Converts a step value, which is a number optionally decorated with sharps and flats, +// to a number and an `offset` number of semitones +function _convertStepToNumberAndOffset(step) { + let asNumber = Number(step); + let offset = 0; + if (isNaN(asNumber)) { + step = String(step); + // Check to see if the step matches the expected format: + // - A number (possibly negative) + // - Some number of sharps or flats (but not both) + const match = /^(-?\d+)(#+|b+)?$/.exec(step); + + if (!match) { + throw new Error(`invalid scale step "${step}", expected number or integer with optional # b suffixes`); + } + asNumber = Number(match[1]); + // These decorations will determine the semitone offset based on the number of + // sharps or flats + const decorations = match[2] || ''; + offset = decorations[0] === '#' ? decorations.length : -decorations.length; + } + return [asNumber, offset]; +} + +let scaleToMidisAndNotes = {}; +// Finds the nearest scale note to `note` +function _getNearestScaleNote(scaleName, note, preferHigher = true) { + let noteMidi = typeof note === 'string' ? noteToMidi(note) : note; + if (scaleToMidisAndNotes[scaleName] === undefined) { + const { intervals, tonic } = getScale(scaleName); + const { pc } = Note.get(tonic); + const expandedIntervals = intervals.concat('8P'); // add the octave for wrapping + const sNotes = expandedIntervals.map((interval) => Note.transpose(pc + '0', interval)); + const sMidi = sNotes.map(noteToMidi); + // Cache + scaleToMidisAndNotes[scaleName] = [sMidi, sNotes]; + } + const [scaleMidis, scaleNotes] = scaleToMidisAndNotes[scaleName]; + const rootMidi = scaleMidis[0]; + const octaveDiff = Math.floor((noteMidi - rootMidi) / 12); + const alignedMidis = scaleMidis.map((m) => m + 12 * octaveDiff); + const noteIdx = nearestNumberIndex(noteMidi, alignedMidis, preferHigher); + const noteMatch = scaleNotes[noteIdx]; + return Note.transpose(noteMatch, Interval.fromSemitones(12 * octaveDiff)); +} + /** - * Turns numbers into notes in the scale (zero indexed). Also sets scale for other scale operations, like {@link Pattern#scaleTranspose}. + * Turns numbers into notes in the scale (zero indexed) or quantizes notes to a scale. + * + * When describing notes via numbers, note that negative numbers can be used to wrap backwards + * in the scale as well as sharps or flats (but not both) to produce notes outside of the scale. + * + * Also sets scale for other scale operations, like {@link Pattern#scaleTranspose}. * * A scale consists of a root note (e.g. `c4`, `c`, `f#`, `bb4`) followed by semicolon (':') and then a [scale type](https://github.com/tonaljs/tonal/blob/main/packages/scale-type/data.ts). * @@ -191,6 +247,12 @@ export const { scaleTranspose, scaleTrans, strans } = register( * n(rand.range(0,12).segment(8)) * .scale("C:ritusen") * .s("piano") + * @example + * n("<[0,7b] [-4# -4] [-2,7##] 4 [0,7] [-4# -4b] [-2,7###] 4b>*4") + * .scale("C:/2") + * .s("piano") + * @example + * note("C1*16").transpose(irand(36)).scale('Cb2 major').scaleTranspose(3) */ export const scale = register( @@ -204,49 +266,35 @@ export const scale = register( pat .fmap((value) => { const isObject = typeof value === 'object'; - let step = isObject ? value.n : value; - if (isObject) { + // The case where the note has been defined via `n` or `pure` + if (!isObject || (isObject && ('n' in value || 'value' in value))) { + const step = isObject ? (value.n ?? value.value) : value; delete value.n; // remove n so it won't cause trouble - } - if (isNote(step)) { - // legacy.. - return pure(step); - } - let asNumber = Number(step); - let semitones = 0; - if (isNaN(asNumber)) { - step = String(step); - if (!/^[-+]?\d+(#*|b*){1}$/.test(step)) { - logger( - `[tonal] invalid scale step "${step}", expected number or integer with optional # b suffixes`, - 'error', - ); + if (isNote(step)) { + // legacy.. + return pure(step); + } + try { + const [number, offset] = _convertStepToNumberAndOffset(step); + let note; + if (isObject && value.anchor) { + note = stepInNamedScale(number, scale, value.anchor); + } else { + note = scaleStep(number, scale); + } + if (offset != 0) note = Note.transpose(note, Interval.fromSemitones(offset)); + value = pure(isObject ? { ...value, note } : note); + } catch (err) { + logger(`[tonal] ${err.message}`, 'error'); return silence; } - const isharp = step.indexOf('#'); - if (isharp >= 0) { - asNumber = Number(step.substring(0, isharp)); - semitones = step.length - isharp; - } else { - const iflat = step.indexOf('b'); - asNumber = Number(step.substring(0, iflat)); - semitones = iflat - step.length; - } + return value; } - try { - let note; - if (isObject && value.anchor) { - note = stepInNamedScale(asNumber, scale, value.anchor); - } else { - note = scaleStep(asNumber, scale); - } - if (semitones != 0) note = Note.transpose(note, Interval.fromSemitones(semitones)); - value = pure(isObject ? { ...value, note } : note); - } catch (err) { - logger(`[tonal] ${err.message}`, 'error'); - value = silence; + // The case where the note has been defined via `note` + else { + const note = _getNearestScaleNote(scale, value.note); + return pure(isObject ? { ...value, note } : note); } - return value; }) .outerJoin() // legacy: diff --git a/packages/tonal/tonleiter.mjs b/packages/tonal/tonleiter.mjs index 3814394f..23312964 100644 --- a/packages/tonal/tonleiter.mjs +++ b/packages/tonal/tonleiter.mjs @@ -101,11 +101,11 @@ export function nearestNumberIndex(target, numbers, preferHigher) { let scaleSteps = {}; // [scaleName]: semitones[] export function stepInNamedScale(step, scale, anchor, preferHigher) { - let [root, scaleName] = Scale.tokenize(scale); + const [root, scaleName] = Scale.tokenize(scale); const rootMidi = x2midi(root); const rootChroma = midi2chroma(rootMidi); if (!scaleSteps[scaleName]) { - let { intervals } = Scale.get(`C ${scaleName}`); + const { intervals } = Scale.get(`C ${scaleName}`); // cache result scaleSteps[scaleName] = intervals.map(step2semitones); } @@ -222,6 +222,7 @@ export const Note = { }; // TODO: support octave numbers +// Example: Note("Bb3").transpose("c3") export function transpose(note, step) { // example: E, 3 const stepNumber = Step.tokenize(step)[1]; // 3 @@ -235,5 +236,3 @@ export function transpose(note, step) { const offsetAccidentals = accidentalString(Step.accidentals(step) + Note.accidentals(note) + stepIndex - indexOffset); // "we need to add a # to to the G to make it a major third from E" return [targetNote, offsetAccidentals].join(''); } - -//Note("Bb3").transpose("c3") diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 46359b62..f91503ca 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -3093,57 +3093,50 @@ exports[`runs examples > example "dry" example index 0 1`] = ` exports[`runs examples > example "duckattack" example index 0 1`] = ` [ "[ 0/1 → 1/16 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]", - "[ 0/1 → 1/8 | note:C3 s:sawtooth delay:0.7 orbit:2 ]", - "[ 1/8 → 1/4 | note:D3 s:sawtooth delay:0.7 orbit:2 ]", "[ 1/4 → 5/16 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]", - "[ 1/4 → 3/8 | note:Eb3 s:sawtooth delay:0.7 orbit:2 ]", - "[ 3/8 → 1/2 | note:F3 s:sawtooth delay:0.7 orbit:2 ]", "[ 1/2 → 9/16 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]", - "[ 1/2 → 5/8 | note:G3 s:sawtooth delay:0.7 orbit:2 ]", - "[ 5/8 → 3/4 | note:Ab3 s:sawtooth delay:0.7 orbit:2 ]", "[ 11/16 → 3/4 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]", - "[ 3/4 → 7/8 | note:Bb3 s:sawtooth delay:0.7 orbit:2 ]", "[ 7/8 → 15/16 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]", - "[ 7/8 → 1/1 | note:C4 s:sawtooth delay:0.7 orbit:2 ]", "[ 1/1 → 17/16 | s:bd n:4 duckorbit:2 duckattack:0 duckdepth:1 ]", - "[ 1/1 → 9/8 | note:C3 s:sawtooth delay:0.7 orbit:2 ]", - "[ 9/8 → 5/4 | note:D3 s:sawtooth delay:0.7 orbit:2 ]", "[ 5/4 → 21/16 | s:bd n:4 duckorbit:2 duckattack:0 duckdepth:1 ]", - "[ 5/4 → 11/8 | note:Eb3 s:sawtooth delay:0.7 orbit:2 ]", - "[ 11/8 → 3/2 | note:F3 s:sawtooth delay:0.7 orbit:2 ]", "[ 3/2 → 25/16 | s:bd n:4 duckorbit:2 duckattack:0 duckdepth:1 ]", - "[ 3/2 → 13/8 | note:G3 s:sawtooth delay:0.7 orbit:2 ]", - "[ 13/8 → 7/4 | note:Ab3 s:sawtooth delay:0.7 orbit:2 ]", "[ 27/16 → 7/4 | s:bd n:4 duckorbit:2 duckattack:0 duckdepth:1 ]", - "[ 7/4 → 15/8 | note:Bb3 s:sawtooth delay:0.7 orbit:2 ]", "[ 15/8 → 31/16 | s:bd n:4 duckorbit:2 duckattack:0 duckdepth:1 ]", - "[ 15/8 → 2/1 | note:C4 s:sawtooth delay:0.7 orbit:2 ]", "[ 2/1 → 33/16 | s:bd n:4 duckorbit:2 duckattack:0.4 duckdepth:1 ]", - "[ 2/1 → 17/8 | note:C3 s:sawtooth delay:0.7 orbit:2 ]", - "[ 17/8 → 9/4 | note:D3 s:sawtooth delay:0.7 orbit:2 ]", "[ 9/4 → 37/16 | s:bd n:4 duckorbit:2 duckattack:0.4 duckdepth:1 ]", - "[ 9/4 → 19/8 | note:Eb3 s:sawtooth delay:0.7 orbit:2 ]", - "[ 19/8 → 5/2 | note:F3 s:sawtooth delay:0.7 orbit:2 ]", "[ 5/2 → 41/16 | s:bd n:4 duckorbit:2 duckattack:0.4 duckdepth:1 ]", - "[ 5/2 → 21/8 | note:G3 s:sawtooth delay:0.7 orbit:2 ]", - "[ 21/8 → 11/4 | note:Ab3 s:sawtooth delay:0.7 orbit:2 ]", "[ 43/16 → 11/4 | s:bd n:4 duckorbit:2 duckattack:0.4 duckdepth:1 ]", - "[ 11/4 → 23/8 | note:Bb3 s:sawtooth delay:0.7 orbit:2 ]", "[ 23/8 → 47/16 | s:bd n:4 duckorbit:2 duckattack:0.4 duckdepth:1 ]", - "[ 23/8 → 3/1 | note:C4 s:sawtooth delay:0.7 orbit:2 ]", "[ 3/1 → 49/16 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]", - "[ 3/1 → 25/8 | note:C3 s:sawtooth delay:0.7 orbit:2 ]", - "[ 25/8 → 13/4 | note:D3 s:sawtooth delay:0.7 orbit:2 ]", "[ 13/4 → 53/16 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]", - "[ 13/4 → 27/8 | note:Eb3 s:sawtooth delay:0.7 orbit:2 ]", - "[ 27/8 → 7/2 | note:F3 s:sawtooth delay:0.7 orbit:2 ]", "[ 7/2 → 57/16 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]", - "[ 7/2 → 29/8 | note:G3 s:sawtooth delay:0.7 orbit:2 ]", - "[ 29/8 → 15/4 | note:Ab3 s:sawtooth delay:0.7 orbit:2 ]", "[ 59/16 → 15/4 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]", - "[ 15/4 → 31/8 | note:Bb3 s:sawtooth delay:0.7 orbit:2 ]", "[ 31/8 → 63/16 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]", - "[ 31/8 → 4/1 | note:C4 s:sawtooth delay:0.7 orbit:2 ]", +] +`; + +exports[`runs examples > example "duckattack" example index 1 1`] = ` +[ + "[ 0/1 → 1/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 1/4 → 5/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 1/2 → 9/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 11/16 → 3/4 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 7/8 → 15/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 1/1 → 17/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 5/4 → 21/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 3/2 → 25/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 27/16 → 7/4 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 15/8 → 31/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 2/1 → 33/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 9/4 → 37/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 5/2 → 41/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 43/16 → 11/4 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 23/8 → 47/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 3/1 → 49/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 13/4 → 53/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 7/2 → 57/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 59/16 → 15/4 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", + "[ 31/8 → 63/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]", ] `; @@ -3204,6 +3197,94 @@ exports[`runs examples > example "duckdepth" example index 0 1`] = ` ] `; +exports[`runs examples > example "duckdepth" example index 1 1`] = ` +[ + "[ 0/1 → 1/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 1/4 → 5/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 1/2 → 9/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 11/16 → 3/4 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 7/8 → 15/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 1/1 → 17/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 5/4 → 21/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 3/2 → 25/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 27/16 → 7/4 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 15/8 → 31/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 2/1 → 33/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 9/4 → 37/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 5/2 → 41/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 43/16 → 11/4 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 23/8 → 47/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 3/1 → 49/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 13/4 → 53/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 7/2 → 57/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 59/16 → 15/4 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", + "[ 31/8 → 63/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]", +] +`; + +exports[`runs examples > example "duckonset" example index 0 1`] = ` +[ + "[ 0/1 → 1/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]", + "[ 1/4 → 1/2 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]", + "[ 1/2 → 3/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]", + "[ 3/4 → 1/1 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]", + "[ 1/1 → 5/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]", + "[ 5/4 → 3/2 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]", + "[ 3/2 → 7/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]", + "[ 7/4 → 2/1 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]", + "[ 2/1 → 9/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]", + "[ 9/4 → 5/2 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]", + "[ 5/2 → 11/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]", + "[ 11/4 → 3/1 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]", + "[ 3/1 → 13/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]", + "[ 13/4 → 7/2 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]", + "[ 7/2 → 15/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]", + "[ 15/4 → 4/1 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]", +] +`; + +exports[`runs examples > example "duckonset" example index 1 1`] = ` +[ + "[ 0/1 → 1/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]", + "[ 1/4 → 1/2 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]", + "[ 1/2 → 3/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]", + "[ 3/4 → 1/1 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]", + "[ 1/1 → 5/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]", + "[ 5/4 → 3/2 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]", + "[ 3/2 → 7/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]", + "[ 7/4 → 2/1 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]", + "[ 2/1 → 9/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]", + "[ 9/4 → 5/2 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]", + "[ 5/2 → 11/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]", + "[ 11/4 → 3/1 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]", + "[ 3/1 → 13/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]", + "[ 13/4 → 7/2 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]", + "[ 7/2 → 15/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]", + "[ 15/4 → 4/1 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]", +] +`; + +exports[`runs examples > example "duckonset" example index 2 1`] = ` +[ + "[ 0/1 → 1/4 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]", + "[ 1/4 → 1/2 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]", + "[ 1/2 → 3/4 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]", + "[ 3/4 → 1/1 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]", + "[ 1/1 → 5/4 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]", + "[ 5/4 → 3/2 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]", + "[ 3/2 → 7/4 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]", + "[ 7/4 → 2/1 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]", + "[ 2/1 → 9/4 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]", + "[ 9/4 → 5/2 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]", + "[ 5/2 → 11/4 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]", + "[ 11/4 → 3/1 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]", + "[ 3/1 → 13/4 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]", + "[ 13/4 → 7/2 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]", + "[ 7/2 → 15/4 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]", + "[ 15/4 → 4/1 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]", +] +`; + exports[`runs examples > example "duckorbit" example index 0 1`] = ` [ "[ 0/1 → 1/16 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]", @@ -3229,6 +3310,31 @@ exports[`runs examples > example "duckorbit" example index 0 1`] = ` ] `; +exports[`runs examples > example "duckorbit" example index 1 1`] = ` +[ + "[ 0/1 → 1/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 1/4 → 5/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 1/2 → 9/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 11/16 → 3/4 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 7/8 → 15/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 1/1 → 17/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 5/4 → 21/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 3/2 → 25/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 27/16 → 7/4 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 15/8 → 31/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 2/1 → 33/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 9/4 → 37/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 5/2 → 41/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 43/16 → 11/4 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 23/8 → 47/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 3/1 → 49/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 13/4 → 53/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 7/2 → 57/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 59/16 → 15/4 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", + "[ 31/8 → 63/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]", +] +`; + exports[`runs examples > example "duration" example index 0 1`] = ` [ "[ 0/1 → 1/4 | note:c s:piano duration:0.5 ]", @@ -3644,6 +3750,8 @@ exports[`runs examples > example "fast" example index 0 1`] = ` exports[`runs examples > example "fastChunk" example index 0 1`] = ` [ + "[ 0/1 → 1/4 | color:red note:0 ]", + "[ 1/4 → 1/2 | color:red note:1 ]", "[ 1/2 → 3/4 | note:E2 ]", "[ 3/4 → 1/1 | note:F2 ]", "[ 1/1 → 5/4 | note:G2 ]", @@ -3652,6 +3760,8 @@ exports[`runs examples > example "fastChunk" example index 0 1`] = ` "[ 7/4 → 2/1 | note:C3 ]", "[ 2/1 → 9/4 | note:D3 ]", "[ 9/4 → 5/2 | note:D2 ]", + "[ 5/2 → 11/4 | color:red note:2 ]", + "[ 11/4 → 3/1 | color:red note:3 ]", "[ 3/1 → 13/4 | note:G2 ]", "[ 13/4 → 7/2 | note:A2 ]", "[ 7/2 → 15/4 | note:B2 ]", @@ -6560,6 +6670,27 @@ exports[`runs examples > example "note" example index 2 1`] = ` ] `; +exports[`runs examples > example "note" example index 3 1`] = ` +[ + "[ 0/1 → 1/4 | note:fbb1 s:saw ]", + "[ 1/4 → 1/2 | note:a#0 s:saw ]", + "[ 1/2 → 3/4 | note:cbbb-1 s:saw ]", + "[ 3/4 → 1/1 | note:e##-2 s:saw ]", + "[ 1/1 → 5/4 | note:fbb1 s:saw ]", + "[ 5/4 → 3/2 | note:a#0 s:saw ]", + "[ 3/2 → 7/4 | note:cbbb-1 s:saw ]", + "[ 7/4 → 2/1 | note:e##-2 s:saw ]", + "[ 2/1 → 9/4 | note:fbb1 s:saw ]", + "[ 9/4 → 5/2 | note:a#0 s:saw ]", + "[ 5/2 → 11/4 | note:cbbb-1 s:saw ]", + "[ 11/4 → 3/1 | note:e##-2 s:saw ]", + "[ 3/1 → 13/4 | note:fbb1 s:saw ]", + "[ 13/4 → 7/2 | note:a#0 s:saw ]", + "[ 7/2 → 15/4 | note:cbbb-1 s:saw ]", + "[ 15/4 → 4/1 | note:e##-2 s:saw ]", +] +`; + exports[`runs examples > example "nrpnn" example index 0 1`] = ` [ "[ 0/1 → 1/1 | note:c4 nrpnn:[1 8] nrpv:123 midichan:1 ]", @@ -8848,6 +8979,108 @@ exports[`runs examples > example "scale" example index 2 1`] = ` ] `; +exports[`runs examples > example "scale" example index 3 1`] = ` +[ + "[ 0/1 → 1/4 | note:C3 s:piano ]", + "[ 0/1 → 1/4 | note:B3 s:piano ]", + "[ 1/4 → 3/8 | note:Gb2 s:piano ]", + "[ 3/8 → 1/2 | note:F2 s:piano ]", + "[ 1/2 → 3/4 | note:A2 s:piano ]", + "[ 1/2 → 3/4 | note:D4 s:piano ]", + "[ 3/4 → 1/1 | note:G3 s:piano ]", + "[ 1/1 → 5/4 | note:C3 s:piano ]", + "[ 1/1 → 5/4 | note:C4 s:piano ]", + "[ 5/4 → 11/8 | note:Gb2 s:piano ]", + "[ 11/8 → 3/2 | note:E2 s:piano ]", + "[ 3/2 → 7/4 | note:A2 s:piano ]", + "[ 3/2 → 7/4 | note:Eb4 s:piano ]", + "[ 7/4 → 2/1 | note:F#3 s:piano ]", + "[ 2/1 → 9/4 | note:C3 s:piano ]", + "[ 2/1 → 9/4 | note:B3 s:piano ]", + "[ 9/4 → 19/8 | note:Gb2 s:piano ]", + "[ 19/8 → 5/2 | note:F2 s:piano ]", + "[ 5/2 → 11/4 | note:Ab2 s:piano ]", + "[ 5/2 → 11/4 | note:D4 s:piano ]", + "[ 11/4 → 3/1 | note:G3 s:piano ]", + "[ 3/1 → 13/4 | note:C3 s:piano ]", + "[ 3/1 → 13/4 | note:C4 s:piano ]", + "[ 13/4 → 27/8 | note:Gb2 s:piano ]", + "[ 27/8 → 7/2 | note:E2 s:piano ]", + "[ 7/2 → 15/4 | note:Ab2 s:piano ]", + "[ 7/2 → 15/4 | note:Eb4 s:piano ]", + "[ 15/4 → 4/1 | note:F#3 s:piano ]", +] +`; + +exports[`runs examples > example "scale" example index 4 1`] = ` +[ + "[ 0/1 → 1/16 | note:Gb1 ]", + "[ 1/16 → 1/8 | note:Gb1 ]", + "[ 1/8 → 3/16 | note:Gb1 ]", + "[ 3/16 → 1/4 | note:Gb1 ]", + "[ 1/4 → 5/16 | note:Gb1 ]", + "[ 5/16 → 3/8 | note:Gb1 ]", + "[ 3/8 → 7/16 | note:Gb1 ]", + "[ 7/16 → 1/2 | note:Gb1 ]", + "[ 1/2 → 9/16 | note:Gb1 ]", + "[ 9/16 → 5/8 | note:Gb1 ]", + "[ 5/8 → 11/16 | note:Gb1 ]", + "[ 11/16 → 3/4 | note:Gb1 ]", + "[ 3/4 → 13/16 | note:Gb1 ]", + "[ 13/16 → 7/8 | note:Gb1 ]", + "[ 7/8 → 15/16 | note:Gb1 ]", + "[ 15/16 → 1/1 | note:Gb1 ]", + "[ 1/1 → 17/16 | note:Cb3 ]", + "[ 17/16 → 9/8 | note:Cb3 ]", + "[ 9/8 → 19/16 | note:Cb3 ]", + "[ 19/16 → 5/4 | note:Cb3 ]", + "[ 5/4 → 21/16 | note:Cb3 ]", + "[ 21/16 → 11/8 | note:Cb3 ]", + "[ 11/8 → 23/16 | note:Cb3 ]", + "[ 23/16 → 3/2 | note:Cb3 ]", + "[ 3/2 → 25/16 | note:Cb3 ]", + "[ 25/16 → 13/8 | note:Cb3 ]", + "[ 13/8 → 27/16 | note:Cb3 ]", + "[ 27/16 → 7/4 | note:Cb3 ]", + "[ 7/4 → 29/16 | note:Cb3 ]", + "[ 29/16 → 15/8 | note:Cb3 ]", + "[ 15/8 → 31/16 | note:Cb3 ]", + "[ 31/16 → 2/1 | note:Cb3 ]", + "[ 2/1 → 33/16 | note:Eb4 ]", + "[ 33/16 → 17/8 | note:Eb4 ]", + "[ 17/8 → 35/16 | note:Eb4 ]", + "[ 35/16 → 9/4 | note:Eb4 ]", + "[ 9/4 → 37/16 | note:Eb4 ]", + "[ 37/16 → 19/8 | note:Eb4 ]", + "[ 19/8 → 39/16 | note:Eb4 ]", + "[ 39/16 → 5/2 | note:Eb4 ]", + "[ 5/2 → 41/16 | note:Eb4 ]", + "[ 41/16 → 21/8 | note:Eb4 ]", + "[ 21/8 → 43/16 | note:Eb4 ]", + "[ 43/16 → 11/4 | note:Eb4 ]", + "[ 11/4 → 45/16 | note:Eb4 ]", + "[ 45/16 → 23/8 | note:Eb4 ]", + "[ 23/8 → 47/16 | note:Eb4 ]", + "[ 47/16 → 3/1 | note:Eb4 ]", + "[ 3/1 → 49/16 | note:Db2 ]", + "[ 49/16 → 25/8 | note:Db2 ]", + "[ 25/8 → 51/16 | note:Db2 ]", + "[ 51/16 → 13/4 | note:Db2 ]", + "[ 13/4 → 53/16 | note:Db2 ]", + "[ 53/16 → 27/8 | note:Db2 ]", + "[ 27/8 → 55/16 | note:Db2 ]", + "[ 55/16 → 7/2 | note:Db2 ]", + "[ 7/2 → 57/16 | note:Db2 ]", + "[ 57/16 → 29/8 | note:Db2 ]", + "[ 29/8 → 59/16 | note:Db2 ]", + "[ 59/16 → 15/4 | note:Db2 ]", + "[ 15/4 → 61/16 | note:Db2 ]", + "[ 61/16 → 31/8 | note:Db2 ]", + "[ 31/8 → 63/16 | note:Db2 ]", + "[ 63/16 → 4/1 | note:Db2 ]", +] +`; + exports[`runs examples > example "scaleTranspose" example index 0 1`] = ` [ "[ 0/1 → 1/2 | note:C3 ]", diff --git a/website/public/fonts/tic80/license.txt b/website/public/fonts/tic80/license.txt new file mode 100644 index 00000000..e42ba4eb --- /dev/null +++ b/website/public/fonts/tic80/license.txt @@ -0,0 +1,5 @@ +The FontStruction “TIC-80 wide font” +(https://fontstruct.com/fontstructions/show/1388526) by “nesbox” is licensed +under a Creative Commons CC0 Public Domain Dedication license +(http://creativecommons.org/publicdomain/zero/1.0/). +[ancestry] \ No newline at end of file diff --git a/website/public/fonts/tic80/readme.txt b/website/public/fonts/tic80/readme.txt new file mode 100644 index 00000000..b3aaff55 --- /dev/null +++ b/website/public/fonts/tic80/readme.txt @@ -0,0 +1,16 @@ +The font file in this archive was created using Fontstruct the free, online +font-building tool. +This font was created by “nesbox”. +This font has a homepage where this archive and other versions may be found: +https://fontstruct.com/fontstructions/show/1388526 +[ancestry] +Try Fontstruct at https://fontstruct.com +It’s easy and it’s fun. + +Fontstruct is copyright ©2017-2025 Rob Meek + +LEGAL NOTICE: +In using this font you must comply with the licensing terms described in the +file “license.txt” included with this archive. +If you redistribute the font file in this archive, it must be accompanied by all +the other files from this archive, including this one. diff --git a/website/public/fonts/tic80/tic-80-wide-font.otf b/website/public/fonts/tic80/tic-80-wide-font.otf new file mode 100644 index 00000000..ba1c1caf Binary files /dev/null and b/website/public/fonts/tic80/tic-80-wide-font.otf differ diff --git a/website/src/repl/components/panel/SettingsTab.jsx b/website/src/repl/components/panel/SettingsTab.jsx index 26c9ae28..80daef01 100644 --- a/website/src/repl/components/panel/SettingsTab.jsx +++ b/website/src/repl/components/panel/SettingsTab.jsx @@ -74,6 +74,7 @@ const fontFamilyOptions = { FiraCode: 'FiraCode', 'FiraCode-SemiBold': 'FiraCode SemiBold', teletext: 'teletext', + tic80: 'tic80', mode7: 'mode7', BigBlueTerminal: 'BigBlueTerminal', x3270: 'x3270', diff --git a/website/src/styles/index.css b/website/src/styles/index.css index 7fa4b2df..41b282ff 100644 --- a/website/src/styles/index.css +++ b/website/src/styles/index.css @@ -50,6 +50,11 @@ src: url('/fonts/teletext/EuropeanTeletext.ttf'); size-adjust: 90%; } +@font-face { + font-family: 'tic80'; + src: url('/fonts/tic80/tic-80-wide-font.otf'); + size-adjust: 60%; +} @font-face { font-family: 'mode7'; src: url('/fonts/mode7/MODE7GX3.TTF');