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);