Skip to content

Commit d62158b

Browse files
Merge pull request #27 from decibri/development
feat: stop recording when the speaker stops talking
2 parents acd93d7 + fc965a3 commit d62158b

12 files changed

Lines changed: 1440 additions & 179 deletions

CHANGELOG.md

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.5.0] - 2026-07-15
11+
12+
### Added
13+
14+
- Silence-stopped capture. `capture_audio` gains `stop_on_silence`
15+
(default `false`): when set, the recording ends when the speaker stops
16+
talking, detected with the Silero voice-activity model that ships
17+
inside decibri and runs on-device through its bundled ONNX Runtime, so
18+
nothing extra is downloaded and no audio leaves the machine. In that
19+
mode `duration_ms` becomes a maximum rather than an exact length, and
20+
the new `silence_ms` argument (default 1000, range 100-10000) sets how
21+
much continuous silence ends the recording. Recording ends at the
22+
first of: `silence_ms` of continuous post-speech silence, the
23+
`duration_ms` ceiling, or 10 seconds with no speech at all. The result
24+
reports the actual captured `duration_ms`, which condition ended it
25+
(`stopped_by`), and whether speech was heard (`speech_detected`).
26+
Every stop condition is measured in delivered audio, never wall-clock
27+
timers, so the stop point is an exact function of the audio; the
28+
detector only decides when to stop and never alters the recorded
29+
samples, and a fixed-duration capture is unchanged byte for byte.
30+
- `voice_query` stops on silence by default, with a 15 second ceiling.
31+
Its only consumer is speech transcription and a spoken query has no
32+
known length, so the previous fixed window truncated long questions
33+
and recorded silence after short ones. `stop_on_silence: false`
34+
restores the previous fixed 5 second window exactly.
35+
- Deterministic test suites, runnable on every platform with no
36+
microphone, whisper, or Ollama: the silence-stop state machine
37+
(scripted per-chunk scores pinning the exact stop chunk and byte count
38+
for each stop condition) and the `voice_query` no-answer contract
39+
(including that a filler-only transcription never reaches the model).
40+
41+
### Changed
42+
43+
- `voice_query`'s no-answer results are restructured, and a caller that
44+
branched on the previous shapes will observe the change. No speech at
45+
all, and speech that produced no usable words, are now non-error
46+
results whose machine-readable contract is `speech_detected` (`false`
47+
vs `true`) with `transcription: null`, so a caller distinguishes "the
48+
user was silent" from "the user spoke but produced no words" without
49+
parsing prose. Empty transcription previously returned `isError: true`
50+
and no longer does. A transcription failure, an unreachable or erroring
51+
Ollama, and an empty model response remain `isError: true`, each
52+
surfacing its real cause. The rule is that a pipeline that ran and
53+
found no words is a success, while a broken dependency is an error.
54+
The structured fields are the contract; any human-readable message is
55+
not, so callers branch on the fields rather than the prose.
56+
57+
### Fixed
58+
59+
- Whisper's non-speech markers no longer reach the language model. When
60+
a recording contained silence or noise, whisper returns markers such
61+
as `[BLANK_AUDIO]`, `[MUSIC]`, or `(silence)` as ordinary text, and
62+
these were forwarded to Ollama as the user's query, so the model
63+
answered a question the user never asked. A transcription is now
64+
treated as having usable words only if, after removing every bracketed
65+
and parenthesised marker, at least one letter or digit remains, so a
66+
marker-only or whitespace-only transcription is reported as no words
67+
and never reaches the model. The check is conservative: a real
68+
transcription that merely contains a bracketed word keeps its words
69+
and is unaffected.
70+
1071
## [0.4.0] - 2026-07-14
1172

1273
### Fixed
@@ -251,7 +312,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
251312
listing, WAV output validation, and error responses.
252313
- Tag-triggered npm publish workflow.
253314

254-
[Unreleased]: https://github.com/decibri/mcp-listen/compare/v0.4.0...HEAD
315+
[Unreleased]: https://github.com/decibri/mcp-listen/compare/v0.5.0...HEAD
316+
[0.5.0]: https://github.com/decibri/mcp-listen/compare/v0.4.0...v0.5.0
255317
[0.4.0]: https://github.com/decibri/mcp-listen/compare/v0.3.0...v0.4.0
256318
[0.3.0]: https://github.com/decibri/mcp-listen/compare/v0.2.1...v0.3.0
257319
[0.2.1]: https://github.com/decibri/mcp-listen/compare/v0.2.0...v0.2.1

README.md

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Intel Mac (darwin-x64) is not supported: Apple has discontinued the platform and
9191

9292
## Tool Reference
9393

94-
Arguments are validated before anything is recorded or written. An argument a tool does not declare is rejected with an error naming it, rather than silently ignored. `duration_ms` must be an integer between 100 and 30000; `device` must be a non-negative integer index or a non-empty string id from `list_audio_devices`. A rejected call writes nothing to disk.
94+
Arguments are validated before anything is recorded or written. An argument a tool does not declare is rejected with an error naming it, rather than silently ignored. `duration_ms` must be an integer between 100 and 30000; `silence_ms` an integer between 100 and 10000; `stop_on_silence` a boolean; `device` a non-negative integer index or a non-empty string id from `list_audio_devices`. A `silence_ms` that cannot take effect (passed without silence-stopping active) is rejected for the same reason unknown arguments are. A rejected call writes nothing to disk.
9595

9696
### list_audio_devices
9797

@@ -112,16 +112,18 @@ The `id` is stable across reboots and device changes. The `index` is positional
112112

113113
### capture_audio
114114

115-
Records audio from the microphone and saves as a WAV file.
115+
Records audio from the microphone and saves as a WAV file. Records for exactly `duration_ms` by default, or until the speaker stops talking with `stop_on_silence: true`.
116116

117117
**Parameters:**
118118

119119
| Parameter | Type | Default | Description |
120120
| ---------- | ------ | --------- | ------------- |
121-
| `duration_ms` | number | 5000 | Recording duration in milliseconds (100-30000) |
121+
| `duration_ms` | number | 5000 | Recording duration in milliseconds (100-30000). A maximum, not an exact length, when `stop_on_silence` is true |
122122
| `device` | number or string | system default | Device index or stable device `id` from `list_audio_devices` |
123+
| `stop_on_silence` | boolean | false | Stop when the speaker stops talking, detected with on-device voice activity detection (Silero VAD, bundled, no download) |
124+
| `silence_ms` | number | 1000 | Continuous silence in milliseconds that ends a `stop_on_silence` recording (100-10000). Requires `stop_on_silence: true` |
123125

124-
**Example response:**
126+
**Example response (fixed duration):**
125127

126128
```json
127129
{
@@ -133,16 +135,34 @@ Records audio from the microphone and saves as a WAV file.
133135
}
134136
```
135137

138+
**Example response (`stop_on_silence: true`):**
139+
140+
```json
141+
{
142+
"path": "/tmp/mcp-listen-1712345678901.wav",
143+
"duration_ms": 2600,
144+
"sample_rate": 16000,
145+
"channels": 1,
146+
"size_bytes": 83244,
147+
"stopped_by": "silence",
148+
"speech_detected": true
149+
}
150+
```
151+
152+
With `stop_on_silence`, `duration_ms` in the response is the actual captured length, and `stopped_by` says how the recording ended: `"silence"` (the speaker finished), `"ceiling"` (the `duration_ms` maximum was reached), or `"no_speech_timeout"` (nobody spoke for 10 seconds; the WAV is still returned, with `speech_detected: false`, so silence is a reported outcome rather than an error). Detection runs per ~100ms audio buffer, so the effective hangover rounds up to the next buffer, and the recording keeps everything from the start of the call through the stop decision: nothing is gated or clipped at speech boundaries, and the audio itself is byte-identical to a fixed-duration capture of the same sounds.
153+
136154
### voice_query
137155

138-
Full voice pipeline: capture audio, transcribe with whisper.cpp, send to Ollama, return the response. Entirely offline.
156+
Full voice pipeline: capture audio, transcribe with whisper.cpp, send to Ollama, return the response. Entirely offline. Recording stops automatically when the speaker stops talking; pass `stop_on_silence: false` for a fixed-length recording.
139157

140158
**Parameters:**
141159

