# Tuning

Source: https://heyparlour.app/docs/tuning

> Adjust wake sensitivity, timing, voice and models, and fix what feels wrong.

Everything on this page is a key in `~/.config/parlour/config.json`.
`parlour config edit` opens the file. `parlour config show` prints what is in
force, with the defaults filled in. `parlour restart` (or a restart from the
app) picks up your changes.

For the wake word, the voice, the microphone and the local model, running
`parlour init` again is often quicker. Every question starts at what is set
now, Escape goes back one question, and a review at the end lets you change
any part before anything is downloaded or written.

## When something is not quite right

| What you notice | What to try |
| --- | --- |
| It wakes up for the television | Raise `wake.threshold` towards 0.7. |
| It never wakes up | Lower `wake.threshold` towards 0.4, and check that `audio.inputDevice` is the microphone you think it is. |
| It cuts you off mid-sentence | Raise `audio.silenceMs`, or lower `audio.silenceThreshold`. |
| It waits too long before answering | Lower `audio.silenceMs` to about 600. |
| It gives up before you start talking | You have 2.5 s after the wake word to begin. Try saying the wake word and the request in one breath. |
| It answers slowly | Try a smaller local model (`parlour models suggest` lists them), or a smaller whisper model: `parlour models fetch --whisper ggml-base.en.bin`, then `parlour service install` to rewrite the whisper service. It loads the first model file in `~/Library/Caches/parlour/models/whisper/`, and `base` sorts before `small`. |
| It asks the cloud too often, or not often enough | By default the local model decides, guided by its prompt (`packages/parlour/src/core/prompt.ts`). For a threshold you can tune, add the [decision model](#decision-model-optional). |
| It says nothing at all | Run `parlour doctor`, or press Check in the app. It names the part that is down. |
| You cannot interrupt it | `audio.bargeIn` lets the wake word cut a reply short. It is off by default, because with one box in one room the microphone hears the speaker. |
| It fires twice on one wake word | Raise `wake.refractoryMs`. |
| The voice is not the one you wanted | Set `tts.voice` and `tts.speed`. Kokoro downloads itself the first time Parlour starts after an install, so that first start is slow and the fallback voice may speak until it is ready. |
| It can see the lights but not the blinds | Expose the blinds. Home Assistant only publishes what is exposed under Settings > Voice assistants > Expose. |

## Audio

```json
{
  "audio": {
    "inputDevice": ":0",
    "silenceMs": 800,
    "maxUtteranceMs": 15000,
    "silenceThreshold": 0.012,
    "bargeIn": false
  }
}
```

- **`inputDevice`** is an avfoundation index, and `":0"` is the default
  microphone. `ffmpeg -f avfoundation -list_devices true -i ""` prints the
  list, and `parlour init` shows it to you when it asks.
- **`silenceMs`** is how much quiet ends a request once you have started
  talking. **`silenceThreshold`** is the RMS level, from 0 to 1, below which a
  frame counts as silence. Raise it for a noisy room. Lower it for a quiet
  speaker.
- **`maxUtteranceMs`** caps a single request. It guards against a microphone
  that never goes quiet. It is not a way to allow long questions.

## The wake word

```json
{ "wake": { "provider": "openwakeword", "words": ["hey_jarvis"], "threshold": 0.5, "refractoryMs": 1500 } }
```

The stock words are `hey_jarvis`, `alexa` and `hey_mycroft`.
`parlour models fetch --wake hey_jarvis,alexa` fetches the ones you name into
`~/Library/Caches/parlour/models/openwakeword/`. Any openWakeWord model works.
Drop `<word>.onnx` into that directory and add `<word>` to `words`. You can
have more than one word live at once, for a small cost in CPU. To train your
own word, see [Your own wake word](/docs/wake-word).

## The voice

```json
{ "tts": { "provider": "kokoro", "voice": "bf_emma", "speed": 1.0, "fallback": "macos-say" } }
```

`parlour init` offers four British voices: `bf_emma` (the default),
`bf_isabella`, `bm_george` and `bm_lewis`. Kokoro has more, among them
`bf_alice`, `bf_lily`, `bm_daniel` and `bm_fable`, and American voices whose
ids start `af_` and `am_`, such as `af_heart`. If you name a voice Kokoro does
not have, the reply falls through to the fallback and the log lists every
voice Kokoro knows.

`fallback` names the voice used when the first one fails (`null` turns the
fallback off). `macos-say` ignores a Kokoro voice id and uses the system
voice. You can also give it a macOS voice name of its own (`"voice": "Daniel"`
when `provider` is `macos-say`).

## Speech to text

whisper.cpp is the default, kept warm as a service. On macOS 26 you can use
Apple's own on-device recogniser instead, through the
[yap](https://github.com/finnvoor/yap) command line tool: nothing to download
and no server to run.

```sh
brew install yap
```

```json
{ "stt": { "provider": "yap", "locale": "en-GB" } }
```

`locale` is optional and defaults to the Mac's own. `parlour init` installs
yap for you once the config names it. The whisper service stays installed
until you remove it with `parlour service uninstall`. If launchd rather than
the app runs the agent, follow it with `parlour service install`, which puts
back only what the config still wants. `parlour doctor` checks yap is found.

On Apple silicon there is also NVIDIA's
[Parakeet](https://huggingface.co/mlx-community/parakeet-tdt-0.6b-v2), through
[parakeet-mlx](https://github.com/senstella/parakeet-mlx). It is fast and
accurate for English and uses about 2 GB of memory.

```sh
pip install parakeet-mlx
```

```json
{ "stt": { "provider": "parakeet-mlx" } }
```

Parlour keeps the model loaded in a Python worker that starts on the first
utterance, so that one waits for the load (and, the first time, the download
into the Hugging Face cache). `model` defaults to
`mlx-community/parakeet-tdt-0.6b-v2`; `mlx-community/parakeet-tdt-0.6b-v3`
trades a little English for about 25 languages. `python` defaults to
`python3`; point it at a venv's own Python if parakeet-mlx lives there, for
example `~/.local/share/uv/tools/parakeet-mlx/bin/python` after
`uv tool install parakeet-mlx`. `parlour doctor` checks that Python can import
it and that ffmpeg is found.

## The models

This is what `parlour init` writes when you let Parlour run the local model,
on a Mac with 16 GB, with the defaults filled in:

```json
{
  "llm": {
    "local": { "provider": "openai-compatible", "managed": true, "baseUrl": "http://127.0.0.1:8920/v1", "model": "qwen3.5-9b", "temperature": 0.3, "timeoutMs": 30000 },
    "cloud": { "provider": "anthropic", "enabled": true, "model": "claude-opus-5", "maxTokens": 1024, "onLocalFailure": true },
    "maxToolRounds": 6
  }
}
```

The catalogue Parlour can run, and how much memory it suggests each for:

| Model id | Download | Suggested from |
| --- | --- | --- |
| `qwen3.5-4b` | 2.7 GB | 8 GB |
| `qwen3.5-9b` | 5.7 GB | 16 GB |
| `gemma-4-26b-a4b` | 17 GB | 32 GB |
| `qwen3.6-35b-a3b` | 20.4 GB | 64 GB |

An Intel Mac runs the model on the CPU, so it is counted as having half its
memory and is suggested a smaller model.

- **`local.managed`** means Parlour runs the model itself: llama.cpp from
  Homebrew and a GGUF from its own catalogue, kept warm by launchd on port
  8920, just like whisper. At a terminal, `init` offers this first and
  suggests the largest model in the catalogue above that the Mac can hold.
  `parlour models suggest` prints the same list with this Mac's pick marked.
  To switch, run `init` again and pick another: it downloads the file and
  points the service at it. By hand, that is `parlour models fetch --llm <id>`,
  `local.model` set to the id, and `parlour service install`. For a script,
  `parlour init --local-model auto|none|<id>` answers the question without a
  terminal.
- **Without `managed`**, Parlour talks to whatever server `baseUrl` names and
  starts none of its own. The provider's defaults are LM Studio's:
  `http://127.0.0.1:1234/v1` and `qwen3-8b-mlx`. That is also what
  `init --yes`, or `init` with no terminal, writes on a fresh setup: a
  download of gigabytes is said yes to, never assumed.
- **`local.model`** is the model id exactly as the server reports it.
  `parlour doctor` lists what is being served next to what is configured.
  A mismatch there is common after loading a different model in LM Studio.
  A managed server answers to the catalogue id, so the two only differ when
  the named model was never fetched.
- **`local.timeoutMs`** applies to each completion. A model that often runs
  past it is too big for the machine. With `cloud.onLocalFailure` on, every
  timeout becomes a cloud answer, which is slower and not private.
- **`cloud.enabled: false`**, or no `ANTHROPIC_API_KEY`, keeps everything
  local. The escalation tool is not offered, and the persona stops telling the
  model to hand over. The local model answers everything itself, with the
  house tools still available, including questions it probably should not.
- **`maxToolRounds`** stops a model that keeps calling tools. Six is enough
  for "turn the kitchen and hall lights off and set a timer".
- A hosted OpenAI-compatible endpoint also works as the local model. Add
  `"apiKeyEnv": "SOME_NAME"` naming a variable in `secrets.env`.

### Through the AI SDK instead

You can fill either slot with `"provider": "ai-sdk"`. It reaches the same two
back ends through Vercel's AI SDK instead of Parlour's own clients:

```json
{
  "llm": {
    "local": { "provider": "ai-sdk", "backend": "openai-compatible", "baseUrl": "http://127.0.0.1:1234/v1", "model": "qwen3-8b-mlx" },
    "cloud": { "provider": "ai-sdk", "backend": "anthropic", "model": "claude-opus-5", "effort": "low", "webSearch": true }
  }
}
```

The loop does not move. Tools are handed to the SDK without an `execute`,
so the SDK returns the calls instead of running them. The tool rounds, the
escalation and the request deadline all stay in Parlour. The only change is
who keeps up with the two APIs.

Parlour only runs its own model server for the `openai-compatible` provider,
so an `ai-sdk` local slot needs a server of its own, such as LM Studio.

The built-in clients remain the default, because they need nothing but
`fetch`. Choose the AI SDK if you would rather track one library than two
APIs, or want to put the SDK's own middleware in front of a model.

### Decision model (optional)

```json
{
  "llm": {
    "decision": {
      "provider": "laya-mlx",
      "mode": "shadow",
      "escalateThreshold": 0.85,
      "localConfidence": 0.75,
      "model": "aac6fef/laya-mlx"
    }
  }
}
```

[Laya MLX](https://github.com/mizorewww/laya-mlx) scores each task on your
Mac and decides whether it needs the cloud. It runs after triage has split
the request and before the local model takes its turn. It needs Apple silicon
and `pip install laya-mlx`. The first load downloads the checkpoint into the
Hugging Face cache.

- **Start with `"mode": "shadow"`.** The log shows `needs_cloud`, the intent
  and the confidence, but `ask_the_clever_one` still decides.
- **Switch to `"mode": "triage"`** once the scores look right. Scores above
  `escalateThreshold` go straight to the cloud model. A clear house or timer
  intent keeps escalation off, so house commands stay local.
- **`"provider": "none"`**, the default, skips the decision model entirely.
- **Multilingual house?** Set `"model": "aac6fef/laya-multilingual-mlx"`.

## The pipeline

```json
{
  "pipeline": {
    "concurrency": 2,
    "queueDepth": 2,
    "triage": "auto",
    "maxTasks": 4,
    "timeoutMs": 45000
  }
}
```

- **`concurrency`** is how many requests are answered at once across the whole
  house. Each client gets one request at a time either way, so this sets how
  many rooms can be answered at once, not how hard one room can push. Two
  suits one machine with one local model. Raise it if the model is hosted
  elsewhere and can take the load. The queue still keeps each room in turn.
- **`queueDepth`** is how many requests may wait in one client's lane. Past
  that, the client's oldest waiting request is dropped silently. Someone who
  asks twice wants the second answer.
- **`triage`** controls when a request is repaired and split before Parlour
  acts on it. `auto` reads anything compound, long or ambiguous, and lets a
  plain instruction straight through. `always` costs a local round trip on
  every request, which is worth it in a noisy room or with a small speech
  model. `never` sends the words to the action agent exactly as heard.
- **`maxTasks`** caps how many tasks one request may become. Four covers "turn
  the kitchen and hall lights off, set a timer and tell me the forecast".
- **`timeoutMs`** is the ceiling on one whole request. It is checked between
  tool rounds and between tasks, not mid-request, so nothing already with a
  model is abandoned. It stops a request that is still running long after the
  person has left the room.

You can change these from a client too: `parlour remote pipeline set` or the
iPhone app's Server tab, within tighter limits than the file allows. See
[Managing the server](/docs/clients#managing-the-server).

### Keeping the Mac responsive

The local model and whisper share the Mac with whoever is using it. On Apple
silicon they also share its memory with the GPU. A model that does not fit
does not fail cleanly: the whole machine slows to a crawl. So Parlour holds
them back:

- A model whose weights would take more than 70% of the Mac's memory is not
  started, and `parlour doctor` says so. `parlour models suggest` names one
  that fits.
- Both servers leave two cores free, and use at most six. The local model
  keeps one conversation's worth of context rather than several.
- Both run at a lower priority than anything you are using. launchd waits a
  minute before starting a crashed one again, rather than ten seconds.
- Inside the App Store app, where Parlour keeps them running itself, a server
  that crashes five times in ten minutes is left stopped until you start it
  from a client.

## Search

```json
{ "search": { "provider": "searxng", "url": "http://searxng.local:8080", "maxResults": 5 } }
```

Only the local model uses this. The cloud model brings its own search.
SearXNG needs `- json` under `search.formats` in its `settings.yml`.
For a hosted alternative, use `"provider": "brave"` with `BRAVE_API_KEY` in
`secrets.env`. `"provider": "none"` leaves the local model without a search
tool.

## Logging

Set `LOG_LEVEL` in `secrets.env` (or the environment) to `debug`, `info`,
`warn` or `error`. `debug` prints every transcription time and every tool call
with its arguments. Use it when you want to know why the model did what it
did. Service logs go to `~/Library/Logs/parlour/agent.log`, `whisper.log`
and, when Parlour runs the local model, `llm.log`.
`parlour service logs --lines 200` prints the tail of each.
