JavaSoundMicrophoneArraySource.java
package org.hammer.audio;
import java.io.IOException;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.TargetDataLine;
import org.hammer.audio.acquisition.CaptureDeviceConfiguration;
import org.hammer.audio.acquisition.LocalizationInputMode;
import org.hammer.audio.acquisition.MicrophoneArray;
import org.hammer.audio.acquisition.MicrophoneArrayProfile;
import org.hammer.audio.acquisition.MicrophoneArrayReadiness;
import org.hammer.audio.acquisition.MultiChannelAudioSource;
import org.hammer.audio.capture.SampleDecoder;
import org.hammer.audio.core.AudioBlock;
import org.hammer.audio.core.AudioFormatDescriptor;
/** Live JavaSound adapter for the common synchronized multichannel source contract. */
public final class JavaSoundMicrophoneArraySource implements MultiChannelAudioSource {
private final MicrophoneArrayProfile profile;
private final CaptureDeviceConfiguration configuration;
private final SampleDecoder decoder;
private final AudioLineProvider lineProvider;
private TargetDataLine line;
private long nextFrameIndex;
private boolean closed;
/**
* Open a live source for the device selected in the profile.
*
* @param profile reusable live array profile
* @param maximumTimingErrorSamples accepted one-sigma timing error budget
*/
public JavaSoundMicrophoneArraySource(
MicrophoneArrayProfile profile, double maximumTimingErrorSamples) {
this(profile, provider(profile), maximumTimingErrorSamples, Instant.now());
}
JavaSoundMicrophoneArraySource(
MicrophoneArrayProfile profile,
AudioLineProvider lineProvider,
double maximumTimingErrorSamples,
Instant observationTime) {
this.profile = Objects.requireNonNull(profile, "profile");
this.lineProvider = Objects.requireNonNull(lineProvider, "lineProvider");
MicrophoneArrayReadiness readiness =
MicrophoneArrayReadiness.assess(
profile,
LocalizationInputMode.LIVE,
Objects.requireNonNull(observationTime, "observationTime"),
maximumTimingErrorSamples);
if (!readiness.ready()) {
throw new IllegalArgumentException(
"microphone-array profile is not ready for live localization: "
+ String.join(" ", readiness.diagnostics()));
}
this.configuration = profile.liveCaptureConfiguration().orElseThrow();
this.decoder =
new SampleDecoder(
configuration.format(), configuration.signed(), configuration.bigEndian());
}
@Override
public AudioFormatDescriptor format() {
return configuration.format();
}
@Override
public MicrophoneArray microphoneArray() {
return profile.array();
}
@Override
public Optional<AudioBlock> readBlock(int frames) throws IOException {
if (frames <= 0) {
throw new IllegalArgumentException("frames must be > 0");
}
if (closed) {
return Optional.empty();
}
ensureOpen();
byte[] data = new byte[Math.multiplyExact(frames, decoder.frameSize())];
int byteCount = readUpTo(data);
int decodedFrames = decoder.framesIn(byteCount);
if (decodedFrames == 0) {
return Optional.empty();
}
float[][] samples = new float[format().channels()][decodedFrames];
decoder.decode(data, decodedFrames * decoder.frameSize(), samples);
AudioBlock block = AudioBlock.wrap(format(), samples, nextFrameIndex, System.nanoTime());
nextFrameIndex += decodedFrames;
return Optional.of(block);
}
@Override
public void close() {
closed = true;
if (line == null) {
return;
}
line.stop();
line.flush();
line.close();
line = null;
}
private void ensureOpen() {
if (line != null) {
return;
}
AudioFormatDescriptor format = configuration.format();
AudioFormat javaSoundFormat =
new AudioFormat(
format.sampleRate(),
format.sourceSampleSizeInBits(),
format.channels(),
configuration.signed(),
configuration.bigEndian());
line = lineProvider.acquireLine(javaSoundFormat);
line.start();
}
private int readUpTo(byte[] data) throws IOException {
int total = 0;
while (total < data.length && !closed) {
int count = line.read(data, total, data.length - total);
if (count < 0) {
break;
}
if (count == 0) {
return total;
}
total += count;
}
return total;
}
private static AudioLineProvider provider(MicrophoneArrayProfile profile) {
Objects.requireNonNull(profile, "profile");
String deviceId = profile.liveCaptureConfiguration().orElseThrow().device().deviceId();
JavaSoundCaptureDeviceDiscovery discovery = new JavaSoundCaptureDeviceDiscovery();
return new DefaultAudioLineProvider(
discovery
.findMixerInfo(deviceId)
.orElseThrow(
() ->
new IllegalArgumentException("capture device is not available: " + deviceId)));
}
}