Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

C8110 Voice Activated Relay (appName: c8110var)

This is an independent, community-developed ACAP package created to show what can be done with Axis devices when you think outside the box. It is not an official Axis product and is not affiliated with, endorsed by, or supported by Axis Communications AB. Use at your own risk.

A software voice-activated relay for the AXIS C8110 Network Audio Bridge. It watches the audio arriving at the C8110's input, and when that audio rises above a configured threshold it closes a "relay" that appears in the device's own events engine as a condition. When the audio stops, the relay opens again after a configurable hold time.

The relay is not wired to any particular outcome. It is a condition, and what happens next is a rule you build - which is the entire point.

Built on the same foundation as this project's sibling C17 Timer: same embedded web server architecture, same UI language, same build and versioning process.

Why this app has to exist

The C8110's datasheet lists exactly four event triggers: Call, Virtual inputs, External input, and ACAP. There is no audio-level trigger on this device. You cannot build a rule that reacts to somebody talking, because the device has no condition that means that.

So this app declares one.

Why the C8110, specifically

Two facts from the datasheet settle the design:

  • It has an audio input. Mic-in and line-in on a 3-pole 3.5 mm jack, 20 Hz - 20 kHz, with an RCA line out. It exists to bridge analog audio into a network audio system, so there is nearly always something interesting arriving on that input.

  • It runs an NXP i.MX 6SoloX, which is a 32-bit Cortex-A9 - so C8110_Voice_Activated_Relay_<version>_armv7hf.eap is the build to install on a C8110. An aarch64 build is produced too, so the same package can go on a newer Axis audio device.

    Note this is the reverse of the sibling C17 Timer, whose C1710 is an i.MX 8M Mini and therefore wants the aarch64 build. Easy to grab the wrong file if you have both folders open.

The C8110 also has a 4-pin terminal block with two configurable I/Os. This app does not drive them itself - the device's rule engine already has an "activate output port" action, so a one-line rule turns the relay into genuine dry contacts, which is what a physical voice-activated relay is. See Events & rules below.

Requirements

  • AXIS OS 12.11.72 or later. Not negotiable, and not a conservative guess: this app captures audio through PipeWire, which is how audio came back to ACAP, and PipeWire is only present from AXIS OS 12. The manifest's floor is the ACAP Native SDK 12.11's own stated minimum. The LTS 11.x track cannot run this app.
  • Something connected to the C8110's audio input, with the input enabled on the device's own Audio page.

How it actually works

PipeWire node ──▶ audio.c ──▶ meter.c ──▶ vox.c ──▶ event.c ──▶ rule engine
 (line/mic in)     samples      dBFS       relay      condition
                                             │
                                             └────▶ webui.c (graph, meter, API)

Audio is captured through the PipeWire stream API, the same route Axis's own audio-capture example uses. Axis documents PipeWire as supported on ARTPEC-6/7/8/9, Ambarella CV25 and CV75, and the i.MX 6SoloX - the C8110's chip is on that list, which is what makes this app possible on it at all.

Two things that follow from that and shape the code:

1. The app needs the pipewire group. Declared in manifest.json under resources.linux.user.groups. Without it the connection to the PipeWire daemon is refused and no level ever arrives. The home page says so in as many words if that happens.

2. PipeWire runs on its own thread. audio.c uses pw_thread_loop, so its callbacks are not on the GLib main loop the rest of the app lives on. Nothing with a side effect happens on that thread - it accumulates a peak and a sum of squares behind a mutex, and the main loop drains that on every tick. So the rule engine, the syslog and the web UI are all still touched from exactly one thread.

That handoff is also why audio.c deals in accumulated level over an interval rather than a current value: a "current" level read from another thread would be whatever the last buffer happened to hold, which at a ~21 ms quantum is easily the quiet half of a syllable.

Which input node

Axis names audio nodes AudioDevice<device><direction><input>[.Suffix], so AudioDevice0Input0.Unprocessed is the raw signal from the first input on the first audio device, and AudioDevice0Input1 is a processed one with the device's own effects applied.

Automatic selection prefers an unprocessed node, and you should leave it there. Automatic gain control exists to make quiet things loud and loud things quiet, which is precisely the information a fixed threshold detector needs left alone. Feeding a threshold from an AGC'd signal means the threshold quietly moves around behind your back: a quiet room gets amplified up toward the trigger point and a genuinely loud source gets pulled back down below it.

