Files
nexus/internal/leviathan/spectrogram/spectrogram.go
T
2026-06-16 11:51:07 +01:00

799 lines
21 KiB
Go

package spectrogram
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"image"
"image/color"
"image/png"
"io"
"math"
"path/filepath"
"strings"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"github.com/hajimehoshi/go-mp3"
)
const (
defaultSampleRate = 44100
defaultDuration = 5.0
// MaxImageBytes caps compressed image inputs before decode. Pixel count is
// checked separately because small compressed images can still expand huge.
MaxImageBytes = 64 << 20
// MaxAudioBytes caps encoded WAV/MP3 inputs before full in-memory decode.
MaxAudioBytes = 128 << 20
maxImagePixels = 24_000_000
maxDecodedBytes = 64 << 20
twoPi = 2 * math.Pi
)
// EncodeOptions controls image-to-audio spectrogram synthesis.
type EncodeOptions struct {
DurationSec float64
SampleRate int
MinHz float64
MaxHz float64
Bins int
Columns int
Invert bool
PreserveAR bool
Gain float64
}
// InsertOptions controls embedding synthesized spectrogram audio into a carrier.
type InsertOptions struct {
EncodeOptions
StartSec float64
MixLevel float64
}
// ViewOptions controls spectrogram image rendering from audio.
type ViewOptions struct {
FFTSize int
Height int
MaxWidth int
Aspect float64
MinHz float64
MaxHz float64
Brightness float64
Contrast float64
}
type audioBuffer struct {
SampleRate int
Channels int
Samples []float64
}
// ImageToWAV converts an image into a WAV whose spectrogram reveals that image.
func ImageToWAV(imageData []byte, opts EncodeOptions) ([]byte, map[string]any, error) {
grid, meta, opts, err := imageGrid(imageData, opts)
if err != nil {
return nil, nil, err
}
mono := synthesize(grid, opts)
audio := audioBuffer{SampleRate: opts.SampleRate, Channels: 1, Samples: mono}
wav, err := encodeWAV(audio)
if err != nil {
return nil, nil, err
}
summary := map[string]any{
"mode": "image-to-wav",
"imageFormat": meta["format"],
"imageWidth": meta["width"],
"imageHeight": meta["height"],
"imageAspect": meta["aspect"],
"durationSec": opts.DurationSec,
"sampleRate": opts.SampleRate,
"minHz": opts.MinHz,
"maxHz": opts.MaxHz,
"bins": opts.Bins,
"columns": opts.Columns,
"preserveAR": opts.PreserveAR,
"wavBytes": len(wav),
}
return wav, summary, nil
}
// InsertImageIntoAudio mixes a synthesized image spectrogram into WAV or MP3 carrier audio.
func InsertImageIntoAudio(imageData, audioData []byte, audioName string, opts InsertOptions) ([]byte, map[string]any, error) {
carrier, err := DecodeAudio(audioData, audioName)
if err != nil {
return nil, nil, err
}
opts.EncodeOptions.SampleRate = carrier.SampleRate
grid, meta, encodeOpts, err := imageGrid(imageData, opts.EncodeOptions)
if err != nil {
return nil, nil, err
}
tone := synthesize(grid, encodeOpts)
if opts.MixLevel <= 0 {
opts.MixLevel = 0.35
}
if opts.MixLevel > 1 {
opts.MixLevel = 1
}
if opts.StartSec < 0 {
opts.StartSec = 0
}
startFrame := int(math.Round(opts.StartSec * float64(carrier.SampleRate)))
requiredFrames := startFrame + len(tone)
currentFrames := len(carrier.Samples) / carrier.Channels
if requiredFrames > currentFrames {
extended := make([]float64, requiredFrames*carrier.Channels)
copy(extended, carrier.Samples)
carrier.Samples = extended
}
for i, sample := range tone {
frame := startFrame + i
for ch := 0; ch < carrier.Channels; ch++ {
idx := frame*carrier.Channels + ch
carrier.Samples[idx] += sample * opts.MixLevel
}
}
limitPeak(carrier.Samples, 0.98)
wav, err := encodeWAV(carrier)
if err != nil {
return nil, nil, err
}
summary := map[string]any{
"mode": "insert-into-audio",
"imageFormat": meta["format"],
"imageWidth": meta["width"],
"imageHeight": meta["height"],
"imageAspect": meta["aspect"],
"carrierSampleRate": carrier.SampleRate,
"carrierChannels": carrier.Channels,
"startSec": opts.StartSec,
"mixLevel": opts.MixLevel,
"durationSec": encodeOpts.DurationSec,
"minHz": encodeOpts.MinHz,
"maxHz": encodeOpts.MaxHz,
"bins": encodeOpts.Bins,
"columns": encodeOpts.Columns,
"preserveAR": encodeOpts.PreserveAR,
"wavBytes": len(wav),
}
return wav, summary, nil
}
// AudioSpectrogramPNG renders WAV or MP3 audio as a PNG spectrogram.
func AudioSpectrogramPNG(audioData []byte, audioName string, opts ViewOptions) ([]byte, map[string]any, error) {
audio, err := DecodeAudio(audioData, audioName)
if err != nil {
return nil, nil, err
}
mono := toMono(audio)
if len(mono) == 0 {
return nil, nil, errors.New("audio contains no samples")
}
opts = normalizeViewOptions(opts, audio.SampleRate)
img := renderSpectrogram(mono, audio.SampleRate, opts)
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, nil, err
}
duration := float64(len(mono)) / float64(audio.SampleRate)
summary := map[string]any{
"mode": "audio-to-spectrogram",
"sampleRate": audio.SampleRate,
"channels": audio.Channels,
"durationSec": duration,
"fftSize": opts.FFTSize,
"width": img.Bounds().Dx(),
"height": img.Bounds().Dy(),
"aspectRatio": opts.Aspect,
"dynamicRange": 80,
"minHz": opts.MinHz,
"maxHz": opts.MaxHz,
"pngBytes": buf.Len(),
}
return buf.Bytes(), summary, nil
}
// DecodeAudio decodes WAV directly and MP3 through go-mp3.
func DecodeAudio(data []byte, name string) (audioBuffer, error) {
if len(data) == 0 {
return audioBuffer{}, errors.New("audio file is empty")
}
if len(data) > MaxAudioBytes {
return audioBuffer{}, fmt.Errorf("audio input is too large: %d bytes exceeds %d bytes", len(data), MaxAudioBytes)
}
if isWAV(data) {
return decodeWAV(data)
}
ext := strings.ToLower(filepath.Ext(name))
if ext == ".mp3" || isMP3(data) {
return decodeMP3(data)
}
return audioBuffer{}, errors.New("unsupported audio format: load a WAV or MP3 file")
}
func imageGrid(data []byte, opts EncodeOptions) ([][]float64, map[string]any, EncodeOptions, error) {
if len(data) == 0 {
return nil, nil, opts, errors.New("image file is empty")
}
if len(data) > MaxImageBytes {
return nil, nil, opts, fmt.Errorf("image input is too large: %d bytes exceeds %d bytes", len(data), MaxImageBytes)
}
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, nil, opts, fmt.Errorf("decode image config: %w", err)
}
if cfg.Width <= 0 || cfg.Height <= 0 {
return nil, nil, opts, errors.New("image has no pixels")
}
if int64(cfg.Width)*int64(cfg.Height) > maxImagePixels {
return nil, nil, opts, fmt.Errorf("image dimensions are too large: %dx%d exceeds %d pixels", cfg.Width, cfg.Height, maxImagePixels)
}
img, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, nil, opts, fmt.Errorf("decode image: %w", err)
}
bounds := img.Bounds()
if bounds.Dx() <= 0 || bounds.Dy() <= 0 {
return nil, nil, opts, errors.New("image has no pixels")
}
opts = normalizeEncodeOptions(opts, bounds.Dx(), bounds.Dy())
grid := make([][]float64, opts.Bins)
for bin := 0; bin < opts.Bins; bin++ {
grid[bin] = make([]float64, opts.Columns)
sourceY := bounds.Min.Y + int((float64(opts.Bins-1-bin)+0.5)*float64(bounds.Dy())/float64(opts.Bins))
if sourceY >= bounds.Max.Y {
sourceY = bounds.Max.Y - 1
}
for col := 0; col < opts.Columns; col++ {
sourceX := bounds.Min.X + int((float64(col)+0.5)*float64(bounds.Dx())/float64(opts.Columns))
if sourceX >= bounds.Max.X {
sourceX = bounds.Max.X - 1
}
grid[bin][col] = pixelAmplitude(img.At(sourceX, sourceY), opts.Invert)
}
}
meta := map[string]any{"format": format, "width": bounds.Dx(), "height": bounds.Dy(), "aspect": float64(bounds.Dy()) / float64(bounds.Dx())}
return grid, meta, opts, nil
}
func normalizeEncodeOptions(opts EncodeOptions, imageWidth, imageHeight int) EncodeOptions {
if opts.DurationSec <= 0 {
opts.DurationSec = defaultDuration
}
opts.DurationSec = clampFloat(opts.DurationSec, 0.25, 60)
if opts.SampleRate <= 0 {
opts.SampleRate = defaultSampleRate
}
opts.SampleRate = clampInt(opts.SampleRate, 8000, 96000)
if opts.MinHz <= 0 {
opts.MinHz = 200
}
if opts.MaxHz <= opts.MinHz {
opts.MaxHz = math.Min(float64(opts.SampleRate)/2-200, 20000)
}
opts.MinHz = clampFloat(opts.MinHz, 20, float64(opts.SampleRate)/2-100)
opts.MaxHz = clampFloat(opts.MaxHz, opts.MinHz+100, float64(opts.SampleRate)/2-50)
if opts.PreserveAR && imageWidth > 0 && imageHeight > 0 {
aspect := float64(imageHeight) / float64(imageWidth)
baseRange := opts.MaxHz - opts.MinHz
desiredRange := baseRange * aspect
opts.MaxHz = clampFloat(opts.MinHz+desiredRange, opts.MinHz+100, float64(opts.SampleRate)/2-50)
}
if opts.Bins <= 0 {
opts.Bins = clampInt(imageHeight, 48, 192)
}
opts.Bins = clampInt(opts.Bins, 16, 384)
if opts.Columns <= 0 {
opts.Columns = clampInt(imageWidth, 64, 1024)
}
opts.Columns = clampInt(opts.Columns, 16, 2048)
if opts.Gain <= 0 {
opts.Gain = 0.85
}
opts.Gain = clampFloat(opts.Gain, 0.05, 1)
return opts
}
func normalizeViewOptions(opts ViewOptions, sampleRate int) ViewOptions {
if opts.FFTSize <= 0 {
opts.FFTSize = 2048
}
opts.FFTSize = nearestPowerOfTwo(clampInt(opts.FFTSize, 256, 8192))
if opts.Height <= 0 {
opts.Height = 512
}
opts.Height = clampInt(opts.Height, 64, 1024)
if opts.MaxWidth <= 0 {
opts.MaxWidth = 1400
}
opts.MaxWidth = clampInt(opts.MaxWidth, 64, 2400)
if opts.Aspect <= 0 {
opts.Aspect = 1
}
opts.Aspect = clampFloat(opts.Aspect, 0.25, 4)
if opts.MinHz < 0 {
opts.MinHz = 0
}
if opts.MaxHz <= opts.MinHz {
opts.MaxHz = float64(sampleRate) / 2
}
opts.MinHz = clampFloat(opts.MinHz, 0, float64(sampleRate)/2-10)
opts.MaxHz = clampFloat(opts.MaxHz, opts.MinHz+10, float64(sampleRate)/2)
if opts.Brightness <= 0 {
opts.Brightness = 1
}
opts.Brightness = clampFloat(opts.Brightness, 0.1, 4)
if opts.Contrast <= 0 {
opts.Contrast = 1
}
opts.Contrast = clampFloat(opts.Contrast, 0.1, 4)
return opts
}
func pixelAmplitude(c color.Color, invert bool) float64 {
r, g, b, a := c.RGBA()
alpha := float64(a) / 65535
if alpha <= 0.001 {
return 0
}
luma := (0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b)) / 65535
amp := luma
if invert {
amp = 1 - luma
}
amp *= alpha
return math.Pow(clampFloat(amp, 0, 1), 1.15)
}
func synthesize(grid [][]float64, opts EncodeOptions) []float64 {
bins := len(grid)
cols := len(grid[0])
frames := int(math.Round(opts.DurationSec * float64(opts.SampleRate)))
if frames < 1 {
frames = 1
}
out := make([]float64, frames)
phase := make([]float64, bins)
inc := make([]float64, bins)
for i := range inc {
var pos float64
if bins > 1 {
pos = float64(i) / float64(bins-1)
}
freq := opts.MinHz + (opts.MaxHz-opts.MinHz)*pos
inc[i] = twoPi * freq / float64(opts.SampleRate)
}
for i := 0; i < frames; i++ {
var x float64
if frames > 1 {
x = float64(i) * float64(cols-1) / float64(frames-1)
}
c0 := int(math.Floor(x))
c1 := c0 + 1
if c1 >= cols {
c1 = cols - 1
}
t := x - float64(c0)
var sample, weight float64
for bin := 0; bin < bins; bin++ {
amp := grid[bin][c0]*(1-t) + grid[bin][c1]*t
if amp < 0.002 {
continue
}
phase[bin] += inc[bin]
if phase[bin] > twoPi {
phase[bin] -= twoPi
}
sample += math.Sin(phase[bin]) * amp
weight += amp
}
if weight > 1 {
sample /= math.Sqrt(weight)
}
out[i] = sample
}
normalizePeak(out, opts.Gain)
fade(out, opts.SampleRate/100)
return out
}
func renderSpectrogram(samples []float64, sampleRate int, opts ViewOptions) image.Image {
fftSize := opts.FFTSize
hop := fftSize / 4
if hop < 1 {
hop = 1
}
frameCount := 1
if len(samples) > fftSize {
frameCount = 1 + (len(samples)-fftSize)/hop
}
width := frameCount
if width > opts.MaxWidth {
width = opts.MaxWidth
}
if width < 1 {
width = 1
}
height := clampInt(int(math.Round(float64(opts.Height)*opts.Aspect)), 32, 4096)
img := image.NewRGBA(image.Rect(0, 0, width, height))
values := make([]float32, width*height)
maxDB := math.Inf(-1)
window := hamming(fftSize)
for x := 0; x < width; x++ {
frameIndex := 0
if width > 1 && frameCount > 1 {
frameIndex = int(math.Round(float64(x) * float64(frameCount-1) / float64(width-1)))
}
start := frameIndex * hop
buf := make([]complex128, fftSize)
for i := 0; i < fftSize; i++ {
if start+i < len(samples) {
buf[i] = complex(samples[start+i]*window[i], 0)
}
}
fft(buf)
for y := 0; y < height; y++ {
pos := 0.0
if height > 1 {
pos = float64(height-1-y) / float64(height-1)
}
freq := opts.MinHz + (opts.MaxHz-opts.MinHz)*pos
bin := int(math.Round(freq * float64(fftSize) / float64(sampleRate)))
if bin < 0 {
bin = 0
}
if bin >= fftSize/2 {
bin = fftSize/2 - 1
}
mag := cmplxAbs(buf[bin]) / float64(fftSize)
db := 20 * math.Log10(mag+1e-10)
values[y*width+x] = float32(db)
if db > maxDB {
maxDB = db
}
}
}
if math.IsInf(maxDB, -1) {
maxDB = -80
}
minDB := maxDB - 80
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
db := float64(values[y*width+x])
norm := clampFloat((db-minDB)/(maxDB-minDB), 0, 1)
norm = clampFloat((norm-0.5)*opts.Contrast+0.5, 0, 1)
norm = clampFloat(norm*opts.Brightness, 0, 1)
img.Set(x, y, palette(norm))
}
}
return img
}
func decodeWAV(data []byte) (audioBuffer, error) {
if !isWAV(data) {
return audioBuffer{}, errors.New("not a WAV file")
}
var fmtChunk, dataChunk []byte
pos := 12
for pos+8 <= len(data) {
id := string(data[pos : pos+4])
size := int(binary.LittleEndian.Uint32(data[pos+4 : pos+8]))
pos += 8
if size < 0 || pos+size > len(data) {
return audioBuffer{}, errors.New("invalid WAV chunk size")
}
chunk := data[pos : pos+size]
switch id {
case "fmt ":
fmtChunk = chunk
case "data":
dataChunk = chunk
}
pos += size
if size%2 == 1 {
pos++
}
}
if len(fmtChunk) < 16 || len(dataChunk) == 0 {
return audioBuffer{}, errors.New("WAV is missing fmt or data chunk")
}
audioFormat := binary.LittleEndian.Uint16(fmtChunk[0:2])
channels := int(binary.LittleEndian.Uint16(fmtChunk[2:4]))
sampleRate := int(binary.LittleEndian.Uint32(fmtChunk[4:8]))
blockAlign := int(binary.LittleEndian.Uint16(fmtChunk[12:14]))
bits := int(binary.LittleEndian.Uint16(fmtChunk[14:16]))
if channels < 1 || channels > 8 || sampleRate < 1 || blockAlign < channels {
return audioBuffer{}, errors.New("unsupported WAV stream parameters")
}
if len(dataChunk) > maxDecodedBytes {
return audioBuffer{}, errors.New("audio data is too large to decode safely")
}
bytesPerSample := bits / 8
if bytesPerSample < 1 || blockAlign < channels*bytesPerSample {
return audioBuffer{}, errors.New("unsupported WAV bit depth")
}
frames := len(dataChunk) / blockAlign
samples := make([]float64, frames*channels)
for frame := 0; frame < frames; frame++ {
base := frame * blockAlign
for ch := 0; ch < channels; ch++ {
off := base + ch*bytesPerSample
samples[frame*channels+ch] = decodeWAVSample(dataChunk[off:], audioFormat, bits)
}
}
return audioBuffer{SampleRate: sampleRate, Channels: channels, Samples: samples}, nil
}
func decodeWAVSample(b []byte, audioFormat uint16, bits int) float64 {
switch audioFormat {
case 1:
switch bits {
case 8:
return (float64(b[0]) - 128) / 128
case 16:
return float64(int16(binary.LittleEndian.Uint16(b[:2]))) / 32768
case 24:
v := int32(b[0]) | int32(b[1])<<8 | int32(b[2])<<16
if v&0x800000 != 0 {
v |= ^0xffffff
}
return float64(v) / 8388608
case 32:
return float64(int32(binary.LittleEndian.Uint32(b[:4]))) / 2147483648
}
case 3:
switch bits {
case 32:
return float64(math.Float32frombits(binary.LittleEndian.Uint32(b[:4])))
case 64:
return math.Float64frombits(binary.LittleEndian.Uint64(b[:8]))
}
}
return 0
}
func decodeMP3(data []byte) (audioBuffer, error) {
decoder, err := mp3.NewDecoder(bytes.NewReader(data))
if err != nil {
return audioBuffer{}, err
}
limited := io.LimitReader(decoder, maxDecodedBytes+1)
pcm, err := io.ReadAll(limited)
if err != nil {
return audioBuffer{}, err
}
if len(pcm) > maxDecodedBytes {
return audioBuffer{}, errors.New("decoded MP3 is too large to process safely")
}
if len(pcm) < 2 {
return audioBuffer{}, errors.New("MP3 decoded no samples")
}
sampleCount := len(pcm) / 2
channels := 2
if sampleCount%2 != 0 {
channels = 1
}
samples := make([]float64, sampleCount)
for i := 0; i < sampleCount; i++ {
samples[i] = float64(int16(binary.LittleEndian.Uint16(pcm[i*2:i*2+2]))) / 32768
}
return audioBuffer{SampleRate: decoder.SampleRate(), Channels: channels, Samples: samples}, nil
}
func encodeWAV(audio audioBuffer) ([]byte, error) {
if audio.SampleRate <= 0 || audio.Channels <= 0 {
return nil, errors.New("invalid audio parameters")
}
var buf bytes.Buffer
dataSize := int64(len(audio.Samples)) * 2
if dataSize > int64(^uint32(0))-44 {
return nil, errors.New("audio is too large for a WAV file")
}
buf.WriteString("RIFF")
_ = binary.Write(&buf, binary.LittleEndian, uint32(36+dataSize))
buf.WriteString("WAVE")
buf.WriteString("fmt ")
_ = binary.Write(&buf, binary.LittleEndian, uint32(16))
_ = binary.Write(&buf, binary.LittleEndian, uint16(1))
_ = binary.Write(&buf, binary.LittleEndian, uint16(audio.Channels))
_ = binary.Write(&buf, binary.LittleEndian, uint32(audio.SampleRate))
byteRate := audio.SampleRate * audio.Channels * 2
_ = binary.Write(&buf, binary.LittleEndian, uint32(byteRate))
_ = binary.Write(&buf, binary.LittleEndian, uint16(audio.Channels*2))
_ = binary.Write(&buf, binary.LittleEndian, uint16(16))
buf.WriteString("data")
_ = binary.Write(&buf, binary.LittleEndian, uint32(dataSize))
for _, sample := range audio.Samples {
sample = clampFloat(sample, -1, 1)
_ = binary.Write(&buf, binary.LittleEndian, int16(math.Round(sample*32767)))
}
return buf.Bytes(), nil
}
func toMono(audio audioBuffer) []float64 {
if audio.Channels == 1 {
out := make([]float64, len(audio.Samples))
copy(out, audio.Samples)
return out
}
frames := len(audio.Samples) / audio.Channels
out := make([]float64, frames)
for frame := 0; frame < frames; frame++ {
var sum float64
for ch := 0; ch < audio.Channels; ch++ {
sum += audio.Samples[frame*audio.Channels+ch]
}
out[frame] = sum / float64(audio.Channels)
}
return out
}
func isWAV(data []byte) bool {
return len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WAVE"
}
func isMP3(data []byte) bool {
return len(data) >= 3 && (string(data[:3]) == "ID3" || (data[0] == 0xff && data[1]&0xe0 == 0xe0))
}
func normalizePeak(samples []float64, target float64) {
var peak float64
for _, s := range samples {
if a := math.Abs(s); a > peak {
peak = a
}
}
if peak <= 0 {
return
}
scale := target / peak
for i := range samples {
samples[i] *= scale
}
}
func limitPeak(samples []float64, target float64) {
var peak float64
for _, s := range samples {
if a := math.Abs(s); a > peak {
peak = a
}
}
if peak <= target || peak <= 0 {
return
}
scale := target / peak
for i := range samples {
samples[i] *= scale
}
}
func fade(samples []float64, fadeLen int) {
if fadeLen <= 0 || len(samples) == 0 {
return
}
if fadeLen*2 > len(samples) {
fadeLen = len(samples) / 2
}
for i := 0; i < fadeLen; i++ {
gain := float64(i) / float64(fadeLen)
samples[i] *= gain
samples[len(samples)-1-i] *= gain
}
}
func hamming(n int) []float64 {
w := make([]float64, n)
if n <= 1 {
if n == 1 {
w[0] = 1
}
return w
}
for i := range w {
w[i] = 0.54 - 0.46*math.Cos(twoPi*float64(i)/float64(n-1))
}
return w
}
func fft(a []complex128) {
n := len(a)
j := 0
for i := 1; i < n; i++ {
bit := n >> 1
for ; j&bit != 0; bit >>= 1 {
j &^= bit
}
j |= bit
if i < j {
a[i], a[j] = a[j], a[i]
}
}
for length := 2; length <= n; length <<= 1 {
angle := -twoPi / float64(length)
wlen := complex(math.Cos(angle), math.Sin(angle))
for i := 0; i < n; i += length {
w := complex(1, 0)
for k := 0; k < length/2; k++ {
u := a[i+k]
v := a[i+k+length/2] * w
a[i+k] = u + v
a[i+k+length/2] = u - v
w *= wlen
}
}
}
}
func cmplxAbs(c complex128) float64 {
return math.Hypot(real(c), imag(c))
}
func palette(v float64) color.RGBA {
v = clampFloat(v, 0, 1)
colors := []color.RGBA{
{R: 68, G: 1, B: 84, A: 255},
{R: 72, G: 40, B: 120, A: 255},
{R: 62, G: 73, B: 137, A: 255},
{R: 49, G: 104, B: 142, A: 255},
{R: 38, G: 130, B: 142, A: 255},
{R: 31, G: 158, B: 137, A: 255},
{R: 53, G: 183, B: 121, A: 255},
{R: 110, G: 206, B: 88, A: 255},
{R: 181, G: 222, B: 43, A: 255},
{R: 253, G: 231, B: 37, A: 255},
}
scaled := v * float64(len(colors)-1)
idx := int(math.Floor(scaled))
if idx >= len(colors)-1 {
return colors[len(colors)-1]
}
t := scaled - float64(idx)
a, b := colors[idx], colors[idx+1]
return color.RGBA{
R: uint8(float64(a.R) + (float64(b.R)-float64(a.R))*t),
G: uint8(float64(a.G) + (float64(b.G)-float64(a.G))*t),
B: uint8(float64(a.B) + (float64(b.B)-float64(a.B))*t),
A: 255,
}
}
func nearestPowerOfTwo(n int) int {
if n <= 1 {
return 1
}
p := 1
for p < n {
p <<= 1
}
prev := p >> 1
if p-n < n-prev {
return p
}
return prev
}
func clampInt(v, min, max int) int {
if v < min {
return min
}
if v > max {
return max
}
return v
}
func clampFloat(v, min, max float64) float64 {
if v < min {
return min
}
if v > max {
return max
}
return v
}