Blog-Archiv

Samstag, 7. Dezember 2024

My Java Sine Wave Generator

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:

  1. Create a tone with a defined duration, like when read from some music score
  2. 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: