Metadata-Version: 2.4
Name: ai-midi
Version: 0.1.0rc1
Summary: Python SDK for the AI-MIDI Public API.
Project-URL: Homepage, https://ai-midi.com
Project-URL: Documentation, https://ai-midi.com/api/docs
Project-URL: Source, https://github.com/sori-ai/sori
Project-URL: Issues, https://github.com/sori-ai/sori/issues
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Multimedia :: Sound/Audio :: MIDI
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: requests>=2.31.0

# AI-MIDI Python SDK

Small Python SDK for the AI-MIDI Public API.

The SDK requires Python 3.10 or newer. `Client` is synchronous and uses blocking
HTTP requests. The asynchronous workflow described below refers to server-side
jobs, not to an `asyncio` Python client.

## Installation

The public beta uses the staging API. Install the prerelease from PyPI:

```bash
python -m pip install --pre ai-midi==0.1.0rc1
```

Configure a staging API key and URL before importing `aimidi`:

```bash
export AIMIDI_API_KEY="YOUR_STAGING_API_KEY"
export AIMIDI_BASE_URL="https://staging-api.ai-midi.com"
```

Do not use production credentials or sensitive audio during the public beta.

After the production release, install the stable `ai-midi` distribution:

```bash
python -m pip install ai-midi
```

For repository development, install the reviewed SDK source directly:

```bash
python -m pip install -e packages/ai_midi_sdk/python
```

The distribution name contains a hyphen, while the Python import is `aimidi`:

```python
import aimidi
```

## Configuration

The SDK defaults to the local Public API at `http://localhost:8010`. It does not
silently select a production service.

Package-level helpers read their initial configuration from these environment
variables:

```bash
export AIMIDI_API_KEY="YOUR_STAGING_API_KEY"
export AIMIDI_BASE_URL="https://staging-api.ai-midi.com"
```

For local development, point the same variable at the local Public API:

```bash
export AIMIDI_API_KEY="aimidi_test_local"
export AIMIDI_BASE_URL="http://localhost:8010"
```

Start that local API from this repository with:

```bash
START_SUPPORT=false \
START_SITE=false \
START_FRONTEND=false \
START_PUBLIC_API=true \
deploy/ai_midi/local/server/run-stack.sh
```

Use the same variable for another deployment:

```bash
export AIMIDI_BASE_URL="https://public-api.example.com"
```

Applications using `Client` pass the deployment URL directly:

```python
import os

import aimidi

client = aimidi.Client(
    api_key=os.environ["AIMIDI_API_KEY"],
    base_url=os.environ.get("AIMIDI_BASE_URL", "http://localhost:8010"),
)
```

Package-level configuration can also be assigned in Python:

```python
import aimidi

aimidi.api_key = "aimidi_test_local"
aimidi.base_url = "http://localhost:8010"
```

## Synchronous Conversion

`aimidi.convert` and `Client.convert` upload one file to `POST /v1/convert` and
wait for MIDI bytes:

```python
midi = aimidi.convert(
    "song.mp3",
    model="piano",
    bpm=120,
    beat=4,
    bar=4,
    input_processing_type="original",
    idempotency_key="song-2026-07-10",
)
midi.save("song.mid")
```

Signature:

```python
aimidi.convert(
    audio_path: str,
    *,
    model: str = "piano",
    output_path: str | None = None,
    timeout_seconds: float = 900.0,
    bpm: int = 120,
    beat: int = 4,
    bar: int = 4,
    input_processing_type: str = "original",
    input_processing_target: str | None = None,
    idempotency_key: str | None = None,
) -> MidiResult
```

`Client.convert` has the same conversion arguments. Existing calls such as
`aimidi.convert("song.mp3")` and `client.convert("song.mp3", model="piano")`
remain valid.

A successful synchronous conversion must return HTTP 200 with a MIDI media
type, normally `Content-Type: audio/midi`. The SDK rejects JSON, HTML, missing,
or other non-MIDI content types without constructing or saving a `MidiResult`,
even when the HTTP status is 2xx. The same media-type check applies to
`Client.download_midi`.

