Linux 声音编程教程(七)

原文:Linux Sound Programming

协议:CC BY-NC-SA 4.0

二十四、MP3+G

本章探讨如何使用 MP3+G 格式的 Karaoke 文件。文件从服务器下载到与显示设备(我的电视)相连的(小)电脑上。使用运行在 Linux 或 Windows 上的 Java Swing 应用选择文件。

在第二十三章中,我讨论了用于 Karaoke 的 MP3+G 格式。每首“歌曲”由两个文件组成:音频的 MP3 文件和视频的低质量 CDG 文件(主要是歌词)。通常这两个文件是压缩在一起的。

使用cdrdaocdgrip.py可以从 CDG Karaoke 光盘中提取文件。当给定 MP3 文件作为参数时,VLC 可以播放它们。它将从同一个目录中提取 CDG 文件。

许多人已经收集了相当多的 MP3+G 歌曲。在这一章中,你将考虑如何列出并播放它们,同时保存喜爱歌曲的列表。本章着眼于一个 Java 应用来执行这一点,这实际上只是标准的 Swing 编程。本章不考虑特殊的音频或 Karaoke 功能。

我把我的文件保存在服务器上。我可以在家里的其他计算机上以多种方式访问它们:Samba 共享、HTTP 下载、SSH 文件系统(sshfs)等等。一些机制不如其他机制可移植;比如sshfs不是标准的 Windows 应用,SMB/Samba 也不是标准的 Android 客户端。所以,在使用sshfs(标准 Linux 下的一个显而易见的工具)让一切工作正常后,我将应用转换成 HTTP 访问。这有它自己的皱纹。

环境如图 24-1 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 24-1。

Client requesting songs on an HTTP server to play on PC

Linux 和 Windows 的 Java 客户端应用如图 24-2 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 24-2。

User interface on client

这显示了歌曲的主窗口,在它的右边是两个人的收藏夹窗口,Jan 和 Linda。该应用处理多种语言;显示英语、韩语和中文。

过滤器可以应用于主歌曲列表。例如,对歌手 Sting 的过滤给出了图 24-3 。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 24-3。

Songs by Sting

当点击播放时,关于选择的信息被发送到媒体播放器,目前是一个连接到我的 HiFi/TV 的 CubieBoard2。媒体计算机从 HTTP 服务器获取文件。使用 VLC 在媒体计算机上播放文件,因为它可以处理 MP3+G 文件。

文件组织

如果 MP3+G 歌曲是从 CDG Karaoke 光盘中抓取的,那么自然的组织将是把文件存储在目录中,每个目录对应一张光盘。通过按普通艺术家、音乐风格等对目录进行分组,你可以给出更多的结构。你可以假设一个目录结构,音乐文件作为叶节点。这些文件保存在 HTTP 服务器上。

我的服务器上目前有大量这样的文件。需要向客户提供关于这些文件的信息。经过一些试验后,使用 Java 的对象序列化方法创建并序列化了一个SongInformationVector。序列化文件也保存在 HTTP 服务器上。当客户机启动时,它从 HTTP 服务器获取这个文件并反序列化它。

构建这个向量意味着遍历 HTTP 服务器上的目录树,并在遍历过程中记录信息。遍历目录树的 Java 代码相当简单。如果您希望它是独立于操作系统的,这有点乏味,但是 Java 1.7 引入了一些机制来使这变得更容易。这些属于新的 I/O ( NIO.2)系统。第一类重要性是 java.nio.file.Path ,它是一个可以用来在文件系统中定位文件的对象。它通常代表一个依赖于系统的文件路径。一个表示文件位置的字符串,比如说,在 Linux 或 Windows 文件系统中,可以用下面的代码转换成一个Path对象:

Path startingDir = FileSystems.getDefault().getPath(dirString);

从给定路径遍历文件系统是通过遍历文件树并在每个点调用一个节点“访问者”来完成的。visitor 是SimpleFileVisitor<Path>的子类,只有对于叶节点,您才需要重写该方法。

public FileVisitResult visitFile(Path file, BasicFileAttributes attr)

遍历是通过以下方式完成的:

Visitor pf = new Visitor();
Files.walkFileTree(startingDir, pf);

Java 教程网站上的“遍历文件树”( http://docs.oracle.com/javase/tutorial/essential/io/walk.html )给出了对此的完整解释。您使用它将所有歌曲信息从光盘加载到SongTable.java中的歌曲路径向量中。

歌曲信息

关于每首歌曲的信息应该包括它在文件系统中的路径、艺术家的名字、歌曲的标题以及任何其他有用的信息。这个信息必须从歌曲的文件路径中提取出来。在我当前的设置中,文件如下所示:

/server/KARAOKE/Sonken/SK-50154 - Crosby, Stills - Carry On.mp3

每首歌曲都有一个合理的唯一标识符(SK-50154),一个唯一的路径,一个艺术家和标题。相当简单的模式匹配代码可以提取这些部分,如下所示:

Path file = ...
String fname = file.getFileName().toString();
if (fname.endsWith(".zip") ||
    fname.endsWith(".mp3")) {
    String root = fname.substring(0, fname.length()-4);
    String parts[] = root.split(" - ", 3);
    if (parts.length != 3)
        return;

        String index = parts[0];
        String artist = parts[1];
        String title = parts[2];

        SongInformation info = new SongInformation(file,
                                                   index,
                                                   title,
                                                   artist);

(用cdrip.py制作的图案不太一样,但是代码很容易改。)

SongInformation类捕获这些信息,并且还包含针对不同字段进行字符串模式匹配的方法。例如,要检查标题是否匹配,请使用以下命令:

public boolean titleMatch(String pattern) {
    return title.matches("(?i).*" + pattern + ".*");
}

这提供了使用 Java 正则表达式支持的不区分大小写的匹配。详见 Lars Vogel 的《Java Regex 教程》( www.vogella.com/articles/JavaRegularExpressions/article.html )。

以下是完整的SongInformation文件:

import java.nio.file.Path;
import java.io.Serializable;

public class SongInformation implements Serializable {

    // Public fields of each song record

    public String path;

    public String index;

    /**
     * song title in Unicode
     */
    public String title;

    /**
     * artist in Unicode
     */
    public String artist;

    public SongInformation(Path path,
                           String index,
                           String title,
                           String artist) {
        this.path = path.toString();
        this.index = index;
        this.title = title;
        this.artist = artist;
    }

    public String toString() {
        return "(" + index + ") " + artist + ": " + title;
    }

    public boolean titleMatch(String pattern) {
        return title.matches("(?i).*" + pattern + ".*");
    }

    public boolean artistMatch(String pattern) {
        return artist.matches("(?i).*" + pattern + ".*");
    }

    public boolean numberMatch(String pattern) {
        return index.equals(pattern);
    }
}

歌曲表

SongTable通过遍历文件树构建了一个SongInformation对象的向量。

如果有很多歌曲(比如说,数以千计),这会导致启动时间很慢。为了减少这种情况,一旦加载了一个表,就通过将它写入一个ObjectOutputStream来将它作为一个持久对象保存到磁盘上。下次程序启动时,会尝试使用ObjectInputStream从这里读回它。注意,您没有使用 Java 持久性 API ( http://en.wikibooks.org/wiki/Java_Persistence/What_is_Java_persistence%3F )。它是为 J2EE 设计的,对我们来说太重了。

SongTable还包括基于模式和标题(或艺术家或编号)之间的匹配来构建更小的歌曲表的代码。它可以搜索模式和歌曲之间的匹配,并基于匹配建立新的表。它包含一个指向原始表的指针,以便以后恢复。这允许模式搜索使用相同的数据结构。

SongTable的代码如下:

import java.util.Vector;
import java.io.FileInputStream;
import java.io.*;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.FileVisitResult;
import java.nio.file.FileSystems;
import java.nio.file.attribute.*;

class Visitor
    extends SimpleFileVisitor<Path> {

    private Vector<SongInformation> songs;

    public Visitor(Vector<SongInformation> songs) {
        this.songs = songs;
    }

    @Override
    public FileVisitResult visitFile(Path file,
                                   BasicFileAttributes attr) {
        if (attr.isRegularFile()) {
            String fname = file.getFileName().toString();
            //System.out.println("Regular file " + fname);
            if (fname.endsWith(".zip") ||
                fname.endsWith(".mp3") ||
                fname.endsWith(".kar")) {
                String root = fname.substring(0, fname.length()-4);
                //System.err.println(" root " + root);
                String parts[] = root.split(" - ", 3);
                if (parts.length != 3)
                    return java.nio.file.FileVisitResult.CONTINUE;

                String index = parts[0];
                String artist = parts[1];
                String title = parts[2];

                SongInformation info = new SongInformation(file,
                                                           index,
                                                           title,
                                                           artist);
                songs.add(info);
            }
        }

        return java.nio.file.FileVisitResult.CONTINUE;
    }
}

public class SongTable {

    private static final String SONG_INFO_ROOT = "/server/KARAOKE/KARAOKE/";

    private static Vector<SongInformation> allSongs;

    public Vector<SongInformation> songs =
        new Vector<SongInformation>  ();

    public static long[] langCount = new long[0x23];

    public SongTable(Vector<SongInformation> songs) {
        this.songs = songs;
    }

    public SongTable(String[] args) throws java.io.IOException,
                                           java.io.FileNotFoundException {
        if (args.length >= 1) {
            System.err.println("Loading from " + args[0]);
            loadTableFromSource(args[0]);
            saveTableToStore();
        } else {
            loadTableFromStore();
        }
    }

    private boolean loadTableFromStore() {
        try {

            File storeFile = new File("/server/KARAOKE/SongStore");

            FileInputStream in = new FileInputStream(storeFile);
            ObjectInputStream is = new ObjectInputStream(in);
            songs = (Vector<SongInformation>) is.readObject();
            in.close();
        } catch(Exception e) {
            System.err.println("Can't load store file " + e.toString());
            return false;
        }
        return true;
    }

    private void saveTableToStore() {
        try {
            File storeFile = new File("/server/KARAOKE/SongStore");
            FileOutputStream out = new FileOutputStream(storeFile);
            ObjectOutputStream os = new ObjectOutputStream(out);
            os.writeObject(songs);
            os.flush();
            out.close();
        } catch(Exception e) {
            System.err.println("Can't save store file " + e.toString());
        }
    }

    private void loadTableFromSource(String dir) throws java.io.IOException,
                              java.io.FileNotFoundException {

        Path startingDir = FileSystems.getDefault().getPath(dir);
        Visitor pf = new Visitor(songs);
        Files.walkFileTree(startingDir, pf);
    }

    public java.util.Iterator<SongInformation> iterator() {
        return songs.iterator();
    }

    public SongTable titleMatches( String pattern) {
        Vector<SongInformation> matchSongs =
            new Vector<SongInformation>  ();

        for (SongInformation song: songs) {
            if (song.titleMatch(pattern)) {
                matchSongs.add(song);
            }
        }
        return new SongTable(matchSongs);
    }

     public SongTable artistMatches( String pattern) {
        Vector<SongInformation> matchSongs =
            new Vector<SongInformation>  ();

        for (SongInformation song: songs) {
            if (song.artistMatch(pattern)) {
                matchSongs.add(song);
            }
        }
        return new SongTable(matchSongs);
    }

    public SongTable numberMatches( String pattern) {
        Vector<SongInformation> matchSongs =
            new Vector<SongInformation>  ();

        for (SongInformation song: songs) {
            if (song.numberMatch(pattern)) {
                matchSongs.add(song);
            }
        }
        return new SongTable(matchSongs);
    }

    public String toString() {
        StringBuffer buf = new StringBuffer();
        for (SongInformation song: songs) {
            buf.append(song.toString() + "\n");
        }
        return buf.toString();
    }

    public static void main(String[] args) {
        // for testing
        SongTable songs = null;
        try {
            songs = new SongTable(new String[] {SONG_INFO_ROOT});
        } catch(Exception e) {
            System.err.println(e.toString());
            System.exit(1);
        }

        System.out.println(songs.artistMatches("Tom Jones").toString());

        System.exit(0);
    }
}

收藏夹

我已经为我的家庭环境系统建立了这个系统,并且我有一群定期来访的朋友。我们每个人都有自己喜欢的歌曲要唱,所以我们在纸片上列出了丢失的歌曲、洒了酒的歌曲等等。所以,这个系统包括了一个最喜欢的歌曲列表。

每个收藏夹列表本质上都是另一个SongTable。但是我在桌子周围放了一个JList来显示它。JList使用了一个DefaultListModel,构造函数通过遍历这个表并添加元素将一个歌曲表加载到这个列表中。

        int n = 0;
        java.util.Iterator<SongInformation> iter = favouriteSongs.iterator();
        while(iter.hasNext()) {
            model.add(n++, iter.next());
        }

其他 Swing 代码在底部添加了三个按钮:

  • 将歌曲添加到列表
  • 从列表中删除歌曲
  • 播放音乐

将一首歌曲添加到列表意味着从主歌曲表中取出所选项目,并将其添加到该表中。主表被传递到构造函数中,只是为了获取它的选择而保留。所选对象被添加到 Swing JList和 favorites SongTable中。

播放一首歌曲的方法很简单:歌曲的完整路径被写入标准输出,换行结束。然后,管道中的另一个程序可以拾取它;这将在本章的后面介绍。

最喜欢的东西如果不能从一天坚持到下一天就没什么用了!因此,与之前相同的对象存储方法被用于完整的歌曲表。每次对服务器进行更改时,都会保存每个收藏夹文件。

以下是Favourites的代码:

import java.awt.*;
import java.awt.event.*;
import javax.swing.event.*;
import javax.swing.*;
import javax.swing.SwingUtilities;
import java.util.regex.*;
import java.io.*;
import java.nio.file.FileSystems;
import java.nio.file.*;

public class Favourites extends JPanel {
    private DefaultListModel model = new DefaultListModel();
    private JList list;

    // whose favoutites these are
    private String user;

    // songs in this favourites list
    private final SongTable favouriteSongs;

    // pointer back to main song table list
    private final SongTableSwing songTable;

    // This font displays Asian and European characters.
    // It should be in your distro.
    // Fonts displaying all Unicode are zysong.ttf and Cyberbit.ttf
    // See http://unicode.org/resources/fonts.html
    private Font font = new Font("WenQuanYi Zen Hei", Font.PLAIN, 16);

    private int findIndex = -1;

    public Favourites(final SongTableSwing songTable,
                      final SongTable favouriteSongs,
                      String user) {
        this.songTable = songTable;
        this.favouriteSongs = favouriteSongs;
        this.user = user;

        if (font == null) {
            System.err.println("Can't find font");
        }

        int n = 0;
        java.util.Iterator<SongInformation> iter = favouriteSongs.iterator();
        while(iter.hasNext()) {
            model.add(n++, iter.next());
        }

        BorderLayout mgr = new BorderLayout();

        list = new JList(model);
        list.setFont(font);
        JScrollPane scrollPane = new JScrollPane(list);

        setLayout(mgr);
        add(scrollPane, BorderLayout.CENTER);

        JPanel bottomPanel = new JPanel();
        bottomPanel.setLayout(new GridLayout(2, 1));
        add(bottomPanel, BorderLayout.SOUTH);

        JPanel searchPanel = new JPanel();
        bottomPanel.add(searchPanel);
        searchPanel.setLayout(new FlowLayout());

        JPanel buttonPanel = new JPanel();
        bottomPanel.add(buttonPanel);
        buttonPanel.setLayout(new FlowLayout());

        JButton addSong = new JButton("Add song to list");
        JButton deleteSong = new JButton("Delete song from list");
        JButton play = new JButton("Play");

        buttonPanel.add(addSong);
        buttonPanel.add(deleteSong);
        buttonPanel.add(play);

        play.addActionListener(new ActionListener() {
                public void actionPerformed(ActionEvent e) {
                    playSong();
                }
            });

        deleteSong.addActionListener(new ActionListener() {
                public void actionPerformed(ActionEvent e) {
                    SongInformation song = (SongInformation) list.getSelectedValue();
                    model.removeElement(song);
                    favouriteSongs.songs.remove(song);
                    saveToStore();
                }
            });

        addSong.addActionListener(new ActionListener() {
                public void actionPerformed(ActionEvent e) {
                    SongInformation song = songTable.getSelection();
                    model.addElement(song);
                    favouriteSongs.songs.add(song);
                    saveToStore();
                }
            });
     }

    private void saveToStore() {
        try {
            File storeFile = new File("/server/KARAOKE/favourites/" + user);
            FileOutputStream out = new FileOutputStream(storeFile);
            ObjectOutputStream os = new ObjectOutputStream(out);
            os.writeObject(favouriteSongs.songs);
            os.flush();
            out.close();
        } catch(Exception e) {
            System.err.println("Can't save favourites file " + e.toString());
        }
    }

    /**
     * "play" a song by printing its file path to standard out.
     * Can be used in a pipeline this way
     */
    public void playSong() {
        SongInformation song = (SongInformation) list.getSelectedValue();
        if (song == null) {
            return;
        }
        System.out.println(song.path.toString());
    }

    class SongInformationRenderer extends JLabel implements ListCellRenderer {

        public Component getListCellRendererComponent(
                                                      JList list,
                                                      Object value,
                                                      int index,
                                                      boolean isSelected,
                                                      boolean cellHasFocus) {
            setText(value.toString());
            return this;
        }
    }
}

所有收藏夹

这里没什么特别的。它只是加载每个人的表,并构建一个放在JTabbedPane中的Favourites对象。它还添加了一个用于添加更多用户的新选项卡。

AllFavourites的代码如下:

import java.awt.*;
import java.awt.event.*;
import javax.swing.*;
import java.util.Vector;
import java.nio.file.*;
import java.io.*;

public class AllFavourites extends JTabbedPane {
    private SongTableSwing songTable;

    public AllFavourites(SongTableSwing songTable) {
        this.songTable = songTable;

        loadFavourites();

        NewPanel newP = new NewPanel(this);
        addTab("NEW", null, newP);
    }

    private void loadFavourites() {
        String userHome = System.getProperty("user.home");
        Path favouritesPath = FileSystems.getDefault().getPath("/server/KARAOKE/favourites");
        try {
            DirectoryStream<Path> stream =
                Files.newDirectoryStream(favouritesPath);
            for (Path entry: stream) {
                int nelmts = entry.getNameCount();
                Path last = entry.subpath(nelmts-1, nelmts);
                System.err.println("Favourite: " + last.toString());
                File storeFile = entry.toFile();

                FileInputStream in = new FileInputStream(storeFile);
                ObjectInputStream is = new ObjectInputStream(in);
                Vector<SongInformation> favouriteSongs =
                    (Vector<SongInformation>) is.readObject();
                in.close();
                for (SongInformation s: favouriteSongs) {
                    System.err.println("Fav: " + s.toString());
                }

                SongTable favouriteSongsTable = new SongTable(favouriteSongs);
                Favourites f = new Favourites(songTable,
                                              favouriteSongsTable,
                                              last.toString());
                addTab(last.toString(), null, f, last.toString());
                System.err.println("Loaded favs " + last.toString());
            }
        } catch(Exception e) {
            System.err.println(e.toString());
        }
    }

    class NewPanel extends JPanel {
        private JTabbedPane pane;

        public NewPanel(final JTabbedPane pane) {
            this.pane = pane;

            setLayout(new FlowLayout());
            JLabel nameLabel = new JLabel("Name of new person");
            final JTextField nameField = new JTextField(10);
            add(nameLabel);
            add(nameField);

            nameField.addActionListener(new ActionListener(){
                    public void actionPerformed(ActionEvent e){
                        String name = nameField.getText();

                        SongTable songs = new SongTable(new Vector<SongInformation>());
                        Favourites favs = new Favourites(songTable, songs, name);

                        pane.addTab(name, null, favs);
                    }});

        }
    }
}

摇摆歌单

这主要是加载不同歌曲表和构建 Swing 界面的代码。它还根据匹配的模式过滤显示表。最初加载的表被保留用于恢复和修补匹配。SongTableSwing的代码如下:

import java.awt.*;
import java.awt.event.*;
import javax.swing.event.*;
import javax.swing.*;
import javax.swing.SwingUtilities;
import java.util.regex.*;
import java.io.*;

public class SongTableSwing extends JPanel {
   private DefaultListModel model = new DefaultListModel();
    private JList list;
    private static SongTable allSongs;

    private JTextField numberField;
    private JTextField langField;
    private JTextField titleField;
    private JTextField artistField;

    // This font displays Asian and European characters.
    // It should be in your distro.
    // Fonts displaying all Unicode are zysong.ttf and Cyberbit.ttf
    // See http://unicode.org/resources/fonts.html
    private Font font = new Font("WenQuanYi Zen Hei", Font.PLAIN, 16);
    // font = new Font("Bitstream Cyberbit", Font.PLAIN, 16);

    private int findIndex = -1;

    /**
     * Describe <code>main</code> method here.
     *
     * @param args a <code>String</code> value
     */
    public static final void main(final String[] args) {
        if (args.length >= 1 &&
            args[0].startsWith("-h")) {
            System.err.println("Usage: java SongTableSwing [song directory]");
            System.exit(0);
        }

        allSongs = null;
        try {
            allSongs = new SongTable(args);
        } catch(Exception e) {
            System.err.println(e.toString());
            System.exit(1);
        }

        JFrame frame = new JFrame();
        frame.setTitle("Song Table");
        frame.setSize(700, 800);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

        SongTableSwing panel = new SongTableSwing(allSongs);
        frame.getContentPane().add(panel);

        frame.setVisible(true);

        JFrame favourites = new JFrame();
        favourites.setTitle("Favourites");
        favourites.setSize(600, 800);
        favourites.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

        AllFavourites lists = new AllFavourites(panel);
        favourites.getContentPane().add(lists);

        favourites.setVisible(true);

    }

    public SongTableSwing(SongTable songs) {

        if (font == null) {
            System.err.println("Can't fnd font");
        }

        int n = 0;
        java.util.Iterator<SongInformation> iter = songs.iterator();
        while(iter.hasNext()) {
            model.add(n++, iter.next());
            // model.add(n++, iter.next().toString());
        }

        BorderLayout mgr = new BorderLayout();

        list = new JList(model);
        // list = new JList(songs);
        list.setFont(font);
        JScrollPane scrollPane = new JScrollPane(list);

        // Support DnD
        list.setDragEnabled(true);

        setLayout(mgr);
        add(scrollPane, BorderLayout.CENTER);

        JPanel bottomPanel = new JPanel();
        bottomPanel.setLayout(new GridLayout(2, 1));
        add(bottomPanel, BorderLayout.SOUTH);

        JPanel searchPanel = new JPanel();
        bottomPanel.add(searchPanel);
        searchPanel.setLayout(new FlowLayout());

        JLabel numberLabel = new JLabel("Number");
        numberField = new JTextField(5);

        JLabel langLabel = new JLabel("Language");
        langField = new JTextField(8);

        JLabel titleLabel = new JLabel("Title");
        titleField = new JTextField(20);
        titleField.setFont(font);

        JLabel artistLabel = new JLabel("Artist");
        artistField = new JTextField(10);
        artistField.setFont(font);

        searchPanel.add(numberLabel);
        searchPanel.add(numberField);
        searchPanel.add(titleLabel);
        searchPanel.add(titleField);
        searchPanel.add(artistLabel);
        searchPanel.add(artistField);

        titleField.getDocument().addDocumentListener(new DocumentListener() {
                public void changedUpdate(DocumentEvent e) {
                    // rest find to -1 to restart any find searches
                    findIndex = -1;
                    // System.out.println("reset find index");
                }
                public void insertUpdate(DocumentEvent e) {
                    findIndex = -1;
                    // System.out.println("reset insert find index");
                }
                public void removeUpdate(DocumentEvent e) {
                    findIndex = -1;
                    // System.out.println("reset remove find index");
                }
            }
            );
        artistField.getDocument().addDocumentListener(new DocumentListener() {
                public void changedUpdate(DocumentEvent e) {
                    // rest find to -1 to restart any find searches
                    findIndex = -1;
                    // System.out.println("reset insert find index");
                }
                public void insertUpdate(DocumentEvent e) {
                    findIndex = -1;
                    // System.out.println("reset insert find index");
                }
                public void removeUpdate(DocumentEvent e) {
                    findIndex = -1;
                    // System.out.println("reset insert find index");
                }
            }
            );

        titleField.addActionListener(new ActionListener(){
                public void actionPerformed(ActionEvent e){
                    filterSongs();
                }});
        artistField.addActionListener(new ActionListener(){
                public void actionPerformed(ActionEvent e){
                    filterSongs();
                }});

        JPanel buttonPanel = new JPanel();
        bottomPanel.add(buttonPanel);
        buttonPanel.setLayout(new FlowLayout());

        JButton find = new JButton("Find");
        JButton filter = new JButton("Filter");
        JButton reset = new JButton("Reset");
        JButton play = new JButton("Play");
        buttonPanel.add(find);
        buttonPanel.add(filter);
        buttonPanel.add(reset);
        buttonPanel.add(play);

        find.addActionListener(new ActionListener() {
                public void actionPerformed(ActionEvent e) {
                    findSong();
                }
            });

        filter.addActionListener(new ActionListener() {
                public void actionPerformed(ActionEvent e) {
                    filterSongs();
                }
            });

        reset.addActionListener(new ActionListener() {
                public void actionPerformed(ActionEvent e) {
                    resetSongs();
                }
            });

        play.addActionListener(new ActionListener() {
                public void actionPerformed(ActionEvent e) {
                    playSong();
                }
            });

     }

    public void findSong() {
        String number = numberField.getText();
        String language = langField.getText();
        String title = titleField.getText();
        String artist = artistField.getText();

        if (number.length() != 0) {
            return;
        }

        for (int n = findIndex + 1; n < model.getSize(); n++) {
            SongInformation info = (SongInformation) model.getElementAt(n);

            if ((title.length() != 0) && (artist.length() != 0)) {
                if (info.titleMatch(title) && info.artistMatch(artist)) {
                        findIndex = n;
                        list.setSelectedIndex(n);
                        list.ensureIndexIsVisible(n);
                        break;
                }
            } else {
                if ((title.length() != 0) && info.titleMatch(title)) {
                    findIndex = n;
                    list.setSelectedIndex(n);
                    list.ensureIndexIsVisible(n);
                    break;
                } else if ((artist.length() != 0) && info.artistMatch(artist)) {
                    findIndex = n;
                    list.setSelectedIndex(n);
                    list.ensureIndexIsVisible(n);
                    break;

                }
            }

        }
    }

    public void filterSongs() {
        String title = titleField.getText();
        String artist = artistField.getText();
        String number = numberField.getText();
        SongTable filteredSongs = allSongs;

        if (allSongs == null) {
            return;
        }

        if (title.length() != 0) {
            filteredSongs = filteredSongs.titleMatches(title);
        }
        if (artist.length() != 0) {
            filteredSongs = filteredSongs.artistMatches(artist);
        }
        if (number.length() != 0) {
            filteredSongs = filteredSongs.numberMatches(number);
        }

        model.clear();
        int n = 0;
        java.util.Iterator<SongInformation> iter = filteredSongs.iterator();
        while(iter.hasNext()) {
            model.add(n++, iter.next());
        }
    }

    public void resetSongs() {
        artistField.setText("");
        titleField.setText("");
        model.clear();
        int n = 0;
        java.util.Iterator<SongInformation> iter = allSongs.iterator();
        while(iter.hasNext()) {
            model.add(n++, iter.next());
        }
    }
    /**
     * "play" a song by printing its file path to standard out.
     * Can be used in a pipeline this way
     */
    public void playSong() {
        SongInformation song = (SongInformation) list.getSelectedValue();
        if (song == null) {
            return;
        }
        System.out.println(song.path);
    }

    public SongInformation getSelection() {
        return (SongInformation) (list.getSelectedValue());
    }

    class SongInformationRenderer extends JLabel implements ListCellRenderer {

        public Component getListCellRendererComponent(
                                                      JList list,
                                                      Object value,
                                                      int index,
                                                      boolean isSelected,
                                                      boolean cellHasFocus) {
            setText(value.toString());
            return this;
        }
    }
}

播放歌曲

每当“播放”一首歌曲时,它的文件路径都被写入标准输出。这使得它适用于 bash shell 管道,如下所示:

#!/bin/bash

VLC_OPTS="--play-and-exit --fullscreen"

java  SongTableSwing |
while read line
do
        if expr match "$line" ".*mp3"
        then
                vlc $VLC_OPTS "$line"
        elif expr match "$line" ".*zip"
        then
                rm -f /tmp/karaoke/*
                unzip -d /tmp/karaoke "$line"
                vlc $VLC_OPTS /tmp/karaoke/*.mp3
        fi
done

可见光通讯

VLC 是一个非常灵活的媒体播放器。它依靠大量的插件来增强其基本的核心功能。您在前面的章节中看到,如果一个目录包含一个 MP3 文件和一个具有相同基本名称的 CDG 文件,那么通过让它播放 MP3 文件,它也将显示 CDG 视频。

Karaoke 玩家的普遍期望是,你可以调整速度和音高。目前 VLC 不能调整音高,但它有一个插件来调整速度(同时保持音高不变)。这个插件可以通过 VLC 的 Lua 接口访问。设置完成后,您可以从启动 VLC 的进程(如命令行 shell)通过标准输入发送如下命令:

rate 1.04

这将改变速度,保持音高不变。

设置 VLC 接受来自stdin的 Lua 命令可通过以下命令选项完成:

vlc -I luaintf --lua-intf cli ...

注意,这去掉了标准的 GUI 控件(菜单等等),只从stdin开始控制 VLC。

目前,给 VLC 增加俯仰控制并不简单。深呼吸。

  • 关闭 PulseAudio,启动 Jack。
  • 运行jack-rack并安装TAP_pitch过滤器。
  • 用 Jack 输出运行 VLC。
  • 使用qjackctl,通过jack-rack钩住 VLC 输出,输出到系统。
  • 通过jack-rack图形用户界面控制俯仰。

通过网络播放歌曲

实际上,我想把服务器磁盘上的歌曲播放到与电视相连的 Raspberry Pi 或 CubieBoard 上,并从我腿上的上网本上控制播放。这是一个分布式系统。

在计算机上安装服务器文件很简单:可以使用 NFS、Samba 等等。我目前使用的sshfs如下:

sshfs -o idmap=user -o rw -o allow_other newmarch@192.168.1.101:/home/httpd/html /server

对于远程访问/控制,我用 TCP 客户机/服务器替换了上一节的run命令。在客户端,控制播放器,我有这个:

java SongTableSwing | client 192.168.1.7

在(Raspberry Pi/CubieBoard)服务器上,我运行以下代码:

#!/bin/bash
set -x
VLC_OPTS="--play-and-exit -f"

server |
while read line
do
        if expr match "$line" ".*mp3"
        then
                vlc $VLC_OPTS "$line"
        elif expr match "$line" ".*zip"
        then
                rm -f /tmp/karaoke/*
                unzip -d /tmp/karaoke "$line"
                vlc $VLC_OPTS /tmp/karaoke/*.mp3
        fi
done

客户机/服务器文件只是标准的 TCP 文件。客户端从标准输入中读取换行符结束的字符串,并将其写入服务器,服务器将同一行打印到标准输出中。这里是client.c:

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdlib.h>
#include <string.h>

#define SIZE 1024
char buf[SIZE];
#define PORT 13000
int main(int argc, char *argv[]) {
    int sockfd;
    int nread;
    struct sockaddr_in serv_addr;
    if (argc != 2) {
        fprintf(stderr, "usage: %s IPaddr\n", argv[0]);
        exit(1);
    }

    while (fgets(buf, SIZE , stdin) != NULL) {
        /* create endpoint */
        if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
            perror(NULL); exit(2);
        }
        /* connect to server */
        serv_addr.sin_family = AF_INET;
        serv_addr.sin_addr.s_addr = inet_addr(argv[1]);
        serv_addr.sin_port = htons(PORT);

        while (connect(sockfd,
                       (struct sockaddr *) &serv_addr,
                       sizeof(serv_addr)) < 0) {
            /* allow for timesouts etc */
            perror(NULL);
            sleep(1);
        }

        printf("%s", buf);
        nread = strlen(buf);
        /* transfer data and quit */
        write(sockfd, buf, nread);
        close(sockfd);
    }
}

