Java Sound API: Can you select what channels on an output device to play a stereo clip?

66 views Asked by At

With the java sound API I don't seem to be able to access all the multiple output (4 mono) channels available on my audio interface, a usb presonus studio 68c, running from Java 21 on both windows and a Mac m1, with the usb presonus studio plugged in.

Mixer: Port Studio 68c
  MICROPHONE source port
  SPEAKER target port


Mixer: Studio 68c
  interface SourceDataLine supporting 20 audio formats, and buffers of at least 32 bytes
  ... (20 supported audio formats removed)
  interface Clip supporting 20 audio formats, and buffers of at least 32 bytes
  ... (20 supported audio formats removed)
  interface TargetDataLine supporting 20 audio formats, and buffers of at least 32 bytes
  ... (8 supported audio formats removed)

I can play a stereo clip on the default master channels 1+2 (with interface Clip), but channels 3+4 (e.g. for headphone qué) on the same audio interface remain elusive.

Is this a limitation of the Java Sound API?

1

There are 1 answers

0
Phil On

Ok, so although you cannot access the individual output channels in source data lines, you can access all the channels at once by writing a 6 channel format audio data to your 6 mono channel capable audio interface. So if you offset the 2 channel stereo audio data by 2 channels on byte array that you are using to write to the 6 channel device, you can play sound on channels 3 and 4. All the other channels get 0 (if you want nothing to play on them).

so here is some code that plays a wav file on channels 3-4.

    final File file = new File("res/Piano7notes_6-3sec_Stereo_16bit_48kHz.wav"); // https://www.kozco.com/tech/soundtests.html //  PCM_SIGNED 48000.0 Hz, 16 bit, stereo, 4 bytes/frame, little-endian

    final AudioInputStream audioStream = AudioSystem.getAudioInputStream(file);
    final AudioFormat audioFormat = audioStream.getFormat();
    final DataLine.Info info = new DataLine.Info(SourceDataLine.class, audioFormat);

    int lengthInFrames = (int)audioStream.getFrameLength();

    if (lengthInFrames == AudioSystem.NOT_SPECIFIED) throw new RuntimeException("lengthInFrames == AudioSystem.NOT_SPECIFIED");

    long audioFileLength = file.length();
    int frameSize = audioFormat.getFrameSize();
    float frameRate = audioFormat.getFrameRate();
    int bytesSize = lengthInFrames * frameSize;
    float durationInSeconds = (audioFileLength / (frameSize * frameRate));

    System.out.println("Duration:  " + durationInSeconds);
    System.out.println("File size: " + audioFileLength);
    System.out.println("Byte size: " + bytesSize);

    final AudioSystemInfo audioSystemInfo = new AudioSystemInfo();

    final AudioSystemInfo.MixerInfo mi = audioSystemInfo.getFirstClipPlayingMixerWithMinOutputChannels(2).orElseThrow();

    final Clip audioClip = AudioSystem.getClip(mi.info);

    audioClip.addLineListener(System.out::println);

    // PCM 16bit stereo (2 channel) wav - [1][1][2][2][1][1][2][2] ...
    // PCM 16bit (6 channel) wav - [1][1][2][2][3][3][4][4][5][5][6][6][1][1][2][2][3][3][4][4][5][5][6][6] ...

    final byte[] data = new byte[bytesSize];
    final int bytesRead = audioStream.read(data, 0, bytesSize);
    if (bytesRead != bytesSize) throw new RuntimeException("bytesRead ("+bytesRead+") != bytesSize (" + bytesSize + ")");

    // get AudioFormat from mixer that has more channels
    final AudioFormat toAudioFormat = new AudioFormat(
            audioFormat.getEncoding(),
            audioFormat.getSampleRate(),
            audioFormat.getSampleSizeInBits(),
            mi.maxOutputChannels,
            audioFormat.getFrameSize(),
            audioFormat.getFrameRate(),
            audioFormat.isBigEndian());

    final AudioChannelBuffer audioChannelBuffer = new AudioChannelBuffer(audioFormat, toAudioFormat);

    final byte[] modifiedData = audioChannelBuffer.convertAudioData(data, 2);

    audioClip.open(toAudioFormat, modifiedData, 0, modifiedData.length);
    audioClip.start();

    // need to wait until finished playing clip.
    Thread.sleep((long) (durationInSeconds * 1000));

    audioClip.stop();
    audioClip.close();

The AudioSystemInfo class is exists to help me find the 6 channel mixer (Audio Interface)

