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.
pip install shazamio-coreOr with uv:
uv add shazamio-corePython 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?.
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.
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...
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.
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())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.
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 onjust 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-devMIT. 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.