这里是server.c:

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdlib.h>
#include <signal.h>

#define SIZE 1024
char buf[SIZE];
#define TIME_PORT 13000

int sockfd, client_sockfd;

void intHandler(int dummy) {
    close(client_sockfd);
    close(sockfd);
    exit(1);
}

int main(int argc, char *argv[]) {
    int sockfd, client_sockfd;
    int nread, len;
    struct sockaddr_in serv_addr, client_addr;
    time_t t;

    signal(SIGINT, intHandler);

    /* create endpoint */
    if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        perror(NULL); exit(2);
    }
    /* bind address */
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(TIME_PORT);
    if (bind(sockfd,
             (struct sockaddr *) &serv_addr,
             sizeof(serv_addr)) < 0) {
        perror(NULL); exit(3);
    }
    /* specify queue */
    listen(sockfd, 5);
    for (;;) {
        len = sizeof(client_addr);
        client_sockfd = accept(sockfd,
                               (struct sockaddr *) &client_addr,
                               &len);
        if (client_sockfd == -1) {
            perror(NULL); continue;
        }
        while ((nread = read(client_sockfd, buf, SIZE-1)) > 0) {
            buf[nread] = '\0';
            fputs(buf, stdout);
            fflush(stdout);
        }
        close(client_sockfd);
    }
}

结论

