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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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({
-
+
+ Redimensionnement des clips audio +
+
+
+ + +
+
Signature rythmique
+ +
@@ -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 @@ +///