| @@ -294,6 +294,7 @@ func main() { | |||||
| ctx, cancel := context.WithCancel(context.Background()) | ctx, cancel := context.WithCancel(context.Background()) | ||||
| defer cancel() | defer cancel() | ||||
| decodeMap := buildDecoderMap(cfg) | |||||
| recMgr := recorder.New(cfg.SampleRate, cfg.FFTSize, recorder.Policy{ | recMgr := recorder.New(cfg.SampleRate, cfg.FFTSize, recorder.Policy{ | ||||
| Enabled: cfg.Recorder.Enabled, | Enabled: cfg.Recorder.Enabled, | ||||
| MinSNRDb: cfg.Recorder.MinSNRDb, | MinSNRDb: cfg.Recorder.MinSNRDb, | ||||
| @@ -303,10 +304,11 @@ func main() { | |||||
| RecordIQ: cfg.Recorder.RecordIQ, | RecordIQ: cfg.Recorder.RecordIQ, | ||||
| RecordAudio: cfg.Recorder.RecordAudio, | RecordAudio: cfg.Recorder.RecordAudio, | ||||
| AutoDemod: cfg.Recorder.AutoDemod, | AutoDemod: cfg.Recorder.AutoDemod, | ||||
| AutoDecode: cfg.Recorder.AutoDecode, | |||||
| OutputDir: cfg.Recorder.OutputDir, | OutputDir: cfg.Recorder.OutputDir, | ||||
| ClassFilter: cfg.Recorder.ClassFilter, | ClassFilter: cfg.Recorder.ClassFilter, | ||||
| RingSeconds: cfg.Recorder.RingSeconds, | RingSeconds: cfg.Recorder.RingSeconds, | ||||
| }, cfg.CenterHz) | |||||
| }, cfg.CenterHz, decodeMap) | |||||
| go runDSP(ctx, srcMgr, cfg, det, window, h, eventFile, eventMu, dspUpdates, gpuState, recMgr) | 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, | RecordIQ: cfg.Recorder.RecordIQ, | ||||
| RecordAudio: cfg.Recorder.RecordAudio, | RecordAudio: cfg.Recorder.RecordAudio, | ||||
| AutoDemod: cfg.Recorder.AutoDemod, | AutoDemod: cfg.Recorder.AutoDemod, | ||||
| AutoDecode: cfg.Recorder.AutoDecode, | |||||
| OutputDir: cfg.Recorder.OutputDir, | OutputDir: cfg.Recorder.OutputDir, | ||||
| ClassFilter: cfg.Recorder.ClassFilter, | ClassFilter: cfg.Recorder.ClassFilter, | ||||
| RingSeconds: cfg.Recorder.RingSeconds, | RingSeconds: cfg.Recorder.RingSeconds, | ||||
| }, cfg.CenterHz) | |||||
| }, cfg.CenterHz, buildDecoderMap(cfg)) | |||||
| } | } | ||||
| if upd.det != nil { | if upd.det != nil { | ||||
| det = upd.det | det = upd.det | ||||
| @@ -742,6 +745,29 @@ func mustParseDuration(raw string, fallback time.Duration) time.Duration { | |||||
| return fallback | 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) { | func parseSince(raw string) (time.Time, error) { | ||||
| if raw == "" { | if raw == "" { | ||||
| return time.Time{}, nil | return time.Time{}, nil | ||||
| @@ -28,11 +28,21 @@ type RecorderConfig struct { | |||||
| RecordIQ bool `yaml:"record_iq" json:"record_iq"` | RecordIQ bool `yaml:"record_iq" json:"record_iq"` | ||||
| RecordAudio bool `yaml:"record_audio" json:"record_audio"` | RecordAudio bool `yaml:"record_audio" json:"record_audio"` | ||||
| AutoDemod bool `yaml:"auto_demod" json:"auto_demod"` | AutoDemod bool `yaml:"auto_demod" json:"auto_demod"` | ||||
| AutoDecode bool `yaml:"auto_decode" json:"auto_decode"` | |||||
| OutputDir string `yaml:"output_dir" json:"output_dir"` | OutputDir string `yaml:"output_dir" json:"output_dir"` | ||||
| ClassFilter []string `yaml:"class_filter" json:"class_filter"` | ClassFilter []string `yaml:"class_filter" json:"class_filter"` | ||||
| RingSeconds int `yaml:"ring_seconds" json:"ring_seconds"` | 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 { | type Config struct { | ||||
| Bands []Band `yaml:"bands" json:"bands"` | Bands []Band `yaml:"bands" json:"bands"` | ||||
| CenterHz float64 `yaml:"center_hz" json:"center_hz"` | CenterHz float64 `yaml:"center_hz" json:"center_hz"` | ||||
| @@ -46,6 +56,7 @@ type Config struct { | |||||
| IQBalance bool `yaml:"iq_balance" json:"iq_balance"` | IQBalance bool `yaml:"iq_balance" json:"iq_balance"` | ||||
| Detector DetectorConfig `yaml:"detector" json:"detector"` | Detector DetectorConfig `yaml:"detector" json:"detector"` | ||||
| Recorder RecorderConfig `yaml:"recorder" json:"recorder"` | Recorder RecorderConfig `yaml:"recorder" json:"recorder"` | ||||
| Decoder DecoderConfig `yaml:"decoder" json:"decoder"` | |||||
| WebAddr string `yaml:"web_addr" json:"web_addr"` | WebAddr string `yaml:"web_addr" json:"web_addr"` | ||||
| EventPath string `yaml:"event_path" json:"event_path"` | EventPath string `yaml:"event_path" json:"event_path"` | ||||
| FrameRate int `yaml:"frame_rate" json:"frame_rate"` | FrameRate int `yaml:"frame_rate" json:"frame_rate"` | ||||
| @@ -77,9 +88,11 @@ func Default() Config { | |||||
| RecordIQ: true, | RecordIQ: true, | ||||
| RecordAudio: false, | RecordAudio: false, | ||||
| AutoDemod: true, | AutoDemod: true, | ||||
| AutoDecode: false, | |||||
| OutputDir: "data/recordings", | OutputDir: "data/recordings", | ||||
| RingSeconds: 8, | RingSeconds: 8, | ||||
| }, | }, | ||||
| Decoder: DecoderConfig{}, | |||||
| WebAddr: ":8080", | WebAddr: ":8080", | ||||
| EventPath: "data/events.jsonl", | EventPath: "data/events.jsonl", | ||||
| FrameRate: 15, | 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"` | RecordIQ bool `yaml:"record_iq" json:"record_iq"` | ||||
| RecordAudio bool `yaml:"record_audio" json:"record_audio"` | RecordAudio bool `yaml:"record_audio" json:"record_audio"` | ||||
| AutoDemod bool `yaml:"auto_demod" json:"auto_demod"` | AutoDemod bool `yaml:"auto_demod" json:"auto_demod"` | ||||
| AutoDecode bool `yaml:"auto_decode" json:"auto_decode"` | |||||
| OutputDir string `yaml:"output_dir" json:"output_dir"` | OutputDir string `yaml:"output_dir" json:"output_dir"` | ||||
| ClassFilter []string `yaml:"class_filter" json:"class_filter"` | ClassFilter []string `yaml:"class_filter" json:"class_filter"` | ||||
| RingSeconds int `yaml:"ring_seconds" json:"ring_seconds"` | RingSeconds int `yaml:"ring_seconds" json:"ring_seconds"` | ||||
| } | } | ||||
| type Manager struct { | 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 == "" { | if policy.OutputDir == "" { | ||||
| policy.OutputDir = "data/recordings" | policy.OutputDir = "data/recordings" | ||||
| } | } | ||||
| if policy.RingSeconds <= 0 { | if policy.RingSeconds <= 0 { | ||||
| policy.RingSeconds = 8 | 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.policy = policy | ||||
| m.sampleRate = sampleRate | m.sampleRate = sampleRate | ||||
| m.blockSize = blockSize | m.blockSize = blockSize | ||||
| m.centerHz = centerHz | m.centerHz = centerHz | ||||
| m.decodeCommands = decodeCommands | |||||
| if m.ring == nil { | if m.ring == nil { | ||||
| m.ring = NewRing(sampleRate, blockSize, policy.RingSeconds) | m.ring = NewRing(sampleRate, blockSize, policy.RingSeconds) | ||||
| return | return | ||||
| @@ -117,9 +120,10 @@ func (m *Manager) recordEvent(ev detector.Event) error { | |||||
| return err | return err | ||||
| } | } | ||||
| files := map[string]any{} | files := map[string]any{} | ||||
| var iqPath string | |||||
| if m.policy.RecordIQ { | 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 | return err | ||||
| } | } | ||||
| files["iq"] = "signal.cf32" | files["iq"] = "signal.cf32" | ||||
| @@ -133,6 +137,9 @@ func (m *Manager) recordEvent(ev detector.Event) error { | |||||
| return err | 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) | 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) | |||||
| } | |||||