229 lines
6.4 KiB
Svelte
229 lines
6.4 KiB
Svelte
<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 isHorizontal = width > height;
|
|
|
|
if (isHorizontal) {
|
|
drawHorizontal(ctx, leftDB, rightDB, width, height);
|
|
} else {
|
|
drawVertical(ctx, leftDB, rightDB, width, height);
|
|
}
|
|
}
|
|
|
|
function drawVertical(ctx: CanvasRenderingContext2D, leftDB: number, rightDB: number, width: number, height: number) {
|
|
const channelWidth = width / 2;
|
|
|
|
drawChannelVertical(ctx, 0, leftDB, channelWidth, height);
|
|
drawChannelVertical(ctx, channelWidth, rightDB, channelWidth, height);
|
|
|
|
ctx.strokeStyle = '#333';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(channelWidth, 0);
|
|
ctx.lineTo(channelWidth, height);
|
|
ctx.stroke();
|
|
}
|
|
|
|
function drawHorizontal(ctx: CanvasRenderingContext2D, leftDB: number, rightDB: number, width: number, height: number) {
|
|
const channelHeight = height / 2;
|
|
|
|
drawChannelHorizontal(ctx, 0, leftDB, width, channelHeight);
|
|
drawChannelHorizontal(ctx, channelHeight, rightDB, width, channelHeight);
|
|
|
|
ctx.strokeStyle = '#333';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, channelHeight);
|
|
ctx.lineTo(width, channelHeight);
|
|
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 dbToX(db: number, width: number): number {
|
|
const minDB = -60;
|
|
const maxDB = 0;
|
|
const clampedDB = Math.max(minDB, Math.min(maxDB, db));
|
|
const normalized = (clampedDB - minDB) / (maxDB - minDB);
|
|
return normalized * width;
|
|
}
|
|
|
|
function drawChannelVertical(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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawChannelHorizontal(ctx: CanvasRenderingContext2D, y: 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 x = dbToX(db, width);
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, y);
|
|
ctx.lineTo(x, y + height);
|
|
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 startX = dbToX(segment.startDB, width);
|
|
const endX = dbToX(segment.endDB, width);
|
|
|
|
const clampedLevelDB = Math.min(levelDB, segment.endDB);
|
|
const levelX = dbToX(clampedLevelDB, width);
|
|
|
|
const segmentWidth = levelX - startX;
|
|
|
|
if (segmentWidth > 0) {
|
|
ctx.fillStyle = segment.color;
|
|
ctx.fillRect(startX, y, segmentWidth, height);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<canvas bind:this={canvas}></canvas>
|
|
|
|
<style>
|
|
canvas {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
</style>
|