| @@ -216,3 +216,17 @@ func (b *txBridge) TXStats() map[string]any { | |||||
| "lastError": s.LastError, "uptimeSeconds": s.UptimeSeconds, | "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, | |||||
| }) | |||||
| } | |||||
| @@ -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 | |||||
| @@ -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: 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. | - `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 | ### 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. | 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 | ### 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 | ### Internal DSP module | ||||
| - `cd internal` | - `cd internal` | ||||
| @@ -67,6 +67,9 @@ type Engine struct { | |||||
| totalSamples atomic.Uint64 | totalSamples atomic.Uint64 | ||||
| underruns atomic.Uint64 | underruns atomic.Uint64 | ||||
| lastError atomic.Value // string | 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 { | func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine { | ||||
| @@ -115,6 +118,86 @@ func (e *Engine) SetChunkDuration(d time.Duration) { | |||||
| e.chunkDuration = d | 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 { | func (e *Engine) Start(ctx context.Context) error { | ||||
| e.mu.Lock() | e.mu.Lock() | ||||
| if e.state != EngineIdle { | if e.state != EngineIdle { | ||||
| @@ -192,6 +275,16 @@ func (e *Engine) run(ctx context.Context) { | |||||
| if ctx.Err() != nil { | if ctx.Err() != nil { | ||||
| return | 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) | frame := e.generator.GenerateFrame(e.chunkDuration) | ||||
| if e.upsampler != nil { | if e.upsampler != nil { | ||||
| frame = e.upsampler.Process(frame) | frame = e.upsampler.Process(frame) | ||||
| @@ -131,3 +131,121 @@ func TestEngineSameRate(t *testing.T) { | |||||
| t.Fatal("expected same-rate mode (upsampler == nil)") | 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) | |||||
| } | |||||
| } | |||||
| @@ -1,6 +1,7 @@ | |||||
| package control | package control | ||||
| import ( | import ( | ||||
| _ "embed" | |||||
| "encoding/json" | "encoding/json" | ||||
| "net/http" | "net/http" | ||||
| "sync" | "sync" | ||||
| @@ -10,11 +11,31 @@ import ( | |||||
| "github.com/jan/fm-rds-tx/internal/platform" | "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 { | type TXController interface { | ||||
| StartTX() error | StartTX() error | ||||
| StopTX() error | StopTX() error | ||||
| TXStats() map[string]any | 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 { | type Server struct { | ||||
| @@ -27,6 +48,10 @@ type Server struct { | |||||
| type ConfigPatch struct { | type ConfigPatch struct { | ||||
| FrequencyMHz *float64 `json:"frequencyMHz,omitempty"` | FrequencyMHz *float64 `json:"frequencyMHz,omitempty"` | ||||
| OutputDrive *float64 `json:"outputDrive,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"` | ToneLeftHz *float64 `json:"toneLeftHz,omitempty"` | ||||
| ToneRightHz *float64 `json:"toneRightHz,omitempty"` | ToneRightHz *float64 `json:"toneRightHz,omitempty"` | ||||
| ToneAmplitude *float64 `json:"toneAmplitude,omitempty"` | ToneAmplitude *float64 `json:"toneAmplitude,omitempty"` | ||||
| @@ -55,6 +80,7 @@ func (s *Server) SetDriver(drv platform.SoapyDriver) { | |||||
| func (s *Server) Handler() http.Handler { | func (s *Server) Handler() http.Handler { | ||||
| mux := http.NewServeMux() | mux := http.NewServeMux() | ||||
| mux.HandleFunc("/", s.handleUI) | |||||
| mux.HandleFunc("/healthz", s.handleHealth) | mux.HandleFunc("/healthz", s.handleHealth) | ||||
| mux.HandleFunc("/status", s.handleStatus) | mux.HandleFunc("/status", s.handleStatus) | ||||
| mux.HandleFunc("/dry-run", s.handleDryRun) | 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}) | _ = 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) { | func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) { | ||||
| s.mu.RLock() | s.mu.RLock() | ||||
| cfg := s.cfg | cfg := s.cfg | ||||
| @@ -162,58 +198,60 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { | |||||
| w.Header().Set("Content-Type", "application/json") | w.Header().Set("Content-Type", "application/json") | ||||
| _ = json.NewEncoder(w).Encode(cfg) | _ = json.NewEncoder(w).Encode(cfg) | ||||
| case http.MethodPost: | 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 | var patch ConfigPatch | ||||
| if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { | if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { | ||||
| http.Error(w, err.Error(), http.StatusBadRequest) | http.Error(w, err.Error(), http.StatusBadRequest) | ||||
| return | return | ||||
| } | } | ||||
| // Update the server's config snapshot (for GET /config and /status) | |||||
| s.mu.Lock() | s.mu.Lock() | ||||
| next := s.cfg | 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 { | if err := next.Validate(); err != nil { | ||||
| s.mu.Unlock() | s.mu.Unlock() | ||||
| http.Error(w, err.Error(), http.StatusBadRequest) | http.Error(w, err.Error(), http.StatusBadRequest) | ||||
| return | return | ||||
| } | } | ||||
| s.cfg = next | s.cfg = next | ||||
| tx := s.tx | |||||
| s.mu.Unlock() | 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") | 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: | default: | ||||
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | ||||
| } | } | ||||
| @@ -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> | |||||
| @@ -5,6 +5,7 @@ import ( | |||||
| "encoding/binary" | "encoding/binary" | ||||
| "fmt" | "fmt" | ||||
| "path/filepath" | "path/filepath" | ||||
| "sync/atomic" | |||||
| "time" | "time" | ||||
| "github.com/jan/fm-rds-tx/internal/audio" | "github.com/jan/fm-rds-tx/internal/audio" | ||||
| @@ -20,6 +21,18 @@ type frameSource interface { | |||||
| NextFrame() audio.Frame | 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. | // PreEmphasizedSource wraps an audio source and applies pre-emphasis. | ||||
| // The source is expected to already output at composite rate (resampled | // The source is expected to already output at composite rate (resampled | ||||
| // upstream). Pre-emphasis is applied per-sample at that rate. | // upstream). Pre-emphasis is applied per-sample at that rate. | ||||
| @@ -71,17 +84,38 @@ type Generator struct { | |||||
| frameSeq uint64 | frameSeq uint64 | ||||
| // Pre-allocated frame buffer — reused every GenerateFrame call. | // 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 | frameBuf *output.CompositeFrame | ||||
| bufCap int | bufCap int | ||||
| // Live-updatable DSP parameters — written by control API, read per chunk. | |||||
| liveParams atomic.Pointer[LiveParams] | |||||
| } | } | ||||
| func NewGenerator(cfg cfgpkg.Config) *Generator { | func NewGenerator(cfg cfgpkg.Config) *Generator { | ||||
| return &Generator{cfg: cfg} | 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() { | func (g *Generator) init() { | ||||
| if g.initialized { | if g.initialized { | ||||
| return | return | ||||
| @@ -113,6 +147,18 @@ func (g *Generator) init() { | |||||
| g.fmMod = dsp.NewFMModulator(g.sampleRate) | g.fmMod = dsp.NewFMModulator(g.sampleRate) | ||||
| if g.cfg.FM.MaxDeviationHz > 0 { g.fmMod.MaxDeviation = g.cfg.FM.MaxDeviationHz } | 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 | g.initialized = true | ||||
| } | } | ||||
| @@ -146,27 +192,38 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||||
| g.frameSeq++ | g.frameSeq++ | ||||
| frame.Sequence = 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 } | if ceiling <= 0 { ceiling = 1.0 } | ||||
| for i := 0; i < samples; i++ { | for i := 0; i < samples; i++ { | ||||
| in := g.source.NextFrame() | in := g.source.NextFrame() | ||||
| comps := g.stereoEncoder.Encode(in) | comps := g.stereoEncoder.Encode(in) | ||||
| if !g.cfg.FM.StereoEnabled { | |||||
| if !lp.StereoEnabled { | |||||
| comps.Stereo = 0; comps.Pilot = 0 | comps.Stereo = 0; comps.Pilot = 0 | ||||
| } | } | ||||
| rdsValue := 0.0 | rdsValue := 0.0 | ||||
| if g.rdsEnc != nil { | |||||
| if g.rdsEnc != nil && lp.RDSEnabled { | |||||
| rdsCarrier := g.stereoEncoder.RDSCarrier() | rdsCarrier := g.stereoEncoder.RDSCarrier() | ||||
| rdsValue = g.rdsEnc.NextSampleWithCarrier(rdsCarrier) | rdsValue = g.rdsEnc.NextSampleWithCarrier(rdsCarrier) | ||||
| } | } | ||||
| composite := g.combiner.Combine(comps.Mono, comps.Stereo, comps.Pilot, rdsValue) | 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 = g.limiter.Process(composite) | ||||
| composite = dsp.HardClip(composite, ceiling) | composite = dsp.HardClip(composite, ceiling) | ||||
| } | } | ||||
| @@ -110,6 +110,7 @@ type PlutoDriver struct { | |||||
| phyDev uintptr // iio_device* (ad9361-phy) | phyDev uintptr // iio_device* (ad9361-phy) | ||||
| chanI uintptr // iio_channel* TX I | chanI uintptr // iio_channel* TX I | ||||
| chanQ uintptr // iio_channel* TX Q | chanQ uintptr // iio_channel* TX Q | ||||
| chanLO uintptr // iio_channel* TX LO (altvoltage1), cached for Tune() | |||||
| buf uintptr // iio_buffer* | buf uintptr // iio_buffer* | ||||
| bufSize int // samples per buffer push | bufSize int // samples per buffer push | ||||
| @@ -204,6 +205,7 @@ func (d *PlutoDriver) Configure(_ context.Context, cfg platform.SoapyConfig) err | |||||
| // TX LO frequency | // TX LO frequency | ||||
| phyChanLO := d.findChannel(phyDev, "altvoltage1", true) // TX LO | phyChanLO := d.findChannel(phyDev, "altvoltage1", true) // TX LO | ||||
| d.chanLO = phyChanLO // cache for Tune() | |||||
| if phyChanLO != 0 { | if phyChanLO != 0 { | ||||
| freqHz := int64(cfg.CenterFreqHz) | freqHz := int64(cfg.CenterFreqHz) | ||||
| if freqHz <= 0 { | 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) 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 { | func (d *PlutoDriver) Close(_ context.Context) error { | ||||
| d.mu.Lock() | d.mu.Lock() | ||||
| defer d.mu.Unlock() | defer d.mu.Unlock() | ||||
| @@ -406,6 +429,7 @@ func (d *PlutoDriver) cleanup() { | |||||
| d.disableChannel(d.chanQ) | d.disableChannel(d.chanQ) | ||||
| d.chanQ = 0 | d.chanQ = 0 | ||||
| } | } | ||||
| d.chanLO = 0 // config-only channel, no disable needed | |||||
| if d.ctx != 0 && d.lib.pDestroyCtx != nil { | if d.ctx != 0 && d.lib.pDestroyCtx != nil { | ||||
| d.lib.pDestroyCtx.Call(d.ctx) | d.lib.pDestroyCtx.Call(d.ctx) | ||||
| d.ctx = 0 | d.ctx = 0 | ||||
| @@ -68,6 +68,8 @@ type SoapyDriver interface { | |||||
| Flush(ctx context.Context) error | Flush(ctx context.Context) error | ||||
| Close(ctx context.Context) error | Close(ctx context.Context) error | ||||
| Stats() RuntimeStats | 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, | 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 | |||||
| } | |||||
| @@ -209,6 +209,15 @@ func (d *nativeDriver) Stop(_ context.Context) error { | |||||
| func (d *nativeDriver) Flush(_ context.Context) error { return nil } | 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 { | func (d *nativeDriver) Close(_ context.Context) error { | ||||
| d.mu.Lock() | d.mu.Lock() | ||||
| defer d.mu.Unlock() | defer d.mu.Unlock() | ||||
| @@ -1,6 +1,9 @@ | |||||
| package rds | package rds | ||||
| import "math" | |||||
| import ( | |||||
| "math" | |||||
| "sync/atomic" | |||||
| ) | |||||
| // RDS encoder — port of PiFmRds, adapted for arbitrary sample rates. | // RDS encoder — port of PiFmRds, adapted for arbitrary sample rates. | ||||
| // At 228 kHz: uses exact {0,+1,0,-1} carrier (identical to PiFmRds). | // At 228 kHz: uses exact {0,+1,0,-1} carrier (identical to PiFmRds). | ||||
| @@ -88,6 +91,11 @@ type Encoder struct { | |||||
| carrierStep float64 | carrierStep float64 | ||||
| SampleRate 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) { | func NewEncoder(cfg RDSConfig) (*Encoder, error) { | ||||
| @@ -141,6 +149,18 @@ func (e *Encoder) Reset() { | |||||
| for i := range e.ring { e.ring[i] = 0 } | 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. | // NextSample returns the next RDS subcarrier sample at the configured rate. | ||||
| // Uses the internal free-running 57 kHz carrier. Prefer NextSampleWithCarrier | // Uses the internal free-running 57 kHz carrier. Prefer NextSampleWithCarrier | ||||
| // for phase-locked operation in a stereo MPX chain. | // 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 { | func (e *Encoder) NextSampleWithCarrier(carrier float64) float64 { | ||||
| if e.sampleCount >= e.spb { | if e.sampleCount >= e.spb { | ||||
| if e.bitPos >= bitsPerGroup { | 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.getRDSGroup() | ||||
| e.bitPos = 0 | e.bitPos = 0 | ||||
| } | } | ||||