saveSpectrogramImage function
Renders a Mel spectrogram in dB to a PNG image file.
This generates a visualization similar to librosa.display.specshow.
spectrogramDb: The spectrogram in dB (rows x frames).
outputPath: The path to save the .png file.
Implementation
Future<void> saveSpectrogramImage(
List<Float64List> spectrogramDb,
String outputPath, {
required int sampleRate,
required int hopLength,
double fMin = 0.0,
double? fMax,
double topDb = 80.0,
String title = 'Spectrogram',
int plotWidth = 800,
int plotHeight = 256,
bool useNearest = true,
bool isMel = true,
}) async {
final nRows = spectrogramDb.length;
final nFrames = spectrogramDb.isEmpty ? 0 : spectrogramDb[0].length;
if (nFrames == 0) throw ArgumentError('Empty spectrogram.');
if (sampleRate <= 0) throw ArgumentError('Sample rate must be positive.');
fMax ??= sampleRate / 2.0;
const marginLeft = 70;
const marginRight = 90;
const marginTop = 40;
const marginBottom = 50;
const colorbarW = 18;
const colorbarGap = 10;
final canvasW = marginLeft + plotWidth + colorbarGap + colorbarW + marginRight;
final canvasH = marginTop + plotHeight + marginBottom;
final canvas = img.Image(width: canvasW, height: canvasH);
final bgColor = img.ColorRgb8(255, 255, 255);
img.fill(canvas, color: bgColor);
final dbFloor = -topDb.abs();
for (int px = 0; px < plotWidth; px++) {
final frameF = px / (plotWidth - 1) * (nFrames - 1);
final f0 = frameF.floor().clamp(0, nFrames - 1);
final f1 = (f0 + 1).clamp(0, nFrames - 1);
final frac = frameF - f0;
for (int py = 0; py < plotHeight; py++) {
final rowF = (1.0 - py / (plotHeight - 1)) * (nRows - 1);
final r0 = rowF.floor().clamp(0, nRows - 1);
final r1 = (r0 + 1).clamp(0, nRows - 1);
final rFrac = rowF - r0;
double v;
if (useNearest) {
v = spectrogramDb[r0][f0];
} else {
v = spectrogramDb[r0][f0] * (1 - rFrac) * (1 - frac) +
spectrogramDb[r1][f0] * rFrac * (1 - frac) +
spectrogramDb[r0][f1] * (1 - rFrac) * frac +
spectrogramDb[r1][f1] * rFrac * frac;
}
final t = ((v - dbFloor) / topDb).clamp(0.0, 1.0);
canvas.setPixel(marginLeft + px, marginTop + py, _magma(t));
}
}
final axisColor = img.ColorRgb8(0, 0, 0);
for (int x = marginLeft; x < marginLeft + plotWidth; x++) {
canvas.setPixel(x, marginTop + plotHeight, axisColor);
}
for (int y = marginTop; y <= marginTop + plotHeight; y++) {
canvas.setPixel(marginLeft, y, axisColor);
}
final totalTime = nFrames * hopLength / sampleRate;
final approxTicks = 8;
final rawInterval = totalTime / approxTicks;
final niceIntervals = [0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.5, 1.0, 2.0, 5.0];
double tickInterval = niceIntervals.last;
for (final iv in niceIntervals) {
if (iv >= rawInterval) { tickInterval = iv; break; }
}
double t = 0.0;
while (t <= totalTime + 1e-9) {
final px = ((t / totalTime) * (plotWidth - 1)).round().clamp(0, plotWidth - 1);
final label = t == t.truncateToDouble() ? t.toStringAsFixed(0) : t.toStringAsFixed(2);
_drawTick(canvas, marginLeft + px, marginTop + plotHeight, label, color: axisColor);
t = ((t * 1e6) + (tickInterval * 1e6)).round() / 1e6;
}
img.drawString(canvas, 'Time', font: img.arial14, x: marginLeft + plotWidth ~/ 2 - 16, y: canvasH - 18, color: axisColor);
final hzLandmarks = <double>[];
if (fMin == 0.0) hzLandmarks.add(0.0);
double hz = 512.0;
while (hz <= (fMax)) {
if (hz >= fMin) hzLandmarks.add(hz);
hz *= 2;
}
final yMin = isMel ? hzToMel(fMin) : fMin;
final yMax = isMel ? hzToMel(fMax) : fMax;
for (final hzVal in hzLandmarks) {
final yVal = isMel ? hzToMel(hzVal) : hzVal;
final norm = (yVal - yMin) / (yMax - yMin);
final py = marginTop + plotHeight - (norm * (plotHeight - 1)).round();
if (py >= marginTop && py <= marginTop + plotHeight) {
final label = hzVal >= 1000 ? '${(hzVal / 1).round()}' : '${hzVal.round()}';
_drawTick(canvas, marginLeft, py, label, vertical: true, color: axisColor);
}
}
img.drawString(canvas, 'Hz', font: img.arial14, x: 4, y: marginTop + plotHeight ~/ 2 - 7, color: axisColor);
final cbX = marginLeft + plotWidth + colorbarGap;
for (int py = 0; py < plotHeight; py++) {
final t = 1.0 - py / (plotHeight - 1);
final c = _magma(t);
for (int dx = 0; dx < colorbarW; dx++) {
canvas.setPixel(cbX + dx, marginTop + py, c);
}
}
for (int py = marginTop; py <= marginTop + plotHeight; py++) {
canvas.setPixel(cbX, py, axisColor);
canvas.setPixel(cbX + colorbarW, py, axisColor);
}
canvas.setPixel(cbX, marginTop, axisColor);
canvas.setPixel(cbX, marginTop + plotHeight, axisColor);
final dbStep = topDb <= 40 ? 10.0 : 20.0;
for (double db = 0; db >= -topDb; db -= dbStep) {
final norm = (db - (-topDb)) / topDb;
final py = marginTop + plotHeight - (norm * (plotHeight - 1)).round();
final label = db == 0 ? '+0 dB' : '${db.toInt()} dB';
_drawTick(canvas, cbX + colorbarW, py, label, right: true, color: axisColor);
}
img.drawString(canvas, title,
font: img.arial14,
x: marginLeft + plotWidth ~/ 2 - title.length * 4,
y: 12,
color: axisColor,
);
final pngBytes = img.encodePng(canvas);
await File(outputPath).writeAsBytes(pngBytes);
}