Просмотр исходного кода

Merge branch 'live-config'

tags/v0.9.0
Jan Svabenik 1 месяц назад
Родитель
Сommit
18f0e974c6
12 измененных файлов: 1557 добавлений и 63 удалений
  1. +14
    -0
      cmd/fmrtx/main.go
  2. +220
    -0
      docs/API.md
  3. +25
    -18
      docs/README.md
  4. +93
    -0
      internal/app/engine.go
  5. +118
    -0
      internal/app/engine_test.go
  6. +74
    -36
      internal/control/control.go
  7. +875
    -0
      internal/control/ui.html
  8. +65
    -8
      internal/offline/generator.go
  9. +24
    -0
      internal/platform/plutosdr/pluto_windows.go
  10. +9
    -0
      internal/platform/soapy.go
  11. +9
    -0
      internal/platform/soapysdr/native.go
  12. +31
    -1
      internal/rds/encoder.go

+ 14
- 0
cmd/fmrtx/main.go Просмотреть файл

@@ -216,3 +216,17 @@ func (b *txBridge) TXStats() map[string]any {
"lastError": s.LastError, "uptimeSeconds": s.UptimeSeconds,
}
}
func (b *txBridge) UpdateConfig(lp ctrlpkg.LivePatch) error {
return b.engine.UpdateConfig(apppkg.LiveConfigUpdate{
FrequencyMHz: lp.FrequencyMHz,
OutputDrive: lp.OutputDrive,
StereoEnabled: lp.StereoEnabled,
PilotLevel: lp.PilotLevel,
RDSInjection: lp.RDSInjection,
RDSEnabled: lp.RDSEnabled,
LimiterEnabled: lp.LimiterEnabled,
LimiterCeiling: lp.LimiterCeiling,
PS: lp.PS,
RadioText: lp.RadioText,
})
}

+ 220
- 0
docs/API.md Просмотреть файл

@@ -0,0 +1,220 @@
# fm-rds-tx HTTP Control API

Base URL: `http://{listenAddress}` (default `127.0.0.1:8088`)

---

## Endpoints

### `GET /healthz`

Health check.

**Response:**
```json
{"ok": true}
```

---

### `GET /status`

Current transmitter status (read-only snapshot).

**Response:**
```json
{
"service": "fm-rds-tx",
"backend": "pluto",
"frequencyMHz": 100.0,
"stereoEnabled": true,
"rdsEnabled": true,
"preEmphasisTauUS": 50,
"limiterEnabled": true,
"fmModulationEnabled": true
}
```

---

### `GET /runtime`

Live engine and driver telemetry. Only populated when TX is active.

**Response:**
```json
{
"engine": {
"state": "running",
"chunksProduced": 12345,
"totalSamples": 1408950000,
"underruns": 0,
"lastError": "",
"uptimeSeconds": 3614.2
},
"driver": {
"txEnabled": true,
"streamActive": true,
"framesWritten": 12345,
"samplesWritten": 1408950000,
"underruns": 0,
"effectiveSampleRateHz": 2280000
}
}
```

---

### `GET /config`

Full current configuration (all fields, including non-patchable).

**Response:** Complete `Config` JSON object.

---

### `POST /config`

**Live parameter update.** Changes are applied to the running TX engine immediately — no restart required. Only include fields you want to change (PATCH semantics).

**Request body:** JSON with any subset of patchable fields.

**Response:**
```json
{"ok": true, "live": true}
```

`"live": true` = changes were forwarded to the running engine.
`"live": false` = engine not active, changes saved for next start.

#### Patchable fields — DSP (applied within ~50ms)

| Field | Type | Range | Description |
|---|---|---|---|
| `frequencyMHz` | float | 65–110 | TX center frequency. Tunes hardware LO live. |
| `outputDrive` | float | 0–3 | Composite output level multiplier. |
| `stereoEnabled` | bool | | Enable/disable stereo (pilot + 38kHz subcarrier). |
| `pilotLevel` | float | 0–0.2 | 19 kHz pilot injection level. |
| `rdsInjection` | float | 0–0.15 | 57 kHz RDS subcarrier injection level. |
| `rdsEnabled` | bool | | Enable/disable RDS subcarrier. |
| `limiterEnabled` | bool | | Enable/disable MPX peak limiter. |
| `limiterCeiling` | float | 0–2 | Limiter ceiling (max composite amplitude). |

#### Patchable fields — RDS text (applied within ~88ms)

| Field | Type | Max length | Description |
|---|---|---|---|
| `ps` | string | 8 chars | Program Service name (station name on receiver display). |
| `radioText` | string | 64 chars | RadioText message (scrolling text on receiver). |

When `radioText` is updated, the RDS A/B flag toggles automatically per spec, signaling receivers to refresh their display.

#### Patchable fields — other (saved, not live-applied)

| Field | Type | Description |
|---|---|---|
| `toneLeftHz` | float | Left tone frequency (test generator). |
| `toneRightHz` | float | Right tone frequency (test generator). |
| `toneAmplitude` | float | Test tone amplitude (0–1). |
| `preEmphasisTauUS` | float | Pre-emphasis time constant. **Requires restart.** |

#### Examples

```bash
# Tune to 99.5 MHz
curl -X POST localhost:8088/config -d '{"frequencyMHz": 99.5}'

# Switch to mono
curl -X POST localhost:8088/config -d '{"stereoEnabled": false}'

# Update now-playing text
curl -X POST localhost:8088/config \
-d '{"ps": "MYRADIO", "radioText": "Artist - Song Title"}'

# Reduce power + disable limiter
curl -X POST localhost:8088/config \
-d '{"outputDrive": 0.8, "limiterEnabled": false}'

# Full update
curl -X POST localhost:8088/config -d '{
"frequencyMHz": 101.3,
"outputDrive": 2.2,
"stereoEnabled": true,
"pilotLevel": 0.041,
"rdsInjection": 0.021,
"rdsEnabled": true,
"limiterEnabled": true,
"limiterCeiling": 1.0,
"ps": "PIRATE",
"radioText": "Broadcasting from the attic"
}'
```

#### Error handling

Invalid values return `400 Bad Request` with a descriptive message:
```bash
curl -X POST localhost:8088/config -d '{"frequencyMHz": 200}'
# → 400: frequencyMHz out of range (65-110)
```

---

### `POST /tx/start`

Start transmission. Requires `--tx` mode with hardware.

**Response:**
```json
{"ok": true, "action": "started"}
```

**Errors:**
- `405` if not POST
- `503` if no TX controller (not in `--tx` mode)
- `409` if already running

---

### `POST /tx/stop`

Stop transmission.

**Response:**
```json
{"ok": true, "action": "stopped"}
```

---

### `GET /dry-run`

Generate a synthetic frame summary without hardware. Useful for config verification.

**Response:** `FrameSummary` JSON with mode, rates, source info, preview samples.

---

## Live update architecture

All live updates are **lock-free** in the DSP path:

| What | Mechanism | Latency |
|---|---|---|
| DSP params | `atomic.Pointer[LiveParams]` loaded once per chunk | ≤ 50ms |
| RDS text | `atomic.Value` in encoder, read at group boundary | ≤ 88ms |
| TX frequency | `atomic.Pointer` in engine, `driver.Tune()` between chunks | ≤ 50ms |

No mutex, no channel, no allocation in the real-time path. The HTTP goroutine writes atomics, the DSP goroutine reads them.

