From 70c966dbada7bbdeb6b4c7a6133113ca7399eb21 Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Wed, 16 Sep 2026 22:44:27 +0300 Subject: [PATCH] audio is prepared, next is MXL part --- cmd/mxl-pattern/main.go | 34 ++++++- cmd/mxl-pattern/main_test.go | 21 +++++ internal/audio/config.go | 6 ++ internal/audio/generator.go | 122 ++++++++++++++++++++++++ internal/audio/generator_test.go | 155 +++++++++++++++++++++++++++++++ 5 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 internal/audio/generator.go create mode 100644 internal/audio/generator_test.go diff --git a/cmd/mxl-pattern/main.go b/cmd/mxl-pattern/main.go index 45a889b..7f4b1d9 100644 --- a/cmd/mxl-pattern/main.go +++ b/cmd/mxl-pattern/main.go @@ -52,6 +52,7 @@ type appArgs struct { audioChannels uint8 audioSamplingFreq string + audioLevel string audioUUID string } @@ -160,7 +161,17 @@ func validateVideoArgs(args *appArgs) error { } func validateAudioArgs(args *appArgs) error { - if args.audioFlowDefFile != "" || args.audioChannels == 0 { + if args.audioFlowDefFile == "" && args.audioChannels == 0 { + return nil + } + if _, ok := audioLevels[args.audioLevel]; !ok { + return fmt.Errorf( + "unsupported audio level %q (supported: %s)", + args.audioLevel, + sortedMapKeys(audioLevels), + ) + } + if args.audioFlowDefFile != "" { return nil } if _, exists := samplingRates[args.audioSamplingFreq]; !exists { @@ -272,6 +283,11 @@ var patterns = map[string]pattern{ }, } +var audioLevels = map[string]float64{ + "ebu": audio.LevelEBUDBFS, + "smpte": audio.LevelSMPTEDBFS, +} + func listPatterns(w io.Writer) { fmt.Fprintln(w, "List of available video patterns:") names := make([]string, 0, len(patterns)) @@ -327,9 +343,10 @@ func addFlags(fs *pflag.FlagSet, args *appArgs) { fs.StringVar(&args.videoFPS, "fps", "25", "Video pattern FPS") fs.StringVar(&args.videoUUID, "video-id", "", "Video UUID. Will be created, if not provided") // Audio pattern flags - fs.Uint8VarP(&args.audioChannels, "channel", "c", 0, "Amount of audio channels. Each channel: num * 1kHz. Zero = no sound [TODO]") - fs.StringVarP(&args.audioSamplingFreq, "freq", "f", "48", "Sampling frequency of test audio feed in kHz [TODO]") - fs.StringVar(&args.audioUUID, "audio-id", "", "Audio UUID. Will be created, if not provided [TODO]") + fs.Uint8VarP(&args.audioChannels, "channel", "c", 0, "Amount of audio channels. Each channel: num * 1kHz") + fs.StringVarP(&args.audioSamplingFreq, "freq", "f", "48", "Sampling frequency of test audio feed in kHz") + fs.StringVar(&args.audioLevel, "audio-level", "ebu", "Audio alignment level: ebu (-18 dBFS) or smpte (-20 dBFS)") + fs.StringVar(&args.audioUUID, "audio-id", "", "Audio UUID. Will be created, if not provided") } func parseArgs(argv []string, stdout, stderr io.Writer) (parseResult, error) { @@ -425,6 +442,10 @@ func buildAudioConfig(args appArgs) (*audio.Config, error) { if args.audioFlowDefFile == "" && args.audioChannels == 0 { return nil, nil } + levelDBFS, ok := audioLevels[args.audioLevel] + if !ok { + return nil, fmt.Errorf("unsupported audio level %q", args.audioLevel) + } var definition flowdef.Audio if args.audioFlowDefFile != "" { @@ -452,7 +473,10 @@ func buildAudioConfig(args appArgs) (*audio.Config, error) { } } - return &audio.Config{Definition: definition}, nil + return &audio.Config{ + Definition: definition, + LevelDBFS: levelDBFS, + }, nil } func main() { diff --git a/cmd/mxl-pattern/main_test.go b/cmd/mxl-pattern/main_test.go index 3f1dee0..6b226cd 100644 --- a/cmd/mxl-pattern/main_test.go +++ b/cmd/mxl-pattern/main_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "mxl-pattern-generator/internal/audio" "mxl-pattern-generator/internal/flowdef" ) @@ -178,6 +179,7 @@ func TestBuildAudioConfigFromArgs(t *testing.T) { audioUUID: "b3bb5be7-9fe9-4324-a5bb-4c70e1084449", audioChannels: 2, audioSamplingFreq: "48", + audioLevel: "ebu", }) if err != nil { t.Fatalf("buildAudioConfig: %v", err) @@ -191,6 +193,9 @@ func TestBuildAudioConfigFromArgs(t *testing.T) { if cfg.Rate().Num != 48000 || cfg.Rate().Den != 1 { t.Fatalf("rate = %d/%d, want 48000/1", cfg.Rate().Num, cfg.Rate().Den) } + if cfg.LevelDBFS != audio.LevelEBUDBFS { + t.Fatalf("level = %.2f dBFS, want %.2f dBFS", cfg.LevelDBFS, audio.LevelEBUDBFS) + } } func TestBuildAudioConfigFromFile(t *testing.T) { @@ -215,6 +220,7 @@ func TestBuildAudioConfigFromFile(t *testing.T) { audioFlowDefFile: path, audioChannels: 2, audioSamplingFreq: "48", + audioLevel: "smpte", }) if err != nil { t.Fatalf("buildAudioConfig: %v", err) @@ -228,4 +234,19 @@ func TestBuildAudioConfigFromFile(t *testing.T) { if cfg.Rate().Num != 96000 || cfg.Rate().Den != 1 { t.Fatalf("rate = %d/%d, want file value 96000/1", cfg.Rate().Num, cfg.Rate().Den) } + if cfg.LevelDBFS != audio.LevelSMPTEDBFS { + t.Fatalf("level = %.2f dBFS, want %.2f dBFS", cfg.LevelDBFS, audio.LevelSMPTEDBFS) + } +} + +func TestValidateAudioArgsRejectsUnknownLevelForFlowDefinition(t *testing.T) { + args := appArgs{ + audioFlowDefFile: "audio.json", + audioLevel: "unknown", + } + + err := validateAudioArgs(&args) + if err == nil || !strings.Contains(err.Error(), "unsupported audio level") { + t.Fatalf("error = %v, want unsupported audio level error", err) + } } diff --git a/internal/audio/config.go b/internal/audio/config.go index 34f295e..38795bf 100644 --- a/internal/audio/config.go +++ b/internal/audio/config.go @@ -6,8 +6,14 @@ import ( "github.com/qvest-digital/go-mxl/mxl" ) +const ( + LevelEBUDBFS = -18.0 + LevelSMPTEDBFS = -20.0 +) + type Config struct { Definition flowdef.Audio + LevelDBFS float64 } func (c Config) ID() string { diff --git a/internal/audio/generator.go b/internal/audio/generator.go new file mode 100644 index 0000000..ab55283 --- /dev/null +++ b/internal/audio/generator.go @@ -0,0 +1,122 @@ +package audio + +import ( + "encoding/binary" + "fmt" + "math" +) + +type Generator interface { + // Generate fills consecutive float32 sample fragments for one channel. + // firstSample is the absolute MXL sample index, so phase does not depend on + // batch or ring-buffer boundaries. + Generate(channel uint, firstSample uint64, fragments ...[]byte) error +} + +type SineGenerator struct { + channels uint + sampleRate float64 + baseFrequency float64 + amplitude float32 +} + +func NewSineGenerator( + cfg Config, + baseFrequency float64, +) (*SineGenerator, error) { + if cfg.Channels() == 0 { + return nil, fmt.Errorf("channel count must be greater than zero") + } + rate := cfg.Rate() + if rate.Num <= 0 || rate.Den <= 0 { + return nil, fmt.Errorf( + "sample rate numerator and denominator must be greater than zero, got %d/%d", + rate.Num, + rate.Den, + ) + } + if math.IsNaN(baseFrequency) || math.IsInf(baseFrequency, 0) || baseFrequency <= 0 { + return nil, fmt.Errorf("base frequency must be finite and greater than zero, got %g", baseFrequency) + } + + sampleRate := float64(rate.Num) / float64(rate.Den) + highestFrequency := float64(cfg.Channels()) * baseFrequency + nyquist := sampleRate / 2 + if highestFrequency >= nyquist { + return nil, fmt.Errorf( + "highest tone frequency %.0f Hz must be below Nyquist frequency %.0f Hz", + highestFrequency, + nyquist, + ) + } + + if math.IsNaN(cfg.LevelDBFS) || math.IsInf(cfg.LevelDBFS, 0) { + return nil, fmt.Errorf("audio level must be finite") + } + if cfg.LevelDBFS > 0 { + return nil, fmt.Errorf( + "audio level must not exceed 0 dBFS, got %.2f", + cfg.LevelDBFS, + ) + } + if cfg.LevelDBFS < -100 { + return nil, fmt.Errorf( + "audio level must be at least -100 dBFS, got %.2f dBFS", + cfg.LevelDBFS, + ) + } + + amplitude := math.Pow(10, cfg.LevelDBFS/20) + return &SineGenerator{ + channels: cfg.Channels(), + sampleRate: sampleRate, + baseFrequency: baseFrequency, + amplitude: float32(amplitude), + }, nil +} + +func (g *SineGenerator) Generate( + channel uint, + firstSample uint64, + fragments ...[]byte, +) error { + if channel >= g.channels { + return fmt.Errorf( + "audio channel %d is out of range [0, %d)", + channel, + g.channels, + ) + } + + frequency := float64(channel+1) * g.baseFrequency + sampleIndex := firstSample + + for fragmentIndex, fragment := range fragments { + if len(fragment)%4 != 0 { + return fmt.Errorf( + "audio fragment %d has %d bytes; float32 data requires a multiple of 4", + fragmentIndex, + len(fragment), + ) + } + } + + for _, fragment := range fragments { + for offset := 0; offset < len(fragment); offset += 4 { + phase := 2 * math.Pi * + frequency * + float64(sampleIndex) / + g.sampleRate + + sample := g.amplitude * float32(math.Sin(phase)) + + binary.LittleEndian.PutUint32( + fragment[offset:offset+4], + math.Float32bits(sample), + ) + sampleIndex++ + } + } + + return nil +} diff --git a/internal/audio/generator_test.go b/internal/audio/generator_test.go new file mode 100644 index 0000000..2214fcf --- /dev/null +++ b/internal/audio/generator_test.go @@ -0,0 +1,155 @@ +package audio + +import ( + "bytes" + "encoding/binary" + "math" + "testing" + + "mxl-pattern-generator/internal/flowdef" +) + +const testAudioID = "b3bb5be7-9fe9-4324-a5bb-4c70e1084449" + +func testConfig(t *testing.T, channels uint, rate flowdef.Rational, level float64) Config { + t.Helper() + definition, err := flowdef.NewFloat32Audio(testAudioID, channels, rate) + if err != nil { + t.Fatalf("NewFloat32Audio: %v", err) + } + return Config{Definition: definition, LevelDBFS: level} +} + +func decodeSample(data []byte, index int) float32 { + return math.Float32frombits(binary.LittleEndian.Uint32(data[index*4:])) +} + +func requireSampleNear(t *testing.T, got, want float32) { + t.Helper() + if math.Abs(float64(got-want)) > 1e-6 { + t.Fatalf("sample = %.8f, want %.8f", got, want) + } +} + +func TestNewSineGeneratorValidation(t *testing.T) { + valid := testConfig(t, 2, flowdef.Rational{Numerator: 48000, Denominator: 1}, LevelEBUDBFS) + + tests := []struct { + name string + cfg Config + baseHz float64 + wantErr bool + }{ + {name: "valid", cfg: valid, baseHz: 1000}, + {name: "zero channels", cfg: Config{Definition: flowdef.Audio{SampleRate: flowdef.Rational{Numerator: 48000, Denominator: 1}}, LevelDBFS: LevelEBUDBFS}, baseHz: 1000, wantErr: true}, + {name: "zero numerator", cfg: Config{Definition: flowdef.Audio{SampleRate: flowdef.Rational{Denominator: 1}, ChannelCount: 1}, LevelDBFS: LevelEBUDBFS}, baseHz: 1000, wantErr: true}, + {name: "zero denominator", cfg: Config{Definition: flowdef.Audio{SampleRate: flowdef.Rational{Numerator: 48000}, ChannelCount: 1}, LevelDBFS: LevelEBUDBFS}, baseHz: 1000, wantErr: true}, + {name: "zero base", cfg: valid, baseHz: 0, wantErr: true}, + {name: "NaN base", cfg: valid, baseHz: math.NaN(), wantErr: true}, + {name: "NaN level", cfg: Config{Definition: valid.Definition, LevelDBFS: math.NaN()}, baseHz: 1000, wantErr: true}, + {name: "above full scale", cfg: Config{Definition: valid.Definition, LevelDBFS: 1}, baseHz: 1000, wantErr: true}, + {name: "too quiet", cfg: Config{Definition: valid.Definition, LevelDBFS: -101}, baseHz: 1000, wantErr: true}, + {name: "Nyquist", cfg: testConfig(t, 24, flowdef.Rational{Numerator: 48000, Denominator: 1}, LevelEBUDBFS), baseHz: 1000, wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := NewSineGenerator(tc.cfg, tc.baseHz) + if (err != nil) != tc.wantErr { + t.Fatalf("error = %v, wantErr %v", err, tc.wantErr) + } + }) + } +} + +func TestNewSineGeneratorPreservesRationalSampleRate(t *testing.T) { + cfg := testConfig(t, 1, flowdef.Rational{Numerator: 48000, Denominator: 1001}, LevelEBUDBFS) + gen, err := NewSineGenerator(cfg, 1) + if err != nil { + t.Fatalf("NewSineGenerator: %v", err) + } + want := 48000.0 / 1001.0 + if math.Abs(gen.sampleRate-want) > 1e-12 { + t.Fatalf("sample rate = %.12f, want %.12f", gen.sampleRate, want) + } +} + +func TestSineGeneratorKnownSamplesAndChannels(t *testing.T) { + cfg := testConfig(t, 2, flowdef.Rational{Numerator: 48000, Denominator: 1}, LevelSMPTEDBFS) + gen, err := NewSineGenerator(cfg, 1000) + if err != nil { + t.Fatalf("NewSineGenerator: %v", err) + } + + channel0 := make([]byte, 37*4) + if err := gen.Generate(0, 0, channel0); err != nil { + t.Fatalf("Generate channel 0: %v", err) + } + requireSampleNear(t, decodeSample(channel0, 0), 0) + requireSampleNear(t, decodeSample(channel0, 12), 0.1) + requireSampleNear(t, decodeSample(channel0, 24), 0) + requireSampleNear(t, decodeSample(channel0, 36), -0.1) + + channel1 := make([]byte, 7*4) + if err := gen.Generate(1, 0, channel1); err != nil { + t.Fatalf("Generate channel 1: %v", err) + } + requireSampleNear(t, decodeSample(channel1, 6), 0.1) +} + +func TestSineGeneratorUsesConfiguredBaseFrequency(t *testing.T) { + cfg := testConfig(t, 1, flowdef.Rational{Numerator: 48000, Denominator: 1}, LevelEBUDBFS) + gen, err := NewSineGenerator(cfg, 2000) + if err != nil { + t.Fatalf("NewSineGenerator: %v", err) + } + + data := make([]byte, 7*4) + if err := gen.Generate(0, 0, data); err != nil { + t.Fatalf("Generate: %v", err) + } + wantPeak := float32(math.Pow(10, LevelEBUDBFS/20)) + requireSampleNear(t, decodeSample(data, 6), wantPeak) +} + +func TestSineGeneratorFragmentContinuity(t *testing.T) { + cfg := testConfig(t, 1, flowdef.Rational{Numerator: 48000, Denominator: 1}, LevelEBUDBFS) + gen, err := NewSineGenerator(cfg, 1000) + if err != nil { + t.Fatalf("NewSineGenerator: %v", err) + } + + whole := make([]byte, 40*4) + if err := gen.Generate(0, 100, whole); err != nil { + t.Fatalf("Generate whole: %v", err) + } + first := make([]byte, 13*4) + second := make([]byte, 27*4) + if err := gen.Generate(0, 100, first, second); err != nil { + t.Fatalf("Generate fragments: %v", err) + } + joined := append(append([]byte(nil), first...), second...) + if !bytes.Equal(joined, whole) { + t.Fatal("fragmented output differs from contiguous output") + } +} + +func TestSineGeneratorRejectsInvalidInputWithoutWriting(t *testing.T) { + cfg := testConfig(t, 1, flowdef.Rational{Numerator: 48000, Denominator: 1}, LevelEBUDBFS) + gen, err := NewSineGenerator(cfg, 1000) + if err != nil { + t.Fatalf("NewSineGenerator: %v", err) + } + + first := bytes.Repeat([]byte{0xAA}, 8) + before := append([]byte(nil), first...) + if err := gen.Generate(0, 0, first, make([]byte, 3)); err == nil { + t.Fatal("Generate accepted a misaligned fragment") + } + if !bytes.Equal(first, before) { + t.Fatal("Generate modified data before reporting an invalid fragment") + } + if err := gen.Generate(1, 0, make([]byte, 4)); err == nil { + t.Fatal("Generate accepted an out-of-range channel") + } +}