Melange
Tutorials

Supertonic Text-to-Speech

Run the four-stage Supertonic 3 text-to-speech pipeline on Android and iOS with ZETIC Melange.

This guide shows how to run Supertonic 3 text-to-speech on a physical Android or iOS device with ZETIC Melange. Melange loads the four model stages and selects the on-device runtime; your app prepares the Supertonic inputs, runs the stages in order, and writes the output samples as a WAV file.

This guide targets ZeticMLange Android 1.9.0 and ZeticMLange iOS 1.9.0. The examples use the Supertonic 3 models validated by ZETIC with eight denoising steps and 44.1 kHz output.

How the pipeline works

StageMelange modelInputsOutput
Duration predictorpalm/supertonic3-dp, version 1Text IDs, duration style, text maskPredicted duration
Text encoderpalm/supertonic3-te, version 1Text IDs, text-to-latent style, text maskText embedding
Vector estimatorpalm/supertonic3-ve, version 1Noisy latent, text embedding, style, latent mask, text mask, current step, total stepsDenoised latent
Vocoderpalm/supertonic3-vo, version 1Denoised latentFloat32 audio samples

The vector estimator output becomes its next input for eight iterations. The vocoder then converts the final latent tensor into audio.

Prerequisites

  • Complete the Android setup or iOS setup.
  • Use a physical device. Melange inference requires device acceleration that is not available in a simulator or emulator.
  • Get a Personal Key from the Melange Dashboard. Inject it through local or CI configuration; never commit it to source control.
  • Download unicode_indexer.json and the M1.json voice style from the official Supertonic 3 model repository. Review the model license before distribution.

The Supertonic JSON assets are not included in the Melange SDK. Bundle your approved copies with the application. Do not put your Personal Key in this asset directory.

Add the assets

The helper code in this guide loads both files from a supertonic resource directory.

Place the files under the application target:

app/src/main/assets/supertonic/M1.json
app/src/main/assets/supertonic/unicode_indexer.json

Load them through context.assets.open("supertonic/<file-name>").

Add a supertonic folder containing both files to the application target:

supertonic/M1.json
supertonic/unicode_indexer.json

Preserve the folder as a bundle resource. Verify that this lookup succeeds for each file:

Bundle.main.url(
  forResource: "unicode_indexer",
  withExtension: "json",
  subdirectory: "supertonic"
)

unicode_indexer.json must contain 65,536 integer entries. M1.json must contain style_dp with shape [1, 8, 16] and style_ttl with shape [1, 50, 256].

Configure the four models

Use fixed version 1 for every stage so that all four deployed models share the validated tensor contract.

import android.content.Context
import com.zeticai.mlange.core.model.ModelMode
import com.zeticai.mlange.core.model.ZeticMLangeModel

private fun loadSupertonicModels(
    context: Context,
    personalKey: String,
): List<ZeticMLangeModel> {
    val models = mutableListOf<ZeticMLangeModel>()
    try {
        listOf(
            "palm/supertonic3-dp",
            "palm/supertonic3-te",
            "palm/supertonic3-ve",
            "palm/supertonic3-vo",
        ).forEach { name ->
            models += ZeticMLangeModel(
                context = context.applicationContext,
                personalKey = personalKey,
                name = name,
                version = 1,
                modelMode = ModelMode.RUN_AUTO,
            )
        }
        return models
    } catch (error: Exception) {
        models.forEach(ZeticMLangeModel::close)
        throw error
    }
}
import ZeticMLange

private func loadSupertonicModels(
  personalKey: String
) async throws -> [ZeticMLangeModel] {
  let names = [
    "palm/supertonic3-dp",
    "palm/supertonic3-te",
    "palm/supertonic3-ve",
    "palm/supertonic3-vo"
  ]

  var models: [ZeticMLangeModel] = []
  do {
    for name in names {
      models.append(try await ZeticMLangeModel(
        personalKey: personalKey,
        name: name,
        version: 1,
        modelMode: .RUN_AUTO
      ))
    }
    return models
  } catch {
    models.forEach { $0.close() }
    throw error
  }
}

Model initialization can download artifacts on first use. Keep it off the main thread and close any models that were already created if a later stage fails to initialize.

