| @@ -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 | |||
| @@ -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, | |||
| @@ -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) | |||
| } | |||
| @@ -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" | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -0,0 +1,7 @@ | |||
| package recorder | |||
| import "os" | |||
| func writeFile(path string, b []byte) error { | |||
| return os.WriteFile(path, b, 0o644) | |||
| } | |||