The Java sound AudioSystem API is a quite complicated and badly documented API, full of mathematics and constants that are related in obscure ways. Thus stackoverflow has questions about it, but the answers mostly present hardly understandable solutions. Generating sine waves seems to be a quite popular thing in the developer world, but not easily achievable. In this Blog I want to present my own solution of it.
There are two ways to generate a tone:
- Create a tone with a defined duration, like when read from some music score
- Start a tone and stop it after a time that is not known before, like when the key of an organ gets pressed
Number one is easier to program in case the sound API provides the capability to play a tone with a defined duration. For number two you need a background thread and synchronization without deadlocks, this is more difficult. In this case the frequency and loudness of the tone should be changeable while it plays, which is not necessary in case one.
I will now simply paste my source-code here. It has no external dependencies except the JDK's sound API and should be compilable easily with javac. Everybody can use this code. From all the problems I have encountered with the Java sound API, I take no responsibility that it will work on everybody's computer's soundcard. In my next Blog I will present a screen piano that makes use of this sine wave generator.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | public class SineWaveGenerator extends WaveGenerator { private static final double TWO_PI = 2.0 * Math.PI; /** See super-class. */ public SineWaveGenerator() { super(); } /** See super-class. */ public SineWaveGenerator( AudioFormat audioFormat, int lineBufferSize, int minimalDurationMilliseconds, int maximumSamplesToFade) { super(audioFormat, lineBufferSize, minimalDurationMilliseconds, maximumSamplesToFade); } @Override protected double createWaveValue(double samplesPerWave, int sampleIndex) { final double angle = TWO_PI * (double) sampleIndex / samplesPerWave; return Math.sin(angle); } } |
To be able to play different kinds of sound waves with this generator (sine, sawtooth, square ...), I implemented an abstract super-class where most of the work is done, once for all:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 | import javax.sound.sampled.AudioFormat; import javax.sound.sampled.AudioSystem; import javax.sound.sampled.LineUnavailableException; import javax.sound.sampled.SourceDataLine; /** * Can play exactly one tone of a given frequency. * Use multiple instances of SineWaveGenerator to play several tones simultaneously. * Durations from 20 milliseconds upwards will be audible. * A frequency of zero will result in a pause (sleep) of given duration. * <p/> * The <code>play()</code> method can not be stopped and returns when the tone was played. * The <code>start()</code> method returns immediately and plays eternally * by starting a background-thread. That thread must be stopped by calling <code>stop()</code>. * When having called <code>start()</code> without <code>stop()</code>, calls to * <code>play()</code> and <code>start()</code> will throw an <code>IllegalStateException</code>. * * @author Fritz Ritzberger, Dec 2024 */ public abstract class WaveGenerator { /** The default AudioFormat sample-rate. */ public static final float SAMPLE_RATE = 44100f; /** The default minimal duration of an audible tone, in milliseconds. */ public static final int MINIMAL_DURATION = 20; /** The default maximum number of sample bytes to fade-in and -out. */ public static final int MAXIMUM_SAMPLES_TO_FADE = 1024; /** The maximum volume (amplitude, loudness). Horrible sounds occur above 127. */ public static final int MAXIMUM_VOLUME = 127; /** State is used to control fade-in and -out. */ private enum State { NONE, START, PLAY, END, START_END; State next(double frequency) { final boolean soundOff = (frequency <= 0.0); if (this == NONE) return soundOff ? NONE : START; if (this == START) return soundOff ? END : PLAY; if (this == PLAY) return soundOff ? END : PLAY; if (this == END) return soundOff ? NONE : START; // START_END has no next return NONE; } } /** Fader is used to avoid start/stop noise. */ private static class Fader { private final State state; private final int headLimit; private final int tailLimit; private final double fadeStep; private double factor; Fader(State state, int samples, int maxSamplesToFade) { this.state = state; final int samplesToFade = Math.min( (state == State.START_END) ? (samples / 3) : samples, maxSamplesToFade); this.fadeStep = 1.0 / samplesToFade; this.headLimit = samplesToFade; this.tailLimit = samples - samplesToFade; this.factor = (state == State.START || state == State.START_END) ? 0.0 : 1.0; } /** To be multiplied with volume. */ double nextValue(int sampleIndex) { if ((state == State.START || state == State.START_END) && sampleIndex < headLimit) { factor = Math.min(factor + fadeStep, 1.0); } else if ((state == State.END || state == State.START_END) && sampleIndex >= tailLimit) { factor = Math.max(factor - fadeStep, 0.0); } return factor; } } private final SourceDataLine audioLine; private final float sampleRate; // "samples" (bytes) per second private final int minDuration; // milliseconds private final int maxSamplesToFade; // fade-in/out control private boolean playing; private Thread thread; private double frequency; private int volume; /** Opens and starts an AudioSystem SourceDataLine. */ protected WaveGenerator() { this( new AudioFormat(SAMPLE_RATE, 8, 1, true, true), 1024, // small buffer size to make audioLine react fast on frequency changes when playing continuously MINIMAL_DURATION, // milliseconds MAXIMUM_SAMPLES_TO_FADE ); } /** * Opens and starts a SourceDataLine from given AudioSystem and configurations. * @param audioFormat the AudioFormat object to use. * @param lineBufferSize the byte-size for the line.open() call, normally the same as sampleRate, * but making it smaller (e.g. 1024) reduces the delays a frequency slider will have. * @param minimalDurationMilliseconds the minimal length of a tone, this will be the duration * of the continuously playing repeated tone. * @param maximumSamplesToFade controls volume fade-in and fade-out (avoids click noise). */ protected WaveGenerator( AudioFormat audioFormat, int lineBufferSize, int minimalDurationMilliseconds, int maximumSamplesToFade) { this.sampleRate = audioFormat.getSampleRate(); this.minDuration = minimalDurationMilliseconds; this.maxSamplesToFade = maximumSamplesToFade; try { this.audioLine = AudioSystem.getSourceDataLine(audioFormat); audioLine.open(audioFormat, lineBufferSize); audioLine.start(); } catch (LineUnavailableException e) { throw new RuntimeException(e); } } /** * Plays given frequency at given volume for given duration, * but returns before the tone has been played completely. * @param frequency the Hertz value for the tone to play. * @param durationMillis the duration of the tone, MINIMAL_DURATION - n. * @param volume the amplitude (loudness) of the tone, 0 - MAX_VOLUME. * @throws IllegalStateException when start() was called before, without stop(). */ public synchronized void play(double frequency, int durationMillis, int volume) { setPlayingAndEnsureJustOne(frequency, durationMillis, volume); try { durationMillis = Math.max(durationMillis, minDuration); writeToAudioLine(frequency, durationMillis, volume, State.START_END, false); } finally { this.playing = false; } } /** * Plays given frequency at given loudness forever. Returns immediately, * you must call stop() to finish the running tone. * @param frequency the Hertz value for the tone to play. * @param volume the amplitude (loudness) of the tone, 0 - MAX_VOLUME. * @throws IllegalStateException when start() was called before, without stop(). */ public synchronized void start(double frequency, int volume) { setPlayingAndEnsureJustOne(frequency, minDuration, volume); thread = new Thread(new Runnable() { @Override public void run() { int volume = getVolume(); double frequency = getFrequency(); State state = State.NONE.next(frequency); while (isPlaying()) { writeToAudioLine(frequency, minDuration, volume, state, true); volume = getVolume(); frequency = getFrequency(); // was possible changed meanwhile state = state.next(frequency); } writeToAudioLine(frequency, minDuration, volume, State.END, true); // fade-out } }); thread.start(); } /** @return true when playing continuously, else false. */ public synchronized boolean isPlaying() { return playing; } /** Call this to stop continuous playing. */ public void stop() { synchronized(this) { playing = false; } if (thread != null) { try { thread.join(); thread = null; } catch (InterruptedException e) { throw new RuntimeException(e); } } } /** @return the most recently set frequency. */ public synchronized double getFrequency() { return frequency; } /** @param frequency sets a new frequency in case a tone is currently playing in background. */ public synchronized void setFrequency(double frequency) { this.frequency = frequency; } /** @return the most recently set volume. */ public synchronized int getVolume() { return volume; } /** @param volume sets a new volume in case a tone is currently playing in background. */ public synchronized void setVolume(int volume) { this.volume = volume; } /** Releases all resources. This generator can not be used any more afterwards. */ public void close() { stop(); //audioLine.drain(); // very slow and unnecessary, could also block audioLine.stop(); audioLine.close(); } private void assertionsOnPlayStart(double frequency, int durationMillis, int volume) { if (durationMillis < minDuration) throw new IllegalArgumentException("Duration is too small, would cause crackling: "+durationMillis); if (volume < 0 || volume > MAXIMUM_VOLUME) // horrible sounds occur above 127 throw new IllegalArgumentException("Volume must be 0-"+MAXIMUM_VOLUME+": "+volume); if (frequency < 0.0 || frequency > 20000.0) // humans can not hear this any more throw new IllegalArgumentException("Frequency must be 0-20000: "+frequency); if (isPlaying()) throw new IllegalStateException(getClass().getSimpleName()+" is playing continuously, call stop()!"); } private void setPlayingAndEnsureJustOne(double frequency, int durationInMilliseconds, int volume) { assertionsOnPlayStart(frequency, durationInMilliseconds, volume); this.playing = true; setFrequency(frequency); setVolume(volume); } private void writeToAudioLine(double frequency, int durationMillis, int volume, State state, boolean continuous) { if (frequency <= 0.0) { // inaudible tone: rest try { Thread.sleep(durationMillis); } catch (InterruptedException e) {} } else { final byte[] toneBuffer = createWaveBuffer(frequency, durationMillis, volume, state, continuous); audioLine.write(toneBuffer, 0, toneBuffer.length); } } private byte[] createWaveBuffer(double frequency, int durationMillis, int volume, State state, boolean continuous) { final double waveDuration = 1.0 / frequency; // seconds per wave final double samplesPerWave = sampleRate * waveDuration; // samples per second * seconds per wave final int samples; if (continuous) // must be complete waves but not duration-accurate samples = (int) Math.round(samplesPerWave * (double) durationMillis) / 2; // 2: empirical time correction else // must be a duration-accurate samples = (int) Math.round(sampleRate * (double) durationMillis / 1000.0); final Fader fader = new Fader(state, samples, maxSamplesToFade); final byte[] buffer = new byte[samples]; for (int i = 0; i < samples; i++) { final double waveValue = createWaveValue(samplesPerWave, i); buffer[i] = (byte) (waveValue * (double) volume * fader.nextValue(i)); } return buffer; } /** * Generates on sample for a specific wave form. * @param samplesPerWave the (sampleRate / frequency) value. * @param sampleIndex the index of the sample to generate. * @return */ protected abstract double createWaveValue(double samplesPerWave, int sampleIndex); } |
Here is a test class to try out:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 | import java.awt.BorderLayout; import java.awt.Color; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import javax.swing.BorderFactory; import javax.swing.JButton; import javax.swing.JComponent; import javax.swing.JFrame; import javax.swing.JPanel; import javax.swing.JSlider; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; public class FrequencySliderDemo { public static void main(String[] args) { final JFrame frame = new JFrame(); frame.setTitle("SineWaveGenerator Demo"); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); final FrequencySliderDemo frequencySliderDemo = new FrequencySliderDemo(); frame.addWindowListener(new WindowAdapter() { @Override public void windowClosing(WindowEvent e) { if (frequencySliderDemo.getSineWaveGenerator() != null) { frequencySliderDemo.getSineWaveGenerator().close(); } } }); frame.getContentPane().add(frequencySliderDemo.panel); frame.setSize(1000, 210); frame.setLocationRelativeTo(null); frame.setVisible(true); } public final JComponent panel; private SineWaveGenerator sineWaveGenerator; public FrequencySliderDemo() { panel = new JPanel(new BorderLayout()); final JSlider frequencySlider = new JSlider(); frequencySlider.setPaintLabels(true); frequencySlider.setPaintTicks(true); frequencySlider.setMajorTickSpacing(100); frequencySlider.setMinimum(0); frequencySlider.setMaximum(3000); frequencySlider.setValue(440); frequencySlider.setBorder(BorderFactory.createTitledBorder("Frequency (Hertz)")); panel.add(frequencySlider, BorderLayout.NORTH); frequencySlider.addChangeListener(new ChangeListener() { @Override public void stateChanged(ChangeEvent e) { double newValue = ((JSlider) e.getSource()).getValue(); if (sineWaveGenerator != null) sineWaveGenerator.setFrequency(newValue); frequencySlider.setToolTipText(""+newValue); } }); final JSlider volumeSlider = new JSlider(); volumeSlider.setPaintLabels(true); volumeSlider.setPaintTicks(true); volumeSlider.setMajorTickSpacing(10); volumeSlider.setMinimum(0); volumeSlider.setMaximum(127); volumeSlider.setValue(20); volumeSlider.setBorder(BorderFactory.createTitledBorder("Volume (Amplitude)")); panel.add(volumeSlider, BorderLayout.SOUTH); volumeSlider.addChangeListener(new ChangeListener() { @Override public void stateChanged(ChangeEvent e) { int newValue = ((JSlider) e.getSource()).getValue(); if (sineWaveGenerator != null) sineWaveGenerator.setVolume(newValue); volumeSlider.setToolTipText(""+newValue); } }); final JButton start = new JButton("Start "+frequencySlider.getValue()+" Hertz"); start.setForeground(Color.BLUE); panel.add(start, BorderLayout.CENTER); start.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent event) { if (sineWaveGenerator == null) { double frequency = frequencySlider.getValue(); int volume = volumeSlider.getValue(); sineWaveGenerator = new SineWaveGenerator(); sineWaveGenerator.start(frequency, volume); start.setText("Stop "+frequency+" Hertz"); start.setForeground(Color.RED); } else { sineWaveGenerator.close(); sineWaveGenerator = null; start.setText("Start "+frequencySlider.getValue()+" Hertz"); start.setForeground(Color.BLUE); } } }); frequencySlider.addChangeListener(new ChangeListener() { @Override public void stateChanged(ChangeEvent e) { double newValue = ((JSlider) e.getSource()).getValue(); if (sineWaveGenerator != null) start.setText("Stop "+newValue+" Hertz"); else start.setText("Start "+newValue+" Hertz"); } }); } public SineWaveGenerator getSineWaveGenerator() { return sineWaveGenerator; } } |
Keine Kommentare:
Kommentar veröffentlichen