1 февраля 2011 г.

Работа со звуковыми WAVE-файлами на Java

Мне на работе иногда приходится иметь дело с WAVE-файлами (или WAV по расширению) по той причине, что в них довольно удобно хранить научные данные и не надо выдумывать формат. Решил написать маленькую заметку о работе с этим форматом на Java. Сразу оговорюсь, что речь тут пойдет не о воспроизведении звуковых файлов с помощью Java, а о том как можно их создавать, изменять, и читать из них данные.

Краткое описание формата WAVE

Углубляться в формат и досконально расписывать его я не буду, этого не нужно, т.к. работать с WAVE-файлами используя Java можно на относительно высоком уровне, но понимать основные принципы формата все же надо. В основе формата лежит концепция так называемых chunk'ов. Каждый chunk (чанк) представляет из себя кусок данных, в котором содержится ID чанка, его размер и данные. ID и размер чанка называют заголовками. По ID можно судить о том, что за данные содержит этот чанк. Любой чанк в качестве данных может содержать другие чанки. Рассмотрим базовую структуру WAVE-файла.


WAVE-файл в простом случае состоит из одного чанка "RIFF", который внутри себя содержит чанк "WAVE", а "WAVE" в свою очередь чанки "fmt" и "data". Чанк "fmt" хранит описание формата, сюда входят частота, размер сэмпла, количество каналов и т.п.. Чанк "data" содержит последовательно расположенные сэмплы. Каждый сэмпл занимает столько байт, сколько указано в чанке "fmt". Для представления значения сэмпла используется порядок little-endian, т.е. младший байт идет первым. Если файл состоит из более чем одного канала, то сэмплы каждого канала чередуются. Например, в стерео-файле (2 канала) сначала идет первый сэмпл левого канала, потом первый сэмпл правого канала, затем второй сэмпл левого канала и второй правого, и т.д.. Сэмплы всех каналов взятых в какой-то один момент времени называются кадром или фреймом (frame). Например, в стерео-файле первые сэмплы левого и правого каналов будут называться первым кадром.

Кроме описанных выше чанков в WAVE-файл могут входить и другие. Подробнее почитать о формате на английском можно здесь и здесь.

Средства Java для работы с WAVE

В стандартной библиотеке Java есть пакет javax.sound.sampled, который предоставляет классы и интерфейсы для работы со звуковыми данными, в том числе и для их воспроизведения. Для того, что бы прочитать какой-либо WAVE-файл есть два главных класса: AudioInputStream, который представляет поток из которого можно читать аудио-данные и AudioFormat, который позволяет легко получить информацию о формате файла. Таким образом, с помощью этих классов можно избежать ручного путешествия по файлу в поисках интересующих вас чанков. Для создания WAVE-файла, главным образом, используется эта же пара классов.

Единственный немного более низкоуровневый момент заключается в том, что преобразованием числа в байты и байтов в число придется заниматься самостоятельно. Но если это вас пугает, можно воспользоваться классом ByteBuffer из пакета java.nio, который предоставляет множество методов для работы с байтовыми буферами.

Пример работы с WAVE-файлами

Приведу пример простого класса для работы с WAVE-файлами. По комментариям должно быть понятно, что там происходит. Только учтите, что класс работает с числами типа int, т.е. для записи такого числа необходимо минимум 4 байта. Можно записывать и в большее количество байт, но использоваться будет только 4, а остальные будут заполнены нулями. После того, как разберетесь с этим простым классом, думаю, легко сможете переделать его под свои задачи. Так же замечу, что чтение данных в память происходит разом, а работа потом уже ведется с массивом в памяти. Мне это нужно для скорости. Если же вам скорость не важна, но зато нужно экономить память, то можете переделать класс для чтения по одному сэмплу.

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;
import javax.sound.sampled.AudioFileFormat;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.UnsupportedAudioFileException;

/**
 * Простой класс для работы с wave-файлами. 
 *  
 * @author eqlbin
 *
 */
public class WaveFile {

    private int INT_SIZE = 4;
    public final int NOT_SPECIFIED = -1;
    private int sampleSize = NOT_SPECIFIED;
    private long framesCount = NOT_SPECIFIED;
    private byte[] data = null;  // массив байт представляющий аудио-данные 
    private AudioInputStream ais = null;
    private AudioFormat af = null;
    