本章展示了如何为 MP3+G 文件构建播放器。

二十五、使用 Java 声音的 Karaoke 应用

Java 对 Karaoke 没有库支持。这太具体应用了。在这一章中,我给你一个可以播放 KAR 文件的 Karaoke 播放器的代码。播放器将显示两行要播放的歌词,已经播放的单词用红色突出显示。在顶部,它显示了一个简单的钢琴键盘,其音符在 MIDI 文件的通道 1 中播放。中间显示旋律线,中间竖线显示当前播放的音符。

玩家长得像图 25-1 。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 25-1。

User interface of karaoke player

图 25-2 显示了 UML 图。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 25-2。

Class diagram of karaoke player

资源

以下是一些资源:

Karaoke 播放器

KaraokePlayer类提取 Karaoke 文件的文件名,并创建一个MidiPlayer来处理该文件。

/*
 * KaraokePlayer.java
 *
 */

import javax.swing.*;

public class KaraokePlayer {

    public static void main(String[] args) throws Exception {
        if (args.length != 1) {
            System.err.println("KaraokePlayer: usage: " +
                             "KaraokePlayer <midifile>");
            System.exit(1);
        }
        String  strFilename = args[0];

        MidiPlayer midiPlayer = new MidiPlayer();
        midiPlayer.playMidiFile(strFilename);
    }
}

媒体播放机

MidiPlayer类从文件中创建一个Sequence。很多地方都需要序列信息,所以不是通过参数传递序列,而是存储在一个单独的(静态)对象中,一个SequenceInformation。这使得序列有效地成为系统的全局对象。

然后,播放器获得默认的音序器,并将 MIDI 事件传输到两个接收器对象:播放事件的默认合成器和管理所有 GUI 处理的DisplayReceiverSequencer方法getTransmitter()名不副实:每次调用它时,它都返回一个新的发送器,每次都向各自的接收器播放相同的事件。

