Control4 Subsystem Reference

# Voice Coordinator

The agent that connects push-to-talk voice input devices to voice targets. It discovers target drivers, collects each target's audio address, registers targets with the voiced daemon, and pushes connection URIs to the input devices. This page documents the contract a driver implements to become a voice target.

- **File**: control4_agent_voicecoordinator.c4z

- **Model**: VC1

- **Proxy**: control4_agent_voicecoordinator

- **Minimum OS**: 3.3.1

- **Documented at**: version 139

## The model

>  Voice involves four separate pieces, and the agent is the only one that never touches audio. It moves addresses. The audio path is a direct TCP socket from the voiced daemon to a server the target driver opens.

**The four pieces**

| Piece | What it is | Where it runs |
| --- | --- | --- |
| Voice input device | The thing with the microphone: a push-to-talk remote or app, bound to the `voiceinput.c4i` proxy. Its side of the exchange has its own page, the voice input proxy reference. | Physical device |
| voiced | The daemon that moves audio. Controlled over newline-free, NUL-terminated JSON on TCP port `9250`. | Main controller |
| Voice Coordinator | This agent. Discovers targets, collects their addresses, and keeps voiced and the input devices in sync. | Director |
| Voice target | Your driver. Publishes an audio address and receives a PCM stream on it. | Director |

Inside the agent, every voice target is represented by a service instance. The instance's **service type** is what voiced is told when the instance is registered. The **service name** is the key under which input devices store the target's connection URI. The instance **name** embeds the target's device id and is how you find your target in the agent's logs.

**Service classes**

| Class | Service type | Service name | Instance name | Created for |
| --- | --- | --- | --- | --- |
| TranscoderVoiceDInstance | transcoder | comcast | transcoder_%04d | Every third-party target, including anything registered by the dynamic scan. |
| AppleVoiceDInstance | apple | appletv | apple_%04d | A driver whose file is named exactly `appleTV.c4z`. |
| SiriVoiceDInstance | siri | siri | siri_%04d | One per input device; piggybacks on an apple instance rather than registering with voiced itself. |
| AmzVoiceDInstance | amazon | avs | amz_%04d | One per input device, for the built-in far-field service. |
| ConciergeVoiceDInstance | concierge | concierge | concierge_%04d | One per input device, when the concierge agent is in the project. |
| NullVoiceDInstance | null | "" | null_%04d | Placeholder held by every input device; never registered with voiced. |

Two of these classes accept third-party drivers, and they work in opposite directions. On the **transcoder path** your driver opens the audio socket and invents its own address. On the **apple path** a separate bridge driver owns the audio endpoint and your driver only relays the bridge's URL. The transcoder path is the general-purpose one; this page centers on it. The `%04d` in instance names is the target's device id, zero-padded to four digits, so `transcoder_0042` is device 42.

## Call flow

_Diagram: Sequence of a transcoder target coming up. At device add, the Voice Coordinator sends the UI request GET_VOICE_TARGET_TYPE to the target driver once. On each start pass the agent sends GET_VOICE_TARGET_CONFIG to the driver, the driver replies with its address in the form 0@ip:port, the agent sends a start message for the instance to voiced over TCP port 9250, then sends status; voiced replies with each service's c4vc_uris, and the agent pushes that URI to the voice input device with SET_VOICE_INPUT_CONFIG under the service name comcast. When the user holds the talk button, the input device streams audio to voiced, and voiced opens a plain TCP connection to the ip and port the driver advertised and streams 16 kHz mono PCM. The agent is not on the audio path._

_ A transcoder target coming up, then carrying audio. **Ochre arrows are coordinator-initiated.** The agent's whole job ends when the c4vc URI reaches the input device; the audio that follows flows from the input device through voiced straight to the socket your driver opened. _

## The start cycle

>  The agent re-reads every target's address on a timer, and it reacts to a changed address by stopping the target, not by re-pointing it. Recovery happens on a later pass. This is the single most consequential behavior on the page.

A maintenance timer fires every **95 seconds** and alternates between two jobs: one tick asks voiced for `status`, the next runs a start pass over every instance. A start pass therefore runs every **190 seconds**. On each start pass the agent sends your driver `GET_VOICE_TARGET_CONFIG` and compares the reply against the address it is already using.

