audiokit-ai 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- audiokit_ai-0.1.0/.gitignore +6 -0
- audiokit_ai-0.1.0/PKG-INFO +45 -0
- audiokit_ai-0.1.0/README.md +33 -0
- audiokit_ai-0.1.0/pyproject.toml +19 -0
- audiokit_ai-0.1.0/src/audiokit_ai/__init__.py +7 -0
- audiokit_ai-0.1.0/src/audiokit_ai/client.py +32 -0
- audiokit_ai-0.1.0/src/audiokit_ai/models.py +449 -0
- audiokit_ai-0.1.0/src/audiokit_ai/py.typed +0 -0
- audiokit_ai-0.1.0/src/audiokit_ai/transport.py +49 -0
- audiokit_ai-0.1.0/src/audiokit_ai/webhook.py +22 -0
- audiokit_ai-0.1.0/test/test_sdk.py +71 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: audiokit-ai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generated Python client and webhook verification for the AudioKit API
|
|
5
|
+
Project-URL: Homepage, https://audiokit.ai/docs/sdks/
|
|
6
|
+
Project-URL: Repository, https://github.com/epsilon-records/audiokit
|
|
7
|
+
License-Expression: LicenseRef-Proprietary
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: httpx<1,>=0.28.1
|
|
10
|
+
Requires-Dist: typing-extensions<5,>=4.13
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# AudioKit for Python
|
|
14
|
+
|
|
15
|
+
Generated from AudioKit's OpenAPI contract; includes raw-body webhook verification. Python 3.11+.
|
|
16
|
+
|
|
17
|
+
**0.1.0 release candidate — not yet on PyPI.** After publication, install with pip install audiokit-ai. For local testing, install the wheel built from this directory.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import os
|
|
21
|
+
from audiokit_ai import AudioKit, ApiError, verify_webhook
|
|
22
|
+
from audiokit_ai.models import JobRequest
|
|
23
|
+
|
|
24
|
+
with AudioKit(api_key=os.environ['AUDIOKIT_API_KEY']) as client:
|
|
25
|
+
request: JobRequest = {
|
|
26
|
+
'job_id': 'my-first-job',
|
|
27
|
+
'operation': 'probe',
|
|
28
|
+
'input': {'url': signed_input_url, 'sha256': input_sha256},
|
|
29
|
+
'webhook': {'url': 'https://example.com/audiokit/webhook'},
|
|
30
|
+
}
|
|
31
|
+
job = client.submit_job(request)
|
|
32
|
+
status = client.get_job(job['provider_job_id'])
|
|
33
|
+
|
|
34
|
+
# In a webhook handler, before parsing JSON:
|
|
35
|
+
if not verify_webhook(raw_body, signature_header, webhook_secret):
|
|
36
|
+
# Return 401. Do not use the event.
|
|
37
|
+
raise ValueError('Invalid webhook signature')
|
|
38
|
+
# After verification, parse the body and deduplicate event_id.
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Set base_url for another API instance. Methods include get_health, get_capabilities, get_open_api, submit_job, get_job and cancel_job. Generated models are TypedDicts, so requests and responses are ordinary dictionaries. Use the context manager or call close() to release connections.
|
|
42
|
+
|
|
43
|
+
HTTP failures raise ApiError with status and code. Network failures are native httpx exceptions. Unknown API codes are preserved; treat unfamiliar ones as internal failures. There are no automatic retries or redirects; requests have a 30-second timeout. The verifier takes raw bytes and returns a boolean with a 300-second tolerance in both time directions. Its optional now argument is Unix seconds for deterministic tests. It does not deduplicate events.
|
|
44
|
+
|
|
45
|
+
[API documentation](https://audiokit.ai/docs/) · [SDK guide](https://audiokit.ai/docs/sdks/)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# AudioKit for Python
|
|
2
|
+
|
|
3
|
+
Generated from AudioKit's OpenAPI contract; includes raw-body webhook verification. Python 3.11+.
|
|
4
|
+
|
|
5
|
+
**0.1.0 release candidate — not yet on PyPI.** After publication, install with pip install audiokit-ai. For local testing, install the wheel built from this directory.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
import os
|
|
9
|
+
from audiokit_ai import AudioKit, ApiError, verify_webhook
|
|
10
|
+
from audiokit_ai.models import JobRequest
|
|
11
|
+
|
|
12
|
+
with AudioKit(api_key=os.environ['AUDIOKIT_API_KEY']) as client:
|
|
13
|
+
request: JobRequest = {
|
|
14
|
+
'job_id': 'my-first-job',
|
|
15
|
+
'operation': 'probe',
|
|
16
|
+
'input': {'url': signed_input_url, 'sha256': input_sha256},
|
|
17
|
+
'webhook': {'url': 'https://example.com/audiokit/webhook'},
|
|
18
|
+
}
|
|
19
|
+
job = client.submit_job(request)
|
|
20
|
+
status = client.get_job(job['provider_job_id'])
|
|
21
|
+
|
|
22
|
+
# In a webhook handler, before parsing JSON:
|
|
23
|
+
if not verify_webhook(raw_body, signature_header, webhook_secret):
|
|
24
|
+
# Return 401. Do not use the event.
|
|
25
|
+
raise ValueError('Invalid webhook signature')
|
|
26
|
+
# After verification, parse the body and deduplicate event_id.
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Set base_url for another API instance. Methods include get_health, get_capabilities, get_open_api, submit_job, get_job and cancel_job. Generated models are TypedDicts, so requests and responses are ordinary dictionaries. Use the context manager or call close() to release connections.
|
|
30
|
+
|
|
31
|
+
HTTP failures raise ApiError with status and code. Network failures are native httpx exceptions. Unknown API codes are preserved; treat unfamiliar ones as internal failures. There are no automatic retries or redirects; requests have a 30-second timeout. The verifier takes raw bytes and returns a boolean with a 300-second tolerance in both time directions. Its optional now argument is Unix seconds for deterministic tests. It does not deduplicate events.
|
|
32
|
+
|
|
33
|
+
[API documentation](https://audiokit.ai/docs/) · [SDK guide](https://audiokit.ai/docs/sdks/)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.27.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "audiokit-ai"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Generated Python client and webhook verification for the AudioKit API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "LicenseRef-Proprietary"
|
|
12
|
+
dependencies = ["httpx>=0.28.1,<1", "typing-extensions>=4.13,<5"]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://audiokit.ai/docs/sdks/"
|
|
16
|
+
Repository = "https://github.com/epsilon-records/audiokit"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["src/audiokit_ai"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Generated from openapi.yaml by sdks/generate.mjs. Do not edit.
|
|
2
|
+
from urllib.parse import quote
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
from . import models
|
|
6
|
+
from .transport import Transport
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AudioKit(Transport):
|
|
10
|
+
def get_health(self) -> models.Health:
|
|
11
|
+
"""Liveness and version."""
|
|
12
|
+
return cast(models.Health, self._request("GET", "/v1/health", None, False, True))
|
|
13
|
+
|
|
14
|
+
def get_capabilities(self) -> models.Capabilities:
|
|
15
|
+
"""Operations that work end to end on this instance."""
|
|
16
|
+
return cast(models.Capabilities, self._request("GET", "/v1/capabilities", None, False, True))
|
|
17
|
+
|
|
18
|
+
def get_open_api(self) -> str:
|
|
19
|
+
"""This contract."""
|
|
20
|
+
return cast(str, self._request("GET", "/v1/openapi.yaml", None, False, False))
|
|
21
|
+
|
|
22
|
+
def submit_job(self, body: models.JobRequest) -> models.Job:
|
|
23
|
+
"""Submit a job."""
|
|
24
|
+
return cast(models.Job, self._request("POST", "/v1/jobs", body, True, True))
|
|
25
|
+
|
|
26
|
+
def get_job(self, provider_job_id: str) -> models.Job:
|
|
27
|
+
"""Job status and result."""
|
|
28
|
+
return cast(models.Job, self._request("GET", f"/v1/jobs/{quote(provider_job_id, safe='')}", None, True, True))
|
|
29
|
+
|
|
30
|
+
def cancel_job(self, provider_job_id: str) -> models.Job:
|
|
31
|
+
"""Cancel a job."""
|
|
32
|
+
return cast(models.Job, self._request("POST", f"/v1/jobs/{quote(provider_job_id, safe='')}/cancel", None, True, True))
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
# generated by datamodel-codegen:
|
|
2
|
+
# filename: openapi.yaml
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Literal, NotRequired, TypedDict
|
|
7
|
+
|
|
8
|
+
from typing_extensions import TypeAliasType
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Health(TypedDict):
|
|
12
|
+
status: Literal['ok']
|
|
13
|
+
version: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
ApiErrorCode = TypeAliasType(
|
|
17
|
+
"ApiErrorCode",
|
|
18
|
+
Literal[
|
|
19
|
+
'invalid_request',
|
|
20
|
+
'operation_unavailable',
|
|
21
|
+
'unauthorized',
|
|
22
|
+
'not_found',
|
|
23
|
+
'idempotency_conflict',
|
|
24
|
+
'client_bootstrap',
|
|
25
|
+
'rate_limited',
|
|
26
|
+
'unavailable',
|
|
27
|
+
]
|
|
28
|
+
| str,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ClientUpsert(TypedDict):
|
|
33
|
+
id: str
|
|
34
|
+
api_key_sha256: str
|
|
35
|
+
webhook_secret: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
JobErrorCode = TypeAliasType(
|
|
39
|
+
"JobErrorCode",
|
|
40
|
+
Literal[
|
|
41
|
+
'input_url_rejected',
|
|
42
|
+
'input_unreachable',
|
|
43
|
+
'input_too_large',
|
|
44
|
+
'input_hash_mismatch',
|
|
45
|
+
'unsupported_format',
|
|
46
|
+
'unsupported_codec',
|
|
47
|
+
'corrupt_input',
|
|
48
|
+
'invalid_params',
|
|
49
|
+
'operation_unavailable',
|
|
50
|
+
'encode_failed',
|
|
51
|
+
'output_url_rejected',
|
|
52
|
+
'output_upload_failed',
|
|
53
|
+
'stems_unavailable',
|
|
54
|
+
'stems_failed',
|
|
55
|
+
'timeout',
|
|
56
|
+
'canceled',
|
|
57
|
+
'internal',
|
|
58
|
+
]
|
|
59
|
+
| str,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class JobError(TypedDict):
|
|
64
|
+
code: JobErrorCode
|
|
65
|
+
message: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
Operation = TypeAliasType(
|
|
69
|
+
"Operation",
|
|
70
|
+
Literal[
|
|
71
|
+
'probe',
|
|
72
|
+
'loudness',
|
|
73
|
+
'waveform',
|
|
74
|
+
'transcode',
|
|
75
|
+
'preview',
|
|
76
|
+
'normalize',
|
|
77
|
+
'tempo',
|
|
78
|
+
'key',
|
|
79
|
+
'fingerprint',
|
|
80
|
+
'analyze',
|
|
81
|
+
'tags',
|
|
82
|
+
'stems',
|
|
83
|
+
],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
OutputName = TypeAliasType(
|
|
88
|
+
"OutputName", Literal['audio', 'vocals', 'drums', 'bass', 'other']
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
Sha256 = TypeAliasType("Sha256", str)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
HttpsUrl = TypeAliasType("HttpsUrl", str)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class InputFile(TypedDict):
|
|
99
|
+
url: HttpsUrl
|
|
100
|
+
sha256: Sha256
|
|
101
|
+
filename: NotRequired[str]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class OutputTarget(TypedDict):
|
|
105
|
+
url: HttpsUrl
|
|
106
|
+
headers: NotRequired[dict[str, str]]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class WebhookTarget(TypedDict):
|
|
110
|
+
url: HttpsUrl
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class AuthorizationRef(TypedDict):
|
|
114
|
+
decision_id: str
|
|
115
|
+
purpose: NotRequired[str]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class NoParams(TypedDict):
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class WaveformParams(TypedDict):
|
|
123
|
+
points: NotRequired[int]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TempoParams(TypedDict):
|
|
127
|
+
min_bpm: NotRequired[float]
|
|
128
|
+
max_bpm: NotRequired[float]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class TagsParams(TypedDict):
|
|
132
|
+
embedding: NotRequired[bool]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
StemsModel = TypeAliasType("StemsModel", Literal['htdemucs_ft', 'htdemucs'])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class StemsParams(TypedDict):
|
|
139
|
+
model: NotRequired[StemsModel]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
Format = TypeAliasType("Format", Literal['wav', 'flac', 'mp3', 'aac'])
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
SampleRate = TypeAliasType(
|
|
146
|
+
"SampleRate", Literal[22050, 24000, 32000, 44100, 48000, 96000]
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TranscodeParams(TypedDict):
|
|
151
|
+
format: Format
|
|
152
|
+
sample_rate: NotRequired[SampleRate]
|
|
153
|
+
channels: NotRequired[Literal[1, 2]]
|
|
154
|
+
bit_depth: NotRequired[Literal[16, 24]]
|
|
155
|
+
bitrate_kbps: NotRequired[int]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class PreviewParams(TypedDict):
|
|
159
|
+
start_seconds: NotRequired[float]
|
|
160
|
+
duration_seconds: NotRequired[float]
|
|
161
|
+
fade_seconds: NotRequired[float]
|
|
162
|
+
format: NotRequired[Format]
|
|
163
|
+
bitrate_kbps: NotRequired[int]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class NormalizeParams(TypedDict):
|
|
167
|
+
target_lufs: float
|
|
168
|
+
true_peak_dbtp: NotRequired[float]
|
|
169
|
+
format: NotRequired[Format]
|
|
170
|
+
sample_rate: NotRequired[SampleRate]
|
|
171
|
+
channels: NotRequired[Literal[1, 2]]
|
|
172
|
+
bit_depth: NotRequired[Literal[16, 24]]
|
|
173
|
+
bitrate_kbps: NotRequired[int]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
JobStatus = TypeAliasType(
|
|
177
|
+
"JobStatus", Literal['queued', 'running', 'succeeded', 'failed', 'canceled']
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class Provenance(TypedDict):
|
|
182
|
+
input_sha256: Sha256
|
|
183
|
+
audiokit_version: str
|
|
184
|
+
operation: Operation
|
|
185
|
+
params: dict[str, Any]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
Decibels = TypeAliasType("Decibels", float | None)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class ProbeResult(TypedDict):
|
|
192
|
+
container: str
|
|
193
|
+
codec: str
|
|
194
|
+
sample_rate: int
|
|
195
|
+
channels: int
|
|
196
|
+
bits_per_sample: int | None
|
|
197
|
+
frames: int
|
|
198
|
+
duration_seconds: float
|
|
199
|
+
size_bytes: int
|
|
200
|
+
decode_errors: int
|
|
201
|
+
valid: bool
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class StreamFields(TypedDict):
|
|
205
|
+
sample_rate: int
|
|
206
|
+
channels: int
|
|
207
|
+
duration_seconds: float
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class LoudnessFields(TypedDict):
|
|
211
|
+
integrated_lufs: Decibels | None
|
|
212
|
+
loudness_range_lu: Decibels | None
|
|
213
|
+
true_peak_dbtp: Decibels | None
|
|
214
|
+
sample_peak_dbfs: Decibels | None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class LoudnessResult(LoudnessFields, StreamFields):
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class TempoFields(TypedDict):
|
|
222
|
+
bpm: float | None
|
|
223
|
+
confidence: float | None
|
|
224
|
+
steady: bool
|
|
225
|
+
beat_count: int
|
|
226
|
+
beats: list[float]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class TempoResult(TempoFields, StreamFields):
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
KeyName = TypeAliasType("KeyName", str)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
Camelot = TypeAliasType("Camelot", str)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class Candidate(TypedDict):
|
|
240
|
+
key: KeyName
|
|
241
|
+
camelot: Camelot
|
|
242
|
+
score: float
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class KeyFields(TypedDict):
|
|
246
|
+
key: KeyName | None
|
|
247
|
+
tonic: (
|
|
248
|
+
Literal['C', 'C#', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B'] | None
|
|
249
|
+
)
|
|
250
|
+
mode: Literal['major', 'minor'] | None
|
|
251
|
+
camelot: Camelot | None
|
|
252
|
+
confidence: float | None
|
|
253
|
+
tuning_cents: float | None
|
|
254
|
+
candidates: list[Candidate]
|
|
255
|
+
chroma: list[float] | None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class KeyResult(KeyFields, StreamFields):
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class FingerprintFields(TypedDict):
|
|
263
|
+
fingerprint: str | None
|
|
264
|
+
algorithm: Literal['chromaprint-2']
|
|
265
|
+
fingerprint_seconds: float
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class FingerprintResult(FingerprintFields, StreamFields):
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class TagScore(TypedDict):
|
|
273
|
+
tag: str
|
|
274
|
+
score: float
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class TagsFields(TypedDict):
|
|
278
|
+
model: str
|
|
279
|
+
windows: int
|
|
280
|
+
embedding: NotRequired[list[float]]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class TagsResult(TypedDict):
|
|
284
|
+
model: str
|
|
285
|
+
windows: int
|
|
286
|
+
embedding: NotRequired[list[float]]
|
|
287
|
+
sample_rate: int
|
|
288
|
+
channels: int
|
|
289
|
+
duration_seconds: float
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class WaveformResult(TypedDict):
|
|
293
|
+
points: int
|
|
294
|
+
peaks: list[float]
|
|
295
|
+
sample_rate: int
|
|
296
|
+
channels: int
|
|
297
|
+
duration_seconds: float
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class AudioOutputFields1(TypedDict):
|
|
301
|
+
format: Format
|
|
302
|
+
content_type: Literal['audio/wav', 'audio/flac', 'audio/mpeg', 'audio/mp4']
|
|
303
|
+
sample_rate: int
|
|
304
|
+
channels: int
|
|
305
|
+
duration_seconds: float
|
|
306
|
+
bit_depth: int
|
|
307
|
+
bitrate_kbps: NotRequired[int]
|
|
308
|
+
sha256: Sha256
|
|
309
|
+
size_bytes: int
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class AudioOutputFields2(TypedDict):
|
|
313
|
+
format: Format
|
|
314
|
+
content_type: Literal['audio/wav', 'audio/flac', 'audio/mpeg', 'audio/mp4']
|
|
315
|
+
sample_rate: int
|
|
316
|
+
channels: int
|
|
317
|
+
duration_seconds: float
|
|
318
|
+
bit_depth: NotRequired[int]
|
|
319
|
+
bitrate_kbps: int
|
|
320
|
+
sha256: Sha256
|
|
321
|
+
size_bytes: int
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
AudioOutputFields = TypeAliasType(
|
|
325
|
+
"AudioOutputFields", AudioOutputFields1 | AudioOutputFields2
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class AudioOutput(TypedDict):
|
|
330
|
+
pass
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class PreviewAudioOutput(TypedDict):
|
|
334
|
+
start_seconds: float
|
|
335
|
+
fade_seconds: float
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class Outputs(TypedDict):
|
|
339
|
+
audio: AudioOutput
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class TranscodeResult(TypedDict):
|
|
343
|
+
outputs: Outputs
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class Outputs1(TypedDict):
|
|
347
|
+
audio: PreviewAudioOutput
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class PreviewResult(TypedDict):
|
|
351
|
+
outputs: Outputs1
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class Outputs2(TypedDict):
|
|
355
|
+
audio: AudioOutput
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class NormalizeResult(TypedDict):
|
|
359
|
+
target_lufs: float
|
|
360
|
+
true_peak_ceiling_dbtp: float
|
|
361
|
+
target_reached: bool
|
|
362
|
+
gain_db: float
|
|
363
|
+
before: LoudnessFields
|
|
364
|
+
after: LoudnessFields
|
|
365
|
+
outputs: Outputs2
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class StemOutput(TypedDict):
|
|
369
|
+
sha256: Sha256
|
|
370
|
+
size_bytes: int
|
|
371
|
+
format: NotRequired[str]
|
|
372
|
+
content_type: NotRequired[str]
|
|
373
|
+
sample_rate: NotRequired[int]
|
|
374
|
+
channels: NotRequired[int]
|
|
375
|
+
duration_seconds: NotRequired[float]
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class Outputs3(TypedDict):
|
|
379
|
+
vocals: StemOutput
|
|
380
|
+
drums: StemOutput
|
|
381
|
+
bass: StemOutput
|
|
382
|
+
other: StemOutput
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class StemsResult(TypedDict):
|
|
386
|
+
model: StemsModel
|
|
387
|
+
outputs: Outputs3
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
Capability = TypedDict(
|
|
391
|
+
'Capability',
|
|
392
|
+
{
|
|
393
|
+
'operation': Operation,
|
|
394
|
+
'outputs': list[OutputName],
|
|
395
|
+
'async': bool,
|
|
396
|
+
'requires_consent': bool,
|
|
397
|
+
},
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
class Error1(TypedDict):
|
|
402
|
+
code: ApiErrorCode
|
|
403
|
+
message: str
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class Error(TypedDict):
|
|
407
|
+
error: Error1
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class JobRequest(TypedDict):
|
|
411
|
+
job_id: str
|
|
412
|
+
operation: Operation
|
|
413
|
+
input: InputFile
|
|
414
|
+
params: NotRequired[dict[str, Any] | None]
|
|
415
|
+
outputs: NotRequired[dict[str, OutputTarget]]
|
|
416
|
+
webhook: NotRequired[WebhookTarget]
|
|
417
|
+
authorization: NotRequired[AuthorizationRef]
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class Job(TypedDict):
|
|
421
|
+
provider_job_id: str
|
|
422
|
+
job_id: str
|
|
423
|
+
operation: Operation
|
|
424
|
+
status: JobStatus
|
|
425
|
+
result: NotRequired[dict[str, Any]]
|
|
426
|
+
error: NotRequired[JobError]
|
|
427
|
+
provenance: NotRequired[Provenance]
|
|
428
|
+
created_at: str
|
|
429
|
+
started_at: NotRequired[str]
|
|
430
|
+
finished_at: NotRequired[str]
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class WebhookEvent(TypedDict):
|
|
434
|
+
event_id: str
|
|
435
|
+
type: Literal['job.succeeded', 'job.failed', 'job.canceled']
|
|
436
|
+
created_at: str
|
|
437
|
+
data: Job
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class AnalyzeResult(StreamFields):
|
|
441
|
+
loudness: LoudnessFields
|
|
442
|
+
tempo: TempoFields
|
|
443
|
+
key: KeyFields
|
|
444
|
+
fingerprint: FingerprintFields
|
|
445
|
+
tags: NotRequired[TagsFields | None]
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class Capabilities(TypedDict):
|
|
449
|
+
operations: list[Capability]
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""HTTP transport. No retries or redirects; HTTP errors preserve API codes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import Any, Self
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ApiError(Exception):
|
|
11
|
+
def __init__(self, status: int, code: str, message: str) -> None:
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.status = status
|
|
14
|
+
self.code = code
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Transport:
|
|
18
|
+
def __init__(self, api_key: str, base_url: str = "https://api.audiokit.ai") -> None:
|
|
19
|
+
self._api_key = api_key
|
|
20
|
+
self._base_url = base_url.rstrip("/")
|
|
21
|
+
self._client = httpx.Client(timeout=30, follow_redirects=False)
|
|
22
|
+
|
|
23
|
+
def close(self) -> None:
|
|
24
|
+
self._client.close()
|
|
25
|
+
|
|
26
|
+
def __enter__(self) -> Self:
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None,
|
|
30
|
+
traceback: TracebackType | None) -> None:
|
|
31
|
+
self.close()
|
|
32
|
+
|
|
33
|
+
def _request(self, method: str, path: str, body: Any, auth: bool, json: bool) -> Any:
|
|
34
|
+
headers = {"Accept": "application/json" if json else "application/yaml"}
|
|
35
|
+
if auth:
|
|
36
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
37
|
+
response = self._client.request(method, self._base_url + path, headers=headers, json=body)
|
|
38
|
+
if not response.is_success:
|
|
39
|
+
try:
|
|
40
|
+
payload = response.json()
|
|
41
|
+
except ValueError:
|
|
42
|
+
payload = None
|
|
43
|
+
error = payload.get("error") if isinstance(payload, dict) else None
|
|
44
|
+
error = error if isinstance(error, dict) else {}
|
|
45
|
+
code = error.get("code")
|
|
46
|
+
message = error.get("message")
|
|
47
|
+
raise ApiError(response.status_code, code if isinstance(code, str) else "internal",
|
|
48
|
+
message if isinstance(message, str) else f"HTTP {response.status_code}")
|
|
49
|
+
return response.json() if json else response.text
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Verify the existing AudioKit v1 signature using Python's HMAC primitives."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def verify_webhook(raw_body: bytes, signature: str | None, secret: str,
|
|
10
|
+
now: float | None = None) -> bool:
|
|
11
|
+
"""Check raw bytes before JSON parsing; deduplicate event_id separately."""
|
|
12
|
+
if not signature:
|
|
13
|
+
return False
|
|
14
|
+
match = re.fullmatch(r"t=([0-9]{1,16}),v1=([0-9a-f]{64})", signature)
|
|
15
|
+
if not match:
|
|
16
|
+
return False
|
|
17
|
+
timestamp = int(match[1])
|
|
18
|
+
current = time.time() if now is None else now
|
|
19
|
+
if timestamp > 9007199254740991 or not math.isfinite(current) or abs(current - timestamp) > 300:
|
|
20
|
+
return False
|
|
21
|
+
expected = hmac.new(secret.encode(), str(timestamp).encode() + b"." + raw_body, hashlib.sha256).digest()
|
|
22
|
+
return hmac.compare_digest(expected, bytes.fromhex(match[2]))
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from audiokit_ai import AudioKit, ApiError, verify_webhook
|
|
8
|
+
|
|
9
|
+
VECTOR = json.loads((Path(__file__).parents[2] / "signature-vector.json").read_text())
|
|
10
|
+
NOW = int(VECTOR["header"].split(",")[0][2:])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SdkTests(unittest.TestCase):
|
|
14
|
+
def test_signatures(self):
|
|
15
|
+
body = VECTOR["body"].encode()
|
|
16
|
+
header, secret = VECTOR["header"], VECTOR["secret"]
|
|
17
|
+
for delta in [0, -300, 300]:
|
|
18
|
+
self.assertTrue(verify_webhook(body, header, secret, NOW + delta))
|
|
19
|
+
for delta in [-301, 301]:
|
|
20
|
+
self.assertFalse(verify_webhook(body, header, secret, NOW + delta))
|
|
21
|
+
self.assertFalse(verify_webhook(body + b" ", header, secret, NOW))
|
|
22
|
+
self.assertFalse(verify_webhook(body, header, "wrong", NOW))
|
|
23
|
+
self.assertFalse(verify_webhook(body, header, secret, float("nan")))
|
|
24
|
+
for bad in [None, "", "garbage", "t=1,v1=abcd", header + "\n", header + ",t=1",
|
|
25
|
+
header.replace("t=", "t=-"), header.replace("1793440800", "9" * 300),
|
|
26
|
+
header.replace("27d3", "zzzz")]:
|
|
27
|
+
self.assertFalse(verify_webhook(body, bad, secret, NOW), bad)
|
|
28
|
+
|
|
29
|
+
def test_transport(self):
|
|
30
|
+
seen = []
|
|
31
|
+
def handle(req):
|
|
32
|
+
seen.append(req)
|
|
33
|
+
path = req.url.path
|
|
34
|
+
if path.endswith("/conflict"):
|
|
35
|
+
return httpx.Response(409, json={"error": {"code": "idempotency_conflict", "message": "Different request"}})
|
|
36
|
+
if path.endswith("/unknown"):
|
|
37
|
+
return httpx.Response(429, json={"error": {"code": "future_code", "message": "Limited"}})
|
|
38
|
+
if path.endswith("/gateway"):
|
|
39
|
+
return httpx.Response(502, text="Bad gateway")
|
|
40
|
+
if path.endswith("/redirect"):
|
|
41
|
+
return httpx.Response(307, headers={"Location": "/v1/jobs/leaked"})
|
|
42
|
+
if path.endswith("openapi.yaml"):
|
|
43
|
+
return httpx.Response(200, text="openapi: 3.1.0")
|
|
44
|
+
return httpx.Response(200, json={"provider_job_id": "id", "job_id": "sdk-test"})
|
|
45
|
+
with AudioKit("test-key", "https://example.com/") as client:
|
|
46
|
+
client._client.close()
|
|
47
|
+
client._client = httpx.Client(transport=httpx.MockTransport(handle))
|
|
48
|
+
client.submit_job({"job_id": "sdk-test", "operation": "probe", "input": {"url": "https://example.com/test.wav", "sha256": "a" * 64}})
|
|
49
|
+
self.assertEqual(seen[-1].headers["Authorization"], "Bearer test-key")
|
|
50
|
+
self.assertEqual(seen[-1].method, "POST")
|
|
51
|
+
self.assertEqual(json.loads(seen[-1].content)["job_id"], "sdk-test")
|
|
52
|
+
client.get_job("a/b?c")
|
|
53
|
+
self.assertEqual(seen[-1].url.raw_path, b"/v1/jobs/a%2Fb%3Fc")
|
|
54
|
+
client.cancel_job("id")
|
|
55
|
+
self.assertEqual(seen[-1].url.path, "/v1/jobs/id/cancel")
|
|
56
|
+
self.assertEqual(seen[-1].method, "POST")
|
|
57
|
+
for method in [client.get_health, client.get_capabilities, client.get_open_api]:
|
|
58
|
+
method()
|
|
59
|
+
self.assertNotIn("Authorization", seen[-1].headers)
|
|
60
|
+
self.assertEqual(client.get_open_api(), "openapi: 3.1.0")
|
|
61
|
+
for name, status, code in [("conflict", 409, "idempotency_conflict"), ("unknown", 429, "future_code"), ("gateway", 502, "internal"), ("redirect", 307, "internal")]:
|
|
62
|
+
before = len(seen)
|
|
63
|
+
with self.assertRaises(ApiError) as error:
|
|
64
|
+
client.get_job(name)
|
|
65
|
+
self.assertEqual(error.exception.status, status)
|
|
66
|
+
self.assertEqual(error.exception.code, code)
|
|
67
|
+
self.assertEqual(len(seen), before + 1)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
unittest.main()
|