diff options
author | Sven Gothel <[email protected]> | 2023-05-17 08:10:33 +0200 |
---|---|---|
committer | Sven Gothel <[email protected]> | 2023-05-17 08:10:33 +0200 |
commit | 1e9e8d108467902b7753c6910f5d1390dbf32edb (patch) | |
tree | 03069269854cf85eebba286efee3539759c9c18d /src/test/com/jogamp/openal | |
parent | 2edee76a2c175719e37548d8627dd4b141c39919 (diff) |
Manual Demos: Add two simple sine wave synthesizer, Synth02AL may be enhanced to a general synth solution
Diffstat (limited to 'src/test/com/jogamp/openal')
-rw-r--r-- | src/test/com/jogamp/openal/test/manual/Synth01AL.java | 234 | ||||
-rw-r--r-- | src/test/com/jogamp/openal/test/manual/Synth02AL.java | 449 |
2 files changed, 683 insertions, 0 deletions
diff --git a/src/test/com/jogamp/openal/test/manual/Synth01AL.java b/src/test/com/jogamp/openal/test/manual/Synth01AL.java new file mode 100644 index 0000000..64da9fa --- /dev/null +++ b/src/test/com/jogamp/openal/test/manual/Synth01AL.java @@ -0,0 +1,234 @@ +/** + * Copyright 2023 JogAmp Community. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are + * permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of + * conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list + * of conditions and the following disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY JogAmp Community ``AS IS'' AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JogAmp Community OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * The views and conclusions contained in the software and documentation are those of the + * authors and should not be interpreted as representing official policies, either expressed + * or implied, of JogAmp Community. + */ +package com.jogamp.openal.test.manual; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.ShortBuffer; + +import com.jogamp.openal.AL; +import com.jogamp.openal.ALC; +import com.jogamp.openal.ALCcontext; +import com.jogamp.openal.ALCdevice; +import com.jogamp.openal.ALConstants; +import com.jogamp.openal.ALFactory; +import com.jogamp.openal.ALVersion; + +/** + * A continuous simple on-thread immutable sine wave synthesizer. + * <p> + * Implementation simply finds the best loop'able sample-count for a fixed frequency + * and plays it indefinitely. + * </p> + */ +public class Synth01AL { + /** The value PI, i.e. 180 degrees in radians. */ + public static final float PI = 3.14159265358979323846f; + + /** The value 2PI, i.e. 360 degrees in radians. */ + public static final float TWO_PI = 2f * PI; + + private static final float EPSILON = 1.1920929E-7f; // Float.MIN_VALUE == 1.4e-45f ; double EPSILON 2.220446049250313E-16d + + private static final float SHORT_MAX = 32767.0f; // == Short.MAX_VALUE + + public static void waitForKey(final String message) { + final BufferedReader stdin = new BufferedReader(new InputStreamReader(System.in)); + System.err.println("> Press enter to "+message); + try { + System.err.println(stdin.readLine()); + } catch (final IOException e) { e.printStackTrace(); } + } + + private ALC alc = null; + private ALCdevice device = null; + private ALCcontext context = null; + private AL al = null; + + private final int[] buffers = { 0 }; + private final int[] sources = { 0 }; + + private void alCheckError(final String given_label, final boolean throwEx) { + final int error = al.alGetError(); + if(ALConstants.AL_NO_ERROR != error) { + final String msg = String.format("ERROR - 0x%X %s (%s)", error, al.alGetString(error), given_label); + System.err.println(msg); + if( throwEx ) { + throw new RuntimeException(msg); + } + } + } + + public void init() { + alc = ALFactory.getALC(); + device = alc.alcOpenDevice(null); + context = alc.alcCreateContext(device, null); + alc.alcMakeContextCurrent(context); + al = ALFactory.getAL(); // valid after makeContextCurrent(..) + System.out.println("ALVersion: "+new ALVersion(al).toString()); + System.out.println("Output devices:"); + { + final String[] outDevices = alc.alcGetDeviceSpecifiers(); + if( null != outDevices ) { + for (final String name : outDevices) { + System.out.println(" "+name); + } + } + } + alCheckError("setup", true); + + al.alGenBuffers(1, buffers, 0); + alCheckError("alGenBuffers", true); + + // Set-up sound source + al.alGenSources(1, sources, 0); + alCheckError("alGenSources", true); + } + + public void exit() { + // Stop the sources + al.alSourceStopv(1, sources, 0); + for (int ii = 0; ii < 1; ++ii) { + al.alSourcei(sources[ii], ALConstants.AL_BUFFER, 0); + } + alCheckError("sources: stop and disconnected", true); + + // Clean-up + al.alDeleteSources(1, sources, 0); + al.alDeleteBuffers(1, buffers, 0); + alCheckError("sources/buffers: deleted", true); + + if( null != context ) { + alc.alcMakeContextCurrent(null); + alc.alcDestroyContext(context); + context = null; + } + if( null != device ) { + alc.alcCloseDevice(device); + device = null; + } + } + + public static int findBestWaveCount(final float freq, final int sampleRate, final int minWaves, final int maxWaves) { + final float period = 1.0f / freq; // [s] + final float sample_step = ( TWO_PI * freq ) / sampleRate; + int wave_count; + float s_diff = Float.MAX_VALUE; + float sc_diff = Float.MAX_VALUE; + int wc_best = -1; + for(wave_count = minWaves; wave_count < maxWaves && s_diff >= EPSILON; ++wave_count) { + final float d = wave_count * period; // duration [s] for 'i' full waves + final float sc_f = d * sampleRate; // sample count for 'i' full waves [n] + final int sc_i = (int)sc_f; + final float s1 = (float) Math.abs( Math.sin( sample_step * sc_i ) ); // last_step + 1 = next wave start == error to zero + if( s1 < s_diff ) { + s_diff = s1; + sc_diff = sc_f - sc_i; // sample_count delta float - int + wc_best = wave_count; + } + } + System.err.printf("%nBest: %d/[%d..%d], waves %d, sample_count diff %.12f, sample diff %.12f%n", wave_count, minWaves, maxWaves, wc_best, sc_diff, s_diff); + return wc_best; + } + + private static final int SAMPLE_RATE = 44100; // [Hz] + + public void loop(final float freq /* [Hz] */) { + final float period = 1.0f / freq; // [s] + final float sample_step = ( TWO_PI * freq ) / SAMPLE_RATE; + + final int wave_count = findBestWaveCount(freq, SAMPLE_RATE, 10, 1000); + final float duration = wave_count * period; // [s], full waves + final int sample_count = (int)( duration * SAMPLE_RATE ); // [n] + + System.err.printf("%nFreq %f Hz, period %f [ms], waves %d, duration %f [ms], sample[rate %d, step %f]%n", freq, 1000.0*period, wave_count, 1000.0*duration, SAMPLE_RATE, sample_step); + + // allocate PCM audio buffer + final ShortBuffer samples = ShortBuffer.allocate(sample_count); + + for(int i=0; i<sample_count; ++i) { + final float s = (float) Math.sin( sample_step * i ); + samples.put( (short)( SHORT_MAX * s ) ); + } + samples.rewind(); + alCheckError("populating samples", true); + + // upload buffer to OpenAL + al.alBufferData(buffers[0], ALConstants.AL_FORMAT_MONO16, samples, sample_count*2, SAMPLE_RATE); + alCheckError("alBufferData samples", true); + + samples.clear(); + + // Play source / buffer + al.alSourcei(sources[0], ALConstants.AL_BUFFER, buffers[0]); + alCheckError("alSourcei source <-> buffer", true); + + al.alSourcei(sources[0], ALConstants.AL_LOOPING, 1); + final int[] loopArray = new int[1]; + al.alGetSourcei(sources[0], ALConstants.AL_LOOPING, loopArray, 0); + System.err.println("Looping 1: " + (loopArray[0] == ALConstants.AL_TRUE)); + + al.alSourceRewind(sources[0]); + al.alSourcePlay(sources[0]); + alCheckError("alSourcePlay", true); + + // --------------------- + + final int[] current_playing_state = { 0 }; + al.alGetSourcei(sources[0], ALConstants.AL_SOURCE_STATE, current_playing_state, 0); + alCheckError("alGetSourcei AL_SOURCE_STATE", true); + + if( ALConstants.AL_PLAYING == current_playing_state[0] ) { + waitForKey("Stop"); + } + } + + public static float atof(final String str, final float def) { + try { + return Float.parseFloat(str); + } catch (final Exception ex) { + ex.printStackTrace(); + } + return def; + } + + public static void main(final String[] args) { + float freq = 100.0f; + for(int i=0; i<args.length; i++) { + if(args[i].equals("-f")) { + i++; + freq = atof(args[i], freq); + } + } + final Synth01AL o = new Synth01AL(); + o.init(); + o.loop(freq); + o.exit(); + } +} diff --git a/src/test/com/jogamp/openal/test/manual/Synth02AL.java b/src/test/com/jogamp/openal/test/manual/Synth02AL.java new file mode 100644 index 0000000..068f168 --- /dev/null +++ b/src/test/com/jogamp/openal/test/manual/Synth02AL.java @@ -0,0 +1,449 @@ +/** + * Copyright 2023 JogAmp Community. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are + * permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of + * conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list + * of conditions and the following disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY JogAmp Community ``AS IS'' AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JogAmp Community OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * The views and conclusions contained in the software and documentation are those of the + * authors and should not be interpreted as representing official policies, either expressed + * or implied, of JogAmp Community. + */ +package com.jogamp.openal.test.manual; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.ByteBuffer; +import java.util.concurrent.TimeUnit; + +import com.jogamp.common.av.AudioSink; +import com.jogamp.common.av.AudioSinkFactory; +import com.jogamp.common.os.Clock; +import com.jogamp.common.util.InterruptSource; +import com.jogamp.common.util.InterruptedRuntimeException; +import com.jogamp.common.util.SourcedInterruptedException; + +/** + * A continuous simple off-thread mutable sine wave synthesizer. + * <p> + * Implementation utilizes an off-thread worker thread, + * allowing to change frequency and amplitude without disturbance. + * </p> + * <p> + * Latency is hardcoded as 1 - 3 times frameDuration, having a frameDuration of 16 ms. + * Averages around 48 ms. + * </p> + * <p> + * Latency needs improvement to have a highly responsive life-music synthesizer. + * </p> + */ +public final class Synth02AL { + private static final boolean DEBUG = false; + + /** The value PI, i.e. 180 degrees in radians. */ + private static final float PI = 3.14159265358979323846f; + + /** The value 2PI, i.e. 360 degrees in radians. */ + private static final float TWO_PI = 2f * PI; + + private static final float EPSILON = 1.1920929E-7f; // Float.MIN_VALUE == 1.4e-45f ; double EPSILON 2.220446049250313E-16d + + private static final float SHORT_MAX = 32767.0f; // == Short.MAX_VALUE + + public static final int frameDuration = 16; // AudioSink.DefaultFrameDuration; // [ms] + + public static final int audioQueueLimit = 3 * frameDuration; + + public static final float MIDDLE_C = 261.625f; + + private final Object stateLock = new Object(); + private volatile float audioAmplitude = 1.0f; + private volatile float audioFreq = MIDDLE_C; + private volatile int lastAudioPTS = 0; + private SynthWorker streamWorker; + + public Synth02AL() { + streamWorker = new SynthWorker(); + } + + public void setFreq(final float f) { + audioFreq = f; + } + public float getFreq() { return audioFreq; } + + public void setAmplitude(final float a) { + audioAmplitude = Math.min(1.0f, Math.max(0.0f, a)); // clip [0..1] + } + public float getAmplitude() { return audioAmplitude; } + + public void play() { + synchronized( stateLock ) { + if( null == streamWorker ) { + streamWorker = new SynthWorker(); + } + streamWorker.doResume(); + } + } + + public void pause(final boolean flush) { + synchronized( stateLock ) { + if( null != streamWorker ) { + streamWorker.doPause(true); + } + } + } + + public void stop() { + synchronized( stateLock ) { + if( null != streamWorker ) { + streamWorker.doStop(); + streamWorker = null; + } + } + } + + public boolean isPlaying() { + synchronized( stateLock ) { + if( null != streamWorker ) { + return streamWorker.isPlaying(); + } + } + return false; + } + + public boolean isRunning() { + synchronized( stateLock ) { + if( null != streamWorker ) { + return streamWorker.isRunning(); + } + } + return false; + } + + public int getGenPTS() { return lastAudioPTS; } + + public int getPTS() { return null != streamWorker ? streamWorker.audioSink.getPTS() : 0; } + + @Override + public final String toString() { + synchronized( stateLock ) { + final String as = null != streamWorker ? streamWorker.audioSink.toString() : "ALAudioSink[null]"; + final int pts = getPTS(); + final int lag = getGenPTS() - pts; + return getClass().getSimpleName()+"[f "+audioFreq+", a "+audioAmplitude+", state[running "+isRunning()+", playing "+isPlaying()+"], pts[gen "+getGenPTS()+", play "+pts+", lag "+lag+"], "+as+"]"; + } + } + + class SynthWorker extends InterruptSource.Thread { + private volatile boolean isRunning = false; + private volatile boolean isPlaying = false; + private volatile boolean isBlocked = false; + + private volatile boolean shallPause = true; + private volatile boolean shallStop = false; + + private final AudioSink audioSink; + private final AudioSink.AudioFormat audioFormat; + private ByteBuffer sampleBuffer = ByteBuffer.allocate(2*1000); + private float lastFreq; + private float nextSin; + private boolean upSin; + private int nextStep; + + /** + * Starts this daemon thread, + * <p> + * This thread pauses after it's started! + * </p> + **/ + SynthWorker() { + setDaemon(true); + synchronized(this) { + lastAudioPTS = 0; + audioSink = AudioSinkFactory.createDefault(Synth02AL.class.getClassLoader()); + audioFormat = new AudioSink.AudioFormat(audioSink.getPreferredSampleRate(), 16, 1, true /* signed */, + true /* fixed point */, false /* planar */, true /* littleEndian */); + audioSink.init(audioFormat, frameDuration, audioQueueLimit, 0, audioQueueLimit); + lastFreq = 0; + nextSin = 0; + upSin = true; + nextStep = 0; + start(); + try { + this.notifyAll(); // wake-up startup-block + while( !isRunning && !shallStop ) { + this.wait(); // wait until started + } + } catch (final InterruptedException e) { + throw new InterruptedRuntimeException(e); + } + } + } + + private final int findNextStep(final boolean upSin, final float nextSin, final float freq, final int sampleRate, final int sampleCount) { + final float sample_step = ( TWO_PI * freq ) / sampleRate; + + float s_diff = Float.MAX_VALUE; + float s_best = 0; + int i_best = -1; + float s0 = 0; + for(int i=0; i < sampleCount && s_diff >= EPSILON ; ++i) { + final float s1 = (float) Math.sin( sample_step * i ); + final float s_d = Math.abs(nextSin - s1); + if( s_d < s_diff && ( ( upSin && s1 >= s0 ) || ( !upSin && s1 < s0 ) ) ) { + s_best = s1; + s_diff = s_d; + i_best = i; + } + s0 = s1; + } + if( DEBUG ) { + System.err.printf("%nBest: %d/[%d..%d]: s %f / %f (up %b), s_diff %f%n", i_best, 0, sampleCount, s_best, nextSin, upSin, s_diff); + } + return i_best; + } + + private final void enqueueWave() { + // use local cache of volatiles, stable values + final float freq = audioFreq; + final float amp = audioAmplitude; + + final float period = 1.0f / freq; // [s] + final float sample_step = ( TWO_PI * freq ) / audioFormat.sampleRate; + + final float duration = frameDuration / 1000.0f; // [s] + final int sample_count = (int)( duration * audioFormat.sampleRate ); // [n] + + final boolean overflow; + final boolean changedFreq; + if( Math.abs( freq - lastFreq ) >= EPSILON ) { + changedFreq = true; + overflow = false; + lastFreq = freq; + nextStep = findNextStep(upSin, nextSin, freq, audioFormat.sampleRate, sample_count); + } else { + changedFreq = false; + if( nextStep + sample_count >= Integer.MAX_VALUE/1000 ) { + nextStep = findNextStep(upSin, nextSin, freq, audioFormat.sampleRate, sample_count); + overflow = true; + } else { + overflow = false; + } + } + + if( DEBUG ) { + if( changedFreq || overflow ) { + final float wave_count = duration / period; + System.err.printf("%nFreq %f Hz, period %f [ms], waves %.2f, duration %f [ms], sample[count %d, rate %d, step %f, next[up %b, sin %f, step %d]]%n", freq, + 1000.0*period, wave_count, 1000.0*duration, sample_count, audioFormat.sampleRate, sample_step, upSin, nextSin, nextStep); + // System.err.println(Synth02AL.this.toString()); + } + } + + if( sampleBuffer.capacity() < 2*sample_count ) { + if( DEBUG ) { + System.err.printf("SampleBuffer grow: %d -> %d%n", sampleBuffer.capacity(), 2*sample_count); + } + sampleBuffer = ByteBuffer.allocate(2*sample_count); + } + + { + final int l = nextStep; + int i; + float s = 0; + for(i=l; i<l+sample_count; ++i) { + s = (float) Math.sin( sample_step * i ); + final short s16 = (short)( SHORT_MAX * s * amp ); + sampleBuffer.put( (byte) ( s16 & 0xff ) ); + sampleBuffer.put( (byte) ( ( s16 >>> 8 ) & 0xff ) ); + } + nextStep = i; + nextSin = (float) Math.sin( sample_step * nextStep ); + upSin = nextSin >= s; + sampleBuffer.rewind(); + } + audioSink.enqueueData(lastAudioPTS, sampleBuffer, sample_count*2); + sampleBuffer.clear(); + lastAudioPTS += frameDuration; + } + + public final synchronized void doPause(final boolean waitUntilDone) { + if( isPlaying ) { + shallPause = true; + if( java.lang.Thread.currentThread() != this ) { + if( isBlocked && isPlaying ) { + this.interrupt(); + } + if( waitUntilDone ) { + try { + while( isPlaying && isRunning ) { + this.wait(); // wait until paused + } + } catch (final InterruptedException e) { + throw new InterruptedRuntimeException(e); + } + } + } + } + } + public final synchronized void doResume() { + if( isRunning && !isPlaying ) { + shallPause = false; + if( java.lang.Thread.currentThread() != this ) { + try { + this.notifyAll(); // wake-up pause-block + while( !isPlaying && !shallPause && isRunning ) { + this.wait(); // wait until resumed + } + } catch (final InterruptedException e) { + final InterruptedException e2 = SourcedInterruptedException.wrap(e); + doPause(false); + throw new InterruptedRuntimeException(e2); + } + } + } + } + public final synchronized void doStop() { + if( isRunning ) { + shallStop = true; + if( java.lang.Thread.currentThread() != this ) { + if( isBlocked && isRunning ) { + this.interrupt(); + } + try { + this.notifyAll(); // wake-up pause-block (opt) + while( isRunning ) { + this.wait(); // wait until stopped + } + } catch (final InterruptedException e) { + throw new InterruptedRuntimeException(e); + } + } + } + audioSink.destroy(); + } + public final boolean isRunning() { return isRunning; } + public final boolean isPlaying() { return isPlaying; } + + @Override + public final void run() { + setName(getName()+"-SynthWorker_"+SynthWorkerInstanceId); + SynthWorkerInstanceId++; + + synchronized ( this ) { + isRunning = true; + this.notifyAll(); // wake-up ctor() + } + + while( !shallStop ) { + if( shallPause ) { + synchronized ( this ) { + while( shallPause && !shallStop ) { + audioSink.pause(); + isPlaying = false; + this.notifyAll(); // wake-up doPause() + try { + this.wait(); // wait until resumed + } catch (final InterruptedException e) { + if( !shallPause ) { + e.printStackTrace(); + } + } + } + audioSink.play(); + isPlaying = true; + this.notifyAll(); // wake-up doResume() + } + } + if( !shallStop ) { + isBlocked = true; + enqueueWave(); + isBlocked = false; + } + } + synchronized ( this ) { + isRunning = false; + isPlaying = false; + this.notifyAll(); // wake-up doStop() + } + } + } + static int SynthWorkerInstanceId = 0; + + public static float atof(final String str, final float def) { + try { + return Float.parseFloat(str); + } catch (final Exception ex) { + ex.printStackTrace(); + } + return def; + } + + public static String enterValue(final String message) { + final BufferedReader stdin = new BufferedReader(new InputStreamReader(System.in)); + System.err.println(message); + try { + final String s = stdin.readLine(); + System.err.println(s); + return s; + } catch (final IOException e) { e.printStackTrace(); } + return ""; + } + + public static void main(final String[] args) { + float freq = MIDDLE_C; + for(int i=0; i<args.length; i++) { + if(args[i].equals("-f")) { + i++; + freq = atof(args[i], freq); + } + } + final Synth02AL o = new Synth02AL(); + o.setFreq(freq); + System.err.println("0: "+o); + o.play(); + System.err.println("1: "+o); + { + final long t0 = Clock.currentNanos(); + for(float f=100; f<10000; f+=30) { + o.setFreq(f); + try { + Thread.sleep(frameDuration); + } catch (final InterruptedException e) { } + } + final long t1 = Clock.currentNanos(); + System.err.println("Loop "+TimeUnit.NANOSECONDS.toMillis(t1-t0)+" ms"); + } + o.setFreq( MIDDLE_C ); + System.err.println("c: "+o); + boolean quit = false; + while ( !quit ){ + final String in = enterValue("Enter new frequency or just enter to exit"); + if( 0 == in.length() ) { + quit = true; + } else { + freq = atof(in, freq); + o.setFreq(freq); + System.err.println("n: "+o); + } + } + o.stop(); + } +} |