## Parameters that require restart

These cannot be hot-reloaded (they affect DSP pipeline structure):

- `compositeRateHz` — changes sample rate of entire DSP chain
- `deviceSampleRateHz` — changes hardware rate / upsampler ratio
- `maxDeviationHz` — changes FM modulator scaling
- `preEmphasisTauUS` — changes filter coefficients
- `rds.pi` / `rds.pty` — rarely change, baked into encoder init
- `audio.inputPath` — audio source selection
- `backend.kind` / `backend.device` — hardware selection

+ 25
- 18
docs/README.md Просмотреть файл

@@ -52,6 +52,26 @@ FM broadcast requires pre-emphasis to boost high frequencies before transmission
- `fmModulationEnabled: true` — output is baseband FM-modulated IQ (I² + Q² = 1). This is what SDR transmitters expect.
- `fmModulationEnabled: false` — output is raw composite MPX (I = composite, Q = 0). Useful for analysis or composite exciters.

### Split-rate mode (Pluto / HackRF)

When `deviceSampleRateHz > compositeRateHz` (e.g. Pluto at 2.28 MHz, composite at 228 kHz), the engine automatically activates split-rate mode:

1. DSP chain (stereo, RDS, limiter) runs at `compositeRateHz` (228 kHz)
2. `FMUpsampler` performs FM modulation + phase-domain interpolation to `deviceSampleRateHz`
3. Hardware receives IQ at device rate

This halves CPU load compared to running all DSP at device rate. Log output confirms the active mode:

```
engine: split-rate mode — DSP@228000Hz → upsample@2280000Hz (ratio 10.00)
```

When rates are equal (e.g. LimeSDR at 228 kHz), same-rate mode is used:

```
engine: same-rate mode — DSP@228000Hz
```

### Limiter

The MPX limiter prevents overmodulation by applying smooth gain reduction when the composite signal exceeds the configured ceiling. A hard clipper acts as a safety net after the limiter.
@@ -61,24 +81,11 @@ The MPX limiter prevents overmodulation by applying smooth gain reduction when t

### HTTP control surface

Available endpoints:
- `GET /healthz`
- `GET /status`
- `GET /dry-run`
- `GET /config`
- `POST /config`

Current patchable runtime fields via `POST /config`:
- `frequencyMHz`
- `outputDrive`
- `toneLeftHz`
- `toneRightHz`
- `toneAmplitude`
- `ps`
- `radioText`
- `preEmphasisUS`
- `limiterEnabled`
- `limiterCeiling`
Full API documentation: **[docs/API.md](API.md)**

All major TX parameters are hot-reloadable via `POST /config` during live transmission — frequency, stereo/mono, RDS text, output drive, pilot/RDS levels, limiter. Changes take effect within 50–88ms without stopping the stream.

Available endpoints: `/healthz`, `/status`, `/runtime`, `/config` (GET/POST), `/dry-run`, `/tx/start`, `/tx/stop`

### Internal DSP module
- `cd internal`


+ 93
- 0
internal/app/engine.go Просмотреть файл

@@ -67,6 +67,9 @@ type Engine struct {
totalSamples atomic.Uint64
underruns atomic.Uint64
lastError atomic.Value // string

// Live config: pending frequency change, applied between chunks
pendingFreq atomic.Pointer[float64]
}

func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine {
@@ -115,6 +118,86 @@ func (e *Engine) SetChunkDuration(d time.Duration) {
e.chunkDuration = d
}

// LiveConfigUpdate carries hot-reloadable parameters from the control API.
// nil pointers mean "no change". Validated before applying.
type LiveConfigUpdate struct {
FrequencyMHz *float64
OutputDrive *float64
StereoEnabled *bool
PilotLevel *float64
RDSInjection *float64
RDSEnabled *bool
LimiterEnabled *bool
LimiterCeiling *float64
PS *string
RadioText *string
}

// UpdateConfig applies live parameter changes without restarting the engine.
// DSP params take effect at the next chunk boundary (~50ms max).
// Frequency changes are applied between chunks via driver.Tune().
// RDS text updates are applied at the next RDS group boundary (~88ms).
func (e *Engine) UpdateConfig(u LiveConfigUpdate) error {
// --- Validate ---
if u.FrequencyMHz != nil {
if *u.FrequencyMHz < 65 || *u.FrequencyMHz > 110 {
return fmt.Errorf("frequencyMHz out of range (65-110)")
}
}
if u.OutputDrive != nil {
if *u.OutputDrive < 0 || *u.OutputDrive > 3 {
return fmt.Errorf("outputDrive out of range (0-3)")
}
}
if u.PilotLevel != nil {
if *u.PilotLevel < 0 || *u.PilotLevel > 0.2 {
return fmt.Errorf("pilotLevel out of range (0-0.2)")
}
}
if u.RDSInjection != nil {
if *u.RDSInjection < 0 || *u.RDSInjection > 0.15 {
return fmt.Errorf("rdsInjection out of range (0-0.15)")
}
}
if u.LimiterCeiling != nil {
if *u.LimiterCeiling < 0 || *u.LimiterCeiling > 2 {
return fmt.Errorf("limiterCeiling out of range (0-2)")
}
}

// --- Frequency: store for run loop to apply via driver.Tune() ---
if u.FrequencyMHz != nil {
freqHz := *u.FrequencyMHz * 1e6
e.pendingFreq.Store(&freqHz)
}

// --- RDS text: forward to encoder atomics ---
if u.PS != nil || u.RadioText != nil {
if enc := e.generator.RDSEncoder(); enc != nil {
ps, rt := "", ""
if u.PS != nil { ps = *u.PS }
if u.RadioText != nil { rt = *u.RadioText }
enc.UpdateText(ps, rt)
}
}

// --- DSP params: build new LiveParams from current + patch ---
// Read current, apply deltas, store new
current := e.generator.CurrentLiveParams()
next := current // copy

if u.OutputDrive != nil { next.OutputDrive = *u.OutputDrive }
if u.StereoEnabled != nil { next.StereoEnabled = *u.StereoEnabled }
if u.PilotLevel != nil { next.PilotLevel = *u.PilotLevel }
if u.RDSInjection != nil { next.RDSInjection = *u.RDSInjection }
if u.RDSEnabled != nil { next.RDSEnabled = *u.RDSEnabled }
if u.LimiterEnabled != nil { next.LimiterEnabled = *u.LimiterEnabled }
if u.LimiterCeiling != nil { next.LimiterCeiling = *u.LimiterCeiling }

e.generator.UpdateLive(next)
return nil
}

func (e *Engine) Start(ctx context.Context) error {
e.mu.Lock()
if e.state != EngineIdle {
@@ -192,6 +275,16 @@ func (e *Engine) run(ctx context.Context) {
if ctx.Err() != nil {
return
}

// Apply pending frequency change between chunks
if pf := e.pendingFreq.Swap(nil); pf != nil {
if err := e.driver.Tune(ctx, *pf); err != nil {
e.lastError.Store(fmt.Sprintf("tune: %v", err))
} else {
log.Printf("engine: tuned to %.3f MHz", *pf/1e6)
}
}

frame := e.generator.GenerateFrame(e.chunkDuration)
if e.upsampler != nil {
frame = e.upsampler.Process(frame)


+ 118
- 0
internal/app/engine_test.go Просмотреть файл

@@ -131,3 +131,121 @@ func TestEngineSameRate(t *testing.T) {
t.Fatal("expected same-rate mode (upsampler == nil)")
}
}

func TestEngineLiveUpdateDSP(t *testing.T) {
cfg := cfgpkg.Default()
driver := platform.NewSimulatedDriver(nil)
eng := NewEngine(cfg, driver)
eng.SetChunkDuration(10 * time.Millisecond)

ctx := context.Background()
if err := eng.Start(ctx); err != nil {
t.Fatalf("start: %v", err)
}
defer eng.Stop(ctx)

time.Sleep(50 * time.Millisecond)

// Update DSP params while running
drive := 1.5
stereo := false
err := eng.UpdateConfig(LiveConfigUpdate{
OutputDrive: &drive,
StereoEnabled: &stereo,
})
if err != nil {
t.Fatalf("UpdateConfig: %v", err)
}

// Engine should still be running after update
time.Sleep(50 * time.Millisecond)
stats := eng.Stats()
if stats.State != "running" {
t.Fatalf("expected running after update, got %s", stats.State)
}
if stats.Underruns > 0 {
t.Fatalf("unexpected underruns after update: %d", stats.Underruns)
}
}

func TestEngineLiveUpdateFrequency(t *testing.T) {
cfg := cfgpkg.Default()
driver := platform.NewSimulatedDriver(nil)
eng := NewEngine(cfg, driver)
eng.SetChunkDuration(10 * time.Millisecond)

ctx := context.Background()
if err := eng.Start(ctx); err != nil {
t.Fatalf("start: %v", err)
}
defer eng.Stop(ctx)

time.Sleep(50 * time.Millisecond)

// Tune frequency
freq := 99.5
err := eng.UpdateConfig(LiveConfigUpdate{FrequencyMHz: &freq})
if err != nil {
t.Fatalf("UpdateConfig freq: %v", err)
}

// Let it process for a bit so the pending freq gets applied
time.Sleep(50 * time.Millisecond)
stats := eng.Stats()
if stats.State != "running" {
t.Fatalf("expected running after tune, got %s", stats.State)
}
}

func TestEngineLiveUpdateRDS(t *testing.T) {
cfg := cfgpkg.Default()
driver := platform.NewSimulatedDriver(nil)
eng := NewEngine(cfg, driver)
eng.SetChunkDuration(10 * time.Millisecond)

ctx := context.Background()
if err := eng.Start(ctx); err != nil {
t.Fatalf("start: %v", err)
}
defer eng.Stop(ctx)

time.Sleep(50 * time.Millisecond)

// Update RDS text
ps := "NEWPS"
rt := "Now playing: test track"
err := eng.UpdateConfig(LiveConfigUpdate{PS: &ps, RadioText: &rt})
if err != nil {
t.Fatalf("UpdateConfig RDS: %v", err)
}

time.Sleep(50 * time.Millisecond)
stats := eng.Stats()
if stats.Underruns > 0 {
t.Fatalf("underruns after RDS update: %d", stats.Underruns)
}
}

func TestEngineLiveUpdateValidation(t *testing.T) {
cfg := cfgpkg.Default()
driver := platform.NewSimulatedDriver(nil)
eng := NewEngine(cfg, driver)

// Out of range frequency
badFreq := 200.0
if err := eng.UpdateConfig(LiveConfigUpdate{FrequencyMHz: &badFreq}); err == nil {
t.Fatal("expected validation error for bad frequency")
}

// Out of range drive
badDrive := 10.0
if err := eng.UpdateConfig(LiveConfigUpdate{OutputDrive: &badDrive}); err == nil {
t.Fatal("expected validation error for bad drive")
}

// Valid update should succeed
goodDrive := 1.0
if err := eng.UpdateConfig(LiveConfigUpdate{OutputDrive: &goodDrive}); err != nil {
t.Fatalf("expected valid update to succeed: %v", err)
}
}

+ 74
- 36
internal/control/control.go Просмотреть файл

@@ -1,6 +1,7 @@
package control

import (
_ "embed"
"encoding/json"
"net/http"
"sync"
@@ -10,11 +11,31 @@ import (
"github.com/jan/fm-rds-tx/internal/platform"
)

// TXController is an optional interface the Server uses to start/stop TX.
//go:embed ui.html
var uiHTML []byte

// TXController is an optional interface the Server uses to start/stop TX
// and apply live config changes.
type TXController interface {
StartTX() error
StopTX() error
TXStats() map[string]any
UpdateConfig(patch LivePatch) error
}

// LivePatch mirrors the patchable fields from ConfigPatch for the engine.
// nil = no change.
type LivePatch struct {
FrequencyMHz *float64
OutputDrive *float64
StereoEnabled *bool
PilotLevel *float64
RDSInjection *float64
RDSEnabled *bool
LimiterEnabled *bool
LimiterCeiling *float64
PS *string
RadioText *string
}

type Server struct {
@@ -27,6 +48,10 @@ type Server struct {
type ConfigPatch struct {
FrequencyMHz *float64 `json:"frequencyMHz,omitempty"`
OutputDrive *float64 `json:"outputDrive,omitempty"`
StereoEnabled *bool `json:"stereoEnabled,omitempty"`
PilotLevel *float64 `json:"pilotLevel,omitempty"`
RDSInjection *float64 `json:"rdsInjection,omitempty"`
RDSEnabled *bool `json:"rdsEnabled,omitempty"`
ToneLeftHz *float64 `json:"toneLeftHz,omitempty"`
ToneRightHz *float64 `json:"toneRightHz,omitempty"`
ToneAmplitude *float64 `json:"toneAmplitude,omitempty"`
@@ -55,6 +80,7 @@ func (s *Server) SetDriver(drv platform.SoapyDriver) {

func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", s.handleUI)
mux.HandleFunc("/healthz", s.handleHealth)
mux.HandleFunc("/status", s.handleStatus)
mux.HandleFunc("/dry-run", s.handleDryRun)
@@ -70,6 +96,16 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}

func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Write(uiHTML)
}

func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
cfg := s.cfg
@@ -162,58 +198,60 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(cfg)
case http.MethodPost:
// TODO: config changes only update the control server's copy.
// The running Engine/Generator holds its own snapshot and won't
// pick up these changes until restarted. Wire up a hot-reload
// path or document this limitation clearly for operators.
var patch ConfigPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

// Update the server's config snapshot (for GET /config and /status)
s.mu.Lock()
next := s.cfg
if patch.FrequencyMHz != nil {
next.FM.FrequencyMHz = *patch.FrequencyMHz
}
if patch.OutputDrive != nil {
next.FM.OutputDrive = *patch.OutputDrive
}
if patch.ToneLeftHz != nil {
next.Audio.ToneLeftHz = *patch.ToneLeftHz
}
if patch.ToneRightHz != nil {
next.Audio.ToneRightHz = *patch.ToneRightHz
}
if patch.ToneAmplitude != nil {
next.Audio.ToneAmplitude = *patch.ToneAmplitude
}
if patch.PS != nil {
next.RDS.PS = *patch.PS
}
if patch.RadioText != nil {
next.RDS.RadioText = *patch.RadioText
}
if patch.PreEmphasisTauUS != nil {
next.FM.PreEmphasisTauUS = *patch.PreEmphasisTauUS
}
if patch.LimiterEnabled != nil {
next.FM.LimiterEnabled = *patch.LimiterEnabled
}
if patch.LimiterCeiling != nil {
next.FM.LimiterCeiling = *patch.LimiterCeiling
}
if patch.FrequencyMHz != nil { next.FM.FrequencyMHz = *patch.FrequencyMHz }
if patch.OutputDrive != nil { next.FM.OutputDrive = *patch.OutputDrive }
if patch.ToneLeftHz != nil { next.Audio.ToneLeftHz = *patch.ToneLeftHz }
if patch.ToneRightHz != nil { next.Audio.ToneRightHz = *patch.ToneRightHz }
if patch.ToneAmplitude != nil { next.Audio.ToneAmplitude = *patch.ToneAmplitude }
if patch.PS != nil { next.RDS.PS = *patch.PS }
if patch.RadioText != nil { next.RDS.RadioText = *patch.RadioText }
if patch.PreEmphasisTauUS != nil { next.FM.PreEmphasisTauUS = *patch.PreEmphasisTauUS }
if patch.StereoEnabled != nil { next.FM.StereoEnabled = *patch.StereoEnabled }
if patch.LimiterEnabled != nil { next.FM.LimiterEnabled = *patch.LimiterEnabled }
if patch.LimiterCeiling != nil { next.FM.LimiterCeiling = *patch.LimiterCeiling }
if patch.RDSEnabled != nil { next.RDS.Enabled = *patch.RDSEnabled }
if patch.PilotLevel != nil { next.FM.PilotLevel = *patch.PilotLevel }
if patch.RDSInjection != nil { next.FM.RDSInjection = *patch.RDSInjection }
if err := next.Validate(); err != nil {
s.mu.Unlock()
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.cfg = next
tx := s.tx
s.mu.Unlock()

// Forward live-patchable params to running engine (if active)
if tx != nil {
lp := LivePatch{
FrequencyMHz: patch.FrequencyMHz,
OutputDrive: patch.OutputDrive,
StereoEnabled: patch.StereoEnabled,
PilotLevel: patch.PilotLevel,
RDSInjection: patch.RDSInjection,
RDSEnabled: patch.RDSEnabled,
LimiterEnabled: patch.LimiterEnabled,
LimiterCeiling: patch.LimiterCeiling,
PS: patch.PS,
RadioText: patch.RadioText,
}
if err := tx.UpdateConfig(lp); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "live": tx != nil})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}


+ 875
- 0
internal/control/ui.html Просмотреть файл

@@ -0,0 +1,875 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>fm-rds-tx</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Archivo+Black&display=swap');

:root {
--bg: #0a0a0c;
--surface: #111116;
--surface2: #18181e;
--border: #2a2a35;
--text: #d4d4dc;
--text-dim: #6a6a78;
--accent: #ff3b30;
--accent-glow: #ff3b3044;
--green: #30d158;
--green-glow: #30d15844;
--amber: #ff9f0a;
--amber-glow: #ff9f0a44;
--blue: #0a84ff;
--mono: 'JetBrains Mono', monospace;
--display: 'Archivo Black', sans-serif;
--radius: 6px;
}

* { box-sizing: border-box; margin: 0; padding: 0; }

body {
background: var(--bg);
color: var(--text);
font-family: var(--mono);
font-size: 13px;
line-height: 1.5;
min-height: 100vh;
overflow-x: hidden;
}

/* Scan lines overlay */
body::before {
content: '';
position: fixed; inset: 0;
background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.03) 2px, rgba(0,0,0,0.03) 4px);
pointer-events: none; z-index: 1000;
}

.app {
max-width: 900px;
margin: 0 auto;
padding: 16px;
}

/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0 24px;
border-bottom: 1px solid var(--border);
margin-bottom: 20px;
}

.header h1 {
font-family: var(--display);
font-size: 22px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--accent);
text-shadow: 0 0 20px var(--accent-glow), 0 0 40px var(--accent-glow);
}

.header-status {
display: flex;
align-items: center;
gap: 12px;
}

/* LED indicator */
.led {
width: 10px; height: 10px;
border-radius: 50%;
background: #333;
box-shadow: none;
transition: all 0.3s;
}
.led.on-green {
background: var(--green);
box-shadow: 0 0 8px var(--green), 0 0 20px var(--green-glow);
}
.led.on-red {
background: var(--accent);
box-shadow: 0 0 8px var(--accent), 0 0 20px var(--accent-glow);
}
.led.on-amber {
background: var(--amber);
box-shadow: 0 0 8px var(--amber), 0 0 20px var(--amber-glow);
}

/* TX control bar */
.tx-bar {
display: flex;
gap: 10px;
align-items: center;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
margin-bottom: 16px;
}

.tx-bar .freq-display {
font-family: var(--display);
font-size: 32px;
color: var(--green);
text-shadow: 0 0 15px var(--green-glow);
letter-spacing: 1px;
min-width: 200px;
}
.tx-bar .freq-display .unit {
font-family: var(--mono);
font-size: 14px;
color: var(--text-dim);
margin-left: 4px;
}

.tx-btn {
padding: 8px 20px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface2);
color: var(--text);
font-family: var(--mono);
font-size: 12px;
font-weight: 600;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.15s;
}
.tx-btn:hover { border-color: var(--text-dim); }
.tx-btn.start { border-color: var(--green); color: var(--green); }
.tx-btn.start:hover { background: var(--green); color: var(--bg); }
.tx-btn.stop { border-color: var(--accent); color: var(--accent); }
.tx-btn.stop:hover { background: var(--accent); color: #fff; }

.tx-state {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--text-dim);
margin-left: auto;
}
.tx-state.running { color: var(--green); }
.tx-state.idle { color: var(--text-dim); }

/* Telemetry strip */
.telem {
display: flex;
gap: 1px;
background: var(--border);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 16px;
}
.telem-cell {
flex: 1;
background: var(--surface);
padding: 10px 12px;
text-align: center;
}
.telem-cell .label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-dim);
margin-bottom: 4px;
}
.telem-cell .value {
font-size: 16px;
font-weight: 700;
color: var(--text);
}
.telem-cell .value.warn { color: var(--amber); }
.telem-cell .value.err { color: var(--accent); }

