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 | # 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 | # 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 | ```powershell | ||||
| go run ./cmd/fmrtx -print-config | |||||
| .\fmrtx.exe --list-devices | |||||
| ``` | ``` | ||||
| ### Dry-run (JSON summary, no hardware) | |||||
| ### Offline IQ file generation | |||||
| ```powershell | ```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 | ```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 | ## Repository layout | ||||
| ```text | ```text | ||||
| cmd/ | 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/ | 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/ | docs/ | ||||
| config.sample.json default config | |||||
| config.plutosdr.json PlutoSDR-specific config | |||||
| scripts/ | 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 | package main | ||||
| import ( | import ( | ||||
| "context" | |||||
| "flag" | "flag" | ||||
| "fmt" | "fmt" | ||||
| "log" | "log" | ||||
| "net/http" | "net/http" | ||||
| "os" | "os" | ||||
| "os/signal" | |||||
| "syscall" | |||||
| "time" | "time" | ||||
| apppkg "github.com/jan/fm-rds-tx/internal/app" | apppkg "github.com/jan/fm-rds-tx/internal/app" | ||||
| cfgpkg "github.com/jan/fm-rds-tx/internal/config" | cfgpkg "github.com/jan/fm-rds-tx/internal/config" | ||||
| ctrlpkg "github.com/jan/fm-rds-tx/internal/control" | ctrlpkg "github.com/jan/fm-rds-tx/internal/control" | ||||
| drypkg "github.com/jan/fm-rds-tx/internal/dryrun" | 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() { | func main() { | ||||
| @@ -22,34 +28,190 @@ func main() { | |||||
| simulate := flag.Bool("simulate-tx", false, "run simulated Soapy/backend transmit path") | simulate := flag.Bool("simulate-tx", false, "run simulated Soapy/backend transmit path") | ||||
| simulateOutput := flag.String("simulate-output", "", "simulated transmit output file") | simulateOutput := flag.String("simulate-output", "", "simulated transmit output file") | ||||
| simulateDuration := flag.Duration("simulate-duration", 500*time.Millisecond, "simulated transmit duration") | 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() | 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) | 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 { | if *printConfig { | ||||
| preemph := "off" | 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, | cfg.Backend.Kind, cfg.FM.FrequencyMHz, cfg.FM.StereoEnabled, cfg.RDS.Enabled, | ||||
| preemph, cfg.FM.LimiterEnabled, cfg.FM.FMModulationEnabled, cfg.FM.MaxDeviationHz, | 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 | return | ||||
| } | } | ||||
| // --- dry-run --- | |||||
| if *dryRun { | if *dryRun { | ||||
| frame := drypkg.Generate(cfg) | 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 | return | ||||
| } | } | ||||
| // --- simulate --- | |||||
| if *simulate { | if *simulate { | ||||
| summary, err := apppkg.RunSimulatedTransmit(cfg, *simulateOutput, *simulateDuration) | 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) | fmt.Println(summary) | ||||
| return | 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) | 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())) | 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" | "time" | ||||
| cfgpkg "github.com/jan/fm-rds-tx/internal/config" | cfgpkg "github.com/jan/fm-rds-tx/internal/config" | ||||
| offpkg "github.com/jan/fm-rds-tx/internal/offline" | offpkg "github.com/jan/fm-rds-tx/internal/offline" | ||||
| "github.com/jan/fm-rds-tx/internal/platform" | "github.com/jan/fm-rds-tx/internal/platform" | ||||
| ) | ) | ||||
| // EngineState represents the current state of the TX engine. | |||||
| type EngineState int | type EngineState int | ||||
| const ( | const ( | ||||
| EngineIdle EngineState = iota | |||||
| EngineIdle EngineState = iota | |||||
| EngineRunning | EngineRunning | ||||
| EngineStopping | EngineStopping | ||||
| ) | ) | ||||
| @@ -34,7 +34,6 @@ func (s EngineState) String() string { | |||||
| } | } | ||||
| } | } | ||||
| // EngineStats exposes runtime telemetry from the engine. | |||||
| type EngineStats struct { | type EngineStats struct { | ||||
| State string `json:"state"` | State string `json:"state"` | ||||
| ChunksProduced uint64 `json:"chunksProduced"` | ChunksProduced uint64 `json:"chunksProduced"` | ||||
| @@ -44,13 +43,17 @@ type EngineStats struct { | |||||
| UptimeSeconds float64 `json:"uptimeSeconds"` | 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 { | type Engine struct { | ||||
| cfg cfgpkg.Config | cfg cfgpkg.Config | ||||
| driver platform.SoapyDriver | driver platform.SoapyDriver | ||||
| generator *offpkg.Generator | generator *offpkg.Generator | ||||
| chunkDuration time.Duration | chunkDuration time.Duration | ||||
| deviceRate float64 | |||||
| mu sync.Mutex | mu sync.Mutex | ||||
| state EngineState | state EngineState | ||||
| @@ -63,23 +66,29 @@ type Engine struct { | |||||
| lastError atomic.Value // string | lastError atomic.Value // string | ||||
| } | } | ||||
| // NewEngine creates a TX engine. Default chunk duration is 50ms. | |||||
| func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine { | 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{ | return &Engine{ | ||||
| cfg: cfg, | cfg: cfg, | ||||
| driver: driver, | driver: driver, | ||||
| generator: offpkg.NewGenerator(cfg), | generator: offpkg.NewGenerator(cfg), | ||||
| chunkDuration: 50 * time.Millisecond, | chunkDuration: 50 * time.Millisecond, | ||||
| deviceRate: deviceRate, | |||||
| state: EngineIdle, | state: EngineIdle, | ||||
| } | } | ||||
| } | } | ||||
| // SetChunkDuration changes the generation chunk size. Must be called before Start. | |||||
| func (e *Engine) SetChunkDuration(d time.Duration) { | func (e *Engine) SetChunkDuration(d time.Duration) { | ||||
| e.chunkDuration = d | e.chunkDuration = d | ||||
| } | } | ||||
| // Start begins continuous transmission. TX is NOT started automatically. | |||||
| func (e *Engine) Start(ctx context.Context) error { | func (e *Engine) Start(ctx context.Context) error { | ||||
| e.mu.Lock() | e.mu.Lock() | ||||
| if e.state != EngineIdle { | if e.state != EngineIdle { | ||||
| @@ -102,7 +111,6 @@ func (e *Engine) Start(ctx context.Context) error { | |||||
| return nil | return nil | ||||
| } | } | ||||
| // Stop gracefully stops the TX engine. | |||||
| func (e *Engine) Stop(ctx context.Context) error { | func (e *Engine) Stop(ctx context.Context) error { | ||||
| e.mu.Lock() | e.mu.Lock() | ||||
| if e.state != EngineRunning { | if e.state != EngineRunning { | ||||
| @@ -113,7 +121,6 @@ func (e *Engine) Stop(ctx context.Context) error { | |||||
| e.cancel() | e.cancel() | ||||
| e.mu.Unlock() | e.mu.Unlock() | ||||
| // Give the run loop time to drain | |||||
| time.Sleep(e.chunkDuration * 2) | time.Sleep(e.chunkDuration * 2) | ||||
| if err := e.driver.Flush(ctx); err != nil { | if err := e.driver.Flush(ctx); err != nil { | ||||
| @@ -129,7 +136,6 @@ func (e *Engine) Stop(ctx context.Context) error { | |||||
| return nil | return nil | ||||
| } | } | ||||
| // Stats returns current engine telemetry. | |||||
| func (e *Engine) Stats() EngineStats { | func (e *Engine) Stats() EngineStats { | ||||
| e.mu.Lock() | e.mu.Lock() | ||||
| state := e.state | state := e.state | ||||
| @@ -140,7 +146,6 @@ func (e *Engine) Stats() EngineStats { | |||||
| if state == EngineRunning { | if state == EngineRunning { | ||||
| uptime = time.Since(startedAt).Seconds() | uptime = time.Since(startedAt).Seconds() | ||||
| } | } | ||||
| errVal, _ := e.lastError.Load().(string) | errVal, _ := e.lastError.Load().(string) | ||||
| return EngineStats{ | return EngineStats{ | ||||
| @@ -154,26 +159,27 @@ func (e *Engine) Stats() EngineStats { | |||||
| } | } | ||||
| func (e *Engine) run(ctx context.Context) { | 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 { | for { | ||||
| select { | |||||
| case <-ctx.Done(): | |||||
| if ctx.Err() != nil { | |||||
| return | 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 | |||||