13、JavaFX 音频可视化:从基础到实践

JavaFX 音频可视化:从基础到实践

1. JavaFX 媒体播放基础

JavaFX 提供了一定的媒体播放支持。可以通过创建 Media 对象并传入指向媒体文件的 URI,再用该 Media 对象创建 MediaPlayer 对象来实现媒体播放。 MediaPlayer 类提供了诸如播放、暂停、重置等功能。示例代码如下:

Media media = new Media(mediaFileURI);
MediaPlayer mediaPlayer = new MediaPlayer(media);
mediaPlayer.play();

若媒体是视频,还需创建 MediaView 对象来在场景中显示视频。 MediaView 作为一个节点,可以进行平移、动画处理或应用效果。不过,JavaFX 并未提供用于启动和停止媒体的小部件,开发者需自行创建供用户点击的启动和停止节点。

此外, javafx.scene.media 包中还有一些在这个简单示例中未使用的类,这些类能让开发者获取特定媒体的更多详细信息,特别是关于轨道的细节。

需要注意的是,由于 JavaFX 的一个 bug,无法像读取图像那样从 JAR 文件中读取电影文件。解决办法是将电影文件复制到本地硬盘的某个位置,并相应地更改 URI。虽然 JavaFX 有不错的媒体支持且 API 易于使用,但它无法以编程方式访问媒体内容,因此需要借助 Java Sound API 来从音频文件中获取所需数据。

2. Java 中的音频播放 API 选择

JavaFX 平台运行在 Java 平台之上,这意味着 JVM 提供的所有功能以及数千个用 Java 编写的库都可用于 JavaFX 应用程序。在开发桌面 JavaFX 应用程序时,至少有四种 API 可用于音频播放:
- JavaFX 媒体类
- Java 媒体框架(JMF)API
- AudioClip API
- Java Sound

这些 API 似乎支持不同格式的音乐文件,Java 的编解码器支持较为复杂。在示例中使用的是 MP3 文件,不过并非所有 MP3 文件都能与 Java Sound 正常配合使用。

各 API 之间存在差异。JMF 是一个强大且复杂的工具,可处理任何类型的媒体,但 Java Sound 的 API 更现代、更简单,更适合作为示例代码。而 AudioClip 类属于 Applet API,仅提供最基本的功能,不适合本需求。

3. 使用 Java Sound API 的准备工作

要使用 Java Sound API,需在代码中完成以下操作:
- 准备音频文件以供播放
- 对歌曲进行缓冲
- 创建一个读取和写入音频数据的线程
- 编写代码在播放音频时分析音频数据

涉及到三个线程:
- 音频线程 :从音频源读取数据,并使用 Java Sound 通过扬声器播放。
- 累积线程 :在音频播放时对声音数据进行采样,并简化数据,使其更适用于应用程序。通过 Observable/Observer 模式通知 JavaFX 线程数据有变化。
- JavaFX 渲染线程 :负责绘制场景,在创建任何 JavaFX 应用程序时隐式定义。

graph LR
    classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
    A(音频线程):::process --> B(扬声器):::process
    C(累积线程):::process --> D(简化音频数据):::process
    D --> E(JavaFX 线程):::process
    E --> F(更新场景):::process
4. 音频文件的准备

示例代码中提供了一个 WAV 文件,同时使用的 MP3 文件位于 org/lj/jfxe/chapter9/media 文件夹中。由于 Java Sound 不能直接从 JAR 文件中播放声音文件,需要将文件从 JAR 复制到本地文件系统。

以下是 SoundHelper 类的部分代码:

public class SoundHelper extends Observable implements SignalProcessorListener {
    private URL url = null;
    private SourceDataLine line = null;
    private AudioFormat decodedFormat = null;
    private AudioDataConsumer audioConsumer = null;
    private ByteArrayInputStream decodedAudio;
    private int chunkCount;
    private int currentChunk;
    private boolean isPlaying = false;
    private Thread thread = null;
    private int bytesPerChunk = 4096;
    private float volume = 1.0f;

    public SoundHelper(String urlStr) {
        try {
            if (urlStr.startsWith("jar:")) {
                this.url = createLocalFile(urlStr);
            } else {
                this.url = new URL(urlStr);
            }
        } catch (Exception ex) {
            throw new RuntimeException(ex);
        }
        init();
    }

    private File getMusicDir() {
        File userHomeDir = new File(System.getProperties().getProperty("user.home"));
        File synethcDir = new File(userHomeDir, ".chapter9_music_cache");
        File musicDir = new File(synethcDir, "music");
        if (!musicDir.exists()) {
            musicDir.mkdirs();
        }
        return musicDir;
    }

