mercury-agent 0.4.7 → 0.4.9
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/container/Dockerfile.power +1 -1
- package/docs/auth/dashboard.md +28 -28
- package/docs/container-lifecycle.md +4 -4
- package/examples/extensions/voice-synth/index.ts +94 -94
- package/examples/extensions/voice-transcribe/scripts/transcribe.py +1 -1
- package/package.json +1 -1
- package/resources/templates/mercury.example.yaml +1 -1
- package/src/adapters/whatsapp.ts +635 -632
- package/src/agent/container-runner.ts +3 -1
- package/src/agent/model-capabilities.ts +231 -231
- package/src/bridges/discord.ts +178 -178
- package/src/bridges/slack.ts +179 -179
- package/src/bridges/teams.ts +162 -162
- package/src/bridges/telegram.ts +579 -579
- package/src/cli/mercury.ts +2551 -2536
- package/src/cli/whatsapp-auth.ts +263 -260
- package/src/config.ts +316 -316
- package/src/core/permissions.ts +196 -196
- package/src/core/router.ts +191 -191
- package/src/core/routes/chat.ts +175 -175
- package/src/core/routes/dashboard.ts +2491 -2491
- package/src/core/routes/messages.ts +37 -37
- package/src/core/routes/mutes.ts +95 -95
- package/src/core/routes/roles.ts +135 -135
- package/src/core/runtime.ts +1140 -1140
- package/src/core/task-scheduler.ts +139 -139
- package/src/extensions/catalog.ts +117 -117
- package/src/extensions/hooks.ts +161 -161
- package/src/extensions/installer.ts +306 -306
- package/src/extensions/loader.ts +271 -271
- package/src/extensions/permission-guard.ts +52 -52
- package/src/server.ts +391 -391
- package/src/storage/db.ts +1625 -1625
- package/src/storage/pi-auth.ts +95 -95
- package/src/tts/azure.ts +52 -52
- package/src/tts/synthesize.ts +133 -133
package/src/storage/pi-auth.ts
CHANGED
|
@@ -1,95 +1,95 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
getOAuthApiKey,
|
|
5
|
-
type OAuthCredentials,
|
|
6
|
-
type OAuthProviderId,
|
|
7
|
-
} from "@earendil-works/pi-ai/oauth";
|
|
8
|
-
import { logger } from "../logger.js";
|
|
9
|
-
|
|
10
|
-
type AuthEntry =
|
|
11
|
-
| ({ type: "oauth" } & OAuthCredentials)
|
|
12
|
-
| { type: "api_key"; key: string }
|
|
13
|
-
| Record<string, unknown>;
|
|
14
|
-
|
|
15
|
-
type AuthFile = Record<string, AuthEntry>;
|
|
16
|
-
|
|
17
|
-
function readAuthFile(authPath: string): AuthFile {
|
|
18
|
-
if (!fs.existsSync(authPath)) return {};
|
|
19
|
-
try {
|
|
20
|
-
return JSON.parse(fs.readFileSync(authPath, "utf8")) as AuthFile;
|
|
21
|
-
} catch (err) {
|
|
22
|
-
logger.warn(
|
|
23
|
-
`pi-auth: auth file at ${authPath} is malformed, ignoring`,
|
|
24
|
-
err instanceof Error ? err : undefined,
|
|
25
|
-
);
|
|
26
|
-
return {};
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function writeAuthFile(authPath: string, auth: AuthFile): void {
|
|
31
|
-
fs.mkdirSync(path.dirname(authPath), { recursive: true });
|
|
32
|
-
fs.writeFileSync(authPath, JSON.stringify(auth, null, 2), "utf8");
|
|
33
|
-
fs.chmodSync(authPath, 0o600);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export async function getApiKeyFromPiAuthFile(options: {
|
|
37
|
-
provider: string;
|
|
38
|
-
authPath: string;
|
|
39
|
-
}): Promise<string | undefined> {
|
|
40
|
-
if (
|
|
41
|
-
process.env.MERCURY_ANTHROPIC_API_KEY ||
|
|
42
|
-
process.env.MERCURY_ANTHROPIC_OAUTH_TOKEN
|
|
43
|
-
) {
|
|
44
|
-
return undefined;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (options.provider !== "anthropic") {
|
|
48
|
-
return undefined;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const authPath = options.authPath;
|
|
52
|
-
const auth = readAuthFile(authPath);
|
|
53
|
-
|
|
54
|
-
const entry = auth.anthropic;
|
|
55
|
-
if (!entry || typeof entry !== "object" || entry.type !== "oauth") {
|
|
56
|
-
return undefined;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const access = typeof entry.access === "string" ? entry.access : undefined;
|
|
60
|
-
const refresh = typeof entry.refresh === "string" ? entry.refresh : undefined;
|
|
61
|
-
const expires = typeof entry.expires === "number" ? entry.expires : undefined;
|
|
62
|
-
if (!access || !refresh || typeof expires !== "number") return undefined;
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
const result = await getOAuthApiKey("anthropic" satisfies OAuthProviderId, {
|
|
66
|
-
anthropic: {
|
|
67
|
-
access,
|
|
68
|
-
refresh,
|
|
69
|
-
expires,
|
|
70
|
-
},
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
if (!result) return undefined;
|
|
74
|
-
|
|
75
|
-
const nextAuth = {
|
|
76
|
-
...auth,
|
|
77
|
-
anthropic: {
|
|
78
|
-
type: "oauth" as const,
|
|
79
|
-
...result.newCredentials,
|
|
80
|
-
},
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
writeAuthFile(authPath, nextAuth);
|
|
84
|
-
logger.debug("Loaded anthropic oauth token from pi auth.json", {
|
|
85
|
-
authPath,
|
|
86
|
-
});
|
|
87
|
-
return result.apiKey;
|
|
88
|
-
} catch (error) {
|
|
89
|
-
logger.warn(
|
|
90
|
-
"Failed to load anthropic oauth token from pi auth.json",
|
|
91
|
-
error instanceof Error ? error : undefined,
|
|
92
|
-
);
|
|
93
|
-
return undefined;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
getOAuthApiKey,
|
|
5
|
+
type OAuthCredentials,
|
|
6
|
+
type OAuthProviderId,
|
|
7
|
+
} from "@earendil-works/pi-ai/oauth";
|
|
8
|
+
import { logger } from "../logger.js";
|
|
9
|
+
|
|
10
|
+
type AuthEntry =
|
|
11
|
+
| ({ type: "oauth" } & OAuthCredentials)
|
|
12
|
+
| { type: "api_key"; key: string }
|
|
13
|
+
| Record<string, unknown>;
|
|
14
|
+
|
|
15
|
+
type AuthFile = Record<string, AuthEntry>;
|
|
16
|
+
|
|
17
|
+
function readAuthFile(authPath: string): AuthFile {
|
|
18
|
+
if (!fs.existsSync(authPath)) return {};
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(authPath, "utf8")) as AuthFile;
|
|
21
|
+
} catch (err) {
|
|
22
|
+
logger.warn(
|
|
23
|
+
`pi-auth: auth file at ${authPath} is malformed, ignoring`,
|
|
24
|
+
err instanceof Error ? err : undefined,
|
|
25
|
+
);
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeAuthFile(authPath: string, auth: AuthFile): void {
|
|
31
|
+
fs.mkdirSync(path.dirname(authPath), { recursive: true });
|
|
32
|
+
fs.writeFileSync(authPath, JSON.stringify(auth, null, 2), "utf8");
|
|
33
|
+
fs.chmodSync(authPath, 0o600);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function getApiKeyFromPiAuthFile(options: {
|
|
37
|
+
provider: string;
|
|
38
|
+
authPath: string;
|
|
39
|
+
}): Promise<string | undefined> {
|
|
40
|
+
if (
|
|
41
|
+
process.env.MERCURY_ANTHROPIC_API_KEY ||
|
|
42
|
+
process.env.MERCURY_ANTHROPIC_OAUTH_TOKEN
|
|
43
|
+
) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (options.provider !== "anthropic") {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const authPath = options.authPath;
|
|
52
|
+
const auth = readAuthFile(authPath);
|
|
53
|
+
|
|
54
|
+
const entry = auth.anthropic;
|
|
55
|
+
if (!entry || typeof entry !== "object" || entry.type !== "oauth") {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const access = typeof entry.access === "string" ? entry.access : undefined;
|
|
60
|
+
const refresh = typeof entry.refresh === "string" ? entry.refresh : undefined;
|
|
61
|
+
const expires = typeof entry.expires === "number" ? entry.expires : undefined;
|
|
62
|
+
if (!access || !refresh || typeof expires !== "number") return undefined;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const result = await getOAuthApiKey("anthropic" satisfies OAuthProviderId, {
|
|
66
|
+
anthropic: {
|
|
67
|
+
access,
|
|
68
|
+
refresh,
|
|
69
|
+
expires,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (!result) return undefined;
|
|
74
|
+
|
|
75
|
+
const nextAuth = {
|
|
76
|
+
...auth,
|
|
77
|
+
anthropic: {
|
|
78
|
+
type: "oauth" as const,
|
|
79
|
+
...result.newCredentials,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
writeAuthFile(authPath, nextAuth);
|
|
84
|
+
logger.debug("Loaded anthropic oauth token from pi auth.json", {
|
|
85
|
+
authPath,
|
|
86
|
+
});
|
|
87
|
+
return result.apiKey;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
logger.warn(
|
|
90
|
+
"Failed to load anthropic oauth token from pi auth.json",
|
|
91
|
+
error instanceof Error ? error : undefined,
|
|
92
|
+
);
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/tts/azure.ts
CHANGED
|
@@ -1,52 +1,52 @@
|
|
|
1
|
-
import type { TtsLanguage } from "./language.js";
|
|
2
|
-
|
|
3
|
-
const AZURE_VOICES: Record<TtsLanguage, string> = {
|
|
4
|
-
"he-IL": "he-IL-HilaNeural",
|
|
5
|
-
"en-US": "en-US-JennyNeural",
|
|
6
|
-
};
|
|
7
|
-
|
|
8
|
-
/** MP3 128 kbps mono 16 kHz — widely compatible for chat attachments. */
|
|
9
|
-
const OUTPUT_FORMAT = "audio-16khz-128kbitrate-mono-mp3";
|
|
10
|
-
|
|
11
|
-
function escapeXml(text: string): string {
|
|
12
|
-
return text
|
|
13
|
-
.replace(/&/g, "&")
|
|
14
|
-
.replace(/</g, "<")
|
|
15
|
-
.replace(/>/g, ">")
|
|
16
|
-
.replace(/"/g, """)
|
|
17
|
-
.replace(/'/g, "'");
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export async function synthesizeAzure(opts: {
|
|
21
|
-
key: string;
|
|
22
|
-
region: string;
|
|
23
|
-
text: string;
|
|
24
|
-
language: TtsLanguage;
|
|
25
|
-
}): Promise<Buffer> {
|
|
26
|
-
const voiceName = AZURE_VOICES[opts.language];
|
|
27
|
-
const ssml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
28
|
-
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="${opts.language}">
|
|
29
|
-
<voice name="${voiceName}">${escapeXml(opts.text)}</voice>
|
|
30
|
-
</speak>`;
|
|
31
|
-
|
|
32
|
-
const url = `https://${opts.region.trim()}.tts.speech.microsoft.com/cognitiveservices/v1`;
|
|
33
|
-
const res = await fetch(url, {
|
|
34
|
-
method: "POST",
|
|
35
|
-
headers: {
|
|
36
|
-
"Content-Type": "application/ssml+xml",
|
|
37
|
-
"X-Microsoft-OutputFormat": OUTPUT_FORMAT,
|
|
38
|
-
"Ocp-Apim-Subscription-Key": opts.key.trim(),
|
|
39
|
-
"User-Agent": "mercury-agent-tts",
|
|
40
|
-
},
|
|
41
|
-
body: ssml,
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
if (!res.ok) {
|
|
45
|
-
const errText = await res.text().catch(() => "");
|
|
46
|
-
throw new Error(
|
|
47
|
-
`Azure TTS HTTP ${res.status}: ${errText.slice(0, 500) || res.statusText}`,
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return Buffer.from(await res.arrayBuffer());
|
|
52
|
-
}
|
|
1
|
+
import type { TtsLanguage } from "./language.js";
|
|
2
|
+
|
|
3
|
+
const AZURE_VOICES: Record<TtsLanguage, string> = {
|
|
4
|
+
"he-IL": "he-IL-HilaNeural",
|
|
5
|
+
"en-US": "en-US-JennyNeural",
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** MP3 128 kbps mono 16 kHz — widely compatible for chat attachments. */
|
|
9
|
+
const OUTPUT_FORMAT = "audio-16khz-128kbitrate-mono-mp3";
|
|
10
|
+
|
|
11
|
+
function escapeXml(text: string): string {
|
|
12
|
+
return text
|
|
13
|
+
.replace(/&/g, "&")
|
|
14
|
+
.replace(/</g, "<")
|
|
15
|
+
.replace(/>/g, ">")
|
|
16
|
+
.replace(/"/g, """)
|
|
17
|
+
.replace(/'/g, "'");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function synthesizeAzure(opts: {
|
|
21
|
+
key: string;
|
|
22
|
+
region: string;
|
|
23
|
+
text: string;
|
|
24
|
+
language: TtsLanguage;
|
|
25
|
+
}): Promise<Buffer> {
|
|
26
|
+
const voiceName = AZURE_VOICES[opts.language];
|
|
27
|
+
const ssml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
28
|
+
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="${opts.language}">
|
|
29
|
+
<voice name="${voiceName}">${escapeXml(opts.text)}</voice>
|
|
30
|
+
</speak>`;
|
|
31
|
+
|
|
32
|
+
const url = `https://${opts.region.trim()}.tts.speech.microsoft.com/cognitiveservices/v1`;
|
|
33
|
+
const res = await fetch(url, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: {
|
|
36
|
+
"Content-Type": "application/ssml+xml",
|
|
37
|
+
"X-Microsoft-OutputFormat": OUTPUT_FORMAT,
|
|
38
|
+
"Ocp-Apim-Subscription-Key": opts.key.trim(),
|
|
39
|
+
"User-Agent": "mercury-agent-tts",
|
|
40
|
+
},
|
|
41
|
+
body: ssml,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const errText = await res.text().catch(() => "");
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Azure TTS HTTP ${res.status}: ${errText.slice(0, 500) || res.statusText}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return Buffer.from(await res.arrayBuffer());
|
|
52
|
+
}
|
package/src/tts/synthesize.ts
CHANGED
|
@@ -1,133 +1,133 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { resolveProjectPath } from "../config.js";
|
|
3
|
-
import { synthesizeAzure } from "./azure.js";
|
|
4
|
-
import { synthesizeGoogle } from "./google.js";
|
|
5
|
-
import {
|
|
6
|
-
resolveTtsLanguageFromText,
|
|
7
|
-
type TtsLanguageInput,
|
|
8
|
-
} from "./language.js";
|
|
9
|
-
|
|
10
|
-
export class TtsConfigError extends Error {
|
|
11
|
-
readonly code: "not_configured";
|
|
12
|
-
|
|
13
|
-
constructor(message: string) {
|
|
14
|
-
super(message);
|
|
15
|
-
this.name = "TtsConfigError";
|
|
16
|
-
this.code = "not_configured";
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Subset of AppConfig used by TTS (extensions import via `mercury-agent/tts`). */
|
|
21
|
-
export interface MercuryTtsConfig {
|
|
22
|
-
ttsProvider: "google" | "azure" | "auto";
|
|
23
|
-
azureSpeechKey?: string;
|
|
24
|
-
azureSpeechRegion?: string;
|
|
25
|
-
googleApplicationCredentials?: string;
|
|
26
|
-
ttsMaxChars: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface TtsSynthesizeOptions {
|
|
30
|
-
text: string;
|
|
31
|
-
language?: TtsLanguageInput;
|
|
32
|
-
providerOverride?: "google" | "azure" | "auto";
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface TtsSynthesizeResult {
|
|
36
|
-
buffer: Buffer;
|
|
37
|
-
mimeType: string;
|
|
38
|
-
filename: string;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function hasGoogleCredentials(config: MercuryTtsConfig): boolean {
|
|
42
|
-
const p = config.googleApplicationCredentials?.trim();
|
|
43
|
-
if (!p) return false;
|
|
44
|
-
try {
|
|
45
|
-
return existsSync(resolveProjectPath(p));
|
|
46
|
-
} catch {
|
|
47
|
-
return false;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function hasAzure(config: MercuryTtsConfig): boolean {
|
|
52
|
-
return Boolean(
|
|
53
|
-
config.azureSpeechKey?.trim() && config.azureSpeechRegion?.trim(),
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function resolveEffectiveBackend(
|
|
58
|
-
config: MercuryTtsConfig,
|
|
59
|
-
override?: "google" | "azure" | "auto",
|
|
60
|
-
): "google" | "azure" {
|
|
61
|
-
const mode = override ?? config.ttsProvider;
|
|
62
|
-
|
|
63
|
-
if (mode === "google") {
|
|
64
|
-
if (!hasGoogleCredentials(config)) {
|
|
65
|
-
throw new TtsConfigError(
|
|
66
|
-
"Google TTS selected but MERCURY_GOOGLE_APPLICATION_CREDENTIALS / GOOGLE_APPLICATION_CREDENTIALS file is missing or not found",
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
return "google";
|
|
70
|
-
}
|
|
71
|
-
if (mode === "azure") {
|
|
72
|
-
if (!hasAzure(config)) {
|
|
73
|
-
throw new TtsConfigError(
|
|
74
|
-
"Azure TTS selected but MERCURY_AZURE_SPEECH_KEY or MERCURY_AZURE_SPEECH_REGION is missing",
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
return "azure";
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (hasGoogleCredentials(config)) return "google";
|
|
81
|
-
if (hasAzure(config)) return "azure";
|
|
82
|
-
|
|
83
|
-
throw new TtsConfigError(
|
|
84
|
-
"No TTS provider configured: set Google service account path or Azure key+region",
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Synthesize speech using host credentials. Intended for `/api/tts` and voice-synth extension.
|
|
90
|
-
*/
|
|
91
|
-
export async function synthesizeSpeech(
|
|
92
|
-
config: MercuryTtsConfig,
|
|
93
|
-
opts: TtsSynthesizeOptions,
|
|
94
|
-
): Promise<TtsSynthesizeResult> {
|
|
95
|
-
const trimmed = opts.text.trim();
|
|
96
|
-
if (!trimmed) {
|
|
97
|
-
throw new Error("TTS text is empty");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const max = Math.min(10_000, Math.max(500, config.ttsMaxChars));
|
|
101
|
-
if (trimmed.length > max) {
|
|
102
|
-
throw new Error(`TTS text exceeds max length (${max} characters)`);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const language = resolveTtsLanguageFromText(trimmed, opts.language);
|
|
106
|
-
const backend = resolveEffectiveBackend(config, opts.providerOverride);
|
|
107
|
-
|
|
108
|
-
let buffer: Buffer;
|
|
109
|
-
if (backend === "azure") {
|
|
110
|
-
buffer = await synthesizeAzure({
|
|
111
|
-
key: config.azureSpeechKey as string,
|
|
112
|
-
region: config.azureSpeechRegion as string,
|
|
113
|
-
text: trimmed,
|
|
114
|
-
language,
|
|
115
|
-
});
|
|
116
|
-
} else {
|
|
117
|
-
const credPath = resolveProjectPath(
|
|
118
|
-
config.googleApplicationCredentials?.trim() ?? "",
|
|
119
|
-
);
|
|
120
|
-
buffer = await synthesizeGoogle({
|
|
121
|
-
credentialsPath: credPath,
|
|
122
|
-
text: trimmed,
|
|
123
|
-
language,
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return {
|
|
128
|
-
buffer,
|
|
129
|
-
mimeType: "audio/mpeg",
|
|
130
|
-
filename:
|
|
131
|
-
language === "he-IL" ? "mercury-tts-he.mp3" : "mercury-tts-en.mp3",
|
|
132
|
-
};
|
|
133
|
-
}
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolveProjectPath } from "../config.js";
|
|
3
|
+
import { synthesizeAzure } from "./azure.js";
|
|
4
|
+
import { synthesizeGoogle } from "./google.js";
|
|
5
|
+
import {
|
|
6
|
+
resolveTtsLanguageFromText,
|
|
7
|
+
type TtsLanguageInput,
|
|
8
|
+
} from "./language.js";
|
|
9
|
+
|
|
10
|
+
export class TtsConfigError extends Error {
|
|
11
|
+
readonly code: "not_configured";
|
|
12
|
+
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "TtsConfigError";
|
|
16
|
+
this.code = "not_configured";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Subset of AppConfig used by TTS (extensions import via `mercury-agent/tts`). */
|
|
21
|
+
export interface MercuryTtsConfig {
|
|
22
|
+
ttsProvider: "google" | "azure" | "auto";
|
|
23
|
+
azureSpeechKey?: string;
|
|
24
|
+
azureSpeechRegion?: string;
|
|
25
|
+
googleApplicationCredentials?: string;
|
|
26
|
+
ttsMaxChars: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TtsSynthesizeOptions {
|
|
30
|
+
text: string;
|
|
31
|
+
language?: TtsLanguageInput;
|
|
32
|
+
providerOverride?: "google" | "azure" | "auto";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TtsSynthesizeResult {
|
|
36
|
+
buffer: Buffer;
|
|
37
|
+
mimeType: string;
|
|
38
|
+
filename: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hasGoogleCredentials(config: MercuryTtsConfig): boolean {
|
|
42
|
+
const p = config.googleApplicationCredentials?.trim();
|
|
43
|
+
if (!p) return false;
|
|
44
|
+
try {
|
|
45
|
+
return existsSync(resolveProjectPath(p));
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hasAzure(config: MercuryTtsConfig): boolean {
|
|
52
|
+
return Boolean(
|
|
53
|
+
config.azureSpeechKey?.trim() && config.azureSpeechRegion?.trim(),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveEffectiveBackend(
|
|
58
|
+
config: MercuryTtsConfig,
|
|
59
|
+
override?: "google" | "azure" | "auto",
|
|
60
|
+
): "google" | "azure" {
|
|
61
|
+
const mode = override ?? config.ttsProvider;
|
|
62
|
+
|
|
63
|
+
if (mode === "google") {
|
|
64
|
+
if (!hasGoogleCredentials(config)) {
|
|
65
|
+
throw new TtsConfigError(
|
|
66
|
+
"Google TTS selected but MERCURY_GOOGLE_APPLICATION_CREDENTIALS / GOOGLE_APPLICATION_CREDENTIALS file is missing or not found",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return "google";
|
|
70
|
+
}
|
|
71
|
+
if (mode === "azure") {
|
|
72
|
+
if (!hasAzure(config)) {
|
|
73
|
+
throw new TtsConfigError(
|
|
74
|
+
"Azure TTS selected but MERCURY_AZURE_SPEECH_KEY or MERCURY_AZURE_SPEECH_REGION is missing",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return "azure";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (hasGoogleCredentials(config)) return "google";
|
|
81
|
+
if (hasAzure(config)) return "azure";
|
|
82
|
+
|
|
83
|
+
throw new TtsConfigError(
|
|
84
|
+
"No TTS provider configured: set Google service account path or Azure key+region",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Synthesize speech using host credentials. Intended for `/api/tts` and voice-synth extension.
|
|
90
|
+
*/
|
|
91
|
+
export async function synthesizeSpeech(
|
|
92
|
+
config: MercuryTtsConfig,
|
|
93
|
+
opts: TtsSynthesizeOptions,
|
|
94
|
+
): Promise<TtsSynthesizeResult> {
|
|
95
|
+
const trimmed = opts.text.trim();
|
|
96
|
+
if (!trimmed) {
|
|
97
|
+
throw new Error("TTS text is empty");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const max = Math.min(10_000, Math.max(500, config.ttsMaxChars));
|
|
101
|
+
if (trimmed.length > max) {
|
|
102
|
+
throw new Error(`TTS text exceeds max length (${max} characters)`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const language = resolveTtsLanguageFromText(trimmed, opts.language);
|
|
106
|
+
const backend = resolveEffectiveBackend(config, opts.providerOverride);
|
|
107
|
+
|
|
108
|
+
let buffer: Buffer;
|
|
109
|
+
if (backend === "azure") {
|
|
110
|
+
buffer = await synthesizeAzure({
|
|
111
|
+
key: config.azureSpeechKey as string,
|
|
112
|
+
region: config.azureSpeechRegion as string,
|
|
113
|
+
text: trimmed,
|
|
114
|
+
language,
|
|
115
|
+
});
|
|
116
|
+
} else {
|
|
117
|
+
const credPath = resolveProjectPath(
|
|
118
|
+
config.googleApplicationCredentials?.trim() ?? "",
|
|
119
|
+
);
|
|
120
|
+
buffer = await synthesizeGoogle({
|
|
121
|
+
credentialsPath: credPath,
|
|
122
|
+
text: trimmed,
|
|
123
|
+
language,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
buffer,
|
|
129
|
+
mimeType: "audio/mpeg",
|
|
130
|
+
filename:
|
|
131
|
+
language === "he-IL" ? "mercury-tts-he.mp3" : "mercury-tts-en.mp3",
|
|
132
|
+
};
|
|
133
|
+
}
|