package plot import ( "fmt" "math" "math/cmplx" "os" "path/filepath" "strings" "image/png" "image/color" "github.com/mjibson/go-dsp/fft" "gonum.org/v1/plot" "gonum.org/v1/plot/font" "gonum.org/v1/plot/plotter" "gonum.org/v1/plot/vg" "gonum.org/v1/plot/vg/draw" "gonum.org/v1/plot/vg/vgimg" ) // PlotIR plots the frequency response (magnitude in dB vs. frequency in Hz) of the IR to a PNG file func PlotIR(ir []float64, sampleRate int, irFileName string) error { if len(ir) == 0 { return nil } // Use only the first 8192 samples of the IR for plotting windowLen := 8192 if len(ir) < windowLen { windowLen = len(ir) } irWin := ir[:windowLen] X := fft.FFTReal(irWin) // Plot from 20 Hz up to 20kHz, include every bin var plotPts plotter.XYs var minDb float64 = 1e9 var maxDb float64 = -1e9 var minDbFreq float64 freqBins := windowLen / 2 for i := 1; i < freqBins; i++ { freq := float64(i) * float64(sampleRate) / float64(windowLen) if freq < 20.0 { continue } if freq > 20000.0 { break } mag := cmplx.Abs(X[i]) if mag < 1e-12 { mag = 1e-12 } db := 20 * math.Log10(mag) plotPts = append(plotPts, plotter.XY{X: freq, Y: db}) if db < minDb { minDb = db minDbFreq = freq } if db > maxDb { maxDb = db } } fmt.Printf("[PlotIR] minDb in plotted range: %.2f dB at %.2f Hz\n", minDb, minDbFreq) irBaseName := filepath.Base(irFileName) p := plot.New() p.Title.Text = fmt.Sprintf("IR Frequency Response: %s", irBaseName) p.X.Label.Text = "Frequency (Hz)" p.Y.Label.Text = "Magnitude (dB)" p.X.Scale = plot.LogScale{} p.X.Tick.Marker = plot.TickerFunc(func(min, max float64) []plot.Tick { ticks := []float64{20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000} labels := []string{"20", "50", "100", "200", "500", "1k", "2k", "5k", "10k", "20k"} var result []plot.Tick for i, v := range ticks { if v >= min && v <= max { result = append(result, plot.Tick{Value: v, Label: labels[i]}) } } return result }) line, err := plotter.NewLine(plotPts) if err != nil { return err } // Set line color to blue line.Color = color.RGBA{R: 30, G: 100, B: 220, A: 255} p.Add(line) // Find minimum dB value between 20 Hz and 50 Hz for y-axis anchor minDb2050 := 1e9 for i := 1; i < freqBins; i++ { freq := float64(i) * float64(sampleRate) / float64(windowLen) if freq < 20.0 { continue } if freq > 50.0 { break } mag := cmplx.Abs(X[i]) if mag < 1e-12 { mag = 1e-12 } db := 20 * math.Log10(mag) if db < minDb2050 { minDb2050 = db } } p.Y.Min = minDb2050 p.Y.Max = math.Ceil(maxDb) p.X.Min = 20.0 p.X.Max = 20000.0 // --- Time-aligned waveform plot --- p2 := plot.New() p2.Title.Text = fmt.Sprintf("IR Waveform: %s", irBaseName) p2.X.Label.Text = "Time (ms)" p2.Y.Label.Text = "Amplitude" // Prepare waveform data (only first 10ms) var pts plotter.XYs maxTimeMs := 10.0 for i := 0; i < windowLen; i++ { t := float64(i) * 1000.0 / float64(sampleRate) // ms if t > maxTimeMs { break } pts = append(pts, plotter.XY{X: t, Y: irWin[i]}) } wline, err := plotter.NewLine(pts) if err != nil { return err } wline.Color = color.RGBA{R: 30, G: 100, B: 220, A: 255} p2.Add(wline) p2.X.Min = 0 p2.X.Max = maxTimeMs // Y range auto // --- Compose both plots vertically --- const width = 6 * vg.Inch const height = 8 * vg.Inch // increased height for frequency diagram img := vgimg.New(width, height+1*vg.Inch) // extra space for logo/headline dc := draw.New(img) // Draw logo at the top left, headline to the right, IR filename below logoPath := "assets/logo.png" logoW := 2.4 * vg.Inch // doubled size logoH := 0.68 * vg.Inch // doubled size logoX := 0.3 * vg.Inch logoY := height + 0.2*vg.Inch // move logo down by an additional ~10px logoDrawn := false f, err := os.Open(logoPath) if err == nil { defer f.Close() logoImg, err := png.Decode(f) if err == nil { rect := vg.Rectangle{ Min: vg.Point{X: logoX, Y: logoY}, Max: vg.Point{X: logoX + logoW, Y: logoY + logoH}, } dc.DrawImage(rect, logoImg) logoDrawn = true } } // Draw headline (bold, larger) to the right of the logo headline := "Valhallir Deconvolver IR Analysis" fntSize := vg.Points(14) // Same as IR filename if logoDrawn { headlineX := logoX + logoW + 0.3*vg.Inch headlineY := logoY + logoH - vg.Points(16) - vg.Points(5) // move headline up by ~10px boldFont := plot.DefaultFont boldFont.Weight = 3 // font.WeightBold is 3 in gonum/plot/font boldFace := font.DefaultCache.Lookup(boldFont, fntSize) dc.SetColor(color.Black) dc.FillString(boldFace, vg.Point{X: headlineX, Y: headlineY}, headline) // Draw IR filename below headline, left-aligned, standard font fileLabel := "IR-File: " + filepath.Base(irFileName) fileY := headlineY - fntSize - vg.Points(6) fileFace := font.DefaultCache.Lookup(plot.DefaultFont, vg.Points(10)) dc.FillString(fileFace, vg.Point{X: headlineX, Y: fileY}, fileLabel) } // Custom tile arrangement: frequency diagram gets more height, waveform gets less tiles := draw.Tiles{ Rows: 2, Cols: 1, PadX: vg.Millimeter, PadY: 20 * vg.Millimeter, // more space between plots to emphasize frequency diagram PadTop: vg.Points(15), // move diagrams down by ~20px } // Offset the plots down by 1 inch to make space for logo/headline imgPlots := vgimg.New(width, height) dcPlots := draw.New(imgPlots) canvases := plot.Align([][]*plot.Plot{{p}, {p2}}, tiles, dcPlots) p.Draw(canvases[0][0]) p2.Draw(canvases[1][0]) dc.DrawImage(vg.Rectangle{Min: vg.Point{X: 0, Y: 0}, Max: vg.Point{X: width, Y: height}}, imgPlots.Image()) // Save as PNG in the same directory as the IR file irDir := filepath.Dir(irFileName) irBase := filepath.Base(irFileName) irNameWithoutExt := strings.TrimSuffix(irBase, filepath.Ext(irBase)) plotFileName := filepath.Join(irDir, irNameWithoutExt+".png") f, err = os.Create(plotFileName) if err != nil { return err } defer f.Close() _, err = vgimg.PngCanvas{Canvas: img}.WriteTo(f) return err }