Synthesizer
<< Base Converter | LabTrailIndex | Chapter 1-9 Practice >>
This project is my adaptation of Kevin Wayne's wonderful nifty project called Guitar Heroine. The first part encourages making the Ring Buffer and testing with JUnit. Next, instead of a Guitar String, a more generic SoundWave super class is made. GuitarString and others can then be subclasses of SoundWave. Students can then experiment with different functions by writing a different strike
method (known as the pluck
method in Dr. Wayne's version)
See a Demonstration of the working version
Ring Buffer
After a note is struck, something vibrates. The strike or pluck causes a displacement which spreads wave-like over time. The Karplus-Strong algorithm simulates this vibration by maintaining a ring buffer of the N samples: the algorithm repeatedly deletes the first sample from the buffer and adds to the end of the buffer the average of the first two samples, scaled by an energy decay factor of 0.994.
Since the ring buffer has a known maximum capacity, implement it using a double
array of that length. For efficiency, use cyclic wrap-around: Maintain one integer instance variable first
that stores the index of the least recently inserted item; maintain a second integer instance variable last
that stores the index one beyond the most recently inserted item. To insert an item, put it at index last
and increment last
. To remove an item, take it from index first
and increment first
. When either index equals capacity, make it wrap-around by changing the index to 0. Implement RingBuffer to throw an exception if the client attempts to dequeue() from an empty buffer or enqueue() into a full buffer.
RingBuffer.java
/** * The RingBuffer models a medium (such as a string tied down at both ends) in which * the energy travels back and forth. The length of the ring buffer determines the * fundamental frequency of the resulting sound. Sonically, the feedback mechanism * reinforces only the fundamental frequency and its harmonics (frequencies at integer * multiples of the fundamental). The energy decay factor (.994 in this case) models the * slight dissipation in energy as the wave makes a roundtrip through the medium. * */ public class RingBuffer { private double[] buffer; private int first, last, size; /** * Constructor initializes the things needed to make a cyclic wrap-around array * @param capacity - the sampling rate 44,100 divided by frequency, rounded up to the nearest integer */ public RingBuffer(int capacity){ buffer = new double[capacity]; first = 0; last = 0; size = 0; } /** * @return the number values in the buffer that are being used */ public int size(){ //your code here } /** * @return whether or not the buffer is empty (size equals zero) */ public boolean isEmpty(){ //your code here } /** * @return whether or not the buffer full (size equals capacity) */ public boolean isFull(){ //your code here } /** * adds item x to the end. To insert an item, put it at index last and increment last. * If list equals the capacity, make it wrap-around by changing the index (last) to 0. * * Ideally, it should throw an exception if there is an attempt to enqueue into a * full buffer. * * @param x */ public void enqueue(double x){ if (isFull()){ IllegalArgumentException exception = new IllegalArgumentException("Cannot enqueue to a full buffer"); throw exception; } //your code here } /** * Deletes and return the item from the front * To remove an item, take it from index first and increment first. If first * equals the capacity, make it wrap-around by changing the index (first) to 0. * * Ideally it should throw an exception if there is an attempt to dequeue from * an empty buffer. * * @return the item from the front. */ public double dequeue(){ if (isEmpty()){ IllegalStateException exception = new IllegalStateException("Cannot dequeue from a empty buffer"); throw exception; } //your code here } /** * returns (but does not delete) the item from the front * @return the item from the front */ public double peek(){ //your code here } public int getFirst(){ return first;} public int geLast(){ return last;} }
JUnit Test
import static org.junit.Assert.*; import org.junit.After; import org.junit.Before; import org.junit.Test; /** * The test class RingBufferTest. * * @author (your name) * @version (a version number or a date) */ public class RingBufferTest { private RingBuffer ringBuff1; /** * Default constructor for test class RingBufferTest */ public RingBufferTest() { } /** * Sets up the test fixture. * * Called before every test case method. */ @Before public void setUp() { } /** * Tears down the test fixture. * * Called after every test case method. */ @After public void tearDown() { } @Test public void TestIsEmpty() { RingBuffer ringBuff2 = new RingBuffer(2); assertEquals(true, ringBuff2.isEmpty()); assertEquals(0, ringBuff2.size()); } @Test public void TestIsFull() { ringBuff1 = new RingBuffer(2); ringBuff1.enqueue(1.0); ringBuff1.enqueue(2.0); assertEquals(true, ringBuff1.isFull()); assertEquals(2, ringBuff1.size()); assertEquals(1.0, ringBuff1.peek(), 0.0); } @Test public void TestDecueEncue() { RingBuffer ringBuff1 = new RingBuffer(3); ringBuff1.enqueue(1.0); ringBuff1.enqueue(2.0); ringBuff1.enqueue(3.0); assertEquals(3, ringBuff1.size()); assertEquals(true, ringBuff1.isFull()); assertEquals(1.0, ringBuff1.dequeue(), 0.0); assertEquals(2, ringBuff1.size()); assertEquals(false, ringBuff1.isFull()); ringBuff1.enqueue(4.0); assertEquals(true, ringBuff1.isFull()); assertEquals(3, ringBuff1.size()); assertEquals(1, ringBuff1.geLast()); assertEquals(2.0, ringBuff1.peek(), 0.0); } }
Starter code for the Syth:
Visualizer.java
import java.awt.Color; import java.awt.Graphics; public class Visualizer { private int [] values; private int x,y, width, height;//location and scale of drawing private int n, oldest, newest; //in of able the data public Visualizer(int size, int x, int y, int width, int height ){ this.n=size; this.x = x; this.y=y; this.width=width; this.height=height; values = new int[n]; for (int i=0; i< n; i++) values[i]=0; oldest=0; newest=n-1; } public void add(double sample){ int value = (int)(height*2.0*(sample+0.5)); oldest=(oldest+1)%n; newest=(newest+1)%n; values[newest]=value; } public void drawLine(Graphics g){ g.setColor(Color.BLACK); for (int i=0; i<(n-1); i++){ int index=(oldest+i)%(n-1); g.drawLine(2*i+x, values[index]+y, 2*i+x+2 , values[index+1]+y); } } public void drawBars(Graphics g){ g.setColor(Color.BLACK); for (int i=0; i<n; i++){ int index=(oldest+i)%n; g.drawLine(2*i+x, values[index]+y, 2*i+x , y+height); } } }
PianoKey.java
import java.awt.Color; import java.awt.Graphics; import java.awt.Rectangle; public class PianoKey { public static final int WIDTH = 25; public static final int BLACK_WIDTH = 16; public static final int LENGTH = 150; private String note; private char key; private boolean pressed; private Color color; private Rectangle rect; private SoundWave sound; private double freq; public PianoKey(String note, char key, int left, int top, double freq){ pressed=false; this.note=note; this.key=key; this.freq=freq; setWave(0); color=Color.WHITE; rect = new Rectangle(left, top, WIDTH, LENGTH); if(note.substring(1).equals("b")|| note.substring(1).equals("#")){ color = Color.BLACK; rect.setSize(BLACK_WIDTH, 3*LENGTH/5); } } /** * You can add other types here as you define your own SoundWaves * @param type */ public void setWave(int type){ if (type==0) sound = new SoundWave(freq); //else if (type==1) //sound = new KotoWave(freq); //else //sound = new NewWave(freq); } public void setPressed(boolean p){ if (p && !pressed){ sound.strike(); } pressed = p; } public void draw(Graphics g){ g.setColor(color); if(pressed) g.setColor(Color.RED); g.fillRect(rect.x, rect.y, rect.width, rect.height); g.setColor(Color.BLACK); g.drawRect(rect.x, rect.y, rect.width, rect.height); if (isBlack()) g.drawString(" "+key, rect.x, rect.y-18); else g.drawString(" "+key, rect.x, rect.y+LENGTH+18); } public boolean isBlack(){ return color.equals(Color.BLACK); } public void shiftLocation(){ rect.translate(BLACK_WIDTH/-2, 0); } public void strike(){ sound.strike(); } public void tic(){ sound.tic(); } public double sample(){ return sound.sample(); } }
SoundWave.java
public class SoundWave { protected RingBuffer rb; protected int n; protected double frequency; /** * The first constructor creates a RingBuffer of the desired capacity N * (sampling rate 44,100 divided by frequency, rounded up to the nearest integer), * and initializes it to represent a guitar string at rest by enqueueing N zeros. * @param frequency, eg. 440.0 is concert A */ public SoundWave(double frequency){ this.frequency=frequency; n = (int) (Math.floor(44100.0/frequency)+1); rb = new RingBuffer(n); for(int i=0; i <n ; i++) rb.enqueue(0.0); } /** * Replace the N items in the ring buffer with N values between -0.5 and +0.5. */ public void strike(){ for(int i=0; i< n; i++){ double value=0.5* Math.sin(i*2.0*Math.PI*frequency/44100.0); rb.dequeue(); rb.enqueue(value); } } /** * Apply the Karplus-Strong update: delete the sample at the front of the ring buffer * and add to the end of the ring buffer the average of the first two samples, * multiplied by the energy decay factor. * * From a mathematical physics viewpoint, the Karplus-Strong algorithm approximately * solves the 1D wave equation, which describes the transverse motion of the string * as a function of time. */ public void tic(){ double a = rb.dequeue(); double b = rb.peek(); double value=0.994 * 0.5*(a+b); rb.enqueue(value); } public double sample(){ return rb.peek(); } }
KotoWave.java
Here is one of many possible subclasses of SoundWave
. Simply write a different strike
method. Rather than a sine wave, fill the ring buffer with random values from -1/2 to +1/2
public class KotoWave extends SoundWave { public KotoWave(double frequency) { super(frequency); } /** * Replace the N items in the ring buffer with N random values between -0.5 and +0.5. */ public void strike(){ //your code here } }
NewWave.java
Here is a template for another SoundWave
. Rather than a simple sine wave, try {⚠ $ \frac{1}{5}\sin(2\theta)+\frac{1}{5}\cos(3\theta) $
} where {⚠ $\theta=i*2\pi*\frac{frequency}{44100.0}$
}. You will get different sounds if you use values other than 2 or 3. For further reading, check out http://www.soundonsound.com/sos/feb00/articles/synthsecrets.htm and http://en.wikipedia.org/wiki/Synthesizer
public class NewWave extends SoundWave { public NewWave(double frequency) { super(frequency); } /** * Replace the N items in the ring buffer with N random values between -0.5 and +0.5. */ public void strike(){ // your code here } }
Synth.java
import java.awt.Color; import java.awt.Frame; import java.awt.Graphics; import java.awt.Image; import java.awt.event.KeyEvent; import java.awt.event.KeyListener; import java.awt.event.WindowEvent; import java.awt.event.WindowListener; public class Synth extends Frame implements KeyListener { public final int WIDTH=625; public final int HEIGHT=400; private boolean[] key=new boolean[68836]; private Image img; private Graphics g0; private int type=0; private Visualizer scope; private PianoKey[] synthKeys; private String keyboardMap = "q2we4r5ty7u8i9op-[=zxdcfvgbnjmk,.;/' "; private String notes = "A BbB C C#D EbE F F#G AbA BbB C C#D EbE F F#G AbA BbB C C#D EbE F F#G AbA "; public Synth(){ this.addKeyListener(this); this.requestFocusInWindow(); this.setSize(WIDTH, HEIGHT); this.addWindowListener( new WindowListener() { public void windowClosing(WindowEvent e) {System.exit(0);} public void windowClosed(WindowEvent e) {} public void windowOpened(WindowEvent e) {} public void windowIconified(WindowEvent e) {} public void windowDeiconified(WindowEvent e) {} public void windowActivated(WindowEvent e) {} public void windowDeactivated(WindowEvent e) {} }); synthKeys = new PianoKey[37]; scope = new Visualizer(270, 30, 200, 500, 150); int next = 25; for (int i=0; i<37; i++){ char k=keyboardMap.charAt(i); String n = notes.substring(2*i, 2*i+2); double freq = 440.0*Math.pow(1.05956, i-24); synthKeys[i]= new PianoKey(n, k, next, 100, freq); if (synthKeys[i].isBlack()){ synthKeys[i].shiftLocation(); next-=PianoKey.WIDTH; } next+=PianoKey.WIDTH; } } public void paint(Graphics g){ // make a place to draw next image off screen img = createImage(this.WIDTH, this.HEIGHT); g0 = img.getGraphics(); // set a background g0.setColor(Color.LIGHT_GRAY); g0.fillRect( 0, 0, WIDTH,HEIGHT); g0.setColor(Color.BLACK); g0.drawString("Use keys to play, number pad to select instrument", 30, 40); g0.drawString("sound wave type="+type, 30, 60); //draw the white piano keys below the black keys for(PianoKey p:synthKeys) if (!p.isBlack()) p.draw(g0); for(PianoKey p:synthKeys) if (p.isBlack()) p.draw(g0); // draw the sound wave g0.setColor(Color.BLUE); scope.drawBars(g0); // or else use scope.drawLine(g0); g.drawImage(img, 0, 0, this); } // needed to avoid screen flicker on Windows machines public void update(Graphics g){ paint(g); requestFocusInWindow(); //make sure we get the key presses! } public void process(){ // check if the user has typed a key, and, if so, process it for(int i=0; i<37; i++) synthKeys[i].setPressed(key[Character.toUpperCase(keyboardMap.charAt(i))]); if (key[KeyEvent.VK_NUMPAD0]){ //change sound wave type=0; for(int i=0; i<37; i++) synthKeys[i].setWave(type); } if (key[KeyEvent.VK_NUMPAD1]){ //change sound wave type=1; for(int i=0; i<37; i++) synthKeys[i].setWave(type); } if (key[KeyEvent.VK_NUMPAD2]){ //change sound wave type=2; for(int i=0; i<37; i++) synthKeys[i].setWave(type); } // compute the superposition of the samples double sample=0; for(int i=0; i<37;i++){ // combine the samples sample+=synthKeys[i].sample(); // advance the simulation of each guitar string by one step synthKeys[i].tic(); } // send the result to simple audio SimpleAudio.play(sample); // update visual data scope.add(sample); repaint(); } @Override public void keyPressed(KeyEvent e) { int keyCode=e.getKeyCode(); if(keyCode>0 && keyCode<key.length){ key[keyCode]=true; } } @Override public void keyReleased(KeyEvent e) { int keyCode=e.getKeyCode(); if(keyCode>0 && keyCode<key.length){ key[keyCode]=false; } } @Override public void keyTyped(KeyEvent e) {} public static void main(String[] args) { Synth app= new Synth(); app.setVisible(true); while(true){ app.process(); } } }
SimpleAudio.java
import javax.sound.sampled.*; /** * A simplified version of Kevin Wayne's StdAudio * http://introcs.cs.princeton.edu/java/stdlib/ * Copyright (c) 2000-2011, Robert Sedgewick and Kevin Wayne, * and are released under the GNU General Public License, version 3 (GPLv3). * (see http://www.gnu.org/copyleft/gpl.html) * * The audio format uses a sampling rate of 44,100 (CD quality audio), 16-bit, monaural. * * For additional documentation, see <a href="http://introcs.cs.princeton.edu/15inout">Section 1.5</a> of * <i>Introduction to Programming in Java: An Interdisciplinary Approach</i> by Robert Sedgewick and Kevin Wayne. * * and http://docs.oracle.com/javase/tutorial/sound/playing.html the Java tutorial from oracle. */ public final class SimpleAudio { /** * The sample rate - 44,100 Hz for CD quality audio. */ public static final int SAMPLE_RATE = 44100; private static final int BYTES_PER_SAMPLE = 2; // 16-bit audio private static final int BITS_PER_SAMPLE = 16; // 16-bit audio private static final double MAX_16_BIT = Short.MAX_VALUE; // 32,767 private static final int SAMPLE_BUFFER_SIZE = 4096; private static SourceDataLine line; // to play the sound private static byte[] buffer; // our internal buffer private static int bufferSize = 0; // number of samples currently in internal buffer // do not instantiate private SimpleAudio() { } // static initializer static { init(); } // open up an audio stream private static void init() { try { // 44,100 samples per second, 16-bit audio, mono, signed PCM, little Endian AudioFormat format = new AudioFormat((float) SAMPLE_RATE, BITS_PER_SAMPLE, 1, true, false); DataLine.Info info = new DataLine.Info(SourceDataLine.class, format); line = (SourceDataLine) AudioSystem.getLine(info); line.open(format, SAMPLE_BUFFER_SIZE * BYTES_PER_SAMPLE); // the internal buffer is a fraction of the actual buffer size, this choice is arbitrary // it gets divided because we can't expect the buffered data to line up exactly with when // the sound card decides to push out its samples. buffer = new byte[SAMPLE_BUFFER_SIZE * BYTES_PER_SAMPLE/3 ]; } catch (Exception e) { System.out.println(e.getMessage()); System.exit(1); } // no sound gets made before this call line.start(); } /** * Close standard audio. */ public static void close() { line.drain(); line.stop(); } /** * Write one sample (between -1.0 and +1.0) to standard audio. If the sample * is outside the range, it will be clipped. */ public static void play(double in) { // clip if outside [-1, +1] if (in < -1.0) in = -1.0; if (in > +1.0) in = +1.0; // convert to bytes short s = (short) (MAX_16_BIT * in); buffer[bufferSize++] = (byte) s; buffer[bufferSize++] = (byte) (s >> 8); // little Endian // send to sound card if buffer is full if (bufferSize >= buffer.length) { line.write(buffer, 0, buffer.length); bufferSize = 0; } } }