# Actions

> One decorated Python function per job. Its parameters are the form, and what it returns goes to the Library or the page.

## An action

```python title="app.py"
import ox1audio as ox


@ox.action("Convert")
def convert(files: ox.Files("audio"), format: ox.Choice("wav", "flac", "mp3") = "wav"):
    made = []
    for i, path in enumerate(files):
        ox.progress(i / len(files), path.name)
        out = ox.audio.convert(path, format)
        made.append(ox.sound(out, title=path.stem))
    return made
```

- `@ox.action("Title")` turns a plain function into an action. The title is the button people press; the function's name is how a [page](https://docs.0x1audio.com/page.md) calls it.
- The parameters are the controls, and Python defaults are their defaults. Mixel draws the form, checks the input before your code sees it, and runs the action in the background.
- Write plain functions: no `async`, no `yield`, no threads, no server, and no `if __name__ == "__main__"`. Mixel runs them.
- One file can hold several actions. `app.json` › `entry` names the file.

## Controls

```python title="app.py"
import ox1audio as ox


@ox.action("Export stems")
def export(
    songs: ox.Files("audio"),
    destination: ox.Folder() | None = None,
    format: ox.Choice("wav", "flac", "mp3") = "wav",
    name: ox.Text(placeholder="Song name") = "",
    gain: ox.Slider(-12, 12, 0.5, unit="dB") = 0.0,
    takes: ox.Number(1, 8, 1) = 1,
    normalize: ox.Toggle(label="Normalize") = False,
):
    return f"Would export {len(songs)} songs as {format}."
```

| Control | The person sees | Your code gets |
| --- | --- | --- |
| `ox.Files("audio" \| "midi" \| "any", multiple=True)` | A file picker that also takes drops and Library items | `list[Path]`, or one `Path` with `multiple=False` |
| `ox.Folder()` | A folder picker | `Path` |
| `ox.Choice("a", "b", multiple=False)` | A segmented choice | `str`, or `list[str]` with `multiple=True` |
| `ox.Text(placeholder="")` | A text field | `str` |
| `ox.Number(min, max, step)` | A number field | `int` when min, max and step are whole, else `float` |
| `ox.Slider(min, max, step)` | A slider. One that runs from below 0 to above fills from 0, and starts there; `origin=` sets another no-change value | the same as `Number` |
| `ox.Range(min, max, step)` | One slider with a start and an end | a `(low, high)` tuple |
| `ox.Toggle()` | A switch | `bool` |

Plain `str`, `int`, `float` and `bool` work too. Every control takes `label=`. `Number`, `Slider` and `Range` take `unit=`, shown after the value, like `"dB"`, `"Hz"` or `"%"`; a `Range` in Hz across decades moves on a log scale. `ox.Folder() | None = None` makes a control optional.

## Progress and Stop

Call `ox.progress(fraction, label)` often as the work moves: a fraction from 0 to 1, or `None` while you can't tell.

When the person presses **Stop**, the next `ox.progress` raises `ox.Cancelled`. To clean up, catch it and raise it again:

```python title="app.py"
import ox1audio as ox


@ox.action("Make copies")
def copies(files: ox.Files("audio")):
    made = []
    try:
        for i, path in enumerate(files):
            ox.progress(i / len(files), path.name)
            made.append(ox.sound(ox.audio.convert(path, "flac"), title=path.stem))
    except ox.Cancelled:
        print(f"Stopped after {len(made)} files")
        raise
    return made
```

Everything already made with `ox.sound`, `ox.midi` or `ox.file` is kept and reaches the Library, so Stop never throws away finished work. `ox.Cancelled` is a `BaseException`, so `except Exception` doesn't swallow it.

An action stops after 30 minutes unless you give it longer: `@ox.action("Separate", timeout=3600)`.

## Results

| Return | What happens |
| --- | --- |
| `ox.sound(path, title)`, `ox.midi(...)`, `ox.file(...)`, or a list of them | Added to the Library, and listed under the form |
| a `str` | Shown to the person, as written |
| a `dict` or `list` of plain values | Handed to your [page](https://docs.0x1audio.com/page.md) |
| `None` | Nothing to show |

Metadata goes after the title: `ox.sound(out, title="Loop", bpm=120, key="A minor")`. More in [The Library](https://docs.0x1audio.com/library.md).

For something the person can fix, raise `ox.Problem`. They see your words, and nothing goes to the log:

```python
raise ox.Problem("That file has no audio.", hint="Try a WAV or MP3.")
```

Any other exception shows "The app ran into a problem", and the details go to the log, which `mixel dev` prints.

## Events

A page can get updates while an action runs. `ox.emit(name, body)` sends one; the body is plain JSON, at most 256 KiB:

```python
ox.emit("partial", {"done": 3, "of": 10})
```

The page receives it in `ox.run(…, { onEvent })`. See [A custom page](https://docs.0x1audio.com/page.md#run).

## Helpers

Use these before reaching for a package.

| Need | Use |
| --- | --- |
| File info | `ox.audio.probe(path)` → `.rate .channels .duration .codec` |
| Another format | `ox.audio.convert(path, "wav")` → `Path` |
| Samples as numpy | `ox.audio.read(path)` and `ox.audio.write(samples, rate, "wav")` |
| A folder for new files | `ox.temp()` |
| Save next to the original | `folder=ox.beside(path)` (`None` for a Library file) |
| The app's own files | `ox.data.read / write / list / remove` |

More in [Working with audio](https://docs.0x1audio.com/audio.md).

## Dependencies

Python packages go in `pyproject.toml` › `[project].dependencies`. Then:

```sh
uv sync
```

That installs them into the app's `.venv`, which `mixel check`, `test` and `run` use, and writes `uv.lock`. Mixel installs exactly the versions the lock pins, so commit it: the registry refuses an app with packages and no lock. `mixel check --fix` adds a package the code imports but doesn't list, then runs `uv sync` for you. numpy, scipy and soundfile are fine. The SDK already ships ffmpeg, so don't add ffmpeg wrappers or pydub. Never commit models, songs, or files over 5 MB; a short test tone is fine.

## Testing

`ox.test` runs an action the way Mixel would and records what it made:

```python title="tests/test_app.py"
import unittest
from pathlib import Path

import ox1audio as ox

import app

TONE = Path(__file__).with_name("tone.wav")


class ConvertTest(unittest.TestCase):
    def test_converts_to_flac(self):
        run = ox.test(app.convert, files=[TONE], format="flac")
        self.assertEqual(run["items"][0]["title"], "tone")
        self.assertTrue(run["items"][0]["path"].endswith(".flac"))

    def test_stop_keeps_finished_files(self):
        with self.assertRaises(ox.Cancelled):
            ox.test(app.convert, files=[TONE, TONE], stop_at=0.5)
```

It returns `{"result", "items", "progress", "events"}`. `stop_at=0.5` presses Stop halfway. `ox.Problem` is raised as it is, so you can assert on it. Run the tests with `mixel test`.

## Never

- Touch files other than the ones the person chose, files next to them, `ox.temp()` and `ox.data`.
- Run programs (`subprocess`, `os.system`), open sockets, or send telemetry.
- Edit `AGENTS.md` or `dist/` by hand, or commit `.venv/` or `node_modules/`.

`mixel check` flags these, and review refuses them.
