瀏覽代碼

feat: add hardware TX mode with PlutoSDR and SoapySDR drivers

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
Jan Svabenik 1 月之前
父節點
當前提交
93cdcab8d8
共有 73 個文件被更改,包括 4120 次插入202 次删除
  1. +25
    -52
      CHANGELOG.md
  2. +72
    -88
      README.md
  3. +29
    -25
      RELEASE.md
  4. +170
    -8
      cmd/fmrtx/main.go
  5. +38
    -0
      docs/config.plutosdr.json
  6. +35
    -29
      internal/app/engine.go
  7. +59
    -0
      internal/dsp/iqresample.go
  8. +58
    -0
      internal/dsp/iqresample_test.go
  9. +11
    -0
      internal/platform/plutosdr/available_pluto.go
  10. +523
    -0
      internal/platform/plutosdr/pluto_windows.go
  11. +21
    -0
      internal/platform/plutosdr/stub.go
  12. +8
    -0
      internal/platform/soapysdr/available_soapy.go
  13. +306
    -0
      internal/platform/soapysdr/lib_unix.go
  14. +293
    -0
      internal/platform/soapysdr/lib_windows.go
  15. +248
    -0
      internal/platform/soapysdr/native.go
  16. +24
    -0
      internal/platform/soapysdr/stub.go
  17. +61
    -0
      libiio/README.txt
  18. 二進制
      libiio/Windows-MinGW-W64/iio_attr.exe
  19. 二進制
      libiio/Windows-MinGW-W64/iio_genxml.exe
  20. 二進制
      libiio/Windows-MinGW-W64/iio_info.exe
  21. 二進制
      libiio/Windows-MinGW-W64/iio_readdev.exe
  22. 二進制
      libiio/Windows-MinGW-W64/iio_reg.exe
  23. 二進制
      libiio/Windows-MinGW-W64/iio_writedev.exe
  24. 二進制
      libiio/Windows-MinGW-W64/libgcc_s_seh-1.dll
  25. 二進制
      libiio/Windows-MinGW-W64/libiconv-2.dll
  26. 二進制
      libiio/Windows-MinGW-W64/libiio-py39-amd64.tar.gz
  27. 二進制
      libiio/Windows-MinGW-W64/libiio.dll
  28. 二進制
      libiio/Windows-MinGW-W64/libiio.dll.a
  29. +51
    -0
      libiio/Windows-MinGW-W64/libiio.iss
  30. 二進制
      libiio/Windows-MinGW-W64/liblzma-5.dll
  31. 二進制
      libiio/Windows-MinGW-W64/libserialport-0.dll
  32. 二進制
      libiio/Windows-MinGW-W64/libstdc++-6.dll
  33. 二進制
      libiio/Windows-MinGW-W64/libusb-1.0.dll
  34. 二進制
      libiio/Windows-MinGW-W64/libwinpthread-1.dll
  35. 二進制
      libiio/Windows-MinGW-W64/libxml2-2.dll
  36. 二進制
      libiio/Windows-MinGW-W64/zlib1.dll
  37. 二進制
      libiio/Windows-VS-2019-x64/iio_attr.exe
  38. 二進制
      libiio/Windows-VS-2019-x64/iio_genxml.exe
  39. 二進制
      libiio/Windows-VS-2019-x64/iio_info.exe
  40. 二進制
      libiio/Windows-VS-2019-x64/iio_readdev.exe
  41. 二進制
      libiio/Windows-VS-2019-x64/iio_reg.exe
  42. 二進制
      libiio/Windows-VS-2019-x64/iio_writedev.exe
  43. 二進制
      libiio/Windows-VS-2019-x64/libiio-py39-amd64.tar.gz
  44. 二進制
      libiio/Windows-VS-2019-x64/libiio-sharp.dll
  45. 二進制
      libiio/Windows-VS-2019-x64/libiio.dll
  46. 二進制
      libiio/Windows-VS-2019-x64/libiio.exp
  47. +51
    -0
      libiio/Windows-VS-2019-x64/libiio.iss
  48. 二進制
      libiio/Windows-VS-2019-x64/libiio.lib
  49. 二進制
      libiio/Windows-VS-2019-x64/libiio.pdb
  50. 二進制
      libiio/Windows-VS-2019-x64/libserialport-0.dll
  51. 二進制
      libiio/Windows-VS-2019-x64/libusb-1.0.dll
  52. 二進制
      libiio/Windows-VS-2019-x64/libxml2.dll
  53. 二進制
      libiio/Windows-VS-2019-x64/msvcp140.dll
  54. 二進制
      libiio/Windows-VS-2019-x64/vcruntime140.dll
  55. 二進制
      libiio/Windows-VS-2022-x64/iio_attr.exe
  56. 二進制
      libiio/Windows-VS-2022-x64/iio_genxml.exe
  57. 二進制
      libiio/Windows-VS-2022-x64/iio_info.exe
  58. 二進制
      libiio/Windows-VS-2022-x64/iio_readdev.exe
  59. 二進制
      libiio/Windows-VS-2022-x64/iio_reg.exe
  60. 二進制
      libiio/Windows-VS-2022-x64/iio_writedev.exe
  61. 二進制
      libiio/Windows-VS-2022-x64/libiio-py39-amd64.tar.gz
  62. 二進制
      libiio/Windows-VS-2022-x64/libiio-sharp.dll
  63. 二進制
      libiio/Windows-VS-2022-x64/libiio.dll
  64. 二進制
      libiio/Windows-VS-2022-x64/libiio.exp
  65. +51
    -0
      libiio/Windows-VS-2022-x64/libiio.iss
  66. 二進制
      libiio/Windows-VS-2022-x64/libiio.lib
  67. 二進制
      libiio/Windows-VS-2022-x64/libiio.pdb
  68. 二進制
      libiio/Windows-VS-2022-x64/libserialport-0.dll
  69. 二進制
      libiio/Windows-VS-2022-x64/libusb-1.0.dll
  70. 二進制
      libiio/Windows-VS-2022-x64/libxml2.dll
  71. 二進制
      libiio/Windows-VS-2022-x64/msvcp140.dll
  72. 二進制
      libiio/Windows-VS-2022-x64/vcruntime140.dll
  73. +1986
    -0
      libiio/include/iio.h