Prepare the tensors

Use these constants for the validated deployment:

sample rate:          44,100 Hz
text bucket:          128
latent dimension:     144
latent chunk size:    512 × 6 = 3,072 samples
denoising steps:      8
speech speed:         1.05
language tag:         en

The platform helper layer must do the following before inference:

  1. Normalize the input text and wrap it as <en>text</en>.
  2. Map each Unicode scalar through unicode_indexer.json, pad the IDs to 128 elements, and create a [1, 1, 128] Float32 text mask.
  3. Reject input whose normalized, language-wrapped representation exceeds 128 Unicode scalars. Split longer text at sentence boundaries and synthesize each chunk separately.
  4. Decode and flatten style_dp and style_ttl from M1.json.
  5. Compute validLength = clamp(ceil((duration / 1.05 × 44,100) / 3,072), 1, 128). Fill the valid frames of a [1, 144, 128] latent tensor with Gaussian noise and create a [1, 1, 128] latent mask.

The validated Android deployment may expose the text-ID input as Int32 or Int64. Check the first input buffer size independently for the duration predictor and text encoder, then create a [1, 128] tensor with the matching integer type. The validated iOS deployment uses Int32 IDs.

For the preprocessing algorithm and WAV encoding details, refer to Supertonic's public Java helper and iOS example. Use the constants and model input order in this guide when running the Melange-hosted models.

Run the stages

The following excerpts show the Melange-specific orchestration. They assume your helper layer provides:

  • tokenizer.encode(text) → padded IDs and text mask
  • style.dp and style.ttl → flattened Float32 style arrays
  • sampleNoise(duration) → noisy latent, latent mask, and valid latent length
  • tensor conversion and Float32 output decoding helpers
  • a 16-bit mono WAV writer
private fun runSupertonic(
    models: List<ZeticMLangeModel>,
    text: String,
): FloatArray {
    require(models.size == 4)
    val (dp, te, ve, vo) = models
    val encoded = tokenizer.encode(text)

    val mask = Tensor.of(encoded.mask, shape = intArrayOf(1, 1, 128))
    val styleDp = Tensor.of(style.dp, shape = intArrayOf(1, 8, 16))
    val styleTtl = Tensor.of(style.ttl, shape = intArrayOf(1, 50, 256))

    val idsForDp = textIdsTensorFor(dp, encoded.ids)
    val durationTensor = dp.run(arrayOf(idsForDp, styleDp, mask)).firstOrNull()
        ?: throw SupertonicException("Duration predictor returned no output")
    val duration = durationTensor.toFloatArray().firstOrNull()
        ?: throw SupertonicException("Duration predictor output is empty")

    val idsForTe = textIdsTensorFor(te, encoded.ids)
    val textEmbedding = te.run(arrayOf(idsForTe, styleTtl, mask)).firstOrNull()
        ?: throw SupertonicException("Text encoder returned no output")

    val noise = sampleNoise(duration)
    var noisy = Tensor.of(noise.values, shape = intArrayOf(1, 144, 128))
    val latentMask = Tensor.of(noise.mask, shape = intArrayOf(1, 1, 128))
    val totalSteps = Tensor.of(floatArrayOf(8f), shape = intArrayOf(1))

    repeat(8) { step ->
        val currentStep = Tensor.of(floatArrayOf(step.toFloat()), shape = intArrayOf(1))
        noisy = ve.run(
            arrayOf(
                noisy,
                textEmbedding,
                styleTtl,
                latentMask,
                mask,
                currentStep,
                totalSteps,
            ),
        ).firstOrNull()
            ?: throw SupertonicException("Vector estimator returned no output")
    }

    val wavTensor = vo.run(arrayOf(noisy)).firstOrNull()
        ?: throw SupertonicException("Vocoder returned no output")
    val wav = wavTensor.toFloatArray()
    val validSamples = minOf(wav.size, noise.validLength * 3_072)
    if (validSamples <= 0) {
        throw SupertonicException("Vocoder output is empty")
    }
    return wav.copyOf(validSamples)
}
private func runSupertonic(
  models: [ZeticMLangeModel],
  text: String
) throws -> [Float] {
  guard models.count == 4 else { throw SupertonicError.invalidModelCount }
  let dp = models[0]
  let te = models[1]
  let ve = models[2]
  let vo = models[3]
  let encoded = try tokenizer.encode(text)

  let ids = Tensor.int32(encoded.ids, shape: [1, 128])
  let mask = Tensor.float32(encoded.mask, shape: [1, 1, 128])
  let styleDP = Tensor.float32(style.dp, shape: [1, 8, 16])
  let styleTTL = Tensor.float32(style.ttl, shape: [1, 50, 256])

  let durationOutput = try dp.run(inputs: [ids, styleDP, mask])
  guard let duration = try durationOutput.first?.floatArray().first else {
    throw SupertonicError.emptyOutput
  }

  guard let textEmbedding = try te.run(inputs: [ids, styleTTL, mask]).first else {
    throw SupertonicError.emptyOutput
  }

  let noise = sampleNoise(duration: duration)
  var noisy = Tensor.float32(noise.values, shape: [1, 144, 128])
  let latentMask = Tensor.float32(noise.mask, shape: [1, 1, 128])
  let totalSteps = Tensor.float32([8], shape: [1])

  for step in 0..<8 {
    let currentStep = Tensor.float32([Float(step)], shape: [1])
    guard let denoised = try ve.run(inputs: [
      noisy,
      textEmbedding,
      styleTTL,
      latentMask,
      mask,
      currentStep,
      totalSteps
    ]).first else {
      throw SupertonicError.emptyOutput
    }
    noisy = denoised
  }

  guard let wavTensor = try vo.run(inputs: [noisy]).first else {
    throw SupertonicError.emptyOutput
  }
  let wav = try wavTensor.floatArray()
  let validSamples = min(wav.count, noise.validLength * 3_072)
  guard validSamples > 0 else { throw SupertonicError.emptyOutput }
  return Array(wav.prefix(validSamples))
}

These are orchestration excerpts, not standalone files. Keep bounds, tensor type, asset-shape, and empty-output validation in your helper implementation instead of replacing those checks with force unwraps.

Run without blocking the UI

Create, run, and close all four models on a worker thread. The one-shot pattern below favors predictable cleanup. For repeated synthesis, you may keep a pipeline alive and reuse it, but serialize access and close it when its owner is destroyed.

suspend fun synthesize(
    context: Context,
    personalKey: String,
    text: String,
): File = withContext(Dispatchers.IO) {
    val models = loadSupertonicModels(context, personalKey)
    try {
        val samples = runSupertonic(models, text)
        WavWriter.write(
            directory = context.filesDir,
            samples = samples,
            sampleRate = 44_100,
            fileName = "supertonic-result.wav",
        )
    } finally {
        models.forEach(ZeticMLangeModel::close)
    }
}
func synthesize(
  personalKey: String,
  text: String
) async throws -> URL {
  try await Task.detached(priority: .userInitiated) {
    let models = try await loadSupertonicModels(personalKey: personalKey)
    defer { models.forEach { $0.close() } }

    let samples = try runSupertonic(models: models, text: text)
    return try WavWriter.write(
      samples: samples,
      sampleRate: 44_100,
      fileName: "supertonic-result.wav"
    )
  }.value
}

Update UI state or start audio playback only after returning to the main actor/thread.

Troubleshooting

SymptomCheck
A model fails to loadVerify the Personal Key, exact model name, version 1, network access for the first download, and physical-device support.
M1.json or unicode_indexer.json is missingConfirm the files are included in the built application under the supertonic resource directory.
Input is longer than the bucketSplit text so each normalized, language-wrapped chunk is at most 128 Unicode scalars.
An input size or type does not matchRecheck the shapes and stage input order above. On Android, also select Int32 or Int64 text IDs from each model's first input buffer size.
The app freezes while loading or synthesizingMove model construction, inference, and WAV file I/O off the main/UI thread.
Audio is empty or distortedDecode the vocoder output as Float32, trim it to validLength × 3,072, clamp samples to [-1, 1], and encode mono PCM16 at 44.1 kHz.

On this page