clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / internal / signal / signal.go
signal.go 11.4K
1// Package signal keeps what the tuners measured on each multiplex (each RF2// channel): readings of signal strength and quality taken while a tuner3// was on it. Nothing here is estimated: a value is there because a tuner4// reported it, and is null otherwise.5//6// The HDHomeRun reports strength, signal-to-noise quality and symbol7// quality as percentages; whether data arrived, and the damage in it,8// Airwaves sees for itself.9package signal1011import (12 "cmp"13 "encoding/json"14 "errors"15 "fmt"16 "io/fs"17 "math"18 "slices"19 "sync"20 "time"2122 "airwaves/internal/store"23 "airwaves/internal/tuner"24)2526// Sources of readings.27const (28 // Active readings are taken while something had a tuner on the29 // multiplex: a viewer or a recording.30 Active = "active"31 // Sweep readings are taken by Measure now, on an idle tuner.32 Sweep = "sweep"33)3435// Reading is one sample of a tuner on a multiplex.36type Reading struct {37 At time.Time `json:"at"`38 Source string `json:"source"` // Active or Sweep39 // Lock is whether data arrived: the tuner locked to the signal.40 Lock bool `json:"lock"`41 // StrengthPct is signal strength, QualityPct the signal-to-noise42 // quality and SymbolPct the share of symbols received whole or43 // corrected over the last second, all as the tuner reports them, 044 // to 100.45 StrengthPct *float64 `json:"strengthPct"`46 QualityPct *float64 `json:"qualityPct"`47 SymbolPct *float64 `json:"symbolPct"`48 // ErrorsPerSec is the rate of transport and continuity errors in what49 // arrived since the reading before, on the same tuning: a picture50 // breaking up. Null for a first reading.51 ErrorsPerSec *float64 `json:"errorsPerSec"`52}5354// FromTuner reads a tuner as it is now: whether data arrives, and what55// the HDHomeRun measures of the signal.56func FromTuner(t tuner.State, at time.Time, source string) Reading {57 pct := func(v *int) *float64 {58 if v == nil {59 return nil60 }61 return ptr(float64(*v))62 }63 return Reading{64 At: at.UTC().Truncate(time.Second), Source: source, Lock: t.Locked,65 StrengthPct: pct(t.Device.StrengthPct), QualityPct: pct(t.Device.QualityPct), SymbolPct: pct(t.Device.SymbolPct),66 }67}6869// SetErrors sets the error rate from the tuner's state at an earlier70// reading of the same tuning, secs before.71func (r *Reading) SetErrors(prev, now tuner.State, secs float64) {72 r.ErrorsPerSec = nil73 same := prev.Number == now.Number && prev.FrequencyHz == now.FrequencyHz && prev.Since.Equal(now.Since)74 if secs <= 0 || !same || now.Errors < prev.Errors {75 return76 }77 r.ErrorsPerSec = ptr(round1(float64(now.Errors-prev.Errors) / secs))78}7980func ptr[T any](v T) *T { return &v }8182func round1(v float64) float64 { return math.Round(v*10) / 10 }8384// Stat is the spread of one quantity over readings.85type Stat struct {86 Min float64 `json:"min"`87 Max float64 `json:"max"`88 Sum float64 `json:"sum"`89 N int `json:"n"`90}9192func (s *Stat) add(v *float64) *Stat {93 if v == nil {94 return s95 }96 if s == nil {97 return &Stat{Min: *v, Max: *v, Sum: *v, N: 1}98 }99 s.Min, s.Max, s.Sum, s.N = min(s.Min, *v), max(s.Max, *v), s.Sum+*v, s.N+1100 return s101}102103func merge(a, b *Stat) *Stat {104 switch {105 case a == nil && b == nil:106 return nil107 case a == nil:108 c := *b109 return &c110 case b == nil:111 c := *a112 return &c113 }114 return &Stat{Min: min(a.Min, b.Min), Max: max(a.Max, b.Max), Sum: a.Sum + b.Sum, N: a.N + b.N}115}116117// Window gathers consecutive readings of one source: a stretch of viewing,118// or one sweep's readings.119type Window struct {120 From time.Time `json:"from"`121 To time.Time `json:"to"`122 Source string `json:"source"`123 Samples int `json:"samples"`124 Locked int `json:"locked"`125 StrengthPct *Stat `json:"strengthPct,omitempty"`126 QualityPct *Stat `json:"qualityPct,omitempty"`127 SymbolPct *Stat `json:"symbolPct,omitempty"`128}129130func (w *Window) add(r Reading) {131 w.To = r.At132 w.Samples++133 if r.Lock {134 w.Locked++135 }136 w.StrengthPct = w.StrengthPct.add(r.StrengthPct)137 w.QualityPct = w.QualityPct.add(r.QualityPct)138 w.SymbolPct = w.SymbolPct.add(r.SymbolPct)139}140141// Limits of the history kept per multiplex.142const (143 // MaxWindows is how many windows a multiplex keeps.144 MaxWindows = 24145 // windowSpan is the longest a window grows; windowGap the longest146 // pause between readings within one.147 windowSpan = 30 * time.Minute148 windowGap = 2 * time.Minute149)150151// Mux is what was measured on one multiplex.152type Mux struct {153 FrequencyHz int64 `json:"frequencyHz"`154 RF int `json:"rf"`155 Latest *Reading `json:"latest,omitempty"`156 Windows []Window `json:"windows,omitempty"`157}158159func (m *Mux) add(r Reading) {160 m.Latest = &r161 if n := len(m.Windows); n > 0 {162 w := &m.Windows[n-1]163 if w.Source == r.Source && r.At.Sub(w.To) <= windowGap && r.At.Sub(w.From) < windowSpan && !r.At.Before(w.To) {164 w.add(r)165 return166 }167 }168 w := Window{From: r.At, Source: r.Source}169 w.add(r)170 m.Windows = append(m.Windows, w)171 if len(m.Windows) > MaxWindows {172 m.Windows = slices.Delete(m.Windows, 0, len(m.Windows)-MaxWindows)173 }174}175176// Range is a quantity's spread for the app: lowest, mean and highest.177type Range struct {178 Min float64 `json:"min"`179 Avg float64 `json:"avg"`180 Max float64 `json:"max"`181}182183func (s *Stat) rng() *Range {184 if s == nil || s.N == 0 {185 return nil186 }187 return &Range{Min: s.Min, Avg: round1(s.Sum / float64(s.N)), Max: s.Max}188}189190// Period summarizes readings over a span: all the history a multiplex191// keeps, or one window of it.192type Period struct {193 From time.Time `json:"from"`194 To time.Time `json:"to"`195 Source string `json:"source,omitempty"` // set on a window196 Samples int `json:"samples"`197 // LockedPct is the share of readings with a lock.198 LockedPct float64 `json:"lockedPct"`199 StrengthPct *Range `json:"strengthPct"`200 QualityPct *Range `json:"qualityPct"`201 SymbolPct *Range `json:"symbolPct"`202}203204func period(w Window) Period {205 p := Period{From: w.From, To: w.To, Source: w.Source, Samples: w.Samples,206 StrengthPct: w.StrengthPct.rng(), QualityPct: w.QualityPct.rng(), SymbolPct: w.SymbolPct.rng()}207 if w.Samples > 0 {208 p.LockedPct = round1(float64(w.Locked) * 100 / float64(w.Samples))209 }210 return p211}212213// History is a multiplex's kept readings for the app: their summary and214// each window, oldest first.215type History struct {216 Period217 Windows []Period `json:"windows"`218}219220// Recent summarizes the latest window of readings (the last sweep of the221// multiplex, or the last stretch of watching it), steadier than any one222// reading; nil when there are none.223func (m *Mux) Recent() *Period {224 if len(m.Windows) == 0 {225 return nil226 }227 p := period(m.Windows[len(m.Windows)-1])228 return &p229}230231// History summarizes the multiplex's readings; nil when there are none.232func (m *Mux) History() *History {233 if len(m.Windows) == 0 {234 return nil235 }236 var all Window237 h := &History{Windows: make([]Period, 0, len(m.Windows))}238 for i, w := range m.Windows {239 if i == 0 {240 all.From = w.From241 }242 if w.To.After(all.To) {243 all.To = w.To244 }245 all.Samples += w.Samples246 all.Locked += w.Locked247 all.StrengthPct = merge(all.StrengthPct, w.StrengthPct)248 all.QualityPct = merge(all.QualityPct, w.QualityPct)249 all.SymbolPct = merge(all.SymbolPct, w.SymbolPct)250 h.Windows = append(h.Windows, period(w))251 }252 h.Period = period(all)253 return h254}255256// Store keeps measurements by multiplex frequency, and saves them to a257// file so they outlive restarts.258type Store struct {259 path string260261 mu sync.Mutex262 muxes map[int64]*Mux263 dirty bool264 saved time.Time265}266267// file is the saved form.268type file struct {269 Version int `json:"version"`270 Muxes []*Mux `json:"muxes"`271}272273// Open loads the measurements saved at path, if any. An empty path keeps274// them in memory only. A file that can't be read is set aside and its275// backup read instead (store.ReadFile); with none, the store starts empty276// and the error says so.277func Open(path string) (*Store, error) {278 s := &Store{path: path, muxes: map[int64]*Mux{}}279 if path == "" {280 return s, nil281 }282 var f file283 err := store.ReadFile(path, func(raw []byte) error {284 var x file285 if err := json.Unmarshal(raw, &x); err != nil {286 return err287 }288 f = x289 return nil290 })291 switch {292 case errors.Is(err, fs.ErrNotExist):293 return s, nil294 case errors.Is(err, store.ErrCorrupt):295 return s, fmt.Errorf("signal measurements: %w; starting empty", err)296 case err != nil:297 return nil, err298 }299 for _, m := range f.Muxes {300 if m != nil && m.FrequencyHz > 0 {301 s.muxes[m.FrequencyHz] = m302 }303 }304 return s, nil305}306307func (s *Store) mux(freq int64, rf int) *Mux {308 m := s.muxes[freq]309 if m == nil {310 m = &Mux{FrequencyHz: freq}311 s.muxes[freq] = m312 }313 if rf > 0 {314 m.RF = rf315 }316 return m317}318319// Record adds a reading of the multiplex at freq (Hz), RF channel rf.320func (s *Store) Record(freq int64, rf int, r Reading) {321 if freq <= 0 {322 return323 }324 s.mu.Lock()325 defer s.mu.Unlock()326 s.mux(freq, rf).add(r)327 s.dirty = true328}329330// Get returns a copy of what was measured at freq; ok is false when331// nothing was.332func (s *Store) Get(freq int64) (Mux, bool) {333 s.mu.Lock()334 defer s.mu.Unlock()335 m := s.muxes[freq]336 if m == nil {337 return Mux{}, false338 }339 return clone(m), true340}341342// All returns copies of every multiplex's measurements, by frequency.343func (s *Store) All() []Mux {344 s.mu.Lock()345 defer s.mu.Unlock()346 out := make([]Mux, 0, len(s.muxes))347 for _, m := range s.muxes {348 out = append(out, clone(m))349 }350 slices.SortFunc(out, func(a, b Mux) int { return cmp.Compare(a.FrequencyHz, b.FrequencyHz) })351 return out352}353354func clone(m *Mux) Mux {355 c := *m356 if m.Latest != nil {357 r := *m.Latest358 c.Latest = &r359 }360 c.Windows = make([]Window, len(m.Windows))361 for i, w := range m.Windows {362 c.Windows[i] = w363 for _, p := range []**Stat{&c.Windows[i].StrengthPct, &c.Windows[i].QualityPct, &c.Windows[i].SymbolPct} {364 if *p != nil {365 st := **p366 *p = &st367 }368 }369 }370 return c371}372373// Save writes the measurements when they changed since the last save.374func (s *Store) Save() error {375 s.mu.Lock()376 if !s.dirty || s.path == "" {377 s.mu.Unlock()378 return nil379 }380 f := file{Version: 1}381 for _, m := range s.muxes {382 c := clone(m)383 f.Muxes = append(f.Muxes, &c)384 }385 s.dirty = false386 s.saved = time.Now()387 s.mu.Unlock()388 slices.SortFunc(f.Muxes, func(a, b *Mux) int { return cmp.Compare(a.FrequencyHz, b.FrequencyHz) })389 raw, err := json.MarshalIndent(f, "", " ")390 if err == nil {391 err = store.WriteFile(s.path, raw, 0o644)392 }393 if err != nil {394 s.mu.Lock()395 s.dirty = true396 s.mu.Unlock()397 }398 return err399}400401// SaveEvery saves when there are changes and the last save was at least402// every ago, so frequent readings don't mean frequent writes.403func (s *Store) SaveEvery(every time.Duration) error {404 s.mu.Lock()405 due := s.dirty && time.Since(s.saved) >= every406 s.mu.Unlock()407 if !due {408 return nil409 }410 return s.Save()411}412413// Frequency is the center frequency (Hz) of US broadcast RF channel rf,414// 0 for one that isn't.415func Frequency(rf int) int64 {416 switch {417 case rf >= 2 && rf <= 4:418 return int64(57+6*(rf-2)) * 1e6419 case rf >= 5 && rf <= 6:420 return int64(79+6*(rf-5)) * 1e6421 case rf >= 7 && rf <= 13:422 return int64(177+6*(rf-7)) * 1e6423 case rf >= 14 && rf <= 36:424 return int64(473+6*(rf-14)) * 1e6425 }426 return 0427}428429// RF is the US broadcast RF channel centered on freq (Hz), 0 when freq430// isn't one.431func RF(freq int64) int {432 mhz := float64(freq) / 1e6433 switch {434 case mhz > 54 && mhz < 72:435 return 2 + int(math.Round((mhz-57)/6))436 case mhz > 76 && mhz < 88:437 return 5 + int(math.Round((mhz-79)/6))438 case mhz > 174 && mhz < 216:439 return 7 + int(math.Round((mhz-177)/6))440 case mhz > 470 && mhz < 698:441 return 14 + int(math.Round((mhz-473)/6))442 }443 return 0444}