From 6e352143d2639f43527e7752fd89853b790936df Mon Sep 17 00:00:00 2001 From: Denny Date: Sun, 24 Dec 2017 06:51:09 -0500 Subject: [PATCH] Adds wave file LIST metadata chunk to audio recordings. Note: the metadata chunk may be added multiple times over the life span of an audio call, as additional metadata details are added. --- .../dsheirer/channel/metadata/Metadata.java | 10 + .../state/AlwaysUnsquelchedDecoderState.java | 5 +- .../dsheirer/record/RecorderManager.java | 47 +- .../record/wave/AudioPacketWaveRecorder.java | 220 +++++++ .../wave/ComplexBufferWaveRecorder.java | 2 +- .../record/wave/RealBufferWaveRecorder.java | 2 +- .../dsheirer/record/wave/WaveMetadata.java | 283 ++++++++ .../record/wave/WaveMetadataType.java | 79 +++ .../dsheirer/record/wave/WaveUtils.java | 214 +++--- .../dsheirer/record/wave/WaveWriter.java | 613 +++++++++++------- 10 files changed, 1132 insertions(+), 343 deletions(-) create mode 100644 src/main/java/io/github/dsheirer/record/wave/AudioPacketWaveRecorder.java create mode 100644 src/main/java/io/github/dsheirer/record/wave/WaveMetadata.java create mode 100644 src/main/java/io/github/dsheirer/record/wave/WaveMetadataType.java diff --git a/src/main/java/io/github/dsheirer/channel/metadata/Metadata.java b/src/main/java/io/github/dsheirer/channel/metadata/Metadata.java index 5145053e..ee058130 100644 --- a/src/main/java/io/github/dsheirer/channel/metadata/Metadata.java +++ b/src/main/java/io/github/dsheirer/channel/metadata/Metadata.java @@ -63,6 +63,8 @@ public class Metadata protected Boolean mDoNotRecord; private Set mBroadcastChannels; + private long mTimestamp = System.currentTimeMillis(); + /** * Channel metadata. Contains all attributes that reflect the state and current attribute values for a channel @@ -82,6 +84,14 @@ public class Metadata mMetadataID = metadataID; } + /** + * Timestamp when this metadata was created + * @return milliseconds since epoch + */ + public long getTimestamp() + { + return mTimestamp; + } /** * Unique string identifier for this metadata that is comprised of the channel ID and the primary TO address. * diff --git a/src/main/java/io/github/dsheirer/channel/state/AlwaysUnsquelchedDecoderState.java b/src/main/java/io/github/dsheirer/channel/state/AlwaysUnsquelchedDecoderState.java index e515d6d6..bd2432fb 100644 --- a/src/main/java/io/github/dsheirer/channel/state/AlwaysUnsquelchedDecoderState.java +++ b/src/main/java/io/github/dsheirer/channel/state/AlwaysUnsquelchedDecoderState.java @@ -34,6 +34,7 @@ import java.util.concurrent.ScheduledExecutorService; public class AlwaysUnsquelchedDecoderState extends DecoderState { private final static Logger mLog = LoggerFactory.getLogger(AlwaysUnsquelchedDecoderState.class); + private static final String NO_SQUELCH = "No Squelch"; private DecoderType mDecoderType; private String mChannelName; @@ -82,7 +83,7 @@ public class AlwaysUnsquelchedDecoderState extends DecoderState { //Each time we're reset, set the PRIMARY TO attribute back to the channel name, otherwise we won't have //a primary ID for any audio produced by this state. - broadcast(new AttributeChangeRequest(Attribute.PRIMARY_ADDRESS_TO, mChannelName)); + broadcast(new AttributeChangeRequest(Attribute.PRIMARY_ADDRESS_TO, NO_SQUELCH)); } } @@ -95,7 +96,7 @@ public class AlwaysUnsquelchedDecoderState extends DecoderState @Override public void start(ScheduledExecutorService executor) { - broadcast(new AttributeChangeRequest(Attribute.PRIMARY_ADDRESS_TO, mChannelName)); + broadcast(new AttributeChangeRequest(Attribute.PRIMARY_ADDRESS_TO, NO_SQUELCH)); broadcast(new DecoderStateEvent(this, Event.ALWAYS_UNSQUELCH, State.IDLE)); } diff --git a/src/main/java/io/github/dsheirer/record/RecorderManager.java b/src/main/java/io/github/dsheirer/record/RecorderManager.java index 9486d1e9..738ffe3f 100644 --- a/src/main/java/io/github/dsheirer/record/RecorderManager.java +++ b/src/main/java/io/github/dsheirer/record/RecorderManager.java @@ -1,27 +1,26 @@ -/******************************************************************************* - * SDR Trunk - * Copyright (C) 2014-2017 Dennis Sheirer +/* + * ********************************************************************************************************************* + * sdr-trunk + * 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 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. + * 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 - ******************************************************************************/ + * You should have received a copy of the GNU General Public License along with this program. + * If not, see + * ********************************************************************************************************************* + */ package io.github.dsheirer.record; import io.github.dsheirer.audio.AudioPacket; import io.github.dsheirer.channel.metadata.Metadata; import io.github.dsheirer.properties.SystemProperties; +import io.github.dsheirer.record.wave.AudioPacketWaveRecorder; import io.github.dsheirer.record.wave.ComplexBufferWaveRecorder; -import io.github.dsheirer.record.wave.RealBufferWaveRecorder; import io.github.dsheirer.sample.Listener; import io.github.dsheirer.sample.OverflowableTransferQueue; import io.github.dsheirer.sample.real.IOverflowListener; @@ -45,7 +44,7 @@ public class RecorderManager implements Listener public static final int AUDIO_SAMPLE_RATE = 8000; public static final long IDLE_RECORDER_REMOVAL_THRESHOLD = 6000; //6 seconds - private Map mRecorders = new HashMap<>(); + private Map mRecorders = new HashMap<>(); private OverflowableTransferQueue mAudioPacketQueue = new OverflowableTransferQueue<>(1000, 100); private ScheduledFuture mBufferProcessorFuture; @@ -121,15 +120,15 @@ public class RecorderManager implements Listener if(mRecorders.containsKey(identifier)) { - RealBufferWaveRecorder recorder = mRecorders.get(identifier); + AudioPacketWaveRecorder recorder = mRecorders.get(identifier); if(audioPacket.getType() == AudioPacket.Type.AUDIO) { - recorder.receive(audioPacket.getAudioBuffer()); + recorder.receive(audioPacket); } else if(audioPacket.getType() == AudioPacket.Type.END) { - RealBufferWaveRecorder finished = mRecorders.remove(identifier); + AudioPacketWaveRecorder finished = mRecorders.remove(identifier); finished.stop(); } } @@ -139,15 +138,15 @@ public class RecorderManager implements Listener { String filePrefix = getFilePrefix(audioPacket); - RealBufferWaveRecorder recorder = null; + AudioPacketWaveRecorder recorder = null; try { - recorder = new RealBufferWaveRecorder(AUDIO_SAMPLE_RATE, filePrefix); + recorder = new AudioPacketWaveRecorder(filePrefix, audioPacket.getMetadata()); recorder.start(ThreadPool.SCHEDULED); - recorder.receive(audioPacket.getAudioBuffer()); + recorder.receive(audioPacket); mRecorders.put(identifier, recorder); } catch(Exception ioe) @@ -176,11 +175,11 @@ public class RecorderManager implements Listener */ private void removeIdleRecorders() { - Iterator> it = mRecorders.entrySet().iterator(); + Iterator> it = mRecorders.entrySet().iterator(); while(it.hasNext()) { - Map.Entry entry = it.next(); + Map.Entry entry = it.next(); if(entry.getValue().getLastBufferReceived() + IDLE_RECORDER_REMOVAL_THRESHOLD < System.currentTimeMillis()) { diff --git a/src/main/java/io/github/dsheirer/record/wave/AudioPacketWaveRecorder.java b/src/main/java/io/github/dsheirer/record/wave/AudioPacketWaveRecorder.java new file mode 100644 index 00000000..10b1b95a --- /dev/null +++ b/src/main/java/io/github/dsheirer/record/wave/AudioPacketWaveRecorder.java @@ -0,0 +1,220 @@ +/* + * ********************************************************************************************************************* + * sdr-trunk + * 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 + * ********************************************************************************************************************* + */ +package io.github.dsheirer.record.wave; + +import io.github.dsheirer.audio.AudioFormats; +import io.github.dsheirer.audio.AudioPacket; +import io.github.dsheirer.channel.metadata.Metadata; +import io.github.dsheirer.module.Module; +import io.github.dsheirer.sample.ConversionUtils; +import io.github.dsheirer.sample.Listener; +import io.github.dsheirer.util.TimeStamp; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.sound.sampled.AudioFormat; +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * WAVE audio recorder module for recording audio buffers to a wave file + */ +public class AudioPacketWaveRecorder extends Module implements Listener +{ + private final static Logger mLog = LoggerFactory.getLogger(AudioPacketWaveRecorder.class); + + private WaveWriter mWriter; + private String mFilePrefix; + private Path mFile; + private AudioFormat mAudioFormat; + + private BufferProcessor mBufferProcessor; + private ScheduledFuture mProcessorHandle; + private LinkedBlockingQueue mAudioPackets = new LinkedBlockingQueue<>(500); + private Metadata mMetadata; + private long mLastBufferReceived; + + private AtomicBoolean mRunning = new AtomicBoolean(); + + public AudioPacketWaveRecorder(String filePrefix, Metadata metadata) + { + mMetadata = metadata; + mFilePrefix = filePrefix; + mAudioFormat = AudioFormats.PCM_SIGNED_8KHZ_16BITS_MONO; + } + + /** + * Indicates if the recorder is currently running. + */ + public boolean isRunning() + { + return mRunning.get(); + } + + /** + * Timestamp of when the latest buffer was received by this recorder + */ + public long getLastBufferReceived() + { + return mLastBufferReceived; + } + + public Path getFile() + { + return mFile; + } + + public void start(ScheduledExecutorService executor) + { + if(mRunning.compareAndSet(false, true)) + { + if(mBufferProcessor == null) + { + mBufferProcessor = new BufferProcessor(); + } + + try + { + StringBuilder sb = new StringBuilder(); + sb.append(mFilePrefix); + sb.append("_"); + sb.append(TimeStamp.getLongTimeStamp("_")); + sb.append(".wav"); + + mFile = Paths.get(sb.toString()); + + mWriter = new WaveWriter(mAudioFormat, mFile); + + if(mMetadata != null) + { + mWriter.writeMetadata(WaveMetadata.createFrom(mMetadata)); + } + + /* Schedule the processor to run every 500 milliseconds */ + mProcessorHandle = executor.scheduleAtFixedRate(mBufferProcessor, 0, 500, TimeUnit.MILLISECONDS); + } + catch(IOException io) + { + mLog.error("Error starting real buffer recorder", io); + } + } + } + + public void stop() + { + if(mRunning.compareAndSet(true, false)) + { + try + { + write(); + + if(mMetadata != null) + { + mWriter.writeMetadata(WaveMetadata.createFrom(mMetadata)); + mMetadata = null; + } + + if(mWriter != null) + { + mWriter.close(); + mWriter = null; + } + } + catch(IOException ioe) + { + mLog.error("Error writing final audio buffers to recording during shutdown", ioe); + } + } + } + + @Override + public void receive(AudioPacket audioPacket) + { + if(mRunning.get()) + { + boolean success = mAudioPackets.offer(audioPacket); + + if(!success) + { + mLog.error("recorder buffer overflow - purging [" + mFile.toFile().getAbsolutePath() + "]"); + mAudioPackets.clear(); + } + + mLastBufferReceived = System.currentTimeMillis(); + } + } + + @Override + public void dispose() + { + stop(); + } + + @Override + public void reset() + { + } + + /** + * Writes all audio currently in the queue to the file + * @throws IOException if there are any errors writing the audio + */ + private void write() throws IOException + { + AudioPacket audioPacket = mAudioPackets.poll(); + + while(audioPacket != null && audioPacket.getType() == AudioPacket.Type.AUDIO) + { + if(audioPacket.hasMetadata() && audioPacket.getMetadata().isUpdated()) + { + if(mMetadata == null || mMetadata != audioPacket.getMetadata()) + { + mMetadata = audioPacket.getMetadata(); + mWriter.writeMetadata(WaveMetadata.createFrom(mMetadata)); + } + } + + mWriter.writeData(ConversionUtils.convertToSigned16BitSamples(audioPacket.getAudioBuffer())); + audioPacket = mAudioPackets.poll(); + } + } + + public class BufferProcessor implements Runnable + { + public void run() + { + try + { + write(); + } + catch(IOException ioe) + { + /* Stop this module if/when we get an IO exception */ + mAudioPackets.clear(); + stop(); + + mLog.error("IO Exception while trying to write to the wave writer", ioe); + } + } + } +} diff --git a/src/main/java/io/github/dsheirer/record/wave/ComplexBufferWaveRecorder.java b/src/main/java/io/github/dsheirer/record/wave/ComplexBufferWaveRecorder.java index ef6de826..2d82af00 100644 --- a/src/main/java/io/github/dsheirer/record/wave/ComplexBufferWaveRecorder.java +++ b/src/main/java/io/github/dsheirer/record/wave/ComplexBufferWaveRecorder.java @@ -188,7 +188,7 @@ public class ComplexBufferWaveRecorder extends Module } else { - mWriter.write( ConversionUtils.convertToSigned16BitSamples( buffer ) ); + mWriter.writeData( ConversionUtils.convertToSigned16BitSamples( buffer ) ); buffer = mBuffers.poll(); } } diff --git a/src/main/java/io/github/dsheirer/record/wave/RealBufferWaveRecorder.java b/src/main/java/io/github/dsheirer/record/wave/RealBufferWaveRecorder.java index 8e6607ad..78ee73a1 100644 --- a/src/main/java/io/github/dsheirer/record/wave/RealBufferWaveRecorder.java +++ b/src/main/java/io/github/dsheirer/record/wave/RealBufferWaveRecorder.java @@ -185,7 +185,7 @@ public class RealBufferWaveRecorder extends Module while(buffer != null) { - mWriter.write(ConversionUtils.convertToSigned16BitSamples(buffer)); + mWriter.writeData(ConversionUtils.convertToSigned16BitSamples(buffer)); buffer = mBuffers.poll(); } } diff --git a/src/main/java/io/github/dsheirer/record/wave/WaveMetadata.java b/src/main/java/io/github/dsheirer/record/wave/WaveMetadata.java new file mode 100644 index 00000000..c8420337 --- /dev/null +++ b/src/main/java/io/github/dsheirer/record/wave/WaveMetadata.java @@ -0,0 +1,283 @@ +package io.github.dsheirer.record.wave; + +import io.github.dsheirer.alias.Alias; +import io.github.dsheirer.alias.PatchGroupAlias; +import io.github.dsheirer.channel.metadata.AliasedIdentifier; +import io.github.dsheirer.channel.metadata.Metadata; +import io.github.dsheirer.properties.SystemProperties; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class WaveMetadata +{ + private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyyMMddHHmmssSSS"); + private static final byte NULL_TERMINATOR = (byte)0x00; + private static final String LIST = "LIST"; + private static final String INFO = "INFO"; + private static final String COMMENT = "Recording created by sdrtrunk"; + + private Map mMetadataMap = new HashMap<>(); + + /** + * Audio Wave File - Metadata Chunk. Supports creating a map of metadata key:value pairs and + * converting the metadata to a .wav audio recording INFO chunk. + */ + public WaveMetadata() + { + } + + /** + * Creates a wave LIST chunk from the metadata tags + */ + public ByteBuffer getLISTChunk() + { + ByteBuffer metadataBuffer = getAllTags(); + int tagsLength = metadataBuffer.capacity(); + + int overallLength = tagsLength + 8; + + //Pad the overall length to make it an event 32-bit boundary + int padding = 0; + + if(overallLength % 4 != 0) + { + padding = 4 - (overallLength % 4); + } + + ByteBuffer chunk = ByteBuffer.allocate(overallLength + padding).order(ByteOrder.LITTLE_ENDIAN); + + chunk.put(LIST.getBytes()); + chunk.putInt(tagsLength + padding); + chunk.put(metadataBuffer); + + chunk.position(0); + + return chunk; + } + + /** + * Formats all metadata key:value pairs in the metadata map into a wave INFO compatible format + */ + private ByteBuffer getAllTags() + { + int length = INFO.length(); + + List buffers = new ArrayList<>(); + + //Do the non-custom metadata types first + for(Map.Entry entry: mMetadataMap.entrySet()) + { + if(!entry.getKey().isCustomType()) + { + ByteBuffer buffer = getListMetadataChunk(entry.getKey(), entry.getValue()); + length += buffer.capacity(); + buffers.add(buffer); + } + } + + //Do the custom metadata types second + for(Map.Entry entry: mMetadataMap.entrySet()) + { + if(entry.getKey().isCustomType()) + { + ByteBuffer buffer = getListMetadataChunk(entry.getKey(), entry.getValue()); + length += buffer.capacity(); + buffers.add(buffer); + } + } + + ByteBuffer joinedBuffer = ByteBuffer.allocate(length); + + joinedBuffer.put(INFO.getBytes()); + + for(ByteBuffer buffer: buffers) + { + joinedBuffer.put(buffer); + } + + joinedBuffer.position(0); + + return joinedBuffer; + } + + /** + * Converts the metadata type and value into a Wave LIST compatible byte array. + * + * @param type of metadata + * @param value for the metadata + * @return metadata and value formatted for wave chunk + */ + private ByteBuffer getListMetadataChunk(WaveMetadataType type, String value) + { + //Length is 4 bytes for tag ID, 4 bytes for length, value, and null terminator + int chunkLength = value.length() + 9; + +// //Ensure length is an even multiple of 4 bytes/32-bit word +// if(chunkLength % 4 != 0) +// { +// chunkLength += (4 - (chunkLength % 4)); +// } +// + ByteBuffer buffer = ByteBuffer.allocate(chunkLength).order(ByteOrder.LITTLE_ENDIAN); + buffer.put(type.getTag().getBytes()); + buffer.putInt(value.length() + 1); + buffer.put(value.getBytes()); + buffer.put(NULL_TERMINATOR); + + buffer.position(0); + + return buffer; + } + + /** + * Adds the metadata key:value pair to the metadata map. Note: since this is a map, any existing + * key:value will be overwritten. + * @param type identifying the metadata type + * @param value to associate with the metadata type + */ + public void add(WaveMetadataType type, String value) + { + if(value != null) + { + mMetadataMap.put(type, value); + } + } + + /** + * Creates a WAVE recording metadata chunk from the audio metadata argument + * @param audioMetadata to create the wave metadata from + * @return wave metadata instance + */ + public static WaveMetadata createFrom(Metadata audioMetadata) + { + WaveMetadata waveMetadata = new WaveMetadata(); + + waveMetadata.add(WaveMetadataType.GENRE, "TEST GENRE"); + waveMetadata.add(WaveMetadataType.PRODUCT, SystemProperties.getInstance().getApplicationName()); + waveMetadata.add(WaveMetadataType.COMMENTS, COMMENT); + waveMetadata.add(WaveMetadataType.DATE_CREATED, SDF.format(new Date(audioMetadata.getTimestamp()))); + waveMetadata.add(WaveMetadataType.ARTIST_NAME, audioMetadata.getChannelConfigurationSystem()); + waveMetadata.add(WaveMetadataType.ALBUM_TITLE, audioMetadata.getChannelConfigurationSite()); + waveMetadata.add(WaveMetadataType.TRACK_TITLE, audioMetadata.getChannelConfigurationName()); + waveMetadata.add(WaveMetadataType.SOURCE_FORM, audioMetadata.getPrimaryDecoderType().getDisplayString()); +// waveMetadata.add(WaveMetadataType.ALIAS_LIST_NAME, "not implemented yet"); + waveMetadata.add(WaveMetadataType.CHANNEL_ID, audioMetadata.getChannelFrequencyLabel()); + waveMetadata.add(WaveMetadataType.CHANNEL_FREQUENCY, String.valueOf(audioMetadata.getChannelFrequency())); + + AliasedIdentifier networkID1 = audioMetadata.getNetworkID1(); + + if(networkID1 != null) + { + waveMetadata.add(WaveMetadataType.NETWORK_ID_1, networkID1.getIdentifier()); + } + + AliasedIdentifier networkID2 = audioMetadata.getNetworkID2(); + + if(networkID2 != null) + { + waveMetadata.add(WaveMetadataType.NETWORK_ID_2, networkID2.getIdentifier()); + } + + AliasedIdentifier primaryFrom = audioMetadata.getPrimaryAddressFrom(); + + if(primaryFrom != null) + { + waveMetadata.add(WaveMetadataType.TALKGROUP_PRIMARY_FROM, primaryFrom.getIdentifier()); + + Alias alias = primaryFrom.getAlias(); + + if(alias != null) + { + waveMetadata.add(WaveMetadataType.TALKGROUP_PRIMARY_FROM_ALIAS, alias.getName()); + waveMetadata.add(WaveMetadataType.TALKGROUP_PRIMARY_FROM_ICON, alias.getIconName()); + } + } + + AliasedIdentifier primaryTo = audioMetadata.getPrimaryAddressTo(); + + if(primaryTo != null) + { + waveMetadata.add(WaveMetadataType.TALKGROUP_PRIMARY_TO, primaryTo.getIdentifier()); + + Alias alias = primaryTo.getAlias(); + + if(alias != null) + { + waveMetadata.add(WaveMetadataType.TALKGROUP_PRIMARY_TO_ALIAS, alias.getName()); + waveMetadata.add(WaveMetadataType.TALKGROUP_PRIMARY_TO_ICON, alias.getIconName()); + + if(alias instanceof PatchGroupAlias) + { + PatchGroupAlias patchGroupAlias = (PatchGroupAlias)alias; + + List patchedAliases = patchGroupAlias.getPatchedAliases(); + + if(patchedAliases.size() >= 1) + { + Alias patch = patchedAliases.get(0); + waveMetadata.add(WaveMetadataType.TALKGROUP_PRIMARY_PATCHED_1, patch.getName()); + } + if(patchedAliases.size() >= 2) + { + Alias patch = patchedAliases.get(1); + waveMetadata.add(WaveMetadataType.TALKGROUP_PRIMARY_PATCHED_2, patch.getName()); + } + if(patchedAliases.size() >= 3) + { + Alias patch = patchedAliases.get(2); + waveMetadata.add(WaveMetadataType.TALKGROUP_PRIMARY_PATCHED_3, patch.getName()); + } + if(patchedAliases.size() >= 4) + { + Alias patch = patchedAliases.get(3); + waveMetadata.add(WaveMetadataType.TALKGROUP_PRIMARY_PATCHED_4, patch.getName()); + } + if(patchedAliases.size() >= 5) + { + Alias patch = patchedAliases.get(4); + waveMetadata.add(WaveMetadataType.TALKGROUP_PRIMARY_PATCHED_5, patch.getName()); + } + } + } + } + + AliasedIdentifier secondaryFrom = audioMetadata.getSecondaryAddressFrom(); + + if(secondaryFrom != null) + { + waveMetadata.add(WaveMetadataType.TALKGROUP_SECONDARY_FROM, secondaryFrom.getIdentifier()); + + Alias alias = secondaryFrom.getAlias(); + + if(alias != null) + { + waveMetadata.add(WaveMetadataType.TALKGROUP_SECONDARY_FROM_ALIAS, alias.getName()); + waveMetadata.add(WaveMetadataType.TALKGROUP_SECONDARY_FROM_ICON, alias.getIconName()); + } + } + + AliasedIdentifier secondaryTo = audioMetadata.getSecondaryAddressTo(); + + if(secondaryTo != null) + { + waveMetadata.add(WaveMetadataType.TALKGROUP_SECONDARY_TO, secondaryTo.getIdentifier()); + + Alias alias = secondaryTo.getAlias(); + + if(alias != null) + { + waveMetadata.add(WaveMetadataType.TALKGROUP_SECONDARY_TO_ALIAS, alias.getName()); + waveMetadata.add(WaveMetadataType.TALKGROUP_SECONDARY_TO_ICON, alias.getIconName()); + } + } + + return waveMetadata; + } +} diff --git a/src/main/java/io/github/dsheirer/record/wave/WaveMetadataType.java b/src/main/java/io/github/dsheirer/record/wave/WaveMetadataType.java new file mode 100644 index 00000000..0af8dc00 --- /dev/null +++ b/src/main/java/io/github/dsheirer/record/wave/WaveMetadataType.java @@ -0,0 +1,79 @@ +/* + * ********************************************************************************************************************* + * sdr-trunk + * 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 + * ********************************************************************************************************************* + */ + +package io.github.dsheirer.record.wave; + +/** + * WAVE audio metadata tags. + * + * INFO tags are detailed here: https://sno.phy.queensu.ca/~phil/exiftool/TagNames/RIFF.html#Info + */ +public enum WaveMetadataType +{ + ARTIST_NAME("IART", false), //System + ALBUM_TITLE("TALB", false), //Site + TRACK_TITLE("INAM", false), //Channel Name + COMMENTS("ICMT", false), + DATE_CREATED("ICRD", false), + GENRE("IGNR", false), //Site + PRODUCT("IPRD", false), //sdrtrunk + SOURCE_FORM("ISRF", false), //protocol + + //Custom Tags + ALIAS_LIST_NAME("ALLN", true), + CHANNEL_FREQUENCY("CHFQ", true), + CHANNEL_ID("CHID", true), + CHANNEL_TIMESLOT("CHTS", true), + NETWORK_ID_1("NTW1", true), + NETWORK_ID_2("NTW2", true), + TALKGROUP_PRIMARY_PATCHED_1("TPP1", true), + TALKGROUP_PRIMARY_PATCHED_2("TPP2", true), + TALKGROUP_PRIMARY_PATCHED_3("TPP3", true), + TALKGROUP_PRIMARY_PATCHED_4("TPP4", true), + TALKGROUP_PRIMARY_PATCHED_5("TPP5", true), + TALKGROUP_PRIMARY_FROM("TPFM", true), + TALKGROUP_PRIMARY_FROM_ALIAS("TPFA", true), + TALKGROUP_PRIMARY_FROM_ICON("TPFI", true), + TALKGROUP_PRIMARY_TO("TPTO", true), + TALKGROUP_PRIMARY_TO_ALIAS("TPTA", true), + TALKGROUP_PRIMARY_TO_ICON("TPTI", true), + TALKGROUP_SECONDARY_FROM("TSFM", true), + TALKGROUP_SECONDARY_FROM_ALIAS("TSFA", true), + TALKGROUP_SECONDARY_FROM_ICON("TSFI", true), + TALKGROUP_SECONDARY_TO("TSTO", true), + TALKGROUP_SECONDARY_TO_ALIAS("TSTA", true), + TALKGROUP_SECONDARY_TO_ICON("TSTI", true); + + private String mTag; + private boolean mCustomType; + + WaveMetadataType(String tag, boolean customType) + { + mTag = tag; + mCustomType = customType; + } + + public String getTag() + { + return mTag; + } + + public boolean isCustomType() + { + return mCustomType; + } +} diff --git a/src/main/java/io/github/dsheirer/record/wave/WaveUtils.java b/src/main/java/io/github/dsheirer/record/wave/WaveUtils.java index 0977aa0d..8b703257 100644 --- a/src/main/java/io/github/dsheirer/record/wave/WaveUtils.java +++ b/src/main/java/io/github/dsheirer/record/wave/WaveUtils.java @@ -1,20 +1,19 @@ -/******************************************************************************* - * SDR Trunk - * Copyright (C) 2014 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 - ******************************************************************************/ +/* + * ********************************************************************************************************************* + * sdr-trunk + * 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 + * ********************************************************************************************************************* + */ package io.github.dsheirer.record.wave; import javax.sound.sampled.AudioFormat; @@ -24,90 +23,125 @@ import java.nio.ByteOrder; public class WaveUtils { - public static final byte[] RIFF_CHUNK = {(byte)0x52, (byte)0x49, (byte)0x46, (byte)0x46}; //RIFF - public static final byte[] WAV_FORMAT = {(byte)0x57, (byte)0x41, (byte)0x56, (byte)0x45}; //WAVE - public static final byte[] CHUNK_FORMAT = {(byte)0x66, (byte)0x6D, (byte)0x74, (byte)0x20}; //fmt space - public static final byte[] CHUNK_DATA = {(byte)0x64, (byte)0x61, (byte)0x74, (byte)0x61}; //data + public static final String RIFF_ID = "RIFF"; + public static final String WAVE_ID = "WAVE"; + public static final int WAVE_HEADER_SIZE = 54; //Includes the 8 byte data chunk header + + public static final String FORMAT_CHUNK = "fmt "; + public static final int FORMAT_CHUNK_SIZE = 16; + public static final short FORMAT_UNCOMPRESSED_PCM = 1; + + public static final String DATA_CHUNK = "data"; + + public static final byte[] RIFF_CHUNK = {(byte)0x52, (byte)0x49, (byte)0x46, (byte)0x46}; //RIFF + public static final byte[] WAV_FORMAT = {(byte)0x57, (byte)0x41, (byte)0x56, (byte)0x45}; //WAVE + public static final byte[] CHUNK_FORMAT = {(byte)0x66, (byte)0x6D, (byte)0x74, (byte)0x20}; //fmt space + public static final byte[] CHUNK_DATA = {(byte)0x64, (byte)0x61, (byte)0x74, (byte)0x61}; //data public static final int PCM_FORMAT = 1; - /** - * Creates a wave file header with the RIFF chunk descriptor and the WAVE - * format chunk 1 and chunk 2 header - */ - public static ByteBuffer getWaveHeader( AudioFormat format ) - { - ByteBuffer descriptor = ByteBuffer.allocate( 44 ); - descriptor.order( ByteOrder.LITTLE_ENDIAN ); + /** + * Creates a wave file header with the RIFF chunk descriptor and the WAVE + * format chunk 1 and chunk 2 header + */ + public static ByteBuffer getWaveHeader(AudioFormat format) + { + ByteBuffer descriptor2 = ByteBuffer.allocate(36).order(ByteOrder.LITTLE_ENDIAN); + descriptor2.put(RIFF_ID.getBytes()); + descriptor2.putInt(WAVE_HEADER_SIZE); + descriptor2.put(WAVE_ID.getBytes()); - /* Chunk ID = RIFF */ - descriptor.put( (byte)0x52 ); //R - descriptor.put( (byte)0x49 ); //I - descriptor.put( (byte)0x46 ); //F - descriptor.put( (byte)0x46 ); //F + //Chunk #1 - format descriptor + descriptor2.put(FORMAT_CHUNK.getBytes()); + descriptor2.putInt(FORMAT_CHUNK_SIZE); + descriptor2.putShort(FORMAT_UNCOMPRESSED_PCM); + descriptor2.putShort((short)format.getChannels()); + descriptor2.putInt((int)format.getSampleRate()); - /* Chunk Size = 36 bytes (initial empty size) */ - descriptor.put( (byte)36 ); - descriptor.put( (byte)0 ); - descriptor.put( (byte)0 ); - descriptor.put( (byte)0 ); + int frameByteRate2 = format.getChannels() * format.getSampleSizeInBits() / 8; - /* Format = WAVE */ - descriptor.put( (byte)0x57 ); //W - descriptor.put( (byte)0x41 ); //A - descriptor.put( (byte)0x56 ); //V - descriptor.put( (byte)0x45 ); //E + /* Byte Rate = sample rate * channels * bits per sample / 8 */ + int byteRate2 = (int)(format.getSampleRate() * frameByteRate2); + descriptor2.putInt(byteRate2); - /* Sub Chunk 1 ID */ - descriptor.put( (byte)0x66 ); //f - descriptor.put( (byte)0x6D ); //m - descriptor.put( (byte)0x74 ); //t - descriptor.put( (byte)0x20 ); //space + /* Block Align */ + descriptor2.putShort((short)frameByteRate2); - /* SubChunk1Size = 1 */ - descriptor.put( (byte)0x10 ); - descriptor.put( (byte)0x0 ); - descriptor.put( (byte)0x0 ); - descriptor.put( (byte)0x0 ); + /* Bits per Sample */ + descriptor2.putShort((short)format.getSampleSizeInBits()); +//Finished with the header here - /* Audio format = 1 (Uncompressed PCM) */ - descriptor.put( (byte)0x1 ); - descriptor.put( (byte)0x0 ); - - /* Number of Channels */ - descriptor.asShortBuffer().put( (short)format.getChannels() ); - descriptor.position( descriptor.position() + 2 ); + ByteBuffer descriptor = ByteBuffer.allocate(44); + descriptor.order(ByteOrder.LITTLE_ENDIAN); - /* Sample Rate - assumes integral sample rate */ - descriptor.asIntBuffer().put( (int)format.getSampleRate() ); - descriptor.position( descriptor.position() + 4 ); + /* Chunk ID = RIFF */ + descriptor.put((byte)0x52); //R + descriptor.put((byte)0x49); //I + descriptor.put((byte)0x46); //F + descriptor.put((byte)0x46); //F - int frameByteRate = format.getChannels() * format.getSampleSizeInBits() / 8; - - /* Byte Rate = sample rate * channels * bits per sample / 8 */ - int byteRate = (int)( format.getSampleRate() * frameByteRate ); - descriptor.asIntBuffer().put( byteRate ); - descriptor.position( descriptor.position() + 4 ); + /* Chunk Size = 36 bytes (initial empty size) */ + descriptor.put((byte)36); + descriptor.put((byte)0); + descriptor.put((byte)0); + descriptor.put((byte)0); - /* Block Align */ - descriptor.asShortBuffer().put( (short)frameByteRate ); - descriptor.position( descriptor.position() + 2 ); + /* Format = WAVE */ + descriptor.put((byte)0x57); //W + descriptor.put((byte)0x41); //A + descriptor.put((byte)0x56); //V + descriptor.put((byte)0x45); //E - /* Bits per Sample */ - descriptor.asShortBuffer().put( (short)( format.getSampleSizeInBits() ) ); - descriptor.position( descriptor.position() + 2 ); - - /* Sub Chunk 2 ID */ - descriptor.put( (byte)0x64 ); //d - descriptor.put( (byte)0x61 ); //a - descriptor.put( (byte)0x74 ); //t - descriptor.put( (byte)0x61 ); //a + /* Sub Chunk 1 ID */ + descriptor.put((byte)0x66); //f + descriptor.put((byte)0x6D); //m + descriptor.put((byte)0x74); //t + descriptor.put((byte)0x20); //space - /* Sub Chunk 2 Size */ - descriptor.put( (byte)0x0 ); - descriptor.put( (byte)0x0 ); - descriptor.put( (byte)0x0 ); - descriptor.put( (byte)0x0 ); - - return descriptor; - } + /* SubChunk1Size = 1 */ + descriptor.put((byte)0x10); + descriptor.put((byte)0x0); + descriptor.put((byte)0x0); + descriptor.put((byte)0x0); + + /* Audio format = 1 (Uncompressed PCM) */ + descriptor.put((byte)0x1); + descriptor.put((byte)0x0); + + /* Number of Channels */ + descriptor.asShortBuffer().put((short)format.getChannels()); + descriptor.position(descriptor.position() + 2); + + /* Sample Rate - assumes integral sample rate */ + descriptor.asIntBuffer().put((int)format.getSampleRate()); + descriptor.position(descriptor.position() + 4); + + int frameByteRate = format.getChannels() * format.getSampleSizeInBits() / 8; + + /* Byte Rate = sample rate * channels * bits per sample / 8 */ + int byteRate = (int)(format.getSampleRate() * frameByteRate); + descriptor.asIntBuffer().put(byteRate); + descriptor.position(descriptor.position() + 4); + + /* Block Align */ + descriptor.asShortBuffer().put((short)frameByteRate); + descriptor.position(descriptor.position() + 2); + + /* Bits per Sample */ + descriptor.asShortBuffer().put((short)(format.getSampleSizeInBits())); + descriptor.position(descriptor.position() + 2); + + /* Sub Chunk 2 ID */ + descriptor.put((byte)0x64); //d + descriptor.put((byte)0x61); //a + descriptor.put((byte)0x74); //t + descriptor.put((byte)0x61); //a + + /* Sub Chunk 2 Size */ + descriptor.put((byte)0x0); + descriptor.put((byte)0x0); + descriptor.put((byte)0x0); + descriptor.put((byte)0x0); + + return descriptor; + } } diff --git a/src/main/java/io/github/dsheirer/record/wave/WaveWriter.java b/src/main/java/io/github/dsheirer/record/wave/WaveWriter.java index f062788d..c8c78d82 100644 --- a/src/main/java/io/github/dsheirer/record/wave/WaveWriter.java +++ b/src/main/java/io/github/dsheirer/record/wave/WaveWriter.java @@ -23,6 +23,7 @@ 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; @@ -32,253 +33,415 @@ import java.util.Arrays; import java.util.regex.Matcher; import java.util.regex.Pattern; -public class WaveWriter implements AutoCloseable +public class WaveWriter implements AutoCloseable { - 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; - - /** - * 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 ) ); + public static final String RIFF_ID = "RIFF"; + public static final int INITIAL_TOTAL_LENGTH = 4; + public static final String WAVE_ID = "WAVE"; - ByteBuffer header = WaveUtils.getWaveHeader( mAudioFormat ); - - header.flip(); + public static final String FORMAT_CHUNK_ID = "fmt "; + public static final int FORMAT_CHUNK_LENGTH = 16; + public static final short FORMAT_UNCOMPRESSED_PCM = 1; - while( header.hasRemaining() ) - { - mFileChannel.write( header ); - } - } + public static final String DATA_CHUNK_ID = "data"; - /** - * Closes the file - */ - public void close() throws IOException - { - mFileChannel.force( true ); - mFileChannel.close(); - mFileChannel = null; - } + 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.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 write( ByteBuffer buffer ) throws IOException - { - buffer.position( 0 ); + * 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); - /* Write the full buffer if there is room, respecting the max file size */ - if( mFileChannel.size() + buffer.capacity() < mMaxSize ) - { - while( buffer.hasRemaining() ) - { - mFileChannel.write( buffer ); - } - - updateWaveFileSize(); - } - 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() ); + openDataChunk(); - /* 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 ) ); + /* 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); + } - ByteBuffer next = ByteBuffer.wrap( Arrays.copyOfRange( bytes, - remaining, bytes.length ) ); + 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()); - while( current.hasRemaining() ) - { - mFileChannel.write( current ); - } - - updateWaveFileSize(); - - rollover(); + /* Ensure we write full frames to fill up the remaining size */ + remaining -= (int)(remaining % mAudioFormat.getFrameSize()); - while( next.hasRemaining() ) - { - mFileChannel.write( next ); - } - - updateWaveFileSize(); - } - } + byte[] bytes = buffer.array(); - /** - * 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 - { - close(); - - mFileRolloverCounter++; - - updateFileName(); - - open(); - } - - /** - * Updates the overall and the chunk2 sizes - */ - private void updateWaveFileSize() throws IOException - { - /* Update overall wave size (total size - 8 bytes) */ - ByteBuffer buffer = getUnsignedIntegerBuffer( mFileChannel.size() - 8 ); + ByteBuffer current = ByteBuffer.wrap(Arrays.copyOf(bytes, remaining)); - mFileChannel.write( buffer, 4 ); + ByteBuffer next = ByteBuffer.wrap(Arrays.copyOfRange(bytes, + remaining, bytes.length)); - ByteBuffer buffer2 = getUnsignedIntegerBuffer( mFileChannel.size() - 44 ); - - mFileChannel.write( buffer2, 40 ); - } + while(current.hasRemaining()) + { + mDataChunkSize += mFileChannel.write(current); + } - /** - * 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(); + updateTotalSize(); + updateDataChunkSize(); - if( mFileRolloverCounter == 2 ) - { - filename = filename.replace( ".wav", "_2.wav" ); - } - else - { - Matcher m = FILENAME_PATTERN.matcher( filename ); + rollover(); - 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 ); - } + 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 metadataChunk = metadata.getLISTChunk(); + + if(mFileChannel.size() + metadataChunk.capacity() >= mMaxSize) + { + throw new IOException("Cannot write metadata chunk - insufficient file space remaining"); + } + + closeDataChunk(); + + metadataChunk.position(0); + + while(metadataChunk.hasRemaining()) + { + mFileChannel.write(metadataChunk); + } + + 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; + } }