_Diagram: State machine for one voice target instance across start passes. In the Stopped state with no cached address: if the driver reports an empty address the instance stays stopped, and if the driver reports an address the agent caches it, queues a start with voiced, and the instance becomes Started. In the Started state: if the driver reports the same address nothing happens; if the driver reports a different address, including an empty one, the agent stops the instance in voiced and clears its cached address, returning to Stopped without adopting the new address. The new address is only adopted on the next start pass, so moving a target to a new port costs up to two passes at 190 seconds each._

_ What one start pass does to a target instance. A changed address takes the bottom arrow first and the top arrow on a later pass, so moving to a new address costs up to two passes. _

The consequence: if your driver binds an ephemeral port (`Listen("*", 0)`), the address changes on every driver reload, and every reload silently tears down your target. The microphone still lights up and says it is listening, your driver receives zero bytes, and voiced's status stops listing your instance. **Always bind a fixed port**, and expose it as a property so an installer can move it if it collides.

Two things bypass the wait. Toggling the service's enable property stops or starts its instances immediately, and an apple-path target that reports `ACTIVE` through `APPLETV_STATUS` is started at once rather than on the next pass. Everything else waits for the timer.

## Conventions

### What makes a driver a candidate

At initialization the agent scans every room and builds a list of eligible third-party targets. A driver must pass all three checks or the agent never creates a target for it:

**Candidate checks**

| Check | Meaning |
| --- | --- |
| Listed as a watch source | The device appears in some room's `GET_WATCH_DEVICES` response. Being in the project is not enough; it must be a watchable source in a room. |
| Type not ignored | Sources of type `RF_MINI_APP`, `RF_SAMSUNG_TV_APP`, and `UIButton` are skipped. |
| Declares push-to-talk | The protocol driver's `driver.xml` carries `<push-to-talk>True</push-to-talk>` inside `<capabilities>`. The value is lowercased before comparison, so any casing of `true` passes. |

The proxy type is not one of the checks. A `cable` proxy, a `media_player` proxy, or any other watchable AV proxy works; the capability is the gate. Capabilities are read when the device is **added** to the project, so adding `<push-to-talk>` to an installed driver does nothing until the device is removed and re-added.

### Registration is by exact filename

The agent keeps a map from c4z filename to a constructor, and the filename is the entire check. It does not verify authorship, model, or anything else. Candidates found by the room scan are added to the map as transcoder targets; a handful of names are wired in permanently:

**Fixed map entries relevant to voice targets**

| Filename | Becomes |
| --- | --- |
| appleTV.c4z | An apple target. Any driver with this name, from anyone. |
| XfinityX1.c4z | A transcoder target, unconditionally. |
| PiVoiceTest.c4z | A transcoder target, unconditionally. This is the platform's own reference target. |
| voiceinput.c4i | A voice input device. |
| roomdevice.c4i | A room, which is how the scan finds watch lists. |

The map is built when the agent initializes. The agent listens for project change events and re-parses the project a few seconds after a device is added, moved, renamed, or removed, which creates targets for any filename *already in the map*. A filename the scan has never seen gets into the map only when initialization runs again: the **Reinitialize Driver** action on the agent, or a Director restart. After installing a brand-new voice-capable driver, run that action.

### Reply parsing is deliberately crude

The agent parses both UI request replies by taking whatever sits between the first `>` and the next `<`. The tag name is decoration; the content is everything. A reply that does not contain that bracket pair yields `"Unknown"` for the type and `""` for the config.

### The daemon protocol has two generations

The agent speaks to voiced assuming the newer protocol (`"3.4"`). If a `start` command comes back with an error of type `Invalid argument`, it downgrades itself to `"3.3"` and restarts every instance. The generation changes the shape of `voice_target_config` (structured table versus raw string; see Data shapes) and is negotiated at runtime, not read from the OS version.

### Library conventions are not the contract

Everything above is wire contract: the capability tag, the map filenames, the UI request names, the reply parsing, the voiced message shapes. The handler style in the examples below is not. This page writes plain `UIRequest` and `ExecuteCommand` functions, which is what Director actually calls. Drivers built on the shared library reach the same handlers through its `UIR.` and `EC.` dispatch tables; use whichever your driver already uses. Helper names in the examples, such as `CanTalk()`, are placeholders to write yourself.

## Requests to your driver

>  These arrive at your driver's `UIRequest` entry point, with no binding context. The first two come from the agent and are the only ones it reads. The other three come from Navigators; implement them for the microphone UI, but know that the agent ignores them.