    /**
     * Создает объект из указанного wave-файла
     * 
     * @param file - wave-файл
     * @throws UnsupportedAudioFileException
     * @throws IOException
     */
    WaveFile(File file) throws UnsupportedAudioFileException, IOException {
        
        if(!file.exists()) {
            throw new FileNotFoundException(file.getAbsolutePath());
        }
        
        // получаем поток с аудио-данными
        ais = AudioSystem.getAudioInputStream(file);  
        
        // получаем информацию о формате
        af = ais.getFormat();
        
        // количество кадров в файле
        framesCount = ais.getFrameLength();
        
        // размер сэмпла в байтах
        sampleSize = af.getSampleSizeInBits()/8;
        
        // размер данных в байтах
        long dataLength = framesCount*af.getSampleSizeInBits()*af.getChannels()/8;
        
        // читаем в память все данные из файла разом
        data = new byte[(int) dataLength]; 
        ais.read(data);
    }

    /**
     * Создает объект из массива целых чисел
     * 
     * @param sampleSize - количество байт занимаемых сэмплом
     * @param sampleRate - частота
     * @param channels - количество каналов
     * @param samples - массив значений (данные)
     * @throws Exception если размер сэмпла меньше, чем необходимо 
     * для хранения переменной типа int 
     */
    WaveFile(int sampleSize, float sampleRate, int channels, int[] samples) throws Exception {

        if(sampleSize < INT_SIZE){
            throw new Exception("sample size < int size");
        }
        
        this.sampleSize = sampleSize;
        this.af = new AudioFormat(sampleRate, sampleSize*8, channels, true, false);
        this.data = new byte[samples.length*sampleSize];
        
        // заполнение данных
        for(int i=0; i < samples.length; i++){
            setSampleInt(i, samples[i]);
        }
        
        framesCount = data.length / (sampleSize*af.getChannels());
        ais = new AudioInputStream(new ByteArrayInputStream(data), af, framesCount);
    }

    /**
     * Возвращает формат аудио-данных
     * 
     * @return формат
     */
    public AudioFormat getAudioFormat(){
        return af;
    }

    /**
     * Возвращает копию массива байт представляющих
     * данные wave-файла
     * 
     * 
     * @return массив байт
     */
    public byte[] getData() {  
        return Arrays.copyOf(data, data.length);
    }
    
    /**
     * Возвращает количество байт которое занимает
     * один сэмпл
     * 
     * @return размер сэмпла
     */
    public int getSampleSize() {
        return sampleSize;
    }

    /**
     * Возвращает продолжительность сигнала в секундах
     * 
     * @return продолжительность сигнала
     */
    public double getDurationTime() {
        return getFramesCount() / getAudioFormat().getFrameRate();
    }
    
    /**
     * Возвращает количество фреймов (кадров) в файле 
     * 
     * @return количество фреймов
     */
    public long getFramesCount(){
        return framesCount;
    }
    
    /**
     * Сохраняет объект WaveFile в стандартный файл формата WAVE
     * 
     * @param file
     * @throws IOException
     */
    public void saveFile(File file) throws IOException{
        AudioSystem.write( new AudioInputStream(new ByteArrayInputStream(data), 
                af, framesCount), AudioFileFormat.Type.WAVE, file); 
    }
    
    /**
     * Возвращает значение сэмпла по порядковому номеру. Если данные
     * записаны в 2 канала, то необходимо учитывать, что сэмплы левого и 
     * правого канала чередуются. Например, сэмпл под номером один это
     * первый сэмпл левого канала, сэмпл номер два это первый сэмпл правого 
     * канала, сэмпл номер три это второй сэмпл левого канала и т.д..
     * 
     * @param sampleNumber - номер сэмпла, начиная с 0
     * @return значение сэмпла
     */
    public int getSampleInt(int sampleNumber) {
    
        if(sampleNumber < 0 || sampleNumber >= data.length/sampleSize){
            throw new IllegalArgumentException(
                    "sample number is can't be < 0 or >= data.length/"
                     + sampleSize);
        }
        
        // массив байт для представления сэмпла 
        // (в данном случае целого числа)
        byte[] sampleBytes = new byte[sampleSize];
        
        // читаем из данных байты которые соответствуют
        // указанному номеру сэмпла
        for(int i=0; i < sampleSize; i++){
            sampleBytes[i] = data[sampleNumber * sampleSize + i];
        }
        
        // преобразуем байты в целое число
        int sample = ByteBuffer.wrap(sampleBytes)
                .order(ByteOrder.LITTLE_ENDIAN).getInt();
    
        return sample;
    }
    
