@pellux/goodvibes-sdk 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contracts/artifacts/operator-contract.json +1361 -57
- package/dist/platform/control-plane/method-catalog-runtime.d.ts.map +1 -1
- package/dist/platform/control-plane/method-catalog-runtime.js +95 -1
- package/dist/platform/control-plane/operator-contract-schemas-runtime.d.ts +7 -0
- package/dist/platform/control-plane/operator-contract-schemas-runtime.d.ts.map +1 -1
- package/dist/platform/control-plane/operator-contract-schemas-runtime.js +38 -0
- package/dist/platform/runtime/memory-spine/client.d.ts +125 -12
- package/dist/platform/runtime/memory-spine/client.d.ts.map +1 -1
- package/dist/platform/runtime/memory-spine/client.js +109 -1
- package/dist/platform/runtime/memory-spine/index.d.ts +7 -1
- package/dist/platform/runtime/memory-spine/index.d.ts.map +1 -1
- package/dist/platform/runtime/memory-spine/index.js +6 -0
- package/dist/platform/runtime/memory-spine/recall-snapshot.d.ts +74 -0
- package/dist/platform/runtime/memory-spine/recall-snapshot.d.ts.map +1 -0
- package/dist/platform/runtime/memory-spine/recall-snapshot.js +74 -0
- package/dist/platform/version.js +1 -1
- package/dist/platform/voice/index.d.ts +2 -0
- package/dist/platform/voice/index.d.ts.map +1 -1
- package/dist/platform/voice/index.js +3 -0
- package/dist/platform/voice/spoken-turn/audio-sink.d.ts +111 -0
- package/dist/platform/voice/spoken-turn/audio-sink.d.ts.map +1 -0
- package/dist/platform/voice/spoken-turn/audio-sink.js +1 -0
- package/dist/platform/voice/spoken-turn/controller.d.ts +117 -0
- package/dist/platform/voice/spoken-turn/controller.d.ts.map +1 -0
- package/dist/platform/voice/spoken-turn/controller.js +448 -0
- package/dist/platform/voice/spoken-turn/index.d.ts +16 -0
- package/dist/platform/voice/spoken-turn/index.d.ts.map +1 -0
- package/dist/platform/voice/spoken-turn/index.js +12 -0
- package/dist/platform/voice/spoken-turn/text-chunker.d.ts +33 -0
- package/dist/platform/voice/spoken-turn/text-chunker.d.ts.map +1 -0
- package/dist/platform/voice/spoken-turn/text-chunker.js +97 -0
- package/package.json +9 -9
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recall-snapshot.ts — the sync-recall seam for the memory spine.
|
|
3
|
+
*
|
|
4
|
+
* THE PROBLEM. Per-turn memory recall wants to inject the recall-eligible records
|
|
5
|
+
* into the system prompt. But the prompt builder is SYNCHRONOUS
|
|
6
|
+
* (`Orchestrator.getSystemPrompt()` returns a string, not a promise), while a wire
|
|
7
|
+
* client's memory reads are ASYNCHRONOUS (a fetch to the adopted daemon). A sync
|
|
8
|
+
* function cannot await a wire read, so it cannot pull fresh memory over the wire
|
|
9
|
+
* inline.
|
|
10
|
+
*
|
|
11
|
+
* THE DESIGN (cached snapshot, freshness-stamped, honest staleness note). Rather
|
|
12
|
+
* than make the whole prompt path async (a large, cross-consumer refactor) or open
|
|
13
|
+
* the store file from a wire client (which would break the single-writer invariant),
|
|
14
|
+
* the spine maintains a CACHED recall snapshot:
|
|
15
|
+
*
|
|
16
|
+
* 1. an ASYNC pre-turn hook calls `MemorySpineClient.refreshRecallSnapshot()` —
|
|
17
|
+
* this awaits the wire (or local) honest recall search and stamps the result
|
|
18
|
+
* with a capture time and the access mode it came from;
|
|
19
|
+
* 2. the SYNC prompt builder calls `MemorySpineClient.recallSnapshot()` — this
|
|
20
|
+
* returns the cached records immediately, with a freshly-computed age, a
|
|
21
|
+
* `stale` flag, and a human-readable `note`.
|
|
22
|
+
*
|
|
23
|
+
* HONESTY. The snapshot never lies about its own freshness. If it has never been
|
|
24
|
+
* refreshed, it returns an EMPTY snapshot whose note says exactly that — never a
|
|
25
|
+
* silent empty that a reader would misread as "nothing was ever stored". If the last
|
|
26
|
+
* refresh is older than the staleness window, `stale` is true and the note says so,
|
|
27
|
+
* so an injector can choose to note the staleness in the prompt rather than present
|
|
28
|
+
* possibly-old memory as live. The `mode` records WHERE the snapshot was captured
|
|
29
|
+
* (local host store vs the adopted daemon over the wire), so the note never implies
|
|
30
|
+
* a wire read happened when it did not.
|
|
31
|
+
*/
|
|
32
|
+
import type { MemoryRecord } from '../../state/memory-store.js';
|
|
33
|
+
import type { HonestMemorySearchResult } from '../../state/memory-recall-contract.js';
|
|
34
|
+
import type { MemoryAccessMode } from './client.js';
|
|
35
|
+
/** Default freshness window: a snapshot older than this reports `stale: true`. */
|
|
36
|
+
export declare const DEFAULT_RECALL_SNAPSHOT_STALE_AFTER_MS = 30000;
|
|
37
|
+
export interface MemoryRecallRefreshOptions {
|
|
38
|
+
/**
|
|
39
|
+
* Apply the recall-injection contract when refreshing (exclude flagged records and
|
|
40
|
+
* drop sub-floor records, each exclusion counted). Defaults to TRUE — the snapshot
|
|
41
|
+
* exists for prompt injection, which must honor the recall floor. Pass false to
|
|
42
|
+
* capture an unfiltered browse set instead.
|
|
43
|
+
*/
|
|
44
|
+
readonly recall?: boolean | undefined;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A freshness-stamped, synchronously-readable recall result. `records` is what a
|
|
48
|
+
* sync prompt builder injects; the rest is the honest receipt.
|
|
49
|
+
*/
|
|
50
|
+
export interface MemoryRecallSnapshot {
|
|
51
|
+
/** The recall-eligible records captured by the last refresh (empty until first refresh). */
|
|
52
|
+
readonly records: readonly MemoryRecord[];
|
|
53
|
+
/** The full honest search envelope the records came from (null until first refresh). */
|
|
54
|
+
readonly search: HonestMemorySearchResult | null;
|
|
55
|
+
/** When the snapshot was captured (epoch ms), or null if never refreshed. */
|
|
56
|
+
readonly capturedAt: number | null;
|
|
57
|
+
/** Age of the snapshot in ms at read time, or null if never refreshed. */
|
|
58
|
+
readonly ageMs: number | null;
|
|
59
|
+
/** True when the snapshot is older than the staleness window (or never captured). */
|
|
60
|
+
readonly stale: boolean;
|
|
61
|
+
/** Where the snapshot was captured from — 'client' (over the wire) or 'local'. */
|
|
62
|
+
readonly mode: MemoryAccessMode;
|
|
63
|
+
/** An honest, human-readable freshness/degradation note — never silent. */
|
|
64
|
+
readonly note: string;
|
|
65
|
+
}
|
|
66
|
+
/** The snapshot returned before any refresh has happened — empty, and it says so. */
|
|
67
|
+
export declare function emptyRecallSnapshot(mode: MemoryAccessMode): MemoryRecallSnapshot;
|
|
68
|
+
/**
|
|
69
|
+
* Build a snapshot from an honest search envelope captured at `capturedAt`, with age
|
|
70
|
+
* and staleness computed against `now`. The `note` states the source, the age, and
|
|
71
|
+
* — when stale — that the records may be out of date.
|
|
72
|
+
*/
|
|
73
|
+
export declare function buildRecallSnapshot(search: HonestMemorySearchResult, mode: MemoryAccessMode, capturedAt: number, staleAfterMs: number, now?: number): MemoryRecallSnapshot;
|
|
74
|
+
//# sourceMappingURL=recall-snapshot.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recall-snapshot.d.ts","sourceRoot":"","sources":["../../../../src/platform/runtime/memory-spine/recall-snapshot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,uCAAuC,CAAC;AACtF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEpD,kFAAkF;AAClF,eAAO,MAAM,sCAAsC,QAAS,CAAC;AAE7D,MAAM,WAAW,0BAA0B;IACzC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CACvC;AAED;;;GAGG;AACH,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,QAAQ,CAAC,OAAO,EAAE,SAAS,YAAY,EAAE,CAAC;IAC1C,wFAAwF;IACxF,QAAQ,CAAC,MAAM,EAAE,wBAAwB,GAAG,IAAI,CAAC;IACjD,6EAA6E;IAC7E,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,0EAA0E;IAC1E,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,qFAAqF;IACrF,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,kFAAkF;IAClF,QAAQ,CAAC,IAAI,EAAE,gBAAgB,CAAC;IAChC,2EAA2E;IAC3E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAID,qFAAqF;AACrF,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,gBAAgB,GAAG,oBAAoB,CAWhF;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,wBAAwB,EAChC,IAAI,EAAE,gBAAgB,EACtB,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,MAAM,EACpB,GAAG,GAAE,MAAmB,GACvB,oBAAoB,CAqBtB"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recall-snapshot.ts — the sync-recall seam for the memory spine.
|
|
3
|
+
*
|
|
4
|
+
* THE PROBLEM. Per-turn memory recall wants to inject the recall-eligible records
|
|
5
|
+
* into the system prompt. But the prompt builder is SYNCHRONOUS
|
|
6
|
+
* (`Orchestrator.getSystemPrompt()` returns a string, not a promise), while a wire
|
|
7
|
+
* client's memory reads are ASYNCHRONOUS (a fetch to the adopted daemon). A sync
|
|
8
|
+
* function cannot await a wire read, so it cannot pull fresh memory over the wire
|
|
9
|
+
* inline.
|
|
10
|
+
*
|
|
11
|
+
* THE DESIGN (cached snapshot, freshness-stamped, honest staleness note). Rather
|
|
12
|
+
* than make the whole prompt path async (a large, cross-consumer refactor) or open
|
|
13
|
+
* the store file from a wire client (which would break the single-writer invariant),
|
|
14
|
+
* the spine maintains a CACHED recall snapshot:
|
|
15
|
+
*
|
|
16
|
+
* 1. an ASYNC pre-turn hook calls `MemorySpineClient.refreshRecallSnapshot()` —
|
|
17
|
+
* this awaits the wire (or local) honest recall search and stamps the result
|
|
18
|
+
* with a capture time and the access mode it came from;
|
|
19
|
+
* 2. the SYNC prompt builder calls `MemorySpineClient.recallSnapshot()` — this
|
|
20
|
+
* returns the cached records immediately, with a freshly-computed age, a
|
|
21
|
+
* `stale` flag, and a human-readable `note`.
|
|
22
|
+
*
|
|
23
|
+
* HONESTY. The snapshot never lies about its own freshness. If it has never been
|
|
24
|
+
* refreshed, it returns an EMPTY snapshot whose note says exactly that — never a
|
|
25
|
+
* silent empty that a reader would misread as "nothing was ever stored". If the last
|
|
26
|
+
* refresh is older than the staleness window, `stale` is true and the note says so,
|
|
27
|
+
* so an injector can choose to note the staleness in the prompt rather than present
|
|
28
|
+
* possibly-old memory as live. The `mode` records WHERE the snapshot was captured
|
|
29
|
+
* (local host store vs the adopted daemon over the wire), so the note never implies
|
|
30
|
+
* a wire read happened when it did not.
|
|
31
|
+
*/
|
|
32
|
+
/** Default freshness window: a snapshot older than this reports `stale: true`. */
|
|
33
|
+
export const DEFAULT_RECALL_SNAPSHOT_STALE_AFTER_MS = 30_000;
|
|
34
|
+
const EMPTY_SEARCH_RECORDS = Object.freeze([]);
|
|
35
|
+
/** The snapshot returned before any refresh has happened — empty, and it says so. */
|
|
36
|
+
export function emptyRecallSnapshot(mode) {
|
|
37
|
+
return {
|
|
38
|
+
records: EMPTY_SEARCH_RECORDS,
|
|
39
|
+
search: null,
|
|
40
|
+
capturedAt: null,
|
|
41
|
+
ageMs: null,
|
|
42
|
+
stale: true,
|
|
43
|
+
mode,
|
|
44
|
+
note: 'memory recall snapshot not yet captured — no memory injected this turn. '
|
|
45
|
+
+ 'Call refreshRecallSnapshot() in an async pre-turn hook before the synchronous prompt build.',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Build a snapshot from an honest search envelope captured at `capturedAt`, with age
|
|
50
|
+
* and staleness computed against `now`. The `note` states the source, the age, and
|
|
51
|
+
* — when stale — that the records may be out of date.
|
|
52
|
+
*/
|
|
53
|
+
export function buildRecallSnapshot(search, mode, capturedAt, staleAfterMs, now = Date.now()) {
|
|
54
|
+
const ageMs = Math.max(0, now - capturedAt);
|
|
55
|
+
const stale = ageMs > staleAfterMs;
|
|
56
|
+
const source = mode === 'client' ? 'over the wire from the adopted daemon' : 'from the local store';
|
|
57
|
+
const freshness = stale
|
|
58
|
+
? `STALE — captured ${ageMs}ms ago (older than the ${staleAfterMs}ms freshness window); a refresh is due`
|
|
59
|
+
: `captured ${ageMs}ms ago`;
|
|
60
|
+
const degraded = search.indexUnavailableReason
|
|
61
|
+
? ` Recall degraded to a literal scan: ${search.indexUnavailableReason}.`
|
|
62
|
+
: '';
|
|
63
|
+
const caveat = search.caveat ? ` ${search.caveat}.` : '';
|
|
64
|
+
return {
|
|
65
|
+
records: search.records,
|
|
66
|
+
search,
|
|
67
|
+
capturedAt,
|
|
68
|
+
ageMs,
|
|
69
|
+
stale,
|
|
70
|
+
mode,
|
|
71
|
+
note: `memory recall snapshot ${source}, ${freshness}; ${search.records.length} record(s) recall-eligible.`
|
|
72
|
+
+ degraded + caveat,
|
|
73
|
+
};
|
|
74
|
+
}
|
package/dist/platform/version.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
let version = '1.
|
|
3
|
+
let version = '1.2.0';
|
|
4
4
|
try {
|
|
5
5
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', '..', 'package.json'), 'utf-8'));
|
|
6
6
|
version = pkg.version ?? version;
|
|
@@ -4,4 +4,6 @@ export type { VoiceProviderDescriptor } from './provider-registry.js';
|
|
|
4
4
|
export { VoiceService } from './service.js';
|
|
5
5
|
export type { VoiceServiceStatus } from './service.js';
|
|
6
6
|
export { ensureBuiltinVoiceProviders } from './builtin-providers.js';
|
|
7
|
+
export { SpokenTurnController, TtsTextChunker, normalizeSpeechText } from './spoken-turn/index.js';
|
|
8
|
+
export type { SpokenTurnControllerOptions, TtsTextChunkerOptions, AudioSink, AudioSinkPlaybackOptions, } from './spoken-turn/index.js';
|
|
7
9
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/platform/voice/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,kBAAkB,EAClB,eAAe,EACf,gBAAgB,EAChB,eAAe,EACf,aAAa,EACb,uBAAuB,EACvB,kBAAkB,EAClB,mBAAmB,EACnB,oBAAoB,EACpB,2BAA2B,EAC3B,qBAAqB,EACrB,oBAAoB,EACpB,0BAA0B,EAC1B,yBAAyB,EACzB,wBAAwB,GACzB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,YAAY,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACtE,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,YAAY,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AACvD,OAAO,EAAE,2BAA2B,EAAE,MAAM,wBAAwB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/platform/voice/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,kBAAkB,EAClB,eAAe,EACf,gBAAgB,EAChB,eAAe,EACf,aAAa,EACb,uBAAuB,EACvB,kBAAkB,EAClB,mBAAmB,EACnB,oBAAoB,EACpB,2BAA2B,EAC3B,qBAAqB,EACrB,oBAAoB,EACpB,0BAA0B,EAC1B,yBAAyB,EACzB,wBAAwB,GACzB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,YAAY,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACtE,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,YAAY,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AACvD,OAAO,EAAE,2BAA2B,EAAE,MAAM,wBAAwB,CAAC;AAIrE,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AACnG,YAAY,EACV,2BAA2B,EAC3B,qBAAqB,EACrB,SAAS,EACT,wBAAwB,GACzB,MAAM,wBAAwB,CAAC"}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
export { VoiceProviderRegistry } from './provider-registry.js';
|
|
2
2
|
export { VoiceService } from './service.js';
|
|
3
3
|
export { ensureBuiltinVoiceProviders } from './builtin-providers.js';
|
|
4
|
+
// Spoken-turn (live TTS) policy engine — shared behavioral contract; consumers
|
|
5
|
+
// inject an AudioSink for I/O. See ./spoken-turn.
|
|
6
|
+
export { SpokenTurnController, TtsTextChunker, normalizeSpeechText } from './spoken-turn/index.js';
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { VoiceAudioChunk } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* AudioSink — the injectable I/O boundary the spoken-turn policy engine plays
|
|
4
|
+
* through. The SDK owns POLICY (chunking, the bounded synthesis window, merge
|
|
5
|
+
* coalescing, retry/backoff, the turn state machine); a sink owns I/O (turning
|
|
6
|
+
* an ordered stream of audio bytes into sound and reporting when it has stopped
|
|
7
|
+
* making sound). The controller never spawns a process, opens a device, or
|
|
8
|
+
* touches Web Audio — it only talks to this interface, so the same policy runs
|
|
9
|
+
* unchanged behind a terminal subprocess player, a browser Web Audio sink, or a
|
|
10
|
+
* test fake.
|
|
11
|
+
*
|
|
12
|
+
* ## Behavioral contract
|
|
13
|
+
*
|
|
14
|
+
* The controller depends on these guarantees, not on any implementation detail:
|
|
15
|
+
*
|
|
16
|
+
* 1. **Readiness / head survival.** `play()` must not drop the leading bytes of
|
|
17
|
+
* the stream while the underlying output is still coming up. A subprocess
|
|
18
|
+
* sink holds the first byte until the player has actually exec'd (its spawn
|
|
19
|
+
* event); a browser sink holds until the MediaSource `sourceopen` /
|
|
20
|
+
* SourceBuffer is ready. The controller writes the whole stream and trusts
|
|
21
|
+
* the sink to have played the head — it does not re-send or pre-buffer.
|
|
22
|
+
* `available` is the coarse, synchronous readiness signal (is any output
|
|
23
|
+
* device usable at all); per-call readiness is internal to `play()`.
|
|
24
|
+
*
|
|
25
|
+
* 2. **Natural drain plays everything.** When the input stream ends without an
|
|
26
|
+
* abort, `play()` resolves only after the last buffered sample has been
|
|
27
|
+
* heard — never at end-of-input. Truncating the tail of a response is the
|
|
28
|
+
* bug this contract exists to prevent.
|
|
29
|
+
*
|
|
30
|
+
* 3. **Abort cuts immediately.** When `options.signal` aborts (a deliberate
|
|
31
|
+
* interrupt — new turn, Ctrl+C, /tts stop, turn cancel), `play()` stops
|
|
32
|
+
* emitting sound and resolves promptly; it must NOT wait on a graceful
|
|
33
|
+
* drain. `stop()` is the same instant cut driven imperatively rather than by
|
|
34
|
+
* a signal.
|
|
35
|
+
*
|
|
36
|
+
* 4. **Bounded exit drain.** `waitForDrain(timeoutMs)` resolves when the sound
|
|
37
|
+
* currently playing finishes naturally OR after `timeoutMs`, whichever comes
|
|
38
|
+
* first, and resolves immediately when nothing is playing. The exit path
|
|
39
|
+
* uses this to let the final audio of a finished response play out inside a
|
|
40
|
+
* short window instead of being cut mid-word.
|
|
41
|
+
*
|
|
42
|
+
* ## Web Audio / browser sink mapping (proof the interface fits a browser)
|
|
43
|
+
*
|
|
44
|
+
* A browser sink for the webui voice build implements this same interface over
|
|
45
|
+
* streaming MP3 into MediaSource; no interface change is required:
|
|
46
|
+
*
|
|
47
|
+
* - `available` -> `('MediaSource' in window) && MediaSource.isTypeSupported('audio/mpeg')`
|
|
48
|
+
* (fall back to Web Audio `decodeAudioData` where MSE is absent).
|
|
49
|
+
* - `play(chunks, { format, signal })` ->
|
|
50
|
+
* - create a `MediaSource`, attach it to an `HTMLAudioElement` via
|
|
51
|
+
* `URL.createObjectURL`, and `await` its `sourceopen` event — this is the
|
|
52
|
+
* readiness gate (contract 1);
|
|
53
|
+
* - `addSourceBuffer(mimeFor(format))` where `mimeFor('mp3') === 'audio/mpeg'`
|
|
54
|
+
* (the same `format` string the controller forwards from the synthesis
|
|
55
|
+
* result), then `await audioEl.play()`;
|
|
56
|
+
* - `for await (const chunk of chunks)` -> `sourceBuffer.appendBuffer(chunk.data)`,
|
|
57
|
+
* awaiting each `updateend`, honoring `signal.aborted` between chunks;
|
|
58
|
+
* - on input end without abort: `mediaSource.endOfStream()` and resolve on
|
|
59
|
+
* the audio element's `ended` event -> natural drain (contract 2);
|
|
60
|
+
* - on `signal` abort: `audioEl.pause()`, `sourceBuffer.abort()`,
|
|
61
|
+
* `mediaSource.endOfStream()`, revoke the object URL, resolve now
|
|
62
|
+
* (contract 3).
|
|
63
|
+
* - `stop()` -> the same teardown as the abort branch, driven imperatively.
|
|
64
|
+
* - `waitForDrain(timeoutMs)` -> resolve on the audio element's `ended` event or
|
|
65
|
+
* a `setTimeout(timeoutMs)`, whichever fires first (contract 4).
|
|
66
|
+
*
|
|
67
|
+
* `VoiceAudioChunk.data` is a `Uint8Array`, which `appendBuffer` accepts
|
|
68
|
+
* directly, and the `format` string maps 1:1 to a MediaSource MIME type, so the
|
|
69
|
+
* byte-and-format shape the controller emits is exactly what a browser sink
|
|
70
|
+
* consumes. (The subprocess sink — mpv/ffplay over stdin — stays consumer-side
|
|
71
|
+
* and is not part of the SDK.)
|
|
72
|
+
*/
|
|
73
|
+
export interface AudioSink {
|
|
74
|
+
/** Human-readable sink name for status lines (e.g. 'mpv', 'web-audio'). */
|
|
75
|
+
readonly label: string;
|
|
76
|
+
/**
|
|
77
|
+
* Coarse, synchronous readiness: is any output device usable at all. When
|
|
78
|
+
* false the controller arms nothing and reports the graceful-degradation
|
|
79
|
+
* notice instead of attempting playback.
|
|
80
|
+
*/
|
|
81
|
+
readonly available: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Play an ordered stream of audio chunks to completion. Resolves after the
|
|
84
|
+
* final sample is heard on a natural end, or promptly on abort. See the
|
|
85
|
+
* interface contract above for readiness, drain, and abort obligations.
|
|
86
|
+
*/
|
|
87
|
+
play(chunks: AsyncIterable<VoiceAudioChunk>, options: AudioSinkPlaybackOptions): Promise<void>;
|
|
88
|
+
/** Instant cut: stop emitting sound now and release the output. Idempotent; a no-op when idle. */
|
|
89
|
+
stop(): void;
|
|
90
|
+
/**
|
|
91
|
+
* Resolves once the currently playing sound has finished naturally or after
|
|
92
|
+
* `timeoutMs`, whichever comes first; resolves immediately when nothing is
|
|
93
|
+
* playing. The bounded exit drain — see contract 4.
|
|
94
|
+
*/
|
|
95
|
+
waitForDrain(timeoutMs: number): Promise<void>;
|
|
96
|
+
}
|
|
97
|
+
export interface AudioSinkPlaybackOptions {
|
|
98
|
+
/**
|
|
99
|
+
* Container/codec hint for the byte stream (e.g. 'mp3'), forwarded verbatim
|
|
100
|
+
* from the synthesis result's `format`. A subprocess sink maps it to a
|
|
101
|
+
* demuxer flag; a browser sink maps it to a MediaSource MIME type. Omitted or
|
|
102
|
+
* a value containing '/' (already a MIME type) leaves the sink on its default.
|
|
103
|
+
*/
|
|
104
|
+
readonly format?: string | undefined;
|
|
105
|
+
/**
|
|
106
|
+
* Deliberate-interrupt signal. When it aborts, playback cuts immediately (no
|
|
107
|
+
* graceful drain) and `play()` resolves. Absence means "play to natural end".
|
|
108
|
+
*/
|
|
109
|
+
readonly signal?: AbortSignal | undefined;
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=audio-sink.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audio-sink.d.ts","sourceRoot":"","sources":["../../../../src/platform/voice/spoken-turn/audio-sink.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AACH,MAAM,WAAW,SAAS;IACxB,2EAA2E;IAC3E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B;;;;OAIG;IACH,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC,eAAe,CAAC,EAAE,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/F,kGAAkG;IAClG,IAAI,IAAI,IAAI,CAAC;IACb;;;;OAIG;IACH,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAChD;AAED,MAAM,WAAW,wBAAwB;IACvC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;CAC3C"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { ConfigManager } from '../../config/index.js';
|
|
2
|
+
import type { TurnEvent } from '../../../events/turn.js';
|
|
3
|
+
import type { VoiceService } from '../service.js';
|
|
4
|
+
import type { AudioSink } from './audio-sink.js';
|
|
5
|
+
export interface SpokenTurnControllerOptions {
|
|
6
|
+
readonly voiceService: Pick<VoiceService, 'synthesizeStream'>;
|
|
7
|
+
readonly configManager: Pick<ConfigManager, 'get'>;
|
|
8
|
+
/** The injectable I/O boundary. See {@link AudioSink}. */
|
|
9
|
+
readonly sink: AudioSink;
|
|
10
|
+
readonly notify?: ((message: string) => void) | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Attribution label recorded in each synthesis request's metadata.source
|
|
13
|
+
* (e.g. 'goodvibes-tui', 'goodvibes-agent', 'goodvibes-webui'). Defaults to
|
|
14
|
+
* 'goodvibes-sdk'.
|
|
15
|
+
*/
|
|
16
|
+
readonly source?: string | undefined;
|
|
17
|
+
readonly now?: (() => number) | undefined;
|
|
18
|
+
readonly setInterval?: typeof setInterval | undefined;
|
|
19
|
+
readonly clearInterval?: typeof clearInterval | undefined;
|
|
20
|
+
/** Injectable clock for retry backoff timers (tests use fakes). */
|
|
21
|
+
readonly setTimeout?: typeof setTimeout | undefined;
|
|
22
|
+
readonly clearTimeout?: typeof clearTimeout | undefined;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* SpokenTurnController — the shared spoken-output policy engine. It watches a
|
|
26
|
+
* turn's lifecycle events, chunks the streamed answer into speech-sized pieces,
|
|
27
|
+
* merges and dispatches synthesis requests through a bounded 2-slot window with
|
|
28
|
+
* retry/backoff, and drives an injected {@link AudioSink} with honest
|
|
29
|
+
* drain-vs-interrupt semantics. All I/O is the sink's; this class is pure
|
|
30
|
+
* policy and runs unchanged in the terminal, the agent, and the browser.
|
|
31
|
+
*/
|
|
32
|
+
export declare class SpokenTurnController {
|
|
33
|
+
private pendingPrompt;
|
|
34
|
+
private activeTurnId;
|
|
35
|
+
private chunker;
|
|
36
|
+
private chunkSequence;
|
|
37
|
+
private playbackChain;
|
|
38
|
+
private readonly abortControllers;
|
|
39
|
+
private timer;
|
|
40
|
+
private errorReportedForTurn;
|
|
41
|
+
private noSinkNoticed;
|
|
42
|
+
/** Chunker output waiting to be merged into a synthesis request. */
|
|
43
|
+
private pendingTexts;
|
|
44
|
+
/** Requests currently in the pipeline (synthesizing / waiting / playing). */
|
|
45
|
+
private pipelineDepth;
|
|
46
|
+
/** Bumped on every teardown so stale pipeline releases are ignored. */
|
|
47
|
+
private pipelineGeneration;
|
|
48
|
+
private pumpScheduled;
|
|
49
|
+
/** Set when TURN_COMPLETED arrives; the turn releases once the pipeline drains. */
|
|
50
|
+
private completedTurnId;
|
|
51
|
+
private readonly voiceService;
|
|
52
|
+
private readonly configManager;
|
|
53
|
+
private readonly sink;
|
|
54
|
+
private readonly notify?;
|
|
55
|
+
private readonly source;
|
|
56
|
+
private readonly now;
|
|
57
|
+
private readonly setIntervalImpl;
|
|
58
|
+
private readonly clearIntervalImpl;
|
|
59
|
+
private readonly setTimeoutImpl;
|
|
60
|
+
private readonly clearTimeoutImpl;
|
|
61
|
+
constructor(options: SpokenTurnControllerOptions);
|
|
62
|
+
submitNextTurn(prompt: string): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Returns whether speech was actually ACTIVE when stopped. The notice only
|
|
65
|
+
* prints in that case — stop() on an idle controller used to notify anyway,
|
|
66
|
+
* spamming "[TTS] Spoken output stopped." on every Ctrl+C (an earlier replay
|
|
67
|
+
* fix); callers use the return to decide whether the press "did a
|
|
68
|
+
* job" (see handleCtrlC's consume-on-speech-stop).
|
|
69
|
+
*/
|
|
70
|
+
stop(message?: string): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Exit-path teardown: drops everything not yet audible (pending arm,
|
|
73
|
+
* buffered text, queued chunks) but lets the audio the user is already
|
|
74
|
+
* hearing finish naturally, capped at `drainTimeoutMs`, before the hard
|
|
75
|
+
* stop. Deliberate interrupts (Ctrl+C, /tts stop, turn cancel) keep their
|
|
76
|
+
* instant path through stop(); this is only for exiting the app while the
|
|
77
|
+
* final audio of a completed response is still draining.
|
|
78
|
+
*/
|
|
79
|
+
stopForExit(drainTimeoutMs?: number): Promise<void>;
|
|
80
|
+
handleTurnEvent(event: TurnEvent): void;
|
|
81
|
+
private maybeStartTurn;
|
|
82
|
+
private finishTurn;
|
|
83
|
+
private resetPipeline;
|
|
84
|
+
private startTimer;
|
|
85
|
+
private stopTimer;
|
|
86
|
+
/**
|
|
87
|
+
* Chunker output does NOT map 1:1 to synthesis requests. Text queues here
|
|
88
|
+
* and the pump merges everything pending into one request whenever a
|
|
89
|
+
* pipeline slot is free — so the request count tracks how often the model
|
|
90
|
+
* out-paces the audio, not how many sentences it wrote. A short answer that
|
|
91
|
+
* arrives before the first pump tick is exactly one request.
|
|
92
|
+
*/
|
|
93
|
+
private queueTexts;
|
|
94
|
+
/**
|
|
95
|
+
* Deferred one tick so text delivered in the same synchronous burst (fast
|
|
96
|
+
* deltas, or a turn that completes instantly) coalesces into a single
|
|
97
|
+
* request instead of firing per sentence boundary.
|
|
98
|
+
*/
|
|
99
|
+
private schedulePump;
|
|
100
|
+
private pump;
|
|
101
|
+
/** Merge everything pending into one request, capped at the per-request text limit. */
|
|
102
|
+
private takeMergedText;
|
|
103
|
+
private dispatchChunk;
|
|
104
|
+
private releasePipelineSlot;
|
|
105
|
+
private maybeReleaseTurn;
|
|
106
|
+
private synthesizeWithRetry;
|
|
107
|
+
/** Abortable backoff sleep — an abort clears the timer and rejects, so a stop mid-backoff leaves nothing running. */
|
|
108
|
+
private delay;
|
|
109
|
+
private synthesize;
|
|
110
|
+
/**
|
|
111
|
+
* One synthesis request failed after its retries. Report once per turn and
|
|
112
|
+
* keep going — the rest of the response still plays.
|
|
113
|
+
*/
|
|
114
|
+
private reportSkippedChunk;
|
|
115
|
+
private reportError;
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=controller.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"controller.d.ts","sourceRoot":"","sources":["../../../../src/platform/voice/spoken-turn/controller.ts"],"names":[],"mappings":"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"}
|