Merge pull request 'Audio' (#1) from audio into main

Reviewed-on: http://gitea.lan/itten/go-mxl-pattern-generator/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
2026-09-16 23:50:45 +03:00
11 changed files with 1854 additions and 337 deletions
+130
View File
@@ -0,0 +1,130 @@
# MXL Pattern Generator Architecture Review
Reviewed on 2026-09-16 against commit `f37f480`.
## Summary
The current stack is a good fit for a test-pattern generator:
- Go is well suited to CLI handling, MXL lifecycle management, timing, and synthetic audio generation.
- `go-mxl` provides the correct data model: discrete grains for video and continuous samples for audio.
- wgpu/WGSL is a reasonable portable GPU abstraction for producing v210 video.
- CPU-rendered text is appropriate because text changes infrequently and font rendering does not need to be implemented in a shader.
The project is still prototype-quality in its orchestration and configuration layers. Before adding substantial audio functionality, flow-definition handling, lifecycle management, and the failing tests should be corrected.
## Recommended audio architecture
Video and audio should run in separate goroutines. Each goroutine should own its own MXL writer and timing loop because video and audio have different units and rates:
```text
run
|-- shared context, signal handling, and error propagation
|-- video goroutine
| OpenGrain -> render -> overlay -> Commit
`-- audio goroutine
OpenSamples -> synthesize -> Commit
```
This is also consistent with `go-mxl`: a writer is not safe for concurrent use, so a writer should belong to exactly one goroutine.
The goroutines should not synchronize by sending a notification for every video frame. That would make audio timing depend on GPU latency and Go scheduler jitter. Instead, both loops should use the MXL clock independently:
```go
videoIndex := mxl.CurrentIndex(videoRate)
audioIndex := mxl.CurrentIndex(audioRate)
```
After committing data, each loop advances its own index and uses `mxl.NsUntilIndex` for pacing. Both loops should share a `context.Context`; an error in either flow should cancel the other flow and be returned to the caller.
For audio, start with CPU generation and approximately 10 ms batches (480 samples at 48 kHz). A sine wave is inexpensive to calculate, so GPU audio generation would add complexity without a useful performance benefit.
Audio phase should be derived from the absolute sample index:
```go
phase := 2 * math.Pi * frequency *
float64(firstSample+i) / sampleRate
```
This preserves continuity across batches and after a timing resynchronization.
Video and audio should expose different interfaces because a video frame and a range of audio samples are fundamentally different units:
```go
type VideoGenerator interface {
GenerateFrame(dst []byte, frameIndex uint64) error
Close() error
}
type AudioGenerator interface {
GenerateSamples(channels [][]byte, firstSample, sampleCount uint64) error
Close() error
}
```
For `audio/float32`, the audio package may use `[]float32` internally and keep byte encoding at the MXL boundary.
## Issue status
| # | Issue | Status | Current observation |
|---|---|---|---|
| 1 | The validated `--domain` value was ignored in favor of `/dev/shm/mxl`. | **Fixed** | Logging, `mxl.NewInstance`, and reuse messages now use `args.domain`. |
| 2 | An external video flow definition does not populate runtime width, height, FPS, or UUID. | **Fixed** | External definitions are parsed and validated as `flowdef.Video`; `video.Config` supplies their dimensions, rate, and ID to the generator and writer. |
| 3 | `checkArgs` received `appArgs` by value, so generated UUIDs were discarded. | **Fixed** | Argument validation mutates the actual configuration, and the hard-coded video UUID has been removed. |
| 4 | The default pattern was `bars`, which did not exist. | **Fixed** | The default is now `ebu75`, which exists in the pattern registry. |
| 5 | `NewFlowDefJSON(TYPE_AUDIO, ...)` produces a video/v210 definition. | **Fixed** | The generic discriminator-based builder was removed. Video and audio have separate schema types, and video uses the typed `NewV210Video` constructor. An audio constructor will be added with audio generation. |
| 6 | The wgpu path was described as zero-copy although it performs GPU readback and a CPU copy. | **Deferred — fix after audio** | Every frame is copied from GPU storage to a mapped host buffer and then copied into the MXL payload. The path is synchronous and serial. This may require substantial benchmarking and architectural work, so audio implementation takes priority. Update the documentation now, but defer optimization or redesign until audio is complete. |
| 7 | The test suite had three failures. | **Open** | `TestNewTextOverlay`, `TestWGPUMoveSquare`, and `TestWGPUGenerator` still fail. |
| 8 | The Makefile clean target uses `fm -f` instead of `rm -f`. | **Fixed** | The clean target now uses `rm -f`. |
## Additional implementation priorities
1. Fix the existing tests or update incorrect expectations after confirming the intended color values and overlay positioning.
2. Add a typed audio flow-definition constructor, CPU audio generator, and continuous-flow writer loop using `OpenSamples`, `ChannelFragments`, and `Commit`.
3. Run video and audio as sibling goroutines with shared cancellation and error propagation.
4. After audio is complete, measure end-to-end frame time and missed deadlines at 1080p50/60 and UHD. The current wgpu path may be adequate, but it is neither zero-copy nor asynchronous. Treat GPU readback optimization as a separate, potentially large task.
## Suggested package layout
```text
cmd/mxl-pattern/
main.go
internal/config/
config.go
flow.go
internal/video/
generator.go
wgpu.go
overlay.go
runner.go
internal/audio/
generator.go
sine.go
runner.go
internal/app/
run.go
```
The exact directory names are less important than keeping CLI parsing, typed configuration, media generation, and MXL writing as separate responsibilities.
## Verification
The following command was used:
```sh
GOCACHE=/tmp/go-mxl-gen-cache go test ./...
```
Package compilation succeeds, but the generator package fails these tests:
- `TestNewTextOverlay`: expected a centered text box, but its center was reported as 45 instead of approximately 960.
- `TestWGPUMoveSquare`: tick 79 produced `590/512/512` instead of expected `893/176/543`.
- `TestWGPUGenerator`: pixel `(0,0)` produced Y=414 instead of expected Y=721.
## Conclusion
Keep the chosen Go + go-mxl + wgpu stack. Implement audio on the CPU in its own goroutine and give video and audio separate writers, indices, and pacing loops. Coordinate them through a shared context and the common MXL timebase, not through per-frame messages. Typed external-video configuration and the context-aware video runner are now implemented; the next architectural work is typed audio construction and generation. The three rendering-test failures remain a separate correctness task.
+1 -1
View File
@@ -17,4 +17,4 @@ test:
clean:
go clean
fm -f ./build/mxl-gen
rm -f ./build/mxl-gen
+626 -216
View File
@@ -4,18 +4,26 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"os/signal"
"sort"
"strings"
"syscall"
"github.com/google/uuid"
"github.com/qvest-digital/go-mxl/mxl"
"github.com/spf13/pflag"
flowdef "mxl-pattern-generator/internal/flow-def"
"mxl-pattern-generator/internal/audio"
"mxl-pattern-generator/internal/flowdef"
"mxl-pattern-generator/internal/generator"
"mxl-pattern-generator/internal/video"
)
const (
@@ -44,9 +52,25 @@ type appArgs struct {
audioChannels uint8
audioSamplingFreq string
audioLevel string
audioUUID string
}
type parseResult struct {
args appArgs
shouldRun bool
}
type namedRunner struct {
name string
run func(context.Context) error
}
type runnerResult struct {
name string
err error
}
var frameRates = map[string]mxl.Rational{
"23.97": {Num: 24000, Den: 1001},
"24": {Num: 24, Den: 1},
@@ -67,119 +91,136 @@ var samplingRates = map[string]mxl.Rational{
"192": {Num: 192000, Den: 1},
}
func printHelp(fs *pflag.FlagSet) {
fmt.Printf("%s %s\n", APP_NAME, APP_VER)
fmt.Println("Usage: mxl-gen -d <domain> [-v <flowDef.json>] [-a <flowDef.json>] [options]")
fmt.Println(" or: mxl-gen -d <domain> [--with <width px>] [--height <height px>] [--fps <framerate>] \\")
fmt.Println(" [-c <channels amount>] [-f <sample rate>]")
fmt.Println(" or: mxl-gen -d <domain> with default params")
fmt.Println("Video and audio feeds params will be ignored, if flow definition file provided.")
fmt.Println()
func printHelp(w io.Writer, fs *pflag.FlagSet) {
fmt.Fprintf(w, "%s %s\n", APP_NAME, APP_VER)
fmt.Fprintln(w, "Usage: mxl-gen -d <domain> [-v <flowDef.json>] [-a <flowDef.json>] [options]")
fmt.Fprintln(w, " or: mxl-gen -d <domain> [--width <width px>] [--height <height px>] [--fps <framerate>] \\")
fmt.Fprintln(w, " [-c <channels amount>] [-f <sample rate>]")
fmt.Fprintln(w, " or: mxl-gen -d <domain> with default params")
fmt.Fprintln(w, "Video and audio feed parameters are ignored when a flow definition file is provided.")
fmt.Fprintln(w)
fs.SetOutput(w)
fs.PrintDefaults()
}
func printUsage() {
fmt.Fprintln(os.Stderr, "Usage: mxl-gen -d <domain> [-v <flowDef.json>] [-a <flowDef.json>] [options]")
fmt.Fprintln(os.Stderr, "Try 'mxl-gen -h' for more information.")
func printUsage(w io.Writer) {
fmt.Fprintln(w, "Usage: mxl-gen -d <domain> [-v <flowDef.json>] [-a <flowDef.json>] [options]")
fmt.Fprintln(w, "Try 'mxl-gen -h' for more information.")
}
func checkArgs(args appArgs) {
printUsageAndExit := func() {
printUsage()
os.Exit(2)
func validateDomain(domain string) error {
if domain == "" {
return fmt.Errorf("domain is required")
}
// domain
if args.domain == "" {
fmt.Fprintf(os.Stderr, "Domain is required\n")
printUsageAndExit()
fi, err := os.Stat(domain)
if err != nil {
return fmt.Errorf("invalid MXL domain %q: %w", domain, err)
}
fi, err := os.Stat(args.domain)
if err != nil || !fi.IsDir() {
fmt.Fprintf(os.Stderr, "Invalid MXL domain: %s\n", args.domain)
fmt.Fprintf(os.Stderr, "Reason: %v\n", err)
printUsageAndExit()
if !fi.IsDir() {
return fmt.Errorf("invalid MXL domain %q: not a directory", domain)
}
if ok, err := mxl.IsTmpFs(args.domain); err != nil || !ok {
fmt.Fprintf(os.Stderr, "Invalid MXL domain: %s\n", args.domain)
fmt.Fprintln(os.Stderr, "Domain must be directory in tmps.")
printUsageAndExit()
}
// FlowDef
checkFlowDef := func(label, flowDef string) {
fi, err := os.Stat(flowDef)
if err != nil || fi.IsDir() {
fmt.Fprintf(os.Stderr, "%s flow definition .json file is not accesible\n", label)
printUsageAndExit()
if ok, err := mxl.IsTmpFs(domain); err != nil || !ok {
if err != nil {
return fmt.Errorf("check MXL domain %q: %w", domain, err)
}
return fmt.Errorf("invalid MXL domain %q: directory must be on tmpfs", domain)
}
return nil
}
func validateFlowDefPath(label, path string) error {
if path == "" {
return nil
}
fi, err := os.Stat(path)
if err != nil {
return fmt.Errorf("%s flow definition %q is not accessible: %w", label, path, err)
}
if fi.IsDir() {
return fmt.Errorf("%s flow definition %q is a directory", label, path)
}
return nil
}
func validateVideoArgs(args *appArgs) error {
if _, exists := patterns[args.pattern]; !exists {
return fmt.Errorf("unknown video pattern %q (use --list-patterns to see available patterns)", args.pattern)
}
videoFlowDefProvided, audioFlowDefProvided := false, false
if args.videoFlowDefFile != "" {
checkFlowDef("Video", args.videoFlowDefFile)
videoFlowDefProvided = true
return nil
}
if args.videoWidth == 0 || args.videoWidth%6 != 0 {
// v210 stores 6 pixels in each 16-byte block.
return fmt.Errorf("video width must be greater than zero and divisible by 6")
}
if args.videoHeight == 0 {
return fmt.Errorf("video height must be greater than zero")
}
if _, exists := frameRates[args.videoFPS]; !exists {
return fmt.Errorf("unsupported video FPS %q (supported: %s); use a flow definition for other rates",
args.videoFPS, sortedMapKeys(frameRates))
}
if args.videoUUID == "" {
args.videoUUID = uuid.NewString()
return nil
}
if _, err := uuid.Parse(args.videoUUID); err != nil {
return fmt.Errorf("invalid video UUID %q: %w", args.videoUUID, err)
}
return nil
}
func validateAudioArgs(args *appArgs) error {
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 != "" {
checkFlowDef("Audio", args.audioFlowDefFile)
audioFlowDefProvided = true
return nil
}
if _, exists := samplingRates[args.audioSamplingFreq]; !exists {
return fmt.Errorf("unsupported audio sample rate %q (supported: %s); use a flow definition for other rates",
args.audioSamplingFreq, sortedMapKeys(samplingRates))
}
if args.audioUUID == "" {
args.audioUUID = uuid.NewString()
return nil
}
if err := uuid.Validate(args.audioUUID); err != nil {
return fmt.Errorf("invalid audio UUID %q: %w", args.audioUUID, err)
}
return nil
}
if !videoFlowDefProvided {
if args.videoWidth == 0 || args.videoWidth%6 != 0 {
// width%6 == 0 - because of v210 (6 pixels per 16-byte block)
fmt.Fprintf(os.Stderr, "Video width must be > 0 and divisible by 6\n")
printUsageAndExit()
}
if args.videoHeight == 0 {
fmt.Fprintf(os.Stderr, "Video height must be > 0\n")
printUsageAndExit()
}
if _, exists := frameRates[args.videoFPS]; !exists {
fmt.Fprintf(os.Stderr, "FPS %s is not in available list.\n", args.videoFPS)
fmt.Fprintln(os.Stderr, "If you need more complex solution, use flow definition .json instead.")
fmt.Fprintln(os.Stderr, "Available list:")
for key, _ := range frameRates {
fmt.Fprintf(os.Stderr, " %s\n", key)
}
printUsageAndExit()
}
if args.videoUUID != "" {
if _, err := uuid.Parse(args.videoUUID); err != nil {
fmt.Fprintf(os.Stderr, "Video UUID %s is not valid.\n", args.videoUUID)
printUsageAndExit()
}
} else {
args.videoUUID = uuid.NewString()
}
if args.pattern != "" {
if _, exists := patterns[args.pattern]; !exists {
fmt.Fprintf(os.Stderr, "Pattern %s is not in available list.\n", args.pattern)
listPatterns(os.Stderr)
os.Exit(2)
}
func validateArgs(args *appArgs) error {
checks := []func() error{
func() error { return validateDomain(args.domain) },
func() error { return validateFlowDefPath("video", args.videoFlowDefFile) },
func() error { return validateFlowDefPath("audio", args.audioFlowDefFile) },
func() error { return validateVideoArgs(args) },
func() error { return validateAudioArgs(args) },
}
for _, check := range checks {
if err := check(); err != nil {
return err
}
}
return nil
}
if !audioFlowDefProvided {
if args.audioChannels == 0 {
// TODO: ignore audio
}
if _, exists := samplingRates[args.audioSamplingFreq]; !exists {
fmt.Fprintf(os.Stderr, "Sample rate %s is not in available list.\n", args.audioSamplingFreq)
fmt.Fprintln(os.Stderr, "If you need more complex solution, use flow definition .json instead.")
fmt.Fprintln(os.Stderr, "Available list:")
for key, _ := range samplingRates {
fmt.Fprintf(os.Stderr, " %s\n", key)
}
printUsageAndExit()
}
if args.audioUUID != "" {
if err := uuid.Validate(args.audioUUID); err != nil {
fmt.Fprintf(os.Stderr, "Audio UUID %s is not valid.\n", args.audioUUID)
printUsageAndExit()
}
} else {
args.audioUUID = uuid.NewString()
}
func sortedMapKeys[V any](values map[string]V) string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return strings.Join(keys, ", ")
}
type pattern struct {
@@ -252,21 +293,30 @@ var patterns = map[string]pattern{
},
}
func listPatterns(f *os.File) {
fmt.Fprintln(f, "List of available video patterns:")
var maxNameWidth int = 0
for name, _ := range patterns {
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))
maxNameWidth := 0
for name := range patterns {
names = append(names, name)
l := len(name)
if l > maxNameWidth {
maxNameWidth = l
}
}
for name, p := range patterns {
fmt.Fprintf(f, " %-*s - %s\n", maxNameWidth, name, p.description)
sort.Strings(names)
for _, name := range names {
p := patterns[name]
fmt.Fprintf(w, " %-*s - %s\n", maxNameWidth, name, p.description)
}
}
func flagSetAddFlags(fs *pflag.FlagSet, args *appArgs) {
func addFlags(fs *pflag.FlagSet, args *appArgs) {
// common flags
fs.BoolVarP(&args.showHelp, "help", "h", false, "Show help message and exit")
// MXL flags
@@ -274,7 +324,7 @@ func flagSetAddFlags(fs *pflag.FlagSet, args *appArgs) {
fs.StringVarP(&args.videoFlowDefFile, "video", "v", "", "Video flow definition JSON file path")
fs.StringVarP(&args.audioFlowDefFile, "audio", "a", "", "Audio flow definition JSON file path [TODO]")
// Video pattern flags
fs.StringVarP(&args.pattern, "pattern", "p", "bars", "Video pattern type")
fs.StringVarP(&args.pattern, "pattern", "p", "ebu75", "Video pattern type")
fs.BoolVar(&args.listPatterns, "list-patterns", false, "List video available video patterns and exit")
fs.StringVarP(&args.textOverlay, "text", "t", "", "Text overlay above video pattern. Ignored if text-pos set")
@@ -298,176 +348,536 @@ func flagSetAddFlags(fs *pflag.FlagSet, args *appArgs) {
"br - bottom-right corner",
)
fs.UintVar(&args.videoWidth, "width", 1920, "Video pattern width. Zero = no video [TODO]")
fs.UintVar(&args.videoWidth, "width", 1920, "Video pattern width. Zero = no video [TODO: 0 width case]")
fs.UintVar(&args.videoHeight, "height", 1080, "Video pattern height")
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")
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 main() {
func parseArgs(argv []string, stdout, stderr io.Writer) (parseResult, error) {
var args appArgs
flagSet := pflag.NewFlagSet("args", pflag.ContinueOnError)
flagSet.SortFlags = false
flagSet.Usage = func() { printUsage() }
flagSetAddFlags(flagSet, &args)
flagSet.SetOutput(stderr)
flagSet.Usage = func() { printUsage(stderr) }
addFlags(flagSet, &args)
if err := flagSet.Parse(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, err)
printUsage()
os.Exit(2)
if err := flagSet.Parse(argv); err != nil {
return parseResult{}, err
}
if args.showHelp {
printHelp(flagSet)
return
printHelp(stdout, flagSet)
return parseResult{args: args}, nil
}
if args.listPatterns {
listPatterns(os.Stderr)
return
listPatterns(stdout)
return parseResult{args: args}, nil
}
checkArgs(args)
if flagSet.NArg() != 0 {
return parseResult{}, fmt.Errorf("unexpected positional arguments: %v", flagSet.Args())
}
if err := validateArgs(&args); err != nil {
return parseResult{}, err
}
return parseResult{args: args, shouldRun: true}, nil
}
var mxlDomain string = "/dev/shm/mxl"
type videoInfo struct {
uuid string
width uint
height uint
fps mxl.Rational
}
var vi videoInfo
var videoFlowDef string
if args.videoFlowDefFile == "" {
args.videoUUID = "8f1d2a4b-6c3e-4f5a-9b2c-1d7e8a3f0b5d" // TODO: remove before public release
vi = videoInfo{
uuid: args.videoUUID,
width: args.videoWidth,
height: args.videoHeight,
fps: frameRates[args.videoFPS],
}
flowDef, err := flowdef.NewFlowDefJSON(
flowdef.TYPE_VIDEO,
vi.uuid,
vi.width,
vi.height,
uint(vi.fps.Num),
uint(vi.fps.Den),
)
videoFlowDef = flowDef
func buildVideoConfig(args appArgs) (video.Config, error) {
var definition flowdef.Video
if args.videoFlowDefFile != "" {
data, err := os.ReadFile(args.videoFlowDefFile)
if err != nil {
log.Fatalf("Could not create Flow Definition: %v", err)
return video.Config{}, fmt.Errorf(
"read video flow definition %q: %w",
args.videoFlowDefFile,
err,
)
}
definition, err = flowdef.ParseV210Video(data)
if err != nil {
return video.Config{}, fmt.Errorf(
"parse video flow definition %q: %w",
args.videoFlowDefFile,
err,
)
}
} else {
flowDef, err := flowdef.ReadFlowDefFile(args.videoFlowDefFile)
if err != nil {
log.Fatalf("Could not read video flow def .json: %s. Reason: %v", args.videoFlowDefFile, err)
rate, ok := frameRates[args.videoFPS]
if !ok {
return video.Config{}, fmt.Errorf("unsupported video FPS %q", args.videoFPS)
}
videoFlowDef = flowDef
var err error
definition, err = flowdef.NewV210Video(
args.videoUUID,
args.videoWidth,
args.videoHeight,
flowdef.Rational{
Numerator: uint(rate.Num),
Denominator: uint(rate.Den),
},
)
if err != nil {
return video.Config{}, fmt.Errorf(
"build video flow definition: %w",
err,
)
}
}
if _, ok := patterns[args.pattern]; !ok {
return video.Config{}, fmt.Errorf("unknown video pattern %q", args.pattern)
}
return video.Config{
Definition: definition,
Pattern: args.pattern,
Overlay: video.OverlayConfig{
Text: args.textOverlay,
X: args.overlayX,
Y: args.overlayY,
Position: args.overlayPos,
},
}, 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() {
parsed, err := parseArgs(os.Args[1:], os.Stdout, os.Stderr)
if err != nil {
fmt.Fprintln(os.Stderr, err)
printUsage(os.Stderr)
os.Exit(2)
}
if !parsed.shouldRun {
return
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, parsed.args); err != nil {
log.Printf("%s: %v", APP_NAME, err)
os.Exit(1)
}
}
func run(ctx context.Context, args appArgs) (runErr error) {
videoCfg, err := buildVideoConfig(args)
if err != nil {
return fmt.Errorf("video configuration: %w", err)
}
audioCfg, err := buildAudioConfig(args)
if err != nil {
return fmt.Errorf("audio configuration: %w", err)
}
log.Printf("%s %s", APP_NAME, APP_VER)
log.Printf("Domain: %s", mxlDomain)
log.Printf("Video: %dx%d %d/%d", vi.width, vi.height, vi.fps.Num, vi.fps.Den)
log.Printf("Video UUID: %s", vi.uuid)
// TODO: if init failed -> CPU generator
videoPattern := patterns[args.pattern]
gen, err := generator.NewWGPUGenerator(vi.width, vi.height, videoPattern.kernelPath)
if err != nil {
log.Fatalf("wgpu init failed: %v", err)
log.Printf("Domain: %s", args.domain)
log.Printf("Video: %dx%d %d/%d",
videoCfg.Width(), videoCfg.Height(), videoCfg.Rate().Num, videoCfg.Rate().Den)
log.Printf("Video ID: %s", videoCfg.ID())
if audioCfg != nil {
log.Printf("Audio: %d channels %d/%d Hz %.0f dBFS",
audioCfg.Channels(), audioCfg.Rate().Num, audioCfg.Rate().Den, audioCfg.LevelDBFS)
log.Printf("Audio ID: %s", audioCfg.ID())
}
defer gen.Close()
// Static text overlay: rasterized + pre-packed once, stamped on each
// frame after the GPU render (microseconds per frame).
var overlay *generator.TextOverlay
if args.textOverlay != "" {
face, err := generator.LoadFace("assets/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf", 48)
if err != nil {
log.Fatalf("text overlay init failed: %v", err)
inst, err := mxl.NewInstance(args.domain, "")
if err != nil {
return fmt.Errorf("initialize MXL domain %q: %w", args.domain, err)
}
defer func() {
if err := inst.Close(); err != nil {
runErr = errors.Join(runErr, fmt.Errorf("close MXL instance: %w", err))
}
defer face.Close()
if args.overlayX < 0 ||
args.overlayX > int(vi.width) ||
args.overlayY < 0 ||
args.overlayY > int(vi.height) {
log.Fatalf("overlay X/Y pos can't be negative or greater, than video width/height")
}
overlay, err = generator.NewTextOverlay(
args.textOverlay,
int(vi.width),
int(vi.height),
args.overlayX,
args.overlayY,
args.overlayPos,
face,
)
if err != nil {
log.Fatalf("text overlay init failed: %v", err)
}()
runners := []namedRunner{
{
name: "video",
run: func(ctx context.Context) error {
return runVideo(ctx, inst, videoCfg)
},
},
}
if audioCfg != nil {
runners = append(runners, namedRunner{
name: "audio",
run: func(ctx context.Context) error {
return runAudio(ctx, inst, *audioCfg)
},
})
}
return runConcurrent(ctx, runners...)
}
func runConcurrent(ctx context.Context, runners ...namedRunner) error {
if len(runners) == 0 {
return nil
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
results := make(chan runnerResult, len(runners))
for _, runner := range runners {
runner := runner
go func() {
results <- runnerResult{name: runner.name, err: runner.run(ctx)}
}()
}
var resultErr error
for range runners {
result := <-results
if result.err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("%s flow: %w", result.name, result.err))
cancel()
}
}
return resultErr
}
inst, err := mxl.NewInstance(mxlDomain, "")
if err != nil {
log.Fatalf("MXL Init Failed: %v", err)
func runVideo(ctx context.Context, inst *mxl.Instance, cfg video.Config) (runErr error) {
pattern, ok := patterns[cfg.Pattern]
if !ok {
return fmt.Errorf("unknown video pattern %q", cfg.Pattern)
}
defer inst.Close()
writer, isCreated, err := inst.NewWriter(videoFlowDef)
// TODO: fall back to a CPU generator if GPU initialization fails.
gen, err := generator.NewWGPUGenerator(cfg.Width(), cfg.Height(), pattern.kernelPath)
if err != nil {
log.Fatalf("Failed to create MXL writer: %v", err)
return fmt.Errorf("initialize wgpu video generator: %w", err)
}
defer func() {
if err := gen.Close(); err != nil {
runErr = errors.Join(runErr, fmt.Errorf("close video generator: %w", err))
}
}()
overlay, err := buildTextOverlay(cfg)
if err != nil {
return err
}
flowJSON, err := json.Marshal(cfg.Definition)
if err != nil {
return fmt.Errorf("marshal video flow definition: %w", err)
}
writer, isCreated, err := inst.NewWriter(string(flowJSON))
if err != nil {
return fmt.Errorf("create video writer: %w", err)
}
defer func() {
if err := writer.Close(); err != nil {
runErr = errors.Join(runErr, fmt.Errorf("close video writer: %w", err))
}
}()
if !isCreated {
log.Printf("reusing existing flow: %s, domain: %s", vi.uuid, mxlDomain)
log.Printf("reusing existing video flow: %s", cfg.ID())
}
defer writer.Close()
flowCfg := writer.Config()
rate := flowCfg.Common.GrainRate
rate := writer.Config().Common.GrainRate
idx := mxl.CurrentIndex(rate)
log.Printf("writing flow grainRate=%d/%d starting at idx=%d", rate.Num, rate.Den, idx)
log.Printf("writing video flow grainRate=%d/%d starting at idx=%d", rate.Num, rate.Den, idx)
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
// core loop
var grainsWritten int64
// animation clock: small counter, not the huge grain index.
// Reason: current wgpu shaders limitations
var tick uint32
var tick uint32 // Small animation counter; shaders cannot use the full grain index yet.
for {
select {
case <-stop:
log.Printf("stopping after %d grains", grainsWritten)
return
case <-ctx.Done():
log.Printf("stopping video after %d grains", grainsWritten)
return nil
default:
}
gwa, err := writer.OpenGrain(idx)
grain, err := writer.OpenGrain(idx)
if err != nil {
log.Fatalf("OpenGrain(%d): %v", idx, err)
return fmt.Errorf("open video grain %d: %w", idx, err)
}
if err := gen.GenerateFrame(gwa.Payload, int(tick)); err != nil {
log.Fatalf("GenerateFrame(%d): %v", idx, err)
if err := gen.GenerateFrame(grain.Payload, int(tick)); err != nil {
return cancelVideoGrain(grain, fmt.Errorf("generate frame for grain %d: %w", idx, err))
}
if overlay != nil {
if err := overlay.ApplyV210(gwa.Payload); err != nil {
log.Fatalf("text overlay: %v", err)
if err := overlay.ApplyV210(grain.Payload); err != nil {
return cancelVideoGrain(grain, fmt.Errorf("apply text overlay to grain %d: %w", idx, err))
}
}
if err := gwa.Commit(gwa.TotalSlices, 0); err != nil {
log.Fatalf("Commit(%d): %v", idx, err)
if err := grain.Commit(grain.TotalSlices, 0); err != nil {
return fmt.Errorf("commit video grain %d: %w", idx, err)
}
grainsWritten++
idx++
tick++
if grainsWritten%100 == 0 {
log.Printf("grains written=%d, index=%d", grainsWritten, idx)
log.Printf("video grains written=%d, next index=%d", grainsWritten, idx)
}
// Pace ourselves to roughly the grain rate
mxl.SleepNs(mxl.NsUntilIndex(idx, rate))
}
}
func runAudio(
ctx context.Context,
inst *mxl.Instance,
cfg audio.Config,
) (runErr error) {
flowJSON, err := json.Marshal(cfg.Definition)
if err != nil {
return fmt.Errorf("marshal audio flow definition: %w", err)
}
writer, isCreated, err := inst.NewWriter(string(flowJSON))
if err != nil {
return fmt.Errorf("create audio writer: %w", err)
}
defer func() {
if err := writer.Close(); err != nil {
runErr = errors.Join(
runErr,
fmt.Errorf("close audio writer: %w", err),
)
}
}()
if !isCreated {
log.Printf("reusing existing audio flow: %s", cfg.ID())
}
writerCfg := writer.Config()
if writerCfg.Common.Format != mxl.FormatAudio {
return fmt.Errorf(
"audio writer has format %s, want audio",
writerCfg.Common.Format,
)
}
if writerCfg.Continuous.ChannelCount != uint32(cfg.Channels()) {
return fmt.Errorf(
"audio writer has %d channels, configured generator expects %d",
writerCfg.Continuous.ChannelCount,
cfg.Channels(),
)
}
rate := writerCfg.Common.GrainRate
if rate != cfg.Rate() {
return fmt.Errorf(
"audio writer has sample rate %d/%d, configured generator expects %d/%d",
rate.Num,
rate.Den,
cfg.Rate().Num,
cfg.Rate().Den,
)
}
const baseFrequency = 1000.0
gen, err := audio.NewSineGenerator(cfg, baseFrequency)
if err != nil {
return fmt.Errorf("initialize audio generator: %w", err)
}
batch := audioBatchSize(rate)
maxBatch, err := writer.GetMaxWriteLengthSamples()
if err != nil {
return fmt.Errorf("get maximum audio write length: %w", err)
}
if maxBatch == 0 {
return fmt.Errorf("audio writer reported a maximum write length of zero samples")
}
if batch > maxBatch {
batch = maxBatch
}
index := mxl.CurrentIndex(rate)
if index < batch-1 {
return fmt.Errorf("current audio index %d is too small for batch size %d", index, batch)
}
log.Printf(
"writing audio flow sampleRate=%d/%d channels=%d batch=%d starting at idx=%d",
rate.Num,
rate.Den,
cfg.Channels(),
batch,
index,
)
var samplesWritten uint64
for {
select {
case <-ctx.Done():
log.Printf("stopping audio after %d samples", samplesWritten)
return nil
default:
}
access, err := writer.OpenSamples(index, int(batch))
if err != nil {
return fmt.Errorf(
"open %d audio samples at index %d: %w",
batch,
index,
err,
)
}
firstSample := index - batch + 1
for channel := uint64(0); channel < access.ChannelCount; channel++ {
first, second, err := access.ChannelFragments(channel)
if err != nil {
return cancelAudioSamples(
access,
fmt.Errorf(
"get fragments for audio channel %d at index %d: %w",
channel,
index,
err,
),
)
}
if err := gen.Generate(
uint(channel),
firstSample,
first,
second,
); err != nil {
return cancelAudioSamples(
access, fmt.Errorf(
"generate audio channel %d at index %d: %w",
channel,
index,
err,
),
)
}
}
if err := access.Commit(); err != nil {
return fmt.Errorf(
"commit %d audio samples at index %d: %w",
batch,
index,
err,
)
}
samplesWritten += batch
index += batch
mxl.SleepNs(mxl.NsUntilIndex(index, rate))
}
}
func buildTextOverlay(cfg video.Config) (overlay *generator.TextOverlay, resultErr error) {
if cfg.Overlay.Text == "" {
return nil, nil
}
if cfg.Overlay.X < 0 || cfg.Overlay.X > int(cfg.Width()) ||
cfg.Overlay.Y < 0 || cfg.Overlay.Y > int(cfg.Height()) {
return nil, fmt.Errorf("text overlay position (%d, %d) is outside the %dx%d video frame",
cfg.Overlay.X, cfg.Overlay.Y, cfg.Width(), cfg.Height())
}
face, err := generator.LoadFace("assets/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf", 48)
if err != nil {
return nil, fmt.Errorf("load text overlay font: %w", err)
}
defer func() {
if err := face.Close(); err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("close text overlay font: %w", err))
}
}()
overlay, err = generator.NewTextOverlay(
cfg.Overlay.Text,
int(cfg.Width()),
int(cfg.Height()),
cfg.Overlay.X,
cfg.Overlay.Y,
cfg.Overlay.Position,
face,
)
if err != nil {
return nil, fmt.Errorf("create text overlay: %w", err)
}
return overlay, nil
}
func cancelVideoGrain(grain *mxl.GrainWriteAccess, cause error) error {
if err := grain.Cancel(); err != nil {
return errors.Join(cause, fmt.Errorf("cancel video grain: %w", err))
}
return cause
}
func audioBatchSize(rate mxl.Rational) uint64 {
if rate.Num <= 0 || rate.Den <= 0 {
return 1
}
samples := rate.Num / (100 * rate.Den)
if samples < 1 {
return 1
}
return uint64(samples)
}
func cancelAudioSamples(
access *mxl.SamplesWriteAccess,
cause error,
) error {
if err := access.Cancel(); err != nil {
return errors.Join(
cause,
fmt.Errorf("cancel audio samples: %w", err),
)
}
return cause
}
+345
View File
@@ -0,0 +1,345 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"time"
"github.com/qvest-digital/go-mxl/mxl"
"mxl-pattern-generator/internal/audio"
"mxl-pattern-generator/internal/flowdef"
)
func TestParseArgsHelpStopsBeforeValidation(t *testing.T) {
var stdout, stderr bytes.Buffer
result, err := parseArgs([]string{"--help"}, &stdout, &stderr)
if err != nil {
t.Fatalf("parseArgs: %v", err)
}
if result.shouldRun {
t.Fatal("shouldRun = true, want false")
}
if !result.args.showHelp {
t.Fatal("showHelp = false, want true")
}
if !strings.Contains(stdout.String(), "Usage: mxl-gen") {
t.Fatalf("help output does not contain usage: %q", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestParseArgsListPatternsStopsBeforeValidation(t *testing.T) {
var stdout, stderr bytes.Buffer
result, err := parseArgs([]string{"--list-patterns"}, &stdout, &stderr)
if err != nil {
t.Fatalf("parseArgs: %v", err)
}
if result.shouldRun {
t.Fatal("shouldRun = true, want false")
}
if !result.args.listPatterns {
t.Fatal("listPatterns = false, want true")
}
if !strings.Contains(stdout.String(), "ebu75") {
t.Fatalf("pattern output does not contain ebu75: %q", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestParseArgsRejectsUnexpectedPositionalArguments(t *testing.T) {
var stdout, stderr bytes.Buffer
result, err := parseArgs([]string{"unexpected"}, &stdout, &stderr)
if err == nil {
t.Fatal("parseArgs returned nil error")
}
if result.shouldRun {
t.Fatal("shouldRun = true, want false")
}
if !strings.Contains(err.Error(), "unexpected positional arguments") {
t.Fatalf("error = %q", err)
}
}
func TestParseArgsRejectsUnknownFlag(t *testing.T) {
var stdout, stderr bytes.Buffer
result, err := parseArgs([]string{"--not-a-flag"}, &stdout, &stderr)
if err == nil {
t.Fatal("parseArgs returned nil error")
}
if result.shouldRun {
t.Fatal("shouldRun = true, want false")
}
}
func TestValidateAudioArgsSkipsDisabledAudio(t *testing.T) {
args := appArgs{
audioChannels: 0,
audioSamplingFreq: "unsupported",
audioUUID: "not-a-uuid",
}
if err := validateAudioArgs(&args); err != nil {
t.Fatalf("validateAudioArgs: %v", err)
}
if args.audioUUID != "not-a-uuid" {
t.Fatalf("audio UUID changed while audio is disabled: %q", args.audioUUID)
}
}
func TestListPatternsIsSorted(t *testing.T) {
var output bytes.Buffer
listPatterns(&output)
text := output.String()
if strings.Index(text, "ebu100") > strings.Index(text, "ebu75") {
t.Fatalf("patterns are not sorted: %q", text)
}
}
func TestBuildVideoConfigFromArgs(t *testing.T) {
args := appArgs{
videoUUID: "5fbec3b1-1b0f-417d-9059-8b94a47197ed",
videoWidth: 1920,
videoHeight: 1080,
videoFPS: "29.97",
pattern: "ebu75",
}
cfg, err := buildVideoConfig(args)
if err != nil {
t.Fatalf("buildVideoConfig: %v", err)
}
if cfg.Width() != 1920 || cfg.Height() != 1080 {
t.Fatalf("dimensions = %dx%d, want 1920x1080", cfg.Width(), cfg.Height())
}
if cfg.Rate().Num != 30000 || cfg.Rate().Den != 1001 {
t.Fatalf("rate = %d/%d, want 30000/1001", cfg.Rate().Num, cfg.Rate().Den)
}
}
func TestBuildVideoConfigFromFile(t *testing.T) {
definition, err := flowdef.NewV210Video(
"5fbec3b1-1b0f-417d-9059-8b94a47197ed",
3840,
2160,
flowdef.Rational{Numerator: 60000, Denominator: 1001},
)
if err != nil {
t.Fatalf("NewV210Video: %v", err)
}
data, err := json.Marshal(definition)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
path := t.TempDir() + "/video.json"
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("os.WriteFile: %v", err)
}
cfg, err := buildVideoConfig(appArgs{
videoFlowDefFile: path,
pattern: "smpte",
// These values must be ignored when a definition file is supplied.
videoWidth: 1920,
videoHeight: 1080,
videoFPS: "25",
})
if err != nil {
t.Fatalf("buildVideoConfig: %v", err)
}
if cfg.Width() != 3840 || cfg.Height() != 2160 {
t.Fatalf("dimensions = %dx%d, want file values 3840x2160", cfg.Width(), cfg.Height())
}
if cfg.Rate().Num != 60000 || cfg.Rate().Den != 1001 {
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)
}
}
func TestAudioBatchSize(t *testing.T) {
tests := []struct {
name string
rate mxl.Rational
want uint64
}{
{name: "44.1 kHz", rate: mxl.Rational{Num: 44100, Den: 1}, want: 441},
{name: "48 kHz", rate: mxl.Rational{Num: 48000, Den: 1}, want: 480},
{name: "96 kHz", rate: mxl.Rational{Num: 96000, Den: 1}, want: 960},
{name: "192 kHz", rate: mxl.Rational{Num: 192000, Den: 1}, want: 1920},
{name: "minimum", rate: mxl.Rational{Num: 1, Den: 1}, want: 1},
{name: "zero numerator", rate: mxl.Rational{Num: 0, Den: 1}, want: 1},
{name: "zero denominator", rate: mxl.Rational{Num: 48000, Den: 0}, want: 1},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := audioBatchSize(tc.rate); got != tc.want {
t.Fatalf("audioBatchSize(%d/%d) = %d, want %d", tc.rate.Num, tc.rate.Den, got, tc.want)
}
})
}
}
func TestRunConcurrentCancelsSiblingAndWaitsForCleanup(t *testing.T) {
wantErr := errors.New("writer failed")
peerStarted := make(chan struct{})
peerStopped := make(chan struct{})
err := runConcurrent(context.Background(),
namedRunner{
name: "video",
run: func(ctx context.Context) error {
<-peerStarted
return wantErr
},
},
namedRunner{
name: "audio",
run: func(ctx context.Context) error {
close(peerStarted)
<-ctx.Done()
close(peerStopped)
return nil
},
},
)
if !errors.Is(err, wantErr) {
t.Fatalf("error = %v, want wrapped %v", err, wantErr)
}
if !strings.Contains(err.Error(), "video flow") {
t.Fatalf("error = %q, want runner name", err)
}
select {
case <-peerStopped:
default:
t.Fatal("runConcurrent returned before the sibling completed cleanup")
}
}
func TestRunConcurrentParentCancellationIsGraceful(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
started := make(chan struct{})
done := make(chan error, 1)
go func() {
done <- runConcurrent(ctx, namedRunner{
name: "video",
run: func(ctx context.Context) error {
close(started)
<-ctx.Done()
return nil
},
})
}()
<-started
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("runConcurrent: %v", err)
}
case <-time.After(time.Second):
t.Fatal("runConcurrent did not stop after parent cancellation")
}
}
+32
View File
@@ -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),
}
}
+125
View File
@@ -0,0 +1,125 @@
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)
}
// Keep every generated tone below the Nyquist frequency (the
// KotelnikovNyquistShannon sampling limit), sampleRate/2
// Nyquist leaved as compromise for common English terminology
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")
}
}
-120
View File
@@ -1,120 +0,0 @@
// MXL Flow Definition helper
package flowdef
import (
"encoding/json"
"errors"
"os"
)
const (
TYPE_VIDEO = iota
TYPE_VIDEO_ALPHA
TYPE_AUDIO
)
var (
ErrUnknownFlowType = errors.New("Unknown flow type provided")
ErrIncorrectFlowUUID = errors.New("Incorrect flow uuid")
)
type flowDef struct {
Description string `json:"description"`
Id string `json:"id"`
Tags map[string][]string `json:"tags"`
Format string `json:"format"`
Label string `json:"label"`
Parents []string `json:"parents"`
MediaType string `json:"media_type"`
GrainRate grainRate `json:"grain_rate"`
FrameWidth uint `json:"frame_width"`
FrameHeight uint `json:"frame_height"`
InterlaceMode string `json:"interlace_mode"`
ColorSpace string `json:"colorspace"`
Components [3]videoComponent `json:"components"`
}
type grainRate struct {
Numerator uint `json:"numerator"`
Denominator uint `json:"denominator"`
}
type videoComponent struct {
Name string `json:"name"`
Width uint `json:"width"`
Height uint `json:"height"`
BitDepth uint `json:"bit_depth"`
}
func NewFlowDefJSON(
feedType int,
uuid string,
width uint,
height uint,
fpsNum uint,
fpsDen uint,
) (string, error) {
if feedType != TYPE_VIDEO &&
feedType != TYPE_VIDEO_ALPHA &&
feedType != TYPE_AUDIO {
return "", ErrUnknownFlowType
}
if uuid == "" {
return "", ErrIncorrectFlowUUID
}
tags := map[string][]string{
"urn:x-nmos:tag:grouphint/v1.0": {
"___one day I will follow NMOS specs___:Video",
},
}
flowDef := flowDef{
Description: "go-mxl-pattern-gen generated feed",
Id: uuid,
Tags: tags,
Format: "urn:x-nmos:format:video",
Label: "go-mxl-pattern-gen generated feed",
Parents: nil,
MediaType: "video/v210",
GrainRate: grainRate{
Numerator: fpsNum,
Denominator: fpsDen,
},
FrameWidth: width,
FrameHeight: height,
InterlaceMode: "progressive",
ColorSpace: "BT709",
Components: [3]videoComponent{
videoComponent{
Name: "Y",
Width: width,
Height: height,
BitDepth: 10,
},
videoComponent{
Name: "Cb",
Width: width / 2,
Height: height,
BitDepth: 10,
},
videoComponent{
Name: "Cr",
Width: width / 2,
Height: height,
BitDepth: 10,
},
},
}
jsonBytes, err := json.Marshal(flowDef)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func ReadFlowDefFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}
+223
View File
@@ -0,0 +1,223 @@
// Package flowdef models and validates MXL flow-definition JSON.
package flowdef
import (
"encoding/json"
"fmt"
"github.com/google/uuid"
)
const (
FormatVideo = "urn:x-nmos:format:video"
FormatAudio = "urn:x-nmos:format:audio"
MediaTypeV210 = "video/v210"
MediaTypeFloat32 = "audio/float32"
InterlaceProgressive = "progressive"
ColorSpaceBT709 = "BT709"
)
type Common struct {
Description string `json:"description"`
ID string `json:"id"`
Tags map[string][]string `json:"tags"`
Format string `json:"format"`
Label string `json:"label"`
Parents []string `json:"parents"`
MediaType string `json:"media_type"`
}
type Rational struct {
Numerator uint `json:"numerator"`
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
GrainRate Rational `json:"grain_rate"`
FrameWidth uint `json:"frame_width"`
FrameHeight uint `json:"frame_height"`
InterlaceMode string `json:"interlace_mode"`
ColorSpace string `json:"colorspace"`
Components []VideoComponent `json:"components"`
}
type Audio struct {
Common
SampleRate Rational `json:"sample_rate"`
ChannelCount uint `json:"channel_count"`
BitDepth uint `json:"bit_depth"`
}
type VideoComponent struct {
Name string `json:"name"`
Width uint `json:"width"`
Height uint `json:"height"`
BitDepth uint `json:"bit_depth"`
}
func NewV210Video(id string, width, height uint, rate Rational) (Video, error) {
definition := Video{
Common: Common{
Description: "go-mxl-pattern-gen generated video",
ID: id,
Tags: map[string][]string{
"urn:x-nmos:tag:grouphint/v1.0": {"go-mxl-pattern-gen:Video"},
},
Format: FormatVideo,
Label: "go-mxl-pattern-gen generated video",
Parents: []string{},
MediaType: MediaTypeV210,
},
GrainRate: rate,
FrameWidth: width,
FrameHeight: height,
InterlaceMode: InterlaceProgressive,
ColorSpace: ColorSpaceBT709,
Components: []VideoComponent{
{Name: "Y", Width: width, Height: height, BitDepth: 10},
{Name: "Cb", Width: width / 2, Height: height, BitDepth: 10},
{Name: "Cr", Width: width / 2, Height: height, BitDepth: 10},
},
}
if err := definition.Validate(); err != nil {
return Video{}, err
}
return definition, nil
}
func ParseV210Video(data []byte) (Video, error) {
var definition Video
if err := json.Unmarshal(data, &definition); err != nil {
return Video{}, fmt.Errorf("decode video flow definition: %w", err)
}
if err := definition.Validate(); err != nil {
return Video{}, fmt.Errorf("invalid video flow definition: %w", err)
}
return definition, nil
}
func (v Video) Validate() error {
if err := uuid.Validate(v.ID); err != nil {
return fmt.Errorf("invalid id %q: %w", v.ID, err)
}
if v.Format != FormatVideo {
return fmt.Errorf("format must be %q, got %q", FormatVideo, v.Format)
}
if v.MediaType != MediaTypeV210 {
return fmt.Errorf("media_type must be %q, got %q", MediaTypeV210, v.MediaType)
}
if v.InterlaceMode != InterlaceProgressive {
return fmt.Errorf("interlace_mode must be %q, got %q", InterlaceProgressive, v.InterlaceMode)
}
if v.ColorSpace != ColorSpaceBT709 {
return fmt.Errorf("colorspace must be %q, got %q", ColorSpaceBT709, v.ColorSpace)
}
if v.FrameWidth == 0 || v.FrameWidth%6 != 0 {
return fmt.Errorf("frame_width must be greater than zero and divisible by 6, got %d", v.FrameWidth)
}
if v.FrameHeight == 0 {
return fmt.Errorf("frame_height must be greater than zero")
}
if v.GrainRate.Numerator == 0 || v.GrainRate.Denominator == 0 {
return fmt.Errorf("grain_rate numerator and denominator must be greater than zero, got %d/%d",
v.GrainRate.Numerator, v.GrainRate.Denominator)
}
want := []VideoComponent{
{Name: "Y", Width: v.FrameWidth, Height: v.FrameHeight, BitDepth: 10},
{Name: "Cb", Width: v.FrameWidth / 2, Height: v.FrameHeight, BitDepth: 10},
{Name: "Cr", Width: v.FrameWidth / 2, Height: v.FrameHeight, BitDepth: 10},
}
if len(v.Components) != len(want) {
return fmt.Errorf("v210 requires %d components, got %d", len(want), len(v.Components))
}
for i := range want {
if v.Components[i] != want[i] {
return fmt.Errorf("component %d must be %+v, got %+v", i, want[i], v.Components[i])
}
}
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
}
+178
View File
@@ -0,0 +1,178 @@
package flowdef
import (
"encoding/json"
"strings"
"testing"
)
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})
if err != nil {
t.Fatalf("NewV210Video: %v", err)
}
if definition.ID != testVideoID {
t.Fatalf("ID = %q, want %q", definition.ID, testVideoID)
}
if definition.Format != FormatVideo || definition.MediaType != MediaTypeV210 {
t.Fatalf("format/media type = %q/%q", definition.Format, definition.MediaType)
}
if len(definition.Components) != 3 {
t.Fatalf("component count = %d, want 3", len(definition.Components))
}
if definition.Parents == nil {
t.Fatal("Parents is nil; want an empty JSON array")
}
}
func TestNewV210VideoRejectsInvalidWidth(t *testing.T) {
_, err := NewV210Video(testVideoID, 1919, 1080, Rational{Numerator: 25, Denominator: 1})
if err == nil || !strings.Contains(err.Error(), "divisible by 6") {
t.Fatalf("error = %v, want width divisibility error", err)
}
}
func TestParseV210Video(t *testing.T) {
want, err := NewV210Video(testVideoID, 1920, 1080, Rational{Numerator: 25, Denominator: 1})
if err != nil {
t.Fatalf("NewV210Video: %v", err)
}
data, err := json.Marshal(want)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
got, err := ParseV210Video(data)
if err != nil {
t.Fatalf("ParseV210Video: %v", err)
}
if got.ID != want.ID || got.FrameWidth != want.FrameWidth || got.GrainRate != want.GrainRate {
t.Fatalf("parsed definition = %+v, want %+v", got, want)
}
}
func TestParseV210VideoRejectsAudio(t *testing.T) {
data := []byte(`{
"id":"5fbec3b1-1b0f-417d-9059-8b94a47197ed",
"format":"urn:x-nmos:format:audio",
"media_type":"audio/float32"
}`)
_, err := ParseV210Video(data)
if err == nil || !strings.Contains(err.Error(), "format must be") {
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)
}
}
+39
View File
@@ -0,0 +1,39 @@
package video
import (
"mxl-pattern-generator/internal/flowdef"
"github.com/qvest-digital/go-mxl/mxl"
)
type Config struct {
Definition flowdef.Video
Pattern string
Overlay OverlayConfig
}
type OverlayConfig struct {
Text string
X int
Y int
Position string
}
func (c Config) ID() string {
return c.Definition.ID
}
func (c Config) Width() uint {
return c.Definition.FrameWidth
}
func (c Config) Height() uint {
return c.Definition.FrameHeight
}
func (c Config) Rate() mxl.Rational {
return mxl.Rational{
Num: int64(c.Definition.GrainRate.Numerator),
Den: int64(c.Definition.GrainRate.Denominator),
}
}