142160
| Parameter | Type | Default | Description |
143161
| ----------- | ------ | --------- | ------------- |
144-
| `duration_ms` | number | 5000 | Recording duration in milliseconds (100-30000) |
162+
| `duration_ms` | number | 15000 / 5000 | Maximum recording duration in milliseconds (100-30000). Default 15000 while silence-stopping is active, 5000 with `stop_on_silence: false` |
145163
| `device` | number or string | system default | Device index or stable device `id` from `list_audio_devices` |
164+
| `stop_on_silence` | boolean | true | Stop recording when the speaker stops talking. Pass false to record for exactly `duration_ms` |
165+
| `silence_ms` | number | 1000 | Continuous silence in milliseconds that ends the recording (100-10000) |
146166
| `whisper_model` | string | ggml-base.en.bin | Path or filename of Whisper GGML model |
147167
| `language` | string | en | Language code for transcription |
148168
| `model` | string | llama3.2 | Ollama model name |
@@ -158,12 +178,52 @@ Full voice pipeline: capture audio, transcribe with whisper.cpp, send to Ollama,
158178
}
159179
```
160180

181+
**Result outcomes.** `voice_query` reports five distinct outcomes. The **structured fields are the contract** (`isError`, `speech_detected`, `transcription`); any `message` is a human-readable hint whose wording is not part of the contract, so a caller branches on the fields, never on the prose. The rule is simple: **if the pipeline ran, the result is a success (even when it found no words); if a dependency broke, the result is an error.**
182+
183+
| Outcome | `isError` | `speech_detected` | `transcription` | `response` |
184+
| --- | --- | --- | --- | --- |
185+
| Normal | absent | (`true`/omitted) | the text | the answer |
186+
| No speech at all | absent | `false` | `null` | `null` |
187+
| Speech, but no transcribable words | absent | `true` | `null` | `null` |
188+
| Transcription step failed | `true` ||||
189+
| Ollama unavailable, errored, or empty | `true` ||||
190+
191+
A caller distinguishes "the user was silent" from "the user spoke but produced no words" by `speech_detected` (`false` vs `true`), both carrying `transcription: null`. Non-speech audio never reaches the language model: whisper's non-speech markers (`[BLANK_AUDIO]`, `[MUSIC]`, `(silence)`, and similar) are treated as no usable words rather than sent on as a query.
192+
193+
**No speech** (`speech_detected: false`):
194+
195+
```json
196+
{
197+
"speech_detected": false,
198+
"stopped_by": "no_speech_timeout",
199+
"transcription": null,
200+
"response": null,
201+
"message": "No speech was detected. Ask the user to repeat, or check that the correct microphone is selected."
202+
}
203+
```
204+
205+
**Speech, but no transcribable words** (`speech_detected: true`, `transcription: null`):
206+
207+
```json
208+
{
209+
"speech_detected": true,
210+
"stopped_by": "silence",
211+
"transcription": null,
212+
"response": null,
213+
"message": "Speech was detected but could not be transcribed. It may have been too quiet, too brief, or unclear. Ask the user to repeat, a little louder and closer to the microphone."
214+
}
215+
```
216+
217+
Transcription and dependency failures return `isError: true` with the real cause (a missing model, a whisper load failure, Ollama not running, a timeout, or an empty model response), so a caller debugging can tell whether the failure was in capture, transcription, or the language model.
218+
161219
## How It Works
162220

163221
mcp-listen uses [decibri](https://decibri.com) for cross-platform microphone capture. No ffmpeg, no SoX, no system audio tools required. Pre-built native binaries with zero setup.
164222

165223
Audio is captured as 16-bit PCM at 16kHz mono, the standard format for speech-to-text engines.
166224

225+
Silence-stopping uses the Silero voice activity detection model that ships inside decibri, running on-device through the bundled ONNX Runtime. Nothing extra is downloaded and no audio leaves the machine. The stop decision is measured in captured audio, not wall-clock time, and the VAD only decides when to stop: it never gates or alters the recorded samples.
226+
167227
The `voice_query` tool runs the full pipeline locally: capture audio, transcribe with whisper.cpp, and send to a local Ollama LLM. Fully offline, nothing leaves your machine.
168228

169229
## Whisper Model Setup
@@ -194,7 +254,7 @@ The model is ~150MB and downloads once. You can also set the `WHISPER_MODEL_PATH
194254

195255
## Known Limitations
196256

197-
1. **Fixed recording duration.** You specify how long to record. There is no "stop when I stop talking" mode yet.
257+
1. **A loud transient can register as speech.** Silence-stopping decides "speech has started" from the VAD score, so a door slam or a cough can start the countdown and end the recording after `silence_ms` of quiet, yielding a short capture of mostly silence. The outcome is visible, not silent: the result reports the actual duration, and `voice_query` reports an empty transcription rather than inventing one. A minimum-speech-duration guard is a candidate refinement.
198258
2. **`voice_query` requires Ollama running.** If Ollama isn't running, the tool returns a clear error message.
199259
3. **Whisper model must be downloaded before first use.** `voice_query` does not download the model itself; the first call requires a pre-downloaded model (~150MB). See [Whisper Model Setup](#whisper-model-setup).
200260
4. **No streaming.** MCP's request/response pattern means the entire recording is captured, then transcribed, then sent to the LLM. No real-time partial results.

SECURITY.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ This security policy applies to the following versions:
8181

8282
| Version | Supported |
8383
| --- | --- |
84-
| 0.4.x | Yes |
85-
| < 0.4 | No |
84+
| 0.5.x | Yes |
85+
| < 0.5 | No |
8686

8787
Security fixes are applied to the latest release only. mcp-listen is pre-1.0 and older versions are not backported. Upgrade to the latest release.
8888

0 commit comments

Comments
 (0)