+ 25
- 52
CHANGELOG.md 查看文件

@@ -1,55 +1,28 @@
# Changelog

## v0.5.0-pre

Full review implementation: HW-integration readiness, unity signal path, TX engine, spectral verification.

### Architecture changes
- All signal sources (pilot, RDS, stereo) now output unity-normalized signals (peak ±1.0)
- Combiner gains are direct config values — no magic-number normalization (`/0.1`, `/0.05` eliminated)
- Pre-emphasis moved from composite rate to audio input rate (correct signal path, efficient on SBC)
- RDS encoder: `NextSample()` zero-allocation hot path, fixed-size `[104]uint8` bit buffer
- PI validation moved to `config.Validate()` — fail-loud, no silent 0x1234 fallback

### New: TX Engine (`internal/app/engine.go`)
- Continuous chunk-based generation loop with configurable chunk duration
- Explicit `Start()`/`Stop()` — TX default is OFF
- Atomic underrun/overrun counters, chunk/sample stats
- Context-based cancellation with clean drain

### New: Extended SoapyDriver interface
- `Start(ctx)`, `Stop(ctx)`, `Capabilities(ctx)`, `Stats()` added to driver contract
- `SimulatedDriver` implements full interface with atomic runtime counters
- `DeviceCaps` struct for hardware capability reporting
- `RuntimeStats` struct for live telemetry

### New: Rate adaptation
- `backend.deviceSampleRateHz` separates SDR device rate from internal composite rate
- `Config.EffectiveDeviceRate()` resolves to device rate or falls back to composite rate
## v0.6.0-pre

Hardware integration: SoapySDR CGO binding, IQ resampling, TX CLI mode.

### Added
- CGO SoapySDR native driver (`internal/platform/soapysdr/native.go`)
- Device enumerate, open, configure (frequency, gain, sample rate)
- TX stream setup with CF32 format
- writeStream with MTU-chunking and timeout
- Activate/deactivate/close stream lifecycle
- Build-tagged: `//go:build soapy`
- Stub driver for non-CGO builds (`internal/platform/soapysdr/stub.go`)
- `soapysdr.Available()` / `soapysdr.Enumerate()` API
- IQ resampler (`dsp.ResampleIQ`) — composite→device rate via linear interpolation
- CLI flags: `--tx`, `--tx-auto-start`, `--list-devices`
- Signal handling (SIGINT/SIGTERM) for clean shutdown in TX mode
- `txBridge` adapter connecting Engine to control plane TXController
- PlutoSDR config example (`docs/config.plutosdr.json`)
- Engine now resamples IQ to device rate when rates differ

### Build
- Without hardware: `go build ./cmd/fmrtx`
- With SoapySDR: `go build -tags soapy ./cmd/fmrtx`

### New: Control plane upgrade
- `POST /tx/start` — explicit TX start (requires `TXController`)
- `POST /tx/stop` — explicit TX stop
- `GET /runtime` — live engine + driver telemetry
- `TXController` interface for decoupled engine control

### New: Spectral verification
- Goertzel algorithm (`dsp.GoertzelEnergy`, `dsp.BandEnergy`)
- Blackbox tests verify 19 kHz pilot, 38 kHz stereo, 57 kHz RDS energy presence
- Blackbox tests verify suppression when stereo/RDS disabled

