ソースを参照

Add UI controls and runtime SDR settings

master
Jan Svabenik 5日前
コミット
d1ea21181f
13個のファイルの変更961行の追加49行の削除
  1. +12
    -0
      README.md
  2. +208
    -11
      cmd/sdrd/main.go
  3. +7
    -4
      config.yaml
  4. +23
    -17
      internal/config/config.go
  5. +79
    -0
      internal/dsp/dsp.go
  6. +9
    -0
      internal/mock/source.go
  7. +113
    -0
      internal/runtime/runtime.go
  8. +73
    -0
      internal/runtime/runtime_test.go
  9. +4
    -0
      internal/sdr/source.go
  10. +75
    -11
      internal/sdrplay/sdrplay.go
  11. +196
    -0
      web/app.js
  12. +63
    -2
      web/index.html
  13. +99
    -4
      web/style.css

+ 12
- 0
README.md ファイルの表示

@@ -8,6 +8,7 @@ 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
- Recorded clips list placeholder (metadata only for now)
- Windows + Linux support
- Mock mode for testing without hardware
@@ -48,16 +49,27 @@ Edit `config.yaml`:
- `sample_rate`: sample rate
- `fft_size`: FFT size
- `gain_db`: device gain
- `agc`: enable automatic gain control
- `dc_block`: enable DC blocking filter
- `iq_balance`: enable basic IQ imbalance correction
- `detector.threshold_db`: power threshold in dB
- `detector.min_duration_ms`, `detector.hold_ms`: debounce/merge

## Web UI
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.

### Event Timeline
- The timeline panel displays recent events (time vs frequency).
- Click any event block to open the detail drawer with event stats and a mini spectrogram slice from the latest frame.

### Config API
- `GET /api/config`: returns the current runtime configuration.
- `POST /api/config`: updates `center_hz`, `sample_rate`, `fft_size`, `gain_db`, and `detector.threshold_db` at runtime.
- `POST /api/sdr/settings`: updates `agc`, `dc_block`, and `iq_balance` at runtime.

### Events API
`/api/events` reads from the JSONL event log and returns the most recent events:
- `limit` (optional): max number of events (default 200, max 2000)


+ 208
- 11
cmd/sdrd/main.go ファイルの表示

@@ -18,9 +18,11 @@ import (

"sdr-visual-suite/internal/config"
"sdr-visual-suite/internal/detector"
"sdr-visual-suite/internal/dsp"
"sdr-visual-suite/internal/events"
fftutil "sdr-visual-suite/internal/fft"
"sdr-visual-suite/internal/mock"
"sdr-visual-suite/internal/runtime"
"sdr-visual-suite/internal/sdr"
"sdr-visual-suite/internal/sdrplay"
)
@@ -64,6 +66,80 @@ func (h *hub) broadcast(frame SpectrumFrame) {
}
}

type sourceManager struct {
mu sync.RWMutex
src sdr.Source
newSource func(cfg config.Config) (sdr.Source, error)
}

func newSourceManager(src sdr.Source, newSource func(cfg config.Config) (sdr.Source, error)) *sourceManager {
return &sourceManager{src: src, newSource: newSource}
}

func (m *sourceManager) Start() error {
m.mu.RLock()
defer m.mu.RUnlock()
return m.src.Start()
}

func (m *sourceManager) Stop() error {
m.mu.RLock()
defer m.mu.RUnlock()
return m.src.Stop()
}

func (m *sourceManager) ReadIQ(n int) ([]complex64, error) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.src.ReadIQ(n)
}

func (m *sourceManager) ApplyConfig(cfg config.Config) error {
m.mu.Lock()
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 {
return nil
}
}

old := m.src
_ = old.Stop()
next, err := m.newSource(cfg)
if err != nil {
_ = old.Start()
return err
}
if err := next.Start(); err != nil {
_ = next.Stop()
_ = old.Start()
return err
}
m.src = next
return nil
}

type dspUpdate struct {
cfg config.Config
det *detector.Detector
window []float64
dcBlock bool
iqBalance bool
}

func pushDSPUpdate(ch chan dspUpdate, update dspUpdate) {
select {
case ch <- update:
default:
select {
case <-ch:
default:
}
ch <- update
}
}

func main() {
var cfgPath string
var mockFlag bool
@@ -76,19 +152,35 @@ func main() {
log.Fatalf("load config: %v", err)
}

var src sdr.Source
if mockFlag {
src = mock.New(cfg.SampleRate)
} else {
src, err = sdrplay.New(cfg.SampleRate, cfg.CenterHz, cfg.GainDb)
cfgManager := runtime.New(cfg)

newSource := func(cfg config.Config) (sdr.Source, error) {
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)
}
return src, nil
}
src, err := sdrplay.New(cfg.SampleRate, cfg.CenterHz, cfg.GainDb)
if err != nil {
log.Fatalf("sdrplay init failed: %v (try --mock or build with -tags sdrplay)", err)
return nil, err
}
if updatable, ok := src.(sdr.ConfigurableSource); ok {
_ = updatable.UpdateConfig(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.AGC)
}
return src, nil
}

