Регистрация | Войти
Lisp — программируемый язык программирования
Предыдущая Оглавление Следующая

25. Практика: разбор ID3

Имея библиотеку для разбора двоичных данных, вы уже готовы к созданию кода для чтения и записи в каком-то реальном двоичном формате, например, формате тэгов ID3. Тэги ID3 используются для хранения дополнительной информации в звуковых файлах MP3. Работа с тэгами ID3 будет хорошей проверкой для библиотеки для работы с двоичными данными, потому что формат ID3 – это настоящий, используемый на практике, формат – смесь инженерных компромиссов и характерных решений, которые, тем не менее, выполняют своё назначение. На тот случай, если вы случайно пропустили революцию свободного обмена данными, вот краткий обзор того, что представляют собой тэги ID3, и как они относятся к файлам MP3.

MP3, или звуковой слой 3 для MPEG1), – это формат для хранения сжатых звуковых данных, разработанный исследователями из Фраунгоферовского института интегральных схем и стандартизованный "Группой экспертов кино"2), объединённым комитетом организаций ISO3) и IEC4). Однако, формат MP3 сам по себе определяет только то, как хранить звуковые данные. Это не страшно до тех пор, пока все ваши звуковые файлы обрабатываются каким-то одним приложением, которое может хранить эти метаданные вне звуковых файлов, сохраняя их связь со звуковыми файлами. Однако, как только люди стали обмениваться файлами MP3 через Интернет через такие файлообменные системы, как Napster, они быстро обнаружили, что нужно как-то вставлять метаданные внутрь самих файлов MP3.

Поскольку стандарт MP3 уже был оформлен, и значительная часть программного обеспечения и оборудования уже была написана и спроектирована, причём так, что они знали, как декодировать существующий формат файлов MP3, то любая схема внедрения информации в файл MP3 была бы такой, что эта информация вынуждено была бы невидима декодерам файлов MP3. Вот тут и появился ID3.

Первоначально формат ID3, изобретённый программистом Эриком Кэмпом5), представлял собой 128 байт, прилепленных в конце файла MP3, где их не замечало бы большинство программ, работающих с MP3. Эта информация состояла из четырёх тридцатибуквенных полей, являвшихся названием песни, названием альбома, именем исполнителя и комментарием, одного четырёхбайтового поля года и одного однобайтового поля кода жанра произведения. Кэмп придумал стандартные значения первых 80-ти кодов жанров. Nullsoft, производитель программы Winamp, очень популярного MP3-плеера, позже добавили в этот список ещё что-то около 60 жанров.

Этот формат было легко разбирать, но он был достаточно ограничен. Не было способа сохранить названия более чем в 30 символов, было ограничение в 256 жанров, и значения кодов жанров должны были одинаково восприниматься всеми пользователями, использующими ID3. Не было даже способа сохранить номер дорожки на исходном диске до тех пор, пока другой программист, Микаэль Мутшлер6), не предложил вставлять номер дорожки в поле комментария, отделяя его от остального комментария нулевым байтом, так, чтобы существующее ПО, использующее ID3, которое предположительно читало бы до первого нулевого символа в каждом текстовом поле, игнорировало бы его. Версия Кэмпа теперь называется "ID3 версия 1" (ID3v1), а версия Мутшлера - "ID3 версия 1.1" (ID3v1.1)

Предложения первой версии, как бы ни ограничены они были, являлись хотя бы частичным решением проблемы хранения метаданных, так что они были применены многими программами копирования музыки7), сохранявшими тэги ID3 в файлах MP3, и MP3-плеерами, вытаскивавшими эту информацию из тэгов ID3 и показывавшими их пользователю.

Однако, к 1998 году все эти ограничения стали совсем уже раздражающими, и новая группа разработчиков, возглавляемая Мартином Нильсоном8), начала работу над совершенно новой схемой хранения метаданных, которую ID3v2. Формат ID3v2 крайне гибок, разрешает включать много видов информации практически без ограничения длины. Также он берёт на вооружение некоторые особенности формата MP3 файла для того, чтобы разместить тэги ID3v2 в начале файла MP3.

Однако, разбирать тэги в формате ID3v2 – задача значительно более сложная, чем тэги в формате версии 1. В этой главе мы будем использовать библиотеку разбора бинарных данных из предыдущей главы для того, чтобы разработать код, который сможет читать и писать тэги в формате ID3v2. Ну или по крайней мере сделаем какое-то приемлимое начало, поскольку если ID3v1 достаточно прост, то ID3v2 порой причудлив до невозможности. Реализация всех закоулков и потаённых уголков спецификации была бы порядочно сложной работой, особенно если бы вы хотели поддержать все три версии, которые были документированы. На самом деле вы можете игнорировать многие возможности в этих спецификациях, поскольку они очень редко используются в "дикой природе". В качестве закуски вы можете опустить поддержку всей версии 2.4, поскольку она не была широко воспринята и в основном всего лишь добавляла некую вовсе не нужную гибкость по сравнению с версией 2.3. Я сконцентрируюсь на версии 2.2 и 2.3, потому что обе они широко используются и достаточно сильно отличаются друг от друга, чтобы сделать нашу работу интересной.

Структура тэга ID3v2.

До того, как начать кодировать, вам нужно познакомиться с общей структурой тэгов ID3v2. Каждый тэг начинается с заголовка, содержащего информацию о тэге в общем. Первые три байта заголовка содержат строку "ID3" в кодировке ISO-8859-1. То есть это байты с кодами 73, 68 и 51. Затем идут два байта, которые кодирую "старшую версию" и ревизию спецификации ID3, которой тэг намеревается соответствовать. Далее идёт один байт, биты которого интерпретируются как различные флаги. Значение каждого из флагов зависит от версии спецификации. Некоторые из флагов могут влиять на то, как обрабатывается весь тэг целиком. Байты "старшей версии" на самом деле используются для записи младшей версии спецификации, в то время как ревизия используется для хранения подверсии спецификации. Таким образом поле "старшая версия" тэга, соответствующего спецификации версии 2.3.0, будет 3. Поле ревизии всегда равно нулю, поскольку каждая новая спецификация ID3v2 увеличивала младшую версию, оставляя подверсию нулём. Значение, хранимое в поле старшей версии тэга, как вы увидите, имеет сильное влияние на то, как надо разбирать всю оставшуюся часть тэга.

Последнее поле в заголовке тэга – это число, закодированное в четырех байтах, в каждом из которых используется лишь по семь бит, содержащее размер всего тэга без учета заголовка. В тэгах версии 2.3 в заголовке может быть еще несколько дополнительных полей; все остальное – это данные, разделенные на фреймы. Разные типы фреймов хранят разные виды информации: от простого текста вроде названия песни до встроенного изображения. Каждый фрейм начинается с заголовка, содержащего строковой идентификатор и размер. В версии 2.3 заголовок фрейма также содержит два байта флагов и – при выставленном флаге – дополнительный однобайтовый код, указывающий, как закодирован остаток фрейма.

Фреймы – идеальный пример FIXME tagged структур данных: чтобы FIXME пропарсить тело фрейма, надо прочитать заголовок и использовать идентификатор, чтобы определить, какой вид данных ты читаешь.

Заголовок ID3 не указывает прямо, сколько фреймов в тэге – он говорит, насколько тот большой, но раз фреймы могут быть разной длины, единственным способом узнать количество фреймов будет прочитать их данные. К тому же размер, записанный в заголовке, может быть больше, чем реальное количество байтов в данных фреймов; после фреймов могут идти нули для выравнивания под указанный размер. Это позволяет программам изменять тэг без переписывания всего MP3-файла9).

Итак, наши главные задачи: чтение заголовка ID3; определение версии, 2.2 или 2.3; чтение данных всех фреймов до конца тэга или до блока выравнивания.

Defining a Package

Как и с другими библиотеками, которые мы разработали ранее, тот код, который мы напишем в этой главе, FIXME worth putting в отдельный пакет. Нам надо будет обращаться к функциям из библиотек binary и pathname из глав 15 и 24, и надо экспортировать имена функций, которые составляют API этого пакета. Определим его так:

(defpackage :com.gigamonkeys.id3v2
  (:use :common-lisp
        :com.gigamonkeys.binary-data
        :com.gigamonkeys.pathnames
)

  (:export
   :read-id3
   :mp3-p
   :id3-p
   :album
   :composer
   :genre
   :encoding-program
   :artist
   :part-of-set
   :track
   :song
   :year
   :size
   :translated-genre
)
)

Как обычно, вы можете, и наверное, вам даже следует заменить "com.gigamonkeys" в имени пакета на ваш собственный домен.

Integer Types

Можно начать с определения бинарных типов для чтения и записи некоторых примитивов FIXME(primitive types – слово типов повторяется два раза), использующихся в формате ID3, несколько целочисленных типов разного размера и четыре вида строк.