/* Section panels */
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 12px;
overflow: hidden;
}
.panel-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
background: var(--surface2);
cursor: pointer;
user-select: none;
}
.panel-head h2 {
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--text-dim);
}
.panel-head .chevron {
margin-left: auto;
color: var(--text-dim);
transition: transform 0.2s;
font-size: 10px;
}
.panel-head.collapsed .chevron { transform: rotate(-90deg); }
.panel-body { padding: 14px; }
.panel-body.collapsed { display: none; }

/* Form controls */
.ctrl-row {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 0;
border-bottom: 1px solid #1a1a22;
}
.ctrl-row:last-child { border-bottom: none; }

.ctrl-label {
font-size: 11px;
color: var(--text-dim);
min-width: 110px;
text-transform: uppercase;
letter-spacing: 0.5px;
}

.ctrl-input {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}

input[type="range"] {
-webkit-appearance: none;
appearance: none;
flex: 1;
height: 4px;
background: var(--border);
border-radius: 2px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
border-radius: 50%;
background: var(--text);
border: 2px solid var(--bg);
cursor: pointer;
transition: background 0.15s;
}
input[type="range"]::-webkit-slider-thumb:hover { background: var(--accent); }

input[type="number"], input[type="text"] {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-family: var(--mono);
font-size: 13px;
padding: 5px 8px;
width: 80px;
outline: none;
transition: border-color 0.15s;
}
input[type="text"] { width: 100%; }
input:focus { border-color: var(--accent); }

