Shazam-like: идентифицируем музыку на раз-два-три

Давным-давно назад, была у меня большая-пребольшая коллекция музыки, которая собиралась чуть ли не со школьных лет. И, если свежая ее часть была разложена красиво и аккуратно, то в клоаку года эдак 2010, я влезал очень неохотно, и, как правило, благодаря shuffle. А потом,  пришло понимание, что хранить тучу файлов локально в эпоху облаков и стримминга — совсем не круто, и я пересел на Google Music. А из особо полюбившейся части своей музыкальной клоаки натворил радиосервис, которым кто-то даже пользуется. И надо понимать, что раз уж половина файлов ни роду ни племени ни, тем более, ID3 тегов не имели, сервис получился практически слепой.

А еще это надо не только понимать, но и исправлять

А что собственно происходит?

Считается, что неудержимая попытка навести порядок на собственном радио, но не суть.

Важно то, что у нас есть куча mp3 файлов разного, но всегда лицензионного происхождения, в которых ID3 тегов почему-то не самозародилось, а из вне завезти забыли.

При этом, пользовать мою любимую CD-DB мы не можем, потому что она у нас все-таки про альбомы и треки внутри них, а не про мешанину несовместимых между собой файликов.

Зато, мы можем пользовать какой-нибудь крутой сервис типа Shazam, который по кусочку трека выдаст нам о нем всю информацию, вплоть до цвета носков солиста

Тактическая вводная номер 1: все эти сервисы имеют закрытую апишку, а приложения, которые в нее могут, работать с большой пачкой файлов сразу не обучены, да еще и вечно пытаются пользовать микрофон для перезаписи трека. И оно логично и прекрасно, но у нас с вами другой юскейс.

Другой кейс — другое приложение. Щас чонить напишем

Как вообще работают все эти Шазамы?

Разумеется, никакой магии в них нет и треки руками никто не идентифицирует. И даже больше — не хранит.

Хранят такие сервисы фингерпринты треков: если хотите — очень крутой и сложный хеш, который строится не на основе байтов в файле, а на основе самой звуковой дорожки.

Когда юзер хочет распознать ту классную мелодию из кафешки, его мобильная игрушка записывает маленький ее кусочек, делает из него фингерпринт и шлет на сервер. А бек — просто сравнивает этот принт с миллионами других, находит наиболее подходящий и возвращает метаданные трека, которые лежали рядом в базюльке.

Чисто математически, задача сводится к тому, чтобы научиться строить фигнерпринт трека так, чтобы минимизировать и ложноположительные и ложноотрицательные результаты.

Задача бекендеров же — написать быструю крудятину и набить ее миллионами фингерпринтов, чтобы было чего крудятить

Так мы теперь математики или бекендеры?

Мы — наглые и ленивые. Поэтому, вместо того, чтобы изобретать велосипед и писать с нуля такой сервис, давайте посмотрим, что у нас есть в продаже

Было бы идеально, если бы можно было за просто так, или хотя бы за какие-то небольшие деньги, получить доступ к самому Shazam и кормить его фингерпринтами до одури. К сожалению, ни Shazam, ни TrackID ни еще пару похожих сервисов с огромными залежами метаданных, свою апишку кому попало не показывают и поставляют только готовое приложение.

Хороший, казалось бы сервис The Echo Nest, судя по описанию, предлагает ровно то, что нам нужно. Ура-урой, но при попытке познакомиться с родственниками что-то пошло не так: 

Тактическая вводная номер 2: почти все более-менее открытые сервисы либо умерли от старости, либо уже родились мертвыми. В общем, все грустно

Но мы так просто не сдадимся?

Еще как не сдадимся! И в итоге, все-таки, достанем из-под земли настоящий рабочий сервис с приличным количеством принтов в БДшке. Причем — даже бесплатный и почти без лимитов на количество запросов!

Знакомьтесь, acoustId.org, который умеет практически то, что нам нужно: по фигнерпринту может выдать список более-менее похожих треков. Правда, возвращает он не саму мету от треков, а мету от меты.

В смысле, какой-то хеш, по которому можно получить метаданные. Правда, уже на другом сервисе. И только по нечетным дням четного месяца третьей недели по предварительной записи. Но это все равно лучше, чем ничего

