UNPKG

@oletizi/audio-tools

Version:

Monorepo for hardware sampler utilities and format parsers

241 lines (231 loc) 7.75 kB
import * as htmlparser2 from "htmlparser2" import * as riffFile from "riff-file"; /** * Akai MPC format utilities for parsing programs and sample slice data * @public */ export namespace mpc { /** * Sample slice definition from MPC slice data * @public */ export interface Slice { /** Slice name/identifier */ name: string /** Start position in samples */ start: number /** End position in samples */ end: number /** Loop start position in samples */ loopStart: number } /** * Sample slice data embedded in WAV file 'atem' chunk * @public */ export interface SampleSliceData { /** Format version number */ version: number /** Musical note */ note: string /** Musical scale */ scale: string /** Array of slice definitions */ slices: Slice[] /** Number of bars */ barCount: number } /** * Extract sample slice data from MPC-format WAV file. * * MPC software can embed slice information in a custom 'atem' RIFF chunk * within WAV files. This function extracts that metadata. * * @param buf - Buffer containing WAV file data * @returns SampleSliceData with all slice points * @public * * @example * ```typescript * import fs from "fs/promises"; * import { mpc } from "@/lib-akai-mpc.js"; * * const wavBuffer = await fs.readFile("drumloop.wav"); * const sliceData = mpc.newSampleSliceDataFromBuffer(wavBuffer); * * console.log(`Found ${sliceData.slices.length} slices`); * for (const slice of sliceData.slices) { * console.log(`${slice.name}: ${slice.start} - ${slice.end}`); * } * ``` * * @remarks * - Looks for 'atem' RIFF chunk in WAV file * - Parses JSON data from chunk containing slice information * - Each slice has start/end sample positions * - Returns empty slices array if no 'atem' chunk found * - Slice positions are in sample frames */ export function newSampleSliceDataFromBuffer(buf: Buffer): SampleSliceData { const rv: SampleSliceData = { version: -1, note: "", scale: "", barCount: -1, slices: [] } const riff = new riffFile.RIFFFile() riff.setSignature(buf) const chunk: any = riff.findChunk('atem') if (chunk && chunk.chunkData) { let d: string = buf.subarray(chunk.chunkData.start, chunk.chunkData.end).toString() const o = JSON.parse(d) const value0 = o['value0'] for (const name of Object.getOwnPropertyNames(value0)) { if (name.startsWith('Slice')) { const s = value0[name] const slice: Slice = { name: name, start: s['Start'], end: s['End'], loopStart: 0 } rv.slices.push(slice) } } } return rv } /** * Parse MPC program XML file from buffer. * * MPC programs are stored in XML format containing layers (samples), * slice information, and program settings. * * @param buf - Buffer containing MPC program XML file * @returns MpcProgram with all layers and settings * @public * * @example * ```typescript * import fs from "fs/promises"; * import { mpc } from "@/lib-akai-mpc.js"; * * const xmlBuffer = await fs.readFile("drumkit.xpm"); * const program = mpc.newProgramFromBuffer(xmlBuffer); * * console.log(`Program: ${program.programName}`); * console.log(`Layers: ${program.layers.length}`); * for (const layer of program.layers) { * console.log(`Layer ${layer.number}: ${layer.sampleName}`); * } * ``` * * @remarks * - Parses standard MPC XML program format * - Extracts program name and layer information * - Each layer references a sample and has slice points * - Only layers with sample names are included in result * - Slice start/end positions are in sample frames */ export function newProgramFromBuffer(buf: Buffer): MpcProgram { const layers: Layer[] = [] const program: MpcProgram = { programName: "", layers: layers } let inProgramName = false let inLayer = false let layer: Partial<Layer> = {} let inSampleName = false let inSliceStart = false let inSliceEnd = false const parser = new htmlparser2.Parser({ onopentag(name: string, attribs: { [p: string]: string }, isImplied: boolean) { switch (name) { case "programname": inProgramName = true break case "instrument" : break case "layer": inLayer = true layer.number = Number.parseInt(attribs['number']) break case "samplename": inSampleName = true break case "slicestart": inSliceStart = true break case "sliceend": inSliceEnd = true break default: break } }, ontext(data: string) { if (inProgramName) { program.programName = data } else if (inSampleName) { layer.sampleName = data // this layer has a sample name, so we care about it layers.push(layer as Layer) } else if (inSliceStart) { layer.sliceStart = Number.parseInt(data) } else if (inSliceEnd) { layer.sliceEnd = Number.parseInt(data) } }, onclosetag(name: string, isImplied: boolean) { switch (name) { case "programname": inProgramName = false break case "layer": inLayer = false layer = {} break case "samplename": inSampleName = false break case "slicestart": inSliceStart = false break case "sliceend": inSliceEnd = false break default: break } } }) parser.write(buf.toString()) return program } /** * Layer definition in MPC program * @public */ export interface Layer { /** Layer number (1-based) */ number: number /** Sample file name (without extension) */ sampleName: string /** Slice start position in samples */ sliceStart: number /** Slice end position in samples */ sliceEnd: number } /** * MPC program containing multiple layers * @public */ export interface MpcProgram { /** Program name */ programName: string /** Array of layers with sample assignments */ layers: Layer[] } }