| @@ -46,6 +46,7 @@ type CandidateEvidenceStateSummary struct { | |||
| type CandidateWindowSummary struct { | |||
| Index int `json:"index"` | |||
| Label string `json:"label,omitempty"` | |||
| Zone string `json:"zone,omitempty"` | |||
| Source string `json:"source,omitempty"` | |||
| StartHz float64 `json:"start_hz,omitempty"` | |||
| EndHz float64 `json:"end_hz,omitempty"` | |||
| @@ -53,6 +54,8 @@ type CandidateWindowSummary struct { | |||
| SpanHz float64 `json:"span_hz,omitempty"` | |||
| Priority float64 `json:"priority,omitempty"` | |||
| PriorityBias float64 `json:"priority_bias,omitempty"` | |||
| RecordBias float64 `json:"record_bias,omitempty"` | |||
| DecodeBias float64 `json:"decode_bias,omitempty"` | |||
| AutoRecord bool `json:"auto_record,omitempty"` | |||
| AutoDecode bool `json:"auto_decode,omitempty"` | |||
| Candidates int `json:"candidates"` | |||
| @@ -230,6 +233,7 @@ func buildCandidateWindowSummary(candidates []pipeline.Candidate, windows []pipe | |||
| entry := CandidateWindowSummary{ | |||
| Index: win.Index, | |||
| Label: win.Label, | |||
| Zone: win.Zone, | |||
| Source: win.Source, | |||
| StartHz: win.StartHz, | |||
| EndHz: win.EndHz, | |||
| @@ -237,6 +241,8 @@ func buildCandidateWindowSummary(candidates []pipeline.Candidate, windows []pipe | |||
| SpanHz: win.SpanHz, | |||
| Priority: win.Priority, | |||
| PriorityBias: win.PriorityBias, | |||
| RecordBias: win.RecordBias, | |||
| DecodeBias: win.DecodeBias, | |||
| AutoRecord: win.AutoRecord, | |||
| AutoDecode: win.AutoDecode, | |||
| } | |||
| @@ -17,6 +17,7 @@ type Band struct { | |||
| type MonitorWindow struct { | |||
| Label string `yaml:"label" json:"label"` | |||
| Zone string `yaml:"zone" json:"zone"` | |||
| StartHz float64 `yaml:"start_hz" json:"start_hz"` | |||
| EndHz float64 `yaml:"end_hz" json:"end_hz"` | |||
| CenterHz float64 `yaml:"center_hz" json:"center_hz"` | |||
| @@ -46,6 +46,7 @@ type queuedDecision struct { | |||
| Hint string | |||
| Class string | |||
| WindowTag string | |||
| WindowZone string | |||
| WindowBias float64 | |||
| FirstSeen time.Time | |||
| LastSeen time.Time | |||
| @@ -65,6 +66,7 @@ type queueSelection struct { | |||
| familyRanks map[int64]int | |||
| tierFloors map[int64]string | |||
| windowTags map[int64]string | |||
| windowZones map[int64]string | |||
| minScore float64 | |||
| maxScore float64 | |||
| cutoff float64 | |||
| @@ -109,8 +111,9 @@ func (dq *decisionQueues) Apply(decisions []SignalDecision, budget BudgetModel, | |||
| qd.SNRDb = decisions[i].Candidate.SNRDb | |||
| qd.Hint = decisions[i].Candidate.Hint | |||
| qd.Class = decisions[i].Class | |||
| qd.WindowTag = windowTagForDecision(decisions[i]) | |||
| qd.WindowBias = decisions[i].MonitorBias | |||
| qd.WindowTag = windowTagForDecision(decisions[i], "record") | |||
| qd.WindowZone = windowZoneForDecision(decisions[i], "record") | |||
| qd.WindowBias = decisions[i].MonitorBias + decisions[i].RecordBias | |||
| qd.LastSeen = now | |||
| recSeen[id] = true | |||
| } | |||
| @@ -123,8 +126,9 @@ func (dq *decisionQueues) Apply(decisions []SignalDecision, budget BudgetModel, | |||
| qd.SNRDb = decisions[i].Candidate.SNRDb | |||
| qd.Hint = decisions[i].Candidate.Hint | |||
| qd.Class = decisions[i].Class | |||
| qd.WindowTag = windowTagForDecision(decisions[i]) | |||
| qd.WindowBias = decisions[i].MonitorBias | |||
| qd.WindowTag = windowTagForDecision(decisions[i], "decode") | |||
| qd.WindowZone = windowZoneForDecision(decisions[i], "decode") | |||
| qd.WindowBias = decisions[i].MonitorBias + decisions[i].DecodeBias | |||
| qd.LastSeen = now | |||
| decSeen[id] = true | |||
| } | |||
| @@ -238,6 +242,7 @@ func selectQueued(queueName string, queue map[int64]*queuedDecision, hold map[in | |||
| familyRanks: map[int64]int{}, | |||
| tierFloors: map[int64]string{}, | |||
| windowTags: map[int64]string{}, | |||
| windowZones: map[int64]string{}, | |||
| } | |||
| if len(queue) == 0 { | |||
| return selection | |||
| @@ -268,6 +273,9 @@ func selectQueued(queueName string, queue map[int64]*queuedDecision, hold map[in | |||
| if qd.WindowTag != "" { | |||
| selection.windowTags[id] = qd.WindowTag | |||
| } | |||
| if qd.WindowZone != "" { | |||
| selection.windowZones[id] = qd.WindowZone | |||
| } | |||
| score := qd.SNRDb + boost + policyBoost + qd.WindowBias | |||
| selection.scores[id] = score | |||
| if len(scoredList) == 0 || score < selection.minScore { | |||
| @@ -412,12 +420,14 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p | |||
| return nil | |||
| } | |||
| windowTag := selection.windowTags[id] | |||
| windowZone := selection.windowZones[id] | |||
| admission := &PriorityAdmission{ | |||
| Basis: queueName, | |||
| Score: score, | |||
| Cutoff: selection.cutoff, | |||
| Tier: selection.tiers[id], | |||
| } | |||
| zoneTag := windowZoneTag(windowZone) | |||
| admission.TierFloor = selection.tierFloors[id] | |||
| admission.Family = selection.families[id] | |||
| admission.FamilyRank = familyRankForOutput(selection.familyRanks[id]) | |||
| @@ -428,6 +438,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p | |||
| if windowTag != "" { | |||
| extras = append(extras, windowTag) | |||
| } | |||
| if zoneTag != "" { | |||
| extras = append(extras, zoneTag) | |||
| } | |||
| if _, ok := selection.protected[id]; ok { | |||
| extras = append(extras, ReasonTagHoldProtected) | |||
| } | |||
| @@ -438,6 +451,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p | |||
| if windowTag != "" { | |||
| extras = append(extras, windowTag) | |||
| } | |||
| if zoneTag != "" { | |||
| extras = append(extras, zoneTag) | |||
| } | |||
| if _, ok := selection.opportunistic[id]; ok { | |||
| extras = append(extras, "pressure:hold", ReasonTagDisplaceOpportunist, ReasonTagDisplaceTier, ReasonTagHoldDisplaced) | |||
| } | |||
| @@ -451,6 +467,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p | |||
| if windowTag != "" { | |||
| extras = append(extras, windowTag) | |||
| } | |||
| if zoneTag != "" { | |||
| extras = append(extras, zoneTag) | |||
| } | |||
| admission.Reason = admissionReason("queue:"+queueName+":displace", policy, holdPolicy, extras...) | |||
| return admission | |||
| } | |||
| @@ -460,6 +479,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p | |||
| if windowTag != "" { | |||
| extras = append(extras, windowTag) | |||
| } | |||
| if zoneTag != "" { | |||
| extras = append(extras, zoneTag) | |||
| } | |||
| admission.Reason = admissionReason("queue:"+queueName+":displace", policy, holdPolicy, extras...) | |||
| return admission | |||
| } | |||
| @@ -468,6 +490,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p | |||
| if windowTag != "" { | |||
| extras = append(extras, windowTag) | |||
| } | |||
| if zoneTag != "" { | |||
| extras = append(extras, zoneTag) | |||
| } | |||
| if _, ok := selection.expired[id]; ok { | |||
| extras = append(extras, ReasonTagHoldExpired) | |||
| } | |||
| @@ -475,13 +500,14 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p | |||
| return admission | |||
| } | |||
| func windowTagForDecision(decision SignalDecision) string { | |||
| if decision.MonitorDetail == nil { | |||
| func windowTagForDecision(decision SignalDecision, action string) string { | |||
| match := windowMatchForDecision(decision, action) | |||
| if match == nil { | |||
| return "" | |||
| } | |||
| label := strings.TrimSpace(decision.MonitorDetail.Label) | |||
| label := strings.TrimSpace(match.Label) | |||
| if label == "" { | |||
| label = "index-" + strconv.Itoa(decision.MonitorDetail.Index) | |||
| label = "index-" + strconv.Itoa(match.Index) | |||
| } | |||
| label = slugToken(label) | |||
| if label == "" { | |||
| @@ -490,6 +516,36 @@ func windowTagForDecision(decision SignalDecision) string { | |||
| return "window:" + label | |||
| } | |||
| func windowZoneForDecision(decision SignalDecision, action string) string { | |||
| match := windowMatchForDecision(decision, action) | |||
| if match == nil { | |||
| return "" | |||
| } | |||
| return match.Zone | |||
| } | |||
| func windowMatchForDecision(decision SignalDecision, action string) *MonitorWindowMatch { | |||
| switch strings.ToLower(strings.TrimSpace(action)) { | |||
| case "record": | |||
| if decision.RecordWindow != nil { | |||
| return decision.RecordWindow | |||
| } | |||
| case "decode": | |||
| if decision.DecodeWindow != nil { | |||
| return decision.DecodeWindow | |||
| } | |||
| } | |||
| return decision.MonitorDetail | |||
| } | |||
| func windowZoneTag(zone string) string { | |||
| zone = slugToken(zone) | |||
| if zone == "" { | |||
| return "" | |||
| } | |||
| return "window-zone:" + zone | |||
| } | |||
| func oldestAge(queue map[int64]*queuedDecision, now time.Time) float64 { | |||
| oldest := 0.0 | |||
| first := true | |||
| @@ -101,6 +101,46 @@ func TestDecisionQueueMonitorWindowBiasSelectsPreferred(t *testing.T) { | |||
| } | |||
| } | |||
| func TestDecisionQueueWindowZoneBiasSelectsPerAction(t *testing.T) { | |||
| arbiter := NewArbiter() | |||
| policy := Policy{ | |||
| DecisionHoldMs: 250, | |||
| AutoRecordClasses: []string{"test"}, | |||
| AutoDecodeClasses: []string{"test"}, | |||
| MonitorWindows: finalizeMonitorWindows([]MonitorWindow{ | |||
| {Label: "record-zone", StartHz: 100, EndHz: 200, SpanHz: 100, Zone: "record", AutoRecord: true, AutoDecode: true}, | |||
| {Label: "decode-zone", StartHz: 300, EndHz: 400, SpanHz: 100, Zone: "decode", AutoRecord: true, AutoDecode: true}, | |||
| }), | |||
| } | |||
| budget := BudgetModel{Record: BudgetQueue{Max: 1}, Decode: BudgetQueue{Max: 1}} | |||
| now := time.Now() | |||
| decisions := []SignalDecision{ | |||
| DecideSignalAction(policy, Candidate{ID: 1, CenterHz: 150, SNRDb: 10, Hint: "test"}, nil), | |||
| DecideSignalAction(policy, Candidate{ID: 2, CenterHz: 350, SNRDb: 10, Hint: "test"}, nil), | |||
| } | |||
| arbiter.ApplyDecisions(decisions, budget, now, policy) | |||
| if !decisions[0].ShouldRecord { | |||
| t.Fatalf("expected record-zone candidate to be selected for record") | |||
| } | |||
| if decisions[1].ShouldRecord { | |||
| t.Fatalf("expected decode-zone candidate to be deferred for record") | |||
| } | |||
| if !decisions[1].ShouldAutoDecode { | |||
| t.Fatalf("expected decode-zone candidate to be selected for decode") | |||
| } | |||
| if decisions[0].ShouldAutoDecode { | |||
| t.Fatalf("expected record-zone candidate to be deferred for decode") | |||
| } | |||
| if decisions[0].RecordAdmission == nil || !strings.Contains(decisions[0].RecordAdmission.Reason, "window-zone:record") { | |||
| t.Fatalf("expected record admission to include window-zone tag, got %+v", decisions[0].RecordAdmission) | |||
| } | |||
| if decisions[1].DecodeAdmission == nil || !strings.Contains(decisions[1].DecodeAdmission.Reason, "window-zone:decode") { | |||
| t.Fatalf("expected decode admission to include window-zone tag, got %+v", decisions[1].DecodeAdmission) | |||
| } | |||
| } | |||
| func TestDecisionQueueHoldKeepsSelection(t *testing.T) { | |||
| arbiter := NewArbiter() | |||
| policy := Policy{DecisionHoldMs: 500} | |||
| @@ -13,6 +13,8 @@ type SignalDecision struct { | |||
| ShouldAutoDecode bool `json:"should_auto_decode"` | |||
| Reason string `json:"reason,omitempty"` | |||
| MonitorBias float64 `json:"monitor_bias,omitempty"` | |||
| RecordBias float64 `json:"record_bias,omitempty"` | |||
| DecodeBias float64 `json:"decode_bias,omitempty"` | |||
| MonitorDetail *MonitorWindowMatch `json:"monitor_detail,omitempty"` | |||
| RecordWindow *MonitorWindowMatch `json:"record_window,omitempty"` | |||
| DecodeWindow *MonitorWindowMatch `json:"decode_window,omitempty"` | |||
| @@ -50,19 +52,29 @@ func DecideSignalAction(policy Policy, candidate Candidate, cls *classifier.Clas | |||
| } | |||
| } | |||
| recordMatch := bestMonitorActionMatch(candidate.MonitorMatches, true, false) | |||
| if !decision.ShouldRecord && recordMatch != nil { | |||
| decision.ShouldRecord = true | |||
| decision.RecordWindow = recordMatch | |||
| if decision.Reason == "" { | |||
| decision.Reason = DecisionReasonRecordWindow | |||
| if recordMatch != nil { | |||
| decision.RecordBias = recordMatch.RecordBias | |||
| if decision.RecordWindow == nil { | |||
| decision.RecordWindow = recordMatch | |||
| } | |||
| if !decision.ShouldRecord { | |||
| decision.ShouldRecord = true | |||
| if decision.Reason == "" { | |||
| decision.Reason = DecisionReasonRecordWindow | |||
| } | |||
| } | |||
| } | |||
| decodeMatch := bestMonitorActionMatch(candidate.MonitorMatches, false, true) | |||
| if !decision.ShouldAutoDecode && decodeMatch != nil { | |||
| decision.ShouldAutoDecode = true | |||
| decision.DecodeWindow = decodeMatch | |||
| if decision.Reason == "" { | |||
| decision.Reason = DecisionReasonDecodeWindow | |||
| if decodeMatch != nil { | |||
| decision.DecodeBias = decodeMatch.DecodeBias | |||
| if decision.DecodeWindow == nil { | |||
| decision.DecodeWindow = decodeMatch | |||
| } | |||
| if !decision.ShouldAutoDecode { | |||
| decision.ShouldAutoDecode = true | |||
| if decision.Reason == "" { | |||
| decision.Reason = DecisionReasonDecodeWindow | |||
| } | |||
| } | |||
| } | |||
| if decision.Reason == "" && candidate.Hint != "" { | |||
| @@ -2,11 +2,13 @@ package pipeline | |||
| import ( | |||
| "math" | |||
| "strings" | |||
| "sdr-wideband-suite/internal/config" | |||
| ) | |||
| const maxMonitorWindowBias = 0.2 | |||
| const maxMonitorWindowZoneBias = 0.15 | |||
| func NormalizeMonitorWindows(goals config.PipelineGoalConfig, centerHz float64) []MonitorWindow { | |||
| if len(goals.MonitorWindows) > 0 { | |||
| @@ -61,6 +63,7 @@ func finalizeMonitorWindows(windows []MonitorWindow) []MonitorWindow { | |||
| } | |||
| for i := range windows { | |||
| windows[i].Index = i | |||
| windows[i].Zone = normalizeMonitorZone(windows[i].Zone) | |||
| priority := normalizeMonitorPriority(windows[i].Priority) | |||
| windows[i].Priority = priority | |||
| spanBias := 0.0 | |||
| @@ -78,6 +81,9 @@ func finalizeMonitorWindows(windows []MonitorWindow) []MonitorWindow { | |||
| totalBias = -maxMonitorWindowBias | |||
| } | |||
| windows[i].PriorityBias = totalBias | |||
| recordBias, decodeBias := monitorWindowZoneBias(windows[i].Zone) | |||
| windows[i].RecordBias = recordBias | |||
| windows[i].DecodeBias = decodeBias | |||
| } | |||
| return windows | |||
| } | |||
| @@ -102,10 +108,12 @@ func MonitorWindowBounds(windows []MonitorWindow) (float64, float64, bool) { | |||
| } | |||
| func normalizeGoalWindow(raw config.MonitorWindow, fallbackCenter float64) (MonitorWindow, bool) { | |||
| zone := normalizeMonitorZone(raw.Zone) | |||
| if raw.StartHz > 0 && raw.EndHz > raw.StartHz { | |||
| span := raw.EndHz - raw.StartHz | |||
| return MonitorWindow{ | |||
| Label: raw.Label, | |||
| Zone: zone, | |||
| StartHz: raw.StartHz, | |||
| EndHz: raw.EndHz, | |||
| CenterHz: (raw.StartHz + raw.EndHz) / 2, | |||
| @@ -128,6 +136,7 @@ func normalizeGoalWindow(raw config.MonitorWindow, fallbackCenter float64) (Moni | |||
| } | |||
| return MonitorWindow{ | |||
| Label: raw.Label, | |||
| Zone: zone, | |||
| StartHz: center - half, | |||
| EndHz: center + half, | |||
| CenterHz: center, | |||
| @@ -154,6 +163,39 @@ func normalizeMonitorPriority(priority float64) float64 { | |||
| return priority | |||
| } | |||
| func normalizeMonitorZone(raw string) string { | |||
| zone := strings.ToLower(strings.TrimSpace(raw)) | |||
| switch zone { | |||
| case "", "neutral", "monitor", "default": | |||
| return "" | |||
| case "focus", "priority", "hot": | |||
| return "focus" | |||
| case "record", "recording", "record-only": | |||
| return "record" | |||
| case "decode", "decoding", "decode-only": | |||
| return "decode" | |||
| case "background", "bg", "defer": | |||
| return "background" | |||
| default: | |||
| return "" | |||
| } | |||
| } | |||
| func monitorWindowZoneBias(zone string) (float64, float64) { | |||
| switch normalizeMonitorZone(zone) { | |||
| case "focus": | |||
| return maxMonitorWindowZoneBias, maxMonitorWindowZoneBias | |||
| case "record": | |||
| return maxMonitorWindowZoneBias, 0 | |||
| case "decode": | |||
| return 0, maxMonitorWindowZoneBias | |||
| case "background": | |||
| return -maxMonitorWindowZoneBias, -maxMonitorWindowZoneBias | |||
| default: | |||
| return 0, 0 | |||
| } | |||
| } | |||
| func monitorBounds(policy Policy) (float64, float64, bool) { | |||
| if len(policy.MonitorWindows) > 0 { | |||
| return MonitorWindowBounds(policy.MonitorWindows) | |||
| @@ -263,9 +305,12 @@ func MonitorWindowMatchesForCandidate(windows []MonitorWindow, candidate Candida | |||
| } | |||
| distance := math.Abs(candidate.CenterHz - center) | |||
| bias := win.PriorityBias * coverage | |||
| recordBias := win.RecordBias * coverage | |||
| decodeBias := win.DecodeBias * coverage | |||
| matches = append(matches, MonitorWindowMatch{ | |||
| Index: win.Index, | |||
| Label: win.Label, | |||
| Zone: win.Zone, | |||
| Source: win.Source, | |||
| StartHz: win.StartHz, | |||
| EndHz: win.EndHz, | |||
| @@ -275,6 +320,8 @@ func MonitorWindowMatchesForCandidate(windows []MonitorWindow, candidate Candida | |||
| Coverage: coverage, | |||
| DistanceHz: distance, | |||
| Bias: bias, | |||
| RecordBias: recordBias, | |||
| DecodeBias: decodeBias, | |||
| AutoRecord: win.AutoRecord, | |||
| AutoDecode: win.AutoDecode, | |||
| }) | |||
| @@ -119,3 +119,42 @@ func TestMonitorWindowPriorityBiasUsesPriority(t *testing.T) { | |||
| t.Fatalf("expected high priority bias > low priority bias, got %.3f vs %.3f", high.PriorityBias, low.PriorityBias) | |||
| } | |||
| } | |||
| func TestMonitorWindowZoneBiases(t *testing.T) { | |||
| goals := config.PipelineGoalConfig{ | |||
| MonitorWindows: []config.MonitorWindow{ | |||
| {Label: "record", StartHz: 100, EndHz: 200, Zone: "record"}, | |||
| {Label: "decode", StartHz: 300, EndHz: 400, Zone: "decode"}, | |||
| }, | |||
| } | |||
| policy := Policy{MonitorWindows: NormalizeMonitorWindows(goals, 0)} | |||
| if len(policy.MonitorWindows) != 2 { | |||
| t.Fatalf("expected 2 windows, got %d", len(policy.MonitorWindows)) | |||
| } | |||
| var recordWin, decodeWin *MonitorWindow | |||
| for i := range policy.MonitorWindows { | |||
| win := &policy.MonitorWindows[i] | |||
| switch win.Label { | |||
| case "record": | |||
| recordWin = win | |||
| case "decode": | |||
| decodeWin = win | |||
| } | |||
| } | |||
| if recordWin == nil || decodeWin == nil { | |||
| t.Fatalf("expected both window entries") | |||
| } | |||
| if recordWin.RecordBias <= 0 || recordWin.DecodeBias != 0 { | |||
| t.Fatalf("unexpected record window biases: %+v", recordWin) | |||
| } | |||
| if decodeWin.DecodeBias <= 0 || decodeWin.RecordBias != 0 { | |||
| t.Fatalf("unexpected decode window biases: %+v", decodeWin) | |||
| } | |||
| matches := MonitorWindowMatches(policy, Candidate{CenterHz: 150, BandwidthHz: 0}) | |||
| if len(matches) != 1 { | |||
| t.Fatalf("expected 1 match, got %d", len(matches)) | |||
| } | |||
| if matches[0].RecordBias <= 0 || matches[0].DecodeBias != 0 { | |||
| t.Fatalf("unexpected match biases: %+v", matches[0]) | |||
| } | |||
| } | |||
| @@ -415,6 +415,7 @@ func buildMonitorWindowStats(windows []MonitorWindow) []MonitorWindowStats { | |||
| stats = append(stats, MonitorWindowStats{ | |||
| Index: win.Index, | |||
| Label: win.Label, | |||
| Zone: win.Zone, | |||
| Source: win.Source, | |||
| StartHz: win.StartHz, | |||
| EndHz: win.EndHz, | |||
| @@ -422,6 +423,8 @@ func buildMonitorWindowStats(windows []MonitorWindow) []MonitorWindowStats { | |||
| SpanHz: win.SpanHz, | |||
| Priority: win.Priority, | |||
| PriorityBias: win.PriorityBias, | |||
| RecordBias: win.RecordBias, | |||
| DecodeBias: win.DecodeBias, | |||
| AutoRecord: win.AutoRecord, | |||
| AutoDecode: win.AutoDecode, | |||
| }) | |||
| @@ -34,6 +34,7 @@ type LevelEvidence struct { | |||
| type MonitorWindow struct { | |||
| Index int `json:"index,omitempty"` | |||
| Label string `json:"label,omitempty"` | |||
| Zone string `json:"zone,omitempty"` | |||
| StartHz float64 `json:"start_hz,omitempty"` | |||
| EndHz float64 `json:"end_hz,omitempty"` | |||
| CenterHz float64 `json:"center_hz,omitempty"` | |||
| @@ -41,6 +42,8 @@ type MonitorWindow struct { | |||
| Source string `json:"source,omitempty"` | |||
| Priority float64 `json:"priority,omitempty"` | |||
| PriorityBias float64 `json:"priority_bias,omitempty"` | |||
| RecordBias float64 `json:"record_bias,omitempty"` | |||
| DecodeBias float64 `json:"decode_bias,omitempty"` | |||
| AutoRecord bool `json:"auto_record,omitempty"` | |||
| AutoDecode bool `json:"auto_decode,omitempty"` | |||
| } | |||
| @@ -49,6 +52,7 @@ type MonitorWindow struct { | |||
| type MonitorWindowMatch struct { | |||
| Index int `json:"index"` | |||
| Label string `json:"label,omitempty"` | |||
| Zone string `json:"zone,omitempty"` | |||
| Source string `json:"source,omitempty"` | |||
| StartHz float64 `json:"start_hz,omitempty"` | |||
| EndHz float64 `json:"end_hz,omitempty"` | |||
| @@ -58,6 +62,8 @@ type MonitorWindowMatch struct { | |||
| Coverage float64 `json:"coverage,omitempty"` | |||
| DistanceHz float64 `json:"distance_hz,omitempty"` | |||
| Bias float64 `json:"bias,omitempty"` | |||
| RecordBias float64 `json:"record_bias,omitempty"` | |||
| DecodeBias float64 `json:"decode_bias,omitempty"` | |||
| AutoRecord bool `json:"auto_record,omitempty"` | |||
| AutoDecode bool `json:"auto_decode,omitempty"` | |||
| } | |||
| @@ -66,6 +72,7 @@ type MonitorWindowMatch struct { | |||
| type MonitorWindowStats struct { | |||
| Index int `json:"index"` | |||
| Label string `json:"label,omitempty"` | |||
| Zone string `json:"zone,omitempty"` | |||
| Source string `json:"source,omitempty"` | |||
| StartHz float64 `json:"start_hz,omitempty"` | |||
| EndHz float64 `json:"end_hz,omitempty"` | |||
| @@ -73,6 +80,8 @@ type MonitorWindowStats struct { | |||
| SpanHz float64 `json:"span_hz,omitempty"` | |||
| Priority float64 `json:"priority,omitempty"` | |||
| PriorityBias float64 `json:"priority_bias,omitempty"` | |||
| RecordBias float64 `json:"record_bias,omitempty"` | |||
| DecodeBias float64 `json:"decode_bias,omitempty"` | |||
| AutoRecord bool `json:"auto_record,omitempty"` | |||
| AutoDecode bool `json:"auto_decode,omitempty"` | |||
| Candidates int `json:"candidates,omitempty"` | |||
| @@ -508,6 +508,9 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||
| func validateMonitorWindows(windows []config.MonitorWindow) error { | |||
| for i, w := range windows { | |||
| if !isValidMonitorZone(w.Zone) { | |||
| return fmt.Errorf("monitor_windows[%d] zone is invalid", i) | |||
| } | |||
| if math.IsNaN(w.Priority) || math.IsInf(w.Priority, 0) || w.Priority < -1 || w.Priority > 1 { | |||
| return fmt.Errorf("monitor_windows[%d] priority must be between -1 and 1", i) | |||
| } | |||
| @@ -528,6 +531,22 @@ func validateMonitorWindows(windows []config.MonitorWindow) error { | |||
| return nil | |||
| } | |||
| func isValidMonitorZone(zone string) bool { | |||
| zone = strings.ToLower(strings.TrimSpace(zone)) | |||
| if zone == "" { | |||
| return true | |||
| } | |||
| switch zone { | |||
| case "neutral", "monitor", "default", "focus", "priority", "hot", | |||
| "record", "recording", "record-only", | |||
| "decode", "decoding", "decode-only", | |||
| "background", "bg", "defer": | |||
| return true | |||
| default: | |||
| return false | |||
| } | |||
| } | |||
| func (m *Manager) ApplySettings(update SettingsUpdate) (config.Config, error) { | |||
| m.mu.Lock() | |||
| defer m.mu.Unlock() | |||