Надоел, давай уже кодить?!

Да, пора бы. Думаю, на счет «а почему опять джава?» мы в очередной раз разговаривать не будем, так что давайте начнем с апишки, а с ужасами получения фигнерпринта будем разбираться позднее.

public interface AcousticIdRepository {

@FormUrlEncoded
@POST("lookup")
Call<AcousticResponse> getTrackIds(
    @Field("fingerprint") String fingerPrint,
    @Field("duration") Long duration,
    @Field("client") String apiKey,
    @Field("meta") String fetchingFields);

class ApiFactory {
    public static AcousticIdRepository create() {
        Retrofit retrofit = new Retrofit.Builder()
            .addConverterFactory(GsonConverterFactory.create())
            .baseUrl(ApiNames.BASE_API)
            .client(new OkHttpClient())
            .build();

        return retrofit.create(AcousticIdRepository.class);
    }
}

Ретрофит, репозиторий, чистая сетевая архитектура — все, как мы любим. Из интересного, кроме самого фингерпринта, мы должны передать еще и длительность файла, с которого он был снят. Ребята из акустика настолько круты, что, в отличие от шазама, не заставляют сначала резать трек до 15 секунд и только потом его фингерпринтить, а могут в динамическое определение. А еще, они могут в экономию трафика, поэтому в fetchingFields мы должны скормить список полей, которые нам нужны обратно. В нашем случае, это recordIds, то есть хешики треков. Но это уже проблемы сервиса

public String identifyTrack(FingerPrint fingerPrint) {
     try {
         AcousticResponse response = acousticIdRepository
             .getTrackIds(
                 fingerPrint.getValue(),
                 fingerPrint.getDuration(),
                 configurationProvider.getAcousticApiKey(),
                 "recordingids")

             .execute()
             .body();

         return findMostSimilarResult(Optional.ofNullable(response)).getId();

     } catch (IOException e) {
         e.printStackTrace();
         throw new ApiException(e);
     }
 }

Вот этого, ага. Про апикей не будем, Apache Common Configuration, лежащий внутри конфиг-класса и хостящий там файлик с ключиком я уже как-то хвалил. Приняли фингерпринт — вернули хеш. Все просто, не так ли?

private AcousticRecordings findMostSimilarResult(Optional<AcousticResponse> response) {
    return response
        .orElseThrow(ApiException::new)
        .getResults()
        .stream()
        .max(Comparator.comparing(AcousticResults::getScore))
        .orElseThrow(ApiException::new)
        .getRecordings()
        .stream()
        .findFirst()
        .orElseThrow(ApiException::new);
}

Не так ли. Акустик у нас типичный стрелочник, и все свои проблемы идентификации переваливает на пользователя, выдавая ему просто пачку более-менее подходящих хешиков, заботливо снабжая их метрикой похожести, в расчете на то, что юзер просто выберет один из предложенных и успокоится.

Так и сделаем: возьмем самый жирный и похожий хешик и пойдем с ним в гости к другому сервису

Отдай мою мету по мете!

Знакомьтесь еще раз: musicbrainz. Этот сервис не хранит в себе никаких фингерпринтов, зато — хранит в себе огромнейшую коллекцию треков, альбомов и исполнителей. И, что самое крутое, он отлично понимает, что делать с хешиками акустика, которые мы с таким усердием добывали.

Апишка у него тоже классная:

@GET
Call<MusicBrainzResponse> getTrackMeta(@Url String url);

Кастомный url — это мои личные тараканы, не обращайте внимания. Сейчас все-все сформируем

public TrackMeta fetchTrackData(String trackId) {
    MusicBrainzResponse response;

    try {
        response = repository.getTrackMeta(buildFetchingUrl(trackId)).execute().body();
        return buildTrackMeta(Optional.ofNullable(response));
    } catch (IOException e) {
        e.printStackTrace();
        throw new ApiException(e);
    }
}

private String buildFetchingUrl(String trackId) {
    return "recording/" + trackId + "?inc=artist-credits+releases&fmt=json";
}

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

Причем, на минутку, вообще всех альбомов! И если трек — это супер-хит, который живет в трех официальных релизах, как это любят делать брутальные парни из Канады, пяти сборниках и доброй сотне бутлегов — все это вывалится в одной большой json-ке. А нам придется с этим как-то жить

public TrackMeta fetchTrackData(String trackId) {
        MusicBrainzResponse response;

        try {
            response = repository.getTrackMeta(buildFetchingUrl(trackId)).execute().body();
            return buildTrackMeta(Optional.ofNullable(response));
        } catch (IOException e) {
            e.printStackTrace();
            throw new ApiException(e);
        }
    }

