stegterm/internal/audio/spectrogram.go
2026-09-14 11:00:27 +03:00

123 lines
4.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package audio
import (
"image"
"image/color"
"math"
"math/cmplx"
)
// Spectrogram считает STFT сигнала: режет samples на перекрывающиеся
// окна размером windowSize (округляется вверх до степени двойки) с
// шагом hopSize, на каждое окно накладывает окно Ханна и считает БПФ.
// Возвращает матрицу [номер_окна][номерастотного_бина] = амплитуда
// в дБ. Используются только первые windowSize/2 бинов — для
// вещественного сигнала спектр симметричен, вторая половина не несёт
// новой информации.
func Spectrogram(samples []float64, windowSize, hopSize int) [][]float64 {
windowSize = nextPowerOfTwo(windowSize)
if hopSize < 1 {
hopSize = windowSize / 4
}
window := hannWindow(windowSize)
var frames [][]float64
for start := 0; start+windowSize <= len(samples); start += hopSize {
buf := make([]complex128, windowSize)
for i := 0; i < windowSize; i++ {
buf[i] = complex(samples[start+i]*window[i], 0)
}
fft(buf)
bins := make([]float64, windowSize/2)
for i := 0; i < windowSize/2; i++ {
mag := cmplx.Abs(buf[i]) / float64(windowSize)
// +эпсилон — чтобы log10(0) не улетел в -Inf на полной тишине
bins[i] = 20 * math.Log10(mag+1e-12)
}
frames = append(frames, bins)
}
return frames
}
// RenderSpectrogram красит матрицу STFT в изображение: время по
// горизонтали (номер окна), частота по вертикали (низкие частоты
// внизу, высокие — вверху, как в любом спектрограф-вьюере вроде
// Sonic Visualiser). Амплитуда нормализуется по фактическому диапазону
// дБ в самой записи (а не по абсолютной шкале) — так тихие записи не
// превращаются в сплошной чёрный экран.
func RenderSpectrogram(frames [][]float64) *image.RGBA {
if len(frames) == 0 || len(frames[0]) == 0 {
return image.NewRGBA(image.Rect(0, 0, 1, 1))
}
width := len(frames)
height := len(frames[0])
minDB, maxDB := frames[0][0], frames[0][0]
for _, f := range frames {
for _, v := range f {
if v < minDB {
minDB = v
}
if v > maxDB {
maxDB = v
}
}
}
if maxDB-minDB < 1 {
maxDB = minDB + 1 // защита от деления на почти ноль на абсолютно однородном сигнале
}
img := image.NewRGBA(image.Rect(0, 0, width, height))
for x, f := range frames {
for bin, v := range f {
t := (v - minDB) / (maxDB - minDB)
if t < 0 {
t = 0
}
if t > 1 {
t = 1
}
y := height - 1 - bin // низкие частоты внизу изображения
img.Set(x, y, heatColor(t))
}
}
return img
}
// heatColor — точки градиента: чёрный (тихо) → синий → циан → жёлтый →
// белый (громко). Обычная "тепловая карта" для спектрограмм — резкий
// контраст между тихим фоном и громким сигналом, именно это и нужно,
// чтобы разглядеть спрятанный в спектре текст/QR-код/паттерн.
func heatColor(t float64) color.RGBA {
stops := []color.RGBA{
{R: 0, G: 0, B: 0, A: 255},
{R: 0, G: 0, B: 180, A: 255},
{R: 0, G: 200, B: 200, A: 255},
{R: 255, G: 230, B: 0, A: 255},
{R: 255, G: 255, B: 255, A: 255},
}
if t <= 0 {
return stops[0]
}
if t >= 1 {
return stops[len(stops)-1]
}
seg := t * float64(len(stops)-1)
i := int(seg)
if i >= len(stops)-1 {
return stops[len(stops)-1]
}
frac := seg - float64(i)
a, b := stops[i], stops[i+1]
return color.RGBA{
R: lerp8(a.R, b.R, frac),
G: lerp8(a.G, b.G, frac),
B: lerp8(a.B, b.B, frac),
A: 255,
}
}
func lerp8(a, b uint8, t float64) uint8 {
return uint8(float64(a) + (float64(b)-float64(a))*t)
}