From 6c11c5756a8b8d30fe14533fe1d83e18e0738a79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sun, 12 Oct 2025 11:17:58 +0200 Subject: [PATCH] Cleaning the codebase --- src/App.svelte | 653 +++++++++-------------- src/lib/components/ProcessorPopup.svelte | 128 +++++ src/lib/components/VUMeter.svelte | 76 ++- src/lib/components/WelcomeModal.svelte | 159 ++++++ src/lib/utils/keyboard.ts | 54 ++ 5 files changed, 681 insertions(+), 389 deletions(-) create mode 100644 src/lib/components/ProcessorPopup.svelte create mode 100644 src/lib/components/WelcomeModal.svelte create mode 100644 src/lib/utils/keyboard.ts diff --git a/src/App.svelte b/src/App.svelte index f8c9137..5a6eef2 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -2,63 +2,81 @@ import { onMount } from "svelte"; import WaveformDisplay from "./lib/components/WaveformDisplay.svelte"; import VUMeter from "./lib/components/VUMeter.svelte"; + import WelcomeModal from "./lib/components/WelcomeModal.svelte"; + import ProcessorPopup from "./lib/components/ProcessorPopup.svelte"; import { engines } from "./lib/audio/engines/registry"; import type { SynthEngine } from "./lib/audio/engines/SynthEngine"; import type { EngineType } from "./lib/audio/engines/SynthEngine"; import { AudioService } from "./lib/audio/services/AudioService"; import { downloadWAV } from "./lib/audio/utils/WAVEncoder"; - import { - loadVolume, - saveVolume, - loadDuration, - saveDuration, - } from "./lib/utils/settings"; + import { loadVolume, saveVolume, loadDuration, saveDuration } from "./lib/utils/settings"; import { generateRandomColor } from "./lib/utils/colors"; - import { - getRandomProcessor, - getAllProcessors, - } from "./lib/audio/processors/registry"; + import { getRandomProcessor } from "./lib/audio/processors/registry"; import type { AudioProcessor } from "./lib/audio/processors/AudioProcessor"; import { Sample } from "./lib/audio/engines/Sample"; import { Input } from "./lib/audio/engines/Input"; + import { createKeyboardHandler } from "./lib/utils/keyboard"; - let currentEngineIndex = 0; - let engine = engines[currentEngineIndex]; - let engineType: EngineType = engine.getType(); - + let currentEngineIndex = $state(0); + const engine = $derived(engines[currentEngineIndex]); + const engineType = $derived(engine.getType()); const audioService = new AudioService(); - let currentParams: any = null; - let currentBuffer: AudioBuffer | null = null; - let duration = loadDuration(); - let volume = loadVolume(); - let playbackPosition = -1; - let waveformColor = generateRandomColor(); - let showModal = true; - let isProcessed = false; - let showProcessorPopup = false; - let popupTimeout: ReturnType | null = null; - let isRecording = false; - let isDragOver = false; + let currentParams = $state(null); + let currentBuffer = $state(null); + let duration = $state(loadDuration()); + let volume = $state(loadVolume()); + let playbackPosition = $state(-1); + let waveformColor = $state(generateRandomColor()); + let showModal = $state(true); + let isProcessed = $state(false); + let showProcessorPopup = $state(false); + let popupTimeout = $state | null>(null); + let isRecording = $state(false); + let isDragOver = $state(false); - const allProcessors = getAllProcessors().sort((a, b) => - a.getName().localeCompare(b.getName()) - ); + const showDuration = $derived(engineType !== 'sample'); + const showRandomButton = $derived(engineType === 'generative'); + const showRecordButton = $derived(engineType === 'input'); + const showFileDropZone = $derived(engineType === 'sample' && !currentBuffer); + const showMutateButton = $derived(engineType === 'generative' && !isProcessed && currentBuffer); - $: showDuration = engineType !== 'sample'; - $: showRandomButton = engineType === 'generative'; - $: showRecordButton = engineType === 'input'; - $: showFileDropZone = engineType === 'sample' && !currentBuffer; - $: showMutateButton = engineType === 'generative' && !isProcessed && currentBuffer; + $effect(() => { + audioService.setVolume(volume); + saveVolume(volume); + }); + + $effect(() => { + saveDuration(duration); + }); onMount(() => { - audioService.setVolume(volume); audioService.setPlaybackUpdateCallback((position) => { playbackPosition = position; }); generateRandom(); }); + const keyboardHandler = createKeyboardHandler({ + onMutate: mutate, + onRandom: generateRandom, + onProcess: processSound, + onDownload: download, + onDurationDecrease: (large) => { + duration = Math.max(0.05, duration - (large ? 1 : 0.05)); + }, + onDurationIncrease: (large) => { + duration = Math.min(32, duration + (large ? 1 : 0.05)); + }, + onVolumeDecrease: (large) => { + volume = Math.max(0, volume - (large ? 0.2 : 0.05)); + }, + onVolumeIncrease: (large) => { + volume = Math.min(1, volume + (large ? 0.2 : 0.05)); + }, + onEscape: () => showModal && closeModal(), + }); + function generateRandom() { currentParams = engine.randomParams(); waveformColor = generateRandomColor(); @@ -98,42 +116,7 @@ function processSound() { if (!currentBuffer) return; - - const processor = getRandomProcessor(); - applyProcessor(processor); - } - - function processWithSpecificProcessor(processor: AudioProcessor) { - if (!currentBuffer) return; - - hideProcessorPopup(); - applyProcessor(processor); - } - - function handlePopupMouseEnter() { - if (popupTimeout) { - clearTimeout(popupTimeout); - } - showProcessorPopup = true; - popupTimeout = setTimeout(() => { - showProcessorPopup = false; - }, 2000); - } - - function handlePopupMouseLeave() { - if (popupTimeout) { - clearTimeout(popupTimeout); - } - popupTimeout = setTimeout(() => { - showProcessorPopup = false; - }, 200); - } - - function hideProcessorPopup() { - if (popupTimeout) { - clearTimeout(popupTimeout); - } - showProcessorPopup = false; + applyProcessor(getRandomProcessor()); } async function applyProcessor(processor: AudioProcessor) { @@ -141,37 +124,16 @@ const leftChannel = currentBuffer.getChannelData(0); const rightChannel = currentBuffer.getChannelData(1); + const [processedLeft, processedRight] = await processor.process(leftChannel, rightChannel); - const [processedLeft, processedRight] = await processor.process( - leftChannel, - rightChannel, - ); - - currentBuffer = audioService.createAudioBuffer([ - processedLeft, - processedRight, - ]); + currentBuffer = audioService.createAudioBuffer([processedLeft, processedRight]); isProcessed = true; audioService.play(currentBuffer); - } - - function handleVolumeChange(event: Event) { - const target = event.target as HTMLInputElement; - volume = parseFloat(target.value); - audioService.setVolume(volume); - saveVolume(volume); - } - - function handleDurationChange(event: Event) { - const target = event.target as HTMLInputElement; - duration = parseFloat(target.value); - saveDuration(duration); + hideProcessorPopup(); } function switchEngine(index: number) { currentEngineIndex = index; - engine = engines[index]; - engineType = engine.getType(); currentBuffer = null; currentParams = null; isProcessed = false; @@ -183,10 +145,8 @@ async function handleFileInput(event: Event) { const input = event.target as HTMLInputElement; - if (!input.files || input.files.length === 0) return; - - const file = input.files[0]; - await loadAudioFile(file); + if (!input.files?.length) return; + await loadAudioFile(input.files[0]); } async function loadAudioFile(file: File) { @@ -205,8 +165,7 @@ } async function recordAudio() { - if (!(engine instanceof Input)) return; - if (isRecording) return; + if (!(engine instanceof Input) || isRecording) return; try { isRecording = true; @@ -227,9 +186,8 @@ event.preventDefault(); isDragOver = false; - if (!event.dataTransfer) return; - const files = event.dataTransfer.files; - if (files.length === 0) return; + const files = event.dataTransfer?.files; + if (!files?.length) return; const file = files[0]; if (!file.type.startsWith('audio/')) { @@ -250,79 +208,41 @@ isDragOver = false; } + function showPopup() { + if (popupTimeout) clearTimeout(popupTimeout); + showProcessorPopup = true; + popupTimeout = setTimeout(() => showProcessorPopup = false, 2000); + } + + function scheduleHidePopup() { + if (popupTimeout) clearTimeout(popupTimeout); + popupTimeout = setTimeout(() => showProcessorPopup = false, 200); + } + + function hideProcessorPopup() { + if (popupTimeout) clearTimeout(popupTimeout); + showProcessorPopup = false; + } + async function closeModal() { showModal = false; await audioService.initialize(); } - - function handleKeydown(event: KeyboardEvent) { - // Ignore if typing in an input - if (event.target instanceof HTMLInputElement) return; - - const key = event.key.toLowerCase(); - - // Close modal with Escape key - if (key === "escape" && showModal) { - closeModal(); - return; - } - - switch (key) { - case "m": - mutate(); - break; - case "r": - generateRandom(); - break; - case "p": - processSound(); - break; - case "s": - download(); - break; - case "arrowleft": - event.preventDefault(); - const durationDecrement = event.shiftKey ? 1 : 0.05; - duration = Math.max(0.05, duration - durationDecrement); - saveDuration(duration); - break; - case "arrowright": - event.preventDefault(); - const durationIncrement = event.shiftKey ? 1 : 0.05; - duration = Math.min(32, duration + durationIncrement); - saveDuration(duration); - break; - case "arrowdown": - event.preventDefault(); - const volumeDecrement = event.shiftKey ? 0.2 : 0.05; - volume = Math.max(0, volume - volumeDecrement); - audioService.setVolume(volume); - saveVolume(volume); - break; - case "arrowup": - event.preventDefault(); - const volumeIncrement = event.shiftKey ? 0.2 : 0.05; - volume = Math.min(1, volume + volumeIncrement); - audioService.setVolume(volume); - saveVolume(volume); - break; - } - } - +
- {#each engines as engine, index} + {#each engines as currentEngine, index} {/each}
@@ -336,8 +256,7 @@ min="0.05" max="32" step="0.01" - value={duration} - oninput={handleDurationChange} + bind:value={duration} />
{/if} @@ -349,8 +268,7 @@ min="0" max="1" step="0.01" - value={volume} - oninput={handleVolumeChange} + bind:value={volume} />
@@ -407,22 +325,12 @@
{#if showProcessorPopup} -
- {#each allProcessors as processor} - - {/each} -
+ {/if}
@@ -435,46 +343,7 @@ {#if showModal} - + {/if} @@ -488,9 +357,8 @@ .top-bar { display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; + flex-direction: column; + gap: 0.75rem; padding: 0.5rem; background-color: #1a1a1a; border-bottom: 1px solid #333; @@ -499,11 +367,35 @@ .mode-buttons { display: flex; gap: 0.5rem; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + scrollbar-color: #444 transparent; + padding-bottom: 0.25rem; + } + + .mode-buttons::-webkit-scrollbar { + height: 4px; + } + + .mode-buttons::-webkit-scrollbar-track { + background: transparent; + } + + .mode-buttons::-webkit-scrollbar-thumb { + background: #444; + border-radius: 0; } .engine-button { opacity: 0.7; position: relative; + flex-shrink: 0; + font-size: 0.85rem; + padding: 0.5rem 0.75rem; + white-space: nowrap; + min-width: calc(25vw - 1rem); } .engine-button:hover { @@ -525,47 +417,111 @@ border: 1px solid #444; color: #ccc; font-size: 0.85rem; - width: 30vw; + width: 200px; + max-width: 90vw; white-space: normal; word-wrap: break-word; pointer-events: none; opacity: 0; transition: opacity 0.2s; z-index: 1000; - } - - .engine-button:hover::after { - opacity: 1; + display: none; } .controls-group { display: flex; - gap: 1rem; - align-items: center; + flex-direction: column; + gap: 0.5rem; + width: 100%; } .slider-control { display: flex; align-items: center; gap: 0.5rem; + width: 100%; } .slider-control label { - font-size: 0.9rem; + font-size: 0.85rem; white-space: nowrap; + min-width: fit-content; } .slider-control input[type="range"] { - width: 100px; + flex: 1; + min-width: 0; } - .duration-slider input[type="range"] { - width: 300px; + @media (min-width: 768px) { + .top-bar { + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 1rem; + } + + .mode-buttons { + flex: 1; + overflow-x: auto; + padding-bottom: 0; + max-width: 60%; + } + + .engine-button { + font-size: 0.9rem; + padding: 0.6rem 1rem; + min-width: auto; + } + + .engine-button::after { + display: block; + width: 250px; + } + + .engine-button:hover::after { + opacity: 1; + } + + .controls-group { + flex-direction: row; + width: auto; + flex-shrink: 0; + } + + .slider-control input[type="range"] { + width: 80px; + } + + .duration-slider input[type="range"] { + width: 150px; + } + } + + @media (min-width: 1024px) { + .mode-buttons { + max-width: 65%; + } + + .slider-control input[type="range"] { + width: 100px; + } + + .duration-slider input[type="range"] { + width: 200px; + } + } + + @media (min-width: 1280px) { + .duration-slider input[type="range"] { + width: 300px; + } } .main-area { flex: 1; display: flex; + flex-direction: column; background-color: #0a0a0a; overflow: hidden; } @@ -577,22 +533,77 @@ align-items: center; justify-content: center; overflow: hidden; + min-height: 200px; } .vu-meter-container { - width: 5%; - min-width: 40px; - max-width: 80px; - border-left: 1px solid #333; + width: 100%; + height: 60px; + border-top: 1px solid #333; } .bottom-controls { position: absolute; - bottom: 2rem; + bottom: 1rem; left: 50%; transform: translateX(-50%); display: flex; - gap: 1rem; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; + padding: 0 0.5rem; + max-width: 95%; + } + + .bottom-controls button { + font-size: 0.85rem; + padding: 0.5rem 0.75rem; + white-space: nowrap; + } + + .process-button-container { + position: relative; + } + + @media (min-width: 768px) { + .main-area { + flex-direction: row; + } + + .waveform-container { + min-height: auto; + } + + .vu-meter-container { + width: 5%; + min-width: 50px; + max-width: 80px; + height: auto; + border-left: 1px solid #333; + border-top: none; + } + + .bottom-controls { + gap: 0.75rem; + bottom: 1.5rem; + } + + .bottom-controls button { + font-size: 0.9rem; + padding: 0.6rem 1rem; + } + } + + @media (min-width: 1024px) { + .bottom-controls { + gap: 1rem; + bottom: 2rem; + } + + .bottom-controls button { + font-size: 1rem; + padding: 0.75rem 1.25rem; + } } input[type="range"] { @@ -647,144 +658,6 @@ background: #ddd; } - .modal-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.8); - display: flex; - justify-content: center; - align-items: center; - z-index: 2000; - } - - .modal-content { - background-color: #000; - border: 2px solid #fff; - padding: 2rem; - max-width: 500px; - width: 90%; - color: #fff; - } - - .modal-content h1 { - margin: 0 0 1rem 0; - font-size: 2rem; - font-weight: bold; - } - - .modal-content .description { - margin: 0 0 1.5rem 0; - line-height: 1.6; - color: #ccc; - } - - .modal-links { - margin: 1.5rem 0; - padding: 1rem 0; - border-top: 1px solid #333; - border-bottom: 1px solid #333; - } - - .modal-links p { - margin: 0.5rem 0; - font-size: 0.9rem; - color: #ccc; - } - - .modal-links a { - color: #646cff; - text-decoration: none; - } - - .modal-links a:hover { - text-decoration: underline; - } - - .modal-close { - margin-top: 1rem; - width: 100%; - padding: 0.75rem; - font-size: 1.1rem; - background-color: #fff; - color: #000; - border: none; - cursor: pointer; - font-weight: bold; - } - - .modal-close:hover { - background-color: #ddd; - } - - .process-button-container { - position: relative; - } - - .processor-popup { - position: absolute; - bottom: 100%; - left: 50%; - transform: translateX(-50%); - background-color: #000; - border: 2px solid #fff; - padding: 0.75rem; - z-index: 1000; - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 0.5rem; - width: 600px; - margin-bottom: 0.5rem; - } - - .processor-tile { - background-color: #1a1a1a; - border: 1px solid #444; - padding: 0.6rem 0.4rem; - text-align: center; - cursor: pointer; - transition: background-color 0.2s, border-color 0.2s; - font-size: 0.85rem; - color: #fff; - position: relative; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .processor-tile:hover { - background-color: #2a2a2a; - border-color: #646cff; - } - - .processor-tile::after { - content: attr(data-description); - position: absolute; - bottom: 100%; - left: 50%; - transform: translateX(-50%); - padding: 0.5rem 0.75rem; - background-color: #0a0a0a; - border: 1px solid #444; - color: #ccc; - font-size: 0.85rem; - width: max-content; - max-width: 300px; - white-space: normal; - word-wrap: break-word; - pointer-events: none; - opacity: 0; - transition: opacity 0.2s; - z-index: 1001; - margin-bottom: 0.25rem; - } - - .processor-tile:hover::after { - opacity: 1; - } - .file-drop-zone { width: 100%; height: 100%; @@ -794,6 +667,7 @@ border: 2px dashed #444; background-color: #0a0a0a; transition: all 0.2s; + padding: 1rem; } .file-drop-zone.drag-over { @@ -807,31 +681,38 @@ } .drop-zone-content h2 { - font-size: 1.5rem; - margin-bottom: 1rem; + font-size: 1.2rem; + margin-bottom: 0.75rem; color: #fff; } - .drop-zone-content p { - margin: 1rem 0; - font-size: 1rem; - } - .file-input-label { display: inline-block; - padding: 0.75rem 1.5rem; + padding: 0.65rem 1.25rem; background-color: #646cff; color: #fff; border: 1px solid #646cff; cursor: pointer; transition: background-color 0.2s; - font-size: 1rem; + font-size: 0.95rem; } .file-input-label:hover { background-color: #535bf2; } + @media (min-width: 768px) { + .drop-zone-content h2 { + font-size: 1.5rem; + margin-bottom: 1rem; + } + + .file-input-label { + padding: 0.75rem 1.5rem; + font-size: 1rem; + } + } + button:disabled { opacity: 0.5; cursor: not-allowed; diff --git a/src/lib/components/ProcessorPopup.svelte b/src/lib/components/ProcessorPopup.svelte new file mode 100644 index 0000000..88dd43a --- /dev/null +++ b/src/lib/components/ProcessorPopup.svelte @@ -0,0 +1,128 @@ + + +
+ {#each allProcessors as processor} + + {/each} +
+ + diff --git a/src/lib/components/VUMeter.svelte b/src/lib/components/VUMeter.svelte index 611893c..9ebff03 100644 --- a/src/lib/components/VUMeter.svelte +++ b/src/lib/components/VUMeter.svelte @@ -88,10 +88,20 @@ if (!buffer) return; const [leftDB, rightDB] = calculateLevels(); + const isHorizontal = width > height; + + if (isHorizontal) { + drawHorizontal(ctx, leftDB, rightDB, width, height); + } else { + drawVertical(ctx, leftDB, rightDB, width, height); + } + } + + function drawVertical(ctx: CanvasRenderingContext2D, leftDB: number, rightDB: number, width: number, height: number) { const channelWidth = width / 2; - drawChannel(ctx, 0, leftDB, channelWidth, height); - drawChannel(ctx, channelWidth, rightDB, channelWidth, height); + drawChannelVertical(ctx, 0, leftDB, channelWidth, height); + drawChannelVertical(ctx, channelWidth, rightDB, channelWidth, height); ctx.strokeStyle = '#333'; ctx.lineWidth = 1; @@ -101,6 +111,20 @@ ctx.stroke(); } + function drawHorizontal(ctx: CanvasRenderingContext2D, leftDB: number, rightDB: number, width: number, height: number) { + const channelHeight = height / 2; + + drawChannelHorizontal(ctx, 0, leftDB, width, channelHeight); + drawChannelHorizontal(ctx, channelHeight, rightDB, width, channelHeight); + + ctx.strokeStyle = '#333'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, channelHeight); + ctx.lineTo(width, channelHeight); + ctx.stroke(); + } + function dbToY(db: number, height: number): number { const minDB = -60; const maxDB = 0; @@ -109,7 +133,15 @@ return height - (normalized * height); } - function drawChannel(ctx: CanvasRenderingContext2D, x: number, levelDB: number, width: number, height: number) { + function dbToX(db: number, width: number): number { + const minDB = -60; + const maxDB = 0; + const clampedDB = Math.max(minDB, Math.min(maxDB, db)); + const normalized = (clampedDB - minDB) / (maxDB - minDB); + return normalized * width; + } + + function drawChannelVertical(ctx: CanvasRenderingContext2D, x: number, levelDB: number, width: number, height: number) { const gridMarks = [0, -3, -6, -10, -20, -40, -60]; ctx.strokeStyle = '#222'; ctx.lineWidth = 1; @@ -146,6 +178,44 @@ } } } + + function drawChannelHorizontal(ctx: CanvasRenderingContext2D, y: number, levelDB: number, width: number, height: number) { + const gridMarks = [0, -3, -6, -10, -20, -40, -60]; + ctx.strokeStyle = '#222'; + ctx.lineWidth = 1; + for (const db of gridMarks) { + const x = dbToX(db, width); + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x, y + height); + ctx.stroke(); + } + + if (levelDB === -Infinity) return; + + const segments = [ + { startDB: -60, endDB: -18, color: '#00ff00' }, + { startDB: -18, endDB: -6, color: '#ffff00' }, + { startDB: -6, endDB: 0, color: '#ff0000' } + ]; + + for (const segment of segments) { + if (levelDB >= segment.startDB) { + const startX = dbToX(segment.startDB, width); + const endX = dbToX(segment.endDB, width); + + const clampedLevelDB = Math.min(levelDB, segment.endDB); + const levelX = dbToX(clampedLevelDB, width); + + const segmentWidth = levelX - startX; + + if (segmentWidth > 0) { + ctx.fillStyle = segment.color; + ctx.fillRect(startX, y, segmentWidth, height); + } + } + } + } diff --git a/src/lib/components/WelcomeModal.svelte b/src/lib/components/WelcomeModal.svelte new file mode 100644 index 0000000..ac2d878 --- /dev/null +++ b/src/lib/components/WelcomeModal.svelte @@ -0,0 +1,159 @@ + + + + + diff --git a/src/lib/utils/keyboard.ts b/src/lib/utils/keyboard.ts new file mode 100644 index 0000000..0eb9366 --- /dev/null +++ b/src/lib/utils/keyboard.ts @@ -0,0 +1,54 @@ +export interface KeyboardActions { + onMutate?: () => void; + onRandom?: () => void; + onProcess?: () => void; + onDownload?: () => void; + onDurationDecrease?: (large: boolean) => void; + onDurationIncrease?: (large: boolean) => void; + onVolumeDecrease?: (large: boolean) => void; + onVolumeIncrease?: (large: boolean) => void; + onEscape?: () => void; +} + +export function createKeyboardHandler(actions: KeyboardActions) { + return (event: KeyboardEvent) => { + if (event.target instanceof HTMLInputElement) return; + + const key = event.key.toLowerCase(); + const isLargeAdjustment = event.shiftKey; + + switch (key) { + case 'm': + actions.onMutate?.(); + break; + case 'r': + actions.onRandom?.(); + break; + case 'p': + actions.onProcess?.(); + break; + case 's': + actions.onDownload?.(); + break; + case 'arrowleft': + event.preventDefault(); + actions.onDurationDecrease?.(isLargeAdjustment); + break; + case 'arrowright': + event.preventDefault(); + actions.onDurationIncrease?.(isLargeAdjustment); + break; + case 'arrowdown': + event.preventDefault(); + actions.onVolumeDecrease?.(isLargeAdjustment); + break; + case 'arrowup': + event.preventDefault(); + actions.onVolumeIncrease?.(isLargeAdjustment); + break; + case 'escape': + actions.onEscape?.(); + break; + } + }; +}