.val-display {
font-size: 12px;
font-weight: 600;
min-width: 55px;
text-align: right;
color: var(--text);
}

/* Toggle switch */
.toggle {
position: relative;
width: 36px; height: 20px;
background: var(--border);
border-radius: 10px;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
}
.toggle.on { background: var(--green); }
.toggle::after {
content: '';
position: absolute;
top: 2px; left: 2px;
width: 16px; height: 16px;
background: var(--text);
border-radius: 50%;
transition: transform 0.2s;
}
.toggle.on::after { transform: translateX(16px); }

/* RDS section */
.rds-input {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--green);
font-family: var(--mono);
font-size: 15px;
font-weight: 700;
padding: 8px 10px;
outline: none;
letter-spacing: 2px;
text-transform: uppercase;
transition: border-color 0.15s;
}
.rds-input:focus { border-color: var(--accent); }
.rds-input.rt {
font-size: 12px;
font-weight: 400;
letter-spacing: 0.5px;
text-transform: none;
color: var(--text);
}
.rds-charcount {
font-size: 10px;
color: var(--text-dim);
text-align: right;
margin-top: 2px;
}

/* Apply button */
.apply-btn {
display: block;
width: 100%;
padding: 10px;
margin-top: 8px;
background: var(--accent);
border: none;
border-radius: var(--radius);
color: #fff;
font-family: var(--mono);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 2px;
cursor: pointer;
transition: all 0.15s;
opacity: 0;
transform: translateY(-4px);
pointer-events: none;
}
.apply-btn.visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.apply-btn:hover { filter: brightness(1.2); }
.apply-btn.sending {
opacity: 0.6;
pointer-events: none;
}
.apply-btn.ok {
background: var(--green);
}

