diff --git a/.github/jive-visualiser.gif b/.github/jive-visualiser.gif index 2f18c58..0edf94f 100644 Binary files a/.github/jive-visualiser.gif and b/.github/jive-visualiser.gif differ diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ca47d30..ab5c57f 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -39,6 +39,9 @@ jobs: go install github.com/gordonklaus/ineffassign@v0.2.0 ineffassign ./... - uses: golangci/golangci-lint-action@v9 + with: + # Pin golangci-lint so CI matches the dev shell (flake.nix nixpkgs pin) + version: v2.12.2 lint-actions: name: Lint Action ⚙️ @@ -111,6 +114,8 @@ jobs: cd third_party/ffmpeg-statigo go run ./cmd/download-lib - name: Test + env: + CGO_ENABLED: "1" # FFmpeg static bindings require cgo run: go test ./... security: @@ -214,6 +219,8 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Building jive-visualiser $VERSION for ${{ matrix.os }}/${{ matrix.arch }}" - name: Build binary + env: + CGO_ENABLED: "1" # FFmpeg static bindings require cgo run: | go build -ldflags="-X main.version=${{ steps.version.outputs.version }}" -o jive-visualiser-${{ matrix.os }}-${{ matrix.arch }} ./cmd/jive-visualiser - name: Upload artifact diff --git a/.tailor.yml b/.tailor.yml index 06e8b0d..726defe 100644 --- a/.tailor.yml +++ b/.tailor.yml @@ -2,7 +2,7 @@ license: GPL-3.0 repository: - description: Spin your podcast .wav into a groovy MP4 visualiser with spring-driven real-time audio frequencies 🔥 + description: Spin your podcast .wav into a groovy MP4 visualiser with spring-driven real-time audio frequencies ✨ homepage: https://linuxmatters.sh has_wiki: false has_discussions: false diff --git a/README.md b/README.md index 5ffd71c..10a64a3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Jive Visualiser 🔥 +# Jive Visualiser ✨ > Spin your podcast .wav into a groovy MP4 visualiser with spring-driven real-time audio frequencies. diff --git a/cmd/bench-yuv/main.go b/cmd/bench-yuv/main.go index a3c87ad..fec1d19 100644 --- a/cmd/bench-yuv/main.go +++ b/cmd/bench-yuv/main.go @@ -13,6 +13,7 @@ import ( "unsafe" ffmpeg "github.com/linuxmatters/ffmpeg-statigo" + "github.com/linuxmatters/jive-visualiser/internal/encoder" "github.com/linuxmatters/jive-visualiser/internal/yuv" ) @@ -21,71 +22,6 @@ const ( height = 720 ) -// 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) - - yLinesize := yuvFrame.Linesize().Get(0) - uLinesize := yuvFrame.Linesize().Get(1) - vLinesize := yuvFrame.Linesize().Get(2) - - pool.Run(func(startY, endY int) { - // Align startY to even for correct UV row calculation - evenStart := startY - if evenStart&1 != 0 { - evenStart++ - } - - // Process even rows: Y + UV - for y := evenStart; y < endY; y += 2 { - yPtr := unsafe.Add(yPlane, y*yLinesize) - uvY := y >> 1 - uRowPtr := unsafe.Add(uPlane, uvY*uLinesize) - vRowPtr := unsafe.Add(vPlane, uvY*vLinesize) - rgbaIdx := y * width * 4 - - for x := range width { - 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) - *(*uint8)(unsafe.Add(vRowPtr, uvX)) = yuv.RGBToCr(r, g, b) - } - } - } - - // 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) - rgbaIdx := y * width * 4 - - for x := range width { - 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) - } - } - }) -} - func checkFFmpeg(ret int, err error, op string) error { if err != nil { return fmt.Errorf("%s: %w", op, err) @@ -168,7 +104,7 @@ func run() error { defer pool.Close() for i := 0; i < *iterations; i++ { - convertRGBAToYUVGo(pool, rgbaData, yuvFrame, width) + encoder.ConvertRGBAToYUV(pool, rgbaData, yuvFrame, width) } case "swscale": swsCtx := ffmpeg.SwsAllocContext() diff --git a/cmd/jive-visualiser/main.go b/cmd/jive-visualiser/main.go index ddce3b6..271c5cf 100644 --- a/cmd/jive-visualiser/main.go +++ b/cmd/jive-visualiser/main.go @@ -27,12 +27,12 @@ type cliOptions struct { Probe bool `help:"Probe and display available hardware encoders"` } -// CLI holds the parsed command-line flags and positional arguments. -var CLI cliOptions - func main() { + // opts holds the parsed command-line flags and positional arguments. + var opts cliOptions + ctx := kong.Parse( - &CLI, + &opts, kong.Name("jive-visualiser"), kong.Description("Spin your podcast .wav into a groovy MP4 visualiser."), kong.Vars{"version": version}, @@ -40,23 +40,23 @@ func main() { kong.Help(cli.StyledHelpPrinter()), ) - if CLI.Version { + if opts.Version { cli.PrintVersion(version) os.Exit(0) } - if CLI.Probe { + if opts.Probe { probeHardwareEncoders() os.Exit(0) } // No arguments: show usage instead of erroring - if CLI.Input == "" && CLI.Output == "" { + if opts.Input == "" && opts.Output == "" { _ = ctx.PrintUsage(true) os.Exit(0) } - runConfig, err := newRunConfig(CLI) + runConfig, err := newRunConfig(opts) if err != nil { cli.PrintError(err.Error()) os.Exit(1) diff --git a/cmd/jive-visualiser/main_test.go b/cmd/jive-visualiser/main_test.go index 05e6fb6..c29f8dc 100644 --- a/cmd/jive-visualiser/main_test.go +++ b/cmd/jive-visualiser/main_test.go @@ -315,9 +315,6 @@ func TestPass2ProgressMessageFieldsAndPreviewPayload(t *testing.T) { 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) } diff --git a/cmd/jive-visualiser/pass2.go b/cmd/jive-visualiser/pass2.go index 1773407..6820818 100644 --- a/cmd/jive-visualiser/pass2.go +++ b/cmd/jive-visualiser/pass2.go @@ -102,7 +102,6 @@ func (r *pass2Runner) renderProgressMessage(frameNum int, preview string, previe Elapsed: elapsed, BarHeights: append([]float64(nil), r.rearrangedHeights...), FileSize: fileSize, - Sensitivity: r.sensitivity, Preview: preview, PreviewFrame: previewFrame, VideoCodec: fmt.Sprintf("H.264 %d×%d", config.Width, config.Height), @@ -229,7 +228,7 @@ func (r *pass2Runner) setupRenderState() { harmonicaSpringFreq = 6.0 harmonicaSpringDamping = 1.0 ) - harmonicaDelta := 1.0 / config.Framerate + harmonicaDelta := 1.0 / float64(config.FPS) r.harmonicaSprings = make([]harmonica.Spring, config.NumBars) for i := range r.harmonicaSprings { r.harmonicaSprings[i] = harmonica.NewSpring(harmonicaDelta, harmonicaSpringFreq, harmonicaSpringDamping) diff --git a/cmd/jive-visualiser/pass2_orchestration.go b/cmd/jive-visualiser/pass2_orchestration.go index f82ef72..95bdbfa 100644 --- a/cmd/jive-visualiser/pass2_orchestration.go +++ b/cmd/jive-visualiser/pass2_orchestration.go @@ -77,7 +77,23 @@ func runPass2(p *tea.Program, profile *audio.Profile, cfg pass2Config) { return } - defer runner.enc.Close() + // Close strategy: the encoder is closed exactly once, here, on every path + // (success and error). reachedEnd records whether rendering finished. On an + // error path the deferred Close only frees resources; the failure was + // already reported. On the success path a Close failure is fatal (a dropped + // trailer truncates the file), so it is surfaced in place of sendComplete. + reachedEnd := false + defer func() { + closeErr := runner.closeEncoder() + if !reachedEnd { + return + } + if closeErr != nil { + runner.fail(closeErr) + return + } + runner.sendComplete() + }() // Asset load failures are non-fatal: nil assets degrade gracefully, but warn // so dropped assets are not silent. @@ -142,12 +158,7 @@ func runPass2(p *tea.Program, profile *audio.Profile, cfg pass2Config) { return } - // A Close failure is fatal: the trailer never lands and the file is - // truncated. - if err := runner.closeEncoder(); err != nil { - runner.fail(err) - return - } - - runner.sendComplete() + // The deferred close finalises the encoder and, on success, reports + // completion or a fatal Close error. + reachedEnd = true } diff --git a/flake.nix b/flake.nix index 844d0f4..8b6ff82 100644 --- a/flake.nix +++ b/flake.nix @@ -31,6 +31,10 @@ gcc go_1_26 gocyclo + # golangci-lint follows the nixpkgs pin in flake.lock (currently + # 2.12.2). Keep this in step with the version: pin on + # golangci/golangci-lint-action in .github/workflows/builder.yml so + # local and CI lint results agree. golangci-lint ineffassign just diff --git a/internal/audio/analyzer.go b/internal/audio/analyzer.go index 0bb5c7e..4544dac 100644 --- a/internal/audio/analyzer.go +++ b/internal/audio/analyzer.go @@ -15,7 +15,9 @@ type FrameAnalysis struct { // Peak FFT magnitude across all bars PeakMagnitude float64 - // RMS level of audio chunk + // RMS level over the overlapping FFT window (config.FFTSize samples), not the + // per-frame audio; consecutive frames overlap because the window is wider than + // one frame's worth of samples. RMSLevel float64 } @@ -128,6 +130,9 @@ func AnalyzeAudio(filename string, progressCb ProgressCallback) (*Profile, error profile.Duration = float64(frameNum*samplesPerFrame) / float64(reader.SampleRate()) profile.GlobalPeak = maxPeak + // Mean of per-window RMS values over overlapping FFT windows, not the true RMS + // of the signal. DynamicRange derived from it is a relative bar-scaling + // heuristic, not a calibrated dB figure. profile.GlobalRMS = sumRMS / float64(profile.NumFrames) if profile.GlobalRMS > 0 { @@ -148,18 +153,19 @@ func AnalyzeAudio(filename string, progressCb ProgressCallback) (*Profile, error return profile, nil } -// analyzeFrame extracts statistics from FFT coefficients and audio chunk. +// analyzeFrame extracts statistics from FFT coefficients and the FFT window. // barMagnitudes is an optional buffer that receives per-bar average magnitudes // for progress display; pass nil when bar magnitudes are not needed. -func analyzeFrame(spectrum Spectrum, audioChunk []float64, barMagnitudes []float64) FrameAnalysis { +func analyzeFrame(spectrum Spectrum, fftWindow []float64, barMagnitudes []float64) FrameAnalysis { analysis := FrameAnalysis{} - // Calculate RMS of audio chunk + // RMS over the FFT window. This is the overlapping analysis window, not the + // per-frame audio, so consecutive frames share samples. var sumSquares float64 - for _, sample := range audioChunk { + for _, sample := range fftWindow { sumSquares += sample * sample } - analysis.RMSLevel = math.Sqrt(sumSquares / float64(len(audioChunk))) + analysis.RMSLevel = math.Sqrt(sumSquares / float64(len(fftWindow))) // Bin frequencies into per-bar raw average magnitudes (shared with BinFFT). // Write into the caller's buffer when supplied; otherwise use a local scratch. diff --git a/internal/audio/fft.go b/internal/audio/fft.go index 57027a4..95b2325 100644 --- a/internal/audio/fft.go +++ b/internal/audio/fft.go @@ -23,6 +23,13 @@ type avComplexFloat struct { // a reused buffer; callers must consume it before the next ProcessChunk call. type Spectrum []float32 +// Compile-time assertion that the positive-frequency bin count (FFTSize/2) +// divides evenly by NumBars. binRawMagnitudes assumes every bar covers exactly +// binsPerBar bins; if the division ever truncated it would silently drop the +// spectrum tail. This declares an array whose length is negative when the +// remainder is non-zero, which fails to compile. +var _ [0 - (config.FFTSize/2)%config.NumBars]struct{} + // binRawMagnitudes bins FFT coefficients into per-bar raw average magnitudes. // It writes config.NumBars values into result. The spectrum runs up to the // Nyquist frequency (~22kHz at 44.1kHz) to capture cymbals, hi-hats, and the diff --git a/internal/audio/metadata.go b/internal/audio/metadata.go index 151e78e..cd9df6c 100644 --- a/internal/audio/metadata.go +++ b/internal/audio/metadata.go @@ -1,9 +1,16 @@ package audio import ( + "errors" + "fmt" + ffmpeg "github.com/linuxmatters/ffmpeg-statigo" ) +// errNoDuration is returned when a stream reports no usable duration (a missing +// PTS or a non-positive value), so a sample count cannot be derived. +var errNoDuration = errors.New("stream has no usable duration") + // Metadata holds information about an audio file. type Metadata struct { NumSamples int64 @@ -21,9 +28,16 @@ func GetMetadata(filename string) (*Metadata, error) { audioStream := inputCtx.Streams().Get(uintptr(audioStreamIdx)) //nolint:gosec // stream index is non-negative codecpar := audioStream.Codecpar() - // Total samples derived from stream duration (in time_base units). + // Total samples derived from stream duration (in time_base units). A missing + // PTS (AVNoptsValue) or a non-positive duration would yield a nonsense sample + // count, so reject it rather than propagate garbage. + rawDuration := audioStream.Duration() + if rawDuration == int64(ffmpeg.AVNoptsValue) || rawDuration <= 0 { + return nil, fmt.Errorf("audio stream reports no usable duration: %w", errNoDuration) + } + sampleRate := codecpar.SampleRate() - duration := float64(audioStream.Duration()) * float64(audioStream.TimeBase().Num()) / float64(audioStream.TimeBase().Den()) + duration := float64(rawDuration) * float64(audioStream.TimeBase().Num()) / float64(audioStream.TimeBase().Den()) numSamples := int64(duration * float64(sampleRate)) return &Metadata{ diff --git a/internal/audio/reader.go b/internal/audio/reader.go index c88913f..9d97cdc 100644 --- a/internal/audio/reader.go +++ b/internal/audio/reader.go @@ -79,25 +79,15 @@ func NewStreamingReader(filename string) (*StreamingReader, error) { return nil, fmt.Errorf("failed to allocate codec context") } - ret, err := ffmpeg.AVCodecParametersToContext(d.codecCtx, audioStream.Codecpar()) - if err != nil { + if _, err := ffmpeg.AVCodecParametersToContext(d.codecCtx, audioStream.Codecpar()); err != nil { d.Close() return nil, fmt.Errorf("failed to copy codec parameters: %w", err) } - if ret < 0 { - d.Close() - return nil, fmt.Errorf("failed to copy codec parameters: error code %d", ret) - } - ret, err = ffmpeg.AVCodecOpen2(d.codecCtx, decoder, nil) - if err != nil { + if _, err := ffmpeg.AVCodecOpen2(d.codecCtx, decoder, nil); err != nil { d.Close() return nil, fmt.Errorf("failed to open codec: %w", err) } - if ret < 0 { - d.Close() - return nil, fmt.Errorf("failed to open codec: error code %d", ret) - } d.sampleRate = d.codecCtx.SampleRate() @@ -136,23 +126,20 @@ func (d *StreamingReader) initResampler() error { outLayout := d.outLayoutFrame.ChLayout() ffmpeg.AVChannelLayoutDefault(outLayout, 1) - ret, err := ffmpeg.SwrAllocSetOpts2( + if _, err := ffmpeg.SwrAllocSetOpts2( &d.swr, outLayout, ffmpeg.AVSampleFmtDbl, d.sampleRate, d.codecCtx.ChLayout(), d.codecCtx.SampleFmt(), d.sampleRate, 0, nil, - ) - if err != nil { + ); err != nil { return fmt.Errorf("failed to configure resampler: %w", err) } - if ret < 0 || d.swr == nil { - return fmt.Errorf("failed to configure resampler: error code %d", ret) + if d.swr == nil { + return fmt.Errorf("failed to configure resampler: swr context is nil") } - if ret, err := ffmpeg.SwrInit(d.swr); err != nil { + if _, err := ffmpeg.SwrInit(d.swr); err != nil { return fmt.Errorf("failed to initialise resampler: %w", err) - } else if ret < 0 { - return fmt.Errorf("failed to initialise resampler: error code %d", ret) } if err := d.growOutputBuffer(swrOutBufferSamples); err != nil { @@ -205,7 +192,7 @@ func (d *StreamingReader) ReadInto(dst []float64) (int, error) { // Decode more packets until the buffer holds enough samples. for d.unreadSamples() < numSamples { - ret, err := ffmpeg.AVReadFrame(d.formatCtx, d.packet) + _, err := ffmpeg.AVReadFrame(d.formatCtx, d.packet) if err != nil { if errors.Is(err, ffmpeg.AVErrorEOF) { // End of stream: flush the decoder once to recover any frames @@ -229,9 +216,6 @@ func (d *StreamingReader) ReadInto(dst []float64) (int, error) { } return 0, fmt.Errorf("failed to read packet: %w", err) } - if ret < 0 { - return 0, fmt.Errorf("failed to read packet: error code %d", ret) - } if d.packet.StreamIndex() != d.streamIndex { ffmpeg.AVPacketUnref(d.packet) diff --git a/internal/cli/help.go b/internal/cli/help.go index febf485..3987315 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -44,7 +44,7 @@ func StyledHelpPrinter() kong.HelpPrinter { return kong.HelpPrinter(func(options kong.HelpOptions, ctx *kong.Context) error { var sb strings.Builder - sb.WriteString(helpTitleStyle.Render("Jive Visualiser 🔥")) + sb.WriteString(helpTitleStyle.Render("Jive Visualiser ✨")) sb.WriteString("\n") sb.WriteString(helpDescStyle.Render("Spin your podcast .wav into a groovy MP4 visualiser with spring-driven real-time audio frequencies.")) sb.WriteString("\n") @@ -162,10 +162,7 @@ func getFlags(ctx *kong.Context) []flag { defaultVal := "" if f.HasDefault && !f.IsBool() { - val := f.Default - if val != "" && val != "STRING" && val != "BOOL" { - defaultVal = val - } + defaultVal = f.Default } flags = append(flags, flag{ diff --git a/internal/cli/styles.go b/internal/cli/styles.go index 82b377d..2fe2e6c 100644 --- a/internal/cli/styles.go +++ b/internal/cli/styles.go @@ -13,30 +13,30 @@ var ( // TitleStyle renders the bold red banner heading. TitleStyle = lipgloss.NewStyle(). Bold(true). - Foreground(theme.JiveRed). + Foreground(theme.FireDeepRed). MarginBottom(1) // HeaderStyle renders a section header. HeaderStyle = lipgloss.NewStyle(). Bold(true). - Foreground(theme.GoldOrange). + Foreground(theme.FireGold). MarginTop(1). MarginBottom(1) // ErrorStyle renders an error message. ErrorStyle = lipgloss.NewStyle(). Bold(true). - Foreground(theme.JiveRed) + Foreground(theme.FireDeepRed) // WarningStyle renders a warning message. WarningStyle = lipgloss.NewStyle(). Bold(true). - Foreground(theme.GoldOrange) + Foreground(theme.FireGold) // HighlightStyle emphasises important values. HighlightStyle = lipgloss.NewStyle(). Bold(true). - Foreground(theme.NeonYellow) + Foreground(theme.FireNeon) // KeyStyle renders the key of a key-value pair. KeyStyle = lipgloss.NewStyle(). @@ -50,7 +50,7 @@ var ( // PrintVersion prints version information func PrintVersion(version string) { - fmt.Println(TitleStyle.Render("Jive Visualiser 🔥")) + fmt.Println(TitleStyle.Render("Jive Visualiser ✨")) fmt.Printf("%s %s\n", KeyStyle.Render("Version:"), ValueStyle.Render(version)) } @@ -63,7 +63,7 @@ type EncoderInfo struct { // PrintHardwareProbe prints a styled hardware encoder probe result func PrintHardwareProbe(encoders []EncoderInfo) { - fmt.Println(TitleStyle.Render("Jive Visualiser 🔥")) + fmt.Println(TitleStyle.Render("Jive Visualiser ✨")) fmt.Println(HeaderStyle.Render("Hardware Encoder Probe")) for _, enc := range encoders { diff --git a/internal/config/config.go b/internal/config/config.go index 477ea73..44446dc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,8 +30,6 @@ const ( ) const ( - Framerate = float64(FPS) - SensitivityDecay = 0.985 // Multiplier when overshoot detected (1.5% reduction per frame) SensitivityGrowth = 1.002 // Multiplier when no overshoot (0.2% increase per frame) SensitivityMin = 0.05 diff --git a/internal/encoder/frame.go b/internal/encoder/frame.go index 2061ecb..6778ec7 100644 --- a/internal/encoder/frame.go +++ b/internal/encoder/frame.go @@ -10,6 +10,13 @@ import ( "github.com/linuxmatters/jive-visualiser/internal/yuv" ) +// ConvertRGBAToYUV converts RGBA data directly to YUV420P (planar) format. It is +// a thin exported wrapper over the production hot path so the bench-yuv harness +// can measure the real converter rather than a drifting copy. +func ConvertRGBAToYUV(pool *yuv.RowPool, rgbaData []byte, yuvFrame *ffmpeg.AVFrame, width int) { + convertRGBAToYUV(pool, rgbaData, yuvFrame, width) +} + // 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 diff --git a/internal/encoder/hwaccel.go b/internal/encoder/hwaccel.go index 0651b70..2e7ce7b 100644 --- a/internal/encoder/hwaccel.go +++ b/internal/encoder/hwaccel.go @@ -35,8 +35,10 @@ type HWEncoder struct { type hwEncoderOptionPolicy int const ( + // hwEncoderOptionsNone sets no dispatched options. Used by encoders whose + // options come from elsewhere (the software encoder, see + // setSoftwareEncoderOptions) or that take no options (auto). hwEncoderOptionsNone hwEncoderOptionPolicy = iota - hwEncoderOptionsSoftware hwEncoderOptionsNVENC hwEncoderOptionsQSV hwEncoderOptionsVAAPI @@ -100,7 +102,7 @@ var hwEncoderRegistry = []hwEncoderRegistryEntry{ priority: hwEncoderPriority{linux: 100, darwin: 100}, probePixelFormat: ffmpeg.AVPixFmtYuv420P, runtimePixelFormat: ffmpeg.AVPixFmtYuv420P, - optionPolicy: hwEncoderOptionsSoftware, + optionPolicy: hwEncoderOptionsNone, cliSelectable: true, }, { diff --git a/internal/encoder/video_encoder.go b/internal/encoder/video_encoder.go index e18f424..f8e0bbd 100644 --- a/internal/encoder/video_encoder.go +++ b/internal/encoder/video_encoder.go @@ -19,6 +19,7 @@ type videoEncoder struct { hwFramesCtx *ffmpeg.AVBufferRef hwNV12Frame *ffmpeg.AVFrame + hwFrame *ffmpeg.AVFrame swYUVFrame *ffmpeg.AVFrame rgbaFrame *ffmpeg.AVFrame @@ -295,6 +296,13 @@ func (v *videoEncoder) setupHWFramesContext(hwPixFmt ffmpeg.AVPixelFormat) error return err } + // Reused across frames: each write unrefs it and checks out a fresh GPU + // buffer via AVHWFrameGetBuffer, avoiding a per-frame Go-side alloc/free. + v.hwFrame = ffmpeg.AVFrameAlloc() + if v.hwFrame == nil { + return fmt.Errorf("failed to allocate reusable hardware frame") + } + return nil } @@ -477,11 +485,9 @@ func (v *videoEncoder) writeFrameHWUpload( convertRGBAToNV12(v.rowPool, rgbaData, nv12Frame, width) - hwFrame := ffmpeg.AVFrameAlloc() - if hwFrame == nil { - return fmt.Errorf("failed to allocate hardware frame") - } - defer ffmpeg.AVFrameFree(&hwFrame) + // Release the previous frame's GPU buffer before checking out a fresh one. + hwFrame := v.hwFrame + ffmpeg.AVFrameUnref(hwFrame) ret, err := ffmpeg.AVHWFrameGetBuffer(v.hwFramesCtx, hwFrame, 0) if err := checkFFmpeg(ret, err, "get hardware frame buffer"); err != nil { @@ -555,6 +561,10 @@ func (v *videoEncoder) close() { ffmpeg.AVFrameFree(&v.hwNV12Frame) v.hwNV12Frame = nil } + if v.hwFrame != nil { + ffmpeg.AVFrameFree(&v.hwFrame) + v.hwFrame = nil + } if v.swYUVFrame != nil { ffmpeg.AVFrameFree(&v.swYUVFrame) v.swYUVFrame = nil diff --git a/internal/renderer/frame.go b/internal/renderer/frame.go index e5eafce..bddb1fe 100644 --- a/internal/renderer/frame.go +++ b/internal/renderer/frame.go @@ -9,6 +9,13 @@ import ( "golang.org/x/image/font" ) +// The black-clear loop in Draw copies the frame buffer 32 bytes (8 pixels) at a +// time and relies on the buffer length being an exact multiple of 32. This +// compile-time guard fails the build with an unsigned-integer underflow if a +// resolution change ever breaks that assumption, so nobody has to remember the +// dependency. Width*Height*4 = 3,686,400 for 1280x720, which is a multiple of 32. +const _ = uint(0) - uint(config.Width*config.Height*4%32) + // PodcastMeta bundles the episode metadata shown on a frame and thumbnail. // A nil Episode means no episode number was supplied; the renderer then omits // the episode overlay entirely rather than drawing a placeholder. @@ -132,6 +139,8 @@ func (f *Frame) Draw(barHeights []float64) { copy(f.img.Pix, f.bgImage.Pix) } else { // Clear to black 8 pixels (32 bytes) per copy for better memory bandwidth. + // This stride assumes len(Pix) is a multiple of 32; the package-level + // compile-time guard above enforces that for the fixed resolution. blackPattern := [32]byte{ 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, diff --git a/internal/renderer/thumbnail_test.go b/internal/renderer/thumbnail_test.go index 046ccfa..33c7a60 100644 --- a/internal/renderer/thumbnail_test.go +++ b/internal/renderer/thumbnail_test.go @@ -2,14 +2,117 @@ package renderer import ( "bytes" + "image" "image/png" "os" "path/filepath" "testing" "github.com/linuxmatters/jive-visualiser/internal/config" + "golang.org/x/image/draw" ) +// TestThumbnailTextPlacement pins where the rotated title text lands, not just +// the image dimensions. It renders the same thumbnail twice with two different +// text colours; the background is identical between the two, so the pixels that +// differ are exactly the text. It then checks the vertical extent of those text +// pixels against the placement invariant in drawThumbnailText: the highest text +// pixel should sit roughly config.ThumbnailMargin from the top, and no text +// should reach the bottom half of the image. +// +// The test asserts observed placement; it does not re-derive the affine formula. +func TestThumbnailTextPlacement(t *testing.T) { + // Multi-word title so both title lines are non-empty (see splitTitle). + const title = "Frankenstein's Ubuntu Server Framework" + outputDir := t.TempDir() + + // Two runtime configs identical except for the text colour. The overridden + // colour drives getThumbnailTextColor, so the text is the only difference. + cfgA := &config.RuntimeConfig{ + TextColor: config.OptionalColor{R: 255, G: 0, B: 0, Set: true}, + } + cfgB := &config.RuntimeConfig{ + TextColor: config.OptionalColor{R: 0, G: 255, B: 0, Set: true}, + } + + imgA := generateThumbnailImage(t, outputDir, "placement_a.png", title, cfgA) + imgB := generateThumbnailImage(t, outputDir, "placement_b.png", title, cfgB) + + if imgA.Bounds() != imgB.Bounds() { + t.Fatalf("thumbnail bounds differ: %v vs %v", imgA.Bounds(), imgB.Bounds()) + } + + // Collect the vertical extent of pixels that differ between the two renders. + // Those differing pixels are the title text. + minY, maxY := -1, -1 + diffCount := 0 + for y := range config.Height { + for x := range config.Width { + if pixelAt(imgA, x, y) != pixelAt(imgB, x, y) { + diffCount++ + if minY == -1 || y < minY { + minY = y + } + if y > maxY { + maxY = y + } + } + } + } + + if diffCount == 0 { + t.Fatal("no differing pixels between the two renders; text not drawn") + } + + // Invariant 1: the highest text pixel aligns with ThumbnailMargin from the + // top. Allow a tolerance band for anti-aliasing, rounding, and the fact that + // the rotated line-1 baseline geometry places the visible top a few pixels + // off the ideal. The band is generous but still excludes gross regressions. + const tolerance = 20 + wantTop := config.ThumbnailMargin + if minY < wantTop-tolerance || minY > wantTop+tolerance { + t.Fatalf("highest text pixel Y = %d, want within %d of %d (ThumbnailMargin)", + minY, tolerance, wantTop) + } + + // Invariant 2: text stays in the top region. drawThumbnailText and + // findOptimalFontSize keep both lines above the vertical centre, so no text + // pixel should reach the bottom half of the image. + centreY := config.Height / 2 + if maxY >= centreY { + t.Fatalf("lowest text pixel Y = %d reaches bottom half (centre %d); text not kept in top region", + maxY, centreY) + } +} + +// generateThumbnailImage writes a thumbnail for the given title and config, then +// decodes it back into an *image.RGBA for pixel inspection. +func generateThumbnailImage(t *testing.T, dir, name, title string, cfg *config.RuntimeConfig) *image.RGBA { + t.Helper() + + outputPath := filepath.Join(dir, name) + if err := GenerateThumbnail(outputPath, PodcastMeta{Title: title}, cfg); err != nil { + t.Fatalf("failed to generate thumbnail: %v", err) + } + + data, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("failed to read thumbnail: %v", err) + } + + decoded, err := png.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("failed to decode thumbnail PNG: %v", err) + } + + rgba, ok := decoded.(*image.RGBA) + if !ok { + rgba = image.NewRGBA(decoded.Bounds()) + draw.Draw(rgba, rgba.Bounds(), decoded, decoded.Bounds().Min, draw.Src) + } + return rgba +} + // TestGenerateSampleThumbnail verifies generated thumbnails without writing to the repository. func TestGenerateSampleThumbnail(t *testing.T) { testCases := []struct { diff --git a/internal/theme/theme.go b/internal/theme/theme.go index 04664df..6660a8b 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -7,22 +7,29 @@ import ( colorful "github.com/lucasb-eyer/go-colorful" ) -// Fire colour palette -// Shared fire theme colours for consistent branding across CLI and TUI. +// Fire colour palette: the single source of truth for brand colours across the +// CLI and TUI. One vocabulary, one home. Every hex is distinct; do not collapse +// values into each other. +// +// Renderer rule: +// - Spectrum ramp and TUI progress use the Fire* ramp colours (crimson → yellow). +// - The Lipgloss help renderer uses FireYellow, FireOrange, FireRed and WarmGray. +// - version/probe output uses FireDeepRed, FireGold, FireNeon and the neutral greys. var ( - // Core fire colours (dark to bright) + // Fire ramp colours, dark to bright. FireCrimson → FireYellow also build the + // spectrum ramp below, so their order and values are load-bearing. FireYellow = lipgloss.Color("#FFD700") // Bright yellow FireOrange = lipgloss.Color("#FF8C00") // Deep orange FireRed = lipgloss.Color("#FF4500") // Orange-red FireCrimson = lipgloss.Color("#DC143C") // Deep crimson - // Accent colours - WarmGray = lipgloss.Color("#B8860B") // Dark goldenrod for subtle text + // Fire accents for version and probe output. + FireDeepRed = lipgloss.Color("#A40000") // Deep red for titles and errors + FireGold = lipgloss.Color("#FFA500") // Orange-gold for section headers + FireNeon = lipgloss.Color("#FFFF00") // Bright yellow for highlighted values - // CLI output colours - JiveRed = lipgloss.Color("#A40000") // Deep red for titles and errors - GoldOrange = lipgloss.Color("#FFA500") // Orange-gold for section headers - NeonYellow = lipgloss.Color("#FFFF00") // Bright yellow for highlighted values + // Neutral accents shared by CLI and TUI. + WarmGray = lipgloss.Color("#B8860B") // Dark goldenrod for subtle text NeutralGray = lipgloss.Color("#888888") // Neutral grey for keys and labels BrightWhite = lipgloss.Color("#FFFFFF") // White for emphasised values ) diff --git a/internal/ui/progress.go b/internal/ui/progress.go index a2abe22..c6b58d8 100644 --- a/internal/ui/progress.go +++ b/internal/ui/progress.go @@ -52,7 +52,6 @@ type RenderProgress struct { Elapsed time.Duration BarHeights []float64 FileSize int64 - Sensitivity float64 Preview string PreviewFrame int VideoCodec string @@ -96,6 +95,20 @@ type AudioProfile struct { AnalysisTime time.Duration } +// decibelFloor is the level reported for silence. A silent file yields a zero +// magnitude, and 20·log10(0) is -Inf; clamping to this floor keeps the summary +// showing a finite "-120 ㏈" instead. +const decibelFloor = -120.0 + +// toDecibels converts a linear magnitude to decibels, clamping non-positive +// input to decibelFloor so silence renders as a finite level, not "-Inf". +func toDecibels(v float64) float64 { + if v <= 0 { + return decibelFloor + } + return 20 * math.Log10(v) +} + // progressQuitMsg is sent when it's time to quit after showing completion type progressQuitMsg struct{} @@ -316,9 +329,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case AnalysisComplete: m.audioProfile = &AudioProfile{ Duration: msg.Duration, - PeakLevel: 20 * math.Log10(msg.PeakMagnitude), - RMSLevel: 20 * math.Log10(msg.RMSLevel), - DynamicRange: 20 * math.Log10(msg.DynamicRange), + PeakLevel: toDecibels(msg.PeakMagnitude), + RMSLevel: toDecibels(msg.RMSLevel), + DynamicRange: toDecibels(msg.DynamicRange), OptimalScale: msg.OptimalScale, AnalysisTime: msg.AnalysisTime, } @@ -335,6 +348,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case RenderProgress: m.renderState = msg m.recordSpeedSample(msg) + m.updatePreviewCache(msg) // Drive the bar's spring toward the new target. The producer owns the // target percent; the bar's own FrameMsg loop animates the fill. if msg.TotalFrames > 0 { @@ -458,7 +472,7 @@ func (m *Model) renderFinalProgress() string { title := lipgloss.NewStyle(). Bold(true). Foreground(theme.FireYellow). - Render("Jive Visualiser 🔥") + Render("Jive Visualiser ✨") s.WriteString(title) s.WriteString("\n") @@ -521,7 +535,7 @@ func (m *Model) renderProgress() string { title := lipgloss.NewStyle(). Bold(true). Foreground(theme.FireYellow). - Render("Jive Visualiser 🔥") + Render("Jive Visualiser ✨") s.WriteString(title) s.WriteString("\n") @@ -640,18 +654,13 @@ func (m *Model) recordSpeedSample(msg RenderProgress) { func (m *Model) renderSpectrumAndStats(s *strings.Builder) { s.WriteString("\n") - s.WriteString(renderLiveSpectrumBlock(m.spectrum.positions, m.spectrumWidth())) + // The spectrum is sized to the preview box's rendered width so its left edge + // and width line up with the preview below. + s.WriteString(renderSpectrum(m.spectrum.positions, m.spectrumWidth())) - var preview string - preview, m.cachedPreview, m.cachedFrameNum = renderLivePreviewBlock( - m.noPreview, - m.renderState, - m.cachedPreview, - m.cachedFrameNum, - ) - if preview != "" { + if !m.noPreview && m.cachedPreview != "" { s.WriteString("\n") - s.WriteString(preview) + s.WriteString(m.cachedPreview) } } @@ -711,26 +720,18 @@ func renderLivePass2Cards(stats liveRenderStats, speedHistory []float64, fileSiz 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 +// updatePreviewCache stores the latest preview frame on the model so View can +// read it without side-effects. It runs in Update when a RenderProgress arrives, +// keeping the last non-empty preview when a message carries none so the block +// never blanks between frames. +func (m *Model) updatePreviewCache(state RenderProgress) { + if m.noPreview { + return } - if state.Preview != "" && state.PreviewFrame != cachedFrameNum { - cachedPreview = state.Preview - cachedFrameNum = state.PreviewFrame + if state.Preview != "" && state.PreviewFrame != m.cachedFrameNum { + m.cachedPreview = state.Preview + m.cachedFrameNum = state.PreviewFrame } - return cachedPreview, cachedPreview, cachedFrameNum } // renderLiveFrameSourceLine renders the " Frame X / Y … video · audio" diff --git a/internal/ui/progress_test.go b/internal/ui/progress_test.go index 45434e8..f435ca0 100644 --- a/internal/ui/progress_test.go +++ b/internal/ui/progress_test.go @@ -107,7 +107,6 @@ func TestUpdateRenderProgressStoresState(t *testing.T) { TotalFrames: 1000, Elapsed: 5 * time.Second, FileSize: 1024, - Sensitivity: 1.5, Preview: "preview", PreviewFrame: 250, VideoCodec: "libx264", @@ -231,6 +230,46 @@ func TestAnalysisCompleteResetsProgressBar(t *testing.T) { } } +// TestAnalysisCompleteSilentInputClampsDecibels guards the silent-file path. The +// analyser reports a zero peak, RMS and dynamic range for silence, and +// 20·log10(0) is -Inf. The AnalysisComplete handler must clamp to the finite +// decibel floor so the summary shows "-120.0 ㏈" instead of "-Inf ㏈". +func TestAnalysisCompleteSilentInputClampsDecibels(t *testing.T) { + m := NewModel(true) + next, _ := m.Update(AnalysisComplete{ + PeakMagnitude: 0, + RMSLevel: 0, + DynamicRange: 0, + Duration: 60 * time.Second, + OptimalScale: 0.0075, + AnalysisTime: time.Second, + }) + got := asModel(t, next) + + if got.audioProfile == nil { + t.Fatal("audioProfile = nil, want populated") + } + for name, level := range map[string]float64{ + "PeakLevel": got.audioProfile.PeakLevel, + "RMSLevel": got.audioProfile.RMSLevel, + "DynamicRange": got.audioProfile.DynamicRange, + } { + if level != decibelFloor { + t.Errorf("%s = %v, want %v (decibel floor)", name, level, decibelFloor) + } + } + + var s strings.Builder + writeAudioAnalysisSummary(&s, got.audioProfile, completionSummaryStyles{}) + summary := stripStyles(s.String()) + if strings.Contains(summary, "-Inf") { + t.Errorf("summary contains -Inf for silent input:\n%s", summary) + } + if !strings.Contains(summary, "-120.0 ㏈") { + t.Errorf("summary missing floor value -120.0 ㏈ for silent input:\n%s", summary) + } +} + func TestUpdateRenderComplete(t *testing.T) { m := NewModel(true) // Zero the delay so the scheduled tea.Tick fires immediately when invoked, @@ -464,60 +503,52 @@ func TestUpdateSpinnerTick(t *testing.T) { } } -func TestRenderLivePreviewBlockCachesFreshPreview(t *testing.T) { - preview, cachedPreview, cachedFrameNum := renderLivePreviewBlock( - false, - RenderProgress{Frame: 10, Preview: "fresh-preview", PreviewFrame: 10}, - "", - 0, - ) +func TestUpdatePreviewCacheStoresFreshPreview(t *testing.T) { + m := NewModel(false) + m.updatePreviewCache(RenderProgress{Frame: 10, Preview: "fresh-preview", PreviewFrame: 10}) - if preview != "fresh-preview" { - t.Fatalf("preview = %q, want fresh-preview", preview) + if m.cachedPreview != "fresh-preview" { + t.Fatalf("cachedPreview = %q, want fresh-preview", m.cachedPreview) } - if cachedPreview != "fresh-preview" { - t.Fatalf("cachedPreview = %q, want fresh-preview", cachedPreview) + if m.cachedFrameNum != 10 { + t.Fatalf("cachedFrameNum = %d, want 10", m.cachedFrameNum) } - if cachedFrameNum != 10 { - t.Fatalf("cachedFrameNum = %d, want 10", cachedFrameNum) + if m.noPreview || m.cachedPreview != "fresh-preview" { + t.Fatalf("preview = %q, want fresh-preview", m.cachedPreview) } } -func TestRenderLivePreviewBlockReusesStalePreview(t *testing.T) { - preview, cachedPreview, cachedFrameNum := renderLivePreviewBlock( - false, - RenderProgress{Frame: 11}, - "cached-preview", - 10, - ) +func TestUpdatePreviewCacheReusesStalePreview(t *testing.T) { + m := NewModel(false) + m.cachedPreview = "cached-preview" + m.cachedFrameNum = 10 + m.updatePreviewCache(RenderProgress{Frame: 11}) - if preview != "cached-preview" { - t.Fatalf("preview = %q, want cached-preview", preview) + if m.cachedPreview != "cached-preview" { + t.Fatalf("cachedPreview = %q, want cached-preview", m.cachedPreview) } - if cachedPreview != "cached-preview" { - t.Fatalf("cachedPreview = %q, want cached-preview", cachedPreview) + if m.cachedFrameNum != 10 { + t.Fatalf("cachedFrameNum = %d, want 10", m.cachedFrameNum) } - if cachedFrameNum != 10 { - t.Fatalf("cachedFrameNum = %d, want 10", cachedFrameNum) + if m.noPreview || m.cachedPreview != "cached-preview" { + t.Fatalf("preview = %q, want cached-preview", m.cachedPreview) } } -func TestRenderLivePreviewBlockHonoursNoPreview(t *testing.T) { - preview, cachedPreview, cachedFrameNum := renderLivePreviewBlock( - true, - RenderProgress{Frame: 10, Preview: "fresh-preview", PreviewFrame: 10}, - "cached-preview", - 9, - ) +func TestUpdatePreviewCacheHonoursNoPreview(t *testing.T) { + m := NewModel(true) + m.cachedPreview = "cached-preview" + m.cachedFrameNum = 9 + m.updatePreviewCache(RenderProgress{Frame: 10, Preview: "fresh-preview", PreviewFrame: 10}) - if preview != "" { - t.Fatalf("preview = %q, want empty preview", preview) + if m.cachedPreview != "cached-preview" { + t.Fatalf("cachedPreview = %q, want cached-preview", m.cachedPreview) } - if cachedPreview != "cached-preview" { - t.Fatalf("cachedPreview = %q, want cached-preview", cachedPreview) + if m.cachedFrameNum != 9 { + t.Fatalf("cachedFrameNum = %d, want 9", m.cachedFrameNum) } - if cachedFrameNum != 9 { - t.Fatalf("cachedFrameNum = %d, want 9", cachedFrameNum) + if !m.noPreview { + t.Fatal("noPreview = false, want true so the preview block stays hidden") } } diff --git a/internal/ui/summary.go b/internal/ui/summary.go index c5142f1..61e3cca 100644 --- a/internal/ui/summary.go +++ b/internal/ui/summary.go @@ -58,10 +58,16 @@ func writeCompletionOverview(s *strings.Builder, complete RenderComplete, dimLab fmt.Fprintf(s, "%s%s\n", dimLabel.Render("Output: "), complete.OutputFile) videoDuration := time.Duration(complete.TotalFrames) * time.Second / config.FPS - fmt.Fprintf(s, "%s%d frames, %.2f fps average\n", - dimLabel.Render("Video: "), - complete.TotalFrames, - float64(complete.TotalFrames)/videoDuration.Seconds()) + if seconds := videoDuration.Seconds(); seconds > 0 { + fmt.Fprintf(s, "%s%d frames, %.2f fps average\n", + dimLabel.Render("Video: "), + complete.TotalFrames, + float64(complete.TotalFrames)/seconds) + } else { + fmt.Fprintf(s, "%s%d frames\n", + dimLabel.Render("Video: "), + complete.TotalFrames) + } if complete.SamplesProcessed > 0 { fmt.Fprintf(s, "%s%d samples processed\n\n", dimLabel.Render("Audio: "), complete.SamplesProcessed) } else {