1140 lines
26 KiB
Svelte
1140 lines
26 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import WaveformDisplay from "./lib/components/WaveformDisplay.svelte";
|
|
import VUMeter from "./lib/components/VUMeter.svelte";
|
|
import WelcomeModal from "./lib/components/WelcomeModal.svelte";
|
|
import ProcessorPopup from "./lib/components/ProcessorPopup.svelte";
|
|
import { engines } from "./lib/audio/engines/registry";
|
|
import type { SynthEngine, 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, loadPitchLockEnabled, savePitchLockEnabled, loadPitchLockFrequency, savePitchLockFrequency } from "./lib/utils/settings";
|
|
import { cropAudio, cutAudio, processSelection } from "./lib/audio/utils/AudioEdit";
|
|
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";
|
|
import { UndoManager, type AudioState } from "./lib/utils/UndoManager";
|
|
|
|
let currentEngineIndex = $state(0);
|
|
const engine = $derived(engines[currentEngineIndex]);
|
|
const engineType = $derived(engine.getType());
|
|
const audioService = new AudioService();
|
|
const undoManager = new UndoManager(20);
|
|
|
|
let currentParams = $state<any>(null);
|
|
let currentBuffer = $state<AudioBuffer | null>(null);
|
|
let duration = $state(loadDuration());
|
|
let volume = $state(loadVolume());
|
|
let playbackPosition = $state(-1);
|
|
let waveformColor = $state(generateRandomColor());
|
|
let showModal = $state(true);
|
|
let isProcessed = $state(false);
|
|
let showProcessorPopup = $state(false);
|
|
let popupTimeout = $state<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);
|
|
let selectionStart = $state<number | null>(null);
|
|
let selectionEnd = $state<number | null>(null);
|
|
let canUndo = $state(false);
|
|
|
|
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 });
|
|
const hasSelection = $derived(selectionStart !== null && selectionEnd !== null && currentBuffer !== null);
|
|
const showEditButtons = $derived(hasSelection);
|
|
|
|
$effect(() => {
|
|
audioService.setVolume(volume);
|
|
saveVolume(volume);
|
|
});
|
|
|
|
$effect(() => {
|
|
saveDuration(duration);
|
|
});
|
|
|
|
$effect(() => {
|
|
savePitchLockEnabled(pitchLockEnabled);
|
|
});
|
|
|
|
$effect(() => {
|
|
savePitchLockFrequency(pitchLockFrequency);
|
|
});
|
|
|
|
onMount(() => {
|
|
audioService.setPlaybackUpdateCallback((position) => {
|
|
playbackPosition = position;
|
|
});
|
|
generateRandom();
|
|
});
|
|
|
|
function captureCurrentState(): AudioState | null {
|
|
return UndoManager.captureState(
|
|
currentBuffer,
|
|
currentParams,
|
|
isProcessed,
|
|
waveformColor,
|
|
currentEngineIndex
|
|
);
|
|
}
|
|
|
|
function pushState(): void {
|
|
const state = captureCurrentState();
|
|
if (state) {
|
|
undoManager.pushState(state);
|
|
canUndo = true;
|
|
}
|
|
}
|
|
|
|
function restoreState(state: AudioState): void {
|
|
currentBuffer = audioService.createAudioBuffer([state.leftChannel, state.rightChannel]);
|
|
currentParams = state.params;
|
|
isProcessed = state.isProcessed;
|
|
waveformColor = state.waveformColor;
|
|
if (state.engineIndex !== currentEngineIndex) {
|
|
currentEngineIndex = state.engineIndex;
|
|
}
|
|
clearSelection();
|
|
audioService.play(currentBuffer);
|
|
}
|
|
|
|
function undo(): void {
|
|
const previousState = undoManager.undo();
|
|
if (previousState) {
|
|
restoreState(previousState);
|
|
canUndo = undoManager.canUndo();
|
|
}
|
|
}
|
|
|
|
const keyboardHandler = createKeyboardHandler({
|
|
onMutate: mutate,
|
|
onRandom: generateRandom,
|
|
onProcess: processSound,
|
|
onDownload: download,
|
|
onUndo: undo,
|
|
onDurationDecrease: (large) => {
|
|
duration = Math.max(0.05, duration - (large ? 1 : 0.05));
|
|
},
|
|
onDurationIncrease: (large) => {
|
|
duration = Math.min(32, duration + (large ? 1 : 0.05));
|
|
},
|
|
onVolumeDecrease: (large) => {
|
|
volume = Math.max(0, volume - (large ? 0.2 : 0.05));
|
|
},
|
|
onVolumeIncrease: (large) => {
|
|
volume = Math.min(1, volume + (large ? 0.2 : 0.05));
|
|
},
|
|
onEscape: () => {
|
|
if (hasSelection) {
|
|
clearSelection();
|
|
} else if (showModal) {
|
|
closeModal();
|
|
}
|
|
},
|
|
});
|
|
|
|
function generateRandom() {
|
|
pushState();
|
|
|
|
currentParams = engine.randomParams(pitchLock);
|
|
waveformColor = generateRandomColor();
|
|
isProcessed = false;
|
|
clearSelection();
|
|
regenerateBuffer();
|
|
}
|
|
|
|
function mutate() {
|
|
if (!currentParams) {
|
|
generateRandom();
|
|
return;
|
|
}
|
|
pushState();
|
|
|
|
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;
|
|
|
|
const sampleRate = audioService.getSampleRate();
|
|
const data = engine.generate(currentParams, sampleRate, duration);
|
|
currentBuffer = audioService.createAudioBuffer(data);
|
|
audioService.play(currentBuffer);
|
|
}
|
|
|
|
function replaySound() {
|
|
if (currentBuffer) {
|
|
audioService.play(currentBuffer);
|
|
}
|
|
}
|
|
|
|
function download() {
|
|
if (!currentBuffer) return;
|
|
downloadWAV(currentBuffer, "synth-sound.wav");
|
|
}
|
|
|
|
function processSound() {
|
|
if (!currentBuffer) return;
|
|
applyProcessor(getRandomProcessor());
|
|
}
|
|
|
|
async function applyProcessor(processor: AudioProcessor) {
|
|
if (!currentBuffer) return;
|
|
|
|
pushState();
|
|
|
|
let processedLeft: Float32Array;
|
|
let processedRight: Float32Array;
|
|
|
|
if (hasSelection) {
|
|
const start = Math.min(selectionStart!, selectionEnd!);
|
|
const end = Math.max(selectionStart!, selectionEnd!);
|
|
const sampleRate = audioService.getSampleRate();
|
|
|
|
[processedLeft, processedRight] = await processSelection(
|
|
currentBuffer,
|
|
start,
|
|
end,
|
|
processor,
|
|
sampleRate
|
|
);
|
|
} else {
|
|
const leftChannel = currentBuffer.getChannelData(0);
|
|
const rightChannel = currentBuffer.getChannelData(1);
|
|
[processedLeft, processedRight] = await processor.process(leftChannel, rightChannel);
|
|
}
|
|
|
|
currentBuffer = audioService.createAudioBuffer([processedLeft, processedRight]);
|
|
isProcessed = true;
|
|
audioService.play(currentBuffer);
|
|
hideProcessorPopup();
|
|
}
|
|
|
|
function switchEngine(index: number) {
|
|
pushState();
|
|
|
|
currentEngineIndex = index;
|
|
currentBuffer = null;
|
|
currentParams = null;
|
|
isProcessed = false;
|
|
clearSelection();
|
|
|
|
if (engineType === 'generative') {
|
|
generateRandom();
|
|
}
|
|
}
|
|
|
|
async function handleFileInput(event: Event) {
|
|
const input = event.target as HTMLInputElement;
|
|
if (!input.files?.length) return;
|
|
await loadAudioFile(input.files[0]);
|
|
}
|
|
|
|
async function loadAudioFile(file: File) {
|
|
if (!(engine instanceof Sample)) return;
|
|
|
|
pushState();
|
|
|
|
try {
|
|
await engine.loadFile(file);
|
|
currentParams = engine.randomParams(pitchLock);
|
|
waveformColor = generateRandomColor();
|
|
isProcessed = false;
|
|
regenerateBuffer();
|
|
} catch (error) {
|
|
console.error('Failed to load audio file:', error);
|
|
alert(`Failed to load audio file: ${error}`);
|
|
}
|
|
}
|
|
|
|
async function recordAudio() {
|
|
if (!(engine instanceof Input) || isRecording) return;
|
|
|
|
pushState();
|
|
|
|
try {
|
|
isRecording = true;
|
|
await engine.record(duration);
|
|
currentParams = engine.randomParams(pitchLock);
|
|
waveformColor = generateRandomColor();
|
|
isProcessed = false;
|
|
regenerateBuffer();
|
|
} catch (error) {
|
|
console.error('Failed to record audio:', error);
|
|
alert(`Failed to record audio: ${error}`);
|
|
} finally {
|
|
isRecording = false;
|
|
}
|
|
}
|
|
|
|
function handleDrop(event: DragEvent) {
|
|
event.preventDefault();
|
|
isDragOver = false;
|
|
|
|
const files = event.dataTransfer?.files;
|
|
if (!files?.length) return;
|
|
|
|
const file = files[0];
|
|
if (!file.type.startsWith('audio/')) {
|
|
alert('Please drop an audio file');
|
|
return;
|
|
}
|
|
|
|
loadAudioFile(file);
|
|
}
|
|
|
|
function handleDragOver(event: DragEvent) {
|
|
event.preventDefault();
|
|
isDragOver = true;
|
|
}
|
|
|
|
function handleDragLeave(event: DragEvent) {
|
|
event.preventDefault();
|
|
isDragOver = false;
|
|
}
|
|
|
|
function showPopup() {
|
|
if (popupTimeout) clearTimeout(popupTimeout);
|
|
showProcessorPopup = true;
|
|
popupTimeout = setTimeout(() => showProcessorPopup = false, 2000);
|
|
}
|
|
|
|
function scheduleHidePopup() {
|
|
if (popupTimeout) clearTimeout(popupTimeout);
|
|
popupTimeout = setTimeout(() => showProcessorPopup = false, 200);
|
|
}
|
|
|
|
function hideProcessorPopup() {
|
|
if (popupTimeout) clearTimeout(popupTimeout);
|
|
showProcessorPopup = false;
|
|
}
|
|
|
|
function handleSelectionChange(start: number | null, end: number | null) {
|
|
selectionStart = start;
|
|
selectionEnd = end;
|
|
}
|
|
|
|
function clearSelection() {
|
|
selectionStart = null;
|
|
selectionEnd = null;
|
|
}
|
|
|
|
function cropSelection() {
|
|
if (!currentBuffer || selectionStart === null || selectionEnd === null) return;
|
|
|
|
pushState();
|
|
|
|
const start = Math.min(selectionStart, selectionEnd);
|
|
const end = Math.max(selectionStart, selectionEnd);
|
|
|
|
const sampleRate = audioService.getSampleRate();
|
|
const [newLeft, newRight] = cropAudio(currentBuffer, start, end, sampleRate);
|
|
|
|
currentBuffer = audioService.createAudioBuffer([newLeft, newRight]);
|
|
clearSelection();
|
|
audioService.play(currentBuffer);
|
|
}
|
|
|
|
function cutSelection() {
|
|
if (!currentBuffer || selectionStart === null || selectionEnd === null) return;
|
|
|
|
pushState();
|
|
|
|
const start = Math.min(selectionStart, selectionEnd);
|
|
const end = Math.max(selectionStart, selectionEnd);
|
|
|
|
const sampleRate = audioService.getSampleRate();
|
|
const [newLeft, newRight] = cutAudio(currentBuffer, start, end, sampleRate);
|
|
|
|
currentBuffer = audioService.createAudioBuffer([newLeft, newRight]);
|
|
clearSelection();
|
|
audioService.play(currentBuffer);
|
|
}
|
|
|
|
async function closeModal() {
|
|
showModal = false;
|
|
await audioService.initialize();
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onkeydown={keyboardHandler} />
|
|
|
|
<div class="container">
|
|
<div class="top-bar">
|
|
<div class="mode-buttons">
|
|
{#each engines as currentEngine, index}
|
|
<button
|
|
class="engine-button"
|
|
class:active={currentEngineIndex === index}
|
|
data-description={currentEngine.getDescription()}
|
|
onclick={() => switchEngine(index)}
|
|
>
|
|
{currentEngine.getName()}
|
|
</button>
|
|
{/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="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"
|
|
min="0.05"
|
|
max="32"
|
|
step="0.01"
|
|
bind:value={duration}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
<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"
|
|
min="0"
|
|
max="1"
|
|
step="0.01"
|
|
bind:value={volume}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="main-area">
|
|
<div class="waveform-container">
|
|
{#if showFileDropZone}
|
|
<div
|
|
class="file-drop-zone"
|
|
class:drag-over={isDragOver}
|
|
role="button"
|
|
tabindex="0"
|
|
ondrop={handleDrop}
|
|
ondragover={handleDragOver}
|
|
ondragleave={handleDragLeave}
|
|
>
|
|
<div class="drop-zone-content">
|
|
<h2>Drop an audio file here</h2>
|
|
<label for="file-input" class="file-input-label">
|
|
<input
|
|
id="file-input"
|
|
type="file"
|
|
accept="audio/*"
|
|
onchange={handleFileInput}
|
|
style="display: none;"
|
|
/>
|
|
Choose a file
|
|
</label>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<WaveformDisplay
|
|
buffer={currentBuffer}
|
|
color={waveformColor}
|
|
{playbackPosition}
|
|
{selectionStart}
|
|
{selectionEnd}
|
|
onselectionchange={handleSelectionChange}
|
|
onclick={replaySound}
|
|
/>
|
|
{/if}
|
|
|
|
<div class="bottom-controls">
|
|
{#if showRandomButton}
|
|
<button onclick={generateRandom}>Random (R)</button>
|
|
{/if}
|
|
{#if showRecordButton}
|
|
<button onclick={recordAudio} disabled={isRecording}>
|
|
{isRecording ? 'Recording...' : 'Record'}
|
|
</button>
|
|
{/if}
|
|
{#if showMutateButton}
|
|
<button onclick={mutate}>Mutate (M)</button>
|
|
{/if}
|
|
{#if showEditButtons}
|
|
<button onclick={cropSelection}>Crop</button>
|
|
<button onclick={cutSelection}>Cut</button>
|
|
{/if}
|
|
{#if currentBuffer}
|
|
<div
|
|
class="process-button-container"
|
|
role="group"
|
|
onmouseenter={showPopup}
|
|
onmouseleave={scheduleHidePopup}
|
|
>
|
|
<button onclick={processSound}>Process (P)</button>
|
|
{#if showProcessorPopup}
|
|
<ProcessorPopup onselect={applyProcessor} />
|
|
{/if}
|
|
</div>
|
|
<button onclick={download}>Download (D)</button>
|
|
<button onclick={undo} disabled={!canUndo}>Undo (Z)</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div class="vu-meter-container">
|
|
<VUMeter buffer={currentBuffer} {playbackPosition} />
|
|
</div>
|
|
</div>
|
|
|
|
{#if showModal}
|
|
<WelcomeModal onclose={closeModal} />
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.container {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.top-bar {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
padding: 0.5rem;
|
|
background-color: #1a1a1a;
|
|
border-bottom: 1px solid #333;
|
|
}
|
|
|
|
.mode-buttons {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
overflow-x: auto;
|
|
overflow-y: hidden;
|
|
-webkit-overflow-scrolling: touch;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: #444 transparent;
|
|
padding-bottom: 0.25rem;
|
|
}
|
|
|
|
.mode-buttons::-webkit-scrollbar {
|
|
height: 4px;
|
|
}
|
|
|
|
.mode-buttons::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
.mode-buttons::-webkit-scrollbar-thumb {
|
|
background: #444;
|
|
border-radius: 0;
|
|
}
|
|
|
|
.engine-button {
|
|
opacity: 0.7;
|
|
position: relative;
|
|
flex-shrink: 0;
|
|
font-size: 0.85rem;
|
|
padding: 0.5rem 0.75rem;
|
|
white-space: nowrap;
|
|
min-width: calc(25vw - 1rem);
|
|
}
|
|
|
|
.engine-button:hover {
|
|
z-index: 1001;
|
|
}
|
|
|
|
.engine-button.active {
|
|
opacity: 1;
|
|
border-color: #646cff;
|
|
}
|
|
|
|
.engine-button::after {
|
|
content: attr(data-description);
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
padding: 0.5rem 0.75rem;
|
|
background-color: #0a0a0a;
|
|
border: 1px solid #444;
|
|
color: #ccc;
|
|
font-size: 0.85rem;
|
|
width: 200px;
|
|
max-width: 90vw;
|
|
white-space: normal;
|
|
word-wrap: break-word;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
z-index: 1000;
|
|
display: none;
|
|
}
|
|
|
|
.controls-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
width: 100%;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.control-header label {
|
|
font-size: 0.75rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: #999;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.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) {
|
|
.top-bar {
|
|
flex-direction: row;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.mode-buttons {
|
|
flex: 1;
|
|
overflow-x: auto;
|
|
padding-bottom: 0;
|
|
max-width: 60%;
|
|
}
|
|
|
|
.engine-button {
|
|
font-size: 0.9rem;
|
|
padding: 0.6rem 1rem;
|
|
min-width: auto;
|
|
}
|
|
|
|
.engine-button::after {
|
|
display: block;
|
|
width: 250px;
|
|
}
|
|
|
|
.engine-button:hover::after {
|
|
opacity: 1;
|
|
}
|
|
|
|
.controls-group {
|
|
width: auto;
|
|
flex-shrink: 0;
|
|
gap: 0.5rem;
|
|
flex-wrap: nowrap;
|
|
}
|
|
|
|
.control-item {
|
|
min-width: 120px;
|
|
padding: 0.45rem 0.6rem;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
}
|
|
|
|
@media (min-width: 1024px) {
|
|
.mode-buttons {
|
|
max-width: 65%;
|
|
}
|
|
|
|
.control-item {
|
|
min-width: 140px;
|
|
padding: 0.5rem 0.65rem;
|
|
}
|
|
|
|
.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) {
|
|
.control-item {
|
|
min-width: 160px;
|
|
}
|
|
|
|
.pitch-lock-control {
|
|
min-width: 180px;
|
|
}
|
|
|
|
.control-item input[type="range"] {
|
|
min-width: 110px;
|
|
}
|
|
}
|
|
|
|
.main-area {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background-color: #0a0a0a;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.waveform-container {
|
|
flex: 1;
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
min-height: 200px;
|
|
}
|
|
|
|
.vu-meter-container {
|
|
width: 100%;
|
|
height: 60px;
|
|
border-top: 1px solid #333;
|
|
}
|
|
|
|
.bottom-controls {
|
|
position: absolute;
|
|
bottom: 1rem;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
justify-content: center;
|
|
padding: 0 0.5rem;
|
|
max-width: 95%;
|
|
}
|
|
|
|
.bottom-controls button {
|
|
font-size: 0.85rem;
|
|
padding: 0.5rem 0.75rem;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.process-button-container {
|
|
position: relative;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.main-area {
|
|
flex-direction: row;
|
|
}
|
|
|
|
.waveform-container {
|
|
min-height: auto;
|
|
}
|
|
|
|
.vu-meter-container {
|
|
width: 5%;
|
|
min-width: 50px;
|
|
max-width: 80px;
|
|
height: auto;
|
|
border-left: 1px solid #333;
|
|
border-top: none;
|
|
}
|
|
|
|
.bottom-controls {
|
|
gap: 0.75rem;
|
|
bottom: 1.5rem;
|
|
}
|
|
|
|
.bottom-controls button {
|
|
font-size: 0.9rem;
|
|
padding: 0.6rem 1rem;
|
|
}
|
|
}
|
|
|
|
@media (min-width: 1024px) {
|
|
.bottom-controls {
|
|
gap: 1rem;
|
|
bottom: 2rem;
|
|
}
|
|
|
|
.bottom-controls button {
|
|
font-size: 1rem;
|
|
padding: 0.75rem 1.25rem;
|
|
}
|
|
}
|
|
|
|
input[type="range"] {
|
|
cursor: pointer;
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
background: transparent;
|
|
height: 20px;
|
|
border-radius: 0;
|
|
}
|
|
|
|
input[type="range"]::-webkit-slider-track {
|
|
background: #333;
|
|
height: 4px;
|
|
border: 1px solid #444;
|
|
border-radius: 0;
|
|
}
|
|
|
|
input[type="range"]::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
width: 16px;
|
|
height: 16px;
|
|
background: #fff;
|
|
border: 1px solid #000;
|
|
border-radius: 0;
|
|
cursor: pointer;
|
|
margin-top: -6px;
|
|
}
|
|
|
|
input[type="range"]::-webkit-slider-thumb:hover {
|
|
background: #ddd;
|
|
}
|
|
|
|
input[type="range"]::-moz-range-track {
|
|
background: #333;
|
|
height: 4px;
|
|
border: 1px solid #444;
|
|
border-radius: 0;
|
|
}
|
|
|
|
input[type="range"]::-moz-range-thumb {
|
|
width: 16px;
|
|
height: 16px;
|
|
background: #fff;
|
|
border: 1px solid #000;
|
|
border-radius: 0;
|
|
cursor: pointer;
|
|
}
|
|
|
|
input[type="range"]::-moz-range-thumb:hover {
|
|
background: #ddd;
|
|
}
|
|
|
|
.file-drop-zone {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: 2px dashed #444;
|
|
background-color: #0a0a0a;
|
|
transition: all 0.2s;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.file-drop-zone.drag-over {
|
|
border-color: #646cff;
|
|
background-color: #1a1a1a;
|
|
}
|
|
|
|
.drop-zone-content {
|
|
text-align: center;
|
|
color: #ccc;
|
|
}
|
|
|
|
.drop-zone-content h2 {
|
|
font-size: 1.2rem;
|
|
margin-bottom: 0.75rem;
|
|
color: #fff;
|
|
}
|
|
|
|
.file-input-label {
|
|
display: inline-block;
|
|
padding: 0.65rem 1.25rem;
|
|
background-color: #646cff;
|
|
color: #fff;
|
|
border: 1px solid #646cff;
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.file-input-label:hover {
|
|
background-color: #535bf2;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.drop-zone-content h2 {
|
|
font-size: 1.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.file-input-label {
|
|
padding: 0.75rem 1.5rem;
|
|
font-size: 1rem;
|
|
}
|
|
}
|
|
|
|
button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
</style>
|