Merge branch 'main' into iclc-slides

This commit is contained in:
Felix Roos
2023-03-23 11:56:14 +01:00
36 changed files with 1126 additions and 1565 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel.cycles/core",
"version": "0.6.8",
"version": "0.7.2",
"description": "Port of Tidal Cycles to JavaScript",
"main": "index.mjs",
"type": "module",
+2 -5
View File
@@ -1127,6 +1127,8 @@ function _composeOp(a, b, func) {
export const polyrhythm = stack;
export const pr = stack;
export const pm = polymeter;
// methods that create patterns, which are added to patternified Pattern methods
// TODO: remove? this is only used in old transpiler (shapeshifter)
Pattern.prototype.factories = {
@@ -1374,11 +1376,6 @@ export function polymeter(...args) {
return polymeterSteps(0, ...args);
}
// alias
export function pm(...args) {
polymeter(...args);
}
export const mask = curry((a, b) => reify(b).mask(a));
export const struct = curry((a, b) => reify(b).struct(a));
export const superimpose = curry((a, b) => reify(b).superimpose(...a));
+2 -2
View File
@@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/st
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Pattern, toMidi, getDrawContext, freqToMidi, isNote } from './index.mjs';
import { Pattern, noteToMidi, getDrawContext, freqToMidi, isNote } from './index.mjs';
const scale = (normalized, min, max) => normalized * (max - min) + min;
const getValue = (e) => {
@@ -18,7 +18,7 @@ const getValue = (e) => {
}
note = note ?? n;
if (typeof note === 'string') {
return toMidi(note);
return noteToMidi(note);
}
if (typeof note === 'number') {
return note;
+17 -17
View File
@@ -8,8 +8,8 @@ import { pure } from '../pattern.mjs';
import {
isNote,
tokenizeNote,
toMidi,
fromMidi,
noteToMidi,
midiToFreq,
freqToMidi,
_mod,
compose,
@@ -75,27 +75,27 @@ describe('isNote', () => {
expect(tokenizeNote(123)).toStrictEqual([]);
});
});
describe('toMidi', () => {
describe('noteToMidi', () => {
it('should turn notes into midi', () => {
expect(toMidi('A4')).toEqual(69);
expect(toMidi('C4')).toEqual(60);
expect(toMidi('Db4')).toEqual(61);
expect(toMidi('C3')).toEqual(48);
expect(toMidi('Cb3')).toEqual(47);
expect(toMidi('Cbb3')).toEqual(46);
expect(toMidi('C#3')).toEqual(49);
expect(toMidi('C#3')).toEqual(49);
expect(toMidi('C##3')).toEqual(50);
expect(noteToMidi('A4')).toEqual(69);
expect(noteToMidi('C4')).toEqual(60);
expect(noteToMidi('Db4')).toEqual(61);
expect(noteToMidi('C3')).toEqual(48);
expect(noteToMidi('Cb3')).toEqual(47);
expect(noteToMidi('Cbb3')).toEqual(46);
expect(noteToMidi('C#3')).toEqual(49);
expect(noteToMidi('C#3')).toEqual(49);
expect(noteToMidi('C##3')).toEqual(50);
});
it('should throw an error when given a non-note', () => {
expect(() => toMidi('Q')).toThrowError(`not a note: "Q"`);
expect(() => toMidi('Z')).toThrowError(`not a note: "Z"`);
expect(() => noteToMidi('Q')).toThrowError(`not a note: "Q"`);
expect(() => noteToMidi('Z')).toThrowError(`not a note: "Z"`);
});
});
describe('fromMidi', () => {
describe('midiToFreq', () => {
it('should turn midi into frequency', () => {
expect(fromMidi(69)).toEqual(440);
expect(fromMidi(57)).toEqual(220);
expect(midiToFreq(69)).toEqual(440);
expect(midiToFreq(57)).toEqual(220);
});
});
describe('freqToMidi', () => {
+9 -9
View File
@@ -19,7 +19,7 @@ export const tokenizeNote = (note) => {
};
// turns the given note into its midi number representation
export const toMidi = (note) => {
export const noteToMidi = (note) => {
const [pc, acc, oct = 3] = tokenizeNote(note);
if (!pc) {
throw new Error('not a note: "' + note + '"');
@@ -28,7 +28,7 @@ export const toMidi = (note) => {
const offset = acc?.split('').reduce((o, char) => o + { '#': 1, b: -1, s: 1 }[char], 0) || 0;
return (Number(oct) + 1) * 12 + chroma + offset;
};
export const fromMidi = (n) => {
export const midiToFreq = (n) => {
return Math.pow(2, (n - 69) / 12) * 440;
};
@@ -45,7 +45,7 @@ export const valueToMidi = (value, fallbackValue) => {
return freqToMidi(freq);
}
if (typeof note === 'string') {
return toMidi(note);
return noteToMidi(note);
}
if (typeof note === 'number') {
return note;
@@ -62,9 +62,9 @@ export const valueToMidi = (value, fallbackValue) => {
*/
export const getFreq = (noteOrMidi) => {
if (typeof noteOrMidi === 'number') {
return fromMidi(noteOrMidi);
return midiToFreq(noteOrMidi);
}
return fromMidi(toMidi(noteOrMidi));
return midiToFreq(noteToMidi(noteOrMidi));
};
/**
@@ -91,7 +91,7 @@ export const getPlayableNoteValue = (hap) => {
}
// if value is number => interpret as midi number as long as its not marked as frequency
if (typeof note === 'number' && context.type !== 'frequency') {
note = fromMidi(hap.value);
note = midiToFreq(hap.value);
} else if (typeof note === 'number' && context.type === 'frequency') {
note = hap.value; // legacy workaround.. will be removed in the future
} else if (typeof note !== 'string' || !isNote(note)) {
@@ -110,9 +110,9 @@ export const getFrequency = (hap) => {
return getFreq(value.note || value.n || value.value);
}
if (typeof value === 'number' && context.type !== 'frequency') {
value = fromMidi(hap.value);
value = midiToFreq(hap.value);
} else if (typeof value === 'string' && isNote(value)) {
value = fromMidi(toMidi(hap.value));
value = midiToFreq(noteToMidi(hap.value));
} else if (typeof value !== 'number') {
throw new Error('not a note or frequency: ' + value);
}
@@ -170,7 +170,7 @@ export function parseNumeral(numOrString) {
return asNumber;
}
if (isNote(numOrString)) {
return toMidi(numOrString);
return noteToMidi(numOrString);
}
throw new Error(`cannot parse as numeral: "${numOrString}"`);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel.cycles/csound",
"version": "0.6.2",
"version": "0.7.1",
"description": "csound bindings for strudel",
"main": "index.mjs",
"publishConfig": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel.cycles/eval",
"version": "0.6.2",
"version": "0.7.1",
"description": "Code evaluator for strudel",
"main": "index.mjs",
"publishConfig": {
+2 -2
View File
@@ -7,7 +7,7 @@ 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 { toMidi } from '@strudel.cycles/core';
import { noteToMidi } from '@strudel.cycles/core';
// if you use WebMidi from outside of this package, make sure to import that instance:
export const { WebMidi } = _WebMidi;
@@ -114,7 +114,7 @@ Pattern.prototype.midi = function (output) {
const duration = hap.duration.valueOf() * 1000 - 5;
if (note) {
const midiNumber = toMidi(note);
const midiNumber = noteToMidi(note);
device.playNote(midiNumber, midichan, {
time,
duration,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel.cycles/midi",
"version": "0.6.0",
"version": "0.7.1",
"description": "Midi API for strudel",
"main": "index.mjs",
"publishConfig": {
+9 -5
View File
@@ -279,7 +279,7 @@ function peg$parse(input, options) {
var peg$f8 = function(p, s, r) { return x => x.options_['ops'].push({ type_: "bjorklund", arguments_ :{ pulse: p, step:s, rotation:r }}) };
var peg$f9 = function(a) { return x => x.options_['ops'].push({ type_: "stretch", arguments_ :{ amount:a, type: 'slow' }}) };
var peg$f10 = function(a) { return x => x.options_['ops'].push({ type_: "stretch", arguments_ :{ amount:a, type: 'fast' }}) };
var peg$f11 = function(a) { return x => x.options_['ops'].push({ type_: "degradeBy", arguments_ :{ amount:a } }) };
var peg$f11 = function(a) { return x => x.options_['ops'].push({ type_: "degradeBy", arguments_ :{ amount:a, seed: seed++ } }) };
var peg$f12 = function(s) { return x => x.options_['ops'].push({ type_: "tail", arguments_ :{ element:s } }) };
var peg$f13 = function(s, ops) { const result = new ElementStub(s, {ops: [], weight: 1, reps: 1});
for (const op of ops) {
@@ -289,8 +289,8 @@ function peg$parse(input, options) {
};
var peg$f14 = function(s) { return new PatternStub(s, 'fastcat'); };
var peg$f15 = function(tail) { return { alignment: 'stack', list: tail }; };
var peg$f16 = function(tail) { return { alignment: 'rand', list: tail }; };
var peg$f17 = function(head, tail) { if (tail && tail.list.length > 0) { return new PatternStub([head, ...tail.list], tail.alignment); } else { return head; } };
var peg$f16 = function(tail) { return { alignment: 'rand', list: tail, seed: seed++ }; };
var peg$f17 = function(head, tail) { if (tail && tail.list.length > 0) { return new PatternStub([head, ...tail.list], tail.alignment, tail.seed); } else { return head; } };
var peg$f18 = function(head, tail) { return new PatternStub(tail ? [head, ...tail.list] : [head], 'polymeter'); };
var peg$f19 = function(sc) { return sc; };
var peg$f20 = function(s) { return { name: "struct", args: { mini:s }}};
@@ -2185,10 +2185,13 @@ function peg$parse(input, options) {
this.location_ = location();
}
var PatternStub = function(source, alignment)
var PatternStub = function(source, alignment, seed)
{
this.type_ = "pattern";
this.arguments_ = { alignment : alignment};
this.arguments_ = { alignment: alignment };
if (seed !== undefined) {
this.arguments_.seed = seed;
}
this.source_ = source;
}
@@ -2214,6 +2217,7 @@ function peg$parse(input, options) {
this.options_ = options;
}
var seed = 0;
peg$result = peg$startRuleFunction();
+9 -5
View File
@@ -19,10 +19,13 @@ This program is free software: you can redistribute it and/or modify it under th
this.location_ = location();
}
var PatternStub = function(source, alignment)
var PatternStub = function(source, alignment, seed)
{
this.type_ = "pattern";
this.arguments_ = { alignment : alignment};
this.arguments_ = { alignment: alignment };
if (seed !== undefined) {
this.arguments_.seed = seed;
}
this.source_ = source;
}
@@ -48,6 +51,7 @@ This program is free software: you can redistribute it and/or modify it under th
this.options_ = options;
}
var seed = 0;
}
start = statement
@@ -137,7 +141,7 @@ op_fast = "*"a:slice
{ return x => x.options_['ops'].push({ type_: "stretch", arguments_ :{ amount:a, type: 'fast' }}) }
op_degrade = "?"a:number?
{ return x => x.options_['ops'].push({ type_: "degradeBy", arguments_ :{ amount:a } }) }
{ return x => x.options_['ops'].push({ type_: "degradeBy", arguments_ :{ amount:a, seed: seed++ } }) }
op_tail = ":" s:slice
{ return x => x.options_['ops'].push({ type_: "tail", arguments_ :{ element:s } }) }
@@ -162,12 +166,12 @@ stack_tail = tail:(comma @sequence)+
// a choose is a series of pipe-separated sequence, one of which is
// chosen at random, each cycle
choose_tail = tail:(pipe @sequence)+
{ return { alignment: 'rand', list: tail }; }
{ return { alignment: 'rand', list: tail, seed: seed++ }; }
// if the stack contains only one element, we don't create a stack but return the
// underlying element
stack_or_choose = head:sequence tail:(stack_tail / choose_tail)?
{ if (tail && tail.list.length > 0) { return new PatternStub([head, ...tail.list], tail.alignment); } else { return head; } }
{ if (tail && tail.list.length > 0) { return new PatternStub([head, ...tail.list], tail.alignment, tail.seed); } else { return head; } }
polymeter_stack = head:sequence tail:stack_tail?
{ return new PatternStub(tail ? [head, ...tail.list] : [head], 'polymeter'); }
+5 -30
View File
@@ -7,12 +7,7 @@ This program is free software: you can redistribute it and/or modify it under th
import * as krill from './krill-parser.js';
import * as strudel from '@strudel.cycles/core';
/* var _seedState = 0;
const randOffset = 0.0002;
function _nextSeed() {
return _seedState++;
} */
const randOffset = 0.0003;
const applyOptions = (parent, code) => (pat, i) => {
const ast = parent.source_[i];
@@ -44,27 +39,9 @@ const applyOptions = (parent, code) => (pat, i) => {
break;
}
case 'degradeBy': {
// TODO: find out what is right here
// example:
/*
stack(
s("hh*8").degrade(),
s("[ht*8]?")
)
*/
// above example will only be in sync when _degradeBy is used...
// it also seems that the nextSeed will create undeterministic behaviour
// as it uses a global _seedState. This is probably the reason for
// https://github.com/tidalcycles/strudel/issues/245
// this is how it was:
/*
return strudel.reify(pat)._degradeByWith(
strudel.rand.early(randOffset * _nextSeed()).segment(1),
op.arguments_.amount ?? 0.5,
);
*/
pat = strudel.reify(pat).degradeBy(op.arguments_.amount === null ? 0.5 : op.arguments_.amount);
pat = strudel
.reify(pat)
._degradeByWith(strudel.rand.early(randOffset * op.arguments_.seed), op.arguments_.amount ?? 0.5);
break;
}
case 'tail': {
@@ -114,9 +91,7 @@ export function patternifyAST(ast, code) {
return strudel.stack(...aligned);
}
if (alignment === 'rand') {
// https://github.com/tidalcycles/strudel/issues/245#issuecomment-1345406422
// return strudel.chooseInWith(strudel.rand.early(randOffset * _nextSeed()).segment(1), children);
return strudel.chooseCycles(...children);
return strudel.chooseInWith(strudel.rand.early(randOffset * ast.arguments_.seed).segment(1), children);
}
const weightedChildren = ast.source_.some((child) => !!child.options_?.weight);
if (!weightedChildren && alignment === 'slowcat') {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel.cycles/mini",
"version": "0.6.0",
"version": "0.7.2",
"description": "Mini notation for strudel",
"main": "index.mjs",
"type": "module",
+19 -5
View File
@@ -140,10 +140,22 @@ describe('mini', () => {
expect(haps.length < 230).toBe(true);
// 'Had too many cycles remaining after degradeBy 0.8');
});
it('supports lists', () => {
expect(minV('a:b c:d:[e:f] g')).toEqual([['a', 'b'], ['c', 'd', ['e', 'f']], 'g']);
it('supports multiple independent uses of the random choice operator ("|")', () => {
const numCycles = 1000;
const values = mini('[a|b] [a|b]')
.queryArc(0, numCycles)
.map((e) => e.value);
const observed = { aa: 0, ab: 0, ba: 0, bb: 0 };
for (let i = 0; i < values.length; i += 2) {
const chunk = values.slice(i, i + 2);
observed[chunk.join('')]++;
}
for (const count of Object.values(observed)) {
// Should fall within 99% confidence interval for binomial with p=0.25.
expect(215 <= count && count <= 286).toBe(true);
}
});
/*it('supports the random choice operator ("|") with nesting', () => {
it('supports the random choice operator ("|") with nesting', () => {
const numCycles = 900;
const haps = mini('a | [b | c] | [d | e | f]').queryArc(0, numCycles);
// Should have about 1/3 a, 1/6 each of b | c, and 1/9 each of d | e | f.
@@ -168,6 +180,8 @@ describe('mini', () => {
// 15.086 is the chisq for 5 degrees of freedom at 99%, so for 99% of uniformly-distributed
// PRNG, this test should succeed
expect(chisq <= 15.086).toBe(true);
// assert(chisq <= 15.086, chisq + ' was expected to be less than 15.086 under chi-squared test');
});*/
});
it('supports lists', () => {
expect(minV('a:b c:d:[e:f] g')).toEqual([['a', 'b'], ['c', 'd', ['e', 'f']], 'g']);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel.cycles/osc",
"version": "0.6.0",
"version": "0.7.1",
"description": "OSC messaging for strudel",
"main": "osc.mjs",
"publishConfig": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel.cycles/react",
"version": "0.6.4",
"version": "0.7.1",
"description": "React components for strudel",
"main": "src/index.js",
"publishConfig": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel.cycles/serial",
"version": "0.6.0",
"version": "0.7.1",
"description": "Webserial API for strudel",
"main": "serial.mjs",
"publishConfig": {
+17 -8
View File
@@ -1,4 +1,4 @@
import { toMidi } from '@strudel.cycles/core';
import { noteToMidi, freqToMidi } from '@strudel.cycles/core';
import { getAudioContext, registerSound, getEnvelope } from '@strudel.cycles/webaudio';
import gm from './gm.mjs';
@@ -18,15 +18,24 @@ async function loadFont(name) {
return loadCache[name];
}
export async function getFontBufferSource(name, pitch, ac) {
if (typeof pitch === 'string') {
pitch = toMidi(pitch);
export async function getFontBufferSource(name, value, ac) {
let { note = 'c3', freq } = value;
let midi;
if (freq) {
midi = freqToMidi(freq);
} else if (typeof note === 'string') {
midi = noteToMidi(note);
} else if (typeof note === 'number') {
midi = note;
} else {
throw new Error(`unexpected "note" type "${typeof note}"`);
}
const { buffer, zone } = await getFontPitch(name, pitch, ac);
const { buffer, zone } = await getFontPitch(name, midi, ac);
const src = ac.createBufferSource();
src.buffer = buffer;
const baseDetune = zone.originalPitch - 100.0 * zone.coarseTune - zone.fineTune;
const playbackRate = 1.0 * Math.pow(2, (100.0 * pitch - baseDetune) / 1200.0);
const playbackRate = 1.0 * Math.pow(2, (100.0 * midi - baseDetune) / 1200.0);
// src detune?
src.playbackRate.value = playbackRate;
const loop = zone.loopStart > 1 && zone.loopStart < zone.loopEnd;
@@ -121,11 +130,11 @@ export function registerSoundfonts() {
registerSound(
name,
async (time, value, onended) => {
const { note = 'c3', n = 0 } = value;
const { n = 0 } = value;
const { attack = 0.001, decay = 0.001, sustain = 1, release = 0.001 } = value;
const font = fonts[n % fonts.length];
const ctx = getAudioContext();
const bufferSource = await getFontBufferSource(font, note, ctx);
const bufferSource = await getFontBufferSource(font, value, ctx);
bufferSource.start(time);
const { node: envelope, stop: releaseEnvelope } = getEnvelope(attack, decay, sustain, release, 0.3, time);
bufferSource.connect(envelope);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel.cycles/soundfonts",
"version": "0.6.0",
"version": "0.7.1",
"description": "Soundsfont support for strudel",
"main": "index.mjs",
"publishConfig": {
+3 -3
View File
@@ -1,4 +1,4 @@
import { Pattern, getPlayableNoteValue, toMidi } from '@strudel.cycles/core';
import { Pattern, getPlayableNoteValue, noteToMidi } from '@strudel.cycles/core';
import { getAudioContext, registerSound } from '@strudel.cycles/webaudio';
import { loadSoundfont as _loadSoundfont, startPresetNote } from 'sfumato';
@@ -8,7 +8,7 @@ Pattern.prototype.soundfont = function (sf, n = 0) {
const note = getPlayableNoteValue(h);
const preset = sf.presets[n % sf.presets.length];
const deadline = ctx.currentTime + t - ct;
const args = [ctx, preset, toMidi(note), deadline];
const args = [ctx, preset, noteToMidi(note), deadline];
const stop = startPresetNote(...args);
stop(deadline + h.duration);
});
@@ -36,7 +36,7 @@ export function loadSoundfont(url) {
throw new Error('preset not found');
}
const deadline = time; // - ctx.currentTime;
const args = [ctx, p, toMidi(note), deadline];
const args = [ctx, p, noteToMidi(note), deadline];
const stop = startPresetNote(...args);
return { node: undefined, stop };
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel.cycles/tonal",
"version": "0.6.0",
"version": "0.7.1",
"description": "Tonal functions for strudel",
"main": "index.mjs",
"publishConfig": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel.cycles/tone",
"version": "0.6.0",
"version": "0.7.1",
"description": "Tone.js API for strudel",
"main": "index.mjs",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel.cycles/transpiler",
"version": "0.6.0",
"version": "0.7.1",
"description": "Transpiler for strudel user code. Converts syntactically correct but semantically meaningless JS into evaluatable strudel code.",
"main": "index.mjs",
"publishConfig": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel.cycles/webaudio",
"version": "0.6.1",
"version": "0.7.1",
"description": "Web Audio helpers for Strudel",
"main": "index.mjs",
"type": "module",
+2 -2
View File
@@ -1,4 +1,4 @@
import { logger, toMidi, valueToMidi } from '@strudel.cycles/core';
import { logger, noteToMidi, valueToMidi } from '@strudel.cycles/core';
import { getAudioContext, registerSound } from './index.mjs';
import { getEnvelope } from './helpers.mjs';
@@ -34,7 +34,7 @@ export const getSampleBufferSource = async (s, n, note, speed, freq, bank) => {
if (Array.isArray(bank)) {
sampleUrl = bank[n % bank.length];
} else {
const midiDiff = (noteA) => toMidi(noteA) - midi;
const midiDiff = (noteA) => noteToMidi(noteA) - midi;
// object format will expect keys as notes
const closest = Object.keys(bank)
.filter((k) => !k.startsWith('_'))
+3 -3
View File
@@ -1,4 +1,4 @@
import { fromMidi, toMidi } from '@strudel.cycles/core';
import { midiToFreq, noteToMidi } from '@strudel.cycles/core';
import { registerSound } from './webaudio.mjs';
import { getOscillator, gainNode, getEnvelope } from './helpers.mjs';
@@ -13,11 +13,11 @@ export function registerSynthSounds() {
// with synths, n and note are the same thing
n = note || n || 36;
if (typeof n === 'string') {
n = toMidi(n); // e.g. c3 => 48
n = noteToMidi(n); // e.g. c3 => 48
}
// get frequency
if (!freq && typeof n === 'number') {
freq = fromMidi(n); // + 48);
freq = midiToFreq(n); // + 48);
}
// maybe pull out the above frequency resolution?? (there is also getFrequency but it has no default)
// make oscillator
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel.cycles/webdirt",
"version": "0.6.0",
"version": "0.7.1",
"description": "WebDirt integration for Strudel",
"main": "index.mjs",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@strudel.cycles/xen",
"version": "0.6.0",
"version": "0.7.1",
"description": "Xenharmonic API for strudel",
"main": "index.mjs",
"publishConfig": {