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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
## Performance Patterns

- RGB→YUV conversion in `encoder/frame.go` parallelised across CPU cores (8.4× faster than swscale)
- `convertRGBAToYUV` (YUV420P) and `convertRGBAToNV12` (NV12) 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
- Frame rendering uses symmetric mirroring (draw 1/4 pixels, mirror 3×)
- Pre-computed intensity/colour tables in `renderer/frame.go`
- Bubbletea UI uses non-blocking goroutine channels
Expand Down
37 changes: 13 additions & 24 deletions internal/audio/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,13 @@ type FrameAnalysis struct {

// RMS level of audio chunk
RMSLevel float64

// Average per-bar magnitudes (for future use)
BarMagnitudes [config.NumBars]float64
}

// Profile holds complete audio analysis results.
type Profile struct {
// Total number of frames in audio
NumFrames int

// Per-frame analysis data
Frames []FrameAnalysis

// Global statistics
GlobalPeak float64 // Highest peak magnitude across all frames
GlobalRMS float64 // Average RMS across all frames
Expand All @@ -57,8 +51,7 @@ func AnalyzeAudio(filename string, progressCb ProgressCallback) (*Profile, error
defer reader.Close()

profile := &Profile{
NumFrames: 0, // Will be set after we count actual samples
Frames: make([]FrameAnalysis, 0), // Will grow as we read
NumFrames: 0, // Will be set after we count actual samples
SampleRate: reader.SampleRate(),
Duration: 0, // Will be calculated from actual sample count
}
Expand Down Expand Up @@ -86,6 +79,9 @@ func AnalyzeAudio(filename string, progressCb ProgressCallback) (*Profile, error
return nil, fmt.Errorf("no audio data in file")
}

// Pre-allocate bar magnitudes buffer for progress callbacks
barHeights := make([]float64, config.NumBars)

startTime := time.Now()
frameNum := 0

Expand All @@ -95,8 +91,7 @@ func AnalyzeAudio(filename string, progressCb ProgressCallback) (*Profile, error
coeffs := processor.ProcessChunk(fftBuffer)

// Analyze frequency bins
analysis := analyzeFrame(coeffs, fftBuffer)
profile.Frames = append(profile.Frames, analysis)
analysis := analyzeFrame(coeffs, fftBuffer, barHeights)

// Track global statistics
if analysis.PeakMagnitude > maxPeak {
Expand All @@ -108,12 +103,6 @@ func AnalyzeAudio(filename string, progressCb ProgressCallback) (*Profile, error

// Send progress update via callback (throttle to every 3 frames for performance)
if progressCb != nil && frameNum%3 == 0 {
// Convert bar magnitudes to slice for progress update
barHeights := make([]float64, config.NumBars)
for i := range config.NumBars {
barHeights[i] = analysis.BarMagnitudes[i]
}

elapsed := time.Since(startTime)
// No total frames estimate available during first pass
progressCb(frameNum, 0, analysis.RMSLevel, analysis.PeakMagnitude, barHeights, elapsed)
Expand All @@ -125,10 +114,6 @@ func AnalyzeAudio(filename string, progressCb ProgressCallback) (*Profile, error
if errors.Is(err, io.EOF) {
// Send final progress update
if progressCb != nil {
barHeights := make([]float64, config.NumBars)
for i := range config.NumBars {
barHeights[i] = analysis.BarMagnitudes[i]
}
elapsed := time.Since(startTime)
progressCb(frameNum, frameNum, analysis.RMSLevel, analysis.PeakMagnitude, barHeights, elapsed)
}
Expand Down Expand Up @@ -176,8 +161,10 @@ func AnalyzeAudio(filename string, progressCb ProgressCallback) (*Profile, error
return profile, nil
}

// analyzeFrame extracts statistics from FFT coefficients and audio chunk
func analyzeFrame(coeffs []complex128, audioChunk []float64) FrameAnalysis {
// analyzeFrame extracts statistics from FFT coefficients and audio chunk.
// barMagnitudes is an optional buffer that receives per-bar average magnitudes
// for progress display; pass nil when bar magnitudes are not needed.
func analyzeFrame(coeffs []complex128, audioChunk []float64, barMagnitudes []float64) FrameAnalysis {
analysis := FrameAnalysis{}

// Calculate RMS of audio chunk
Expand All @@ -187,7 +174,7 @@ func analyzeFrame(coeffs []complex128, audioChunk []float64) FrameAnalysis {
}
analysis.RMSLevel = math.Sqrt(sumSquares / float64(len(audioChunk)))

// Analyze frequency bins (same logic as BinFFT)
// Analyse frequency bins (same logic as BinFFT)
// Use full spectrum up to Nyquist frequency for complete frequency coverage
halfSize := len(coeffs) / 2
maxFreqBin := halfSize
Expand All @@ -205,7 +192,9 @@ func analyzeFrame(coeffs []complex128, audioChunk []float64) FrameAnalysis {
}

avgMagnitude := sum / float64(binsPerBar)
analysis.BarMagnitudes[bar] = avgMagnitude
if barMagnitudes != nil {
barMagnitudes[bar] = avgMagnitude
}

// Track peak
if avgMagnitude > analysis.PeakMagnitude {
Expand Down
82 changes: 1 addition & 81 deletions internal/audio/analyzer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,6 @@ func TestAnalyzeAudio(t *testing.T) {
t.Errorf("Expected positive OptimalBaseScale, got %.6f", profile.OptimalBaseScale)
}

// Validate frame analysis array
if len(profile.Frames) != profile.NumFrames {
t.Errorf("Frame count mismatch: expected %d, got %d", profile.NumFrames, len(profile.Frames))
}

t.Logf("Analysis complete:")
t.Logf(" Duration: %.1f seconds", profile.Duration)
t.Logf(" Frames: %d", profile.NumFrames)
Expand All @@ -71,32 +66,6 @@ func TestAnalyzeAudioInvalidFile(t *testing.T) {
}
}

func TestAnalyzeFrameStatistics(t *testing.T) {
profile := mustAnalyze(t)

// Check first few frames have valid statistics
for i := 0; i < 10 && i < len(profile.Frames); i++ {
frame := profile.Frames[i]

if frame.PeakMagnitude < 0 {
t.Errorf("Frame %d: negative PeakMagnitude: %.6f", i, frame.PeakMagnitude)
}

if frame.RMSLevel < 0 {
t.Errorf("Frame %d: negative RMSLevel: %.6f", i, frame.RMSLevel)
}

// Check bar magnitudes
for bar := range config.NumBars {
if frame.BarMagnitudes[bar] < 0 {
t.Errorf("Frame %d, Bar %d: negative magnitude: %.6f", i, bar, frame.BarMagnitudes[bar])
}
}
}

t.Logf("Frame statistics validated for %d frames", profile.NumFrames)
}

func TestOptimalBaseScaleCalculation(t *testing.T) {
profile := mustAnalyze(t)

Expand All @@ -119,55 +88,6 @@ func TestOptimalBaseScaleCalculation(t *testing.T) {
profile.GlobalPeak, profile.OptimalBaseScale, testValue)
}

func TestGlobalPeakIsMaximum(t *testing.T) {
profile := mustAnalyze(t)

// GlobalPeak should be >= all frame peaks
for i, frame := range profile.Frames {
if frame.PeakMagnitude > profile.GlobalPeak {
t.Errorf("Frame %d peak (%.6f) exceeds GlobalPeak (%.6f)",
i, frame.PeakMagnitude, profile.GlobalPeak)
}
}

// Find the actual maximum to verify it matches
var maxFound float64
var maxFrameIdx int
for i, frame := range profile.Frames {
if frame.PeakMagnitude > maxFound {
maxFound = frame.PeakMagnitude
maxFrameIdx = i
}
}

if maxFound != profile.GlobalPeak {
t.Errorf("GlobalPeak (%.6f) doesn't match actual maximum (%.6f) at frame %d",
profile.GlobalPeak, maxFound, maxFrameIdx)
}

t.Logf("GlobalPeak correctly represents maximum: %.6f at frame %d", profile.GlobalPeak, maxFrameIdx)
}

func TestGlobalRMSIsAverage(t *testing.T) {
profile := mustAnalyze(t)

// Calculate average RMS manually
var sumRMS float64
for _, frame := range profile.Frames {
sumRMS += frame.RMSLevel
}
expectedRMS := sumRMS / float64(len(profile.Frames))

// Allow small floating point error
diff := profile.GlobalRMS - expectedRMS
if diff < -0.000001 || diff > 0.000001 {
t.Errorf("GlobalRMS (%.6f) doesn't match calculated average (%.6f)",
profile.GlobalRMS, expectedRMS)
}

t.Logf("GlobalRMS correctly calculated as average: %.6f", profile.GlobalRMS)
}

func TestDynamicRangeCalculation(t *testing.T) {
profile := mustAnalyze(t)

Expand Down Expand Up @@ -197,7 +117,7 @@ func TestAnalyzeFrameDirectly(t *testing.T) {
coeffs := processor.ProcessChunk(testSamples)

// Analyze
analysis := analyzeFrame(coeffs, testSamples)
analysis := analyzeFrame(coeffs, testSamples, nil)

// Validate results
if analysis.PeakMagnitude <= 0 {
Expand Down
Loading
Loading