    private URL createLocalFile(String urlStr) throws Exception {
        File musicDir = getMusicDir();
        String fileName = urlStr.substring(urlStr.lastIndexOf('/')).replace("%20", " ");
        File musicFile = new File(musicDir, fileName);
        if (!musicFile.exists()) {
            InputStream is = new URL(urlStr).openStream();
            FileOutputStream fos = new FileOutputStream(musicFile);
            byte[] buffer = new byte[512];
            int nBytesRead = 0;
            while ((nBytesRead = is.read(buffer, 0, buffer.length)) != -1) {
                fos.write(buffer, 0, nBytesRead);
            }
            fos.close();
        }
        return musicFile.toURL();
    }

    private void init() {
        fft = new FFT(saFFTSampleSize);
        old_FFT = new float[saFFTSampleSize];
        saMultiplier = (saFFTSampleSize / 2) / saBands;
        AudioInputStream in = null;
        try {
            in = AudioSystem.getAudioInputStream(url.openStream());
            AudioFormat baseFormat = in.getFormat();
            decodedFormat = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
                    baseFormat.getSampleRate(), 16, baseFormat.getChannels(),
                    baseFormat.getChannels() * 2,
                    baseFormat.getSampleRate(), false);
            AudioInputStream decodedInputStream = AudioSystem.getAudioInputStream(decodedFormat, in);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            chunkCount = 0;
            byte[] data = new byte[bytesPerChunk];
            int bytesRead = 0;
            while ((bytesRead = decodedInputStream.read(data, 0, data.length)) != -1) {
                chunkCount++;
                baos.write(data, 0, bytesRead);
            }
            decodedInputStream.close();
            decodedAudio = new ByteArrayInputStream(baos.toByteArray());
            DataLine.Info info = new DataLine.Info(SourceDataLine.class, decodedFormat);
            line = (SourceDataLine) AudioSystem.getLine(info);
            line.open(decodedFormat);
            line.start();
            audioConsumer = new AudioDataConsumer(bytesPerChunk, 10);
            audioConsumer.start(line);
            audioConsumer.add(this);
            isPlaying = false;
            thread = new Thread(new SoundRunnable());
            thread.start();
        } catch (Exception ex) {
            throw new RuntimeException(ex);
        }
    }
}

若提供的 URL 以 jar 开头,会调用 createLocalFile 方法将声音文件从 JAR 复制到本地文件系统。 init 方法使用 AudioSystem 类的 getAudioInputStream 方法对音频文件进行解码,将音频数据存储在 decodedAudio 中,最终目的是将整个歌曲解码并存储在内存中,以便随时播放、停止或从任意点开始播放。

5. 音频数据的处理

init 方法的最后使用 AudioSubsystem 类创建了一个 DataLine 对象,用于通过扬声器发出声音。 SoundRunnable 类在单独的线程中完成此操作:

