From 188a521770df00a3b1c615b0cadcacd423402c21 Mon Sep 17 00:00:00 2001 From: Denny Date: Mon, 25 Dec 2017 07:08:55 -0500 Subject: [PATCH] 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);