Files
sdrtrunk/src/main/java/io/github/dsheirer/audio/playback/AudioOutput.java
Denny Sheirer c81e6cccfa #2356 Audio playback redesign. Resolves audio playback issue on some (e.g. Kubuntu) systems. For stereo playback, (#2359)
consolidates left and right channels into a single playback channel.  Implements continuous audio streaming using
silence to fill the gaps to avoid mixer source data line start/stop issues.  Code structure is ready to layer on
multi-channel (ie 5.1 or 7.1) audio playback devices, but that hasn't been tested.

Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-02-08 05:30:39 -05:00

379 lines
13 KiB
Java

/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.audio.playback;
import io.github.dsheirer.audio.AudioEvent;
import io.github.dsheirer.controller.NamingThreadFactory;
import io.github.dsheirer.log.LoggingSuppressor;
import io.github.dsheirer.preference.playback.PlayTestAudioRequest;
import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.Control;
import javax.sound.sampled.DataLine;
import javax.sound.sampled.FloatControl;
import javax.sound.sampled.LineEvent;
import javax.sound.sampled.LineListener;
import javax.sound.sampled.LineUnavailableException;
import javax.sound.sampled.Mixer;
import javax.sound.sampled.SourceDataLine;
/**
* Audio output/playback channel for a single audio mixer channel. Providers support for playback of audio segments
* and broadcasts audio segment metadata to registered listeners (ie gui components).
*/
public class AudioOutput implements LineListener
{
private static final Logger mLog = LoggerFactory.getLogger(AudioOutput.class);
private static final LoggingSuppressor LOGGING_SUPPRESSOR = new LoggingSuppressor(mLog);
private static final int BUFFER_SIZE_SAMPLES = 1000; //At 8 kHz audio
private final AudioPlaybackDeviceDescriptor mAudioPlaybackDeviceDescriptor;
private final AudioProvider mAudioProvider;
private FloatControl mGainControl;
private ScheduledExecutorService mScheduledExecutorService;
private ScheduledFuture<?> mProcessorFuture;
private SourceDataLine mSourceDataLine;
private boolean mCanProcessAudio = false;
private boolean mRunning = false;
/**
* Audio output for the selected audio playback device and provider. Opens a SourceDataLine from the miser for the
* audio format specified in the descriptor. Employs a single threaded executor to process audio 10x a second and
* manages the start/stop control for the dataline based on audio availability.
*
* @param descriptor for the mixer and audio format
* @param audioProvider for access to audio from audio segments
*/
public AudioOutput(AudioPlaybackDeviceDescriptor descriptor, AudioProvider audioProvider)
{
mAudioProvider = audioProvider;
Mixer mixer = AudioSystem.getMixer(descriptor.getMixerInfo());
if(mixer == null)
{
List<AudioPlaybackDeviceDescriptor> descriptors = AudioPlaybackDeviceManager.getAudioPlaybackDevices();
int selected = 0;
while((mixer == null) && (selected < descriptors.size()))
{
descriptor = descriptors.get(selected++);
mixer = AudioSystem.getMixer(descriptor.getMixerInfo());
}
}
mAudioPlaybackDeviceDescriptor = descriptor;
if(mixer != null)
{
try
{
mSourceDataLine = (SourceDataLine) mixer.getLine(new DataLine.Info(SourceDataLine.class,
descriptor.getAudioFormat()));
if(mSourceDataLine != null)
{
mSourceDataLine.addLineListener(this);
try
{
Control gain = mSourceDataLine.getControl(FloatControl.Type.MASTER_GAIN);
mGainControl = (FloatControl) gain;
}
catch(IllegalArgumentException iae)
{
LOGGING_SUPPRESSOR.error("no gain control", 2, "Couldn't obtain " +
"MASTER GAIN control for stereo line [" + mixer.getMixerInfo().getName() + "]");
}
mCanProcessAudio = true;
}
}
catch(LineUnavailableException e)
{
mLog.error("Couldn't open source data line for mixer: " + descriptor.getMixerInfo().getName(), e);
}
if(mCanProcessAudio)
{
openSourceDataLine();
//The audio provider gives us audio 160 samples per interval representing 20 milliseconds of audio.
// Run the scheduled executor at just slightly faster than that time, recognizing that it will
// block against the mixer source data line until it can write all 160 samples.
mScheduledExecutorService = Executors.newSingleThreadScheduledExecutor(new NamingThreadFactory(
"sdrtrunk audio output " + descriptor.getMixerInfo().getName()));
mProcessorFuture = mScheduledExecutorService.scheduleAtFixedRate(new AudioProcessor(),
0, 19, TimeUnit.MILLISECONDS);
}
else
{
//Disable each of the audio channels so they don't queue audio segments.
for(AudioChannel audioChannel: mAudioProvider.getAudioChannels())
{
audioChannel.setDisabled(true);
}
}
}
}
/**
* Opens and starts the source data line.
*/
private void openSourceDataLine()
{
try
{
if(mSourceDataLine != null && !mSourceDataLine.isOpen())
{
int bufferSize = BUFFER_SIZE_SAMPLES * 2 * mAudioPlaybackDeviceDescriptor.getAudioFormat().getChannels();
mSourceDataLine.open(mAudioProvider.getAudioFormat(), bufferSize);
bufferSize = mSourceDataLine.getBufferSize();
//Fill the line with silence so we can start it
mSourceDataLine.write(new byte[bufferSize], 0, bufferSize);
mSourceDataLine.start();
for(AudioChannel audioChannel: mAudioProvider.getAudioChannels())
{
audioChannel.setDisabled(false);
}
}
}
catch(LineUnavailableException e)
{
LOGGING_SUPPRESSOR.error("Can't open", 3, "Error opening source data line", e);
mRunning = false;
for(AudioChannel audioChannel: mAudioProvider.getAudioChannels())
{
audioChannel.setDisabled(true);
}
}
}
/**
* Plays the test audio over the selected audio channel(s)
* @param request to play test audio
*/
public void playTestAudio(PlayTestAudioRequest request)
{
//If we're not running, resize the audio samples to fill the buffer enough to trigger playback.
float[] audio = mRunning ? request.audio() : Arrays.copyOf(request.audio(), BUFFER_SIZE_SAMPLES);
List<AudioChannel> channels = mAudioProvider.getAudioChannels();
if(request.isAllChannels())
{
for(AudioChannel channel : channels)
{
channel.playTest(audio);
}
}
else if(request.channel() < channels.size())
{
channels.get(request.channel()).playTest(audio);
}
else if(!channels.isEmpty())
{
channels.getFirst().playTest(audio);
}
else
{
LOGGING_SUPPRESSOR.info("No Audio Channels", 2,
"Unable to play test audio - no audio channels configured currently");
}
}
/**
* Audio playback device descriptor for this audio output
*/
public AudioPlaybackDeviceDescriptor getAudioPlaybackDeviceDescriptor()
{
return mAudioPlaybackDeviceDescriptor;
}
/**
* Audio provider managed by this audio output
*/
public AudioProvider getAudioProvider()
{
return mAudioProvider;
}
/**
* Process audio from the audio provider and writes it to the mixer's source data line. Calls to this method can
* block on the source data line until it has capacity available to accept the audio.
*/
private void processAudio()
{
if(mSourceDataLine == null)
{
LOGGING_SUPPRESSOR.error("null output", 2,
"Audio Output source data line is null - ignoring audio playback request");
return;
}
ByteBuffer buffer = mAudioProvider.getAudio();
if(buffer != null)
{
//This is a blocking method call.
int wrote = mSourceDataLine.write(buffer.array(), 0, buffer.array().length);
//Around JDK 22 something started causing the source data line to fail to accept audio byte data via the
//write() method. When this happens, close and reopen the data line to clear the error state.
if(wrote <= 0)
{
LOGGING_SUPPRESSOR.info("Stalled Data Line", 3,
"Audio playback data line has stopped accepting samples - recycling to clear the error");
mSourceDataLine.close();
openSourceDataLine();
}
}
}
/**
* Prepares this audio output for disposal.
*/
public void dispose()
{
if(mProcessorFuture != null)
{
mProcessorFuture.cancel(true);
}
mProcessorFuture = null;
if(mScheduledExecutorService != null)
{
mScheduledExecutorService.shutdownNow();
mScheduledExecutorService = null;
}
mAudioProvider.dispose();
mCanProcessAudio = false;
if(mSourceDataLine != null)
{
mSourceDataLine.stop();
mSourceDataLine.flush();
mSourceDataLine.close();
}
mSourceDataLine = null;
mGainControl = null;
mRunning = false;
}
/**
* Sets the mute state for this audio output channel
*/
public void setMuted(boolean muted)
{
for(AudioChannel audioChannel: mAudioProvider.getAudioChannels())
{
audioChannel.setMuted(muted);
}
AudioEvent.Type type = muted ? AudioEvent.Type.AUDIO_MUTED : AudioEvent.Type.AUDIO_UNMUTED;
mAudioProvider.notify(type);
}
/**
* Current mute state for this audio output channel
*/
public boolean isMuted()
{
if(!mAudioProvider.getAudioChannels().isEmpty())
{
return mAudioProvider.getAudioChannels().getFirst().isMuted();
}
return false;
}
/**
* Gain/volume control for this audio output channel, if one is available.
*/
public FloatControl getGainControl()
{
return mGainControl;
}
/**
* Indicates if this audio output has a gain control available
*/
public boolean hasGainControl()
{
return mGainControl != null;
}
/**
* Monitors the source data line playback state and broadcasts audio events to the registered listener as the
* state changes
*/
@Override
public void update(LineEvent event)
{
LineEvent.Type type = event.getType();
if(type == LineEvent.Type.START)
{
mAudioProvider.notify(AudioEvent.Type.AUDIO_STARTED);
}
else if(type == LineEvent.Type.STOP)
{
mAudioProvider.notify(AudioEvent.Type.AUDIO_STOPPED);
}
}
/**
* Audio Processor thread
*/
public class AudioProcessor implements Runnable
{
private final AtomicBoolean mProcessing = new AtomicBoolean();
@Override
public void run()
{
if(mProcessing.compareAndSet(false, true))
{
try
{
processAudio();
}
catch(Throwable t)
{
mLog.error("Error while processing audio buffers", t);
}
mProcessing.set(false);
}
}
}
}