feat: add signal generator

This commit is contained in:
Tomáš Mládek 2025-09-27 12:16:18 +02:00
parent d538a7a2b0
commit 26be01e058
14 changed files with 777 additions and 10 deletions

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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": "ループ"
}

View file

@ -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": "Цикл"
}

View file

@ -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
View file

@ -0,0 +1 @@
cache

View file

@ -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"]
}

View 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>

View 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>

View file

@ -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',

View 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>