diff --git a/tui/src/tui_runtime.cpp b/tui/src/tui_runtime.cpp index 951c593..ec8e839 100644 --- a/tui/src/tui_runtime.cpp +++ b/tui/src/tui_runtime.cpp @@ -1012,6 +1012,136 @@ float maxHistoryValue(const ScrollingHistoryFrame& history, return result; } +float spectrogramPixelValue(const ScrollingHistoryFrame& history, + size_t column, + int frequencyPixel, + int frequencyPixels) { + if (frequencyPixels <= 0 || frequencyPixel < 0 || + frequencyPixel >= frequencyPixels) { + return 0.0f; + } + const float normalizedStart = static_cast(frequencyPixel) / + static_cast(frequencyPixels); + const float normalizedEnd = static_cast(frequencyPixel + 1) / + static_cast(frequencyPixels); + const size_t firstRow = static_cast(std::floor( + normalizedStart * static_cast(history.columnStride))); + const size_t lastRow = static_cast(std::ceil( + normalizedEnd * static_cast(history.columnStride))); + const float value = maxHistoryValue(history, column, firstRow, lastRow); + return std::isfinite(value) ? std::clamp(value, 0.0f, 1.0f) : 0.0f; +} + +void drawClassicSpectrogram(ftxui::Canvas& surface, + const ScrollingHistoryFrame& history, + SpectrogramColorMode colorMode, + bool vertical) { + const int cellColumns = surface.width() / 2; + const int cellRows = surface.height() / 4; + if (cellColumns <= 0 || cellRows <= 0) return; + + const int timeCells = vertical ? cellRows : cellColumns; + const int frequencyCells = vertical ? cellColumns : cellRows; + const size_t visibleColumns = std::min( + history.columnCount, static_cast(timeCells)); + const size_t sourceStart = history.columnCount - visibleColumns; + const int destinationStart = timeCells - static_cast(visibleColumns); + for (size_t time = 0; time < visibleColumns; ++time) { + const size_t sourceColumn = sourceStart + time; + const int timeCell = destinationStart + static_cast(time); + for (int frequencyCell = 0; frequencyCell < frequencyCells; + ++frequencyCell) { + const float intensity = spectrogramPixelValue( + history, sourceColumn, frequencyCell, frequencyCells); + if (intensity < 0.008f) continue; + + const int cellX = vertical ? frequencyCell : timeCell; + const int cellY = vertical ? timeCell : frequencyCell; + const ftxui::Color color = spectrogramColor(intensity, colorMode); + for (int dx = 0; dx < 2; ++dx) { + surface.DrawBlock(cellX * 2 + dx, cellY * 4, true, color); + surface.DrawBlock(cellX * 2 + dx, cellY * 4 + 2, true, color); + } + } + } +} + +void drawDetailedSpectrogram(ftxui::Canvas& surface, + const ScrollingHistoryFrame& history, + SpectrogramColorMode colorMode, + SpectrogramClarity clarity, + bool vertical) { + const int cellColumns = surface.width() / 2; + const int cellRows = surface.height() / 4; + if (cellColumns <= 0 || cellRows <= 0) return; + + // A terminal cell contains eight Braille dots. Treat those as a 2x4 + // spectrogram raster instead of collapsing the analyzer output to one + // solid character. The ordered thresholds give quieter energy texture + // without hiding the strongest narrow feature in each cell. + constexpr std::array orderedThresholds = { + 1.0f / 9.0f, 5.0f / 9.0f, + 7.0f / 9.0f, 3.0f / 9.0f, + 2.0f / 9.0f, 6.0f / 9.0f, + 8.0f / 9.0f, 4.0f / 9.0f, + }; + const float thresholdScale = clarity == SpectrogramClarity::Sharp + ? 0.82f + : 1.0f; + const int timePixels = vertical ? surface.height() : surface.width(); + const int frequencyPixels = vertical ? surface.width() : surface.height(); + const size_t visibleColumns = std::min( + history.columnCount, static_cast(timePixels)); + const size_t sourceStart = history.columnCount - visibleColumns; + const int destinationStart = timePixels - static_cast(visibleColumns); + + for (int cellY = 0; cellY < cellRows; ++cellY) { + for (int cellX = 0; cellX < cellColumns; ++cellX) { + std::array intensities{}; + float strongest = 0.0f; + size_t strongestIndex = 0; + for (int dotY = 0; dotY < 4; ++dotY) { + for (int dotX = 0; dotX < 2; ++dotX) { + const size_t index = static_cast(dotY * 2 + dotX); + const int timePixel = vertical + ? cellY * 4 + dotY + : cellX * 2 + dotX; + const int frequencyPixel = vertical + ? cellX * 2 + dotX + : cellY * 4 + dotY; + if (timePixel < destinationStart) continue; + const size_t sourceColumn = sourceStart + + static_cast(timePixel - destinationStart); + intensities[index] = spectrogramPixelValue( + history, sourceColumn, frequencyPixel, frequencyPixels); + if (intensities[index] > strongest) { + strongest = intensities[index]; + strongestIndex = index; + } + } + } + if (strongest < 0.008f) continue; + + const ftxui::Color color = spectrogramColor(strongest, colorMode); + for (int dotY = 0; dotY < 4; ++dotY) { + for (int dotX = 0; dotX < 2; ++dotX) { + const size_t index = static_cast(dotY * 2 + dotX); + const bool strongestDot = index == strongestIndex; + const bool visible = intensities[index] >= + orderedThresholds[index] * thresholdScale; + if (strongestDot || visible) { + surface.DrawPoint( + cellX * 2 + dotX, + cellY * 4 + dotY, + true, + color); + } + } + } + } + } +} + ftxui::Element renderSpectrogramPanel(const DisplayFrame& frame, int width, int height, @@ -1025,51 +1155,28 @@ ftxui::Element renderSpectrogramPanel(const DisplayFrame& frame, auto plot = canvas([ history, colorMode = settings.spectrogramColor, + clarity = settings.spectrogramClarity, vertical ](Canvas& surface) { - const int cellColumns = surface.width() / 2; - const int cellRows = surface.height() / 4; - if (cellColumns <= 0 || cellRows <= 0 || history.columnCount == 0 || + if (surface.width() <= 0 || surface.height() <= 0 || + history.columnCount == 0 || history.columnStride == 0) { return; } - - const int timeCells = vertical ? cellRows : cellColumns; - const int frequencyCells = vertical ? cellColumns : cellRows; - const size_t visibleColumns = std::min( - history.columnCount, static_cast(timeCells)); - const size_t sourceStart = history.columnCount - visibleColumns; - const int destinationStart = timeCells - static_cast(visibleColumns); - for (size_t time = 0; time < visibleColumns; ++time) { - const size_t sourceColumn = sourceStart + time; - const int timeCell = destinationStart + static_cast(time); - for (int frequencyCell = 0; frequencyCell < frequencyCells; ++frequencyCell) { - const float normalizedStart = static_cast(frequencyCell) / - static_cast(frequencyCells); - const float normalizedEnd = static_cast(frequencyCell + 1) / - static_cast(frequencyCells); - const size_t firstRow = static_cast(std::floor( - normalizedStart * static_cast(history.columnStride))); - const size_t lastRow = static_cast(std::ceil( - normalizedEnd * static_cast(history.columnStride))); - const float intensity = maxHistoryValue( - history, sourceColumn, firstRow, lastRow); - if (!std::isfinite(intensity) || intensity < 0.008f) continue; - - const int cellX = vertical ? frequencyCell : timeCell; - const int cellY = vertical - ? timeCell - : frequencyCell; - const Color color = spectrogramColor(intensity, colorMode); - for (int dx = 0; dx < 2; ++dx) { - surface.DrawBlock(cellX * 2 + dx, cellY * 4, true, color); - surface.DrawBlock(cellX * 2 + dx, cellY * 4 + 2, true, color); - } - } + if (clarity == SpectrogramClarity::Classic) { + drawClassicSpectrogram(surface, history, colorMode, vertical); + } else { + drawDetailedSpectrogram( + surface, history, colorMode, clarity, vertical); } }) | flex; - std::string detail = std::string(spectrogramColorName(settings.spectrogramColor)); + std::string detail = std::string( + spectrogramClarityName(settings.spectrogramClarity)); + if (width >= 42) { + detail += " • " + std::string( + spectrogramColorName(settings.spectrogramColor)); + } if (width >= 48) { detail += " • " + std::string(spectrogramScaleName(settings.spectrogramScale)); }