Computer World

Planet Fox > Computer World > General MIDI's Jukebox

General MIDI's Jukebox

General MIDI wants you to download his programThis is a project I started working on way back in 2010, but didn't really get around to finishing until now (January of 2015).  Basically, I wanted a MIDI player that can handle both hardware and software synthesis, didn't use a lot of resources, and looked cool. There weren't really a lot of options, so I decided to make my own. I got the idea after I heard how much better the software synthesizer that comes with Java sounds than the Microsoft GS Wavetable Synth, which is the default on Windows systems without a hardware MIDI output. As for the name, it's a play on the term "General MIDI" which is used to describe the default bank of instruments and notes a MIDI instrument has to support. Because if there's one thing programmers like more than recursion, it's bad jokes.

Features

OK, so here's a list of features I wanted my program to have. It helps to do this before you start writing anything, since it's easier to take features into account beforehand than trying to force something new in at the last minute. I wanted a playlist, the ability to save the playlists, the ability to open individual files, groups of files, or directories, a display that shows the name of the song, playing time and length, random, repeats, and keyboard shortcuts for all of that stuff. I also wanted it to (kind of) look like a stereo component.

General MIDI's Jukebox

Graphical Components

I wrote a lot of this from scratch, the total size of this is close to 2,000 lines of code, spread across 11 classes, and about 90 methods/functions. The big stuff like the windowing toolkit and synthesizer are already part of Java, but it's the tiny details that really take a lot of thought on a program like this, things like making the load file method skip duplicates or making the display strip the file extension and non-alphanumeric characters from the file name before displaying it.

Probably the best part of Java is the flexible and easy to use GUI components. I haven't really found another programming language or native library that makes it as easy to create graphical components as the Abstract Windowing Toolkit (AWT) and its extension, Swing. The window itself is a subclass of JFrame, the progress bar that shows the time is a JProgressBar, the playlist is a JList, the menu is made up of a JMenuBar, three JMenu objects, and 13 JMenuItems. The Loop and Random menu items are JCheckBoxMenuItem objects, which allow for turning an option on and off. The play, stop, forward and back buttons are JButtons. There are also a few JPanel objects and a bunch of layout managers like FlowLayout which positions components evenly along a single line.

Display

The display is a subclass of JPanel. It has internal variables for the playing time, current time, and some math to take care of converting seconds into minutes:seconds. It turns out that it's really easy to do that, to get the minutes, divide the total time by 60: time/60, to get the seconds, mod the total time by 60: time%60. The actual elements of the display are just JLabels. How'd I get the cool font loaded in then? It turns out you can load a TrueType font directly into the JVM like this:

FileInputStream is = new FileInputStream("resources/delusion.ttf");
Font timeFont = Font.createFont(Font.TRUETYPE_FONT, fis);

You can change the size with the Font.deriveFont(float size) method. I wanted the display to look like the vacuum fluorescent displays used on high end electronics equipment, so I chose a bright bluish green on black and used two really cool freeware fonts: Delusion and Digital Readout.

Each digit of the playing time is an individual JLabel, arranged in an absolute (non-flexible) orientation with the text centered and spaced apart farther than the widest character, which, aside from using a monospaced font, is the only way to keep things from moving around as the numbers change. The file name, status, and length are also JLabels, and can be set externally by public methods.

There's some internal logic for converting raw file names like "Jane_Child - Hey_Mr_Jones.mid" to strings suitable for display. This is surprisingly complex:

    public void setName(String filename){
        StringBuilder name = new StringBuilder();
        char[] chars = filename.toCharArray();
        int length = chars.length;
        int separator = 0;
        if(length != 0){
            //Count backwards until the filename extension is reached
            for(int i=length-1; i > 0; i--){
                if(Character.compare(chars[i], '.') == 0){
                    //File extension separator has been found
                    separator = i;
                    break;
                }
            }
        }
        for(int i=0; i< separator; i++){
            if(i < 36){
                //Convert to upper case
                char ucase = Character.toUpperCase(chars[i]);
                int code = Character.getNumericValue(ucase);
                //Append numbers and letters
                if((code >= 0 && code <= 35)){
                        name.append(ucase);
                    }
                //If it's non-alphanumeric, insert a space instead
                else{
                        name.append(' ');
                }
            }
            else{
                break;
            }
        }
        //Set the label to the new name
        displaySequence.setText(name.toString());
    }

