/******************************************************************************* * SDR Trunk * Copyright (C) 2014,2015 Dennis Sheirer * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see ******************************************************************************/ package io.github.dsheirer.spectrum; import io.github.dsheirer.settings.ColorSetting; import io.github.dsheirer.settings.ColorSetting.ColorSettingName; import io.github.dsheirer.settings.Setting; import io.github.dsheirer.settings.SettingChangeListener; import io.github.dsheirer.settings.SettingsManager; import org.apache.commons.math3.util.FastMath; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.swing.JPanel; import java.awt.Color; import java.awt.EventQueue; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.Image; import java.awt.Point; import java.awt.geom.Line2D; import java.awt.image.ColorModel; import java.awt.image.MemoryImageSource; import java.text.DecimalFormat; import java.util.Arrays; public class WaterfallPanel extends JPanel implements DFTResultsListener, Pausable, SettingChangeListener { private static final long serialVersionUID = 1L; private final static Logger mLog = LoggerFactory.getLogger(WaterfallPanel.class); private static DecimalFormat CURSOR_FORMAT = new DecimalFormat("0.00000"); private static final String PAUSED = "PAUSED - Right Click to Unpause"; private static final String DISABLED = "DISABLED - Right Click to Select a Tuner"; private byte[] mPixels; private byte[] mPausedPixels; private int mDFTSize = 4096; private int mImageHeight = 700; private MemoryImageSource mMemoryImageSource; private ColorModel mColorModel = WaterfallColorModel.getDefaultColorModel(); private Color mColorSpectrumCursor; private Image mWaterfallImage; private Point mCursorLocation = new Point(0, 0); private boolean mCursorVisible = false; private long mCursorFrequency = 0; private boolean mPaused = false; private boolean mDisabled = true; private int mZoom = 0; private int mDFTZoomWindowOffset = 0; private SettingsManager mSettingsManager; /** * Displays a scrolling window of multiple DFT frequency bin outputs over * time. Maps DFT frequency bin decibel values into a 256 bucket color map * for display. * * @param settingsManager */ public WaterfallPanel(SettingsManager settingsManager) { super(); mSettingsManager = settingsManager; mSettingsManager.addListener(this); mColorSpectrumCursor = getColor(ColorSettingName.SPECTRUM_CURSOR); reset(); } /** * Prepares this instance for disposal */ public void dispose() { if(mSettingsManager != null) { mSettingsManager.removeListener(this); } mSettingsManager = null; mMemoryImageSource = null; } /** * Resets the memory image source and byte backing array when the DFT point * size has changed */ private void reset() { mPixels = new byte[mDFTSize * mImageHeight]; mMemoryImageSource = new MemoryImageSource(mDFTSize, mImageHeight, mColorModel, mPixels, 0, mDFTSize); mMemoryImageSource.setAnimated(true); mWaterfallImage = createImage(mMemoryImageSource); repaint(); } /** * Pausable interface - pauses updates to the waterfall */ public void setPaused(boolean paused) { if(paused) { mPausedPixels = mPixels.clone(); } mPaused = paused; repaint(); } /** * Returns current pause state * * @return true if paused, false otherwise */ public boolean isPaused() { return mPaused; } /** * Indicates if the waterfall is currently disabled. */ public boolean isDisabled() { return mDisabled; } /** * Sets the current zoom level (2^zoom) * * 0 No Zoom * 1 2x Zoom * 2 4x Zoom * 3 8x Zoom * 4 16x Zoom * 5 32x Zoom * 6 64x Zoom * * @param zoom level, 0 - 6. * @param offset into the DFT bins for display */ public void setZoom(int zoom) { mZoom = zoom; } /** * Multiplier for the current zoom level */ private int getZoomMultiplier() { return (int) FastMath.pow(2.0, mZoom); } /** * Sets the zoom window offset from zero * * @param offset in DFT bins */ public void setZoomWindowOffset(int offset) { mDFTZoomWindowOffset = offset; } /** * Fetches a named color setting from the settings manager. If the setting * doesn't exist, creates the setting using the defaultColor */ private Color getColor(ColorSettingName name) { ColorSetting setting = mSettingsManager.getColorSetting(name); return setting.getColor(); } /** * Monitors for setting changes. Colors can be changed by external actions * and will automatically update in this class */ @Override public void settingChanged(Setting setting) { if(setting instanceof ColorSetting) { ColorSetting colorSetting = (ColorSetting)setting; if(((ColorSetting)setting).getColorSettingName() == ColorSettingName.SPECTRUM_CURSOR) { mColorSpectrumCursor = colorSetting.getColor(); } } } @Override public void settingDeleted(Setting setting) { /* Not implemented */ } /** * Sets the display location of the cursor. Cursor location monitoring is * handled external to this class. * * @param point */ public void setCursorLocation(Point point) { mCursorLocation = point; repaint(); } /** * Sets the current cursor display frequency. Cursor location frequency * monitoring is handled external to this class. * * @param frequency */ public void setCursorFrequency(long frequency) { mCursorFrequency = frequency; } /** * Toggles the visibility of the cursor * * @param visible */ public void setCursorVisible(boolean visible) { mCursorVisible = visible; repaint(); } /** * Calculates the x-axis pixel offset from zero where to start rendering the * waterfall image * * @param multiplier - current zoom multiplier * @return x-axis pixel offset */ private double getPixelOffset(int multiplier) { double offset = 0; if(mZoom != 0) { double binPixelWidth = getBinPixelWidth(multiplier); offset = -binPixelWidth * (double)(mDFTZoomWindowOffset); } return offset; } private double getBinPixelWidth(int multiplier) { return ((double)getWidth() * (double)multiplier) / (double)mDFTSize; } /** * Renders the screen at each refresh */ public void paintComponent(Graphics g) { super.paintComponent(g); int multiplier = getZoomMultiplier(); double binPixelWidth = getBinPixelWidth(multiplier); int offset = (int)(getPixelOffset(multiplier) - binPixelWidth); g.drawImage(mWaterfallImage, offset, 0, (getWidth() * multiplier) + (int)binPixelWidth, mImageHeight, this); Graphics2D graphics = (Graphics2D)g; graphics.setColor(mColorSpectrumCursor); if(mCursorVisible) { graphics.draw(new Line2D.Float(mCursorLocation.x, 0, mCursorLocation.x, (float)(getSize().getHeight()))); String frequency = CURSOR_FORMAT.format(mCursorFrequency / 1000000.0D); graphics.drawString(frequency, mCursorLocation.x + 5, mCursorLocation.y); } if(mDisabled) { graphics.drawString(DISABLED, 20, 20); } else if(mPaused) { graphics.drawString(PAUSED, 20, 20); } paintZoomIndicator(graphics); graphics.dispose(); } /** * When zoom level is greater than zero, paints a small indicator at the * bottom center of the screen showing the location of the zoom window * within the overall DFT results window */ private void paintZoomIndicator(Graphics2D graphics) { if(mZoom != 0) { int width = getWidth() / 4; int x = (getWidth() / 2) - (width / 2); //Draw the outer window graphics.drawRect(x, getHeight() - 12, width, 10); int zoomWidth = width / getZoomMultiplier(); int windowOffset = 0; if(mDFTZoomWindowOffset != 0) { windowOffset = (int)(((double)mDFTZoomWindowOffset / (double)mDFTSize) * width); } //Draw the zoom window graphics.fillRect(x + windowOffset, getHeight() - 12, zoomWidth, 10); //Draw the zoom text graphics.drawString("Zoom: " + getZoomMultiplier() + "x", x + width + 3, getHeight() - 2); } } /** * Implements the DFT results listener interface method. This is the * primary method for receiving new frequency bin results. */ @Override public void receive(float[] update) { mDisabled = false; //If our FFT size changes, reset our pixel map and image source if(mDFTSize != update.length) { mDFTSize = update.length; reset(); } //Move the pixels down a row to make room for the new results System.arraycopy(mPixels, 0, mPixels, mDFTSize, mPixels.length - mDFTSize); /** * Find the average value and scale the display to it */ double sum = 0.0d; for(int x = 0; x < update.length - 1; x++) { sum += update[x]; } float average = (float)(sum / (double)update.length - 1); float scale = 256.0f / average; for(int x = 0; x < update.length - 1; x++) { float value = (average - update[x]) * scale; if(value < 0) { mPixels[x] = 0; } else if(value > 255) { mPixels[x] = (byte)255; } else { mPixels[x] = (byte)value; } } //Task the swing event thread to update the display EventQueue.invokeLater(new Runnable() { @Override public void run() { if(mMemoryImageSource != null) { if(mPaused) { mMemoryImageSource.newPixels(mPausedPixels, mColorModel, 0, mDFTSize); } else { mMemoryImageSource.newPixels(mPixels, mColorModel, 0, mDFTSize); } } } }); } public void clearWaterfall() { Arrays.fill(mPixels, (byte)0); mDisabled = true; EventQueue.invokeLater(new Runnable() { @Override public void run() { if(mMemoryImageSource != null) { mMemoryImageSource.newPixels(mPixels, mColorModel, 0, mDFTSize); } } }); } }