diff --git a/client/src/components/DrumMachine/DrumMachine.tsx b/client/src/components/DrumMachine/DrumMachine.tsx index 7a8723f3..0a5fe6c5 100644 --- a/client/src/components/DrumMachine/DrumMachine.tsx +++ b/client/src/components/DrumMachine/DrumMachine.tsx @@ -114,6 +114,7 @@ import { } from "@/utils/e2sExport"; import { verifyE2AllpatBank } from "@/utils/korg/e2AllpatVerify"; import { convertSynthstudioPatternToE2 } from "@/utils/electribePatternConvert"; +import { buildE2ExportGuide, downloadGuideMarkdown } from "@/utils/e2ExportGuide"; import { wrapPatternBodyAsFile } from "@/utils/korg/e2NativeSysex"; import { requestPatternFromDevice, sendPatternToDevice } from "@/audio/E2NativeSysexTransfer"; import { useElectron } from "../../../../electron/useElectron"; @@ -4851,12 +4852,19 @@ function DrumMachineInner({ .slice(0, 60) || "pattern"; const filename = `${safeName}.e2spat`; + // v3.314: Zuweisungsdatei — was das .e2spat NICHT trägt + // (Sample-Zuordnung, Channel-FX) zum Nachbauen am Gerät. + const guide = buildE2ExportGuide([currentPattern], { + title: filename, + }); + if (electron.isElectron) { const result = await electron.saveE2Pattern( filename, buffer ); if (result.success) { + downloadGuideMarkdown(`${safeName}-zuweisung.md`, guide); toast(`E2 Pattern gespeichert: ${result.filePath}`, { kind: "success", }); @@ -4878,6 +4886,7 @@ function DrumMachineInner({ a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); + downloadGuideMarkdown(`${safeName}-zuweisung.md`, guide); toast(`E2 Pattern heruntergeladen: ${filename}`, { kind: "success", }); @@ -4929,6 +4938,11 @@ function DrumMachineInner({ allPatterns.length > 250 ? allPatterns.length - 250 : 0; const filename = "synthstudio-bank.e2sallpat"; + // v3.314: Zuweisungsdatei für die ganze Bank (Sample- + // Zuordnung + Channel-FX zum Nachbauen am Gerät). + const guide = buildE2ExportGuide(allPatterns.slice(0, 250), { + title: filename, + }); if (electron.isElectron) { const result = await electron.saveE2AllPat( @@ -4936,6 +4950,7 @@ function DrumMachineInner({ buffer ); if (result.success) { + downloadGuideMarkdown("synthstudio-bank-zuweisung.md", guide); toast( `E2 Pattern-Bank gespeichert (${e2Inputs.length} Patterns${dropped ? `, ${dropped} übersprungen` : ""}): ${result.filePath}`, { kind: "success" } @@ -4957,6 +4972,7 @@ function DrumMachineInner({ a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); + downloadGuideMarkdown("synthstudio-bank-zuweisung.md", guide); toast( `E2 Pattern-Bank heruntergeladen (${e2Inputs.length} Patterns${dropped ? `, ${dropped} übersprungen` : ""}): ${filename}`, { kind: "success" } diff --git a/client/src/utils/e2ExportGuide.ts b/client/src/utils/e2ExportGuide.ts new file mode 100644 index 00000000..4e559b48 --- /dev/null +++ b/client/src/utils/e2ExportGuide.ts @@ -0,0 +1,152 @@ +/** + * client/src/utils/e2ExportGuide.ts + * + * v3.314 — Zuweisungsdatei für den Sequenzer→E2-Export. + * + * Der `.e2spat`/`.e2sallpat`-Export trägt Trigger, Velocity, Note, Volume, + * Pan und Mute — aber NICHT: welches Sample auf welchem Part liegt (die E2S + * nutzt ihre eigene Sample-Bank), und keine der Synthstudio-Channel-FX + * (Filter/Distortion/Compressor/Delay/Reverb/EQ laufen in der WebAudio-Engine + * und haben kein Byte-Feld im Pattern-Format). Diese Markdown-Datei listet + * pro Part alles auf, was am Gerät von Hand nachgebaut werden muss — + * analog zur FX-Zuweisung des ESX-Imports (v3.313). + * + * Reine TS-Logik (kein DOM) — testbar in Node. + */ + +import type { PatternData, PartData, ChannelFx } from "../audio/AudioEngine"; +import { synthVolumeToE2, synthPanToE2 } from "./electribePatternConvert"; + +/** Ein aktivierter Channel-FX als Zeile "Name (Params) — E2-Vorschlag". */ +function fxLines(fx: ChannelFx | undefined): string[] { + if (!fx) return []; + const out: string[] = []; + if (fx.filterEnabled) { + const type = + fx.filterType === "highpass" + ? "HPF" + : fx.filterType === "bandpass" + ? "BPF" + : fx.filterType === "notch" + ? "Notch" + : "LPF"; + out.push( + `Filter ${type} @ ${Math.round(fx.filterFreq)} Hz, Q ${fx.filterQ.toFixed(1)}` + + ` — E2: Part-Filter (${type === "HPF" ? "HPF" : "LPF"}-Typ, Cutoff/Reso)` + ); + } + if (fx.distortionEnabled) { + out.push( + `Distortion (Drive ${Math.round(fx.distortionAmount)}) — E2: IFX Distortion` + ); + } + if (fx.compressorEnabled) { + out.push( + `Compressor (Thr ${fx.compressorThreshold} dB, Ratio ${fx.compressorRatio}:1)` + + ` — E2: IFX Compressor` + ); + } + if (fx.delayEnabled) { + out.push( + `Delay (${(fx.delayTime * 1000).toFixed(0)} ms, FB ${(fx.delayFeedback * 100).toFixed(0)} %,` + + ` Mix ${(fx.delayMix * 100).toFixed(0)} %) — E2: IFX BPM Sync Delay / Tape Echo` + ); + } + if (fx.reverbEnabled) { + out.push( + `Reverb (Decay ${fx.reverbDecay.toFixed(1)} s, Mix ${(fx.reverbMix * 100).toFixed(0)} %)` + + ` — E2: MFX Hall/Plate + MFX-Send AN` + ); + } + if (fx.eqEnabled) { + out.push( + `EQ (Low ${fx.eqLow > 0 ? "+" : ""}${fx.eqLow} / Mid ${fx.eqMid > 0 ? "+" : ""}${fx.eqMid}` + + ` / High ${fx.eqHigh > 0 ? "+" : ""}${fx.eqHigh} dB) — E2: IFX 2-Band EQ` + ); + } + return out; +} + +function partHasActivity(part: PartData | undefined): boolean { + return !!part && (part.steps ?? []).some((s) => s?.active); +} + +function sourceLabel(part: PartData): string { + if (part.sampleName) return part.sampleName; + if (part.sourceType && part.sourceType !== "sample") + return `[${part.sourceType}-Synth]`; + return "—"; +} + +/** + * Baut die Zuweisungs-Markdown für einen Sequenzer→E2-Export. + * `patterns` = die exportierten Patterns in Bank-Reihenfolge (Slot 1..n). + */ +export function buildE2ExportGuide( + patterns: PatternData[], + opts?: { title?: string } +): string { + const lines: string[] = []; + lines.push(`# ${opts?.title ?? "Synthstudio → E2 Sampler"} — Zuweisung`); + lines.push(""); + lines.push( + "Das Pattern-Format trägt Trigger/Velocity/Note/Volume/Pan/Mute. NICHT" + ); + lines.push( + "übertragen werden: Sample-Zuordnung (die E2S spielt ihre eigene Bank)" + ); + lines.push( + "und die Synthstudio-Channel-FX. Diese Liste zum Nachbauen am Gerät:" + ); + lines.push(""); + + let any = false; + patterns.forEach((pattern, idx) => { + const parts = (pattern.parts ?? []).filter(partHasActivity); + if (parts.length === 0) return; + any = true; + const bpm = typeof pattern.bpm === "number" ? ` · ${pattern.bpm} BPM` : ""; + lines.push(`## Pattern ${idx + 1}${pattern.name ? ` „${pattern.name}"` : ""}${bpm}`); + lines.push(""); + lines.push("| Part | Sound | Level (E2) | Pan (E2) | Nachbauen |"); + lines.push("|---:|---|---:|---:|---|"); + (pattern.parts ?? []).forEach((part, pi) => { + if (!partHasActivity(part)) return; + const fx = fxLines(part.fx); + const extra: string[] = [...fx]; + if (part.stretchRatio && part.stretchRatio !== 1) + extra.push(`Time-Stretch ×${part.stretchRatio}`); + if (part.microTiming) + extra.push(`Micro-Timing ${part.microTiming > 0 ? "+" : ""}${part.microTiming} ms`); + lines.push( + `| ${pi + 1} | ${sourceLabel(part)} | ${synthVolumeToE2(part.volume)} | ` + + `${synthPanToE2(part.pan)} | ${extra.length ? extra.join("; ") : "—"} |` + ); + }); + lines.push(""); + }); + + if (!any) { + lines.push("_Keine Parts mit aktiven Steps im Export._"); + lines.push(""); + } + return lines.join("\n"); +} + +/** + * Bietet die Zuweisung als `.md`-Download an (Browser UND Electron — beide + * rendern in Chromium, der Anchor-Download funktioniert in beiden). Ohne DOM + * (Node-Testkontext) ein No-op, damit die reine Guide-Logik testbar bleibt. + */ +export function downloadGuideMarkdown(filename: string, text: string): void { + if (typeof document === "undefined" || typeof URL === "undefined") return; + const blob = new Blob([text], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} diff --git a/client/src/utils/e2sExport.ts b/client/src/utils/e2sExport.ts index 41ae79e9..57e47c85 100644 --- a/client/src/utils/e2sExport.ts +++ b/client/src/utils/e2sExport.ts @@ -117,6 +117,11 @@ const PART_VOLUME_OFF = 0x18; /** Amp Pan (i8, 0 = Center) @ +0x19 — v3.297-Korrektur: vorher u8@0x22 * (= IFX Edit). UI-0..127 wird via e2PanUiToDevice konvertiert. */ const PART_PAN_OFF = 0x19; +/** EG Decay/Release (u8) @ +0x15 (Korg TABLE 6 "EG"). Init/Default 127 = + * klingt voll aus. v3.312: nimmt beim ESX-Import das Part-egtime auf — + * vorher blieben alle Parts auf 127 und kurze perkussive ESX-Hüllkurven + * gingen verloren (Gerätebefund: Mix klingt anders als auf der ESX). */ +const PART_EGTIME_OFF = 0x15; const PART_STEPS_OFF = 0x30; const STEP_RECORD_SIZE = 12; const STEPS_PER_PART = 64; @@ -258,6 +263,11 @@ export function buildE2PatternBody(input: E2PatternInput): Uint8Array { clampInt(part.pan, 0, 127, 64) ); } + // v3.312: Amp-EG-Zeit @ +0x15 — nur überlagern, wenn der Aufrufer einen + // Wert liefert (ESX-Import); sonst bleibt der Template-Wert (127) stehen. + if (typeof part.egTime === "number") { + body[partStart + PART_EGTIME_OFF] = clampInt(part.egTime, 0, 127, 127); + } // Per-part sample reference @ +0x08 (u16 LE). Only written when the caller // provides one (e.g. repointing parts to imported user samples at 501+); // otherwise the template's factory sample assignment is preserved. diff --git a/client/src/utils/electribePatternBuilder.ts b/client/src/utils/electribePatternBuilder.ts index 89f84762..405a33c3 100644 --- a/client/src/utils/electribePatternBuilder.ts +++ b/client/src/utils/electribePatternBuilder.ts @@ -142,6 +142,13 @@ export interface E2PartInput { pitch?: number; /** 0..127 — Effect-Send. Default 0. */ fxSend?: number; + /** + * v3.312 — Amp-EG-Zeit (EG Decay/Release) @ part+0x15. 0..127, Default 127 + * (Init-Wert; klingt voll aus). ESX-Import: das Part-egtime-Byte trug die + * perkussiven Hüllkurven — ohne Übernahme klangen kurze ESX-Parts auf dem + * E2S dauerhaft lang und verschoben den gehörten Mix. + */ + egTime?: number; /** * v3.288: Part-Mute-Zustand. Wird beim Export in das E2-Pattern geschrieben * (Offset aus verifizierter Reverse-Eng), damit die exportierte Bank die diff --git a/client/src/utils/korg/e2sBankBuilder.ts b/client/src/utils/korg/e2sBankBuilder.ts index d83ef125..e8b96c43 100644 --- a/client/src/utils/korg/e2sBankBuilder.ts +++ b/client/src/utils/korg/e2sBankBuilder.ts @@ -619,7 +619,11 @@ function buildKorgSubchunk( // Fixed-value fields the device expects (Oe2sSLE "_UFix"; verified constant // across real factory/user banks). Oe2sSLE warns if these differ → set them. - chunk[bodyOffset + 0x1d] = 0x02; + // v3.316-Fix: +0x1D ist KEIN Fix-Byte, sondern das MSB von importNum + // (u16 @0x1C = sampleNumber+50)! In den Referenz-Banken lagen alle + // importNums zufaellig in 0x200..0x2FF, daher sah das Byte konstant 0x02 + // aus. Der Hardcode hat ab sampleNumber 718 (importNum >= 0x300) das MSB + // zerstoert — Befund beim Bau der 233-Slot-Bank "hunnel" (Slots bis 733). chunk[bodyOffset + 0x21] = 0x7f; chunk[bodyOffset + 0x23] = 0x01; chunk[bodyOffset + 0x4b] = 0x01; diff --git a/client/src/utils/korg/esxParser.ts b/client/src/utils/korg/esxParser.ts index baa96d65..63c50a9a 100644 --- a/client/src/utils/korg/esxParser.ts +++ b/client/src/utils/korg/esxParser.ts @@ -255,6 +255,23 @@ export interface EsxPart { pitch: number; /** 0..127. */ fxAmount: number; + /** + * v3.312 — Amp-EG-Zeit (egtime) 0..127, 127 = klingt voll aus. Layout + * (lammas/electribe drumpart.js + open-electribe-editor): Drum/Keyboard + * @+11, Stretch/Slice @+9 (Block um -2 verschoben). Traegt die perkussiven + * Huellkurven — Verlust verschiebt den gehoerten Mix auf dem E2S. + */ + egTime?: number; + /** + * v3.313 — FX-Send an/aus (fxflags bit 2). fxflags-Byte: Drum/Keyboard + * @+13, Stretch/Slice @+11. Bit-Layout lt. lammas Common.FXFlags: + * bits 0-1 = FxSelect (FX1/2/3), bit 2 = FxSend, bit 3 = Roll, + * bit 4 = AmpEg, bit 5 = Reverse. Ein Part mit FxSend=an laeuft auf der + * ESX insert-artig DURCH den gewaehlten FX-Prozessor. + */ + fxSend?: boolean; + /** v3.313 — Gewaehlter FX-Prozessor 0..2 (= FX1..FX3), fxflags bits 0-1. */ + fxSelect?: number; /** v3.293: Verifizierte Per-Part Filter/Mod-Werte (siehe EsxPartFilter). */ filter?: EsxPartFilter; /** Trigger-Steps, Laenge === ESX1_DEFAULT_STEPS. */ @@ -283,6 +300,12 @@ export interface EsxKeyboardPart { volume: number; /** 0..127 (64 = center). */ pan: number; + /** v3.312 — Amp-EG-Zeit (egtime @+11), 0..127. */ + egTime?: number; + /** v3.313 — FX-Send an/aus (fxflags @+13, bit 2). */ + fxSend?: boolean; + /** v3.313 — Gewaehlter FX-Prozessor 0..2 (fxflags bits 0-1). */ + fxSelect?: number; /** 128 Note-Bytes (roh — die Note-Semantik ist nicht öffentlich RE-d). */ note: Uint8Array; /** 128 Gate-Bytes (roh — Gate-Länge pro Step). */ @@ -334,10 +357,57 @@ export interface EsxPattern { * daten). Additiv zu `parts` — verifiziert gegen open-electribe-editor. */ keyboardParts: EsxKeyboardPart[]; + /** + * v3.313 — Die 3 Pattern-FX-Prozessoren (FX1..FX3) @ Pattern+1148, je 4 B + * (fxtype, edit1, edit2, motionseqstatus — lammas fxparam.js). Empirisch + * verifiziert (lukn kicks Pattern 1: EQ 99/67 · Compressor 62/38 · + * Short Delay 127/0 — alles plausibel dekodiert). + */ + fx?: EsxFxSlot[]; + /** + * v3.313 — FX-Chain-Routing @ Pattern-Byte 12 (lammas pattern.js FXChain): + * 0 = keine Kette, 1 = FX1→FX2, 2 = FX2→FX3, 3 = FX1→FX2→FX3. + */ + fxChain?: number; /** Rohbytes des 4280-Byte Pattern-Blocks. Hilft beim Debugging + Diff. */ raw?: Uint8Array; } +/** v3.313 — Ein Pattern-FX-Prozessor (fxtype + 2 Edit-Parameter). */ +export interface EsxFxSlot { + /** 0..15 (siehe ESX_FX_TYPE_NAMES). */ + fxType: number; + /** Edit-1-Parameter 0..127. */ + edit1: number; + /** Edit-2-Parameter 0..127. */ + edit2: number; +} + +/** v3.313 — ESX-FX-Typnamen (lammas fxparam.js Enum 0..15). */ +export const ESX_FX_TYPE_NAMES: readonly string[] = [ + "Reverb", + "BPM Sync Delay", + "Short Delay", + "Mod Delay", + "Grain Shifter", + "Cho/Flg", + "Phaser", + "Ring Mod", + "Talking Mod", + "Pitch Shifter", + "Compressor", + "Distortion", + "Decimator", + "EQ", + "LPF", + "HPF", +]; + +/** v3.313 — Name eines ESX-FX-Typs (unbekannte Werte → "FX "). */ +export function esxFxTypeName(fxType: number): string { + return ESX_FX_TYPE_NAMES[fxType] ?? `FX ${fxType}`; +} + /** * v3.89.0 — Ein einzelnes Song-Event (8 Bytes). * @@ -679,6 +749,24 @@ const ESX1_STRETCHSLICE_STRIDE = 32; const ESX1_STRETCHSLICE_STEPS_OFFSET = 16; const ESX1_PITCH_NEUTRAL_RAW = 0x40; +// v3.313: Pattern-FX (lammas fxparam.js/pattern.js). 3 Prozessoren à 4 Byte +// direkt hinter dem Accent-Part (1130 + 18 = 1148); FX-Chain @ Byte 12. +const ESX1_FXPARAM_OFFSET = 1148; +const ESX1_FXPARAM_STRIDE = 4; +const ESX1_FX_SLOT_COUNT = 3; +const ESX1_FXCHAIN_OFFSET = 12; +// fxflags-Bits (lammas Common.FXFlags): bits 0-1 FxSelect, bit 2 FxSend. +const ESX1_FXFLAGS_SELECT_MASK = 0x03; +const ESX1_FXFLAGS_SEND_BIT = 0x04; + +/** v3.313: fxflags-Byte → { fxSend, fxSelect }. */ +function decodeFxFlags(byte: number): { fxSend: boolean; fxSelect: number } { + return { + fxSend: (byte & ESX1_FXFLAGS_SEND_BIT) !== 0, + fxSelect: byte & ESX1_FXFLAGS_SELECT_MASK, + }; +} + // ─── Mute-Status (Pattern-Header) ──────────────────────────────────────────── // PatternHeader-Offset 16: 16-Bit muteStatus (per-Part). Storage-Order der // Parts: Drum 0..8 → Bits 0..8, Keyboard 0..1 → Bits 9..10, Stretch/Slice 0..2 @@ -869,6 +957,9 @@ function decodeDrumPart( pan: number; pitch: number; fxAmount: number; + egTime: number; + fxSend: boolean; + fxSelect: number; filter: EsxPartFilter; steps: EsxStepEvent[]; } @@ -881,6 +972,10 @@ function decodeDrumPart( const pitch = decodePitchByte(raw[partOff + 8] ?? ESX1_PITCH_NEUTRAL_RAW); const volume = Math.max(0, Math.min(127, raw[partOff + 9] ?? 100)); const pan = Math.max(0, Math.min(127, raw[partOff + 10] ?? 64)); + // v3.312: egtime @+11 (lammas drumpart.js: ...level@9, pan@10, egtime@11) + const egTime = Math.max(0, Math.min(127, raw[partOff + 11] ?? 127)); + // v3.313: fxflags @+13 (egtime@11, startpoint@12, fxflags@13) + const { fxSend, fxSelect } = decodeFxFlags(raw[partOff + 13] ?? 0); const filter = decodeEsxFilter(raw, partOff, ESX_FILTER_LAYOUT_DRUM); const steps = bitmaskToSteps(raw, partOff + ESX1_PART_HEADER_BYTES); return { @@ -889,6 +984,9 @@ function decodeDrumPart( pan, pitch, fxAmount: 0, + egTime, + fxSend, + fxSelect, filter, steps, }; @@ -910,6 +1008,9 @@ function decodeStretchSlicePart( pan: number; pitch: number; fxAmount: number; + egTime: number; + fxSend: boolean; + fxSelect: number; filter: EsxPartFilter; steps: EsxStepEvent[]; } @@ -921,6 +1022,10 @@ function decodeStretchSlicePart( const pitch = decodePitchByte(raw[partOff + 6] ?? ESX1_PITCH_NEUTRAL_RAW); const volume = Math.max(0, Math.min(127, raw[partOff + 7] ?? 100)); const pan = Math.max(0, Math.min(127, raw[partOff + 8] ?? 64)); + // v3.312: egtime @+9 (Stretch/Slice-Block um -2 verschoben, s. v3.293) + const egTime = Math.max(0, Math.min(127, raw[partOff + 9] ?? 127)); + // v3.313: fxflags @+11 (Block -2 gegenueber Drum: 13-2) + const { fxSend, fxSelect } = decodeFxFlags(raw[partOff + 11] ?? 0); const filter = decodeEsxFilter(raw, partOff, ESX_FILTER_LAYOUT_STRETCHSLICE); const steps = bitmaskToSteps(raw, partOff + ESX1_STRETCHSLICE_STEPS_OFFSET); return { @@ -929,6 +1034,9 @@ function decodeStretchSlicePart( pan, pitch, fxAmount: 0, + egTime, + fxSend, + fxSelect, filter, steps, }; @@ -959,6 +1067,11 @@ function decodeKeyboardPart( const { sampleId, off } = decodeSamplePointer(raw, partOff); const volume = Math.max(0, Math.min(127, raw[partOff + 9] ?? 100)); const pan = Math.max(0, Math.min(127, raw[partOff + 10] ?? 64)); + // v3.312: egtime @+11 — Keyboard teilt die Level/Pan/EG-Offsets mit Drum + // (nur der Filter-Sub-Block ist um +1 verschoben, s. v3.293). + const egTime = Math.max(0, Math.min(127, raw[partOff + 11] ?? 127)); + // v3.313: fxflags @+13 (geteilte Fx-Offsets mit Drum, s. v3.293) + const { fxSend, fxSelect } = decodeFxFlags(raw[partOff + 13] ?? 0); const filter = decodeEsxFilter(raw, partOff, ESX_FILTER_LAYOUT_KEYBOARD); const noteOff = partOff + ESX1_KEYBOARD_NOTE_OFFSET; const gateOff = partOff + ESX1_KEYBOARD_GATE_OFFSET; @@ -978,6 +1091,9 @@ function decodeKeyboardPart( sampleId: off ? 0 : sampleId, volume, pan, + egTime, + fxSend, + fxSelect, note, gate, filter, @@ -1092,6 +1208,9 @@ export function parseEsxPattern( pan: number; pitch: number; fxAmount: number; + egTime?: number; + fxSend?: boolean; + fxSelect?: number; filter?: EsxPartFilter; steps: EsxStepEvent[]; } @@ -1124,15 +1243,60 @@ export function parseEsxPattern( for (let k = 0; k < ESX1_KEYBOARD_PART_COUNT; k++) { const kp = decodeKeyboardPart(raw, k); if (kp) { - const { steps, note, gate, sampleId, volume, pan, partIndex, filter } = - kp; - keyboardParts.push({ partIndex, sampleId, volume, pan, note, gate }); - pushPart({ sampleId, volume, pan, pitch: 0, fxAmount: 0, filter, steps }); + const { + steps, + note, + gate, + sampleId, + volume, + pan, + egTime, + fxSend, + fxSelect, + partIndex, + filter, + } = kp; + keyboardParts.push({ + partIndex, + sampleId, + volume, + pan, + egTime, + fxSend, + fxSelect, + note, + gate, + }); + pushPart({ + sampleId, + volume, + pan, + pitch: 0, + fxAmount: 0, + egTime, + fxSend, + fxSelect, + filter, + steps, + }); } else { pushPart(undefined); } } + // v3.313: Pattern-FX (3 Prozessoren @1148, je fxtype/edit1/edit2) + Chain + // (Byte 12). Werte defensiv geklemmt; motionseqstatus (4. Byte) ignoriert. + const fx: EsxFxSlot[] = []; + for (let f = 0; f < ESX1_FX_SLOT_COUNT; f++) { + const fo = ESX1_FXPARAM_OFFSET + f * ESX1_FXPARAM_STRIDE; + fx.push({ + fxType: (raw[fo] ?? 0) & 0x0f, + edit1: Math.min(127, raw[fo + 1] ?? 0), + edit2: Math.min(127, raw[fo + 2] ?? 0), + }); + } + const fxChain = (raw[ESX1_FXCHAIN_OFFSET] ?? 0) & 0x03; + return { index: patternIndex, name, @@ -1144,6 +1308,8 @@ export function parseEsxPattern( muteMask, parts, keyboardParts, + fx, + fxChain, raw, }; } diff --git a/client/src/utils/korg/esxToE2sBank.ts b/client/src/utils/korg/esxToE2sBank.ts index ba6be0fb..b393e897 100644 --- a/client/src/utils/korg/esxToE2sBank.ts +++ b/client/src/utils/korg/esxToE2sBank.ts @@ -16,6 +16,7 @@ * überzähligen weggelassen (Report) — das verhindert den "Import-Fehler". */ +import { esxFxTypeName } from "./esxParser"; import type { EsxBank, EsxPattern, EsxSample } from "./esxParser"; import { buildE2sBank, type E2sSlotInput } from "./e2sBankBuilder"; import { buildE2AllPatFile } from "../e2sExport"; @@ -150,6 +151,10 @@ export function convertEsxToE2sBank( return { volume: part.volume, pan: part.pan, + // v3.312: Amp-EG-Zeit (ESX egtime) mitnehmen — sonst stehen alle + // E2-Parts auf Decay 127 und kurze perkussive Hüllkurven gehen + // verloren (Gerätebefund: Mix klingt anders als auf der ESX). + egTime: part.egTime, sampleId: mapped ? mapped.hwNumber : undefined, // v3.288: Mute-Zustand aus dem ESX-Pattern in den E2-Export übernehmen. muted: part.muted === true, @@ -199,6 +204,97 @@ export function convertEsxToE2sBank( for (const [esxIdx, m] of [...sampleMap.entries()].sort((a, b) => a[1].hwNumber - b[1].hwNumber)) { lines.push(`| ${m.hwNumber} | ${m.name} | ${esxIdx} |`); } + + // v3.313: FX-Zuweisung — die ESX routet Parts insert-artig durch ihre 3 + // Master-FX; auf der E2S muss das pro Part von Hand (IFX) nachgebaut werden. + // Ohne diese Liste klingen die betroffenen Parts trocken/leiser als im + // Original (Geraetebefund 2026-08-01). v3.315: Regler-Labels statt roher + // Edit1/Edit2-Zahlen; EQ als Low/High relativ zur Mitte (2-Band, KEIN Mid). + const CHAIN_LABEL = ["keine Kette", "FX1→FX2", "FX2→FX3", "FX1→FX2→FX3"]; + // Edit1/Edit2-Beschriftungen lt. ESX-1-Manual; Typen ohne gesichertes + // Label (Grain Shifter, Talking Mod) bleiben bei "Edit1/Edit2". + const EDIT_LABELS: Record = { + 0: ["Time", "Level"], + 1: ["Beat", "Depth"], + 2: ["Time", "Depth"], + 3: ["Time", "Depth"], + 5: ["Speed", "Depth"], + 6: ["Speed", "Depth"], + 7: ["Freq", "Balance"], + 9: ["Pitch", "Balance"], + 10: ["Sens", "Attack"], + 11: ["Gain", "Level"], + 12: ["Freq", "Balance"], + 14: ["Cutoff", "Reso"], + 15: ["Cutoff", "Reso"], + }; + const eqBand = (val: number, band: string): string => { + const d = val - 64; // 64 = Reglermitte = neutral + const qual = Math.abs(d) <= 5 ? "≈neutral" : d > 0 ? "Boost" : "Cut"; + return `${band} ${d >= 0 ? "+" : ""}${d} (${qual})`; + }; + const fxSlotDesc = (f: { fxType: number; edit1: number; edit2: number }): string => { + if (f.fxType === 13) { + // EQ: 2-Band — Edit1 = LOW, Edit2 = HIGH (ESX-1-Manual), kein Mid. + return `EQ [${eqBand(f.edit1, "Low")} / ${eqBand(f.edit2, "High")}]`; + } + const lab = EDIT_LABELS[f.fxType]; + const name = esxFxTypeName(f.fxType); + return lab + ? `${name} [${lab[0]} ${f.edit1} / ${lab[1]} ${f.edit2}]` + : `${name} [Edit1 ${f.edit1} / Edit2 ${f.edit2}]`; + }; + const fxSections: string[] = []; + selected.forEach((p, bankIdx) => { + const routed = p.parts.filter( + (pt) => pt.fxSend === true && pt.steps.some((s) => s.active) + ); + if (routed.length === 0) return; + const fxDesc = (p.fx ?? []) + .map((f, i) => `FX${i + 1} = ${fxSlotDesc(f)}`) + .join(" · "); + fxSections.push(`### Pattern ${bankIdx + 1}${p.name ? ` „${p.name}"` : ""}`); + fxSections.push(`Prozessoren: ${fxDesc} · Chain: ${CHAIN_LABEL[p.fxChain ?? 0]}`); + for (const pt of routed) { + const sel = typeof pt.fxSelect === "number" ? pt.fxSelect : 0; + const fxSlot = (p.fx ?? [])[sel]; + const sample = sampleMap.get(pt.sampleId); + const label = sample ? ` (${sample.hwNumber} ${sample.name})` : ""; + fxSections.push( + `- Part ${pt.partIndex + 1}${label} → FX${sel + 1}: ` + + (fxSlot ? fxSlotDesc(fxSlot) : `FX${sel + 1}`) + ); + } + fxSections.push(""); + }); + if (fxSections.length > 0) { + lines.push(""); + lines.push("## FX-Zuweisung (am Gerät nachbauen)"); + lines.push(""); + lines.push( + "Die ESX-1 schickt diese Parts DURCH ihre Master-FX (insert-artig — das" + ); + lines.push( + "macht sie lauter/dichter). Die E2S bekommt keine automatische" + ); + lines.push( + "FX-Zuweisung: pro Part am Gerät ein passendes IFX wählen und AN schalten," + ); + lines.push("sonst klingen genau diese Parts trockener/leiser als im Original."); + lines.push(""); + lines.push( + "**EQ-Lesehilfe:** Der ESX-EQ ist ein 2-Band-EQ — NUR Low und High, kein" + ); + lines.push( + "Mid. Werte relativ zur Reglermitte (±0 = neutral); am E2S das IFX" + ); + lines.push( + "„2band EQ“ verwenden (gleiche Logik). Andere FX: [Reglername Wert] =" + ); + lines.push("ESX-Drehregler Edit1/Edit2 (0–127)."); + lines.push(""); + lines.push(...fxSections); + } const mapping = lines.join("\n") + "\n"; return { diff --git a/package.json b/package.json index 27aaeca8..af02b034 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "synthstudio", - "version": "3.311.0", + "version": "3.316.0", "type": "module", "license": "MIT", "main": "electron-dist/main.cjs", diff --git a/tests/features/e2-export-guide.test.ts b/tests/features/e2-export-guide.test.ts new file mode 100644 index 00000000..f6ebe904 --- /dev/null +++ b/tests/features/e2-export-guide.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { buildE2ExportGuide } from "../../client/src/utils/e2ExportGuide"; +import { DEFAULT_CHANNEL_FX } from "../../client/src/audio/AudioEngine"; +import type { PatternData, PartData, StepData } from "../../client/src/audio/AudioEngine"; + +// v3.314: Der Sequenzer→E2-Export bekommt eine Zuweisungsdatei — Sample- +// Zuordnung + Channel-FX stehen nicht im .e2spat-Format und müssen am +// Gerät nachgebaut werden. + +function step(active: boolean): StepData { + return { active, velocity: 100 } as StepData; +} + +function part(over: Partial): PartData { + return { + id: "p", + name: "Part", + muted: false, + soloed: false, + volume: 1, + pan: 0, + steps: [step(true)], + fx: { ...DEFAULT_CHANNEL_FX }, + ...over, + } as PartData; +} + +function pattern(parts: PartData[], name = "Test"): PatternData { + return { + id: "pat", + name, + stepCount: 16, + stepResolution: "16", + bpm: 128, + parts, + } as unknown as PatternData; +} + +describe("buildE2ExportGuide", () => { + it("listet aktive Parts mit Sample, E2-Level/Pan und aktivierten FX", () => { + const p1 = part({ + name: "Kick", + sampleName: "BiG_KiCkX.wav", + volume: 0.5, + pan: -1, + fx: { + ...DEFAULT_CHANNEL_FX, + compressorEnabled: true, + compressorThreshold: -20, + compressorRatio: 4, + reverbEnabled: true, + reverbDecay: 2.5, + reverbMix: 0.3, + }, + }); + const silent = part({ name: "Leer", steps: [step(false)] }); + const md = buildE2ExportGuide([pattern([p1, silent], "Banger")], { + title: "test.e2spat", + }); + + expect(md).toContain("test.e2spat"); + expect(md).toContain("Banger"); + expect(md).toContain("BiG_KiCkX.wav"); + expect(md).toContain("| 1 |"); // Part 1 + expect(md).toContain("| 64 |"); // volume 0.5 → E2 64 + expect(md).toContain("| 1 |"); // pan -1 → E2 1 + expect(md).toContain("IFX Compressor"); + expect(md).toContain("MFX Hall/Plate"); + // Part ohne aktive Steps taucht nicht auf + expect(md).not.toContain("Leer"); + }); + + it("Synth-Parts ohne Sample werden als Synth-Quelle gelabelt", () => { + const p = part({ name: "Lead", sourceType: "wavetable", sampleName: undefined }); + const md = buildE2ExportGuide([pattern([p])]); + expect(md).toContain("[wavetable-Synth]"); + }); + + it("leerer Export → Hinweis statt leerer Tabellen", () => { + const md = buildE2ExportGuide([pattern([part({ steps: [step(false)] })])]); + expect(md).toContain("Keine Parts mit aktiven Steps"); + }); +}); diff --git a/tests/features/e2s-bank-importnum-msb.test.ts b/tests/features/e2s-bank-importnum-msb.test.ts new file mode 100644 index 00000000..3d6c8fbd --- /dev/null +++ b/tests/features/e2s-bank-importnum-msb.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { buildE2sBank } from "../../client/src/utils/korg/e2sBankBuilder"; + +// v3.316: Regression — chunk[body+0x1D] = 0x02 (vermeintliches Fix-Byte) +// überschrieb das MSB von importNum (u16 @0x1C = sampleNumber+50). In den +// Referenz-Banken lagen alle importNums in 0x200..0x2FF, daher fiel es nie +// auf; ab sampleNumber 718 (importNum ≥ 0x300) verlor jedes Sample 256. + +function importNumOf(sampleNumber: number): number { + const res = buildE2sBank([ + { + slotIndex: 0, + sampleNumber, + name: "T", + category: 17, + pcmData: new Float32Array(100), + sampleRate: 44100, + channels: 1, + }, + ]); + const b = Buffer.from(res.buffer); + const p = b.readUInt32LE(0x10); + const rlen = b.readUInt32LE(p + 4) + 8; + return b.readUInt16LE(p + rlen - 0x494 + 0x14); +} + +describe("e2sBankBuilder importNum MSB", () => { + it("importNum = sampleNumber+50 auch jenseits von 0x2FF", () => { + expect(importNumOf(500)).toBe(550); // Bestand (MSB 0x02) + expect(importNumOf(717)).toBe(767); // letzter Wert vor der Grenze + expect(importNumOf(718)).toBe(768); // vorher: 512 (MSB zerstoert) + expect(importNumOf(949)).toBe(999); // hohe User-Slots + }); +}); diff --git a/tests/features/esx-egtime-transfer.test.ts b/tests/features/esx-egtime-transfer.test.ts new file mode 100644 index 00000000..b81384be --- /dev/null +++ b/tests/features/esx-egtime-transfer.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { buildE2PatternBody } from "../../client/src/utils/e2sExport"; +import { parseEsxBank } from "../../client/src/utils/korg/esxParser"; +import { convertEsxToE2sBank } from "../../client/src/utils/korg/esxToE2sBank"; + +// v3.312: ESX-Part-egtime (Amp-EG-Zeit) muss im E2-Export landen (@ Part+0x15). +// Vorher blieben alle Parts auf dem Template-Wert 127 — kurze perkussive +// ESX-Hüllkurven gingen verloren und der gehörte Mix verschob sich +// (Gerätebefund 2026-08-01, "lukn kicks"-Bank). + +const PARTS_OFF = 0x800; +const PART_STRIDE = 0x330; +const EG_OFF = 0x15; + +describe("egTime → E2 EG-Decay-Byte (+0x15)", () => { + it("schreibt egTime, wenn gesetzt — und lässt Template (127) sonst stehen", () => { + const body = buildE2PatternBody({ + name: "EGT", + bpm: 120, + stepLength: 16, + parts: [ + { egTime: 13, steps: [{ active: true }] }, + { steps: [{ active: true }] }, // kein egTime → Template + { egTime: 0, steps: [] }, // 0 ist gültig (kürzeste Hüllkurve) + { egTime: 999, steps: [] }, // wird auf 0..127 geklemmt → 127 + ], + }); + expect(body[PARTS_OFF + 0 * PART_STRIDE + EG_OFF]).toBe(13); + expect(body[PARTS_OFF + 1 * PART_STRIDE + EG_OFF]).toBe(127); + expect(body[PARTS_OFF + 2 * PART_STRIDE + EG_OFF]).toBe(0); + expect(body[PARTS_OFF + 3 * PART_STRIDE + EG_OFF]).toBe(127); + }); +}); + +// Integration gegen echte ESX-Datei (Repo-lokal; skip, wenn nicht vorhanden). +const ESX_PATH = path.join(__dirname, "..", "..", "Korg ESX files", "lukn kicks.esx"); +(fs.existsSync(ESX_PATH) ? describe : describe.skip)( + "ESX→E2S überträgt egtime (lukn kicks)", + () => { + it("mindestens ein aktiver Part bekommt EG < 127, Werte matchen die Quelle", () => { + const esx = parseEsxBank( + new Uint8Array(fs.readFileSync(ESX_PATH)), + "lukn kicks" + ); + // Quelle: es gibt Parts mit kurzer Hüllkurve (sonst testet das nichts) + const shortSrc = esx.patterns + .flatMap((p) => p.parts) + .filter( + (pt) => + pt.steps.some((s) => s.active) && + typeof pt.egTime === "number" && + pt.egTime < 127 + ); + expect(shortSrc.length).toBeGreaterThan(0); + + const r = convertEsxToE2sBank(esx); + const allpat = r.allpat; + const BPAT = 0x10100, + BPLEN = 0x4000, + BPART = 0x800, + BPARTLEN = 0x330; + // Erwartung pro Bank-Pattern i = i-tes selektiertes ESX-Pattern: + // EG-Byte des Parts == ESX-egtime (geklemmt 0..127). + const selected = esx.patterns.filter( + (p) => + (p.name && p.name.trim().length > 0) || + p.parts.some((pt) => pt.steps.some((s) => s.active)) + ); + let checked = 0, + short = 0; + for (let i = 0; i < Math.min(selected.length, 250); i++) { + for (const pt of selected[i].parts) { + if (typeof pt.egTime !== "number") continue; + const off = + BPAT + i * BPLEN + BPART + pt.partIndex * BPARTLEN + EG_OFF; + expect(allpat[off]).toBe(Math.max(0, Math.min(127, pt.egTime))); + checked++; + if (pt.egTime < 127) short++; + } + } + expect(checked).toBeGreaterThan(0); + expect(short).toBeGreaterThan(0); + }); + } +); diff --git a/tests/features/esx-fx-assignment.test.ts b/tests/features/esx-fx-assignment.test.ts new file mode 100644 index 00000000..a244577f --- /dev/null +++ b/tests/features/esx-fx-assignment.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + parseEsxPattern, + parseEsxBank, + esxFxTypeName, + ESX_FX_TYPE_NAMES, +} from "../../client/src/utils/korg/esxParser"; +import { convertEsxToE2sBank } from "../../client/src/utils/korg/esxToE2sBank"; + +// v3.313: Die ESX routet Parts insert-artig durch ihre 3 Master-FX (fxflags +// bit 2 = Send, bits 0-1 = FX-Auswahl; FXParam-Block @ Pattern+1148). Beim +// Transfer geht das Routing verloren → die Anleitung (mapping.md) bekommt +// eine FX-Zuweisungs-Sektion zum Nachbauen am Gerät. + +describe("esxParser FX-Dekodierung (synthetisch)", () => { + it("dekodiert fxflags (Send/Select) und die 3 FXParam-Slots + Chain", () => { + const raw = new Uint8Array(4280); + // Drum-Part 0 @24: Trigger-Bit für Step 0 (Bitmaske MSB-first @+18) + raw[24 + 18] = 0x80; + // fxflags @+13: 0b101 = Send AN, Select FX2 (bits0-1 = 1) + raw[24 + 13] = 0b101; + // FXParam @1148: FX1 = Compressor(10) 62/38, FX2 = EQ(13) 99/67, + // FX3 = Short Delay(2) 127/0 + raw.set([10, 62, 38, 0, 13, 99, 67, 0, 2, 127, 0, 0], 1148); + // FX-Chain @12: 3 = FX1→FX2→FX3 + raw[12] = 3; + + const p = parseEsxPattern(raw, 0); + expect(p).not.toBeNull(); + expect(p!.parts[0].fxSend).toBe(true); + expect(p!.parts[0].fxSelect).toBe(1); + expect(p!.fx).toEqual([ + { fxType: 10, edit1: 62, edit2: 38 }, + { fxType: 13, edit1: 99, edit2: 67 }, + { fxType: 2, edit1: 127, edit2: 0 }, + ]); + expect(p!.fxChain).toBe(3); + expect(esxFxTypeName(10)).toBe("Compressor"); + expect(ESX_FX_TYPE_NAMES).toHaveLength(16); + }); +}); + +const ESX_PATH = path.join(__dirname, "..", "..", "Korg ESX files", "lukn kicks.esx"); +(fs.existsSync(ESX_PATH) ? describe : describe.skip)( + "FX-Zuweisung in der Import-Anleitung (lukn kicks)", + () => { + it("Pattern 1 dekodiert EQ/Compressor/ShortDelay; mapping.md listet Routing", () => { + const esx = parseEsxBank( + new Uint8Array(fs.readFileSync(ESX_PATH)), + "lukn kicks" + ); + // Empirisch am Hexdump verifiziert (2026-08-01): Pattern-Slot 0 trägt + // FX1=EQ(99/67), FX2=Compressor(62/38), FX3=Short Delay(127/0). + const p0 = esx.patterns.find((p) => p.index === 0); + expect(p0).toBeDefined(); + expect(p0!.fx).toEqual([ + { fxType: 13, edit1: 99, edit2: 67 }, + { fxType: 10, edit1: 62, edit2: 38 }, + { fxType: 2, edit1: 127, edit2: 0 }, + ]); + // Es gibt Parts mit aktivem FX-Send (87 lt. Analyse; hier nur >0 prüfen) + const routed = esx.patterns + .flatMap((p) => p.parts) + .filter((pt) => pt.fxSend && pt.steps.some((s) => s.active)); + expect(routed.length).toBeGreaterThan(0); + + const r = convertEsxToE2sBank(esx); + expect(r.mapping).toContain("## FX-Zuweisung (am Gerät nachbauen)"); + expect(r.mapping).toContain("Compressor [Sens "); + // v3.315: EQ als Low/High relativ zur Mitte (Pattern 1: 99/67 → +35/+3) + expect(r.mapping).toContain("EQ [Low +35 (Boost) / High +3 (≈neutral)]"); + expect(r.mapping).toContain("EQ-Lesehilfe"); + expect(r.mapping).toMatch(/- Part \d+.* → FX[123]: /); + }); + } +);