Compare commits
3 Commits
1a16436688
...
70c966dbad
| Author | SHA1 | Date | |
|---|---|---|---|
| 70c966dbad | |||
| ce547451a0 | |||
| ce00305c25 |
+65
-4
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/qvest-digital/go-mxl/mxl"
|
"github.com/qvest-digital/go-mxl/mxl"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
|
|
||||||
|
"mxl-pattern-generator/internal/audio"
|
||||||
"mxl-pattern-generator/internal/flowdef"
|
"mxl-pattern-generator/internal/flowdef"
|
||||||
"mxl-pattern-generator/internal/generator"
|
"mxl-pattern-generator/internal/generator"
|
||||||
"mxl-pattern-generator/internal/video"
|
"mxl-pattern-generator/internal/video"
|
||||||
@@ -51,6 +52,7 @@ type appArgs struct {
|
|||||||
|
|
||||||
audioChannels uint8
|
audioChannels uint8
|
||||||
audioSamplingFreq string
|
audioSamplingFreq string
|
||||||
|
audioLevel string
|
||||||
audioUUID string
|
audioUUID string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,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 {
|
||||||
@@ -271,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))
|
||||||
@@ -326,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) {
|
||||||
@@ -418,6 +436,49 @@ func buildVideoConfig(args appArgs) (video.Config, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildAudioConfig returns nil when audio is disabled. An explicit audio flow
|
||||||
|
// definition enables audio even when --channel is zero.
|
||||||
|
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 != "" {
|
||||||
|
data, err := os.ReadFile(args.audioFlowDefFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read audio flow definition %q: %w", args.audioFlowDefFile, err)
|
||||||
|
}
|
||||||
|
definition, err = flowdef.ParseFloat32Audio(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse audio flow definition %q: %w", args.audioFlowDefFile, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rate, ok := samplingRates[args.audioSamplingFreq]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unsupported audio sample rate %q", args.audioSamplingFreq)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
definition, err = flowdef.NewFloat32Audio(
|
||||||
|
args.audioUUID,
|
||||||
|
uint(args.audioChannels),
|
||||||
|
flowdef.Rational{Numerator: uint(rate.Num), Denominator: uint(rate.Den)},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build audio flow definition: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &audio.Config{
|
||||||
|
Definition: definition,
|
||||||
|
LevelDBFS: levelDBFS,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
parsed, err := parseArgs(os.Args[1:], os.Stdout, os.Stderr)
|
parsed, err := parseArgs(os.Args[1:], os.Stdout, os.Stderr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"mxl-pattern-generator/internal/audio"
|
||||||
"mxl-pattern-generator/internal/flowdef"
|
"mxl-pattern-generator/internal/flowdef"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,3 +163,90 @@ func TestBuildVideoConfigFromFile(t *testing.T) {
|
|||||||
t.Fatalf("rate = %d/%d, want file value 60000/1001", cfg.Rate().Num, cfg.Rate().Den)
|
t.Fatalf("rate = %d/%d, want file value 60000/1001", cfg.Rate().Num, cfg.Rate().Den)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildAudioConfigDisabled(t *testing.T) {
|
||||||
|
cfg, err := buildAudioConfig(appArgs{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildAudioConfig: %v", err)
|
||||||
|
}
|
||||||
|
if cfg != nil {
|
||||||
|
t.Fatalf("config = %+v, want nil for disabled audio", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAudioConfigFromArgs(t *testing.T) {
|
||||||
|
cfg, err := buildAudioConfig(appArgs{
|
||||||
|
audioUUID: "b3bb5be7-9fe9-4324-a5bb-4c70e1084449",
|
||||||
|
audioChannels: 2,
|
||||||
|
audioSamplingFreq: "48",
|
||||||
|
audioLevel: "ebu",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildAudioConfig: %v", err)
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
t.Fatal("config is nil")
|
||||||
|
}
|
||||||
|
if cfg.Channels() != 2 {
|
||||||
|
t.Fatalf("channels = %d, want 2", cfg.Channels())
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
definition, err := flowdef.NewFloat32Audio(
|
||||||
|
"b3bb5be7-9fe9-4324-a5bb-4c70e1084449",
|
||||||
|
8,
|
||||||
|
flowdef.Rational{Numerator: 96000, Denominator: 1},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFloat32Audio: %v", err)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(definition)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
|
}
|
||||||
|
path := t.TempDir() + "/audio.json"
|
||||||
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||||
|
t.Fatalf("os.WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := buildAudioConfig(appArgs{
|
||||||
|
audioFlowDefFile: path,
|
||||||
|
audioChannels: 2,
|
||||||
|
audioSamplingFreq: "48",
|
||||||
|
audioLevel: "smpte",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildAudioConfig: %v", err)
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
t.Fatal("config is nil")
|
||||||
|
}
|
||||||
|
if cfg.Channels() != 8 {
|
||||||
|
t.Fatalf("channels = %d, want file value 8", cfg.Channels())
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mxl-pattern-generator/internal/flowdef"
|
||||||
|
|
||||||
|
"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 {
|
||||||
|
return c.Definition.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) Channels() uint {
|
||||||
|
return c.Definition.ChannelCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) Rate() mxl.Rational {
|
||||||
|
return mxl.Rational{
|
||||||
|
Num: int64(c.Definition.SampleRate.Numerator),
|
||||||
|
Den: int64(c.Definition.SampleRate.Denominator),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
FormatVideo = "urn:x-nmos:format:video"
|
FormatVideo = "urn:x-nmos:format:video"
|
||||||
FormatAudio = "urn:x-nmos:format:audio"
|
FormatAudio = "urn:x-nmos:format:audio"
|
||||||
MediaTypeV210 = "video/v210"
|
MediaTypeV210 = "video/v210"
|
||||||
|
MediaTypeFloat32 = "audio/float32"
|
||||||
|
|
||||||
InterlaceProgressive = "progressive"
|
InterlaceProgressive = "progressive"
|
||||||
ColorSpaceBT709 = "BT709"
|
ColorSpaceBT709 = "BT709"
|
||||||
@@ -32,6 +33,22 @@ type Rational struct {
|
|||||||
Denominator uint `json:"denominator"`
|
Denominator uint `json:"denominator"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Rational) UnmarshalJSON(data []byte) error {
|
||||||
|
var value struct {
|
||||||
|
Numerator uint `json:"numerator"`
|
||||||
|
Denominator *uint `json:"denominator"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Numerator = value.Numerator
|
||||||
|
r.Denominator = 1
|
||||||
|
if value.Denominator != nil {
|
||||||
|
r.Denominator = *value.Denominator
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type Video struct {
|
type Video struct {
|
||||||
Common
|
Common
|
||||||
|
|
||||||
@@ -141,3 +158,66 @@ func (v Video) Validate() error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewFloat32Audio(
|
||||||
|
id string,
|
||||||
|
channels uint,
|
||||||
|
rate Rational,
|
||||||
|
) (Audio, error) {
|
||||||
|
definition := Audio{
|
||||||
|
Common: Common{
|
||||||
|
Description: "go-mxl-pattern-gen generated audio",
|
||||||
|
ID: id,
|
||||||
|
Tags: map[string][]string{
|
||||||
|
"urn:x-nmos:tag:grouphint/v1.0": {
|
||||||
|
"go-mxl-pattern-gen:Audio",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Format: FormatAudio,
|
||||||
|
Label: "go-mxl-pattern-gen generated audio",
|
||||||
|
Parents: []string{},
|
||||||
|
MediaType: MediaTypeFloat32,
|
||||||
|
},
|
||||||
|
SampleRate: rate,
|
||||||
|
ChannelCount: channels,
|
||||||
|
BitDepth: 32,
|
||||||
|
}
|
||||||
|
if err := definition.Validate(); err != nil {
|
||||||
|
return Audio{}, err
|
||||||
|
}
|
||||||
|
return definition, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Audio) Validate() error {
|
||||||
|
if err := uuid.Validate(a.ID); err != nil {
|
||||||
|
return fmt.Errorf("invalid id %q: %w", a.ID, err)
|
||||||
|
}
|
||||||
|
if a.Format != FormatAudio {
|
||||||
|
return fmt.Errorf("format must be %q, got %q", FormatAudio, a.Format)
|
||||||
|
}
|
||||||
|
if a.MediaType != MediaTypeFloat32 {
|
||||||
|
return fmt.Errorf("media_type must be %q, got %q", MediaTypeFloat32, a.MediaType)
|
||||||
|
}
|
||||||
|
if a.ChannelCount == 0 {
|
||||||
|
return fmt.Errorf("channel_count must be greater than zero")
|
||||||
|
}
|
||||||
|
if a.SampleRate.Numerator == 0 || a.SampleRate.Denominator == 0 {
|
||||||
|
return fmt.Errorf("sample_rate numerator and denominator must be greater than zero, got %d/%d",
|
||||||
|
a.SampleRate.Numerator, a.SampleRate.Denominator)
|
||||||
|
}
|
||||||
|
if a.BitDepth != 32 {
|
||||||
|
return fmt.Errorf("bit_depth must be 32, got %d", a.BitDepth)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseFloat32Audio(data []byte) (Audio, error) {
|
||||||
|
var definition Audio
|
||||||
|
if err := json.Unmarshal(data, &definition); err != nil {
|
||||||
|
return Audio{}, fmt.Errorf("decode audio flow definition: %w", err)
|
||||||
|
}
|
||||||
|
if err := definition.Validate(); err != nil {
|
||||||
|
return Audio{}, fmt.Errorf("invalid audio flow definition: %w", err)
|
||||||
|
}
|
||||||
|
return definition, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const testVideoID = "5fbec3b1-1b0f-417d-9059-8b94a47197ed"
|
const testVideoID = "5fbec3b1-1b0f-417d-9059-8b94a47197ed"
|
||||||
|
const testAudioID = "b3bb5be7-9fe9-4324-a5bb-4c70e1084449"
|
||||||
|
|
||||||
func TestNewV210Video(t *testing.T) {
|
func TestNewV210Video(t *testing.T) {
|
||||||
definition, err := NewV210Video(testVideoID, 1920, 1080, Rational{Numerator: 30000, Denominator: 1001})
|
definition, err := NewV210Video(testVideoID, 1920, 1080, Rational{Numerator: 30000, Denominator: 1001})
|
||||||
@@ -65,3 +66,113 @@ func TestParseV210VideoRejectsAudio(t *testing.T) {
|
|||||||
t.Fatalf("error = %v, want video format error", err)
|
t.Fatalf("error = %v, want video format error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewFloat32Audio(t *testing.T) {
|
||||||
|
definition, err := NewFloat32Audio(testAudioID, 2, Rational{Numerator: 48000, Denominator: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFloat32Audio: %v", err)
|
||||||
|
}
|
||||||
|
if definition.ID != testAudioID {
|
||||||
|
t.Fatalf("ID = %q, want %q", definition.ID, testAudioID)
|
||||||
|
}
|
||||||
|
if definition.Format != FormatAudio || definition.MediaType != MediaTypeFloat32 {
|
||||||
|
t.Fatalf("format/media type = %q/%q", definition.Format, definition.MediaType)
|
||||||
|
}
|
||||||
|
if definition.ChannelCount != 2 || definition.BitDepth != 32 {
|
||||||
|
t.Fatalf("channels/bit depth = %d/%d, want 2/32", definition.ChannelCount, definition.BitDepth)
|
||||||
|
}
|
||||||
|
if definition.SampleRate != (Rational{Numerator: 48000, Denominator: 1}) {
|
||||||
|
t.Fatalf("sample rate = %+v, want 48000/1", definition.SampleRate)
|
||||||
|
}
|
||||||
|
if definition.Parents == nil {
|
||||||
|
t.Fatal("Parents is nil; want an empty JSON array")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFloat32AudioRejectsZeroChannels(t *testing.T) {
|
||||||
|
_, err := NewFloat32Audio(testAudioID, 0, Rational{Numerator: 48000, Denominator: 1})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "channel_count") {
|
||||||
|
t.Fatalf("error = %v, want channel_count error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFloat32AudioRejectsInvalidRate(t *testing.T) {
|
||||||
|
_, err := NewFloat32Audio(testAudioID, 2, Rational{Numerator: 48000, Denominator: 0})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "sample_rate") {
|
||||||
|
t.Fatalf("error = %v, want sample_rate error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFloat32Audio(t *testing.T) {
|
||||||
|
want, err := NewFloat32Audio(testAudioID, 2, Rational{Numerator: 48000, Denominator: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFloat32Audio: %v", err)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(want)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ParseFloat32Audio(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseFloat32Audio: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != want.ID || got.ChannelCount != want.ChannelCount || got.SampleRate != want.SampleRate {
|
||||||
|
t.Fatalf("parsed definition = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFloat32AudioDefaultsDenominator(t *testing.T) {
|
||||||
|
data := []byte(`{
|
||||||
|
"description":"test audio",
|
||||||
|
"id":"b3bb5be7-9fe9-4324-a5bb-4c70e1084449",
|
||||||
|
"tags":{},
|
||||||
|
"format":"urn:x-nmos:format:audio",
|
||||||
|
"label":"test audio",
|
||||||
|
"parents":[],
|
||||||
|
"media_type":"audio/float32",
|
||||||
|
"sample_rate":{"numerator":48000},
|
||||||
|
"channel_count":2,
|
||||||
|
"bit_depth":32
|
||||||
|
}`)
|
||||||
|
|
||||||
|
definition, err := ParseFloat32Audio(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseFloat32Audio: %v", err)
|
||||||
|
}
|
||||||
|
if definition.SampleRate.Denominator != 1 {
|
||||||
|
t.Fatalf("denominator = %d, want implicit 1", definition.SampleRate.Denominator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFloat32AudioRejectsExplicitZeroDenominator(t *testing.T) {
|
||||||
|
data := []byte(`{
|
||||||
|
"id":"b3bb5be7-9fe9-4324-a5bb-4c70e1084449",
|
||||||
|
"format":"urn:x-nmos:format:audio",
|
||||||
|
"media_type":"audio/float32",
|
||||||
|
"sample_rate":{"numerator":48000,"denominator":0},
|
||||||
|
"channel_count":2,
|
||||||
|
"bit_depth":32
|
||||||
|
}`)
|
||||||
|
|
||||||
|
_, err := ParseFloat32Audio(data)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "sample_rate") {
|
||||||
|
t.Fatalf("error = %v, want sample_rate error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFloat32AudioRejectsVideo(t *testing.T) {
|
||||||
|
definition, err := NewV210Video(testVideoID, 1920, 1080, Rational{Numerator: 25, Denominator: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewV210Video: %v", err)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(definition)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ParseFloat32Audio(data)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "format must be") {
|
||||||
|
t.Fatalf("error = %v, want audio format error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user