smplr
Version:
A Sampled collection of instruments
1 lines • 155 kB
Source Map (JSON)
{"version":3,"sources":["../src/player/connect.ts","../src/player/signals.ts","../src/player/volume.ts","../src/player/channel.ts","../src/player/sorted-queue.ts","../src/player/queued-player.ts","../src/player/sample-player.ts","../src/player/default-player.ts","../src/player/load-audio.ts","../src/storage.ts","../src/drum-machine/dm-instrument.ts","../src/drum-machine/drum-machine.ts","../src/player/midi.ts","../src/sfz/sfz-load.ts","../src/sfz/sfz-regions.ts","../src/sfz/sfz-sampler.ts","../src/tremolo.ts","../src/electric-piano.ts","../src/player/layers.ts","../src/player/region-player.ts","../src/sfz2.ts","../src/versilian.ts","../src/mallet.ts","../src/mellotron.ts","../src/reverb/processor.min.ts","../src/reverb/reverb.ts","../src/sampler.ts","../src/smolken.ts","../src/soundfont/soundfont-instrument.ts","../src/soundfont/soundfont-loops.ts","../src/soundfont/soundfont.ts","../src/soundfont2.ts","../src/splendid-grand-piano.ts"],"sourcesContent":["export type AudioInsert = {\n input: AudioNode;\n output: AudioNode;\n};\n\nexport function connectSerial(nodes: (AudioNode | AudioInsert | undefined)[]) {\n const _nodes = nodes.filter((x): x is AudioNode | AudioInsert => !!x);\n _nodes.reduce((a, b) => {\n const left = \"output\" in a ? a.output : a;\n const right = \"input\" in b ? b.input : b;\n left.connect(right);\n return b;\n });\n\n return () => {\n _nodes.reduce((a, b) => {\n const left = \"output\" in a ? a.output : a;\n const right = \"input\" in b ? b.input : b;\n left.disconnect(right);\n return b;\n });\n };\n}\n\nexport function connectAudioBus(\n node: AudioNode,\n destination: AudioNode,\n gain: number\n) {\n const mix = node.context.createGain();\n mix.gain.value = gain;\n node.connect(mix);\n mix.connect(destination);\n\n return () => {\n node.disconnect(mix);\n mix.disconnect(destination);\n };\n}\n","/**\n * A function to unsubscribe from an event or control\n */\nexport type Unsubscribe = () => void;\n/**\n * A function that listener to event or control changes\n */\nexport type Listener<T> = (value: T) => void;\n/**\n * A function to subscribe an trigger or control events\n */\nexport type Subscribe<T> = (listener: Listener<T>) => Unsubscribe;\n\n/**\n * A trigger is a subscribable event\n */\nexport type Trigger<T> = {\n subscribe: Subscribe<T>;\n trigger: (event: T) => void;\n};\n\n/**\n * A control is a subscribable value\n */\nexport type Control<T> = {\n subscribe: Subscribe<T>;\n set: (value: T) => void;\n get: () => T;\n};\n\n/**\n * Create a control signal\n * @param initialValue\n * @returns Control\n */\nexport function createControl<T>(initialValue: T): Control<T> {\n let current = initialValue;\n const listeners = new Set<Listener<T>>();\n\n function subscribe(listener: Listener<T>) {\n listeners.add(listener);\n listener(current);\n return () => {\n listeners.delete(listener);\n };\n }\n\n function set(value: T) {\n current = value;\n listeners.forEach((listener) => listener(current));\n }\n\n function get(): T {\n return current;\n }\n return { subscribe, set, get };\n}\n\nexport function createTrigger<T>(): Trigger<T> {\n const listeners = new Set<Listener<T>>();\n\n function subscribe(listener: Listener<T>) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n }\n\n function trigger(value: T) {\n listeners.forEach((listener) => listener(value));\n }\n\n return { subscribe, trigger };\n}\n\nexport function unsubscribeAll(unsubscribe: Array<Unsubscribe | undefined>) {\n let done = false;\n return () => {\n if (done) return;\n done = true;\n unsubscribe.forEach((cb) => cb?.());\n };\n}\n","/// This is how the MIDI association converts midi velocity [0..127] into gain [0..1]\n/// @see https://www.midi.org/specifications/file-format-specifications/dls-downloadable-sounds/dls-level-1\nexport function midiVelToGain(vel: number) {\n return (vel * vel) / 16129; // 16129 = 127 * 127\n}\n\nexport function dbToGain(decibels: number) {\n return Math.pow(10, decibels / 20);\n}\n","import { AudioInsert, connectSerial } from \"./connect\";\nimport { createControl } from \"./signals\";\nimport { midiVelToGain } from \"./volume\";\n\nexport type ChannelConfig = {\n destination: AudioNode;\n volume: number;\n volumeToGain: (volume: number) => number;\n};\n\nexport type OutputChannel = Omit<Channel, \"input\">;\n\ntype Send = {\n name: string;\n mix: GainNode;\n disconnect: () => void;\n};\n\n/**\n * An output channel with audio effects\n * @private\n */\nexport class Channel {\n public readonly setVolume: (vol: number) => void;\n public readonly input: AudioNode;\n\n #volume: GainNode;\n #sends?: Send[];\n #inserts?: (AudioNode | AudioInsert)[];\n #disconnect: () => void;\n #unsubscribe: () => void;\n #config: Readonly<ChannelConfig>;\n #disconnected = false;\n\n constructor(\n public readonly context: BaseAudioContext,\n options?: Partial<ChannelConfig>\n ) {\n this.#config = {\n destination: options?.destination ?? context.destination,\n volume: options?.volume ?? 100,\n volumeToGain: options?.volumeToGain ?? midiVelToGain,\n };\n\n this.input = context.createGain();\n this.#volume = context.createGain();\n\n this.#disconnect = connectSerial([\n this.input,\n this.#volume,\n this.#config.destination,\n ]);\n\n const volume = createControl(this.#config.volume);\n this.setVolume = volume.set;\n this.#unsubscribe = volume.subscribe((volume) => {\n this.#volume.gain.value = this.#config.volumeToGain(volume);\n });\n }\n\n addInsert(effect: AudioNode | AudioInsert) {\n if (this.#disconnected) {\n throw Error(\"Can't add insert to disconnected channel\");\n }\n this.#inserts ??= [];\n this.#inserts.push(effect);\n this.#disconnect();\n this.#disconnect = connectSerial([\n this.input,\n ...this.#inserts,\n this.#volume,\n this.#config.destination,\n ]);\n }\n\n addEffect(\n name: string,\n effect: AudioNode | { input: AudioNode },\n mixValue: number\n ) {\n if (this.#disconnected) {\n throw Error(\"Can't add effect to disconnected channel\");\n }\n const mix = this.context.createGain();\n mix.gain.value = mixValue;\n const input = \"input\" in effect ? effect.input : effect;\n const disconnect = connectSerial([this.#volume, mix, input]);\n\n this.#sends ??= [];\n this.#sends.push({ name, mix, disconnect });\n }\n\n sendEffect(name: string, mix: number) {\n if (this.#disconnected) {\n throw Error(\"Can't send effect to disconnected channel\");\n }\n\n const send = this.#sends?.find((send) => send.name === name);\n if (send) {\n send.mix.gain.value = mix;\n } else {\n console.warn(\"Send bus not found: \" + name);\n }\n }\n\n disconnect() {\n if (this.#disconnected) return;\n this.#disconnected = true;\n this.#disconnect();\n this.#unsubscribe();\n this.#sends?.forEach((send) => send.disconnect());\n this.#sends = undefined;\n }\n}\n","/**\n * A sorted items that uses binary search to insert items in sorted order.\n * @private\n */\nexport class SortedQueue<T> {\n #items: T[] = [];\n constructor(public readonly compare: (a: T, b: T) => number) {}\n\n push(item: T) {\n const len = this.#items.length;\n\n let left = 0;\n let right = len - 1;\n let index = len;\n\n while (left <= right) {\n const mid = Math.floor((left + right) / 2);\n if (this.compare(item, this.#items[mid]) < 0) {\n index = mid;\n right = mid - 1;\n } else {\n left = mid + 1;\n }\n }\n\n this.#items.splice(index, 0, item);\n }\n\n pop() {\n return this.#items.shift();\n }\n\n peek(): T | undefined {\n return this.#items[0];\n }\n\n removeAll(predicate: (item: T) => boolean) {\n const len = this.#items.length;\n this.#items = this.#items.filter((item) => !predicate(item));\n return this.#items.length !== len;\n }\n\n clear() {\n this.#items = [];\n }\n\n size() {\n return this.#items.length;\n }\n}\n","import { SortedQueue } from \"./sorted-queue\";\nimport { InternalPlayer, SampleStart, SampleStop } from \"./types\";\n\ntype SampleStartWithTime = SampleStart & { time: number };\n\nfunction compose<T>(a?: (x: T) => void, b?: (x: T) => void) {\n return a && b\n ? (x: T) => {\n a(x);\n b(x);\n }\n : a ?? b;\n}\n\nexport type QueuedPlayerConfig = {\n disableScheduler: boolean;\n scheduleLookaheadMs: number;\n scheduleIntervalMs: number;\n onStart?: (sample: SampleStart) => void;\n onEnded?: (sample: SampleStart) => void;\n};\n\nfunction getConfig(options: Partial<QueuedPlayerConfig>) {\n const config: QueuedPlayerConfig = {\n disableScheduler: options.disableScheduler ?? false,\n scheduleLookaheadMs: options.scheduleLookaheadMs ?? 200,\n scheduleIntervalMs: options.scheduleIntervalMs ?? 50,\n onStart: options.onStart,\n onEnded: options.onEnded,\n };\n\n if (config.scheduleLookaheadMs < 1) {\n throw Error(\"scheduleLookaheadMs must be greater than 0\");\n }\n if (config.scheduleIntervalMs < 1) {\n throw Error(\"scheduleIntervalMs must be greater than 0\");\n }\n if (config.scheduleLookaheadMs < config.scheduleIntervalMs) {\n throw Error(\"scheduleLookaheadMs must be greater than scheduleIntervalMs\");\n }\n\n return config;\n}\n\n/**\n * A SamplePlayer that queues up samples to be played in the future.\n *\n * @private\n */\nexport class QueuedPlayer implements InternalPlayer {\n private readonly player: InternalPlayer;\n #config: QueuedPlayerConfig;\n #queue: SortedQueue<SampleStartWithTime>;\n #intervalId: NodeJS.Timeout | undefined;\n\n public constructor(\n player: InternalPlayer,\n options: Partial<QueuedPlayerConfig> = {}\n ) {\n this.#config = getConfig(options);\n\n this.#queue = new SortedQueue<SampleStartWithTime>(\n (a, b) => a.time - b.time\n );\n this.player = player;\n }\n\n get context() {\n return this.player.context;\n }\n\n get buffers() {\n return this.player.buffers;\n }\n\n get isRunning() {\n return this.#intervalId !== undefined;\n }\n\n start(sample: SampleStart) {\n if (this.#config.disableScheduler) {\n return this.player.start(sample);\n }\n const context = this.player.context;\n const now = context.currentTime;\n const startAt = sample.time ?? now;\n const lookAhead = this.#config.scheduleLookaheadMs / 1000;\n sample.onStart = compose(sample.onStart, this.#config.onStart);\n sample.onEnded = compose(sample.onEnded, this.#config.onEnded);\n\n if (startAt < now + lookAhead) {\n return this.player.start(sample);\n }\n this.#queue.push({ ...sample, time: startAt });\n\n if (!this.#intervalId) {\n this.#intervalId = setInterval(() => {\n const nextTick = context.currentTime + lookAhead;\n while (this.#queue.size() && this.#queue.peek()!.time <= nextTick) {\n const sample = this.#queue.pop();\n if (sample) {\n this.player.start(sample);\n }\n }\n if (!this.#queue.size()) {\n clearInterval(this.#intervalId!);\n this.#intervalId = undefined;\n }\n }, this.#config.scheduleIntervalMs);\n }\n\n return (time?: number) => {\n if (!time || time < startAt) {\n if (!this.#queue.removeAll((item) => item === sample)) {\n this.player.stop({ ...sample, time });\n }\n } else {\n this.player.stop({ ...sample, time });\n }\n };\n }\n\n stop(sample?: SampleStop) {\n if (this.#config.disableScheduler) {\n return this.player.stop(sample);\n }\n\n this.player.stop(sample);\n\n if (!sample) {\n this.#queue.clear();\n return;\n }\n\n const time = sample?.time ?? 0;\n const stopId = sample?.stopId;\n if (stopId) {\n this.#queue.removeAll((item) =>\n item.time >= time && item.stopId\n ? item.stopId === stopId\n : item.note === stopId\n );\n } else {\n this.#queue.removeAll((item) => item.time >= time);\n }\n }\n\n disconnect() {\n this.player.disconnect();\n }\n}\n","import { connectSerial } from \"./connect\";\nimport { AudioBuffers } from \"./load-audio\";\nimport { Trigger, createTrigger, unsubscribeAll } from \"./signals\";\nimport {\n InternalPlayer,\n SampleOptions,\n SampleStart,\n SampleStop,\n} from \"./types\";\nimport { midiVelToGain } from \"./volume\";\n\nexport type SamplePlayerConfig = {\n velocityToGain: (velocity: number) => number;\n destination: AudioNode;\n} & SampleOptions;\n\n/**\n * A sample player. This is used internally by the Sampler.\n *\n * @private Not intended for public use\n */\nexport class SamplePlayer implements InternalPlayer {\n public readonly buffers: AudioBuffers;\n #config: SamplePlayerConfig;\n #disconnected = false;\n #stop: Trigger<SampleStop | undefined>;\n\n public constructor(\n public readonly context: BaseAudioContext,\n private readonly options: Partial<SamplePlayerConfig>\n ) {\n this.#config = {\n velocityToGain: options.velocityToGain ?? midiVelToGain,\n destination: options.destination ?? context.destination,\n };\n this.buffers = {};\n this.#stop = createTrigger();\n }\n\n public start(sample: SampleStart) {\n if (this.#disconnected) {\n throw new Error(\"Can't start a sample on disconnected player\");\n }\n const context = this.context;\n const buffer =\n (sample.name && this.buffers[sample.name]) || this.buffers[sample.note];\n if (!buffer) {\n console.warn(`Sample not found: '${sample.note}'`);\n return () => undefined;\n }\n\n const source = context.createBufferSource();\n source.buffer = buffer;\n\n // Seems that detune is not implemented in Safari (and therefore, in standardized-audio-context)\n const cents = sample.detune ?? this.options.detune ?? 0;\n if (source.detune) {\n source.detune.value = cents;\n } else if (source.playbackRate) {\n source.playbackRate.value = Math.pow(2, cents / 1200);\n }\n\n // Low pass filter\n const lpfCutoffHz = sample.lpfCutoffHz ?? this.options.lpfCutoffHz;\n const lpf = lpfCutoffHz ? context.createBiquadFilter() : undefined;\n if (lpfCutoffHz && lpf) {\n lpf.type = \"lowpass\";\n lpf.frequency.value = lpfCutoffHz;\n }\n\n // Sample volume\n const volume = context.createGain();\n const velocity = sample.velocity ?? this.options.velocity ?? 100;\n volume.gain.value = this.#config.velocityToGain(velocity);\n\n const loop = sample.loop ?? this.options.loop;\n if (loop) {\n source.loop = true;\n source.loopStart = sample.loopStart ?? 0;\n source.loopEnd = sample.loopEnd ?? buffer.duration;\n }\n\n // Stop with decay\n const decayTime = sample.decayTime ?? this.options.decayTime;\n const [decay, startDecay] = createDecayEnvelope(context, decayTime);\n function stop(time?: number) {\n time ??= context.currentTime;\n if (time <= startAt) {\n source.stop(time);\n } else {\n const stopAt = startDecay(time);\n source.stop(stopAt);\n }\n }\n\n // Compensate gain\n const gainCompensation = sample.gainOffset\n ? context.createGain()\n : undefined;\n if (gainCompensation && sample.gainOffset) {\n gainCompensation.gain.value = sample.gainOffset;\n }\n\n const stopId = sample.stopId ?? sample.note;\n const cleanup = unsubscribeAll([\n connectSerial([\n source,\n lpf,\n volume,\n decay,\n gainCompensation,\n this.#config.destination,\n ]),\n sample.stop?.(stop),\n this.#stop.subscribe((event) => {\n if (!event || event.stopId === undefined || event.stopId === stopId) {\n stop(event?.time);\n }\n }),\n ]);\n source.onended = () => {\n cleanup();\n sample.onEnded?.(sample);\n };\n\n sample.onStart?.(sample);\n const startAt = Math.max(sample.time ?? 0, context.currentTime);\n source.start(sample.time);\n\n let duration = sample.duration ?? buffer.duration;\n if (typeof sample.duration == \"number\") {\n stop(startAt + duration);\n }\n\n return stop;\n }\n\n stop(sample?: SampleStop) {\n this.#stop.trigger(sample);\n }\n\n public disconnect() {\n if (this.#disconnected) return;\n this.#disconnected = true;\n this.stop();\n Object.keys(this.buffers).forEach((key) => {\n delete this.buffers[key];\n });\n }\n\n public get connected() {\n return !this.#disconnected;\n }\n}\n\nfunction createDecayEnvelope(\n context: BaseAudioContext,\n envelopeTime = 0.2\n): [AudioNode, (time: number) => number] {\n let stopAt = 0;\n const envelope = context.createGain();\n envelope.gain.value = 1.0;\n\n function start(time: number): number {\n if (stopAt) return stopAt;\n envelope.gain.cancelScheduledValues(time);\n const envelopeAt = time || context.currentTime;\n stopAt = envelopeAt + envelopeTime;\n envelope.gain.setValueAtTime(1.0, envelopeAt);\n envelope.gain.linearRampToValueAtTime(0, stopAt);\n\n return stopAt;\n }\n\n return [envelope, start];\n}\n","import { SamplerConfig } from \"../sampler\";\nimport { Channel, ChannelConfig, OutputChannel } from \"./channel\";\nimport { QueuedPlayer, QueuedPlayerConfig } from \"./queued-player\";\nimport { SamplePlayer } from \"./sample-player\";\nimport { InternalPlayer, SampleStart, SampleStop } from \"./types\";\n\nexport type DefaultPlayerConfig = ChannelConfig &\n SamplerConfig &\n QueuedPlayerConfig;\n\n/**\n * Player used by instruments\n * @private\n */\nexport class DefaultPlayer implements InternalPlayer {\n public readonly output: OutputChannel;\n private readonly player: InternalPlayer;\n\n constructor(\n public readonly context: BaseAudioContext,\n options?: Partial<DefaultPlayerConfig>\n ) {\n const channel = new Channel(context, options);\n this.player = new QueuedPlayer(\n new SamplePlayer(context, { ...options, destination: channel.input }),\n options\n );\n this.output = channel;\n }\n\n get buffers() {\n return this.player.buffers;\n }\n\n public start(sample: SampleStart) {\n return this.player.start(sample);\n }\n\n public stop(sample?: SampleStop | string | number) {\n this.player.stop(\n typeof sample === \"object\"\n ? sample\n : sample !== undefined\n ? { stopId: sample }\n : undefined\n );\n }\n\n disconnect() {\n this.output.disconnect();\n this.player.disconnect();\n }\n}\n","import { Storage } from \"../storage\";\n\nexport type AudioBuffers = Record<string | number, AudioBuffer | undefined>;\n\n/**\n * A function that downloads audio into a AudioBuffers\n */\nexport type AudioBuffersLoader = (\n context: BaseAudioContext,\n buffers: AudioBuffers\n) => Promise<void>;\n\nexport async function loadAudioBuffer(\n context: BaseAudioContext,\n url: string,\n storage: Storage\n): Promise<AudioBuffer | undefined> {\n url = url.replace(/#/g, \"%23\").replace(/([^:]\\/)\\/+/g, \"$1\");\n const response = await storage.fetch(url);\n if (response.status !== 200) {\n console.warn(\n \"Error loading buffer. Invalid status: \",\n response.status,\n url\n );\n return;\n }\n try {\n const audioData = await response.arrayBuffer();\n const buffer = await context.decodeAudioData(audioData);\n return buffer;\n } catch (error) {\n console.warn(\"Error loading buffer\", error, url);\n }\n}\n\nexport function findFirstSupportedFormat(formats: string[]): string | null {\n if (typeof document === \"undefined\") return null;\n\n const audio = document.createElement(\"audio\");\n for (let i = 0; i < formats.length; i++) {\n const format = formats[i];\n const canPlay = audio.canPlayType(`audio/${format}`);\n if (canPlay === \"probably\" || canPlay === \"maybe\") {\n return format;\n }\n // check Safari for aac format\n if (format === \"m4a\") {\n const canPlay = audio.canPlayType(`audio/aac`);\n if (canPlay === \"probably\" || canPlay === \"maybe\") {\n return format;\n }\n }\n }\n return null;\n}\n\nexport function getPreferredAudioExtension() {\n const format = findFirstSupportedFormat([\"ogg\", \"m4a\"]) ?? \"ogg\";\n return \".\" + format;\n}\n","export type StorageResponse = {\n readonly status: number;\n arrayBuffer(): Promise<ArrayBuffer>;\n json(): Promise<any>;\n text(): Promise<string>;\n};\n\nexport type Storage = {\n fetch: (url: string) => Promise<StorageResponse>;\n};\n\nexport const HttpStorage: Storage = {\n fetch(url) {\n return fetch(url);\n },\n};\n\nexport class CacheStorage implements Storage {\n #cache: Promise<Cache>;\n\n constructor(name = \"smplr\") {\n if (typeof window === \"undefined\" || !(\"caches\" in window)) {\n this.#cache = Promise.reject(\"CacheStorage not supported\");\n } else {\n this.#cache = caches.open(name);\n }\n }\n\n async fetch(url: string): Promise<StorageResponse> {\n const request = new Request(url);\n try {\n return await this.#tryFromCache(request);\n } catch (err) {\n const response = await fetch(request);\n await this.#saveResponse(request, response);\n return response;\n }\n }\n\n async #tryFromCache(request: Request): Promise<StorageResponse> {\n const cache = await this.#cache;\n const response = await cache.match(request);\n if (response) return response;\n else throw Error(\"Not found\");\n }\n\n async #saveResponse(request: Request, response: Response) {\n try {\n const cache = await this.#cache;\n await cache.put(request, response.clone());\n } catch (err) {}\n }\n}\n","import { Storage } from \"../storage\";\n\nexport function isDrumMachineInstrument(\n instrument: any\n): instrument is DrumMachineInstrument {\n return (\n typeof instrument === \"object\" &&\n typeof instrument.baseUrl === \"string\" &&\n typeof instrument.name === \"string\" &&\n Array.isArray(instrument.samples) &&\n Array.isArray(instrument.groupNames) &&\n typeof instrument.nameToSampleName === \"object\" &&\n typeof instrument.sampleGroupVariations === \"object\"\n );\n}\n\nexport type DrumMachineInstrument = {\n baseUrl: string;\n name: string;\n samples: string[];\n groupNames: string[];\n nameToSampleName: Record<string, string | undefined>;\n sampleGroupVariations: Record<string, string[]>;\n};\nexport const EMPTY_INSTRUMENT: DrumMachineInstrument = {\n baseUrl: \"\",\n name: \"\",\n samples: [],\n groupNames: [],\n nameToSampleName: {},\n sampleGroupVariations: {},\n};\n\nexport async function fetchDrumMachineInstrument(\n url: string,\n storage: Storage\n): Promise<DrumMachineInstrument> {\n const res = await storage.fetch(url);\n const json = await res.json();\n // need to fix json\n json.baseUrl = url.replace(\"/dm.json\", \"\");\n json.groupNames = [];\n json.nameToSampleName = {};\n json.sampleGroupVariations = {};\n for (const sample of json.samples) {\n json.nameToSampleName[sample] = sample;\n const separator = sample.indexOf(\"/\") !== -1 ? \"/\" : \"-\";\n const [base, variation] = sample.split(separator);\n if (!json.groupNames.includes(base)) {\n json.groupNames.push(base);\n }\n json.nameToSampleName[base] ??= sample;\n json.sampleGroupVariations[base] ??= [];\n if (variation) {\n json.sampleGroupVariations[base].push(`${base}${separator}${variation}`);\n }\n }\n\n return json;\n}\n","import { OutputChannel } from \"../player/channel\";\nimport { DefaultPlayer, DefaultPlayerConfig } from \"../player/default-player\";\nimport {\n AudioBuffers,\n findFirstSupportedFormat,\n loadAudioBuffer,\n} from \"../player/load-audio\";\nimport { SampleStart, SampleStop } from \"../player/types\";\nimport { HttpStorage, Storage } from \"../storage\";\nimport {\n DrumMachineInstrument,\n EMPTY_INSTRUMENT,\n fetchDrumMachineInstrument,\n isDrumMachineInstrument,\n} from \"./dm-instrument\";\n\nexport function getDrumMachineNames() {\n return Object.keys(INSTRUMENTS);\n}\n\nconst INSTRUMENTS: Record<string, string> = {\n \"TR-808\": \"https://smpldsnds.github.io/drum-machines/TR-808/dm.json\",\n \"Casio-RZ1\": \"https://smpldsnds.github.io/drum-machines/Casio-RZ1/dm.json\",\n \"LM-2\": \"https://smpldsnds.github.io/drum-machines/LM-2/dm.json\",\n \"MFB-512\": \"https://smpldsnds.github.io/drum-machines/MFB-512/dm.json\",\n \"Roland CR-8000\":\n \"https://smpldsnds.github.io/drum-machines/Roland-CR-8000/dm.json\",\n};\n\ntype DrumMachineConfig = {\n instrument: string | DrumMachineInstrument;\n url: string;\n storage: Storage;\n};\n\nexport type DrumMachineOptions = Partial<\n DrumMachineConfig & DefaultPlayerConfig\n>;\n\nfunction getConfig(options?: DrumMachineOptions): DrumMachineConfig {\n const config = {\n instrument: options?.instrument ?? \"TR-808\",\n storage: options?.storage ?? HttpStorage,\n url: options?.url ?? \"\",\n };\n if (typeof config.instrument === \"string\") {\n config.url ||= INSTRUMENTS[config.instrument];\n if (!config.url)\n throw new Error(\"Invalid instrument: \" + config.instrument);\n } else if (!isDrumMachineInstrument(config.instrument)) {\n throw new Error(\"Invalid instrument: \" + config.instrument);\n }\n\n return config;\n}\n\nexport class DrumMachine {\n #instrument = EMPTY_INSTRUMENT;\n private readonly player: DefaultPlayer;\n public readonly load: Promise<this>;\n public readonly output: OutputChannel;\n\n public constructor(context: AudioContext, options?: DrumMachineOptions) {\n const config = getConfig(options);\n\n const instrument = isDrumMachineInstrument(config.instrument)\n ? Promise.resolve(config.instrument)\n : fetchDrumMachineInstrument(config.url, config.storage);\n this.player = new DefaultPlayer(context, options);\n this.output = this.player.output;\n this.load = drumMachineLoader(\n context,\n this.player.buffers,\n instrument,\n config.storage\n ).then(() => this);\n\n instrument.then((instrument) => {\n this.#instrument = instrument;\n });\n }\n\n getSampleNames(): string[] {\n return this.#instrument.samples.slice();\n }\n\n getGroupNames(): string[] {\n return this.#instrument.groupNames.slice();\n }\n\n getSampleNamesForGroup(groupName: string): string[] {\n return this.#instrument.sampleGroupVariations[groupName] ?? [];\n }\n\n start(sample: SampleStart) {\n const sampleName = this.#instrument.nameToSampleName[sample.note];\n return this.player.start({\n ...sample,\n note: sampleName ? sampleName : sample.note,\n stopId: sample.stopId ?? sample.note,\n });\n }\n\n stop(sample: SampleStop) {\n return this.player.stop(sample);\n }\n\n /** @deprecated */\n async loaded() {\n console.warn(\"deprecated: use load instead\");\n return this.load;\n }\n /** @deprecated */\n get sampleNames(): string[] {\n console.log(\"deprecated: Use getGroupNames instead\");\n return this.#instrument.groupNames.slice();\n }\n /** @deprecated */\n getVariations(groupName: string): string[] {\n console.warn(\"deprecated: use getSampleNamesForGroup\");\n return this.#instrument.sampleGroupVariations[groupName] ?? [];\n }\n}\n\nfunction drumMachineLoader(\n context: BaseAudioContext,\n buffers: AudioBuffers,\n instrument: Promise<DrumMachineInstrument>,\n storage: Storage\n) {\n const format = findFirstSupportedFormat([\"ogg\", \"m4a\"]) ?? \"ogg\";\n return instrument.then((data) =>\n Promise.all(\n data.samples.map(async (sampleName) => {\n const url = `${data.baseUrl}/${sampleName}.${format}`;\n const buffer = await loadAudioBuffer(context, url, storage);\n if (buffer) buffers[sampleName] = buffer;\n })\n )\n );\n}\n","function noteNameToMidi(note: string): number | undefined {\n const REGEX = /^([a-gA-G]?)(#{1,}|b{1,}|)(-?\\d+)$/;\n const m = REGEX.exec(note);\n if (!m) return;\n const letter = m[1].toUpperCase();\n if (!letter) return;\n\n const acc = m[2];\n const alt = acc[0] === \"b\" ? -acc.length : acc.length;\n const oct = m[3] ? +m[3] : 4;\n\n const step = (letter.charCodeAt(0) + 3) % 7;\n return [0, 2, 4, 5, 7, 9, 11][step] + alt + 12 * (oct + 1);\n}\n\nexport function toMidi(note: string | number | undefined): number | undefined {\n return note === undefined\n ? undefined\n : typeof note === \"number\"\n ? note\n : noteNameToMidi(note);\n}\n\nexport function findNearestMidi(\n midi: number,\n isAvailable: Record<string | number, unknown>\n): [number, number] {\n let i = 0;\n while (isAvailable[midi + i] === undefined && i < 128) {\n if (i > 0) i = -i;\n else i = -i + 1;\n }\n\n return i === 127 ? [midi, 0] : [midi + i, -i * 100];\n}\n","import {\n AudioBuffers,\n findFirstSupportedFormat,\n loadAudioBuffer,\n} from \"../player/load-audio\";\nimport { Storage } from \"../storage\";\nimport { SfzInstrument } from \"./sfz-kits\";\nimport { Websfz, WebsfzGroup } from \"./websfz\";\n\nexport async function loadSfzBuffers(\n context: AudioContext,\n buffers: AudioBuffers,\n websfz: Websfz,\n storage: Storage\n) {\n websfz.groups.forEach((group) => {\n const urls = getWebsfzGroupUrls(websfz, group);\n return loadAudioBuffers(context, buffers, urls, storage);\n });\n}\n\nexport async function SfzInstrumentLoader(\n instrument: string | Websfz | SfzInstrument,\n storage: Storage\n): Promise<Websfz> {\n const isWebsfz = (inst: any): inst is Websfz => \"global\" in inst;\n const isSfzInstrument = (inst: any): inst is SfzInstrument =>\n \"websfzUrl\" in inst;\n\n if (typeof instrument === \"string\") {\n return fetchWebSfz(instrument, storage);\n } else if (isWebsfz(instrument)) {\n return instrument;\n } else if (isSfzInstrument(instrument)) {\n const websfz = await fetchWebSfz(instrument.websfzUrl, storage);\n websfz.meta ??= {};\n if (instrument.name) websfz.meta.name = instrument.name;\n if (instrument.baseUrl) websfz.meta.baseUrl = instrument.baseUrl;\n if (instrument.formats) websfz.meta.formats = instrument.formats;\n return websfz;\n } else {\n throw new Error(\"Invalid instrument: \" + JSON.stringify(instrument));\n }\n}\n\n// @private\nasync function loadAudioBuffers(\n context: AudioContext,\n buffers: AudioBuffers,\n urls: Record<string, string>,\n storage: Storage\n) {\n await Promise.all(\n Object.keys(urls).map(async (sampleId) => {\n if (buffers[sampleId]) return;\n\n const buffer = await loadAudioBuffer(context, urls[sampleId], storage);\n if (buffer) buffers[sampleId] = buffer;\n return buffers;\n })\n );\n}\n\n// @private\nasync function fetchWebSfz(url: string, storage: Storage): Promise<Websfz> {\n try {\n const response = await fetch(url);\n const json = await response.json();\n return json as Websfz;\n } catch (error) {\n console.warn(`Can't load SFZ file ${url}`, error);\n throw new Error(`Can't load SFZ file ${url}`);\n }\n}\n\n// @private\nexport function getWebsfzGroupUrls(websfz: Websfz, group: WebsfzGroup) {\n const urls: Record<string, string> = {};\n const baseUrl = websfz.meta.baseUrl ?? \"\";\n const format = findFirstSupportedFormat(websfz.meta.formats ?? []) ?? \"ogg\";\n\n const prefix = websfz.global[\"default_path\"] ?? \"\";\n\n if (!group) return urls;\n\n return group.regions.reduce((urls, region) => {\n if (region.sample) {\n urls[region.sample] = `${baseUrl}/${prefix}${region.sample}.${format}`;\n }\n return urls;\n }, urls);\n}\n","import { Websfz, WebsfzGroup, WebsfzRegion } from \"./websfz\";\n\nfunction checkRange(value?: number, low?: number, hi?: number) {\n const isAboveLow = low === undefined || (value !== undefined && value >= low);\n const isBelowHi = hi === undefined || (value !== undefined && value <= hi);\n return isAboveLow && isBelowHi;\n}\n\nexport function findRegions(\n websfz: Websfz,\n note: { midi?: number; velocity?: number; cc64?: number }\n): [WebsfzGroup, WebsfzRegion][] {\n const regions: [WebsfzGroup, WebsfzRegion][] = [];\n for (const group of websfz.groups) {\n if (\n checkRange(note.midi, group.lokey, group.hikey) &&\n checkRange(note.velocity, group.lovel, group.hivel) &&\n checkRange(note.cc64, group.locc64, group.hicc64)\n ) {\n for (const region of group.regions) {\n if (\n checkRange(note.midi, region.lokey, region.hikey) &&\n checkRange(note.velocity, region.lovel, region.hivel) &&\n checkRange(note.cc64, group.locc64, group.hicc64)\n ) {\n regions.push([group, region]);\n }\n }\n }\n }\n return regions;\n}\n","import { DefaultPlayer, DefaultPlayerConfig } from \"../player/default-player\";\nimport { toMidi } from \"../player/midi\";\nimport { SampleStart, SampleStop } from \"../player/types\";\nimport { HttpStorage, Storage } from \"../storage\";\nimport { SfzInstrument } from \"./sfz-kits\";\nimport { SfzInstrumentLoader, loadSfzBuffers } from \"./sfz-load\";\nimport { findRegions } from \"./sfz-regions\";\nimport { Websfz } from \"./websfz\";\n\nexport type SfzSamplerConfig = {\n instrument: SfzInstrument | Websfz | string;\n storage: Storage;\n destination: AudioNode;\n volume: number;\n velocity: number;\n detune: number;\n decayTime?: number;\n lpfCutoffHz?: number;\n};\n\nconst EMPTY_WEBSFZ: Websfz = Object.freeze({\n meta: {},\n global: {},\n groups: [],\n});\n\nexport class SfzSampler {\n public readonly options: Readonly<SfzSamplerConfig>;\n private readonly player: DefaultPlayer;\n #websfz: Websfz;\n public readonly load: Promise<this>;\n\n constructor(\n public readonly context: AudioContext,\n options: Partial<SfzSamplerConfig & DefaultPlayerConfig> &\n Pick<SfzSamplerConfig, \"instrument\">\n ) {\n this.options = Object.freeze(\n Object.assign(\n {\n volume: 100,\n velocity: 100,\n storage: HttpStorage,\n detune: 0,\n destination: context.destination,\n },\n options\n )\n );\n this.player = new DefaultPlayer(context, options);\n this.#websfz = EMPTY_WEBSFZ;\n\n this.load = SfzInstrumentLoader(options.instrument, this.options.storage)\n .then((result) => {\n this.#websfz = Object.freeze(result);\n return loadSfzBuffers(\n context,\n this.player.buffers,\n this.#websfz,\n this.options.storage\n );\n })\n .then(() => this);\n }\n\n get output() {\n return this.player.output;\n }\n\n async loaded() {\n console.warn(\"deprecated: use load instead\");\n return this.load;\n }\n\n start(sample: SampleStart | string | number) {\n this.#startLayers(typeof sample === \"object\" ? sample : { note: sample });\n }\n\n stop(sample?: SampleStop | string | number) {\n this.player.stop(sample);\n }\n\n disconnect() {\n this.player.disconnect();\n }\n\n #startLayers(sample: SampleStart) {\n const midi = toMidi(sample.note);\n if (midi === undefined) return () => undefined;\n\n const velocity = sample.velocity ?? this.options.velocity;\n const regions = findRegions(this.#websfz, { midi, velocity });\n\n const onEnded = () => {\n sample.onEnded?.(sample);\n };\n\n const stopAll = regions.map(([group, region]) => {\n let target = region.pitch_keycenter ?? region.key ?? midi;\n const detune = (midi - target) * 100;\n return this.player.start({\n ...sample,\n note: region.sample,\n decayTime: sample.decayTime,\n detune: detune + (sample.detune ?? this.options.detune),\n onEnded,\n stopId: midi,\n });\n });\n\n switch (stopAll.length) {\n case 0:\n return () => undefined;\n case 1:\n return stopAll[0];\n default:\n return (time?: number) => stopAll.forEach((stop) => stop(time));\n }\n }\n}\n","import { AudioInsert } from \"./player/connect\";\nimport { Subscribe } from \"./player/signals\";\n\n// @private\nexport function createTremolo(\n context: AudioContext,\n depth: Subscribe<number>\n): AudioInsert {\n const input = context.createGain();\n const output = context.createGain();\n\n // force mono sources to be stereo\n input.channelCount = 2;\n input.channelCountMode = \"explicit\";\n\n const splitter = context.createChannelSplitter(2);\n const ampL = context.createGain();\n const ampR = context.createGain();\n const merger = context.createChannelMerger(2);\n\n const lfoL = context.createOscillator();\n lfoL.type = \"sine\";\n lfoL.frequency.value = 1;\n lfoL.start();\n const lfoLAmp = context.createGain();\n const lfoR = context.createOscillator();\n lfoR.type = \"sine\";\n lfoR.frequency.value = 1.1;\n lfoR.start();\n const lfoRAmp = context.createGain();\n\n input.connect(splitter);\n splitter.connect(ampL, 0);\n splitter.connect(ampR, 1);\n ampL.connect(merger, 0, 0);\n ampR.connect(merger, 0, 1);\n lfoL.connect(lfoLAmp);\n lfoLAmp.connect(ampL.gain);\n lfoR.connect(lfoRAmp);\n lfoRAmp.connect(ampR.gain);\n merger.connect(output);\n\n const unsubscribe = depth((depth) => {\n lfoLAmp.gain.value = depth;\n lfoRAmp.gain.value = depth;\n });\n\n input.disconnect = () => {\n unsubscribe();\n lfoL.stop();\n lfoR.stop();\n input.disconnect(splitter);\n splitter.disconnect(ampL, 0);\n splitter.disconnect(ampR, 1);\n ampL.disconnect(merger, 0, 0);\n ampR.disconnect(merger, 0, 1);\n lfoL.disconnect(ampL);\n lfoR.disconnect(ampR);\n merger.disconnect(output);\n };\n\n return { input, output };\n}\n","import { createControl } from \"./player/signals\";\nimport { midiVelToGain } from \"./player/volume\";\nimport { SfzSampler, SfzSamplerConfig } from \"./sfz/sfz-sampler\";\nimport { createTremolo } from \"./tremolo\";\n\nexport function getElectricPianoNames() {\n return Object.keys(INSTRUMENTS);\n}\n\nconst INSTRUMENTS: Record<string, string> = {\n CP80: \"https://danigb.github.io/samples/gs-e-pianos/CP80/cp80.websfz.json\",\n PianetT:\n \"https://danigb.github.io/samples/gs-e-pianos/Pianet T/pianet-t.websfz.json\",\n WurlitzerEP200:\n \"https://danigb.github.io/samples/gs-e-pianos/Wurlitzer EP200/wurlitzer-ep200.websfz.json\",\n TX81Z:\n \"https://danigb.github.io/samples/vcsl/TX81Z/tx81z-fm-piano.websfz.json\",\n};\n\nexport class ElectricPiano extends SfzSampler {\n public readonly tremolo: Readonly<{ level: (value: number) => void }>;\n constructor(\n context: AudioContext,\n options: Partial<SfzSamplerConfig> & { instrument: string }\n ) {\n super(context, {\n ...options,\n instrument: INSTRUMENTS[options.instrument] ?? options.instrument,\n });\n const depth = createControl(0);\n this.tremolo = {\n level: (level) => depth.set(midiVelToGain(level)),\n };\n const tremolo = createTremolo(context, depth.subscribe);\n this.output.addInsert(tremolo);\n }\n}\n","import { toMidi } from \"./midi\";\nimport {\n RegionGroup,\n SampleOptions,\n SampleRegion,\n SampleStart,\n SamplerInstrument,\n} from \"./types\";\nimport { dbToGain } from \"./volume\";\n\nexport function createEmptySamplerInstrument(\n options: Partial<SampleOptions> = {}\n): SamplerInstrument {\n return { groups: [], options };\n}\n\nexport function createEmptyRegionGroup(): RegionGroup {\n return { regions: [] };\n}\n\nexport function findSamplesInRegions(\n group: RegionGroup,\n sample: SampleStart,\n seqNumber?: number | undefined,\n options?: Partial<SampleOptions>\n): SampleStart[] {\n const results: SampleStart[] = [];\n const midi = toMidi(sample.note);\n if (midi === undefined) return results;\n\n for (const region of group.regions) {\n const found = findSampleInRegion(\n midi,\n seqNumber ?? 0,\n sample,\n region,\n options\n );\n if (found) results.push(found);\n }\n return results;\n}\n\nexport function findFirstSampleInRegions(\n group: RegionGroup,\n sample: SampleStart,\n seqNumber?: number | undefined,\n options?: SampleOptions\n): SampleStart | undefined {\n const midi = toMidi(sample.note);\n\n if (midi === undefined) return undefined;\n\n for (const region of group.regions) {\n const found = findSampleInRegion(\n midi,\n seqNumber ?? 0,\n sample,\n region,\n options\n );\n if (found) return found;\n }\n return undefined;\n}\n\nfunction findSampleInRegion(\n midi: number,\n seqNum: number,\n sample: SampleStart,\n region: SampleRegion,\n defaults?: Partial<SampleOptions>\n): SampleStart | undefined {\n const matchMidi =\n midi >= (region.midiLow ?? 0) && midi < (region.midiHigh ?? 127) + 1;\n if (!matchMidi) return undefined;\n const matchVelocity =\n sample.velocity === undefined ||\n (sample.velocity >= (region.velLow ?? 0) &&\n sample.velocity <= (region.velHigh ?? 127));\n if (!matchVelocity) return undefined;\n\n if (region.seqLength) {\n const currentSeq = seqNum % region.seqLength;\n const regionIndex = (region.seqPosition ?? 1) - 1;\n if (currentSeq !== regionIndex) return undefined;\n }\n\n const semitones = midi - region.midiPitch;\n const velocity = sample.velocity ?? defaults?.velocity;\n const regionGainOffset = region.volume ? dbToGain(region.volume) : 0;\n const sampleGainOffset = sample.gainOffset ?? defaults?.gainOffset ?? 0;\n const sampleDetune = sample.detune ?? 0;\n return {\n decayTime:\n sample?.decayTime ?? region.sample?.decayTime ?? defaults?.decayTime,\n detune: 100 * (semitones + (region.tune ?? 0)) + sampleDetune,\n duration: sample?.duration ?? region.sample?.duration ?? defaults?.duration,\n gainOffset: sampleGainOffset + regionGainOffset || undefined,\n loop: sample?.loop ?? region.sample?.loop ?? defaults?.loop,\n loopEnd: sample?.loopEnd ?? region.sample?.loopEnd ?? defaults?.loopEnd,\n loopStart:\n sample?.loopStart ?? region.sample?.loopStart ?? defaults?.loopStart,\n lpfCutoffHz:\n sample?.lpfCutoffHz ??\n region.sample?.lpfCutoffHz ??\n defaults?.lpfCutoffHz,\n name: region.sampleName,\n note: midi,\n onEnded: sample.onEnded,\n onStart: sample.onStart,\n stopId: sample.name,\n time: sample.time,\n velocity: velocity == undefined ? undefined : velocity,\n };\n}\n\nexport function spreadRegions(regions: SampleRegion[]) {\n if (regions.length === 0) return [];\n regions.sort((a, b) => a.midiPitch - b.midiPitch);\n regions[0].midiLow = 0;\n regions[0].midiHigh = 127;\n if (regions.length === 1) return regions;\n\n for (let i = 1; i < regions.length; i++) {\n const prev = regions[i - 1];\n const curr = regions[i];\n const mid = Math.floor((prev.midiPitch + curr.midiPitch) / 2);\n prev.midiHigh = mid;\n curr.midiLow = mid + 1;\n }\n regions[regions.length - 1].midiHigh = 127;\n\n return regions;\n}\n","import { Channel, ChannelConfig, OutputChannel } from \"./channel\";\nimport { createEmptySamplerInstrument, findSamplesInRegions } from \"./layers\";\nimport { toMidi } from \"./midi\";\nimport { QueuedPlayer, QueuedPlayerConfig } from \"./queued-player\";\nimport { SamplePlayer } from \"./sample-player\";\nimport {\n InternalPlayer,\n SampleOptions,\n SampleStart,\n SampleStop,\n SamplerInstrument,\n StopFn,\n} from \"./types\";\n\nexport type RegionPlayerOptions = ChannelConfig &\n SampleOptions &\n QueuedPlayerConfig;\n\n/**\n * A player with an channel output and a region group to read samples info from\n * @private\n */\nexport class RegionPlayer implements InternalPlayer {\n public readonly output: OutputChannel;\n public instrument: SamplerInstrument;\n private readonly player: InternalPlayer;\n private seqNum = 0;\n\n constructor(\n public readonly context: BaseAudioContext,\n options: Partial<RegionPlayerOptions>\n ) {\n const channel = new Channel(context, options);\n this.instrument = createEmptySamplerInstrument(options);\n this.player = new QueuedPlayer(\n new SamplePlayer(context, { ...options, destination: channel.input }),\n options\n );\n this.output = channel;\n }\n\n get buffers() {\n return this.player.buffers;\n }\n\n start(sample: SampleStart | string | number) {\n const stopAll: StopFn[] = [];\n const sampleStart = typeof sample === \"object\" ? sample : { note: sample };\n for (const group of this.instrument.groups) {\n const found = findSamplesInRegions(group, sampleStart, this.seqNum);\n this.seqNum++;\n for (const sample of found) {\n let stop = this.player.start(sample);\n stopAll.push(stop);\n }\n }\n return (time?: number) => stopAll.forEach((stop) => stop(time));\n }\n\n stop(sample?: SampleStop | string | number) {\n if (sample == undefined) {\n this.player.stop();\n return;\n }\n\n const toStop = typeof sample === \"object\" ? sample : { stopId: sample };\n const midi = toMidi(toStop.stopId);\n if (!midi) return;\n toStop.stopId = midi;\n this.player.stop(toStop);\n }\n\n disconnect() {\n this.output.disconnect();\n this.player.disconnect();\n }\n}\n","import {\n AudioBuffers,\n getPreferredAudioExtension,\n loadAudioBuffer,\n} from \"./player/load-audio\";\nimport { RegionGroup, SampleRegion } from \"./player/types\";\nimport { Storage } from \"./storage\";\n\nexport type SfzLoaderConfig = {\n urlFromSampleName: (sampleName: string, audioExt: string) => string;\n buffers: AudioBuffers;\n group: RegionGroup;\n};\n\nexport function SfzInstrumentLoader(url: string, config: SfzLoaderConfig) {\n const audioExt = getPreferredAudioExtension();\n\n return async (\n context: BaseAudioContext,\n storage: Storage\n ): Promise<RegionGroup> => {\n const sfz = await fetch(url).then((res) => res.text());\n const errors = sfzToLayer(sfz, config.group);\n if (errors.length) {\n console.warn(\"Problems converting sfz\", errors);\n }\n const sampleNames = new Set<string>();\n config.group.regions.forEach((r) => sampleNames.add(r.sampleName));\n return Promise.all(\n Array.from(sampleNames).map(async (sampleName) => {\n const sampleUrl = config.urlFromSampleName(sampleName, audioExt);\n const buffer = await loadAudioBuffer(context, sampleUrl, storage);\n config.buffers[sampleName] = buffer;\n })\n ).then(() => config.group);\n };\n}\n\nexport function sfzToLayer(sfz: string, group: RegionGroup) {\n let mode = \"global\";\n const tokens = sfz\n .split(\"\\n\")\n .map(parseToken)\n .filter((x): x is Token => !!x);\n\n const scope = new Scope();\n let errors: (string | undefined)[] = [];\n\n for (const token of tokens) {\n switch (token.type) {\n case \"mode\":\n errors.push(scope.closeScope(mode, group));\n mode = token.value;\n\n break;\n\n case \"prop:num\":\n case \"prop:str\":\n case \"prop:num_arr\":\n scope.push(token.key, token.value);\n break;\n\n case \"unknown\":\n console.warn(\"Unknown SFZ token\", token.value);\n break;\n }\n }\n closeScope(mode, scope, group);\n\n return errors.filter((x) => !!x) as string[];\n\n function closeScope(mode: string, scope: Scope, group: RegionGroup) {}\n}\n\ntype Token =\n | { type: \"unknown\"; value: string }\n | { type: \"mode\"; value: string }\n | { type: \"prop:num\"; key: string; value: number }\n | { type: \"prop:num_arr\"; key: string; value: [number, number] }\n | { type: \"prop:str\"; key: string; value: string };\n\nconst MODE_REGEX = /^<([^>]+)>$/;\nconst PROP_NUM_REGEX = /^([^=]+)=([-\\.\\d]+)$/;\nconst PROP_STR_REGEX = /^([^=]+)=(.+)$/;\nconst PROP_NUM_ARR_REGEX = /^([^=]+)_(\\d+)=(\\d+)$/;\nfunction parseToken(line: string): Token | undefined {\n line = line.trim();\n if (line === \"\") return undefined;\n if (line.startsWith(\"//\")) return undefined;\n\n const modeMatch = line.match(MODE_REGEX);\n if (modeMatch) return { type: \"mode\", value: modeMatch[1] };\n\n const propNumArrMatch = line.match(PROP_NUM_ARR_REGEX);\n if (propNumArrMatch)\n return {\n type: \"prop:num_arr\",\n key: propNumArrMatch[1],\n value: [Number(propNumArrMatch[2]), Number(propNumArrMatch[3])],\n };\n\n const propNumMatch = line.match(PROP_NUM_REGEX);\n if (propNumMatch)\n return {\n type: \"prop:num\",\n key: propNumMatch[1],\n value: Number(propNumMatch[2]),\n };\n\n const propStrMatch = line.match(PROP_STR_REGEX);\n if (propStrMatch)\n return {\n type: \"prop:str\",\n key: propStrMatch[1],\n value: propStrMatch[2],\n };\n\n return { type: \"unknown\", value: line };\n}\n\ntype DestKey = keyof SampleRegion | \"ignore\";\n\nclass Scope {\n values: Record<string, any> = {};\n global: Partial<SampleRegion> = {};\n group: Partial<SampleRegion> = {};\n\n closeScope(mode: string, group: RegionGroup) {\n if (mode === \"global\") {\n // Save global properties\n this.#closeRegion(this.global as SampleRegion);\n } else if (mode === \"group\") {\n // Save group properties\n this.group = this.#closeRegion({} as SampleRegion);\n } else if (mode === \"region\") {\n const region = this.#closeRegion({\n sampleName: \"\",\n midiPitch: -1,\n ...this.global,\n ...this.group,\n });\n\n if (region.sampleName === \"\") {\n