// MXL pattern generator feed rendered on the GPU via wgpu (WebGPU/Vulkan). // Run from the repo root: make wgpu-gen // (mixing libmxl cgo with wgpu/goffi needs the internal linker) 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" "mxl-pattern-generator/internal/audio" "mxl-pattern-generator/internal/flowdef" "mxl-pattern-generator/internal/generator" "mxl-pattern-generator/internal/video" ) const ( APP_NAME = "MXL pattern generator" APP_VER = "0.1.0" ) type appArgs struct { showHelp bool domain string videoFlowDefFile string audioFlowDefFile string pattern string listPatterns bool textOverlay string overlayX int overlayY int overlayPos string videoWidth uint videoHeight uint videoFPS string videoUUID string 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}, "25": {Num: 25, Den: 1}, "29.97": {Num: 30000, Den: 1001}, "30": {Num: 30, Den: 1}, "50": {Num: 50, Den: 1}, "59.94": {Num: 60000, Den: 1001}, "60": {Num: 60, Den: 1}, "120": {Num: 120, Den: 1}, "240": {Num: 240, Den: 1}, } var samplingRates = map[string]mxl.Rational{ "44.1": {Num: 44100, Den: 1}, "48": {Num: 48000, Den: 1}, "96": {Num: 96000, Den: 1}, "192": {Num: 192000, Den: 1}, } 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 [-v ] [-a ] [options]") fmt.Fprintln(w, " or: mxl-gen -d [--width ] [--height ] [--fps ] \\") fmt.Fprintln(w, " [-c ] [-f ]") fmt.Fprintln(w, " or: mxl-gen -d 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(w io.Writer) { fmt.Fprintln(w, "Usage: mxl-gen -d [-v ] [-a ] [options]") fmt.Fprintln(w, "Try 'mxl-gen -h' for more information.") } func validateDomain(domain string) error { if domain == "" { return fmt.Errorf("domain is required") } fi, err := os.Stat(domain) if err != nil { return fmt.Errorf("invalid MXL domain %q: %w", domain, err) } if !fi.IsDir() { return fmt.Errorf("invalid MXL domain %q: not a directory", domain) } 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) } if args.videoFlowDefFile != "" { 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 != "" { 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 } 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 } 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 { name string description string kernelPath string motion bool } var patterns = map[string]pattern{ "ebu75": { name: "ebu75", description: "EBU 75% Color Bar Signal", kernelPath: "kernels/static/ebu75.wgsl", motion: false, }, "ebu75-move": { name: "ebu75-move", description: "EBU 75% Color Bar Signal with moving square", kernelPath: "kernels/dynamic/ebu75.wgsl", motion: true, }, "ebu100": { name: "ebu100", description: "EBU 100% Color Bar Signal", kernelPath: "kernels/static/ebu100.wgsl", motion: false, }, "ebu100-move": { name: "ebu100-move", description: "EBU 100% Color Bar Signal with moving square", kernelPath: "kernels/dynamic/ebu100.wgsl", motion: true, }, "smpte": { name: "smpte", description: "SMPTE RP-219 Color Bar Signal", kernelPath: "kernels/static/smpteBars.wgsl", motion: false, }, "smpte-move": { name: "smpte-move", description: "SMPTE RP-219 Color Bar Signal with moving square", kernelPath: "kernels/dynamic/smpteBars.wgsl", motion: true, }, "gray-bars": { name: "gray-bars", description: "13-step grayscale bars (Y 64..940)", kernelPath: "kernels/static/yBars.wgsl", motion: false, }, "gray-bars-move": { name: "gray-bars-move", description: "13-step grayscale bars (Y 64..940) with moving square", kernelPath: "kernels/dynamic/yBars.wgsl", motion: true, }, "gray-ramp": { name: "gray-ramp", description: "Y gradient (black -> 100% white)", kernelPath: "kernels/static/yRamp.wgsl", motion: false, }, "gray-ramp-move": { name: "gray-ramp-move", description: "Y gradient with moving square", kernelPath: "kernels/dynamic/yRamp.wgsl", motion: true, }, } 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 } } sort.Strings(names) for _, name := range names { p := patterns[name] fmt.Fprintf(w, " %-*s - %s\n", maxNameWidth, name, p.description) } } func addFlags(fs *pflag.FlagSet, args *appArgs) { // common flags fs.BoolVarP(&args.showHelp, "help", "h", false, "Show help message and exit") // MXL flags fs.StringVarP(&args.domain, "domain", "d", "", "MXL domain") 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", "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") fs.IntVar(&args.overlayX, "text-x", 0, "Text overlay X position in px. Ignored if text-pos set") fs.IntVar( &args.overlayY, "text-y", 0, "Text overlay Y position in px") fs.StringVar( &args.overlayPos, "text-pos", "", "Text overlay position with pre-defined values:\n"+ "tl - top-left corner\n"+ "tc - top-center\n"+ "tr - top-right corner\n"+ "cc - center of the frame\n"+ "bl - bottom-left corner\n"+ "bc - bottom-center\n"+ "br - bottom-right corner", ) 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") fs.StringVarP(&args.audioSamplingFreq, "freq", "f", "48", "Sampling frequency of test audio feed in kHz") fs.StringVar(&args.audioLevel, "audio-level", "ebu", "Audio alignment level: ebu (-18 dBFS) or smpte (-20 dBFS)") fs.StringVar(&args.audioUUID, "audio-id", "", "Audio UUID. Will be created, if not provided") } func parseArgs(argv []string, stdout, stderr io.Writer) (parseResult, error) { var args appArgs flagSet := pflag.NewFlagSet("args", pflag.ContinueOnError) flagSet.SortFlags = false flagSet.SetOutput(stderr) flagSet.Usage = func() { printUsage(stderr) } addFlags(flagSet, &args) if err := flagSet.Parse(argv); err != nil { return parseResult{}, err } if args.showHelp { printHelp(stdout, flagSet) return parseResult{args: args}, nil } if args.listPatterns { listPatterns(stdout) return parseResult{args: args}, nil } 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 } func buildVideoConfig(args appArgs) (video.Config, error) { var definition flowdef.Video if args.videoFlowDefFile != "" { data, err := os.ReadFile(args.videoFlowDefFile) if err != nil { 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 { rate, ok := frameRates[args.videoFPS] if !ok { return video.Config{}, fmt.Errorf("unsupported video FPS %q", args.videoFPS) } 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", 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()) } 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)) } }() 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 } 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) } // TODO: fall back to a CPU generator if GPU initialization fails. gen, err := generator.NewWGPUGenerator(cfg.Width(), cfg.Height(), pattern.kernelPath) if err != nil { 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 video flow: %s", cfg.ID()) } rate := writer.Config().Common.GrainRate idx := mxl.CurrentIndex(rate) log.Printf("writing video flow grainRate=%d/%d starting at idx=%d", rate.Num, rate.Den, idx) var grainsWritten int64 var tick uint32 // Small animation counter; shaders cannot use the full grain index yet. for { select { case <-ctx.Done(): log.Printf("stopping video after %d grains", grainsWritten) return nil default: } grain, err := writer.OpenGrain(idx) if err != nil { return fmt.Errorf("open video grain %d: %w", 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(grain.Payload); err != nil { return cancelVideoGrain(grain, fmt.Errorf("apply text overlay to grain %d: %w", 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("video grains written=%d, next index=%d", grainsWritten, idx) } 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 }