Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .github/jive-visualiser.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions .github/workflows/builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ⚙️
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .tailor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Jive Visualiser 🔥
# Jive Visualiser

> Spin your podcast .wav into a groovy MP4 visualiser with spring-driven real-time audio frequencies.

Expand Down
68 changes: 2 additions & 66 deletions cmd/bench-yuv/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
16 changes: 8 additions & 8 deletions cmd/jive-visualiser/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,36 +27,36 @@ 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},
kong.UsageOnError(),
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)
Expand Down
3 changes: 0 additions & 3 deletions cmd/jive-visualiser/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
3 changes: 1 addition & 2 deletions cmd/jive-visualiser/pass2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 20 additions & 9 deletions cmd/jive-visualiser/pass2_orchestration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
4 changes: 4 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions internal/audio/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions internal/audio/fft.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions internal/audio/metadata.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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{
Expand Down
Loading
Loading