Auto-selection waits for PipeWire's initial registry dump to complete before choosing, so "prefer unprocessed" is a decision rather than a race with whichever node happened to be announced first.

Connecting a two-way radio

This is the use the defaults are set for. A base or mobile two-way radio's headphone or speaker output goes into the C8110's 3.5 mm input. When somebody keys up out in the field, the radio receives it, this app hears it, the relay closes, and a rule pages the message out over the audio system as a SIP call.

Two things on the radio decide whether it works

Squelch must be closed between transmissions. This matters more than any setting in this app. A radio with its squelch open outputs full-scale hiss, which will hold the relay closed permanently - and no threshold can separate hiss from speech, because the hiss is louder. Set the squelch so the radio is genuinely silent when nobody is transmitting, then set the threshold.

The volume knob sets the trigger level. Not how loudly anyone speaks - a two-way radio's audio is heavily compressed, so a whisper and a shout arrive at nearly the same level. What varies is the knob. Set it, leave it, calibrate the threshold against it, and re-check the threshold if anyone ever moves it. It is worth a piece of tape.

Why the hold time is 5 seconds

A radio exchange has two kinds of gap, and the hold has to ride over both: the pauses inside one transmission, and the two or three seconds between one person unkeying and the next replying.

At a speech-like 2 seconds the relay drops out between overs, so every reply becomes a fresh SIP call - and each new call loses its opening words to call setup. At 5 seconds the whole exchange stays on one call. The cost is that the call hangs on 5 seconds past the last word, which for paging is much the cheaper mistake.

The first words of the first message will be lost

Worth knowing before you deploy, because no setting fixes it: SIP call setup takes time. The relay closes within ~100 ms of the radio audio starting, and the rule fires within a few milliseconds of that - but the call still has to be placed, ring, and be answered before any audio flows. That is typically one to three seconds, and it happens after the talker has already started speaking.

Three things help, in order of how much:

  1. Set the far end to auto-answer. Ringing is usually the largest part of the delay, and a paging endpoint has no reason to ring.
  2. Keep the hold at 5 seconds or more, so a conversation stays on one call and only the very first transmission is clipped rather than every single one.
  3. Key up, pause a beat, then talk. This is standard two-way radio practice anyway, and it is the only thing that fixes the first transmission.

If losing the opening of a page is unacceptable, a voice-activated call is the wrong shape for the job - you would want a call held up permanently instead, which is not what this app is for.

A sensible starting point

Setting Default Why
Threshold -45 dBFS Triggers on a radio somebody has turned down; safe because a squelched radio is near-silent
Detector RMS, smoothing 3 Radio audio is compressed and steady; peak would fire on squelch crackle
Attack 100 ms Rejects a key-up thump or a click without eating the first word
Hysteresis 6 dB Compressed audio does not swing far
Hold 5 s Bridges pauses within a transmission and gaps between overs
Maximum on-time 5 min A stuck squelch or transmitter is the realistic failure here
Lockout 0 Never miss the next over

Then calibrate the threshold against your actual radio at its actual volume, using the Trigger page's live meter.

The detector

Units

Everything is in dBFS - decibels relative to digital full scale, where 0 dBFS is the loudest sample the converter can represent and everything real is negative. That is the natural unit here because PipeWire hands over normalized float samples.

