@oletizi/audio-tools
Version:
Monorepo for hardware sampler utilities and format parsers
221 lines (197 loc) • 7.09 kB
text/typescript
/**
* Akai S5000/S6000 Program to DecentSampler Converter
*
* Converts Akai .AKP program files to DecentSampler .dspreset format with
* XML structure including multi-zone support and envelope parameters.
*
* @module converters/s5k-to-decentsampler
*/
import { readFileSync, writeFileSync } from "fs";
import { basename, join, relative, extname } from "pathe";
import { create } from "xmlbuilder2";
import { newProgramFromBuffer } from "@oletizi/sampler-devices/s5k";
import type { AkaiS56kProgram } from "@oletizi/sampler-devices/s5k";
/**
* Convert an Akai S5000/S6000 .AKP program file to DecentSampler format
*
* Parses S5K/S6K program file and generates DecentSampler preset with
* complete parameter mapping including global tuning, multi-zone keygroups,
* and amplitude envelopes.
*
* @param akpPath - Path to .AKP file
* @param dsOutputDir - Output directory for dspreset file
* @param wavOutputDir - Directory containing WAV samples
* @returns Path to created dspreset file
*
* @throws Error if file cannot be read, parsed, or written
*
* @remarks
* Parameter mapping from S5K to DecentSampler:
* - Global tune → group tuning attribute (semitones)
* - Output loudness → group volume (0-1 normalized)
* - Keygroup → group element
* - Zones → sample elements with velocity layers
* - Tuning → combined semitone + fine tune
* - Pan: Akai (-50 to +50) → DecentSampler (-1 to +1)
* - Volume: Akai level (-100 to +100) → dB offset * 0.2
* - Envelope: Akai values / 100 → seconds
*
* DecentSampler XML structure:
* ```xml
* <DecentSampler>
* <ui>...</ui>
* <groups>
* <group tuning="..." volume="...">
* <sample path="..." rootNote="..." loNote="..." hiNote="..."
* loVel="..." hiVel="..." tuning="..." volume="..." pan="..."
* attack="..." decay="..." sustain="..." release="..."/>
* </group>
* </groups>
* </DecentSampler>
* ```
*
* A basic UI is included with a volume control.
* Empty zones (no sample name) are automatically skipped.
*
* @example
* ```typescript
* const dsPath = convertAKPToDecentSampler(
* '/disks/s5k/raw/synth.akp',
* '/disks/s5k/decentsampler',
* '/disks/s5k/wav'
* );
* console.log(`Created DecentSampler preset: ${dsPath}`);
* ```
*
* @example
* ```typescript
* // Batch convert directory
* import { glob } from 'glob';
*
* const akpFiles = glob.sync('/raw/**\/*.akp');
* for (const akp of akpFiles) {
* convertAKPToDecentSampler(akp, '/decentsampler', '/wav');
* }
* ```
*
* @public
*/
export function convertAKPToDecentSampler(
akpPath: string,
dsOutputDir: string,
wavOutputDir: string
): string {
// Read AKP file
const akpBuffer = readFileSync(akpPath);
const program: AkaiS56kProgram = newProgramFromBuffer(akpBuffer);
// Extract program name from filename
const programName = basename(akpPath, extname(akpPath));
// Build XML structure
const root = create({ version: "1.0" }).ele("DecentSampler");
// Global settings
const tune = program.getTune();
const output = program.getOutput();
// Add basic UI
const ui = root.ele("ui", { width: "812", height: "375" });
const tab = ui.ele("tab", { name: "Main" });
tab.ele("labeled-knob", {
x: "10",
y: "20",
label: "Volume",
type: "float",
minValue: "0",
maxValue: "1",
value: "0.5",
});
// Add groups element
const groups = root.ele("groups");
// Process keygroups
const keygroups = program.getKeygroups();
for (const keygroup of keygroups) {
const kloc = keygroup.kloc;
const ampEnv = keygroup.ampEnvelope;
const filter = keygroup.filter;
// Create group
const groupAttrs: Record<string, string> = {};
if (tune.semiToneTune !== 0) {
groupAttrs.tuning = String(tune.semiToneTune);
}
if (output.loudness !== 85) {
const volume = output.loudness / 100;
groupAttrs.volume = volume.toFixed(3);
}
const group = groups.ele("group", groupAttrs);
// Process zones
const zones = [
keygroup.zone1,
keygroup.zone2,
keygroup.zone3,
keygroup.zone4,
];
for (const zone of zones) {
// Skip empty zones
if (!zone.sampleName || zone.sampleName.length === 0) {
continue;
}
const sampleName = zone.sampleName.trim();
const relPath = relative(dsOutputDir, wavOutputDir);
const samplePath = join(relPath, `${sampleName}.wav`).replace(
/\\/g,
"/"
);
const sampleAttrs: Record<string, string> = {
path: samplePath,
rootNote: String(kloc.lowNote),
loNote: String(kloc.lowNote),
hiNote: String(kloc.highNote),
};
// Velocity range
const lovel = Math.max(0, Math.min(127, zone.lowVelocity || 0));
const hivel = Math.max(0, Math.min(127, zone.highVelocity || 127));
if (lovel > 0 || hivel < 127) {
sampleAttrs.loVel = String(lovel);
sampleAttrs.hiVel = String(hivel);
}
// Tuning (combine all tuning sources)
let totalTune = 0;
if (zone.semiToneTune !== 0) totalTune += zone.semiToneTune;
if (kloc.semiToneTune !== 0) totalTune += kloc.semiToneTune;
if (zone.fineTune !== 0) totalTune += zone.fineTune / 100;
if (kloc.fineTune !== 0) totalTune += kloc.fineTune / 100;
if (totalTune !== 0) {
sampleAttrs.tuning = totalTune.toFixed(2);
}
// Volume
if (zone.level !== 0) {
const volumeDb = zone.level * 0.2;
sampleAttrs.volume = volumeDb.toFixed(2);
}
// Pan (convert -50 to 50 to -1 to 1)
if (zone.panBalance !== 0) {
const pan = zone.panBalance / 50;
sampleAttrs.pan = pan.toFixed(3);
}
// Envelope
if (ampEnv) {
if (ampEnv.attack > 0) {
sampleAttrs.attack = (ampEnv.attack / 100).toFixed(3);
}
if (ampEnv.decay > 0) {
sampleAttrs.decay = (ampEnv.decay / 100).toFixed(3);
}
if (ampEnv.sustain !== 100) {
sampleAttrs.sustain = (ampEnv.sustain / 100).toFixed(3);
}
if (ampEnv.release > 0) {
sampleAttrs.release = (ampEnv.release / 100).toFixed(3);
}
}
group.ele("sample", sampleAttrs);
}
}
// Convert to XML and write
const xml = root.end({ prettyPrint: true, indent: " " });
const dsPath = join(dsOutputDir, `${programName}.dspreset`);
writeFileSync(dsPath, xml);
return dsPath;
}