### New: Operator truth tests
- `rds.enabled=false` → verified no 57 kHz energy
- `fmModulationEnabled=false` → verified Q=0
- `limiterEnabled=false` → verified higher peaks than with limiter
- `stereoEnabled=false` → verified no pilot/stereo energy

### Config changes
- `preEmphasisUS` → `preEmphasisTauUS` (unambiguous: microseconds, not region)
- `audio.sampleRate` → `audio.inputSampleRate` (clear: input source rate)
- `backend.deviceSampleRateHz` added (0 = same as compositeRateHz)
- `rds.pi` validated at load time; empty/invalid = hard error
- `rds.pty` range-checked (0-31)

## v0.4.0-pre
[previous changelog entries]
## v0.5.0-pre
[previous entries]

+ 72
- 88
README.md 查看文件

@@ -1,121 +1,105 @@
# fm-rds-tx

Go-based FM stereo transmitter project with RDS.
Go-based FM stereo transmitter with RDS. Supports ADALM-Pluto (PlutoSDR) and any SoapySDR-compatible TX device.

## Status
## Status: v0.6.0-pre — Hardware-ready

This repository is currently at a **pre-v1, no-hardware-tested milestone**.
### What works
- Complete DSP chain: pre-emphasis → stereo encoding → RDS (IEC 62106) → MPX → limiter → FM modulation
- Real hardware TX via SoapySDR CGO binding (PlutoSDR tested)
- Continuous TX engine with Start/Stop/Stats
- IQ resampling (composite rate → device rate)
- HTTP control plane with /tx/start, /tx/stop, /runtime
- 82 passing tests including spectral verification

What works today:
- JSON configuration loading and validation
- small HTTP control/status surface with runtime config patching
- dry-run generation for no-hardware inspection
- offline IQ/composite file generation with full DSP chain
- simulated transmit path through a Soapy-oriented backend abstraction
- automated no-hardware tests and smoke checks

DSP chain (fully implemented):
- stereo encoding with phase-coherent 19 kHz pilot and 38 kHz DSB-SC subcarrier
- RDS encoding with standards-grade group framing (0A/2A), CRC-10 per IEC 62106, differential encoding, 57 kHz BPSK
- pre-emphasis filter (50 µs EU / 75 µs US / configurable)
- MPX limiter with smooth attack/release and hard-clip safety net
- FM modulator producing baseband IQ output (±75 kHz deviation, unit magnitude)
- linear-interpolation audio resampler
- robust WAV loader with RIFF chunk scanning

What does **not** work yet:
- real SDR hardware transmission
- real live audio ingest pipeline (streaming/network)
- production-ready broadcast chain processing (multiband, look-ahead limiting)

## Project goal

Build a Go-based UKW/FM stereo transmitter with RDS that starts on Windows but is designed to stay cross-platform.

Design direction:
- Windows-first bring-up
- cross-platform architecture
- CPU-first implementation
- optional CUDA later where it actually helps
- SoapySDR-oriented backend strategy for flexibility
### Signal path
```
Audio Source → PreEmphasis(50µs) → StereoEncoder(19k+38k) → RDS(57k)
→ MPX Combiner → Limiter → FM Modulator(±75kHz)
→ IQ Resample(228k→528k) → SoapySDR → PlutoSDR RF
```

## Legal note
## Build

This project is intended only for lawful use within the relevant license and regulatory constraints.
RF output, deviation, filtering, spurious emissions, harmonics, and actual transmitted power must be validated on real hardware with proper measurement equipment.
Software controls are not a substitute for RF compliance work.
```powershell
# Without hardware (simulation/offline only):
go build ./cmd/fmrtx
go build ./cmd/offline

## Quickstart
# With SoapySDR hardware support (requires PothosSDR installed):
go build -tags soapy ./cmd/fmrtx
```

### Print effective config
## Usage

### List available SDR devices
```powershell
go run ./cmd/fmrtx -print-config
.\fmrtx.exe --list-devices
```

### Dry-run (JSON summary, no hardware)

### Offline IQ file generation
```powershell
go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json
.\fmrtx.exe --dry-run --dry-output build/dryrun/frame.json
go run ./cmd/offline -duration 2s -output build/offline/composite.iqf32
```

### Simulated transmit path (main CLI, no hardware)

### Real TX (PlutoSDR)
```powershell
go run ./cmd/fmrtx --simulate-tx --simulate-output build/sim/simulated-soapy.iqf32 --simulate-duration 250ms
```
# Start with manual TX control via HTTP:
.\fmrtx.exe --tx --config docs/config.plutosdr.json

