Skip to content

Repository files navigation

shazamio-core

Audio fingerprinting for Shazam, written in Rust and exposed to Python.

It turns an audio file, or its bytes, into the signature Shazam's endpoint accepts. It does not talk to Shazam itself: ShazamIO is the client that sends the signature and reads the answer back.

Install

pip install shazamio-core

Or with uv:

uv add shazamio-core

Python 3.10 and newer. Prebuilt wheels:

Platform CPython 3.10+ (abi3) PyPy 3.11
Linux x86_64, aarch64 (manylinux_2_28) yes yes
Linux x86_64, aarch64 (musllinux_1_2) yes no
macOS x86_64 (10.12+), arm64 (11.0+) yes no
Windows win_amd64, win32 yes no

Everything else builds from the source distribution, which needs a Rust toolchain 1.87 or newer, CMake and a C compiler. libopus is built from vendored sources rather than linked against a system copy, so without CMake the build stops on is 'cmake' not installed?.

Usage

Both entry points return an asyncio.Future rather than a coroutine, so they need a running event loop at the call and the work starts there rather than at the await. await, asyncio.ensure_future and asyncio.gather all accept what they return; asyncio.create_task takes a coroutine alone and rejects it.

import asyncio
from pathlib import Path

from shazamio_core import Recognizer


async def main() -> None:
    recognizer = Recognizer()

    from_path = await recognizer.recognize_path("track.mp3")
    from_bytes = await recognizer.recognize_bytes(Path("track.mp3").read_bytes())

    print(from_path.signature.uri == from_bytes.signature.uri)


asyncio.run(main())

recognize_path accepts a str or an os.PathLike[str]. A __fspath__ returning bytes is rejected.

What comes back

Both return a Signature:

Field Meaning
signature.uri the fingerprint itself, base64 inside a data:audio/vnd.shazam.sig URI
signature.samples duration of the analysed segment in milliseconds
timestamp, signature.timestamp when the signature was produced, in epoch milliseconds. Both hold the same reading
timezone, geolocation fixed values the request envelope carries. They are not read from the machine and mean nothing on their own

Every field above is read only: assigning to one raises AttributeError.

The URI is the part a client sends on:

data:audio/vnd.shazam.sig;base64,gCX+ypQoAnWcBQAAAJwRlAAAAAA...

How much audio is analysed

Ten seconds by default, taken from the middle of the file. A file shorter than the segment is used whole. Audio is converted to mono and downsampled to 16 kHz before analysis, whatever it started as.

Set it per recognizer, or per call:

import asyncio

from shazamio_core import Recognizer, SearchParams


async def main() -> None:
    recognizer = Recognizer(segment_duration_seconds=5)

    signature = await recognizer.recognize_path(
        "track.mp3",
        SearchParams(segment_duration_seconds=15),
    )

    print(signature.signature.samples)


asyncio.run(main())

SearchParams wins where both are given. The duration must be at least 1; zero raises ValueError. A value at or above the length of the file analyses it whole, whatever the value.

Errors

Anything the decoder cannot use raises SignatureError, and the message opens with the input that failed: the path for recognize_path, the byte payload for recognize_bytes.

Given Message
a path that is not there <input>: No such file or directory (os error 2)
a stream nothing in this build reads, a directory included <input>: unsupported feature: no reader in this build recognises the stream
a container that is read, holding a codec that is not <input>: unsupported feature: the stream carries no track with a codec this build can decode
audio that stops before the segment does <input>: unexpected end of file

What follows the input is symphonia's wording, or the operating system's, and differs between platforms.

An argument of the wrong type raises TypeError instead, and raises it at the call rather than on the await, so nothing is scheduled.

import asyncio

from shazamio_core import Recognizer, SignatureError


async def main() -> None:
    try:
        await Recognizer().recognize_path("not-audio.txt")
    except SignatureError as error:
        print(error)


asyncio.run(main())

Formats

Decoding goes through symphonia with every codec and container it ships enabled, and resampling to mono 16 kHz through rubato. Opus is the one codec symphonia has no decoder for, so it goes through libopus, which is compiled into the wheel rather than loaded from the system. Nothing is shelled out to, so no external binary has to be installed.

That leaves these codecs, each one probed through the public API in the container named beside it:

Codec Probed in In the test suite
AAC ADTS
ADPCM WAV
ALAC MP4
FLAC FLAC yes
MP1 not probed
MP2 MPEG
MP3 MPEG yes
Opus Ogg yes
PCM WAV, AIFF, CAF
Vorbis Ogg yes

MP1 is the one row taken from what symphonia registers rather than from a run: nothing here encodes it. The containers recognised are ADTS, AIFF, CAF, FLAC, Matroska and WebM, MP4, MPEG, Ogg and WAV, and the test suite runs on Linux, macOS and Windows.

Anything else raises SignatureError, and a container from that list is no guarantee: what has to be decodable is the codec inside it. The two refusals differ in how far the file gets:

Refused Fails as Why
AC-3 in Matroska no track with a codec this build can decode the container is read, the codec is not
WMA in ASF no reader in this build recognises the stream there is no ASF demuxer at all

Windows Media Audio decoded in earlier releases through an ffmpeg fallback that has since been removed.

Development

Every check CI runs is a just recipe, so the two cannot drift apart:

just --list      # what there is
just install     # builds the extension, installs the test dependencies, `cargo-about` and the commit hooks
just ci          # everything CI gates on

just install also wires the same recipes into git commit through pre-commit, each one scoped to the files it gates: a change to a test fixture runs both suites, a change to the notices runs the licence check alone. CI scopes its jobs from the same sets, which also cover the release builds no checkout runs: .github/path-filters.yaml.

just install needs the toolchain the Install section lists; maturin comes from pyproject.toml and is fetched automatically. just itself is packaged for most systems, listed under Packages.

just test-rust links libpython, so on Debian and Ubuntu the development package of the interpreter cargo picks up has to be present, or the build stops at rust-lld: error: unable to find library -lpython3.14:

sudo apt install libpython3.14-dev

License

MIT. See LICENSE.

The wheel statically links its Rust dependencies, so the terms in THIRD-PARTY-NOTICES.md apply to it as well. That file says how it is generated; just licenses-check fails once a dependency change has left it behind.

About

No description or website provided.

Topics

Resources

Stars

18 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages