voice: add voice catalog for Phonon

2 hours ago

Phonon, the text to speech model in Ollama, can now carry Gradium's whole voice catalog. You pick a voice by name, and Phonon speaks in it. Behind that one flag, ollama create imports every voice file the checkpoint ships, ollama show lists them, names match in any case, and leaving the voice out means default.

Ask about this presentation

Chapters

  1. 0:00Phonon imports Gradium's voice catalog
  2. 0:20One voice per checkpoint
  3. 0:42create reads the voices folder
  4. 1:04Each voice file is checked
  5. 1:27ollama show lists the voices
  6. 1:49Names match in any case
  7. 2:13No voice means default
  8. 2:36Aside: compact voice prompts
  9. 2:58Three tests
  10. 3:27Try it
Show transcript

Phonon imports Gradium's voice catalog

Ollama
createshowrun
speech

Phonon now imports Gradium's voice catalog

Every voice in the checkpoint, listed by ollama show, picked by name.

$ ollama run phonon --voice Zoey "Hi, this is Zoey."

Phonon, the text to speech model in Ollama, can now carry Gradium's whole voice catalog. You pick a voice by name, and Phonon speaks in it. Behind that one flag, ollama create imports every voice file the checkpoint ships, ollama show lists them, names match in any case, and leaving the voice out means default.

One voice per checkpoint

Ollama
createshowrun

Until now, create imported one voice

The default speaker, kept beside the model weights.

create/phonon.go

Checkpoint folder

model weights
default-voice.safetensors→voices.default

Until this change, importing a Phonon checkpoint read exactly one voice. The publisher keeps its default speaker beside the model weights, in a file named default voice, and create stored it as a single tensor called voices dot default. Gradium keeps its other voices in a voices folder next to that file, and the old import didn't read that folder.

create reads the voices folder

Ollama
createshowrun

Now every voices/NAME.safetensors becomes a voice

model weights
default-voice.safetensors→voices.default
voices/
Zoey.safetensors→voices.Zoey
Freya.safetensors→voices.Freya

Also changed

The voice files join the model's weight files, so they ship as blobs with it.

create/inventory.go

Now create reads that folder too. Each safetensors file in that folder becomes a voice named after the file, so the Zoey file is stored as voices dot Zoey, right next to the default. The voice files are added to the model's weight files as well, so their blobs travel with the model. A checkpoint without a voices folder imports exactly as it did before.

Each voice file is checked

Ollama
createshowrun

One helper checks every voice, default included

create/phonon.go · addPhononVoice

func addPhononVoice(inv Inventory, name, file string) error {
	// … open the voice file
	if names := reader.ListTensors(); !slices.Equal(names, []string{"speaker_wavs"}) {
		return fmt.Errorf("Phonon %s voice must contain only speaker_wavs, got %v", name, names)
	}
	// … read the speaker_wavs tensor
	if voice.Dtype != "F32" || len(voice.Shape) != 3 ||
		voice.Shape[0] != 1 || voice.Shape[1] != 512 || voice.Shape[2] <= 0 {
		return fmt.Errorf("unsupported Phonon %s voice: %s %v", name, voice.Dtype, voice.Shape)
	}
	if inv.Has("voices." + name) {
		return fmt.Errorf("Phonon checkpoint already defines voices.%s", name)
	}
	inv.Tensors["voices."+name] = SourceTensor{Name: "speaker_wavs", …, File: file}
	return nil
}

The old single-voice code became one helper, and every voice goes through it, the default included. The file must hold one tensor, called speaker wavs. That tensor must be 32-bit float, shaped one by 512 by any length. The name can't collide with a voice the checkpoint already defines. Only then is the voice stored, pointing back at its own file.

ollama show lists the voices

Ollama
createshowrun

ollama show lists a speech model's voices

The server reads the manifestserver/speech.go · speechVoices()
The show API returns them/api/show → "voices"
The CLI prints a tablecmd/cmd.go · showInfo()
$ ollama show phonon Model architecture phonon parameters 31M quantization F32 Capabilities speech Voices default Fairy Godmother Zoey

