UNPKG

@oletizi/audio-tools

Version:

Monorepo for hardware sampler utilities and format parsers

351 lines (305 loc) 9.42 kB
import {WriteStream} from "fs"; import * as wavefile from "wavefile" import fs from "fs/promises"; /** * Supported audio formats * @public */ export enum AudioFormat { /** WAV audio format */ wav = "wav", } /** * Sample metadata from WAV file's smpl chunk * @public * @see https://www.recordingblogs.com/wiki/sample-chunk-of-a-wave-file */ export interface SampleMetadata { /** MIDI Manufacturers Association (MMA) manufacturer code */ manufacturerId: number /** Product ID (manufacturer-specific) */ productId: number /** Sample period in nanoseconds (1/sample rate * 1e9) */ samplePeriod: number /** MIDI root note (0-127) */ rootNote: number /** Fine pitch adjustment (0-2^32, where 2^32 = 1 semitone) */ pitchFraction: number /** SMPTE format code */ smpteFormat: number /** SMPTE offset in hours, minutes, seconds, frames */ smpteOffset: number /** Number of sample loops */ loopCount: number /** Total number of samples */ sampleLength: number /** Number of audio channels */ channelCount: number /** Bit depth (e.g., 16, 24) */ bitDepth: number /** Sample rate in Hz */ sampleRate: number } /** * Represents an audio sample with operations for manipulation and conversion * @public */ export interface Sample { /** * Get sample metadata from WAV smpl chunk * @returns SampleMetadata object with all metadata fields */ getMetadata(): SampleMetadata /** * Get total number of samples (frames) * @returns Number of samples */ getSampleCount(): number /** * Get number of audio channels * @returns Channel count (1 = mono, 2 = stereo) */ getChannelCount(): number /** * Get sample rate * @returns Sample rate in Hz */ getSampleRate(): number /** * Get bit depth * @returns Bit depth (e.g., 16, 24) */ getBitDepth(): number /** * Set the MIDI root note in sample metadata * @param r - MIDI note number (0-127) */ setRootNote(r: number): void /** * Trim sample to specified range * @param start - Start sample index (inclusive) * @param end - End sample index (exclusive) * @returns New Sample instance with trimmed audio */ trim(start: number, end: number): Sample /** * Convert sample to 16-bit depth * @returns This Sample instance (mutates in place) */ to16Bit(): Sample /** * Convert sample to 24-bit depth * @returns This Sample instance (mutates in place) */ to24Bit(): Sample /** * Convert sample to 44.1 kHz sample rate * @returns This Sample instance (mutates in place) */ to441(): Sample /** * Convert sample to 48 kHz sample rate * @returns This Sample instance (mutates in place) */ to48(): Sample /** * Write sample data to buffer * @param buf - Target buffer * @param offset - Offset in buffer to start writing (default: 0) * @returns Number of bytes written */ write(buf: Buffer, offset?: number): number /** * Write sample data to stream * @param stream - Writable stream to write to * @returns Promise resolving to number of bytes written * @throws Error if stream encounters an error */ writeToStream(stream: WriteStream): Promise<number> /** * Get sample data as Float64 array * @returns Float64Array containing interleaved sample data */ getSampleData(): Float64Array; /** * Get raw binary data * @returns Uint8Array containing raw WAV file bytes */ getRawData(): Uint8Array } /** * Factory for creating Sample instances from files or buffers * @public */ export interface SampleFactory { /** * Create Sample from audio file * @param filename - Path to audio file * @returns Promise resolving to Sample instance * @throws Error if file cannot be read or parsed */ newSampleFromFile(filename: string): Promise<Sample> /** * Create Sample from buffer * @param buf - Audio data buffer * @param format - Audio format (currently only supports WAV) * @returns Sample instance * @throws Error if buffer cannot be parsed */ newSampleFromBuffer(buf: Uint8Array, format: AudioFormat): Sample } /** * Creates a default sample factory that supports WAV format * @returns SampleFactory instance * @public * @example * ```typescript * const factory = newDefaultSampleFactory(); * const sample = await factory.newSampleFromFile("piano-C4.wav"); * console.log(sample.getSampleRate()); // 44100 * ``` */ export function newDefaultSampleFactory(): SampleFactory { function wavFactory(buf: Uint8Array) { const wav = new wavefile.default.WaveFile() wav.fromBuffer(buf) return wav } function fromBuffer(buf: Uint8Array, format: AudioFormat): Sample { return new WavSample(wavFactory, buf) } async function fromFile(filename: string): Promise<Sample> { return fromBuffer(await fs.readFile(filename), AudioFormat.wav) } return { newSampleFromBuffer: fromBuffer, newSampleFromFile: fromFile } } /** * WAV file sample implementation using wavefile library * @internal */ export class WavSample implements Sample { private readonly wav: wavefile.WaveFile private readonly buf: Uint8Array; private readonly factory: (buf: Uint8Array) => wavefile.WaveFile; /** * Create WavSample instance * @param factory - Factory function to create WaveFile from buffer * @param buf - WAV file data buffer * @internal */ constructor(factory: (buf: Uint8Array) => wavefile.WaveFile, buf: Uint8Array) { this.wav = factory(buf) this.buf = buf this.factory = factory } getMetadata(): SampleMetadata { const rv = {} as SampleMetadata const smpl = this.wav.smpl if (smpl) { // @ts-ignore rv.manufacturerId = smpl['dwManufacturer'] // @ts-ignore rv.productId = smpl['dwProduct'] // @ts-ignore rv.samplePeriod = smpl['dwSamplePeriod'] // @ts-ignore rv.rootNote = smpl['dwMIDIUnityNote'] // @ts-ignore rv.pitchFraction = smpl['dwMIDIPitchFraction'] // @ts-ignore rv.smpteFormat = smpl['dwSMPTEFormat'] // @ts-ignore rv.smpteOffset = smpl['dwSMPTEOffset'] // @ts-ignore rv.loopCount = smpl['dwNumSampleLoops'] } rv.sampleLength = this.getSampleCount() rv.sampleRate = this.getSampleRate() rv.channelCount = this.getChannelCount() rv.bitDepth = this.getBitDepth() return rv } getChannelCount(): number { // @ts-ignore return this.wav.fmt["numChannels"] } getSampleCount(): number { // XXX: There's probably a more efficient way to do this const channelCount = this.getChannelCount() ? this.getChannelCount() : 1 return this.wav.getSamples(true).length / channelCount } getSampleRate(): number { // @ts-ignore return this.wav.fmt.sampleRate } getBitDepth(): number { return parseInt(this.wav.bitDepth) } setRootNote(r: number) { // @ts-ignore this.wav.smpl['dwMIDIUnityNote'] = r } to16Bit(): Sample { this.wav.toBitDepth("16") return this } to24Bit(): Sample { this.wav.toBitDepth("24") return this } to441(): Sample { this.wav.toSampleRate(44100) this.cleanup() return this } to48(): Sample { this.wav.toSampleRate(48000) this.cleanup() return this } trim(start: number, end: number): Sample { // @ts-ignore const channelCount = this.wav.fmt["numChannels"] const trimmedSamples = this.wav.getSamples(true).slice(start * channelCount, end * channelCount) const trimmed = new wavefile.default.WaveFile() // @ts-ignore trimmed.fromScratch(channelCount, this.wav.fmt.sampleRate, this.wav.bitDepth, trimmedSamples) return new WavSample(this.factory, trimmed.toBuffer()) } write(buf: Buffer, offset: number = 0) { const wavBuffer = Buffer.from(this.wav.toBuffer()) wavBuffer.copy(buf, offset, 0, wavBuffer.length) return wavBuffer.length } writeToStream(stream: WriteStream): Promise<number> { return new Promise((resolve, reject) => { stream.on('error', (e => reject(e))) const buf = this.wav.toBuffer() stream.write(buf) stream.end(() => resolve(buf.length)) }) } /** * Clean up WAV file metadata after sample rate conversion * @internal */ private cleanup(): Sample { // @ts-ignore const sampleLength = this.wav.data.chunkSize / this.wav.fmt["numChannels"]; this.wav.fact = { chunkId: "fact", chunkSize: 4, dwSampleLength: sampleLength } return this } getSampleData(): Float64Array { return this.wav.getSamples(true) } getRawData(): Uint8Array { return this.buf } }