/* Toast notification */
.toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 10px 16px;
border-radius: var(--radius);
font-size: 12px;
font-weight: 600;
z-index: 2000;
transform: translateY(60px);
opacity: 0;
transition: all 0.3s;
}
.toast.show { transform: translateY(0); opacity: 1; }
.toast.ok { background: var(--green); color: var(--bg); }
.toast.err { background: var(--accent); color: #fff; }

/* Log */
.log {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px 10px;
font-size: 10px;
color: var(--text-dim);
max-height: 120px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.log .entry { padding: 1px 0; }
.log .entry.err { color: var(--accent); }
.log .entry.ok { color: var(--green); }

/* Responsive */
@media (max-width: 600px) {
.tx-bar { flex-wrap: wrap; }
.tx-bar .freq-display { font-size: 24px; min-width: auto; }
.telem { flex-wrap: wrap; }
.telem-cell { flex: 1 1 30%; }
.ctrl-row { flex-wrap: wrap; }
.ctrl-label { min-width: auto; width: 100%; }
}
</style>
</head>
<body>
<div class="app" id="app">

<!-- Header -->
<div class="header">
<h1>FM-RDS-TX</h1>
<div class="header-status">
<div class="led" id="led-conn"></div>
<span style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px" id="conn-label">connecting</span>
</div>
</div>

<!-- TX Control Bar -->
<div class="tx-bar">
<div class="freq-display" id="freq-display">---.--<span class="unit">MHz</span></div>
<button class="tx-btn start" id="btn-start" onclick="txStart()">TX ON</button>
<button class="tx-btn stop" id="btn-stop" onclick="txStop()">TX OFF</button>
<div class="tx-state" id="tx-state">--</div>
</div>

<!-- Telemetry -->
<div class="telem" id="telem">
<div class="telem-cell"><div class="label">Chunks</div><div class="value" id="t-chunks">--</div></div>
<div class="telem-cell"><div class="label">Samples</div><div class="value" id="t-samples">--</div></div>
<div class="telem-cell"><div class="label">Underruns</div><div class="value" id="t-underruns">0</div></div>
<div class="telem-cell"><div class="label">Uptime</div><div class="value" id="t-uptime">--</div></div>
<div class="telem-cell"><div class="label">Rate</div><div class="value" id="t-rate">--</div></div>
</div>

<!-- Frequency -->
<div class="panel">
<div class="panel-head" onclick="togglePanel(this)">
<div class="led on-green" style="width:6px;height:6px"></div>
<h2>Frequency</h2>
<span class="chevron">▼</span>
</div>
<div class="panel-body">
<div class="ctrl-row">
<span class="ctrl-label">TX Freq</span>
<div class="ctrl-input">
<input type="range" min="87.5" max="108.0" step="0.1" id="freq-slider"
oninput="onFreqSlider(this.value)">
<input type="number" min="65" max="110" step="0.1" id="freq-num"
onchange="onFreqNum(this.value)">
<span class="val-display">MHz</span>
</div>
</div>
<button class="apply-btn" id="freq-apply" onclick="applyFreq()">Apply Frequency</button>
</div>
</div>

<!-- Levels -->
<div class="panel">
<div class="panel-head" onclick="togglePanel(this)">
<div class="led on-amber" style="width:6px;height:6px"></div>
<h2>Levels</h2>
<span class="chevron">▼</span>
</div>
<div class="panel-body">
<div class="ctrl-row">
<span class="ctrl-label">Output Drive</span>
<div class="ctrl-input">
<input type="range" min="0" max="3" step="0.01" id="drive-slider"
oninput="onSlider('outputDrive', this.value, 'drive-val')">
<span class="val-display" id="drive-val">--</span>
</div>
</div>
<div class="ctrl-row">
<span class="ctrl-label">Pilot Level</span>
<div class="ctrl-input">
<input type="range" min="0" max="0.2" step="0.001" id="pilot-slider"
oninput="onSlider('pilotLevel', this.value, 'pilot-val')">
<span class="val-display" id="pilot-val">--</span>
</div>
</div>
<div class="ctrl-row">
<span class="ctrl-label">RDS Inject</span>
<div class="ctrl-input">
<input type="range" min="0" max="0.15" step="0.001" id="rds-inj-slider"
oninput="onSlider('rdsInjection', this.value, 'rds-inj-val')">
<span class="val-display" id="rds-inj-val">--</span>
</div>
</div>
<div class="ctrl-row">
<span class="ctrl-label">Limiter Ceil</span>
<div class="ctrl-input">
<input type="range" min="0" max="2" step="0.01" id="ceil-slider"
oninput="onSlider('limiterCeiling', this.value, 'ceil-val')">
<span class="val-display" id="ceil-val">--</span>
</div>
</div>
<button class="apply-btn" id="levels-apply" onclick="applyLevels()">Apply Levels</button>
</div>
</div>

<!-- Switches -->
<div class="panel">
<div class="panel-head" onclick="togglePanel(this)">
<div class="led on-green" style="width:6px;height:6px"></div>
<h2>Switches</h2>
<span class="chevron">▼</span>
</div>
<div class="panel-body">
<div class="ctrl-row">
<span class="ctrl-label">Stereo</span>
<div class="ctrl-input">
<div class="toggle" id="tog-stereo" onclick="applyToggle('stereoEnabled', this)"></div>
<span class="val-display" id="stereo-label">--</span>
</div>
</div>
<div class="ctrl-row">
<span class="ctrl-label">RDS</span>
<div class="ctrl-input">
<div class="toggle" id="tog-rds" onclick="applyToggle('rdsEnabled', this)"></div>
<span class="val-display" id="rds-label">--</span>
</div>
</div>
<div class="ctrl-row">
<span class="ctrl-label">Limiter</span>
<div class="ctrl-input">
<div class="toggle" id="tog-limiter" onclick="applyToggle('limiterEnabled', this)"></div>
<span class="val-display" id="limiter-label">--</span>
</div>
</div>
</div>
</div>

<!-- RDS -->
<div class="panel">
<div class="panel-head" onclick="togglePanel(this)">
<div class="led on-amber" style="width:6px;height:6px"></div>
<h2>RDS</h2>
<span class="chevron">▼</span>
</div>
<div class="panel-body">
<div class="ctrl-row" style="flex-direction:column;align-items:stretch;gap:4px">
<span class="ctrl-label">Program Service (PS)</span>
<input type="text" class="rds-input" id="rds-ps" maxlength="8" placeholder="STATION">
<div class="rds-charcount"><span id="ps-count">0</span>/8</div>
</div>
<div class="ctrl-row" style="flex-direction:column;align-items:stretch;gap:4px;margin-top:8px">
<span class="ctrl-label">RadioText (RT)</span>
<input type="text" class="rds-input rt" id="rds-rt" maxlength="64" placeholder="Now playing...">
<div class="rds-charcount"><span id="rt-count">0</span>/64</div>
</div>
<button class="apply-btn" id="rds-apply" onclick="applyRDS()">Apply RDS Text</button>
</div>
</div>

<!-- Log -->
<div class="panel">
<div class="panel-head" onclick="togglePanel(this)">
<h2>Log</h2>
<span class="chevron">▼</span>
</div>
<div class="panel-body">
<div class="log" id="log"></div>
</div>
</div>

</div>

<!-- Toast -->
<div class="toast" id="toast"></div>

<script>
const $ = id => document.getElementById(id);

// State
let cfg = {};
let pending = {};
let pollTimer = null;

// --- API ---
async function api(path, opts) {
try {
const r = await fetch(path, opts);
const text = await r.text();
if (!r.ok) throw new Error(text.trim() || `HTTP ${r.status}`);
try { return JSON.parse(text); }
catch(e) { return {ok: true}; }
} catch(e) {
throw e;
}
}

async function loadConfig() {
try {
cfg = await api('/config');
$('led-conn').className = 'led on-green';
$('conn-label').textContent = 'connected';
syncUI();
} catch(e) {
$('led-conn').className = 'led on-red';
$('conn-label').textContent = 'offline';
log('config load failed: ' + e.message, 'err');
}
}

async function loadRuntime() {
try {
const rt = await api('/runtime');
const eng = rt.engine || {};
const drv = rt.driver || {};

// TX state
const state = eng.state || 'idle';
const el = $('tx-state');
el.textContent = state.toUpperCase();
el.className = 'tx-state ' + state;

// Telemetry
$('t-chunks').textContent = fmt(eng.chunksProduced || 0);
$('t-samples').textContent = fmt(eng.totalSamples || 0);
const ur = eng.underruns || 0;
const urEl = $('t-underruns');
urEl.textContent = ur;
urEl.className = 'value' + (ur > 0 ? ' err' : '');
$('t-uptime').textContent = fmtTime(eng.uptimeSeconds || 0);
$('t-rate').textContent = drv.effectiveSampleRateHz ? (drv.effectiveSampleRateHz/1000).toFixed(0) + 'k' : '--';

$('led-conn').className = 'led on-green';
$('conn-label').textContent = 'connected';
} catch(e) {
// Silent on poll errors
}
}

async function txStart() {
try {
await api('/tx/start', {method:'POST'});
toast('TX started', 'ok');
log('TX started', 'ok');
} catch(e) { toast(e.message, 'err'); log('TX start failed: ' + e.message, 'err'); }
}

async function txStop() {
try {
await api('/tx/stop', {method:'POST'});
toast('TX stopped', 'ok');
log('TX stopped', 'ok');
} catch(e) { toast(e.message, 'err'); log('TX stop failed: ' + e.message, 'err'); }
}

async function sendPatch(patch, btnId) {
const btn = btnId ? $(btnId) : null;
if (btn) btn.classList.add('sending');
try {
const r = await api('/config', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(patch)
});
Object.assign(cfg, flatCfg(patch));
toast('Applied' + (r.live ? ' (live)' : ''), 'ok');
log('PATCH ' + JSON.stringify(patch) + (r.live ? ' [live]' : ''), 'ok');
if (btn) { btn.classList.remove('sending'); btn.classList.add('ok'); setTimeout(()=>{btn.classList.remove('ok','visible')}, 800); }
pending = {};
} catch(e) {
toast(e.message, 'err');
log('PATCH failed: ' + e.message, 'err');
if (btn) btn.classList.remove('sending');
}
}

// --- UI sync ---
function syncUI() {
// Frequency
const freq = cfg.fm?.frequencyMHz || 100;
$('freq-display').innerHTML = freq.toFixed(1) + '<span class="unit">MHz</span>';
$('freq-slider').value = freq;
$('freq-num').value = freq;

// Levels
setSlider('drive-slider', 'drive-val', cfg.fm?.outputDrive, 2);
setSlider('pilot-slider', 'pilot-val', cfg.fm?.pilotLevel, 3);
setSlider('rds-inj-slider', 'rds-inj-val', cfg.fm?.rdsInjection, 3);
setSlider('ceil-slider', 'ceil-val', cfg.fm?.limiterCeiling, 2);

// Toggles
setToggle('tog-stereo', 'stereo-label', cfg.fm?.stereoEnabled);
setToggle('tog-rds', 'rds-label', cfg.rds?.enabled);
setToggle('tog-limiter', 'limiter-label', cfg.fm?.limiterEnabled);

// RDS text
$('rds-ps').value = cfg.rds?.ps || '';
$('rds-rt').value = cfg.rds?.radioText || '';
$('ps-count').textContent = ($('rds-ps').value || '').length;
$('rt-count').textContent = ($('rds-rt').value || '').length;
}

function setSlider(sliderId, valId, value, decimals) {
const v = value ?? 0;
$(sliderId).value = v;
$(valId).textContent = v.toFixed(decimals || 2);
}

function setToggle(togId, labelId, on) {
$(togId).className = 'toggle' + (on ? ' on' : '');
$(labelId).textContent = on ? 'ON' : 'OFF';
}

// --- Handlers ---
function onFreqSlider(v) {
v = parseFloat(v);
$('freq-num').value = v.toFixed(1);
$('freq-display').innerHTML = v.toFixed(1) + '<span class="unit">MHz</span>';
pending.frequencyMHz = v;
showApply('freq-apply');
}
function onFreqNum(v) {
v = parseFloat(v);
if (isNaN(v)) return;
$('freq-slider').value = v;
$('freq-display').innerHTML = v.toFixed(1) + '<span class="unit">MHz</span>';
pending.frequencyMHz = v;
showApply('freq-apply');
}
function applyFreq() {
if (pending.frequencyMHz != null) sendPatch({frequencyMHz: pending.frequencyMHz}, 'freq-apply');
}

function onSlider(key, v, valId) {
v = parseFloat(v);
$(valId).textContent = v.toFixed(key === 'outputDrive' || key === 'limiterCeiling' ? 2 : 3);
pending[key] = v;
showApply('levels-apply');
}
function applyLevels() {
const patch = {};
for (const k of ['outputDrive','pilotLevel','rdsInjection','limiterCeiling']) {
if (pending[k] != null) patch[k] = pending[k];
}
if (Object.keys(patch).length) sendPatch(patch, 'levels-apply');
}

function applyToggle(key, el) {
const isOn = el.classList.contains('on');
const newVal = !isOn;
const patch = {};
patch[key] = newVal;
sendPatch(patch);
// Optimistic UI
el.classList.toggle('on');
const labelId = el.id.replace('tog-', '') + '-label';
const lbl = document.getElementById(labelId);
if (lbl) lbl.textContent = newVal ? 'ON' : 'OFF';
}

function applyRDS() {
const ps = $('rds-ps').value;
const rt = $('rds-rt').value;
const patch = {};
if (ps !== (cfg.rds?.ps || '')) patch.ps = ps;
if (rt !== (cfg.rds?.radioText || '')) patch.radioText = rt;
if (Object.keys(patch).length) sendPatch(patch, 'rds-apply');
else toast('No changes', 'ok');
}

// RDS char counters
$('rds-ps').addEventListener('input', function() {
$('ps-count').textContent = this.value.length;
showApply('rds-apply');
});
$('rds-rt').addEventListener('input', function() {
$('rt-count').textContent = this.value.length;
showApply('rds-apply');
});

// --- Panel toggle ---
function togglePanel(head) {
head.classList.toggle('collapsed');
head.nextElementSibling.classList.toggle('collapsed');
}

// --- Apply button visibility ---
function showApply(btnId) {
$(btnId).classList.add('visible');
}

// --- Toast ---
function toast(msg, type) {
const t = $('toast');
t.textContent = msg;
t.className = 'toast ' + type + ' show';
clearTimeout(t._timer);
t._timer = setTimeout(() => t.classList.remove('show'), 2500);
}

// --- Log ---
function log(msg, type) {
const el = $('log');
const ts = new Date().toLocaleTimeString();
const d = document.createElement('div');
d.className = 'entry ' + (type || '');
d.textContent = ts + ' ' + msg;
el.appendChild(d);
el.scrollTop = el.scrollHeight;
// Keep max 200 entries
while (el.children.length > 200) el.removeChild(el.firstChild);
}

// --- Helpers ---
function fmt(n) {
if (n >= 1e9) return (n/1e9).toFixed(2) + 'G';
if (n >= 1e6) return (n/1e6).toFixed(2) + 'M';
if (n >= 1e3) return (n/1e3).toFixed(1) + 'k';
return n.toString();
}
function fmtTime(s) {
if (!s || s <= 0) return '--';
const h = Math.floor(s/3600);
const m = Math.floor((s%3600)/60);
const sec = Math.floor(s%60);
if (h > 0) return h + 'h ' + m + 'm';
if (m > 0) return m + 'm ' + sec + 's';
return sec + 's';
}
function flatCfg(patch) {
// Update local cfg mirror from patch keys
const map = {
frequencyMHz: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.frequencyMHz=v; },
outputDrive: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.outputDrive=v; },
stereoEnabled: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.stereoEnabled=v; },
pilotLevel: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.pilotLevel=v; },
rdsInjection: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.rdsInjection=v; },
rdsEnabled: (v) => { if(!cfg.rds) cfg.rds={}; cfg.rds.enabled=v; },
limiterEnabled: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.limiterEnabled=v; },
limiterCeiling: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.limiterCeiling=v; },
ps: (v) => { if(!cfg.rds) cfg.rds={}; cfg.rds.ps=v; },
radioText: (v) => { if(!cfg.rds) cfg.rds={}; cfg.rds.radioText=v; },
};
for (const [k,v] of Object.entries(patch)) { if (map[k]) map[k](v); }
return {};
}

