UNPKG

scribbletune

Version:

Create music with JavaScript and Node.js!

1 lines 81.6 kB
{"version":3,"sources":["../src/browser-index.ts","../src/arp.ts","../src/utils.ts","../src/channel/instrument-factory.ts","../src/channel/sequence-builder.ts","../src/clip-utils.ts","../src/browser-clip.ts","../src/midi.ts","../src/progression.ts","../src/channel/effects-chain.ts","../src/channel.ts","../src/session.ts"],"sourcesContent":["import { chord, chords, scale, scales } from 'harmonics';\nimport { arp } from './arp';\nimport { clip } from './browser-clip';\nimport { midi } from './midi';\nimport {\n getChordDegrees,\n getChordsByProgression,\n progression,\n} from './progression';\nimport { Session } from './session';\n\nexport * from './types';\nexport {\n scale,\n scale as mode,\n scales,\n scales as modes,\n chord,\n chords,\n clip,\n getChordDegrees,\n getChordsByProgression,\n progression,\n arp,\n midi,\n Session,\n};\n","import { inlineChord } from 'harmonics';\nimport type { ArpParams } from './types';\nimport { errorHasMessage } from './utils';\n\nconst DEFAULT_OCTAVE = 4;\n\n/**\n * Take an array and fill it with it s own elements in the next octave till it s of the specified `len`\n * @param {Array} arr e.g. ['a4', 'b4']\n * @param {Number} e.g. len 4\n * @return {Array} e.g. ['a4', 'b4', 'a5', 'b5']\n */\nconst fillArr = (arr: string[], len: number): string[] => {\n const bumpOctave = (el: string): string => {\n if (!el) {\n throw new Error('Empty element');\n }\n const note = el.replace(/\\d/, '');\n const oct = el.replace(/\\D/g, '') || DEFAULT_OCTAVE;\n if (!note) {\n throw new Error('Incorrect note');\n }\n return note + (+oct + 1);\n };\n\n // Create a couple of chord arrays with bumped octaves\n // so that something like [c3, e3, g4] turns into [c4, e4, g5] and [c5, e5, g6]\n const arr1 = arr.map(bumpOctave);\n const arr2 = arr1.map(bumpOctave);\n const finalArr = [...arr, ...arr1, ...arr2];\n\n // Slice and return only as much as required\n return finalArr.slice(0, len);\n};\n\n/**\n *\n * @param chordsOrParams a string that denotes space (comma?) separated chords to be used or an object with additional properties\n * By default, if this is a string, the count of notes generated is 8 and the order is ascending.\n * For instance arp('CM FM') will result in an array of notes [C4, E4, G4, F4, A4, C4, C5, E5]\n * You can even provide Params as an object.\n * For e.g. arp({count: 8, order: '10325476', chords: 'FM_4 Gm7b5_4 AbM_4 Bbm_4 Cm_5 DbM_5 EbM_5})\n */\nexport const arp = (chordsOrParams: string | ArpParams): string[] => {\n let finalArr: string[] = [];\n const params: ArpParams = {\n count: 4,\n order: '0123',\n chords: '',\n };\n\n if (typeof chordsOrParams === 'string') {\n params.chords = chordsOrParams;\n } else {\n if (chordsOrParams.order?.match(/\\D/g)) {\n throw new TypeError('Invalid value for order');\n }\n\n if (chordsOrParams.count > 8 || chordsOrParams.count < 2) {\n throw new TypeError('Invalid value for count');\n }\n\n // Provision a order for the notes in case only count was provided\n if (chordsOrParams.count && !chordsOrParams.order) {\n params.order = Array.from(Array(chordsOrParams.count).keys()).join('');\n }\n Object.assign(params, chordsOrParams);\n }\n\n // Chords can be passed as a string, e.g. 'CM_4 FM_4'\n // or as an array of notes arrays e.g. [['C3', 'E3', 'G3', 'B3'], ['F3', 'A3', 'C4', 'E4']]\n if (typeof params.chords === 'string') {\n const chordsArr: string[] = params.chords.split(' ');\n chordsArr.forEach((c, i) => {\n try {\n const filledArr = fillArr(inlineChord(c), params.count);\n // reorder the filledArr as per params.order\n const reorderedArr = (params.order as string)\n .split('')\n .map((idx: string) => filledArr[Number(idx)]);\n finalArr = [...finalArr, ...reorderedArr];\n } catch (_e) {\n throw new Error(\n `Cannot decode chord ${i + 1} \"${c}\" in given \"${params.chords}\"`\n );\n }\n });\n } else if (Array.isArray(params.chords)) {\n params.chords.forEach((c, i) => {\n try {\n const filledArr = fillArr(c as string[], params.count);\n // reorder the filledArr as per params.order\n const reorderedArr = (params.order as string)\n .split('')\n .map((idx: string) => filledArr[Number(idx)]);\n finalArr = [...finalArr, ...reorderedArr];\n } catch (e) {\n throw new Error(\n `${errorHasMessage(e) ? e.message : e} in chord ${i + 1} \"${c}\"`\n );\n }\n });\n } else {\n throw new TypeError('Invalid value for chords');\n }\n\n return finalArr;\n};\n","import { chord, inlineChord } from 'harmonics';\nimport type { PatternElement } from './types';\n\n/**\n * Take a string input and check if it s a note name or not\n * @param {String} str Note name e.g. c4\n * @return {Boolean} Return true for c4 or return false for something like CM\n */\nexport const isNote = (str: string): boolean =>\n /^[a-gA-G](?:#|b)?\\d$/.test(str);\n\n/**\n * Take a String input such as xxx[xx[xx]]\n * and return an Array as ['x', 'x', 'x', ['x', 'x', ['x', 'x']]]\n * @param {String} str\n * @return {Array}\n */\nexport const expandStr = (str: string): PatternElement[] => {\n str = JSON.stringify(str.split(''));\n str = str.replace(/,\"\\[\",/g, ', [');\n str = str.replace(/\"\\[\",/g, '[');\n str = str.replace(/,\"\\]\"/g, ']');\n return JSON.parse(str);\n};\n\n/**\n * Basic Array randomizer\n * @param {Array} arr\n * @param {boolean} fullShuffle Ensure no elements remain in old place\n * @return {Array}\n */\nexport const shuffle = <T>(arr: T[], fullShuffle = true): T[] => {\n const lastIndex: number = arr.length - 1;\n\n // Shuffle algorithm by Richard Durstenfeld (Donald E. Knuth), also Ronald Fisher and Frank Yates.\n // \"Full Shuffle\" Modification to ensure no elements remain in their original place (by taking each element once\n // and swapping with any remaining elements)\n arr.forEach((el, idx: number) => {\n if (idx >= lastIndex) {\n // No shuffling last element\n // One before last is always swapped with last at the end of the loop\n // Since previous swaps can move last element into other places, there is still a random shuffle of last element\n return;\n }\n // Swap el with one of the higher elements randomly\n const rnd = fullShuffle\n ? // Pick random number from idx+1 to lastIndex (Modified algorithm, (N-1)! combinations)\n // Math.random -> [0, 1) -> [0, lastIndex-idx ) --floor-> [0, lastIndex-idx-1]\n // rnd = [0, lastIndex-idx-1] + 1 + idx = [1 + idx, lastIndex]\n // (Original algorithm would pick rnd = [idx, lastIndex], thus any element could arrive back into its slot)\n Math.floor(Math.random() * (lastIndex - idx)) + 1 + idx\n : // Pick random number from idx to lastIndex (Unmodified Richard Durstenfeld, N! combinations)\n Math.floor(Math.random() * (lastIndex + 1 - idx)) + idx;\n arr[idx] = arr[rnd];\n arr[rnd] = el;\n });\n\n return arr;\n};\n\n/**\n * Return an array of numbers relative to maxLevel (default 127) ordered in a Sine wave format.\n * Used by the `sizzle` param of `clip` to add rudimentary variation to the accent of each note.\n * @param maxLevel A number not exceeding 127\n * @return Example: [63, 90, 110, 127, 110, 90, 63, 0, 63, 90, 110, 127, 110, 90, 63, 0]\n */\nexport const sizzleMap = (maxLevel = 127): number[] => {\n const pi = Math.PI;\n const piArr: number[] = [\n pi / 6,\n pi / 4,\n pi / 3,\n pi / 2,\n (2 * pi) / 3,\n (3 * pi) / 4,\n (5 * pi) / 6,\n pi,\n ];\n const piArrRev: number[] = [\n 0,\n pi / 6,\n pi / 4,\n pi / 3,\n pi / 2,\n (2 * pi) / 3,\n (3 * pi) / 4,\n (5 * pi) / 6,\n ];\n piArrRev.reverse();\n const arr: number[] = piArr.concat(piArrRev);\n return arr.map(element => Math.round(Math.sin(element) * maxLevel));\n};\n\n/**\n * Pick one item randomly from an array and return it\n * @param arr\n */\nexport const pickOne = <T = unknown>(arr: T[]): T =>\n arr[Math.floor(Math.random() * arr.length)];\n\n/**\n * Boolean generator\n */\nexport const dice = (): boolean => !!Math.round(Math.random());\n\n/** Type guard: check if a caught value has a string `message` property. */\nexport const errorHasMessage = (x: unknown): x is { message: string } => {\n return (\n typeof x === 'object' &&\n x !== null &&\n 'message' in x &&\n typeof (x as { message: unknown }).message === 'string'\n );\n};\n\n/**\n * 'el' could be an inlineChord() e.g. Cmaj7 or Dbsus2_5\n * or a chord() e.g. 'C3 M'\n */\nexport const convertChordToNotes = (el: string): string[] => {\n // Try both inlineChord() and chord()\n let c1: string[] | undefined;\n let c2: string[] | undefined;\n let e1: unknown;\n let e2: unknown;\n try {\n c1 = inlineChord(el);\n } catch (e) {\n e1 = e;\n }\n try {\n c2 = chord(el.replace(/_/g, ' ')); // chord() is not friendly to underscores\n } catch (e) {\n e2 = e;\n }\n\n if (!e1 && !e2 && c1 && c2) {\n // Both inlineChord() and chord() have result\n if (c1.toString() !== c2.toString()) {\n throw new Error(`Chord ${el} cannot decode, guessing ${c1} or ${c2}`);\n }\n return c1;\n } // else\n if (!e1 && c1) {\n return c1;\n } // else\n if (!e2 && c2) {\n return c2;\n } // else\n\n // Give up, last try:\n return chord(el);\n};\n\n/**\n * Normalize a note, chord name, or array of notes into a string array.\n * Single notes become `['C4']`, chord names are expanded via harmonics.\n */\nexport const convertChordsToNotes = (\n el: string | (string | string[])[]\n): string[] => {\n if (typeof el === 'string' && isNote(el as string)) {\n // A note needs to be an array so that it can accomodate chords or single notes with a single interface\n return [el];\n }\n\n if (Array.isArray(el)) {\n // This could be a chord provided as an array or an array of arrays\n el.forEach(n => {\n // This could be a chord provided as an array\n if (Array.isArray(n)) {\n // TODO: Can we convert it to something useful?\n // make sure it uses valid notes\n n.forEach(n1 => {\n if (typeof n1 !== 'string' || !isNote(n1)) {\n throw new TypeError('array of arrays must comprise valid notes');\n }\n });\n // throw new TypeError('cannot decode array of arrays');\n } else if (typeof n !== 'string' || !isNote(n)) {\n // make sure it uses valid notes\n throw new TypeError('array must comprise valid notes');\n }\n });\n\n return el as string[];\n // ? return el as (string | string[])[];\n }\n\n if (!Array.isArray(el)) {\n const c = convertChordToNotes(el);\n if (c?.length) {\n return c;\n }\n }\n\n throw new Error(`Chord ${el} not found`);\n};\n\n/** Return a random integer from 0 to `num` (inclusive). */\nexport const randomInt = (num = 1): number => Math.round(Math.random() * num);\n","import type { ChannelParams, SynthParams } from '../types';\nimport { errorHasMessage } from '../utils';\n\n/**\n * Check Tone.js object loaded state and either invoke `resolve` right away, or attach to and wait using Tone onload cb.\n * It's an ugly hack that reaches into Tone's internal ._buffers or ._buffer to insert itself into .onload() callback.\n * Tone has different ways to pull the onload callback from within to the API, so this implementation is very brittle.\n * The sole reason for its existence is to handle async loaded state of Tone instruments that we allow to pass in from outside.\n * If that option is eliminated, then this hacky function can be killed (or re-implemented via public onload API)\n * @param toneObject Tone.js object (will work with non-Tone objects that have same loaded/onload properties)\n * @param resolve onload callback\n */\nexport function checkToneObjLoaded(\n toneObject: ToneLoadable,\n resolve: () => void\n): void {\n const skipRecursion = toneObject instanceof Tone.Sampler; // Sampler has a Map of ToneAudioBuffer, and our method to find inner .onload() does not work since there is no single one.\n\n if ('loaded' in toneObject) {\n if (toneObject.loaded) {\n resolve();\n return;\n }\n if (skipRecursion) {\n return;\n }\n // Try Recursion into inner objects:\n let handled = false;\n ['buffer', '_buffer', '_buffers'].forEach(key => {\n if (key in toneObject) {\n checkToneObjLoaded(toneObject[key] as ToneLoadable, resolve);\n handled = true;\n }\n });\n if (handled) {\n return;\n }\n }\n\n // Check object type if it has load/onload (and _buffers or _buffer), then call resolve()\n // The list was created for Tone@14.8.0 by grepping and reviewing the source code.\n // Known objecs to have:\n const hasOnload =\n toneObject instanceof Tone.ToneAudioBuffer ||\n toneObject instanceof Tone.ToneBufferSource ||\n // Falback for \"future\" objects\n ('loaded' in toneObject && 'onload' in toneObject);\n\n if (!hasOnload) {\n // This is not a good assumption. E.g. it does not work for Tone.ToneAudioBuffers\n resolve();\n } else {\n const oldOnLoad = toneObject.onload;\n toneObject.onload = () => {\n if (oldOnLoad && typeof oldOnLoad === 'function') {\n toneObject.onload = oldOnLoad;\n oldOnLoad();\n }\n resolve();\n };\n }\n}\n\n/** Clone a Tone instrument into a different audio context, waiting for it to load. */\nexport function recreateToneObjectInContext(\n toneObject: ToneInstrument,\n context: ToneAudioContext\n): Promise<ToneInstrument> {\n context = context || Tone.getContext();\n\n return new Promise<ToneInstrument>((resolve, _reject) => {\n if (toneObject instanceof Tone.PolySynth) {\n const newObj = new (Tone as unknown as ToneDynamic)[\n toneObject._dummyVoice.name\n ]({\n ...toneObject.get(),\n context,\n });\n checkToneObjLoaded(newObj as ToneLoadable, () => resolve(newObj));\n } else if (toneObject instanceof Tone.Player) {\n const newObj = new Tone.Player({\n url: toneObject._buffer,\n context,\n onload: () =>\n checkToneObjLoaded(newObj as ToneLoadable, () => resolve(newObj)),\n } as Record<string, unknown>);\n } else if (toneObject instanceof Tone.Sampler) {\n const { attack, curve, release, volume } = toneObject.get();\n const paramsFromSampler = {\n attack,\n curve,\n release,\n volume,\n };\n const paramsFromBuffers = {\n baseUrl: toneObject._buffers.baseUrl,\n urls: Object.fromEntries(toneObject._buffers._buffers.entries()),\n };\n const newObj = new Tone.Sampler({\n ...paramsFromSampler,\n ...paramsFromBuffers,\n context,\n onload: () =>\n checkToneObjLoaded(newObj as unknown as ToneLoadable, () =>\n resolve(newObj)\n ),\n } as Record<string, unknown>);\n } else {\n const newObj = new (Tone as unknown as ToneDynamic)[toneObject.name]({\n ...toneObject.get(),\n context,\n onload: () =>\n checkToneObjLoaded(newObj as ToneLoadable, () => resolve(newObj)),\n });\n checkToneObjLoaded(newObj as ToneLoadable, () => resolve(newObj));\n }\n });\n}\n\nexport interface InstrumentResult {\n instrument: ToneInstrument;\n external?: ExternalOutput;\n}\n\n/**\n * Create an instrument from channel params. The instrument is available\n * synchronously on the returned object; `initPromise` resolves once the\n * instrument is fully loaded, recreated in the correct context, and\n * volume-adjusted.\n */\nexport function createInstrument(\n context: ToneAudioContext,\n params: ChannelParams,\n channelMeta: { idx: number | string; name: string }\n): {\n instrument: ToneInstrument;\n external?: ExternalOutput;\n initPromise: Promise<ToneInstrument>;\n} {\n let instrument!: ToneInstrument;\n let external: ExternalOutput | undefined;\n\n context = context || Tone.getContext();\n\n const loadPromise = new Promise<void>((resolve, reject) => {\n /*\n * 1. The params object can be used to pass a sample (sound source) OR a synth(Synth/FMSynth/AMSynth etc) or samples.\n * Scribbletune will then create a Tone.js Player or Tone.js Instrument or Tone.js Sampler respectively\n * 2. It can also be used to pass a Tone.js Player object or instrument that was created elsewhere\n * (mostly by Scribbletune itself in the channel creation method)\n **/\n\n if (params.synth) {\n if (params.instrument) {\n throw new Error(\n 'Either synth or instrument can be provided, but not both.'\n );\n }\n if ((params.synth as SynthParams).synth) {\n const synthName = (params.synth as SynthParams).synth;\n const preset = (params.synth as SynthParams).preset || {};\n instrument = new (Tone as unknown as ToneDynamic)[synthName]({\n ...preset,\n context,\n // Use onload for cases when synthName calls out Tone.Sample/Player/Sampler.\n // It could be a universal way to load Tone.js instruments.\n onload: () => checkToneObjLoaded(instrument, resolve),\n // This onload is ignored in all synths. Therefore we call checkToneObjLoaded() again below.\n // It is safe to call resolve() multiple times for Promise<void>\n });\n checkToneObjLoaded(instrument, resolve);\n } else {\n instrument = params.synth as unknown as ToneInstrument; // TODO: This is dangerous by-reference assignment.\n console.warn(\n 'The \"synth\" parameter with instrument will be deprecated in the future. Please use the \"instrument\" parameter instead.'\n );\n // params.synth describing the Tone[params.synth.synth] is allowed.\n checkToneObjLoaded(instrument, resolve);\n }\n } else if (typeof params.instrument === 'string') {\n instrument = new (Tone as unknown as ToneDynamic)[\n params.instrument as string\n ]({ context });\n checkToneObjLoaded(instrument, resolve);\n } else if (params.instrument) {\n instrument = params.instrument; // TODO: This is dangerous by-reference assignment.\n checkToneObjLoaded(instrument, resolve);\n } else if (params.sample || params.buffer) {\n instrument = new Tone.Player({\n url: params.sample || params.buffer,\n context,\n onload: () => checkToneObjLoaded(instrument, resolve),\n });\n } else if (params.samples) {\n instrument = new Tone.Sampler({\n urls: params.samples,\n context,\n onload: () => checkToneObjLoaded(instrument, resolve),\n });\n } else if (params.sampler) {\n instrument = params.sampler; // TODO: This is dangerous by-reference assignment.\n checkToneObjLoaded(instrument, resolve);\n } else if (params.player) {\n instrument = params.player; // TODO: This is dangerous by-reference assignment.\n checkToneObjLoaded(instrument, resolve);\n } else if (params.external) {\n external = { ...params.external }; // Sanitize object by shallow clone\n instrument = {\n context,\n volume: { value: 0 },\n } as unknown as ToneInstrument;\n // Do not call! checkToneObjLoaded(instrument, resolve);\n\n if (params.external.init) {\n return params.external\n .init(context.rawContext)\n .then(() => {\n resolve();\n })\n .catch((e: unknown) => {\n reject(\n new Error(\n `${errorHasMessage(e) ? e.message : e} loading external output module of channel idx ${\n channelMeta.idx\n }, ${channelMeta.name ?? '(no name)'}`\n )\n );\n });\n } else {\n resolve();\n }\n } else {\n throw new Error(\n 'One of required synth|instrument|sample|sampler|samples|buffer|player|external is not provided!'\n );\n }\n\n if (!instrument) {\n throw new Error('Failed instantiating instrument from given params.');\n }\n });\n\n const initPromise = loadPromise\n .then(() => {\n // Recreate instrument in the target context if needed\n if (!external && instrument?.context !== context) {\n return recreateToneObjectInContext(instrument, context).then(newObj => {\n instrument = newObj;\n });\n }\n })\n .then(() => {\n // Adjust volume\n if (params.volume) {\n instrument.volume.value = params.volume;\n external?.setVolume?.(params.volume);\n }\n return instrument;\n });\n\n return { instrument, external, initPromise };\n}\n","import { getDuration, getNote } from '../browser-clip';\nimport type { ClipParams, SeqFn } from '../types';\n\n/** Subset of Channel properties needed by the sequence callback. */\nexport interface SequenceHost {\n instrument: ToneInstrument;\n external?: ExternalOutput;\n hasLoaded: boolean;\n clipNoteCount: number;\n}\n\n/**\n * Build the callback function for a Tone.Sequence based on the instrument type.\n * Returns a function that triggers notes/samples at each step of the sequence.\n */\nexport function buildSequenceCallback(\n params: ClipParams,\n host: SequenceHost,\n playerCb: (params: Record<string, unknown>) => void,\n eventCb: (event: string, params: Record<string, unknown>) => void\n): SeqFn {\n if (host.external) {\n const ext = host.external;\n return (time: string, el: string) => {\n if (el === 'x' || el === 'R') {\n const counter = host.clipNoteCount;\n if (host.hasLoaded) {\n const note = getNote(el, params, counter)[0] as string;\n const duration = getDuration(params, counter);\n const durSeconds = Tone.Time(duration).toSeconds();\n playerCb({ note, duration, time, counter });\n try {\n ext.triggerAttackRelease?.(note, durSeconds, time);\n } catch (e) {\n eventCb('error', { e }); // Report play errors.\n }\n }\n host.clipNoteCount++;\n }\n };\n }\n\n if (host.instrument instanceof Tone.Player) {\n return (time: string, el: string) => {\n if (el === 'x' || el === 'R') {\n const counter = host.clipNoteCount;\n if (host.hasLoaded) {\n playerCb({ note: '', duration: '', time, counter });\n try {\n host.instrument.start(time);\n } catch (e) {\n eventCb('error', { e }); // Report play errors.\n }\n }\n host.clipNoteCount++;\n }\n };\n }\n\n if (\n host.instrument instanceof Tone.PolySynth ||\n host.instrument instanceof Tone.Sampler\n ) {\n return (time: string, el: string) => {\n if (el === 'x' || el === 'R') {\n const counter = host.clipNoteCount;\n if (host.hasLoaded) {\n const note = getNote(el, params, counter) as string | string[];\n const duration = getDuration(params, counter);\n playerCb({ note, duration, time, counter });\n try {\n host.instrument.triggerAttackRelease(note, duration, time);\n } catch (e) {\n eventCb('error', { e }); // Report play errors.\n }\n }\n host.clipNoteCount++;\n }\n };\n }\n\n if (host.instrument instanceof Tone.NoiseSynth) {\n return (time: string, el: string) => {\n if (el === 'x' || el === 'R') {\n const counter = host.clipNoteCount;\n if (host.hasLoaded) {\n const duration = getDuration(params, counter);\n playerCb({ note: '', duration, time, counter });\n try {\n (host.instrument as Tone.NoiseSynth).triggerAttackRelease(\n duration,\n time\n );\n } catch (e) {\n eventCb('error', { e }); // Report play errors.\n }\n }\n host.clipNoteCount++;\n }\n };\n }\n\n return (time: string, el: string) => {\n if (el === 'x' || el === 'R') {\n const counter = host.clipNoteCount;\n if (host.hasLoaded) {\n const note = getNote(el, params, counter)[0] as string;\n const duration = getDuration(params, counter);\n playerCb({ note, duration, time, counter });\n try {\n host.instrument.triggerAttackRelease(note, duration, time);\n } catch (e) {\n eventCb('error', { e }); // Report play errors.\n }\n }\n host.clipNoteCount++;\n }\n };\n}\n","import type { ClipParams } from './types';\nimport { convertChordsToNotes, shuffle } from './utils';\n\nconst defaultParams: ClipParams = {\n notes: ['C4'],\n pattern: 'x',\n shuffle: false,\n sizzle: false,\n sizzleReps: 1,\n arpegiate: false,\n subdiv: '4n',\n amp: 100,\n accentLow: 70,\n randomNotes: null,\n offlineRendering: false,\n};\n\n/** Throw if the pattern string contains characters outside `x - _ [ ] R`. */\nexport const validatePattern = (pattern: string): void => {\n if (/[^x\\-_[\\]R]/.test(pattern)) {\n throw new TypeError(\n `pattern can only comprise x - _ [ ] R, found ${pattern}`\n );\n }\n};\n\n/**\n * Merge defaults into clip params, normalize notes/randomNotes from strings\n * to arrays, validate the pattern, and optionally shuffle notes.\n */\nexport const preprocessClipParams = (\n params: ClipParams,\n extraDefaults?: Partial<ClipParams>\n): ClipParams => {\n params = { ...defaultParams, ...extraDefaults, ...(params || {}) };\n\n // Notes: string → array\n if (typeof params.notes === 'string') {\n params.notes = params.notes.replace(/\\s{2,}/g, ' ').split(' ');\n }\n params.notes = params.notes ? params.notes.map(convertChordsToNotes) : [];\n\n // Pattern validation\n validatePattern(params.pattern);\n\n // Shuffle\n if (params.shuffle) {\n params.notes = shuffle(params.notes);\n }\n\n // Random notes preprocessing\n if (params.randomNotes && typeof params.randomNotes === 'string') {\n params.randomNotes = params.randomNotes.replace(/\\s{2,}/g, ' ').split(/\\s/);\n }\n if (params.randomNotes) {\n params.randomNotes = (params.randomNotes as string[]).map(\n convertChordsToNotes\n );\n }\n\n return params;\n};\n","import type { Channel } from './channel';\nimport { checkToneObjLoaded } from './channel/instrument-factory';\nimport {\n buildSequenceCallback,\n type SequenceHost,\n} from './channel/sequence-builder';\nimport { preprocessClipParams } from './clip-utils';\nimport type { ClipParams, PatternElement, SeqFn } from './types';\nimport { expandStr, randomInt } from './utils';\n\nconst defaultSubdiv = '4n';\nconst defaultDur = '8n';\n\n/** Get the note(s) for the current step, cycling through the notes array. */\nexport const getNote = (\n el: string,\n params: ClipParams,\n counter: number\n): string | (string | string[])[] => {\n if (el === 'R' && params.randomNotes && params.randomNotes.length > 0) {\n return params.randomNotes[randomInt(params.randomNotes.length - 1)];\n }\n if (params.notes) {\n return params.notes[counter % (params.notes.length || 1)];\n }\n return '';\n};\n\n/** Get the duration for the current step, cycling through the durations array. */\nexport const getDuration = (\n params: ClipParams,\n counter: number\n): string | number => {\n return params.durations\n ? params.durations[counter % params.durations.length]\n : params.dur || params.subdiv || defaultDur;\n};\n\n/**\n * Walk a nested pattern array and compute a flat list of note durations (in seconds).\n * Underscores (`_`) extend the previous note's duration.\n */\nexport const recursivelyApplyPatternToDurations = (\n patternArr: PatternElement[],\n length: number,\n durations: number[] = []\n): number[] => {\n patternArr.forEach(char => {\n if (typeof char === 'string') {\n if (char === 'x' || char === 'R') {\n durations.push(length);\n }\n if (char === '_' && durations.length) {\n durations[durations.length - 1] += length;\n }\n }\n if (Array.isArray(char)) {\n recursivelyApplyPatternToDurations(char, length / char.length, durations);\n }\n });\n return durations;\n};\n\n/**\n * Create a Tone.Sequence from clip parameters for live browser playback.\n * When a Channel is provided, uses its instrument. When omitted, creates a\n * standalone Tone.Player from `params.sample`.\n */\nconst generateSequence = (\n params: ClipParams,\n channel?: Channel,\n context?: ToneAudioContext\n): ToneSequence => {\n context = context || Tone.getContext();\n\n if (!params.pattern) {\n throw new Error('No pattern provided!');\n }\n\n if (!params.durations && !params.dur) {\n params.durations = recursivelyApplyPatternToDurations(\n expandStr(params.pattern),\n Tone.Ticks(params.subdiv || defaultSubdiv).toSeconds()\n );\n }\n\n let callback: SeqFn;\n\n if (channel) {\n // Channel-based path (existing behavior)\n callback = channel.getSeqFn(params);\n } else if (params.sample) {\n // Standalone sample-based path\n const player = new Tone.Player({ url: params.sample, context });\n player.toDestination();\n player.sync();\n\n const host: SequenceHost = {\n instrument: player,\n hasLoaded: false,\n clipNoteCount: 0,\n };\n\n checkToneObjLoaded(player as ToneLoadable, () => {\n host.hasLoaded = true;\n });\n\n const noop = () => {};\n callback = buildSequenceCallback(params, host, noop, noop);\n } else {\n throw new Error(\n 'Either a Channel or a sample URL must be provided to create a clip.'\n );\n }\n\n return new Tone.Sequence({\n callback,\n events: expandStr(params.pattern),\n subdivision: params.subdiv || defaultSubdiv,\n context,\n });\n};\n\n/** Calculate total duration (in seconds) of a pattern at the given subdivision. */\nexport const totalPatternDuration = (\n pattern: string,\n subdivOrLength: string | number\n): number => {\n return typeof subdivOrLength === 'number'\n ? subdivOrLength * expandStr(pattern).length\n : Tone.Ticks(subdivOrLength).toSeconds() * expandStr(pattern).length;\n};\n\n/** Compute the least common multiple of two positive integers. */\nconst leastCommonMultiple = (n1: number, n2: number): number => {\n const [smallest, largest] = n1 < n2 ? [n1, n2] : [n2, n1];\n let i = largest;\n while (i % smallest !== 0) {\n i += largest;\n }\n return i;\n};\n\n/**\n * Calculate the minimum duration (in seconds) needed to offline-render a clip\n * so that all notes cycle through completely.\n */\nexport const renderingDuration = (\n pattern: string,\n subdivOrLength: string | number,\n notes: string | (string | string[])[],\n randomNotes: undefined | null | string | (string | string[])[]\n): number => {\n const patternRegularNotesCount = pattern.split('').filter(c => {\n return c === 'x';\n }).length;\n const patternRandomNotesCount = pattern.split('').filter(c => {\n return c === 'R';\n }).length;\n const patternNotesCount = randomNotes?.length\n ? patternRegularNotesCount\n : patternRegularNotesCount + patternRandomNotesCount;\n const notesCount = notes.length || 1;\n return (\n (totalPatternDuration(pattern, subdivOrLength) / patternNotesCount) *\n leastCommonMultiple(notesCount, patternNotesCount)\n );\n};\n\nlet ongoingRenderingCounter = 0;\nlet originalContext: ToneAudioContext | undefined;\n\n/** Render a clip offline into a Tone.Player buffer for later playback. */\nconst offlineRenderClip = (params: ClipParams, duration: number) => {\n if (!originalContext) {\n originalContext = Tone.getContext();\n }\n ongoingRenderingCounter++;\n const player = new Tone.Player({ context: originalContext, loop: true });\n Tone.Offline(async (context: ToneAudioContext): Promise<void> => {\n const sequence = generateSequence(params, context as unknown as Channel);\n await Tone.loaded();\n sequence.start();\n context.transport.start();\n }, duration).then((buffer: ToneLoadable) => {\n player.buffer = buffer;\n ongoingRenderingCounter--;\n if (ongoingRenderingCounter === 0) {\n if (originalContext) {\n Tone.setContext(originalContext);\n }\n params.offlineRenderingCallback?.();\n }\n });\n player.toDestination();\n player.sync();\n return player;\n};\n\n/**\n * @param {Object}\n * @return {Tone.js Sequence Object}\n * Take a object literal that may have a Tone.js player OR instrument\n * or simply a sample or synth with a pattern and return a Tone.js sequence\n */\nexport const clip = (\n params: ClipParams,\n channel?: Channel\n): ToneSequence | ToneInstrument => {\n params = preprocessClipParams(params, { align: '1m', alignOffset: '0' });\n\n if (params.offlineRendering) {\n return offlineRenderClip(\n params,\n renderingDuration(\n params.pattern,\n params.subdiv || defaultSubdiv,\n params.notes || [],\n params.randomNotes\n )\n );\n }\n return generateSequence(params, channel, originalContext);\n};\n","import fs from 'node:fs';\nimport { File, Track } from '@scribbletune/midi';\nimport type { NoteObject } from './types';\n\n/**\n * Take an array of note objects to generate a MIDI file in the same location as this method is called\n * @param {NoteObject[]} notes Notes are in the format: {note: ['c3'], level: 127, length: 64}\n * @param {String | null} fileName If a filename is not provided, then `music.mid` is used by default\n * If `null` is passed for `fileName`, bytes are returned instead of creating a file\n * If this method is called from a browser then it will return a HTML link that you can append in your page\n * This link will enable the generated MIDI as a downloadable file.\n * @param {Number | null} bpm If a value is provided, the generated midi file will be set to this bpm value.\n */\nexport const midi = (\n notes: NoteObject[],\n fileName: string | null = 'music.mid',\n bpm?: number\n): string | HTMLAnchorElement | undefined => {\n const file = createFileFromNotes(notes, bpm);\n const bytes = file.toBytes();\n\n if (fileName === null) {\n return bytes;\n }\n\n if (!fileName.endsWith('.mid')) {\n fileName = `${fileName}.mid`;\n }\n\n if (\n typeof window !== 'undefined' &&\n window.URL &&\n typeof window.URL.createObjectURL === 'function'\n ) {\n return createDownloadLink(bytes, fileName);\n }\n\n fs.writeFileSync(fileName, bytes, 'binary');\n console.log(`MIDI file generated: ${fileName}.`);\n};\n\n/**\n * Create a downloadable link\n * @param b\n */\nconst createDownloadLink = (b: string, fileName: string): HTMLAnchorElement => {\n // Convert bytes to array buffer\n // Accepted answer on https://stackoverflow.com/questions/35038884/download-file-from-bytes-in-javascript\n const bytes = new Uint8Array(b.length);\n for (let i = 0; i < b.length; i++) {\n const ascii = b.charCodeAt(i);\n bytes[i] = ascii;\n }\n\n // Create a Blob so that we can set it up with the type of file we want (for eg MIDI)\n const blob = new Blob([bytes], { type: 'audio/midi' });\n\n // Create a link element to be used (you can use an existing link on the page as well)\n const link = document.createElement('a');\n link.href =\n (typeof window !== 'undefined' &&\n typeof window.URL !== 'undefined' &&\n typeof window.URL.createObjectURL !== 'undefined' &&\n window.URL.createObjectURL(blob)) ||\n '';\n\n // Give the downloadable file a name\n link.download = fileName;\n link.innerText = 'Download MIDI file';\n\n return link;\n};\n\n/** Build a MIDI File with a single track from the given note objects. */\nconst createFileFromNotes = (notes: NoteObject[], bpm?: number) => {\n const file = new File();\n const track = new Track();\n\n // set the track's bpm if it is provided\n if (typeof bpm === 'number') {\n track.setTempo(bpm);\n }\n\n file.addTrack(track);\n\n for (const noteObj of notes) {\n const level = noteObj.level || 127;\n // While writing chords (multiple notes per tick)\n // only the first noteOn (or noteOff) needs the complete arity of the function call\n // subsequent calls need only the first 2 args (channel and note)\n if (noteObj.note) {\n if (typeof noteObj.note === 'string') {\n track.noteOn(0, noteObj.note, noteObj.length, level); // channel, pitch(note), length, velocity\n track.noteOff(0, noteObj.note, noteObj.length, level);\n } else {\n track.addChord(0, noteObj.note, noteObj.length, level);\n }\n } else {\n track.noteOff(0, '', noteObj.length);\n }\n }\n\n return file;\n};\n","import { scale } from 'harmonics';\nimport type { NVP, ProgressionScale, TPD } from './types';\nimport { dice, pickOne } from './utils';\n\n/**\n * Get the chords that go with a given scale/mode\n * This is useful only in case you want to check what chords work with a scale/mode\n * so that you can come up with chord progressions\n * @param {String} mode e.g. major\n * @return {Array} e.g.['I', 'ii', 'iii', 'IV', 'V', 'vi', 'vii°']\n */\nexport const getChordDegrees = (mode: string): string[] => {\n const theRomans: NVP<string[]> = {\n ionian: ['I', 'ii', 'iii', 'IV', 'V', 'vi', 'vii°'],\n dorian: ['i', 'ii', 'III', 'IV', 'v', 'vi°', 'VII'],\n phrygian: ['i', 'II', 'III', 'iv', 'v°', 'VI', 'vii'],\n lydian: ['I', 'II', 'iii', 'iv°', 'V', 'vi', 'vii'],\n mixolydian: ['I', 'ii', 'iii°', 'IV', 'v', 'vi', 'VII'],\n aeolian: ['i', 'ii°', 'III', 'iv', 'v', 'VI', 'VII'],\n locrian: ['i°', 'II', 'iii', 'iv', 'V', 'VI', 'vii'],\n 'melodic minor': ['i', 'ii', 'III+', 'IV', 'V', 'vi°', 'vii°'],\n 'harmonic minor': ['i', 'ii°', 'III+', 'iv', 'V', 'VI', 'vii°'],\n };\n theRomans.major = theRomans.ionian;\n theRomans.minor = theRomans.aeolian;\n\n return theRomans[mode] || [];\n};\n\nconst idxByDegree: NVP<number> = {\n i: 0,\n ii: 1,\n iii: 2,\n iv: 3,\n v: 4,\n vi: 5,\n vii: 6,\n};\n\n/**\n * Get a chord name from degree\n * @param {String} roman e.g. ii OR ii° OR V7\n * @return {String} e.g. m OR m7b5 OR Maj7\n */\nconst getChordName = (roman: string): string => {\n // remove any non character\n const str = roman.replace(/\\W/g, '');\n let prefix = 'M';\n // check if it s lowercase\n if (str.toLowerCase() === str) {\n prefix = 'm';\n }\n if (roman.indexOf('°') > -1) {\n return `${prefix}7b5`;\n }\n if (roman.indexOf('+') > -1) {\n return `${prefix}#5`;\n }\n\n if (roman.indexOf('7') > -1) {\n return prefix === 'M' ? 'maj7' : 'm7';\n }\n\n return prefix;\n};\n\n/**\n * Take the specified scale and degrees and return the chord names for them\n * These can be used as the value for the `notes` param of the `clip` method\n * @param {String} noteOctaveScale e.g. 'C4 major'\n * @param {String} chordDegress e.g. 'I IV V IV'\n * @return {String} e.g. 'CM FM GM FM'\n */\nexport const getChordsByProgression = (\n noteOctaveScale: string,\n chordDegress: string\n): string => {\n // Set the octave if missing\n // For example if the method was called with `C major` instead of `C4 major`, then add the 4\n const noteOctaveScaleArr = noteOctaveScale.split(' ');\n if (!noteOctaveScaleArr[0].match(/\\d/)) {\n noteOctaveScaleArr[0] += '4';\n noteOctaveScale = noteOctaveScaleArr.join(' ');\n }\n\n // Get the scale from the given note and scale/mode combination\n const mode = scale(noteOctaveScale);\n const chordDegreesArr = chordDegress.replace(/\\s*,+\\s*/g, ' ').split(' ');\n // Now we have something like ['i', 'ii', 'IV']\n // Convert it to a chord family such as ['Cm', 'Dm', 'FM']\n const chordFamily = chordDegreesArr.map(roman => {\n const chordName = getChordName(roman); // e.g. m\n // get the index to be used by removing any digit or non alphabet character\n const scaleId = idxByDegree[roman.replace(/\\W|\\d/g, '').toLowerCase()]; // e.g. 0\n // get the note itself\n const note = mode[scaleId]; // e.g. C\n // get the octave of the note;\n const oct = note.replace(/\\D+/, ''); // e.g. 4\n // now get the chord\n return `${note.replace(/\\d/, '') + chordName}_${oct}`;\n });\n\n return chordFamily.toString().replace(/,/g, ' ');\n};\n\n/** Create a progression generator that follows tonic -> predominant -> dominant flow. */\nconst getProgFactory = ({ T, P, D }: TPD) => {\n return (count = 4) => {\n const chords = [];\n\n // Push root/tonic\n chords.push(pickOne(T));\n\n let i = 1;\n\n // Pick a predominant\n if (i < count - 1) {\n chords.push(pickOne(P));\n i++;\n }\n\n // Try another predominant\n if (i < count - 1 && dice()) {\n chords.push(pickOne(P));\n i++;\n }\n\n // /////// 4 or more//////////\n if (i < count - 1) {\n // Pick a dominant\n chords.push(pickOne(D));\n i++;\n }\n\n if (i < count - 1) {\n // Pick a predominant\n chords.push(pickOne(P));\n i++;\n }\n\n if (i < count - 1) {\n // Pick a dominant\n chords.push(pickOne(D));\n i++;\n }\n\n // Pick a predominant if possible\n if (i < count - 1 && dice()) {\n chords.push(pickOne(P));\n i++;\n }\n // //////////////////////////\n\n // Fill the rest with dominant\n while (i < count) {\n chords.push(pickOne(D));\n i++;\n }\n\n return chords;\n };\n};\n\nconst M = getProgFactory({ T: ['I', 'vi'], P: ['ii', 'IV'], D: ['V'] });\nconst m = getProgFactory({ T: ['i', 'VI'], P: ['ii', 'iv'], D: ['V'] });\n\n/**\n * Generate a chord progression based on basic music theory\n * where we follow tonic to optionally predominant and then dominant\n * and then randomly to predominant and continue this till we reach `count`\n * @param scaleType e.g. M (for major chord progression), m (for minor chord progression)\n * @param count e.g. 4\n */\nexport const progression = (\n scaleType: ProgressionScale,\n count = 4\n): string[] => {\n if (scaleType === 'major' || scaleType === 'M') {\n return M(count);\n }\n\n if (scaleType === 'minor' || scaleType === 'm') {\n return m(count);\n }\n\n return [];\n};\n","import type { ChannelParams } from '../types';\nimport { recreateToneObjectInContext } from './instrument-factory';\n\n/** Create effects from channel params and chain them onto the instrument. */\nexport function initEffects(\n instrument: ToneInstrument,\n context: ToneAudioContext,\n params: ChannelParams\n): Promise<void> {\n context = context || Tone.getContext();\n\n const createEffect = (effect: string | ToneNode): Promise<ToneNode> => {\n return new Promise<ToneNode>((resolve, _reject) => {\n if (typeof effect === 'string') {\n resolve(\n new (Tone as unknown as ToneDynamic)[effect]({\n context,\n }) as unknown as ToneNode\n );\n } else if (effect.context !== context) {\n return recreateToneObjectInContext(\n effect as unknown as ToneInstrument,\n context\n );\n } else {\n resolve(effect);\n }\n }).then(effectOut => {\n return effectOut.toDestination();\n });\n };\n\n const startEffect = (eff: ToneNode) => {\n return typeof eff.start === 'function' ? eff.start() : eff;\n };\n\n const toArray = <T>(someVal: T | T[] | undefined): T[] => {\n if (!someVal) {\n return [];\n }\n if (Array.isArray(someVal)) {\n return someVal;\n }\n return [someVal];\n };\n\n const effectsIn = toArray(params.effects);\n if (params.external) {\n if (effectsIn.length !== 0) {\n throw new Error('Effects cannot be used with external output');\n }\n return Promise.resolve();\n }\n\n return Promise.all(effectsIn.map(createEffect))\n .then(results => results.map(startEffect))\n .then(effects => {\n instrument.chain(...effects).toDestination();\n });\n}\n","import { clip } from './browser-clip';\nimport { initEffects } from './channel/effects-chain';\nimport { createInstrument } from './channel/instrument-factory';\nimport { buildSequenceCallback } from './channel/sequence-builder';\nimport type {\n ChannelParams,\n ClipParams,\n EventFn,\n PlayerObserverFn,\n SeqFn,\n} from './types';\nimport { errorHasMessage } from './utils';\n\n/**\n * Get the next logical position to play in the session\n * Tone has a build-in method `Tone.Transport.nextSubdivision('4n')`\n * but I think it s better to round off as follows for live performance\n */\nconst getNextPos = (\n clip: null | { align?: string; alignOffset?: string }\n): number | ToneTicksValue => {\n const transportPosTicks = Tone.Transport.ticks;\n // If we are still in the first beat (bar 0, beat 0), start immediately\n if (transportPosTicks < Tone.Ticks('4n').toTicks()) {\n return 0;\n }\n\n // Else set it to the next aligned position\n const align = clip?.align || '1m';\n const alignOffset = clip?.alignOffset || '0';\n const alignTicks: number = Tone.Ticks(align).toTicks();\n const alignOffsetTicks: number = Tone.Ticks(alignOffset).toTicks();\n const nextPosTicks = Tone.Ticks(\n Math.floor(transportPosTicks / alignTicks + 1) * alignTicks +\n alignOffsetTicks\n );\n return nextPosTicks;\n};\n\n/**\n * Channel\n * A channel is made up of a Tone.js Player/Instrument, one or more\n * Tone.js sequences (known as clips in Scribbletune)\n * & optionally a set of effects (with or without presets)\n *\n * API:\n * clips -> Get all clips for this channel\n * addClip -> Add a new clip to the channel\n * startClip -> Start a clip at the provided index\n * stopClip -> Stop a clip at the provided index\n * activeClipIdx -> Get the clip that is currently playing\n */\nexport class Channel {\n idx: number | string;\n name: string;\n activePatternIdx: number;\n channelClips: (ToneSequence | null)[];\n clipNoteCount: number;\n counterResetTask: number | undefined;\n instrument!: ToneInstrument;\n external: ExternalOutput | undefined;\n initializerTask: Promise<void>;\n hasLoaded: boolean;\n hasFailed: boolean | Error;\n private eventCbFn: EventFn | undefined;\n private playerCbFn: PlayerObserverFn | undefined;\n constructor(params: ChannelParams) {\n this.idx = params.idx || 0;\n this.name = params.name || `ch ${params.idx}`;\n this.activePatternIdx = -1;\n this.channelClips = [];\n this.clipNoteCount = 0;\n\n // Filter out unrequired params and create clip params object\n\n const { clips, samples, sample, synth, ...params1 } = params;\n\n const { external, sampler, buffer, ...params2 } = params1;\n\n const { player, instrument, volume, ...params3 } = params2;\n\n const { eventCb, playerCb, effects, ...params4 } = params3;\n const { context = Tone.getContext(), ...originalParamsFiltered } = params4;\n\n this.eventCbFn = eventCb;\n this.playerCbFn = playerCb;\n\n // Async section\n this.hasLoaded = false;\n this.hasFailed = false;\n const result = createInstrument(context, params, {\n idx: this.idx,\n name: this.name,\n });\n this.instrument = result.instrument;\n this.external = result.external;\n this.initializerTask = result.initPromise.then(finalInstrument => {\n this.instrument = finalInstrument;\n return initEffects(this.instrument, context, params);\n });\n // End Async section\n\n // Sync section\n let clipsFailed: { message: string } | false = false;\n try {\n (params.clips ?? []).forEach((c: ClipParams, i: number) => {\n try {\n this.addClip({\n ...c,\n ...originalParamsFiltered,\n });\n } catch (e) {\n // Annotate the error with Clip info\n throw new Error(\n `${errorHasMessage(e) ? e.message : e} in clip ${i + 1}`\n );\n }\n }, this);\n } catch (e) {\n clipsFailed = e as { message: string }; // Stash the error\n }\n // End Sync section\n\n // Reconcile sync section with async section\n this.initializerTask\n .then(() => {\n if (clipsFailed) {\n throw clipsFailed;\n }\n this.hasLoaded = true;\n this.eventCb('loaded', {}); // Report async load completion.\n })\n .catch(e => {\n this.hasFailed = e;\n this.eventCb('error', { e }); // Report async errors.\n });\n }\n\n /** Set the global transport tempo in BPM. */\n static setTransportTempo(valueBpm: number): void {\n Tone.Transport.bpm.value = valueBpm;\n }\n\n /** Resume the audio context and start the global transport. */\n static startTransport(): void {\n Tone.start();\n Tone.Transport.start();\n }\n\n /**\n * Stop the global transport.\n * @param deleteEvents - If true (default), cancels all scheduled transport events.\n */\n static stopTransport(deleteEvents = true): void {\n Tone.Transport.stop();\n if (deleteEvents) {\n // Delete all events in the Tone.Transport\n Tone.Transport.cancel();\n }\n }\n\n /** Set the volume (in dB) of this channel's instrument and external output. */\n setVolume(volume: number): void {\n if (this.instrument) {\n this.instrument.volume.value = volume;\n }\n\n if (this.external) {\n this.external.setVolume?.(volume);\n }\n }\n\n /**\n * Start the clip at the given index, stopping any other active clip first.\n * @param idx - Clip index in this channel\n * @param position - Transport time to start at; defaults to the next aligned position\n */\n startClip(idx: number, position?: number | string | ToneTicksValue): void {\n const clip = this.channelClips[idx];\n position = positi