This commit is contained in:
2025-10-10 23:13:57 +02:00
commit 58d1424adb
25 changed files with 1958 additions and 0 deletions

285
src/App.svelte Normal file
View File

@ -0,0 +1,285 @@
<script lang="ts">
import { onMount } from 'svelte';
import WaveformDisplay from './lib/components/WaveformDisplay.svelte';
import VUMeter from './lib/components/VUMeter.svelte';
import { TwoOpFM, type TwoOpFMParams } from './lib/audio/engines/TwoOpFM';
import { AudioService } from './lib/audio/services/AudioService';
import { downloadWAV } from './lib/audio/utils/WAVEncoder';
import { loadVolume, saveVolume, loadDuration, saveDuration } from './lib/utils/settings';
import { generateRandomColor } from './lib/utils/colors';
let currentMode = 'Mode 1';
const modes = ['Mode 1', 'Mode 2', 'Mode 3'];
const engine = new TwoOpFM();
const audioService = new AudioService();
let currentParams: TwoOpFMParams | null = null;
let currentBuffer: AudioBuffer | null = null;
let duration = loadDuration();
let volume = loadVolume();
let playbackPosition = 0;
let waveformColor = generateRandomColor();
onMount(() => {
audioService.setVolume(volume);
audioService.setPlaybackUpdateCallback((position) => {
playbackPosition = position;
});
generateRandom();
});
function generateRandom() {
currentParams = engine.randomParams();
waveformColor = generateRandomColor();
regenerateBuffer();
}
function mutate() {
if (!currentParams) {
generateRandom();
return;
}
currentParams = engine.mutateParams(currentParams);
waveformColor = generateRandomColor();
regenerateBuffer();
}
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 handleVolumeChange(event: Event) {
const target = event.target as HTMLInputElement;
volume = parseFloat(target.value);
audioService.setVolume(volume);
saveVolume(volume);
}
function handleDurationChange(event: Event) {
const target = event.target as HTMLInputElement;
duration = parseFloat(target.value);
saveDuration(duration);
}
</script>
<div class="container">
<div class="top-bar">
<div class="mode-buttons">
{#each modes as mode}
<button
class:active={currentMode === mode}
onclick={() => currentMode = mode}
>
{mode}
</button>
{/each}
</div>
<div class="controls-group">
<div class="slider-control duration-slider">
<label for="duration">Duration: {duration.toFixed(2)}s</label>
<input
id="duration"
type="range"
min="0.05"
max="8"
step="0.01"
value={duration}
oninput={handleDurationChange}
/>
</div>
<div class="slider-control">
<label for="volume">Volume</label>
<input
id="volume"
type="range"
min="0"
max="1"
step="0.01"
value={volume}
oninput={handleVolumeChange}
/>
</div>
</div>
</div>
<div class="main-area">
<div class="waveform-container">
<WaveformDisplay
buffer={currentBuffer}
color={waveformColor}
playbackPosition={playbackPosition}
onclick={replaySound}
/>
<div class="bottom-controls">
<button onclick={generateRandom}>Random</button>
<button onclick={mutate}>Mutate</button>
<button onclick={download}>Download</button>
</div>
</div>
<div class="vu-meter-container">
<VUMeter
buffer={currentBuffer}
playbackPosition={playbackPosition}
/>
</div>
</div>
</div>
<style>
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.5rem;
background-color: #1a1a1a;
border-bottom: 1px solid #333;
}
.mode-buttons {
display: flex;
gap: 0.5rem;
}
.mode-buttons button {
opacity: 0.7;
}
.mode-buttons button.active {
opacity: 1;
border-color: #646cff;
}
.controls-group {
display: flex;
gap: 1rem;
align-items: center;
}
.slider-control {
display: flex;
align-items: center;
gap: 0.5rem;
}
.slider-control label {
font-size: 0.9rem;
white-space: nowrap;
}
.slider-control input[type="range"] {
width: 100px;
}
.duration-slider input[type="range"] {
width: 300px;
}
.main-area {
flex: 1;
display: flex;
background-color: #0a0a0a;
overflow: hidden;
}
.waveform-container {
flex: 1;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.vu-meter-container {
width: 5%;
min-width: 40px;
max-width: 80px;
border-left: 1px solid #333;
}
.bottom-controls {
position: absolute;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 1rem;
}
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;
}
</style>

69
src/app.css Normal file
View File