A keyed `/v1/convert` replay whose MIDI cannot be streamed returns HTTP 409 with
top-level `code`/`detail` and a nested `job`. The SDK maps
`CONVERSION_FAILED` to `ConversionFailedError`, and maps
`CONVERSION_NOT_READY` plus `CONVERSION_OUTPUT_UNAVAILABLE` to
`TranscriptionNotReadyError`. Exception messages retain the top-level contract
code/detail together with the nested job ID and status. Legacy 409 job documents
with top-level `status` remain supported. The earlier `TRANSCRIPTION_*` code
names are also accepted as compatibility aliases.

A terminal failure on the first synchronous attempt uses the same nested
envelope with HTTP 500 and `code=CONVERSION_FAILED`. The SDK raises
`ConversionFailedError` and preserves the request ID, human detail, contract
code, and nested inference error code in its message. Generic or unstructured
HTTP 5xx responses, including uncertain output-storage failures, remain
`ServerUnavailableError`.

Supported conversion form values:

| Option | Values |
| --- | --- |
| `model` | `"piano"`, `"guitar"`, or `"multi"` |
| `bpm` | Positive integer; default `120` |
| `beat` | Positive integer time-signature numerator; default `4` |
| `bar` | Positive integer time-signature denominator; default `4` |
| `input_processing_type` | `"original"` or `"source_separated"` |
| `input_processing_target` | `"other"`, `"bass"`, `"drums"`, or `"vocals"` |

Source separation requires a target:

```python
midi = client.convert(
    "band.wav",
    input_processing_type="source_separated",
    input_processing_target="bass",
    idempotency_key="band-bass-v1",
)
```

## Asynchronous Server Jobs

The asynchronous Public API creates a server-side job. Every Python method below
is still a regular blocking HTTP call; polling is explicit and bounded.

```python
job = client.create_transcription(
    "song.mp3",
    model="piano",
    bpm=120,
    beat=4,
    bar=4,
    input_processing_type="original",
    idempotency_key="song-async-2026-07-10",
)

print(job.id, job.status)  # queued or processing

job = client.wait_for_transcription(
    job.id,
    timeout_seconds=900,
    poll_interval_seconds=2,
)

midi = client.download_midi(job.id, output_path="song.mid")
print(len(midi.content))
```

`submit_transcription` is an alias for `create_transcription`. The async job
methods are:

```python
client.create_transcription(
    audio_path,
    *,
    model="piano",
    bpm=120,
    beat=4,
    bar=4,
    input_processing_type="original",
    input_processing_target=None,
    callback_url=None,
    idempotency_key=None,
    timeout_seconds=None,
) -> TranscriptionJob

client.get_transcription(
    transcription_id,
    *,
    timeout_seconds=None,
) -> TranscriptionJob

client.wait_for_transcription(
    transcription_id,
    *,
    timeout_seconds=900.0,
    poll_interval_seconds=2.0,
) -> TranscriptionJob

client.download_midi(
    transcription_id,
    *,
    output_path=None,
    timeout_seconds=None,
) -> MidiDownload
```

`get_transcription_status` aliases `get_transcription`. A `TranscriptionJob`
contains `id`, `status`, `model`, `audio_duration_seconds`, `credits_used`,
`created_at`, `completed_at`, `error`, and optional `download_url`. Status is one
of `queued`, `processing`, `completed`, or `failed`.

`callback_url` remains in the SDK signature for forward compatibility, but
customer callbacks are not currently available. Passing it to the API returns
HTTP 422 before the upload or credit hold begins. Poll the transcription status
instead.

## Folder Conversion

`convert_folder` remains available at package and client level. Each supported
file is converted independently, so one failure is recorded on its
`BatchItemResult` without aborting the batch.

```python
results = aimidi.convert_folder(
    "songs/",
    model="piano",
    output_dir="midi_out/",
    recursive=True,
    bpm=120,
    beat=4,
    bar=4,
    idempotency_key_prefix="nightly-2026-07-10",
)

for item in results:
    if item.ok:
        print(f"{item.source_path} -> {item.output_path}")
    else:
        print(f"{item.source_path} failed: {item.error}")
```

