Control4 DriverWorks References / Voice Coordinator: Agent API / Markdown

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
PieceWhat it isWhere 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

input device coordinator voiced your driver Device add GET_VOICE_TARGET_TYPE once, when the target object is built Each start pass GET_VOICE_TARGET_CONFIG "0@ip:port" start {name, service, config} status services[].c4vc_uris SET_VOICE_INPUT_CONFIG c4vc URI, keyed by service name Push to talk audio to the c4vc URI TCP connect, PCM stream direct socket; the coordinator is not involved
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.

Stopped cached address empty Started registered with voiced config non-empty cache it, queue start config changed or empty stop, clear cache; new value not adopted config empty: skip config unchanged: no-op one transition is evaluated per start pass, every 190 s
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
CheckMeaning
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
FilenameBecomes
appleTV.c4zAn apple target. Any driver with this name, from anyone.
XfinityX1.c4zA transcoder target, unconditionally.
PiVoiceTest.c4zA transcoder target, unconditionally. This is the platform's own reference target.
voiceinput.c4iA voice input device.
roomdevice.c4iA 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.

-- 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.

<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.

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
NameTypeNotes
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
NameTypeNotes
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.

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.

-- 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

-- 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
PropertyTypeBehavior
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
ActionEffect
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.

-- 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.