diff --git a/package-lock.json b/package-lock.json
index 1cd8219..41da9e1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"@elemaudio/core": "^4.0.1",
"@elemaudio/web-renderer": "^4.0.3",
"@modelcontextprotocol/sdk": "^1.29.0",
+ "@soundtouchjs/audio-worklet": "^2.1.0",
"@tailwindcss/vite": "^4.1.11",
"@tonejs/midi": "^2.0.28",
"@types/cors": "^2.8.19",
@@ -4470,6 +4471,41 @@
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
+ "node_modules/@soundtouchjs/audio-worklet": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@soundtouchjs/audio-worklet/-/audio-worklet-2.1.0.tgz",
+ "integrity": "sha512-+LKvyvUx4ErwdD6jiUTHYqm5K7LMr+wGPz9PJufOORdDHL283RL2EsPHmdrtsCaDyapZIX2Xf7NljQVnx9lDwQ==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "@soundtouchjs/core": "2.1.0",
+ "@soundtouchjs/interpolation-strategy-lanczos": "2.1.0",
+ "@soundtouchjs/worklet-base": "2.1.0"
+ }
+ },
+ "node_modules/@soundtouchjs/core": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@soundtouchjs/core/-/core-2.1.0.tgz",
+ "integrity": "sha512-LXZ1HPXnyu0iPw+qlxcb48zuIQfP+DZAyRGG2w2/nfdKsJVft3122J6wHO/uVhZwUOjrgjYPrwu6DBkd66KMtA==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "@soundtouchjs/interpolation-strategy-lanczos": "2.1.0"
+ }
+ },
+ "node_modules/@soundtouchjs/interpolation-strategy-lanczos": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@soundtouchjs/interpolation-strategy-lanczos/-/interpolation-strategy-lanczos-2.1.0.tgz",
+ "integrity": "sha512-Nf/JO03CEVoMcLCo2tQdXg7zC5PkEtcudgonYRjmkFWASj7bc/EX48Qy70L9kt1bfol54x1Fg/xU2os32b6Z2g==",
+ "license": "MPL-2.0"
+ },
+ "node_modules/@soundtouchjs/worklet-base": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@soundtouchjs/worklet-base/-/worklet-base-2.1.0.tgz",
+ "integrity": "sha512-450GQpS1ehZ6iJep/udiUbXFvOtDDoX92Nk4xPp2YzMrkH3uz4tc2TSaZj4qmNy+mgjaIP17vB4pzzVzR3BZoQ==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "@soundtouchjs/core": "2.1.0"
+ }
+ },
"node_modules/@sqltools/formatter": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz",
diff --git a/package.json b/package.json
index 5ed09ef..ebb0896 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"@elemaudio/core": "^4.0.1",
"@elemaudio/web-renderer": "^4.0.3",
"@modelcontextprotocol/sdk": "^1.29.0",
+ "@soundtouchjs/audio-worklet": "^2.1.0",
"@tailwindcss/vite": "^4.1.11",
"@tonejs/midi": "^2.0.28",
"@types/cors": "^2.8.19",
diff --git a/webapp/src/components/ad/ArturiaCard.vue b/webapp/src/components/ad/ArturiaCard.vue
new file mode 100644
index 0000000..b59b6af
--- /dev/null
+++ b/webapp/src/components/ad/ArturiaCard.vue
@@ -0,0 +1,213 @@
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+ {{ props.brand }}
+
+
+
{{ props.title }}
+
{{ props.description }}
+
+
+
+ {{ props.ctaLabel }}
+
+
+
+
+
+
diff --git a/webapp/src/components/ad/AudiofanzineCard.vue b/webapp/src/components/ad/AudiofanzineCard.vue
new file mode 100644
index 0000000..fb35e0b
--- /dev/null
+++ b/webapp/src/components/ad/AudiofanzineCard.vue
@@ -0,0 +1,242 @@
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
{{ props.brand }}
+
+
{{ props.tagline }}
+
+
{{ props.title }}
+
{{ props.description }}
+
+
+ {{ props.ctaLabel }}
+
+
+
+
+
+
+
diff --git a/webapp/src/components/ad/VpnBanner.vue b/webapp/src/components/ad/VpnBanner.vue
new file mode 100644
index 0000000..e0b9016
--- /dev/null
+++ b/webapp/src/components/ad/VpnBanner.vue
@@ -0,0 +1,265 @@
+
+
+
+
+
+
+
diff --git a/webapp/src/components/app/CLAUDE.md b/webapp/src/components/app/CLAUDE.md
index 9e2ed33..0a70d7e 100644
--- a/webapp/src/components/app/CLAUDE.md
+++ b/webapp/src/components/app/CLAUDE.md
@@ -74,19 +74,20 @@ setAutomationPoints(trackId, laneId, points): boolean
### Types clés (`lib/utils/types.ts`)
```typescript
-InstrumentType = "basicSynth" | "elementarySynth" | "smplr" | "undertale" | "audioTrack" | "samplePlayer"
+InstrumentType = "basicSynth" | "elementarySynth" | "smplr" | "undertale" | "fmSynth" | "audioTrack" | "samplePlayer"
// Discriminated unions pour type safety
BasicSynthConfig { type: "basicSynth", oscillatorType: OscillatorType, gain? }
SmplrConfig { type: "smplr", soundfont: string, gain?, attack?, decay?, sustain?, release? }
ElementarySynthConfig { type: "elementarySynth", preset?, gain? }
UndertaleConfig { type: "undertale", instrument: string, gain?, attack?, decay?, sustain?, release? }
+FmSynthConfig { type: "fmSynth", patch: Dx7Patch, gain? } // synthèse FM style DX7, voir plus bas
AudioTrackConfig { type: "audioTrack", gain? }
SamplePlayerConfig { type: "samplePlayer", sampleId: string | null, rootNote: NoteName, mode: "normal" | "stretch", gain?, attack?, decay?, sustain?, release? }
-InstrumentConfig = BasicSynthConfig | SmplrConfig | ElementarySynthConfig | UndertaleConfig | AudioTrackConfig | SamplePlayerConfig
+InstrumentConfig = BasicSynthConfig | SmplrConfig | ElementarySynthConfig | UndertaleConfig | FmSynthConfig | AudioTrackConfig | SamplePlayerConfig
// Pour les updates partiels (sans discriminant)
-InstrumentConfigUpdate { oscillatorType?, soundfont?, preset?, gain?, sampleId?, rootNote?, mode? }
+InstrumentConfigUpdate { oscillatorType?, soundfont?, preset?, gain?, patch?, sampleId?, rootNote?, mode? }
EQBand {
id: string // "sub", "bass", "mid", "presence", "brilliance"
@@ -192,6 +193,14 @@ engines/
undertale/
UndertaleEngine.ts # Soundfont custom Undertale, ADSR via VoicePool
+ fm-synth/
+ FmSynthEngine.ts # Synthèse FM (6 opérateurs, style DX7) via AudioWorklet
+ fm-synth-processor.ts # AudioWorkletProcessor : rendu échantillon par échantillon
+ protocol.ts # Messages thread principal <-> worklet
+ dsp/ # Port du moteur DX7 de mmontag/dx7-synth-js (MIT) — pure computation, sans dépendance UI/AudioContext
+ presets/rom1a.json # 32 presets d'usine (bank ROM1A du DX7 original)
+ index.ts # Re-exports (FmSynthEngine, FM_SYNTH_PRESETS, ALGORITHMS)
+
sample-player/
SamplePlayerEngine.ts # Joue un AudioSample par note (playbackRate natif en
# mode "normal", Tone.GrainPlayer en mode "stretch")
@@ -218,6 +227,13 @@ via `engine.setBuffer(buffer)`, hors de l'interface `InstrumentEngine`.
Factory : `lib/audio/instrumentFactory.ts` - Crée les instances d'engines selon le type.
+`fmSynth` est le seul engine avec une interface dédiée plutôt que des paramètres
+inline dans `InstrumentSettings.vue` — voir `hasCustomInterface` /
+`INSTRUMENT_INTERFACE_COMPONENTS` (composant : `Dx7InstrumentPanel.vue`), qui
+ouvre l'interface réelle de l'instrument dans une modale (`BaseModal`) au lieu
+d'adapter ses contrôles aux sliders génériques du drawer. Pattern réutilisable
+pour tout futur instrument avec sa propre UI.
+
### Bibliothèque de samples (Audiothèque)
Système de gestion des samples audio stockés sur Cloudflare R2.
@@ -360,7 +376,7 @@ Voir [Système d'effets](#système-deffets-libaudioeffects) plus haut. `EffectRa
## Flow utilisateur
-1. **Ajouter une piste** : Bouton "+" → Menu instruments (BasicSynth, Smplr, Undertale, AudioTrack, Sample Player)
+1. **Ajouter une piste** : Bouton "+" → Menu instruments (BasicSynth, Smplr, Undertale, FM Synth, AudioTrack, Sample Player)
2. **Éditer les notes** : Double-clic sur la timeline d'une piste → Piano roll s'expand en dessous
3. **Ajouter une note** : Clic simple sur la grille du piano roll
4. **Supprimer une note** : Clic droit sur la note
diff --git a/webapp/src/components/app/ProjectSelector.vue b/webapp/src/components/app/ProjectSelector.vue
index 835b478..d730af2 100644
--- a/webapp/src/components/app/ProjectSelector.vue
+++ b/webapp/src/components/app/ProjectSelector.vue
@@ -12,6 +12,7 @@ import type {
TrashedProjectListItem,
} from "../../lib/utils/types";
import TabBar, { type TabItem } from "../shared/TabBar.vue";
+import VpnBanner from "../ad/VpnBanner.vue";
type Tab = "mine" | "favorites" | "discover" | "trash";
@@ -602,6 +603,9 @@ onMounted(() => loadProjects());
/>
+
+
+
diff --git a/webapp/src/components/app/instruments/Dx7InstrumentPanel.vue b/webapp/src/components/app/instruments/Dx7InstrumentPanel.vue
new file mode 100644
index 0000000..42ae070
--- /dev/null
+++ b/webapp/src/components/app/instruments/Dx7InstrumentPanel.vue
@@ -0,0 +1,589 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ updatePatchField({ feedback: value })"
+ />
+
+
+
+
+
+ {{ opIdx + 1 }}
+
+
+
+
+ → {{ modulationTargets[opIdx].map((t) => t + 1).join(", ") }}
+
+
+
+
+
+
+
+
+
+
+
+ updatePatchField({ lfoSpeed: value })"
+ />
+
+
+
+ updatePatchField({ lfoDelay: value })"
+ />
+
+
+
+ updatePatchField({ lfoPitchModDepth: value })
+ "
+ />
+
+
+
+ updatePatchField({ lfoAmpModDepth: value })
+ "
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ updateOperator(activeOperatorIndex, { volume: value })
+ "
+ />
+
+
+
+ updateOperator(activeOperatorIndex, { detune: value })
+ "
+ />
+
+
+
+
+
+
+
+ updateOperator(activeOperatorIndex, { freqCoarse: value })
+ "
+ />
+
+
+
+
+ updateOperator(activeOperatorIndex, { freqFine: value })
+ "
+ />
+
+
+
+ updateOperator(activeOperatorIndex, { pan: value })
+ "
+ />
+
+
+
+
+
+
+
+ updateOperator(activeOperatorIndex, { velocitySens: value })
+ "
+ />
+
+
+
+
+ updateOperator(activeOperatorIndex, { lfoAmpModSens: value })
+ "
+ />
+
+
+
+
+
+
+
+ {{ stage + 1 }}
+
+ updateOperatorStage(
+ activeOperatorIndex,
+ 'rates',
+ stage,
+ value,
+ )
+ "
+ />
+
+ updateOperatorStage(
+ activeOperatorIndex,
+ 'levels',
+ stage,
+ value,
+ )
+ "
+ />
+
+
+
+
+
+
+
+
diff --git a/webapp/src/components/app/instruments/InstrumentSettings.vue b/webapp/src/components/app/instruments/InstrumentSettings.vue
index a86e61f..5be9933 100644
--- a/webapp/src/components/app/instruments/InstrumentSettings.vue
+++ b/webapp/src/components/app/instruments/InstrumentSettings.vue
@@ -1,5 +1,5 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webapp/src/components/app/timeline/TimelineView.vue b/webapp/src/components/app/timeline/TimelineView.vue
index 6d4122e..f3b694b 100644
--- a/webapp/src/components/app/timeline/TimelineView.vue
+++ b/webapp/src/components/app/timeline/TimelineView.vue
@@ -27,6 +27,7 @@ import { useTimelineMidiRecording } from "../../../composables/timelineView/useT
import { useTimelineFileDrop } from "../../../composables/timelineView/useTimelineFileDrop";
import { useMidiImport } from "../../../composables/timelineView/useMidiImport";
import { useTimelineProjectMeta } from "../../../composables/timelineView/useTimelineProjectMeta";
+import { useStretchRecompute } from "../../../composables/timelineView/useStretchRecompute";
import { useDropdown } from "../../../composables/useDropdown";
import {
pxPerTick,
@@ -48,6 +49,7 @@ import EmptyState from "../../ui/EmptyState.vue";
import FormField from "../../ui/FormField.vue";
import BaseInput from "../../ui/BaseInput.vue";
import { useToast } from "../../../composables/useToast";
+import VpnBanner from "../../ad/VpnBanner.vue";
const emit = defineEmits<{
(
@@ -74,7 +76,7 @@ const timelineStore = useTimelineStore();
const projectStore = useProjectStore();
const toast = useToast();
-const { isReadOnly, currentProjectOwner } = storeToRefs(projectStore);
+const { isReadOnly, isPublic, currentProjectOwner } = storeToRefs(projectStore);
// Densité visuelle inchangée par rapport à l'ancien COL_WIDTH=20px/colonne
// (colonne = 1/16 de temps) : 20 * 4 = 80px par temps, avant application du zoom.
@@ -357,8 +359,17 @@ const {
handleBackToProjects,
handleResetReadOnly,
handleCloneProject,
+ showShareMenu,
+ closeShareMenu,
+ toggleShareMenu,
+ isTogglingVisibility,
+ setVisibility,
+ copyShareLink,
+ shareToBlog,
} = useTimelineProjectMeta();
+useStretchRecompute();
+
const handleAddTrack = (type: InstrumentType) => {
const config = getDefaultConfigForType(type);
timelineStore.createTrack(config);
@@ -652,13 +663,6 @@ defineExpose({
@@ -1183,6 +1265,8 @@ defineExpose({
+
+
diff --git a/webapp/src/components/ui/BaseInput.vue b/webapp/src/components/ui/BaseInput.vue
index 36c0ee8..df83c1c 100644
--- a/webapp/src/components/ui/BaseInput.vue
+++ b/webapp/src/components/ui/BaseInput.vue
@@ -54,4 +54,10 @@ const model = defineModel();
opacity: 0.5;
cursor: not-allowed;
}
+
+@media (max-width: 768px) {
+ .base-input {
+ font-size: 16px;
+ }
+}
diff --git a/webapp/src/components/ui/BaseSelect.vue b/webapp/src/components/ui/BaseSelect.vue
index f8d3e32..3b5edb4 100644
--- a/webapp/src/components/ui/BaseSelect.vue
+++ b/webapp/src/components/ui/BaseSelect.vue
@@ -44,4 +44,10 @@ const model = defineModel();
opacity: 0.5;
cursor: not-allowed;
}
+
+@media (max-width: 768px) {
+ .base-select {
+ font-size: 16px;
+ }
+}
diff --git a/webapp/src/composables/audioClip/index.ts b/webapp/src/composables/audioClip/index.ts
index 64fab60..9e92816 100644
--- a/webapp/src/composables/audioClip/index.ts
+++ b/webapp/src/composables/audioClip/index.ts
@@ -4,3 +4,4 @@ export { useAudioClipKeyboard } from "./useAudioClipKeyboard";
export { useAudioClipDrag } from "./useAudioClipDrag";
export { useAudioClipResize } from "./useAudioClipResize";
export { useAudioClipCanvas } from "./useAudioClipCanvas";
+export { useSampleClipPreview } from "./useSampleClipPreview";
diff --git a/webapp/src/composables/audioClip/useAudioClipCanvas.ts b/webapp/src/composables/audioClip/useAudioClipCanvas.ts
index 32acc37..4dfb598 100644
--- a/webapp/src/composables/audioClip/useAudioClipCanvas.ts
+++ b/webapp/src/composables/audioClip/useAudioClipCanvas.ts
@@ -1,7 +1,16 @@
import { ref, watch, type Ref } from "vue";
-import type { AudioClip, TimeSignature } from "../../lib/utils/types";
-import { getVisibleTickRange, ticksPerSecond } from "../../lib/audio/timeGrid";
+import type {
+ AudioClip,
+ ClipResizeMode,
+ TimeSignature,
+} from "../../lib/utils/types";
+import { getVisibleTickRange } from "../../lib/audio/timeGrid";
+import {
+ computeClipPlaybackParams,
+ applyResizeStretch,
+} from "../../lib/audio/clipStretch";
import { useAudioLibraryStore } from "../../stores/audioLibraryStore";
+import { useStretchRenderCacheStore } from "../../stores/stretchRenderCacheStore";
import {
AudioClipRenderer,
type ClipRenderData,
@@ -20,6 +29,7 @@ interface UseAudioClipCanvasConfig {
clips: () => AudioClip[];
trackColor: () => string;
tempo: () => number;
+ clipResizeMode: () => ClipResizeMode;
selectedClipIds: Ref>;
dragState: Ref;
dragPreviewDeltaTicks: Ref;
@@ -37,6 +47,7 @@ export function useAudioClipCanvas(
config: UseAudioClipCanvasConfig,
) {
const audioLibraryStore = useAudioLibraryStore();
+ const stretchRenderCacheStore = useStretchRenderCacheStore();
const renderer = ref(null);
// Taille du wrapper DOM, mise à jour uniquement dans le même chemin
// throttlé (rAF) que le resize du canvas — voir usePianoGridCanvas.ts pour
@@ -68,7 +79,12 @@ export function useAudioClipCanvas(
let previewX: number | undefined;
let previewW: number | undefined;
- let previewStartOffset: number | undefined;
+ // Clip "effectif" pour le calcul de la waveform : identique à `clip` sauf
+ // pendant un resize en cours, où il simule le résultat d'un
+ // applyResizeStretch sur les valeurs d'aperçu — pour que la waveform
+ // reflète immédiatement le stretch pendant le drag, pas seulement une
+ // fois le resize relâché.
+ let waveformClip: AudioClip = clip;
if (isDragging && config.dragPreviewDeltaTicks.value !== null) {
const initial = config.dragState.value!.clipsInitialPos.get(clip.id)!;
@@ -80,6 +96,7 @@ export function useAudioClipCanvas(
clip.id,
)!;
const delta = config.resizePreviewDeltaTicks.value;
+ let previewStartOffset = initial.startOffset;
if (config.resizingState.value!.side === "right") {
previewW = initial.w + delta;
} else {
@@ -87,17 +104,39 @@ export function useAudioClipCanvas(
previewW = initial.w - delta;
previewStartOffset = initial.startOffset + delta;
}
+ waveformClip = {
+ ...clip,
+ ...applyResizeStretch(
+ clip,
+ {
+ x: previewX ?? initial.x,
+ w: previewW,
+ startOffset: previewStartOffset,
+ },
+ config.clipResizeMode(),
+ config.tempo(),
+ ),
+ };
}
+ const waveformParams = computeClipPlaybackParams(
+ waveformClip,
+ config.tempo(),
+ );
+
+ // Un clip en cours de re-rendu (BPM/tune changé, cache pas encore chaud)
+ // affiche le même placeholder qu'une waveform pas encore chargée — le
+ // rendu stretché en cache pourrait être obsolète le temps du recalcul.
+ const isReprocessing = stretchRenderCacheStore.pendingClipIds.has(clip.id);
+
return {
id: clip.id,
x: clip.x,
w: clip.w,
- startOffset: clip.startOffset,
- sampleDurationTicks: sample
- ? Math.ceil(sample.duration * ticksPerSecond(config.tempo()))
- : 0,
- waveformData: sample?.waveformData ?? null,
+ waveformOffsetSeconds: waveformParams.offsetSeconds,
+ waveformDurationSeconds: waveformParams.durationSeconds,
+ sampleDurationSeconds: sample?.duration ?? 0,
+ waveformData: isReprocessing ? null : (sample?.waveformData ?? null),
color: config.trackColor(),
name: sample?.name ?? "Loading...",
isSelected: config.selectedClipIds.value.has(clip.id),
@@ -105,7 +144,6 @@ export function useAudioClipCanvas(
isResizing,
previewX,
previewW,
- previewStartOffset,
};
};
@@ -204,8 +242,12 @@ export function useAudioClipCanvas(
// clip visible, sinon l'arrivée tardive d'une waveform décodée après le
// premier rendu ne déclenche aucun redraw. Regroupé avec les autres sources
// qui ont besoin d'un vrai deep watch (Set/Map mutés en place :
- // selectedClipIds, dragState, resizingState) — ces collections restent
- // petites (quelques clips), le traversal reste bon marché.
+ // selectedClipIds, dragState, resizingState, pendingClipIds) — ces
+ // collections restent petites (quelques clips), le traversal reste bon
+ // marché. `tempo()` est ici aussi : le découpage de la waveform en dépend
+ // (cf. computeClipPlaybackParams, y compris pour un clip non stretché) —
+ // sans lui, un changement de BPM laissait la waveform visuellement figée
+ // sur l'ancienne tempo jusqu'à un redraw déclenché par autre chose.
watch(
[
() => config.clips(),
@@ -213,10 +255,12 @@ export function useAudioClipCanvas(
config
.clips()
.map((c) => audioLibraryStore.samples.get(c.sampleId)?.waveformData),
+ () => config.tempo(),
config.selectedClipIds,
config.dragState,
config.resizingState,
config.selectionRect,
+ () => stretchRenderCacheStore.pendingClipIds,
],
scheduleRender,
{ deep: true },
diff --git a/webapp/src/composables/audioClip/useAudioClipClipboard.ts b/webapp/src/composables/audioClip/useAudioClipClipboard.ts
index daf82cf..8dfaf7f 100644
--- a/webapp/src/composables/audioClip/useAudioClipClipboard.ts
+++ b/webapp/src/composables/audioClip/useAudioClipClipboard.ts
@@ -1,12 +1,18 @@
import { ref, type Ref } from "vue";
import { nextTick } from "vue";
import type { AudioClip } from "../../lib/utils/types";
+import { pickStretchFields } from "../../lib/audio/clipStretch";
export interface ClipboardClip {
dx: number;
sampleId: string;
w: number;
startOffset: number;
+ stretched?: boolean;
+ stretchReferenceTicks?: number;
+ stretchReferenceTempo?: number;
+ semitones?: number;
+ cents?: number;
}
export function useAudioClipClipboard(
@@ -36,6 +42,7 @@ export function useAudioClipClipboard(
sampleId: clip.sampleId,
w: clip.w,
startOffset: clip.startOffset,
+ ...pickStretchFields(clip),
}));
};
@@ -55,6 +62,7 @@ export function useAudioClipClipboard(
x,
w: clipData.w,
startOffset: clipData.startOffset,
+ ...pickStretchFields(clipData),
});
}
@@ -101,6 +109,7 @@ export function useAudioClipClipboard(
x,
w: clip.w,
startOffset: clip.startOffset,
+ ...pickStretchFields(clip),
});
}
diff --git a/webapp/src/composables/audioClip/useSampleClipPreview.ts b/webapp/src/composables/audioClip/useSampleClipPreview.ts
new file mode 100644
index 0000000..56dc180
--- /dev/null
+++ b/webapp/src/composables/audioClip/useSampleClipPreview.ts
@@ -0,0 +1,70 @@
+import { ref, onBeforeUnmount } from "vue";
+import type { AudioClip } from "../../lib/utils/types";
+import { useAudioLibraryStore } from "../../stores/audioLibraryStore";
+import { useAudioBusStore } from "../../stores/audioBusStore";
+import { useTimelineStore } from "../../stores/timelineStore";
+import { computeClipPlaybackParams } from "../../lib/audio/clipStretch";
+import {
+ createStretchVoice,
+ ensureStretchEngineReady,
+ type StretchVoice,
+} from "../../lib/audio/engines/stretchEngine";
+
+// Preview d'un clip dans la modale d'édition : indépendant du scheduler de
+// la timeline (jouable même piste arrêtée), toujours via le moteur de
+// stretch/pitch partagé pour que le tune (vrai pitch-shift) soit audible
+// même sur un clip non stretché — le preview existant de l'audiothèque
+// (audioLibraryStore.startPreview) ne gère ni playbackRate ni detune.
+export function useSampleClipPreview() {
+ const isPreviewing = ref(false);
+ let voice: StretchVoice | null = null;
+
+ const stopPreview = (): void => {
+ if (voice) {
+ try {
+ voice.stop();
+ } catch {
+ // Ignore if already stopped
+ }
+ voice = null;
+ }
+ isPreviewing.value = false;
+ };
+
+ const startPreview = async (clip: AudioClip): Promise => {
+ stopPreview();
+
+ const audioLibraryStore = useAudioLibraryStore();
+ const audioBusStore = useAudioBusStore();
+ const timelineStore = useTimelineStore();
+
+ const buffer = await audioLibraryStore.loadSample(clip.sampleId);
+ if (!buffer) return;
+
+ await audioBusStore.ensureAudioContextResumed();
+ await ensureStretchEngineReady(audioBusStore.audioContext);
+
+ const params = computeClipPlaybackParams(clip, timelineStore.tempo);
+ const stretchVoice = createStretchVoice({
+ context: audioBusStore.audioContext,
+ buffer,
+ playbackRate: params.playbackRate,
+ detuneCents: params.detuneCents,
+ });
+ stretchVoice.connect(audioBusStore.inputBus);
+ stretchVoice.onended = () => {
+ if (voice === stretchVoice) {
+ voice = null;
+ isPreviewing.value = false;
+ }
+ };
+ stretchVoice.start(0, params.offsetSeconds, params.durationSeconds);
+
+ voice = stretchVoice;
+ isPreviewing.value = true;
+ };
+
+ onBeforeUnmount(stopPreview);
+
+ return { isPreviewing, startPreview, stopPreview };
+}
diff --git a/webapp/src/composables/timelineView/useStretchRecompute.ts b/webapp/src/composables/timelineView/useStretchRecompute.ts
new file mode 100644
index 0000000..47b56c8
--- /dev/null
+++ b/webapp/src/composables/timelineView/useStretchRecompute.ts
@@ -0,0 +1,17 @@
+import { watch } from "vue";
+import { useTimelineStore } from "../../stores/timelineStore";
+import { useStretchRenderCacheStore } from "../../stores/stretchRenderCacheStore";
+
+// Relance le pré-calcul du cache de stretch (stretchRenderCacheStore) dès que
+// la tempo se stabilise — le debounce et la boucle de recalcul vivent dans le
+// store lui-même (scheduleRecompute), réutilisés aussi par AudioClipRow.vue
+// juste après un resize.
+export function useStretchRecompute(): void {
+ const timelineStore = useTimelineStore();
+ const stretchRenderCacheStore = useStretchRenderCacheStore();
+
+ watch(
+ () => timelineStore.tempo,
+ () => stretchRenderCacheStore.scheduleRecompute(),
+ );
+}
diff --git a/webapp/src/composables/timelineView/useTimelineProjectMeta.ts b/webapp/src/composables/timelineView/useTimelineProjectMeta.ts
index 6041d1f..9836b66 100644
--- a/webapp/src/composables/timelineView/useTimelineProjectMeta.ts
+++ b/webapp/src/composables/timelineView/useTimelineProjectMeta.ts
@@ -3,14 +3,17 @@ import { useRoute, useRouter } from "vue-router";
import { useProjectStore } from "../../stores/projectStore";
import { useTimelineStore } from "../../stores/timelineStore";
import { useDawLoadingStore } from "../../stores/dawLoadingStore";
+import { useDropdown } from "../useDropdown";
+import { useToast } from "../useToast";
-/** Métadonnées de projet : nom, sauvegarde, clonage, rechargement (route + lecture seule). */
+/** Métadonnées de projet : nom, sauvegarde, clonage, rechargement (route + lecture seule), partage. */
export function useTimelineProjectMeta() {
const router = useRouter();
const route = useRoute();
const projectStore = useProjectStore();
const timelineStore = useTimelineStore();
const dawLoadingStore = useDawLoadingStore();
+ const toast = useToast();
const isCloning = ref(false);
const isSaving = ref(false);
@@ -18,6 +21,13 @@ export function useTimelineProjectMeta() {
null,
);
+ const {
+ isOpen: showShareMenu,
+ close: closeShareMenu,
+ toggle: toggleShareMenu,
+ } = useDropdown();
+ const isTogglingVisibility = ref(false);
+
const isEditingProjectName = ref(false);
const editedProjectName = ref("");
const projectNameInputRef: Ref = ref(null);
@@ -115,6 +125,49 @@ export function useTimelineProjectMeta() {
}
};
+ const getShareLink = () =>
+ `${window.location.origin}/app/sequencer?projectId=${projectStore.currentProjectId}`;
+
+ const setVisibility = async (nextIsPublic: boolean) => {
+ if (
+ !projectStore.currentProjectId ||
+ isTogglingVisibility.value ||
+ nextIsPublic === projectStore.isPublic
+ )
+ return;
+
+ isTogglingVisibility.value = true;
+ const result = await projectStore.updateVisibility(
+ projectStore.currentProjectId,
+ nextIsPublic,
+ );
+ isTogglingVisibility.value = false;
+
+ if (!result.success) {
+ toast.error(result.error || "Erreur lors du changement de visibilité.");
+ }
+ };
+
+ const copyShareLink = async () => {
+ if (!projectStore.currentProjectId) return;
+
+ await navigator.clipboard.writeText(getShareLink());
+ toast.success("Lien copié !");
+ closeShareMenu();
+ };
+
+ const shareToBlog = () => {
+ if (!projectStore.currentProjectId) return;
+
+ closeShareMenu();
+ router.push({
+ name: "app-blog",
+ query: {
+ shareText: `Découvrez ma nouvelle musique : ${getShareLink()}`,
+ },
+ });
+ };
+
return {
isCloning,
isSaving,
@@ -129,5 +182,12 @@ export function useTimelineProjectMeta() {
handleBackToProjects,
handleResetReadOnly,
handleCloneProject,
+ showShareMenu,
+ closeShareMenu,
+ toggleShareMenu,
+ isTogglingVisibility,
+ setVisibility,
+ copyShareLink,
+ shareToBlog,
};
}
diff --git a/webapp/src/lib/audio/clipStretch.ts b/webapp/src/lib/audio/clipStretch.ts
new file mode 100644
index 0000000..a386315
--- /dev/null
+++ b/webapp/src/lib/audio/clipStretch.ts
@@ -0,0 +1,181 @@
+import type { AudioClip, ClipResizeMode } from "../utils/types";
+import { ticksPerSecond } from "./timeGrid";
+
+export type StretchFields = Pick<
+ AudioClip,
+ | "stretched"
+ | "stretchReferenceTicks"
+ | "stretchReferenceTempo"
+ | "semitones"
+ | "cents"
+>;
+
+// Champs qui définissent l'altération d'un clip (stretch + tune) — liste
+// unique à tenir à jour si un futur champ d'édition s'ajoute : copier/coller,
+// dupliquer et couper un clip doivent tous préserver l'altération de la même
+// façon plutôt que de réénumérer ces clés à chaque call site. Paramètre typé
+// en `StretchFields` (pas `AudioClip`) pour accepter aussi bien un clip
+// complet qu'une entrée de presse-papier (ClipboardClip) qui partage ces
+// mêmes 5 champs sans être un AudioClip complet.
+export function pickStretchFields(clip: StretchFields): StretchFields {
+ return {
+ stretched: clip.stretched,
+ stretchReferenceTicks: clip.stretchReferenceTicks,
+ stretchReferenceTempo: clip.stretchReferenceTempo,
+ semitones: clip.semitones,
+ cents: clip.cents,
+ };
+}
+
+export interface ClipPlaybackParams {
+ offsetSeconds: number;
+ durationSeconds: number;
+ playbackRate: number; // 1 si non stretché
+ detuneCents: number; // 0 si non tuné
+ needsStretchEngine: boolean; // true si stretched OU semitones/cents non nul
+}
+
+// Calcule les paramètres de lecture d'un clip à un instant donné (tempo
+// courant), en tenant compte d'un éventuel stretch BPM-synchronisé et/ou
+// tune. Recalculé à chaque déclenchement de lecture (jamais stocké) : un
+// changement de tempo change directement le ratio au prochain trigger, ce
+// qui recale rythmiquement un clip stretché sans action de l'utilisateur.
+//
+// Pour un clip stretché, offset/durée sont l'emplacement NATIF à lire dans
+// le buffer source — calculés à la tempo de RÉFÉRENCE (stretchReferenceTempo,
+// celle du dernier resize en mode stretch), jamais à la tempo courante :
+// currentTempo ne sert qu'à calculer la durée CIBLE (temps réel voulu), via
+// playbackRate. Utiliser la tempo courante pour l'offset natif ferait dériver
+// la portion du buffer lue dès que le tempo change par rapport à la
+// référence.
+export function computeClipPlaybackParams(
+ clip: AudioClip,
+ currentTempo: number,
+ playbackOffsetTicks: number = 0,
+): ClipPlaybackParams {
+ const detuneCents = (clip.semitones ?? 0) * 100 + (clip.cents ?? 0);
+ const currentTickRate = ticksPerSecond(currentTempo);
+
+ let offsetSeconds: number;
+ let durationSeconds: number;
+ let playbackRate = 1;
+
+ if (
+ clip.stretched &&
+ clip.stretchReferenceTicks &&
+ clip.stretchReferenceTempo
+ ) {
+ const referenceTickRate = ticksPerSecond(clip.stretchReferenceTempo);
+ const nativeDurationSeconds =
+ clip.stretchReferenceTicks / referenceTickRate;
+ const targetDurationSeconds = clip.w / currentTickRate;
+ playbackRate = nativeDurationSeconds / targetDurationSeconds;
+
+ // Position/durée natives dans le buffer, proportionnelles à la fraction
+ // du clip déjà écoulée (ex. démarrage en milieu de clip via un scrub) —
+ // la même fraction s'applique dans le domaine natif et dans le domaine
+ // cible puisque le stretch est uniforme sur toute la durée du clip.
+ const fractionElapsed = playbackOffsetTicks / clip.w;
+ offsetSeconds =
+ clip.startOffset / referenceTickRate +
+ fractionElapsed * nativeDurationSeconds;
+ durationSeconds = (1 - fractionElapsed) * nativeDurationSeconds;
+ } else {
+ offsetSeconds = (clip.startOffset + playbackOffsetTicks) / currentTickRate;
+ durationSeconds = (clip.w - playbackOffsetTicks) / currentTickRate;
+ }
+
+ return {
+ offsetSeconds,
+ durationSeconds,
+ playbackRate,
+ detuneCents,
+ needsStretchEngine: playbackRate !== 1 || detuneCents !== 0,
+ };
+}
+
+// Applique la règle de resize selon le mode toolbar actif. Sticky : une fois
+// stretched, un clip le reste pour toujours, peu importe le mode toolbar au
+// moment d'un resize ultérieur — celui-ci ne fait alors que ré-ancrer la
+// référence (comme un warp marker), il ne peut jamais faire revenir un clip
+// à un comportement de simple trim.
+//
+// Le geste de resize (useAudioClipResize.ts) produit x/w/startOffset dans le
+// référentiel BRUT de la grille de ticks de la timeline (tempo-agnostique en
+// tant que COMPTE de ticks, mais implicitement rattaché à la tempo COURANTE
+// dès qu'on le convertit en secondes). Pour un clip déjà stretché,
+// startOffset/stretchReferenceTicks sont eux interprétés à la tempo de
+// RÉFÉRENCE (voir computeClipPlaybackParams) — donc tout delta produit par
+// le drag doit être reconverti vers ce référentiel AVANT d'être appliqué, en
+// utilisant le ratio de stretch (playbackRate) actuel comme facteur de
+// conversion : convertToNativeTicks ci-dessous fait exactement ça, et sert
+// aussi bien au décalage de départ (bord gauche) qu'à la largeur (bord
+// droit), puisque c'est le même facteur de conversion linéaire dans les
+// deux cas.
+export function applyResizeStretch(
+ prevClip: AudioClip,
+ newTicks: { x: number; w: number; startOffset: number },
+ toolbarMode: ClipResizeMode,
+ currentTempo: number,
+): Partial {
+ const shouldStretch = prevClip.stretched || toolbarMode === "stretch";
+ if (!shouldStretch) return newTicks;
+
+ if (
+ !prevClip.stretched ||
+ prevClip.stretchReferenceTicks === undefined ||
+ prevClip.stretchReferenceTempo === undefined
+ ) {
+ // Premier passage en stretch : le clip n'était pas encore stretché,
+ // startOffset est donc déjà dans le même référentiel que la tempo
+ // courante — rien à convertir, ancrage initial simple.
+ return {
+ ...newTicks,
+ stretched: true,
+ stretchReferenceTicks: prevClip.w,
+ stretchReferenceTempo: currentTempo,
+ };
+ }
+
+ const referenceTickRate = ticksPerSecond(prevClip.stretchReferenceTempo);
+ const currentTickRate = ticksPerSecond(currentTempo);
+ const nativeDurationSecondsBefore =
+ prevClip.stretchReferenceTicks / referenceTickRate;
+ const targetDurationSecondsBefore = prevClip.w / currentTickRate;
+ const playbackRateBefore =
+ nativeDurationSecondsBefore / targetDurationSecondsBefore;
+
+ // Facteur de conversion "tick de drag (référentiel timeline courant)" ->
+ // "tick natif (référentiel de référence)", dérivé du ratio de stretch
+ // actuel — voir dérivation dans la conversation/la mémoire du projet.
+ const conversionFactor =
+ playbackRateBefore * (referenceTickRate / currentTickRate);
+ const startOffsetDeltaTicks = newTicks.startOffset - prevClip.startOffset;
+ const startOffset =
+ prevClip.startOffset + startOffsetDeltaTicks * conversionFactor;
+
+ const base = {
+ ...newTicks,
+ startOffset,
+ stretched: true as const,
+ stretchReferenceTempo: prevClip.stretchReferenceTempo,
+ };
+
+ if (toolbarMode === "stretch") {
+ // Mode "étirer" : on ne touche jamais la référence native déjà ancrée
+ // (fix précédent) — la largeur pilote délibérément un NOUVEAU ratio de
+ // stretch, recalculé par computeClipPlaybackParams à partir de cette
+ // référence fixe. Seul le décalage de départ (bord gauche) est reconverti
+ // dans le référentiel natif ici.
+ return { ...base, stretchReferenceTicks: prevClip.stretchReferenceTicks };
+ }
+
+ // Mode "couper" sur un clip déjà stretché : le stretch (playbackRate) doit
+ // rester EXACTEMENT identique — seuls le début et la fin de la fenêtre lue
+ // sont ajustés, comme un trim classique mais sur l'audio déjà étiré (cas
+ // d'usage : caler une loop 110bpm sur un projet à 120bpm, puis ajuster où
+ // elle démarre/finit sans perdre le calage à 120). stretchReferenceTicks
+ // est donc recalculé avec le MÊME conversionFactor pour que le NOUVEAU w
+ // reproduise exactement playbackRateBefore.
+ return { ...base, stretchReferenceTicks: newTicks.w * conversionFactor };
+}
diff --git a/webapp/src/lib/audio/engines/audio-clip/AudioClipEngine.ts b/webapp/src/lib/audio/engines/audio-clip/AudioClipEngine.ts
index 7d6d7ee..7ed7e43 100644
--- a/webapp/src/lib/audio/engines/audio-clip/AudioClipEngine.ts
+++ b/webapp/src/lib/audio/engines/audio-clip/AudioClipEngine.ts
@@ -4,11 +4,15 @@ import type {
NoteName,
} from "../../../utils/types";
import { BaseEngine } from "../BaseEngine";
+import {
+ createStretchVoice,
+ ensureStretchEngineReady,
+ type StretchVoice,
+} from "../stretchEngine";
-interface ActiveSource {
- source: AudioBufferSourceNode;
- gainNode: GainNode;
-}
+type ActiveSource =
+ | { kind: "buffer"; source: AudioBufferSourceNode; gainNode: GainNode }
+ | { kind: "stretch"; voice: StretchVoice; gainNode: GainNode };
export class AudioClipEngine extends BaseEngine {
readonly type = "audioTrack";
@@ -84,7 +88,56 @@ export class AudioClipEngine extends BaseEngine {
}
};
- this.activeSources.set(clipId, { source, gainNode });
+ this.activeSources.set(clipId, { kind: "buffer", source, gainNode });
+ }
+
+ // Clip stretché (BPM-synchronisé) et/ou tuné : passe par le moteur de
+ // stretch/pitch partagé (stretchEngine.ts, SoundTouchJS) au lieu d'un
+ // AudioBufferSourceNode brut, pour découpler pitch et durée. N'affecte
+ // jamais playClip() ni les clips ni stretched ni tunés, qui gardent leur
+ // chemin natif inchangé.
+ async playStretchedClip(
+ clipId: string,
+ buffer: AudioBuffer,
+ offsetSeconds: number,
+ durationSeconds: number,
+ playbackRate: number,
+ detuneCents: number,
+ ): Promise {
+ if (this.activeSources.has(clipId)) {
+ this.stopClip(clipId);
+ }
+
+ await ensureStretchEngineReady(this.audioContext);
+
+ const voice = createStretchVoice({
+ context: this.audioContext,
+ buffer,
+ playbackRate,
+ detuneCents,
+ });
+
+ const gainNode = this.audioContext.createGain();
+ gainNode.gain.setValueAtTime(this.gain, this.audioContext.currentTime);
+
+ voice.connect(gainNode);
+ gainNode.connect(this.destination);
+
+ voice.onended = () => {
+ const active = this.activeSources.get(clipId);
+ if (active?.kind === "stretch" && active.voice === voice) {
+ this.activeSources.delete(clipId);
+ try {
+ gainNode.disconnect();
+ } catch {
+ // Ignore if already disconnected
+ }
+ }
+ };
+
+ voice.start(0, offsetSeconds, durationSeconds);
+
+ this.activeSources.set(clipId, { kind: "stretch", voice, gainNode });
}
stopClip(clipId: string): void {
@@ -97,8 +150,12 @@ export class AudioClipEngine extends BaseEngine {
);
setTimeout(() => {
try {
- active.source.stop();
- active.source.disconnect();
+ if (active.kind === "buffer") {
+ active.source.stop();
+ active.source.disconnect();
+ } else {
+ active.voice.stop();
+ }
active.gainNode.disconnect();
} catch {
// Ignore if already stopped
diff --git a/webapp/src/lib/audio/engines/fm-synth/FmSynthEngine.ts b/webapp/src/lib/audio/engines/fm-synth/FmSynthEngine.ts
new file mode 100644
index 0000000..5a82a87
--- /dev/null
+++ b/webapp/src/lib/audio/engines/fm-synth/FmSynthEngine.ts
@@ -0,0 +1,126 @@
+import type {
+ FmSynthConfig,
+ InstrumentConfigUpdate,
+ NoteName,
+} from "../../../utils/types";
+import { BaseEngine } from "../BaseEngine";
+import { noteNameToMidi } from "../noteUtils";
+import type { FmSynthMessageToProcessor } from "./protocol";
+import fmSynthProcessorUrl from "./fm-synth-processor.ts?worker&url";
+
+const PROCESSOR_NAME = "fm-synth-processor";
+
+export class FmSynthEngine extends BaseEngine {
+ readonly type = "fmSynth";
+ readonly resourceKey = "fm-synth:worklet";
+ readonly resourceLabel = "FM Synth";
+
+ private workletNode: AudioWorkletNode | null = null;
+ private outputNode: GainNode;
+ private loadPromise: Promise | null = null;
+ private patch: FmSynthConfig["patch"];
+
+ constructor(
+ audioContext: AudioContext,
+ destination: AudioNode,
+ config: FmSynthConfig,
+ ) {
+ super(audioContext, destination, config);
+ this.patch = config.patch;
+ this.outputNode = audioContext.createGain();
+ this.outputNode.gain.value = config.gain ?? 1;
+ this.outputNode.connect(destination);
+ }
+
+ async preload(): Promise {
+ if (this._state === "ready") return;
+ if (this._state === "loading" && this.loadPromise) {
+ await this.loadPromise;
+ return;
+ }
+ this.loadPromise = this.setup();
+ await this.loadPromise;
+ }
+
+ private async setup(): Promise {
+ this.setState("loading");
+ try {
+ await this.audioContext.audioWorklet.addModule(fmSynthProcessorUrl);
+
+ const node = new AudioWorkletNode(this.audioContext, PROCESSOR_NAME, {
+ numberOfInputs: 0,
+ numberOfOutputs: 1,
+ outputChannelCount: [2],
+ });
+ node.connect(this.outputNode);
+ this.workletNode = node;
+
+ this.postMessage({ type: "loadPatch", patch: this.patch });
+
+ this.setState("ready");
+ } catch (error) {
+ console.error("[FmSynthEngine] Failed to initialize worklet:", error);
+ this.setState("error");
+ }
+ }
+
+ private postMessage(message: FmSynthMessageToProcessor): void {
+ this.workletNode?.port.postMessage(message);
+ }
+
+ playNote(noteName: NoteName, noteId: string, velocity: number = 100): void {
+ if (this._state !== "ready") {
+ this.preload().then(() => {
+ if (this._state === "ready")
+ this.playNoteInternal(noteName, noteId, velocity);
+ });
+ return;
+ }
+ this.playNoteInternal(noteName, noteId, velocity);
+ }
+
+ private playNoteInternal(
+ noteName: NoteName,
+ noteId: string,
+ velocity: number,
+ ): void {
+ this.postMessage({
+ type: "noteOn",
+ noteId,
+ note: noteNameToMidi(noteName),
+ velocity: velocity / 127,
+ });
+ }
+
+ stopNote(noteId: string): void {
+ this.postMessage({ type: "noteOff", noteId });
+ }
+
+ stopAllNotes(): void {
+ this.postMessage({ type: "panic" });
+ }
+
+ updateConfig(config: InstrumentConfigUpdate): void {
+ if (config.patch) {
+ this.patch = config.patch;
+ this.postMessage({ type: "loadPatch", patch: config.patch });
+ }
+ if (config.gain !== undefined) {
+ this.outputNode.gain.setValueAtTime(
+ config.gain,
+ this.audioContext.currentTime,
+ );
+ }
+ this.config = { ...this.config, ...config } as FmSynthConfig;
+ }
+
+ dispose(): void {
+ this.stopAllNotes();
+ this.workletNode?.disconnect();
+ this.workletNode = null;
+ this.outputNode.disconnect();
+ this.loadPromise = null;
+ this.stateCallbacks.clear();
+ this._state = "idle";
+ }
+}
diff --git a/webapp/src/lib/audio/engines/fm-synth/audioWorkletGlobals.d.ts b/webapp/src/lib/audio/engines/fm-synth/audioWorkletGlobals.d.ts
new file mode 100644
index 0000000..816009c
--- /dev/null
+++ b/webapp/src/lib/audio/engines/fm-synth/audioWorkletGlobals.d.ts
@@ -0,0 +1,25 @@
+// Globals de l'AudioWorkletGlobalScope, absents de la lib DOM de TypeScript.
+// Ce fichier ne concerne que fm-synth-processor.ts (le seul module exécuté
+// dans ce scope).
+export {};
+
+declare global {
+ const sampleRate: number;
+
+ class AudioWorkletProcessor {
+ readonly port: MessagePort;
+ constructor(options?: AudioWorkletNodeOptions);
+ process(
+ inputs: Float32Array[][],
+ outputs: Float32Array[][],
+ parameters: Record,
+ ): boolean;
+ }
+
+ function registerProcessor(
+ name: string,
+ processorCtor: new (
+ options?: AudioWorkletNodeOptions,
+ ) => AudioWorkletProcessor,
+ ): void;
+}
diff --git a/webapp/src/lib/audio/engines/fm-synth/dsp/config.ts b/webapp/src/lib/audio/engines/fm-synth/dsp/config.ts
new file mode 100644
index 0000000..7f2c77c
--- /dev/null
+++ b/webapp/src/lib/audio/engines/fm-synth/dsp/config.ts
@@ -0,0 +1,4 @@
+// Seedé depuis le `sampleRate` global de l'AudioWorkletGlobalScope au démarrage du processor.
+export const dspConfig = {
+ sampleRate: 44100,
+};
diff --git a/webapp/src/lib/audio/engines/fm-synth/dsp/envelopeDx7.ts b/webapp/src/lib/audio/engines/fm-synth/dsp/envelopeDx7.ts
new file mode 100644
index 0000000..788da7d
--- /dev/null
+++ b/webapp/src/lib/audio/engines/fm-synth/dsp/envelopeDx7.ts
@@ -0,0 +1,83 @@
+// Port de envelope-dx7.js (dx7-synth-js, MIT, Matt Montag)
+// Basé sur http://wiki.music-synthesizer-for-android.googlecode.com/git/img/env.html
+const ENV_OFF = 4;
+
+const outputlevel = [
+ 0, 5, 9, 13, 17, 20, 23, 25, 27, 29, 31, 33, 35, 37, 39, 41, 42, 43, 45, 46,
+ 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66,
+ 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85,
+ 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103,
+ 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118,
+ 119, 120, 121, 122, 123, 124, 125, 126, 127,
+];
+
+const outputLUT: number[] = [];
+for (let i = 0; i < 4096; i++) {
+ const dB = (i - 3824) * 0.0235;
+ outputLUT[i] = Math.pow(20, dB / 20);
+}
+
+export class EnvelopeDX7 {
+ private levels: number[];
+ private rates: number[];
+ private level = 0;
+ private down = true;
+ private state = 0;
+ private targetlevel = 0;
+ private rising = false;
+ private qr = 0;
+ private decayIncrement = 0;
+
+ constructor(levels: number[], rates: number[]) {
+ this.levels = levels;
+ this.rates = rates;
+ this.advance(0);
+ }
+
+ render(): number {
+ if (this.state < 3 || (this.state < 4 && !this.down)) {
+ let lev = this.level;
+ if (this.rising) {
+ lev += this.decayIncrement * (2 + (this.targetlevel - lev) / 256);
+ if (lev >= this.targetlevel) {
+ lev = this.targetlevel;
+ this.advance(this.state + 1);
+ }
+ } else {
+ lev -= this.decayIncrement;
+ if (lev <= this.targetlevel) {
+ lev = this.targetlevel;
+ this.advance(this.state + 1);
+ }
+ }
+ this.level = lev;
+ }
+
+ // Convertit le niveau DX7 -> dB -> amplitude
+ return outputLUT[Math.floor(this.level)];
+ }
+
+ private advance(newstate: number): void {
+ this.state = newstate;
+ if (this.state < 4) {
+ const newlevel = this.levels[this.state];
+ this.targetlevel = Math.max(0, (outputlevel[newlevel] << 5) - 224);
+ this.rising = this.targetlevel - this.level > 0;
+ const rateScaling = 0;
+ this.qr = Math.min(
+ 63,
+ rateScaling + ((this.rates[this.state] * 41) >> 6),
+ );
+ this.decayIncrement = Math.pow(2, this.qr / 4) / 2048;
+ }
+ }
+
+ noteOff(): void {
+ this.down = false;
+ this.advance(3);
+ }
+
+ isFinished(): boolean {
+ return this.state === ENV_OFF;
+ }
+}
diff --git a/webapp/src/lib/audio/engines/fm-synth/dsp/lfoDx7.ts b/webapp/src/lib/audio/engines/fm-synth/dsp/lfoDx7.ts
new file mode 100644
index 0000000..a507504
--- /dev/null
+++ b/webapp/src/lib/audio/engines/fm-synth/dsp/lfoDx7.ts
@@ -0,0 +1,164 @@
+// Port de lfo-dx7.js (dx7-synth-js, MIT, Matt Montag)
+import { dspConfig } from "./config";
+import type { RuntimeOperatorParams, RuntimeParams } from "./runtimeTypes";
+
+const PERIOD = Math.PI * 2;
+const PERIOD_HALF = PERIOD / 2;
+const PERIOD_RECIP = 1 / PERIOD;
+const LFO_SAMPLE_PERIOD = 100;
+
+// see https://github.com/smbolton/hexter/tree/master/src/dx7_voice.c#L1002
+const LFO_FREQUENCY_TABLE = [
+ 0.062506, 0.124815, 0.311474, 0.435381, 0.619784, 0.744396, 0.930495, 1.11639,
+ 1.28422, 1.49688, 1.56783, 1.738994, 1.910158, 2.081322, 2.252486, 2.42365,
+ 2.580668, 2.737686, 2.894704, 3.051722, 3.20874, 3.36682, 3.5249, 3.68298,
+ 3.84106, 3.99914, 4.15942, 4.3197, 4.47998, 4.64026, 4.80054, 4.953584,
+ 5.106628, 5.259672, 5.412716, 5.56576, 5.724918, 5.884076, 6.043234, 6.202392,
+ 6.36155, 6.520044, 6.678538, 6.837032, 6.995526, 7.15402, 7.3005, 7.44698,
+ 7.59346, 7.73994, 7.88642, 8.020588, 8.154756, 8.288924, 8.423092, 8.55726,
+ 8.712624, 8.867988, 9.023352, 9.178716, 9.33408, 9.669644, 10.005208,
+ 10.340772, 10.676336, 11.0119, 11.96368, 12.91546, 13.86724, 14.81902,
+ 15.7708, 16.64024, 17.50968, 18.37912, 19.24856, 20.118, 21.0407, 21.9634,
+ 22.8861, 23.8088, 24.7315, 25.75974, 26.78798, 27.81622, 28.84446, 29.8727,
+ 31.2282, 32.5837, 33.9392, 35.2947, 36.6502, 37.81248, 38.97476, 40.13704,
+ 41.29932, 42.4616, 43.6398, 44.818, 45.9962, 47.1744, 47.1744, 47.1744,
+ 47.1744, 47.1744, 47.1744, 47.1744, 47.1744, 47.1744, 47.1744, 47.1744,
+ 47.1744, 47.1744, 47.1744, 47.1744, 47.1744, 47.1744, 47.1744, 47.1744,
+ 47.1744, 47.1744, 47.1744, 47.1744, 47.1744, 47.1744,
+];
+
+const LFO_PITCH_MOD_TABLE = [
+ 0, 0.0264, 0.0534, 0.0889, 0.1612, 0.2769, 0.4967, 1,
+];
+
+const LFO_MODE_TRIANGLE = 0,
+ LFO_MODE_SAW_DOWN = 1,
+ LFO_MODE_SAW_UP = 2,
+ LFO_MODE_SQUARE = 3,
+ LFO_MODE_SINE = 4,
+ LFO_MODE_SAMPLE_HOLD = 5;
+
+const LFO_DELAY_ONSET = 0,
+ LFO_DELAY_RAMP = 1,
+ LFO_DELAY_COMPLETE = 2;
+
+// État statique partagé par toutes les instances (un seul patch actif à la fois,
+// comme dans l'original — voir FMVoice.setParams).
+let phaseStep = 0;
+let ampModDepth = 0;
+let sampleHoldRandom = 0;
+const delayTimes = [0, 0, 0];
+const delayIncrements = [0, 0, 0];
+const delayVals = [0, 0, 1];
+let params: RuntimeParams | null = null;
+
+export class LfoDX7 {
+ private phase = 0;
+ private pitchVal = 0;
+ private counter = 0;
+ private ampVal = 1;
+ private ampValTarget = 1;
+ private ampIncrement = 0;
+ private delayVal = 0;
+ private delayState = LFO_DELAY_ONSET;
+
+ constructor(private opParams: RuntimeOperatorParams) {
+ LfoDX7.update();
+ }
+
+ render(): number {
+ if (!params) return this.pitchVal;
+ let amp = 0;
+ if (this.counter % LFO_SAMPLE_PERIOD === 0) {
+ switch (params.lfoWaveform) {
+ case LFO_MODE_TRIANGLE:
+ amp =
+ this.phase < PERIOD_HALF
+ ? 4 * this.phase * PERIOD_RECIP - 1
+ : 3 - 4 * this.phase * PERIOD_RECIP;
+ break;
+ case LFO_MODE_SAW_DOWN:
+ amp = 1 - 2 * this.phase * PERIOD_RECIP;
+ break;
+ case LFO_MODE_SAW_UP:
+ amp = 2 * this.phase * PERIOD_RECIP - 1;
+ break;
+ case LFO_MODE_SQUARE:
+ amp = this.phase < PERIOD_HALF ? -1 : 1;
+ break;
+ case LFO_MODE_SINE:
+ amp = Math.sin(this.phase);
+ break;
+ case LFO_MODE_SAMPLE_HOLD:
+ amp = sampleHoldRandom;
+ break;
+ }
+
+ switch (this.delayState) {
+ case LFO_DELAY_ONSET:
+ case LFO_DELAY_RAMP:
+ this.delayVal += delayIncrements[this.delayState];
+ if (this.counter / LFO_SAMPLE_PERIOD > delayTimes[this.delayState]) {
+ this.delayState++;
+ this.delayVal = delayVals[this.delayState];
+ }
+ break;
+ case LFO_DELAY_COMPLETE:
+ break;
+ }
+
+ amp *= this.delayVal;
+ const pitchModDepth =
+ 1 +
+ LFO_PITCH_MOD_TABLE[params.lfoPitchModSens] *
+ (params.controllerModVal + params.lfoPitchModDepth / 99);
+ this.pitchVal = Math.pow(pitchModDepth, amp);
+
+ const ampSensDepth = Math.abs(this.opParams.lfoAmpModSens) * 0.333333;
+ const phase = this.opParams.lfoAmpModSens > 0 ? 1 : -1;
+ this.ampValTarget =
+ 1 -
+ (ampModDepth + params.controllerModVal) *
+ ampSensDepth *
+ (amp * phase + 1) *
+ 0.5;
+ this.ampIncrement = (this.ampValTarget - this.ampVal) / LFO_SAMPLE_PERIOD;
+ this.phase += phaseStep;
+ if (this.phase >= PERIOD) {
+ sampleHoldRandom = 1 - Math.random() * 2;
+ this.phase -= PERIOD;
+ }
+ }
+ this.counter++;
+ return this.pitchVal;
+ }
+
+ renderAmp(): number {
+ this.ampVal += this.ampIncrement;
+ return this.ampVal;
+ }
+
+ static setParams(globalParams: RuntimeParams): void {
+ params = globalParams;
+ }
+
+ static update(): void {
+ if (!params) return;
+ const frequency = LFO_FREQUENCY_TABLE[params.lfoSpeed];
+ const lfoRate = dspConfig.sampleRate / LFO_SAMPLE_PERIOD;
+ phaseStep = (PERIOD * frequency) / lfoRate;
+ ampModDepth = params.lfoAmpModDepth * 0.01;
+ delayTimes[LFO_DELAY_ONSET] =
+ (lfoRate * 0.001753 * Math.pow(params.lfoDelay, 3.10454) +
+ 169.344 -
+ 168) /
+ 1000;
+ delayTimes[LFO_DELAY_RAMP] =
+ (lfoRate * 0.321877 * Math.pow(params.lfoDelay, 2.01163) +
+ 494.201 -
+ 168) /
+ 1000;
+ delayIncrements[LFO_DELAY_RAMP] =
+ 1 / (delayTimes[LFO_DELAY_RAMP] - delayTimes[LFO_DELAY_ONSET]);
+ }
+}
diff --git a/webapp/src/lib/audio/engines/fm-synth/dsp/operator.ts b/webapp/src/lib/audio/engines/fm-synth/dsp/operator.ts
new file mode 100644
index 0000000..6d30ffa
--- /dev/null
+++ b/webapp/src/lib/audio/engines/fm-synth/dsp/operator.ts
@@ -0,0 +1,57 @@
+// Port de operator.js (dx7-synth-js, MIT, Matt Montag)
+import { dspConfig } from "./config";
+import type { EnvelopeDX7 } from "./envelopeDx7";
+import type { LfoDX7 } from "./lfoDx7";
+import type { RuntimeOperatorParams } from "./runtimeTypes";
+
+// http://www.chipple.net/dx7/fig09-4.gif
+const OCTAVE_1024 = 1.0006771307; // Math.exp(Math.log(2)/1024)
+const PERIOD = Math.PI * 2;
+
+export class Operator {
+ phase = 0;
+ val = 0;
+ // Niveau de sortie effectif de cette voix précise : initialisé depuis le
+ // patch puis réajusté selon la vélocité par FMVoice (voir son constructeur).
+ outputLevel: number;
+ private phaseStep = 0;
+
+ constructor(
+ private params: RuntimeOperatorParams,
+ baseFrequency: number,
+ private envelope: EnvelopeDX7,
+ private lfo: LfoDX7,
+ ) {
+ this.outputLevel = params.outputLevel;
+ this.updateFrequency(baseFrequency);
+ }
+
+ updateFrequency(baseFrequency: number): void {
+ const frequency = this.params.oscMode
+ ? (this.params.freqFixed as number)
+ : baseFrequency *
+ (this.params.freqRatio as number) *
+ Math.pow(OCTAVE_1024, this.params.detune);
+ this.phaseStep = (PERIOD * frequency) / dspConfig.sampleRate;
+ }
+
+ render(mod: number): number {
+ this.val =
+ Math.sin(this.phase + mod) *
+ this.envelope.render() *
+ this.lfo.renderAmp();
+ this.phase += this.phaseStep * this.lfo.render();
+ if (this.phase >= PERIOD) {
+ this.phase -= PERIOD;
+ }
+ return this.val;
+ }
+
+ noteOff(): void {
+ this.envelope.noteOff();
+ }
+
+ isFinished(): boolean {
+ return this.envelope.isFinished();
+ }
+}
diff --git a/webapp/src/lib/audio/engines/fm-synth/dsp/runtimeTypes.ts b/webapp/src/lib/audio/engines/fm-synth/dsp/runtimeTypes.ts
new file mode 100644
index 0000000..6159c9d
--- /dev/null
+++ b/webapp/src/lib/audio/engines/fm-synth/dsp/runtimeTypes.ts
@@ -0,0 +1,25 @@
+import type { Dx7Operator } from "../../../../utils/types";
+
+// Forme "runtime" d'un opérateur : les champs bruts du patch (stockés/persistés)
+// étendus des valeurs dérivées calculées une fois au chargement du patch
+// (équivalent de FMVoice.setOutputLevel/setPan/updateFrequency dans l'app d'origine).
+export interface RuntimeOperatorParams extends Dx7Operator {
+ outputLevel: number;
+ ampL: number;
+ ampR: number;
+ freqRatio?: number;
+ freqFixed?: number;
+}
+
+export interface RuntimeParams {
+ algorithm: number;
+ fbRatio: number;
+ controllerModVal: number;
+ lfoSpeed: number;
+ lfoDelay: number;
+ lfoPitchModDepth: number;
+ lfoAmpModDepth: number;
+ lfoPitchModSens: number;
+ lfoWaveform: number;
+ operators: RuntimeOperatorParams[];
+}
diff --git a/webapp/src/lib/audio/engines/fm-synth/dsp/sysexDx7.ts b/webapp/src/lib/audio/engines/fm-synth/dsp/sysexDx7.ts
new file mode 100644
index 0000000..5580c9b
--- /dev/null
+++ b/webapp/src/lib/audio/engines/fm-synth/dsp/sysexDx7.ts
@@ -0,0 +1,55 @@
+// Port de sysex-dx7.js (dx7-synth-js, MIT, Matt Montag)
+// Parse un DX7 SysEx Bulk Data Bank (32 voix) — voir
+// http://homepages.abdn.ac.uk/mth192/pages/dx7/sysex-format.txt (Section F)
+import type { Dx7Operator, Dx7Patch } from "../../../../utils/types";
+
+export function extractPatchFromRom(
+ bankData: Uint8Array,
+ patchId: number,
+): Dx7Patch {
+ const dataStart = 128 * patchId + 6;
+ const voiceData = bankData.subarray(dataStart, dataStart + 128);
+ const operators: Dx7Operator[] = [];
+
+ for (let i = 5; i >= 0; --i) {
+ const oscStart = (5 - i) * 17;
+ const b = (offset: number) => voiceData[oscStart + offset];
+
+ operators[i] = {
+ idx: i,
+ enabled: true,
+ rates: [b(0), b(1), b(2), b(3)],
+ levels: [b(4), b(5), b(6), b(7)],
+ detune: Math.floor(b(12) >> 3) - 7,
+ velocitySens: b(13) >> 2,
+ lfoAmpModSens: b(13) & 3,
+ volume: b(14),
+ oscMode: (b(15) & 1) as 0 | 1,
+ freqCoarse: Math.floor(b(15) >> 1),
+ freqFine: b(16),
+ pan: (((i + 1) % 3) - 1) * 25, // panning alterné: -25, 0, 25, -25, 0, 25
+ };
+ }
+
+ return {
+ algorithm: voiceData[110] + 1, // 1-indexé pour lisibilité
+ feedback: voiceData[111] & 7,
+ name: String.fromCharCode(...voiceData.subarray(118, 128)).trim(),
+ lfoSpeed: voiceData[112],
+ lfoDelay: voiceData[113],
+ lfoPitchModDepth: voiceData[114],
+ lfoAmpModDepth: voiceData[115],
+ lfoPitchModSens: voiceData[116] >> 4,
+ lfoWaveform: Math.floor(voiceData[116] >> 1) & 7,
+ operators,
+ };
+}
+
+// Attend un Bulk Dump SysEx DX7 pour 32 voix (en-tête 6 octets inclus).
+export function loadBank(bankData: Uint8Array): Dx7Patch[] {
+ const presets: Dx7Patch[] = [];
+ for (let i = 0; i < 32; i++) {
+ presets.push(extractPatchFromRom(bankData, i));
+ }
+ return presets;
+}
diff --git a/webapp/src/lib/audio/engines/fm-synth/dsp/voiceDx7.ts b/webapp/src/lib/audio/engines/fm-synth/dsp/voiceDx7.ts
new file mode 100644
index 0000000..63d4601
--- /dev/null
+++ b/webapp/src/lib/audio/engines/fm-synth/dsp/voiceDx7.ts
@@ -0,0 +1,223 @@
+// Port de voice-dx7.js (dx7-synth-js, MIT, Matt Montag)
+import type { Dx7Patch } from "../../../../utils/types";
+import { EnvelopeDX7 } from "./envelopeDx7";
+import { LfoDX7 } from "./lfoDx7";
+import { Operator } from "./operator";
+import type { RuntimeOperatorParams, RuntimeParams } from "./runtimeTypes";
+
+const OUTPUT_LEVEL_TABLE = [
+ 0.0, 0.000337, 0.000476, 0.000674, 0.000952, 0.001235, 0.001602, 0.001905,
+ 0.002265, 0.002694, 0.003204, 0.00381, 0.004531, 0.005388, 0.006408, 0.00762,
+ 0.00831, 0.009062, 0.010776, 0.011752, 0.013975, 0.01524, 0.016619, 0.018123,
+ 0.019764, 0.021552, 0.023503, 0.02563, 0.02795, 0.03048, 0.033238, 0.036247,
+ 0.039527, 0.043105, 0.047006, 0.051261, 0.0559, 0.06096, 0.066477, 0.072494,
+ 0.079055, 0.08621, 0.094012, 0.102521, 0.1118, 0.121919, 0.132954, 0.144987,
+ 0.15811, 0.17242, 0.188025, 0.205043, 0.223601, 0.243838, 0.265907, 0.289974,
+ 0.316219, 0.344839, 0.37605, 0.410085, 0.447201, 0.487676, 0.531815, 0.579948,
+ 0.632438, 0.689679, 0.7521, 0.820171, 0.894403, 0.975353, 1.06363, 1.159897,
+ 1.264876, 1.379357, 1.5042, 1.640341, 1.788805, 1.950706, 2.12726, 2.319793,
+ 2.529752, 2.758714, 3.008399, 3.280683, 3.57761, 3.901411, 4.254519, 4.639586,
+ 5.059505, 5.517429, 6.016799, 6.561366, 7.15522, 7.802823, 8.509039, 9.279172,
+ 10.11901, 11.03486, 12.0336, 13.12273,
+];
+
+export interface Dx7Algorithm {
+ outputMix: number[];
+ modulationMatrix: number[][];
+}
+
+// Exporté pour l'interface (diagramme de routing) — lecture seule, purement
+// descriptif, n'affecte pas le rendu DSP ci-dessous.
+export const ALGORITHMS: Dx7Algorithm[] = [
+ { outputMix: [0, 2], modulationMatrix: [[1], [], [3], [4], [5], [5]] }, // 1
+ { outputMix: [0, 2], modulationMatrix: [[1], [1], [3], [4], [5], []] }, // 2
+ { outputMix: [0, 3], modulationMatrix: [[1], [2], [], [4], [5], [5]] }, // 3
+ { outputMix: [0, 3], modulationMatrix: [[1], [2], [], [4], [5], [3]] }, // 4
+ { outputMix: [0, 2, 4], modulationMatrix: [[1], [], [3], [], [5], [5]] }, // 5
+ { outputMix: [0, 2, 4], modulationMatrix: [[1], [], [3], [], [5], [4]] }, // 6
+ { outputMix: [0, 2], modulationMatrix: [[1], [], [3, 4], [], [5], [5]] }, // 7
+ { outputMix: [0, 2], modulationMatrix: [[1], [], [3, 4], [3], [5], []] }, // 8
+ { outputMix: [0, 2], modulationMatrix: [[1], [1], [3, 4], [], [5], []] }, // 9
+ { outputMix: [0, 3], modulationMatrix: [[1], [2], [2], [4, 5], [], []] }, // 10
+ { outputMix: [0, 3], modulationMatrix: [[1], [2], [], [4, 5], [], [5]] }, // 11
+ { outputMix: [0, 2], modulationMatrix: [[1], [1], [3, 4, 5], [], [], []] }, // 12
+ { outputMix: [0, 2], modulationMatrix: [[1], [], [3, 4, 5], [], [], [5]] }, // 13
+ { outputMix: [0, 2], modulationMatrix: [[1], [], [3], [4, 5], [], [5]] }, // 14
+ { outputMix: [0, 2], modulationMatrix: [[1], [1], [3], [4, 5], [], []] }, // 15
+ { outputMix: [0], modulationMatrix: [[1, 2, 4], [], [3], [], [5], [5]] }, // 16
+ { outputMix: [0], modulationMatrix: [[1, 2, 4], [1], [3], [], [5], []] }, // 17
+ { outputMix: [0], modulationMatrix: [[1, 2, 3], [], [2], [4], [5], []] }, // 18
+ { outputMix: [0, 3, 4], modulationMatrix: [[1], [2], [], [5], [5], [5]] }, // 19
+ { outputMix: [0, 1, 3], modulationMatrix: [[2], [2], [2], [4, 5], [], []] }, // 20
+ { outputMix: [0, 1, 3, 4], modulationMatrix: [[2], [2], [2], [5], [5], []] }, // 21
+ { outputMix: [0, 2, 3, 4], modulationMatrix: [[1], [], [5], [5], [5], [5]] }, // 22
+ { outputMix: [0, 1, 3, 4], modulationMatrix: [[], [2], [], [5], [5], [5]] }, // 23
+ {
+ outputMix: [0, 1, 2, 3, 4],
+ modulationMatrix: [[], [], [5], [5], [5], [5]],
+ }, // 24
+ { outputMix: [0, 1, 2, 3, 4], modulationMatrix: [[], [], [], [5], [5], [5]] }, // 25
+ { outputMix: [0, 1, 3], modulationMatrix: [[], [2], [], [4, 5], [], [5]] }, // 26
+ { outputMix: [0, 1, 3], modulationMatrix: [[], [2], [2], [4, 5], [], []] }, // 27
+ { outputMix: [0, 2, 5], modulationMatrix: [[1], [], [3], [4], [4], []] }, // 28
+ { outputMix: [0, 1, 2, 4], modulationMatrix: [[], [], [3], [], [5], [5]] }, // 29
+ { outputMix: [0, 1, 2, 5], modulationMatrix: [[], [], [3], [4], [4], []] }, // 30
+ { outputMix: [0, 1, 2, 3, 4], modulationMatrix: [[], [], [], [], [5], [5]] }, // 31
+ {
+ outputMix: [0, 1, 2, 3, 4, 5],
+ modulationMatrix: [[], [], [], [], [], [5]],
+ }, // 32
+];
+
+let params: RuntimeParams | null = null;
+
+export class FMVoice {
+ down = true;
+ note: number;
+ frequency: number;
+ velocity: number;
+ operators: Operator[] = new Array(6);
+
+ static aftertouch = 0;
+ static mod = 0;
+ static bend = 0;
+
+ constructor(note: number, velocity: number) {
+ this.note = note;
+ this.frequency = FMVoice.frequencyFromNoteNumber(this.note);
+ this.velocity = velocity;
+
+ const runtimeParams = params as RuntimeParams;
+ for (let i = 0; i < 6; i++) {
+ const opParams = runtimeParams.operators[i];
+ const op = new Operator(
+ opParams,
+ this.frequency,
+ new EnvelopeDX7(opParams.levels, opParams.rates),
+ new LfoDX7(opParams),
+ );
+ op.outputLevel =
+ (1 + (this.velocity - 1) * (opParams.velocitySens / 7)) *
+ opParams.outputLevel;
+ this.operators[i] = op;
+ }
+ this.updatePitchBend();
+ }
+
+ static frequencyFromNoteNumber(note: number): number {
+ return 440 * Math.pow(2, (note - 69) / 12);
+ }
+
+ static setParams(globalParams: RuntimeParams): void {
+ LfoDX7.setParams(globalParams);
+ params = globalParams;
+ }
+
+ static pitchBend(value: number): void {
+ FMVoice.bend = value;
+ }
+
+ render(): [number, number] {
+ const runtimeParams = params as RuntimeParams;
+ const algorithmIdx = runtimeParams.algorithm - 1;
+ const { modulationMatrix, outputMix } = ALGORITHMS[algorithmIdx];
+ const outputScaling = 1 / outputMix.length;
+ let outputL = 0;
+ let outputR = 0;
+
+ for (let i = 5; i >= 0; i--) {
+ let mod = 0;
+ if (runtimeParams.operators[i].enabled) {
+ const modulators = modulationMatrix[i];
+ for (let j = 0; j < modulators.length; j++) {
+ const modulator = modulators[j];
+ if (runtimeParams.operators[modulator].enabled) {
+ const modOp = this.operators[modulator];
+ if (modulator === i) {
+ // L'opérateur se module lui-même : ratio de feedback
+ mod += modOp.val * runtimeParams.fbRatio;
+ } else {
+ mod += modOp.val * modOp.outputLevel;
+ }
+ }
+ }
+ }
+ this.operators[i].render(mod);
+ }
+
+ for (let k = 0; k < outputMix.length; k++) {
+ if (runtimeParams.operators[outputMix[k]].enabled) {
+ const carrier = this.operators[outputMix[k]];
+ const carrierParams = runtimeParams.operators[outputMix[k]];
+ const carrierLevel = carrier.val * carrier.outputLevel;
+ outputL += carrierLevel * carrierParams.ampL;
+ outputR += carrierLevel * carrierParams.ampR;
+ }
+ }
+ return [outputL * outputScaling, outputR * outputScaling];
+ }
+
+ noteOff(): void {
+ this.down = false;
+ for (let i = 0; i < 6; i++) {
+ this.operators[i].noteOff();
+ }
+ }
+
+ updatePitchBend(): void {
+ const frequency = FMVoice.frequencyFromNoteNumber(this.note + FMVoice.bend);
+ for (let i = 0; i < 6; i++) {
+ this.operators[i].updateFrequency(frequency);
+ }
+ }
+
+ isFinished(): boolean {
+ const runtimeParams = params as RuntimeParams;
+ const outputMix = ALGORITHMS[runtimeParams.algorithm - 1].outputMix;
+ for (let i = 0; i < outputMix.length; i++) {
+ if (!this.operators[outputMix[i]].isFinished()) return false;
+ }
+ return true;
+ }
+}
+
+function mapOutputLevel(volume: number): number {
+ const idx = Math.min(99, Math.max(0, Math.floor(volume)));
+ return OUTPUT_LEVEL_TABLE[idx] * 1.27;
+}
+
+// Convertit un patch persisté (champs bruts DX7) en RuntimeParams consommables
+// par FMVoice/Operator/LfoDX7 — équivalent de ce que faisait l'UI d'origine via
+// FMVoice.setOutputLevel/setPan/updateFrequency/setFeedback à chaque chargement
+// de patch.
+export function buildRuntimeParams(patch: Dx7Patch): RuntimeParams {
+ const operators: RuntimeOperatorParams[] = patch.operators.map((op) => {
+ const runtimeOp: RuntimeOperatorParams = {
+ ...op,
+ outputLevel: mapOutputLevel(op.volume),
+ ampL: Math.cos(((Math.PI / 2) * (op.pan + 50)) / 100),
+ ampR: Math.sin(((Math.PI / 2) * (op.pan + 50)) / 100),
+ };
+ if (op.oscMode === 0) {
+ const freqCoarse = op.freqCoarse || 0.5; // 0 -> ratio de 0.5
+ runtimeOp.freqRatio = freqCoarse * (1 + op.freqFine / 100);
+ } else {
+ runtimeOp.freqFixed =
+ Math.pow(10, op.freqCoarse % 4) * (1 + (op.freqFine / 99) * 8.772);
+ }
+ return runtimeOp;
+ });
+
+ return {
+ algorithm: patch.algorithm,
+ fbRatio: Math.pow(2, patch.feedback - 7),
+ controllerModVal: 0,
+ lfoSpeed: patch.lfoSpeed,
+ lfoDelay: patch.lfoDelay,
+ lfoPitchModDepth: patch.lfoPitchModDepth,
+ lfoAmpModDepth: patch.lfoAmpModDepth,
+ lfoPitchModSens: patch.lfoPitchModSens,
+ lfoWaveform: patch.lfoWaveform,
+ operators,
+ };
+}
diff --git a/webapp/src/lib/audio/engines/fm-synth/fm-synth-processor.ts b/webapp/src/lib/audio/engines/fm-synth/fm-synth-processor.ts
new file mode 100644
index 0000000..cbf2c32
--- /dev/null
+++ b/webapp/src/lib/audio/engines/fm-synth/fm-synth-processor.ts
@@ -0,0 +1,78 @@
+///
+// Rendu FM (style DX7) échantillon par échantillon, sur le thread audio.
+// Le DSP (dsp/*) est un renderer logiciel pur — pas de graphe Web Audio natif —
+// d'où la nécessité d'un AudioWorkletProcessor.
+import { dspConfig } from "./dsp/config";
+import { buildRuntimeParams, FMVoice } from "./dsp/voiceDx7";
+import type { FmSynthMessageToProcessor } from "./protocol";
+
+const MAX_POLYPHONY = 8;
+// Niveau nominal par voix, emprunté à Hexter (voir synth.js de dx7-synth-js).
+const PER_VOICE_LEVEL = 0.125 / 6;
+
+class FmSynthProcessor extends AudioWorkletProcessor {
+ private voices = new Map();
+ private patchLoaded = false;
+
+ constructor() {
+ super();
+ dspConfig.sampleRate = sampleRate;
+ this.port.onmessage = (event: MessageEvent) => {
+ this.handleMessage(event.data);
+ };
+ }
+
+ private handleMessage(message: FmSynthMessageToProcessor): void {
+ switch (message.type) {
+ case "loadPatch":
+ FMVoice.setParams(buildRuntimeParams(message.patch));
+ this.patchLoaded = true;
+ break;
+
+ case "noteOn": {
+ if (!this.patchLoaded) break;
+ if (this.voices.size >= MAX_POLYPHONY) {
+ const oldestNoteId = this.voices.keys().next().value;
+ if (oldestNoteId !== undefined) this.voices.delete(oldestNoteId);
+ }
+ this.voices.set(
+ message.noteId,
+ new FMVoice(message.note, message.velocity),
+ );
+ break;
+ }
+
+ case "noteOff":
+ this.voices.get(message.noteId)?.noteOff();
+ break;
+
+ case "panic":
+ this.voices.clear();
+ break;
+ }
+ }
+
+ process(_inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
+ const [left, right] = outputs[0];
+
+ for (let frame = 0; frame < left.length; frame++) {
+ let outL = 0;
+ let outR = 0;
+ for (const [noteId, voice] of this.voices) {
+ if (voice.isFinished()) {
+ this.voices.delete(noteId);
+ continue;
+ }
+ const [l, r] = voice.render();
+ outL += l;
+ outR += r;
+ }
+ left[frame] = outL * PER_VOICE_LEVEL;
+ right[frame] = outR * PER_VOICE_LEVEL;
+ }
+
+ return true;
+ }
+}
+
+registerProcessor("fm-synth-processor", FmSynthProcessor);
diff --git a/webapp/src/lib/audio/engines/fm-synth/index.ts b/webapp/src/lib/audio/engines/fm-synth/index.ts
new file mode 100644
index 0000000..83bdadc
--- /dev/null
+++ b/webapp/src/lib/audio/engines/fm-synth/index.ts
@@ -0,0 +1,8 @@
+import type { Dx7Patch } from "../../../utils/types";
+import rom1aPresets from "./presets/rom1a.json";
+
+export { FmSynthEngine } from "./FmSynthEngine";
+export { ALGORITHMS, type Dx7Algorithm } from "./dsp/voiceDx7";
+
+// Bank ROM1A du DX7 original (32 presets d'usine), parsé depuis le sysex.
+export const FM_SYNTH_PRESETS = rom1aPresets as Dx7Patch[];
diff --git a/webapp/src/lib/audio/engines/fm-synth/presets/rom1a.json b/webapp/src/lib/audio/engines/fm-synth/presets/rom1a.json
new file mode 100644
index 0000000..5e5455d
--- /dev/null
+++ b/webapp/src/lib/audio/engines/fm-synth/presets/rom1a.json
@@ -0,0 +1,5026 @@
+[
+ {
+ "algorithm": 22,
+ "feedback": 7,
+ "name": "BRASS 1",
+ "lfoSpeed": 37,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 5,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 3,
+ "lfoWaveform": 4,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 72,
+ 76,
+ 99,
+ 71
+ ],
+ "levels": [
+ 99,
+ 88,
+ 96,
+ 0
+ ],
+ "detune": 7,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 98,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 62,
+ 51,
+ 29,
+ 71
+ ],
+ "levels": [
+ 82,
+ 95,
+ 96,
+ 0
+ ],
+ "detune": 7,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 86,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 77,
+ 76,
+ 82,
+ 71
+ ],
+ "levels": [
+ 99,
+ 98,
+ 98,
+ 0
+ ],
+ "detune": -2,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 77,
+ 36,
+ 41,
+ 71
+ ],
+ "levels": [
+ 99,
+ 98,
+ 98,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 77,
+ 36,
+ 41,
+ 71
+ ],
+ "levels": [
+ 99,
+ 98,
+ 98,
+ 0
+ ],
+ "detune": 1,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 98,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 49,
+ 99,
+ 28,
+ 68
+ ],
+ "levels": [
+ 98,
+ 98,
+ 91,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 82,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 22,
+ "feedback": 7,
+ "name": "BRASS 2",
+ "lfoSpeed": 37,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 3,
+ "lfoWaveform": 4,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 99,
+ 39,
+ 32,
+ 71
+ ],
+ "levels": [
+ 99,
+ 98,
+ 80,
+ 0
+ ],
+ "detune": 7,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 99,
+ 39,
+ 32,
+ 71
+ ],
+ "levels": [
+ 99,
+ 98,
+ 80,
+ 0
+ ],
+ "detune": 7,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 84,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 99,
+ 39,
+ 32,
+ 71
+ ],
+ "levels": [
+ 99,
+ 98,
+ 81,
+ 0
+ ],
+ "detune": -3,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 99,
+ 39,
+ 32,
+ 71
+ ],
+ "levels": [
+ 99,
+ 98,
+ 81,
+ 0
+ ],
+ "detune": -2,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 99,
+ 39,
+ 32,
+ 71
+ ],
+ "levels": [
+ 99,
+ 98,
+ 81,
+ 0
+ ],
+ "detune": 1,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 99,
+ 39,
+ 32,
+ 71
+ ],
+ "levels": [
+ 99,
+ 98,
+ 88,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 80,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 18,
+ "feedback": 6,
+ "name": "BRASS 3",
+ "lfoSpeed": 35,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 5,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 3,
+ "lfoWaveform": 0,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 55,
+ 24,
+ 19,
+ 55
+ ],
+ "levels": [
+ 99,
+ 86,
+ 86,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 37,
+ 34,
+ 15,
+ 70
+ ],
+ "levels": [
+ 85,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 70,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 46,
+ 35,
+ 22,
+ 50
+ ],
+ "levels": [
+ 99,
+ 86,
+ 86,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 77,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 66,
+ 92,
+ 22,
+ 50
+ ],
+ "levels": [
+ 53,
+ 61,
+ 62,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 79,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 48,
+ 55,
+ 22,
+ 50
+ ],
+ "levels": [
+ 98,
+ 61,
+ 62,
+ 0
+ ],
+ "detune": -1,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 70,
+ "oscMode": 0,
+ "freqCoarse": 3,
+ "freqFine": 6,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 77,
+ 56,
+ 20,
+ 70
+ ],
+ "levels": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 79,
+ "oscMode": 0,
+ "freqCoarse": 7,
+ "freqFine": 21,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 2,
+ "feedback": 7,
+ "name": "STRINGS 1",
+ "lfoSpeed": 30,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 8,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 2,
+ "lfoWaveform": 0,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 45,
+ 24,
+ 20,
+ 41
+ ],
+ "levels": [
+ 99,
+ 85,
+ 70,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 3,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 75,
+ 71,
+ 17,
+ 49
+ ],
+ "levels": [
+ 82,
+ 92,
+ 62,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 83,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 44,
+ 45,
+ 20,
+ 54
+ ],
+ "levels": [
+ 99,
+ 85,
+ 82,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 7,
+ "lfoAmpModSens": 0,
+ "volume": 86,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 96,
+ 19,
+ 20,
+ 54
+ ],
+ "levels": [
+ 99,
+ 92,
+ 86,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 77,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 53,
+ 19,
+ 20,
+ 54
+ ],
+ "levels": [
+ 86,
+ 92,
+ 86,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 84,
+ "oscMode": 0,
+ "freqCoarse": 3,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 53,
+ 19,
+ 20,
+ 54
+ ],
+ "levels": [
+ 99,
+ 92,
+ 86,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 53,
+ "oscMode": 0,
+ "freqCoarse": 14,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 2,
+ "feedback": 7,
+ "name": "STRINGS 2",
+ "lfoSpeed": 30,
+ "lfoDelay": 81,
+ "lfoPitchModDepth": 8,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 2,
+ "lfoWaveform": 4,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 48,
+ 56,
+ 10,
+ 47
+ ],
+ "levels": [
+ 98,
+ 98,
+ 36,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 92,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 81,
+ 13,
+ 7,
+ 25
+ ],
+ "levels": [
+ 99,
+ 92,
+ 28,
+ 0
+ ],
+ "detune": -6,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 74,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 51,
+ 15,
+ 10,
+ 47
+ ],
+ "levels": [
+ 99,
+ 92,
+ 0,
+ 0
+ ],
+ "detune": 6,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 92,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 49,
+ 74,
+ 10,
+ 32
+ ],
+ "levels": [
+ 98,
+ 98,
+ 36,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 76,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 76,
+ 73,
+ 10,
+ 28
+ ],
+ "levels": [
+ 99,
+ 92,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 66,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 72,
+ 76,
+ 10,
+ 32
+ ],
+ "levels": [
+ 99,
+ 92,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 70,
+ "oscMode": 0,
+ "freqCoarse": 8,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 15,
+ "feedback": 7,
+ "name": "STRINGS 3",
+ "lfoSpeed": 28,
+ "lfoDelay": 46,
+ "lfoPitchModDepth": 30,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 1,
+ "lfoWaveform": 4,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 52,
+ 30,
+ 25,
+ 43
+ ],
+ "levels": [
+ 99,
+ 92,
+ 90,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 99,
+ 71,
+ 35,
+ 51
+ ],
+ "levels": [
+ 82,
+ 92,
+ 87,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 86,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 50,
+ 52,
+ 35,
+ 41
+ ],
+ "levels": [
+ 99,
+ 92,
+ 91,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 96,
+ 19,
+ 20,
+ 54
+ ],
+ "levels": [
+ 99,
+ 92,
+ 89,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 75,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 53,
+ 67,
+ 38,
+ 54
+ ],
+ "levels": [
+ 86,
+ 92,
+ 74,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 84,
+ "oscMode": 0,
+ "freqCoarse": 3,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 53,
+ 64,
+ 44,
+ 54
+ ],
+ "levels": [
+ 99,
+ 92,
+ 56,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 54,
+ "oscMode": 0,
+ "freqCoarse": 14,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 2,
+ "feedback": 7,
+ "name": "ORCHESTRA",
+ "lfoSpeed": 30,
+ "lfoDelay": 63,
+ "lfoPitchModDepth": 6,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 3,
+ "lfoWaveform": 4,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 80,
+ 56,
+ 10,
+ 45
+ ],
+ "levels": [
+ 98,
+ 98,
+ 36,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 53,
+ 46,
+ 32,
+ 61
+ ],
+ "levels": [
+ 99,
+ 93,
+ 90,
+ 0
+ ],
+ "detune": -6,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 83,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 54,
+ 15,
+ 10,
+ 47
+ ],
+ "levels": [
+ 99,
+ 92,
+ 0,
+ 0
+ ],
+ "detune": 6,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 96,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 56,
+ 74,
+ 10,
+ 45
+ ],
+ "levels": [
+ 98,
+ 98,
+ 36,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 72,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 76,
+ 73,
+ 10,
+ 55
+ ],
+ "levels": [
+ 99,
+ 92,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 80,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 72,
+ 76,
+ 10,
+ 32
+ ],
+ "levels": [
+ 99,
+ 92,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 82,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 19,
+ "feedback": 6,
+ "name": "PIANO 1",
+ "lfoSpeed": 35,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 4,
+ "lfoWaveform": 0,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 81,
+ 25,
+ 20,
+ 48
+ ],
+ "levels": [
+ 99,
+ 82,
+ 0,
+ 0
+ ],
+ "detune": -2,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 99,
+ 0,
+ 25,
+ 0
+ ],
+ "levels": [
+ 99,
+ 75,
+ 0,
+ 0
+ ],
+ "detune": 2,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 87,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 81,
+ 25,
+ 25,
+ 14
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 57,
+ "oscMode": 0,
+ "freqCoarse": 3,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 81,
+ 23,
+ 22,
+ 45
+ ],
+ "levels": [
+ 99,
+ 78,
+ 0,
+ 0
+ ],
+ "detune": 1,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 81,
+ 58,
+ 36,
+ 39
+ ],
+ "levels": [
+ 99,
+ 14,
+ 0,
+ 0
+ ],
+ "detune": -1,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 93,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 58,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 99,
+ 0,
+ 25,
+ 0
+ ],
+ "levels": [
+ 99,
+ 75,
+ 0,
+ 0
+ ],
+ "detune": -1,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 82,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 18,
+ "feedback": 5,
+ "name": "PIANO 2",
+ "lfoSpeed": 30,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 4,
+ "lfoWaveform": 0,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 80,
+ 24,
+ 10,
+ 50
+ ],
+ "levels": [
+ 99,
+ 62,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 94,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 95,
+ 0,
+ 25,
+ 0
+ ],
+ "levels": [
+ 99,
+ 75,
+ 0,
+ 0
+ ],
+ "detune": 2,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 86,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 90,
+ 27,
+ 20,
+ 50
+ ],
+ "levels": [
+ 99,
+ 85,
+ 0,
+ 0
+ ],
+ "detune": 1,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 83,
+ "oscMode": 0,
+ "freqCoarse": 5,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 97,
+ 27,
+ 10,
+ 25
+ ],
+ "levels": [
+ 99,
+ 86,
+ 48,
+ 0
+ ],
+ "detune": -1,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 84,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 90,
+ 71,
+ 33,
+ 31
+ ],
+ "levels": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 94,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 92,
+ 71,
+ 58,
+ 36
+ ],
+ "levels": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": 1,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 78,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 3,
+ "feedback": 4,
+ "name": "PIANO 3",
+ "lfoSpeed": 45,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 4,
+ "lfoWaveform": 0,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 90,
+ 30,
+ 28,
+ 45
+ ],
+ "levels": [
+ 99,
+ 95,
+ 0,
+ 0
+ ],
+ "detune": -4,
+ "velocitySens": 3,
+ "lfoAmpModSens": 0,
+ "volume": 86,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 98,
+ 36,
+ 6,
+ 32
+ ],
+ "levels": [
+ 91,
+ 90,
+ 0,
+ 0
+ ],
+ "detune": 4,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 85,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 94,
+ 80,
+ 19,
+ 12
+ ],
+ "levels": [
+ 83,
+ 67,
+ 0,
+ 0
+ ],
+ "detune": -3,
+ "velocitySens": 3,
+ "lfoAmpModSens": 0,
+ "volume": 97,
+ "oscMode": 0,
+ "freqCoarse": 7,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 90,
+ 64,
+ 28,
+ 45
+ ],
+ "levels": [
+ 99,
+ 97,
+ 0,
+ 0
+ ],
+ "detune": 3,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 95,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 98,
+ 20,
+ 6,
+ 2
+ ],
+ "levels": [
+ 91,
+ 90,
+ 0,
+ 0
+ ],
+ "detune": -2,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 87,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 80,
+ 73,
+ 15,
+ 10
+ ],
+ "levels": [
+ 99,
+ 19,
+ 0,
+ 0
+ ],
+ "detune": 2,
+ "velocitySens": 5,
+ "lfoAmpModSens": 0,
+ "volume": 84,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 5,
+ "feedback": 6,
+ "name": "E.PIANO 1",
+ "lfoSpeed": 34,
+ "lfoDelay": 33,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 3,
+ "lfoWaveform": 4,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 96,
+ 25,
+ 25,
+ 67
+ ],
+ "levels": [
+ 99,
+ 75,
+ 0,
+ 0
+ ],
+ "detune": 3,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 95,
+ 50,
+ 35,
+ 78
+ ],
+ "levels": [
+ 99,
+ 75,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 7,
+ "lfoAmpModSens": 0,
+ "volume": 58,
+ "oscMode": 0,
+ "freqCoarse": 14,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 95,
+ 20,
+ 20,
+ 50
+ ],
+ "levels": [
+ 99,
+ 95,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 95,
+ 29,
+ 20,
+ 50
+ ],
+ "levels": [
+ 99,
+ 95,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 6,
+ "lfoAmpModSens": 0,
+ "volume": 89,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 95,
+ 20,
+ 20,
+ 50
+ ],
+ "levels": [
+ 99,
+ 95,
+ 0,
+ 0
+ ],
+ "detune": -7,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 95,
+ 29,
+ 20,
+ 50
+ ],
+ "levels": [
+ 99,
+ 95,
+ 0,
+ 0
+ ],
+ "detune": 7,
+ "velocitySens": 6,
+ "lfoAmpModSens": 0,
+ "volume": 79,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 8,
+ "feedback": 7,
+ "name": "GUITAR 1",
+ "lfoSpeed": 35,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 1,
+ "lfoAmpModDepth": 3,
+ "lfoPitchModSens": 3,
+ "lfoWaveform": 4,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 74,
+ 85,
+ 27,
+ 70
+ ],
+ "levels": [
+ 99,
+ 95,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 5,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 91,
+ 25,
+ 39,
+ 60
+ ],
+ "levels": [
+ 99,
+ 86,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 7,
+ "lfoAmpModSens": 0,
+ "volume": 93,
+ "oscMode": 0,
+ "freqCoarse": 3,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 78,
+ 87,
+ 22,
+ 75
+ ],
+ "levels": [
+ 99,
+ 92,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 7,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 81,
+ 87,
+ 22,
+ 75
+ ],
+ "levels": [
+ 99,
+ 92,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 4,
+ "lfoAmpModSens": 0,
+ "volume": 89,
+ "oscMode": 0,
+ "freqCoarse": 3,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 81,
+ 87,
+ 22,
+ 75
+ ],
+ "levels": [
+ 99,
+ 92,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 7,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 3,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 99,
+ 57,
+ 99,
+ 75
+ ],
+ "levels": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 6,
+ "lfoAmpModSens": 3,
+ "volume": 57,
+ "oscMode": 0,
+ "freqCoarse": 12,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 16,
+ "feedback": 7,
+ "name": "GUITAR 2",
+ "lfoSpeed": 35,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 4,
+ "lfoWaveform": 0,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 95,
+ 67,
+ 99,
+ 71
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 86,
+ "oscMode": 0,
+ "freqCoarse": 3,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 99,
+ 99,
+ 99,
+ 42
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 99
+ ],
+ "detune": 0,
+ "velocitySens": 7,
+ "lfoAmpModSens": 0,
+ "volume": 87,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 99,
+ 99,
+ 99,
+ 71
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 7,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 50,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 92,
+ 99,
+ 15,
+ 71
+ ],
+ "levels": [
+ 99,
+ 96,
+ 75,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 70,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 99,
+ 99,
+ 12,
+ 0
+ ],
+ "levels": [
+ 99,
+ 99,
+ 76,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 7,
+ "lfoAmpModSens": 0,
+ "volume": 85,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 99,
+ 44,
+ 1,
+ 71
+ ],
+ "levels": [
+ 99,
+ 96,
+ 75,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 73,
+ "oscMode": 0,
+ "freqCoarse": 3,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 18,
+ "feedback": 7,
+ "name": "SYN-LEAD 1",
+ "lfoSpeed": 37,
+ "lfoDelay": 42,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 99,
+ "lfoPitchModSens": 4,
+ "lfoWaveform": 4,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 99,
+ 0,
+ 12,
+ 70
+ ],
+ "levels": [
+ 99,
+ 95,
+ 95,
+ 0
+ ],
+ "detune": 1,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 99,
+ 95,
+ 0,
+ 0
+ ],
+ "levels": [
+ 99,
+ 96,
+ 89,
+ 0
+ ],
+ "detune": -1,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 71,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 99,
+ 87,
+ 0,
+ 0
+ ],
+ "levels": [
+ 93,
+ 90,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 82,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 99,
+ 92,
+ 28,
+ 60
+ ],
+ "levels": [
+ 99,
+ 90,
+ 0,
+ 0
+ ],
+ "detune": 2,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 71,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 99,
+ 99,
+ 97,
+ 0
+ ],
+ "levels": [
+ 99,
+ 65,
+ 60,
+ 0
+ ],
+ "detune": -2,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 43,
+ "oscMode": 0,
+ "freqCoarse": 3,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 99,
+ 70,
+ 60,
+ 0
+ ],
+ "levels": [
+ 99,
+ 99,
+ 97,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 47,
+ "oscMode": 0,
+ "freqCoarse": 17,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 16,
+ "feedback": 7,
+ "name": "BASS 1",
+ "lfoSpeed": 35,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 3,
+ "lfoWaveform": 0,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 95,
+ 62,
+ 17,
+ 58
+ ],
+ "levels": [
+ 99,
+ 95,
+ 32,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 99,
+ 20,
+ 0,
+ 0
+ ],
+ "levels": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 80,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 88,
+ 96,
+ 32,
+ 30
+ ],
+ "levels": [
+ 79,
+ 65,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 3,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 90,
+ 42,
+ 7,
+ 55
+ ],
+ "levels": [
+ 90,
+ 30,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 5,
+ "lfoAmpModSens": 0,
+ "volume": 93,
+ "oscMode": 0,
+ "freqCoarse": 5,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "levels": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 3,
+ "lfoAmpModSens": 0,
+ "volume": 62,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 94,
+ 56,
+ 24,
+ 55
+ ],
+ "levels": [
+ 93,
+ 28,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 7,
+ "lfoAmpModSens": 0,
+ "volume": 85,
+ "oscMode": 0,
+ "freqCoarse": 9,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 17,
+ "feedback": 7,
+ "name": "BASS 2",
+ "lfoSpeed": 31,
+ "lfoDelay": 33,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 2,
+ "lfoWaveform": 4,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 75,
+ 37,
+ 18,
+ 63
+ ],
+ "levels": [
+ 99,
+ 70,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 1,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 28,
+ 37,
+ 42,
+ 50
+ ],
+ "levels": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 80,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 3,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 73,
+ 25,
+ 32,
+ 30
+ ],
+ "levels": [
+ 97,
+ 78,
+ 0,
+ 0
+ ],
+ "detune": 7,
+ "velocitySens": 3,
+ "lfoAmpModSens": 0,
+ "volume": 68,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 80,
+ 39,
+ 28,
+ 53
+ ],
+ "levels": [
+ 93,
+ 57,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 99,
+ 51,
+ 0,
+ 0
+ ],
+ "levels": [
+ 99,
+ 74,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 75,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 1,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 25,
+ 50,
+ 24,
+ 55
+ ],
+ "levels": [
+ 96,
+ 97,
+ 0,
+ 0
+ ],
+ "detune": 1,
+ "velocitySens": 7,
+ "lfoAmpModSens": 0,
+ "volume": 87,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 32,
+ "feedback": 0,
+ "name": "E.ORGAN 1",
+ "lfoSpeed": 35,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 4,
+ "lfoWaveform": 0,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 99,
+ 80,
+ 22,
+ 90
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 0
+ ],
+ "detune": -2,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 94,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 99,
+ 20,
+ 22,
+ 90
+ ],
+ "levels": [
+ 99,
+ 99,
+ 97,
+ 0
+ ],
+ "detune": -6,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 94,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 1,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 99,
+ 80,
+ 54,
+ 82
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 0
+ ],
+ "detune": 4,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 94,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 50,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 99,
+ 80,
+ 22,
+ 90
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 0
+ ],
+ "detune": 5,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 94,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 99,
+ 80,
+ 22,
+ 90
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 0
+ ],
+ "detune": 2,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 94,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 99,
+ 54,
+ 22,
+ 90
+ ],
+ "levels": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 94,
+ "oscMode": 0,
+ "freqCoarse": 3,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 19,
+ "feedback": 7,
+ "name": "PIPES 1",
+ "lfoSpeed": 34,
+ "lfoDelay": 33,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 2,
+ "lfoWaveform": 4,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 45,
+ 25,
+ 25,
+ 36
+ ],
+ "levels": [
+ 99,
+ 99,
+ 98,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 99,
+ 97,
+ 62,
+ 47
+ ],
+ "levels": [
+ 99,
+ 99,
+ 90,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 90,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 99,
+ 97,
+ 62,
+ 47
+ ],
+ "levels": [
+ 99,
+ 99,
+ 90,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 75,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 61,
+ 25,
+ 25,
+ 50
+ ],
+ "levels": [
+ 99,
+ 99,
+ 97,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 88,
+ "oscMode": 0,
+ "freqCoarse": 4,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 61,
+ 25,
+ 25,
+ 61
+ ],
+ "levels": [
+ 99,
+ 99,
+ 93,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 97,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 72,
+ 25,
+ 25,
+ 70
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 76,
+ "oscMode": 0,
+ "freqCoarse": 10,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 5,
+ "feedback": 1,
+ "name": "HARPSICH 1",
+ "lfoSpeed": 35,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 2,
+ "lfoWaveform": 0,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 95,
+ 28,
+ 27,
+ 47
+ ],
+ "levels": [
+ 99,
+ 90,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 89,
+ "oscMode": 0,
+ "freqCoarse": 4,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 95,
+ 72,
+ 71,
+ 99
+ ],
+ "levels": [
+ 99,
+ 97,
+ 91,
+ 98
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 95,
+ 28,
+ 27,
+ 47
+ ],
+ "levels": [
+ 99,
+ 90,
+ 0,
+ 0
+ ],
+ "detune": -1,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 85,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 95,
+ 72,
+ 71,
+ 99
+ ],
+ "levels": [
+ 99,
+ 97,
+ 91,
+ 98
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 3,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 95,
+ 28,
+ 27,
+ 47
+ ],
+ "levels": [
+ 99,
+ 90,
+ 0,
+ 0
+ ],
+ "detune": -1,
+ "velocitySens": 3,
+ "lfoAmpModSens": 0,
+ "volume": 83,
+ "oscMode": 0,
+ "freqCoarse": 4,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 95,
+ 72,
+ 71,
+ 99
+ ],
+ "levels": [
+ 99,
+ 97,
+ 91,
+ 98
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 87,
+ "oscMode": 0,
+ "freqCoarse": 6,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 3,
+ "feedback": 5,
+ "name": "CLAV 1",
+ "lfoSpeed": 30,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 2,
+ "lfoWaveform": 4,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 95,
+ 92,
+ 28,
+ 60
+ ],
+ "levels": [
+ 99,
+ 90,
+ 0,
+ 0
+ ],
+ "detune": 1,
+ "velocitySens": 3,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 95,
+ 95,
+ 0,
+ 0
+ ],
+ "levels": [
+ 99,
+ 96,
+ 89,
+ 0
+ ],
+ "detune": -1,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 98,
+ 87,
+ 0,
+ 0
+ ],
+ "levels": [
+ 87,
+ 86,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 71,
+ "oscMode": 0,
+ "freqCoarse": 4,
+ "freqFine": 50,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 95,
+ 92,
+ 28,
+ 60
+ ],
+ "levels": [
+ 99,
+ 90,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 95,
+ 95,
+ 0,
+ 0
+ ],
+ "levels": [
+ 99,
+ 96,
+ 89,
+ 0
+ ],
+ "detune": -2,
+ "velocitySens": 6,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 98,
+ 87,
+ 0,
+ 0
+ ],
+ "levels": [
+ 87,
+ 86,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 7,
+ "lfoAmpModSens": 0,
+ "volume": 78,
+ "oscMode": 0,
+ "freqCoarse": 8,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 23,
+ "feedback": 5,
+ "name": "VIBE 1",
+ "lfoSpeed": 26,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 2,
+ "lfoWaveform": 0,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 99,
+ 28,
+ 99,
+ 50
+ ],
+ "levels": [
+ 99,
+ 25,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 7,
+ "lfoAmpModSens": 0,
+ "volume": 50,
+ "oscMode": 0,
+ "freqCoarse": 4,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 80,
+ 85,
+ 24,
+ 50
+ ],
+ "levels": [
+ 99,
+ 90,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 80,
+ 85,
+ 43,
+ 50
+ ],
+ "levels": [
+ 99,
+ 74,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 4,
+ "lfoAmpModSens": 0,
+ "volume": 72,
+ "oscMode": 0,
+ "freqCoarse": 3,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 80,
+ 85,
+ 24,
+ 50
+ ],
+ "levels": [
+ 99,
+ 90,
+ 0,
+ 0
+ ],
+ "detune": -7,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 80,
+ 85,
+ 24,
+ 50
+ ],
+ "levels": [
+ 99,
+ 90,
+ 42,
+ 0
+ ],
+ "detune": 7,
+ "velocitySens": 5,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 99,
+ 48,
+ 99,
+ 50
+ ],
+ "levels": [
+ 99,
+ 32,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 7,
+ "lfoAmpModSens": 0,
+ "volume": 57,
+ "oscMode": 0,
+ "freqCoarse": 14,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 7,
+ "feedback": 0,
+ "name": "MARIMBA",
+ "lfoSpeed": 35,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 3,
+ "lfoWaveform": 0,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 95,
+ 40,
+ 49,
+ 55
+ ],
+ "levels": [
+ 99,
+ 92,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 95,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 99,
+ 72,
+ 0,
+ 0
+ ],
+ "levels": [
+ 82,
+ 48,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 96,
+ "oscMode": 0,
+ "freqCoarse": 3,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 95,
+ 33,
+ 49,
+ 41
+ ],
+ "levels": [
+ 99,
+ 92,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 99,
+ 75,
+ 0,
+ 82
+ ],
+ "levels": [
+ 82,
+ 48,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 85,
+ "oscMode": 0,
+ "freqCoarse": 5,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 99,
+ 75,
+ 0,
+ 8
+ ],
+ "levels": [
+ 82,
+ 48,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 93,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 50,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 0,
+ 63,
+ 55,
+ 0
+ ],
+ "levels": [
+ 78,
+ 78,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 4,
+ "freqFine": 13,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 2,
+ "feedback": 7,
+ "name": "KOTO",
+ "lfoSpeed": 30,
+ "lfoDelay": 40,
+ "lfoPitchModDepth": 17,
+ "lfoAmpModDepth": 15,
+ "lfoPitchModSens": 2,
+ "lfoWaveform": 4,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 94,
+ 62,
+ 58,
+ 34
+ ],
+ "levels": [
+ 99,
+ 92,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 3,
+ "lfoAmpModSens": 0,
+ "volume": 90,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 99,
+ 68,
+ 28,
+ 48
+ ],
+ "levels": [
+ 99,
+ 83,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 4,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 94,
+ 64,
+ 30,
+ 33
+ ],
+ "levels": [
+ 99,
+ 92,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 3,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 90,
+ 28,
+ 17,
+ 39
+ ],
+ "levels": [
+ 99,
+ 76,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 82,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 91,
+ 37,
+ 29,
+ 29
+ ],
+ "levels": [
+ 99,
+ 90,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 83,
+ "oscMode": 0,
+ "freqCoarse": 4,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 82,
+ 53,
+ 37,
+ 48
+ ],
+ "levels": [
+ 99,
+ 81,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 81,
+ "oscMode": 0,
+ "freqCoarse": 3,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 16,
+ "feedback": 5,
+ "name": "FLUTE 1",
+ "lfoSpeed": 30,
+ "lfoDelay": 23,
+ "lfoPitchModDepth": 8,
+ "lfoAmpModDepth": 13,
+ "lfoPitchModSens": 1,
+ "lfoWaveform": 0,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 61,
+ 67,
+ 70,
+ 65
+ ],
+ "levels": [
+ 93,
+ 89,
+ 98,
+ 0
+ ],
+ "detune": -2,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 98,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 99,
+ 97,
+ 62,
+ 54
+ ],
+ "levels": [
+ 99,
+ 99,
+ 90,
+ 0
+ ],
+ "detune": 4,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 75,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 53,
+ 38,
+ 75,
+ 61
+ ],
+ "levels": [
+ 88,
+ 44,
+ 24,
+ 0
+ ],
+ "detune": -3,
+ "velocitySens": 0,
+ "lfoAmpModSens": 1,
+ "volume": 76,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 61,
+ 25,
+ 25,
+ 60
+ ],
+ "levels": [
+ 99,
+ 99,
+ 97,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 0,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 65,
+ 38,
+ 0,
+ 61
+ ],
+ "levels": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 56,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 99,
+ 64,
+ 98,
+ 61
+ ],
+ "levels": [
+ 99,
+ 67,
+ 52,
+ 0
+ ],
+ "detune": 4,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 83,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 53,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 5,
+ "feedback": 7,
+ "name": "ORCH-CHIME",
+ "lfoSpeed": 30,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 5,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 3,
+ "lfoWaveform": 0,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 34,
+ 42,
+ 71,
+ 34
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 0
+ ],
+ "detune": 5,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 97,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 0
+ ],
+ "detune": 5,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 87,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 80,
+ 49,
+ 17,
+ 30
+ ],
+ "levels": [
+ 99,
+ 95,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 80,
+ 70,
+ 9,
+ 12
+ ],
+ "levels": [
+ 88,
+ 80,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 3,
+ "lfoAmpModSens": 0,
+ "volume": 91,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 57,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 41,
+ 42,
+ 71,
+ 34
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 0
+ ],
+ "detune": 7,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 98,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 0
+ ],
+ "detune": -7,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 75,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 5,
+ "feedback": 7,
+ "name": "TUB BELLS",
+ "lfoSpeed": 35,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 1,
+ "lfoWaveform": 1,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 95,
+ 33,
+ 71,
+ 25
+ ],
+ "levels": [
+ 99,
+ 0,
+ 32,
+ 0
+ ],
+ "detune": 2,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 95,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 98,
+ 12,
+ 71,
+ 28
+ ],
+ "levels": [
+ 99,
+ 0,
+ 32,
+ 0
+ ],
+ "detune": 3,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 78,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 75,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 95,
+ 33,
+ 71,
+ 25
+ ],
+ "levels": [
+ 99,
+ 0,
+ 32,
+ 0
+ ],
+ "detune": -5,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 98,
+ 12,
+ 71,
+ 28
+ ],
+ "levels": [
+ 99,
+ 0,
+ 32,
+ 0
+ ],
+ "detune": -2,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 75,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 75,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 76,
+ 78,
+ 71,
+ 70
+ ],
+ "levels": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 5,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 1,
+ "freqCoarse": 2,
+ "freqFine": 51,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 98,
+ 91,
+ 0,
+ 28
+ ],
+ "levels": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": -7,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 85,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 15,
+ "feedback": 5,
+ "name": "STEEL DRUM",
+ "lfoSpeed": 25,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 10,
+ "lfoAmpModDepth": 99,
+ "lfoPitchModSens": 2,
+ "lfoWaveform": 4,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 99,
+ 40,
+ 33,
+ 38
+ ],
+ "levels": [
+ 99,
+ 92,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 99,
+ 19,
+ 20,
+ 9
+ ],
+ "levels": [
+ 99,
+ 87,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 0,
+ "volume": 64,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 70,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 99,
+ 30,
+ 35,
+ 42
+ ],
+ "levels": [
+ 99,
+ 92,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 3,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 99,
+ 44,
+ 50,
+ 21
+ ],
+ "levels": [
+ 91,
+ 82,
+ 0,
+ 0
+ ],
+ "detune": 7,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 88,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 99,
+ 40,
+ 38,
+ 0
+ ],
+ "levels": [
+ 91,
+ 82,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 64,
+ "oscMode": 0,
+ "freqCoarse": 4,
+ "freqFine": 33,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 99,
+ 49,
+ 28,
+ 12
+ ],
+ "levels": [
+ 91,
+ 82,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 49,
+ "oscMode": 1,
+ "freqCoarse": 2,
+ "freqFine": 60,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 16,
+ "feedback": 7,
+ "name": "TIMPANI",
+ "lfoSpeed": 11,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 16,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 2,
+ "lfoWaveform": 0,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 99,
+ 36,
+ 98,
+ 33
+ ],
+ "levels": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 99,
+ 74,
+ 0,
+ 0
+ ],
+ "levels": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": 3,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 86,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 99,
+ 77,
+ 26,
+ 23
+ ],
+ "levels": [
+ 99,
+ 72,
+ 0,
+ 0
+ ],
+ "detune": -3,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 85,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 36,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 99,
+ 31,
+ 17,
+ 30
+ ],
+ "levels": [
+ 99,
+ 75,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 7,
+ "lfoAmpModSens": 0,
+ "volume": 87,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 75,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 99,
+ 50,
+ 26,
+ 19
+ ],
+ "levels": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 73,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 98,
+ 2,
+ 26,
+ 27
+ ],
+ "levels": [
+ 98,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 73,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 56,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 18,
+ "feedback": 2,
+ "name": "REFS WHISL",
+ "lfoSpeed": 99,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 6,
+ "lfoWaveform": 5,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 60,
+ 39,
+ 28,
+ 49
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 90,
+ "oscMode": 1,
+ "freqCoarse": 3,
+ "freqFine": 32,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 60,
+ 39,
+ 28,
+ 45
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 93,
+ "oscMode": 1,
+ "freqCoarse": 9,
+ "freqFine": 53,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 60,
+ 39,
+ 8,
+ 0
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 66,
+ "oscMode": 1,
+ "freqCoarse": 1,
+ "freqFine": 67,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 94,
+ 68,
+ 24,
+ 55
+ ],
+ "levels": [
+ 96,
+ 89,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 75,
+ "oscMode": 1,
+ "freqCoarse": 7,
+ "freqFine": 82,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "levels": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 64,
+ "oscMode": 1,
+ "freqCoarse": 4,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 94,
+ 56,
+ 24,
+ 55
+ ],
+ "levels": [
+ 96,
+ 78,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 78,
+ "oscMode": 1,
+ "freqCoarse": 5,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 7,
+ "feedback": 7,
+ "name": "VOICE 1",
+ "lfoSpeed": 35,
+ "lfoDelay": 35,
+ "lfoPitchModDepth": 11,
+ "lfoAmpModDepth": 2,
+ "lfoPitchModSens": 4,
+ "lfoWaveform": 0,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 34,
+ 20,
+ 53,
+ 57
+ ],
+ "levels": [
+ 99,
+ 94,
+ 97,
+ 0
+ ],
+ "detune": -7,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 87,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 19,
+ 26,
+ 53,
+ 25
+ ],
+ "levels": [
+ 51,
+ 61,
+ 76,
+ 51
+ ],
+ "detune": 0,
+ "velocitySens": 2,
+ "lfoAmpModSens": 2,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 33,
+ 20,
+ 53,
+ 39
+ ],
+ "levels": [
+ 99,
+ 94,
+ 97,
+ 0
+ ],
+ "detune": 7,
+ "velocitySens": 3,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 72,
+ 19,
+ 41,
+ 12
+ ],
+ "levels": [
+ 48,
+ 58,
+ 20,
+ 9
+ ],
+ "detune": 3,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 2,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 35,
+ 21,
+ 36,
+ 63
+ ],
+ "levels": [
+ 99,
+ 90,
+ 85,
+ 0
+ ],
+ "detune": -1,
+ "velocitySens": 1,
+ "lfoAmpModSens": 0,
+ "volume": 53,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 1,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 99,
+ 72,
+ 48,
+ 17
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 0
+ ],
+ "detune": 1,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 55,
+ "oscMode": 0,
+ "freqCoarse": 5,
+ "freqFine": 2,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 5,
+ "feedback": 7,
+ "name": "TRAIN",
+ "lfoSpeed": 39,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 99,
+ "lfoPitchModSens": 0,
+ "lfoWaveform": 0,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 65,
+ 24,
+ 19,
+ 57
+ ],
+ "levels": [
+ 99,
+ 85,
+ 85,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 64,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 39,
+ 13,
+ 12,
+ 72
+ ],
+ "levels": [
+ 99,
+ 61,
+ 66,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 72,
+ "oscMode": 0,
+ "freqCoarse": 3,
+ "freqFine": 1,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 98,
+ 29,
+ 28,
+ 33
+ ],
+ "levels": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": 2,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 1,
+ "freqCoarse": 22,
+ "freqFine": 57,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 98,
+ 29,
+ 28,
+ 27
+ ],
+ "levels": [
+ 99,
+ 0,
+ 0,
+ 0
+ ],
+ "detune": -2,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 89,
+ "oscMode": 1,
+ "freqCoarse": 10,
+ "freqFine": 99,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 42,
+ 17,
+ 25,
+ 53
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 99
+ ],
+ "detune": 3,
+ "velocitySens": 0,
+ "lfoAmpModSens": 3,
+ "volume": 83,
+ "oscMode": 0,
+ "freqCoarse": 9,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 49,
+ 17,
+ 25,
+ 53
+ ],
+ "levels": [
+ 99,
+ 99,
+ 99,
+ 98
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 5,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ },
+ {
+ "algorithm": 10,
+ "feedback": 0,
+ "name": "TAKE OFF",
+ "lfoSpeed": 65,
+ "lfoDelay": 0,
+ "lfoPitchModDepth": 0,
+ "lfoAmpModDepth": 0,
+ "lfoPitchModSens": 5,
+ "lfoWaveform": 2,
+ "operators": [
+ {
+ "idx": 0,
+ "enabled": true,
+ "rates": [
+ 9,
+ 14,
+ 17,
+ 34
+ ],
+ "levels": [
+ 61,
+ 96,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 4,
+ "freqFine": 1,
+ "pan": 0
+ },
+ {
+ "idx": 1,
+ "enabled": true,
+ "rates": [
+ 82,
+ 80,
+ 19,
+ 14
+ ],
+ "levels": [
+ 80,
+ 95,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 96,
+ "oscMode": 0,
+ "freqCoarse": 1,
+ "freqFine": 0,
+ "pan": 25
+ },
+ {
+ "idx": 2,
+ "enabled": true,
+ "rates": [
+ 76,
+ 35,
+ 99,
+ 11
+ ],
+ "levels": [
+ 67,
+ 38,
+ 73,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 6,
+ "freqFine": 1,
+ "pan": -25
+ },
+ {
+ "idx": 3,
+ "enabled": true,
+ "rates": [
+ 13,
+ 14,
+ 20,
+ 30
+ ],
+ "levels": [
+ 99,
+ 95,
+ 99,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": 0
+ },
+ {
+ "idx": 4,
+ "enabled": true,
+ "rates": [
+ 88,
+ 24,
+ 23,
+ 37
+ ],
+ "levels": [
+ 99,
+ 90,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 96,
+ "oscMode": 0,
+ "freqCoarse": 2,
+ "freqFine": 1,
+ "pan": 25
+ },
+ {
+ "idx": 5,
+ "enabled": true,
+ "rates": [
+ 89,
+ 22,
+ 20,
+ 41
+ ],
+ "levels": [
+ 99,
+ 92,
+ 0,
+ 0
+ ],
+ "detune": 0,
+ "velocitySens": 0,
+ "lfoAmpModSens": 0,
+ "volume": 99,
+ "oscMode": 0,
+ "freqCoarse": 0,
+ "freqFine": 0,
+ "pan": -25
+ }
+ ]
+ }
+]
diff --git a/webapp/src/lib/audio/engines/fm-synth/protocol.ts b/webapp/src/lib/audio/engines/fm-synth/protocol.ts
new file mode 100644
index 0000000..377e93c
--- /dev/null
+++ b/webapp/src/lib/audio/engines/fm-synth/protocol.ts
@@ -0,0 +1,8 @@
+import type { Dx7Patch } from "../../../utils/types";
+
+// Messages envoyés du thread principal (FmSynthEngine) vers le worklet audio.
+export type FmSynthMessageToProcessor =
+ | { type: "loadPatch"; patch: Dx7Patch }
+ | { type: "noteOn"; noteId: string; note: number; velocity: number }
+ | { type: "noteOff"; noteId: string }
+ | { type: "panic" };
diff --git a/webapp/src/lib/audio/engines/index.ts b/webapp/src/lib/audio/engines/index.ts
index d007962..03b299b 100644
--- a/webapp/src/lib/audio/engines/index.ts
+++ b/webapp/src/lib/audio/engines/index.ts
@@ -16,5 +16,11 @@ export { noteNameToFrequency, noteNameToMidi } from "./noteUtils";
export { BasicSynthEngine } from "./basic-synth";
export { SmplrEngine, SOUNDFONT_LIST, type SoundfontName } from "./smplr";
export { UndertaleEngine } from "./undertale";
+export {
+ FmSynthEngine,
+ FM_SYNTH_PRESETS,
+ ALGORITHMS,
+ type Dx7Algorithm,
+} from "./fm-synth";
export { AudioClipEngine } from "./audio-clip";
export { SamplePlayerEngine } from "./sample-player";
diff --git a/webapp/src/lib/audio/engines/sample-player/SamplePlayerEngine.ts b/webapp/src/lib/audio/engines/sample-player/SamplePlayerEngine.ts
index d5c794f..1e8af54 100644
--- a/webapp/src/lib/audio/engines/sample-player/SamplePlayerEngine.ts
+++ b/webapp/src/lib/audio/engines/sample-player/SamplePlayerEngine.ts
@@ -1,4 +1,3 @@
-import { GrainPlayer, getContext, setContext } from "tone";
import type {
SamplePlayerConfig,
InstrumentConfigUpdate,
@@ -6,13 +5,16 @@ import type {
} from "../../../utils/types";
import { BaseEngine } from "../BaseEngine";
import { noteNameToMidi } from "../noteUtils";
-
-const GRAIN_SIZE = 0.1;
-const GRAIN_OVERLAP = 0.05;
+import {
+ createStretchVoice,
+ ensureStretchEngineReady,
+ isStretchEngineReady,
+ type StretchVoice,
+} from "../stretchEngine";
type ActiveVoice =
| { kind: "normal"; source: AudioBufferSourceNode; envelopeNode: GainNode }
- | { kind: "stretch"; player: GrainPlayer; envelopeNode: GainNode };
+ | { kind: "stretch"; voice: StretchVoice; envelopeNode: GainNode };
export class SamplePlayerEngine extends BaseEngine {
readonly type = "samplePlayer";
@@ -37,9 +39,11 @@ export class SamplePlayerEngine extends BaseEngine {
) {
super(audioContext, destination, config);
- if (getContext().rawContext !== audioContext) {
- setContext(audioContext);
- }
+ // Fire-and-forget : en pratique déjà déclenché globalement par
+ // audioBusStore dès la création du contexte partagé, bien avant qu'une
+ // note ne puisse être jouée (chargement des samples sur le CDN prend
+ // largement plus de temps qu'un addModule local).
+ ensureStretchEngineReady(audioContext);
this.sampleId = config.sampleId;
this.rootNoteMidi = noteNameToMidi(config.rootNote);
@@ -94,24 +98,27 @@ export class SamplePlayerEngine extends BaseEngine {
const semitoneOffset = noteNameToMidi(noteName) - this.rootNoteMidi;
- if (this.mode === "stretch") {
- const player = new GrainPlayer({
- url: this.buffer,
- grainSize: GRAIN_SIZE,
- overlap: GRAIN_OVERLAP,
- playbackRate: 1,
- detune: semitoneOffset * 100,
+ // playNote est synchrone (déclenchement temps réel) : si le worklet
+ // SoundTouch n'est pas encore prêt (cas rare, seulement possible dans
+ // les premières dizaines de ms après le boot de l'app), on dégrade
+ // gracieusement vers le pitch "classique" (playbackRate natif) pour
+ // cette note-là — auto-corrigé dès la note suivante.
+ if (this.mode === "stretch" && isStretchEngineReady(this.audioContext)) {
+ const voice = createStretchVoice({
+ context: this.audioContext,
+ buffer: this.buffer,
+ detuneCents: semitoneOffset * 100,
});
- player.connect(envelopeNode);
- player.onstop = () => {
- const voice = this.activeVoices.get(noteId);
- if (voice?.kind === "stretch" && voice.player === player) {
+ voice.connect(envelopeNode);
+ voice.onended = () => {
+ const active = this.activeVoices.get(noteId);
+ if (active?.kind === "stretch" && active.voice === voice) {
this.activeVoices.delete(noteId);
envelopeNode.disconnect();
}
};
- player.start(now);
- this.activeVoices.set(noteId, { kind: "stretch", player, envelopeNode });
+ voice.start(now, 0, this.buffer.duration);
+ this.activeVoices.set(noteId, { kind: "stretch", voice, envelopeNode });
} else {
const source = this.audioContext.createBufferSource();
source.buffer = this.buffer;
@@ -156,8 +163,7 @@ export class SamplePlayerEngine extends BaseEngine {
voice.source.stop();
voice.source.disconnect();
} else {
- voice.player.stop();
- voice.player.dispose();
+ voice.voice.stop();
}
voice.envelopeNode.disconnect();
} catch {
diff --git a/webapp/src/lib/audio/engines/stretchEngine.ts b/webapp/src/lib/audio/engines/stretchEngine.ts
new file mode 100644
index 0000000..a367dce
--- /dev/null
+++ b/webapp/src/lib/audio/engines/stretchEngine.ts
@@ -0,0 +1,156 @@
+import { SoundTouchNode, processOffline } from "@soundtouchjs/audio-worklet";
+import processorUrl from "@soundtouchjs/audio-worklet/processor?url";
+
+// Enregistrement du worklet SoundTouch : une fois par AudioContext (l'app
+// n'en a qu'un seul, partagé — voir audioBusStore.ts qui déclenche cet appel
+// dès la création du contexte). isStretchEngineReady() expose un flag
+// synchrone pour les call sites qui ne peuvent pas attendre une promesse
+// (SamplePlayerEngine.playNote, déclenchement temps réel d'une note).
+const registrations = new WeakMap<
+ BaseAudioContext,
+ { promise: Promise; ready: boolean }
+>();
+
+export function ensureStretchEngineReady(
+ context: BaseAudioContext,
+): Promise {
+ let entry = registrations.get(context);
+ if (!entry) {
+ const promise = SoundTouchNode.register(context, processorUrl).then(() => {
+ entry!.ready = true;
+ });
+ entry = { promise, ready: false };
+ registrations.set(context, entry);
+ }
+ return entry.promise;
+}
+
+export function isStretchEngineReady(context: BaseAudioContext): boolean {
+ return registrations.get(context)?.ready ?? false;
+}
+
+export interface StretchVoiceOptions {
+ context: BaseAudioContext;
+ buffer: AudioBuffer;
+ playbackRate?: number; // défaut 1 (pas d'étirement)
+ detuneCents?: number; // défaut 0 (pas de tune)
+}
+
+export interface StretchVoice {
+ connect(destination: AudioNode): void;
+ start(when: number, offsetSeconds: number, durationSeconds: number): void;
+ stop(): void;
+ onended: (() => void) | null;
+}
+
+// Une "voix" de stretch/pitch, découplant durée et hauteur via SoundTouchJS
+// (WSOLA, tourne dans un AudioWorklet). Couvre deux nodes connectés (source
+// natif + SoundTouchNode) derrière la même interface {connect, start, stop,
+// onended} qu'un moteur de lecture "normal" — seul point d'extension si on
+// change un jour d'algorithme/librairie de stretch.
+export interface RenderStretchedBufferOptions {
+ // Fenêtre native À RENDRE, avec `padFrames` frames de préroll déjà
+ // préfixées en tête (voir buildPaddedNativeWindow dans
+ // stretchRenderCacheStore.ts — construit ce préroll à partir de vrai audio
+ // précédent quand disponible, jamais du silence).
+ buffer: AudioBuffer;
+ playbackRate?: number; // défaut 1
+ detuneCents?: number; // défaut 0
+ padFrames?: number; // défaut 0 : nb de frames natives de préroll à couper après rendu
+}
+
+// Pré-rendu offline (OfflineAudioContext, pas de contrainte temps réel) d'un
+// buffer étiré/tuné — utilisé pour peupler le cache (stretchRenderCacheStore)
+// au lieu de payer le coût de construction d'un SoundTouchNode + la latence
+// de traitement WSOLA à chaque déclenchement de lecture (voir plan, §10).
+//
+// Le pipe WSOLA de SoundTouch a besoin d'accumuler un minimum de signal
+// avant de produire une sortie propre (warm-up structurel — voir
+// soundtouch-processor.js: `toExtract = Math.min(available, frameCount)`,
+// zero-fill tant que le buffer interne n'est pas assez rempli).
+// `processOffline` ne compense jamais ce délai tout seul.
+//
+// Un premier essai avait préfixé l'entrée de silence pur (jusqu'à 1.5s) avant
+// de couper la portion correspondante en sortie — mesuré : le résidu de
+// silence en tête restait constant (~90-100ms) QUELLE QUE SOIT la quantité
+// de silence ajoutée avant. Conclusion : le problème n'est pas un manque de
+// marge, c'est la transition silence -> signal elle-même qui empêche le pipe
+// de bien s'amorcer (il a besoin de signal non trivial, pas de zéros). Fix
+// définitif : le préroll est maintenant construit par l'appelant à partir de
+// VRAI audio (contenu natif précédant réellement le clip dans le sample
+// source, ou à défaut un "mirror" du tout début du clip — jamais du
+// silence), passé ici via `padFrames`. On rend l'ensemble puis on jette
+// exactement la portion de sortie correspondant à ce préroll.
+export async function renderStretchedBuffer(
+ options: RenderStretchedBufferOptions,
+): Promise {
+ const { buffer, playbackRate = 1, detuneCents = 0, padFrames = 0 } = options;
+
+ const rendered = await processOffline({
+ input: buffer,
+ processorUrl,
+ playbackRate,
+ pitch: Math.pow(2, detuneCents / 1200),
+ });
+
+ const desiredNativeLength = buffer.length - padFrames;
+ const desiredLength = Math.ceil(desiredNativeLength / playbackRate);
+ const padOutputFrames = Math.min(
+ Math.round(padFrames / playbackRate),
+ Math.max(0, rendered.length - desiredLength),
+ );
+
+ const trimmed = new AudioBuffer({
+ numberOfChannels: rendered.numberOfChannels,
+ length: desiredLength,
+ sampleRate: rendered.sampleRate,
+ });
+ for (let ch = 0; ch < rendered.numberOfChannels; ch++) {
+ const slice = rendered
+ .getChannelData(ch)
+ .slice(
+ padOutputFrames,
+ padOutputFrames + desiredLength,
+ ) as Float32Array;
+ trimmed.copyToChannel(slice, ch);
+ }
+ return trimmed;
+}
+
+export function createStretchVoice(options: StretchVoiceOptions): StretchVoice {
+ const { context, buffer, playbackRate = 1, detuneCents = 0 } = options;
+
+ const source = context.createBufferSource();
+ source.buffer = buffer;
+ source.playbackRate.value = playbackRate;
+
+ const stNode = new SoundTouchNode({ context });
+ // Miroir obligatoire de source.playbackRate : le processeur s'en sert pour
+ // annuler le changement de pitch induit par le playbackRate de la source,
+ // et n'applique plus que le pitch-shift indépendant demandé ci-dessous.
+ stNode.playbackRate.value = playbackRate;
+ stNode.pitch.value = Math.pow(2, detuneCents / 1200);
+ source.connect(stNode);
+
+ let endedCallback: (() => void) | null = null;
+ source.onended = () => endedCallback?.();
+
+ return {
+ connect: (destination) => stNode.connect(destination),
+ start: (when, offsetSeconds, durationSeconds) =>
+ source.start(when, offsetSeconds, durationSeconds),
+ stop: () => {
+ try {
+ source.stop();
+ } catch {
+ // déjà arrêté
+ }
+ },
+ set onended(cb: (() => void) | null) {
+ endedCallback = cb;
+ },
+ get onended() {
+ return endedCallback;
+ },
+ };
+}
diff --git a/webapp/src/lib/audio/instrumentFactory.ts b/webapp/src/lib/audio/instrumentFactory.ts
index 151406c..e11c8b3 100644
--- a/webapp/src/lib/audio/instrumentFactory.ts
+++ b/webapp/src/lib/audio/instrumentFactory.ts
@@ -2,6 +2,8 @@ import type { InstrumentConfig } from "../utils/types";
import {
AudioClipEngine,
BasicSynthEngine,
+ FM_SYNTH_PRESETS,
+ FmSynthEngine,
SamplePlayerEngine,
SmplrEngine,
UndertaleEngine,
@@ -33,6 +35,9 @@ export function createInstrumentEngine(
case "undertale":
return new UndertaleEngine(audioContext, destination, config);
+ case "fmSynth":
+ return new FmSynthEngine(audioContext, destination, config);
+
case "audioTrack":
return new AudioClipEngine(audioContext, destination, config);
@@ -84,6 +89,13 @@ export function getDefaultConfigForType(
release: 0.3,
};
+ case "fmSynth":
+ return {
+ type: "fmSynth",
+ patch: FM_SYNTH_PRESETS[0],
+ gain: 1,
+ };
+
case "audioTrack":
return {
type: "audioTrack",
diff --git a/webapp/src/lib/canvas/audioClipRenderer.ts b/webapp/src/lib/canvas/audioClipRenderer.ts
index 9d3d2a7..98eb75e 100644
--- a/webapp/src/lib/canvas/audioClipRenderer.ts
+++ b/webapp/src/lib/canvas/audioClipRenderer.ts
@@ -18,8 +18,13 @@ export interface ClipRenderData {
id: string;
x: number;
w: number;
- startOffset: number;
- sampleDurationTicks: number;
+ // Fenêtre à afficher dans waveformData, en secondes NATIVES du sample
+ // (déjà résolue par l'appelant via computeClipPlaybackParams — tient
+ // compte du stretch BPM-synchronisé, contrairement à startOffset/w bruts
+ // qui ne représentent la fenêtre native que pour un clip non stretché).
+ waveformOffsetSeconds: number;
+ waveformDurationSeconds: number;
+ sampleDurationSeconds: number;
waveformData: number[] | null;
color: string;
name: string;
@@ -28,7 +33,6 @@ export interface ClipRenderData {
isResizing: boolean;
previewX?: number;
previewW?: number;
- previewStartOffset?: number;
}
// Géométrie minimale pour le hit-testing (mousedown/mousemove) : un
@@ -182,7 +186,6 @@ export class AudioClipRenderer {
const clipX = clip.previewX ?? clip.x;
const clipW = clip.previewW ?? clip.w;
- const clipStartOffset = clip.previewStartOffset ?? clip.startOffset;
const px = clipX * config.colWidth;
const pw = clipW * config.colWidth;
@@ -202,7 +205,7 @@ export class AudioClipRenderer {
ctx.clip();
if (clip.waveformData) {
- this.drawClipWaveform(clip, clipStartOffset, clipW, { px, py, pw, ph });
+ this.drawClipWaveform(clip, { px, py, pw, ph });
} else {
this.drawLoadingPlaceholder(px, py, pw, ph);
}
@@ -232,23 +235,38 @@ export class AudioClipRenderer {
// Porte la logique de dessin de barres de l'ancien WaveformCanvas.vue :
// moyenne d'amplitude par barre sur la portion de `waveformData` (1000
- // points, fixe par sample) visible compte tenu de startOffset/largeur.
+ // points, fixe par sample) visible. La fenêtre (offset/durée) est déjà
+ // résolue par l'appelant en secondes NATIVES du sample (via
+ // computeClipPlaybackParams), donc correcte aussi bien pour un clip trimmé
+ // que pour un clip stretché (BPM-synchronisé) — sans ça, la waveform d'un
+ // clip stretché afficherait la mauvaise portion du sample, sans rapport
+ // avec ce qui est réellement audible.
private drawClipWaveform(
clip: ClipRenderData,
- startOffset: number,
- clipWidthTicks: number,
rect: { px: number; py: number; pw: number; ph: number },
) {
- const { ctx, config } = this;
- const { waveformData, color, sampleDurationTicks } = clip;
+ const { ctx } = this;
+ const {
+ waveformData,
+ color,
+ sampleDurationSeconds,
+ waveformOffsetSeconds,
+ waveformDurationSeconds,
+ } = clip;
const { px, py, pw, ph } = rect;
if (!waveformData || waveformData.length === 0) return;
const startRatio =
- sampleDurationTicks > 0 ? startOffset / sampleDurationTicks : 0;
+ sampleDurationSeconds > 0
+ ? waveformOffsetSeconds / sampleDurationSeconds
+ : 0;
const endRatio =
- sampleDurationTicks > 0
- ? Math.min(1, (startOffset + clipWidthTicks) / sampleDurationTicks)
+ sampleDurationSeconds > 0
+ ? Math.min(
+ 1,
+ (waveformOffsetSeconds + waveformDurationSeconds) /
+ sampleDurationSeconds,
+ )
: 1;
const startIndex = Math.floor(startRatio * waveformData.length);
@@ -259,9 +277,20 @@ export class AudioClipRenderer {
const midY = py + ph / 2;
const barStep = BAR_WIDTH + BAR_GAP;
+ // Portion de la fenêtre demandée qui tombe réellement dans le sample —
+ // si elle dépasse la fin du fichier (ex. trim au-delà du contenu
+ // disponible), les barres s'arrêtent avant la fin de la box plutôt que
+ // d'afficher du silence comme si c'était du contenu réel.
+ const availableDurationSeconds = Math.max(
+ 0,
+ Math.min(
+ waveformDurationSeconds,
+ sampleDurationSeconds - waveformOffsetSeconds,
+ ),
+ );
const audioPixelWidth =
- sampleDurationTicks > 0
- ? Math.min(pw, (sampleDurationTicks - startOffset) * config.colWidth)
+ waveformDurationSeconds > 0
+ ? pw * (availableDurationSeconds / waveformDurationSeconds)
: pw;
const barCount = Math.max(0, Math.floor(audioPixelWidth / barStep));
if (barCount === 0) return;
diff --git a/webapp/src/lib/utils/debounce.ts b/webapp/src/lib/utils/debounce.ts
new file mode 100644
index 0000000..ea3505f
--- /dev/null
+++ b/webapp/src/lib/utils/debounce.ts
@@ -0,0 +1,23 @@
+// Debounce générique : `schedule()` (re)lance le timer, `flush()` exécute
+// immédiatement s'il y a un timer en attente (ex. sur beforeunload, pour ne
+// rien perdre si l'onglet se ferme juste après un schedule()).
+export function createDebouncer(
+ fn: () => void,
+ ms: number,
+): { schedule: () => void; flush: () => void } {
+ let id: ReturnType | null = null;
+ const schedule = (): void => {
+ if (id) clearTimeout(id);
+ id = setTimeout(() => {
+ id = null;
+ fn();
+ }, ms);
+ };
+ const flush = (): void => {
+ if (!id) return;
+ clearTimeout(id);
+ id = null;
+ fn();
+ };
+ return { schedule, flush };
+}
diff --git a/webapp/src/lib/utils/types.ts b/webapp/src/lib/utils/types.ts
index 720b13c..1fc5f9a 100644
--- a/webapp/src/lib/utils/types.ts
+++ b/webapp/src/lib/utils/types.ts
@@ -111,6 +111,7 @@ export type InstrumentType =
| "elementarySynth"
| "smplr"
| "undertale"
+ | "fmSynth"
| "audioTrack"
| "samplePlayer";
@@ -153,6 +154,43 @@ export interface AudioTrackConfig {
gain?: number;
}
+// Un opérateur FM (style DX7) : forme brute/persistée, sans les valeurs
+// dérivées (outputLevel/ampL/ampR/freqRatio) qui sont recalculées au
+// chargement du patch — voir buildRuntimeParams (dsp/voiceDx7.ts).
+export interface Dx7Operator {
+ idx: number;
+ enabled: boolean;
+ rates: [number, number, number, number]; // vitesses EG (0-99)
+ levels: [number, number, number, number]; // niveaux EG (0-99)
+ detune: number; // -7 à 7
+ velocitySens: number; // 0-7
+ lfoAmpModSens: number; // 0-3
+ volume: number; // 0-99, niveau de sortie brut
+ oscMode: 0 | 1; // 0 = ratio de fréquence, 1 = fréquence fixe
+ freqCoarse: number;
+ freqFine: number;
+ pan: number; // -50 à 50
+}
+
+export interface Dx7Patch {
+ name: string;
+ algorithm: number; // 1-32
+ feedback: number; // 0-7
+ lfoSpeed: number;
+ lfoDelay: number;
+ lfoPitchModDepth: number;
+ lfoAmpModDepth: number;
+ lfoPitchModSens: number; // 0-7
+ lfoWaveform: number; // 0-5
+ operators: Dx7Operator[]; // 6 opérateurs
+}
+
+export interface FmSynthConfig {
+ type: "fmSynth";
+ patch: Dx7Patch;
+ gain?: number;
+}
+
export type SamplePlaybackMode = "normal" | "stretch";
export interface SamplePlayerConfig {
@@ -172,6 +210,7 @@ export type InstrumentConfig =
| SmplrConfig
| ElementarySynthConfig
| UndertaleConfig
+ | FmSynthConfig
| AudioTrackConfig
| SamplePlayerConfig;
@@ -185,6 +224,7 @@ export interface InstrumentConfigUpdate {
decay?: number;
sustain?: number;
release?: number;
+ patch?: Dx7Patch;
sampleId?: string | null;
rootNote?: NoteName;
mode?: SamplePlaybackMode;
@@ -196,7 +236,26 @@ export interface AudioClip {
x: number;
w: number;
startOffset: number;
-}
+
+ // Sticky : une fois vrai, reste vrai pour toujours (même si l'outil
+ // toolbar repasse en "edit" et qu'on redimensionne encore ce clip) — voir
+ // applyResizeStretch (lib/audio/clipStretch.ts).
+ stretched?: boolean;
+ // Ancre du ratio de stretch, réécrite à chaque resize en mode stretch :
+ // w (ticks) et tempo (BPM) juste avant ce resize. Permet de recalculer le
+ // playbackRate à tout moment (y compris après un changement de BPM sans
+ // nouveau resize) — voir computeClipPlaybackParams.
+ stretchReferenceTicks?: number;
+ stretchReferenceTempo?: number;
+
+ // Tune/detune : vrai pitch-shift indépendant de la durée (via GrainPlayer).
+ semitones?: number; // -12..+12
+ cents?: number; // fin, dans le demi-ton courant
+}
+
+// Nom dédié, distinct de SamplePlaybackMode (SamplePlayerConfig.mode) qui est
+// un concept différent (lecture de notes MIDI via l'instrument sampler).
+export type ClipResizeMode = "edit" | "stretch";
export interface AudioSample {
id: string;
diff --git a/webapp/src/stores/adsStore.ts b/webapp/src/stores/adsStore.ts
new file mode 100644
index 0000000..f79d90d
--- /dev/null
+++ b/webapp/src/stores/adsStore.ts
@@ -0,0 +1,33 @@
+import { defineStore } from "pinia";
+import { ref } from "vue";
+
+const STORAGE_KEY = "display-ads";
+
+export const useAdsStore = defineStore("ads", () => {
+ const isEnabled = ref(localStorage.getItem(STORAGE_KEY) !== "false");
+
+ const enable = () => {
+ isEnabled.value = true;
+ localStorage.setItem(STORAGE_KEY, "true");
+ };
+
+ const disable = () => {
+ isEnabled.value = false;
+ localStorage.setItem(STORAGE_KEY, "false");
+ };
+
+ const toggle = () => {
+ if (isEnabled.value) {
+ disable();
+ } else {
+ enable();
+ }
+ };
+
+ return {
+ isEnabled,
+ enable,
+ disable,
+ toggle,
+ };
+});
diff --git a/webapp/src/stores/audioBusStore.ts b/webapp/src/stores/audioBusStore.ts
index fcf1cc2..557fc44 100644
--- a/webapp/src/stores/audioBusStore.ts
+++ b/webapp/src/stores/audioBusStore.ts
@@ -6,6 +6,7 @@ import { EffectChain } from "../lib/audio/effects";
import { applyAutomationToChannel } from "../lib/audio/automation";
import type { AutomationTarget, TimeSignature } from "../lib/utils/types";
import type { TrackChannel } from "../lib/audio/automationTypes";
+import { ensureStretchEngineReady } from "../lib/audio/engines/stretchEngine";
export const useAudioBusStore = defineStore("audioBusStore", () => {
const sequencerStore = useSequencerStore();
@@ -22,6 +23,12 @@ export const useAudioBusStore = defineStore("audioBusStore", () => {
const audioContext = new (
window.AudioContext || (window as any).webkitAudioContext
)();
+
+ // Kick-off précoce (fire-and-forget) : enregistre le worklet SoundTouch dès
+ // que possible, pour qu'il soit prêt bien avant qu'un utilisateur déclenche
+ // une note/un clip stretché (voir stretchEngine.ts).
+ ensureStretchEngineReady(audioContext);
+
const inputBus = audioContext.createGain();
const masterGain = audioContext.createGain();
const effectsOutput = audioContext.createGain();
diff --git a/webapp/src/stores/dawLoadingStore.ts b/webapp/src/stores/dawLoadingStore.ts
index a59ca3a..22ba1d8 100644
--- a/webapp/src/stores/dawLoadingStore.ts
+++ b/webapp/src/stores/dawLoadingStore.ts
@@ -5,13 +5,14 @@ import { useTrackAudioStore } from "./trackAudioStore";
import { useAudioLibraryStore } from "./audioLibraryStore";
import { useElementaryStore } from "./elementaryStore";
import { useAudioBusStore } from "./audioBusStore";
+import { useStretchRenderCacheStore } from "./stretchRenderCacheStore";
type TaskStatus = "pending" | "loading" | "complete" | "error";
type PhaseStatus = "pending" | "loading" | "complete";
interface LoadingTask {
id: string;
- type: "engine" | "instrument" | "sample";
+ type: "engine" | "instrument" | "sample" | "stretch";
label: string;
status: TaskStatus;
trackId?: string;
@@ -154,6 +155,33 @@ export const useDawLoadingStore = defineStore("dawLoading", () => {
tasks: sampleTasks,
});
}
+
+ // Phase 4: Pré-calcul des clips audio étirés (mode stretch) — rend
+ // disponible un cache chaud dès l'ouverture du projet, pour un premier
+ // play instantané (voir stretchRenderCacheStore.ts).
+ const stretchTasks: LoadingTask[] = [];
+ for (const track of project.tracks) {
+ if (track.instrument.type !== "audioTrack" || !track.clips) continue;
+ for (const clip of track.clips) {
+ if (!clip.stretched) continue;
+ const sample = audioLibraryStore.getSample(clip.sampleId);
+ stretchTasks.push({
+ id: clip.id,
+ type: "stretch",
+ label: `Étirement — ${sample?.name || sample?.filename || clip.sampleId}`,
+ status: "pending",
+ });
+ }
+ }
+
+ if (stretchTasks.length > 0) {
+ phases.value.push({
+ id: "stretch-prerender",
+ name: "Pré-calcul des boucles étirées",
+ status: "pending",
+ tasks: stretchTasks,
+ });
+ }
}
async function executeInitPhase(): Promise {
@@ -239,6 +267,60 @@ export const useDawLoadingStore = defineStore("dawLoading", () => {
phase.status = "complete";
}
+ async function executeStretchPrerenderPhase(
+ project: TimelineProject,
+ ): Promise {
+ const phase = phases.value.find((p) => p.id === "stretch-prerender");
+ if (!phase) return;
+
+ phase.status = "loading";
+ const audioLibraryStore = useAudioLibraryStore();
+ const audioBusStore = useAudioBusStore();
+ const stretchRenderCacheStore = useStretchRenderCacheStore();
+
+ // Rendus indépendants les uns des autres (samples déjà en cache mémoire
+ // depuis la phase précédente) — en parallèle, comme executeInstrumentsPhase
+ // ci-dessus, plutôt que séquentiel comme executeSamplesPhase (qui limite
+ // volontairement les requêtes réseau concurrentes ; ici il n'y en a pas).
+ const renderTasks: Promise[] = [];
+
+ for (const track of project.tracks) {
+ if (track.instrument.type !== "audioTrack" || !track.clips) continue;
+ for (const clip of track.clips) {
+ if (!clip.stretched) continue;
+ const task = phase.tasks.find((t) => t.id === clip.id);
+ if (!task) continue;
+
+ task.status = "loading";
+ renderTasks.push(
+ (async () => {
+ try {
+ const buffer = await audioLibraryStore.loadSample(clip.sampleId);
+ if (buffer) {
+ await stretchRenderCacheStore.ensureStretchedClipCached(
+ clip,
+ buffer,
+ project.tempo,
+ audioBusStore.audioContext,
+ );
+ }
+ task.status = "complete";
+ } catch (e) {
+ console.error(
+ `[DawLoading] Failed to prerender stretched clip ${clip.id}:`,
+ e,
+ );
+ task.status = "error";
+ }
+ })(),
+ );
+ }
+ }
+
+ await Promise.all(renderTasks);
+ phase.status = "complete";
+ }
+
async function preloadProject(project: TimelineProject): Promise {
if (isPreloading.value) return;
@@ -265,6 +347,9 @@ export const useDawLoadingStore = defineStore("dawLoading", () => {
// Phase 3: Samples
await executeSamplesPhase();
+ // Phase 4: Pré-calcul des clips étirés
+ await executeStretchPrerenderPhase(project);
+
isComplete.value = true;
} catch (e) {
error.value = e instanceof Error ? e.message : "Erreur de chargement";
diff --git a/webapp/src/stores/projectStore.ts b/webapp/src/stores/projectStore.ts
index 0cd53c0..667ffdc 100644
--- a/webapp/src/stores/projectStore.ts
+++ b/webapp/src/stores/projectStore.ts
@@ -8,6 +8,7 @@ import type {
TrashedProjectListItem,
} from "../lib/utils/types";
import type { useTimelineStore } from "./timelineStore";
+import { useAudioLibraryStore } from "./audioLibraryStore";
const stripTimestamps = (obj: any): any => {
return JSON.parse(
@@ -24,6 +25,7 @@ export const useProjectStore = defineStore("project", () => {
const hasUnsavedChanges = ref(false);
const lastSavedState = ref(null);
const isReadOnly = ref(false);
+ const isPublic = ref(false);
const currentProjectOwner = ref<{
id: string;
firstName: string;
@@ -208,6 +210,31 @@ export const useProjectStore = defineStore("project", () => {
}
};
+ const updateVisibility = async (
+ id: string,
+ nextIsPublic: boolean,
+ ): Promise<{ success: boolean; error?: string }> => {
+ try {
+ const { error } = await apiClient.patch<{ body: { isPublic: boolean } }>(
+ `/app/projects/${id}/visibility`,
+ { isPublic: nextIsPublic },
+ );
+
+ if (error) {
+ throw new Error(error);
+ }
+
+ isPublic.value = nextIsPublic;
+
+ return { success: true };
+ } catch {
+ return {
+ success: false,
+ error: "Erreur lors du changement de visibilité.",
+ };
+ }
+ };
+
const addFavorite = async (
projectId: string,
): Promise<{ success: boolean; error?: string }> => {
@@ -327,11 +354,15 @@ export const useProjectStore = defineStore("project", () => {
if (isCompatibleFormat && projectWrapper?.data) {
const timelineData = projectWrapper.data as TimelineProject;
timelineStore.loadProjectData(timelineData);
+ if (timelineData.usedSamples) {
+ useAudioLibraryStore().restoreSamples(timelineData.usedSamples);
+ }
currentProjectId.value = projectId;
hasUnsavedChanges.value = false;
lastSavedState.value = JSON.stringify(stripTimestamps(timelineData));
isReadOnly.value = result.data.isOwned === false;
+ isPublic.value = result.data.isPublic ?? false;
currentProjectOwner.value = result.data.owner ?? null;
isLoading.value = false;
@@ -364,6 +395,7 @@ export const useProjectStore = defineStore("project", () => {
hasUnsavedChanges.value = false;
lastSavedState.value = null;
isReadOnly.value = false;
+ isPublic.value = false;
currentProjectOwner.value = null;
};
@@ -394,6 +426,7 @@ export const useProjectStore = defineStore("project", () => {
currentProjectId,
hasUnsavedChanges,
isReadOnly,
+ isPublic,
currentProjectOwner,
lastSavedAt,
lastExportedAt,
@@ -406,6 +439,7 @@ export const useProjectStore = defineStore("project", () => {
getPublicProjects,
getFavoriteProjects,
cloneProject,
+ updateVisibility,
addFavorite,
removeFavorite,
deleteProject,
diff --git a/webapp/src/stores/stretchRenderCacheStore.ts b/webapp/src/stores/stretchRenderCacheStore.ts
new file mode 100644
index 0000000..22ebcf7
--- /dev/null
+++ b/webapp/src/stores/stretchRenderCacheStore.ts
@@ -0,0 +1,389 @@
+import { defineStore } from "pinia";
+import { openDB, type IDBPDatabase } from "idb";
+import type { AudioClip } from "../lib/utils/types";
+import {
+ computeClipPlaybackParams,
+ type ClipPlaybackParams,
+} from "../lib/audio/clipStretch";
+import { renderStretchedBuffer } from "../lib/audio/engines/stretchEngine";
+import { createDebouncer } from "../lib/utils/debounce";
+import { useTimelineStore } from "./timelineStore";
+import { useAudioLibraryStore } from "./audioLibraryStore";
+import { useAudioBusStore } from "./audioBusStore";
+
+export function buildStretchCacheKey(
+ sampleId: string,
+ params: ClipPlaybackParams,
+): string {
+ return [
+ sampleId,
+ params.offsetSeconds.toFixed(3),
+ params.durationSeconds.toFixed(3),
+ params.playbackRate.toFixed(4),
+ params.detuneCents.toFixed(1),
+ ].join(":");
+}
+
+// Dédup des rendus concurrents pour une même clé (ex. deux déclenchements
+// rapprochés avant que le premier rendu ne soit en cache).
+const inFlight = new Map>();
+
+// Cache mémoire des AudioBuffer déjà reconstruits — même rôle que
+// `buffers` dans audioLibraryStore pour les samples natifs : une fois un
+// rendu chargé une première fois (depuis IndexedDB ou fraîchement calculé),
+// il reste résident en mémoire pour le reste de la session. Sans ça, `get()`
+// relit et redésérialise le PCM complet depuis IndexedDB à CHAQUE play — un
+// coût qui grandit avec la durée du rendu (plus un clip est étiré en durée,
+// plus le buffer stocké est gros), exactement le symptôme "plus j'étire, plus
+// c'est lent" observé alors que le cache est déjà chaud.
+const decodedBuffers = new Map();
+
+// Marge de préroll fournie au moteur de stretch avant le point de départ
+// réel du clip — voir buildPaddedNativeWindow ci-dessous.
+const NATIVE_PAD_SECONDS = 0.4;
+
+// Extrait la fenêtre native [offsetSeconds, offsetSeconds+durationSeconds[ du
+// buffer source, préfixée d'un préroll de `NATIVE_PAD_SECONDS` construit à
+// partir de VRAI audio (jamais du silence) : le pipe WSOLA de SoundTouch a
+// besoin de signal non trivial pour s'amorcer correctement — du silence en
+// tête laisse un artefact fixe (~90-100ms, mesuré) quelle que soit sa durée,
+// voir stretchEngine.ts. Le préroll vient du contenu natif précédant
+// réellement le clip dans le sample source quand il y en a assez ; sinon
+// (clip trimmé depuis le tout début du sample), on complète par un "mirror"
+// (les toutes premières frames du clip lui-même, inversées) — jamais parfait
+// musicalement mais toujours du signal réel, pas des zéros.
+function buildPaddedNativeWindow(
+ audioContext: BaseAudioContext,
+ buffer: AudioBuffer,
+ offsetSeconds: number,
+ durationSeconds: number,
+): { windowed: AudioBuffer; padFrames: number } {
+ const { sampleRate } = buffer;
+ const clipStartFrame = Math.max(0, Math.floor(offsetSeconds * sampleRate));
+ const clipFrameCount = Math.max(
+ 1,
+ Math.min(
+ Math.round(durationSeconds * sampleRate),
+ buffer.length - clipStartFrame,
+ ),
+ );
+
+ const padFrames = Math.ceil(NATIVE_PAD_SECONDS * sampleRate);
+ const realPadFrames = Math.min(padFrames, clipStartFrame);
+ const mirrorFrames = padFrames - realPadFrames;
+ const coreStartFrame = clipStartFrame - realPadFrames;
+ const coreFrameCount = realPadFrames + clipFrameCount;
+
+ const windowed = audioContext.createBuffer(
+ buffer.numberOfChannels,
+ padFrames + clipFrameCount,
+ sampleRate,
+ );
+
+ for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
+ const core = buffer
+ .getChannelData(ch)
+ .slice(coreStartFrame, coreStartFrame + coreFrameCount);
+
+ if (mirrorFrames > 0) {
+ const mirror = core.slice(0, mirrorFrames).reverse();
+ windowed.copyToChannel(mirror as Float32Array, ch, 0);
+ windowed.copyToChannel(
+ core as Float32Array,
+ ch,
+ mirrorFrames,
+ );
+ } else {
+ windowed.copyToChannel(core as Float32Array, ch, 0);
+ }
+ }
+
+ return { windowed, padFrames };
+}
+
+interface CachedStretchRender {
+ key: string;
+ channels: Float32Array[];
+ sampleRate: number;
+ length: number;
+ numberOfChannels: number;
+ cachedAt: number;
+ size: number;
+}
+
+const DB_NAME = "bloop-stretch-cache";
+// v2 : invalide les rendus mis en cache par la v1, qui étirait le sample
+// entier (position 0) au lieu de la fenêtre réelle du clip (startOffset).
+// v3 : invalide les rendus v2, qui contenaient un silence de démarrage
+// (warm-up du pipe WSOLA jamais compensé par processOffline — voir
+// renderStretchedBuffer dans stretchEngine.ts).
+// v4 : diagnostic (marge de silence 250ms -> 1.5s), a confirmé que le
+// silence en tête n'est pas une question de marge.
+// v5 : le préroll est désormais du vrai audio (contenu natif précédant le
+// clip, ou mirror du début du clip) au lieu de silence — voir
+// buildPaddedNativeWindow. Contenu dérivé/régénérable, pas de perte à tout
+// recalculer.
+const DB_VERSION = 5;
+const STORE_NAME = "renders";
+// Contenu dérivé/regénérable (contrairement à sampleCacheStore, la source de
+// vérité) : pas besoin de partager son budget de 500MB, un plafond plus
+// modeste suffit largement pour quelques dizaines de clips étirés en cache.
+const MAX_CACHE_SIZE = 200 * 1024 * 1024; // 200MB
+
+// Un changement de BPM ou un resize en mode stretch change le playbackRate
+// d'un clip stretché — les rendus déjà en cache deviennent obsolètes (cache
+// miss naturel, la clé inclut le playbackRate). recomputeAllStretchedClips
+// relance un pré-calcul en fond pour tous les clips stretchés du projet dès
+// que ça se stabilise (debounced), pour qu'un premier play soit déjà rapide
+// plutôt que de dépendre du fallback "cache miss → lecture live + re-cache"
+// de trackAudioStore. Le timer vit ici (module-level, comme `inFlight`/
+// `decodedBuffers` ci-dessus) plutôt que dans un composable Vue, pour être
+// appelable depuis n'importe où (watcher de tempo, ou juste après un resize
+// dans AudioClipRow.vue) sans prop-drilling ni second watcher redondant.
+const RECOMPUTE_DEBOUNCE_MS = 600;
+
+async function recomputeAllStretchedClips(): Promise {
+ const timelineStore = useTimelineStore();
+ const audioLibraryStore = useAudioLibraryStore();
+ const audioBusStore = useAudioBusStore();
+ const stretchRenderCacheStore = useStretchRenderCacheStore();
+
+ const tempo = timelineStore.tempo;
+ for (const track of timelineStore.project.tracks) {
+ if (track.instrument.type !== "audioTrack" || !track.clips) continue;
+ for (const clip of track.clips) {
+ if (!clip.stretched) continue;
+ const buffer = await audioLibraryStore.loadSample(clip.sampleId);
+ if (!buffer) continue;
+ stretchRenderCacheStore.ensureStretchedClipCached(
+ clip,
+ buffer,
+ tempo,
+ audioBusStore.audioContext,
+ );
+ }
+ }
+}
+
+const recomputeDebouncer = createDebouncer(
+ recomputeAllStretchedClips,
+ RECOMPUTE_DEBOUNCE_MS,
+);
+
+export const useStretchRenderCacheStore = defineStore("stretchRenderCache", {
+ state: () => ({
+ db: null as IDBPDatabase | null,
+ isInitialized: false,
+ cacheSize: 0,
+ // Clips actuellement en cours de re-rendu en fond (BPM changé, tune
+ // changé, ou premier stretch jamais mis en cache) — consommé par
+ // useAudioClipCanvas.ts pour afficher le placeholder de chargement
+ // existant pendant le recalcul.
+ pendingClipIds: new Set() as Set,
+ }),
+
+ actions: {
+ async initialize(): Promise {
+ if (this.isInitialized) return;
+
+ this.db = await openDB(DB_NAME, DB_VERSION, {
+ upgrade(db) {
+ // Recrée le store à chaque montée de version : contenu
+ // dérivé/regénérable, plus simple et plus sûr que migrer les
+ // entrées existantes en place.
+ if (db.objectStoreNames.contains(STORE_NAME)) {
+ db.deleteObjectStore(STORE_NAME);
+ }
+ const store = db.createObjectStore(STORE_NAME, { keyPath: "key" });
+ store.createIndex("cachedAt", "cachedAt");
+ },
+ });
+
+ await this.calculateCacheSize();
+ this.isInitialized = true;
+ },
+
+ async get(
+ key: string,
+ audioContext: BaseAudioContext,
+ ): Promise {
+ const resident = decodedBuffers.get(key);
+ if (resident) return resident;
+
+ if (!this.isInitialized) await this.initialize();
+ if (!this.db) return null;
+
+ const cached = (await this.db.get(STORE_NAME, key)) as
+ CachedStretchRender | undefined;
+ if (!cached) return null;
+
+ const buffer = audioContext.createBuffer(
+ cached.numberOfChannels,
+ cached.length,
+ cached.sampleRate,
+ );
+ for (let ch = 0; ch < cached.numberOfChannels; ch++) {
+ // IndexedDB (structured clone) rend un Float32Array
+ // plutôt que le Float32Array attendu par copyToChannel —
+ // même bibliothèque de types stricte que overdrive.ts/exportEncoders.ts.
+ buffer.copyToChannel(
+ cached.channels[ch] as Float32Array,
+ ch,
+ );
+ }
+ decodedBuffers.set(key, buffer);
+
+ // Rafraîchit le timestamp LRU en fond, sans jamais retarder le retour
+ // du buffer : ce `get` est sur le chemin chaud du play (déclenché
+ // just-in-time, sans lookahead — cf. useTimelinePlaybackEngine), une
+ // écriture IndexedDB bloquante ici réintroduirait la latence qu'on a
+ // justement construit ce cache pour éliminer.
+ cached.cachedAt = Date.now();
+ this.db.put(STORE_NAME, cached).catch((e) => {
+ console.error("[StretchCache] Failed to refresh LRU timestamp:", e);
+ });
+
+ return buffer;
+ },
+
+ async set(key: string, buffer: AudioBuffer): Promise {
+ if (!this.isInitialized) await this.initialize();
+ if (!this.db) return;
+
+ const channels: Float32Array[] = [];
+ for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
+ channels.push(buffer.getChannelData(ch).slice());
+ }
+ const size = channels.reduce((sum, c) => sum + c.byteLength, 0);
+
+ await this.ensureSpace(size);
+
+ const entry: CachedStretchRender = {
+ key,
+ channels,
+ sampleRate: buffer.sampleRate,
+ length: buffer.length,
+ numberOfChannels: buffer.numberOfChannels,
+ cachedAt: Date.now(),
+ size,
+ };
+ await this.db.put(STORE_NAME, entry);
+ this.cacheSize += size;
+ // Le rendu vient d'être produit en mémoire (renderStretchedBuffer) :
+ // pas besoin d'attendre un futur `get()` pour le rendre résident, le
+ // premier play qui suivra doit déjà le trouver instantanément.
+ decodedBuffers.set(key, buffer);
+ },
+
+ async ensureSpace(needed: number): Promise {
+ if (!this.db) return;
+
+ while (this.cacheSize + needed > MAX_CACHE_SIZE) {
+ const tx = this.db.transaction(STORE_NAME, "readwrite");
+ const index = tx.store.index("cachedAt");
+ const cursor = await index.openCursor();
+
+ if (cursor) {
+ const entry = cursor.value as CachedStretchRender;
+ this.cacheSize -= entry.size;
+ decodedBuffers.delete(entry.key);
+ await cursor.delete();
+ } else {
+ break;
+ }
+ }
+ },
+
+ async calculateCacheSize(): Promise {
+ if (!this.db) return;
+
+ let total = 0;
+ const all = (await this.db.getAll(STORE_NAME)) as CachedStretchRender[];
+ for (const entry of all) {
+ total += entry.size;
+ }
+ this.cacheSize = total;
+ },
+
+ async clear(): Promise {
+ if (!this.db) return;
+ await this.db.clear(STORE_NAME);
+ this.cacheSize = 0;
+ decodedBuffers.clear();
+ },
+
+ async has(key: string): Promise {
+ if (!this.isInitialized) await this.initialize();
+ if (!this.db) return false;
+ const existing = await this.db.getKey(STORE_NAME, key);
+ return existing !== undefined;
+ },
+
+ // Récupère la version pré-calculée (déjà étirée/tunée) d'un clip
+ // stretché, pour lecture via le chemin natif rapide (AudioClipEngine
+ // .playClip) — sans jamais construire de SoundTouchNode au
+ // déclenchement. null si non stretché/tuné ou si pas encore en cache.
+ async getCachedStretchedClip(
+ clip: AudioClip,
+ tempo: number,
+ audioContext: BaseAudioContext,
+ ): Promise {
+ const params = computeClipPlaybackParams(clip, tempo);
+ if (!params.needsStretchEngine) return null;
+
+ const key = buildStretchCacheKey(clip.sampleId, params);
+ return this.get(key, audioContext);
+ },
+
+ // Pré-calcule (si besoin) et met en cache le rendu étiré/tuné d'un clip,
+ // en fond — jamais bloquant pour la lecture en cours. Appelé depuis le
+ // chargement de projet (dawLoadingStore), le changement de BPM en
+ // session (useStretchRecompute) et en cache-miss pendant la lecture
+ // (trackAudioStore).
+ async ensureStretchedClipCached(
+ clip: AudioClip,
+ buffer: AudioBuffer,
+ tempo: number,
+ audioContext: BaseAudioContext,
+ ): Promise {
+ const params = computeClipPlaybackParams(clip, tempo);
+ if (!params.needsStretchEngine) return;
+
+ const key = buildStretchCacheKey(clip.sampleId, params);
+ if (await this.has(key)) return;
+
+ const pending = inFlight.get(key);
+ if (pending) return pending;
+
+ const task = (async () => {
+ this.pendingClipIds.add(clip.id);
+ try {
+ const { windowed, padFrames } = buildPaddedNativeWindow(
+ audioContext,
+ buffer,
+ params.offsetSeconds,
+ params.durationSeconds,
+ );
+ const rendered = await renderStretchedBuffer({
+ buffer: windowed,
+ playbackRate: params.playbackRate,
+ detuneCents: params.detuneCents,
+ padFrames,
+ });
+ await this.set(key, rendered);
+ } finally {
+ this.pendingClipIds.delete(clip.id);
+ inFlight.delete(key);
+ }
+ })();
+ inFlight.set(key, task);
+ return task;
+ },
+
+ // Déclenche (debounced) un recalcul en fond de tous les clips stretchés
+ // du projet — voir recomputeAllStretchedClips ci-dessus pour le pourquoi.
+ scheduleRecompute(): void {
+ recomputeDebouncer.schedule();
+ },
+ },
+});
diff --git a/webapp/src/stores/timelineStore.ts b/webapp/src/stores/timelineStore.ts
index bc99be7..22d30f8 100644
--- a/webapp/src/stores/timelineStore.ts
+++ b/webapp/src/stores/timelineStore.ts
@@ -6,6 +6,7 @@ import type {
MidiNote,
AudioClip,
AudioSample,
+ ClipResizeMode,
InstrumentConfig,
InstrumentType,
TrackColor,
@@ -28,6 +29,8 @@ import {
LEGACY_TO_TICKS_SCALE,
migrateLegacyProject,
} from "../lib/audio/timeGrid";
+import { pickStretchFields } from "../lib/audio/clipStretch";
+import { createDebouncer } from "../lib/utils/debounce";
import { useProjectStore } from "./projectStore";
import { useUiLayoutPreference } from "../composables/useUiLayoutStorage";
@@ -207,6 +210,12 @@ export const useTimelineStore = defineStore("timelineStore", () => {
const automationExpandedMaster = ref(false);
const isLoadingProject = ref(false); // Flag pour ignorer markAsChanged pendant le chargement
const metronomeEnabled = useUiLayoutPreference("metronome-enabled", false);
+ // Mode de resize des clips audio ("edit" = couper, "stretch" = étirer) —
+ // volontairement NON persisté (contrairement aux autres préférences de vue
+ // ci-dessus) : le stretch est un geste ponctuel, moins fréquent que le
+ // trim classique, donc chaque session/reload repart en mode "couper" par
+ // défaut plutôt que de rester bloquée en "stretch" silencieusement.
+ const clipResizeMode = ref("edit");
// Aide d'édition éphémère (non persistée) : dernière largeur donnée à une
// note via un resize individuel, utilisée comme largeur par défaut pour la
// prochaine note posée au clic dans le piano roll.
@@ -324,6 +333,7 @@ export const useTimelineStore = defineStore("timelineStore", () => {
elementarySynth: "Elementary",
smplr: "Sampler",
undertale: "Undertale",
+ fmSynth: "FM Synth",
audioTrack: "Audio",
samplePlayer: "Sample",
};
@@ -646,6 +656,7 @@ export const useTimelineStore = defineStore("timelineStore", () => {
x: cutPosition,
w: rightW,
startOffset: rightStartOffset,
+ ...pickStretchFields(clip),
});
track.updatedAt = new Date();
@@ -974,28 +985,6 @@ export const useTimelineStore = defineStore("timelineStore", () => {
// Persistence
// ============================================
- // Factory de debounce partagée par la sauvegarde de projet et la
- // persistance du zoom ci-dessous : un seul setTimeout/clearTimeout à la
- // fois par `fn`, plus un `flush` pour ne rien perdre si l'onglet se ferme
- // juste après (voir les deux usages).
- const createDebouncer = (fn: () => void, ms: number) => {
- let id: ReturnType | null = null;
- const schedule = (): void => {
- if (id) clearTimeout(id);
- id = setTimeout(() => {
- id = null;
- fn();
- }, ms);
- };
- const flush = (): void => {
- if (!id) return;
- clearTimeout(id);
- id = null;
- fn();
- };
- return { schedule, flush };
- };
-
const saveToLocalStorage = (): void => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(project.value));
@@ -1234,6 +1223,7 @@ export const useTimelineStore = defineStore("timelineStore", () => {
expandedTrack,
automationExpandedMaster,
metronomeEnabled,
+ clipResizeMode,
lastResizedNoteWidth,
zoomLevel,
zoomWheelSpeed,
diff --git a/webapp/src/stores/trackAudioStore.ts b/webapp/src/stores/trackAudioStore.ts
index 65e1afa..7db86e8 100644
--- a/webapp/src/stores/trackAudioStore.ts
+++ b/webapp/src/stores/trackAudioStore.ts
@@ -13,12 +13,13 @@ import type {
Track,
} from "../lib/utils/types";
import { useAudioLibraryStore } from "./audioLibraryStore";
+import { useStretchRenderCacheStore } from "./stretchRenderCacheStore";
import { AudioClipEngine } from "../lib/audio/engines/audio-clip";
import { SamplePlayerEngine } from "../lib/audio/engines/sample-player";
import { createInstrumentEngine } from "../lib/audio/instrumentFactory";
import { EffectChain } from "../lib/audio/effects";
import { applyAutomationToChannel } from "../lib/audio/automation";
-import { ticksPerSecond } from "../lib/audio/timeGrid";
+import { computeClipPlaybackParams } from "../lib/audio/clipStretch";
interface TrackChannel {
trackId: string;
@@ -230,12 +231,58 @@ export const useTrackAudioStore = defineStore("trackAudioStore", () => {
return;
}
- const tickRate = ticksPerSecond(timelineStore.tempo);
- const offsetInSeconds =
- (clip.startOffset + playbackOffsetColumns) / tickRate;
- const durationInSeconds = (clip.w - playbackOffsetColumns) / tickRate;
+ const params = computeClipPlaybackParams(
+ clip,
+ timelineStore.tempo,
+ playbackOffsetColumns,
+ );
+
+ if (params.needsStretchEngine) {
+ const stretchRenderCacheStore = useStretchRenderCacheStore();
+ const cached = await stretchRenderCacheStore.getCachedStretchedClip(
+ clip,
+ timelineStore.tempo,
+ audioContext,
+ );
- engine.playClip(clip.id, buffer, offsetInSeconds, durationInSeconds);
+ if (cached) {
+ // Buffer déjà dans le domaine CIBLE (déjà étiré) : le scrub se
+ // calcule en simple proportion, pas besoin de compenser un
+ // playbackRate comme dans le chemin live ci-dessous.
+ const fraction = playbackOffsetColumns / clip.w;
+ const scrubOffset = fraction * cached.duration;
+ engine.playClip(
+ clip.id,
+ cached,
+ scrubOffset,
+ cached.duration - scrubOffset,
+ );
+ } else {
+ await engine.playStretchedClip(
+ clip.id,
+ buffer,
+ params.offsetSeconds,
+ params.durationSeconds,
+ params.playbackRate,
+ params.detuneCents,
+ );
+ // Fire-and-forget : rend disponible un cache chaud pour la prochaine
+ // lecture, sans jamais bloquer celle-ci.
+ stretchRenderCacheStore.ensureStretchedClipCached(
+ clip,
+ buffer,
+ timelineStore.tempo,
+ audioContext,
+ );
+ }
+ } else {
+ engine.playClip(
+ clip.id,
+ buffer,
+ params.offsetSeconds,
+ params.durationSeconds,
+ );
+ }
};
const stopClipOnTrack = (trackId: string, clipId: string): void => {
@@ -401,14 +448,6 @@ export const useTrackAudioStore = defineStore("trackAudioStore", () => {
const initialize = (): void => {
if (isInitialized.value) return;
- // Restaurer les métadonnées des samples utilisés par le projet AVANT de
- // synchroniser les pistes ci-dessous : un Sample Player résout son sample
- // dès la création de son channel (syncTracksWithStore), donc restaurer
- // ces métadonnées après aurait été une course perdue d'avance.
- if (timelineStore.project.usedSamples) {
- useAudioLibraryStore().restoreSamples(timelineStore.project.usedSamples);
- }
-
// Synchroniser avec les pistes existantes
syncTracksWithStore();
diff --git a/webapp/src/styles/base.css b/webapp/src/styles/base.css
index c6f93f1..b91f76e 100644
--- a/webapp/src/styles/base.css
+++ b/webapp/src/styles/base.css
@@ -302,4 +302,4 @@ input[type="submit"]:disabled {
padding: 40px 24px;
max-width: 1200px;
width: 100%;
-}
\ No newline at end of file
+}
diff --git a/webapp/src/styles/blog.css b/webapp/src/styles/blog.css
index ee9cb7d..8aa61e2 100644
--- a/webapp/src/styles/blog.css
+++ b/webapp/src/styles/blog.css
@@ -890,7 +890,7 @@
}
.tag-suggestion-item:hover {
- background: var(--color-accent);
+ background: var(--color-accent-pale);
color: var(--color-accent-hover);
}
diff --git a/webapp/src/styles/learning.css b/webapp/src/styles/learning.css
index 46eeca4..c9b6b61 100644
--- a/webapp/src/styles/learning.css
+++ b/webapp/src/styles/learning.css
@@ -165,6 +165,7 @@
}
.md-editor-preview table {
+ max-width: 100%;
border-color: var(--color-border-secondary);
}
diff --git a/webapp/src/views/profile/PublicProfileView.vue b/webapp/src/views/profile/PublicProfileView.vue
index 4a862a3..1961400 100644
--- a/webapp/src/views/profile/PublicProfileView.vue
+++ b/webapp/src/views/profile/PublicProfileView.vue
@@ -60,8 +60,7 @@ const loadProfile = async () => {
};
const openProject = (projectId: string) => {
- // À adapter selon la route réelle de consultation d'un projet public.
- router.push(`/studio/${projectId}`);
+ router.push(`/app/sequencer?projectId/${projectId}`);
};
const formatDate = (dateString: string): string => {
diff --git a/webapp/src/vite-env.d.ts b/webapp/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/webapp/src/vite-env.d.ts
@@ -0,0 +1 @@
+///