Compare commits

...

4 Commits

Author SHA1 Message Date
3cadc23238 Fixing warnings 2025-12-25 14:56:55 +01:00
aba4abb054 Feat: adding filter and 'All' button to processors 2025-11-11 11:19:05 +01:00
dfb57a082f OK, rename 2025-10-14 02:11:59 +02:00
6116745795 Working on processors a tiny bit 2025-10-13 18:09:47 +02:00
54 changed files with 1264 additions and 266 deletions

View File

@ -1,4 +1,4 @@
# poof # rsgp
Audio synthesis web application for generating random sound samples. Users generate sounds via different synthesis engines, mutate parameters, apply audio processors in multiple passes, and export as WAV. Built for musicians seeking unexpected textures and one-shots. Audio synthesis web application for generating random sound samples. Users generate sounds via different synthesis engines, mutate parameters, apply audio processors in multiple passes, and export as WAV. Built for musicians seeking unexpected textures and one-shots.
@ -39,8 +39,8 @@ pnpm build
## Docker ## Docker
```sh ```sh
docker build -t poof . docker build -t rsgp .
docker run -p 8080:80 poof docker run -p 8080:80 rsgp
``` ```
Opens on http://localhost:8080 Opens on http://localhost:8080

View File

@ -4,9 +4,9 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Poof: a sample generator" /> <meta name="description" content="RSGP: Random Sample Generator and Processor" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<title>Poof</title> <title>RSGP</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -1,5 +1,5 @@
{ {
"name": "poof", "name": "rsgp",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",

BIN
public/tutorial.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

View File

@ -5,15 +5,35 @@
import WelcomeModal from "./lib/components/WelcomeModal.svelte"; import WelcomeModal from "./lib/components/WelcomeModal.svelte";
import ProcessorPopup from "./lib/components/ProcessorPopup.svelte"; import ProcessorPopup from "./lib/components/ProcessorPopup.svelte";
import { engines } from "./lib/audio/engines/registry"; import { engines } from "./lib/audio/engines/registry";
import type { SynthEngine, PitchLock } from "./lib/audio/engines/base/SynthEngine"; import type {
SynthEngine,
PitchLock,
} from "./lib/audio/engines/base/SynthEngine";
import type { EngineType } from "./lib/audio/engines/base/SynthEngine"; import type { EngineType } from "./lib/audio/engines/base/SynthEngine";
import { AudioService } from "./lib/audio/services/AudioService"; import { AudioService } from "./lib/audio/services/AudioService";
import { downloadWAV } from "./lib/audio/utils/WAVEncoder"; import { downloadWAV } from "./lib/audio/utils/WAVEncoder";
import { loadVolume, saveVolume, loadDuration, saveDuration, loadPitchLockEnabled, savePitchLockEnabled, loadPitchLockFrequency, savePitchLockFrequency, loadExpandedCategories, saveExpandedCategories } from "./lib/utils/settings"; import {
import { cropAudio, cutAudio, processSelection } from "./lib/audio/utils/AudioEdit"; loadVolume,
saveVolume,
loadDuration,
saveDuration,
loadPitchLockEnabled,
savePitchLockEnabled,
loadPitchLockFrequency,
savePitchLockFrequency,
loadExpandedCategories,
saveExpandedCategories,
loadSelectedProcessorCategory,
saveSelectedProcessorCategory,
} from "./lib/utils/settings";
import {
cropAudio,
cutAudio,
processSelection,
} from "./lib/audio/utils/AudioEdit";
import { generateRandomColor } from "./lib/utils/colors"; import { generateRandomColor } from "./lib/utils/colors";
import { getRandomProcessor } from "./lib/audio/processors/registry"; import { getRandomProcessor } from "./lib/audio/processors/registry";
import type { AudioProcessor } from "./lib/audio/processors/AudioProcessor"; import type { AudioProcessor, ProcessorCategory } from "./lib/audio/processors/AudioProcessor";
import { Sample } from "./lib/audio/engines/Sample"; import { Sample } from "./lib/audio/engines/Sample";
import { Input } from "./lib/audio/engines/Input"; import { Input } from "./lib/audio/engines/Input";
import { createKeyboardHandler } from "./lib/utils/keyboard"; import { createKeyboardHandler } from "./lib/utils/keyboard";
@ -32,6 +52,7 @@
let duration = $state(loadDuration()); let duration = $state(loadDuration());
let volume = $state(loadVolume()); let volume = $state(loadVolume());
let playbackPosition = $state(-1); let playbackPosition = $state(-1);
let cuePoint = $state(0);
let waveformColor = $state(generateRandomColor()); let waveformColor = $state(generateRandomColor());
let showModal = $state(true); let showModal = $state(true);
let isProcessed = $state(false); let isProcessed = $state(false);
@ -48,15 +69,25 @@
let canUndo = $state(false); let canUndo = $state(false);
let sidebarOpen = $state(false); let sidebarOpen = $state(false);
let expandedCategories = $state<Set<string>>(loadExpandedCategories()); let expandedCategories = $state<Set<string>>(loadExpandedCategories());
let selectedProcessorCategory = $state<ProcessorCategory | 'All'>(
loadSelectedProcessorCategory()
);
const showDuration = $derived(engineType !== 'sample'); const showDuration = $derived(engineType !== "sample");
const showRandomButton = $derived(engineType === 'generative'); const showRandomButton = $derived(engineType === "generative");
const showRecordButton = $derived(engineType === 'input'); const showRecordButton = $derived(engineType === "input");
const showFileDropZone = $derived(engineType === 'sample' && !currentBuffer); const showFileDropZone = $derived(engineType === "sample" && !currentBuffer);
const showMutateButton = $derived(engineType === 'generative' && !isProcessed && currentBuffer); const showMutateButton = $derived(
const showPitchLock = $derived(engineType === 'generative'); engineType === "generative" && !isProcessed && currentBuffer,
const pitchLock = $derived<PitchLock>({ enabled: pitchLockEnabled, frequency: pitchLockFrequency }); );
const hasSelection = $derived(selectionStart !== null && selectionEnd !== null && currentBuffer !== null); const showPitchLock = $derived(engineType === "generative");
const pitchLock = $derived<PitchLock>({
enabled: pitchLockEnabled,
frequency: pitchLockFrequency,
});
const hasSelection = $derived(
selectionStart !== null && selectionEnd !== null && currentBuffer !== null,
);
const showEditButtons = $derived(hasSelection); const showEditButtons = $derived(hasSelection);
$effect(() => { $effect(() => {
@ -80,6 +111,10 @@
saveExpandedCategories(expandedCategories); saveExpandedCategories(expandedCategories);
}); });
$effect(() => {
saveSelectedProcessorCategory(selectedProcessorCategory);
});
// Group engines by category // Group engines by category
const enginesByCategory = $derived.by(() => { const enginesByCategory = $derived.by(() => {
const grouped = new Map<EngineCategory, typeof engines>(); const grouped = new Map<EngineCategory, typeof engines>();
@ -103,6 +138,10 @@
expandedCategories = newSet; expandedCategories = newSet;
} }
function selectProcessorCategory(category: ProcessorCategory | 'All') {
selectedProcessorCategory = category;
}
onMount(() => { onMount(() => {
audioService.setPlaybackUpdateCallback((position) => { audioService.setPlaybackUpdateCallback((position) => {
playbackPosition = position; playbackPosition = position;
@ -116,7 +155,7 @@
currentParams, currentParams,
isProcessed, isProcessed,
waveformColor, waveformColor,
currentEngineIndex currentEngineIndex,
); );
} }
@ -129,7 +168,10 @@
} }
function restoreState(state: AudioState): void { function restoreState(state: AudioState): void {
currentBuffer = audioService.createAudioBuffer([state.leftChannel, state.rightChannel]); currentBuffer = audioService.createAudioBuffer([
state.leftChannel,
state.rightChannel,
]);
currentParams = state.params; currentParams = state.params;
isProcessed = state.isProcessed; isProcessed = state.isProcessed;
waveformColor = state.waveformColor; waveformColor = state.waveformColor;
@ -154,7 +196,7 @@
onProcess: processSound, onProcess: processSound,
onDownload: download, onDownload: download,
onUndo: undo, onUndo: undo,
onPlayFromStart: replaySound, onPlayFromStart: togglePlayback,
onDurationDecrease: (large) => { onDurationDecrease: (large) => {
duration = Math.max(0.05, duration - (large ? 1 : 0.05)); duration = Math.max(0.05, duration - (large ? 1 : 0.05));
}, },
@ -219,8 +261,14 @@
if (!currentParams) return; if (!currentParams) return;
const sampleRate = audioService.getSampleRate(); const sampleRate = audioService.getSampleRate();
const data = await engine.generate(currentParams, sampleRate, duration, pitchLock); const data = await engine.generate(
currentParams,
sampleRate,
duration,
pitchLock,
);
currentBuffer = audioService.createAudioBuffer(data); currentBuffer = audioService.createAudioBuffer(data);
cuePoint = 0;
audioService.play(currentBuffer); audioService.play(currentBuffer);
} }
@ -230,9 +278,15 @@
} }
} }
function playFromPosition(offset: number) { function setCuePoint(offset: number) {
if (currentBuffer) { cuePoint = offset;
audioService.play(currentBuffer, offset); }
function togglePlayback() {
if (playbackPosition >= 0) {
audioService.stop();
} else if (currentBuffer) {
audioService.play(currentBuffer, cuePoint);
} }
} }
@ -264,15 +318,21 @@
start, start,
end, end,
processor, processor,
sampleRate sampleRate,
); );
} else { } else {
const leftChannel = currentBuffer.getChannelData(0); const leftChannel = currentBuffer.getChannelData(0);
const rightChannel = currentBuffer.getChannelData(1); const rightChannel = currentBuffer.getChannelData(1);
[processedLeft, processedRight] = await processor.process(leftChannel, rightChannel); [processedLeft, processedRight] = await processor.process(
leftChannel,
rightChannel,
);
} }
currentBuffer = audioService.createAudioBuffer([processedLeft, processedRight]); currentBuffer = audioService.createAudioBuffer([
processedLeft,
processedRight,
]);
isProcessed = true; isProcessed = true;
audioService.play(currentBuffer); audioService.play(currentBuffer);
hideProcessorPopup(); hideProcessorPopup();
@ -288,7 +348,7 @@
clearSelection(); clearSelection();
sidebarOpen = false; sidebarOpen = false;
if (engineType === 'generative') { if (engineType === "generative") {
generateRandom(); generateRandom();
} }
} }
@ -311,7 +371,7 @@
isProcessed = false; isProcessed = false;
regenerateBuffer(); regenerateBuffer();
} catch (error) { } catch (error) {
console.error('Failed to load audio file:', error); console.error("Failed to load audio file:", error);
alert(`Failed to load audio file: ${error}`); alert(`Failed to load audio file: ${error}`);
} }
} }
@ -329,7 +389,7 @@
isProcessed = false; isProcessed = false;
regenerateBuffer(); regenerateBuffer();
} catch (error) { } catch (error) {
console.error('Failed to record audio:', error); console.error("Failed to record audio:", error);
alert(`Failed to record audio: ${error}`); alert(`Failed to record audio: ${error}`);
} finally { } finally {
isRecording = false; isRecording = false;
@ -344,8 +404,8 @@
if (!files?.length) return; if (!files?.length) return;
const file = files[0]; const file = files[0];
if (!file.type.startsWith('audio/')) { if (!file.type.startsWith("audio/")) {
alert('Please drop an audio file'); alert("Please drop an audio file");
return; return;
} }
@ -365,12 +425,17 @@
function showPopup() { function showPopup() {
if (popupTimeout) clearTimeout(popupTimeout); if (popupTimeout) clearTimeout(popupTimeout);
showProcessorPopup = true; showProcessorPopup = true;
popupTimeout = setTimeout(() => showProcessorPopup = false, 2000); popupTimeout = setTimeout(() => (showProcessorPopup = false), 2000);
}
function keepPopupOpen() {
if (popupTimeout) clearTimeout(popupTimeout);
showProcessorPopup = true;
} }
function scheduleHidePopup() { function scheduleHidePopup() {
if (popupTimeout) clearTimeout(popupTimeout); if (popupTimeout) clearTimeout(popupTimeout);
popupTimeout = setTimeout(() => showProcessorPopup = false, 200); popupTimeout = setTimeout(() => (showProcessorPopup = false), 200);
} }
function hideProcessorPopup() { function hideProcessorPopup() {
@ -389,7 +454,8 @@
} }
function cropSelection() { function cropSelection() {
if (!currentBuffer || selectionStart === null || selectionEnd === null) return; if (!currentBuffer || selectionStart === null || selectionEnd === null)
return;
pushState(); pushState();
@ -397,7 +463,12 @@
const end = Math.max(selectionStart, selectionEnd); const end = Math.max(selectionStart, selectionEnd);
const sampleRate = audioService.getSampleRate(); const sampleRate = audioService.getSampleRate();
const [newLeft, newRight] = cropAudio(currentBuffer, start, end, sampleRate); const [newLeft, newRight] = cropAudio(
currentBuffer,
start,
end,
sampleRate,
);
currentBuffer = audioService.createAudioBuffer([newLeft, newRight]); currentBuffer = audioService.createAudioBuffer([newLeft, newRight]);
clearSelection(); clearSelection();
@ -405,7 +476,8 @@
} }
function cutSelection() { function cutSelection() {
if (!currentBuffer || selectionStart === null || selectionEnd === null) return; if (!currentBuffer || selectionStart === null || selectionEnd === null)
return;
pushState(); pushState();
@ -434,31 +506,54 @@
<div class="container"> <div class="container">
{#if sidebarOpen} {#if sidebarOpen}
<div class="sidebar-overlay" onclick={toggleSidebar} onkeydown={(e) => e.key === 'Escape' && toggleSidebar()} role="button" tabindex="-1" aria-label="Close sidebar"></div> <div
class="sidebar-overlay"
onclick={toggleSidebar}
onkeydown={(e) => e.key === "Escape" && toggleSidebar()}
role="button"
tabindex="-1"
aria-label="Close sidebar"
></div>
{/if} {/if}
<div class="top-bar"> <div class="top-bar">
<button class="hamburger" onclick={toggleSidebar} aria-label="Toggle engine menu"> <button
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> class="hamburger"
onclick={toggleSidebar}
aria-label="Toggle engine menu"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="3" y1="6" x2="21" y2="6" /> <line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" /> <line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" /> <line x1="3" y1="18" x2="21" y2="18" />
</svg> </svg>
</button> </button>
<h1 class="app-title">Poof: a sample generator</h1> <h1 class="app-title">RSGP: Random Sample Generator and Processor</h1>
<div class="controls-group"> <div class="controls-group">
{#if showPitchLock} {#if showPitchLock}
<div class="control-item pitch-lock-control"> <div class="control-item pitch-lock-control">
<div class="control-header"> <div class="control-header">
<label for="pitch">Pitch</label> <label for="pitch">Pitch</label>
<label class="custom-checkbox" title="Lock pitch across random/mutate"> <label
<input class="custom-checkbox"
type="checkbox" title="Lock pitch across random/mutate"
bind:checked={pitchLockEnabled} >
/> <input type="checkbox" bind:checked={pitchLockEnabled} />
<span class="checkbox-box" class:checked={pitchLockEnabled}> <span class="checkbox-box" class:checked={pitchLockEnabled}>
{#if pitchLockEnabled} {#if pitchLockEnabled}
<svg width="10" height="10" viewBox="0 0 10 10" fill="none"> <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M1 5L4 8L9 2" stroke="currentColor" stroke-width="2" stroke-linecap="square"/> <path
d="M1 5L4 8L9 2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="square"
/>
</svg> </svg>
{/if} {/if}
</span> </span>
@ -521,8 +616,19 @@
class:collapsed={!expandedCategories.has(category)} class:collapsed={!expandedCategories.has(category)}
onclick={() => toggleCategory(category)} onclick={() => toggleCategory(category)}
> >
<svg class="category-arrow" width="12" height="12" viewBox="0 0 12 12" fill="none"> <svg
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="square"/> class="category-arrow"
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg> </svg>
<span>{category}</span> <span>{category}</span>
</button> </button>
@ -575,10 +681,11 @@
buffer={currentBuffer} buffer={currentBuffer}
color={waveformColor} color={waveformColor}
{playbackPosition} {playbackPosition}
{cuePoint}
{selectionStart} {selectionStart}
{selectionEnd} {selectionEnd}
onselectionchange={handleSelectionChange} onselectionchange={handleSelectionChange}
onclick={playFromPosition} onclick={setCuePoint}
/> />
{/if} {/if}
@ -588,7 +695,7 @@
{/if} {/if}
{#if showRecordButton} {#if showRecordButton}
<button onclick={recordAudio} disabled={isRecording}> <button onclick={recordAudio} disabled={isRecording}>
{isRecording ? 'Recording...' : 'Record'} {isRecording ? "Recording..." : "Record"}
</button> </button>
{/if} {/if}
{#if showMutateButton} {#if showMutateButton}
@ -607,7 +714,13 @@
> >
<button onclick={processSound}>Process (P)</button> <button onclick={processSound}>Process (P)</button>
{#if showProcessorPopup} {#if showProcessorPopup}
<ProcessorPopup onselect={applyProcessor} /> <div role="menu" tabindex="-1" onmouseenter={keepPopupOpen} onmouseleave={scheduleHidePopup}>
<ProcessorPopup
onselect={applyProcessor}
selectedCategory={selectedProcessorCategory}
onselectcategory={selectProcessorCategory}
/>
</div>
{/if} {/if}
</div> </div>
<button onclick={download}>Download (D)</button> <button onclick={download}>Download (D)</button>
@ -692,7 +805,9 @@
border: 1px solid #3a3a3a; border: 1px solid #3a3a3a;
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
transition: border-color 0.2s, background-color 0.2s; transition:
border-color 0.2s,
background-color 0.2s;
} }
.hamburger svg { .hamburger svg {
@ -759,7 +874,9 @@
color: #999; color: #999;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
transition: color 0.2s, background-color 0.2s; transition:
color 0.2s,
background-color 0.2s;
flex-shrink: 0; flex-shrink: 0;
} }
@ -950,7 +1067,10 @@
color: #fff; color: #fff;
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
transition: border-color 0.2s, background-color 0.2s, box-shadow 0.2s; transition:
border-color 0.2s,
background-color 0.2s,
box-shadow 0.2s;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
box-sizing: border-box; box-sizing: border-box;
} }
@ -981,7 +1101,6 @@
font-weight: 400; font-weight: 400;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.sidebar { .sidebar {
position: static; position: static;

View File

@ -1,6 +1,9 @@
export type ProcessorCategory = 'Amplitude' | 'Filter' | 'Time' | 'Space' | 'Pitch' | 'Modulation' | 'Distortion' | 'Spectral' | 'Utility';
export interface AudioProcessor { export interface AudioProcessor {
getName(): string; getName(): string;
getDescription(): string; getDescription(): string;
getCategory(): ProcessorCategory;
process( process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from "./AudioProcessor"; import type { AudioProcessor, ProcessorCategory } from "./AudioProcessor";
export class BitCrusher implements AudioProcessor { export class BitCrusher implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class BitCrusher implements AudioProcessor {
return "Reduces bit depth for lo-fi digital distortion"; return "Reduces bit depth for lo-fi digital distortion";
} }
getCategory(): ProcessorCategory {
return 'Distortion';
}
async process( async process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class Chorus implements AudioProcessor { export class Chorus implements AudioProcessor {
private readonly sampleRate = 44100; private readonly sampleRate = 44100;
@ -11,6 +11,10 @@ export class Chorus implements AudioProcessor {
return 'Multiple delayed copies with pitch modulation for thick, ensemble sounds'; return 'Multiple delayed copies with pitch modulation for thick, ensemble sounds';
} }
getCategory(): ProcessorCategory {
return 'Time';
}
process( process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from "./AudioProcessor"; import type { AudioProcessor, ProcessorCategory } from "./AudioProcessor";
export class Compressor implements AudioProcessor { export class Compressor implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class Compressor implements AudioProcessor {
return "Reduces dynamic range by taming peaks with makeup gain"; return "Reduces dynamic range by taming peaks with makeup gain";
} }
getCategory(): ProcessorCategory {
return 'Amplitude';
}
async process( async process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
type RoomType = 'small' | 'medium' | 'large' | 'hall' | 'plate' | 'chamber'; type RoomType = 'small' | 'medium' | 'large' | 'hall' | 'plate' | 'chamber';
@ -22,6 +22,10 @@ export class ConvolutionReverb implements AudioProcessor {
return 'Realistic room ambience using Web Audio ConvolverNode with synthetic impulse responses'; return 'Realistic room ambience using Web Audio ConvolverNode with synthetic impulse responses';
} }
getCategory(): ProcessorCategory {
return 'Space';
}
async process( async process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from "./AudioProcessor"; import type { AudioProcessor, ProcessorCategory } from "./AudioProcessor";
export class DCOffsetRemover implements AudioProcessor { export class DCOffsetRemover implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class DCOffsetRemover implements AudioProcessor {
return "Removes DC offset bias from the audio signal"; return "Removes DC offset bias from the audio signal";
} }
getCategory(): ProcessorCategory {
return 'Utility';
}
async process( async process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class ExpFadeIn implements AudioProcessor { export class ExpFadeIn implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class ExpFadeIn implements AudioProcessor {
return 'Applies an exponential fade from silence to current level'; return 'Applies an exponential fade from silence to current level';
} }
getCategory(): ProcessorCategory {
return 'Amplitude';
}
process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] { process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] {
const length = leftIn.length; const length = leftIn.length;

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class ExpFadeOut implements AudioProcessor { export class ExpFadeOut implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class ExpFadeOut implements AudioProcessor {
return 'Applies an exponential fade from current level to silence'; return 'Applies an exponential fade from current level to silence';
} }
getCategory(): ProcessorCategory {
return 'Amplitude';
}
process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] { process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] {
const length = leftIn.length; const length = leftIn.length;

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from "./AudioProcessor"; import type { AudioProcessor, ProcessorCategory } from "./AudioProcessor";
export class HaasEffect implements AudioProcessor { export class HaasEffect implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class HaasEffect implements AudioProcessor {
return "Creates stereo width with micro-delay (precedence effect)"; return "Creates stereo width with micro-delay (precedence effect)";
} }
getCategory(): ProcessorCategory {
return 'Space';
}
async process( async process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -0,0 +1,65 @@
import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class HighPassSweepDown implements AudioProcessor {
getName(): string {
return 'HP Sweep Down';
}
getDescription(): string {
return 'Sweeps a high-pass filter from thin to full';
}
getCategory(): ProcessorCategory {
return 'Filter';
}
process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] {
const length = leftIn.length;
const leftOut = new Float32Array(length);
const rightOut = new Float32Array(length);
const sampleRate = 44100;
const startFreq = 8000;
const endFreq = 20;
const Q = 1.5;
let lx1 = 0, lx2 = 0, ly1 = 0, ly2 = 0;
let rx1 = 0, rx2 = 0, ry1 = 0, ry2 = 0;
let b0 = 0, b1 = 0, b2 = 0, a1 = 0, a2 = 0;
for (let i = 0; i < length; i++) {
if (i % 64 === 0) {
const t = i / length;
const freq = startFreq * Math.pow(endFreq / startFreq, t);
const omega = 2.0 * Math.PI * freq / sampleRate;
const alpha = Math.sin(omega) / (2.0 * Q);
const a0 = 1.0 + alpha;
b0 = ((1.0 + Math.cos(omega)) / 2.0) / a0;
b1 = (-(1.0 + Math.cos(omega))) / a0;
b2 = b0;
a1 = (-2.0 * Math.cos(omega)) / a0;
a2 = (1.0 - alpha) / a0;
}
const lx0 = leftIn[i];
const rx0 = rightIn[i];
leftOut[i] = b0 * lx0 + b1 * lx1 + b2 * lx2 - a1 * ly1 - a2 * ly2;
rightOut[i] = b0 * rx0 + b1 * rx1 + b2 * rx2 - a1 * ry1 - a2 * ry2;
lx2 = lx1;
lx1 = lx0;
ly2 = ly1;
ly1 = leftOut[i];
rx2 = rx1;
rx1 = rx0;
ry2 = ry1;
ry1 = rightOut[i];
}
return [leftOut, rightOut];
}
}

View File

@ -0,0 +1,65 @@
import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class HighPassSweepUp implements AudioProcessor {
getName(): string {
return 'HP Sweep Up';
}
getDescription(): string {
return 'Sweeps a high-pass filter from full to thin';
}
getCategory(): ProcessorCategory {
return 'Filter';
}
process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] {
const length = leftIn.length;
const leftOut = new Float32Array(length);
const rightOut = new Float32Array(length);
const sampleRate = 44100;
const startFreq = 20;
const endFreq = 8000;
const Q = 1.5;
let lx1 = 0, lx2 = 0, ly1 = 0, ly2 = 0;
let rx1 = 0, rx2 = 0, ry1 = 0, ry2 = 0;
let b0 = 0, b1 = 0, b2 = 0, a1 = 0, a2 = 0;
for (let i = 0; i < length; i++) {
if (i % 64 === 0) {
const t = i / length;
const freq = startFreq * Math.pow(endFreq / startFreq, t);
const omega = 2.0 * Math.PI * freq / sampleRate;
const alpha = Math.sin(omega) / (2.0 * Q);
const a0 = 1.0 + alpha;
b0 = ((1.0 + Math.cos(omega)) / 2.0) / a0;
b1 = (-(1.0 + Math.cos(omega))) / a0;
b2 = b0;
a1 = (-2.0 * Math.cos(omega)) / a0;
a2 = (1.0 - alpha) / a0;
}
const lx0 = leftIn[i];
const rx0 = rightIn[i];
leftOut[i] = b0 * lx0 + b1 * lx1 + b2 * lx2 - a1 * ly1 - a2 * ly2;
rightOut[i] = b0 * rx0 + b1 * rx1 + b2 * rx2 - a1 * ry1 - a2 * ry2;
lx2 = lx1;
lx1 = lx0;
ly2 = ly1;
ly1 = leftOut[i];
rx2 = rx1;
rx1 = rx0;
ry2 = ry1;
ry1 = rightOut[i];
}
return [leftOut, rightOut];
}
}

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class LinearFadeIn implements AudioProcessor { export class LinearFadeIn implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class LinearFadeIn implements AudioProcessor {
return 'Applies a linear fade from silence to current level'; return 'Applies a linear fade from silence to current level';
} }
getCategory(): ProcessorCategory {
return 'Amplitude';
}
process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] { process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] {
const length = leftIn.length; const length = leftIn.length;

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class LinearFadeOut implements AudioProcessor { export class LinearFadeOut implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class LinearFadeOut implements AudioProcessor {
return 'Applies a linear fade from current level to silence'; return 'Applies a linear fade from current level to silence';
} }
getCategory(): ProcessorCategory {
return 'Amplitude';
}
process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] { process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] {
const length = leftIn.length; const length = leftIn.length;

View File

@ -0,0 +1,65 @@
import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class LowPassSweepDown implements AudioProcessor {
getName(): string {
return 'LP Sweep Down';
}
getDescription(): string {
return 'Sweeps a low-pass filter from bright to dark';
}
getCategory(): ProcessorCategory {
return 'Filter';
}
process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] {
const length = leftIn.length;
const leftOut = new Float32Array(length);
const rightOut = new Float32Array(length);
const sampleRate = 44100;
const startFreq = 18000;
const endFreq = 100;
const Q = 1.5;
let lx1 = 0, lx2 = 0, ly1 = 0, ly2 = 0;
let rx1 = 0, rx2 = 0, ry1 = 0, ry2 = 0;
let b0 = 0, b1 = 0, b2 = 0, a1 = 0, a2 = 0;
for (let i = 0; i < length; i++) {
if (i % 64 === 0) {
const t = i / length;
const freq = startFreq * Math.pow(endFreq / startFreq, t);
const omega = 2.0 * Math.PI * freq / sampleRate;
const alpha = Math.sin(omega) / (2.0 * Q);
const a0 = 1.0 + alpha;
b0 = ((1.0 - Math.cos(omega)) / 2.0) / a0;
b1 = (1.0 - Math.cos(omega)) / a0;
b2 = b0;
a1 = (-2.0 * Math.cos(omega)) / a0;
a2 = (1.0 - alpha) / a0;
}
const lx0 = leftIn[i];
const rx0 = rightIn[i];
leftOut[i] = b0 * lx0 + b1 * lx1 + b2 * lx2 - a1 * ly1 - a2 * ly2;
rightOut[i] = b0 * rx0 + b1 * rx1 + b2 * rx2 - a1 * ry1 - a2 * ry2;
lx2 = lx1;
lx1 = lx0;
ly2 = ly1;
ly1 = leftOut[i];
rx2 = rx1;
rx1 = rx0;
ry2 = ry1;
ry1 = rightOut[i];
}
return [leftOut, rightOut];
}
}

View File

@ -0,0 +1,65 @@
import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class LowPassSweepUp implements AudioProcessor {
getName(): string {
return 'LP Sweep Up';
}
getDescription(): string {
return 'Sweeps a low-pass filter from dark to bright';
}
getCategory(): ProcessorCategory {
return 'Filter';
}
process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] {
const length = leftIn.length;
const leftOut = new Float32Array(length);
const rightOut = new Float32Array(length);
const sampleRate = 44100;
const startFreq = 100;
const endFreq = 18000;
const Q = 1.5;
let lx1 = 0, lx2 = 0, ly1 = 0, ly2 = 0;
let rx1 = 0, rx2 = 0, ry1 = 0, ry2 = 0;
let b0 = 0, b1 = 0, b2 = 0, a1 = 0, a2 = 0;
for (let i = 0; i < length; i++) {
if (i % 64 === 0) {
const t = i / length;
const freq = startFreq * Math.pow(endFreq / startFreq, t);
const omega = 2.0 * Math.PI * freq / sampleRate;
const alpha = Math.sin(omega) / (2.0 * Q);
const a0 = 1.0 + alpha;
b0 = ((1.0 - Math.cos(omega)) / 2.0) / a0;
b1 = (1.0 - Math.cos(omega)) / a0;
b2 = b0;
a1 = (-2.0 * Math.cos(omega)) / a0;
a2 = (1.0 - alpha) / a0;
}
const lx0 = leftIn[i];
const rx0 = rightIn[i];
leftOut[i] = b0 * lx0 + b1 * lx1 + b2 * lx2 - a1 * ly1 - a2 * ly2;
rightOut[i] = b0 * rx0 + b1 * rx1 + b2 * rx2 - a1 * ry1 - a2 * ry2;
lx2 = lx1;
lx1 = lx0;
ly2 = ly1;
ly1 = leftOut[i];
rx2 = rx1;
rx1 = rx0;
ry2 = ry1;
ry1 = rightOut[i];
}
return [leftOut, rightOut];
}
}

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class MicroPitch implements AudioProcessor { export class MicroPitch implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class MicroPitch implements AudioProcessor {
return 'Applies subtle random pitch variations for analog warmth and character'; return 'Applies subtle random pitch variations for analog warmth and character';
} }
getCategory(): ProcessorCategory {
return 'Pitch';
}
process( process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from "./AudioProcessor"; import type { AudioProcessor, ProcessorCategory } from "./AudioProcessor";
export class Normalize implements AudioProcessor { export class Normalize implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class Normalize implements AudioProcessor {
return "Normalizes audio to maximum amplitude without clipping"; return "Normalizes audio to maximum amplitude without clipping";
} }
getCategory(): ProcessorCategory {
return 'Amplitude';
}
async process( async process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from "./AudioProcessor"; import type { AudioProcessor, ProcessorCategory } from "./AudioProcessor";
export class OctaveDown implements AudioProcessor { export class OctaveDown implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class OctaveDown implements AudioProcessor {
return "Shifts pitch down one octave by halving playback rate"; return "Shifts pitch down one octave by halving playback rate";
} }
getCategory(): ProcessorCategory {
return 'Pitch';
}
async process( async process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from "./AudioProcessor"; import type { AudioProcessor, ProcessorCategory } from "./AudioProcessor";
export class OctaveUp implements AudioProcessor { export class OctaveUp implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class OctaveUp implements AudioProcessor {
return "Shifts pitch up one octave by doubling playback rate"; return "Shifts pitch up one octave by doubling playback rate";
} }
getCategory(): ProcessorCategory {
return 'Pitch';
}
async process( async process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -0,0 +1,34 @@
import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class PanLeftToRight implements AudioProcessor {
getName(): string {
return 'Pan L→R';
}
getDescription(): string {
return 'Gradually pans the sound from left to right';
}
getCategory(): ProcessorCategory {
return 'Space';
}
process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] {
const length = leftIn.length;
const leftOut = new Float32Array(length);
const rightOut = new Float32Array(length);
for (let i = 0; i < length; i++) {
const t = i / length;
const panAngle = t * Math.PI * 0.5;
const leftGain = Math.cos(panAngle);
const rightGain = Math.sin(panAngle);
const mono = (leftIn[i] + rightIn[i]) * 0.5;
leftOut[i] = mono * leftGain;
rightOut[i] = mono * rightGain;
}
return [leftOut, rightOut];
}
}

View File

@ -0,0 +1,34 @@
import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class PanRightToLeft implements AudioProcessor {
getName(): string {
return 'Pan R→L';
}
getDescription(): string {
return 'Gradually pans the sound from right to left';
}
getCategory(): ProcessorCategory {
return 'Space';
}
process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] {
const length = leftIn.length;
const leftOut = new Float32Array(length);
const rightOut = new Float32Array(length);
for (let i = 0; i < length; i++) {
const t = i / length;
const panAngle = (1.0 - t) * Math.PI * 0.5;
const leftGain = Math.cos(panAngle);
const rightGain = Math.sin(panAngle);
const mono = (leftIn[i] + rightIn[i]) * 0.5;
leftOut[i] = mono * leftGain;
rightOut[i] = mono * rightGain;
}
return [leftOut, rightOut];
}
}

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class PanShuffler implements AudioProcessor { export class PanShuffler implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class PanShuffler implements AudioProcessor {
return 'Smoothly pans segments across the stereo field'; return 'Smoothly pans segments across the stereo field';
} }
getCategory(): ProcessorCategory {
return 'Space';
}
process( process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from "./AudioProcessor"; import type { AudioProcessor, ProcessorCategory } from "./AudioProcessor";
export class PhaseInverter implements AudioProcessor { export class PhaseInverter implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class PhaseInverter implements AudioProcessor {
return "Inverts polarity of one or both channels"; return "Inverts polarity of one or both channels";
} }
getCategory(): ProcessorCategory {
return 'Utility';
}
async process( async process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class Phaser implements AudioProcessor { export class Phaser implements AudioProcessor {
private readonly sampleRate = 44100; private readonly sampleRate = 44100;
@ -11,6 +11,10 @@ export class Phaser implements AudioProcessor {
return 'Classic phaser effect with sweeping all-pass filters for swirling, spacey sounds'; return 'Classic phaser effect with sweeping all-pass filters for swirling, spacey sounds';
} }
getCategory(): ProcessorCategory {
return 'Filter';
}
process( process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class PitchShifter implements AudioProcessor { export class PitchShifter implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class PitchShifter implements AudioProcessor {
return 'Transposes audio up or down in semitones without changing duration'; return 'Transposes audio up or down in semitones without changing duration';
} }
getCategory(): ProcessorCategory {
return 'Pitch';
}
process( process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class PitchWobble implements AudioProcessor { export class PitchWobble implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class PitchWobble implements AudioProcessor {
return 'Variable-rate playback with LFO modulation for tape wow/vibrato effects'; return 'Variable-rate playback with LFO modulation for tape wow/vibrato effects';
} }
getCategory(): ProcessorCategory {
return 'Pitch';
}
process( process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class Resonator implements AudioProcessor { export class Resonator implements AudioProcessor {
private readonly sampleRate = 44100; private readonly sampleRate = 44100;
@ -11,6 +11,10 @@ export class Resonator implements AudioProcessor {
return 'Multi-band resonant filter bank that adds tonal character through resonance'; return 'Multi-band resonant filter bank that adds tonal character through resonance';
} }
getCategory(): ProcessorCategory {
return 'Filter';
}
process( process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class Reverser implements AudioProcessor { export class Reverser implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class Reverser implements AudioProcessor {
return 'Plays the sound backwards'; return 'Plays the sound backwards';
} }
getCategory(): ProcessorCategory {
return 'Time';
}
process( process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from "./AudioProcessor"; import type { AudioProcessor, ProcessorCategory } from "./AudioProcessor";
export class RingModulator implements AudioProcessor { export class RingModulator implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class RingModulator implements AudioProcessor {
return "Frequency modulation for metallic, bell-like tones"; return "Frequency modulation for metallic, bell-like tones";
} }
getCategory(): ProcessorCategory {
return 'Modulation';
}
async process( async process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class SegmentShuffler implements AudioProcessor { export class SegmentShuffler implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class SegmentShuffler implements AudioProcessor {
return 'Randomly reorganizes and swaps parts of the sound'; return 'Randomly reorganizes and swaps parts of the sound';
} }
getCategory(): ProcessorCategory {
return 'Time';
}
process( process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -0,0 +1,50 @@
import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class SlowTapeStop implements AudioProcessor {
getName(): string {
return 'Slow Tape Stop';
}
getDescription(): string {
return 'Simulates a tape machine gradually slowing to a stop';
}
getCategory(): ProcessorCategory {
return 'Time';
}
process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] {
const length = leftIn.length;
const leftOut = new Float32Array(length);
const rightOut = new Float32Array(length);
const stopPoint = length * 0.4;
const stopDuration = length * 0.6;
let readPos = 0;
for (let i = 0; i < length; i++) {
if (i < stopPoint) {
readPos = i;
} else {
const t = (i - stopPoint) / stopDuration;
const curve = 1.0 - (t * t * t);
const speed = Math.max(0, curve);
readPos += speed;
}
const idx = Math.floor(readPos);
const frac = readPos - idx;
if (idx < length - 1) {
leftOut[i] = leftIn[idx] * (1.0 - frac) + leftIn[idx + 1] * frac;
rightOut[i] = rightIn[idx] * (1.0 - frac) + rightIn[idx + 1] * frac;
} else if (idx < length) {
leftOut[i] = leftIn[idx];
rightOut[i] = rightIn[idx];
}
}
return [leftOut, rightOut];
}
}

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class SpectralBlur implements AudioProcessor { export class SpectralBlur implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class SpectralBlur implements AudioProcessor {
return 'Smears frequency content across neighboring bins for dreamy, diffused textures'; return 'Smears frequency content across neighboring bins for dreamy, diffused textures';
} }
getCategory(): ProcessorCategory {
return 'Spectral';
}
process( process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class SpectralShift implements AudioProcessor { export class SpectralShift implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class SpectralShift implements AudioProcessor {
return 'Shifts all frequencies by a fixed Hz amount creating inharmonic, metallic timbres'; return 'Shifts all frequencies by a fixed Hz amount creating inharmonic, metallic timbres';
} }
getCategory(): ProcessorCategory {
return 'Spectral';
}
process( process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class StereoSwap implements AudioProcessor { export class StereoSwap implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class StereoSwap implements AudioProcessor {
return 'Swaps left and right channels'; return 'Swaps left and right channels';
} }
getCategory(): ProcessorCategory {
return 'Space';
}
process( process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from "./AudioProcessor"; import type { AudioProcessor, ProcessorCategory } from "./AudioProcessor";
export class StereoWidener implements AudioProcessor { export class StereoWidener implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class StereoWidener implements AudioProcessor {
return "Expands stereo field using mid-side processing"; return "Expands stereo field using mid-side processing";
} }
getCategory(): ProcessorCategory {
return 'Space';
}
async process( async process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class Stutter implements AudioProcessor { export class Stutter implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class Stutter implements AudioProcessor {
return 'Rapidly repeats small fragments with smooth crossfades'; return 'Rapidly repeats small fragments with smooth crossfades';
} }
getCategory(): ProcessorCategory {
return 'Time';
}
process( process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -0,0 +1,49 @@
import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class TapeSpeedUp implements AudioProcessor {
getName(): string {
return 'Tape Speed Up';
}
getDescription(): string {
return 'Simulates a tape machine accelerating from slow to normal speed';
}
getCategory(): ProcessorCategory {
return 'Time';
}
process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] {
const length = leftIn.length;
const leftOut = new Float32Array(length);
const rightOut = new Float32Array(length);
const accelDuration = length * 0.3;
let readPos = 0;
for (let i = 0; i < length; i++) {
if (i < accelDuration) {
const t = i / accelDuration;
const curve = t * t * t;
const speed = curve;
readPos += speed;
} else {
readPos += 1.0;
}
const idx = Math.floor(readPos);
const frac = readPos - idx;
if (idx < length - 1) {
leftOut[i] = leftIn[idx] * (1.0 - frac) + leftIn[idx + 1] * frac;
rightOut[i] = rightIn[idx] * (1.0 - frac) + rightIn[idx + 1] * frac;
} else if (idx < length) {
leftOut[i] = leftIn[idx];
rightOut[i] = rightIn[idx];
}
}
return [leftOut, rightOut];
}
}

View File

@ -0,0 +1,50 @@
import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class TapeStop implements AudioProcessor {
getName(): string {
return 'Tape Stop';
}
getDescription(): string {
return 'Simulates a tape machine slowing to a stop';
}
getCategory(): ProcessorCategory {
return 'Time';
}
process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] {
const length = leftIn.length;
const leftOut = new Float32Array(length);
const rightOut = new Float32Array(length);
const stopPoint = length * 0.7;
const stopDuration = length * 0.3;
let readPos = 0;
for (let i = 0; i < length; i++) {
if (i < stopPoint) {
readPos = i;
} else {
const t = (i - stopPoint) / stopDuration;
const curve = 1.0 - (t * t * t);
const speed = Math.max(0, curve);
readPos += speed;
}
const idx = Math.floor(readPos);
const frac = readPos - idx;
if (idx < length - 1) {
leftOut[i] = leftIn[idx] * (1.0 - frac) + leftIn[idx + 1] * frac;
rightOut[i] = rightIn[idx] * (1.0 - frac) + rightIn[idx + 1] * frac;
} else if (idx < length) {
leftOut[i] = leftIn[idx];
rightOut[i] = rightIn[idx];
}
}
return [leftOut, rightOut];
}
}

View File

@ -0,0 +1,48 @@
import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class TapeWobble implements AudioProcessor {
getName(): string {
return 'Tape Wobble';
}
getDescription(): string {
return 'Simulates tape machine speed instability with pitch variations';
}
getCategory(): ProcessorCategory {
return 'Time';
}
process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] {
const length = leftIn.length;
const leftOut = new Float32Array(length);
const rightOut = new Float32Array(length);
const sampleRate = 44100;
const wobbleFreq = 2.0 + Math.random() * 2.0;
const wobbleDepth = 0.015;
let readPos = 0;
for (let i = 0; i < length; i++) {
const t = i / sampleRate;
const wobble = Math.sin(2.0 * Math.PI * wobbleFreq * t) * wobbleDepth;
const speed = 1.0 + wobble;
readPos += speed;
const idx = Math.floor(readPos);
const frac = readPos - idx;
if (idx < length - 1) {
leftOut[i] = leftIn[idx] * (1.0 - frac) + leftIn[idx + 1] * frac;
rightOut[i] = rightIn[idx] * (1.0 - frac) + rightIn[idx + 1] * frac;
} else if (idx < length) {
leftOut[i] = leftIn[idx];
rightOut[i] = rightIn[idx];
}
}
return [leftOut, rightOut];
}
}

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from './AudioProcessor'; import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class Tremolo implements AudioProcessor { export class Tremolo implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class Tremolo implements AudioProcessor {
return 'Applies rhythmic volume modulation'; return 'Applies rhythmic volume modulation';
} }
getCategory(): ProcessorCategory {
return 'Amplitude';
}
process( process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from "./AudioProcessor"; import type { AudioProcessor, ProcessorCategory } from "./AudioProcessor";
export class TrimSilence implements AudioProcessor { export class TrimSilence implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class TrimSilence implements AudioProcessor {
return "Removes leading and trailing silence from audio"; return "Removes leading and trailing silence from audio";
} }
getCategory(): ProcessorCategory {
return 'Utility';
}
async process( async process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -0,0 +1,50 @@
import type { AudioProcessor, ProcessorCategory } from './AudioProcessor';
export class VinylStop implements AudioProcessor {
getName(): string {
return 'Vinyl Stop';
}
getDescription(): string {
return 'Simulates a turntable slowing to a stop with realistic physics';
}
getCategory(): ProcessorCategory {
return 'Time';
}
process(leftIn: Float32Array, rightIn: Float32Array): [Float32Array, Float32Array] {
const length = leftIn.length;
const leftOut = new Float32Array(length);
const rightOut = new Float32Array(length);
const stopPoint = length * 0.7;
const stopDuration = length * 0.3;
let readPos = 0;
for (let i = 0; i < length; i++) {
if (i < stopPoint) {
readPos = i;
} else {
const t = (i - stopPoint) / stopDuration;
const curve = Math.exp(-5.0 * t);
const speed = Math.max(0, curve);
readPos += speed;
}
const idx = Math.floor(readPos);
const frac = readPos - idx;
if (idx < length - 1) {
leftOut[i] = leftIn[idx] * (1.0 - frac) + leftIn[idx + 1] * frac;
rightOut[i] = rightIn[idx] * (1.0 - frac) + rightIn[idx + 1] * frac;
} else if (idx < length) {
leftOut[i] = leftIn[idx];
rightOut[i] = rightIn[idx];
}
}
return [leftOut, rightOut];
}
}

View File

@ -1,4 +1,4 @@
import type { AudioProcessor } from "./AudioProcessor"; import type { AudioProcessor, ProcessorCategory } from "./AudioProcessor";
export class Waveshaper implements AudioProcessor { export class Waveshaper implements AudioProcessor {
getName(): string { getName(): string {
@ -9,6 +9,10 @@ export class Waveshaper implements AudioProcessor {
return "Transfer function distortion with various curve shapes"; return "Transfer function distortion with various curve shapes";
} }
getCategory(): ProcessorCategory {
return 'Distortion';
}
async process( async process(
leftChannel: Float32Array, leftChannel: Float32Array,
rightChannel: Float32Array rightChannel: Float32Array

View File

@ -29,6 +29,17 @@ import { LinearFadeOut } from './LinearFadeOut';
import { ExpFadeOut } from './ExpFadeOut'; import { ExpFadeOut } from './ExpFadeOut';
import { LinearFadeIn } from './LinearFadeIn'; import { LinearFadeIn } from './LinearFadeIn';
import { ExpFadeIn } from './ExpFadeIn'; import { ExpFadeIn } from './ExpFadeIn';
import { PanLeftToRight } from './PanLeftToRight';
import { PanRightToLeft } from './PanRightToLeft';
import { LowPassSweepDown } from './LowPassSweepDown';
import { LowPassSweepUp } from './LowPassSweepUp';
import { HighPassSweepDown } from './HighPassSweepDown';
import { HighPassSweepUp } from './HighPassSweepUp';
import { TapeStop } from './TapeStop';
import { SlowTapeStop } from './SlowTapeStop';
import { TapeSpeedUp } from './TapeSpeedUp';
import { VinylStop } from './VinylStop';
import { TapeWobble } from './TapeWobble';
const processors: AudioProcessor[] = [ const processors: AudioProcessor[] = [
new SegmentShuffler(), new SegmentShuffler(),
@ -61,6 +72,17 @@ const processors: AudioProcessor[] = [
new ExpFadeOut(), new ExpFadeOut(),
new LinearFadeIn(), new LinearFadeIn(),
new ExpFadeIn(), new ExpFadeIn(),
new PanLeftToRight(),
new PanRightToLeft(),
new LowPassSweepDown(),
new LowPassSweepUp(),
new HighPassSweepDown(),
new HighPassSweepUp(),
new TapeStop(),
new SlowTapeStop(),
new TapeSpeedUp(),
new VinylStop(),
new TapeWobble(),
]; ];
export function getRandomProcessor(): AudioProcessor { export function getRandomProcessor(): AudioProcessor {

View File

@ -1,20 +1,68 @@
<script lang="ts"> <script lang="ts">
import { getAllProcessors } from "../audio/processors/registry"; import { getAllProcessors } from "../audio/processors/registry";
import type { AudioProcessor } from "../audio/processors/AudioProcessor"; import type { AudioProcessor, ProcessorCategory } from "../audio/processors/AudioProcessor";
interface Props { interface Props {
onselect: (processor: AudioProcessor) => void; onselect: (processor: AudioProcessor) => void;
selectedCategory: ProcessorCategory | 'All';
onselectcategory: (category: ProcessorCategory | 'All') => void;
} }
let { onselect }: Props = $props(); let { onselect, selectedCategory, onselectcategory }: Props = $props();
const allProcessors = getAllProcessors().sort((a, b) => const allProcessors = getAllProcessors();
a.getName().localeCompare(b.getName())
const allCategories: ProcessorCategory[] = [
'Amplitude',
'Filter',
'Time',
'Space',
'Pitch',
'Modulation',
'Distortion',
'Spectral',
'Utility'
];
const categoryCountsMap = $derived.by(() => {
const counts = new Map<ProcessorCategory, number>();
for (const processor of allProcessors) {
const category = processor.getCategory();
counts.set(category, (counts.get(category) || 0) + 1);
}
return counts;
});
const filteredProcessors = $derived(
selectedCategory === 'All'
? allProcessors.sort((a, b) => a.getName().localeCompare(b.getName()))
: allProcessors
.filter(p => p.getCategory() === selectedCategory)
.sort((a, b) => a.getName().localeCompare(b.getName()))
); );
</script> </script>
<div class="processor-popup"> <div class="processor-popup">
{#each allProcessors as processor} <div class="popup-sidebar">
<button
class="category-filter"
class:active={selectedCategory === 'All'}
onclick={() => onselectcategory('All')}
>
All ({allProcessors.length})
</button>
{#each allCategories as category}
<button
class="category-filter"
class:active={selectedCategory === category}
onclick={() => onselectcategory(category)}
>
{category} ({categoryCountsMap.get(category) || 0})
</button>
{/each}
</div>
<div class="processors-grid">
{#each filteredProcessors as processor}
<button <button
class="processor-tile" class="processor-tile"
data-description={processor.getDescription()} data-description={processor.getDescription()}
@ -24,6 +72,7 @@
</button> </button>
{/each} {/each}
</div> </div>
</div>
<style> <style>
.processor-popup { .processor-popup {
@ -31,18 +80,74 @@
bottom: 100%; bottom: 100%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
background-color: #000; background-color: #0a0a0a;
border: 2px solid #fff; border: 2px solid #fff;
padding: 0.5rem;
z-index: 1000; z-index: 1000;
width: 90vw;
max-width: 500px;
margin-bottom: 0.5rem;
max-height: 60vh;
display: flex;
flex-direction: row;
overflow: hidden;
}
.popup-sidebar {
display: flex;
flex-direction: column;
background-color: #1a1a1a;
border-right: 1px solid #333;
min-width: 80px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #444 transparent;
}
.popup-sidebar::-webkit-scrollbar {
width: 3px;
}
.popup-sidebar::-webkit-scrollbar-track {
background: transparent;
}
.popup-sidebar::-webkit-scrollbar-thumb {
background: #444;
}
.category-filter {
font-size: 0.65rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.5rem 0.4rem;
background-color: #1a1a1a;
border: none;
border-bottom: 1px solid #2a2a2a;
color: #888;
text-align: left;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
white-space: nowrap;
}
.category-filter:hover {
background-color: #222;
color: #aaa;
}
.category-filter.active {
background-color: #0a0a0a;
color: #fff;
}
.processors-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 0.4rem; gap: 0.4rem;
width: 90vw; padding: 0.5rem;
max-width: 400px;
margin-bottom: 0.5rem;
max-height: 60vh;
overflow-y: auto; overflow-y: auto;
flex: 1;
} }
.processor-tile { .processor-tile {
@ -90,11 +195,23 @@
@media (min-width: 768px) { @media (min-width: 768px) {
.processor-popup { .processor-popup {
width: 550px;
max-width: 550px;
}
.popup-sidebar {
min-width: 100px;
}
.category-filter {
font-size: 0.7rem;
padding: 0.5rem 0.6rem;
}
.processors-grid {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
width: 500px;
max-width: 500px;
padding: 0.6rem;
gap: 0.5rem; gap: 0.5rem;
padding: 0.6rem;
} }
.processor-tile { .processor-tile {
@ -114,9 +231,16 @@
@media (min-width: 1024px) { @media (min-width: 1024px) {
.processor-popup { .processor-popup {
width: 650px;
max-width: 650px;
}
.popup-sidebar {
min-width: 110px;
}
.processors-grid {
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
width: 600px;
max-width: 600px;
padding: 0.75rem; padding: 0.75rem;
} }

View File

@ -5,6 +5,7 @@
buffer: AudioBuffer | null; buffer: AudioBuffer | null;
color?: string; color?: string;
playbackPosition?: number; playbackPosition?: number;
cuePoint?: number;
selectionStart?: number | null; selectionStart?: number | null;
selectionEnd?: number | null; selectionEnd?: number | null;
onselectionchange?: (start: number | null, end: number | null) => void; onselectionchange?: (start: number | null, end: number | null) => void;
@ -15,6 +16,7 @@
buffer, buffer,
color = '#646cff', color = '#646cff',
playbackPosition = 0, playbackPosition = 0,
cuePoint = 0,
selectionStart = null, selectionStart = null,
selectionEnd = null, selectionEnd = null,
onselectionchange, onselectionchange,
@ -43,6 +45,7 @@
buffer; buffer;
color; color;
playbackPosition; playbackPosition;
cuePoint;
selectionStart; selectionStart;
selectionEnd; selectionEnd;
draw(); draw();
@ -251,6 +254,16 @@
const duration = buffer.length / buffer.sampleRate; const duration = buffer.length / buffer.sampleRate;
const x = (playbackPosition / duration) * width; const x = (playbackPosition / duration) * width;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
} else if (cuePoint > 0 && buffer) {
const duration = buffer.length / buffer.sampleRate;
const x = (cuePoint / duration) * width;
ctx.strokeStyle = '#fff'; ctx.strokeStyle = '#fff';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();

View File

@ -21,39 +21,35 @@
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}
> >
<h1 id="modal-title">Poof: a sample generator</h1> <h1 id="modal-title">Random Sample Generator and Processor</h1>
<p class="description"> <div class="modal-columns">
Do you need to generate audio samples for your projects? Poof, it's <div class="modal-column gif-column">
already done! These are not the best samples you'll ever hear, but they <img src="/tutorial.gif" alt="Tutorial showing how to use RSGP" class="tutorial-gif" />
have the right to exist, nonetheless, in the realm of all the random and </div>
haphazardly generated digital sounds. Have fun, give computers some love! <div class="modal-column text-column">
</p> <ol>
<ul>
<li class="description"> <li class="description">
Generate audio samples using various audio synthesis generators. Random Generate new samples using a diverse collection of synth engines.
parameters.
</li> </li>
<li class="description"> <li class="description">
Process each sound with with a growing collection of random effects. Process samples using a growing list of tools and effects.
</li> </li>
<li class="description">Export your samples as WAV files.</li> <li class="description">Export your samples as WAV files.</li>
</ul> </ol>
<div class="modal-links"> </div>
<p> </div>
<p class="modal-footer">
Created by <a Created by <a
href="https://raphaelforment.fr" href="https://raphaelforment.fr"
target="_blank" target="_blank"
rel="noopener noreferrer">Raphaël Forment (BuboBubo)</a rel="noopener noreferrer">Raphaël Forment (BuboBubo)</a
> >
</p> • Licensed under <a
<p>
Licensed under <a
href="https://www.gnu.org/licenses/gpl-3.0.html" href="https://www.gnu.org/licenses/gpl-3.0.html"
target="_blank" target="_blank"
rel="noopener noreferrer">GPL 3.0</a rel="noopener noreferrer">GPL 3.0</a
> >
</p> </p>
</div>
<button class="modal-close" onclick={onclose}>Start</button> <button class="modal-close" onclick={onclose}>Start</button>
</div> </div>
</div> </div>
@ -98,7 +94,7 @@
background-color: #000; background-color: #000;
border: 2px solid #fff; border: 2px solid #fff;
padding: 1.25rem; padding: 1.25rem;
max-width: 500px; max-width: 800px;
width: calc(100% - 2rem); width: calc(100% - 2rem);
color: #fff; color: #fff;
max-height: 90vh; max-height: 90vh;
@ -108,60 +104,83 @@
} }
.modal-content h1 { .modal-content h1 {
margin: 0 0 0.5rem 0; margin: 0 0 1.575rem 0;
font-size: 1.75rem; font-size: 1.25rem;
font-weight: bold; font-weight: bold;
letter-spacing: 0.02em; letter-spacing: 0.02em;
text-align: center;
}
.modal-columns {
display: flex;
flex-direction: column;
gap: 1.575rem;
margin-bottom: 1.575rem;
}
.modal-column {
flex: 1;
}
.gif-column {
flex: 0 0 auto;
max-width: 420px;
}
.text-column {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.text-column ol {
display: flex;
flex-direction: column;
justify-content: space-evenly;
height: 100%;
margin: 0;
padding-left: 2rem;
} }
.modal-content .description { .modal-content .description {
margin: 0 0 1rem 0; margin: 0;
line-height: 1.6; line-height: 1.6;
color: #e0e0e0; color: #e0e0e0;
font-size: 0.875rem; font-size: 0.875rem;
} }
.modal-content ul { .tutorial-gif {
margin: 0 0 1rem 0; width: 100%;
padding-left: 1.25rem; max-width: 100%;
height: auto;
border: 1px solid #444;
} }
.modal-content ul li { .text-column ol li {
margin-bottom: 0.5rem;
}
.modal-content ul li:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
.modal-links { .modal-footer {
margin: 1.25rem 0; margin: 0 0 1.575rem 0;
padding: 0.875rem 0; font-size: 0.75rem;
border-top: 1px solid #444;
border-bottom: 1px solid #444;
}
.modal-links p {
margin: 0.375rem 0;
font-size: 0.8125rem;
color: #bbb; color: #bbb;
line-height: 1.5; text-align: center;
} }
.modal-links a { .modal-footer a {
color: #646cff; color: #646cff;
text-decoration: none; text-decoration: none;
word-break: break-word;
transition: color 0.2s ease; transition: color 0.2s ease;
} }
.modal-links a:hover { .modal-footer a:hover {
color: #8891ff; color: #8891ff;
text-decoration: underline; text-decoration: underline;
} }
.modal-close { .modal-close {
margin-top: 1rem; margin-top: 0;
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
font-size: 1rem; font-size: 1rem;
@ -194,17 +213,13 @@
} }
.modal-content h1 { .modal-content h1 {
font-size: 2rem; font-size: 1.5rem;
margin: 0 0 0.75rem 0; margin: 0 0 1.575rem 0;
} }
.modal-content .description { .modal-content .description {
font-size: 0.9375rem; font-size: 0.9375rem;
} }
.modal-links p {
font-size: 0.875rem;
}
} }
@media (min-width: 768px) { @media (min-width: 768px) {
@ -213,32 +228,29 @@
} }
.modal-content h1 { .modal-content h1 {
font-size: 2.5rem; font-size: 1.75rem;
margin: 0 0 1rem 0; margin: 0 0 2.1rem 0;
}
.modal-columns {
flex-direction: row;
gap: 2.1rem;
margin-bottom: 2.1rem;
} }
.modal-content .description { .modal-content .description {
margin: 0 0 1.25rem 0; margin: 0;
font-size: 1rem; font-size: 1rem;
line-height: 1.7; line-height: 1.7;
} }
.modal-content ul { .modal-footer {
margin: 0 0 1.25rem 0; margin: 0 0 2.1rem 0;
} font-size: 0.875rem;
.modal-links {
margin: 1.75rem 0;
padding: 1.125rem 0;
}
.modal-links p {
font-size: 0.9375rem;
margin: 0.5rem 0;
} }
.modal-close { .modal-close {
margin-top: 1.25rem; margin-top: 0;
padding: 0.875rem; padding: 0.875rem;
font-size: 1.125rem; font-size: 1.125rem;
} }
@ -253,9 +265,5 @@
.modal-close { .modal-close {
transition: none; transition: none;
} }
.modal-links a {
transition: none;
}
} }
</style> </style>

View File

@ -32,7 +32,7 @@ export function createKeyboardHandler(actions: KeyboardActions) {
case 'p': case 'p':
actions.onProcess?.(); actions.onProcess?.();
break; break;
case 's': case 'd':
actions.onDownload?.(); actions.onDownload?.();
break; break;
case ' ': case ' ':

View File

@ -9,6 +9,7 @@ const STORAGE_KEYS = {
PITCH_LOCK_ENABLED: 'pitchLockEnabled', PITCH_LOCK_ENABLED: 'pitchLockEnabled',
PITCH_LOCK_FREQUENCY: 'pitchLockFrequency', PITCH_LOCK_FREQUENCY: 'pitchLockFrequency',
EXPANDED_CATEGORIES: 'expandedCategories', EXPANDED_CATEGORIES: 'expandedCategories',
SELECTED_PROCESSOR_CATEGORY: 'selectedProcessorCategory',
} as const; } as const;
export function loadVolume(): number { export function loadVolume(): number {
@ -63,3 +64,12 @@ export function loadExpandedCategories(): Set<string> {
export function saveExpandedCategories(categories: Set<string>): void { export function saveExpandedCategories(categories: Set<string>): void {
localStorage.setItem(STORAGE_KEYS.EXPANDED_CATEGORIES, JSON.stringify(Array.from(categories))); localStorage.setItem(STORAGE_KEYS.EXPANDED_CATEGORIES, JSON.stringify(Array.from(categories)));
} }
export function loadSelectedProcessorCategory(): string {
const stored = localStorage.getItem(STORAGE_KEYS.SELECTED_PROCESSOR_CATEGORY);
return stored || 'All';
}
export function saveSelectedProcessorCategory(category: string): void {
localStorage.setItem(STORAGE_KEYS.SELECTED_PROCESSOR_CATEGORY, category);
}