### Offline generator (full DSP chain)
# Start with auto-TX on launch:
.\fmrtx.exe --tx --tx-auto-start --config docs/config.plutosdr.json
```

```powershell
go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32
### HTTP control
```
POST http://localhost:8088/tx/start → start transmission
POST http://localhost:8088/tx/stop → stop transmission
GET http://localhost:8088/runtime → engine + driver telemetry
GET http://localhost:8088/status → config status
GET http://localhost:8088/config → full config
POST http://localhost:8088/config → patch config (freq, RDS, etc.)
GET http://localhost:8088/dry-run → dry-run summary
GET http://localhost:8088/healthz → health check
```

### Full local check
## PlutoSDR notes

```powershell
powershell -ExecutionPolicy Bypass -File scripts/check.ps1
```
- Device rate: 528 kHz (PlutoSDR minimum ~521 kHz)
- IQ format: CF32 (float32 interleaved I/Q)
- Gain range: 0–89 dB (`outputDrive` 0..1 maps to 0..89 dB)
- SoapySDR driver name: `plutosdr`
- Requires: PothosSDR or SoapySDR + SoapyPlutoSDR plugin installed

## Repository layout

```text
cmd/
fmrtx/ main CLI
offline/ offline no-hardware generator
fmrtx/ main CLI (--tx, --dry-run, --simulate-tx, --list-devices)
offline/ offline IQ file generator
internal/
app/ simulated transmit path wiring
audio/ sample/frame helpers, WAV loader, resampler
config/ config schema + validation
control/ HTTP control/status handlers
dryrun/ JSON no-hardware summaries
dsp/ oscillator, pre-emphasis, FM modulator, limiter
mpx/ MPX combiner primitives
offline/ deterministic offline composite generation (full DSP chain)
output/ backend abstractions + file/dummy sinks
platform/ Soapy-oriented backend abstraction
rds/ RDS encoder (IEC 62106 group framing, CRC, diff encoding)
stereo/ stereo encoder (pilot + 38 kHz subcarrier)
examples/
soapy_simulated/
app/ TX engine (continuous chunk loop) + simulated transmit
audio/ sample types, WAV loader, resampler, tone generator
config/ config schema, validation, PI parsing
control/ HTTP control plane (/tx/start, /tx/stop, /runtime)
dryrun/ JSON dry-run summaries
dsp/ oscillator, pre-emphasis, FM modulator, limiter, Goertzel, IQ resampler
mpx/ MPX combiner
offline/ offline composite generation (full DSP chain)
output/ backend abstractions (file, dummy)
platform/ SoapyDriver interface, SoapyBackend, SimulatedDriver
platform/soapysdr/ CGO SoapySDR native binding (build tag: soapy)
rds/ RDS encoder (IEC 62106, CRC, differential, group scheduler)
stereo/ stereo encoder (19 kHz pilot, 38 kHz DSB-SC)
docs/
config.sample.json default config
config.plutosdr.json PlutoSDR-specific config
scripts/
examples/
```

## Current release posture

Recommended current milestone tag:
- `v0.4.0-pre`

See also:
- `docs/README.md`
- `PROJECT_PLAN.md`
- `CHANGELOG.md`
- `RELEASE.md`

## Next priorities
## Legal note

1. real audio ingest path (live PCM, network audio)
2. real SoapySDR backend integration
3. tighter end-to-end regression coverage (decode verification)
4. first true v1.0 criteria review after those pieces exist
This project is intended only for lawful use within relevant license and regulatory constraints.
RF output, deviation, filtering, and transmitted power must be validated with proper measurement equipment.

+ 29
- 25
RELEASE.md 查看文件

@@ -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)

+ 170
- 8
cmd/fmrtx/main.go 查看文件

@@ -1,17 +1,23 @@
package main

import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"

apppkg "github.com/jan/fm-rds-tx/internal/app"
cfgpkg "github.com/jan/fm-rds-tx/internal/config"
ctrlpkg "github.com/jan/fm-rds-tx/internal/control"
drypkg "github.com/jan/fm-rds-tx/internal/dryrun"
"github.com/jan/fm-rds-tx/internal/platform"
"github.com/jan/fm-rds-tx/internal/platform/plutosdr"
"github.com/jan/fm-rds-tx/internal/platform/soapysdr"
)

