如何在Java中同步TargetDataLine和SourceDataLine(同步音频记录和播放)

时间:2019-08-28 20:08:35

标签: java audio synchronization audio-streaming audio-recording

我正在尝试创建一个Java应用程序,该应用程序能够播放音频,记录用户语音并告诉用户是否在正确的时间唱歌。

此刻,我只专注于录制和播放音频(音调识别超出范围)。

为此,我使用了Java音频API中的TargetDataLine和SourceDataLine。首先,我开始录音,然后开始音频播放。由于我想确保用户在正确的时间唱歌,因此我需要在录制的音频和播放的音频之间保持同步。

例如,如果在音频录制后1秒钟开始音频播放,我知道我将忽略记录缓冲区中的第一秒数据。

我在测试中使用以下代码(该代码远非完美,但仅用于测试目的)。

import javax.sound.sampled.*;
import java.io.File;
import java.io.IOException;

class AudioSynchro {

private TargetDataLine targetDataLine;
private SourceDataLine sourceDataLine;
private AudioInputStream ais;
private AudioFormat recordAudioFormat;
private AudioFormat playAudioFormat;

public AudioSynchro(String sourceFile) throws IOException, UnsupportedAudioFileException {
    ais = AudioSystem.getAudioInputStream(new File(sourceFile));

    recordAudioFormat = new AudioFormat(44100f, 16, 1, true, false);
    playAudioFormat = ais.getFormat();
}

//Enumerate the mixers
public void enumerate() {
    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());
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

//Init datalines
public void initDataLines() throws LineUnavailableException {
    Mixer.Info[] mixerInfo =
            AudioSystem.getMixerInfo();

    DataLine.Info targetDataLineInfo = new DataLine.Info(TargetDataLine.class, recordAudioFormat);

    Mixer targetMixer = AudioSystem.getMixer(mixerInfo[5]);

    targetDataLine = (TargetDataLine)targetMixer.getLine(targetDataLineInfo);

    DataLine.Info sourceDataLineInfo = new DataLine.Info(SourceDataLine.class, playAudioFormat);

    Mixer sourceMixer = AudioSystem.getMixer(mixerInfo[3]);

    sourceDataLine = (SourceDataLine)sourceMixer.getLine(sourceDataLineInfo);
}

public void startRecord() throws LineUnavailableException {
    AudioInputStream stream = new AudioInputStream(targetDataLine);

    targetDataLine.open(recordAudioFormat);

    byte currentByteBuffer[] = new byte[512];

    Runnable readAudioStream = new Runnable() {
        @Override
        public void run() {
            int count = 0;
            try {
                targetDataLine.start();
                while ((count = stream.read(currentByteBuffer)) != -1) {
                    //Do something
                }
            }
            catch(Exception e) {
                e.printStackTrace();
            }
        }
    };
    Thread thread = new Thread(readAudioStream);
    thread.start();
}

public void startPlay() throws LineUnavailableException {
    sourceDataLine.open(playAudioFormat);
    sourceDataLine.start();

    Runnable playAudio = new Runnable() {
        @Override
        public void run() {
            try {
                int nBytesRead = 0;
                byte[] abData = new byte[8192];
                while (nBytesRead != -1) {
                    nBytesRead = ais.read(abData, 0, abData.length);
                    if (nBytesRead >= 0) {
                        int nBytesWritten = sourceDataLine.write(abData, 0, nBytesRead);
                    }
                }

                sourceDataLine.drain();
                sourceDataLine.close();
            }
            catch(Exception e) {
                e.printStackTrace();
            }
        }
    };
    Thread thread = new Thread(playAudio);
    thread.start();
}

public void printStats() {
    Runnable stats = new Runnable() {

        @Override
        public void run() {
            while(true) {
                long targetDataLinePosition = targetDataLine.getMicrosecondPosition();
                long sourceDataLinePosition = sourceDataLine.getMicrosecondPosition();
                long delay = targetDataLinePosition - sourceDataLinePosition;
                System.out.println(targetDataLinePosition+"\t"+sourceDataLinePosition+"\t"+delay);

                try {
                    Thread.sleep(20);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    };

    Thread thread = new Thread(stats);
    thread.start();
}

public static void main(String[] args) {
    try {
        AudioSynchro audio = new AudioSynchro("C:\\dev\\intellij-ws\\guitar-challenge\\src\\main\\resources\\com\\ouestdev\\guitarchallenge\\al_adagi.mid");
        audio.enumerate();
        audio.initDataLines();
        audio.startRecord();
        audio.startPlay();
        audio.printStats();
    } catch (IOException | LineUnavailableException | UnsupportedAudioFileException e) {
        e.printStackTrace();
    }
}

}

该代码初始化2条数据线,开始音频记录,开始音频播放并显示统计信息。 enumerate()方法用于显示系统上可用的混合器。您必须根据系统来更改initDataLines()方法中使用的混合器,以进行自己的测试。 printStats方法()启动一个线程,以2毫秒为单位询问位置。这是我尝试用来跟踪同步的数据。我观察到的是,两条数据线并非一直保持同步。这是我的输出控制台的简短摘录:

130000 0 130000

150000 748 149252

170000 20748 149252

190000 40748 149252

210000 60748 149252

230000 80748 149252

250000 100748 149252

270000 120748 149252

290000 140748 149252

310000 160748 149252

330000 180748 149252

350000 190748 159252

370000 210748 159252

390000 240748 149252

410000 260748 149252

430000 280748 149252

450000 300748 149252

470000 310748 159252

490000 340748 149252

510000 350748 159252

530000 370748 159252

如我们所见,延迟可能会定期变化10毫秒,因此我无法精确地确定记录缓冲区中的哪个位置与播放缓冲区的开头相匹配。特别是,在前面的示例中,我不知道应该从位置149252还是159252开始。 对于音频处理,10毫秒很重要,我想更准确一些(可以接受1或2毫秒)。 而且,听起来很奇怪,当两个小节之间存在差异时,仍然相差10毫秒。

然后我尝试将测试进一步推进,但没有得到更好的结果: -尝试使用更大或更小的缓冲区 -尝试将缓冲区增大两倍以进行播放。由于音频文件为立体声,因此会占用更多字节(用于录制的每个字节2个字节,用于播放的每个字节4个字节) -尝试在同一音频设备上录制和播放

我认为,有两种策略可以同步两个缓冲区: -我想做的。精确确定回放开始时在记录缓冲区中的位置。 -同步记录的开始和播放。

在这两种策略中,我都需要确保保持同步。

你们中有人遇到过这种类型的问题吗?

目前,我将Java 12和JavaFx用于我的应用程序,但我准备使用其他框架。我没有尝试过,但是可以使用lwjgl(https://www.lwjgl.org/基于OpenAl的框架)或念珠(http:// www.beadsproject.net/)获得更好的结果和更多控制。如果你们中的任何人知道他的框架并且可以给我回报,我就会很感兴趣。

最后,最后一个可接受的解决方案是更改编程语言。

2 个答案:

答案 0 :(得分:0)

我对TargetDataLines的工作还不多,但是我认为我可以提供有用的观察和建议。

首先,您编写的测试可能是在多线程算法中测量方差,而不是文件时间上的延误。 JVM在处理线程之间来回反弹的方式可能是无法预测的。您可以阅读good article on real time, low-latency coding in Java以获得背景信息。

第二,Java使用带有音频IO的阻塞队列的方式提供了很多稳定性。如果没有,我们将在播放或录音时听到各种音频失真。

这里是一个尝试的想法:创建一个具有runnable循环的单个while,该循环处理来自TargetDataLineSourceDataLine中相同数量的帧相同的迭代。该runnable可以松散耦合(使用布尔值来打开/关闭线路)。

主要优点是您知道每次循环迭代都将产生协调的数据。


编辑:这是我对帧计数所做的几个示例: (1)我有一个音频循环,在处理过程中对帧进行计数。所有时序均严格由处理的帧数确定。我从不理会从SDL的位置读取数据。我已经编写了一个节拍器,它每N帧会发起一个合成的点击(其中N是基于速度的)。在第N帧,用于合成点击的数据被混合到从SDL发送出去的音频数据中。通过这种方法获得的计时精度非常出色。

在第N个框架上的另一个应用程序,我启动了视觉/图形事件。图形循环通常设置为60fps,音频设置为44100fps。初始化是通过松散耦合来处理的:事件的布尔值被音频线程翻转(仅此而已,由于多余的活动而使音频线程杂乱是危险的,可能导致结结和辍学)。图形处理循环(也称为“游戏循环”)获取布尔值更改并在自己的时间(60 fps)中进行处理。我通过这种方式发生了一些不错的视觉+听觉同步,包括使对象的亮度随所播放声音的音量而变化。这类似于许多人使用Java编写的数字VU表。

根据您希望的准确性水平,我认为帧计数就足够了。我不知道使用Java可以提供同样的准确性。

答案 1 :(得分:0)

我使用以下代码进行了新的测试(菲尔,请告诉我您是否打算这样做)。

public void startAll() throws LineUnavailableException, IOException {
    AudioInputStream stream = new AudioInputStream(targetDataLine);

    targetDataLine.open(recordAudioFormat);

    byte reccordByteBuffer[] = new byte[512];
    byte playByteBuffer[] = new byte[1024];


    sourceDataLine.open(playAudioFormat);
    targetDataLine.start();
    sourceDataLine.start();

    Runnable audio = new Runnable() {
        @Override
        public void run() {
            int reccordCount = 0;
            int totalReccordCount = 0;
            int playCount = 0;
            int totalPlayCount = 0;
            int playWriteCount = 0;
            int totalWritePlayCount = 0;
            try {
                while (playCount != -1) {
                    reccordCount = stream.read(reccordByteBuffer);
                    totalReccordCount += reccordCount;
                    long targetDataLinePosition = targetDataLine.getLongFramePosition();
                    playCount = ais.read(playByteBuffer, 0, playByteBuffer.length);
                    playWriteCount = sourceDataLine.write(playByteBuffer, 0, playCount);
                    totalPlayCount += playCount;
                    totalWritePlayCount += playWriteCount;
                    long sourceDataLinePosition = sourceDataLine.getLongFramePosition();


                    long delay = targetDataLinePosition - sourceDataLinePosition;
                    System.out.println(targetDataLinePosition + "\t" + sourceDataLinePosition + "\t" + delay + "\t" + totalReccordCount + "\t" + totalPlayCount + "\t" + totalWritePlayCount + "\t" + System.nanoTime());
                }
            } catch (IOException e) {
                e.printStackTrace();
            }

        }
    };

    Thread thread = new Thread(audio);
    thread.start();

}

这是结果(我只放了块,因为堆栈很长)。

1439300 <-TargetDataLine的起点与SourceDataLine的起点之间的ns偏移量。

119297 0 119297 512 1024 1024 565993368423500

179297 0 179297 1024 2048 2048 565993388887000

189297 0 189297 1536 3072 3072 565993390006000

189297 0 189297 2048 4096 4096 565993390998900

189297 0 189297 2560 5120 5120 565993391737300

189297 0 189297 3072 6144 6144 565993392430700

189297 0 189297 3584 7168 7168 565993392608000

189297 0 189297 4096 8192 8192 565993393295200

189297 0 189297 4608 9216 9216 565993393971900

189297 0 189297 5120 10240 10240 565993394690200

189297 0 189297 5632 11264 11264 565993395476900

189297 0 189297 6144 12288 12288 565993396160600

189297 0 189297 6656 13312 13312 565993396864500

189297 0 189297 7168 14336 14336 565993397032000

189297 0 189297 7680 15360 15360 565993397736000

189297 0 189297 8192 16384 16384 565993398467800

199297 0 199297 8704 17408 17408 565993399156300


199297 0 199297 15360 30720 30720 565993406362500

199297 0 199297 15872 31744 31744 565993407001900

199297 0 199297 16384 32768 32768 565993407585200

329297 115804 213493 16896 33792 33792 565993532785500 <-从此处开始播放

329297 115804 213493 17408 34816 34816 565993533320600

329297 115804 213493 17920 35840 35840 565993533486300


329297 115804 213493 22016 44032 44032 565993536512600

329297 115804 213493 22528 45056 45056 565993536941700

329297 125804 203493 23040 46080 46080 565993537363100 <-SourceDataLine会增加10 ms,但不会增加TargetDataLine

329297 125804 203493 23552 47104 47104 565993537746900

329297 125804 203493 24064 48128 48128 565993538158600

339297 125804 213493 24576 49152 49152 565993538306400 <-TargetDataLine会增加10 ms,但不会增加SourceDataLine。情况正在恢复。

339297 125804 213493 25088 50176 50176 565993538762200


469297 255804 213493 39424 78848 78848 565993674194900

469297 255804 213493 39936 79872 79872 565993674513700

469297 255804 213493 40448 80896 80896 565993674872000

469297 255804 213493 40960 81920 81920 565993675177000

599297 385804 213493 41472 82944 82944 565993800684100 <-TargetDataLine和SourceDataLine递增10毫秒。没有滞后。

599297 385804 213493 41984 83968 83968 565993800871800

599297 385804 213493 42496 84992 84992 565993801189300

599297 385804 213493 43008 86016 86016 565993801486800

599297 385804 213493 43520 87040 87040 565993801814500

我的观察如下:

  • 即使同时启动两个数据线,它们之间也存在很大的差距。使用System.out.println(System.nanoTime()-targetStart)进行的测量;表示偏移量为1.4393 ms,而延迟变量在213.493 ms和203.493 ms之间。因此,我们无法保持同时启动两个数据线以获得完美同步的解决方案。
  • SourceDataLine在开始播放之前已收到33792字节
  • 我们可以看到getMicrosecondPosition()方法的精度不是很好(getLongFramePosition()不太好,并且getMicrosecondPosition()基于它的计算)。实际上,对于targetDataline(记录),我们看到值189297被显示14次。由System.nanoTime()方法估计的14个显示之间花费的时间为8.4618毫秒!这似乎证实了使用这种方法不可能获得小于10 ms的精度。

  • 就我而言,使用的Java实现是DirectAudioDevice $ DirectTDL和DirectAudioDevice $ DirectSDL(还有其他实现,具体取决于操作系统)。调用的低层方法是静态本机长nGetBytePosition(长id,布尔isSource,长javaPos)。此方法是本地方法,因此它要求使用另一种语言(一定要吸引驱动程序)实现。精度不足是由这种方法引起的,而不是直接来自Java代码。

  • 可以看出,当其中一条数据线花费另外10 ms而另一条数据线保持旧值时,就会发生偏移。当另一个偏移量也额外花费10 ms时,偏移量将被吸收。使用printStats()方法,这种现象就不那么明显了,因为我们使用了Thread.sleep(20)。

  • 在单个线程上传递的事实并没有太大变化。因此,我认为Java Audio API对于我要完成的工作不够准确。

  • Phil在其评论中引用的文档指出,结果与Java Sound API尚无定论,并且它们是通过RtAudio和Java映射传递的。