@ -0,0 +1,69 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
}
#app {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
button {
border: 1px solid transparent;
border-radius: 0;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

1
src/assets/svelte.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

10
src/lib/Counter.svelte Normal file
View File

@ -0,0 +1,10 @@
<script lang="ts">
let count: number = $state(0)
const increment = () => {
count += 1
}
</script>
<button onclick={increment}>
count is {count}
</button>

View File

@ -0,0 +1,10 @@
// Synthesis engines generate audio buffers with given parameters
// The duration parameter should be used to scale time-based parameters (envelopes, LFOs, etc.)
// Time-based parameters should be stored as ratios (0-1) and scaled by duration during generation
// Engines must generate stereo output: [leftChannel, rightChannel]
export interface SynthEngine<T = any> {
name: string;
generate(params: T, sampleRate: number, duration: number): [Float32Array, Float32Array];
randomParams(): T;
mutateParams(params: T, mutationAmount?: number): T;
}

View File

@ -0,0 +1,123 @@
import type { SynthEngine } from './SynthEngine';
export interface TwoOpFMParams {
carrierFreq: number;
modRatio: number;
modIndex: number;
attack: number; // 0-1, ratio of total duration
decay: number; // 0-1, ratio of total duration
sustain: number; // 0-1, amplitude level
release: number; // 0-1, ratio of total duration
vibratoRate: number; // Hz
vibratoDepth: number; // 0-1, pitch modulation depth
stereoWidth: number; // 0-1, amount of stereo separation
}
export class TwoOpFM implements SynthEngine<TwoOpFMParams> {
name = '2-OP FM';
generate(params: TwoOpFMParams, sampleRate: number, duration: number): [Float32Array, Float32Array] {
const numSamples = Math.floor(sampleRate * duration);
const leftBuffer = new Float32Array(numSamples);
const rightBuffer = new Float32Array(numSamples);
const TAU = Math.PI * 2;
const detune = 1 + (params.stereoWidth * 0.002);
const leftFreq = params.carrierFreq / detune;
const rightFreq = params.carrierFreq * detune;
const modulatorFreq = params.carrierFreq * params.modRatio;
let carrierPhaseL = 0;
let carrierPhaseR = Math.PI * params.stereoWidth * 0.1;
let modulatorPhaseL = 0;
let modulatorPhaseR = 0;
let vibratoPhaseL = 0;
let vibratoPhaseR = Math.PI * params.stereoWidth * 0.3;
for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate;
const envelope = this.calculateEnvelope(t, duration, params);
const vibratoL = Math.sin(vibratoPhaseL) * params.vibratoDepth;
const vibratoR = Math.sin(vibratoPhaseR) * params.vibratoDepth;
const carrierFreqL = leftFreq * (1 + vibratoL);
const carrierFreqR = rightFreq * (1 + vibratoR);
const modulatorL = Math.sin(modulatorPhaseL);
const modulatorR = Math.sin(modulatorPhaseR);
const carrierL = Math.sin(carrierPhaseL + params.modIndex * modulatorL);
const carrierR = Math.sin(carrierPhaseR + params.modIndex * modulatorR);
leftBuffer[i] = carrierL * envelope;
rightBuffer[i] = carrierR * envelope;
carrierPhaseL += (TAU * carrierFreqL) / sampleRate;
carrierPhaseR += (TAU * carrierFreqR) / sampleRate;
modulatorPhaseL += (TAU * modulatorFreq) / sampleRate;
modulatorPhaseR += (TAU * modulatorFreq) / sampleRate;
vibratoPhaseL += (TAU * params.vibratoRate) / sampleRate;
vibratoPhaseR += (TAU * params.vibratoRate) / sampleRate;
}
return [leftBuffer, rightBuffer];
}
private calculateEnvelope(t: number, duration: number, params: TwoOpFMParams): number {
const attackTime = params.attack * duration;
const decayTime = params.decay * duration;
const releaseTime = params.release * duration;
const sustainStart = attackTime + decayTime;
const releaseStart = duration - releaseTime;
if (t < attackTime) {
return t / attackTime;
} else if (t < sustainStart) {
const decayProgress = (t - attackTime) / decayTime;
return 1 - decayProgress * (1 - params.sustain);
} else if (t < releaseStart) {
return params.sustain;
} else {
const releaseProgress = (t - releaseStart) / releaseTime;
return params.sustain * (1 - releaseProgress);
}
}
randomParams(): TwoOpFMParams {
return {
carrierFreq: this.randomRange(100, 800),
modRatio: this.randomRange(0.5, 8),
modIndex: this.randomRange(0, 10),
attack: this.randomRange(0.01, 0.15),
decay: this.randomRange(0.05, 0.2),
sustain: this.randomRange(0.3, 0.9),
release: this.randomRange(0.1, 0.4),
vibratoRate: this.randomRange(3, 8),
vibratoDepth: this.randomRange(0, 0.03),
stereoWidth: this.randomRange(0.3, 0.8),
};
}
mutateParams(params: TwoOpFMParams, mutationAmount: number = 0.15): TwoOpFMParams {
return {
carrierFreq: this.mutateValue(params.carrierFreq, mutationAmount, 50, 1000),
modRatio: this.mutateValue(params.modRatio, mutationAmount, 0.25, 10),
modIndex: this.mutateValue(params.modIndex, mutationAmount, 0, 15),
attack: this.mutateValue(params.attack, mutationAmount, 0.001, 0.3),
decay: this.mutateValue(params.decay, mutationAmount, 0.01, 0.4),
sustain: this.mutateValue(params.sustain, mutationAmount, 0.1, 1.0),
release: this.mutateValue(params.release, mutationAmount, 0.05, 0.6),
vibratoRate: this.mutateValue(params.vibratoRate, mutationAmount, 2, 12),
vibratoDepth: this.mutateValue(params.vibratoDepth, mutationAmount, 0, 0.05),
stereoWidth: this.mutateValue(params.stereoWidth, mutationAmount, 0.0, 1.0),
};
}
private randomRange(min: number, max: number): number {
return min + Math.random() * (max - min);
}
private mutateValue(value: number, amount: number, min: number, max: number): number {
const variation = value * amount * (Math.random() * 2 - 1);
return Math.max(min, Math.min(max, value + variation));
}
}