Supported extensions are `.mp3`, `.wav`, `.flac`, `.ogg`, and `.m4a`. MIDI is
written next to each source by default. A missing or non-directory input raises
`NotADirectoryError`; a folder with no supported files returns an empty list.
With `output_dir`, non-recursive files are written directly beneath that
directory. Recursive scans mirror each source-relative subdirectory beneath
`output_dir`, so files such as `takes/one/song.mp3` and `takes/two/song.wav`
produce distinct `one/song.mid` and `two/song.mid` outputs.

## Idempotency

Both synchronous and asynchronous submission methods accept
`idempotency_key`. Retrying the same operation with the same key resolves to the
existing server job, avoiding a second conversion and charge. A new key starts a
new conversion.

```python
sync_result = client.convert(
    "song.mp3",
    idempotency_key="song-sync-v1",
)

async_job = client.create_transcription(
    "song.mp3",
    idempotency_key="song-async-v1",
)
```

For batches, `idempotency_key_prefix` derives a stable, fixed-length ASCII key
for each file from:

- the caller's prefix
- the path relative to the scanned folder
- a streamed SHA-256 hash of the file bytes
- `model`, `bpm`, `beat`, `bar`, `input_processing_type`, and
  `input_processing_target`

Timeouts and output paths are deliberately excluded because they do not change
conversion semantics. An unchanged file with unchanged semantic options gets
the same key on every rerun. Changing the bytes, relative path, model, or another
semantic option gets a new key and therefore starts a new conversion.

```python
results = client.convert_folder(
    "songs/",
    idempotency_key_prefix="release-2026-07-10",
)
```

Compatibility note: folder keys now use the `aimidi-folder-v2:` format instead
of the legacy prefix-plus-path hash. The first keyed folder run after upgrading
will therefore submit new keys, even for unchanged files, and may create and
charge new conversions. Subsequent unchanged reruns are stable. Explicit
`idempotency_key` values passed to `convert` or `create_transcription` are not
changed by this migration.

Omitting these values preserves the original behavior: each submission is an
independent conversion.

## Errors

All HTTP/API errors subclass `aimidi.ApiError`.

| Exception | Raised when |
| --- | --- |
| `InvalidApiKeyError` | The API key is missing or invalid (HTTP 401). |
| `InsufficientCreditsError` | The account is out of credits (HTTP 402). |
| `InvalidRequestError` | Request metadata is rejected (HTTP 422). |
| `UnsupportedAudioFormatError` | The extension is unsupported (HTTP 415); subclasses `InvalidAudioError`. |
| `InvalidAudioError` | The file content is unreadable or invalid (HTTP 422). |
| `FileTooLargeError` | The file exceeds the size limit (HTTP 413). |
| `AudioTooLongError` | The audio duration exceeds the selected model limit (HTTP 413). |
| `TooManyJobsError` | Too many conversions are active (HTTP 429). |
| `TranscriptionNotFoundError` | The requested async job does not exist (HTTP 404). |
| `TranscriptionNotReadyError` | MIDI is unavailable for a queued, processing, or unavailable completed job (HTTP 409). |
| `ConversionFailedError` | Polling, a synchronous replay, or a structured first-attempt HTTP 500 reports a failed job. |
| `ConversionTimeoutError` | An HTTP request times out or bounded polling reaches its deadline. This subclasses `ServerUnavailableError` for backward-compatible transport error handling. |
| `ServerUnavailableError` | A connection fails, another request error occurs, or the API returns a generic or unstructured HTTP 5xx. |

Invalid local option values, such as a non-positive `bpm` or source separation
without a target, raise `ValueError` before an HTTP request is sent.

```python
try:
    job = client.create_transcription(
        "song.mp3",
        idempotency_key="song-retry-safe-v1",
    )
    job = client.wait_for_transcription(job.id, timeout_seconds=900)
    client.download_midi(job.id, output_path="song.mid")
except aimidi.ConversionTimeoutError as error:
    print(error)
except aimidi.ConversionFailedError as error:
    print(error)
except aimidi.ServerUnavailableError:
    print("AI-MIDI API is unavailable. Retry with the same Idempotency-Key.")
```