ID3 использует беззнаковые целые, закодированные в одном, двух, трех или четырех байтах. FIXME If you first write a general unsigned-integer binary type that takes the number of bytes to read as an argument, you can then use the short form of define-binary-type to define the specific types. The general unsigned-integer type looks like this:

(define-binary-type unsigned-integer (bytes)
  (:reader (in)
    (loop with value = 0
       for low-bit downfrom (* 8 (1- bytes)) to 0 by 8 do
         (setf (ldb (byte 8 low-bit) value) (read-byte in))
       finally (return value)
)
)

  (:writer (out value)
    (loop for low-bit downfrom (* 8 (1- bytes)) to 0 by 8
       do (write-byte (ldb (byte 8 low-bit) value) out)
)
)
)

Теперь можно пользоваться короткой формой define-binary-type для определения типов для каждого размера целого из формата ID3:

(define-binary-type u1 () (unsigned-integer :bytes 1))
(define-binary-type u2 () (unsigned-integer :bytes 2))
(define-binary-type u3 () (unsigned-integer :bytes 3))
(define-binary-type u4 () (unsigned-integer :bytes 4))

Еще один тип, который надо уметь читать и писать, это 28-ми битное значение из заголовка. Это размер, закодированный не как обычно – количеством бит, кратным 8, таким как 32 – а 28-ю FIXME PUNCTUATION, потому что тэг ID3 не может содержать байт #xff, за которым идут три включенных бита – такой FIXME pattern имеет особое значение для MP3-декодеров. В принципе, ни одно поле в заголовке ID3 не может содержать такую последовательность байтов, но если бы размер тэга был закодирован обычным беззнаковым целым, то были бы проблемы. Чтобы исключить такую возможность, размер кодируется в семи младших битах каждого байта, все старшие всегда нули10).

Таким образом, оно может быть считано и записано во многом как беззнаковое целое, только размер байта, который передается в LDB, должен быть 7, а не 8. Это сходство наводит на мысль, что если добавить параметр bits-per-byte к существующему бинарному типу unsigned-integer, тогда можно определить новый тип id3-tag-size, используя короткую форму define-binary-type. Новая версия unsigned-integer такая же, как старая, только bits-per-byte заменяет прописанную везде в старой 8-ку. Выглядит так:

(define-binary-type unsigned-integer (bytes bits-per-byte)
  (:reader (in)
    (loop with value = 0
       for low-bit downfrom (* bits-per-byte (1- bytes)) to 0 by bits-per-byte do
         (setf (ldb (byte bits-per-byte low-bit) value) (read-byte in))
       finally (return value)
)
)

  (:writer (out value)
    (loop for low-bit downfrom (* bits-per-byte (1- bytes)) to 0 by bits-per-byte
       do (write-byte (ldb (byte bits-per-byte low-bit) value) out)
)
)
)

Теперь определение id3-tag-size становится тривиальным:

(define-binary-type id3-tag-size () (unsigned-integer :bytes 4 :bits-per-byte 7))

Также надо изменить определения u1–u4 для указания, что там 8 бит в байте:

(define-binary-type u1 () (unsigned-integer :bytes 1 :bits-per-byte 8))
(define-binary-type u2 () (unsigned-integer :bytes 2 :bits-per-byte 8))
(define-binary-type u3 () (unsigned-integer :bytes 3 :bits-per-byte 8))
(define-binary-type u4 () (unsigned-integer :bytes 4 :bits-per-byte 8))

String Types

Еще один из примитивных типов, который повсеместен в тэге ID3, это строка FIXME PUNCTUATION. В предыдущей главе мы обсудили некоторые вещи, на которые надо обратить внимание, когда имеешь дело со строками в бинарных файлах, такие как разница между кодом знака и кодировкой.

ID3 использует две разные кодировки: ISO 8859-1 и Unicode. ISO 8859-1, также известный как Latin-1, FIXME PUNCTUATION – это 8-ми битная кодировка, которая дополняет ASCII буквами из языков Восточной Европы. Другими словами, одни и те же коды от 0 до 127 указывают на одни и те же знаки ASCII и ISO 8859-1, но ISO 8859-1 FIXME also provides mappings for code points up to 255. Unicode – это кодировка, сделанная, чтобы обеспечить кодом FIXME virlually каждый знак всех на свете языков. Unicode – надмножество ISO 8859-1 так же, как ISO 8859-1 – надмножество ASCII: коды 0-255 отображаются на одни и те же знаки ISO 8859-1 и Unicode. (Таким образом, Unicode еще и надмножестно ASCII.FIXME PUNCTUATION)

Поскольку ISO 8859-1 является 8-ми битной кодировкой, она использует один байт на знак. Для Unicode-строк ID3 использует кодировку UCS-2 с меткой порядка байтов11). Через пару мгновений я расскажу, что это такое.

Чтение и запись этих двух кодировок не является проблемой – это всего лишь вопрос чтения и записи беззнаковых чисел в разных форматах, и мы только что написали код для этого. Трюк в том, чтобы перевести эти числовые значения в объекты знаков языка Lisp.

Ваша реализация Lisp возможно искользует или Unicode, или ISO 8859-1 в качестве внутренней кодировки. И раз все значения от 0 до 255 отображаются на одни и те же знаки в ISO 8859-1 и Unicode, то можно использовать функции CODE-CHAR и СHAR-CODE для их транслирования в обе кодировки. Однако, если ваш Lisp поддерживает только ISO 8859-1, тогда можно будет FIXME represent only the first 255 Unicode characters as Lisp characters. Другими словами, в такой реализации Lisp, если вы попробуете обработать тэг ID3, который использует строки Unicode, и любая из этих строк содержит знак с кодом, большим 255, то вы получите ошибку, когда попытаетесь перевести этот код в Lisp FIXME character. Пока будем считать, что мы или используем Lisp, поддерживающий Unicode, или не будем работать с файлами, содержащими знаки вне досягаемости ISO 8859-1.

FIXME The other issue with encoding strings is how to know how many bytes to interpret as character data. ID3 использует две стратегии, рассмотренные в предыдущей главе: некоторые строки заканчиваются нулевым символом, тогда как другие встречаются на позициях, по которым можно определить количество байт для считывания: или когда строка в том расположении всегда одной длины, или когда она в конце составной структуры, чей размер известен. Тем не менее обратите внимание, что количество байт не обязательно совпадает с количеством знаков в строке.

Складывая все эти варианты вместе, получим, что формат ID3 использует четыре способа чтения и записи строк: два вида знаков на два вида разграничения строковых данных.

Очевидно, значительная часть логики чтения и записи строк будет полностью совпадать. Так что, можно начать с определения двух бинарных типов: один для чтения строк заданной длины (в знаках) FIXME(не понял, почему в знаках, а не в байтах) и другой для чтения FIXME terminated строк. Оба пользуются тем, что тип, передаваемый в read-value и write-value, это такие же данные; FIXME you can make the type of character to read a parameter of these types. Этой техникой мы будем пользоваться довольно часто в этой главе.

(define-binary-type generic-string (length character-type)
  (:reader (in)
    (let ((string (make-string length)))
      (dotimes (i length)
        (setf (char string i) (read-value character-type in))
)

      string
)
)

  (:writer (out string)
    (dotimes (i length)
      (write-value character-type out (char string i))
)
)
)


(define-binary-type generic-terminated-string (terminator character-type)
  (:reader (in)
    (with-output-to-string (s)
      (loop for char = (read-value character-type in)
            until (char= char terminator) do (write-char char s)
)
)
)

  (:writer (out string)
    (loop for char across string
          do (write-value character-type out char)
          finally (write-value character-type out terminator)
)
)
)

С этими типами несложно будет прочитать строки ISO 8859-1. Поскольку character-type, который передается в read-value и write-value должен быть именем бинарного типа, то надо определить iso-8859-1-char. Здесь же неплохо разместить немного FIXME sanity checking on the code points of characters you read and write.