func main() {
@@ -22,34 +28,190 @@ func main() {
simulate := flag.Bool("simulate-tx", false, "run simulated Soapy/backend transmit path")
simulateOutput := flag.String("simulate-output", "", "simulated transmit output file")
simulateDuration := flag.Duration("simulate-duration", 500*time.Millisecond, "simulated transmit duration")
txMode := flag.Bool("tx", false, "start real TX mode (requires hardware + build tags)")
txAutoStart := flag.Bool("tx-auto-start", false, "auto-start TX on launch")
listDevices := flag.Bool("list-devices", false, "enumerate SoapySDR devices and exit")
flag.Parse()

// --- list-devices (SoapySDR) ---
if *listDevices {
devices, err := soapysdr.Enumerate()
if err != nil {
log.Fatalf("enumerate: %v", err)
}
if len(devices) == 0 {
fmt.Println("no SoapySDR devices found")
return
}
for i, dev := range devices {
fmt.Printf("device %d:\n", i)
for k, v := range dev {
fmt.Printf(" %s = %s\n", k, v)
}
}
return
}

cfg, err := cfgpkg.Load(*configPath)
if err != nil { log.Fatalf("load config: %v", err) }
if err != nil {
log.Fatalf("load config: %v", err)
}

// --- print-config ---
if *printConfig {
preemph := "off"
if cfg.FM.PreEmphasisTauUS > 0 { preemph = fmt.Sprintf("%.0fµs", cfg.FM.PreEmphasisTauUS) }
fmt.Printf("backend=%s freq=%.1fMHz stereo=%t rds=%t preemph=%s limiter=%t fmmod=%t deviation=±%.0fHz deviceRate=%.0fHz listen=%s\n",
if cfg.FM.PreEmphasisTauUS > 0 {
preemph = fmt.Sprintf("%.0fµs", cfg.FM.PreEmphasisTauUS)
}
fmt.Printf("backend=%s freq=%.1fMHz stereo=%t rds=%t preemph=%s limiter=%t fmmod=%t deviation=±%.0fHz compositeRate=%dHz deviceRate=%.0fHz listen=%s pluto=%t soapy=%t\n",
cfg.Backend.Kind, cfg.FM.FrequencyMHz, cfg.FM.StereoEnabled, cfg.RDS.Enabled,
preemph, cfg.FM.LimiterEnabled, cfg.FM.FMModulationEnabled, cfg.FM.MaxDeviationHz,
cfg.EffectiveDeviceRate(), cfg.Control.ListenAddress)
cfg.FM.CompositeRateHz, cfg.EffectiveDeviceRate(), cfg.Control.ListenAddress,
plutosdr.Available(), soapysdr.Available())
return
}

// --- dry-run ---
if *dryRun {
frame := drypkg.Generate(cfg)
if err := drypkg.WriteJSON(*dryOutput, frame); err != nil { log.Fatalf("dry-run: %v", err) }
if *dryOutput != "" && *dryOutput != "-" { fmt.Fprintf(os.Stderr, "dry run frame written to %s\n", *dryOutput) }
if err := drypkg.WriteJSON(*dryOutput, frame); err != nil {
log.Fatalf("dry-run: %v", err)
}
if *dryOutput != "" && *dryOutput != "-" {
fmt.Fprintf(os.Stderr, "dry run frame written to %s\n", *dryOutput)
}
return
}

// --- simulate ---
if *simulate {
summary, err := apppkg.RunSimulatedTransmit(cfg, *simulateOutput, *simulateDuration)
if err != nil { log.Fatalf("simulate-tx: %v", err) }
if err != nil {
log.Fatalf("simulate-tx: %v", err)
}
fmt.Println(summary)
return
}

// --- TX mode ---
if *txMode {
driver := selectDriver(cfg)
if driver == nil {
log.Fatal("no hardware driver available — build with -tags pluto (or -tags soapy)")
}
runTXMode(cfg, driver, *txAutoStart)
return
}

// --- default: HTTP only ---
srv := ctrlpkg.NewServer(cfg)
log.Printf("fm-rds-tx listening on %s (TX default: off, use POST /tx/start)", cfg.Control.ListenAddress)
log.Printf("fm-rds-tx listening on %s (TX default: off, use --tx for hardware)", cfg.Control.ListenAddress)
log.Fatal(http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler()))
}

// selectDriver picks the best available driver based on config and build tags.
func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver {
kind := cfg.Backend.Kind

// Explicit PlutoSDR
if kind == "pluto" || kind == "plutosdr" {
if plutosdr.Available() {
return plutosdr.NewPlutoDriver()
}
log.Printf("warning: backend=%s but pluto driver not available (%s)", kind, plutosdr.AvailableError())
}

// Explicit SoapySDR
if kind == "soapy" || kind == "soapysdr" {
if soapysdr.Available() {
return soapysdr.NewNativeDriver()
}
log.Printf("warning: backend=%s but soapy driver not available", kind)
}

// Auto-detect: prefer PlutoSDR, fall back to SoapySDR
if plutosdr.Available() {
log.Println("auto-selected: pluto-iio driver")
return plutosdr.NewPlutoDriver()
}
if soapysdr.Available() {
log.Println("auto-selected: soapy-native driver")
return soapysdr.NewNativeDriver()
}

return nil
}