GET_VOICE_TARGET_TYPE Agent, once

Sent once, when the agent builds its object for your device: at agent initialization or when your device is first created by a project re-parse. The reply is a type string such as `<TargetType>googletv</TargetType>`.

**Returns.** Anything between the first `>` and the next `<` is kept. An unparseable reply is stored as `Unknown`.

**Side effects.** For a transcoder target the stored value is immediately overwritten with `comcast`, because the input-device firmware only accepts a fixed set of service names. Whatever you return does not survive, and your device appears under the Comcast service in the voice UI. That is normal, not a misconfiguration. Apple targets keep their reply.

GET_VOICE_TARGET_CONFIG Agent, every start pass

Sent on every start pass. The reply is your audio address, and it drives the whole lifecycle in the section above.

```lua
-- transcoder form: literal id 0, then controller-reachable ip:port
<TargetConfig>0@192.168.1.50:9302</TargetConfig>

-- apple form: relayed from the bridge, scheme and trailing slash required
<TargetConfig>c4vc://2853628976@192.168.1.50:9251/</TargetConfig>
```

**Returns.** Same bracket parsing as above. Return an empty `<TargetConfig></TargetConfig>` when you are not ready; that reads as "no address" and the agent skips or stops you cleanly rather than registering a dead endpoint.

**Side effects.** A reply that differs from the cached address stops the instance. See the state diagram.

GET_PTT_URL Navigator

Same address, different envelope: `<PTT><URL>0@ip:port</URL><Protocol>Control4</Protocol></PTT>`. Return empty inner tags when not ready.

GET_VOICE_PROTOCOL Navigator

The codec family for the stream: `<VoiceProtocol>PCM</VoiceProtocol>`.

GET_VOICE_INFO Navigator

Codec detail and host, JSON inside the tag. The codec fields the platform's own transcoder target checks for are `encoding = "PCM"`, `bitrate = 16000`, `channels = 1`, `bitqty = 16`.

```lua
<voice_info>{"codec":{"id":1,"encoding":"PCM","bitrate":16000,
"channels":1,"bitqty":16},"host":"192.168.1.50","port":9302}</voice_info>
```

## Commands to the agent

>  The agent is a singleton; find it by filename. Commands sent with `C4:SendToDevice` arrive at its `ExecuteCommand` entry point. It declares no programming commands, so none of this is visible to Composer programming.

```lua
local agentId = next(C4:GetDevicesByC4iName("control4_agent_voicecoordinator.c4z"))
```

REQUEST_RESYNC_VOICE_INPUT_CONFIG You send

Asks the agent to re-push every valid service configuration to one voice input device, bypassing its change-suppression cache. This is the remedy when a remote has lost or blanked its stored voice endpoints and the agent believes nothing has changed.

**Parameters**

| Name | Type | Notes |
| --- | --- | --- |
| DEVICE_ID | string req | The voice input device's **proxy** device id, as a string. The protocol id is not recognized and the command logs a warning and does nothing. |

**Returns.** Nothing. **Side effects.** Services whose config is currently empty are skipped rather than pushed, so a resync never blanks a working endpoint. The current target is re-asserted only if its config is valid.

APPLETV_STATUS Bridge sends

Sent by the apple-path bridge driver when a target's session comes up or drops. An `ACTIVE` status starts the target's voiced instance immediately instead of waiting for the next start pass; anything else marks it inactive.

**Parameters**

| Name | Type | Notes |
| --- | --- | --- |
| STATUS | string req | `ACTIVE` or `NOT_ACTIVE`. Compared literally against `ACTIVE`. |
| DEVICE_ID | string req | The apple target's device id. Unknown ids are ignored. | SET_PTT_URL You send, to your proxy

Not an agent command, but part of the same choreography: when your audio server comes up or moves, notify your own proxy so Navigators see the current address without polling.

```lua
C4:SendToProxy(5001, "SET_PTT_URL", { URL = "0@192.168.1.50:9302",
                                     Protocol = "Control4" })
```

## Data shapes

### The transcoder address

The agent parses your config reply with the pattern `"(.+)@(.+):(.+)"`, or `"(.+):(.+)"` when there is no `@`, into id, ip, and port. The id is unused on the transcoder path; every known target sends `0`. Do not use the apple form here.

