The following class is used in an application to record internet radio streams*. These streams consist of big chunks of audio data with smaller chunks of metadata between them. By monitoring the metadata, one can determine not only what song is playing at the moment but also when the song changes.
*AFAIK this is legal for personal use, at least here. This isn't the first program to do this kind of thing, see e.g. StreamWriter.
This, of course, is just theory as the metadata never changes exactly when the song changes. Sometimes, the metadata is changed way after the song has already changed and sometimes it changes way before. The recorder needs to compensate for that so that the recorded songs aren't cut off. On top of that, if the metadata doesn't change, some streams send 0-length metadata instead of repeating the data, probably to save bandwidth.
Here's the solution I came up with. Getting it to work was a massive headache, as the comment at the top of the file suggests. While I do know how to work with it, what the concept behind it is and that it behaves as I want it to, I wouldn't be able to explain how exactly this code achieves this.
/// BufferQueue.java
import java.util.ArrayList;
// setup = volatile, use = robust
// implementation = magic
public class BufferQueue {
ArrayList<byte[]> buffers = new ArrayList<>();
ArrayList<SongMeta> meta = new ArrayList<>();
private SongMeta lastNonEmptyMeta = SongMeta.NO_META;
// compensate for the meta not changing at the same time as the song
// offsetN = 0/offsetP = 0 --> file is saved as soon as the metadata changes
// offsetN is increased by the user if the start of the song is cut off
// --> "save more of the data that was recorded before the metadata changed", "offset negative in time relative to when the meta changed"
// offsetP is increased by the user if the end of the song is cut off
// --> "save more of the data after the metadata changed", "offset positiv in time relative to when the meta changed"
// these come in validated from the GUI
private int offsetN, offsetP;
private int size;
public BufferQueue(int offsN, int offsP) {
offsetN = offsN;
offsetP = offsP;
size = offsetN + offsetP + 2;
for (int i = 0; i < size; i++) {
buffers.add(new byte[] {});
meta.add(SongMeta.NO_META);
}
}
public void pushBuffer(byte[] buf) {
buffers.add(0, buf);
buffers.remove(size);
}
public void pushMeta(SongMeta sMeta) {
if (sMeta.equals(SongMeta.NO_META)) {
meta.add(0, lastNonEmptyMeta);
} else {
meta.add(0, sMeta);
lastNonEmptyMeta = sMeta;
}
meta.remove(size);
}
public byte[] getRelevantBuffer() {
return buffers.get(offsetP);
}
public SongMeta getPrevMeta() {
return meta.get(offsetP + 1);
}
public SongMeta getCurrMeta() {
return meta.get(offsetP);
}
public int[] getFinalIdxes() {
int[] i = new int[offsetP];
for (int pos = offsetP - 1, idx = 0; pos >= 0; pos--, idx++) {
i[idx] = pos;
}
return i;
}
public int[] getInitIdxes() {
int[] i = new int[offsetN + 1];
for (int pos = size - 2, idx = 0; pos >= offsetP; pos--, idx++) {
i[idx] = pos;
}
return i;
}
public byte[] getBuffer(int idx) {
return buffers.get(idx);
}
public int size() {
return this.size;
}
}
Here's the SongMeta
record that I'm using to hold the parsed metadata.
/// SongMeta.java
public record SongMeta(String artist, String track) {
// used only in the actual program
public static final SongMeta NO_META = new SongMeta("No Info", "No Info");
public static final SongMeta AD = new SongMeta("Ad", "Ad");
@Override
public String toString() {
return String.format("%s - %s", artist, track);
}
}
I doubt that the usage of this class isn't that clear from just the code alone, so here's a rough, pseudo-code-like overview of the code that uses a BufferQueue
:
// dummy functions to improve readability.
// byte[] READ_AUDIO_DATA(): Read a chunk of audio data from the stream
// SongMeta READ_META_DATA(): Read a chunk of meta data from the stream,
// return a SongMeta containng the parsed info
// void TMP_FILE_WRITE(byte[]): Write data to a temp file
// void FINALIZE_FILE(): Save a copy of the temp file as the recorded file
// the user sees later, with ID3 tags set and all.
// I can't do that with the tmp file directly, limitation of mp3agic
// void RESET_TMP_FILE(): close and reopen streams to tmp file
public void record() {
BufferQueue bufferQ = new BufferQueue(1, 2);
// pre-fill queue. -1, since reading is the next thing we do
// this saves 1 unneeded read any decreases the chance of
// missing the end of a song at this stage, albeit ever so slightly
for (int i = 0; i < bufferQ.size() - 1; i++) {
bufferQ.pushBuffer(READ_AUDIO_DATA());
bufferQ.pushMeta(READ_META_DATA());
}
while (true) {
bufferQ.pushBuffer(READ_AUDIO_DATA());
bufferQ.pushMeta(READ_META_DATA());
TMP_FILE_WRITE(bufferQ.getRelevantBuffer());
if (metaChanged()) {
save();
}
}
}
private void save() {
for (int i : bufferQ.getFinalIdxes()) {
TMP_FILE_WRITE(bufferQ.getBuffer(i));
}
SongMeta prev = bufferQ.getPrevMeta();
System.out.print("Saving: " + prev + ".mp3\n");
FINALIZE_FILE();
RESET_TMP_FILE();
for (int i : bufferQ.getInitIdxes()) {
outStream.write(bufferQ.getBuffer(i));
}
}
private boolean metaChanged() {
SongMeta curr = bufferQ.getCurrMeta();
SongMeta prev = bufferQ.getPrevMeta();
if (!curr.equals(prev)) {
System.out.println("New song started: " + curr);
return true;
}
return false;
}
public SongMeta getCurrSong() {
// usage for GUI
return bufferQ.getCurrMeta();
}
Questions
- Is there a better class to use as a base for the implementation than an
ArrayList
? Standard java classes are preferred, I'd rather not pull in Apache Commons. - Can the same effect be created in a simpler way that is as intuitive to configure?
Please note that this class was written for use in this project/context only. If this turns out to be useful somewhere else, I may consider it as a separate project later but for now, this use case in this context is enough.
EDIT: Non-pseudo code
NOTE: This code is not meant to be reviewed, I'm refactoring it at the moment. It's just here to demonstrate the usage of the BufferQueue
.
Test class
import java.util.ArrayDeque;
public class BufferQueueTest {
public static void main(String[] args) {
BufferQueue bufferQ = new BufferQueue(0, 0);
ArrayDeque<byte[]> dataQ = new ArrayDeque<>();
for (int i = 0; i < 26; i++) {
dataQ.add(((char)(i + 'A') + "").getBytes());
}
SongMeta smA = new SongMeta("1", "I");
SongMeta smB = new SongMeta("2", "II");
SongMeta smC = new SongMeta("3", "III");
ArrayDeque<SongMeta> metaQ = new ArrayDeque<>();
for (int i = 0; i < 5; i++) {
metaQ.add(smA);
}
for (int i = 6; i < 10; i++) {
metaQ.add(smB);
}
for (int i = 11; i < 15; i++) {
metaQ.add(smC);
}
for (int i = 0; i < bufferQ.size() - 1; i++) {
bufferQ.pushBuffer(dataQ.pop());
bufferQ.pushMeta(metaQ.pop());
}
for (int i = 0; i < 10; i++) {
bufferQ.pushBuffer(dataQ.pop());
bufferQ.pushMeta(metaQ.pop());
System.out.print(new String(bufferQ.getRelevantBuffer()));
if (metaChanged(bufferQ)) {
save(bufferQ);
}
}
}
private static void save(BufferQueue bufferQ) {
for (int i : bufferQ.getFinalIdxes()) {
System.out.print(new String(bufferQ.getBuffer(i)));
}
SongMeta prev = bufferQ.getPrevMeta();
System.out.println(" ->: " + prev + ".mp3");
for (int i : bufferQ.getInitIdxes()) {
System.out.print(new String(bufferQ.getBuffer(i)));
}
}
private static boolean metaChanged(BufferQueue bufferQ) {
SongMeta curr = bufferQ.getCurrMeta();
SongMeta prev = bufferQ.getPrevMeta();
return !curr.equals(prev);
}
}
Real class
package com.msgprograms.audiras.streamlogic;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URL;
import java.net.URLConnection;
import com.mpatric.mp3agic.ID3v1Tag;
import com.mpatric.mp3agic.Mp3File;
import com.msgprograms.audiras.settings.Lang;
import com.msgprograms.audiras.settings.Settings;
public class RadioRecorder implements Runnable {
private InputStream music;
private File tmpFile;
private OutputStream outStream;
private RadioStation rs;
private int blocksize = 0;
private BufferQueue bufferQ;
private boolean first = false;
public RadioRecorder(RadioStation rs) {
this.rs = rs;
this.first = true;
this.bufferQ = new BufferQueue(rs.getOffsetN(), rs.getOffsetN());
this.tmpFile = new File(rs.streamdir.getAbsolutePath() + "\\tmp.mp3");
}
public void run() {
try {
URLConnection toMusic = new URL(rs.url).openConnection();
toMusic.setRequestProperty("Icy-MetaData", "1");
toMusic.setRequestProperty("Connection", "close");
toMusic.connect();
music = toMusic.getInputStream();
this.blocksize = rs.getMetaInt();
} catch (Exception e) {
rs.errorStop(Lang.get("rstation.diag.errConn"));
return;
}
try {
outStream = new FileOutputStream(tmpFile);
// pre-fill queue. -1, since reading is the next thing we do
// this saves 1 unneeded read any decreases the chance of
// missing the end of a song at this stage, albeit ever so slightly
for (int i = 0; i < bufferQ.size() - 1; i++) {
bufferQ.pushBuffer(music.readNBytes(blocksize));
bufferQ.pushMeta(readMeta());
}
rs.updateLabels();
while (rs.isRecording) {
bufferQ.pushBuffer(music.readNBytes(blocksize));
bufferQ.pushMeta(readMeta());
outStream.write(bufferQ.getRelevantBuffer());
if (metaChanged()) {
rs.updateLabels();
save();
}
}
music.close();
outStream.close();
} catch (IOException e) {
System.err.println("Error while recording stream " + rs.getName());
e.printStackTrace();
rs.errorStop(Lang.get("rstation.diag.errConn"));
return;
}
}
private SongMeta readMeta() {
try {
int len = 0;
len = music.read();
if (len == -1) {
rs.errorStop(Lang.get("rstation.diag.errConn"));
return SongMeta.NO_META;
}
if (len == 0) {
return SongMeta.NO_META;
}
String raw = new String(music.readNBytes(len * 16), "UTF-8").trim();
// raw has format key='value';key='value';...
// find and parse StreamTitle='<artist> - <title>''
String[] entries = raw.split(";");
String titleEntry = null;
for (String entry : entries) {
// sort out bs surprises
if (entry.toLowerCase().contains("streamtitle")) {
titleEntry = entry;
break;
}
}
if (titleEntry == null) {
return SongMeta.AD;
}
String titleValue = titleEntry.split("=")[1];
if (titleValue.contains(" - ")) {
// whoever puts a " - " in their track title gets f'd over by this way of parsing
// they deserve that! why not use "-"? grr
// it's rare, so whatever. should still be a good enough hint to find the title
// and fix it manually
String[] info = titleValue.split(" - ");
String artist = info[0].trim().substring(1); // cut off leading "'"
String title = info[1].trim();
title = title.substring(0, title.length() - 2); // cut off trailing "'"
return new SongMeta(artist, title);
} else {
// ads have the StreamTitle field set to "''"
// if there is no " - " in th title, assume ad because we can't seperate title and artist
// and return the AD SongMeta
return SongMeta.AD;
}
} catch (Exception ex) {
return SongMeta.NO_META;
}
}
private void save() {
try {
if (first) {
first = false;
} else if (!bufferQ.getPrevMeta().equals(SongMeta.AD)) {
for (int i : bufferQ.getFinalIdxes()) {
outStream.write(bufferQ.getBuffer(i));
}
Mp3File mp3 = new Mp3File(tmpFile);
SongMeta prev = bufferQ.getPrevMeta();
// mp3.getLengthInSeconds() is buggy for some reason
// this should approximate the length reasonably well
if (tmpFile.length() * 8 / 1000 / rs.getBitrate() >= Settings.getMinRecLen()) {
System.out.print("[" + rs.getName() + "] Saving: " + prev + ".mp3\n");
ID3v1Tag tag = new ID3v1Tag();
mp3.setId3v1Tag(tag);
tag.setArtist(prev.artist());
tag.setTitle(prev.track());
String safeC = this.removeBadChars(prev.artist());
String safeT = this.removeBadChars(prev.track());
File targetFile = new File(rs.streamdir.getAbsolutePath() + "\\" + safeC + " - " + safeT + ".mp3");
if (!targetFile.exists()) {
try {
mp3.save(targetFile.getAbsolutePath());
rs.recordings.add(targetFile);
} catch (Exception e) {
targetFile.delete();
System.err.println(e.getMessage());
}
}
}
}
outStream.close();
outStream = new FileOutputStream(tmpFile);
rs.recalcFull();
if (rs.isFull) {
System.out.println("Recorder for " + rs.getName() + " is full!");
rs.stopRec(); // status is set by recalcFull()
return;
}
for (int i : bufferQ.getInitIdxes()) {
outStream.write(bufferQ.getBuffer(i));
}
} catch (Exception e) {
e.printStackTrace();
rs.errorStop(Lang.get("rstation.diag.errSave"));
}
}
private boolean metaChanged() {
SongMeta curr = bufferQ.getCurrMeta();
SongMeta prev = bufferQ.getPrevMeta();
if (curr.equals(SongMeta.NO_META)) {
return false;
}
if (!curr.equals(prev)) {
System.out.println("[" + rs.getName() + "] New song started: " + curr);
return true;
}
return false;
}
private String removeBadChars(String str) {
return str.replace("*", "#")
.replace("<", "[")
.replace(">", "]")
.replace(":", "")
.replace("\"", "'")
.replace("\\", " ")
.replace("/", " ")
.replace("|", " ")
.replace("?", " ")
.trim();
}
public SongMeta getCurrSong() {
return bufferQ.getCurrMeta();
}
}
Is there a better class to use as a base …
There'sArrayDeque<E>
-ArrayList.add(0)
looks costly. \$\endgroup\$ArrayDeque<>
is that you can't get elements at arbitrary positions easily, seegetRelevantBuffer(), getCurrMeta(), getPrevMeta()
. As for the second comment, while I don't quite understand what you mean, I'll change the wording in the question. Reading it the next day, this passage might be misunderstood. \$\endgroup\$