Expected output in cmd/cmd_test.go

Once the voices are in the model, ollama show lists them. This is the exact output the new test expects: a Voices table under Capabilities, with default, Fairy Godmother and Zoey. For speech models, the server builds that list from the manifest's voice layers, sorted without regard to case, and returns it as a new voices field in the show API.

Names match in any case

Ollama
createshowrun

Voice names match in any case, with or without accents

mlxrunner/model/phonon/model.go

voice() · foldVoiceName()

You ask for → you get

zoey
→
Zoey
DEFAULT
→
default
kaua
→
Kauã
KAUÃ
→
Kauã
alloy
→
400, lists the voices

The runner now finds a voice the way the publisher's own runtime does. An exact name wins. Otherwise both names are folded: decomposed, stripped of accent marks, and lowercased. So lowercase zoey finds Zoey, and a name typed without its accent still finds the accented voice. A name that matches nothing, like alloy, still returns a 400 error that lists every available voice.

No voice means default

Ollama
createshowrun

Leaving the voice out now always means default

Before

name = m.VoiceNames()[0]

The first name in sorted order

After

name = "default"

Asked for by name

Loading fails without it: model has no default voice

Before, a request without a voice took the first name in sorted order. With one voice, that was always default. In a catalog, byte order puts a capitalized name like Easton ahead of lowercase default, so the runner now asks for default by name. And a model now fails to load unless it has a default voice, so that request always has a voice to land on.

Aside: compact voice prompts

Ollama
createshowrun

Aside

Each loaded voice now keeps a compact copy of its prompt

mlxrunner/model/phonon/model.go · prefillVoice()

  m.Flow.forward(prompt, 0, caches)
+ // Copy out the prompt's frames so the
+ // cache's padded buffers are freed.
  var kv []*mlx.Array
  for _, c := range caches {
-   kv = append(kv, c.State()...)
+   for _, s := range c.State() {
+     kv = append(kv, mlx.Contiguous(s, false))
+   }
  }
  return kv

An aside on memory. When the model loads, the runner runs every voice through the flow model once and keeps the resulting cache state. That state is now copied out contiguously, and the new comment says why: so the cache's padded buffers are freed. A catalog means one of these prefilled prompts per voice, held for as long as the model stays loaded.

Three tests

Ollama
createshowrun

Three tests cover import, listing and lookup

ImportA checkpoint with a Zoey voice file stores both voices.default and voices.Zoeycreate/phonon_test.go
Listingollama show prints the exact Voices tablecmd/cmd_test.go
LookupSeven names, including KAUÃ and alloy, resolve to the right voice or nonephonon/model_test.go

The test voices hold no real speaker data, so these tests check names and wiring only.

The evidence is a test at each layer. The import test builds a checkpoint with a Zoey voice file and checks that both voices land in the model as blobs. The show test pins the exact Voices table. And the lookup test runs seven names through the runner, including an accented one and one that doesn't exist. The test voices are placeholders with no real speaker data, so these tests check names and wiring, not how a voice sounds. The pull request includes no audio comparison.

Try it

Ollama
createshowrun

Phonon now imports Gradium's voice catalog

Voices come from the checkpoint's voices/ folder. Without one, a model has only default.

$ ollama show phonon
$ ollama run phonon --voice zoey "Hi, this is Zoey."

docs/capabilities/speech.mdx · docs/cli.mdx · docs/api/openai-compatibility.mdx

One limit to keep in mind. The voices come from the checkpoint, so a model created without a voices folder still has exactly one voice, default. To see what a model carries, run ollama show, then pass any listed name to the voice flag, in whatever case you type it. The speech guide, the CLI page and the OpenAI compatibility page all say the same now. Phonon now imports Gradium's voice catalog.

What happened in AI today, in your inbox

One short email each evening. Read it and you’re caught up.

Built in New York with ❤️