diff --git a/internal/flowdef/flowdef.go b/internal/flowdef/flowdef.go index 18f10fa..c916250 100644 --- a/internal/flowdef/flowdef.go +++ b/internal/flowdef/flowdef.go @@ -9,9 +9,10 @@ import ( ) const ( - FormatVideo = "urn:x-nmos:format:video" - FormatAudio = "urn:x-nmos:format:audio" - MediaTypeV210 = "video/v210" + FormatVideo = "urn:x-nmos:format:video" + FormatAudio = "urn:x-nmos:format:audio" + MediaTypeV210 = "video/v210" + MediaTypeFloat32 = "audio/float32" InterlaceProgressive = "progressive" ColorSpaceBT709 = "BT709" @@ -32,6 +33,22 @@ type Rational struct { 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 { Common @@ -141,3 +158,66 @@ func (v Video) Validate() error { } 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 +} diff --git a/internal/flowdef/flowdef_test.go b/internal/flowdef/flowdef_test.go index 14ee7ec..d30bc54 100644 --- a/internal/flowdef/flowdef_test.go +++ b/internal/flowdef/flowdef_test.go @@ -7,6 +7,7 @@ import ( ) const testVideoID = "5fbec3b1-1b0f-417d-9059-8b94a47197ed" +const testAudioID = "b3bb5be7-9fe9-4324-a5bb-4c70e1084449" func TestNewV210Video(t *testing.T) { 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) } } + +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) + } +}