From a5a3c5a9fb967ed2d829ce8334bf7416007ae42a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Lavi=C3=A9ville?= <91781579+TuberculeP@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:11:31 +0200 Subject: [PATCH 1/7] feat: improving responsive on learning (#205) --- webapp/src/components/blog/ProjectLinkRenderer.vue | 4 ++++ webapp/src/components/ui/BaseInput.vue | 6 ++++++ webapp/src/components/ui/BaseSelect.vue | 6 ++++++ webapp/src/styles/learning.css | 1 + 4 files changed, 17 insertions(+) diff --git a/webapp/src/components/blog/ProjectLinkRenderer.vue b/webapp/src/components/blog/ProjectLinkRenderer.vue index dc8009a..18ad407 100644 --- a/webapp/src/components/blog/ProjectLinkRenderer.vue +++ b/webapp/src/components/blog/ProjectLinkRenderer.vue @@ -205,8 +205,10 @@ function deriveLabelFromUrl(url: string) { .project-card { display: inline-flex; + flex-wrap: wrap; align-items: center; gap: 6px; + max-width: 100%; padding: 3px 10px; border-radius: 8px; text-decoration: none; @@ -217,6 +219,8 @@ function deriveLabelFromUrl(url: string) { font-size: 0.9em; cursor: pointer; vertical-align: middle; + overflow-wrap: break-word; + box-sizing: border-box; transition: background 0.15s ease, border-color 0.15s ease; 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/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); } From 5dfc8a63c657cbbc72d56c04c75ddf8e390a7e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9line=20Eap?= <113120569+Elyn03@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:21:58 +0200 Subject: [PATCH 2/7] Merge pull request #206 from TuberculeP/fix-message-profile fix: email user in message & profil project link --- .../components/messages/UserSearchList.vue | 40 +++---------------- .../src/views/profile/PublicProfileView.vue | 3 +- 2 files changed, 6 insertions(+), 37 deletions(-) diff --git a/webapp/src/components/messages/UserSearchList.vue b/webapp/src/components/messages/UserSearchList.vue index 8ae6d02..8598bf3 100644 --- a/webapp/src/components/messages/UserSearchList.vue +++ b/webapp/src/components/messages/UserSearchList.vue @@ -1,5 +1,6 @@ + + + + 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..f8af804 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, @@ -359,6 +360,8 @@ const { handleCloneProject, } = useTimelineProjectMeta(); +useStretchRecompute(); + const handleAddTrack = (type: InstrumentType) => { const config = getDefaultConfigForType(type); timelineStore.createTrack(config); @@ -877,6 +880,29 @@ defineExpose({ @update:model-value="timelineStore.setZoomWheelSpeed" /> +
+ Redimensionnement des clips audio +
+
+
+ + +
+
Signature rythmique
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/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/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/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 4294691..1fc5f9a 100644 --- a/webapp/src/lib/utils/types.ts +++ b/webapp/src/lib/utils/types.ts @@ -236,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/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/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 13ffdc5..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. @@ -647,6 +656,7 @@ export const useTimelineStore = defineStore("timelineStore", () => { x: cutPosition, w: rightW, startOffset: rightStartOffset, + ...pickStretchFields(clip), }); track.updatedAt = new Date(); @@ -975,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)); @@ -1235,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..af4c267 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 => { From fd6ef494d978d0c4ec103a0881c8bae6ca199455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Lavi=C3=A9ville?= <91781579+TuberculeP@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:12:17 +0200 Subject: [PATCH 5/7] fix: projects load correctly when passing from one to another (#209) --- webapp/src/stores/projectStore.ts | 4 ++++ webapp/src/stores/trackAudioStore.ts | 8 -------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/webapp/src/stores/projectStore.ts b/webapp/src/stores/projectStore.ts index 0cd53c0..3bfee7b 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( @@ -327,6 +328,9 @@ 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)); diff --git a/webapp/src/stores/trackAudioStore.ts b/webapp/src/stores/trackAudioStore.ts index af4c267..7db86e8 100644 --- a/webapp/src/stores/trackAudioStore.ts +++ b/webapp/src/stores/trackAudioStore.ts @@ -448,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(); From 5ba9a15e721978cf8c3e7078ec3abec71cc66214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Lavi=C3=A9ville?= <91781579+TuberculeP@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:00:20 +0200 Subject: [PATCH 6/7] Buttons and share (#210) * fix: moving back-to-menu button to right * feat: adding share to blog feature --- .../components/app/timeline/TimelineView.vue | 98 ++++++++++++++++--- webapp/src/components/blog/BlogCreate.vue | 10 ++ .../timelineView/useTimelineProjectMeta.ts | 62 +++++++++++- webapp/src/stores/projectStore.ts | 30 ++++++ 4 files changed, 187 insertions(+), 13 deletions(-) diff --git a/webapp/src/components/app/timeline/TimelineView.vue b/webapp/src/components/app/timeline/TimelineView.vue index f8af804..90f1026 100644 --- a/webapp/src/components/app/timeline/TimelineView.vue +++ b/webapp/src/components/app/timeline/TimelineView.vue @@ -75,7 +75,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. @@ -358,6 +358,13 @@ const { handleBackToProjects, handleResetReadOnly, handleCloneProject, + showShareMenu, + closeShareMenu, + toggleShareMenu, + isTogglingVisibility, + setVisibility, + copyShareLink, + shareToBlog, } = useTimelineProjectMeta(); useStretchRecompute(); @@ -655,13 +662,6 @@ defineExpose({
- + + + + +
+
@@ -1547,7 +1602,8 @@ defineExpose({ .mic-picker-dropdown, .midi-picker-dropdown, -.toolbar-settings-dropdown { +.toolbar-settings-dropdown, +.share-menu-dropdown { position: absolute; top: calc(100% + 8px); background: var(--color-bg-secondary-dark); @@ -1566,7 +1622,8 @@ defineExpose({ .mic-picker-header, .midi-picker-header, -.toolbar-settings-header { +.toolbar-settings-header, +.share-menu-header { padding: 10px 14px; font-size: 11px; font-weight: 600; @@ -1578,7 +1635,8 @@ defineExpose({ } .mic-picker-option, -.midi-picker-option { +.midi-picker-option, +.share-menu-option { width: 100%; display: block; text-align: left; @@ -1843,7 +1901,8 @@ defineExpose({ } } -.toolbar-settings { +.toolbar-settings, +.share-menu { position: relative; } @@ -1852,6 +1911,21 @@ defineExpose({ min-width: 260px; } +.share-menu-dropdown { + right: 0; + min-width: 220px; +} + +.share-menu-option { + display: flex; + align-items: center; + gap: 8px; +} + +.share-menu-row { + padding: 12px 14px; +} + .toolbar-settings-row { padding: 12px 14px; diff --git a/webapp/src/components/blog/BlogCreate.vue b/webapp/src/components/blog/BlogCreate.vue index 7fab71f..4a89812 100644 --- a/webapp/src/components/blog/BlogCreate.vue +++ b/webapp/src/components/blog/BlogCreate.vue @@ -1,5 +1,6 @@ + + + + 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/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/timeline/TimelineView.vue b/webapp/src/components/app/timeline/TimelineView.vue index 90f1026..f3b694b 100644 --- a/webapp/src/components/app/timeline/TimelineView.vue +++ b/webapp/src/components/app/timeline/TimelineView.vue @@ -49,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<{ ( @@ -1264,6 +1265,8 @@ defineExpose({ + + 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/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); }