// --- Init ---
async function init() {
log('fm-rds-tx web control initializing');
await loadConfig();
// Poll runtime every 500ms
setInterval(loadRuntime, 500);
// Reload config every 5s (catch external changes)
setInterval(loadConfig, 5000);
log('polling active', 'ok');
}
init();
</script>
</body>
</html>

+ 65
- 8
internal/offline/generator.go Просмотреть файл

@@ -5,6 +5,7 @@ import (
"encoding/binary"
"fmt"
"path/filepath"
"sync/atomic"
"time"

"github.com/jan/fm-rds-tx/internal/audio"
@@ -20,6 +21,18 @@ type frameSource interface {
NextFrame() audio.Frame
}

// LiveParams carries DSP parameters that can be hot-swapped at runtime.
// Loaded once per chunk via atomic pointer — zero per-sample overhead.
type LiveParams struct {
OutputDrive float64
StereoEnabled bool
PilotLevel float64
RDSInjection float64
RDSEnabled bool
LimiterEnabled bool
LimiterCeiling float64
}

// PreEmphasizedSource wraps an audio source and applies pre-emphasis.
// The source is expected to already output at composite rate (resampled
// upstream). Pre-emphasis is applied per-sample at that rate.
@@ -71,17 +84,38 @@ type Generator struct {
frameSeq uint64

// Pre-allocated frame buffer — reused every GenerateFrame call.
// Safe because driver.Write() is blocking: it returns only after
// the hardware has consumed the data. Do NOT hold references to
// frame.Samples beyond Write's return.
frameBuf *output.CompositeFrame
bufCap int

// Live-updatable DSP parameters — written by control API, read per chunk.
liveParams atomic.Pointer[LiveParams]
}

func NewGenerator(cfg cfgpkg.Config) *Generator {
return &Generator{cfg: cfg}
}

// UpdateLive hot-swaps DSP parameters. Thread-safe — called from control API,
// applied at the next chunk boundary by the DSP goroutine.
func (g *Generator) UpdateLive(p LiveParams) {
g.liveParams.Store(&p)
}

// CurrentLiveParams returns the current live parameter snapshot.
// Used by Engine.UpdateConfig to do read-modify-write on the params.
func (g *Generator) CurrentLiveParams() LiveParams {
if lp := g.liveParams.Load(); lp != nil {
return *lp
}
return LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0}
}

// RDSEncoder returns the live RDS encoder, or nil if RDS is disabled.
// Used by the Engine to forward text updates.
func (g *Generator) RDSEncoder() *rds.Encoder {
return g.rdsEnc
}

func (g *Generator) init() {
if g.initialized {
return
@@ -113,6 +147,18 @@ func (g *Generator) init() {
g.fmMod = dsp.NewFMModulator(g.sampleRate)
if g.cfg.FM.MaxDeviationHz > 0 { g.fmMod.MaxDeviation = g.cfg.FM.MaxDeviationHz }
}

// Seed initial live params from config
g.liveParams.Store(&LiveParams{
OutputDrive: g.cfg.FM.OutputDrive,
StereoEnabled: g.cfg.FM.StereoEnabled,
PilotLevel: g.cfg.FM.PilotLevel,
RDSInjection: g.cfg.FM.RDSInjection,
RDSEnabled: g.cfg.RDS.Enabled,
LimiterEnabled: g.cfg.FM.LimiterEnabled,
LimiterCeiling: ceiling,
})

g.initialized = true
}

@@ -146,27 +192,38 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame
g.frameSeq++
frame.Sequence = g.frameSeq

ceiling := g.cfg.FM.LimiterCeiling
// Load live params once per chunk — single atomic read, zero per-sample cost
lp := g.liveParams.Load()
if lp == nil {
// Fallback: should never happen after init(), but be safe
lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0}
}