What it's doing here is breaking the string up into an array of chars, then counting backwards until it finds a '.' character, signifying the the file extension has been found. This works regardless of the length of the string or the file extension. But why count backwards? If it counted forward, any '.' characters in the filename would cause everything after it to be chopped off. For example, a track with the filename "Run D.M.C. - It's Like That.mid" would be shortened to "Run D".  Once it reaches the last dot, it breaks the loop, storing the position of the dot as separator.

The next loop goes through the charcters and converts them to uppercase, and uses their numeric value to sort out any non-alphanumeric characters. Numbers have a numeric code between 0 and 9, while letters have a code between 10 and 35. A space is used to replace any punctuation. Each character is appended to the string builder until the index of the file extension comes up or it goes over the 36 charcter limit.

The buttons are the same as you'd find on any CD player: Play/Pause, Stop, Forward and Back. The default way to make a button is to use JButton.setText(String text) but I wanted something cooler than text labels so I made my own icons. You can load them into the JButtons with only a few lines of code:

Image img = Toolkit.getDefaultToolkit().getImage("resources/stop.gif");
ImageIcon icon = new ImageIcon(img);
stopButton.setIcon(icon);

Non-Graphical Components

All of the GUI elements are encapsulated in the Window class. When you're working with a graphical program it's good not to have the graphical objects themselves doing too much work. Instead, all of the work of keeping the playlist organized and determining what tracks should be played fall to a class that I made called Control. All of the action listeners in Window are programmed to call methods in Control after updating whatever graphical components need updating. To keep Window from having too much access to Control, it accesses it through a custom interface, ControlInterface, which has one abstract method for each button in Window.

Control

Control is essentially the central class of this program. It's instantiated by Main, which then exits. Control builds the MIDI Player, the window components and any other objects that are needed in its constructor. It also acts as a WindowListener for Window and as a MetaEventListener for the Sequencer in MIDIPlayer, but we'll get to that later.

The most complicated logic in Control is to determine which file should be accessed from the playlist. There are actually two playlists: one is the JList graphical representation of it in Window and the other is a Playlist object, which I subclassed from ArrayList. The Playlist object is setup as a list of File objects, which describe absolute paths to a given file. JList are modified by adding and removing things from a DefaultListModel, a type of List. It's capable of holding File objects, but would display them with File's toString() method, which prints the entire file path from the root directory. Instead I setup a separate Playlist object for keeping the File objects. When stuff is added to the Playlist it's also added to the JList. Keeping them synchronized across all of the method calls that may modify them can be challenging. Here's the mothod in Control that's called when a user clicks on Open File:

    public void openFile() {
        //Setup the file chooser
        picker.setFileFilter(midFilter);
        picker.setFileSelectionMode(JFileChooser.FILES_ONLY);
        picker.setMultiSelectionEnabled(true);
        picker.setCurrentDirectory(lastDir);
        int returnVal = picker.showOpenDialog(main.getParent());
        if(returnVal == JFileChooser.APPROVE_OPTION) {
            //Files are returned as an array
                File[] files = picker.getSelectedFiles();
                lastDir = files[0].getParentFile();
                //If only one file is selected, play it
                if(files.length == 1){
                    addToPlaylist(files[0]);
                    loadMidi(files[0]);
                }
                //If multiple files are selected,
                //add them all to the playlist
                //and start playing the first one
                else{
                    addToPlaylist(files[0]);
                    int start = list.size() - 1;
                    for(int i = 1; i < files.length; i++){
                        addToPlaylist(files[i]);
                    }
                    main.setSelection(start);
                    loadMidi(list.get(start));
                }
        }
    }

In this example, the variable main is the Window, picker is a JFileChooser, and the method calls are custom methods that do pretty much what their names say they do. The method addToPlaylist(File file) is called from the various add/open file methods. It gets the list model from the JList in main, adds the filename only (not the entire path) to that, then adds the File object itself to the Playlist. To keep from adding duplicates, it looks up the incoming File object to see if it's already in the Playlist, if it's not Playlist.indexOf(File file) will return -1 which means the file is not a duplicate.

        if(list.indexOf(file) == -1){
            lm.addElement(file.getName());
            list.add(file);
            main.setSelection(list.indexOf(file));
        }

