LA2L for developers

Android

An Android app sends with the Android SDK, or with five HTTPS calls of its own (below, in Kotlin), or shares its WAV files with LA2L Sender without any network code: see Sharing.

The Android SDK is nl.lloydstellar:la2l for Java, without dependencies, and nl.lloydstellar:la2l-ktx for Kotlin, for Android 7.0 (API 24) and newer. They are in LA2L's own Maven repository:

// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven("https://lloydstellar.nl/la2l/maven/")
    }
}

// app/build.gradle.kts
dependencies {
    implementation("nl.lloydstellar:la2l-ktx:0.1.0") // or nl.lloydstellar:la2l:0.1.0 from Java
}

Every file in it is signed with the key at https://lloydstellar.nl/la2l/maven/signing-key.asc. Sending, in Kotlin:

import nl.lloydstellar.la2l.*
import nl.lloydstellar.la2l.ktx.* // pair, send, awaitImport as suspend functions

val la2l = La2l(context, "Web808") // Live shows "Paired with Web808"
if (!la2l.isPaired) la2l.pair(codeFromUser) // "K7P 42Q", from LA2L in Live

val delivery = la2l.send(title = "Treinbeat", bpm = 92.0) {
    sound("Kick", Wav.fromPcm16(kickSamples, 48000, 2), kind = Kind.LOOP, role = Role.DRUMS)
    sound("Robot hallo", robotWavFile, kind = Kind.ONESHOT, role = Role.VOCAL)
}
delivery.awaitImport() // the user clicked Import in Live

From Java, every call has a variant that reports to a Callback on the main thread, and one that returns a CompletableFuture:

La2l la2l = new La2l(context, "Web808");
la2l.send(new SoundPackage("Treinbeat").bpm(92).add(Sound.loop("Kick", kickWav).role(Role.DRUMS)), new Callback<Delivery>() {
    @Override public void onSuccess(Delivery delivery) { status.setText("Waiting for Import in Live"); }
    @Override public void onError(La2lException error) { status.setText(error.getMessage()); }
});

Without the SDK

For an app that wants no dependency: the same five HTTPS calls, in Kotlin with nothing but the Android platform: HttpURLConnection, org.json and MessageDigest. Run them off the main thread.

1. A WAV file

From 16-bit PCM, as AudioRecord gives it or as your audio engine renders it:

fun wav16(pcm: ShortArray, sampleRate: Int, channels: Int): ByteArray {
    val data = pcm.size * 2
    val b = java.nio.ByteBuffer.allocate(44 + data).order(java.nio.ByteOrder.LITTLE_ENDIAN)
    b.put("RIFF".toByteArray()).putInt(36 + data).put("WAVE".toByteArray())
    b.put("fmt ".toByteArray()).putInt(16).putShort(1).putShort(channels.toShort())
    b.putInt(sampleRate).putInt(sampleRate * channels * 2).putShort((channels * 2).toShort()).putShort(16)
    b.put("data".toByteArray()).putInt(data)
    pcm.forEach { b.putShort(it) }
    return b.array()
}

fun sha256Hex(bytes: ByteArray): String =
    java.security.MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }

A loop must be a whole number of beats long: sampleRate * 60 / bpm * beats frames. An m4a or mp3 must be decoded first (MediaCodec): LA2L takes WAV only.

2. The calls

const val BASE = "https://lloydstellar.nl/la2l/api"

class Answer(val status: Int, val json: org.json.JSONObject, val retryAfter: Int?) {
    fun error(fallback: String) = java.io.IOException(json.optString("error").ifEmpty { fallback })
}

fun call(method: String, path: String, token: String?, body: ByteArray?, type: String = "application/json"): Answer {
    val c = java.net.URL(BASE + path).openConnection() as java.net.HttpURLConnection
    c.connectTimeout = 15_000
    c.readTimeout = 60_000
    c.requestMethod = method
    c.setRequestProperty("X-LA2L-Client", "my-app/1.0 (My app)")
    if (token != null) c.setRequestProperty("Authorization", "Bearer $token")
    try {
        if (body != null) {
            c.doOutput = true
            c.setRequestProperty("Content-Type", type)
            c.setFixedLengthStreamingMode(body.size)
            c.outputStream.use { it.write(body) }
        }
        val status = c.responseCode
        val text = (if (status < 400) c.inputStream else c.errorStream)?.bufferedReader()?.use { it.readText() } ?: ""
        // An answer that is not JSON, such as an error page of a proxy, counts as an empty one.
        val json = try { org.json.JSONObject(text) } catch (e: org.json.JSONException) { org.json.JSONObject() }
        return Answer(status, json, c.getHeaderField("Retry-After")?.toIntOrNull())
    } finally {
        c.disconnect()
    }
}

fun pair(code: String, installId: String): String {
    val body = org.json.JSONObject().put("code", code).put("app_name", "My app").put("app_id", installId)
    val a = call("POST", "/pair/claim", null, body.toString().toByteArray())
    if (a.status != 200) throw a.error("Pairing failed.")
    return a.json.getString("app_token") // keep it in SharedPreferences, Context.MODE_PRIVATE
}

fun send(token: String, title: String, bpm: Double, name: String, wav: ByteArray): String {
    val item = org.json.JSONObject().put("file", "sound.wav").put("name", name).put("kind", "loop")
        .put("role", "drums").put("bytes", wav.size).put("sha256", sha256Hex(wav))
    val manifest = org.json.JSONObject().put("schema", "app2live/1").put("title", title)
        .put("source", org.json.JSONObject().put("app", "My app").put("platform", "android"))
        .put("tempo", org.json.JSONObject().put("bpm", bpm))
        .put("items", org.json.JSONArray().put(item))
    val created = call("POST", "/packages", token, manifest.toString().toByteArray())
    if (created.status != 201) throw created.error("Sending failed.")
    val id = created.json.getString("package_id")
    val put = call("PUT", created.json.getJSONObject("upload").getString("sound.wav"), token, wav, "audio/wav")
    if (put.status != 200) throw put.error("The upload failed.")
    val commit = call("POST", "/packages/$id/commit", token, "{}".toByteArray())
    if (commit.status != 200) throw commit.error("Sending failed.")
    return id // then GET /packages/$id/status every few seconds until "imported"
}

3. Following it

GET /packages/{id}/status says waiting until the user clicks Import in Live, then imported, partial (with the result per sound), failed or expired. While your app is open, ask every few seconds and show "Waiting for Import in Live".

A large upload that must go on when the user leaves the app belongs in a WorkManager job.

Every call and field: The REST API and The package description.