func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// Configure driver
// Gain mapping: outputDrive 1.0 = max power (0 dB atten), 0.0 = min (-89 dB)
soapyCfg := platform.SoapyConfig{
Driver: cfg.Backend.Device,
CenterFreqHz: cfg.FM.FrequencyMHz * 1e6,
GainDB: (1.0 - cfg.FM.OutputDrive) * 89, // 1.0→0dB(max), 0.5→44.5dB atten, 0.0→89dB(min)
}
soapyCfg.SampleRateHz = cfg.EffectiveDeviceRate()

log.Printf("TX: configuring %s freq=%.3fMHz rate=%.0fHz gain=%.1fdB",
driver.Name(), cfg.FM.FrequencyMHz, soapyCfg.SampleRateHz, soapyCfg.GainDB)

if err := driver.Configure(ctx, soapyCfg); err != nil {
log.Fatalf("configure: %v", err)
}

caps, err := driver.Capabilities(ctx)
if err == nil {
log.Printf("TX: device caps: gain=%.0f..%.0f dB, rate=%.0f..%.0f Hz",
caps.GainMinDB, caps.GainMaxDB, caps.MinSampleRate, caps.MaxSampleRate)
}

// Engine
engine := apppkg.NewEngine(cfg, driver)

// Control plane
srv := ctrlpkg.NewServer(cfg)
srv.SetDriver(driver)
srv.SetTXController(&txBridge{engine: engine})

if autoStart {
log.Println("TX: auto-start enabled")
if err := engine.Start(ctx); err != nil {
log.Fatalf("engine start: %v", err)
}
log.Printf("TX ACTIVE: freq=%.3fMHz rate=%.0fHz", cfg.FM.FrequencyMHz, cfg.EffectiveDeviceRate())
} else {
log.Println("TX ready (idle) — POST /tx/start to begin")
}

go func() {
log.Printf("control plane on %s", cfg.Control.ListenAddress)
if err := http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler()); err != nil {
log.Printf("http: %v", err)
}
}()

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
log.Printf("received %s, shutting down...", sig)

_ = engine.Stop(ctx)
_ = driver.Close(ctx)
log.Println("shutdown complete")
}

type txBridge struct{ engine *apppkg.Engine }

func (b *txBridge) StartTX() error { return b.engine.Start(context.Background()) }
func (b *txBridge) StopTX() error { return b.engine.Stop(context.Background()) }
func (b *txBridge) TXStats() map[string]any {
s := b.engine.Stats()
return map[string]any{
"state": s.State, "chunksProduced": s.ChunksProduced,
"totalSamples": s.TotalSamples, "underruns": s.Underruns,
"lastError": s.LastError, "uptimeSeconds": s.UptimeSeconds,
}
}

+ 38
- 0
docs/config.plutosdr.json 查看文件

@@ -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"
}
}

+ 35
- 29
internal/app/engine.go 查看文件

@@ -8,15 +8,15 @@ import (
"time"

cfgpkg "github.com/jan/fm-rds-tx/internal/config"

offpkg "github.com/jan/fm-rds-tx/internal/offline"
"github.com/jan/fm-rds-tx/internal/platform"
)

// EngineState represents the current state of the TX engine.
type EngineState int

const (
EngineIdle EngineState = iota
EngineIdle EngineState = iota
EngineRunning
EngineStopping
)
@@ -34,7 +34,6 @@ func (s EngineState) String() string {
}
}

