| @@ -8,7 +8,8 @@ Go-based SDRplay RSP1b live spectrum + waterfall visualizer with a minimal event | |||
| - In-browser spectrogram slice for selected events | |||
| - Basic detector with event JSONL output (`data/events.jsonl`) | |||
| - Events API (`/api/events?limit=...&since=...`) | |||
| - Runtime UI controls for center frequency, span, FFT size, gain, AGC, DC block, IQ balance, detector threshold | |||
| - Runtime UI controls for center frequency, span, sample rate, tuner bandwidth, FFT size, gain, AGC, DC block, IQ balance, detector threshold | |||
| - Display controls: averaging + max-hold | |||
| - Recorded clips list placeholder (metadata only for now) | |||
| - Windows + Linux support | |||
| - Mock mode for testing without hardware | |||
| @@ -48,7 +49,8 @@ Edit `config.yaml`: | |||
| - `center_hz`: center frequency | |||
| - `sample_rate`: sample rate | |||
| - `fft_size`: FFT size | |||
| - `gain_db`: device gain | |||
| - `gain_db`: device gain (gain reduction) | |||
| - `tuner_bw_khz`: tuner bandwidth (200/300/600/1536/5000/6000/7000/8000) | |||
| - `agc`: enable automatic gain control | |||
| - `dc_block`: enable DC blocking filter | |||
| - `iq_balance`: enable basic IQ imbalance correction | |||
| @@ -59,7 +61,7 @@ Edit `config.yaml`: | |||
| The UI is served from `web/` and connects to `/ws` for spectrum frames. | |||
| ### Controls Panel | |||
| Use the right-side controls to adjust center frequency, span, FFT size, gain, AGC, DC block, IQ balance, and detector threshold. Preset buttons provide quick jumps to 40m/20m/17m. | |||
| Use the right-side controls to adjust center frequency, span (zoom), sample rate, tuner bandwidth, FFT size, gain, AGC, DC block, IQ balance, and detector threshold. Preset buttons provide quick jumps to 40m/20m/17m. | |||
| ### Event Timeline | |||
| - The timeline panel displays recent events (time vs frequency). | |||
| @@ -99,7 +99,7 @@ func (m *sourceManager) ApplyConfig(cfg config.Config) error { | |||
| defer m.mu.Unlock() | |||
| if updatable, ok := m.src.(sdr.ConfigurableSource); ok { | |||
| if err := updatable.UpdateConfig(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.AGC); err == nil { | |||
| if err := updatable.UpdateConfig(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.AGC, cfg.TunerBwKHz); err == nil { | |||
| return nil | |||
| } | |||
| } | |||
| @@ -158,16 +158,16 @@ func main() { | |||
| if mockFlag { | |||
| src := mock.New(cfg.SampleRate) | |||
| if updatable, ok := interface{}(src).(sdr.ConfigurableSource); ok { | |||
| _ = updatable.UpdateConfig(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.AGC) | |||
| _ = updatable.UpdateConfig(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.AGC, cfg.TunerBwKHz) | |||
| } | |||
| return src, nil | |||
| } | |||
| src, err := sdrplay.New(cfg.SampleRate, cfg.CenterHz, cfg.GainDb) | |||
| src, err := sdrplay.New(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.TunerBwKHz) | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| if updatable, ok := src.(sdr.ConfigurableSource); ok { | |||
| _ = updatable.UpdateConfig(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.AGC) | |||
| _ = updatable.UpdateConfig(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.AGC, cfg.TunerBwKHz) | |||
| } | |||
| return src, nil | |||
| } | |||
| @@ -241,7 +241,7 @@ func main() { | |||
| http.Error(w, err.Error(), http.StatusBadRequest) | |||
| return | |||
| } | |||
| sourceChanged := prev.CenterHz != next.CenterHz || prev.SampleRate != next.SampleRate || prev.GainDb != next.GainDb || prev.AGC != next.AGC | |||
| sourceChanged := prev.CenterHz != next.CenterHz || prev.SampleRate != next.SampleRate || prev.GainDb != next.GainDb || prev.AGC != next.AGC || prev.TunerBwKHz != next.TunerBwKHz | |||
| if sourceChanged { | |||
| if err := srcMgr.ApplyConfig(next); err != nil { | |||
| cfgManager.Replace(prev) | |||
| @@ -295,10 +295,10 @@ func main() { | |||
| http.Error(w, err.Error(), http.StatusBadRequest) | |||
| return | |||
| } | |||
| if prev.AGC != next.AGC { | |||
| if prev.AGC != next.AGC || prev.TunerBwKHz != next.TunerBwKHz { | |||
| if err := srcMgr.ApplyConfig(next); err != nil { | |||
| cfgManager.Replace(prev) | |||
| http.Error(w, "failed to apply agc", http.StatusInternalServerError) | |||
| http.Error(w, "failed to apply sdr settings", http.StatusInternalServerError) | |||
| return | |||
| } | |||
| } | |||
| @@ -6,6 +6,7 @@ center_hz: 7.1e6 | |||
| sample_rate: 2048000 | |||
| fft_size: 2048 | |||
| gain_db: 30 | |||
| tuner_bw_khz: 1536 | |||
| agc: false | |||
| dc_block: false | |||
| iq_balance: false | |||
| @@ -25,6 +25,7 @@ type Config struct { | |||
| SampleRate int `yaml:"sample_rate" json:"sample_rate"` | |||
| FFTSize int `yaml:"fft_size" json:"fft_size"` | |||
| GainDb float64 `yaml:"gain_db" json:"gain_db"` | |||
| TunerBwKHz int `yaml:"tuner_bw_khz" json:"tuner_bw_khz"` | |||
| AGC bool `yaml:"agc" json:"agc"` | |||
| DCBlock bool `yaml:"dc_block" json:"dc_block"` | |||
| IQBalance bool `yaml:"iq_balance" json:"iq_balance"` | |||
| @@ -45,6 +46,7 @@ func Default() Config { | |||
| SampleRate: 2_048_000, | |||
| FFTSize: 2048, | |||
| GainDb: 30, | |||
| TunerBwKHz: 1536, | |||
| AGC: false, | |||
| DCBlock: false, | |||
| IQBalance: false, | |||
| @@ -93,6 +95,9 @@ func Load(path string) (Config, error) { | |||
| if cfg.FFTSize <= 0 { | |||
| cfg.FFTSize = 2048 | |||
| } | |||
| if cfg.TunerBwKHz <= 0 { | |||
| cfg.TunerBwKHz = 1536 | |||
| } | |||
| if cfg.CenterHz == 0 { | |||
| cfg.CenterHz = 100.0e6 | |||
| } | |||
| @@ -27,7 +27,7 @@ func New(sampleRate int) *Source { | |||
| func (s *Source) Start() error { return nil } | |||
| func (s *Source) Stop() error { return nil } | |||
| func (s *Source) UpdateConfig(sampleRate int, centerHz float64, gainDb float64, agc bool) error { | |||
| func (s *Source) UpdateConfig(sampleRate int, centerHz float64, gainDb float64, agc bool, bwKHz int) error { | |||
| s.mu.Lock() | |||
| defer s.mu.Unlock() | |||
| if sampleRate > 0 { | |||
| @@ -12,6 +12,7 @@ type ConfigUpdate struct { | |||
| SampleRate *int `json:"sample_rate"` | |||
| FFTSize *int `json:"fft_size"` | |||
| GainDb *float64 `json:"gain_db"` | |||
| TunerBwKHz *int `json:"tuner_bw_khz"` | |||
| Detector *DetectorUpdate `json:"detector"` | |||
| } | |||
| @@ -71,6 +72,12 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||
| if update.GainDb != nil { | |||
| next.GainDb = *update.GainDb | |||
| } | |||
| if update.TunerBwKHz != nil { | |||
| if *update.TunerBwKHz <= 0 { | |||
| return m.cfg, errors.New("tuner_bw_khz must be > 0") | |||
| } | |||
| next.TunerBwKHz = *update.TunerBwKHz | |||
| } | |||
| if update.Detector != nil { | |||
| if update.Detector.ThresholdDb != nil { | |||
| next.Detector.ThresholdDb = *update.Detector.ThresholdDb | |||
| @@ -14,11 +14,13 @@ func TestApplyConfigUpdate(t *testing.T) { | |||
| sampleRate := 1_024_000 | |||
| fftSize := 4096 | |||
| threshold := -35.0 | |||
| bw := 1536 | |||
| updated, err := mgr.ApplyConfig(ConfigUpdate{ | |||
| CenterHz: ¢er, | |||
| SampleRate: &sampleRate, | |||
| FFTSize: &fftSize, | |||
| TunerBwKHz: &bw, | |||
| Detector: &DetectorUpdate{ | |||
| ThresholdDb: &threshold, | |||
| }, | |||
| @@ -38,6 +40,9 @@ func TestApplyConfigUpdate(t *testing.T) { | |||
| if updated.Detector.ThresholdDb != threshold { | |||
| t.Fatalf("threshold: %v", updated.Detector.ThresholdDb) | |||
| } | |||
| if updated.TunerBwKHz != bw { | |||
| t.Fatalf("tuner bw: %v", updated.TunerBwKHz) | |||
| } | |||
| } | |||
| func TestApplyConfigRejectsInvalid(t *testing.T) { | |||
| @@ -9,7 +9,7 @@ type Source interface { | |||
| } | |||
| type ConfigurableSource interface { | |||
| UpdateConfig(sampleRate int, centerHz float64, gainDb float64, agc bool) error | |||
| UpdateConfig(sampleRate int, centerHz float64, gainDb float64, agc bool, bwKHz int) error | |||
| } | |||
| var ErrNotImplemented = errors.New("sdrplay support not built; build with -tags sdrplay or use --mock") | |||
| @@ -41,6 +41,10 @@ static void sdrplay_set_gain(sdrplay_api_DeviceParamsT *p, unsigned int grDb) { | |||
| if (p && p->rxChannelA) p->rxChannelA->tunerParams.gain.gRdB = grDb; | |||
| } | |||
| static void sdrplay_set_bw(sdrplay_api_DeviceParamsT *p, sdrplay_api_Bw_MHzT bw) { | |||
| if (p && p->rxChannelA) p->rxChannelA->tunerParams.bwType = bw; | |||
| } | |||
| static void sdrplay_set_if_zero(sdrplay_api_DeviceParamsT *p) { | |||
| if (p && p->rxChannelA) p->rxChannelA->tunerParams.ifType = sdrplay_api_IF_Zero; | |||
| } | |||
| @@ -87,20 +91,22 @@ type Source struct { | |||
| gainDb float64 | |||
| agc bool | |||
| buf []complex64 | |||
| bwKHz int | |||
| } | |||
| func New(sampleRate int, centerHz float64, gainDb float64) (sdr.Source, error) { | |||
| func New(sampleRate int, centerHz float64, gainDb float64, bwKHz int) (sdr.Source, error) { | |||
| s := &Source{ | |||
| ch: make(chan []complex64, 16), | |||
| sampleRate: sampleRate, | |||
| centerHz: centerHz, | |||
| gainDb: gainDb, | |||
| bwKHz: bwKHz, | |||
| } | |||
| s.handle = cgo.NewHandle(s) | |||
| return s, s.configure(sampleRate, centerHz, gainDb) | |||
| return s, s.configure(sampleRate, centerHz, gainDb, bwKHz) | |||
| } | |||
| func (s *Source) configure(sampleRate int, centerHz float64, gainDb float64) error { | |||
| func (s *Source) configure(sampleRate int, centerHz float64, gainDb float64, bwKHz int) error { | |||
| if err := cErr(C.sdrplay_api_Open()); err != nil { | |||
| return fmt.Errorf("sdrplay_api_Open: %w", err) | |||
| } | |||
| @@ -127,6 +133,12 @@ func (s *Source) configure(sampleRate int, centerHz float64, gainDb float64) err | |||
| C.sdrplay_set_fs(s.params, C.double(sampleRate)) | |||
| C.sdrplay_set_rf(s.params, C.double(centerHz)) | |||
| C.sdrplay_set_gain(s.params, C.uint(gainDb)) | |||
| if bw := bwEnum(bwKHz); bw != 0 { | |||
| C.sdrplay_set_bw(s.params, bw) | |||
| if bwKHz > 0 { | |||
| s.bwKHz = bwKHz | |||
| } | |||
| } | |||
| C.sdrplay_set_if_zero(s.params) | |||
| C.sdrplay_disable_agc(s.params) | |||
| @@ -140,7 +152,7 @@ func (s *Source) configure(sampleRate int, centerHz float64, gainDb float64) err | |||
| func (s *Source) Start() error { return nil } | |||
| func (s *Source) UpdateConfig(sampleRate int, centerHz float64, gainDb float64, agc bool) error { | |||
| func (s *Source) UpdateConfig(sampleRate int, centerHz float64, gainDb float64, agc bool, bwKHz int) error { | |||
| s.mu.Lock() | |||
| defer s.mu.Unlock() | |||
| if s.params == nil { | |||
| @@ -172,6 +184,13 @@ func (s *Source) UpdateConfig(sampleRate int, centerHz float64, gainDb float64, | |||
| updateReasons |= C.int(C.sdrplay_api_Update_Ctrl_Agc) | |||
| s.agc = agc | |||
| } | |||
| if bwKHz > 0 && bwKHz != s.bwKHz { | |||
| if bw := bwEnum(bwKHz); bw != 0 { | |||
| C.sdrplay_set_bw(s.params, bw) | |||
| updateReasons |= C.int(C.sdrplay_api_Update_Tuner_BwType) | |||
| s.bwKHz = bwKHz | |||
| } | |||
| } | |||
| if updateReasons == 0 { | |||
| return nil | |||
| } | |||
| @@ -181,6 +200,29 @@ func (s *Source) UpdateConfig(sampleRate int, centerHz float64, gainDb float64, | |||
| return nil | |||
| } | |||
| func bwEnum(khz int) C.sdrplay_api_Bw_MHzT { | |||
| switch khz { | |||
| case 200: | |||
| return C.sdrplay_api_BW_0_200 | |||
| case 300: | |||
| return C.sdrplay_api_BW_0_300 | |||
| case 600: | |||
| return C.sdrplay_api_BW_0_600 | |||
| case 1536: | |||
| return C.sdrplay_api_BW_1_536 | |||
| case 5000: | |||
| return C.sdrplay_api_BW_5_000 | |||
| case 6000: | |||
| return C.sdrplay_api_BW_6_000 | |||
| case 7000: | |||
| return C.sdrplay_api_BW_7_000 | |||
| case 8000: | |||
| return C.sdrplay_api_BW_8_000 | |||
| default: | |||
| return 0 | |||
| } | |||
| } | |||
| func (s *Source) Stop() error { | |||
| s.mu.Lock() | |||
| defer s.mu.Unlock() | |||
| @@ -4,6 +4,6 @@ package sdrplay | |||
| import "sdr-visual-suite/internal/sdr" | |||
| func New(sampleRate int, centerHz float64, gainDb float64) (sdr.Source, error) { | |||
| func New(sampleRate int, centerHz float64, gainDb float64, bwKHz int) (sdr.Source, error) { | |||
| return nil, sdr.ErrNotImplemented | |||
| } | |||
| @@ -18,6 +18,7 @@ const centerInput = document.getElementById('centerInput'); | |||
| const spanInput = document.getElementById('spanInput'); | |||
| const sampleRateSelect = document.getElementById('sampleRateSelect'); | |||
| const fftSelect = document.getElementById('fftSelect'); | |||
| const bwSelect = document.getElementById('bwSelect'); | |||
| const gainRange = document.getElementById('gainRange'); | |||
| const gainInput = document.getElementById('gainInput'); | |||
| const thresholdRange = document.getElementById('thresholdRange'); | |||
| @@ -25,6 +26,9 @@ const thresholdInput = document.getElementById('thresholdInput'); | |||
| const agcToggle = document.getElementById('agcToggle'); | |||
| const dcToggle = document.getElementById('dcToggle'); | |||
| const iqToggle = document.getElementById('iqToggle'); | |||
| const avgSelect = document.getElementById('avgSelect'); | |||
| const maxHoldToggle = document.getElementById('maxHoldToggle'); | |||
| const maxHoldReset = document.getElementById('maxHoldReset'); | |||
| const presetButtons = Array.from(document.querySelectorAll('.preset-btn')); | |||
| let latest = null; | |||
| @@ -42,6 +46,11 @@ let pendingSettingsUpdate = null; | |||
| let configTimer = null; | |||
| let settingsTimer = null; | |||
| const GAIN_MAX = 60; | |||
| let avgAlpha = 0; | |||
| let avgSpectrum = null; | |||
| let maxHold = false; | |||
| let maxSpectrum = null; | |||
| let lastFFTSize = null; | |||
| const events = []; | |||
| const eventsById = new Map(); | |||
| @@ -95,6 +104,14 @@ function applyConfigToUI(cfg) { | |||
| const spanMHz = toMHz(cfg.sample_rate / zoom); | |||
| spanInput.value = spanMHz.toFixed(3); | |||
| fftSelect.value = String(cfg.fft_size); | |||
| if (lastFFTSize !== cfg.fft_size) { | |||
| avgSpectrum = null; | |||
| maxSpectrum = null; | |||
| lastFFTSize = cfg.fft_size; | |||
| } | |||
| if (bwSelect) { | |||
| bwSelect.value = String(cfg.tuner_bw_khz || 1536); | |||
| } | |||
| const uiGain = Math.max(0, Math.min(GAIN_MAX, GAIN_MAX - cfg.gain_db)); | |||
| gainRange.value = uiGain; | |||
| gainInput.value = uiGain; | |||
| @@ -213,6 +230,32 @@ function maxInBinRange(spectrum, b0, b1) { | |||
| return max; | |||
| } | |||
| function processSpectrum(spectrum) { | |||
| if (!spectrum) return spectrum; | |||
| let base = spectrum; | |||
| if (avgAlpha > 0) { | |||
| if (!avgSpectrum || avgSpectrum.length !== spectrum.length) { | |||
| avgSpectrum = spectrum.slice(); | |||
| } else { | |||
| for (let i = 0; i < spectrum.length; i++) { | |||
| avgSpectrum[i] = avgAlpha * spectrum[i] + (1 - avgAlpha) * avgSpectrum[i]; | |||
| } | |||
| } | |||
| base = avgSpectrum; | |||
| } | |||
| if (maxHold) { | |||
| if (!maxSpectrum || maxSpectrum.length !== base.length) { | |||
| maxSpectrum = base.slice(); | |||
| } else { | |||
| for (let i = 0; i < base.length; i++) { | |||
| if (base[i] > maxSpectrum[i]) maxSpectrum[i] = base[i]; | |||
| } | |||
| } | |||
| base = maxSpectrum; | |||
| } | |||
| return base; | |||
| } | |||
| function snrColor(snr) { | |||
| const norm = Math.max(0, Math.min(1, (snr + 5) / 30)); | |||
| const [r, g, b] = colorMap(norm); | |||
| @@ -238,7 +281,8 @@ function renderSpectrum() { | |||
| } | |||
| const { spectrum_db, sample_rate, center_hz } = latest; | |||
| const n = spectrum_db.length; | |||
| const display = processSpectrum(spectrum_db); | |||
| const n = display.length; | |||
| const span = sample_rate / zoom; | |||
| const startHz = center_hz - span / 2 + pan * span; | |||
| const endHz = center_hz + span / 2 + pan * span; | |||
| @@ -257,7 +301,7 @@ function renderSpectrum() { | |||
| const f2 = startHz + ((x + 1) / w) * (endHz - startHz); | |||
| const b0 = binForFreq(f1, center_hz, sample_rate, n); | |||
| const b1 = binForFreq(f2, center_hz, sample_rate, n); | |||
| const v = maxInBinRange(spectrum_db, b0, b1); | |||
| const v = maxInBinRange(display, b0, b1); | |||
| const y = h - ((v - minDb) / (maxDb - minDb)) * h; | |||
| if (x === 0) ctx.moveTo(x, y); | |||
| else ctx.lineTo(x, y); | |||
| @@ -296,7 +340,8 @@ function renderWaterfall() { | |||
| ctx.putImageData(image, 0, 1); | |||
| const { spectrum_db, sample_rate, center_hz } = latest; | |||
| const n = spectrum_db.length; | |||
| const display = processSpectrum(spectrum_db); | |||
| const n = display.length; | |||
| const span = sample_rate / zoom; | |||
| const startHz = center_hz - span / 2 + pan * span; | |||
| const endHz = center_hz + span / 2 + pan * span; | |||
| @@ -310,7 +355,7 @@ function renderWaterfall() { | |||
| const b0 = binForFreq(f1, center_hz, sample_rate, n); | |||
| const b1 = binForFreq(f2, center_hz, sample_rate, n); | |||
| if (b0 < n && b1 >= 0) { | |||
| const v = maxInBinRange(spectrum_db, b0, b1); | |||
| const v = maxInBinRange(display, b0, b1); | |||
| const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb))); | |||
| const [r, g, b] = colorMap(norm); | |||
| row.data[x * 4 + 0] = r; | |||
| @@ -411,7 +456,8 @@ function renderDetailSpectrogram(ev) { | |||
| const endHz = ev.center_hz + span / 2; | |||
| const { spectrum_db, sample_rate, center_hz } = latest; | |||
| const n = spectrum_db.length; | |||
| const display = processSpectrum(spectrum_db); | |||
| const n = display.length; | |||
| const minDb = -120; | |||
| const maxDb = 0; | |||
| @@ -422,7 +468,7 @@ function renderDetailSpectrogram(ev) { | |||
| const b0 = binForFreq(f1, center_hz, sample_rate, n); | |||
| const b1 = binForFreq(f2, center_hz, sample_rate, n); | |||
| if (b0 < n && b1 >= 0) { | |||
| const v = maxInBinRange(spectrum_db, b0, b1); | |||
| const v = maxInBinRange(display, b0, b1); | |||
| const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb))); | |||
| const [r, g, b] = colorMap(norm); | |||
| row.data[x * 4 + 0] = r; | |||
| @@ -518,6 +564,37 @@ if (sampleRateSelect) { | |||
| }); | |||
| } | |||
| if (bwSelect) { | |||
| bwSelect.addEventListener('change', () => { | |||
| const bw = parseInt(bwSelect.value, 10); | |||
| if (Number.isFinite(bw)) { | |||
| queueConfigUpdate({ tuner_bw_khz: bw }); | |||
| } | |||
| }); | |||
| } | |||
| if (avgSelect) { | |||
| avgSelect.addEventListener('change', () => { | |||
| avgAlpha = parseFloat(avgSelect.value) || 0; | |||
| avgSpectrum = null; | |||
| }); | |||
| } | |||
| if (maxHoldToggle) { | |||
| maxHoldToggle.addEventListener('change', () => { | |||
| maxHold = maxHoldToggle.checked; | |||
| if (!maxHold) { | |||
| maxSpectrum = null; | |||
| } | |||
| }); | |||
| } | |||
| if (maxHoldReset) { | |||
| maxHoldReset.addEventListener('click', () => { | |||
| maxSpectrum = null; | |||
| }); | |||
| } | |||
| fftSelect.addEventListener('change', () => { | |||
| const size = parseInt(fftSelect.value, 10); | |||
| if (Number.isFinite(size)) { | |||
| @@ -46,6 +46,20 @@ | |||
| </select> | |||
| </div> | |||
| <label class="control-label" for="bwSelect">Tuner BW (kHz)</label> | |||
| <div class="control-row"> | |||
| <select id="bwSelect"> | |||
| <option value="200">200</option> | |||
| <option value="300">300</option> | |||
| <option value="600">600</option> | |||
| <option value="1536">1536</option> | |||
| <option value="5000">5000</option> | |||
| <option value="6000">6000</option> | |||
| <option value="7000">7000</option> | |||
| <option value="8000">8000</option> | |||
| </select> | |||
| </div> | |||
| <label class="control-label" for="fftSelect">FFT Size</label> | |||
| <div class="control-row"> | |||
| <select id="fftSelect"> | |||
| @@ -87,6 +101,23 @@ | |||
| <span>IQ Balance</span> | |||
| </label> | |||
| </div> | |||
| <label class="control-label">Display</label> | |||
| <div class="control-row"> | |||
| <select id="avgSelect"> | |||
| <option value="0">Averaging Off</option> | |||
| <option value="0.4">Averaging Fast</option> | |||
| <option value="0.2">Averaging Medium</option> | |||
| <option value="0.1">Averaging Slow</option> | |||
| </select> | |||
| </div> | |||
| <div class="toggle-row"> | |||
| <label class="toggle"> | |||
| <input id="maxHoldToggle" type="checkbox" /> | |||
| <span>Max Hold</span> | |||
| </label> | |||
| <button id="maxHoldReset" type="button" class="preset-btn">Reset Max</button> | |||
| </div> | |||
| </div> | |||
| </section> | |||
| <section class="panel spectrum-panel"> | |||