| @@ -66,7 +66,7 @@ Edit `config.yaml`: | |||||
| - `iq_balance`: enable basic IQ imbalance correction | - `iq_balance`: enable basic IQ imbalance correction | ||||
| - `detector.threshold_db`: power threshold in dB | - `detector.threshold_db`: power threshold in dB | ||||
| - `detector.min_duration_ms`, `detector.hold_ms`: debounce/merge | - `detector.min_duration_ms`, `detector.hold_ms`: debounce/merge | ||||
| - `recorder.*`: enable IQ/audio recording, preroll, output_dir | |||||
| - `recorder.*`: enable IQ/audio recording, preroll, output_dir, max_disk_mb | |||||
| - `decoder.*`: external decode commands (use `{iq}` and `{sr}` placeholders) | - `decoder.*`: external decode commands (use `{iq}` and `{sr}` placeholders) | ||||
| ## APIs | ## APIs | ||||
| @@ -678,6 +678,7 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||||
| RecordAudio: cfg.Recorder.RecordAudio, | RecordAudio: cfg.Recorder.RecordAudio, | ||||
| AutoDemod: cfg.Recorder.AutoDemod, | AutoDemod: cfg.Recorder.AutoDemod, | ||||
| AutoDecode: cfg.Recorder.AutoDecode, | AutoDecode: cfg.Recorder.AutoDecode, | ||||
| MaxDiskMB: cfg.Recorder.MaxDiskMB, | |||||
| OutputDir: cfg.Recorder.OutputDir, | OutputDir: cfg.Recorder.OutputDir, | ||||
| ClassFilter: cfg.Recorder.ClassFilter, | ClassFilter: cfg.Recorder.ClassFilter, | ||||
| RingSeconds: cfg.Recorder.RingSeconds, | RingSeconds: cfg.Recorder.RingSeconds, | ||||
| @@ -25,6 +25,7 @@ recorder: | |||||
| record_audio: true | record_audio: true | ||||
| auto_demod: true | auto_demod: true | ||||
| auto_decode: false | auto_decode: false | ||||
| max_disk_mb: 0 | |||||
| output_dir: "data/recordings" | output_dir: "data/recordings" | ||||
| class_filter: [] | class_filter: [] | ||||
| ring_seconds: 8 | ring_seconds: 8 | ||||
| @@ -29,6 +29,7 @@ type RecorderConfig struct { | |||||
| 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"` | AutoDecode bool `yaml:"auto_decode" json:"auto_decode"` | ||||
| MaxDiskMB int `yaml:"max_disk_mb" json:"max_disk_mb"` | |||||
| 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"` | ||||
| @@ -89,6 +90,7 @@ func Default() Config { | |||||
| RecordAudio: false, | RecordAudio: false, | ||||
| AutoDemod: true, | AutoDemod: true, | ||||
| AutoDecode: false, | AutoDecode: false, | ||||
| MaxDiskMB: 0, | |||||
| OutputDir: "data/recordings", | OutputDir: "data/recordings", | ||||
| RingSeconds: 8, | RingSeconds: 8, | ||||
| }, | }, | ||||
| @@ -0,0 +1,72 @@ | |||||
| package recorder | |||||
| import ( | |||||
| "os" | |||||
| "path/filepath" | |||||
| "sort" | |||||
| ) | |||||
| type recInfo struct { | |||||
| id string | |||||
| path string | |||||
| start int64 | |||||
| size int64 | |||||
| } | |||||
| func enforceQuota(root string, maxMB int) { | |||||
| if maxMB <= 0 { | |||||
| return | |||||
| } | |||||
| maxBytes := int64(maxMB) * 1024 * 1024 | |||||
| infos, total := scanRecordings(root) | |||||
| if total <= maxBytes { | |||||
| return | |||||
| } | |||||
| // oldest first | |||||
| sort.Slice(infos, func(i, j int) bool { return infos[i].start < infos[j].start }) | |||||
| for _, info := range infos { | |||||
| if total <= maxBytes { | |||||
| break | |||||
| } | |||||
| _ = os.RemoveAll(info.path) | |||||
| total -= info.size | |||||
| } | |||||
| } | |||||
| func scanRecordings(root string) ([]recInfo, int64) { | |||||
| entries, err := os.ReadDir(root) | |||||
| if err != nil { | |||||
| return nil, 0 | |||||
| } | |||||
| infos := make([]recInfo, 0, len(entries)) | |||||
| var total int64 | |||||
| for _, e := range entries { | |||||
| if !e.IsDir() { | |||||
| continue | |||||
| } | |||||
| id := e.Name() | |||||
| path := filepath.Join(root, id) | |||||
| size := dirSize(path) | |||||
| start := int64(0) | |||||
| if meta, err := ReadMeta(filepath.Join(path, "meta.json")); err == nil { | |||||
| start = meta.Start.UnixMilli() | |||||
| } | |||||
| infos = append(infos, recInfo{id: id, path: path, start: start, size: size}) | |||||
| total += size | |||||
| } | |||||
| return infos, total | |||||
| } | |||||
| func dirSize(path string) int64 { | |||||
| var size int64 | |||||
| _ = filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { | |||||
| if err != nil { | |||||
| return nil | |||||
| } | |||||
| if !info.IsDir() { | |||||
| size += info.Size() | |||||
| } | |||||
| return nil | |||||
| }) | |||||
| return size | |||||
| } | |||||
| @@ -21,6 +21,7 @@ type Policy struct { | |||||
| 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"` | AutoDecode bool `yaml:"auto_decode" json:"auto_decode"` | ||||
| MaxDiskMB int `yaml:"max_disk_mb" json:"max_disk_mb"` | |||||
| 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"` | ||||
| @@ -40,6 +40,7 @@ type RecorderUpdate struct { | |||||
| RecordAudio *bool `json:"record_audio"` | RecordAudio *bool `json:"record_audio"` | ||||
| AutoDemod *bool `json:"auto_demod"` | AutoDemod *bool `json:"auto_demod"` | ||||
| AutoDecode *bool `json:"auto_decode"` | AutoDecode *bool `json:"auto_decode"` | ||||
| MaxDiskMB *int `json:"max_disk_mb"` | |||||
| OutputDir *string `json:"output_dir"` | OutputDir *string `json:"output_dir"` | ||||
| ClassFilter *[]string `json:"class_filter"` | ClassFilter *[]string `json:"class_filter"` | ||||
| RingSeconds *int `json:"ring_seconds"` | RingSeconds *int `json:"ring_seconds"` | ||||
| @@ -149,6 +150,9 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||||
| if update.Recorder.AutoDecode != nil { | if update.Recorder.AutoDecode != nil { | ||||
| next.Recorder.AutoDecode = *update.Recorder.AutoDecode | next.Recorder.AutoDecode = *update.Recorder.AutoDecode | ||||
| } | } | ||||
| if update.Recorder.MaxDiskMB != nil { | |||||
| next.Recorder.MaxDiskMB = *update.Recorder.MaxDiskMB | |||||
| } | |||||
| if update.Recorder.OutputDir != nil { | if update.Recorder.OutputDir != nil { | ||||
| next.Recorder.OutputDir = *update.Recorder.OutputDir | next.Recorder.OutputDir = *update.Recorder.OutputDir | ||||
| } | } | ||||
| @@ -35,6 +35,13 @@ const iqToggle = qs('iqToggle'); | |||||
| const avgSelect = qs('avgSelect'); | const avgSelect = qs('avgSelect'); | ||||
| const maxHoldToggle = qs('maxHoldToggle'); | const maxHoldToggle = qs('maxHoldToggle'); | ||||
| const gpuToggle = qs('gpuToggle'); | const gpuToggle = qs('gpuToggle'); | ||||
| const recEnableToggle = qs('recEnableToggle'); | |||||
| const recIQToggle = qs('recIQToggle'); | |||||
| const recAudioToggle = qs('recAudioToggle'); | |||||
| const recDemodToggle = qs('recDemodToggle'); | |||||
| const recDecodeToggle = qs('recDecodeToggle'); | |||||
| const recMinSNR = qs('recMinSNR'); | |||||
| const recMaxDisk = qs('recMaxDisk'); | |||||
| const signalList = qs('signalList'); | const signalList = qs('signalList'); | ||||
| const eventList = qs('eventList'); | const eventList = qs('eventList'); | ||||
| @@ -281,6 +288,7 @@ function applyConfigToUI(cfg) { | |||||
| if (recDemodToggle) recDemodToggle.checked = !!cfg.recorder.auto_demod; | if (recDemodToggle) recDemodToggle.checked = !!cfg.recorder.auto_demod; | ||||
| if (recDecodeToggle) recDecodeToggle.checked = !!cfg.recorder.auto_decode; | if (recDecodeToggle) recDecodeToggle.checked = !!cfg.recorder.auto_decode; | ||||
| if (recMinSNR) recMinSNR.value = cfg.recorder.min_snr_db ?? 10; | if (recMinSNR) recMinSNR.value = cfg.recorder.min_snr_db ?? 10; | ||||
| if (recMaxDisk) recMaxDisk.value = cfg.recorder.max_disk_mb ?? 0; | |||||
| } | } | ||||
| spanInput.value = (cfg.sample_rate / zoom / 1e6).toFixed(3); | spanInput.value = (cfg.sample_rate / zoom / 1e6).toFixed(3); | ||||
| isSyncingConfig = false; | isSyncingConfig = false; | ||||
| @@ -1101,6 +1109,13 @@ agcToggle.addEventListener('change', () => queueSettingsUpdate({ agc: agcToggle. | |||||
| dcToggle.addEventListener('change', () => queueSettingsUpdate({ dc_block: dcToggle.checked })); | dcToggle.addEventListener('change', () => queueSettingsUpdate({ dc_block: dcToggle.checked })); | ||||
| iqToggle.addEventListener('change', () => queueSettingsUpdate({ iq_balance: iqToggle.checked })); | iqToggle.addEventListener('change', () => queueSettingsUpdate({ iq_balance: iqToggle.checked })); | ||||
| gpuToggle.addEventListener('change', () => queueConfigUpdate({ use_gpu_fft: gpuToggle.checked })); | gpuToggle.addEventListener('change', () => queueConfigUpdate({ use_gpu_fft: gpuToggle.checked })); | ||||
| if (recEnableToggle) recEnableToggle.addEventListener('change', () => queueConfigUpdate({ recorder: { enabled: recEnableToggle.checked } })); | |||||
| if (recIQToggle) recIQToggle.addEventListener('change', () => queueConfigUpdate({ recorder: { record_iq: recIQToggle.checked } })); | |||||
| if (recAudioToggle) recAudioToggle.addEventListener('change', () => queueConfigUpdate({ recorder: { record_audio: recAudioToggle.checked } })); | |||||
| if (recDemodToggle) recDemodToggle.addEventListener('change', () => queueConfigUpdate({ recorder: { auto_demod: recDemodToggle.checked } })); | |||||
| if (recDecodeToggle) recDecodeToggle.addEventListener('change', () => queueConfigUpdate({ recorder: { auto_decode: recDecodeToggle.checked } })); | |||||
| if (recMinSNR) recMinSNR.addEventListener('change', () => queueConfigUpdate({ recorder: { min_snr_db: parseFloat(recMinSNR.value) } })); | |||||
| if (recMaxDisk) recMaxDisk.addEventListener('change', () => queueConfigUpdate({ recorder: { max_disk_mb: parseInt(recMaxDisk.value || '0', 10) } })); | |||||
| avgSelect.addEventListener('change', () => { | avgSelect.addEventListener('change', () => { | ||||
| avgAlpha = parseFloat(avgSelect.value) || 0; | avgAlpha = parseFloat(avgSelect.value) || 0; | ||||
| @@ -151,6 +151,7 @@ | |||||
| <label class="pill-toggle"><input id="recDecodeToggle" type="checkbox" /><span class="pt"><span class="pk"></span></span><span class="pl">Auto Decode</span></label> | <label class="pill-toggle"><input id="recDecodeToggle" type="checkbox" /><span class="pt"><span class="pk"></span></span><span class="pl">Auto Decode</span></label> | ||||
| </div> | </div> | ||||
| <label class="field"><span>Min SNR (dB)</span><input id="recMinSNR" type="number" step="1" min="0" /></label> | <label class="field"><span>Min SNR (dB)</span><input id="recMinSNR" type="number" step="1" min="0" /></label> | ||||
| <label class="field"><span>Max Disk (MB)</span><input id="recMaxDisk" type="number" step="256" min="0" /></label> | |||||
| </div> | </div> | ||||
| <div class="form-group"> | <div class="form-group"> | ||||