private class SoundRunnable implements Runnable {
    public void run() {
        try {
            byte[] data = new byte[bytesPerChunk];
            byte[] dataToAudio = new byte[bytesPerChunk];
            int nBytesRead;
            while (true) {
                if (isPlaying) {
                    while (isPlaying && (nBytesRead = decodedAudio.read(data, 0, data.length)) != -1) {
                        for (int i = 0; i < nBytesRead; i++) {
                            dataToAudio[i] = (byte) (data[i] * volume);
                        }
                        line.write(dataToAudio, 0, nBytesRead);
                        audioConsumer.writeAudioData(data);
                        currentChunk++;
                    }
                }
                Thread.sleep(10);
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

SoundRunnable 类的 run 方法中有两个 while 循环。外层循环用于切换是否播放声音,内层循环从 decodedAudio 中读取数据块,并将其写入 line audioConsumer line 是实际发出声音的 Java Sound 对象,其 write 方法会阻塞,直到准备好接收更多数据,从而使循环与听到的声音同步。 audioConsumer 负责进行数字信号处理。

数字信号处理类会将音频数据分解为 20 个值,每个值代表音频中特定频率的声音贡献程度,这些值存储在 SoundHelper 类的 levels 变量中,该变量是一个包含 20 个双精度浮点数的数组,值在 0.0 到 1.0 之间。

6. JavaFX 与 Java 的交互

SoundHelper 类使我们能够播放音频文件并获取音频播放时各频率的高低信息。接下来,需要将此功能暴露给 JavaFX 应用程序。在创建桥接 JavaFX 和 Java 两个环境的应用程序时,建议使用 Observer/Observable 模式。

Java 提供的 java.lang.Observable 类实现了多个方法,我们关注的是 addObserver setChanged notifyObservers 。当 Observable 的数据发生变化时, addObserver 方法用于添加应被通知的 Observer。Observable 通过调用 setChanged notifyObservers 方法通知 Observer,这会触发 Observer 接口的 update 方法。

SoundPlayer 类实现了 Observer 接口,用于包装 SoundHelper 并为需要其功能的应用程序提供 JavaFX 风格的接口:

public class SoundPlayer extends Observer{
    public var volume:Number = 1.0 on replace {
        soundHelper.setVolume(volume);
    }
    public var currentTime:Duration;
    public var songDuration:Duration;
    public var url:String;
    public var file:File;
    var soundHelper:SoundHelper;
    override function update(observable: Observable, arg: Object) {
        FX.deferAction(
        function(): Void {
            for (i in [0..(soundHelper.levels.length-1)]){
                levels[i] = soundHelper.getLevel(i);
            }
            currentTime = 
(soundHelper.getCurrentChunk()*1.0/soundHelper.getChunkCount()*1.0)*soundHelper.getSongLengt
hInSeconds()*1s;
        }
        );
    }
    //20 channels
    public var levels: Number[] = for (i in [1..20]) 0.0;
    public var hiChannels:Number = bind levels[19] + levels[18] + levels[17] + levels[16] +
levels[15] + levels[14] + levels[13];
    public var midChannels:Number = bind levels[7] + levels[8] + levels[9] + levels[10] +
levels[11] + levels[12];
    public var lowChannels:Number = bind levels[0] + levels[1] + levels[2] + levels[3] +
levels[4] + levels[5] + levels[6];
    init{
        soundHelper = new SoundHelper(url);
        soundHelper.addObserver(this);
        songDuration = soundHelper.getSongLengthInSeconds() * 1s;
        soundHelper.setVolume(volume);
        reset();
    }
    public function reset():Void{
        soundHelper.pause();
        soundHelper.setTimeInMills(0);
    }
    public function stop():Void{
        soundHelper.pause();
    }
    public function pause():Void{
        soundHelper.pause();
    }
    public function play():Void{
        soundHelper.play();
    }
    public function setTime(time:Duration){
        soundHelper.setTimeInMills(time.toMillis());
    }
    public function isPlaying():Boolean{
        return soundHelper.isPlaying();
    }
}

init 函数中, SoundPlayer 创建一个新的 SoundHelper 并将自己注册为观察者。当 SoundHelper 中的 levels 发生变化时, SoundPlayer update 函数会被调用。 update 函数将 SoundHelper 中的 levels 复制到 SoundPlayer levels 序列中,且复制操作在传递给 FX.deferAction 的函数中执行,这确保了其他 JavaFX 对象能可靠地绑定到 levels 序列。

此外, SoundPlayer 还有一些变量(如 hiChannels midChannels lowChannels )绑定到 levels ,这些变量是 levels 值的聚合结果,后续可用于音频可视化,使其仅绑定到歌曲的高音、中音或低音部分。

7. 音频可视化示例

现在有了一个不错的 JavaFX 接口用于声音处理代码,可以在示例应用程序中使用 SoundPlayer 来展示在 JavaFX 中创建引人注目的音频可视化是多么容易。示例应用程序的场景包含用于启动和暂停音乐的控件、可更改歌曲播放位置的控制条,以及三个复选框,用于控制三种示例效果的显示。

以下是 Main.fx 的代码:

var soundPlayer = SoundPlayer{
    url: "{__DIR__}media/01 One Sound.mp3";
}
var bars = Bars{
    translateX: 50
    translateY: 400
    soundPlayer:soundPlayer
    visible: bind barsButton.selected
}
var barsButton = CheckBox{
        graphic: Label{text: "Show Bars", textFill: Color.WHITESMOKE}
}
var disco = DiscoStar{
    translateX: 320
    translateY: 240
    soundPlayer:soundPlayer
    visible: bind discoButton.selected
}
var discoButton = CheckBox{
        graphic: Label{text: "Show Disco", textFill: Color.WHITESMOKE}
}
var wave = Wave{
    translateX: 620
    translateY: 380
    soundPlayer:soundPlayer
    visible: bind waveButton.selected
}
var waveButton = CheckBox{
        graphic: Label{text: "Show Wave", textFill: Color.WHITESMOKE}
}
var scene = Scene {
    fill: Color.BLACK
    content: [
        SoundControl{
            translateX: 30
            translateY: 30
            soundPlayer:soundPlayer
        }, wave, disco, bars
    ]
}
function run():Void{
    var vbox = VBox{
        translateX: 500
        translateY: 50
        content: [barsButton, discoButton, waveButton]
    }
    insert vbox into scene.content;
    Stage {
        title: "Chapter 9"
        width: 640
        height: 480
        scene: scene
    }    barsButton.selected = true;
}

通过上述代码,我们可以看到如何利用 SoundPlayer 以及各种控件和效果类来创建一个完整的音频可视化应用程序。用户可以通过复选框控制不同效果的显示,为音频播放增添丰富的视觉体验。

综上所述,通过 JavaFX 和 Java Sound API 的结合,我们能够实现音频的播放、处理以及可视化,为用户带来更加生动的音频体验。在实际开发中,开发者可以根据具体需求对代码进行扩展和优化,创造出更多有趣的音频可视化效果。

JavaFX 音频可视化:从基础到实践

8. 音频可视化效果的实现原理

在前面的示例中,我们看到了 Bars DiscoStar Wave 等效果类,这些类是实现音频可视化的关键部分。下面我们来分析一下这些效果是如何与音频数据进行关联并实现动态可视化的。

Bars 效果为例,它的高度或长度通常会根据音频的不同频率级别进行动态变化。在 SoundPlayer 类中,我们已经将音频的频率级别信息存储在 levels 序列中, Bars 效果可以通过绑定到 levels 序列中的特定值来实现动态变化。

graph LR
    classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
    A(SoundPlayer):::process --> B(levels 序列):::process
    B --> C(Bars 效果):::process
    C --> D(动态变化):::process

同样, DiscoStar 效果可能会根据音频的节奏闪烁或改变颜色, Wave 效果可能会根据音频的波动呈现出波浪形状的变化。这些效果的实现都依赖于 SoundPlayer 提供的音频数据。

9. 扩展音频可视化效果

开发者可以根据自己的创意和需求扩展音频可视化效果。以下是一些扩展思路:

  • 自定义形状 :创建自定义的形状类,如圆形、三角形等,根据音频数据改变其大小、颜色或透明度。
  • 动画效果 :为可视化元素添加动画效果,如旋转、缩放、平移等,增强视觉效果。
  • 多通道可视化 :除了高音、中音和低音通道,还可以对更多的频率通道进行可视化,展示更详细的音频信息。

例如,我们可以创建一个自定义的 CircleVisualizer 类,根据音频的某个频率级别改变圆形的半径:

public class CircleVisualizer extends Node {
    private Circle circle;
    private SoundPlayer soundPlayer;

    public CircleVisualizer(SoundPlayer soundPlayer) {
        this.soundPlayer = soundPlayer;
        circle = new Circle(50);
        circle.setFill(Color.BLUE);
        getChildren().add(circle);

        // 绑定圆形半径到音频级别
        circle.radiusProperty().bind(soundPlayer.levels[5].multiply(100));
    }
}

然后在 Main.fx 中添加这个自定义的可视化效果:

var circleVisualizer = CircleVisualizer{
    translateX: 400
    translateY: 300
    soundPlayer:soundPlayer
}

var scene = Scene {
    fill: Color.BLACK
    content: [
        SoundControl{
            translateX: 30
            translateY: 30
            soundPlayer:soundPlayer
        }, wave, disco, bars, circleVisualizer
    ]
}
10. 性能优化

在实现音频可视化时,性能是一个需要考虑的重要因素。以下是一些性能优化的建议:

优化策略 说明
减少不必要的计算 在处理音频数据时,避免进行不必要的计算,只处理必要的频率通道。
批量更新 尽量批量更新可视化元素的属性,减少频繁的重绘操作。
线程管理 合理管理线程,避免创建过多的线程,防止资源耗尽。

例如,在 SoundRunnable 类中,可以通过减少不必要的循环和计算来提高性能:

private class SoundRunnable implements Runnable {
    public void run() {
        try {
            byte[] data = new byte[bytesPerChunk];
            byte[] dataToAudio = new byte[bytesPerChunk];
            int nBytesRead;
            while (true) {
                if (isPlaying) {
                    while (isPlaying && (nBytesRead = decodedAudio.read(data, 0, data.length)) != -1) {
                        // 减少不必要的计算
                        for (int i = 0; i < nBytesRead; i++) {
                            dataToAudio[i] = (byte) (data[i] * volume);
                        }
                        line.write(dataToAudio, 0, nBytesRead);
                        audioConsumer.writeAudioData(data);
                        currentChunk++;
                    }
                }
                Thread.sleep(10);
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}
11. 兼容性问题

在使用 JavaFX 和 Java Sound API 时,可能会遇到一些兼容性问题。例如,不同版本的 JavaFX 和 Java 可能会对音频文件的支持有所不同,某些编解码器可能无法正常工作。

为了避免兼容性问题,建议:

  • 使用稳定版本的 JavaFX 和 Java。
  • 测试不同格式的音频文件,确保应用程序在各种情况下都能正常工作。
  • 关注官方文档和社区论坛,及时了解和解决兼容性问题。
12. 总结

通过结合 JavaFX 和 Java Sound API,我们可以实现强大而有趣的音频可视化效果。整个过程包括准备音频文件、处理音频数据、在 JavaFX 和 Java 之间进行交互以及创建可视化效果。

在实际开发中,开发者可以根据自己的需求选择合适的 API,扩展可视化效果,并进行性能优化和兼容性测试。希望本文能为你在音频可视化领域的开发提供一些帮助和灵感,让你能够创造出更加精彩的音频可视化应用程序。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值