    /**
     * Устанавливает значение сэмпла 
     * 
     * @param sampleNumber - номер сэмпла
     * @param sampleValue - значение сэмпла
     */
    public void setSampleInt(int sampleNumber, int sampleValue){
    
        // представляем целое число в виде массива байт
        byte[] sampleBytes = ByteBuffer.allocate(sampleSize).
            order(ByteOrder.LITTLE_ENDIAN).putInt(sampleValue).array();

        // последовательно записываем полученные байты
        // в место, которое соответствует указанному
        // номеру сэмпла
            for(int i=0; i < sampleSize; i++){
                data[sampleNumber * sampleSize + i] = sampleBytes[i];
            }
        }
    }

Теперь посмотрим как можно работать с этим классом. Ниже приведен пример программы, которая создает синусоиду частотой 440 Гц (нота Ля) и сохраняет ее в моно-файл, затем создает стерео-файл, в котором в одном канале записана такая же синусоида 440 Гц, а в другом 329.6 Гц (нота Ми). В конце программа выводит первые 10 сэмплов из моно-файла. Получившиеся WAVE-файлы можно прослушать в проигрывателе или открыть в звуковом редакторе, чтобы убедиться в правильности работы программы. Правда, честно говоря, у меня на линуксе не все проигрыватели нормально проиграли эти файлы, но VLC, Audacious и Audacity с ними работают корректно.

import java.io.File;

public class TestWaveFile {
    public static void main(String[] args) throws Exception {
        
        // создание одноканального wave-файла из массива целых чисел
        System.out.println("Создание моно-файла...");
        int[] samples = new int[3000000];
        for(int i=0; i < samples.length; i++){
            samples[i] = (int)Math.round((Integer.MAX_VALUE/2)*
                        (Math.sin(2*Math.PI*440*i/44100)));
        }
        
        WaveFile wf = new WaveFile(4, 44100, 1, samples);
        wf.saveFile(new File("/home/user/test/testwav1.wav"));
        System.out.println("Продолжительность моно-файла: "+wf.getDurationTime()+ " сек.");
        
        // Создание стерео-файла
        System.out.println("Создание стерео-файла...");
        int x=0;
        for(int i=0; i < samples.length; i++){
            samples[i++] = (int)Math.round((Integer.MAX_VALUE/2)*
                                   (Math.sin(2*Math.PI*329.6*x/44100)));
            samples[i] = (int)Math.round((Integer.MAX_VALUE/2)*
                                   (Math.sin(2*Math.PI*440*x/44100)));
            x++;
        }
        wf = new WaveFile(4, 44100, 2, samples);
        wf.saveFile(new File("/home/user/test/testwav2.wav"));
        System.out.println("Продолжительность стерео-файла: "+wf.getDurationTime()+ " сек.");
        
        // Чтение данных из файла
        System.out.println("Чтение данных из моно-файла:");
        wf = new WaveFile(new File("/home/user/test/testwav1.wav"));
        for(int i=0; i<10; i++){
            System.out.println(wf.getSampleInt(i));
        }
    }
}

Вот так выглядят созданные файлы в Audacity:


26 комментариев:

  1. Большое спасибо!
    Замечательная статья и написана отлично.
    Однако я использовал не 4 байта, а 2 байта на сэмпл, заменив тип int на short. так работает во всех проигрывателях. Просто у меня другая цель. Мне важно было воспроизводить wav-файл с записанной речью.
    В своей проге обрабатывал речь. Записывал сэмплы в txt-файл, который потом обрабатывал различными пакетами. Конечно, обработку речи можно написать и на JAVA, но не все сразу.
    P.S.
    Сам работаю под ХРюшей(Windows XP). С вопросами можно обращаться на ящик vitaly.korzhun@gmail.com

    ОтветитьУдалить
  2. Спасибо огромное, интересная работа, с удовольствием прочла. Для дипломной работы понадобилась работа с wav, а точнее открытие в отсчетах, модификация и сохранение. Так, что еще как пригодилось)

    ОтветитьУдалить
    Ответы
    1. Хорошо, что статья вам помогла. Спасибо за комментарий и удачи с дипломом )

      Удалить
  3. Скажи пожалуйста, как из полученного wave файла определить с высокой точностью момент времени максимального уровня шума?

    ОтветитьУдалить
  4. т.е Необходимо сгенерировать гармонический сигнал, после осуществить его захвати записать wave файл. А дальше работая с wave файлом определить на каких участках образовавшийся шум максимальный.

    ОтветитьУдалить
    Ответы
    1. 1. Из вашего сообщения мне не совсем понятно, что вы подразумеваете под "сгенерировать гармонический сигнал, после осуществить его захвати записать wave файл". Сгенерировать программно на Java или, например, дёрнуть струну на гитаре и записать?

      2. Исходя из первого недопонимания, далее мне не ясно, что вы подразумеваете под максимальным уровнем шума. Именно шум как неполезная часть сигнала или как просто максимальный уровень сигнала? В данной статье гармонический сигнал генерируется в коде Java, поэтому там никакого шума нет. Если же дёргать струну гитары и записывать микрофоном, то помимо гармонического сигнала, там, конечно же, будет и шум.

      3. Если же вы всё-таки говорите о настоящем шуме, то советы такие:
      * Почитайте про цифровые сигналы, дискретизацию и т.д. Зная частоту дискретизации и порядковый номер отсчета (сэмпла), можно легко определить время, которое ему соответствует: T=1/fd, где T - расстояние между сэмплами в секундах, а fd - частота дискретизации
      * Почитайте про цифровые фильтры. Сначала вам нужно отделить шум от сигнала, а потом уже смотреть, где этот шум имеет максимальные значения. Вот, например http://clck.ru/19efX

      Удалить
  5. Скажите, а как на Java прочитать wav файл в 16 битном формате ?
    Вячеслав Михайлович

    ОтветитьУдалить
  6. Спасибо за прекрасную статью! Очень помогло в озвучивании фракталов)

    ОтветитьУдалить
    Ответы
    1. Спасибо за положительный комментарий и удачи с фракталами ))

      Удалить
  7. Как сделать, чтобы не создавать wav файл, а уже из готового получить массив целых чисел?

    ОтветитьУдалить
  8. А можно использовать ваш класс в моей статье в универе?

    ОтветитьУдалить
  9. Спасибо за толковый пример! Долго искал, как корректно работать с Wave -файлами.

    ОтветитьУдалить
  10. Этот комментарий был удален автором.

    ОтветитьУдалить
  11. Этот комментарий был удален автором.

    ОтветитьУдалить
  12. Извините, уже разобрался. Поспешил с вопросом :)

    ОтветитьУдалить
  13. Спасибо, наконец-то нашел то что нужно и надеюсь смогу реализовать курсовую.
    Но, почему-то не работает с моим аудио файлом. Создаю объект WaveFile(файл), выбивает ошибку "Exception in thread "main" javax.sound.sampled.UnsupportedAudioFileException: could not get audio input stream from input file
    at javax.sound.sampled.AudioSystem.getAudioInputStream(AudioSystem.java:1189)"
    Я не самый опытный программист Java, но интересно почему работает с файлом созданным в Вашем примере, но не работает с другим аудио.
    Буду благодарен за ответ.

    ОтветитьУдалить
    Ответы
    1. Очевидно, что формат вашего файла не поддерживается джавовским API. Звуковые файлы могут быть довольно разнообразны :)

      Из документации:

      An UnsupportedAudioFileException is an exception indicating that an operation failed because a file did not contain valid data of a recognized file type and format.

      Удалить
    2. Так все тот же .wav... Или я чего-то еще не понимаю? :)

      Удалить
    3. Ну хорошо, погуглю для вас:

      https://stackoverflow.com/questions/2843847/workaround-for-unsupportedaudiofileexception

      https://stackoverflow.com/questions/29713371/javas-sound-api-doesnt-work-with-all-wav-files

      https://stackoverflow.com/questions/28570841/cant-get-audio-file-to-play

      http://docs.oracle.com/javase/8/docs/technotes/guides/sound/

      Удалить
  14. Все отлично работает, но при попытке прочитать данные о конкретном сэмпле с файла, сгенерированного сторонней программой (audacity, ardour) выскакивает превышение объема буфера... При увеличении объема выделяемого буфера (sampleBytes) в 2 раза ошибка пропадает. С чем это связано?

    ОтветитьУдалить
  15. Почему метод getSampleInt[i] возвращает ряд 0 переодически чередующиеся +-32767 при чтении файла.wav записанного с микрофона в формате 16bit 44100

    ОтветитьУдалить