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
6 changes: 3 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,16 @@ jobs:

- name: Download ffmpeg-statigo libraries
run: |
cd vendor/ffmpeg-statigo
cd third_party/ffmpeg-statigo
go run ./cmd/download-lib

- name: Run tests
run: go test -mod=mod -v ./...
run: go test -v ./...

- name: Build ${{ matrix.os }} ${{ matrix.arch }}
run: |
echo "Building for ${{ matrix.os }}/${{ matrix.arch }}..."
go build -mod=mod -v -o jivefire-${{ matrix.os }}-${{ matrix.arch }} ./cmd/jivefire
go build -v -o jivefire-${{ matrix.os }}-${{ matrix.arch }} ./cmd/jivefire
ls -lh jivefire-${{ matrix.os }}-${{ matrix.arch }}

- name: Upload artifact
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ jobs:

- name: Download ffmpeg-statigo libraries
run: |
cd vendor/ffmpeg-statigo
cd third_party/ffmpeg-statigo
go run ./cmd/download-lib

- name: Get version from tag
Expand All @@ -118,7 +118,7 @@ jobs:
run: |
VERSION=${{ steps.get_version.outputs.version }}
echo "Building jivefire $VERSION for ${{ matrix.os }}/${{ matrix.arch }}"
go build -mod=mod -ldflags="-X main.version=$VERSION" -o jivefire-${{ matrix.os }}-${{ matrix.arch }} ./cmd/jivefire
go build -ldflags="-X main.version=$VERSION" -o jivefire-${{ matrix.os }}-${{ matrix.arch }} ./cmd/jivefire
ls -lh jivefire-${{ matrix.os }}-${{ matrix.arch }}

