| @@ -8,7 +8,8 @@ Go-based SDRplay RSP1b live spectrum + waterfall visualizer with a minimal event | |||||
| - In-browser spectrogram slice for selected events | - In-browser spectrogram slice for selected events | ||||
| - Basic detector with event JSONL output (`data/events.jsonl`) | - Basic detector with event JSONL output (`data/events.jsonl`) | ||||
| - Events API (`/api/events?limit=...&since=...`) | - 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) | - Recorded clips list placeholder (metadata only for now) | ||||
| - Windows + Linux support | - Windows + Linux support | ||||
| - Mock mode for testing without hardware | - Mock mode for testing without hardware | ||||
| @@ -48,7 +49,8 @@ Edit `config.yaml`: | |||||
| - `center_hz`: center frequency | - `center_hz`: center frequency | ||||
| - `sample_rate`: sample rate | - `sample_rate`: sample rate | ||||
| - `fft_size`: FFT size | - `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 | - `agc`: enable automatic gain control | ||||
| - `dc_block`: enable DC blocking filter | - `dc_block`: enable DC blocking filter | ||||
| - `iq_balance`: enable basic IQ imbalance correction | - `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. | The UI is served from `web/` and connects to `/ws` for spectrum frames. | ||||
| ### Controls Panel | ### 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 | ### Event Timeline | ||||
| - The timeline panel displays recent events (time vs frequency). | - 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() | defer m.mu.Unlock() | ||||
| if updatable, ok := m.src.(sdr.ConfigurableSource); ok { | 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 | return nil | ||||
| } | } | ||||
| } | } | ||||
| @@ -158,16 +158,16 @@ func main() { | |||||
| if mockFlag { | if mockFlag { | ||||
| src := mock.New(cfg.SampleRate) | src := mock.New(cfg.SampleRate) | ||||
| if updatable, ok := interface{}(src).(sdr.ConfigurableSource); ok { | 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 | 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 { | if err != nil { | ||||
| return nil, err | return nil, err | ||||
| } | } | ||||
| if updatable, ok := src.(sdr.ConfigurableSource); ok { | 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 | return src, nil | ||||
| } | } | ||||
| @@ -241,7 +241,7 @@ func main() { | |||||
| http.Error(w, err.Error(), http.StatusBadRequest) | http.Error(w, err.Error(), http.StatusBadRequest) | ||||
| return | 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 sourceChanged { | ||||
| if err := srcMgr.ApplyConfig(next); err != nil { | if err := srcMgr.ApplyConfig(next); err != nil { | ||||
| cfgManager.Replace(prev) | cfgManager.Replace(prev) | ||||
| @@ -295,10 +295,10 @@ func main() { | |||||
| http.Error(w, err.Error(), http.StatusBadRequest) | http.Error(w, err.Error(), http.StatusBadRequest) | ||||
| return | return | ||||
| } | } | ||||
| if prev.AGC != next.AGC { | |||||
| if prev.AGC != next.AGC || prev.TunerBwKHz != next.TunerBwKHz { | |||||
| if err := srcMgr.ApplyConfig(next); err != nil { | if err := srcMgr.ApplyConfig(next); err != nil { | ||||
| cfgManager.Replace(prev) | cfgManager.Replace(prev) | ||||
| http.Error(w, "failed to apply agc", http.StatusInternalServerError) | |||||
| http.Error(w, "failed to apply sdr settings", http.StatusInternalServerError) | |||||
| return | return | ||||
| } | } | ||||
| } | } | ||||
| @@ -6,6 +6,7 @@ center_hz: 7.1e6 | |||||
| sample_rate: 2048000 | sample_rate: 2048000 | ||||
| fft_size: 2048 | fft_size: 2048 | ||||
| gain_db: 30 | gain_db: 30 | ||||
| tuner_bw_khz: 1536 | |||||
| agc: false | agc: false | ||||
| dc_block: false | dc_block: false | ||||
| iq_balance: false | iq_balance: false | ||||
| @@ -25,6 +25,7 @@ type Config struct { | |||||
| SampleRate int `yaml:"sample_rate" json:"sample_rate"` | SampleRate int `yaml:"sample_rate" json:"sample_rate"` | ||||
| FFTSize int `yaml:"fft_size" json:"fft_size"` | FFTSize int `yaml:"fft_size" json:"fft_size"` | ||||
| GainDb float64 `yaml:"gain_db" json:"gain_db"` | GainDb float64 `yaml:"gain_db" json:"gain_db"` | ||||
| TunerBwKHz int `yaml:"tuner_bw_khz" json:"tuner_bw_khz"` | |||||
| AGC bool `yaml:"agc" json:"agc"` | AGC bool `yaml:"agc" json:"agc"` | ||||
| DCBlock bool `yaml:"dc_block" json:"dc_block"` | DCBlock bool `yaml:"dc_block" json:"dc_block"` | ||||
| IQBalance bool `yaml:"iq_balance" json:"iq_balance"` | IQBalance bool `yaml:"iq_balance" json:"iq_balance"` | ||||
| @@ -45,6 +46,7 @@ func Default() Config { | |||||
| SampleRate: 2_048_000, | SampleRate: 2_048_000, | ||||
| FFTSize: 2048, | FFTSize: 2048, | ||||
| GainDb: 30, | GainDb: 30, | ||||
| TunerBwKHz: 1536, | |||||
| AGC: false, | AGC: false, | ||||
| DCBlock: false, | DCBlock: false, | ||||
| IQBalance: false, | IQBalance: false, | ||||
| @@ -93,6 +95,9 @@ func Load(path string) (Config, error) { | |||||
| if cfg.FFTSize <= 0 { | if cfg.FFTSize <= 0 { | ||||
| cfg.FFTSize = 2048 | cfg.FFTSize = 2048 | ||||
| } | } | ||||
| if cfg.TunerBwKHz <= 0 { | |||||
| cfg.TunerBwKHz = 1536 | |||||
| } | |||||
| if cfg.CenterHz == 0 { | if cfg.CenterHz == 0 { | ||||
| cfg.CenterHz = 100.0e6 | cfg.CenterHz = 100.0e6 | ||||
| } | } | ||||
| @@ -27,7 +27,7 @@ func New(sampleRate int) *Source { | |||||
| func (s *Source) Start() error { return nil } | func (s *Source) Start() error { return nil } | ||||
| func (s *Source) Stop() 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() | s.mu.Lock() | ||||
| defer s.mu.Unlock() | defer s.mu.Unlock() | ||||
| if sampleRate > 0 { | if sampleRate > 0 { | ||||
| @@ -12,6 +12,7 @@ type ConfigUpdate struct { | |||||
| SampleRate *int `json:"sample_rate"` | SampleRate *int `json:"sample_rate"` | ||||
| FFTSize *int `json:"fft_size"` | FFTSize *int `json:"fft_size"` | ||||
| GainDb *float64 `json:"gain_db"` | GainDb *float64 `json:"gain_db"` | ||||
| TunerBwKHz *int `json:"tuner_bw_khz"` | |||||
| Detector *DetectorUpdate `json:"detector"` | Detector *DetectorUpdate `json:"detector"` | ||||
| } | } | ||||
| @@ -71,6 +72,12 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||||
| if update.GainDb != nil { | if update.GainDb != nil { | ||||
| next.GainDb = *update.GainDb | 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 != nil { | ||||
| if update.Detector.ThresholdDb != nil { | if update.Detector.ThresholdDb != nil { | ||||
| next.Detector.ThresholdDb = *update.Detector.ThresholdDb | next.Detector.ThresholdDb = *update.Detector.ThresholdDb | ||||
| @@ -14,11 +14,13 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| sampleRate := 1_024_000 | sampleRate := 1_024_000 | ||||
| fftSize := 4096 | fftSize := 4096 | ||||
| threshold := -35.0 | threshold := -35.0 | ||||
| bw := 1536 | |||||
| updated, err := mgr.ApplyConfig(ConfigUpdate{ | updated, err := mgr.ApplyConfig(ConfigUpdate{ | ||||
| CenterHz: ¢er, | CenterHz: ¢er, | ||||
| SampleRate: &sampleRate, | SampleRate: &sampleRate, | ||||
| FFTSize: &fftSize, | FFTSize: &fftSize, | ||||
| TunerBwKHz: &bw, | |||||
| Detector: &DetectorUpdate{ | Detector: &DetectorUpdate{ | ||||
| ThresholdDb: &threshold, | ThresholdDb: &threshold, | ||||
| }, | }, | ||||
| @@ -38,6 +40,9 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| if updated.Detector.ThresholdDb != threshold { | if updated.Detector.ThresholdDb != threshold { | ||||
| t.Fatalf("threshold: %v", updated.Detector.ThresholdDb) | t.Fatalf("threshold: %v", updated.Detector.ThresholdDb) | ||||
| } | } | ||||
| if updated.TunerBwKHz != bw { | |||||
| t.Fatalf("tuner bw: %v", updated.TunerBwKHz) | |||||
| } | |||||
| } | } | ||||
| func TestApplyConfigRejectsInvalid(t *testing.T) { | func TestApplyConfigRejectsInvalid(t *testing.T) { | ||||
| @@ -9,7 +9,7 @@ type Source interface { | |||||
| } | } | ||||
| type ConfigurableSource 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") | 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; | 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) { | static void sdrplay_set_if_zero(sdrplay_api_DeviceParamsT *p) { | ||||
| if (p && p->rxChannelA) p->rxChannelA->tunerParams.ifType = sdrplay_api_IF_Zero; | if (p && p->rxChannelA) p->rxChannelA->tunerParams.ifType = sdrplay_api_IF_Zero; | ||||
| } | } | ||||
| @@ -87,20 +91,22 @@ type Source struct { | |||||
| gainDb float64 | gainDb float64 | ||||
| agc bool | agc bool | ||||
| buf []complex64 | 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{ | s := &Source{ | ||||
| ch: make(chan []complex64, 16), | ch: make(chan []complex64, 16), | ||||
| sampleRate: sampleRate, | sampleRate: sampleRate, | ||||
| centerHz: centerHz, | centerHz: centerHz, | ||||
| gainDb: gainDb, | gainDb: gainDb, | ||||
| bwKHz: bwKHz, | |||||
| } | } | ||||
| s.handle = cgo.NewHandle(s) | 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 { | if err := cErr(C.sdrplay_api_Open()); err != nil { | ||||
| return fmt.Errorf("sdrplay_api_Open: %w", err) | 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_fs(s.params, C.double(sampleRate)) | ||||
| C.sdrplay_set_rf(s.params, C.double(centerHz)) | C.sdrplay_set_rf(s.params, C.double(centerHz)) | ||||
| C.sdrplay_set_gain(s.params, C.uint(gainDb)) | 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_set_if_zero(s.params) | ||||
| C.sdrplay_disable_agc(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) 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() | s.mu.Lock() | ||||
| defer s.mu.Unlock() | defer s.mu.Unlock() | ||||
| if s.params == nil { | 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) | updateReasons |= C.int(C.sdrplay_api_Update_Ctrl_Agc) | ||||
| s.agc = 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 { | if updateReasons == 0 { | ||||
| return nil | return nil | ||||
| } | } | ||||
| @@ -181,6 +200,29 @@ func (s *Source) UpdateConfig(sampleRate int, centerHz float64, gainDb float64, | |||||
| return nil | 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 { | func (s *Source) Stop() error { | ||||
| s.mu.Lock() | s.mu.Lock() | ||||
| defer s.mu.Unlock() | defer s.mu.Unlock() | ||||
| @@ -4,6 +4,6 @@ package sdrplay | |||||
| import "sdr-visual-suite/internal/sdr" | 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 | return nil, sdr.ErrNotImplemented | ||||
| } | } | ||||
| @@ -18,6 +18,7 @@ const centerInput = document.getElementById('centerInput'); | |||||
| const spanInput = document.getElementById('spanInput'); | const spanInput = document.getElementById('spanInput'); | ||||
| const sampleRateSelect = document.getElementById('sampleRateSelect'); | const sampleRateSelect = document.getElementById('sampleRateSelect'); | ||||
| const fftSelect = document.getElementById('fftSelect'); | const fftSelect = document.getElementById('fftSelect'); | ||||
| const bwSelect = document.getElementById('bwSelect'); | |||||
| const gainRange = document.getElementById('gainRange'); | const gainRange = document.getElementById('gainRange'); | ||||
| const gainInput = document.getElementById('gainInput'); | const gainInput = document.getElementById('gainInput'); | ||||
| const thresholdRange = document.getElementById('thresholdRange'); | const thresholdRange = document.getElementById('thresholdRange'); | ||||
| @@ -25,6 +26,9 @@ const thresholdInput = document.getElementById('thresholdInput'); | |||||
| const agcToggle = document.getElementById('agcToggle'); | const agcToggle = document.getElementById('agcToggle'); | ||||
| const dcToggle = document.getElementById('dcToggle'); | const dcToggle = document.getElementById('dcToggle'); | ||||
| const iqToggle = document.getElementById('iqToggle'); | 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')); | const presetButtons = Array.from(document.querySelectorAll('.preset-btn')); | ||||
| let latest = null; | let latest = null; | ||||
| @@ -42,6 +46,11 @@ let pendingSettingsUpdate = null; | |||||
| let configTimer = null; | let configTimer = null; | ||||
| let settingsTimer = null; | let settingsTimer = null; | ||||
| const GAIN_MAX = 60; | const GAIN_MAX = 60; | ||||
| let avgAlpha = 0; | |||||
| let avgSpectrum = null; | |||||
| let maxHold = false; | |||||
| let maxSpectrum = null; | |||||
| let lastFFTSize = null; | |||||
| const events = []; | const events = []; | ||||
| const eventsById = new Map(); | const eventsById = new Map(); | ||||
| @@ -95,6 +104,14 @@ function applyConfigToUI(cfg) { | |||||
| const spanMHz = toMHz(cfg.sample_rate / zoom); | const spanMHz = toMHz(cfg.sample_rate / zoom); | ||||
| spanInput.value = spanMHz.toFixed(3); | spanInput.value = spanMHz.toFixed(3); | ||||
| fftSelect.value = String(cfg.fft_size); | 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)); | const uiGain = Math.max(0, Math.min(GAIN_MAX, GAIN_MAX - cfg.gain_db)); | ||||
| gainRange.value = uiGain; | gainRange.value = uiGain; | ||||
| gainInput.value = uiGain; | gainInput.value = uiGain; | ||||
| @@ -213,6 +230,32 @@ function maxInBinRange(spectrum, b0, b1) { | |||||
| return max; | 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) { | function snrColor(snr) { | ||||
| const norm = Math.max(0, Math.min(1, (snr + 5) / 30)); | const norm = Math.max(0, Math.min(1, (snr + 5) / 30)); | ||||
| const [r, g, b] = colorMap(norm); | const [r, g, b] = colorMap(norm); | ||||
| @@ -238,7 +281,8 @@ function renderSpectrum() { | |||||
| } | } | ||||
| const { spectrum_db, sample_rate, center_hz } = latest; | 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 span = sample_rate / zoom; | ||||
| const startHz = center_hz - span / 2 + pan * span; | const startHz = center_hz - span / 2 + pan * span; | ||||
| const endHz = 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 f2 = startHz + ((x + 1) / w) * (endHz - startHz); | ||||
| const b0 = binForFreq(f1, center_hz, sample_rate, n); | const b0 = binForFreq(f1, center_hz, sample_rate, n); | ||||
| const b1 = binForFreq(f2, 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; | const y = h - ((v - minDb) / (maxDb - minDb)) * h; | ||||
| if (x === 0) ctx.moveTo(x, y); | if (x === 0) ctx.moveTo(x, y); | ||||
| else ctx.lineTo(x, y); | else ctx.lineTo(x, y); | ||||
| @@ -296,7 +340,8 @@ function renderWaterfall() { | |||||
| ctx.putImageData(image, 0, 1); | ctx.putImageData(image, 0, 1); | ||||
| const { spectrum_db, sample_rate, center_hz } = latest; | 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 span = sample_rate / zoom; | ||||
| const startHz = center_hz - span / 2 + pan * span; | const startHz = center_hz - span / 2 + pan * span; | ||||
| const endHz = 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 b0 = binForFreq(f1, center_hz, sample_rate, n); | ||||
| const b1 = binForFreq(f2, center_hz, sample_rate, n); | const b1 = binForFreq(f2, center_hz, sample_rate, n); | ||||
| if (b0 < n && b1 >= 0) { | 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 norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb))); | ||||
| const [r, g, b] = colorMap(norm); | const [r, g, b] = colorMap(norm); | ||||
| row.data[x * 4 + 0] = r; | row.data[x * 4 + 0] = r; | ||||
| @@ -411,7 +456,8 @@ function renderDetailSpectrogram(ev) { | |||||
| const endHz = ev.center_hz + span / 2; | const endHz = ev.center_hz + span / 2; | ||||
| const { spectrum_db, sample_rate, center_hz } = latest; | 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 minDb = -120; | ||||
| const maxDb = 0; | const maxDb = 0; | ||||
| @@ -422,7 +468,7 @@ function renderDetailSpectrogram(ev) { | |||||
| const b0 = binForFreq(f1, center_hz, sample_rate, n); | const b0 = binForFreq(f1, center_hz, sample_rate, n); | ||||
| const b1 = binForFreq(f2, center_hz, sample_rate, n); | const b1 = binForFreq(f2, center_hz, sample_rate, n); | ||||
| if (b0 < n && b1 >= 0) { | 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 norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb))); | ||||
| const [r, g, b] = colorMap(norm); | const [r, g, b] = colorMap(norm); | ||||
| row.data[x * 4 + 0] = r; | 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', () => { | fftSelect.addEventListener('change', () => { | ||||
| const size = parseInt(fftSelect.value, 10); | const size = parseInt(fftSelect.value, 10); | ||||
| if (Number.isFinite(size)) { | if (Number.isFinite(size)) { | ||||
| @@ -46,6 +46,20 @@ | |||||
| </select> | </select> | ||||
| </div> | </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> | <label class="control-label" for="fftSelect">FFT Size</label> | ||||
| <div class="control-row"> | <div class="control-row"> | ||||
| <select id="fftSelect"> | <select id="fftSelect"> | ||||
| @@ -87,6 +101,23 @@ | |||||
| <span>IQ Balance</span> | <span>IQ Balance</span> | ||||
| </label> | </label> | ||||
| </div> | </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> | </div> | ||||
| </section> | </section> | ||||
| <section class="panel spectrum-panel"> | <section class="panel spectrum-panel"> | ||||