以下摘自 Java SE 文档,具体来说,第十章,“发送和接收 MIDI 消息”( http://docs.oracle.com/javase/7/docs/technotes/guides/sound/programmer_guide/chapter10.html ):

  • 这段代码(在他们的例子中)引入了对MidiDevice.getTransmitter方法的双重调用,将结果分配给inPortTrans1inPortTrans2。如前所述,一个设备可以拥有多个发射机和接收机。每次为给定设备调用MidiDevice.getTransmitter()时,都会返回另一个发送器,直到没有更多的发送器可用为止,此时会抛出一个异常。

这样,序列器可以发送给两个接收器。

接收者没有得到MetaMessages。这些包含文本或歌词事件等信息。DisplayReceiver被注册为MetaEventListener,这样它就可以管理这些事件以及其他事件。

MidiPlayer如下所示:

import javax.sound.midi.MidiSystem;
import javax.sound.midi.InvalidMidiDataException;
import javax.sound.midi.Sequence;
import javax.sound.midi.Receiver;
import javax.sound.midi.Sequencer;
import javax.sound.midi.Transmitter;
import javax.sound.midi.MidiChannel;
import javax.sound.midi.MidiDevice;
import javax.sound.midi.Synthesizer;
import javax.sound.midi.ShortMessage;
import javax.sound.midi.SysexMessage;

import java.io.File;
import java.io.IOException;

public class MidiPlayer {

    private DisplayReceiver receiver;

    public  void playMidiFile(String strFilename) throws Exception {
        File    midiFile = new File(strFilename);

        /*
         *      We try to get a Sequence object, loaded with the content
         *      of the MIDI file.
         */
        Sequence        sequence = null;
        try {
            sequence = MidiSystem.getSequence(midiFile);
        }
        catch (InvalidMidiDataException e) {
            e.printStackTrace();
            System.exit(1);
        }
        catch (IOException e) {
            e.printStackTrace();
            System.exit(1);
        }

        if (sequence == null) {
            out("Cannot retrieve Sequence.");
        } else {
            SequenceInformation.setSequence(sequence);
            playMidi(sequence);
        }
    }

    public  void playMidi(Sequence sequence) throws Exception {

        Sequencer sequencer = MidiSystem.getSequencer(true);
        sequencer.open();
        sequencer.setSequence(sequence);

        receiver = new DisplayReceiver(sequencer);
        sequencer.getTransmitter().setReceiver(receiver);
        sequencer.addMetaEventListener(receiver);

        if (sequencer instance of Synthesizer) {
            Debug.println("Sequencer is also a synthesizer");
        } else {
            Debug.println("Sequencer is not a synthesizer");
        }
        //sequencer.start();

        /*
        Synthesizer synthesizer = MidiSystem.getSynthesizer();
        synthesizer.open();

        if (synthesizer.getDefaultSoundbank() == null) {
            // then you know that java sound is using the hardware soundbank
            Debug.println("Synthesizer using h/w soundbank");
        } else Debug.println("Synthesizer using s/w soundbank");

        Receiver synthReceiver = synthesizer.getReceiver();
        Transmitter seqTransmitter = sequencer.getTransmitter();
        seqTransmitter.setReceiver(synthReceiver);
        MidiChannel[] channels = synthesizer.getChannels();
        Debug.println("Num channels is " + channels.length);
        */
        sequencer.start();

        /* default synth doesn't support pitch bending
        Synthesizer synthesizer = MidiSystem.getSynthesizer();
        MidiChannel[] channels = synthesizer.getChannels();
        for (int i = 0; i < channels.length; i++) {
            System.out.printf("Channel %d has bend %d\n", i, channels[i].getPitchBend());
            channels[i].setPitchBend(16000);
            System.out.printf("Channel %d now has bend %d\n", i, channels[i].getPitchBend());
        }
        */

        /* set volume - doesn't work */
        /*KaraokeUML
        for (int i = 0; i < channels.length; i++) {
            channels[i].controlChange(7, 0);
        }
        */
        /*
        System.out.println("Turning notes off");
        for (int i = 0; i < channels.length; i++) {
            channels[i].allNotesOff();
            channels[i].allSoundOff();
        }
        */

        /* set volume - doesn't work either */
        /*
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }

        if (synthReceiver == MidiSystem.getReceiver())
            System.out.println("Reciver is default");
        else
            System.out.println("Reciver is not default");
        System.out.println("Receiver is " + synthReceiver.toString());
        //synthReceiver = MidiSystem.getReceiver();
        System.out.println("Receiver is now " + synthReceiver.toString());
        ShortMessage volMessage = new ShortMessage();
        int midiVolume = 1;
        for (Receiver rec: synthesizer.getReceivers()) {
            System.out.println("Setting vol on recveiver " + rec.toString());
        for (int i = 0; i < channels.length; i++) {
            try {
                // volMessage.setMessage(ShortMessage.CONTROL_CHANGE, i, 123, midiVolume);
                volMessage.setMessage(ShortMessage.CONTROL_CHANGE, i, 7, midiVolume);
            } catch (InvalidMidiDataException e) {
                e.printStackTrace();
}
            synthReceiver.send(volMessage, -1);
            rec.send(volMessage, -1);
        }
        }
        System.out.println("Changed midi volume");
        */
        /* master volume control using sysex */
        /* http://www.blitter.com/∼russtopia/MIDI/∼jglatt/tech/midispec/mastrvol.htm */
        /*
        SysexMessage sysexMessage = new SysexMessage();
        /* volume values from http://www.bandtrax.com.au/sysex.htm */
        /* default volume 0x7F * 128 + 0x7F from */
        /*
        byte[] data = {(byte) 0xF0, (byte) 0x7F, (byte) 0x7F, (byte) 0x04,
                       (byte) 0x01, (byte) 0x0, (byte) 0x7F, (byte) 0xF7};
        sysexMessage.setMessage(data, data.length);
        synthReceiver.send(sysexMessage, -1);
        for (Receiver rec: synthesizer.getReceivers()) {
            System.out.println("Setting vol on recveiver " + rec.toString());
            rec.send(sysexMessage, -1);
        }
        */
     }

    public DisplayReceiver getReceiver() {
        return receiver;
    }

    private static void out(String strMessage)
    {
        System.out.println(strMessage);
    }
}

显示接收器

DisplayReceiver收集了作为ReceiverShortMessages和作为MetaEventListenerMetaMessages。看音符和歌词都需要这些。

DisplayReceiver解码发送给它的笔记和文本。反过来,它将这些传递给一个MidiGUI来显示它们。这个类如下:

/**
 * DisplayReceiver
 *
 * Acts as a Midi receiver to the default Java Midi sequencer.
 * It collects Midi events and Midi meta messages from the sequencer.
 * these are handed to a UI object for display.
 *
 * The current UI object is a MidiGUI but could be replaced.
 */

import javax.sound.midi.*;
import javax.swing.SwingUtilities;

public class DisplayReceiver implements Receiver,
                                        MetaEventListener {
    private MidiGUI gui;KaraokeUML
    private Sequencer sequencer;
    private int melodyChannel = SequenceInformation.getMelodyChannel();

    public DisplayReceiver(Sequencer sequencer) {
        this.sequencer = sequencer;
        gui = new MidiGUI(sequencer);
    }

    public void close() {
    }

    /**
     * Called by a Transmitter to receive events
     * as a Receiver
     */
    public void send(MidiMessage msg, long timeStamp) {
        // Note on/off messages come from the midi player
        // but not meta messages

        if (msg instanceof ShortMessage) {
            ShortMessage smsg = (ShortMessage) msg;

            String strMessage = "Channel " + smsg.getChannel() + " ";

            switch (smsg.getCommand())
                {
                case Constants.MIDI_NOTE_OFF:
                    strMessage += "note Off " +
                        getKeyName(smsg.getData1()) + " " + timeStamp;
                    break;

                case Constants.MIDI_NOTE_ON:
                    strMessage += "note On " +
                        getKeyName(smsg.getData1()) + " " + timeStamp;
                    break;
                }
            Debug.println(strMessage);
            if (smsg.getChannel() == melodyChannel) {
                gui.setNote(timeStamp, smsg.getCommand(), smsg.getData1());
            }

        }
    }

    public void meta(MetaMessage msg) {
        Debug.println("Reciever got a meta message");
        if (((MetaMessage) msg).getType() == Constants.MIDI_TEXT_TYPE) {
            setLyric((MetaMessage) msg);
        } else if (((MetaMessage) msg).getType() == Constants.MIDI_END_OF_TRACK)  {
            System.exit(0);
        }
    }

    public void setLyric(MetaMessage message) {
        byte[] data = message.getData();
        String str = new String(data);
        Debug.println("Lyric +\"" + str + "\" at " + sequencer.getTickPosition());
        gui.setLyric(str);

    }

    private static String[] keyNames = {"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"};

    public static String getKeyName(int keyNumber) {
        if (keyNumber > 127) {
            return "illegal value";
        } else {
            int note = keyNumber % 12;
            int octave = keyNumber / 12;
            return keyNames[note] + (octave - 1);
        }
    }

}

图形系统

用两种方法调用MidiGUI:setLyric()setNote()。GUI 由三个主要区域组成:一个区域在旋律播放时提供“钢琴”视图(pianoPanel),一个区域显示完整的旋律音符(melodyPanel),一组Panel显示歌词。setNote()相当简单,因为它只调用了pianoPanel. setLyric()中的drawNote(),而pianoPanel. setLyric()要复杂得多。

大多数 Karaoke 播放器会显示几行歌词。随着歌词的播放,文本通常会改变颜色以与之匹配。到了一行末尾,焦点会切换到下一行,上一行会被另一行歌词替换。

每行必须容纳一行歌词。该线必须能够对播放的歌词做出反应。这由稍后显示的AttributedTextPanel处理。主要任务是将歌词中的变化传递给所选面板,以便它可以用正确的颜色显示它们。

这里MidiGUI的另一个主要任务是当检测到行尾时在AttributedTextPanel之间切换焦点,并更新下一行文本。新的文本行不能来自播放的歌词,而是必须从包含所有音符和歌词的序列中构建。便利类SequenceInformation(稍后显示)接受一个Sequence对象,并有一个方法提取一组LyricLine对象。显示一条线的每个面板都被赋予该数组中的一条线。

import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import javax.sound.midi.*;
import java.util.Vector;
import java.util.Map;
import java.io.*;

public class MidiGUI extends JFrame {
    //private GridLayout mgr = new GridLayout(3,1);
    private BorderLayout mgr = new BorderLayout();

    private PianoPanel pianoPanel;
    private MelodyPanel melodyPanel;

    private AttributedLyricPanel lyric1;
    private AttributedLyricPanel lyric2;
    private AttributedLyricPanel[] lyricLinePanels;
    private int whichLyricPanel = 0;

    private JPanel lyricsPanel = new JPanel();

    private Sequencer sequencer;
    private Sequence sequence;
    private Vector<LyricLine> lyricLines;

    private int lyricLine = -1;

    private boolean inLyricHeader = true;
    private Vector<DurationNote> melodyNotes;

    private Map<Character, String> pinyinMap;

    private int language;

    public MidiGUI(final Sequencer sequencer) {
        this.sequencer = sequencer;
        sequence = sequencer.getSequence();

        // get lyrics and notes from Sequence Info
        lyricLines = SequenceInformation.getLyrics();
        melodyNotes = SequenceInformation.getMelodyNotes();
        language = SequenceInformation.getLanguage();

        pianoPanel = new PianoPanel(sequencer);
        melodyPanel = new MelodyPanel(sequencer);

        pinyinMap = CharsetEncoding.loadPinyinMap();
        lyric1 = new AttributedLyricPanel(pinyinMap);
        lyric2 = new AttributedLyricPanel(pinyinMap);
        lyricLinePanels = new AttributedLyricPanel[] {
            lyric1, lyric2};

        Debug.println("Lyrics ");

        for (LyricLine line: lyricLines) {
            Debug.println(line.line + " " + line.startTick + " " + line.endTick +
                          " num notes " + line.notes.size());
        }

        getContentPane().setLayout(mgr);
        /*
        getContentPane().add(pianoPanel);
        getContentPane().add(melodyPanel);

        getContentPane().add(lyricsPanel);
        */
        getContentPane().add(pianoPanel, BorderLayout.PAGE_START);
        getContentPane().add(melodyPanel,  BorderLayout.CENTER);

        getContentPane().add(lyricsPanel,  BorderLayout.PAGE_END);

        lyricsPanel.setLayout(new GridLayout(2, 1));
        lyricsPanel.add(lyric1);
        lyricsPanel.add(lyric2);
        setLanguage(language);

        setText(lyricLinePanels[whichLyricPanel], lyricLines.elementAt(0).line);

        Debug.println("First lyric line: " + lyricLines.elementAt(0).line);
        if (lyricLine < lyricLines.size() - 1) {
            setText(lyricLinePanels[(whichLyricPanel+1) % 2], lyricLines.elementAt(1).line);
            Debug.println("Second lyric line: " + lyricLines.elementAt(1).line);
        }

        // handle window closing
        setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
        addWindowListener(new WindowAdapter() {
                public void windowClosing(WindowEvent e) {
                    sequencer.stop();
                    System.exit(0);
                }
            });

        // handle resize events
        addComponentListener(new ComponentAdapter() {
                public void componentResized(ComponentEvent e) {
                    Debug.printf("Component has resized to width %d, height %d\n",
                                      getWidth(), getHeight());
                    // force resize of children - especially the middle MelodyPanel
                    e.getComponent().validate();
                }
                public void componentShown(ComponentEvent e) {
                    Debug.printf("Component is visible with width %d, height %d\n",
                                      getWidth(), getHeight());
                }
            });

        setSize(1600, 900);
        setVisible(true);
    }

    public void setLanguage(int lang) {
        lyric1.setLanguage(lang);
        lyric2.setLanguage(lang);
    }

    /**
     * A lyric starts with a header section
     * We have to skip over that, but can pick useful
     * data out of it
     */

    /**
     * header format is
     *   \@Llanguage code
     *   \@Ttitle
     *   \@Tsinger
     */

    public void setLyric(String txt) {
        Debug.println("Setting lyric to " + txt);
        if (inLyricHeader) {
            if (txt.startsWith("@")) {
                Debug.println("Header: " + txt);
                return;
            } else {
                inLyricHeader = false;
            }
        }

        if ((lyricLine == -1) && (txt.charAt(0) == '\\')) {
            lyricLine = 0;
            colourLyric(lyricLinePanels[whichLyricPanel], txt.substring(1));
            // lyricLinePanels[whichLyricPanel].colourLyric(txt.substring(1));
            return;
        }

        if (txt.equals("\r\n") || (txt.charAt(0) == '/') || (txt.charAt(0) == '\\')) {
            if (lyricLine < lyricLines.size() -1)
                Debug.println("Setting next lyric line to \"" +
                              lyricLines.elementAt(lyricLine + 1).line + "\"");

            final int thisPanel = whichLyricPanel;
            whichLyricPanel = (whichLyricPanel + 1) % 2;

            Debug.println("Setting new lyric line at tick " +
                          sequencer.getTickPosition());

            lyricLine++;

            // if it's a \ r /, the rest of the txt should be the next  word to
            // be coloured

            if ((txt.charAt(0) == '/') || (txt.charAt(0) == '\\')) {
                Debug.println("Colouring newline of " + txt);
                colourLyric(lyricLinePanels[whichLyricPanel], txt.substring(1));
            }

            // Update the current line of text to show the one after next
            // But delay the update until 0.25 seconds after the next line
            // starts playing, to preserve visual continuity
            if (lyricLine + 1 < lyricLines.size()) {
                /*
                  long startNextLineTick = lyricLines.elementAt(lyricLine).startTick;
                  long delayForTicks = startNextLineTick - sequencer.getTickPosition();
                  Debug.println("Next  current "  + startNextLineTick + " " + sequencer.getTickPosition());
                  float microSecsPerQNote = sequencer.getTempoInMPQ();
                  float delayInMicroSecs = microSecsPerQNote * delayForTicks / 24 + 250000L;
                */

                final Vector<DurationNote> notes = lyricLines.elementAt(lyricLine).notes;

                final int nextLineForPanel = lyricLine + 1;

                if (lyricLines.size() >= nextLineForPanel) {
                    Timer timer = new Timer((int) 1000,
                                            new ActionListener() {
                                                public void actionPerformed(ActionEvent e) {
                                                    if (nextLineForPanel >= lyricLines.size()) {
                                                        return;
                                                    }
                                                    setText(lyricLinePanels[thisPanel], lyricLines.elementAt(nextLineForPanel).line);
                                                    //lyricLinePanels[thisPanel].setText(lyricLines.elementAt(nextLineForPanel).line);

                                                }
                                            });
                    timer.setRepeats(false);
                    timer.start();
                } else {
                    // no more lines
                }
            }
        } else {
            Debug.println("Playing lyric " + txt);
            colourLyric(lyricLinePanels[whichLyricPanel], txt);
            //lyricLinePanels[whichLyricPanel].colourLyric(txt);
        }
    }

    /**
     * colour the lyric of a panel.
     * called by one thread, makes changes in GUI thread
     */
    private void colourLyric(final AttributedLyricPanel p, final String txt) {
        SwingUtilities.invokeLater(new Runnable() {
                public void run() {
                    Debug.print("Colouring lyric \"" + txt + "\"");
                    if (p == lyric1) Debug.println(" on panel 1");
                    else Debug.println(" on panel 2");
                    p.colourLyric(txt);
                }
            }
            );
    }

    /**
     * set the lyric of a panel.
     * called by one thread, makes changes in GUI thread
     */
    private void setText(final AttributedLyricPanel p, final String txt) {
        SwingUtilities.invokeLater(new Runnable() {
                public void run() {
                    Debug.println("Setting text \"" + txt + "\"");
                    if (p == lyric1) Debug.println(" on panel 1");
                    else Debug.println(" on panel 2");
                    p.setText(txt);
                }
            }
            );
    }

    public void setNote(long timeStamp, int onOff, int note) {
        Debug.printf("Setting note in gui to %d\n", note);

        if (onOff == Constants.MIDI_NOTE_OFF) {
            pianoPanel.drawNoteOff(note);
        } else if (onOff == Constants.MIDI_NOTE_ON) {
            pianoPanel.drawNoteOn(note);
        }
    }
}

AttributedLyricPanel

显示一行歌词的面板必须能够以两种颜色显示文本:已经播放的歌词和尚未播放的歌词。Java AttributedText类对此很有用,因为文本可以用不同的属性标记,比如颜色。这被包裹在一个AttributedTextPanel中,稍后显示。

一个小问题与语言有关。中文既有汉字形式,也有一种罗马化的拼音形式。说中文的人可以阅读汉字。像我这样的人只能理解拼音形式。因此,如果语言是中文,那么AttributedTextPanel会在汉字旁边显示拼音。语言身份也应该传递给AttributedLyricPanel

AttributedLyricPanel如下所示:

import javax.swing.*;
import java.awt.*;
import java.awt.font.*;
import java.text.*;
import java.util.Map;

public class AttributedLyricPanel extends JPanel {

    private final int PINYIN_Y = 40;
    private final int TEXT_Y = 90;

    private String text;
    private AttributedString attrText;
    private int coloured = 0;
    private Font font = new Font(Constants.CHINESE_FONT, Font.PLAIN, 36);
    private Font smallFont = new Font(Constants.CHINESE_FONT, Font.PLAIN, 24);
    private Color red = Color.RED;
    private int language;
    private String pinyinTxt = null;

    private Map<Character, String> pinyinMap = null;

    public AttributedLyricPanel(Map<Character, String> pinyinMap) {
        this.pinyinMap = pinyinMap;
    }

    public Dimension getPreferredSize() {
        return new Dimension(1000, TEXT_Y + 20);
    }

    public void setLanguage(int lang) {
        language = lang;
        Debug.printf("Lang in panel is %X\n", lang);
    }

    public boolean isChinese() {
        switch (language) {
        case SongInformation.CHINESE1:
        case SongInformation.CHINESE2:
        case SongInformation.CHINESE8:
        case SongInformation.CHINESE131:
        case SongInformation.TAIWANESE3:
        case SongInformation.TAIWANESE7:
        case SongInformation.CANTONESE:
            return true;
        }
        return false;
    }

    public void setText(String txt) {
        coloured = 0;
        text = txt;
        Debug.println("set text " + text);
        attrText = new AttributedString(text);
        if (text.length() == 0) {
            return;
        }
        attrText.addAttribute(TextAttribute.FONT, font, 0, text.length());

        if (isChinese()) {
            pinyinTxt = "";
            for (int n = 0; n < txt.length(); n++) {
                char ch = txt.charAt(n);
                String pinyin = pinyinMap.get(ch);
                if (pinyin != null) {
                    pinyinTxt += pinyin + " ";
                } else {
                    Debug.printf("No pinyin map for character \"%c\"\n", ch);
                }
            }

        }

        repaint();
    }

    public void colourLyric(String txt) {
        coloured += txt.length();
        if (coloured != 0) {
            repaint();
        }
    }

    /**
     * Draw the string with the first part in red, rest in green.
     * String is centred
     */

    @Override
    public void paintComponent(Graphics g) {
        if ((text.length() == 0) || (coloured > text.length())) {
            return;
        }
        g.setFont(font);
        FontMetrics metrics = g.getFontMetrics();
        int strWidth = metrics.stringWidth(text);
        int panelWidth = getWidth();
        int offset = (panelWidth - strWidth) / 2;

        if (coloured != 0) {
            try {
                attrText.addAttribute(TextAttribute.FOREGROUND, red, 0, coloured);
            } catch(Exception e) {
                System.out.println(attrText.toString() + " " + e.toString());
            }
        }
        g.clearRect(0, 0, getWidth(), getHeight());
        try {
            g.drawString(attrText.getIterator(), offset, TEXT_Y);
        } catch (Exception e) {
            System.err.println("Attr Str exception on " + text);
        }
        // Draw the Pinyin if it's not zero
        if (pinyinTxt != null && pinyinTxt.length() != 0) {
            g.setFont(smallFont);
            metrics = g.getFontMetrics();
            strWidth = metrics.stringWidth(pinyinTxt);
            offset = (panelWidth - strWidth) / 2;

            g.drawString(pinyinTxt, offset, PINYIN_Y);
            g.setFont(font);
        }
    }
}

钢琴面板

PianoPanel展示了一个类似钢琴的键盘。当音符打开时,它会将音符涂成蓝色,并将之前播放的任何音符恢复正常。关闭便笺时,便笺会恢复正常颜色(黑色或白色)。

音符着色由setNote调用,因为没有来自音序器的开/关信息。

PianoPanel如下所示:

import java.util.Vector;
import javax.swing.*;
import java.awt.*;
import javax.sound.midi.*;

public class PianoPanel extends JPanel {

    private final int HEIGHT = 100;
    private final int HEIGHT_OFFSET = 10;

    long timeStamp;
    private Vector<DurationNote> notes;
    private Vector<DurationNote> sungNotes;
    private int lastNoteDrawn = -1;
    private Sequencer sequencer;
    private Sequence sequence;
    private int maxNote;
    private int minNote;

    private Vector<DurationNote> unresolvedNotes = new Vector<DurationNote> ();

    private int playingNote = -1;

    public PianoPanel(Sequencer sequencer) {

        maxNote = SequenceInformation.getMaxMelodyNote();
        minNote = SequenceInformation.getMinMelodyNote();
        Debug.println("Max: " + maxNote + " Min " + minNote);
    }

    public Dimension getPreferredSize() {
        return new Dimension(1000, 120);
    }

    public void drawNoteOff(int note) {
        if (note < minNote || note > maxNote) {
            return;
        }

        Debug.println("Note off played is " + note);
        if (note != playingNote) {
            // Sometimes "note off" followed immediately by "note on"
            // gets mixed up to "note on" followed by "note off".
            // Ignore the "note off" since the next note has already
            // been processed
            Debug.println("Ignoring note off");
            return;
        }
        playingNote = -1;
        repaint();
    }

    public void drawNoteOn(int note) {
        if (note < minNote || note > maxNote) {
            return;
        }

        Debug.println("Note on played is " + note);
        playingNote = note;
        repaint();

    }

    private void drawPiano(Graphics g, int width, int height) {
        int noteWidth = width / (Constants.MIDI_NOTE_C8 - Constants.MIDI_NOTE_A0);
        for (int noteNum =  Constants.MIDI_NOTE_A0; // A0
             noteNum <=  Constants.MIDI_NOTE_C8; // C8
             noteNum++) {

            drawNote(g, noteNum, noteWidth);
        }
    }

    private void drawNote(Graphics g, int noteNum, int width) {
        if (isWhite(noteNum)) {
            noteNum -= Constants.MIDI_NOTE_A0;
            g.setColor(Color.WHITE);
            g.fillRect(noteNum*width, HEIGHT_OFFSET, width, HEIGHT);
            g.setColor(Color.BLACK);
            g.drawRect(noteNum*width, HEIGHT_OFFSET, width, HEIGHT);
        } else {
            noteNum -= Constants.MIDI_NOTE_A0;
            g.setColor(Color.BLACK);
            g.fillRect(noteNum*width, HEIGHT_OFFSET, width, HEIGHT);
        }
        if (playingNote != -1) {
            g.setColor(Color.BLUE);
            g.fillRect((playingNote - Constants.MIDI_NOTE_A0) * width, HEIGHT_OFFSET, width, HEIGHT);
        }
    }

    private boolean isWhite(int noteNum) {
        noteNum = noteNum % 12;
        switch (noteNum) {
        case 1:
        case 3:
        case 6:
        case 8:
        case 10:
        case 13:
            return false;
        default:
            return true;
        }
    }

    @Override
    public void paintComponent(Graphics g) {

        int ht = getHeight();
        int width = getWidth();

        drawPiano(g, width, ht);

    }
}

melodyangel

MelodyPanel是一个滚动面板,显示旋律的所有音符。当前播放的音符在显示屏上居中。这是通过将所有音符绘制到一个BufferedImage中,然后每 50 毫秒复制一次相关部分来完成的。

MelodyPanel如下所示:

import java.util.Vector;
import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import javax.sound.midi.*;
import java.awt.image.BufferedImage;
import java.io.*;
import javax.imageio.*;

public class MelodyPanel extends JPanel {

    private static int DBL_BUF_SCALE = 2;
    private static final int NOTE_HEIGHT = 10;
    private static final int SLEEP_MSECS = 5;

    private long timeStamp;
    private Vector<DurationNote> notes;
    private Sequencer sequencer;
    private Sequence sequence;
    private int maxNote;
    private int minNote;
    private long tickLength = -1;
    private long currentTick = -1;
    private Image image = null;

    /**
     * The panel where the melody notes are shown in a
     * scrolling panel
     */
    public MelodyPanel(Sequencer sequencer) {

        maxNote = SequenceInformation.getMaxMelodyNote();
        minNote = SequenceInformation.getMinMelodyNote();
        Debug.println("Max: " + maxNote + " Min " + minNote);
        notes = SequenceInformation.getMelodyNotes();
        this.sequencer = sequencer;
        tickLength = sequencer.getTickLength() + 1000; // hack to make white space at end, plus fix bug

        //new TickPointer().start();
        // handle resize events
        addComponentListener(new ComponentAdapter() {
                public void componentResized(ComponentEvent e) {
                    Debug.printf("Component melody panel has resized to width %d, height %d\n",
                                      getWidth(), getHeight());
                }
                public void componentShown(ComponentEvent e) {
                    Debug.printf("Component malody panel is visible with width %d, height %d\n",
                                      getWidth(), getHeight());
                }
            });

    }

    /**
     * Redraw the melody image after each tick
     * to give a scrolling effect
     */
    private class TickPointer extends Thread {
        public void run() {
            while (true) {
                currentTick = sequencer.getTickPosition();
                MelodyPanel.this.repaint();
                /*
                SwingUtilities.invokeLater(
                                            new Runnable() {
                                                public void run() {
                                                    synchronized(MelodyPanel.this) {
                                                    MelodyPanel.this.repaint();
                                                    }
                                                }
                                            });
                */
                try {
                    sleep(SLEEP_MSECS);
                } catch (Exception e) {
                    // keep going
                    e.printStackTrace();
                }
            }
        }

    }

    /**
     * Draw the melody into a buffer so we can just copy bits to the screen
     */
    private void drawMelody(Graphics g, int front, int width, int height) {
        try {
        g.setColor(Color.WHITE);
        g.fillRect(0, 0, width, height);
        g.setColor(Color.BLACK);

        String title = SequenceInformation.getTitle();
        if (title != null) {
            //Font f = new Font("SanSerif", Font.ITALIC, 40);
            Font f = new Font(Constants.CHINESE_FONT, Font.ITALIC, 40);
            g.setFont(f);
            int strWidth = g.getFontMetrics().stringWidth(title);
            g.drawString(title, (front - strWidth/2), height/2);
            Debug.println("Drawn title " + title);
        }

        for (DurationNote note: notes) {
            long startNote = note.startTick;
            long endNote = note.endTick;
            int value = note.note;

            int ht = (value - minNote) * (height - NOTE_HEIGHT) / (maxNote - minNote) + NOTE_HEIGHT/2;
            // it's upside down
            ht = height - ht;

            long start = front + (int) (startNote * DBL_BUF_SCALE);
            long end = front + (int) (endNote * DBL_BUF_SCALE);

            drawNote(g, ht, start, end);
            //g.drawString(title, (int)start, (int)height/2);
        }
        } catch(Exception e) {
            System.err.println("Drawing melody error " + e.toString());
        }
    }

    /**
     * Draw a horizontal bar to represent a nore
     */
    private void drawNote(Graphics g, int height, long start, long end) {
        Debug.printf("Drawing melody at start %d end %d height %d\n", start, end,  height - NOTE_HEIGHT/2);

        g.fillRect((int) start, height - NOTE_HEIGHT/2, (int) (end-start), NOTE_HEIGHT);
    }

    /**
     * Draw a vertical line in the middle of the screen to
     * represent where we are in the playing notes
     */
    private void paintTick(Graphics g, long width, long height) {
        long x = (currentTick * width) / tickLength;
        g.drawLine((int) width/2, 0, (int) width/2, (int) height);
        //System.err.println("Painted tcik");
    }

    // leave space at the front of the image to draw title, etc
    int front = 1000;

    /**
     * First time, draw the melody notes into an off-screen buffer
     * After that, copy a segment of the buffer into the image,
     * with the centre of the image the current note
     */
    @Override
    public void paintComponent(Graphics g) {
        int ht = getHeight();
        int width = getWidth();
        //int front = width / 2;

        synchronized(this) {
        if (image == null) {
            /*
             * We want to stretch out the notes so that they appear nice and wide on the screen.
             * A DBL_BUF_SCALE of 2 does this okay. But then tickLength * DBL_BUF_SCALE may end
             * up larger than an int, and we can't make a BufferedImage wider than MAXINT.
             * So we may have to adjust DBL_BUF_SCALE.
             *
             * Yes, I know we ask Java to rescale images on the fly, but that costs in runtime.
             */

            Debug.println("tick*DBLBUFSCALE " + tickLength * DBL_BUF_SCALE);

            if ((long) (tickLength * DBL_BUF_SCALE) > (long) Short.MAX_VALUE) {
                // DBL_BUF_SCALE = ((float)  Integer.MAX_VALUE) / ((float) tickLength);
                DBL_BUF_SCALE = 1;
                Debug.println("Adjusted DBL_BUF_SCALE to "+ DBL_BUF_SCALE);
            }

            Debug.println("DBL_BUF_SCALE is "+ DBL_BUF_SCALE);

            // draw melody into a buffered image
            Debug.printf("New buffered img width %d ht %d\n", tickLength, ht);
            image = new BufferedImage(front + (int) (tickLength * DBL_BUF_SCALE), ht, BufferedImage.TYPE_INT_RGB);
            Graphics ig = image.getGraphics();
            drawMelody(ig, front, (int) (tickLength * DBL_BUF_SCALE), ht);
            new TickPointer().start();

            try {
                File outputfile = new File("saved.png");
                ImageIO.write((BufferedImage) image, "png", outputfile);
            } catch (Exception e) {
                System.err.println("Error in image write " + e.toString());
            }

        }
        //System.err.printf("Drawing img from %d ht %d width %d\n",
        //                front + (int) (currentTick * DBL_BUF_SCALE - width/2), ht, width);

        boolean b = g.drawImage(image, 0, 0, width, ht,
                                front + (int) (currentTick * DBL_BUF_SCALE - width/2), 0,
                                front + (int) (currentTick * DBL_BUF_SCALE + width/2), ht,
                    null);
        /*System.out.printf("Ht of BI %d, width %d\n", ((BufferedImage)image).getHeight(),
                          ((BufferedImage) image).getWidth());
        */

        //if (b) System.err.println("Drawn ok"); else System.err.println("NOt drawn ok");
        paintTick(g, width, ht);
        }
    }

}

序列信息

SequenceInformation类是一个方便的类,由其他几个类使用。它存储序列、歌词线和旋律音符的副本,以通过用户界面显示歌词和旋律,以及歌曲标题、设置音符显示比例的最大和最小音符,以及旋律在哪个频道上。

public class SequenceInformation {

    private static Sequence sequence = null;
    private static Vector<LyricLine> lyricLines = null;
    private static Vector<DurationNote> melodyNotes = null;
    private static int lang = -1;
    private static String title = null;
    private static String performer = null;
    private static int maxNote;
    private static int minNote;

    private static int melodyChannel = -1;// no such channel
    ...
}

该类的方法如下:

public static void setSequence(Sequence seq)
public static long getTickLength()
public static int getMelodyChannel()
public static int getLanguage()
public static String getTitle()
public static String getPerformer()
public static Vector<LyricLine> getLyrics()
public static Vector<DurationNote> getMelodyNotes()
public static int getMaxMelodyNote()
public static int getMinMelodyNote()

getLyrics()的代码需要遍历序列中的轨道,寻找MIDI_TEXT_TYPE类型的MetaMessage,然后将它们添加到当前行,或者在换行符上开始新的一行。在这个过程中,它从文件的开始处获取表演者和标题的元数据。

    /*
     * Build a vector of lyric lines
     * Each line has a start and an end tick
     * and a string for the lyrics in that line
     */
    public static Vector<LyricLine> getLyrics() {
        if (lyricLines != null) {
            return lyricLines;
        }

        lyricLines = new Vector<LyricLine> ();
        LyricLine nextLyricLine = new LyricLine();
        StringBuffer buff = new StringBuffer();
        long ticks = 0L;

        Track[] tracks = sequence.getTracks();
        for (int nTrack = 0; nTrack < tracks.length; nTrack++) {
            for (int n = 0; n < tracks[nTrack].size(); n++) {
                MidiEvent evt = tracks[nTrack].get(n);
                MidiMessage msg = evt.getMessage();
                ticks = evt.getTick();

                if (msg instanceof MetaMessage) {
                    Debug.println("Got a meta mesg in seq");
                    if (((MetaMessage) msg).getType() == Constants.MIDI_TEXT_TYPE) {
                        MetaMessage message = (MetaMessage) msg;

                        byte[] data = message.getData();
                        String str = new String(data);
                        Debug.println("Got a text mesg in seq \"" + str + "\" " + ticks);

                        if (ticks == 0) {
                            if (str.startsWith("@L")) {
                                lang = decodeLang(str.substring(2));
                            } else if (str.startsWith("@T")) {
                                if (title == null) {
                                    title = str.substring(2);
                                } else {
                                    performer = str.substring(2);
                                }
                            }

                        }
                        if (ticks > 0) {
                            //if (str.equals("\r") || str.equals("\n")) {
                            if ((data[0] == '/') || (data[0] == '\\')) {
                                if (buff.length() == 0) {
                                    // blank line -  maybe at start of song
                                    // fix start time from NO_TICK
                                    nextLyricLine.startTick = ticks;
                                } else {
                                    nextLyricLine.line = buff.toString();
                                    nextLyricLine.endTick = ticks;
                                    lyricLines.add(nextLyricLine);
                                    buff.delete(0, buff.length());

                                    nextLyricLine = new LyricLine();
                                }
                                buff.append(str.substring(1));
                            } else {
                                if (nextLyricLine.startTick == Constants.NO_TICK) {
                                    nextLyricLine.startTick = ticks;
                                }
                                buff.append(str);
                            }
                        }
                    }
                }
            }
            // save last line (but only once)
            if (buff.length() != 0) {
                nextLyricLine.line = buff.toString();
                nextLyricLine.endTick = ticks;
                lyricLines.add(nextLyricLine);
                buff.delete(0, buff.length());
            }
        }
        if (Debug.DEBUG) {
            dumpLyrics();
        }
        return lyricLines;
    }

getMelodyNotes()的代码遍历序列,在旋律通道中寻找 MIDI 开/关音符。代码有点混乱,因为一些歌曲有“不干净”的数据:它们可能包含超出允许范围的音符值,有时会重叠,而不是一个音符在下一个音符开始之前结束。这段代码如下:

    /*
     * gets a vector of lyric notes
     * side-effect: sets last tick
     */
    public static Vector<DurationNote> getMelodyNotes() {
        if (melodyChannel == -1) {
            getMelodyChannel();
        }

        if (melodyNotes != null) {
            return melodyNotes;
        }

        melodyNotes = new Vector<DurationNote> ();
        Vector<DurationNote> unresolvedNotes = new Vector<DurationNote> ();

        Track[] tracks = sequence.getTracks();
        for (int nTrack = 0; nTrack < tracks.length; nTrack++) {
            for (int n = 0; n < tracks[nTrack].size(); n++) {
                MidiEvent evt = tracks[nTrack].get(n);
                MidiMessage msg = evt.getMessage();
                long ticks = evt.getTick();

                if (msg instanceof ShortMessage) {
                    ShortMessage smsg= (ShortMessage) msg;
                    if (smsg.getChannel() == melodyChannel) {
                        int note = smsg.getData1();
                        if (note < Constants.MIDI_NOTE_A0 || note > Constants.MIDI_NOTE_C8) {
                            continue;
                        }

                        if (smsg.getCommand() == Constants.MIDI_NOTE_ON) {
                            // note on
                            DurationNote dnote = new DurationNote(ticks, note);
                            melodyNotes.add(dnote);
                            unresolvedNotes.add(dnote);

                        } else if (smsg.getCommand() == Constants.MIDI_NOTE_OFF) {
                            // note off
                            for (int m = 0; m < unresolvedNotes.size(); m++) {
                                DurationNote dnote = unresolvedNotes.elementAt(m);
                                if (dnote.note == note) {
                                    dnote.duration = ticks - dnote.startTick;
                                    dnote.endTick = ticks;
                                    unresolvedNotes.remove(m);
                                }
                            }

                        }

                    }
                }
            }
        }
        return melodyNotes;
    }

任何复杂度的最后一个方法是getMelodyChannel()。MIDI 信息不区分哪个通道包含旋律。大多数歌曲都有 1 频道的旋律,但不是全部。因此,必须使用一种启发式方法:搜索第一个要播放的音符非常接近第一个真正歌词的频道。这不是 100%可靠的。

    public static int getMelodyChannel() {
        boolean firstNoteSeen[] = {false, false, false, false, false, false, false, false,
                                   false, false, false, false, false, false, false, false};
        boolean possibleChannel[] = {false, false, false, false, false, false, false, false,
                                   false, false, false, false, false, false, false, false};
        if (melodyChannel != -1) {
            return melodyChannel;
        }

        if (lyricLines == null) {
            lyricLines = getLyrics();
        }

        long startLyricTick = ((LyricLine) lyricLines.get(0)).startTick;
        Debug.printf("Lyrics start at %d\n", startLyricTick);

        Track[] tracks = sequence.getTracks();
        for (int nTrack = 0; nTrack < tracks.length; nTrack++) {
            Track track = tracks[nTrack];
            for (int nEvent = 0; nEvent < track.size(); nEvent++) {
                MidiEvent evt = track.get(nEvent);
                MidiMessage msg = evt.getMessage();
                if (msg instanceof ShortMessage) {
                    ShortMessage smsg= (ShortMessage) msg;
                    int channel = smsg.getChannel();
                    if (firstNoteSeen[channel]) {
                        continue;
                    }
                    if (smsg.getCommand() == Constants.MIDI_NOTE_ON) {
                        long tick = evt.getTick();
                        Debug.printf("First note on for channel %d at tick %d\n",
                                          channel, tick);
                        firstNoteSeen[channel] = true;
                        if (Math.abs(startLyricTick - tick) < 10) {
                            // close enough - we hope!
                            melodyChannel = channel;
                            possibleChannel[channel] = true;
                            Debug.printf("Possible melody channel is %d\n", channel);
                        }
                        if (tick > startLyricTick + 11) {
                            break;
                        }
                    }
                }
            }
        }

        return melodyChannel;
    }

其他方法相对简单,在此省略。

拼音

对于中文文件,我的目标之一是显示中国象形文字的拼音(罗马化形式)。为此,我需要能够将任何序列的汉字改写成拼音形式。我找不到字符及其对应字符的列表。最接近的是汉英词典( www.mandarintools.com/worddict.html ),你可以从里面下载词典作为文本文件。该文件中的典型行如下:

不賴 不赖 [bu4 lai4] /not bad/good/fine/

每一行都有繁体字、简化字、[…]的拼音,然后是英文意思。

我使用下面的 shell 脚本创建了一个字符/拼音对列表:

#!/bin/bash

# get pairs of character + pinyin by throwing away other stuff in the dictionary

awk '{print $2, $3}' cedict_ts.u8 | grep -v '[A-Z]' |
  grep -v '^.[^ ]' | sed -e 's/\[//' -e 's/\]//' -e 's/[0-9]$//' |
    sort | uniq -w 1 > pinyinmap.txt

给出如下所示的行:

好 hao
妁 shuo
如 ru
妃 fei

然后可以将它读入 Java Map,然后可以进行快速查找,将中文翻译成拼音。

带采样的 Karaoke 播放器

到目前为止所描述的 Karaoke 播放器在功能上等同于kmidipykar。它播放 KAR 文件,显示音符,滚动歌词。要跟着唱,你需要使用 ALSA 或 PulseAudio 播放器。

但是 Java 也可以播放样本声音,这在前面的章节中已经讨论过了。因此,可以将该代码引入 Karaoke 播放器,以提供更完整的解决方案。对于 MIDI,Java 通常只给出一个 Gervill 合成器,这是一个通过 PulseAudio 默认设备播放的软件合成器。实际的输出设备不能通过 Java 访问,而是由底层的 PulseAudio 输出设备控制。但是对于采样媒体,输入设备是可以控制的。因此,在下面的代码中,选择框允许选择采样的输入设备,并将输出设备保留为默认值。

/*
 * KaraokePlayer.java
 *
 */

import javax.swing.*;
import javax.sound.sampled.*;

public class KaraokePlayerSampled {

    public static void main(String[] args) throws Exception {
        if (args.length != 1) {
            System.err.println("KaraokePlayer: usage: " +
                               "KaraokePlayer <midifile>");
            System.exit(1);
        }
        String  strFilename = args[0];

        Mixer.Info[] mixerInfo = AudioSystem.getMixerInfo();

        String[] possibleValues = new String[mixerInfo.length];
        for(int cnt = 0; cnt < mixerInfo.length; cnt++){
            possibleValues[cnt] = mixerInfo[cnt].getName();
        }
        Object selectedValue = JOptionPane.showInputDialog(null, "Choose mixer", "Input",
                                                       JOptionPane.INFORMATION_MESSAGE, null,
                                                       possibleValues, possibleValues[0]);

        System.out.println("Mixer string selected " + ((String)selectedValue));

        Mixer mixer = null;
        for(int cnt = 0; cnt < mixerInfo.length; cnt++){
            if (mixerInfo[cnt].getName().equals((String)selectedValue)) {
                mixer = AudioSystem.getMixer(mixerInfo[cnt]);
                System.out.println("Got a mixer");
                break;
            }
        }//end for loop

        MidiPlayer midiPlayer = new MidiPlayer();
        midiPlayer.playMidiFile(strFilename);

        SampledPlayer sampledPlayer = new SampledPlayer(/* midiPlayer.getReceiver(), */ mixer);
        sampledPlayer.playAudio();
    }
}

播放样本媒体的代码与您之前看到的非常相似。

import java.io.IOException;

import javax.sound.sampled.Line;
import javax.sound.sampled.Mixer;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.DataLine;
import javax.sound.sampled.TargetDataLine;
import javax.sound.sampled.FloatControl;
import javax.sound.sampled.LineUnavailableException;
import javax.sound.sampled.SourceDataLine;
import javax.sound.sampled.Control;

import javax.swing.*;

public class SampledPlayer {

    private DisplayReceiver receiver;
    private Mixer mixer;

    public SampledPlayer(/* DisplayReceiver receiver, */ Mixer mixer) {
        this.receiver = receiver;
        this.mixer = mixer;
    }

    //This method creates and returns an
    // AudioFormat object for a given set of format
    // parameters.  If these parameters don't work
    // well for you, try some of the other
    // allowable parameter values, which are shown
    // in comments following the declarations.
    private static AudioFormat getAudioFormat(){
        float sampleRate = 44100.0F;
        //8000,11025,16000,22050,44100
        int sampleSizeInBits = 16;
        //8,16
        int channels = 1;
        //1,2
        boolean signed = true;
        //true,false
        boolean bigEndian = false;
        //true,false
        return new AudioFormat(sampleRate,
                               sampleSizeInBits,
                               channels,
                               signed,
                               bigEndian);
    }//end getAudioFormat

    public  void playAudio() throws Exception {
        AudioFormat audioFormat;
        TargetDataLine targetDataLine;

        audioFormat = getAudioFormat();
        DataLine.Info dataLineInfo =
            new DataLine.Info(
                              TargetDataLine.class,
                              audioFormat);
        targetDataLine = (TargetDataLine)
            AudioSystem.getLine(dataLineInfo);

        targetDataLine.open(audioFormat,
                            audioFormat.getFrameSize() * Constants.FRAMES_PER_BUFFER);
        targetDataLine.start();

        /*
        for (Control control: targetDataLine.getControls()) {
            System.out.println("Target control: " + control.getType());
        }
        */

        playAudioStream(new AudioInputStream(targetDataLine), mixer);
    } // playAudioFile

    /** Plays audio from the given audio input stream. */
    public  void playAudioStream(AudioInputStream audioInputStream, Mixer mixer) {

        new AudioPlayer(audioInputStream, mixer).start();
    } // playAudioStream

    class AudioPlayer extends Thread {
        AudioInputStream audioInputStream;
        SourceDataLine dataLine;
        AudioFormat audioFormat;

        // YIN stuff
        // PitchProcessorWrapper ppw;

        AudioPlayer( AudioInputStream audioInputStream, Mixer mixer) {
            this.audioInputStream = audioInputStream;

            // Set to nearly max, like Midi sequencer does
            Thread curr = Thread.currentThread();
            Debug.println("Priority on sampled: " + curr.getPriority());
            int priority = Thread.NORM_PRIORITY
                + ((Thread.MAX_PRIORITY - Thread.NORM_PRIORITY) * 3) / 4;
            curr.setPriority(priority);
            Debug.println("Priority now on sampled: " + curr.getPriority());

            // Audio format provides information like sample rate, size, channels.
            audioFormat = audioInputStream.getFormat();
            Debug.println( "Play input audio format=" + audioFormat );

            // Open a data line to play our type of sampled audio.
            // Use SourceDataLine for play and TargetDataLine for record.

            if (mixer == null) {
                System.out.println("can't find a mixer");
            } else {
                Line.Info[] lines = mixer.getSourceLineInfo();
                if (lines.length >= 1) {
                    try {
                        dataLine = (SourceDataLine) AudioSystem.getLine(lines[0]);
                        System.out.println("Got a source line for " + mixer.toString());
                    } catch(Exception e) {
                    }
                } else {
                    System.out.println("no source lines for this mixer " + mixer.toString());
                }
            }

                for (Control control: mixer.getControls()) {
                    System.out.println("Mixer control: " + control.getType());
                }

            DataLine.Info info = null;
            if (dataLine == null) {
                info = new DataLine.Info( SourceDataLine.class, audioFormat );
                if ( !AudioSystem.isLineSupported( info ) ) {
                    System.out.println( "Play.playAudioStream does not handle this type of audio on this system." );
                    return;
                }
            }

            try {
                // Create a SourceDataLine for play back (throws LineUnavailableException).
                if (dataLine == null) {
                    dataLine = (SourceDataLine) AudioSystem.getLine( info );
                }
                Debug.println( "SourceDataLine class=" + dataLine.getClass() );

                // The line acquires system resources (throws LineAvailableException).
                dataLine.open( audioFormat,
                               audioFormat.getFrameSize() * Constants.FRAMES_PER_BUFFER);

                for (Control control: dataLine.getControls()) {
                    System.out.println("Source control: " + control.getType());
                }
                // Adjust the volume on the output line.
                if( dataLine.isControlSupported( FloatControl.Type.VOLUME) ) {
                    // if( dataLine.isControlSupported( FloatControl.Type.MASTER_GAIN ) ) {
                    //FloatControl volume = (FloatControl) dataLine.getControl( FloatControl.Type.MASTER_GAIN );
                    FloatControl volume = (FloatControl) dataLine.getControl( FloatControl.Type.VOLUME);
                    System.out.println("Max vol " + volume.getMaximum());
                    System.out.println("Min vol " + volume.getMinimum());
                    System.out.println("Current vol " + volume.getValue());
                    volume.setValue( 60000.0F );
                    System.out.println("New vol " + volume.getValue());
                } else {
                    System.out.println("Volume control not supported");
                }
                if (dataLine.isControlSupported( FloatControl.Type.REVERB_RETURN)) {
                    System.out.println("reverb return supported");
                } else {
                    System.out.println("reverb return not supported");
                }
                if (dataLine.isControlSupported( FloatControl.Type.REVERB_SEND)) {
                    System.out.println("reverb send supported");
                } else {
                    System.out.println("reverb send not supported");
                }

            } catch ( LineUnavailableException e ) {
                e.printStackTrace();
            }

            // ppw = new PitchProcessorWrapper(audioInputStream, receiver);
        }

        public void run() {

            // Allows the line to move data in and out to a port.
            dataLine.start();

            // Create a buffer for moving data from the audio stream to the line.
            int bufferSize = (int) audioFormat.getSampleRate() * audioFormat.getFrameSize();
            bufferSize =  audioFormat.getFrameSize() * Constants.FRAMES_PER_BUFFER;
            Debug.println("Buffer size: " + bufferSize);
            byte [] buffer = new byte[bufferSize];

            try {
                int bytesRead = 0;
                while ( bytesRead >= 0 ) {
                    bytesRead = audioInputStream.read( buffer, 0, buffer.length );
                    if ( bytesRead >= 0 ) {
                        int framesWritten = dataLine.write( buffer, 0, bytesRead );
                        // ppw.write(buffer, bytesRead);
                    }
                } // while
            } catch ( IOException e ) {
                e.printStackTrace();
            }

            // Continues data line I/O until its buffer is drained.
            dataLine.drain();

            Debug.println( "Sampled player closing line." );
            // Closes the data line, freeing any resources such as the audio device.
            dataLine.close();
        }
    }

    // Turn into a GUI version or pick up from prefs
    public void listMixers() {
        try{
            Mixer.Info[] mixerInfo =
                AudioSystem.getMixerInfo();
            System.out.println("Available mixers:");
            for(int cnt = 0; cnt < mixerInfo.length;
                cnt++){
                System.out.println(mixerInfo[cnt].
                                   getName());

                Mixer mixer = AudioSystem.getMixer(mixerInfo[cnt]);
                Line.Info[] sourceLines = mixer.getSourceLineInfo();
                for (Line.Info s: sourceLines) {
                    System.out.println("  Source line: " + s.toString());
                }
                Line.Info[] targetLines = mixer.getTargetLineInfo();
                for (Line.Info t: targetLines) {
                    System.out.println("  Target line: " + t.toString());
                }

            }//end for loop
        } catch(Exception e) {
        }
    }
}

对设备选择的评论

如果选择了默认设备,则输入和输出设备是 PulseAudio 默认设备。通常这两个都是计算机的声卡。但是,可以使用 PulseAudio 音量控制等功能来更改默认设备。这些可以设置输入设备和/或输出设备。该对话还可以用于设置采样媒体的输入设备。

这提出了一些可能的情况:

  • 默认 PulseAudio 器件选择相同的输入和输出器件。
  • 默认 PulseAudio 器件选择不同的输入和输出器件。
  • 默认的 PulseAudio 设备用于输出,而 ALSA 设备用于输入,但物理设备是相同的。
  • 默认的 PulseAudio 设备用于输出,ALSA 设备用于输入,物理设备不同。

使用不同的器件会产生时钟漂移问题,即器件具有不同步的不同时钟。最糟糕的情况似乎是第二种情况,在我的系统上播放一首三分钟的歌曲时,我可以听到播放采样音频时有明显的延迟,而 KAR 文件播放得很愉快。它还在播放采样音频时引入了明显的延迟。

表演

程序top可以让你很好的了解各种进程使用了多少 CPU。我目前的电脑是一台高端戴尔笔记本电脑,配有四核英特尔 i7-2760QM CPU,运行频率为 2.4GHz。根据 CPU 基准测试( www.cpubenchmark.net/ ),该处理器位于“高端 CPU 图表”中在这台电脑上,用各种 KAR 文件进行了测试,PulseAudio 占用了大约 30%的 CPU,而 Java 占用了大约 60%。有时会超过这些数字。附加功能已经所剩无几了!

此外,在播放 MIDI 文件时,有时 Java 进程会挂起,以高达 600%的 CPU 使用率恢复(我不知道top如何设法记录这一点)。这使得它实际上无法使用,我不知道问题出在哪里。

结论

Java 声音对 Karaoke 没有直接支持。本章介绍了如何将 Java 声音库与 Swing 等其他库结合起来,为 MIDI 文件创建一个 Karaoke 播放器。运行这些程序需要一台高端电脑。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值