init
This commit is contained in:
10
src/lib/Counter.svelte
Normal file
10
src/lib/Counter.svelte
Normal 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>
|
||||
10
src/lib/audio/engines/SynthEngine.ts
Normal file
10
src/lib/audio/engines/SynthEngine.ts
Normal 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;
|
||||
}
|
||||
123
src/lib/audio/engines/TwoOpFM.ts
Normal file
123
src/lib/audio/engines/TwoOpFM.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
97
src/lib/audio/services/AudioService.ts
Normal file
97
src/lib/audio/services/AudioService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
82
src/lib/audio/utils/WAVEncoder.ts
Normal file
82
src/lib/audio/utils/WAVEncoder.ts
Normal 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);
|
||||
}
|
||||
158
src/lib/components/VUMeter.svelte
Normal file
158
src/lib/components/VUMeter.svelte
Normal 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>
|
||||
126
src/lib/components/WaveformDisplay.svelte
Normal file
126
src/lib/components/WaveformDisplay.svelte
Normal 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
6
src/lib/utils/colors.ts
Normal 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
25
src/lib/utils/settings.ts
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user