(define-binary-type iso-8859-1-char ()
  (:reader (in)
    (let ((code (read-byte in)))
      (or (code-char code)
          (error "Character code ~d not supported" code)
)
)
)

  (:writer (out char)
    (let ((code (char-code char)))
      (if (<= 0 code #xff)
          (write-byte code out)
          (error "Illegal character for iso-8859-1 encoding: character: ~c with code: ~d" char code)
)
)
)
)

Теперь определение строк ISO 8859-1 становится тривиальным:

(define-binary-type iso-8859-1-string (length)
  (generic-string :length length :character-type 'iso-8859-1-char)
)


(define-binary-type iso-8859-1-terminated-string (terminator)
  (generic-terminated-string :terminator terminator :character-type 'iso-8859-1-char)
)

Чтение строк UCS-2 лишь немногим сложнее. Трудности возникают из-за того, что можно кодировать UCS-2 двумя способами: в порядке байтов от старшего к младшему (big-endian) или от младшего к старшему (little-endian). Поэтому строки UCS-2 начинаются с двух дополнительных байтов, которые называются меткой порядка байтов, состоящих из числового значения #xfeff, закодированных или в порядке big-endian, или в little-endian. При чтении строки UCS-2, надо прочитать метку порядка байтов, а потом, в зависимости от ее значения, читать знаки в порядке big-endian или в little-endian. Так что понадобится два разных типа знаков UCS-2. Но нужна только одна версия FIXME sanity-checking code. Значит можно определить параметризованный бинарный тип:

(define-binary-type ucs-2-char (swap)
  (:reader (in)
    (let ((code (read-value 'u2 in)))
      (when swap (setf code (swap-bytes code)))
      (or (code-char code) (error "Character code ~d not supported" code))
)
)

  (:writer (out char)
    (let ((code (char-code char)))
      (unless (<= 0 code #xffff)
        (error "Illegal character for ucs-2 encoding: ~c with char-code: ~d" char code)
)

      (when swap (setf code (swap-bytes code)))
      (write-value 'u2 out code)
)
)
)

где функция swap-bytes определена ниже, с использованием преимучества функции LDB, с которой можно делать SETF и, соответственно, ROTATEF. FIXME второй вариант чуть более неформальный: где функция swap-bytes определена ниже, с использованием преимучества LDB, которую можно SETFить и, соответственно, ROTATEFить.

where the swap-bytes function can be defined as follows, taking advantage of LDB being SETFable and thus ROTATEFable:

(defun swap-bytes (code)
  (assert (<= code #xffff))
  (rotatef (ldb (byte 8 0) code) (ldb (byte 8 8) code))
  code
)

Используя ucs-2-char, определим два типа знаков, которые будут использоваться в качестве аргумента character-type функций обобщенных строк.

(define-binary-type ucs-2-char-big-endian () (ucs-2-char :swap nil))

(define-binary-type ucs-2-char-little-endian () (ucs-2-char :swap t))

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

(defun ucs-2-char-type (byte-order-mark)
  (ecase byte-order-mark
    (#xfeff 'ucs-2-char-big-endian)
    (#xfffe 'ucs-2-char-little-endian)
)
)

Now you can define length- and terminator-delimited string types for UCS-2-encoded strings которые читают метку порядка байтов и определяют, какой вариант знаков UCS-2 передавать в качестве аргумента character-type в read-value и write-value. FIXME The only other wrinkle is, что надо переводить аргумент length, который дан в байтах, в количество знаков, что надо прочесть, учитывая метку порядка байтов.

(define-binary-type ucs-2-string (length)
  (:reader (in)
    (let ((byte-order-mark (read-value 'u2 in))
          (characters (1- (/ length 2)))
)

      (read-value
       'generic-string in
       :length characters
       :character-type (ucs-2-char-type byte-order-mark)
)
)
)

  (:writer (out string)
    (write-value 'u2 out #xfeff)
    (write-value
     'generic-string out string
     :length (length string)
     :character-type (ucs-2-char-type #xfeff)
)
)
)


(define-binary-type ucs-2-terminated-string (terminator)
  (:reader (in)
    (let ((byte-order-mark (read-value 'u2 in)))
      (read-value
       'generic-terminated-string in
       :terminator terminator
       :character-type (ucs-2-char-type byte-order-mark)
)
)
)

  (:writer (out string)
    (write-value 'u2 out #xfeff)
    (write-value
     'generic-terminated-string out string
     :terminator terminator
     :character-type (ucs-2-char-type #xfeff)
)
)
)

ID3 Tag Header

Закончив с основными примитивными типами, мы готовы перейти к более общей картине и начать определять бинарные классы для представления сначала тэга ID3 в целом, а потом и отдельных фреймов.

Если заглянуть в спецификацию ID3v2.2, то мы увидим, что в основе структуры тэга такой заголовок:

ID3/file identifier      "ID3"
ID3 version              $02 00
ID3 flags                %xx000000
ID3 size             4 * %0xxxxxxx

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

(define-binary-class id3-tag ()
  ((identifier     (iso-8859-1-string :length 3))
   (major-version  u1)
   (revision       u1)
   (flags          u1)
   (size           id3-tag-size)
)
)

Если у вас под рукой есть какой-нибудь MP3-файл, вы можете проверить всю эту кучу кода и заодно посмотреть, какую версию тэга ID3 он содержит. Для начала напишем функцию, которая считывает только что определенный id3-tag из начала файла. Надо понимать, тем не менее, что тэг ID3 не обязан находиться в начале файла, хотя в наши дни он почти всегда там. Чтобы найти тэг ID3 где-то еще в файле, последний можно просканировать в поисках последовательности байтов 73, 68, 51 (другими словами, это строка "ID3")12). Правда, сейчас уже, наверное, можно считать, что файлы начинаются с тэгов.

(defun read-id3 (file)
  (with-open-file (in file :element-type '(unsigned-byte 8))
    (read-value 'id3-tag in)
)
)

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

(defun show-tag-header (file)
  (with-slots (identifier major-version revision flags size) (read-id3 file)
    (format t "~a ~d.~d ~8,'0b ~d bytes -- ~a~%"
            identifier major-version revision flags size (enough-namestring file)
)
)
)

Она выдаст примерно следующее:

ID3V2> (show-tag-header "/usr2/mp3/Kitka/Wintersongs/02 Byla Cesta.mp3")
ID3 2.0 00000000 2165 bytes -- Kitka/Wintersongs/02 Byla Cesta.mp3
NIL

Конечно, чтобы определить, какая версия ID3 встречается чаще всего в вашей библиотеке, лучше бы иметь функцию, которая выдает сводку по всем MP3-файлам в директории. Такую легко реализовать с помощью функции walk-directory из главы 15. Для начала определим вспомогательную функцию, которая проверяет, что у файла расширение MP3.

(defun mp3-p (file)
  (and
   (not (directory-pathname-p file))
   (string-equal "mp3" (pathname-type file))
)
)

Затем соединим show-tag-header, mp3-p с walk-directory, чтобы печатать сводку по заголовкам ID3 в файлах в заданной директории.

(defun show-tag-headers (dir) 
  (walk-directory dir #'show-tag-header :test #'mp3-p)
)

Однако, если у вас много MP3-файлов, вы можете пожелать просто посчитать, сколько тэгов ID3 каждой версии у вас в MP3 коллекции. Для получения этой информации, можно было бы написать такую функцию:

(defun count-versions (dir)
  (let ((versions (mapcar #'(lambda (x) (cons x 0)) '(2 3 4))))
    (flet ((count-version (file)
             (incf (cdr (assoc (major-version (read-id3 file)) versions)))
)
)

      (walk-directory dir #'count-version :test #'mp3-p)
)

    versions
)
)

Другая функция, которая понадобится в главе 29, для проверки, что файл действительно начинается с тэга ID3, которую можно определить вот так:

(defun id3-p (file)
  (with-open-file (in file :element-type '(unsigned-byte 8))
    (string= "ID3" (read-value 'iso-8859-1-string in :length 3))
)
)

ID3 Frames

FIXME As I discussed earlier, основная часть тэга ID3 разделена на фреймы. Каждый фрейм имеет структуру, похожую на структуру всего тэга. Каждый фрейм начинается с заголовка, указывающего вид фрейма и размер фрейма в байтах. Структура заголовка фрейма немного разная у версий 2.2 и 2.3 формата ID3, и так получилось, что нам придется работать с обеими формами. Для начала сфокусируемся на разборе версии 2.2.

Заголовок в версии 2.2 состоит из трех байт, которые кодируют трехбуквенную ISO 8859-1 строку, за которой идет трехбайтовое беззнаковое число, FIXME which specifies размер фрейма в байтах без шестибайтового заголовка. Строка указывает тип фрейма, что определяет, как мы будем разбирать данные. Это как раз та ситуация, для которой мы определили макрос define-tagged-binary-class. Мы можем определить FIXME tagged класс, который читает заголовок фрейма и затем подбирает подходящий конкретный класс, используя функцию, которая отображает ID на имя класса.

(define-tagged-binary-class id3-frame ()
  ((id (iso-8859-1-string :length 3))
   (size u3)
)

  (:dispatch (find-frame-class id))
)

Now you're ready to start implementing concrete frame classes. Теперь мы готовы начать строить реализацию конкретных классов фреймов. FIXME However, спецификация определяет FIXME quite a few – 63 в версии 2.2 и FIXME even more в более поздних версиях. Даже считая типы фреймов, которые имеют общую структуру, эквивалентными, мы все еще получим 24 уникальных типа в версии 2.2. Но только несколько из них используется на практике. Так что, вместо того, чтобы сразу приступить So rather than immediately setting to work defining classes for each of the frame types, you can start by writing a generic frame class that lets you read the frames in a tag without parsing the data within the frames themselves. This will give you a way to find out what frames are actually present in the MP3s you want to process. You'll need this class eventually anyway because the specification allows for experimental frames that you'll need to be able to read without parsing.

Так как поле размера из заголовка фрейма точно говорит вам, какова длинна фрейма в байтах, вы можете определить класс generic-frame (обобщённый фрейм), который расширяет id3-frame и добавляет единственное поле, data, которое будет содержать массив байт.

(define-binary-class generic-frame (id3-frame)
  ((data (raw-bytes :size size)))
)

Тип поля data, raw-bytes, должен просто содержать массив байт. Вы можете определить его вот так:

(define-binary-type raw-bytes (size)
  (:reader (in)
    (let ((buf (make-array size :element-type '(unsigned-byte 8))))
      (read-sequence buf in)
      buf
)
)

  (:writer (out buf)
    (write-sequence buf out)
)
)

На данный момент, нам нужно, чтобы все фреймы читались как greneric-frame, так что можно определить функцию find-frame-class, которая используется в выражении :dispatch в классе id3-frame так, чтобы она всегда возвращала generic-frame, не обращая внимания на индентификатор фрейма.

(defun find-frame-class (id)
  (declare (ignore id))
  'generic-frame
)

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

Чтобы управится с этим, можно определить бинарный тип id3-frames, который будет ответственен за чтение остатка тега, создание объектов фреймов для представления всех найденных фреймов и пропуск заполнителя. Этот тип будет принимать как параметр размер тега, который он сможет использовать, чтобы избежать чтения за концом тега. Но читающему коду ещё и придётся определять начало заполнителя, который может следовать за данными фрейма в теге. Вместо того, чтобы вызывать read-value прямо в форме :reader типа id3-frames, лучше использовать функцию read-frame, определив её так, чтобы она возвращала NIL, когда обнаружит заполнитель, иначе возвращая объект id3-frame, прочитанный через read-value. Предпологая, что read-frame определена так, что она читает только один байт после конца предыдущего фрейма для обнаружения заполнителя, можно определить бинарный тип id3-frames так:

(define-binary-type id3-frames (tag-size)
  (:reader (in)
    (loop with to-read = tag-size
          while (plusp to-read)
          for frame = (read-frame in)
          while frame
          do (decf to-read (+ 6 (size frame)))
          collect frame
          finally (loop repeat (1- to-read) do (read-byte in))
)
)

  (:writer (out frames)
    (loop with to-write = tag-size
          for frame in frames
          do (write-value 'id3-frame out frame)
          (decf to-write (+ 6 (size frame)))
          finally (loop repeat to-write do (write-byte 0 out))
)
)
)

Следующим кодом мы добавим слот frames в id3-tag.

(define-binary-class id3-tag ()
  ((identifier     (iso-8859-1-string :length 3))
   (major-version  u1)
   (revision       u1)
   (flags          u1)
   (size           id3-tag-size)
   (frames         (id3-frames :tag-size size))
)
)

Обнаружение заполнителя тега

Теперь всё, что осталось доделать - реализовать read-frame. Это потребует немного сноровки, так как код, который на самом деле читает байты из потока, лежит на несколько уровней ниже read-frame.

То, что вам бы действительно хотелось делать в read-frame - прочитать один байт и, если он нулевой, вернуть NIL, в противном случае прочитать фрейм при помощи read-value. К несчастью, если вы прочитаете байт в read-frame, то он не сможет быть заново прочитан read-value.(примечание 6)

Выходит, это прекрасная возможность использовать систему условий – вы можете устроить проверку на нулевые байты в коде нижнего уровня, читающем поток, и сигнализировать условие, когда прочитан ноль; read-frame сможет затем обработать условие, размотав стек до того, как будут прочитаны следующие байты. В дополнение к тому, что это аккуратное решение проблемы обнаружения начала заполнителя тега, это также и пример, как можно использовать условия для целей, отличных от обработки ошибок.

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

(define-condition in-padding () ())

Затем, вам нужно определить бинарный тип, чей :reader читает данное число байт, сначала читая один байт и сигнализируя условие in-padding, если он нулевой и, иначе, читая оставшиеся байты как iso-8859-1-string и соединяя их с первым прочитанным.

(define-binary-type frame-id (length)
  (:reader (in)
    (let ((first-byte (read-byte in)))
      (when (= first-byte 0) (signal 'in-padding))
      (let ((rest (read-value 'iso-8859-1-string in :length (1- length))))
        (concatenate
         'string (string (code-char first-byte)) rest
)
)
)
)

  (:writer (out id)
    (write-value 'iso-8859-1-string out id :length length)
)
)

Если переопределить id3-frame так, чтобы тип его слота id был frame-id, а не iso-8859-1-string, условие будет сигнализировано, когда метод read-value класса id3-frame прочтёт нулевой байт вместо начала фрейма.

(define-tagged-binary-class id3-frame ()
  ((id (frame-id :length 3))
   (size u3)
)

  (:dispatch (find-frame-class id))
)

Теперь все, что нужно сделать read-frame - это обернуть вызов read-value в HANDLER-CASE, который обработает условие in-padding, просто вернув NIL.

(defun read-frame (in)
  (handler-case (read-value 'id3-frame in)
    (in-padding () nil))
)

Определив read-frame, вы можете прочитать ID3 тег версии 2.2 целиком, представляя фреймы экземплярами generic-frame. В секции "Какие фреймы вам на самом деле нужны?", вы проведёте несколько экспериментов в REPL, чтобы определить, какие классы фреймов вам нужно реализовать. Но сначала давайте добавим поддержку для тегов ID3 версии 2.3.

Поддержка нескольких версий ID3

На данный момент, id3-tag определён с помощью define-binary-class, но, если вы хотите поддерживать различные версии ID3, больше смысла в использовании define-tagged-binary-class, который диспетчеризует значение major-version. Как выясняется, всё версии ID3v2 имеют одну и ту же структуру вплоть до поля size. Итак, вы можете определить помеченный бинарный класс, как в следующем коде, который определяет базовую структуру и потом передаёт управление подходящему подклассу, специфичному для данной версии:

(define-tagged-binary-class id3-tag ()
  ((identifier     (iso-8859-1-string :length 3))
   (major-version  u1)
   (revision       u1)
   (flags          u1)
   (size           id3-tag-size)
)

  (:dispatch
   (ecase major-version
     (2 'id3v2.2-tag)
     (3 'id3v2.3-tag)
)
)
)

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

Так как новый класс id3-tag основан на том классе, который вы первоначально написали для представления тега версии 2.2, не удивительно, что новый класс id3v2.2-tag тривиален, наследуя большую часть слотов от нового класса id3-tag и добавляя один недостающий слот, frames. Так как теги версиий 2.2 и 2.3 используют различные форматы фреймов, вам придётся изменить тип id3-frames так, чтобы он параметризовался типом фрейма для чтения. Но сейчас предположим, что вы это сделаете, и добавим аргумент :frame-type к дескриптору типов id3-frames так:

(define-binary-class id3v2.2-tag (id3-tag)
  ((frames (id3-frames :tag-size size :frame-type 'id3v2.2-frame)))
)

Класс id3v2.3-tag немого более сложен из-за необязательных полей. Первые три из четырёх необязательных полей добавляются, когда установлен шестой бит в поле flags. Они представляют собой четырёхбайтовое целое, указывающее размер расширенного заголовка, два байта флагов и ещё одно четырёхбайтовое целое, указывающее, сколько байт заполнителя включено в тег13). Четвёртое необязательное поле добавляется, когда установлен пятнадцатый бит дополнительных флагов заголовка - четырёхбайтовая циклическая избыточностная проверка (CRC) оставшейся части тега.

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

(define-binary-type optional (type if)
  (:reader (in)
    (when if (read-value type in))
)

  (:writer (out value)
    (when if (write-value type out value))
)
)

Использование if как имени параметра кажется немного странным в этом коде, но оно делает дескрипторы необязательных типов волне читаемыми.

(define-binary-class id3v2.3-tag (id3-tag)
  ((extended-header-size (optional :type 'u4 :if (extended-p flags)))
   (extra-flags          (optional :type 'u2 :if (extended-p flags)))
   (padding-size         (optional :type 'u4 :if (extended-p flags)))
   (crc                  (optional :type 'u4 :if (crc-p flags extra-flags)))
   (frames               (id3-frames :tag-size size :frame-type 'id3v2.3-frame))
)
)

где extended-p и crc-p - вспомогательные функции, которые проверяют соответствующий бит флагов, переданных им. Чтобы определить, выставлен отдельный бит в целом числе или нет, можно использовать LOGBITP, ещё одну жонглирующую битами функцию. Она принимает индекс и целое и возвращает истину, если указанный бит установлен в числе.

(defun extended-p (flags) (logbitp 6 flags))

(defun crc-p (flags extra-flags)
  (and (extended-p flags) (logbitp 15 extra-flags))
)

Как и в классе тега версии 2.2, слот frames определяется с типом id3-frames, передавая имя типа фрейма как параметр. Вам, однако, придётся сделать незначительные изменения в id3-frames и read-frame для поддержки дополнительного параметра frame-type.

(define-binary-type id3-frames (tag-size frame-type)
  (:reader (in)
    (loop with to-read = tag-size
          while (plusp to-read)
          for frame = (read-frame frame-type in)
          while frame
          do (decf to-read (+ (frame-header-size frame) (size frame)))
          collect frame
          finally (loop repeat (1- to-read) do (read-byte in))
)
)

  (:writer (out frames)
    (loop with to-write = tag-size
          for frame in frames
          do (write-value frame-type out frame)
          (decf to-write (+ (frame-header-size frame) (size frame)))
          finally (loop repeat to-write do (write-byte 0 out))
)
)
)


(defun read-frame (frame-type in)
  (handler-case (read-value frame-type in)
    (in-padding () nil))
)

Изменения заключены в вызовах read-frame и write-value, где вам нужно передать аргумент frame-type, и в вычислении размера фрейма, где нужно использовать функцию frame-header-size, а не прописать значение 6, так как размер заголовка изменился между версиями 2.2 и 2.3. Так как различие в результате этой функции основано на классе фрейма, имеет смысл определить обобщённую функцию так:

(defgeneric frame-header-size (frame))

Вы определите необходимые методы для этой обобщённой функции в следующей секции, после того, как определите новые классы фреймов.

Versioned Frame Base Classes

Раньше вы определили один базовый класс для всех фреймов, но теперь у вас два класса, id3v2.2-frame и id3v2.3-frame. Класс id3v2.2-frame будет по сути таким же, как и первоначальный класс id3-frame.

(define-tagged-binary-class id3v2.2-frame ()
  ((id (frame-id :length 3))
   (size u3)
)

  (:dispatch (find-frame-class id))
)

id3v2.3-frame, с другой стороны, требует больших изменений. Идентификатор фрейма и поле размера были расширены в версии 2.3 с трёх до четырёх байт каждое, и были добавлены два байта с флагами. Дополнительно, фрейм, как и тег версии 2.3, может содержать необязательные поля, управляемые значениями трёх флагов фрейма14). Держа эти изменения в уме, вы можете определить базовый класс фрейма версии 2.3, вместе с несколькими вспомогательными функциями, например так:

(define-tagged-binary-class id3v2.3-frame ()
  ((id                (frame-id :length 4))
   (size              u4)
   (flags             u2)
   (decompressed-size (optional :type 'u4 :if (frame-compressed-p flags)))
   (encryption-scheme (optional :type 'u1 :if (frame-encrypted-p flags)))
   (grouping-identity (optional :type 'u1 :if (frame-grouped-p flags)))
)

  (:dispatch (find-frame-class id))
)


(defun frame-compressed-p (flags) (logbitp 7 flags))

(defun frame-encrypted-p (flags) (logbitp 6 flags))

(defun frame-grouped-p (flags) (logbitp 5 flags))

Определив эти два класса, вы можете реализовать методы обобщённой функции frame-header-size.

(defmethod frame-header-size ((frame id3v2.2-frame)) 6)

(defmethod frame-header-size ((frame id3v2.3-frame)) 10)

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

Versioned Concrete Frame Classes

При первоначальном определении класс generic-frame наследовал id3-frame. Но сейчас id3-frame заменён двумя специфичными для версий базовыми классами, id3v2.2-frame и id3v2.3-frame. Так что, вам надо определить две новые версии generic-frame, по каждой для своего базового класса. Один из способов определить эти классы таков:

(define-binary-class generic-frame-v2.2 (id3v2.2-frame)
  ((data (raw-bytes :size size)))
)


(define-binary-class generic-frame-v2.3 (id3v2.3-frame)
  ((data (raw-bytes :size size)))
)

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

Другой подход, тот, который вам на самом деле следует использовать - определить класс generic-frame как "примесь" (mixin): класс, который предполагается для использования как суперкласс с одним из специфичных для версии базовых классов для получения конкретного, специфичного для версии класса фрейма. В этом способе только один хитрый момент: generic-frame не расширяет любой из базовых классов фрейма, так что вы не сможете обращаться к слоту size в определении. Вместо этого, вы должны использовать функцию current-binary-object, которая обсуждалась в конце предыдущей части, для доступа к объекту, в процессе чтения или записи которого находитесь, и передать его в size. И вам нужно учесть разницу в числе байт полного размера фрейма, которые будут отложены, если любое из необязательных полей будет включено во фрейм. Так что, вы должны определить обобщённую функцию data-bytes и методамы, которые делают правильные действия и для фреймов версии 2.2, и для версии 2.3.

(define-binary-class generic-frame ()
  ((data (raw-bytes :size (data-bytes (current-binary-object)))))
)


(defgeneric data-bytes (frame))

(defmethod data-bytes ((frame id3v2.2-frame))
  (size frame)
)


(defmethod data-bytes ((frame id3v2.3-frame))
  (let ((flags (flags frame)))
    (- (size frame)
       (if (frame-compressed-p flags) 4 0)
       (if (frame-encrypted-p flags) 1 0)
       (if (frame-grouped-p flags) 1 0)
)
)
)

После этого вы можете определить конкретные классы, которые расширяют один из специфичных для версий классов и класс generic-frame для определения специфичного для версии класса фрейма.

(define-binary-class generic-frame-v2.2 (id3v2.2-frame generic-frame) ())

(define-binary-class generic-frame-v2.3 (id3v2.3-frame generic-frame) ())

Определив эти классы, вы можете переопределить функцию find-frame-class так, чтобы она возвращала правильный класс для версии, основываясь на длине идентификатора.

(defun find-frame-class (id)
  (ecase (length id)
    (3 'generic-frame-v2.2)
    (4 'generic-frame-v2.3)
)
)

Какие фреймы на самом деле нужны?

Имея возможность читать теги и версии 2.2, и версии 2.3, используя обобщённые фреймы, вы готовы начать реализацию классов для представления специфичных фреймов, которые вам нужны. Однако, перед тем как нырнуть в это, вам следует набрать воздуха и выяснить, какие фреймы вам на самом деле нужны, так как я уже упомянул ранее, что спецификация ID3 содержит множество фреймов, которые почти никогда не используются. Конечно, то, какие фреймы вас заботят, зависит от того, какие приложения вы хотите написать. Если вы более заинтересованы в извлечении информации из существующих ID3 тегов, тогда вам надо реализовать только классы, представляющие информацию, до которой вам есть дело. С другой стороны, если вы хотите написать редактор тегов ID3, вам может понадобится поддержка всех фреймов.

Чем угадывать, какие фреймы будут наиболее полезными, вы можете использовать код, который вы уже написали, чтобы немного поковыряться в REPL и узнать, какие фреймы действительно используютcя в ваших MP3. Для начала, вам понадобится экземпляр id3-tag, который вы можете получить с помощью функции read-id3.

ID3V2> (read-id3 "/usr2/mp3/Kitka/Wintersongs/02 Byla Cesta.mp3")
#<ID3V2.2-TAG @ #x727b2912>

Так нам захочется немного поиграть с этим объектом, вам нужно сохранить его в переменную.

ID3V2> (defparameter *id3* (read-id3 "/usr2/mp3/Kitka/Wintersongs/02 Byla Cesta.mp3"))
*ID3*

Теперь вы можете узнать, например, сколько в нем фреймов:

ID3V2> (length (frames *id3*))
11

Не слишком много – давайте посмотрим, что они из себя представляют.

ID3V2> (frames *id3*)
(#<GENERIC-FRAME-V2.2 @ #x72dabdda> #<GENERIC-FRAME-V2.2 @ #x72dabec2>
 #<GENERIC-FRAME-V2.2 @ #x72dabfa2> #<GENERIC-FRAME-V2.2 @ #x72dac08a>
 #<GENERIC-FRAME-V2.2 @ #x72dac16a> #<GENERIC-FRAME-V2.2 @ #x72dac24a>
 #<GENERIC-FRAME-V2.2 @ #x72dac32a> #<GENERIC-FRAME-V2.2 @ #x72dac40a>
 #<GENERIC-FRAME-V2.2 @ #x72dac4f2> #<GENERIC-FRAME-V2.2 @ #x72dac632>
 #<GENERIC-FRAME-V2.2 @ #x72dac7b2>
)

Ладно, это не очень информативно. То, что вы действительно хотите знать – это какие типы фреймов там содержатся. Другими словами, вам нужны идентификаторы этих фреймов, которые вы можете получить простым MAPCAR, например так:

ID3V2> (mapcar #'id (frames *id3*))
("TT2" "TP1" "TAL" "TRK" "TPA" "TYE" "TCO" "TEN" "COM" "COM" "COM")

Если вы посмотрите эти идентификаторы в спецификации ID3v2.2, вы обнаружите, что все фреймы с идентификаторами, начинающимися с T являются текстовой информацией и имеют похожую структуру. А COM – это идентификатор для фреймов с комментариями, структура которых схожа со структурой текстовых. В частности, фреймы с текстовой информацией здесь, оказывается, представляют название песни, исполнителя, альбом, дорожку, часть набора, год, жанр, и кодировавшую программу.

Конечно, это только один MP3 файл. Возможно, в других файлах используются другие фреймы. Это достаточно просто определить. Для начала, определим функцию, которая комбинирует выражение MAPCAR с вызовом read-id3 и заворачивает всё это в DELETE-DUPLICATES, чтобы поддерживать чистоту. Вам придётся использовать #'string= как аргумент :test у DELETE-DUPLICATES, чтобы указать, что два элемента считаются одинаковыми, если это одна и та же строка.

(defun frame-types (file)
  (delete-duplicates (mapcar #'id (frames (read-id3 file))) :test #'string=)
)

Это должно давать тот же результат для такого же имени файла, за исключением того, что каждый идентификатор встречается один раз.

ID3V2> (frame-types "/usr2/mp3/Kitka/Wintersongs/02 Byla Cesta.mp3")
("TT2" "TP1" "TAL" "TRK" "TPA" "TYE" "TCO" "TEN" "COM")

Теперь вы можете использовать функцию walk-directory из главы 15 для нахождения всех MP3 файлов в директории и комбинирования результатов вызова frame-types на каждом файле. Вспомните, что NUNION - это деструктивная версия функции UNION, но, так как frame-types делает новый список для каждого файла, она безопасна.

(defun frame-types-in-dir (dir)
  (let ((ids ()))
    (flet ((collect (file)
             (setf ids (nunion ids (frame-types file) :test #'string=))
)
)

      (walk-directory dir #'collect :test #'mp3-p)
)

    ids
)
)

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

ID3V2> (frame-types-in-dir "/usr2/mp3/")
("TCON" "COMM" "TRCK" "TIT2" "TPE1" "TALB" "TCP" "TT2" "TP1" "TCM"
 "TAL" "TRK" "TPA" "TYE" "TCO" "TEN" "COM"
)

Четырёхбуквенные идентификаторы версии 2.3 - эквиваленты идентификаторов версии 2.2, которые я обсуждал ранее. Так как информация, хранимая в этих фреймах в точности та, которая понадобится вам в главе 27, имеет смысл реализовать классы только для тех фреймов, которые на самом деле используются, а именно, фреймов текстовой информации и комментариев, что вы и сделаете в следующих двух секциях. Если позже вы решите, что вы хотите поддерживать другие типы фреймов, то это больше вопрос преобразования спецификаций ID3 в подходящие определения бинарных классов.

Фреймы текстовой информации

Все фреймы с текстовой информацией состоят из двух полей: одного байта, указывающего, какая кодировка строк используется во фрейме, и строки, закодированной в оставшихся байтах строки. Если кодирующий байт равен нулю, строка закодирована в ISO 8859-1; если он равен единице, строка в кодировке UCS-2.

Вы уже определили бинарные типы для представления двух типов строк – двух типов кодировок, каждую с двумя различными методами определения границ строки. Однако, define-binary-class не предоставляет прямую возможность для определить тип значения для чтения, основываясь на других значениях в объекте. Вместо этого, вы можете определить бинарный тип, которому вы передадите значение байта кодировки, и после этого он будет читать или писать подходящий вид строки.

Когда вы будете определять этот тип, вы можете определить его так, чтобы он принимал два параметра, :length и :terminator, и выбирал правильный тип строки, основанный на том, какой аргумент подан. Для реализации этого нового типа, вы должны для начала определить некоторые вспомогательные функции. Первая из двух возвращает имя подходящего строкового типа, основываясь на байте кодировки.

(defun non-terminated-type (encoding)
  (ecase encoding
    (0 'iso-8859-1-string)
    (1 'ucs-2-string)
)
)


(defun terminated-type (encoding)
  (ecase encoding
    (0 'iso-8859-1-terminated-string)
    (1 'ucs-2-terminated-string)
)
)

Затем string-args использует этот байт кодировки, длину и terminator для определения нескольких аргументов для передачи их read-value и write-value с помощью :reader и :writer в id3-encoded-string. Один из аргументов string-args – либо length, либо terminator – всегда дожен быть NIL.

(defun string-args (encoding length terminator)
  (cond
    (length
     (values (non-terminated-type encoding) :length length)
)

    (terminator
     (values (terminated-type encoding) :terminator terminator)
)
)
)

С этими помощниками, определить id3-encoded-string просто. Одна деталь, которую нужно отметить, это то, что ключ – или :length, или :terminator – используемый в вызове read-value и write-value, является просто ещё одной частью данных, возвращённых string-arts. Даже если ключевые символы в списке аргументов практически всегда вписаны в текст программы, они не обязаны быть вписаны туда всегда.

(define-binary-type id3-encoded-string (encoding length terminator)
  (:reader (in)
    (multiple-value-bind (type keyword arg)
        (string-args encoding length terminator)
      (read-value type in keyword arg)
)
)

  (:writer (out string)
    (multiple-value-bind (type keyword arg)
        (string-args encoding length terminator)
      (write-value type out string keyword arg)
)
)
)

Теперь можно определить примесный класс text-info, точно так же, как был определён generic-frame ранее.

(define-binary-class text-info-frame ()
  ((encoding u1)
   (information (id3-encoded-string :encoding encoding :length (bytes-left 1)))
)
)

Как и при определении generic-frame, вам нужно получить доступ к размеру фрейма, в данном случае, для того, чтобы вычислить аргумент :length для передачи id3-encoded-string. Так как вам понадобится похожее вычисление в следующем определяемом вами классе, вы можете пойти дальше и определить вспомогательную функцию bytes-left, которая использует current-binary-object для получения размера фрейма.

(defun bytes-left (bytes-read)
  (- (size (current-binary-object)) bytes-read)
)

Теперь, вы можете определить два индивидуальных для каждой версии конкретных класса с примесью дублируемого кода, так же, как вы сделали это с примесью generic-frame.

(define-binary-class text-info-frame-v2.2 (id3v2.2-frame text-info-frame) ())

(define-binary-class text-info-frame-v2.3 (id3v2.3-frame text-info-frame) ())

Чтобы запрячь эти классы за работу, вам нужно подравить find-frame-class, чтобы он возвращал правильное имя класса, когда ID указывает, что фрейм является текстовым, а именно, всегда, когда ID начинается с T и не является TXX или TXXX.

(defun find-frame-class (name)
  (cond
    ((and (char= (char name 0) #\T)
          (not (member name '("TXX" "TXXX") :test #'string=))
)

     (ecase (length name)
       (3 'text-info-frame-v2.2)
       (4 'text-info-frame-v2.3)
)
)

    (t
     (ecase (length name)
       (3 'generic-frame-v2.2)
       (4 'generic-frame-v2.3)
)
)
)
)

Фреймы комментариев

Другим часто используемым фреймом является фрейм с комментариями, который похож на фрейм текстовой информации с несколькими дополнительными полями. Как и фрейм текстовой информации, он начинается с единственного байта, означающего кодировку строки, используемую во фрейме. За этим байтом следует трёхбуквенная строка ISO 8859-1 (вне зависимости от значения байта кодировки), которая указывает, каков язык комментария, используя код ISO-639-2, например "eng" для английского или "jpn" для японского. За ним следует две строки, закодированные, как указано в первом байте. Первая завершаемая нулём строка содержит описание комментария в кодировке, указанной первым байтом. Вторая строка, занимающая остаток фрейма – сам комментарий.

(define-binary-class comment-frame ()
  ((encoding u1)
   (language (iso-8859-1-string :length 3))
   (description (id3-encoded-string :encoding encoding :terminator +null+))
   (text (id3-encoded-string
          :encoding encoding
          :length (bytes-left
                   (+ 1 ; encoding
                     3 ; language
                     (encoded-string-length description encoding t)
)
)
)
)
)
)

Как и в определении примеси text-inf, вы можете использовать bytes-left для вычисления размера последней строки. Однако, так так поле описания – строка переменной длины, число байт, прочитанных до начала текста не является постоянным. Чтобы запутать всё ещё больше, число байт, используемых для кодирования описания, зависит от кодировки. Итак, вам нужно определить вспомогательную функцию, которая возвращает число байт, использованных для кодирования строки, принимающая строку, код кодировки, и логический индикатор того, завершается строка дополнительным знаком или нет.

(defun encoded-string-length (string encoding terminated)
  (let ((characters (+ (length string) (if terminated 1 0))))
    (* characters (ecase encoding (0 1) (1 2)))
)
)

И, как и раньше, вы можете определить индивидуальные для каждой версии классы фреймов и включить их в find-frame-class.

(define-binary-class comment-frame-v2.2 (id3v2.2-frame comment-frame) ())

(define-binary-class comment-frame-v2.3 (id3v2.3-frame comment-frame) ())

(defun find-frame-class (name)
  (cond
    ((and (char= (char name 0) #\T)
          (not (member name '("TXX" "TXXX") :test #'string=))
)

     (ecase (length name)
       (3 'text-info-frame-v2.2)
       (4 'text-info-frame-v2.3)
)
)

    ((string= name "COM")  'comment-frame-v2.2)
    ((string= name "COMM") 'comment-frame-v2.3)
    (t
     (ecase (length name)
       (3 'generic-frame-v2.2)
       (4 'generic-frame-v2.3)
)
)
)
)

Извлечение информации из тега ID3

Теперь у вас есть базовая возможность для чтения и записи тегов ID3, и есть много путей, по который можно развивать ваш код. Если вы хотите разработать полный редактор ID3 тегов, вам нужно реализовать индивидуальные классы для всех типов фреймов. Вам также необходимо будет определить методы для манипулирования объектами тегов и фреймов согласованным образом (например, если вы измените значение строки в text-info-frame, вам вероятнее всего придётся поменять и размер); при нынешнем состоянии кода, нельзя быть уверенным в том, что это произойдёт. 15).

Или, если вам нужна только определённая часть информации о MP3 файле из его ID3 тега – например, как вам, когда вы будете разрабатывать потоковый сервер MP3 в частях 27, 28 и 29 – то нужно написать функции, которые находят подходящие фреймы и извлекают из них желаемую информацию.

Наконец, чтобы сделать ваш код готовым к внедрению в реальные приложения, вам придётся покорпеть над спецификациями ID3 и иметь дело с деталями, которые я опустил ради экономии места. В частности, некоторые флаги как в теге, так и во фрейме могут влиять на способ чтения содержимого тега; если вы не напишете некоторый код, который выполняет правильные действия, когда установлены эти флаги, могут существовать ID3 теги, которые ваш код не будет способен прочитать правильно. Но код из этой главы должен быть способен разобрать почти все MP3 которые вы можете встретить в действительности.

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

Чтобы получить, скажем, имя песни для MP3, из которого извлечен id3-tag, вам надо найти ID3 фрейм со специальным идентификатором и потом извлечь поле информации. А некоторые части информации, такие как жанр, могут потребовать дальнейшего декодирования. К счастью, все фреймы, содержащие информацию, до которой вам есть дело- это фреймы текстовой информации, так что извлечение конкретного кусочка информации сводится к использованию правильного идентификатора для поиска подходящего фрейма. Конечно, авторы ID3 решили сменить все идентификаторы при переходе от ID3v2.2 к ID3v2.3, так что вам придётся принять это в расчёт.

Ничего слишком сложного – вам просто надо разыскать правильный путь для получения различных частей информации. Это прекрасный кусок кода для интерактивной разработки, очень похожей на тот способ, которым вы выяснили, какие классы фреймов вам нужно реализовать. Для начала, вам нужен объект класса id3-tag для экспериментов. Предполагая, что где-то рядом с вами лежит какой-нибудь MP3 файл, вы можете воспользоваться read-id3 вот так:

ID3V2> (defparameter *id3* (read-id3 "Kitka/Wintersongs/02 Byla Cesta.mp3"))
*ID3*
ID3V2> *id3*
#<ID3V2.2-TAG @ #x73d04c1a>

Замените Kitka/Wintersongs/02 Byla Cesta.mp3 на имя вашего MP3 файла. Как только у вас появится объект id3-tag, вы сможете начать копаться в нём. Например, вы можете проверить список объектов фреймов с функцией frames.

ID3V2> (frames *id3*)
(#<TEXT-INFO-FRAME-V2.2 @ #x73d04cca>
 #<TEXT-INFO-FRAME-V2.2 @ #x73d04dba>
 #<TEXT-INFO-FRAME-V2.2 @ #x73d04ea2>
 #<TEXT-INFO-FRAME-V2.2 @ #x73d04f9a>
 #<TEXT-INFO-FRAME-V2.2 @ #x73d05082>
 #<TEXT-INFO-FRAME-V2.2 @ #x73d0516a>
 #<TEXT-INFO-FRAME-V2.2 @ #x73d05252>
 #<TEXT-INFO-FRAME-V2.2 @ #x73d0533a>
 #<COMMENT-FRAME-V2.2 @ #x73d0543a>
 #<COMMENT-FRAME-V2.2 @ #x73d05612>
 #<COMMENT-FRAME-V2.2 @ #x73d0586a>
)

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

ID3V2> (mapcar #'id (frames *id3*))
("TT2" "TP1" "TAL" "TRK" "TPA" "TYE" "TCO" "TEN" "COM" "COM" "COM")

Ага, вот он, первый фрейм. Однако, нет гарантии, что он всегда будет первым, так возможно вам следует искать его не по позиции, а по идентификатору. Это тоже просто, используйте функцию FIND.

ID3V2> (find "TT2" (frames *id3*) :test #'string= :key #'id)
#<TEXT-INFO-FRAME-V2.2 @ #x73d04cca>

Теперь, чтобы получить саму информацию из фрейма, сделайте следующее:

ID3V2> (information (find "TT2" (frames *id3*) :test #'string= :key #'id))
"Byla Cesta^@"

Опаньки. Этот ^@ - то, как емакс печатает нулевой символ. В ходе манёвра, напоминающего клудж, который превратил спецификацию ID3v1 в ID3v1.1, информационная ячейка фрейма текстовой информации, которая официально не является обрываемой нулём строкой, может содержать нуль, и предпологается, что считыватели ID3 будут игнорировать любой знак после нуля. Так что, вам нужна функция, которая принимает строку и возвращает её содержимое, вплоть до первого нулевого знака, если он есть. Используя константу +null+ из библиотеки бинарный данных, сделать это достаточно просто.

(defun upto-null (string)
  (subseq string 0 (position +null+ string))
)

Now you can get just the title.

ID3V2> (upto-null (information (find "TT2" (frames *id3*) :test #'string= :key #'id)))
"Byla Cesta"

Вы могли бы просто обернуть этот код в функцию с именем song, принимающую экземпляр id3-tag как аргумент, и дело с концом. Однако, единственная разница между этим кодом и кодом, который бы вы использовали для извлечения других кусочков информации, которые вам нужны (таких как название альбома, исполнитель и жанр), в идентификаторе. Так что, лучше немного разделить этот код. Для начала, вы можете написать функцию, которая просто находит фрейм для данных экземпляра id3-tag и идентификатора, вроде этой:

(defun find-frame (id3 id)
  (find id (frames id3) :test #'string= :key #'id)
)

ID3V2> (find-frame *id3* "TT2")
#<TEXT-INFO-FRAME-V2.2 @ #x73d04cca>

Тогда другой кусочек кода, часть, извлекающая информацию из text-info-frame, может отойти в другую функцию.

(defun get-text-info (id3 id)
  (let ((frame (find-frame id3 id)))
    (when frame (upto-null (information frame)))
)
)

ID3V2> (get-text-info *id3* "TT2")
"Byla Cesta"

Теперь определение song – просто дело передачи правильного идентификатора.

(defun song (id3) (get-text-info id3 "TT2"))

ID3V2> (song *id3*)
"Byla Cesta"

Однако, это определение song работает только с тегами версии 2.2, так как идентификатор поменялся с "TT2" в версии 2.2 на "TIT2" в версии 2.3. И все остальные теги поменялись тоже. Так как пользователь этой библиотеки не не должен обязательно знать о различных версиях формата ID3 для того, чтобы сделать такую простую вещь, как получение названия песни, вам наверное лучше иметь дело с этими деталями за него. Простой способ состоит в таком изменении find-frame, что она не просто принимает один идентификатор, а список идентификаторов, вроде этой:

(defun find-frame (id3 ids)
  (find-if #'(lambda (x) (find (id x) ids :test #'string=)) (frames id3))
)

Теперь слегка поменяем get-text-info, чтобы она могла принимать один идентификатор и более, используя параметр &rest.

(defun get-text-info (id3 &rest ids)
  (let ((frame (find-frame id3 ids)))
    (when frame (upto-null (information frame)))
)
)

Теперь изменение, позволяющее song поддерживать и теги версии 2.2, и версии 2.3 – просто вопрос добавления идентификатора из версии 2.3.

(defun song (id3) (get-text-info id3 "TT2" "TIT2"))

После этого вам просто нужно найти подходящие идентификаторы версий 2.2 и 2.3 для каждого поля, к которому вы хотите предоставить функцию доступа. Вот те функции, которые вам понадобятся в главе 27:

(defun album (id3) (get-text-info id3 "TAL" "TALB"))

(defun artist (id3) (get-text-info id3 "TP1" "TPE1"))

(defun track (id3) (get-text-info id3 "TRK" "TRCK"))

(defun year (id3) (get-text-info id3 "TYE" "TYER" "TDRC"))

(defun genre (id3) (get-text-info id3 "TCO" "TCON"))

Последняя трудность в том, что жанр хранится в фреймах TCO и TCON нечитаемым человеком способом. Вспомните, что в ID3v1, жанры хранились как один байт, который кодировал определённых жанр из фиксированного списка. К несчастью, эти коды продолжают жить и в ID3v2: если текст жанрового фрейма – число в круглых скобках, это число обязано быть интерпретировано как код жанра из ID3v1. Но, опять, пользователи этой библиотеки вероятно не будут заботиться об этой древней истории. Так что вам следует предоставить им функцию, которая автоматически перекодирует жанр. Следующая функция использует функцию genre, определённую лишь для того, чтобы извлекать сам жанр как текст, затем проверять, начинается ли он с левой круглой скобки, и если это так, то раскодировать код жанра версии 1 при помощи функции, которую мы определим через пару мнгновений.

(defun translated-genre (id3)
  (let ((genre (genre id3)))
    (if (and genre (char= #\( (char genre 0)))
      (translate-v1-genre genre)
      genre
)
)
)

Так как код жанра версии 1 в сущности – просто индекс в массиве стандартных имён, самый простой способ реализовать translate-v1-genre – извлечь число из строки жанра и воспользоваться им как индексом в настоящем массиве.

(defun translate-v1-genre (genre)
  (aref *id3-v1-genres* (parse-integer genre :start 1 :junk-allowed t))
)

Теперь, всё, что вам нужно – это определить массив имён. Следующий массив имён включает 80 официальных жанров версии 1 плюс жанры, созданные авторами Winamp:

(defparameter *id3-v1-genres*
  #(
    ;; These are the official ID3v1 genres.
   "Blues" "Classic Rock" "Country" "Dance" "Disco" "Funk" "Grunge"
    "Hip-Hop" "Jazz" "Metal" "New Age" "Oldies" "Other" "Pop" "R&B" "Rap"
    "Reggae" "Rock" "Techno" "Industrial" "Alternative" "Ska"
    "Death Metal" "Pranks" "Soundtrack" "Euro-Techno" "Ambient"
    "Trip-Hop" "Vocal" "Jazz+Funk" "Fusion" "Trance" "Classical"
    "Instrumental" "Acid" "House" "Game" "Sound Clip" "Gospel" "Noise"
    "AlternRock" "Bass" "Soul" "Punk" "Space" "Meditative"
    "Instrumental Pop" "Instrumental Rock" "Ethnic" "Gothic" "Darkwave"
    "Techno-Industrial" "Electronic" "Pop-Folk" "Eurodance" "Dream"
    "Southern Rock" "Comedy" "Cult" "Gangsta" "Top 40" "Christian Rap"
    "Pop/Funk" "Jungle" "Native American" "Cabaret" "New Wave"
    "Psychadelic" "Rave" "Showtunes" "Trailer" "Lo-Fi" "Tribal"
    "Acid Punk" "Acid Jazz" "Polka" "Retro" "Musical" "Rock & Roll"
    "Hard Rock"

    ;; These were made up by the authors of Winamp but backported into
   ;; the ID3 spec.
   "Folk" "Folk-Rock" "National Folk" "Swing" "Fast Fusion"
    "Bebob" "Latin" "Revival" "Celtic" "Bluegrass" "Avantgarde"
    "Gothic Rock" "Progressive Rock" "Psychedelic Rock" "Symphonic Rock"
    "Slow Rock" "Big Band" "Chorus" "Easy Listening" "Acoustic" "Humour"
    "Speech" "Chanson" "Opera" "Chamber Music" "Sonata" "Symphony"
    "Booty Bass" "Primus" "Porn Groove" "Satire" "Slow Jam" "Club"
    "Tango" "Samba" "Folklore" "Ballad" "Power Ballad" "Rhythmic Soul"
    "Freestyle" "Duet" "Punk Rock" "Drum Solo" "A capella" "Euro-House"
    "Dance Hall"

    ;; These were also invented by the Winamp folks but ignored by the
   ;; ID3 authors.
   "Goa" "Drum & Bass" "Club-House" "Hardcore" "Terror" "Indie"
    "BritPop" "Negerpunk" "Polsk Punk" "Beat" "Christian Gangsta Rap"
    "Heavy Metal" "Black Metal" "Crossover" "Contemporary Christian"
    "Christian Rock" "Merengue" "Salsa" "Thrash Metal" "Anime" "Jpop"
    "Synthpop"
)
)

Ещё раз, возможно вы чувствуете, что написали в этой главе тонну кода. Но если вы положите его в один файл или если скачаете его версию с сайта этой книги, вы увидите, что строк там не настолько много – большая часть проблем с написанием этой библиотеки происходят от необходимости понять сложности самого формата ID3. В любом случае, теперь у вас есть существенная часть того, что вы превратите в потоковый MP3 сервер в главах 27, 28 и 29. Другая крупная часть инфраструктуры, которая вам понадобится – способ написания Web-программ со стороны сервера, является темой следующей главы.

1)MPEG Audio Layer 3
2)Moving Picture Experts Group
3)International Organization for Standardization
4)International Electrotechnical Commission
5)Eric Kemp
6)Michael Mutschler
7)Выдирание(ripping) - процесс, при помощи которого аудио CD преобразуется в MP3 файл на вашем жёстком диске. В наши дни большинство таких программ ещё и автоматически получают информацию о песнях, которые они выдирают, из онлайновых баз данных, таких так Gracenote (n�e the Compact Disc Database [CDDB]) или FreeDB, которую они затем встраивают в MP3 файлы и ID3 теги.
8)Martin Nilsson
9)Почти все файловые системы предоставляют возможность перезаписывать существующие байты файла, но немноие – если вообще такие есть – дают возможность добавлять или удалять данные в начало или середину файла без необходимости перезаписать остаток файла. Так как теги ID3 обычно хранятся в начале файла, чтобы перезаписать тег ID3, не трогая оставшуюся часть файла, вы должны заменить старый тег новым точно такой же длины. Записывая теги ID3 с некоторым количеством заполнения, вы получаете лучшие шансы сделать так – если в новом теге будет больше данных, чем первоначальный, вы используете меньше заполнителя, а если короче – больше.
10)Данные фреймов, идущих за заголовком ID3, также потенциально могут содержать эту незаконную последовательность. Это предотвращается использованием специальной схемы, которая включается при помощи одного из флагов в заголовке тега. Код из этой главы не принимает в расчёт возможность установки этого флага, он редко используется на практике.
11)В ID3v2.4 UCS-2 заменили на почти идентичную ей UTF-16 и добавили дополнительные кодировки UTF-16BE и UTF-8
12)Версия 2.4 формата ID3 также поддерживает размещение похожего окончания в конце тега, что позволяет проще находить тег, присоединённый к концу файла.
13)Если в теге есть расширенный заголовок, вы можете использовать это значение, чтобы определить, где должны кончаться данные. Однако, если расширенный заголовок не используется, вам всё равно придётся использовать старый алгоритм, так что не стоит добавлять код, делающий это по-другому.
14)Эти флаги не только контролируют, включены ли необязательные поля, но и могут влиять на оставшуюся часть тега. В частности, если установлен седьмой бит флага, данные шифруются. На практике эти возможности применяются редко, если вообще где-нибудь применяются, так что пока вы можете просто проигнорировать их. Но к этой задаче вам пришлось бы обратиться, чтобы качество вашего кода соответствовало промышленным стандартам. Одним простым половинчатым решением было бы поменять find-frame-class так, чтобы он принимал второй аргумент, и передавать ему флаги; если фрейм зашифрован, вы могли бы создать экземпляр обобщённого фрейма и положить в него данные фрейма.
15)Гарантия таких согласований между полями – отличное применение для методов :after обобщённой функции доступа. Например, вы могли бы определить этот метод :after, чтобы держать размер синхронизированными со строкой информации:
(defmethod (setf information) :after (value (frame text-info-frame))
  (declare (ignore value))
  (with-slots (encoding size information) frame
    (setf size (encoded-string-length information encoding nil))
)
)

Предыдущая Оглавление Следующая
@2009-2013 lisper.ru