    private String buildFetchingUrl(String trackId) {
        return "recording/" + trackId + "?inc=artist-credits+releases&fmt=json";
    }
private TrackMeta buildTrackMeta(Optional<MusicBrainzResponse> responseOptional) {
    MusicBrainzResponse response = responseOptional.orElseThrow(ApiException::new);

    TrackMeta result = new TrackMeta();

    String artistName = response
        .getArtistCredit()
        .stream()
        .findFirst()
        .orElse(new MusicBrainzAtristCredit())
        .getName();

    result.setArtist(artistName);

    MusicBrainzRelease release = response
        .getReleases()
        .stream()
        .filter(r -> r.getStatus().equalsIgnoreCase(MusicBraizReleaseType.OFFICIAL_TYPE))
        .findFirst()
        .orElse(response
            .getReleases()
            .stream()
            .findFirst()
            .orElse(new MusicBrainzRelease()));

    result.setRelease(release.getTitle());
    result.setDate(release.getDate());
    result.setTitle(response.getTitle());

    return result;
}

Я прекрасно понимаю, что для любителей пихать функциональный сахар куда не надо, в аду есть отдельный котел с пытками высшего порядка и композицией вил, но сделать с собой пока ничего не могу. Простите.

В остальном — ничего сложного. Просто собираем данные из респонса, причем, название альбома пытаемся брать из официальных релизов, если можно, и возвращаем DTO наружу: все, как мы и хотели

Отфингерпринть меня, ну?!

Да, самое время. Сейчас наш джава-сервис, уже не маленький и тупой, но все еще очень несамостоятельный: работать с файлами не умеет и ждет готовых фингерпринтов, делать которые не умеем пока мы.

Впрочем, это быстро пройдет, поскольку заботливый акустик дает нам возможность не играть в великих математиков и предлагает готовый бинарник да еще и под кучу платформ

Ну сказка просто! Нам остается только еще немного порадоваться и написать свой Джава-враппер, который будет запускать этот бинарник, парсить респонс и возвращать нам DTO

public static String runCliCommand(File executiveFile, String arguments) {
    try {

        ProcessBuilder processBuilder = new ProcessBuilder(executiveFile.getAbsolutePath(), arguments);
        Process p = processBuilder.start();
        p.waitFor();

        if (p.exitValue() != SUCCESSFUL_PROCESS_CODE) {
            String errorResult = new BufferedReader(new InputStreamReader(p.getErrorStream())).readLine();
            throw new CliExecutionException(errorResult);
        }

        BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()));
        StringBuilder result = new StringBuilder(reader.readLine());

        String line;
        while ((line = reader.readLine()) != null) {
            result
                .append("\n")
                .append(line);
        }

        return result.toString();
    } catch (IOException | InterruptedException | NullPointerException ex) {
        ex.printStackTrace();
        throw new CliExecutionException();
    }
}

Мда, Джаву явно к такому не готовили. Зато, теперь мы можем дернуть этот метод откуда угодно, скормив ему путь к бинарнику и строчку аргументов и, когда этот бинарник, наконец-то сдохнет, получить респонс. Или не получить, если код возврата такой себе

private File getFpCalcExecutable() {
    String osType = OperatingSystemUtil.getOperatingSystemType();

    File file = new File(LIBS_DIRECTORY + separator + FP_CALC_MARK + separator + osType, FP_CALC_MARK);

    if (!file.exists() || !file.canExecute()) {
        throw new NativeLibraryException("Can't find fpcalc library for specified OS");
    }

    return file;
}

Здесь автор вовремя вспомнил, что MacOS — это не единственная в мире и, даже не самая лучшая на свете операционка и хотел позаботиться о тех, кто любит всякие там Линуксы и фряхи.

