Skip to content

Latest commit

 

History

History
235 lines (192 loc) · 6.91 KB

File metadata and controls

235 lines (192 loc) · 6.91 KB

Agent Integration

Utter's stable integration boundary is the utter command. Install it once as a user-scoped tool, download the model once, and call it from any project that can launch a subprocess. The caller does not need Python or MLX dependencies in its own environment.

The Python distribution is named utter-mlx because the name utter is owned by an unrelated project on PyPI. The installed executable remains utter.

Install

Until a registry release is published, install from Git:

uv tool install --python 3.12 \
  "utter-mlx @ git+https://github.com/maskedsyntax/utter.git@master"
uv tool update-shell

For reproducible automation, replace master with a release tag or commit SHA. Restart the shell after uv tool update-shell, then verify and install the pinned model:

utter --version
utter models pull
utter models status --json

The model is stored in ~/Library/Caches/utter/. All projects and agents under the same macOS user share that installation.

For local Utter development, install the current checkout in editable mode:

uv tool install --force --python 3.12 --editable \
  /absolute/path/to/utter

Reinstall a newer Git revision with:

uv tool install --force --python 3.12 \
  "utter-mlx @ git+https://github.com/maskedsyntax/utter.git@NEW_REVISION"

Agent Contract

Agents should use --json, parse stdout as exactly one JSON document, and keep stderr for progress or diagnostics. Successful synthesis returns an absolute output path. Failures return nonzero status and, when --json is active, a stable symbolic error in stdout.

Before synthesis:

  1. Run utter models status --json and require model.ready to be true.
  2. Run utter voices --json when voice selection is not already known.
  3. Create the intended output directory.
  4. Use an absolute .wav output path inside the current project.
  5. Do not use --force unless replacing an existing file was requested.

Example:

mkdir -p "$PWD/artifacts/audio"
utter speak "Your build completed successfully." \
  --voice Ryan \
  --language en \
  --output "$PWD/artifacts/audio/build-complete.wav" \
  --json

For multiline or dynamically supplied text, avoid shell interpolation and use stdin:

printf '%s' "$TEXT" | utter speak \
  --stdin \
  --voice Aiden \
  --style "Speak calmly and warmly." \
  --output "$PWD/artifacts/audio/message.wav" \
  --json

Exit Codes

Code Meaning
0 success
1 unexpected runtime failure
2 invalid input or command arguments
3 model missing, incomplete, or pull failure
4 invalid voice or language
5 synthesis or waveform failure
6 output path or write failure
130 interrupted with Ctrl+C

Copy-Ready AGENTS.md Section

Add this to a consuming project's AGENTS.md or equivalent instructions:

## Local Text-to-Speech

Use the globally installed `utter` command for local speech generation.

- Run `utter models status --json` before synthesis and require
  `model.ready == true`.
- Run `utter voices --json` to discover exact, case-sensitive voice IDs.
- Put generated files under `artifacts/audio/` unless the task specifies
  another project-local directory.
- Create the parent directory before invoking Utter.
- Always pass an absolute `.wav` output path and `--json`.
- Parse stdout as JSON. Treat stderr as progress and diagnostics.
- Do not use `--force` unless replacement was explicitly requested.
- Do not run `utter models pull` automatically unless model installation was
  requested; it downloads approximately 3.1 GB.
- Prefer `--stdin` for user-provided, multiline, or shell-sensitive text.

Example:

```bash
mkdir -p "$PWD/artifacts/audio"
utter speak "Text to synthesize." \
  --voice Ryan \
  --language en \
  --output "$PWD/artifacts/audio/output.wav" \
  --json

## Python Subprocess Example

This example has no Utter Python dependency. It requires only the globally
installed command. A runnable copy is available at
`examples/python_subprocess.py`:

```python
import json
import subprocess
from pathlib import Path


def synthesize(text: str, output: Path, voice: str = "Ryan") -> Path:
    output = output.expanduser().resolve()
    output.parent.mkdir(parents=True, exist_ok=True)
    result = subprocess.run(
        [
            "utter",
            "speak",
            "--stdin",
            "--voice",
            voice,
            "--language",
            "en",
            "--output",
            str(output),
            "--json",
        ],
        input=text,
        text=True,
        capture_output=True,
        check=False,
    )
    payload = json.loads(result.stdout)
    if result.returncode != 0:
        error = payload.get("error", {})
        raise RuntimeError(
            f"Utter failed ({error.get('code')}): {error.get('message')}\n"
            f"{result.stderr.strip()}"
        )
    return Path(payload["output"])

Node.js Subprocess Example

A reusable ES module is available at examples/node_subprocess.mjs.

import { mkdir } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { spawn } from "node:child_process";

export async function synthesize(text, output, voice = "Ryan") {
  const absoluteOutput = resolve(output);
  await mkdir(dirname(absoluteOutput), { recursive: true });

  return await new Promise((resolvePromise, reject) => {
    const child = spawn("utter", [
      "speak", "--stdin", "--voice", voice, "--language", "en",
      "--output", absoluteOutput, "--json",
    ]);
    let stdout = "";
    let stderr = "";
    child.stdout.setEncoding("utf8").on("data", chunk => stdout += chunk);
    child.stderr.setEncoding("utf8").on("data", chunk => stderr += chunk);
    child.on("error", reject);
    child.on("close", code => {
      let payload;
      try {
        payload = JSON.parse(stdout);
      } catch (error) {
        reject(new Error(`Invalid Utter response: ${stdout}\n${stderr}`));
        return;
      }
      if (code !== 0) {
        reject(new Error(
          `Utter failed (${payload.error?.code}): ` +
          `${payload.error?.message}\n${stderr.trim()}`
        ));
        return;
      }
      resolvePromise(payload.output);
    });
    child.stdin.end(text, "utf8");
  });
}

When MCP Is Worth Adding

CLI subprocess execution should remain the default. Add an optional local MCP stdio server only when at least one of these is true:

  • the target host cannot run shell commands;
  • typed tool discovery materially improves the agent workflow;
  • repeated requests need a warm resident model;
  • the host needs WAV bytes returned as MCP audio content.

An MCP implementation should expose list_voices, model_status, and synthesize_speech, call the same internal Utter service as the CLI, restrict file output to a configured project root, and keep model installation as an explicit administrative CLI action. It should use local stdio transport rather than open an HTTP port.