# Changelog Notable changes to this project. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/). ## [0.1.6] - 2026-09-05 ### Fixed - Closing an interrupted turn no longer cancels a Piper render that is under the voice lock. `_TurnSpeaker.close()` cancelled the synthesis task outright, and when a barge-in landed while the next sentence was rendering, the cancel hit the `to_thread` await inside the `_VOICE_LOCK` context and released the lock while espeak-ng was still running in the worker thread. The next render, from this call or any other, then entered Piper concurrently, which is the thread-safety problem the lock exists to prevent and which 0.1.4 had deliberately avoided on the polling path. `close()` now asks the loop to stop and waits for it: `_put` returns as soon as it polls and `synthesise` discards a finished render unplayed, so the task ends by itself once the in-flight render returns. Cancel remains as a 20 s backstop for a provider that hangs. Measured in the shipped image with the real voice, barging in five frames into a turn while the second sentence rendered, then rendering from a second call straight away: | | close() | task | concurrent Piper renders | second call's render | |---|---|---|---|---| | 0.1.5 | 0.00 s | cancelled | 2 | 2.14 s | | 0.1.6 | 1.32 s | finished | 1 | 1.32 s | The wait is the remainder of the render that was already in flight. Nothing about the barge-in itself changes: playback still stops at the next frame, and the discarded render was never going to be heard either way. - A barge-in no longer corrupts the conversation history. Breaking out of the LLM stream skipped the assistant append, so the model never learned it had said anything, and if it had already asked for a tool before the caller cut in, the tool still ran and its result was appended against a tool_use that was never recorded. The API rejects that request and every request after it, so one badly timed interrupt left the caller hearing only the apology line for the rest of the call. After an interrupt the last assistant message is now the sentences that actually reached the transport (to sentence granularity: the one that was cut off is kept in full, since the model should know what it was saying), any tool_use is dropped with the tools it named, and the abandoned API stream is closed at once rather than when the generator is collected. - TTS chunks are cut at sentence ends only. The splitter fired on every comma, semicolon and colon, so each clause played as its own utterance and the voice lost the run of the sentence at each cut, and it took any full stop before whitespace as a sentence end, so "Dr. Smith", "e.g. tea", "J. Smith" and "1. Restart the phone" were cut in half. Sentence terminators now need whitespace after them and are ignored after a known abbreviation, an initial or a list number; clause breaks are used only once a sentence has run past 120 characters, so a long one still starts playing before the model finishes it. The Urdu and Devanagari terminators are unchanged. ### Changed - The 8 kHz format is now called `slin` throughout the README, PORTING.md and the module docstrings. In Asterisk naming `slin` is 16-bit signed linear at 8 kHz and `slin16` is the same at 16 kHz, so 320 bytes per 20 ms is plain `slin`; the old label would have led anyone copying the numbers into a 16 kHz setup to 10 ms frames. Nothing on the wire changes. ### Added - `tests/test_sentence_split.py` and `tests/test_interrupted_turn.py`, the latter driving the real `LLM` and `Call` classes through a scripted Anthropic stream, plus three new cases in `tests/test_turn_speaker.py` covering the close-without-cancel path, the hung-provider backstop and `spoken_text()`. All wired into CI. ## [0.1.5] - 2026-09-05 ### Fixed - Sentence N+1 now synthesises while sentence N is still playing. `say()` played one sentence at a time and returned only once the caller had heard it, so every sentence boundary carried a silent gap the length of the next sentence's synthesis, on every sentence rather than just the first. A turn now feeds one frame queue that the transport plays from continuously, with synthesis running ahead of playback up to a bounded lookahead. (#2) Measured on Asterisk 22.10.1 with `Echo()`, three sentences, using the arrival times of the echoed audio as the caller's experience of the turn: | per sentence | before | after | |---|---|---| | 0.5 s synthesis, 0.4 s audio | 2.65 s turn, two 500 ms holes | 1.88 s turn, 120 ms worst gap | | 0.25 s synthesis, 0.5 s audio | n/a | 1.74 s turn, 21 ms worst gap, no holes | Overlap cannot create throughput. Where a sentence costs more to synthesise than it takes to play, the pipeline falls behind by that difference on every sentence and the residual gap is the deficit; what goes away is the old gap, which was the entire synthesis time. Where synthesis is faster than playback, which is the usual case, the gap closes completely. The lookahead queue is unbounded with synthesis gated on its depth, rather than a bounded queue: a bounded one can refuse the end-of-turn sentinel when it is full, and a dropped sentinel leaves playback waiting for ever. A second effect worth knowing about: `speaking` now stays true across the whole turn instead of flickering false at each sentence boundary, so a caller who starts talking in a gap is detected as barge-in rather than missed. ### Added - `tests/test_turn_speaker.py`, timing the frames rather than counting them, because a gap between sentences still delivers every word. Covers overlap, boundary gap, barge-in stopping both synthesis and playback, the lookahead bound, and an empty turn. Wired into CI. ### Notes - Investigated and closed #3 (the process-wide Piper lock) without a code change. Moving synthesis into worker processes was measured against the lock in the shipped image with a real voice, four concurrent calls, five trials: 2.30 s against 1.99 s total, and the first caller waited 1.89 s instead of 0.48 s. Worse on every axis. ONNX Runtime already parallelises a single render across every core, so the lock serialises work that is already saturating the CPU, and the pool only adds contention, IPC, and the loss of FIFO ordering. Capping each worker to one ONNX thread was worse again, 5.41 s against 2.00 s. The ceiling is CPU, not the lock. Full numbers on the issue. ## [0.1.4] - 2026-09-04 ### Fixed - Barge-in now reaches synthesis. `Call._should_stop()` was polled only by the transports, inside the loop that consumes frames, so it could not run until synthesis had produced a frame. `StreamingTTS.synthesise()` renders a whole sentence before yielding its first one, which meant a caller who interrupted while we were still rendering had no effect at all: the sentence was built in full, then playback stopped at frame zero. The stop check is now passed into `synthesise()` and polled around synthesis as well as between frames. On the ElevenLabs path this closes the HTTP stream, so an interrupted sentence stops being billed. On the Piper path the render is deliberately *not* cancelled: it runs in a worker thread under the process-wide `_VOICE_LOCK`, and abandoning the await would release that lock while espeak-ng still held the voice, which is the thread-safety problem the lock exists to prevent. Piper is checked either side of the render instead, so the audio is discarded rather than played. (#4) - ElevenLabs audio now streams. The `/stream` endpoint was already being called, but the response was consumed with `resp.content`, which waits for the whole body, so the caller heard nothing until the entire utterance had been synthesised. The response is now read with `aiter_bytes()`, resampled incrementally and yielded frame by frame, so first audio lands at roughly time-to-first-byte. (#1) ### Changed - Outbound media now goes through a `Transport` interface (`transport.py`) instead of `Call` holding an asyncio reader/writer pair directly. `AudioSocketTransport` is the only implementation and behaviour is unchanged; the 20 ms metered writer moved from `Call._paced_write` to `AudioSocketTransport.play` verbatim. The split is at `play()` rather than at frame encoding, because that is where AudioSocket and `chan_websocket` genuinely differ: under AudioSocket we own the playout clock and must not queue ahead, since queued audio cannot be taken back on barge-in, whereas `chan_websocket` lets Asterisk pace and `FLUSH_MEDIA` takes queued audio back. Measured on Asterisk 22.10.1: 18 s queued, flushed after 2 s, and not one flushed byte reached the caller. ### Added - `WebSocketTransport`, so the sidecar can also run over `chan_websocket` on Asterisk 20.18+/22.8+. Off unless `listen.websocket_port` is set; AudioSocket remains the default and both can run at once. Asterisk owns the playout clock here, so `play()` hands audio over ahead of real time and waits out the remainder rather than metering frames, and barge-in sends `FLUSH_MEDIA` instead of simply ceasing to write. It buffers 2 s ahead: enough to ride out a TTS stall, and far clear of the 900-frame (18 s) `MEDIA_XOFF` watermark. Queueing deeper would cost nothing in barge-in latency but buys nothing either. `chan_websocket` has no UUID frame, and the `connection_id` is identical on every call, so the persona allowlist would have had nothing to key on. The call id comes from the dial string instead — `Dial(WebSocket/conn1/c(slin),v(uuid=${CALLUUID}))` — which arrives as a query parameter on the HTTP handshake, before any media. So an unregistered call is still refused before a pipeline is built, marginally earlier than AudioSocket manages. Verified on 22.10.1, along with the `Set(__FOO=)`/`Set(_FOO=)` distinction noted in the README. - `tests/test_websocket_transport.py`, which drives the transport against a fake chan_websocket peer and asserts the three behaviours that would otherwise fail silently: the id is read from the handshake URI, playback runs ahead of real time yet `play()` still returns only once the caller has heard it, and barge-in emits `FLUSH_MEDIA`. Wired into CI. - `packaging/systemd/asterisk-ai-voice-agent.service`, so a pip install can run as a service without Docker. Runs as a dedicated unprivileged system user, restarts on failure, and keeps `/etc/asterisk-ai-voice-agent` read-only to the process via `ProtectSystem=strict`, because the config file holds API keys. Piper voices live in a `StateDirectory=`, which is the only writable path. Requested by `crystalsighting` on r/Asterisk. - `_StreamResampler`, a linear resampler that can be fed in chunks without a seam at each join. `_resample_linear` interpolates across `linspace(0, N-1)`, so its step depends on the total length and it cannot be used on a stream; the streaming one holds a fixed ratio and carries the unconsumed input samples and fractional read position between calls. - `tests/test_tts_stream.py`, covering both fixes plus the resampler: chunked output is seam-free at any chunk size, a 220 Hz tone stays 220 Hz, the first frame is available from the first network chunk, a stop mid-stream abandons the response rather than draining it, and Piper audio rendered during a barge-in is discarded rather than played. Wired into CI. ## [0.1.3] - 2026-08-17 ### Fixed - The start of the first word of an utterance is no longer clipped. WebRTC VAD only calls a frame voiced once the talk-spurt carries enough energy, so a quiet onset (a leading fricative, the closure before a plosive) was discarded before the utterance buffer opened and the transcript began mid-word. The STT now keeps a rolling 300 ms window of pre-onset frames and prepends it when the utterance starts. It is most audible after barge-in, where the caller's first word competes with the agent still speaking. Suggested by `Asteriskdev` on r/Asterisk. One ring buffer per call, filled only while no utterance is open and drained at each talk-spurt, so the cost is fixed at 4.8 kB per call. The prepended audio is deliberately not counted towards `voice_ms`, which is what bounds the hallucination word-density check. ### Added - `tests/test_lookback.py`, which drives `feed()` with a scripted VAD and asserts the pre-onset frames arrive at the transcriber, that the window stays bounded, and that `voice_ms` still counts only genuinely voiced audio. ## [0.1.2] - 2026-08-12 ### Security - The persona registry is now a strict allowlist. An AudioSocket connection whose UUID was never pre-registered by the dialplan is dropped instead of being served the `demo` persona, so the UUID acts as an authentication check against connections from unauthorized sources. Suggested by `ldo` on the Asterisk community forum. ### Fixed - `TCP_NODELAY` is now set on every accepted AudioSocket connection. Outbound audio is one 320-byte frame every 20 ms, and Nagle's algorithm holds writes that small back waiting to coalesce them, so the pacing the writer works hard to get right could still be undone by the kernel. Reported by `crystalsighting` on r/Asterisk. ### Changed - **Behaviour change:** if the dialplan's `POST /register` never reaches the sidecar (for example the `curl` times out), the call is now dropped rather than answered by the fallback persona. ### Added - `tests/test_allowlist.py`, which drives `Call.run()` over a real UUID frame and checks both directions: unregistered UUIDs are rejected before the pipeline is built, and pre-registered ones still reach it with persona and caller intact. Wired into CI. - `tests/test_nodelay.py`, which starts the real server on an ephemeral port, connects to it, and reads `TCP_NODELAY` back off the accepted socket. Wired into CI. - README section on latency and network tuning. ## [0.1.0] - 2026-08-09 First public release. Extracted from the ICTContact AI Voice Agent, de-coupled from the platform (multi-tenancy, billing, internal REST), and made config-driven. ### Added - AudioSocket media sidecar: streaming STT → LLM → TTS over a single call. - `agent.py` orchestrator: pre-register HTTP endpoint + AudioSocket server, reader/consumer tasks, sentence-buffered playback, barge-in. - `stt.py`: OpenAI Whisper + ElevenLabs Scribe, WebRTC VAD end-of-utterance detection, DTX/silence-gap watchdog, hallucination filtering. - `llm.py`: Anthropic Claude, streamed, with `tool_use`. - `tts.py`: Piper (local) + ElevenLabs (cloud), resampled to 8 kHz slin16, process-wide voice cache; voices directory is configurable (no hardcoded paths). - `tools.py`: default tool specs (transfer, schedule_callback, mark_dnc, crm_lookup, crm_update, send_sms), relayed to a webhook you control. - Per-agent personas in YAML; Asterisk dialplan include; Docker + compose; Piper voice downloader. ### Known limitations - v0.1: verified by construction, not yet exercised on a live call end-to-end. - Tool *behaviour* is external, so you implement the webhook. - TTS/STT/LLM providers beyond those listed require implementing the module interface (see PORTING.md). [0.1.2]: https://github.com/ictinnovations/asterisk-ai-voice-agent/releases/tag/v0.1.2 [0.1.0]: https://github.com/ictinnovations/asterisk-ai-voice-agent/releases/tag/v0.1.0