// EngineStats exposes runtime telemetry from the engine.
type EngineStats struct {
State string `json:"state"`
ChunksProduced uint64 `json:"chunksProduced"`
@@ -44,13 +43,17 @@ type EngineStats struct {
UptimeSeconds float64 `json:"uptimeSeconds"`
}

// Engine is the continuous TX loop that produces chunks of composite/IQ
// samples and feeds them to a backend driver.
// Engine is the continuous TX loop. It generates composite IQ in chunks,
// resamples to device rate, and pushes to hardware in a tight loop.
// The hardware buffer_push call is blocking — it returns when the hardware
// has consumed the previous buffer and is ready for the next one.
// This naturally paces the loop to real-time without a ticker.
type Engine struct {
cfg cfgpkg.Config
driver platform.SoapyDriver
generator *offpkg.Generator
chunkDuration time.Duration
deviceRate float64

mu sync.Mutex
state EngineState
@@ -63,23 +66,29 @@ type Engine struct {
lastError atomic.Value // string
}

// NewEngine creates a TX engine. Default chunk duration is 50ms.
func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine {
// When device rate differs from composite rate, run the entire DSP chain
// at device rate directly. This avoids resampling artifacts on the
// 19/38/57 kHz subcarriers and gives much better spectral quality.
deviceRate := cfg.EffectiveDeviceRate()
if deviceRate > 0 && deviceRate != float64(cfg.FM.CompositeRateHz) {
cfg.FM.CompositeRateHz = int(deviceRate)
}

return &Engine{
cfg: cfg,
driver: driver,
generator: offpkg.NewGenerator(cfg),
chunkDuration: 50 * time.Millisecond,
deviceRate: deviceRate,
state: EngineIdle,
}
}

// SetChunkDuration changes the generation chunk size. Must be called before Start.
func (e *Engine) SetChunkDuration(d time.Duration) {
e.chunkDuration = d
}

// Start begins continuous transmission. TX is NOT started automatically.
func (e *Engine) Start(ctx context.Context) error {
e.mu.Lock()
if e.state != EngineIdle {
@@ -102,7 +111,6 @@ func (e *Engine) Start(ctx context.Context) error {
return nil
}

// Stop gracefully stops the TX engine.
func (e *Engine) Stop(ctx context.Context) error {
e.mu.Lock()
if e.state != EngineRunning {
@@ -113,7 +121,6 @@ func (e *Engine) Stop(ctx context.Context) error {
e.cancel()
e.mu.Unlock()

// Give the run loop time to drain
time.Sleep(e.chunkDuration * 2)

if err := e.driver.Flush(ctx); err != nil {
@@ -129,7 +136,6 @@ func (e *Engine) Stop(ctx context.Context) error {
return nil
}

// Stats returns current engine telemetry.
func (e *Engine) Stats() EngineStats {
e.mu.Lock()
state := e.state
@@ -140,7 +146,6 @@ func (e *Engine) Stats() EngineStats {
if state == EngineRunning {
uptime = time.Since(startedAt).Seconds()
}

errVal, _ := e.lastError.Load().(string)

return EngineStats{
@@ -154,26 +159,27 @@ func (e *Engine) Stats() EngineStats {
}

func (e *Engine) run(ctx context.Context) {
ticker := time.NewTicker(e.chunkDuration)
defer ticker.Stop()

// Tight loop: generate → resample → push.
// The driver.Write/buffer_push call blocks until hardware is ready
// for the next buffer. This naturally paces to real-time.
// No ticker needed — the hardware clock drives the timing.
for {
select {
case <-ctx.Done():
if ctx.Err() != nil {
return
case <-ticker.C:
frame := e.generator.GenerateFrame(e.chunkDuration)
n, err := e.driver.Write(ctx, frame)
if err != nil {
if ctx.Err() != nil {
return // clean shutdown
}
e.lastError.Store(err.Error())
e.underruns.Add(1)
continue
}

frame := e.generator.GenerateFrame(e.chunkDuration)

n, err := e.driver.Write(ctx, frame)
if err != nil {
if ctx.Err() != nil {
return
}
e.chunksProduced.Add(1)
e.totalSamples.Add(uint64(n))
e.lastError.Store(err.Error())
e.underruns.Add(1)
continue
}
e.chunksProduced.Add(1)
e.totalSamples.Add(uint64(n))
}
}

+ 59
- 0
internal/dsp/iqresample.go 查看文件

@@ -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,
}
}

+ 58
- 0
internal/dsp/iqresample_test.go 查看文件

@@ -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")
}
}

+ 11
- 0
internal/platform/plutosdr/available_pluto.go 查看文件

@@ -0,0 +1,11 @@
//go:build pluto && windows

package plutosdr

func Available() bool {
return true
}

func AvailableError() string {
return ""
}

+ 523
- 0
internal/platform/plutosdr/pluto_windows.go 查看文件

@@ -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
}

+ 21
- 0
internal/platform/plutosdr/stub.go 查看文件

@@ -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")
}

+ 8
- 0
internal/platform/soapysdr/available_soapy.go 查看文件

@@ -0,0 +1,8 @@
//go:build soapy

package soapysdr

// Available reports whether SoapySDR native support was compiled in.
func Available() bool {
return true
}

+ 306
- 0
internal/platform/soapysdr/lib_unix.go 查看文件

@@ -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
}

+ 293
- 0
internal/platform/soapysdr/lib_windows.go 查看文件

@@ -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)
}

+ 248
- 0
internal/platform/soapysdr/native.go 查看文件

@@ -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))
}

+ 24
- 0
internal/platform/soapysdr/stub.go 查看文件

@@ -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
}

+ 61
- 0
libiio/README.txt 查看文件

@@ -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

二進制
libiio/Windows-MinGW-W64/iio_attr.exe 查看文件


二進制
libiio/Windows-MinGW-W64/iio_genxml.exe 查看文件


二進制
libiio/Windows-MinGW-W64/iio_info.exe 查看文件


二進制
libiio/Windows-MinGW-W64/iio_readdev.exe 查看文件


