210 lines
6.0 KiB
Go
210 lines
6.0 KiB
Go
package plot
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image/color"
|
|
"image/png"
|
|
"math"
|
|
"math/cmplx"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"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
|
|
// logoData is the embedded logo image data (can be nil if not available)
|
|
func PlotIR(ir []float64, sampleRate int, irFileName string, logoData []byte) 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)
|
|
p := plot.New()
|
|
p.Title.Text = "IR Frequency Response"
|
|
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 = "IR Waveform"
|
|
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
|
|
// Logo is embedded in the binary
|
|
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
|
|
if len(logoData) > 0 {
|
|
logoImg, err := png.Decode(bytes.NewReader(logoData))
|
|
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")
|
|
|
|
plotFile, err := os.Create(plotFileName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer plotFile.Close()
|
|
_, err = vgimg.PngCanvas{Canvas: img}.WriteTo(plotFile)
|
|
return err
|
|
}
|