diff --git a/.gitignore b/.gitignore index 04837f0..fc557ca 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ build/ *.iq *.raw *.bak +*.zip +*.exe diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index cfa71f8..a25a9b8 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -12,6 +12,7 @@ import ( "time" apppkg "github.com/jan/fm-rds-tx/internal/app" + "github.com/jan/fm-rds-tx/internal/audio" cfgpkg "github.com/jan/fm-rds-tx/internal/config" ctrlpkg "github.com/jan/fm-rds-tx/internal/control" drypkg "github.com/jan/fm-rds-tx/internal/dryrun" @@ -31,6 +32,8 @@ func main() { txMode := flag.Bool("tx", false, "start real TX mode (requires hardware + build tags)") txAutoStart := flag.Bool("tx-auto-start", false, "auto-start TX on launch") listDevices := flag.Bool("list-devices", false, "enumerate SoapySDR devices and exit") + audioStdin := flag.Bool("audio-stdin", false, "read S16LE stereo PCM audio from stdin") + audioRate := flag.Int("audio-rate", 44100, "sample rate of stdin audio input (Hz)") flag.Parse() // --- list-devices (SoapySDR) --- @@ -99,7 +102,7 @@ func main() { if driver == nil { log.Fatal("no hardware driver available — build with -tags pluto (or -tags soapy)") } - runTXMode(cfg, driver, *txAutoStart) + runTXMode(cfg, driver, *txAutoStart, *audioStdin, *audioRate) return } @@ -142,7 +145,7 @@ func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver { return nil } -func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool) { +func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, audioStdin bool, audioRate int) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -150,9 +153,17 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool) { // OutputDrive controls composite signal level, NOT hardware gain. // Hardware TX gain is always 0 dB (max power). Use external attenuator for power control. soapyCfg := platform.SoapyConfig{ - Driver: cfg.Backend.Device, + Driver: cfg.Backend.Driver, + Device: cfg.Backend.Device, CenterFreqHz: cfg.FM.FrequencyMHz * 1e6, GainDB: 0, // 0 dB = max TX power on PlutoSDR + DeviceArgs: map[string]string{}, + } + if cfg.Backend.URI != "" { + soapyCfg.DeviceArgs["uri"] = cfg.Backend.URI + } + for k, v := range cfg.Backend.DeviceArgs { + soapyCfg.DeviceArgs[k] = v } soapyCfg.SampleRateHz = cfg.EffectiveDeviceRate() @@ -172,10 +183,31 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool) { // Engine engine := apppkg.NewEngine(cfg, driver) + // Live audio stream source (optional) + var streamSrc *audio.StreamSource + if audioStdin { + // Buffer: 2 seconds at input rate — enough to absorb jitter + streamSrc = audio.NewStreamSource(audioRate*2, audioRate) + engine.SetStreamSource(streamSrc) + + // Stdin ingest goroutine + go func() { + log.Printf("audio: reading S16LE stereo PCM from stdin at %d Hz", audioRate) + if err := audio.IngestReader(os.Stdin, streamSrc); err != nil { + log.Printf("audio: stdin ingest ended: %v", err) + } else { + log.Println("audio: stdin EOF") + } + }() + } + // Control plane srv := ctrlpkg.NewServer(cfg) srv.SetDriver(driver) srv.SetTXController(&txBridge{engine: engine}) + if streamSrc != nil { + srv.SetStreamSource(streamSrc) + } if autoStart { log.Println("TX: auto-start enabled") diff --git a/docs/API.md b/docs/API.md index 8e3b096..b29d676 100644 --- a/docs/API.md +++ b/docs/API.md @@ -218,3 +218,103 @@ These cannot be hot-reloaded (they affect DSP pipeline structure): - `rds.pi` / `rds.pty` — rarely change, baked into encoder init - `audio.inputPath` — audio source selection - `backend.kind` / `backend.device` — hardware selection + +--- + +### `POST /audio/stream` + +Push raw audio data into the live stream buffer. Format: **S16LE stereo PCM** at the configured `--audio-rate` (default 44100 Hz). + +Requires `--audio-stdin` or a configured stream source. + +**Request:** Binary body, `application/octet-stream`, raw S16LE stereo PCM bytes. + +**Response:** +```json +{ + "ok": true, + "frames": 4096, + "stats": { + "available": 12000, + "capacity": 131072, + "buffered": 0.09, + "written": 890000, + "underruns": 0, + "overflows": 0 + } +} +``` + +**Example:** +```bash +# Push a file +ffmpeg -i song.mp3 -f s16le -ar 44100 -ac 2 - | \ + curl -X POST --data-binary @- http://pluto:8088/audio/stream +``` + +**Errors:** +- `405` if not POST +- `503` if no audio stream configured + +--- + +## Audio Streaming + +### Stdin pipe (primary method) + +Pipe any audio source through ffmpeg into the transmitter: + +```bash +# Internet radio stream +ffmpeg -i "http://stream.example.com/radio.mp3" -f s16le -ar 44100 -ac 2 - | \ + fmrtx --tx --tx-auto-start --audio-stdin --config config.json + +# Local music file +ffmpeg -i music.flac -f s16le -ar 44100 -ac 2 - | \ + fmrtx --tx --tx-auto-start --audio-stdin + +# Playlist (ffmpeg concat) +ffmpeg -f concat -i playlist.txt -f s16le -ar 44100 -ac 2 - | \ + fmrtx --tx --tx-auto-start --audio-stdin + +# PulseAudio / ALSA capture (Linux) +parecord --format=s16le --rate=44100 --channels=2 - | \ + fmrtx --tx --tx-auto-start --audio-stdin + +# Custom sample rate (e.g. 48kHz source) +ffmpeg -i source.wav -f s16le -ar 48000 -ac 2 - | \ + fmrtx --tx --tx-auto-start --audio-stdin --audio-rate 48000 +``` + +### HTTP audio push + +Push audio from a remote machine via the HTTP API: + +```bash +# From another machine on the network +ffmpeg -i music.mp3 -f s16le -ar 44100 -ac 2 - | \ + curl -X POST --data-binary @- http://pluto-host:8088/audio/stream +``` + +### Audio buffer + +The stream uses a lock-free ring buffer (default: 2 seconds at input rate). Buffer stats are available in `GET /runtime` under `audioStream`: + +```json +{ + "audioStream": { + "available": 12000, + "capacity": 131072, + "buffered": 0.09, + "written": 890000, + "underruns": 0, + "overflows": 0 + } +} +``` + +- **underruns**: DSP consumed faster than audio arrived (silence inserted) +- **overflows**: Audio arrived faster than DSP consumed (data dropped) +- **buffered**: Fill ratio (0.0 = empty, 1.0 = full) + +When no audio is streaming, the transmitter falls back to the configured tone generator or silence. diff --git a/docs/DSP-CHAIN.md b/docs/DSP-CHAIN.md new file mode 100644 index 0000000..3a287f6 --- /dev/null +++ b/docs/DSP-CHAIN.md @@ -0,0 +1,287 @@ +# fm-rds-tx — DSP Signal Chain & Konfiguration + +## Übersicht + +fm-rds-tx ist ein broadcast-konformer FM-Stereo-MPX-Encoder mit RDS für PlutoSDR/SoapySDR. +Die DSP-Kette folgt dem Industriestandard (Omnia, Orban, Stereo Tool) mit Clip-Filter-Clip- +Architektur und ITU-R BS.412 MPX Power Limiting. + +--- + +## Signalkette + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ AUDIO INPUT │ +│ S16LE Stereo PCM via stdin (ffmpeg) oder interner Tongenerator │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 1: Pre-Emphasis + Band-Limiting (pro Kanal L/R) │ +│ │ +│ Audio × gain ──→ Pre-Emphasis (50µs EU / 75µs US) │ +│ ──→ 15kHz LPF (8th-order Chebyshev Type I, 0.5dB Ripple) │ +│ ──→ 19kHz Notch (Q=15, double-cascade) │ +│ │ +│ Frequenzantwort (verifiziert): │ +│ 10kHz: +0.1dB (flat) 15kHz: -0.2dB │ +│ 17kHz: -21dB 18.5kHz: -40dB │ +│ 19kHz: -155dB (tot) 22kHz: -51dB │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 2: Drive + Kompression + Clip₁ │ +│ │ +│ × OutputDrive │ +│ ──→ StereoLimiter (5ms Attack / 200ms Release, ceiling) │ +│ ──→ HardClip₁ (ceiling) │ +│ │ +│ Der Limiter komprimiert die Dynamik (bringt Average hoch). │ +│ Der Clip fängt Peaks die der Limiter's Attack verpasst. │ +│ "Slow-to-fast Progression" — Broadcast-Standard. │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 3: Cleanup LPF + Clip₂ (Overshoot-Kompensator) │ +│ │ +│ ──→ 15kHz LPF (8th-order Chebyshev, identisch zu Stage 1) │ +│ ──→ HardClip₂ (ceiling) │ +│ │ +│ Der zweite LPF-Pass entfernt Clip₁-Harmonische. │ +│ Clip₂ fängt die LPF-Overshoots (IIR-Filter erzeugen diese). │ +│ Doppelter LPF-Pass verdoppelt die Guard-Band-Dämpfung. │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 4: Stereo-Encode │ +│ │ +│ L/R ──→ Mono: (L+R)/2 (0–15kHz Baseband) │ +│ ──→ Stereo: (L-R)/2 × sin(38kHz) (23–53kHz DSB-SC) │ +│ ──→ Pilot: sin(19kHz) (phase-locked, kohärent) │ +│ ──→ RDS Carrier: sin(57kHz) (3× Pilot-Phase, kohärent) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 5: Composite Clip + Schutzfilter │ +│ │ +│ Audio-MPX (Mono + Stereo-Sub) │ +│ ──→ HardClip₃ (ceiling) — finale Deviations-Kontrolle │ +│ ──→ 19kHz Notch (Q=10, double) — Clip-Harmonische bei Pilot │ +│ ──→ 57kHz Notch (Q=10, double) — Clip-Harmonische bei RDS │ +│ │ +│ Guard Bands (total, 2× LPF + Notches): │ +│ 19kHz: >-80dB broadband, >-90dB exakt │ +│ 57kHz: >-100dB │ +│ (Omnia 11 Spezifikation: >80dB — wir sind on par) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 6: BS.412 MPX Power Limiter (optional) │ +│ │ +│ ──→ × BS412 Gain │ +│ │ +│ Rolling 60-Sekunden RMS-Messung auf dem Audio-Composite. │ +│ Langsamer Gain-Regler (2s Attack / 5s Release). │ +│ Zieht Pilot+RDS-Power automatisch vom Budget ab. │ +│ Pflicht in CH, DE, NL, FR für lizenzierte FM-Sender. │ +│ ~5dB Lautheitsverlust bei 0 dBr Threshold. │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 7: Pilot + RDS Injection (fixe Amplitude) │ +│ │ +│ composite = audioMPX │ +│ + pilotLevel × sin(19kHz) — IMMER 9% │ +│ + rdsInjection × rdsWaveform — IMMER 4% │ +│ │ +│ Pilot und RDS werden NIE geclippt, NIE gefiltert, NIE vom │ +│ BS.412-Limiter berührt. Konstante Amplitude, immer. │ +│ │ +│ Peak Composite = ceiling + pilotLevel + rdsInjection ≈ 113% │ +│ (Standard-Broadcast-Praxis — Pilot/RDS werden von den meisten │ +│ Regulierungsbehörden aus dem Modulationslimit ausgenommen) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 8: FM-Modulation │ +│ │ +│ Split-Rate: Composite @ 228kHz ──→ FMUpsampler ──→ IQ @ 2.28MHz│ +│ maxDeviation × mpxGain = effektive Deviation │ +│ composite=1.0 → ±75kHz Deviation (bei mpxGain=1.0) │ +│ │ +│ Ausgabe: IQ-Samples (float32) an PlutoSDR via libiio │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Konfiguration + +### Audio + +| Parameter | Typ | Default | Beschreibung | +|---|---|---|---| +| `audio.gain` | float | 1.0 | Eingangsverstärkung vor Pre-Emphasis. 1.0 = unity. | +| `audio.inputPath` | string | "" | WAV-Datei als Quelle (leer = stdin oder Tongenerator) | + +**Empfehlung:** `gain: 1.0`. Pegel-Kontrolle über `outputDrive`. + +### FM — Audio-Processing + +| Parameter | Typ | Default | Bereich | Beschreibung | +|---|---|---|---|---| +| `outputDrive` | float | 0.5 | 0–10 | Eingangsverstärkung vor Limiter/Clip. Bestimmt wie aggressiv die Kompression arbeitet. | +| `limiterEnabled` | bool | true | — | Aktiviert den StereoLimiter (5ms/200ms). | +| `limiterCeiling` | float | 1.0 | 0–2 | Maximum-Amplitude für Audio L/R und Composite. 1.0 = ±75kHz. | +| `preEmphasisTauUS` | float | 50 | 0/50/75 | Pre-Emphasis Zeitkonstante. 50µs = Europa/CH, 75µs = USA, 0 = aus. | + +**outputDrive im Detail:** + +Der Drive bestimmt den *Klangcharakter*, nicht die Lautstärke (wenn BS.412 aktiv ist): + +| Drive | Effekt | Einsatz | +|---|---|---| +| 1–2 | Wenig Kompression, dynamisch, sauber | Klassik, Jazz, Wortbeiträge | +| 3–4 | Moderate Kompression, ausgewogen | **Empfohlen für die meisten Formate** | +| 5–7 | Aggressive Kompression, dichter Sound | Pop/Rock-Formatradio | +| 8–10 | Maximale Dichte, hörbare Clip-Artefakte | Nicht empfohlen | + +**Empfehlung:** `outputDrive: 3.0` für sauberen, broadcast-fähigen Sound. + +### FM — Pilot & RDS + +| Parameter | Typ | Default | Bereich | Beschreibung | +|---|---|---|---|---| +| `pilotLevel` | float | 0.09 | 0–0.2 | 19kHz Pilot-Amplitude. **Direkte Prozentangabe von ±75kHz.** 0.09 = 9% = ITU-Standard. | +| `rdsInjection` | float | 0.04 | 0–0.15 | RDS-Amplitude. **Direkte Prozentangabe.** 0.04 = 4%. Waveform ist unity-normalisiert. | +| `stereoEnabled` | bool | true | — | Stereo-Encode an/aus. Aus = nur Mono (L+R)/2, kein Pilot. | + +**Empfehlung:** `pilotLevel: 0.09`, `rdsInjection: 0.04`. Nicht ändern ausser es gibt einen guten Grund. + +Pilot und RDS sind **unabhängig vom OutputDrive** — sie bleiben immer bei der konfigurierten Amplitude, egal wie hart das Audio komprimiert wird. + +### FM — BS.412 (ITU-R MPX Power Limiter) + +| Parameter | Typ | Default | Beschreibung | +|---|---|---|---| +| `bs412Enabled` | bool | false | Aktiviert den BS.412 MPX Power Limiter. **Pflicht in CH, DE, NL, FR.** | +| `bs412ThresholdDBr` | float | 0 | Power-Limit in dBr. 0 = Standard. +3 = relaxiert. | + +**Was BS.412 macht:** +Begrenzt die durchschnittliche MPX-Leistung über ein rollendes 60-Sekunden-Fenster. +Reduziert die Audio-Amplitude langsam wenn die Power den Threshold überschreitet. +Pilot und RDS werden automatisch vom Power-Budget abgezogen. + +**Effekt auf den Sound:** +- ~5dB Lautheitsverlust bei 0 dBr mit aggressiver Kompression +- Weniger Verlust bei dynamischerem Material +- OutputDrive beeinflusst bei aktivem BS.412 nur den Klangcharakter, nicht die Lautheit + +**Empfehlung:** `bs412Enabled: true`, `bs412ThresholdDBr: 0` für BAKOM-Compliance. + +### FM — Hardware-Kalibrierung + +| Parameter | Typ | Default | Bereich | Beschreibung | +|---|---|---|---|---| +| `mpxGain` | float | 1.0 | 0.1–5 | Skaliert die FM-Deviation (nicht den Composite!). Kompensiert DAC/SDR-Hardware-Faktoren. | +| `maxDeviationHz` | float | 75000 | 0–150000 | Maximale FM-Deviation in Hz. 75000 = Standard. | +| `compositeRateHz` | int | 228000 | — | Interne DSP-Sample-Rate. 228000 = 12×19kHz (optimal für Pilot-Kohärenz). | + +**MpxTool-Kalibrierung:** +1. `mpxGain: 1.0` setzen (keine Skalierung) +2. MpxTool Ref Level so einstellen dass **Pilot Level = 9.0%** anzeigt +3. Für PlutoSDR typisch: Ref Level ca. **-7.5 dBFS** +4. Einmal kalibrieren, nie wieder anfassen + +**Empfehlung:** `mpxGain: 1.0`, `maxDeviationHz: 75000`. Kalibrierung über MpxTool Ref Level. + +### RDS + +| Parameter | Typ | Default | Beschreibung | +|---|---|---|---| +| `rds.enabled` | bool | true | RDS an/aus | +| `rds.pi` | string | "1234" | Programme Identification (4-stellig hex). Muss mit BAKOM-Zuteilung übereinstimmen. | +| `rds.ps` | string | "FMRTX" | Programme Service Name (max 8 Zeichen). Stationsname auf dem Display. | +| `rds.radioText` | string | "" | Radio Text (max 64 Zeichen). Scrolltext auf dem Display. | +| `rds.pty` | int | 0 | Programme Type. 0=undefined, 1=News, 3=Info, 10=Pop, 15=Other Music, etc. | + +### Backend + +| Parameter | Typ | Default | Beschreibung | +|---|---|---|---| +| `backend.kind` | string | "file" | `"pluto"` für PlutoSDR, `"soapy"` für SoapySDR, `"file"` für Dateiausgabe | +| `backend.device` | string | "" | Device-String. PlutoSDR: `"usb:"` oder `"ip:192.168.2.1"` | +| `backend.deviceSampleRateHz` | float | 0 | SDR-Device-Rate. 2280000 = 10× compositeRate (optimal). | + +--- + +## Referenz-Konfiguration (BAKOM-konform, PlutoSDR) + +```json +{ + "audio": { + "gain": 1.0 + }, + "rds": { + "enabled": true, + "pi": "BEEF", + "ps": "RADIO-ZH", + "radioText": "Ihr Zürcher Kurzradio", + "pty": 0 + }, + "fm": { + "frequencyMHz": 100.0, + "stereoEnabled": true, + "pilotLevel": 0.09, + "rdsInjection": 0.04, + "preEmphasisTauUS": 50, + "outputDrive": 3.0, + "limiterEnabled": true, + "limiterCeiling": 1.0, + "bs412Enabled": true, + "bs412ThresholdDBr": 0, + "mpxGain": 1.0, + "compositeRateHz": 228000, + "maxDeviationHz": 75000, + "fmModulationEnabled": true + }, + "backend": { + "kind": "pluto", + "device": "usb:", + "deviceSampleRateHz": 2280000 + }, + "control": { + "listenAddress": "127.0.0.1:8088" + } +} +``` + +--- + +## Audio-Streaming (Produktionsbetrieb) + +```bash +ffmpeg -i http://stream-url/stream -f s16le -ar 44100 -ac 2 - | fmrtx.exe --tx --tx-auto-start --audio-stdin --audio-rate 44100 --config config.json +``` + +**Hinweis:** Unter Windows `cmd.exe` verwenden, nicht PowerShell (korrumpiert die Binary-Pipe). + +--- + +## Verifizierte Messwerte (MpxTool, PlutoSDR @ 100MHz) + +| Parameter | Messung | Soll | +|---|---|---| +| Pilot Level | 9.0% | 9% ✓ | +| RDS Injection | 3.4% | 4% (≈, BPSK-Mittelung) | +| MPX Peak | 105–110% | 100–113% ✓ | +| Guard Band 19kHz | >-80dB | >-80dB (Omnia 11: >80dB) ✓ | +| Audio Bandwidth | flat bis 15kHz | 15kHz ✓ | diff --git a/docs/config.orangepi-pluto-soapy.json b/docs/config.orangepi-pluto-soapy.json new file mode 100644 index 0000000..6a5e9b2 --- /dev/null +++ b/docs/config.orangepi-pluto-soapy.json @@ -0,0 +1,44 @@ +{ + "audio": { + "inputPath": "", + "gain": 1.0, + "toneLeftHz": 1000, + "toneRightHz": 1600, + "toneAmplitude": 0.2 + }, + "rds": { + "enabled": true, + "pi": "BEEF", + "ps": "PLUTOOPI", + "radioText": "Orange Pi Pluto Soapy test", + "pty": 0 + }, + "fm": { + "frequencyMHz": 100.0, + "stereoEnabled": true, + "pilotLevel": 0.09, + "rdsInjection": 0.04, + "preEmphasisTauUS": 50, + "outputDrive": 0.5, + "compositeRateHz": 228000, + "maxDeviationHz": 75000, + "limiterEnabled": true, + "limiterCeiling": 1.0, + "fmModulationEnabled": true, + "mpxGain": 1.0, + "bs412Enabled": false, + "bs412ThresholdDBr": 0 + }, + "backend": { + "kind": "soapy", + "driver": "plutosdr", + "device": "", + "uri": "ip:pluto.local", + "deviceArgs": {}, + "outputPath": "", + "deviceSampleRateHz": 2280000 + }, + "control": { + "listenAddress": "127.0.0.1:8088" + } +} diff --git a/docs/config.plutosdr.json b/docs/config.plutosdr.json index 51c3b28..1cb9738 100644 --- a/docs/config.plutosdr.json +++ b/docs/config.plutosdr.json @@ -10,16 +10,19 @@ "enabled": true, "pi": "BEEF", "ps": "PLUTO-TX", - "radioText": "Hello from PlutoSDR", + "radioText": "TESTATSSENDUNG 1mW", "pty": 0 }, "fm": { + "bs412Enabled": true, + "bs412ThresholdDBr": 0, "frequencyMHz": 100.0, "stereoEnabled": true, - "pilotLevel": 0.041, - "rdsInjection": 0.021, + "pilotLevel": 0.09, + "rdsInjection": 0.04, "preEmphasisTauUS": 50, - "outputDrive": 2.2, + "outputDrive": 1.0, + "mpxGain": 1.0, "compositeRateHz": 228000, "maxDeviationHz": 75000, "limiterEnabled": true, @@ -29,6 +32,9 @@ "backend": { "kind": "pluto", "device": "usb:", + "driver": "", + "uri": "", + "deviceArgs": {}, "outputPath": "", "deviceSampleRateHz": 2280000 }, diff --git a/internal/app/engine.go b/internal/app/engine.go index 7384d9a..3e84b34 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -8,6 +8,7 @@ import ( "sync/atomic" "time" + "github.com/jan/fm-rds-tx/internal/audio" cfgpkg "github.com/jan/fm-rds-tx/internal/config" "github.com/jan/fm-rds-tx/internal/dsp" offpkg "github.com/jan/fm-rds-tx/internal/offline" @@ -70,6 +71,30 @@ type Engine struct { // Live config: pending frequency change, applied between chunks pendingFreq atomic.Pointer[float64] + + // Live audio stream (optional) + streamSrc *audio.StreamSource +} + +// SetStreamSource configures a live audio stream as the audio source. +// Must be called before Start(). The StreamResampler is created internally +// to convert from the stream's sample rate to the DSP composite rate. +func (e *Engine) SetStreamSource(src *audio.StreamSource) { + e.streamSrc = src + compositeRate := float64(e.cfg.FM.CompositeRateHz) + if compositeRate <= 0 { + compositeRate = 228000 + } + resampler := audio.NewStreamResampler(src, compositeRate) + e.generator.SetExternalSource(resampler) + log.Printf("engine: live audio stream — %d Hz → %.0f Hz (buffer %d frames)", + src.SampleRate, compositeRate, src.Stats().Capacity) +} + +// StreamSource returns the live audio stream source, or nil. +// Used by the control server for stats and HTTP audio ingest. +func (e *Engine) StreamSource() *audio.StreamSource { + return e.streamSrc } func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine { @@ -90,6 +115,11 @@ func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine { if maxDev <= 0 { maxDev = 75000 } + // mpxGain scales the FM deviation to compensate for hardware + // DAC/SDR scaling factors. DSP chain stays at logical 0-1.0 levels. + if cfg.FM.MpxGain > 0 && cfg.FM.MpxGain != 1.0 { + maxDev *= cfg.FM.MpxGain + } upsampler = dsp.NewFMUpsampler(compositeRate, deviceRate, maxDev) log.Printf("engine: split-rate mode — DSP@%.0fHz → upsample@%.0fHz (ratio %.2f)", compositeRate, deviceRate, deviceRate/compositeRate) diff --git a/internal/audio/stream.go b/internal/audio/stream.go new file mode 100644 index 0000000..bf951a8 --- /dev/null +++ b/internal/audio/stream.go @@ -0,0 +1,196 @@ +package audio + +import ( + "encoding/binary" + "fmt" + "io" + "sync/atomic" +) + +// StreamSource is a lock-free SPSC (single-producer, single-consumer) ring buffer +// for real-time audio streaming. One goroutine writes PCM frames, the DSP +// goroutine reads them via NextFrame(). Returns silence on underrun. +// +// Zero allocations in steady state. No mutex in the read or write path. +type StreamSource struct { + ring []Frame + size int + mask int // size-1, for fast modulo (size must be power of 2) + SampleRate int + + writePos atomic.Int64 + readPos atomic.Int64 + + Underruns atomic.Uint64 + Overflows atomic.Uint64 + Written atomic.Uint64 +} + +// NewStreamSource creates a ring buffer with the given capacity (rounded up +// to next power of 2) and input sample rate. +func NewStreamSource(capacity, sampleRate int) *StreamSource { + // Round up to power of 2 + size := 1 + for size < capacity { + size <<= 1 + } + return &StreamSource{ + ring: make([]Frame, size), + size: size, + mask: size - 1, + SampleRate: sampleRate, + } +} + +// WriteFrame pushes a single frame into the ring buffer. +// Returns false if the buffer is full (overflow). +func (s *StreamSource) WriteFrame(f Frame) bool { + wp := s.writePos.Load() + rp := s.readPos.Load() + if wp-rp >= int64(s.size) { + s.Overflows.Add(1) + return false + } + s.ring[int(wp)&s.mask] = f + s.writePos.Add(1) + s.Written.Add(1) + return true +} + +// WritePCM decodes interleaved S16LE stereo PCM bytes and writes frames +// to the ring buffer. Returns the number of frames written. +func (s *StreamSource) WritePCM(data []byte) int { + frames := len(data) / 4 // 2 channels × 2 bytes per sample + written := 0 + for i := 0; i < frames; i++ { + off := i * 4 + l := int16(binary.LittleEndian.Uint16(data[off:])) + r := int16(binary.LittleEndian.Uint16(data[off+2:])) + f := NewFrame( + Sample(float64(l)/32768.0), + Sample(float64(r)/32768.0), + ) + if !s.WriteFrame(f) { + break + } + written++ + } + return written +} + +// ReadFrame consumes one frame from the ring buffer. +// Returns silence (0,0) on underrun. +func (s *StreamSource) ReadFrame() Frame { + rp := s.readPos.Load() + wp := s.writePos.Load() + if rp >= wp { + s.Underruns.Add(1) + return NewFrame(0, 0) + } + f := s.ring[int(rp)&s.mask] + s.readPos.Add(1) + return f +} + +// NextFrame implements the frameSource interface. +func (s *StreamSource) NextFrame() Frame { + return s.ReadFrame() +} + +// Available returns the number of frames currently buffered. +func (s *StreamSource) Available() int { + return int(s.writePos.Load() - s.readPos.Load()) +} + +// Buffered returns the fill ratio (0.0 = empty, 1.0 = full). +func (s *StreamSource) Buffered() float64 { + return float64(s.Available()) / float64(s.size) +} + +// Stats returns diagnostic counters. +func (s *StreamSource) Stats() StreamStats { + return StreamStats{ + Available: s.Available(), + Capacity: s.size, + Buffered: s.Buffered(), + Written: s.Written.Load(), + Underruns: s.Underruns.Load(), + Overflows: s.Overflows.Load(), + } +} + +// StreamStats exposes runtime telemetry for the stream buffer. +type StreamStats struct { + Available int `json:"available"` + Capacity int `json:"capacity"` + Buffered float64 `json:"buffered"` + Written uint64 `json:"written"` + Underruns uint64 `json:"underruns"` + Overflows uint64 `json:"overflows"` +} + +// --- StreamResampler --- + +// StreamResampler wraps a StreamSource and rate-converts from the stream's +// native sample rate to the target output rate using linear interpolation. +// Consumes input frames on demand — no buffering beyond the ring buffer. +type StreamResampler struct { + src *StreamSource + ratio float64 // inputRate / outputRate (< 1 when upsampling) + pos float64 + prev Frame + curr Frame +} + +// NewStreamResampler creates a streaming resampler. +func NewStreamResampler(src *StreamSource, outputRate float64) *StreamResampler { + if src == nil || outputRate <= 0 || src.SampleRate <= 0 { + return &StreamResampler{src: src, ratio: 1.0} + } + return &StreamResampler{ + src: src, + ratio: float64(src.SampleRate) / outputRate, + } +} + +// NextFrame returns the next interpolated frame at the output rate. +// Implements the frameSource interface. +func (r *StreamResampler) NextFrame() Frame { + if r.src == nil { + return NewFrame(0, 0) + } + + // Consume input samples as the fractional position advances + for r.pos >= 1.0 { + r.prev = r.curr + r.curr = r.src.ReadFrame() + r.pos -= 1.0 + } + + frac := r.pos + l := float64(r.prev.L)*(1-frac) + float64(r.curr.L)*frac + ri := float64(r.prev.R)*(1-frac) + float64(r.curr.R)*frac + r.pos += r.ratio + return NewFrame(Sample(l), Sample(ri)) +} + +// --- Ingest helpers --- + +// IngestReader continuously reads S16LE stereo PCM from an io.Reader into +// a StreamSource. Blocks until the reader returns an error or io.EOF. +// Designed to run as a goroutine. +func IngestReader(r io.Reader, dst *StreamSource) error { + buf := make([]byte, 16384) // 4096 frames per read (16KB) + for { + n, err := r.Read(buf) + if n > 0 { + dst.WritePCM(buf[:n]) + } + if err != nil { + if err == io.EOF { + return nil + } + return fmt.Errorf("audio ingest: %w", err) + } + } +} diff --git a/internal/audio/stream_test.go b/internal/audio/stream_test.go new file mode 100644 index 0000000..cc2820a --- /dev/null +++ b/internal/audio/stream_test.go @@ -0,0 +1,376 @@ +package audio + +import ( + "bytes" + "encoding/binary" + "io" + "math" + "sync" + "sync/atomic" + "testing" +) + +func TestStreamSource_WriteRead(t *testing.T) { + s := NewStreamSource(1024, 44100) + if s.size != 1024 { + t.Fatalf("expected size 1024, got %d", s.size) + } + + // Write and read a frame + f := NewFrame(0.5, -0.3) + if !s.WriteFrame(f) { + t.Fatal("write failed") + } + if s.Available() != 1 { + t.Fatalf("expected 1 available, got %d", s.Available()) + } + + out := s.ReadFrame() + if out.L != 0.5 || out.R != -0.3 { + t.Fatalf("read mismatch: got L=%.2f R=%.2f", out.L, out.R) + } + if s.Available() != 0 { + t.Fatalf("expected 0 available, got %d", s.Available()) + } +} + +func TestStreamSource_Underrun(t *testing.T) { + s := NewStreamSource(16, 44100) + + // Read from empty buffer — should return silence + f := s.ReadFrame() + if f.L != 0 || f.R != 0 { + t.Fatal("expected silence on underrun") + } + if s.Underruns.Load() != 1 { + t.Fatalf("expected 1 underrun, got %d", s.Underruns.Load()) + } +} + +func TestStreamSource_Overflow(t *testing.T) { + s := NewStreamSource(4, 44100) // size rounds up to 4 + + // Fill completely + for i := 0; i < 4; i++ { + if !s.WriteFrame(NewFrame(Sample(float64(i)/10), 0)) { + t.Fatalf("write %d failed", i) + } + } + + // Next write should overflow + if s.WriteFrame(NewFrame(1, 1)) { + t.Fatal("expected overflow") + } + if s.Overflows.Load() != 1 { + t.Fatalf("expected 1 overflow, got %d", s.Overflows.Load()) + } +} + +func TestStreamSource_PowerOf2Rounding(t *testing.T) { + tests := []struct{ in, expect int }{ + {1, 1}, {2, 2}, {3, 4}, {5, 8}, {100, 128}, {1024, 1024}, {1025, 2048}, + } + for _, tt := range tests { + s := NewStreamSource(tt.in, 44100) + if s.size != tt.expect { + t.Fatalf("NewStreamSource(%d): size=%d, expected %d", tt.in, s.size, tt.expect) + } + } +} + +func TestStreamSource_FIFO(t *testing.T) { + s := NewStreamSource(64, 44100) + n := 50 + for i := 0; i < n; i++ { + s.WriteFrame(NewFrame(Sample(float64(i)), 0)) + } + for i := 0; i < n; i++ { + f := s.ReadFrame() + if int(f.L) != i { + t.Fatalf("FIFO order broken at %d: got %d", i, int(f.L)) + } + } +} + +func TestStreamSource_Wraparound(t *testing.T) { + s := NewStreamSource(8, 44100) // size = 8 + + // Write and read more than buffer size to test wraparound + for round := 0; round < 10; round++ { + for i := 0; i < 8; i++ { + val := float64(round*8 + i) + if !s.WriteFrame(NewFrame(Sample(val), 0)) { + t.Fatalf("write failed round=%d i=%d", round, i) + } + } + for i := 0; i < 8; i++ { + expected := float64(round*8 + i) + f := s.ReadFrame() + if float64(f.L) != expected { + t.Fatalf("round=%d i=%d: got %f expected %f", round, i, float64(f.L), expected) + } + } + } + + stats := s.Stats() + if stats.Underruns != 0 || stats.Overflows != 0 { + t.Fatalf("unexpected errors: underruns=%d overflows=%d", stats.Underruns, stats.Overflows) + } +} + +func TestStreamSource_WritePCM(t *testing.T) { + s := NewStreamSource(256, 44100) + + // Create 10 stereo frames of S16LE PCM + var buf bytes.Buffer + for i := 0; i < 10; i++ { + l := int16(i * 1000) + r := int16(-i * 1000) + binary.Write(&buf, binary.LittleEndian, l) + binary.Write(&buf, binary.LittleEndian, r) + } + + written := s.WritePCM(buf.Bytes()) + if written != 10 { + t.Fatalf("expected 10 frames, wrote %d", written) + } + + // Verify first frame + f := s.ReadFrame() + if f.L != 0 || f.R != 0 { + t.Fatalf("frame 0: L=%.4f R=%.4f, expected 0", f.L, f.R) + } + // Verify frame 5 + for i := 1; i < 5; i++ { + s.ReadFrame() + } + f = s.ReadFrame() + expectedL := 5000.0 / 32768.0 + if math.Abs(float64(f.L)-expectedL) > 0.001 { + t.Fatalf("frame 5 L=%.4f, expected %.4f", f.L, expectedL) + } +} + +func TestStreamSource_ConcurrentSPSC(t *testing.T) { + s := NewStreamSource(4096, 44100) + frames := 50000 + var producerDone atomic.Bool + + var wg sync.WaitGroup + wg.Add(2) + + // Producer + go func() { + defer wg.Done() + for i := 0; i < frames; i++ { + for !s.WriteFrame(NewFrame(Sample(float64(i+1)), 0)) { + // Buffer full — yield + } + } + producerDone.Store(true) + }() + + // Consumer + var lastVal float64 + var orderOK = true + var readCount int + go func() { + defer wg.Done() + for { + if s.Available() == 0 { + if producerDone.Load() { + break + } + continue + } + f := s.ReadFrame() + readCount++ + v := float64(f.L) + if v > 0 && v < lastVal { + orderOK = false + } + if v > 0 { + lastVal = v + } + } + }() + + wg.Wait() + + if !orderOK { + t.Fatal("FIFO order broken in concurrent SPSC") + } + if readCount < frames/2 { + t.Fatalf("read too few frames: %d (expected ~%d)", readCount, frames) + } +} + +// --- StreamResampler tests --- + +func TestStreamResampler_1to1(t *testing.T) { + s := NewStreamSource(256, 44100) + r := NewStreamResampler(s, 44100) // 1:1 + + for i := 0; i < 100; i++ { + s.WriteFrame(NewFrame(Sample(float64(i)/100), 0)) + } + + // At 1:1 ratio, output should track input with a small startup delay. + // Skip first few samples (resampler priming), then verify monotonic increase. + prev := -1.0 + for i := 0; i < 90; i++ { + f := r.NextFrame() + v := float64(f.L) + if i > 5 && v < prev-0.001 { + t.Fatalf("sample %d: non-monotonic %.4f < %.4f", i, v, prev) + } + if v > 0 { + prev = v + } + } + // Final value should be close to 0.9 (we wrote 0..0.99) + if prev < 0.5 { + t.Fatalf("final value %.4f too low (expected > 0.5)", prev) + } +} + +func TestStreamResampler_Upsample(t *testing.T) { + // 44100 → 228000 (ratio ≈ 0.1934, ~5.17× upsampling) + s := NewStreamSource(4096, 44100) + r := NewStreamResampler(s, 228000) + + // Write 1000 frames of a 1kHz sine at 44100 Hz + for i := 0; i < 1000; i++ { + v := math.Sin(2 * math.Pi * 1000 * float64(i) / 44100) + s.WriteFrame(NewFrame(Sample(v), Sample(v))) + } + + // Read upsampled output — should be ~5170 samples for 1000 input + // (minus a few for resampler priming) + out := make([]float64, 0, 5200) + for i := 0; i < 5000; i++ { + f := r.NextFrame() + out = append(out, float64(f.L)) + } + + // Verify the output is a smooth sine, not clicks or zeros + // Check that max amplitude is close to 1.0 + maxAmp := 0.0 + for _, v := range out[100:] { // skip initial ramp + if math.Abs(v) > maxAmp { + maxAmp = math.Abs(v) + } + } + if maxAmp < 0.8 { + t.Fatalf("max amplitude %.4f too low (expected ~1.0)", maxAmp) + } + + // Check smoothness: no sudden jumps > 0.1 between adjacent samples + maxJump := 0.0 + for i := 101; i < len(out); i++ { + d := math.Abs(out[i] - out[i-1]) + if d > maxJump { + maxJump = d + } + } + // At 228kHz with 1kHz tone: max step ≈ sin(2π*1000/228000) ≈ 0.0276 + if maxJump > 0.05 { + t.Fatalf("max inter-sample jump %.4f (expected < 0.05 for smooth sine)", maxJump) + } +} + +func TestStreamResampler_Downsample(t *testing.T) { + // 96000 → 44100 (ratio ≈ 2.177, downsampling) + s := NewStreamSource(8192, 96000) + r := NewStreamResampler(s, 44100) + + // Write 4000 frames at 96kHz + for i := 0; i < 4000; i++ { + v := math.Sin(2 * math.Pi * 440 * float64(i) / 96000) + s.WriteFrame(NewFrame(Sample(v), 0)) + } + + // Should get ~1837 output frames (4000 × 44100/96000) + count := 0 + for i := 0; i < 1800; i++ { + f := r.NextFrame() + _ = f + count++ + } + if count != 1800 { + t.Fatalf("expected 1800 reads, got %d", count) + } +} + +func TestStreamResampler_NilSource(t *testing.T) { + r := NewStreamResampler(nil, 228000) + f := r.NextFrame() + if f.L != 0 || f.R != 0 { + t.Fatal("expected silence from nil source") + } +} + +// --- IngestReader test --- + +func TestIngestReader(t *testing.T) { + s := NewStreamSource(4096, 44100) + + // Create PCM data: 100 stereo frames + var buf bytes.Buffer + for i := 0; i < 100; i++ { + l := int16(i * 100) + r := int16(-i * 100) + binary.Write(&buf, binary.LittleEndian, l) + binary.Write(&buf, binary.LittleEndian, r) + } + + // IngestReader should read all data and return nil (EOF) + err := IngestReader(bytes.NewReader(buf.Bytes()), s) + if err != nil { + t.Fatalf("IngestReader: %v", err) + } + + if s.Available() != 100 { + t.Fatalf("expected 100 frames, got %d", s.Available()) + } + + // Verify first and last + f := s.ReadFrame() + if f.L != 0 { + t.Fatalf("frame 0 L=%.4f, expected 0", f.L) + } + for i := 1; i < 99; i++ { + s.ReadFrame() + } + f = s.ReadFrame() + expectedL := 9900.0 / 32768.0 + if math.Abs(float64(f.L)-expectedL) > 0.01 { + t.Fatalf("frame 99 L=%.4f, expected ~%.4f", f.L, expectedL) + } +} + +func TestIngestReader_Error(t *testing.T) { + s := NewStreamSource(256, 44100) + errReader := &errAfterN{n: 10} + err := IngestReader(errReader, s) + if err == nil { + t.Fatal("expected error") + } +} + +type errAfterN struct { + n, count int +} + +func (r *errAfterN) Read(p []byte) (int, error) { + if r.count >= r.n { + return 0, io.ErrUnexpectedEOF + } + r.count++ + // Return 4 bytes (one stereo frame) + if len(p) >= 4 { + p[0], p[1], p[2], p[3] = 0, 0, 0, 0 + return 4, nil + } + return 0, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 584d189..768a40a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,8 +35,8 @@ type RDSConfig struct { type FMConfig struct { FrequencyMHz float64 `json:"frequencyMHz"` StereoEnabled bool `json:"stereoEnabled"` - PilotLevel float64 `json:"pilotLevel"` // linear injection level in composite (e.g. 0.1 = 10%) - RDSInjection float64 `json:"rdsInjection"` // linear injection level in composite (e.g. 0.05 = 5%) + PilotLevel float64 `json:"pilotLevel"` // fraction of ±75kHz deviation (0.09 = 9%, ITU standard) + RDSInjection float64 `json:"rdsInjection"` // fraction of ±75kHz deviation (0.04 = 4%, typical) PreEmphasisTauUS float64 `json:"preEmphasisTauUS"` // time constant in µs: 50 (EU) or 75 (US), 0=off OutputDrive float64 `json:"outputDrive"` CompositeRateHz int `json:"compositeRateHz"` // internal DSP/MPX sample rate @@ -44,13 +44,19 @@ type FMConfig struct { LimiterEnabled bool `json:"limiterEnabled"` LimiterCeiling float64 `json:"limiterCeiling"` FMModulationEnabled bool `json:"fmModulationEnabled"` + MpxGain float64 `json:"mpxGain"` // hardware calibration: scales entire composite output (default 1.0) + BS412Enabled bool `json:"bs412Enabled"` // ITU-R BS.412 MPX power limiter (EU requirement) + BS412ThresholdDBr float64 `json:"bs412ThresholdDBr"` // power limit in dBr (0 = standard, +3 = relaxed) } type BackendConfig struct { - Kind string `json:"kind"` - Device string `json:"device"` - OutputPath string `json:"outputPath"` - DeviceSampleRateHz float64 `json:"deviceSampleRateHz"` // actual SDR device rate; 0 = same as compositeRateHz + Kind string `json:"kind"` + Driver string `json:"driver,omitempty"` + Device string `json:"device"` + URI string `json:"uri,omitempty"` + DeviceArgs map[string]string `json:"deviceArgs,omitempty"` + OutputPath string `json:"outputPath"` + DeviceSampleRateHz float64 `json:"deviceSampleRateHz"` // actual SDR device rate; 0 = same as compositeRateHz } type ControlConfig struct { @@ -64,8 +70,8 @@ func Default() Config { FM: FMConfig{ FrequencyMHz: 100.0, StereoEnabled: true, - PilotLevel: 0.1, - RDSInjection: 0.05, + PilotLevel: 0.09, + RDSInjection: 0.04, PreEmphasisTauUS: 50, OutputDrive: 0.5, CompositeRateHz: 228000, @@ -73,6 +79,7 @@ func Default() Config { LimiterEnabled: true, LimiterCeiling: 1.0, FMModulationEnabled: true, + MpxGain: 1.0, }, Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"}, Control: ControlConfig{ListenAddress: "127.0.0.1:8088"}, @@ -128,7 +135,7 @@ func (c Config) Validate() error { if c.FM.RDSInjection < 0 || c.FM.RDSInjection > 0.15 { return fmt.Errorf("fm.rdsInjection out of range") } - if c.FM.OutputDrive < 0 || c.FM.OutputDrive > 3 { + if c.FM.OutputDrive < 0 || c.FM.OutputDrive > 10 { return fmt.Errorf("fm.outputDrive out of range (0..3)") } if c.FM.CompositeRateHz < 96000 || c.FM.CompositeRateHz > 1520000 { @@ -143,6 +150,10 @@ func (c Config) Validate() error { if c.FM.LimiterCeiling < 0 || c.FM.LimiterCeiling > 2 { return fmt.Errorf("fm.limiterCeiling out of range") } + if c.FM.MpxGain == 0 { c.FM.MpxGain = 1.0 } // default if omitted from JSON + if c.FM.MpxGain < 0.1 || c.FM.MpxGain > 5 { + return fmt.Errorf("fm.mpxGain out of range (0.1..5)") + } if c.Backend.Kind == "" { return fmt.Errorf("backend.kind is required") } diff --git a/internal/control/control.go b/internal/control/control.go index e0cf0e8..7c74e7f 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -3,9 +3,11 @@ package control import ( _ "embed" "encoding/json" + "io" "net/http" "sync" + "github.com/jan/fm-rds-tx/internal/audio" "github.com/jan/fm-rds-tx/internal/config" drypkg "github.com/jan/fm-rds-tx/internal/dryrun" "github.com/jan/fm-rds-tx/internal/platform" @@ -39,10 +41,11 @@ type LivePatch struct { } type Server struct { - mu sync.RWMutex - cfg config.Config - tx TXController - drv platform.SoapyDriver // optional, for runtime stats + mu sync.RWMutex + cfg config.Config + tx TXController + drv platform.SoapyDriver // optional, for runtime stats + streamSrc *audio.StreamSource // optional, for live audio ingest } type ConfigPatch struct { @@ -78,6 +81,12 @@ func (s *Server) SetDriver(drv platform.SoapyDriver) { s.mu.Unlock() } +func (s *Server) SetStreamSource(src *audio.StreamSource) { + s.mu.Lock() + s.streamSrc = src + s.mu.Unlock() +} + func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/", s.handleUI) @@ -88,6 +97,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/runtime", s.handleRuntime) mux.HandleFunc("/tx/start", s.handleTXStart) mux.HandleFunc("/tx/stop", s.handleTXStop) + mux.HandleFunc("/audio/stream", s.handleAudioStream) return mux } @@ -128,6 +138,7 @@ func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) { s.mu.RLock() drv := s.drv tx := s.tx + stream := s.streamSrc s.mu.RUnlock() result := map[string]any{} @@ -137,10 +148,56 @@ func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) { if tx != nil { result["engine"] = tx.TXStats() } + if stream != nil { + result["audioStream"] = stream.Stats() + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(result) } +// handleAudioStream accepts raw S16LE stereo PCM via HTTP POST and pushes +// it into the live audio ring buffer. Use with: +// curl -X POST --data-binary @- http://host:8088/audio/stream < audio.raw +// ffmpeg ... -f s16le -ar 44100 -ac 2 - | curl -X POST --data-binary @- http://host:8088/audio/stream +func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.mu.RLock() + stream := s.streamSrc + s.mu.RUnlock() + + if stream == nil { + http.Error(w, "audio stream not configured (use --audio-stdin or --audio-http)", http.StatusServiceUnavailable) + return + } + + // Read body in chunks and push to ring buffer + buf := make([]byte, 32768) + totalFrames := 0 + for { + n, err := r.Body.Read(buf) + if n > 0 { + totalFrames += stream.WritePCM(buf[:n]) + } + if err != nil { + if err == io.EOF { + break + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "frames": totalFrames, + "stats": stream.Stats(), + }) +} + func (s *Server) handleTXStart(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) diff --git a/internal/control/ui.html b/internal/control/ui.html index 1a70e9c..8445eca 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -9,11 +9,15 @@ :root { --bg: #0a0a0c; + --bg-2: #0f1015; --surface: #111116; --surface2: #18181e; + --surface3: #1f2028; --border: #2a2a35; + --border-strong: #3a3a49; --text: #d4d4dc; - --text-dim: #6a6a78; + --text-dim: #8b8b99; + --text-muted: #666674; --accent: #ff3b30; --accent-glow: #ff3b3044; --green: #30d158; @@ -21,15 +25,20 @@ --amber: #ff9f0a; --amber-glow: #ff9f0a44; --blue: #0a84ff; + --blue-glow: #0a84ff33; --mono: 'JetBrains Mono', monospace; --display: 'Archivo Black', sans-serif; - --radius: 6px; + --radius: 8px; + --shadow: 0 10px 30px rgba(0,0,0,.25); } * { box-sizing: border-box; margin: 0; padding: 0; } - +html { color-scheme: dark; } body { - background: var(--bg); + background: + radial-gradient(circle at top right, rgba(10,132,255,.06), transparent 28%), + radial-gradient(circle at top left, rgba(255,59,48,.06), transparent 30%), + var(--bg); color: var(--text); font-family: var(--mono); font-size: 13px; @@ -38,52 +47,83 @@ body { 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; + background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.015) 2px, rgba(255,255,255,0.015) 4px); + pointer-events: none; + z-index: 1000; } +button, input { font: inherit; } +button { user-select: none; } + .app { - max-width: 900px; + max-width: 1120px; margin: 0 auto; - padding: 16px; + padding: 18px; } -/* Header */ .header { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; - padding: 16px 0 24px; + gap: 18px; + padding: 8px 0 22px; border-bottom: 1px solid var(--border); - margin-bottom: 20px; + margin-bottom: 18px; +} +.header-main { + display: flex; + flex-direction: column; + gap: 8px; } - .header h1 { font-family: var(--display); - font-size: 22px; + font-size: 24px; letter-spacing: 2px; text-transform: uppercase; color: var(--accent); text-shadow: 0 0 20px var(--accent-glow), 0 0 40px var(--accent-glow); } - +.header-sub { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.badge { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 28px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: rgba(255,255,255,0.02); + color: var(--text-dim); + font-size: 11px; + text-transform: uppercase; + letter-spacing: .8px; +} +.badge strong { + color: var(--text); + font-weight: 700; +} .header-status { display: flex; align-items: center; - gap: 12px; + gap: 10px; + padding-top: 6px; } -/* LED indicator */ .led { - width: 10px; height: 10px; + width: 10px; + height: 10px; border-radius: 50%; background: #333; box-shadow: none; - transition: all 0.3s; + transition: all .25s ease; + flex-shrink: 0; } .led.on-green { background: var(--green); @@ -97,779 +137,1843 @@ body::before { background: var(--amber); box-shadow: 0 0 8px var(--amber), 0 0 20px var(--amber-glow); } +.led.on-blue { + background: var(--blue); + box-shadow: 0 0 8px var(--blue), 0 0 20px var(--blue-glow); +} -/* TX control bar */ -.tx-bar { - display: flex; - gap: 10px; - align-items: center; - background: var(--surface); +.status-text { + font-size: 10px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 1.2px; +} + +.layout { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(310px, .75fr); + gap: 14px; + align-items: start; +} +.stack { display: flex; flex-direction: column; gap: 12px; } + +.card { + background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)); border: 1px solid var(--border); border-radius: var(--radius); - padding: 12px 16px; - margin-bottom: 16px; + box-shadow: var(--shadow); +} + +.hero { + padding: 16px; + position: relative; + overflow: hidden; +} +.hero.tx-live::after { + content: ''; + position: absolute; + inset: -40%; + background: radial-gradient(circle, rgba(48,209,88,.12), transparent 55%); + animation: pulseGlow 2.8s ease-in-out infinite; + pointer-events: none; +} +.hero.tx-busy::after { + content: ''; + position: absolute; + inset: -50%; + background: conic-gradient(from 0deg, transparent, rgba(255,159,10,.12), transparent 45%); + animation: spinWash 2s linear infinite; + pointer-events: none; +} +@keyframes pulseGlow { + 0%, 100% { transform: scale(.95); opacity: .45; } + 50% { transform: scale(1.05); opacity: .8; } +} +@keyframes spinWash { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +@keyframes blinkSoft { + 0%, 100% { opacity: 1; } + 50% { opacity: .55; } } -.tx-bar .freq-display { +.tx-bar { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(180px, 250px) 1fr auto; + gap: 14px; + align-items: center; +} + +.freq-display-wrap { + display: flex; + flex-direction: column; + gap: 6px; +} +.freq-display-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 1.4px; + color: var(--text-dim); +} +.freq-display { font-family: var(--display); - font-size: 32px; + font-size: 38px; color: var(--green); text-shadow: 0 0 15px var(--green-glow); letter-spacing: 1px; - min-width: 200px; + line-height: 1; } -.tx-bar .freq-display .unit { +.freq-display .unit { font-family: var(--mono); font-size: 14px; color: var(--text-dim); - margin-left: 4px; + margin-left: 5px; } -.tx-btn { - padding: 8px 20px; - border: 1px solid var(--border); +.tx-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.tx-btn, .ghost-btn, .apply-btn, .preset-btn, .danger-btn { + min-height: 40px; + padding: 0 18px; border-radius: var(--radius); + border: 1px solid var(--border); background: var(--surface2); color: var(--text); - font-family: var(--mono); - font-size: 12px; - font-weight: 600; cursor: pointer; + font-size: 12px; + font-weight: 700; text-transform: uppercase; letter-spacing: 1px; - transition: all 0.15s; + transition: all .16s ease; +} +.tx-btn:hover, .ghost-btn:hover, .apply-btn:hover, .preset-btn:hover, .danger-btn:hover { + transform: translateY(-1px); + border-color: var(--border-strong); +} +.tx-btn:disabled, .ghost-btn:disabled, .apply-btn:disabled, .preset-btn:disabled, .danger-btn:disabled { + opacity: .45; + cursor: not-allowed; + transform: none; } -.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.start:hover:not(:disabled) { background: rgba(48,209,88,.1); } .tx-btn.stop { border-color: var(--accent); color: var(--accent); } -.tx-btn.stop:hover { background: var(--accent); color: #fff; } +.tx-btn.stop:hover:not(:disabled) { background: rgba(255,59,48,.1); } +.ghost-btn { color: var(--text-dim); } +.danger-btn { + border-color: rgba(255,59,48,.45); + color: var(--accent); + background: rgba(255,59,48,.04); +} +.danger-btn:hover:not(:disabled) { + background: rgba(255,59,48,.12); +} +.tx-state-wrap { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; +} .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); } +.tx-state.running { color: var(--green); animation: blinkSoft 2s ease-in-out infinite; } +.tx-state.idle, .tx-state.stopped { color: var(--text-dim); } +.tx-state.starting, .tx-state.stopping, .tx-state.working { color: var(--amber); animation: blinkSoft 1.1s ease-in-out infinite; } +.tx-state.error { color: var(--accent); } +.status-hint { + font-size: 10px; + color: var(--text-muted); + text-align: right; +} -/* Telemetry strip */ -.telem { - display: flex; - gap: 1px; - background: var(--border); - border-radius: var(--radius); - overflow: hidden; - margin-bottom: 16px; +.quick-grid { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin-top: 16px; } -.telem-cell { - flex: 1; - background: var(--surface); - padding: 10px 12px; - text-align: center; +.quick-item { + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-2); } -.telem-cell .label { +.quick-item .label { font-size: 9px; text-transform: uppercase; - letter-spacing: 1.5px; + letter-spacing: 1.4px; color: var(--text-dim); - margin-bottom: 4px; + margin-bottom: 6px; } -.telem-cell .value { - font-size: 16px; +.quick-item .value { + font-size: 18px; font-weight: 700; color: var(--text); } -.telem-cell .value.warn { color: var(--amber); } -.telem-cell .value.err { color: var(--accent); } +.quick-item .value.warn { color: var(--amber); } +.quick-item .value.err { color: var(--accent); } +.quick-item .value.good { color: var(--green); } -/* Section panels */ -.panel { - background: var(--surface); +.signal-grid { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-top: 12px; +} +.signal-card { + padding: 12px; border: 1px solid var(--border); border-radius: var(--radius); - margin-bottom: 12px; + background: var(--bg-2); +} +.signal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; +} +.signal-title { + font-size: 10px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 1.2px; +} +.signal-value { + font-size: 11px; + color: var(--text); + font-weight: 700; +} +.meter { + width: 100%; + height: 10px; + border-radius: 999px; + background: #171821; + border: 1px solid var(--border); + overflow: hidden; +} +.meter-fill { + height: 100%; + width: 0%; + transition: width .25s ease, background-color .25s ease; + background: linear-gradient(90deg, var(--green), #5cff90); +} +.meter-fill.warn { + background: linear-gradient(90deg, var(--amber), #ffc45b); +} +.meter-fill.err { + background: linear-gradient(90deg, var(--accent), #ff6b63); +} +.spark { + width: 100%; + height: 34px; + margin-top: 10px; + border-radius: 6px; + background: rgba(255,255,255,0.01); + border: 1px solid rgba(255,255,255,0.03); +} +.spark path.line { + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} +.spark path.area { + opacity: .14; +} +.spark.good path.line { stroke: var(--green); } +.spark.good path.area { fill: var(--green); } +.spark.warn path.line { stroke: var(--amber); } +.spark.warn path.area { fill: var(--amber); } +.spark.err path.line { stroke: var(--accent); } +.spark.err path.area { fill: var(--accent); } + +.panel { overflow: hidden; } .panel-head { display: flex; align-items: center; gap: 8px; - padding: 10px 14px; + padding: 12px 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; + font-weight: 700; text-transform: uppercase; - letter-spacing: 2px; + letter-spacing: 1.6px; color: var(--text-dim); } -.panel-head .chevron { +.panel-head .meta { margin-left: auto; + margin-right: 8px; + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 1px; +} +.panel-head .chevron { color: var(--text-dim); - transition: transform 0.2s; + transition: transform .2s ease; font-size: 10px; } .panel-head.collapsed .chevron { transform: rotate(-90deg); } .panel-body { padding: 14px; } .panel-body.collapsed { display: none; } -/* Form controls */ +.section-note { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 12px; +} +.shortcuts-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 12px; +} +.shortcut-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 7px 0; + border-bottom: 1px solid #1a1a22; +} +.shortcut-line:last-child { border-bottom: none; } +.shortcut-line .name { font-size: 11px; color: var(--text-dim); } +.shortcut-line .keys { + display: inline-flex; + gap: 6px; + flex-wrap: wrap; +} +.kbd { + min-width: 28px; + padding: 3px 7px; + border: 1px solid var(--border); + border-bottom-width: 2px; + border-radius: 6px; + background: var(--bg-2); + font-size: 10px; + color: var(--text); + text-align: center; +} + +.preset-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} +.preset-btn { + min-height: 34px; + padding: 0 12px; + font-size: 11px; + letter-spacing: .8px; + color: var(--text-dim); +} +.preset-btn.active { + border-color: var(--blue); + color: var(--blue); + background: rgba(10,132,255,.08); +} +.preset-btn.rds { + text-transform: none; + font-weight: 600; +} + .ctrl-row { display: flex; align-items: center; gap: 12px; - padding: 6px 0; + padding: 10px 0; border-bottom: 1px solid #1a1a22; } .ctrl-row:last-child { border-bottom: none; } - +.ctrl-label-wrap { + min-width: 130px; + display: flex; + flex-direction: column; + gap: 2px; +} .ctrl-label { font-size: 11px; color: var(--text-dim); - min-width: 110px; text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: .8px; +} +.ctrl-sub { + font-size: 10px; + color: var(--text-muted); } - .ctrl-input { flex: 1; display: flex; align-items: center; - gap: 8px; + gap: 10px; } input[type="range"] { -webkit-appearance: none; appearance: none; flex: 1; - height: 4px; - background: var(--border); - border-radius: 2px; + height: 6px; + background: linear-gradient(90deg, var(--border), var(--surface3)); + border-radius: 999px; outline: none; } input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; - width: 14px; height: 14px; + width: 16px; + height: 16px; border-radius: 50%; background: var(--text); border: 2px solid var(--bg); cursor: pointer; - transition: background 0.15s; + transition: background .15s ease, transform .15s ease; +} +input[type="range"]::-webkit-slider-thumb:hover { + background: var(--accent); + transform: scale(1.06); } -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; + border-radius: 6px; color: var(--text); - font-family: var(--mono); - font-size: 13px; - padding: 5px 8px; - width: 80px; + padding: 8px 10px; outline: none; - transition: border-color 0.15s; + transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease; +} +input[type="number"] { + width: 92px; + text-align: right; } input[type="text"] { width: 100%; } -input:focus { border-color: var(--accent); } - +input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(255,59,48,.12); +} +input.input-dirty { + border-color: var(--amber); + box-shadow: 0 0 0 3px rgba(255,159,10,.08); +} +input.input-error { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(255,59,48,.14); + background: rgba(255,59,48,.04); +} .val-display { - font-size: 12px; - font-weight: 600; - min-width: 55px; + min-width: 64px; text-align: right; + font-size: 12px; + font-weight: 700; color: var(--text); } +.unit-label { + font-size: 11px; + color: var(--text-dim); + min-width: 44px; +} +.field-error { + display: none; + margin-top: 8px; + font-size: 11px; + color: var(--accent); +} +.field-error.show { display: block; } -/* Toggle switch */ +.toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 12px 0; + border-bottom: 1px solid #1a1a22; +} +.toggle-row:last-child { border-bottom: none; } +.toggle-copy { + display: flex; + flex-direction: column; + gap: 3px; +} +.toggle-copy .title { + font-size: 12px; + color: var(--text); + font-weight: 700; +} +.toggle-copy .sub { + font-size: 10px; + color: var(--text-muted); +} +.toggle-ctl { + display: flex; + align-items: center; + gap: 10px; +} .toggle { position: relative; - width: 36px; height: 20px; + width: 42px; + height: 24px; background: var(--border); - border-radius: 10px; + border-radius: 999px; cursor: pointer; - transition: background 0.2s; + transition: all .2s ease; flex-shrink: 0; } -.toggle.on { background: var(--green); } .toggle::after { content: ''; position: absolute; - top: 2px; left: 2px; - width: 16px; height: 16px; + top: 3px; + left: 3px; + width: 18px; + height: 18px; background: var(--text); border-radius: 50%; - transition: transform 0.2s; + transition: transform .2s ease; +} +.toggle.on { background: var(--green); } +.toggle.on::after { transform: translateX(18px); } +.toggle.busy { opacity: .55; pointer-events: none; } +.toggle-state { + min-width: 52px; + text-align: right; + font-size: 11px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 1px; } -.toggle.on::after { transform: translateX(16px); } -/* RDS section */ +.rds-grid { + display: grid; + gap: 12px; +} +.rds-field { + display: flex; + flex-direction: column; + gap: 6px; +} .rds-input { width: 100%; background: var(--bg); border: 1px solid var(--border); - border-radius: 4px; + border-radius: 6px; color: var(--green); font-family: var(--mono); font-size: 15px; font-weight: 700; - padding: 8px 10px; + padding: 10px 12px; 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); + text-transform: none; + letter-spacing: .5px; + font-size: 12px; + font-weight: 500; } .rds-charcount { font-size: 10px; color: var(--text-dim); text-align: right; - margin-top: 2px; } -/* Apply button */ +.actions-row { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 14px; +} .apply-btn { - display: block; - width: 100%; - padding: 10px; - margin-top: 8px; background: var(--accent); - border: none; - border-radius: var(--radius); + border-color: transparent; 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.secondary { + background: var(--surface2); + color: var(--text-dim); + border-color: var(--border); } -.apply-btn:hover { filter: brightness(1.2); } -.apply-btn.sending { - opacity: 0.6; - pointer-events: none; +.apply-btn.ok { background: var(--green); color: var(--bg); } + +.sidebar-card { + padding: 14px; } -.apply-btn.ok { - background: var(--green); +.sidebar-section + .sidebar-section { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--border); } - -/* Toast notification */ -.toast { - position: fixed; - bottom: 20px; - right: 20px; - padding: 10px 16px; - border-radius: var(--radius); +.sidebar-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1.4px; + color: var(--text-dim); + margin-bottom: 10px; +} +.kv { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 12px; + align-items: start; +} +.kv .k { + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 1px; +} +.kv .v { font-size: 12px; - font-weight: 600; - z-index: 2000; - transform: translateY(60px); - opacity: 0; - transition: all 0.3s; + color: var(--text); + word-break: break-word; } -.toast.show { transform: translateY(0); opacity: 1; } -.toast.ok { background: var(--green); color: var(--bg); } -.toast.err { background: var(--accent); color: #fff; } +.health-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid #1a1a22; +} +.health-line:last-child { border-bottom: none; } +.health-line .name { + font-size: 11px; + color: var(--text-dim); +} +.health-line .val { + font-size: 11px; + color: var(--text); + text-align: right; +} +.health-line .val.good { color: var(--green); } +.health-line .val.warn { color: var(--amber); } +.health-line .val.err { color: var(--accent); } -/* Log */ .log { background: var(--bg); border: 1px solid var(--border); - border-radius: 4px; - padding: 8px 10px; + border-radius: 6px; + padding: 10px; font-size: 10px; color: var(--text-dim); - max-height: 120px; + max-height: 220px; overflow-y: auto; white-space: pre-wrap; - word-break: break-all; + word-break: break-word; } -.log .entry { padding: 1px 0; } +.log .entry { padding: 3px 0; } .log .entry.err { color: var(--accent); } .log .entry.ok { color: var(--green); } +.log .entry.warn { color: var(--amber); } +.log .entry.info { color: var(--blue); } +.empty-log { + color: var(--text-muted); +} -/* 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%; } +.toast { + position: fixed; + right: 16px; + bottom: 16px; + max-width: min(420px, calc(100vw - 24px)); + padding: 12px 15px; + border-radius: var(--radius); + font-size: 12px; + font-weight: 700; + z-index: 2000; + transform: translateY(60px); + opacity: 0; + transition: all .25s ease; + box-shadow: var(--shadow); +} +.toast.show { transform: translateY(0); opacity: 1; } +.toast.ok { background: var(--green); color: var(--bg); } +.toast.err { background: var(--accent); color: #fff; } +.toast.info { background: var(--blue); color: #fff; } +.toast.warn { background: var(--amber); color: #141414; } + +@media (max-width: 980px) { + .layout { grid-template-columns: 1fr; } + .tx-bar { + grid-template-columns: 1fr; + align-items: stretch; + } + .tx-state-wrap { + align-items: flex-start; + } + .status-hint { text-align: left; } + .quick-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .signal-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 640px) { + .app { padding: 12px; } + .header { flex-direction: column; align-items: stretch; gap: 10px; } + .header h1 { font-size: 22px; } + .header-sub { gap: 6px; } + .badge { width: 100%; justify-content: space-between; } + .quick-grid { grid-template-columns: 1fr 1fr; gap: 8px; } + .quick-item { padding: 10px; } + .quick-item .value { font-size: 16px; } + .ctrl-row { flex-direction: column; align-items: stretch; } + .ctrl-label-wrap { min-width: auto; } + .ctrl-input { flex-wrap: wrap; } + input[type="number"] { width: 100%; text-align: left; } + .actions-row, .tx-actions { flex-direction: column; } + .tx-btn, .ghost-btn, .apply-btn, .preset-btn, .danger-btn { width: 100%; } + .panel-head { padding: 11px 12px; } + .panel-body, .sidebar-card { padding: 12px; } + .freq-display { font-size: 31px; } + .preset-row { flex-direction: column; } + .shortcuts-grid { grid-template-columns: 1fr; } }
-