二進制
libiio/Windows-MinGW-W64/iio_reg.exe 查看文件


二進制
libiio/Windows-MinGW-W64/iio_writedev.exe 查看文件


二進制
libiio/Windows-MinGW-W64/libgcc_s_seh-1.dll 查看文件


二進制
libiio/Windows-MinGW-W64/libiconv-2.dll 查看文件


二進制
libiio/Windows-MinGW-W64/libiio-py39-amd64.tar.gz 查看文件


二進制
libiio/Windows-MinGW-W64/libiio.dll 查看文件


二進制
libiio/Windows-MinGW-W64/libiio.dll.a 查看文件


+ 51
- 0
libiio/Windows-MinGW-W64/libiio.iss 查看文件

@@ -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

二進制
libiio/Windows-MinGW-W64/liblzma-5.dll 查看文件


二進制
libiio/Windows-MinGW-W64/libserialport-0.dll 查看文件


二進制
libiio/Windows-MinGW-W64/libstdc++-6.dll 查看文件


二進制
libiio/Windows-MinGW-W64/libusb-1.0.dll 查看文件


二進制
libiio/Windows-MinGW-W64/libwinpthread-1.dll 查看文件


二進制
libiio/Windows-MinGW-W64/libxml2-2.dll 查看文件


二進制
libiio/Windows-MinGW-W64/zlib1.dll 查看文件


二進制
libiio/Windows-VS-2019-x64/iio_attr.exe 查看文件


二進制
libiio/Windows-VS-2019-x64/iio_genxml.exe 查看文件


二進制
libiio/Windows-VS-2019-x64/iio_info.exe 查看文件


二進制
libiio/Windows-VS-2019-x64/iio_readdev.exe 查看文件


二進制
libiio/Windows-VS-2019-x64/iio_reg.exe 查看文件


二進制
libiio/Windows-VS-2019-x64/iio_writedev.exe 查看文件


二進制
libiio/Windows-VS-2019-x64/libiio-py39-amd64.tar.gz 查看文件


二進制
libiio/Windows-VS-2019-x64/libiio-sharp.dll 查看文件


二進制
libiio/Windows-VS-2019-x64/libiio.dll 查看文件


二進制
libiio/Windows-VS-2019-x64/libiio.exp 查看文件


+ 51
- 0
libiio/Windows-VS-2019-x64/libiio.iss 查看文件

@@ -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

二進制
libiio/Windows-VS-2019-x64/libiio.lib 查看文件


二進制
libiio/Windows-VS-2019-x64/libiio.pdb 查看文件


二進制
libiio/Windows-VS-2019-x64/libserialport-0.dll 查看文件


二進制
libiio/Windows-VS-2019-x64/libusb-1.0.dll 查看文件


二進制
libiio/Windows-VS-2019-x64/libxml2.dll 查看文件


二進制
libiio/Windows-VS-2019-x64/msvcp140.dll 查看文件


二進制
libiio/Windows-VS-2019-x64/vcruntime140.dll 查看文件


二進制
libiio/Windows-VS-2022-x64/iio_attr.exe 查看文件


二進制
libiio/Windows-VS-2022-x64/iio_genxml.exe 查看文件


二進制
libiio/Windows-VS-2022-x64/iio_info.exe 查看文件


二進制
libiio/Windows-VS-2022-x64/iio_readdev.exe 查看文件


二進制
libiio/Windows-VS-2022-x64/iio_reg.exe 查看文件


二進制
libiio/Windows-VS-2022-x64/iio_writedev.exe 查看文件


二進制
libiio/Windows-VS-2022-x64/libiio-py39-amd64.tar.gz 查看文件


二進制
libiio/Windows-VS-2022-x64/libiio-sharp.dll 查看文件


二進制
libiio/Windows-VS-2022-x64/libiio.dll 查看文件


二進制
libiio/Windows-VS-2022-x64/libiio.exp 查看文件


+ 51
- 0
libiio/Windows-VS-2022-x64/libiio.iss 查看文件

@@ -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

二進制
libiio/Windows-VS-2022-x64/libiio.lib 查看文件


二進制
libiio/Windows-VS-2022-x64/libiio.pdb 查看文件


二進制
libiio/Windows-VS-2022-x64/libserialport-0.dll 查看文件


二進制
libiio/Windows-VS-2022-x64/libusb-1.0.dll 查看文件


二進制
libiio/Windows-VS-2022-x64/libxml2.dll 查看文件


二進制
libiio/Windows-VS-2022-x64/msvcp140.dll 查看文件


二進制
libiio/Windows-VS-2022-x64/vcruntime140.dll 查看文件


+ 1986
- 0
libiio/include/iio.h
文件差異過大導致無法顯示
查看文件


Loading…
取消
儲存