The code for moving along the playlist can also be somewhat complex. It has to determine if we're doing random or looping, look at where the current location is, then decide where to go from there.

    public void next() {
        int index = list.lastIndex();
        if(!random && !loop){
            if(index + 1 == list.size()){
                return;
            }
            else{
                index ++;
                loadNext(index);
            }
        }
        if(random){
            nextRand(index);
            return;
        }
        if(loop)
            index = loop(index);
        loadNext(index);
    }
    /*
     * Loads the next track randomly
     */
    private void nextRand(int index){
        if(list.size() != 1){
            int random = rand.nextInt(list.size());
            if(random == index){
                while(random==index){
                    random = rand.nextInt(list.size());
                }
            }
            index = random;
        }
        loadNext(index);
    }
   
    /*
     * Load the next track and set the selection to the proper index
     */
    private void loadNext(int index){
        main.setSelection(index);
        if(!list.isEmpty())
            loadMidi(list.get(index));
    }
   
    /*
     * What to do when next needs to loop
     * Returns the new index
     */
    private int loop(int index){
        if(index + 1 == list.size())
            index = 0;
        else
            index ++;
        return index;
    }

Playlist keeps track of the index of the last file that was accessed, which is the number given by Playlist.lastIndex(). There's also an autoNext() method in Control that's called at the end of a track. It does the same thing, but stops at the end of the playlist when random and loop = false, instead of doing nothing. Control implements the MetaEventListener interface, which attaches to a Sequencer and provides meta data. The autoNext() method is called when the MetaMessage equals 47, the end of file message for MIDI files.

Playlist

The Playlist class is a subclass of ArrayList, it implements Serializable, which basically means that it can be written to a disk. The .fox playlists written by this program are raw Playlist objects written using an ObjectOutputStream. It saves some effort on processing the File strings into a text file, and doesn't really take up any extra room, so I figured why not. Here's the code for saving playlists.

    /*
     * Save the current Playlist object
     */
    @Override
    public void saveList() {
        //Setup the file chooser
        picker.setFileFilter(plsFilter);
        picker.setFileSelectionMode(JFileChooser.FILES_ONLY);
        picker.setMultiSelectionEnabled(false);
        picker.setCurrentDirectory(lastDir);
        int returnVal = picker.showSaveDialog(main.getParent());
        if(returnVal == JFileChooser.APPROVE_OPTION) {
            File selected = picker.getSelectedFile();
            try{
                File save = newName(selected);
                BufferedOutputStream buffer = new BufferedOutputStream(new FileOutputStream(save));
                ObjectOutputStream objectStream = new ObjectOutputStream(buffer);
                objectStream.writeObject(list);
                objectStream.close();
                buffer.close();
                lastDir = selected.getParentFile();
            }
            catch(IOException e){
                System.out.println("Failed to save playlist");
            }
        }
    }
   
    /*
     * Build a filename for the playlist name given by the user
     * If the name is less than  characters long or doesn't have
     * the proper file extension, append .fox to the name to get
     * the filename

     */
private File newName(File oldName){
  String name = oldName.getName();
  int length = name.length();
  if(length > 4 && name.substring(length - 4, length).equalsIgnoreCase(".fox")){
      return oldName;
  }
  else{
      File file = new File(oldName.getParent() + File.separator + name + ".fox");
      return file;
  }
}

It looks complicated, but basically what it's doing is letting the user select a location and name to save the file, then checking it for the .fox file extension and adding it if it's not there. Buffered input and output streams are used because they don't hang other processes while writing to the disk, which is very important on slower computers, as is closing the streams when you're done with them.

Deleting a file from the playlist can also be hard, because you have to adjust the lastIndex variable in the playlist if the file being deleted has a smaller index than the currently playing file to keep from getting an ArrayIndexOutOfBoundsException, that's what the method Playlist.decLastIndex() does, decreases the last index by 1 every time it's called. You'll also have to determine if the currently playing file is the one being deleted, and whether or not it's the last one in the list. If it's not, you want to go down the list, if it is, you want to go back up, and if it's the only one in the list you just want to clear everything.


    /*
     * Delete a file from the playlist and determine the correct
     * action to take one the file has been deleted. If the deleted
     * file was the only one in the list, stop playing. If the
     * currently playing file was deleted, stop playing and highlight
     * the next track
     */
    private void delFile(int index){
        //If there is only one item in the list, clear it and
        //return immediately
        if(list.size() == 1){
            clear(main.getDefaultListModel());
            return;
        }
        //Find the new index to skip to
        int newIndex;
        if(index >= list.size()-1){
            newIndex = list.size()-2;
        }
        else
            newIndex = index + 1;
        if(player.isRunning() && list.lastIndex() == index){
            stop();
            loadMidi(list.get(newIndex));
        }
        main.setSelection(newIndex);
        list.remove(index);
        main.getDefaultListModel().remove(index);
        if(index < list.lastIndex()){
            list.decLastIndex();
        }
    }

    /*
     * Clear all of the files in the playlist.
     * Builds a new Playlist object, and takes the DefaultListModel
     * used by the JList in Window and removes all of its entries
     */
    @Override
    public void clear(DefaultListModel<String> lm) {
        stop();
        if(list.isEmpty()){
            if(!lm.isEmpty())
                lm.remove(0);
        }
        list = new Playlist();
    }
    /*
     * Remove a single file from the playlist and
     * recalculate the index if the removed file had a lower
     * index than the current file
     */
    @Override
    public void removeFile(int index) {
        //If the playlist isn't empty and a list entry is selected
        if(!list.isEmpty())
            delFile(index);
    }

