/* * Copyright (C) 2008 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.ringdroid; import android.content.Context; import android.graphics.Canvas; import android.graphics.DashPathEffect; import android.graphics.Paint; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import com.ringdroid.soundfile.CheapSoundFile; /** * WaveformView is an Android view that displays a visual representation * of an audio waveform. It retrieves the frame gains from a CheapSoundFile * object and recomputes the shape contour at several zoom levels. * * This class doesn't handle selection or any of the touch interactions * directly, so it exposes a listener interface. The class that embeds * this view should add itself as a listener and make the view scroll * and respond to other events appropriately. * * WaveformView doesn't actually handle selection, but it will just display * the selected part of the waveform in a different color. */ public class WaveformView extends View { public interface WaveformListener { public void waveformTouchStart(float x); public void waveformTouchMove(float x); public void waveformTouchEnd(); public void waveformDraw(); }; // Colors private Paint mGridPaint; private Paint mSelectedLinePaint; private Paint mUnselectedLinePaint; private Paint mUnselectedBkgndLinePaint; private Paint mBorderLinePaint; private Paint mPlaybackLinePaint; private Paint mTimecodePaint; private CheapSoundFile mSoundFile; private int[] mLenByZoomLevel; private double[][] mValuesByZoomLevel; private double[] mZoomFactorByZoomLevel; private int[] mHeightsAtThisZoomLevel; private int mZoomLevel; private int mNumZoomLevels; private int mSampleRate; private int mSamplesPerFrame; private int mOffset; private int mSelectionStart; private int mSelectionEnd; private int mPlaybackPos; private float mDensity; private WaveformListener mListener; private boolean mInitialized; public WaveformView(Context context, AttributeSet attrs) { super(context, attrs); // We don't want keys, the markers get these setFocusable(false); mGridPaint = new Paint(); mGridPaint.setAntiAlias(false); mGridPaint.setColor( getResources().getColor(R.drawable.grid_line)); mSelectedLinePaint = new Paint(); mSelectedLinePaint.setAntiAlias(false); mSelectedLinePaint.setColor( getResources().getColor(R.drawable.waveform_selected)); mUnselectedLinePaint = new Paint(); mUnselectedLinePaint.setAntiAlias(false); mUnselectedLinePaint.setColor( getResources().getColor(R.drawable.waveform_unselected)); mUnselectedBkgndLinePaint = new Paint(); mUnselectedBkgndLinePaint.setAntiAlias(false); mUnselectedBkgndLinePaint.setColor( getResources().getColor( R.drawable.waveform_unselected_bkgnd_overlay)); mBorderLinePaint = new Paint(); mBorderLinePaint.setAntiAlias(true); mBorderLinePaint.setStrokeWidth(1.5f); mBorderLinePaint.setPathEffect( new DashPathEffect(new float[] { 3.0f, 2.0f }, 0.0f)); mBorderLinePaint.setColor( getResources().getColor(R.drawable.selection_border)); mPlaybackLinePaint = new Paint(); mPlaybackLinePaint.setAntiAlias(false); mPlaybackLinePaint.setColor( getResources().getColor(R.drawable.playback_indicator)); mTimecodePaint = new Paint(); mTimecodePaint.setAntiAlias(true); mTimecodePaint.setColor( getResources().getColor(R.drawable.timecode)); mTimecodePaint.setShadowLayer( 2, 1, 1, getResources().getColor(R.drawable.timecode_shadow)); mSoundFile = null; mLenByZoomLevel = null; mValuesByZoomLevel = null; mHeightsAtThisZoomLevel = null; mOffset = 0; mPlaybackPos = -1; mSelectionStart = 0; mSelectionEnd = 0; mDensity = 1.0f; mInitialized = false; } @Override public boolean onTouchEvent(MotionEvent event) { switch(event.getAction()) { case MotionEvent.ACTION_DOWN: mListener.waveformTouchStart(event.getX()); break; case MotionEvent.ACTION_MOVE: mListener.waveformTouchMove(event.getX()); break; case MotionEvent.ACTION_UP: mListener.waveformTouchEnd(); break; } return true; } public void setSoundFile(CheapSoundFile soundFile) { mSoundFile = soundFile; mSampleRate = mSoundFile.getSampleRate(); mSamplesPerFrame = mSoundFile.getSamplesPerFrame(); computeDoublesForAllZoomLevels(); mHeightsAtThisZoomLevel = null; } public boolean isInitialized() { return mInitialized; } public int getZoomLevel() { return mZoomLevel; } public void setZoomLevel(int zoomLevel) { while (mZoomLevel > zoomLevel) { zoomIn(); } while (mZoomLevel < zoomLevel) { zoomOut(); } } public boolean canZoomIn() { return (mZoomLevel > 0); } public void zoomIn() { if (canZoomIn()) { mZoomLevel--; mSelectionStart *= 2; mSelectionEnd *= 2; mHeightsAtThisZoomLevel = null; int offsetCenter = mOffset + getMeasuredWidth() / 2; offsetCenter *= 2; mOffset = offsetCenter - getMeasuredWidth() / 2; if (mOffset < 0) mOffset = 0; invalidate(); } } public boolean canZoomOut() { return (mZoomLevel < mNumZoomLevels - 1); } public void zoomOut() { if (canZoomOut()) { mZoomLevel++; mSelectionStart /= 2; mSelectionEnd /= 2; int offsetCenter = mOffset + getMeasuredWidth() / 2; offsetCenter /= 2; mOffset = offsetCenter - getMeasuredWidth() / 2; if (mOffset < 0) mOffset = 0; mHeightsAtThisZoomLevel = null; invalidate(); } } public int maxPos() { if (mLenByZoomLevel != null) return mLenByZoomLevel[mZoomLevel]; else return 0; } public int secondsToFrames(double seconds) { return (int)(1.0 * seconds * mSampleRate / mSamplesPerFrame + 0.5); } public int secondsToPixels(double seconds) { double z = mZoomFactorByZoomLevel[mZoomLevel]; return (int)(z * seconds * mSampleRate / mSamplesPerFrame + 0.5); } public double pixelsToSeconds(int pixels) { double z = mZoomFactorByZoomLevel[mZoomLevel]; return (pixels * (double)mSamplesPerFrame / (mSampleRate * z)); } public int millisecsToPixels(int msecs) { double z = mZoomFactorByZoomLevel[mZoomLevel]; return (int)((msecs * 1.0 * mSampleRate * z) / (1000.0 * mSamplesPerFrame) + 0.5); } public int pixelsToMillisecs(int pixels) { double z = mZoomFactorByZoomLevel[mZoomLevel]; return (int)(pixels * (1000.0 * mSamplesPerFrame) / (mSampleRate * z) + 0.5); } public void setParameters(int start, int end, int offset) { mSelectionStart = start; mSelectionEnd = end; mOffset = offset; } public int getStart() { return mSelectionStart; } public int getEnd() { return mSelectionEnd; } public int getOffset() { return mOffset; } public void setPlayback(int pos) { mPlaybackPos = pos; } public void setListener(WaveformListener listener) { mListener = listener; } public void recomputeHeights(float density) { mHeightsAtThisZoomLevel = null; mDensity = density; invalidate(); } /** * Our waveform is "painted" as a series of vertical lines, not * anti-aliased. * * But, the Cupcake API only supports the standard resolution * and automatically scales everything up by a factor of 1.5 on * a high-resolution display, which leaves "gaps" when we try to * draw one vertical line per "pixel". (The coordinates are * scaled, but the lines are drawn normally.) * * So, if the density is > 1.0, then we draw two vertical lines instead * of one every other time. This solves the problem for a scale * factor between 1.0 and 2.0. A future version of Ringdroid will * use the 1.6 (Donut) API or later and natively support all resolutions. */ protected void drawWaveformLine(Canvas canvas, int x, int y0, int y1, Paint paint) { if (mDensity > 1.0 && (x % 2) == 1) { canvas.drawLine(x + 0.5f, y0, x + 0.5f, y1, paint); } canvas.drawLine(x, y0, x, y1, paint); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (mSoundFile == null) return; if (mHeightsAtThisZoomLevel == null) computeIntsForThisZoomLevel(); // Draw waveform int measuredWidth = getMeasuredWidth(); int measuredHeight = getMeasuredHeight(); int start = mOffset; int width = mHeightsAtThisZoomLevel.length - start; int ctr = measuredHeight / 2; if (width > measuredWidth) width = measuredWidth; // Draw grid double onePixelInSecs = pixelsToSeconds(1); boolean onlyEveryFiveSecs = (onePixelInSecs > 1.0 / 50.0); double fractionalSecs = mOffset * onePixelInSecs; int integerSecs = (int) fractionalSecs; int i = 0; while (i < width) { i++; fractionalSecs += onePixelInSecs; int integerSecsNew = (int) fractionalSecs; if (integerSecsNew != integerSecs) { integerSecs = integerSecsNew; if (!onlyEveryFiveSecs || 0 == (integerSecs % 5)) { canvas.drawLine(i, 0, i, measuredHeight, mGridPaint); } } } // Draw waveform for (i = 0; i < width; i++) { Paint paint; if (i + start >= mSelectionStart && i + start < mSelectionEnd) { paint = mSelectedLinePaint; } else { drawWaveformLine(canvas, i, 0, measuredHeight, mUnselectedBkgndLinePaint); paint = mUnselectedLinePaint; } drawWaveformLine( canvas, i, ctr - mHeightsAtThisZoomLevel[start + i], ctr + 1 + mHeightsAtThisZoomLevel[start + i], paint); if (i + start == mPlaybackPos) { canvas.drawLine(i, 0, i, measuredHeight, mPlaybackLinePaint); } } // If we can see the right edge of the waveform, draw the // non-waveform area to the right as unselected for (i = width; i < measuredWidth; i++) { drawWaveformLine(canvas, i, 0, measuredHeight, mUnselectedBkgndLinePaint); } // Draw borders canvas.drawLine( mSelectionStart - mOffset + 0.5f, 30, mSelectionStart - mOffset + 0.5f, measuredHeight, mBorderLinePaint); canvas.drawLine( mSelectionEnd - mOffset + 0.5f, 0, mSelectionEnd - mOffset + 0.5f, measuredHeight - 30, mBorderLinePaint); // Draw timecode double timecodeIntervalSecs = 1.0; if (timecodeIntervalSecs / onePixelInSecs < 50) { timecodeIntervalSecs = 5.0; } if (timecodeIntervalSecs / onePixelInSecs < 50) { timecodeIntervalSecs = 15.0; } // Draw grid fractionalSecs = mOffset * onePixelInSecs; int integerTimecode = (int) (fractionalSecs / timecodeIntervalSecs); i = 0; while (i < width) { i++; fractionalSecs += onePixelInSecs; integerSecs = (int) fractionalSecs; int integerTimecodeNew = (int) (fractionalSecs / timecodeIntervalSecs); if (integerTimecodeNew != integerTimecode) { integerTimecode = integerTimecodeNew; // Turn, e.g. 67 seconds into "1:07" String timecodeMinutes = "" + (integerSecs / 60); String timecodeSeconds = "" + (integerSecs % 60); if ((integerSecs % 60) < 10) { timecodeSeconds = "0" + timecodeSeconds; } String timecodeStr = timecodeMinutes + ":" + timecodeSeconds; float offset = (float) ( 0.5 * mTimecodePaint.measureText(timecodeStr)); canvas.drawText(timecodeStr, i - offset, 15, mTimecodePaint); } } if (mListener != null) { mListener.waveformDraw(); } } /** * Called once when a new sound file is added */ private void computeDoublesForAllZoomLevels() { int numFrames = mSoundFile.getNumFrames(); int[] frameGains = mSoundFile.getFrameGains(); double[] smoothedGains = new double[numFrames]; if (numFrames == 1) { smoothedGains[0] = frameGains[0]; } else if (numFrames == 2) { smoothedGains[0] = frameGains[0]; smoothedGains[1] = frameGains[1]; } else if (numFrames > 2) { smoothedGains[0] = (double)( (frameGains[0] / 2.0) + (frameGains[1] / 2.0)); for (int i = 1; i < numFrames - 1; i++) { smoothedGains[i] = (double)( (frameGains[i - 1] / 3.0) + (frameGains[i ] / 3.0) + (frameGains[i + 1] / 3.0)); } smoothedGains[numFrames - 1] = (double)( (frameGains[numFrames - 2] / 2.0) + (frameGains[numFrames - 1] / 2.0)); } // Make sure the range is no more than 0 - 255 double maxGain = 1.0; for (int i = 0; i < numFrames; i++) { if (smoothedGains[i] > maxGain) { maxGain = smoothedGains[i]; } } double scaleFactor = 1.0; if (maxGain > 255.0) { scaleFactor = 255 / maxGain; } // Build histogram of 256 bins and figure out the new scaled max maxGain = 0; int gainHist[] = new int[256]; for (int i = 0; i < numFrames; i++) { int smoothedGain = (int)(smoothedGains[i] * scaleFactor); if (smoothedGain < 0) smoothedGain = 0; if (smoothedGain > 255) smoothedGain = 255; if (smoothedGain > maxGain) maxGain = smoothedGain; gainHist[smoothedGain]++; } // Re-calibrate the min to be 5% double minGain = 0; int sum = 0; while (minGain < 255 && sum < numFrames / 20) { sum += gainHist[(int)minGain]; minGain++; } // Re-calibrate the max to be 99% sum = 0; while (maxGain > 2 && sum < numFrames / 100) { sum += gainHist[(int)maxGain]; maxGain--; } // Compute the heights double[] heights = new double[numFrames]; double range = maxGain - minGain; for (int i = 0; i < numFrames; i++) { double value = (smoothedGains[i] * scaleFactor - minGain) / range; if (value < 0.0) value = 0.0; if (value > 1.0) value = 1.0; heights[i] = value * value; } mNumZoomLevels = 5; mLenByZoomLevel = new int[5]; mZoomFactorByZoomLevel = new double[5]; mValuesByZoomLevel = new double[5][]; // Level 0 is doubled, with interpolated values mLenByZoomLevel[0] = numFrames * 2; mZoomFactorByZoomLevel[0] = 2.0; mValuesByZoomLevel[0] = new double[mLenByZoomLevel[0]]; if (numFrames > 0) { mValuesByZoomLevel[0][0] = 0.5 * heights[0]; mValuesByZoomLevel[0][1] = heights[0]; } for (int i = 1; i < numFrames; i++) { mValuesByZoomLevel[0][2 * i] = 0.5 * (heights[i - 1] + heights[i]); mValuesByZoomLevel[0][2 * i + 1] = heights[i]; } // Level 1 is normal mLenByZoomLevel[1] = numFrames; mValuesByZoomLevel[1] = new double[mLenByZoomLevel[1]]; mZoomFactorByZoomLevel[1] = 1.0; for (int i = 0; i < mLenByZoomLevel[1]; i++) { mValuesByZoomLevel[1][i] = heights[i]; } // 3 more levels are each halved for (int j = 2; j < 5; j++) { mLenByZoomLevel[j] = mLenByZoomLevel[j - 1] / 2; mValuesByZoomLevel[j] = new double[mLenByZoomLevel[j]]; mZoomFactorByZoomLevel[j] = mZoomFactorByZoomLevel[j - 1] / 2.0; for (int i = 0; i < mLenByZoomLevel[j]; i++) { mValuesByZoomLevel[j][i] = 0.5 * (mValuesByZoomLevel[j - 1][2 * i] + mValuesByZoomLevel[j - 1][2 * i + 1]); } } if (numFrames > 5000) { mZoomLevel = 3; } else if (numFrames > 1000) { mZoomLevel = 2; } else if (numFrames > 300) { mZoomLevel = 1; } else { mZoomLevel = 0; } mInitialized = true; } /** * Called the first time we need to draw when the zoom level has changed * or the screen is resized */ private void computeIntsForThisZoomLevel() { int halfHeight = (getMeasuredHeight() / 2) - 1; mHeightsAtThisZoomLevel = new int[mLenByZoomLevel[mZoomLevel]]; for (int i = 0; i < mLenByZoomLevel[mZoomLevel]; i++) { mHeightsAtThisZoomLevel[i] = (int)(mValuesByZoomLevel[mZoomLevel][i] * halfHeight); } } }