diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..43306d1 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# S3 Cloudflare +R2_ACCOUNT_ID= +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_BUCKET_NAME= + +# Public access for S3 Cloudflare +CDN_BASE_URL= +VITE_CDN_BASE_URL= + +# Initial admin credentials +DEFAULT_ADMIN_EMAIL=your@email.com +DEFAULT_ADMIN_PASSWORD=password + +# Email sender (optional) +RESEND_API_KEY= + +# Prod Database (optional) +POSTGRES_URL= + +# 0Auth (optional) +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_CALLBACK_URL=http://yourdomain.com/api/auth/google/callback \ No newline at end of file diff --git a/.gitignore b/.gitignore index ee04813..a2f9860 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ logs /public/uploads/** .env* +!.env.example diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..372ce0a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# Bloop - Documentation Projet + +> DAW collaboratif web avec réseau social intégré. + +## Stack technique + +| Couche | Technologie | +|--------|-------------| +| Frontend | Vue 3 + Pinia + TypeScript + SCSS | +| Backend | Express.js + TypeORM + PostgreSQL | +| Storage | Cloudflare R2 (S3-compatible) | +| Cache audio | IndexedDB (500MB, LRU) | +| Auth | Session-based + Google OAuth | + +## Structure + +``` +pie-poc-2/ +├── webapp/ # Frontend Vue 3 +│ └── src/ +│ ├── components/app/ # DAW (voir CLAUDE.md) +│ ├── views/admin/ # Admin (voir CLAUDE.md) +│ └── stores/ # Pinia stores +├── server/ # Backend Express +│ └── src/ +│ ├── config/entities/ # Entités TypeORM +│ └── routes/ # API REST +└── CLAUDE.md +``` + +## Documentation par module + +| Module | Fichier | Description | +|--------|---------|-------------| +| DAW Timeline | `webapp/src/components/app/CLAUDE.md` | Piano roll, pistes, engines audio | +| Piano Roll | `webapp/src/components/app/timeline/PianoRoll/CLAUDE.md` | Éditeur de notes | +| Admin | `webapp/src/views/admin/CLAUDE.md` | Gestion users + samples | + +## Commandes + +```bash +# Dev (depuis racine) +npm run dev # Lance server + webapp + +# Depuis /webapp +npm run dev # Vite dev server +npm run build # Build production +npm run lint # ESLint + +# Depuis /server +npm run dev # Express avec nodemon +npm run build # TypeScript compile +``` + +## Variables d'environnement + +```bash +# Auth Google +GOOGLE_CLIENT_ID=xxx +GOOGLE_CLIENT_SECRET=xxx +GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback + +# Database +POSTGRES_DB=mydatabase +POSTGRES_PASSWORD=password +POSTGRES_USER=user + +# Cloudflare R2 +R2_ACCOUNT_ID=xxx +R2_ACCESS_KEY_ID=xxx +R2_SECRET_ACCESS_KEY=xxx +R2_BUCKET_NAME=bloop-samples +CDN_BASE_URL=https://samples.bloop-on.cloud +VITE_CDN_BASE_URL=https://samples.bloop-on.cloud + +# Email +RESEND_API_KEY=xxx + +# Admin initial +DEFAULT_ADMIN_EMAIL=xxx +DEFAULT_ADMIN_PASSWORD=xxx +``` + +## Conventions + +- **Stores** : Pinia Composition API +- **Composants** : Vue 3 ` + + diff --git a/webapp/src/components/app/CLAUDE.md b/webapp/src/components/app/CLAUDE.md index 9e1e55e..07fc2d8 100644 --- a/webapp/src/components/app/CLAUDE.md +++ b/webapp/src/components/app/CLAUDE.md @@ -60,13 +60,15 @@ getTrackNotesAtPosition(trackId, position): MidiNote[] ### Types clés (`lib/utils/types.ts`) ```typescript -InstrumentType = "basicSynth" | "elementarySynth" | "smplr" +InstrumentType = "basicSynth" | "elementarySynth" | "smplr" | "undertale" | "audioTrack" // Discriminated unions pour type safety BasicSynthConfig { type: "basicSynth", oscillatorType: OscillatorType, gain? } SmplrConfig { type: "smplr", soundfont: string, gain? } ElementarySynthConfig { type: "elementarySynth", preset?, gain? } -InstrumentConfig = BasicSynthConfig | SmplrConfig | ElementarySynthConfig +UndertaleConfig { type: "undertale", instrument: string, gain?, attack?, decay?, sustain?, release? } +AudioTrackConfig { type: "audioTrack", gain? } +InstrumentConfig = BasicSynthConfig | SmplrConfig | ElementarySynthConfig | UndertaleConfig | AudioTrackConfig // Pour les updates partiels (sans discriminant) InstrumentConfigUpdate { oscillatorType?, soundfont?, preset?, gain? } @@ -83,10 +85,24 @@ Track { id, name, instrument, color, volume, reverb, eqBands: EQBand[], // EQ 5 bandes par piste muted, solo, order, - notes: MidiNote[] // Notes avec positions absolues sur la timeline + notes: MidiNote[] // Notes avec positions absolues sur la timeline (MIDI tracks) + clips?: AudioClip[] // Clips audio (audio tracks uniquement) createdAt, updatedAt } +AudioClip { + id: string + sampleId: string // Référence vers AudioSample + x: number // Position sur la timeline (en colonnes) + w: number // Largeur (en colonnes) + startOffset: number // Décalage dans le sample source +} + +AudioSample { + id, name, packId, folder, filename, + duration, waveformData?, fullUrl // URL CDN R2 +} + MidiNote { i: string // ID unique x: number // Position absolue sur la timeline (en colonnes) @@ -121,10 +137,67 @@ engines/ smplr/ SmplrEngine.ts # Soundfonts via `smplr` (128+ instruments) soundfonts.ts # SOUNDFONT_LIST + SoundfontName + + undertale/ + UndertaleEngine.ts # Soundfont custom Undertale avec ADSR ``` Factory : `lib/audio/instrumentFactory.ts` - Crée les instances d'engines selon le type. +### Bibliothèque de samples (Audiothèque) + +Système de gestion des samples audio stockés sur Cloudflare R2. + +``` +stores/ +├── audioLibraryStore.ts # Fetch samples API + gestion buffers +└── sampleCacheStore.ts # Cache IndexedDB (500MB, LRU) +``` + +#### audioLibraryStore - API + +```typescript +// État +packs: SamplePack[] // Packs chargés +samples: Map // Samples indexés par ID +buffers: Map // AudioBuffers décodés +loadingStates: Map // "idle" | "loading" | "ready" | "error" + +// Fetch depuis API +fetchPacksFromApi(page, limit): SamplePack[] +fetchPackDetails(slug): SamplePack | null +fetchFolderSamples(packSlug, folderId): AudioSample[] + +// Chargement audio +loadSample(sampleId): AudioBuffer | null // Charge depuis R2 + cache IndexedDB +preloadPack(packId): void // Précharge tous les samples d'un pack +``` + +#### sampleCacheStore (IndexedDB) + +Cache LRU avec limite de 500MB. + +```typescript +get(sampleId): ArrayBuffer | null // Récupère depuis cache +set(sampleId, arrayBuffer): void // Stocke + éviction LRU si nécessaire +delete(sampleId): void +clear(): void +``` + +#### Flow de chargement + +``` +User sélectionne sample + → audioLibraryStore.loadSample(sampleId) + → Check buffers Map (déjà décodé?) + → Check IndexedDB cache (déjà téléchargé?) + → Fetch depuis CDN R2 (sample.fullUrl) + → Store dans IndexedDB + → Decode AudioBuffer + → Store dans buffers Map + → Génère waveformData +``` + #### Routing Audio par piste (`trackAudioStore`) Chaque piste a sa propre chaîne audio : @@ -181,7 +254,7 @@ Engine → GainNode (volume) → EQ Filters (5 bandes) → DryGain → inputBus ## Flow utilisateur -1. **Ajouter une piste** : Bouton "+" → Menu avec 3 choix (Synth, Elementary, Sampler) +1. **Ajouter une piste** : Bouton "+" → Menu instruments (BasicSynth, Smplr, Undertale, AudioTrack) 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 @@ -253,6 +326,10 @@ cloneEQBands() // Clone profond des bandes EQ - [x] EQ/Reverb par piste (dans InstrumentSettings) - [ ] Zoom timeline - [ ] ADSR pour ElementarySynth +- [x] Undertale soundfont engine avec ADSR +- [ ] Audio tracks (pistes samples) - en cours +- [x] Bibliothèque de samples connectée à R2/CDN +- [x] Cache IndexedDB pour samples (500MB, LRU) ## Conventions de code diff --git a/webapp/src/components/app/DawLoadingOverlay.vue b/webapp/src/components/app/DawLoadingOverlay.vue new file mode 100644 index 0000000..34d50e5 --- /dev/null +++ b/webapp/src/components/app/DawLoadingOverlay.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/webapp/src/components/app/instruments/InstrumentSettings.vue b/webapp/src/components/app/instruments/InstrumentSettings.vue index 1ca69ca..56c4dff 100644 --- a/webapp/src/components/app/instruments/InstrumentSettings.vue +++ b/webapp/src/components/app/instruments/InstrumentSettings.vue @@ -110,7 +110,10 @@ const handleUndertaleInstrumentChange = (instrument: string) => { trackAudioStore.updateTrackInstrument(props.track.id, { instrument }); }; -const handleADSRChange = (param: "attack" | "decay" | "sustain" | "release", value: number) => { +const handleADSRChange = ( + param: "attack" | "decay" | "sustain" | "release", + value: number, +) => { timelineStore.updateTrackInstrument(props.track.id, { [param]: value }); trackAudioStore.updateTrackInstrument(props.track.id, { [param]: value }); }; @@ -274,7 +277,9 @@ const handleClose = () => { ) " /> - {{ undertaleAttack.toFixed(2) }}s + {{ undertaleAttack.toFixed(2) }}s
D @@ -291,7 +296,9 @@ const handleClose = () => { ) " /> - {{ undertaleDecay.toFixed(2) }}s + {{ undertaleDecay.toFixed(2) }}s
S @@ -308,7 +315,9 @@ const handleClose = () => { ) " /> - {{ (undertaleSustain * 100).toFixed(0) }}% + {{ (undertaleSustain * 100).toFixed(0) }}%
R @@ -325,7 +334,9 @@ const handleClose = () => { ) " /> - {{ undertaleRelease.toFixed(2) }}s + {{ undertaleRelease.toFixed(2) }}s
diff --git a/webapp/src/components/app/timeline/AddTrackButton.vue b/webapp/src/components/app/timeline/AddTrackButton.vue index 905a3d1..a210d88 100644 --- a/webapp/src/components/app/timeline/AddTrackButton.vue +++ b/webapp/src/components/app/timeline/AddTrackButton.vue @@ -10,6 +10,12 @@ const emit = defineEmits<{ const showMenu = ref(false); const instruments = [ + { + type: "audioTrack" as InstrumentType, + name: "Audio", + icon: "🔊", + description: "Piste audio pour samples et boucles", + }, { type: "basicSynth" as InstrumentType, name: "Synth", diff --git a/webapp/src/components/app/timeline/AudioClipPreview.vue b/webapp/src/components/app/timeline/AudioClipPreview.vue new file mode 100644 index 0000000..b67c390 --- /dev/null +++ b/webapp/src/components/app/timeline/AudioClipPreview.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/webapp/src/components/app/timeline/AudioClipRow/AudioClipItem.vue b/webapp/src/components/app/timeline/AudioClipRow/AudioClipItem.vue new file mode 100644 index 0000000..62cf98d --- /dev/null +++ b/webapp/src/components/app/timeline/AudioClipRow/AudioClipItem.vue @@ -0,0 +1,281 @@ + + + + + diff --git a/webapp/src/components/app/timeline/AudioClipRow/AudioClipRow.vue b/webapp/src/components/app/timeline/AudioClipRow/AudioClipRow.vue new file mode 100644 index 0000000..72ba796 --- /dev/null +++ b/webapp/src/components/app/timeline/AudioClipRow/AudioClipRow.vue @@ -0,0 +1,365 @@ + + + + + diff --git a/webapp/src/components/app/timeline/AudioClipRow/WaveformCanvas.vue b/webapp/src/components/app/timeline/AudioClipRow/WaveformCanvas.vue new file mode 100644 index 0000000..63a6249 --- /dev/null +++ b/webapp/src/components/app/timeline/AudioClipRow/WaveformCanvas.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/webapp/src/components/app/timeline/AudioClipRow/index.ts b/webapp/src/components/app/timeline/AudioClipRow/index.ts new file mode 100644 index 0000000..00b8ac0 --- /dev/null +++ b/webapp/src/components/app/timeline/AudioClipRow/index.ts @@ -0,0 +1 @@ +export { default as AudioClipRow } from "./AudioClipRow.vue"; diff --git a/webapp/src/components/app/timeline/AudioLibraryPanel.vue b/webapp/src/components/app/timeline/AudioLibraryPanel.vue new file mode 100644 index 0000000..7cf1e09 --- /dev/null +++ b/webapp/src/components/app/timeline/AudioLibraryPanel.vue @@ -0,0 +1,583 @@ + + + + + diff --git a/webapp/src/components/app/timeline/TimelineView.vue b/webapp/src/components/app/timeline/TimelineView.vue index ae0df45..3d4432e 100644 --- a/webapp/src/components/app/timeline/TimelineView.vue +++ b/webapp/src/components/app/timeline/TimelineView.vue @@ -1,5 +1,12 @@ + + + + diff --git a/webapp/src/layouts/AppLayout.vue b/webapp/src/layouts/AppLayout.vue index e12aec3..6ffcf82 100644 --- a/webapp/src/layouts/AppLayout.vue +++ b/webapp/src/layouts/AppLayout.vue @@ -1,10 +1,12 @@ @@ -19,10 +21,53 @@ async function disconnect() { const result = await apiClient.post("/auth/logout"); if (result.data) { console.log("Déconnexion réussie"); - user.value = undefined; // Clear user data - window.location.reload(); // Reload to reset state + user.value = undefined; + window.location.reload(); } else { console.error("Erreur lors de la déconnexion :", result.error); } } + + diff --git a/webapp/src/lib/audio/engines/BaseEngine.ts b/webapp/src/lib/audio/engines/BaseEngine.ts index f62cf8c..d5e49b6 100644 --- a/webapp/src/lib/audio/engines/BaseEngine.ts +++ b/webapp/src/lib/audio/engines/BaseEngine.ts @@ -7,6 +7,8 @@ import type { export abstract class BaseEngine implements InstrumentEngine { abstract readonly type: string; + abstract readonly resourceKey: string | null; + abstract readonly resourceLabel: string; config: InstrumentConfig; protected _state: EngineState = "idle"; diff --git a/webapp/src/lib/audio/engines/audio-clip/AudioClipEngine.ts b/webapp/src/lib/audio/engines/audio-clip/AudioClipEngine.ts new file mode 100644 index 0000000..7d6d7ee --- /dev/null +++ b/webapp/src/lib/audio/engines/audio-clip/AudioClipEngine.ts @@ -0,0 +1,133 @@ +import type { + AudioTrackConfig, + InstrumentConfigUpdate, + NoteName, +} from "../../../utils/types"; +import { BaseEngine } from "../BaseEngine"; + +interface ActiveSource { + source: AudioBufferSourceNode; + gainNode: GainNode; +} + +export class AudioClipEngine extends BaseEngine { + readonly type = "audioTrack"; + readonly resourceKey = null; + readonly resourceLabel = "Audio Track"; + + private activeSources: Map = new Map(); + private gain: number; + + constructor( + audioContext: AudioContext, + destination: AudioNode, + config: AudioTrackConfig, + ) { + super(audioContext, destination, config); + this.gain = config.gain ?? 1; + this._state = "ready"; + } + + async preload(): Promise { + // No-op: AudioClipEngine is always ready (buffers loaded externally) + } + + // MIDI methods are no-op for audio tracks + playNote(_noteName: NoteName, _noteId: string, _velocity?: number): void { + // No-op: Audio tracks don't play MIDI notes + } + + stopNote(_noteId: string): void { + // No-op: Audio tracks don't play MIDI notes + } + + stopAllNotes(): void { + this.stopAllClips(); + } + + // Audio clip methods + playClip( + clipId: string, + buffer: AudioBuffer, + offsetSeconds: number = 0, + durationSeconds?: number, + ): void { + if (this.activeSources.has(clipId)) { + this.stopClip(clipId); + } + + const source = this.audioContext.createBufferSource(); + source.buffer = buffer; + + const gainNode = this.audioContext.createGain(); + gainNode.gain.setValueAtTime(this.gain, this.audioContext.currentTime); + + source.connect(gainNode); + gainNode.connect(this.destination); + + const actualOffset = Math.max(0, Math.min(offsetSeconds, buffer.duration)); + const remainingDuration = buffer.duration - actualOffset; + const actualDuration = + durationSeconds !== undefined + ? Math.min(durationSeconds, remainingDuration) + : remainingDuration; + + source.start(0, actualOffset, actualDuration); + + source.onended = () => { + this.activeSources.delete(clipId); + try { + source.disconnect(); + gainNode.disconnect(); + } catch { + // Ignore if already disconnected + } + }; + + this.activeSources.set(clipId, { source, gainNode }); + } + + stopClip(clipId: string): void { + const active = this.activeSources.get(clipId); + if (active) { + try { + active.gainNode.gain.exponentialRampToValueAtTime( + 0.001, + this.audioContext.currentTime + 0.02, + ); + setTimeout(() => { + try { + active.source.stop(); + active.source.disconnect(); + active.gainNode.disconnect(); + } catch { + // Ignore if already stopped + } + }, 30); + } catch { + // Ignore errors + } + this.activeSources.delete(clipId); + } + } + + stopAllClips(): void { + for (const clipId of this.activeSources.keys()) { + this.stopClip(clipId); + } + } + + updateConfig(config: InstrumentConfigUpdate): void { + if (config.gain !== undefined) { + this.gain = config.gain; + } + this.config = { ...this.config, ...config } as AudioTrackConfig; + } + + dispose(): void { + this.stopAllClips(); + this.activeSources.clear(); + this.stateCallbacks.clear(); + this._state = "idle"; + } +} diff --git a/webapp/src/lib/audio/engines/audio-clip/index.ts b/webapp/src/lib/audio/engines/audio-clip/index.ts new file mode 100644 index 0000000..3a445c6 --- /dev/null +++ b/webapp/src/lib/audio/engines/audio-clip/index.ts @@ -0,0 +1 @@ +export { AudioClipEngine } from "./AudioClipEngine"; diff --git a/webapp/src/lib/audio/engines/basic-synth/BasicSynthEngine.ts b/webapp/src/lib/audio/engines/basic-synth/BasicSynthEngine.ts index e95200b..d003530 100644 --- a/webapp/src/lib/audio/engines/basic-synth/BasicSynthEngine.ts +++ b/webapp/src/lib/audio/engines/basic-synth/BasicSynthEngine.ts @@ -14,6 +14,8 @@ interface ActiveOscillator { export class BasicSynthEngine extends BaseEngine { readonly type = "basicSynth"; + readonly resourceKey = null; + readonly resourceLabel = "Basic Synth"; private activeOscillators: Map = new Map(); private oscillatorType: OscillatorType; diff --git a/webapp/src/lib/audio/engines/index.ts b/webapp/src/lib/audio/engines/index.ts index fedda40..7def987 100644 --- a/webapp/src/lib/audio/engines/index.ts +++ b/webapp/src/lib/audio/engines/index.ts @@ -16,3 +16,4 @@ export { noteNameToFrequency } from "./noteUtils"; export { BasicSynthEngine } from "./basic-synth"; export { SmplrEngine, SOUNDFONT_LIST, type SoundfontName } from "./smplr"; export { UndertaleEngine } from "./undertale"; +export { AudioClipEngine } from "./audio-clip"; diff --git a/webapp/src/lib/audio/engines/smplr/SmplrEngine.ts b/webapp/src/lib/audio/engines/smplr/SmplrEngine.ts index ae17466..2372ad9 100644 --- a/webapp/src/lib/audio/engines/smplr/SmplrEngine.ts +++ b/webapp/src/lib/audio/engines/smplr/SmplrEngine.ts @@ -23,6 +23,16 @@ export class SmplrEngine extends BaseEngine { this.currentSoundfontName = config.soundfont; } + get resourceKey(): string { + return `smplr:${this.currentSoundfontName}`; + } + + get resourceLabel(): string { + return this.currentSoundfontName + .replace(/_/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); + } + async preload(): Promise { if (this._state === "ready") return; if (this._state === "loading" && this.loadPromise) { diff --git a/webapp/src/lib/audio/engines/types.ts b/webapp/src/lib/audio/engines/types.ts index 5c4a51e..2ae854e 100644 --- a/webapp/src/lib/audio/engines/types.ts +++ b/webapp/src/lib/audio/engines/types.ts @@ -14,6 +14,18 @@ export interface InstrumentEngine { readonly state: EngineState; + /** + * Clé unique identifiant la ressource à précharger. + * Les engines avec la même clé partagent la même ressource (un seul chargement). + * null = pas de préchargement nécessaire. + */ + readonly resourceKey: string | null; + + /** + * Label lisible pour l'affichage pendant le chargement. + */ + readonly resourceLabel: string; + onStateChange(callback: EngineStateCallback): () => void; preload(): Promise; diff --git a/webapp/src/lib/audio/engines/undertale/UndertaleEngine.ts b/webapp/src/lib/audio/engines/undertale/UndertaleEngine.ts index 4294ada..aa2f484 100644 --- a/webapp/src/lib/audio/engines/undertale/UndertaleEngine.ts +++ b/webapp/src/lib/audio/engines/undertale/UndertaleEngine.ts @@ -16,6 +16,8 @@ interface ActiveNote { export class UndertaleEngine extends BaseEngine { readonly type = "undertale"; + readonly resourceKey = "undertale:sf2"; + readonly resourceLabel = "Undertale Soundfont"; private sampler: Soundfont2Sampler | null = null; private activeNotes: Map = new Map(); @@ -170,9 +172,12 @@ export class UndertaleEngine extends BaseEngine { if (activeNote) { try { activeNote.stopFn(); - setTimeout(() => { - activeNote.envelopeNode.disconnect(); - }, this.release * 1000 + 100); + setTimeout( + () => { + activeNote.envelopeNode.disconnect(); + }, + this.release * 1000 + 100, + ); } catch { // Ignore errors } diff --git a/webapp/src/lib/audio/instrumentFactory.ts b/webapp/src/lib/audio/instrumentFactory.ts index ff14b44..e4bba09 100644 --- a/webapp/src/lib/audio/instrumentFactory.ts +++ b/webapp/src/lib/audio/instrumentFactory.ts @@ -1,5 +1,6 @@ import type { InstrumentConfig } from "../utils/types"; import { + AudioClipEngine, BasicSynthEngine, SmplrEngine, UndertaleEngine, @@ -31,6 +32,9 @@ export function createInstrumentEngine( case "undertale": return new UndertaleEngine(audioContext, destination, config); + case "audioTrack": + return new AudioClipEngine(audioContext, destination, config); + default: throw new Error(`Unknown instrument type: ${(config as any).type}`); } @@ -72,6 +76,12 @@ export function getDefaultConfigForType( release: 0.3, }; + case "audioTrack": + return { + type: "audioTrack", + gain: 1, + }; + default: throw new Error(`Unknown instrument type: ${type}`); } diff --git a/webapp/src/lib/canvas/pianoKeysRenderer.ts b/webapp/src/lib/canvas/pianoKeysRenderer.ts index 342a510..4b354a8 100644 --- a/webapp/src/lib/canvas/pianoKeysRenderer.ts +++ b/webapp/src/lib/canvas/pianoKeysRenderer.ts @@ -3,7 +3,6 @@ import { NOTE_ROW_HEIGHT, WHITE_KEY_MULTIPLIERS, isOctaveStart, - getOctaveNumber, getWhiteKeys, getBlackKeys, ALL_NOTES, @@ -54,11 +53,7 @@ export class PianoKeysRenderer { private allNotes: NoteName[]; private hoveredKey: NoteName | null = null; - constructor( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - ) { + constructor(ctx: CanvasRenderingContext2D, width: number, height: number) { this.ctx = ctx; this.width = width; this.height = height; @@ -155,7 +150,9 @@ export class PianoKeysRenderer { ctx.roundRect(key.x, key.y, key.w - 1, key.h - 1, [0, 0, 3, 3]); ctx.fill(); - ctx.strokeStyle = isOctave ? COLORS.whiteKey.octaveBorder : COLORS.whiteKey.border; + ctx.strokeStyle = isOctave + ? COLORS.whiteKey.octaveBorder + : COLORS.whiteKey.border; ctx.lineWidth = isOctave ? 2 : 1; ctx.beginPath(); ctx.moveTo(key.x, key.y + key.h - 1); @@ -165,7 +162,9 @@ export class PianoKeysRenderer { ctx.font = "bold 10px system-ui, sans-serif"; ctx.textAlign = "right"; ctx.textBaseline = "middle"; - ctx.fillStyle = isActive ? COLORS.whiteKey.textActive : COLORS.whiteKey.text; + ctx.fillStyle = isActive + ? COLORS.whiteKey.textActive + : COLORS.whiteKey.text; ctx.fillText(key.note, key.w - 6, key.y + key.h / 2); } @@ -173,7 +172,12 @@ export class PianoKeysRenderer { const { ctx } = this; const isHovered = this.hoveredKey === key.note; - const gradient = ctx.createLinearGradient(key.x, key.y, key.x, key.y + key.h); + const gradient = ctx.createLinearGradient( + key.x, + key.y, + key.x, + key.y + key.h, + ); if (isActive) { gradient.addColorStop(0, COLORS.blackKey.fillActive); gradient.addColorStop(1, COLORS.blackKey.fillActive); @@ -202,7 +206,9 @@ export class PianoKeysRenderer { ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0; - ctx.fillStyle = isActive ? COLORS.blackKey.textActive : COLORS.blackKey.text; + ctx.fillStyle = isActive + ? COLORS.blackKey.textActive + : COLORS.blackKey.text; ctx.font = "bold 8px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; diff --git a/webapp/src/lib/utils/types.ts b/webapp/src/lib/utils/types.ts index 20d7b71..e2c0c7c 100644 --- a/webapp/src/lib/utils/types.ts +++ b/webapp/src/lib/utils/types.ts @@ -95,7 +95,8 @@ export type InstrumentType = | "basicSynth" | "elementarySynth" | "smplr" - | "undertale"; + | "undertale" + | "audioTrack"; export type OscillatorType = "sine" | "square" | "sawtooth" | "triangle"; @@ -127,11 +128,17 @@ export interface UndertaleConfig { release?: number; } +export interface AudioTrackConfig { + type: "audioTrack"; + gain?: number; +} + export type InstrumentConfig = | BasicSynthConfig | SmplrConfig | ElementarySynthConfig - | UndertaleConfig; + | UndertaleConfig + | AudioTrackConfig; export interface InstrumentConfigUpdate { oscillatorType?: OscillatorType; @@ -145,6 +152,40 @@ export interface InstrumentConfigUpdate { release?: number; } +export interface AudioClip { + id: string; + sampleId: string; + x: number; + w: number; + startOffset: number; +} + +export interface AudioSample { + id: string; + name: string; + packId: string; + folder: string; + filename: string; + duration: number; + waveformData?: number[]; + fullUrl: string; +} + +export interface SampleFolder { + id?: string; + name: string; + samples: AudioSample[]; +} + +export interface SamplePack { + id: string; + name: string; + author?: string; + featured?: boolean; + cover?: string; + folders: SampleFolder[]; +} + export interface Track { id: string; name: string; @@ -156,7 +197,8 @@ export interface Track { muted: boolean; solo: boolean; order: number; - notes: MidiNote[]; // Notes avec positions absolues sur la timeline + notes: MidiNote[]; // Notes avec positions absolues sur la timeline (MIDI tracks) + clips?: AudioClip[]; // Audio clips (audio tracks) createdAt: Date; updatedAt: Date; } @@ -181,6 +223,7 @@ export interface TimelineProject { volume: number; // Volume master (0-100) reverb: number; // Reverb master (0-100) eqBands?: EQBand[]; + usedSamples?: Record; // Samples utilisés par les audio clips version: string; createdAt: Date; updatedAt: Date; diff --git a/webapp/src/router.ts b/webapp/src/router.ts index 39522e3..9e7b50d 100644 --- a/webapp/src/router.ts +++ b/webapp/src/router.ts @@ -26,6 +26,25 @@ async function authGuard(to: any, from: any, next: any) { next({ name: "app-login", query: { redirect: to.name } }); } } + +async function adminGuard(to: any, from: any, next: any) { + const authStore = useAuthStore(); + const check = await apiClient.get<{ user: User }>("/auth/check"); + + if (check.error || !check.data?.user) { + next({ name: "app-login", query: { redirect: to.name } }); + return; + } + + authStore.user = check.data.user; + + if (check.data.user.role !== "ROLE_ADMIN") { + next({ name: "landing-main" }); + return; + } + + next(); +} import BlogApp from "./views/blog/BlogApp.vue"; import BlogSearchResults from "./views/blog/BlogSearchResults.vue"; import BlogPostDetail from "./views/blog/BlogPostDetail.vue"; @@ -61,11 +80,48 @@ const routes = [ }, { path: "/profile", component: ProfileView, name: "profile" }, { path: "/messages", component: MessagesView, name: "messages" }, + // Admin routes + { + path: "/admin", + component: () => import("./views/admin/AdminDashboard.vue"), + name: "admin-dashboard", + meta: { requiresAdmin: true }, + }, + { + path: "/admin/users", + component: () => import("./views/admin/AdminUsers.vue"), + name: "admin-users", + meta: { requiresAdmin: true }, + }, + { + path: "/admin/samples", + component: () => import("./views/admin/AdminSamples.vue"), + name: "admin-samples", + meta: { requiresAdmin: true }, + }, + { + path: "/admin/samples/:packId", + component: () => import("./views/admin/AdminPackDetail.vue"), + name: "admin-pack-detail", + meta: { requiresAdmin: true }, + }, + { + path: "/admin/samples/:packId/:folderId", + component: () => import("./views/admin/AdminFolderDetail.vue"), + name: "admin-folder-detail", + meta: { requiresAdmin: true }, + }, ]; const getGuardedRoutes = () => { const guardedMatches = ["app", "blog", "settings", "profile", "messages"]; - return routes.map((route) => { + return routes.map((route: any) => { + if (route.meta?.requiresAdmin) { + return { + ...route, + beforeEnter: adminGuard, + }; + } if (guardedMatches.some((match) => route.path.includes(match))) { return { ...route, diff --git a/webapp/src/stores/adminStore.ts b/webapp/src/stores/adminStore.ts new file mode 100644 index 0000000..4ad9e19 --- /dev/null +++ b/webapp/src/stores/adminStore.ts @@ -0,0 +1,437 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import apiClient from "../lib/utils/apiClient"; + +interface AdminUser { + id: string; + email: string; + firstName: string; + lastName: string; + role: string; + isActive: boolean; + createdAt: string; + profilePicture?: string; +} + +interface AdminPack { + id: string; + slug: string; + name: string; + author: string | null; + cover: string | null; + featured: boolean; + isActive: boolean; + createdAt: string; +} + +interface AdminFolder { + id: string; + name: string; + order: number; + packId: string; +} + +interface AdminSample { + id: string; + name: string; + filename: string; + duration: number; + waveform: number[] | null; + folderId: string; + previewUrl: string | null; + fullUrl: string | null; +} + +interface Pagination { + page: number; + limit: number; + total: number; + pages: number; +} + +interface ApiResponse { + body: T; +} + +export const useAdminStore = defineStore("admin", () => { + // Users state + const users = ref([]); + const usersPagination = ref({ + page: 1, + limit: 20, + total: 0, + pages: 0, + }); + const usersLoading = ref(false); + + // Packs state + const packs = ref([]); + const packsPagination = ref({ + page: 1, + limit: 20, + total: 0, + pages: 0, + }); + const packsLoading = ref(false); + + // Current pack detail + const currentPack = ref(null); + const currentFolders = ref([]); + + // Current folder detail + const currentFolder = ref(null); + const currentSamples = ref([]); + + // Stats + const stats = ref({ + totalUsers: 0, + totalPacks: 0, + totalSamples: 0, + }); + + // ===== USER ACTIONS ===== + + async function fetchUsers(page = 1, search?: string) { + usersLoading.value = true; + const params = new URLSearchParams({ page: String(page), limit: "20" }); + if (search) params.append("search", search); + + const result = await apiClient.get< + ApiResponse<{ users: AdminUser[]; pagination: Pagination }> + >(`/admin/users?${params}`); + + if (result.data?.body) { + users.value = result.data.body.users; + usersPagination.value = result.data.body.pagination; + } + usersLoading.value = false; + } + + async function updateUser( + id: string, + data: { role?: string; isActive?: boolean }, + ) { + const result = await apiClient.patch>( + `/admin/users/${id}`, + data, + ); + if (!result.error && result.data?.body) { + const index = users.value.findIndex((u) => u.id === id); + if (index >= 0) { + users.value[index] = result.data.body; + } + } + return result; + } + + async function deleteUser(id: string) { + const result = await apiClient.delete<{ message: string }>( + `/admin/users/${id}`, + ); + if (!result.error) { + const index = users.value.findIndex((u) => u.id === id); + if (index >= 0) { + users.value[index].isActive = false; + } + } + return result; + } + + // ===== PACK ACTIONS ===== + + async function fetchPacks(page = 1) { + packsLoading.value = true; + const result = await apiClient.get< + ApiResponse<{ packs: AdminPack[]; pagination: Pagination }> + >(`/admin/samples/packs?page=${page}`); + + if (result.data?.body) { + packs.value = result.data.body.packs; + packsPagination.value = result.data.body.pagination; + } + packsLoading.value = false; + } + + async function fetchPackDetail(id: string) { + const result = await apiClient.get< + ApiResponse + >(`/admin/samples/packs/${id}`); + + if (result.data?.body) { + currentPack.value = result.data.body; + currentFolders.value = result.data.body.folders || []; + } + return result.data?.body || null; + } + + async function createPack(data: { + name: string; + slug: string; + author?: string; + cover?: string; + featured?: boolean; + }) { + const result = await apiClient.post>( + "/admin/samples/packs", + data, + ); + if (!result.error && result.data?.body) { + packs.value.unshift(result.data.body); + } + return result; + } + + async function updatePack(id: string, data: Partial) { + const result = await apiClient.put>( + `/admin/samples/packs/${id}`, + data, + ); + if (!result.error && result.data?.body) { + const index = packs.value.findIndex((p) => p.id === id); + if (index >= 0) { + packs.value[index] = result.data.body; + } + if (currentPack.value?.id === id) { + currentPack.value = result.data.body; + } + } + return result; + } + + async function deletePack(id: string) { + const result = await apiClient.delete<{ message: string }>( + `/admin/samples/packs/${id}`, + ); + if (!result.error) { + packs.value = packs.value.filter((p) => p.id !== id); + } + return result; + } + + // ===== FOLDER ACTIONS ===== + + async function fetchFolders(packId: string) { + const result = await apiClient.get>( + `/admin/samples/packs/${packId}/folders`, + ); + + if (result.data?.body) { + currentFolders.value = result.data.body; + } + return result.data?.body || []; + } + + async function createFolder( + packId: string, + data: { name: string; order?: number }, + ) { + const result = await apiClient.post>( + `/admin/samples/packs/${packId}/folders`, + data, + ); + if (!result.error && result.data?.body) { + currentFolders.value.push(result.data.body); + } + return result; + } + + async function updateFolder( + id: string, + data: { name?: string; order?: number }, + ) { + const result = await apiClient.put>( + `/admin/samples/folders/${id}`, + data, + ); + if (!result.error && result.data?.body) { + const index = currentFolders.value.findIndex((f) => f.id === id); + if (index >= 0) { + currentFolders.value[index] = result.data.body; + } + } + return result; + } + + async function deleteFolder(id: string) { + const result = await apiClient.delete<{ message: string }>( + `/admin/samples/folders/${id}`, + ); + if (!result.error) { + currentFolders.value = currentFolders.value.filter((f) => f.id !== id); + } + return result; + } + + // ===== SAMPLE ACTIONS ===== + + async function fetchSamples(folderId: string) { + const result = await apiClient.get>( + `/admin/samples/folders/${folderId}/samples`, + ); + + if (result.data?.body) { + currentSamples.value = result.data.body; + } + return result.data?.body || []; + } + + async function createSample( + folderId: string, + data: { + name: string; + filename: string; + duration?: number; + waveform?: number[]; + previewUrl?: string; + fullUrl?: string; + }, + ) { + const result = await apiClient.post>( + `/admin/samples/folders/${folderId}/samples`, + data, + ); + if (!result.error && result.data?.body) { + currentSamples.value.push(result.data.body); + } + return result; + } + + async function updateSample(id: string, data: Partial) { + const result = await apiClient.put>( + `/admin/samples/samples/${id}`, + data, + ); + if (!result.error && result.data?.body) { + const index = currentSamples.value.findIndex((s) => s.id === id); + if (index >= 0) { + currentSamples.value[index] = result.data.body; + } + } + return result; + } + + async function deleteSample(id: string) { + const result = await apiClient.delete<{ message: string }>( + `/admin/samples/samples/${id}`, + ); + if (!result.error) { + currentSamples.value = currentSamples.value.filter((s) => s.id !== id); + } + return result; + } + + // ===== UPLOAD ===== + + async function uploadFile( + file: File, + packSlug: string, + folderName?: string, + ): Promise<{ + filename: string; + key: string; + url: string; + size: number; + mimetype: string; + } | null> { + const formData = new FormData(); + formData.append("file", file); + formData.append("packSlug", packSlug); + if (folderName) formData.append("folderName", folderName); + + try { + const response = await fetch("/api/admin/upload", { + method: "POST", + body: formData, + credentials: "include", + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Upload failed"); + } + + const data = await response.json(); + return data.body; + } catch (error) { + console.error("Upload error:", error); + return null; + } + } + + // ===== STATS ===== + + async function fetchStats() { + const result = await apiClient.get< + ApiResponse<{ + totalUsers: number; + totalPacks: number; + totalSamples: number; + }> + >("/admin/stats"); + + if (result.data?.body) { + stats.value = result.data.body; + } + } + + // ===== RESET ===== + + function resetPackDetail() { + currentPack.value = null; + currentFolders.value = []; + } + + function resetFolderDetail() { + currentFolder.value = null; + currentSamples.value = []; + } + + return { + // State + users, + usersPagination, + usersLoading, + packs, + packsPagination, + packsLoading, + currentPack, + currentFolders, + currentFolder, + currentSamples, + stats, + + // User actions + fetchUsers, + updateUser, + deleteUser, + + // Pack actions + fetchPacks, + fetchPackDetail, + createPack, + updatePack, + deletePack, + + // Folder actions + fetchFolders, + createFolder, + updateFolder, + deleteFolder, + + // Sample actions + fetchSamples, + createSample, + updateSample, + deleteSample, + + // Upload + uploadFile, + + // Stats + fetchStats, + + // Reset + resetPackDetail, + resetFolderDetail, + }; +}); diff --git a/webapp/src/stores/audioLibraryStore.ts b/webapp/src/stores/audioLibraryStore.ts new file mode 100644 index 0000000..4375a45 --- /dev/null +++ b/webapp/src/stores/audioLibraryStore.ts @@ -0,0 +1,367 @@ +import { defineStore } from "pinia"; +import { ref, markRaw } from "vue"; +import type { AudioSample, SamplePack, SampleFolder } from "../lib/utils/types"; +import { useAudioBusStore } from "./audioBusStore"; +import { useSampleCacheStore } from "./sampleCacheStore"; +import apiClient from "../lib/utils/apiClient"; + +type LoadingState = "idle" | "loading" | "ready" | "error"; + +interface ApiPack { + id: string; + slug: string; + name: string; + author: string | null; + cover: string | null; + featured: boolean; + isActive: boolean; + createdAt: string; + folders?: ApiFolder[]; +} + +interface ApiFolder { + id: string; + name: string; + order: number; + packId: string; +} + +interface ApiSample { + id: string; + name: string; + filename: string; + duration: number; + waveform: number[] | null; + folderId: string; + previewUrl: string | null; + fullUrl: string | null; +} + +interface ApiResponse { + status: number; + message: string; + body: T; +} + +interface PaginatedPacksResponse { + packs: ApiPack[]; + pagination: { + page: number; + limit: number; + total: number; + pages: number; + }; +} + +export const useAudioLibraryStore = defineStore("audioLibrary", () => { + const audioBusStore = useAudioBusStore(); + const cacheStore = useSampleCacheStore(); + + const packs = ref([]); + const samples = ref>(new Map()); + const buffers = ref>(new Map()); + const loadingStates = ref>(new Map()); + const isInitialized = ref(false); + + const currentPackSlug = ref(null); + const currentFolderId = ref(null); + const pagination = ref({ page: 1, limit: 20, total: 0, pages: 0 }); + + const getSample = (sampleId: string): AudioSample | undefined => { + return samples.value.get(sampleId); + }; + + const getSampleBuffer = (sampleId: string): AudioBuffer | undefined => { + return buffers.value.get(sampleId); + }; + + const getLoadingState = (sampleId: string): LoadingState => { + return loadingStates.value.get(sampleId) ?? "idle"; + }; + + const getPack = (packId: string): SamplePack | undefined => { + return packs.value.find((p) => p.id === packId); + }; + + const getAllPacks = (): SamplePack[] => { + return packs.value; + }; + + const getFeaturedPacks = (): SamplePack[] => { + return packs.value.filter((p) => p.featured); + }; + + const getAllSamples = (): AudioSample[] => { + return Array.from(samples.value.values()); + }; + + const generateWaveformData = ( + buffer: AudioBuffer, + points: number = 128, + ): number[] => { + const channelData = buffer.getChannelData(0); + const blockSize = Math.floor(channelData.length / points); + const waveform: number[] = []; + + for (let i = 0; i < points; i++) { + const start = i * blockSize; + let sum = 0; + for (let j = 0; j < blockSize; j++) { + sum += Math.abs(channelData[start + j] ?? 0); + } + waveform.push(sum / blockSize); + } + + const max = Math.max(...waveform, 0.001); + return waveform.map((v) => v / max); + }; + + const loadSample = async (sampleId: string): Promise => { + const sample = samples.value.get(sampleId); + if (!sample) { + console.warn(`Sample not found: ${sampleId}`); + return null; + } + + if (buffers.value.has(sampleId)) { + return buffers.value.get(sampleId)!; + } + + if (loadingStates.value.get(sampleId) === "loading") { + return new Promise((resolve) => { + const checkInterval = setInterval(() => { + const state = loadingStates.value.get(sampleId); + if (state === "ready") { + clearInterval(checkInterval); + resolve(buffers.value.get(sampleId) ?? null); + } else if (state === "error") { + clearInterval(checkInterval); + resolve(null); + } + }, 50); + }); + } + + loadingStates.value.set(sampleId, "loading"); + + try { + await cacheStore.initialize(); + const cached = await cacheStore.get(sampleId); + + if (cached) { + try { + const audioBuffer = await audioBusStore.audioContext.decodeAudioData( + cached.slice(0), + ); + buffers.value.set(sampleId, markRaw(audioBuffer)); + sample.duration = audioBuffer.duration; + sample.waveformData = generateWaveformData(audioBuffer); + loadingStates.value.set(sampleId, "ready"); + return audioBuffer; + } catch { + // Cache corrompu, on continue avec fetch + } + } + + const response = await fetch(sample.fullUrl); + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status}`); + } + + const arrayBuffer = await response.arrayBuffer(); + + await cacheStore.set(sampleId, arrayBuffer); + + const audioBuffer = await audioBusStore.audioContext.decodeAudioData( + arrayBuffer.slice(0), + ); + + buffers.value.set(sampleId, markRaw(audioBuffer)); + sample.duration = audioBuffer.duration; + sample.waveformData = generateWaveformData(audioBuffer); + loadingStates.value.set(sampleId, "ready"); + + return audioBuffer; + } catch (error) { + console.error(`Failed to load sample ${sampleId}:`, error); + loadingStates.value.set(sampleId, "error"); + return null; + } + }; + + const preloadPack = async (packId: string): Promise => { + const pack = getPack(packId); + if (!pack) return; + + const allSamples: AudioSample[] = []; + for (const folder of pack.folders) { + allSamples.push(...folder.samples); + } + await Promise.all(allSamples.map((s) => loadSample(s.id))); + }; + + const preloadAllSamples = async (): Promise => { + const allSamples = getAllSamples(); + await Promise.all(allSamples.map((s) => loadSample(s.id))); + }; + + const fetchPacksFromApi = async ( + page = 1, + limit = 50, + ): Promise => { + const result = await apiClient.get>( + `/samples/packs?page=${page}&limit=${limit}`, + ); + + if (result.error || !result.data?.body) { + return []; + } + + const { packs: apiPacks, pagination: pag } = result.data.body; + pagination.value = pag; + + return apiPacks.map((ap) => ({ + id: ap.slug, + name: ap.name, + author: ap.author ?? undefined, + featured: ap.featured, + cover: ap.cover ?? undefined, + folders: [], + })); + }; + + const fetchPackDetails = async (slug: string): Promise => { + const result = await apiClient.get>( + `/samples/packs/${slug}`, + ); + + if (result.error || !result.data?.body) { + return null; + } + + const ap = result.data.body; + currentPackSlug.value = slug; + + const folders: SampleFolder[] = (ap.folders ?? []).map((f) => ({ + id: f.id, + name: f.name, + samples: [], + })); + + const pack: SamplePack = { + id: ap.slug, + name: ap.name, + author: ap.author ?? undefined, + featured: ap.featured, + cover: ap.cover ?? undefined, + folders, + }; + + const existingIndex = packs.value.findIndex((p) => p.id === slug); + if (existingIndex >= 0) { + packs.value[existingIndex] = pack; + } else { + packs.value.push(pack); + } + + return pack; + }; + + const fetchFolderSamples = async ( + packSlug: string, + folderId: string, + ): Promise => { + const result = await apiClient.get>( + `/samples/packs/${packSlug}/folders/${folderId}`, + ); + + if (result.error || !result.data?.body) { + return []; + } + + currentFolderId.value = folderId; + + const fetchedSamples: AudioSample[] = result.data.body.map((as) => ({ + id: as.id, + name: as.name, + packId: packSlug, + folder: folderId, + filename: as.filename, + duration: as.duration, + waveformData: as.waveform ?? undefined, + fullUrl: as.fullUrl ?? "", + })); + + for (const sample of fetchedSamples) { + samples.value.set(sample.id, sample); + if (!loadingStates.value.has(sample.id)) { + loadingStates.value.set(sample.id, "idle"); + } + } + + const pack = packs.value.find((p) => p.id === packSlug); + if (pack) { + const folder = pack.folders.find((f: any) => f.id === folderId); + if (folder) { + folder.samples = fetchedSamples; + } + } + + return fetchedSamples; + }; + + const initializeFromApi = async (): Promise => { + try { + const fetchedPacks = await fetchPacksFromApi(1, 50); + if (fetchedPacks.length === 0) { + return false; + } + packs.value = fetchedPacks; + return true; + } catch { + return false; + } + }; + + const initialize = async (): Promise => { + if (isInitialized.value) return; + await initializeFromApi(); + isInitialized.value = true; + }; + + const restoreSamples = (sampleMap: Record): void => { + for (const [sampleId, sample] of Object.entries(sampleMap)) { + if (!samples.value.has(sampleId)) { + samples.value.set(sampleId, sample); + loadingStates.value.set(sampleId, "idle"); + } + } + }; + + return { + packs, + samples, + isInitialized, + pagination, + currentPackSlug, + currentFolderId, + + getSample, + getSampleBuffer, + getLoadingState, + getPack, + getAllPacks, + getFeaturedPacks, + getAllSamples, + + loadSample, + preloadPack, + preloadAllSamples, + initialize, + + fetchPacksFromApi, + fetchPackDetails, + fetchFolderSamples, + restoreSamples, + }; +}); diff --git a/webapp/src/stores/dawLoadingStore.ts b/webapp/src/stores/dawLoadingStore.ts new file mode 100644 index 0000000..ff2dc65 --- /dev/null +++ b/webapp/src/stores/dawLoadingStore.ts @@ -0,0 +1,287 @@ +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; +import type { TimelineProject } from "../lib/utils/types"; +import { useTrackAudioStore } from "./trackAudioStore"; +import { useAudioLibraryStore } from "./audioLibraryStore"; +import { useElementaryStore } from "./elementaryStore"; +import { useAudioBusStore } from "./audioBusStore"; + +type TaskStatus = "pending" | "loading" | "complete" | "error"; +type PhaseStatus = "pending" | "loading" | "complete"; + +interface LoadingTask { + id: string; + type: "engine" | "instrument" | "sample"; + label: string; + status: TaskStatus; + trackId?: string; +} + +interface LoadingPhase { + id: string; + name: string; + tasks: LoadingTask[]; + status: PhaseStatus; +} + +export const useDawLoadingStore = defineStore("dawLoading", () => { + const phases = ref([]); + const isPreloading = ref(false); + const isComplete = ref(false); + const error = ref(null); + + const currentPhase = computed(() => + phases.value.find((p) => p.status === "loading"), + ); + + const currentTask = computed(() => { + const phase = currentPhase.value; + if (!phase) return null; + return phase.tasks.find((t) => t.status === "loading"); + }); + + const statusText = computed(() => { + const task = currentTask.value; + if (!task) { + const phase = currentPhase.value; + return phase?.name || "Préparation..."; + } + return task.label; + }); + + const overallProgress = computed(() => { + const allTasks = phases.value.flatMap((p) => p.tasks); + if (allTasks.length === 0) return 0; + const completed = allTasks.filter((t) => t.status === "complete").length; + return Math.round((completed / allTasks.length) * 100); + }); + + const allTasks = computed(() => phases.value.flatMap((p) => p.tasks)); + + function buildPhases(project: TimelineProject): void { + phases.value = []; + const trackAudioStore = useTrackAudioStore(); + + // Phase 1: Initialisation + phases.value.push({ + id: "init", + name: "Initialisation", + status: "pending", + tasks: [ + { + id: "audio-context", + type: "engine", + label: "Audio Context", + status: "pending", + }, + { + id: "elementary", + type: "engine", + label: "Elementary Audio", + status: "pending", + }, + ], + }); + + // Phase 2: Instruments - utilise engine.resourceKey pour dédupliquer + const instrumentTasks: LoadingTask[] = []; + const seenResources = new Set(); + + for (const track of project.tracks) { + const engine = trackAudioStore.getEngine(track.id); + if (!engine) continue; + + const resourceKey = engine.resourceKey; + if (resourceKey && !seenResources.has(resourceKey)) { + seenResources.add(resourceKey); + instrumentTasks.push({ + id: resourceKey, + type: "instrument", + label: engine.resourceLabel, + status: "pending", + trackId: track.id, + }); + } + } + + if (instrumentTasks.length > 0) { + phases.value.push({ + id: "instruments", + name: "Chargement des instruments", + status: "pending", + tasks: instrumentTasks, + }); + } + + // Phase 3: Samples audio + const sampleTasks: LoadingTask[] = []; + const audioLibraryStore = useAudioLibraryStore(); + + for (const track of project.tracks) { + if (track.instrument.type === "audioTrack" && track.clips) { + for (const clip of track.clips) { + if (!sampleTasks.some((t) => t.id === clip.sampleId)) { + const sample = audioLibraryStore.getSample(clip.sampleId); + const label = sample?.name || sample?.filename || clip.sampleId; + sampleTasks.push({ + id: clip.sampleId, + type: "sample", + label, + status: "pending", + }); + } + } + } + } + + if (sampleTasks.length > 0) { + phases.value.push({ + id: "samples", + name: "Chargement des samples", + status: "pending", + tasks: sampleTasks, + }); + } + } + + async function executeInitPhase(): Promise { + const phase = phases.value.find((p) => p.id === "init"); + if (!phase) return; + + phase.status = "loading"; + const audioBusStore = useAudioBusStore(); + const elementaryStore = useElementaryStore(); + + // AudioContext + const audioContextTask = phase.tasks.find((t) => t.id === "audio-context"); + if (audioContextTask) { + audioContextTask.status = "loading"; + await audioBusStore.ensureAudioContextResumed(); + audioContextTask.status = "complete"; + } + + // Elementary + const elementaryTask = phase.tasks.find((t) => t.id === "elementary"); + if (elementaryTask) { + elementaryTask.status = "loading"; + if (!elementaryStore.isLoaded) { + await elementaryStore.load(); + } + elementaryTask.status = "complete"; + } + + phase.status = "complete"; + } + + async function executeInstrumentsPhase(): Promise { + const phase = phases.value.find((p) => p.id === "instruments"); + if (!phase) return; + + phase.status = "loading"; + const trackAudioStore = useTrackAudioStore(); + + // Charger les instruments en parallèle + const promises: Promise[] = []; + + for (const task of phase.tasks) { + task.status = "loading"; + + const loadTask = async () => { + try { + if (task.trackId) { + await trackAudioStore.preloadTrack(task.trackId); + } + task.status = "complete"; + } catch (e) { + console.error(`[DawLoading] Failed to load ${task.label}:`, e); + task.status = "error"; + } + }; + + promises.push(loadTask()); + } + + await Promise.all(promises); + phase.status = "complete"; + } + + async function executeSamplesPhase(): Promise { + const phase = phases.value.find((p) => p.id === "samples"); + if (!phase) return; + + phase.status = "loading"; + const audioLibraryStore = useAudioLibraryStore(); + + // Charger les samples séquentiellement pour éviter trop de requêtes parallèles + for (const task of phase.tasks) { + task.status = "loading"; + try { + await audioLibraryStore.loadSample(task.id); + task.status = "complete"; + } catch (e) { + console.error(`[DawLoading] Failed to load sample ${task.label}:`, e); + task.status = "error"; + } + } + + phase.status = "complete"; + } + + async function preloadProject(project: TimelineProject): Promise { + if (isPreloading.value) return; + + isPreloading.value = true; + isComplete.value = false; + error.value = null; + + buildPhases(project); + + // Si aucune task (projet vide), compléter immédiatement + if (phases.value.flatMap((p) => p.tasks).length === 0) { + isComplete.value = true; + isPreloading.value = false; + return; + } + + try { + // Phase 1: Init + await executeInitPhase(); + + // Phase 2: Instruments + await executeInstrumentsPhase(); + + // Phase 3: Samples + await executeSamplesPhase(); + + isComplete.value = true; + } catch (e) { + error.value = e instanceof Error ? e.message : "Erreur de chargement"; + console.error("[DawLoading] Preload failed:", e); + // Même en cas d'erreur, on permet de continuer + isComplete.value = true; + } finally { + isPreloading.value = false; + } + } + + function reset(): void { + phases.value = []; + isPreloading.value = false; + isComplete.value = false; + error.value = null; + } + + return { + phases, + isPreloading, + isComplete, + error, + currentPhase, + currentTask, + statusText, + overallProgress, + allTasks, + preloadProject, + reset, + }; +}); diff --git a/webapp/src/stores/sampleCacheStore.ts b/webapp/src/stores/sampleCacheStore.ts new file mode 100644 index 0000000..733dbba --- /dev/null +++ b/webapp/src/stores/sampleCacheStore.ts @@ -0,0 +1,110 @@ +import { defineStore } from "pinia"; +import { openDB, type IDBPDatabase } from "idb"; + +interface CachedSample { + id: string; + buffer: ArrayBuffer; + cachedAt: number; + size: number; +} + +const DB_NAME = "bloop-sample-cache"; +const DB_VERSION = 1; +const STORE_NAME = "samples"; +const MAX_CACHE_SIZE = 500 * 1024 * 1024; // 500MB + +export const useSampleCacheStore = defineStore("sampleCache", { + state: () => ({ + db: null as IDBPDatabase | null, + isInitialized: false, + cacheSize: 0, + }), + + actions: { + async initialize(): Promise { + if (this.isInitialized) return; + + this.db = await openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: "id" }); + store.createIndex("cachedAt", "cachedAt"); + } + }, + }); + + await this.calculateCacheSize(); + this.isInitialized = true; + }, + + async get(sampleId: string): Promise { + if (!this.db) return null; + + const cached = (await this.db.get(STORE_NAME, sampleId)) as + | CachedSample + | undefined; + if (cached) { + cached.cachedAt = Date.now(); + await this.db.put(STORE_NAME, cached); + return cached.buffer; + } + return null; + }, + + async set(sampleId: string, buffer: ArrayBuffer): Promise { + if (!this.db) return; + + await this.ensureSpace(buffer.byteLength); + + const entry: CachedSample = { + id: sampleId, + buffer, + cachedAt: Date.now(), + size: buffer.byteLength, + }; + await this.db.put(STORE_NAME, entry); + this.cacheSize += buffer.byteLength; + }, + + 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 CachedSample; + this.cacheSize -= entry.size; + await cursor.delete(); + } else { + break; + } + } + }, + + async calculateCacheSize(): Promise { + if (!this.db) return; + + let total = 0; + const all = (await this.db.getAll(STORE_NAME)) as CachedSample[]; + 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; + }, + + async has(sampleId: string): Promise { + if (!this.db) return false; + const key = await this.db.getKey(STORE_NAME, sampleId); + return key !== undefined; + }, + }, +}); diff --git a/webapp/src/stores/timelineStore.ts b/webapp/src/stores/timelineStore.ts index e8f6a82..3eeee67 100644 --- a/webapp/src/stores/timelineStore.ts +++ b/webapp/src/stores/timelineStore.ts @@ -4,6 +4,8 @@ import type { Track, TimelineProject, MidiNote, + AudioClip, + AudioSample, EQBand, InstrumentConfig, InstrumentType, @@ -112,6 +114,10 @@ export const useTimelineStore = defineStore("timelineStore", () => { return `${trackId}_note_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`; }; + const generateClipId = (trackId: string): string => { + return `${trackId}_clip_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`; + }; + const getNextTrackColor = (): TrackColor => { const usedColors = new Set(project.value.tracks.map((t) => t.color)); const available = TRACK_COLORS.filter((c) => !usedColors.has(c)); @@ -132,6 +138,7 @@ export const useTimelineStore = defineStore("timelineStore", () => { elementarySynth: "Elementary", smplr: "Sampler", undertale: "Undertale", + audioTrack: "Audio", }; const baseName = baseNames[instrumentType]; let counter = 1; @@ -354,6 +361,99 @@ export const useTimelineStore = defineStore("timelineStore", () => { return true; }; + // ============================================ + // Actions - Audio Clips sur Track + // ============================================ + + const addClipToTrack = ( + trackId: string, + clip: Omit, + sample?: AudioSample, + ): string | null => { + const track = project.value.tracks.find((t) => t.id === trackId); + if (!track) return null; + + if (!track.clips) { + track.clips = []; + } + + const clipId = generateClipId(trackId); + const newClip: AudioClip = { + ...clip, + id: clipId, + }; + + track.clips.push(newClip); + + // Stocker les métadonnées du sample pour la persistence + if (sample) { + if (!project.value.usedSamples) { + project.value.usedSamples = {}; + } + project.value.usedSamples[sample.id] = sample; + } + + track.updatedAt = new Date(); + project.value.updatedAt = new Date(); + + return clipId; + }; + + const removeClipFromTrack = (trackId: string, clipId: string): boolean => { + const track = project.value.tracks.find((t) => t.id === trackId); + if (!track || !track.clips) return false; + + const index = track.clips.findIndex((c) => c.id === clipId); + if (index === -1) return false; + + track.clips.splice(index, 1); + track.updatedAt = new Date(); + project.value.updatedAt = new Date(); + + return true; + }; + + const updateClipInTrack = ( + trackId: string, + clipId: string, + updates: Partial, + ): boolean => { + const track = project.value.tracks.find((t) => t.id === trackId); + if (!track || !track.clips) return false; + + const clip = track.clips.find((c) => c.id === clipId); + if (!clip) return false; + + Object.assign(clip, updates); + track.updatedAt = new Date(); + project.value.updatedAt = new Date(); + + return true; + }; + + const setTrackClips = (trackId: string, clips: AudioClip[]): boolean => { + const track = project.value.tracks.find((t) => t.id === trackId); + if (!track) return false; + + track.clips = clips; + track.updatedAt = new Date(); + project.value.updatedAt = new Date(); + + return true; + }; + + const getTrackClipsAtPosition = ( + trackId: string, + position: number, + ): AudioClip[] => { + const track = project.value.tracks.find((t) => t.id === trackId); + if (!track || !track.clips) return []; + + return track.clips.filter( + (clip) => position >= clip.x && position < clip.x + clip.w, + ); + }; + // ============================================ // Actions - Piano Roll Expand/Collapse // ============================================ @@ -475,6 +575,10 @@ export const useTimelineStore = defineStore("timelineStore", () => { if (!track.notes) { track.notes = []; } + // S'assurer que clips existe pour les audio tracks + if (track.instrument.type === "audioTrack" && !track.clips) { + track.clips = []; + } // Migration: ajouter reverb et eqBands si manquants if (track.reverb === undefined) { track.reverb = 0; @@ -508,6 +612,10 @@ export const useTimelineStore = defineStore("timelineStore", () => { if (!track.notes) { track.notes = []; } + // S'assurer que clips existe pour les audio tracks + if (track.instrument.type === "audioTrack" && !track.clips) { + track.clips = []; + } // Migration: ajouter reverb et eqBands si manquants if (track.reverb === undefined) { track.reverb = 0; @@ -524,6 +632,14 @@ export const useTimelineStore = defineStore("timelineStore", () => { project.value = data; + // Restaurer les métadonnées des samples utilisés dans audioLibraryStore + if (data.usedSamples) { + import("./audioLibraryStore").then(({ useAudioLibraryStore }) => { + const audioLibraryStore = useAudioLibraryStore(); + audioLibraryStore.restoreSamples(data.usedSamples!); + }); + } + // Clear all undo/redo history when loading a new project import("./trackHistoryStore").then(({ useTrackHistoryStore }) => { useTrackHistoryStore().clearAllHistory(); @@ -639,6 +755,13 @@ export const useTimelineStore = defineStore("timelineStore", () => { updateNoteInTrack, setTrackNotes, + // Actions - Clips + addClipToTrack, + removeClipFromTrack, + updateClipInTrack, + setTrackClips, + getTrackClipsAtPosition, + // Actions - Piano Roll expandTrack, collapseTrack, diff --git a/webapp/src/stores/trackAudioStore.ts b/webapp/src/stores/trackAudioStore.ts index 8ee925c..3318a70 100644 --- a/webapp/src/stores/trackAudioStore.ts +++ b/webapp/src/stores/trackAudioStore.ts @@ -4,11 +4,14 @@ import { useTimelineStore } from "./timelineStore"; import { useAudioBusStore } from "./audioBusStore"; import type { EngineState, InstrumentEngine } from "../lib/audio/engines/types"; import type { + AudioClip, InstrumentConfig, NoteName, Track, EQBand, } from "../lib/utils/types"; +import { useAudioLibraryStore } from "./audioLibraryStore"; +import { AudioClipEngine } from "../lib/audio/engines/audio-clip"; import { createInstrumentEngine } from "../lib/audio/instrumentFactory"; import { createImpulseResponse, createEQFilter } from "../lib/audio/config"; @@ -176,6 +179,79 @@ export const useTrackAudioStore = defineStore("trackAudioStore", () => { } }; + // ============================================ + // Audio Clip Methods + // ============================================ + + const playClipOnTrack = async ( + trackId: string, + clip: AudioClip, + playbackOffsetColumns: number = 0, + ): Promise => { + await ensureAudioContextResumed(); + + const track = timelineStore.tracks.find((t) => t.id === trackId); + if (!track) { + console.warn(`Track not found: ${trackId}`); + return; + } + + if (track.instrument.type !== "audioTrack") { + console.warn(`Track ${trackId} is not an audio track`); + return; + } + + const hasSolo = timelineStore.tracks.some((t) => t.solo); + if (track.muted || (hasSolo && !track.solo)) { + return; + } + + const channel = getOrCreateChannel(track); + const engine = channel.engine; + + if (!(engine instanceof AudioClipEngine)) { + console.warn(`Track ${trackId} engine is not an AudioClipEngine`); + return; + } + + const audioLibraryStore = useAudioLibraryStore(); + const buffer = await audioLibraryStore.loadSample(clip.sampleId); + + if (!buffer) { + console.warn(`Failed to load sample: ${clip.sampleId}`); + return; + } + + const stepsPerSecond = (timelineStore.tempo / 60) * 4; + const offsetInSeconds = + (clip.startOffset + playbackOffsetColumns) / stepsPerSecond; + const durationInSeconds = (clip.w - playbackOffsetColumns) / stepsPerSecond; + + engine.playClip(clip.id, buffer, offsetInSeconds, durationInSeconds); + }; + + const stopClipOnTrack = (trackId: string, clipId: string): void => { + const channel = trackChannels.value.get(trackId); + if (channel && channel.engine instanceof AudioClipEngine) { + channel.engine.stopClip(clipId); + } + }; + + const stopAllClipsOnTrack = (trackId: string): void => { + const channel = trackChannels.value.get(trackId); + if (channel && channel.engine instanceof AudioClipEngine) { + channel.engine.stopAllClips(); + } + }; + + const stopAllClips = (): void => { + for (const channel of trackChannels.value.values()) { + if (channel.engine instanceof AudioClipEngine) { + channel.engine.stopAllClips(); + } + } + }; + const updateTrackVolume = (trackId: string, volume: number): void => { const channel = trackChannels.value.get(trackId); if (channel) { @@ -364,6 +440,11 @@ export const useTrackAudioStore = defineStore("trackAudioStore", () => { stopAllNotesOnTrack, stopAllNotes, + playClipOnTrack, + stopClipOnTrack, + stopAllClipsOnTrack, + stopAllClips, + updateTrackVolume, updateTrackReverb, updateTrackEQBand, diff --git a/webapp/src/stores/trackHistoryStore.ts b/webapp/src/stores/trackHistoryStore.ts index 67cfc05..a6831e1 100644 --- a/webapp/src/stores/trackHistoryStore.ts +++ b/webapp/src/stores/trackHistoryStore.ts @@ -1,6 +1,6 @@ import { defineStore } from "pinia"; import { ref } from "vue"; -import type { MidiNote } from "../lib/utils/types"; +import type { MidiNote, AudioClip, AudioSample } from "../lib/utils/types"; import { useTimelineStore } from "./timelineStore"; const MAX_HISTORY_SIZE = 50; @@ -10,6 +10,8 @@ interface HistoryEntry { description: string; notesBefore: MidiNote[]; notesAfter: MidiNote[]; + clipsBefore?: AudioClip[]; + clipsAfter?: AudioClip[]; } interface TrackHistory { @@ -23,6 +25,7 @@ export const useTrackHistoryStore = defineStore("trackHistory", () => { let pendingBatch: { trackId: string; notesBefore: MidiNote[]; + clipsBefore: AudioClip[]; description: string; } | null = null; @@ -37,11 +40,18 @@ export const useTrackHistoryStore = defineStore("trackHistory", () => { return JSON.parse(JSON.stringify(notes)); }; + const cloneClips = (clips: AudioClip[] | undefined): AudioClip[] => { + if (!clips) return []; + return JSON.parse(JSON.stringify(clips)); + }; + const pushHistory = ( trackId: string, notesBefore: MidiNote[], notesAfter: MidiNote[], description: string, + clipsBefore?: AudioClip[], + clipsAfter?: AudioClip[], ): void => { const history = getTrackHistory(trackId); @@ -50,6 +60,8 @@ export const useTrackHistoryStore = defineStore("trackHistory", () => { description, notesBefore: cloneNotes(notesBefore), notesAfter: cloneNotes(notesAfter), + clipsBefore: clipsBefore ? cloneClips(clipsBefore) : undefined, + clipsAfter: clipsAfter ? cloneClips(clipsAfter) : undefined, }; history.undoStack.push(entry); @@ -72,6 +84,10 @@ export const useTrackHistoryStore = defineStore("trackHistory", () => { timelineStore.setTrackNotes(trackId, cloneNotes(entry.notesBefore)); + if (entry.clipsBefore !== undefined) { + timelineStore.setTrackClips(trackId, cloneClips(entry.clipsBefore)); + } + return true; }; @@ -86,6 +102,10 @@ export const useTrackHistoryStore = defineStore("trackHistory", () => { timelineStore.setTrackNotes(trackId, cloneNotes(entry.notesAfter)); + if (entry.clipsAfter !== undefined) { + timelineStore.setTrackClips(trackId, cloneClips(entry.clipsAfter)); + } + return true; }; @@ -130,6 +150,54 @@ export const useTrackHistoryStore = defineStore("trackHistory", () => { return success; }; + const recordAddClip = ( + trackId: string, + clip: Omit, + sample?: AudioSample, + ): string | null => { + const timelineStore = useTimelineStore(); + const track = timelineStore.project.tracks.find((t) => t.id === trackId); + if (!track) return null; + + const clipsBefore = cloneClips(track.clips); + const clipId = timelineStore.addClipToTrack(trackId, clip, sample); + + if (clipId) { + pushHistory( + trackId, + track.notes, + track.notes, + "Add clip", + clipsBefore, + track.clips, + ); + } + + return clipId; + }; + + const recordRemoveClip = (trackId: string, clipId: string): boolean => { + const timelineStore = useTimelineStore(); + const track = timelineStore.project.tracks.find((t) => t.id === trackId); + if (!track) return false; + + const clipsBefore = cloneClips(track.clips); + const success = timelineStore.removeClipFromTrack(trackId, clipId); + + if (success) { + pushHistory( + trackId, + track.notes, + track.notes, + "Remove clip", + clipsBefore, + track.clips, + ); + } + + return success; + }; + const startBatch = (trackId: string, description: string): void => { const timelineStore = useTimelineStore(); const track = timelineStore.project.tracks.find((t) => t.id === trackId); @@ -138,6 +206,7 @@ export const useTrackHistoryStore = defineStore("trackHistory", () => { pendingBatch = { trackId, notesBefore: cloneNotes(track.notes), + clipsBefore: cloneClips(track.clips), description, }; }; @@ -156,6 +225,8 @@ export const useTrackHistoryStore = defineStore("trackHistory", () => { pendingBatch.notesBefore, track.notes, pendingBatch.description, + pendingBatch.clipsBefore, + track.clips, ); } @@ -181,6 +252,8 @@ export const useTrackHistoryStore = defineStore("trackHistory", () => { canRedo, recordAddNote, recordRemoveNote, + recordAddClip, + recordRemoveClip, startBatch, endBatch, cancelBatch, diff --git a/webapp/src/views/admin/AdminDashboard.vue b/webapp/src/views/admin/AdminDashboard.vue new file mode 100644 index 0000000..2a9ca9f --- /dev/null +++ b/webapp/src/views/admin/AdminDashboard.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/webapp/src/views/admin/AdminFolderDetail.vue b/webapp/src/views/admin/AdminFolderDetail.vue new file mode 100644 index 0000000..63436ce --- /dev/null +++ b/webapp/src/views/admin/AdminFolderDetail.vue @@ -0,0 +1,433 @@ + + + + + diff --git a/webapp/src/views/admin/AdminPackDetail.vue b/webapp/src/views/admin/AdminPackDetail.vue new file mode 100644 index 0000000..c73f4c8 --- /dev/null +++ b/webapp/src/views/admin/AdminPackDetail.vue @@ -0,0 +1,479 @@ + + + + + diff --git a/webapp/src/views/admin/AdminSamples.vue b/webapp/src/views/admin/AdminSamples.vue new file mode 100644 index 0000000..6b91619 --- /dev/null +++ b/webapp/src/views/admin/AdminSamples.vue @@ -0,0 +1,504 @@ + + + + + diff --git a/webapp/src/views/admin/AdminUsers.vue b/webapp/src/views/admin/AdminUsers.vue new file mode 100644 index 0000000..fec7cd1 --- /dev/null +++ b/webapp/src/views/admin/AdminUsers.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/webapp/src/views/admin/CLAUDE.md b/webapp/src/views/admin/CLAUDE.md new file mode 100644 index 0000000..8f123ec --- /dev/null +++ b/webapp/src/views/admin/CLAUDE.md @@ -0,0 +1,224 @@ +# Interface Admin - Documentation Technique + +> Interface d'administration pour la gestion des utilisateurs et de la bibliothèque de samples. + +## Vue d'ensemble + +L'admin permet de : +- Gérer les utilisateurs (rôles, activation) +- Gérer la bibliothèque de samples (packs → folders → samples) +- Uploader des fichiers audio vers Cloudflare R2 + +## Architecture + +``` +views/admin/ +├── AdminDashboard.vue # Stats globales +├── AdminUsers.vue # Liste + gestion users +├── AdminSamples.vue # Liste des packs +├── AdminPackDetail.vue # Détail pack + folders +├── AdminFolderDetail.vue # Détail folder + samples + upload +└── CLAUDE.md + +components/admin/ +└── SampleUploader.vue # Drag & drop upload vers R2 + +layouts/ +└── AdminLayout.vue # Sidebar + header admin + +stores/ +└── adminStore.ts # État + actions CRUD +``` + +## Store `adminStore` + +Pinia store centralisant toute la logique admin. + +### État + +```typescript +// Users +users: AdminUser[] +usersPagination: Pagination +usersLoading: boolean + +// Packs +packs: AdminPack[] +packsPagination: Pagination +packsLoading: boolean + +// Détails +currentPack: AdminPack | null +currentFolders: AdminFolder[] +currentFolder: AdminFolder | null +currentSamples: AdminSample[] + +// Stats +stats: { totalUsers, totalPacks, totalSamples } +``` + +### Actions principales + +| Action | Description | +|--------|-------------| +| `fetchUsers(page, search?)` | Liste paginée avec recherche | +| `updateUser(id, data)` | Modifie rôle/isActive | +| `deleteUser(id)` | Désactive l'utilisateur | +| `fetchPacks(page)` | Liste des packs | +| `fetchPackDetail(id)` | Pack + ses folders | +| `createPack/updatePack/deletePack` | CRUD packs | +| `createFolder/updateFolder/deleteFolder` | CRUD folders | +| `fetchSamples(folderId)` | Samples d'un folder | +| `createSample/updateSample/deleteSample` | CRUD samples | +| `uploadFile(file, packSlug, folderName?)` | Upload vers R2 | + +## Routes API Admin + +Base: `/api/admin` + +### Users +- `GET /users` - Liste paginée +- `PATCH /users/:id` - Update role/isActive +- `DELETE /users/:id` - Soft delete + +### Samples +- `GET /samples/packs` - Liste packs +- `GET /samples/packs/:id` - Détail + folders +- `POST /samples/packs` - Créer pack +- `PUT /samples/packs/:id` - Update pack +- `DELETE /samples/packs/:id` - Supprimer (cascade) + +- `GET /samples/packs/:packId/folders` - Folders d'un pack +- `POST /samples/packs/:packId/folders` - Créer folder +- `PUT /samples/folders/:id` - Update folder +- `DELETE /samples/folders/:id` - Supprimer (cascade) + +- `GET /samples/folders/:folderId/samples` - Samples d'un folder +- `POST /samples/folders/:folderId/samples` - Créer sample +- `PUT /samples/samples/:id` - Update sample +- `DELETE /samples/samples/:id` - Supprimer + +### Upload +- `POST /upload` - Upload fichier vers R2 + - `multipart/form-data` avec `file`, `packSlug`, `folderName?` + - Retourne `{ filename, key, url, size, mimetype }` + +## Stockage R2 (Cloudflare) + +### Configuration (.env) +``` +R2_ACCOUNT_ID=xxx +R2_ACCESS_KEY_ID=xxx +R2_SECRET_ACCESS_KEY=xxx +R2_BUCKET_NAME=bloop-samples +CDN_BASE_URL=https://samples.bloop-on.cloud +``` + +### Structure des fichiers +``` +R2 bucket: bloop-samples/ +├── samples/ +│ └── {packSlug}/ +│ └── {folderName}/ +│ └── {uuid}_{filename}.wav +``` + +### URL publique +Les samples sont servis via le CDN : `https://samples.bloop-on.cloud/samples/...` + +Le champ `fullUrl` de chaque sample contient l'URL complète. + +## Entités TypeORM (server) + +### SamplePack +```typescript +@Entity("sample_packs") +{ + id: string (uuid) + slug: string (unique, URL-friendly) + name: string + author?: string + cover?: string + featured: boolean + isActive: boolean + folders: SampleFolder[] +} +``` + +### SampleFolder +```typescript +@Entity("sample_folders") +{ + id: string (uuid) + name: string + order: number + pack: SamplePack + packId: string + samples: AudioSample[] +} +``` + +### AudioSample +```typescript +@Entity("audio_samples") +{ + id: string (uuid) + name: string + filename: string + duration: number + waveform?: number[] + folder: SampleFolder + folderId: string + previewUrl?: string + fullUrl: string // URL CDN obligatoire +} +``` + +## SampleUploader + +Composant drag & drop pour l'upload de samples. + +### Props +```typescript +packSlug: string // Slug du pack (pour le path R2) +folderName?: string // Nom du folder (pour le path R2) +folderId: string // ID du folder (pour la création en DB) +``` + +### Emit +```typescript +emit('uploaded', sample) // Quand un sample est créé +``` + +### Flow +1. User drop/sélectionne fichiers audio +2. Pour chaque fichier : + - Upload vers R2 via `adminStore.uploadFile()` + - Crée le sample en DB via `adminStore.createSample()` + - Émet `uploaded` avec le sample créé +3. Progress bar pendant l'upload + +## Points d'attention + +### CORS R2 +Le bucket R2 doit autoriser les origins : +- `http://localhost:3000` (dev) +- `https://bloop-on.cloud` (prod) +- `https://staging.bloop-on.cloud` (staging) + +### Accès admin +- Route protégée par `role === "ROLE_ADMIN"` +- Middleware backend vérifie le rôle +- Bouton admin visible dans le header uniquement pour les admins + +### Slug des packs +- Format: `[a-z0-9-]+` +- Utilisé pour l'URL et le path R2 +- Non modifiable après création + +## Conventions + +- Couleurs admin : rose `#ff3fb4`, fond sombre `#2a1520`, `#1a0e15` +- Modals avec overlay semi-transparent +- Confirmation avant suppression (alert natif pour l'instant) +- Toast/notifications non implémentés (à faire) diff --git a/webapp/src/views/app/BloopApp.vue b/webapp/src/views/app/BloopApp.vue index 40b3daa..dc607bf 100644 --- a/webapp/src/views/app/BloopApp.vue +++ b/webapp/src/views/app/BloopApp.vue @@ -3,17 +3,21 @@ import { useMainStore } from "../../stores/mainStore"; import { storeToRefs } from "pinia"; import AppLayout from "../../layouts/AppLayout.vue"; import { TimelineView } from "../../components/app/timeline"; -import { computed, onMounted, ref } from "vue"; +import AudioLibraryPanel from "../../components/app/timeline/AudioLibraryPanel.vue"; +import DawLoadingOverlay from "../../components/app/DawLoadingOverlay.vue"; +import { computed, onMounted, ref, provide } from "vue"; import { useRoute, useRouter } from "vue-router"; import { useTimelineStore } from "../../stores/timelineStore"; import { useTrackAudioStore } from "../../stores/trackAudioStore"; import { useProjectStore } from "../../stores/projectStore"; +import { useDawLoadingStore } from "../../stores/dawLoadingStore"; const route = useRoute(); const router = useRouter(); const timelineStore = useTimelineStore(); const trackAudioStore = useTrackAudioStore(); const projectStore = useProjectStore(); +const dawLoadingStore = useDawLoadingStore(); const isNewProject = computed(() => route.query.new === "true"); const projectIdFromUrl = computed( @@ -21,6 +25,10 @@ const projectIdFromUrl = computed( ); const loadError = ref(null); +const showAudioLibrary = ref(false); + +// Provide sidebar state to children +provide("showAudioLibrary", showAudioLibrary); const mainStore = useMainStore(); const { isLoaded, loadPercentage } = storeToRefs(mainStore); @@ -32,36 +40,35 @@ onMounted(async () => { } loadError.value = null; + dawLoadingStore.reset(); if (isNewProject.value) { - // Nouveau projet projectStore.resetProject(); timelineStore.createNewProject("Nouveau Projet"); } else if (projectIdFromUrl.value) { - // Charger depuis l'API const result = await projectStore.loadProjectToTimeline( projectIdFromUrl.value, timelineStore, ); if (!result.success) { loadError.value = result.error || "Erreur lors du chargement"; - // Fallback: créer un nouveau projet projectStore.resetProject(); timelineStore.createNewProject("Nouveau Projet"); } } else { - // Charger depuis localStorage (comportement par défaut) projectStore.resetProject(); timelineStore.initialize(); } trackAudioStore.initialize(); + + // Précharger les ressources du projet + await dawLoadingStore.preloadProject(timelineStore.project); }); const handleSave = async () => { const result = await projectStore.saveProjectOnline(timelineStore.project); if (result.success && result.projectId) { - // Mettre à jour l'URL avec le projectId router.replace({ name: "app-sequencer", query: { projectId: result.projectId }, @@ -74,15 +81,20 @@ const handleBackToProjects = () => { router.push({ name: "app-main" }); }; -// Exposer les fonctions pour TimelineView +const toggleAudioLibrary = () => { + showAudioLibrary.value = !showAudioLibrary.value; +}; + defineExpose({ handleSave, handleBackToProjects, + toggleAudioLibrary, });