clean interface
This commit is contained in:
343
src/App.svelte
343
src/App.svelte
@ -5,17 +5,18 @@
|
||||
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 { SynthEngine, PitchLock } 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, loadPitchLockEnabled, savePitchLockEnabled, loadPitchLockFrequency, savePitchLockFrequency } from "./lib/utils/settings";
|
||||
import { generateRandomColor } from "./lib/utils/colors";
|
||||
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";
|
||||
import { parseFrequencyInput, formatFrequency } from "./lib/utils/pitch";
|
||||
|
||||
let currentEngineIndex = $state(0);
|
||||
const engine = $derived(engines[currentEngineIndex]);
|
||||
@ -34,12 +35,18 @@
|
||||
let popupTimeout = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||
let isRecording = $state(false);
|
||||
let isDragOver = $state(false);
|
||||
let pitchLockEnabled = $state(loadPitchLockEnabled());
|
||||
let pitchLockFrequency = $state(loadPitchLockFrequency());
|
||||
let pitchLockInput = $state(formatFrequency(loadPitchLockFrequency()));
|
||||
let pitchLockInputValid = $state(true);
|
||||
|
||||
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);
|
||||
const showPitchLock = $derived(engineType === 'generative');
|
||||
const pitchLock = $derived<PitchLock>({ enabled: pitchLockEnabled, frequency: pitchLockFrequency });
|
||||
|
||||
$effect(() => {
|
||||
audioService.setVolume(volume);
|
||||
@ -50,6 +57,14 @@
|
||||
saveDuration(duration);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
savePitchLockEnabled(pitchLockEnabled);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
savePitchLockFrequency(pitchLockFrequency);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
audioService.setPlaybackUpdateCallback((position) => {
|
||||
playbackPosition = position;
|
||||
@ -78,7 +93,7 @@
|
||||
});
|
||||
|
||||
function generateRandom() {
|
||||
currentParams = engine.randomParams();
|
||||
currentParams = engine.randomParams(pitchLock);
|
||||
waveformColor = generateRandomColor();
|
||||
isProcessed = false;
|
||||
regenerateBuffer();
|
||||
@ -89,11 +104,28 @@
|
||||
generateRandom();
|
||||
return;
|
||||
}
|
||||
currentParams = engine.mutateParams(currentParams);
|
||||
currentParams = engine.mutateParams(currentParams, 0.15, pitchLock);
|
||||
waveformColor = generateRandomColor();
|
||||
regenerateBuffer();
|
||||
}
|
||||
|
||||
function handlePitchLockInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
pitchLockInput = input.value;
|
||||
|
||||
const parsed = parseFrequencyInput(pitchLockInput);
|
||||
if (parsed !== null) {
|
||||
pitchLockFrequency = parsed;
|
||||
pitchLockInputValid = true;
|
||||
} else {
|
||||
pitchLockInputValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
function togglePitchLock() {
|
||||
pitchLockEnabled = !pitchLockEnabled;
|
||||
}
|
||||
|
||||
function regenerateBuffer() {
|
||||
if (!currentParams) return;
|
||||
|
||||
@ -154,7 +186,7 @@
|
||||
|
||||
try {
|
||||
await engine.loadFile(file);
|
||||
currentParams = engine.randomParams();
|
||||
currentParams = engine.randomParams(pitchLock);
|
||||
waveformColor = generateRandomColor();
|
||||
isProcessed = false;
|
||||
regenerateBuffer();
|
||||
@ -170,7 +202,7 @@
|
||||
try {
|
||||
isRecording = true;
|
||||
await engine.record(duration);
|
||||
currentParams = engine.randomParams();
|
||||
currentParams = engine.randomParams(pitchLock);
|
||||
waveformColor = generateRandomColor();
|
||||
isProcessed = false;
|
||||
regenerateBuffer();
|
||||
@ -247,9 +279,44 @@
|
||||
{/each}
|
||||
</div>
|
||||
<div class="controls-group">
|
||||
{#if showPitchLock}
|
||||
<div class="control-item pitch-lock-control">
|
||||
<div class="control-header">
|
||||
<label for="pitch">Pitch</label>
|
||||
<label class="custom-checkbox" title="Lock pitch across random/mutate">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={pitchLockEnabled}
|
||||
/>
|
||||
<span class="checkbox-box" class:checked={pitchLockEnabled}>
|
||||
{#if pitchLockEnabled}
|
||||
<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"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="checkbox-text">Lock</span>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
id="pitch"
|
||||
type="text"
|
||||
class="pitch-input"
|
||||
class:locked={pitchLockEnabled}
|
||||
class:invalid={!pitchLockInputValid}
|
||||
value={pitchLockInput}
|
||||
oninput={handlePitchLockInput}
|
||||
placeholder="440 or A4"
|
||||
title="Enter frequency (Hz) or note (e.g., A4, C#3)"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showDuration}
|
||||
<div class="slider-control duration-slider">
|
||||
<label for="duration">Duration: {duration.toFixed(2)}s</label>
|
||||
<div class="control-item">
|
||||
<div class="control-header">
|
||||
<label for="duration">Duration</label>
|
||||
<span class="control-value-display">{duration.toFixed(2)}s</span>
|
||||
</div>
|
||||
<input
|
||||
id="duration"
|
||||
type="range"
|
||||
@ -260,8 +327,11 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="slider-control">
|
||||
<label for="volume">Volume</label>
|
||||
<div class="control-item">
|
||||
<div class="control-header">
|
||||
<label for="volume">Volume</label>
|
||||
<span class="control-value-display">{Math.round(volume * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
id="volume"
|
||||
type="range"
|
||||
@ -431,26 +501,166 @@
|
||||
.controls-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slider-control {
|
||||
.control-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
background-color: #0f0f0f;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid #2a2a2a;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.control-item:hover {
|
||||
border-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.control-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slider-control label {
|
||||
font-size: 0.85rem;
|
||||
.control-header label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.slider-control input[type="range"] {
|
||||
flex: 1;
|
||||
.control-value-display {
|
||||
font-size: 0.8rem;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 3.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.control-item input[type="range"] {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.custom-checkbox input[type="checkbox"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.checkbox-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid #3a3a3a;
|
||||
background-color: #1a1a1a;
|
||||
transition: all 0.2s;
|
||||
color: #0a0a0a;
|
||||
}
|
||||
|
||||
.checkbox-box.checked {
|
||||
background-color: #646cff;
|
||||
border-color: #646cff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.custom-checkbox:hover .checkbox-box {
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.custom-checkbox:hover .checkbox-box.checked {
|
||||
background-color: #535bf2;
|
||||
border-color: #535bf2;
|
||||
}
|
||||
|
||||
.checkbox-text {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.custom-checkbox:hover .checkbox-text {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.pitch-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: border-color 0.2s, background-color 0.2s, box-shadow 0.2s;
|
||||
font-variant-numeric: tabular-nums;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pitch-input:focus {
|
||||
outline: none;
|
||||
border-color: #646cff;
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
|
||||
.pitch-input.locked {
|
||||
border-color: #646cff;
|
||||
background-color: #0a0a0a;
|
||||
box-shadow: 0 0 0 1px rgba(100, 108, 255, 0.2);
|
||||
}
|
||||
|
||||
.pitch-input.locked:focus {
|
||||
box-shadow: 0 0 0 1px rgba(100, 108, 255, 0.4);
|
||||
}
|
||||
|
||||
.pitch-input.invalid {
|
||||
border-color: #ff4444;
|
||||
box-shadow: 0 0 0 1px rgba(255, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.pitch-input::placeholder {
|
||||
color: #555;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.controls-group {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-item {
|
||||
min-width: 140px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pitch-lock-control {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.control-item input[type="range"] {
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@ -484,17 +694,51 @@
|
||||
}
|
||||
|
||||
.controls-group {
|
||||
flex-direction: row;
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.slider-control input[type="range"] {
|
||||
width: 80px;
|
||||
.control-item {
|
||||
min-width: 120px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
}
|
||||
|
||||
.duration-slider input[type="range"] {
|
||||
width: 150px;
|
||||
.pitch-lock-control {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.control-header label {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.control-value-display {
|
||||
font-size: 0.75rem;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
.checkbox-text {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.checkbox-box {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.checkbox-box svg {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.pitch-input {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.45rem;
|
||||
}
|
||||
|
||||
.control-item input[type="range"] {
|
||||
min-width: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -503,18 +747,59 @@
|
||||
max-width: 65%;
|
||||
}
|
||||
|
||||
.slider-control input[type="range"] {
|
||||
width: 100px;
|
||||
.control-item {
|
||||
min-width: 140px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
}
|
||||
|
||||
.duration-slider input[type="range"] {
|
||||
width: 200px;
|
||||
.pitch-lock-control {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.control-header label {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.control-value-display {
|
||||
font-size: 0.8rem;
|
||||
min-width: 3.5rem;
|
||||
}
|
||||
|
||||
.checkbox-text {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.checkbox-box {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.checkbox-box svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.pitch-input {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.control-item input[type="range"] {
|
||||
min-width: 90px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.duration-slider input[type="range"] {
|
||||
width: 300px;
|
||||
.control-item {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.pitch-lock-control {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.control-item input[type="range"] {
|
||||
min-width: 110px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user