A physical voice-activated relay is calibrated in millivolts instead (the Bogen VAR1's sensitivity trimpot spans 20-150 mVrms). Those do not translate: the same millivolt figure lands at a different dBFS depending on whether the C8110's input is set to mic or line and what hardware gain is applied. So this app does not pretend to offer a millivolt scale it cannot honor. It shows the real level, all the time, and the Trigger page can take a threshold straight from it.

Integration window

A single 20 ms tick of audio is not a level. Speech has gaps inside individual words - at the closure of every stop consonant - and a detector looking at one tick at a time reads those as silence. So each tick goes into a short ring and the level is computed across the whole window. This is the software equivalent of the detector time constant in a physical VOX, and it is what stops the relay stuttering mid-sentence.

RMS (the default) averages energy across the window, so it tracks how loud something actually is. Peak takes the loudest instant, which reacts faster but will fire on a single click or a door slam - the exact false trigger a sensitivity trimpot gets fiddled with to avoid.

The relay

Five states, in vox.c. The interesting ones are the two where the relay is doing something other than the obvious:

State Relay What it means
Idle open Nothing happening
Detecting open Above the threshold, waiting out the attack time
Active closed Above the release threshold
Holding closed Below the release threshold, hang timer running
Lockout open Cooling down; refusing to close

Two thresholds, not one. The relay closes at the trigger threshold and begins releasing only below trigger minus hysteresis. Without that gap, a level sitting on the threshold makes the relay chatter - closing and opening on alternate ticks as it wobbles by a fraction of a dB.

Falling back during the attack fires nothing at all. No event, no condition, no rule. The transient rejection is invisible from outside, which is what it should be.

Speech resuming during the hold fires nothing either. The relay never opened, so nothing happened. That is the pause between two sentences, and it is what the hold time is for.

What the UI calls things

The web UI deliberately avoids audio vocabulary - an installer with a radio and a screwdriver should not need to know what hysteresis is. The code and this README keep the technical names, because that is what the literature uses. They map like this:

UI label Technical name Config key
Activation level Threshold threshold_db
Deactivation margin Hysteresis hysteresis_db
Activation delay Attack time attack_ms
Deactivation delay Hold / hang time hold_ms
Force deactivate after Maximum on-time max_on_ms
Cool-down Re-trigger lockout lockout_ms
Smoothing Integration window window_ms
Quietest level shown Meter floor floor_db
Average level / Loudest moment RMS / Peak metric
Audio source PipeWire node input_node
Activation / Deactivation Trigger / Release pages trigger / release

The relay activates and deactivates rather than closing and opening - matching the rule engine's own Relay activated / Relay deactivated wording, so the app and the device's own pages read the same. The states are Inactive / Checking / Active / Waiting / Cooling down rather than Idle / Attack / Active / Hold / Lockout.

Every editable value is a whole number, and carries its unit inside the field. Neither is cosmetic. A threshold of "-45.0" invites somebody to believe tenths of a dB matter here, and they do not - nothing in this app is meaningful to a tenth. A bare "300" in a box is a number nobody can act on without hunting for what it counts, and a unit in a note underneath reads as commentary and gets skipped. Levels round on the way in as well as on the way out, so a config edited by hand is normalised rather than displayed rounded and saved unrounded.

dBFS is kept as the unit, because it is the honest one and because plain "dB" would be worse: everyday dB means sound-pressure level, which is positive and would invite someone to compare -45 here against the 70 their phone's meter shows. Instead the scale is anchored with words at both ends of the meter (silence ... loudest) and explained in one sentence on the Activation page, so it can be read as a position without knowing the unit.

Field names in the HTML forms and keys in config.json are unchanged, so renaming the labels does not disturb a saved configuration.

Every setting, and why it is there

Setting Physical equivalent What it does
Trigger threshold Sensitivity trimpot The level at which the relay closes
Attack time Detector charging time How long the level must stay up before closing. Separates a syllable from a door slam
Hysteresis Comparator hysteresis How far below the threshold before releasing begins. Stops chatter
Hold time Release-delay trimpot How long the quiet must last before opening. The VAR1's spans 0.25-25 s
Maximum on-time - Forces it open after this long. See below
Re-trigger lockout - Minimum gap between activations. Anti-chatter in time rather than level
Armed Power switch Whether the relay may close at all

The maximum on-time, and the trap in it

Continuous noise on an input - an air handler starting up, a stuck intercom, a music source somebody left playing - will hold a plain VOX closed indefinitely. If the relay is placing a call, that is an open call nobody knows about. So there is a maximum on-time, on by default at ten minutes, that forces the relay open regardless.

Which introduces a second, worse problem, and this is worth reading before you tune anything:

The noise that caused the cutoff is still there. Without care, the very next tick starts a fresh attack and the relay closes again a fraction of a second later. Forever. A safety cutoff that turns into a machine gun placing a call every second is worse than the stuck relay it was meant to prevent.

So after a maximum-on-time cutoff - and after a manual Release, for the same reason - the relay latches open until the input has actually fallen below the release threshold at least once. The input has to let go before the relay can grab again. The home page shows this as Waiting for quiet rather than a bare "Idle", because the detector is actively refusing to fire and that is worth saying.

Arming and the Test button both clear the latch: those are explicit instructions from a person or a rule, not the detector making up its own mind.

The web UI

The graph

The home page draws the last 15, 30 or 60 seconds of input level as inline SVG, with:

  • the input level as a filled line
  • a solid dashed line at the trigger threshold
  • a fainter dashed line at the release threshold - the gap between the two is the hysteresis setting, which is otherwise an abstract number
  • shaded bands wherever the relay was closed
  • a countdown reading seconds until the relay releases while it is holding

Ticks are binned into 100 ms buckets, and each bucket keeps the loudest tick in it rather than the average - a peak-hold, the same convention every audio meter uses. Averaging would smooth away the very transient that tripped the relay, which would make the graph disagree with what the relay just did.

The graph is fed the same level the relay decided on, not a separately measured one. What you see is what fired.

The trace scrolls continuously, not in steps. The server bins samples every 100 ms and the page polls four times a second, so redrawing on the poll made the whole trace leap sideways several pixels at a time. Instead the redraw still happens only on a poll, but everything that moves lives in one <g class="trace"> which an animation-frame loop slides left by a fraction of a bin - about 0.3 px per frame on a 30 second window rather than 4.7 px four times a second. Grid, threshold lines and axis labels sit outside that group and stay put, because they are levels rather than history.

Two details make it hold together:

  • The trace is drawn anchored three bins past the right edge, so there is always data waiting off-screen to slide in. Without that a gap opens at the right and snaps shut on the next poll - the same jerk in a new place. It costs a fixed 300 ms of display lag, which is invisible.

  • The position is never derived from the polls. The page holds a single number, base - the bucket that lines up with t=0 on its own clock - and the bucket belonging at the right-hand edge is then base + t/BIN_MS - GLAG. That is a straight line in time, so the velocity is exactly one bucket per BIN_MS and never varies. Poll timing, fetch latency and buckets arriving two-then-three all land in base, which moves by thousandths and is eased in across frames rather than stepped at each poll.

    This took three attempts, and the first two failed the same way: they derived the position from the polls, so every irregularity in polling became an irregularity in the motion. Wide shapes like the relay bands hid it completely; a polyline with a vertex every 1.9 px did not - which is the symptom that finally identified it, the bands looking smooth while the line shimmered.

    Worst frame-to-frame deviation
    Redraw on poll 4.7 px
    Clock-paced slide ±0.94 px
    Follower on bucket count 0.11 px
    Straight line in time, eased base 0.003 px
  • The graph draws 800 ms behind the newest data (GLAG, in buckets). Not decoration: the trace must never arrive at data that has not landed yet, and a poll is 250 ms plus however long the fetch took. Running comfortably behind removes that problem rather than correcting for it, and 800 ms of lag on a 30 second window is not something anyone can see. Simulated against 5-50 ms of fetch latency and ±20 ms of timer jitter, the right-hand edge never once outran the available data.

  • base resets outright rather than easing if it is ever more than 20 buckets out, which covers a stalled poll or a hidden browser tab resuming - animation frames stop while a tab is hidden.

  • The moving group is hidden by two static covers, not by a clip. A clip-path on something that is animating gives a hard, unantialiased edge, and a line crossing it chatters as it moves - which is what the left-hand edge was doing even once the motion itself was even. The covers are plain rectangles in the graph background colour, they never move, and their edges antialias like any other fill. The trace is painted first and runs underneath them.

    Paint order matters and is not arbitrary: horizontal grid below the trace, so the waveform draws over it rather than being crossed by it; then the trace; then the covers; then the y-axis labels, which live in the margin the left cover paints over and so have to come after it; then the threshold lines. Everything that defines the plot ends at the same x, so the margin is white space rather than somewhere the line can spill.

Colour carries the meaning. The signal is the accent colour, via --accent-text rather than --accent - bright #ffcc33 on a white ground is too pale to read as a line, which is the same reason the accent steps down for text; that token is already themed, so the line follows light and dark with no second rule. The activation level is green and the deactivation level red, the same go/stop reading as the state pill, both pointing at the existing --ok and --danger tokens.

  • The server sends more history than the window shows (METER_EXTRA_SECONDS, two seconds of it). The trace is drawn and then shifted right, so without spare buckets off the left-hand end that shift drags the start of the line into view and leaves a widening empty wedge - and the leading vertex flickering at the boundary reads as a twitch even when the motion is smooth.
  • Vertical gridlines and time labels every third of the window (-30s, -20s, -10s, now on a 30 second graph). Static, like the threshold lines: they mark a position on the plot, not a moment in the data.

Buckets close on the clock, not on a count of ticks. g_timeout_add promises only to fire at least TICK_MS apart, so the tick rate runs slow and uneven - measured at about 43 Hz against a nominal 50. Counting five ticks per bucket therefore produced buckets every ~116 ms rather than every 100, which stretched the graph's time axis by a sixth and left the web UI pacing itself against a bucket rate that did not exist. meter_record() takes the monotonic clock and closes a bucket when METER_BIN_MS has genuinely elapsed, advancing by exactly one bucket each time so a late tick is made up rather than shifting the phase for good. Measured after the change: 9.96 buckets a second.

BIN_MS in the script is stringified from METER_BIN_MS through the preprocessor rather than written twice, so the slide rate cannot drift from the rate the server actually bins at.

The whole window travels in the status response as two compact parallel strings (hist of whole dB, act of 0/1) rather than an array of objects: 300 buckets of JSON objects four times a second would be about 8 KB per poll, and this is under 1.5 KB. Whole dB is not a compromise - the plot is 126 pixels tall over a 60 dB span, so a tenth of a dB is a fifth of a pixel.

The countdown

The headline countdown is whichever timer is about to do something, which is not simply the smallest number running. The maximum on-time is nearly always the largest of these and almost never the one that fires; letting it take the headline meant a relay that had just closed on somebody talking displayed "599.1 seconds until the maximum on-time cuts it off" - true, useless, and crowding out the number that matters. It takes the headline only once it is within thirty seconds; the rest of the time it sits in the stats list below.

Calibration

The Trigger page carries a live meter whose marks move as you type, before you save, plus a button that takes the threshold straight from what the input is hearing right now.

Two ways to use it. Either let the room sit at its normal background noise, capture that, and raise the threshold by 6-10 dB so ordinary room tone can never reach it - or talk at the volume you want to trigger on, capture that, and drop it a few dB for headroom. The first gives the fewest false triggers; the second the fewest missed ones.

One address, always authenticated

The web server binds to loopback only, so the only thing that can reach it is Apache on the same device, forwarding requests that already satisfied the reverseProxy entry's admin access level. There is no unauthenticated way to reach the detector, its settings or its API from the network at all, and nothing in the app does authentication of its own because nothing unauthenticated can arrive.

Three inherited gotchas, all confirmed on real hardware

Carried over from the C8310 Customizer and the C17 Timer, and they shape every URL in webui.c:

  1. reverseProxy matches its apiPath exactly - no subtree. A request to /local/c8110var/ui/settings never reaches the app. So every page and every API verb is a query parameter on the one exact path, never a second path segment.
  2. Apache forwards the full original path unmodified, producing a request line like GET //local/c8110var/ui. The mount prefix is stripped in code rather than assumed away.
  3. The proxied path carries a script-src 'self' CSP, which silently drops inline <script> blocks and inline event-handler attributes. All JavaScript is therefore served as same-origin external scripts, referenced by query string (?action=poll-js) - not by a relative filename, because the proxied URL has no trailing slash and a bare filename would resolve against the parent directory.

There is also a fourth, which cost the C8310 Customizer a debugging session and is commented at the top of on_incoming(): GDataInputStream defaults to LF-only newlines, so with the default the blank line ending HTTP's CRLF headers never reads as blank and the server deadlocks waiting for a line the browser will never send.

Events engine

Conditions (the relay driving a rule)

Both appear under Application in the condition picker.

C8110 VAR: Relay event - momentary, with an Event dropdown offering Relay activated and Relay deactivated. Each is a single pulse.

C8110 VAR: Relay active - stateful. True for the whole time the relay is closed.

Each condition shows a state checkbox under its dropdown. That checkbox is the platform's, not this app's, and cannot be removed. It is a filter on the expected value: checked matches TRUE, unchecked matches FALSE.

It starts out unchecked, and you must tick it. A rule left unchecked never fires - no action, no error, nothing in the log. Verified on hardware: with both rules enabled and the boxes unchecked, a real relay activation produced zero actions and zero calls.

That is the safe direction, and it is deliberate - see "Pulse vs property state" in event.h. Before v0.1.2 the momentary events were declared as property states, and an unchecked rule then matched the platform's resting value and fired when nothing had happened at all - which placed ten unwanted SIP calls on a real device while the relay had never once closed. Failing silently is a great deal better than failing open, but it does mean a rule that quietly does nothing is almost always this checkbox.

Which condition to use

Look at the action you want. If its name ends in "while the rule is active", use Relay active - it starts when the relay closes and stops when it opens, from a single rule. Otherwise use Relay event.

Confirmed against a real C8110's action list:

Use Relay active with Use Relay event with
Toggle I/O while the rule is active Make call
Play audio clip while the rule is active End calls
Record audio while the rule is active Answer call
Flash status LED while the rule is active Play audio clip
Send notification through TCP while the rule is active Stop playing audio clip
Send notification through HTTP / HTTPS / TCP / email
Send MQTT publish message
Record audio, Record with recording group
Toggle I/O once, Flash status LED

Toggle I/O while the rule is active on Relay active drives the C8110's terminal block directly, which gives you real dry contacts - which is what a physical voice-activated relay is.

Placing a call - two rules

The C8110 has no "make call while the rule is active" action. Its call actions are Make call, End calls and Answer call, all momentary. So a call takes two rules, both on Relay event:

Rule 1 - place the call

  • Condition: Application → C8110 VAR: Relay event, Event Relay activated, state checkbox checked
  • Action: Make call, with your destination

Rule 2 - end the call

  • Condition: Application → C8110 VAR: Relay event, Event Relay deactivated, state checkbox checked
  • Action: End calls

Set a maximum on-time on the Release page. With the call spread across two rules there is no single thing guaranteeing it ends, so that cutoff is the backstop between a stuck input and a call that never finishes.

Actions (a rule driving the relay)

An ACAP cannot add actions to the rule engine - the action list is the platform's own and third-party apps cannot extend it. So the other direction goes through the built-in Notification: HTTP action pointed at this app's own local endpoint. The HTTP API page lists every URL with a copy button.

Three worth knowing about:

  • Arm on a schedule. ?api=arm in the morning, ?api=disarm in the evening.
  • Anti-VOX. If the C8110 is playing audio out and a microphone on its input can hear it, the relay would retrigger on the device's own sound. Build a rule on the device's audio clip playing condition that calls ?api=disarm while it is active, and the detector goes deaf for exactly as long as the device is talking. This is how the classic anti-VOX problem is solved here - through the rule engine, rather than by this app guessing at the device's audio state.
  • Different sensitivity at night. ?api=threshold&db=-25.

The HTTP API

Plain GET URLs, each answering the full status object as JSON.

Verb Does
?api=status Read everything. Changes nothing
?api=arm / ?api=disarm / ?api=toggle-arm Whether the relay may fire
?api=trigger Close it now, skipping the attack. Refused while disarmed
?api=release Open it now, ignoring hold and minimum on-time
?api=threshold&db=-30 Set the threshold. Saved to disk
?api=calibrate Take the current level as the threshold. &margin=6 to sit that many dB above it

Every answer carries ok (did the request make sense), changed (did anything actually happen) and message (why, when one of those is false). Arming an already-armed detector is ok:true, changed:false rather than a pretence that something moved.

Configuration and storage

Every setting lives in localdata/config.json, not in AXParameter. Anything declared in paramConfig always shows up somewhere in the native ACAP Settings dialog - hidden: only tucks it behind the "Show hidden parameters" toggle - and all of these already have a proper editing UI on their own pages. localdata/ is also the Axis-documented location for content that must survive an application upgrade.

Axis warns against continuous writes there, which is worth taking seriously in an app running a detector fifty times a second: nothing about the live level, the relay state or the graph is ever written to disk. Only a settings save touches it, and that is a human-paced action. Writes are atomic - temp file, then rename().

A field missing from the file keeps its built-in default, so a config written by an older version upgrades cleanly rather than losing settings. Values are clamped on the way in as well as on the way out of the form, because the form is not the only thing that can write that file.

Project layout

c8110-var-acap/
├── build.command            # Double-click to build both architectures
├── Dockerfile
├── README.md
├── QUICKSTART.txt
├── older-versions/          # Created automatically by build.command
└── app/
    ├── manifest.json        # App identity, reverse proxy, pipewire group
    ├── LICENSE
    ├── Makefile
    ├── main.c               # Wiring; every side effect hangs off on_relay_event()
    ├── audio.c/h            # PipeWire capture - the only file that sees a sample
    ├── meter.c/h            # Integration window, dBFS, and the graph's history
    ├── vox.c/h              # The relay state machine - state and timing only
    ├── event.c/h            # The custom rule engine conditions
    ├── webui.c/h            # The whole UI (home, three settings pages) and HTTP API
    ├── local_config.c/h     # localdata/config.json
    ├── app_config.h         # APP_NAME, WEB_PORT, TICK_MS
    └── html/index.html      # settingPage stub that redirects to the real UI

vox.c and meter.c have no dependency on PipeWire, the event system or the web UI. They own state and arithmetic, and vox.c reports what happened through one observer callback that main.c registers - so the rules about when a condition fires live in one readable place instead of being scattered through the modules that perform them. It is also what makes them testable off-device, which is where the behavioral checks below run.

There is deliberately no VAPIX in this app, and therefore no libcurl and no D-Bus credential fetch. Everything it needs comes from PipeWire and axevent. The C8110's input gain and mic/line selection are set on the device's own Audio page, which is where they belong.

Building

Requires Docker and the official Axis ACAP Native SDK image (12.11 or later - earlier SDKs have no PipeWire).

Easiest: double-click build.command

It builds both architectures, drops both .eap files into the project root, and bumps the patch version in manifest.json ready for the next build.

Older builds are never lost. Before each build, any .eap already in the project root is moved into older-versions/, so the root only ever shows the newest build at a glance while the last known-working one is still there to fall back on.

Manual, one command per architecture

armv7hf (the C8110 - this is the one you want):

docker build --platform=linux/amd64 --build-arg ARCH=armv7hf --tag c8110var-armv7hf . && docker create --name c8110var-extract --platform=linux/amd64 c8110var-armv7hf && docker cp c8110var-extract:/opt/app/C8110_Voice_Activated_Relay_0_1_0_armv7hf.eap ./C8110_Voice_Activated_Relay_0_1_0_armv7hf.eap && docker rm c8110var-extract

aarch64 (newer Axis audio devices):

docker build --platform=linux/amd64 --build-arg ARCH=aarch64 --tag c8110var-aarch64 . && docker create --name c8110var-extract --platform=linux/amd64 c8110var-aarch64 && docker cp c8110var-extract:/opt/app/C8110_Voice_Activated_Relay_0_1_0_aarch64.eap ./C8110_Voice_Activated_Relay_0_1_0_aarch64.eap && docker rm c8110var-extract

Adjust the filename to the current version in manifest.json, or just use build.command, which discovers it itself.

What has and hasn't been verified

Verified:

  • Builds clean for both architectures with the project's full warning set (-Wall -Wextra -Wformat=2 -Wbad-function-cast -Wstrict-prototypes -Wmissing-prototypes -Wfloat-equal and the rest) against the real ACAP Native SDK 12.11 - no warnings, and both .eap files package successfully.
  • 120 behavioral checks pass against a native harness with a virtual clock:
    • Relay state machine: threshold crossing and the exact-threshold boundary; transient rejection shorter than the attack, firing nothing at all; the hysteresis band holding ACTIVE rather than releasing; hold expiry; speech resuming inside the hold never opening the relay; a hold of zero; the maximum on-time cutting off both from ACTIVE and from HOLD, and not re-triggering on the noise that caused it; lockout, including a signal that never stopped still having to re-qualify; arming and disarming mid-cycle; forced trigger and release; every countdown; the activation counter; and a maximum on-time shortened below the elapsed time releasing immediately.
    • Meter: dBFS conversion at full scale and half scale; RMS averaging a one-tick burst where peak holds it; a short gap between PipeWire buffers carrying the last level forward rather than reading as silence; a long gap decaying to silence and reporting a stall; peak-hold within a graph bucket; and the history ring wrapping correctly at each window length.
  • All four client scripts parse (node --check), and every element id the JavaScript looks up plus every JSON field it reads is cross-referenced against what the C actually emits.
  • The whole web UI was exercised end to end off-device, with the real webui.c, meter.c, vox.c and local_config.c driven by a synthetic input: every page and script route returns 200; the status JSON's hist and act stay the same length and carry real relay bands; every API verb returns the right ok/changed/message including the idempotent and error cases; and a settings POST round-trips through config.json and back into the rendered form for all three pages, seconds-to-milliseconds conversion included.
  • The graph, meter and countdown were checked visually in light and dark mode: the threshold and release lines land at the right positions, relay bands align with the level crossings, the countdown reads "seconds until the relay releases" while holding, and the calibrate button, slider/number pairing and live threshold marks all update as intended.

Verified on a real C8110 (v0.1.0, AXIS OS 12.11):

  • PipeWire capture works. The app auto-selected AudioDevice0Input0.Unprocessed with no configuration, negotiated 2 channels at 48 kHz, and reported a live level. The .Unprocessed preference, the pipewire group in the manifest and the node naming all behaved as documented.

  • The relay fires on real audio. Activations were counted and the home page reported "hold time expired" as the release reason.

  • The conditions appear in the rule engine. C8110 VAR: Relay event shows up under Application in the condition picker and can be bound to an action.

  • The device's full action list was read, which is where the two-rule call pattern above comes from - see the table under "Which condition to use".

  • A rule fires end to end (v0.1.2). ?api=trigger produced, within 3 ms:

    18:13:43.755  c8110var: Relay ACTIVE (activation 1)
    18:13:43.755  actionengined-user: Action rule "VAR to Call" is starting action "Make call"
    18:13:43.758  c8110var: Rule engine event: Relay activated
    18:13:45.768  c8110var: Relay OPEN after 2021 ms - hold time expired
    

    The hold time was honored to 21 ms and both edges fired.

  • Changing the declaration kind did not break an existing rule. A rule built against the old property-state declaration still bound correctly after the upgrade to stateless, with its Event value and state checkbox intact. The rule engine also greys out "Use this condition as a trigger" for the pulse conditions, which is what an event with no duration should do.

Still not verified:

  • The Relay active stateful condition paired with a "while the rule is active" action. The condition is declared correctly and is confirmed to change state in the log (Relay active = true / = false), but the start-and-stop behavior of a while-active action has not been watched end to end.

  • The full call cycle, end to end (v0.1.2). With both rules enabled and their state boxes ticked, one ?api=trigger:

    18:31:44.005  c8110var: Relay ACTIVE (activation 3)
    18:31:44.005  actionengined-user: Action rule "VAR Make Call" is starting action "Make call"
    18:31:44.012  sipd: Making call Out-1-1788733904.012624<RHMziliJ...
    18:31:46.022  c8110var: Relay OPEN after 2031 ms - hold time expired
    18:31:46.029  actionengined-user: Action rule "VAR End Call" is starting action "End calls"
    18:31:46.097  sipd: Terminated Outgoing call: Out-1-1788733904.012624<RHMziliJ...
    

    Exactly one call, the same CallId placed and terminated, and the call's life matched the relay's to within a few tens of milliseconds.

  • An unchecked state checkbox fails closed. The same trigger, with both rules enabled but the boxes unchecked, produced a real activation and both rule-engine events - and zero actions and zero calls.

  • What a sensible default threshold is. -35 dBFS is a starting point, not a recommendation - it will be wrong for most installs, in a direction only the meter can tell you.

Known limitations

  • One relay. One audio input, one threshold, one relay. Deliberate.
  • Input only. It does not monitor what the bridge is playing out.
  • No SIP of its own. The app fires a condition; making a call is the rule engine's job.
  • Anti-VOX is a rule, not a setting. See above - the app has no way to know the device is talking, but the rule engine does.
  • Levels are dBFS, not SPL. Nothing here is calibrated to real-world sound pressure, and it could not be without knowing the microphone.

About

Software voice-activated relay for the AXIS C8110 Network Audio Bridge. Watches the audio input and exposes a threshold-triggered relay as a condition in the device's own events engine - e.g. to place a SIP call when someone keys up a two-way radio.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages