From 6e352143d2639f43527e7752fd89853b790936df Mon Sep 17 00:00:00 2001 From: Denny Date: Sun, 24 Dec 2017 06:51:09 -0500 Subject: [PATCH 1/2] 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; + } } From 188a521770df00a3b1c615b0cadcacd423402c21 Mon Sep 17 00:00:00 2001 From: Denny Date: Mon, 25 Dec 2017 07:08:55 -0500 Subject: [PATCH 2/2] Adds wave file ID3 metadata chunk to audio recordings. Updates wave writer to add the metadata LIST and ID3 chunks to the end of the recording so that the file contains the final/complete set of metadata that was accumulated over the call lifecycle. --- .../record/wave/AudioPacketWaveRecorder.java | 30 ++- .../dsheirer/record/wave/WaveMetadata.java | 207 +++++++++++++++--- .../record/wave/WaveMetadataType.java | 110 ++++++---- .../dsheirer/record/wave/WaveWriter.java | 31 ++- 4 files changed, 280 insertions(+), 98 deletions(-) diff --git a/src/main/java/io/github/dsheirer/record/wave/AudioPacketWaveRecorder.java b/src/main/java/io/github/dsheirer/record/wave/AudioPacketWaveRecorder.java index 10b1b95a..ab3d806a 100644 --- a/src/main/java/io/github/dsheirer/record/wave/AudioPacketWaveRecorder.java +++ b/src/main/java/io/github/dsheirer/record/wave/AudioPacketWaveRecorder.java @@ -56,6 +56,11 @@ public class AudioPacketWaveRecorder extends Module implements Listener 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. + * converting the metadata to WAV audio recording INFO/LIST and ID3 v2.4.0 metadata chunks. */ public WaveMetadata() { } + public ByteBuffer getID3Chunk() + { + ByteBuffer subChunks = getID3Frames(); + int subChunksLength = subChunks.capacity(); + + int overallLength = subChunksLength + 18; + + //Pad the overall length to make it an even 32-bit boundary + int padding = 0; + + if(overallLength % 4 != 0) + { + padding = 4 - (overallLength % 4); + } + + ByteBuffer chunk = ByteBuffer.allocate(overallLength + padding).order(ByteOrder.LITTLE_ENDIAN); + + int chunkLength = subChunksLength + 10 + padding; + + chunk.put(ID3_CHUNK_IDENTIFIER.getBytes()); + chunk.putInt(chunkLength); + chunk.put(ID3V2_IDENTIFIER.getBytes()); + chunk.put(ID3V2_MAJOR_VERSION); + chunk.put(ID3V2_MINOR_VERSION); + chunk.put(ID3V2_FLAGS); + chunk.put(getID3EncodedLength(subChunksLength)); + + chunk.put(subChunks); + + chunk.position(0); + + return chunk; + } + + /** + * Creates an ID3 v2.4.0 compatible frame set from the metadata + */ + public ByteBuffer getID3Frames() + { + int length = 0; + List buffers = new ArrayList<>(); + + //List the primary metadata tags first + for(Map.Entry entry: mMetadataMap.entrySet()) + { + if(entry.getKey().isPrimaryTag()) + { + ByteBuffer buffer = getID3Frame(entry.getKey(), entry.getValue()); + + if(buffer != null) + { + length += buffer.capacity(); + buffers.add(buffer); + } + } + } + + for(Map.Entry entry: mMetadataMap.entrySet()) + { + if(!entry.getKey().isPrimaryTag()) + { + ByteBuffer buffer = getID3Frame(entry.getKey(), entry.getValue()); + + if(buffer != null) + { + length += buffer.capacity(); + buffers.add(buffer); + } + } + } + + ByteBuffer concatenatedBuffer = ByteBuffer.allocate(length); + + for(ByteBuffer buffer: buffers) + { + concatenatedBuffer.put(buffer); + } + + concatenatedBuffer.position(0); + + return concatenatedBuffer; + } + + /** + * Creates an ID3 v2.4.0 compatible metadata frame + * @param type of metadata + * @param value of the metadata + * @return frame byte buffer or null if the value is empty or null + */ + public ByteBuffer getID3Frame(WaveMetadataType type, String value) + { + if(value != null && !value.isEmpty()) + { + ByteBuffer encodedValue = ISO_8859_1.encode(value); + + //Length is 4 bytes for tag ID, 4 bytes for length, value, and null terminator + int chunkLength = encodedValue.capacity() + 11; + + ByteBuffer buffer = ByteBuffer.allocate(chunkLength).order(ByteOrder.LITTLE_ENDIAN); + buffer.put(type.getID3Tag().getBytes()); + buffer.put(getID3EncodedLength(encodedValue.capacity() + 1)); + buffer.put(ID3V2_FLAGS); + buffer.put(ID3V2_FLAGS); + buffer.put(ID3V2_ISO_8859_1_ENCODING); + buffer.put(encodedValue); + + buffer.position(0); + + return buffer; + } + + return null; + } + /** * Creates a wave LIST chunk from the metadata tags */ public ByteBuffer getLISTChunk() { - ByteBuffer metadataBuffer = getAllTags(); + ByteBuffer metadataBuffer = getLISTSubChunks(); int tagsLength = metadataBuffer.capacity(); int overallLength = tagsLength + 8; @@ -53,7 +175,7 @@ public class WaveMetadata ByteBuffer chunk = ByteBuffer.allocate(overallLength + padding).order(ByteOrder.LITTLE_ENDIAN); - chunk.put(LIST.getBytes()); + chunk.put(LIST_CHUNK_IDENTIFIER.getBytes()); chunk.putInt(tagsLength + padding); chunk.put(metadataBuffer); @@ -65,29 +187,28 @@ public class WaveMetadata /** * Formats all metadata key:value pairs in the metadata map into a wave INFO compatible format */ - private ByteBuffer getAllTags() + private ByteBuffer getLISTSubChunks() { - int length = INFO.length(); + int length = INFO_TYPE_IDENTIFIER.length(); List buffers = new ArrayList<>(); - //Do the non-custom metadata types first + //Add the primary tags first for(Map.Entry entry: mMetadataMap.entrySet()) { - if(!entry.getKey().isCustomType()) + if(entry.getKey().isPrimaryTag()) { - ByteBuffer buffer = getListMetadataChunk(entry.getKey(), entry.getValue()); + ByteBuffer buffer = getLISTSubChunk(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()) + if(!entry.getKey().isPrimaryTag()) { - ByteBuffer buffer = getListMetadataChunk(entry.getKey(), entry.getValue()); + ByteBuffer buffer = getLISTSubChunk(entry.getKey(), entry.getValue()); length += buffer.capacity(); buffers.add(buffer); } @@ -95,7 +216,7 @@ public class WaveMetadata ByteBuffer joinedBuffer = ByteBuffer.allocate(length); - joinedBuffer.put(INFO.getBytes()); + joinedBuffer.put(INFO_TYPE_IDENTIFIER.getBytes()); for(ByteBuffer buffer: buffers) { @@ -114,26 +235,26 @@ public class WaveMetadata * @param value for the metadata * @return metadata and value formatted for wave chunk */ - private ByteBuffer getListMetadataChunk(WaveMetadataType type, String value) + private ByteBuffer getLISTSubChunk(WaveMetadataType type, String value) { - //Length is 4 bytes for tag ID, 4 bytes for length, value, and null terminator - int chunkLength = value.length() + 9; + if(value != null && !value.isEmpty()) + { + //Length is 4 bytes for tag ID, 4 bytes for length, value, and null terminator + ByteBuffer encodedValue = UTF_8.encode(value); + int chunkLength = encodedValue.capacity() + 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); + ByteBuffer buffer = ByteBuffer.allocate(chunkLength).order(ByteOrder.LITTLE_ENDIAN); + buffer.put(type.getLISTTag().getBytes()); + buffer.putInt(encodedValue.capacity() + 1); + buffer.put(encodedValue); + buffer.put(NULL_TERMINATOR); - buffer.position(0); + buffer.position(0); - return buffer; + return buffer; + } + + return null; } /** @@ -159,15 +280,12 @@ public class WaveMetadata { 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.SOFTWARE, SystemProperties.getInstance().getApplicationName()); 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.COMMENTS, audioMetadata.getPrimaryDecoderType().getDisplayString()); waveMetadata.add(WaveMetadataType.CHANNEL_ID, audioMetadata.getChannelFrequencyLabel()); waveMetadata.add(WaveMetadataType.CHANNEL_FREQUENCY, String.valueOf(audioMetadata.getChannelFrequency())); @@ -280,4 +398,21 @@ public class WaveMetadata return waveMetadata; } + + /** + * Converts the integer length to an ID3 compatible length field where each byte only uses the 7 least significant + * bits for a maximum of 28 bits used out of the 32-bit representation. + * @param length + * @return + */ + public static byte[] getID3EncodedLength(int length) + { + byte[] value = new byte[4]; + value[0] = (byte)((length >> 21) & 0x7F); + value[1] = (byte)((length >> 14) & 0x7F); + value[2] = (byte)((length >> 7) & 0x7F); + value[3] = (byte)(length & 0x7F); + + return value; + } } diff --git a/src/main/java/io/github/dsheirer/record/wave/WaveMetadataType.java b/src/main/java/io/github/dsheirer/record/wave/WaveMetadataType.java index 0af8dc00..666e4455 100644 --- a/src/main/java/io/github/dsheirer/record/wave/WaveMetadataType.java +++ b/src/main/java/io/github/dsheirer/record/wave/WaveMetadataType.java @@ -20,60 +20,86 @@ 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 + * Metadata tag details: + * LIST: https://sno.phy.queensu.ca/~phil/exiftool/TagNames/RIFF.html#Info + * ID3: http://id3.org/id3v2.3.0 + * */ 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 + //Primary tags + ARTIST_NAME("TPE1", "IART", true), //System + ALBUM_TITLE("TALB", "IPRD", true), //Site + TRACK_TITLE("TIT2", "INAM", true), //Channel Name + COMMENTS("COMM", "ICMT", true), + DATE_CREATED("TDRC", "ICRD", true), + GENRE("TCON", "IGNR", true), + SOFTWARE("TSSE", "ISFT", true), //sdrtrunk application name + SOURCE_FORM("TMED", "ISRF", true), //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); + //Secondary tags + ALIAS_LIST_NAME("TALN", "ALLN", false), + CHANNEL_FREQUENCY("TCHF", "CHFQ", false), + CHANNEL_ID("TCHI", "CHID", false), + CHANNEL_TIMESLOT("TCHT", "CHTS", false), + NETWORK_ID_1("TNT1", "NTW1", false), + NETWORK_ID_2("TNT2", "NTW2", false), + TALKGROUP_PRIMARY_PATCHED_1("TPP1", "TPP1", false), + TALKGROUP_PRIMARY_PATCHED_2("TPP2", "TPP2", false), + TALKGROUP_PRIMARY_PATCHED_3("TPP3", "TPP3", false), + TALKGROUP_PRIMARY_PATCHED_4("TPP4", "TPP4", false), + TALKGROUP_PRIMARY_PATCHED_5("TPP5", "TPP5", false), + TALKGROUP_PRIMARY_FROM("TPFM", "TPFM", false), + TALKGROUP_PRIMARY_FROM_ALIAS("TPFA", "TPFA", false), + TALKGROUP_PRIMARY_FROM_ICON("TPFI", "TPFI", false), + TALKGROUP_PRIMARY_TO("TPTO", "TPTO", false), + TALKGROUP_PRIMARY_TO_ALIAS("TOTA", "TPTA", false), + TALKGROUP_PRIMARY_TO_ICON("TPTI", "TPTI", false), + TALKGROUP_SECONDARY_FROM("TSFM", "TSFM", false), + TALKGROUP_SECONDARY_FROM_ALIAS("TSFA", "TSFA", false), + TALKGROUP_SECONDARY_FROM_ICON("TSFI", "TSFI", false), + TALKGROUP_SECONDARY_TO("TSTO", "TSTO", false), + TALKGROUP_SECONDARY_TO_ALIAS("TSTA", "TSTA", false), + TALKGROUP_SECONDARY_TO_ICON("TSTI", "TSTI", false); - private String mTag; - private boolean mCustomType; + private String mID3Tag; + private String mLISTTag; + private boolean mPrimaryTag; - WaveMetadataType(String tag, boolean customType) + /** + * Wave file LIST and ID3 chunk metadata tag types + * @param id3Tag used in the ID3 chunk + * @param listTag used in the LIST chunk + * @param PrimaryTag indicates if this is a custom LIST tag (true) or standard LIST tag (false) + */ + WaveMetadataType(String id3Tag, String listTag, boolean PrimaryTag) { - mTag = tag; - mCustomType = customType; + mID3Tag = id3Tag; + mLISTTag = listTag; + mPrimaryTag = PrimaryTag; } - public String getTag() + /** + * Tag to use when this metadata type is included in a WAV ID3 chunk + */ + public String getID3Tag() { - return mTag; + return mID3Tag; } - public boolean isCustomType() + /** + * Tag to use when this metadata type is included in a WAV INFO/LIST chunk + */ + public String getLISTTag() { - return mCustomType; + return mLISTTag; + } + + /** + * Indicates if this is a primary tag that should be listed first, before the secondary tags + */ + public boolean isPrimaryTag() + { + return mPrimaryTag; } } 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 c8c78d82..de251556 100644 --- a/src/main/java/io/github/dsheirer/record/wave/WaveWriter.java +++ b/src/main/java/io/github/dsheirer/record/wave/WaveWriter.java @@ -256,20 +256,36 @@ public class WaveWriter implements AutoCloseable */ public void writeMetadata(WaveMetadata metadata) throws IOException { - ByteBuffer metadataChunk = metadata.getLISTChunk(); + ByteBuffer listChunk = metadata.getLISTChunk(); - if(mFileChannel.size() + metadataChunk.capacity() >= mMaxSize) + if(mFileChannel.size() + listChunk.capacity() >= mMaxSize) { - throw new IOException("Cannot write metadata chunk - insufficient file space remaining"); + throw new IOException("Cannot write LIST metadata chunk - insufficient file space remaining"); } closeDataChunk(); - metadataChunk.position(0); + listChunk.position(0); - while(metadataChunk.hasRemaining()) + while(listChunk.hasRemaining()) { - mFileChannel.write(metadataChunk); + 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(); @@ -332,8 +348,7 @@ public class WaveWriter implements AutoCloseable /* 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.put((byte)Long.rotateRight(Long.rotateRight(size & 0xFF000000l, 16), 8)); buffer.position(0);