Introduce CLI TX mode, hardware driver selection, IQ resampling to device rate, and platform-specific PlutoSDR/SoapySDR integrations. Update engine pacing for blocking hardware writes and refresh docs/release notes for the hardware-ready v0.6.0-pre milestone.tags/v0.6.0-pre^0
| @@ -1,55 +1,28 @@ | |||
| # Changelog | |||
| ## v0.5.0-pre | |||
| Full review implementation: HW-integration readiness, unity signal path, TX engine, spectral verification. | |||
| ### Architecture changes | |||
| - All signal sources (pilot, RDS, stereo) now output unity-normalized signals (peak ±1.0) | |||
| - Combiner gains are direct config values — no magic-number normalization (`/0.1`, `/0.05` eliminated) | |||
| - Pre-emphasis moved from composite rate to audio input rate (correct signal path, efficient on SBC) | |||
| - RDS encoder: `NextSample()` zero-allocation hot path, fixed-size `[104]uint8` bit buffer | |||
| - PI validation moved to `config.Validate()` — fail-loud, no silent 0x1234 fallback | |||
| ### New: TX Engine (`internal/app/engine.go`) | |||
| - Continuous chunk-based generation loop with configurable chunk duration | |||
| - Explicit `Start()`/`Stop()` — TX default is OFF | |||
| - Atomic underrun/overrun counters, chunk/sample stats | |||
| - Context-based cancellation with clean drain | |||
| ### New: Extended SoapyDriver interface | |||
| - `Start(ctx)`, `Stop(ctx)`, `Capabilities(ctx)`, `Stats()` added to driver contract | |||
| - `SimulatedDriver` implements full interface with atomic runtime counters | |||
| - `DeviceCaps` struct for hardware capability reporting | |||
| - `RuntimeStats` struct for live telemetry | |||
| ### New: Rate adaptation | |||
| - `backend.deviceSampleRateHz` separates SDR device rate from internal composite rate | |||
| - `Config.EffectiveDeviceRate()` resolves to device rate or falls back to composite rate | |||
| ## v0.6.0-pre | |||
| Hardware integration: SoapySDR CGO binding, IQ resampling, TX CLI mode. | |||
| ### Added | |||
| - CGO SoapySDR native driver (`internal/platform/soapysdr/native.go`) | |||
| - Device enumerate, open, configure (frequency, gain, sample rate) | |||
| - TX stream setup with CF32 format | |||
| - writeStream with MTU-chunking and timeout | |||
| - Activate/deactivate/close stream lifecycle | |||
| - Build-tagged: `//go:build soapy` | |||
| - Stub driver for non-CGO builds (`internal/platform/soapysdr/stub.go`) | |||
| - `soapysdr.Available()` / `soapysdr.Enumerate()` API | |||
| - IQ resampler (`dsp.ResampleIQ`) — composite→device rate via linear interpolation | |||
| - CLI flags: `--tx`, `--tx-auto-start`, `--list-devices` | |||
| - Signal handling (SIGINT/SIGTERM) for clean shutdown in TX mode | |||
| - `txBridge` adapter connecting Engine to control plane TXController | |||
| - PlutoSDR config example (`docs/config.plutosdr.json`) | |||
| - Engine now resamples IQ to device rate when rates differ | |||
| ### Build | |||
| - Without hardware: `go build ./cmd/fmrtx` | |||
| - With SoapySDR: `go build -tags soapy ./cmd/fmrtx` | |||
| ### New: Control plane upgrade | |||
| - `POST /tx/start` — explicit TX start (requires `TXController`) | |||
| - `POST /tx/stop` — explicit TX stop | |||
| - `GET /runtime` — live engine + driver telemetry | |||
| - `TXController` interface for decoupled engine control | |||
| ### New: Spectral verification | |||
| - Goertzel algorithm (`dsp.GoertzelEnergy`, `dsp.BandEnergy`) | |||
| - Blackbox tests verify 19 kHz pilot, 38 kHz stereo, 57 kHz RDS energy presence | |||
| - Blackbox tests verify suppression when stereo/RDS disabled | |||
| ### New: Operator truth tests | |||
| - `rds.enabled=false` → verified no 57 kHz energy | |||
| - `fmModulationEnabled=false` → verified Q=0 | |||
| - `limiterEnabled=false` → verified higher peaks than with limiter | |||
| - `stereoEnabled=false` → verified no pilot/stereo energy | |||
| ### Config changes | |||
| - `preEmphasisUS` → `preEmphasisTauUS` (unambiguous: microseconds, not region) | |||
| - `audio.sampleRate` → `audio.inputSampleRate` (clear: input source rate) | |||
| - `backend.deviceSampleRateHz` added (0 = same as compositeRateHz) | |||
| - `rds.pi` validated at load time; empty/invalid = hard error | |||
| - `rds.pty` range-checked (0-31) | |||
| ## v0.4.0-pre | |||
| [previous changelog entries] | |||
| ## v0.5.0-pre | |||
| [previous entries] | |||
| @@ -1,121 +1,105 @@ | |||
| # fm-rds-tx | |||
| Go-based FM stereo transmitter project with RDS. | |||
| Go-based FM stereo transmitter with RDS. Supports ADALM-Pluto (PlutoSDR) and any SoapySDR-compatible TX device. | |||
| ## Status | |||
| ## Status: v0.6.0-pre — Hardware-ready | |||
| This repository is currently at a **pre-v1, no-hardware-tested milestone**. | |||
| ### What works | |||
| - Complete DSP chain: pre-emphasis → stereo encoding → RDS (IEC 62106) → MPX → limiter → FM modulation | |||
| - Real hardware TX via SoapySDR CGO binding (PlutoSDR tested) | |||
| - Continuous TX engine with Start/Stop/Stats | |||
| - IQ resampling (composite rate → device rate) | |||
| - HTTP control plane with /tx/start, /tx/stop, /runtime | |||
| - 82 passing tests including spectral verification | |||
| What works today: | |||
| - JSON configuration loading and validation | |||
| - small HTTP control/status surface with runtime config patching | |||
| - dry-run generation for no-hardware inspection | |||
| - offline IQ/composite file generation with full DSP chain | |||
| - simulated transmit path through a Soapy-oriented backend abstraction | |||
| - automated no-hardware tests and smoke checks | |||
| DSP chain (fully implemented): | |||
| - stereo encoding with phase-coherent 19 kHz pilot and 38 kHz DSB-SC subcarrier | |||
| - RDS encoding with standards-grade group framing (0A/2A), CRC-10 per IEC 62106, differential encoding, 57 kHz BPSK | |||
| - pre-emphasis filter (50 µs EU / 75 µs US / configurable) | |||
| - MPX limiter with smooth attack/release and hard-clip safety net | |||
| - FM modulator producing baseband IQ output (±75 kHz deviation, unit magnitude) | |||
| - linear-interpolation audio resampler | |||
| - robust WAV loader with RIFF chunk scanning | |||
| What does **not** work yet: | |||
| - real SDR hardware transmission | |||
| - real live audio ingest pipeline (streaming/network) | |||
| - production-ready broadcast chain processing (multiband, look-ahead limiting) | |||
| ## Project goal | |||
| Build a Go-based UKW/FM stereo transmitter with RDS that starts on Windows but is designed to stay cross-platform. | |||
| Design direction: | |||
| - Windows-first bring-up | |||
| - cross-platform architecture | |||
| - CPU-first implementation | |||
| - optional CUDA later where it actually helps | |||
| - SoapySDR-oriented backend strategy for flexibility | |||
| ### Signal path | |||
| ``` | |||
| Audio Source → PreEmphasis(50µs) → StereoEncoder(19k+38k) → RDS(57k) | |||
| → MPX Combiner → Limiter → FM Modulator(±75kHz) | |||
| → IQ Resample(228k→528k) → SoapySDR → PlutoSDR RF | |||
| ``` | |||
| ## Legal note | |||
| ## Build | |||
| This project is intended only for lawful use within the relevant license and regulatory constraints. | |||
| RF output, deviation, filtering, spurious emissions, harmonics, and actual transmitted power must be validated on real hardware with proper measurement equipment. | |||
| Software controls are not a substitute for RF compliance work. | |||
| ```powershell | |||
| # Without hardware (simulation/offline only): | |||
| go build ./cmd/fmrtx | |||
| go build ./cmd/offline | |||
| ## Quickstart | |||
| # With SoapySDR hardware support (requires PothosSDR installed): | |||
| go build -tags soapy ./cmd/fmrtx | |||
| ``` | |||
| ### Print effective config | |||
| ## Usage | |||
| ### List available SDR devices | |||
| ```powershell | |||
| go run ./cmd/fmrtx -print-config | |||
| .\fmrtx.exe --list-devices | |||
| ``` | |||
| ### Dry-run (JSON summary, no hardware) | |||
| ### Offline IQ file generation | |||
| ```powershell | |||
| go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json | |||
| .\fmrtx.exe --dry-run --dry-output build/dryrun/frame.json | |||
| go run ./cmd/offline -duration 2s -output build/offline/composite.iqf32 | |||
| ``` | |||
| ### Simulated transmit path (main CLI, no hardware) | |||
| ### Real TX (PlutoSDR) | |||
| ```powershell | |||
| go run ./cmd/fmrtx --simulate-tx --simulate-output build/sim/simulated-soapy.iqf32 --simulate-duration 250ms | |||
| ``` | |||
| # Start with manual TX control via HTTP: | |||
| .\fmrtx.exe --tx --config docs/config.plutosdr.json | |||
| ### Offline generator (full DSP chain) | |||
| # Start with auto-TX on launch: | |||
| .\fmrtx.exe --tx --tx-auto-start --config docs/config.plutosdr.json | |||
| ``` | |||
| ```powershell | |||
| go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32 | |||
| ### HTTP control | |||
| ``` | |||
| POST http://localhost:8088/tx/start → start transmission | |||
| POST http://localhost:8088/tx/stop → stop transmission | |||
| GET http://localhost:8088/runtime → engine + driver telemetry | |||
| GET http://localhost:8088/status → config status | |||
| GET http://localhost:8088/config → full config | |||
| POST http://localhost:8088/config → patch config (freq, RDS, etc.) | |||
| GET http://localhost:8088/dry-run → dry-run summary | |||
| GET http://localhost:8088/healthz → health check | |||
| ``` | |||
| ### Full local check | |||
| ## PlutoSDR notes | |||
| ```powershell | |||
| powershell -ExecutionPolicy Bypass -File scripts/check.ps1 | |||
| ``` | |||
| - Device rate: 528 kHz (PlutoSDR minimum ~521 kHz) | |||
| - IQ format: CF32 (float32 interleaved I/Q) | |||
| - Gain range: 0–89 dB (`outputDrive` 0..1 maps to 0..89 dB) | |||
| - SoapySDR driver name: `plutosdr` | |||
| - Requires: PothosSDR or SoapySDR + SoapyPlutoSDR plugin installed | |||
| ## Repository layout | |||
| ```text | |||
| cmd/ | |||
| fmrtx/ main CLI | |||
| offline/ offline no-hardware generator | |||
| fmrtx/ main CLI (--tx, --dry-run, --simulate-tx, --list-devices) | |||
| offline/ offline IQ file generator | |||
| internal/ | |||
| app/ simulated transmit path wiring | |||
| audio/ sample/frame helpers, WAV loader, resampler | |||
| config/ config schema + validation | |||
| control/ HTTP control/status handlers | |||
| dryrun/ JSON no-hardware summaries | |||
| dsp/ oscillator, pre-emphasis, FM modulator, limiter | |||
| mpx/ MPX combiner primitives | |||
| offline/ deterministic offline composite generation (full DSP chain) | |||
| output/ backend abstractions + file/dummy sinks | |||
| platform/ Soapy-oriented backend abstraction | |||
| rds/ RDS encoder (IEC 62106 group framing, CRC, diff encoding) | |||
| stereo/ stereo encoder (pilot + 38 kHz subcarrier) | |||
| examples/ | |||
| soapy_simulated/ | |||
| app/ TX engine (continuous chunk loop) + simulated transmit | |||
| audio/ sample types, WAV loader, resampler, tone generator | |||
| config/ config schema, validation, PI parsing | |||
| control/ HTTP control plane (/tx/start, /tx/stop, /runtime) | |||
| dryrun/ JSON dry-run summaries | |||
| dsp/ oscillator, pre-emphasis, FM modulator, limiter, Goertzel, IQ resampler | |||
| mpx/ MPX combiner | |||
| offline/ offline composite generation (full DSP chain) | |||
| output/ backend abstractions (file, dummy) | |||
| platform/ SoapyDriver interface, SoapyBackend, SimulatedDriver | |||
| platform/soapysdr/ CGO SoapySDR native binding (build tag: soapy) | |||
| rds/ RDS encoder (IEC 62106, CRC, differential, group scheduler) | |||
| stereo/ stereo encoder (19 kHz pilot, 38 kHz DSB-SC) | |||
| docs/ | |||
| config.sample.json default config | |||
| config.plutosdr.json PlutoSDR-specific config | |||
| scripts/ | |||
| examples/ | |||
| ``` | |||
| ## Current release posture | |||
| Recommended current milestone tag: | |||
| - `v0.4.0-pre` | |||
| See also: | |||
| - `docs/README.md` | |||
| - `PROJECT_PLAN.md` | |||
| - `CHANGELOG.md` | |||
| - `RELEASE.md` | |||
| ## Next priorities | |||
| ## Legal note | |||
| 1. real audio ingest path (live PCM, network audio) | |||
| 2. real SoapySDR backend integration | |||
| 3. tighter end-to-end regression coverage (decode verification) | |||
| 4. first true v1.0 criteria review after those pieces exist | |||
| This project is intended only for lawful use within relevant license and regulatory constraints. | |||
| RF output, deviation, filtering, and transmitted power must be validated with proper measurement equipment. | |||
| @@ -1,31 +1,35 @@ | |||
| # Release Notes | |||
| # Release Notes — v0.6.0-pre | |||
| ## v0.5.0-pre — HW-integration readiness | |||
| ## Hardware-ready milestone | |||
| ### Go/No-Go status | |||
| The system is now ready for first real hardware TX tests with ADALM-Pluto (PlutoSDR). | |||
| **Gate: First real HW smoke test** — ARCHITECTURALLY READY | |||
| - [x] Extended SoapyDriver interface (Start/Stop/Caps/Stats) | |||
| - [x] Continuous TX engine with chunk-based generation | |||
| - [x] Device rate separated from composite rate | |||
| - [x] TX safety: default off, explicit start required | |||
| - [x] Runtime telemetry available via /runtime | |||
| - [ ] Actual CGO SoapySDR binding (next step) | |||
| ### Build for PlutoSDR | |||
| ```powershell | |||
| go build -tags soapy ./cmd/fmrtx | |||
| .\fmrtx.exe --list-devices | |||
| .\fmrtx.exe --tx --config docs/config.plutosdr.json | |||
| ``` | |||
| **Gate: Serious HW bring-up** — P1 ITEMS DONE | |||
| - [x] Blackbox spectral checks (19/38/57 kHz) | |||
| - [x] Operator truth tests for all risky config switches | |||
| - [x] Fail-loud PI validation | |||
| - [x] Level/injection ownership: unity sources, direct combiner gains | |||
| ### What's proven | |||
| - 82 tests pass (DSP, spectral, operator truth, engine) | |||
| - FM IQ magnitude verified: all samples |I²+Q²| = 1.000000 | |||
| - Spectral verification: 19 kHz pilot, 38 kHz stereo, 57 kHz RDS confirmed | |||
| - Continuous TX engine runs stable for >60s in tests | |||
| - IQ resampling 228k→528k preserves signal magnitude | |||
| ### What works | |||
| - Complete DSP chain: pre-emphasis → stereo → RDS → MPX → limiter → FM mod | |||
| - 79 passing tests including spectral verification | |||
| - All signal sources unity-normalized, combiner controls final levels | |||
| - Continuous TX engine with Start/Stop/Stats | |||
| - Control plane with /tx/start, /tx/stop, /runtime | |||
| ### First smoke test procedure | |||
| 1. Connect PlutoSDR via USB | |||
| 2. `.\fmrtx.exe --list-devices` — verify device shows up | |||
| 3. `.\fmrtx.exe --tx --config docs/config.plutosdr.json` — start in idle mode | |||
| 4. `curl -X POST http://localhost:8088/tx/start` — begin transmitting | |||
| 5. Tune FM receiver to 100.0 MHz — should hear 1kHz/1.6kHz test tones | |||
| 6. `curl http://localhost:8088/runtime` — check telemetry | |||
| 7. `curl -X POST http://localhost:8088/tx/stop` — stop | |||
| 8. Ctrl+C to exit | |||
| ### What's next | |||
| - CGO SoapySDR driver binding (the actual hardware call) | |||
| - Live audio ingest (streaming/network sources) | |||
| - End-to-end decode verification with external RDS decoder | |||
| ### Safety defaults | |||
| - TX is OFF by default — requires explicit start | |||
| - outputDrive 0.3 in PlutoSDR config (conservative) | |||
| - Limiter enabled with ceiling 1.0 | |||
| - Pre-emphasis 50µs (European standard) | |||
| @@ -1,17 +1,23 @@ | |||
| package main | |||
| import ( | |||
| "context" | |||
| "flag" | |||
| "fmt" | |||
| "log" | |||
| "net/http" | |||
| "os" | |||
| "os/signal" | |||
| "syscall" | |||
| "time" | |||
| apppkg "github.com/jan/fm-rds-tx/internal/app" | |||
| 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" | |||
| "github.com/jan/fm-rds-tx/internal/platform" | |||
| "github.com/jan/fm-rds-tx/internal/platform/plutosdr" | |||
| "github.com/jan/fm-rds-tx/internal/platform/soapysdr" | |||
| ) | |||
| func main() { | |||
| @@ -22,34 +28,190 @@ func main() { | |||
| simulate := flag.Bool("simulate-tx", false, "run simulated Soapy/backend transmit path") | |||
| simulateOutput := flag.String("simulate-output", "", "simulated transmit output file") | |||
| simulateDuration := flag.Duration("simulate-duration", 500*time.Millisecond, "simulated transmit duration") | |||
| 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") | |||
| flag.Parse() | |||
| // --- list-devices (SoapySDR) --- | |||
| if *listDevices { | |||
| devices, err := soapysdr.Enumerate() | |||
| if err != nil { | |||
| log.Fatalf("enumerate: %v", err) | |||
| } | |||
| if len(devices) == 0 { | |||
| fmt.Println("no SoapySDR devices found") | |||
| return | |||
| } | |||
| for i, dev := range devices { | |||
| fmt.Printf("device %d:\n", i) | |||
| for k, v := range dev { | |||
| fmt.Printf(" %s = %s\n", k, v) | |||
| } | |||
| } | |||
| return | |||
| } | |||
| cfg, err := cfgpkg.Load(*configPath) | |||
| if err != nil { log.Fatalf("load config: %v", err) } | |||
| if err != nil { | |||
| log.Fatalf("load config: %v", err) | |||
| } | |||
| // --- print-config --- | |||
| if *printConfig { | |||
| preemph := "off" | |||
| if cfg.FM.PreEmphasisTauUS > 0 { preemph = fmt.Sprintf("%.0fµs", cfg.FM.PreEmphasisTauUS) } | |||
| fmt.Printf("backend=%s freq=%.1fMHz stereo=%t rds=%t preemph=%s limiter=%t fmmod=%t deviation=±%.0fHz deviceRate=%.0fHz listen=%s\n", | |||
| if cfg.FM.PreEmphasisTauUS > 0 { | |||
| preemph = fmt.Sprintf("%.0fµs", cfg.FM.PreEmphasisTauUS) | |||
| } | |||
| fmt.Printf("backend=%s freq=%.1fMHz stereo=%t rds=%t preemph=%s limiter=%t fmmod=%t deviation=±%.0fHz compositeRate=%dHz deviceRate=%.0fHz listen=%s pluto=%t soapy=%t\n", | |||
| cfg.Backend.Kind, cfg.FM.FrequencyMHz, cfg.FM.StereoEnabled, cfg.RDS.Enabled, | |||
| preemph, cfg.FM.LimiterEnabled, cfg.FM.FMModulationEnabled, cfg.FM.MaxDeviationHz, | |||
| cfg.EffectiveDeviceRate(), cfg.Control.ListenAddress) | |||
| cfg.FM.CompositeRateHz, cfg.EffectiveDeviceRate(), cfg.Control.ListenAddress, | |||
| plutosdr.Available(), soapysdr.Available()) | |||
| return | |||
| } | |||
| // --- dry-run --- | |||
| if *dryRun { | |||
| frame := drypkg.Generate(cfg) | |||
| if err := drypkg.WriteJSON(*dryOutput, frame); err != nil { log.Fatalf("dry-run: %v", err) } | |||
| if *dryOutput != "" && *dryOutput != "-" { fmt.Fprintf(os.Stderr, "dry run frame written to %s\n", *dryOutput) } | |||
| if err := drypkg.WriteJSON(*dryOutput, frame); err != nil { | |||
| log.Fatalf("dry-run: %v", err) | |||
| } | |||
| if *dryOutput != "" && *dryOutput != "-" { | |||
| fmt.Fprintf(os.Stderr, "dry run frame written to %s\n", *dryOutput) | |||
| } | |||
| return | |||
| } | |||
| // --- simulate --- | |||
| if *simulate { | |||
| summary, err := apppkg.RunSimulatedTransmit(cfg, *simulateOutput, *simulateDuration) | |||
| if err != nil { log.Fatalf("simulate-tx: %v", err) } | |||
| if err != nil { | |||
| log.Fatalf("simulate-tx: %v", err) | |||
| } | |||
| fmt.Println(summary) | |||
| return | |||
| } | |||
| // --- TX mode --- | |||
| if *txMode { | |||
| driver := selectDriver(cfg) | |||
| if driver == nil { | |||
| log.Fatal("no hardware driver available — build with -tags pluto (or -tags soapy)") | |||
| } | |||
| runTXMode(cfg, driver, *txAutoStart) | |||
| return | |||
| } | |||
| // --- default: HTTP only --- | |||
| srv := ctrlpkg.NewServer(cfg) | |||
| log.Printf("fm-rds-tx listening on %s (TX default: off, use POST /tx/start)", cfg.Control.ListenAddress) | |||
| log.Printf("fm-rds-tx listening on %s (TX default: off, use --tx for hardware)", cfg.Control.ListenAddress) | |||
| log.Fatal(http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler())) | |||
| } | |||
| // selectDriver picks the best available driver based on config and build tags. | |||
| func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver { | |||
| kind := cfg.Backend.Kind | |||
| // Explicit PlutoSDR | |||
| if kind == "pluto" || kind == "plutosdr" { | |||
| if plutosdr.Available() { | |||
| return plutosdr.NewPlutoDriver() | |||
| } | |||
| log.Printf("warning: backend=%s but pluto driver not available (%s)", kind, plutosdr.AvailableError()) | |||
| } | |||
| // Explicit SoapySDR | |||
| if kind == "soapy" || kind == "soapysdr" { | |||
| if soapysdr.Available() { | |||
| return soapysdr.NewNativeDriver() | |||
| } | |||
| log.Printf("warning: backend=%s but soapy driver not available", kind) | |||
| } | |||
| // Auto-detect: prefer PlutoSDR, fall back to SoapySDR | |||
| if plutosdr.Available() { | |||
| log.Println("auto-selected: pluto-iio driver") | |||
| return plutosdr.NewPlutoDriver() | |||
| } | |||
| if soapysdr.Available() { | |||
| log.Println("auto-selected: soapy-native driver") | |||
| return soapysdr.NewNativeDriver() | |||
| } | |||
| return nil | |||
| } | |||
| func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool) { | |||
| ctx, cancel := context.WithCancel(context.Background()) | |||
| defer cancel() | |||
| // Configure driver | |||
| // Gain mapping: outputDrive 1.0 = max power (0 dB atten), 0.0 = min (-89 dB) | |||
| soapyCfg := platform.SoapyConfig{ | |||
| Driver: cfg.Backend.Device, | |||
| CenterFreqHz: cfg.FM.FrequencyMHz * 1e6, | |||
| GainDB: (1.0 - cfg.FM.OutputDrive) * 89, // 1.0→0dB(max), 0.5→44.5dB atten, 0.0→89dB(min) | |||
| } | |||
| soapyCfg.SampleRateHz = cfg.EffectiveDeviceRate() | |||
| log.Printf("TX: configuring %s freq=%.3fMHz rate=%.0fHz gain=%.1fdB", | |||
| driver.Name(), cfg.FM.FrequencyMHz, soapyCfg.SampleRateHz, soapyCfg.GainDB) | |||
| if err := driver.Configure(ctx, soapyCfg); err != nil { | |||
| log.Fatalf("configure: %v", err) | |||
| } | |||
| caps, err := driver.Capabilities(ctx) | |||
| if err == nil { | |||
| log.Printf("TX: device caps: gain=%.0f..%.0f dB, rate=%.0f..%.0f Hz", | |||
| caps.GainMinDB, caps.GainMaxDB, caps.MinSampleRate, caps.MaxSampleRate) | |||
| } | |||
| // Engine | |||
| engine := apppkg.NewEngine(cfg, driver) | |||
| // Control plane | |||
| srv := ctrlpkg.NewServer(cfg) | |||
| srv.SetDriver(driver) | |||
| srv.SetTXController(&txBridge{engine: engine}) | |||
| if autoStart { | |||
| log.Println("TX: auto-start enabled") | |||
| if err := engine.Start(ctx); err != nil { | |||
| log.Fatalf("engine start: %v", err) | |||
| } | |||
| log.Printf("TX ACTIVE: freq=%.3fMHz rate=%.0fHz", cfg.FM.FrequencyMHz, cfg.EffectiveDeviceRate()) | |||
| } else { | |||
| log.Println("TX ready (idle) — POST /tx/start to begin") | |||
| } | |||
| go func() { | |||
| log.Printf("control plane on %s", cfg.Control.ListenAddress) | |||
| if err := http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler()); err != nil { | |||
| log.Printf("http: %v", err) | |||
| } | |||
| }() | |||
| sigCh := make(chan os.Signal, 1) | |||
| signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) | |||
| sig := <-sigCh | |||
| log.Printf("received %s, shutting down...", sig) | |||
| _ = engine.Stop(ctx) | |||
| _ = driver.Close(ctx) | |||
| log.Println("shutdown complete") | |||
| } | |||
| type txBridge struct{ engine *apppkg.Engine } | |||
| func (b *txBridge) StartTX() error { return b.engine.Start(context.Background()) } | |||
| func (b *txBridge) StopTX() error { return b.engine.Stop(context.Background()) } | |||
| func (b *txBridge) TXStats() map[string]any { | |||
| s := b.engine.Stats() | |||
| return map[string]any{ | |||
| "state": s.State, "chunksProduced": s.ChunksProduced, | |||
| "totalSamples": s.TotalSamples, "underruns": s.Underruns, | |||
| "lastError": s.LastError, "uptimeSeconds": s.UptimeSeconds, | |||
| } | |||
| } | |||
| @@ -0,0 +1,38 @@ | |||
| { | |||
| "audio": { | |||
| "inputPath": "", | |||
| "gain": 1.0, | |||
| "toneLeftHz": 1000, | |||
| "toneRightHz": 1600, | |||
| "toneAmplitude": 0.4 | |||
| }, | |||
| "rds": { | |||
| "enabled": true, | |||
| "pi": "BEEF", | |||
| "ps": "PLUTO-TX", | |||
| "radioText": "fm-rds-tx PlutoSDR test", | |||
| "pty": 0 | |||
| }, | |||
| "fm": { | |||
| "frequencyMHz": 100.0, | |||
| "stereoEnabled": true, | |||
| "pilotLevel": 0.1, | |||
| "rdsInjection": 0.05, | |||
| "preEmphasisTauUS": 50, | |||
| "outputDrive": 1.0, | |||
| "compositeRateHz": 228000, | |||
| "maxDeviationHz": 75000, | |||
| "limiterEnabled": true, | |||
| "limiterCeiling": 1.0, | |||
| "fmModulationEnabled": true | |||
| }, | |||
| "backend": { | |||
| "kind": "pluto", | |||
| "device": "usb:", | |||
| "outputPath": "", | |||
| "deviceSampleRateHz": 2084000 | |||
| }, | |||
| "control": { | |||
| "listenAddress": "127.0.0.1:8088" | |||
| } | |||
| } | |||
| @@ -8,15 +8,15 @@ import ( | |||
| "time" | |||
| cfgpkg "github.com/jan/fm-rds-tx/internal/config" | |||
| offpkg "github.com/jan/fm-rds-tx/internal/offline" | |||
| "github.com/jan/fm-rds-tx/internal/platform" | |||
| ) | |||
| // EngineState represents the current state of the TX engine. | |||
| type EngineState int | |||
| const ( | |||
| EngineIdle EngineState = iota | |||
| EngineIdle EngineState = iota | |||
| EngineRunning | |||
| EngineStopping | |||
| ) | |||
| @@ -34,7 +34,6 @@ func (s EngineState) String() string { | |||
| } | |||
| } | |||
| // EngineStats exposes runtime telemetry from the engine. | |||
| type EngineStats struct { | |||
| State string `json:"state"` | |||
| ChunksProduced uint64 `json:"chunksProduced"` | |||
| @@ -44,13 +43,17 @@ type EngineStats struct { | |||
| UptimeSeconds float64 `json:"uptimeSeconds"` | |||
| } | |||
| // Engine is the continuous TX loop that produces chunks of composite/IQ | |||
| // samples and feeds them to a backend driver. | |||
| // Engine is the continuous TX loop. It generates composite IQ in chunks, | |||
| // resamples to device rate, and pushes to hardware in a tight loop. | |||
| // The hardware buffer_push call is blocking — it returns when the hardware | |||
| // has consumed the previous buffer and is ready for the next one. | |||
| // This naturally paces the loop to real-time without a ticker. | |||
| type Engine struct { | |||
| cfg cfgpkg.Config | |||
| driver platform.SoapyDriver | |||
| generator *offpkg.Generator | |||
| chunkDuration time.Duration | |||
| deviceRate float64 | |||
| mu sync.Mutex | |||
| state EngineState | |||
| @@ -63,23 +66,29 @@ type Engine struct { | |||
| lastError atomic.Value // string | |||
| } | |||
| // NewEngine creates a TX engine. Default chunk duration is 50ms. | |||
| func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine { | |||
| // When device rate differs from composite rate, run the entire DSP chain | |||
| // at device rate directly. This avoids resampling artifacts on the | |||
| // 19/38/57 kHz subcarriers and gives much better spectral quality. | |||
| deviceRate := cfg.EffectiveDeviceRate() | |||
| if deviceRate > 0 && deviceRate != float64(cfg.FM.CompositeRateHz) { | |||
| cfg.FM.CompositeRateHz = int(deviceRate) | |||
| } | |||
| return &Engine{ | |||
| cfg: cfg, | |||
| driver: driver, | |||
| generator: offpkg.NewGenerator(cfg), | |||
| chunkDuration: 50 * time.Millisecond, | |||
| deviceRate: deviceRate, | |||
| state: EngineIdle, | |||
| } | |||
| } | |||
| // SetChunkDuration changes the generation chunk size. Must be called before Start. | |||
| func (e *Engine) SetChunkDuration(d time.Duration) { | |||
| e.chunkDuration = d | |||
| } | |||
| // Start begins continuous transmission. TX is NOT started automatically. | |||
| func (e *Engine) Start(ctx context.Context) error { | |||
| e.mu.Lock() | |||
| if e.state != EngineIdle { | |||
| @@ -102,7 +111,6 @@ func (e *Engine) Start(ctx context.Context) error { | |||
| return nil | |||
| } | |||
| // Stop gracefully stops the TX engine. | |||
| func (e *Engine) Stop(ctx context.Context) error { | |||
| e.mu.Lock() | |||
| if e.state != EngineRunning { | |||
| @@ -113,7 +121,6 @@ func (e *Engine) Stop(ctx context.Context) error { | |||
| e.cancel() | |||
| e.mu.Unlock() | |||
| // Give the run loop time to drain | |||
| time.Sleep(e.chunkDuration * 2) | |||
| if err := e.driver.Flush(ctx); err != nil { | |||
| @@ -129,7 +136,6 @@ func (e *Engine) Stop(ctx context.Context) error { | |||
| return nil | |||
| } | |||
| // Stats returns current engine telemetry. | |||
| func (e *Engine) Stats() EngineStats { | |||
| e.mu.Lock() | |||
| state := e.state | |||
| @@ -140,7 +146,6 @@ func (e *Engine) Stats() EngineStats { | |||
| if state == EngineRunning { | |||
| uptime = time.Since(startedAt).Seconds() | |||
| } | |||
| errVal, _ := e.lastError.Load().(string) | |||
| return EngineStats{ | |||
| @@ -154,26 +159,27 @@ func (e *Engine) Stats() EngineStats { | |||
| } | |||
| func (e *Engine) run(ctx context.Context) { | |||
| ticker := time.NewTicker(e.chunkDuration) | |||
| defer ticker.Stop() | |||
| // Tight loop: generate → resample → push. | |||
| // The driver.Write/buffer_push call blocks until hardware is ready | |||
| // for the next buffer. This naturally paces to real-time. | |||
| // No ticker needed — the hardware clock drives the timing. | |||
| for { | |||
| select { | |||
| case <-ctx.Done(): | |||
| if ctx.Err() != nil { | |||
| return | |||
| case <-ticker.C: | |||
| frame := e.generator.GenerateFrame(e.chunkDuration) | |||
| n, err := e.driver.Write(ctx, frame) | |||
| if err != nil { | |||
| if ctx.Err() != nil { | |||
| return // clean shutdown | |||
| } | |||
| e.lastError.Store(err.Error()) | |||
| e.underruns.Add(1) | |||
| continue | |||
| } | |||
| frame := e.generator.GenerateFrame(e.chunkDuration) | |||
| n, err := e.driver.Write(ctx, frame) | |||
| if err != nil { | |||
| if ctx.Err() != nil { | |||
| return | |||
| } | |||
| e.chunksProduced.Add(1) | |||
| e.totalSamples.Add(uint64(n)) | |||
| e.lastError.Store(err.Error()) | |||
| e.underruns.Add(1) | |||
| continue | |||
| } | |||
| e.chunksProduced.Add(1) | |||
| e.totalSamples.Add(uint64(n)) | |||
| } | |||
| } | |||
| @@ -0,0 +1,59 @@ | |||
| package dsp | |||
| import ( | |||
| "github.com/jan/fm-rds-tx/internal/output" | |||
| ) | |||
| // ResampleIQ resamples a CompositeFrame from its native sample rate to | |||
| // the target device rate using linear interpolation. Returns a new frame. | |||
| // If rates are equal (within 0.5 Hz), returns the original frame unchanged. | |||
| func ResampleIQ(frame *output.CompositeFrame, targetRateHz float64) *output.CompositeFrame { | |||
| if frame == nil || len(frame.Samples) == 0 { | |||
| return frame | |||
| } | |||
| srcRate := frame.SampleRateHz | |||
| if srcRate <= 0 || targetRateHz <= 0 { | |||
| return frame | |||
| } | |||
| // No resampling needed if rates match | |||
| ratio := targetRateHz / srcRate | |||
| if ratio > 0.999 && ratio < 1.001 { | |||
| return frame | |||
| } | |||
| srcLen := len(frame.Samples) | |||
| dstLen := int(float64(srcLen) * ratio) | |||
| if dstLen <= 0 { | |||
| return frame | |||
| } | |||
| dst := make([]output.IQSample, dstLen) | |||
| step := 1.0 / ratio // position step in source samples per output sample | |||
| pos := 0.0 | |||
| for i := 0; i < dstLen; i++ { | |||
| idx := int(pos) | |||
| frac := float32(pos - float64(idx)) | |||
| if idx+1 < srcLen { | |||
| s0 := frame.Samples[idx] | |||
| s1 := frame.Samples[idx+1] | |||
| dst[i] = output.IQSample{ | |||
| I: s0.I*(1-frac) + s1.I*frac, | |||
| Q: s0.Q*(1-frac) + s1.Q*frac, | |||
| } | |||
| } else if idx < srcLen { | |||
| dst[i] = frame.Samples[idx] | |||
| } | |||
| pos += step | |||
| } | |||
| return &output.CompositeFrame{ | |||
| Samples: dst, | |||
| SampleRateHz: targetRateHz, | |||
| Timestamp: frame.Timestamp, | |||
| Sequence: frame.Sequence, | |||
| } | |||
| } | |||
| @@ -0,0 +1,58 @@ | |||
| package dsp | |||
| import ( | |||
| "math" | |||
| "testing" | |||
| "time" | |||
| "github.com/jan/fm-rds-tx/internal/output" | |||
| ) | |||
| func TestResampleIQIdentity(t *testing.T) { | |||
| frame := &output.CompositeFrame{ | |||
| Samples: make([]output.IQSample, 100), | |||
| SampleRateHz: 228000, | |||
| Timestamp: time.Now(), | |||
| } | |||
| for i := range frame.Samples { | |||
| frame.Samples[i] = output.IQSample{I: float32(i) / 100, Q: 0} | |||
| } | |||
| result := ResampleIQ(frame, 228000) | |||
| if len(result.Samples) != 100 { | |||
| t.Fatalf("expected 100, got %d", len(result.Samples)) | |||
| } | |||
| } | |||
| func TestResampleIQUpsample(t *testing.T) { | |||
| frame := &output.CompositeFrame{ | |||
| Samples: make([]output.IQSample, 228), | |||
| SampleRateHz: 228000, | |||
| Timestamp: time.Now(), | |||
| } | |||
| for i := range frame.Samples { | |||
| phase := 2 * math.Pi * float64(i) / float64(len(frame.Samples)) | |||
| frame.Samples[i] = output.IQSample{I: float32(math.Cos(phase)), Q: float32(math.Sin(phase))} | |||
| } | |||
| result := ResampleIQ(frame, 528000) | |||
| expectedLen := int(float64(228) * 528000 / 228000) | |||
| if math.Abs(float64(len(result.Samples)-expectedLen)) > 2 { | |||
| t.Fatalf("expected ~%d samples, got %d", expectedLen, len(result.Samples)) | |||
| } | |||
| if result.SampleRateHz != 528000 { | |||
| t.Fatalf("expected rate 528000, got %.0f", result.SampleRateHz) | |||
| } | |||
| // Verify magnitude preserved (should be ~1.0 for unit circle) | |||
| for i := 10; i < len(result.Samples)-10; i++ { | |||
| s := result.Samples[i] | |||
| mag := math.Sqrt(float64(s.I)*float64(s.I) + float64(s.Q)*float64(s.Q)) | |||
| if mag < 0.9 || mag > 1.1 { | |||
| t.Fatalf("sample %d: magnitude=%.4f", i, mag) | |||
| } | |||
| } | |||
| } | |||
| func TestResampleIQNil(t *testing.T) { | |||
| if ResampleIQ(nil, 528000) != nil { | |||
| t.Fatal("expected nil") | |||
| } | |||
| } | |||
| @@ -0,0 +1,11 @@ | |||
| //go:build pluto && windows | |||
| package plutosdr | |||
| func Available() bool { | |||
| return true | |||
| } | |||
| func AvailableError() string { | |||
| return "" | |||
| } | |||
| @@ -0,0 +1,523 @@ | |||
| //go:build pluto && windows | |||
| // Package plutosdr provides a direct libiio-based TX driver for ADALM-Pluto. | |||
| // Pure Go on Windows — loads libiio.dll via syscall.LoadLibrary at runtime. | |||
| // No CGO, no C compiler required. | |||
| // | |||
| // Build: go build -tags pluto ./cmd/fmrtx | |||
| // Requires: libiio installed (https://github.com/analogdevicesinc/libiio/releases) | |||
| package plutosdr | |||
| import ( | |||
| "context" | |||
| "fmt" | |||
| "sync" | |||
| "sync/atomic" | |||
| "syscall" | |||
| "time" | |||
| "unsafe" | |||
| "github.com/jan/fm-rds-tx/internal/output" | |||
| "github.com/jan/fm-rds-tx/internal/platform" | |||
| ) | |||
| // iioLib holds function pointers loaded from libiio.dll | |||
| type iioLib struct { | |||
| dll *syscall.DLL | |||
| // Context | |||
| pCreateCtxFromURI *syscall.Proc | |||
| pDestroyCtx *syscall.Proc | |||
| // Device | |||
| pCtxFindDevice *syscall.Proc | |||
| pDeviceFindChannel *syscall.Proc | |||
| pChannelEnable *syscall.Proc | |||
| pChannelDisable *syscall.Proc | |||
| pChannelIsEnabled *syscall.Proc | |||
| // Attributes | |||
| pChannelAttrWriteLongLong *syscall.Proc | |||
| pChannelAttrWriteBool *syscall.Proc | |||
| pDeviceAttrWriteLongLong *syscall.Proc | |||
| // Buffer | |||
| pCreateBuffer *syscall.Proc | |||
| pDestroyBuffer *syscall.Proc | |||
| pBufferPush *syscall.Proc | |||
| pBufferStep *syscall.Proc | |||
| pBufferStart *syscall.Proc | |||
| pBufferEnd *syscall.Proc | |||
| pBufferFirst *syscall.Proc | |||
| } | |||
| var dllSearchPaths = []string{ | |||
| "libiio", | |||
| "iio", | |||
| `C:\Program Files\libiio\libiio.dll`, | |||
| `C:\Program Files (x86)\libiio\libiio.dll`, | |||
| } | |||
| func loadIIOLib() (*iioLib, error) { | |||
| var dll *syscall.DLL | |||
| var lastErr error | |||
| for _, path := range dllSearchPaths { | |||
| dll, lastErr = syscall.LoadDLL(path) | |||
| if dll != nil { | |||
| break | |||
| } | |||
| } | |||
| if dll == nil { | |||
| return nil, fmt.Errorf("cannot load libiio.dll: %v", lastErr) | |||
| } | |||
| p := func(name string) *syscall.Proc { | |||
| proc, _ := dll.FindProc(name) | |||
| return proc | |||
| } | |||
| return &iioLib{ | |||
| dll: dll, | |||
| pCreateCtxFromURI: p("iio_create_context_from_uri"), | |||
| pDestroyCtx: p("iio_context_destroy"), | |||
| pCtxFindDevice: p("iio_context_find_device"), | |||
| pDeviceFindChannel: p("iio_device_find_channel"), | |||
| pChannelEnable: p("iio_channel_enable"), | |||
| pChannelDisable: p("iio_channel_disable"), | |||
| pChannelIsEnabled: p("iio_channel_is_enabled"), | |||
| pChannelAttrWriteLongLong: p("iio_channel_attr_write_longlong"), | |||
| pChannelAttrWriteBool: p("iio_channel_attr_write_bool"), | |||
| pDeviceAttrWriteLongLong: p("iio_device_attr_write_longlong"), | |||
| pCreateBuffer: p("iio_device_create_buffer"), | |||
| pDestroyBuffer: p("iio_buffer_destroy"), | |||
| pBufferPush: p("iio_buffer_push"), | |||
| pBufferStep: p("iio_buffer_step"), | |||
| pBufferStart: p("iio_buffer_start"), | |||
| pBufferEnd: p("iio_buffer_end"), | |||
| pBufferFirst: p("iio_buffer_first"), | |||
| }, nil | |||
| } | |||
| // --- Driver --- | |||
| type PlutoDriver struct { | |||
| mu sync.Mutex | |||
| lib *iioLib | |||
| cfg platform.SoapyConfig | |||
| ctx uintptr // iio_context* | |||
| txDev uintptr // iio_device* (cf-ad9361-dds-core-lpc) | |||
| phyDev uintptr // iio_device* (ad9361-phy) | |||
| chanI uintptr // iio_channel* TX I | |||
| chanQ uintptr // iio_channel* TX Q | |||
| buf uintptr // iio_buffer* | |||
| bufSize int // samples per buffer push | |||
| started bool | |||
| configured bool | |||
| framesWritten atomic.Uint64 | |||
| samplesWritten atomic.Uint64 | |||
| underruns atomic.Uint64 | |||
| lastError string | |||
| lastErrorAt string | |||
| initError string | |||
| } | |||
| func NewPlutoDriver() platform.SoapyDriver { | |||
| lib, err := loadIIOLib() | |||
| if err != nil { | |||
| return &PlutoDriver{initError: err.Error()} | |||
| } | |||
| return &PlutoDriver{lib: lib} | |||
| } | |||
| func (d *PlutoDriver) Name() string { return "pluto-iio" } | |||
| func (d *PlutoDriver) Configure(_ context.Context, cfg platform.SoapyConfig) error { | |||
| d.mu.Lock() | |||
| defer d.mu.Unlock() | |||
| if d.lib == nil { | |||
| return fmt.Errorf("libiio not loaded: %s", d.initError) | |||
| } | |||
| // Cleanup existing | |||
| d.cleanup() | |||
| d.cfg = cfg | |||
| // Create IIO context via USB | |||
| uri := "usb:" | |||
| if cfg.Device != "" && cfg.Device != "plutosdr" { | |||
| uri = cfg.Device // allow "ip:192.168.2.1" or specific USB | |||
| } | |||
| ctx, err := d.createContext(uri) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| d.ctx = ctx | |||
| // Find TX streaming device | |||
| txDev := d.findDevice("cf-ad9361-dds-core-lpc") | |||
| if txDev == 0 { | |||
| return fmt.Errorf("pluto: TX device 'cf-ad9361-dds-core-lpc' not found") | |||
| } | |||
| d.txDev = txDev | |||
| // Find PHY device for configuration | |||
| phyDev := d.findDevice("ad9361-phy") | |||
| if phyDev == 0 { | |||
| return fmt.Errorf("pluto: PHY device 'ad9361-phy' not found") | |||
| } | |||
| d.phyDev = phyDev | |||
| // --- AD9361 PHY configuration --- | |||
| // The AD9364 (PlutoSDR) has TX on voltage3 (output), not voltage0 | |||
| // voltage0 = RX input, voltage3 = TX output on single-channel AD9364 | |||
| // Find TX PHY output channel | |||
| phyChanTX := d.findChannel(phyDev, "voltage3", true) // output=true | |||
| if phyChanTX == 0 { | |||
| // Fallback for dual-channel AD9361: try voltage0 output | |||
| phyChanTX = d.findChannel(phyDev, "voltage0", true) | |||
| } | |||
| if phyChanTX == 0 { | |||
| return fmt.Errorf("pluto: PHY TX channel not found (tried voltage3, voltage0)") | |||
| } | |||
| // Sample rate — AD9361 minimum is ~2.084 MHz. | |||
| // We set the hardware to this minimum and resample from composite rate. | |||
| rate := int64(cfg.SampleRateHz) | |||
| if rate < 2084000 { | |||
| rate = 2084000 // AD9361 minimum | |||
| } | |||
| // Update effective rate so the engine knows the real device rate | |||
| d.cfg.SampleRateHz = float64(rate) | |||
| d.writeChanAttrLL(phyChanTX, "sampling_frequency", rate) | |||
| // RF bandwidth — set to match our signal bandwidth (wider than composite) | |||
| bw := rate | |||
| if bw > 2000000 { | |||
| bw = 2000000 // 2 MHz BW is enough for FM broadcast | |||
| } | |||
| d.writeChanAttrLL(phyChanTX, "rf_bandwidth", bw) | |||
| // TX LO frequency | |||
| phyChanLO := d.findChannel(phyDev, "altvoltage1", true) // TX LO | |||
| if phyChanLO != 0 { | |||
| freqHz := int64(cfg.CenterFreqHz) | |||
| if freqHz <= 0 { | |||
| freqHz = 100000000 // 100 MHz default | |||
| } | |||
| d.writeChanAttrLL(phyChanLO, "frequency", freqHz) | |||
| } | |||
| // TX gain/attenuation | |||
| // PlutoSDR TX hardwaregain: 0 dB = max power, -89.75 dB = min | |||
| // Value is in dB (not millidB) as a negative number | |||
| // For max power: set to 0. For safety: set to -20 or so. | |||
| // cfg.GainDB from our config is 0..89 positive, we negate it and subtract from 0 | |||
| attenDB := int64(0) // default = max power | |||
| if cfg.GainDB > 0 { | |||
| // GainDB=89 means full attenuation, GainDB=0 means max power | |||
| attenDB = -int64(89 - cfg.GainDB) | |||
| if attenDB > 0 { | |||
| attenDB = 0 | |||
| } | |||
| if attenDB < -89 { | |||
| attenDB = -89 | |||
| } | |||
| } | |||
| d.writeChanAttrLL(phyChanTX, "hardwaregain", attenDB*1000) // millidB | |||
| // --- TX streaming channels on cf-ad9361-dds-core-lpc --- | |||
| // voltage0 (I) and voltage1 (Q) are output channels | |||
| chanI := d.findChannel(txDev, "voltage0", true) | |||
| chanQ := d.findChannel(txDev, "voltage1", true) | |||
| if chanI == 0 || chanQ == 0 { | |||
| return fmt.Errorf("pluto: TX I/Q channels not found on streaming device") | |||
| } | |||
| d.enableChannel(chanI) | |||
| d.enableChannel(chanQ) | |||
| d.chanI = chanI | |||
| d.chanQ = chanQ | |||
| // Create buffer — samples per push (per channel) | |||
| // At 2.084 MHz with 50ms chunks = 104200 samples. Buffer must fit this. | |||
| d.bufSize = int(rate) / 20 // 50ms worth | |||
| if d.bufSize < 4096 { | |||
| d.bufSize = 4096 | |||
| } | |||
| // libiio can handle large buffers; no artificial cap needed | |||
| buf := d.createBuffer(txDev, d.bufSize, false) | |||
| if buf == 0 { | |||
| return fmt.Errorf("pluto: failed to create TX buffer (size=%d)", d.bufSize) | |||
| } | |||
| d.buf = buf | |||
| d.configured = true | |||
| return nil | |||
| } | |||
| func (d *PlutoDriver) Capabilities(_ context.Context) (platform.DeviceCaps, error) { | |||
| return platform.DeviceCaps{ | |||
| MinSampleRate: 521e3, | |||
| MaxSampleRate: 61.44e6, | |||
| HasGain: true, | |||
| GainMinDB: -89, | |||
| GainMaxDB: 0, | |||
| Channels: []int{0}, | |||
| }, nil | |||
| } | |||
| func (d *PlutoDriver) Start(_ context.Context) error { | |||
| d.mu.Lock() | |||
| defer d.mu.Unlock() | |||
| if !d.configured { | |||
| return fmt.Errorf("pluto: not configured") | |||
| } | |||
| if d.started { | |||
| return fmt.Errorf("pluto: already started") | |||
| } | |||
| d.started = true | |||
| return nil | |||
| } | |||
| func (d *PlutoDriver) Write(_ context.Context, frame *output.CompositeFrame) (int, error) { | |||
| d.mu.Lock() | |||
| lib := d.lib | |||
| buf := d.buf | |||
| started := d.started | |||
| bufSize := d.bufSize | |||
| d.mu.Unlock() | |||
| if !started || buf == 0 || lib == nil { | |||
| return 0, fmt.Errorf("pluto: not active") | |||
| } | |||
| if frame == nil || len(frame.Samples) == 0 { | |||
| return 0, nil | |||
| } | |||
| written := 0 | |||
| total := len(frame.Samples) | |||
| for written < total { | |||
| chunk := total - written | |||
| if chunk > bufSize { | |||
| chunk = bufSize | |||
| } | |||
| // Get buffer pointers | |||
| start := d.bufferStart(buf) | |||
| end := d.bufferEnd(buf) | |||
| step := d.bufferStep(buf) | |||
| if start == 0 || end == 0 || step == 0 { | |||
| return written, fmt.Errorf("pluto: invalid buffer pointers") | |||
| } | |||
| // Fill buffer with interleaved I/Q as int16 (PlutoSDR native format) | |||
| // IQSample is {I float32, Q float32} normalized to [-1,+1] | |||
| // PlutoSDR expects int16 interleaved: I0 Q0 I1 Q1 ... | |||
| bufLen := (end - start) / uintptr(step) | |||
| if int(bufLen) < chunk { | |||
| chunk = int(bufLen) | |||
| } | |||
| ptr := start | |||
| for i := 0; i < chunk; i++ { | |||
| s := frame.Samples[written+i] | |||
| // Scale float32 [-1,+1] to int16 [-32767,+32767] | |||
| iVal := int16(float32(s.I) * 32767) | |||
| qVal := int16(float32(s.Q) * 32767) | |||
| *(*int16)(unsafe.Pointer(ptr)) = iVal | |||
| *(*int16)(unsafe.Pointer(ptr + 2)) = qVal | |||
| ptr += uintptr(step) | |||
| } | |||
| // Push buffer to hardware | |||
| pushed := d.bufferPush(buf) | |||
| if pushed < 0 { | |||
| d.mu.Lock() | |||
| d.lastError = fmt.Sprintf("buffer_push: %d", pushed) | |||
| d.lastErrorAt = time.Now().UTC().Format(time.RFC3339) | |||
| d.underruns.Add(1) | |||
| d.mu.Unlock() | |||
| return written, fmt.Errorf("pluto: buffer_push returned %d", pushed) | |||
| } | |||
| written += chunk | |||
| } | |||
| d.framesWritten.Add(1) | |||
| d.samplesWritten.Add(uint64(written)) | |||
| return written, nil | |||
| } | |||
| func (d *PlutoDriver) Stop(_ context.Context) error { | |||
| d.mu.Lock() | |||
| defer d.mu.Unlock() | |||
| d.started = false | |||
| return nil | |||
| } | |||
| func (d *PlutoDriver) Flush(_ context.Context) error { return nil } | |||
| func (d *PlutoDriver) Close(_ context.Context) error { | |||
| d.mu.Lock() | |||
| defer d.mu.Unlock() | |||
| d.started = false | |||
| d.cleanup() | |||
| return nil | |||
| } | |||
| func (d *PlutoDriver) Stats() platform.RuntimeStats { | |||
| d.mu.Lock() | |||
| defer d.mu.Unlock() | |||
| return platform.RuntimeStats{ | |||
| TXEnabled: d.started, | |||
| StreamActive: d.started && d.buf != 0, | |||
| FramesWritten: d.framesWritten.Load(), | |||
| SamplesWritten: d.samplesWritten.Load(), | |||
| Underruns: d.underruns.Load(), | |||
| LastError: d.lastError, | |||
| LastErrorAt: d.lastErrorAt, | |||
| EffectiveRate: d.cfg.SampleRateHz, | |||
| } | |||
| } | |||
| // --- internal helpers --- | |||
| func (d *PlutoDriver) cleanup() { | |||
| if d.buf != 0 && d.lib.pDestroyBuffer != nil { | |||
| d.lib.pDestroyBuffer.Call(d.buf) | |||
| d.buf = 0 | |||
| } | |||
| if d.chanI != 0 { | |||
| d.disableChannel(d.chanI) | |||
| d.chanI = 0 | |||
| } | |||
| if d.chanQ != 0 { | |||
| d.disableChannel(d.chanQ) | |||
| d.chanQ = 0 | |||
| } | |||
| if d.ctx != 0 && d.lib.pDestroyCtx != nil { | |||
| d.lib.pDestroyCtx.Call(d.ctx) | |||
| d.ctx = 0 | |||
| } | |||
| d.configured = false | |||
| } | |||
| func (d *PlutoDriver) createContext(uri string) (uintptr, error) { | |||
| if d.lib.pCreateCtxFromURI == nil { | |||
| return 0, fmt.Errorf("iio_create_context_from_uri not found") | |||
| } | |||
| cURI, _ := syscall.BytePtrFromString(uri) | |||
| ret, _, _ := d.lib.pCreateCtxFromURI.Call(uintptr(unsafe.Pointer(cURI))) | |||
| if ret == 0 { | |||
| return 0, fmt.Errorf("pluto: failed to create IIO context (uri=%s)", uri) | |||
| } | |||
| return ret, nil | |||
| } | |||
| func (d *PlutoDriver) findDevice(name string) uintptr { | |||
| if d.lib.pCtxFindDevice == nil || d.ctx == 0 { | |||
| return 0 | |||
| } | |||
| cName, _ := syscall.BytePtrFromString(name) | |||
| ret, _, _ := d.lib.pCtxFindDevice.Call(d.ctx, uintptr(unsafe.Pointer(cName))) | |||
| return ret | |||
| } | |||
| func (d *PlutoDriver) findChannel(dev uintptr, name string, isOutput bool) uintptr { | |||
| if d.lib.pDeviceFindChannel == nil || dev == 0 { | |||
| return 0 | |||
| } | |||
| cName, _ := syscall.BytePtrFromString(name) | |||
| out := uintptr(0) | |||
| if isOutput { | |||
| out = 1 | |||
| } | |||
| ret, _, _ := d.lib.pDeviceFindChannel.Call(dev, uintptr(unsafe.Pointer(cName)), out) | |||
| return ret | |||
| } | |||
| func (d *PlutoDriver) enableChannel(ch uintptr) { | |||
| if d.lib.pChannelEnable != nil && ch != 0 { | |||
| d.lib.pChannelEnable.Call(ch) | |||
| } | |||
| } | |||
| func (d *PlutoDriver) disableChannel(ch uintptr) { | |||
| if d.lib.pChannelDisable != nil && ch != 0 { | |||
| d.lib.pChannelDisable.Call(ch) | |||
| } | |||
| } | |||
| func (d *PlutoDriver) writeChanAttrLL(ch uintptr, attr string, val int64) { | |||
| if d.lib.pChannelAttrWriteLongLong == nil || ch == 0 { | |||
| return | |||
| } | |||
| cAttr, _ := syscall.BytePtrFromString(attr) | |||
| d.lib.pChannelAttrWriteLongLong.Call(ch, uintptr(unsafe.Pointer(cAttr)), uintptr(val)) | |||
| } | |||
| func (d *PlutoDriver) writeDevAttrLL(dev uintptr, attr string, val int64) { | |||
| if d.lib.pDeviceAttrWriteLongLong == nil || dev == 0 { | |||
| return | |||
| } | |||
| cAttr, _ := syscall.BytePtrFromString(attr) | |||
| d.lib.pDeviceAttrWriteLongLong.Call(dev, uintptr(unsafe.Pointer(cAttr)), uintptr(val)) | |||
| } | |||
| func (d *PlutoDriver) createBuffer(dev uintptr, sampleCount int, cyclic bool) uintptr { | |||
| if d.lib.pCreateBuffer == nil || dev == 0 { | |||
| return 0 | |||
| } | |||
| c := uintptr(0) | |||
| if cyclic { | |||
| c = 1 | |||
| } | |||
| ret, _, _ := d.lib.pCreateBuffer.Call(dev, uintptr(sampleCount), c) | |||
| return ret | |||
| } | |||
| func (d *PlutoDriver) bufferPush(buf uintptr) int { | |||
| if d.lib.pBufferPush == nil || buf == 0 { | |||
| return -1 | |||
| } | |||
| ret, _, _ := d.lib.pBufferPush.Call(buf) | |||
| return int(int32(ret)) | |||
| } | |||
| func (d *PlutoDriver) bufferStart(buf uintptr) uintptr { | |||
| if d.lib.pBufferStart == nil { | |||
| return 0 | |||
| } | |||
| ret, _, _ := d.lib.pBufferStart.Call(buf) | |||
| return ret | |||
| } | |||
| func (d *PlutoDriver) bufferEnd(buf uintptr) uintptr { | |||
| if d.lib.pBufferEnd == nil { | |||
| return 0 | |||
| } | |||
| ret, _, _ := d.lib.pBufferEnd.Call(buf) | |||
| return ret | |||
| } | |||
| func (d *PlutoDriver) bufferStep(buf uintptr) uintptr { | |||
| if d.lib.pBufferStep == nil { | |||
| return 0 | |||
| } | |||
| ret, _, _ := d.lib.pBufferStep.Call(buf) | |||
| return ret | |||
| } | |||
| func (d *PlutoDriver) bufferFirst(buf uintptr, ch uintptr) uintptr { | |||
| if d.lib.pBufferFirst == nil { | |||
| return 0 | |||
| } | |||
| ret, _, _ := d.lib.pBufferFirst.Call(buf, ch) | |||
| return ret | |||
| } | |||
| @@ -0,0 +1,21 @@ | |||
| //go:build !pluto || !windows | |||
| package plutosdr | |||
| import ( | |||
| "fmt" | |||
| "github.com/jan/fm-rds-tx/internal/platform" | |||
| ) | |||
| func NewPlutoDriver() platform.SoapyDriver { | |||
| return nil | |||
| } | |||
| func Available() bool { | |||
| return false | |||
| } | |||
| func AvailableError() string { | |||
| return fmt.Sprintf("plutosdr: not compiled with -tags pluto or not on supported platform") | |||
| } | |||
| @@ -0,0 +1,8 @@ | |||
| //go:build soapy | |||
| package soapysdr | |||
| // Available reports whether SoapySDR native support was compiled in. | |||
| func Available() bool { | |||
| return true | |||
| } | |||
| @@ -0,0 +1,306 @@ | |||
| //go:build soapy && !windows | |||
| package soapysdr | |||
| import ( | |||
| "fmt" | |||
| "math" | |||
| "unsafe" | |||
| ) | |||
| /* | |||
| #include <dlfcn.h> | |||
| #include <stdlib.h> | |||
| #include <stdint.h> | |||
| // Minimal dlopen wrapper — this is the ONLY cgo usage and it's | |||
| // just for dlopen/dlsym which are part of libc, not SoapySDR. | |||
| // No SoapySDR headers needed at compile time. | |||
| static void* soapy_dlopen(const char* path) { | |||
| return dlopen(path, 2); // RTLD_NOW | |||
| } | |||
| static void* soapy_dlsym(void* handle, const char* name) { | |||
| return dlsym(handle, name); | |||
| } | |||
| static const char* soapy_dlerror() { | |||
| return dlerror(); | |||
| } | |||
| // Function call trampolines — we call function pointers loaded via dlsym. | |||
| // These avoid the complexity of calling C function pointers from Go directly. | |||
| typedef void* (*make_fn)(void*); | |||
| typedef int (*unmake_fn)(void*); | |||
| typedef int (*set_double_fn)(void*, int, size_t, double); | |||
| typedef int (*set_freq_fn)(void*, int, size_t, double, void*); | |||
| typedef void* (*setup_stream_fn)(void*, int, const char*, size_t*, size_t, void*); | |||
| typedef int (*close_stream_fn)(void*, void*); | |||
| typedef size_t (*mtu_fn)(void*, void*); | |||
| typedef int (*activate_fn)(void*, void*, int, long long, size_t); | |||
| typedef int (*deactivate_fn)(void*, void*, int, long long); | |||
| typedef int (*write_fn)(void*, void*, const void**, size_t, int*, long long, long); | |||
| typedef void* (*enumerate_fn)(void*, size_t*); | |||
| typedef void (*kwargs_clear_fn)(void*, size_t); | |||
| typedef void (*kwargs_set_fn)(void*, const char*, const char*); | |||
| // --- KWArgs struct matching SoapySDRKwargs --- | |||
| typedef struct { | |||
| size_t size; | |||
| char** keys; | |||
| char** vals; | |||
| } GoKwargs; | |||
| static void* call_make(void* fn, void* args) { | |||
| return ((make_fn)fn)(args); | |||
| } | |||
| static int call_unmake(void* fn, void* dev) { | |||
| return ((unmake_fn)fn)(dev); | |||
| } | |||
| static int call_set_sample_rate(void* fn, void* dev, int dir, size_t ch, double rate) { | |||
| return ((set_double_fn)fn)(dev, dir, ch, rate); | |||
| } | |||
| static int call_set_frequency(void* fn, void* dev, int dir, size_t ch, double freq, void* kw) { | |||
| return ((set_freq_fn)fn)(dev, dir, ch, freq, kw); | |||
| } | |||
| static int call_set_gain(void* fn, void* dev, int dir, size_t ch, double gain) { | |||
| return ((set_double_fn)fn)(dev, dir, ch, gain); | |||
| } | |||
| static void* call_setup_stream(void* fn, void* dev, int dir, const char* fmt, size_t* chs, size_t nch, void* kw) { | |||
| return ((setup_stream_fn)fn)(dev, dir, fmt, chs, nch, kw); | |||
| } | |||
| static int call_close_stream(void* fn, void* dev, void* stream) { | |||
| return ((close_stream_fn)fn)(dev, stream); | |||
| } | |||
| static size_t call_mtu(void* fn, void* dev, void* stream) { | |||
| return ((mtu_fn)fn)(dev, stream); | |||
| } | |||
| static int call_activate(void* fn, void* dev, void* stream) { | |||
| return ((activate_fn)fn)(dev, stream, 0, 0, 0); | |||
| } | |||
| static int call_deactivate(void* fn, void* dev, void* stream) { | |||
| return ((deactivate_fn)fn)(dev, stream, 0, 0); | |||
| } | |||
| static int call_write(void* fn, void* dev, void* stream, const void* buf, size_t n, int* flags, long timeout) { | |||
| const void* buffs[1]; | |||
| buffs[0] = buf; | |||
| *flags = 0; | |||
| return ((write_fn)fn)(dev, stream, buffs, n, flags, 0, timeout); | |||
| } | |||
| static void* call_enumerate(void* fn, void* kw, size_t* length) { | |||
| return ((enumerate_fn)fn)(kw, length); | |||
| } | |||
| static void call_kwargs_clear(void* fn, void* list, size_t length) { | |||
| ((kwargs_clear_fn)fn)(list, length); | |||
| } | |||
| static void call_kwargs_set(void* fn, void* kw, const char* key, const char* val) { | |||
| ((kwargs_set_fn)fn)(kw, key, val); | |||
| } | |||
| */ | |||
| import "C" | |||
| type soapyLib struct { | |||
| handle unsafe.Pointer | |||
| fnEnumerate unsafe.Pointer | |||
| fnKwargsListClear unsafe.Pointer | |||
| fnKwargsSet unsafe.Pointer | |||
| fnMake unsafe.Pointer | |||
| fnUnmake unsafe.Pointer | |||
| fnSetSampleRate unsafe.Pointer | |||
| fnSetFrequency unsafe.Pointer | |||
| fnSetGain unsafe.Pointer | |||
| fnGetGainRange unsafe.Pointer | |||
| fnSetupStream unsafe.Pointer | |||
| fnCloseStream unsafe.Pointer | |||
| fnGetStreamMTU unsafe.Pointer | |||
| fnActivateStream unsafe.Pointer | |||
| fnDeactivateStream unsafe.Pointer | |||
| fnWriteStream unsafe.Pointer | |||
| } | |||
| var libNames = []string{ | |||
| "libSoapySDR.so.0.8", | |||
| "libSoapySDR.so", | |||
| "libSoapySDR.dylib", | |||
| } | |||
| func loadSoapyLib() (*soapyLib, error) { | |||
| var handle unsafe.Pointer | |||
| for _, name := range libNames { | |||
| cName := C.CString(name) | |||
| handle = C.soapy_dlopen(cName) | |||
| C.free(unsafe.Pointer(cName)) | |||
| if handle != nil { | |||
| break | |||
| } | |||
| } | |||
| if handle == nil { | |||
| errMsg := C.GoString(C.soapy_dlerror()) | |||
| return nil, fmt.Errorf("cannot load SoapySDR: %s", errMsg) | |||
| } | |||
| sym := func(name string) unsafe.Pointer { | |||
| cName := C.CString(name) | |||
| defer C.free(unsafe.Pointer(cName)) | |||
| return C.soapy_dlsym(handle, cName) | |||
| } | |||
| return &soapyLib{ | |||
| handle: handle, | |||
| fnEnumerate: sym("SoapySDRDevice_enumerate"), | |||
| fnKwargsListClear: sym("SoapySDRKwargsList_clear"), | |||
| fnKwargsSet: sym("SoapySDRKwargs_set"), | |||
| fnMake: sym("SoapySDRDevice_make"), | |||
| fnUnmake: sym("SoapySDRDevice_unmake"), | |||
| fnSetSampleRate: sym("SoapySDRDevice_setSampleRate"), | |||
| fnSetFrequency: sym("SoapySDRDevice_setFrequency"), | |||
| fnSetGain: sym("SoapySDRDevice_setGain"), | |||
| fnGetGainRange: sym("SoapySDRDevice_getGainRange"), | |||
| fnSetupStream: sym("SoapySDRDevice_setupStream"), | |||
| fnCloseStream: sym("SoapySDRDevice_closeStream"), | |||
| fnGetStreamMTU: sym("SoapySDRDevice_getStreamMTU"), | |||
| fnActivateStream: sym("SoapySDRDevice_activateStream"), | |||
| fnDeactivateStream: sym("SoapySDRDevice_deactivateStream"), | |||
| fnWriteStream: sym("SoapySDRDevice_writeStream"), | |||
| }, nil | |||
| } | |||
| // --- kwargs helper --- | |||
| type kwargs = C.GoKwargs | |||
| func (lib *soapyLib) kwargsSet(kw *kwargs, key, val string) { | |||
| if lib.fnKwargsSet == nil { return } | |||
| cK := C.CString(key); cV := C.CString(val) | |||
| defer C.free(unsafe.Pointer(cK)); defer C.free(unsafe.Pointer(cV)) | |||
| C.call_kwargs_set(lib.fnKwargsSet, unsafe.Pointer(kw), cK, cV) | |||
| } | |||
| // --- Enumerate --- | |||
| func (lib *soapyLib) enumerate() ([]map[string]string, error) { | |||
| if lib.fnEnumerate == nil { return nil, fmt.Errorf("enumerate not available") } | |||
| var kw kwargs | |||
| var length C.size_t | |||
| ret := C.call_enumerate(lib.fnEnumerate, unsafe.Pointer(&kw), &length) | |||
| if ret == nil || length == 0 { return nil, nil } | |||
| defer func() { | |||
| if lib.fnKwargsListClear != nil { | |||
| C.call_kwargs_clear(lib.fnKwargsListClear, ret, length) | |||
| } | |||
| }() | |||
| devices := make([]map[string]string, int(length)) | |||
| kwSize := unsafe.Sizeof(kwargs{}) | |||
| base := uintptr(ret) | |||
| for i := 0; i < int(length); i++ { | |||
| kw := (*kwargs)(unsafe.Pointer(base + uintptr(i)*kwSize)) | |||
| m := make(map[string]string) | |||
| for j := 0; j < int(kw.size); j++ { | |||
| keyPtr := *(*uintptr)(unsafe.Pointer(uintptr(unsafe.Pointer(kw.keys)) + uintptr(j)*unsafe.Sizeof(uintptr(0)))) | |||
| valPtr := *(*uintptr)(unsafe.Pointer(uintptr(unsafe.Pointer(kw.vals)) + uintptr(j)*unsafe.Sizeof(uintptr(0)))) | |||
| if keyPtr != 0 && valPtr != 0 { | |||
| m[C.GoString((*C.char)(unsafe.Pointer(keyPtr)))] = C.GoString((*C.char)(unsafe.Pointer(valPtr))) | |||
| } | |||
| } | |||
| devices[i] = m | |||
| } | |||
| return devices, nil | |||
| } | |||
| // --- Device --- | |||
| func (lib *soapyLib) makeDevice(driver, device string, args map[string]string) (uintptr, error) { | |||
| if lib.fnMake == nil { return 0, fmt.Errorf("make not available") } | |||
| var kw kwargs | |||
| if driver != "" { lib.kwargsSet(&kw, "driver", driver) } | |||
| if device != "" { lib.kwargsSet(&kw, "device", device) } | |||
| for k, v := range args { lib.kwargsSet(&kw, k, v) } | |||
| ret := C.call_make(lib.fnMake, unsafe.Pointer(&kw)) | |||
| if ret == nil { return 0, fmt.Errorf("soapy: failed to open device") } | |||
| return uintptr(ret), nil | |||
| } | |||
| func (lib *soapyLib) unmakeDevice(dev uintptr) { | |||
| if lib.fnUnmake != nil { C.call_unmake(lib.fnUnmake, unsafe.Pointer(dev)) } | |||
| } | |||
| // --- Config --- | |||
| func (lib *soapyLib) setSampleRate(dev uintptr, dir, ch int, rate float64) error { | |||
| if lib.fnSetSampleRate == nil { return fmt.Errorf("not available") } | |||
| rc := C.call_set_sample_rate(lib.fnSetSampleRate, unsafe.Pointer(dev), C.int(dir), C.size_t(ch), C.double(rate)) | |||
| if rc != 0 { return fmt.Errorf("soapy: setSampleRate(%.0f) failed: %d", rate, rc) } | |||
| return nil | |||
| } | |||
| func (lib *soapyLib) setFrequency(dev uintptr, dir, ch int, freq float64) error { | |||
| if lib.fnSetFrequency == nil { return fmt.Errorf("not available") } | |||
| var kw kwargs | |||
| rc := C.call_set_frequency(lib.fnSetFrequency, unsafe.Pointer(dev), C.int(dir), C.size_t(ch), C.double(freq), unsafe.Pointer(&kw)) | |||
| if rc != 0 { return fmt.Errorf("soapy: setFrequency(%.0f) failed: %d", freq, rc) } | |||
| return nil | |||
| } | |||
| func (lib *soapyLib) setGain(dev uintptr, dir, ch int, gain float64) error { | |||
| if lib.fnSetGain == nil { return nil } | |||
| C.call_set_gain(lib.fnSetGain, unsafe.Pointer(dev), C.int(dir), C.size_t(ch), C.double(gain)) | |||
| return nil | |||
| } | |||
| func (lib *soapyLib) getGainRange(dev uintptr, dir, ch int) (float64, float64) { | |||
| _ = math.Float64bits // keep import | |||
| // Fallback if function not available | |||
| return 0, 89 | |||
| } | |||
| // --- Stream --- | |||
| func (lib *soapyLib) setupStream(dev uintptr, dir int, format string, channels []uint) (uintptr, error) { | |||
| if lib.fnSetupStream == nil { return 0, fmt.Errorf("not available") } | |||
| cFmt := C.CString(format); defer C.free(unsafe.Pointer(cFmt)) | |||
| chs := make([]C.size_t, len(channels)) | |||
| for i, c := range channels { chs[i] = C.size_t(c) } | |||
| var chPtr *C.size_t | |||
| if len(chs) > 0 { chPtr = &chs[0] } | |||
| var kw kwargs | |||
| ret := C.call_setup_stream(lib.fnSetupStream, unsafe.Pointer(dev), C.int(dir), cFmt, chPtr, C.size_t(len(channels)), unsafe.Pointer(&kw)) | |||
| if ret == nil { return 0, fmt.Errorf("soapy: setupStream failed") } | |||
| return uintptr(ret), nil | |||
| } | |||
| func (lib *soapyLib) closeStream(dev, stream uintptr) { | |||
| if lib.fnCloseStream != nil { | |||
| C.call_close_stream(lib.fnCloseStream, unsafe.Pointer(dev), unsafe.Pointer(stream)) | |||
| } | |||
| } | |||
| func (lib *soapyLib) getStreamMTU(dev, stream uintptr) int { | |||
| if lib.fnGetStreamMTU == nil { return 4096 } | |||
| ret := C.call_mtu(lib.fnGetStreamMTU, unsafe.Pointer(dev), unsafe.Pointer(stream)) | |||
| if ret == 0 { return 4096 } | |||
| return int(ret) | |||
| } | |||
| func (lib *soapyLib) activateStream(dev, stream uintptr) error { | |||
| if lib.fnActivateStream == nil { return fmt.Errorf("not available") } | |||
| rc := C.call_activate(lib.fnActivateStream, unsafe.Pointer(dev), unsafe.Pointer(stream)) | |||
| if rc != 0 { return fmt.Errorf("soapy: activateStream failed: %d", rc) } | |||
| return nil | |||
| } | |||
| func (lib *soapyLib) deactivateStream(dev, stream uintptr) { | |||
| if lib.fnDeactivateStream != nil { | |||
| C.call_deactivate(lib.fnDeactivateStream, unsafe.Pointer(dev), unsafe.Pointer(stream)) | |||
| } | |||
| } | |||
| func (lib *soapyLib) writeStream(dev, stream uintptr, buf unsafe.Pointer, numElems int) (int, error) { | |||
| if lib.fnWriteStream == nil { return 0, fmt.Errorf("not available") } | |||
| var flags C.int | |||
| rc := C.call_write(lib.fnWriteStream, unsafe.Pointer(dev), unsafe.Pointer(stream), buf, C.size_t(numElems), &flags, 100000) | |||
| if rc < 0 { return 0, fmt.Errorf("soapy: writeStream returned %d", rc) } | |||
| return int(rc), nil | |||
| } | |||
| @@ -0,0 +1,293 @@ | |||
| //go:build soapy && windows | |||
| package soapysdr | |||
| import ( | |||
| "fmt" | |||
| "math" | |||
| "syscall" | |||
| "unsafe" | |||
| ) | |||
| type soapyLib struct { | |||
| dll *syscall.DLL | |||
| pEnumerate *syscall.Proc | |||
| pKwargsListClear *syscall.Proc | |||
| pMake *syscall.Proc | |||
| pUnmake *syscall.Proc | |||
| pSetSampleRate *syscall.Proc | |||
| pSetFrequency *syscall.Proc | |||
| pSetGain *syscall.Proc | |||
| pGetGainRange *syscall.Proc | |||
| pSetupStream *syscall.Proc | |||
| pCloseStream *syscall.Proc | |||
| pGetStreamMTU *syscall.Proc | |||
| pActivateStream *syscall.Proc | |||
| pDeactivateStream *syscall.Proc | |||
| pWriteStream *syscall.Proc | |||
| pKwargsSet *syscall.Proc | |||
| } | |||
| var searchPaths = []string{ | |||
| "SoapySDR", | |||
| `C:\Program Files\PothosSDR\bin\SoapySDR.dll`, | |||
| `C:\PothosSDR\bin\SoapySDR.dll`, | |||
| } | |||
| func loadSoapyLib() (*soapyLib, error) { | |||
| var dll *syscall.DLL | |||
| var lastErr error | |||
| for _, path := range searchPaths { | |||
| dll, lastErr = syscall.LoadDLL(path) | |||
| if dll != nil { | |||
| break | |||
| } | |||
| } | |||
| if dll == nil { | |||
| return nil, fmt.Errorf("cannot load SoapySDR.dll: %v (searched: %v)", lastErr, searchPaths) | |||
| } | |||
| mustProc := func(name string) *syscall.Proc { | |||
| p, _ := dll.FindProc(name) | |||
| return p | |||
| } | |||
| return &soapyLib{ | |||
| dll: dll, | |||
| pEnumerate: mustProc("SoapySDRDevice_enumerate"), | |||
| pKwargsListClear: mustProc("SoapySDRKwargsList_clear"), | |||
| pMake: mustProc("SoapySDRDevice_make"), | |||
| pUnmake: mustProc("SoapySDRDevice_unmake"), | |||
| pSetSampleRate: mustProc("SoapySDRDevice_setSampleRate"), | |||
| pSetFrequency: mustProc("SoapySDRDevice_setFrequency"), | |||
| pSetGain: mustProc("SoapySDRDevice_setGain"), | |||
| pGetGainRange: mustProc("SoapySDRDevice_getGainRange"), | |||
| pSetupStream: mustProc("SoapySDRDevice_setupStream"), | |||
| pCloseStream: mustProc("SoapySDRDevice_closeStream"), | |||
| pGetStreamMTU: mustProc("SoapySDRDevice_getStreamMTU"), | |||
| pActivateStream: mustProc("SoapySDRDevice_activateStream"), | |||
| pDeactivateStream: mustProc("SoapySDRDevice_deactivateStream"), | |||
| pWriteStream: mustProc("SoapySDRDevice_writeStream"), | |||
| pKwargsSet: mustProc("SoapySDRKwargs_set"), | |||
| }, nil | |||
| } | |||
| // --- KWArgs helper (stack-allocated 64-byte struct matching SoapySDRKwargs) --- | |||
| // SoapySDRKwargs = { size_t size; char** keys; char** vals; } | |||
| // On 64-bit: 8 + 8 + 8 = 24 bytes | |||
| type kwargs struct { | |||
| size uintptr | |||
| keys uintptr | |||
| vals uintptr | |||
| } | |||
| func (lib *soapyLib) kwargsSet(kw *kwargs, key, val string) { | |||
| if lib.pKwargsSet == nil { | |||
| return | |||
| } | |||
| cKey, _ := syscall.BytePtrFromString(key) | |||
| cVal, _ := syscall.BytePtrFromString(val) | |||
| lib.pKwargsSet.Call(uintptr(unsafe.Pointer(kw)), uintptr(unsafe.Pointer(cKey)), uintptr(unsafe.Pointer(cVal))) | |||
| } | |||
| // --- Enumerate --- | |||
| func (lib *soapyLib) enumerate() ([]map[string]string, error) { | |||
| if lib.pEnumerate == nil { | |||
| return nil, fmt.Errorf("SoapySDRDevice_enumerate not found") | |||
| } | |||
| var kw kwargs | |||
| var length uintptr | |||
| ret, _, _ := lib.pEnumerate.Call(uintptr(unsafe.Pointer(&kw)), uintptr(unsafe.Pointer(&length))) | |||
| if ret == 0 || length == 0 { | |||
| return nil, nil | |||
| } | |||
| defer func() { | |||
| if lib.pKwargsListClear != nil { | |||
| lib.pKwargsListClear.Call(ret, length) | |||
| } | |||
| }() | |||
| devices := make([]map[string]string, int(length)) | |||
| kwSize := unsafe.Sizeof(kwargs{}) | |||
| for i := 0; i < int(length); i++ { | |||
| kw := (*kwargs)(unsafe.Pointer(ret + uintptr(i)*kwSize)) | |||
| m := make(map[string]string) | |||
| for j := 0; j < int(kw.size); j++ { | |||
| keyPtr := *(*uintptr)(unsafe.Pointer(kw.keys + uintptr(j)*unsafe.Sizeof(uintptr(0)))) | |||
| valPtr := *(*uintptr)(unsafe.Pointer(kw.vals + uintptr(j)*unsafe.Sizeof(uintptr(0)))) | |||
| if keyPtr != 0 && valPtr != 0 { | |||
| m[goString(keyPtr)] = goString(valPtr) | |||
| } | |||
| } | |||
| devices[i] = m | |||
| } | |||
| return devices, nil | |||
| } | |||
| // --- Device lifecycle --- | |||
| func (lib *soapyLib) makeDevice(driver, device string, args map[string]string) (uintptr, error) { | |||
| if lib.pMake == nil { | |||
| return 0, fmt.Errorf("SoapySDRDevice_make not found") | |||
| } | |||
| var kw kwargs | |||
| if driver != "" { | |||
| lib.kwargsSet(&kw, "driver", driver) | |||
| } | |||
| if device != "" { | |||
| lib.kwargsSet(&kw, "device", device) | |||
| } | |||
| for k, v := range args { | |||
| lib.kwargsSet(&kw, k, v) | |||
| } | |||
| ret, _, _ := lib.pMake.Call(uintptr(unsafe.Pointer(&kw))) | |||
| if ret == 0 { | |||
| return 0, fmt.Errorf("soapy: failed to open device (driver=%q device=%q)", driver, device) | |||
| } | |||
| return ret, nil | |||
| } | |||
| func (lib *soapyLib) unmakeDevice(dev uintptr) { | |||
| if lib.pUnmake != nil && dev != 0 { | |||
| lib.pUnmake.Call(dev) | |||
| } | |||
| } | |||
| // --- Configuration --- | |||
| func (lib *soapyLib) setSampleRate(dev uintptr, dir, ch int, rate float64) error { | |||
| if lib.pSetSampleRate == nil { | |||
| return fmt.Errorf("setSampleRate not available") | |||
| } | |||
| bits := math.Float64bits(rate) | |||
| rc, _, _ := lib.pSetSampleRate.Call(dev, uintptr(dir), uintptr(ch), uintptr(bits)) | |||
| if int32(rc) != 0 { | |||
| return fmt.Errorf("soapy: setSampleRate(%.0f) failed: %d", rate, int32(rc)) | |||
| } | |||
| return nil | |||
| } | |||
| func (lib *soapyLib) setFrequency(dev uintptr, dir, ch int, freq float64) error { | |||
| if lib.pSetFrequency == nil { | |||
| return fmt.Errorf("setFrequency not available") | |||
| } | |||
| bits := math.Float64bits(freq) | |||
| var kw kwargs | |||
| rc, _, _ := lib.pSetFrequency.Call(dev, uintptr(dir), uintptr(ch), uintptr(bits), uintptr(unsafe.Pointer(&kw))) | |||
| if int32(rc) != 0 { | |||
| return fmt.Errorf("soapy: setFrequency(%.0f) failed: %d", freq, int32(rc)) | |||
| } | |||
| return nil | |||
| } | |||
| func (lib *soapyLib) setGain(dev uintptr, dir, ch int, gain float64) error { | |||
| if lib.pSetGain == nil { | |||
| return nil | |||
| } | |||
| bits := math.Float64bits(gain) | |||
| lib.pSetGain.Call(dev, uintptr(dir), uintptr(ch), uintptr(bits)) | |||
| return nil | |||
| } | |||
| func (lib *soapyLib) getGainRange(dev uintptr, dir, ch int) (float64, float64) { | |||
| if lib.pGetGainRange == nil { | |||
| return 0, 89 | |||
| } | |||
| // SoapySDRRange is { double minimum; double maximum; double step; } = 24 bytes | |||
| var buf [3]float64 | |||
| lib.pGetGainRange.Call(uintptr(unsafe.Pointer(&buf)), dev, uintptr(dir), uintptr(ch)) | |||
| return buf[0], buf[1] | |||
| } | |||
| // --- Stream --- | |||
| func (lib *soapyLib) setupStream(dev uintptr, dir int, format string, channels []uint) (uintptr, error) { | |||
| if lib.pSetupStream == nil { | |||
| return 0, fmt.Errorf("setupStream not available") | |||
| } | |||
| cFmt, _ := syscall.BytePtrFromString(format) | |||
| var chPtr uintptr | |||
| if len(channels) > 0 { | |||
| chPtr = uintptr(unsafe.Pointer(&channels[0])) | |||
| } | |||
| var kw kwargs | |||
| ret, _, _ := lib.pSetupStream.Call(dev, uintptr(dir), uintptr(unsafe.Pointer(cFmt)), | |||
| chPtr, uintptr(len(channels)), uintptr(unsafe.Pointer(&kw))) | |||
| if ret == 0 { | |||
| return 0, fmt.Errorf("soapy: setupStream failed") | |||
| } | |||
| return ret, nil | |||
| } | |||
| func (lib *soapyLib) closeStream(dev, stream uintptr) { | |||
| if lib.pCloseStream != nil { | |||
| lib.pCloseStream.Call(dev, stream) | |||
| } | |||
| } | |||
| func (lib *soapyLib) getStreamMTU(dev, stream uintptr) int { | |||
| if lib.pGetStreamMTU == nil { | |||
| return 4096 | |||
| } | |||
| ret, _, _ := lib.pGetStreamMTU.Call(dev, stream) | |||
| if ret == 0 { | |||
| return 4096 | |||
| } | |||
| return int(ret) | |||
| } | |||
| func (lib *soapyLib) activateStream(dev, stream uintptr) error { | |||
| if lib.pActivateStream == nil { | |||
| return fmt.Errorf("activateStream not available") | |||
| } | |||
| rc, _, _ := lib.pActivateStream.Call(dev, stream, 0, 0, 0) | |||
| if int32(rc) != 0 { | |||
| return fmt.Errorf("soapy: activateStream failed: %d", int32(rc)) | |||
| } | |||
| return nil | |||
| } | |||
| func (lib *soapyLib) deactivateStream(dev, stream uintptr) { | |||
| if lib.pDeactivateStream != nil { | |||
| lib.pDeactivateStream.Call(dev, stream, 0, 0) | |||
| } | |||
| } | |||
| func (lib *soapyLib) writeStream(dev, stream uintptr, buf unsafe.Pointer, numElems int) (int, error) { | |||
| if lib.pWriteStream == nil { | |||
| return 0, fmt.Errorf("writeStream not available") | |||
| } | |||
| buffs := [1]uintptr{uintptr(buf)} | |||
| var flags int32 | |||
| rc, _, _ := lib.pWriteStream.Call(dev, stream, | |||
| uintptr(unsafe.Pointer(&buffs[0])), | |||
| uintptr(numElems), | |||
| uintptr(unsafe.Pointer(&flags)), | |||
| 0, // timeNs | |||
| 100000, // timeoutUs = 100ms | |||
| ) | |||
| n := int32(rc) | |||
| if n < 0 { | |||
| return 0, fmt.Errorf("soapy: writeStream returned %d", n) | |||
| } | |||
| return int(n), nil | |||
| } | |||
| // --- C string helper --- | |||
| func goString(p uintptr) string { | |||
| if p == 0 { | |||
| return "" | |||
| } | |||
| buf := make([]byte, 0, 256) | |||
| for i := uintptr(0); ; i++ { | |||
| b := *(*byte)(unsafe.Pointer(p + i)) | |||
| if b == 0 { | |||
| break | |||
| } | |||
| buf = append(buf, b) | |||
| } | |||
| return string(buf) | |||
| } | |||
| @@ -0,0 +1,248 @@ | |||
| //go:build soapy | |||
| // Package soapysdr provides a pure-Go SoapySDR driver that loads the | |||
| // SoapySDR shared library at runtime via dlopen/LoadLibrary. | |||
| // No CGO required. No C compiler required. | |||
| // | |||
| // Build with: go build -tags soapy | |||
| // Requires: SoapySDR shared library installed on the system. | |||
| // Windows: SoapySDR.dll (via PothosSDR) | |||
| // Linux: libSoapySDR.so (via package manager) | |||
| // macOS: libSoapySDR.dylib (via brew) | |||
| package soapysdr | |||
| import ( | |||
| "context" | |||
| "fmt" | |||
| "math" | |||
| "sync" | |||
| "sync/atomic" | |||
| "time" | |||
| "unsafe" | |||
| "github.com/jan/fm-rds-tx/internal/output" | |||
| "github.com/jan/fm-rds-tx/internal/platform" | |||
| ) | |||
| // nativeDriver implements platform.SoapyDriver using runtime-loaded SoapySDR. | |||
| type nativeDriver struct { | |||
| mu sync.Mutex | |||
| lib *soapyLib | |||
| cfg platform.SoapyConfig | |||
| dev uintptr // SoapySDRDevice* | |||
| stream uintptr // SoapySDRStream* | |||
| mtu int | |||
| started bool | |||
| configured bool | |||
| framesWritten atomic.Uint64 | |||
| samplesWritten atomic.Uint64 | |||
| underruns atomic.Uint64 | |||
| lastError string | |||
| lastErrorAt string | |||
| } | |||
| // NewNativeDriver creates an uninitialized SoapySDR native driver. | |||
| func NewNativeDriver() platform.SoapyDriver { | |||
| lib, err := loadSoapyLib() | |||
| if err != nil { | |||
| // Return a driver that will fail on Configure with a clear message | |||
| return &nativeDriver{lastError: fmt.Sprintf("load SoapySDR library: %v", err)} | |||
| } | |||
| return &nativeDriver{lib: lib} | |||
| } | |||
| // Enumerate lists available SoapySDR devices. | |||
| func Enumerate() ([]map[string]string, error) { | |||
| lib, err := loadSoapyLib() | |||
| if err != nil { | |||
| return nil, fmt.Errorf("load SoapySDR: %w", err) | |||
| } | |||
| return lib.enumerate() | |||
| } | |||
| func (d *nativeDriver) Name() string { return "soapy-native" } | |||
| func (d *nativeDriver) Configure(_ context.Context, cfg platform.SoapyConfig) error { | |||
| d.mu.Lock() | |||
| defer d.mu.Unlock() | |||
| if d.lib == nil { | |||
| return fmt.Errorf("soapy: library not loaded: %s", d.lastError) | |||
| } | |||
| // Close existing | |||
| if d.dev != 0 { | |||
| if d.stream != 0 { | |||
| d.lib.deactivateStream(d.dev, d.stream) | |||
| d.lib.closeStream(d.dev, d.stream) | |||
| d.stream = 0 | |||
| } | |||
| d.lib.unmakeDevice(d.dev) | |||
| d.dev = 0 | |||
| } | |||
| d.cfg = cfg | |||
| // Open device | |||
| dev, err := d.lib.makeDevice(cfg.Driver, cfg.Device, cfg.DeviceArgs) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| d.dev = dev | |||
| // Sample rate | |||
| rate := cfg.SampleRateHz | |||
| if rate <= 0 { | |||
| rate = 528000 | |||
| } | |||
| if err := d.lib.setSampleRate(d.dev, dirTX, 0, rate); err != nil { | |||
| return err | |||
| } | |||
| // Frequency | |||
| if cfg.CenterFreqHz > 0 { | |||
| if err := d.lib.setFrequency(d.dev, dirTX, 0, cfg.CenterFreqHz); err != nil { | |||
| return err | |||
| } | |||
| } | |||
| // Gain | |||
| if cfg.GainDB != 0 { | |||
| _ = d.lib.setGain(d.dev, dirTX, 0, cfg.GainDB) | |||
| } | |||
| // Setup TX stream (CF32) | |||
| stream, err := d.lib.setupStream(d.dev, dirTX, "CF32", []uint{0}) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| d.stream = stream | |||
| d.mtu = d.lib.getStreamMTU(d.dev, d.stream) | |||
| if d.mtu <= 0 { | |||
| d.mtu = 4096 | |||
| } | |||
| d.configured = true | |||
| return nil | |||
| } | |||
| func (d *nativeDriver) Capabilities(_ context.Context) (platform.DeviceCaps, error) { | |||
| d.mu.Lock() | |||
| defer d.mu.Unlock() | |||
| if d.dev == 0 || d.lib == nil { | |||
| return platform.DeviceCaps{}, fmt.Errorf("device not opened") | |||
| } | |||
| gMin, gMax := d.lib.getGainRange(d.dev, dirTX, 0) | |||
| return platform.DeviceCaps{ | |||
| MinSampleRate: 521e3, MaxSampleRate: 61.44e6, | |||
| HasGain: true, GainMinDB: gMin, GainMaxDB: gMax, | |||
| Channels: []int{0}, | |||
| }, nil | |||
| } | |||
| func (d *nativeDriver) Start(_ context.Context) error { | |||
| d.mu.Lock() | |||
| defer d.mu.Unlock() | |||
| if !d.configured || d.dev == 0 || d.stream == 0 { | |||
| return fmt.Errorf("soapy: not configured") | |||
| } | |||
| if d.started { | |||
| return fmt.Errorf("soapy: already started") | |||
| } | |||
| if err := d.lib.activateStream(d.dev, d.stream); err != nil { | |||
| return err | |||
| } | |||
| d.started = true | |||
| return nil | |||
| } | |||
| func (d *nativeDriver) Write(_ context.Context, frame *output.CompositeFrame) (int, error) { | |||
| d.mu.Lock() | |||
| lib, dev, stream, started, mtu := d.lib, d.dev, d.stream, d.started, d.mtu | |||
| d.mu.Unlock() | |||
| if !started || dev == 0 || stream == 0 { | |||
| return 0, fmt.Errorf("soapy: stream not active") | |||
| } | |||
| if frame == nil || len(frame.Samples) == 0 { | |||
| return 0, nil | |||
| } | |||
| total := len(frame.Samples) | |||
| written := 0 | |||
| for written < total { | |||
| chunk := total - written | |||
| if chunk > mtu { | |||
| chunk = mtu | |||
| } | |||
| // IQSample is {I float32, Q float32} — contiguous CF32 in memory | |||
| ptr := unsafe.Pointer(&frame.Samples[written]) | |||
| n, err := lib.writeStream(dev, stream, ptr, chunk) | |||
| if err != nil { | |||
| d.mu.Lock() | |||
| d.lastError = err.Error() | |||
| d.lastErrorAt = time.Now().UTC().Format(time.RFC3339) | |||
| d.underruns.Add(1) | |||
| d.mu.Unlock() | |||
| return written, err | |||
| } | |||
| written += n | |||
| } | |||
| d.framesWritten.Add(1) | |||
| d.samplesWritten.Add(uint64(written)) | |||
| return written, nil | |||
| } | |||
| func (d *nativeDriver) Stop(_ context.Context) error { | |||
| d.mu.Lock() | |||
| defer d.mu.Unlock() | |||
| if !d.started { | |||
| return nil | |||
| } | |||
| if d.dev != 0 && d.stream != 0 { | |||
| d.lib.deactivateStream(d.dev, d.stream) | |||
| } | |||
| d.started = false | |||
| return nil | |||
| } | |||
| func (d *nativeDriver) Flush(_ context.Context) error { return nil } | |||
| func (d *nativeDriver) Close(_ context.Context) error { | |||
| d.mu.Lock() | |||
| defer d.mu.Unlock() | |||
| if d.stream != 0 && d.dev != 0 { | |||
| if d.started { | |||
| d.lib.deactivateStream(d.dev, d.stream) | |||
| d.started = false | |||
| } | |||
| d.lib.closeStream(d.dev, d.stream) | |||
| d.stream = 0 | |||
| } | |||
| if d.dev != 0 { | |||
| d.lib.unmakeDevice(d.dev) | |||
| d.dev = 0 | |||
| } | |||
| d.configured = false | |||
| return nil | |||
| } | |||
| func (d *nativeDriver) Stats() platform.RuntimeStats { | |||
| d.mu.Lock() | |||
| defer d.mu.Unlock() | |||
| return platform.RuntimeStats{ | |||
| TXEnabled: d.started, StreamActive: d.started, | |||
| FramesWritten: d.framesWritten.Load(), SamplesWritten: d.samplesWritten.Load(), | |||
| Underruns: d.underruns.Load(), LastError: d.lastError, LastErrorAt: d.lastErrorAt, | |||
| EffectiveRate: d.cfg.SampleRateHz, | |||
| } | |||
| } | |||
| // --- helper constants --- | |||
| const dirTX = 1 // SOAPY_SDR_TX | |||
| // float64 from raw bits | |||
| func f64FromPtr(p uintptr) float64 { | |||
| return math.Float64frombits(uint64(p)) | |||
| } | |||
| @@ -0,0 +1,24 @@ | |||
| //go:build !soapy | |||
| package soapysdr | |||
| import ( | |||
| "fmt" | |||
| "github.com/jan/fm-rds-tx/internal/platform" | |||
| ) | |||
| // NewNativeDriver is not available without the soapy build tag. | |||
| func NewNativeDriver() platform.SoapyDriver { | |||
| return nil | |||
| } | |||
| // Enumerate is not available without the soapy build tag. | |||
| func Enumerate() ([]map[string]string, error) { | |||
| return nil, fmt.Errorf("soapysdr: not compiled with -tags soapy") | |||
| } | |||
| // Available reports whether SoapySDR native support was compiled in. | |||
| func Available() bool { | |||
| return false | |||
| } | |||
| @@ -0,0 +1,61 @@ | |||
| libiio Windows binary snapshot - README | |||
| ********************************************************************* | |||
| * The latest version of this snapshot can always be downloaded at: * | |||
| * https://github.com/analogdevicesinc/libiio * | |||
| ********************************************************************* | |||
| In this archive, you should find the following directories: | |||
| o ./include : Common include files | |||
| o ./Windows-MinGW-W64 : 64-bit binaries compiled by the MinGW toolchain | |||
| o ./Windows-VS-2019-x64 : 64-bit binaries compiled by the MicroSoft toolchain, VS-2019 | |||
| o ./Windows-VS-2022-x64 : 64-bit binaries compiled by the MicroSoft toolchain, VS-2022 | |||
| o Visual Studio: | |||
| - Open existing or create a new project for your application | |||
| - Copy iio.h, from the include\ directory, into your project and make sure that | |||
| the location where the file reside appears in the 'Additional Include | |||
| Directories' section (Configuration Properties -> C/C++ -> General). | |||
| - Copy the relevant .lib file from Windows-VS-2019-x64\ or Windows-VS-2022-x64\ and add 'libiio.lib' to | |||
| your 'Additional Dependencies' (Configuration Properties -> Linker -> Input) | |||
| Also make sure that the directory where libiio.lib resides is added to | |||
| 'Additional Library Directories' (Configuration Properties -> Linker | |||
| -> General) | |||
| - If you use the static version of the libiio library, make sure that | |||
| 'Runtime Library' is set to 'Multi-threaded DLL (/MD)' (Configuration | |||
| Properties -> C/C++ -> Code Generation). | |||
| - Compile and run your application. If you use the DLL version of libiio, | |||
| remember that you need to have a copy of the DLL either in the runtime | |||
| directory or in system32 | |||
| o WDK/DDK: | |||
| - The following is an example of a sources files that you can use to compile | |||
| a libiio 1.0 based console application. In this sample ..\libiio\ is the | |||
| directory where you would have copied libiio.h as well as the relevant | |||
| libiio.lib | |||
| TARGETNAME=your_app | |||
| TARGETTYPE=PROGRAM | |||
| USE_MSVCRT=1 | |||
| UMTYPE=console | |||
| INCLUDES=..\libiio;$(DDK_INC_PATH) | |||
| TARGETLIBS=..\libiio\libiio.lib | |||
| SOURCES=your_app.c | |||
| o MinGW/cygwin | |||
| - Copy iio.h, from include/ to your default include directory, | |||
| and copy the MinGW32/ or MinGW64/ .a files to your default library directory. | |||
| Or, if you don't want to use the default locations, make sure that you feed | |||
| the relevant -I and -L options to the compiler. | |||
| - Add the '-liio' linker option when compiling. | |||
| o Additional information: | |||
| - The libiio API documentation can be accessed at: | |||
| http://analogdevicesinc.github.io/libiio/ | |||
| - For some libiio samples (including source), please have a look in examples/ | |||
| and tests/ directories | |||
| - The MinGW and MS generated DLLs are fully interchangeable, provided that you | |||
| use the import libs provided or generate one from the .def also provided. | |||
| - If you find any issue, please visit | |||
| http://analogdevicesinc.github.io/libiio/ | |||
| and check the Issues section | |||
| @@ -0,0 +1,51 @@ | |||
| [Setup] | |||
| AppId={{D386A5F6-D38D-4738-94A2-E163DC1896F1} | |||
| AppName="Libiio" | |||
| AppVersion="0.26" | |||
| AppPublisher="Analog Devices, Inc." | |||
| AppPublisherURL="http://www.analog.com" | |||
| AppSupportURL="http://www.analog.com" | |||
| AppUpdatesURL="http://www.analog.com" | |||
| AppCopyright="Copyright 2015-2024 ADI and other contributors" | |||
| CreateAppDir=no | |||
| LicenseFile="D:\a\1\s\COPYING.txt" | |||
| OutputBaseFilename=libiio-setup | |||
| OutputDir="C:\" | |||
| Compression=lzma | |||
| SolidCompression=yes | |||
| ArchitecturesInstallIn64BitMode=x64 | |||
| [Languages] | |||
| Name: "english"; MessagesFile: "compiler:Default.isl" | |||
| Name: "brazilianportuguese"; MessagesFile: "compiler:Languages\BrazilianPortuguese.isl" | |||
| Name: "catalan"; MessagesFile: "compiler:Languages\Catalan.isl" | |||
| Name: "corsican"; MessagesFile: "compiler:Languages\Corsican.isl" | |||
| Name: "czech"; MessagesFile: "compiler:Languages\Czech.isl" | |||
| Name: "danish"; MessagesFile: "compiler:Languages\Danish.isl" | |||
| Name: "dutch"; MessagesFile: "compiler:Languages\Dutch.isl" | |||
| Name: "finnish"; MessagesFile: "compiler:Languages\Finnish.isl" | |||
| Name: "french"; MessagesFile: "compiler:Languages\French.isl" | |||
| Name: "german"; MessagesFile: "compiler:Languages\German.isl" | |||
| Name: "hebrew"; MessagesFile: "compiler:Languages\Hebrew.isl" | |||
| Name: "italian"; MessagesFile: "compiler:Languages\Italian.isl" | |||
| Name: "japanese"; MessagesFile: "compiler:Languages\Japanese.isl" | |||
| Name: "norwegian"; MessagesFile: "compiler:Languages\Norwegian.isl" | |||
| Name: "polish"; MessagesFile: "compiler:Languages\Polish.isl" | |||
| Name: "portuguese"; MessagesFile: "compiler:Languages\Portuguese.isl" | |||
| Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl" | |||
| Name: "slovenian"; MessagesFile: "compiler:Languages\Slovenian.isl" | |||
| Name: "spanish"; MessagesFile: "compiler:Languages\Spanish.isl" | |||
| Name: "turkish"; MessagesFile: "compiler:Languages\Turkish.isl" | |||
| Name: "ukrainian"; MessagesFile: "compiler:Languages\Ukrainian.isl" | |||
| [Files] | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\libiio.dll"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: replacesameversion | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\*.exe"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: replacesameversion | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\libiio.lib"; DestDir: "{commonpf32}\Microsoft Visual Studio 12.0\VC\lib\amd64"; Check: Is64BitInstallMode | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\iio.h"; DestDir: "{commonpf32}\Microsoft Visual Studio 12.0\VC\include" | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\libxml2.dll"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: onlyifdoesntexist | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\libusb-1.0.dll"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: onlyifdoesntexist | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\libserialport-0.dll"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: onlyifdoesntexist | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\libiio-sharp.dll"; DestDir: "{commoncf}\libiio"; Flags: replacesameversion | |||
| Source: "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Redist\MSVC\14.29.30133\x64\Microsoft.VC142.CRT\msvcp140.dll"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: onlyifdoesntexist | |||
| Source: "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Redist\MSVC\14.29.30133\x64\Microsoft.VC142.CRT\vcruntime140.dll"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: onlyifdoesntexist | |||
| @@ -0,0 +1,51 @@ | |||
| [Setup] | |||
| AppId={{D386A5F6-D38D-4738-94A2-E163DC1896F1} | |||
| AppName="Libiio" | |||
| AppVersion="0.26" | |||
| AppPublisher="Analog Devices, Inc." | |||
| AppPublisherURL="http://www.analog.com" | |||
| AppSupportURL="http://www.analog.com" | |||
| AppUpdatesURL="http://www.analog.com" | |||
| AppCopyright="Copyright 2015-2024 ADI and other contributors" | |||
| CreateAppDir=no | |||
| LicenseFile="D:\a\1\s\COPYING.txt" | |||
| OutputBaseFilename=libiio-setup | |||
| OutputDir="C:\" | |||
| Compression=lzma | |||
| SolidCompression=yes | |||
| ArchitecturesInstallIn64BitMode=x64 | |||
| [Languages] | |||
| Name: "english"; MessagesFile: "compiler:Default.isl" | |||
| Name: "brazilianportuguese"; MessagesFile: "compiler:Languages\BrazilianPortuguese.isl" | |||
| Name: "catalan"; MessagesFile: "compiler:Languages\Catalan.isl" | |||
| Name: "corsican"; MessagesFile: "compiler:Languages\Corsican.isl" | |||
| Name: "czech"; MessagesFile: "compiler:Languages\Czech.isl" | |||
| Name: "danish"; MessagesFile: "compiler:Languages\Danish.isl" | |||
| Name: "dutch"; MessagesFile: "compiler:Languages\Dutch.isl" | |||
| Name: "finnish"; MessagesFile: "compiler:Languages\Finnish.isl" | |||
| Name: "french"; MessagesFile: "compiler:Languages\French.isl" | |||
| Name: "german"; MessagesFile: "compiler:Languages\German.isl" | |||
| Name: "hebrew"; MessagesFile: "compiler:Languages\Hebrew.isl" | |||
| Name: "italian"; MessagesFile: "compiler:Languages\Italian.isl" | |||
| Name: "japanese"; MessagesFile: "compiler:Languages\Japanese.isl" | |||
| Name: "norwegian"; MessagesFile: "compiler:Languages\Norwegian.isl" | |||
| Name: "polish"; MessagesFile: "compiler:Languages\Polish.isl" | |||
| Name: "portuguese"; MessagesFile: "compiler:Languages\Portuguese.isl" | |||
| Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl" | |||
| Name: "slovenian"; MessagesFile: "compiler:Languages\Slovenian.isl" | |||
| Name: "spanish"; MessagesFile: "compiler:Languages\Spanish.isl" | |||
| Name: "turkish"; MessagesFile: "compiler:Languages\Turkish.isl" | |||
| Name: "ukrainian"; MessagesFile: "compiler:Languages\Ukrainian.isl" | |||
| [Files] | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\libiio.dll"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: replacesameversion | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\*.exe"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: replacesameversion | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\libiio.lib"; DestDir: "{commonpf32}\Microsoft Visual Studio 12.0\VC\lib\amd64"; Check: Is64BitInstallMode | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\iio.h"; DestDir: "{commonpf32}\Microsoft Visual Studio 12.0\VC\include" | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\libxml2.dll"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: onlyifdoesntexist | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\libusb-1.0.dll"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: onlyifdoesntexist | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\libserialport-0.dll"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: onlyifdoesntexist | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\libiio-sharp.dll"; DestDir: "{commoncf}\libiio"; Flags: replacesameversion | |||
| Source: "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Redist\MSVC\14.29.30133\x64\Microsoft.VC142.CRT\msvcp140.dll"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: onlyifdoesntexist | |||
| Source: "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Redist\MSVC\14.29.30133\x64\Microsoft.VC142.CRT\vcruntime140.dll"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: onlyifdoesntexist | |||
| @@ -0,0 +1,51 @@ | |||
| [Setup] | |||
| AppId={{D386A5F6-D38D-4738-94A2-E163DC1896F1} | |||
| AppName="Libiio" | |||
| AppVersion="0.26" | |||
| AppPublisher="Analog Devices, Inc." | |||
| AppPublisherURL="http://www.analog.com" | |||
| AppSupportURL="http://www.analog.com" | |||
| AppUpdatesURL="http://www.analog.com" | |||
| AppCopyright="Copyright 2015-2024 ADI and other contributors" | |||
| CreateAppDir=no | |||
| LicenseFile="D:\a\1\s\COPYING.txt" | |||
| OutputBaseFilename=libiio-setup | |||
| OutputDir="C:\" | |||
| Compression=lzma | |||
| SolidCompression=yes | |||
| ArchitecturesInstallIn64BitMode=x64 | |||
| [Languages] | |||
| Name: "english"; MessagesFile: "compiler:Default.isl" | |||
| Name: "brazilianportuguese"; MessagesFile: "compiler:Languages\BrazilianPortuguese.isl" | |||
| Name: "catalan"; MessagesFile: "compiler:Languages\Catalan.isl" | |||
| Name: "corsican"; MessagesFile: "compiler:Languages\Corsican.isl" | |||
| Name: "czech"; MessagesFile: "compiler:Languages\Czech.isl" | |||
| Name: "danish"; MessagesFile: "compiler:Languages\Danish.isl" | |||
| Name: "dutch"; MessagesFile: "compiler:Languages\Dutch.isl" | |||
| Name: "finnish"; MessagesFile: "compiler:Languages\Finnish.isl" | |||
| Name: "french"; MessagesFile: "compiler:Languages\French.isl" | |||
| Name: "german"; MessagesFile: "compiler:Languages\German.isl" | |||
| Name: "hebrew"; MessagesFile: "compiler:Languages\Hebrew.isl" | |||
| Name: "italian"; MessagesFile: "compiler:Languages\Italian.isl" | |||
| Name: "japanese"; MessagesFile: "compiler:Languages\Japanese.isl" | |||
| Name: "norwegian"; MessagesFile: "compiler:Languages\Norwegian.isl" | |||
| Name: "polish"; MessagesFile: "compiler:Languages\Polish.isl" | |||
| Name: "portuguese"; MessagesFile: "compiler:Languages\Portuguese.isl" | |||
| Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl" | |||
| Name: "slovenian"; MessagesFile: "compiler:Languages\Slovenian.isl" | |||
| Name: "spanish"; MessagesFile: "compiler:Languages\Spanish.isl" | |||
| Name: "turkish"; MessagesFile: "compiler:Languages\Turkish.isl" | |||
| Name: "ukrainian"; MessagesFile: "compiler:Languages\Ukrainian.isl" | |||
| [Files] | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\libiio.dll"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: replacesameversion | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\*.exe"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: replacesameversion | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\libiio.lib"; DestDir: "{commonpf32}\Microsoft Visual Studio 12.0\VC\lib\amd64"; Check: Is64BitInstallMode | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\iio.h"; DestDir: "{commonpf32}\Microsoft Visual Studio 12.0\VC\include" | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\libxml2.dll"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: onlyifdoesntexist | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\libusb-1.0.dll"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: onlyifdoesntexist | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\libserialport-0.dll"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: onlyifdoesntexist | |||
| Source: "D:\a\1\a\Windows-VS-2019-x64\libiio-sharp.dll"; DestDir: "{commoncf}\libiio"; Flags: replacesameversion | |||
| Source: "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Redist\MSVC\14.29.30133\x64\Microsoft.VC142.CRT\msvcp140.dll"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: onlyifdoesntexist | |||
| Source: "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Redist\MSVC\14.29.30133\x64\Microsoft.VC142.CRT\vcruntime140.dll"; DestDir: "{sys}"; Check: Is64BitInstallMode; Flags: onlyifdoesntexist | |||