123 lines
4.2 KiB
Go
123 lines
4.2 KiB
Go
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)
|
||
}
|