AudioCaptureServiceImpl.java
package org.hammer.audio;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.Mixer;
import javax.sound.sampled.TargetDataLine;
import org.hammer.audio.buffer.AudioRingBuffer;
import org.hammer.audio.capture.SampleDecoder;
import org.hammer.audio.core.AudioBlock;
import org.hammer.audio.core.AudioFormatDescriptor;
import org.hammer.audio.snapshot.WaveformSnapshot;
import org.hammer.audio.ui.WaveformRenderer;
/**
* Audio capture service implementation: the bridge between the JavaSound input device and the
* platform's audio-domain pipeline.
*
* <p><strong>Architecture (post-refactor)</strong>:
*
* <pre>{@code
* TargetDataLine
* -> raw bytes
* -> SampleDecoder (-> normalized float[][])
* -> AudioBlock (immutable, with frame index + timestamp)
* -> AudioRingBuffer<AudioBlock> (lock-free SPSC; downstream DSP/analysis polls asynchronously)
* -> latestBlock (volatile, for "give me the latest" UI consumers)
* -> WaveformModel (legacy compatibility view, built via WaveformRenderer)
* }</pre>
*
* <p>The capture loop knows nothing about pixels, panel coordinates or Swing — pixel scaling has
* moved into {@link WaveformRenderer}. The legacy {@link WaveformModel} is still produced for
* existing UI consumers and tests; it is now derived from the same {@link AudioBlock} the rest of
* the platform sees.
*
* <p>Thread-safety: all public methods are thread-safe. The capture worker thread is the sole
* producer for the ring buffer; downstream DSP/analysis threads are the consumers.
*
* @author refactoring
*/
public class AudioCaptureServiceImpl implements AudioCaptureService {
private static final Logger LOGGER = Logger.getLogger(AudioCaptureServiceImpl.class.getName());
/** Tick distance in seconds (1 ms). */
private static final float TICK_SECONDS = 1f / 1000f;
/** Minimum buffer size in bytes to prevent overly small allocations. */
private static final int MIN_BUFFER_SIZE = 256;
/** Default ring-buffer capacity (in {@link AudioBlock}s). */
private static final int RING_BUFFER_CAPACITY = 64;
private final AtomicBoolean running = new AtomicBoolean(false);
private volatile WaveformModel latestModel;
private volatile AudioBlock latestBlock;
// Audio configuration
private final float sampleRate;
private final int sampleSizeInBits;
private final int channels;
private final boolean signed;
private final boolean bigEndian;
private final AudioFormatDescriptor descriptor;
private final SampleDecoder decoder;
private final AudioRingBuffer<AudioBlock> ringBuffer;
// Capture state
private volatile int divisor;
private volatile int panelWidth;
private volatile int panelHeight;
private TargetDataLine line;
private AudioFormat format;
private ExecutorService workerExecutor;
// Capture buffers (mostly worker-thread owned; volatile for visibility on reconfiguration)
private volatile byte[] datas;
private volatile int datasize;
private volatile int numberOfPoints;
private final int tickEveryNSample;
// Audio line provider (for testability)
private final AudioLineProvider lineProvider;
/**
* Create a new AudioCaptureServiceImpl with specified audio parameters.
*
* @param sampleRate sample rate in Hz (e.g., 16000.0f)
* @param sampleSizeInBits sample size in bits (e.g., 8 or 16)
* @param channels number of audio channels (e.g., 1 for mono, 2 for stereo)
* @param signed true if samples are signed
* @param bigEndian true if samples are big-endian
* @param divisor initial divisor for buffer size calculation
*/
public AudioCaptureServiceImpl(
float sampleRate,
int sampleSizeInBits,
int channels,
boolean signed,
boolean bigEndian,
int divisor) {
this(
sampleRate,
sampleSizeInBits,
channels,
signed,
bigEndian,
divisor,
new DefaultAudioLineProvider());
}
/**
* Create a new AudioCaptureServiceImpl using a selected JavaSound input mixer.
*
* @param sampleRate sample rate in Hz
* @param sampleSizeInBits sample size in bits
* @param channels number of channels
* @param signed true if samples are signed
* @param bigEndian true if samples are big-endian
* @param divisor initial divisor for buffer size calculation
* @param mixerInfo selected mixer, or {@code null} for the system default
*/
public AudioCaptureServiceImpl(
float sampleRate,
int sampleSizeInBits,
int channels,
boolean signed,
boolean bigEndian,
int divisor,
Mixer.Info mixerInfo) {
this(
sampleRate,
sampleSizeInBits,
channels,
signed,
bigEndian,
divisor,
new DefaultAudioLineProvider(mixerInfo));
}
/** Package-private constructor for testing with custom AudioLineProvider. */
AudioCaptureServiceImpl(
float sampleRate,
int sampleSizeInBits,
int channels,
boolean signed,
boolean bigEndian,
int divisor,
AudioLineProvider lineProvider) {
this.sampleRate = sampleRate;
this.sampleSizeInBits = sampleSizeInBits;
this.channels = Math.max(1, channels);
this.signed = signed;
this.bigEndian = bigEndian;
this.divisor = Math.max(1, divisor);
this.tickEveryNSample = (int) (TICK_SECONDS * sampleRate);
this.panelWidth = 640;
this.panelHeight = 200;
this.lineProvider = lineProvider;
this.descriptor = new AudioFormatDescriptor(sampleRate, this.channels, sampleSizeInBits);
this.decoder = new SampleDecoder(descriptor, signed, bigEndian);
this.ringBuffer = new AudioRingBuffer<>(RING_BUFFER_CAPACITY);
}
@Override
public void start() {
if (running.get()) {
LOGGER.warning("AudioCaptureService is already running");
return;
}
try {
initializeAudioLine();
computeDataSize();
running.set(true);
workerExecutor =
Executors.newSingleThreadExecutor(
r -> {
Thread t = new Thread(r, "AudioCaptureWorker");
t.setDaemon(true);
return t;
});
workerExecutor.submit(this::captureLoop);
LOGGER.info("AudioCaptureService started successfully");
} catch (Exception e) {
running.set(false);
LOGGER.log(Level.SEVERE, "Failed to start AudioCaptureService", e);
throw new IllegalStateException("Failed to start audio capture", e);
}
}
@Override
public void stop() {
if (!running.get()) {
return;
}
running.set(false);
if (workerExecutor != null) {
workerExecutor.shutdownNow();
try {
if (!workerExecutor.awaitTermination(1, TimeUnit.SECONDS)) {
workerExecutor.shutdownNow();
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
workerExecutor = null;
}
if (line != null) {
try {
line.stop();
line.flush();
line.close();
} catch (Exception e) {
LOGGER.log(Level.WARNING, "Error closing TargetDataLine", e);
}
line = null;
}
LOGGER.info("AudioCaptureService stopped");
}
@Override
public boolean isRunning() {
return running.get();
}
@Override
public WaveformModel getLatestModel() {
WaveformModel cached = latestModel;
if (cached != null) {
return cached;
}
return WaveformModel.EMPTY;
}
@Override
public AudioFormat getFormat() {
return format;
}
@Override
public AudioFormatDescriptor getDescriptor() {
return descriptor;
}
@Override
public AudioBlock getLatestBlock() {
return latestBlock;
}
@Override
public AudioRingBuffer<AudioBlock> getRingBuffer() {
return ringBuffer;
}
@Override
public void setDivisor(int divisor) {
if (divisor < 1) {
throw new IllegalArgumentException("Divisor must be >= 1");
}
this.divisor = divisor;
if (line != null) {
computeDataSize();
}
}
@Override
public int getDivisor() {
return divisor;
}
@Override
public void recomputeLayout(int width, int height) {
this.panelWidth = width;
this.panelHeight = height;
// Re-render the latest block under the new layout so resized panels see fresh pixel
// coordinates immediately, even before the next capture cycle.
AudioBlock cached = latestBlock;
if (cached != null) {
latestModel = buildLegacyModel(cached);
}
}
/** Initialize and open the audio line. */
private void initializeAudioLine() {
format = new AudioFormat(sampleRate, sampleSizeInBits, channels, signed, bigEndian);
line = lineProvider.acquireLine(format);
LOGGER.info("Opened audio line with format: " + format);
}
/** Compute byte buffer size and number of frames per block. */
private void computeDataSize() {
if (line == null) {
throw new IllegalStateException("Line must be opened before computing buffer sizes.");
}
datasize = Math.max(MIN_BUFFER_SIZE, line.getBufferSize() / Math.max(1, divisor));
int frameSize = decoder.frameSize();
int points = datasize / Math.max(1, frameSize);
if (points <= 0) {
points = 1;
}
numberOfPoints = points;
datas = new byte[datasize];
LOGGER.fine(String.format("Computed data size: %d, points: %d", datasize, points));
}
/** Main capture loop running in worker thread. */
private void captureLoop() {
if (line == null) {
LOGGER.warning("TargetDataLine is null, aborting capture loop.");
return;
}
line.start();
long frameIndex = 0L;
int allocatedFrames = numberOfPoints;
float[][] decodeBuffer = new float[channels][allocatedFrames];
while (running.get() && !Thread.currentThread().isInterrupted()) {
try {
byte[] localData = datas;
final int numBytesRead = line.read(localData, 0, localData.length);
if (numBytesRead <= 0) {
continue;
}
int currentPoints = numberOfPoints;
if (allocatedFrames < currentPoints) {
allocatedFrames = currentPoints;
decodeBuffer = new float[channels][allocatedFrames];
}
final int decodedFrames = Math.min(currentPoints, decoder.framesIn(numBytesRead));
if (decodedFrames <= 0) {
continue;
}
decoder.decode(localData, decodedFrames * decoder.frameSize(), decodeBuffer);
// Zero-pad the tail so block.frames() always equals the configured buffer size; this
// preserves the legacy semantics where the model's numberOfPoints reflects the configured
// buffer (driven by the divisor) rather than the partial bytes read in this iteration.
for (int c = 0; c < channels; c++) {
for (int i = decodedFrames; i < currentPoints; i++) {
decodeBuffer[c][i] = 0f;
}
}
// Build a fresh, exactly-sized float[channels][currentPoints] for the immutable block.
float[][] blockSamples = new float[channels][currentPoints];
for (int c = 0; c < channels; c++) {
System.arraycopy(decodeBuffer[c], 0, blockSamples[c], 0, currentPoints);
}
AudioBlock block = AudioBlock.wrap(descriptor, blockSamples, frameIndex, System.nanoTime());
frameIndex += decodedFrames;
// Publish to ring buffer (plain offer + drop-on-full). The capture thread is the sole
// producer; downstream DSP/analysis consumers may run on other threads, so we cannot
// safely use offerOverwrite here (see AudioRingBuffer.offerOverwrite Javadoc). The
// "latest wins" path is served by the volatile latestBlock pointer below, so dropping
// the new block on overflow is preferable to corrupting the consumer's view.
ringBuffer.offer(block);
// Cache "latest" view for cheap polling consumers (UI, REST).
latestBlock = block;
// Build the legacy WaveformModel for backwards-compatible Swing rendering.
latestModel = buildLegacyModel(block);
} catch (Exception ex) {
if (running.get()) {
LOGGER.log(Level.SEVERE, "Error during audio capture loop", ex);
}
}
}
LOGGER.fine("Capture loop ended");
}
/** Build a legacy {@link WaveformModel} from a new {@link AudioBlock}. */
private WaveformModel buildLegacyModel(AudioBlock block) {
WaveformSnapshot snap =
WaveformSnapshot.wrap(
block.samples(),
block.format().sampleRate(),
block.frameIndex(),
block.timestampNanos());
int[] xPoints = WaveformRenderer.computeXPoints(snap.frames(), panelWidth);
// Swing panels can transiently report height==0 before they are laid out; in that case we
// emit empty per-channel arrays rather than asking WaveformRenderer to throw, since this is
// a legitimate "nothing to draw yet" state, not a programming error.
int h = panelHeight;
int[][] yPoints;
if (h <= 0) {
yPoints = new int[snap.channels()][0];
} else {
yPoints = WaveformRenderer.computeYPointsAllChannels(snap, h);
}
return new WaveformModel(xPoints, yPoints, tickEveryNSample, datasize);
}
}