View File

@ -0,0 +1,97 @@
const DEFAULT_SAMPLE_RATE = 44100;
export class AudioService {
private context: AudioContext | null = null;
private currentSource: AudioBufferSourceNode | null = null;
private gainNode: GainNode | null = null;
private startTime = 0;
private isPlaying = false;
private onPlaybackUpdate: ((position: number) => void) | null = null;
private animationFrameId: number | null = null;
private getContext(): AudioContext {
if (!this.context) {
this.context = new AudioContext({ sampleRate: DEFAULT_SAMPLE_RATE });
this.gainNode = this.context.createGain();
this.gainNode.connect(this.context.destination);
}
return this.context;
}
getSampleRate(): number {
return DEFAULT_SAMPLE_RATE;
}
setVolume(volume: number): void {
if (this.gainNode) {
this.gainNode.gain.value = Math.max(0, Math.min(1, volume));
}
}
setPlaybackUpdateCallback(callback: ((position: number) => void) | null): void {
this.onPlaybackUpdate = callback;
}
createAudioBuffer(stereoData: [Float32Array, Float32Array]): AudioBuffer {
const ctx = this.getContext();
const [leftChannel, rightChannel] = stereoData;
const buffer = ctx.createBuffer(2, leftChannel.length, DEFAULT_SAMPLE_RATE);
buffer.copyToChannel(leftChannel, 0);
buffer.copyToChannel(rightChannel, 1);
return buffer;
}
play(buffer: AudioBuffer): void {
this.stop();
const ctx = this.getContext();
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(this.gainNode!);
this.startTime = ctx.currentTime;
this.isPlaying = true;
source.onended = () => {
this.isPlaying = false;
if (this.onPlaybackUpdate) {
this.onPlaybackUpdate(0);
}
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
};
source.start();
this.currentSource = source;
this.updatePlaybackPosition();
}
private updatePlaybackPosition(): void {
if (!this.isPlaying || !this.context || !this.onPlaybackUpdate) {
return;
}
const elapsed = this.context.currentTime - this.startTime;
this.onPlaybackUpdate(elapsed);
this.animationFrameId = requestAnimationFrame(() => this.updatePlaybackPosition());
}
stop(): void {
if (this.currentSource) {
try {
this.currentSource.stop();
} catch {
// Already stopped
}
this.currentSource = null;
}
this.isPlaying = false;
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
}
}

View File