// Apply live combiner gains
g.combiner.PilotGain = lp.PilotLevel
g.combiner.RDSGain = lp.RDSInjection

ceiling := lp.LimiterCeiling
if ceiling <= 0 { ceiling = 1.0 }

for i := 0; i < samples; i++ {
in := g.source.NextFrame()

comps := g.stereoEncoder.Encode(in)
if !g.cfg.FM.StereoEnabled {
if !lp.StereoEnabled {
comps.Stereo = 0; comps.Pilot = 0
}

rdsValue := 0.0
if g.rdsEnc != nil {
if g.rdsEnc != nil && lp.RDSEnabled {
rdsCarrier := g.stereoEncoder.RDSCarrier()
rdsValue = g.rdsEnc.NextSampleWithCarrier(rdsCarrier)
}

composite := g.combiner.Combine(comps.Mono, comps.Stereo, comps.Pilot, rdsValue)
composite *= g.cfg.FM.OutputDrive
composite *= lp.OutputDrive

if g.limiter != nil {
if lp.LimiterEnabled && g.limiter != nil {
composite = g.limiter.Process(composite)
composite = dsp.HardClip(composite, ceiling)
}


+ 24
- 0
internal/platform/plutosdr/pluto_windows.go Просмотреть файл

@@ -110,6 +110,7 @@ type PlutoDriver struct {
phyDev uintptr // iio_device* (ad9361-phy)
chanI uintptr // iio_channel* TX I
chanQ uintptr // iio_channel* TX Q
chanLO uintptr // iio_channel* TX LO (altvoltage1), cached for Tune()
buf uintptr // iio_buffer*
bufSize int // samples per buffer push

@@ -204,6 +205,7 @@ func (d *PlutoDriver) Configure(_ context.Context, cfg platform.SoapyConfig) err

// TX LO frequency
phyChanLO := d.findChannel(phyDev, "altvoltage1", true) // TX LO
d.chanLO = phyChanLO // cache for Tune()
if phyChanLO != 0 {
freqHz := int64(cfg.CenterFreqHz)
if freqHz <= 0 {
@@ -368,6 +370,27 @@ func (d *PlutoDriver) Stop(_ context.Context) error {

func (d *PlutoDriver) Flush(_ context.Context) error { return nil }

func (d *PlutoDriver) Tune(_ context.Context, freqHz float64) error {
d.mu.Lock()
defer d.mu.Unlock()
if !d.configured || d.chanLO == 0 {
return fmt.Errorf("pluto: not configured or LO channel not available")
}
if d.lib.pChannelAttrWriteLongLong == nil {
return fmt.Errorf("pluto: iio_channel_attr_write_longlong not loaded")
}
cAttr, _ := syscall.BytePtrFromString("frequency")
ret, _, _ := d.lib.pChannelAttrWriteLongLong.Call(
d.chanLO,
uintptr(unsafe.Pointer(cAttr)),
uintptr(int64(freqHz)),
)
if int32(ret) < 0 {
return fmt.Errorf("pluto: LO tune to %.0f Hz failed (iio rc=%d)", freqHz, int32(ret))
}
return nil
}

func (d *PlutoDriver) Close(_ context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
@@ -406,6 +429,7 @@ func (d *PlutoDriver) cleanup() {
d.disableChannel(d.chanQ)
d.chanQ = 0
}
d.chanLO = 0 // config-only channel, no disable needed
if d.ctx != 0 && d.lib.pDestroyCtx != nil {
d.lib.pDestroyCtx.Call(d.ctx)
d.ctx = 0


+ 9
- 0
internal/platform/soapy.go Просмотреть файл

@@ -68,6 +68,8 @@ type SoapyDriver interface {
Flush(ctx context.Context) error
Close(ctx context.Context) error
Stats() RuntimeStats
// Tune changes the TX center frequency while streaming. Thread-safe.
Tune(ctx context.Context, freqHz float64) error
}

// -----------------------------------------------------------------------
@@ -220,3 +222,10 @@ func (sd *SimulatedDriver) Stats() RuntimeStats {
EffectiveRate: sd.cfg.SampleRateHz,
}
}

func (sd *SimulatedDriver) Tune(_ context.Context, freqHz float64) error {
sd.mu.Lock()
sd.cfg.CenterFreqHz = freqHz
sd.mu.Unlock()
return nil
}

+ 9
- 0
internal/platform/soapysdr/native.go Просмотреть файл

@@ -209,6 +209,15 @@ func (d *nativeDriver) Stop(_ context.Context) error {

func (d *nativeDriver) Flush(_ context.Context) error { return nil }

func (d *nativeDriver) Tune(_ context.Context, freqHz float64) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.dev == 0 || d.lib == nil {
return fmt.Errorf("soapy: not configured")
}
return d.lib.setFrequency(d.dev, dirTX, 0, freqHz)
}

func (d *nativeDriver) Close(_ context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()


+ 31
- 1
internal/rds/encoder.go Просмотреть файл

@@ -1,6 +1,9 @@
package rds

import "math"
import (
"math"
"sync/atomic"
)

// RDS encoder — port of PiFmRds, adapted for arbitrary sample rates.
// At 228 kHz: uses exact {0,+1,0,-1} carrier (identical to PiFmRds).
@@ -88,6 +91,11 @@ type Encoder struct {
carrierStep float64

SampleRate float64

// Live-updatable text — written by control API, read at group boundaries.
// Zero-contention: atomic swap, checked once per RDS group (~88ms at 228kHz).
livePS atomic.Value // string
liveRT atomic.Value // string
}

func NewEncoder(cfg RDSConfig) (*Encoder, error) {
@@ -141,6 +149,18 @@ func (e *Encoder) Reset() {
for i := range e.ring { e.ring[i] = 0 }
}

// UpdateText hot-swaps PS and/or RT. Thread-safe — called from HTTP handlers,
// applied at the next RDS group boundary by the DSP goroutine.
// Pass empty string to leave a field unchanged.
func (e *Encoder) UpdateText(ps, rt string) {
if ps != "" {
e.livePS.Store(normalizePS(ps))
}
if rt != "" {
e.liveRT.Store(normalizeRT(rt))
}
}

// NextSample returns the next RDS subcarrier sample at the configured rate.
// Uses the internal free-running 57 kHz carrier. Prefer NextSampleWithCarrier
// for phase-locked operation in a stereo MPX chain.
@@ -157,6 +177,16 @@ func (e *Encoder) NextSample() float64 {
func (e *Encoder) NextSampleWithCarrier(carrier float64) float64 {
if e.sampleCount >= e.spb {
if e.bitPos >= bitsPerGroup {
// Apply live text updates at group boundaries (~88ms at 228kHz).
// This is the only place we read the atomics — zero per-sample overhead.
if ps, ok := e.livePS.Load().(string); ok && ps != "" {
e.scheduler.cfg.PS = ps
}
if rt, ok := e.liveRT.Load().(string); ok && rt != "" {
e.scheduler.cfg.RT = rt
e.scheduler.rtIdx = 0 // restart RT transmission for new text
e.scheduler.rtABFlag = !e.scheduler.rtABFlag // toggle A/B per RDS spec
}
e.getRDSGroup()
e.bitPos = 0
}


Загрузка…
Отмена
Сохранить