@perkos/perkos-voice 0.1.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/LICENSE +21 -0
- package/README.md +218 -0
- package/dist/a2aEnrollment.d.ts +41 -0
- package/dist/a2aEnrollment.js +110 -0
- package/dist/acceptance.d.ts +25 -0
- package/dist/acceptance.js +147 -0
- package/dist/acceptanceCli.d.ts +2 -0
- package/dist/acceptanceCli.js +6 -0
- package/dist/adapters/livekit.d.ts +15 -0
- package/dist/adapters/livekit.js +236 -0
- package/dist/adapters/openaiSpeech.d.ts +24 -0
- package/dist/adapters/openaiSpeech.js +194 -0
- package/dist/adapters/openclaw.d.ts +58 -0
- package/dist/adapters/openclaw.js +236 -0
- package/dist/adapters/speech.d.ts +13 -0
- package/dist/adapters/speech.js +57 -0
- package/dist/adapters/zeroclaw.d.ts +12 -0
- package/dist/adapters/zeroclaw.js +36 -0
- package/dist/bootstrap.d.ts +2 -0
- package/dist/bootstrap.js +68 -0
- package/dist/bragiDelivery.d.ts +31 -0
- package/dist/bragiDelivery.js +263 -0
- package/dist/bragiDeliveryCli.d.ts +2 -0
- package/dist/bragiDeliveryCli.js +32 -0
- package/dist/capability.d.ts +15 -0
- package/dist/capability.js +84 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +188 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.js +107 -0
- package/dist/doctor.d.ts +55 -0
- package/dist/doctor.js +423 -0
- package/dist/doctorCli.d.ts +2 -0
- package/dist/doctorCli.js +36 -0
- package/dist/echoSuppression.d.ts +13 -0
- package/dist/echoSuppression.js +42 -0
- package/dist/fakes.d.ts +42 -0
- package/dist/fakes.js +80 -0
- package/dist/gateway.d.ts +46 -0
- package/dist/gateway.js +418 -0
- package/dist/grants.d.ts +22 -0
- package/dist/grants.js +40 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +23 -0
- package/dist/installer.d.ts +41 -0
- package/dist/installer.js +83 -0
- package/dist/mediaMetrics.d.ts +15 -0
- package/dist/mediaMetrics.js +43 -0
- package/dist/mediaStages.d.ts +12 -0
- package/dist/mediaStages.js +48 -0
- package/dist/onboarding.d.ts +67 -0
- package/dist/onboarding.js +72 -0
- package/dist/openclaw-plugin.d.ts +16 -0
- package/dist/openclaw-plugin.js +47 -0
- package/dist/ports.d.ts +23 -0
- package/dist/ports.js +1 -0
- package/dist/presenceTone.d.ts +5 -0
- package/dist/presenceTone.js +43 -0
- package/dist/readiness.d.ts +27 -0
- package/dist/readiness.js +68 -0
- package/dist/service.d.ts +10 -0
- package/dist/service.js +42 -0
- package/dist/sessionControl.d.ts +55 -0
- package/dist/sessionControl.js +118 -0
- package/dist/speechErrors.d.ts +18 -0
- package/dist/speechErrors.js +26 -0
- package/dist/state-machine.d.ts +55 -0
- package/dist/state-machine.js +107 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +1 -0
- package/dist/voiceSubtask.d.ts +12 -0
- package/dist/voiceSubtask.js +46 -0
- package/dist/workCallContext.d.ts +14 -0
- package/dist/workCallContext.js +26 -0
- package/docs/external-agent-onboarding.md +152 -0
- package/external-agent-contract.schema.json +46 -0
- package/openclaw.plugin.json +27 -0
- package/package.json +78 -0
- package/scripts/hermes/install.mjs +70 -0
- package/scripts/run-with-env.mjs +23 -0
- package/scripts/zeroclaw/install.mjs +76 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export class EchoSuppressionGate {
|
|
2
|
+
now;
|
|
3
|
+
cooldownMs;
|
|
4
|
+
onSuppressed;
|
|
5
|
+
#barrier;
|
|
6
|
+
#outputDepth = 0;
|
|
7
|
+
#guardUntil = 0;
|
|
8
|
+
#marked = false;
|
|
9
|
+
constructor(now = Date.now, cooldownMs = 350, onSuppressed = () => { }) {
|
|
10
|
+
this.now = now;
|
|
11
|
+
this.cooldownMs = cooldownMs;
|
|
12
|
+
this.onSuppressed = onSuppressed;
|
|
13
|
+
}
|
|
14
|
+
start(barrier) {
|
|
15
|
+
if (this.#barrier)
|
|
16
|
+
return;
|
|
17
|
+
this.#marked = false;
|
|
18
|
+
const current = barrier.catch(() => { }).then(() => { this.#guardUntil = this.now() + this.cooldownMs; });
|
|
19
|
+
this.#barrier = current.finally(() => { this.#barrier = undefined; });
|
|
20
|
+
}
|
|
21
|
+
beginOutput() { this.#outputDepth += 1; this.#marked = false; }
|
|
22
|
+
endOutput() {
|
|
23
|
+
if (this.#outputDepth > 0)
|
|
24
|
+
this.#outputDepth -= 1;
|
|
25
|
+
if (this.#outputDepth === 0)
|
|
26
|
+
this.#guardUntil = this.now() + this.cooldownMs;
|
|
27
|
+
}
|
|
28
|
+
shouldSuppress() {
|
|
29
|
+
const suppressed = this.#outputDepth > 0 || this.#barrier !== undefined || this.now() < this.#guardUntil;
|
|
30
|
+
if (suppressed && !this.#marked) {
|
|
31
|
+
this.#marked = true;
|
|
32
|
+
this.onSuppressed();
|
|
33
|
+
}
|
|
34
|
+
if (!suppressed)
|
|
35
|
+
this.#marked = false;
|
|
36
|
+
return suppressed;
|
|
37
|
+
}
|
|
38
|
+
async waitForBarrier() { await this.#barrier; }
|
|
39
|
+
}
|
|
40
|
+
export function acceptRemoteMicrophone(source, microphoneSource, remoteIdentity, localIdentity, agentIdentity) {
|
|
41
|
+
return source === microphoneSource && Boolean(remoteIdentity) && remoteIdentity !== localIdentity && remoteIdentity !== agentIdentity;
|
|
42
|
+
}
|
package/dist/fakes.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AgentRuntime, MediaRoom, MediaTurnInput, SpeechPipeline } from "./ports.js";
|
|
2
|
+
import type { TranscriptPolicy, VoiceGatewayGrant, VoiceMode } from "./types.js";
|
|
3
|
+
export declare class InMemoryMediaRoom implements MediaRoom {
|
|
4
|
+
#private;
|
|
5
|
+
private readonly blockPublishUntilCancelled;
|
|
6
|
+
joined: boolean;
|
|
7
|
+
closed: boolean;
|
|
8
|
+
readonly published: Map<string, Uint8Array<ArrayBufferLike>[]>;
|
|
9
|
+
readonly cancelled: string[];
|
|
10
|
+
readonly inputSuppression: boolean[];
|
|
11
|
+
constructor(blockPublishUntilCancelled?: boolean);
|
|
12
|
+
enqueueTurn(turnId: string, audio: readonly Uint8Array[]): void;
|
|
13
|
+
join(_grant: VoiceGatewayGrant): Promise<void>;
|
|
14
|
+
setBargeInHandler(handler: (turnId: string) => Promise<void>): void;
|
|
15
|
+
setInputSuppressed(suppressed: boolean): void;
|
|
16
|
+
simulateBargeIn(turnId: string): Promise<void>;
|
|
17
|
+
receiveTurn(): Promise<MediaTurnInput>;
|
|
18
|
+
publish(turnId: string, audio: AsyncIterable<Uint8Array>): Promise<"completed" | "cancelled">;
|
|
19
|
+
cancelPublish(turnId: string): Promise<void>;
|
|
20
|
+
close(): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export declare class FakeSpeechPipeline implements SpeechPipeline {
|
|
23
|
+
private readonly transcript;
|
|
24
|
+
private readonly audio;
|
|
25
|
+
readonly cancelled: string[];
|
|
26
|
+
readonly policies: TranscriptPolicy[];
|
|
27
|
+
constructor(transcript?: string, audio?: Uint8Array<ArrayBuffer>);
|
|
28
|
+
transcribe(_turnId: string, input: AsyncIterable<Uint8Array>, policy: TranscriptPolicy): Promise<string>;
|
|
29
|
+
synthesize(_turnId: string, _text: string, _mode: VoiceMode, _speechVoice?: string): AsyncIterable<Uint8Array>;
|
|
30
|
+
cancel(turnId: string): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export declare class FakeAgentRuntime implements AgentRuntime {
|
|
33
|
+
private readonly reply;
|
|
34
|
+
readonly inputs: Array<{
|
|
35
|
+
turnId: string;
|
|
36
|
+
text: string;
|
|
37
|
+
}>;
|
|
38
|
+
readonly cancelled: string[];
|
|
39
|
+
constructor(reply?: string);
|
|
40
|
+
respond(turnId: string, input: string, signal: AbortSignal): Promise<string>;
|
|
41
|
+
cancel(turnId: string): Promise<void>;
|
|
42
|
+
}
|
package/dist/fakes.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
async function* chunks(values) {
|
|
2
|
+
for (const value of values)
|
|
3
|
+
yield value;
|
|
4
|
+
}
|
|
5
|
+
export class InMemoryMediaRoom {
|
|
6
|
+
blockPublishUntilCancelled;
|
|
7
|
+
joined = false;
|
|
8
|
+
closed = false;
|
|
9
|
+
published = new Map();
|
|
10
|
+
cancelled = [];
|
|
11
|
+
inputSuppression = [];
|
|
12
|
+
#inputs = [];
|
|
13
|
+
#releasePublish;
|
|
14
|
+
#bargeInHandler;
|
|
15
|
+
constructor(blockPublishUntilCancelled = false) {
|
|
16
|
+
this.blockPublishUntilCancelled = blockPublishUntilCancelled;
|
|
17
|
+
}
|
|
18
|
+
enqueueTurn(turnId, audio) {
|
|
19
|
+
this.#inputs.push({ turnId, audio: chunks(audio) });
|
|
20
|
+
}
|
|
21
|
+
async join(_grant) { this.joined = true; }
|
|
22
|
+
setBargeInHandler(handler) { this.#bargeInHandler = handler; }
|
|
23
|
+
setInputSuppressed(suppressed) { this.inputSuppression.push(suppressed); }
|
|
24
|
+
async simulateBargeIn(turnId) { await this.#bargeInHandler?.(turnId); }
|
|
25
|
+
async receiveTurn() {
|
|
26
|
+
const input = this.#inputs.shift();
|
|
27
|
+
if (!input)
|
|
28
|
+
throw new Error("No in-memory media turn is queued");
|
|
29
|
+
return input;
|
|
30
|
+
}
|
|
31
|
+
async publish(turnId, audio) {
|
|
32
|
+
const received = [];
|
|
33
|
+
for await (const chunk of audio)
|
|
34
|
+
received.push(chunk);
|
|
35
|
+
this.published.set(turnId, received);
|
|
36
|
+
if (this.blockPublishUntilCancelled) {
|
|
37
|
+
await new Promise((resolve) => { this.#releasePublish = resolve; });
|
|
38
|
+
return "cancelled";
|
|
39
|
+
}
|
|
40
|
+
return "completed";
|
|
41
|
+
}
|
|
42
|
+
async cancelPublish(turnId) {
|
|
43
|
+
this.cancelled.push(turnId);
|
|
44
|
+
this.#releasePublish?.();
|
|
45
|
+
this.#releasePublish = undefined;
|
|
46
|
+
}
|
|
47
|
+
async close() { this.closed = true; }
|
|
48
|
+
}
|
|
49
|
+
export class FakeSpeechPipeline {
|
|
50
|
+
transcript;
|
|
51
|
+
audio;
|
|
52
|
+
cancelled = [];
|
|
53
|
+
policies = [];
|
|
54
|
+
constructor(transcript = "hello", audio = new Uint8Array([1, 2, 3])) {
|
|
55
|
+
this.transcript = transcript;
|
|
56
|
+
this.audio = audio;
|
|
57
|
+
}
|
|
58
|
+
async transcribe(_turnId, input, policy) {
|
|
59
|
+
this.policies.push(policy);
|
|
60
|
+
for await (const _chunk of input) { /* consume without persistence */ }
|
|
61
|
+
return this.transcript;
|
|
62
|
+
}
|
|
63
|
+
async *synthesize(_turnId, _text, _mode, _speechVoice) { yield this.audio; }
|
|
64
|
+
async cancel(turnId) { this.cancelled.push(turnId); }
|
|
65
|
+
}
|
|
66
|
+
export class FakeAgentRuntime {
|
|
67
|
+
reply;
|
|
68
|
+
inputs = [];
|
|
69
|
+
cancelled = [];
|
|
70
|
+
constructor(reply = "hello back") {
|
|
71
|
+
this.reply = reply;
|
|
72
|
+
}
|
|
73
|
+
async respond(turnId, input, signal) {
|
|
74
|
+
if (signal.aborted)
|
|
75
|
+
throw new Error("Turn cancelled");
|
|
76
|
+
this.inputs.push({ turnId, text: input });
|
|
77
|
+
return this.reply;
|
|
78
|
+
}
|
|
79
|
+
async cancel(turnId) { this.cancelled.push(turnId); }
|
|
80
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { AgentRuntime, MediaRoom, SpeechPipeline } from "./ports.js";
|
|
2
|
+
import { VoiceSessionStateMachine } from "./state-machine.js";
|
|
3
|
+
import type { VoiceCapabilityHandshake, VoiceGatewayGrant, VoiceSessionRequest } from "./types.js";
|
|
4
|
+
import type { MediaSuccessObserver } from "./mediaMetrics.js";
|
|
5
|
+
export declare class VoicePolicyError extends Error {
|
|
6
|
+
readonly code = "VOICE_POLICY_ERROR";
|
|
7
|
+
}
|
|
8
|
+
export declare class VoiceAuthorizationError extends Error {
|
|
9
|
+
readonly code = "VOICE_AUTHORIZATION_ERROR";
|
|
10
|
+
}
|
|
11
|
+
export interface StartVoiceSession {
|
|
12
|
+
request: VoiceSessionRequest;
|
|
13
|
+
handshake: VoiceCapabilityHandshake | unknown;
|
|
14
|
+
grant: VoiceGatewayGrant;
|
|
15
|
+
}
|
|
16
|
+
export interface FinalAcceptedVoiceTurn {
|
|
17
|
+
turnId: string;
|
|
18
|
+
transcript: string;
|
|
19
|
+
response: string;
|
|
20
|
+
outcome: "completed" | "chat_fallback";
|
|
21
|
+
}
|
|
22
|
+
export type FinalTurnSink = (turn: FinalAcceptedVoiceTurn) => Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Orchestrates transient media and text. It exposes no persistence or content
|
|
25
|
+
* logging hooks; adapters must treat audio/transcripts as session-scoped data.
|
|
26
|
+
*/
|
|
27
|
+
export declare class VoiceGateway {
|
|
28
|
+
#private;
|
|
29
|
+
private readonly media;
|
|
30
|
+
private readonly speech;
|
|
31
|
+
private readonly runtime;
|
|
32
|
+
private readonly now;
|
|
33
|
+
private readonly observe?;
|
|
34
|
+
private readonly publishTimeoutMs;
|
|
35
|
+
private readonly logFailure;
|
|
36
|
+
private readonly finalTurnSink?;
|
|
37
|
+
private readonly runtimeTimeoutMs;
|
|
38
|
+
private readonly speechSlaMs;
|
|
39
|
+
private readonly presenceMs;
|
|
40
|
+
readonly state: VoiceSessionStateMachine;
|
|
41
|
+
constructor(media: MediaRoom, speech: SpeechPipeline, runtime: AgentRuntime, now?: () => Date, observe?: MediaSuccessObserver | undefined, publishTimeoutMs?: number, logFailure?: (line: string) => void, finalTurnSink?: FinalTurnSink | undefined, runtimeTimeoutMs?: number, speechSlaMs?: number, presenceMs?: number);
|
|
42
|
+
start(input: StartVoiceSession): Promise<void>;
|
|
43
|
+
runTurn(): Promise<string>;
|
|
44
|
+
bargeIn(turnId: string): Promise<void>;
|
|
45
|
+
close(): Promise<void>;
|
|
46
|
+
}
|
package/dist/gateway.js
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { reasonVoiceAvailability } from "./capability.js";
|
|
2
|
+
import { VoiceSessionStateMachine } from "./state-machine.js";
|
|
3
|
+
import { NoSpeechError, TtsError } from "./speechErrors.js";
|
|
4
|
+
import { VOICE_SPEECH_SLA_MS, looksLikeDetachedVoiceWork, voiceSubtaskAck, voiceSubtaskTimedOut } from "./voiceSubtask.js";
|
|
5
|
+
import { VOICE_PRESENCE_MS, presencePublishId, presenceToneFrames } from "./presenceTone.js";
|
|
6
|
+
export class VoicePolicyError extends Error {
|
|
7
|
+
code = "VOICE_POLICY_ERROR";
|
|
8
|
+
}
|
|
9
|
+
export class VoiceAuthorizationError extends Error {
|
|
10
|
+
code = "VOICE_AUTHORIZATION_ERROR";
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Orchestrates transient media and text. It exposes no persistence or content
|
|
14
|
+
* logging hooks; adapters must treat audio/transcripts as session-scoped data.
|
|
15
|
+
*/
|
|
16
|
+
export class VoiceGateway {
|
|
17
|
+
media;
|
|
18
|
+
speech;
|
|
19
|
+
runtime;
|
|
20
|
+
now;
|
|
21
|
+
observe;
|
|
22
|
+
publishTimeoutMs;
|
|
23
|
+
logFailure;
|
|
24
|
+
finalTurnSink;
|
|
25
|
+
runtimeTimeoutMs;
|
|
26
|
+
speechSlaMs;
|
|
27
|
+
presenceMs;
|
|
28
|
+
state = new VoiceSessionStateMachine();
|
|
29
|
+
#turnControllers = new Map();
|
|
30
|
+
#subtaskControllers = new Map();
|
|
31
|
+
#subtaskOperations = new Set();
|
|
32
|
+
#bargeInOperations = new Map();
|
|
33
|
+
#activeTurnOperations = new Set();
|
|
34
|
+
#request;
|
|
35
|
+
#closing = false;
|
|
36
|
+
constructor(media, speech, runtime, now = () => new Date(), observe, publishTimeoutMs = 30_000, logFailure = () => { }, finalTurnSink, runtimeTimeoutMs = 12_000, speechSlaMs = VOICE_SPEECH_SLA_MS, presenceMs = VOICE_PRESENCE_MS) {
|
|
37
|
+
this.media = media;
|
|
38
|
+
this.speech = speech;
|
|
39
|
+
this.runtime = runtime;
|
|
40
|
+
this.now = now;
|
|
41
|
+
this.observe = observe;
|
|
42
|
+
this.publishTimeoutMs = publishTimeoutMs;
|
|
43
|
+
this.logFailure = logFailure;
|
|
44
|
+
this.finalTurnSink = finalTurnSink;
|
|
45
|
+
this.runtimeTimeoutMs = runtimeTimeoutMs;
|
|
46
|
+
this.speechSlaMs = speechSlaMs;
|
|
47
|
+
this.presenceMs = presenceMs;
|
|
48
|
+
this.media.setBargeInHandler?.((turnId) => this.bargeIn(turnId));
|
|
49
|
+
}
|
|
50
|
+
async start(input) {
|
|
51
|
+
this.#validatePolicy(input.request);
|
|
52
|
+
this.state.transition({ type: "authorization_requested" });
|
|
53
|
+
const decision = reasonVoiceAvailability(input.handshake, {
|
|
54
|
+
agentId: input.request.agentId,
|
|
55
|
+
mode: input.request.mode,
|
|
56
|
+
transcriptPolicy: input.request.transcriptPolicy,
|
|
57
|
+
requireInterrupt: false,
|
|
58
|
+
now: this.now(),
|
|
59
|
+
});
|
|
60
|
+
if (!decision.available) {
|
|
61
|
+
this.state.transition({ type: "failed", reason: decision.reason });
|
|
62
|
+
throw new VoiceAuthorizationError(`Voice authorization denied: ${decision.reason}`);
|
|
63
|
+
}
|
|
64
|
+
if (input.grant.agentIdentity !== input.request.agentId || input.grant.meetingId !== input.request.meetingId) {
|
|
65
|
+
this.state.transition({ type: "failed", reason: "grant_scope_mismatch" });
|
|
66
|
+
throw new VoiceAuthorizationError("Grant is not scoped to the requested agent and meeting");
|
|
67
|
+
}
|
|
68
|
+
if (Date.parse(input.grant.expiresAt) <= this.now().getTime()) {
|
|
69
|
+
this.state.transition({ type: "failed", reason: "grant_expired" });
|
|
70
|
+
throw new VoiceAuthorizationError("Grant has expired");
|
|
71
|
+
}
|
|
72
|
+
this.#request = input.request;
|
|
73
|
+
this.state.transition({ type: "authorized" });
|
|
74
|
+
this.state.transition({ type: "join_started" });
|
|
75
|
+
await this.media.join(input.grant);
|
|
76
|
+
this.state.transition({ type: "joined" });
|
|
77
|
+
this.state.transition({ type: "listening" });
|
|
78
|
+
}
|
|
79
|
+
async runTurn() {
|
|
80
|
+
if (this.#closing)
|
|
81
|
+
return "session-closed";
|
|
82
|
+
const operation = this.#runTurn();
|
|
83
|
+
this.#activeTurnOperations.add(operation);
|
|
84
|
+
try {
|
|
85
|
+
return await operation;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (this.#closing)
|
|
89
|
+
return "session-closed";
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
this.#activeTurnOperations.delete(operation);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async #runTurn() {
|
|
97
|
+
await Promise.allSettled([...this.#bargeInOperations.values()]);
|
|
98
|
+
const request = this.#request;
|
|
99
|
+
if (!request)
|
|
100
|
+
throw new VoicePolicyError("Session has not started");
|
|
101
|
+
const input = await this.media.receiveTurn();
|
|
102
|
+
this.media.setInputSuppressed?.(true);
|
|
103
|
+
this.state.transition({ type: "turn_committed", turnId: input.turnId });
|
|
104
|
+
const controller = new AbortController();
|
|
105
|
+
this.#turnControllers.set(input.turnId, controller);
|
|
106
|
+
try {
|
|
107
|
+
let transcript;
|
|
108
|
+
try {
|
|
109
|
+
transcript = await this.speech.transcribe(input.turnId, input.audio, request.transcriptPolicy);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (error instanceof NoSpeechError) {
|
|
113
|
+
this.state.transition({ type: "turn_skipped", turnId: input.turnId });
|
|
114
|
+
return input.turnId;
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
this.observe?.("stt_success");
|
|
119
|
+
const runtime = this.runtime.respond(input.turnId, transcript, controller.signal);
|
|
120
|
+
runtime.catch(() => { });
|
|
121
|
+
const presence = this.#armPresence(input.turnId);
|
|
122
|
+
let slaTimer;
|
|
123
|
+
let hardTimer;
|
|
124
|
+
const slaMs = this.speechSlaMs;
|
|
125
|
+
const allowSla = slaMs > 0 && slaMs < this.runtimeTimeoutMs && looksLikeDetachedVoiceWork(transcript);
|
|
126
|
+
const sla = allowSla
|
|
127
|
+
? new Promise((resolve) => { slaTimer = setTimeout(() => resolve("sla"), slaMs); })
|
|
128
|
+
: new Promise(() => { });
|
|
129
|
+
const hardTimeout = new Promise((resolve) => { hardTimer = setTimeout(() => resolve("timeout"), this.runtimeTimeoutMs); });
|
|
130
|
+
const runtimeOutcome = await Promise.race([
|
|
131
|
+
runtime.then((response) => ({ status: "completed", response })),
|
|
132
|
+
...(allowSla ? [sla.then(() => ({ status: "sla" }))] : []),
|
|
133
|
+
hardTimeout.then(() => ({ status: "timeout" })),
|
|
134
|
+
]).finally(() => { if (slaTimer)
|
|
135
|
+
clearTimeout(slaTimer); });
|
|
136
|
+
await presence.stop();
|
|
137
|
+
if (runtimeOutcome.status === "timeout") {
|
|
138
|
+
if (hardTimer)
|
|
139
|
+
clearTimeout(hardTimer);
|
|
140
|
+
controller.abort();
|
|
141
|
+
await this.runtime.cancel(input.turnId);
|
|
142
|
+
this.observe?.("runtime_timeout");
|
|
143
|
+
if (!this.#closing && this.state.snapshot.state === "thinking")
|
|
144
|
+
this.state.transition({ type: "turn_skipped", turnId: input.turnId });
|
|
145
|
+
return input.turnId;
|
|
146
|
+
}
|
|
147
|
+
if (runtimeOutcome.status === "sla") {
|
|
148
|
+
await this.#speakNow(input.turnId, voiceSubtaskAck(transcript), request, controller);
|
|
149
|
+
this.observe?.("voice_subtask_started");
|
|
150
|
+
if (!this.#closing && this.state.snapshot.state === "thinking")
|
|
151
|
+
this.state.transition({ type: "turn_skipped", turnId: input.turnId });
|
|
152
|
+
this.#subtaskControllers.set(input.turnId, controller);
|
|
153
|
+
this.#followSubtask(input.turnId, transcript, runtime, controller, request);
|
|
154
|
+
return input.turnId;
|
|
155
|
+
}
|
|
156
|
+
if (hardTimer)
|
|
157
|
+
clearTimeout(hardTimer);
|
|
158
|
+
if (this.#closing || controller.signal.aborted)
|
|
159
|
+
return input.turnId;
|
|
160
|
+
const response = runtimeOutcome.response;
|
|
161
|
+
this.observe?.("openclaw_success");
|
|
162
|
+
this.state.transition({ type: "response_started", turnId: input.turnId });
|
|
163
|
+
try {
|
|
164
|
+
const publish = this.media.publish(input.turnId, this.speech.synthesize(input.turnId, response, request.mode, request.speechVoice));
|
|
165
|
+
publish.catch(() => { });
|
|
166
|
+
const cancelled = new Promise((resolve) => controller.signal.addEventListener("abort", () => resolve("cancelled"), { once: true }));
|
|
167
|
+
let timer;
|
|
168
|
+
const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new TtsError("stream_stalled")), this.publishTimeoutMs); });
|
|
169
|
+
const outcome = await Promise.race([publish, cancelled, timeout]).finally(() => { if (timer)
|
|
170
|
+
clearTimeout(timer); });
|
|
171
|
+
if (outcome === "stream_stalled") {
|
|
172
|
+
this.logFailure("voice tts failed: stream_stalled");
|
|
173
|
+
controller.abort();
|
|
174
|
+
await Promise.allSettled([this.media.cancelPublish(input.turnId), this.speech.cancel(input.turnId)]);
|
|
175
|
+
if (!this.#closing && this.state.snapshot.state === "speaking")
|
|
176
|
+
this.state.transition({ type: "response_aborted", turnId: input.turnId });
|
|
177
|
+
await this.#commitFallback(input.turnId, transcript, response);
|
|
178
|
+
return input.turnId;
|
|
179
|
+
}
|
|
180
|
+
if (outcome === "cancelled") {
|
|
181
|
+
if (this.finalTurnSink)
|
|
182
|
+
this.observe?.("chat_commit_skipped_cancelled");
|
|
183
|
+
if (this.state.snapshot.state === "speaking") {
|
|
184
|
+
controller.abort();
|
|
185
|
+
await Promise.allSettled([this.media.cancelPublish(input.turnId), this.speech.cancel(input.turnId)]);
|
|
186
|
+
}
|
|
187
|
+
await Promise.race([publish.catch(() => "cancelled"), new Promise((resolve) => setTimeout(() => resolve("cancelled"), 1_000))]);
|
|
188
|
+
if (this.state.snapshot.state === "speaking")
|
|
189
|
+
this.state.transition({ type: "response_aborted", turnId: input.turnId });
|
|
190
|
+
return input.turnId;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (error instanceof TtsError && error.category === "stream_stalled") {
|
|
195
|
+
this.logFailure("voice tts failed: stream_stalled");
|
|
196
|
+
controller.abort();
|
|
197
|
+
await Promise.allSettled([this.media.cancelPublish(input.turnId), this.speech.cancel(input.turnId)]);
|
|
198
|
+
if (!this.#closing && this.state.snapshot.state === "speaking")
|
|
199
|
+
this.state.transition({ type: "response_aborted", turnId: input.turnId });
|
|
200
|
+
await this.#commitFallback(input.turnId, transcript, response);
|
|
201
|
+
return input.turnId;
|
|
202
|
+
}
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
this.observe?.("tts_success");
|
|
206
|
+
if (!this.state.snapshot.cancelledTurnIds.includes(input.turnId)) {
|
|
207
|
+
this.state.transition({ type: "response_completed", turnId: input.turnId });
|
|
208
|
+
if (this.finalTurnSink) {
|
|
209
|
+
this.observe?.("chat_commit_started");
|
|
210
|
+
try {
|
|
211
|
+
await this.finalTurnSink({ turnId: input.turnId, transcript, response, outcome: "completed" });
|
|
212
|
+
this.observe?.("chat_commit_succeeded");
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
this.observe?.("chat_commit_failed");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return input.turnId;
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
if (!this.#subtaskControllers.has(input.turnId))
|
|
223
|
+
this.#turnControllers.delete(input.turnId);
|
|
224
|
+
this.media.setInputSuppressed?.(false);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
#armPresence(turnId) {
|
|
228
|
+
if (!(this.presenceMs > 0))
|
|
229
|
+
return { stop: async () => { } };
|
|
230
|
+
const presenceId = presencePublishId(turnId);
|
|
231
|
+
const controller = new AbortController();
|
|
232
|
+
let timer;
|
|
233
|
+
let started = false;
|
|
234
|
+
let cancelled = false;
|
|
235
|
+
let publish;
|
|
236
|
+
timer = setTimeout(() => {
|
|
237
|
+
if (cancelled || this.#closing || controller.signal.aborted)
|
|
238
|
+
return;
|
|
239
|
+
started = true;
|
|
240
|
+
this.observe?.("voice_presence_started");
|
|
241
|
+
publish = this.media.publish(presenceId, presenceToneFrames(controller.signal));
|
|
242
|
+
publish.catch(() => { });
|
|
243
|
+
}, this.presenceMs);
|
|
244
|
+
return {
|
|
245
|
+
stop: async () => {
|
|
246
|
+
cancelled = true;
|
|
247
|
+
if (timer)
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
controller.abort();
|
|
250
|
+
if (!started)
|
|
251
|
+
return;
|
|
252
|
+
await this.media.cancelPublish(presenceId);
|
|
253
|
+
if (publish)
|
|
254
|
+
await Promise.race([publish.catch(() => undefined), new Promise((resolve) => setTimeout(resolve, 250))]);
|
|
255
|
+
this.observe?.("voice_presence_cancelled");
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
#followSubtask(turnId, transcript, runtime, controller, request) {
|
|
260
|
+
const operation = (async () => {
|
|
261
|
+
let leftoverTimer;
|
|
262
|
+
try {
|
|
263
|
+
const leftover = new Promise((resolve) => {
|
|
264
|
+
leftoverTimer = setTimeout(() => resolve("timeout"), Math.max(1, this.runtimeTimeoutMs));
|
|
265
|
+
});
|
|
266
|
+
const outcome = await Promise.race([
|
|
267
|
+
runtime.then((response) => ({ status: "completed", response })),
|
|
268
|
+
leftover.then(() => ({ status: "timeout" })),
|
|
269
|
+
]);
|
|
270
|
+
if (this.#closing || controller.signal.aborted)
|
|
271
|
+
return;
|
|
272
|
+
if (outcome.status === "timeout") {
|
|
273
|
+
controller.abort();
|
|
274
|
+
await this.runtime.cancel(turnId);
|
|
275
|
+
this.observe?.("voice_subtask_timeout");
|
|
276
|
+
await this.#speakProgress(`${turnId}:subtask-timeout`, voiceSubtaskTimedOut(transcript), request, controller);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
this.observe?.("openclaw_success");
|
|
280
|
+
this.observe?.("voice_subtask_completed");
|
|
281
|
+
await this.#speakProgress(`${turnId}:subtask`, outcome.response, request, controller);
|
|
282
|
+
if (this.finalTurnSink && !this.#closing && !controller.signal.aborted) {
|
|
283
|
+
this.observe?.("chat_commit_started");
|
|
284
|
+
try {
|
|
285
|
+
await this.finalTurnSink({ turnId, transcript, response: outcome.response, outcome: "completed" });
|
|
286
|
+
this.observe?.("chat_commit_succeeded");
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
this.observe?.("chat_commit_failed");
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
this.observe?.("voice_subtask_failed");
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
if (leftoverTimer)
|
|
298
|
+
clearTimeout(leftoverTimer);
|
|
299
|
+
this.#subtaskControllers.delete(turnId);
|
|
300
|
+
this.#turnControllers.delete(turnId);
|
|
301
|
+
}
|
|
302
|
+
})();
|
|
303
|
+
this.#subtaskOperations.add(operation);
|
|
304
|
+
void operation.finally(() => this.#subtaskOperations.delete(operation));
|
|
305
|
+
}
|
|
306
|
+
async #speakProgress(turnId, text, request, controller) {
|
|
307
|
+
if (this.#closing || controller.signal.aborted || !text.trim())
|
|
308
|
+
return;
|
|
309
|
+
if (this.state.snapshot.state !== "listening")
|
|
310
|
+
return;
|
|
311
|
+
try {
|
|
312
|
+
this.state.transition({ type: "turn_committed", turnId });
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
await this.#speakNow(turnId, text, request, controller);
|
|
318
|
+
}
|
|
319
|
+
async #speakNow(turnId, text, request, controller) {
|
|
320
|
+
if (this.#closing || controller.signal.aborted || !text.trim())
|
|
321
|
+
return;
|
|
322
|
+
if (this.state.snapshot.state === "listening" || this.state.snapshot.state === "thinking") {
|
|
323
|
+
try {
|
|
324
|
+
this.state.transition({ type: "response_started", turnId });
|
|
325
|
+
}
|
|
326
|
+
catch { /* already speaking */ }
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
const publish = this.media.publish(turnId, this.speech.synthesize(turnId, text, request.mode, request.speechVoice));
|
|
330
|
+
publish.catch(() => { });
|
|
331
|
+
const cancelled = new Promise((resolve) => controller.signal.addEventListener("abort", () => resolve("cancelled"), { once: true }));
|
|
332
|
+
let timer;
|
|
333
|
+
const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve("stalled"), this.publishTimeoutMs); });
|
|
334
|
+
const outcome = await Promise.race([publish, cancelled, timeout]).finally(() => { if (timer)
|
|
335
|
+
clearTimeout(timer); });
|
|
336
|
+
if (outcome === "cancelled" || outcome === "stalled") {
|
|
337
|
+
await Promise.allSettled([this.media.cancelPublish(turnId), this.speech.cancel(turnId)]);
|
|
338
|
+
if (this.state.snapshot.state === "speaking")
|
|
339
|
+
this.state.transition({ type: "response_aborted", turnId });
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
this.observe?.("tts_success");
|
|
343
|
+
if (this.state.snapshot.state === "speaking")
|
|
344
|
+
this.state.transition({ type: "response_completed", turnId });
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
if (this.state.snapshot.state === "speaking") {
|
|
348
|
+
try {
|
|
349
|
+
this.state.transition({ type: "response_aborted", turnId });
|
|
350
|
+
}
|
|
351
|
+
catch { /* ignore */ }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async #commitFallback(turnId, transcript, response) {
|
|
356
|
+
if (!this.finalTurnSink || this.#closing)
|
|
357
|
+
return;
|
|
358
|
+
this.observe?.("chat_fallback_started");
|
|
359
|
+
try {
|
|
360
|
+
await this.finalTurnSink({ turnId, transcript, response, outcome: "chat_fallback" });
|
|
361
|
+
this.observe?.("chat_fallback_succeeded");
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
this.observe?.("chat_fallback_failed");
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
async bargeIn(turnId) {
|
|
368
|
+
const existing = this.#bargeInOperations.get(turnId);
|
|
369
|
+
if (existing)
|
|
370
|
+
return existing;
|
|
371
|
+
const operation = (async () => {
|
|
372
|
+
if (this.state.snapshot.state === "speaking")
|
|
373
|
+
this.state.transition({ type: "barge_in", turnId });
|
|
374
|
+
this.#turnControllers.get(turnId)?.abort();
|
|
375
|
+
await Promise.allSettled([this.media.cancelPublish(turnId), this.speech.cancel(turnId), this.runtime.cancel(turnId)]);
|
|
376
|
+
this.observe?.("barge_in_confirmed");
|
|
377
|
+
this.observe?.("publish_cancelled_barge_in");
|
|
378
|
+
this.#turnControllers.delete(turnId);
|
|
379
|
+
})();
|
|
380
|
+
this.#bargeInOperations.set(turnId, operation);
|
|
381
|
+
try {
|
|
382
|
+
await operation;
|
|
383
|
+
}
|
|
384
|
+
finally {
|
|
385
|
+
this.#bargeInOperations.delete(turnId);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
async close() {
|
|
389
|
+
this.#closing = true;
|
|
390
|
+
await Promise.allSettled([...this.#bargeInOperations.values()]);
|
|
391
|
+
this.state.transition({ type: "close_requested" });
|
|
392
|
+
const activeTurnIds = [...new Set([...this.#turnControllers.keys(), ...this.#subtaskControllers.keys()])];
|
|
393
|
+
if (activeTurnIds.length > 0)
|
|
394
|
+
this.observe?.("turn_cancelled_session_close");
|
|
395
|
+
for (const turnId of activeTurnIds)
|
|
396
|
+
this.#turnControllers.get(turnId)?.abort();
|
|
397
|
+
for (const turnId of activeTurnIds)
|
|
398
|
+
this.#subtaskControllers.get(turnId)?.abort();
|
|
399
|
+
await Promise.allSettled(activeTurnIds.flatMap((turnId) => [this.runtime.cancel(turnId), this.speech.cancel(turnId), this.media.cancelPublish(turnId)]));
|
|
400
|
+
await this.media.close();
|
|
401
|
+
await Promise.race([
|
|
402
|
+
Promise.allSettled([...this.#activeTurnOperations, ...this.#subtaskOperations]),
|
|
403
|
+
new Promise((resolve) => setTimeout(resolve, 2_000)),
|
|
404
|
+
]);
|
|
405
|
+
this.state.transition({ type: "closed" });
|
|
406
|
+
}
|
|
407
|
+
#validatePolicy(request) {
|
|
408
|
+
if (request.transcriptPolicy === "saved") {
|
|
409
|
+
const consent = request.transcriptConsent;
|
|
410
|
+
if (!consent?.granted || !consent.subjectId || !Number.isFinite(Date.parse(consent.grantedAt))) {
|
|
411
|
+
throw new VoicePolicyError("Saved transcripts require explicit, valid consent");
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
else if (request.transcriptConsent) {
|
|
415
|
+
throw new VoicePolicyError("Transcript consent is only accepted for saved transcripts");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
package/dist/grants.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { VoiceGatewayGrant } from "./types.js";
|
|
2
|
+
export interface GrantRequest {
|
|
3
|
+
sessionId: string;
|
|
4
|
+
projectId: string;
|
|
5
|
+
meetingId: string;
|
|
6
|
+
voiceProcessingConsent: true;
|
|
7
|
+
}
|
|
8
|
+
export interface GrantSource {
|
|
9
|
+
obtain(request: GrantRequest): Promise<VoiceGatewayGrant>;
|
|
10
|
+
}
|
|
11
|
+
export declare class FileGrantSource implements GrantSource {
|
|
12
|
+
private readonly path;
|
|
13
|
+
constructor(path: string);
|
|
14
|
+
obtain(): Promise<VoiceGatewayGrant>;
|
|
15
|
+
}
|
|
16
|
+
export declare class M2mGrantSource implements GrantSource {
|
|
17
|
+
private readonly endpoint;
|
|
18
|
+
private readonly credential;
|
|
19
|
+
private readonly fetcher;
|
|
20
|
+
constructor(endpoint: string, credential: string, fetcher?: typeof fetch);
|
|
21
|
+
obtain(request: GrantRequest): Promise<VoiceGatewayGrant>;
|
|
22
|
+
}
|