diff --git a/README.md b/README.md index b50aa6f..76c93e4 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Edit `config.yaml`: - `iq_balance`: enable basic IQ imbalance correction - `detector.threshold_db`: power threshold in dB - `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) ## APIs diff --git a/cmd/sdrd/main.go b/cmd/sdrd/main.go index 95309da..be82f64 100644 --- a/cmd/sdrd/main.go +++ b/cmd/sdrd/main.go @@ -678,6 +678,7 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * RecordAudio: cfg.Recorder.RecordAudio, AutoDemod: cfg.Recorder.AutoDemod, AutoDecode: cfg.Recorder.AutoDecode, + MaxDiskMB: cfg.Recorder.MaxDiskMB, OutputDir: cfg.Recorder.OutputDir, ClassFilter: cfg.Recorder.ClassFilter, RingSeconds: cfg.Recorder.RingSeconds, diff --git a/config.yaml b/config.yaml index 8b27392..69d1757 100644 --- a/config.yaml +++ b/config.yaml @@ -25,6 +25,7 @@ recorder: record_audio: true auto_demod: true auto_decode: false + max_disk_mb: 0 output_dir: "data/recordings" class_filter: [] ring_seconds: 8 diff --git a/internal/config/config.go b/internal/config/config.go index d38f4e6..6581cfb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,7 @@ type RecorderConfig struct { RecordAudio bool `yaml:"record_audio" json:"record_audio"` AutoDemod bool `yaml:"auto_demod" json:"auto_demod"` 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"` ClassFilter []string `yaml:"class_filter" json:"class_filter"` RingSeconds int `yaml:"ring_seconds" json:"ring_seconds"` @@ -89,6 +90,7 @@ func Default() Config { RecordAudio: false, AutoDemod: true, AutoDecode: false, + MaxDiskMB: 0, OutputDir: "data/recordings", RingSeconds: 8, }, diff --git a/internal/recorder/quota.go b/internal/recorder/quota.go new file mode 100644 index 0000000..9da6743 --- /dev/null +++ b/internal/recorder/quota.go @@ -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 +} diff --git a/internal/recorder/recorder.go b/internal/recorder/recorder.go index 0a51064..613f313 100644 --- a/internal/recorder/recorder.go +++ b/internal/recorder/recorder.go @@ -21,6 +21,7 @@ type Policy struct { RecordAudio bool `yaml:"record_audio" json:"record_audio"` AutoDemod bool `yaml:"auto_demod" json:"auto_demod"` 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"` ClassFilter []string `yaml:"class_filter" json:"class_filter"` RingSeconds int `yaml:"ring_seconds" json:"ring_seconds"` diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 05050ee..0b72d77 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -40,6 +40,7 @@ type RecorderUpdate struct { RecordAudio *bool `json:"record_audio"` AutoDemod *bool `json:"auto_demod"` AutoDecode *bool `json:"auto_decode"` + MaxDiskMB *int `json:"max_disk_mb"` OutputDir *string `json:"output_dir"` ClassFilter *[]string `json:"class_filter"` RingSeconds *int `json:"ring_seconds"` @@ -149,6 +150,9 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { if update.Recorder.AutoDecode != nil { next.Recorder.AutoDecode = *update.Recorder.AutoDecode } + if update.Recorder.MaxDiskMB != nil { + next.Recorder.MaxDiskMB = *update.Recorder.MaxDiskMB + } if update.Recorder.OutputDir != nil { next.Recorder.OutputDir = *update.Recorder.OutputDir } diff --git a/web/app.js b/web/app.js index b90cd99..6f6561b 100644 --- a/web/app.js +++ b/web/app.js @@ -35,6 +35,13 @@ const iqToggle = qs('iqToggle'); const avgSelect = qs('avgSelect'); const maxHoldToggle = qs('maxHoldToggle'); 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 eventList = qs('eventList'); @@ -281,6 +288,7 @@ function applyConfigToUI(cfg) { if (recDemodToggle) recDemodToggle.checked = !!cfg.recorder.auto_demod; if (recDecodeToggle) recDecodeToggle.checked = !!cfg.recorder.auto_decode; 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); isSyncingConfig = false; @@ -1101,6 +1109,13 @@ agcToggle.addEventListener('change', () => queueSettingsUpdate({ agc: agcToggle. dcToggle.addEventListener('change', () => queueSettingsUpdate({ dc_block: dcToggle.checked })); iqToggle.addEventListener('change', () => queueSettingsUpdate({ iq_balance: iqToggle.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', () => { avgAlpha = parseFloat(avgSelect.value) || 0; diff --git a/web/index.html b/web/index.html index b83ee84..773ce5d 100644 --- a/web/index.html +++ b/web/index.html @@ -151,6 +151,7 @@ +