clean interface

This commit is contained in:
2025-10-12 11:32:36 +02:00
parent 6c11c5756a
commit fcb784d403
13 changed files with 536 additions and 118 deletions

View File

@ -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;
}
}