Audio #1

Merged
itten merged 11 commits from audio into main 2026-09-16 23:50:47 +03:00
5 changed files with 333 additions and 5 deletions
Showing only changes of commit 70c966dbad - Show all commits
+29 -5
View File
@@ -52,6 +52,7 @@ type appArgs struct {
audioChannels uint8 audioChannels uint8
audioSamplingFreq string audioSamplingFreq string
audioLevel string
audioUUID string audioUUID string
} }
@@ -160,7 +161,17 @@ func validateVideoArgs(args *appArgs) error {
} }
func validateAudioArgs(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 return nil
} }
if _, exists := samplingRates[args.audioSamplingFreq]; !exists { 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) { func listPatterns(w io.Writer) {
fmt.Fprintln(w, "List of available video patterns:") fmt.Fprintln(w, "List of available video patterns:")
names := make([]string, 0, len(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.videoFPS, "fps", "25", "Video pattern FPS")
fs.StringVar(&args.videoUUID, "video-id", "", "Video UUID. Will be created, if not provided") fs.StringVar(&args.videoUUID, "video-id", "", "Video UUID. Will be created, if not provided")
// Audio pattern flags // Audio pattern flags
fs.Uint8VarP(&args.audioChannels, "channel", "c", 0, "Amount of audio channels. Each channel: num * 1kHz. Zero = no sound [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 [TODO]") fs.StringVarP(&args.audioSamplingFreq, "freq", "f", "48", "Sampling frequency of test audio feed in kHz")
fs.StringVar(&args.audioUUID, "audio-id", "", "Audio UUID. Will be created, if not provided [TODO]") 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) { 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 { if args.audioFlowDefFile == "" && args.audioChannels == 0 {
return nil, nil return nil, nil
} }
levelDBFS, ok := audioLevels[args.audioLevel]
if !ok {
return nil, fmt.Errorf("unsupported audio level %q", args.audioLevel)
}
var definition flowdef.Audio var definition flowdef.Audio
if args.audioFlowDefFile != "" { 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() { func main() {
+21
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"testing" "testing"
"mxl-pattern-generator/internal/audio"
"mxl-pattern-generator/internal/flowdef" "mxl-pattern-generator/internal/flowdef"
) )
@@ -178,6 +179,7 @@ func TestBuildAudioConfigFromArgs(t *testing.T) {
audioUUID: "b3bb5be7-9fe9-4324-a5bb-4c70e1084449", audioUUID: "b3bb5be7-9fe9-4324-a5bb-4c70e1084449",
audioChannels: 2, audioChannels: 2,
audioSamplingFreq: "48", audioSamplingFreq: "48",
audioLevel: "ebu",
}) })
if err != nil { if err != nil {
t.Fatalf("buildAudioConfig: %v", err) t.Fatalf("buildAudioConfig: %v", err)
@@ -191,6 +193,9 @@ func TestBuildAudioConfigFromArgs(t *testing.T) {
if cfg.Rate().Num != 48000 || cfg.Rate().Den != 1 { if cfg.Rate().Num != 48000 || cfg.Rate().Den != 1 {
t.Fatalf("rate = %d/%d, want 48000/1", cfg.Rate().Num, cfg.Rate().Den) 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) { func TestBuildAudioConfigFromFile(t *testing.T) {
@@ -215,6 +220,7 @@ func TestBuildAudioConfigFromFile(t *testing.T) {
audioFlowDefFile: path, audioFlowDefFile: path,
audioChannels: 2, audioChannels: 2,
audioSamplingFreq: "48", audioSamplingFreq: "48",
audioLevel: "smpte",
}) })
if err != nil { if err != nil {
t.Fatalf("buildAudioConfig: %v", err) t.Fatalf("buildAudioConfig: %v", err)
@@ -228,4 +234,19 @@ func TestBuildAudioConfigFromFile(t *testing.T) {
if cfg.Rate().Num != 96000 || cfg.Rate().Den != 1 { 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) 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)
}
} }
+6
View File
@@ -6,8 +6,14 @@ import (
"github.com/qvest-digital/go-mxl/mxl" "github.com/qvest-digital/go-mxl/mxl"
) )
const (
LevelEBUDBFS = -18.0
LevelSMPTEDBFS = -20.0
)
type Config struct { type Config struct {
Definition flowdef.Audio Definition flowdef.Audio
LevelDBFS float64
} }
func (c Config) ID() string { func (c Config) ID() string {
+122
View File
@@ -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
}
+155
View File
@@ -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")
}
}