feat: add signal generator
This commit is contained in:
parent
d538a7a2b0
commit
26be01e058
14 changed files with 777 additions and 10 deletions
|
@ -83,5 +83,24 @@
|
|||
"common_back": "Zpět",
|
||||
"audio_title": "Test zvuku",
|
||||
"avSync_title": "Synchronizace zvuku a videa",
|
||||
"internet_title": "Rychlost internetu"
|
||||
"internet_title": "Rychlost internetu",
|
||||
"tests_signal-generator_description": "Generujte sinusové vlny, šum (bílý, růžový, hnědý) a frekvenční přechody. Zahrnuje osciloskop a spektrum.",
|
||||
"tests_signal-generator_label": "Generátor signálu",
|
||||
"signalGen_title": "Generátor signálu",
|
||||
"signalGen_type": "Typ",
|
||||
"signalGen_sine": "Sinus",
|
||||
"signalGen_sweep": "Přechod",
|
||||
"signalGen_noiseWhite": "Bílý šum",
|
||||
"signalGen_noisePink": "Růžový šum",
|
||||
"signalGen_noiseBrown": "Hnědý šum",
|
||||
"signalGen_frequency": "Frekvence",
|
||||
"signalGen_from": "Od",
|
||||
"signalGen_to": "Do",
|
||||
"signalGen_duration": "Doba trvání",
|
||||
"signalGen_gain": "Hlasitost",
|
||||
"signalGen_start": "Start",
|
||||
"signalGen_stop": "Stop",
|
||||
"signalGen_scope": "Osciloskop",
|
||||
"signalGen_spectrum": "Spektrum",
|
||||
"signalGen_loop": "Smyčka"
|
||||
}
|
||||
|
|
|
@ -83,5 +83,24 @@
|
|||
"common_back": "Zurück",
|
||||
"audio_title": "Audiotest",
|
||||
"avSync_title": "Audio/Video-Synchronisation",
|
||||
"internet_title": "Internetgeschwindigkeit"
|
||||
"internet_title": "Internetgeschwindigkeit",
|
||||
"tests_signal-generator_description": "Erzeugen Sie Sinuswellen, Rauschen (weiß, pink, braun) und Frequenz-Sweeps. Mit Oszilloskop und Spektrum.",
|
||||
"tests_signal-generator_label": "Signalgenerator",
|
||||
"signalGen_title": "Signalgenerator",
|
||||
"signalGen_type": "Typ",
|
||||
"signalGen_sine": "Sinus",
|
||||
"signalGen_sweep": "Sweep",
|
||||
"signalGen_noiseWhite": "Weißes Rauschen",
|
||||
"signalGen_noisePink": "Pinkes Rauschen",
|
||||
"signalGen_noiseBrown": "Braunes Rauschen",
|
||||
"signalGen_frequency": "Frequenz",
|
||||
"signalGen_from": "Von",
|
||||
"signalGen_to": "Bis",
|
||||
"signalGen_duration": "Dauer",
|
||||
"signalGen_gain": "Verstärkung",
|
||||
"signalGen_start": "Start",
|
||||
"signalGen_stop": "Stopp",
|
||||
"signalGen_scope": "Oszilloskop",
|
||||
"signalGen_spectrum": "Spektrum",
|
||||
"signalGen_loop": "Schleife"
|
||||
}
|
||||
|
|
|
@ -83,5 +83,24 @@
|
|||
"common_back": "Back",
|
||||
"audio_title": "Audio test",
|
||||
"avSync_title": "Audio/Video Synchronization",
|
||||
"internet_title": "Internet speed"
|
||||
"internet_title": "Internet speed",
|
||||
"tests_signal-generator_description": "Generate sine waves, noise (white, pink, brown) and frequency sweeps. Includes oscilloscope and spectrum.",
|
||||
"tests_signal-generator_label": "Signal Generator",
|
||||
"signalGen_title": "Signal Generator",
|
||||
"signalGen_type": "Type",
|
||||
"signalGen_sine": "Sine",
|
||||
"signalGen_sweep": "Sweep",
|
||||
"signalGen_noiseWhite": "White noise",
|
||||
"signalGen_noisePink": "Pink noise",
|
||||
"signalGen_noiseBrown": "Brown noise",
|
||||
"signalGen_frequency": "Frequency",
|
||||
"signalGen_from": "From",
|
||||
"signalGen_to": "To",
|
||||
"signalGen_duration": "Duration",
|
||||
"signalGen_gain": "Gain",
|
||||
"signalGen_start": "Start",
|
||||
"signalGen_stop": "Stop",
|
||||
"signalGen_scope": "Oscilloscope",
|
||||
"signalGen_spectrum": "Spectrum",
|
||||
"signalGen_loop": "Loop"
|
||||
}
|
||||
|
|
|
@ -83,5 +83,24 @@
|
|||
"common_back": "Atrás",
|
||||
"audio_title": "Prueba de audio",
|
||||
"avSync_title": "Sincronización de Audio/Video",
|
||||
"internet_title": "Velocidad de internet"
|
||||
"internet_title": "Velocidad de internet",
|
||||
"tests_signal-generator_description": "Genera ondas senoidales, ruido (blanco, rosa, marrón) y barridos de frecuencia. Incluye osciloscopio y espectro.",
|
||||
"tests_signal-generator_label": "Generador de señal",
|
||||
"signalGen_title": "Generador de señal",
|
||||
"signalGen_type": "Tipo",
|
||||
"signalGen_sine": "Senoidal",
|
||||
"signalGen_sweep": "Barrido",
|
||||
"signalGen_noiseWhite": "Ruido blanco",
|
||||
"signalGen_noisePink": "Ruido rosa",
|
||||
"signalGen_noiseBrown": "Ruido marrón",
|
||||
"signalGen_frequency": "Frecuencia",
|
||||
"signalGen_from": "Desde",
|
||||
"signalGen_to": "Hasta",
|
||||
"signalGen_duration": "Duración",
|
||||
"signalGen_gain": "Ganancia",
|
||||
"signalGen_start": "Iniciar",
|
||||
"signalGen_stop": "Detener",
|
||||
"signalGen_scope": "Osciloscopio",
|
||||
"signalGen_spectrum": "Espectro",
|
||||
"signalGen_loop": "Bucle"
|
||||
}
|
||||
|
|
|
@ -83,5 +83,24 @@
|
|||
"common_back": "Retour",
|
||||
"audio_title": "Test audio",
|
||||
"avSync_title": "Synchronisation Audio/Vidéo",
|
||||
"internet_title": "Vitesse Internet"
|
||||
"internet_title": "Vitesse Internet",
|
||||
"tests_signal-generator_description": "Générez des ondes sinusoïdales, du bruit (blanc, rose, marron) et des balayages de fréquence. Comprend un oscilloscope et un spectre.",
|
||||
"tests_signal-generator_label": "Générateur de signaux",
|
||||
"signalGen_title": "Générateur de signaux",
|
||||
"signalGen_type": "Type",
|
||||
"signalGen_sine": "Sinusoïdal",
|
||||
"signalGen_sweep": "Balayage",
|
||||
"signalGen_noiseWhite": "Bruit blanc",
|
||||
"signalGen_noisePink": "Bruit rose",
|
||||
"signalGen_noiseBrown": "Bruit marron",
|
||||
"signalGen_frequency": "Fréquence",
|
||||
"signalGen_from": "De",
|
||||
"signalGen_to": "À",
|
||||
"signalGen_duration": "Durée",
|
||||
"signalGen_gain": "Volume",
|
||||
"signalGen_start": "Démarrer",
|
||||
"signalGen_stop": "Arrêter",
|
||||
"signalGen_scope": "Oscilloscope",
|
||||
"signalGen_spectrum": "Spectre",
|
||||
"signalGen_loop": "Boucle"
|
||||
}
|
||||
|
|
|
@ -83,5 +83,24 @@
|
|||
"common_back": "戻る",
|
||||
"audio_title": "オーディオテスト",
|
||||
"avSync_title": "オーディオ/ビデオ同期",
|
||||
"internet_title": "インターネット速度"
|
||||
"internet_title": "インターネット速度",
|
||||
"tests_signal-generator_description": "正弦波、ノイズ(ホワイト、ピンク、ブラウン)、周波数スイープを生成します。オシロスコープとスペクトラムが含まれています。",
|
||||
"tests_signal-generator_label": "信号発生器",
|
||||
"signalGen_title": "信号発生器",
|
||||
"signalGen_type": "タイプ",
|
||||
"signalGen_sine": "正弦波",
|
||||
"signalGen_sweep": "スイープ",
|
||||
"signalGen_noiseWhite": "ホワイトノイズ",
|
||||
"signalGen_noisePink": "ピンクノイズ",
|
||||
"signalGen_noiseBrown": "ブラウンノイズ",
|
||||
"signalGen_frequency": "周波数",
|
||||
"signalGen_from": "から",
|
||||
"signalGen_to": "まで",
|
||||
"signalGen_duration": "期間",
|
||||
"signalGen_gain": "音量",
|
||||
"signalGen_start": "開始",
|
||||
"signalGen_stop": "停止",
|
||||
"signalGen_scope": "オシロスコープ",
|
||||
"signalGen_spectrum": "スペクトラム",
|
||||
"signalGen_loop": "ループ"
|
||||
}
|
||||
|
|
|
@ -83,5 +83,24 @@
|
|||
"common_back": "Назад",
|
||||
"audio_title": "Тест аудіо",
|
||||
"avSync_title": "Синхронізація аудіо/відео",
|
||||
"internet_title": "Швидкість Інтернету"
|
||||
"internet_title": "Швидкість Інтернету",
|
||||
"tests_signal-generator_description": "Генеруйте синусоїди, шум (білий, рожевий, коричневий) та частотні розгортки. Включає осцилограф та спектр.",
|
||||
"tests_signal-generator_label": "Генератор сигналів",
|
||||
"signalGen_title": "Генератор сигналів",
|
||||
"signalGen_type": "Тип",
|
||||
"signalGen_sine": "Синусоїда",
|
||||
"signalGen_sweep": "Розгортка",
|
||||
"signalGen_noiseWhite": "Білий шум",
|
||||
"signalGen_noisePink": "Рожевий шум",
|
||||
"signalGen_noiseBrown": "Коричневий шум",
|
||||
"signalGen_frequency": "Частота",
|
||||
"signalGen_from": "Від",
|
||||
"signalGen_to": "До",
|
||||
"signalGen_duration": "Тривалість",
|
||||
"signalGen_gain": "Гучність",
|
||||
"signalGen_start": "Старт",
|
||||
"signalGen_stop": "Стоп",
|
||||
"signalGen_scope": "Осцилограф",
|
||||
"signalGen_spectrum": "Спектр",
|
||||
"signalGen_loop": "Цикл"
|
||||
}
|
||||
|
|
|
@ -83,5 +83,24 @@
|
|||
"common_back": "返回",
|
||||
"audio_title": "音频测试",
|
||||
"avSync_title": "音视频同步",
|
||||
"internet_title": "网速"
|
||||
"internet_title": "网速",
|
||||
"tests_signal-generator_description": "生成正弦波、噪声(白噪声、粉红噪声、布朗噪声)和频率扫描。包括示波器和频谱图。",
|
||||
"tests_signal-generator_label": "信号发生器",
|
||||
"signalGen_title": "信号发生器",
|
||||
"signalGen_type": "类型",
|
||||
"signalGen_sine": "正弦波",
|
||||
"signalGen_sweep": "扫描",
|
||||
"signalGen_noiseWhite": "白噪声",
|
||||
"signalGen_noisePink": "粉红噪声",
|
||||
"signalGen_noiseBrown": "布朗噪声",
|
||||
"signalGen_frequency": "频率",
|
||||
"signalGen_from": "从",
|
||||
"signalGen_to": "到",
|
||||
"signalGen_duration": "持续时间",
|
||||
"signalGen_gain": "音量",
|
||||
"signalGen_start": "开始",
|
||||
"signalGen_stop": "停止",
|
||||
"signalGen_scope": "示波器",
|
||||
"signalGen_spectrum": "频谱图",
|
||||
"signalGen_loop": "循环"
|
||||
}
|
||||
|
|
1
project.inlang/.gitignore
vendored
Normal file
1
project.inlang/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
cache
|
|
@ -8,6 +8,5 @@
|
|||
"pathPattern": "./messages/{locale}.json"
|
||||
},
|
||||
"baseLocale": "en",
|
||||
"locales": ["en", "es", "fr", "de", "zh-CN", "ja", "cs", "ukr"],
|
||||
"strategy": ["localStorage", "preferredLanguage", "baseLocale"]
|
||||
"locales": ["en", "es", "fr", "de", "zh-CN", "ja", "cs", "ukr"]
|
||||
}
|
||||
|
|
86
src/lib/audio/Oscilloscope.svelte
Normal file
86
src/lib/audio/Oscilloscope.svelte
Normal file
|
@ -0,0 +1,86 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
analyser: AnalyserNode;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
let { analyser, title = 'Oscilloscope' }: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
let animationId: number | null = null;
|
||||
|
||||
function draw() {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
const w = canvas.clientWidth * dpr;
|
||||
const h = canvas.clientHeight * dpr;
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
}
|
||||
|
||||
const bufferLength = analyser.fftSize;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
analyser.getByteTimeDomainData(dataArray);
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.lineWidth = 2 * dpr;
|
||||
ctx.strokeStyle = '#5cb85c';
|
||||
ctx.beginPath();
|
||||
|
||||
const sliceWidth = w / bufferLength;
|
||||
let x = 0;
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const v = dataArray[i] / 128.0; // 0..255 -> 0..2
|
||||
const y = (v * h) / 2;
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
x += sliceWidth;
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
animationId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
analyser.fftSize = 2048;
|
||||
analyser.smoothingTimeConstant = 0.2;
|
||||
animationId = requestAnimationFrame(draw);
|
||||
return () => {
|
||||
if (animationId) cancelAnimationFrame(animationId);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<div class="title">{title}</div>
|
||||
<canvas bind:this={canvas} class="scope"></canvas>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.title {
|
||||
opacity: 0.85;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.scope {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
170
src/lib/audio/Spectrum.svelte
Normal file
170
src/lib/audio/Spectrum.svelte
Normal file
|
@ -0,0 +1,170 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
analyser: AnalyserNode;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
let { analyser, title = 'Spectrum' }: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
let animationId: number | null = null;
|
||||
|
||||
// Format frequency values nicely for axis labels
|
||||
function formatHz(f: number): string {
|
||||
if (f >= 1000) {
|
||||
const k = f / 1000;
|
||||
// Show at most one decimal place when needed
|
||||
return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)} kHz`;
|
||||
}
|
||||
return `${Math.round(f)} Hz`;
|
||||
}
|
||||
|
||||
// Generate 1-2-5 decade ticks up to Nyquist
|
||||
function generateFreqTicks(maxF: number): number[] {
|
||||
const ticks: number[] = [];
|
||||
const bases = [1, 2, 5];
|
||||
const maxExp = Math.floor(Math.log10(Math.max(1, maxF)));
|
||||
for (let e = 0; e <= maxExp; e++) {
|
||||
const pow = Math.pow(10, e);
|
||||
for (const b of bases) {
|
||||
const f = b * pow;
|
||||
if (f >= 20 && f <= maxF) ticks.push(f);
|
||||
}
|
||||
}
|
||||
// Ensure sorted unique ticks
|
||||
return [...new Set(ticks)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
const w = canvas.clientWidth * dpr;
|
||||
const h = canvas.clientHeight * dpr;
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
}
|
||||
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Layout for axis and grid
|
||||
const sampleRate = analyser.context.sampleRate;
|
||||
const nyquist = sampleRate / 2;
|
||||
const axisPad = 24 * dpr; // space reserved at bottom for tick labels
|
||||
const plotTop = 0;
|
||||
const plotBottom = h - axisPad;
|
||||
const plotH = Math.max(1, plotBottom - plotTop);
|
||||
|
||||
ctx.fillStyle = 'rgba(92, 184, 92, 0.25)';
|
||||
ctx.strokeStyle = '#5cb85c';
|
||||
ctx.lineWidth = 2 * dpr;
|
||||
ctx.beginPath();
|
||||
|
||||
const barWidth = w / bufferLength;
|
||||
let x = 0;
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const v = dataArray[i] / 255.0;
|
||||
const y = plotBottom - v * plotH;
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
x += barWidth;
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Draw frequency axis, gridlines, and labels
|
||||
ctx.save();
|
||||
const gridColor = 'rgba(255, 255, 255, 0.15)';
|
||||
const labelColor = 'rgba(255, 255, 255, 0.8)';
|
||||
// Baseline
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 1 * dpr;
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, plotBottom);
|
||||
ctx.lineTo(w, plotBottom);
|
||||
ctx.stroke();
|
||||
|
||||
// Ticks
|
||||
const ticks = generateFreqTicks(nyquist);
|
||||
const tickLen = 6 * dpr;
|
||||
ctx.font = `${Math.round(11 * dpr)}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
let lastLabelX = -Infinity;
|
||||
const minLabelSpacing = 60 * dpr;
|
||||
|
||||
for (const f of ticks) {
|
||||
const xTick = (f / nyquist) * w;
|
||||
// Gridline
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 1 * dpr;
|
||||
ctx.setLineDash([2 * dpr, 4 * dpr]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(xTick, plotTop);
|
||||
ctx.lineTo(xTick, plotBottom);
|
||||
ctx.stroke();
|
||||
|
||||
// Tick mark at the axis
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(xTick, plotBottom);
|
||||
ctx.lineTo(xTick, plotBottom + tickLen);
|
||||
ctx.stroke();
|
||||
|
||||
// Label if there's room
|
||||
if (xTick - lastLabelX >= minLabelSpacing) {
|
||||
ctx.fillStyle = labelColor;
|
||||
ctx.fillText(formatHz(f), xTick, plotBottom + tickLen + 2 * dpr);
|
||||
lastLabelX = xTick;
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
animationId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
analyser.fftSize = 2048;
|
||||
analyser.smoothingTimeConstant = 0.8;
|
||||
animationId = requestAnimationFrame(draw);
|
||||
return () => {
|
||||
if (animationId) cancelAnimationFrame(animationId);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<div class="title">{title}</div>
|
||||
<canvas bind:this={canvas} class="spectrum"></canvas>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.title {
|
||||
opacity: 0.85;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.spectrum {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
|
@ -64,6 +64,11 @@
|
|||
icon: 'ti-volume',
|
||||
categories: ['outputs', 'audio']
|
||||
},
|
||||
{
|
||||
id: 'signal-generator',
|
||||
icon: 'ti-wave-sine',
|
||||
categories: ['outputs', 'audio']
|
||||
},
|
||||
{
|
||||
id: 'av-sync',
|
||||
icon: 'ti-time-duration-off',
|
||||
|
|
354
src/routes/(pages)/signal-generator/+page.svelte
Normal file
354
src/routes/(pages)/signal-generator/+page.svelte
Normal file
|
@ -0,0 +1,354 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import Oscilloscope from '$lib/audio/Oscilloscope.svelte';
|
||||
import Spectrum from '$lib/audio/Spectrum.svelte';
|
||||
|
||||
type GenType = 'off' | 'sine' | 'sweep' | 'white' | 'pink' | 'brown';
|
||||
|
||||
let ctx: AudioContext | null = null;
|
||||
let gainNode: GainNode | null = null;
|
||||
let analyser = $state<AnalyserNode | null>(null);
|
||||
let source: AudioNode | null = null; // OscillatorNode or AudioBufferSourceNode
|
||||
let started = $state(false);
|
||||
|
||||
let genType = $state<GenType>('sine');
|
||||
let frequency = $state(440);
|
||||
let volume = $state(0.2);
|
||||
|
||||
let sweepFrom = $state(20);
|
||||
let sweepTo = $state(20000);
|
||||
let sweepDuration = $state(10); // seconds
|
||||
let sweepLoop = $state(false);
|
||||
|
||||
function ensureAudio() {
|
||||
if (!ctx) {
|
||||
ctx = new AudioContext();
|
||||
gainNode = ctx.createGain();
|
||||
gainNode.gain.value = volume;
|
||||
analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 2048;
|
||||
analyser.smoothingTimeConstant = 0.7;
|
||||
gainNode.connect(analyser);
|
||||
analyser.connect(ctx.destination);
|
||||
}
|
||||
}
|
||||
|
||||
function connectSource(node: AudioNode) {
|
||||
ensureAudio();
|
||||
stopSource();
|
||||
source = node;
|
||||
node.connect(gainNode!);
|
||||
}
|
||||
|
||||
function stopSource() {
|
||||
if (!source) return;
|
||||
try {
|
||||
// Try to stop if it has a stop() method
|
||||
(source as any).stop?.();
|
||||
(source as any).disconnect?.();
|
||||
} catch {}
|
||||
source = null;
|
||||
}
|
||||
|
||||
function start() {
|
||||
ensureAudio();
|
||||
ctx!.resume();
|
||||
started = true;
|
||||
if (genType === 'sine') startSine();
|
||||
else if (genType === 'sweep') startSweep();
|
||||
else if (genType === 'white') startNoise('white');
|
||||
else if (genType === 'pink') startNoise('pink');
|
||||
else if (genType === 'brown') startNoise('brown');
|
||||
}
|
||||
|
||||
function stop() {
|
||||
stopSource();
|
||||
started = false;
|
||||
}
|
||||
|
||||
function onTypeChange(t: GenType) {
|
||||
genType = t;
|
||||
if (!started) return;
|
||||
// Restart with new type
|
||||
start();
|
||||
}
|
||||
|
||||
function onFrequencyChange(v: number) {
|
||||
frequency = v;
|
||||
if (source && (source as OscillatorNode).frequency) {
|
||||
(source as OscillatorNode).frequency.setValueAtTime(frequency, ctx!.currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
function onVolumeChange(v: number) {
|
||||
volume = v;
|
||||
if (gainNode) gainNode.gain.setValueAtTime(volume, ctx!.currentTime);
|
||||
}
|
||||
|
||||
function startSine() {
|
||||
const osc = ctx!.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(frequency, ctx!.currentTime);
|
||||
connectSource(osc);
|
||||
osc.start();
|
||||
}
|
||||
|
||||
function startSweep() {
|
||||
const osc = ctx!.createOscillator();
|
||||
osc.type = 'sine';
|
||||
const startF = Math.max(0.1, sweepFrom);
|
||||
const endF = Math.max(0.1, sweepTo);
|
||||
const now = ctx!.currentTime;
|
||||
osc.frequency.cancelScheduledValues(now);
|
||||
osc.frequency.setValueAtTime(startF, now);
|
||||
// Use exponential ramp if both positive and not equal; fall back to linear otherwise
|
||||
if (startF > 0 && endF > 0 && startF !== endF) {
|
||||
osc.frequency.exponentialRampToValueAtTime(endF, now + sweepDuration);
|
||||
} else {
|
||||
osc.frequency.linearRampToValueAtTime(endF, now + sweepDuration);
|
||||
}
|
||||
connectSource(osc);
|
||||
osc.start();
|
||||
// auto stop or loop after sweep
|
||||
const timeoutId = setTimeout(() => {
|
||||
try {
|
||||
osc.stop();
|
||||
} catch {}
|
||||
if (started && genType === 'sweep') {
|
||||
if (sweepLoop) {
|
||||
startSweep(); // restart for loop
|
||||
} else {
|
||||
started = false;
|
||||
}
|
||||
}
|
||||
}, sweepDuration * 1000);
|
||||
|
||||
// When the source is replaced, clear this timeout
|
||||
source?.addEventListener('disconnect', () => clearTimeout(timeoutId), { once: true });
|
||||
}
|
||||
|
||||
function makeNoiseBuffer(kind: 'white' | 'pink' | 'brown') {
|
||||
const sr = ctx!.sampleRate;
|
||||
const seconds = 2;
|
||||
const frameCount = sr * seconds;
|
||||
const buffer = ctx!.createBuffer(2, frameCount, sr);
|
||||
for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
|
||||
const data = buffer.getChannelData(ch);
|
||||
if (kind === 'white') {
|
||||
for (let i = 0; i < frameCount; i++) data[i] = Math.random() * 2 - 1;
|
||||
} else if (kind === 'pink') {
|
||||
// Voss-McCartney algorithm approximation
|
||||
let b0 = 0,
|
||||
b1 = 0,
|
||||
b2 = 0,
|
||||
b3 = 0,
|
||||
b4 = 0,
|
||||
b5 = 0,
|
||||
b6 = 0;
|
||||
for (let i = 0; i < frameCount; i++) {
|
||||
const white = Math.random() * 2 - 1;
|
||||
b0 = 0.99886 * b0 + white * 0.0555179;
|
||||
b1 = 0.99332 * b1 + white * 0.0750759;
|
||||
b2 = 0.969 * b2 + white * 0.153852;
|
||||
b3 = 0.8665 * b3 + white * 0.3104856;
|
||||
b4 = 0.55 * b4 + white * 0.5329522;
|
||||
b5 = -0.7616 * b5 - white * 0.016898;
|
||||
const pink = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362;
|
||||
b6 = white * 0.115926;
|
||||
data[i] = pink * 0.11; // gain normalize
|
||||
}
|
||||
} else if (kind === 'brown') {
|
||||
let lastOut = 0;
|
||||
for (let i = 0; i < frameCount; i++) {
|
||||
const white = Math.random() * 2 - 1;
|
||||
const brown = (lastOut + 0.02 * white) / 1.02;
|
||||
lastOut = brown;
|
||||
data[i] = brown * 3.5; // gain normalize
|
||||
}
|
||||
}
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function startNoise(kind: 'white' | 'pink' | 'brown') {
|
||||
const buf = makeNoiseBuffer(kind);
|
||||
const src = ctx!.createBufferSource();
|
||||
src.buffer = buf;
|
||||
src.loop = true;
|
||||
connectSource(src);
|
||||
src.start();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// prepare audio on mount but start only on user gesture
|
||||
ensureAudio();
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<h2><i class="ti ti-wave-sine"></i> {m.signalGen_title()}</h2>
|
||||
|
||||
<article class="layout">
|
||||
<section class="controls">
|
||||
<div class="row">
|
||||
<span class="label">{m.signalGen_type()}:</span>
|
||||
<div class="buttons">
|
||||
<button class:active={genType === 'sine'} onclick={() => onTypeChange('sine')}
|
||||
>{m.signalGen_sine()}</button
|
||||
>
|
||||
<button class:active={genType === 'sweep'} onclick={() => onTypeChange('sweep')}
|
||||
>{m.signalGen_sweep()}</button
|
||||
>
|
||||
<button class:active={genType === 'white'} onclick={() => onTypeChange('white')}
|
||||
>{m.signalGen_noiseWhite()}</button
|
||||
>
|
||||
<button class:active={genType === 'pink'} onclick={() => onTypeChange('pink')}
|
||||
>{m.signalGen_noisePink()}</button
|
||||
>
|
||||
<button class:active={genType === 'brown'} onclick={() => onTypeChange('brown')}
|
||||
>{m.signalGen_noiseBrown()}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if genType === 'sine'}
|
||||
<div class="row">
|
||||
<label for="freq">{m.signalGen_frequency()}:</label>
|
||||
<input
|
||||
id="freq"
|
||||
type="range"
|
||||
min="20"
|
||||
max="20000"
|
||||
step="1"
|
||||
bind:value={frequency}
|
||||
oninput={(e) => onFrequencyChange(+e.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="20"
|
||||
max="20000"
|
||||
class="exact-freq"
|
||||
bind:value={frequency}
|
||||
oninput={(e) => onFrequencyChange(+e.currentTarget.value)}
|
||||
/>
|
||||
<span class="value">Hz</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if genType === 'sweep'}
|
||||
<div class="row">
|
||||
<label for="sweep-from">{m.signalGen_from()}:</label>
|
||||
<input id="sweep-from" type="number" min="1" max="20000" bind:value={sweepFrom} />
|
||||
<label for="sweep-to">{m.signalGen_to()}:</label>
|
||||
<input id="sweep-to" type="number" min="1" max="20000" bind:value={sweepTo} />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="sweep-duration">{m.signalGen_duration()}:</label>
|
||||
<input id="sweep-duration" type="number" min="1" max="120" bind:value={sweepDuration} />
|
||||
<span class="value">s</span>
|
||||
<label class="checkbox-label"
|
||||
><input type="checkbox" bind:checked={sweepLoop} />{m.signalGen_loop()}</label
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="row">
|
||||
<label for="gain">{m.signalGen_gain()}:</label>
|
||||
<input
|
||||
id="gain"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
bind:value={volume}
|
||||
oninput={(e) => onVolumeChange(+e.currentTarget.value)}
|
||||
/>
|
||||
<span class="value">{(volume * 100) | 0}%</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
{#if !started}
|
||||
<button class="primary" onclick={start}
|
||||
><i class="ti ti-player-play"></i> {m.signalGen_start()}</button
|
||||
>
|
||||
{:else}
|
||||
<button onclick={stop}><i class="ti ti-player-stop"></i> {m.signalGen_stop()}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="displays" aria-hidden={!started}>
|
||||
{#if analyser}
|
||||
<Oscilloscope {analyser} title={m.signalGen_scope()} />
|
||||
<Spectrum {analyser} title={m.signalGen_spectrum()} />
|
||||
{/if}
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.controls .row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.controls label {
|
||||
opacity: 0.8;
|
||||
min-width: 6rem;
|
||||
}
|
||||
.controls .buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.controls button {
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.controls button.active,
|
||||
.controls button.primary {
|
||||
background: rgba(92, 184, 92, 0.2);
|
||||
border-color: #5cb85c;
|
||||
}
|
||||
.controls input[type='range'] {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.controls .value {
|
||||
min-width: 4rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.exact-freq {
|
||||
width: 5em;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.displays {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
Loading…
Add table
Reference in a new issue