@ -0,0 +1,82 @@
const WAV_HEADER_SIZE = 44;
const IEEE_FLOAT_FORMAT = 3;
const BIT_DEPTH = 32;
export function encodeWAV(buffer: AudioBuffer): ArrayBuffer {
const numChannels = buffer.numberOfChannels;
const sampleRate = buffer.sampleRate;
const bytesPerSample = BIT_DEPTH / 8;
const blockAlign = numChannels * bytesPerSample;
const channelData: Float32Array[] = [];
for (let i = 0; i < numChannels; i++) {
const data = new Float32Array(buffer.length);
buffer.copyFromChannel(data, i);
channelData.push(data);
}
const dataLength = buffer.length * numChannels * bytesPerSample;
const bufferLength = WAV_HEADER_SIZE + dataLength;
const arrayBuffer = new ArrayBuffer(bufferLength);
const view = new DataView(arrayBuffer);
writeWAVHeader(view, numChannels, sampleRate, blockAlign, dataLength);
writePCMData(view, channelData, WAV_HEADER_SIZE);
return arrayBuffer;
}
function writeWAVHeader(
view: DataView,
numChannels: number,
sampleRate: number,
blockAlign: number,
dataLength: number
): void {
const writeString = (offset: number, string: string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
writeString(0, 'RIFF');
view.setUint32(4, 36 + dataLength, true);
writeString(8, 'WAVE');
writeString(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, IEEE_FLOAT_FORMAT, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * blockAlign, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, BIT_DEPTH, true);
writeString(36, 'data');
view.setUint32(40, dataLength, true);
}
function writePCMData(view: DataView, channelData: Float32Array[], startOffset: number): void {
const numChannels = channelData.length;
const numSamples = channelData[0].length;
let offset = startOffset;
for (let i = 0; i < numSamples; i++) {
for (let channel = 0; channel < numChannels; channel++) {
const sample = Math.max(-1, Math.min(1, channelData[channel][i]));
view.setFloat32(offset, sample, true);
offset += 4;
}
}
}
export function downloadWAV(buffer: AudioBuffer, filename: string = 'sound.wav'): void {
const wav = encodeWAV(buffer);
const blob = new Blob([wav], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}

View File

@ -0,0 +1,158 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
buffer: AudioBuffer | null;
playbackPosition?: number;
}
let { buffer, playbackPosition = 0 }: Props = $props();
let canvas: HTMLCanvasElement;
onMount(() => {
const resizeObserver = new ResizeObserver(() => {
if (canvas) {
updateCanvasSize();
draw();
}
});
resizeObserver.observe(canvas.parentElement!);
return () => resizeObserver.disconnect();
});
$effect(() => {
buffer;
playbackPosition;
draw();
});
function updateCanvasSize() {
const parent = canvas.parentElement!;
canvas.width = parent.clientWidth;
canvas.height = parent.clientHeight;
}
function calculateLevels(): [number, number] {
if (!buffer) return [-Infinity, -Infinity];
const numChannels = buffer.numberOfChannels;
const duration = buffer.length / buffer.sampleRate;
if (playbackPosition <= 0 || playbackPosition >= duration) {
return [-Infinity, -Infinity];
}
const windowSize = Math.floor(buffer.sampleRate * 0.05);
const currentSample = Math.floor(playbackPosition * buffer.sampleRate);
const startSample = Math.max(0, currentSample - windowSize);
const endSample = Math.min(buffer.length, currentSample);
const leftData = new Float32Array(endSample - startSample);
buffer.copyFromChannel(leftData, 0, startSample);
let leftSum = 0;
for (let i = 0; i < leftData.length; i++) {
leftSum += leftData[i] * leftData[i];
}
const leftRMS = Math.sqrt(leftSum / leftData.length);
const leftDB = leftRMS > 0 ? 20 * Math.log10(leftRMS) : -Infinity;
let rightDB = leftDB;
if (numChannels > 1) {
const rightData = new Float32Array(endSample - startSample);
buffer.copyFromChannel(rightData, 1, startSample);
let rightSum = 0;
for (let i = 0; i < rightData.length; i++) {
rightSum += rightData[i] * rightData[i];
}
const rightRMS = Math.sqrt(rightSum / rightData.length);
rightDB = rightRMS > 0 ? 20 * Math.log10(rightRMS) : -Infinity;
}
return [leftDB, rightDB];
}
function draw() {
if (!canvas) return;
const ctx = canvas.getContext('2d')!;
const width = canvas.width;
const height = canvas.height;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, width, height);
if (!buffer) return;
const [leftDB, rightDB] = calculateLevels();
const channelWidth = width / 2;
drawChannel(ctx, 0, leftDB, channelWidth, height);
drawChannel(ctx, channelWidth, rightDB, channelWidth, height);
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(channelWidth, 0);
ctx.lineTo(channelWidth, height);
ctx.stroke();
}
function dbToY(db: number, height: number): number {
const minDB = -60;
const maxDB = 0;
const clampedDB = Math.max(minDB, Math.min(maxDB, db));
const normalized = (clampedDB - minDB) / (maxDB - minDB);
return height - (normalized * height);
}
function drawChannel(ctx: CanvasRenderingContext2D, x: number, levelDB: number, width: number, height: number) {
const gridMarks = [0, -3, -6, -10, -20, -40, -60];
ctx.strokeStyle = '#222';
ctx.lineWidth = 1;
for (const db of gridMarks) {
const y = dbToY(db, height);
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + width, y);
ctx.stroke();
}
if (levelDB === -Infinity) return;
const segments = [
{ startDB: -60, endDB: -18, color: '#00ff00' },
{ startDB: -18, endDB: -6, color: '#ffff00' },
{ startDB: -6, endDB: 0, color: '#ff0000' }
];
for (const segment of segments) {
if (levelDB >= segment.startDB) {
const startY = dbToY(segment.startDB, height);
const endY = dbToY(segment.endDB, height);
const clampedLevelDB = Math.min(levelDB, segment.endDB);
const levelY = dbToY(clampedLevelDB, height);
const segmentHeight = startY - levelY;
if (segmentHeight > 0) {
ctx.fillStyle = segment.color;
ctx.fillRect(x, levelY, width, segmentHeight);
}
}
}
}
</script>
<canvas bind:this={canvas}></canvas>
<style>
canvas {
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,126 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
buffer: AudioBuffer | null;
color?: string;
playbackPosition?: number;
onclick?: () => void;
}
let { buffer, color = '#646cff', playbackPosition = 0, onclick }: Props = $props();
let canvas: HTMLCanvasElement;
onMount(() => {
const resizeObserver = new ResizeObserver(() => {
if (canvas) {
updateCanvasSize();
draw();
}
});
resizeObserver.observe(canvas.parentElement!);
return () => resizeObserver.disconnect();
});
$effect(() => {
buffer;
color;
playbackPosition;
draw();
});
function updateCanvasSize() {
const parent = canvas.parentElement!;
canvas.width = parent.clientWidth;
canvas.height = parent.clientHeight;
}
function handleClick() {
if (onclick) {
onclick();
}
}
function draw() {
if (!canvas) return;
const ctx = canvas.getContext('2d')!;
const width = canvas.width;
const height = canvas.height;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, width, height);
if (!buffer) {
ctx.fillStyle = '#666';
ctx.font = '24px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('No waveform generated', width / 2, height / 2);
return;
}
const numChannels = buffer.numberOfChannels;
const channelHeight = height / numChannels;
const step = Math.ceil(buffer.length / width);
for (let channel = 0; channel < numChannels; channel++) {
const data = new Float32Array(buffer.length);
buffer.copyFromChannel(data, channel);
const channelTop = channel * channelHeight;
const channelCenter = channelTop + channelHeight / 2;
const amp = channelHeight / 2;
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < width; i++) {
const index = i * step;
const value = data[index] || 0;
const y = channelCenter - value * amp;
if (i === 0) {
ctx.moveTo(i, y);
} else {
ctx.lineTo(i, y);
}
}
ctx.stroke();
if (channel < numChannels - 1) {
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, channelTop + channelHeight);
ctx.lineTo(width, channelTop + channelHeight);
ctx.stroke();
}
}
if (playbackPosition > 0 && buffer) {
const duration = buffer.length / buffer.sampleRate;
const x = (playbackPosition / duration) * width;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
}
</script>
<canvas bind:this={canvas} onclick={handleClick} style="cursor: pointer;"></canvas>
<style>
canvas {
width: 100%;
height: 100%;
}
</style>

