Skip to content

Commit 1caaef9

Browse files
author
Mikhail Samin's Claude
committed
Add file-absolute start_seconds/end_seconds to enterprise matches
recognize_enterprise now reports where each song plays in the file as start_seconds / end_seconds (seconds, file-absolute). These are computed from the chunk offset the response otherwise carries only at the chunk level, so the position is no longer lost when chunks are flattened. The endpoint is now asked for accurate offsets by default, so the values are precise. The raw start_offset / end_offset remain as the fragment-relative milliseconds.
1 parent 1eccee3 commit 1caaef9

5 files changed

Lines changed: 80 additions & 5 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ print(result.preview_url()) # first preview across requested providers,
101101

102102
Valid `return_metadata` values: `apple_music`, `spotify`, `deezer`, `napster`, `musicbrainz`. Attributes are `None` when not requested.
103103

104-
`EnterpriseMatch` (returned by `recognize_enterprise`) carries the same core tags plus `score`, `start_offset`, `end_offset`, `isrc`, `upc`. Access to `isrc`, `upc`, and `score` requires a Startup plan or higher — [contact us](mailto:api@audd.io) for enterprise features.
104+
`EnterpriseMatch` (returned by `recognize_enterprise`) carries the same core tags plus `score`, `isrc`, `upc`, and — the fields you need for a long file — **`start_seconds` and `end_seconds`**: where this song plays in your file, in seconds (e.g. `64.2` to `71.8`). Feed them straight to a player or `ffmpeg`. They're computed for you while parsing the chunked enterprise response, and `recognize_enterprise` requests accurate offsets by default so they're precise to the sub-second. (`start_offset`/`end_offset` are the raw millisecond positions *within* AudD's internal 12-second scan fragment that these are derived from; you rarely need them directly.) Access to `isrc`, `upc`, and `score` requires a Startup plan or higher — [contact us](mailto:api@audd.io) for enterprise features.
105105

106106
For ad-hoc inspection during development, `result.pretty_print()` dumps the full state — typed fields plus everything in `model_extra` — as indented JSON.
107107

src/audd/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Single source of truth for the package version. Hatch reads this."""
22

3-
__version__ = "1.5.11"
3+
__version__ = "1.5.12"

src/audd/client.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@
2525
AudDServerError,
2626
raise_from_error_response,
2727
)
28-
from audd.models import EnterpriseChunkResult, EnterpriseMatch, RecognitionResult
28+
from audd.models import (
29+
EnterpriseChunkResult,
30+
EnterpriseMatch,
31+
RecognitionResult,
32+
_offset_to_seconds,
33+
)
2934

3035
API_BASE = "https://api.audd.io"
3136
ENTERPRISE_BASE = "https://enterprise.audd.io"
@@ -208,6 +213,14 @@ def _decode_enterprise(resp: HTTPResponse) -> list[EnterpriseMatch]:
208213
chunk = EnterpriseChunkResult.model_validate(chunk_dict)
209214
except Exception: # degrade, never raise on response parse
210215
continue
216+
# The file position lives on the chunk; the API drops it once we flatten
217+
# to a song list. Resolve each match's absolute position in the file
218+
# here (chunk offset + in-fragment offset) so callers don't lose it.
219+
base = _offset_to_seconds(chunk.offset)
220+
if base is not None:
221+
for song in chunk.songs:
222+
song.start_seconds = base + (song.start_offset or 0) / 1000
223+
song.end_seconds = base + (song.end_offset or 0) / 1000
211224
out.extend(chunk.songs)
212225
return out
213226

@@ -411,7 +424,7 @@ def recognize_enterprise(
411424
limit: int | None = None,
412425
skip_first_seconds: int | None = None,
413426
use_timecode: bool | None = None,
414-
accurate_offsets: bool | None = None,
427+
accurate_offsets: bool = True,
415428
timeout: float | None = None,
416429
extra_parameters: dict[str, str] | None = None,
417430
) -> list[EnterpriseMatch]:
@@ -603,7 +616,7 @@ async def recognize_enterprise(
603616
limit: int | None = None,
604617
skip_first_seconds: int | None = None,
605618
use_timecode: bool | None = None,
606-
accurate_offsets: bool | None = None,
619+
accurate_offsets: bool = True,
607620
timeout: float | None = None,
608621
extra_parameters: dict[str, str] | None = None,
609622
) -> list[EnterpriseMatch]:

src/audd/models.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,24 @@ class _Forward(BaseModel):
3737
model_config = ConfigDict(extra="allow", populate_by_name=True, str_strip_whitespace=False)
3838

3939

40+
def _offset_to_seconds(offset: str | None) -> float | None:
41+
"""Parse a chunk offset (``"SS"``, ``"MM:SS"``, or ``"HH:MM:SS"``, or a
42+
bare number) into seconds. Returns ``None`` for missing/unparseable input —
43+
response parsing never raises on a malformed field."""
44+
if offset is None:
45+
return None
46+
text = str(offset).strip()
47+
if not text:
48+
return None
49+
try:
50+
total = 0.0
51+
for part in text.split(":"):
52+
total = total * 60 + float(part)
53+
return total
54+
except ValueError:
55+
return None
56+
57+
4058
def _coerce_model_list(value: Any, model: type[BaseModel]) -> list[Any]:
4159
"""Best-effort list-of-model coercion that never raises.
4260
@@ -295,6 +313,13 @@ class EnterpriseMatch(_Forward):
295313
song_link: str | None = None
296314
start_offset: int | None = None
297315
end_offset: int | None = None
316+
start_seconds: float | None = None
317+
"""Where this song starts in your file, in seconds (e.g. ``64.2``). Computed
318+
by the client from the fragment's file position and the in-fragment offset.
319+
``None`` if the position can't be determined. ``start_offset``/``end_offset``
320+
are the raw, fragment-relative millisecond values behind this."""
321+
end_seconds: float | None = None
322+
"""Where this song ends in your file, in seconds. ``None`` if unknown."""
298323

299324
def __repr__(self) -> str:
300325
parts: list[str] = []

tests/unit/test_client.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,43 @@ def test_recognize_enterprise_returns_list_of_matches() -> None:
140140
assert matches[0].artist == "X"
141141

142142

143+
@respx.mock
144+
def test_recognize_enterprise_anchors_offsets_to_file() -> None:
145+
"""The flattened matches expose file-absolute start_seconds/end_seconds,
146+
derived from the chunk offset + the in-fragment start_offset/end_offset.
147+
The chunk offset is the only source of file position and must not be lost."""
148+
respx.post("https://enterprise.audd.io/").mock(
149+
return_value=httpx.Response(200, json={
150+
"status": "success",
151+
"result": [
152+
{"offset": "00:01:00", "songs": [{
153+
"artist": "A", "title": "T",
154+
"start_offset": 4200, "end_offset": 11800,
155+
}]},
156+
{"offset": None, "songs": [{"artist": "B", "title": "U"}]},
157+
],
158+
}),
159+
)
160+
matches = AudD(api_token="t-test").recognize_enterprise("https://example.mp3", limit=2)
161+
assert matches[0].start_seconds == 64.2 # 60 + 4200/1000
162+
assert matches[0].end_seconds == 71.8 # 60 + 11800/1000
163+
assert matches[1].start_seconds is None # chunk offset absent → unknown
164+
assert matches[1].end_seconds is None
165+
166+
167+
@respx.mock
168+
def test_recognize_enterprise_requests_accurate_offsets_by_default() -> None:
169+
captured: dict[str, str] = {}
170+
171+
def handler(request: httpx.Request) -> httpx.Response:
172+
captured["body"] = request.read().decode()
173+
return httpx.Response(200, json={"status": "success", "result": []})
174+
175+
respx.post("https://enterprise.audd.io/").mock(side_effect=handler)
176+
AudD(api_token="t-test").recognize_enterprise("https://example.mp3", limit=1)
177+
assert "accurate_offsets=true" in captured["body"]
178+
179+
143180
@respx.mock
144181
def test_recognize_enterprise_song_without_score_parses() -> None:
145182
"""Enterprise endpoint legitimately returns songs with no score (and no

0 commit comments

Comments
 (0)