CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# 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