io.github.dsheirer.record.wave.WaveWriter.java Source code

Java tutorial

Introduction

Here is the source code for io.github.dsheirer.record.wave.WaveWriter.java

Source

/*******************************************************************************
 * sdrtrunk
 * Copyright (C) 2014-2017 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.record.wave;

import org.apache.commons.lang3.Validate;

import javax.sound.sampled.AudioFormat;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class WaveWriter implements AutoCloseable {
    public static final String RIFF_ID = "RIFF";
    public static final int INITIAL_TOTAL_LENGTH = 4;
    public static final String WAVE_ID = "WAVE";

    public static final String FORMAT_CHUNK_ID = "fmt ";
    public static final int FORMAT_CHUNK_LENGTH = 16;
    public static final short FORMAT_UNCOMPRESSED_PCM = 1;

    public static final String DATA_CHUNK_ID = "data";

    private static final Pattern FILENAME_PATTERN = Pattern.compile("(.*_)(\\d+)(\\.wav)");
    public static final long MAX_WAVE_SIZE = 2l * (long) Integer.MAX_VALUE;

    private AudioFormat mAudioFormat;
    private int mFileRolloverCounter = 1;
    private long mMaxSize;
    private Path mFile;
    private FileChannel mFileChannel;
    private boolean mDataChunkOpen = false;
    private long mDataChunkSizeOffset = 0;
    private int mDataChunkSize = 0;

    /**
     * Constructs a new wave writer that is open with a complete header, ready
     * for writing buffers of PCM sample data.
     *
     * Each time the maximum file size is reached, a new file is created with a
     * series suffix appended to the file name.
     *
     * @param format - audio format (channels, sample size, sample rate)
     * @param file - wave file to write
     * @param maxSize - maximum file size ( range: 1 - 4,294,967,294 bytes )
     * @throws IOException - if there are any IO issues
     */
    public WaveWriter(AudioFormat format, Path file, long maxSize) throws IOException {
        Validate.isTrue(format != null);
        Validate.isTrue(file != null);

        mAudioFormat = format;
        mFile = file;

        if (0 < maxSize && maxSize <= MAX_WAVE_SIZE) {
            mMaxSize = maxSize;
        } else {
            mMaxSize = MAX_WAVE_SIZE;
        }

        open();
    }

    /**
     * Constructs a new wave writer that is open with a complete header, ready
     * for writing buffers of PCM sample data.  The maximum file size is limited
     * to the max size specified in the wave file format: max unsigned integer
     *
     * @param format - audio format (channels, sample size, sample rate)
     * @param file - wave file to write
     * @throws IOException - if there are any IO issues
     */
    public WaveWriter(AudioFormat format, Path file) throws IOException {
        this(format, file, Integer.MAX_VALUE * 2);
    }

    /**
     * Opens the file and writes a wave header.
     */
    private void open() throws IOException {
        int version = 2;

        while (Files.exists(mFile)) {
            mFile = Paths.get(mFile.toFile().getAbsolutePath().replace(".wav", "_" + version + ".wav"));
            version++;
        }

        mFileChannel = (FileChannel.open(mFile, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW));

        ByteBuffer header = getWaveHeader(mAudioFormat);

        while (header.hasRemaining()) {
            mFileChannel.write(header);
        }
    }

    /**
     * Closes the file
     */
    public void close() throws IOException {
        mFileChannel.force(true);
        mFileChannel.close();
        mFileChannel = null;
    }

    @Override
    protected void finalize() throws IOException {
        mFileChannel.force(true);
        mFileChannel.close();
        mFileChannel = null;
    }

    /**
     * Writes the buffer contents to the file.  Assumes that the buffer is full
     * and the first byte of data is at position 0.
     */
    public void writeData(ByteBuffer buffer) throws IOException {
        buffer.position(0);

        openDataChunk();

        /* Write the full buffer if there is room, respecting the max file size */
        if (mFileChannel.size() + buffer.capacity() < mMaxSize) {
            while (buffer.hasRemaining()) {
                mDataChunkSize += mFileChannel.write(buffer);
            }

            updateTotalSize();
            updateDataChunkSize();
        } else {
            /* Split the buffer to finish filling the current file and then put
             * the leftover into a new file */
            int remaining = (int) (mMaxSize - mFileChannel.size());

            /* Ensure we write full frames to fill up the remaining size */
            remaining -= (int) (remaining % mAudioFormat.getFrameSize());

            byte[] bytes = buffer.array();

            ByteBuffer current = ByteBuffer.wrap(Arrays.copyOf(bytes, remaining));

            ByteBuffer next = ByteBuffer.wrap(Arrays.copyOfRange(bytes, remaining, bytes.length));

            while (current.hasRemaining()) {
                mDataChunkSize += mFileChannel.write(current);
            }

            updateTotalSize();
            updateDataChunkSize();

            rollover();

            openDataChunk();

            while (next.hasRemaining()) {
                mDataChunkSize += mFileChannel.write(next);
            }

            updateTotalSize();
            updateDataChunkSize();
        }
    }

    /**
     * Closes the current data chunk
     */
    private void closeDataChunk() {
        mDataChunkOpen = false;
    }

    /**
     * Opens a new data chunk if a data chunk is not currently open.  This method can be invoked repeatedly as an
     * assurance that the data chunk header has been written.
     *
     * @throws IOException if there is an error writing the data chunk header.
     */
    private void openDataChunk() throws IOException {
        if (!mDataChunkOpen) {
            if (mFileChannel.size() + 32 >= mMaxSize) {
                rollover();
            }

            ByteBuffer formatChunk = getFormatChunk(mAudioFormat);
            formatChunk.position(0);

            while (formatChunk.hasRemaining()) {
                mFileChannel.write(formatChunk);
            }

            ByteBuffer dataHeader = getDataHeader();
            dataHeader.position(0);

            while (dataHeader.hasRemaining()) {
                mFileChannel.write(dataHeader);
            }

            mDataChunkSizeOffset = mFileChannel.size() - 4;
            mDataChunkSize = 0;
            mDataChunkOpen = true;

            updateTotalSize();
        }
    }

    /**
     * Writes the metadata to the end of the file if there is sufficient space without exceeding the
     * max file size.
     */
    public void writeMetadata(WaveMetadata metadata) throws IOException {
        ByteBuffer listChunk = metadata.getLISTChunk();

        if (mFileChannel.size() + listChunk.capacity() >= mMaxSize) {
            throw new IOException("Cannot write LIST metadata chunk - insufficient file space remaining");
        }

        closeDataChunk();

        listChunk.position(0);

        while (listChunk.hasRemaining()) {
            mFileChannel.write(listChunk);
        }

        updateTotalSize();

        ByteBuffer id3Chunk = metadata.getID3Chunk();

        if (mFileChannel.size() + id3Chunk.capacity() >= mMaxSize) {
            throw new IOException("Cannot write ID3 metadata chunk - insufficient file space remaining");
        }

        id3Chunk.position(0);

        while (id3Chunk.hasRemaining()) {
            mFileChannel.write(id3Chunk);
        }

        updateTotalSize();
    }

    /**
     * Closes out the current file, appends an incremented sequence number to
     * the file name and opens up a new file.
     */
    private void rollover() throws IOException {
        closeDataChunk();
        close();

        mFileRolloverCounter++;

        updateFileName();

        open();
    }

    /**
     * Updates the overall and the chunk2 sizes
     */
    private void updateTotalSize() throws IOException {
        /* Update overall wave size (total size - 8 bytes) */
        ByteBuffer buffer = getUnsignedIntegerBuffer(mFileChannel.size() - 8);
        mFileChannel.write(buffer, 4);
    }

    /**
     * Updates the data chunk size
     */
    private void updateDataChunkSize() throws IOException {
        if (!mDataChunkOpen) {
            throw new IOException("Can't update data chunk size - data chunk is not currently open");
        }

        /* Update overall wave size (total size - 8 bytes) */
        ByteBuffer size = getUnsignedIntegerBuffer(mDataChunkSize);
        mFileChannel.write(size, mDataChunkSizeOffset);
    }

    /**
     * Creates a little-endian 4-byte buffer containing an unsigned 32-bit
     * integer value derived from the 4 least significant bytes of the argument.
     *
     * The buffer's position is set to 0 to prepare it for writing to a channel.
     */
    protected static ByteBuffer getUnsignedIntegerBuffer(long size) {
        ByteBuffer buffer = ByteBuffer.allocate(4);

        buffer.put((byte) (size & 0xFFl));
        buffer.put((byte) (Long.rotateRight(size & 0xFF00l, 8)));
        buffer.put((byte) (Long.rotateRight(size & 0xFF0000l, 16)));

        /* This side-steps an issue with right shifting a signed long by 32
         * where it produces an error value.  Instead, we right shift in two steps. */
        buffer.put((byte) Long.rotateRight(Long.rotateRight(size & 0xFF000000l, 16), 8));

        buffer.position(0);

        return buffer;
    }

    public static String toString(ByteBuffer buffer) {
        StringBuilder sb = new StringBuilder();

        byte[] bytes = buffer.array();

        for (byte b : bytes) {
            sb.append(String.format("%02X ", b));
            sb.append(" ");
        }

        return sb.toString();
    }

    /**
     * Updates the current file name with the rollover counter series suffix
     */
    private void updateFileName() {
        String filename = mFile.toString();

        if (mFileRolloverCounter == 2) {
            filename = filename.replace(".wav", "_2.wav");
        } else {
            Matcher m = FILENAME_PATTERN.matcher(filename);

            if (m.find()) {
                StringBuilder sb = new StringBuilder();
                sb.append(m.group(1));
                sb.append(mFileRolloverCounter);
                sb.append(m.group(3));

                filename = sb.toString();
            }
        }

        mFile = Paths.get(filename);
    }

    /**
     * Creates a data chunk header with the chunk size initialized to zero
     */
    public static ByteBuffer getDataHeader() {
        ByteBuffer header = ByteBuffer.allocate(8);
        header.put(DATA_CHUNK_ID.getBytes());
        header.position(0);

        return header;
    }

    /**
     * Creates a wave file header with a format descriptor chunk
     */
    public static ByteBuffer getWaveHeader(AudioFormat format) {
        ByteBuffer header = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN);

        //RIFF/WAVE header and size
        header.put(RIFF_ID.getBytes());
        header.putInt(INITIAL_TOTAL_LENGTH);
        header.put(WAVE_ID.getBytes());

        //Reset the buffer pointer to 0
        header.position(0);

        return header;
    }

    /**
     * Creates an audio format chunk
     */
    public static ByteBuffer getFormatChunk(AudioFormat format) {
        ByteBuffer header = ByteBuffer.allocate(24).order(ByteOrder.LITTLE_ENDIAN);

        //Format descriptor
        header.put(FORMAT_CHUNK_ID.getBytes());
        header.putInt(FORMAT_CHUNK_LENGTH);
        header.putShort(FORMAT_UNCOMPRESSED_PCM);
        header.putShort((short) format.getChannels());
        header.putInt((int) format.getSampleRate());

        //Byte Rate = sample rate * channels * bits per sample / 8
        int frameByteRate = format.getChannels() * format.getSampleSizeInBits() / 8;
        int byteRate = (int) (format.getSampleRate() * frameByteRate);
        header.putInt(byteRate);

        //Block Align
        header.putShort((short) frameByteRate);

        //Bits per Sample
        header.putShort((short) format.getSampleSizeInBits());

        //Reset the buffer pointer to 0
        header.position(0);

        return header;
    }
}