### The apple address

Apple targets are parsed with `"c4vc://(.+)@(.+):(.+)/"`. The scheme and the trailing slash are load-bearing; without them the match fails and the target never starts. The id becomes `appled_id` in what voiced receives.

### What voiced receives

On the current protocol generation the agent re-parses your address into a structured table; on the older generation it forwards the raw string. You never speak this protocol yourself, but its shape is what you will see in logs.

```lua
-- registration, current generation ("3.4")
{
  method = "start",
  args = {
    name = "transcoder_0042",              -- instance name
    service = "transcoder",                -- service type
    voice_target_config = {
      target_ip_v4 = "192.168.1.50",
      target_port = 9302,
    },
  },
}

-- older generation ("3.3"): both name and type are "comcast",
-- voice_target_config is the raw address string, and a data field is added
```

Every message on the daemon socket is JSON terminated by a NUL byte (`'\000'`), on TCP port `9250` of the main controller. The five methods the agent uses are `manage` (claim the daemon), `status` (enumerate services), `start`, `stop`, and `clear` (wipe instance data, then stop).

### What comes back

```lua
-- status response, one entry per registered service
{
  method = "status",
  data = {
    services = {
      {
        name = "transcoder_0042",
        c4vc_uris = { "c4vc://2853628976@192.168.1.50:9251/" },
        status = { ... },
      },
    },
  },
}
```

The first entry of `c4vc_uris` is the URI pushed to input devices for this service. An instance missing from the status list has its URI cleared and is treated as stopped. If your `transcoder_<id>` is absent, or present with an empty `c4vc_uris`, the agent never successfully started it; the problem is discovery or enablement, not audio.

### The audio stream

What arrives on your socket is signed 16-bit little-endian mono PCM at 16 kHz, in whatever chunk sizes the sender's reads produce. A socket read can land mid-sample; carry leftover bytes across reads if you resample or reframe.

## Composer surface

>  The agent exposes two actions and a handful of properties. The service enables are the switches behind "disabled in the voice UI".

**Properties**

| Property | Type | Behavior |
| --- | --- | --- |
| Log Level | LIST | `0 - Fatal` through `5 - Trace`; default `1 - Error`. |
| Log Mode | LIST | `Off`, `Print`, `Log`, `Print and Log`; default `Off`. |
| Driver Version | STRING, read-only | Reports the installed version. |
| AppleTV Service | LIST | `Enabled` / `Disabled`; default Enabled. Applies to every apple target at once. |
| Comcast Service | LIST | `Enabled` / `Disabled`; default Enabled. Applies to every transcoder target at once, including all third-party ones. |
| Concierge Service | LIST | `Enabled` / `Disabled`; default Disabled. Hidden unless the concierge agent is in the project. |

**Actions**

| Action | Effect |
| --- | --- |
| Query Statuses | Asks voiced for status and logs every instance's name and current URI. The fastest way to see whether your target exists and has an address. A healthy line reads `transcoder_0042 -> c4vc://...`; a stopped or never-started one has nothing after the arrow. |
| Reinitialize Driver | Shuts down every instance, rebuilds the filename map (re-running the room scan), reconnects to voiced, and re-parses the project. Run this after installing a new voice-capable driver; it is the lighter alternative to restarting Director. |

The agent declares no programming commands and no variables. Service enables persist across restarts.

## Behavior notes

#### A listening indicator is not evidence your driver was reached

The microphone UI lights up based on the input device's stored state, not on a live connection. If the agent holds a stale address, the remote says "listening" while the bytes go nowhere. Verify delivery by counting bytes on your socket, not by watching the remote.

#### Recovery waits for the timer

Start passes run every 190 seconds, and a changed address needs two of them: one to stop, one to adopt. Budget several minutes before concluding something is broken, or force the issue by toggling the service enable property.

#### All third-party targets share one service name

Every transcoder target is filed under the service name `comcast`, and an input device holds one configuration per service name. Two transcoder targets can coexist in a project in different rooms, but a given input device reaches whichever one its room currently selects; they contend for the same slot on the remote. Some input firmware additionally keys its stored endpoint by port, so two services sharing a port collide even when their service names differ.

#### The room's selected device picks the target

An input device follows its room. When the room's selected watch device corresponds to a voice target with a valid address, the room switches its input devices to that target's service. A target with no address yet is skipped rather than selected, so a half-configured driver cannot steal the slot from a working one.

