Working with audio
Read and write files in any common format, get at the samples, and keep your own files, with nothing to install.
The SDK carries its own ffmpeg, so these helpers work the same on every computer Mixel runs on, and your app never runs programs itself.
Look at a file
info = ox.audio.probe(path)
info.rate, info.channels, info.duration, info.codec # 44100, 2, 183.4, "pcm_s16le"Change the format
ox.audio.convert(path, format) writes a new file and returns its Path. The formats are wav, aiff, flac, mp3, aac, ogg and opus.
import ox1audio as ox
@ox.action("Make FLAC copies")
def flac(files: ox.Files("audio")):
made = []
for i, path in enumerate(files):
ox.progress(i / len(files), path.name)
out = ox.audio.convert(path, "flac", on_progress=lambda f: ox.progress((i + f) / len(files), path.name))
made.append(ox.sound(out, title=path.stem))
return made- WAV, AIFF and FLAC keep the source's bit depth. Pass
bits=16or24to choose, orbits=32for float (WAV and AIFF). See Bit depth. - Tags, cover art, BPM, key and loop points come along wherever the new format can hold them. See What a copy keeps.
- The new file goes in a fresh temporary folder, or in
folder=when you give one:folder=ox.beside(path)puts it next to the original. It never replaces a file that's already there:song.wavbecomessong 2.wav. on_progressgets the fraction of this one file, so a long conversion still moves the bar and still stops when the person presses Stop.
Work with the samples
ox.audio.read(path) gives (samples, rate): a float32 numpy array shaped (frames, channels), from -1 to 1. ox.audio.write(samples, rate, format) writes it back and returns the Path. Add numpy to pyproject.toml (mixel check --fix does it when you import it).
import numpy as np
import ox1audio as ox
@ox.action("Reverse")
def reverse(file: ox.Files("audio", multiple=False), fade: ox.Slider(0, 200, 5, label="Fade (ms)") = 10):
samples, rate = ox.audio.read(file)
backwards = samples[::-1].copy()
n = min(int(rate * fade / 1000), len(backwards) // 2)
if n:
ramp = np.linspace(0, 1, n, dtype=np.float32)[:, None]
backwards[:n] *= ramp
backwards[-n:] *= ramp[::-1]
out = ox.audio.write(backwards, rate, "wav", name=f"{file.stem} reversed")
return ox.sound(out, title=f"{file.stem} reversed")WAV, AIFF and FLAC are written at 24-bit unless you pass bits=. A one-dimensional array is written as mono.
Levels
Samples run from -1 to 1, which is 0 dBFS. Gain, EQ, sums and separated stems often go over, and an integer or lossy file can't hold what's over: it would clip. So ox.audio.write refuses, and its error says by how much:
ValueError: ox.audio.write: the samples peak at +5.1 dBFS, over full scale, so 24-bit WAV would clip them.
fix: lower the gain by 5.2 dB (samples * 0.549), or write "wav" with bits=32 (float keeps overs)Lower the gain yourself, by the same amount on every channel and every stem that belongs together, or write bits=32: 32-bit float WAV or AIFF keeps overs exactly, and any DAW reads it. Samples with NaN or infinite values are refused too. A level of exactly 1.0 is fine.
Samples in the thousands are integers, not floats: scipy.io.wavfile and soundfile with dtype="int16" give 32767 for full scale. The error says so and names the number to divide by. ox.audio.read always gives floats.
Converting a float file that goes over. A 32-bit float WAV can hold samples over 0 dBFS, and a copy that isn't float can't. So convert turns the whole file down once, by one gain, until its true peak is −1 dBTP, and tells you. For WAV, AIFF and FLAC that happens only when samples go over 0 dBFS; for MP3, AAC, Ogg and Opus, when the true peak does, because their decoders overshoot between samples. It never limits or compresses. The returned path carries .lowered_db and a .note you can show the person:
out = ox.audio.convert(path, "mp3")
if out.note:
print(out.note) # Lowered 7 dB so it doesn't clip.Bit depth
bits= | WAV, AIFF | FLAC |
|---|---|---|
16 | 16-bit | 16-bit |
24 (the default for write) | 24-bit | 24-bit |
32 | 32-bit float | not possible: use WAV or AIFF |
Going to fewer bits adds TPDF dither, the standard way to keep quiet audio (fades, reverb tails) clean instead of distorted. It happens only when the depth goes down: samples that already fit the depth are written exactly, so reading a 16-bit file and writing it back at 16-bit gives the same samples. The dither is the same every run, so tests can compare outputs.
What a copy keeps
convert keeps the title, artist and other tags, and these, where the format can hold them:
| Cover | BPM | Key | Loop points | |
|---|---|---|---|---|
| WAV | yes | yes | yes | yes |
| AIFF | yes | yes | yes | yes |
| FLAC | yes | yes | yes | yes |
| MP3 | yes | yes | yes | no |
| AAC | yes | yes | no | no |
| Ogg, Opus | no | yes | yes | yes |
Every channel is kept too, in any layout (5.1, 7.1, hexagonal and the rest). MP3 and Opus hold two, so they get a stereo mix. FLAC, AAC and Ogg hold up to eight: a file with more is refused, with a message that says to choose WAV or AIFF.
Scratch files
ox.temp() gives an empty folder that's removed when the app closes. convert and write already use one when you don't pass folder=. Files you return with ox.sound are copied into the Library first, so they outlive it.
The app's own files
ox.data is a private folder for the app, kept between runs: presets, a downloaded model, a cache. Paths are relative, and they can't leave the folder. It needs the data permission.
import json
import ox1audio as ox
@ox.action("Save a preset")
def save(name: ox.Text(placeholder="Warm vocal") = "Default", gain: ox.Slider(-12, 12, 0.5, label="Gain (dB)") = 0.0):
ox.data.write(f"presets/{name}.json", json.dumps({"gain": gain}))
return f"Saved {name}. You have {len(ox.data.list('presets'))} presets."ox.data.read(path) returns bytes, read_text a string, list(folder) the names inside, and remove(path) deletes one. A page has the same calls as ox.data.*.
mixel run and ox.test don't give an app a data folder yet, so try code that uses ox.data with mixel dev.