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
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.
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_TYPEAgent, 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_CONFIGAgent, 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_URLNavigator
Same address, different envelope:
<PTT><URL>0@ip:port</URL><Protocol>Control4</Protocol></PTT>.
Return empty inner tags when not ready.
GET_VOICE_PROTOCOLNavigator
The codec family for the stream:
<VoiceProtocol>PCM</VoiceProtocol>.
GET_VOICE_INFONavigator
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.
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_CONFIGYou 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_STATUSBridge 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_URLYou 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.
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
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.
-- 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.