0
\$\begingroup\$

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

}

\$\endgroup\$
5
  • \$\begingroup\$ Is there a better class to use as a base … There's ArrayDeque<E> - ArrayList.add(0) looks costly. \$\endgroup\$
    – greybeard
    Commented Jul 15, 2022 at 4:51
  • \$\begingroup\$ The problem with ArrayDeque<> is that you can't get elements at arbitrary positions easily, see getRelevantBuffer(), 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\$ Commented Jul 15, 2022 at 12:13
  • \$\begingroup\$ A code comment says "offsetN is increased if ..." but in posted code it never changes after construction time. Did you post all the relevant code? Same concern about offsetP too. \$\endgroup\$
    – janos
    Commented Jul 16, 2022 at 13:03
  • 1
    \$\begingroup\$ About "here's a rough, pseudo-code-like overview of the code that uses a BufferQueue"... It would be better to post the real code that uses the class, so we could review code that's verified to be working, and therefore guaranteed to not have copy paste mistakes or speculation. \$\endgroup\$
    – janos
    Commented Jul 16, 2022 at 13:10
  • \$\begingroup\$ Will do as soon as I can. It'll contain references to some more classes though. If I find the time I'll also add a smaller test program. \$\endgroup\$ Commented Jul 18, 2022 at 6:42

1 Answer 1

1
\$\begingroup\$

Queue-like class - I only see half of that.

BufferQueue isn't an interface. Neither class nor methods feature doc comments.
BufferQueue neither implements java.util.Queue<E>, nor does it follow the naming of at least the main methods.

(The usage sketch consequently shows sequential in, out-by-offset.)

Without even a sketch of FINALIZE_FILE(), I have no idea if it was appropriate to write a temporary file in the first place.

I have to guess the N&P in offsN…offsetP are for next&previous, respectively.
I don't get to see offsetN is increased if ….

You defined a SongMeta.NO_META - I often have an analogous static final NO_BYTES[] = {};
Shouldn't max(offsetN, offsetP)+1 suffice for size? And maybe the offsets should be checked for correct sign.

pushMeta() could just be

    public void pushMeta(SongMeta sMeta) {
        if (!sMeta.equals(SongMeta.NO_META)) {
            lastNonEmptyMeta = sMeta;
        }

        meta.add(0, lastNonEmptyMeta);
        meta.remove(size);
    }

The use of this class seems to be to write several chunks that have been written to the end of one file to the start of the following file, too - there got to be many ways to skin that cat, including transfer between java.nio.Channels.
I wish I found Java's nio package less weird than io.


You could try to implement a Queue<E> with mark()&reset() - to get a "replay-mechanism" similar to Buffer's - or just use ByteBuffer.

\$\endgroup\$
1
  • \$\begingroup\$ About FINALIZE_FILE() and the tmp file: Yes, that is needed. The actual code there uses mp3agic to add ID3v2 tags to the file and Mp3File.save() doesn't allow the source filename to be the same as the destination filename (see here). \$\endgroup\$ Commented Jul 18, 2022 at 6:37

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.