Configuration

Another thing that came up that a lot of people don't consider is keeping the configuration from one run to the next. Things like the value of random and loop, the last directory visited with the file chooser, and the location of the window on the screen are a good thing to preserve for the next time the program runs. When this program exits, it saves a small configuration file which is an instance of Configuration, a class I wrote that encapsulates a few variables. It's created, saved, and read by a ConfigurationManager class, and read into Control when it starts up. Random and Repeat are set by boolean variables in Control, the location on the screen is defined by a Point, and the last directory that was visited is stored as a File. This method is called from the constructor when Control is created:

    /*
     * Load the configuration into the program
     * If the configuration is null, create conservative default values
     */
    private Point setConfig() {
        ConfigManager conf = new ConfigManager();
        Configuration setup = conf.loadConfig();
        if(setup != null){
            if(!setup.isDirNull()){
                lastDir = setup.getDir();
            }
            random = setup.random();
            loop = setup.loop();
            if(!setup.isCoordsNull()){
                return setup.getCoords();
            }
        }
        Point center = GraphicsEnvironment.getLocalGraphicsEnvironment().getCenterPoint();
        center.translate(-320, -240);
        return center;
    }

Saving is the reverse of that. When the program exits cleanly, by either selecting File -> Quit or closing the window, a method is called that saves the value of all of these variables and writes them using an ObjectOutputStream to the program's resources directory

MIDIPlayer

This is, of course the player class that encapsulates the Sequencer and its support components. The cool thing is that Java basically has a lot of functionality built in when it comes to handling MIDI files, so there really isn't a lot of code in this class. In fact, loading a new Sequence into the sequencer is as easy as calling the static method MidiSystem.getSequence(File file). What is there essentially deals with setting up the Sequencer and implementing some extra functionality. Java's Sequencer doesn't have a pause function, so I added pause and resume methods. The pause method calls Sequencer.getMicrosecondPosition(), stops playback,  and saves that value in an instance variable, and resume passes that variable to Sequencer.setMicrosecondPosition() and restarts playback.

The time and length figures returned by Sequencer as a long integer representing the microsecond position. I didn't need this kind of resolution, so the MIDIPlayer.getTime() and MIDIPlayer.getLength() methods divide that number by 1,000,000 and convert it to an integer before returning the value.

    public int getTime(){
        long time = seq.getMicrosecondPosition();
        Long seconds = new Long(time/1000000);
        return seconds.intValue();
    }

This method is called by a Timer in Control that fires every 333 mS, which then updates Display with the new time values. Using odd numbers for timers where an arbitrary value is needed is a habit of mine, since in some unlikely circumstances it breaks up some unforseeable race conditions.

Other Thoughts

That was a long article wasn't it? Surprisingly this wasn't that hard of a project. Like many programs, the big parts were easy, but the tiny details take a while to work out. I spent most of my time on this project on things the user wouldn't really notice unless they were missing or broken, a lot more time than I spent on the UI anyway. I am pleased with it though. I might extend it down the road with sampled sound playback capabilities. Java has built in support for handling linear PCM files like Sun Audio, RIFF Wave and AIFF, but lacks native support for more complex formats like MP3, which means I'll have to write an MP3 decoder myself from scratch.

That being said, there won't be any future versions of this program. Unlike some people, I don't believe in bug fixes or updates. If your program has bugs in it you shouldn't have released it. So if you download this it's not going to pop up a thing every three days reminding you that there's an update available. That's my rant for today, and I think we all know who it's directed at.

If you liked reading about this project, you may want to go to the download page.


Powered by FreeBSD
Valid HTML 4.01
Site Map
©MMIX-MMXIV Planet Fox