src, err := newSource(cfg)
if err != nil {
log.Fatalf("sdrplay init failed: %v (try --mock or build with -tags sdrplay)", err)
}
if err := src.Start(); err != nil {
srcMgr := newSourceManager(src, newSource)
if err := srcMgr.Start(); err != nil {
log.Fatalf("source start: %v", err)
}
defer src.Stop()
defer srcMgr.Stop()

if err := os.MkdirAll(filepath.Dir(cfg.EventPath), 0o755); err != nil {
log.Fatalf("event path: %v", err)
@@ -106,11 +198,12 @@ func main() {

window := fftutil.Hann(cfg.FFTSize)
h := newHub()
dspUpdates := make(chan dspUpdate, 1)

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

go runDSP(ctx, src, cfg, det, window, h, eventFile)
go runDSP(ctx, srcMgr, cfg, det, window, h, eventFile, dspUpdates)

upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
@@ -133,7 +226,90 @@ func main() {

http.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(cfg)
switch r.Method {
case http.MethodGet:
_ = json.NewEncoder(w).Encode(cfgManager.Snapshot())
case http.MethodPost:
var update runtime.ConfigUpdate
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
prev := cfgManager.Snapshot()
next, err := cfgManager.ApplyConfig(update)
if err != nil {
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
if sourceChanged {
if err := srcMgr.ApplyConfig(next); err != nil {
cfgManager.Replace(prev)
http.Error(w, "failed to apply source config", http.StatusInternalServerError)
return
}
}
detChanged := prev.Detector.ThresholdDb != next.Detector.ThresholdDb ||
prev.Detector.MinDurationMs != next.Detector.MinDurationMs ||
prev.Detector.HoldMs != next.Detector.HoldMs ||
prev.SampleRate != next.SampleRate ||
prev.FFTSize != next.FFTSize
windowChanged := prev.FFTSize != next.FFTSize
var newDet *detector.Detector
var newWindow []float64
if detChanged {
newDet = detector.New(next.Detector.ThresholdDb, next.SampleRate, next.FFTSize,
time.Duration(next.Detector.MinDurationMs)*time.Millisecond,
time.Duration(next.Detector.HoldMs)*time.Millisecond)
}
if windowChanged {
newWindow = fftutil.Hann(next.FFTSize)
}
pushDSPUpdate(dspUpdates, dspUpdate{
cfg: next,
det: newDet,
window: newWindow,
dcBlock: next.DCBlock,
iqBalance: next.IQBalance,
})
_ = json.NewEncoder(w).Encode(next)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})

http.HandleFunc("/api/sdr/settings", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var update runtime.SettingsUpdate
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
prev := cfgManager.Snapshot()
next, err := cfgManager.ApplySettings(update)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if prev.AGC != next.AGC {
if err := srcMgr.ApplyConfig(next); err != nil {
cfgManager.Replace(prev)
http.Error(w, "failed to apply agc", http.StatusInternalServerError)
return
}
}
if prev.DCBlock != next.DCBlock || prev.IQBalance != next.IQBalance {
pushDSPUpdate(dspUpdates, dspUpdate{
cfg: next,
dcBlock: next.DCBlock,
iqBalance: next.IQBalance,
})
}
_ = json.NewEncoder(w).Encode(next)
})

http.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) {
@@ -179,21 +355,42 @@ func main() {
_ = server.Shutdown(ctxTimeout)
}

func runDSP(ctx context.Context, src sdr.Source, cfg config.Config, det *detector.Detector, window []float64, h *hub, eventFile *os.File) {
func runDSP(ctx context.Context, src sdr.Source, cfg config.Config, det *detector.Detector, window []float64, h *hub, eventFile *os.File, updates <-chan dspUpdate) {
ticker := time.NewTicker(cfg.FrameInterval())
defer ticker.Stop()
enc := json.NewEncoder(eventFile)
dcBlocker := dsp.NewDCBlocker(0.995)
dcEnabled := cfg.DCBlock
iqEnabled := cfg.IQBalance

for {
select {
case <-ctx.Done():
return
case upd := <-updates:
cfg = upd.cfg
if upd.det != nil {
det = upd.det
}
if upd.window != nil {
window = upd.window
}
dcEnabled = upd.dcBlock
iqEnabled = upd.iqBalance
dcBlocker.Reset()
ticker.Reset(cfg.FrameInterval())
case <-ticker.C:
iq, err := src.ReadIQ(cfg.FFTSize)
if err != nil {
log.Printf("read IQ: %v", err)
continue
}
if dcEnabled {
dcBlocker.Apply(iq)
}
if iqEnabled {
dsp.IQBalance(iq)
}
spectrum := fftutil.Spectrum(iq, window)
now := time.Now()
finished, signals := det.Process(now, spectrum, cfg.CenterHz)


+ 7
- 4
config.yaml ファイルの表示

@@ -1,11 +1,14 @@
bands:
- name: fm-test
start_hz: 99.5e6
end_hz: 100.5e6
center_hz: 100.0e6
- name: 40m
start_hz: 7.0e6
end_hz: 7.2e6
center_hz: 7.1e6
sample_rate: 2048000
fft_size: 2048
gain_db: 30
agc: false
dc_block: false
iq_balance: false
detector:
threshold_db: -20
min_duration_ms: 250


+ 23
- 17
internal/config/config.go ファイルの表示

@@ -8,29 +8,32 @@ import (
)

type Band struct {
Name string `yaml:"name"`
StartHz float64 `yaml:"start_hz"`
EndHz float64 `yaml:"end_hz"`
Name string `yaml:"name" json:"name"`
StartHz float64 `yaml:"start_hz" json:"start_hz"`
EndHz float64 `yaml:"end_hz" json:"end_hz"`
}

type DetectorConfig struct {
ThresholdDb float64 `yaml:"threshold_db"`
MinDurationMs int `yaml:"min_duration_ms"`
HoldMs int `yaml:"hold_ms"`
ThresholdDb float64 `yaml:"threshold_db" json:"threshold_db"`
MinDurationMs int `yaml:"min_duration_ms" json:"min_duration_ms"`
HoldMs int `yaml:"hold_ms" json:"hold_ms"`
}

type Config struct {
Bands []Band `yaml:"bands"`
CenterHz float64 `yaml:"center_hz"`
SampleRate int `yaml:"sample_rate"`
FFTSize int `yaml:"fft_size"`
GainDb float64 `yaml:"gain_db"`
Detector DetectorConfig `yaml:"detector"`
WebAddr string `yaml:"web_addr"`
EventPath string `yaml:"event_path"`
FrameRate int `yaml:"frame_rate"`
WaterfallLines int `yaml:"waterfall_lines"`
WebRoot string `yaml:"web_root"`
Bands []Band `yaml:"bands" json:"bands"`
CenterHz float64 `yaml:"center_hz" json:"center_hz"`
SampleRate int `yaml:"sample_rate" json:"sample_rate"`
FFTSize int `yaml:"fft_size" json:"fft_size"`
GainDb float64 `yaml:"gain_db" json:"gain_db"`
AGC bool `yaml:"agc" json:"agc"`
DCBlock bool `yaml:"dc_block" json:"dc_block"`
IQBalance bool `yaml:"iq_balance" json:"iq_balance"`
Detector DetectorConfig `yaml:"detector" json:"detector"`
WebAddr string `yaml:"web_addr" json:"web_addr"`
EventPath string `yaml:"event_path" json:"event_path"`
FrameRate int `yaml:"frame_rate" json:"frame_rate"`
WaterfallLines int `yaml:"waterfall_lines" json:"waterfall_lines"`
WebRoot string `yaml:"web_root" json:"web_root"`
}

func Default() Config {
@@ -42,6 +45,9 @@ func Default() Config {
SampleRate: 2_048_000,
FFTSize: 2048,
GainDb: 30,
AGC: false,
DCBlock: false,
IQBalance: false,
Detector: DetectorConfig{ThresholdDb: -20, MinDurationMs: 250, HoldMs: 500},
WebAddr: ":8080",
EventPath: "data/events.jsonl",


+ 79
- 0
internal/dsp/dsp.go ファイルの表示

@@ -0,0 +1,79 @@
package dsp

import "math"

type DCBlocker struct {
r float64
prevX complex64
prevY complex64
}

func NewDCBlocker(r float64) *DCBlocker {
if r <= 0 || r >= 1 {
r = 0.995
}
return &DCBlocker{r: r}
}

func (d *DCBlocker) Reset() {
d.prevX = 0
d.prevY = 0
}

func (d *DCBlocker) Apply(iq []complex64) {
if d == nil {
return
}
for i := 0; i < len(iq); i++ {
x := iq[i]
y := complex(
float32(float64(real(x)-real(d.prevX))+d.r*float64(real(d.prevY))),
float32(float64(imag(x)-imag(d.prevX))+d.r*float64(imag(d.prevY))),
)
d.prevX = x
d.prevY = y
iq[i] = y
}
}

func IQBalance(iq []complex64) {
if len(iq) == 0 {
return
}
var sumI, sumQ float64
for _, v := range iq {
sumI += float64(real(v))
sumQ += float64(imag(v))
}
meanI := sumI / float64(len(iq))
meanQ := sumQ / float64(len(iq))

var varI, varQ, cov float64
for _, v := range iq {
i := float64(real(v)) - meanI
q := float64(imag(v)) - meanQ
varI += i * i
varQ += q * q
cov += i * q
}
n := float64(len(iq))
varI /= n
varQ /= n
cov /= n
if varI <= 0 || varQ <= 0 {
return
}

gain := math.Sqrt(varI / varQ)
phi := 0.5 * math.Atan2(2*cov, varI-varQ)
cosP := math.Cos(phi)
sinP := math.Sin(phi)

for i := 0; i < len(iq); i++ {
re := float64(real(iq[i])) - meanI
im := (float64(imag(iq[i])) - meanQ) * gain
i2 := re*cosP - im*sinP
q2 := re*sinP + im*cosP
iq[i] = complex(float32(i2+meanI), float32(q2+meanQ))
}
}

+ 9
- 0
internal/mock/source.go ファイルの表示

@@ -27,6 +27,15 @@ 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 {
s.mu.Lock()
defer s.mu.Unlock()
if sampleRate > 0 {
s.sampleRate = float64(sampleRate)
}
return nil
}

func (s *Source) ReadIQ(n int) ([]complex64, error) {
s.mu.Lock()
defer s.mu.Unlock()


+ 113
- 0
internal/runtime/runtime.go ファイルの表示

@@ -0,0 +1,113 @@
package runtime

import (
"errors"
"sync"

"sdr-visual-suite/internal/config"
)

type ConfigUpdate struct {
CenterHz *float64 `json:"center_hz"`
SampleRate *int `json:"sample_rate"`
FFTSize *int `json:"fft_size"`
GainDb *float64 `json:"gain_db"`
Detector *DetectorUpdate `json:"detector"`
}

type DetectorUpdate struct {
ThresholdDb *float64 `json:"threshold_db"`
MinDuration *int `json:"min_duration_ms"`
HoldMs *int `json:"hold_ms"`
}

type SettingsUpdate struct {
AGC *bool `json:"agc"`
DCBlock *bool `json:"dc_block"`
IQBalance *bool `json:"iq_balance"`
}

type Manager struct {
mu sync.RWMutex
cfg config.Config
}

func New(cfg config.Config) *Manager {
return &Manager{cfg: cfg}
}

func (m *Manager) Snapshot() config.Config {
m.mu.RLock()
defer m.mu.RUnlock()
return m.cfg
}

func (m *Manager) Replace(cfg config.Config) {
m.mu.Lock()
defer m.mu.Unlock()
m.cfg = cfg
}

func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) {
m.mu.Lock()
defer m.mu.Unlock()

next := m.cfg
if update.CenterHz != nil {
next.CenterHz = *update.CenterHz
}
if update.SampleRate != nil {
if *update.SampleRate <= 0 {
return m.cfg, errors.New("sample_rate must be > 0")
}
next.SampleRate = *update.SampleRate
}
if update.FFTSize != nil {
if *update.FFTSize <= 0 {
return m.cfg, errors.New("fft_size must be > 0")
}
next.FFTSize = *update.FFTSize
}
if update.GainDb != nil {
next.GainDb = *update.GainDb
}
if update.Detector != nil {
if update.Detector.ThresholdDb != nil {
next.Detector.ThresholdDb = *update.Detector.ThresholdDb
}
if update.Detector.MinDuration != nil {
if *update.Detector.MinDuration <= 0 {
return m.cfg, errors.New("min_duration_ms must be > 0")
}
next.Detector.MinDurationMs = *update.Detector.MinDuration
}
if update.Detector.HoldMs != nil {
if *update.Detector.HoldMs <= 0 {
return m.cfg, errors.New("hold_ms must be > 0")
}
next.Detector.HoldMs = *update.Detector.HoldMs
}
}

m.cfg = next
return m.cfg, nil
}

func (m *Manager) ApplySettings(update SettingsUpdate) (config.Config, error) {
m.mu.Lock()
defer m.mu.Unlock()

next := m.cfg
if update.AGC != nil {
next.AGC = *update.AGC
}
if update.DCBlock != nil {
next.DCBlock = *update.DCBlock
}
if update.IQBalance != nil {
next.IQBalance = *update.IQBalance
}

m.cfg = next
return m.cfg, nil
}

+ 73
- 0
internal/runtime/runtime_test.go ファイルの表示

@@ -0,0 +1,73 @@
package runtime

import (
"testing"

"sdr-visual-suite/internal/config"
)

func TestApplyConfigUpdate(t *testing.T) {
cfg := config.Default()
mgr := New(cfg)

center := 7.2e6
sampleRate := 1_024_000
fftSize := 4096
threshold := -35.0

updated, err := mgr.ApplyConfig(ConfigUpdate{
CenterHz: &center,
SampleRate: &sampleRate,
FFTSize: &fftSize,
Detector: &DetectorUpdate{
ThresholdDb: &threshold,
},
})
if err != nil {
t.Fatalf("apply: %v", err)
}
if updated.CenterHz != center {
t.Fatalf("center hz: %v", updated.CenterHz)
}
if updated.SampleRate != sampleRate {
t.Fatalf("sample rate: %v", updated.SampleRate)
}
if updated.FFTSize != fftSize {
t.Fatalf("fft size: %v", updated.FFTSize)
}
if updated.Detector.ThresholdDb != threshold {
t.Fatalf("threshold: %v", updated.Detector.ThresholdDb)
}
}

func TestApplyConfigRejectsInvalid(t *testing.T) {
cfg := config.Default()
mgr := New(cfg)
bad := 0
if _, err := mgr.ApplyConfig(ConfigUpdate{SampleRate: &bad}); err == nil {
t.Fatalf("expected error")
}
snap := mgr.Snapshot()
if snap.SampleRate != cfg.SampleRate {
t.Fatalf("sample rate changed on error")
}
}

func TestApplySettings(t *testing.T) {
cfg := config.Default()
mgr := New(cfg)
agc := true
dc := true
iq := true
updated, err := mgr.ApplySettings(SettingsUpdate{
AGC: &agc,
DCBlock: &dc,
IQBalance: &iq,
})
if err != nil {
t.Fatalf("apply settings: %v", err)
}
if !updated.AGC || !updated.DCBlock || !updated.IQBalance {
t.Fatalf("settings not applied: %+v", updated)
}
}

+ 4
- 0
internal/sdr/source.go ファイルの表示

@@ -8,4 +8,8 @@ type Source interface {
ReadIQ(n int) ([]complex64, error)
}

type ConfigurableSource interface {
UpdateConfig(sampleRate int, centerHz float64, gainDb float64, agc bool) error
}

var ErrNotImplemented = errors.New("sdrplay support not built; build with -tags sdrplay or use --mock")

+ 75
- 11
internal/sdrplay/sdrplay.go ファイルの表示

@@ -7,6 +7,7 @@ package sdrplay
#cgo linux LDFLAGS: -lsdrplay_api
#include "sdrplay_api.h"
#include <stdlib.h>
#include <string.h>

extern void goStreamCallback(short *xi, short *xq, unsigned int numSamples, void *cbContext);

@@ -20,6 +21,15 @@ static void EventCallback(sdrplay_api_EventT eventId, sdrplay_api_TunerSelectT t
(void)eventId; (void)tuner; (void)params; (void)cbContext;
}

static sdrplay_api_CallbackFnsT sdrplay_get_callbacks() {
sdrplay_api_CallbackFnsT cb;
memset(&cb, 0, sizeof(cb));
cb.StreamACbFn = StreamACallback;
cb.StreamBCbFn = NULL;
cb.EventCbFn = EventCallback;
return cb;
}

static void sdrplay_set_fs(sdrplay_api_DeviceParamsT *p, double fsHz) {
if (p && p->devParams) p->devParams->fsFreq.fsHz = fsHz;
}
@@ -39,6 +49,15 @@ static void sdrplay_set_if_zero(sdrplay_api_DeviceParamsT *p) {
static void sdrplay_disable_agc(sdrplay_api_DeviceParamsT *p) {
if (p && p->rxChannelA) p->rxChannelA->ctrlParams.agc.enable = sdrplay_api_AGC_DISABLE;
}

static void sdrplay_set_agc(sdrplay_api_DeviceParamsT *p, int enable) {
if (!p || !p->rxChannelA) return;
if (enable) {
p->rxChannelA->ctrlParams.agc.enable = sdrplay_api_AGC_100;
} else {
p->rxChannelA->ctrlParams.agc.enable = sdrplay_api_AGC_DISABLE;
}
}
*/
import "C"

@@ -53,17 +72,24 @@ import (
)

type Source struct {
mu sync.Mutex
dev C.sdrplay_api_DeviceT
params *C.sdrplay_api_DeviceParamsT
ch chan []complex64
handle cgo.Handle
open bool
mu sync.Mutex
dev C.sdrplay_api_DeviceT
params *C.sdrplay_api_DeviceParamsT
ch chan []complex64
handle cgo.Handle
open bool
sampleRate int
centerHz float64
gainDb float64
agc bool
}

func New(sampleRate int, centerHz float64, gainDb float64) (sdr.Source, error) {
s := &Source{
ch: make(chan []complex64, 16),
ch: make(chan []complex64, 16),
sampleRate: sampleRate,
centerHz: centerHz,
gainDb: gainDb,
}
s.handle = cgo.NewHandle(s)
return s, s.configure(sampleRate, centerHz, gainDb)
@@ -99,10 +125,7 @@ func (s *Source) configure(sampleRate int, centerHz float64, gainDb float64) err
C.sdrplay_set_if_zero(s.params)
C.sdrplay_disable_agc(s.params)

cb := C.sdrplay_api_CallbackFnsT{}
cb.StreamACbFn = (C.sdrplay_api_StreamCallback_t)(unsafe.Pointer(C.StreamACallback))
cb.StreamBCbFn = nil
cb.EventCbFn = (C.sdrplay_api_EventCallback_t)(unsafe.Pointer(C.EventCallback))
cb := C.sdrplay_get_callbacks()

if err := cErr(C.sdrplay_api_Init(s.dev.dev, &cb, unsafe.Pointer(uintptr(s.handle)))); err != nil {
return fmt.Errorf("sdrplay_api_Init: %w", err)
@@ -112,6 +135,47 @@ 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 {
s.mu.Lock()
defer s.mu.Unlock()
if s.params == nil {
return errors.New("sdrplay not initialized")
}

updateReasons := C.int(0)
if sampleRate > 0 && sampleRate != s.sampleRate {
C.sdrplay_set_fs(s.params, C.double(sampleRate))
updateReasons |= C.int(C.sdrplay_api_Update_Dev_Fs)
s.sampleRate = sampleRate
}
if centerHz != 0 && centerHz != s.centerHz {
C.sdrplay_set_rf(s.params, C.double(centerHz))
updateReasons |= C.int(C.sdrplay_api_Update_Tuner_Frf)
s.centerHz = centerHz
}
if gainDb != s.gainDb {
C.sdrplay_set_gain(s.params, C.uint(gainDb))
updateReasons |= C.int(C.sdrplay_api_Update_Tuner_Gr)
s.gainDb = gainDb
}
if agc != s.agc {
if agc {
C.sdrplay_set_agc(s.params, 1)
} else {
C.sdrplay_set_agc(s.params, 0)
}
updateReasons |= C.int(C.sdrplay_api_Update_Ctrl_Agc)
s.agc = agc
}
if updateReasons == 0 {
return nil
}
if err := cErr(C.sdrplay_api_Update(s.dev.dev, C.sdrplay_api_Tuner_A, C.sdrplay_api_UpdateReasonT(updateReasons), C.sdrplay_api_Update_Ext1_None)); err != nil {
return err
}
return nil
}

func (s *Source) Stop() error {
s.mu.Lock()
defer s.mu.Unlock()


+ 196
- 0
web/app.js ファイルの表示

@@ -13,6 +13,18 @@ const detailEndEl = document.getElementById('detailEnd');
const detailSnrEl = document.getElementById('detailSnr');
const detailDurEl = document.getElementById('detailDur');
const detailSpectrogram = document.getElementById('detailSpectrogram');
const configStatusEl = document.getElementById('configStatus');
const centerInput = document.getElementById('centerInput');
const spanInput = document.getElementById('spanInput');
const fftSelect = document.getElementById('fftSelect');
const gainRange = document.getElementById('gainRange');
const gainInput = document.getElementById('gainInput');
const thresholdRange = document.getElementById('thresholdRange');
const thresholdInput = document.getElementById('thresholdInput');
const agcToggle = document.getElementById('agcToggle');
const dcToggle = document.getElementById('dcToggle');
const iqToggle = document.getElementById('iqToggle');
const presetButtons = Array.from(document.querySelectorAll('.preset-btn'));

let latest = null;
let zoom = 1.0;
@@ -22,6 +34,12 @@ let dragStartX = 0;
let dragStartPan = 0;
let timelineDirty = true;
let detailDirty = false;
let currentConfig = null;
let isSyncingConfig = false;
let pendingConfigUpdate = null;
let pendingSettingsUpdate = null;
let configTimer = null;
let settingsTimer = null;

const events = [];
const eventsById = new Map();
@@ -51,6 +69,114 @@ function resize() {
window.addEventListener('resize', resize);
resize();

function setConfigStatus(text) {
if (configStatusEl) {
configStatusEl.textContent = text;
}
}

function toMHz(hz) {
return hz / 1e6;
}

function fromMHz(mhz) {
return mhz * 1e6;
}

function applyConfigToUI(cfg) {
if (!cfg) return;
isSyncingConfig = true;
centerInput.value = toMHz(cfg.center_hz).toFixed(6);
spanInput.value = toMHz(cfg.sample_rate).toFixed(3);
fftSelect.value = String(cfg.fft_size);
gainRange.value = cfg.gain_db;
gainInput.value = cfg.gain_db;
thresholdRange.value = cfg.detector.threshold_db;
thresholdInput.value = cfg.detector.threshold_db;
agcToggle.checked = !!cfg.agc;
dcToggle.checked = !!cfg.dc_block;
iqToggle.checked = !!cfg.iq_balance;
isSyncingConfig = false;
}

async function loadConfig() {
try {
const res = await fetch('/api/config');
if (!res.ok) {
setConfigStatus('Failed to load');
return;
}
const data = await res.json();
currentConfig = data;
applyConfigToUI(currentConfig);
setConfigStatus('Synced');
} catch (err) {
setConfigStatus('Offline');
}
}

function queueConfigUpdate(partial) {
if (isSyncingConfig) return;
pendingConfigUpdate = { ...(pendingConfigUpdate || {}), ...partial };
setConfigStatus('Updating...');
if (configTimer) clearTimeout(configTimer);
configTimer = setTimeout(sendConfigUpdate, 200);
}

function queueSettingsUpdate(partial) {
if (isSyncingConfig) return;
pendingSettingsUpdate = { ...(pendingSettingsUpdate || {}), ...partial };
setConfigStatus('Updating...');
if (settingsTimer) clearTimeout(settingsTimer);
settingsTimer = setTimeout(sendSettingsUpdate, 100);
}

async function sendConfigUpdate() {
if (!pendingConfigUpdate) return;
const payload = pendingConfigUpdate;
pendingConfigUpdate = null;
try {
const res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
setConfigStatus('Apply failed');
return;
}
const data = await res.json();
currentConfig = data;
applyConfigToUI(currentConfig);
setConfigStatus('Applied');
} catch (err) {
setConfigStatus('Offline');
}
}

async function sendSettingsUpdate() {
if (!pendingSettingsUpdate) return;
const payload = pendingSettingsUpdate;
pendingSettingsUpdate = null;
try {
const res = await fetch('/api/sdr/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
setConfigStatus('Apply failed');
return;
}
const data = await res.json();
currentConfig = data;
applyConfigToUI(currentConfig);
setConfigStatus('Applied');
} catch (err) {
setConfigStatus('Offline');
}
}

function colorMap(v) {
// v in [0..1]
const r = Math.min(255, Math.max(0, Math.floor(255 * Math.pow(v, 0.6))));
@@ -330,6 +456,75 @@ window.addEventListener('mousemove', (ev) => {
pan = Math.max(-0.5, Math.min(0.5, pan));
});

centerInput.addEventListener('change', () => {
const mhz = parseFloat(centerInput.value);
if (Number.isFinite(mhz)) {
queueConfigUpdate({ center_hz: fromMHz(mhz) });
}
});

spanInput.addEventListener('change', () => {
const mhz = parseFloat(spanInput.value);
if (Number.isFinite(mhz) && mhz > 0) {
queueConfigUpdate({ sample_rate: Math.round(fromMHz(mhz)) });
}
});

fftSelect.addEventListener('change', () => {
const size = parseInt(fftSelect.value, 10);
if (Number.isFinite(size)) {
queueConfigUpdate({ fft_size: size });
}
});

gainRange.addEventListener('input', () => {
gainInput.value = gainRange.value;
queueConfigUpdate({ gain_db: parseFloat(gainRange.value) });
});

gainInput.addEventListener('change', () => {
const v = parseFloat(gainInput.value);
if (Number.isFinite(v)) {
gainRange.value = v;
queueConfigUpdate({ gain_db: v });
}
});

thresholdRange.addEventListener('input', () => {
thresholdInput.value = thresholdRange.value;
queueConfigUpdate({ detector: { threshold_db: parseFloat(thresholdRange.value) } });
});

thresholdInput.addEventListener('change', () => {
const v = parseFloat(thresholdInput.value);
if (Number.isFinite(v)) {
thresholdRange.value = v;
queueConfigUpdate({ detector: { threshold_db: v } });
}
});

agcToggle.addEventListener('change', () => {
queueSettingsUpdate({ agc: agcToggle.checked });
});

dcToggle.addEventListener('change', () => {
queueSettingsUpdate({ dc_block: dcToggle.checked });
});

iqToggle.addEventListener('change', () => {
queueSettingsUpdate({ iq_balance: iqToggle.checked });
});

for (const btn of presetButtons) {
btn.addEventListener('click', () => {
const mhz = parseFloat(btn.dataset.center);
if (Number.isFinite(mhz)) {
centerInput.value = mhz.toFixed(3);
queueConfigUpdate({ center_hz: fromMHz(mhz) });
}
});
}

function normalizeEvent(ev) {
const startMs = new Date(ev.start).getTime();
const endMs = new Date(ev.end).getTime();
@@ -428,6 +623,7 @@ timelineCanvas.addEventListener('click', (ev) => {
}
});

loadConfig();
connect();
requestAnimationFrame(tick);
fetchEvents(true);


+ 63
- 2
web/index.html ファイルの表示

@@ -12,10 +12,71 @@
<div class="meta" id="meta"></div>
</header>
<main>
<section class="panel">
<section class="panel controls-panel">
<div class="panel-header">
<div>Radio Controls</div>
<div class="panel-subtitle" id="configStatus">Loading...</div>
</div>
<div class="controls-grid">
<label class="control-label" for="centerInput">Center (MHz)</label>
<div class="control-row">
<input id="centerInput" type="number" step="0.001" min="0" />
<div class="preset-row">
<button class="preset-btn" data-center="7.1">40m</button>
<button class="preset-btn" data-center="14.1">20m</button>
<button class="preset-btn" data-center="18.1">17m</button>
</div>
</div>

<label class="control-label" for="spanInput">Span (MHz)</label>
<div class="control-row">
<input id="spanInput" type="number" step="0.1" min="0.1" />
</div>

<label class="control-label" for="fftSelect">FFT Size</label>
<div class="control-row">
<select id="fftSelect">
<option value="512">512</option>
<option value="1024">1024</option>
<option value="2048">2048</option>
<option value="4096">4096</option>
<option value="8192">8192</option>
</select>
</div>

<label class="control-label" for="gainRange">Gain (dB)</label>
<div class="control-row">
<input id="gainRange" type="range" min="0" max="60" step="1" />
<input id="gainInput" type="number" min="0" max="60" step="1" />
</div>

<label class="control-label" for="thresholdRange">Detector (dB)</label>
<div class="control-row">
<input id="thresholdRange" type="range" min="-120" max="0" step="1" />
<input id="thresholdInput" type="number" min="-120" max="0" step="1" />
</div>

<label class="control-label">DSP</label>
<div class="toggle-row">
<label class="toggle">
<input id="agcToggle" type="checkbox" />
<span>AGC</span>
</label>
<label class="toggle">
<input id="dcToggle" type="checkbox" />
<span>DC Block</span>
</label>
<label class="toggle">
<input id="iqToggle" type="checkbox" />
<span>IQ Balance</span>
</label>
</div>
</div>
</section>
<section class="panel spectrum-panel">
<canvas id="spectrum"></canvas>
</section>
<section class="panel">
<section class="panel waterfall-panel">
<canvas id="waterfall"></canvas>
</section>
<section class="panel timeline-panel">


+ 99
- 4
web/style.css ファイルの表示

@@ -47,7 +47,7 @@ main {
flex: 1;
display: grid;
grid-template-columns: 2fr 1fr;
grid-template-rows: 1fr 1.2fr;
grid-template-rows: auto 1fr;
gap: 12px;
padding: 12px;
}
@@ -67,12 +67,104 @@ canvas {
background: #06090d;
}

.controls-panel {
display: flex;
flex-direction: column;
gap: 12px;
grid-column: 2;
grid-row: 1;
}

.spectrum-panel {
grid-column: 1;
grid-row: 1;
}

.waterfall-panel {
grid-column: 1;
grid-row: 2;
}

.timeline-panel {
grid-column: 2;
grid-row: 2;
}

.controls-grid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
font-size: 0.9rem;
}

.control-label {
color: var(--muted);
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.08em;
}

.control-row {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}

.control-row input[type="number"],
.control-row select {
width: 100%;
background: #0b111a;
border: 1px solid #20344b;
color: var(--text);
border-radius: 8px;
padding: 6px 8px;
}

.control-row input[type="range"] {
width: 100%;
}

.preset-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}

.preset-btn {
background: #142233;
color: var(--text);
border: 1px solid #1f3248;
border-radius: 8px;
padding: 4px 10px;
cursor: pointer;
}

.preset-btn:hover {
border-color: #2b4b68;
}

.toggle-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}

.toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: var(--text);
}

.toggle input {
accent-color: var(--accent);
}

.timeline-panel {
display: flex;
flex-direction: column;
gap: 8px;
grid-row: 1 / span 2;
grid-column: 2;
}

.timeline-panel canvas {
@@ -180,9 +272,12 @@ canvas {
@media (max-width: 820px) {
main {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr 1fr;
grid-template-rows: auto auto auto auto;
}

.controls-panel,
.spectrum-panel,
.waterfall-panel,
.timeline-panel {
grid-row: auto;
grid-column: auto;


読み込み中…
キャンセル
保存