#### Empty config is deferred, then meaningful

Before a target's first valid URI arrives, the agent refuses to push its empty config to input devices, so a slow start never erases a remote's stored endpoint. After the first valid URI, empty means stopped or disabled and is pushed deliberately.

#### Enablement is per service class, not per target

The three service properties enable or disable every instance of their class together. A disabled instance is never started, and any config it reports is forced to empty. There is no per-target switch.

#### Capabilities are read at device add

Adding `<push-to-talk>` to a driver already in the project does nothing. Remove and re-add the device, then run Reinitialize Driver if the filename is new to the agent.

#### Stale instances accumulate in logs

Instance names embed the device id, and every remove and re-add produces a new id and therefore a new `transcoder_<id>`. Old names can linger in voiced's status until the agent reinitializes or Director restarts. Print your own device id from your driver so you can pick your line out of the list.

#### Starts are queued, not simultaneous

Instance starts go through a queue drained one entry every 2 seconds, so a system with many targets brings them up in series. A start shortly after another target's is not stuck, just queued.

#### The daemon connection self-heals

The agent connects to voiced at the main controller's address on port 9250 and retries on each maintenance tick while disconnected. When the connection drops, every instance's URI is cleared and input devices are updated; when it returns, all instances restart. Empty URIs across every target usually mean voiced is down or the service is disabled, not that drivers broke.

## Integration recipe

The minimum for a transcoder-path target, written with plain entry points. `VOICE_PORT` should come from a ranged-integer property so it is fixed but movable.

```lua
-- 1. driver.xml: the discovery gate, inside <capabilities>
--    <push-to-talk>True</push-to-talk>
--    The device must also be a watchable source in a room.

-- 2. Open the audio server on a fixed port. Listen is asynchronous;
--    the address is not valid until OnListen fires.
local VOICE_PORT = 9302   -- from a property
local gServerUp = false

C4:CreateTCPServer()
  :Option("reuseaddr", true)
  :OnListen(function(server)
    gServerUp = true
    local url = "0@" .. C4:GetControllerNetworkAddress() .. ":" .. VOICE_PORT
    C4:SendToProxy(5001, "SET_PTT_URL", { URL = url, Protocol = "Control4" })
  end)
  :OnAccept(function(server, client)
    client:OnRead(function(cli, data)
      HandleAudioChunk(data)   -- 16 kHz s16le mono PCM; write this yourself
      cli:ReadUpTo(10 * 1024)
    end)
    client:OnDisconnect(function() EndAudioSession() end)
    client:ReadUpTo(10 * 1024)
  end)
  :Listen("*", VOICE_PORT)

-- 3. Answer the UI requests, all gated on readiness.
function UIRequest(strCommand, tParams)
  local host  = C4:GetControllerNetworkAddress() or ""
  local url   = "0@" .. host .. ":" .. tostring(VOICE_PORT)
  local ready = gServerUp and host ~= ""

  if strCommand == "GET_VOICE_TARGET_CONFIG" then
    if not ready then return "<TargetConfig></TargetConfig>" end
    return "<TargetConfig>" .. url .. "</TargetConfig>"

  elseif strCommand == "GET_VOICE_TARGET_TYPE" then
    return "<TargetType>googletv</TargetType>"  -- rewritten to comcast

  elseif strCommand == "GET_PTT_URL" then
    if not ready then return "<PTT><URL></URL><Protocol></Protocol></PTT>" end
    return "<PTT><URL>" .. url ..
           "</URL><Protocol>Control4</Protocol></PTT>"

  elseif strCommand == "GET_VOICE_PROTOCOL" then
    return "<VoiceProtocol>PCM</VoiceProtocol>"

  elseif strCommand == "GET_VOICE_INFO" then
    if not ready then return "<voice_info></voice_info>" end
    return "<voice_info>" .. C4:JsonEncode({
      codec = { id = 1, encoding = "PCM", bitrate = 16000,
                channels = 1, bitqty = 16 },
      host = host, port = VOICE_PORT,
    }) .. "</voice_info>"
  end
end

-- 4. After first install: run the agent's Reinitialize Driver action so
--    the filename enters its map. On later reloads the fixed port keeps
--    the address stable, so the agent never needs to re-point.
```

Voice Coordinator, agent reference Requires Control4 OS 3.3.1 or later