- name: Upload Assets to Release
Expand Down
4 changes: 2 additions & 2 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[submodule "vendor/ffmpeg-statigo"]
path = vendor/ffmpeg-statigo
[submodule "third_party/ffmpeg-statigo"]
path = third_party/ffmpeg-statigo
url = https://github.com/linuxmatters/ffmpeg-statigo
60 changes: 33 additions & 27 deletions cmd/jivefire/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,12 @@ func generateVideo(inputFile string, outputFile string, channels int, noPreview
overallStartTime := time.Now()

thumbnailPath := strings.Replace(outputFile, ".mp4", ".png", 1)
thumbnailStartTime := time.Now()
if err := renderer.GenerateThumbnail(thumbnailPath, CLI.Title, runtimeConfig); err != nil {
cli.PrintError(fmt.Sprintf("failed to generate thumbnail: %v", err))
os.Exit(1)
}
thumbnailDuration := time.Since(thumbnailStartTime)

// Create Bubbletea program for Pass 1
model := ui.NewPass1Model()
Expand Down Expand Up @@ -203,14 +205,14 @@ func generateVideo(inputFile string, outputFile string, channels int, noPreview
}
defer reader.Close()

// Initialize encoder with both video and audio
// Initialize encoder with video and audio (using new sample-based API)
enc, err := encoder.New(encoder.Config{
OutputPath: outputFile,
Width: config.Width,
Height: config.Height,
Framerate: config.FPS,
AudioPath: inputFile, // Enable Phase 2B audio processing
AudioChannels: channels, // Mono (1) or stereo (2)
SampleRate: reader.SampleRate(), // Use sample rate from audio file
AudioChannels: channels, // Mono (1) or stereo (2)
})
if err != nil {
cli.PrintError(fmt.Sprintf("creating encoder: %v", err))
Expand All @@ -226,7 +228,7 @@ func generateVideo(inputFile string, outputFile string, channels int, noPreview
// Run rendering in goroutine
var encodingErr error
var perfStats struct {
fftTime, binTime, drawTime, writeTime, audioFlushTime, totalTime time.Duration
fftTime, binTime, drawTime, writeTime, totalTime time.Duration
}

go func() {
Expand Down Expand Up @@ -314,6 +316,18 @@ func generateVideo(inputFile string, outputFile string, channels int, noPreview

copy(fftBuffer, initialSamples)

// Write initial audio samples to encoder (first samplesPerFrame worth)
// This corresponds to the audio for frame 0
initialAudioSamples := make([]float32, samplesPerFrame)
for i := 0; i < samplesPerFrame && i < len(initialSamples); i++ {
initialAudioSamples[i] = float32(initialSamples[i])
}
if err := enc.WriteAudioSamples(initialAudioSamples); err != nil {
encodingErr = fmt.Errorf("error writing initial audio: %w", err)
p2.Quit()
return
}

// Process frames until we run out of audio
frameNum := 0
for frameNum < numFrames {
Expand Down Expand Up @@ -414,15 +428,6 @@ func generateVideo(inputFile string, outputFile string, channels int, noPreview
}
totalWrite += time.Since(t0)

// Process audio up to this video frame's timestamp (interleaved encoding)
// This eliminates the 99% stall by muxing audio alongside video
videoPTS := int64(frameNum)
if err := enc.ProcessAudioUpToVideoPTS(videoPTS); err != nil {
encodingErr = fmt.Errorf("error processing audio at frame %d: %w", frameNum, err)
p2.Quit()
return
}

// Send progress update every 3 frames
// Send frame data for preview every 6 frames (5Hz at 30fps - good balance)
// Skip frame data entirely if preview is disabled for better batch performance
Expand Down Expand Up @@ -488,6 +493,18 @@ func generateVideo(inputFile string, outputFile string, channels int, noPreview
break
}

// Write audio samples for this frame to encoder
// Convert float64 samples to float32 for AAC encoder
audioSamples := make([]float32, len(newSamples))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Repeated allocation in hot loop. Consider pre-allocating audioSamplesBuffer outside the frame loop and reusing it with slicing (audioSamples := audioSamplesBuffer[:len(newSamples)]), consistent with how barHeights and other buffers are pre-allocated at lines 273-280.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/jivefire/main.go, line 498:

<comment>Repeated allocation in hot loop. Consider pre-allocating `audioSamplesBuffer` outside the frame loop and reusing it with slicing (`audioSamples := audioSamplesBuffer[:len(newSamples)]`), consistent with how `barHeights` and other buffers are pre-allocated at lines 273-280.</comment>

<file context>
@@ -488,6 +493,18 @@ func generateVideo(inputFile string, outputFile string, channels int, noPreview
 
+			// Write audio samples for this frame to encoder
+			// Convert float64 samples to float32 for AAC encoder
+			audioSamples := make([]float32, len(newSamples))
+			for i, s := range newSamples {
+				audioSamples[i] = float32(s)
</file context>

for i, s := range newSamples {
audioSamples[i] = float32(s)
}
if err := enc.WriteAudioSamples(audioSamples); err != nil {
encodingErr = fmt.Errorf("error writing audio at frame %d: %w", frameNum, err)
p2.Quit()
return
}

// Shift buffer left by samplesPerFrame, append new samples
copy(fftBuffer, fftBuffer[samplesPerFrame:])
// Pad with zeros if we got fewer samples than expected
Expand All @@ -503,22 +520,12 @@ func generateVideo(inputFile string, outputFile string, channels int, noPreview
}

// Flush any remaining audio after all video frames are written
// Audio has been incrementally processed during the frame loop,
// but there may be some remaining at the end
audioFlushStart := time.Now()
audioFlushCallback := func(packetsProcessed int, elapsed time.Duration) {
// Send audio flush progress to UI
p2.Send(ui.Pass2AudioFlush{
PacketsProcessed: packetsProcessed,
Elapsed: elapsed,
})
}
if err := enc.FlushRemainingAudio(audioFlushCallback); err != nil {
// This encodes any samples remaining in the FIFO and flushes the encoder
if err := enc.FlushAudioEncoder(); err != nil {
encodingErr = fmt.Errorf("error flushing audio: %w", err)
p2.Quit()
return
}
audioFlushTime := time.Since(audioFlushStart)

// Finalize encoding
if err := enc.Close(); err != nil {
Expand All @@ -535,7 +542,6 @@ func generateVideo(inputFile string, outputFile string, channels int, noPreview
perfStats.binTime = totalBin
perfStats.drawTime = totalDraw
perfStats.writeTime = totalWrite
perfStats.audioFlushTime = audioFlushTime
perfStats.totalTime = totalTime

// Get actual file size
Expand All @@ -561,9 +567,9 @@ func generateVideo(inputFile string, outputFile string, channels int, noPreview
BinTime: totalBin,
DrawTime: totalDraw,
EncodeTime: totalWrite,
AudioFlushTime: audioFlushTime,
TotalTime: overallTotalTime, // Use overall total, not just Pass 2
Pass1Time: pass1Duration,
ThumbnailTime: thumbnailDuration,
SamplesProcessed: samplesProcessed,
})
}()
Expand Down
40 changes: 24 additions & 16 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Jivefire Architecture

**TL;DR:** 2-pass streaming audio visualiser that generates broadcast-ready MP4s from podcast audio. Pure Go audio decoding + ffmpeg-statigo static linking = single deployable binary.
**TL;DR:** 2-pass streaming audio visualiser that generates broadcast-ready MP4s from podcast audio. FFmpeg-based audio decoding + ffmpeg-statigo static linking = single deployable binary with broad format support.

---

Expand Down Expand Up @@ -36,22 +36,28 @@ Memory-efficient approach: analyse first, render second.

**Why not single-pass?** Naive approach requires pre-loading entire audio file into memory (600MB for 30 minutes). 2-pass reduces memory by 92% while enabling optimal bar height scaling.

### 3. **Pure Go Audio Decoders**
Supports WAV, MP3, FLAC via pure Go libraries:
- WAV: `go-audio/wav`
- MP3: `sukus21/go-mp3`
- FLAC: `mewkiz/flac`
### 3. **Unified FFmpeg Audio Pipeline**
Audio decoding uses ffmpeg-statigo's libavformat/libavcodec, supporting any format FFmpeg handles: MP3, FLAC, WAV, OGG, AAC, and more.

**Why pure Go?** Maintains single-binary distribution without codec dependencies. Automatic stereo-to-mono downmixing. Format detection via file extension.
**Why FFmpeg for decoding?** Single decode path for all formats. Audio samples are decoded once and shared between FFT analysis (Pass 1) and AAC encoding (Pass 2). The unified pipeline eliminates the "catch-up" delay that occurred when audio was re-decoded during encoding.

**Architecture:**
- `FFmpegDecoder` implements the `AudioDecoder` interface
- Streaming decode: reads chunks on demand, no full-file buffering
- Automatic stereo-to-mono downmixing for visualisation
- Sample rate preserved for AAC encoding

---

## Processing Pipeline

```
Input Audio (WAV/MP3/FLAC)
Input Audio (MP3/FLAC/WAV/OGG/AAC/...)
Pure Go Decoder (streaming, 2048 samples/frame)
FFmpeg Decoder (ffmpeg-statigo, streaming)
├─ libavformat for demuxing
├─ libavcodec for decoding
└─ Automatic stereo→mono downmix
FFT Analysis (gonum/fourier)
├─ 2048-point Hanning window
Expand All @@ -73,9 +79,10 @@ ffmpeg-statigo H.264 Encoder (libx264)
└─ yuv420p pixel format
ffmpeg-statigo AAC Encoder
├─ Audio FIFO buffer (2048→1024 frame size mismatch)
├─ int16/float32 → float32 planar conversion
└─ Mono→stereo duplication
├─ Receives pre-decoded samples via WriteAudioSamples()
├─ Audio FIFO buffer (handles frame size mismatches)
├─ float32 → float32 planar conversion
└─ Mono or stereo output
MP4 Muxer (libavformat)
└─ Interleaved audio/video packets
Expand Down Expand Up @@ -121,10 +128,11 @@ Preview renders via Unicode blocks (`▁▂▃▄▅▆▇█`) using actual bar
```
cmd/jivefire/ # CLI entry point, 2-pass coordinator
internal/
audio/ # Pure Go decoders (WAV/MP3/FLAC)
audio/ # Audio processing
├─ ffmpeg_decoder.go # FFmpeg-based decoder (AudioDecoder interface)
├─ analyzer.go # FFT analysis, bar binning
├─ decoder.go # AudioDecoder interface
└─ reader.go # Streaming reader with format detection
├─ decoder.go # AudioDecoder interface definition
└─ reader.go # Streaming reader wrapper

encoder/ # ffmpeg-statigo wrapper
├─ encoder.go # H.264 + AAC encoding, FIFO buffer
Expand Down Expand Up @@ -172,7 +180,7 @@ FFT bar binning logic mirrors CAVA's approach, making it familiar territory for

This a Jivefire, a Go project, that encodes podcast audio file to MP4 videos suitable for uploading to YouTube.

Orientate yourself with the project by reading the documentation (README.md and docs/ARCHITECTURE.md) and analysing the code. This project uses `ffmpeg-statigo` for FFmpeg 8.0 static bindings, included as a git submodule in `vendor/ffmpeg-statigo`.
Orientate yourself with the project by reading the documentation (README.md and docs/ARCHITECTURE.md) and analysing the code. This project uses `ffmpeg-statigo` for FFmpeg 8.0 static bindings, included as a git submodule in `third_party/ffmpeg-statigo`.

Sample audio file is in `testdata/`. You should only build and test via `just` commands. We are using NixOS as the host operating system and `flake.nix` provides tooling for the development shell. I use the `fish` shell. If you need to create "throw-away" test code, the put it in `testdata/`.

Expand Down
10 changes: 1 addition & 9 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,8 @@ require (
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/go-audio/audio v1.0.0
github.com/go-audio/wav v1.1.0
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/hajimehoshi/go-mp3 v0.3.4
github.com/linuxmatters/ffmpeg-statigo v0.0.0-00010101000000-000000000000
github.com/mewkiz/flac v1.0.13
golang.org/x/image v0.33.0
)

Expand All @@ -25,14 +21,10 @@ require (
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-audio/riff v1.0.0 // indirect
github.com/icza/bitio v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
Expand All @@ -42,4 +34,4 @@ require (
golang.org/x/text v0.31.0 // indirect
)

replace github.com/linuxmatters/ffmpeg-statigo => ./vendor/ffmpeg-statigo
replace github.com/linuxmatters/ffmpeg-statigo => ./third_party/ffmpeg-statigo
20 changes: 0 additions & 20 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4=
github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs=
github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA=
github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498=
github.com/go-audio/wav v1.1.0 h1:jQgLtbqBzY7G+BM8fXF7AHUk1uHUviWS4X39d5rsL2g=
github.com/go-audio/wav v1.1.0/go.mod h1:mpe9qfwbScEbkd8uybLuIpTgHyrISw/OTuvjUW2iGtE=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/ktye/fft v0.0.0-20160109133121-5beb24bb6a43 h1:P/FC0vnk8mHtU+PrgHwsexh/FGJUpHSNZOMp2II7XZo=
github.com/ktye/fft v0.0.0-20160109133121-5beb24bb6a43/go.mod h1:NOC+5BizuazWsAS/Ge7DXbXTrYzmmDXGqypnTaeNGcc=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
Expand All @@ -55,12 +42,6 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
github.com/mjibson/go-dsp v0.0.0-20180508042940-11479a337f12 h1:dd7vnTDfjtwCETZDrRe+GPYNLA1jBtbZeyfyE8eZCyk=
github.com/mjibson/go-dsp v0.0.0-20180508042940-11479a337f12/go.mod h1:i/KKcxEWEO8Yyl11DYafRPKOPVYTrhxiTRigjtEEXZU=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
Expand All @@ -83,7 +64,6 @@ golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
Expand Down
Loading
Loading