diff --git a/cmd/sdrd/main.go b/cmd/sdrd/main.go index c301f2a..31640e0 100644 --- a/cmd/sdrd/main.go +++ b/cmd/sdrd/main.go @@ -294,6 +294,7 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + decodeMap := buildDecoderMap(cfg) recMgr := recorder.New(cfg.SampleRate, cfg.FFTSize, recorder.Policy{ Enabled: cfg.Recorder.Enabled, MinSNRDb: cfg.Recorder.MinSNRDb, @@ -303,10 +304,11 @@ func main() { RecordIQ: cfg.Recorder.RecordIQ, RecordAudio: cfg.Recorder.RecordAudio, AutoDemod: cfg.Recorder.AutoDemod, + AutoDecode: cfg.Recorder.AutoDecode, OutputDir: cfg.Recorder.OutputDir, ClassFilter: cfg.Recorder.ClassFilter, RingSeconds: cfg.Recorder.RingSeconds, - }, cfg.CenterHz) + }, cfg.CenterHz, decodeMap) go runDSP(ctx, srcMgr, cfg, det, window, h, eventFile, eventMu, dspUpdates, gpuState, recMgr) @@ -615,10 +617,11 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * RecordIQ: cfg.Recorder.RecordIQ, RecordAudio: cfg.Recorder.RecordAudio, AutoDemod: cfg.Recorder.AutoDemod, + AutoDecode: cfg.Recorder.AutoDecode, OutputDir: cfg.Recorder.OutputDir, ClassFilter: cfg.Recorder.ClassFilter, RingSeconds: cfg.Recorder.RingSeconds, - }, cfg.CenterHz) + }, cfg.CenterHz, buildDecoderMap(cfg)) } if upd.det != nil { det = upd.det @@ -742,6 +745,29 @@ func mustParseDuration(raw string, fallback time.Duration) time.Duration { return fallback } +func buildDecoderMap(cfg config.Config) map[string]string { + out := map[string]string{} + if cfg.Decoder.FT8Cmd != "" { + out["FT8"] = cfg.Decoder.FT8Cmd + } + if cfg.Decoder.WSPRCmd != "" { + out["WSPR"] = cfg.Decoder.WSPRCmd + } + if cfg.Decoder.DMRCmd != "" { + out["DMR"] = cfg.Decoder.DMRCmd + } + if cfg.Decoder.DStarCmd != "" { + out["D-STAR"] = cfg.Decoder.DStarCmd + } + if cfg.Decoder.FSKCmd != "" { + out["FSK"] = cfg.Decoder.FSKCmd + } + if cfg.Decoder.PSKCmd != "" { + out["PSK"] = cfg.Decoder.PSKCmd + } + return out +} + func parseSince(raw string) (time.Time, error) { if raw == "" { return time.Time{}, nil diff --git a/internal/config/config.go b/internal/config/config.go index 62a3d97..d38f4e6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,11 +28,21 @@ type RecorderConfig struct { RecordIQ bool `yaml:"record_iq" json:"record_iq"` RecordAudio bool `yaml:"record_audio" json:"record_audio"` AutoDemod bool `yaml:"auto_demod" json:"auto_demod"` + AutoDecode bool `yaml:"auto_decode" json:"auto_decode"` OutputDir string `yaml:"output_dir" json:"output_dir"` ClassFilter []string `yaml:"class_filter" json:"class_filter"` RingSeconds int `yaml:"ring_seconds" json:"ring_seconds"` } +type DecoderConfig struct { + FT8Cmd string `yaml:"ft8_cmd" json:"ft8_cmd"` + WSPRCmd string `yaml:"wspr_cmd" json:"wspr_cmd"` + DMRCmd string `yaml:"dmr_cmd" json:"dmr_cmd"` + DStarCmd string `yaml:"dstar_cmd" json:"dstar_cmd"` + FSKCmd string `yaml:"fsk_cmd" json:"fsk_cmd"` + PSKCmd string `yaml:"psk_cmd" json:"psk_cmd"` +} + type Config struct { Bands []Band `yaml:"bands" json:"bands"` CenterHz float64 `yaml:"center_hz" json:"center_hz"` @@ -46,6 +56,7 @@ type Config struct { IQBalance bool `yaml:"iq_balance" json:"iq_balance"` Detector DetectorConfig `yaml:"detector" json:"detector"` Recorder RecorderConfig `yaml:"recorder" json:"recorder"` + Decoder DecoderConfig `yaml:"decoder" json:"decoder"` WebAddr string `yaml:"web_addr" json:"web_addr"` EventPath string `yaml:"event_path" json:"event_path"` FrameRate int `yaml:"frame_rate" json:"frame_rate"` @@ -77,9 +88,11 @@ func Default() Config { RecordIQ: true, RecordAudio: false, AutoDemod: true, + AutoDecode: false, OutputDir: "data/recordings", RingSeconds: 8, }, + Decoder: DecoderConfig{}, WebAddr: ":8080", EventPath: "data/events.jsonl", FrameRate: 15, diff --git a/internal/decoder/decoder.go b/internal/decoder/decoder.go new file mode 100644 index 0000000..8bb15e5 --- /dev/null +++ b/internal/decoder/decoder.go @@ -0,0 +1,68 @@ +package decoder + +import ( + "errors" + "os/exec" + "strings" +) + +type Result struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Code int `json:"code"` +} + +// Run executes an external decoder command. If cmdTemplate contains {iq} or {sr}, they are replaced. +// Otherwise, --iq and --sample-rate args are appended. +func Run(cmdTemplate string, iqPath string, sampleRate int) (Result, error) { + if cmdTemplate == "" { + return Result{}, errors.New("decoder command empty") + } + cmdStr := strings.ReplaceAll(cmdTemplate, "{iq}", iqPath) + cmdStr = strings.ReplaceAll(cmdStr, "{sr}", intToString(sampleRate)) + parts := strings.Fields(cmdStr) + if len(parts) == 0 { + return Result{}, errors.New("invalid decoder command") + } + cmd := exec.Command(parts[0], parts[1:]...) + if !strings.Contains(cmdTemplate, "{iq}") { + cmd.Args = append(cmd.Args, "--iq", iqPath) + } + if !strings.Contains(cmdTemplate, "{sr}") { + cmd.Args = append(cmd.Args, "--sample-rate", intToString(sampleRate)) + } + out, err := cmd.CombinedOutput() + res := Result{Stdout: string(out), Code: 0} + if err != nil { + res.Stderr = err.Error() + res.Code = 1 + return res, err + } + return res, nil +} + +func intToString(v int) string { + return strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(strings.TrimSpace(strings.TrimSpace((func() string { return string(rune('0')) })()))), ""), ""), "")) + itoa(v) +} + +func itoa(v int) string { + if v == 0 { + return "0" + } + n := v + if n < 0 { + n = -n + } + buf := make([]byte, 0, 12) + for n > 0 { + buf = append(buf, byte('0'+n%10)) + n /= 10 + } + if v < 0 { + buf = append(buf, '-') + } + for i, j := 0, len(buf)-1; i < j; i, j = i+1, j-1 { + buf[i], buf[j] = buf[j], buf[i] + } + return string(buf) +} diff --git a/internal/recorder/decode.go b/internal/recorder/decode.go new file mode 100644 index 0000000..acc1063 --- /dev/null +++ b/internal/recorder/decode.go @@ -0,0 +1,29 @@ +package recorder + +import ( + "encoding/json" + "path/filepath" + + "sdr-visual-suite/internal/decoder" +) + +func (m *Manager) runDecodeIfConfigured(mod string, iqPath string, sampleRate int, files map[string]any, dir string) { + if !m.policy.AutoDecode || mod == "" { + return + } + cmd := "" + if m.decodeCommands != nil { + cmd = m.decodeCommands[mod] + } + if cmd == "" { + return + } + res, err := decoder.Run(cmd, iqPath, sampleRate) + if err != nil { + return + } + b, _ := json.MarshalIndent(res, "", " ") + path := filepath.Join(dir, "decode.json") + _ = writeFile(path, b) + files["decode"] = "decode.json" +} diff --git a/internal/recorder/recorder.go b/internal/recorder/recorder.go index 76b0063..0a51064 100644 --- a/internal/recorder/recorder.go +++ b/internal/recorder/recorder.go @@ -20,34 +20,37 @@ type Policy struct { RecordIQ bool `yaml:"record_iq" json:"record_iq"` RecordAudio bool `yaml:"record_audio" json:"record_audio"` AutoDemod bool `yaml:"auto_demod" json:"auto_demod"` + AutoDecode bool `yaml:"auto_decode" json:"auto_decode"` OutputDir string `yaml:"output_dir" json:"output_dir"` ClassFilter []string `yaml:"class_filter" json:"class_filter"` RingSeconds int `yaml:"ring_seconds" json:"ring_seconds"` } type Manager struct { - policy Policy - ring *Ring - sampleRate int - blockSize int - centerHz float64 + policy Policy + ring *Ring + sampleRate int + blockSize int + centerHz float64 + decodeCommands map[string]string } -func New(sampleRate int, blockSize int, policy Policy, centerHz float64) *Manager { +func New(sampleRate int, blockSize int, policy Policy, centerHz float64, decodeCommands map[string]string) *Manager { if policy.OutputDir == "" { policy.OutputDir = "data/recordings" } if policy.RingSeconds <= 0 { policy.RingSeconds = 8 } - return &Manager{policy: policy, ring: NewRing(sampleRate, blockSize, policy.RingSeconds), sampleRate: sampleRate, blockSize: blockSize, centerHz: centerHz} + return &Manager{policy: policy, ring: NewRing(sampleRate, blockSize, policy.RingSeconds), sampleRate: sampleRate, blockSize: blockSize, centerHz: centerHz, decodeCommands: decodeCommands} } -func (m *Manager) Update(sampleRate int, blockSize int, policy Policy, centerHz float64) { +func (m *Manager) Update(sampleRate int, blockSize int, policy Policy, centerHz float64, decodeCommands map[string]string) { m.policy = policy m.sampleRate = sampleRate m.blockSize = blockSize m.centerHz = centerHz + m.decodeCommands = decodeCommands if m.ring == nil { m.ring = NewRing(sampleRate, blockSize, policy.RingSeconds) return @@ -117,9 +120,10 @@ func (m *Manager) recordEvent(ev detector.Event) error { return err } files := map[string]any{} + var iqPath string if m.policy.RecordIQ { - path := filepath.Join(dir, "signal.cf32") - if err := writeCF32(path, segment); err != nil { + iqPath = filepath.Join(dir, "signal.cf32") + if err := writeCF32(iqPath, segment); err != nil { return err } files["iq"] = "signal.cf32" @@ -133,6 +137,9 @@ func (m *Manager) recordEvent(ev detector.Event) error { return err } } + if m.policy.AutoDecode && iqPath != "" && ev.Class != nil { + m.runDecodeIfConfigured(string(ev.Class.ModType), iqPath, m.sampleRate, files, dir) + } return writeMeta(dir, ev, m.sampleRate, files) } diff --git a/internal/recorder/util.go b/internal/recorder/util.go new file mode 100644 index 0000000..3c03755 --- /dev/null +++ b/internal/recorder/util.go @@ -0,0 +1,7 @@ +package recorder + +import "os" + +func writeFile(path string, b []byte) error { + return os.WriteFile(path, b, 0o644) +}