6
src/lib/utils/colors.ts Normal file
View File

@ -0,0 +1,6 @@
export function generateRandomColor(): string {
const hue = Math.floor(Math.random() * 360);
const saturation = 60 + Math.floor(Math.random() * 30);
const lightness = 50 + Math.floor(Math.random() * 20);
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}

25
src/lib/utils/settings.ts Normal file
View File

@ -0,0 +1,25 @@
const DEFAULT_VOLUME = 0.7;
const DEFAULT_DURATION = 1.0;
const STORAGE_KEYS = {
VOLUME: 'volume',
DURATION: 'duration',
} as const;
export function loadVolume(): number {
const stored = localStorage.getItem(STORAGE_KEYS.VOLUME);
return stored ? parseFloat(stored) : DEFAULT_VOLUME;
}
export function saveVolume(volume: number): void {
localStorage.setItem(STORAGE_KEYS.VOLUME, volume.toString());
}
export function loadDuration(): number {
const stored = localStorage.getItem(STORAGE_KEYS.DURATION);
return stored ? parseFloat(stored) : DEFAULT_DURATION;
}
export function saveDuration(duration: number): void {
localStorage.setItem(STORAGE_KEYS.DURATION, duration.toString());
}

9
src/main.ts Normal file
View File

@ -0,0 +1,9 @@
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
const app = mount(App, {
target: document.getElementById('app')!,
})
export default app