Поэтому, он, в смысле я, положил в папку /lib рядом с джарником целую стопку бинарников для разных платформ так, чтобы, зная название и разрядность оси, можно было достать нужный.

На Windows автор тоже положил, совсем. Пусть сначала научатся файлы без расширений исполнять, как все нормальные люди. Тогда и поговорим

public FingerPrint getFingerPrint(File audioFile) {
    if (null == audioFile || !audioFile.exists() || !audioFile.canRead()) {
        throw new IllegalArgumentException("file is not acceptable");
    }

    String rawResult = OperatingSystemUtil.runCliCommand(getFpCalcExecutable(), audioFile.getAbsolutePath());

    return parseRawFingerPrint(rawResult);
}

Вот, то, чего мы так хотели. Метод, который вернет нам настоящий фингерпринт от настоящего аудиофайла. Или не вернет. Кстати, дальше будет ужасный, но такой необходимый говнокод. Отвернитесь

private FingerPrint parseRawFingerPrint(String rawValue) {

    if (null == rawValue || rawValue.isEmpty()) {
        throw new IllegalArgumentException();
    }

    FingerPrint fingerPrint = new FingerPrint();
    String rawDuration = rawValue.substring(rawValue.indexOf(DURATION_MARK) + DURATION_MARK.length(), rawValue.indexOf("\n"));
    String rawFingerPrint = rawValue.substring(rawValue.indexOf(FINGER_PRINT_MARK) + FINGER_PRINT_MARK.length());

    fingerPrint.setDuration(Long.valueOf(rawDuration));
    fingerPrint.setValue(rawFingerPrint);

    return fingerPrint;
}

Жить-то как-то надо. А возвращать из консольки что-то, кроме строки пока никто не умеет. Приходится изобретать велосипеды и тосковать по тому, что мой любимый Apache Config здесь ни при чем

Запускай!

Так, рано. Все это мы пока умеем делать для одного файлика и ничем не отличаемся от какого-нибудь бесполезного в нашем случае шазама. Поэтому, давайте быстрее писать батч-процессинг и кормить ему целый список файлов

