From 7def88da824b75640befc8f4de52611caa2ffb8e Mon Sep 17 00:00:00 2001 From: Martin Wimpress Date: Sat, 4 Jul 2026 22:41:57 +0100 Subject: [PATCH 1/7] refactor: fix peer review findings in audio, encoder, renderer, and CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Audio: implement SlideFFTWindow helper to handle prefill and remove panic on high sample rates; write complete prefill to encoder instead of truncating to one frame - Encoder: fix inverted AVFrameMakeWritable checks in software and hardware paths; tag H.264 stream with full-range BT.601 JFIF colourspace; improve Close() to check flush/drain/trailer/avio results, join errors, prevent spin loops, ensure idempotence - Renderer: use clamped bar height in mirrors; add mirror-symmetry and bar-pixel tests; remove unfalsifiable assertion - CLI: route render-pass failures through RenderComplete.Err; print error after TUI closes, exit non-zero on failure - Module rename: github.com/linuxmatters/jivefire โ†’ github.com/linuxmatters/jive-visualiser across all Go imports, issue template, SECURITY.md - Tests: close five leaked C FFT contexts; add prefill-count tests; audit analyser fixtures; TestConversionEquivalence now asserts full-range swscale setup - Docs: credit FFmpeg av_tx RDFT and linear binning in ARCHITECTURE.md; update AGENTS.md with Go 1.26, linear binning, releases rule (no checksum generation); refresh benchmark figures 8.4x โ†’ 13.2x; add Harper dictionary terms Signed-off-by: Martin Wimpress --- .github/ISSUE_TEMPLATE/config.yml | 2 +- .harper-dictionary.txt | 18 +++ AGENTS.md | 10 +- README.md | 2 +- SECURITY.md | 2 +- cmd/bench-yuv/main.go | 69 +++++---- cmd/jive-visualiser/main.go | 143 ++++++++++--------- cmd/jive-visualiser/main_test.go | 68 +++++++++ docs/ARCHITECTURE.md | 6 +- go.mod | 2 +- internal/audio/analyzer.go | 9 +- internal/audio/analyzer_test.go | 131 ++++++++++++++--- internal/audio/fft.go | 2 +- internal/audio/fft_test.go | 4 + internal/audio/window.go | 39 +++++ internal/audio/window_test.go | 62 ++++++++ internal/cli/help.go | 2 +- internal/cli/styles.go | 2 +- internal/encoder/encoder.go | 79 +++++++--- internal/encoder/frame.go | 2 +- internal/encoder/hwaccel.go | 2 +- internal/encoder/sws_benchmark_test.go | 190 ++++++++++--------------- internal/renderer/assets.go | 2 +- internal/renderer/frame.go | 12 +- internal/renderer/frame_bench_test.go | 21 +-- internal/renderer/frame_test.go | 109 ++++++++++++++ internal/renderer/thumbnail.go | 2 +- internal/renderer/thumbnail_test.go | 2 +- internal/ui/progress.go | 25 +++- internal/ui/spectrum.go | 2 +- internal/ui/spectrum_test.go | 4 +- internal/ui/summary.go | 4 +- internal/ui/widgets.go | 2 +- internal/ui/widgets_test.go | 2 +- 34 files changed, 722 insertions(+), 311 deletions(-) create mode 100644 cmd/jive-visualiser/main_test.go create mode 100644 internal/audio/window.go create mode 100644 internal/audio/window_test.go create mode 100644 internal/renderer/frame_test.go diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 6cf9d62..70e53d2 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,7 +1,7 @@ blank_issues_enabled: false contact_links: - name: ๐Ÿ›Ÿ Support - url: https://github.com/linuxmatters/jivefire/blob/HEAD/SUPPORT.md + url: https://github.com/linuxmatters/jive-visualiser/blob/HEAD/SUPPORT.md about: Check the support guide before opening an issue. # - name: Code of Conduct Report # url: https://yourproject.org/community-report/ diff --git a/.harper-dictionary.txt b/.harper-dictionary.txt index a721c65..38e147b 100644 --- a/.harper-dictionary.txt +++ b/.harper-dictionary.txt @@ -1,3 +1,21 @@ Jivefire Visualiser visualiser +CGO +resampler +colour +stderr +stdout +stdin +WAV +FLAC +YUV +NVENC +QSV +VideoToolbox +Hanning +statigo +ffmpeg +FFmpeg +aarch64 +centre diff --git a/AGENTS.md b/AGENTS.md index 20c1a8e..a346628 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,13 +47,13 @@ ## Audio Processing - FFT size: 2048 samples (Hanning window) -- 64 frequency bars with log-scale binning +- 64 frequency bars with linear (uniform) frequency binning; logarithmic scaling applies to amplitude only - Harmonica spring peak-hold bar dynamics: each bar rises instantly to any new peak, then springs back toward the live level. Spring params: frequency `6.0`, damping `1.0`, delta `1/FPS`, gain `2.0` (replaces the amplitude lift the old CAVA integrator provided) - Audio frame size mismatch handled by FFmpeg's `AVAudioFifo` (in `internal/encoder/encoder.go`; FFT needs 2048, AAC expects 1024) ## Performance Patterns -- RGBโ†’YUV conversion in `encoder/frame.go` parallelised across CPU cores via `yuv.ParallelRows` (8.4ร— faster than swscale) +- RGBโ†’YUV conversion in `encoder/frame.go` parallelised across CPU cores via `yuv.ParallelRows` (13.2ร— faster than swscale) - `convertRGBAToYUV` (YUV420P) and `convertRGBAToNV12` (NV12) in `encoder/frame.go` are intentionally kept as separate functions despite near-identical structure โ€” the hot-path duplication avoids a callback/interface indirection that would hurt throughput; do not refactor into a shared helper (shared low-level primitives live in `internal/yuv`) - Frame rendering uses symmetric mirroring (draw 1/4 pixels, mirror 3ร—) - Pre-computed intensity/colour tables in `renderer/frame.go` @@ -89,9 +89,13 @@ - Audio profile display persists from Pass 1 through Pass 2 - Video preview: `internal/ui/preview.go` +## Releases + +- Do not add checksum or hash generation to the release workflow. GitHub shows SHA256 digests for release assets by default. + ## Environment - NixOS development shell via `flake.nix` - Fish shell for terminal commands - CGO required (`CGO_ENABLED=1` in build) -- Go 1.24.0 minimum +- Go 1.26 minimum diff --git a/README.md b/README.md index 9aec243..67738d2 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Your podcast audio deserves more than a static image on YouTube. Jive Visualiser - ๐ŸŽฌ **1280ร—720 @ 30fps** H.264/AAC YouTube-ready MP4, no questions asked - ๐ŸŽš๏ธ **64 frequency bars** that actually look discrete (not that smeared spectrum nonsense) - ๐Ÿชž **Symmetric mirroring** above and below centre, doubles the visual impact - - ๐Ÿ”ฌ **FFT-based analysis** 2048-point Hanning window, log scale frequency binning + - ๐Ÿ”ฌ **FFT-based analysis** 2048-point Hanning window, linear frequency binning, log-scaled amplitude - โœจ **Spring-driven bar dynamics** bars snap up instantly, spring back down via harmonica peak-hold - ๐Ÿš€ **Stupidly fast** streaming pipeline, parallel RGBโ†’YUV conversion - โšก **GPU acceleration** auto-detected: NVENC, Vulkan, VA-API, QuickSync, VideoToolbox diff --git a/SECURITY.md b/SECURITY.md index 7aa3ba7..1775169 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,7 +8,7 @@ Only the latest release is supported. Security fixes are released as patch versi **Do not open a public issue for security vulnerabilities.** -[Report vulnerabilities privately](https://github.com/linuxmatters/jivefire/security/advisories/new). Include: +[Report vulnerabilities privately](https://github.com/linuxmatters/jive-visualiser/security/advisories/new). Include: - Steps to reproduce - Affected versions diff --git a/cmd/bench-yuv/main.go b/cmd/bench-yuv/main.go index 0e451e3..7065ead 100644 --- a/cmd/bench-yuv/main.go +++ b/cmd/bench-yuv/main.go @@ -1,4 +1,4 @@ -// bench-yuv is a standalone benchmark for RGBโ†’YUV colour space conversion. +// bench-yuv is a standalone benchmark for RGBAโ†’YUV colour space conversion. // Designed to be called by hyperfine for statistical analysis. // // Usage: @@ -13,7 +13,7 @@ import ( "unsafe" ffmpeg "github.com/linuxmatters/ffmpeg-statigo" - "github.com/linuxmatters/jivefire/internal/yuv" + "github.com/linuxmatters/jive-visualiser/internal/yuv" ) const ( @@ -21,7 +21,10 @@ const ( height = 720 ) -func convertRGBToYUVGo(rgbData []byte, yuvFrame *ffmpeg.AVFrame, width, height int) { +// convertRGBAToYUVGo mirrors the production hot path (convertRGBAToYUV in +// internal/encoder/frame.go): RGBA input converted over a yuv.RowPool. The +// production function is unexported, so the loop is replicated here. +func convertRGBAToYUVGo(pool *yuv.RowPool, rgbaData []byte, yuvFrame *ffmpeg.AVFrame, width int) { yPlane := yuvFrame.Data().Get(0) uPlane := yuvFrame.Data().Get(1) vPlane := yuvFrame.Data().Get(2) @@ -30,7 +33,8 @@ func convertRGBToYUVGo(rgbData []byte, yuvFrame *ffmpeg.AVFrame, width, height i uLinesize := yuvFrame.Linesize().Get(1) vLinesize := yuvFrame.Linesize().Get(2) - yuv.ParallelRows(height, func(startY, endY int) { + pool.Run(func(startY, endY int) { + // Align startY to even for correct UV row calculation evenStart := startY if evenStart&1 != 0 { evenStart++ @@ -42,16 +46,17 @@ func convertRGBToYUVGo(rgbData []byte, yuvFrame *ffmpeg.AVFrame, width, height i uvY := y >> 1 uRowPtr := unsafe.Add(uPlane, uvY*uLinesize) vRowPtr := unsafe.Add(vPlane, uvY*vLinesize) - rgbIdx := y * width * 3 + rgbaIdx := y * width * 4 for x := range width { - r := int32(rgbData[rgbIdx]) - g := int32(rgbData[rgbIdx+1]) - b := int32(rgbData[rgbIdx+2]) - rgbIdx += 3 + r := int32(rgbaData[rgbaIdx]) + g := int32(rgbaData[rgbaIdx+1]) + b := int32(rgbaData[rgbaIdx+2]) + rgbaIdx += 4 // Skip alpha *(*uint8)(unsafe.Add(yPtr, x)) = yuv.RGBToY(r, g, b) + // UV subsampling: every other pixel on even rows if (x & 1) == 0 { uvX := x >> 1 *(*uint8)(unsafe.Add(uRowPtr, uvX)) = yuv.RGBToCb(r, g, b) @@ -60,20 +65,20 @@ func convertRGBToYUVGo(rgbData []byte, yuvFrame *ffmpeg.AVFrame, width, height i } } - // Process odd rows: Y only + // Process odd rows: Y only (no UV) oddStart := startY if oddStart&1 == 0 { oddStart++ } for y := oddStart; y < endY; y += 2 { yPtr := unsafe.Add(yPlane, y*yLinesize) - rgbIdx := y * width * 3 + rgbaIdx := y * width * 4 for x := range width { - r := int32(rgbData[rgbIdx]) - g := int32(rgbData[rgbIdx+1]) - b := int32(rgbData[rgbIdx+2]) - rgbIdx += 3 + r := int32(rgbaData[rgbaIdx]) + g := int32(rgbaData[rgbaIdx+1]) + b := int32(rgbaData[rgbaIdx+2]) + rgbaIdx += 4 // Skip alpha *(*uint8)(unsafe.Add(yPtr, x)) = yuv.RGBToY(r, g, b) } @@ -81,16 +86,16 @@ func convertRGBToYUVGo(rgbData []byte, yuvFrame *ffmpeg.AVFrame, width, height i }) } -func convertSwscale(rgbData []byte, yuvFrame *ffmpeg.AVFrame, swsCtx *ffmpeg.SwsContext, srcFrame *ffmpeg.AVFrame, width, height int) { - // Copy RGB data into source frame +func convertSwscale(rgbaData []byte, yuvFrame *ffmpeg.AVFrame, swsCtx *ffmpeg.SwsContext, srcFrame *ffmpeg.AVFrame, width, height int) { + // Copy RGBA data into source frame srcLinesize := srcFrame.Linesize().Get(0) srcData := srcFrame.Data().Get(0) for y := range height { srcOffset := y * srcLinesize - rgbOffset := y * width * 3 - for x := 0; x < width*3; x++ { - *(*uint8)(unsafe.Add(srcData, srcOffset+x)) = rgbData[rgbOffset+x] + rgbaOffset := y * width * 4 + for x := 0; x < width*4; x++ { + *(*uint8)(unsafe.Add(srcData, srcOffset+x)) = rgbaData[rgbaOffset+x] } } @@ -107,12 +112,13 @@ func main() { os.Exit(1) } - rgbSize := width * height * 3 - rgbData := make([]byte, rgbSize) - for i := 0; i < rgbSize; i += 3 { - rgbData[i] = uint8(i % 256) - rgbData[i+1] = uint8(i % 128) - rgbData[i+2] = uint8(i % 64) + rgbaSize := width * height * 4 + rgbaData := make([]byte, rgbaSize) + for i := 0; i < rgbaSize; i += 4 { + rgbaData[i] = uint8(i % 256) // R + rgbaData[i+1] = uint8(i % 128) // G + rgbaData[i+2] = uint8(i % 64) // B + rgbaData[i+3] = 255 // A } yuvFrame := ffmpeg.AVFrameAlloc() @@ -124,14 +130,17 @@ func main() { switch *impl { case "go": + pool := yuv.NewRowPool(height) + defer pool.Close() + for i := 0; i < *iterations; i++ { - convertRGBToYUVGo(rgbData, yuvFrame, width, height) + convertRGBAToYUVGo(pool, rgbaData, yuvFrame, width) } case "swscale": swsCtx := ffmpeg.SwsAllocContext() swsCtx.SetSrcW(width) swsCtx.SetSrcH(height) - swsCtx.SetSrcFormat(int(ffmpeg.AVPixFmtRgb24)) + swsCtx.SetSrcFormat(int(ffmpeg.AVPixFmtRgba)) swsCtx.SetDstW(width) swsCtx.SetDstH(height) swsCtx.SetDstFormat(int(ffmpeg.AVPixFmtYuv420P)) @@ -142,12 +151,12 @@ func main() { srcFrame := ffmpeg.AVFrameAlloc() srcFrame.SetWidth(width) srcFrame.SetHeight(height) - srcFrame.SetFormat(int(ffmpeg.AVPixFmtRgb24)) + srcFrame.SetFormat(int(ffmpeg.AVPixFmtRgba)) _, _ = ffmpeg.AVFrameGetBuffer(srcFrame, 0) defer ffmpeg.AVFrameFree(&srcFrame) for i := 0; i < *iterations; i++ { - convertSwscale(rgbData, yuvFrame, swsCtx, srcFrame, width, height) + convertSwscale(rgbaData, yuvFrame, swsCtx, srcFrame, width, height) } } } diff --git a/cmd/jive-visualiser/main.go b/cmd/jive-visualiser/main.go index 3d212ef..3732c57 100644 --- a/cmd/jive-visualiser/main.go +++ b/cmd/jive-visualiser/main.go @@ -13,12 +13,12 @@ import ( tea "charm.land/bubbletea/v2" "github.com/alecthomas/kong" "github.com/charmbracelet/harmonica" - "github.com/linuxmatters/jivefire/internal/audio" - "github.com/linuxmatters/jivefire/internal/cli" - "github.com/linuxmatters/jivefire/internal/config" - "github.com/linuxmatters/jivefire/internal/encoder" - "github.com/linuxmatters/jivefire/internal/renderer" - "github.com/linuxmatters/jivefire/internal/ui" + "github.com/linuxmatters/jive-visualiser/internal/audio" + "github.com/linuxmatters/jive-visualiser/internal/cli" + "github.com/linuxmatters/jive-visualiser/internal/config" + "github.com/linuxmatters/jive-visualiser/internal/encoder" + "github.com/linuxmatters/jive-visualiser/internal/renderer" + "github.com/linuxmatters/jive-visualiser/internal/ui" ) // version is set via ldflags at build time: "dev" for local builds, the git tag @@ -273,6 +273,10 @@ func generateVideo(inputFile string, outputFile string, channels int, noPreview for _, w := range m.AssetWarnings() { cli.PrintWarning(w) } + if renderErr := m.RenderError(); renderErr != nil { + cli.PrintError(renderErr.Error()) + os.Exit(1) + } if summary := m.CompletionSummary(); summary != "" { fmt.Println(summary) } @@ -310,15 +314,50 @@ func expandMonoToStereo(dst []float32, src []float64, n int) { } } +// convertAndWriteAudio converts n mono float64 samples from src to float32 via +// the pre-allocated buffers (duplicating each sample into interleaved L,R +// pairs when stereo) and writes them with write. When stereo, stereoBuf must +// hold at least 2*n elements (monoBuf is unused and may be nil); otherwise +// monoBuf must hold at least n (stereoBuf is unused and may be nil). +func convertAndWriteAudio(write func([]float32) error, src []float64, n int, stereo bool, monoBuf, stereoBuf []float32) error { + if stereo { + expandMonoToStereo(stereoBuf, src, n) + return write(stereoBuf[:n*2]) + } + for i := range n { + monoBuf[i] = float32(src[i]) + } + return write(monoBuf[:n]) +} + +// writeAudioPrefill converts and writes every one of the n prefill samples; +// truncating to samplesPerFrame here would silently drop audio from the start +// of the stream. +func writeAudioPrefill(write func([]float32) error, fftBuffer []float64, n int, stereo bool, monoBuf, stereoBuf []float32) error { + return convertAndWriteAudio(write, fftBuffer, n, stereo, monoBuf, stereoBuf) +} + +// audioConvBufLen returns the length the audio conversion buffers need: they +// must hold the whole FFT prefill (config.FFTSize samples), which exceeds +// samplesPerFrame at common sample rates; at high rates samplesPerFrame is +// the larger of the two. +func audioConvBufLen(samplesPerFrame int) int { + return max(samplesPerFrame, config.FFTSize) +} + // runPass2 collects any non-fatal warnings during rendering (e.g. an asset that // failed to load and was dropped) and delivers them on the RenderComplete // message so the caller can print them after the Bubbletea alt screen exits. func runPass2(p *tea.Program, profile *audio.Profile, cfg pass2Config) { var warnings []string + // fail delivers a fatal Pass 2 error on the RenderComplete message so the + // caller can print it (and exit non-zero) after the alt screen closes. + fail := func(err error) { + p.Send(ui.RenderComplete{Err: err, AssetWarnings: warnings}) + } reader, err := audio.NewStreamingReader(cfg.inputFile) if err != nil { - cli.PrintError(fmt.Sprintf("opening audio stream: %v", err)) - p.Quit() + fail(fmt.Errorf("opening audio stream: %w", err)) return } defer reader.Close() @@ -333,14 +372,12 @@ func runPass2(p *tea.Program, profile *audio.Profile, cfg pass2Config) { HWAccel: cfg.hwAccel, }) if err != nil { - cli.PrintError(fmt.Sprintf("creating encoder: %v", err)) - p.Quit() + fail(fmt.Errorf("creating encoder: %w", err)) return } if err = enc.Initialize(); err != nil { - cli.PrintError(fmt.Sprintf("initialising encoder: %v", err)) - p.Quit() + fail(fmt.Errorf("initialising encoder: %w", err)) return } @@ -371,8 +408,7 @@ func runPass2(p *tea.Program, profile *audio.Profile, cfg pass2Config) { processor, err := audio.NewProcessor() if err != nil { - cli.PrintError(fmt.Sprintf("creating FFT processor: %v", err)) - p.Quit() + fail(fmt.Errorf("creating FFT processor: %w", err)) return } defer processor.Close() @@ -445,49 +481,39 @@ func runPass2(p *tea.Program, profile *audio.Profile, cfg pass2Config) { samplesPerFrame := reader.SampleRate() / config.FPS fftBuffer := make([]float64, config.FFTSize) - // Pre-allocate reusable buffers for audio processing (avoid per-frame allocations) + // Pre-allocate reusable buffers for audio processing (avoid per-frame + // allocations). See audioConvBufLen for the sizing rationale. + convBufLen := audioConvBufLen(samplesPerFrame) newSamples := make([]float64, samplesPerFrame) - audioSamples := make([]float32, samplesPerFrame) + audioSamples := make([]float32, convBufLen) // The reader downmixes to mono. For stereo output the encoder expects // interleaved L,R pairs, so duplicate each mono sample into both channels via // this pre-allocated buffer (no per-frame allocation). stereo := cfg.channels == 2 var stereoSamples []float32 if stereo { - stereoSamples = make([]float32, samplesPerFrame*2) + stereoSamples = make([]float32, convBufLen*2) } // Pre-fill buffer with first chunk n, err := audio.FillFFTBuffer(reader, fftBuffer) if err != nil { - cli.PrintError(fmt.Sprintf("error reading initial audio chunk: %v", err)) - p.Quit() + fail(fmt.Errorf("reading initial audio chunk: %w", err)) return } if n == 0 { - cli.PrintError("no audio data available") - p.Quit() + fail(errors.New("no audio data available")) return } - // Write initial audio samples to encoder (first samplesPerFrame worth). - // This corresponds to the audio for frame 0. Reuse the audioSamples buffer: - // WriteAudioSamples copies into the FIFO and retains no reference, and the - // buffer is overwritten before each later use in the render loop. - initialCount := min(samplesPerFrame, n) - var initialErr error - if stereo { - expandMonoToStereo(stereoSamples, fftBuffer, initialCount) - initialErr = enc.WriteAudioSamples(stereoSamples[:initialCount*2]) - } else { - for i := range initialCount { - audioSamples[i] = float32(fftBuffer[i]) - } - initialErr = enc.WriteAudioSamples(audioSamples[:initialCount]) - } - if initialErr != nil { - cli.PrintError(fmt.Sprintf("error writing initial audio: %v", initialErr)) - p.Quit() + // Write the whole prefill to the encoder. FillFFTBuffer consumed n samples + // from the reader, so all n must reach the encoder or that audio is lost + // (truncating to samplesPerFrame dropped ~13 ms at 44.1 kHz). The FIFO in + // the encoder absorbs the surplus beyond frame 0. Reuse the conversion + // buffers: WriteAudioSamples copies into the FIFO and retains no reference, + // and the buffers are overwritten before each later use in the render loop. + if err := writeAudioPrefill(enc.WriteAudioSamples, fftBuffer, n, stereo, audioSamples, stereoSamples); err != nil { + fail(fmt.Errorf("writing initial audio: %w", err)) return } @@ -580,8 +606,7 @@ func runPass2(p *tea.Program, profile *audio.Profile, cfg pass2Config) { t0 = time.Now() img := frame.GetImage() if err := enc.WriteFrameRGBA(img.Pix); err != nil { - cli.PrintError(fmt.Sprintf("error encoding frame %d: %v", frameNum, err)) - p.Quit() + fail(fmt.Errorf("encoding frame %d: %w", frameNum, err)) return } totalEncode += time.Since(t0) @@ -635,52 +660,32 @@ func runPass2(p *tea.Program, profile *audio.Profile, cfg pass2Config) { totalAudio += time.Since(t0) break } - cli.PrintError(fmt.Sprintf("error reading audio: %v", readErr)) - p.Quit() + fail(fmt.Errorf("reading audio: %w", readErr)) return } // Convert this frame's float64 samples to float32 for the AAC encoder via // the pre-allocated buffers, sliced to the actual length. For stereo the // mono signal is duplicated into both interleaved channels. - var writeErr error - if stereo { - expandMonoToStereo(stereoSamples, newSamples, nRead) - writeErr = enc.WriteAudioSamples(stereoSamples[:nRead*2]) - } else { - for i := range nRead { - audioSamples[i] = float32(newSamples[i]) - } - writeErr = enc.WriteAudioSamples(audioSamples[:nRead]) - } - if writeErr != nil { - cli.PrintError(fmt.Sprintf("error writing audio at frame %d: %v", frameNum, writeErr)) - p.Quit() + if writeErr := convertAndWriteAudio(enc.WriteAudioSamples, newSamples, nRead, stereo, audioSamples, stereoSamples); writeErr != nil { + fail(fmt.Errorf("writing audio at frame %d: %w", frameNum, writeErr)) return } - // Shift the buffer left by samplesPerFrame and append the new samples, - // zero-padding a short final read so stale samples never feed the FFT. - copy(fftBuffer, fftBuffer[samplesPerFrame:]) - if nRead < samplesPerFrame { - copy(fftBuffer[config.FFTSize-samplesPerFrame:], newSamples[:nRead]) - clear(fftBuffer[config.FFTSize-samplesPerFrame+nRead:]) - } else { - copy(fftBuffer[config.FFTSize-samplesPerFrame:], newSamples[:nRead]) - } + audio.SlideFFTWindow(fftBuffer, newSamples, nRead) totalAudio += time.Since(t0) // === AUDIO TIMING END === } // Flush samples still in the FIFO after the last video frame is written. if err := enc.FlushAudioEncoder(); err != nil { - cli.PrintError(fmt.Sprintf("error flushing audio: %v", err)) - p.Quit() + fail(fmt.Errorf("flushing audio: %w", err)) return } + // A Close failure is fatal: the trailer never lands and the file is + // truncated. if err := enc.Close(); err != nil { - cli.PrintError(fmt.Sprintf("error closing encoder: %v", err)) - p.Quit() + fail(fmt.Errorf("closing encoder: %w", err)) return } diff --git a/cmd/jive-visualiser/main_test.go b/cmd/jive-visualiser/main_test.go new file mode 100644 index 0000000..68ead27 --- /dev/null +++ b/cmd/jive-visualiser/main_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "testing" + + "github.com/linuxmatters/jive-visualiser/internal/config" +) + +// TestPrefillWritesWholeBuffer asserts the whole FFT prefill (all n samples +// FillFFTBuffer returned) reaches the encoder, not just one frame's worth. +// Truncating to samplesPerFrame dropped ~13 ms of audio at 44.1 kHz. It pins +// writeAudioPrefill, the function the runPass2 call site uses, so a +// regression at that seam fails the suite. +func TestPrefillWritesWholeBuffer(t *testing.T) { + // 44.1 kHz: samplesPerFrame (1470) is smaller than the FFT prefill, the + // case where truncation loses audio. + convBufLen := audioConvBufLen(44100 / config.FPS) + + src := make([]float64, config.FFTSize) + for i := range src { + src[i] = float64(i) / float64(len(src)) + } + + cases := []struct { + name string + stereo bool + want int + }{ + {"mono", false, config.FFTSize}, + {"stereo", true, config.FFTSize * 2}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var got int + var gotLast float32 + write := func(s []float32) error { + got = len(s) + gotLast = s[len(s)-1] + return nil + } + monoBuf := make([]float32, convBufLen) + stereoBuf := make([]float32, convBufLen*2) + if err := writeAudioPrefill(write, src, config.FFTSize, tc.stereo, monoBuf, stereoBuf); err != nil { + t.Fatalf("writeAudioPrefill: %v", err) + } + if got != tc.want { + t.Errorf("prefill wrote %d samples, want %d", got, tc.want) + } + if wantLast := float32(src[config.FFTSize-1]); gotLast != wantLast { + t.Errorf("last written sample = %v, want %v", gotLast, wantLast) + } + }) + } +} + +// TestAudioConvBufLen pins the conversion buffer sizing: the buffers must +// hold the whole FFT prefill, and grow with samplesPerFrame when that is the +// larger of the two. +func TestAudioConvBufLen(t *testing.T) { + // 44.1 kHz: samplesPerFrame is 1470, below FFTSize. + if got := audioConvBufLen(1470); got < config.FFTSize { + t.Errorf("audioConvBufLen(1470) = %d, want at least %d", got, config.FFTSize) + } + // High sample rate: samplesPerFrame exceeds FFTSize. + if got := audioConvBufLen(3200); got != 3200 { + t.Errorf("audioConvBufLen(3200) = %d, want 3200", got) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 905438e..5e82444 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -51,9 +51,9 @@ FFmpeg Decoder (ffmpeg-statigo, streaming) โ”œโ”€ libavcodec for decoding โ””โ”€ Automatic stereoโ†’mono downmix โ†“ -FFT Analysis (gonum/fourier) +FFT Analysis (FFmpeg av_tx RDFT via ffmpeg-statigo, internal/audio/fft.go) โ”œโ”€ 2048-point Hanning window - โ”œโ”€ Log-scale frequency binning โ†’ 64 bars + โ”œโ”€ Linear frequency binning โ†’ 64 bars (binsPerBar = maxFreqBin / NumBars; log scaling applies to amplitude only) โ””โ”€ Harmonica spring peak-hold dynamics (bars snap up, spring back down) โ†“ Frame Renderer (image/draw + custom optimizations) @@ -171,5 +171,3 @@ There's currently no pure Go library offering parallelised colourspace conversio - WebRTC/streaming applications with real-time constraints The FIFO buffer implementation is generic enough for any audio frame size mismatch scenario in Go audio processing pipelines. - -FFT bar binning logic mirrors CAVA's approach, making it familiar territory for anyone who's worked with terminal audio visualisers. diff --git a/go.mod b/go.mod index 8eff6af..3cd1401 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/linuxmatters/jivefire +module github.com/linuxmatters/jive-visualiser go 1.26 diff --git a/internal/audio/analyzer.go b/internal/audio/analyzer.go index a1ddc0d..0bb5c7e 100644 --- a/internal/audio/analyzer.go +++ b/internal/audio/analyzer.go @@ -7,7 +7,7 @@ import ( "math" "time" - "github.com/linuxmatters/jivefire/internal/config" + "github.com/linuxmatters/jive-visualiser/internal/config" ) // FrameAnalysis holds statistics for a single frame. @@ -119,12 +119,7 @@ func AnalyzeAudio(filename string, progressCb ProgressCallback) (*Profile, error return nil, fmt.Errorf("error reading audio at frame %d: %w", frameNum, err) } - // Shift buffer left by samplesPerFrame, append new samples. - copy(fftBuffer, fftBuffer[samplesPerFrame:]) - copy(fftBuffer[config.FFTSize-samplesPerFrame:], frameBuf[:nRead]) - if nRead < samplesPerFrame { - clear(fftBuffer[config.FFTSize-samplesPerFrame+nRead:]) - } + SlideFFTWindow(fftBuffer, frameBuf, nRead) } // Duration tracks the number of frames advanced, not total samples read; each diff --git a/internal/audio/analyzer_test.go b/internal/audio/analyzer_test.go index 7beaf21..1efca91 100644 --- a/internal/audio/analyzer_test.go +++ b/internal/audio/analyzer_test.go @@ -1,12 +1,89 @@ package audio import ( + "bytes" + "encoding/binary" "math" + "os" + "path/filepath" "testing" - "github.com/linuxmatters/jivefire/internal/config" + "github.com/linuxmatters/jive-visualiser/internal/config" ) +// Sine fixture parameters. The frequency sits exactly on FFT bin 20 of a +// 2048-point transform at 44.1 kHz, so every analysis window sees an +// identical spectrum and the measured values below are deterministic. +const ( + sineAmplitude = 0.5 + sineFrequency = 20.0 * float64(config.SampleRate) / float64(config.FFTSize) + sineSeconds = 1 +) + +// Pre-computed fixture expectations for the sine WAV. These are literal +// values recorded from one reference run of the analysis pipeline, plus the +// analytical RMS of a 0.5-amplitude sine (0.5/sqrt(2)). They are NOT derived +// from the code's formulas at test time, so a formula change in the analyser +// (for example the 0.85 headroom constant) fails these tests. +const ( + sineExpectedPeak = 41.6907 // measured raw bar magnitude + sineExpectedRMS = 0.35355 // 0.5 / sqrt(2), analytical + sineExpectedBaseScale = 0.020388 // 0.85 headroom / 41.6907 peak, pre-computed + sineExpectedDynamicRange = 118.55 // 41.6907 peak / 0.351687 measured RMS, pre-computed +) + +// writeSineWAV writes a mono 16-bit PCM WAV containing the fixture sine and +// returns its path. +func writeSineWAV(t *testing.T) string { + t.Helper() + + // Constant expression, so the uint32 conversions below are checked at + // compile time. + const numSamples = config.SampleRate * sineSeconds + const dataSize = uint32(numSamples * 2) + samples := make([]int16, numSamples) + for i := range samples { + v := sineAmplitude * math.Sin(2*math.Pi*sineFrequency*float64(i)/float64(config.SampleRate)) + samples[i] = int16(v * 32767) + } + + var buf bytes.Buffer + write := func(v any) { + if err := binary.Write(&buf, binary.LittleEndian, v); err != nil { + t.Fatalf("writing WAV field: %v", err) + } + } + buf.WriteString("RIFF") + write(36 + dataSize) + buf.WriteString("WAVEfmt ") + write(uint32(16)) // fmt chunk size + write(uint16(1)) // PCM + write(uint16(1)) // mono + write(uint32(config.SampleRate)) // sample rate + write(uint32(config.SampleRate * 2)) // byte rate + write(uint16(2)) // block align + write(uint16(16)) // bits per sample + buf.WriteString("data") + write(dataSize) + write(samples) + + path := filepath.Join(t.TempDir(), "sine.wav") + if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { + t.Fatalf("writing sine fixture: %v", err) + } + return path +} + +// analyzeSineFixture runs the analyser over the deterministic sine fixture. +func analyzeSineFixture(t *testing.T) *Profile { + t.Helper() + profile, err := AnalyzeAudio(writeSineWAV(t), nil) + if err != nil { + t.Fatalf("Failed to analyse sine fixture: %v", err) + } + return profile +} + func mustAnalyze(t *testing.T) *Profile { t.Helper() profile, err := AnalyzeAudio("../../testdata/LMP0.mp3", nil) @@ -64,43 +141,50 @@ func TestAnalyzeAudioInvalidFile(t *testing.T) { } func TestOptimalBaseScaleCalculation(t *testing.T) { - profile := mustAnalyze(t) + profile := analyzeSineFixture(t) - // Optimal baseScale should be calculated as: 0.85 / GlobalPeak - expectedBaseScale := 0.85 / profile.GlobalPeak - - if profile.OptimalBaseScale != expectedBaseScale { - t.Errorf("OptimalBaseScale mismatch: expected %.6f, got %.6f", - expectedBaseScale, profile.OptimalBaseScale) + // The fixture spectrum is deterministic, so the global peak must match the + // recorded reference value. + if !withinRelative(profile.GlobalPeak, sineExpectedPeak, 0.002) { + t.Errorf("GlobalPeak mismatch: expected ~%.6f, got %.6f", + sineExpectedPeak, profile.GlobalPeak) } - // When multiplied by GlobalPeak and sensitivity 1.0, should give ~0.85 - testValue := profile.GlobalPeak * profile.OptimalBaseScale * 1.0 - if testValue < 0.84 || testValue > 0.86 { - t.Errorf("OptimalBaseScale validation failed: GlobalPeak * OptimalBaseScale = %.6f (expected ~0.85)", testValue) + // Assert against the pre-computed literal, not a formula recomputed here. + if !withinRelative(profile.OptimalBaseScale, sineExpectedBaseScale, 0.002) { + t.Errorf("OptimalBaseScale mismatch: expected ~%.6f, got %.6f", + sineExpectedBaseScale, profile.OptimalBaseScale) } - t.Logf("OptimalBaseScale correctly calculated: %.6f", profile.OptimalBaseScale) - t.Logf("Verification: GlobalPeak (%.6f) ร— OptimalBaseScale (%.6f) = %.6f", - profile.GlobalPeak, profile.OptimalBaseScale, testValue) + t.Logf("GlobalPeak: %.6f, OptimalBaseScale: %.6f", + profile.GlobalPeak, profile.OptimalBaseScale) } func TestDynamicRangeCalculation(t *testing.T) { - profile := mustAnalyze(t) + profile := analyzeSineFixture(t) - expectedDynamicRange := profile.GlobalPeak / profile.GlobalRMS + // A 0.5-amplitude sine has RMS 0.5/sqrt(2) ~= 0.35355. The last few frames + // include zero padding at end of file, hence the loose tolerance. + if !withinRelative(profile.GlobalRMS, sineExpectedRMS, 0.01) { + t.Errorf("GlobalRMS mismatch: expected ~%.5f, got %.6f", + sineExpectedRMS, profile.GlobalRMS) + } - // Allow small floating point error - diff := profile.DynamicRange - expectedDynamicRange - if diff < -0.01 || diff > 0.01 { - t.Errorf("DynamicRange (%.2f) doesn't match GlobalPeak/GlobalRMS (%.2f)", - profile.DynamicRange, expectedDynamicRange) + // Assert against the pre-computed literal, not a formula recomputed here. + if !withinRelative(profile.DynamicRange, sineExpectedDynamicRange, 0.01) { + t.Errorf("DynamicRange mismatch: expected ~%.4f, got %.4f", + sineExpectedDynamicRange, profile.DynamicRange) } - t.Logf("DynamicRange correctly calculated: %.2f (Peak %.6f / RMS %.6f)", + t.Logf("DynamicRange: %.4f (Peak %.6f / RMS %.6f)", profile.DynamicRange, profile.GlobalPeak, profile.GlobalRMS) } +// withinRelative reports whether got is within tol (relative) of want. +func withinRelative(got, want, tol float64) bool { + return math.Abs(got-want) <= tol*math.Abs(want) +} + func TestAnalyzeFrameDirectly(t *testing.T) { // 440 Hz sine wave at 0.5 amplitude. testSamples := make([]float64, config.FFTSize) @@ -112,6 +196,7 @@ func TestAnalyzeFrameDirectly(t *testing.T) { if err != nil { t.Fatal(err) } + defer processor.Close() coeffs := processor.ProcessChunk(testSamples) analysis := analyzeFrame(coeffs, testSamples, nil) diff --git a/internal/audio/fft.go b/internal/audio/fft.go index 1e408eb..57027a4 100644 --- a/internal/audio/fft.go +++ b/internal/audio/fft.go @@ -6,7 +6,7 @@ import ( "unsafe" ffmpeg "github.com/linuxmatters/ffmpeg-statigo" - "github.com/linuxmatters/jivefire/internal/config" + "github.com/linuxmatters/jive-visualiser/internal/config" ) // avComplexFloat mirrors C's AVComplexFloat: two contiguous 32-bit floats with diff --git a/internal/audio/fft_test.go b/internal/audio/fft_test.go index 64649d8..2a8c1e0 100644 --- a/internal/audio/fft_test.go +++ b/internal/audio/fft_test.go @@ -42,6 +42,7 @@ func TestBinFFT_KnownSineWave(t *testing.T) { if err != nil { t.Fatal(err) } + defer processor.Close() fftInput := processor.ProcessChunk(windowSamples) // Bin the FFT results into 64 bars @@ -166,6 +167,7 @@ func TestBinFFT_NoiseGate(t *testing.T) { if err != nil { t.Fatal(err) } + defer processor.Close() fftInput := processor.ProcessChunk(quietSignal) result := make([]float64, numBars) @@ -209,6 +211,7 @@ func TestBinFFT_EnergyDistribution(t *testing.T) { if err != nil { t.Fatal(err) } + defer processor.Close() fftInput := processor.ProcessChunk(signal) result := make([]float64, numBars) @@ -503,6 +506,7 @@ func BenchmarkProcessChunk(b *testing.B) { if err != nil { b.Fatal(err) } + defer processor.Close() // Generate test audio (random-ish data simulating real audio) samples := make([]float64, 2048) diff --git a/internal/audio/window.go b/internal/audio/window.go new file mode 100644 index 0000000..5a3b042 --- /dev/null +++ b/internal/audio/window.go @@ -0,0 +1,39 @@ +package audio + +// SlideFFTWindow advances the FFT window by one video frame of audio. +// fftBuffer holds the FFT window (FFTSize samples) and newSamples holds one +// frame of audio (samplesPerFrame samples), of which nRead are valid. +// +// When a frame holds fewer samples than the FFT window, the window slides: +// the buffer shifts left by samplesPerFrame and the new samples append at +// the end. When a frame holds at least a full window (high sample rates), +// the window is replaced with the most recent FFTSize samples instead, so +// no out-of-range slice of fftBuffer is ever taken. +// +// Short final reads (nRead below the expected count) zero-pad the tail so +// stale samples never feed the FFT. +func SlideFFTWindow(fftBuffer, newSamples []float64, nRead int) { + fftSize := len(fftBuffer) + samplesPerFrame := len(newSamples) + + if samplesPerFrame >= fftSize { + // Replace the window with the last fftSize valid samples. + if nRead < fftSize { + copy(fftBuffer, newSamples[:nRead]) + clear(fftBuffer[nRead:]) + } else { + copy(fftBuffer, newSamples[nRead-fftSize:nRead]) + } + return + } + + // Shift the buffer left by samplesPerFrame and append the new samples, + // zero-padding a short final read so stale samples never feed the FFT. + copy(fftBuffer, fftBuffer[samplesPerFrame:]) + if nRead < samplesPerFrame { + copy(fftBuffer[fftSize-samplesPerFrame:], newSamples[:nRead]) + clear(fftBuffer[fftSize-samplesPerFrame+nRead:]) + } else { + copy(fftBuffer[fftSize-samplesPerFrame:], newSamples[:nRead]) + } +} diff --git a/internal/audio/window_test.go b/internal/audio/window_test.go new file mode 100644 index 0000000..da74a5a --- /dev/null +++ b/internal/audio/window_test.go @@ -0,0 +1,62 @@ +package audio + +import ( + "slices" + "testing" +) + +// ramp returns [start, start+1, ..., start+n-1] as float64s. +func ramp(start, n int) []float64 { + s := make([]float64, n) + for i := range s { + s[i] = float64(start + i) + } + return s +} + +func TestSlideFFTWindow(t *testing.T) { + t.Run("normal slide", func(t *testing.T) { + fftBuffer := ramp(0, 8) + newSamples := ramp(100, 3) + SlideFFTWindow(fftBuffer, newSamples, 3) + want := []float64{3, 4, 5, 6, 7, 100, 101, 102} + if !slices.Equal(fftBuffer, want) { + t.Errorf("got %v, want %v", fftBuffer, want) + } + }) + + t.Run("short read zero-pads tail", func(t *testing.T) { + fftBuffer := ramp(0, 8) + newSamples := ramp(100, 3) + SlideFFTWindow(fftBuffer, newSamples, 1) + want := []float64{3, 4, 5, 6, 7, 100, 0, 0} + if !slices.Equal(fftBuffer, want) { + t.Errorf("got %v, want %v", fftBuffer, want) + } + }) + + t.Run("samplesPerFrame >= FFTSize", func(t *testing.T) { + // 96 kHz at 30 FPS gives 3200 samples per frame with FFTSize 2048. + const fftSize, samplesPerFrame = 2048, 3200 + + fftBuffer := make([]float64, fftSize) + newSamples := ramp(0, samplesPerFrame) + SlideFFTWindow(fftBuffer, newSamples, samplesPerFrame) + want := ramp(samplesPerFrame-fftSize, fftSize) + if !slices.Equal(fftBuffer, want) { + t.Errorf("full read: window is not the last %d samples", fftSize) + } + + // Short read below fftSize zero-pads the tail. + nRead := 100 + SlideFFTWindow(fftBuffer, newSamples, nRead) + if !slices.Equal(fftBuffer[:nRead], newSamples[:nRead]) { + t.Errorf("short read: head does not match new samples") + } + for i := nRead; i < fftSize; i++ { + if fftBuffer[i] != 0 { + t.Fatalf("short read: fftBuffer[%d] = %v, want 0", i, fftBuffer[i]) + } + } + }) +} diff --git a/internal/cli/help.go b/internal/cli/help.go index f915897..1ef48a0 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -7,7 +7,7 @@ import ( "charm.land/lipgloss/v2" "charm.land/lipgloss/v2/table" "github.com/alecthomas/kong" - "github.com/linuxmatters/jivefire/internal/theme" + "github.com/linuxmatters/jive-visualiser/internal/theme" ) // Custom help styles - fire theme diff --git a/internal/cli/styles.go b/internal/cli/styles.go index 0372a4c..7d642e6 100644 --- a/internal/cli/styles.go +++ b/internal/cli/styles.go @@ -5,7 +5,7 @@ import ( "os" "charm.land/lipgloss/v2" - "github.com/linuxmatters/jivefire/internal/theme" + "github.com/linuxmatters/jive-visualiser/internal/theme" ) // Styles diff --git a/internal/encoder/encoder.go b/internal/encoder/encoder.go index 8735842..772bc89 100644 --- a/internal/encoder/encoder.go +++ b/internal/encoder/encoder.go @@ -8,7 +8,7 @@ import ( "unsafe" ffmpeg "github.com/linuxmatters/ffmpeg-statigo" - "github.com/linuxmatters/jivefire/internal/yuv" + "github.com/linuxmatters/jive-visualiser/internal/yuv" ) // checkFFmpeg provides consistent error handling for FFmpeg API calls. @@ -356,6 +356,21 @@ func (e *Encoder) Initialize() (err error) { e.videoStream.SetTimeBase(timeBase) + // internal/yuv converts with full-range BT.601 JFIF coefficients; untagged + // streams decode as limited-range BT.709, crushing blacks. Tag the stream + // before AVCodecOpen2 (so libx264 writes the VUI) and before + // AVCodecParametersFromContext (so codecpar carries the tags into the MP4). + // These tags are verified correct for the CPU-converted paths (software + // YUV420P and NV12 hardware upload, both via internal/yuv). The NVENC + // RGBA-direct path (writeFrameRGBADirect) converts on the GPU with an + // unverified matrix/range; no NVENC hardware was available to test. If + // NVENC output shows shifted colours, align the GPU conversion with these + // tags rather than changing the tags. + e.videoCodec.SetColorRange(ffmpeg.AVColRangeJpeg) + e.videoCodec.SetColorspace(ffmpeg.AVColSpcSmpte170M) + e.videoCodec.SetColorPrimaries(ffmpeg.AVColPriSmpte170M) + e.videoCodec.SetColorTrc(ffmpeg.AVColTrcSmpte170M) + var opts *ffmpeg.AVDictionary defer ffmpeg.AVDictFree(&opts) @@ -743,8 +758,9 @@ func (e *Encoder) writeFrameRGBASoftware(rgbaData []byte) error { // Use pre-allocated YUV frame (configured in configurePixelFormat). // Make writable as the encoder may still hold a reference from the previous frame. yuvFrame := e.swYUVFrame - if ret, err := ffmpeg.AVFrameMakeWritable(yuvFrame); err != nil { - return checkFFmpeg(ret, err, "make YUV frame writable") + ret, err := ffmpeg.AVFrameMakeWritable(yuvFrame) + if err := checkFFmpeg(ret, err, "make YUV frame writable"); err != nil { + return err } // Convert RGBA directly to YUV420P (skips RGB24 intermediate) @@ -755,7 +771,7 @@ func (e *Encoder) writeFrameRGBASoftware(rgbaData []byte) error { e.nextVideoPts++ // Send frame to encoder - ret, err := ffmpeg.AVCodecSendFrame(e.videoCodec, yuvFrame) + ret, err = ffmpeg.AVCodecSendFrame(e.videoCodec, yuvFrame) if err := checkFFmpeg(ret, err, "send frame to encoder"); err != nil { return err } @@ -770,8 +786,9 @@ func (e *Encoder) writeFrameRGBADirect(rgbaData []byte) error { // Use pre-allocated RGBA frame (configured in configurePixelFormat). // Make writable as the encoder may still hold a reference from the previous frame. rgbaFrame := e.rgbaFrame - if ret, err := ffmpeg.AVFrameMakeWritable(rgbaFrame); err != nil { - return checkFFmpeg(ret, err, "make RGBA frame writable") + ret, err := ffmpeg.AVFrameMakeWritable(rgbaFrame) + if err := checkFFmpeg(ret, err, "make RGBA frame writable"); err != nil { + return err } // Copy RGBA data to frame @@ -794,7 +811,7 @@ func (e *Encoder) writeFrameRGBADirect(rgbaData []byte) error { e.nextVideoPts++ // Send frame to encoder - ret, err := ffmpeg.AVCodecSendFrame(e.videoCodec, rgbaFrame) + ret, err = ffmpeg.AVCodecSendFrame(e.videoCodec, rgbaFrame) if err := checkFFmpeg(ret, err, "send frame to encoder"); err != nil { return err } @@ -806,7 +823,7 @@ func (e *Encoder) writeFrameRGBADirect(rgbaData []byte) error { // writeFrameHWUpload converts RGBA to NV12, uploads to GPU, and encodes // Pipeline: RGBA (CPU) โ†’ parallel Go conversion โ†’ NV12 (CPU) โ†’ AVHWFrameTransferData โ†’ GPU โ†’ encode // Used by Vulkan (h264_vulkan) and QSV (h264_qsv) encoders -// Uses pre-allocated reusable NV12 frame and parallel Go conversion (8.4ร— faster than SwsScaleFrame) +// Uses pre-allocated reusable NV12 frame and parallel Go conversion (13.2ร— faster than SwsScaleFrame) func (e *Encoder) writeFrameHWUpload(rgbaData []byte) error { width := e.config.Width @@ -1079,34 +1096,58 @@ func writeStereoFloats(frame *ffmpeg.AVFrame, samples []float32) error { } // Close finalizes the output file and frees resources. +// Finalisation failures (flush, drain, trailer) are collected and returned as +// a joined error; resource freeing continues regardless. A second call is a +// no-op returning nil because every field is nilled on the first call. func (e *Encoder) Close() error { - // Flush the video encoder before writing the trailer. + var errs []error + + // Flush the video encoder before writing the trailer. A failed flush + // truncates the output, so report it. if e.videoCodec != nil && e.pkt != nil { - _, _ = ffmpeg.AVCodecSendFrame(e.videoCodec, nil) + ret, err := ffmpeg.AVCodecSendFrame(e.videoCodec, nil) + if err := checkFFmpeg(ret, err, "flush video encoder"); err != nil { + errs = append(errs, err) + } // Drain remaining packets, reusing the shared e.pkt (freed once below). + // Break on any error other than EOF/EAGAIN so a persistent receive + // error cannot spin this loop forever. pkt := e.pkt for { ret, err := ffmpeg.AVCodecReceivePacket(e.videoCodec, pkt) - if errors.Is(err, ffmpeg.AVErrorEOF) || errors.Is(err, ffmpeg.EAgain) { break } + if err := checkFFmpeg(ret, err, "receive flushed packet"); err != nil { + errs = append(errs, err) + break + } - if ret >= 0 { - pkt.SetStreamIndex(e.videoStream.Index()) - ffmpeg.AVPacketRescaleTs(pkt, e.videoCodec.TimeBase(), e.videoStream.TimeBase()) - _, _ = ffmpeg.AVInterleavedWriteFrame(e.formatCtx, pkt) - ffmpeg.AVPacketUnref(pkt) + pkt.SetStreamIndex(e.videoStream.Index()) + ffmpeg.AVPacketRescaleTs(pkt, e.videoCodec.TimeBase(), e.videoStream.TimeBase()) + ret, err = ffmpeg.AVInterleavedWriteFrame(e.formatCtx, pkt) + ffmpeg.AVPacketUnref(pkt) + if err := checkFFmpeg(ret, err, "write flushed packet"); err != nil { + errs = append(errs, err) + break } } } if e.formatCtx != nil { - _, _ = ffmpeg.AVWriteTrailer(e.formatCtx) + ret, err := ffmpeg.AVWriteTrailer(e.formatCtx) + if err := checkFFmpeg(ret, err, "write trailer"); err != nil { + errs = append(errs, err) + } if e.formatCtx.Pb() != nil { - ffmpeg.AVIOClose(e.formatCtx.Pb()) + // A failed close can drop buffered writes after a successful + // trailer, leaving a truncated file; surface it. + ret, err := ffmpeg.AVIOClose(e.formatCtx.Pb()) + if err := checkFFmpeg(ret, err, "close output file"); err != nil { + errs = append(errs, err) + } } } @@ -1165,5 +1206,5 @@ func (e *Encoder) Close() error { e.formatCtx = nil } - return nil + return errors.Join(errs...) } diff --git a/internal/encoder/frame.go b/internal/encoder/frame.go index 2cbbb36..abe8784 100644 --- a/internal/encoder/frame.go +++ b/internal/encoder/frame.go @@ -7,7 +7,7 @@ import ( "unsafe" ffmpeg "github.com/linuxmatters/ffmpeg-statigo" - "github.com/linuxmatters/jivefire/internal/yuv" + "github.com/linuxmatters/jive-visualiser/internal/yuv" ) // convertRGBAToYUV converts RGBA data directly to YUV420P (planar) format. diff --git a/internal/encoder/hwaccel.go b/internal/encoder/hwaccel.go index 2b731ff..741b062 100644 --- a/internal/encoder/hwaccel.go +++ b/internal/encoder/hwaccel.go @@ -6,7 +6,7 @@ import ( ffmpeg "github.com/linuxmatters/ffmpeg-statigo" - "github.com/linuxmatters/jivefire/internal/config" + "github.com/linuxmatters/jive-visualiser/internal/config" ) // HWAccelType represents a hardware acceleration type diff --git a/internal/encoder/sws_benchmark_test.go b/internal/encoder/sws_benchmark_test.go index 6790882..9adb8be 100644 --- a/internal/encoder/sws_benchmark_test.go +++ b/internal/encoder/sws_benchmark_test.go @@ -1,12 +1,13 @@ package encoder // ============================================================================= -// RGBโ†’YUV420P Colourspace Conversion Benchmark +// RGBAโ†’YUV420P Colourspace Conversion Benchmark // ============================================================================= // -// This benchmark compares two approaches for converting RGB24 frames to YUV420P: +// This benchmark compares two approaches for converting RGBA frames to YUV420P: // // 1. Go Implementation (parallelised) +// - The production hot path: convertRGBAToYUV over a yuv.RowPool // - Uses goroutines to process row groups across CPU cores // - ITU-R BT.601 coefficients matching Go's color package // - Located in encoder/frame.go @@ -19,8 +20,8 @@ package encoder // Run with: just bench-yuv // // Expected results on multi-core systems: -// - Go implementation is ~8ร— faster due to parallelisation -// - swscale has zero allocations but can't leverage multiple cores +// - Go implementation is several times faster due to parallelisation +// - swscale has zero allocations but cannot use multiple cores // // ============================================================================= @@ -31,7 +32,7 @@ import ( "unsafe" ffmpeg "github.com/linuxmatters/ffmpeg-statigo" - "github.com/linuxmatters/jivefire/internal/yuv" + "github.com/linuxmatters/jive-visualiser/internal/yuv" ) const ( @@ -39,43 +40,7 @@ const ( benchHeight = 720 ) -// convertRGBToYUVGo converts RGB24 frames to YUV420P using the production -// yuv-package conversion primitives, so the benchmark measures the current -// hot-path maths rather than a stale copy. -func convertRGBToYUVGo(rgbData []byte, yuvFrame *ffmpeg.AVFrame, width, height int) { - yPlane := yuvFrame.Data().Get(0) - uPlane := yuvFrame.Data().Get(1) - vPlane := yuvFrame.Data().Get(2) - - yLinesize := yuvFrame.Linesize().Get(0) - uLinesize := yuvFrame.Linesize().Get(1) - vLinesize := yuvFrame.Linesize().Get(2) - - yuv.ParallelRows(height, func(startY, endY int) { - for y := startY; y < endY; y++ { - yPtr := unsafe.Add(yPlane, y*yLinesize) - rgbIdx := y * width * 3 - - for x := range width { - r := int32(rgbData[rgbIdx]) - g := int32(rgbData[rgbIdx+1]) - b := int32(rgbData[rgbIdx+2]) - rgbIdx += 3 - - *(*uint8)(unsafe.Add(yPtr, x)) = yuv.RGBToY(r, g, b) - - if (y&1) == 0 && (x&1) == 0 { - uvY := y >> 1 - uvX := x >> 1 - *(*uint8)(unsafe.Add(uPlane, uvY*uLinesize+uvX)) = yuv.RGBToCb(r, g, b) - *(*uint8)(unsafe.Add(vPlane, uvY*vLinesize+uvX)) = yuv.RGBToCr(r, g, b) - } - } - } - }) -} - -// SwsConverter wraps FFmpeg's swscale for RGB24 โ†’ YUV420P conversion +// SwsConverter wraps FFmpeg's swscale for RGBA โ†’ YUV420P conversion type SwsConverter struct { swsCtx *ffmpeg.SwsContext srcFrame *ffmpeg.AVFrame @@ -92,7 +57,7 @@ func NewSwsConverter(width, height int) (*SwsConverter, error) { // Configure the scaler swsCtx.SetSrcW(width) swsCtx.SetSrcH(height) - swsCtx.SetSrcFormat(int(ffmpeg.AVPixFmtRgb24)) + swsCtx.SetSrcFormat(int(ffmpeg.AVPixFmtRgba)) swsCtx.SetDstW(width) swsCtx.SetDstH(height) swsCtx.SetDstFormat(int(ffmpeg.AVPixFmtYuv420P)) @@ -104,14 +69,26 @@ func NewSwsConverter(width, height int) (*SwsConverter, error) { return nil, err } - // Allocate source frame for RGB data + // The Go path uses full-range BT.601 (Go's colour package), but swscale + // defaults to limited-range YUV output. Request full-range output so the + // two implementations differ only by rounding. + invTable, srcRange, table, _, brightness, contrast, saturation, err := ffmpeg.SwsGetColorspaceDetails(swsCtx) + if err != nil { + return nil, err + } + ret, err = ffmpeg.SwsSetColorspaceDetails(swsCtx, invTable, srcRange, table, 1, brightness, contrast, saturation) + if err != nil || ret < 0 { + return nil, err + } + + // Allocate source frame for RGBA data srcFrame := ffmpeg.AVFrameAlloc() if srcFrame == nil { return nil, nil } srcFrame.SetWidth(width) srcFrame.SetHeight(height) - srcFrame.SetFormat(int(ffmpeg.AVPixFmtRgb24)) + srcFrame.SetFormat(int(ffmpeg.AVPixFmtRgba)) ret, err = ffmpeg.AVFrameGetBuffer(srcFrame, 0) if err != nil || ret < 0 { @@ -127,16 +104,16 @@ func NewSwsConverter(width, height int) (*SwsConverter, error) { }, nil } -func (c *SwsConverter) Convert(rgbData []byte, dstFrame *ffmpeg.AVFrame) error { - // Copy RGB data into source frame +func (c *SwsConverter) Convert(rgbaData []byte, dstFrame *ffmpeg.AVFrame) error { + // Copy RGBA data into source frame srcLinesize := c.srcFrame.Linesize().Get(0) srcData := c.srcFrame.Data().Get(0) for y := 0; y < c.height; y++ { srcOffset := y * srcLinesize - rgbOffset := y * c.width * 3 - for x := 0; x < c.width*3; x++ { - *(*uint8)(unsafe.Add(srcData, srcOffset+x)) = rgbData[rgbOffset+x] + rgbaOffset := y * c.width * 4 + for x := 0; x < c.width*4; x++ { + *(*uint8)(unsafe.Add(srcData, srcOffset+x)) = rgbaData[rgbaOffset+x] } } @@ -155,13 +132,14 @@ func (c *SwsConverter) Close() { } func createTestFrames() ([]byte, *ffmpeg.AVFrame) { - // Create RGB test data with some pattern - rgbSize := benchWidth * benchHeight * 3 - rgbData := make([]byte, rgbSize) - for i := 0; i < rgbSize; i += 3 { - rgbData[i] = uint8(i % 256) // R - rgbData[i+1] = uint8(i % 128) // G - rgbData[i+2] = uint8(i % 64) // B + // Create RGBA test data with some pattern + rgbaSize := benchWidth * benchHeight * 4 + rgbaData := make([]byte, rgbaSize) + for i := 0; i < rgbaSize; i += 4 { + rgbaData[i] = uint8(i % 256) // R + rgbaData[i+1] = uint8(i % 128) // G + rgbaData[i+2] = uint8(i % 64) // B + rgbaData[i+3] = 255 // A } // Allocate YUV frame @@ -171,29 +149,33 @@ func createTestFrames() ([]byte, *ffmpeg.AVFrame) { yuvFrame.SetFormat(int(ffmpeg.AVPixFmtYuv420P)) _, _ = ffmpeg.AVFrameGetBuffer(yuvFrame, 0) - return rgbData, yuvFrame + return rgbaData, yuvFrame } // ============================================================================= // Benchmarks // ============================================================================= -// BenchmarkGoRGBToYUV measures the parallelised Go implementation. -// This is the production code path used by Jive Visualiser. -func BenchmarkGoRGBToYUV(b *testing.B) { - rgbData, yuvFrame := createTestFrames() +// BenchmarkGoRGBAToYUV measures the parallelised Go implementation. +// This is the production code path used by Jive Visualiser: convertRGBAToYUV +// over a persistent yuv.RowPool. +func BenchmarkGoRGBAToYUV(b *testing.B) { + rgbaData, yuvFrame := createTestFrames() defer ffmpeg.AVFrameFree(&yuvFrame) + pool := yuv.NewRowPool(benchHeight) + defer pool.Close() + b.ResetTimer() for i := 0; i < b.N; i++ { - convertRGBToYUVGo(rgbData, yuvFrame, benchWidth, benchHeight) + convertRGBAToYUV(pool, rgbaData, yuvFrame, benchWidth) } } -// BenchmarkSwscaleRGBToYUV measures FFmpeg's swscale library. +// BenchmarkSwscaleRGBAToYUV measures FFmpeg's swscale library. // Single-threaded but SIMD-optimised. -func BenchmarkSwscaleRGBToYUV(b *testing.B) { - rgbData, yuvFrame := createTestFrames() +func BenchmarkSwscaleRGBAToYUV(b *testing.B) { + rgbaData, yuvFrame := createTestFrames() defer ffmpeg.AVFrameFree(&yuvFrame) converter, err := NewSwsConverter(benchWidth, benchHeight) @@ -204,37 +186,7 @@ func BenchmarkSwscaleRGBToYUV(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = converter.Convert(rgbData, yuvFrame) - } -} - -// BenchmarkRGBAToYUVDirect measures the direct RGBAโ†’YUV420P conversion. -// This skips the intermediate RGB24 buffer for software encoding path. -func BenchmarkRGBAToYUVDirect(b *testing.B) { - // Create RGBA test data - rgbaSize := benchWidth * benchHeight * 4 - rgbaData := make([]byte, rgbaSize) - for i := 0; i < rgbaSize; i += 4 { - rgbaData[i] = uint8(i % 256) // R - rgbaData[i+1] = uint8(i % 128) // G - rgbaData[i+2] = uint8(i % 64) // B - rgbaData[i+3] = 255 // A - } - - // Allocate YUV frame - yuvFrame := ffmpeg.AVFrameAlloc() - yuvFrame.SetWidth(benchWidth) - yuvFrame.SetHeight(benchHeight) - yuvFrame.SetFormat(int(ffmpeg.AVPixFmtYuv420P)) - _, _ = ffmpeg.AVFrameGetBuffer(yuvFrame, 0) - defer ffmpeg.AVFrameFree(&yuvFrame) - - pool := yuv.NewRowPool(benchHeight) - defer pool.Close() - - b.ResetTimer() - for i := 0; i < b.N; i++ { - convertRGBAToYUV(pool, rgbaData, yuvFrame, benchWidth) + _ = converter.Convert(rgbaData, yuvFrame) } } @@ -242,14 +194,16 @@ func BenchmarkRGBAToYUVDirect(b *testing.B) { // Some pixel differences are expected due to different rounding in coefficient // implementations (Go uses integer arithmetic, FFmpeg uses floating-point). func TestConversionEquivalence(t *testing.T) { - rgbData, yuvFrameGo := createTestFrames() + rgbaData, yuvFrameGo := createTestFrames() defer ffmpeg.AVFrameFree(&yuvFrameGo) _, yuvFrameSws := createTestFrames() defer ffmpeg.AVFrameFree(&yuvFrameSws) - // Convert with Go implementation - convertRGBToYUVGo(rgbData, yuvFrameGo, benchWidth, benchHeight) + // Convert with the production Go implementation + pool := yuv.NewRowPool(benchHeight) + defer pool.Close() + convertRGBAToYUV(pool, rgbaData, yuvFrameGo, benchWidth) // Convert with swscale converter, err := NewSwsConverter(benchWidth, benchHeight) @@ -258,7 +212,7 @@ func TestConversionEquivalence(t *testing.T) { } defer converter.Close() - err = converter.Convert(rgbData, yuvFrameSws) + err = converter.Convert(rgbaData, yuvFrameSws) if err != nil { t.Fatalf("Swscale conversion failed: %v", err) } @@ -268,6 +222,10 @@ func TestConversionEquivalence(t *testing.T) { yPlaneGo := yuvFrameGo.Data().Get(0) yPlaneSws := yuvFrameSws.Data().Get(0) + // BT.601 integer arithmetic vs swscale floating-point permits a + // per-sample difference of at most 1. + const tolerance = 1 + diffCount := 0 maxDiff := 0 for y := range benchHeight { @@ -279,16 +237,23 @@ func TestConversionEquivalence(t *testing.T) { if diff < 0 { diff = -diff } - if diff > 1 { // Allow 1 unit difference for rounding + if diff > maxDiff { + maxDiff = diff + } + if diff > tolerance { diffCount++ - if diff > maxDiff { - maxDiff = diff - } } } } - t.Logf("Y plane differences > 1: %d pixels (max diff: %d)", diffCount, maxDiff) + t.Logf("Y plane differences > %d: %d pixels (max diff: %d)", tolerance, diffCount, maxDiff) + + if maxDiff > tolerance { + t.Errorf("max Y plane difference %d exceeds tolerance %d", maxDiff, tolerance) + } + if diffCount > 0 { + t.Errorf("%d Y plane samples differ by more than %d", diffCount, tolerance) + } } // ============================================================================= @@ -302,13 +267,16 @@ func TestBenchmarkSummary(t *testing.T) { t.Skip("Skipping summary in short mode") } - rgbData, yuvFrame := createTestFrames() + rgbaData, yuvFrame := createTestFrames() defer ffmpeg.AVFrameFree(&yuvFrame) - // Benchmark Go implementation + // Benchmark the production Go implementation + pool := yuv.NewRowPool(benchHeight) + defer pool.Close() + goStart := testing.Benchmark(func(b *testing.B) { for i := 0; i < b.N; i++ { - convertRGBToYUVGo(rgbData, yuvFrame, benchWidth, benchHeight) + convertRGBAToYUV(pool, rgbaData, yuvFrame, benchWidth) } }) @@ -321,7 +289,7 @@ func TestBenchmarkSummary(t *testing.T) { swsStart := testing.Benchmark(func(b *testing.B) { for i := 0; i < b.N; i++ { - _ = converter.Convert(rgbData, yuvFrame) + _ = converter.Convert(rgbaData, yuvFrame) } }) @@ -331,7 +299,7 @@ func TestBenchmarkSummary(t *testing.T) { fmt.Println() fmt.Println("โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ") - fmt.Println("โ”‚ RGBโ†’YUV420P Colourspace Conversion Benchmark โ”‚") + fmt.Println("โ”‚ RGBAโ†’YUV420P Colourspace Conversion Benchmark โ”‚") fmt.Println("โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค") fmt.Printf("โ”‚ Resolution: %dร—%d (%.1f megapixels) โ”‚\n", benchWidth, benchHeight, float64(benchWidth*benchHeight)/1e6) fmt.Printf("โ”‚ CPU cores: %-2d โ”‚\n", runtime.NumCPU()) diff --git a/internal/renderer/assets.go b/internal/renderer/assets.go index b7ae410..8a4b6fb 100644 --- a/internal/renderer/assets.go +++ b/internal/renderer/assets.go @@ -10,7 +10,7 @@ import ( "github.com/golang/freetype" "github.com/golang/freetype/truetype" - "github.com/linuxmatters/jivefire/internal/config" + "github.com/linuxmatters/jive-visualiser/internal/config" "golang.org/x/image/draw" "golang.org/x/image/font" ) diff --git a/internal/renderer/frame.go b/internal/renderer/frame.go index d7fb545..39229ef 100644 --- a/internal/renderer/frame.go +++ b/internal/renderer/frame.go @@ -5,7 +5,7 @@ import ( "image" "image/color" - "github.com/linuxmatters/jivefire/internal/config" + "github.com/linuxmatters/jive-visualiser/internal/config" "golang.org/x/image/font" ) @@ -151,8 +151,8 @@ func (f *Frame) drawBars(barHeights []float64) { // operations to fill the remaining 3/4 of the bars within the same iteration. // The mirrors read only pixels written by renderBar earlier in this iteration // (the left upward bar), so merging the former render/mirror loops keeps output - // identical. The clamped barHeight feeds renderBar; the mirrors derive yStart - // from the unclamped barHeight, matching the original mirror loop. + // identical. The clamped barHeight feeds both renderBar and the mirrors, so + // render and mirror share the same geometry. halfBars := config.NumBars / 2 for i := range halfBars { barHeight := int(barHeights[i]) @@ -170,14 +170,14 @@ func (f *Frame) drawBars(barHeights []float64) { // Render upward bar (left half) with the clamped height - always opaque, // no background blending needed. clampedHeight := min(barHeight, f.maxBarHeight) - f.renderBar(xLeft, f.centerY-clampedHeight-config.CenterGap/2, yEnd, clampedHeight, pixelPattern) + yStart := f.centerY - clampedHeight - config.CenterGap/2 + f.renderBar(xLeft, yStart, yEnd, clampedHeight, pixelPattern) - // Mirror using the unclamped barHeight, matching the original mirror loop: + // Mirror using the same clamped geometry as renderBar: // 1. Vertical mirror โ†’ left-side downward bar // 2. Horizontal mirror โ†’ right-side upward bar // 3. Both mirrors โ†’ right-side downward bar xRight := f.startX + (config.NumBars-1-i)*(config.BarWidth+config.BarGap) - yStart := f.centerY - barHeight - config.CenterGap/2 f.mirrorBarVertical(xLeft, yStart, yEnd) f.mirrorBarHorizontal(xLeft, xRight, yStart, yEnd) diff --git a/internal/renderer/frame_bench_test.go b/internal/renderer/frame_bench_test.go index 311ea97..de35791 100644 --- a/internal/renderer/frame_bench_test.go +++ b/internal/renderer/frame_bench_test.go @@ -4,7 +4,7 @@ import ( "image" "testing" - "github.com/linuxmatters/jivefire/internal/config" + "github.com/linuxmatters/jive-visualiser/internal/config" "golang.org/x/image/font/basicfont" ) @@ -98,20 +98,7 @@ func TestFrameRendering(t *testing.T) { img.Bounds().Dx(), img.Bounds().Dy(), config.Width, config.Height) } - // Check that bars were drawn (center should have bar color) - centerY := config.Height / 2 - - // Find a bar position - totalWidth := config.NumBars*config.BarWidth + (config.NumBars-1)*config.BarGap - startX := (config.Width - totalWidth) / 2 - - // Check first bar area - barX := startX + config.BarWidth/2 - offset := centerY*img.Stride + barX*4 - - // Should see bar color (red) or background depending on bar height - r := img.Pix[offset] - if r != config.BarColorR && r != uint8(barX%256) { - t.Errorf("Unexpected color at bar position: got R=%d", r) - } + // Bar-pixel colour assertions live in frame_test.go (TestBarPixelColour). + // The old check here sampled centerY, inside CenterGap where no bar is + // drawn, with a fallback that made it near-unfalsifiable. } diff --git a/internal/renderer/frame_test.go b/internal/renderer/frame_test.go new file mode 100644 index 0000000..591b02d --- /dev/null +++ b/internal/renderer/frame_test.go @@ -0,0 +1,109 @@ +package renderer + +import ( + "image" + "testing" + + "github.com/linuxmatters/jive-visualiser/internal/config" +) + +// newAsymmetricBackground returns a background whose pixels vary with both +// x and y. Any mirroring defect then changes the output instead of hiding +// behind a symmetric backdrop. +func newAsymmetricBackground() *image.RGBA { + bg := image.NewRGBA(image.Rect(0, 0, config.Width, config.Height)) + for y := range config.Height { + for x := range config.Width { + offset := y*bg.Stride + x*4 + bg.Pix[offset] = uint8(x % 256) + bg.Pix[offset+1] = uint8(y % 256) + bg.Pix[offset+2] = uint8((x + y) % 256) + bg.Pix[offset+3] = 255 + } + } + return bg +} + +// pixelAt returns the RGBA bytes of the pixel at (x, y). +func pixelAt(img *image.RGBA, x, y int) [4]uint8 { + offset := y*img.Stride + x*4 + return [4]uint8{img.Pix[offset], img.Pix[offset+1], img.Pix[offset+2], img.Pix[offset+3]} +} + +// TestMirrorSymmetry renders one frame with every bar driven past +// maxBarHeight, so the clamp engages, then asserts the four quadrants of +// each bar column are pixel-mirrored. For each drawn bar pixel above the +// centre gap, the vertically mirrored pixel below the gap, the horizontally +// mirrored bar's pixel, and the doubly mirrored pixel must all be equal. +func TestMirrorSymmetry(t *testing.T) { + bg := newAsymmetricBackground() + frame := NewFrame(bg, nil, PodcastMeta{}, &config.RuntimeConfig{}) + + // Overdrive every bar past maxBarHeight with varying amounts. + barHeights := make([]float64, config.NumBars) + for i := range barHeights { + barHeights[i] = float64(frame.maxBarHeight) + 50 + float64(i*7) + } + frame.Draw(barHeights) + img := frame.GetImage() + + yEnd := frame.centerY - config.CenterGap/2 + downStart := frame.centerY + config.CenterGap/2 + halfBars := config.NumBars / 2 + for i := range halfBars { + clamped := min(int(barHeights[i]), frame.maxBarHeight) + yStart := frame.centerY - clamped - config.CenterGap/2 + xLeft := frame.startX + i*(config.BarWidth+config.BarGap) + xRight := frame.startX + (config.NumBars-1-i)*(config.BarWidth+config.BarGap) + + for y := max(yStart, 0); y < yEnd; y++ { + // Vertical mirror: row yEnd-1 maps to downStart, and so on. + yMirror := downStart + (yEnd - 1 - y) + if yMirror >= config.Height { + continue + } + for dx := range config.BarWidth { + upLeft := pixelAt(img, xLeft+dx, y) + downLeft := pixelAt(img, xLeft+dx, yMirror) + upRight := pixelAt(img, xRight+dx, y) + downRight := pixelAt(img, xRight+dx, yMirror) + if upLeft != downLeft || upLeft != upRight || upLeft != downRight { + t.Fatalf("bar %d pixel (%d,%d) not mirrored: upLeft=%v downLeft=%v upRight=%v downRight=%v", + i, xLeft+dx, y, upLeft, downLeft, upRight, downRight) + } + } + } + } +} + +// TestBarPixelColour samples a pixel inside a bar of known height and +// asserts it carries the bar colour. This replaces the old check in +// TestFrameRendering, which sampled centerY (inside CenterGap, where no bar +// is drawn) with a near-unfalsifiable background fallback. +func TestBarPixelColour(t *testing.T) { + bg := newAsymmetricBackground() + frame := NewFrame(bg, nil, PodcastMeta{}, &config.RuntimeConfig{}) + + const barHeight = 100 + barHeights := make([]float64, config.NumBars) + barHeights[0] = barHeight + frame.Draw(barHeights) + img := frame.GetImage() + + // Sample the middle of bar 0, halfway up the bar, clear of the framing + // line that overwrites the rows nearest the centre gap. + x := frame.startX + config.BarWidth/2 + yEnd := frame.centerY - config.CenterGap/2 + y := yEnd - barHeight/2 + + got := pixelAt(img, x, y) + background := pixelAt(bg, x, y) + if got == background { + t.Fatalf("pixel (%d,%d) still shows background %v, bar not drawn", x, y, got) + } + // Default bar colour is red (164, 0, 0), dimmed by the intensity + // gradient. The green and blue channels stay zero at every intensity. + if got[0] == 0 || got[1] != 0 || got[2] != 0 || got[3] != 255 { + t.Fatalf("pixel (%d,%d) not bar-coloured: got %v, want dimmed red with G=B=0, A=255", x, y, got) + } +} diff --git a/internal/renderer/thumbnail.go b/internal/renderer/thumbnail.go index 381d6ab..8512513 100644 --- a/internal/renderer/thumbnail.go +++ b/internal/renderer/thumbnail.go @@ -12,7 +12,7 @@ import ( "github.com/golang/freetype" "github.com/golang/freetype/truetype" - "github.com/linuxmatters/jivefire/internal/config" + "github.com/linuxmatters/jive-visualiser/internal/config" "golang.org/x/image/draw" "golang.org/x/image/font" "golang.org/x/image/math/f64" diff --git a/internal/renderer/thumbnail_test.go b/internal/renderer/thumbnail_test.go index bb7759e..dd8799a 100644 --- a/internal/renderer/thumbnail_test.go +++ b/internal/renderer/thumbnail_test.go @@ -5,7 +5,7 @@ import ( "path/filepath" "testing" - "github.com/linuxmatters/jivefire/internal/config" + "github.com/linuxmatters/jive-visualiser/internal/config" ) // TestGenerateSampleThumbnail generates a sample thumbnail for development/testing diff --git a/internal/ui/progress.go b/internal/ui/progress.go index 5a5df57..0a8873c 100644 --- a/internal/ui/progress.go +++ b/internal/ui/progress.go @@ -14,8 +14,8 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/harmonica" - "github.com/linuxmatters/jivefire/internal/config" - "github.com/linuxmatters/jivefire/internal/theme" + "github.com/linuxmatters/jive-visualiser/internal/config" + "github.com/linuxmatters/jive-visualiser/internal/theme" ) // Phase represents the current processing phase @@ -80,6 +80,11 @@ type RenderComplete struct { // the completion signal, so the caller reads them from the final model after // p.Run() returns without a data race on a shared slice. AssetWarnings []string + + // Err carries a fatal Pass 2 error. Like AssetWarnings, it crosses the same + // channel as the completion signal and is read from the final model after + // p.Run() returns, so no extra synchronisation is needed. + Err error } // AudioProfile holds the audio analysis results for display @@ -356,6 +361,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.complete = &msg m.phase = PhaseComplete + // On error, quit at once; the completion delay only shows the success view. + if msg.Err != nil { + return m, tea.Quit + } + return m, tea.Tick(m.completionDelay, func(t time.Time) tea.Msg { return progressQuitMsg{} }) @@ -421,12 +431,21 @@ func (m *Model) View() tea.View { // CompletionSummary returns the final completion summary for printing after alt screen exits. // Returns empty string if encoding is not complete. func (m *Model) CompletionSummary() string { - if m.complete == nil { + if m.complete == nil || m.complete.Err != nil { return "" } return m.renderFinalProgress() + "\n" + m.renderComplete() } +// RenderError returns the fatal error delivered with the RenderComplete +// message, or nil if Pass 2 did not complete or succeeded. +func (m *Model) RenderError() error { + if m.complete == nil { + return nil + } + return m.complete.Err +} + // AssetWarnings returns the non-fatal asset-load warnings delivered with the // RenderComplete message, or nil if Pass 2 did not complete. func (m *Model) AssetWarnings() []string { diff --git a/internal/ui/spectrum.go b/internal/ui/spectrum.go index 3631624..2eae941 100644 --- a/internal/ui/spectrum.go +++ b/internal/ui/spectrum.go @@ -6,7 +6,7 @@ import ( "strings" "charm.land/lipgloss/v2" - "github.com/linuxmatters/jivefire/internal/theme" + "github.com/linuxmatters/jive-visualiser/internal/theme" ) // Spectrum spring tuning. The delta time matches the UI tick cadence so each diff --git a/internal/ui/spectrum_test.go b/internal/ui/spectrum_test.go index 7812786..be493dd 100644 --- a/internal/ui/spectrum_test.go +++ b/internal/ui/spectrum_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" - "github.com/linuxmatters/jivefire/internal/config" - "github.com/linuxmatters/jivefire/internal/theme" + "github.com/linuxmatters/jive-visualiser/internal/config" + "github.com/linuxmatters/jive-visualiser/internal/theme" ) // TestRenderSpectrumUsesThemeRamp verifies renderSpectrum colours its bars from diff --git a/internal/ui/summary.go b/internal/ui/summary.go index ab99575..40e4169 100644 --- a/internal/ui/summary.go +++ b/internal/ui/summary.go @@ -7,8 +7,8 @@ import ( "charm.land/lipgloss/v2" "charm.land/lipgloss/v2/table" - "github.com/linuxmatters/jivefire/internal/config" - "github.com/linuxmatters/jivefire/internal/theme" + "github.com/linuxmatters/jive-visualiser/internal/config" + "github.com/linuxmatters/jive-visualiser/internal/theme" ) func (m *Model) renderComplete() string { diff --git a/internal/ui/widgets.go b/internal/ui/widgets.go index bacedcf..011e665 100644 --- a/internal/ui/widgets.go +++ b/internal/ui/widgets.go @@ -6,7 +6,7 @@ import ( "strings" "charm.land/lipgloss/v2" - "github.com/linuxmatters/jivefire/internal/theme" + "github.com/linuxmatters/jive-visualiser/internal/theme" ) // speedHistoryCap bounds the realtime-speed trace shown in the Speed card's diff --git a/internal/ui/widgets_test.go b/internal/ui/widgets_test.go index 43d447d..8a78f7d 100644 --- a/internal/ui/widgets_test.go +++ b/internal/ui/widgets_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/linuxmatters/jivefire/internal/theme" + "github.com/linuxmatters/jive-visualiser/internal/theme" ) // TestSparklineEmpty verifies an empty series renders nothing rather than From 88789b6d027f2ee3d3639225d9ba18c5d1e1af9c Mon Sep 17 00:00:00 2001 From: Martin Wimpress Date: Sat, 4 Jul 2026 23:10:14 +0100 Subject: [PATCH 2/7] docs: improve comments across audio, CLI, config, encoder, renderer, UI, and YUV packages - Correct 54 drifted comments to present-tense design language - Add rationale to 18 comments explaining non-obvious implementation choices - Remove 18 self-evident or process-narration comments - Fix American spellings to British, replace dashes with punctuation - Add missing doc comments to exported symbols Signed-off-by: Martin Wimpress --- cmd/jive-visualiser/main.go | 1 + internal/audio/buffer.go | 4 +- internal/audio/fft_test.go | 9 ++-- internal/cli/help.go | 7 ++- internal/cli/styles.go | 17 ++++---- internal/config/config.go | 37 ++++++++-------- internal/config/config_test.go | 10 ++--- internal/encoder/encoder.go | 11 ++--- internal/encoder/encoder_test.go | 11 +++-- internal/encoder/frame.go | 14 ++++-- internal/renderer/assets.go | 2 +- internal/renderer/frame.go | 61 ++++++++++++++------------- internal/renderer/frame_bench_test.go | 7 --- internal/renderer/thumbnail.go | 41 +++++++++--------- internal/renderer/thumbnail_test.go | 6 +-- internal/ui/progress.go | 7 ++- internal/ui/progress_test.go | 4 +- internal/ui/spectrum.go | 6 +-- internal/ui/spectrum_test.go | 4 +- internal/ui/summary.go | 7 ++- internal/yuv/yuv.go | 26 +++++++++--- 21 files changed, 148 insertions(+), 144 deletions(-) diff --git a/cmd/jive-visualiser/main.go b/cmd/jive-visualiser/main.go index 3732c57..ddc50bc 100644 --- a/cmd/jive-visualiser/main.go +++ b/cmd/jive-visualiser/main.go @@ -25,6 +25,7 @@ import ( // (e.g. "v0.1.0") for releases. var version = "dev" +// CLI holds the parsed command-line flags and positional arguments. var CLI struct { Input string `arg:"" name:"input" help:"Input WAV file" optional:""` Output string `arg:"" name:"output" help:"Output MP4 file" optional:""` diff --git a/internal/audio/buffer.go b/internal/audio/buffer.go index ad63b7c..9b7dd73 100644 --- a/internal/audio/buffer.go +++ b/internal/audio/buffer.go @@ -24,8 +24,8 @@ func readIntoBuffer(reader *StreamingReader, buf []float64) (int, error) { return total, nil } -// FillFFTBuffer reads up to len(buf) samples from reader via repeated ReadChunk -// calls. Returns the number of samples read. Returns (0, nil) on immediate EOF, +// FillFFTBuffer reads up to len(buf) samples from reader via readIntoBuffer. +// Returns the number of samples read. Returns (0, nil) on immediate EOF, // allowing callers to decide whether that is an error. func FillFFTBuffer(reader *StreamingReader, buf []float64) (int, error) { total, err := readIntoBuffer(reader, buf) diff --git a/internal/audio/fft_test.go b/internal/audio/fft_test.go index 2a8c1e0..99e8970 100644 --- a/internal/audio/fft_test.go +++ b/internal/audio/fft_test.go @@ -243,8 +243,9 @@ func TestBinFFT_EnergyDistribution(t *testing.T) { t.Logf("Energy distribution: %.6f total energy across %d/%d bars", totalEnergy, nonzeroCount, numBars) } -// absf returns the absolute value of a float32 (mirrors tx_test.go:96-101). Kept -// local; test helpers do not cross the module boundary. +// absf returns the absolute value of a float32 (mirrors the absf helper in +// ffmpeg-statigo's tx_test.go). Kept local; test helpers do not cross the +// module boundary. func absf(v float32) float32 { if v < 0 { return -v @@ -253,7 +254,7 @@ func absf(v float32) float32 { } // TestAVTxRDFTForwardDC mirrors the DC semantics of ffmpeg-statigo's -// TestAVTxFloatFFTForwardDC (tx_test.go:26-93) but at the Processor/ProcessChunk +// TestAVTxFloatFFTForwardDC but at the Processor/ProcessChunk // level, exercising the live av_tx RDFT path end to end. A constant (DC) real // input is fed through ProcessChunk and the DC bin (Spectrum index 0, i.e. // spectrum[0]/spectrum[1] re/im) must hold essentially all the energy while the @@ -466,7 +467,7 @@ func TestRearrangeFrequenciesCenterOut_EdgeCases(t *testing.T) { // TestRearrangeFrequenciesCenterOut_SmallInput tests with minimal input size. func TestRearrangeFrequenciesCenterOut_SmallInput(t *testing.T) { - // Test the example from PLAN.md: [1,2,3,4] โ†’ symmetric output + // Worked example: [1,2,3,4] mirrors to symmetric output. input := []float64{1, 2, 3, 4} result := make([]float64, 4) diff --git a/internal/cli/help.go b/internal/cli/help.go index 1ef48a0..04a79a3 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -10,7 +10,7 @@ import ( "github.com/linuxmatters/jive-visualiser/internal/theme" ) -// Custom help styles - fire theme +// Custom help styles, fire theme var ( helpTitleStyle = lipgloss.NewStyle(). Bold(true). @@ -40,7 +40,8 @@ var ( Italic(true) ) -// StyledHelpPrinter creates a custom help printer with Lipgloss styling +// StyledHelpPrinter returns a Kong help printer that renders usage, arguments, +// and flags with the Lipgloss fire theme. func StyledHelpPrinter(options kong.HelpOptions) kong.HelpPrinter { return kong.HelpPrinter(func(options kong.HelpOptions, ctx *kong.Context) error { var sb strings.Builder @@ -142,7 +143,6 @@ type flag struct { func getArguments(ctx *kong.Context) []argument { var args []argument - // Parse arguments from the model for _, arg := range ctx.Model.Positional { name := arg.Summary() help := arg.Help @@ -161,7 +161,6 @@ func getFlags(ctx *kong.Context) []flag { help: "Show context-sensitive help.", }) - // Parse flags from the model for _, f := range ctx.Model.Flags { if f.Name == "help" { continue // Already added diff --git a/internal/cli/styles.go b/internal/cli/styles.go index 7d642e6..82b377d 100644 --- a/internal/cli/styles.go +++ b/internal/cli/styles.go @@ -8,40 +8,41 @@ import ( "github.com/linuxmatters/jive-visualiser/internal/theme" ) -// Styles +// Shared Lipgloss styles for CLI output. var ( - // Title style - bold red with fire emoji + // TitleStyle renders the bold red banner heading. TitleStyle = lipgloss.NewStyle(). Bold(true). Foreground(theme.JiveRed). MarginBottom(1) - // Section header style + // HeaderStyle renders a section header. HeaderStyle = lipgloss.NewStyle(). Bold(true). Foreground(theme.GoldOrange). MarginTop(1). MarginBottom(1) - // Error message style + // ErrorStyle renders an error message. ErrorStyle = lipgloss.NewStyle(). Bold(true). Foreground(theme.JiveRed) - // Warning message style + // WarningStyle renders a warning message. WarningStyle = lipgloss.NewStyle(). Bold(true). Foreground(theme.GoldOrange) - // Highlight style for important values + // HighlightStyle emphasises important values. HighlightStyle = lipgloss.NewStyle(). Bold(true). Foreground(theme.NeonYellow) - // Key-value pair styles + // KeyStyle renders the key of a key-value pair. KeyStyle = lipgloss.NewStyle(). Foreground(theme.NeutralGray) + // ValueStyle renders the value of a key-value pair. ValueStyle = lipgloss.NewStyle(). Bold(true). Foreground(theme.BrightWhite) @@ -53,7 +54,7 @@ func PrintVersion(version string) { fmt.Printf("%s %s\n", KeyStyle.Render("Version:"), ValueStyle.Render(version)) } -// EncoderInfo holds information about a hardware encoder for display +// EncoderInfo holds one hardware encoder's details for the probe display. type EncoderInfo struct { Name string Description string diff --git a/internal/config/config.go b/internal/config/config.go index f0ccb2b..85fa6e7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,9 +8,9 @@ import ( // Video settings const ( - Width = 1280 - Height = 720 - FPS = 30 + Width = 1280 // Output frame width in pixels + Height = 720 // Output frame height in pixels + FPS = 30 // Output frames per second ) // Audio settings @@ -20,15 +20,15 @@ const ( // (reader.SampleRate()); this constant is a documented default used for // synthetic test signals and as a fallback reference. SampleRate = 44100 - FFTSize = 2048 + FFTSize = 2048 // FFT window size in samples; power of two for a fast transform ) -// Visualization settings +// Visualisation settings const ( - NumBars = 64 // Number of bars - BarWidth = 12 // Width of each bar - BarGap = 8 // Gap between bars - CenterGap = 100 // Gap between top and bottom bar sections + NumBars = 64 // Frequency bars across the width + BarWidth = 12 // Bar width in pixels + BarGap = 8 // Gap between bars in pixels + CenterGap = 100 // Vertical gap in pixels between top and bottom bar sections MaxBarHeight = 0.50 // Maximum bar height as fraction of available space ) @@ -49,13 +49,13 @@ const ( // Embedded assets live in internal/renderer/assets/. Runtime overrides for // colours and image paths are applied via RuntimeConfig. const ( - // Bar colors (RGB values for visualization bars) + // Bar colour, brand red #A40000 (RGB values for visualisation bars) BarColorR = 164 BarColorG = 0 BarColorB = 0 - // Text/UI colors (RGB values for title overlay and framing lines) - // Brand yellow #F8B31D - used for title text, framing lines, and thumbnail text + // Text/UI colour, brand yellow #F8B31D. Used for title text, framing lines, + // and thumbnail text. TextColorR = 248 TextColorG = 179 TextColorB = 29 @@ -87,8 +87,8 @@ type OptionalColor struct { Set bool } -// RuntimeConfig holds optional runtime overrides for customization -// When fields are unset/empty, the defaults from constants above are used +// RuntimeConfig holds optional runtime overrides for customisation. +// When fields are unset or empty, the defaults from the constants above are used. type RuntimeConfig struct { // Optional colour overrides (apply only when Set is true) BarColor OptionalColor @@ -99,7 +99,7 @@ type RuntimeConfig struct { ThumbnailImagePath string } -// GetBarColor returns the bar color RGB values (uses override or default) +// GetBarColor returns the bar colour RGB values, using the override when set or the default otherwise. func (c *RuntimeConfig) GetBarColor() (r, g, b uint8) { if c.BarColor.Set { return c.BarColor.R, c.BarColor.G, c.BarColor.B @@ -107,7 +107,7 @@ func (c *RuntimeConfig) GetBarColor() (r, g, b uint8) { return BarColorR, BarColorG, BarColorB } -// GetTextColor returns the text color RGB values (uses override or default) +// GetTextColor returns the text colour RGB values, using the override when set or the default otherwise. func (c *RuntimeConfig) GetTextColor() (r, g, b uint8) { if c.TextColor.Set { return c.TextColor.R, c.TextColor.G, c.TextColor.B @@ -133,17 +133,14 @@ func (c *RuntimeConfig) GetThumbnailImagePath() (path string, isCustom bool) { return ThumbnailImageAsset, false } -// ParseHexColor parses a hex color string (#RRGGBB or RRGGBB) and returns RGB values +// ParseHexColor parses a hex colour string (#RRGGBB or RRGGBB) and returns RGB values. func ParseHexColor(hex string) (r, g, b uint8, err error) { - // Remove leading # if present hex = strings.TrimPrefix(hex, "#") - // Validate length if len(hex) != 6 { return 0, 0, 0, fmt.Errorf("invalid hex color format: must be 6 characters (RRGGBB)") } - // Parse RGB components var rgb uint64 rgb, err = strconv.ParseUint(hex, 16, 32) if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index abb21bf..0fa7675 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -334,7 +334,7 @@ func TestParseHexColor_ByteOrder(t *testing.T) { } // TestRuntimeConfig_GetBarColor verifies that GetBarColor returns default -// values when optional fields are nil. +// values when the bar colour override is unset (Set is false). func TestRuntimeConfig_GetBarColor(t *testing.T) { testCases := []struct { name string @@ -392,7 +392,7 @@ func TestRuntimeConfig_GetBarColor(t *testing.T) { } // TestRuntimeConfig_GetTextColor verifies that GetTextColor returns default -// values when optional fields are nil. +// values when the text colour override is unset (Set is false). func TestRuntimeConfig_GetTextColor(t *testing.T) { testCases := []struct { name string @@ -440,8 +440,8 @@ func TestRuntimeConfig_GetTextColor(t *testing.T) { } // TestRuntimeConfig_NilFields verifies that Get*() methods return defaults -// when optional fields are nil. This catches nil pointer dereferences in -// config access that could panic during rendering. +// when optional fields are unset (colour Set is false, image path empty). +// This guards the zero-value config path used during rendering. func TestRuntimeConfig_NilFields(t *testing.T) { tests := []struct { name string @@ -451,7 +451,7 @@ func TestRuntimeConfig_NilFields(t *testing.T) { { name: "Completely nil config", config: &RuntimeConfig{ - // All fields nil/empty + // Zero value: colour overrides unset, image paths empty }, validate: func(t *testing.T, c *RuntimeConfig) { // GetBarColor should return defaults diff --git a/internal/encoder/encoder.go b/internal/encoder/encoder.go index 772bc89..bc6aaa8 100644 --- a/internal/encoder/encoder.go +++ b/internal/encoder/encoder.go @@ -38,7 +38,8 @@ type Config struct { // avAudioFIFO wraps FFmpeg's AVAudioFifo, confining the C handle and all // plane-pointer marshalling behind this type so no consumer touches the C // boundary directly. The FIFO is packed (AVSampleFmtFlt) to preserve the -// interleaved push contract; the planar split happens at drain (Task 2.3). +// interleaved push contract. The planar split happens at drain, in +// writeMonoFloats and writeStereoFloats. type avAudioFIFO struct { fifo *ffmpeg.AVAudioFifo channels int @@ -378,7 +379,7 @@ func (e *Encoder) Initialize() (err error) { // Hardware encoder options e.setHWEncoderOptions(&opts) } else { - // Software encoder (x264) options optimized for visualization content + // Software encoder (x264) options optimised for visualisation content // CRF 24 = good quality for busy visualizations _, _ = ffmpeg.AVDictSet(&opts, ffmpeg.ToCStr("crf"), ffmpeg.ToCStr("24"), 0) // Faster preset prioritizes encoding speed @@ -434,7 +435,7 @@ func (e *Encoder) setHWEncoderOptions(opts **ffmpeg.AVDictionary) { switch e.hwEncoder.Type { case HWAccelNVENC: - // NVENC options optimized for fast visualisation encoding + // NVENC options optimised for fast visualisation encoding // Preset p1 = fastest encoding (scale runs p1=fastest to p7=slowest) _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("preset"), ffmpeg.ToCStr("p1"), 0) // Low latency tuning - reduces pipeline delay @@ -456,7 +457,7 @@ func (e *Encoder) setHWEncoderOptions(opts **ffmpeg.AVDictionary) { _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("profile"), ffmpeg.ToCStr("main"), 0) case HWAccelVulkan: - // Vulkan Video options optimized for fast visualisation encoding + // Vulkan Video options optimised for fast visualisation encoding _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("content"), ffmpeg.ToCStr("rendered"), 0) // Quality level (0-51, lower=better) - same as NVENC CQ _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("qp"), ffmpeg.ToCStr("24"), 0) @@ -470,7 +471,7 @@ func (e *Encoder) setHWEncoderOptions(opts **ffmpeg.AVDictionary) { _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("b_depth"), ffmpeg.ToCStr("1"), 0) case HWAccelVAAPI: - // VA-API options optimized for fast visualisation encoding + // VA-API options optimised for fast visualisation encoding // Quality level (1-51, lower=better) - CQP rate control _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("qp"), ffmpeg.ToCStr("24"), 0) // Main profile for broad compatibility diff --git a/internal/encoder/encoder_test.go b/internal/encoder/encoder_test.go index 068ecdc..14b3e08 100644 --- a/internal/encoder/encoder_test.go +++ b/internal/encoder/encoder_test.go @@ -105,9 +105,8 @@ func TestAVAudioFIFO_WriteReadStereo(t *testing.T) { } } -// TestAVAudioFIFO_ReadMoreThanAvailable mirrors the old _PopMoreThanAvailable -// intent: a read request larger than the buffered count returns only what is -// available and drains the FIFO. +// TestAVAudioFIFO_ReadMoreThanAvailable checks that a read request larger than +// the buffered count returns only what is available and drains the FIFO. func TestAVAudioFIFO_ReadMoreThanAvailable(t *testing.T) { f := newTestFIFO(t, 1) @@ -135,7 +134,7 @@ func TestAVAudioFIFO_ReadMoreThanAvailable(t *testing.T) { } // TestAVAudioFIFO_EmptyBuffer verifies operations on an empty FIFO do not panic -// and report zero, mirroring the old _EmptyBuffer intent. +// and report zero. func TestAVAudioFIFO_EmptyBuffer(t *testing.T) { f := newTestFIFO(t, 1) @@ -214,7 +213,7 @@ func TestAVAudioFIFO_FrameAlignedDrain(t *testing.T) { } // TestAVAudioFIFO_SequentialOperations exercises multiple write/read rounds and -// checks size() after each, mirroring the old _SequentialOperations intent. +// checks size() after each. func TestAVAudioFIFO_SequentialOperations(t *testing.T) { f := newTestFIFO(t, 1) @@ -316,7 +315,7 @@ func TestEncoderPOC(t *testing.T) { // TestEncoderRGBA tests the RGBA frame writing path func TestEncoderRGBA(t *testing.T) { outputPath := "../../testdata/poc-rgba-video.mp4" - defer os.Remove(outputPath) // Clean up after test + defer os.Remove(outputPath) config := Config{ OutputPath: outputPath, diff --git a/internal/encoder/frame.go b/internal/encoder/frame.go index abe8784..2061ecb 100644 --- a/internal/encoder/frame.go +++ b/internal/encoder/frame.go @@ -10,8 +10,12 @@ import ( "github.com/linuxmatters/jive-visualiser/internal/yuv" ) -// convertRGBAToYUV converts RGBA data directly to YUV420P (planar) format. -// Skips the intermediate RGB24 buffer allocation for significantly faster software encoding. +// convertRGBAToYUV converts RGBA data directly to YUV420P (planar) format, +// skipping the intermediate RGB24 buffer allocation for faster software encoding. +// This near-duplicates convertRGBAToNV12 on purpose: the two hot paths stay +// separate to avoid a callback or interface indirection that would slow the +// per-pixel loop. Do not merge them; shared low-level primitives live in +// internal/yuv. func convertRGBAToYUV(pool *yuv.RowPool, rgbaData []byte, yuvFrame *ffmpeg.AVFrame, width int) { yPlane := yuvFrame.Data().Get(0) uPlane := yuvFrame.Data().Get(1) @@ -74,8 +78,10 @@ func convertRGBAToYUV(pool *yuv.RowPool, rgbaData []byte, yuvFrame *ffmpeg.AVFra }) } -// convertRGBAToNV12 converts RGBA data to NV12 (semi-planar) format. -// NV12 has a Y plane followed by interleaved UV plane. +// convertRGBAToNV12 converts RGBA data to NV12 (semi-planar) format: a Y plane +// followed by a single interleaved UV plane. This near-duplicates +// convertRGBAToYUV on purpose; see that function for why the two are kept +// separate rather than merged. func convertRGBAToNV12(pool *yuv.RowPool, rgbaData []byte, nv12Frame *ffmpeg.AVFrame, width int) { yPlane := nv12Frame.Data().Get(0) uvPlane := nv12Frame.Data().Get(1) diff --git a/internal/renderer/assets.go b/internal/renderer/assets.go index 8a4b6fb..7810345 100644 --- a/internal/renderer/assets.go +++ b/internal/renderer/assets.go @@ -72,7 +72,7 @@ func LoadFont(size float64) (font.Face, error) { return face, nil } -// DrawCenterText draws text centered horizontally at the specified Y position +// DrawCenterText draws text centred horizontally at the specified Y position. func DrawCenterText(img *image.RGBA, face font.Face, text string, centerY int, textColor color.RGBA) { d := newTextDrawer(img, face, textColor) diff --git a/internal/renderer/frame.go b/internal/renderer/frame.go index 39229ef..68d4e96 100644 --- a/internal/renderer/frame.go +++ b/internal/renderer/frame.go @@ -17,7 +17,7 @@ type PodcastMeta struct { Episode *int } -// Frame represents a single video frame with visualization bars +// Frame represents a single video frame with visualisation bars type Frame struct { img *image.RGBA bgImage *image.RGBA @@ -30,17 +30,17 @@ type Frame struct { episodeNum string hasEpisode bool title string - textColor color.RGBA // Text color for overlays + textColor color.RGBA // Text colour for overlays // Pre-computed values maxBarHeight int - intensityTable []uint8 // Pre-computed intensity values for opaque gradient (0.5 to 1.0) - barColorTable [][3]uint8 // Pre-computed bar colors at different intensity levels + intensityTable []uint8 // Intensity values for the opaque gradient (0.5 dim tip to 1.0 bright centre) + barColorTable [][3]uint8 // Bar colours pre-dimmed for each of the 256 intensity levels framingLineData []byte // Pre-rendered framing line pixel pattern hasBackground bool } -// NewFrame creates a new optimized frame renderer +// NewFrame creates a new optimised frame renderer func NewFrame(bgImage *image.RGBA, fontFace font.Face, meta PodcastMeta, runtimeConfig *config.RuntimeConfig) *Frame { totalWidth := config.NumBars*config.BarWidth + (config.NumBars-1)*config.BarGap startX := (config.Width - totalWidth) / 2 @@ -52,17 +52,19 @@ func NewFrame(bgImage *image.RGBA, fontFace font.Face, meta PodcastMeta, runtime barR, barG, barB := runtimeConfig.GetBarColor() textR, textG, textB := runtimeConfig.GetTextColor() - // Pre-compute intensity gradient table (0.5 to 1.0 range for opaque gradient) - // This creates a fade from dim at tips to bright at center without alpha blending + // Pre-compute the intensity gradient once so the per-pixel hot path only + // indexes this table. Values fade from 0.5 at the tips to 1.0 at the + // centre, giving an opaque gradient with no alpha blending. intensityTable := make([]uint8, maxBarHeight) for i := range maxBarHeight { distanceFromCenter := float64(i) / float64(maxBarHeight) - intensityFactor := 1.0 - (distanceFromCenter * 0.5) // 0.5 at tips, 1.0 at center + intensityFactor := 1.0 - (distanceFromCenter * 0.5) // 0.5 at tips, 1.0 at centre intensityTable[i] = uint8(intensityFactor * 255) } - // Pre-compute bar colors at different intensity levels (0-255) - // Colors are fully opaque - RGB values dimmed by intensity, alpha always 255 + // Pre-compute the dimmed bar colour for every intensity level (0-255) so the + // hot path needs no per-pixel multiply. Colours stay fully opaque, RGB values + // dimmed by intensity, alpha always 255. barColorTable := make([][3]uint8, 256) for intensity := range 256 { factor := float64(intensity) / 255.0 @@ -109,14 +111,13 @@ func NewFrame(bgImage *image.RGBA, fontFace font.Face, meta PodcastMeta, runtime return f } -// Draw renders the visualization bars using pre-computed values +// Draw renders the visualisation bars using pre-computed values func (f *Frame) Draw(barHeights []float64) { // Clear or copy background if f.hasBackground { copy(f.img.Pix, f.bgImage.Pix) } else { - // Fast clear to black using optimized pattern - // Clear 8 pixels at a time for better memory bandwidth + // Clear to black 8 pixels (32 bytes) per copy for better memory bandwidth. blackPattern := [32]byte{ 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, @@ -135,14 +136,16 @@ func (f *Frame) Draw(barHeights []float64) { f.applyTextOverlay() } -// drawBars renders all bars using horizontal + vertical symmetry optimization. -// The frequency data is arranged symmetrically: bars 0-31 are mirrored to create bars 32-63. -// We render only the first 32 bars upward, then mirror 3 times: -// 1. Vertical mirror โ†’ bars 0-31 downward -// 2. Horizontal mirror โ†’ bars 32-63 upward -// 3. Both mirrors โ†’ bars 32-63 downward +// drawBars renders all bars using horizontal and vertical symmetry. +// The frequency data is symmetric: bars 0-31 mirror to bars 32-63, and each +// bar mirrors about the centre gap. So only the first 32 bars are rendered +// upward, running the gradient maths once, then mirrored 3 times with plain +// pixel copies: +// 1. Vertical mirror to bars 0-31 downward +// 2. Horizontal mirror to bars 32-63 upward +// 3. Both mirrors to bars 32-63 downward // -// This renders 1/4 of the pixels and is ~4x faster. +// This computes 1/4 of the pixels and is roughly 4x faster than rendering each. func (f *Frame) drawBars(barHeights []float64) { // Pre-allocate pixel pattern buffer (reused for all bars) pixelPattern := make([]byte, config.BarWidth*4) @@ -192,7 +195,7 @@ func (f *Frame) renderBar(x, yStart, yEnd, barHeight int, pixelPattern []byte) { continue } - // Calculate intensity for fade gradient (dim at tip โ†’ bright at center) + // Intensity for the fade gradient (dim at tip to bright at centre). distanceFromCenter := yEnd - 1 - y intensityIndex := (distanceFromCenter * f.maxBarHeight) / barHeight if intensityIndex >= f.maxBarHeight { @@ -264,18 +267,17 @@ func (f *Frame) applyTextOverlay() { } } -// drawFramingLines draws horizontal lines above and below the center gap -// using the text color from config to frame the title text +// drawFramingLines draws horizontal lines above and below the centre gap, +// in the config text colour, to frame the title text. func (f *Frame) drawFramingLines() { lineHeight := config.FramingLineHeight - // Calculate line positions - // Top line: just above where upward bars end + // Top line sits just above where upward bars end. topLineY := f.centerY - config.CenterGap/2 - lineHeight - // Bottom line: just below where downward bars start + // Bottom line sits just below where downward bars start. bottomLineY := f.centerY + config.CenterGap/2 - // Draw top framing line (4 pixels high) - reuse pre-rendered pattern + // Each line copies the pre-rendered pattern once per scanline. for y := topLineY; y < topLineY+lineHeight; y++ { if y >= 0 && y < config.Height { offset := y*f.img.Stride + f.startX*4 @@ -283,7 +285,6 @@ func (f *Frame) drawFramingLines() { } } - // Draw bottom framing line (4 pixels high) - reuse pre-rendered pattern for y := bottomLineY; y < bottomLineY+lineHeight; y++ { if y >= 0 && y < config.Height { offset := y*f.img.Stride + f.startX*4 @@ -292,12 +293,12 @@ func (f *Frame) drawFramingLines() { } } -// formatEpisodeNumber formats an episode number as a two-digit string +// formatEpisodeNumber zero-pads an episode number below 100 to two digits, +// and returns larger numbers unpadded. func formatEpisodeNumber(num int) string { if num < 100 { return fmt.Sprintf("%02d", num) } - // For numbers >= 100, return as-is return fmt.Sprintf("%d", num) } diff --git a/internal/renderer/frame_bench_test.go b/internal/renderer/frame_bench_test.go index de35791..7e400ab 100644 --- a/internal/renderer/frame_bench_test.go +++ b/internal/renderer/frame_bench_test.go @@ -20,9 +20,7 @@ func generateTestBarHeights() []float64 { // BenchmarkFrameWithBackground benchmarks frame rendering with background func BenchmarkFrameWithBackground(b *testing.B) { - // Setup bgImage := image.NewRGBA(image.Rect(0, 0, config.Width, config.Height)) - // Fill background with test pattern for i := 0; i < len(bgImage.Pix); i += 4 { bgImage.Pix[i] = 64 bgImage.Pix[i+1] = 64 @@ -42,7 +40,6 @@ func BenchmarkFrameWithBackground(b *testing.B) { // BenchmarkFrameNoBackground benchmarks frame rendering without background (black) func BenchmarkFrameNoBackground(b *testing.B) { - // Setup - no background runtimeConfig := &config.RuntimeConfig{} frame := NewFrame(nil, nil, PodcastMeta{}, runtimeConfig) barHeights := generateTestBarHeights() @@ -55,7 +52,6 @@ func BenchmarkFrameNoBackground(b *testing.B) { // BenchmarkFrameWithText benchmarks frame rendering with text overlay func BenchmarkFrameWithText(b *testing.B) { - // Setup bgImage := image.NewRGBA(image.Rect(0, 0, config.Width, config.Height)) fontFace := basicfont.Face7x13 runtimeConfig := &config.RuntimeConfig{} @@ -71,7 +67,6 @@ func BenchmarkFrameWithText(b *testing.B) { // TestFrameRendering verifies that frame rendering produces expected output func TestFrameRendering(t *testing.T) { bgImage := image.NewRGBA(image.Rect(0, 0, config.Width, config.Height)) - // Fill with a simple pattern for y := range config.Height { for x := range config.Width { offset := y*bgImage.Stride + x*4 @@ -86,13 +81,11 @@ func TestFrameRendering(t *testing.T) { runtimeConfig := &config.RuntimeConfig{} frame := NewFrame(bgImage, fontFace, PodcastMeta{Title: "Linux Matters", Episode: new(42)}, runtimeConfig) - // Test with various bar heights barHeights := generateTestBarHeights() frame.Draw(barHeights) img := frame.GetImage() - // Verify image dimensions if img.Bounds().Dx() != config.Width || img.Bounds().Dy() != config.Height { t.Errorf("Image dimensions incorrect: got %dx%d, want %dx%d", img.Bounds().Dx(), img.Bounds().Dy(), config.Width, config.Height) diff --git a/internal/renderer/thumbnail.go b/internal/renderer/thumbnail.go index 8512513..b8504f3 100644 --- a/internal/renderer/thumbnail.go +++ b/internal/renderer/thumbnail.go @@ -18,7 +18,7 @@ import ( "golang.org/x/image/math/f64" ) -// getThumbnailTextColor returns the text color for thumbnail (uses runtime config or default) +// getThumbnailTextColor returns the thumbnail text colour from runtime config, or the default. func getThumbnailTextColor(runtimeConfig *config.RuntimeConfig) color.RGBA { r, g, b := runtimeConfig.GetTextColor() return color.RGBA{R: r, G: g, B: b, A: 255} @@ -103,11 +103,10 @@ func splitTitle(title string) (string, string) { return line1, line2 } -// findOptimalFontSize finds the largest font size that fits within constraints -// Constraints: +// findOptimalFontSize finds the largest font size that fits within these constraints: // - ThumbnailMargin from left and right edges -// - Line 1 starts at top margin (ThumbnailMargin) -// - Bottom edge of line 2 must not extend below center line (y=360) +// - Line 1 starts at the top margin (ThumbnailMargin) +// - Line 2 bottom edge must not extend below the vertical centre line (Height/2) func findOptimalFontSize(parsedFont *truetype.Font, line1, line2 string) float64 { centerY := config.Height / 2 maxWidth := config.Width - (2 * config.ThumbnailMargin) @@ -144,7 +143,7 @@ func findOptimalFontSize(parsedFont *truetype.Font, line1, line2 string) float64 // Line 2 bottom: margin + height1 + lineSpacing + height2 line2Bottom := config.ThumbnailMargin + height1 + lineSpacing + height2 - // Check if line 2 bottom fits above center line + // Accept this size when line 2 bottom fits above the centre line. if line2Bottom <= centerY { return size } @@ -153,10 +152,10 @@ func findOptimalFontSize(parsedFont *truetype.Font, line1, line2 string) float64 return 10.0 // Minimum fallback size } -// drawThumbnailText draws the title text on the thumbnail with a slight rotation -// Line 1 is top-aligned at the ThumbnailMargin -// Bottom edge of line 2 must not extend below center line -// Text is rotated ThumbnailTextRotationDegrees clockwise for dynamic effect +// drawThumbnailText draws the title text on the thumbnail with a slight rotation. +// Line 1 is top-aligned at the ThumbnailMargin, line 2 bottom edge must not +// extend below the centre line, and the text is rotated +// ThumbnailTextRotationDegrees clockwise for a dynamic effect. func drawThumbnailText(img *image.RGBA, face font.Face, line1, line2 string, runtimeConfig *config.RuntimeConfig) { // Measure text dimensions - bounds.Min.Y is negative (ascent), bounds.Max.Y is positive (descent) width1, bounds1 := measureTextBounds(face, line1) @@ -181,13 +180,13 @@ func drawThumbnailText(img *image.RGBA, face font.Face, line1, line2 string, run tempSize := int(float64(maxWidth+totalHeight) * 1.5) tempImg := image.NewRGBA(image.Rect(0, 0, tempSize, tempSize)) - // Draw text on temporary image, centered - // The baseline is where DrawString draws. The visual top is baseline + bounds.Min.Y (Min.Y is negative) + // Draw text on the temporary image, centred. + // The baseline is where DrawString draws. The visual top is baseline + bounds.Min.Y (Min.Y is negative). tempCenterY := tempSize / 2 - // Position line 1: we want the text block centered, so calculate baseline positions - // Text block spans from (center - totalHeight/2) to (center + totalHeight/2) - // Line 1's visual top should be at (center - totalHeight/2) + // Position line 1 so the text block is centred, by calculating baseline positions. + // The text block spans (centre - totalHeight/2) to (centre + totalHeight/2). + // Line 1's visual top should be at (centre - totalHeight/2). // Since visual top = baseline + bounds1.Min.Y, we get: baseline = visualTop - bounds1.Min.Y line1VisualTop := tempCenterY - totalHeight/2 line1BaselineY := line1VisualTop - bounds1.Min.Y.Ceil() // Min.Y is negative, so this adds to visualTop @@ -204,7 +203,7 @@ func drawThumbnailText(img *image.RGBA, face font.Face, line1, line2 string, run cos := math.Cos(angle) sin := math.Sin(angle) - // Center of rotation (center of temp image) + // Centre of rotation (centre of temp image). cx := float64(tempSize) / 2.0 cy := float64(tempSize) / 2.0 @@ -231,8 +230,8 @@ func drawThumbnailText(img *image.RGBA, face font.Face, line1, line2 string, run line1Right := cx + float64(width1)/2.0 // This is the point that will be highest after clockwise rotation - topRightX := line1Right - cx // Relative to rotation center - topRightY := line1Top - cy // Relative to rotation center + topRightX := line1Right - cx // Relative to rotation centre + topRightY := line1Top - cy // Relative to rotation centre // Apply rotation to find where this point ends up // For clockwise rotation: y' = x*sin + y*cos @@ -241,7 +240,7 @@ func drawThumbnailText(img *image.RGBA, face font.Face, line1, line2 string, run // Translate back to tempImg coordinates highestPointY := rotatedTopY + cy - // Position the rotated text block centered horizontally + // Position the rotated text block centred horizontally. destX := (config.Width - tempSize) / 2 // For vertical positioning: @@ -256,7 +255,7 @@ func drawThumbnailText(img *image.RGBA, face font.Face, line1, line2 string, run draw.Draw(img, destRect, rotatedImg, image.Point{}, draw.Over) } -// drawCenteredLineOnTemp draws a line of text centered on a temporary image +// drawCenteredLineOnTemp draws a line of text centred on a temporary image. func drawCenteredLineOnTemp(img *image.RGBA, face font.Face, text string, imgWidth, baselineY int, runtimeConfig *config.RuntimeConfig) { if text == "" { return @@ -265,7 +264,7 @@ func drawCenteredLineOnTemp(img *image.RGBA, face font.Face, text string, imgWid d := newTextDrawer(img, face, getThumbnailTextColor(runtimeConfig)) textWidth := measureStringWidth(d, text) - // Center horizontally + // Centre horizontally. x := (imgWidth - textWidth) / 2 d.Dot = freetype.Pt(x, baselineY) diff --git a/internal/renderer/thumbnail_test.go b/internal/renderer/thumbnail_test.go index dd8799a..1b9d507 100644 --- a/internal/renderer/thumbnail_test.go +++ b/internal/renderer/thumbnail_test.go @@ -11,7 +11,6 @@ import ( // TestGenerateSampleThumbnail generates a sample thumbnail for development/testing // This serves both as a test and as a useful development tool func TestGenerateSampleThumbnail(t *testing.T) { - // Test with real recent episode titles testCases := []struct { title string outputName string @@ -30,21 +29,18 @@ func TestGenerateSampleThumbnail(t *testing.T) { }, } - // Use default config (nil runtimeConfig uses defaults) + // An empty RuntimeConfig falls back to the default colours and assets. runtimeConfig := &config.RuntimeConfig{} for _, tc := range testCases { t.Run(tc.title, func(t *testing.T) { - // Create output in testdata directory outputPath := filepath.Join("../../testdata", tc.outputName) - // Generate thumbnail err := GenerateThumbnail(outputPath, PodcastMeta{Title: tc.title}, runtimeConfig) if err != nil { t.Fatalf("failed to generate thumbnail: %v", err) } - // Verify file was created if _, err := os.Stat(outputPath); os.IsNotExist(err) { t.Fatalf("thumbnail file was not created: %s", outputPath) } diff --git a/internal/ui/progress.go b/internal/ui/progress.go index 0a8873c..94fed82 100644 --- a/internal/ui/progress.go +++ b/internal/ui/progress.go @@ -327,7 +327,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case AnalysisComplete: - // Store audio profile for display m.audioProfile = &AudioProfile{ Duration: msg.Duration, PeakLevel: 20 * math.Log10(msg.PeakMagnitude), @@ -422,7 +421,7 @@ func (m *Model) View() tea.View { } // Alternate screen buffer prevents ghost box edges when the view height - // changes between passes (replaces v1 tea.WithAltScreen()). + // changes between passes. v := tea.NewView(content) v.AltScreen = true return v @@ -472,7 +471,7 @@ func (m *Model) renderFinalProgress() string { writeProgressRow(&s, m.progressBar.ViewAs(1.0), 100) s.WriteString("\n\n") - // Final timing โ€” the finished mirror of the live Pass 2 gauge cards. Time is + // Final timing, the finished mirror of the live Pass 2 gauge cards. Time is // the total time taken; Speed is the final realtime ratio (no live sparkline); // Size is the final file size; the live ETA card is repurposed as a Duration // card showing the source audio length, with the ๐ŸŽœ glyph in vivid red. @@ -541,7 +540,7 @@ func (m *Model) renderProgress() string { m.renderSpectrumAndStats(&s) } - // Help footer โ€” a single, always-present line so the box height is stable + // Help footer, a single always-present line so the box height is stable // across the Pass 1 โ†’ Pass 2 transition (no footer-driven jitter). Styled to // match the fire palette; rendered inside the bordered box so it stays within // the alt screen. Omitted from the post-exit completion summary. diff --git a/internal/ui/progress_test.go b/internal/ui/progress_test.go index a722fd4..1ac93b4 100644 --- a/internal/ui/progress_test.go +++ b/internal/ui/progress_test.go @@ -433,8 +433,8 @@ func TestSpinnerShownOnlyInDeadAir(t *testing.T) { }) t.Run("render with progress shows spinner in frame line", func(t *testing.T) { - // The spinner now leads the frame/source line (the film glyph was - // replaced), so it is present even with live progress data. + // The spinner leads the frame/source line, so it is present even with + // live progress data. m := NewModel(true) m.phase = PhaseRendering m.pass2StartTime = time.Now() diff --git a/internal/ui/spectrum.go b/internal/ui/spectrum.go index 2eae941..8b77c4d 100644 --- a/internal/ui/spectrum.go +++ b/internal/ui/spectrum.go @@ -13,8 +13,8 @@ import ( // tick advances the springs by exactly one repaint interval. Angular frequency // and damping are chosen so bars chase a new target quickly without overshoot. const ( - // spectrumSpringFreq sets how fast bars chase a new target. Raised from 8.0 - // so the spectrum tracks the audio livelier, closer to the Speed sparkline's + // spectrumSpringFreq sets how fast bars chase a new target. A high value keeps + // the spectrum tracking the audio livelier, close to the Speed sparkline's // energy; damping stays critical (1.0) so faster tracking adds no jitter. spectrumSpringFreq = 15.0 spectrumSpringDamping = 1.0 @@ -68,7 +68,7 @@ func renderSpectrum(barHeights []float64, width int) string { } // Normalise to the loudest current bar so the spectrum fills both rows each - // frame (per-frame auto-scaling, as the original did). + // frame (per-frame auto-scaling). maxHeight := slices.Max(barHeights) if maxHeight == 0 { maxHeight = 1.0 // Avoid division by zero diff --git a/internal/ui/spectrum_test.go b/internal/ui/spectrum_test.go index be493dd..9b76451 100644 --- a/internal/ui/spectrum_test.go +++ b/internal/ui/spectrum_test.go @@ -13,8 +13,8 @@ import ( // TestRenderSpectrumUsesThemeRamp verifies renderSpectrum colours its bars from // the theme-provided go-colorful Lab ramp: the ramp is consumed (FireSpectrum has // stops), block runes survive, the output carries ANSI colour codes, and a hot -// bar is styled differently from a cool bar โ€” proving the per-cell colour-index -// mapping still works across the new ramp length. +// bar is styled differently from a cool bar, proving the per-cell colour-index +// mapping works across the ramp length. func TestRenderSpectrumUsesThemeRamp(t *testing.T) { if len(theme.FireSpectrum) == 0 { t.Fatal("theme.FireSpectrum is empty; spectrum has no colours to draw from") diff --git a/internal/ui/summary.go b/internal/ui/summary.go index 40e4169..9003128 100644 --- a/internal/ui/summary.go +++ b/internal/ui/summary.go @@ -55,8 +55,8 @@ func (m *Model) renderComplete() string { s.WriteString("\n") // Pass 1 table: a borderless two-column label/value grid. The table handles - // column alignment in place of the old %-18s manual padding; it renders - // borderless so it nests inside the rounded-border box without double chrome. + // column alignment, and renders borderless so it nests inside the + // rounded-border box without double chrome. if m.audioProfile != nil { pass1 := summaryTable().StyleFunc(func(_, col int) lipgloss.Style { if col == 0 { @@ -81,8 +81,7 @@ func (m *Model) renderComplete() string { // Pass 2 table: label, duration, percentage and a rendered summary bar. The // bar is pre-rendered into a cell value (summaryBar.ViewAs renders once at - // completion, which the proposal permits). The table aligns the four columns - // in place of the old %-18s/%-6s manual padding. + // completion). The table aligns the four columns. pass2 := summaryTable().StyleFunc(func(_, col int) lipgloss.Style { switch col { case 0: diff --git a/internal/yuv/yuv.go b/internal/yuv/yuv.go index 24223da..4e19f1e 100644 --- a/internal/yuv/yuv.go +++ b/internal/yuv/yuv.go @@ -7,8 +7,11 @@ import ( "sync" ) -// YCbCr coefficients from Go's color/ycbcr.go (BT.601 standard). -// These are fixed-point values scaled by 65536 for integer arithmetic. +// YCbCr coefficients from Go's color/ycbcr.go (full-range JFIF BT.601). +// Full-range means Y, Cb and Cr span the whole 0-255 byte range, so there is no +// studio-swing headroom or footroom. The values are the real coefficients scaled +// by 65536, letting the conversion run in integer arithmetic and recover the +// result with a single >>16 shift instead of float multiplies on the hot path. const ( // Y coefficients (sum = 65536) YR = 19595 // 0.299 * 65536 @@ -33,7 +36,12 @@ func RGBToY(r, g, b int32) uint8 { return uint8((YR*r + YG*g + YB*b + 1<<15) >> 16) //nolint:gosec // result is clamped to 0-255 } -// RGBToCb converts RGB to Cb (blue-difference chroma) with branchless clamping. +// RGBToCb converts RGB to Cb (blue-difference chroma) with a branchless clamp. +// +// The 257<<15 bias centres chroma on 128 and adds the half-LSB rounding term. A +// valid result occupies the low 24 bits, so a set top byte means the value fell +// outside 0-255: ^(cb >> 31) then fills the byte with 0 for a negative overflow +// or 255 for a positive one, dodging a compare-and-branch on the hot path. // //go:inline func RGBToCb(r, g, b int32) uint8 { @@ -46,7 +54,8 @@ func RGBToCb(r, g, b int32) uint8 { return uint8(cb) //nolint:gosec // value is clamped by branch above } -// RGBToCr converts RGB to Cr (red-difference chroma) with branchless clamping. +// RGBToCr converts RGB to Cr (red-difference chroma) with a branchless clamp. +// The clamp works exactly as in RGBToCb. // //go:inline func RGBToCr(r, g, b int32) uint8 { @@ -64,9 +73,12 @@ type rowRange struct { startY, endY int } -// partitionRows splits height rows across numCPU workers using the same rules -// ParallelRows uses: even slices of rowsPerWorker, a < 1 fallback that gives -// one row per worker, and the last worker absorbing the remainder. +// partitionRows splits height rows across one worker per CPU. Each worker takes +// an even slice of rowsPerWorker and the last worker absorbs the remainder, so +// every row is covered exactly once with no gaps or overlap. When there are more +// CPUs than rows, rowsPerWorker floors to 0, so the fallback pins it to one row +// per worker and caps numCPU at height to avoid empty trailing ranges. +// ParallelRows and RowPool share this split, keeping their output identical. func partitionRows(height int) []rowRange { numCPU := runtime.NumCPU() rowsPerWorker := height / numCPU From 54dda06eaeef379476b76092dce9e6d33190669c Mon Sep 17 00:00:00 2001 From: Martin Wimpress Date: Sat, 4 Jul 2026 23:48:06 +0100 Subject: [PATCH 3/7] refactor: remove dead wrappers and share helpers - Remove redundant wrappers, fields, and comments in audio, CLI, encoder, and YUV code. - Reuse shared helpers in the CLI, renderer, and encoder paths. - Keep tests aligned with the shared code paths. Signed-off-by: Martin Wimpress --- cmd/jive-visualiser/main.go | 11 ++--------- cmd/jive-visualiser/main_test.go | 8 ++++---- internal/audio/reader.go | 14 -------------- internal/audio/reader_test.go | 23 ++++++++++++++++------- internal/cli/help.go | 14 +++----------- internal/encoder/encoder.go | 32 ++++---------------------------- internal/renderer/assets.go | 10 ++-------- internal/renderer/text.go | 8 +++++--- internal/renderer/thumbnail.go | 3 ++- internal/yuv/yuv.go | 6 ------ 10 files changed, 38 insertions(+), 91 deletions(-) diff --git a/cmd/jive-visualiser/main.go b/cmd/jive-visualiser/main.go index ddc50bc..cb8ef2e 100644 --- a/cmd/jive-visualiser/main.go +++ b/cmd/jive-visualiser/main.go @@ -49,7 +49,7 @@ func main() { kong.Description("Spin your podcast .wav into a groovy MP4 visualiser."), kong.Vars{"version": version}, kong.UsageOnError(), - kong.Help(cli.StyledHelpPrinter(kong.HelpOptions{Compact: true})), + kong.Help(cli.StyledHelpPrinter()), ) if CLI.Version { @@ -331,13 +331,6 @@ func convertAndWriteAudio(write func([]float32) error, src []float64, n int, ste return write(monoBuf[:n]) } -// writeAudioPrefill converts and writes every one of the n prefill samples; -// truncating to samplesPerFrame here would silently drop audio from the start -// of the stream. -func writeAudioPrefill(write func([]float32) error, fftBuffer []float64, n int, stereo bool, monoBuf, stereoBuf []float32) error { - return convertAndWriteAudio(write, fftBuffer, n, stereo, monoBuf, stereoBuf) -} - // audioConvBufLen returns the length the audio conversion buffers need: they // must hold the whole FFT prefill (config.FFTSize samples), which exceeds // samplesPerFrame at common sample rates; at high rates samplesPerFrame is @@ -513,7 +506,7 @@ func runPass2(p *tea.Program, profile *audio.Profile, cfg pass2Config) { // the encoder absorbs the surplus beyond frame 0. Reuse the conversion // buffers: WriteAudioSamples copies into the FIFO and retains no reference, // and the buffers are overwritten before each later use in the render loop. - if err := writeAudioPrefill(enc.WriteAudioSamples, fftBuffer, n, stereo, audioSamples, stereoSamples); err != nil { + if err := convertAndWriteAudio(enc.WriteAudioSamples, fftBuffer, n, stereo, audioSamples, stereoSamples); err != nil { fail(fmt.Errorf("writing initial audio: %w", err)) return } diff --git a/cmd/jive-visualiser/main_test.go b/cmd/jive-visualiser/main_test.go index 68ead27..3ff5b69 100644 --- a/cmd/jive-visualiser/main_test.go +++ b/cmd/jive-visualiser/main_test.go @@ -9,8 +9,8 @@ import ( // TestPrefillWritesWholeBuffer asserts the whole FFT prefill (all n samples // FillFFTBuffer returned) reaches the encoder, not just one frame's worth. // Truncating to samplesPerFrame dropped ~13 ms of audio at 44.1 kHz. It pins -// writeAudioPrefill, the function the runPass2 call site uses, so a -// regression at that seam fails the suite. +// convertAndWriteAudio, the function the runPass2 call site uses, so a +// regression in that path fails the suite. func TestPrefillWritesWholeBuffer(t *testing.T) { // 44.1 kHz: samplesPerFrame (1470) is smaller than the FFT prefill, the // case where truncation loses audio. @@ -40,8 +40,8 @@ func TestPrefillWritesWholeBuffer(t *testing.T) { } monoBuf := make([]float32, convBufLen) stereoBuf := make([]float32, convBufLen*2) - if err := writeAudioPrefill(write, src, config.FFTSize, tc.stereo, monoBuf, stereoBuf); err != nil { - t.Fatalf("writeAudioPrefill: %v", err) + if err := convertAndWriteAudio(write, src, config.FFTSize, tc.stereo, monoBuf, stereoBuf); err != nil { + t.Fatalf("convertAndWriteAudio: %v", err) } if got != tc.want { t.Errorf("prefill wrote %d samples, want %d", got, tc.want) diff --git a/internal/audio/reader.go b/internal/audio/reader.go index b3accb9..75d7e88 100644 --- a/internal/audio/reader.go +++ b/internal/audio/reader.go @@ -29,7 +29,6 @@ type StreamingReader struct { packet *ffmpeg.AVPacket frame *ffmpeg.AVFrame sampleRate int - channels int // swr converts each decoded frame to packed mono float64. outLayoutFrame // owns the mono output channel layout passed to swr; outPlanes is the @@ -99,7 +98,6 @@ func NewStreamingReader(filename string) (*StreamingReader, error) { } d.sampleRate = d.codecCtx.SampleRate() - d.channels = d.codecCtx.ChLayout().NbChannels() d.packet = ffmpeg.AVPacketAlloc() if d.packet == nil { @@ -186,18 +184,6 @@ func (d *StreamingReader) growOutputBuffer(n int) error { return nil } -// ReadChunk reads the next chunk of samples as float64. -// Multi-channel input is automatically downmixed to mono. -// Returns io.EOF when no more samples are available. -func (d *StreamingReader) ReadChunk(numSamples int) ([]float64, error) { - result := make([]float64, numSamples) - n, err := d.ReadInto(result) - if err != nil { - return nil, err - } - return result[:n], nil -} - // ReadInto fills dst with the next samples as float64, decoding more from the // stream as needed, and returns the number of samples written. Samples are // copied straight into dst with no intermediate allocation. Multi-channel input diff --git a/internal/audio/reader_test.go b/internal/audio/reader_test.go index 78c6e9f..f09b1c8 100644 --- a/internal/audio/reader_test.go +++ b/internal/audio/reader_test.go @@ -37,7 +37,7 @@ func TestNewStreamingReaderInvalidFile(t *testing.T) { } } -func TestStreamingReaderReadChunk(t *testing.T) { +func TestStreamingReaderReadInto(t *testing.T) { reader, err := NewStreamingReader("../../testdata/LMP0.mp3") if err != nil { t.Fatalf("Failed to create streaming reader: %v", err) @@ -45,10 +45,12 @@ func TestStreamingReaderReadChunk(t *testing.T) { defer reader.Close() // Read a chunk of 2048 samples (MP3 may return less due to frame boundaries) - chunk, err := reader.ReadChunk(2048) + buf := make([]float64, 2048) + n, err := reader.ReadInto(buf) if err != nil { t.Fatalf("Failed to read chunk: %v", err) } + chunk := buf[:n] if len(chunk) == 0 { t.Errorf("Expected non-empty chunk, got %d samples", len(chunk)) @@ -78,15 +80,17 @@ func TestStreamingReaderMultipleChunks(t *testing.T) { chunkSize := 2048 totalRead := int64(0) chunkCount := 0 + buf := make([]float64, chunkSize) for { - chunk, err := reader.ReadChunk(chunkSize) + n, err := reader.ReadInto(buf) if errors.Is(err, io.EOF) { break } if err != nil { t.Fatalf("Error reading chunk %d: %v", chunkCount, err) } + chunk := buf[:n] if len(chunk) > chunkSize { t.Errorf("Chunk %d is larger than requested: %d > %d", chunkCount, len(chunk), chunkSize) @@ -123,9 +127,10 @@ func TestStreamingReaderEOF(t *testing.T) { // Read all samples chunkSize := 4096 + buf := make([]float64, chunkSize) for { - _, err := reader.ReadChunk(chunkSize) + _, err := reader.ReadInto(buf) if errors.Is(err, io.EOF) { // Expected EOF break @@ -136,7 +141,7 @@ func TestStreamingReaderEOF(t *testing.T) { } // Try reading again - should get EOF immediately - _, err = reader.ReadChunk(chunkSize) + _, err = reader.ReadInto(buf) if !errors.Is(err, io.EOF) { t.Errorf("Expected EOF on second read past end, got: %v", err) } @@ -173,14 +178,16 @@ func TestStreamingReaderMultipleReads(t *testing.T) { var samples1 []float64 chunkSize := 2048 + buf1 := make([]float64, chunkSize) for { - chunk, err := reader1.ReadChunk(chunkSize) + n, err := reader1.ReadInto(buf1) if errors.Is(err, io.EOF) { break } if err != nil { t.Fatalf("Error reading from first reader: %v", err) } + chunk := buf1[:n] samples1 = append(samples1, chunk...) } @@ -193,14 +200,16 @@ func TestStreamingReaderMultipleReads(t *testing.T) { var samples2 []float64 chunkSize2 := 4096 + buf2 := make([]float64, chunkSize2) for { - chunk, err := reader2.ReadChunk(chunkSize2) + n, err := reader2.ReadInto(buf2) if errors.Is(err, io.EOF) { break } if err != nil { t.Fatalf("Error reading from second reader: %v", err) } + chunk := buf2[:n] samples2 = append(samples2, chunk...) } diff --git a/internal/cli/help.go b/internal/cli/help.go index 04a79a3..b988d56 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -5,7 +5,6 @@ import ( "strings" "charm.land/lipgloss/v2" - "charm.land/lipgloss/v2/table" "github.com/alecthomas/kong" "github.com/linuxmatters/jive-visualiser/internal/theme" ) @@ -42,7 +41,7 @@ var ( // StyledHelpPrinter returns a Kong help printer that renders usage, arguments, // and flags with the Lipgloss fire theme. -func StyledHelpPrinter(options kong.HelpOptions) kong.HelpPrinter { +func StyledHelpPrinter() kong.HelpPrinter { return kong.HelpPrinter(func(options kong.HelpOptions, ctx *kong.Context) error { var sb strings.Builder @@ -84,16 +83,9 @@ func StyledHelpPrinter(options kong.HelpOptions) kong.HelpPrinter { }) } -// helpTable returns a borderless table used purely for column alignment, so the -// name column (flags or arguments) lines up regardless of name length. Borders -// and column dividers are off; the StyleFunc keys on the column. -func helpTable() *table.Table { - return theme.BorderlessTable() -} - // argumentTable renders parsed arguments into aligned name/help columns. func argumentTable(args []argument) string { - t := helpTable().StyleFunc(func(_, col int) lipgloss.Style { + t := theme.BorderlessTable().StyleFunc(func(_, col int) lipgloss.Style { if col == 0 { return helpArgStyle.PaddingLeft(2).PaddingRight(2) } @@ -108,7 +100,7 @@ func argumentTable(args []argument) string { // flagTable renders parsed flags into aligned name/help columns, appending the // default-value suffix to the help cell. func flagTable(flags []flag) string { - t := helpTable().StyleFunc(func(_, col int) lipgloss.Style { + t := theme.BorderlessTable().StyleFunc(func(_, col int) lipgloss.Style { if col == 0 { return helpFlagStyle.PaddingLeft(2).PaddingRight(2) } diff --git a/internal/encoder/encoder.go b/internal/encoder/encoder.go index bc6aaa8..ef7b12a 100644 --- a/internal/encoder/encoder.go +++ b/internal/encoder/encoder.go @@ -126,11 +126,8 @@ func (f *avAudioFIFO) write(samples []float32) error { // size returns the number of samples per channel currently buffered. func (f *avAudioFIFO) size() (int, error) { ret, err := ffmpeg.AVAudioFifoSize(f.fifo) - if err != nil { - return 0, fmt.Errorf("query audio FIFO size: %w", err) - } - if ret < 0 { - return 0, fmt.Errorf("query audio FIFO size: %w", ffmpeg.WrapErr(ret)) + if err := checkFFmpeg(ret, err, "query audio FIFO size"); err != nil { + return 0, err } return ret, nil } @@ -1110,29 +1107,8 @@ func (e *Encoder) Close() error { if err := checkFFmpeg(ret, err, "flush video encoder"); err != nil { errs = append(errs, err) } - - // Drain remaining packets, reusing the shared e.pkt (freed once below). - // Break on any error other than EOF/EAGAIN so a persistent receive - // error cannot spin this loop forever. - pkt := e.pkt - for { - ret, err := ffmpeg.AVCodecReceivePacket(e.videoCodec, pkt) - if errors.Is(err, ffmpeg.AVErrorEOF) || errors.Is(err, ffmpeg.EAgain) { - break - } - if err := checkFFmpeg(ret, err, "receive flushed packet"); err != nil { - errs = append(errs, err) - break - } - - pkt.SetStreamIndex(e.videoStream.Index()) - ffmpeg.AVPacketRescaleTs(pkt, e.videoCodec.TimeBase(), e.videoStream.TimeBase()) - ret, err = ffmpeg.AVInterleavedWriteFrame(e.formatCtx, pkt) - ffmpeg.AVPacketUnref(pkt) - if err := checkFFmpeg(ret, err, "write flushed packet"); err != nil { - errs = append(errs, err) - break - } + if err := e.receiveAndWriteVideoPackets(); err != nil { + errs = append(errs, err) } } diff --git a/internal/renderer/assets.go b/internal/renderer/assets.go index 7810345..0a82504 100644 --- a/internal/renderer/assets.go +++ b/internal/renderer/assets.go @@ -75,10 +75,7 @@ func LoadFont(size float64) (font.Face, error) { // DrawCenterText draws text centred horizontally at the specified Y position. func DrawCenterText(img *image.RGBA, face font.Face, text string, centerY int, textColor color.RGBA) { d := newTextDrawer(img, face, textColor) - - bounds, _ := d.BoundString(text) - textWidth := (bounds.Max.X - bounds.Min.X).Ceil() - textHeight := (bounds.Max.Y - bounds.Min.Y).Ceil() + textWidth, textHeight := measureDrawerText(d, text) // DrawString places the baseline at y, so offset below centre to visually // centre the glyph block. @@ -92,10 +89,7 @@ func DrawCenterText(img *image.RGBA, face font.Face, text string, centerY int, t // DrawEpisodeNumber draws the episode number in the top right corner func DrawEpisodeNumber(img *image.RGBA, face font.Face, episodeNum string, textColor color.RGBA) { d := newTextDrawer(img, face, textColor) - - bounds, _ := d.BoundString(episodeNum) - textWidth := (bounds.Max.X - bounds.Min.X).Ceil() - textHeight := (bounds.Max.Y - bounds.Min.Y).Ceil() + textWidth, textHeight := measureDrawerText(d, episodeNum) // Top-right corner, inset 30px from the edges. offset := 30 diff --git a/internal/renderer/text.go b/internal/renderer/text.go index 4796a1e..e54a911 100644 --- a/internal/renderer/text.go +++ b/internal/renderer/text.go @@ -17,10 +17,12 @@ func newTextDrawer(img *image.RGBA, face font.Face, col color.RGBA) *font.Drawer } } -// measureStringWidth returns the pixel width of text as rendered by the drawer. -func measureStringWidth(d *font.Drawer, text string) int { +// measureDrawerText returns the pixel width and height of text as rendered by the drawer. +func measureDrawerText(d *font.Drawer, text string) (int, int) { bounds, _ := d.BoundString(text) - return (bounds.Max.X - bounds.Min.X).Ceil() + width := (bounds.Max.X - bounds.Min.X).Ceil() + height := (bounds.Max.Y - bounds.Min.Y).Ceil() + return width, height } // measureTextBounds returns the pixel width and full bounds of text as rendered by the given face. diff --git a/internal/renderer/thumbnail.go b/internal/renderer/thumbnail.go index b8504f3..98aa7d7 100644 --- a/internal/renderer/thumbnail.go +++ b/internal/renderer/thumbnail.go @@ -262,7 +262,8 @@ func drawCenteredLineOnTemp(img *image.RGBA, face font.Face, text string, imgWid } d := newTextDrawer(img, face, getThumbnailTextColor(runtimeConfig)) - textWidth := measureStringWidth(d, text) + bounds, _ := d.BoundString(text) + textWidth := (bounds.Max.X - bounds.Min.X).Ceil() // Centre horizontally. x := (imgWidth - textWidth) / 2 diff --git a/internal/yuv/yuv.go b/internal/yuv/yuv.go index 4e19f1e..5c0ddad 100644 --- a/internal/yuv/yuv.go +++ b/internal/yuv/yuv.go @@ -30,8 +30,6 @@ const ( ) // RGBToY converts RGB to Y (luma) component. -// -//go:inline func RGBToY(r, g, b int32) uint8 { return uint8((YR*r + YG*g + YB*b + 1<<15) >> 16) //nolint:gosec // result is clamped to 0-255 } @@ -42,8 +40,6 @@ func RGBToY(r, g, b int32) uint8 { // valid result occupies the low 24 bits, so a set top byte means the value fell // outside 0-255: ^(cb >> 31) then fills the byte with 0 for a negative overflow // or 255 for a positive one, dodging a compare-and-branch on the hot path. -// -//go:inline func RGBToCb(r, g, b int32) uint8 { cb := CbR*r + CbG*g + CbB*b + 257<<15 if uint32(cb)&0xff000000 == 0 { //nolint:gosec // intentional bit manipulation @@ -56,8 +52,6 @@ func RGBToCb(r, g, b int32) uint8 { // RGBToCr converts RGB to Cr (red-difference chroma) with a branchless clamp. // The clamp works exactly as in RGBToCb. -// -//go:inline func RGBToCr(r, g, b int32) uint8 { cr := CrR*r + CrG*g + CrB*b + 257<<15 if uint32(cr)&0xff000000 == 0 { //nolint:gosec // intentional bit manipulation From 1bc12b7e80b9f72053061c3913c0853fa3f0640d Mon Sep 17 00:00:00 2001 From: Martin Wimpress Date: Sun, 5 Jul 2026 01:12:45 +0100 Subject: [PATCH 4/7] refactor: split encoder, pass 2, and UI helpers - Move hardware encoder metadata into a registry and tighten CLI parsing. - Extract Pass 2 runner helpers for setup, asset loading, and progress updates. - Split encoder audio and video setup, and separate UI state from view helpers. - Add coverage for the refactor and refresh the architecture notes. Signed-off-by: Martin Wimpress --- AGENTS.md | 20 +- cmd/jive-visualiser/main.go | 375 ++-------- cmd/jive-visualiser/main_test.go | 268 +++++++ cmd/jive-visualiser/pass2.go | 401 +++++++++++ docs/ARCHITECTURE.md | 29 +- internal/encoder/audio_encoder.go | 419 +++++++++++ internal/encoder/encoder.go | 1081 ++--------------------------- internal/encoder/encoder_test.go | 212 ++++++ internal/encoder/hwaccel.go | 305 +++++--- internal/encoder/hwaccel_test.go | 181 +++++ internal/encoder/video_encoder.go | 570 +++++++++++++++ internal/ui/boxwidth_test.go | 4 +- internal/ui/progress.go | 281 ++++---- internal/ui/spectrum.go | 40 +- internal/ui/spectrum_test.go | 6 +- internal/ui/summary.go | 154 ++-- 16 files changed, 2702 insertions(+), 1644 deletions(-) create mode 100644 cmd/jive-visualiser/pass2.go create mode 100644 internal/encoder/audio_encoder.go create mode 100644 internal/encoder/video_encoder.go diff --git a/AGENTS.md b/AGENTS.md index a346628..e5ec96a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,20 +19,23 @@ - Memory-efficient: ~50MB footprint for 30-minute audio vs 600MB for single-pass ### Key Modules -- `cmd/jive-visualiser/main.go` โ€” CLI entry, 2-pass coordinator +- `cmd/jive-visualiser/main.go` - CLI entry and Pass 1 coordinator +- `cmd/jive-visualiser/pass2.go` - Pass 2 rendering and encoding runner - `internal/audio/` โ€” `StreamingReader` (reader.go) chunk-based decode, FFT analysis -- `internal/encoder/` โ€” ffmpeg-statigo wrapper, RGBโ†’YUV conversion, FIFO buffer -- `internal/yuv/` โ€” YCbCr coefficients, `RGBToY`/`RGBToCb`/`RGBToCr`, `ParallelRows` +- `internal/encoder/` - ffmpeg-statigo muxer facade, video/audio helpers, RGBโ†’YUV conversion, FIFO buffer +- `internal/yuv/` - YCbCr coefficients, `RGBToY`/`RGBToCb`/`RGBToCr`, `ParallelRows`, `RowPool` - `internal/renderer/` โ€” Frame generation, bar drawing, thumbnail -- `internal/ui/` โ€” Bubbletea v2 TUI (unified progress.go for both passes) +- `internal/ui/` - Bubbletea v2 TUI state, spectrum, preview, and summary views - `internal/config/` โ€” Constants (dimensions, FFT params, colours) +- `internal/theme/` - Terminal colour theme +- `internal/cli/` - Kong CLI helpers and styled help ## FFmpeg Integration - All FFmpeg access through `third_party/ffmpeg-statigo` submodule (FFmpeg 8.0 static bindings) - `*.gen.go` files in submodule are auto-generated โ€” do not edit - Audio decoding: `internal/audio/reader.go` โ€” `NewStreamingReader` returns `*StreamingReader` -- Video/audio encoding: `internal/encoder/encoder.go` wraps libx264/AAC +- Video/audio encoding: `internal/encoder/encoder.go` exposes the public facade; `audio_encoder.go` and `video_encoder.go` own codec setup ## Charm TUI (v2) @@ -49,11 +52,11 @@ - FFT size: 2048 samples (Hanning window) - 64 frequency bars with linear (uniform) frequency binning; logarithmic scaling applies to amplitude only - Harmonica spring peak-hold bar dynamics: each bar rises instantly to any new peak, then springs back toward the live level. Spring params: frequency `6.0`, damping `1.0`, delta `1/FPS`, gain `2.0` (replaces the amplitude lift the old CAVA integrator provided) -- Audio frame size mismatch handled by FFmpeg's `AVAudioFifo` (in `internal/encoder/encoder.go`; FFT needs 2048, AAC expects 1024) +- Audio frame size mismatch handled by FFmpeg's `AVAudioFifo` (in `internal/encoder/audio_encoder.go`; FFT needs 2048, AAC expects 1024) ## Performance Patterns -- RGBโ†’YUV conversion in `encoder/frame.go` parallelised across CPU cores via `yuv.ParallelRows` (13.2ร— faster than swscale) +- RGBโ†’YUV conversion in `encoder/frame.go` parallelised across CPU cores via `yuv.RowPool` (13.2ร— faster than swscale) - `convertRGBAToYUV` (YUV420P) and `convertRGBAToNV12` (NV12) in `encoder/frame.go` are intentionally kept as separate functions despite near-identical structure โ€” the hot-path duplication avoids a callback/interface indirection that would hurt throughput; do not refactor into a shared helper (shared low-level primitives live in `internal/yuv`) - Frame rendering uses symmetric mirroring (draw 1/4 pixels, mirror 3ร—) - Pre-computed intensity/colour tables in `renderer/frame.go` @@ -84,7 +87,8 @@ - Gradient/alpha tables: pre-computed in `NewFrame()` ### Changing UI output -- Unified progress UI: `internal/ui/progress.go` (handles both passes) +- Progress UI state: `internal/ui/progress.go` (handles both passes) +- Spectrum and completion helpers: `internal/ui/spectrum.go`, `internal/ui/summary.go` - Message types: `AnalysisProgress`, `AnalysisComplete`, `RenderProgress`, `RenderComplete` - Audio profile display persists from Pass 1 through Pass 2 - Video preview: `internal/ui/preview.go` diff --git a/cmd/jive-visualiser/main.go b/cmd/jive-visualiser/main.go index cb8ef2e..715d0eb 100644 --- a/cmd/jive-visualiser/main.go +++ b/cmd/jive-visualiser/main.go @@ -1,18 +1,13 @@ package main import ( - "errors" "fmt" - "image" - "io" - "math" "os" "strings" "time" tea "charm.land/bubbletea/v2" "github.com/alecthomas/kong" - "github.com/charmbracelet/harmonica" "github.com/linuxmatters/jive-visualiser/internal/audio" "github.com/linuxmatters/jive-visualiser/internal/cli" "github.com/linuxmatters/jive-visualiser/internal/config" @@ -93,17 +88,9 @@ func main() { os.Exit(1) } - validEncoders := map[string]encoder.HWAccelType{ - "auto": encoder.HWAccelAuto, - "nvenc": encoder.HWAccelNVENC, - "qsv": encoder.HWAccelQSV, - "vaapi": encoder.HWAccelVAAPI, - "vulkan": encoder.HWAccelVulkan, - "software": encoder.HWAccelNone, - } - hwAccelType, ok := validEncoders[CLI.Encoder] - if !ok { - cli.PrintError(fmt.Sprintf("invalid --encoder value: %s (must be auto, nvenc, qsv, vaapi, vulkan, or software)", CLI.Encoder)) + hwAccelType, err := parseEncoderFlag(CLI.Encoder) + if err != nil { + cli.PrintError(err.Error()) os.Exit(1) } @@ -177,6 +164,24 @@ func main() { generateVideo(inputFile, outputFile, channels, noPreview, hwAccelType, runtimeConfig, meta) } +func parseEncoderFlag(value string) (encoder.HWAccelType, error) { + hwAccelType, ok := encoder.HWAccelTypeForCLIName(value) + if !ok { + return "", fmt.Errorf("invalid --encoder value: %s (must be %s)", value, formatEncoderNames(encoder.ValidCLIEncoderNames())) + } + return hwAccelType, nil +} + +func formatEncoderNames(names []string) string { + if len(names) == 0 { + return "" + } + if len(names) == 1 { + return names[0] + } + return fmt.Sprintf("%s, or %s", strings.Join(names[:len(names)-1], ", "), names[len(names)-1]) +} + func generateVideo(inputFile string, outputFile string, channels int, noPreview bool, hwAccel encoder.HWAccelType, runtimeConfig *config.RuntimeConfig, meta renderer.PodcastMeta) { overallStartTime := time.Now() @@ -343,114 +348,35 @@ func audioConvBufLen(samplesPerFrame int) int { // failed to load and was dropped) and delivers them on the RenderComplete // message so the caller can print them after the Bubbletea alt screen exits. func runPass2(p *tea.Program, profile *audio.Profile, cfg pass2Config) { - var warnings []string - // fail delivers a fatal Pass 2 error on the RenderComplete message so the - // caller can print it (and exit non-zero) after the alt screen closes. - fail := func(err error) { - p.Send(ui.RenderComplete{Err: err, AssetWarnings: warnings}) - } - reader, err := audio.NewStreamingReader(cfg.inputFile) - if err != nil { - fail(fmt.Errorf("opening audio stream: %w", err)) - return - } - defer reader.Close() - - enc, err := encoder.New(encoder.Config{ - OutputPath: cfg.outputFile, - Width: config.Width, - Height: config.Height, - Framerate: config.FPS, - SampleRate: reader.SampleRate(), - AudioChannels: cfg.channels, - HWAccel: cfg.hwAccel, - }) - if err != nil { - fail(fmt.Errorf("creating encoder: %w", err)) + runner := newPass2Runner(p, profile, cfg) + + if err := runner.setupReader(); err != nil { + runner.fail(err) return } + defer runner.reader.Close() - if err = enc.Initialize(); err != nil { - fail(fmt.Errorf("initialising encoder: %w", err)) + if err := runner.setupEncoder(); err != nil { + runner.fail(err) return } - defer enc.Close() - - // Load background image (custom or embedded). A load failure is non-fatal: - // the renderer tolerates a nil background, but warn so the dropped asset is - // not silent (a malformed --background-image otherwise vanishes without a - // trace). - bgImage, err := renderer.LoadBackgroundImage(cfg.runtimeConfig) - if err != nil { - bgImage = nil - if _, isCustom := cfg.runtimeConfig.GetBackgroundImagePath(); isCustom { - warnings = append(warnings, fmt.Sprintf("could not load background image, rendering without it: %v", err)) - } else { - warnings = append(warnings, fmt.Sprintf("could not load embedded default background, rendering without it: %v", err)) - } - } + defer runner.enc.Close() - // Load font for centre text (embedded). A nil face degrades gracefully, but - // a failed load of the embedded font signals an internal problem worth - // surfacing. - fontFace, err := renderer.LoadFont(48) - if err != nil { - fontFace = nil - warnings = append(warnings, fmt.Sprintf("could not load embedded font, rendering without centre text: %v", err)) - } + // Asset load failures are non-fatal: nil assets degrade gracefully, but warn + // so dropped assets are not silent. + runner.loadAssets() - processor, err := audio.NewProcessor() - if err != nil { - fail(fmt.Errorf("creating FFT processor: %w", err)) + if err := runner.setupProcessorAndFrame(); err != nil { + runner.fail(err) return } - defer processor.Close() - frame := renderer.NewFrame(bgImage, fontFace, cfg.meta, cfg.runtimeConfig) - - numFrames := profile.NumFrames + defer runner.processor.Close() - var totalVis, totalEncode, totalAudio time.Duration - renderStartTime := time.Now() - lastProgressUpdate := renderStartTime + runner.setupTimingAndDisplay() const progressUpdateInterval = 30 * time.Millisecond - // Codec display uses the output channel count (from CLI), not the input's. - audioSampleRate := reader.SampleRate() - audioChannelStr := "mono" - if cfg.channels == 2 { - audioChannelStr = "stereo" - } - audioCodecInfo := fmt.Sprintf("AAC %.1fใŽ‘ %s", float64(audioSampleRate)/1000.0, audioChannelStr) - - // Harmonica spring peak-hold state. Each bar rises INSTANTLY to a new high, - // then springs DOWN toward the raw level over subsequent frames. The spring - // delta is locked to the video frame interval (1/FPS) so the fall rate is - // framerate-independent. - prevBarHeights := make([]float64, config.NumBars) - const ( - harmonicaSpringFreq = 6.0 - harmonicaSpringDamping = 1.0 - // harmonicaGain lifts the spring bars to a fuller amplitude. The peak-hold - // path has no integrator, so bars otherwise peak at the raw scaled height - // and look short (the old leaky-integrator path had a steady-state gain of - // roughly 4.3x for free). The existing soft-knee compression caps the loud - // bars, so this keeps dynamic spread rather than flattening everything to - // full height. Tune for taste. - harmonicaGain = 2.0 - ) - harmonicaDelta := 1.0 / config.Framerate - harmonicaSprings := make([]harmonica.Spring, config.NumBars) - for i := range harmonicaSprings { - harmonicaSprings[i] = harmonica.NewSpring(harmonicaDelta, harmonicaSpringFreq, harmonicaSpringDamping) - } - harmonicaPos := make([]float64, config.NumBars) - harmonicaVel := make([]float64, config.NumBars) - - // Reusable buffers to avoid per-frame allocations in the render loop. - barHeights := make([]float64, config.NumBars) - rearrangedHeights := make([]float64, config.NumBars) - barHeightsCopy := make([]float64, config.NumBars) // For UI updates + runner.setupRenderState() // Double-buffered private RGBA images for the preview. The render loop reuses // the frame's internal image every iteration, so the UI goroutine must read a @@ -460,45 +386,15 @@ func runPass2(p *tea.Program, profile *audio.Profile, cfg pass2Config) { // between two buffers so the producer always fills the one the UI is not // reading. Allocated once here, only when preview is enabled, to keep it off // the hot path. - var previewImgs [2]*image.RGBA - previewIdx := 0 - if !cfg.noPreview { - previewImgs[0] = image.NewRGBA(image.Rect(0, 0, config.Width, config.Height)) - previewImgs[1] = image.NewRGBA(image.Rect(0, 0, config.Width, config.Height)) - } - - sensitivity := 1.0 + runner.setupPreviewBuffers() // Sliding buffer for FFT: we read samplesPerFrame but need FFTSize for FFT. // Derive from the file's actual sample rate so encoded audio and video // durations stay aligned for any input rate. - samplesPerFrame := reader.SampleRate() / config.FPS - fftBuffer := make([]float64, config.FFTSize) - - // Pre-allocate reusable buffers for audio processing (avoid per-frame - // allocations). See audioConvBufLen for the sizing rationale. - convBufLen := audioConvBufLen(samplesPerFrame) - newSamples := make([]float64, samplesPerFrame) - audioSamples := make([]float32, convBufLen) // The reader downmixes to mono. For stereo output the encoder expects // interleaved L,R pairs, so duplicate each mono sample into both channels via // this pre-allocated buffer (no per-frame allocation). - stereo := cfg.channels == 2 - var stereoSamples []float32 - if stereo { - stereoSamples = make([]float32, convBufLen*2) - } - - // Pre-fill buffer with first chunk - n, err := audio.FillFFTBuffer(reader, fftBuffer) - if err != nil { - fail(fmt.Errorf("reading initial audio chunk: %w", err)) - return - } - if n == 0 { - fail(errors.New("no audio data available")) - return - } + runner.setupAudioBuffers() // Write the whole prefill to the encoder. FillFFTBuffer consumed n samples // from the reader, so all n must reach the encoder or that audio is lost @@ -506,205 +402,46 @@ func runPass2(p *tea.Program, profile *audio.Profile, cfg pass2Config) { // the encoder absorbs the surplus beyond frame 0. Reuse the conversion // buffers: WriteAudioSamples copies into the FIFO and retains no reference, // and the buffers are overwritten before each later use in the render loop. - if err := convertAndWriteAudio(enc.WriteAudioSamples, fftBuffer, n, stereo, audioSamples, stereoSamples); err != nil { - fail(fmt.Errorf("writing initial audio: %w", err)) + if err := runner.prefillFFT(); err != nil { + runner.fail(err) return } // Process frames until we run out of audio frameNum := 0 - for frameNum < numFrames { - // Use current buffer for FFT - chunk := fftBuffer[:config.FFTSize] - - // === VISUALISATION TIMING START === - t0 := time.Now() - - coeffs := processor.ProcessChunk(chunk) - - // Bin magnitudes into bars using the optimal baseScale from Pass 1. - audio.BinFFT(coeffs, sensitivity, profile.OptimalBaseScale, barHeights) - - // Auto-sensitivity: detect overshoot, applying soft-knee compression to any - // bar above the threshold. - overshootDetected := false - - for i, h := range barHeights { - if h > config.OvershootThreshold { - overshootDetected = true - overshoot := h - config.OvershootThreshold - barHeights[i] = config.OvershootThreshold + overshoot*math.Exp(-overshoot/config.OvershootThreshold) - } - } - - if overshootDetected { - sensitivity *= config.SensitivityDecay - } else { - sensitivity *= config.SensitivityGrowth - } - - if sensitivity < config.SensitivityMin { - sensitivity = config.SensitivityMin - } - if sensitivity > config.SensitivityMax { - sensitivity = config.SensitivityMax - } - - // Scale normalised bar heights into pixel space. - actualAvailableSpace := float64(config.Height/2 - config.CenterGap/2) - availableHeight := actualAvailableSpace * config.MaxBarHeight - for i := range barHeights { - barHeights[i] *= availableHeight - } - - // Harmonica peak-hold dynamic. Each bar rises instantly to a new peak, then - // springs down toward the raw level. Writes into prevBarHeights so the - // downstream rearrange/draw pipeline stays unchanged. - for i := range barHeights { - // Apply the spring-path gain so bars reach a fuller amplitude; the - // soft-knee below caps the loud ones, preserving dynamic spread. - currentHeight := barHeights[i] * harmonicaGain - - if currentHeight >= harmonicaPos[i] { - // Instant rise to the new peak; reset velocity so the fall starts - // from rest. - harmonicaPos[i] = currentHeight - harmonicaVel[i] = 0 - } else { - harmonicaPos[i], harmonicaVel[i] = harmonicaSprings[i].Update( - harmonicaPos[i], harmonicaVel[i], currentHeight) - if harmonicaPos[i] < 0 { - harmonicaPos[i] = 0 - harmonicaVel[i] = 0 - } - } - - heldHeight := harmonicaPos[i] - - // Soft knee compression - if heldHeight > availableHeight { - overshoot := heldHeight - availableHeight - heldHeight = availableHeight + overshoot*math.Exp(-overshoot/availableHeight) - } - - prevBarHeights[i] = heldHeight - } - - audio.RearrangeFrequenciesCenterOut(prevBarHeights, rearrangedHeights) - - frame.Draw(rearrangedHeights) - totalVis += time.Since(t0) - // === VISUALISATION TIMING END === - - // === VIDEO ENCODING TIMING START === - t0 = time.Now() - img := frame.GetImage() - if err := enc.WriteFrameRGBA(img.Pix); err != nil { - fail(fmt.Errorf("encoding frame %d: %w", frameNum, err)) + for frameNum < runner.numFrames { + img := runner.renderFrame() + if err := runner.writeVideoFrame(frameNum, img); err != nil { + runner.fail(err) return } - totalEncode += time.Since(t0) - // === VIDEO ENCODING TIMING END === - // Throttled UI updates, outside the timed sections. - if time.Since(lastProgressUpdate) >= progressUpdateInterval { - lastProgressUpdate = time.Now() - elapsed := time.Since(renderStartTime) - - copy(barHeightsCopy, rearrangedHeights) - - // Actual on-disk file size, not an estimate. - var currentFileSize int64 - if fileInfo, err := os.Stat(cfg.outputFile); err == nil { - currentFileSize = fileInfo.Size() - } - - var frameData *image.RGBA - if !cfg.noPreview { - // Copy into the buffer the UI is not reading; the next frame.Draw - // mutates img and the next send reuses the other buffer. - previewImg := previewImgs[previewIdx] - copy(previewImg.Pix, img.Pix) - frameData = previewImg - previewIdx ^= 1 - } - - p.Send(ui.RenderProgress{ - Frame: frameNum + 1, - TotalFrames: numFrames, - Elapsed: elapsed, - BarHeights: barHeightsCopy, - FileSize: currentFileSize, - Sensitivity: sensitivity, - FrameData: frameData, - VideoCodec: fmt.Sprintf("H.264 %dร—%d", config.Width, config.Height), - AudioCodec: audioCodecInfo, - EncoderName: enc.EncoderName(), - }) - } + runner.sendProgressIfDue(frameNum, img, progressUpdateInterval) frameNum++ - // === AUDIO TIMING START === - // Read audio, encode, and shift the FFT buffer ready for the next frame. - t0 = time.Now() - nRead, readErr := audio.ReadNextFrame(reader, newSamples) - if readErr != nil { - if errors.Is(readErr, io.EOF) { - totalAudio += time.Since(t0) - break - } - fail(fmt.Errorf("reading audio: %w", readErr)) + hasAudio, err := runner.processNextAudioFrame(frameNum) + if err != nil { + runner.fail(err) return } - - // Convert this frame's float64 samples to float32 for the AAC encoder via - // the pre-allocated buffers, sliced to the actual length. For stereo the - // mono signal is duplicated into both interleaved channels. - if writeErr := convertAndWriteAudio(enc.WriteAudioSamples, newSamples, nRead, stereo, audioSamples, stereoSamples); writeErr != nil { - fail(fmt.Errorf("writing audio at frame %d: %w", frameNum, writeErr)) - return + if !hasAudio { + break } - audio.SlideFFTWindow(fftBuffer, newSamples, nRead) - totalAudio += time.Since(t0) - // === AUDIO TIMING END === } // Flush samples still in the FIFO after the last video frame is written. - if err := enc.FlushAudioEncoder(); err != nil { - fail(fmt.Errorf("flushing audio: %w", err)) + if err := runner.enc.FlushAudioEncoder(); err != nil { + runner.fail(fmt.Errorf("flushing audio: %w", err)) return } // A Close failure is fatal: the trailer never lands and the file is // truncated. - if err := enc.Close(); err != nil { - fail(fmt.Errorf("closing encoder: %w", err)) + if err := runner.closeEncoder(); err != nil { + runner.fail(err) return } - fileInfo, err := os.Stat(cfg.outputFile) - var actualFileSize int64 - if err == nil { - actualFileSize = fileInfo.Size() - } - - samplesProcessed := int64(profile.SampleRate) * int64(profile.Duration) - - overallTotalTime := time.Since(cfg.overallStartTime) - - p.Send(ui.RenderComplete{ - OutputFile: cfg.outputFile, - FileSize: actualFileSize, - TotalFrames: numFrames, - VisTime: totalVis, - EncodeTime: totalEncode, - AudioTime: totalAudio, - TotalTime: overallTotalTime, - ThumbnailTime: cfg.thumbnailDuration, - SamplesProcessed: samplesProcessed, - EncoderName: enc.EncoderName(), - EncoderIsHW: enc.IsHardware(), - AssetWarnings: warnings, - }) + runner.sendComplete() } diff --git a/cmd/jive-visualiser/main_test.go b/cmd/jive-visualiser/main_test.go index 3ff5b69..e75d0ea 100644 --- a/cmd/jive-visualiser/main_test.go +++ b/cmd/jive-visualiser/main_test.go @@ -1,11 +1,61 @@ package main import ( + "image" + "os" "testing" + "time" + "github.com/linuxmatters/jive-visualiser/internal/audio" "github.com/linuxmatters/jive-visualiser/internal/config" + "github.com/linuxmatters/jive-visualiser/internal/encoder" ) +func TestParseEncoderFlagAcceptsRegistryNames(t *testing.T) { + tests := map[string]encoder.HWAccelType{ + "auto": encoder.HWAccelAuto, + "nvenc": encoder.HWAccelNVENC, + "qsv": encoder.HWAccelQSV, + "vaapi": encoder.HWAccelVAAPI, + "vulkan": encoder.HWAccelVulkan, + "software": encoder.HWAccelNone, + } + + for name, want := range tests { + got, err := parseEncoderFlag(name) + if err != nil { + t.Fatalf("parseEncoderFlag(%q): %v", name, err) + } + if got != want { + t.Fatalf("parseEncoderFlag(%q) = %q, want %q", name, got, want) + } + } +} + +func TestParseEncoderFlagRejectsVideoToolbox(t *testing.T) { + _, err := parseEncoderFlag("videotoolbox") + if err == nil { + t.Fatal("expected VideoToolbox to be rejected") + } + + want := "invalid --encoder value: videotoolbox (must be auto, nvenc, qsv, vaapi, vulkan, or software)" + if err.Error() != want { + t.Fatalf("error = %q, want %q", err, want) + } +} + +func TestParseEncoderFlagInvalidError(t *testing.T) { + _, err := parseEncoderFlag("bogus") + if err == nil { + t.Fatal("expected invalid encoder error") + } + + want := "invalid --encoder value: bogus (must be auto, nvenc, qsv, vaapi, vulkan, or software)" + if err.Error() != want { + t.Fatalf("error = %q, want %q", err, want) + } +} + // TestPrefillWritesWholeBuffer asserts the whole FFT prefill (all n samples // FillFFTBuffer returned) reaches the encoder, not just one frame's worth. // Truncating to samplesPerFrame dropped ~13 ms of audio at 44.1 kHz. It pins @@ -53,6 +103,52 @@ func TestPrefillWritesWholeBuffer(t *testing.T) { } } +func TestConvertAndWriteAudioWritesConsumedSamples(t *testing.T) { + src := []float64{0.1, 0.2, 0.3, 0.4, 0.5} + + cases := []struct { + name string + stereo bool + want []float32 + }{ + { + name: "mono", + stereo: false, + want: []float32{0.1, 0.2, 0.3}, + }, + { + name: "stereo", + stereo: true, + want: []float32{0.1, 0.1, 0.2, 0.2, 0.3, 0.3}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var got []float32 + write := func(samples []float32) error { + got = append([]float32(nil), samples...) + return nil + } + monoBuf := make([]float32, len(src)) + stereoBuf := make([]float32, len(src)*2) + + if err := convertAndWriteAudio(write, src, 3, tc.stereo, monoBuf, stereoBuf); err != nil { + t.Fatalf("convertAndWriteAudio: %v", err) + } + + if len(got) != len(tc.want) { + t.Fatalf("wrote %d samples, want %d", len(got), len(tc.want)) + } + for i, want := range tc.want { + if got[i] != want { + t.Errorf("sample %d = %v, want %v", i, got[i], want) + } + } + }) + } +} + // TestAudioConvBufLen pins the conversion buffer sizing: the buffers must // hold the whole FFT prefill, and grow with samplesPerFrame when that is the // larger of the two. @@ -66,3 +162,175 @@ func TestAudioConvBufLen(t *testing.T) { t.Errorf("audioConvBufLen(3200) = %d, want 3200", got) } } + +func TestPass2ProgressMessageFieldsAndPreviewCopy(t *testing.T) { + src := image.NewRGBA(image.Rect(0, 0, config.Width, config.Height)) + src.Pix[0] = 99 + + runner := &pass2Runner{ + cfg: pass2Config{ + noPreview: false, + }, + enc: &encoder.Encoder{}, + numFrames: 42, + audioCodecInfo: "AAC 44.1ใŽ‘ mono", + sensitivity: 1.25, + rearrangedHeights: []float64{1, 2, 3}, + barHeightsCopy: make([]float64, 3), + previewImgs: [2]*image.RGBA{ + image.NewRGBA(image.Rect(0, 0, config.Width, config.Height)), + image.NewRGBA(image.Rect(0, 0, config.Width, config.Height)), + }, + } + + msg := runner.renderProgressMessage(4, src, 2*time.Second, 2048) + + if msg.Frame != 5 { + t.Errorf("Frame = %d, want 5", msg.Frame) + } + if msg.TotalFrames != 42 { + t.Errorf("TotalFrames = %d, want 42", msg.TotalFrames) + } + if msg.Elapsed != 2*time.Second { + t.Errorf("Elapsed = %v, want 2s", msg.Elapsed) + } + if msg.FileSize != 2048 { + t.Errorf("FileSize = %d, want 2048", msg.FileSize) + } + if msg.Sensitivity != 1.25 { + t.Errorf("Sensitivity = %v, want 1.25", msg.Sensitivity) + } + if msg.VideoCodec != "H.264 1280ร—720" { + t.Errorf("VideoCodec = %q, want H.264 1280ร—720", msg.VideoCodec) + } + if msg.AudioCodec != "AAC 44.1ใŽ‘ mono" { + t.Errorf("AudioCodec = %q, want AAC 44.1ใŽ‘ mono", msg.AudioCodec) + } + if msg.EncoderName != "libx264" { + t.Errorf("EncoderName = %q, want libx264", msg.EncoderName) + } + if msg.FrameData == nil { + t.Fatal("FrameData is nil") + } + if msg.FrameData == src { + t.Fatal("FrameData points at the source frame") + } + if got := msg.FrameData.Pix[0]; got != 99 { + t.Errorf("FrameData first byte = %d, want 99", got) + } + src.Pix[0] = 12 + if got := msg.FrameData.Pix[0]; got != 99 { + t.Errorf("FrameData changed after source mutation: %d, want 99", got) + } + if runner.previewIdx != 1 { + t.Errorf("previewIdx = %d, want 1", runner.previewIdx) + } + for i, want := range []float64{1, 2, 3} { + if msg.BarHeights[i] != want { + t.Errorf("BarHeights[%d] = %v, want %v", i, msg.BarHeights[i], want) + } + } +} + +func TestPass2ProgressMessageOmitsPreviewWhenDisabled(t *testing.T) { + runner := &pass2Runner{ + cfg: pass2Config{ + noPreview: true, + }, + enc: &encoder.Encoder{}, + barHeightsCopy: make([]float64, 1), + rearrangedHeights: []float64{1}, + } + msg := runner.renderProgressMessage(0, image.NewRGBA(image.Rect(0, 0, 1, 1)), time.Second, 0) + if msg.FrameData != nil { + t.Fatal("FrameData is set when preview is disabled") + } +} + +func TestPass2ProgressDueUsesInterval(t *testing.T) { + runner := &pass2Runner{lastProgressUpdate: time.Now()} + if runner.progressDue(30 * time.Millisecond) { + t.Fatal("progress is due before interval") + } + + runner.lastProgressUpdate = time.Now().Add(-60 * time.Millisecond) + if !runner.progressDue(30 * time.Millisecond) { + t.Fatal("progress is not due after interval") + } +} + +func TestPass2CurrentOutputFileSize(t *testing.T) { + outputFile := t.TempDir() + "/out.mp4" + if err := os.WriteFile(outputFile, []byte("12345"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + runner := &pass2Runner{cfg: pass2Config{outputFile: outputFile}} + if got := runner.currentOutputFileSize(); got != 5 { + t.Errorf("currentOutputFileSize() = %d, want 5", got) + } + + runner.cfg.outputFile = outputFile + ".missing" + if got := runner.currentOutputFileSize(); got != 0 { + t.Errorf("currentOutputFileSize() for missing file = %d, want 0", got) + } +} + +func TestPass2RenderCompleteMessageFields(t *testing.T) { + warnings := []string{"could not load embedded font"} + runner := &pass2Runner{ + profile: &audio.Profile{ + SampleRate: 48000, + Duration: 3, + }, + cfg: pass2Config{ + outputFile: "episode.mp4", + thumbnailDuration: 700 * time.Millisecond, + }, + enc: &encoder.Encoder{}, + numFrames: 90, + totalVis: 100 * time.Millisecond, + totalEncode: 200 * time.Millisecond, + totalAudio: 300 * time.Millisecond, + warnings: warnings, + } + + msg := runner.renderCompleteMessage(123456, 5*time.Second) + + if msg.OutputFile != "episode.mp4" { + t.Errorf("OutputFile = %q, want episode.mp4", msg.OutputFile) + } + if msg.FileSize != 123456 { + t.Errorf("FileSize = %d, want 123456", msg.FileSize) + } + if msg.TotalFrames != 90 { + t.Errorf("TotalFrames = %d, want 90", msg.TotalFrames) + } + if msg.VisTime != 100*time.Millisecond { + t.Errorf("VisTime = %v, want 100ms", msg.VisTime) + } + if msg.EncodeTime != 200*time.Millisecond { + t.Errorf("EncodeTime = %v, want 200ms", msg.EncodeTime) + } + if msg.AudioTime != 300*time.Millisecond { + t.Errorf("AudioTime = %v, want 300ms", msg.AudioTime) + } + if msg.TotalTime != 5*time.Second { + t.Errorf("TotalTime = %v, want 5s", msg.TotalTime) + } + if msg.ThumbnailTime != 700*time.Millisecond { + t.Errorf("ThumbnailTime = %v, want 700ms", msg.ThumbnailTime) + } + if msg.SamplesProcessed != 144000 { + t.Errorf("SamplesProcessed = %d, want 144000", msg.SamplesProcessed) + } + if msg.EncoderName != "libx264" { + t.Errorf("EncoderName = %q, want libx264", msg.EncoderName) + } + if msg.EncoderIsHW { + t.Fatal("EncoderIsHW = true, want false") + } + if len(msg.AssetWarnings) != 1 || msg.AssetWarnings[0] != warnings[0] { + t.Errorf("AssetWarnings = %v, want %v", msg.AssetWarnings, warnings) + } +} diff --git a/cmd/jive-visualiser/pass2.go b/cmd/jive-visualiser/pass2.go new file mode 100644 index 0000000..d8162ad --- /dev/null +++ b/cmd/jive-visualiser/pass2.go @@ -0,0 +1,401 @@ +package main + +import ( + "errors" + "fmt" + "image" + "io" + "math" + "os" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/harmonica" + "github.com/linuxmatters/jive-visualiser/internal/audio" + "github.com/linuxmatters/jive-visualiser/internal/config" + "github.com/linuxmatters/jive-visualiser/internal/encoder" + "github.com/linuxmatters/jive-visualiser/internal/renderer" + "github.com/linuxmatters/jive-visualiser/internal/ui" + "golang.org/x/image/font" +) + +type pass2Runner struct { + p *tea.Program + profile *audio.Profile + cfg pass2Config + + warnings []string + + reader *audio.StreamingReader + enc *encoder.Encoder + bgImage *image.RGBA + fontFace font.Face + processor *audio.Processor + frame *renderer.Frame + + numFrames int + totalVis time.Duration + totalEncode time.Duration + totalAudio time.Duration + renderStartTime time.Time + lastProgressUpdate time.Time + audioCodecInfo string + + prevBarHeights []float64 + harmonicaSprings []harmonica.Spring + harmonicaPos []float64 + harmonicaVel []float64 + barHeights []float64 + rearrangedHeights []float64 + barHeightsCopy []float64 + + previewImgs [2]*image.RGBA + previewIdx int + + sensitivity float64 + samplesPerFrame int + fftBuffer []float64 + newSamples []float64 + audioSamples []float32 + stereo bool + stereoSamples []float32 +} + +func newPass2Runner(p *tea.Program, profile *audio.Profile, cfg pass2Config) *pass2Runner { + return &pass2Runner{ + p: p, + profile: profile, + cfg: cfg, + numFrames: profile.NumFrames, + sensitivity: 1.0, + } +} + +func (r *pass2Runner) fail(err error) { + r.p.Send(ui.RenderComplete{Err: err, AssetWarnings: r.warnings}) +} + +func (r *pass2Runner) sendProgressIfDue(frameNum int, img *image.RGBA, interval time.Duration) { + if !r.progressDue(interval) { + return + } + + r.lastProgressUpdate = time.Now() + elapsed := time.Since(r.renderStartTime) + r.p.Send(r.renderProgressMessage(frameNum, img, elapsed, r.currentOutputFileSize())) +} + +func (r *pass2Runner) progressDue(interval time.Duration) bool { + return time.Since(r.lastProgressUpdate) >= interval +} + +func (r *pass2Runner) renderProgressMessage(frameNum int, img *image.RGBA, elapsed time.Duration, fileSize int64) ui.RenderProgress { + copy(r.barHeightsCopy, r.rearrangedHeights) + + return ui.RenderProgress{ + Frame: frameNum + 1, + TotalFrames: r.numFrames, + Elapsed: elapsed, + BarHeights: r.barHeightsCopy, + FileSize: fileSize, + Sensitivity: r.sensitivity, + FrameData: r.copyPreviewFrame(img), + VideoCodec: fmt.Sprintf("H.264 %dร—%d", config.Width, config.Height), + AudioCodec: r.audioCodecInfo, + EncoderName: r.enc.EncoderName(), + } +} + +func (r *pass2Runner) currentOutputFileSize() int64 { + fileInfo, err := os.Stat(r.cfg.outputFile) + if err != nil { + return 0 + } + return fileInfo.Size() +} + +func (r *pass2Runner) copyPreviewFrame(img *image.RGBA) *image.RGBA { + if r.cfg.noPreview { + return nil + } + + previewImg := r.previewImgs[r.previewIdx] + copy(previewImg.Pix, img.Pix) + r.previewIdx ^= 1 + return previewImg +} + +func (r *pass2Runner) closeEncoder() error { + if err := r.enc.Close(); err != nil { + return fmt.Errorf("closing encoder: %w", err) + } + return nil +} + +func (r *pass2Runner) sendComplete() { + r.p.Send(r.renderCompleteMessage(r.currentOutputFileSize(), time.Since(r.cfg.overallStartTime))) +} + +func (r *pass2Runner) renderCompleteMessage(fileSize int64, totalTime time.Duration) ui.RenderComplete { + return ui.RenderComplete{ + OutputFile: r.cfg.outputFile, + FileSize: fileSize, + TotalFrames: r.numFrames, + VisTime: r.totalVis, + EncodeTime: r.totalEncode, + AudioTime: r.totalAudio, + TotalTime: totalTime, + ThumbnailTime: r.cfg.thumbnailDuration, + SamplesProcessed: int64(r.profile.SampleRate) * int64(r.profile.Duration), + EncoderName: r.enc.EncoderName(), + EncoderIsHW: r.enc.IsHardware(), + AssetWarnings: r.warnings, + } +} + +func (r *pass2Runner) setupReader() error { + reader, err := audio.NewStreamingReader(r.cfg.inputFile) + if err != nil { + return fmt.Errorf("opening audio stream: %w", err) + } + r.reader = reader + return nil +} + +func (r *pass2Runner) setupEncoder() error { + enc, err := encoder.New(encoder.Config{ + OutputPath: r.cfg.outputFile, + Width: config.Width, + Height: config.Height, + Framerate: config.FPS, + SampleRate: r.reader.SampleRate(), + AudioChannels: r.cfg.channels, + HWAccel: r.cfg.hwAccel, + }) + if err != nil { + return fmt.Errorf("creating encoder: %w", err) + } + + if err = enc.Initialize(); err != nil { + return fmt.Errorf("initialising encoder: %w", err) + } + + r.enc = enc + return nil +} + +func (r *pass2Runner) loadAssets() { + bgImage, err := renderer.LoadBackgroundImage(r.cfg.runtimeConfig) + if err != nil { + bgImage = nil + if _, isCustom := r.cfg.runtimeConfig.GetBackgroundImagePath(); isCustom { + r.warnings = append(r.warnings, fmt.Sprintf("could not load background image, rendering without it: %v", err)) + } else { + r.warnings = append(r.warnings, fmt.Sprintf("could not load embedded default background, rendering without it: %v", err)) + } + } + r.bgImage = bgImage + + fontFace, err := renderer.LoadFont(48) + if err != nil { + fontFace = nil + r.warnings = append(r.warnings, fmt.Sprintf("could not load embedded font, rendering without centre text: %v", err)) + } + r.fontFace = fontFace +} + +func (r *pass2Runner) setupProcessorAndFrame() error { + processor, err := audio.NewProcessor() + if err != nil { + return fmt.Errorf("creating FFT processor: %w", err) + } + + r.processor = processor + r.frame = renderer.NewFrame(r.bgImage, r.fontFace, r.cfg.meta, r.cfg.runtimeConfig) + return nil +} + +func (r *pass2Runner) setupTimingAndDisplay() { + r.renderStartTime = time.Now() + r.lastProgressUpdate = r.renderStartTime + + audioSampleRate := r.reader.SampleRate() + audioChannelStr := "mono" + if r.cfg.channels == 2 { + audioChannelStr = "stereo" + } + r.audioCodecInfo = fmt.Sprintf("AAC %.1fใŽ‘ %s", float64(audioSampleRate)/1000.0, audioChannelStr) +} + +func (r *pass2Runner) setupRenderState() { + r.prevBarHeights = make([]float64, config.NumBars) + + const ( + harmonicaSpringFreq = 6.0 + harmonicaSpringDamping = 1.0 + ) + harmonicaDelta := 1.0 / config.Framerate + r.harmonicaSprings = make([]harmonica.Spring, config.NumBars) + for i := range r.harmonicaSprings { + r.harmonicaSprings[i] = harmonica.NewSpring(harmonicaDelta, harmonicaSpringFreq, harmonicaSpringDamping) + } + r.harmonicaPos = make([]float64, config.NumBars) + r.harmonicaVel = make([]float64, config.NumBars) + + r.barHeights = make([]float64, config.NumBars) + r.rearrangedHeights = make([]float64, config.NumBars) + r.barHeightsCopy = make([]float64, config.NumBars) +} + +func (r *pass2Runner) setupPreviewBuffers() { + if r.cfg.noPreview { + return + } + r.previewImgs[0] = image.NewRGBA(image.Rect(0, 0, config.Width, config.Height)) + r.previewImgs[1] = image.NewRGBA(image.Rect(0, 0, config.Width, config.Height)) +} + +func (r *pass2Runner) setupAudioBuffers() { + r.samplesPerFrame = r.reader.SampleRate() / config.FPS + r.fftBuffer = make([]float64, config.FFTSize) + + convBufLen := audioConvBufLen(r.samplesPerFrame) + r.newSamples = make([]float64, r.samplesPerFrame) + r.audioSamples = make([]float32, convBufLen) + r.stereo = r.cfg.channels == 2 + if r.stereo { + r.stereoSamples = make([]float32, convBufLen*2) + } +} + +func (r *pass2Runner) prefillFFT() error { + n, err := audio.FillFFTBuffer(r.reader, r.fftBuffer) + if err != nil { + return fmt.Errorf("reading initial audio chunk: %w", err) + } + if n == 0 { + return errors.New("no audio data available") + } + + if err := convertAndWriteAudio(r.enc.WriteAudioSamples, r.fftBuffer, n, r.stereo, r.audioSamples, r.stereoSamples); err != nil { + return fmt.Errorf("writing initial audio: %w", err) + } + return nil +} + +func (r *pass2Runner) renderFrame() *image.RGBA { + t0 := time.Now() + + r.processBars(r.fftBuffer[:config.FFTSize]) + r.frame.Draw(r.rearrangedHeights) + + r.totalVis += time.Since(t0) + return r.frame.GetImage() +} + +func (r *pass2Runner) processBars(chunk []float64) { + coeffs := r.processor.ProcessChunk(chunk) + audio.BinFFT(coeffs, r.sensitivity, r.profile.OptimalBaseScale, r.barHeights) + + r.applySensitivity() + availableHeight := r.scaleBars() + r.applySpringDynamics(availableHeight) + audio.RearrangeFrequenciesCenterOut(r.prevBarHeights, r.rearrangedHeights) +} + +func (r *pass2Runner) applySensitivity() { + overshootDetected := false + + for i, h := range r.barHeights { + if h > config.OvershootThreshold { + overshootDetected = true + overshoot := h - config.OvershootThreshold + r.barHeights[i] = config.OvershootThreshold + overshoot*math.Exp(-overshoot/config.OvershootThreshold) + } + } + + if overshootDetected { + r.sensitivity *= config.SensitivityDecay + } else { + r.sensitivity *= config.SensitivityGrowth + } + + if r.sensitivity < config.SensitivityMin { + r.sensitivity = config.SensitivityMin + } + if r.sensitivity > config.SensitivityMax { + r.sensitivity = config.SensitivityMax + } +} + +func (r *pass2Runner) scaleBars() float64 { + actualAvailableSpace := float64(config.Height/2 - config.CenterGap/2) + availableHeight := actualAvailableSpace * config.MaxBarHeight + for i := range r.barHeights { + r.barHeights[i] *= availableHeight + } + return availableHeight +} + +func (r *pass2Runner) applySpringDynamics(availableHeight float64) { + const harmonicaGain = 2.0 + + for i := range r.barHeights { + currentHeight := r.barHeights[i] * harmonicaGain + + if currentHeight >= r.harmonicaPos[i] { + r.harmonicaPos[i] = currentHeight + r.harmonicaVel[i] = 0 + } else { + r.harmonicaPos[i], r.harmonicaVel[i] = r.harmonicaSprings[i].Update( + r.harmonicaPos[i], r.harmonicaVel[i], currentHeight) + if r.harmonicaPos[i] < 0 { + r.harmonicaPos[i] = 0 + r.harmonicaVel[i] = 0 + } + } + + heldHeight := r.harmonicaPos[i] + if heldHeight > availableHeight { + overshoot := heldHeight - availableHeight + heldHeight = availableHeight + overshoot*math.Exp(-overshoot/availableHeight) + } + + r.prevBarHeights[i] = heldHeight + } +} + +func (r *pass2Runner) writeVideoFrame(frameNum int, img *image.RGBA) error { + t0 := time.Now() + if err := r.enc.WriteFrameRGBA(img.Pix); err != nil { + return fmt.Errorf("encoding frame %d: %w", frameNum, err) + } + r.totalEncode += time.Since(t0) + return nil +} + +func (r *pass2Runner) processNextAudioFrame(frameNum int) (bool, error) { + t0 := time.Now() + nRead, readErr := audio.ReadNextFrame(r.reader, r.newSamples) + if readErr != nil { + if errors.Is(readErr, io.EOF) { + r.totalAudio += time.Since(t0) + return false, nil + } + return false, fmt.Errorf("reading audio: %w", readErr) + } + + if err := r.writeAudioSamples(frameNum, nRead); err != nil { + return false, err + } + audio.SlideFFTWindow(r.fftBuffer, r.newSamples, nRead) + r.totalAudio += time.Since(t0) + return true, nil +} + +func (r *pass2Runner) writeAudioSamples(frameNum int, nRead int) error { + if err := convertAndWriteAudio(r.enc.WriteAudioSamples, r.newSamples, nRead, r.stereo, r.audioSamples, r.stereoSamples); err != nil { + return fmt.Errorf("writing audio at frame %d: %w", frameNum, err) + } + return nil +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5e82444..a401aa4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -65,7 +65,7 @@ Colourspace Conversion (path depends on encoder) โ”‚ โ”œโ”€ [Software] RGBA โ†’ YUV420P (Pure Go, parallelised) โ”‚ โ”œโ”€ Direct conversion skips intermediate RGB24 buffer - โ”‚ โ”œโ”€ Parallel row processing via internal/yuv.ParallelRows + โ”‚ โ”œโ”€ Parallel row processing via internal/yuv.RowPool โ”‚ โ””โ”€ ITU-R BT.601 coefficients from internal/yuv โ”‚ โ””โ”€ [Hardware] RGBA โ†’ NV12 (Pure Go, parallelised) @@ -92,7 +92,7 @@ MP4 Muxer (libavformat) ## Key Technical Choices ### Audio Frame Size Mismatch -FFT analysis requires 2048 samples for frequency resolution, but AAC encoder expects 1024 samples per frame. **Solution:** `AudioFIFO` in `encoder/encoder.go` buffers incoming audio samples and drains them in encoder-sized frames, decoupling the FFT chunk size from the AAC frame size. +FFT analysis requires 2048 samples for frequency resolution, but AAC encoder expects 1024 samples per frame. **Solution:** `AVAudioFifo` in `encoder/audio_encoder.go` buffers incoming audio samples and drains them in encoder-sized frames, decoupling the FFT chunk size from the AAC frame size. ### Hardware-Accelerated Encoding Automatic GPU encoder detection in `encoder/hwaccel.go`: @@ -101,6 +101,8 @@ Automatic GPU encoder detection in `encoder/hwaccel.go`: - **VideoToolbox** (macOS): Apple Silicon and Intel Mac hardware encoding - **Software fallback**: Optimised libx264 with `veryfast` preset when no GPU available +The hardware encoder registry stores probe, priority, pixel-format, and option metadata in one place. `--encoder` still accepts only `auto`, `nvenc`, `qsv`, `vaapi`, `vulkan`, and `software`. VideoToolbox remains available for macOS auto-selection and probe output, not as an explicit CLI value. + **Why RGBA for hardware encoders?** Initial implementation used CPU-side RGBโ†’YUV conversion for all encoders. Benchmarking showed hardware encoders were bottlenecked by CPU conversion overhead. Hardware encoders accept NV12 (semi-planar YUV) natively, so we convert RGBAโ†’NV12 on CPU and let the GPU handle encoding onlyโ€”avoiding the RGBโ†’YUVโ†’NV12 double conversion that would occur if we sent YUV420P. ### Colourspace Conversion @@ -108,10 +110,10 @@ Hot-path converters in `encoder/frame.go` (`convertRGBAToYUV`, `convertRGBAToNV1 - **RGBAโ†’YUV420P** (software encoder): Direct conversion skips intermediate RGB24 buffer allocation - **RGBAโ†’NV12** (hardware encoders): Semi-planar format for GPU upload -Both call shared BT.601 coefficient helpers and `ParallelRows` from `internal/yuv`. The two functions are kept deliberately separate despite near-identical structure โ€” the hot-path duplication avoids a callback/interface indirection that would hurt throughput. +Both call shared BT.601 coefficient helpers and `RowPool` from `internal/yuv`. The two functions stay separate because a callback or interface would slow the per-pixel loop. All converters share common characteristics: -- Parallel row processing across CPU cores via `internal/yuv.ParallelRows` +- Parallel row processing across CPU cores via `internal/yuv.RowPool` - Even/odd row separation eliminates per-pixel conditionals in inner loops - ITU-R BT.601 coefficients with fixed-point integer arithmetic (no floating-point in hot path) @@ -140,16 +142,19 @@ Preview renders via Unicode blocks (`โ–โ–‚โ–ƒโ–„โ–…โ–†โ–‡โ–ˆ`) using actual bar ## File Structure ``` -cmd/jive-visualiser/main.go โ†’ CLI entry, 2-pass coordinator +cmd/jive-visualiser/main.go โ†’ CLI entry and Pass 1 coordinator +cmd/jive-visualiser/pass2.go โ†’ Pass 2 rendering and encoding runner internal/audio/ โ†’ StreamingReader (chunk-based FFmpeg decode), FFT analysis -internal/encoder/ โ†’ ffmpeg-statigo wrapper, RGBโ†’YUV conversion, FIFO buffer - โ”œโ”€ encoder.go โ†’ Video/audio encoding, frame submission - โ”œโ”€ hwaccel.go โ†’ Hardware encoder detection (NVENC, QSV, VA-API, Vulkan, VideoToolbox) +internal/encoder/ โ†’ ffmpeg-statigo muxer facade, RGBโ†’YUV conversion, FIFO buffer + โ”œโ”€ encoder.go โ†’ Public encoder API and MP4 muxer ownership + โ”œโ”€ audio_encoder.go โ†’ AAC setup, FIFO writes, flush, and audio packets + โ”œโ”€ video_encoder.go โ†’ H.264 setup, hardware context, frames, and video packets + โ”œโ”€ hwaccel.go โ†’ Hardware encoder registry, detection, and selection โ””โ”€ frame.go โ†’ RGBAโ†’YUV420P / RGBAโ†’NV12 parallelised conversion internal/renderer/ โ†’ Frame generation, bar drawing, thumbnail -internal/ui/ โ†’ Bubbletea TUI (unified progress.go for both passes) +internal/ui/ โ†’ Bubbletea TUI state, spectrum, preview, and summary views internal/config/ โ†’ Constants (dimensions, FFT params, colours) -internal/yuv/ โ†’ Shared BT.601 coefficient helpers and ParallelRows +internal/yuv/ โ†’ Shared BT.601 coefficient helpers, ParallelRows, and RowPool internal/theme/ โ†’ Terminal colour theme internal/cli/ โ†’ Kong CLI helpers and styled help third_party/ffmpeg-statigo/ โ†’ Git submodule: FFmpeg 8.0 static bindings @@ -160,9 +165,9 @@ third_party/ffmpeg-statigo/ โ†’ Git submodule: FFmpeg 8.0 static bindings ## Future-Proofing ### go-yuv: Parallelised Colourspace Conversion -BT.601 coefficient helpers and `ParallelRows` have been extracted into `internal/yuv`. The hot-path converters (`convertRGBAToYUV`, `convertRGBAToNV12`) in `encoder/frame.go` call these shared primitives. The `internal/yuv` package is a strong candidate for further extraction as a standalone Go module: +BT.601 coefficient helpers, `ParallelRows`, and `RowPool` have been extracted into `internal/yuv`. The hot-path converters (`convertRGBAToYUV`, `convertRGBAToNV12`) in `encoder/frame.go` call these shared primitives. The `internal/yuv` package is a strong candidate for further extraction as a standalone Go module: - Multiple format conversions: RGBAโ†’YUV420P, RGBAโ†’NV12 -- Goroutine-based parallelisation across CPU cores via `ParallelRows` +- Goroutine-based parallelisation across CPU cores via `ParallelRows` or `RowPool` - Pure Go with no CGO dependencies (coefficients only, no FFmpeg) There's currently no pure Go library offering parallelised colourspace conversion. Existing options are either single-threaded (stdlib `color.RGBToYCbCr`) or require CGO FFmpeg bindings. A standalone `go-yuv` module would benefit: diff --git a/internal/encoder/audio_encoder.go b/internal/encoder/audio_encoder.go new file mode 100644 index 0000000..c865ddf --- /dev/null +++ b/internal/encoder/audio_encoder.go @@ -0,0 +1,419 @@ +package encoder + +import ( + "encoding/binary" + "errors" + "fmt" + "math" + "unsafe" + + ffmpeg "github.com/linuxmatters/ffmpeg-statigo" +) + +type audioEncoder struct { + stream *ffmpeg.AVStream + codec *ffmpeg.AVCodecContext + frame *ffmpeg.AVFrame + fifo *avAudioFIFO + outputChannels int + nextPts int64 +} + +// avAudioFIFO wraps FFmpeg's AVAudioFifo, confining the C handle and all +// plane-pointer marshalling behind this type so no consumer touches the C +// boundary directly. The FIFO is packed (AVSampleFmtFlt) to preserve the +// interleaved push contract. The planar split happens at drain, in +// writeMonoFloats and writeStereoFloats. +type avAudioFIFO struct { + fifo *ffmpeg.AVAudioFifo + channels int + + // Persistent C scratch plane (AVMalloc-backed) used to marshal interleaved + // float32 samples across the CGO boundary for both write and read. Packed + // AVSampleFmtFlt has a single plane, so all interleaved samples live here. + // Write and read never run concurrently, so one buffer is shared. scratchCap + // is measured in float32 elements; grown on demand. + scratch unsafe.Pointer + scratchCap int +} + +// newAudioEncoder sets up the AAC encoder for direct sample input. +func newAudioEncoder( + formatCtx *ffmpeg.AVFormatContext, + sampleRate int, + outputChannels int, +) (_ *audioEncoder, err error) { + audioEncoderCodec := ffmpeg.AVCodecFindEncoder(ffmpeg.AVCodecIdAac) + if audioEncoderCodec == nil { + return nil, fmt.Errorf("AAC encoder not found") + } + + a := &audioEncoder{ + outputChannels: outputChannels, + } + defer func() { + if err != nil { + a.close() + } + }() + + a.stream = ffmpeg.AVFormatNewStream(formatCtx, nil) + if a.stream == nil { + return nil, fmt.Errorf("failed to create audio stream") + } + a.stream.SetId(1) + + a.codec = ffmpeg.AVCodecAllocContext3(audioEncoderCodec) + if a.codec == nil { + return nil, fmt.Errorf("failed to allocate audio encoder context") + } + + // Configure AAC encoder using config sample rate. + a.codec.SetSampleFmt(ffmpeg.AVSampleFmtFltp) // AAC requires float planar + a.codec.SetSampleRate(sampleRate) + ffmpeg.AVChannelLayoutDefault(a.codec.ChLayout(), outputChannels) + a.codec.SetBitRate(192000) // 192 kbps + a.stream.SetTimeBase(ffmpeg.AVMakeQ(1, a.codec.SampleRate())) + + ret, err := ffmpeg.AVCodecOpen2(a.codec, audioEncoderCodec, nil) + if err := checkFFmpeg(ret, err, "open audio encoder"); err != nil { + return nil, err + } + + ret, err = ffmpeg.AVCodecParametersFromContext(a.stream.Codecpar(), a.codec) + if err := checkFFmpeg(ret, err, "copy audio encoder parameters"); err != nil { + return nil, err + } + + a.frame = ffmpeg.AVFrameAlloc() + if a.frame == nil { + return nil, fmt.Errorf("failed to allocate audio encoder frame") + } + + // AVAudioFifo-backed FIFO (packed float32) bridges the FFT chunk size + // (2048) to the AAC encoder frame size (1024). + audioFIFO, err := newAVAudioFIFO(outputChannels, a.codec.FrameSize()) + if err != nil { + return nil, err + } + a.fifo = audioFIFO + + a.frame.SetNbSamples(a.codec.FrameSize()) + a.frame.SetFormat(int(ffmpeg.AVSampleFmtFltp)) + ffmpeg.AVChannelLayoutDefault(a.frame.ChLayout(), outputChannels) + a.frame.SetSampleRate(a.codec.SampleRate()) + + ret, err = ffmpeg.AVFrameGetBuffer(a.frame, 0) + if err := checkFFmpeg(ret, err, "allocate encoder frame buffer"); err != nil { + return nil, err + } + + return a, nil +} + +func (a *audioEncoder) writeSamples(samples []float32, pkt *ffmpeg.AVPacket, writePacket func(*ffmpeg.AVPacket) error) error { + encoderFrameSize := a.codec.FrameSize() // 1024 for AAC + + if err := a.fifo.write(samples); err != nil { + return err + } + + // Drain the FIFO one encoder frame at a time. AVAudioFifoSize reports + // samples-per-channel, so compare against encoderFrameSize (1024), not + // encoderFrameSize*channels. + for { + size, err := a.fifo.size() + if err != nil { + return err + } + if size < encoderFrameSize { + break + } + + frameSamples, err := a.fifo.read(encoderFrameSize) + if err != nil { + return err + } + + _, _ = ffmpeg.AVFrameMakeWritable(a.frame) + + var writeErr error + if a.outputChannels == 2 { + writeErr = writeStereoFloats(a.frame, frameSamples) + } else { + writeErr = writeMonoFloats(a.frame, frameSamples) + } + + if writeErr != nil { + return fmt.Errorf("failed to write %s samples: %w", + channelLayoutName(a.outputChannels), writeErr) + } + + a.frame.SetPts(a.nextPts) + a.nextPts += int64(encoderFrameSize) + + ret, err := ffmpeg.AVCodecSendFrame(a.codec, a.frame) + if err := checkFFmpeg(ret, err, "send audio frame to encoder"); err != nil { + return err + } + + if err := a.receiveAndWritePackets(pkt, writePacket); err != nil { + return err + } + } + + return nil +} + +func (a *audioEncoder) flush(pkt *ffmpeg.AVPacket, writePacket func(*ffmpeg.AVPacket) error) error { + encoderFrameSize := a.codec.FrameSize() + + // Drain any residual partial frame (< encoderFrameSize samples-per-channel) + // from the AVAudioFifo and zero-pad it to a full encoder frame. + remaining, err := a.fifo.size() + if err != nil { + return err + } + if remaining > 0 { + samplesPerFrame := encoderFrameSize * a.outputChannels + frameSamples := make([]float32, samplesPerFrame) + partialSamples, err := a.fifo.read(remaining) + if err != nil { + return err + } + copy(frameSamples, partialSamples) + + _, _ = ffmpeg.AVFrameMakeWritable(a.frame) + + var writeErr error + if a.outputChannels == 2 { + writeErr = writeStereoFloats(a.frame, frameSamples) + } else { + writeErr = writeMonoFloats(a.frame, frameSamples) + } + + if writeErr != nil { + return fmt.Errorf("failed to write final samples: %w", writeErr) + } + + a.frame.SetPts(a.nextPts) + a.nextPts += int64(encoderFrameSize) + + ret, err := ffmpeg.AVCodecSendFrame(a.codec, a.frame) + if err := checkFFmpeg(ret, err, "send final audio frame"); err != nil { + return err + } + } + + // Send a NULL frame to enter draining mode. + _, _ = ffmpeg.AVCodecSendFrame(a.codec, nil) + + return a.receiveAndWritePackets(pkt, writePacket) +} + +// receiveAndWritePackets receives encoded packets from the audio codec and +// writes them to the output. Reuses the shared packet; safe because the encoder +// is single-goroutine and the video and audio receive loops never run +// concurrently. Write errors are propagated, not swallowed. +func (a *audioEncoder) receiveAndWritePackets(pkt *ffmpeg.AVPacket, writePacket func(*ffmpeg.AVPacket) error) error { + for { + _, err := ffmpeg.AVCodecReceivePacket(a.codec, pkt) + if err != nil { + // EAGAIN and EOF are expected - means no more packets available. + if errors.Is(err, ffmpeg.EAgain) || errors.Is(err, ffmpeg.AVErrorEOF) { + break + } + return fmt.Errorf("receive audio packet from encoder: %w", err) + } + + if err := writePacket(pkt); err != nil { + return err + } + } + + return nil +} + +func (a *audioEncoder) close() { + if a == nil { + return + } + if a.codec != nil { + ffmpeg.AVCodecFreeContext(&a.codec) + } + if a.frame != nil { + ffmpeg.AVFrameFree(&a.frame) + } + if a.fifo != nil { + a.fifo.free() + a.fifo = nil + } +} + +// newAVAudioFIFO allocates a packed float32 AVAudioFifo for the given channel +// count with an initial sample-per-channel capacity. Returns an error if the +// C allocation fails. +func newAVAudioFIFO(channels, initialNbSamples int) (*avAudioFIFO, error) { + fifo := ffmpeg.AVAudioFifoAlloc(ffmpeg.AVSampleFmtFlt, channels, initialNbSamples) + if fifo == nil { + return nil, fmt.Errorf("failed to allocate AVAudioFifo") + } + return &avAudioFIFO{fifo: fifo, channels: channels}, nil +} + +// growScratch (re)allocates the C scratch plane to hold at least n float32 +// elements, mirroring the grow-on-demand pattern in reader.go:growOutputBuffer. +func (f *avAudioFIFO) growScratch(n int) error { + if n <= 0 { + return fmt.Errorf("growScratch: non-positive element count %d", n) + } + if n <= f.scratchCap && f.scratch != nil { + return nil + } + if f.scratch != nil { + ffmpeg.AVFree(f.scratch) + f.scratch = nil + } + p := ffmpeg.AVMalloc(uint64(n) * uint64(unsafe.Sizeof(float32(0)))) + if p == nil { + return fmt.Errorf("failed to allocate audio FIFO scratch") + } + f.scratch = p + f.scratchCap = n + return nil +} + +// scratchSlice returns a []float32 view over the first n elements of the C +// scratch plane. The view aliases C memory and stays valid until the next +// growScratch or free. +func (f *avAudioFIFO) scratchSlice(n int) []float32 { + return unsafe.Slice((*float32)(f.scratch), n) +} + +// write copies interleaved float32 samples into the C scratch plane and writes +// them to the packed FIFO. samples is interleaved (mono, or L0,R0,L1,R1 for +// stereo); the per-channel sample count is len(samples)/channels. +func (f *avAudioFIFO) write(samples []float32) error { + if len(samples) == 0 { + return nil + } + if err := f.growScratch(len(samples)); err != nil { + return err + } + copy(f.scratchSlice(len(samples)), samples) + + nbSamples := len(samples) / f.channels + ret, err := ffmpeg.AVAudioFifoWrite( + f.fifo, + []unsafe.Pointer{f.scratch}, + nbSamples, f.channels, ffmpeg.AVSampleFmtFlt, + ) + if err != nil { + return fmt.Errorf("write audio FIFO: %w", err) + } + if ret < 0 { + return fmt.Errorf("write audio FIFO: %w", ffmpeg.WrapErr(ret)) + } + if ret != nbSamples { + return fmt.Errorf("write audio FIFO: wrote %d of %d samples", ret, nbSamples) + } + return nil +} + +// size returns the number of samples per channel currently buffered. +func (f *avAudioFIFO) size() (int, error) { + ret, err := ffmpeg.AVAudioFifoSize(f.fifo) + if err := checkFFmpeg(ret, err, "query audio FIFO size"); err != nil { + return 0, err + } + return ret, nil +} + +// read removes nbSamples samples per channel from the FIFO into the C scratch +// plane and returns an interleaved []float32 view over it (mono, or +// L0,R0,L1,R1 for stereo). The view aliases the scratch plane and is valid +// until the next write/read/free. Returns the actual per-channel sample count +// read. +func (f *avAudioFIFO) read(nbSamples int) ([]float32, error) { + total := nbSamples * f.channels + if err := f.growScratch(total); err != nil { + return nil, err + } + ret, err := ffmpeg.AVAudioFifoRead( + f.fifo, + []unsafe.Pointer{f.scratch}, + nbSamples, f.channels, ffmpeg.AVSampleFmtFlt, + ) + if err != nil { + return nil, fmt.Errorf("read audio FIFO: %w", err) + } + if ret < 0 { + return nil, fmt.Errorf("read audio FIFO: %w", ffmpeg.WrapErr(ret)) + } + return f.scratchSlice(ret * f.channels), nil +} + +// free releases the C AVAudioFifo and scratch plane. Safe to call on a nil +// receiver or after the handles are already freed. +func (f *avAudioFIFO) free() { + if f == nil { + return + } + if f.fifo != nil { + ffmpeg.AVAudioFifoFree(f.fifo) + f.fifo = nil + } + if f.scratch != nil { + ffmpeg.AVFree(f.scratch) + f.scratch = nil + f.scratchCap = 0 + } +} + +// channelLayoutName returns the human-readable name for a channel count. +func channelLayoutName(channels int) string { + if channels == 2 { + return "stereo" + } + return "mono" +} + +// writeMonoFloats writes mono float samples to a planar encoder frame. +func writeMonoFloats(frame *ffmpeg.AVFrame, samples []float32) error { + nbSamples := len(samples) + + dataPtr := frame.Data().Get(0) + if dataPtr == nil { + return fmt.Errorf("frame data pointer not allocated") + } + + data := unsafe.Slice((*byte)(dataPtr), nbSamples*4) + + for i := range nbSamples { + binary.LittleEndian.PutUint32(data[i*4:(i+1)*4], math.Float32bits(samples[i])) + } + + return nil +} + +// writeStereoFloats writes interleaved stereo float samples to a planar encoder +// frame, splitting them into the left and right channel planes. +func writeStereoFloats(frame *ffmpeg.AVFrame, samples []float32) error { + nbSamples := len(samples) / 2 + + leftPtr := frame.Data().Get(0) + rightPtr := frame.Data().Get(1) + if leftPtr == nil || rightPtr == nil { + return fmt.Errorf("frame data pointers not allocated") + } + + leftData := unsafe.Slice((*byte)(leftPtr), nbSamples*4) + rightData := unsafe.Slice((*byte)(rightPtr), nbSamples*4) + + for i := range nbSamples { + binary.LittleEndian.PutUint32(leftData[i*4:(i+1)*4], math.Float32bits(samples[i*2])) + binary.LittleEndian.PutUint32(rightData[i*4:(i+1)*4], math.Float32bits(samples[i*2+1])) + } + + return nil +} diff --git a/internal/encoder/encoder.go b/internal/encoder/encoder.go index ef7b12a..9d83484 100644 --- a/internal/encoder/encoder.go +++ b/internal/encoder/encoder.go @@ -1,14 +1,10 @@ package encoder import ( - "encoding/binary" "errors" "fmt" - "math" - "unsafe" ffmpeg "github.com/linuxmatters/ffmpeg-statigo" - "github.com/linuxmatters/jive-visualiser/internal/yuv" ) // checkFFmpeg provides consistent error handling for FFmpeg API calls. @@ -35,189 +31,19 @@ type Config struct { HWAccel HWAccelType // Hardware acceleration type (default: auto-detect) } -// avAudioFIFO wraps FFmpeg's AVAudioFifo, confining the C handle and all -// plane-pointer marshalling behind this type so no consumer touches the C -// boundary directly. The FIFO is packed (AVSampleFmtFlt) to preserve the -// interleaved push contract. The planar split happens at drain, in -// writeMonoFloats and writeStereoFloats. -type avAudioFIFO struct { - fifo *ffmpeg.AVAudioFifo - channels int - - // Persistent C scratch plane (AVMalloc-backed) used to marshal interleaved - // float32 samples across the CGO boundary for both write and read. Packed - // AVSampleFmtFlt has a single plane, so all interleaved samples live here. - // Write and read never run concurrently, so one buffer is shared. scratchCap - // is measured in float32 elements; grown on demand. - scratch unsafe.Pointer - scratchCap int -} - -// newAVAudioFIFO allocates a packed float32 AVAudioFifo for the given channel -// count with an initial sample-per-channel capacity. Returns an error if the -// C allocation fails. -func newAVAudioFIFO(channels, initialNbSamples int) (*avAudioFIFO, error) { - fifo := ffmpeg.AVAudioFifoAlloc(ffmpeg.AVSampleFmtFlt, channels, initialNbSamples) - if fifo == nil { - return nil, fmt.Errorf("failed to allocate AVAudioFifo") - } - return &avAudioFIFO{fifo: fifo, channels: channels}, nil -} - -// growScratch (re)allocates the C scratch plane to hold at least n float32 -// elements, mirroring the grow-on-demand pattern in reader.go:growOutputBuffer. -func (f *avAudioFIFO) growScratch(n int) error { - if n <= 0 { - return fmt.Errorf("growScratch: non-positive element count %d", n) - } - if n <= f.scratchCap && f.scratch != nil { - return nil - } - if f.scratch != nil { - ffmpeg.AVFree(f.scratch) - f.scratch = nil - } - p := ffmpeg.AVMalloc(uint64(n) * uint64(unsafe.Sizeof(float32(0)))) - if p == nil { - return fmt.Errorf("failed to allocate audio FIFO scratch") - } - f.scratch = p - f.scratchCap = n - return nil -} - -// scratchSlice returns a []float32 view over the first n elements of the C -// scratch plane. The view aliases C memory and stays valid until the next -// growScratch or free. -func (f *avAudioFIFO) scratchSlice(n int) []float32 { - return unsafe.Slice((*float32)(f.scratch), n) -} - -// write copies interleaved float32 samples into the C scratch plane and writes -// them to the packed FIFO. samples is interleaved (mono, or L0,R0,L1,R1 for -// stereo); the per-channel sample count is len(samples)/channels. -func (f *avAudioFIFO) write(samples []float32) error { - if len(samples) == 0 { - return nil - } - if err := f.growScratch(len(samples)); err != nil { - return err - } - copy(f.scratchSlice(len(samples)), samples) - - nbSamples := len(samples) / f.channels - ret, err := ffmpeg.AVAudioFifoWrite( - f.fifo, - []unsafe.Pointer{f.scratch}, - nbSamples, f.channels, ffmpeg.AVSampleFmtFlt, - ) - if err != nil { - return fmt.Errorf("write audio FIFO: %w", err) - } - if ret < 0 { - return fmt.Errorf("write audio FIFO: %w", ffmpeg.WrapErr(ret)) - } - if ret != nbSamples { - return fmt.Errorf("write audio FIFO: wrote %d of %d samples", ret, nbSamples) - } - return nil -} - -// size returns the number of samples per channel currently buffered. -func (f *avAudioFIFO) size() (int, error) { - ret, err := ffmpeg.AVAudioFifoSize(f.fifo) - if err := checkFFmpeg(ret, err, "query audio FIFO size"); err != nil { - return 0, err - } - return ret, nil -} - -// read removes nbSamples samples per channel from the FIFO into the C scratch -// plane and returns an interleaved []float32 view over it (mono, or -// L0,R0,L1,R1 for stereo). The view aliases the scratch plane and is valid -// until the next write/read/free. Returns the actual per-channel sample count -// read. -func (f *avAudioFIFO) read(nbSamples int) ([]float32, error) { - total := nbSamples * f.channels - if err := f.growScratch(total); err != nil { - return nil, err - } - ret, err := ffmpeg.AVAudioFifoRead( - f.fifo, - []unsafe.Pointer{f.scratch}, - nbSamples, f.channels, ffmpeg.AVSampleFmtFlt, - ) - if err != nil { - return nil, fmt.Errorf("read audio FIFO: %w", err) - } - if ret < 0 { - return nil, fmt.Errorf("read audio FIFO: %w", ffmpeg.WrapErr(ret)) - } - return f.scratchSlice(ret * f.channels), nil -} - -// free releases the C AVAudioFifo and scratch plane. Safe to call on a nil -// receiver or after the handles are already freed. -func (f *avAudioFIFO) free() { - if f == nil { - return - } - if f.fifo != nil { - ffmpeg.AVAudioFifoFree(f.fifo) - f.fifo = nil - } - if f.scratch != nil { - ffmpeg.AVFree(f.scratch) - f.scratch = nil - f.scratchCap = 0 - } -} - // Encoder wraps FFmpeg encoding functionality type Encoder struct { config Config // Output muxer (MP4 container) formatCtx *ffmpeg.AVFormatContext + pkt *ffmpeg.AVPacket // Video stream and encoder - videoStream *ffmpeg.AVStream - videoCodec *ffmpeg.AVCodecContext - - // Hardware acceleration (nil for software encoding) - hwEncoder *HWEncoder - hwDeviceCtx *ffmpeg.AVBufferRef - - // Hardware frames context for GPU upload (Vulkan and QSV) - hwFramesCtx *ffmpeg.AVBufferRef - - // Pre-allocated reusable NV12 frame for parallel Go conversion (Vulkan and QSV) - hwNV12Frame *ffmpeg.AVFrame - - // Pre-allocated reusable software YUV420P frame (libx264 path) - swYUVFrame *ffmpeg.AVFrame - - // Pre-allocated reusable RGBA frame (NVENC path) - rgbaFrame *ffmpeg.AVFrame - - // Pre-allocated reusable packet for the video receive loop - pkt *ffmpeg.AVPacket - - // Persistent worker pool for per-frame RGBโ†’YUV row conversion - rowPool *yuv.RowPool - - // Input pixel format (RGBA for NVENC, NV12 for Vulkan/QSV, YUV420P for software) - inputPixFmt ffmpeg.AVPixelFormat + video *videoEncoder // Audio stream and encoder - audioStream *ffmpeg.AVStream - audioCodec *ffmpeg.AVCodecContext - audioEncFrame *ffmpeg.AVFrame - audioFIFO *avAudioFIFO // AVAudioFifo-backed FIFO for frame size adjustment (FFT needs 2048, AAC expects 1024) - - // Timestamp tracking - nextVideoPts int64 - nextAudioPts int64 + audio *audioEncoder } // New creates a new encoder instance @@ -233,11 +59,7 @@ func New(config Config) (*Encoder, error) { return nil, fmt.Errorf("output path cannot be empty") } - return &Encoder{ - config: config, - nextVideoPts: 0, - nextAudioPts: 0, - }, nil + return &Encoder{config: config}, nil } // Initialize sets up the FFmpeg encoder pipeline @@ -246,17 +68,11 @@ func (e *Encoder) Initialize() (err error) { // Suppress FFmpeg log output so it does not corrupt the TUI. ffmpeg.AVLogSetLevel(ffmpeg.AVLogQuiet) - - // Persistent worker pool for per-frame RGBโ†’YUV conversion. The row - // partition never changes, so reuse long-lived workers across all frames. - // Stop the workers if a later setup step fails, since the caller only - // defers Close once Initialize returns successfully. - e.rowPool = yuv.NewRowPool(e.config.Height) defer func() { - if err != nil && e.rowPool != nil { - e.rowPool.Close() - e.rowPool = nil + if err == nil { + return } + e.freeResources() }() outputPath := ffmpeg.ToCStr(e.config.OutputPath) @@ -267,141 +83,16 @@ func (e *Encoder) Initialize() (err error) { return err } - // Select encoder based on hardware acceleration preference - hwAccelType := e.config.HWAccel - if hwAccelType == "" { - hwAccelType = HWAccelAuto // Default to auto-detection - } - - e.hwEncoder = SelectBestEncoder(hwAccelType) - - var codec *ffmpeg.AVCodec - if e.hwEncoder != nil { - // Use hardware encoder - encoderName := ffmpeg.ToCStr(e.hwEncoder.Name) - codec = ffmpeg.AVCodecFindEncoderByName(encoderName) - encoderName.Free() - if codec == nil { - return fmt.Errorf("hardware encoder %s not found", e.hwEncoder.Name) - } - - // Create hardware device context - // For QSV on Linux with multiple GPUs, try common Intel render nodes - var deviceCreated bool - if e.hwEncoder.Type == HWAccelQSV { - // Try specific Intel GPU render nodes first - for _, device := range []string{"/dev/dri/renderD128", "/dev/dri/renderD129", ""} { - var deviceCStr *ffmpeg.CStr - if device != "" { - deviceCStr = ffmpeg.ToCStr(device) - } - ret, err = ffmpeg.AVHWDeviceCtxCreate(&e.hwDeviceCtx, e.hwEncoder.DeviceType, deviceCStr, nil, 0) - if deviceCStr != nil { - deviceCStr.Free() - } - if err == nil && ret >= 0 { - deviceCreated = true - break - } - } - } else { - ret, err = ffmpeg.AVHWDeviceCtxCreate(&e.hwDeviceCtx, e.hwEncoder.DeviceType, nil, nil, 0) - deviceCreated = (err == nil && ret >= 0) - } - - if !deviceCreated { - // Fall back to software if hardware init fails - e.hwEncoder = nil - e.hwDeviceCtx = nil - codec = ffmpeg.AVCodecFindEncoder(ffmpeg.AVCodecIdH264) - if codec == nil { - return fmt.Errorf("H.264 encoder not found") - } - } - } else { - // Use software encoder (libx264) - codec = ffmpeg.AVCodecFindEncoder(ffmpeg.AVCodecIdH264) - if codec == nil { - return fmt.Errorf("H.264 encoder not found") - } - } - - e.videoStream = ffmpeg.AVFormatNewStream(e.formatCtx, nil) - if e.videoStream == nil { - return fmt.Errorf("failed to create video stream") - } - e.videoStream.SetId(0) - - e.videoCodec = ffmpeg.AVCodecAllocContext3(codec) - if e.videoCodec == nil { - return fmt.Errorf("failed to allocate codec context") - } - - e.videoCodec.SetWidth(e.config.Width) - e.videoCodec.SetHeight(e.config.Height) - - if err := e.configurePixelFormat(); err != nil { - return err - } - - timeBase := ffmpeg.AVMakeQ(1, e.config.Framerate) - e.videoCodec.SetTimeBase(timeBase) - - framerate := ffmpeg.AVMakeQ(e.config.Framerate, 1) - e.videoCodec.SetFramerate(framerate) - - e.videoCodec.SetGopSize(e.config.Framerate * 2) // Keyframe every 2 seconds - - e.videoStream.SetTimeBase(timeBase) - - // internal/yuv converts with full-range BT.601 JFIF coefficients; untagged - // streams decode as limited-range BT.709, crushing blacks. Tag the stream - // before AVCodecOpen2 (so libx264 writes the VUI) and before - // AVCodecParametersFromContext (so codecpar carries the tags into the MP4). - // These tags are verified correct for the CPU-converted paths (software - // YUV420P and NV12 hardware upload, both via internal/yuv). The NVENC - // RGBA-direct path (writeFrameRGBADirect) converts on the GPU with an - // unverified matrix/range; no NVENC hardware was available to test. If - // NVENC output shows shifted colours, align the GPU conversion with these - // tags rather than changing the tags. - e.videoCodec.SetColorRange(ffmpeg.AVColRangeJpeg) - e.videoCodec.SetColorspace(ffmpeg.AVColSpcSmpte170M) - e.videoCodec.SetColorPrimaries(ffmpeg.AVColPriSmpte170M) - e.videoCodec.SetColorTrc(ffmpeg.AVColTrcSmpte170M) - - var opts *ffmpeg.AVDictionary - defer ffmpeg.AVDictFree(&opts) - - if e.hwEncoder != nil { - // Hardware encoder options - e.setHWEncoderOptions(&opts) - } else { - // Software encoder (x264) options optimised for visualisation content - // CRF 24 = good quality for busy visualizations - _, _ = ffmpeg.AVDictSet(&opts, ffmpeg.ToCStr("crf"), ffmpeg.ToCStr("24"), 0) - // Faster preset prioritizes encoding speed - _, _ = ffmpeg.AVDictSet(&opts, ffmpeg.ToCStr("preset"), ffmpeg.ToCStr("veryfast"), 0) - // Tune for animation content - _, _ = ffmpeg.AVDictSet(&opts, ffmpeg.ToCStr("tune"), ffmpeg.ToCStr("animation"), 0) - // Main profile for faster encoding and broad compatibility - _, _ = ffmpeg.AVDictSet(&opts, ffmpeg.ToCStr("profile"), ffmpeg.ToCStr("main"), 0) - // Single reference frame (simple vertical bar motion doesn't need multiple refs) - _, _ = ffmpeg.AVDictSet(&opts, ffmpeg.ToCStr("ref"), ffmpeg.ToCStr("1"), 0) - // Reduce b-frames for faster encoding (predictable bar motion) - _, _ = ffmpeg.AVDictSet(&opts, ffmpeg.ToCStr("bf"), ffmpeg.ToCStr("1"), 0) - // Simpler subpixel motion estimation (bars move in discrete pixels) - _, _ = ffmpeg.AVDictSet(&opts, ffmpeg.ToCStr("subme"), ffmpeg.ToCStr("4"), 0) - } - - ret, err = ffmpeg.AVCodecOpen2(e.videoCodec, codec, &opts) - if err := checkFFmpeg(ret, err, "open codec"); err != nil { - return err + e.pkt = ffmpeg.AVPacketAlloc() + if e.pkt == nil { + return fmt.Errorf("failed to allocate reusable packet") } - ret, err = ffmpeg.AVCodecParametersFromContext(e.videoStream.Codecpar(), e.videoCodec) - if err := checkFFmpeg(ret, err, "copy codec parameters"); err != nil { + video, err := newVideoEncoder(e.formatCtx, e.config) + if err != nil { return err } + e.video = video var pb *ffmpeg.AVIOContext ret, err = ffmpeg.AVIOOpen(&pb, outputPath, ffmpeg.AVIOFlagWrite) @@ -411,9 +102,11 @@ func (e *Encoder) Initialize() (err error) { e.formatCtx.SetPb(pb) if e.config.SampleRate > 0 { - if err := e.initializeAudioEncoder(); err != nil { + audio, err := newAudioEncoder(e.formatCtx, e.config.SampleRate, e.outputChannels()) + if err != nil { return fmt.Errorf("failed to initialize audio encoder: %w", err) } + e.audio = audio } ret, err = ffmpeg.AVFormatWriteHeader(e.formatCtx, nil) @@ -424,230 +117,14 @@ func (e *Encoder) Initialize() (err error) { return nil } -// setHWEncoderOptions configures encoder-specific options for hardware encoders -func (e *Encoder) setHWEncoderOptions(opts **ffmpeg.AVDictionary) { - if e.hwEncoder == nil { - return - } - - switch e.hwEncoder.Type { - case HWAccelNVENC: - // NVENC options optimised for fast visualisation encoding - // Preset p1 = fastest encoding (scale runs p1=fastest to p7=slowest) - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("preset"), ffmpeg.ToCStr("p1"), 0) - // Low latency tuning - reduces pipeline delay - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("tune"), ffmpeg.ToCStr("ull"), 0) - // Target quality (CQ mode) - similar to CRF, lower=better (0-51) - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("rc"), ffmpeg.ToCStr("vbr"), 0) - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("cq"), ffmpeg.ToCStr("24"), 0) - // Main profile for broad compatibility - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("profile"), ffmpeg.ToCStr("main"), 0) - // No B-frames for faster encoding (visualisation has low motion) - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("bf"), ffmpeg.ToCStr("0"), 0) - // Zero latency mode - no reordering delay - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("zerolatency"), ffmpeg.ToCStr("1"), 0) - - case HWAccelQSV: - // Intel Quick Sync Video options - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("preset"), ffmpeg.ToCStr("medium"), 0) - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("global_quality"), ffmpeg.ToCStr("24"), 0) - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("profile"), ffmpeg.ToCStr("main"), 0) - - case HWAccelVulkan: - // Vulkan Video options optimised for fast visualisation encoding - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("content"), ffmpeg.ToCStr("rendered"), 0) - // Quality level (0-51, lower=better) - same as NVENC CQ - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("qp"), ffmpeg.ToCStr("24"), 0) - // Low latency tuning - reduces pipeline delay - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("tune"), ffmpeg.ToCStr("ull"), 0) - // Increase async depth for more parallelism (default=2) - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("async_depth"), ffmpeg.ToCStr("4"), 0) - // Main profile for broad compatibility - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("profile"), ffmpeg.ToCStr("main"), 0) - // Minimal B-frame depth (1 is minimum) - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("b_depth"), ffmpeg.ToCStr("1"), 0) - - case HWAccelVAAPI: - // VA-API options optimised for fast visualisation encoding - // Quality level (1-51, lower=better) - CQP rate control - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("qp"), ffmpeg.ToCStr("24"), 0) - // Main profile for broad compatibility - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("profile"), ffmpeg.ToCStr("main"), 0) - // Low latency: disable B-frames for faster encoding - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("bf"), ffmpeg.ToCStr("0"), 0) - - case HWAccelVideoToolbox: - // Apple VideoToolbox options optimised for fast visualisation encoding - // Note: VideoToolbox does not support constant quality (CRF/CQ) encoding. - // It uses bitrate-based rate control only, so we cannot set quality levels - // like other hardware encoders. The encoder will use default VBR settings. - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("profile"), ffmpeg.ToCStr("main"), 0) - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("level"), ffmpeg.ToCStr("4.1"), 0) - // Real-time encoding hint - prioritises speed for live/visualisation use - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("realtime"), ffmpeg.ToCStr("1"), 0) - // Require hardware encoding - fail if hardware unavailable - _, _ = ffmpeg.AVDictSet(opts, ffmpeg.ToCStr("allow_sw"), ffmpeg.ToCStr("0"), 0) - } -} - -// setupHWFramesContext creates and configures the hardware frames context -// required for Vulkan and QSV video encoding. These encoders require frames to -// be uploaded to GPU memory before encoding, using NV12 format as the software -// pixel format. -func (e *Encoder) setupHWFramesContext(hwPixFmt ffmpeg.AVPixelFormat) error { - if e.hwDeviceCtx == nil { - return fmt.Errorf("hardware device context not available") - } - - // Allocate hardware frames context from the device context - hwFramesRef := ffmpeg.AVHWFrameCtxAlloc(e.hwDeviceCtx) - if hwFramesRef == nil { - return fmt.Errorf("failed to allocate hardware frames context") - } - e.hwFramesCtx = hwFramesRef - - // Cast the data pointer to AVHWFramesContext for configuration - framesCtx := ffmpeg.ToAVHWFramesContext(hwFramesRef.Data()) - if framesCtx == nil { - return fmt.Errorf("failed to get hardware frames context") - } - - // Configure the frames context for hardware encoding (Vulkan or QSV) - framesCtx.SetFormat(hwPixFmt) // Hardware format (AVPixFmtVulkan or AVPixFmtQsv) - framesCtx.SetSwFormat(ffmpeg.AVPixFmtNv12) // Software format for upload - framesCtx.SetWidth(e.config.Width) - framesCtx.SetHeight(e.config.Height) - framesCtx.SetInitialPoolSize(20) // Pool size for frame reuse - - // Initialize the frames context - ret, err := ffmpeg.AVHWFrameCtxInit(hwFramesRef) - if err := checkFFmpeg(ret, err, "initialize hardware frames context"); err != nil { - return err - } - - // Attach frames context to the video encoder - e.videoCodec.SetHwFramesCtx(ffmpeg.AVBufferRef_(hwFramesRef)) - - // Pre-allocate reusable NV12 frame for parallel Go RGBAโ†’NV12 conversion - e.hwNV12Frame = ffmpeg.AVFrameAlloc() - if e.hwNV12Frame == nil { - return fmt.Errorf("failed to allocate reusable NV12 frame") - } - e.hwNV12Frame.SetWidth(e.config.Width) - e.hwNV12Frame.SetHeight(e.config.Height) - e.hwNV12Frame.SetFormat(int(ffmpeg.AVPixFmtNv12)) - - ret, err = ffmpeg.AVFrameGetBuffer(e.hwNV12Frame, 0) - if err := checkFFmpeg(ret, err, "allocate NV12 buffer"); err != nil { - return err - } - - return nil -} - -// configurePixelFormat sets up pixel formats and hardware context based on encoder type. -// NVENC: accepts RGBA directly, GPU does colourspace conversion -// Vulkan/QSV/VA-API: require NV12 uploaded to GPU via hardware frames context -// Software: uses YUV420P with CPU-side RGBโ†’YUV conversion -func (e *Encoder) configurePixelFormat() error { - // Pre-allocate reusable packet for the video receive loop - e.pkt = ffmpeg.AVPacketAlloc() - if e.pkt == nil { - return fmt.Errorf("failed to allocate reusable packet") - } - - if e.hwEncoder == nil { - // Software encoder (libx264) - e.inputPixFmt = ffmpeg.AVPixFmtYuv420P - e.videoCodec.SetPixFmt(ffmpeg.AVPixFmtYuv420P) - - // Pre-allocate reusable YUV420P frame for CPU-side conversion - e.swYUVFrame = ffmpeg.AVFrameAlloc() - if e.swYUVFrame == nil { - return fmt.Errorf("failed to allocate reusable YUV frame") - } - e.swYUVFrame.SetWidth(e.config.Width) - e.swYUVFrame.SetHeight(e.config.Height) - e.swYUVFrame.SetFormat(int(ffmpeg.AVPixFmtYuv420P)) - - ret, err := ffmpeg.AVFrameGetBuffer(e.swYUVFrame, 0) - if err := checkFFmpeg(ret, err, "allocate YUV buffer"); err != nil { - return err - } - return nil - } - - switch e.hwEncoder.Type { - case HWAccelNVENC: - // NVENC can accept RGBA directly - GPU handles colourspace conversion - e.inputPixFmt = ffmpeg.AVPixFmtRgba - e.videoCodec.SetPixFmt(ffmpeg.AVPixFmtRgba) - e.videoCodec.SetHwDeviceCtx(ffmpeg.AVBufferRef_(e.hwDeviceCtx)) - - // Pre-allocate reusable RGBA frame - e.rgbaFrame = ffmpeg.AVFrameAlloc() - if e.rgbaFrame == nil { - return fmt.Errorf("failed to allocate reusable RGBA frame") - } - e.rgbaFrame.SetWidth(e.config.Width) - e.rgbaFrame.SetHeight(e.config.Height) - e.rgbaFrame.SetFormat(int(ffmpeg.AVPixFmtRgba)) - - ret, err := ffmpeg.AVFrameGetBuffer(e.rgbaFrame, 0) - if err := checkFFmpeg(ret, err, "allocate RGBA buffer"); err != nil { - return err - } - - case HWAccelVulkan: - // Vulkan requires hardware frames context with NV12 software format - e.inputPixFmt = ffmpeg.AVPixFmtNv12 - e.videoCodec.SetPixFmt(ffmpeg.AVPixFmtVulkan) - if err := e.setupHWFramesContext(ffmpeg.AVPixFmtVulkan); err != nil { - return fmt.Errorf("failed to setup Vulkan frames context: %w", err) - } - - case HWAccelQSV: - // QSV requires hardware frames context with NV12 software format - e.inputPixFmt = ffmpeg.AVPixFmtNv12 - e.videoCodec.SetPixFmt(ffmpeg.AVPixFmtQsv) - if err := e.setupHWFramesContext(ffmpeg.AVPixFmtQsv); err != nil { - return fmt.Errorf("failed to setup QSV frames context: %w", err) - } - - case HWAccelVAAPI: - // VA-API requires hardware frames context with NV12 software format - e.inputPixFmt = ffmpeg.AVPixFmtNv12 - e.videoCodec.SetPixFmt(ffmpeg.AVPixFmtVaapi) - if err := e.setupHWFramesContext(ffmpeg.AVPixFmtVaapi); err != nil { - return fmt.Errorf("failed to setup VA-API frames context: %w", err) - } - - case HWAccelVideoToolbox: - // VideoToolbox requires hardware frames context with NV12 software format - e.inputPixFmt = ffmpeg.AVPixFmtNv12 - e.videoCodec.SetPixFmt(ffmpeg.AVPixFmtVideotoolbox) - if err := e.setupHWFramesContext(ffmpeg.AVPixFmtVideotoolbox); err != nil { - return fmt.Errorf("failed to setup VideoToolbox frames context: %w", err) - } - - default: - return fmt.Errorf("unsupported hardware encoder type: %s", e.hwEncoder.Type) - } - - return nil -} - // EncoderName returns the name of the video encoder being used func (e *Encoder) EncoderName() string { - if e.hwEncoder != nil { - return e.hwEncoder.Name - } - return "libx264" + return e.video.encoderName() } // IsHardware reports whether encoding ran on a hardware-backed encoder. func (e *Encoder) IsHardware() bool { - return e.hwEncoder != nil + return e.video.isHardware() } // outputChannels returns the configured audio channel count, defaulting to mono. @@ -658,282 +135,13 @@ func (e *Encoder) outputChannels() int { return e.config.AudioChannels } -// initializeAudioEncoder sets up the AAC encoder for direct sample input. -// Samples are provided via WriteAudioSamples(). -// Requires SampleRate to be set in Config. -func (e *Encoder) initializeAudioEncoder() error { - audioEncoder := ffmpeg.AVCodecFindEncoder(ffmpeg.AVCodecIdAac) - if audioEncoder == nil { - return fmt.Errorf("AAC encoder not found") - } - - e.audioStream = ffmpeg.AVFormatNewStream(e.formatCtx, nil) - if e.audioStream == nil { - return fmt.Errorf("failed to create audio stream") - } - e.audioStream.SetId(1) - - e.audioCodec = ffmpeg.AVCodecAllocContext3(audioEncoder) - if e.audioCodec == nil { - return fmt.Errorf("failed to allocate audio encoder context") - } - - // Configure AAC encoder using config sample rate - e.audioCodec.SetSampleFmt(ffmpeg.AVSampleFmtFltp) // AAC requires float planar - e.audioCodec.SetSampleRate(e.config.SampleRate) - - outputChannels := e.outputChannels() - ffmpeg.AVChannelLayoutDefault(e.audioCodec.ChLayout(), outputChannels) - - e.audioCodec.SetBitRate(192000) // 192 kbps - e.audioStream.SetTimeBase(ffmpeg.AVMakeQ(1, e.audioCodec.SampleRate())) - - ret, err := ffmpeg.AVCodecOpen2(e.audioCodec, audioEncoder, nil) - if err := checkFFmpeg(ret, err, "open audio encoder"); err != nil { - return err - } - - ret, err = ffmpeg.AVCodecParametersFromContext(e.audioStream.Codecpar(), e.audioCodec) - if err := checkFFmpeg(ret, err, "copy audio encoder parameters"); err != nil { - return err - } - - e.audioEncFrame = ffmpeg.AVFrameAlloc() - if e.audioEncFrame == nil { - return fmt.Errorf("failed to allocate audio encoder frame") - } - - // AVAudioFifo-backed FIFO (packed float32) bridges the FFT chunk size - // (2048) to the AAC encoder frame size (1024). - audioFIFO, err := newAVAudioFIFO(outputChannels, e.audioCodec.FrameSize()) - if err != nil { - return err - } - e.audioFIFO = audioFIFO - - e.audioEncFrame.SetNbSamples(e.audioCodec.FrameSize()) - e.audioEncFrame.SetFormat(int(ffmpeg.AVSampleFmtFltp)) - ffmpeg.AVChannelLayoutDefault(e.audioEncFrame.ChLayout(), outputChannels) - e.audioEncFrame.SetSampleRate(e.audioCodec.SampleRate()) - - ret, err = ffmpeg.AVFrameGetBuffer(e.audioEncFrame, 0) - if err := checkFFmpeg(ret, err, "allocate encoder frame buffer"); err != nil { - return err - } - - return nil -} - -// WriteFrameRGBA encodes and writes a single RGBA frame -// For NVENC: sends RGBA directly to GPU (colourspace conversion on GPU) -// For Vulkan: converts RGBAโ†’NV12 on CPU, uploads to GPU via hwframe -// For software: converts to RGB24โ†’YUV420P on CPU then encodes +// WriteFrameRGBA encodes and writes a single RGBA frame. func (e *Encoder) WriteFrameRGBA(rgbaData []byte) error { - // Validate frame size - expectedSize := e.config.Width * e.config.Height * 4 // RGBA = 4 bytes per pixel + expectedSize := e.config.Width * e.config.Height * 4 if len(rgbaData) != expectedSize { return fmt.Errorf("invalid RGBA frame size: got %d, expected %d", len(rgbaData), expectedSize) } - - // For NVENC, send RGBA directly - GPU does colourspace conversion - if e.inputPixFmt == ffmpeg.AVPixFmtRgba { - return e.writeFrameRGBADirect(rgbaData) - } - - // For Vulkan/QSV/VAAPI/VideoToolbox, convert RGBAโ†’NV12 then upload to GPU; - // configurePixelFormat sets NV12 for exactly those hardware encoders. - if e.inputPixFmt == ffmpeg.AVPixFmtNv12 { - return e.writeFrameHWUpload(rgbaData) - } - - // For software encoder, convert RGBA directly to YUV420P (skipping RGB24 intermediate) - return e.writeFrameRGBASoftware(rgbaData) -} - -// writeFrameRGBASoftware converts RGBA directly to YUV420P and encodes. -// This avoids the intermediate RGB24 buffer allocation for ~3% faster software encoding. -func (e *Encoder) writeFrameRGBASoftware(rgbaData []byte) error { - // Use pre-allocated YUV frame (configured in configurePixelFormat). - // Make writable as the encoder may still hold a reference from the previous frame. - yuvFrame := e.swYUVFrame - ret, err := ffmpeg.AVFrameMakeWritable(yuvFrame) - if err := checkFFmpeg(ret, err, "make YUV frame writable"); err != nil { - return err - } - - // Convert RGBA directly to YUV420P (skips RGB24 intermediate) - convertRGBAToYUV(e.rowPool, rgbaData, yuvFrame, e.config.Width) - - // Set presentation timestamp - yuvFrame.SetPts(e.nextVideoPts) - e.nextVideoPts++ - - // Send frame to encoder - ret, err = ffmpeg.AVCodecSendFrame(e.videoCodec, yuvFrame) - if err := checkFFmpeg(ret, err, "send frame to encoder"); err != nil { - return err - } - - // Receive and write encoded packets - return e.receiveAndWriteVideoPackets() -} - -// writeFrameRGBADirect sends RGBA frame directly to hardware encoder -// This avoids CPU-side colourspace conversion - GPU handles it -func (e *Encoder) writeFrameRGBADirect(rgbaData []byte) error { - // Use pre-allocated RGBA frame (configured in configurePixelFormat). - // Make writable as the encoder may still hold a reference from the previous frame. - rgbaFrame := e.rgbaFrame - ret, err := ffmpeg.AVFrameMakeWritable(rgbaFrame) - if err := checkFFmpeg(ret, err, "make RGBA frame writable"); err != nil { - return err - } - - // Copy RGBA data to frame - width := e.config.Width - height := e.config.Height - linesize := rgbaFrame.Linesize().Get(0) - data := rgbaFrame.Data().Get(0) - - // Copy row by row (frame may have padding) - srcStride := width * 4 - for y := range height { - srcOffset := y * srcStride - dstOffset := y * linesize - copy(unsafe.Slice((*byte)(unsafe.Add(data, dstOffset)), srcStride), //nolint:gosec // offset is within allocated frame - rgbaData[srcOffset:srcOffset+srcStride]) - } - - // Set presentation timestamp - rgbaFrame.SetPts(e.nextVideoPts) - e.nextVideoPts++ - - // Send frame to encoder - ret, err = ffmpeg.AVCodecSendFrame(e.videoCodec, rgbaFrame) - if err := checkFFmpeg(ret, err, "send frame to encoder"); err != nil { - return err - } - - // Receive and write encoded packets - return e.receiveAndWriteVideoPackets() -} - -// writeFrameHWUpload converts RGBA to NV12, uploads to GPU, and encodes -// Pipeline: RGBA (CPU) โ†’ parallel Go conversion โ†’ NV12 (CPU) โ†’ AVHWFrameTransferData โ†’ GPU โ†’ encode -// Used by Vulkan (h264_vulkan) and QSV (h264_qsv) encoders -// Uses pre-allocated reusable NV12 frame and parallel Go conversion (13.2ร— faster than SwsScaleFrame) -func (e *Encoder) writeFrameHWUpload(rgbaData []byte) error { - width := e.config.Width - - // Use pre-allocated NV12 frame (already configured in setupHWFramesContext) - nv12Frame := e.hwNV12Frame - - // Convert RGBA โ†’ NV12 using parallel Go conversion (much faster than SwsScaleFrame) - convertRGBAToNV12(e.rowPool, rgbaData, nv12Frame, width) - - // Allocate hardware frame from pool - // Note: hwFrame must be allocated per-call as it's returned to pool after encoding - hwFrame := ffmpeg.AVFrameAlloc() - if hwFrame == nil { - return fmt.Errorf("failed to allocate hardware frame") - } - defer ffmpeg.AVFrameFree(&hwFrame) - - // Get buffer from hardware frames context pool - ret, err := ffmpeg.AVHWFrameGetBuffer(e.hwFramesCtx, hwFrame, 0) - if err := checkFFmpeg(ret, err, "get hardware frame buffer"); err != nil { - return err - } - - // Upload NV12 frame to GPU memory - ret, err = ffmpeg.AVHWFrameTransferData(hwFrame, nv12Frame, 0) - if err := checkFFmpeg(ret, err, "upload frame to GPU"); err != nil { - return err - } - - // Copy frame properties for encoder - hwFrame.SetPts(e.nextVideoPts) - e.nextVideoPts++ - - // Send hardware frame to encoder - ret, err = ffmpeg.AVCodecSendFrame(e.videoCodec, hwFrame) - if err := checkFFmpeg(ret, err, "send frame to hardware encoder"); err != nil { - return err - } - - // Receive and write encoded packets - return e.receiveAndWriteVideoPackets() -} - -// receiveAndWriteVideoPackets receives encoded packets from video codec and writes to output -func (e *Encoder) receiveAndWriteVideoPackets() error { - pkt := e.pkt - for { - _, err := ffmpeg.AVCodecReceivePacket(e.videoCodec, pkt) - if err != nil { - // EAGAIN and EOF are expected - means no more packets available - if errors.Is(err, ffmpeg.EAgain) || errors.Is(err, ffmpeg.AVErrorEOF) { - break - } - return fmt.Errorf("receive packet: %w", err) - } - - // Set stream index and rescale timestamps - pkt.SetStreamIndex(e.videoStream.Index()) - ffmpeg.AVPacketRescaleTs(pkt, e.videoCodec.TimeBase(), e.videoStream.TimeBase()) - - // Write packet to output. AVInterleavedWriteFrame consumes the packet's - // reference; unref afterwards to reset it for reuse on the next iteration. - ret, err := ffmpeg.AVInterleavedWriteFrame(e.formatCtx, pkt) - ffmpeg.AVPacketUnref(pkt) - - if err := checkFFmpeg(ret, err, "write packet"); err != nil { - return err - } - } - - return nil -} - -// receiveAndWriteAudioPackets receives encoded packets from the audio codec and -// writes them to the output. Reuses the shared e.pkt packet; safe because the -// encoder is single-goroutine and the video and audio receive loops never run -// concurrently. Write errors are propagated, not swallowed. -func (e *Encoder) receiveAndWriteAudioPackets() error { - pkt := e.pkt - for { - _, err := ffmpeg.AVCodecReceivePacket(e.audioCodec, pkt) - if err != nil { - // EAGAIN and EOF are expected - means no more packets available - if errors.Is(err, ffmpeg.EAgain) || errors.Is(err, ffmpeg.AVErrorEOF) { - break - } - return fmt.Errorf("receive audio packet from encoder: %w", err) - } - - // Set stream index and rescale timestamps - pkt.SetStreamIndex(e.audioStream.Index()) - ffmpeg.AVPacketRescaleTs(pkt, e.audioCodec.TimeBase(), e.audioStream.TimeBase()) - - // Write packet to output. AVInterleavedWriteFrame consumes the packet's - // reference; unref afterwards to reset it for reuse on the next iteration. - ret, err := ffmpeg.AVInterleavedWriteFrame(e.formatCtx, pkt) - ffmpeg.AVPacketUnref(pkt) - - if err := checkFFmpeg(ret, err, "write audio packet"); err != nil { - return err - } - } - - return nil -} - -// channelLayoutName returns the human-readable name for a channel count. -func channelLayoutName(channels int) string { - if channels == 2 { - return "stereo" - } - return "mono" + return e.video.writeFrameRGBA(rgbaData, e.pkt, e.writeVideoPacket) } // WriteAudioSamples writes pre-decoded audio samples to the encoder. @@ -941,175 +149,32 @@ func channelLayoutName(channels int) string { // For mono: just the samples. For stereo: L0, R0, L1, R1, ... // This method handles FIFO buffering and encodes complete AAC frames. func (e *Encoder) WriteAudioSamples(samples []float32) error { - if e.audioCodec == nil { + if e.audio == nil { return nil // No audio configured } - - encoderFrameSize := e.audioCodec.FrameSize() // 1024 for AAC - outputChannels := e.outputChannels() - - if err := e.audioFIFO.write(samples); err != nil { - return err - } - - // Drain the FIFO one encoder frame at a time. AVAudioFifoSize reports - // samples-per-channel, so compare against encoderFrameSize (1024), not - // encoderFrameSize*channels. - for { - size, err := e.audioFIFO.size() - if err != nil { - return err - } - if size < encoderFrameSize { - break - } - - frameSamples, err := e.audioFIFO.read(encoderFrameSize) - if err != nil { - return err - } - - _, _ = ffmpeg.AVFrameMakeWritable(e.audioEncFrame) - - var writeErr error - if outputChannels == 2 { - writeErr = writeStereoFloats(e.audioEncFrame, frameSamples) - } else { - writeErr = writeMonoFloats(e.audioEncFrame, frameSamples) - } - - if writeErr != nil { - return fmt.Errorf("failed to write %s samples: %w", - channelLayoutName(outputChannels), writeErr) - } - - e.audioEncFrame.SetPts(e.nextAudioPts) - e.nextAudioPts += int64(encoderFrameSize) - - ret, err := ffmpeg.AVCodecSendFrame(e.audioCodec, e.audioEncFrame) - if err := checkFFmpeg(ret, err, "send audio frame to encoder"); err != nil { - return err - } - - if err := e.receiveAndWriteAudioPackets(); err != nil { - return err - } - } - - return nil + return e.audio.writeSamples(samples, e.pkt, e.writeAudioPacket) } // FlushAudioEncoder flushes any remaining samples in the FIFO and encoder. // Call this after all audio samples have been written. func (e *Encoder) FlushAudioEncoder() error { - if e.audioCodec == nil { + if e.audio == nil { return nil // No audio configured } - - encoderFrameSize := e.audioCodec.FrameSize() - outputChannels := e.outputChannels() - - // Drain any residual partial frame (< encoderFrameSize samples-per-channel) - // from the AVAudioFifo and zero-pad it to a full encoder frame. The residual - // now lives in e.audioFIFO since WriteAudioSamples routes through it. - remaining, err := e.audioFIFO.size() - if err != nil { - return err - } - if remaining > 0 { - samplesPerFrame := encoderFrameSize * outputChannels - frameSamples := make([]float32, samplesPerFrame) - partialSamples, err := e.audioFIFO.read(remaining) - if err != nil { - return err - } - copy(frameSamples, partialSamples) - - _, _ = ffmpeg.AVFrameMakeWritable(e.audioEncFrame) - - var writeErr error - if outputChannels == 2 { - writeErr = writeStereoFloats(e.audioEncFrame, frameSamples) - } else { - writeErr = writeMonoFloats(e.audioEncFrame, frameSamples) - } - - if writeErr != nil { - return fmt.Errorf("failed to write final samples: %w", writeErr) - } - - e.audioEncFrame.SetPts(e.nextAudioPts) - e.nextAudioPts += int64(encoderFrameSize) - - ret, err := ffmpeg.AVCodecSendFrame(e.audioCodec, e.audioEncFrame) - if err := checkFFmpeg(ret, err, "send final audio frame"); err != nil { - return err - } - } - - // Send a NULL frame to enter draining mode. - _, _ = ffmpeg.AVCodecSendFrame(e.audioCodec, nil) - - return e.receiveAndWriteAudioPackets() -} - -// writeMonoFloats writes mono float samples to a planar encoder frame. -func writeMonoFloats(frame *ffmpeg.AVFrame, samples []float32) error { - nbSamples := len(samples) - - dataPtr := frame.Data().Get(0) - if dataPtr == nil { - return fmt.Errorf("frame data pointer not allocated") - } - - data := unsafe.Slice((*byte)(dataPtr), nbSamples*4) - - for i := range nbSamples { - binary.LittleEndian.PutUint32(data[i*4:(i+1)*4], math.Float32bits(samples[i])) - } - - return nil -} - -// writeStereoFloats writes interleaved stereo float samples to a planar encoder -// frame, splitting them into the left and right channel planes. -func writeStereoFloats(frame *ffmpeg.AVFrame, samples []float32) error { - nbSamples := len(samples) / 2 - - leftPtr := frame.Data().Get(0) - rightPtr := frame.Data().Get(1) - if leftPtr == nil || rightPtr == nil { - return fmt.Errorf("frame data pointers not allocated") - } - - leftData := unsafe.Slice((*byte)(leftPtr), nbSamples*4) - rightData := unsafe.Slice((*byte)(rightPtr), nbSamples*4) - - for i := range nbSamples { - binary.LittleEndian.PutUint32(leftData[i*4:(i+1)*4], math.Float32bits(samples[i*2])) - binary.LittleEndian.PutUint32(rightData[i*4:(i+1)*4], math.Float32bits(samples[i*2+1])) - } - - return nil + return e.audio.flush(e.pkt, e.writeAudioPacket) } // Close finalizes the output file and frees resources. // Finalisation failures (flush, drain, trailer) are collected and returned as // a joined error; resource freeing continues regardless. A second call is a -// no-op returning nil because every field is nilled on the first call. +// no-op returning nil because freed handles are nilled on the first call. func (e *Encoder) Close() error { var errs []error // Flush the video encoder before writing the trailer. A failed flush // truncates the output, so report it. - if e.videoCodec != nil && e.pkt != nil { - ret, err := ffmpeg.AVCodecSendFrame(e.videoCodec, nil) - if err := checkFFmpeg(ret, err, "flush video encoder"); err != nil { - errs = append(errs, err) - } - if err := e.receiveAndWriteVideoPackets(); err != nil { - errs = append(errs, err) - } + if err := e.video.flush(e.pkt, e.writeVideoPacket); err != nil { + errs = append(errs, err) } if e.formatCtx != nil { @@ -1118,70 +183,64 @@ func (e *Encoder) Close() error { errs = append(errs, err) } - if e.formatCtx.Pb() != nil { - // A failed close can drop buffered writes after a successful - // trailer, leaving a truncated file; surface it. - ret, err := ffmpeg.AVIOClose(e.formatCtx.Pb()) - if err := checkFFmpeg(ret, err, "close output file"); err != nil { - errs = append(errs, err) - } + // A failed close can drop buffered writes after a successful trailer, + // leaving a truncated file; surface it. + if err := e.closeOutputFile(); err != nil { + errs = append(errs, err) } } - if e.videoCodec != nil { - ffmpeg.AVCodecFreeContext(&e.videoCodec) - } - if e.audioCodec != nil { - ffmpeg.AVCodecFreeContext(&e.audioCodec) - } + e.freeResources() - if e.audioEncFrame != nil { - ffmpeg.AVFrameFree(&e.audioEncFrame) - } + return errors.Join(errs...) +} - if e.audioFIFO != nil { - e.audioFIFO.free() - e.audioFIFO = nil - } +func (e *Encoder) writeVideoPacket(pkt *ffmpeg.AVPacket) error { + return e.writePacket(pkt, e.video.codec, e.video.stream, "write video packet") +} - if e.hwDeviceCtx != nil { - ffmpeg.AVBufferUnref(&e.hwDeviceCtx) - e.hwDeviceCtx = nil - } +func (e *Encoder) writeAudioPacket(pkt *ffmpeg.AVPacket) error { + return e.writePacket(pkt, e.audio.codec, e.audio.stream, "write audio packet") +} - if e.hwFramesCtx != nil { - ffmpeg.AVBufferUnref(&e.hwFramesCtx) - e.hwFramesCtx = nil - } - if e.hwNV12Frame != nil { - ffmpeg.AVFrameFree(&e.hwNV12Frame) - e.hwNV12Frame = nil - } +func (e *Encoder) writePacket( + pkt *ffmpeg.AVPacket, + codec *ffmpeg.AVCodecContext, + stream *ffmpeg.AVStream, + op string, +) error { + pkt.SetStreamIndex(stream.Index()) + ffmpeg.AVPacketRescaleTs(pkt, codec.TimeBase(), stream.TimeBase()) - if e.swYUVFrame != nil { - ffmpeg.AVFrameFree(&e.swYUVFrame) - e.swYUVFrame = nil - } + ret, err := ffmpeg.AVInterleavedWriteFrame(e.formatCtx, pkt) + ffmpeg.AVPacketUnref(pkt) - if e.rgbaFrame != nil { - ffmpeg.AVFrameFree(&e.rgbaFrame) - e.rgbaFrame = nil - } + return checkFFmpeg(ret, err, op) +} - if e.pkt != nil { - ffmpeg.AVPacketFree(&e.pkt) - e.pkt = nil +func (e *Encoder) closeOutputFile() error { + if e.formatCtx == nil || e.formatCtx.Pb() == nil { + return nil } + ret, err := ffmpeg.AVIOClose(e.formatCtx.Pb()) + e.formatCtx.SetPb(nil) + return checkFFmpeg(ret, err, "close output file") +} - if e.rowPool != nil { - e.rowPool.Close() - e.rowPool = nil +func (e *Encoder) freeResources() { + if e.audio != nil { + e.audio.close() + e.audio = nil } + e.video.close() if e.formatCtx != nil { + _ = e.closeOutputFile() ffmpeg.AVFormatFreeContext(e.formatCtx) e.formatCtx = nil } - - return errors.Join(errs...) + if e.pkt != nil { + ffmpeg.AVPacketFree(&e.pkt) + e.pkt = nil + } } diff --git a/internal/encoder/encoder_test.go b/internal/encoder/encoder_test.go index 14b3e08..0a8e7d2 100644 --- a/internal/encoder/encoder_test.go +++ b/internal/encoder/encoder_test.go @@ -2,7 +2,10 @@ package encoder import ( "os" + "reflect" "testing" + + ffmpeg "github.com/linuxmatters/ffmpeg-statigo" ) // newTestFIFO allocates an avAudioFIFO for the given channel count with the AAC @@ -255,6 +258,215 @@ func TestAVAudioFIFO_SequentialOperations(t *testing.T) { } } +func TestHWEncoderOptionsFollowRegistryPolicy(t *testing.T) { + tests := map[HWAccelType][]hwEncoderOption{ + HWAccelNVENC: { + {"preset", "p1"}, + {"tune", "ull"}, + {"rc", "vbr"}, + {"cq", "24"}, + {"profile", "main"}, + {"bf", "0"}, + {"zerolatency", "1"}, + }, + HWAccelQSV: { + {"preset", "medium"}, + {"global_quality", "24"}, + {"profile", "main"}, + }, + HWAccelVAAPI: { + {"qp", "24"}, + {"profile", "main"}, + {"bf", "0"}, + }, + HWAccelVulkan: { + {"content", "rendered"}, + {"qp", "24"}, + {"tune", "ull"}, + {"async_depth", "4"}, + {"profile", "main"}, + {"b_depth", "1"}, + }, + HWAccelVideoToolbox: { + {"profile", "main"}, + {"level", "4.1"}, + {"realtime", "1"}, + {"allow_sw", "0"}, + }, + } + + for accelType, want := range tests { + entry, ok := hwEncoderRegistryEntryForType(accelType) + if !ok { + t.Fatalf("registry entry missing for %q", accelType) + } + got := hwEncoderOptions(entry.optionPolicy) + if !reflect.DeepEqual(got, want) { + t.Fatalf("%q options = %#v, want %#v", accelType, got, want) + } + } +} + +func TestInputPixelFormatFollowsRegistryRuntimeFormat(t *testing.T) { + tests := map[HWAccelType]ffmpeg.AVPixelFormat{ + HWAccelNone: ffmpeg.AVPixFmtYuv420P, + HWAccelNVENC: ffmpeg.AVPixFmtRgba, + HWAccelQSV: ffmpeg.AVPixFmtNv12, + HWAccelVAAPI: ffmpeg.AVPixFmtNv12, + HWAccelVulkan: ffmpeg.AVPixFmtNv12, + HWAccelVideoToolbox: ffmpeg.AVPixFmtNv12, + } + + for accelType, want := range tests { + entry, ok := hwEncoderRegistryEntryForType(accelType) + if !ok { + t.Fatalf("registry entry missing for %q", accelType) + } + got := inputPixelFormatForRegistryEntry(entry) + if got != want { + t.Fatalf("%q input pixel format = %v, want %v", accelType, got, want) + } + } +} + +func TestEncoderCloseIsDoubleCloseSafe(t *testing.T) { + outputPath := t.TempDir() + "/double-close.mp4" + config := Config{ + OutputPath: outputPath, + Width: 64, + Height: 64, + Framerate: 30, + HWAccel: HWAccelNone, + } + + enc, err := New(config) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + + if err := enc.Initialize(); err != nil { + t.Fatalf("Initialize() failed: %v", err) + } + + frame := make([]byte, config.Width*config.Height*4) + for i := 3; i < len(frame); i += 4 { + frame[i] = 255 + } + if err := enc.WriteFrameRGBA(frame); err != nil { + t.Fatalf("WriteFrameRGBA() failed: %v", err) + } + + if err := enc.Close(); err != nil { + t.Fatalf("first Close() failed: %v", err) + } + if err := enc.Close(); err != nil { + t.Fatalf("second Close() failed: %v", err) + } +} + +func TestEncoderFlushAudioEncoderWritesPartialFrame(t *testing.T) { + outputPath := t.TempDir() + "/audio-flush.mp4" + config := Config{ + OutputPath: outputPath, + Width: 64, + Height: 64, + Framerate: 30, + SampleRate: 48000, + AudioChannels: 2, + HWAccel: HWAccelNone, + } + + enc, err := New(config) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + if err := enc.Initialize(); err != nil { + t.Fatalf("Initialize() failed: %v", err) + } + + frame := makeOpaqueRGBAFrame(config.Width, config.Height, 0x20, 0x40, 0x80) + if err := enc.WriteFrameRGBA(frame); err != nil { + t.Fatalf("WriteFrameRGBA() failed: %v", err) + } + + const samplesPerChannel = 300 + samples := make([]float32, samplesPerChannel*config.AudioChannels) + for i := range samplesPerChannel { + samples[i*2] = 0.25 + samples[i*2+1] = -0.25 + } + if err := enc.WriteAudioSamples(samples); err != nil { + t.Fatalf("WriteAudioSamples() failed: %v", err) + } + if got := fifoSize(t, enc.audio.fifo); got != samplesPerChannel { + t.Fatalf("audio FIFO size before flush = %d, want %d", got, samplesPerChannel) + } + + if err := enc.FlushAudioEncoder(); err != nil { + t.Fatalf("FlushAudioEncoder() failed: %v", err) + } + if got := fifoSize(t, enc.audio.fifo); got != 0 { + t.Fatalf("audio FIFO size after flush = %d, want 0", got) + } + + if err := enc.Close(); err != nil { + t.Fatalf("Close() failed: %v", err) + } + + info, err := os.Stat(outputPath) + if err != nil { + t.Fatalf("output file not created: %v", err) + } + if info.Size() == 0 { + t.Fatal("output file is empty") + } +} + +func TestEncoderUnknownHardwareFallsBackToSoftwareMetadata(t *testing.T) { + outputPath := t.TempDir() + "/unknown-hardware-fallback.mp4" + config := Config{ + OutputPath: outputPath, + Width: 64, + Height: 64, + Framerate: 30, + HWAccel: HWAccelType("unknown"), + } + + enc, err := New(config) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + if err := enc.Initialize(); err != nil { + t.Fatalf("Initialize() failed: %v", err) + } + defer enc.Close() + + if got := enc.EncoderName(); got != "libx264" { + t.Fatalf("EncoderName() = %q, want %q", got, "libx264") + } + if enc.IsHardware() { + t.Fatal("IsHardware() = true, want false") + } + + if err := enc.WriteFrameRGBA(makeOpaqueRGBAFrame(config.Width, config.Height, 0, 0, 0)); err != nil { + t.Fatalf("WriteFrameRGBA() failed: %v", err) + } + if err := enc.Close(); err != nil { + t.Fatalf("Close() failed: %v", err) + } +} + +func makeOpaqueRGBAFrame(width, height int, red, green, blue byte) []byte { + frame := make([]byte, width*height*4) + for i := 0; i < len(frame); i += 4 { + frame[i] = red + frame[i+1] = green + frame[i+2] = blue + frame[i+3] = 255 + } + return frame +} + // TestEncoderPOC is a proof-of-concept test that encodes a single black frame via RGBA path func TestEncoderPOC(t *testing.T) { outputPath := "../../testdata/poc-video.mp4" diff --git a/internal/encoder/hwaccel.go b/internal/encoder/hwaccel.go index 741b062..0651b70 100644 --- a/internal/encoder/hwaccel.go +++ b/internal/encoder/hwaccel.go @@ -3,6 +3,7 @@ package encoder import ( "os" "runtime" + "sort" ffmpeg "github.com/linuxmatters/ffmpeg-statigo" @@ -31,28 +32,206 @@ type HWEncoder struct { Description string // Human-readable description } -// encoderSpec defines a hardware encoder configuration for priority lists -type encoderSpec struct { - name string - accelType HWAccelType - deviceType ffmpeg.AVHWDeviceType - desc string +type hwEncoderOptionPolicy int + +const ( + hwEncoderOptionsNone hwEncoderOptionPolicy = iota + hwEncoderOptionsSoftware + hwEncoderOptionsNVENC + hwEncoderOptionsQSV + hwEncoderOptionsVAAPI + hwEncoderOptionsVulkan + hwEncoderOptionsVideoToolbox +) + +type hwEncoderPriority struct { + linux int + darwin int +} + +func (p hwEncoderPriority) forGOOS(goos string) int { + if goos == "darwin" { + return p.darwin + } + return p.linux +} + +type hwEncoderRegistryEntry struct { + cliName string + ffmpegName string + accelType HWAccelType + deviceType ffmpeg.AVHWDeviceType + description string + priority hwEncoderPriority + probePixelFormat ffmpeg.AVPixelFormat + runtimePixelFormat ffmpeg.AVPixelFormat + optionPolicy hwEncoderOptionPolicy + cliSelectable bool +} + +func (entry hwEncoderRegistryEntry) toHWEncoder(available bool) HWEncoder { + return HWEncoder{ + Name: entry.ffmpegName, + Type: entry.accelType, + DeviceType: entry.deviceType, + Description: entry.description, + Available: available, + } +} + +var hwEncoderRegistry = []hwEncoderRegistryEntry{ + { + cliName: "auto", + accelType: HWAccelAuto, + deviceType: ffmpeg.AVHWDeviceTypeNone, + description: "Auto-detect best available", + priority: hwEncoderPriority{}, + probePixelFormat: ffmpeg.AVPixFmtNone, + runtimePixelFormat: ffmpeg.AVPixFmtNone, + optionPolicy: hwEncoderOptionsNone, + cliSelectable: true, + }, + { + cliName: "software", + ffmpegName: "libx264", + accelType: HWAccelNone, + deviceType: ffmpeg.AVHWDeviceTypeNone, + description: "Software encoding", + priority: hwEncoderPriority{linux: 100, darwin: 100}, + probePixelFormat: ffmpeg.AVPixFmtYuv420P, + runtimePixelFormat: ffmpeg.AVPixFmtYuv420P, + optionPolicy: hwEncoderOptionsSoftware, + cliSelectable: true, + }, + { + cliName: "nvenc", + ffmpegName: "h264_nvenc", + accelType: HWAccelNVENC, + deviceType: ffmpeg.AVHWDeviceTypeCuda, + description: "NVIDIA NVENC", + priority: hwEncoderPriority{linux: 10}, + probePixelFormat: ffmpeg.AVPixFmtRgba, + runtimePixelFormat: ffmpeg.AVPixFmtRgba, + optionPolicy: hwEncoderOptionsNVENC, + cliSelectable: true, + }, + { + cliName: "qsv", + ffmpegName: "h264_qsv", + accelType: HWAccelQSV, + deviceType: ffmpeg.AVHWDeviceTypeQsv, + description: "Intel Quick Sync Video", + priority: hwEncoderPriority{linux: 20}, + probePixelFormat: ffmpeg.AVPixFmtQsv, + runtimePixelFormat: ffmpeg.AVPixFmtQsv, + optionPolicy: hwEncoderOptionsQSV, + cliSelectable: true, + }, + { + cliName: "vaapi", + ffmpegName: "h264_vaapi", + accelType: HWAccelVAAPI, + deviceType: ffmpeg.AVHWDeviceTypeVaapi, + description: "VA-API", + priority: hwEncoderPriority{linux: 30}, + probePixelFormat: ffmpeg.AVPixFmtVaapi, + runtimePixelFormat: ffmpeg.AVPixFmtVaapi, + optionPolicy: hwEncoderOptionsVAAPI, + cliSelectable: true, + }, + { + cliName: "vulkan", + ffmpegName: "h264_vulkan", + accelType: HWAccelVulkan, + deviceType: ffmpeg.AVHWDeviceTypeVulkan, + description: "Vulkan Video", + priority: hwEncoderPriority{linux: 40}, + probePixelFormat: ffmpeg.AVPixFmtVulkan, + runtimePixelFormat: ffmpeg.AVPixFmtVulkan, + optionPolicy: hwEncoderOptionsVulkan, + cliSelectable: true, + }, + { + cliName: "videotoolbox", + ffmpegName: "h264_videotoolbox", + accelType: HWAccelVideoToolbox, + deviceType: ffmpeg.AVHWDeviceTypeVideotoolbox, + description: "Apple VideoToolbox", + priority: hwEncoderPriority{darwin: 10}, + probePixelFormat: ffmpeg.AVPixFmtVideotoolbox, + runtimePixelFormat: ffmpeg.AVPixFmtVideotoolbox, + optionPolicy: hwEncoderOptionsVideoToolbox, + cliSelectable: false, + }, +} + +var cliEncoderNames = []string{"auto", "nvenc", "qsv", "vaapi", "vulkan", "software"} + +// ValidCLIEncoderNames returns encoder names accepted by the --encoder flag. +func ValidCLIEncoderNames() []string { + names := make([]string, len(cliEncoderNames)) + copy(names, cliEncoderNames) + return names } -// linuxEncoderPriority defines the encoder preference order for Linux -// Priority: nvenc > qsv > vaapi > vulkan > software -// VAAPI is preferred over Vulkan as it has broader hardware support (AMD, Intel, older Intel) -var linuxEncoderPriority = []encoderSpec{ - {"h264_nvenc", HWAccelNVENC, ffmpeg.AVHWDeviceTypeCuda, "NVIDIA NVENC"}, - {"h264_qsv", HWAccelQSV, ffmpeg.AVHWDeviceTypeQsv, "Intel Quick Sync Video"}, - {"h264_vaapi", HWAccelVAAPI, ffmpeg.AVHWDeviceTypeVaapi, "VA-API"}, - {"h264_vulkan", HWAccelVulkan, ffmpeg.AVHWDeviceTypeVulkan, "Vulkan Video"}, +// HWAccelTypeForCLIName maps a CLI encoder name to its hardware acceleration type. +func HWAccelTypeForCLIName(name string) (HWAccelType, bool) { + entry, ok := hwEncoderRegistryEntryForCLIName(name) + if !ok { + return "", false + } + return entry.accelType, true } -// macOSEncoderPriority defines the encoder preference order for macOS -// Priority: videotoolbox > software -var macOSEncoderPriority = []encoderSpec{ - {"h264_videotoolbox", HWAccelVideoToolbox, ffmpeg.AVHWDeviceTypeVideotoolbox, "Apple VideoToolbox"}, +func hwEncoderRegistryEntryForCLIName(name string) (hwEncoderRegistryEntry, bool) { + for _, entry := range hwEncoderRegistry { + if entry.cliName == name && entry.cliSelectable { + return entry, true + } + } + return hwEncoderRegistryEntry{}, false +} + +func hwEncoderRegistryEntryForType(accelType HWAccelType) (hwEncoderRegistryEntry, bool) { + for _, entry := range hwEncoderRegistry { + if entry.accelType == accelType { + return entry, true + } + } + return hwEncoderRegistryEntry{}, false +} + +func hwEncoderRegistryEntryForHWEncoder(encoder *HWEncoder) (hwEncoderRegistryEntry, bool) { + if encoder == nil { + return hwEncoderRegistryEntry{}, false + } + entry, ok := hwEncoderRegistryEntryForType(encoder.Type) + if !ok { + return hwEncoderRegistryEntry{}, false + } + if encoder.Name != "" && entry.ffmpegName != "" && encoder.Name != entry.ffmpegName { + return hwEncoderRegistryEntry{}, false + } + return entry, true +} + +func hardwareEncoderRegistryForOS(goos string) []hwEncoderRegistryEntry { + var entries []hwEncoderRegistryEntry + for _, entry := range hwEncoderRegistry { + if entry.accelType == HWAccelNone || entry.accelType == HWAccelAuto { + continue + } + if entry.priority.forGOOS(goos) == 0 { + continue + } + entries = append(entries, entry) + } + + sort.SliceStable(entries, func(i, j int) bool { + return entries[i].priority.forGOOS(goos) < entries[j].priority.forGOOS(goos) + }) + + return entries } // suppressHWProbeLogging temporarily silences FFmpeg and libva logging during @@ -110,11 +289,11 @@ func setupTestHWFramesContext(hwDeviceCtx *ffmpeg.AVBufferRef, codecCtx *ffmpeg. // configure and open the encoder with proper hardware context. This catches cases // where a hardware device exists but doesn't support the specific encoder // (e.g., Intel iGPU with Vulkan but no Vulkan Video encoding support). -func testEncoderAvailable(encoderName string, deviceType ffmpeg.AVHWDeviceType, accelType HWAccelType) bool { +func testEncoderAvailable(entry hwEncoderRegistryEntry) bool { restoreLogging := suppressHWProbeLogging() defer restoreLogging() - encName := ffmpeg.ToCStr(encoderName) + encName := ffmpeg.ToCStr(entry.ffmpegName) defer encName.Free() codec := ffmpeg.AVCodecFindEncoderByName(encName) if codec == nil { @@ -122,7 +301,7 @@ func testEncoderAvailable(encoderName string, deviceType ffmpeg.AVHWDeviceType, } var hwDeviceCtx *ffmpeg.AVBufferRef - ret, _ := ffmpeg.AVHWDeviceCtxCreate(&hwDeviceCtx, deviceType, nil, nil, 0) + ret, _ := ffmpeg.AVHWDeviceCtxCreate(&hwDeviceCtx, entry.deviceType, nil, nil, 0) if ret < 0 || hwDeviceCtx == nil { return false } @@ -140,46 +319,15 @@ func testEncoderAvailable(encoderName string, deviceType ffmpeg.AVHWDeviceType, codecCtx.SetTimeBase(ffmpeg.AVMakeQ(1, config.FPS)) codecCtx.SetFramerate(ffmpeg.AVMakeQ(config.FPS, 1)) - // Set pixel format based on encoder type - switch accelType { - case HWAccelNVENC: - // NVENC can accept RGBA directly - codecCtx.SetPixFmt(ffmpeg.AVPixFmtRgba) + codecCtx.SetPixFmt(entry.probePixelFormat) + if entry.probePixelFormat == ffmpeg.AVPixFmtRgba { codecCtx.SetHwDeviceCtx(ffmpeg.AVBufferRef_(hwDeviceCtx)) - case HWAccelVulkan: - // Vulkan requires hardware frames context - codecCtx.SetPixFmt(ffmpeg.AVPixFmtVulkan) - hwFramesRef := setupTestHWFramesContext(hwDeviceCtx, codecCtx, ffmpeg.AVPixFmtVulkan) + } else { + hwFramesRef := setupTestHWFramesContext(hwDeviceCtx, codecCtx, entry.probePixelFormat) if hwFramesRef == nil { return false } defer ffmpeg.AVBufferUnref(&hwFramesRef) - case HWAccelQSV: - // QSV requires hardware frames context - codecCtx.SetPixFmt(ffmpeg.AVPixFmtQsv) - hwFramesRef := setupTestHWFramesContext(hwDeviceCtx, codecCtx, ffmpeg.AVPixFmtQsv) - if hwFramesRef == nil { - return false - } - defer ffmpeg.AVBufferUnref(&hwFramesRef) - case HWAccelVAAPI: - // VA-API requires hardware frames context - codecCtx.SetPixFmt(ffmpeg.AVPixFmtVaapi) - hwFramesRef := setupTestHWFramesContext(hwDeviceCtx, codecCtx, ffmpeg.AVPixFmtVaapi) - if hwFramesRef == nil { - return false - } - defer ffmpeg.AVBufferUnref(&hwFramesRef) - case HWAccelVideoToolbox: - // VideoToolbox requires hardware frames context with NV12 software format - codecCtx.SetPixFmt(ffmpeg.AVPixFmtVideotoolbox) - hwFramesRef := setupTestHWFramesContext(hwDeviceCtx, codecCtx, ffmpeg.AVPixFmtVideotoolbox) - if hwFramesRef == nil { - return false - } - defer ffmpeg.AVBufferUnref(&hwFramesRef) - default: - return false } // Try to open the encoder - this is the definitive test @@ -192,30 +340,14 @@ func testEncoderAvailable(encoderName string, deviceType ffmpeg.AVHWDeviceType, func DetectHWEncoders() []HWEncoder { var encoders []HWEncoder - // Select encoder list based on OS - var priority []encoderSpec - - switch runtime.GOOS { - case "darwin": - priority = macOSEncoderPriority - default: // Linux and others - priority = linuxEncoderPriority - } - // Check each encoder in priority order - for _, enc := range priority { - encoder := HWEncoder{ - Name: enc.name, - Type: enc.accelType, - DeviceType: enc.deviceType, - Description: enc.desc, - Available: false, - } + for _, entry := range hardwareEncoderRegistryForOS(runtime.GOOS) { + encoder := entry.toHWEncoder(false) // Perform comprehensive encoder test - this actually attempts to open // the encoder with proper hardware context, catching cases where the // hardware device exists but doesn't support the specific encoder - encoder.Available = testEncoderAvailable(enc.name, enc.deviceType, enc.accelType) + encoder.Available = testEncoderAvailable(entry) encoders = append(encoders, encoder) } @@ -245,13 +377,11 @@ func SelectBestEncoderFrom(encoders []HWEncoder, requestedType HWAccelType) *HWE } if requestedType == HWAccelAuto { - // Return first available encoder from the priority list - for i := range encoders { - if encoders[i].Available { - return &encoders[i] - } - } - return nil // No hardware available, fall back to software + return selectBestAvailableEncoderFromRegistry(encoders, runtime.GOOS) + } + + if _, ok := hwEncoderRegistryEntryForType(requestedType); !ok { + return nil // Unknown hardware type } // Look for specifically requested encoder type @@ -266,3 +396,14 @@ func SelectBestEncoderFrom(encoders []HWEncoder, requestedType HWAccelType) *HWE return nil // Requested type not found } + +func selectBestAvailableEncoderFromRegistry(encoders []HWEncoder, goos string) *HWEncoder { + for _, entry := range hardwareEncoderRegistryForOS(goos) { + for i := range encoders { + if encoders[i].Type == entry.accelType && encoders[i].Available { + return &encoders[i] + } + } + } + return nil +} diff --git a/internal/encoder/hwaccel_test.go b/internal/encoder/hwaccel_test.go index 7a4f6d6..752f6ea 100644 --- a/internal/encoder/hwaccel_test.go +++ b/internal/encoder/hwaccel_test.go @@ -1,9 +1,190 @@ package encoder import ( + "reflect" "testing" + + ffmpeg "github.com/linuxmatters/ffmpeg-statigo" ) +func TestValidCLIEncoderNames(t *testing.T) { + want := []string{"auto", "nvenc", "qsv", "vaapi", "vulkan", "software"} + got := ValidCLIEncoderNames() + + if !reflect.DeepEqual(got, want) { + t.Fatalf("ValidCLIEncoderNames() = %v, want %v", got, want) + } + + got[0] = "changed" + if ValidCLIEncoderNames()[0] != "auto" { + t.Fatal("ValidCLIEncoderNames returned mutable package state") + } +} + +func TestHWAccelTypeForCLINameValidNames(t *testing.T) { + tests := map[string]HWAccelType{ + "auto": HWAccelAuto, + "nvenc": HWAccelNVENC, + "qsv": HWAccelQSV, + "vaapi": HWAccelVAAPI, + "vulkan": HWAccelVulkan, + "software": HWAccelNone, + } + + for name, want := range tests { + got, ok := HWAccelTypeForCLIName(name) + if !ok { + t.Fatalf("expected %q to be valid", name) + } + if got != want { + t.Fatalf("hwAccelTypeForCLIName(%q) = %q, want %q", name, got, want) + } + } +} + +func TestHWAccelTypeForCLINameRejectsVideoToolbox(t *testing.T) { + entry, ok := hwEncoderRegistryEntryForType(HWAccelVideoToolbox) + if !ok { + t.Fatal("VideoToolbox registry entry missing") + } + if entry.cliSelectable { + t.Fatal("VideoToolbox must not be CLI-selectable") + } + + if got, ok := HWAccelTypeForCLIName("videotoolbox"); ok { + t.Fatalf("expected VideoToolbox to be rejected for CLI lookup, got %q", got) + } +} + +func TestSelectBestEncoderFromFallsBackToSoftware(t *testing.T) { + encoders := []HWEncoder{ + {Name: "h264_nvenc", Type: HWAccelNVENC, Available: false}, + {Name: "h264_qsv", Type: HWAccelQSV, Available: false}, + } + + if got := SelectBestEncoderFrom(encoders, HWAccelAuto); got != nil { + t.Fatalf("expected nil when no hardware encoder is available, got %q", got.Name) + } + if got := SelectBestEncoderFrom(encoders, HWAccelNone); got != nil { + t.Fatalf("expected nil for software selection, got %q", got.Name) + } +} + +func TestHardwareEncoderRegistrySelectionPriority(t *testing.T) { + entries := hardwareEncoderRegistryForOS("linux") + encoders := make([]HWEncoder, 0, len(entries)) + + for _, entry := range entries { + available := entry.accelType == HWAccelNVENC || entry.accelType == HWAccelQSV + encoders = append(encoders, entry.toHWEncoder(available)) + } + + got := selectBestAvailableEncoderFromRegistry(encoders, "linux") + if got == nil { + t.Fatal("expected an encoder from Linux priority list") + } + if got.Type != HWAccelNVENC { + t.Fatalf("selected %q, want %q", got.Type, HWAccelNVENC) + } +} + +func TestSelectBestAvailableEncoderFromRegistryUsesPriority(t *testing.T) { + encoders := []HWEncoder{ + {Name: "h264_qsv", Type: HWAccelQSV, Available: true}, + {Name: "h264_nvenc", Type: HWAccelNVENC, Available: true}, + {Name: "h264_vaapi", Type: HWAccelVAAPI, Available: true}, + } + + got := selectBestAvailableEncoderFromRegistry(encoders, "linux") + if got == nil { + t.Fatal("expected an encoder from Linux priority list") + } + if got.Type != HWAccelNVENC { + t.Fatalf("selected %q, want %q", got.Type, HWAccelNVENC) + } +} + +func TestSelectBestEncoderFromExplicitSemantics(t *testing.T) { + encoders := []HWEncoder{ + {Name: "h264_nvenc", Type: HWAccelNVENC, Available: false}, + {Name: "h264_qsv", Type: HWAccelQSV, Available: true}, + } + + got := SelectBestEncoderFrom(encoders, HWAccelQSV) + if got == nil { + t.Fatal("expected available explicit QSV encoder") + } + if got.Type != HWAccelQSV { + t.Fatalf("selected %q, want %q", got.Type, HWAccelQSV) + } + + if got := SelectBestEncoderFrom(encoders, HWAccelNVENC); got != nil { + t.Fatalf("expected nil for unavailable explicit NVENC encoder, got %q", got.Name) + } + if got := SelectBestEncoderFrom(encoders, HWAccelType("unknown")); got != nil { + t.Fatalf("expected nil for unknown hardware type, got %q", got.Name) + } +} + +func TestHardwareEncoderRegistryRuntimeChoices(t *testing.T) { + tests := map[HWAccelType]struct { + deviceType ffmpeg.AVHWDeviceType + probePixelFormat ffmpeg.AVPixelFormat + runtimePixelFormat ffmpeg.AVPixelFormat + optionPolicy hwEncoderOptionPolicy + }{ + HWAccelNVENC: { + deviceType: ffmpeg.AVHWDeviceTypeCuda, + probePixelFormat: ffmpeg.AVPixFmtRgba, + runtimePixelFormat: ffmpeg.AVPixFmtRgba, + optionPolicy: hwEncoderOptionsNVENC, + }, + HWAccelQSV: { + deviceType: ffmpeg.AVHWDeviceTypeQsv, + probePixelFormat: ffmpeg.AVPixFmtQsv, + runtimePixelFormat: ffmpeg.AVPixFmtQsv, + optionPolicy: hwEncoderOptionsQSV, + }, + HWAccelVAAPI: { + deviceType: ffmpeg.AVHWDeviceTypeVaapi, + probePixelFormat: ffmpeg.AVPixFmtVaapi, + runtimePixelFormat: ffmpeg.AVPixFmtVaapi, + optionPolicy: hwEncoderOptionsVAAPI, + }, + HWAccelVulkan: { + deviceType: ffmpeg.AVHWDeviceTypeVulkan, + probePixelFormat: ffmpeg.AVPixFmtVulkan, + runtimePixelFormat: ffmpeg.AVPixFmtVulkan, + optionPolicy: hwEncoderOptionsVulkan, + }, + HWAccelVideoToolbox: { + deviceType: ffmpeg.AVHWDeviceTypeVideotoolbox, + probePixelFormat: ffmpeg.AVPixFmtVideotoolbox, + runtimePixelFormat: ffmpeg.AVPixFmtVideotoolbox, + optionPolicy: hwEncoderOptionsVideoToolbox, + }, + } + + for accelType, want := range tests { + entry, ok := hwEncoderRegistryEntryForType(accelType) + if !ok { + t.Fatalf("registry entry missing for %q", accelType) + } + if entry.deviceType != want.deviceType { + t.Fatalf("%q device type = %v, want %v", accelType, entry.deviceType, want.deviceType) + } + if entry.probePixelFormat != want.probePixelFormat { + t.Fatalf("%q probe pixel format = %v, want %v", accelType, entry.probePixelFormat, want.probePixelFormat) + } + if entry.runtimePixelFormat != want.runtimePixelFormat { + t.Fatalf("%q runtime pixel format = %v, want %v", accelType, entry.runtimePixelFormat, want.runtimePixelFormat) + } + if entry.optionPolicy != want.optionPolicy { + t.Fatalf("%q option policy = %v, want %v", accelType, entry.optionPolicy, want.optionPolicy) + } + } +} + func TestDetectHWEncoders(t *testing.T) { encoders := DetectHWEncoders() diff --git a/internal/encoder/video_encoder.go b/internal/encoder/video_encoder.go new file mode 100644 index 0000000..e18f424 --- /dev/null +++ b/internal/encoder/video_encoder.go @@ -0,0 +1,570 @@ +package encoder + +import ( + "errors" + "fmt" + "unsafe" + + ffmpeg "github.com/linuxmatters/ffmpeg-statigo" + "github.com/linuxmatters/jive-visualiser/internal/yuv" +) + +type videoEncoder struct { + config Config + stream *ffmpeg.AVStream + codec *ffmpeg.AVCodecContext + + hwEncoder *HWEncoder + hwDeviceCtx *ffmpeg.AVBufferRef + hwFramesCtx *ffmpeg.AVBufferRef + + hwNV12Frame *ffmpeg.AVFrame + swYUVFrame *ffmpeg.AVFrame + rgbaFrame *ffmpeg.AVFrame + + rowPool *yuv.RowPool + inputPixFmt ffmpeg.AVPixelFormat + nextPts int64 +} + +func newVideoEncoder(formatCtx *ffmpeg.AVFormatContext, config Config) (_ *videoEncoder, err error) { + v := &videoEncoder{ + config: config, + rowPool: yuv.NewRowPool(config.Height), + } + defer func() { + if err != nil { + v.close() + } + }() + + codec, err := v.selectCodec() + if err != nil { + return nil, err + } + + v.stream = ffmpeg.AVFormatNewStream(formatCtx, nil) + if v.stream == nil { + return nil, fmt.Errorf("failed to create video stream") + } + v.stream.SetId(0) + + v.codec = ffmpeg.AVCodecAllocContext3(codec) + if v.codec == nil { + return nil, fmt.Errorf("failed to allocate codec context") + } + + v.codec.SetWidth(config.Width) + v.codec.SetHeight(config.Height) + + if err := v.configurePixelFormat(); err != nil { + return nil, err + } + + timeBase := ffmpeg.AVMakeQ(1, config.Framerate) + v.codec.SetTimeBase(timeBase) + + framerate := ffmpeg.AVMakeQ(config.Framerate, 1) + v.codec.SetFramerate(framerate) + + v.codec.SetGopSize(config.Framerate * 2) // Keyframe every 2 seconds + v.stream.SetTimeBase(timeBase) + + // internal/yuv converts with full-range BT.601 JFIF coefficients; untagged + // streams decode as limited-range BT.709, crushing blacks. Tag the stream + // before AVCodecOpen2 (so libx264 writes the VUI) and before + // AVCodecParametersFromContext (so codecpar carries the tags into the MP4). + // These tags are verified correct for the CPU-converted paths (software + // YUV420P and NV12 hardware upload, both via internal/yuv). The NVENC + // RGBA-direct path (writeFrameRGBADirect) converts on the GPU with an + // unverified matrix/range; no NVENC hardware was available to test. If + // NVENC output shows shifted colours, align the GPU conversion with these + // tags rather than changing the tags. + v.codec.SetColorRange(ffmpeg.AVColRangeJpeg) + v.codec.SetColorspace(ffmpeg.AVColSpcSmpte170M) + v.codec.SetColorPrimaries(ffmpeg.AVColPriSmpte170M) + v.codec.SetColorTrc(ffmpeg.AVColTrcSmpte170M) + + var opts *ffmpeg.AVDictionary + defer ffmpeg.AVDictFree(&opts) + + if v.hwEncoder != nil { + v.setHWEncoderOptions(&opts) + } else { + setSoftwareEncoderOptions(&opts) + } + + ret, err := ffmpeg.AVCodecOpen2(v.codec, codec, &opts) + if err := checkFFmpeg(ret, err, "open codec"); err != nil { + return nil, err + } + + ret, err = ffmpeg.AVCodecParametersFromContext(v.stream.Codecpar(), v.codec) + if err := checkFFmpeg(ret, err, "copy codec parameters"); err != nil { + return nil, err + } + + return v, nil +} + +func (v *videoEncoder) selectCodec() (*ffmpeg.AVCodec, error) { + hwAccelType := v.config.HWAccel + if hwAccelType == "" { + hwAccelType = HWAccelAuto + } + + v.hwEncoder = SelectBestEncoder(hwAccelType) + if v.hwEncoder == nil { + return softwareVideoCodec() + } + + encoderName := ffmpeg.ToCStr(v.hwEncoder.Name) + codec := ffmpeg.AVCodecFindEncoderByName(encoderName) + encoderName.Free() + if codec == nil { + return nil, fmt.Errorf("hardware encoder %s not found", v.hwEncoder.Name) + } + + if !v.createHWDeviceContext() { + v.hwEncoder = nil + v.hwDeviceCtx = nil + return softwareVideoCodec() + } + + return codec, nil +} + +func softwareVideoCodec() (*ffmpeg.AVCodec, error) { + codec := ffmpeg.AVCodecFindEncoder(ffmpeg.AVCodecIdH264) + if codec == nil { + return nil, fmt.Errorf("H.264 encoder not found") + } + return codec, nil +} + +func (v *videoEncoder) createHWDeviceContext() bool { + if v.hwEncoder.Type != HWAccelQSV { + ret, err := ffmpeg.AVHWDeviceCtxCreate(&v.hwDeviceCtx, v.hwEncoder.DeviceType, nil, nil, 0) + return err == nil && ret >= 0 + } + + for _, device := range []string{"/dev/dri/renderD128", "/dev/dri/renderD129", ""} { + var deviceCStr *ffmpeg.CStr + if device != "" { + deviceCStr = ffmpeg.ToCStr(device) + } + ret, err := ffmpeg.AVHWDeviceCtxCreate(&v.hwDeviceCtx, v.hwEncoder.DeviceType, deviceCStr, nil, 0) + if deviceCStr != nil { + deviceCStr.Free() + } + if err == nil && ret >= 0 { + return true + } + } + + return false +} + +// setHWEncoderOptions configures encoder-specific options for hardware encoders. +func (v *videoEncoder) setHWEncoderOptions(opts **ffmpeg.AVDictionary) { + entry, ok := hwEncoderRegistryEntryForHWEncoder(v.hwEncoder) + if !ok { + return + } + + for _, option := range hwEncoderOptions(entry.optionPolicy) { + setEncoderOption(opts, option.key, option.value) + } +} + +func setSoftwareEncoderOptions(opts **ffmpeg.AVDictionary) { + for _, option := range []hwEncoderOption{ + {"crf", "24"}, + {"preset", "veryfast"}, + {"tune", "animation"}, + {"profile", "main"}, + {"ref", "1"}, + {"bf", "1"}, + {"subme", "4"}, + } { + setEncoderOption(opts, option.key, option.value) + } +} + +type hwEncoderOption struct { + key string + value string +} + +func hwEncoderOptions(policy hwEncoderOptionPolicy) []hwEncoderOption { + switch policy { + case hwEncoderOptionsNVENC: + return []hwEncoderOption{ + {"preset", "p1"}, + {"tune", "ull"}, + {"rc", "vbr"}, + {"cq", "24"}, + {"profile", "main"}, + {"bf", "0"}, + {"zerolatency", "1"}, + } + case hwEncoderOptionsQSV: + return []hwEncoderOption{ + {"preset", "medium"}, + {"global_quality", "24"}, + {"profile", "main"}, + } + case hwEncoderOptionsVulkan: + return []hwEncoderOption{ + {"content", "rendered"}, + {"qp", "24"}, + {"tune", "ull"}, + {"async_depth", "4"}, + {"profile", "main"}, + {"b_depth", "1"}, + } + case hwEncoderOptionsVAAPI: + return []hwEncoderOption{ + {"qp", "24"}, + {"profile", "main"}, + {"bf", "0"}, + } + case hwEncoderOptionsVideoToolbox: + return []hwEncoderOption{ + {"profile", "main"}, + {"level", "4.1"}, + {"realtime", "1"}, + {"allow_sw", "0"}, + } + default: + return nil + } +} + +func setEncoderOption(opts **ffmpeg.AVDictionary, key, value string) { + keyC := ffmpeg.ToCStr(key) + defer keyC.Free() + valueC := ffmpeg.ToCStr(value) + defer valueC.Free() + _, _ = ffmpeg.AVDictSet(opts, keyC, valueC, 0) +} + +// setupHWFramesContext creates and configures the hardware frames context +// required for GPU-upload encoders. These encoders require frames to be +// uploaded to GPU memory before encoding, using NV12 as the software pixel +// format. +func (v *videoEncoder) setupHWFramesContext(hwPixFmt ffmpeg.AVPixelFormat) error { + if v.hwDeviceCtx == nil { + return fmt.Errorf("hardware device context not available") + } + + hwFramesRef := ffmpeg.AVHWFrameCtxAlloc(v.hwDeviceCtx) + if hwFramesRef == nil { + return fmt.Errorf("failed to allocate hardware frames context") + } + v.hwFramesCtx = hwFramesRef + + framesCtx := ffmpeg.ToAVHWFramesContext(hwFramesRef.Data()) + if framesCtx == nil { + return fmt.Errorf("failed to get hardware frames context") + } + + framesCtx.SetFormat(hwPixFmt) + framesCtx.SetSwFormat(ffmpeg.AVPixFmtNv12) + framesCtx.SetWidth(v.config.Width) + framesCtx.SetHeight(v.config.Height) + framesCtx.SetInitialPoolSize(20) + + ret, err := ffmpeg.AVHWFrameCtxInit(hwFramesRef) + if err := checkFFmpeg(ret, err, "initialize hardware frames context"); err != nil { + return err + } + + v.codec.SetHwFramesCtx(ffmpeg.AVBufferRef_(hwFramesRef)) + + v.hwNV12Frame = ffmpeg.AVFrameAlloc() + if v.hwNV12Frame == nil { + return fmt.Errorf("failed to allocate reusable NV12 frame") + } + v.hwNV12Frame.SetWidth(v.config.Width) + v.hwNV12Frame.SetHeight(v.config.Height) + v.hwNV12Frame.SetFormat(int(ffmpeg.AVPixFmtNv12)) + + ret, err = ffmpeg.AVFrameGetBuffer(v.hwNV12Frame, 0) + if err := checkFFmpeg(ret, err, "allocate NV12 buffer"); err != nil { + return err + } + + return nil +} + +func inputPixelFormatForRegistryEntry(entry hwEncoderRegistryEntry) ffmpeg.AVPixelFormat { + if entry.accelType == HWAccelNone { + return entry.runtimePixelFormat + } + if entry.runtimePixelFormat == ffmpeg.AVPixFmtRgba { + return ffmpeg.AVPixFmtRgba + } + return ffmpeg.AVPixFmtNv12 +} + +// configurePixelFormat sets up pixel formats and hardware context based on +// encoder type. +func (v *videoEncoder) configurePixelFormat() error { + if v.hwEncoder == nil { + entry, ok := hwEncoderRegistryEntryForType(HWAccelNone) + if !ok { + return fmt.Errorf("software encoder registry entry missing") + } + + v.inputPixFmt = inputPixelFormatForRegistryEntry(entry) + v.codec.SetPixFmt(entry.runtimePixelFormat) + + v.swYUVFrame = ffmpeg.AVFrameAlloc() + if v.swYUVFrame == nil { + return fmt.Errorf("failed to allocate reusable YUV frame") + } + v.swYUVFrame.SetWidth(v.config.Width) + v.swYUVFrame.SetHeight(v.config.Height) + v.swYUVFrame.SetFormat(int(entry.runtimePixelFormat)) + + ret, err := ffmpeg.AVFrameGetBuffer(v.swYUVFrame, 0) + if err := checkFFmpeg(ret, err, "allocate YUV buffer"); err != nil { + return err + } + return nil + } + + entry, ok := hwEncoderRegistryEntryForHWEncoder(v.hwEncoder) + if !ok { + return fmt.Errorf("unsupported hardware encoder type: %s", v.hwEncoder.Type) + } + + v.inputPixFmt = inputPixelFormatForRegistryEntry(entry) + v.codec.SetPixFmt(entry.runtimePixelFormat) + + switch v.inputPixFmt { + case ffmpeg.AVPixFmtRgba: + v.codec.SetHwDeviceCtx(ffmpeg.AVBufferRef_(v.hwDeviceCtx)) + + v.rgbaFrame = ffmpeg.AVFrameAlloc() + if v.rgbaFrame == nil { + return fmt.Errorf("failed to allocate reusable RGBA frame") + } + v.rgbaFrame.SetWidth(v.config.Width) + v.rgbaFrame.SetHeight(v.config.Height) + v.rgbaFrame.SetFormat(int(v.inputPixFmt)) + + ret, err := ffmpeg.AVFrameGetBuffer(v.rgbaFrame, 0) + if err := checkFFmpeg(ret, err, "allocate RGBA buffer"); err != nil { + return err + } + + case ffmpeg.AVPixFmtNv12: + if err := v.setupHWFramesContext(entry.runtimePixelFormat); err != nil { + return fmt.Errorf("failed to setup %s frames context: %w", entry.description, err) + } + + default: + return fmt.Errorf("unsupported runtime pixel format for hardware encoder type: %s", v.hwEncoder.Type) + } + + return nil +} + +func (v *videoEncoder) encoderName() string { + if v != nil && v.hwEncoder != nil { + return v.hwEncoder.Name + } + return "libx264" +} + +func (v *videoEncoder) isHardware() bool { + return v != nil && v.hwEncoder != nil +} + +// writeFrameRGBA encodes and writes a single RGBA frame. +func (v *videoEncoder) writeFrameRGBA( + rgbaData []byte, + pkt *ffmpeg.AVPacket, + writePacket func(*ffmpeg.AVPacket) error, +) error { + expectedSize := v.config.Width * v.config.Height * 4 + if len(rgbaData) != expectedSize { + return fmt.Errorf("invalid RGBA frame size: got %d, expected %d", len(rgbaData), expectedSize) + } + + if v.inputPixFmt == ffmpeg.AVPixFmtRgba { + return v.writeFrameRGBADirect(rgbaData, pkt, writePacket) + } + + if v.inputPixFmt == ffmpeg.AVPixFmtNv12 { + return v.writeFrameHWUpload(rgbaData, pkt, writePacket) + } + + return v.writeFrameRGBASoftware(rgbaData, pkt, writePacket) +} + +// writeFrameRGBASoftware converts RGBA directly to YUV420P and encodes. +func (v *videoEncoder) writeFrameRGBASoftware( + rgbaData []byte, + pkt *ffmpeg.AVPacket, + writePacket func(*ffmpeg.AVPacket) error, +) error { + yuvFrame := v.swYUVFrame + ret, err := ffmpeg.AVFrameMakeWritable(yuvFrame) + if err := checkFFmpeg(ret, err, "make YUV frame writable"); err != nil { + return err + } + + convertRGBAToYUV(v.rowPool, rgbaData, yuvFrame, v.config.Width) + + yuvFrame.SetPts(v.nextPts) + v.nextPts++ + + ret, err = ffmpeg.AVCodecSendFrame(v.codec, yuvFrame) + if err := checkFFmpeg(ret, err, "send frame to encoder"); err != nil { + return err + } + + return v.receiveAndWritePackets(pkt, writePacket) +} + +// writeFrameRGBADirect sends an RGBA frame directly to the hardware encoder. +func (v *videoEncoder) writeFrameRGBADirect( + rgbaData []byte, + pkt *ffmpeg.AVPacket, + writePacket func(*ffmpeg.AVPacket) error, +) error { + rgbaFrame := v.rgbaFrame + ret, err := ffmpeg.AVFrameMakeWritable(rgbaFrame) + if err := checkFFmpeg(ret, err, "make RGBA frame writable"); err != nil { + return err + } + + width := v.config.Width + height := v.config.Height + linesize := rgbaFrame.Linesize().Get(0) + data := rgbaFrame.Data().Get(0) + + srcStride := width * 4 + for y := range height { + srcOffset := y * srcStride + dstOffset := y * linesize + copy(unsafe.Slice((*byte)(unsafe.Add(data, dstOffset)), srcStride), //nolint:gosec // offset is within allocated frame + rgbaData[srcOffset:srcOffset+srcStride]) + } + + rgbaFrame.SetPts(v.nextPts) + v.nextPts++ + + ret, err = ffmpeg.AVCodecSendFrame(v.codec, rgbaFrame) + if err := checkFFmpeg(ret, err, "send frame to encoder"); err != nil { + return err + } + + return v.receiveAndWritePackets(pkt, writePacket) +} + +// writeFrameHWUpload converts RGBA to NV12, uploads to GPU, and encodes. +func (v *videoEncoder) writeFrameHWUpload( + rgbaData []byte, + pkt *ffmpeg.AVPacket, + writePacket func(*ffmpeg.AVPacket) error, +) error { + width := v.config.Width + nv12Frame := v.hwNV12Frame + + convertRGBAToNV12(v.rowPool, rgbaData, nv12Frame, width) + + hwFrame := ffmpeg.AVFrameAlloc() + if hwFrame == nil { + return fmt.Errorf("failed to allocate hardware frame") + } + defer ffmpeg.AVFrameFree(&hwFrame) + + ret, err := ffmpeg.AVHWFrameGetBuffer(v.hwFramesCtx, hwFrame, 0) + if err := checkFFmpeg(ret, err, "get hardware frame buffer"); err != nil { + return err + } + + ret, err = ffmpeg.AVHWFrameTransferData(hwFrame, nv12Frame, 0) + if err := checkFFmpeg(ret, err, "upload frame to GPU"); err != nil { + return err + } + + hwFrame.SetPts(v.nextPts) + v.nextPts++ + + ret, err = ffmpeg.AVCodecSendFrame(v.codec, hwFrame) + if err := checkFFmpeg(ret, err, "send frame to hardware encoder"); err != nil { + return err + } + + return v.receiveAndWritePackets(pkt, writePacket) +} + +// receiveAndWritePackets receives encoded packets from the video codec and +// writes them to the output. +func (v *videoEncoder) receiveAndWritePackets(pkt *ffmpeg.AVPacket, writePacket func(*ffmpeg.AVPacket) error) error { + for { + _, err := ffmpeg.AVCodecReceivePacket(v.codec, pkt) + if err != nil { + if errors.Is(err, ffmpeg.EAgain) || errors.Is(err, ffmpeg.AVErrorEOF) { + break + } + return fmt.Errorf("receive packet: %w", err) + } + + if err := writePacket(pkt); err != nil { + return err + } + } + + return nil +} + +func (v *videoEncoder) flush(pkt *ffmpeg.AVPacket, writePacket func(*ffmpeg.AVPacket) error) error { + if v == nil || v.codec == nil { + return nil + } + + ret, err := ffmpeg.AVCodecSendFrame(v.codec, nil) + if err := checkFFmpeg(ret, err, "flush video encoder"); err != nil { + return err + } + return v.receiveAndWritePackets(pkt, writePacket) +} + +func (v *videoEncoder) close() { + if v == nil { + return + } + if v.codec != nil { + ffmpeg.AVCodecFreeContext(&v.codec) + } + if v.hwDeviceCtx != nil { + ffmpeg.AVBufferUnref(&v.hwDeviceCtx) + v.hwDeviceCtx = nil + } + if v.hwFramesCtx != nil { + ffmpeg.AVBufferUnref(&v.hwFramesCtx) + v.hwFramesCtx = nil + } + if v.hwNV12Frame != nil { + ffmpeg.AVFrameFree(&v.hwNV12Frame) + v.hwNV12Frame = nil + } + if v.swYUVFrame != nil { + ffmpeg.AVFrameFree(&v.swYUVFrame) + v.swYUVFrame = nil + } + if v.rgbaFrame != nil { + ffmpeg.AVFrameFree(&v.rgbaFrame) + v.rgbaFrame = nil + } + if v.rowPool != nil { + v.rowPool.Close() + v.rowPool = nil + } +} diff --git a/internal/ui/boxwidth_test.go b/internal/ui/boxwidth_test.go index c40b7e3..0dae9eb 100644 --- a/internal/ui/boxwidth_test.go +++ b/internal/ui/boxwidth_test.go @@ -55,8 +55,8 @@ func boxWidthFixture(t *testing.T, width int) *Model { BarHeights: bars, FrameData: image.NewRGBA(image.Rect(0, 0, 1920, 1080)), } - for i := range m.spectrumPos { - m.spectrumPos[i] = 0.5 + for i := range m.spectrum.positions { + m.spectrum.positions[i] = 0.5 } // Prime the speed sparkline and the Pass 2 wall clock so the gauge cards, // sparkline, meters and frame/source line all render with real content. diff --git a/internal/ui/progress.go b/internal/ui/progress.go index 94fed82..f999a6a 100644 --- a/internal/ui/progress.go +++ b/internal/ui/progress.go @@ -13,7 +13,6 @@ import ( "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/charmbracelet/harmonica" "github.com/linuxmatters/jive-visualiser/internal/config" "github.com/linuxmatters/jive-visualiser/internal/theme" ) @@ -158,13 +157,10 @@ type Model struct { renderState RenderProgress complete *RenderComplete - // Spectrum smoothing: one spring per displayed bar with parallel position - // and velocity slices. The tick is the sole owner that advances these toward - // the producer-owned target m.renderState.BarHeights; renderSpectrum reads - // the positions but never allocates or steps the springs. - spectrumSprings []harmonica.Spring - spectrumPos []float64 - spectrumVel []float64 + // Spectrum smoothing. The tick is the sole owner that advances this toward + // the producer-owned BarHeights; renderSpectrum reads positions but never + // allocates or steps springs. + spectrum spectrumState // speedHistory is a bounded trace of recent realtime-speed samples, appended // once per RenderProgress update and drawn as the Speed card's sparkline. @@ -276,14 +272,6 @@ func NewModel(noPreview bool) *Model { sp.Spinner = spinner.Dot sp.Style = lipgloss.NewStyle().Foreground(midGrey) - // One spring per displayed bar (config.NumBars == 64). Springs share the same - // coefficients; positions and velocities are per-bar. Initialised at rest at - // zero so the first targets ease in rather than snapping. - springs := make([]harmonica.Spring, config.NumBars) - for i := range springs { - springs[i] = harmonica.NewSpring(spectrumSpringDelta, spectrumSpringFreq, spectrumSpringDamping) - } - return &Model{ progressBar: p, summaryBar: summaryBar, @@ -292,9 +280,7 @@ func NewModel(noPreview bool) *Model { phase: PhaseAnalysis, completionDelay: 2 * time.Second, noPreview: noPreview, - spectrumSprings: springs, - spectrumPos: make([]float64, config.NumBars), - spectrumVel: make([]float64, config.NumBars), + spectrum: newSpectrumState(config.NumBars), } } @@ -372,11 +358,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tickMsg: // Advance the spectrum springs one step toward the producer-owned target, // then re-issue the tick to keep the repaint clock running. The tick is the - // SOLE owner that steps the springs; RenderProgress only updates the target - // (m.renderState.BarHeights). This avoids a tick-vs-p.Send double-update - // race. This clock is distinct from spinner.TickMsg; never re-issue one + // sole owner that steps the springs; RenderProgress only updates the + // target. This clock is distinct from spinner.TickMsg; never re-issue one // from the other. - m.advanceSpectrumSprings() + m.spectrum.advance(m.renderState.BarHeights) return m, tickCmd() case spinner.TickMsg: @@ -415,7 +400,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) View() tea.View { var content string if m.phase == PhaseComplete { - content = m.renderFinalProgress() + "\n" + m.renderComplete() + content = m.renderCompletionView() } else { content = m.renderProgress() } @@ -433,7 +418,7 @@ func (m *Model) CompletionSummary() string { if m.complete == nil || m.complete.Err != nil { return "" } - return m.renderFinalProgress() + "\n" + m.renderComplete() + return m.renderCompletionView() } // RenderError returns the fatal error delivered with the RenderComplete @@ -454,6 +439,10 @@ func (m *Model) AssetWarnings() []string { return m.complete.AssetWarnings } +func (m *Model) renderCompletionView() string { + return m.renderFinalProgress() + "\n" + m.renderComplete() +} + // renderFinalProgress renders the progress UI in its final completed state func (m *Model) renderFinalProgress() string { var s strings.Builder @@ -471,32 +460,10 @@ func (m *Model) renderFinalProgress() string { writeProgressRow(&s, m.progressBar.ViewAs(1.0), 100) s.WriteString("\n\n") - // Final timing, the finished mirror of the live Pass 2 gauge cards. Time is - // the total time taken; Speed is the final realtime ratio (no live sparkline); - // Size is the final file size; the live ETA card is repurposed as a Duration - // card showing the source audio length, with the ๐ŸŽœ glyph in vivid red. - videoDuration := time.Duration(m.complete.TotalFrames) * time.Second / config.FPS - var finalSpeed float64 - if m.complete.TotalTime > 0 { - finalSpeed = float64(videoDuration) / float64(m.complete.TotalTime) - } - var sourceDuration time.Duration - if m.audioProfile != nil { - sourceDuration = m.audioProfile.Duration - } - - timeCard := gaugeCard("โฑ", lipgloss.Color("#FFFFFF"), "Time", formatDuration(m.complete.TotalTime), finalCardWidth) - speedCard := gaugeCard("โšก", theme.WarmGray, "Speed", fmt.Sprintf("%.1fร—", finalSpeed), finalCardWidth) - sizeCard := gaugeCard("๐Ÿ–ฌ", lipgloss.Color("#FF8C00"), "Size", formatSizeGlyph(m.complete.FileSize), finalCardWidth) - durationCard := gaugeCard("๐ŸŽœ", lipgloss.Color("#FF2D2D"), "Duration", formatDuration(sourceDuration), finalCardWidth) - - cardsRow := lipgloss.JoinHorizontal(lipgloss.Top, timeCard, " ", speedCard, " ", sizeCard, " ", durationCard) + stats := finalProgressStatsFromComplete(*m.complete, m.audioProfile) + cardsRow := renderFinalProgressCards(stats) s.WriteString(cardsRow) - // Frame line in finished form: a static mid-grey check leads "Frame: N / N" - // (no animated spinner, which would freeze as a glitch in the post-exit static - // print). The codec info matches the live line exactly, right-aligned to the - // cards-row width. s.WriteString("\n") m.writeFinalFrameLine(&s, lipgloss.Width(cardsRow)) @@ -508,6 +475,38 @@ func (m *Model) renderFinalProgress() string { Render(s.String()) } +type finalProgressStats struct { + totalTime time.Duration + sourceDuration time.Duration + speed float64 + fileSize int64 +} + +func finalProgressStatsFromComplete(complete RenderComplete, profile *AudioProfile) finalProgressStats { + videoDuration := time.Duration(complete.TotalFrames) * time.Second / config.FPS + + stats := finalProgressStats{ + totalTime: complete.TotalTime, + fileSize: complete.FileSize, + } + if complete.TotalTime > 0 { + stats.speed = float64(videoDuration) / float64(complete.TotalTime) + } + if profile != nil { + stats.sourceDuration = profile.Duration + } + return stats +} + +func renderFinalProgressCards(stats finalProgressStats) string { + timeCard := gaugeCard("โฑ", lipgloss.Color("#FFFFFF"), "Time", formatDuration(stats.totalTime), finalCardWidth) + speedCard := gaugeCard("โšก", theme.WarmGray, "Speed", fmt.Sprintf("%.1fร—", stats.speed), finalCardWidth) + sizeCard := gaugeCard("๐Ÿ–ฌ", lipgloss.Color("#FF8C00"), "Size", formatSizeGlyph(stats.fileSize), finalCardWidth) + durationCard := gaugeCard("๐ŸŽœ", lipgloss.Color("#FF2D2D"), "Duration", formatDuration(stats.sourceDuration), finalCardWidth) + + return lipgloss.JoinHorizontal(lipgloss.Top, timeCard, " ", speedCard, " ", sizeCard, " ", durationCard) +} + func (m *Model) renderProgress() string { var s strings.Builder @@ -597,46 +596,14 @@ func (m *Model) renderRenderingProgress(s *strings.Builder) { writeProgressRow(s, m.progressBar.View(), int(percent*100)) s.WriteString("\n\n") - // Timing information. Derive elapsed from wall-clock at render time so the - // ~60ms UI tick advances it (and the ETA/speed derived from it) between - // p.Send data updates, rather than freezing on the stale message field. - // Fall back to the message field only if pass2StartTime is unset. - elapsed := time.Since(m.pass2StartTime) - if m.pass2StartTime.IsZero() { - elapsed = m.renderState.Elapsed - } - - var estimatedTotal, eta time.Duration - var speed float64 - - if percent > 0 { - estimatedTotal = time.Duration(float64(elapsed) / percent) - eta = estimatedTotal - elapsed - - videoEncodedSoFar := time.Duration(m.renderState.Frame) * time.Second / config.FPS - if elapsed > 0 { - speed = float64(videoEncodedSoFar) / float64(elapsed) - } - } - - // Four stat gauge cards joined horizontally: Time, Speed (with a live - // sparkline of recent speed samples), Size (live output file size) and ETA. - // The card inner widths are chosen so the joined row plus its three - // separators fits the 74-cell box content area without wrapping. - timeCard := gaugeCard("โฑ", lipgloss.Color("#FFFFFF"), "Time", fmt.Sprintf("%s / %s", - formatClock(elapsed), formatClock(estimatedTotal)), 13) - speedValue := fmt.Sprintf("%.1fร— %s", speed, sparkline(m.speedHistory)) - speedCard := gaugeCard("โšก", theme.WarmGray, "Speed", speedValue, 12) - sizeCard := gaugeCard("๐Ÿ–ฌ", lipgloss.Color("#FF8C00"), "Size", formatSizeGlyph(m.renderState.FileSize), 11) - etaCard := gaugeCard("๐Ÿž‹", lipgloss.Color("#FF2D2D"), "ETA", formatClock(eta), 10) - - cardsRow := lipgloss.JoinHorizontal(lipgloss.Top, timeCard, " ", speedCard, " ", sizeCard, " ", etaCard) + stats := liveRenderStatsFromProgress(m.renderState, m.pass2StartTime) + cardsRow := renderLivePass2Cards(stats, m.speedHistory, m.renderState.FileSize) s.WriteString(cardsRow) // Frame counter and a compact source/codec/size summary on one line, below the // gauge cards. The codec info is right-aligned to end under the cards row. s.WriteString("\n") - m.writeFrameSourceLine(s, lipgloss.Width(cardsRow)) + s.WriteString(renderLiveFrameSourceLine(m.spinner.View(), m.renderState, lipgloss.Width(cardsRow))) } // recordSpeedSample appends the current realtime speed to the bounded history @@ -665,28 +632,18 @@ func (m *Model) recordSpeedSample(msg RenderProgress) { func (m *Model) renderSpectrumAndStats(s *strings.Builder) { s.WriteString("\n") - // Size the spectrum to the preview box's rendered width so its left edge and - // width line up with the preview below. renderSpectrum upsamples the 64 bars - // across the wider column count. Draw the spring positions, not the raw target - // heights, so bars ease toward new BarHeights over successive ticks. The - // springs are advanced only in the tickMsg case; renderSpectrum stays pure - // over its inputs. - spectrum := renderSpectrum(m.spectrumPos, m.spectrumWidth()) - s.WriteString(spectrum) - - // Video preview - if !m.noPreview { - if m.renderState.FrameData != nil && m.renderState.Frame != m.cachedFrameNum { - config := DefaultPreviewConfig() - preview := DownsampleFrame(m.renderState.FrameData, config) - m.cachedPreview = RenderPreview(preview) - m.cachedFrameNum = m.renderState.Frame - } + s.WriteString(renderLiveSpectrumBlock(m.spectrum.positions, m.spectrumWidth())) - if m.cachedPreview != "" { - s.WriteString("\n") - s.WriteString(m.cachedPreview) - } + var preview string + preview, m.cachedPreview, m.cachedFrameNum = renderLivePreviewBlock( + m.noPreview, + m.renderState, + m.cachedPreview, + m.cachedFrameNum, + ) + if preview != "" { + s.WriteString("\n") + s.WriteString(preview) } } @@ -701,23 +658,91 @@ func (m *Model) spectrumWidth() int { return max(m.boxContentWidth()-6, 10) } -// writeFrameSourceLine writes the " Frame X / Y โ€ฆ video ยท audio" line. -// The animated spinner (mid grey) leads the frame counter on the left; the codec -// info (video codec with the live encoder name in mid-grey brackets, then the -// audio codec) is right-aligned so its last character ends at rowWidth, the gauge -// cards row width. The output file size lives in the Size gauge card; the source -// duration is omitted. The codec summary is dropped until any codec data arrives. -func (m *Model) writeFrameSourceLine(s *strings.Builder, rowWidth int) { +type liveRenderStats struct { + elapsed time.Duration + estimatedTotal time.Duration + eta time.Duration + speed float64 +} + +func liveRenderStatsFromProgress(state RenderProgress, pass2StartTime time.Time) liveRenderStats { + // Derive elapsed from wall-clock at render time so the ~60ms UI tick advances + // Time, ETA and Speed between p.Send data updates. Fall back to the message + // field only if pass2StartTime is unset. + elapsed := time.Since(pass2StartTime) + if pass2StartTime.IsZero() { + elapsed = state.Elapsed + } + + stats := liveRenderStats{elapsed: elapsed} + if state.TotalFrames == 0 || state.Frame == 0 { + return stats + } + + percent := float64(state.Frame) / float64(state.TotalFrames) + stats.estimatedTotal = time.Duration(float64(elapsed) / percent) + stats.eta = stats.estimatedTotal - elapsed + + videoEncodedSoFar := time.Duration(state.Frame) * time.Second / config.FPS + if elapsed > 0 { + stats.speed = float64(videoEncodedSoFar) / float64(elapsed) + } + return stats +} + +func renderLivePass2Cards(stats liveRenderStats, speedHistory []float64, fileSize int64) string { + // The card inner widths are chosen so the joined row plus its three separators + // fits the 74-cell box content area without wrapping. + timeCard := gaugeCard("โฑ", lipgloss.Color("#FFFFFF"), "Time", fmt.Sprintf("%s / %s", + formatClock(stats.elapsed), formatClock(stats.estimatedTotal)), 13) + speedValue := fmt.Sprintf("%.1fร— %s", stats.speed, sparkline(speedHistory)) + speedCard := gaugeCard("โšก", theme.WarmGray, "Speed", speedValue, 12) + sizeCard := gaugeCard("๐Ÿ–ฌ", lipgloss.Color("#FF8C00"), "Size", formatSizeGlyph(fileSize), 11) + etaCard := gaugeCard("๐Ÿž‹", lipgloss.Color("#FF2D2D"), "ETA", formatClock(stats.eta), 10) + + return lipgloss.JoinHorizontal(lipgloss.Top, timeCard, " ", speedCard, " ", sizeCard, " ", etaCard) +} + +// renderLiveSpectrumBlock sizes the spectrum to the preview box's rendered +// width, so its left edge and width line up with the preview below. +func renderLiveSpectrumBlock(positions []float64, width int) string { + return renderSpectrum(positions, width) +} + +func renderLivePreviewBlock( + noPreview bool, + state RenderProgress, + cachedPreview string, + cachedFrameNum int, +) (string, string, int) { + if noPreview { + return "", cachedPreview, cachedFrameNum + } + if state.FrameData != nil && state.Frame != cachedFrameNum { + config := DefaultPreviewConfig() + preview := DownsampleFrame(state.FrameData, config) + cachedPreview = RenderPreview(preview) + cachedFrameNum = state.Frame + } + return cachedPreview, cachedPreview, cachedFrameNum +} + +// renderLiveFrameSourceLine renders the " Frame X / Y โ€ฆ video ยท audio" +// line. The output file size lives in the Size gauge card; the source duration is +// omitted. The codec summary is dropped until any codec data arrives. +func renderLiveFrameSourceLine(spinnerView string, state RenderProgress, rowWidth int) string { labelStyle := lipgloss.NewStyle().Foreground(theme.WarmGray) valueStyle := lipgloss.NewStyle().Bold(true) frame := lipgloss.JoinHorizontal(lipgloss.Top, - m.spinner.View(), + spinnerView, labelStyle.Render("Frame: "), - valueStyle.Render(fmt.Sprintf("%d / %d", m.renderState.Frame, m.renderState.TotalFrames)), + valueStyle.Render(fmt.Sprintf("%d / %d", state.Frame, state.TotalFrames)), ) - writeFrameLine(s, frame, m.codecInfo(m.renderState.EncoderName), rowWidth) + var s strings.Builder + writeFrameLine(&s, frame, renderCodecInfo(state.VideoCodec, state.AudioCodec, state.EncoderName), rowWidth) + return s.String() } // writeFinalFrameLine writes the finished Pass 2 frame line: a static mid-grey @@ -726,6 +751,10 @@ func (m *Model) writeFrameSourceLine(s *strings.Builder, rowWidth int) { // info as the live line. The encoder name falls back to the completion record // when the live render state has been cleared. func (m *Model) writeFinalFrameLine(s *strings.Builder, rowWidth int) { + s.WriteString(renderFinalFrameLine(m.renderState, *m.complete, rowWidth)) +} + +func renderFinalFrameLine(state RenderProgress, complete RenderComplete, rowWidth int) string { labelStyle := lipgloss.NewStyle().Foreground(theme.WarmGray) valueStyle := lipgloss.NewStyle().Bold(true) checkStyle := lipgloss.NewStyle().Foreground(midGrey) @@ -733,26 +762,28 @@ func (m *Model) writeFinalFrameLine(s *strings.Builder, rowWidth int) { frame := lipgloss.JoinHorizontal(lipgloss.Top, checkStyle.Render("โœ“ "), labelStyle.Render("Frame: "), - valueStyle.Render(fmt.Sprintf("%d / %d", m.complete.TotalFrames, m.complete.TotalFrames)), + valueStyle.Render(fmt.Sprintf("%d / %d", complete.TotalFrames, complete.TotalFrames)), ) - encoder := m.renderState.EncoderName + encoder := state.EncoderName if encoder == "" { - encoder = m.complete.EncoderName + encoder = complete.EncoderName } - writeFrameLine(s, frame, m.codecInfo(encoder), rowWidth) + var s strings.Builder + writeFrameLine(&s, frame, renderCodecInfo(state.VideoCodec, state.AudioCodec, encoder), rowWidth) + return s.String() } -// codecInfo builds the compact codec summary shared by the live and finished +// renderCodecInfo builds the compact codec summary shared by the live and finished // frame lines: the video codec with the encoder name in mid-grey brackets, then // the audio codec, joined with " ยท ". Returns "" when no codec data is present. -func (m *Model) codecInfo(encoder string) string { +func renderCodecInfo(videoCodec, audioCodec, encoder string) string { valueStyle := lipgloss.NewStyle().Bold(true) greyStyle := lipgloss.NewStyle().Foreground(midGrey) var codec string - if m.renderState.VideoCodec != "" { - video := valueStyle.Render(compactCodec(m.renderState.VideoCodec)) + if videoCodec != "" { + video := valueStyle.Render(compactCodec(videoCodec)) if encoder != "" { video = lipgloss.JoinHorizontal(lipgloss.Top, video, @@ -762,8 +793,8 @@ func (m *Model) codecInfo(encoder string) string { } codec = video } - if m.renderState.AudioCodec != "" { - audio := valueStyle.Render(compactCodec(m.renderState.AudioCodec)) + if audioCodec != "" { + audio := valueStyle.Render(compactCodec(audioCodec)) if codec != "" { codec = lipgloss.JoinHorizontal(lipgloss.Top, codec, valueStyle.Render(" ยท "), audio) } else { diff --git a/internal/ui/spectrum.go b/internal/ui/spectrum.go index 8b77c4d..4297e58 100644 --- a/internal/ui/spectrum.go +++ b/internal/ui/spectrum.go @@ -6,6 +6,7 @@ import ( "strings" "charm.land/lipgloss/v2" + "github.com/charmbracelet/harmonica" "github.com/linuxmatters/jive-visualiser/internal/theme" ) @@ -30,21 +31,38 @@ const spectrumColourGamma = 0.5 // one tick equals one spring step. var spectrumSpringDelta = uiTickInterval.Seconds() -// advanceSpectrumSprings steps every spectrum spring one tick toward the latest -// producer-owned target (m.renderState.BarHeights), storing the new positions -// and velocities back into the Model. Called only from the tickMsg case so the -// tick is the single owner of spring state. Bars without a corresponding target -// (BarHeights shorter than the spring count, or empty before the first -// RenderProgress) ease toward zero. -func (m *Model) advanceSpectrumSprings() { - targets := m.renderState.BarHeights - for i := range m.spectrumSprings { +// spectrumState owns one spring per displayed bar, plus parallel position and +// velocity slices. It starts at rest at zero, so the first targets ease in. +type spectrumState struct { + springs []harmonica.Spring + positions []float64 + velocities []float64 +} + +func newSpectrumState(barCount int) spectrumState { + springs := make([]harmonica.Spring, barCount) + for i := range springs { + springs[i] = harmonica.NewSpring(spectrumSpringDelta, spectrumSpringFreq, spectrumSpringDamping) + } + + return spectrumState{ + springs: springs, + positions: make([]float64, barCount), + velocities: make([]float64, barCount), + } +} + +// advance steps every spring one tick toward the latest producer-owned target. +// Called only from tickMsg, so the tick is the single owner of spring state. +// Bars without a corresponding target ease toward zero. +func (s *spectrumState) advance(targets []float64) { + for i := range s.springs { var target float64 if i < len(targets) { target = targets[i] } - m.spectrumPos[i], m.spectrumVel[i] = m.spectrumSprings[i].Update( - m.spectrumPos[i], m.spectrumVel[i], target) + s.positions[i], s.velocities[i] = s.springs[i].Update( + s.positions[i], s.velocities[i], target) } } diff --git a/internal/ui/spectrum_test.go b/internal/ui/spectrum_test.go index 9b76451..f9c136f 100644 --- a/internal/ui/spectrum_test.go +++ b/internal/ui/spectrum_test.go @@ -71,13 +71,13 @@ func TestSpectrumSpringsInterpolate(t *testing.T) { } m.Update(RenderProgress{Frame: 1, TotalFrames: 100, BarHeights: target}) - if got := m.spectrumPos[0]; got != 0 { + if got := m.spectrum.positions[0]; got != 0 { t.Fatalf("RenderProgress advanced a spring (pos=%v); the producer must only set the target", got) } // One tick: positions move off zero but stay short of the target (no snap). m.Update(tickMsg{}) - afterOne := m.spectrumPos[0] + afterOne := m.spectrum.positions[0] if afterOne <= 0 { t.Fatalf("spring did not move toward target after one tick, pos=%v", afterOne) } @@ -87,7 +87,7 @@ func TestSpectrumSpringsInterpolate(t *testing.T) { // Further ticks keep approaching the target monotonically over this range. m.Update(tickMsg{}) - afterTwo := m.spectrumPos[0] + afterTwo := m.spectrum.positions[0] if afterTwo <= afterOne { t.Fatalf("spring did not continue toward target: tick1=%v tick2=%v", afterOne, afterTwo) } diff --git a/internal/ui/summary.go b/internal/ui/summary.go index 9003128..c5142f1 100644 --- a/internal/ui/summary.go +++ b/internal/ui/summary.go @@ -14,82 +14,106 @@ import ( func (m *Model) renderComplete() string { var s strings.Builder - title := lipgloss.NewStyle(). + s.WriteString(renderCompleteTitle()) + s.WriteString("\n\n") + + styles := completionSummaryStyles{ + dimLabel: lipgloss.NewStyle().Faint(true), + header: lipgloss.NewStyle().Bold(true).Foreground(theme.FireOrange), + label: lipgloss.NewStyle().Faint(true), + value: lipgloss.NewStyle(), + highlightValueStyle: lipgloss.NewStyle().Foreground(theme.FireOrange), + } + + writeCompletionOverview(&s, *m.complete, styles.dimLabel) + writeAudioAnalysisSummary(&s, m.audioProfile, styles) + writeRenderPerformanceSummary(&s, *m.complete, styles, m.summaryBar.ViewAs) + + return lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(theme.FireOrange). + Padding(1, 1). + Width(m.boxContentWidth()). + Render(s.String()) + "\n" +} + +type completionSummaryStyles struct { + dimLabel lipgloss.Style + header lipgloss.Style + label lipgloss.Style + value lipgloss.Style + highlightValueStyle lipgloss.Style +} + +func renderCompleteTitle() string { + return lipgloss.NewStyle(). Bold(true). Foreground(theme.FireYellow). Render("โœ“ Encoding Complete!") +} - s.WriteString(title) - s.WriteString("\n\n") - - // Styles for output summary - dimLabel := lipgloss.NewStyle().Faint(true) - - // Output summary. Size, source duration, total time taken and the encoder now - // live in the finished Pass 2 box above, so they are omitted here. - fmt.Fprintf(&s, "%s%s\n", dimLabel.Render("Output: "), m.complete.OutputFile) +func writeCompletionOverview(s *strings.Builder, complete RenderComplete, dimLabel lipgloss.Style) { + // Size, source duration, total time taken and the encoder live in the + // finished Pass 2 box above, so they are omitted here. + fmt.Fprintf(s, "%s%s\n", dimLabel.Render("Output: "), complete.OutputFile) - videoDuration := time.Duration(m.complete.TotalFrames) * time.Second / config.FPS - fmt.Fprintf(&s, "%s%d frames, %.2f fps average\n", + videoDuration := time.Duration(complete.TotalFrames) * time.Second / config.FPS + fmt.Fprintf(s, "%s%d frames, %.2f fps average\n", dimLabel.Render("Video: "), - m.complete.TotalFrames, - float64(m.complete.TotalFrames)/videoDuration.Seconds()) - if m.complete.SamplesProcessed > 0 { - fmt.Fprintf(&s, "%s%d samples processed\n\n", dimLabel.Render("Audio: "), m.complete.SamplesProcessed) + complete.TotalFrames, + float64(complete.TotalFrames)/videoDuration.Seconds()) + if complete.SamplesProcessed > 0 { + fmt.Fprintf(s, "%s%d samples processed\n\n", dimLabel.Render("Audio: "), complete.SamplesProcessed) } else { s.WriteString("\n") } +} - // Audio Profile section - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(theme.FireOrange) - labelStyle := lipgloss.NewStyle().Faint(true) - valueStyle := lipgloss.NewStyle() - highlightValueStyle := lipgloss.NewStyle().Foreground(theme.FireOrange) - - totalMs := m.complete.TotalTime.Milliseconds() - if totalMs == 0 { - totalMs = 1 - } - - s.WriteString(headerStyle.Render("Pass 1: Audio Analysis")) +func writeAudioAnalysisSummary(s *strings.Builder, profile *AudioProfile, styles completionSummaryStyles) { + s.WriteString(styles.header.Render("Pass 1: Audio Analysis")) s.WriteString("\n") - // Pass 1 table: a borderless two-column label/value grid. The table handles - // column alignment, and renders borderless so it nests inside the - // rounded-border box without double chrome. - if m.audioProfile != nil { + if profile != nil { pass1 := summaryTable().StyleFunc(func(_, col int) lipgloss.Style { if col == 0 { - return labelStyle.PaddingLeft(2).PaddingRight(2) + return styles.label.PaddingLeft(2).PaddingRight(2) } - return valueStyle + return styles.value }) - pass1.Row("Peak Level:", fmt.Sprintf("%.1f ใˆ", m.audioProfile.PeakLevel)) - pass1.Row("RMS Level:", fmt.Sprintf("%.1f ใˆ", m.audioProfile.RMSLevel)) - pass1.Row("Dynamic Range:", fmt.Sprintf("%.1f ใˆ", m.audioProfile.DynamicRange)) - pass1.Row("Optimal Scale:", fmt.Sprintf("%.3f", m.audioProfile.OptimalScale)) - pass1.Row("Analysis Time:", highlightValueStyle.Render(formatDuration(m.audioProfile.AnalysisTime))) + pass1.Row("Peak Level:", fmt.Sprintf("%.1f ใˆ", profile.PeakLevel)) + pass1.Row("RMS Level:", fmt.Sprintf("%.1f ใˆ", profile.RMSLevel)) + pass1.Row("Dynamic Range:", fmt.Sprintf("%.1f ใˆ", profile.DynamicRange)) + pass1.Row("Optimal Scale:", fmt.Sprintf("%.3f", profile.OptimalScale)) + pass1.Row("Analysis Time:", styles.highlightValueStyle.Render(formatDuration(profile.AnalysisTime))) s.WriteString(pass1.Render()) s.WriteString("\n") } s.WriteString("\n") +} - // Pass 2 Performance Breakdown - s.WriteString(headerStyle.Render("Pass 2: Rendering & Encoding")) +func writeRenderPerformanceSummary( + s *strings.Builder, + complete RenderComplete, + styles completionSummaryStyles, + renderBar func(float64) string, +) { + totalMs := complete.TotalTime.Milliseconds() + if totalMs == 0 { + totalMs = 1 + } + + s.WriteString(styles.header.Render("Pass 2: Rendering & Encoding")) s.WriteString("\n") - // Pass 2 table: label, duration, percentage and a rendered summary bar. The - // bar is pre-rendered into a cell value (summaryBar.ViewAs renders once at - // completion). The table aligns the four columns. pass2 := summaryTable().StyleFunc(func(_, col int) lipgloss.Style { switch col { case 0: - return labelStyle.PaddingLeft(2).PaddingRight(2) + return styles.label.PaddingLeft(2).PaddingRight(2) case 1, 2: - return valueStyle.PaddingRight(2) + return styles.value.PaddingRight(2) default: - return valueStyle + return styles.value } }) @@ -99,46 +123,34 @@ func (m *Model) renderComplete() string { label, fmt.Sprintf("~%s", formatDuration(duration)), fmt.Sprintf("(~%d%%)", pct), - m.summaryBar.ViewAs(float64(duration.Milliseconds())/float64(totalMs)), + renderBar(float64(duration.Milliseconds())/float64(totalMs)), ) } - if m.complete.ThumbnailTime > 0 { - barRow("Thumbnail:", m.complete.ThumbnailTime) + if complete.ThumbnailTime > 0 { + barRow("Thumbnail:", complete.ThumbnailTime) } - barRow("Visualisation:", m.complete.VisTime) - barRow("Video encoding:", m.complete.EncodeTime) + barRow("Visualisation:", complete.VisTime) + barRow("Video encoding:", complete.EncodeTime) - if m.complete.AudioTime > 0 { - barRow("Audio encoding:", m.complete.AudioTime) + if complete.AudioTime > 0 { + barRow("Audio encoding:", complete.AudioTime) } - // Calculate unaccounted time including finalisation (Pass 2 only) - // Roll finalisation into runtime/pipeline since it's typically small - accountedTime := m.complete.ThumbnailTime + m.complete.VisTime + - m.complete.EncodeTime + m.complete.AudioTime - otherTime := m.complete.TotalTime - accountedTime + accountedTime := complete.ThumbnailTime + complete.VisTime + + complete.EncodeTime + complete.AudioTime + otherTime := complete.TotalTime - accountedTime if otherTime > 0 { - // Label based on encoder type: hardware encoders have GPU pipeline overhead, - // software encoder has Go runtime/GC overhead otherLabel := "Runtime:" - if m.complete.EncoderIsHW { + if complete.EncoderIsHW { otherLabel = "GPU pipeline:" } barRow(otherLabel, otherTime) } - // Total time gets its own label/value row with the highlight style applied. - pass2.Row("Total time:", highlightValueStyle.Render(formatDuration(m.complete.TotalTime)), "", "") + pass2.Row("Total time:", styles.highlightValueStyle.Render(formatDuration(complete.TotalTime)), "", "") s.WriteString(pass2.Render()) - - return lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(theme.FireOrange). - Padding(1, 1). - Width(m.boxContentWidth()). - Render(s.String()) + "\n" } // summaryTable builds a borderless lipgloss table used for the completion From 52ae74a982d324b9e66c0c52da1ffbdca405457f Mon Sep 17 00:00:00 2001 From: Martin Wimpress Date: Sun, 5 Jul 2026 01:23:38 +0100 Subject: [PATCH 5/7] fix: copy progress bars and check audio encoder errors - Copy bar heights when building render progress messages so the UI reads its own data. - Check FFmpeg return values when making audio frames writable, sending frames, and draining the encoder. - Add a regression test for bar height copying. Signed-off-by: Martin Wimpress --- cmd/jive-visualiser/main_test.go | 7 +++++-- cmd/jive-visualiser/pass2.go | 6 +----- internal/encoder/audio_encoder.go | 19 ++++++++++++++----- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/cmd/jive-visualiser/main_test.go b/cmd/jive-visualiser/main_test.go index e75d0ea..5ae3547 100644 --- a/cmd/jive-visualiser/main_test.go +++ b/cmd/jive-visualiser/main_test.go @@ -176,7 +176,6 @@ func TestPass2ProgressMessageFieldsAndPreviewCopy(t *testing.T) { audioCodecInfo: "AAC 44.1ใŽ‘ mono", sensitivity: 1.25, rearrangedHeights: []float64{1, 2, 3}, - barHeightsCopy: make([]float64, 3), previewImgs: [2]*image.RGBA{ image.NewRGBA(image.Rect(0, 0, config.Width, config.Height)), image.NewRGBA(image.Rect(0, 0, config.Width, config.Height)), @@ -230,6 +229,11 @@ func TestPass2ProgressMessageFieldsAndPreviewCopy(t *testing.T) { t.Errorf("BarHeights[%d] = %v, want %v", i, msg.BarHeights[i], want) } } + + runner.rearrangedHeights[0] = 99 + if msg.BarHeights[0] != 1 { + t.Fatalf("BarHeights shares producer storage, got %v want 1", msg.BarHeights[0]) + } } func TestPass2ProgressMessageOmitsPreviewWhenDisabled(t *testing.T) { @@ -238,7 +242,6 @@ func TestPass2ProgressMessageOmitsPreviewWhenDisabled(t *testing.T) { noPreview: true, }, enc: &encoder.Encoder{}, - barHeightsCopy: make([]float64, 1), rearrangedHeights: []float64{1}, } msg := runner.renderProgressMessage(0, image.NewRGBA(image.Rect(0, 0, 1, 1)), time.Second, 0) diff --git a/cmd/jive-visualiser/pass2.go b/cmd/jive-visualiser/pass2.go index d8162ad..66228a1 100644 --- a/cmd/jive-visualiser/pass2.go +++ b/cmd/jive-visualiser/pass2.go @@ -47,7 +47,6 @@ type pass2Runner struct { harmonicaVel []float64 barHeights []float64 rearrangedHeights []float64 - barHeightsCopy []float64 previewImgs [2]*image.RGBA previewIdx int @@ -90,13 +89,11 @@ func (r *pass2Runner) progressDue(interval time.Duration) bool { } func (r *pass2Runner) renderProgressMessage(frameNum int, img *image.RGBA, elapsed time.Duration, fileSize int64) ui.RenderProgress { - copy(r.barHeightsCopy, r.rearrangedHeights) - return ui.RenderProgress{ Frame: frameNum + 1, TotalFrames: r.numFrames, Elapsed: elapsed, - BarHeights: r.barHeightsCopy, + BarHeights: append([]float64(nil), r.rearrangedHeights...), FileSize: fileSize, Sensitivity: r.sensitivity, FrameData: r.copyPreviewFrame(img), @@ -244,7 +241,6 @@ func (r *pass2Runner) setupRenderState() { r.barHeights = make([]float64, config.NumBars) r.rearrangedHeights = make([]float64, config.NumBars) - r.barHeightsCopy = make([]float64, config.NumBars) } func (r *pass2Runner) setupPreviewBuffers() { diff --git a/internal/encoder/audio_encoder.go b/internal/encoder/audio_encoder.go index c865ddf..29327ce 100644 --- a/internal/encoder/audio_encoder.go +++ b/internal/encoder/audio_encoder.go @@ -135,7 +135,10 @@ func (a *audioEncoder) writeSamples(samples []float32, pkt *ffmpeg.AVPacket, wri return err } - _, _ = ffmpeg.AVFrameMakeWritable(a.frame) + ret, err := ffmpeg.AVFrameMakeWritable(a.frame) + if err := checkFFmpeg(ret, err, "make audio frame writable"); err != nil { + return err + } var writeErr error if a.outputChannels == 2 { @@ -152,7 +155,7 @@ func (a *audioEncoder) writeSamples(samples []float32, pkt *ffmpeg.AVPacket, wri a.frame.SetPts(a.nextPts) a.nextPts += int64(encoderFrameSize) - ret, err := ffmpeg.AVCodecSendFrame(a.codec, a.frame) + ret, err = ffmpeg.AVCodecSendFrame(a.codec, a.frame) if err := checkFFmpeg(ret, err, "send audio frame to encoder"); err != nil { return err } @@ -183,7 +186,10 @@ func (a *audioEncoder) flush(pkt *ffmpeg.AVPacket, writePacket func(*ffmpeg.AVPa } copy(frameSamples, partialSamples) - _, _ = ffmpeg.AVFrameMakeWritable(a.frame) + ret, err := ffmpeg.AVFrameMakeWritable(a.frame) + if err := checkFFmpeg(ret, err, "make final audio frame writable"); err != nil { + return err + } var writeErr error if a.outputChannels == 2 { @@ -199,14 +205,17 @@ func (a *audioEncoder) flush(pkt *ffmpeg.AVPacket, writePacket func(*ffmpeg.AVPa a.frame.SetPts(a.nextPts) a.nextPts += int64(encoderFrameSize) - ret, err := ffmpeg.AVCodecSendFrame(a.codec, a.frame) + ret, err = ffmpeg.AVCodecSendFrame(a.codec, a.frame) if err := checkFFmpeg(ret, err, "send final audio frame"); err != nil { return err } } // Send a NULL frame to enter draining mode. - _, _ = ffmpeg.AVCodecSendFrame(a.codec, nil) + ret, err := ffmpeg.AVCodecSendFrame(a.codec, nil) + if err := checkFFmpeg(ret, err, "drain audio encoder"); err != nil { + return err + } return a.receiveAndWritePackets(pkt, writePacket) } From abdf22754094dcb8deab0e0c89b268311a835437 Mon Sep 17 00:00:00 2001 From: Martin Wimpress Date: Sun, 5 Jul 2026 07:10:15 +0100 Subject: [PATCH 6/7] fix: address peer review findings - Validate CLI input, background, and thumbnail paths before use. - Derive thumbnail paths from the output name instead of string replace. - Guard encoder, FIFO, preview, and row-partition helpers against bad input. - Add tests for path handling, preview sizing, YUV row coverage, and thumbnail output. - Pin workflow tooling versions and refresh README wording and the Harper dictionary. Signed-off-by: Martin Wimpress --- .github/workflows/builder.yml | 10 +-- .harper-dictionary.txt | 8 +++ README.md | 12 ++-- cmd/bench-yuv/main.go | 76 +++++++++++++++----- cmd/jive-visualiser/main.go | 33 +++++++-- cmd/jive-visualiser/main_test.go | 77 +++++++++++++++++++++ internal/encoder/audio_encoder.go | 6 ++ internal/encoder/encoder.go | 3 + internal/encoder/encoder_test.go | 42 ++++++++++-- internal/renderer/thumbnail_test.go | 35 ++++++++-- internal/ui/preview.go | 35 +++++++--- internal/ui/preview_test.go | 103 ++++++++++++++++++++++++++++ internal/yuv/yuv_test.go | 95 +++++++++++++++++++++++++ 13 files changed, 479 insertions(+), 56 deletions(-) create mode 100644 internal/ui/preview_test.go diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 4a6dffc..ca47d30 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -32,11 +32,11 @@ jobs: - run: go vet ./... - name: Check cyclomatic complexity run: | - go install github.com/fzipp/gocyclo/cmd/gocyclo@latest + go install github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0 gocyclo -top 20 -ignore '_test\.go$' -avg . - name: Check ineffectual assignments run: | - go install github.com/gordonklaus/ineffassign@latest + go install github.com/gordonklaus/ineffassign@v0.2.0 ineffassign ./... - uses: golangci/golangci-lint-action@v9 @@ -139,7 +139,7 @@ jobs: comment-summary-in-pr: on-failure - name: Run govulncheck - uses: golang/govulncheck-action@master + uses: golang/govulncheck-action@v1.0.4 with: repo-checkout: false go-version-file: go.mod @@ -264,10 +264,6 @@ jobs: chmod +x jive-visualiser-darwin-arm64 sudo mv jive-visualiser-darwin-arm64 /usr/local/bin/jive-visualiser ``` - - ## Checksums - - SHA256 checksums are provided below for verification. NOTES cat CHANGELOG.md diff --git a/.harper-dictionary.txt b/.harper-dictionary.txt index 38e147b..d712c4f 100644 --- a/.harper-dictionary.txt +++ b/.harper-dictionary.txt @@ -19,3 +19,11 @@ ffmpeg FFmpeg aarch64 centre +AArch64 +amd64 +QuickSync +Qt5 +kung +fu +H +H. diff --git a/README.md b/README.md index 67738d2..5ffd71c 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,14 @@ Your podcast audio deserves more than a static image on YouTube. Jive Visualiser - ๐Ÿ–ผ๏ธ **Thumbnail generator** YouTube-style PNG with your title, saved alongside the video - ๐ŸŽฌ **1280ร—720 @ 30fps** H.264/AAC YouTube-ready MP4, no questions asked - - ๐ŸŽš๏ธ **64 frequency bars** that actually look discrete (not that smeared spectrum nonsense) + - ๐ŸŽš๏ธ **64 frequency bars** that look discrete (not that smeared spectrum nonsense) - ๐Ÿชž **Symmetric mirroring** above and below centre, doubles the visual impact - ๐Ÿ”ฌ **FFT-based analysis** 2048-point Hanning window, linear frequency binning, log-scaled amplitude - โœจ **Spring-driven bar dynamics** bars snap up instantly, spring back down via harmonica peak-hold - ๐Ÿš€ **Stupidly fast** streaming pipeline, parallel RGBโ†’YUV conversion - โšก **GPU acceleration** auto-detected: NVENC, Vulkan, VA-API, QuickSync, VideoToolbox - ๐Ÿ“ฆ **Single binary** No Python. No FFmpeg install required. Just drop and render - - ๐Ÿง **Linux** (amd64 and aarch64) + - ๐Ÿง **Linux** (amd64 and AArch64) - ๐Ÿ **macOS** (x86 and Apple Silicon) ## Usage @@ -36,7 +36,7 @@ Your podcast audio deserves more than a static image on YouTube. Jive Visualiser ./jive-visualiser --episode=42 --title="Linux Matters" input.wav output.mp4 ``` -### Without Episode Number (unnumbered audio) +### Without Episode Number (Unnumbered Audio) ```bash ./jive-visualiser --title="Linux Matters" input.wav output.mp4 ``` @@ -53,7 +53,7 @@ Your podcast audio deserves more than a static image on YouTube. Jive Visualiser ## Build -Jive Visualiser uses [ffmpeg-statigo](https://github.com/linuxmatters/ffmpeg-statigo) for FFmpeg static bindings. +Jive Visualiser uses [FFmpeg statigo](https://github.com/linuxmatters/ffmpeg-statigo) for FFmpeg static bindings. ```bash # Setup or update ffmpeg-statigo submodule and library @@ -69,6 +69,6 @@ just test-encoder # Test encoder FFmpeg's audio visualisation filters (`showfreqs`, `showspectrum`) render continuous frequency spectra, not discrete bars. No amount of FFmpeg filter chain kung-fu can achieve the discrete 64-bar aesthetic required for Linux Matters branding. Solution: Do the FFT analysis and bar rendering in Go, pipe frames to FFmpeg for encoding. -**Why Go over Python?** The original `djfun/audio-visualizer-python` tool is a moribund Qt5 GUI with significant tech debt. For our podcast production needs we wanted multi-archtitecture tools that's that can integrate into automation pipelines. +**Why Go over Python?** The original `djfun/audio-visualizer-python` tool is a moribund Qt5 GUI with significant tech debt. For our podcast production needs we wanted multi-architecture tools that can integrate into automation pipelines. -The Jive Visualiser architecture, such as it is, is available in the [ARCHITECTURE.md](docs/ARCHITECTURE.md) document. +The Jive Visualiser architecture, such as it is, is available in the [architecture document](docs/ARCHITECTURE.md). diff --git a/cmd/bench-yuv/main.go b/cmd/bench-yuv/main.go index 7065ead..8859f6d 100644 --- a/cmd/bench-yuv/main.go +++ b/cmd/bench-yuv/main.go @@ -86,7 +86,35 @@ func convertRGBAToYUVGo(pool *yuv.RowPool, rgbaData []byte, yuvFrame *ffmpeg.AVF }) } -func convertSwscale(rgbaData []byte, yuvFrame *ffmpeg.AVFrame, swsCtx *ffmpeg.SwsContext, srcFrame *ffmpeg.AVFrame, width, height int) { +func checkFFmpeg(ret int, err error, op string) error { + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + if ret < 0 { + return fmt.Errorf("%s: %w", op, ffmpeg.WrapErr(ret)) + } + return nil +} + +func allocateFrame(name string, pixFmt ffmpeg.AVPixelFormat, width, height int) (*ffmpeg.AVFrame, error) { + frame := ffmpeg.AVFrameAlloc() + if frame == nil { + return nil, fmt.Errorf("%s frame allocation failed", name) + } + frame.SetWidth(width) + frame.SetHeight(height) + frame.SetFormat(int(pixFmt)) + + ret, err := ffmpeg.AVFrameGetBuffer(frame, 0) + if err := checkFFmpeg(ret, err, name+" frame buffer allocation"); err != nil { + ffmpeg.AVFrameFree(&frame) + return nil, err + } + + return frame, nil +} + +func convertSwscale(rgbaData []byte, yuvFrame *ffmpeg.AVFrame, swsCtx *ffmpeg.SwsContext, srcFrame *ffmpeg.AVFrame, width, height int) error { // Copy RGBA data into source frame srcLinesize := srcFrame.Linesize().Get(0) srcData := srcFrame.Data().Get(0) @@ -99,17 +127,24 @@ func convertSwscale(rgbaData []byte, yuvFrame *ffmpeg.AVFrame, swsCtx *ffmpeg.Sw } } - _, _ = ffmpeg.SwsScaleFrame(swsCtx, yuvFrame, srcFrame) + ret, err := ffmpeg.SwsScaleFrame(swsCtx, yuvFrame, srcFrame) + return checkFFmpeg(ret, err, "SwsScaleFrame") } func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "bench-yuv: %v\n", err) + os.Exit(1) + } +} + +func run() error { iterations := flag.Int("iterations", 1000, "number of conversions to perform") impl := flag.String("impl", "go", "implementation: go or swscale") flag.Parse() if *impl != "go" && *impl != "swscale" { - fmt.Fprintf(os.Stderr, "Unknown implementation: %s (use 'go' or 'swscale')\n", *impl) - os.Exit(1) + return fmt.Errorf("unknown implementation: %s (use 'go' or 'swscale')", *impl) } rgbaSize := width * height * 4 @@ -121,11 +156,10 @@ func main() { rgbaData[i+3] = 255 // A } - yuvFrame := ffmpeg.AVFrameAlloc() - yuvFrame.SetWidth(width) - yuvFrame.SetHeight(height) - yuvFrame.SetFormat(int(ffmpeg.AVPixFmtYuv420P)) - _, _ = ffmpeg.AVFrameGetBuffer(yuvFrame, 0) + yuvFrame, err := allocateFrame("YUV output", ffmpeg.AVPixFmtYuv420P, width, height) + if err != nil { + return err + } defer ffmpeg.AVFrameFree(&yuvFrame) switch *impl { @@ -138,6 +172,9 @@ func main() { } case "swscale": swsCtx := ffmpeg.SwsAllocContext() + if swsCtx == nil { + return fmt.Errorf("swscale context allocation failed") + } swsCtx.SetSrcW(width) swsCtx.SetSrcH(height) swsCtx.SetSrcFormat(int(ffmpeg.AVPixFmtRgba)) @@ -145,18 +182,25 @@ func main() { swsCtx.SetDstH(height) swsCtx.SetDstFormat(int(ffmpeg.AVPixFmtYuv420P)) swsCtx.SetFlags(uint(ffmpeg.SwsBilinear)) - _, _ = ffmpeg.SwsInitContext(swsCtx, nil, nil) + ret, err := ffmpeg.SwsInitContext(swsCtx, nil, nil) + if err := checkFFmpeg(ret, err, "swscale context initialisation"); err != nil { + ffmpeg.SwsFreecontext(swsCtx) + return err + } defer ffmpeg.SwsFreecontext(swsCtx) - srcFrame := ffmpeg.AVFrameAlloc() - srcFrame.SetWidth(width) - srcFrame.SetHeight(height) - srcFrame.SetFormat(int(ffmpeg.AVPixFmtRgba)) - _, _ = ffmpeg.AVFrameGetBuffer(srcFrame, 0) + srcFrame, err := allocateFrame("RGBA source", ffmpeg.AVPixFmtRgba, width, height) + if err != nil { + return err + } defer ffmpeg.AVFrameFree(&srcFrame) for i := 0; i < *iterations; i++ { - convertSwscale(rgbaData, yuvFrame, swsCtx, srcFrame, width, height) + if err := convertSwscale(rgbaData, yuvFrame, swsCtx, srcFrame, width, height); err != nil { + return err + } } } + + return nil } diff --git a/cmd/jive-visualiser/main.go b/cmd/jive-visualiser/main.go index 715d0eb..8d010a0 100644 --- a/cmd/jive-visualiser/main.go +++ b/cmd/jive-visualiser/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "path/filepath" "strings" "time" @@ -78,8 +79,8 @@ func main() { os.Exit(1) } - if _, err := os.Stat(CLI.Input); os.IsNotExist(err) { - cli.PrintError(fmt.Sprintf("input file does not exist: %s", CLI.Input)) + if err := validateExistingPath("input file", CLI.Input, os.Stat); err != nil { + cli.PrintError(err.Error()) os.Exit(1) } @@ -138,16 +139,16 @@ func main() { } if CLI.BackgroundImage != "" { - if _, err := os.Stat(CLI.BackgroundImage); os.IsNotExist(err) { - cli.PrintError(fmt.Sprintf("background image does not exist: %s", CLI.BackgroundImage)) + if err := validateExistingPath("background image", CLI.BackgroundImage, os.Stat); err != nil { + cli.PrintError(err.Error()) os.Exit(1) } runtimeConfig.BackgroundImagePath = CLI.BackgroundImage } if CLI.ThumbnailImage != "" { - if _, err := os.Stat(CLI.ThumbnailImage); os.IsNotExist(err) { - cli.PrintError(fmt.Sprintf("thumbnail image does not exist: %s", CLI.ThumbnailImage)) + if err := validateExistingPath("thumbnail image", CLI.ThumbnailImage, os.Stat); err != nil { + cli.PrintError(err.Error()) os.Exit(1) } runtimeConfig.ThumbnailImagePath = CLI.ThumbnailImage @@ -172,6 +173,24 @@ func parseEncoderFlag(value string) (encoder.HWAccelType, error) { return hwAccelType, nil } +func validateExistingPath(label, path string, stat func(string) (os.FileInfo, error)) error { + if _, err := stat(path); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("%s does not exist: %s", label, path) + } + return fmt.Errorf("checking %s %q: %w", label, path, err) + } + return nil +} + +func thumbnailOutputPath(outputFile string) string { + ext := filepath.Ext(outputFile) + if ext == "" { + return outputFile + ".png" + } + return strings.TrimSuffix(outputFile, ext) + ".png" +} + func formatEncoderNames(names []string) string { if len(names) == 0 { return "" @@ -185,7 +204,7 @@ func formatEncoderNames(names []string) string { func generateVideo(inputFile string, outputFile string, channels int, noPreview bool, hwAccel encoder.HWAccelType, runtimeConfig *config.RuntimeConfig, meta renderer.PodcastMeta) { overallStartTime := time.Now() - thumbnailPath := strings.Replace(outputFile, ".mp4", ".png", 1) + thumbnailPath := thumbnailOutputPath(outputFile) thumbnailStartTime := time.Now() if err := renderer.GenerateThumbnail(thumbnailPath, meta, runtimeConfig); err != nil { cli.PrintError(fmt.Sprintf("failed to generate thumbnail: %v", err)) diff --git a/cmd/jive-visualiser/main_test.go b/cmd/jive-visualiser/main_test.go index 5ae3547..c5fbac9 100644 --- a/cmd/jive-visualiser/main_test.go +++ b/cmd/jive-visualiser/main_test.go @@ -1,8 +1,10 @@ package main import ( + "errors" "image" "os" + "path/filepath" "testing" "time" @@ -56,6 +58,81 @@ func TestParseEncoderFlagInvalidError(t *testing.T) { } } +func TestThumbnailOutputPathUsesExtension(t *testing.T) { + output := filepath.Join(t.TempDir(), "episode.final.mp4") + want := filepath.Join(filepath.Dir(output), "episode.final.png") + + if got := thumbnailOutputPath(output); got != want { + t.Fatalf("thumbnailOutputPath(%q) = %q, want %q", output, got, want) + } +} + +func TestThumbnailOutputPathHandlesNoExtension(t *testing.T) { + output := filepath.Join(t.TempDir(), "episode") + want := output + ".png" + + if got := thumbnailOutputPath(output); got != want { + t.Fatalf("thumbnailOutputPath(%q) = %q, want %q", output, got, want) + } +} + +func TestStatErrorsAreReportedForCLIPaths(t *testing.T) { + stat := func(path string) (os.FileInfo, error) { + return nil, &os.PathError{Op: "stat", Path: path, Err: os.ErrPermission} + } + + tests := []struct { + label string + path string + want string + }{ + { + label: "input file", + path: "input.wav", + want: "checking input file \"input.wav\": stat input.wav: permission denied", + }, + { + label: "background image", + path: "background.png", + want: "checking background image \"background.png\": stat background.png: permission denied", + }, + { + label: "thumbnail image", + path: "thumbnail.png", + want: "checking thumbnail image \"thumbnail.png\": stat thumbnail.png: permission denied", + }, + } + + for _, tc := range tests { + t.Run(tc.label, func(t *testing.T) { + err := validateExistingPath(tc.label, tc.path, stat) + if err == nil { + t.Fatal("expected stat error") + } + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v, want permission error", err) + } + if err.Error() != tc.want { + t.Fatalf("error = %q, want %q", err, tc.want) + } + }) + } +} + +func TestStatMissingPathKeepsSpecificError(t *testing.T) { + err := validateExistingPath("input file", "missing.wav", func(string) (os.FileInfo, error) { + return nil, os.ErrNotExist + }) + if err == nil { + t.Fatal("expected missing path error") + } + + want := "input file does not exist: missing.wav" + if err.Error() != want { + t.Fatalf("error = %q, want %q", err, want) + } +} + // TestPrefillWritesWholeBuffer asserts the whole FFT prefill (all n samples // FillFFTBuffer returned) reaches the encoder, not just one frame's worth. // Truncating to samplesPerFrame dropped ~13 ms of audio at 44.1 kHz. It pins diff --git a/internal/encoder/audio_encoder.go b/internal/encoder/audio_encoder.go index 29327ce..c09bb71 100644 --- a/internal/encoder/audio_encoder.go +++ b/internal/encoder/audio_encoder.go @@ -306,6 +306,12 @@ func (f *avAudioFIFO) write(samples []float32) error { if len(samples) == 0 { return nil } + if f.channels <= 0 { + return fmt.Errorf("invalid audio FIFO channel count: %d", f.channels) + } + if len(samples)%f.channels != 0 { + return fmt.Errorf("audio FIFO sample length %d is not divisible by channel count %d", len(samples), f.channels) + } if err := f.growScratch(len(samples)); err != nil { return err } diff --git a/internal/encoder/encoder.go b/internal/encoder/encoder.go index 9d83484..87ad3e1 100644 --- a/internal/encoder/encoder.go +++ b/internal/encoder/encoder.go @@ -58,6 +58,9 @@ func New(config Config) (*Encoder, error) { if config.OutputPath == "" { return nil, fmt.Errorf("output path cannot be empty") } + if config.AudioChannels != 0 && config.AudioChannels != 1 && config.AudioChannels != 2 { + return nil, fmt.Errorf("invalid audio channel count: %d", config.AudioChannels) + } return &Encoder{config: config}, nil } diff --git a/internal/encoder/encoder_test.go b/internal/encoder/encoder_test.go index 0a8e7d2..56c9383 100644 --- a/internal/encoder/encoder_test.go +++ b/internal/encoder/encoder_test.go @@ -54,6 +54,31 @@ func fifoRead(t *testing.T, f *avAudioFIFO, nbSamples int) []float32 { return out } +func TestAudioChannelsValidation(t *testing.T) { + base := Config{ + OutputPath: "out.mp4", + Width: 64, + Height: 64, + Framerate: 30, + } + + for _, channels := range []int{0, 1, 2} { + config := base + config.AudioChannels = channels + if _, err := New(config); err != nil { + t.Fatalf("New() with AudioChannels=%d failed: %v", channels, err) + } + } + + for _, channels := range []int{-1, 3} { + config := base + config.AudioChannels = channels + if _, err := New(config); err == nil { + t.Fatalf("New() with AudioChannels=%d succeeded, want error", channels) + } + } +} + // TestAVAudioFIFO_WriteReadMono writes interleaved mono samples, reads a full // frame, and asserts the values round-trip exactly through the packed float32 // FIFO. @@ -108,6 +133,17 @@ func TestAVAudioFIFO_WriteReadStereo(t *testing.T) { } } +func TestAVAudioFIFO_WriteRejectsPartialChannelFrame(t *testing.T) { + f := newTestFIFO(t, 2) + + if err := f.write([]float32{1.0, 2.0, 3.0}); err == nil { + t.Fatal("write() with odd stereo sample count succeeded, want error") + } + if got := fifoSize(t, f); got != 0 { + t.Fatalf("size() after rejected write = %d, want 0", got) + } +} + // TestAVAudioFIFO_ReadMoreThanAvailable checks that a read request larger than // the buffered count returns only what is available and drains the FIFO. func TestAVAudioFIFO_ReadMoreThanAvailable(t *testing.T) { @@ -469,8 +505,7 @@ func makeOpaqueRGBAFrame(width, height int, red, green, blue byte) []byte { // TestEncoderPOC is a proof-of-concept test that encodes a single black frame via RGBA path func TestEncoderPOC(t *testing.T) { - outputPath := "../../testdata/poc-video.mp4" - defer os.Remove(outputPath) + outputPath := t.TempDir() + "/poc-video.mp4" config := Config{ OutputPath: outputPath, @@ -526,8 +561,7 @@ func TestEncoderPOC(t *testing.T) { // TestEncoderRGBA tests the RGBA frame writing path func TestEncoderRGBA(t *testing.T) { - outputPath := "../../testdata/poc-rgba-video.mp4" - defer os.Remove(outputPath) + outputPath := t.TempDir() + "/poc-rgba-video.mp4" config := Config{ OutputPath: outputPath, diff --git a/internal/renderer/thumbnail_test.go b/internal/renderer/thumbnail_test.go index 1b9d507..046ccfa 100644 --- a/internal/renderer/thumbnail_test.go +++ b/internal/renderer/thumbnail_test.go @@ -1,6 +1,8 @@ package renderer import ( + "bytes" + "image/png" "os" "path/filepath" "testing" @@ -8,8 +10,7 @@ import ( "github.com/linuxmatters/jive-visualiser/internal/config" ) -// TestGenerateSampleThumbnail generates a sample thumbnail for development/testing -// This serves both as a test and as a useful development tool +// TestGenerateSampleThumbnail verifies generated thumbnails without writing to the repository. func TestGenerateSampleThumbnail(t *testing.T) { testCases := []struct { title string @@ -31,21 +32,43 @@ func TestGenerateSampleThumbnail(t *testing.T) { // An empty RuntimeConfig falls back to the default colours and assets. runtimeConfig := &config.RuntimeConfig{} + outputDir := t.TempDir() + outputs := make(map[string][]byte) for _, tc := range testCases { t.Run(tc.title, func(t *testing.T) { - outputPath := filepath.Join("../../testdata", tc.outputName) + outputPath := filepath.Join(outputDir, tc.outputName) err := GenerateThumbnail(outputPath, PodcastMeta{Title: tc.title}, runtimeConfig) if err != nil { t.Fatalf("failed to generate thumbnail: %v", err) } - if _, err := os.Stat(outputPath); os.IsNotExist(err) { - t.Fatalf("thumbnail file was not created: %s", outputPath) + data, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("failed to read thumbnail: %v", err) } - t.Logf("โœ“ Generated sample thumbnail: %s", outputPath) + img, err := png.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("failed to decode thumbnail PNG: %v", err) + } + + bounds := img.Bounds() + if bounds.Dx() != config.Width || bounds.Dy() != config.Height { + t.Fatalf("thumbnail dimensions = %dx%d, want %dx%d", + bounds.Dx(), bounds.Dy(), config.Width, config.Height) + } + + outputs[tc.title] = data }) } + + for i, first := range testCases { + for _, second := range testCases[i+1:] { + if bytes.Equal(outputs[first.title], outputs[second.title]) { + t.Fatalf("thumbnails for %q and %q have identical output data", first.title, second.title) + } + } + } } diff --git a/internal/ui/preview.go b/internal/ui/preview.go index e1cf8da..1b30956 100644 --- a/internal/ui/preview.go +++ b/internal/ui/preview.go @@ -26,13 +26,16 @@ func DefaultPreviewConfig() PreviewConfig { // Each terminal cell represents a rectangular region of the source image // Averages all pixels in each region for smooth, high-quality downsampling func DownsampleFrame(frame *image.RGBA, config PreviewConfig) [][]color.RGBA { + if frame == nil || config.Width <= 0 || config.Height <= 0 { + return nil + } + bounds := frame.Bounds() srcWidth := bounds.Dx() srcHeight := bounds.Dy() - - // Source pixels covered by each terminal cell. - cellWidth := srcWidth / config.Width - cellHeight := srcHeight / config.Height + if srcWidth <= 0 || srcHeight <= 0 { + return nil + } preview := make([][]color.RGBA, config.Height) @@ -42,17 +45,17 @@ func DownsampleFrame(frame *image.RGBA, config PreviewConfig) [][]color.RGBA { for row := 0; row < config.Height; row++ { preview[row] = make([]color.RGBA, config.Width) + srcY0, srcY1 := sourceRange(row, config.Height, srcHeight) for col := 0; col < config.Width; col++ { - srcX := col * cellWidth - srcY := row * cellHeight + srcX0, srcX1 := sourceRange(col, config.Width, srcWidth) // Average every source pixel in this cell's region. var sumR, sumG, sumB uint32 pixelCount := uint32(0) - for y := srcY; y < srcY+cellHeight && y < srcHeight; y++ { - offset := y*stride + srcX*4 - for x := 0; x < cellWidth && srcX+x < srcWidth; x++ { + for y := srcY0; y < srcY1; y++ { + offset := y*stride + srcX0*4 + for x := srcX0; x < srcX1; x++ { sumR += uint32(pix[offset]) sumG += uint32(pix[offset+1]) sumB += uint32(pix[offset+2]) @@ -75,8 +78,20 @@ func DownsampleFrame(frame *image.RGBA, config PreviewConfig) [][]color.RGBA { return preview } +func sourceRange(index, cells, sourceSize int) (int, int) { + start := index * sourceSize / cells + end := (index + 1) * sourceSize / cells + if end <= start { + end = start + 1 + } + if end > sourceSize { + end = sourceSize + } + return start, end +} + // RenderPreview converts an RGB preview grid to a string representation -// using ANSI 24-bit true color escape codes for beautiful colored rendering +// using ANSI 24-bit true colour escape codes for beautiful coloured rendering func RenderPreview(preview [][]color.RGBA) string { if len(preview) == 0 { return "" diff --git a/internal/ui/preview_test.go b/internal/ui/preview_test.go new file mode 100644 index 0000000..a00a1f6 --- /dev/null +++ b/internal/ui/preview_test.go @@ -0,0 +1,103 @@ +package ui + +import ( + "image" + "image/color" + "strings" + "testing" +) + +func TestDownsampleFrameOneByOneInput(t *testing.T) { + want := color.RGBA{R: 12, G: 34, B: 56, A: 255} + frame := image.NewRGBA(image.Rect(0, 0, 1, 1)) + frame.SetRGBA(0, 0, want) + + preview := DownsampleFrame(frame, PreviewConfig{Width: 3, Height: 2}) + if len(preview) != 2 { + t.Fatalf("preview height = %d, want 2", len(preview)) + } + + for y, row := range preview { + if len(row) != 3 { + t.Fatalf("preview row %d width = %d, want 3", y, len(row)) + } + for x, got := range row { + if got != want { + t.Errorf("preview[%d][%d] = %#v, want %#v", y, x, got, want) + } + } + } +} + +func TestDownsampleFrameAveragesColourOutput(t *testing.T) { + frame := image.NewRGBA(image.Rect(0, 0, 2, 2)) + frame.SetRGBA(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255}) + frame.SetRGBA(1, 0, color.RGBA{R: 20, G: 30, B: 40, A: 255}) + frame.SetRGBA(0, 1, color.RGBA{R: 30, G: 40, B: 50, A: 255}) + frame.SetRGBA(1, 1, color.RGBA{R: 40, G: 50, B: 60, A: 255}) + + preview := DownsampleFrame(frame, PreviewConfig{Width: 1, Height: 1}) + if len(preview) != 1 { + t.Fatalf("preview height = %d, want 1", len(preview)) + } + if len(preview[0]) != 1 { + t.Fatalf("preview width = %d, want 1", len(preview[0])) + } + + want := color.RGBA{R: 25, G: 35, B: 45, A: 255} + if got := preview[0][0]; got != want { + t.Errorf("averaged colour = %#v, want %#v", got, want) + } +} + +func TestDownsampleFrameEmptyInputs(t *testing.T) { + frame := image.NewRGBA(image.Rect(0, 0, 1, 1)) + + tests := []struct { + name string + frame *image.RGBA + config PreviewConfig + }{ + {name: "nil input", frame: nil, config: PreviewConfig{Width: 1, Height: 1}}, + {name: "zero width", frame: frame, config: PreviewConfig{Width: 0, Height: 1}}, + {name: "zero height", frame: frame, config: PreviewConfig{Width: 1, Height: 0}}, + {name: "negative width", frame: frame, config: PreviewConfig{Width: -1, Height: 1}}, + {name: "negative height", frame: frame, config: PreviewConfig{Width: 1, Height: -1}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := DownsampleFrame(tc.frame, tc.config); len(got) != 0 { + t.Errorf("preview length = %d, want 0", len(got)) + } + }) + } +} + +func TestRenderPreviewBorderDimensions(t *testing.T) { + preview := [][]color.RGBA{ + { + {R: 10, G: 20, B: 30, A: 255}, + {R: 40, G: 50, B: 60, A: 255}, + {R: 70, G: 80, B: 90, A: 255}, + }, + { + {R: 90, G: 80, B: 70, A: 255}, + {R: 60, G: 50, B: 40, A: 255}, + {R: 30, G: 20, B: 10, A: 255}, + }, + } + + lines := strings.Split(strings.TrimPrefix(stripStyles(RenderPreview(preview)), "\n"), "\n") + wantLines := len(preview) + 2 + if len(lines) != wantLines { + t.Fatalf("line count = %d, want %d", len(lines), wantLines) + } + + wantWidth := len(preview[0]) + 2 + for i, line := range lines { + if width := len([]rune(line)); width != wantWidth { + t.Errorf("line %d width = %d, want %d: %q", i, width, wantWidth, line) + } + } +} diff --git a/internal/yuv/yuv_test.go b/internal/yuv/yuv_test.go index 479456c..f2a5166 100644 --- a/internal/yuv/yuv_test.go +++ b/internal/yuv/yuv_test.go @@ -2,6 +2,8 @@ package yuv import ( "image/color" + "runtime" + "sync" "testing" ) @@ -24,6 +26,99 @@ func diffWithin(a, b uint8) bool { return d <= tolerance } +func addRangeCounts(t *testing.T, height int, counts []int, startY, endY int) { + t.Helper() + + if startY < 0 || endY < startY || endY > height { + t.Errorf("range %d:%d outside height %d", startY, endY, height) + return + } + for y := startY; y < endY; y++ { + counts[y]++ + } +} + +func assertRowsCoveredOnce(t *testing.T, name string, counts []int) { + t.Helper() + + for row, count := range counts { + if count != 1 { + t.Errorf("%s row %d processed %d times, want 1", name, row, count) + } + } +} + +func rowCoverageHeights() []struct { + name string + height int +} { + numCPU := runtime.NumCPU() + smallHeight := 1 + if numCPU > 1 { + smallHeight = numCPU - 1 + } + + return []struct { + name string + height int + }{ + {"small", smallHeight}, + {"equal-to-CPU", numCPU}, + {"larger", numCPU*2 + 3}, + } +} + +func TestParallelRows_partitionRowsCoverEveryRowOnce(t *testing.T) { + for _, tc := range rowCoverageHeights() { + t.Run(tc.name, func(t *testing.T) { + counts := make([]int, tc.height) + for _, r := range partitionRows(tc.height) { + addRangeCounts(t, tc.height, counts, r.startY, r.endY) + } + assertRowsCoveredOnce(t, tc.name, counts) + }) + } +} + +func TestParallelRowsProcessesEveryRowOnce(t *testing.T) { + for _, tc := range rowCoverageHeights() { + t.Run(tc.name, func(t *testing.T) { + counts := make([]int, tc.height) + var mu sync.Mutex + + ParallelRows(tc.height, func(startY, endY int) { + mu.Lock() + defer mu.Unlock() + + addRangeCounts(t, tc.height, counts, startY, endY) + }) + + assertRowsCoveredOnce(t, tc.name, counts) + }) + } +} + +func TestRowPoolRunProcessesEveryRowOnce(t *testing.T) { + for _, tc := range rowCoverageHeights() { + t.Run(tc.name, func(t *testing.T) { + pool := NewRowPool(tc.height) + defer pool.Close() + + counts := make([]int, tc.height) + var mu sync.Mutex + + pool.Run(func(startY, endY int) { + mu.Lock() + defer mu.Unlock() + + addRangeCounts(t, tc.height, counts, startY, endY) + }) + + assertRowsCoveredOnce(t, tc.name, counts) + }) + } +} + func TestRGBToYCbCr_AgainstStdlib(t *testing.T) { cases := []struct { name string From 6bbdd4f49b93bfd2aefe3fd1ea147f7c5260f67b Mon Sep 17 00:00:00 2001 From: Martin Wimpress Date: Sun, 5 Jul 2026 08:35:07 +0100 Subject: [PATCH 7/7] chore: drop harper dictionary Signed-off-by: Martin Wimpress --- .harper-dictionary.txt | 29 ----------------------------- 1 file changed, 29 deletions(-) delete mode 100644 .harper-dictionary.txt diff --git a/.harper-dictionary.txt b/.harper-dictionary.txt deleted file mode 100644 index d712c4f..0000000 --- a/.harper-dictionary.txt +++ /dev/null @@ -1,29 +0,0 @@ -Jivefire -Visualiser -visualiser -CGO -resampler -colour -stderr -stdout -stdin -WAV -FLAC -YUV -NVENC -QSV -VideoToolbox -Hanning -statigo -ffmpeg -FFmpeg -aarch64 -centre -AArch64 -amd64 -QuickSync -Qt5 -kung -fu -H -H.