opencharm 0.1.0 → 0.3.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/README.md +3 -2
- package/dist/main.mjs +1241 -66
- package/package.json +3 -1
- package/sim/charm_sim.js +1 -1
- package/sim/charm_sim.wasm +0 -0
- package/sim/index.html +2 -0
- package/sim/sim.js +10 -0
package/dist/main.mjs
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { chmodSync, createWriteStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { homedir, tmpdir } from "node:os";
|
|
4
5
|
import { dirname, extname, isAbsolute, join, normalize, resolve, sep } from "node:path";
|
|
5
6
|
import { z } from "zod";
|
|
6
7
|
import { createConnection, createServer } from "node:net";
|
|
7
8
|
import { createInterface } from "node:readline";
|
|
8
|
-
import { execFile, spawn, spawnSync } from "node:child_process";
|
|
9
|
+
import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
|
|
9
10
|
import { createHash, randomBytes, randomInt, randomUUID, scrypt, timingSafeEqual } from "node:crypto";
|
|
10
11
|
import { createServer as createServer$1 } from "node:http";
|
|
11
|
-
import { WebSocketServer } from "ws";
|
|
12
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
12
13
|
import { Readable, Writable } from "node:stream";
|
|
14
|
+
import { pipeline } from "node:stream/promises";
|
|
13
15
|
import * as acp from "@agentclientprotocol/sdk";
|
|
14
16
|
import OpusScript from "opusscript";
|
|
15
17
|
import { fileURLToPath } from "node:url";
|
|
@@ -455,12 +457,20 @@ const audioParams = z.object({
|
|
|
455
457
|
channels: z.number().int().min(1).max(2),
|
|
456
458
|
frame_duration: z.number().int().positive()
|
|
457
459
|
});
|
|
460
|
+
const plain = (max) => z.string().min(1).max(max).regex(/^[\w.@+-]+$/);
|
|
461
|
+
const charmBuild = z.object({
|
|
462
|
+
kind: plain(16),
|
|
463
|
+
version: plain(64),
|
|
464
|
+
commit: plain(40)
|
|
465
|
+
});
|
|
466
|
+
const build = charmBuild.optional().catch(void 0);
|
|
458
467
|
const clientHello = z.object({
|
|
459
468
|
type: z.literal("hello"),
|
|
460
469
|
version: z.number().int().positive(),
|
|
461
470
|
features: z.record(z.string(), z.boolean()).optional(),
|
|
462
471
|
transport: z.literal("websocket"),
|
|
463
|
-
audio_params: audioParams
|
|
472
|
+
audio_params: audioParams,
|
|
473
|
+
build
|
|
464
474
|
});
|
|
465
475
|
const listen = z.object({
|
|
466
476
|
session_id: sessionId,
|
|
@@ -702,7 +712,7 @@ const secret = {
|
|
|
702
712
|
apiKey: z.string().min(1).optional(),
|
|
703
713
|
apiKeyEnv: z.string().min(1).optional()
|
|
704
714
|
};
|
|
705
|
-
const
|
|
715
|
+
const legacyVoiceSchema = z.discriminatedUnion("provider", [
|
|
706
716
|
z.object({
|
|
707
717
|
provider: z.literal("fake"),
|
|
708
718
|
transcript: z.string().optional()
|
|
@@ -721,6 +731,52 @@ const voiceSchema = z.discriminatedUnion("provider", [
|
|
|
721
731
|
voice: z.string().optional()
|
|
722
732
|
}).strict()
|
|
723
733
|
]);
|
|
734
|
+
const languageCode = z.string().regex(/^[a-z]{2}$/);
|
|
735
|
+
const listenSchema = z.discriminatedUnion("provider", [
|
|
736
|
+
z.object({ provider: z.literal("local") }).strict(),
|
|
737
|
+
z.object({
|
|
738
|
+
provider: z.literal("whisper"),
|
|
739
|
+
model: z.string().optional(),
|
|
740
|
+
command: z.string().optional()
|
|
741
|
+
}).strict(),
|
|
742
|
+
z.object({
|
|
743
|
+
provider: z.literal("openai"),
|
|
744
|
+
...secret,
|
|
745
|
+
baseUrl: z.url().optional(),
|
|
746
|
+
model: z.string().optional()
|
|
747
|
+
}).strict(),
|
|
748
|
+
z.object({
|
|
749
|
+
provider: z.literal("fake"),
|
|
750
|
+
transcript: z.string().optional()
|
|
751
|
+
}).strict()
|
|
752
|
+
]);
|
|
753
|
+
const speakSchema = z.discriminatedUnion("provider", [
|
|
754
|
+
z.object({
|
|
755
|
+
provider: z.literal("microsoft"),
|
|
756
|
+
voices: z.record(languageCode, z.string().regex(/^[A-Za-z]{2,3}-[A-Za-z]{2,4}-\w+$/)).optional()
|
|
757
|
+
}).strict(),
|
|
758
|
+
z.object({
|
|
759
|
+
provider: z.literal("local"),
|
|
760
|
+
speaker: z.number().int().min(0).max(9).optional()
|
|
761
|
+
}).strict(),
|
|
762
|
+
z.object({
|
|
763
|
+
provider: z.literal("system"),
|
|
764
|
+
voice: z.string().optional()
|
|
765
|
+
}).strict(),
|
|
766
|
+
z.object({
|
|
767
|
+
provider: z.literal("openai"),
|
|
768
|
+
...secret,
|
|
769
|
+
baseUrl: z.url().optional(),
|
|
770
|
+
model: z.string().optional(),
|
|
771
|
+
voice: z.string().optional()
|
|
772
|
+
}).strict(),
|
|
773
|
+
z.object({ provider: z.literal("fake") }).strict()
|
|
774
|
+
]);
|
|
775
|
+
const voiceSchema = z.union([legacyVoiceSchema, z.object({
|
|
776
|
+
listen: listenSchema,
|
|
777
|
+
speak: speakSchema,
|
|
778
|
+
language: languageCode.default("en")
|
|
779
|
+
}).strict()]);
|
|
724
780
|
const agentSchema = z.discriminatedUnion("adapter", [
|
|
725
781
|
z.object({ adapter: z.literal("fake") }).strict(),
|
|
726
782
|
z.object({
|
|
@@ -801,6 +857,52 @@ function resolveAgent(agent, base) {
|
|
|
801
857
|
...agent.projects ? { projects: agent.projects.map((p) => expandPath(p, base)) } : {}
|
|
802
858
|
};
|
|
803
859
|
}
|
|
860
|
+
function normalizeVoice(voice) {
|
|
861
|
+
if ("listen" in voice) return voice;
|
|
862
|
+
switch (voice.provider) {
|
|
863
|
+
case "fake": return {
|
|
864
|
+
listen: {
|
|
865
|
+
provider: "fake",
|
|
866
|
+
...voice.transcript ? { transcript: voice.transcript } : {}
|
|
867
|
+
},
|
|
868
|
+
speak: { provider: "fake" },
|
|
869
|
+
language: "en"
|
|
870
|
+
};
|
|
871
|
+
case "local": return {
|
|
872
|
+
listen: voice.whisperModel ? {
|
|
873
|
+
provider: "whisper",
|
|
874
|
+
model: voice.whisperModel
|
|
875
|
+
} : { provider: "local" },
|
|
876
|
+
speak: voice.sayVoice ? {
|
|
877
|
+
provider: "system",
|
|
878
|
+
voice: voice.sayVoice
|
|
879
|
+
} : { provider: "local" },
|
|
880
|
+
language: "en"
|
|
881
|
+
};
|
|
882
|
+
case "openai": {
|
|
883
|
+
const { sttModel, ttsModel, voice: name } = voice;
|
|
884
|
+
const rest = {
|
|
885
|
+
...voice.apiKey ? { apiKey: voice.apiKey } : {},
|
|
886
|
+
...voice.apiKeyEnv ? { apiKeyEnv: voice.apiKeyEnv } : {},
|
|
887
|
+
...voice.baseUrl ? { baseUrl: voice.baseUrl } : {}
|
|
888
|
+
};
|
|
889
|
+
return {
|
|
890
|
+
listen: {
|
|
891
|
+
provider: "openai",
|
|
892
|
+
...rest,
|
|
893
|
+
...sttModel ? { model: sttModel } : {}
|
|
894
|
+
},
|
|
895
|
+
speak: {
|
|
896
|
+
provider: "openai",
|
|
897
|
+
...rest,
|
|
898
|
+
...ttsModel ? { model: ttsModel } : {},
|
|
899
|
+
...name ? { voice: name } : {}
|
|
900
|
+
},
|
|
901
|
+
language: "en"
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
804
906
|
function parseConfig(input, baseDir) {
|
|
805
907
|
const result = schema.safeParse(input);
|
|
806
908
|
if (!result.success) {
|
|
@@ -816,7 +918,8 @@ function parseConfig(input, baseDir) {
|
|
|
816
918
|
...config,
|
|
817
919
|
statePath,
|
|
818
920
|
adminSocket: config.adminSocket ?? defaultAdminSocket(statePath),
|
|
819
|
-
agent
|
|
921
|
+
agent,
|
|
922
|
+
voice: normalizeVoice(config.voice)
|
|
820
923
|
};
|
|
821
924
|
}
|
|
822
925
|
function loadConfig(path, options = {}) {
|
|
@@ -858,7 +961,7 @@ function sendAdmin(socketPath, request) {
|
|
|
858
961
|
}
|
|
859
962
|
//#endregion
|
|
860
963
|
//#region package.json
|
|
861
|
-
var version = "0.
|
|
964
|
+
var version = "0.3.0";
|
|
862
965
|
function channelToCube(value) {
|
|
863
966
|
if (value < 48) return 0;
|
|
864
967
|
if (value < 115) return 1;
|
|
@@ -999,6 +1102,18 @@ function promptHidden(question) {
|
|
|
999
1102
|
}
|
|
1000
1103
|
//#endregion
|
|
1001
1104
|
//#region src/commands/admin.ts
|
|
1105
|
+
const statusSchema = z.object({
|
|
1106
|
+
charmd: z.string().regex(/^[\w.@+() -]{1,96}$/).optional().catch(void 0),
|
|
1107
|
+
starter: z.string().regex(/^[0-9a-f]{4,40}$/).optional().catch(void 0),
|
|
1108
|
+
voice: z.string().regex(/^[\w.+()%· -]{1,160}$/).optional().catch(void 0),
|
|
1109
|
+
charms: z.array(z.object({
|
|
1110
|
+
name: z.string(),
|
|
1111
|
+
state: z.string(),
|
|
1112
|
+
blocked: z.boolean(),
|
|
1113
|
+
failedTries: z.number(),
|
|
1114
|
+
build: charmBuild.optional().catch(void 0)
|
|
1115
|
+
}))
|
|
1116
|
+
});
|
|
1002
1117
|
const DONE = {
|
|
1003
1118
|
lock: "Locked",
|
|
1004
1119
|
unlock: "Unblocked and unlocked",
|
|
@@ -1083,14 +1198,17 @@ async function look(ctx, args, deps) {
|
|
|
1083
1198
|
if (Object.keys(update).length === 0) return;
|
|
1084
1199
|
ctx.out.write(`${result.connected === 0 ? "No charm is unlocked right now: it gets the look when it unlocks." : `Sent to ${result.connected} charm${result.connected === 1 ? "" : "s"}.`} It lasts until charmd restarts; to keep it, set "charm" in opencharm.json.\n`);
|
|
1085
1200
|
}
|
|
1086
|
-
function printStatus(ctx, charms) {
|
|
1201
|
+
function printStatus(ctx, charms, charmd, starter, voice) {
|
|
1202
|
+
if (charmd) ctx.out.write(`charmd ${charmd}\n`);
|
|
1203
|
+
if (voice) ctx.out.write(`voice ${voice}\n`);
|
|
1204
|
+
if (starter) ctx.out.write(`workspace from starter ${starter}\n`);
|
|
1087
1205
|
if (charms.length === 0) {
|
|
1088
1206
|
ctx.out.write("No charms paired yet. Start one and run: opencharm pair <code>\n");
|
|
1089
1207
|
return;
|
|
1090
1208
|
}
|
|
1091
1209
|
for (const charm of charms) {
|
|
1092
|
-
const
|
|
1093
|
-
ctx.out.write(`${charm.name.padEnd(16)} ${charm.state.padEnd(10)} ${
|
|
1210
|
+
const details = [charm.blocked ? "blocked (opencharm unlock to clear)" : charm.failedTries > 0 ? `${charm.failedTries} wrong PIN(s)` : "", charm.build ? `${charm.build.kind} ${charm.build.version} (${charm.build.commit})` : ""].filter(Boolean).join(" ");
|
|
1211
|
+
ctx.out.write(`${charm.name.padEnd(16)} ${charm.state.padEnd(10)} ${details}`.trimEnd() + "\n");
|
|
1094
1212
|
}
|
|
1095
1213
|
}
|
|
1096
1214
|
async function runAdminCommand(ctx, command, args, deps) {
|
|
@@ -1108,8 +1226,13 @@ async function runAdminCommand(ctx, command, args, deps) {
|
|
|
1108
1226
|
return;
|
|
1109
1227
|
}
|
|
1110
1228
|
if (command === "status") {
|
|
1111
|
-
const
|
|
1112
|
-
|
|
1229
|
+
const answer = statusSchema.safeParse(await deps.send(deps.socket, { cmd: "status" }));
|
|
1230
|
+
if (!answer.success) {
|
|
1231
|
+
fail$2(ctx, "Unexpected answer from charmd: is it the same version as this CLI?");
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
const { charms, charmd, starter, voice } = answer.data;
|
|
1235
|
+
printStatus(ctx, charms, charmd, starter, voice);
|
|
1113
1236
|
return;
|
|
1114
1237
|
}
|
|
1115
1238
|
const charm = firstPositional(args);
|
|
@@ -1323,6 +1446,7 @@ function runHelp(ctx) {
|
|
|
1323
1446
|
opencharm setup --domain <d> install charmd as a service (Linux, sudo) [--dry-run]
|
|
1324
1447
|
opencharm serve start charmd [--config <file>]
|
|
1325
1448
|
opencharm sim the charm on screen, in your browser [--url <ws>] [--port <n>]
|
|
1449
|
+
opencharm voice the local voice's models [install: download them now]
|
|
1326
1450
|
opencharm pair <code> pair the charm showing <code> [--name <name>]
|
|
1327
1451
|
opencharm status paired charms and their state
|
|
1328
1452
|
opencharm lock <charm> lock a charm now
|
|
@@ -1347,6 +1471,10 @@ function runHelp(ctx) {
|
|
|
1347
1471
|
//#endregion
|
|
1348
1472
|
//#region src/commands/init.ts
|
|
1349
1473
|
const STARTER = "https://github.com/opencharm-labs/opencharm-starter.git";
|
|
1474
|
+
const DEFAULT_VOICE = {
|
|
1475
|
+
listen: { provider: "local" },
|
|
1476
|
+
speak: { provider: "microsoft" }
|
|
1477
|
+
};
|
|
1350
1478
|
function checkLook(options) {
|
|
1351
1479
|
const result = z.object({
|
|
1352
1480
|
name: lookFields.name,
|
|
@@ -1383,13 +1511,9 @@ function initWorkspace(target, options) {
|
|
|
1383
1511
|
const path = join(target, "opencharm.json");
|
|
1384
1512
|
if (!existsSync(path)) throw new Error(`${from} has no opencharm.json: is it an OpenCharm starter? (${target} was cloned; delete it)`);
|
|
1385
1513
|
const config = JSON.parse(readFileSync(path, "utf8"));
|
|
1386
|
-
const voice = options.platform === "darwin" ? "local" : "fake";
|
|
1387
1514
|
const hasLook = Object.keys(look).length > 0;
|
|
1388
|
-
if (config.voice
|
|
1389
|
-
config.voice =
|
|
1390
|
-
...config.voice,
|
|
1391
|
-
provider: voice
|
|
1392
|
-
};
|
|
1515
|
+
if (JSON.stringify(config.voice) === JSON.stringify(DEFAULT_VOICE) && config.agent?.agent === agent && !hasLook) return;
|
|
1516
|
+
config.voice = DEFAULT_VOICE;
|
|
1393
1517
|
config.agent = {
|
|
1394
1518
|
...config.agent,
|
|
1395
1519
|
agent
|
|
@@ -1408,8 +1532,7 @@ function runInit(ctx, args) {
|
|
|
1408
1532
|
from: flagValue(args, "--from"),
|
|
1409
1533
|
agent: flagValue(args, "--agent"),
|
|
1410
1534
|
name,
|
|
1411
|
-
colour: flagValue(args, "--colour") ?? flagValue(args, "--color")
|
|
1412
|
-
platform: process.platform
|
|
1535
|
+
colour: flagValue(args, "--colour") ?? flagValue(args, "--color")
|
|
1413
1536
|
});
|
|
1414
1537
|
} catch (error) {
|
|
1415
1538
|
ctx.err.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
@@ -1425,7 +1548,8 @@ function runInit(ctx, args) {
|
|
|
1425
1548
|
opencharm sim no charm yet? it appears in your browser (another terminal)
|
|
1426
1549
|
opencharm pair <code> the code on the charm (or the emulator); you choose its PIN
|
|
1427
1550
|
|
|
1428
|
-
The charm is called ${charm}: its name, colour and greeting are in opencharm.json ("charm").
|
|
1551
|
+
The charm is called ${charm}: its name, colour and greeting are in opencharm.json ("charm").
|
|
1552
|
+
${ctx.style.dim("Voice: it listens on this computer; it speaks with Microsoft's free voices, so the text of each spoken reply goes to Microsoft. To keep everything here, set \"speak\": { \"provider\": \"local\" } in opencharm.json. The first start downloads the voice models (about 620 MB): opencharm voice.")}
|
|
1429
1553
|
`);
|
|
1430
1554
|
}
|
|
1431
1555
|
//#endregion
|
|
@@ -1705,7 +1829,7 @@ function parseInput(schema, input) {
|
|
|
1705
1829
|
if (!result.success) throw new Error(result.error.issues.map((i) => i.message).join("; "));
|
|
1706
1830
|
return result.data;
|
|
1707
1831
|
}
|
|
1708
|
-
function createAdminActions({ store, pairing, sessions, look, agentCanChangeLook }) {
|
|
1832
|
+
function createAdminActions({ store, pairing, sessions, look, agentCanChangeLook, identity, starter, voice }) {
|
|
1709
1833
|
function sessionsOf(charmId) {
|
|
1710
1834
|
return [...sessions()].filter((s) => s.charmId === charmId && s.state !== "closed");
|
|
1711
1835
|
}
|
|
@@ -1811,17 +1935,23 @@ function createAdminActions({ store, pairing, sessions, look, agentCanChangeLook
|
|
|
1811
1935
|
});
|
|
1812
1936
|
},
|
|
1813
1937
|
status() {
|
|
1814
|
-
return {
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1938
|
+
return {
|
|
1939
|
+
charmd: identity ?? "unknown",
|
|
1940
|
+
...starter ? { starter } : {},
|
|
1941
|
+
...voice ? { voice: voice() } : {},
|
|
1942
|
+
charms: store.list().map((charm) => {
|
|
1943
|
+
const live = sessionsOf(charm.id).at(-1);
|
|
1944
|
+
return {
|
|
1945
|
+
id: charm.id,
|
|
1946
|
+
name: charm.name,
|
|
1947
|
+
blocked: charm.blocked,
|
|
1948
|
+
failedTries: charm.failedTries,
|
|
1949
|
+
connected: live !== void 0,
|
|
1950
|
+
state: live?.state ?? "offline",
|
|
1951
|
+
build: live?.build
|
|
1952
|
+
};
|
|
1953
|
+
})
|
|
1954
|
+
};
|
|
1825
1955
|
}
|
|
1826
1956
|
};
|
|
1827
1957
|
}
|
|
@@ -2085,6 +2215,7 @@ var Session = class {
|
|
|
2085
2215
|
sessionId = randomBytes(8).toString("hex");
|
|
2086
2216
|
#state = "awaiting_hello";
|
|
2087
2217
|
#charmId;
|
|
2218
|
+
#build;
|
|
2088
2219
|
#queue = Promise.resolve();
|
|
2089
2220
|
#turn;
|
|
2090
2221
|
#asks = Promise.resolve();
|
|
@@ -2097,6 +2228,9 @@ var Session = class {
|
|
|
2097
2228
|
get state() {
|
|
2098
2229
|
return this.#state;
|
|
2099
2230
|
}
|
|
2231
|
+
get build() {
|
|
2232
|
+
return this.#build;
|
|
2233
|
+
}
|
|
2100
2234
|
get charmId() {
|
|
2101
2235
|
return this.#charmId;
|
|
2102
2236
|
}
|
|
@@ -2150,6 +2284,7 @@ var Session = class {
|
|
|
2150
2284
|
type: "charm",
|
|
2151
2285
|
op: "unlocked"
|
|
2152
2286
|
});
|
|
2287
|
+
this.#activeTurn()?.warm();
|
|
2153
2288
|
}
|
|
2154
2289
|
#issuePairCode() {
|
|
2155
2290
|
try {
|
|
@@ -2181,6 +2316,7 @@ var Session = class {
|
|
|
2181
2316
|
this.#close(CLOSE_POLICY$1);
|
|
2182
2317
|
return;
|
|
2183
2318
|
}
|
|
2319
|
+
this.#build = message.build;
|
|
2184
2320
|
this.#onHello();
|
|
2185
2321
|
return;
|
|
2186
2322
|
}
|
|
@@ -2621,6 +2757,449 @@ function readOggOpus(data) {
|
|
|
2621
2757
|
};
|
|
2622
2758
|
}
|
|
2623
2759
|
//#endregion
|
|
2760
|
+
//#region ../charmd/src/voice/language.ts
|
|
2761
|
+
const COMMON_WORDS = {
|
|
2762
|
+
en: [
|
|
2763
|
+
"the",
|
|
2764
|
+
"and",
|
|
2765
|
+
"is",
|
|
2766
|
+
"are",
|
|
2767
|
+
"you",
|
|
2768
|
+
"to",
|
|
2769
|
+
"of",
|
|
2770
|
+
"it",
|
|
2771
|
+
"that",
|
|
2772
|
+
"what",
|
|
2773
|
+
"this",
|
|
2774
|
+
"for",
|
|
2775
|
+
"with",
|
|
2776
|
+
"have",
|
|
2777
|
+
"i",
|
|
2778
|
+
"my",
|
|
2779
|
+
"your",
|
|
2780
|
+
"do",
|
|
2781
|
+
"can",
|
|
2782
|
+
"on",
|
|
2783
|
+
"in",
|
|
2784
|
+
"be",
|
|
2785
|
+
"was",
|
|
2786
|
+
"will",
|
|
2787
|
+
"not",
|
|
2788
|
+
"me",
|
|
2789
|
+
"please",
|
|
2790
|
+
"tomorrow",
|
|
2791
|
+
"today"
|
|
2792
|
+
],
|
|
2793
|
+
it: [
|
|
2794
|
+
"il",
|
|
2795
|
+
"lo",
|
|
2796
|
+
"la",
|
|
2797
|
+
"gli",
|
|
2798
|
+
"le",
|
|
2799
|
+
"di",
|
|
2800
|
+
"che",
|
|
2801
|
+
"è",
|
|
2802
|
+
"e",
|
|
2803
|
+
"un",
|
|
2804
|
+
"una",
|
|
2805
|
+
"per",
|
|
2806
|
+
"con",
|
|
2807
|
+
"non",
|
|
2808
|
+
"sono",
|
|
2809
|
+
"mi",
|
|
2810
|
+
"ti",
|
|
2811
|
+
"ho",
|
|
2812
|
+
"hai",
|
|
2813
|
+
"cosa",
|
|
2814
|
+
"come",
|
|
2815
|
+
"del",
|
|
2816
|
+
"della",
|
|
2817
|
+
"dei",
|
|
2818
|
+
"delle",
|
|
2819
|
+
"nel",
|
|
2820
|
+
"nella",
|
|
2821
|
+
"fai",
|
|
2822
|
+
"oggi",
|
|
2823
|
+
"domani",
|
|
2824
|
+
"anche",
|
|
2825
|
+
"questo",
|
|
2826
|
+
"quello",
|
|
2827
|
+
"sì",
|
|
2828
|
+
"grazie"
|
|
2829
|
+
],
|
|
2830
|
+
es: [
|
|
2831
|
+
"el",
|
|
2832
|
+
"los",
|
|
2833
|
+
"las",
|
|
2834
|
+
"de",
|
|
2835
|
+
"que",
|
|
2836
|
+
"es",
|
|
2837
|
+
"y",
|
|
2838
|
+
"un",
|
|
2839
|
+
"una",
|
|
2840
|
+
"por",
|
|
2841
|
+
"con",
|
|
2842
|
+
"no",
|
|
2843
|
+
"para",
|
|
2844
|
+
"lo",
|
|
2845
|
+
"mi",
|
|
2846
|
+
"tu",
|
|
2847
|
+
"qué",
|
|
2848
|
+
"cómo",
|
|
2849
|
+
"está",
|
|
2850
|
+
"son",
|
|
2851
|
+
"hoy",
|
|
2852
|
+
"mañana",
|
|
2853
|
+
"gracias",
|
|
2854
|
+
"pero",
|
|
2855
|
+
"muy",
|
|
2856
|
+
"también"
|
|
2857
|
+
],
|
|
2858
|
+
fr: [
|
|
2859
|
+
"le",
|
|
2860
|
+
"les",
|
|
2861
|
+
"des",
|
|
2862
|
+
"de",
|
|
2863
|
+
"et",
|
|
2864
|
+
"est",
|
|
2865
|
+
"un",
|
|
2866
|
+
"une",
|
|
2867
|
+
"pour",
|
|
2868
|
+
"avec",
|
|
2869
|
+
"pas",
|
|
2870
|
+
"je",
|
|
2871
|
+
"tu",
|
|
2872
|
+
"vous",
|
|
2873
|
+
"nous",
|
|
2874
|
+
"que",
|
|
2875
|
+
"qui",
|
|
2876
|
+
"ce",
|
|
2877
|
+
"cette",
|
|
2878
|
+
"dans",
|
|
2879
|
+
"sur",
|
|
2880
|
+
"aujourd'hui",
|
|
2881
|
+
"demain",
|
|
2882
|
+
"merci",
|
|
2883
|
+
"oui",
|
|
2884
|
+
"mais",
|
|
2885
|
+
"très"
|
|
2886
|
+
],
|
|
2887
|
+
de: [
|
|
2888
|
+
"der",
|
|
2889
|
+
"die",
|
|
2890
|
+
"das",
|
|
2891
|
+
"und",
|
|
2892
|
+
"ist",
|
|
2893
|
+
"ein",
|
|
2894
|
+
"eine",
|
|
2895
|
+
"nicht",
|
|
2896
|
+
"ich",
|
|
2897
|
+
"du",
|
|
2898
|
+
"sie",
|
|
2899
|
+
"wir",
|
|
2900
|
+
"mit",
|
|
2901
|
+
"für",
|
|
2902
|
+
"auf",
|
|
2903
|
+
"zu",
|
|
2904
|
+
"den",
|
|
2905
|
+
"dem",
|
|
2906
|
+
"was",
|
|
2907
|
+
"wie",
|
|
2908
|
+
"heute",
|
|
2909
|
+
"morgen",
|
|
2910
|
+
"danke",
|
|
2911
|
+
"ja",
|
|
2912
|
+
"auch",
|
|
2913
|
+
"bitte"
|
|
2914
|
+
],
|
|
2915
|
+
pt: [
|
|
2916
|
+
"o",
|
|
2917
|
+
"os",
|
|
2918
|
+
"as",
|
|
2919
|
+
"de",
|
|
2920
|
+
"que",
|
|
2921
|
+
"é",
|
|
2922
|
+
"e",
|
|
2923
|
+
"um",
|
|
2924
|
+
"uma",
|
|
2925
|
+
"para",
|
|
2926
|
+
"com",
|
|
2927
|
+
"não",
|
|
2928
|
+
"eu",
|
|
2929
|
+
"você",
|
|
2930
|
+
"do",
|
|
2931
|
+
"da",
|
|
2932
|
+
"dos",
|
|
2933
|
+
"das",
|
|
2934
|
+
"no",
|
|
2935
|
+
"na",
|
|
2936
|
+
"hoje",
|
|
2937
|
+
"amanhã",
|
|
2938
|
+
"obrigado",
|
|
2939
|
+
"obrigada",
|
|
2940
|
+
"sim",
|
|
2941
|
+
"também",
|
|
2942
|
+
"muito"
|
|
2943
|
+
]
|
|
2944
|
+
};
|
|
2945
|
+
const LANGUAGES = Object.keys(COMMON_WORDS);
|
|
2946
|
+
const WORDS = new Map(LANGUAGES.map((language) => [language, new Set(COMMON_WORDS[language])]));
|
|
2947
|
+
function guessLanguage(text, among = LANGUAGES) {
|
|
2948
|
+
const words = text.toLowerCase().match(/[\p{L}]+(?:'(?:hui))?/gu) ?? [];
|
|
2949
|
+
const [best, second] = among.map((language) => ({
|
|
2950
|
+
language,
|
|
2951
|
+
score: words.filter((word) => WORDS.get(language)?.has(word)).length
|
|
2952
|
+
})).sort((a, b) => b.score - a.score);
|
|
2953
|
+
if (!best || best.score < 2) return void 0;
|
|
2954
|
+
return best.score > (second?.score ?? 0) ? best.language : void 0;
|
|
2955
|
+
}
|
|
2956
|
+
//#endregion
|
|
2957
|
+
//#region ../charmd/src/voice/models.ts
|
|
2958
|
+
const RELEASES = "https://github.com/k2-fsa/sherpa-onnx/releases/download";
|
|
2959
|
+
const MODELS = {
|
|
2960
|
+
parakeet: {
|
|
2961
|
+
label: "Parakeet (listening)",
|
|
2962
|
+
url: `${RELEASES}/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2`,
|
|
2963
|
+
sha256: "5793d0fd397c5778d2cf2126994d58e9d56b1be7c04d13c7a15bb1b4eafb16bf",
|
|
2964
|
+
bytes: 487170055,
|
|
2965
|
+
folder: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8",
|
|
2966
|
+
files: [
|
|
2967
|
+
"encoder.int8.onnx",
|
|
2968
|
+
"decoder.int8.onnx",
|
|
2969
|
+
"joiner.int8.onnx",
|
|
2970
|
+
"tokens.txt"
|
|
2971
|
+
]
|
|
2972
|
+
},
|
|
2973
|
+
supertonic: {
|
|
2974
|
+
label: "Supertonic (speaking)",
|
|
2975
|
+
url: `${RELEASES}/tts-models/sherpa-onnx-supertonic-3-tts-int8-2026-05-11.tar.bz2`,
|
|
2976
|
+
sha256: "82fa96f91c4ef8abaae3a14a3f4153facf88bed821d1f7331cec2700f432c427",
|
|
2977
|
+
bytes: 128774318,
|
|
2978
|
+
folder: "sherpa-onnx-supertonic-3-tts-int8-2026-05-11",
|
|
2979
|
+
files: [
|
|
2980
|
+
"duration_predictor.int8.onnx",
|
|
2981
|
+
"text_encoder.int8.onnx",
|
|
2982
|
+
"vector_estimator.int8.onnx",
|
|
2983
|
+
"vocoder.int8.onnx",
|
|
2984
|
+
"tts.json",
|
|
2985
|
+
"unicode_indexer.bin",
|
|
2986
|
+
"voice.bin"
|
|
2987
|
+
]
|
|
2988
|
+
}
|
|
2989
|
+
};
|
|
2990
|
+
var VoiceNotReady = class extends Error {};
|
|
2991
|
+
const ABANDONED_MS = 36e5;
|
|
2992
|
+
const states = /* @__PURE__ */ new Map();
|
|
2993
|
+
const installing = /* @__PURE__ */ new Map();
|
|
2994
|
+
function defaultModelsDir() {
|
|
2995
|
+
return join(homedir(), ".opencharm", "models");
|
|
2996
|
+
}
|
|
2997
|
+
function isInstalled(spec, dir) {
|
|
2998
|
+
return spec.files.every((file) => existsSync(join(dir, spec.folder, file)));
|
|
2999
|
+
}
|
|
3000
|
+
function untar(archive, into) {
|
|
3001
|
+
return new Promise((resolve, reject) => {
|
|
3002
|
+
execFile("tar", [
|
|
3003
|
+
"-xjf",
|
|
3004
|
+
archive,
|
|
3005
|
+
"-C",
|
|
3006
|
+
into
|
|
3007
|
+
], (error, _out, stderr) => {
|
|
3008
|
+
if (!error) resolve();
|
|
3009
|
+
else reject(/* @__PURE__ */ new Error(`Couldn't unpack the model: ${stderr.trim().slice(0, 200) || error.message}`));
|
|
3010
|
+
});
|
|
3011
|
+
});
|
|
3012
|
+
}
|
|
3013
|
+
async function download(spec, archive, options) {
|
|
3014
|
+
const response = await fetch(spec.url, { signal: options.signal ?? null });
|
|
3015
|
+
if (!response.ok || !response.body) throw new Error(`Couldn't download ${spec.label}: ${response.status} ${response.statusText}`);
|
|
3016
|
+
const total = Number(response.headers.get("content-length")) || spec.bytes;
|
|
3017
|
+
const hash = createHash("sha256");
|
|
3018
|
+
let received = 0;
|
|
3019
|
+
const body = Readable.fromWeb(response.body);
|
|
3020
|
+
body.on("data", (chunk) => {
|
|
3021
|
+
hash.update(chunk);
|
|
3022
|
+
received += chunk.length;
|
|
3023
|
+
if (spec.bytes && received > spec.bytes) body.destroy(/* @__PURE__ */ new Error(`${spec.label} was bigger than expected, so it was discarded`));
|
|
3024
|
+
else options.onProgress?.(received, total);
|
|
3025
|
+
});
|
|
3026
|
+
await pipeline(body, createWriteStream(archive, { mode: 384 }), { ...options.signal ? { signal: options.signal } : {} });
|
|
3027
|
+
if (spec.bytes && received !== spec.bytes || hash.digest("hex") !== spec.sha256) throw new Error(`${spec.label} didn't match its pinned checksum, so it was discarded`);
|
|
3028
|
+
}
|
|
3029
|
+
function removeAbandoned(dir, folder) {
|
|
3030
|
+
for (const name of readdirSync(dir)) {
|
|
3031
|
+
if (!name.startsWith(`.download-${folder}-`)) continue;
|
|
3032
|
+
const path = join(dir, name);
|
|
3033
|
+
try {
|
|
3034
|
+
if (Date.now() - statSync(path).mtimeMs > ABANDONED_MS) rmSync(path, {
|
|
3035
|
+
recursive: true,
|
|
3036
|
+
force: true
|
|
3037
|
+
});
|
|
3038
|
+
} catch {}
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
async function install(spec, dir, options) {
|
|
3042
|
+
mkdirSync(dir, { recursive: true });
|
|
3043
|
+
removeAbandoned(dir, spec.folder);
|
|
3044
|
+
const temp = join(dir, `.download-${spec.folder}-${randomBytes(6).toString("hex")}`);
|
|
3045
|
+
mkdirSync(temp);
|
|
3046
|
+
try {
|
|
3047
|
+
const archive = join(temp, "model.tar.bz2");
|
|
3048
|
+
await download(spec, archive, options);
|
|
3049
|
+
const unpacked = join(temp, "unpacked");
|
|
3050
|
+
mkdirSync(unpacked);
|
|
3051
|
+
await untar(archive, unpacked);
|
|
3052
|
+
if (!isInstalled(spec, unpacked)) throw new Error(`${spec.label} is missing files`);
|
|
3053
|
+
rmSync(join(dir, spec.folder), {
|
|
3054
|
+
recursive: true,
|
|
3055
|
+
force: true
|
|
3056
|
+
});
|
|
3057
|
+
renameSync(join(unpacked, spec.folder), join(dir, spec.folder));
|
|
3058
|
+
return join(dir, spec.folder);
|
|
3059
|
+
} finally {
|
|
3060
|
+
rmSync(temp, {
|
|
3061
|
+
recursive: true,
|
|
3062
|
+
force: true
|
|
3063
|
+
});
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
function modelState(id, dir = defaultModelsDir()) {
|
|
3067
|
+
if (isInstalled(MODELS[id], dir)) return { state: "ready" };
|
|
3068
|
+
return states.get(join(dir, id)) ?? { state: "missing" };
|
|
3069
|
+
}
|
|
3070
|
+
function installModel(id, options = {}) {
|
|
3071
|
+
const spec = options.spec ?? MODELS[id];
|
|
3072
|
+
const dir = options.dir ?? defaultModelsDir();
|
|
3073
|
+
if (isInstalled(spec, dir)) return Promise.resolve(join(dir, spec.folder));
|
|
3074
|
+
const key = join(dir, id);
|
|
3075
|
+
const running = installing.get(key);
|
|
3076
|
+
if (running) return running;
|
|
3077
|
+
states.set(key, {
|
|
3078
|
+
state: "downloading",
|
|
3079
|
+
received: 0,
|
|
3080
|
+
total: spec.bytes
|
|
3081
|
+
});
|
|
3082
|
+
const work = install(spec, dir, {
|
|
3083
|
+
...options,
|
|
3084
|
+
onProgress: (received, total) => {
|
|
3085
|
+
states.set(key, {
|
|
3086
|
+
state: "downloading",
|
|
3087
|
+
received,
|
|
3088
|
+
total
|
|
3089
|
+
});
|
|
3090
|
+
options.onProgress?.(received, total);
|
|
3091
|
+
}
|
|
3092
|
+
}).then((path) => {
|
|
3093
|
+
states.set(key, { state: "ready" });
|
|
3094
|
+
return path;
|
|
3095
|
+
}).catch((error) => {
|
|
3096
|
+
states.set(key, {
|
|
3097
|
+
state: "failed",
|
|
3098
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3099
|
+
at: Date.now()
|
|
3100
|
+
});
|
|
3101
|
+
throw error;
|
|
3102
|
+
}).finally(() => installing.delete(key));
|
|
3103
|
+
installing.set(key, work);
|
|
3104
|
+
return work;
|
|
3105
|
+
}
|
|
3106
|
+
//#endregion
|
|
3107
|
+
//#region ../charmd/src/voice/packets.ts
|
|
3108
|
+
function asError(problem) {
|
|
3109
|
+
if (problem instanceof Error) return problem;
|
|
3110
|
+
return new Error(typeof problem === "string" ? problem : "The voice failed");
|
|
3111
|
+
}
|
|
3112
|
+
var PacketStream = class {
|
|
3113
|
+
#packets = [];
|
|
3114
|
+
#done = false;
|
|
3115
|
+
#error;
|
|
3116
|
+
#waiters = [];
|
|
3117
|
+
constructor(source) {
|
|
3118
|
+
this.#pump(source);
|
|
3119
|
+
}
|
|
3120
|
+
async #pump(source) {
|
|
3121
|
+
try {
|
|
3122
|
+
for await (const packet of source) {
|
|
3123
|
+
this.#packets.push(packet);
|
|
3124
|
+
this.#wake();
|
|
3125
|
+
}
|
|
3126
|
+
} catch (error) {
|
|
3127
|
+
this.#error = asError(error);
|
|
3128
|
+
} finally {
|
|
3129
|
+
this.#done = true;
|
|
3130
|
+
this.#wake();
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
#wake() {
|
|
3134
|
+
for (const waiter of this.#waiters.splice(0)) waiter();
|
|
3135
|
+
}
|
|
3136
|
+
#next() {
|
|
3137
|
+
return new Promise((resolve) => this.#waiters.push(resolve));
|
|
3138
|
+
}
|
|
3139
|
+
async first() {
|
|
3140
|
+
while (this.#packets.length === 0) {
|
|
3141
|
+
if (this.#error !== void 0) throw this.#error;
|
|
3142
|
+
if (this.#done) throw new Error("The voice produced no audio");
|
|
3143
|
+
await this.#next();
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
async *[Symbol.asyncIterator]() {
|
|
3147
|
+
let index = 0;
|
|
3148
|
+
for (;;) {
|
|
3149
|
+
if (index < this.#packets.length) {
|
|
3150
|
+
yield this.#packets[index++];
|
|
3151
|
+
continue;
|
|
3152
|
+
}
|
|
3153
|
+
if (this.#error !== void 0) throw this.#error;
|
|
3154
|
+
if (this.#done) return;
|
|
3155
|
+
await this.#next();
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
};
|
|
3159
|
+
function packetsOf(speaker, text, signal, language) {
|
|
3160
|
+
if (speaker.stream) return speaker.stream(text, signal, language);
|
|
3161
|
+
return (async function* () {
|
|
3162
|
+
yield* readOggOpus(await speaker.synthesize(text, signal, language)).packets;
|
|
3163
|
+
})();
|
|
3164
|
+
}
|
|
3165
|
+
function createChannel() {
|
|
3166
|
+
const items = [];
|
|
3167
|
+
let done = false;
|
|
3168
|
+
let error;
|
|
3169
|
+
let waiter;
|
|
3170
|
+
const wake = () => {
|
|
3171
|
+
waiter?.();
|
|
3172
|
+
waiter = void 0;
|
|
3173
|
+
};
|
|
3174
|
+
return {
|
|
3175
|
+
push(item) {
|
|
3176
|
+
items.push(item);
|
|
3177
|
+
wake();
|
|
3178
|
+
},
|
|
3179
|
+
end() {
|
|
3180
|
+
done = true;
|
|
3181
|
+
wake();
|
|
3182
|
+
},
|
|
3183
|
+
fail(problem) {
|
|
3184
|
+
error = asError(problem);
|
|
3185
|
+
done = true;
|
|
3186
|
+
wake();
|
|
3187
|
+
},
|
|
3188
|
+
async *[Symbol.asyncIterator]() {
|
|
3189
|
+
for (;;) {
|
|
3190
|
+
const item = items.shift();
|
|
3191
|
+
if (item !== void 0) {
|
|
3192
|
+
yield item;
|
|
3193
|
+
continue;
|
|
3194
|
+
}
|
|
3195
|
+
if (error !== void 0) throw error;
|
|
3196
|
+
if (done) return;
|
|
3197
|
+
await new Promise((resolve) => waiter = resolve);
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
};
|
|
3201
|
+
}
|
|
3202
|
+
//#endregion
|
|
2624
3203
|
//#region ../charmd/src/turn/sentences.ts
|
|
2625
3204
|
const MIN_CHARS = 10;
|
|
2626
3205
|
const ABBREVIATIONS = /* @__PURE__ */ new Set([
|
|
@@ -2635,9 +3214,12 @@ const ABBREVIATIONS = /* @__PURE__ */ new Set([
|
|
|
2635
3214
|
"no."
|
|
2636
3215
|
]);
|
|
2637
3216
|
const BOUNDARY = /[.!?…]+(?=\s)|\n+/g;
|
|
3217
|
+
const FIRST_BOUNDARY = /[.!?…]+(?=\s)|[,;:](?=\s)|\n+/g;
|
|
3218
|
+
const FIRST_CLAUSE_CHARS = 20;
|
|
2638
3219
|
var SentenceSplitter = class {
|
|
2639
3220
|
#buffer = "";
|
|
2640
3221
|
#pending = "";
|
|
3222
|
+
#started = false;
|
|
2641
3223
|
#take(piece) {
|
|
2642
3224
|
const text = piece.replace(/\s+/g, " ").trim();
|
|
2643
3225
|
if (!text) return [];
|
|
@@ -2645,14 +3227,16 @@ var SentenceSplitter = class {
|
|
|
2645
3227
|
if (this.#pending.length < MIN_CHARS) return [];
|
|
2646
3228
|
const out = this.#pending;
|
|
2647
3229
|
this.#pending = "";
|
|
3230
|
+
this.#started = true;
|
|
2648
3231
|
return [out];
|
|
2649
3232
|
}
|
|
2650
3233
|
push(chunk) {
|
|
2651
3234
|
this.#buffer += chunk;
|
|
2652
3235
|
const out = [];
|
|
2653
3236
|
let start = 0;
|
|
2654
|
-
for (const match of this.#buffer.matchAll(BOUNDARY)) {
|
|
3237
|
+
for (const match of this.#buffer.matchAll(this.#started ? BOUNDARY : FIRST_BOUNDARY)) {
|
|
2655
3238
|
const end = match.index + match[0].length;
|
|
3239
|
+
if (/^[,;:]/.test(match[0]) && (this.#started || this.#pending.length + end - start < FIRST_CLAUSE_CHARS)) continue;
|
|
2656
3240
|
const lastWord = this.#buffer.slice(start, end).trim().split(/\s+/).at(-1)?.toLowerCase() ?? "";
|
|
2657
3241
|
if (match[0].startsWith(".") && ABBREVIATIONS.has(lastWord)) continue;
|
|
2658
3242
|
out.push(...this.#take(this.#buffer.slice(start, end)));
|
|
@@ -2675,12 +3259,13 @@ function cleanForSpeech(text) {
|
|
|
2675
3259
|
//#endregion
|
|
2676
3260
|
//#region ../charmd/src/turn/turn.ts
|
|
2677
3261
|
const MAX_FRAMES = 1e3;
|
|
3262
|
+
const LOOKAHEAD = 2;
|
|
2678
3263
|
const AGENT_LABELS = { ...Object.fromEntries(Object.entries(ACP_AGENTS).map(([name, agent]) => [name, agent.label])) };
|
|
2679
3264
|
var TurnTimeout = class extends Error {};
|
|
2680
3265
|
var StageError = class extends Error {
|
|
2681
3266
|
stage;
|
|
2682
3267
|
constructor(stage, cause) {
|
|
2683
|
-
super(cause instanceof Error ? cause.message : String(cause));
|
|
3268
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
|
2684
3269
|
this.stage = stage;
|
|
2685
3270
|
}
|
|
2686
3271
|
};
|
|
@@ -2742,6 +3327,10 @@ var TurnController = class {
|
|
|
2742
3327
|
});
|
|
2743
3328
|
this.#speaking = false;
|
|
2744
3329
|
}
|
|
3330
|
+
warm() {
|
|
3331
|
+
this.#deps.agent.warm?.(this.#deps.sessionKey);
|
|
3332
|
+
this.#deps.voice.warm?.().catch(() => void 0);
|
|
3333
|
+
}
|
|
2745
3334
|
listenStart() {
|
|
2746
3335
|
if (this.#state === "thinking" || this.#state === "speaking") this.#cancel();
|
|
2747
3336
|
this.#state = "listening";
|
|
@@ -2783,6 +3372,21 @@ var TurnController = class {
|
|
|
2783
3372
|
let outcome = "done";
|
|
2784
3373
|
const startClock = () => setTimeout(() => controller.abort(new TurnTimeout()), this.#deps.timeoutMs);
|
|
2785
3374
|
let timer = startClock();
|
|
3375
|
+
let speakingStarted = false;
|
|
3376
|
+
let asking = false;
|
|
3377
|
+
const restartClock = () => {
|
|
3378
|
+
clearTimeout(timer);
|
|
3379
|
+
timer = startClock();
|
|
3380
|
+
};
|
|
3381
|
+
const clock = {
|
|
3382
|
+
started: () => {
|
|
3383
|
+
speakingStarted = true;
|
|
3384
|
+
restartClock();
|
|
3385
|
+
},
|
|
3386
|
+
touch: () => {
|
|
3387
|
+
if (speakingStarted && !asking) restartClock();
|
|
3388
|
+
}
|
|
3389
|
+
};
|
|
2786
3390
|
const timeoutGuard = new Promise((_, reject) => signal.addEventListener("abort", () => {
|
|
2787
3391
|
if (signal.reason instanceof TurnTimeout) reject(signal.reason);
|
|
2788
3392
|
}));
|
|
@@ -2807,6 +3411,7 @@ var TurnController = class {
|
|
|
2807
3411
|
const ask = this.#deps.ask;
|
|
2808
3412
|
const label = AGENT_LABELS[this.#deps.agent.name] ?? "Your agent";
|
|
2809
3413
|
if (ask) this.#asking = async (text, options) => {
|
|
3414
|
+
asking = true;
|
|
2810
3415
|
clearTimeout(timer);
|
|
2811
3416
|
try {
|
|
2812
3417
|
return await ask(text, {
|
|
@@ -2814,6 +3419,7 @@ var TurnController = class {
|
|
|
2814
3419
|
signal
|
|
2815
3420
|
});
|
|
2816
3421
|
} finally {
|
|
3422
|
+
asking = false;
|
|
2817
3423
|
if (!signal.aborted) timer = startClock();
|
|
2818
3424
|
}
|
|
2819
3425
|
};
|
|
@@ -2823,7 +3429,7 @@ var TurnController = class {
|
|
|
2823
3429
|
signal,
|
|
2824
3430
|
...ask ? { ask: (action) => this.ask(`${label} wants to ${action}.`) } : {}
|
|
2825
3431
|
});
|
|
2826
|
-
said = await this.#speak(reply, signal, live, timings, started, timeoutGuard);
|
|
3432
|
+
said = await this.#speak(reply, signal, live, timings, started, timeoutGuard, guessLanguage(heard) ?? this.#deps.voice.language ?? "en", clock);
|
|
2827
3433
|
if (!live()) return;
|
|
2828
3434
|
if (this.#speaking) this.#deps.send({
|
|
2829
3435
|
type: "tts",
|
|
@@ -2835,6 +3441,7 @@ var TurnController = class {
|
|
|
2835
3441
|
this.#endFace = void 0;
|
|
2836
3442
|
this.#face(end.state, end.text);
|
|
2837
3443
|
} catch (error) {
|
|
3444
|
+
if (!signal.aborted) controller.abort(error);
|
|
2838
3445
|
outcome = error instanceof TurnTimeout ? "timeout" : error instanceof StageError ? `${error.stage}-error` : "error";
|
|
2839
3446
|
if (!live()) return;
|
|
2840
3447
|
if (this.#speaking) this.#deps.send({
|
|
@@ -2864,9 +3471,18 @@ var TurnController = class {
|
|
|
2864
3471
|
});
|
|
2865
3472
|
}
|
|
2866
3473
|
}
|
|
2867
|
-
async #speak(source, signal, live, timings, started, timeoutGuard) {
|
|
3474
|
+
async #speak(source, signal, live, timings, started, timeoutGuard, language, clock) {
|
|
2868
3475
|
const now = this.#deps.now ?? Date.now;
|
|
3476
|
+
const going = () => live() && !signal.aborted;
|
|
3477
|
+
let speaking = language;
|
|
2869
3478
|
const queue = [];
|
|
3479
|
+
let playing = 0;
|
|
3480
|
+
const startUpTo = (end) => {
|
|
3481
|
+
for (let i = playing; i < Math.min(end, queue.length); i++) {
|
|
3482
|
+
const item = queue[i];
|
|
3483
|
+
item.packets ??= new PacketStream(packetsOf(this.#deps.voice, item.text, signal, item.language));
|
|
3484
|
+
}
|
|
3485
|
+
};
|
|
2870
3486
|
let wake = () => void 0;
|
|
2871
3487
|
let sourceDone = false;
|
|
2872
3488
|
let said = "";
|
|
@@ -2874,19 +3490,23 @@ var TurnController = class {
|
|
|
2874
3490
|
const text = cleanForSpeech(sentence);
|
|
2875
3491
|
if (!text) return;
|
|
2876
3492
|
said += `${text} `;
|
|
2877
|
-
|
|
3493
|
+
speaking = guessLanguage(text) ?? speaking;
|
|
3494
|
+
const sentenceLanguage = speaking;
|
|
3495
|
+
timings.firstSentenceMs ??= now() - started;
|
|
3496
|
+
queue.push({
|
|
2878
3497
|
text,
|
|
2879
|
-
|
|
2880
|
-
})
|
|
2881
|
-
|
|
2882
|
-
queue.push(pending);
|
|
3498
|
+
language: sentenceLanguage
|
|
3499
|
+
});
|
|
3500
|
+
startUpTo(playing + LOOKAHEAD);
|
|
2883
3501
|
wake();
|
|
2884
3502
|
};
|
|
2885
3503
|
const produce = (async () => {
|
|
2886
3504
|
const splitter = new SentenceSplitter();
|
|
2887
3505
|
await inStage("agent", async () => {
|
|
2888
3506
|
for await (const chunk of source) {
|
|
2889
|
-
if (!
|
|
3507
|
+
if (!going()) return;
|
|
3508
|
+
timings.agentFirstMs ??= now() - started;
|
|
3509
|
+
clock.touch();
|
|
2890
3510
|
for (const sentence of splitter.push(chunk)) enqueue(sentence);
|
|
2891
3511
|
}
|
|
2892
3512
|
});
|
|
@@ -2897,19 +3517,23 @@ var TurnController = class {
|
|
|
2897
3517
|
});
|
|
2898
3518
|
produce.catch(() => void 0);
|
|
2899
3519
|
const play = async () => {
|
|
2900
|
-
let index = 0;
|
|
2901
3520
|
for (;;) {
|
|
2902
|
-
if (
|
|
3521
|
+
if (!going()) return;
|
|
3522
|
+
if (playing >= queue.length) {
|
|
2903
3523
|
if (sourceDone) return;
|
|
2904
3524
|
await new Promise((resolve) => wake = resolve);
|
|
2905
3525
|
continue;
|
|
2906
3526
|
}
|
|
2907
|
-
|
|
2908
|
-
|
|
3527
|
+
startUpTo(playing + LOOKAHEAD);
|
|
3528
|
+
const { text, packets } = queue[playing];
|
|
3529
|
+
if (!packets) throw new Error("A sentence wasn't started");
|
|
3530
|
+
await Promise.race([inStage("tts", () => packets.first()), timeoutGuard]);
|
|
3531
|
+
if (!going()) return;
|
|
2909
3532
|
if (!this.#speaking) {
|
|
2910
3533
|
this.#speaking = true;
|
|
2911
3534
|
this.#state = "speaking";
|
|
2912
3535
|
timings.firstAudioMs = now() - started;
|
|
3536
|
+
clock.started();
|
|
2913
3537
|
this.#deps.send({
|
|
2914
3538
|
type: "tts",
|
|
2915
3539
|
state: "start"
|
|
@@ -2920,7 +3544,8 @@ var TurnController = class {
|
|
|
2920
3544
|
state: "sentence_start",
|
|
2921
3545
|
text
|
|
2922
3546
|
});
|
|
2923
|
-
await this.#sendPaced(
|
|
3547
|
+
await Promise.race([inStage("tts", () => this.#sendPaced(packets, going, clock.touch)), timeoutGuard]);
|
|
3548
|
+
playing += 1;
|
|
2924
3549
|
}
|
|
2925
3550
|
};
|
|
2926
3551
|
await Promise.all([Promise.race([produce, timeoutGuard]), play()]);
|
|
@@ -2932,7 +3557,16 @@ var TurnController = class {
|
|
|
2932
3557
|
const live = () => this.#generation === generation;
|
|
2933
3558
|
const controller = new AbortController();
|
|
2934
3559
|
this.#controller = controller;
|
|
2935
|
-
const
|
|
3560
|
+
const startClock = () => setTimeout(() => controller.abort(new TurnTimeout()), this.#deps.timeoutMs);
|
|
3561
|
+
let timer = startClock();
|
|
3562
|
+
const restartClock = () => {
|
|
3563
|
+
clearTimeout(timer);
|
|
3564
|
+
timer = startClock();
|
|
3565
|
+
};
|
|
3566
|
+
const clock = {
|
|
3567
|
+
started: restartClock,
|
|
3568
|
+
touch: restartClock
|
|
3569
|
+
};
|
|
2936
3570
|
const timeoutGuard = new Promise((_, reject) => controller.signal.addEventListener("abort", () => {
|
|
2937
3571
|
if (controller.signal.reason instanceof TurnTimeout) reject(controller.signal.reason);
|
|
2938
3572
|
}));
|
|
@@ -2941,7 +3575,7 @@ var TurnController = class {
|
|
|
2941
3575
|
yield await Promise.resolve(text);
|
|
2942
3576
|
}
|
|
2943
3577
|
try {
|
|
2944
|
-
await this.#speak(once(), controller.signal, live, {}, Date.now(), timeoutGuard);
|
|
3578
|
+
await this.#speak(once(), controller.signal, live, {}, Date.now(), timeoutGuard, guessLanguage(text) ?? this.#deps.voice.language ?? "en", clock);
|
|
2945
3579
|
if (!live()) return;
|
|
2946
3580
|
if (this.#speaking) this.#deps.send({
|
|
2947
3581
|
type: "tts",
|
|
@@ -2951,6 +3585,7 @@ var TurnController = class {
|
|
|
2951
3585
|
this.#state = "idle";
|
|
2952
3586
|
this.#face("idle");
|
|
2953
3587
|
} catch (error) {
|
|
3588
|
+
if (!controller.signal.aborted) controller.abort(error);
|
|
2954
3589
|
if (!live()) return;
|
|
2955
3590
|
if (this.#speaking) this.#deps.send({
|
|
2956
3591
|
type: "tts",
|
|
@@ -2965,16 +3600,19 @@ var TurnController = class {
|
|
|
2965
3600
|
}
|
|
2966
3601
|
#failureLine(error) {
|
|
2967
3602
|
if (error instanceof TurnTimeout) return "That took too long.";
|
|
3603
|
+
if (error instanceof StageError && error.cause instanceof VoiceNotReady) return error.cause.message;
|
|
2968
3604
|
if (error instanceof StageError && error.stage === "stt") return "I couldn't hear that.";
|
|
2969
3605
|
if (error instanceof StageError && error.stage === "tts") return "I couldn't speak that.";
|
|
2970
3606
|
return `Can't reach ${AGENT_LABELS[this.#deps.agent.name] ?? "your agent"}`;
|
|
2971
3607
|
}
|
|
2972
|
-
async #sendPaced(packets, live) {
|
|
3608
|
+
async #sendPaced(packets, live, onPacket) {
|
|
2973
3609
|
const sleep = this.#deps.sleep ?? sleepMs;
|
|
2974
|
-
|
|
3610
|
+
let index = 0;
|
|
3611
|
+
for await (const packet of packets) {
|
|
2975
3612
|
if (!live()) return;
|
|
2976
3613
|
this.#deps.sendAudio(packet);
|
|
2977
|
-
|
|
3614
|
+
onPacket();
|
|
3615
|
+
if (index++ >= 2) await sleep(opusPacketSamples48k(packet) / 48);
|
|
2978
3616
|
}
|
|
2979
3617
|
}
|
|
2980
3618
|
dispose() {
|
|
@@ -3484,6 +4122,205 @@ function createLocalVoice(options) {
|
|
|
3484
4122
|
};
|
|
3485
4123
|
}
|
|
3486
4124
|
//#endregion
|
|
4125
|
+
//#region ../charmd/src/audio/webm-opus.ts
|
|
4126
|
+
const SEGMENT = 408125543;
|
|
4127
|
+
const CLUSTER = 524531317;
|
|
4128
|
+
const BLOCK_GROUP = 160;
|
|
4129
|
+
const BLOCK = 161;
|
|
4130
|
+
const SIMPLE_BLOCK = 163;
|
|
4131
|
+
const CONTAINERS = /* @__PURE__ */ new Set([
|
|
4132
|
+
SEGMENT,
|
|
4133
|
+
CLUSTER,
|
|
4134
|
+
BLOCK_GROUP
|
|
4135
|
+
]);
|
|
4136
|
+
var CutShort = class extends Error {};
|
|
4137
|
+
function readVint(data, offset, keepMarker) {
|
|
4138
|
+
const first = data[offset];
|
|
4139
|
+
if (first === void 0) throw new CutShort();
|
|
4140
|
+
if (first === 0) throw new Error("Not a WebM file: bad element header");
|
|
4141
|
+
const length = Math.clz32(first) - 23;
|
|
4142
|
+
if (offset + length > data.length) throw new CutShort();
|
|
4143
|
+
let value = keepMarker ? first : first & 255 >> length;
|
|
4144
|
+
let allOnes = value === 255 >> length;
|
|
4145
|
+
for (let i = 1; i < length; i++) {
|
|
4146
|
+
const byte = data[offset + i];
|
|
4147
|
+
value = value * 256 + byte;
|
|
4148
|
+
allOnes &&= byte === 255;
|
|
4149
|
+
}
|
|
4150
|
+
return {
|
|
4151
|
+
value,
|
|
4152
|
+
length,
|
|
4153
|
+
unknown: !keepMarker && allOnes
|
|
4154
|
+
};
|
|
4155
|
+
}
|
|
4156
|
+
function frameOf(block) {
|
|
4157
|
+
const track = readVint(block, 0, false);
|
|
4158
|
+
const flags = block[track.length + 2];
|
|
4159
|
+
if (flags === void 0) throw new Error("Not a WebM file: empty block");
|
|
4160
|
+
if (flags & 6) throw new Error("WebM lacing isn't supported");
|
|
4161
|
+
return block.subarray(track.length + 3);
|
|
4162
|
+
}
|
|
4163
|
+
function parse(data) {
|
|
4164
|
+
const packets = [];
|
|
4165
|
+
let offset = 0;
|
|
4166
|
+
while (offset < data.length) {
|
|
4167
|
+
let id;
|
|
4168
|
+
let size;
|
|
4169
|
+
try {
|
|
4170
|
+
id = readVint(data, offset, true);
|
|
4171
|
+
size = readVint(data, offset + id.length, false);
|
|
4172
|
+
} catch (error) {
|
|
4173
|
+
if (error instanceof CutShort) break;
|
|
4174
|
+
throw error;
|
|
4175
|
+
}
|
|
4176
|
+
const body = offset + id.length + size.length;
|
|
4177
|
+
if (CONTAINERS.has(id.value)) {
|
|
4178
|
+
offset = body;
|
|
4179
|
+
continue;
|
|
4180
|
+
}
|
|
4181
|
+
if (size.unknown) throw new Error("Not a WebM file: an element of unknown size");
|
|
4182
|
+
const end = body + size.value;
|
|
4183
|
+
if (end > data.length) break;
|
|
4184
|
+
if (id.value === SIMPLE_BLOCK || id.value === BLOCK) {
|
|
4185
|
+
const frame = frameOf(data.subarray(body, end));
|
|
4186
|
+
if (frame.length) packets.push(frame);
|
|
4187
|
+
}
|
|
4188
|
+
offset = end;
|
|
4189
|
+
}
|
|
4190
|
+
return {
|
|
4191
|
+
packets,
|
|
4192
|
+
consumed: offset
|
|
4193
|
+
};
|
|
4194
|
+
}
|
|
4195
|
+
var WebmOpusReader = class {
|
|
4196
|
+
#pending = Buffer.alloc(0);
|
|
4197
|
+
push(chunk) {
|
|
4198
|
+
this.#pending = Buffer.concat([this.#pending, chunk]);
|
|
4199
|
+
const { packets, consumed } = parse(this.#pending);
|
|
4200
|
+
this.#pending = this.#pending.subarray(consumed);
|
|
4201
|
+
return packets;
|
|
4202
|
+
}
|
|
4203
|
+
get complete() {
|
|
4204
|
+
return this.#pending.length === 0;
|
|
4205
|
+
}
|
|
4206
|
+
};
|
|
4207
|
+
//#endregion
|
|
4208
|
+
//#region ../charmd/src/voice/microsoft.ts
|
|
4209
|
+
const TRUSTED_CLIENT_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4";
|
|
4210
|
+
const SERVICE_URL = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1";
|
|
4211
|
+
const GEC_VERSION = "1-143.0.3650.96";
|
|
4212
|
+
const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0";
|
|
4213
|
+
const ORIGIN = "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold";
|
|
4214
|
+
const OUTPUT_FORMAT = "webm-24khz-16bit-mono-opus";
|
|
4215
|
+
const DEFAULT_VOICES = {
|
|
4216
|
+
en: "en-US-AvaMultilingualNeural",
|
|
4217
|
+
it: "it-IT-IsabellaNeural",
|
|
4218
|
+
es: "es-ES-ElviraNeural",
|
|
4219
|
+
fr: "fr-FR-DeniseNeural",
|
|
4220
|
+
de: "de-DE-KatjaNeural",
|
|
4221
|
+
pt: "pt-BR-FranciscaNeural"
|
|
4222
|
+
};
|
|
4223
|
+
const WINDOWS_EPOCH_SECONDS = 11644473600;
|
|
4224
|
+
function secMsGec(nowMs = Date.now()) {
|
|
4225
|
+
const seconds = Math.floor(nowMs / 1e3) + WINDOWS_EPOCH_SECONDS;
|
|
4226
|
+
const ticks = BigInt(seconds - seconds % 300) * 10000000n;
|
|
4227
|
+
return createHash("sha256").update(`${ticks}${TRUSTED_CLIENT_TOKEN}`).digest("hex").toUpperCase();
|
|
4228
|
+
}
|
|
4229
|
+
function escapeXml(text) {
|
|
4230
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
4231
|
+
}
|
|
4232
|
+
function ssml(text, voice) {
|
|
4233
|
+
return `<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='${voice.split("-").slice(0, 2).join("-")}'><voice name='${voice}'><prosody pitch='+0Hz' rate='+0%' volume='+0%'>${escapeXml(text)}</prosody></voice></speak>`;
|
|
4234
|
+
}
|
|
4235
|
+
function message(path, contentType, body, requestId) {
|
|
4236
|
+
return `X-Timestamp:${(/* @__PURE__ */ new Date()).toISOString()}\r\n` + (requestId ? `X-RequestId:${requestId}\r\n` : "") + `Content-Type:${contentType}\r\nPath:${path}\r\n\r\n${body}`;
|
|
4237
|
+
}
|
|
4238
|
+
function audioOf(frame) {
|
|
4239
|
+
if (frame.length < 2) return void 0;
|
|
4240
|
+
const headerLength = frame.readUInt16BE(0);
|
|
4241
|
+
return frame.subarray(2, 2 + headerLength).toString().includes("Path:audio") ? frame.subarray(2 + headerLength) : void 0;
|
|
4242
|
+
}
|
|
4243
|
+
function voiceFor(language, voices) {
|
|
4244
|
+
return voices[language] ?? voices.en ?? DEFAULT_VOICES.en;
|
|
4245
|
+
}
|
|
4246
|
+
function connect(text, language, voices, signal, options) {
|
|
4247
|
+
const timeoutMs = options.timeoutMs ?? 15e3;
|
|
4248
|
+
const connection = randomUUID().replace(/-/g, "");
|
|
4249
|
+
const url = `${options.url ?? SERVICE_URL}?TrustedClientToken=${TRUSTED_CLIENT_TOKEN}&Sec-MS-GEC=${secMsGec()}&Sec-MS-GEC-Version=${GEC_VERSION}&ConnectionId=${connection}`;
|
|
4250
|
+
const packets = createChannel();
|
|
4251
|
+
const reader = new WebmOpusReader();
|
|
4252
|
+
let sent = 0;
|
|
4253
|
+
let settled = false;
|
|
4254
|
+
const socket = new WebSocket(url, {
|
|
4255
|
+
headers: {
|
|
4256
|
+
"User-Agent": USER_AGENT,
|
|
4257
|
+
Origin: ORIGIN
|
|
4258
|
+
},
|
|
4259
|
+
handshakeTimeout: timeoutMs
|
|
4260
|
+
});
|
|
4261
|
+
const finish = (error) => {
|
|
4262
|
+
if (settled) return;
|
|
4263
|
+
settled = true;
|
|
4264
|
+
clearTimeout(timer);
|
|
4265
|
+
signal.removeEventListener("abort", onAbort);
|
|
4266
|
+
socket.terminate();
|
|
4267
|
+
if (error) packets.fail(error);
|
|
4268
|
+
else if (sent === 0) packets.fail(/* @__PURE__ */ new Error("Microsoft's voice sent no audio"));
|
|
4269
|
+
else packets.end();
|
|
4270
|
+
};
|
|
4271
|
+
const onAbort = () => finish(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("Aborted"));
|
|
4272
|
+
const timer = setTimeout(() => finish(/* @__PURE__ */ new Error("Microsoft's voice took too long")), timeoutMs);
|
|
4273
|
+
if (signal.aborted) onAbort();
|
|
4274
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
4275
|
+
socket.on("open", () => {
|
|
4276
|
+
socket.send(message("speech.config", "application/json; charset=utf-8", JSON.stringify({ context: { synthesis: { audio: {
|
|
4277
|
+
metadataoptions: {
|
|
4278
|
+
sentenceBoundaryEnabled: "false",
|
|
4279
|
+
wordBoundaryEnabled: "false"
|
|
4280
|
+
},
|
|
4281
|
+
outputFormat: OUTPUT_FORMAT
|
|
4282
|
+
} } } })));
|
|
4283
|
+
socket.send(message("ssml", "application/ssml+xml", ssml(text, voiceFor(language, voices)), connection));
|
|
4284
|
+
});
|
|
4285
|
+
socket.on("message", (data, isBinary) => {
|
|
4286
|
+
const frame = Buffer.isBuffer(data) ? data : Buffer.concat(data);
|
|
4287
|
+
if (!isBinary) {
|
|
4288
|
+
if (frame.toString().includes("Path:turn.end")) finish();
|
|
4289
|
+
return;
|
|
4290
|
+
}
|
|
4291
|
+
const audio = audioOf(frame);
|
|
4292
|
+
if (!audio?.length) return;
|
|
4293
|
+
try {
|
|
4294
|
+
for (const packet of reader.push(audio)) {
|
|
4295
|
+
sent += 1;
|
|
4296
|
+
packets.push(packet);
|
|
4297
|
+
}
|
|
4298
|
+
} catch (error) {
|
|
4299
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
4300
|
+
}
|
|
4301
|
+
});
|
|
4302
|
+
socket.on("unexpected-response", (_request, response) => finish(/* @__PURE__ */ new Error(`Microsoft's voice refused the request: ${response.statusCode}`)));
|
|
4303
|
+
socket.on("error", (error) => finish(/* @__PURE__ */ new Error(`Microsoft's voice failed: ${error.message}`)));
|
|
4304
|
+
socket.on("close", () => finish(/* @__PURE__ */ new Error("Microsoft's voice closed before it finished")));
|
|
4305
|
+
return packets;
|
|
4306
|
+
}
|
|
4307
|
+
function createMicrosoftSpeaker(options = {}) {
|
|
4308
|
+
const voices = {
|
|
4309
|
+
...DEFAULT_VOICES,
|
|
4310
|
+
...options.voices
|
|
4311
|
+
};
|
|
4312
|
+
const stream = (text, signal, language = "en") => connect(text, language, voices, signal, options);
|
|
4313
|
+
return {
|
|
4314
|
+
name: "microsoft",
|
|
4315
|
+
stream,
|
|
4316
|
+
async synthesize(text, signal, language = "en") {
|
|
4317
|
+
const packets = [];
|
|
4318
|
+
for await (const packet of stream(text, signal, language)) packets.push(packet);
|
|
4319
|
+
return writeOggOpus(packets, { inputSampleRate: 24e3 });
|
|
4320
|
+
}
|
|
4321
|
+
};
|
|
4322
|
+
}
|
|
4323
|
+
//#endregion
|
|
3487
4324
|
//#region ../charmd/src/voice/openai.ts
|
|
3488
4325
|
function createOpenAiVoice(options) {
|
|
3489
4326
|
const apiKey = options.apiKey ?? process.env.OPENAI_API_KEY;
|
|
@@ -3527,22 +4364,260 @@ function createOpenAiVoice(options) {
|
|
|
3527
4364
|
};
|
|
3528
4365
|
}
|
|
3529
4366
|
//#endregion
|
|
4367
|
+
//#region ../charmd/src/voice/sherpa.ts
|
|
4368
|
+
const SUPERTONIC_LANGUAGES = /* @__PURE__ */ new Set([
|
|
4369
|
+
"en",
|
|
4370
|
+
"it",
|
|
4371
|
+
"es",
|
|
4372
|
+
"fr",
|
|
4373
|
+
"de",
|
|
4374
|
+
"pt"
|
|
4375
|
+
]);
|
|
4376
|
+
const DEFAULT_SPEAKER = 5;
|
|
4377
|
+
const OUTPUT_RATE = 24e3;
|
|
4378
|
+
let sherpa;
|
|
4379
|
+
function loadSherpa() {
|
|
4380
|
+
if (sherpa) return sherpa;
|
|
4381
|
+
try {
|
|
4382
|
+
sherpa = createRequire(import.meta.url)("sherpa-onnx-node");
|
|
4383
|
+
return sherpa;
|
|
4384
|
+
} catch (error) {
|
|
4385
|
+
throw new Error(`The local voice can't run on this computer: sherpa-onnx didn't load (${error instanceof Error ? error.message.split("\n")[0] : String(error)})`, { cause: error });
|
|
4386
|
+
}
|
|
4387
|
+
}
|
|
4388
|
+
function ready(id, dir) {
|
|
4389
|
+
const state = modelState(id, dir);
|
|
4390
|
+
if (state.state === "ready") return join(dir, MODELS[id].folder);
|
|
4391
|
+
if (state.state === "failed" && Date.now() - state.at < 3e5) throw new VoiceNotReady(`Couldn't get ${MODELS[id].label}: ${state.error}`.slice(0, 160));
|
|
4392
|
+
if (state.state !== "downloading") installModel(id, { dir }).catch(() => void 0);
|
|
4393
|
+
const now = modelState(id, dir);
|
|
4394
|
+
const percent = now.state === "downloading" && now.total ? ` (${Math.floor(now.received * 100 / now.total)}%)` : "";
|
|
4395
|
+
throw new VoiceNotReady(`${MODELS[id].label} is still downloading${percent}`);
|
|
4396
|
+
}
|
|
4397
|
+
function toFloat(pcm) {
|
|
4398
|
+
const out = new Float32Array(pcm.length / 2);
|
|
4399
|
+
for (let i = 0; i < out.length; i++) out[i] = pcm.readInt16LE(i * 2) / 32768;
|
|
4400
|
+
return out;
|
|
4401
|
+
}
|
|
4402
|
+
function toPcm(samples) {
|
|
4403
|
+
const out = Buffer.alloc(samples.length * 2);
|
|
4404
|
+
for (let i = 0; i < samples.length; i++) out.writeInt16LE(Math.round(Math.max(-1, Math.min(1, samples[i] ?? 0)) * 32767), i * 2);
|
|
4405
|
+
return out;
|
|
4406
|
+
}
|
|
4407
|
+
function createParakeetListener(options = {}) {
|
|
4408
|
+
const dir = options.modelsDir ?? defaultModelsDir();
|
|
4409
|
+
let recognizer;
|
|
4410
|
+
const load = () => {
|
|
4411
|
+
const folder = ready("parakeet", dir);
|
|
4412
|
+
recognizer ??= loadSherpa().OfflineRecognizer.createAsync({
|
|
4413
|
+
featConfig: {
|
|
4414
|
+
sampleRate: 16e3,
|
|
4415
|
+
featureDim: 80
|
|
4416
|
+
},
|
|
4417
|
+
modelConfig: {
|
|
4418
|
+
transducer: {
|
|
4419
|
+
encoder: join(folder, "encoder.int8.onnx"),
|
|
4420
|
+
decoder: join(folder, "decoder.int8.onnx"),
|
|
4421
|
+
joiner: join(folder, "joiner.int8.onnx")
|
|
4422
|
+
},
|
|
4423
|
+
tokens: join(folder, "tokens.txt"),
|
|
4424
|
+
modelType: "nemo_transducer",
|
|
4425
|
+
numThreads: options.threads ?? 4,
|
|
4426
|
+
provider: "cpu"
|
|
4427
|
+
}
|
|
4428
|
+
}).catch((error) => {
|
|
4429
|
+
recognizer = void 0;
|
|
4430
|
+
throw error;
|
|
4431
|
+
});
|
|
4432
|
+
return recognizer;
|
|
4433
|
+
};
|
|
4434
|
+
return {
|
|
4435
|
+
name: "local",
|
|
4436
|
+
warm: async () => {
|
|
4437
|
+
await load();
|
|
4438
|
+
},
|
|
4439
|
+
async transcribe(ogg) {
|
|
4440
|
+
const engine = await load();
|
|
4441
|
+
const stream = engine.createStream();
|
|
4442
|
+
stream.acceptWaveform({
|
|
4443
|
+
samples: toFloat(oggOpusToPcm(ogg, 16e3)),
|
|
4444
|
+
sampleRate: 16e3
|
|
4445
|
+
});
|
|
4446
|
+
return ((await engine.decodeAsync(stream)).text ?? "").replace(/\s+/g, " ").trim();
|
|
4447
|
+
}
|
|
4448
|
+
};
|
|
4449
|
+
}
|
|
4450
|
+
function createSupertonicSpeaker(options = {}) {
|
|
4451
|
+
const dir = options.modelsDir ?? defaultModelsDir();
|
|
4452
|
+
let tts;
|
|
4453
|
+
const load = () => {
|
|
4454
|
+
const folder = ready("supertonic", dir);
|
|
4455
|
+
const file = (name) => join(folder, name);
|
|
4456
|
+
tts ??= loadSherpa().OfflineTts.createAsync({
|
|
4457
|
+
model: {
|
|
4458
|
+
supertonic: {
|
|
4459
|
+
durationPredictor: file("duration_predictor.int8.onnx"),
|
|
4460
|
+
textEncoder: file("text_encoder.int8.onnx"),
|
|
4461
|
+
vectorEstimator: file("vector_estimator.int8.onnx"),
|
|
4462
|
+
vocoder: file("vocoder.int8.onnx"),
|
|
4463
|
+
ttsJson: file("tts.json"),
|
|
4464
|
+
unicodeIndexer: file("unicode_indexer.bin"),
|
|
4465
|
+
voiceStyle: file("voice.bin")
|
|
4466
|
+
},
|
|
4467
|
+
numThreads: options.threads ?? 4,
|
|
4468
|
+
provider: "cpu"
|
|
4469
|
+
},
|
|
4470
|
+
maxNumSentences: 1
|
|
4471
|
+
}).catch((error) => {
|
|
4472
|
+
tts = void 0;
|
|
4473
|
+
throw error;
|
|
4474
|
+
});
|
|
4475
|
+
return tts;
|
|
4476
|
+
};
|
|
4477
|
+
return {
|
|
4478
|
+
name: "local",
|
|
4479
|
+
onDevice: true,
|
|
4480
|
+
warm: async () => {
|
|
4481
|
+
await load();
|
|
4482
|
+
},
|
|
4483
|
+
async synthesize(text, signal, language = "en") {
|
|
4484
|
+
const engine = await load();
|
|
4485
|
+
const sherpaModule = loadSherpa();
|
|
4486
|
+
const audio = await engine.generateAsync({
|
|
4487
|
+
text,
|
|
4488
|
+
generationConfig: new sherpaModule.GenerationConfig({
|
|
4489
|
+
sid: options.speaker ?? DEFAULT_SPEAKER,
|
|
4490
|
+
numSteps: 8,
|
|
4491
|
+
extra: { lang: SUPERTONIC_LANGUAGES.has(language) ? language : "en" }
|
|
4492
|
+
}),
|
|
4493
|
+
onProgress: () => signal.aborted ? 0 : 1
|
|
4494
|
+
});
|
|
4495
|
+
signal.throwIfAborted();
|
|
4496
|
+
return pcmToOggOpus(toPcm(audio.sampleRate === OUTPUT_RATE ? audio.samples : new sherpaModule.LinearResampler(audio.sampleRate, OUTPUT_RATE).flush(audio.samples)), OUTPUT_RATE);
|
|
4497
|
+
}
|
|
4498
|
+
};
|
|
4499
|
+
}
|
|
4500
|
+
//#endregion
|
|
4501
|
+
//#region ../charmd/src/voice/speak.ts
|
|
4502
|
+
const FIRST_AUDIO_MS = 3e3;
|
|
4503
|
+
const REST_MS = 6e4;
|
|
4504
|
+
async function firstWithin(packets, ms, name) {
|
|
4505
|
+
let timer;
|
|
4506
|
+
try {
|
|
4507
|
+
await Promise.race([packets.first(), new Promise((_, reject) => {
|
|
4508
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`${name} had no audio within ${ms} ms`)), ms);
|
|
4509
|
+
})]);
|
|
4510
|
+
} finally {
|
|
4511
|
+
clearTimeout(timer);
|
|
4512
|
+
}
|
|
4513
|
+
}
|
|
4514
|
+
function speakWithFallback(chain, options = {}) {
|
|
4515
|
+
const [first] = chain;
|
|
4516
|
+
if (!first) throw new Error("No voice to speak with");
|
|
4517
|
+
if (chain.length === 1) return first;
|
|
4518
|
+
const now = options.now ?? Date.now;
|
|
4519
|
+
const resting = /* @__PURE__ */ new Map();
|
|
4520
|
+
async function* stream(text, signal, language) {
|
|
4521
|
+
let lastError;
|
|
4522
|
+
for (const [index, speaker] of chain.entries()) {
|
|
4523
|
+
const last = index === chain.length - 1;
|
|
4524
|
+
if (!last && (resting.get(speaker) ?? 0) > now()) continue;
|
|
4525
|
+
const controller = new AbortController();
|
|
4526
|
+
const abort = () => controller.abort(signal.reason);
|
|
4527
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
4528
|
+
const packets = new PacketStream(packetsOf(speaker, text, controller.signal, language));
|
|
4529
|
+
try {
|
|
4530
|
+
if (last || speaker.onDevice) await packets.first();
|
|
4531
|
+
else await firstWithin(packets, options.timeoutMs ?? FIRST_AUDIO_MS, speaker.name);
|
|
4532
|
+
} catch (error) {
|
|
4533
|
+
signal.removeEventListener("abort", abort);
|
|
4534
|
+
controller.abort(error);
|
|
4535
|
+
signal.throwIfAborted();
|
|
4536
|
+
lastError = error;
|
|
4537
|
+
resting.set(speaker, now() + (options.restMs ?? REST_MS));
|
|
4538
|
+
continue;
|
|
4539
|
+
}
|
|
4540
|
+
try {
|
|
4541
|
+
yield* packets;
|
|
4542
|
+
} finally {
|
|
4543
|
+
signal.removeEventListener("abort", abort);
|
|
4544
|
+
}
|
|
4545
|
+
return;
|
|
4546
|
+
}
|
|
4547
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
4548
|
+
}
|
|
4549
|
+
return {
|
|
4550
|
+
name: first.name,
|
|
4551
|
+
stream,
|
|
4552
|
+
async synthesize(text, signal, language) {
|
|
4553
|
+
const packets = [];
|
|
4554
|
+
for await (const packet of stream(text, signal, language)) packets.push(packet);
|
|
4555
|
+
return writeOggOpus(packets, { inputSampleRate: 24e3 });
|
|
4556
|
+
},
|
|
4557
|
+
warm: async () => {
|
|
4558
|
+
await Promise.allSettled(chain.map((speaker) => speaker.warm?.() ?? Promise.resolve()));
|
|
4559
|
+
}
|
|
4560
|
+
};
|
|
4561
|
+
}
|
|
4562
|
+
//#endregion
|
|
3530
4563
|
//#region ../charmd/src/wiring.ts
|
|
3531
4564
|
function key(config, fallbackEnv) {
|
|
3532
4565
|
const env = config.apiKeyEnv ?? fallbackEnv;
|
|
3533
4566
|
return config.apiKey ?? (env ? process.env[env] : void 0);
|
|
3534
4567
|
}
|
|
3535
|
-
function
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
case "
|
|
3539
|
-
case "
|
|
4568
|
+
function createListener(config) {
|
|
4569
|
+
switch (config.provider) {
|
|
4570
|
+
case "fake": return createFakeVoice(config);
|
|
4571
|
+
case "local": return createParakeetListener();
|
|
4572
|
+
case "whisper": return createLocalVoice({
|
|
4573
|
+
...config.model ? { whisperModel: config.model } : {},
|
|
4574
|
+
...config.command ? { whisperCommand: config.command } : {}
|
|
4575
|
+
});
|
|
3540
4576
|
case "openai": return createOpenAiVoice({
|
|
3541
|
-
...
|
|
3542
|
-
apiKey: key(
|
|
4577
|
+
...config,
|
|
4578
|
+
apiKey: key(config, "OPENAI_API_KEY"),
|
|
4579
|
+
...config.model ? { sttModel: config.model } : {}
|
|
3543
4580
|
});
|
|
3544
4581
|
}
|
|
3545
4582
|
}
|
|
4583
|
+
function createSpeaker(config) {
|
|
4584
|
+
switch (config.provider) {
|
|
4585
|
+
case "fake": return createFakeVoice();
|
|
4586
|
+
case "microsoft": return createMicrosoftSpeaker(config.voices ? { voices: config.voices } : {});
|
|
4587
|
+
case "local": return createSupertonicSpeaker(config.speaker === void 0 ? {} : { speaker: config.speaker });
|
|
4588
|
+
case "system": return {
|
|
4589
|
+
...createLocalVoice(config.voice ? { sayVoice: config.voice } : {}),
|
|
4590
|
+
name: "system",
|
|
4591
|
+
onDevice: true
|
|
4592
|
+
};
|
|
4593
|
+
case "openai": return createOpenAiVoice({
|
|
4594
|
+
...config,
|
|
4595
|
+
apiKey: key(config, "OPENAI_API_KEY"),
|
|
4596
|
+
...config.model ? { ttsModel: config.model } : {}
|
|
4597
|
+
});
|
|
4598
|
+
}
|
|
4599
|
+
}
|
|
4600
|
+
function speakerChain(config) {
|
|
4601
|
+
if (config.provider === "fake" || config.provider === "system") return createSpeaker(config);
|
|
4602
|
+
const chain = [createSpeaker(config)];
|
|
4603
|
+
if (config.provider === "microsoft") chain.push(createSupertonicSpeaker());
|
|
4604
|
+
if (process.platform === "darwin") chain.push(createSpeaker({ provider: "system" }));
|
|
4605
|
+
return speakWithFallback(chain);
|
|
4606
|
+
}
|
|
4607
|
+
function createVoiceFromConfig(config) {
|
|
4608
|
+
const listener = createListener(config.voice.listen);
|
|
4609
|
+
const speaker = speakerChain(config.voice.speak);
|
|
4610
|
+
return {
|
|
4611
|
+
name: listener.name === speaker.name ? listener.name : `${listener.name}+${speaker.name}`,
|
|
4612
|
+
transcribe: listener.transcribe,
|
|
4613
|
+
synthesize: speaker.synthesize,
|
|
4614
|
+
...speaker.stream ? { stream: speaker.stream } : {},
|
|
4615
|
+
language: config.voice.language,
|
|
4616
|
+
warm: async () => {
|
|
4617
|
+
await Promise.allSettled([listener.warm?.(), speaker.warm?.()]);
|
|
4618
|
+
}
|
|
4619
|
+
};
|
|
4620
|
+
}
|
|
3546
4621
|
function charmToolsServers(config, tools) {
|
|
3547
4622
|
const agent = config.agent;
|
|
3548
4623
|
if (agent.adapter !== "acp" || !agent.charmTools || !tools) return [];
|
|
@@ -3595,6 +4670,7 @@ async function startDaemon(config, options = {}) {
|
|
|
3595
4670
|
});
|
|
3596
4671
|
const sessions = /* @__PURE__ */ new Map();
|
|
3597
4672
|
const voice = createVoiceFromConfig(config);
|
|
4673
|
+
voice.warm?.().catch(() => void 0);
|
|
3598
4674
|
const log = options.log ?? ((entry) => {
|
|
3599
4675
|
if (!options.quiet) console.log(JSON.stringify({
|
|
3600
4676
|
time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -3612,7 +4688,17 @@ async function startDaemon(config, options = {}) {
|
|
|
3612
4688
|
pairing,
|
|
3613
4689
|
sessions: () => sessions.values(),
|
|
3614
4690
|
look,
|
|
3615
|
-
agentCanChangeLook
|
|
4691
|
+
agentCanChangeLook,
|
|
4692
|
+
identity: options.identity,
|
|
4693
|
+
starter: options.starter,
|
|
4694
|
+
voice: () => {
|
|
4695
|
+
const downloading = ["parakeet", "supertonic"].flatMap((id) => {
|
|
4696
|
+
const state = modelState(id);
|
|
4697
|
+
if (state.state === "failed") return [`${MODELS[id].label} failed`];
|
|
4698
|
+
return state.state === "downloading" && state.total ? [`${MODELS[id].label} downloading ${Math.floor(state.received * 100 / state.total)}%`] : [];
|
|
4699
|
+
});
|
|
4700
|
+
return [voice.name, ...downloading].join(" · ");
|
|
4701
|
+
}
|
|
3616
4702
|
});
|
|
3617
4703
|
const adminServer = await startAdminServer(config.adminSocket, admin);
|
|
3618
4704
|
let websocketUrl = config.publicUrl ?? "";
|
|
@@ -3699,7 +4785,12 @@ async function startDaemon(config, options = {}) {
|
|
|
3699
4785
|
const { port } = http.address();
|
|
3700
4786
|
const url = `ws://${config.listen.host.includes(":") ? `[${config.listen.host}]` : config.listen.host}:${port}/charm`;
|
|
3701
4787
|
websocketUrl = config.publicUrl ?? url;
|
|
3702
|
-
if (!options.quiet) console.log(
|
|
4788
|
+
if (!options.quiet) console.log([
|
|
4789
|
+
"charmd",
|
|
4790
|
+
options.identity,
|
|
4791
|
+
"listening on",
|
|
4792
|
+
url
|
|
4793
|
+
].filter(Boolean).join(" "));
|
|
3703
4794
|
return {
|
|
3704
4795
|
url,
|
|
3705
4796
|
admin,
|
|
@@ -3714,14 +4805,53 @@ async function startDaemon(config, options = {}) {
|
|
|
3714
4805
|
};
|
|
3715
4806
|
}
|
|
3716
4807
|
//#endregion
|
|
4808
|
+
//#region src/identity.ts
|
|
4809
|
+
function git(cwd, ...args) {
|
|
4810
|
+
return execFileSync("git", args, {
|
|
4811
|
+
cwd,
|
|
4812
|
+
encoding: "utf8",
|
|
4813
|
+
stdio: [
|
|
4814
|
+
"ignore",
|
|
4815
|
+
"pipe",
|
|
4816
|
+
"ignore"
|
|
4817
|
+
]
|
|
4818
|
+
}).trim();
|
|
4819
|
+
}
|
|
4820
|
+
function starterCommit(dir) {
|
|
4821
|
+
try {
|
|
4822
|
+
if (!existsSync(join(git(dir, "rev-parse", "--show-toplevel"), "opencharm.json"))) return void 0;
|
|
4823
|
+
return git(dir, "rev-parse", "--short", git(dir, "merge-base", "HEAD", "upstream/HEAD"));
|
|
4824
|
+
} catch {
|
|
4825
|
+
return;
|
|
4826
|
+
}
|
|
4827
|
+
}
|
|
4828
|
+
function formatIdentity(unit, version, commit) {
|
|
4829
|
+
return `${unit}@${version} (${commit})`;
|
|
4830
|
+
}
|
|
4831
|
+
let cached;
|
|
4832
|
+
function identity() {
|
|
4833
|
+
if (cached) return cached;
|
|
4834
|
+
const commit = "7d76bc9";
|
|
4835
|
+
cached = {
|
|
4836
|
+
version: `cli@${version}`,
|
|
4837
|
+
commit,
|
|
4838
|
+
text: formatIdentity("cli", version, commit)
|
|
4839
|
+
};
|
|
4840
|
+
return cached;
|
|
4841
|
+
}
|
|
4842
|
+
//#endregion
|
|
3717
4843
|
//#region src/commands/serve.ts
|
|
3718
4844
|
async function runServe(ctx, args) {
|
|
3719
4845
|
let config;
|
|
3720
4846
|
let daemon;
|
|
3721
4847
|
try {
|
|
3722
|
-
|
|
4848
|
+
const configPath = flagValue(args, "--config");
|
|
4849
|
+
config = loadConfig(configPath);
|
|
4850
|
+
const workspace = configPath ? dirname(resolve(configPath)) : existsSync("opencharm.json") ? process.cwd() : void 0;
|
|
3723
4851
|
daemon = await startDaemon(config, {
|
|
3724
4852
|
quiet: true,
|
|
4853
|
+
identity: identity().text,
|
|
4854
|
+
starter: workspace ? starterCommit(workspace) : void 0,
|
|
3725
4855
|
charmTools: {
|
|
3726
4856
|
command: process.execPath,
|
|
3727
4857
|
args: [
|
|
@@ -3740,7 +4870,7 @@ async function runServe(ctx, args) {
|
|
|
3740
4870
|
process.exitCode = 1;
|
|
3741
4871
|
return;
|
|
3742
4872
|
}
|
|
3743
|
-
ctx.out.write(`charmd (the charm daemon) is listening on ${daemon.url}\n`);
|
|
4873
|
+
ctx.out.write(`charmd (the charm daemon) ${identity().text} is listening on ${daemon.url}\n`);
|
|
3744
4874
|
ctx.out.write(`Admin socket: ${config.adminSocket}\n`);
|
|
3745
4875
|
ctx.out.write("Pair a charm: opencharm pair <code> Stop: Ctrl-C\n");
|
|
3746
4876
|
const stop = () => {
|
|
@@ -3750,6 +4880,45 @@ async function runServe(ctx, args) {
|
|
|
3750
4880
|
process.once("SIGTERM", stop);
|
|
3751
4881
|
}
|
|
3752
4882
|
//#endregion
|
|
4883
|
+
//#region src/commands/voice.ts
|
|
4884
|
+
const IDS = Object.keys(MODELS);
|
|
4885
|
+
function megabytes(bytes) {
|
|
4886
|
+
return `${Math.round(bytes / 1e6)} MB`;
|
|
4887
|
+
}
|
|
4888
|
+
async function runVoice(ctx, args, deps = {}) {
|
|
4889
|
+
const install = deps.install ?? installModel;
|
|
4890
|
+
const state = deps.state ?? modelState;
|
|
4891
|
+
const action = firstPositional(args) ?? "";
|
|
4892
|
+
if (action !== "" && action !== "install") {
|
|
4893
|
+
ctx.err.write(`Unknown voice command "${action}". Try: opencharm voice install\n`);
|
|
4894
|
+
process.exitCode = 1;
|
|
4895
|
+
return;
|
|
4896
|
+
}
|
|
4897
|
+
for (const id of IDS) {
|
|
4898
|
+
const model = MODELS[id];
|
|
4899
|
+
if (action === "") {
|
|
4900
|
+
const ready = state(id).state === "ready";
|
|
4901
|
+
ctx.out.write(`${model.label}: ${ready ? "installed" : `not downloaded yet (${megabytes(model.bytes)})`}\n`);
|
|
4902
|
+
continue;
|
|
4903
|
+
}
|
|
4904
|
+
let shown = -1;
|
|
4905
|
+
try {
|
|
4906
|
+
await install(id, { onProgress: (received, total) => {
|
|
4907
|
+
const percent = Math.floor(received * 100 / (total || model.bytes));
|
|
4908
|
+
if (percent >= shown + 10) {
|
|
4909
|
+
shown = percent - percent % 10;
|
|
4910
|
+
ctx.out.write(`${model.label}: ${shown}%\n`);
|
|
4911
|
+
}
|
|
4912
|
+
} });
|
|
4913
|
+
ctx.out.write(`${model.label}: installed\n`);
|
|
4914
|
+
} catch (error) {
|
|
4915
|
+
ctx.err.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
4916
|
+
process.exitCode = 1;
|
|
4917
|
+
return;
|
|
4918
|
+
}
|
|
4919
|
+
}
|
|
4920
|
+
}
|
|
4921
|
+
//#endregion
|
|
3753
4922
|
//#region src/commands/setup.ts
|
|
3754
4923
|
const USER = "charmd";
|
|
3755
4924
|
const STATE_DIR = "/var/lib/charmd";
|
|
@@ -3993,7 +5162,10 @@ function createSimServer(dir, charmdUrl) {
|
|
|
3993
5162
|
return;
|
|
3994
5163
|
}
|
|
3995
5164
|
if (path === "/config.json") {
|
|
3996
|
-
res.writeHead(200, { "content-type": TYPES[".json"] }).end(JSON.stringify({
|
|
5165
|
+
res.writeHead(200, { "content-type": TYPES[".json"] }).end(JSON.stringify({
|
|
5166
|
+
url: charmdUrl,
|
|
5167
|
+
version: identity().version
|
|
5168
|
+
}));
|
|
3997
5169
|
return;
|
|
3998
5170
|
}
|
|
3999
5171
|
const file = resolve(root, normalize(`.${path === "/" ? "/index.html" : path}`));
|
|
@@ -4086,7 +5258,7 @@ async function main(argv) {
|
|
|
4086
5258
|
const ctx = buildContext(argv);
|
|
4087
5259
|
const { command, rest, help, version } = parseArgs(argv);
|
|
4088
5260
|
if (version) {
|
|
4089
|
-
ctx.out.write(`${
|
|
5261
|
+
ctx.out.write(`${identity().text}\n`);
|
|
4090
5262
|
return;
|
|
4091
5263
|
}
|
|
4092
5264
|
if (help || command === "help") {
|
|
@@ -4121,6 +5293,9 @@ async function main(argv) {
|
|
|
4121
5293
|
case "serve":
|
|
4122
5294
|
await runServe(ctx, rest);
|
|
4123
5295
|
return;
|
|
5296
|
+
case "voice":
|
|
5297
|
+
await runVoice(ctx, rest);
|
|
5298
|
+
return;
|
|
4124
5299
|
case "mcp": {
|
|
4125
5300
|
const socket = flagValue(rest, "--socket") ?? loadConfig(flagValue(rest, "--config")).adminSocket;
|
|
4126
5301
|
await serveMcp(process.stdin, process.stdout, {
|