private BatchResult tagMp3Files(List<File> files) {
        BatchResult result = new BatchResult();

        files.forEach(file -> {
            try {
                NativeLibraryUtil nativeLibraryUtil = new NativeLibraryUtil();
                LOGGER.info("Processing file: {}", file.getName());
                FingerPrint fingerPrint = nativeLibraryUtil.getFingerPrint(file);

                AcousticIdService acousticIdService = new AcousticIdService();
                String trackId = acousticIdService.identifyTrack(fingerPrint);

                MusicBrainzService musicBrainzService = new MusicBrainzService();
                TrackMeta trackMeta = musicBrainzService.fetchTrackData(trackId);

                result.MarkFileAsSuccess(file);
                LOGGER.info("Finished file: {}", file.getName());
            } catch (Exception ex) {
                LOGGER.error(ex.getMessage());
                result.MarkFileAsFailure(file);
                LOGGER.info("Failure file: {}", file.getName());
            }
        });

Так, пока все хорошо. Не совсем еще, правда, понятно, что делать с готовыми мета-данными, но это потом. А сейчас, давайте припишем пару строчек кода, которые складывают все мета-данные в мапу от файла и посмотрим на нее дебаггером, когда джоба отработает

Так, это успех. Из 50 файлов мы успешно и правильно смогли аж в 43 штуки и даже ни разу не поймали NPE

Сохраняя — сохраняй

После того, как мы вытянули мету для mp3, хорошо бы было ее туда и записать, перетерев к черту всю имеющуюся там чепуху. Изобретать самостоятельно ничего не придется, благо библиотек хватает

Например, JAudioTagger, продуктивный, всеядный, но не в меру болтливый, прям как я. Видимо, авторы библиотечки были настолько крутые, что перфоманса, во всех смыслах этого слова, ради написали свой логгер, который из вне конфигурится никак. Нам же остается только смириться и настрочить чуток кода

public File writeTrackMeta(File file, TrackMeta meta) {

    if (null == file || !file.exists() || !file.canRead()) {
        throw new Id3TagException();
    }

    try {
        MP3File mp3File = (MP3File) AudioFileIO.read(file);

        mp3File = setId3v1Tags(mp3File, meta);
        mp3File = setId3v2Tags(mp3File, meta);

        mp3File.commit();
        mp3File.save();

    } catch (Exception e) {
        throw new Id3TagException(e);
    }
    return null;
}

Писать будем во все поля: и ID3v2 и ID3v1 заодно. Будет красиво выглядеть даже на самом старом калькуляторе, если кодировки хватит.

private MP3File setId3v2Tags(MP3File mp3File, TrackMeta meta) {
    ID3v24Tag id3v2Tag = mp3File.getID3v2TagAsv24() == null ? new ID3v24Tag() : mp3File.getID3v2TagAsv24();

    setId3v2Value(id3v2Tag, FieldKey.ARTIST, meta.getArtist());
    setId3v2Value(id3v2Tag, FieldKey.TITLE, meta.getTitle());
    setId3v2Value(id3v2Tag, FieldKey.YEAR, meta.getDate());
    setId3v2Value(id3v2Tag, FieldKey.ALBUM, meta.getRelease());

    mp3File.setID3v2Tag(id3v2Tag);
    return mp3File;
}

private void setId3v2Value(ID3v24Tag tag, FieldKey key, String value) {
    try {
        tag.setField(key, value);
    } catch (FieldDataInvalidException ignored) {
    }
}

Во, теперь совсем хорошо. Для первой версии — все то же самое, потом в исходниках почитаете, там не интересно

Точка входа где?!

Вот. Но тут ничего важного, просто достаем путь к папке с музыкой из аргументов и дергаем процессер файлов.

public class Application {

    public static void main(String[] args) throws IOException {
        if (args.length != 1) {
            System.out.println("Directory isn't specified");
            System.out.println("USAGE: java -jar <filename> <directory>");
            System.exit(1);
        }

        File directory = new File(args[0]);

        if (!directory.isDirectory()) {
            System.out.println("Path isn't correct");
            System.exit(1);
        }

        BatchResult result = new BatchProcessor().tagMp3Files(directory);

        System.out.println("");
        System.out.println("TAGGING FINISHED!");
        System.out.println("");
        System.out.println("PROCESSED FILES: " + result.getProcessedFiles().size());
        System.out.println("SUCCESSFUL FILES: " + result.getSuccessFiles().size());
        System.out.println("FAILURE FILES: " + result.getFailureFiles().size());
        System.out.println("");
        System.out.println("FAILURES IN: ");
        result.getFailureFiles().forEach(file -> System.out.println("\t" + file.getName()));
        System.out.println("FINISHING...");
    }
}

 

Бабахнем в прод?

Еще как бабахнем, весь радиопоток в труху! И не потом, а прямщас. Запустим наш джарник прямо на моей западно-европейской ноде, натравив его на папочку с mp3 и уйдем спать: там больше тысячи файлов набралось, дело, прям небыстрое

Круто, у этого трека тегов точно не было, я проверял! Давайте посмотрим репорт?

PROCESSED FILES: 1182
SUCCESSFUL FILES: 1036
FAILURE FILES: 146

FAILURES IN:
         05. Perfect Love... Gone Wrong.mp3
        09. Lie Lie Lie.mp3
        01. Highland.mp3
        01. Shut Up!.mp3
        Ехо - OST.mp3
        13. Fly On The Wall.mp3
        05. Sake Of The Song.mp3
        05. The Great Gig In The Sky.mp3
        Марко Поло - Дейви джонс.mp3
        07.  The Man's Too Strong.mp3
        08. Honking Antelope.mp3
        07. I Don't Want To Know.mp3
        10. Pauvre Diable.mp3
         02. Ветер-менестрель.mp3
        12. All I Need To Know.mp3
        ...
        05. Сопряжение сфер.mp3

Так, прекрасно. Это же прям 87% всех треков.

Теперь никаких скучных No Title — No Album, только свежие и годные теги треков! Круто, правда?

Исходники проекта: https://bitbucket.org/GrakovNe/radio-tagger/

Искренне Ваш, слишком функциональный, GrakovNe

Top