public class AudioSystemInfo {

public static class MixerInfo {
    final Mixer.Info info;
    final Mixer mixer;
    final int maxOutputChannels;
    final int maxInputChannels;

    final boolean canPlayClips;

    MixerInfo(final Mixer.Info info) {
        this.info = info;
        this.mixer = AudioSystem.getMixer(info);
        this.maxInputChannels = findMaxChannelsInLineInfos(mixer.getTargetLineInfo());
        this.maxOutputChannels = findMaxChannelsInLineInfos(mixer.getSourceLineInfo());
        this.canPlayClips = Arrays.stream(mixer.getSourceLineInfo()).anyMatch(li -> li.getLineClass().equals(javax.sound.sampled.Clip.class));
    }

    private int findMaxChannelsInLineInfos(Line.Info[] infos) {
        return Arrays.stream(infos)
                .filter(s -> s instanceof DataLine.Info)
                .map(s -> (DataLine.Info)s)
                .map(di -> findMaxChannelsInAudioFormats(di.getFormats()))
                .max(Integer::compareTo).orElse(0);
    }

    private static int findMaxChannelsInAudioFormats(AudioFormat[] audioFormats) {
        return Arrays.stream(audioFormats).map(AudioFormat::getChannels).max(Integer::compareTo).orElse(0);
    }
}

final List<MixerInfo> mixerInfos;

AudioSystemInfo() {
    mixerInfos = Arrays.stream(AudioSystem.getMixerInfo()).map(MixerInfo::new).toList();
}

public Optional<MixerInfo> getFirstClipPlayingMixerWithMinOutputChannels(int minOutputChannels) {
    return mixerInfos.stream().filter(mi -> mi.canPlayClips && mi.maxOutputChannels >= minOutputChannels).findFirst();
}

public static void main(String[] args) {
    final AudioSystemInfo info = new AudioSystemInfo();
    info.mixerInfos.forEach(i -> {
        if (i.maxOutputChannels > 2) {
            System.out.println("Name:        " + i.info.getName());
            System.out.println("Description: " + i.info.getDescription());
            System.out.println("Max Input:   " + i.maxInputChannels);
            System.out.println("Max Output:  " + i.maxOutputChannels);
            System.out.println("Play Clips:  " + i.canPlayClips);
        }
    });
}}

The AudioChannelBuffer class deals with creating the byte array of audio data in the 6 channel 'shape' and copying the 2 channel audio data into it, at a channel offset.

public class AudioChannelBuffer {
final AudioFormat from;
final AudioFormat to;
final int sampleSizeInBytes;

public AudioChannelBuffer(final AudioFormat from, final AudioFormat to) {
    if (from.getChannels() > to.getChannels()) throw new IllegalArgumentException("from audio format must have the same or less channels that the audio format we are converting to");
    if (from.getSampleSizeInBits() != to.getSampleSizeInBits()) throw new IllegalArgumentException("sample size must match");
    if (from.getFrameSize() != to.getFrameSize()) throw new IllegalArgumentException("frame size must match");
    if (!from.getEncoding().equals(to.getEncoding())) throw new IllegalArgumentException("encoding must match");
    if (from.isBigEndian() != to.isBigEndian()) throw new IllegalArgumentException("endian encoding must match");
    if (from.getFrameSize() != to.getFrameSize()) throw new IllegalArgumentException("frame size must match");

    this.from = from;
    this.to = to;

    this.sampleSizeInBytes = from.getSampleSizeInBits() / 8; // same for both.
}

public byte[] convertAudioData(final byte[] dataFrom, final int channelOffset) {

    if (channelOffset > (this.to.getChannels() - this.from.getChannels())) throw new IllegalArgumentException("Channel offset to big.");

    final int fromLengthPerChannel = dataFrom.length / from.getChannels();
    final int channelOffsetInBytes = channelOffset * sampleSizeInBytes;

    final byte[] toBuffer = new byte[to.getChannels() * fromLengthPerChannel];
    Arrays.fill( toBuffer, (byte) 0);

    final int fromFrameSize = sampleSizeInBytes * from.getChannels();
    final int toFrameSize = sampleSizeInBytes * to.getChannels();

    final int numberOfFromFrames = dataFrom.length / fromFrameSize;

    for (int i = 0; i < numberOfFromFrames; i++) {
        int fromOffset = i * fromFrameSize;
        int toOffset = (i * toFrameSize) + channelOffsetInBytes;

        System.arraycopy(dataFrom, fromOffset, toBuffer, toOffset, fromFrameSize);
    }

    return toBuffer;
}}