@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,236 @@
|
|
|
1
|
+
import { AudioFrame, AudioSource, AudioStream, LocalAudioTrack, RemoteAudioTrack, Room, RoomEvent, TrackPublishOptions, TrackSource } from "@livekit/rtc-node";
|
|
2
|
+
import { runVoiceSessionStage } from "../mediaStages.js";
|
|
3
|
+
import { acceptRemoteMicrophone, EchoSuppressionGate } from "../echoSuppression.js";
|
|
4
|
+
import { TtsError } from "../speechErrors.js";
|
|
5
|
+
const LIVEKIT_CONNECT_TIMEOUT_MS = 15_000;
|
|
6
|
+
const TRACK_PUBLISH_TIMEOUT_MS = 10_000;
|
|
7
|
+
export class LiveKitMediaRoom {
|
|
8
|
+
observe;
|
|
9
|
+
#room = new Room();
|
|
10
|
+
#turns = [];
|
|
11
|
+
#waiters = [];
|
|
12
|
+
#publishes = new Map();
|
|
13
|
+
#source;
|
|
14
|
+
#track;
|
|
15
|
+
#activePublishTurn;
|
|
16
|
+
#bargeInHandler;
|
|
17
|
+
#agentIdentity;
|
|
18
|
+
#echoGate;
|
|
19
|
+
constructor(observe) {
|
|
20
|
+
this.observe = observe;
|
|
21
|
+
this.#echoGate = new EchoSuppressionGate(Date.now, 500, () => this.observe?.("turn_based_input_suppressed"));
|
|
22
|
+
this.#room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
|
23
|
+
if (!(track instanceof RemoteAudioTrack) || !acceptRemoteMicrophone(publication.source, TrackSource.SOURCE_MICROPHONE, participant.identity, this.#room.localParticipant?.identity, this.#agentIdentity)) {
|
|
24
|
+
this.observe?.("remote_track_rejected");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
this.observe?.("track_subscribed");
|
|
28
|
+
void this.#segment(track);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async #segment(track) {
|
|
32
|
+
const stream = new AudioStream(track, { sampleRate: 48_000, numChannels: 1, frameSizeMs: 20 });
|
|
33
|
+
let chunks = [];
|
|
34
|
+
let speaking = false;
|
|
35
|
+
let silentFrames = 0;
|
|
36
|
+
let totalFrames = 0;
|
|
37
|
+
let voicedFrames = 0;
|
|
38
|
+
const emit = () => {
|
|
39
|
+
if (!chunks.length)
|
|
40
|
+
return;
|
|
41
|
+
const buffered = chunks;
|
|
42
|
+
const accepted = voicedFrames >= 10;
|
|
43
|
+
chunks = [];
|
|
44
|
+
speaking = false;
|
|
45
|
+
silentFrames = 0;
|
|
46
|
+
totalFrames = 0;
|
|
47
|
+
voicedFrames = 0;
|
|
48
|
+
if (!accepted)
|
|
49
|
+
return;
|
|
50
|
+
const turn = { turnId: crypto.randomUUID(), audio: (async function* () { for (const chunk of buffered)
|
|
51
|
+
yield chunk; })() };
|
|
52
|
+
this.observe?.("turn_received");
|
|
53
|
+
const waiter = this.#waiters.shift();
|
|
54
|
+
if (waiter)
|
|
55
|
+
waiter.resolve(turn);
|
|
56
|
+
else
|
|
57
|
+
this.#turns.push(turn);
|
|
58
|
+
};
|
|
59
|
+
for await (const frame of stream) {
|
|
60
|
+
if (this.#echoGate.shouldSuppress()) {
|
|
61
|
+
chunks = [];
|
|
62
|
+
speaking = false;
|
|
63
|
+
silentFrames = 0;
|
|
64
|
+
totalFrames = 0;
|
|
65
|
+
voicedFrames = 0;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const samples = frame.data;
|
|
69
|
+
let energy = 0;
|
|
70
|
+
for (let index = 0; index < samples.length; index += 1)
|
|
71
|
+
energy += Math.abs(samples[index]);
|
|
72
|
+
const voiced = samples.length > 0 && energy / samples.length >= 500;
|
|
73
|
+
if (!speaking && voiced && this.#activePublishTurn && this.#bargeInHandler) {
|
|
74
|
+
this.observe?.("barge_in_candidate");
|
|
75
|
+
const interrupted = this.#activePublishTurn;
|
|
76
|
+
const barrier = this.#bargeInHandler(interrupted);
|
|
77
|
+
this.#echoGate.start(barrier);
|
|
78
|
+
this.#echoGate.shouldSuppress();
|
|
79
|
+
chunks = [];
|
|
80
|
+
speaking = false;
|
|
81
|
+
silentFrames = 0;
|
|
82
|
+
totalFrames = 0;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!speaking && !voiced)
|
|
86
|
+
continue;
|
|
87
|
+
speaking = true;
|
|
88
|
+
totalFrames += 1;
|
|
89
|
+
if (voiced)
|
|
90
|
+
voicedFrames += 1;
|
|
91
|
+
const copy = new Uint8Array(frame.data.byteLength);
|
|
92
|
+
copy.set(new Uint8Array(frame.data.buffer, frame.data.byteOffset, frame.data.byteLength));
|
|
93
|
+
chunks.push(copy);
|
|
94
|
+
silentFrames = voiced ? 0 : silentFrames + 1;
|
|
95
|
+
if (silentFrames >= 60 || totalFrames >= 1_000)
|
|
96
|
+
emit();
|
|
97
|
+
}
|
|
98
|
+
emit();
|
|
99
|
+
}
|
|
100
|
+
async join(grant) {
|
|
101
|
+
this.#agentIdentity = grant.agentIdentity;
|
|
102
|
+
const cleanup = () => this.#cleanupJoin();
|
|
103
|
+
await runVoiceSessionStage("livekit_connect", () => {
|
|
104
|
+
if (!grant.url)
|
|
105
|
+
throw new Error("unavailable");
|
|
106
|
+
return this.#room.connect(grant.url, grant.token, { autoSubscribe: true, dynacast: false });
|
|
107
|
+
}, { timeoutMs: LIVEKIT_CONNECT_TIMEOUT_MS, cleanup });
|
|
108
|
+
this.#source = await runVoiceSessionStage("audio_source_create", () => new AudioSource(48_000, 1), { cleanup });
|
|
109
|
+
this.#track = await runVoiceSessionStage("track_create", () => LocalAudioTrack.createAudioTrack("voice", this.#source), { cleanup });
|
|
110
|
+
const options = new TrackPublishOptions();
|
|
111
|
+
options.source = TrackSource.SOURCE_MICROPHONE;
|
|
112
|
+
const participant = this.#room.localParticipant;
|
|
113
|
+
if (!participant) {
|
|
114
|
+
await runVoiceSessionStage("track_publish", () => { throw new Error("unavailable"); }, { cleanup });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await runVoiceSessionStage("track_publish", () => participant.publishTrack(this.#track, options), { timeoutMs: TRACK_PUBLISH_TIMEOUT_MS, cleanup });
|
|
118
|
+
}
|
|
119
|
+
async receiveTurn() {
|
|
120
|
+
const turn = this.#turns.shift();
|
|
121
|
+
if (turn)
|
|
122
|
+
return turn;
|
|
123
|
+
return new Promise((resolve, reject) => this.#waiters.push({ resolve, reject }));
|
|
124
|
+
}
|
|
125
|
+
setInputSuppressed(suppressed) { if (suppressed)
|
|
126
|
+
this.#echoGate.beginOutput();
|
|
127
|
+
else
|
|
128
|
+
this.#echoGate.endOutput(); }
|
|
129
|
+
async publish(turnId, audio) {
|
|
130
|
+
if (!this.#source)
|
|
131
|
+
throw new Error("LiveKit room is not joined");
|
|
132
|
+
const controller = new AbortController();
|
|
133
|
+
const iterator = audio[Symbol.asyncIterator]();
|
|
134
|
+
let resolveSettled;
|
|
135
|
+
const settled = new Promise((resolve) => { resolveSettled = resolve; });
|
|
136
|
+
const context = { controller, iterator, settled, resolveSettled };
|
|
137
|
+
this.#publishes.set(turnId, context);
|
|
138
|
+
this.#activePublishTurn = turnId;
|
|
139
|
+
this.#echoGate.beginOutput();
|
|
140
|
+
let started = false;
|
|
141
|
+
try {
|
|
142
|
+
while (true) {
|
|
143
|
+
const next = await this.#raceCancellation(iterator.next(), controller.signal);
|
|
144
|
+
if (next.status === "cancelled" || next.value.done)
|
|
145
|
+
break;
|
|
146
|
+
const chunk = next.value.value;
|
|
147
|
+
if (!started) {
|
|
148
|
+
started = true;
|
|
149
|
+
this.observe?.("audio_publish_started");
|
|
150
|
+
}
|
|
151
|
+
const aligned = chunk.byteLength - (chunk.byteLength % 2);
|
|
152
|
+
const bytes = new Uint8Array(aligned);
|
|
153
|
+
bytes.set(chunk.subarray(0, aligned));
|
|
154
|
+
const samples = new Int16Array(bytes.buffer);
|
|
155
|
+
const captured = await this.#raceCancellation(this.#source.captureFrame(new AudioFrame(samples, 48_000, 1, samples.length)), controller.signal);
|
|
156
|
+
if (captured.status === "cancelled")
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
if (controller.signal.aborted) {
|
|
160
|
+
this.observe?.("publish_cancelled");
|
|
161
|
+
return "cancelled";
|
|
162
|
+
}
|
|
163
|
+
if (started)
|
|
164
|
+
this.observe?.("audio_publish_completed");
|
|
165
|
+
return "completed";
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
if (error instanceof TtsError && error.category === "stream_stalled") {
|
|
169
|
+
controller.abort();
|
|
170
|
+
this.observe?.("publish_cancelled");
|
|
171
|
+
return "stream_stalled";
|
|
172
|
+
}
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
this.#echoGate.endOutput();
|
|
177
|
+
context.resolveSettled();
|
|
178
|
+
this.#publishes.delete(turnId);
|
|
179
|
+
if (this.#activePublishTurn === turnId)
|
|
180
|
+
this.#activePublishTurn = undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
setBargeInHandler(handler) { this.#bargeInHandler = handler; }
|
|
184
|
+
async cancelPublish(turnId) {
|
|
185
|
+
const context = this.#publishes.get(turnId);
|
|
186
|
+
if (!context) {
|
|
187
|
+
await this.#source?.clearQueue();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
context.controller.abort();
|
|
191
|
+
try {
|
|
192
|
+
await this.#bounded(context.iterator.return?.());
|
|
193
|
+
}
|
|
194
|
+
catch { /* cancellation cleanup */ }
|
|
195
|
+
try {
|
|
196
|
+
await this.#bounded(this.#source?.clearQueue());
|
|
197
|
+
}
|
|
198
|
+
catch { /* cancellation cleanup */ }
|
|
199
|
+
await Promise.race([context.settled, new Promise((resolve) => setTimeout(resolve, 1_000))]);
|
|
200
|
+
}
|
|
201
|
+
async #bounded(operation) {
|
|
202
|
+
if (!operation)
|
|
203
|
+
return;
|
|
204
|
+
operation.catch(() => { });
|
|
205
|
+
await Promise.race([operation, new Promise((resolve) => setTimeout(resolve, 1_000))]);
|
|
206
|
+
}
|
|
207
|
+
async #raceCancellation(operation, signal) {
|
|
208
|
+
operation.catch(() => { });
|
|
209
|
+
if (signal.aborted)
|
|
210
|
+
return { status: "cancelled" };
|
|
211
|
+
return Promise.race([operation.then((value) => ({ status: "completed", value })), new Promise((resolve) => signal.addEventListener("abort", () => resolve({ status: "cancelled" }), { once: true }))]);
|
|
212
|
+
}
|
|
213
|
+
async #cleanupJoin() {
|
|
214
|
+
try {
|
|
215
|
+
await this.#track?.close();
|
|
216
|
+
}
|
|
217
|
+
catch { /* cleanup is best effort */ }
|
|
218
|
+
this.#track = undefined;
|
|
219
|
+
this.#source = undefined;
|
|
220
|
+
try {
|
|
221
|
+
await this.#room.disconnect();
|
|
222
|
+
}
|
|
223
|
+
catch { /* cleanup is best effort */ }
|
|
224
|
+
}
|
|
225
|
+
async close() {
|
|
226
|
+
if (this.#publishes.size > 0)
|
|
227
|
+
this.observe?.("publish_cancelled_session_close");
|
|
228
|
+
await Promise.allSettled([...this.#publishes.keys()].map((turnId) => this.cancelPublish(turnId)));
|
|
229
|
+
if (this.#turns.length > 0)
|
|
230
|
+
this.observe?.("queued_turns_discarded");
|
|
231
|
+
this.#turns.splice(0);
|
|
232
|
+
for (const waiter of this.#waiters.splice(0))
|
|
233
|
+
waiter.reject(new Error("LiveKit room closed"));
|
|
234
|
+
await this.#cleanupJoin();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SpeechPipeline } from "../ports.js";
|
|
2
|
+
import type { TranscriptPolicy, VoiceMode } from "../types.js";
|
|
3
|
+
import type { MediaSuccessObserver } from "../mediaMetrics.js";
|
|
4
|
+
export interface OpenAiTurnSpeechOptions {
|
|
5
|
+
apiKey: string;
|
|
6
|
+
baseUrl?: string;
|
|
7
|
+
transcriptionModel?: string;
|
|
8
|
+
speechModel?: string;
|
|
9
|
+
voice?: string;
|
|
10
|
+
fetcher?: typeof fetch;
|
|
11
|
+
observe?: MediaSuccessObserver;
|
|
12
|
+
ttsIdleTimeoutMs?: number;
|
|
13
|
+
ttsAbsoluteTimeoutMs?: number;
|
|
14
|
+
logFailure?: (line: string) => void;
|
|
15
|
+
}
|
|
16
|
+
export declare class OpenAiTurnSpeechAdapter implements SpeechPipeline {
|
|
17
|
+
#private;
|
|
18
|
+
private readonly options;
|
|
19
|
+
constructor(options: OpenAiTurnSpeechOptions);
|
|
20
|
+
probe(): Promise<void>;
|
|
21
|
+
transcribe(turnId: string, audio: AsyncIterable<Uint8Array>, policy: TranscriptPolicy): Promise<string>;
|
|
22
|
+
synthesize(turnId: string, text: string, _mode: VoiceMode, speechVoice?: string): AsyncIterable<Uint8Array>;
|
|
23
|
+
cancel(turnId: string): Promise<void>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { NoSpeechError, TranscriptionError, TtsError } from "../speechErrors.js";
|
|
2
|
+
function wavPcm48Mono(pcm) {
|
|
3
|
+
const output = new Uint8Array(44 + pcm.byteLength);
|
|
4
|
+
const view = new DataView(output.buffer);
|
|
5
|
+
const ascii = (offset, value) => [...value].forEach((char, index) => view.setUint8(offset + index, char.charCodeAt(0)));
|
|
6
|
+
ascii(0, "RIFF");
|
|
7
|
+
view.setUint32(4, 36 + pcm.byteLength, true);
|
|
8
|
+
ascii(8, "WAVE");
|
|
9
|
+
ascii(12, "fmt ");
|
|
10
|
+
view.setUint32(16, 16, true);
|
|
11
|
+
view.setUint16(20, 1, true);
|
|
12
|
+
view.setUint16(22, 1, true);
|
|
13
|
+
view.setUint32(24, 48_000, true);
|
|
14
|
+
view.setUint32(28, 96_000, true);
|
|
15
|
+
view.setUint16(32, 2, true);
|
|
16
|
+
view.setUint16(34, 16, true);
|
|
17
|
+
ascii(36, "data");
|
|
18
|
+
view.setUint32(40, pcm.byteLength, true);
|
|
19
|
+
output.set(pcm, 44);
|
|
20
|
+
return output;
|
|
21
|
+
}
|
|
22
|
+
async function collect(audio, maxBytes = 3_840_000) {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
let size = 0;
|
|
25
|
+
for await (const chunk of audio) {
|
|
26
|
+
size += chunk.byteLength;
|
|
27
|
+
if (size > maxBytes)
|
|
28
|
+
throw new Error("speech turn exceeds maximum size");
|
|
29
|
+
chunks.push(chunk);
|
|
30
|
+
}
|
|
31
|
+
const result = new Uint8Array(size);
|
|
32
|
+
let offset = 0;
|
|
33
|
+
for (const chunk of chunks) {
|
|
34
|
+
result.set(chunk, offset);
|
|
35
|
+
offset += chunk.byteLength;
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
function pcm24To48(input) {
|
|
40
|
+
const aligned = input.byteLength - (input.byteLength % 2);
|
|
41
|
+
const source = new Int16Array(input.buffer, input.byteOffset, aligned / 2);
|
|
42
|
+
const result = new Int16Array(source.length * 2);
|
|
43
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
44
|
+
result[index * 2] = source[index];
|
|
45
|
+
result[index * 2 + 1] = source[index];
|
|
46
|
+
}
|
|
47
|
+
return new Uint8Array(result.buffer);
|
|
48
|
+
}
|
|
49
|
+
export class OpenAiTurnSpeechAdapter {
|
|
50
|
+
options;
|
|
51
|
+
#controllers = new Map();
|
|
52
|
+
#baseUrl;
|
|
53
|
+
#fetcher;
|
|
54
|
+
constructor(options) {
|
|
55
|
+
this.options = options;
|
|
56
|
+
this.#baseUrl = (options.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "");
|
|
57
|
+
this.#fetcher = options.fetcher ?? fetch;
|
|
58
|
+
}
|
|
59
|
+
async probe() {
|
|
60
|
+
for (const model of [this.options.transcriptionModel ?? "gpt-4o-mini-transcribe", this.options.speechModel ?? "gpt-4o-mini-tts"]) {
|
|
61
|
+
const response = await this.#fetcher(`${this.#baseUrl}/models/${encodeURIComponent(model)}`, { headers: this.#headers(), signal: AbortSignal.timeout(10_000) });
|
|
62
|
+
if (!response.ok)
|
|
63
|
+
throw new Error(`speech provider probe failed (${response.status})`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async transcribe(turnId, audio, policy) {
|
|
67
|
+
if (policy === "saved")
|
|
68
|
+
throw new Error("saved transcripts are not supported");
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
this.#controllers.set(turnId, controller);
|
|
71
|
+
try {
|
|
72
|
+
const pcm = await collect(audio);
|
|
73
|
+
const form = new FormData();
|
|
74
|
+
form.set("model", this.options.transcriptionModel ?? "gpt-4o-mini-transcribe");
|
|
75
|
+
form.set("response_format", "json");
|
|
76
|
+
const wav = wavPcm48Mono(pcm);
|
|
77
|
+
form.set("file", new Blob([wav.buffer], { type: "audio/wav" }), "turn.wav");
|
|
78
|
+
let response;
|
|
79
|
+
try {
|
|
80
|
+
response = await this.#fetcher(`${this.#baseUrl}/audio/transcriptions`, { method: "POST", headers: this.#headers(), body: form, signal: controller.signal });
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
const name = error instanceof Error ? error.name : "";
|
|
84
|
+
if (name === "AbortError" || name === "TimeoutError")
|
|
85
|
+
throw new TranscriptionError("timeout_abort");
|
|
86
|
+
if (error instanceof TypeError)
|
|
87
|
+
throw new TranscriptionError("network");
|
|
88
|
+
throw new TranscriptionError("other");
|
|
89
|
+
}
|
|
90
|
+
if (!response.ok)
|
|
91
|
+
throw new TranscriptionError(response.status >= 500 ? "http_5xx" : response.status >= 400 ? "http_4xx" : "other");
|
|
92
|
+
let body;
|
|
93
|
+
try {
|
|
94
|
+
body = await response.json();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
throw new TranscriptionError("other");
|
|
98
|
+
}
|
|
99
|
+
if (typeof body.text !== "string" || !body.text.trim())
|
|
100
|
+
throw new NoSpeechError();
|
|
101
|
+
return body.text;
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
this.#controllers.delete(turnId);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async *synthesize(turnId, text, _mode, speechVoice) {
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
this.#controllers.set(turnId, controller);
|
|
110
|
+
try {
|
|
111
|
+
let response;
|
|
112
|
+
try {
|
|
113
|
+
response = await this.#fetcher(`${this.#baseUrl}/audio/speech`, {
|
|
114
|
+
method: "POST", headers: { ...this.#headers(), "content-type": "application/json" },
|
|
115
|
+
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]),
|
|
116
|
+
body: JSON.stringify({ model: this.options.speechModel ?? "gpt-4o-mini-tts", voice: speechVoice ?? this.options.voice ?? "alloy", input: text, response_format: "pcm" }),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
throw this.#ttsTransportError(error);
|
|
121
|
+
}
|
|
122
|
+
if (!response.ok)
|
|
123
|
+
throw new TtsError(response.status >= 500 ? "http_5xx" : response.status >= 400 ? "http_4xx" : "other");
|
|
124
|
+
if (!response.body)
|
|
125
|
+
throw new TtsError("malformed");
|
|
126
|
+
let carry = new Uint8Array();
|
|
127
|
+
let first = true;
|
|
128
|
+
let reader;
|
|
129
|
+
const deadline = Date.now() + (this.options.ttsAbsoluteTimeoutMs ?? 30_000);
|
|
130
|
+
try {
|
|
131
|
+
reader = response.body.getReader();
|
|
132
|
+
while (true) {
|
|
133
|
+
const remaining = deadline - Date.now();
|
|
134
|
+
if (remaining <= 0)
|
|
135
|
+
throw this.#streamStalled();
|
|
136
|
+
const timeoutMs = first ? remaining : Math.min(this.options.ttsIdleTimeoutMs ?? 5_000, remaining);
|
|
137
|
+
let timer;
|
|
138
|
+
const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => reject(this.#streamStalled()), timeoutMs); });
|
|
139
|
+
const result = await Promise.race([reader.read(), timeout]).finally(() => { if (timer)
|
|
140
|
+
clearTimeout(timer); });
|
|
141
|
+
if (result.done)
|
|
142
|
+
break;
|
|
143
|
+
const chunk = result.value;
|
|
144
|
+
const incoming = new Uint8Array(chunk);
|
|
145
|
+
const combined = new Uint8Array(carry.byteLength + incoming.byteLength);
|
|
146
|
+
combined.set(carry);
|
|
147
|
+
combined.set(incoming, carry.byteLength);
|
|
148
|
+
const aligned = combined.byteLength - (combined.byteLength % 2);
|
|
149
|
+
carry = combined.slice(aligned);
|
|
150
|
+
if (!aligned)
|
|
151
|
+
continue;
|
|
152
|
+
if (first) {
|
|
153
|
+
first = false;
|
|
154
|
+
this.options.observe?.("tts_first_byte");
|
|
155
|
+
}
|
|
156
|
+
yield pcm24To48(combined.subarray(0, aligned));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (error instanceof TtsError)
|
|
161
|
+
throw error;
|
|
162
|
+
throw this.#ttsTransportError(error);
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
if (reader) {
|
|
166
|
+
try {
|
|
167
|
+
await reader.cancel();
|
|
168
|
+
}
|
|
169
|
+
catch { /* best-effort stream cleanup */ }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (carry.byteLength || first)
|
|
173
|
+
throw new TtsError("malformed");
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
this.#controllers.delete(turnId);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async cancel(turnId) { this.#controllers.get(turnId)?.abort(); this.#controllers.delete(turnId); }
|
|
180
|
+
#headers() { return { authorization: `Bearer ${this.options.apiKey}` }; }
|
|
181
|
+
#ttsTransportError(error) {
|
|
182
|
+
const name = error instanceof Error ? error.name : "";
|
|
183
|
+
if (name === "AbortError" || name === "TimeoutError")
|
|
184
|
+
return new TtsError("timeout_abort");
|
|
185
|
+
if (error instanceof TypeError)
|
|
186
|
+
return new TtsError("network");
|
|
187
|
+
return new TtsError("other");
|
|
188
|
+
}
|
|
189
|
+
#streamStalled() {
|
|
190
|
+
const error = new TtsError("stream_stalled");
|
|
191
|
+
this.options.logFailure?.("voice tts failed: stream_stalled");
|
|
192
|
+
return error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { AgentRuntime } from "../ports.js";
|
|
2
|
+
/** Uses OpenClaw's documented local OpenResponses API; this is not Talk integration. */
|
|
3
|
+
export declare class OpenClawResponsesAdapter implements AgentRuntime {
|
|
4
|
+
#private;
|
|
5
|
+
private readonly endpoint;
|
|
6
|
+
private readonly token;
|
|
7
|
+
private readonly fetcher;
|
|
8
|
+
constructor(endpoint: string, token: string, fetcher?: typeof fetch);
|
|
9
|
+
respond(turnId: string, input: string, signal: AbortSignal): Promise<string>;
|
|
10
|
+
cancel(turnId: string): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export interface OpenClawChatCompletionsOptions {
|
|
13
|
+
endpoint: string;
|
|
14
|
+
token: string;
|
|
15
|
+
model: string;
|
|
16
|
+
backendModel?: string;
|
|
17
|
+
/** Short spoken identity only (e.g. "Athena"). Must NOT contain tools/docs/history. */
|
|
18
|
+
spokenName?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Optional Working Call brief from bound chat (API claim). Already sanitized/bounded
|
|
21
|
+
* by API; still re-checked here. Private Call must leave this empty.
|
|
22
|
+
*/
|
|
23
|
+
workChatBrief?: string;
|
|
24
|
+
/** Max prior final pairs kept in-call (default 4). */
|
|
25
|
+
inCallHistoryPairs?: number;
|
|
26
|
+
/** Completion budget for spoken turns. Default 80. */
|
|
27
|
+
maxTokens?: number;
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
observe?: (event: "work_chat_brief_loaded" | "work_chat_brief_empty" | "openclaw_model_default" | "openclaw_model_canary" | "openclaw_latency_fast" | "openclaw_latency_acceptable" | "openclaw_latency_slow" | "openclaw_response_filtered" | "openclaw_language_retry" | "openclaw_safe_fallback") => void;
|
|
30
|
+
}
|
|
31
|
+
export declare const WORK_CHAT_BRIEF_MAX_CHARS = 2400;
|
|
32
|
+
/** Sanitize optional work-chat brief for thin system policy. */
|
|
33
|
+
export declare function sanitizeWorkChatBrief(value: unknown): string | undefined;
|
|
34
|
+
/** Build the thin spoken system policy. Identity is optional and must stay short. */
|
|
35
|
+
export declare function buildSpokenPolicy(spokenName?: string, repair?: boolean, workChatBrief?: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Thin chat-completions runtime for voice turns.
|
|
38
|
+
*
|
|
39
|
+
* IMPORTANT: Point OPENCLAW_ENDPOINT at a **thin** OpenAI-compatible LLM
|
|
40
|
+
* (direct model API or a voice-only route). Do NOT point it at a full agent
|
|
41
|
+
* stack (Hermes api_server / OpenClaw with tools+memory+skills) — those inject
|
|
42
|
+
* tens of thousands of system tokens per turn and destroy call latency.
|
|
43
|
+
*
|
|
44
|
+
* Payload: short spoken system policy (+ optional Working Call brief) +
|
|
45
|
+
* last few in-call final pairs + current user transcript.
|
|
46
|
+
*/
|
|
47
|
+
export declare class OpenClawChatCompletionsAdapter implements AgentRuntime {
|
|
48
|
+
#private;
|
|
49
|
+
private readonly options;
|
|
50
|
+
private readonly fetcher;
|
|
51
|
+
constructor(options: OpenClawChatCompletionsOptions, fetcher?: typeof fetch);
|
|
52
|
+
respond(turnId: string, input: string, signal: AbortSignal): Promise<string>;
|
|
53
|
+
cancel(turnId: string): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
export declare class UnavailableOpenClawTalkAdapter implements AgentRuntime {
|
|
56
|
+
respond(): Promise<string>;
|
|
57
|
+
cancel(): Promise<void>;
|
|
58
|
+
}
|