v3 candidate
This commit is contained in:
@@ -0,0 +1,798 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package spectrogram
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestImageToWAVAndRenderSpectrogram(t *testing.T) {
|
||||
img := testPNG(t)
|
||||
wav, summary, err := ImageToWAV(img, EncodeOptions{
|
||||
DurationSec: 0.25,
|
||||
SampleRate: 8000,
|
||||
MinHz: 300,
|
||||
MaxHz: 3000,
|
||||
Bins: 32,
|
||||
Columns: 32,
|
||||
Invert: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ImageToWAV failed: %v", err)
|
||||
}
|
||||
if !isWAV(wav) {
|
||||
t.Fatalf("expected WAV header")
|
||||
}
|
||||
if summary["wavBytes"].(int) != len(wav) {
|
||||
t.Fatalf("summary wavBytes mismatch")
|
||||
}
|
||||
|
||||
rendered, viewSummary, err := AudioSpectrogramPNG(wav, "test.wav", ViewOptions{
|
||||
FFTSize: 512,
|
||||
Height: 96,
|
||||
Aspect: 2,
|
||||
MaxWidth: 128,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AudioSpectrogramPNG failed: %v", err)
|
||||
}
|
||||
if _, err := png.Decode(bytes.NewReader(rendered)); err != nil {
|
||||
t.Fatalf("rendered spectrogram is not a PNG: %v", err)
|
||||
}
|
||||
if viewSummary["sampleRate"].(int) != 8000 {
|
||||
t.Fatalf("unexpected sample rate: %v", viewSummary["sampleRate"])
|
||||
}
|
||||
if viewSummary["height"].(int) != 192 {
|
||||
t.Fatalf("expected aspect-adjusted height 192, got %v", viewSummary["height"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreserveAspectRatioAdjustsFrequencyRange(t *testing.T) {
|
||||
img := testPNGSize(t, 32, 16)
|
||||
_, summary, err := ImageToWAV(img, EncodeOptions{
|
||||
DurationSec: 0.1,
|
||||
SampleRate: 8000,
|
||||
MinHz: 200,
|
||||
MaxHz: 3000,
|
||||
Bins: 16,
|
||||
Columns: 16,
|
||||
PreserveAR: true,
|
||||
Invert: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ImageToWAV failed: %v", err)
|
||||
}
|
||||
if summary["preserveAR"] != true {
|
||||
t.Fatalf("expected preserveAR summary")
|
||||
}
|
||||
if got := summary["maxHz"].(float64); got != 1600 {
|
||||
t.Fatalf("expected landscape image to halve frequency range to 1600Hz, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageToWAVRejectsOversizedImageDimensionsBeforeDecode(t *testing.T) {
|
||||
_, _, err := ImageToWAV(hugePNGHeader(t, 100000, 100000), EncodeOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected oversized image dimensions to be rejected")
|
||||
}
|
||||
if !bytes.Contains([]byte(err.Error()), []byte("image dimensions are too large")) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertImageIntoAudioExtendsCarrier(t *testing.T) {
|
||||
img := testPNG(t)
|
||||
carrier, _, err := ImageToWAV(img, EncodeOptions{
|
||||
DurationSec: 0.1,
|
||||
SampleRate: 8000,
|
||||
MinHz: 300,
|
||||
MaxHz: 2500,
|
||||
Bins: 16,
|
||||
Columns: 16,
|
||||
Invert: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("carrier generation failed: %v", err)
|
||||
}
|
||||
out, summary, err := InsertImageIntoAudio(img, carrier, "carrier.wav", InsertOptions{
|
||||
EncodeOptions: EncodeOptions{
|
||||
DurationSec: 0.2,
|
||||
MinHz: 300,
|
||||
MaxHz: 2500,
|
||||
Bins: 16,
|
||||
Columns: 16,
|
||||
Invert: true,
|
||||
},
|
||||
StartSec: 0.05,
|
||||
MixLevel: 0.25,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InsertImageIntoAudio failed: %v", err)
|
||||
}
|
||||
if !isWAV(out) {
|
||||
t.Fatalf("expected WAV output")
|
||||
}
|
||||
if summary["mode"] != "insert-into-audio" {
|
||||
t.Fatalf("unexpected mode: %v", summary["mode"])
|
||||
}
|
||||
if len(out) <= len(carrier) {
|
||||
t.Fatalf("expected carrier to be extended")
|
||||
}
|
||||
}
|
||||
|
||||
func testPNG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
return testPNGSize(t, 32, 32)
|
||||
}
|
||||
|
||||
func testPNGSize(t *testing.T, width, height int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
img.Set(x, y, color.RGBA{R: 255, G: 255, B: 255, A: 255})
|
||||
}
|
||||
}
|
||||
limit := width
|
||||
if height < limit {
|
||||
limit = height
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
img.Set(i, i, color.RGBA{A: 255})
|
||||
img.Set(width-1-i, i, color.RGBA{A: 255})
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func hugePNGHeader(t *testing.T, width, height uint32) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
buf.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'})
|
||||
ihdr := make([]byte, 13)
|
||||
binary.BigEndian.PutUint32(ihdr[0:4], width)
|
||||
binary.BigEndian.PutUint32(ihdr[4:8], height)
|
||||
ihdr[8] = 8
|
||||
ihdr[9] = 2
|
||||
writePNGChunk(&buf, "IHDR", ihdr)
|
||||
writePNGChunk(&buf, "IEND", nil)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func writePNGChunk(buf *bytes.Buffer, kind string, data []byte) {
|
||||
_ = binary.Write(buf, binary.BigEndian, uint32(len(data)))
|
||||
buf.WriteString(kind)
|
||||
buf.Write(data)
|
||||
crc := crc32.NewIEEE()
|
||||
crc.Write([]byte(kind))
|
||||
crc.Write(data)
|
||||
_ = binary.Write(buf, binary.BigEndian, crc.Sum32())
|
||||
}
|
||||
Reference in New Issue
Block a user