diff --git a/lib/chromaprint.ex b/lib/chromaprint.ex new file mode 100644 index 0000000..6316552 --- /dev/null +++ b/lib/chromaprint.ex @@ -0,0 +1,166 @@ +defmodule Chromaprint do + @moduledoc """ + Elixir bindings for the [Chromaprint](https://github.com/acoustid/chromaprint) + audio fingerprinting library. + + Two ways to use it: + + * **Streaming** - `new/1` → `start/2` → `feed/2` (any number of times) → + `finish/1` → `fingerprint/1` (or `raw_fingerprint/1`, `hash/1`). + * **One-shot** - `compute/2` takes the entire 16-bit PCM buffer and returns + `{:ok, fingerprint_string}`. + + Audio input is **little-endian signed 16-bit PCM**, interleaved across + channels. + """ + + alias Chromaprint.NIF + + @algorithms %{test1: 0, test2: 1, test3: 2, test4: 3, test5: 4} + @algorithm_codes Map.new(@algorithms, fn {k, v} -> {v, k} end) + @default_algorithm :test2 + + defmodule Context do + @moduledoc """ + Opaque handle to a Chromaprint fingerprinting session. + """ + @enforce_keys [:ref, :algorithm] + defstruct [:ref, :algorithm] + + @type t :: %__MODULE__{ref: reference(), algorithm: atom()} + end + + @type algorithm :: :test1 | :test2 | :test3 | :test4 | :test5 + @type raw_fingerprint :: [non_neg_integer()] + + @doc "Names of the supported algorithms." + @spec algorithms() :: [algorithm()] + def algorithms, do: Map.keys(@algorithms) + + @doc "The version string of the linked chromaprint library." + @spec version() :: String.t() + def version, do: NIF.version() + + @doc """ + Create a new fingerprinting context. + + ## Options + * `:algorithm` - one of `#{inspect(Map.keys(@algorithms))}`. Defaults to + `#{inspect(@default_algorithm)}` (chromaprint's `DEFAULT`). + """ + @spec new(keyword()) :: {:ok, Context.t()} | {:error, term()} + def new(opts \\ []) do + algorithm = Keyword.get(opts, :algorithm, @default_algorithm) + + with {:ok, code} <- algorithm_to_code(algorithm), + {:ok, ref} <- NIF.new_context(code) do + {:ok, %Context{ref: ref, algorithm: algorithm}} + end + end + + @doc """ + Start a stream. Must be called before any `feed/2`. + + ## Options + * `:sample_rate` - required, e.g. `44_100`. + * `:channels` - required, `1` or `2`. + """ + @spec start(Context.t(), keyword()) :: :ok | {:error, term()} + def start(%Context{ref: ref}, opts) do + sample_rate = Keyword.fetch!(opts, :sample_rate) + channels = Keyword.fetch!(opts, :channels) + NIF.start(ref, sample_rate, channels) + end + + @doc """ + Feed PCM samples. `samples` is a binary (or iolist) of interleaved, + little-endian signed 16-bit integers. + """ + @spec feed(Context.t(), iodata()) :: :ok | {:error, term()} + def feed(%Context{ref: ref}, samples) do + bin = IO.iodata_to_binary(samples) + NIF.feed(ref, bin) + end + + @doc "Signal end of input. Must precede `fingerprint/1` / `raw_fingerprint/1`." + @spec finish(Context.t()) :: :ok | {:error, term()} + def finish(%Context{ref: ref}), do: NIF.finish(ref) + + @doc "Get the compressed base64-style fingerprint string." + @spec fingerprint(Context.t()) :: {:ok, String.t()} | {:error, term()} + def fingerprint(%Context{ref: ref}), do: NIF.get_fingerprint(ref) + + @doc "Get the raw fingerprint as a list of unsigned 32-bit integers." + @spec raw_fingerprint(Context.t()) :: {:ok, raw_fingerprint()} | {:error, term()} + def raw_fingerprint(%Context{ref: ref}), do: NIF.get_raw_fingerprint(ref) + + @doc "Get a compact 32-bit hash of the fingerprint." + @spec hash(Context.t()) :: {:ok, non_neg_integer()} | {:error, term()} + def hash(%Context{ref: ref}), do: NIF.get_fingerprint_hash(ref) + + @doc """ + Encode a raw fingerprint to its compressed form. + + ## Options + * `:algorithm` - required. + * `:base64` - defaults to `true`. When `false`, returns the raw byte form. + """ + @spec encode(raw_fingerprint(), keyword()) :: {:ok, binary()} | {:error, term()} + def encode(raw, opts) when is_list(raw) do + algorithm = Keyword.fetch!(opts, :algorithm) + base64? = Keyword.get(opts, :base64, true) + + with {:ok, code} <- algorithm_to_code(algorithm) do + NIF.encode_fingerprint(raw, code, base64?) + end + end + + @doc """ + Decode a compressed fingerprint. + + ## Options + * `:base64` - defaults to `true`. + + Returns `{:ok, %{algorithm: atom(), raw: [non_neg_integer()]}}`. + """ + @spec decode(binary(), keyword()) :: + {:ok, %{algorithm: algorithm() | non_neg_integer(), raw: raw_fingerprint()}} + | {:error, term()} + def decode(encoded, opts \\ []) when is_binary(encoded) do + base64? = Keyword.get(opts, :base64, true) + + case NIF.decode_fingerprint(encoded, base64?) do + {:ok, {algo_code, raw}} -> + {:ok, %{algorithm: code_to_algorithm(algo_code), raw: raw}} + + other -> + other + end + end + + @doc """ + One-shot fingerprint of an entire PCM buffer. + + ## Options + * `:sample_rate` - required. + * `:channels` - required. + * `:algorithm` - defaults to `:test2`. + """ + @spec compute(iodata(), keyword()) :: {:ok, String.t()} | {:error, term()} + def compute(pcm, opts) do + with {:ok, ctx} <- new(opts), + :ok <- start(ctx, opts), + :ok <- feed(ctx, pcm), + :ok <- finish(ctx) do + fingerprint(ctx) + end + end + + defp algorithm_to_code(algo) when is_map_key(@algorithms, algo), + do: {:ok, Map.fetch!(@algorithms, algo)} + + defp algorithm_to_code(code) when is_integer(code), do: {:ok, code} + defp algorithm_to_code(other), do: {:error, {:unknown_algorithm, other}} + + defp code_to_algorithm(code), do: Map.get(@algorithm_codes, code, code) +end diff --git a/lib/chromaprint/nif.ex b/lib/chromaprint/nif.ex new file mode 100644 index 0000000..28d3979 --- /dev/null +++ b/lib/chromaprint/nif.ex @@ -0,0 +1,21 @@ +defmodule Chromaprint.NIF do + @moduledoc false + + @on_load :load_nif + + def load_nif do + path = :filename.join(:code.priv_dir(:chromaprint), ~c"chromaprint_nif") + :erlang.load_nif(path, 0) + end + + def new_context(_algorithm), do: :erlang.nif_error(:nif_not_loaded) + def start(_ref, _sample_rate, _channels), do: :erlang.nif_error(:nif_not_loaded) + def feed(_ref, _samples), do: :erlang.nif_error(:nif_not_loaded) + def finish(_ref), do: :erlang.nif_error(:nif_not_loaded) + def get_fingerprint(_ref), do: :erlang.nif_error(:nif_not_loaded) + def get_raw_fingerprint(_ref), do: :erlang.nif_error(:nif_not_loaded) + def get_fingerprint_hash(_ref), do: :erlang.nif_error(:nif_not_loaded) + def encode_fingerprint(_raw, _algorithm, _base64), do: :erlang.nif_error(:nif_not_loaded) + def decode_fingerprint(_encoded, _base64), do: :erlang.nif_error(:nif_not_loaded) + def version, do: :erlang.nif_error(:nif_not_loaded) +end