Просмотр исходного кода

feat: add offline composite generator command

tags/v0.3.0-pre
Jan Svabenik 1 месяц назад
Родитель
Сommit
41ca46996f
6 измененных файлов: 174 добавлений и 0 удалений
  1. +29
    -0
      cmd/offline/main.go
  2. +6
    -0
      docs/README.md
  3. +96
    -0
      internal/offline/generator.go
  4. +38
    -0
      internal/offline/generator_test.go
  5. +4
    -0
      internal/output/file.go
  6. +1
    -0
      scripts/check.ps1

+ 29
- 0
cmd/offline/main.go Просмотреть файл

@@ -0,0 +1,29 @@
package main

import (
"flag"
"fmt"
"log"
"time"

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

func main() {
configPath := flag.String("config", "", "path to JSON config")
out := flag.String("output", "", "output IQ file path")
duration := flag.Duration("duration", 2*time.Second, "generation duration")
flag.Parse()

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

gen := offpkg.NewGenerator(cfg)
if err := gen.WriteFile(*out, *duration); err != nil {
log.Fatalf("offline generation failed: %v", err)
}
fmt.Println(gen.Summary(*duration))
}

+ 6
- 0
docs/README.md Просмотреть файл

@@ -7,6 +7,7 @@
- `go run ./cmd/fmrtx -print-config`
- `go run ./cmd/fmrtx -config docs/config.sample.json`
- `go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json`
- `go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32`

### Internal DSP module
- `cd internal`
@@ -21,3 +22,8 @@

The dry-run mode generates a synthetic, hardware-free frame summary based on the current config.
It is intended as a no-hardware smoke path for the CLI and config/control-adjacent logic.

## Offline generation

`cmd/offline` generates a deterministic no-hardware IQ/composite-style file using the repository's output backend path.
This is still an MVP path, but it is a more realistic offline artifact than the JSON-only dry-run.

+ 96
- 0
internal/offline/generator.go Просмотреть файл

@@ -0,0 +1,96 @@
package offline

import (
"context"
"encoding/binary"
"fmt"
"math"
"path/filepath"
"time"

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

type Generator struct {
cfg cfgpkg.Config
}

func NewGenerator(cfg cfgpkg.Config) *Generator {
return &Generator{cfg: cfg}
}

func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame {
sampleRate := float64(g.cfg.FM.CompositeRateHz)
if sampleRate <= 0 {
sampleRate = 228000
}
samples := int(duration.Seconds() * sampleRate)
if samples <= 0 {
samples = int(sampleRate / 10)
}

frame := &output.CompositeFrame{
Samples: make([]output.IQSample, samples),
SampleRateHz: sampleRate,
Timestamp: time.Now().UTC(),
Sequence: 1,
}

leftFreq := 1000.0
rightFreq := 1600.0
pilotFreq := 19000.0
rdsFreq := 57000.0

for i := 0; i < samples; i++ {
t := float64(i) / sampleRate
left := 0.4 * math.Sin(2*math.Pi*leftFreq*t)
right := 0.4 * math.Sin(2*math.Pi*rightFreq*t+math.Pi/3)
mono := (left + right) / 2
stereo := (left - right) / 2 * 0.8 * math.Sin(2*math.Pi*38000*t)
pilot := g.cfg.FM.PilotLevel * math.Sin(2*math.Pi*pilotFreq*t)
rds := g.cfg.FM.RDSInjection * math.Sin(2*math.Pi*rdsFreq*t)
composite := (mono + stereo + pilot + rds) * g.cfg.FM.OutputDrive
frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0}
}

return frame
}

func (g *Generator) WriteFile(path string, duration time.Duration) error {
if path == "" {
path = g.cfg.Backend.OutputPath
}
if path == "" {
path = filepath.Join("build", "offline", "composite.iqf32")
}
backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{
Name: "offline-file",
Description: "offline composite file backend",
})
if err != nil {
return err
}
defer backend.Close(context.Background())

if err := backend.Configure(context.Background(), output.BackendConfig{
SampleRateHz: float64(g.cfg.FM.CompositeRateHz),
Channels: 2,
IQLevel: float32(g.cfg.FM.OutputDrive),
}); err != nil {
return err
}

frame := g.GenerateFrame(duration)
if _, err := backend.Write(context.Background(), frame); err != nil {
return err
}
if err := backend.Flush(context.Background()); err != nil {
return err
}
return nil
}

func (g *Generator) Summary(duration time.Duration) string {
return fmt.Sprintf("offline frame: freq=%.1fMHz sampleRate=%d duration=%s outputDrive=%.2f", g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(), g.cfg.FM.OutputDrive)
}

+ 38
- 0
internal/offline/generator_test.go Просмотреть файл

@@ -0,0 +1,38 @@
package offline

import (
"os"
"path/filepath"
"testing"
"time"

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

func TestGenerateFrame(t *testing.T) {
g := NewGenerator(cfgpkg.Default())
frame := g.GenerateFrame(50 * time.Millisecond)
if frame == nil {
t.Fatal("expected frame")
}
if len(frame.Samples) == 0 {
t.Fatal("expected samples")
}
}

func TestWriteFile(t *testing.T) {
cfg := cfgpkg.Default()
out := filepath.Join(t.TempDir(), "test.iqf32")
cfg.Backend.OutputPath = out
g := NewGenerator(cfg)
if err := g.WriteFile(out, 20*time.Millisecond); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
info, err := os.Stat(out)
if err != nil {
t.Fatalf("expected output file: %v", err)
}
if info.Size() == 0 {
t.Fatal("expected non-empty file")
}
}

+ 4
- 0
internal/output/file.go Просмотреть файл

@@ -6,6 +6,7 @@ import (
"fmt"
"math"
"os"
"path/filepath"
"sync"
)

@@ -21,6 +22,9 @@ type FileBackend struct {

// NewFileBackend creates a writer that appends float32 interleaved I/Q pairs to the named file.
func NewFileBackend(path string, order binary.ByteOrder, info BackendInfo) (*FileBackend, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("create output dir: %w", err)
}
f, err := os.Create(path)
if err != nil {
return nil, fmt.Errorf("open output file: %w", err)


+ 1
- 0
scripts/check.ps1 Просмотреть файл

@@ -1,6 +1,7 @@
$ErrorActionPreference = "Stop"
go test ./...
go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json
go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32
Push-Location internal
go test ./...
Pop-Location


Загрузка…
Отмена
Сохранить